From 757ee5e60b5873af8c604f260f71675ad77175de Mon Sep 17 00:00:00 2001 From: Guillaume Hivert Date: Sat, 11 Oct 2025 12:18:31 +0200 Subject: [PATCH] Add new Lustre's frontend --- .github/workflows/frontend.yml | 59 ++ .gitignore | 5 + frontend/README.md | 37 ++ frontend/assets/favicon/apple-touch-icon.png | Bin 0 -> 5726 bytes frontend/assets/favicon/favicon-96x96.png | Bin 0 -> 4013 bytes frontend/assets/favicon/favicon.ico | Bin 0 -> 15086 bytes frontend/assets/favicon/favicon.svg | 17 + frontend/assets/favicon/site.webmanifest | 21 + .../favicon/web-app-manifest-192x192.png | Bin 0 -> 6289 bytes .../favicon/web-app-manifest-512x512.png | Bin 0 -> 20861 bytes frontend/assets/images/hexdocs-logo.svg | 22 + frontend/gleam.toml | 57 ++ frontend/manifest.toml | 61 ++ frontend/src/browser/document.ffi.mjs | 4 + frontend/src/browser/document.gleam | 3 + frontend/src/browser/window.gleam | 4 + frontend/src/browser/window/location.ffi.mjs | 16 + frontend/src/browser/window/location.gleam | 28 + frontend/src/hexdocs.css | 102 +++ frontend/src/hexdocs.ffi.mjs | 35 ++ frontend/src/hexdocs.gleam | 412 ++++++++++++ .../src/hexdocs/components/attributes.gleam | 15 + frontend/src/hexdocs/components/iframe.gleam | 102 +++ frontend/src/hexdocs/data/model.gleam | 588 ++++++++++++++++++ .../src/hexdocs/data/model/autocomplete.gleam | 69 ++ frontend/src/hexdocs/data/model/route.gleam | 77 +++ frontend/src/hexdocs/data/model/version.gleam | 30 + frontend/src/hexdocs/data/msg.gleam | 59 ++ frontend/src/hexdocs/effects.gleam | 48 ++ frontend/src/hexdocs/endpoints.gleam | 22 + frontend/src/hexdocs/environment.gleam | 23 + frontend/src/hexdocs/loss.gleam | 11 + frontend/src/hexdocs/services/hex.gleam | 54 ++ frontend/src/hexdocs/services/hexdocs.gleam | 123 ++++ frontend/src/hexdocs/setup.ffi.mjs | 28 + frontend/src/hexdocs/setup.gleam | 49 ++ frontend/src/hexdocs/view/home.gleam | 315 ++++++++++ frontend/src/hexdocs/view/home/footer.gleam | 121 ++++ frontend/src/hexdocs/view/search.gleam | 586 +++++++++++++++++ frontend/test/hexdocs_test.gleam | 12 + 40 files changed, 3215 insertions(+) create mode 100644 .github/workflows/frontend.yml create mode 100644 frontend/README.md create mode 100644 frontend/assets/favicon/apple-touch-icon.png create mode 100644 frontend/assets/favicon/favicon-96x96.png create mode 100644 frontend/assets/favicon/favicon.ico create mode 100644 frontend/assets/favicon/favicon.svg create mode 100644 frontend/assets/favicon/site.webmanifest create mode 100644 frontend/assets/favicon/web-app-manifest-192x192.png create mode 100644 frontend/assets/favicon/web-app-manifest-512x512.png create mode 100644 frontend/assets/images/hexdocs-logo.svg create mode 100644 frontend/gleam.toml create mode 100644 frontend/manifest.toml create mode 100644 frontend/src/browser/document.ffi.mjs create mode 100644 frontend/src/browser/document.gleam create mode 100644 frontend/src/browser/window.gleam create mode 100644 frontend/src/browser/window/location.ffi.mjs create mode 100644 frontend/src/browser/window/location.gleam create mode 100644 frontend/src/hexdocs.css create mode 100644 frontend/src/hexdocs.ffi.mjs create mode 100644 frontend/src/hexdocs.gleam create mode 100644 frontend/src/hexdocs/components/attributes.gleam create mode 100644 frontend/src/hexdocs/components/iframe.gleam create mode 100644 frontend/src/hexdocs/data/model.gleam create mode 100644 frontend/src/hexdocs/data/model/autocomplete.gleam create mode 100644 frontend/src/hexdocs/data/model/route.gleam create mode 100644 frontend/src/hexdocs/data/model/version.gleam create mode 100644 frontend/src/hexdocs/data/msg.gleam create mode 100644 frontend/src/hexdocs/effects.gleam create mode 100644 frontend/src/hexdocs/endpoints.gleam create mode 100644 frontend/src/hexdocs/environment.gleam create mode 100644 frontend/src/hexdocs/loss.gleam create mode 100644 frontend/src/hexdocs/services/hex.gleam create mode 100644 frontend/src/hexdocs/services/hexdocs.gleam create mode 100644 frontend/src/hexdocs/setup.ffi.mjs create mode 100644 frontend/src/hexdocs/setup.gleam create mode 100644 frontend/src/hexdocs/view/home.gleam create mode 100644 frontend/src/hexdocs/view/home/footer.gleam create mode 100644 frontend/src/hexdocs/view/search.gleam create mode 100644 frontend/test/hexdocs_test.gleam diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml new file mode 100644 index 0000000..575d3e9 --- /dev/null +++ b/.github/workflows/frontend.yml @@ -0,0 +1,59 @@ +name: Lustre Frontend CI + +on: [push, pull_request] + +defaults: + run: + working-directory: ./frontend + +jobs: + test: + name: Test Lustre Frontend + runs-on: ubuntu-24.04 + + steps: + - uses: actions/checkout@v4 + + - name: Install OTP and Gleam + uses: erlef/setup-beam@v1 + with: + otp-version: 27.2 + gleam-version: 1.12.0 + rebar3-version: 3 + + - name: Install dependencies + run: gleam deps download + + - name: Run tests + run: gleam test + + - name: Check Gleam formatted + run: gleam format --check src test + + build: + name: Build Lustre Frontend + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install OTP and Gleam + uses: erlef/setup-beam@v1 + with: + otp-version: 27.2 + gleam-version: 1.12.0 + rebar3-version: 3 + + - name: Install dependencies + run: gleam deps download + + - name: Build Hexdocs website + run: gleam run -m lustre/dev build + + # - name: Copy files + # run: | + # mkdir _site + # cp -r dist _site + # cp -r assets _site + + # - name: Upload artifacts + # uses: actions/upload-pages-artifact@v3 diff --git a/.gitignore b/.gitignore index 120c01b..3ca4a17 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,8 @@ erl_crash.dump *.ez hexdocs-*.tar + +# Lustre need some additional ignores. +/frontend/.lustre +/frontend/build +/frontend/dist diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..11cab38 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,37 @@ +# Hexdocs Frontend + +Hexdocs Frontend is using [Lustre](https://lustre.build), a single-page +application running in client, interacting with the backend through asynchronous +HTTP requests. + +Running a Lustre application can easily be achieved using [Gleam](https://gleam.run/) +and the [Lustre Dev Tools](https://hexdocs.pm/lustre_dev_tools/). +Lustre Dev Tools is a companion package to Lustre, in charge of compiling, +bundling, and running the application in browser. + +## Launching the dev server + +With Gleam installed on your path, you can directly start the development server +using Lustre Dev Tools. + +```sh +gleam run -m lustre/dev start +``` + +The application will be running at `http://localhost:1234`. + +## Building the application + +Building the application can be done with the Lustre Dev Tools too. + +```sh +gleam run -m lustre/dev build +``` + +## Quick reminder of the structure + +- All source files reside in `src` folder. +- There's no `index.html` in the sources, as the file is automatically + generated by Lustre Dev Tools with the configuration written in `gleam.toml`. +- `hexdocs.css` is the entrypoint for CSS for the application. Tailwind is + setup in that file. diff --git a/frontend/assets/favicon/apple-touch-icon.png b/frontend/assets/favicon/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..4826a084db4346df8457606f69e6cad080ec5a30 GIT binary patch literal 5726 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 T;mEMC00000NkvXXu0mjfGqj1W literal 0 HcmV?d00001 diff --git a/frontend/assets/favicon/favicon.ico b/frontend/assets/favicon/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..f6ef04018a1fa51586e8727b05c973fea4597c8a GIT binary patch literal 15086 zcmeHOdr(x@89xkZXww)UEYoJhKx&<8)G?zqrpAaG1!SXbn(q2+wYG_EoUu-vN!m`8 zM^vhD#-L%Bjat)Js#N+2ooSkMWO*gHcSU?c)hNgUsU(PWm=Ihz{hehG9`4?|_nynz z{==O)eCPF@-|zeGxp&X~&Vi6P5>Fm|lt8AEqmu}Ej1ZEV8ZM6^iN-qB+DrP*tt`l=#pK?*Vdvt-bUOHd zX3w~N60csuoKcvK4L|w*o!=_{n`IH%e6HElBsu^h^5X4+_rpK{;;{9uZ_ZMoPIN$#XjKvb2S$$)%>rn-4CXW;ly|ptE0#ht)dvtzw0LOJU+G zi*o7Br}8M3NzjQY|KHTD{i4Ueg36{MzcU+Lie}09KOUcBgDm$@|61ql z`FV5-Z0MJ@0Xi{xem(xD@ALSbt@8Yxf8+CYVIFl`;NSh^j^V#Q!9Cvs_uhl29AD-9 z=jGF}@Mnhw8;14!1>O_EzgL>S;@rczbGayoZj$BC`Y+11zJ4fDoTakdp>yL<{ql&| zildmu;~rd;wE;SHgvzhqJU-`QS?;XA+wWM`zXJcda}qZE)QGdX9lvScHp73;>(t9R$;dgr z`g(KxolPG9^On}S@NH!@8y4l!_6aq#ONO`DcZagYcpmv2%hkR$TMn;{f_gU0_yesJ z`N-y{`af>Nd$dRIK45+Mu34^n{jk=2O^bipPI_LJuU7vT{kzMw_`|NCxW>`rXH-9T zHywp}bVF^!#P?`}79ZKVRR5>lZojiV!Un|=ll=>3ALjWFMgC9hqZP6|qxD~_9IX)f z>#cE&e68D#^$qSp9kT(l>!E5qw-WDrqrS;XU;hE$nk(D}tZ|;x6_C@Zh{ce8G?N=5~ba%f4eLWgPH_$&ep%?#Ph8ykUPehW)7aZ@A3PX{919KpHux z1t(azuy2+A*3xRP>HSO9`J=Nd_P>o7_SY;x6JSq6JhyjQ`t1u=@UbV^)4X?b05Ryx z)I+4#`M@scvE{v)7v%S$H~gZE$BwbD_@aytuz&L{jvbPc=W?)L`%&|O@!f;?E?ZY? z!TqMbGgB*y6)sYb6=&D(8Ju$>u9y$Fk{ntbfIU+Wlh>oqCCtrnTKcT6wJ>ouZ0r;9 zBJwWVb+%8f9s^_7a2!3!_R-aP>N4;TL}04+zR_pMxews2)WhWU;8&`~(kin(T#Vll zjRV@d`MC{@p8%h>avUuQ>|XR4jH_$mighh6+vaHatPSs^-!mPIyIl)YtONdDaeSjZ z&Wgsj6xwv31J13@Gj0yX{zSym%4cQKFB@U1+ws2H#BuZ_?!#~K-Pa<1!)oQN?5E({ z`vv~przeXPMN$Kf5gL9(*e|=;88ucdc_qIHuSt;;HlC*o*H>)AP^c zcWmYudJ-|9L;Rj0Cb(LSsV;+qsprpicJ)};-l2y(P#+n0MJ%I6jj1lPeF)B3PZs%{ z2clzRKUQ(Rq}GQIW~uSiW%!_h#f{Xuyo`N_Ct!PDG<>vQW&0dlN0b=fnQN&SBC!@-f|=Lt^O8t$H|m z^>`kqfhRx4p%k1$IJkwh%5Ym7wuW; z!E1hdNl-lozJY>GyJviewbfazcrn&{`}i6!H{)`-TL0X-9fRk$)n_`fR-Fv}!T&qR zNbl=--~RS3K1S%fS2#Zn4mVpo_uIG;(5>RwOrl)>_~Y~oyq$d`L#K1ddeLP&!rFoT zJyDnCQ8$`WYoHOEY z5Z0OR;rDAjf1h%R^-KhRwnriAhVtR_0l=*0x=f;c@sVsgxRyA0cIB6F?xs_ve~|N9 zoJXfaXK>#U@7R_JCut9Ve@#=`n!!ED^^w2n@vj=Ze{39a-*3uasgLV&`7>pEIoD@v zaE{Goc;BsU-fnu5>xgoa($xXnTg-=A^Ouc--$(nyUizV^C+0W$-6uS?D + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/assets/favicon/site.webmanifest b/frontend/assets/favicon/site.webmanifest new file mode 100644 index 0000000..93ecebf --- /dev/null +++ b/frontend/assets/favicon/site.webmanifest @@ -0,0 +1,21 @@ +{ + "name": "Hexdocs", + "short_name": "Hexdocs", + "icons": [ + { + "src": "/favicon/web-app-manifest-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "/favicon/web-app-manifest-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" +} diff --git a/frontend/assets/favicon/web-app-manifest-192x192.png b/frontend/assets/favicon/web-app-manifest-192x192.png new file mode 100644 index 0000000000000000000000000000000000000000..5d384fbc4e395a84c85fac9a029e6def0d56d22e GIT binary patch literal 6289 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 mqls%YJB=0~R4Xzc{AbLZy2jhHbnQU~An + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/gleam.toml b/frontend/gleam.toml new file mode 100644 index 0000000..57f8491 --- /dev/null +++ b/frontend/gleam.toml @@ -0,0 +1,57 @@ +name = "hexdocs" +version = "1.0.0" +target = "javascript" + +[dependencies] +gleam_fetch = ">= 1.2.0 and < 2.0.0" +gleam_hexpm = ">= 3.0.0 and < 4.0.0" +gleam_http = ">= 4.0.0 and < 5.0.0" +gleam_javascript = ">= 1.0.0 and < 2.0.0" +gleam_regexp = ">= 1.1.0 and < 2.0.0" +gleam_stdlib = ">= 0.54.0 and < 1.0.0" +grille_pain = ">= 1.1.0 and < 2.0.0" +lustre = ">= 5.0.0 and < 6.0.0" +modem = ">= 2.0.0 and < 3.0.0" + +[dev-dependencies] +gleeunit = ">= 1.0.0 and < 2.0.0" +lustre_dev_tools = ">= 2.0.0 and < 3.0.0" + +[tools.lustre.dev] +host = "0.0.0.0" + +[tools.lustre.build] +minify = true + +[tools.lustre.html] +lang = "en" +title = "Hexdocs" +meta = [{ name = "apple-mobile-web-app-title", content = "Hexdocs" }] +stylesheets = [ + { href = "https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700&display=swap" }, + { href = "https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css" }, +] + +[[tools.lustre.html.links]] +rel = "icon" +href = "/favicon/favicon-96x96.png" +sizes="96x96" +type = "image/png" + +[[tools.lustre.html.links]] +rel = "icon" +type = "image/svg+xml" +href = "/favicon/favicon.svg" + +[[tools.lustre.html.links]] +rel = "shortcut icon" +href = "/favicon/favicon.ico" + +[[tools.lustre.html.links]] +rel = "apple-touch-icon" +sizes = "180x180" +href = "/favicon/apple-touch-icon.png" + +[[tools.lustre.html.links]] +rel = "manifest" +href = "/favicon/site.webmanifest" diff --git a/frontend/manifest.toml b/frontend/manifest.toml new file mode 100644 index 0000000..474f886 --- /dev/null +++ b/frontend/manifest.toml @@ -0,0 +1,61 @@ +# This file was generated by Gleam +# You typically do not need to edit this file + +packages = [ + { name = "argv", version = "1.0.2", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "BA1FF0929525DEBA1CE67256E5ADF77A7CDDFE729E3E3F57A5BDCAA031DED09D" }, + { name = "booklet", version = "1.0.2", build_tools = ["gleam"], requirements = [], otp_app = "booklet", source = "hex", outer_checksum = "279247A5FD6388B34058A6109E99D7E7C7A4CA3EC8A13912536A05E98BC2D275" }, + { name = "directories", version = "1.2.0", build_tools = ["gleam"], requirements = ["envoy", "gleam_stdlib", "platform", "simplifile"], otp_app = "directories", source = "hex", outer_checksum = "D13090CFCDF6759B87217E8DDD73A75903A700148A82C1D33799F333E249BF9E" }, + { name = "envoy", version = "1.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "95FD059345AA982E89A0B6E2A3BF1CF43E17A7048DCD85B5B65D3B9E4E39D359" }, + { name = "exception", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "329D269D5C2A314F7364BD2711372B6F2C58FA6F39981572E5CA68624D291F8C" }, + { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, + { name = "gleam_community_ansi", version = "1.4.3", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_regexp", "gleam_stdlib"], otp_app = "gleam_community_ansi", source = "hex", outer_checksum = "8A62AE9CC6EA65BEA630D95016D6C07E4F9973565FA3D0DE68DC4200D8E0DD27" }, + { name = "gleam_community_colour", version = "2.0.2", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "E34DD2C896AC3792151EDA939DA435FF3B69922F33415ED3C4406C932FBE9634" }, + { name = "gleam_crypto", version = "1.5.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "50774BAFFF1144E7872814C566C5D653D83A3EBF23ACC3156B757A1B6819086E" }, + { name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" }, + { name = "gleam_fetch", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_http", "gleam_javascript", "gleam_stdlib"], otp_app = "gleam_fetch", source = "hex", outer_checksum = "2CBF9F2E1C71AEBBFB13A9D5720CD8DB4263EB02FE60C5A7A1C6E17B0151C20C" }, + { name = "gleam_hexpm", version = "3.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_time"], otp_app = "gleam_hexpm", source = "hex", outer_checksum = "AAA7813FFD1F32B12C9C0BA5C0BA451324DAC16B7D76E0540EFA526B5208CDAB" }, + { name = "gleam_http", version = "4.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "82EA6A717C842456188C190AFB372665EA56CE13D8559BF3B1DD9E40F619EE0C" }, + { name = "gleam_httpc", version = "5.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gleam_httpc", source = "hex", outer_checksum = "C545172618D07811494E97AAA4A0FB34DA6F6D0061FDC8041C2F8E3BE2B2E48F" }, + { name = "gleam_javascript", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_javascript", source = "hex", outer_checksum = "EF6C77A506F026C6FB37941889477CD5E4234FCD4337FF0E9384E297CB8F97EB" }, + { name = "gleam_json", version = "3.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "874FA3C3BB6E22DD2BB111966BD40B3759E9094E05257899A7C08F5DE77EC049" }, + { name = "gleam_otp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" }, + { name = "gleam_regexp", version = "1.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_regexp", source = "hex", outer_checksum = "9C215C6CA84A5B35BB934A9B61A9A306EC743153BE2B0425A0D032E477B062A9" }, + { name = "gleam_stdlib", version = "0.65.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "7C69C71D8C493AE11A5184828A77110EB05A7786EBF8B25B36A72F879C3EE107" }, + { name = "gleam_time", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "DCDDC040CE97DA3D2A925CDBBA08D8A78681139745754A83998641C8A3F6587E" }, + { name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" }, + { name = "gleeunit", version = "1.6.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "FDC68A8C492B1E9B429249062CD9BAC9B5538C6FBF584817205D0998C42E1DAC" }, + { name = "glint", version = "1.2.1", build_tools = ["gleam"], requirements = ["gleam_community_ansi", "gleam_community_colour", "gleam_stdlib", "snag"], otp_app = "glint", source = "hex", outer_checksum = "2214C7CEFDE457CEE62140C3D4899B964E05236DA74E4243DFADF4AF29C382BB" }, + { name = "glisten", version = "8.0.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging", "telemetry"], otp_app = "glisten", source = "hex", outer_checksum = "534BB27C71FB9E506345A767C0D76B17A9E9199934340C975DC003C710E3692D" }, + { name = "gramps", version = "6.0.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "8B7195978FBFD30B43DF791A8A272041B81E45D245314D7A41FC57237AA882A0" }, + { name = "grille_pain", version = "1.1.4", build_tools = ["gleam"], requirements = ["gleam_stdlib", "lustre"], otp_app = "grille_pain", source = "hex", outer_checksum = "6707CCB5E0FBDA94FEECA8BCB8437877A64BE09ADB2453E9EAE6C27BBCFFE641" }, + { name = "group_registry", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib"], otp_app = "group_registry", source = "hex", outer_checksum = "BC798A53D6F2406DB94E27CB45C57052CB56B32ACF7CC16EA20F6BAEC7E36B90" }, + { name = "houdini", version = "1.2.0", build_tools = ["gleam"], requirements = [], otp_app = "houdini", source = "hex", outer_checksum = "5DB1053F1AF828049C2B206D4403C18970ABEF5C18671CA3C2D2ED0DD64F6385" }, + { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" }, + { name = "justin", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "justin", source = "hex", outer_checksum = "7FA0C6DB78640C6DC5FBFD59BF3456009F3F8B485BF6825E97E1EB44E9A1E2CD" }, + { name = "logging", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "1098FBF10B54B44C2C7FDF0B01C1253CAFACDACABEFB4B0D027803246753E06D" }, + { name = "lustre", version = "5.3.5", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib", "houdini"], otp_app = "lustre", source = "hex", outer_checksum = "5CBB5DD2849D8316A2101792FC35AEB58CE4B151451044A9C2A2A70A2F7FCEB8" }, + { name = "lustre_dev_tools", version = "2.1.1", build_tools = ["gleam"], requirements = ["argv", "booklet", "filepath", "gleam_community_ansi", "gleam_crypto", "gleam_erlang", "gleam_http", "gleam_httpc", "gleam_json", "gleam_otp", "gleam_regexp", "gleam_stdlib", "glint", "group_registry", "justin", "lustre", "mist", "polly", "simplifile", "tom", "wisp"], otp_app = "lustre_dev_tools", source = "hex", outer_checksum = "935E089A90181B6C964FD3E454420FA810865054F7C182685DFFC3E5525AC6CD" }, + { name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" }, + { name = "mist", version = "5.0.3", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "7C4BE717A81305323C47C8A591E6B9BA4AC7F56354BF70B4D3DF08CC01192668" }, + { name = "modem", version = "2.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "lustre"], otp_app = "modem", source = "hex", outer_checksum = "444332FF806610B955D57389B3643245BBEC61A705A61B4FF7E67E3AF148F339" }, + { name = "platform", version = "1.0.0", build_tools = ["gleam"], requirements = [], otp_app = "platform", source = "hex", outer_checksum = "8339420A95AD89AAC0F82F4C3DB8DD401041742D6C3F46132A8739F6AEB75391" }, + { name = "polly", version = "2.1.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib", "simplifile"], otp_app = "polly", source = "hex", outer_checksum = "1BA4D0ACE9BCF52AEA6AD9DE020FD8220CCA399A379E50A1775FC5C1204FCF56" }, + { name = "simplifile", version = "2.3.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "0A868DAC6063D9E983477981839810DC2E553285AB4588B87E3E9C96A7FB4CB4" }, + { name = "snag", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "snag", source = "hex", outer_checksum = "7E9F06390040EB5FAB392CE642771484136F2EC103A92AE11BA898C8167E6E17" }, + { name = "telemetry", version = "1.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "7015FC8919DBE63764F4B4B87A95B7C0996BD539E0D499BE6EC9D7F3875B79E6" }, + { name = "tom", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_time"], otp_app = "tom", source = "hex", outer_checksum = "74D0C5A3761F7A7D06994755D4D5AD854122EF8E9F9F76A3E7547606D8C77091" }, + { name = "wisp", version = "2.1.0", build_tools = ["gleam"], requirements = ["directories", "exception", "filepath", "gleam_crypto", "gleam_erlang", "gleam_http", "gleam_json", "gleam_stdlib", "houdini", "logging", "marceau", "mist", "simplifile"], otp_app = "wisp", source = "hex", outer_checksum = "362BDDD11BF48EB38CDE51A73BC7D1B89581B395CA998E3F23F11EC026151C54" }, +] + +[requirements] +gleam_fetch = { version = ">= 1.2.0 and < 2.0.0" } +gleam_hexpm = { version = ">= 3.0.0 and < 4.0.0" } +gleam_http = { version = ">= 4.0.0 and < 5.0.0" } +gleam_javascript = { version = ">= 1.0.0 and < 2.0.0" } +gleam_regexp = { version = ">= 1.1.0 and < 2.0.0" } +gleam_stdlib = { version = ">= 0.54.0 and < 1.0.0" } +gleeunit = { version = ">= 1.0.0 and < 2.0.0" } +grille_pain = { version = ">= 1.1.0 and < 2.0.0" } +lustre = { version = ">= 5.0.0 and < 6.0.0" } +lustre_dev_tools = { version = ">= 2.0.0 and < 3.0.0" } +modem = { version = ">= 2.0.0 and < 3.0.0" } diff --git a/frontend/src/browser/document.ffi.mjs b/frontend/src/browser/document.ffi.mjs new file mode 100644 index 0000000..f6ea517 --- /dev/null +++ b/frontend/src/browser/document.ffi.mjs @@ -0,0 +1,4 @@ +export function addDocumentListener(callback) { + document.addEventListener("click", callback, { once: true }) + return () => document.removeEventListener("click", callback) +} diff --git a/frontend/src/browser/document.gleam b/frontend/src/browser/document.gleam new file mode 100644 index 0000000..41949e0 --- /dev/null +++ b/frontend/src/browser/document.gleam @@ -0,0 +1,3 @@ +/// Subscribes to a click on the DOM, and returns an unsubscriber. +@external(javascript, "./document.ffi.mjs", "addDocumentListener") +pub fn add_listener(callback: fn() -> Nil) -> fn() -> Nil diff --git a/frontend/src/browser/window.gleam b/frontend/src/browser/window.gleam new file mode 100644 index 0000000..addb107 --- /dev/null +++ b/frontend/src/browser/window.gleam @@ -0,0 +1,4 @@ +import browser/window/location + +@external(javascript, "./window/location.ffi.mjs", "location") +pub fn location() -> Result(location.Location, Nil) diff --git a/frontend/src/browser/window/location.ffi.mjs b/frontend/src/browser/window/location.ffi.mjs new file mode 100644 index 0000000..1d88566 --- /dev/null +++ b/frontend/src/browser/window/location.ffi.mjs @@ -0,0 +1,16 @@ +import * as gleam from "../../gleam.mjs" + +export function location() { + if (typeof window === "undefined") return new gleam.Error() + return new gleam.Ok(window.location) +} + +export const hash = (location) => location.hash +export const host = (location) => location.host +export const hostname = (location) => location.hostname +export const href = (location) => location.href +export const origin = (location) => location.origin +export const pathname = (location) => location.pathname +export const port = (location) => location.port +export const protocol = (location) => location.protocol +export const search = (location) => location.search diff --git a/frontend/src/browser/window/location.gleam b/frontend/src/browser/window/location.gleam new file mode 100644 index 0000000..949b4d4 --- /dev/null +++ b/frontend/src/browser/window/location.gleam @@ -0,0 +1,28 @@ +pub type Location + +@external(javascript, "./location.ffi.mjs", "hash") +pub fn hash(location: Location) -> String + +@external(javascript, "./location.ffi.mjs", "host") +pub fn host(location: Location) -> String + +@external(javascript, "./location.ffi.mjs", "hostname") +pub fn hostname(location: Location) -> String + +@external(javascript, "./location.ffi.mjs", "href") +pub fn href(location: Location) -> String + +@external(javascript, "./location.ffi.mjs", "origin") +pub fn origin(location: Location) -> String + +@external(javascript, "./location.ffi.mjs", "pathname") +pub fn pathname(location: Location) -> String + +@external(javascript, "./location.ffi.mjs", "port") +pub fn port(location: Location) -> String + +@external(javascript, "./location.ffi.mjs", "protocol") +pub fn protocol(location: Location) -> String + +@external(javascript, "./location.ffi.mjs", "search") +pub fn search(location: Location) -> String diff --git a/frontend/src/hexdocs.css b/frontend/src/hexdocs.css new file mode 100644 index 0000000..b95efc0 --- /dev/null +++ b/frontend/src/hexdocs.css @@ -0,0 +1,102 @@ +@import "tailwindcss"; + +@font-face { + font-family: "Calibri"; + font-weight: normal; + font-style: normal; + src: + url("/fonts/Calibri.woff2") format("woff2"), + url("/fonts/Calibri.woff") format("woff"); +} + +@font-face { + font-family: "JetBrains Mono"; + font-weight: normal; + font-style: normal; + src: + url("/fonts/JetBrainsMono-Regular.woff2") format("woff2"), + url("/fonts/JetBrainsMono-Regular.woff") format("woff"); +} + +@custom-variant dark (&:where(.dark, .dark *)); + +@theme { + --font-inter: Inter, sans-serif; + --font-calibri: Calibri, sans-serif; + --font-mono: "JetBrains Mono", monospace; + + --container-8xl: 1440px; + + --color-gray-50: #f0f5f9; + --color-gray-100: #e1e8f0; + --color-gray-200: #cad5e0; + --color-gray-300: #91a4b7; + --color-gray-400: #61758a; + --color-gray-500: #445668; + --color-gray-600: #304254; + --color-gray-700: #1c2a3a; + --color-gray-800: #0d1829; + --color-gray-900: #030913; + + --color-blue-50: #f3f7fc; + --color-blue-100: #e9f1fb; + --color-blue-200: #d2e2f6; + --color-blue-300: #a4c5ec; + --color-blue-400: #77a7e3; + --color-blue-500: #498ad9; + --color-blue-600: #1c6dd0; + --color-blue-700: #1454b2; + --color-blue-800: #0e3e95; + --color-blue-900: #082b78; + + --color-purple-50: #f9f8fd; + --color-purple-100: #f4f2fd; + --color-purple-200: #e8e5fa; + --color-purple-300: #d2cbf5; + --color-purple-400: #bbb0f0; + --color-purple-500: #a596eb; + --color-purple-600: #8e7ce6; + --color-purple-700: #6a5ac5; + --color-purple-800: #4c3ea5; + --color-purple-900: #322785; + + --color-green-50: #fffaf5; + --color-green-100: #e6f3ec; + --color-green-200: #cce6d9; + --color-green-300: #99ccb3; + --color-green-400: #66b38c; + --color-green-500: #339966; + --color-green-600: #008040; + --color-green-700: #006e42; + --color-green-800: #005c40; + --color-green-900: #004a3b; + + --color-yellow-50: #fffaf5; + --color-yellow-100: #fff7ec; + --color-yellow-200: #ffeed9; + --color-yellow-300: #ffdcb2; + --color-yellow-400: #ffcb8c; + --color-yellow-500: #ffb965; + --color-yellow-600: #ffa83f; + --color-yellow-700: #db842e; + --color-yellow-800: #b7641f; + --color-yellow-900: #934814; + + --color-red-50: #fdf5f5; + --color-red-100: #fdeced; + --color-red-200: #fad7da; + --color-red-300: #f5b0b5; + --color-red-400: #f08890; + --color-red-500: #eb616b; + --color-red-600: #e63946; + --color-red-700: #c52943; + --color-red-800: #a51c3f; + --color-red-900: #6e0a36; +} + +.search-input:focus { + border-radius: 0.5rem; + outline: none; + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.5); + border-color: transparent; +} diff --git a/frontend/src/hexdocs.ffi.mjs b/frontend/src/hexdocs.ffi.mjs new file mode 100644 index 0000000..5a7db4f --- /dev/null +++ b/frontend/src/hexdocs.ffi.mjs @@ -0,0 +1,35 @@ +export function submitPackageInput() { + if (document.activeElement.id !== "search-package-input") return + document.activeElement.blur() + window.requestAnimationFrame(() => focusNode()) +} + +function focusNode() { + const node = document.getElementById("search-version-input") + if (!node) return + const hasDisabled = node.hasAttribute("disabled") + if (hasDisabled) { + const disabled = node.getAttribute("disabled") + if (disabled === null || disabled === "" || disabled === "true") { + return window.requestAnimationFrame(() => focusNode()) + } + } + node.focus() +} + +export function updateColorTheme(colorMode) { + localStorage.setItem("theme", colorMode) + if (colorMode === "light") { + document.documentElement.classList.remove("dark") + document.documentElement.classList.add("light") + } + if (colorMode === "dark") { + document.documentElement.classList.remove("light") + document.documentElement.classList.add("dark") + } +} + +export function copyUrl() { + const href = window.location.href + return navigator.clipboard.writeText(href) +} diff --git a/frontend/src/hexdocs.gleam b/frontend/src/hexdocs.gleam new file mode 100644 index 0000000..3d6f155 --- /dev/null +++ b/frontend/src/hexdocs.gleam @@ -0,0 +1,412 @@ +import gleam/dict +import gleam/dynamic/decode +import gleam/function +import gleam/hexpm +import gleam/list +import gleam/option.{None, Some} +import gleam/pair +import gleam/result +import gleam/string +import grille_pain +import grille_pain/lustre/toast +import hexdocs/components/iframe +import hexdocs/data/model.{type Model, Model} +import hexdocs/data/model/autocomplete +import hexdocs/data/model/route +import hexdocs/data/msg.{type Msg} +import hexdocs/effects +import hexdocs/loss.{type Loss} +import hexdocs/services/hexdocs +import hexdocs/setup +import hexdocs/view/home +import hexdocs/view/search +import lustre +import lustre/effect.{type Effect} +import lustre/element/html +import modem + +pub fn main() { + let flags = Nil + let assert Ok(_) = iframe.register() + let assert Ok(_) = grille_pain.simple() + lustre.application(setup.init, update, view) + |> lustre.start("#app", flags) +} + +pub fn view(model: Model) { + case model.route { + route.Home -> home.home(model) + route.Search(..) -> search.search(model) + route.NotFound -> html.div([], []) + } +} + +fn update(model: Model, msg: Msg) { + case msg { + msg.ApiReturnedPackageVersions(response) -> + api_returned_package_versions(model, response) + msg.ApiReturnedPackagesVersions(packages) -> + api_returned_packages_versions(model, packages) + msg.ApiReturnedPackages(response) -> api_returned_packages(model, response) + msg.ApiReturnedTypesenseSearch(response) -> + api_returned_typesense_search(model, response) + + msg.DocumentChangedLocation(location:) -> + model.update_route(model, location) + msg.DocumentRegisteredEventListener(unsubscriber:) -> + document_registered_event_listener(model, unsubscriber) + msg.DocumentRegisteredSidebarListener(unsubscriber:) -> + document_registered_sidebar_listener(model, unsubscriber) + msg.DocumentChangedTheme(theme) -> + model.update_color_theme(model, theme) + |> pair.new(effect.none()) + + msg.UserToggledDarkMode -> user_toggled_dark_mode(model) + msg.UserToggledSidebar -> model.toggle_sidebar(model) + msg.UserClosedSidebar -> model.close_sidebar(model) + msg.UserClickedGoBack -> user_clicked_go_back(model) + + msg.UserFocusedSearch -> user_focused_search(model) + msg.UserBlurredSearch -> model.blur_search(model) + + msg.UserEditedSearch(search:) -> model.update_home_search(model, search) + msg.UserClickedAutocompletePackage(package:) -> + user_clicked_autocomplete_package(model, package) + msg.UserSelectedNextAutocompletePackage -> + user_selected_next_autocomplete_package(model) + msg.UserSelectedPreviousAutocompletePackage -> + user_selected_previous_autocomplete_package(model) + msg.UserSubmittedSearch -> user_submitted_search(model) + msg.UserSubmittedAutocomplete -> user_submitted_autocomplete(model) + + msg.UserDeletedPackagesFilter(filter) -> + user_deleted_packages_filter(model, filter) + msg.UserEditedSearchInput(search_input:) -> + user_edited_search_input(model, search_input) + msg.UserSubmittedPackagesFilter -> user_submitted_packages_filter(model) + msg.UserSubmittedSearchInput -> user_submitted_search_input(model) + msg.UserEditedPackagesFilterInput(content) -> + user_edited_packages_filter_input(model, content) + msg.UserEditedPackagesFilterVersion(content) -> + user_edited_packages_filter_version(model, content) + msg.UserFocusedPackagesFilterInput -> + user_focused_packages_filter_input(model) + msg.UserFocusedPackagesFilterVersion -> + user_focused_packages_filter_version_input(model) + msg.UserToggledPreview(id) -> user_toggled_preview(model, id) + msg.UserSelectedPackageFilter -> user_selected_package_filter(model) + msg.UserSelectedPackageFilterVersion -> + user_selected_package_filter_version(model) + msg.UserClickedShare -> #(model, { + effect.batch([ + effect.from(fn(_) { copy_url() }), + toast.info("The current URL has been copied in your clipboard."), + ]) + }) + + msg.None -> #(model, effect.none()) + } +} + +fn api_returned_package_versions( + model: Model, + response: Loss(hexpm.Package), +) -> #(Model, Effect(Msg)) { + case response { + Error(_) -> #(model, toast.error("Server error. Retry later.")) + Ok(package) -> { + model + |> model.add_packages_versions([package]) + |> model.focus_home_search + } + } +} + +fn api_returned_packages_versions( + model: Model, + packages: Loss(List(hexpm.Package)), +) -> #(Model, Effect(Msg)) { + case packages { + Error(_) -> #(model, toast.error("Server error. Retry later.")) + Ok(packages) -> { + model + |> model.add_packages_versions(packages) + |> model.compute_filters_input + } + } +} + +fn api_returned_packages( + model: Model, + response: Loss(String), +) -> #(Model, Effect(msg)) { + case response { + Error(_) -> #(model, toast.error("Server error. Retry later.")) + Ok(packages) -> + packages + |> string.split(on: "\n") + |> model.add_packages(model, _) + |> pair.new(effect.none()) + } +} + +fn api_returned_typesense_search(model: Model, response: Loss(decode.Dynamic)) { + response + |> result.try(fn(search_result) { + search_result + |> decode.run(hexdocs.typesense_decoder()) + |> result.map_error(loss.DecodeError) + }) + |> result.map(model.set_search_results(model, _)) + |> result.map(pair.new(_, effect.none())) + |> result.unwrap(#(model, effect.none())) +} + +fn document_registered_event_listener(model: Model, unsubscriber: fn() -> Nil) { + let dom_click_unsubscriber = Some(unsubscriber) + Model(..model, dom_click_unsubscriber:) + |> pair.new(effect.none()) +} + +fn document_registered_sidebar_listener(model: Model, unsubscriber: fn() -> Nil) { + let dom_click_sidebar_unsubscriber = Some(unsubscriber) + Model(..model, dom_click_sidebar_unsubscriber:) + |> pair.new(effect.none()) +} + +fn user_toggled_dark_mode(model: Model) { + let model = model.toggle_dark_theme(model) + #(model, { + use _ <- effect.from() + update_color_theme(case model.dark_mode.mode { + msg.Dark -> "dark" + msg.Light -> "light" + }) + }) +} + +fn user_submitted_search(model: Model) { + case model.autocomplete { + None -> model.compute_filters_input(model) + Some(#(_, autocomplete)) -> { + case autocomplete.current(autocomplete) { + None -> model.compute_filters_input(model) + Some(_) -> + model.update_home_search(model, model.home_input_displayed <> " ") + } + } + } +} + +fn user_submitted_autocomplete(model: Model) { + case model.autocomplete { + None -> #(model, effect.none()) + Some(#(model.Version, autocomplete)) -> { + case autocomplete.current(autocomplete) { + None -> #(model, effect.none()) + Some(_) -> + model.update_home_search(model, model.home_input_displayed <> " ") + } + } + Some(#(model.Package, autocomplete)) -> { + case autocomplete.current(autocomplete) { + None -> #(model, effect.none()) + Some(_) -> + model.update_home_search(model, model.home_input_displayed <> ":") + } + } + } +} + +fn user_edited_search_input(model: Model, search_input: String) { + Model(..model, search_input:) + |> pair.new(effect.none()) +} + +fn user_edited_packages_filter_input(model: Model, content: String) { + Model( + ..model, + search_packages_filter_input: content, + search_packages_filter_input_displayed: content, + ) + |> model.autocomplete_packages(content) + |> function.tap(fn(m) { m.autocomplete }) + |> pair.new(effect.none()) +} + +fn user_edited_packages_filter_version(model: Model, content: String) { + Model( + ..model, + search_packages_filter_version_input: content, + search_packages_filter_version_input_displayed: content, + ) + |> pair.new(effect.none()) +} + +fn user_submitted_search_input(model: Model) { + #(model, { + route.push({ + route.Search( + q: model.search_input, + packages: model.search_packages_filters, + ) + }) + }) +} + +fn user_focused_search(model: Model) { + let #(model, effect) = model.focus_home_search(model) + let effects = effect.batch([effect, effects.subscribe_blurred_search()]) + #(model, effects) +} + +fn user_selected_next_autocomplete_package(model: Model) { + model + |> model.select_next_package + |> pair.new(effect.none()) +} + +fn user_selected_previous_autocomplete_package(model: Model) { + model + |> model.select_previous_package + |> pair.new(effect.none()) +} + +fn user_clicked_autocomplete_package(model: Model, package: String) { + model + |> model.select_autocomplete_option(package) + |> model.blur_search + |> pair.map_second(fn(effects) { + let versions = case model.autocomplete { + None -> effect.none() + Some(#(model.Version, _)) -> effect.none() + Some(#(model.Package, _)) -> effects.package_versions(package) + } + effect.batch([versions, effects]) + }) +} + +fn user_deleted_packages_filter( + model: Model, + filter: #(String, String), +) -> #(Model, Effect(msg)) { + let search_packages_filters = + list.filter(model.search_packages_filters, fn(f) { f != filter }) + let model = Model(..model, search_packages_filters:) + #(model, { + route.push(route.Search( + q: model.search_input, + packages: model.search_packages_filters, + )) + }) +} + +fn user_clicked_go_back(model: Model) -> #(Model, Effect(msg)) { + #(model, modem.back(1)) +} + +fn user_submitted_packages_filter(model: Model) { + let package = model.search_packages_filter_input + let version = model.search_packages_filter_version_input + model.packages_versions + |> dict.get(package) + |> result.map(fn(package) { package.releases }) + |> result.try(list.find(_, fn(r) { r.version == version })) + |> result.map(fn(_) { + let search_packages_filters = + [#(package, version)] + |> list.append(model.search_packages_filters, _) + |> list.unique + let model = + Model( + ..model, + search_packages_filters:, + search_packages_filter_input: "", + search_packages_filter_input_displayed: "", + search_packages_filter_version_input: "", + search_packages_filter_version_input_displayed: "", + ) + route.Search(q: model.search_input, packages: model.search_packages_filters) + |> route.push + |> pair.new(model, _) + }) + |> result.lazy_unwrap(fn() { #(model, effect.none()) }) +} + +fn user_focused_packages_filter_input(model: Model) { + let model = model.focus_packages_filter_search(model) + let effect = effects.subscribe_blurred_search() + #(model, effect) +} + +fn user_focused_packages_filter_version_input( + model: Model, +) -> #(Model, Effect(Msg)) { + let #(model, effect) = model.focus_packages_filter_version_search(model) + let effects = effect.batch([effects.subscribe_blurred_search(), effect]) + #(model, effects) +} + +fn user_toggled_preview(model: Model, id: String) { + Model(..model, search_opened_previews: { + use opened <- dict.upsert(model.search_opened_previews, id) + let opened = option.unwrap(opened, False) + !opened + }) + |> pair.new(effect.none()) +} + +fn user_selected_package_filter(model: Model) { + case model.get_selected_package_filter_name(model) { + Error(_) -> #(model, effect.none()) + Ok(package) -> { + Model( + ..model, + search_packages_filter_input_displayed: package, + search_packages_filter_input: package, + ) + |> model.blur_search + |> pair.map_second(fn(blur_effect) { + let submit_package_input = effect.from(fn(_) { submit_package_input() }) + effect.batch([blur_effect, submit_package_input]) + }) + } + } +} + +fn user_selected_package_filter_version(model: Model) { + let package = model.search_packages_filter_input_displayed + let version = model.search_packages_filter_version_input_displayed + let releases = + model.packages_versions + |> dict.get(package) + |> result.map(fn(p) { p.releases }) + |> result.unwrap([]) + let release = + releases + |> list.find(fn(r) { r.version == version }) + |> result.try_recover(fn(_) { list.first(releases) }) + case release { + Error(_) -> #(model, effect.none()) + Ok(release) -> { + let model = + Model( + ..model, + search_packages_filter_version_input: release.version, + search_packages_filter_version_input_displayed: release.version, + ) + let #(model, effect1) = model.blur_search(model) + let #(model, effect2) = user_submitted_packages_filter(model) + #(model, effect.batch([effect1, effect2])) + } + } +} + +@external(javascript, "./hexdocs.ffi.mjs", "submitPackageInput") +fn submit_package_input() -> Nil + +@external(javascript, "./hexdocs.ffi.mjs", "updateColorTheme") +fn update_color_theme(color_mode: String) -> Nil + +@external(javascript, "./hexdocs.ffi.mjs", "copyUrl") +fn copy_url() -> Nil diff --git a/frontend/src/hexdocs/components/attributes.gleam b/frontend/src/hexdocs/components/attributes.gleam new file mode 100644 index 0000000..bcb74ab --- /dev/null +++ b/frontend/src/hexdocs/components/attributes.gleam @@ -0,0 +1,15 @@ +import gleam/int +import lustre/component + +pub fn string(name: String, msg: fn(String) -> msg) -> component.Option(msg) { + use content <- component.on_attribute_change(name) + Ok(msg(content)) +} + +pub fn int(name: String, msg: fn(Int) -> a) -> component.Option(a) { + use content <- component.on_attribute_change(name) + case int.parse(content) { + Ok(content) -> Ok(msg(content)) + Error(_) -> Error(Nil) + } +} diff --git a/frontend/src/hexdocs/components/iframe.gleam b/frontend/src/hexdocs/components/iframe.gleam new file mode 100644 index 0000000..7bc4044 --- /dev/null +++ b/frontend/src/hexdocs/components/iframe.gleam @@ -0,0 +1,102 @@ +import gleam/dynamic/decode +import gleam/option.{type Option, None, Some} +import gleam/pair +import hexdocs/components/attributes +import lustre +import lustre/attribute.{type Attribute, class} +import lustre/component +import lustre/effect.{type Effect} +import lustre/element +import lustre/element/html +import lustre/event + +const tag_name = "hexdocs-iframe" + +pub fn register() { + lustre.component(init, update, view, [ + component.adopt_styles(True), + attributes.string("to", UserChangedTo), + attributes.string("title", UserChangedTitle), + component.open_shadow_root(True), + ]) + |> lustre.register(tag_name) +} + +pub fn to(to: String) -> Attribute(msg) { + attribute.attribute("to", to) +} + +pub fn title(title: String) -> Attribute(msg) { + attribute.attribute("title", title) +} + +pub fn iframe(attributes: List(Attribute(msg))) { + element.element(tag_name, attributes, []) +} + +type Msg { + UserChangedTo(to: String) + UserChangedTitle(title: String) + IFrameStateChanged(State) +} + +type Model { + Model(to: Option(String), title: String, state: State) +} + +type State { + Loading + Loaded +} + +fn init(_) { + Model(to: None, title: "", state: Loading) + |> pair.new(effect.none()) +} + +fn update(model: Model, msg: Msg) -> #(Model, Effect(msg)) { + case echo msg { + UserChangedTitle(title) -> #(Model(..model, title:), effect.none()) + IFrameStateChanged(state) -> #(Model(..model, state:), effect.none()) + UserChangedTo(to) -> #( + Model(..model, to: Some(to), state: Loading), + effect.none(), + ) + } +} + +fn view(model: Model) { + case model.to { + None -> element.none() + Some(to) -> { + html.div( + [class("relative size-full rounded-lg shadow-sm overflow-hidden")], + [ + html.iframe([ + class("size-full overflow-scroll"), + attribute.title(model.title), + event.on("load", decode.success(IFrameStateChanged(Loaded))), + attribute.src(to), + ]), + case model.state { + Loaded -> element.none() + Loading -> loading_state() + }, + ], + ) + } + } +} + +fn loading_state() { + html.div( + [ + class( + "absolute top-0 bg-white size-full flex items-center justify-center", + ), + ], + [ + html.text("Loading"), + ], + ) +} diff --git a/frontend/src/hexdocs/data/model.gleam b/frontend/src/hexdocs/data/model.gleam new file mode 100644 index 0000000..bf240b8 --- /dev/null +++ b/frontend/src/hexdocs/data/model.gleam @@ -0,0 +1,588 @@ +import browser/document +import gleam/bool +import gleam/dict.{type Dict} +import gleam/function +import gleam/hexpm +import gleam/javascript/promise +import gleam/list +import gleam/option.{type Option, None, Some} +import gleam/pair +import gleam/result +import gleam/string +import gleam/uri +import hexdocs/data/model/autocomplete.{type Autocomplete} +import hexdocs/data/model/route.{type Route} +import hexdocs/data/model/version +import hexdocs/data/msg.{type Msg} +import hexdocs/effects +import hexdocs/loss +import hexdocs/services/hex +import hexdocs/services/hexdocs +import lustre/effect.{type Effect} + +pub type Model { + Model( + /// Current route of the application. Mapping `window.location` <=> `Route`. + route: Route, + /// When focusing the autocomplete, clicking on the DOM should close it. + /// To listen to such event, an event listener on the `document` should be + /// setup. It should be cleaned atferwards, if the user closed the + /// autocomplete while not clicking on the DOM (for example, because the + /// user accepted a proposition). `dom_click_unsubscriber` stores the + /// function to revoke the event listener. + dom_click_unsubscriber: Option(fn() -> Nil), + dark_mode: msg.ColorSetting, + /// Stores the content of the `https://hexdocs.pm/package_names.csv`. + packages: List(String), + /// Stores the different versions of a package. + /// `Dict(Package Name, hexpm.Package)`. + packages_versions: Dict(String, hexpm.Package), + /// Stores the open state of the sidebar. + sidebar_opened: Bool, + dom_click_sidebar_unsubscriber: Option(fn() -> Nil), + /// Stores the content of the search input on the home page, entered + /// by the user. + home_input: String, + /// Stores the current displayed content of the search input on the home + /// page. Differs from `home_input` as, like on Google Search, hovering on + /// the autocomplete will update the displayed value in the input, to let + /// the user to continue typing after selecting an item. \ + /// For instance, a user could type `#lus`, select `lustre` in the + /// autocomplete, the input will display `#lustre`, and the user can then + /// type `:`. The input will be `#lustre:`, and it will trigger the + /// autocomplete package versions. + home_input_displayed: String, + /// Stores the current state of the autocomplete. The autocomplete can be + /// triggered for packages and version numbers. + autocomplete: Option(#(Type, Autocomplete)), + /// Whether the autocomplete is focused, or not. + autocomplete_search_focused: AutocompleteFocused, + /// Keeps the results from TypeSense. + /// `#(Page, List(Results))`. + search_result: Option(#(Int, List(hexdocs.TypeSense))), + /// Stores the current value of the search bar on top of the search page. + search_input: String, + /// Stores the current state of the different previews opened in + /// the search results, in the search page. An item missing from the + /// `Dict` indicates a preview _not_ openend. + search_opened_previews: Dict(String, Bool), + /// Stores the current value of the packages filter input on + /// left of the search page. + search_packages_filter_input: String, + search_packages_filter_input_displayed: String, + /// Stores the current value of the packages version input on + /// left of the search page. + search_packages_filter_version_input: String, + search_packages_filter_version_input_displayed: String, + /// Store the current set packages filters. + search_packages_filters: List(#(String, String)), + ) +} + +pub type AutocompleteFocused { + AutocompleteClosed + AutocompleteOnHome + AutocompleteOnPackage + AutocompleteOnVersion +} + +/// Autocomplete can be used with Package or Version. +pub type Type { + Package + Version +} + +pub fn new(dark_mode: msg.ColorSetting) -> Model { + Model( + route: route.Home, + dom_click_unsubscriber: None, + dark_mode:, + packages: [], + packages_versions: dict.new(), + sidebar_opened: False, + dom_click_sidebar_unsubscriber: None, + home_input: "", + home_input_displayed: "", + autocomplete: None, + autocomplete_search_focused: AutocompleteClosed, + search_result: None, + search_input: "", + search_opened_previews: dict.new(), + search_packages_filter_input: "", + search_packages_filter_input_displayed: "", + search_packages_filter_version_input: "", + search_packages_filter_version_input_displayed: "", + search_packages_filters: [], + ) +} + +/// Add packages in the `Model`, allowing them to be easily parsed, used in +/// autocomplete, etc. The `Model` acts as a cache for the packages list, +/// fetched at every application startup. +pub fn add_packages(model: Model, packages: List(String)) -> Model { + let packages = list.filter(packages, fn(p) { p != "" }) + Model(..model, packages:) +} + +pub fn add_packages_versions( + model: Model, + packages: List(hexpm.Package), +) -> Model { + use model, package <- list.fold(packages, model) + Model(..model, packages_versions: { + dict.insert(model.packages_versions, package.name, package) + }) +} + +pub fn toggle_sidebar(model: Model) { + let model = Model(..model, sidebar_opened: !model.sidebar_opened) + let unsub = unsubscribe_sidebar_dom_click(model) + case model.sidebar_opened { + False -> #(Model(..model, dom_click_sidebar_unsubscriber: None), unsub) + True -> #( + Model(..model, dom_click_sidebar_unsubscriber: None), + effect.batch([unsub, subscribe_sidebar_dom_click()]), + ) + } +} + +fn unsubscribe_sidebar_dom_click(model: Model) { + use _ <- effect.from() + let unsub = model.dom_click_sidebar_unsubscriber + let unsub = option.unwrap(unsub, fn() { Nil }) + unsub() +} + +fn subscribe_sidebar_dom_click() { + use dispatch, _ <- effect.after_paint() + document.add_listener(fn() { dispatch(msg.UserClosedSidebar) }) + |> msg.DocumentRegisteredSidebarListener + |> dispatch +} + +pub fn close_sidebar(model: Model) { + Model(..model, dom_click_sidebar_unsubscriber: None, sidebar_opened: False) + |> pair.new(effect.none()) +} + +/// Updates the color theme according to `(prefers-color-scheme)` of the +/// browser. If user setup setting by hand, the change _will not_ have any +/// effect. +pub fn update_color_theme(model: Model, color_theme: msg.ColorMode) { + case model.dark_mode { + msg.System(_) -> Model(..model, dark_mode: msg.System(color_theme)) + msg.User(_) -> model + } +} + +/// Toggle the dark theme as asked by the user. By design, when the user +/// overrides the system setting, the theme will now only be controlled by the +/// user, and `(prefers-color-scheme: dark)` will have no effect on the color +/// mode of the application. +pub fn toggle_dark_theme(model: Model) { + Model(..model, dark_mode: { + msg.User({ + case model.dark_mode.mode { + msg.Dark -> msg.Light + msg.Light -> msg.Dark + } + }) + }) +} + +pub fn update_home_search(model: Model, home_input: String) { + Model(..model, home_input:, home_input_displayed: home_input) + |> autocomplete_packages(home_input) + |> autocomplete_versions(home_input) +} + +pub fn focus_home_search(model: Model) { + Model(..model, autocomplete_search_focused: { + case model.autocomplete_search_focused, model.route { + AutocompleteClosed, route.Home -> AutocompleteOnHome + state, _ -> state + } + }) + |> autocomplete_packages(model.home_input) + |> autocomplete_versions(model.home_input) +} + +pub fn focus_packages_filter_search(model: Model) { + Model(..model, autocomplete_search_focused: AutocompleteOnPackage) + |> autocomplete_packages(model.search_packages_filter_input) +} + +pub fn focus_packages_filter_version_search(model: Model) { + Model(..model, autocomplete_search_focused: AutocompleteOnVersion) + |> autocomplete_versions(model.search_packages_filter_version_input_displayed) +} + +pub fn update_route(model: Model, route: uri.Uri) { + let route = route.from_uri(route) + let model = + Model( + ..model, + route:, + search_packages_filter_version_input: "", + search_packages_filter_version_input_displayed: "", + search_packages_filter_input: "", + search_packages_filter_input_displayed: "", + ) + case route { + route.Home | route.NotFound -> #(model, effect.none()) + route.Search(q:, packages:) -> { + Model(..model, search_input: q, search_packages_filters: packages) + |> pair.new(effects.typesense_search(q, packages)) + } + } +} + +pub fn select_autocomplete_option(model: Model, package: String) { + case model.autocomplete, model.route { + None, _ -> model + Some(_), route.NotFound -> model + Some(#(type_, _autocomplete)), route.Home -> { + let home_input_displayed = + replace_last_word(model.home_input_displayed, package, type_) + Model( + ..model, + home_input: home_input_displayed, + home_input_displayed:, + autocomplete: None, + ) + } + Some(#(type_, _autocomplete)), route.Search(..) -> { + let model = Model(..model, autocomplete: None) + case type_ { + Package -> { + model.packages + |> list.find(fn(p) { p == package }) + |> result.map(fn(_) { + Model( + ..model, + search_packages_filter_input: package, + search_packages_filter_input_displayed: package, + ) + }) + |> result.unwrap(model) + } + Version -> { + let version = package + let package = model.search_packages_filter_input_displayed + model.packages_versions + |> dict.get(package) + |> result.map(fn(package) { package.releases }) + |> result.try(list.find(_, fn(r) { r.version == version })) + |> result.map(fn(_) { + Model( + ..model, + search_packages_filter_version_input: version, + search_packages_filter_version_input_displayed: version, + ) + }) + |> result.unwrap(model) + } + } + } + } +} + +/// When going from the home page, where you have a free text input to the +/// search page, it's needed to keep the different parts of the search, while +/// changing how they're handled in the model. That function transforms the +/// simple text input in the advanced filters parts in the Model. +pub fn compute_filters_input(model: Model) -> #(Model, Effect(Msg)) { + let #(filters, packages_to_fetch) = extract_packages_filters_or_fetches(model) + let search_input = keep_search_input_non_packages_text(model) + case list.is_empty(packages_to_fetch) { + True -> { + #(Model(..model, search_packages_filters: filters, search_input:), { + route.push(route.Search(q: search_input, packages: filters)) + }) + } + False -> #(model, { + use dispatch <- effect.from() + use _ <- function.tap(Nil) + packages_to_fetch + |> list.map(fn(package) { hex.package_versions(package) }) + |> promise.await_list + |> promise.map(fn(packages) { + use response <- list.try_map(packages) + use response <- result.try(response) + let is_valid = response.status == 200 + use <- bool.guard(when: !is_valid, return: Error(loss.HttpError)) + Ok(response.body) + }) + |> promise.map(fn(packages) { + dispatch(msg.ApiReturnedPackagesVersions(packages)) + }) + }) + } +} + +/// Typical home search input will be something like `foo #phoenix #ecto:1.0.0`. +/// `extract_packages_filters_or_fetches` will extract the `#ecto:1.0.0` part +/// as a filter, and will return a side-effect to fetch `phoenix`, in order to +/// always query the latest version. When all packages have been fetched and are +/// stored in the model, `extract_packages_filters_or_fetches` will return the +/// correct model and will reroute to the search page. +fn extract_packages_filters_or_fetches(model: Model) { + let segments = string.split(model.home_input_displayed, on: " ") + let search_packages_filters = list.filter_map(segments, version.match_package) + list.fold(search_packages_filters, #([], []), fn(acc, val) { + let #(filters, packages_to_fetch) = acc + let #(package, version) = val + let is_existing_package = list.contains(model.packages, package) + use <- bool.guard(when: !is_existing_package, return: acc) + case version { + Some(version) -> #([#(package, version), ..filters], packages_to_fetch) + None -> { + case dict.get(model.packages_versions, package) { + Error(_) -> #(filters, [package, ..packages_to_fetch]) + Ok(versionned) -> { + case list.first(versionned.releases) { + // That case is impossible, returning the neutral element. + Error(_) -> #(filters, packages_to_fetch) + Ok(release) -> { + let version = release.version + #([#(package, version), ..filters], packages_to_fetch) + } + } + } + } + } + } + }) +} + +/// Typical home search input will be something like `foo #phoenix #ecto:1.0.0`. +/// `keep_search_input_non_packages_text` keeps only the `foo` part of the +/// search input. +fn keep_search_input_non_packages_text(model: Model) -> String { + let segments = string.split(model.home_input_displayed, on: " ") + segments + |> list.filter(fn(s) { version.match_package(s) |> result.is_error }) + |> string.join(with: " ") +} + +/// When typing to select a new package filter on the search page, if the +/// package is incomplete when submitting, the autocomplete will automatically +/// takes the first package in the list. +pub fn get_selected_package_filter_name(model: Model) { + let is_valid = + list.contains(model.packages, model.search_packages_filter_input_displayed) + case is_valid, model.autocomplete { + True, _ -> Ok(model.search_packages_filter_input_displayed) + False, None -> Error(Nil) + False, Some(#(_, autocomplete)) -> { + autocomplete.all(autocomplete) + |> list.first + } + } +} + +pub fn set_search_results( + model: Model, + search_result: #(Int, List(hexdocs.TypeSense)), +) -> Model { + let search_result = Some(search_result) + Model(..model, search_result:) +} + +pub fn blur_search(model: Model) { + Model( + ..model, + autocomplete_search_focused: AutocompleteClosed, + autocomplete: None, + home_input: model.home_input_displayed, + dom_click_unsubscriber: None, + ) + |> pair.new({ unsubscribe_dom_listener(model) }) +} + +pub fn unsubscribe_dom_listener(model: Model) { + use _ <- effect.from() + let none = fn() { Nil } + let unsubscriber = option.unwrap(model.dom_click_unsubscriber, none) + unsubscriber() +} + +pub fn autocomplete_packages(model: Model, search: String) { + case should_trigger_autocomplete_packages(model, search) { + Error(_) -> Model(..model, autocomplete: None) + Ok(search) -> { + let autocomplete = autocomplete.init(model.packages, search) + let autocomplete = #(Package, autocomplete) + Model(..model, autocomplete: Some(autocomplete)) + } + } +} + +pub fn autocomplete_versions(model: Model, search: String) { + case should_trigger_autocomplete_versions(model, search) { + Error(_) -> #(model, effect.none()) + Ok(#(package, version)) -> { + case dict.get(model.packages_versions, package) { + Error(_) -> + case list.contains(model.packages, package) { + True -> #(model, effects.package_versions(package)) + False -> #(model, effect.none()) + } + Ok(package) -> { + let versions = list.map(package.releases, fn(r) { r.version }) + let autocomplete = autocomplete.init(versions, version) + let autocomplete = #(Version, autocomplete) + let model = Model(..model, autocomplete: Some(autocomplete)) + #(model, effect.none()) + } + } + } + } +} + +pub fn select_next_package(model: Model) -> Model { + use autocomplete <- map_autocomplete(model) + autocomplete.next(autocomplete) +} + +pub fn select_previous_package(model: Model) -> Model { + use autocomplete <- map_autocomplete(model) + autocomplete.previous(autocomplete) +} + +fn map_autocomplete(model: Model, mapper: fn(Autocomplete) -> Autocomplete) { + case model.autocomplete { + None -> model + Some(#(type_, autocomplete)) -> { + let autocomplete = mapper(autocomplete) + let autocomplete = #(type_, autocomplete) + let model = Model(..model, autocomplete: Some(autocomplete)) + update_displayed(model, autocomplete) + } + } +} + +fn update_displayed(model: Model, autocomplete: #(Type, Autocomplete)) { + let #(type_, autocomplete) = autocomplete + case autocomplete.current(autocomplete), model.route, type_ { + _, route.NotFound, _ -> model + None, route.Home, _ -> + Model(..model, home_input_displayed: model.home_input) + None, route.Search(..), Package -> { + Model(..model, search_packages_filter_input_displayed: { + model.search_packages_filter_input + }) + } + None, route.Search(..), Version -> { + Model(..model, search_packages_filter_version_input_displayed: { + model.search_packages_filter_version_input + }) + } + Some(current), route.Home, _ -> { + let home_input_displayed = + replace_last_word(model.home_input_displayed, current, type_) + Model(..model, home_input_displayed:) + } + Some(current), route.Search(..), Package -> { + Model(..model, search_packages_filter_input_displayed: current) + } + Some(current), route.Search(..), Version -> { + Model(..model, search_packages_filter_version_input_displayed: current) + } + } +} + +/// When using the home search input, only the last word in the input should be +/// replaced when using the autocomplete. That helper helps by managing directly +/// the replacement. +fn replace_last_word(content: String, word: String, type_: Type) { + case type_ { + Package -> { + let parts = string.split(content, on: " ") + let length = list.length(parts) + parts + |> list.take(length - 1) + |> list.append(["#" <> word]) + |> string.join(with: " ") + } + Version -> { + let parts = string.split(content, on: " ") + let length = list.length(parts) + let start = list.take(parts, length - 1) + case list.last(parts) { + Error(_) -> string.join(parts, with: " ") + Ok(last_word) -> { + let segments = string.split(last_word, on: ":") + let length = list.length(segments) + list.take(segments, length - 1) + |> list.append([word]) + |> string.join(with: ":") + |> list.wrap + |> list.append(start, _) + |> string.join(with: " ") + } + } + } + } +} + +/// Autocomplete is triggered on multiple cases: +/// - On home page (`model.route` is `route.Home`), when the user typed `#`, +/// the autocomplete will trigger. +/// - On search page (`model.route` is `route.Search(..)`), when the user +/// focuses the input, the autocomplete will instantly trigger. +/// `should_trigger_autocomplete_packages` returns the string to match on. +fn should_trigger_autocomplete_packages(model: Model, search: String) { + let no_search = string.is_empty(search) || string.ends_with(search, " ") + use <- bool.guard(when: no_search, return: Error(Nil)) + search + |> string.split(on: " ") + |> list.last + |> result.try(fn(search) { + let length = string.length(search) + case + string.starts_with(search, "#"), + string.contains(search, ":"), + model.route + { + _, True, _ -> Error(Nil) + True, False, _ -> Ok(string.slice(from: search, at_index: 1, length:)) + False, _, route.Search(..) -> Ok(search) + False, _, _ -> Error(Nil) + } + }) +} + +/// Autocomplete is triggered on multiple cases: +/// - On home page (`model.route` is `route.Home`), when the user typed `:`, +/// the autocomplete will trigger. +/// - On search page (`model.route` is `route.Search(..)`), when the user +/// focus the input, the autocomplete will instantly trigger +/// iif the package is correctly selected. +/// `should_trigger_autocomplete_packages` returns the string to match on. +fn should_trigger_autocomplete_versions(model: Model, search: String) { + case model.route, search { + route.NotFound, _ -> Error(Nil) + route.Home, "" -> Error(Nil) + route.Search(..), _ -> + Ok(#(model.search_packages_filter_input_displayed, "")) + route.Home, search -> { + use <- bool.guard(when: string.ends_with(search, " "), return: Error(Nil)) + search + |> string.split(on: " ") + |> list.last + |> result.try(fn(search) { + let length = string.length(search) + case string.starts_with(search, "#") { + False -> Error(Nil) + True -> + case string.split(search, on: ":") { + [word, version] -> + Ok(#(string.slice(from: word, at_index: 1, length:), version)) + _ -> Error(Nil) + } + } + }) + } + } +} diff --git a/frontend/src/hexdocs/data/model/autocomplete.gleam b/frontend/src/hexdocs/data/model/autocomplete.gleam new file mode 100644 index 0000000..88ed53a --- /dev/null +++ b/frontend/src/hexdocs/data/model/autocomplete.gleam @@ -0,0 +1,69 @@ +import gleam/list +import gleam/option.{type Option, None, Some} +import gleam/string + +/// Zipper, providing a visualisation of an element of an autocompleted list. +/// Current value can be obtained using `current`, while the entire list can be +/// obtained through `all` for display purposes. +pub opaque type Autocomplete { + Autocomplete( + all: List(String), + previous: List(String), + current: Option(String), + next: List(String), + ) +} + +/// Initialise the current autocomplete, with no current selected element. +pub fn init(options: List(String), search: String) -> Autocomplete { + let options = keep_first_ten_options(options, search) + Autocomplete(all: options, previous: [], current: None, next: options) +} + +pub fn all(autocomplete: Autocomplete) -> List(String) { + autocomplete.all +} + +pub fn current(autocomplete: Autocomplete) -> Option(String) { + autocomplete.current +} + +pub fn is_selected(autocomplete: Autocomplete, element: String) -> Bool { + autocomplete.current == Some(element) +} + +/// Select the next element. If there's no next element, nothing happens. +pub fn next(autocomplete: Autocomplete) -> Autocomplete { + case autocomplete { + Autocomplete(next: [], ..) -> autocomplete + Autocomplete(next: [fst, ..next], current: None, ..) -> + Autocomplete(..autocomplete, current: Some(fst), next:) + Autocomplete(next: [fst, ..next], current: Some(c), ..) -> { + let previous = [c, ..autocomplete.previous] + let current = Some(fst) + Autocomplete(..autocomplete, previous:, current:, next:) + } + } +} + +/// Select the previous element. If there's no previous element, current element +/// is deselected. If there's no previous element & no current element, nothing +/// happens. +pub fn previous(autocomplete: Autocomplete) -> Autocomplete { + case autocomplete { + Autocomplete(previous: [], current: None, ..) -> autocomplete + Autocomplete(previous: [], current: Some(c), next:, ..) -> + Autocomplete(..autocomplete, next: [c, ..next], current: None) + Autocomplete(previous: [fst, ..previous], current: Some(c), next:, ..) -> { + let current = Some(fst) + Autocomplete(..autocomplete, previous:, current:, next: [c, ..next]) + } + _ -> panic as "previous cannot be filled if current is None" + } +} + +fn keep_first_ten_options(options: List(String), search: String) { + options + |> list.filter(string.starts_with(_, search)) + |> list.take(10) +} diff --git a/frontend/src/hexdocs/data/model/route.gleam b/frontend/src/hexdocs/data/model/route.gleam new file mode 100644 index 0000000..0209afc --- /dev/null +++ b/frontend/src/hexdocs/data/model/route.gleam @@ -0,0 +1,77 @@ +import gleam/bool +import gleam/list +import gleam/option.{None, Some} +import gleam/result +import gleam/string +import gleam/uri.{type Uri} +import hexdocs/data/model/version +import modem + +pub type Route { + Home + Search(q: String, packages: List(#(String, String))) + NotFound +} + +pub fn from_uri(location: Uri) -> Route { + case uri.path_segments(location.path) { + [] -> Home + ["search"] -> search_from_uri(location) + _ -> NotFound + } +} + +pub fn to_uri(route: Route) -> Uri { + let assert Ok(uri) = case route { + Home -> uri.parse("/") + NotFound -> uri.parse("/") + Search(q:, packages:) -> { + use uri <- result.map(uri.parse("/search")) + let query = create_query([#("q", q)], packages) + let query = uri.query_to_string(query) + uri.Uri(..uri, query: Some(query)) + } + } + uri +} + +pub fn push(route: Route) { + let route = to_uri(route) + modem.push(route.path, route.query, route.fragment) +} + +fn create_query( + query: List(#(String, String)), + packages: List(#(String, String)), +) -> List(#(String, String)) { + use <- bool.guard(when: list.is_empty(packages), return: query) + let packages = list.map(packages, version.to_string) + let packages = string.join(packages, with: ",") + list.append(query, [#("packages", packages)]) +} + +fn search_from_uri(location: Uri) { + case location.query { + None -> Search(q: "", packages: []) + Some(query) -> { + case uri.parse_query(query) { + Error(_) -> Search(q: "", packages: []) + Ok(query) -> { + let q = list.key_find(query, "q") |> result.unwrap("") + Search(q:, packages: { + list.key_find(query, "packages") + |> result.unwrap("") + |> string.split(on: ",") + |> list.filter_map(fn(package) { + case version.match_package(package) { + Ok(#(package, Some(version))) -> Ok(#(package, version)) + Ok(_) -> Error(Nil) + Error(Nil) -> Error(Nil) + } + }) + }) + } + } + } + } +} diff --git a/frontend/src/hexdocs/data/model/version.gleam b/frontend/src/hexdocs/data/model/version.gleam new file mode 100644 index 0000000..5ae6ae6 --- /dev/null +++ b/frontend/src/hexdocs/data/model/version.gleam @@ -0,0 +1,30 @@ +import gleam/option.{type Option, None, Some} +import gleam/regexp + +const version_regexp = "^#([a-zA-Z_0-9]+)(:(([0-9]+|\\.){1,5}))?" + +pub fn match_package(word: String) -> Result(#(String, Option(String)), Nil) { + let regexp = version_search() + case regexp.scan(regexp, word) { + [regexp.Match(content: _, submatches:)] -> { + case submatches { + [Some(package), _, Some(version), ..] -> Ok(#(package, Some(version))) + [Some(package)] -> Ok(#(package, None)) + _ -> Error(Nil) + } + } + _ -> Error(Nil) + } +} + +pub fn to_string(package: #(String, String)) { + let #(package, version) = package + let package = "#" <> package + package <> ":" <> version +} + +fn version_search() { + let options = regexp.Options(case_insensitive: False, multi_line: False) + let assert Ok(regexp) = regexp.compile(version_regexp, with: options) + regexp +} diff --git a/frontend/src/hexdocs/data/msg.gleam b/frontend/src/hexdocs/data/msg.gleam new file mode 100644 index 0000000..519dcfd --- /dev/null +++ b/frontend/src/hexdocs/data/msg.gleam @@ -0,0 +1,59 @@ +import gleam/dynamic.{type Dynamic} +import gleam/hexpm +import gleam/uri +import hexdocs/loss.{type Loss} + +pub type Msg { + // API messages. + ApiReturnedPackageVersions(response: Loss(hexpm.Package)) + ApiReturnedPackages(Loss(String)) + ApiReturnedTypesenseSearch(Loss(Dynamic)) + ApiReturnedPackagesVersions(packages: Loss(List(hexpm.Package))) + + // Application messages. + DocumentChangedLocation(location: uri.Uri) + DocumentRegisteredEventListener(unsubscriber: fn() -> Nil) + DocumentRegisteredSidebarListener(unsubscriber: fn() -> Nil) + DocumentChangedTheme(color_theme: ColorMode) + UserClickedGoBack + UserToggledDarkMode + UserToggledSidebar + UserClosedSidebar + + // Home page messages. + UserBlurredSearch + UserClickedAutocompletePackage(package: String) + UserEditedSearch(search: String) + UserFocusedSearch + UserSelectedNextAutocompletePackage + UserSelectedPreviousAutocompletePackage + UserSubmittedSearch + UserSubmittedAutocomplete + + // Search page messages. + UserDeletedPackagesFilter(#(String, String)) + UserEditedPackagesFilterInput(String) + UserEditedPackagesFilterVersion(String) + UserEditedSearchInput(search_input: String) + UserFocusedPackagesFilterInput + UserFocusedPackagesFilterVersion + UserSelectedPackageFilter + UserSelectedPackageFilterVersion + UserSubmittedPackagesFilter + UserSubmittedSearchInput + UserToggledPreview(id: String) + UserClickedShare + + // Neutral element, because we need to call `stop_propagation` conditionnally. + None +} + +pub type ColorSetting { + User(mode: ColorMode) + System(mode: ColorMode) +} + +pub type ColorMode { + Light + Dark +} diff --git a/frontend/src/hexdocs/effects.gleam b/frontend/src/hexdocs/effects.gleam new file mode 100644 index 0000000..16ad4c4 --- /dev/null +++ b/frontend/src/hexdocs/effects.gleam @@ -0,0 +1,48 @@ +import browser/document +import gleam/function +import gleam/http/response.{type Response} +import gleam/javascript/promise +import hexdocs/data/msg +import hexdocs/loss.{type Loss} +import hexdocs/services/hex +import hexdocs/services/hexdocs +import lustre/effect + +pub fn packages() { + use dispatch <- effect.from() + use _ <- function.tap(Nil) + use response <- promise.map(hexdocs.packages()) + let response = response_to_loss(response) + dispatch(msg.ApiReturnedPackages(response)) +} + +pub fn package_versions(package: String) { + use dispatch <- effect.from() + use _ <- function.tap(Nil) + use response <- promise.map(hex.package_versions(package)) + let response = response_to_loss(response) + dispatch(msg.ApiReturnedPackageVersions(response:)) +} + +pub fn subscribe_blurred_search() { + use dispatch <- effect.from() + document.add_listener(fn() { dispatch(msg.UserBlurredSearch) }) + |> msg.DocumentRegisteredEventListener + |> dispatch +} + +pub fn typesense_search(query: String, packages: List(#(String, String))) { + use dispatch <- effect.from() + use _ <- function.tap(Nil) + use response <- promise.map(hexdocs.typesense_search(query, packages, 1)) + let response = response_to_loss(response) + dispatch(msg.ApiReturnedTypesenseSearch(response)) +} + +fn response_to_loss(response: Loss(Response(a))) -> Loss(a) { + case response { + Error(error) -> Error(error) + Ok(response) if response.status == 200 -> Ok(response.body) + Ok(_response) -> Error(loss.HttpError) + } +} diff --git a/frontend/src/hexdocs/endpoints.gleam b/frontend/src/hexdocs/endpoints.gleam new file mode 100644 index 0000000..55bc1c4 --- /dev/null +++ b/frontend/src/hexdocs/endpoints.gleam @@ -0,0 +1,22 @@ +import gleam/uri.{type Uri} + +const search_url = "https://search.hexdocs.pm" + +const hexdocs_url = "https://hexdocs.pm" + +const hexpm_url = "https://hex.pm" + +pub fn search() -> Uri { + let assert Ok(uri) = uri.parse(search_url) + uri +} + +pub fn packages() -> Uri { + let assert Ok(uri) = uri.parse(hexdocs_url <> "/package_names.csv") + uri +} + +pub fn package(package: String) -> Uri { + let assert Ok(uri) = uri.parse(hexpm_url <> "/api/packages/" <> package) + uri +} diff --git a/frontend/src/hexdocs/environment.gleam b/frontend/src/hexdocs/environment.gleam new file mode 100644 index 0000000..14ca777 --- /dev/null +++ b/frontend/src/hexdocs/environment.gleam @@ -0,0 +1,23 @@ +import browser/window +import browser/window/location +import gleam/result + +pub type Environment { + Development + Staging + Production +} + +/// Read `GLEAM_ENV` environment variable to detect the global environment. +/// `GLEAM_ENV` should be `"production"`, `"staging"` or `"development"`. In case +/// `GLEAM_ENV` is missing, it fallback automatically on `Production` to avoid +/// potential leaking of critical data. +pub fn read() { + let location = window.location() + let hostname = result.map(location, location.hostname) + case hostname { + Ok("localhost") | Ok("127.0.0.1") -> Development + Ok("staging" <> _) -> Staging + _ -> Production + } +} diff --git a/frontend/src/hexdocs/loss.gleam b/frontend/src/hexdocs/loss.gleam new file mode 100644 index 0000000..a7c61d1 --- /dev/null +++ b/frontend/src/hexdocs/loss.gleam @@ -0,0 +1,11 @@ +import gleam/dynamic/decode +import gleam/fetch + +pub type Loss(a) = + Result(a, HexdocsSearchError) + +pub type HexdocsSearchError { + HttpError + FetchError(fetch.FetchError) + DecodeError(List(decode.DecodeError)) +} diff --git a/frontend/src/hexdocs/services/hex.gleam b/frontend/src/hexdocs/services/hex.gleam new file mode 100644 index 0000000..25f973e --- /dev/null +++ b/frontend/src/hexdocs/services/hex.gleam @@ -0,0 +1,54 @@ +import gleam/dynamic/decode +import gleam/fetch +import gleam/hexpm +import gleam/http/request +import gleam/http/response +import gleam/javascript/promise +import gleam/option +import gleam/result +import gleam/string +import gleam/uri +import hexdocs/endpoints +import hexdocs/loss +import hexdocs/services/hexdocs + +pub fn package_versions(name: String) { + let endpoint = endpoints.package(name) + let assert Ok(request) = request.from_uri(endpoint) + fetch.send(request) + |> promise.try_await(fetch.read_json_body) + |> promise.map(result.map_error(_, loss.FetchError)) + |> promise.map_try(fn(res) { + decode.run(res.body, hexpm.package_decoder()) + |> result.map_error(loss.DecodeError) + |> result.map(response.set_body(res, _)) + }) +} + +pub fn go_to_link(document: hexdocs.Document) { + case string.split(document.package, on: "-") { + [name, version, ..rest] -> { + let version = string.join([version, ..rest], with: "-") + ["https://hexdocs.pm", name, version, document.ref] + |> string.join(with: "/") + |> Ok + } + _ -> Error(Nil) + } +} + +pub fn preview_link(document: hexdocs.Document, theme: String) { + let assert [name, vsn] = string.split(document.package, on: "-") + ["https://hexdocs.pm", name, vsn, document.ref] + |> string.join(with: "/") + |> uri.parse + |> result.map(fn(u) { + uri.Uri( + ..u, + query: option.Some({ + uri.query_to_string([#("preview", "true"), #("theme", theme)]) + }), + ) + }) + |> result.map(uri.to_string) +} diff --git a/frontend/src/hexdocs/services/hexdocs.gleam b/frontend/src/hexdocs/services/hexdocs.gleam new file mode 100644 index 0000000..625917b --- /dev/null +++ b/frontend/src/hexdocs/services/hexdocs.gleam @@ -0,0 +1,123 @@ +import gleam/bool +import gleam/dynamic/decode +import gleam/fetch +import gleam/http/request +import gleam/int +import gleam/javascript/promise +import gleam/list +import gleam/option.{type Option, None, Some} +import gleam/result +import gleam/string +import gleam/uri +import hexdocs/endpoints +import hexdocs/environment +import hexdocs/loss + +pub type TypeSense { + TypeSense(document: Document, highlight: Highlights) +} + +pub type Document { + Document( + doc: String, + id: String, + package: String, + proglang: String, + ref: String, + title: String, + type_: String, + ) +} + +pub type Highlights { + Highlights(doc: Option(Highlight), title: Option(Highlight)) +} + +pub type Highlight { + Highlight(matched_tokens: List(String), snippet: String) +} + +pub fn packages() { + case environment.read() { + environment.Development | environment.Staging | environment.Production -> { + let endpoint = endpoints.packages() + let assert Ok(request) = request.from_uri(endpoint) + fetch.send(request) + |> promise.try_await(fetch.read_text_body) + |> promise.map(result.map_error(_, loss.FetchError)) + } + } +} + +pub fn typesense_search( + query: String, + packages: List(#(String, String)), + page: Int, +) { + let query = new_search_query_params(query, packages, page) + let endpoint = uri.Uri(..endpoints.search(), query: Some(query)) + let assert Ok(request) = request.from_uri(endpoint) + fetch.send(request) + |> promise.try_await(fetch.read_json_body) + |> promise.map(result.map_error(_, loss.FetchError)) +} + +pub fn typesense_decoder() { + use found <- decode.field("found", decode.int) + use hits <- decode.field("hits", { + decode.list({ + use document <- decode.field("document", { + use doc <- decode.field("doc", decode.string) + use id <- decode.field("id", decode.string) + use package <- decode.field("package", decode.string) + use proglang <- decode.field("proglang", decode.string) + use ref <- decode.field("ref", decode.string) + use title <- decode.field("title", decode.string) + use type_ <- decode.field("type", decode.string) + Document(doc:, id:, package:, proglang:, ref:, title:, type_:) + |> decode.success + }) + use highlight <- decode.field("highlight", { + let highlight = highlight_decoder() |> decode.map(Some) + use doc <- decode.optional_field("doc", None, highlight) + use title <- decode.optional_field("title", None, highlight) + decode.success(Highlights(doc:, title:)) + }) + decode.success(TypeSense(document:, highlight:)) + }) + }) + decode.success(#(found, hits)) +} + +fn new_search_query_params( + query: String, + packages: List(#(String, String)), + page: Int, +) { + list.new() + |> list.key_set("q", query) + |> list.key_set("query_by", "title,doc") + |> list.key_set("page", int.to_string(page)) + |> add_filter_by_packages_param(packages) + |> uri.query_to_string +} + +fn add_filter_by_packages_param( + query: List(#(String, String)), + packages: List(#(String, String)), +) -> List(#(String, String)) { + use <- bool.guard(when: list.is_empty(packages), return: query) + packages + |> list.map(fn(p) { p.0 <> "-" <> p.1 }) + |> list.map(string.append("package:=", _)) + |> string.join("||") + |> list.key_set(query, "filter_by", _) +} + +fn highlight_decoder() { + let matched_tokens = decode.list(decode.string) + use matched_tokens <- decode.field("matched_tokens", matched_tokens) + use snippet <- decode.field("snippet", decode.string) + Highlight(matched_tokens:, snippet:) + |> decode.success +} diff --git a/frontend/src/hexdocs/setup.ffi.mjs b/frontend/src/hexdocs/setup.ffi.mjs new file mode 100644 index 0000000..367c748 --- /dev/null +++ b/frontend/src/hexdocs/setup.ffi.mjs @@ -0,0 +1,28 @@ +export function readDarkMode() { + const [custom, theme] = doReadDarkMode() + if (theme === "dark") document.documentElement.classList.add("dark") + if (theme === "light") document.documentElement.classList.add("light") + return [custom, theme] +} + +function doReadDarkMode() { + const theme = window.localStorage.getItem("theme") + if (theme === null) return readSystemMode() + if (!["light", "dark"].includes(theme)) return readSystemMode() + return ["user", theme] +} + +function readSystemMode() { + const isDark = window.matchMedia("(prefers-color-scheme: dark)").matches + if (isDark) return ["system", "dark"] + return ["system", "light"] +} + +export function watchIsDark(callback) { + window + .matchMedia("(prefers-color-scheme: dark)") + .addEventListener("change", (event) => { + if (event.matches) return callback("dark") + if (!event.matches) return callback("light") + }) +} diff --git a/frontend/src/hexdocs/setup.gleam b/frontend/src/hexdocs/setup.gleam new file mode 100644 index 0000000..38f8c96 --- /dev/null +++ b/frontend/src/hexdocs/setup.gleam @@ -0,0 +1,49 @@ +import hexdocs/data/model +import hexdocs/data/msg +import hexdocs/effects +import lustre/effect +import modem + +pub fn init(_) { + let modem = modem.init(msg.DocumentChangedLocation) + let packages = effects.packages() + let assert Ok(initial_uri) = modem.initial_uri() + let dark_mode = get_dark_mode() + let model = model.new(dark_mode) + let #(model, route) = model.update_route(model, initial_uri) + let watch = watch_color_theme() + #(model, effect.batch([packages, modem, route, watch])) +} + +fn get_dark_mode() { + let #(defined, dark_mode) = read_dark_mode() + let dark_mode = to_dark_mode(dark_mode) + case defined { + "user" -> msg.User(dark_mode) + "system" -> msg.System(dark_mode) + _ -> panic as "Unrecognized settings" + } +} + +fn watch_color_theme() { + use dispatch <- effect.from + use color_mode <- watch_is_dark + color_mode + |> to_dark_mode + |> msg.DocumentChangedTheme + |> dispatch +} + +@external(javascript, "./setup.ffi.mjs", "readDarkMode") +fn read_dark_mode() -> #(String, String) + +@external(javascript, "./setup.ffi.mjs", "watchIsDark") +fn watch_is_dark(callback: fn(String) -> Nil) -> Nil + +fn to_dark_mode(value: String) -> msg.ColorMode { + case value { + "dark" -> msg.Dark + "light" -> msg.Light + _ -> panic as "Unrecognized color mode" + } +} diff --git a/frontend/src/hexdocs/view/home.gleam b/frontend/src/hexdocs/view/home.gleam new file mode 100644 index 0000000..6c77f9a --- /dev/null +++ b/frontend/src/hexdocs/view/home.gleam @@ -0,0 +1,315 @@ +import gleam/bool +import gleam/dynamic/decode +import gleam/list +import gleam/option.{None, Some} +import gleam/string +import hexdocs/data/model.{type Model} +import hexdocs/data/model/autocomplete +import hexdocs/data/msg +import hexdocs/view/home/footer +import lustre/attribute.{class, id} +import lustre/element +import lustre/element/html +import lustre/event + +pub fn home(model: Model) { + // let go_back = event.on_click(msg.UserClickedGoBack) + let toggle_mode = event.on_click(msg.UserToggledDarkMode) + html.div([class("bg-white dark:bg-gray-900")], [ + html.div( + [ + class("flex flex-col"), + class("min-h-screen max-w-8xl"), + class("mx-auto"), + class("dark:text-gray-50"), + class("transition-colors duration-200"), + class("px-4 lg:px-0"), + ], + [ + html.main([class("flex-grow")], [ + html.section([class("sm:py-8 ly:py-10")], [ + html.div([id("nav"), class("flex justify-between items-center")], [ + html.a( + [ + attribute.href("#"), + class("text-sm text-gray-600 dark:text-gray-100 mt-10"), + ], + [html.text("← Go back to Hex")], + ), + html.button( + [ + toggle_mode, + class("p-3 text-gray-700 dark:text-gray-100 mt-10"), + ], + [html.i([class("theme-icon text-xl")], [])], + ), + ]), + html.div([class("flex flex-col justify-around mt-14 lg:mt-40")], [ + html.div( + [id("logo"), class("flex items-center justify-start gap-6")], + [ + html.img([ + attribute.src("/images/hexdocs-logo.svg"), + attribute.alt("HexDocs Logo"), + class("w-auto h-14 lg:w-auto lg:h-24"), + ]), + html.h1( + [ + class( + "text-gray-700 dark:text-gray-700 text-5xl lg:text-7xl font-(family-name:--font-calibri)", + ), + ], + [ + html.span([class("font-semibold")], [html.text("hex")]), + html.span([class("font-light")], [html.text("docs")]), + ], + ), + ], + ), + html.form( + [ + event.on_submit(fn(_) { msg.UserSubmittedSearch }) + |> event.prevent_default + |> event.stop_propagation, + id("search"), + class( + "flex flex-col lg:flex-row items-center gap-4 mt-10 lg:mt-20", + ), + ], + [ + html.div([class("relative max-w-lg w-full")], [ + html.input([ + attribute.value(model.home_input_displayed), + event.on_input(msg.UserEditedSearch), + event.on_click(msg.None) |> event.stop_propagation, + event.on_focus(msg.UserFocusedSearch), + event.advanced("keydown", on_arrow_up_down(model)), + attribute.autofocus(True), + attribute.type_("text"), + class("search-input w-full bg-white dark:bg-gray-800"), + class( + "rounded-lg border border-gray-200 dark:border-gray-700", + ), + class( + "font-(family-name:--font-inter) placeholder:text-gray-400 dark:placeholder:text-gray-400 text-gray-700 dark:text-gray-100", + ), + class("px-10 py-3"), + class( + "focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent", + ), + attribute.placeholder("Search for packages..."), + ]), + html.i( + [ + class( + "ri-search-2-line absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 text-lg", + ), + ], + [], + ), + autocomplete(model), + ]), + html.button( + [ + event.on("click", decode.failure(msg.None, "")) + |> event.stop_propagation, + class( + "px-6 py-3 bg-blue-600 dark:bg-blue-600 text-gray-50 font-(family-name:--font-inter) rounded-lg hover:bg-blue-700 transition duration-200 whitespace-nowrap w-full sm:w-auto", + ), + ], + [html.text("Search Packages")], + ), + ], + ), + html.div([id("how-to"), class("mt-10 lg:mt-32")], [ + html.div([], [ + html.h6( + [ + class( + "text-gray-700 dark:text-gray-100 text-xl font-semibold font-(family-name:--font-inter) leading-loose", + ), + ], + [html.text("To search specific packages")], + ), + html.span( + [ + class( + "text-gray-600 dark:text-gray-200 font-(family-name:--font-inter)", + ), + ], + [html.text("Type ")], + ), + html.span( + [class("bg-black px-0.5 text-gray-50 font-mono rounded")], + [html.text("#")], + ), + html.span( + [ + class( + "text-gray-600 dark:text-gray-200 font-(family-name:--font-inter)", + ), + ], + [ + html.text( + " to scope your search to one or more packages.", + ), + html.br([]), + html.text("Use "), + ], + ), + html.span( + [class("bg-black px-0.5 text-gray-50 font-mono rounded")], + [html.text("#:")], + ), + html.span( + [ + class( + "text-gray-600 dark:text-gray-200 font-(family-name:--font-inter)", + ), + ], + [html.text(" to pick a specific version.")], + ), + ]), + html.div( + [attribute.class("font-(family-name:--font-inter) mt-10")], + [ + html.h6( + [ + attribute.class( + "text-gray-700 dark:text-gray-100 text-xl font-semibold leading-loose", + ), + ], + [ + html.text( + "To access a package documentation + ", + ), + ], + ), + html.span( + [attribute.class("text-gray-600 dark:text-gray-200")], + [html.text("Visit ")], + ), + html.span( + [ + attribute.class( + "text-blue-600 dark:text-blue-600 font-semibold", + ), + ], + [html.text("hexdocs.pm/")], + ), + html.span( + [attribute.class("text-gray-600 dark:text-gray-200")], + [html.text("")], + ), + html.span( + [attribute.class("text-gray-600 dark:text-gray-200")], + [html.text(" or ")], + ), + html.span( + [ + attribute.class( + "text-blue-600 dark:text-blue-600 font-semibold", + ), + ], + [html.text("hexdocs.pm/")], + ), + html.span( + [attribute.class("text-gray-600 dark:text-gray-200")], + [html.text("/")], + ), + ], + ), + ]), + ]), + ]), + ]), + footer.footer(), + ], + ), + ]) +} + +fn on_arrow_up_down(model: Model) { + use key <- decode.field("key", decode.string) + let message = case key, model.autocomplete { + "ArrowDown", _ -> Ok(msg.UserSelectedNextAutocompletePackage) + "ArrowUp", _ -> Ok(msg.UserSelectedPreviousAutocompletePackage) + "Tab", Some(_) -> Ok(msg.UserSubmittedAutocomplete) + // Error case, giving anything to please the decode failure. + _, _ -> Error(msg.None) + } + case message { + Ok(msg) -> + event.handler(msg, stop_propagation: False, prevent_default: True) + Error(msg) -> + event.handler(msg, stop_propagation: False, prevent_default: False) + } + |> decode.success +} + +fn autocomplete(model: Model) { + let no_search = string.is_empty(model.home_input) + let no_autocomplete = option.is_none(model.autocomplete) + use <- bool.lazy_guard( + when: model.autocomplete_search_focused != model.AutocompleteOnHome, + return: element.none, + ) + use <- bool.lazy_guard(when: no_search, return: element.none) + use <- bool.lazy_guard(when: no_autocomplete, return: element.none) + html.div( + [ + class( + "absolute top-14 w-full bg-white dark:bg-gray-800 shadow-md rounded-lg overflow-hidden", + ), + ], + [ + case model.autocomplete { + None -> element.none() + Some(#(type_, autocomplete)) -> { + let items = autocomplete.all(autocomplete) + case list.is_empty(items), type_ { + True, model.Package -> empty_package_autocomplete() + True, model.Version -> empty_versions_autocomplete() + False, _ -> { + html.div([], { + use package <- list.map(items) + let is_selected = + autocomplete.is_selected(autocomplete, package) + let selected = case is_selected { + True -> class("bg-stone-100 dark:bg-stone-600") + False -> attribute.none() + } + let on_click = on_select_package(package) + html.div( + [ + class( + "py-2 px-4 text-md hover:bg-stone-200 dark:hover:bg-stone-800 cursor-pointer", + ), + selected, + on_click, + ], + [html.text(package)], + ) + }) + } + } + } + }, + ], + ) +} + +fn empty_package_autocomplete() { + html.text("No packages found") +} + +fn empty_versions_autocomplete() { + html.text("No versions found") +} + +fn on_select_package(package: String) { + msg.UserClickedAutocompletePackage(package) + |> event.on_click + |> event.stop_propagation +} diff --git a/frontend/src/hexdocs/view/home/footer.gleam b/frontend/src/hexdocs/view/home/footer.gleam new file mode 100644 index 0000000..b378f08 --- /dev/null +++ b/frontend/src/hexdocs/view/home/footer.gleam @@ -0,0 +1,121 @@ +import lustre/attribute as a +import lustre/element/html as h + +pub fn footer() { + h.footer([a.class("mt-16 lg:mt-auto")], [ + h.section([a.class("flex justify-end"), a.id("publishing-docs")], [hint()]), + h.section( + [ + a.class("w-full"), + a.class("border-t"), + a.class("border-gray-200"), + a.class("dark:border-gray-700"), + a.class("flex"), + a.class("flex-col"), + a.class("lg:flex-row"), + a.class("gap-4"), + a.class("lg:gap-0"), + a.class("justify-between"), + a.class("text-sm"), + a.class("px-4"), + a.class("py-4"), + a.id("footer"), + ], + [ + h.div([], [ + h.span([a.class("text-gray-600 dark:text-gray-200")], [ + h.text("Is something wrong? Let us know by "), + ]), + h.span([a.class("text-blue-600 dark:text-blue-600 font-medium")], [ + h.text("Opening an Issue"), + ]), + h.span([a.class("text-gray-600 dark:text-gray-200")], [h.text(" or ")]), + h.span([a.class("text-blue-600 dark:text-blue-600 font-medium")], [ + h.text("Emailing Support"), + ]), + ]), + h.div([a.class("text-gray-600 dark:text-gray-200")], [ + h.span([], [h.text("Search powered by Typesense")]), + ]), + ], + ), + ]) +} + +pub fn hint() { + h.div([a.class("relative w-64 h-72")], [ + h.div( + [ + a.class("absolute"), + a.class("inset-0"), + a.class("bg-gray-50"), + a.class("dark:bg-gray-800"), + a.class("rounded-tl-xl"), + a.class("rounded-tr-xl"), + a.class("z-10"), + ], + [ + h.div( + [ + a.class("w-14"), + a.class("h-14"), + a.class("bg-gray-100"), + a.class("dark:bg-gray-100"), + a.class("rounded-full"), + a.class("flex"), + a.class("items-center"), + a.class("justify-center"), + a.class("m-3"), + ], + [ + h.i( + [ + a.class("ri-contacts-book-upload-line"), + a.class("text-gray-600"), + a.class("dark:text-gray-600"), + a.class("text-xl"), + ], + [], + ), + ], + ), + h.div([a.class("px-4 text-sm mt-4")], [ + h.h6([a.class("text-gray-700 dark:text-gray-100 font-semibold")], [ + h.text("Publishing Documentation"), + ]), + h.p([a.class("leading-tight mt-2")], [ + h.span([a.class("text-gray-500 dark:text-gray-200")], [ + h.text( + "Documentation is automatically published when you publish + your package, you can find more information ", + ), + ]), + h.span([a.class("text-purple-700 font-medium")], [h.text("here")]), + h.span([a.class("text-gray-500 dark:text-gray-200")], [h.text(".")]), + ]), + h.p([a.class("leading-tight mt-4")], [ + h.span([a.class("text-gray-500 dark:text-gray-200")], [ + h.text("Learn how to write documentation "), + ]), + h.span([a.class("text-purple-700 font-medium")], [h.text("here")]), + h.span([a.class("text-gray-500 dark:text-gray-200")], [h.text(".")]), + ]), + ]), + ], + ), + h.div( + [ + a.class("absolute"), + a.class("inset-0"), + a.class("bg-gray-100"), + a.class("dark:bg-gray-700"), + a.class("rotate-6"), + a.class("left-4"), + a.class("rounded-tl-xl"), + a.class("rounded-tr-xl"), + a.class("z-0"), + ], + [], + ), + ]) +} diff --git a/frontend/src/hexdocs/view/search.gleam b/frontend/src/hexdocs/view/search.gleam new file mode 100644 index 0000000..8553152 --- /dev/null +++ b/frontend/src/hexdocs/view/search.gleam @@ -0,0 +1,586 @@ +import gleam/bool +import gleam/dict +import gleam/dynamic/decode +import gleam/list +import gleam/option.{None, Some} +import gleam/string +import hexdocs/components/iframe +import hexdocs/data/model.{type Model} +import hexdocs/data/model/autocomplete +import hexdocs/data/msg +import hexdocs/services/hex +import hexdocs/services/hexdocs +import lustre/attribute.{class} +import lustre/element +import lustre/element/html +import lustre/event + +pub fn search(model: Model) { + element.fragment([ + html.div( + [ + class( + "fixed top-[22px] right-4 z-50 flex-col items-end gap-4 hidden 2xl:flex", + ), + ], + [hexdocs_logo()], + ), + html.div([class("flex flex-col md:flex-row")], [ + html.div( + [ + class( + "md:hidden flex items-center justify-between p-4 bg-slate-100 dark:bg-slate-800", + ), + ], + [ + html.button( + [ + class("p-2"), + event.on_click(msg.UserToggledSidebar), + ], + [ + html.i( + [ + class( + "ri-menu-line text-xl text-slate-700 dark:text-slate-300", + ), + ], + [], + ), + ], + ), + hexdocs_logo(), + html.button([class("p-2"), event.on_click(msg.UserToggledDarkMode)], [ + html.i( + [ + class("theme-icon text-xl text-slate-700 dark:text-slate-300"), + class(case model.dark_mode.mode { + msg.Dark -> "ri-sun-line" + msg.Light -> "ri-moon-line" + }), + ], + [], + ), + ]), + ], + ), + html.div( + [ + class( + "w-80 h-screen bg-slate-100 dark:bg-slate-800 fixed md:static z-40 -translate-x-full md:translate-x-0 transition-transform duration-300 ease-in-out top-0", + ), + class(case model.sidebar_opened { + True -> "translate-x-0" + False -> "-translate-x-full" + }), + event.on_click(msg.None) |> event.stop_propagation, + attribute.id("sidebar"), + ], + [ + html.div([class("p-5")], [ + html.div([class("flex justify-between items-center mt-2")], [ + html.h2( + [ + class( + "text-slate-950 dark:text-slate-50 text-lg font-medium leading-7", + ), + ], + [html.text("Selected Packages")], + ), + html.button( + [ + class("md:hidden p-2"), + event.on_click(msg.UserToggledSidebar), + ], + [ + html.i( + [ + class( + "ri-close-line text-xl text-slate-700 dark:text-slate-300", + ), + ], + [], + ), + ], + ), + ]), + html.form( + [event.on_submit(fn(_) { msg.UserSubmittedPackagesFilter })], + [ + html.div([class("mt-4 flex gap-2")], [ + html.div( + [ + class( + "flex-grow bg-slate-100 dark:bg-slate-700 rounded-lg border border-slate-300 dark:border-slate-600 relative", + ), + ], + [ + html.input([ + attribute.id("search-package-input"), + class( + "search-input w-full h-10 bg-transparent px-10 text-slate-800 dark:text-slate-200 text-sm focus:outline-none focus:ring-1 focus:ring-blue-500", + ), + attribute.placeholder("Package Name"), + attribute.type_("text"), + attribute.value( + model.search_packages_filter_input_displayed, + ), + event.on_input(msg.UserEditedPackagesFilterInput), + event.on_focus(msg.UserFocusedPackagesFilterInput), + event.on_click(msg.None) |> event.stop_propagation, + event.advanced( + "keydown", + on_arrow_up_down(model.Package), + ), + ]), + html.i( + [ + class( + "ri-search-2-line absolute left-3 top-1/2 transform -translate-y-1/2 text-slate-500 dark:text-slate-400 text-lg", + ), + ], + [], + ), + autocomplete( + model, + model.Package, + model.AutocompleteOnPackage, + ), + ], + ), + html.div( + [ + class( + "w-20 bg-slate-100 dark:bg-slate-700 rounded-lg border border-slate-300 dark:border-slate-600 relative", + ), + ], + [ + html.input([ + attribute.id("search-version-input"), + class( + "search-input w-full h-10 bg-transparent px-2 text-slate-800 dark:text-slate-200 text-sm focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-[0.2]", + ), + attribute.placeholder("ver"), + attribute.type_("text"), + attribute.value( + model.search_packages_filter_version_input_displayed, + ), + attribute.disabled( + !list.contains( + model.packages, + model.search_packages_filter_input_displayed, + ), + ), + event.on_input(msg.UserEditedPackagesFilterVersion), + event.on_focus(msg.UserFocusedPackagesFilterVersion), + event.on_click(msg.None) |> event.stop_propagation, + event.advanced( + "keydown", + on_arrow_up_down(model.Version), + ), + ]), + autocomplete( + model, + model.Version, + model.AutocompleteOnVersion, + ), + ], + ), + ]), + html.div([class("mt-4 flex gap-2")], [ + html.button( + [ + attribute.type_("submit"), + class( + "flex-grow bg-blue-600 hover:bg-blue-700 text-slate-100 rounded-lg h-10 flex items-center justify-center transition duration-200", + ), + ], + [ + html.span([class("text-sm font-medium")], [ + html.text("+ Add Package"), + ]), + ], + ), + html.button( + [ + event.on_click(msg.UserClickedShare), + class( + "w-10 h-10 bg-slate-100 dark:bg-slate-700 rounded-lg border border-slate-300 dark:border-slate-600 flex items-center justify-center cursor-pointer", + ), + ], + [ + html.i( + [ + class( + "ri-share-forward-line text-slate-500 dark:text-slate-400 text-lg", + ), + ], + [], + ), + ], + ), + ]), + ], + ), + html.hr([class("mt-6 border-slate-200 dark:border-slate-700")]), + case list.is_empty(model.search_packages_filters) { + True -> { + html.div([class("text-slate-950 dark:text-slate-50 pt-4")], [ + html.text("No package selected, searching all packages"), + ]) + } + False -> { + element.fragment({ + use filter <- list.map(model.search_packages_filters) + let #(package, version) = filter + html.div([class("flex justify-between items-center mt-4")], [ + html.div( + [class("inline-flex flex-col justify-start items-start")], + [ + html.div( + [ + class( + "self-stretch justify-start text-gray-950 dark:text-slate-50 text-lg font-semibold leading-none", + ), + ], + [html.text(package)], + ), + html.div( + [ + class( + "self-stretch justify-start text-slate-700 dark:text-slate-400 text-sm font-normal leading-none", + ), + ], + [html.text(version)], + ), + ], + ), + trash_button(filter), + ]) + }) + } + }, + ]), + ], + ), + html.div([class("flex-1 md:ml-0 mt-0 md:mt-0")], [ + html.div([class("p-5 flex flex-col items-center")], [ + html.div([class("w-full max-w-[800px] flex items-center gap-3")], [ + html.div([class("relative flex-1")], [ + html.input([ + attribute.value(model.search_input), + event.on_input(msg.UserEditedSearchInput), + event.on("keydown", { + use key <- decode.field("key", decode.string) + case key { + "Enter" -> decode.success(msg.UserSubmittedSearchInput) + _ -> decode.failure(msg.UserSubmittedSearchInput, "Key") + } + }), + attribute.placeholder("Search for packages..."), + class( + "search-input w-full h-10 bg-indigo-50 dark:bg-slate-800 rounded-lg border border-blue-500 dark:border-blue-600 pl-10 pr-4 text-slate-950 dark:text-slate-50 focus:outline-none focus:ring-1 focus:ring-blue-500", + ), + attribute.type_("text"), + ]), + html.i( + [ + class( + "ri-search-2-line absolute left-4 top-1/2 transform -translate-y-1/2 text-slate-950 dark:text-slate-400", + ), + ], + [], + ), + ]), + // html.i( + // [ + // class( + // "ri-settings-4-line text-xl text-slate-700 dark:text-slate-300", + // ), + // ], + // [], + // ), + html.button( + [class("p-2"), event.on_click(msg.UserToggledDarkMode)], + [ + html.i( + [ + class( + "theme-icon text-xl text-slate-700 dark:text-slate-300", + ), + class(case model.dark_mode.mode { + msg.Dark -> "ri-sun-line" + msg.Light -> "ri-moon-line" + }), + ], + [], + ), + ], + ), + ]), + ]), + html.div([class("px-5 flex flex-col items-center")], [ + html.div([class("space-y-6 w-full max-w-[800px]")], { + let results = option.unwrap(model.search_result, #(0, [])) + use result <- list.map(results.1) + result_card(model, result) + }), + ]), + ]), + ]), + ]) +} + +fn on_arrow_up_down(type_: model.Type) { + use key <- decode.field("key", decode.string) + let message = case key, type_ { + "ArrowDown", _ -> Ok(msg.UserSelectedNextAutocompletePackage) + "ArrowUp", _ -> Ok(msg.UserSelectedPreviousAutocompletePackage) + "Enter", model.Package -> Ok(msg.UserSelectedPackageFilter) + "Enter", model.Version -> Ok(msg.UserSelectedPackageFilterVersion) + // Error case, giving anything to please the decode failure. + _, _ -> Error(msg.None) + } + case message { + Ok(msg) -> + event.handler(msg, stop_propagation: False, prevent_default: True) + Error(msg) -> + event.handler(msg, stop_propagation: False, prevent_default: False) + } + |> decode.success +} + +fn autocomplete( + model: Model, + type_: model.Type, + opened: model.AutocompleteFocused, +) -> element.Element(msg.Msg) { + let no_search = case type_ { + model.Package -> string.is_empty(model.search_packages_filter_input) + model.Version -> False + } + let no_autocomplete = option.is_none(model.autocomplete) + use <- bool.lazy_guard( + when: model.autocomplete_search_focused != opened, + return: element.none, + ) + use <- bool.lazy_guard(when: no_search, return: element.none) + use <- bool.lazy_guard(when: no_autocomplete, return: element.none) + html.div( + [ + class( + "absolute top-14 w-full bg-white dark:bg-gray-800 shadow-md rounded-lg overflow-hidden", + ), + ], + [ + case model.autocomplete { + None -> element.none() + Some(#(_type_, autocomplete)) -> { + let items = autocomplete.all(autocomplete) + let is_empty = list.is_empty(items) + use <- bool.lazy_guard(when: is_empty, return: empty_autocomplete) + html.div([], { + use package <- list.map(items) + let is_selected = autocomplete.is_selected(autocomplete, package) + let selected = case is_selected { + True -> class("bg-stone-100 dark:bg-stone-600") + False -> attribute.none() + } + let on_click = on_select_package(package) + html.div( + [ + class( + "py-2 px-4 text-md hover:bg-stone-200 dark:hover:bg-stone-800 cursor-pointer", + ), + selected, + on_click, + ], + [html.text(package)], + ) + }) + } + }, + ], + ) +} + +fn empty_autocomplete() { + html.text("No packages found") +} + +fn on_select_package(package: String) { + msg.UserClickedAutocompletePackage(package) + |> event.on_click + |> event.stop_propagation +} + +fn hexdocs_logo() { + html.a([class("flex items-center gap-2"), attribute.href("/")], [ + html.img([ + class("w-auto h-10"), + attribute.alt("HexDocs Logo"), + attribute.src("/images/hexdocs-logo.svg"), + ]), + html.div([class("flex items-center")], [ + html.span( + [ + class( + "text-slate-950 text-lg font-bold font-(family-name:--font-calibri)", + ), + ], + [html.text("hex")], + ), + html.span( + [ + class("text-slate-950 text-lg font-(family-name:--font-calibri)"), + ], + [html.text("docs")], + ), + ]), + ]) +} + +fn trash_button(filter: #(String, String)) { + let on_delete = event.on_click(msg.UserDeletedPackagesFilter(filter)) + html.div( + [class("w-5 h-5 relative overflow-hidden cursor-pointer"), on_delete], + [ + sidebar_icon("ri-delete-bin-5-fill"), + ], + ) +} + +fn result_card(model: Model, result: hexdocs.TypeSense) { + html.div([class("w-full bg-slate-100 dark:bg-slate-800 rounded-2xl p-4")], [ + html.div([class("text-slate-700 dark:text-slate-300 text-sm")], [ + html.text(result.document.package), + ]), + html.h3( + [ + class( + "text-slate-950 dark:text-slate-50 text-xl font-semibold leading-loose mt-1", + ), + ], + [html.text(result.document.title)], + ), + // element.unsafe_raw_html( + // "", + // "p", + // [ + // class( + // "mt-4 text-slate-800 dark:text-slate-300 leading-normal line-clamp-2 overflow-hidden", + // ), + // ], + // result.document.doc, + // ), + html.div( + [ + class( + "mt-2 inline-flex px-3 py-0.5 bg-slate-300 dark:bg-slate-700 rounded-full", + ), + ], + [ + html.span([class("text-blue-600 dark:text-blue-400 text-sm")], [ + html.text(result.document.ref), + ]), + ], + ), + case result.highlight { + hexdocs.Highlights(doc: Some(doc), ..) -> { + element.unsafe_raw_html( + "", + "p", + [ + class( + "mt-4 text-slate-800 dark:text-slate-300 leading-normal line-clamp-2 overflow-hidden", + ), + ], + doc.snippet, + ) + // html.text("Channels are a really good abstraction"), + // html.span( + // [class("bg-slate-950 text-slate-100 px-1 rounded")], + // [html.text("for")], + // ), + // html.text( + // "real-time communication. They are bi-directional and persistent connections between the browser and server...", + // ) + } + _ -> element.none() + }, + html.div([class("mt-4 flex flex-wrap gap-3")], [ + html.button( + [ + event.on_click(msg.UserToggledPreview(result.document.id)), + class( + "h-10 px-4 py-2.5 bg-slate-100 dark:bg-slate-700 rounded-lg border border-slate-300 dark:border-slate-600 flex items-center justify-center", + ), + ], + [ + html.span( + [class("text-slate-800 dark:text-slate-200 text-sm font-semibold")], + [html.text("Show Preview")], + ), + card_icon("ri-arrow-down-s-line"), + ], + ), + case hex.go_to_link(result.document) { + Error(_) -> element.none() + Ok(link) -> + html.a( + [ + attribute.href(link), + class( + "h-10 px-4 py-2.5 bg-slate-100 dark:bg-slate-700 rounded-lg border border-slate-300 dark:border-slate-600 flex items-center justify-center", + ), + ], + [ + html.span( + [ + class( + "text-slate-800 dark:text-slate-200 text-sm font-semibold", + ), + ], + [html.text("Go to Page")], + ), + card_icon("ri-external-link-line"), + ], + ) + }, + ]), + case dict.get(model.search_opened_previews, result.document.id) { + Ok(False) | Error(_) -> element.none() + Ok(True) -> { + case + hex.preview_link(result.document, case model.dark_mode.mode { + msg.Dark -> "dark" + msg.Light -> "light" + }) + { + Error(_) -> element.none() + Ok(link) -> { + html.div([class("h-100 pt-4")], [ + iframe.iframe([ + class("rounded-lg shadow-sm"), + iframe.to(link), + iframe.title(result.document.package), + ]), + ]) + } + } + } + }, + ]) +} + +fn sidebar_icon(icon: String) { + let icon = class(icon) + let default = class("text-slate-400 dark:text-slate-500") + html.i([icon, default], []) +} + +fn card_icon(icon: String) { + let icon = class(icon) + let default = class("ml-2 text-slate-500 dark:text-slate-400") + html.i([icon, default], []) +} diff --git a/frontend/test/hexdocs_test.gleam b/frontend/test/hexdocs_test.gleam new file mode 100644 index 0000000..3831e7a --- /dev/null +++ b/frontend/test/hexdocs_test.gleam @@ -0,0 +1,12 @@ +import gleeunit +import gleeunit/should + +pub fn main() { + gleeunit.main() +} + +// gleeunit test functions end in `_test` +pub fn hello_world_test() { + 1 + |> should.equal(1) +}