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)
+}