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 0000000..4826a08 Binary files /dev/null and b/frontend/assets/favicon/apple-touch-icon.png differ diff --git a/frontend/assets/favicon/favicon-96x96.png b/frontend/assets/favicon/favicon-96x96.png new file mode 100644 index 0000000..e035d52 Binary files /dev/null and b/frontend/assets/favicon/favicon-96x96.png differ diff --git a/frontend/assets/favicon/favicon.ico b/frontend/assets/favicon/favicon.ico new file mode 100644 index 0000000..f6ef040 Binary files /dev/null and b/frontend/assets/favicon/favicon.ico differ diff --git a/frontend/assets/favicon/favicon.svg b/frontend/assets/favicon/favicon.svg new file mode 100644 index 0000000..36db2b1 --- /dev/null +++ b/frontend/assets/favicon/favicon.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + \ 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 0000000..5d384fb Binary files /dev/null and b/frontend/assets/favicon/web-app-manifest-192x192.png differ diff --git a/frontend/assets/favicon/web-app-manifest-512x512.png b/frontend/assets/favicon/web-app-manifest-512x512.png new file mode 100644 index 0000000..9a7caef Binary files /dev/null and b/frontend/assets/favicon/web-app-manifest-512x512.png differ diff --git a/frontend/assets/images/hexdocs-logo.svg b/frontend/assets/images/hexdocs-logo.svg new file mode 100644 index 0000000..0aea3ad --- /dev/null +++ b/frontend/assets/images/hexdocs-logo.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + 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) +}