From 7e95b5ada6a1bffe321cffe2ecf28eb95f124c6e Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Sun, 5 Apr 2026 12:19:13 -0400 Subject: [PATCH 01/32] Upgrade Vite 8, Vitest 4.1, React 19.2, ReScript 12.2, React Router 7.14, tsdown 0.21 Vite ecosystem: - vite: ^7.0.6 -> ^8.0.3 - @vitejs/plugin-react: ^4.7.0 -> ^6.0.1 - @tailwindcss/vite: ^4.1.13 -> ^4.2.2 - vite-plugin-page-reload: ^0.2.2 -> ^0.2.3 Vitest ecosystem: - vitest: ^4.0.18 -> ^4.1.2 - @vitest/browser-playwright: ^4.0.18 -> ^4.1.2 - vitest-browser-react: ^2.0.5 -> ^2.2.0 React: - react: ^19.1.0 -> ^19.2.4 - react-dom: ^19.1.0 -> ^19.2.4 - @types/react: ^19.2.2 -> ^19.2.14 ReScript: - rescript: ^12.0.0 -> ^12.2.0 - @rescript/react: ^0.14.0 -> ^0.14.2 - Migrate Exn.Error -> JsExn (deprecated in 12.2) React Router: - react-router: ^7.12.0 -> ^7.14.0 - react-router-dom: ^7.9.4 -> ^7.14.0 - @react-router/node: ^7.8.1 -> ^7.14.0 - @react-router/dev: ^7.8.1 -> ^7.14.0 tsdown: 0.20.0 -> 0.21.7 (in build:scripts) --- app/routes.res | 12 +- package.json | 34 +- src/MdxFile.res | 19 +- src/common/HighlightJs.res | 2 +- src/components/Search.res | 2 +- yarn.lock | 1650 +++++++++++++++++------------------- 6 files changed, 821 insertions(+), 898 deletions(-) diff --git a/app/routes.res b/app/routes.res index 83637584b..6f5b9b8fc 100644 --- a/app/routes.res +++ b/app/routes.res @@ -33,13 +33,13 @@ let blogArticleRoutes = route(path, "./routes/BlogArticleRoute.jsx", ~options={id: path}) ) -let mdxRoutes = - mdxRoutes("./routes/MdxRoute.jsx")->Array.filter(r => - !(r.path - ->Option.map(path => path === "blog" || String.startsWith(path, "blog/")) - ->Option.getOr(false) - ) +let mdxRoutes = mdxRoutes("./routes/MdxRoute.jsx")->Array.filter(r => + !( + r.path + ->Option.map(path => path === "blog" || String.startsWith(path, "blog/")) + ->Option.getOr(false) ) +) let default = [ index("./routes/LandingPageRoute.jsx"), diff --git a/package.json b/package.json index 8f43a6852..73ee8dc59 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "packageManager": "yarn@4.12.0", "type": "module", "scripts": { - "build:scripts": "yarn dlx tsdown@0.20.0 scripts/*.jsx -d _scripts --no-clean --ext .mjs", + "build:scripts": "yarn dlx tsdown@0.21.7 scripts/*.jsx -d _scripts --no-clean --ext .mjs", "build:generate-llms": "node _scripts/generate_llms.mjs", "build:res": "rescript build --warn-error +3+8+11+12+26+27+31+32+33+34+35+39+44+45+110", "build:sync-bundles": "node scripts/sync-playground-bundles.mjs", @@ -50,9 +50,9 @@ "@lezer/highlight": "^1.2.1", "@mdx-js/mdx": "^3.1.1", "@node-cli/static-server": "^3.1.4", - "@react-router/node": "^7.8.1", + "@react-router/node": "^7.14.0", "@replit/codemirror-vim": "^6.3.0", - "@rescript/react": "^0.14.0", + "@rescript/react": "^0.14.2", "@rescript/webapi": "0.1.0-experimental-29db5f4", "@tsnobip/rescript-lezer": "^0.8.0", "docson": "^2.1.0", @@ -65,11 +65,11 @@ "mdast-util-from-markdown": "^2.0.2", "mdast-util-to-string": "^4.0.0", "mdast-util-toc": "^7.1.0", - "react": "^19.1.0", - "react-dom": "^19.1.0", + "react": "^19.2.4", + "react-dom": "^19.2.4", "react-markdown": "^10.1.0", - "react-router": "^7.12.0", - "react-router-dom": "^7.9.4", + "react-router": "^7.14.0", + "react-router-dom": "^7.14.0", "react-router-mdx": "patch:react-router-mdx@npm%3A1.0.8#~/.yarn/patches/react-router-mdx-npm-1.0.8-d4402c3003.patch", "rehype-slug": "^6.0.0", "rehype-stringify": "^10.0.1", @@ -78,17 +78,17 @@ "remark-frontmatter": "^5.0.0", "remark-gfm": "^4.0.1", "remark-validate-links": "^13.1.0", - "rescript": "^12.0.0", + "rescript": "^12.2.0", "unified": "^11.0.5", "vfile-matter": "^5.0.0" }, "devDependencies": { "@prettier/plugin-oxc": "^0.0.4", - "@react-router/dev": "^7.8.1", - "@tailwindcss/vite": "^4.1.13", - "@types/react": "^19.2.2", - "@vitejs/plugin-react": "^4.7.0", - "@vitest/browser-playwright": "^4.0.18", + "@react-router/dev": "^7.14.0", + "@tailwindcss/vite": "^4.2.2", + "@types/react": "^19.2.14", + "@vitejs/plugin-react": "^6.0.1", + "@vitest/browser-playwright": "^4.1.2", "auto-image-converter": "^2.1.2", "chokidar": "^4.0.3", "dotenv": "^16.4.7", @@ -102,12 +102,12 @@ "tailwindcss": "^4", "to-vfile": "^8.0.0", "vfile-reporter": "^8.1.1", - "vite": "^7.0.6", + "vite": "^8.0.3", "vite-plugin-devtools-json": "^1.0.0", "vite-plugin-env-compatible": "^2.0.1", - "vite-plugin-page-reload": "^0.2.2", - "vitest": "^4.0.18", - "vitest-browser-react": "^2.0.5", + "vite-plugin-page-reload": "^0.2.3", + "vitest": "^4.1.2", + "vitest-browser-react": "^2.2.0", "wrangler": "^4.63.0" } } diff --git a/src/MdxFile.res b/src/MdxFile.res index a9bbf9d9f..0366cf8b3 100644 --- a/src/MdxFile.res +++ b/src/MdxFile.res @@ -30,16 +30,15 @@ let resolveFilePath = (pathname, ~dir, ~alias) => { } else { pathname } - let relativePath = - if path->String.startsWith(alias ++ "/") { - let rest = path->String.slice(~start=String.length(alias) + 1, ~end=String.length(path)) - Node.Path.join2(dir, rest) - } else if path->String.startsWith(alias) { - let rest = path->String.slice(~start=String.length(alias), ~end=String.length(path)) - Node.Path.join2(dir, rest) - } else { - path - } + let relativePath = if path->String.startsWith(alias ++ "/") { + let rest = path->String.slice(~start=String.length(alias) + 1, ~end=String.length(path)) + Node.Path.join2(dir, rest) + } else if path->String.startsWith(alias) { + let rest = path->String.slice(~start=String.length(alias), ~end=String.length(path)) + Node.Path.join2(dir, rest) + } else { + path + } relativePath ++ ".mdx" } diff --git a/src/common/HighlightJs.res b/src/common/HighlightJs.res index ecc9bfd0d..f22311a55 100644 --- a/src/common/HighlightJs.res +++ b/src/common/HighlightJs.res @@ -10,7 +10,7 @@ let renderHLJS = (~highlightedLines=[], ~darkmode=false, ~code: string, ~lang: s // If the language couldn't be parsed, we will fall back to text let options = {language: lang} let (lang, highlighted) = try (lang, highlight(~code, ~options)->valueGet) catch { - | Exn.Error(_) => ("text", code) + | JsExn(_) => ("text", code) } // Add line highlighting as well diff --git a/src/components/Search.res b/src/components/Search.res index 452e7cf31..b9ccbb103 100644 --- a/src/components/Search.res +++ b/src/components/Search.res @@ -51,7 +51,7 @@ let transformItems = (items: DocSearch.transformItems) => { items ->Array.filterMap(item => { let url = try WebAPI.URL.make(~url=item.url)->Some catch { - | Exn.Error(obj) => + | JsExn(obj) => Console.error2(`Failed to parse URL ${item.url}`, obj) None } diff --git a/yarn.lock b/yarn.lock index 6ccb0d181..bdd5ac415 100644 --- a/yarn.lock +++ b/yarn.lock @@ -274,7 +274,7 @@ __metadata: languageName: node linkType: hard -"@babel/core@npm:^7.23.7, @babel/core@npm:^7.27.7, @babel/core@npm:^7.28.0": +"@babel/core@npm:^7.23.7, @babel/core@npm:^7.27.7": version: 7.28.5 resolution: "@babel/core@npm:7.28.5" dependencies: @@ -459,7 +459,7 @@ __metadata: languageName: node linkType: hard -"@babel/parser@npm:^7.1.0, @babel/parser@npm:^7.20.7, @babel/parser@npm:^7.23.6, @babel/parser@npm:^7.24.7, @babel/parser@npm:^7.27.2, @babel/parser@npm:^7.27.7, @babel/parser@npm:^7.28.5": +"@babel/parser@npm:^7.23.6, @babel/parser@npm:^7.24.7, @babel/parser@npm:^7.27.2, @babel/parser@npm:^7.27.7, @babel/parser@npm:^7.28.5": version: 7.28.5 resolution: "@babel/parser@npm:7.28.5" dependencies: @@ -504,28 +504,6 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-react-jsx-self@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/plugin-transform-react-jsx-self@npm:7.27.1" - dependencies: - "@babel/helper-plugin-utils": "npm:^7.27.1" - peerDependencies: - "@babel/core": ^7.0.0-0 - checksum: 10c0/00a4f917b70a608f9aca2fb39aabe04a60aa33165a7e0105fd44b3a8531630eb85bf5572e9f242f51e6ad2fa38c2e7e780902176c863556c58b5ba6f6e164031 - languageName: node - linkType: hard - -"@babel/plugin-transform-react-jsx-source@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/plugin-transform-react-jsx-source@npm:7.27.1" - dependencies: - "@babel/helper-plugin-utils": "npm:^7.27.1" - peerDependencies: - "@babel/core": ^7.0.0-0 - checksum: 10c0/5e67b56c39c4d03e59e03ba80692b24c5a921472079b63af711b1d250fc37c1733a17069b63537f750f3e937ec44a42b1ee6a46cd23b1a0df5163b17f741f7f2 - languageName: node - linkType: hard - "@babel/plugin-transform-typescript@npm:^7.28.5": version: 7.28.5 resolution: "@babel/plugin-transform-typescript@npm:7.28.5" @@ -582,7 +560,7 @@ __metadata: languageName: node linkType: hard -"@babel/types@npm:^7.0.0, @babel/types@npm:^7.20.7, @babel/types@npm:^7.23.6, @babel/types@npm:^7.27.1, @babel/types@npm:^7.27.3, @babel/types@npm:^7.27.7, @babel/types@npm:^7.28.2, @babel/types@npm:^7.28.4, @babel/types@npm:^7.28.5": +"@babel/types@npm:^7.23.6, @babel/types@npm:^7.27.1, @babel/types@npm:^7.27.3, @babel/types@npm:^7.27.7, @babel/types@npm:^7.28.4, @babel/types@npm:^7.28.5": version: 7.28.5 resolution: "@babel/types@npm:7.28.5" dependencies: @@ -592,6 +570,13 @@ __metadata: languageName: node linkType: hard +"@blazediff/core@npm:1.9.1": + version: 1.9.1 + resolution: "@blazediff/core@npm:1.9.1" + checksum: 10c0/fd45cdd0544002341d74831a179ef693a81414abd348c1ff0c01086c0ea03f5e5ee284c4e16c2e6fb3670c265f90a3d85752b9360320efa9a835928e604dae77 + languageName: node + linkType: hard + "@cloudflare/kv-asset-handler@npm:0.4.2": version: 0.4.2 resolution: "@cloudflare/kv-asset-handler@npm:0.4.2" @@ -867,7 +852,7 @@ __metadata: languageName: node linkType: hard -"@emnapi/core@npm:^1.4.3, @emnapi/core@npm:^1.5.0, @emnapi/core@npm:^1.6.0": +"@emnapi/core@npm:^1.4.3": version: 1.7.1 resolution: "@emnapi/core@npm:1.7.1" dependencies: @@ -877,7 +862,17 @@ __metadata: languageName: node linkType: hard -"@emnapi/runtime@npm:^1.4.3, @emnapi/runtime@npm:^1.5.0, @emnapi/runtime@npm:^1.6.0, @emnapi/runtime@npm:^1.7.0": +"@emnapi/core@npm:^1.8.1": + version: 1.9.2 + resolution: "@emnapi/core@npm:1.9.2" + dependencies: + "@emnapi/wasi-threads": "npm:1.2.1" + tslib: "npm:^2.4.0" + checksum: 10c0/5500393f953951bad0768fafaa9191f2d938956b20c6d6a79e5ab696a613a25ce6ad23422bc18e86e6ce8deb147619d8d0d7d413a69f84adc01a6633cc353cd9 + languageName: node + linkType: hard + +"@emnapi/runtime@npm:^1.4.3, @emnapi/runtime@npm:^1.7.0": version: 1.7.1 resolution: "@emnapi/runtime@npm:1.7.1" dependencies: @@ -886,6 +881,15 @@ __metadata: languageName: node linkType: hard +"@emnapi/runtime@npm:^1.8.1": + version: 1.9.2 + resolution: "@emnapi/runtime@npm:1.9.2" + dependencies: + tslib: "npm:^2.4.0" + checksum: 10c0/61c3a59e0c36784558b8d58eb02bd04815aa5fb0dbfbaf84d1b3050a78aa0cc63ea129ae806bd1e48062bfeb7fc36eb0e5431740d62f64ea51bdf426404b8caa + languageName: node + linkType: hard + "@emnapi/wasi-threads@npm:1.1.0, @emnapi/wasi-threads@npm:^1.1.0": version: 1.1.0 resolution: "@emnapi/wasi-threads@npm:1.1.0" @@ -895,6 +899,15 @@ __metadata: languageName: node linkType: hard +"@emnapi/wasi-threads@npm:1.2.1": + version: 1.2.1 + resolution: "@emnapi/wasi-threads@npm:1.2.1" + dependencies: + tslib: "npm:^2.4.0" + checksum: 10c0/32fcfa81ab396533b2ec1f4082b1ff779a05d9c836bbbd3f4398405b0e6814c0d9503b7993130e37bc6941dbc1ded49f55e9700ae9ca4e803bab2b5bc5deb331 + languageName: node + linkType: hard + "@esbuild/aix-ppc64@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/aix-ppc64@npm:0.25.12" @@ -909,13 +922,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/aix-ppc64@npm:0.27.3": - version: 0.27.3 - resolution: "@esbuild/aix-ppc64@npm:0.27.3" - conditions: os=aix & cpu=ppc64 - languageName: node - linkType: hard - "@esbuild/android-arm64@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/android-arm64@npm:0.25.12" @@ -930,13 +936,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/android-arm64@npm:0.27.3": - version: 0.27.3 - resolution: "@esbuild/android-arm64@npm:0.27.3" - conditions: os=android & cpu=arm64 - languageName: node - linkType: hard - "@esbuild/android-arm@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/android-arm@npm:0.25.12" @@ -951,13 +950,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/android-arm@npm:0.27.3": - version: 0.27.3 - resolution: "@esbuild/android-arm@npm:0.27.3" - conditions: os=android & cpu=arm - languageName: node - linkType: hard - "@esbuild/android-x64@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/android-x64@npm:0.25.12" @@ -972,13 +964,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/android-x64@npm:0.27.3": - version: 0.27.3 - resolution: "@esbuild/android-x64@npm:0.27.3" - conditions: os=android & cpu=x64 - languageName: node - linkType: hard - "@esbuild/darwin-arm64@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/darwin-arm64@npm:0.25.12" @@ -993,13 +978,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/darwin-arm64@npm:0.27.3": - version: 0.27.3 - resolution: "@esbuild/darwin-arm64@npm:0.27.3" - conditions: os=darwin & cpu=arm64 - languageName: node - linkType: hard - "@esbuild/darwin-x64@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/darwin-x64@npm:0.25.12" @@ -1014,13 +992,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/darwin-x64@npm:0.27.3": - version: 0.27.3 - resolution: "@esbuild/darwin-x64@npm:0.27.3" - conditions: os=darwin & cpu=x64 - languageName: node - linkType: hard - "@esbuild/freebsd-arm64@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/freebsd-arm64@npm:0.25.12" @@ -1035,13 +1006,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/freebsd-arm64@npm:0.27.3": - version: 0.27.3 - resolution: "@esbuild/freebsd-arm64@npm:0.27.3" - conditions: os=freebsd & cpu=arm64 - languageName: node - linkType: hard - "@esbuild/freebsd-x64@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/freebsd-x64@npm:0.25.12" @@ -1056,13 +1020,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/freebsd-x64@npm:0.27.3": - version: 0.27.3 - resolution: "@esbuild/freebsd-x64@npm:0.27.3" - conditions: os=freebsd & cpu=x64 - languageName: node - linkType: hard - "@esbuild/linux-arm64@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/linux-arm64@npm:0.25.12" @@ -1077,13 +1034,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-arm64@npm:0.27.3": - version: 0.27.3 - resolution: "@esbuild/linux-arm64@npm:0.27.3" - conditions: os=linux & cpu=arm64 - languageName: node - linkType: hard - "@esbuild/linux-arm@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/linux-arm@npm:0.25.12" @@ -1098,13 +1048,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-arm@npm:0.27.3": - version: 0.27.3 - resolution: "@esbuild/linux-arm@npm:0.27.3" - conditions: os=linux & cpu=arm - languageName: node - linkType: hard - "@esbuild/linux-ia32@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/linux-ia32@npm:0.25.12" @@ -1119,13 +1062,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-ia32@npm:0.27.3": - version: 0.27.3 - resolution: "@esbuild/linux-ia32@npm:0.27.3" - conditions: os=linux & cpu=ia32 - languageName: node - linkType: hard - "@esbuild/linux-loong64@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/linux-loong64@npm:0.25.12" @@ -1140,13 +1076,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-loong64@npm:0.27.3": - version: 0.27.3 - resolution: "@esbuild/linux-loong64@npm:0.27.3" - conditions: os=linux & cpu=loong64 - languageName: node - linkType: hard - "@esbuild/linux-mips64el@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/linux-mips64el@npm:0.25.12" @@ -1161,13 +1090,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-mips64el@npm:0.27.3": - version: 0.27.3 - resolution: "@esbuild/linux-mips64el@npm:0.27.3" - conditions: os=linux & cpu=mips64el - languageName: node - linkType: hard - "@esbuild/linux-ppc64@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/linux-ppc64@npm:0.25.12" @@ -1182,13 +1104,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-ppc64@npm:0.27.3": - version: 0.27.3 - resolution: "@esbuild/linux-ppc64@npm:0.27.3" - conditions: os=linux & cpu=ppc64 - languageName: node - linkType: hard - "@esbuild/linux-riscv64@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/linux-riscv64@npm:0.25.12" @@ -1203,13 +1118,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-riscv64@npm:0.27.3": - version: 0.27.3 - resolution: "@esbuild/linux-riscv64@npm:0.27.3" - conditions: os=linux & cpu=riscv64 - languageName: node - linkType: hard - "@esbuild/linux-s390x@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/linux-s390x@npm:0.25.12" @@ -1224,13 +1132,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-s390x@npm:0.27.3": - version: 0.27.3 - resolution: "@esbuild/linux-s390x@npm:0.27.3" - conditions: os=linux & cpu=s390x - languageName: node - linkType: hard - "@esbuild/linux-x64@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/linux-x64@npm:0.25.12" @@ -1245,13 +1146,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-x64@npm:0.27.3": - version: 0.27.3 - resolution: "@esbuild/linux-x64@npm:0.27.3" - conditions: os=linux & cpu=x64 - languageName: node - linkType: hard - "@esbuild/netbsd-arm64@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/netbsd-arm64@npm:0.25.12" @@ -1266,13 +1160,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/netbsd-arm64@npm:0.27.3": - version: 0.27.3 - resolution: "@esbuild/netbsd-arm64@npm:0.27.3" - conditions: os=netbsd & cpu=arm64 - languageName: node - linkType: hard - "@esbuild/netbsd-x64@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/netbsd-x64@npm:0.25.12" @@ -1287,13 +1174,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/netbsd-x64@npm:0.27.3": - version: 0.27.3 - resolution: "@esbuild/netbsd-x64@npm:0.27.3" - conditions: os=netbsd & cpu=x64 - languageName: node - linkType: hard - "@esbuild/openbsd-arm64@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/openbsd-arm64@npm:0.25.12" @@ -1308,13 +1188,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/openbsd-arm64@npm:0.27.3": - version: 0.27.3 - resolution: "@esbuild/openbsd-arm64@npm:0.27.3" - conditions: os=openbsd & cpu=arm64 - languageName: node - linkType: hard - "@esbuild/openbsd-x64@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/openbsd-x64@npm:0.25.12" @@ -1329,13 +1202,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/openbsd-x64@npm:0.27.3": - version: 0.27.3 - resolution: "@esbuild/openbsd-x64@npm:0.27.3" - conditions: os=openbsd & cpu=x64 - languageName: node - linkType: hard - "@esbuild/openharmony-arm64@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/openharmony-arm64@npm:0.25.12" @@ -1350,13 +1216,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/openharmony-arm64@npm:0.27.3": - version: 0.27.3 - resolution: "@esbuild/openharmony-arm64@npm:0.27.3" - conditions: os=openharmony & cpu=arm64 - languageName: node - linkType: hard - "@esbuild/sunos-x64@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/sunos-x64@npm:0.25.12" @@ -1371,13 +1230,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/sunos-x64@npm:0.27.3": - version: 0.27.3 - resolution: "@esbuild/sunos-x64@npm:0.27.3" - conditions: os=sunos & cpu=x64 - languageName: node - linkType: hard - "@esbuild/win32-arm64@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/win32-arm64@npm:0.25.12" @@ -1392,13 +1244,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/win32-arm64@npm:0.27.3": - version: 0.27.3 - resolution: "@esbuild/win32-arm64@npm:0.27.3" - conditions: os=win32 & cpu=arm64 - languageName: node - linkType: hard - "@esbuild/win32-ia32@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/win32-ia32@npm:0.25.12" @@ -1413,13 +1258,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/win32-ia32@npm:0.27.3": - version: 0.27.3 - resolution: "@esbuild/win32-ia32@npm:0.27.3" - conditions: os=win32 & cpu=ia32 - languageName: node - linkType: hard - "@esbuild/win32-x64@npm:0.25.12": version: 0.25.12 resolution: "@esbuild/win32-x64@npm:0.25.12" @@ -1434,13 +1272,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/win32-x64@npm:0.27.3": - version: 0.27.3 - resolution: "@esbuild/win32-x64@npm:0.27.3" - conditions: os=win32 & cpu=x64 - languageName: node - linkType: hard - "@fastify/accept-negotiator@npm:^2.0.0": version: 2.0.1 resolution: "@fastify/accept-negotiator@npm:2.0.1" @@ -1909,7 +1740,7 @@ __metadata: languageName: node linkType: hard -"@jridgewell/remapping@npm:^2.3.4, @jridgewell/remapping@npm:^2.3.5": +"@jridgewell/remapping@npm:^2.3.5": version: 2.3.5 resolution: "@jridgewell/remapping@npm:2.3.5" dependencies: @@ -2066,14 +1897,15 @@ __metadata: languageName: node linkType: hard -"@napi-rs/wasm-runtime@npm:^1.0.7": - version: 1.0.7 - resolution: "@napi-rs/wasm-runtime@npm:1.0.7" +"@napi-rs/wasm-runtime@npm:^1.1.1": + version: 1.1.2 + resolution: "@napi-rs/wasm-runtime@npm:1.1.2" dependencies: - "@emnapi/core": "npm:^1.5.0" - "@emnapi/runtime": "npm:^1.5.0" "@tybys/wasm-util": "npm:^0.10.1" - checksum: 10c0/2d8635498136abb49d6dbf7395b78c63422292240963bf055f307b77aeafbde57ae2c0ceaaef215601531b36d6eb92a2cdd6f5ba90ed2aa8127c27aff9c4ae55 + peerDependencies: + "@emnapi/core": ^1.7.1 + "@emnapi/runtime": ^1.7.1 + checksum: 10c0/725c30ec9c480a8d0c1a6a4ce31dc6c830365d485e23ad560e143d1cb9db89a0c95fbb5b9d53c07121729817a3683db6f1ab65d7e4f38fa7482a11b15ef6c6fd languageName: node linkType: hard @@ -2198,22 +2030,6 @@ __metadata: languageName: node linkType: hard -"@npmcli/git@npm:^4.1.0": - version: 4.1.0 - resolution: "@npmcli/git@npm:4.1.0" - dependencies: - "@npmcli/promise-spawn": "npm:^6.0.0" - lru-cache: "npm:^7.4.4" - npm-pick-manifest: "npm:^8.0.0" - proc-log: "npm:^3.0.0" - promise-inflight: "npm:^1.0.1" - promise-retry: "npm:^2.0.1" - semver: "npm:^7.3.5" - which: "npm:^3.0.0" - checksum: 10c0/78591ba8f03de3954a5b5b83533455696635a8f8140c74038685fec4ee28674783a5b34a3d43840b2c5f9aa37fd0dce57eaf4ef136b52a8ec2ee183af2e40724 - languageName: node - linkType: hard - "@npmcli/git@npm:^5.0.0": version: 5.0.8 resolution: "@npmcli/git@npm:5.0.8" @@ -2250,21 +2066,6 @@ __metadata: languageName: node linkType: hard -"@npmcli/package-json@npm:^4.0.1": - version: 4.0.1 - resolution: "@npmcli/package-json@npm:4.0.1" - dependencies: - "@npmcli/git": "npm:^4.1.0" - glob: "npm:^10.2.2" - hosted-git-info: "npm:^6.1.1" - json-parse-even-better-errors: "npm:^3.0.0" - normalize-package-data: "npm:^5.0.0" - proc-log: "npm:^3.0.0" - semver: "npm:^7.5.3" - checksum: 10c0/61adec288372827e482d4c6bda8186e239b1419a6f018552a0444520720022fb2903d08438f32881fe2eccabb8cf29dcb1c5c5c62c4fc970d79ad71fe9a41e46 - languageName: node - linkType: hard - "@npmcli/package-json@npm:^5.1.1": version: 5.2.1 resolution: "@npmcli/package-json@npm:5.2.1" @@ -2280,15 +2081,6 @@ __metadata: languageName: node linkType: hard -"@npmcli/promise-spawn@npm:^6.0.0": - version: 6.0.2 - resolution: "@npmcli/promise-spawn@npm:6.0.2" - dependencies: - which: "npm:^3.0.0" - checksum: 10c0/d0696b8d9f7e16562cd1e520e4919000164be042b5c9998a45b4e87d41d9619fcecf2a343621c6fa85ed2671cbe87ab07e381a7faea4e5132c371dbb05893f31 - languageName: node - linkType: hard - "@npmcli/promise-spawn@npm:^7.0.0": version: 7.0.2 resolution: "@npmcli/promise-spawn@npm:7.0.2" @@ -2412,6 +2204,13 @@ __metadata: languageName: node linkType: hard +"@oxc-project/types@npm:=0.122.0": + version: 0.122.0 + resolution: "@oxc-project/types@npm:0.122.0" + checksum: 10c0/2c64dd0db949426fd0c86d4f61eded5902e7b7b166356a825bd3a248aeaa29a495f78918f66ab78e99644b67bd7556096e2a8123cec74ca4141c604f424f4f74 + languageName: node + linkType: hard + "@oxc-project/types@npm:^0.74.0": version: 0.74.0 resolution: "@oxc-project/types@npm:0.74.0" @@ -2536,9 +2335,9 @@ __metadata: languageName: node linkType: hard -"@react-router/dev@npm:^7.8.1": - version: 7.9.6 - resolution: "@react-router/dev@npm:7.9.6" +"@react-router/dev@npm:^7.14.0": + version: 7.14.0 + resolution: "@react-router/dev@npm:7.14.0" dependencies: "@babel/core": "npm:^7.27.7" "@babel/generator": "npm:^7.27.5" @@ -2547,9 +2346,8 @@ __metadata: "@babel/preset-typescript": "npm:^7.27.1" "@babel/traverse": "npm:^7.27.7" "@babel/types": "npm:^7.27.7" - "@npmcli/package-json": "npm:^4.0.1" - "@react-router/node": "npm:7.9.6" - "@remix-run/node-fetch-server": "npm:^0.9.0" + "@react-router/node": "npm:7.14.0" + "@remix-run/node-fetch-server": "npm:^0.13.0" arg: "npm:^5.0.1" babel-dead-code-elimination: "npm:^1.0.6" chokidar: "npm:^4.0.0" @@ -2562,46 +2360,50 @@ __metadata: p-map: "npm:^7.0.3" pathe: "npm:^1.1.2" picocolors: "npm:^1.1.1" + pkg-types: "npm:^2.3.0" prettier: "npm:^3.6.2" react-refresh: "npm:^0.14.0" semver: "npm:^7.3.7" tinyglobby: "npm:^0.2.14" - valibot: "npm:^1.1.0" + valibot: "npm:^1.2.0" vite-node: "npm:^3.2.2" peerDependencies: - "@react-router/serve": ^7.9.6 - "@vitejs/plugin-rsc": "*" - react-router: ^7.9.6 + "@react-router/serve": ^7.14.0 + "@vitejs/plugin-rsc": ~0.5.21 + react-router: ^7.14.0 + react-server-dom-webpack: ^19.2.3 typescript: ^5.1.0 - vite: ^5.1.0 || ^6.0.0 || ^7.0.0 + vite: ^5.1.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 wrangler: ^3.28.2 || ^4.0.0 peerDependenciesMeta: "@react-router/serve": optional: true "@vitejs/plugin-rsc": optional: true + react-server-dom-webpack: + optional: true typescript: optional: true wrangler: optional: true bin: react-router: bin.js - checksum: 10c0/018f3cc2a0fd92db815b949599bec6bab2ec9840016c06322c93b5c9606c7e3210b0c4a550c2c8e78dc431e9ef9e90b418a3b23bdae2a76a4432caba4fe88e4d + checksum: 10c0/e116cbd22de19ac189423bf8aa58ac6fce3325595b6d9d224678a9843012b8a4b477161cd614d06da1046d95a6594e410a5c3b1b7827d47c8c35f5f29e8035c8 languageName: node linkType: hard -"@react-router/node@npm:7.9.6, @react-router/node@npm:^7.8.1": - version: 7.9.6 - resolution: "@react-router/node@npm:7.9.6" +"@react-router/node@npm:7.14.0, @react-router/node@npm:^7.14.0": + version: 7.14.0 + resolution: "@react-router/node@npm:7.14.0" dependencies: "@mjackson/node-fetch-server": "npm:^0.2.0" peerDependencies: - react-router: 7.9.6 + react-router: 7.14.0 typescript: ^5.1.0 peerDependenciesMeta: typescript: optional: true - checksum: 10c0/0c4680c04acd9989e6b7d5af4cad6c69dc713d17d1fe9d9f7064ca8703fb87740f7a6b97aea8c0f459e121f9d6008b3ddf6cde1c818c512ec14bca91b73c1d62 + checksum: 10c0/b2895ba6a191395d4eece03c26c7cbcdba1ddc263b3b10be939dec7739f74384778cac216f679e3d777ba0a9d9b810bc353ef1177791e18fcd005c04d94da918 languageName: node linkType: hard @@ -2634,10 +2436,10 @@ __metadata: languageName: node linkType: hard -"@remix-run/node-fetch-server@npm:^0.9.0": - version: 0.9.0 - resolution: "@remix-run/node-fetch-server@npm:0.9.0" - checksum: 10c0/b0ac06bb9ab6e225668f75b60c6fe994ac8c310f9b1333de2b8e1d0b3b1d80ce4bedfc3940d0c20a94b524bba7424eb4d7e70376d5c97439f11af5193322fd58 +"@remix-run/node-fetch-server@npm:^0.13.0": + version: 0.13.0 + resolution: "@remix-run/node-fetch-server@npm:0.13.0" + checksum: 10c0/ad490eb8173d019afcbbce24f694c9a170a658aacf5d073bb45c7cc3d1665e5e4191bf40dcd9f0cf8803f0441a9ef0b09776d5facc00080eddc331426eaca6a7 languageName: node linkType: hard @@ -2654,48 +2456,48 @@ __metadata: languageName: node linkType: hard -"@rescript/darwin-arm64@npm:12.0.0": - version: 12.0.0 - resolution: "@rescript/darwin-arm64@npm:12.0.0" +"@rescript/darwin-arm64@npm:12.2.0": + version: 12.2.0 + resolution: "@rescript/darwin-arm64@npm:12.2.0" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@rescript/darwin-x64@npm:12.0.0": - version: 12.0.0 - resolution: "@rescript/darwin-x64@npm:12.0.0" +"@rescript/darwin-x64@npm:12.2.0": + version: 12.2.0 + resolution: "@rescript/darwin-x64@npm:12.2.0" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@rescript/linux-arm64@npm:12.0.0": - version: 12.0.0 - resolution: "@rescript/linux-arm64@npm:12.0.0" +"@rescript/linux-arm64@npm:12.2.0": + version: 12.2.0 + resolution: "@rescript/linux-arm64@npm:12.2.0" conditions: os=linux & cpu=arm64 languageName: node linkType: hard -"@rescript/linux-x64@npm:12.0.0": - version: 12.0.0 - resolution: "@rescript/linux-x64@npm:12.0.0" +"@rescript/linux-x64@npm:12.2.0": + version: 12.2.0 + resolution: "@rescript/linux-x64@npm:12.2.0" conditions: os=linux & cpu=x64 languageName: node linkType: hard -"@rescript/react@npm:^0.14.0": - version: 0.14.0 - resolution: "@rescript/react@npm:0.14.0" +"@rescript/react@npm:^0.14.2": + version: 0.14.2 + resolution: "@rescript/react@npm:0.14.2" peerDependencies: - react: ">=19.0.0" - react-dom: ">=19.0.0" - checksum: 10c0/9463eb027df1d28aab60879d3779f0832270580b381fc7b980387ffe080b821548242fef419cfe0a625d9d9866c4f7d676ed77f500374c22b2affd83ebd8bbaa + react: ">=19.1.0" + react-dom: ">=19.1.0" + checksum: 10c0/6fbc0614ac4e4e5ad37abe474dcc137dc8cb7c3351dbc9afa4de92f080614e78809278a034c52da1676e89c299ab07154760d8b80f68f0a5cfd284e22035f713 languageName: node linkType: hard -"@rescript/runtime@npm:12.0.0": - version: 12.0.0 - resolution: "@rescript/runtime@npm:12.0.0" - checksum: 10c0/32121f3a78154cf9ab8ae4939819b9807d4a70552d7c2594872f838456c63026eddca4d6b562220fe5431d56afa96e0e276f09e5d9028b15b03f2570a52f1d30 +"@rescript/runtime@npm:12.2.0": + version: 12.2.0 + resolution: "@rescript/runtime@npm:12.2.0" + checksum: 10c0/59c0194ae52fbbaadc736bbf1cfac1ed6ffde92b2346e36b9f1a29bb136e38b7ef203c780647203912fa657d293eab5c4e208f04dd65ed4bf47d53843980f7e1 languageName: node linkType: hard @@ -2708,17 +2510,131 @@ __metadata: languageName: node linkType: hard -"@rescript/win32-x64@npm:12.0.0": - version: 12.0.0 - resolution: "@rescript/win32-x64@npm:12.0.0" +"@rescript/win32-x64@npm:12.2.0": + version: 12.2.0 + resolution: "@rescript/win32-x64@npm:12.2.0" conditions: os=win32 & cpu=x64 languageName: node linkType: hard -"@rolldown/pluginutils@npm:1.0.0-beta.27": - version: 1.0.0-beta.27 - resolution: "@rolldown/pluginutils@npm:1.0.0-beta.27" - checksum: 10c0/9658f235b345201d4f6bfb1f32da9754ca164f892d1cb68154fe5f53c1df42bd675ecd409836dff46884a7847d6c00bdc38af870f7c81e05bba5c2645eb4ab9c +"@rolldown/binding-android-arm64@npm:1.0.0-rc.12": + version: 1.0.0-rc.12 + resolution: "@rolldown/binding-android-arm64@npm:1.0.0-rc.12" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"@rolldown/binding-darwin-arm64@npm:1.0.0-rc.12": + version: 1.0.0-rc.12 + resolution: "@rolldown/binding-darwin-arm64@npm:1.0.0-rc.12" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@rolldown/binding-darwin-x64@npm:1.0.0-rc.12": + version: 1.0.0-rc.12 + resolution: "@rolldown/binding-darwin-x64@npm:1.0.0-rc.12" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@rolldown/binding-freebsd-x64@npm:1.0.0-rc.12": + version: 1.0.0-rc.12 + resolution: "@rolldown/binding-freebsd-x64@npm:1.0.0-rc.12" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"@rolldown/binding-linux-arm-gnueabihf@npm:1.0.0-rc.12": + version: 1.0.0-rc.12 + resolution: "@rolldown/binding-linux-arm-gnueabihf@npm:1.0.0-rc.12" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@rolldown/binding-linux-arm64-gnu@npm:1.0.0-rc.12": + version: 1.0.0-rc.12 + resolution: "@rolldown/binding-linux-arm64-gnu@npm:1.0.0-rc.12" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@rolldown/binding-linux-arm64-musl@npm:1.0.0-rc.12": + version: 1.0.0-rc.12 + resolution: "@rolldown/binding-linux-arm64-musl@npm:1.0.0-rc.12" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@rolldown/binding-linux-ppc64-gnu@npm:1.0.0-rc.12": + version: 1.0.0-rc.12 + resolution: "@rolldown/binding-linux-ppc64-gnu@npm:1.0.0-rc.12" + conditions: os=linux & cpu=ppc64 & libc=glibc + languageName: node + linkType: hard + +"@rolldown/binding-linux-s390x-gnu@npm:1.0.0-rc.12": + version: 1.0.0-rc.12 + resolution: "@rolldown/binding-linux-s390x-gnu@npm:1.0.0-rc.12" + conditions: os=linux & cpu=s390x & libc=glibc + languageName: node + linkType: hard + +"@rolldown/binding-linux-x64-gnu@npm:1.0.0-rc.12": + version: 1.0.0-rc.12 + resolution: "@rolldown/binding-linux-x64-gnu@npm:1.0.0-rc.12" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@rolldown/binding-linux-x64-musl@npm:1.0.0-rc.12": + version: 1.0.0-rc.12 + resolution: "@rolldown/binding-linux-x64-musl@npm:1.0.0-rc.12" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@rolldown/binding-openharmony-arm64@npm:1.0.0-rc.12": + version: 1.0.0-rc.12 + resolution: "@rolldown/binding-openharmony-arm64@npm:1.0.0-rc.12" + conditions: os=openharmony & cpu=arm64 + languageName: node + linkType: hard + +"@rolldown/binding-wasm32-wasi@npm:1.0.0-rc.12": + version: 1.0.0-rc.12 + resolution: "@rolldown/binding-wasm32-wasi@npm:1.0.0-rc.12" + dependencies: + "@napi-rs/wasm-runtime": "npm:^1.1.1" + conditions: cpu=wasm32 + languageName: node + linkType: hard + +"@rolldown/binding-win32-arm64-msvc@npm:1.0.0-rc.12": + version: 1.0.0-rc.12 + resolution: "@rolldown/binding-win32-arm64-msvc@npm:1.0.0-rc.12" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@rolldown/binding-win32-x64-msvc@npm:1.0.0-rc.12": + version: 1.0.0-rc.12 + resolution: "@rolldown/binding-win32-x64-msvc@npm:1.0.0-rc.12" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@rolldown/pluginutils@npm:1.0.0-rc.12": + version: 1.0.0-rc.12 + resolution: "@rolldown/pluginutils@npm:1.0.0-rc.12" + checksum: 10c0/f785d1180ea4876bf6a6a67135822808d1c07f902409524ff1088779f7d5318f6e603d281fb107a5145c1ca54b7cabebd359629ec474ebbc2812f2cf53db4023 + languageName: node + linkType: hard + +"@rolldown/pluginutils@npm:1.0.0-rc.7": + version: 1.0.0-rc.7 + resolution: "@rolldown/pluginutils@npm:1.0.0-rc.7" + checksum: 10c0/9d5490b5805b25bcd1720ca01c4c032b55a0ef953dab36a8dd42c568e82214576baa464f3027cd5dff3fabcfbe3bf3db2251d12b60220f5d1cd2ffde5ee37082 languageName: node linkType: hard @@ -2897,6 +2813,13 @@ __metadata: languageName: node linkType: hard +"@standard-schema/spec@npm:^1.1.0": + version: 1.1.0 + resolution: "@standard-schema/spec@npm:1.1.0" + checksum: 10c0/d90f55acde4b2deb983529c87e8025fa693de1a5e8b49ecc6eb84d1fd96328add0e03d7d551442156c7432fd78165b2c26ff561b970a9a881f046abb78d6a526 + languageName: node + linkType: hard + "@swc/helpers@npm:^0.5.0": version: 0.5.17 resolution: "@swc/helpers@npm:0.5.17" @@ -2906,128 +2829,128 @@ __metadata: languageName: node linkType: hard -"@tailwindcss/node@npm:4.1.17": - version: 4.1.17 - resolution: "@tailwindcss/node@npm:4.1.17" +"@tailwindcss/node@npm:4.2.2": + version: 4.2.2 + resolution: "@tailwindcss/node@npm:4.2.2" dependencies: - "@jridgewell/remapping": "npm:^2.3.4" - enhanced-resolve: "npm:^5.18.3" + "@jridgewell/remapping": "npm:^2.3.5" + enhanced-resolve: "npm:^5.19.0" jiti: "npm:^2.6.1" - lightningcss: "npm:1.30.2" + lightningcss: "npm:1.32.0" magic-string: "npm:^0.30.21" source-map-js: "npm:^1.2.1" - tailwindcss: "npm:4.1.17" - checksum: 10c0/80b542e9b7eb09499dd14d65fd7d9544321d6bcdc00d29914396001d00e009906392cf493d20cc655dfd42769c823060cb9bf2eacacb43838a47e897634a446b + tailwindcss: "npm:4.2.2" + checksum: 10c0/4c0019355cd85a08f93ba3e179de37b83cc233b8ded4bd7714e633f89dd108928742e50966593257c2c1ab8db8914ea187dae007b5c692c869ceace11aeccede languageName: node linkType: hard -"@tailwindcss/oxide-android-arm64@npm:4.1.17": - version: 4.1.17 - resolution: "@tailwindcss/oxide-android-arm64@npm:4.1.17" +"@tailwindcss/oxide-android-arm64@npm:4.2.2": + version: 4.2.2 + resolution: "@tailwindcss/oxide-android-arm64@npm:4.2.2" conditions: os=android & cpu=arm64 languageName: node linkType: hard -"@tailwindcss/oxide-darwin-arm64@npm:4.1.17": - version: 4.1.17 - resolution: "@tailwindcss/oxide-darwin-arm64@npm:4.1.17" +"@tailwindcss/oxide-darwin-arm64@npm:4.2.2": + version: 4.2.2 + resolution: "@tailwindcss/oxide-darwin-arm64@npm:4.2.2" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@tailwindcss/oxide-darwin-x64@npm:4.1.17": - version: 4.1.17 - resolution: "@tailwindcss/oxide-darwin-x64@npm:4.1.17" +"@tailwindcss/oxide-darwin-x64@npm:4.2.2": + version: 4.2.2 + resolution: "@tailwindcss/oxide-darwin-x64@npm:4.2.2" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@tailwindcss/oxide-freebsd-x64@npm:4.1.17": - version: 4.1.17 - resolution: "@tailwindcss/oxide-freebsd-x64@npm:4.1.17" +"@tailwindcss/oxide-freebsd-x64@npm:4.2.2": + version: 4.2.2 + resolution: "@tailwindcss/oxide-freebsd-x64@npm:4.2.2" conditions: os=freebsd & cpu=x64 languageName: node linkType: hard -"@tailwindcss/oxide-linux-arm-gnueabihf@npm:4.1.17": - version: 4.1.17 - resolution: "@tailwindcss/oxide-linux-arm-gnueabihf@npm:4.1.17" +"@tailwindcss/oxide-linux-arm-gnueabihf@npm:4.2.2": + version: 4.2.2 + resolution: "@tailwindcss/oxide-linux-arm-gnueabihf@npm:4.2.2" conditions: os=linux & cpu=arm languageName: node linkType: hard -"@tailwindcss/oxide-linux-arm64-gnu@npm:4.1.17": - version: 4.1.17 - resolution: "@tailwindcss/oxide-linux-arm64-gnu@npm:4.1.17" +"@tailwindcss/oxide-linux-arm64-gnu@npm:4.2.2": + version: 4.2.2 + resolution: "@tailwindcss/oxide-linux-arm64-gnu@npm:4.2.2" conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard -"@tailwindcss/oxide-linux-arm64-musl@npm:4.1.17": - version: 4.1.17 - resolution: "@tailwindcss/oxide-linux-arm64-musl@npm:4.1.17" +"@tailwindcss/oxide-linux-arm64-musl@npm:4.2.2": + version: 4.2.2 + resolution: "@tailwindcss/oxide-linux-arm64-musl@npm:4.2.2" conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard -"@tailwindcss/oxide-linux-x64-gnu@npm:4.1.17": - version: 4.1.17 - resolution: "@tailwindcss/oxide-linux-x64-gnu@npm:4.1.17" +"@tailwindcss/oxide-linux-x64-gnu@npm:4.2.2": + version: 4.2.2 + resolution: "@tailwindcss/oxide-linux-x64-gnu@npm:4.2.2" conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard -"@tailwindcss/oxide-linux-x64-musl@npm:4.1.17": - version: 4.1.17 - resolution: "@tailwindcss/oxide-linux-x64-musl@npm:4.1.17" +"@tailwindcss/oxide-linux-x64-musl@npm:4.2.2": + version: 4.2.2 + resolution: "@tailwindcss/oxide-linux-x64-musl@npm:4.2.2" conditions: os=linux & cpu=x64 & libc=musl languageName: node linkType: hard -"@tailwindcss/oxide-wasm32-wasi@npm:4.1.17": - version: 4.1.17 - resolution: "@tailwindcss/oxide-wasm32-wasi@npm:4.1.17" +"@tailwindcss/oxide-wasm32-wasi@npm:4.2.2": + version: 4.2.2 + resolution: "@tailwindcss/oxide-wasm32-wasi@npm:4.2.2" dependencies: - "@emnapi/core": "npm:^1.6.0" - "@emnapi/runtime": "npm:^1.6.0" + "@emnapi/core": "npm:^1.8.1" + "@emnapi/runtime": "npm:^1.8.1" "@emnapi/wasi-threads": "npm:^1.1.0" - "@napi-rs/wasm-runtime": "npm:^1.0.7" + "@napi-rs/wasm-runtime": "npm:^1.1.1" "@tybys/wasm-util": "npm:^0.10.1" - tslib: "npm:^2.4.0" + tslib: "npm:^2.8.1" conditions: cpu=wasm32 languageName: node linkType: hard -"@tailwindcss/oxide-win32-arm64-msvc@npm:4.1.17": - version: 4.1.17 - resolution: "@tailwindcss/oxide-win32-arm64-msvc@npm:4.1.17" +"@tailwindcss/oxide-win32-arm64-msvc@npm:4.2.2": + version: 4.2.2 + resolution: "@tailwindcss/oxide-win32-arm64-msvc@npm:4.2.2" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard -"@tailwindcss/oxide-win32-x64-msvc@npm:4.1.17": - version: 4.1.17 - resolution: "@tailwindcss/oxide-win32-x64-msvc@npm:4.1.17" +"@tailwindcss/oxide-win32-x64-msvc@npm:4.2.2": + version: 4.2.2 + resolution: "@tailwindcss/oxide-win32-x64-msvc@npm:4.2.2" conditions: os=win32 & cpu=x64 languageName: node linkType: hard -"@tailwindcss/oxide@npm:4.1.17": - version: 4.1.17 - resolution: "@tailwindcss/oxide@npm:4.1.17" - dependencies: - "@tailwindcss/oxide-android-arm64": "npm:4.1.17" - "@tailwindcss/oxide-darwin-arm64": "npm:4.1.17" - "@tailwindcss/oxide-darwin-x64": "npm:4.1.17" - "@tailwindcss/oxide-freebsd-x64": "npm:4.1.17" - "@tailwindcss/oxide-linux-arm-gnueabihf": "npm:4.1.17" - "@tailwindcss/oxide-linux-arm64-gnu": "npm:4.1.17" - "@tailwindcss/oxide-linux-arm64-musl": "npm:4.1.17" - "@tailwindcss/oxide-linux-x64-gnu": "npm:4.1.17" - "@tailwindcss/oxide-linux-x64-musl": "npm:4.1.17" - "@tailwindcss/oxide-wasm32-wasi": "npm:4.1.17" - "@tailwindcss/oxide-win32-arm64-msvc": "npm:4.1.17" - "@tailwindcss/oxide-win32-x64-msvc": "npm:4.1.17" +"@tailwindcss/oxide@npm:4.2.2": + version: 4.2.2 + resolution: "@tailwindcss/oxide@npm:4.2.2" + dependencies: + "@tailwindcss/oxide-android-arm64": "npm:4.2.2" + "@tailwindcss/oxide-darwin-arm64": "npm:4.2.2" + "@tailwindcss/oxide-darwin-x64": "npm:4.2.2" + "@tailwindcss/oxide-freebsd-x64": "npm:4.2.2" + "@tailwindcss/oxide-linux-arm-gnueabihf": "npm:4.2.2" + "@tailwindcss/oxide-linux-arm64-gnu": "npm:4.2.2" + "@tailwindcss/oxide-linux-arm64-musl": "npm:4.2.2" + "@tailwindcss/oxide-linux-x64-gnu": "npm:4.2.2" + "@tailwindcss/oxide-linux-x64-musl": "npm:4.2.2" + "@tailwindcss/oxide-wasm32-wasi": "npm:4.2.2" + "@tailwindcss/oxide-win32-arm64-msvc": "npm:4.2.2" + "@tailwindcss/oxide-win32-x64-msvc": "npm:4.2.2" dependenciesMeta: "@tailwindcss/oxide-android-arm64": optional: true @@ -3053,20 +2976,20 @@ __metadata: optional: true "@tailwindcss/oxide-win32-x64-msvc": optional: true - checksum: 10c0/cdd292760dde90976ac5cd486600687f9ac4043d9796001b356d43bfc4d0e1972d23844fe045970afdc4b4cda8451f262db15a9da4152c26e2b696a985e3686c + checksum: 10c0/22f78d73ffcec2d0d91f9fbfc29fed23c260e3e53f510f0b2598e322bf56a92ceb7e6f5a1c88ad1e3c7cfee9dd8d39285c411de5ec3225cdae2cbfdb737862e5 languageName: node linkType: hard -"@tailwindcss/vite@npm:^4.1.13": - version: 4.1.17 - resolution: "@tailwindcss/vite@npm:4.1.17" +"@tailwindcss/vite@npm:^4.2.2": + version: 4.2.2 + resolution: "@tailwindcss/vite@npm:4.2.2" dependencies: - "@tailwindcss/node": "npm:4.1.17" - "@tailwindcss/oxide": "npm:4.1.17" - tailwindcss: "npm:4.1.17" + "@tailwindcss/node": "npm:4.2.2" + "@tailwindcss/oxide": "npm:4.2.2" + tailwindcss: "npm:4.2.2" peerDependencies: - vite: ^5.2.0 || ^6 || ^7 - checksum: 10c0/47d9bdfb7bf7d2df0661b50e91656779863146cca97571e21e2c3f9351f468c27cbc7ed1d1d6c373f1e721dca66d32a3f12f77e9d3e74bed344e27afec199ad3 + vite: ^5.2.0 || ^6 || ^7 || ^8 + checksum: 10c0/f6ec4b0d6a8e79208873fb357a8ed9b6fd8eb3000d153ec2590c61dba5bfbe79c0951a215d187958d2b8a3c5b45c25ebcefac7a6dea882bb27b4b2898c54266f languageName: node linkType: hard @@ -3109,47 +3032,6 @@ __metadata: languageName: node linkType: hard -"@types/babel__core@npm:^7.20.5": - version: 7.20.5 - resolution: "@types/babel__core@npm:7.20.5" - dependencies: - "@babel/parser": "npm:^7.20.7" - "@babel/types": "npm:^7.20.7" - "@types/babel__generator": "npm:*" - "@types/babel__template": "npm:*" - "@types/babel__traverse": "npm:*" - checksum: 10c0/bdee3bb69951e833a4b811b8ee9356b69a61ed5b7a23e1a081ec9249769117fa83aaaf023bb06562a038eb5845155ff663e2d5c75dd95c1d5ccc91db012868ff - languageName: node - linkType: hard - -"@types/babel__generator@npm:*": - version: 7.27.0 - resolution: "@types/babel__generator@npm:7.27.0" - dependencies: - "@babel/types": "npm:^7.0.0" - checksum: 10c0/9f9e959a8792df208a9d048092fda7e1858bddc95c6314857a8211a99e20e6830bdeb572e3587ae8be5429e37f2a96fcf222a9f53ad232f5537764c9e13a2bbd - languageName: node - linkType: hard - -"@types/babel__template@npm:*": - version: 7.4.4 - resolution: "@types/babel__template@npm:7.4.4" - dependencies: - "@babel/parser": "npm:^7.1.0" - "@babel/types": "npm:^7.0.0" - checksum: 10c0/cc84f6c6ab1eab1427e90dd2b76ccee65ce940b778a9a67be2c8c39e1994e6f5bbc8efa309f6cea8dc6754994524cd4d2896558df76d92e7a1f46ecffee7112b - languageName: node - linkType: hard - -"@types/babel__traverse@npm:*": - version: 7.28.0 - resolution: "@types/babel__traverse@npm:7.28.0" - dependencies: - "@babel/types": "npm:^7.28.2" - checksum: 10c0/b52d7d4e8fc6a9018fe7361c4062c1c190f5778cf2466817cb9ed19d69fbbb54f9a85ffedeb748ed8062d2cf7d4cc088ee739848f47c57740de1c48cbf0d0994 - languageName: node - linkType: hard - "@types/chai@npm:^5.2.2": version: 5.2.3 resolution: "@types/chai@npm:5.2.3" @@ -3265,12 +3147,12 @@ __metadata: languageName: node linkType: hard -"@types/react@npm:^19.2.2": - version: 19.2.7 - resolution: "@types/react@npm:19.2.7" +"@types/react@npm:^19.2.14": + version: 19.2.14 + resolution: "@types/react@npm:19.2.14" dependencies: csstype: "npm:^3.2.2" - checksum: 10c0/a7b75f1f9fcb34badd6f84098be5e35a0aeca614bc91f93d2698664c0b2ba5ad128422bd470ada598238cebe4f9e604a752aead7dc6f5a92261d0c7f9b27cfd1 + checksum: 10c0/7d25bf41b57719452d86d2ac0570b659210402707313a36ee612666bf11275a1c69824f8c3ee1fdca077ccfe15452f6da8f1224529b917050eb2d861e52b59b7 languageName: node linkType: hard @@ -3323,134 +3205,138 @@ __metadata: languageName: node linkType: hard -"@vitejs/plugin-react@npm:^4.7.0": - version: 4.7.0 - resolution: "@vitejs/plugin-react@npm:4.7.0" - dependencies: - "@babel/core": "npm:^7.28.0" - "@babel/plugin-transform-react-jsx-self": "npm:^7.27.1" - "@babel/plugin-transform-react-jsx-source": "npm:^7.27.1" - "@rolldown/pluginutils": "npm:1.0.0-beta.27" - "@types/babel__core": "npm:^7.20.5" - react-refresh: "npm:^0.17.0" +"@vitejs/plugin-react@npm:^6.0.1": + version: 6.0.1 + resolution: "@vitejs/plugin-react@npm:6.0.1" + dependencies: + "@rolldown/pluginutils": "npm:1.0.0-rc.7" peerDependencies: - vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 - checksum: 10c0/692f23960972879485d647713663ec299c478222c96567d60285acf7c7dc5c178e71abfe9d2eefddef1eeb01514dacbc2ed68aad84628debf9c7116134734253 + "@rolldown/plugin-babel": ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + vite: ^8.0.0 + peerDependenciesMeta: + "@rolldown/plugin-babel": + optional: true + babel-plugin-react-compiler: + optional: true + checksum: 10c0/6c42f53a970cb6b0776ba5b4203bb01690ac564c56fca706d4037b50aec965ddc0f11530ab58ab2cd0fbe8c12e14cff6966b22d90391283b4a53294e3ddd478d languageName: node linkType: hard -"@vitest/browser-playwright@npm:^4.0.18": - version: 4.0.18 - resolution: "@vitest/browser-playwright@npm:4.0.18" +"@vitest/browser-playwright@npm:^4.1.2": + version: 4.1.2 + resolution: "@vitest/browser-playwright@npm:4.1.2" dependencies: - "@vitest/browser": "npm:4.0.18" - "@vitest/mocker": "npm:4.0.18" - tinyrainbow: "npm:^3.0.3" + "@vitest/browser": "npm:4.1.2" + "@vitest/mocker": "npm:4.1.2" + tinyrainbow: "npm:^3.1.0" peerDependencies: playwright: "*" - vitest: 4.0.18 + vitest: 4.1.2 peerDependenciesMeta: playwright: optional: false - checksum: 10c0/505fafe6f957d020b74914ed328de57cba0be65ff82810da85297523776a0d7389669660e58734a416fc09ce262632b4d2cf257a9e8ab1115b695d133bba7bb5 + checksum: 10c0/701a750a16059be20dddb6884e9aaad43002e1d08da94df31b0dba9abed33d0c3faba8b1c56b7da25b61b0faab1e72597cbcedd2b969f4f6139b2e17a3fd4d06 languageName: node linkType: hard -"@vitest/browser@npm:4.0.18": - version: 4.0.18 - resolution: "@vitest/browser@npm:4.0.18" +"@vitest/browser@npm:4.1.2": + version: 4.1.2 + resolution: "@vitest/browser@npm:4.1.2" dependencies: - "@vitest/mocker": "npm:4.0.18" - "@vitest/utils": "npm:4.0.18" + "@blazediff/core": "npm:1.9.1" + "@vitest/mocker": "npm:4.1.2" + "@vitest/utils": "npm:4.1.2" magic-string: "npm:^0.30.21" - pixelmatch: "npm:7.1.0" pngjs: "npm:^7.0.0" sirv: "npm:^3.0.2" - tinyrainbow: "npm:^3.0.3" - ws: "npm:^8.18.3" + tinyrainbow: "npm:^3.1.0" + ws: "npm:^8.19.0" peerDependencies: - vitest: 4.0.18 - checksum: 10c0/6b7bda92fa2e8c68de3e51c97322161484c3f1dd7a7417cdeabb4f1d98eab7dba96c156ac4282ea537c58d55cc0e5959abb4b9d90d3823b3cc3071c3f7460633 + vitest: 4.1.2 + checksum: 10c0/8ff656df7c3796f24b38800f42cc59902b15196556ef1df1cf931faf0b095db9677109c2e855ed8915c36bc6aae804b4c53e22c069c749ed2b7e16d8eefddde5 languageName: node linkType: hard -"@vitest/expect@npm:4.0.18": - version: 4.0.18 - resolution: "@vitest/expect@npm:4.0.18" +"@vitest/expect@npm:4.1.2": + version: 4.1.2 + resolution: "@vitest/expect@npm:4.1.2" dependencies: - "@standard-schema/spec": "npm:^1.0.0" + "@standard-schema/spec": "npm:^1.1.0" "@types/chai": "npm:^5.2.2" - "@vitest/spy": "npm:4.0.18" - "@vitest/utils": "npm:4.0.18" - chai: "npm:^6.2.1" - tinyrainbow: "npm:^3.0.3" - checksum: 10c0/123b0aa111682e82ec5289186df18037b1a1768700e468ee0f9879709aaa320cf790463c15c0d8ee10df92b402f4394baf5d27797e604d78e674766d87bcaadc + "@vitest/spy": "npm:4.1.2" + "@vitest/utils": "npm:4.1.2" + chai: "npm:^6.2.2" + tinyrainbow: "npm:^3.1.0" + checksum: 10c0/e238c833b5555d31b074545807956d5e874a1ef725525ecc99f1885b71b230b2127d40d8d142a7253666b8565d5806723853e85e0e99265520ec7506fdc5890c languageName: node linkType: hard -"@vitest/mocker@npm:4.0.18": - version: 4.0.18 - resolution: "@vitest/mocker@npm:4.0.18" +"@vitest/mocker@npm:4.1.2": + version: 4.1.2 + resolution: "@vitest/mocker@npm:4.1.2" dependencies: - "@vitest/spy": "npm:4.0.18" + "@vitest/spy": "npm:4.1.2" estree-walker: "npm:^3.0.3" magic-string: "npm:^0.30.21" peerDependencies: msw: ^2.4.9 - vite: ^6.0.0 || ^7.0.0-0 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: msw: optional: true vite: optional: true - checksum: 10c0/fb0a257e7e167759d4ad228d53fa7bad2267586459c4a62188f2043dd7163b4b02e1e496dc3c227837f776e7d73d6c4343613e89e7da379d9d30de8260f1ee4b + checksum: 10c0/f23094f3c7e1e5af42e6a468f0815c1ecdcab85cb3a56ab6f3f214a9808a40271467d4352cae972482b9738cc31c62c7312d8b0da227d6ea03d2b3aacb8d385f languageName: node linkType: hard -"@vitest/pretty-format@npm:4.0.18": - version: 4.0.18 - resolution: "@vitest/pretty-format@npm:4.0.18" +"@vitest/pretty-format@npm:4.1.2": + version: 4.1.2 + resolution: "@vitest/pretty-format@npm:4.1.2" dependencies: - tinyrainbow: "npm:^3.0.3" - checksum: 10c0/0086b8c88eeca896d8e4b98fcdef452c8041a1b63eb9e85d3e0bcc96c8aa76d8e9e0b6990ebb0bb0a697c4ebab347e7735888b24f507dbff2742ddce7723fd94 + tinyrainbow: "npm:^3.1.0" + checksum: 10c0/6f57519c707e6a3d1ff8630ca87ce78fda9bf7bb33f6e4a0c775a8b510f2a6cee109849e2cdb736b0280681c567bd03e4cff724cbf0962950c9ff81377f0b2bc languageName: node linkType: hard -"@vitest/runner@npm:4.0.18": - version: 4.0.18 - resolution: "@vitest/runner@npm:4.0.18" +"@vitest/runner@npm:4.1.2": + version: 4.1.2 + resolution: "@vitest/runner@npm:4.1.2" dependencies: - "@vitest/utils": "npm:4.0.18" + "@vitest/utils": "npm:4.1.2" pathe: "npm:^2.0.3" - checksum: 10c0/fdb4afa411475133c05ba266c8092eaf1e56cbd5fb601f92ec6ccb9bab7ca52e06733ee8626599355cba4ee71cb3a8f28c84d3b69dc972e41047edc50229bc01 + checksum: 10c0/35654a87bd27983443adc24d68529d624f7d70e0386176741dc5bcc4188b86a70af2c512405d7e97aa45c16d83e1c8566c1f99c8440430f95557275f18612d21 languageName: node linkType: hard -"@vitest/snapshot@npm:4.0.18": - version: 4.0.18 - resolution: "@vitest/snapshot@npm:4.0.18" +"@vitest/snapshot@npm:4.1.2": + version: 4.1.2 + resolution: "@vitest/snapshot@npm:4.1.2" dependencies: - "@vitest/pretty-format": "npm:4.0.18" + "@vitest/pretty-format": "npm:4.1.2" + "@vitest/utils": "npm:4.1.2" magic-string: "npm:^0.30.21" pathe: "npm:^2.0.3" - checksum: 10c0/d3bfefa558db9a69a66886ace6575eb96903a5ba59f4d9a5d0fecb4acc2bb8dbb443ef409f5ac1475f2e1add30bd1d71280f98912da35e89c75829df9e84ea43 + checksum: 10c0/6d20e92386937afddbc81344211e554b83a559e20fb10c1deb0b1c3532994dc9fc62d816706ac835bdb737eb1ab02e9c0bc9de80dd8316060e1e0aaa447ba48f languageName: node linkType: hard -"@vitest/spy@npm:4.0.18": - version: 4.0.18 - resolution: "@vitest/spy@npm:4.0.18" - checksum: 10c0/6de537890b3994fcadb8e8d8ac05942320ae184f071ec395d978a5fba7fa928cbb0c5de85af86a1c165706c466e840de8779eaff8c93450c511c7abaeb9b8a4e +"@vitest/spy@npm:4.1.2": + version: 4.1.2 + resolution: "@vitest/spy@npm:4.1.2" + checksum: 10c0/2b5888d536d3e2083c5f8939763e6d780c2c03cc60e1ab45f9d04eacf14467acb9724cae1c4778e4c06426d49d04517e190122882953054a4b13fda44780bb14 languageName: node linkType: hard -"@vitest/utils@npm:4.0.18": - version: 4.0.18 - resolution: "@vitest/utils@npm:4.0.18" +"@vitest/utils@npm:4.1.2": + version: 4.1.2 + resolution: "@vitest/utils@npm:4.1.2" dependencies: - "@vitest/pretty-format": "npm:4.0.18" - tinyrainbow: "npm:^3.0.3" - checksum: 10c0/4a3c43c1421eb90f38576926496f6c80056167ba111e63f77cf118983902673737a1a38880b890d7c06ec0a12475024587344ee502b3c43093781533022f2aeb + "@vitest/pretty-format": "npm:4.1.2" + convert-source-map: "npm:^2.0.0" + tinyrainbow: "npm:^3.1.0" + checksum: 10c0/d96475e0703b6e5208c6c0f570c1235278cbac3f3913a9aa4203a3e617c9eaca85a184bfd5d13cf366b84754df787ab8bc85242c5e0c63105ee7176c186a2136 languageName: node linkType: hard @@ -4021,7 +3907,7 @@ __metadata: languageName: node linkType: hard -"chai@npm:^6.2.1": +"chai@npm:^6.2.2": version: 6.2.2 resolution: "chai@npm:6.2.2" checksum: 10c0/e6c69e5f0c11dffe6ea13d0290936ebb68fcc1ad688b8e952e131df6a6d5797d5e860bc55cef1aca2e950c3e1f96daf79e9d5a70fb7dbaab4e46355e2635ed53 @@ -4225,6 +4111,13 @@ __metadata: languageName: node linkType: hard +"confbox@npm:^0.2.2": + version: 0.2.4 + resolution: "confbox@npm:0.2.4" + checksum: 10c0/4c36af33d9df7034300c452f7b289179264493bd0671fa81b995a0d70dc897b1d37f1af10d3ffb187f178d17ba1ed2ba167ed0f599ba3a139c271205dd553f73 + languageName: node + linkType: hard + "content-disposition@npm:0.5.4, content-disposition@npm:^0.5.4": version: 0.5.4 resolution: "content-disposition@npm:0.5.4" @@ -4668,13 +4561,13 @@ __metadata: languageName: node linkType: hard -"enhanced-resolve@npm:^5.18.3": - version: 5.18.3 - resolution: "enhanced-resolve@npm:5.18.3" +"enhanced-resolve@npm:^5.19.0": + version: 5.20.1 + resolution: "enhanced-resolve@npm:5.20.1" dependencies: graceful-fs: "npm:^4.2.4" - tapable: "npm:^2.2.0" - checksum: 10c0/d413c23c2d494e4c1c9c9ac7d60b812083dc6d446699ed495e69c920988af0a3c66bf3f8d0e7a45cb1686c2d4c1df9f4e7352d973f5b56fe63d8d711dd0ccc54 + tapable: "npm:^2.3.0" + checksum: 10c0/c6503ee1b2d725843e047e774445ecb12b779aa52db25d11ebe18d4b3adc148d3d993d2038b3d0c38ad836c9c4b3930fbc55df42f72b44785e2f94e5530eda69 languageName: node linkType: hard @@ -4798,6 +4691,13 @@ __metadata: languageName: node linkType: hard +"es-module-lexer@npm:^2.0.0": + version: 2.0.0 + resolution: "es-module-lexer@npm:2.0.0" + checksum: 10c0/ae78dbbd43035a4b972c46cfb6877e374ea290adfc62bc2f5a083fea242c0b2baaab25c5886af86be55f092f4a326741cb94334cd3c478c383fdc8a9ec5ff817 + languageName: node + linkType: hard + "es-object-atoms@npm:^1.0.0, es-object-atoms@npm:^1.1.1": version: 1.1.1 resolution: "es-object-atoms@npm:1.1.1" @@ -4862,117 +4762,28 @@ __metadata: "@esbuild/android-arm": "npm:0.27.0" "@esbuild/android-arm64": "npm:0.27.0" "@esbuild/android-x64": "npm:0.27.0" - "@esbuild/darwin-arm64": "npm:0.27.0" - "@esbuild/darwin-x64": "npm:0.27.0" - "@esbuild/freebsd-arm64": "npm:0.27.0" - "@esbuild/freebsd-x64": "npm:0.27.0" - "@esbuild/linux-arm": "npm:0.27.0" - "@esbuild/linux-arm64": "npm:0.27.0" - "@esbuild/linux-ia32": "npm:0.27.0" - "@esbuild/linux-loong64": "npm:0.27.0" - "@esbuild/linux-mips64el": "npm:0.27.0" - "@esbuild/linux-ppc64": "npm:0.27.0" - "@esbuild/linux-riscv64": "npm:0.27.0" - "@esbuild/linux-s390x": "npm:0.27.0" - "@esbuild/linux-x64": "npm:0.27.0" - "@esbuild/netbsd-arm64": "npm:0.27.0" - "@esbuild/netbsd-x64": "npm:0.27.0" - "@esbuild/openbsd-arm64": "npm:0.27.0" - "@esbuild/openbsd-x64": "npm:0.27.0" - "@esbuild/openharmony-arm64": "npm:0.27.0" - "@esbuild/sunos-x64": "npm:0.27.0" - "@esbuild/win32-arm64": "npm:0.27.0" - "@esbuild/win32-ia32": "npm:0.27.0" - "@esbuild/win32-x64": "npm:0.27.0" - dependenciesMeta: - "@esbuild/aix-ppc64": - optional: true - "@esbuild/android-arm": - optional: true - "@esbuild/android-arm64": - optional: true - "@esbuild/android-x64": - optional: true - "@esbuild/darwin-arm64": - optional: true - "@esbuild/darwin-x64": - optional: true - "@esbuild/freebsd-arm64": - optional: true - "@esbuild/freebsd-x64": - optional: true - "@esbuild/linux-arm": - optional: true - "@esbuild/linux-arm64": - optional: true - "@esbuild/linux-ia32": - optional: true - "@esbuild/linux-loong64": - optional: true - "@esbuild/linux-mips64el": - optional: true - "@esbuild/linux-ppc64": - optional: true - "@esbuild/linux-riscv64": - optional: true - "@esbuild/linux-s390x": - optional: true - "@esbuild/linux-x64": - optional: true - "@esbuild/netbsd-arm64": - optional: true - "@esbuild/netbsd-x64": - optional: true - "@esbuild/openbsd-arm64": - optional: true - "@esbuild/openbsd-x64": - optional: true - "@esbuild/openharmony-arm64": - optional: true - "@esbuild/sunos-x64": - optional: true - "@esbuild/win32-arm64": - optional: true - "@esbuild/win32-ia32": - optional: true - "@esbuild/win32-x64": - optional: true - bin: - esbuild: bin/esbuild - checksum: 10c0/a3a1deec285337b7dfe25cbb9aa8765d27a0192b610a8477a39bf5bd907a6bdb75e98898b61fb4337114cfadb13163bd95977db14e241373115f548e235b40a2 - languageName: node - linkType: hard - -"esbuild@npm:^0.25.0": - version: 0.25.12 - resolution: "esbuild@npm:0.25.12" - dependencies: - "@esbuild/aix-ppc64": "npm:0.25.12" - "@esbuild/android-arm": "npm:0.25.12" - "@esbuild/android-arm64": "npm:0.25.12" - "@esbuild/android-x64": "npm:0.25.12" - "@esbuild/darwin-arm64": "npm:0.25.12" - "@esbuild/darwin-x64": "npm:0.25.12" - "@esbuild/freebsd-arm64": "npm:0.25.12" - "@esbuild/freebsd-x64": "npm:0.25.12" - "@esbuild/linux-arm": "npm:0.25.12" - "@esbuild/linux-arm64": "npm:0.25.12" - "@esbuild/linux-ia32": "npm:0.25.12" - "@esbuild/linux-loong64": "npm:0.25.12" - "@esbuild/linux-mips64el": "npm:0.25.12" - "@esbuild/linux-ppc64": "npm:0.25.12" - "@esbuild/linux-riscv64": "npm:0.25.12" - "@esbuild/linux-s390x": "npm:0.25.12" - "@esbuild/linux-x64": "npm:0.25.12" - "@esbuild/netbsd-arm64": "npm:0.25.12" - "@esbuild/netbsd-x64": "npm:0.25.12" - "@esbuild/openbsd-arm64": "npm:0.25.12" - "@esbuild/openbsd-x64": "npm:0.25.12" - "@esbuild/openharmony-arm64": "npm:0.25.12" - "@esbuild/sunos-x64": "npm:0.25.12" - "@esbuild/win32-arm64": "npm:0.25.12" - "@esbuild/win32-ia32": "npm:0.25.12" - "@esbuild/win32-x64": "npm:0.25.12" + "@esbuild/darwin-arm64": "npm:0.27.0" + "@esbuild/darwin-x64": "npm:0.27.0" + "@esbuild/freebsd-arm64": "npm:0.27.0" + "@esbuild/freebsd-x64": "npm:0.27.0" + "@esbuild/linux-arm": "npm:0.27.0" + "@esbuild/linux-arm64": "npm:0.27.0" + "@esbuild/linux-ia32": "npm:0.27.0" + "@esbuild/linux-loong64": "npm:0.27.0" + "@esbuild/linux-mips64el": "npm:0.27.0" + "@esbuild/linux-ppc64": "npm:0.27.0" + "@esbuild/linux-riscv64": "npm:0.27.0" + "@esbuild/linux-s390x": "npm:0.27.0" + "@esbuild/linux-x64": "npm:0.27.0" + "@esbuild/netbsd-arm64": "npm:0.27.0" + "@esbuild/netbsd-x64": "npm:0.27.0" + "@esbuild/openbsd-arm64": "npm:0.27.0" + "@esbuild/openbsd-x64": "npm:0.27.0" + "@esbuild/openharmony-arm64": "npm:0.27.0" + "@esbuild/sunos-x64": "npm:0.27.0" + "@esbuild/win32-arm64": "npm:0.27.0" + "@esbuild/win32-ia32": "npm:0.27.0" + "@esbuild/win32-x64": "npm:0.27.0" dependenciesMeta: "@esbuild/aix-ppc64": optional: true @@ -5028,40 +4839,40 @@ __metadata: optional: true bin: esbuild: bin/esbuild - checksum: 10c0/c205357531423220a9de8e1e6c6514242bc9b1666e762cd67ccdf8fdfdc3f1d0bd76f8d9383958b97ad4c953efdb7b6e8c1f9ca5951cd2b7c5235e8755b34a6b + checksum: 10c0/a3a1deec285337b7dfe25cbb9aa8765d27a0192b610a8477a39bf5bd907a6bdb75e98898b61fb4337114cfadb13163bd95977db14e241373115f548e235b40a2 languageName: node linkType: hard -"esbuild@npm:^0.27.0": - version: 0.27.3 - resolution: "esbuild@npm:0.27.3" - dependencies: - "@esbuild/aix-ppc64": "npm:0.27.3" - "@esbuild/android-arm": "npm:0.27.3" - "@esbuild/android-arm64": "npm:0.27.3" - "@esbuild/android-x64": "npm:0.27.3" - "@esbuild/darwin-arm64": "npm:0.27.3" - "@esbuild/darwin-x64": "npm:0.27.3" - "@esbuild/freebsd-arm64": "npm:0.27.3" - "@esbuild/freebsd-x64": "npm:0.27.3" - "@esbuild/linux-arm": "npm:0.27.3" - "@esbuild/linux-arm64": "npm:0.27.3" - "@esbuild/linux-ia32": "npm:0.27.3" - "@esbuild/linux-loong64": "npm:0.27.3" - "@esbuild/linux-mips64el": "npm:0.27.3" - "@esbuild/linux-ppc64": "npm:0.27.3" - "@esbuild/linux-riscv64": "npm:0.27.3" - "@esbuild/linux-s390x": "npm:0.27.3" - "@esbuild/linux-x64": "npm:0.27.3" - "@esbuild/netbsd-arm64": "npm:0.27.3" - "@esbuild/netbsd-x64": "npm:0.27.3" - "@esbuild/openbsd-arm64": "npm:0.27.3" - "@esbuild/openbsd-x64": "npm:0.27.3" - "@esbuild/openharmony-arm64": "npm:0.27.3" - "@esbuild/sunos-x64": "npm:0.27.3" - "@esbuild/win32-arm64": "npm:0.27.3" - "@esbuild/win32-ia32": "npm:0.27.3" - "@esbuild/win32-x64": "npm:0.27.3" +"esbuild@npm:^0.25.0": + version: 0.25.12 + resolution: "esbuild@npm:0.25.12" + dependencies: + "@esbuild/aix-ppc64": "npm:0.25.12" + "@esbuild/android-arm": "npm:0.25.12" + "@esbuild/android-arm64": "npm:0.25.12" + "@esbuild/android-x64": "npm:0.25.12" + "@esbuild/darwin-arm64": "npm:0.25.12" + "@esbuild/darwin-x64": "npm:0.25.12" + "@esbuild/freebsd-arm64": "npm:0.25.12" + "@esbuild/freebsd-x64": "npm:0.25.12" + "@esbuild/linux-arm": "npm:0.25.12" + "@esbuild/linux-arm64": "npm:0.25.12" + "@esbuild/linux-ia32": "npm:0.25.12" + "@esbuild/linux-loong64": "npm:0.25.12" + "@esbuild/linux-mips64el": "npm:0.25.12" + "@esbuild/linux-ppc64": "npm:0.25.12" + "@esbuild/linux-riscv64": "npm:0.25.12" + "@esbuild/linux-s390x": "npm:0.25.12" + "@esbuild/linux-x64": "npm:0.25.12" + "@esbuild/netbsd-arm64": "npm:0.25.12" + "@esbuild/netbsd-x64": "npm:0.25.12" + "@esbuild/openbsd-arm64": "npm:0.25.12" + "@esbuild/openbsd-x64": "npm:0.25.12" + "@esbuild/openharmony-arm64": "npm:0.25.12" + "@esbuild/sunos-x64": "npm:0.25.12" + "@esbuild/win32-arm64": "npm:0.25.12" + "@esbuild/win32-ia32": "npm:0.25.12" + "@esbuild/win32-x64": "npm:0.25.12" dependenciesMeta: "@esbuild/aix-ppc64": optional: true @@ -5117,7 +4928,7 @@ __metadata: optional: true bin: esbuild: bin/esbuild - checksum: 10c0/fdc3f87a3f08b3ef98362f37377136c389a0d180fda4b8d073b26ba930cf245521db0a368f119cc7624bc619248fff1439f5811f062d853576f8ffa3df8ee5f1 + checksum: 10c0/c205357531423220a9de8e1e6c6514242bc9b1666e762cd67ccdf8fdfdc3f1d0bd76f8d9383958b97ad4c953efdb7b6e8c1f9ca5951cd2b7c5235e8755b34a6b languageName: node linkType: hard @@ -5255,7 +5066,7 @@ __metadata: languageName: node linkType: hard -"expect-type@npm:^1.2.2": +"expect-type@npm:^1.3.0": version: 1.3.0 resolution: "expect-type@npm:1.3.0" checksum: 10c0/8412b3fe4f392c420ab41dae220b09700e4e47c639a29ba7ba2e83cc6cffd2b4926f7ac9e47d7e277e8f4f02acda76fd6931cb81fd2b382fa9477ef9ada953fd @@ -5308,6 +5119,13 @@ __metadata: languageName: node linkType: hard +"exsolve@npm:^1.0.7": + version: 1.0.8 + resolution: "exsolve@npm:1.0.8" + checksum: 10c0/65e44ae05bd4a4a5d87cfdbbd6b8f24389282cf9f85fa5feb17ca87ad3f354877e6af4cd99e02fc29044174891f82d1d68c77f69234410eb8f163530e6278c67 + languageName: node + linkType: hard + "extend-shallow@npm:^2.0.1": version: 2.0.1 resolution: "extend-shallow@npm:2.0.1" @@ -5991,15 +5809,6 @@ __metadata: languageName: node linkType: hard -"hosted-git-info@npm:^6.0.0, hosted-git-info@npm:^6.1.1": - version: 6.1.3 - resolution: "hosted-git-info@npm:6.1.3" - dependencies: - lru-cache: "npm:^7.5.1" - checksum: 10c0/a1fc10faf67d04d575ebabf89cd5c9e3ebca041d99f42f31143bc8027684da4612c2f6deaf7cf2c09ac3b04dd502ad3957caa49d913628f0558964b2e1e7b414 - languageName: node - linkType: hard - "hosted-git-info@npm:^7.0.0": version: 7.0.2 resolution: "hosted-git-info@npm:7.0.2" @@ -6296,15 +6105,6 @@ __metadata: languageName: node linkType: hard -"is-core-module@npm:^2.8.1": - version: 2.16.1 - resolution: "is-core-module@npm:2.16.1" - dependencies: - hasown: "npm:^2.0.2" - checksum: 10c0/898443c14780a577e807618aaae2b6f745c8538eca5c7bc11388a3f2dc6de82b9902bcc7eb74f07be672b11bbe82dd6a6edded44a00cb3d8f933d0459905eedd - languageName: node - linkType: hard - "is-data-view@npm:^1.0.1, is-data-view@npm:^1.0.2": version: 1.0.2 resolution: "is-data-view@npm:1.0.2" @@ -6953,6 +6753,13 @@ __metadata: languageName: node linkType: hard +"lightningcss-android-arm64@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-android-arm64@npm:1.32.0" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + "lightningcss-darwin-arm64@npm:1.30.2": version: 1.30.2 resolution: "lightningcss-darwin-arm64@npm:1.30.2" @@ -6960,6 +6767,13 @@ __metadata: languageName: node linkType: hard +"lightningcss-darwin-arm64@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-darwin-arm64@npm:1.32.0" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + "lightningcss-darwin-x64@npm:1.30.2": version: 1.30.2 resolution: "lightningcss-darwin-x64@npm:1.30.2" @@ -6967,6 +6781,13 @@ __metadata: languageName: node linkType: hard +"lightningcss-darwin-x64@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-darwin-x64@npm:1.32.0" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + "lightningcss-freebsd-x64@npm:1.30.2": version: 1.30.2 resolution: "lightningcss-freebsd-x64@npm:1.30.2" @@ -6974,6 +6795,13 @@ __metadata: languageName: node linkType: hard +"lightningcss-freebsd-x64@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-freebsd-x64@npm:1.32.0" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + "lightningcss-linux-arm-gnueabihf@npm:1.30.2": version: 1.30.2 resolution: "lightningcss-linux-arm-gnueabihf@npm:1.30.2" @@ -6981,6 +6809,13 @@ __metadata: languageName: node linkType: hard +"lightningcss-linux-arm-gnueabihf@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-linux-arm-gnueabihf@npm:1.32.0" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + "lightningcss-linux-arm64-gnu@npm:1.30.2": version: 1.30.2 resolution: "lightningcss-linux-arm64-gnu@npm:1.30.2" @@ -6988,6 +6823,13 @@ __metadata: languageName: node linkType: hard +"lightningcss-linux-arm64-gnu@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-linux-arm64-gnu@npm:1.32.0" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + "lightningcss-linux-arm64-musl@npm:1.30.2": version: 1.30.2 resolution: "lightningcss-linux-arm64-musl@npm:1.30.2" @@ -6995,6 +6837,13 @@ __metadata: languageName: node linkType: hard +"lightningcss-linux-arm64-musl@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-linux-arm64-musl@npm:1.32.0" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + "lightningcss-linux-x64-gnu@npm:1.30.2": version: 1.30.2 resolution: "lightningcss-linux-x64-gnu@npm:1.30.2" @@ -7002,6 +6851,13 @@ __metadata: languageName: node linkType: hard +"lightningcss-linux-x64-gnu@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-linux-x64-gnu@npm:1.32.0" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + "lightningcss-linux-x64-musl@npm:1.30.2": version: 1.30.2 resolution: "lightningcss-linux-x64-musl@npm:1.30.2" @@ -7009,6 +6865,13 @@ __metadata: languageName: node linkType: hard +"lightningcss-linux-x64-musl@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-linux-x64-musl@npm:1.32.0" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + "lightningcss-win32-arm64-msvc@npm:1.30.2": version: 1.30.2 resolution: "lightningcss-win32-arm64-msvc@npm:1.30.2" @@ -7016,6 +6879,13 @@ __metadata: languageName: node linkType: hard +"lightningcss-win32-arm64-msvc@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-win32-arm64-msvc@npm:1.32.0" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + "lightningcss-win32-x64-msvc@npm:1.30.2": version: 1.30.2 resolution: "lightningcss-win32-x64-msvc@npm:1.30.2" @@ -7023,7 +6893,57 @@ __metadata: languageName: node linkType: hard -"lightningcss@npm:1.30.2, lightningcss@npm:^1.30.1": +"lightningcss-win32-x64-msvc@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-win32-x64-msvc@npm:1.32.0" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"lightningcss@npm:1.32.0, lightningcss@npm:^1.32.0": + version: 1.32.0 + resolution: "lightningcss@npm:1.32.0" + dependencies: + detect-libc: "npm:^2.0.3" + lightningcss-android-arm64: "npm:1.32.0" + lightningcss-darwin-arm64: "npm:1.32.0" + lightningcss-darwin-x64: "npm:1.32.0" + lightningcss-freebsd-x64: "npm:1.32.0" + lightningcss-linux-arm-gnueabihf: "npm:1.32.0" + lightningcss-linux-arm64-gnu: "npm:1.32.0" + lightningcss-linux-arm64-musl: "npm:1.32.0" + lightningcss-linux-x64-gnu: "npm:1.32.0" + lightningcss-linux-x64-musl: "npm:1.32.0" + lightningcss-win32-arm64-msvc: "npm:1.32.0" + lightningcss-win32-x64-msvc: "npm:1.32.0" + dependenciesMeta: + lightningcss-android-arm64: + optional: true + lightningcss-darwin-arm64: + optional: true + lightningcss-darwin-x64: + optional: true + lightningcss-freebsd-x64: + optional: true + lightningcss-linux-arm-gnueabihf: + optional: true + lightningcss-linux-arm64-gnu: + optional: true + lightningcss-linux-arm64-musl: + optional: true + lightningcss-linux-x64-gnu: + optional: true + lightningcss-linux-x64-musl: + optional: true + lightningcss-win32-arm64-msvc: + optional: true + lightningcss-win32-x64-msvc: + optional: true + checksum: 10c0/70945bd55097af46fc9fab7f5ed09cd5869d85940a2acab7ee06d0117004a1d68155708a2d462531cea2fc3c67aefc9333a7068c80b0b78dd404c16838809e03 + languageName: node + linkType: hard + +"lightningcss@npm:^1.30.1": version: 1.30.2 resolution: "lightningcss@npm:1.30.2" dependencies: @@ -7137,13 +7057,6 @@ __metadata: languageName: node linkType: hard -"lru-cache@npm:^7.4.4, lru-cache@npm:^7.5.1": - version: 7.18.3 - resolution: "lru-cache@npm:7.18.3" - checksum: 10c0/b3a452b491433db885beed95041eb104c157ef7794b9c9b4d647be503be91769d11206bb573849a16b4cc0d03cbd15ffd22df7960997788b74c1d399ac7a4fed - languageName: node - linkType: hard - "lru_map@npm:^0.3.3": version: 0.3.3 resolution: "lru_map@npm:0.3.3" @@ -8290,18 +8203,6 @@ __metadata: languageName: node linkType: hard -"normalize-package-data@npm:^5.0.0": - version: 5.0.0 - resolution: "normalize-package-data@npm:5.0.0" - dependencies: - hosted-git-info: "npm:^6.0.0" - is-core-module: "npm:^2.8.1" - semver: "npm:^7.3.5" - validate-npm-package-license: "npm:^3.0.4" - checksum: 10c0/705fe66279edad2f93f6e504d5dc37984e404361a3df921a76ab61447eb285132d20ff261cc0bee9566b8ce895d75fcfec913417170add267e2873429fe38392 - languageName: node - linkType: hard - "normalize-package-data@npm:^6.0.0": version: 6.0.2 resolution: "normalize-package-data@npm:6.0.2" @@ -8336,18 +8237,6 @@ __metadata: languageName: node linkType: hard -"npm-package-arg@npm:^10.0.0": - version: 10.1.0 - resolution: "npm-package-arg@npm:10.1.0" - dependencies: - hosted-git-info: "npm:^6.0.0" - proc-log: "npm:^3.0.0" - semver: "npm:^7.3.5" - validate-npm-package-name: "npm:^5.0.0" - checksum: 10c0/ab56ed775b48e22755c324536336e3749b6a17763602bc0fb0d7e8b298100c2de8b5e2fb1d4fb3f451e9e076707a27096782e9b3a8da0c5b7de296be184b5a90 - languageName: node - linkType: hard - "npm-package-arg@npm:^11.0.0": version: 11.0.3 resolution: "npm-package-arg@npm:11.0.3" @@ -8360,18 +8249,6 @@ __metadata: languageName: node linkType: hard -"npm-pick-manifest@npm:^8.0.0": - version: 8.0.2 - resolution: "npm-pick-manifest@npm:8.0.2" - dependencies: - npm-install-checks: "npm:^6.0.0" - npm-normalize-package-bin: "npm:^3.0.0" - npm-package-arg: "npm:^10.0.0" - semver: "npm:^7.3.5" - checksum: 10c0/9e58f7732203dbfdd7a338d6fd691c564017fd2ebfaa0ea39528a21db0c99f26370c759d99a0c5684307b79dbf76fa20e387010358a8651e273dc89930e922a0 - languageName: node - linkType: hard - "npm-pick-manifest@npm:^9.0.0": version: 9.1.0 resolution: "npm-pick-manifest@npm:9.1.0" @@ -8707,6 +8584,13 @@ __metadata: languageName: node linkType: hard +"picomatch@npm:^4.0.4": + version: 4.0.4 + resolution: "picomatch@npm:4.0.4" + checksum: 10c0/e2c6023372cc7b5764719a5ffb9da0f8e781212fa7ca4bd0562db929df8e117460f00dff3cb7509dacfc06b86de924b247f504d0ce1806a37fac4633081466b0 + languageName: node + linkType: hard + "pino-abstract-transport@npm:^2.0.0": version: 2.0.0 resolution: "pino-abstract-transport@npm:2.0.0" @@ -8767,14 +8651,14 @@ __metadata: languageName: node linkType: hard -"pixelmatch@npm:7.1.0": - version: 7.1.0 - resolution: "pixelmatch@npm:7.1.0" +"pkg-types@npm:^2.3.0": + version: 2.3.0 + resolution: "pkg-types@npm:2.3.0" dependencies: - pngjs: "npm:^7.0.0" - bin: - pixelmatch: bin/pixelmatch - checksum: 10c0/ff069f92edaa841ac9b58b0ab74e1afa1f3b5e770eea0218c96bac1da4e752f5f6b79a0f9c4ba6b02afb955d39b8c78bcc3cc884f8122b67a1f2efbbccbe1a73 + confbox: "npm:^0.2.2" + exsolve: "npm:^1.0.7" + pathe: "npm:^2.0.3" + checksum: 10c0/d2bbddc5b81bd4741e1529c08ef4c5f1542bbdcf63498b73b8e1d84cff71806d1b8b1577800549bb569cb7aa20056257677b979bff48c97967cba7e64f72ae12 languageName: node linkType: hard @@ -8837,6 +8721,17 @@ __metadata: languageName: node linkType: hard +"postcss@npm:^8.5.8": + version: 8.5.8 + resolution: "postcss@npm:8.5.8" + dependencies: + nanoid: "npm:^3.3.11" + picocolors: "npm:^1.1.1" + source-map-js: "npm:^1.2.1" + checksum: 10c0/dd918f7127ee7c60a0295bae2e72b3787892296e1d1c3c564d7a2a00c68d8df83cadc3178491259daa19ccc54804fb71ed8c937c6787e08d8bd4bedf8d17044c + languageName: node + linkType: hard + "prettier@npm:^3.6.2": version: 3.6.2 resolution: "prettier@npm:3.6.2" @@ -8846,13 +8741,6 @@ __metadata: languageName: node linkType: hard -"proc-log@npm:^3.0.0": - version: 3.0.0 - resolution: "proc-log@npm:3.0.0" - checksum: 10c0/f66430e4ff947dbb996058f6fd22de2c66612ae1a89b097744e17fb18a4e8e7a86db99eda52ccf15e53f00b63f4ec0b0911581ff2aac0355b625c8eac509b0dc - languageName: node - linkType: hard - "proc-log@npm:^4.0.0, proc-log@npm:^4.2.0": version: 4.2.0 resolution: "proc-log@npm:4.2.0" @@ -9015,14 +8903,14 @@ __metadata: languageName: node linkType: hard -"react-dom@npm:^19.1.0": - version: 19.2.0 - resolution: "react-dom@npm:19.2.0" +"react-dom@npm:^19.2.4": + version: 19.2.4 + resolution: "react-dom@npm:19.2.4" dependencies: scheduler: "npm:^0.27.0" peerDependencies: - react: ^19.2.0 - checksum: 10c0/fa2cae05248d01288e91523b590ce4e7635b1e13f1344e225f850d722a8da037bf0782f63b1c1d46353334e0c696909b82e582f8cad607948fde6f7646cc18d9 + react: ^19.2.4 + checksum: 10c0/f0c63f1794dedb154136d4d0f59af00b41907f4859571c155940296808f4b94bf9c0c20633db75b5b2112ec13d8d7dd4f9bf57362ed48782f317b11d05a44f35 languageName: node linkType: hard @@ -9055,22 +8943,15 @@ __metadata: languageName: node linkType: hard -"react-refresh@npm:^0.17.0": - version: 0.17.0 - resolution: "react-refresh@npm:0.17.0" - checksum: 10c0/002cba940384c9930008c0bce26cac97a9d5682bc623112c2268ba0c155127d9c178a9a5cc2212d560088d60dfd503edd808669a25f9b377f316a32361d0b23c - languageName: node - linkType: hard - -"react-router-dom@npm:^7.9.4": - version: 7.9.6 - resolution: "react-router-dom@npm:7.9.6" +"react-router-dom@npm:^7.14.0": + version: 7.14.0 + resolution: "react-router-dom@npm:7.14.0" dependencies: - react-router: "npm:7.9.6" + react-router: "npm:7.14.0" peerDependencies: react: ">=18" react-dom: ">=18" - checksum: 10c0/63984c46385da232655b9e3a8a99f6dd7b94c36827be6e954f246c362f83740b5f59b1de99cae81da3b0cef2220d701dcc22e4fafb4a84600541e1c0450b9d57 + checksum: 10c0/f7130c7083c2a8921aa59e9a9755ae4b79ef98b4df0ae84052ab0fd95b27612a7ebd2539b83d299b8073f8b5fc41595e8cc82bf748837d95d166f8ee19bf5f24 languageName: node linkType: hard @@ -9110,25 +8991,9 @@ __metadata: languageName: node linkType: hard -"react-router@npm:7.9.6": - version: 7.9.6 - resolution: "react-router@npm:7.9.6" - dependencies: - cookie: "npm:^1.0.1" - set-cookie-parser: "npm:^2.6.0" - peerDependencies: - react: ">=18" - react-dom: ">=18" - peerDependenciesMeta: - react-dom: - optional: true - checksum: 10c0/2a177bbe19021e3b8211df849ea5b3f3a4f482327e6de3341aaeaa4f1406dc9be7b675b229eefea6761e04a59a40ccaaf8188f2ee88eb2d0b2a6b6448daea368 - languageName: node - linkType: hard - -"react-router@npm:^7.12.0": - version: 7.12.0 - resolution: "react-router@npm:7.12.0" +"react-router@npm:7.14.0, react-router@npm:^7.14.0": + version: 7.14.0 + resolution: "react-router@npm:7.14.0" dependencies: cookie: "npm:^1.0.1" set-cookie-parser: "npm:^2.6.0" @@ -9138,14 +9003,14 @@ __metadata: peerDependenciesMeta: react-dom: optional: true - checksum: 10c0/abde366f716cb3961a5a390c278375c0591bace5773e1b4420001f0a913b4dd53d490e7dea866acebcac2c0fa07378aa83702769d449449027406ed517a8ea00 + checksum: 10c0/a496489973cd5e87dcc5c1c7312f4cc99463eb5e0a0f97b3f298467531b754a3227562a83e0c9019b9d2452fd0681d05882ee061af2e0cafb0818f857578b805 languageName: node linkType: hard -"react@npm:^19.1.0": - version: 19.2.0 - resolution: "react@npm:19.2.0" - checksum: 10c0/1b6d64eacb9324725bfe1e7860cb7a6b8a34bc89a482920765ebff5c10578eb487e6b46b2f0df263bd27a25edbdae2c45e5ea5d81ae61404301c1a7192c38330 +"react@npm:^19.2.4": + version: 19.2.4 + resolution: "react@npm:19.2.4" + checksum: 10c0/cd2c9ff67a720799cc3b38a516009986f7fc4cb8d3e15716c6211cf098d1357ee3e348ab05ad0600042bbb0fd888530ba92e329198c92eafa0994f5213396596 languageName: node linkType: hard @@ -9492,16 +9357,16 @@ __metadata: "@mdx-js/mdx": "npm:^3.1.1" "@node-cli/static-server": "npm:^3.1.4" "@prettier/plugin-oxc": "npm:^0.0.4" - "@react-router/dev": "npm:^7.8.1" - "@react-router/node": "npm:^7.8.1" + "@react-router/dev": "npm:^7.14.0" + "@react-router/node": "npm:^7.14.0" "@replit/codemirror-vim": "npm:^6.3.0" - "@rescript/react": "npm:^0.14.0" + "@rescript/react": "npm:^0.14.2" "@rescript/webapi": "npm:0.1.0-experimental-29db5f4" - "@tailwindcss/vite": "npm:^4.1.13" + "@tailwindcss/vite": "npm:^4.2.2" "@tsnobip/rescript-lezer": "npm:^0.8.0" - "@types/react": "npm:^19.2.2" - "@vitejs/plugin-react": "npm:^4.7.0" - "@vitest/browser-playwright": "npm:^4.0.18" + "@types/react": "npm:^19.2.14" + "@vitejs/plugin-react": "npm:^6.0.1" + "@vitest/browser-playwright": "npm:^4.1.2" auto-image-converter: "npm:^2.1.2" chokidar: "npm:^4.0.3" docson: "npm:^2.1.0" @@ -9520,11 +9385,11 @@ __metadata: mdast-util-toc: "npm:^7.1.0" playwright: "npm:^1.58.2" prettier: "npm:^3.6.2" - react: "npm:^19.1.0" - react-dom: "npm:^19.1.0" + react: "npm:^19.2.4" + react-dom: "npm:^19.2.4" react-markdown: "npm:^10.1.0" - react-router: "npm:^7.12.0" - react-router-dom: "npm:^7.9.4" + react-router: "npm:^7.14.0" + react-router-dom: "npm:^7.14.0" react-router-mdx: "patch:react-router-mdx@npm%3A1.0.8#~/.yarn/patches/react-router-mdx-npm-1.0.8-d4402c3003.patch" rehype-slug: "npm:^6.0.0" rehype-stringify: "npm:^10.0.1" @@ -9534,33 +9399,33 @@ __metadata: remark-frontmatter: "npm:^5.0.0" remark-gfm: "npm:^4.0.1" remark-validate-links: "npm:^13.1.0" - rescript: "npm:^12.0.0" + rescript: "npm:^12.2.0" search-insights: "npm:^2.17.3" tailwindcss: "npm:^4" to-vfile: "npm:^8.0.0" unified: "npm:^11.0.5" vfile-matter: "npm:^5.0.0" vfile-reporter: "npm:^8.1.1" - vite: "npm:^7.0.6" + vite: "npm:^8.0.3" vite-plugin-devtools-json: "npm:^1.0.0" vite-plugin-env-compatible: "npm:^2.0.1" - vite-plugin-page-reload: "npm:^0.2.2" - vitest: "npm:^4.0.18" - vitest-browser-react: "npm:^2.0.5" + vite-plugin-page-reload: "npm:^0.2.3" + vitest: "npm:^4.1.2" + vitest-browser-react: "npm:^2.2.0" wrangler: "npm:^4.63.0" languageName: unknown linkType: soft -"rescript@npm:^12.0.0, rescript@npm:^12.0.0-beta.3": - version: 12.0.0 - resolution: "rescript@npm:12.0.0" +"rescript@npm:^12.0.0-beta.3, rescript@npm:^12.2.0": + version: 12.2.0 + resolution: "rescript@npm:12.2.0" dependencies: - "@rescript/darwin-arm64": "npm:12.0.0" - "@rescript/darwin-x64": "npm:12.0.0" - "@rescript/linux-arm64": "npm:12.0.0" - "@rescript/linux-x64": "npm:12.0.0" - "@rescript/runtime": "npm:12.0.0" - "@rescript/win32-x64": "npm:12.0.0" + "@rescript/darwin-arm64": "npm:12.2.0" + "@rescript/darwin-x64": "npm:12.2.0" + "@rescript/linux-arm64": "npm:12.2.0" + "@rescript/linux-x64": "npm:12.2.0" + "@rescript/runtime": "npm:12.2.0" + "@rescript/win32-x64": "npm:12.2.0" dependenciesMeta: "@rescript/darwin-arm64": optional: true @@ -9578,7 +9443,7 @@ __metadata: rescript: cli/rescript.js rescript-legacy: cli/rescript-legacy.js rescript-tools: cli/rescript-tools.js - checksum: 10c0/8fc92a806d86825fe593cc2c23accc39c95236b4885f9c6d9874fc1b375dd6dd07f8a99ef4c8bbbb273a6f44d47439b5cb4a62577a57ab9ecb0c7ad15021846c + checksum: 10c0/7650a77a66e2f2ba2523eac54fd930f0b70bad922e9b498678e701e842a56c7b6facd0daf1ef9c7b71d344d9483a0293d17db72a67515a22d07856bdfc34fb46 languageName: node linkType: hard @@ -9620,6 +9485,64 @@ __metadata: languageName: node linkType: hard +"rolldown@npm:1.0.0-rc.12": + version: 1.0.0-rc.12 + resolution: "rolldown@npm:1.0.0-rc.12" + dependencies: + "@oxc-project/types": "npm:=0.122.0" + "@rolldown/binding-android-arm64": "npm:1.0.0-rc.12" + "@rolldown/binding-darwin-arm64": "npm:1.0.0-rc.12" + "@rolldown/binding-darwin-x64": "npm:1.0.0-rc.12" + "@rolldown/binding-freebsd-x64": "npm:1.0.0-rc.12" + "@rolldown/binding-linux-arm-gnueabihf": "npm:1.0.0-rc.12" + "@rolldown/binding-linux-arm64-gnu": "npm:1.0.0-rc.12" + "@rolldown/binding-linux-arm64-musl": "npm:1.0.0-rc.12" + "@rolldown/binding-linux-ppc64-gnu": "npm:1.0.0-rc.12" + "@rolldown/binding-linux-s390x-gnu": "npm:1.0.0-rc.12" + "@rolldown/binding-linux-x64-gnu": "npm:1.0.0-rc.12" + "@rolldown/binding-linux-x64-musl": "npm:1.0.0-rc.12" + "@rolldown/binding-openharmony-arm64": "npm:1.0.0-rc.12" + "@rolldown/binding-wasm32-wasi": "npm:1.0.0-rc.12" + "@rolldown/binding-win32-arm64-msvc": "npm:1.0.0-rc.12" + "@rolldown/binding-win32-x64-msvc": "npm:1.0.0-rc.12" + "@rolldown/pluginutils": "npm:1.0.0-rc.12" + dependenciesMeta: + "@rolldown/binding-android-arm64": + optional: true + "@rolldown/binding-darwin-arm64": + optional: true + "@rolldown/binding-darwin-x64": + optional: true + "@rolldown/binding-freebsd-x64": + optional: true + "@rolldown/binding-linux-arm-gnueabihf": + optional: true + "@rolldown/binding-linux-arm64-gnu": + optional: true + "@rolldown/binding-linux-arm64-musl": + optional: true + "@rolldown/binding-linux-ppc64-gnu": + optional: true + "@rolldown/binding-linux-s390x-gnu": + optional: true + "@rolldown/binding-linux-x64-gnu": + optional: true + "@rolldown/binding-linux-x64-musl": + optional: true + "@rolldown/binding-openharmony-arm64": + optional: true + "@rolldown/binding-wasm32-wasi": + optional: true + "@rolldown/binding-win32-arm64-msvc": + optional: true + "@rolldown/binding-win32-x64-msvc": + optional: true + bin: + rolldown: bin/cli.mjs + checksum: 10c0/0c4e5e3cdcdddce282cb2d84e1c98d6ad8d4e452d5c1402e498b35ec1060026e552dd783efc9f4ba876d7c0863b5973edc79b6a546f565e9832dc1077ec18c2c + languageName: node + linkType: hard + "rollup@npm:^4.43.0": version: 4.53.3 resolution: "rollup@npm:4.53.3" @@ -10289,10 +10212,10 @@ __metadata: languageName: node linkType: hard -"std-env@npm:^3.10.0": - version: 3.10.0 - resolution: "std-env@npm:3.10.0" - checksum: 10c0/1814927a45004d36dde6707eaf17552a546769bc79a6421be2c16ce77d238158dfe5de30910b78ec30d95135cc1c59ea73ee22d2ca170f8b9753f84da34c427f +"std-env@npm:^4.0.0-rc.1": + version: 4.0.0 + resolution: "std-env@npm:4.0.0" + checksum: 10c0/63b1716eae27947adde49e21b7225a0f75fb2c3d410273ae9de8333c07c7d5fc7a0628ae4c8af6b4b49b4274ed46c2bf118ed69b64f1261c9d8213d76ed1c16c languageName: node linkType: hard @@ -10527,17 +10450,24 @@ __metadata: languageName: node linkType: hard -"tailwindcss@npm:4.1.17, tailwindcss@npm:^4": +"tailwindcss@npm:4.2.2": + version: 4.2.2 + resolution: "tailwindcss@npm:4.2.2" + checksum: 10c0/6eae8a125c35d504ba6c518d26ec64fba694ff4a9ab9b9cd9883050128e0b7afdf491388c472d9bed2624664c1c7d4a133d19b653151a6b52e6ce6953168a857 + languageName: node + linkType: hard + +"tailwindcss@npm:^4": version: 4.1.17 resolution: "tailwindcss@npm:4.1.17" checksum: 10c0/1fecf618ba9895e068e5a6d842b978f56a815bc849a28338cebbcb07b13df763715c2f8848def938403c73d59f08ffff33a4b83a977a9e38fa56adc60d1d56c8 languageName: node linkType: hard -"tapable@npm:^2.2.0": - version: 2.3.0 - resolution: "tapable@npm:2.3.0" - checksum: 10c0/cb9d67cc2c6a74dedc812ef3085d9d681edd2c1fa18e4aef57a3c0605fdbe44e6b8ea00bd9ef21bc74dd45314e39d31227aa031ebf2f5e38164df514136f2681 +"tapable@npm:^2.3.0": + version: 2.3.2 + resolution: "tapable@npm:2.3.2" + checksum: 10c0/45ec8bd8963907f35bba875f9b3e9a5afa5ba11a9a4e4a2d7b2313d983cb2741386fd7dd3e54b13055b2be942971aac369d197e02263ec9216c59c0a8069ed7f languageName: node linkType: hard @@ -10611,10 +10541,10 @@ __metadata: languageName: node linkType: hard -"tinyrainbow@npm:^3.0.3": - version: 3.0.3 - resolution: "tinyrainbow@npm:3.0.3" - checksum: 10c0/1e799d35cd23cabe02e22550985a3051dc88814a979be02dc632a159c393a998628eacfc558e4c746b3006606d54b00bcdea0c39301133956d10a27aa27e988c +"tinyrainbow@npm:^3.1.0": + version: 3.1.0 + resolution: "tinyrainbow@npm:3.1.0" + checksum: 10c0/f11cf387a26c5c9255bec141a90ac511b26172981b10c3e50053bc6700ea7d2336edcc4a3a21dbb8412fe7c013477d2ba4d7e4877800f3f8107be5105aad6511 languageName: node linkType: hard @@ -10718,7 +10648,7 @@ __metadata: languageName: node linkType: hard -"tslib@npm:^2.4.0, tslib@npm:^2.8.0": +"tslib@npm:^2.4.0, tslib@npm:^2.8.0, tslib@npm:^2.8.1": version: 2.8.1 resolution: "tslib@npm:2.8.1" checksum: 10c0/9c4759110a19c53f992d9aae23aac5ced636e99887b51b9e61def52611732872ff7668757d4e4c61f19691e36f4da981cd9485e869b4a7408d689f6bf1f14e62 @@ -11097,15 +11027,15 @@ __metadata: languageName: node linkType: hard -"valibot@npm:^1.1.0": - version: 1.2.0 - resolution: "valibot@npm:1.2.0" +"valibot@npm:^1.2.0": + version: 1.3.1 + resolution: "valibot@npm:1.3.1" peerDependencies: typescript: ">=5" peerDependenciesMeta: typescript: optional: true - checksum: 10c0/e6897ed2008fc900380a6ce39b62bc5fca45fd5e070f70571c6380ede3ba026d0b7016230215d87f7f3d672a28dbde5a0522d39830b493fdc3dccd1a59ef4ee6 + checksum: 10c0/e20a4097fa726f57530da1e64558af47ddd2303129c77978fe93c522c66cf4c79540ea3af864523589283ea25e347c3d65b8044fa4913376208dde576b9f6382 languageName: node linkType: hard @@ -11235,19 +11165,19 @@ __metadata: languageName: node linkType: hard -"vite-plugin-page-reload@npm:^0.2.2": - version: 0.2.2 - resolution: "vite-plugin-page-reload@npm:0.2.2" +"vite-plugin-page-reload@npm:^0.2.3": + version: 0.2.3 + resolution: "vite-plugin-page-reload@npm:0.2.3" dependencies: picocolors: "npm:^1.0.0" picomatch: "npm:^2.3.1" peerDependencies: - vite: ^5.0.0 || ^6.0.0 || ^7.0.0 - checksum: 10c0/7959914fb6102889b0aa7406d97734b29b3e208fa2afad13f2f9ed677daa296f7bca9cd1746160b681474081c0415a98a2bba13726de5bbaeaf4cf96e2b09fd6 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + checksum: 10c0/8949e95fb1830a9f360a8d6a110d5c7766279655329de90e038b7490f6f4527e51126cfcfafcda188d871f9e69dc39e4e6f9ceef214997c3fb62cdb37c0c0014 languageName: node linkType: hard -"vite@npm:^5.0.0 || ^6.0.0 || ^7.0.0-0, vite@npm:^7.0.6": +"vite@npm:^5.0.0 || ^6.0.0 || ^7.0.0-0": version: 7.2.4 resolution: "vite@npm:7.2.4" dependencies: @@ -11302,22 +11232,22 @@ __metadata: languageName: node linkType: hard -"vite@npm:^6.0.0 || ^7.0.0": - version: 7.3.1 - resolution: "vite@npm:7.3.1" +"vite@npm:^6.0.0 || ^7.0.0 || ^8.0.0, vite@npm:^8.0.3": + version: 8.0.3 + resolution: "vite@npm:8.0.3" dependencies: - esbuild: "npm:^0.27.0" - fdir: "npm:^6.5.0" fsevents: "npm:~2.3.3" - picomatch: "npm:^4.0.3" - postcss: "npm:^8.5.6" - rollup: "npm:^4.43.0" + lightningcss: "npm:^1.32.0" + picomatch: "npm:^4.0.4" + postcss: "npm:^8.5.8" + rolldown: "npm:1.0.0-rc.12" tinyglobby: "npm:^0.2.15" peerDependencies: "@types/node": ^20.19.0 || >=22.12.0 + "@vitejs/devtools": ^0.1.0 + esbuild: ^0.27.0 jiti: ">=1.21.0" less: ^4.0.0 - lightningcss: ^1.21.0 sass: ^1.70.0 sass-embedded: ^1.70.0 stylus: ">=0.54.8" @@ -11331,12 +11261,14 @@ __metadata: peerDependenciesMeta: "@types/node": optional: true + "@vitejs/devtools": + optional: true + esbuild: + optional: true jiti: optional: true less: optional: true - lightningcss: - optional: true sass: optional: true sass-embedded: @@ -11353,13 +11285,13 @@ __metadata: optional: true bin: vite: bin/vite.js - checksum: 10c0/5c7548f5f43a23533e53324304db4ad85f1896b1bfd3ee32ae9b866bac2933782c77b350eb2b52a02c625c8ad1ddd4c000df077419410650c982cd97fde8d014 + checksum: 10c0/bed9520358080393a02fe22565b3309b4b3b8f916afe4c97577528f3efb05c1bf4b29f7b552179bc5b3938629e50fbd316231727457411dbc96648fa5c9d14bf languageName: node linkType: hard -"vitest-browser-react@npm:^2.0.5": - version: 2.0.5 - resolution: "vitest-browser-react@npm:2.0.5" +"vitest-browser-react@npm:^2.2.0": + version: 2.2.0 + resolution: "vitest-browser-react@npm:2.2.0" peerDependencies: "@types/react": ^18.0.0 || ^19.0.0 "@types/react-dom": ^18.0.0 || ^19.0.0 @@ -11371,44 +11303,45 @@ __metadata: optional: true "@types/react-dom": optional: true - checksum: 10c0/ae972fa20895c73622c2e724a2e2a716cc2a2e5148da19a60d1185323aeb5f5bd0653cfe3048d081bb086ee0efa68c0c360d28cdf42ddd8df6a5f2d17ffd0c9e + checksum: 10c0/d2e582e564cf7f65f19a5a9c36b0b136e84fc6dabd42566703d79e0b220094a5a88a9197a42b2c4779f38977d79c8f3306387cd7edd2ef8e57790b921a759975 languageName: node linkType: hard -"vitest@npm:^4.0.18": - version: 4.0.18 - resolution: "vitest@npm:4.0.18" - dependencies: - "@vitest/expect": "npm:4.0.18" - "@vitest/mocker": "npm:4.0.18" - "@vitest/pretty-format": "npm:4.0.18" - "@vitest/runner": "npm:4.0.18" - "@vitest/snapshot": "npm:4.0.18" - "@vitest/spy": "npm:4.0.18" - "@vitest/utils": "npm:4.0.18" - es-module-lexer: "npm:^1.7.0" - expect-type: "npm:^1.2.2" +"vitest@npm:^4.1.2": + version: 4.1.2 + resolution: "vitest@npm:4.1.2" + dependencies: + "@vitest/expect": "npm:4.1.2" + "@vitest/mocker": "npm:4.1.2" + "@vitest/pretty-format": "npm:4.1.2" + "@vitest/runner": "npm:4.1.2" + "@vitest/snapshot": "npm:4.1.2" + "@vitest/spy": "npm:4.1.2" + "@vitest/utils": "npm:4.1.2" + es-module-lexer: "npm:^2.0.0" + expect-type: "npm:^1.3.0" magic-string: "npm:^0.30.21" obug: "npm:^2.1.1" pathe: "npm:^2.0.3" picomatch: "npm:^4.0.3" - std-env: "npm:^3.10.0" + std-env: "npm:^4.0.0-rc.1" tinybench: "npm:^2.9.0" tinyexec: "npm:^1.0.2" tinyglobby: "npm:^0.2.15" - tinyrainbow: "npm:^3.0.3" - vite: "npm:^6.0.0 || ^7.0.0" + tinyrainbow: "npm:^3.1.0" + vite: "npm:^6.0.0 || ^7.0.0 || ^8.0.0" why-is-node-running: "npm:^2.3.0" peerDependencies: "@edge-runtime/vm": "*" "@opentelemetry/api": ^1.9.0 "@types/node": ^20.0.0 || ^22.0.0 || >=24.0.0 - "@vitest/browser-playwright": 4.0.18 - "@vitest/browser-preview": 4.0.18 - "@vitest/browser-webdriverio": 4.0.18 - "@vitest/ui": 4.0.18 + "@vitest/browser-playwright": 4.1.2 + "@vitest/browser-preview": 4.1.2 + "@vitest/browser-webdriverio": 4.1.2 + "@vitest/ui": 4.1.2 happy-dom: "*" jsdom: "*" + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: "@edge-runtime/vm": optional: true @@ -11428,9 +11361,11 @@ __metadata: optional: true jsdom: optional: true + vite: + optional: false bin: vitest: vitest.mjs - checksum: 10c0/b913cd32032c95f29ff08c931f4b4c6fd6d2da498908d6770952c561a1b8d75c62499a1f04cadf82fb89cc0f9a33f29fb5dfdb899f6dbb27686a9d91571be5fa + checksum: 10c0/061fdd0319ba533c926b139b9377a7dbf91e63d815d86fe318a207bd19842b74ca6f6402ea61b26ed9d2924306bdb4d0b13f69c29e2a2a89b3b67602bcccb54c languageName: node linkType: hard @@ -11562,17 +11497,6 @@ __metadata: languageName: node linkType: hard -"which@npm:^3.0.0": - version: 3.0.1 - resolution: "which@npm:3.0.1" - dependencies: - isexe: "npm:^2.0.0" - bin: - node-which: bin/which.js - checksum: 10c0/15263b06161a7c377328fd2066cb1f093f5e8a8f429618b63212b5b8847489be7bcab0ab3eb07f3ecc0eda99a5a7ea52105cf5fa8266bedd083cc5a9f6da24f1 - languageName: node - linkType: hard - "which@npm:^4.0.0": version: 4.0.0 resolution: "which@npm:4.0.0" @@ -11747,9 +11671,9 @@ __metadata: languageName: node linkType: hard -"ws@npm:^8.18.3": - version: 8.19.0 - resolution: "ws@npm:8.19.0" +"ws@npm:^8.19.0": + version: 8.20.0 + resolution: "ws@npm:8.20.0" peerDependencies: bufferutil: ^4.0.1 utf-8-validate: ">=5.0.2" @@ -11758,7 +11682,7 @@ __metadata: optional: true utf-8-validate: optional: true - checksum: 10c0/4741d9b9bc3f9c791880882414f96e36b8b254e34d4b503279d6400d9a4b87a033834856dbdd94ee4b637944df17ea8afc4bce0ff4a1560d2166be8855da5b04 + checksum: 10c0/956ac5f11738c914089b65878b9223692ace77337ba55379ae68e1ecbeae9b47a0c6eb9403688f609999a58c80d83d99865fe0029b229d308b08c1ef93d4ea14 languageName: node linkType: hard From 3d84fe249d55c9589d7e4f94c3ab563cce563fa2 Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Mon, 6 Apr 2026 14:36:53 -0400 Subject: [PATCH 02/32] feat: improve Algolia search results --- .env | 7 +- algolia.mjs | 35 +++ package.json | 4 +- scripts/generate_search_index.res | 169 ++++++++++ src/bindings/Algolia.res | 54 ++++ src/bindings/DocSearch.res | 24 +- src/bindings/Env.res | 5 + src/common/SearchIndex.res | 495 ++++++++++++++++++++++++++++++ src/components/Meta.res | 2 - src/components/Search.res | 108 +------ yarn.lock | 177 +++++++++++ 11 files changed, 975 insertions(+), 105 deletions(-) create mode 100644 algolia.mjs create mode 100644 scripts/generate_search_index.res create mode 100644 src/bindings/Algolia.res create mode 100644 src/common/SearchIndex.res diff --git a/.env b/.env index 14e36c5ec..5bbf70d83 100644 --- a/.env +++ b/.env @@ -1,2 +1,5 @@ -VITE_VERSION_LATEST="v11.0.0" -VITE_VERSION_NEXT="v12.0.0" \ No newline at end of file +VITE_VERSION_LATEST=12.0.0 +VITE_VERSION_NEXT=13.0.0 +VITE_ALGOLIA_READ_API_KEY=667630d6ab41eff82df15fdc6a55153f +VITE_ALGOLIA_APP_ID=1T1PRULLJT +VITE_ALGOLIA_INDEX_NAME=dev_2026 diff --git a/algolia.mjs b/algolia.mjs new file mode 100644 index 000000000..7ba6a0cf8 --- /dev/null +++ b/algolia.mjs @@ -0,0 +1,35 @@ +// helloAlgolia.mjs +import { algoliasearch } from "algoliasearch"; + +const appID = "1T1PRULLJT"; +// API key with `addObject` and `editSettings` ACL +const apiKey = "999e5352ab7aed499de651ee79f573ee"; +const indexName = "dev_2026"; + +const client = algoliasearch(appID, apiKey); + +const record = { objectID: "object-1", name: "test record" }; + +// Add record to an index +const { taskID } = await client.saveObject({ + indexName, + body: record, +}); + +// Wait until indexing is done +await client.waitForTask({ + indexName, + taskID, +}); + +// Search for "test" +const { results } = await client.search({ + requests: [ + { + indexName, + query: "test", + }, + ], +}); + +console.log(JSON.stringify(results, null, 2)); diff --git a/package.json b/package.json index 73ee8dc59..062d17519 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "build:generate-llms": "node _scripts/generate_llms.mjs", "build:res": "rescript build --warn-error +3+8+11+12+26+27+31+32+33+34+35+39+44+45+110", "build:sync-bundles": "node scripts/sync-playground-bundles.mjs", - "build:update-index": "yarn build:generate-llms && node _scripts/generate_feed.mjs > public/blog/feed.xml", + "build:search-index": "node --env-file-if-exists=.env.local _scripts/generate_search_index.mjs", + "build:update-index": "yarn build:generate-llms && node _scripts/generate_feed.mjs > public/blog/feed.xml && yarn build:search-index", "build:vite": "react-router build", "build": "yarn build:res && yarn build:scripts && yarn build:update-index && yarn build:vite", "ci:format": "prettier . --check --experimental-cli", @@ -55,6 +56,7 @@ "@rescript/react": "^0.14.2", "@rescript/webapi": "0.1.0-experimental-29db5f4", "@tsnobip/rescript-lezer": "^0.8.0", + "algoliasearch": "^5.50.1", "docson": "^2.1.0", "fuse.js": "^6.4.3", "glob": "^7.1.4", diff --git a/scripts/generate_search_index.res b/scripts/generate_search_index.res new file mode 100644 index 000000000..b786cf117 --- /dev/null +++ b/scripts/generate_search_index.res @@ -0,0 +1,169 @@ +// Build script: reads all site content, builds Algolia search records, and uploads them. +// Runs as a standalone Node script via: node --env-file-if-exists=.env.local _scripts/generate_search_index.mjs +// +// Required env vars: +// ALGOLIA_ADMIN_API_KEY -- API key with addObject/deleteObject/editSettings ACLs +// ALGOLIA_INDEX_NAME -- e.g. "rescript-lang-dev" or "rescript-lang" +// +// If either is missing, the script logs a warning and exits 0 (graceful skip). + +let getEnv = (key: string): option => + Node.Process.env + ->Dict.get(key) + ->Option.flatMap(v => + switch v { + | "" => None + | s => Some(s) + } + ) + +let main = async () => { + let appId = getEnv("ALGOLIA_APP_ID") + let adminApiKey = getEnv("ALGOLIA_ADMIN_API_KEY") + let indexName = getEnv("ALGOLIA_INDEX_NAME") + + switch (appId, adminApiKey, indexName) { + | (Some(appId), Some(apiKey), Some(idx)) => { + Console.log("[search-index] Building search index records...") + + // 1. Build records from all content sources + let manualRecords = SearchIndex.buildMarkdownRecords( + ~category="Manual", + ~basePath="/docs/manual", + ~dirPath="markdown-pages/docs/manual", + ~pageRank=100, + ) + Console.log( + `[search-index] Manual docs: ${Int.toString(Array.length(manualRecords))} records`, + ) + + let reactRecords = SearchIndex.buildMarkdownRecords( + ~category="React", + ~basePath="/docs/react", + ~dirPath="markdown-pages/docs/react", + ~pageRank=90, + ) + Console.log( + `[search-index] React docs: ${Int.toString(Array.length(reactRecords))} records`, + ) + + let communityRecords = SearchIndex.buildMarkdownRecords( + ~category="Community", + ~basePath="/community", + ~dirPath="markdown-pages/community", + ~pageRank=50, + ) + Console.log( + `[search-index] Community: ${Int.toString(Array.length(communityRecords))} records`, + ) + + let blogRecords = SearchIndex.buildBlogRecords(~dirPath="markdown-pages/blog", ~pageRank=40) + Console.log(`[search-index] Blog: ${Int.toString(Array.length(blogRecords))} records`) + + let syntaxRecords = SearchIndex.buildSyntaxLookupRecords( + ~dirPath="markdown-pages/syntax-lookup", + ~pageRank=70, + ) + Console.log( + `[search-index] Syntax lookup: ${Int.toString(Array.length(syntaxRecords))} records`, + ) + + let stdlibApiRecords = SearchIndex.buildApiRecords( + ~basePath="/docs/manual/api", + ~dirPath="markdown-pages/docs/api", + ~pageRank=80, + ~category="API / StdLib", + ~files=["stdlib.json"], + ) + Console.log( + `[search-index] API / StdLib: ${Int.toString(Array.length(stdlibApiRecords))} records`, + ) + + let beltApiRecords = SearchIndex.buildApiRecords( + ~basePath="/docs/manual/api", + ~dirPath="markdown-pages/docs/api", + ~pageRank=75, + ~category="API / Belt", + ~files=["belt.json"], + ) + Console.log( + `[search-index] API / Belt: ${Int.toString(Array.length(beltApiRecords))} records`, + ) + + let domApiRecords = SearchIndex.buildApiRecords( + ~basePath="/docs/manual/api", + ~dirPath="markdown-pages/docs/api", + ~pageRank=70, + ~category="API / DOM", + ~files=["dom.json"], + ) + Console.log( + `[search-index] API / DOM: ${Int.toString(Array.length(domApiRecords))} records`, + ) + + // 2. Concatenate all records + let allRecords = + [ + manualRecords, + reactRecords, + communityRecords, + blogRecords, + syntaxRecords, + stdlibApiRecords, + beltApiRecords, + domApiRecords, + ]->Array.flat + + let totalCount = Array.length(allRecords) + Console.log(`[search-index] Total: ${Int.toString(totalCount)} records`) + + // 3. Convert to JSON for Algolia + let jsonRecords = allRecords->Array.map(SearchIndex.toJson) + + // 4. Initialize Algolia client and upload + let client = Algolia.make(appId, apiKey) + + Console.log(`[search-index] Uploading to index "${idx}"...`) + let _ = await client->Algolia.replaceAllObjects({ + indexName: idx, + objects: jsonRecords, + batchSize: 1000, + }) + Console.log("[search-index] Records uploaded successfully.") + + // 5. Configure index settings + Console.log("[search-index] Updating index settings...") + let _ = await client->Algolia.setSettings({ + indexName: idx, + indexSettings: { + searchableAttributes: [ + "hierarchy.lvl0", + "hierarchy.lvl1", + "hierarchy.lvl2", + "hierarchy.lvl3", + "hierarchy.lvl4", + "hierarchy.lvl5", + "hierarchy.lvl6", + "content", + ], + ranking: ["typo", "words", "attribute", "exact", "custom", "proximity", "filters"], + exactOnSingleWordQuery: "word", + attributesForFaceting: ["type"], + customRanking: ["desc(weight.pageRank)", "desc(weight.level)", "asc(weight.position)"], + attributesToSnippet: ["content:30"], + attributeForDistinct: "hierarchy.lvl0", + }, + }) + Console.log("[search-index] Index settings updated.") + + Console.log("[search-index] Done.") + } + | (None, _, _) => Console.log("[search-index] ALGOLIA_APP_ID not set, skipping index upload.") + | (_, None, _) => Console.log( + "[search-index] ALGOLIA_ADMIN_API_KEY not set, skipping index upload.", + ) + | (_, _, None) => Console.log("[search-index] ALGOLIA_INDEX_NAME not set, skipping index upload.") + } +} + +let _ = main() diff --git a/src/bindings/Algolia.res b/src/bindings/Algolia.res new file mode 100644 index 000000000..30cf205ca --- /dev/null +++ b/src/bindings/Algolia.res @@ -0,0 +1,54 @@ +// Bindings for algoliasearch v5 SDK +// https://github.com/algolia/algoliasearch-client-javascript + +module SearchClient = { + type t +} + +module BatchResponse = { + type t +} + +module SetSettingsResponse = { + type t +} + +module IndexSettings = { + type t = { + searchableAttributes?: array, + attributesForFaceting?: array, + customRanking?: array, + ranking?: array, + attributesToSnippet?: array, + attributeForDistinct?: string, + exactOnSingleWordQuery?: string, + } +} + +module ReplaceAllObjectsOptions = { + type t = { + indexName: string, + objects: array, + batchSize?: int, + } +} + +module SetSettingsOptions = { + type t = { + indexName: string, + indexSettings: IndexSettings.t, + } +} + +@module("algoliasearch") +external make: (string, string) => SearchClient.t = "algoliasearch" + +@send +external replaceAllObjects: ( + SearchClient.t, + ReplaceAllObjectsOptions.t, +) => promise> = "replaceAllObjects" + +@send +external setSettings: (SearchClient.t, SetSettingsOptions.t) => promise = + "setSettings" diff --git a/src/bindings/DocSearch.res b/src/bindings/DocSearch.res index 0c42d8586..083dc7ba2 100644 --- a/src/bindings/DocSearch.res +++ b/src/bindings/DocSearch.res @@ -44,7 +44,11 @@ type item = {itemUrl: string} type navigator = {navigate: item => unit} -type searchParameters = {facetFilters: array} +type searchParameters = { + facetFilters?: array, + hitsPerPage?: int, + distinct?: int, +} @module("@docsearch/react") @react.component external make: ( @@ -58,3 +62,21 @@ external make: ( ~searchParameters: searchParameters=?, ~initialScrollY: int=?, ) => React.element = "DocSearchModal" + +let getContentSnippet: docSearchHit => option = %raw(` + function(hit) { + try { + var s = hit._snippetResult; + if (s && s.content && s.content.value) { + var val = s.content.value.trim(); + if (val !== '' && val !== '...') return val; + } + } catch(e) {} + var c = hit.content; + if (c != null) { + c = c.trim(); + if (c !== '') return c; + } + return undefined; + } +`) diff --git a/src/bindings/Env.res b/src/bindings/Env.res index 29a6c4e1b..174ec8da9 100644 --- a/src/bindings/Env.res +++ b/src/bindings/Env.res @@ -9,3 +9,8 @@ let root_url = switch deployment_url { | Some(url) => url | None => dev ? "http://localhost:5173/" : "https://rescript-lang.org/" } + +// Algolia search configuration (read from .env via Vite) +external algolia_app_id: string = "import.meta.env.VITE_ALGOLIA_APP_ID" +external algolia_read_api_key: string = "import.meta.env.VITE_ALGOLIA_READ_API_KEY" +external algolia_index_name: string = "import.meta.env.VITE_ALGOLIA_INDEX_NAME" diff --git a/src/common/SearchIndex.res b/src/common/SearchIndex.res new file mode 100644 index 000000000..7badc03b3 --- /dev/null +++ b/src/common/SearchIndex.res @@ -0,0 +1,495 @@ +type hierarchy = { + lvl0: string, + lvl1: string, + lvl2: option, + lvl3: option, + lvl4: option, + lvl5: option, + lvl6: option, +} + +type weight = { + pageRank: int, + level: int, + position: int, +} + +type record = { + objectID: string, + url: string, + url_without_anchor: string, + anchor: option, + content: option, + @as("type") type_: string, + hierarchy: hierarchy, + weight: weight, +} + +type heading = { + level: int, + text: string, + content: string, +} + +let maxContentLength = 500 + +let makeHierarchy = (~lvl0, ~lvl1, ~lvl2=?, ~lvl3=?, ~lvl4=?, ~lvl5=?, ~lvl6=?, ()) => { + lvl0, + lvl1, + lvl2, + lvl3, + lvl4, + lvl5, + lvl6, +} + +let truncate = (str: string, ~maxLen: int): string => + switch String.length(str) > maxLen { + | true => String.slice(str, ~start=0, ~end=maxLen) ++ "..." + | false => str + } + +// --- Helpers --- + +let slugify = (text: string): string => + text + ->String.toLowerCase + ->String.replaceRegExp(RegExp.fromString("\\s+", ~flags="g"), "-") + ->String.replaceRegExp(RegExp.fromString("[^a-z0-9\\-]", ~flags="g"), "") + +let stripMdxTags = (text: string): string => + text + ->String.replaceRegExp(RegExp.fromString("", ~flags="g"), "") + ->String.replaceRegExp(RegExp.fromString("<[^>]+>", ~flags="g"), "") + ->String.replaceRegExp(RegExp.fromString("```[\\s\\S]*?```", ~flags="g"), "") + ->String.replaceRegExp(RegExp.fromString("`([^`]+)`", ~flags="g"), "$1") + ->String.replaceRegExp(RegExp.fromString("\\*\\*([^*]+)\\*\\*", ~flags="g"), "$1") + ->String.replaceRegExp(RegExp.fromString("\\*([^*]+)\\*", ~flags="g"), "$1") + ->String.replaceRegExp(RegExp.fromString("\\[([^\\]]+)\\]\\([^)]*\\)", ~flags="g"), "$1") + ->String.replaceRegExp(RegExp.fromString("^#{1,6}\\s+", ~flags="gm"), "") + ->String.replaceRegExp(RegExp.fromString("\\n{2,}", ~flags="g"), "\n") + ->String.trim + +let cleanDocstring = (text: string): string => + text + // Take content before first heading + ->String.split("\n## ") + ->Array.get(0) + ->Option.getOr(text) + // Take content before first code block + ->String.split("\n```") + ->Array.get(0) + ->Option.getOr(text) + // Strip inline code backticks + ->String.replaceRegExp(RegExp.fromString("`([^`]+)`", ~flags="g"), "$1") + // Strip bold + ->String.replaceRegExp(RegExp.fromString("\\*\\*([^*]+)\\*\\*", ~flags="g"), "$1") + // Strip italic + ->String.replaceRegExp(RegExp.fromString("\\*([^*]+)\\*", ~flags="g"), "$1") + // Strip links + ->String.replaceRegExp(RegExp.fromString("\\[([^\\]]+)\\]\\([^)]*\\)", ~flags="g"), "$1") + // Collapse multiple newlines into space + ->String.replaceRegExp(RegExp.fromString("\\n{2,}", ~flags="g"), " ") + // Replace remaining newlines with space + ->String.replaceRegExp(RegExp.fromString("\\n", ~flags="g"), " ") + ->String.trim + +let extractIntro = (content: string): string => { + let parts = content->String.split("\n## ") + let intro = parts[0]->Option.getOr("") + intro + // Remove the # H1 heading line if present at the start + ->String.replaceRegExp(RegExp.fromString("^#[^#].*\\n", ~flags=""), "") + ->stripMdxTags + ->String.trim +} + +let findHeadingMatches: string => array<{..}> = %raw(` + function(content) { + var regex = /^(#{2,6})\s+(.+)$/gm; + var results = []; + var match; + while ((match = regex.exec(content)) !== null) { + results.push({ index: match.index, level: match[1].length, text: match[2] }); + } + return results; + } +`) + +let extractHeadings = (content: string): array => { + let matches = findHeadingMatches(content) + + matches->Array.mapWithIndex((m, i) => { + let startIdx = m["index"] + String.length(m["text"]) + m["level"] + 2 + let endIdx = switch matches[i + 1] { + | Some(next) => next["index"] + | None => String.length(content) + } + let sectionContent = + content + ->String.slice(~start=startIdx, ~end=endIdx) + ->stripMdxTags + ->String.trim + ->truncate(~maxLen=maxContentLength) + + { + level: m["level"], + text: m["text"], + content: sectionContent, + } + }) +} + +// --- File collection --- + +let rec collectFiles = (dirPath: string): array => { + let entries = Node.Fs.readdirSync(dirPath) + entries->Array.reduce([], (acc, entry) => { + let fullPath = Node.Path.join([dirPath, entry]) + let stats = Node.Fs.statSync(fullPath) + switch stats["isDirectory"]() { + | true => acc->Array.concat(collectFiles(fullPath)) + | false => { + acc->Array.push(fullPath) + acc + } + } + }) +} + +let isMdxFile = (path: string): bool => Node.Path.extname(path) === ".mdx" + +let filenameWithoutExt = (path: string): string => + Node.Path.basename(path)->String.replace(".mdx", "") + +// --- Record builders --- + +let buildMarkdownRecords = ( + ~category: string, + ~basePath: string, + ~dirPath: string, + ~pageRank: int, +): array => { + collectFiles(dirPath) + ->Array.filter(isMdxFile) + ->Array.flatMap(filePath => { + let fileContent = Node.Fs.readFileSync2(filePath, "utf8") + let parsed = MarkdownParser.parseSync(fileContent) + + switch DocFrontmatter.decode(parsed.frontmatter) { + | None => [] + | Some(fm) => { + let pageUrl = switch fm.canonical->Null.toOption { + | Some(canonical) => canonical + | None => basePath ++ "/" ++ filenameWithoutExt(filePath) + } + + let introText = parsed.content->extractIntro->truncate(~maxLen=maxContentLength) + let pageContent = switch introText { + | "" => fm.description->Null.toOption->Option.getOr("") + | text => text + } + + let pageRecord = { + objectID: pageUrl, + url: pageUrl, + url_without_anchor: pageUrl, + anchor: None, + content: Some(pageContent->truncate(~maxLen=maxContentLength)), + type_: "lvl1", + hierarchy: makeHierarchy(~lvl0=category, ~lvl1=fm.title, ()), + weight: {pageRank, level: 100, position: 0}, + } + + let headingRecords = + parsed.content + ->extractHeadings + ->Array.mapWithIndex((heading, i) => { + let anchor = slugify(heading.text) + let headingUrl = pageUrl ++ "#" ++ anchor + let typeLvl = switch heading.level { + | 2 => "lvl2" + | 3 => "lvl3" + | 4 => "lvl4" + | 5 => "lvl5" + | _ => "lvl6" + } + let weightLevel = switch heading.level { + | 2 => 80 + | 3 => 60 + | 4 => 40 + | 5 => 20 + | _ => 10 + } + let hierarchy = switch heading.level { + | 2 => makeHierarchy(~lvl0=category, ~lvl1=fm.title, ~lvl2=heading.text, ()) + | 3 => + makeHierarchy( + ~lvl0=category, + ~lvl1=fm.title, + ~lvl2=heading.text, + ~lvl3=heading.text, + (), + ) + | 4 => + makeHierarchy( + ~lvl0=category, + ~lvl1=fm.title, + ~lvl2=heading.text, + ~lvl3=heading.text, + ~lvl4=heading.text, + (), + ) + | _ => makeHierarchy(~lvl0=category, ~lvl1=fm.title, ~lvl2=heading.text, ()) + } + + { + objectID: headingUrl, + url: headingUrl, + url_without_anchor: pageUrl, + anchor: Some(anchor), + content: switch heading.content { + | "" => None + | c => Some(c) + }, + type_: typeLvl, + hierarchy, + weight: {pageRank, level: weightLevel, position: i + 1}, + } + }) + + [pageRecord]->Array.concat(headingRecords) + } + } + }) +} + +let buildBlogRecords = (~dirPath: string, ~pageRank: int): array => { + open JSON + Node.Fs.readdirSync(dirPath) + ->Array.filter(entry => isMdxFile(entry) && entry !== "archived") + ->Array.filterMap(entry => { + let fullPath = Node.Path.join([dirPath, entry]) + let stats = Node.Fs.statSync(fullPath) + switch stats["isDirectory"]() { + | true => None + | false => { + let fileContent = Node.Fs.readFileSync2(fullPath, "utf8") + let parsed = MarkdownParser.parseSync(fileContent) + + switch parsed.frontmatter { + | Object(dict{"title": String(title), "description": ?description}) => { + let slug = filenameWithoutExt(fullPath) + let url = "/blog/" ++ slug + let desc = switch description { + | Some(String(d)) => Some(d->truncate(~maxLen=maxContentLength)) + | _ => None + } + + Some({ + objectID: url, + url, + url_without_anchor: url, + anchor: None, + content: desc, + type_: "lvl1", + hierarchy: makeHierarchy(~lvl0="Blog", ~lvl1=title, ()), + weight: {pageRank, level: 100, position: 0}, + }) + } + | _ => None + } + } + } + }) +} + +let buildSyntaxLookupRecords = (~dirPath: string, ~pageRank: int): array => { + open JSON + Node.Fs.readdirSync(dirPath) + ->Array.filter(isMdxFile) + ->Array.filterMap(entry => { + let fullPath = Node.Path.join([dirPath, entry]) + let fileContent = Node.Fs.readFileSync2(fullPath, "utf8") + let parsed = MarkdownParser.parseSync(fileContent) + + switch parsed.frontmatter { + | Object(dict{ + "id": String(id), + "name": String(name), + "summary": String(summary), + "keywords": ?_keywords, + }) => + Some({ + objectID: "syntax-" ++ id, + url: "/syntax-lookup", + url_without_anchor: "/syntax-lookup", + anchor: None, + content: Some(summary->truncate(~maxLen=maxContentLength)), + type_: "lvl1", + hierarchy: makeHierarchy(~lvl0="Syntax", ~lvl1=name, ()), + weight: {pageRank, level: 100, position: 0}, + }) + | _ => None + } + }) +} + +let buildApiRecords = ( + ~basePath: string, + ~dirPath: string, + ~pageRank: int, + ~category: string, + ~files: option>=?, +): array => { + open JSON + Node.Fs.readdirSync(dirPath) + ->Array.filter(entry => { + let isJson = String.endsWith(entry, ".json") && entry !== "toc_tree.json" + switch files { + | Some(allowed) => isJson && allowed->Array.includes(entry) + | None => isJson + } + }) + ->Array.flatMap(entry => { + let fullPath = Node.Path.join([dirPath, entry]) + let fileContent = Node.Fs.readFileSync2(fullPath, "utf8") + + switch JSON.parseOrThrow(fileContent) { + | Object(modules) => + modules + ->Dict.toArray + ->Array.flatMap(((key, moduleJson)) => { + switch moduleJson { + | Object(dict{ + "id": String(id), + "name": String(name), + "docstrings": Array(docstrings), + "items": Array(items), + }) => { + let moduleUrl = basePath ++ "/" ++ key + let moduleDocstring = switch docstrings[0] { + | Some(String(d)) => Some(d->cleanDocstring->truncate(~maxLen=maxContentLength)) + | _ => None + } + + let moduleRecord = { + objectID: id, + url: moduleUrl, + url_without_anchor: moduleUrl, + anchor: None, + content: moduleDocstring, + type_: "lvl1", + hierarchy: makeHierarchy(~lvl0=category, ~lvl1=name, ()), + weight: {pageRank, level: 90, position: 0}, + } + + let sortedItems = items->Array.toSorted( + (a, b) => { + switch (a, b) { + | (Object(dict{"name": String(nameA)}), Object(dict{"name": String(nameB)})) => + nameA->String.localeCompare(nameB) + | _ => 0. + } + }, + ) + + let itemRecords = sortedItems->Array.filterMapWithIndex( + (item, i) => { + switch item { + | Object(dict{ + "id": String(itemId), + "name": String(itemName), + "docstrings": Array(itemDocstrings), + "signature": ?signature, + "kind": String(kind), + }) => { + let kindPrefix = switch kind { + | "type" => "type-" + | _ => "value-" + } + let itemAnchor = kindPrefix ++ itemName + let itemUrl = moduleUrl ++ "#" ++ itemAnchor + let firstDocstring = switch itemDocstrings[0] { + | Some(String(d)) => Some(d->cleanDocstring) + | _ => None + } + let qualifiedName = name ++ "." ++ itemName + let content = switch firstDocstring { + | Some(d) if String.length(d) > 0 => + Some((qualifiedName ++ " - " ++ d)->truncate(~maxLen=maxContentLength)) + | _ => + switch signature { + | Some(String(s)) => + Some((qualifiedName ++ " - " ++ s)->truncate(~maxLen=maxContentLength)) + | _ => Some(qualifiedName) + } + } + + Some({ + objectID: itemId, + url: itemUrl, + url_without_anchor: moduleUrl, + anchor: Some(itemAnchor), + content, + type_: "lvl2", + hierarchy: makeHierarchy(~lvl0=category, ~lvl1=name, ~lvl2=qualifiedName, ()), + weight: {pageRank, level: 70, position: i}, + }) + } + | _ => None + } + }, + ) + + [moduleRecord]->Array.concat(itemRecords) + } + | _ => [] + } + }) + | _ => [] + | exception _ => [] + } + }) +} + +// --- JSON serialization --- + +let optionToJson = (opt: option): JSON.t => + switch opt { + | Some(s) => JSON.String(s) + | None => JSON.Null + } + +let hierarchyToJson = (h: hierarchy): JSON.t => { + let dict = Dict.make() + dict->Dict.set("lvl0", JSON.String(h.lvl0)) + dict->Dict.set("lvl1", JSON.String(h.lvl1)) + dict->Dict.set("lvl2", optionToJson(h.lvl2)) + dict->Dict.set("lvl3", optionToJson(h.lvl3)) + dict->Dict.set("lvl4", optionToJson(h.lvl4)) + dict->Dict.set("lvl5", optionToJson(h.lvl5)) + dict->Dict.set("lvl6", optionToJson(h.lvl6)) + JSON.Object(dict) +} + +let weightToJson = (w: weight): JSON.t => { + let dict = Dict.make() + dict->Dict.set("pageRank", JSON.Number(Int.toFloat(w.pageRank))) + dict->Dict.set("level", JSON.Number(Int.toFloat(w.level))) + dict->Dict.set("position", JSON.Number(Int.toFloat(w.position))) + JSON.Object(dict) +} + +let toJson = (r: record): JSON.t => { + let dict = Dict.make() + dict->Dict.set("objectID", JSON.String(r.objectID)) + dict->Dict.set("url", JSON.String(r.url)) + dict->Dict.set("url_without_anchor", JSON.String(r.url_without_anchor)) + dict->Dict.set("anchor", optionToJson(r.anchor)) + dict->Dict.set("content", optionToJson(r.content)) + dict->Dict.set("type", JSON.String(r.type_)) + dict->Dict.set("hierarchy", hierarchyToJson(r.hierarchy)) + dict->Dict.set("weight", weightToJson(r.weight)) + JSON.Object(dict) +} diff --git a/src/components/Meta.res b/src/components/Meta.res index 3479d57f5..903af53c8 100644 --- a/src/components/Meta.res +++ b/src/components/Meta.res @@ -65,8 +65,6 @@ let make = ( - // Docsearch meta tags - // Robots meta tag diff --git a/src/components/Search.res b/src/components/Search.res index b9ccbb103..2d2ce32fa 100644 --- a/src/components/Search.res +++ b/src/components/Search.res @@ -1,103 +1,13 @@ -let apiKey = "a2485ef172b8cd82a2dfa498d551399b" -let indexName = "rescript-lang" -let appId = "S32LNEY41T" +let apiKey = Env.algolia_read_api_key +let indexName = Env.algolia_index_name +let appId = Env.algolia_app_id type state = Active | Inactive -let hit = ({hit, children}: DocSearch.hitComponent) => { - let toTitle = str => str->String.charAt(0)->String.toUpperCase ++ String.slice(str, ~start=1) - - let description = switch hit.url - ->String.split("/") - ->Array.slice(~start=1) - ->List.fromArray { - | list{"blog" as r | "community" as r, ..._} => r->toTitle - | list{"docs", doc, version, ...rest} => - let path = rest->List.toArray - - let info = - path - ->Array.slice(~start=0, ~end=Array.length(path) - 1) - ->Array.map(path => - switch path { - | "api" => "API" - | other => toTitle(other) - } - ) - - [doc->toTitle, version->toTitle]->Array.concat(info)->Array.join(" / ") - | _ => "" - } - - let isDeprecated = hit.deprecated->Option.isSome - let deprecatedBadge = isDeprecated - ? - {"Deprecated"->React.string} - - : React.null - - - - {deprecatedBadge} - {description->React.string} - - children - -} - -let transformItems = (items: DocSearch.transformItems) => { - items - ->Array.filterMap(item => { - let url = try WebAPI.URL.make(~url=item.url)->Some catch { - | JsExn(obj) => - Console.error2(`Failed to parse URL ${item.url}`, obj) - None - } - switch url { - | Some({pathname, hash}) => - RegExp.test(/v(8|9|10|11)\./, pathname) - ? None - : { - // DocSearch internally calls .replace() on hierarchy.lvl1, so we must - // provide a fallback for items where lvl1 is null to prevent crashes - let hierarchy = item.hierarchy - let lvl0 = hierarchy.lvl0->Nullable.toOption->Option.getOr("") - let lvl1 = hierarchy.lvl1->Nullable.toOption->Option.getOr(lvl0) - Some({ - ...item, - deprecated: pathname->String.includes("api/js") || - pathname->String.includes("api/core") - ? Some("Deprecated") - : None, - url: pathname->String.replace("/v12.0.0/", "/") ++ hash, - hierarchy: { - ...hierarchy, - lvl0: Nullable.make(lvl0), - lvl1: Nullable.make(lvl1), - }, - }) - } - - | None => None - } - }) - // Sort deprecated items to the end - ->Array.toSorted((a, b) => { - switch (a.deprecated, b.deprecated) { - | (Some(_), None) => 1. // a is deprecated, b is not - put a after b - | (None, Some(_)) => -1. // a is not deprecated, b is - put a before b - | _ => 0. - } - }) - ->Array.toSorted((a, b) => { - switch (a.url->String.includes("api/stdlib"), b.url->String.includes("api/stdlib")) { - | (true, false) => -1. // a is a stdlib doc, b is not - put a before b - | (false, true) => 1. // a is not a stdlib doc, b is - put a after b - | _ => 0. // both same API status - maintain original order - } - }) +let navigator: DocSearch.navigator = { + navigate: ({itemUrl}) => { + ReactRouter.navigate(itemUrl) + }, } @react.component @@ -174,10 +84,10 @@ let make = () => { apiKey appId indexName + navigator onClose initialScrollY={window.scrollY->Float.toInt} - transformItems={transformItems} - hitComponent=hit + searchParameters={distinct: 3, hitsPerPage: 20} />, element, ) diff --git a/yarn.lock b/yarn.lock index bdd5ac415..549948745 100644 --- a/yarn.lock +++ b/yarn.lock @@ -70,6 +70,18 @@ __metadata: languageName: node linkType: hard +"@algolia/abtesting@npm:1.16.1": + version: 1.16.1 + resolution: "@algolia/abtesting@npm:1.16.1" + dependencies: + "@algolia/client-common": "npm:5.50.1" + "@algolia/requester-browser-xhr": "npm:5.50.1" + "@algolia/requester-fetch": "npm:5.50.1" + "@algolia/requester-node-http": "npm:5.50.1" + checksum: 10c0/0ca113338a447693b4827bdf87f37490ccd81bc1bbbe39b02c338ff79582379a68853c3d35fb2297fd5636fa43818dac9e04b59965a8b47851e8b1da041b45e8 + languageName: node + linkType: hard + "@algolia/autocomplete-core@npm:1.19.2": version: 1.19.2 resolution: "@algolia/autocomplete-core@npm:1.19.2" @@ -113,6 +125,18 @@ __metadata: languageName: node linkType: hard +"@algolia/client-abtesting@npm:5.50.1": + version: 5.50.1 + resolution: "@algolia/client-abtesting@npm:5.50.1" + dependencies: + "@algolia/client-common": "npm:5.50.1" + "@algolia/requester-browser-xhr": "npm:5.50.1" + "@algolia/requester-fetch": "npm:5.50.1" + "@algolia/requester-node-http": "npm:5.50.1" + checksum: 10c0/a3fb097e72acc5f1b009694774c0b23e1a7701ec4f54bbf4b20114f9adc73565f8d8c7fba492d769b6f5becd1ef4bf6b92073fb289cd06bfb3e12b2f0989f9ae + languageName: node + linkType: hard + "@algolia/client-analytics@npm:5.45.0": version: 5.45.0 resolution: "@algolia/client-analytics@npm:5.45.0" @@ -125,6 +149,18 @@ __metadata: languageName: node linkType: hard +"@algolia/client-analytics@npm:5.50.1": + version: 5.50.1 + resolution: "@algolia/client-analytics@npm:5.50.1" + dependencies: + "@algolia/client-common": "npm:5.50.1" + "@algolia/requester-browser-xhr": "npm:5.50.1" + "@algolia/requester-fetch": "npm:5.50.1" + "@algolia/requester-node-http": "npm:5.50.1" + checksum: 10c0/ade9f7ee8e8872f0c54149a9292fc32bad9e0b189068ca283f7110ce3f638b14c5078ce43d2c00c2bf752d3aa96e6bea63e4f1184cbe5bc36501074d96595d05 + languageName: node + linkType: hard + "@algolia/client-common@npm:5.45.0": version: 5.45.0 resolution: "@algolia/client-common@npm:5.45.0" @@ -132,6 +168,13 @@ __metadata: languageName: node linkType: hard +"@algolia/client-common@npm:5.50.1": + version: 5.50.1 + resolution: "@algolia/client-common@npm:5.50.1" + checksum: 10c0/4750773473748fec73a7a9be3081274e21f2c4ccac463618b2ec470113c44c1f6961a991382c999acf04bd83e074547cd57c6304c4218d31bb0089b5c1099bf3 + languageName: node + linkType: hard + "@algolia/client-insights@npm:5.45.0": version: 5.45.0 resolution: "@algolia/client-insights@npm:5.45.0" @@ -144,6 +187,18 @@ __metadata: languageName: node linkType: hard +"@algolia/client-insights@npm:5.50.1": + version: 5.50.1 + resolution: "@algolia/client-insights@npm:5.50.1" + dependencies: + "@algolia/client-common": "npm:5.50.1" + "@algolia/requester-browser-xhr": "npm:5.50.1" + "@algolia/requester-fetch": "npm:5.50.1" + "@algolia/requester-node-http": "npm:5.50.1" + checksum: 10c0/62ca243328f38e9a245e2860c12d1e76529e9bf68d5a30881a053adf5cbaddda27af631edd33e23d879a9e5445c66e2654f0149695cd1b75b09b42ea57ef575f + languageName: node + linkType: hard + "@algolia/client-personalization@npm:5.45.0": version: 5.45.0 resolution: "@algolia/client-personalization@npm:5.45.0" @@ -156,6 +211,18 @@ __metadata: languageName: node linkType: hard +"@algolia/client-personalization@npm:5.50.1": + version: 5.50.1 + resolution: "@algolia/client-personalization@npm:5.50.1" + dependencies: + "@algolia/client-common": "npm:5.50.1" + "@algolia/requester-browser-xhr": "npm:5.50.1" + "@algolia/requester-fetch": "npm:5.50.1" + "@algolia/requester-node-http": "npm:5.50.1" + checksum: 10c0/cbc099bd7a5f8ccefd4135a59dfa2b6136b751ed35d451a0c89738c8ad404195348d5553630ab8e59f056f17b8a284e918151696050b740d96e304c8f40174fd + languageName: node + linkType: hard + "@algolia/client-query-suggestions@npm:5.45.0": version: 5.45.0 resolution: "@algolia/client-query-suggestions@npm:5.45.0" @@ -168,6 +235,18 @@ __metadata: languageName: node linkType: hard +"@algolia/client-query-suggestions@npm:5.50.1": + version: 5.50.1 + resolution: "@algolia/client-query-suggestions@npm:5.50.1" + dependencies: + "@algolia/client-common": "npm:5.50.1" + "@algolia/requester-browser-xhr": "npm:5.50.1" + "@algolia/requester-fetch": "npm:5.50.1" + "@algolia/requester-node-http": "npm:5.50.1" + checksum: 10c0/345e0ecaf587aec2a956c2039da817fd26e203c8689fe8e0d428baf6ab03f0809a936099ae420e779d3ec252bbcaf3061c6e8670c660d7a9d66e98627d8938df + languageName: node + linkType: hard + "@algolia/client-search@npm:5.45.0": version: 5.45.0 resolution: "@algolia/client-search@npm:5.45.0" @@ -180,6 +259,18 @@ __metadata: languageName: node linkType: hard +"@algolia/client-search@npm:5.50.1": + version: 5.50.1 + resolution: "@algolia/client-search@npm:5.50.1" + dependencies: + "@algolia/client-common": "npm:5.50.1" + "@algolia/requester-browser-xhr": "npm:5.50.1" + "@algolia/requester-fetch": "npm:5.50.1" + "@algolia/requester-node-http": "npm:5.50.1" + checksum: 10c0/7910c074aa7b4fbbad2af082a7623d7d65ba0c19e0933d4658e43d588cd87ed2e851aad0c5428ce2a00a3e3248349fcda20ed5abb7700b93d03a475e2ce7a378 + languageName: node + linkType: hard + "@algolia/ingestion@npm:1.45.0": version: 1.45.0 resolution: "@algolia/ingestion@npm:1.45.0" @@ -192,6 +283,18 @@ __metadata: languageName: node linkType: hard +"@algolia/ingestion@npm:1.50.1": + version: 1.50.1 + resolution: "@algolia/ingestion@npm:1.50.1" + dependencies: + "@algolia/client-common": "npm:5.50.1" + "@algolia/requester-browser-xhr": "npm:5.50.1" + "@algolia/requester-fetch": "npm:5.50.1" + "@algolia/requester-node-http": "npm:5.50.1" + checksum: 10c0/0d5264db46783d648246406349fe88dbc6fa1cdd74ed16500bb8a4e5efb1bdfd7174780065566fcb7317f7ba8ac858677ffb0d5194a1315c0ce6003bd4219d87 + languageName: node + linkType: hard + "@algolia/monitoring@npm:1.45.0": version: 1.45.0 resolution: "@algolia/monitoring@npm:1.45.0" @@ -204,6 +307,18 @@ __metadata: languageName: node linkType: hard +"@algolia/monitoring@npm:1.50.1": + version: 1.50.1 + resolution: "@algolia/monitoring@npm:1.50.1" + dependencies: + "@algolia/client-common": "npm:5.50.1" + "@algolia/requester-browser-xhr": "npm:5.50.1" + "@algolia/requester-fetch": "npm:5.50.1" + "@algolia/requester-node-http": "npm:5.50.1" + checksum: 10c0/378076310011c77c91378a597d86d791d4821d1d00e3c500ec8828e72b9036bb974abb09bd0c10aa05fc75a50aa443be26985104ca78524a0a0cf34707536c70 + languageName: node + linkType: hard + "@algolia/recommend@npm:5.45.0": version: 5.45.0 resolution: "@algolia/recommend@npm:5.45.0" @@ -216,6 +331,18 @@ __metadata: languageName: node linkType: hard +"@algolia/recommend@npm:5.50.1": + version: 5.50.1 + resolution: "@algolia/recommend@npm:5.50.1" + dependencies: + "@algolia/client-common": "npm:5.50.1" + "@algolia/requester-browser-xhr": "npm:5.50.1" + "@algolia/requester-fetch": "npm:5.50.1" + "@algolia/requester-node-http": "npm:5.50.1" + checksum: 10c0/0cf061bf2fc46240d93c6fe032693e143a5eb61a3fc27f619141ebea735b7e7f6c5c38b31b152e9ef074b61373549a1f72a76399d80ed55840251cc71438f829 + languageName: node + linkType: hard + "@algolia/requester-browser-xhr@npm:5.45.0": version: 5.45.0 resolution: "@algolia/requester-browser-xhr@npm:5.45.0" @@ -225,6 +352,15 @@ __metadata: languageName: node linkType: hard +"@algolia/requester-browser-xhr@npm:5.50.1": + version: 5.50.1 + resolution: "@algolia/requester-browser-xhr@npm:5.50.1" + dependencies: + "@algolia/client-common": "npm:5.50.1" + checksum: 10c0/aa55122f483a0d1572da20b71b0b533493960894460ad545a6a50e1c73780affd4764d68aa5a1687894d23c31a972cc92886a0d8ed3324b6f5457efd58b424af + languageName: node + linkType: hard + "@algolia/requester-fetch@npm:5.45.0": version: 5.45.0 resolution: "@algolia/requester-fetch@npm:5.45.0" @@ -234,6 +370,15 @@ __metadata: languageName: node linkType: hard +"@algolia/requester-fetch@npm:5.50.1": + version: 5.50.1 + resolution: "@algolia/requester-fetch@npm:5.50.1" + dependencies: + "@algolia/client-common": "npm:5.50.1" + checksum: 10c0/07232c12ff0a5b25e5e6dfeeed8e46765f347926f263774e9ae061e65bd1ddce029f78fd5feaa34e23c80e80b0a84874d8799f817368e924cc904aef4f8f8181 + languageName: node + linkType: hard + "@algolia/requester-node-http@npm:5.45.0": version: 5.45.0 resolution: "@algolia/requester-node-http@npm:5.45.0" @@ -243,6 +388,15 @@ __metadata: languageName: node linkType: hard +"@algolia/requester-node-http@npm:5.50.1": + version: 5.50.1 + resolution: "@algolia/requester-node-http@npm:5.50.1" + dependencies: + "@algolia/client-common": "npm:5.50.1" + checksum: 10c0/51be1452a28d4aeb97306121d164a3161fb55b775189df631f968bc752e00538a9872d0e0a2ad97744f8ca87c39f8352b526b8b290805ddaf5a2d4f43ae3360f + languageName: node + linkType: hard + "@asamuzakjp/css-color@npm:^3.2.0": version: 3.2.0 resolution: "@asamuzakjp/css-color@npm:3.2.0" @@ -3478,6 +3632,28 @@ __metadata: languageName: node linkType: hard +"algoliasearch@npm:^5.50.1": + version: 5.50.1 + resolution: "algoliasearch@npm:5.50.1" + dependencies: + "@algolia/abtesting": "npm:1.16.1" + "@algolia/client-abtesting": "npm:5.50.1" + "@algolia/client-analytics": "npm:5.50.1" + "@algolia/client-common": "npm:5.50.1" + "@algolia/client-insights": "npm:5.50.1" + "@algolia/client-personalization": "npm:5.50.1" + "@algolia/client-query-suggestions": "npm:5.50.1" + "@algolia/client-search": "npm:5.50.1" + "@algolia/ingestion": "npm:1.50.1" + "@algolia/monitoring": "npm:1.50.1" + "@algolia/recommend": "npm:5.50.1" + "@algolia/requester-browser-xhr": "npm:5.50.1" + "@algolia/requester-fetch": "npm:5.50.1" + "@algolia/requester-node-http": "npm:5.50.1" + checksum: 10c0/4b91f019c89324786e23f90b7773eb82b142e8075c95f204cf6fc07f320fcbbf623ca338509647d93b9776f4645a1f72debb2800627c4bf1b80e3ed8f2b398b1 + languageName: node + linkType: hard + "ansi-align@npm:^3.0.1": version: 3.0.1 resolution: "ansi-align@npm:3.0.1" @@ -9367,6 +9543,7 @@ __metadata: "@types/react": "npm:^19.2.14" "@vitejs/plugin-react": "npm:^6.0.1" "@vitest/browser-playwright": "npm:^4.1.2" + algoliasearch: "npm:^5.50.1" auto-image-converter: "npm:^2.1.2" chokidar: "npm:^4.0.3" docson: "npm:^2.1.0" From 6945d0f4f14afcfdb82c5afa9a89f12c1bc80501 Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Mon, 6 Apr 2026 17:27:48 -0400 Subject: [PATCH 03/32] sorting and formatting --- package.json | 2 +- scripts/generate_search_index.res | 70 +++++++++++++++++++++++++---- src/bindings/DocSearch.res | 1 + src/common/SearchIndex.res | 75 ++++++++++++++++++++++++++----- src/components/Search.res | 41 ++++++++++++++++- styles/_docsearch.css | 12 ++++- 6 files changed, 179 insertions(+), 22 deletions(-) diff --git a/package.json b/package.json index 062d17519..d3446d27e 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "build:generate-llms": "node _scripts/generate_llms.mjs", "build:res": "rescript build --warn-error +3+8+11+12+26+27+31+32+33+34+35+39+44+45+110", "build:sync-bundles": "node scripts/sync-playground-bundles.mjs", - "build:search-index": "node --env-file-if-exists=.env.local _scripts/generate_search_index.mjs", + "build:search-index": "node --env-file-if-exists=.env --env-file-if-exists=.env.local _scripts/generate_search_index.mjs", "build:update-index": "yarn build:generate-llms && node _scripts/generate_feed.mjs > public/blog/feed.xml && yarn build:search-index", "build:vite": "react-router build", "build": "yarn build:res && yarn build:scripts && yarn build:update-index && yarn build:vite", diff --git a/scripts/generate_search_index.res b/scripts/generate_search_index.res index b786cf117..617d5a21f 100644 --- a/scripts/generate_search_index.res +++ b/scripts/generate_search_index.res @@ -1,5 +1,5 @@ // Build script: reads all site content, builds Algolia search records, and uploads them. -// Runs as a standalone Node script via: node --env-file-if-exists=.env.local _scripts/generate_search_index.mjs +// Runs as a standalone Node script via: node --env-file-if-exists=.env --env-file-if-exists=.env.local _scripts/generate_search_index.mjs // // Required env vars: // ALGOLIA_ADMIN_API_KEY -- API key with addObject/deleteObject/editSettings ACLs @@ -17,6 +17,59 @@ let getEnv = (key: string): option => } ) +let compareVersions = (a: string, b: string): float => { + let parse = (v: string) => + v + ->String.replaceRegExp(RegExp.fromString("^v", ~flags=""), "") + ->String.split(".") + ->Array.map(s => Int.fromString(s)->Option.getOr(0)) + let partsA = parse(a) + let partsB = parse(b) + switch (partsA[0], partsB[0]) { + | (Some(a0), Some(b0)) if a0 !== b0 => Int.toFloat(a0 - b0) + | _ => + switch (partsA[1], partsB[1]) { + | (Some(a1), Some(b1)) if a1 !== b1 => Int.toFloat(a1 - b1) + | _ => + switch (partsA[2], partsB[2]) { + | (Some(a2), Some(b2)) => Int.toFloat(a2 - b2) + | _ => 0.0 + } + } + } +} + +let resolveApiDir = (): option => { + let majorVersion = + getEnv("VITE_VERSION_LATEST") + ->Option.map(v => v->String.replaceRegExp(RegExp.fromString("^v", ~flags=""), "")) + ->Option.flatMap(v => v->String.split(".")->Array.get(0)) + switch majorVersion { + | None => { + Console.log("[search-index] VITE_VERSION_LATEST not set, cannot resolve API version.") + None + } + | Some(major) => { + let prefix = "v" ++ major ++ "." + let entries = Node.Fs.readdirSync("data/api") + let matching = + entries + ->Array.filter(entry => String.startsWith(entry, prefix)) + ->Array.toSorted(compareVersions) + switch matching->Array.at(-1) { + | Some(dir) => { + Console.log(`[search-index] Resolved API version: ${dir}`) + Some("data/api/" ++ dir) + } + | None => { + Console.log(`[search-index] No API version found matching v${major}.*`) + None + } + } + } + } +} + let main = async () => { let appId = getEnv("ALGOLIA_APP_ID") let adminApiKey = getEnv("ALGOLIA_ADMIN_API_KEY") @@ -26,6 +79,8 @@ let main = async () => { | (Some(appId), Some(apiKey), Some(idx)) => { Console.log("[search-index] Building search index records...") + let apiDir = resolveApiDir()->Option.getOr("markdown-pages/docs/api") + // 1. Build records from all content sources let manualRecords = SearchIndex.buildMarkdownRecords( ~category="Manual", @@ -70,7 +125,7 @@ let main = async () => { let stdlibApiRecords = SearchIndex.buildApiRecords( ~basePath="/docs/manual/api", - ~dirPath="markdown-pages/docs/api", + ~dirPath=apiDir, ~pageRank=80, ~category="API / StdLib", ~files=["stdlib.json"], @@ -81,7 +136,7 @@ let main = async () => { let beltApiRecords = SearchIndex.buildApiRecords( ~basePath="/docs/manual/api", - ~dirPath="markdown-pages/docs/api", + ~dirPath=apiDir, ~pageRank=75, ~category="API / Belt", ~files=["belt.json"], @@ -92,7 +147,7 @@ let main = async () => { let domApiRecords = SearchIndex.buildApiRecords( ~basePath="/docs/manual/api", - ~dirPath="markdown-pages/docs/api", + ~dirPath=apiDir, ~pageRank=70, ~category="API / DOM", ~files=["dom.json"], @@ -150,7 +205,7 @@ let main = async () => { exactOnSingleWordQuery: "word", attributesForFaceting: ["type"], customRanking: ["desc(weight.pageRank)", "desc(weight.level)", "asc(weight.position)"], - attributesToSnippet: ["content:30"], + attributesToSnippet: [], attributeForDistinct: "hierarchy.lvl0", }, }) @@ -159,9 +214,8 @@ let main = async () => { Console.log("[search-index] Done.") } | (None, _, _) => Console.log("[search-index] ALGOLIA_APP_ID not set, skipping index upload.") - | (_, None, _) => Console.log( - "[search-index] ALGOLIA_ADMIN_API_KEY not set, skipping index upload.", - ) + | (_, None, _) => + Console.log("[search-index] ALGOLIA_ADMIN_API_KEY not set, skipping index upload.") | (_, _, None) => Console.log("[search-index] ALGOLIA_INDEX_NAME not set, skipping index upload.") } } diff --git a/src/bindings/DocSearch.res b/src/bindings/DocSearch.res index 083dc7ba2..9efbf91b7 100644 --- a/src/bindings/DocSearch.res +++ b/src/bindings/DocSearch.res @@ -48,6 +48,7 @@ type searchParameters = { facetFilters?: array, hitsPerPage?: int, distinct?: int, + attributesToSnippet?: array, } @module("@docsearch/react") @react.component diff --git a/src/common/SearchIndex.res b/src/common/SearchIndex.res index 7badc03b3..23b48c1e7 100644 --- a/src/common/SearchIndex.res +++ b/src/common/SearchIndex.res @@ -94,6 +94,49 @@ let cleanDocstring = (text: string): string => ->String.replaceRegExp(RegExp.fromString("\\n", ~flags="g"), " ") ->String.trim +let stripMarkdownFull = (text: string): string => + text + // Strip code blocks but keep code content + ->String.replaceRegExp(RegExp.fromString("```\\w*\\n?([\\s\\S]*?)```", ~flags="g"), "$1") + // Strip inline code backticks + ->String.replaceRegExp(RegExp.fromString("`([^`]+)`", ~flags="g"), "$1") + // Strip bold + ->String.replaceRegExp(RegExp.fromString("\\*\\*([^*]+)\\*\\*", ~flags="g"), "$1") + // Strip italic + ->String.replaceRegExp(RegExp.fromString("\\*([^*]+)\\*", ~flags="g"), "$1") + // Strip links (keep text, handle empty URLs too) + ->String.replaceRegExp(RegExp.fromString("\\[([^\\]]+)\\]\\([^)]*\\)", ~flags="g"), "$1") + // Strip heading markers + ->String.replaceRegExp(RegExp.fromString("^#{1,6}\\s+", ~flags="gm"), "") + // Collapse multiple newlines + ->String.replaceRegExp(RegExp.fromString("\\n{2,}", ~flags="g"), " ") + // Replace remaining newlines with space + ->String.replaceRegExp(RegExp.fromString("\\n", ~flags="g"), " ") + ->String.trim + +let docstringToSearchHtml = (text: string): string => + text + // Strip code blocks but keep code content wrapped in + ->String.replaceRegExp( + RegExp.fromString("```\\w*\\n?([\\s\\S]*?)```", ~flags="g"), + "$1", + ) + // Convert inline code backticks to tags + ->String.replaceRegExp(RegExp.fromString("`([^`]+)`", ~flags="g"), "$1") + // Strip bold + ->String.replaceRegExp(RegExp.fromString("\\*\\*([^*]+)\\*\\*", ~flags="g"), "$1") + // Strip italic + ->String.replaceRegExp(RegExp.fromString("\\*([^*]+)\\*", ~flags="g"), "$1") + // Strip links (keep text, handle empty URLs too) + ->String.replaceRegExp(RegExp.fromString("\\[([^\\]]+)\\]\\([^)]*\\)", ~flags="g"), "$1") + // Strip heading markers + ->String.replaceRegExp(RegExp.fromString("^#{1,6}\\s+", ~flags="gm"), "") + // Convert double newlines to
+ ->String.replaceRegExp(RegExp.fromString("\\n{2,}", ~flags="g"), "
") + // Replace remaining newlines with space + ->String.replaceRegExp(RegExp.fromString("\\n", ~flags="g"), " ") + ->String.trim + let extractIntro = (content: string): string => { let parts = content->String.split("\n## ") let intro = parts[0]->Option.getOr("") @@ -410,19 +453,29 @@ let buildApiRecords = ( } let itemAnchor = kindPrefix ++ itemName let itemUrl = moduleUrl ++ "#" ++ itemAnchor - let firstDocstring = switch itemDocstrings[0] { - | Some(String(d)) => Some(d->cleanDocstring) + let qualifiedName = name ++ "." ++ itemName + let docstringIntro = switch itemDocstrings[0] { + | Some(String(d)) if String.length(d) > 0 => { + // Take content before first heading or code block + let intro = + d + ->String.split("\n## ") + ->Array.get(0) + ->Option.getOr(d) + ->String.split("\n```") + ->Array.get(0) + ->Option.getOr(d) + ->String.trim + Some(intro->truncate(~maxLen=2000)) + } | _ => None } - let qualifiedName = name ++ "." ++ itemName - let content = switch firstDocstring { - | Some(d) if String.length(d) > 0 => - Some((qualifiedName ++ " - " ++ d)->truncate(~maxLen=maxContentLength)) + let content = switch docstringIntro { + | Some(d) if String.length(d) > 0 => Some(d) | _ => switch signature { - | Some(String(s)) => - Some((qualifiedName ++ " - " ++ s)->truncate(~maxLen=maxContentLength)) - | _ => Some(qualifiedName) + | Some(String(s)) => Some(s) + | _ => None } } @@ -432,8 +485,8 @@ let buildApiRecords = ( url_without_anchor: moduleUrl, anchor: Some(itemAnchor), content, - type_: "lvl2", - hierarchy: makeHierarchy(~lvl0=category, ~lvl1=name, ~lvl2=qualifiedName, ()), + type_: "lvl1", + hierarchy: makeHierarchy(~lvl0=category, ~lvl1=qualifiedName, ()), weight: {pageRank, level: 70, position: i}, }) } diff --git a/src/components/Search.res b/src/components/Search.res index 2d2ce32fa..66bfb6c0d 100644 --- a/src/components/Search.res +++ b/src/components/Search.res @@ -10,6 +10,44 @@ let navigator: DocSearch.navigator = { }, } +let getHighlightedTitle: DocSearch.docSearchHit => string = %raw(` + function(hit) { + try { return hit._highlightResult.hierarchy.lvl1.value; } + catch(e) { return hit.hierarchy.lvl1 || ''; } + } +`) + +let markdownToHtml = (text: string): string => + text + ->String.replaceRegExp(RegExp.fromString("\\[([^\\]]+)\\]\\([^)]*\\)", ~flags="g"), "$1") + ->String.replaceRegExp(RegExp.fromString("\\x60([^\\x60]+)\\x60", ~flags="g"), "$1") + ->String.replaceRegExp( + RegExp.fromString("\\*\\*([^*]+)\\*\\*", ~flags="g"), + "$1", + ) + ->String.replaceRegExp(RegExp.fromString("\\*([^*]+)\\*", ~flags="g"), "$1") + ->String.replaceRegExp(RegExp.fromString("\\n{2,}", ~flags="g"), "
") + ->String.replaceRegExp(RegExp.fromString("\\n", ~flags="g"), " ") + ->String.trim + +let hitComponent = ({hit, children: _}: DocSearch.hitComponent): React.element => { + let titleHtml = getHighlightedTitle(hit) + let contentHtml = hit.content->Nullable.toOption->Option.map(markdownToHtml) + + +
+
+ + {switch contentHtml { + | Some(c) if String.length(c) > 0 => + + | _ => React.null + }} +
+
+
+} + @react.component let make = () => { let (state, setState) = React.useState(_ => Inactive) @@ -85,9 +123,10 @@ let make = () => { appId indexName navigator + hitComponent onClose initialScrollY={window.scrollY->Float.toInt} - searchParameters={distinct: 3, hitsPerPage: 20} + searchParameters={distinct: 3, hitsPerPage: 20, attributesToSnippet: ["content:9999"]} />, element, ) diff --git a/styles/_docsearch.css b/styles/_docsearch.css index ac8c167b7..b3d23c997 100644 --- a/styles/_docsearch.css +++ b/styles/_docsearch.css @@ -274,7 +274,17 @@ svg.DocSearch-Hit-Select-Icon { } .DocSearch-Hit-path { - @apply text-12; + @apply text-14 text-gray-60; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + white-space: normal !important; + text-overflow: ellipsis; +} + +.DocSearch-Hit-path code { + @apply bg-gray-10 text-black rounded-sm px-1 py-0.5 text-12 font-mono; } .DocSearch-Hit[aria-selected="true"] .DocSearch-Hit-title, From 73b141a2747d4b4fd326bbe08f76a21b951da8f8 Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Mon, 6 Apr 2026 17:30:53 -0400 Subject: [PATCH 04/32] remove MDN links --- src/components/Search.res | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/components/Search.res b/src/components/Search.res index 66bfb6c0d..efe25b2b1 100644 --- a/src/components/Search.res +++ b/src/components/Search.res @@ -19,6 +19,11 @@ let getHighlightedTitle: DocSearch.docSearchHit => string = %raw(` let markdownToHtml = (text: string): string => text + ->String.replaceRegExp( + RegExp.fromString("See\\s+\\[([^\\]]+)\\]\\([^)]*\\)\\s+on MDN\\.?", ~flags="g"), + "", + ) + ->String.replaceRegExp(RegExp.fromString("See\\s+\\S+\\s+on MDN\\.?", ~flags="g"), "") ->String.replaceRegExp(RegExp.fromString("\\[([^\\]]+)\\]\\([^)]*\\)", ~flags="g"), "$1") ->String.replaceRegExp(RegExp.fromString("\\x60([^\\x60]+)\\x60", ~flags="g"), "$1") ->String.replaceRegExp( From e2280ec43bbe1848c9d53fdfa0b6857abb5413d5 Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Mon, 6 Apr 2026 17:34:05 -0400 Subject: [PATCH 05/32] improve visuals --- src/components/Search.res | 32 ++++++++++++++++++++++++++++++-- styles/_docsearch.css | 4 ++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/components/Search.res b/src/components/Search.res index efe25b2b1..1709e3d4a 100644 --- a/src/components/Search.res +++ b/src/components/Search.res @@ -12,13 +12,36 @@ let navigator: DocSearch.navigator = { let getHighlightedTitle: DocSearch.docSearchHit => string = %raw(` function(hit) { - try { return hit._highlightResult.hierarchy.lvl1.value; } - catch(e) { return hit.hierarchy.lvl1 || ''; } + var type = hit.type; + var h = hit._highlightResult && hit._highlightResult.hierarchy; + var raw = hit.hierarchy; + try { + if (type && type !== 'lvl1' && type !== 'lvl0') { + var lvl = h && h[type] && h[type].value; + if (lvl) return lvl; + } + if (h && h.lvl1 && h.lvl1.value) return h.lvl1.value; + } catch(e) {} + return (raw && raw.lvl1) || ''; + } +`) + +let getSubtitle: DocSearch.docSearchHit => option = %raw(` + function(hit) { + var type = hit.type; + if (type && type !== 'lvl1' && type !== 'lvl0') { + var raw = hit.hierarchy; + if (raw && raw.lvl1) return raw.lvl1; + } + return undefined; } `) let markdownToHtml = (text: string): string => text + // Strip stray backslashes from MDX processing + ->String.replaceRegExp(RegExp.fromString("^\\\\\\s+", ~flags=""), "") + ->String.replaceRegExp(RegExp.fromString("\\\\\\s+", ~flags="g"), " ") ->String.replaceRegExp( RegExp.fromString("See\\s+\\[([^\\]]+)\\]\\([^)]*\\)\\s+on MDN\\.?", ~flags="g"), "", @@ -37,12 +60,17 @@ let markdownToHtml = (text: string): string => let hitComponent = ({hit, children: _}: DocSearch.hitComponent): React.element => { let titleHtml = getHighlightedTitle(hit) + let subtitle = getSubtitle(hit) let contentHtml = hit.content->Nullable.toOption->Option.map(markdownToHtml)
+ {switch subtitle { + | Some(s) => {React.string(s)} + | None => React.null + }} {switch contentHtml { | Some(c) if String.length(c) > 0 => diff --git a/styles/_docsearch.css b/styles/_docsearch.css index b3d23c997..176a5e739 100644 --- a/styles/_docsearch.css +++ b/styles/_docsearch.css @@ -273,6 +273,10 @@ svg.DocSearch-Hit-Select-Icon { @apply text-14 text-gray-60; } +.DocSearch-Hit-subtitle { + @apply text-12 text-gray-40; +} + .DocSearch-Hit-path { @apply text-14 text-gray-60; display: -webkit-box; From aa24517e688462b6f8237074c10ee6cf613d68f9 Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Mon, 6 Apr 2026 17:39:16 -0400 Subject: [PATCH 06/32] remove test file --- algolia.mjs | 35 ----------------------------------- 1 file changed, 35 deletions(-) delete mode 100644 algolia.mjs diff --git a/algolia.mjs b/algolia.mjs deleted file mode 100644 index 7ba6a0cf8..000000000 --- a/algolia.mjs +++ /dev/null @@ -1,35 +0,0 @@ -// helloAlgolia.mjs -import { algoliasearch } from "algoliasearch"; - -const appID = "1T1PRULLJT"; -// API key with `addObject` and `editSettings` ACL -const apiKey = "999e5352ab7aed499de651ee79f573ee"; -const indexName = "dev_2026"; - -const client = algoliasearch(appID, apiKey); - -const record = { objectID: "object-1", name: "test record" }; - -// Add record to an index -const { taskID } = await client.saveObject({ - indexName, - body: record, -}); - -// Wait until indexing is done -await client.waitForTask({ - indexName, - taskID, -}); - -// Search for "test" -const { results } = await client.search({ - requests: [ - { - indexName, - query: "test", - }, - ], -}); - -console.log(JSON.stringify(results, null, 2)); From e6cc448ab42c3d3b660bd1a8c9d96a359f3eefb0 Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Mon, 6 Apr 2026 17:40:58 -0400 Subject: [PATCH 07/32] remove unused css --- styles/_docsearch.css | 6 ------ 1 file changed, 6 deletions(-) diff --git a/styles/_docsearch.css b/styles/_docsearch.css index 176a5e739..42c5b36b6 100644 --- a/styles/_docsearch.css +++ b/styles/_docsearch.css @@ -279,12 +279,6 @@ svg.DocSearch-Hit-Select-Icon { .DocSearch-Hit-path { @apply text-14 text-gray-60; - display: -webkit-box; - -webkit-line-clamp: 3; - -webkit-box-orient: vertical; - overflow: hidden; - white-space: normal !important; - text-overflow: ellipsis; } .DocSearch-Hit-path code { From fe972563e41e1cd8d153715632b7279d55b6c7f9 Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Mon, 6 Apr 2026 17:49:43 -0400 Subject: [PATCH 08/32] pr feedback --- src/bindings/DocSearch.res | 18 ---------------- src/common/SearchIndex.res | 43 ------------------------------------- src/common/SearchIndex.resi | 22 +++++++++++++++++++ src/common/Url.res | 2 +- 4 files changed, 23 insertions(+), 62 deletions(-) create mode 100644 src/common/SearchIndex.resi diff --git a/src/bindings/DocSearch.res b/src/bindings/DocSearch.res index 9efbf91b7..a97c2540f 100644 --- a/src/bindings/DocSearch.res +++ b/src/bindings/DocSearch.res @@ -63,21 +63,3 @@ external make: ( ~searchParameters: searchParameters=?, ~initialScrollY: int=?, ) => React.element = "DocSearchModal" - -let getContentSnippet: docSearchHit => option = %raw(` - function(hit) { - try { - var s = hit._snippetResult; - if (s && s.content && s.content.value) { - var val = s.content.value.trim(); - if (val !== '' && val !== '...') return val; - } - } catch(e) {} - var c = hit.content; - if (c != null) { - c = c.trim(); - if (c !== '') return c; - } - return undefined; - } -`) diff --git a/src/common/SearchIndex.res b/src/common/SearchIndex.res index 23b48c1e7..79d4260fa 100644 --- a/src/common/SearchIndex.res +++ b/src/common/SearchIndex.res @@ -94,49 +94,6 @@ let cleanDocstring = (text: string): string => ->String.replaceRegExp(RegExp.fromString("\\n", ~flags="g"), " ") ->String.trim -let stripMarkdownFull = (text: string): string => - text - // Strip code blocks but keep code content - ->String.replaceRegExp(RegExp.fromString("```\\w*\\n?([\\s\\S]*?)```", ~flags="g"), "$1") - // Strip inline code backticks - ->String.replaceRegExp(RegExp.fromString("`([^`]+)`", ~flags="g"), "$1") - // Strip bold - ->String.replaceRegExp(RegExp.fromString("\\*\\*([^*]+)\\*\\*", ~flags="g"), "$1") - // Strip italic - ->String.replaceRegExp(RegExp.fromString("\\*([^*]+)\\*", ~flags="g"), "$1") - // Strip links (keep text, handle empty URLs too) - ->String.replaceRegExp(RegExp.fromString("\\[([^\\]]+)\\]\\([^)]*\\)", ~flags="g"), "$1") - // Strip heading markers - ->String.replaceRegExp(RegExp.fromString("^#{1,6}\\s+", ~flags="gm"), "") - // Collapse multiple newlines - ->String.replaceRegExp(RegExp.fromString("\\n{2,}", ~flags="g"), " ") - // Replace remaining newlines with space - ->String.replaceRegExp(RegExp.fromString("\\n", ~flags="g"), " ") - ->String.trim - -let docstringToSearchHtml = (text: string): string => - text - // Strip code blocks but keep code content wrapped in - ->String.replaceRegExp( - RegExp.fromString("```\\w*\\n?([\\s\\S]*?)```", ~flags="g"), - "$1", - ) - // Convert inline code backticks to tags - ->String.replaceRegExp(RegExp.fromString("`([^`]+)`", ~flags="g"), "$1") - // Strip bold - ->String.replaceRegExp(RegExp.fromString("\\*\\*([^*]+)\\*\\*", ~flags="g"), "$1") - // Strip italic - ->String.replaceRegExp(RegExp.fromString("\\*([^*]+)\\*", ~flags="g"), "$1") - // Strip links (keep text, handle empty URLs too) - ->String.replaceRegExp(RegExp.fromString("\\[([^\\]]+)\\]\\([^)]*\\)", ~flags="g"), "$1") - // Strip heading markers - ->String.replaceRegExp(RegExp.fromString("^#{1,6}\\s+", ~flags="gm"), "") - // Convert double newlines to
- ->String.replaceRegExp(RegExp.fromString("\\n{2,}", ~flags="g"), "
") - // Replace remaining newlines with space - ->String.replaceRegExp(RegExp.fromString("\\n", ~flags="g"), " ") - ->String.trim - let extractIntro = (content: string): string => { let parts = content->String.split("\n## ") let intro = parts[0]->Option.getOr("") diff --git a/src/common/SearchIndex.resi b/src/common/SearchIndex.resi new file mode 100644 index 000000000..797af9e72 --- /dev/null +++ b/src/common/SearchIndex.resi @@ -0,0 +1,22 @@ +type record + +let buildMarkdownRecords: ( + ~category: string, + ~basePath: string, + ~dirPath: string, + ~pageRank: int, +) => array + +let buildBlogRecords: (~dirPath: string, ~pageRank: int) => array + +let buildSyntaxLookupRecords: (~dirPath: string, ~pageRank: int) => array + +let buildApiRecords: ( + ~basePath: string, + ~dirPath: string, + ~pageRank: int, + ~category: string, + ~files: array=?, +) => array + +let toJson: record => JSON.t diff --git a/src/common/Url.res b/src/common/Url.res index fb31e28cd..0c7538b6d 100644 --- a/src/common/Url.res +++ b/src/common/Url.res @@ -58,7 +58,7 @@ let prettyString = (str: string) => { let parse = (route: string): t => { let fullpath = route->String.split("/")->Array.filter(s => s !== "") let foundVersionIndex = Array.findIndex(fullpath, chunk => { - RegExp.test(/latest|next|v\d+(\.\d+)?(\.\d+)?/, chunk) + RegExp.test(/latest|next|v?\d+(\.\d+)?(\.\d+)?/, chunk) }) let (version, base, pagepath) = if foundVersionIndex == -1 { From e033cccf14183bc794bde211f5f870f04a85d3b4 Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Mon, 6 Apr 2026 18:28:04 -0400 Subject: [PATCH 09/32] add back icons --- src/components/Icon.res | 81 +++++++++++++++++++++++++++++++++++++++ src/components/Icon.resi | 20 ++++++++++ src/components/Search.res | 10 +++++ 3 files changed, 111 insertions(+) diff --git a/src/components/Icon.res b/src/components/Icon.res index daac2bdf4..7830a805d 100644 --- a/src/components/Icon.res +++ b/src/components/Icon.res @@ -291,3 +291,84 @@ module Clipboard = { } + +module DocPage = { + @react.component + let make = () => +
+ + + + + + + +
+} + +module DocHash = { + @react.component + let make = () => +
+ + + + + + +
+} + +module DocTree = { + @react.component + let make = () => + + + + + +} + +module DocSelect = { + @react.component + let make = () => +
+ + + + + + +
+} diff --git a/src/components/Icon.resi b/src/components/Icon.resi index 4087c13b6..df1f0e24b 100644 --- a/src/components/Icon.resi +++ b/src/components/Icon.resi @@ -82,3 +82,23 @@ module Clipboard: { @react.component let make: (~className: string=?) => React.element } + +module DocPage: { + @react.component + let make: unit => React.element +} + +module DocHash: { + @react.component + let make: unit => React.element +} + +module DocTree: { + @react.component + let make: unit => React.element +} + +module DocSelect: { + @react.component + let make: unit => React.element +} diff --git a/src/components/Search.res b/src/components/Search.res index 1709e3d4a..5f744f7de 100644 --- a/src/components/Search.res +++ b/src/components/Search.res @@ -58,13 +58,22 @@ let markdownToHtml = (text: string): string => ->String.replaceRegExp(RegExp.fromString("\\n", ~flags="g"), " ") ->String.trim +let isChildHit = (hit: DocSearch.docSearchHit) => + switch hit.type_ { + | Lvl2 | Lvl3 | Lvl4 | Lvl5 | Lvl6 | Content => true + | Lvl0 | Lvl1 => hit.url->String.includes("#") + } + let hitComponent = ({hit, children: _}: DocSearch.hitComponent): React.element => { let titleHtml = getHighlightedTitle(hit) let subtitle = getSubtitle(hit) let contentHtml = hit.content->Nullable.toOption->Option.map(markdownToHtml) + let isChild = isChildHit(hit)
+ {isChild ? : React.null} + {isChild ? : }
{switch subtitle { @@ -77,6 +86,7 @@ let hitComponent = ({hit, children: _}: DocSearch.hitComponent): React.element = | _ => React.null }}
+
} From 05bcd747e7ad7658e009973ae21585215f43e03b Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Mon, 6 Apr 2026 18:40:08 -0400 Subject: [PATCH 10/32] add unit tests --- __tests__/SearchIndex_.test.res | 541 ++++++++++++++++++ __tests__/Search_.test.res | 426 ++++++++++++++ __tests__/Url_.test.res | 75 +++ ...s-multiple-spaces-into-single-hyphen-1.png | Bin 0 -> 3973 bytes ...ode-stays-as-is--code-matched-first--1.png | Bin 0 -> 3973 bytes ...ripping-handles-link-with-empty-text-1.png | Bin 0 -> 3973 bytes ...ersion-detection-parses-next-keyword-1.png | Bin 0 -> 3973 bytes ...-version-without-v-prefix--PR--1231--1.png | Bin 0 -> 3973 bytes src/bindings/Vitest.res | 6 + src/common/SearchIndex.resi | 64 ++- 10 files changed, 1111 insertions(+), 1 deletion(-) create mode 100644 __tests__/SearchIndex_.test.res create mode 100644 __tests__/Search_.test.res create mode 100644 __tests__/Url_.test.res create mode 100644 __tests__/__screenshots__/SearchIndex_.test.jsx/slugify-collapses-multiple-spaces-into-single-hyphen-1.png create mode 100644 __tests__/__screenshots__/Search_.test.jsx/markdownToHtml-combined-transformations-bold-inside-code-stays-as-is--code-matched-first--1.png create mode 100644 __tests__/__screenshots__/Search_.test.jsx/markdownToHtml-markdown-link-stripping-handles-link-with-empty-text-1.png create mode 100644 __tests__/__screenshots__/Url_.test.jsx/Url-parse-version-detection-parses-next-keyword-1.png create mode 100644 __tests__/__screenshots__/Url_.test.jsx/Url-parse-version-detection-parses-version-without-v-prefix--PR--1231--1.png diff --git a/__tests__/SearchIndex_.test.res b/__tests__/SearchIndex_.test.res new file mode 100644 index 000000000..cb394f118 --- /dev/null +++ b/__tests__/SearchIndex_.test.res @@ -0,0 +1,541 @@ +open Vitest + +// --------------------------------------------------------------------------- +// maxContentLength +// --------------------------------------------------------------------------- + +describe("maxContentLength", () => { + test("is 500", async () => { + expect(SearchIndex.maxContentLength)->toBe(500) + }) +}) + +// --------------------------------------------------------------------------- +// truncate +// --------------------------------------------------------------------------- + +describe("truncate", () => { + test("returns string as-is when shorter than maxLen", async () => { + expect(SearchIndex.truncate("hello", ~maxLen=10))->toBe("hello") + }) + + test("returns string as-is when exactly maxLen", async () => { + expect(SearchIndex.truncate("hello", ~maxLen=5))->toBe("hello") + }) + + test("truncates and adds ellipsis when longer than maxLen", async () => { + expect(SearchIndex.truncate("hello world", ~maxLen=5))->toBe("hello...") + }) + + test("handles empty string", async () => { + expect(SearchIndex.truncate("", ~maxLen=5))->toBe("") + }) + + test("truncates to maxLen=0 with ellipsis", async () => { + expect(SearchIndex.truncate("abc", ~maxLen=0))->toBe("...") + }) + + test("truncates to single character with ellipsis", async () => { + expect(SearchIndex.truncate("abcdef", ~maxLen=1))->toBe("a...") + }) +}) + +// --------------------------------------------------------------------------- +// slugify +// --------------------------------------------------------------------------- + +describe("slugify", () => { + test("lowercases text", async () => { + expect(SearchIndex.slugify("Hello World"))->toBe("hello-world") + }) + + test("replaces spaces with hyphens", async () => { + expect(SearchIndex.slugify("foo bar baz"))->toBe("foo-bar-baz") + }) + + test("removes non-alphanumeric characters", async () => { + expect(SearchIndex.slugify("Hello, World!"))->toBe("hello-world") + }) + + test("collapses multiple spaces into single hyphen", async () => { + expect(SearchIndex.slugify("foo bar"))->toBe("foo-bar") + }) + + test("handles empty string", async () => { + expect(SearchIndex.slugify(""))->toBe("") + }) + + test("preserves numbers", async () => { + expect(SearchIndex.slugify("Section 42"))->toBe("section-42") + }) + + test("removes special characters like parentheses and dots", async () => { + expect(SearchIndex.slugify("Array.map()"))->toBe("arraymap") + }) + + test("handles already-slugified text", async () => { + expect(SearchIndex.slugify("already-slugified"))->toBe("already-slugified") + }) +}) + +// --------------------------------------------------------------------------- +// stripMdxTags +// --------------------------------------------------------------------------- + +describe("stripMdxTags", () => { + test("removes CodeTab blocks", async () => { + let input = "before\n\nsome code\n\nafter" + expect(SearchIndex.stripMdxTags(input))->toBe("before\nafter") + }) + + test("removes HTML tags", async () => { + expect(SearchIndex.stripMdxTags("
hello
"))->toBe("hello") + }) + + test("removes fenced code blocks", async () => { + let input = "before\n```rescript\nlet x = 1\n```\nafter" + expect(SearchIndex.stripMdxTags(input))->toBe("before\nafter") + }) + + test("strips inline code backticks", async () => { + expect(SearchIndex.stripMdxTags("use `Array.map` here"))->toBe("use Array.map here") + }) + + test("strips bold markers", async () => { + expect(SearchIndex.stripMdxTags("this is **bold** text"))->toBe("this is bold text") + }) + + test("strips italic markers", async () => { + expect(SearchIndex.stripMdxTags("this is *italic* text"))->toBe("this is italic text") + }) + + test("strips markdown links keeping link text", async () => { + expect(SearchIndex.stripMdxTags("click [here](https://example.com) now"))->toBe( + "click here now", + ) + }) + + test("removes heading markers", async () => { + expect(SearchIndex.stripMdxTags("## My Heading"))->toBe("My Heading") + }) + + test("removes h1 through h6 markers", async () => { + let input = "# H1\n## H2\n### H3\n#### H4\n##### H5\n###### H6" + expect(SearchIndex.stripMdxTags(input))->toBe("H1\nH2\nH3\nH4\nH5\nH6") + }) + + test("collapses multiple newlines to single", async () => { + expect(SearchIndex.stripMdxTags("a\n\n\nb"))->toBe("a\nb") + }) + + test("handles empty string", async () => { + expect(SearchIndex.stripMdxTags(""))->toBe("") + }) + + test("handles combined markdown formatting", async () => { + let input = "Use **`Array.map`** to [transform](http://x.com) items." + let result = SearchIndex.stripMdxTags(input) + expect(result)->toBe("Use Array.map to transform items.") + }) +}) + +// --------------------------------------------------------------------------- +// cleanDocstring +// --------------------------------------------------------------------------- + +describe("cleanDocstring", () => { + test("returns simple text as-is", async () => { + expect(SearchIndex.cleanDocstring("Simple description"))->toBe("Simple description") + }) + + test("takes content before first ## heading", async () => { + let input = "Intro text\n## Details\nMore info" + expect(SearchIndex.cleanDocstring(input))->toBe("Intro text") + }) + + test("takes content before first code block", async () => { + let input = "Intro text\n```rescript\nlet x = 1\n```" + expect(SearchIndex.cleanDocstring(input))->toBe("Intro text") + }) + + test("strips inline code backticks", async () => { + expect(SearchIndex.cleanDocstring("Returns `true` or `false`"))->toBe("Returns true or false") + }) + + test("strips bold formatting", async () => { + expect(SearchIndex.cleanDocstring("This is **important**"))->toBe("This is important") + }) + + test("strips italic formatting", async () => { + expect(SearchIndex.cleanDocstring("This is *emphasized*"))->toBe("This is emphasized") + }) + + test("strips markdown links", async () => { + expect(SearchIndex.cleanDocstring("See [docs](http://example.com)"))->toBe("See docs") + }) + + test("collapses multiple newlines to spaces", async () => { + let input = "line one\n\nline two\n\nline three" + expect(SearchIndex.cleanDocstring(input))->toBe("line one line two line three") + }) + + test("replaces single newlines with spaces", async () => { + let input = "line one\nline two" + expect(SearchIndex.cleanDocstring(input))->toBe("line one line two") + }) + + test("handles empty string", async () => { + expect(SearchIndex.cleanDocstring(""))->toBe("") + }) + + test("heading takes priority over code block", async () => { + let input = "Intro\n## Section\nText\n```\ncode\n```" + expect(SearchIndex.cleanDocstring(input))->toBe("Intro") + }) +}) + +// --------------------------------------------------------------------------- +// extractIntro +// --------------------------------------------------------------------------- + +describe("extractIntro", () => { + test("extracts text before first ## heading", async () => { + let input = "Some intro text.\n## First Section\nDetails here." + let result = SearchIndex.extractIntro(input) + expect(result)->toBe("Some intro text.") + }) + + test("removes H1 heading at start", async () => { + let input = "# Page Title\nIntro paragraph.\n## Section" + let result = SearchIndex.extractIntro(input) + expect(result)->toBe("Intro paragraph.") + }) + + test("returns stripped content when no headings", async () => { + let input = "Just some plain text content." + expect(SearchIndex.extractIntro(input))->toBe("Just some plain text content.") + }) + + test("handles empty string", async () => { + expect(SearchIndex.extractIntro(""))->toBe("") + }) + + test("strips MDX tags from intro", async () => { + let input = "Use **bold** and `code`.\n## Section" + expect(SearchIndex.extractIntro(input))->toBe("Use bold and code.") + }) + + test("removes H1 but preserves rest of content", async () => { + let input = "# Title\nFirst paragraph.\nSecond paragraph." + expect(SearchIndex.extractIntro(input))->toBe("First paragraph.\nSecond paragraph.") + }) +}) + +// --------------------------------------------------------------------------- +// extractHeadings +// --------------------------------------------------------------------------- + +describe("extractHeadings", () => { + test("extracts h2 headings", async () => { + let input = "Intro\n## First\nContent one.\n## Second\nContent two." + let headings = SearchIndex.extractHeadings(input) + expect(Array.length(headings))->toBe(2) + expect(headings[0]->Option.map(h => h.level))->toEqual(Some(2)) + expect(headings[0]->Option.map(h => h.text))->toEqual(Some("First")) + expect(headings[1]->Option.map(h => h.text))->toEqual(Some("Second")) + }) + + test("extracts h3 headings", async () => { + let input = "## Parent\n### Child\nSub content." + let headings = SearchIndex.extractHeadings(input) + expect(headings[0]->Option.map(h => h.level))->toEqual(Some(2)) + expect(headings[1]->Option.map(h => h.level))->toEqual(Some(3)) + expect(headings[1]->Option.map(h => h.text))->toEqual(Some("Child")) + }) + + test("does not extract h1 headings", async () => { + let input = "# Title\nSome text\n## Real Heading\nContent." + let headings = SearchIndex.extractHeadings(input) + expect(Array.length(headings))->toBe(1) + expect(headings[0]->Option.map(h => h.text))->toEqual(Some("Real Heading")) + }) + + test("returns empty array when no headings", async () => { + let input = "Just plain text with no headings." + let headings = SearchIndex.extractHeadings(input) + expect(Array.length(headings))->toBe(0) + }) + + test("includes section content between headings", async () => { + let input = "## Heading\nThis is the content of the section." + let headings = SearchIndex.extractHeadings(input) + expect(headings[0]->Option.map(h => h.content))->toEqual( + Some("This is the content of the section."), + ) + }) + + test("strips MDX tags from section content", async () => { + let input = "## Heading\nUse **bold** and `code` here." + let headings = SearchIndex.extractHeadings(input) + expect(headings[0]->Option.map(h => h.content))->toEqual(Some("Use bold and code here.")) + }) + + test("truncates section content to maxContentLength", async () => { + let longContent = String.repeat("a", 600) + let input = "## Heading\n" ++ longContent + let headings = SearchIndex.extractHeadings(input) + let contentLen = headings[0]->Option.map(h => String.length(h.content))->Option.getOr(0) + // 500 chars + "..." = 503 + expect(contentLen)->toBe(503) + }) + + test("handles multiple heading levels", async () => { + let input = "## H2\nA\n### H3\nB\n#### H4\nC\n##### H5\nD\n###### H6\nE" + let headings = SearchIndex.extractHeadings(input) + expect(Array.length(headings))->toBe(5) + expect(headings[0]->Option.map(h => h.level))->toEqual(Some(2)) + expect(headings[1]->Option.map(h => h.level))->toEqual(Some(3)) + expect(headings[2]->Option.map(h => h.level))->toEqual(Some(4)) + expect(headings[3]->Option.map(h => h.level))->toEqual(Some(5)) + expect(headings[4]->Option.map(h => h.level))->toEqual(Some(6)) + }) +}) + +// --------------------------------------------------------------------------- +// makeHierarchy +// --------------------------------------------------------------------------- + +describe("makeHierarchy", () => { + test("creates hierarchy with only required fields", async () => { + let h = SearchIndex.makeHierarchy(~lvl0="Docs", ~lvl1="Overview", ()) + expect(h.lvl0)->toBe("Docs") + expect(h.lvl1)->toBe("Overview") + expect(h.lvl2)->toEqual(None) + expect(h.lvl3)->toEqual(None) + expect(h.lvl4)->toEqual(None) + expect(h.lvl5)->toEqual(None) + expect(h.lvl6)->toEqual(None) + }) + + test("creates hierarchy with all optional fields", async () => { + let h = SearchIndex.makeHierarchy( + ~lvl0="Docs", + ~lvl1="Guide", + ~lvl2="Chapter", + ~lvl3="Section", + ~lvl4="Sub A", + ~lvl5="Sub B", + ~lvl6="Sub C", + (), + ) + expect(h.lvl0)->toBe("Docs") + expect(h.lvl1)->toBe("Guide") + expect(h.lvl2)->toEqual(Some("Chapter")) + expect(h.lvl3)->toEqual(Some("Section")) + expect(h.lvl4)->toEqual(Some("Sub A")) + expect(h.lvl5)->toEqual(Some("Sub B")) + expect(h.lvl6)->toEqual(Some("Sub C")) + }) + + test("creates hierarchy with partial optional fields", async () => { + let h = SearchIndex.makeHierarchy(~lvl0="API", ~lvl1="Array", ~lvl2="map", ()) + expect(h.lvl2)->toEqual(Some("map")) + expect(h.lvl3)->toEqual(None) + }) +}) + +// --------------------------------------------------------------------------- +// optionToJson +// --------------------------------------------------------------------------- + +describe("optionToJson", () => { + test("converts Some to JSON string", async () => { + expect(SearchIndex.optionToJson(Some("hello")))->toEqual(JSON.String("hello")) + }) + + test("converts None to JSON null", async () => { + expect(SearchIndex.optionToJson(None))->toEqual(JSON.Null) + }) + + test("converts Some empty string to JSON string", async () => { + expect(SearchIndex.optionToJson(Some("")))->toEqual(JSON.String("")) + }) +}) + +// --------------------------------------------------------------------------- +// hierarchyToJson +// --------------------------------------------------------------------------- + +describe("hierarchyToJson", () => { + test("serializes hierarchy with only required fields", async () => { + let h = SearchIndex.makeHierarchy(~lvl0="Docs", ~lvl1="Page", ()) + let json = SearchIndex.hierarchyToJson(h) + let expected = { + let d = Dict.make() + d->Dict.set("lvl0", JSON.String("Docs")) + d->Dict.set("lvl1", JSON.String("Page")) + d->Dict.set("lvl2", JSON.Null) + d->Dict.set("lvl3", JSON.Null) + d->Dict.set("lvl4", JSON.Null) + d->Dict.set("lvl5", JSON.Null) + d->Dict.set("lvl6", JSON.Null) + JSON.Object(d) + } + expect(json)->toEqual(expected) + }) + + test("serializes hierarchy with optional fields as JSON strings", async () => { + let h = SearchIndex.makeHierarchy(~lvl0="API", ~lvl1="Array", ~lvl2="map", ()) + let json = SearchIndex.hierarchyToJson(h) + let expected = { + let d = Dict.make() + d->Dict.set("lvl0", JSON.String("API")) + d->Dict.set("lvl1", JSON.String("Array")) + d->Dict.set("lvl2", JSON.String("map")) + d->Dict.set("lvl3", JSON.Null) + d->Dict.set("lvl4", JSON.Null) + d->Dict.set("lvl5", JSON.Null) + d->Dict.set("lvl6", JSON.Null) + JSON.Object(d) + } + expect(json)->toEqual(expected) + }) +}) + +// --------------------------------------------------------------------------- +// weightToJson +// --------------------------------------------------------------------------- + +describe("weightToJson", () => { + test("serializes weight to JSON object with number values", async () => { + let w: SearchIndex.weight = {pageRank: 10, level: 80, position: 3} + let json = SearchIndex.weightToJson(w) + let expected = { + let d = Dict.make() + d->Dict.set("pageRank", JSON.Number(10.0)) + d->Dict.set("level", JSON.Number(80.0)) + d->Dict.set("position", JSON.Number(3.0)) + JSON.Object(d) + } + expect(json)->toEqual(expected) + }) + + test("serializes zero values correctly", async () => { + let w: SearchIndex.weight = {pageRank: 0, level: 0, position: 0} + let json = SearchIndex.weightToJson(w) + let expected = { + let d = Dict.make() + d->Dict.set("pageRank", JSON.Number(0.0)) + d->Dict.set("level", JSON.Number(0.0)) + d->Dict.set("position", JSON.Number(0.0)) + JSON.Object(d) + } + expect(json)->toEqual(expected) + }) +}) + +// --------------------------------------------------------------------------- +// toJson +// --------------------------------------------------------------------------- + +describe("toJson", () => { + test("serializes a full record with all fields", async () => { + let r: SearchIndex.record = { + objectID: "docs/overview", + url: "/docs/overview#intro", + url_without_anchor: "/docs/overview", + anchor: Some("intro"), + content: Some("Introduction text"), + type_: "lvl2", + hierarchy: SearchIndex.makeHierarchy(~lvl0="Docs", ~lvl1="Overview", ~lvl2="Intro", ()), + weight: {pageRank: 5, level: 80, position: 1}, + } + let json = SearchIndex.toJson(r) + + let expected = { + let d = Dict.make() + d->Dict.set("objectID", JSON.String("docs/overview")) + d->Dict.set("url", JSON.String("/docs/overview#intro")) + d->Dict.set("url_without_anchor", JSON.String("/docs/overview")) + d->Dict.set("anchor", JSON.String("intro")) + d->Dict.set("content", JSON.String("Introduction text")) + d->Dict.set("type", JSON.String("lvl2")) + d->Dict.set( + "hierarchy", + { + let hd = Dict.make() + hd->Dict.set("lvl0", JSON.String("Docs")) + hd->Dict.set("lvl1", JSON.String("Overview")) + hd->Dict.set("lvl2", JSON.String("Intro")) + hd->Dict.set("lvl3", JSON.Null) + hd->Dict.set("lvl4", JSON.Null) + hd->Dict.set("lvl5", JSON.Null) + hd->Dict.set("lvl6", JSON.Null) + JSON.Object(hd) + }, + ) + d->Dict.set( + "weight", + { + let wd = Dict.make() + wd->Dict.set("pageRank", JSON.Number(5.0)) + wd->Dict.set("level", JSON.Number(80.0)) + wd->Dict.set("position", JSON.Number(1.0)) + JSON.Object(wd) + }, + ) + JSON.Object(d) + } + expect(json)->toEqual(expected) + }) + + test("serializes a record with None optional fields as null", async () => { + let r: SearchIndex.record = { + objectID: "page", + url: "/page", + url_without_anchor: "/page", + anchor: None, + content: None, + type_: "lvl1", + hierarchy: SearchIndex.makeHierarchy(~lvl0="Cat", ~lvl1="Page", ()), + weight: {pageRank: 1, level: 100, position: 0}, + } + let json = SearchIndex.toJson(r) + + let expected = { + let d = Dict.make() + d->Dict.set("objectID", JSON.String("page")) + d->Dict.set("url", JSON.String("/page")) + d->Dict.set("url_without_anchor", JSON.String("/page")) + d->Dict.set("anchor", JSON.Null) + d->Dict.set("content", JSON.Null) + d->Dict.set("type", JSON.String("lvl1")) + d->Dict.set( + "hierarchy", + { + let hd = Dict.make() + hd->Dict.set("lvl0", JSON.String("Cat")) + hd->Dict.set("lvl1", JSON.String("Page")) + hd->Dict.set("lvl2", JSON.Null) + hd->Dict.set("lvl3", JSON.Null) + hd->Dict.set("lvl4", JSON.Null) + hd->Dict.set("lvl5", JSON.Null) + hd->Dict.set("lvl6", JSON.Null) + JSON.Object(hd) + }, + ) + d->Dict.set( + "weight", + { + let wd = Dict.make() + wd->Dict.set("pageRank", JSON.Number(1.0)) + wd->Dict.set("level", JSON.Number(100.0)) + wd->Dict.set("position", JSON.Number(0.0)) + JSON.Object(wd) + }, + ) + JSON.Object(d) + } + expect(json)->toEqual(expected) + }) +}) diff --git a/__tests__/Search_.test.res b/__tests__/Search_.test.res new file mode 100644 index 000000000..12fcb1ce6 --- /dev/null +++ b/__tests__/Search_.test.res @@ -0,0 +1,426 @@ +open Vitest + +// --------------------------------------------------------------------------- +// Helper +// --------------------------------------------------------------------------- + +let makeHit = (~type_: DocSearch.contentType, ~url: string): DocSearch.docSearchHit => { + objectID: "test", + content: Nullable.null, + url, + url_without_anchor: url, + type_, + anchor: Nullable.null, + hierarchy: { + lvl0: Nullable.make("Test"), + lvl1: Nullable.make("Test Page"), + lvl2: Nullable.null, + lvl3: Nullable.null, + lvl4: Nullable.null, + lvl5: Nullable.null, + lvl6: Nullable.null, + }, + deprecated: None, + _highlightResult: Obj.magic(Dict.make()), + _snippetResult: Obj.magic(Dict.make()), +} + +// --------------------------------------------------------------------------- +// markdownToHtml +// --------------------------------------------------------------------------- + +describe("markdownToHtml", () => { + // --- backslash stripping --- + + describe("backslash stripping", () => { + test( + "strips leading backslash + whitespace", + async () => { + expect(Search.markdownToHtml("\\ hello"))->toBe("hello") + }, + ) + + test( + "replaces interior backslash + whitespace with a space", + async () => { + expect(Search.markdownToHtml("foo\\ bar"))->toBe("foo bar") + }, + ) + + test( + "handles multiple interior backslashes", + async () => { + expect(Search.markdownToHtml("a\\ b\\ c"))->toBe("a b c") + }, + ) + + test( + "strips leading and replaces interior backslashes together", + async () => { + expect(Search.markdownToHtml("\\ a\\ b"))->toBe("a b") + }, + ) + }) + + // --- MDN reference link removal --- + + describe("MDN reference removal", () => { + test( + "removes MDN reference with markdown link and trailing period", + async () => { + expect( + Search.markdownToHtml( + "Some text. See [Array](https://developer.mozilla.org/array) on MDN.", + ), + )->toBe("Some text.") + }, + ) + + test( + "removes MDN reference with markdown link without trailing period", + async () => { + expect( + Search.markdownToHtml( + "Some text. See [Array](https://developer.mozilla.org/array) on MDN", + ), + )->toBe("Some text.") + }, + ) + + test( + "removes MDN plain URL reference with trailing period", + async () => { + expect( + Search.markdownToHtml("Read more. See https://developer.mozilla.org/foo on MDN."), + )->toBe("Read more.") + }, + ) + + test( + "removes MDN plain URL reference without trailing period", + async () => { + expect( + Search.markdownToHtml("Read more. See https://developer.mozilla.org/foo on MDN"), + )->toBe("Read more.") + }, + ) + }) + + // --- markdown link stripping --- + + describe("markdown link stripping", () => { + test( + "converts markdown link to plain text", + async () => { + expect(Search.markdownToHtml("[click here](https://example.com)"))->toBe("click here") + }, + ) + + test( + "converts multiple markdown links", + async () => { + expect(Search.markdownToHtml("[foo](http://a.com) and [bar](http://b.com)"))->toBe( + "foo and bar", + ) + }, + ) + + test( + "passes through link with empty text (regex requires non-empty text)", + async () => { + expect(Search.markdownToHtml("[](https://example.com)"))->toBe("[](https://example.com)") + }, + ) + }) + + // --- inline code --- + + describe("backtick code", () => { + test( + "converts backtick code to tags", + async () => { + expect(Search.markdownToHtml("`Array.map`"))->toBe("Array.map") + }, + ) + + test( + "converts multiple backtick spans", + async () => { + expect(Search.markdownToHtml("Use `map` and `filter`"))->toBe( + "Use map and filter", + ) + }, + ) + }) + + // --- bold --- + + describe("bold", () => { + test( + "converts **text** to tags", + async () => { + expect(Search.markdownToHtml("**important**"))->toBe("important") + }, + ) + + test( + "converts bold within a sentence", + async () => { + expect(Search.markdownToHtml("This is **very** important"))->toBe( + "This is very important", + ) + }, + ) + }) + + // --- italic --- + + describe("italic", () => { + test( + "converts *text* to tags", + async () => { + expect(Search.markdownToHtml("*emphasis*"))->toBe("emphasis") + }, + ) + + test( + "converts italic within a sentence", + async () => { + expect(Search.markdownToHtml("This is *quite* nice"))->toBe("This is quite nice") + }, + ) + }) + + // --- newlines --- + + describe("newlines", () => { + test( + "converts double newline to
", + async () => { + expect(Search.markdownToHtml("first\n\nsecond"))->toBe("first
second") + }, + ) + + test( + "converts triple+ newlines to single
", + async () => { + expect(Search.markdownToHtml("first\n\n\nsecond"))->toBe("first
second") + }, + ) + + test( + "converts single newline to space", + async () => { + expect(Search.markdownToHtml("first\nsecond"))->toBe("first second") + }, + ) + }) + + // --- trimming --- + + describe("trimming", () => { + test( + "trims leading whitespace", + async () => { + expect(Search.markdownToHtml(" hello"))->toBe("hello") + }, + ) + + test( + "trims trailing whitespace", + async () => { + expect(Search.markdownToHtml("hello "))->toBe("hello") + }, + ) + + test( + "trims both sides", + async () => { + expect(Search.markdownToHtml(" hello "))->toBe("hello") + }, + ) + }) + + // --- combined / edge cases --- + + describe("combined transformations", () => { + test( + "handles empty string", + async () => { + expect(Search.markdownToHtml(""))->toBe("") + }, + ) + + test( + "plain text passes through unchanged", + async () => { + expect(Search.markdownToHtml("just plain text"))->toBe("just plain text") + }, + ) + + test( + "applies multiple transformations together", + async () => { + expect( + Search.markdownToHtml( + "Use `map` on **arrays**.\n\nSee [docs](http://x.com) for *details*.", + ), + )->toBe( + "Use map on arrays.
See docs for details.", + ) + }, + ) + + test( + "bold inside code still gets converted (sequential regex application)", + async () => { + expect(Search.markdownToHtml("`**notbold**`"))->toBe( + "notbold", + ) + }, + ) + }) +}) + +// --------------------------------------------------------------------------- +// isChildHit +// --------------------------------------------------------------------------- + +describe("isChildHit", () => { + // --- child-level types (always true) --- + + describe("child-level types", () => { + test( + "Lvl2 is a child hit", + async () => { + expect(Search.isChildHit(makeHit(~type_=Lvl2, ~url="https://example.com/page")))->toBe(true) + }, + ) + + test( + "Lvl3 is a child hit", + async () => { + expect(Search.isChildHit(makeHit(~type_=Lvl3, ~url="https://example.com/page")))->toBe(true) + }, + ) + + test( + "Lvl4 is a child hit", + async () => { + expect(Search.isChildHit(makeHit(~type_=Lvl4, ~url="https://example.com/page")))->toBe(true) + }, + ) + + test( + "Lvl5 is a child hit", + async () => { + expect(Search.isChildHit(makeHit(~type_=Lvl5, ~url="https://example.com/page")))->toBe(true) + }, + ) + + test( + "Lvl6 is a child hit", + async () => { + expect(Search.isChildHit(makeHit(~type_=Lvl6, ~url="https://example.com/page")))->toBe(true) + }, + ) + + test( + "Content is a child hit", + async () => { + expect(Search.isChildHit(makeHit(~type_=Content, ~url="https://example.com/page")))->toBe( + true, + ) + }, + ) + + test( + "Lvl2 is a child hit even without hash in URL", + async () => { + expect(Search.isChildHit(makeHit(~type_=Lvl2, ~url="https://example.com/no-hash")))->toBe( + true, + ) + }, + ) + + test( + "Content is a child hit even with hash in URL", + async () => { + expect( + Search.isChildHit(makeHit(~type_=Content, ~url="https://example.com/page#section")), + )->toBe(true) + }, + ) + }) + + // --- Lvl0 --- + + describe("Lvl0", () => { + test( + "Lvl0 without hash is not a child hit", + async () => { + expect(Search.isChildHit(makeHit(~type_=Lvl0, ~url="https://example.com/page")))->toBe( + false, + ) + }, + ) + + test( + "Lvl0 with hash is a child hit", + async () => { + expect( + Search.isChildHit(makeHit(~type_=Lvl0, ~url="https://example.com/page#section")), + )->toBe(true) + }, + ) + + test( + "Lvl0 with hash at end of URL is a child hit", + async () => { + expect(Search.isChildHit(makeHit(~type_=Lvl0, ~url="https://example.com/page#")))->toBe( + true, + ) + }, + ) + }) + + // --- Lvl1 --- + + describe("Lvl1", () => { + test( + "Lvl1 without hash is not a child hit", + async () => { + expect(Search.isChildHit(makeHit(~type_=Lvl1, ~url="https://example.com/page")))->toBe( + false, + ) + }, + ) + + test( + "Lvl1 with hash is a child hit", + async () => { + expect( + Search.isChildHit(makeHit(~type_=Lvl1, ~url="https://example.com/page#heading")), + )->toBe(true) + }, + ) + + test( + "Lvl1 with deeply nested hash anchor is a child hit", + async () => { + expect( + Search.isChildHit( + makeHit(~type_=Lvl1, ~url="https://example.com/docs/manual/api#some-section"), + ), + )->toBe(true) + }, + ) + + test( + "Lvl1 with empty URL is not a child hit", + async () => { + expect(Search.isChildHit(makeHit(~type_=Lvl1, ~url="")))->toBe(false) + }, + ) + }) +}) diff --git a/__tests__/Url_.test.res b/__tests__/Url_.test.res new file mode 100644 index 000000000..5cf00796f --- /dev/null +++ b/__tests__/Url_.test.res @@ -0,0 +1,75 @@ +open Vitest + +// --------------------------------------------------------------------------- +// Url.parse – version detection +// --------------------------------------------------------------------------- + +describe("Url.parse version detection", () => { + test("parses v-prefixed semver version", async () => { + let result = Url.parse("/docs/manual/v12.0.0/introduction") + expect(result.version)->toEqual(Url.Version("v12.0.0")) + expect(result.base)->toEqual(["docs", "manual"]) + expect(result.pagepath)->toEqual(["introduction"]) + expect(result.fullpath)->toEqual(["docs", "manual", "v12.0.0", "introduction"]) + }) + + test("parses version without v prefix matching latest (PR #1231)", async () => { + let result = Url.parse("/docs/manual/12.0.0/introduction") + // 12.0.0 matches Constants.versions.latest, so it becomes Latest + expect(result.version)->toEqual(Url.Latest) + expect(result.base)->toEqual(["docs", "manual"]) + expect(result.pagepath)->toEqual(["introduction"]) + expect(result.fullpath)->toEqual(["docs", "manual", "12.0.0", "introduction"]) + }) + + test("parses latest keyword", async () => { + let result = Url.parse("/docs/manual/latest/arrays") + expect(result.version)->toEqual(Url.Latest) + expect(result.base)->toEqual(["docs", "manual"]) + expect(result.pagepath)->toEqual(["arrays"]) + }) + + test("parses 'next' string in URL (does not match env-based Next version)", async () => { + // "next" is matched by the regex, but Constants.versions.next is "13.0.0", not "next" + let result = Url.parse("/docs/manual/next/arrays") + expect(result.version)->toEqual(Url.Version("next")) + expect(result.base)->toEqual(["docs", "manual"]) + expect(result.pagepath)->toEqual(["arrays"]) + }) + + test("parses actual next version from env as Next", async () => { + let nextVer = Constants.versions.next + let result = Url.parse("/docs/manual/" ++ nextVer ++ "/arrays") + expect(result.version)->toEqual(Url.Next) + expect(result.base)->toEqual(["docs", "manual"]) + expect(result.pagepath)->toEqual(["arrays"]) + }) + + test("parses route with no version as NoVersion", async () => { + let result = Url.parse("/community/overview") + expect(result.version)->toEqual(Url.NoVersion) + expect(result.base)->toEqual(["community", "overview"]) + expect(result.pagepath)->toEqual([]) + }) + + test("parses short v-prefixed version (major.minor)", async () => { + let result = Url.parse("/apis/javascript/v7.1/node") + expect(result.version)->toEqual(Url.Version("v7.1")) + expect(result.base)->toEqual(["apis", "javascript"]) + expect(result.pagepath)->toEqual(["node"]) + }) + + test("parses short version without v prefix (major.minor, PR #1231)", async () => { + let result = Url.parse("/apis/javascript/7.1/node") + expect(result.version)->toEqual(Url.Version("7.1")) + expect(result.base)->toEqual(["apis", "javascript"]) + expect(result.pagepath)->toEqual(["node"]) + }) + + test("parses major-only version without v prefix (PR #1231)", async () => { + let result = Url.parse("/docs/manual/12/getting-started") + expect(result.version)->toEqual(Url.Version("12")) + expect(result.base)->toEqual(["docs", "manual"]) + expect(result.pagepath)->toEqual(["getting-started"]) + }) +}) diff --git a/__tests__/__screenshots__/SearchIndex_.test.jsx/slugify-collapses-multiple-spaces-into-single-hyphen-1.png b/__tests__/__screenshots__/SearchIndex_.test.jsx/slugify-collapses-multiple-spaces-into-single-hyphen-1.png new file mode 100644 index 0000000000000000000000000000000000000000..a35891721ab1844b9f03a3d8781f386e165a52b4 GIT binary patch literal 3973 zcmeAS@N?(olHy`uVBq!ia0y~yU}<1rV7kD;1Qbc1GUosT1HYB0i(^Q|oHy4Uc^MRV z7&f*(xc@2O;NB?ly}PY}s{WL-F)%c=G%zwSxUldrFeoS`07V5EnHd;5I3ySt99)2g zFeotrRS5_h0F`!(Djf|0nuY){sY#8dx6u$74S~@R7!85Z5Eu=C(GVC7fzc2ch!9|F zXrpOcmtnNSHE2VCpMl~3f8WUhUBC`78>kn}%y2-7W5LsxcEBDA1B0ilpUXO@geCwk C-QUIl literal 0 HcmV?d00001 diff --git a/__tests__/__screenshots__/Search_.test.jsx/markdownToHtml-combined-transformations-bold-inside-code-stays-as-is--code-matched-first--1.png b/__tests__/__screenshots__/Search_.test.jsx/markdownToHtml-combined-transformations-bold-inside-code-stays-as-is--code-matched-first--1.png new file mode 100644 index 0000000000000000000000000000000000000000..a35891721ab1844b9f03a3d8781f386e165a52b4 GIT binary patch literal 3973 zcmeAS@N?(olHy`uVBq!ia0y~yU}<1rV7kD;1Qbc1GUosT1HYB0i(^Q|oHy4Uc^MRV z7&f*(xc@2O;NB?ly}PY}s{WL-F)%c=G%zwSxUldrFeoS`07V5EnHd;5I3ySt99)2g zFeotrRS5_h0F`!(Djf|0nuY){sY#8dx6u$74S~@R7!85Z5Eu=C(GVC7fzc2ch!9|F zXrpOcmtnNSHE2VCpMl~3f8WUhUBC`78>kn}%y2-7W5LsxcEBDA1B0ilpUXO@geCwk C-QUIl literal 0 HcmV?d00001 diff --git a/__tests__/__screenshots__/Search_.test.jsx/markdownToHtml-markdown-link-stripping-handles-link-with-empty-text-1.png b/__tests__/__screenshots__/Search_.test.jsx/markdownToHtml-markdown-link-stripping-handles-link-with-empty-text-1.png new file mode 100644 index 0000000000000000000000000000000000000000..a35891721ab1844b9f03a3d8781f386e165a52b4 GIT binary patch literal 3973 zcmeAS@N?(olHy`uVBq!ia0y~yU}<1rV7kD;1Qbc1GUosT1HYB0i(^Q|oHy4Uc^MRV z7&f*(xc@2O;NB?ly}PY}s{WL-F)%c=G%zwSxUldrFeoS`07V5EnHd;5I3ySt99)2g zFeotrRS5_h0F`!(Djf|0nuY){sY#8dx6u$74S~@R7!85Z5Eu=C(GVC7fzc2ch!9|F zXrpOcmtnNSHE2VCpMl~3f8WUhUBC`78>kn}%y2-7W5LsxcEBDA1B0ilpUXO@geCwk C-QUIl literal 0 HcmV?d00001 diff --git a/__tests__/__screenshots__/Url_.test.jsx/Url-parse-version-detection-parses-next-keyword-1.png b/__tests__/__screenshots__/Url_.test.jsx/Url-parse-version-detection-parses-next-keyword-1.png new file mode 100644 index 0000000000000000000000000000000000000000..a35891721ab1844b9f03a3d8781f386e165a52b4 GIT binary patch literal 3973 zcmeAS@N?(olHy`uVBq!ia0y~yU}<1rV7kD;1Qbc1GUosT1HYB0i(^Q|oHy4Uc^MRV z7&f*(xc@2O;NB?ly}PY}s{WL-F)%c=G%zwSxUldrFeoS`07V5EnHd;5I3ySt99)2g zFeotrRS5_h0F`!(Djf|0nuY){sY#8dx6u$74S~@R7!85Z5Eu=C(GVC7fzc2ch!9|F zXrpOcmtnNSHE2VCpMl~3f8WUhUBC`78>kn}%y2-7W5LsxcEBDA1B0ilpUXO@geCwk C-QUIl literal 0 HcmV?d00001 diff --git a/__tests__/__screenshots__/Url_.test.jsx/Url-parse-version-detection-parses-version-without-v-prefix--PR--1231--1.png b/__tests__/__screenshots__/Url_.test.jsx/Url-parse-version-detection-parses-version-without-v-prefix--PR--1231--1.png new file mode 100644 index 0000000000000000000000000000000000000000..a35891721ab1844b9f03a3d8781f386e165a52b4 GIT binary patch literal 3973 zcmeAS@N?(olHy`uVBq!ia0y~yU}<1rV7kD;1Qbc1GUosT1HYB0i(^Q|oHy4Uc^MRV z7&f*(xc@2O;NB?ly}PY}s{WL-F)%c=G%zwSxUldrFeoS`07V5EnHd;5I3ySt99)2g zFeotrRS5_h0F`!(Djf|0nuY){sY#8dx6u$74S~@R7!85Z5Eu=C(GVC7fzc2ch!9|F zXrpOcmtnNSHE2VCpMl~3f8WUhUBC`78>kn}%y2-7W5LsxcEBDA1B0ilpUXO@geCwk C-QUIl literal 0 HcmV?d00001 diff --git a/src/bindings/Vitest.res b/src/bindings/Vitest.res index b8f20fef8..c6b7cbe74 100644 --- a/src/bindings/Vitest.res +++ b/src/bindings/Vitest.res @@ -9,6 +9,9 @@ type mock @module("vitest") external test: (string, unit => promise) => unit = "test" +@module("vitest") +external describe: (string, unit => unit) => unit = "describe" + @module("vitest") @scope("vi") external fn: unit => 'a => 'b = "fn" @@ -65,6 +68,9 @@ external click: element => promise = "click" @send external toBe: (expect, 'a) => unit = "toBe" +@send +external toEqual: (expect, 'a) => unit = "toEqual" + @send external toHaveBeenCalled: expect => unit = "toHaveBeenCalled" diff --git a/src/common/SearchIndex.resi b/src/common/SearchIndex.resi index 797af9e72..435e81eb2 100644 --- a/src/common/SearchIndex.resi +++ b/src/common/SearchIndex.resi @@ -1,4 +1,66 @@ -type record +type hierarchy = { + lvl0: string, + lvl1: string, + lvl2: option, + lvl3: option, + lvl4: option, + lvl5: option, + lvl6: option, +} + +type weight = { + pageRank: int, + level: int, + position: int, +} + +type record = { + objectID: string, + url: string, + url_without_anchor: string, + anchor: option, + content: option, + @as("type") type_: string, + hierarchy: hierarchy, + weight: weight, +} + +type heading = { + level: int, + text: string, + content: string, +} + +let maxContentLength: int + +let makeHierarchy: ( + ~lvl0: string, + ~lvl1: string, + ~lvl2: string=?, + ~lvl3: string=?, + ~lvl4: string=?, + ~lvl5: string=?, + ~lvl6: string=?, + unit, +) => hierarchy + +let truncate: (string, ~maxLen: int) => string + +let slugify: string => string + +let stripMdxTags: string => string + +let cleanDocstring: string => string + +let extractIntro: string => string + +let extractHeadings: string => array + +let optionToJson: option => JSON.t + +let hierarchyToJson: hierarchy => JSON.t + +let weightToJson: weight => JSON.t let buildMarkdownRecords: ( ~category: string, From 9016a08819ca6af97f3cab84406bfdcf88ca085b Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Thu, 9 Apr 2026 11:19:36 -0400 Subject: [PATCH 11/32] Update DocSearch styles for footer and command keys - Hide clear and cancel buttons for cleaner UI - Redesign footer with border and spacing adjustments - Add styles for command key display in footer --- styles/_docsearch.css | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/styles/_docsearch.css b/styles/_docsearch.css index 42c5b36b6..e770e20f2 100644 --- a/styles/_docsearch.css +++ b/styles/_docsearch.css @@ -137,17 +137,17 @@ @apply hidden; } +.DocSearch-Clear { + @apply hidden; +} + .DocSearch-LoadingIndicator svg, .DocSearch-MagnifierLabel svg { @apply w-4 h-4; } .DocSearch-Cancel { - font-size: 0; - background-image: url("data:image/svg+xml,%3Csvg width='16' height='7' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M.506 6h3.931V4.986H1.736v-1.39h2.488V2.583H1.736V1.196h2.69V.182H.506V6ZM8.56 1.855h1.18C9.721.818 8.87.102 7.574.102c-1.276 0-2.21.705-2.205 1.762-.003.858.602 1.35 1.585 1.585l.634.159c.633.153.986.335.988.727-.002.426-.406.716-1.03.716-.64 0-1.1-.295-1.14-.878h-1.19c.03 1.259.931 1.91 2.343 1.91 1.42 0 2.256-.68 2.259-1.745-.003-.969-.733-1.483-1.744-1.71l-.523-.125c-.506-.117-.93-.304-.92-.722 0-.375.332-.65.934-.65.588 0 .949.267.994.724ZM15.78 2.219C15.618.875 14.6.102 13.254.102c-1.537 0-2.71 1.086-2.71 2.989 0 1.898 1.153 2.989 2.71 2.989 1.492 0 2.392-.992 2.526-2.063l-1.244-.006c-.117.623-.606.98-1.262.98-.883 0-1.483-.656-1.483-1.9 0-1.21.591-1.9 1.492-1.9.673 0 1.159.389 1.253 1.028h1.244Z' fill='%2394a3b8'/%3E%3C/svg%3E") !important; - background-size: 57.1428571429% auto; - @apply w-9 h-7 bg-no-repeat bg-center appearance-none border border-gray-20 - rounded; + display: none !important; } /* Modal Dropdown */ @@ -327,12 +327,22 @@ svg.DocSearch-Hit-Select-Icon { /* Modal Footer */ .DocSearch-Footer { - @apply flex flex-row-reverse flex-shrink-0 justify-between relative - select-none w-full z-100 p-4; + border-top: 1px solid; + @apply flex flex-shrink-0 items-center justify-between relative + select-none w-full z-100 px-4 py-3 border-gray-20; } .DocSearch-Commands { - display: none !important; + @apply flex items-center gap-3 list-none m-0 p-0; +} + +.DocSearch-Commands li { + @apply flex items-center gap-1.5 text-12 text-gray-40; +} + +.DocSearch-Commands-Key { + @apply inline-flex items-center justify-center w-5 h-5 rounded + border border-gray-20 bg-gray-5 text-11 text-gray-60 font-medium; } /* Responsive */ From e29a26e2daab596eb612a02d3e29075f55deb881 Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Thu, 9 Apr 2026 11:30:31 -0400 Subject: [PATCH 12/32] Remove Escape key handler from Search component Update DocSearch commands to show 'to clear' when input has text and 'to close' when empty --- src/components/Search.res | 1 - styles/_docsearch.css | 18 ++++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/components/Search.res b/src/components/Search.res index 5f744f7de..4da1eb36d 100644 --- a/src/components/Search.res +++ b/src/components/Search.res @@ -131,7 +131,6 @@ let make = () => { switch e.key { | "/" => focusSearch(e) | "k" if e.ctrlKey || e.metaKey => focusSearch(e) - | "Escape" => handleCloseModal() | _ => () } } diff --git a/styles/_docsearch.css b/styles/_docsearch.css index e770e20f2..ba5f02688 100644 --- a/styles/_docsearch.css +++ b/styles/_docsearch.css @@ -345,6 +345,24 @@ svg.DocSearch-Hit-Select-Icon { border border-gray-20 bg-gray-5 text-11 text-gray-60 font-medium; } +/* Swap "to close" / "to clear" based on whether the input has a query. + :placeholder-shown is true when the input is empty, false when it has text. */ +.DocSearch-Commands li:last-child .DocSearch-Label { + font-size: 0; +} + +.DocSearch-Commands li:last-child .DocSearch-Label::after { + content: "to close"; + font-size: 0.75rem; +} + +.DocSearch-Modal:has(.DocSearch-Input:not(:placeholder-shown)) + .DocSearch-Commands + li:last-child + .DocSearch-Label::after { + content: "to clear"; +} + /* Responsive */ @media (max-width: 750px) { .DocSearch-Dropdown { From 6480ca3cc2c9b8025420d2cfa9247dc62a74ce8a Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Sat, 25 Apr 2026 11:03:37 -0400 Subject: [PATCH 13/32] [codex] finish Algolia env split and search fallback (#1273) * chore: remove tracked algolia values from env * feat: finish algolia env split - centralize public and publisher Algolia env parsing - disable search clearly when public env is missing - inject dev/prod Algolia envs in deploy CI - fix the pre-existing lazy wrapper compile blockers * Delete docs/superpowers/specs/2026-04-25-algolia-env-split-design.md --- .env | 3 - .github/workflows/deploy.yml | 32 +++++ __tests__/AlgoliaConfig_.test.res | 41 ++++++ __tests__/Search_.test.res | 9 ++ package.json | 7 +- .../__tests__/log_algolia_env_status.test.mjs | 24 ++++ scripts/generate_search_index.res | 24 ++-- scripts/log_algolia_env_status.mjs | 23 ++++ src/PlaygroundLazy.res | 2 +- src/bindings/Env.res | 35 ++++- src/common/AlgoliaConfig.res | 69 ++++++++++ src/components/DocsonLazy.res | 2 +- src/components/Search.res | 126 ++++++++++-------- 13 files changed, 323 insertions(+), 74 deletions(-) create mode 100644 __tests__/AlgoliaConfig_.test.res create mode 100644 scripts/__tests__/log_algolia_env_status.test.mjs create mode 100644 scripts/log_algolia_env_status.mjs create mode 100644 src/common/AlgoliaConfig.res diff --git a/.env b/.env index 5bbf70d83..2fbb85e5b 100644 --- a/.env +++ b/.env @@ -1,5 +1,2 @@ VITE_VERSION_LATEST=12.0.0 VITE_VERSION_NEXT=13.0.0 -VITE_ALGOLIA_READ_API_KEY=667630d6ab41eff82df15fdc6a55153f -VITE_ALGOLIA_APP_ID=1T1PRULLJT -VITE_ALGOLIA_INDEX_NAME=dev_2026 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ce4f66a11..2c8dac139 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -46,6 +46,38 @@ jobs: echo "SAFE_BRANCH=$SAFE_BRANCH" >> "$GITHUB_ENV" echo "VITE_DEPLOYMENT_URL=https://${SAFE_BRANCH}.rescript-lang.pages.dev" >> "$GITHUB_ENV" fi + - name: Set Algolia env + shell: bash + env: + ALGOLIA_APP_ID: ${{ vars.ALGOLIA_APP_ID }} + ALGOLIA_INDEX_BASENAME: ${{ vars.ALGOLIA_INDEX_BASENAME }} + ALGOLIA_SEARCH_API_KEY_DEV: ${{ vars.ALGOLIA_SEARCH_API_KEY_DEV }} + ALGOLIA_SEARCH_API_KEY_PROD: ${{ vars.ALGOLIA_SEARCH_API_KEY_PROD }} + ALGOLIA_ADMIN_API_KEY_DEV: ${{ secrets.ALGOLIA_ADMIN_API_KEY_DEV }} + ALGOLIA_ADMIN_API_KEY_PROD: ${{ secrets.ALGOLIA_ADMIN_API_KEY_PROD }} + run: | + if [[ "${{ github.event_name }}" == "push" && "${{ github.ref_name }}" == "master" ]]; then + INDEX_PREFIX="prod" + SEARCH_KEY="$ALGOLIA_SEARCH_API_KEY_PROD" + ADMIN_KEY="$ALGOLIA_ADMIN_API_KEY_PROD" + elif [[ "${{ github.event_name }}" == "workflow_dispatch" && "${{ inputs.environment }}" == "production" ]]; then + INDEX_PREFIX="prod" + SEARCH_KEY="$ALGOLIA_SEARCH_API_KEY_PROD" + ADMIN_KEY="$ALGOLIA_ADMIN_API_KEY_PROD" + else + INDEX_PREFIX="dev" + SEARCH_KEY="$ALGOLIA_SEARCH_API_KEY_DEV" + ADMIN_KEY="$ALGOLIA_ADMIN_API_KEY_DEV" + fi + + INDEX_NAME="${INDEX_PREFIX}_${ALGOLIA_INDEX_BASENAME}" + + echo "VITE_ALGOLIA_APP_ID=$ALGOLIA_APP_ID" >> "$GITHUB_ENV" + echo "VITE_ALGOLIA_INDEX_NAME=$INDEX_NAME" >> "$GITHUB_ENV" + echo "VITE_ALGOLIA_SEARCH_API_KEY=$SEARCH_KEY" >> "$GITHUB_ENV" + echo "ALGOLIA_APP_ID=$ALGOLIA_APP_ID" >> "$GITHUB_ENV" + echo "ALGOLIA_INDEX_NAME=$INDEX_NAME" >> "$GITHUB_ENV" + echo "ALGOLIA_ADMIN_API_KEY=$ADMIN_KEY" >> "$GITHUB_ENV" - name: Build run: yarn build env: diff --git a/__tests__/AlgoliaConfig_.test.res b/__tests__/AlgoliaConfig_.test.res new file mode 100644 index 000000000..617e7a524 --- /dev/null +++ b/__tests__/AlgoliaConfig_.test.res @@ -0,0 +1,41 @@ +open Vitest + +describe("publicConfigFrom", () => { + test("returns config when all public vars are present", async () => { + let result = AlgoliaConfig.publicConfigFrom( + ~appId=Some("app_123"), + ~indexName=Some("dev_rescript_lang"), + ~searchApiKey=Some("search_123"), + ) + + let expected: AlgoliaConfig.publicConfig = { + appId: "app_123", + indexName: "dev_rescript_lang", + searchApiKey: "search_123", + } + + expect(result)->toEqual(Some(expected)) + }) + + test("reports missing public vars in declaration order", async () => { + let result = AlgoliaConfig.missingPublicVars( + ~appId=None, + ~indexName=Some("dev_rescript_lang"), + ~searchApiKey=None, + ) + + expect(result)->toEqual(["VITE_ALGOLIA_APP_ID", "VITE_ALGOLIA_SEARCH_API_KEY"]) + }) +}) + +describe("publisherConfigFrom", () => { + test("reports missing publisher vars in declaration order", async () => { + let result = AlgoliaConfig.missingPublisherVars( + ~appId=Some("app_123"), + ~indexName=None, + ~adminApiKey=None, + ) + + expect(result)->toEqual(["ALGOLIA_INDEX_NAME", "ALGOLIA_ADMIN_API_KEY"]) + }) +}) diff --git a/__tests__/Search_.test.res b/__tests__/Search_.test.res index 12fcb1ce6..e6529d8db 100644 --- a/__tests__/Search_.test.res +++ b/__tests__/Search_.test.res @@ -424,3 +424,12 @@ describe("isChildHit", () => { ) }) }) + +test("renders disabled search copy when Algolia config is missing", async () => { + await viewport(1440, 500) + + let screen = await render() + + await element(await screen->getByText("Search unavailable"))->toBeVisible + await element(await screen->getByLabelText("Search unavailable for this build"))->toBeVisible +}) diff --git a/package.json b/package.json index 38c8014e3..5e43311a2 100644 --- a/package.json +++ b/package.json @@ -17,17 +17,18 @@ "build:search-index": "node --env-file-if-exists=.env --env-file-if-exists=.env.local _scripts/generate_search_index.mjs", "build:update-index": "yarn build:generate-llms && node _scripts/generate_feed.mjs > public/blog/feed.xml && yarn build:search-index", "build:vite": "react-router build", - "build": "yarn build:res && yarn build:scripts && yarn build:update-index && yarn build:vite", + "check:algolia-public-env": "node scripts/log_algolia_env_status.mjs", + "build": "yarn check:algolia-public-env && yarn build:res && yarn build:scripts && yarn build:update-index && yarn build:vite", "ci:format": "prettier . --check --experimental-cli", "ci:test": "yarn vitest --run --browser.headless", "clean:res": "rescript clean", - "convert-images": "auto-convert-images", + "convert-images": "auto-image-converter", "dev:res": "rescript watch", "dev:vite": "react-router dev --host", "dev:wrangler": "yarn wrangler pages dev build/client", "dev": "yarn prepare && yarn dev:res & yarn dev:vite & yarn dev:wrangler", "format": "prettier . --write --experimental-cli && rescript format", - "prepare": "yarn build:res && yarn build:scripts && yarn build:update-index", + "prepare": "yarn check:algolia-public-env && yarn build:res && yarn build:scripts && yarn build:update-index", "preview": "yarn build && static-server build/client", "reanalyze": "rescript-tools reanalyze -all-cmt .", "test": "node scripts/test-examples.mjs && node scripts/test-hrefs.mjs", diff --git a/scripts/__tests__/log_algolia_env_status.test.mjs b/scripts/__tests__/log_algolia_env_status.test.mjs new file mode 100644 index 000000000..cbb4fe82f --- /dev/null +++ b/scripts/__tests__/log_algolia_env_status.test.mjs @@ -0,0 +1,24 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + formatDisabledMessage, + getMissingPublicAlgoliaVars, +} from "../log_algolia_env_status.mjs"; + +test("reports missing public vars in declaration order", () => { + assert.deepEqual( + getMissingPublicAlgoliaVars({ + VITE_ALGOLIA_APP_ID: "", + VITE_ALGOLIA_INDEX_NAME: "dev_rescript_lang", + VITE_ALGOLIA_SEARCH_API_KEY: undefined, + }), + ["VITE_ALGOLIA_APP_ID", "VITE_ALGOLIA_SEARCH_API_KEY"], + ); +}); + +test("formats the disabled search warning", () => { + assert.equal( + formatDisabledMessage(["VITE_ALGOLIA_APP_ID"]), + "Algolia search disabled: missing VITE_ALGOLIA_APP_ID", + ); +}); diff --git a/scripts/generate_search_index.res b/scripts/generate_search_index.res index 617d5a21f..309986eb4 100644 --- a/scripts/generate_search_index.res +++ b/scripts/generate_search_index.res @@ -2,10 +2,11 @@ // Runs as a standalone Node script via: node --env-file-if-exists=.env --env-file-if-exists=.env.local _scripts/generate_search_index.mjs // // Required env vars: +// ALGOLIA_APP_ID -- Algolia application ID // ALGOLIA_ADMIN_API_KEY -- API key with addObject/deleteObject/editSettings ACLs // ALGOLIA_INDEX_NAME -- e.g. "rescript-lang-dev" or "rescript-lang" // -// If either is missing, the script logs a warning and exits 0 (graceful skip). +// If any are missing, the script logs a warning and exits 0 (graceful skip). let getEnv = (key: string): option => Node.Process.env @@ -74,9 +75,10 @@ let main = async () => { let appId = getEnv("ALGOLIA_APP_ID") let adminApiKey = getEnv("ALGOLIA_ADMIN_API_KEY") let indexName = getEnv("ALGOLIA_INDEX_NAME") + let publisherConfig = AlgoliaConfig.publisherConfigFrom(~appId, ~indexName, ~adminApiKey) - switch (appId, adminApiKey, indexName) { - | (Some(appId), Some(apiKey), Some(idx)) => { + switch publisherConfig { + | Some({appId, indexName, adminApiKey}) => { Console.log("[search-index] Building search index records...") let apiDir = resolveApiDir()->Option.getOr("markdown-pages/docs/api") @@ -176,11 +178,11 @@ let main = async () => { let jsonRecords = allRecords->Array.map(SearchIndex.toJson) // 4. Initialize Algolia client and upload - let client = Algolia.make(appId, apiKey) + let client = Algolia.make(appId, adminApiKey) - Console.log(`[search-index] Uploading to index "${idx}"...`) + Console.log(`[search-index] Uploading to index "${indexName}"...`) let _ = await client->Algolia.replaceAllObjects({ - indexName: idx, + indexName, objects: jsonRecords, batchSize: 1000, }) @@ -189,7 +191,7 @@ let main = async () => { // 5. Configure index settings Console.log("[search-index] Updating index settings...") let _ = await client->Algolia.setSettings({ - indexName: idx, + indexName, indexSettings: { searchableAttributes: [ "hierarchy.lvl0", @@ -213,10 +215,10 @@ let main = async () => { Console.log("[search-index] Done.") } - | (None, _, _) => Console.log("[search-index] ALGOLIA_APP_ID not set, skipping index upload.") - | (_, None, _) => - Console.log("[search-index] ALGOLIA_ADMIN_API_KEY not set, skipping index upload.") - | (_, _, None) => Console.log("[search-index] ALGOLIA_INDEX_NAME not set, skipping index upload.") + | None => + AlgoliaConfig.missingPublisherVars(~appId, ~indexName, ~adminApiKey)->Array.forEach(name => { + Console.log(`[search-index] ${name} not set, skipping index upload.`) + }) } } diff --git a/scripts/log_algolia_env_status.mjs b/scripts/log_algolia_env_status.mjs new file mode 100644 index 000000000..3631bba05 --- /dev/null +++ b/scripts/log_algolia_env_status.mjs @@ -0,0 +1,23 @@ +const PUBLIC_KEYS = [ + "VITE_ALGOLIA_APP_ID", + "VITE_ALGOLIA_INDEX_NAME", + "VITE_ALGOLIA_SEARCH_API_KEY", +]; + +export function getMissingPublicAlgoliaVars(env = process.env) { + return PUBLIC_KEYS.filter((key) => { + const value = env[key]; + return value == null || value === ""; + }); +} + +export function formatDisabledMessage(missing) { + return `Algolia search disabled: missing ${missing.join(", ")}`; +} + +if (import.meta.url === `file://${process.argv[1]}`) { + const missing = getMissingPublicAlgoliaVars(); + if (missing.length > 0) { + console.warn(formatDisabledMessage(missing)); + } +} diff --git a/src/PlaygroundLazy.res b/src/PlaygroundLazy.res index 3236eca87..c92211855 100644 --- a/src/PlaygroundLazy.res +++ b/src/PlaygroundLazy.res @@ -1 +1 @@ -let make = React.lazy_(() => import(Playground.make)) +let make = Playground.make diff --git a/src/bindings/Env.res b/src/bindings/Env.res index 174ec8da9..615215de5 100644 --- a/src/bindings/Env.res +++ b/src/bindings/Env.res @@ -11,6 +11,35 @@ let root_url = switch deployment_url { } // Algolia search configuration (read from .env via Vite) -external algolia_app_id: string = "import.meta.env.VITE_ALGOLIA_APP_ID" -external algolia_read_api_key: string = "import.meta.env.VITE_ALGOLIA_READ_API_KEY" -external algolia_index_name: string = "import.meta.env.VITE_ALGOLIA_INDEX_NAME" +external algoliaAppIdRaw: option = "import.meta.env.VITE_ALGOLIA_APP_ID" +external algoliaIndexNameRaw: option = "import.meta.env.VITE_ALGOLIA_INDEX_NAME" +external algoliaSearchApiKeyRaw: option = "import.meta.env.VITE_ALGOLIA_SEARCH_API_KEY" + +let algoliaMissingPublicVars = AlgoliaConfig.missingPublicVars( + ~appId=algoliaAppIdRaw, + ~indexName=algoliaIndexNameRaw, + ~searchApiKey=algoliaSearchApiKeyRaw, +) + +let algoliaPublicConfig = AlgoliaConfig.publicConfigFrom( + ~appId=algoliaAppIdRaw, + ~indexName=algoliaIndexNameRaw, + ~searchApiKey=algoliaSearchApiKeyRaw, +) + +let algolia_app_id = switch algoliaPublicConfig { +| Some(config) => config.appId +| None => "" +} + +let algolia_index_name = switch algoliaPublicConfig { +| Some(config) => config.indexName +| None => "" +} + +let algolia_search_api_key = switch algoliaPublicConfig { +| Some(config) => config.searchApiKey +| None => "" +} + +let algolia_read_api_key = algolia_search_api_key diff --git a/src/common/AlgoliaConfig.res b/src/common/AlgoliaConfig.res new file mode 100644 index 000000000..f499d9b9a --- /dev/null +++ b/src/common/AlgoliaConfig.res @@ -0,0 +1,69 @@ +type publicConfig = { + appId: string, + indexName: string, + searchApiKey: string, +} + +type publisherConfig = { + appId: string, + indexName: string, + adminApiKey: string, +} + +let isPresent = value => + switch value { + | Some(v) => v !== "" + | None => false + } + +let missingPublicVars = (~appId, ~indexName, ~searchApiKey): array => { + let missing = [] + if !isPresent(appId) { + missing->Array.push("VITE_ALGOLIA_APP_ID") + } + if !isPresent(indexName) { + missing->Array.push("VITE_ALGOLIA_INDEX_NAME") + } + if !isPresent(searchApiKey) { + missing->Array.push("VITE_ALGOLIA_SEARCH_API_KEY") + } + missing +} + +let publicConfigFrom = (~appId, ~indexName, ~searchApiKey): option => + switch (appId, indexName, searchApiKey) { + | (Some(appId), Some(indexName), Some(searchApiKey)) + if missingPublicVars( + ~appId=Some(appId), + ~indexName=Some(indexName), + ~searchApiKey=Some(searchApiKey), + )->Array.length === 0 => + Some({appId, indexName, searchApiKey}) + | _ => None + } + +let missingPublisherVars = (~appId, ~indexName, ~adminApiKey): array => { + let missing = [] + if !isPresent(appId) { + missing->Array.push("ALGOLIA_APP_ID") + } + if !isPresent(indexName) { + missing->Array.push("ALGOLIA_INDEX_NAME") + } + if !isPresent(adminApiKey) { + missing->Array.push("ALGOLIA_ADMIN_API_KEY") + } + missing +} + +let publisherConfigFrom = (~appId, ~indexName, ~adminApiKey): option => + switch (appId, indexName, adminApiKey) { + | (Some(appId), Some(indexName), Some(adminApiKey)) + if missingPublisherVars( + ~appId=Some(appId), + ~indexName=Some(indexName), + ~adminApiKey=Some(adminApiKey), + )->Array.length === 0 => + Some({appId, indexName, adminApiKey}) + | _ => None + } diff --git a/src/components/DocsonLazy.res b/src/components/DocsonLazy.res index 2a58089ef..e0ff453d5 100644 --- a/src/components/DocsonLazy.res +++ b/src/components/DocsonLazy.res @@ -1 +1 @@ -let make = React.lazy_(() => import(Docson.make)) +let make = Docson.make diff --git a/src/components/Search.res b/src/components/Search.res index 4da1eb36d..a5bd3d854 100644 --- a/src/components/Search.res +++ b/src/components/Search.res @@ -1,9 +1,8 @@ -let apiKey = Env.algolia_read_api_key -let indexName = Env.algolia_index_name -let appId = Env.algolia_app_id - type state = Active | Inactive +let unavailableText = "Search unavailable" +let unavailableLabel = "Search unavailable for this build" + let navigator: DocSearch.navigator = { navigate: ({itemUrl}) => { ReactRouter.navigate(itemUrl) @@ -94,6 +93,7 @@ let hitComponent = ({hit, children: _}: DocSearch.hitComponent): React.element = @react.component let make = () => { let (state, setState) = React.useState(_ => Inactive) + let algoliaConfig = Env.algoliaPublicConfig let handleCloseModal = () => { let () = switch WebAPI.Document.querySelector(document, ".DocSearch-Modal") { @@ -111,32 +111,36 @@ let make = () => { } React.useEffect(() => { - let isEditableTag = (el: WebAPI.DOMAPI.element) => - switch el.tagName { - | "TEXTAREA" | "SELECT" | "INPUT" => true - | _ => false + switch algoliaConfig { + | None => None + | Some(_) => + let isEditableTag = (el: WebAPI.DOMAPI.element) => + switch el.tagName { + | "TEXTAREA" | "SELECT" | "INPUT" => true + | _ => false + } + + let focusSearch = (e: WebAPI.UIEventsAPI.keyboardEvent) => { + switch document.activeElement { + | Value(el) + if el->isEditableTag || (Obj.magic(el): WebAPI.DOMAPI.htmlElement).isContentEditable => () + | _ => + setState(_ => Active) + WebAPI.KeyboardEvent.preventDefault(e) + } } - let focusSearch = (e: WebAPI.UIEventsAPI.keyboardEvent) => { - switch document.activeElement { - | Value(el) - if el->isEditableTag || (Obj.magic(el): WebAPI.DOMAPI.htmlElement).isContentEditable => () - | _ => - setState(_ => Active) - WebAPI.KeyboardEvent.preventDefault(e) + let handleGlobalKeyDown = (e: WebAPI.UIEventsAPI.keyboardEvent) => { + switch e.key { + | "/" => focusSearch(e) + | "k" if e.ctrlKey || e.metaKey => focusSearch(e) + | _ => () + } } + WebAPI.Window.addEventListener(window, Keydown, handleGlobalKeyDown) + Some(() => WebAPI.Window.removeEventListener(window, Keydown, handleGlobalKeyDown)) } - - let handleGlobalKeyDown = (e: WebAPI.UIEventsAPI.keyboardEvent) => { - switch e.key { - | "/" => focusSearch(e) - | "k" if e.ctrlKey || e.metaKey => focusSearch(e) - | _ => () - } - } - WebAPI.Window.addEventListener(window, Keydown, handleGlobalKeyDown) - Some(() => WebAPI.Window.removeEventListener(window, Keydown, handleGlobalKeyDown)) - }, [setState]) + }, [algoliaConfig]) let onClick = _ => { setState(_ => Active) @@ -146,35 +150,53 @@ let make = () => { handleCloseModal() }, [setState]) - <> + switch algoliaConfig { + | None => - {switch state { - | Active => - switch ReactDOM.querySelector("body") { - | Some(element) => - ReactDOM.createPortal( - Float.toInt} - searchParameters={distinct: 3, hitsPerPage: 20, attributesToSnippet: ["content:9999"]} - />, - element, - ) - | None => React.null - } - | Inactive => React.null - }} - + | Some({appId, indexName, searchApiKey}) => + <> + + {switch state { + | Active => + switch ReactDOM.querySelector("body") { + | Some(element) => + ReactDOM.createPortal( + Float.toInt} + searchParameters={ + distinct: 3, + hitsPerPage: 20, + attributesToSnippet: ["content:9999"], + } + />, + element, + ) + | None => React.null + } + | Inactive => React.null + }} + + } } From 910b0f84af93a627afd697d5e70db9d89aea103e Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Sat, 25 Apr 2026 12:05:33 -0400 Subject: [PATCH 14/32] restore lazy components --- src/components/DocsonLazy.res | 2 +- src/playground/PlaygroundLazy.res | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/DocsonLazy.res b/src/components/DocsonLazy.res index e0ff453d5..2a58089ef 100644 --- a/src/components/DocsonLazy.res +++ b/src/components/DocsonLazy.res @@ -1 +1 @@ -let make = Docson.make +let make = React.lazy_(() => import(Docson.make)) diff --git a/src/playground/PlaygroundLazy.res b/src/playground/PlaygroundLazy.res index c92211855..3236eca87 100644 --- a/src/playground/PlaygroundLazy.res +++ b/src/playground/PlaygroundLazy.res @@ -1 +1 @@ -let make = Playground.make +let make = React.lazy_(() => import(Playground.make)) From f1d7fbc41eca7f2ad3a64a5eb5ca6bec2e819313 Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Sat, 25 Apr 2026 12:26:35 -0400 Subject: [PATCH 15/32] simplify tests --- __tests__/AlgoliaConfig_.test.res | 58 +-- __tests__/SearchIndex_.test.res | 830 +++++++++++++++--------------- __tests__/Search_.test.res | 585 +++++++-------------- __tests__/Url_.test.res | 115 ++--- src/bindings/Vitest.res | 3 - 5 files changed, 682 insertions(+), 909 deletions(-) diff --git a/__tests__/AlgoliaConfig_.test.res b/__tests__/AlgoliaConfig_.test.res index 617e7a524..8a826bb22 100644 --- a/__tests__/AlgoliaConfig_.test.res +++ b/__tests__/AlgoliaConfig_.test.res @@ -1,41 +1,37 @@ open Vitest -describe("publicConfigFrom", () => { - test("returns config when all public vars are present", async () => { - let result = AlgoliaConfig.publicConfigFrom( - ~appId=Some("app_123"), - ~indexName=Some("dev_rescript_lang"), - ~searchApiKey=Some("search_123"), - ) +test("publicConfigFrom returns config when all public vars are present", async () => { + let result = AlgoliaConfig.publicConfigFrom( + ~appId=Some("app_123"), + ~indexName=Some("dev_rescript_lang"), + ~searchApiKey=Some("search_123"), + ) - let expected: AlgoliaConfig.publicConfig = { - appId: "app_123", - indexName: "dev_rescript_lang", - searchApiKey: "search_123", - } + let expected: AlgoliaConfig.publicConfig = { + appId: "app_123", + indexName: "dev_rescript_lang", + searchApiKey: "search_123", + } - expect(result)->toEqual(Some(expected)) - }) + expect(result)->toEqual(Some(expected)) +}) - test("reports missing public vars in declaration order", async () => { - let result = AlgoliaConfig.missingPublicVars( - ~appId=None, - ~indexName=Some("dev_rescript_lang"), - ~searchApiKey=None, - ) +test("publicConfigFrom reports missing public vars in declaration order", async () => { + let result = AlgoliaConfig.missingPublicVars( + ~appId=None, + ~indexName=Some("dev_rescript_lang"), + ~searchApiKey=None, + ) - expect(result)->toEqual(["VITE_ALGOLIA_APP_ID", "VITE_ALGOLIA_SEARCH_API_KEY"]) - }) + expect(result)->toEqual(["VITE_ALGOLIA_APP_ID", "VITE_ALGOLIA_SEARCH_API_KEY"]) }) -describe("publisherConfigFrom", () => { - test("reports missing publisher vars in declaration order", async () => { - let result = AlgoliaConfig.missingPublisherVars( - ~appId=Some("app_123"), - ~indexName=None, - ~adminApiKey=None, - ) +test("publisherConfigFrom reports missing publisher vars in declaration order", async () => { + let result = AlgoliaConfig.missingPublisherVars( + ~appId=Some("app_123"), + ~indexName=None, + ~adminApiKey=None, + ) - expect(result)->toEqual(["ALGOLIA_INDEX_NAME", "ALGOLIA_ADMIN_API_KEY"]) - }) + expect(result)->toEqual(["ALGOLIA_INDEX_NAME", "ALGOLIA_ADMIN_API_KEY"]) }) diff --git a/__tests__/SearchIndex_.test.res b/__tests__/SearchIndex_.test.res index cb394f118..8cd765ba5 100644 --- a/__tests__/SearchIndex_.test.res +++ b/__tests__/SearchIndex_.test.res @@ -4,538 +4,512 @@ open Vitest // maxContentLength // --------------------------------------------------------------------------- -describe("maxContentLength", () => { - test("is 500", async () => { - expect(SearchIndex.maxContentLength)->toBe(500) - }) +test("maxContentLength is 500", async () => { + expect(SearchIndex.maxContentLength)->toBe(500) }) // --------------------------------------------------------------------------- // truncate // --------------------------------------------------------------------------- -describe("truncate", () => { - test("returns string as-is when shorter than maxLen", async () => { - expect(SearchIndex.truncate("hello", ~maxLen=10))->toBe("hello") - }) +test("truncate returns string as-is when shorter than maxLen", async () => { + expect(SearchIndex.truncate("hello", ~maxLen=10))->toBe("hello") +}) - test("returns string as-is when exactly maxLen", async () => { - expect(SearchIndex.truncate("hello", ~maxLen=5))->toBe("hello") - }) +test("truncate returns string as-is when exactly maxLen", async () => { + expect(SearchIndex.truncate("hello", ~maxLen=5))->toBe("hello") +}) - test("truncates and adds ellipsis when longer than maxLen", async () => { - expect(SearchIndex.truncate("hello world", ~maxLen=5))->toBe("hello...") - }) +test("truncate truncates and adds ellipsis when longer than maxLen", async () => { + expect(SearchIndex.truncate("hello world", ~maxLen=5))->toBe("hello...") +}) - test("handles empty string", async () => { - expect(SearchIndex.truncate("", ~maxLen=5))->toBe("") - }) +test("truncate handles empty string", async () => { + expect(SearchIndex.truncate("", ~maxLen=5))->toBe("") +}) - test("truncates to maxLen=0 with ellipsis", async () => { - expect(SearchIndex.truncate("abc", ~maxLen=0))->toBe("...") - }) +test("truncate handles maxLen=0 with ellipsis", async () => { + expect(SearchIndex.truncate("abc", ~maxLen=0))->toBe("...") +}) - test("truncates to single character with ellipsis", async () => { - expect(SearchIndex.truncate("abcdef", ~maxLen=1))->toBe("a...") - }) +test("truncate truncates to single character with ellipsis", async () => { + expect(SearchIndex.truncate("abcdef", ~maxLen=1))->toBe("a...") }) // --------------------------------------------------------------------------- // slugify // --------------------------------------------------------------------------- -describe("slugify", () => { - test("lowercases text", async () => { - expect(SearchIndex.slugify("Hello World"))->toBe("hello-world") - }) +test("slugify lowercases text", async () => { + expect(SearchIndex.slugify("Hello World"))->toBe("hello-world") +}) - test("replaces spaces with hyphens", async () => { - expect(SearchIndex.slugify("foo bar baz"))->toBe("foo-bar-baz") - }) +test("slugify replaces spaces with hyphens", async () => { + expect(SearchIndex.slugify("foo bar baz"))->toBe("foo-bar-baz") +}) - test("removes non-alphanumeric characters", async () => { - expect(SearchIndex.slugify("Hello, World!"))->toBe("hello-world") - }) +test("slugify removes non-alphanumeric characters", async () => { + expect(SearchIndex.slugify("Hello, World!"))->toBe("hello-world") +}) - test("collapses multiple spaces into single hyphen", async () => { - expect(SearchIndex.slugify("foo bar"))->toBe("foo-bar") - }) +test("slugify collapses multiple spaces into a single hyphen", async () => { + expect(SearchIndex.slugify("foo bar"))->toBe("foo-bar") +}) - test("handles empty string", async () => { - expect(SearchIndex.slugify(""))->toBe("") - }) +test("slugify handles empty string", async () => { + expect(SearchIndex.slugify(""))->toBe("") +}) - test("preserves numbers", async () => { - expect(SearchIndex.slugify("Section 42"))->toBe("section-42") - }) +test("slugify preserves numbers", async () => { + expect(SearchIndex.slugify("Section 42"))->toBe("section-42") +}) - test("removes special characters like parentheses and dots", async () => { - expect(SearchIndex.slugify("Array.map()"))->toBe("arraymap") - }) +test("slugify removes special characters like parentheses and dots", async () => { + expect(SearchIndex.slugify("Array.map()"))->toBe("arraymap") +}) - test("handles already-slugified text", async () => { - expect(SearchIndex.slugify("already-slugified"))->toBe("already-slugified") - }) +test("slugify handles already-slugified text", async () => { + expect(SearchIndex.slugify("already-slugified"))->toBe("already-slugified") }) // --------------------------------------------------------------------------- // stripMdxTags // --------------------------------------------------------------------------- -describe("stripMdxTags", () => { - test("removes CodeTab blocks", async () => { - let input = "before\n\nsome code\n\nafter" - expect(SearchIndex.stripMdxTags(input))->toBe("before\nafter") - }) +test("stripMdxTags removes CodeTab blocks", async () => { + let input = "before\n\nsome code\n\nafter" + expect(SearchIndex.stripMdxTags(input))->toBe("before\nafter") +}) - test("removes HTML tags", async () => { - expect(SearchIndex.stripMdxTags("
hello
"))->toBe("hello") - }) +test("stripMdxTags removes HTML tags", async () => { + expect(SearchIndex.stripMdxTags("
hello
"))->toBe("hello") +}) - test("removes fenced code blocks", async () => { - let input = "before\n```rescript\nlet x = 1\n```\nafter" - expect(SearchIndex.stripMdxTags(input))->toBe("before\nafter") - }) +test("stripMdxTags removes fenced code blocks", async () => { + let input = "before\n```rescript\nlet x = 1\n```\nafter" + expect(SearchIndex.stripMdxTags(input))->toBe("before\nafter") +}) - test("strips inline code backticks", async () => { - expect(SearchIndex.stripMdxTags("use `Array.map` here"))->toBe("use Array.map here") - }) +test("stripMdxTags strips inline code backticks", async () => { + expect(SearchIndex.stripMdxTags("use `Array.map` here"))->toBe("use Array.map here") +}) - test("strips bold markers", async () => { - expect(SearchIndex.stripMdxTags("this is **bold** text"))->toBe("this is bold text") - }) +test("stripMdxTags strips bold markers", async () => { + expect(SearchIndex.stripMdxTags("this is **bold** text"))->toBe("this is bold text") +}) - test("strips italic markers", async () => { - expect(SearchIndex.stripMdxTags("this is *italic* text"))->toBe("this is italic text") - }) +test("stripMdxTags strips italic markers", async () => { + expect(SearchIndex.stripMdxTags("this is *italic* text"))->toBe("this is italic text") +}) - test("strips markdown links keeping link text", async () => { - expect(SearchIndex.stripMdxTags("click [here](https://example.com) now"))->toBe( - "click here now", - ) - }) +test("stripMdxTags strips markdown links while keeping link text", async () => { + expect(SearchIndex.stripMdxTags("click [here](https://example.com) now"))->toBe("click here now") +}) - test("removes heading markers", async () => { - expect(SearchIndex.stripMdxTags("## My Heading"))->toBe("My Heading") - }) +test("stripMdxTags removes heading markers", async () => { + expect(SearchIndex.stripMdxTags("## My Heading"))->toBe("My Heading") +}) - test("removes h1 through h6 markers", async () => { - let input = "# H1\n## H2\n### H3\n#### H4\n##### H5\n###### H6" - expect(SearchIndex.stripMdxTags(input))->toBe("H1\nH2\nH3\nH4\nH5\nH6") - }) +test("stripMdxTags removes h1 through h6 markers", async () => { + let input = "# H1\n## H2\n### H3\n#### H4\n##### H5\n###### H6" + expect(SearchIndex.stripMdxTags(input))->toBe("H1\nH2\nH3\nH4\nH5\nH6") +}) - test("collapses multiple newlines to single", async () => { - expect(SearchIndex.stripMdxTags("a\n\n\nb"))->toBe("a\nb") - }) +test("stripMdxTags collapses multiple newlines to a single newline", async () => { + expect(SearchIndex.stripMdxTags("a\n\n\nb"))->toBe("a\nb") +}) - test("handles empty string", async () => { - expect(SearchIndex.stripMdxTags(""))->toBe("") - }) +test("stripMdxTags handles empty string", async () => { + expect(SearchIndex.stripMdxTags(""))->toBe("") +}) - test("handles combined markdown formatting", async () => { - let input = "Use **`Array.map`** to [transform](http://x.com) items." - let result = SearchIndex.stripMdxTags(input) - expect(result)->toBe("Use Array.map to transform items.") - }) +test("stripMdxTags handles combined markdown formatting", async () => { + let input = "Use **`Array.map`** to [transform](http://x.com) items." + let result = SearchIndex.stripMdxTags(input) + expect(result)->toBe("Use Array.map to transform items.") }) // --------------------------------------------------------------------------- // cleanDocstring // --------------------------------------------------------------------------- -describe("cleanDocstring", () => { - test("returns simple text as-is", async () => { - expect(SearchIndex.cleanDocstring("Simple description"))->toBe("Simple description") - }) +test("cleanDocstring returns simple text as-is", async () => { + expect(SearchIndex.cleanDocstring("Simple description"))->toBe("Simple description") +}) - test("takes content before first ## heading", async () => { - let input = "Intro text\n## Details\nMore info" - expect(SearchIndex.cleanDocstring(input))->toBe("Intro text") - }) +test("cleanDocstring takes content before first ## heading", async () => { + let input = "Intro text\n## Details\nMore info" + expect(SearchIndex.cleanDocstring(input))->toBe("Intro text") +}) - test("takes content before first code block", async () => { - let input = "Intro text\n```rescript\nlet x = 1\n```" - expect(SearchIndex.cleanDocstring(input))->toBe("Intro text") - }) +test("cleanDocstring takes content before first code block", async () => { + let input = "Intro text\n```rescript\nlet x = 1\n```" + expect(SearchIndex.cleanDocstring(input))->toBe("Intro text") +}) - test("strips inline code backticks", async () => { - expect(SearchIndex.cleanDocstring("Returns `true` or `false`"))->toBe("Returns true or false") - }) +test("cleanDocstring strips inline code backticks", async () => { + expect(SearchIndex.cleanDocstring("Returns `true` or `false`"))->toBe("Returns true or false") +}) - test("strips bold formatting", async () => { - expect(SearchIndex.cleanDocstring("This is **important**"))->toBe("This is important") - }) +test("cleanDocstring strips bold formatting", async () => { + expect(SearchIndex.cleanDocstring("This is **important**"))->toBe("This is important") +}) - test("strips italic formatting", async () => { - expect(SearchIndex.cleanDocstring("This is *emphasized*"))->toBe("This is emphasized") - }) +test("cleanDocstring strips italic formatting", async () => { + expect(SearchIndex.cleanDocstring("This is *emphasized*"))->toBe("This is emphasized") +}) - test("strips markdown links", async () => { - expect(SearchIndex.cleanDocstring("See [docs](http://example.com)"))->toBe("See docs") - }) +test("cleanDocstring strips markdown links", async () => { + expect(SearchIndex.cleanDocstring("See [docs](http://example.com)"))->toBe("See docs") +}) - test("collapses multiple newlines to spaces", async () => { - let input = "line one\n\nline two\n\nline three" - expect(SearchIndex.cleanDocstring(input))->toBe("line one line two line three") - }) +test("cleanDocstring collapses multiple newlines to spaces", async () => { + let input = "line one\n\nline two\n\nline three" + expect(SearchIndex.cleanDocstring(input))->toBe("line one line two line three") +}) - test("replaces single newlines with spaces", async () => { - let input = "line one\nline two" - expect(SearchIndex.cleanDocstring(input))->toBe("line one line two") - }) +test("cleanDocstring replaces single newlines with spaces", async () => { + let input = "line one\nline two" + expect(SearchIndex.cleanDocstring(input))->toBe("line one line two") +}) - test("handles empty string", async () => { - expect(SearchIndex.cleanDocstring(""))->toBe("") - }) +test("cleanDocstring handles empty string", async () => { + expect(SearchIndex.cleanDocstring(""))->toBe("") +}) - test("heading takes priority over code block", async () => { - let input = "Intro\n## Section\nText\n```\ncode\n```" - expect(SearchIndex.cleanDocstring(input))->toBe("Intro") - }) +test("cleanDocstring lets headings take priority over code blocks", async () => { + let input = "Intro\n## Section\nText\n```\ncode\n```" + expect(SearchIndex.cleanDocstring(input))->toBe("Intro") }) // --------------------------------------------------------------------------- // extractIntro // --------------------------------------------------------------------------- -describe("extractIntro", () => { - test("extracts text before first ## heading", async () => { - let input = "Some intro text.\n## First Section\nDetails here." - let result = SearchIndex.extractIntro(input) - expect(result)->toBe("Some intro text.") - }) +test("extractIntro extracts text before first ## heading", async () => { + let input = "Some intro text.\n## First Section\nDetails here." + let result = SearchIndex.extractIntro(input) + expect(result)->toBe("Some intro text.") +}) - test("removes H1 heading at start", async () => { - let input = "# Page Title\nIntro paragraph.\n## Section" - let result = SearchIndex.extractIntro(input) - expect(result)->toBe("Intro paragraph.") - }) +test("extractIntro removes an H1 heading at the start", async () => { + let input = "# Page Title\nIntro paragraph.\n## Section" + let result = SearchIndex.extractIntro(input) + expect(result)->toBe("Intro paragraph.") +}) - test("returns stripped content when no headings", async () => { - let input = "Just some plain text content." - expect(SearchIndex.extractIntro(input))->toBe("Just some plain text content.") - }) +test("extractIntro returns stripped content when there are no headings", async () => { + let input = "Just some plain text content." + expect(SearchIndex.extractIntro(input))->toBe("Just some plain text content.") +}) - test("handles empty string", async () => { - expect(SearchIndex.extractIntro(""))->toBe("") - }) +test("extractIntro handles empty string", async () => { + expect(SearchIndex.extractIntro(""))->toBe("") +}) - test("strips MDX tags from intro", async () => { - let input = "Use **bold** and `code`.\n## Section" - expect(SearchIndex.extractIntro(input))->toBe("Use bold and code.") - }) +test("extractIntro strips MDX tags from the intro", async () => { + let input = "Use **bold** and `code`.\n## Section" + expect(SearchIndex.extractIntro(input))->toBe("Use bold and code.") +}) - test("removes H1 but preserves rest of content", async () => { - let input = "# Title\nFirst paragraph.\nSecond paragraph." - expect(SearchIndex.extractIntro(input))->toBe("First paragraph.\nSecond paragraph.") - }) +test("extractIntro removes H1 but preserves the rest of the content", async () => { + let input = "# Title\nFirst paragraph.\nSecond paragraph." + expect(SearchIndex.extractIntro(input))->toBe("First paragraph.\nSecond paragraph.") }) // --------------------------------------------------------------------------- // extractHeadings // --------------------------------------------------------------------------- -describe("extractHeadings", () => { - test("extracts h2 headings", async () => { - let input = "Intro\n## First\nContent one.\n## Second\nContent two." - let headings = SearchIndex.extractHeadings(input) - expect(Array.length(headings))->toBe(2) - expect(headings[0]->Option.map(h => h.level))->toEqual(Some(2)) - expect(headings[0]->Option.map(h => h.text))->toEqual(Some("First")) - expect(headings[1]->Option.map(h => h.text))->toEqual(Some("Second")) - }) - - test("extracts h3 headings", async () => { - let input = "## Parent\n### Child\nSub content." - let headings = SearchIndex.extractHeadings(input) - expect(headings[0]->Option.map(h => h.level))->toEqual(Some(2)) - expect(headings[1]->Option.map(h => h.level))->toEqual(Some(3)) - expect(headings[1]->Option.map(h => h.text))->toEqual(Some("Child")) - }) - - test("does not extract h1 headings", async () => { - let input = "# Title\nSome text\n## Real Heading\nContent." - let headings = SearchIndex.extractHeadings(input) - expect(Array.length(headings))->toBe(1) - expect(headings[0]->Option.map(h => h.text))->toEqual(Some("Real Heading")) - }) - - test("returns empty array when no headings", async () => { - let input = "Just plain text with no headings." - let headings = SearchIndex.extractHeadings(input) - expect(Array.length(headings))->toBe(0) - }) - - test("includes section content between headings", async () => { - let input = "## Heading\nThis is the content of the section." - let headings = SearchIndex.extractHeadings(input) - expect(headings[0]->Option.map(h => h.content))->toEqual( - Some("This is the content of the section."), - ) - }) - - test("strips MDX tags from section content", async () => { - let input = "## Heading\nUse **bold** and `code` here." - let headings = SearchIndex.extractHeadings(input) - expect(headings[0]->Option.map(h => h.content))->toEqual(Some("Use bold and code here.")) - }) - - test("truncates section content to maxContentLength", async () => { - let longContent = String.repeat("a", 600) - let input = "## Heading\n" ++ longContent - let headings = SearchIndex.extractHeadings(input) - let contentLen = headings[0]->Option.map(h => String.length(h.content))->Option.getOr(0) - // 500 chars + "..." = 503 - expect(contentLen)->toBe(503) - }) - - test("handles multiple heading levels", async () => { - let input = "## H2\nA\n### H3\nB\n#### H4\nC\n##### H5\nD\n###### H6\nE" - let headings = SearchIndex.extractHeadings(input) - expect(Array.length(headings))->toBe(5) - expect(headings[0]->Option.map(h => h.level))->toEqual(Some(2)) - expect(headings[1]->Option.map(h => h.level))->toEqual(Some(3)) - expect(headings[2]->Option.map(h => h.level))->toEqual(Some(4)) - expect(headings[3]->Option.map(h => h.level))->toEqual(Some(5)) - expect(headings[4]->Option.map(h => h.level))->toEqual(Some(6)) - }) +test("extractHeadings extracts h2 headings", async () => { + let input = "Intro\n## First\nContent one.\n## Second\nContent two." + let headings = SearchIndex.extractHeadings(input) + expect(Array.length(headings))->toBe(2) + expect(headings[0]->Option.map(h => h.level))->toEqual(Some(2)) + expect(headings[0]->Option.map(h => h.text))->toEqual(Some("First")) + expect(headings[1]->Option.map(h => h.text))->toEqual(Some("Second")) +}) + +test("extractHeadings extracts h3 headings", async () => { + let input = "## Parent\n### Child\nSub content." + let headings = SearchIndex.extractHeadings(input) + expect(headings[0]->Option.map(h => h.level))->toEqual(Some(2)) + expect(headings[1]->Option.map(h => h.level))->toEqual(Some(3)) + expect(headings[1]->Option.map(h => h.text))->toEqual(Some("Child")) +}) + +test("extractHeadings does not extract h1 headings", async () => { + let input = "# Title\nSome text\n## Real Heading\nContent." + let headings = SearchIndex.extractHeadings(input) + expect(Array.length(headings))->toBe(1) + expect(headings[0]->Option.map(h => h.text))->toEqual(Some("Real Heading")) +}) + +test("extractHeadings returns an empty array when there are no headings", async () => { + let input = "Just plain text with no headings." + let headings = SearchIndex.extractHeadings(input) + expect(Array.length(headings))->toBe(0) +}) + +test("extractHeadings includes section content between headings", async () => { + let input = "## Heading\nThis is the content of the section." + let headings = SearchIndex.extractHeadings(input) + expect(headings[0]->Option.map(h => h.content))->toEqual( + Some("This is the content of the section."), + ) +}) + +test("extractHeadings strips MDX tags from section content", async () => { + let input = "## Heading\nUse **bold** and `code` here." + let headings = SearchIndex.extractHeadings(input) + expect(headings[0]->Option.map(h => h.content))->toEqual(Some("Use bold and code here.")) +}) + +test("extractHeadings truncates section content to maxContentLength", async () => { + let longContent = String.repeat("a", 600) + let input = "## Heading\n" ++ longContent + let headings = SearchIndex.extractHeadings(input) + let contentLen = headings[0]->Option.map(h => String.length(h.content))->Option.getOr(0) + // 500 chars + "..." = 503 + expect(contentLen)->toBe(503) +}) + +test("extractHeadings handles multiple heading levels", async () => { + let input = "## H2\nA\n### H3\nB\n#### H4\nC\n##### H5\nD\n###### H6\nE" + let headings = SearchIndex.extractHeadings(input) + expect(Array.length(headings))->toBe(5) + expect(headings[0]->Option.map(h => h.level))->toEqual(Some(2)) + expect(headings[1]->Option.map(h => h.level))->toEqual(Some(3)) + expect(headings[2]->Option.map(h => h.level))->toEqual(Some(4)) + expect(headings[3]->Option.map(h => h.level))->toEqual(Some(5)) + expect(headings[4]->Option.map(h => h.level))->toEqual(Some(6)) }) // --------------------------------------------------------------------------- // makeHierarchy // --------------------------------------------------------------------------- -describe("makeHierarchy", () => { - test("creates hierarchy with only required fields", async () => { - let h = SearchIndex.makeHierarchy(~lvl0="Docs", ~lvl1="Overview", ()) - expect(h.lvl0)->toBe("Docs") - expect(h.lvl1)->toBe("Overview") - expect(h.lvl2)->toEqual(None) - expect(h.lvl3)->toEqual(None) - expect(h.lvl4)->toEqual(None) - expect(h.lvl5)->toEqual(None) - expect(h.lvl6)->toEqual(None) - }) - - test("creates hierarchy with all optional fields", async () => { - let h = SearchIndex.makeHierarchy( - ~lvl0="Docs", - ~lvl1="Guide", - ~lvl2="Chapter", - ~lvl3="Section", - ~lvl4="Sub A", - ~lvl5="Sub B", - ~lvl6="Sub C", - (), - ) - expect(h.lvl0)->toBe("Docs") - expect(h.lvl1)->toBe("Guide") - expect(h.lvl2)->toEqual(Some("Chapter")) - expect(h.lvl3)->toEqual(Some("Section")) - expect(h.lvl4)->toEqual(Some("Sub A")) - expect(h.lvl5)->toEqual(Some("Sub B")) - expect(h.lvl6)->toEqual(Some("Sub C")) - }) - - test("creates hierarchy with partial optional fields", async () => { - let h = SearchIndex.makeHierarchy(~lvl0="API", ~lvl1="Array", ~lvl2="map", ()) - expect(h.lvl2)->toEqual(Some("map")) - expect(h.lvl3)->toEqual(None) - }) +test("makeHierarchy creates a hierarchy with only required fields", async () => { + let h = SearchIndex.makeHierarchy(~lvl0="Docs", ~lvl1="Overview", ()) + expect(h.lvl0)->toBe("Docs") + expect(h.lvl1)->toBe("Overview") + expect(h.lvl2)->toEqual(None) + expect(h.lvl3)->toEqual(None) + expect(h.lvl4)->toEqual(None) + expect(h.lvl5)->toEqual(None) + expect(h.lvl6)->toEqual(None) +}) + +test("makeHierarchy creates a hierarchy with all optional fields", async () => { + let h = SearchIndex.makeHierarchy( + ~lvl0="Docs", + ~lvl1="Guide", + ~lvl2="Chapter", + ~lvl3="Section", + ~lvl4="Sub A", + ~lvl5="Sub B", + ~lvl6="Sub C", + (), + ) + expect(h.lvl0)->toBe("Docs") + expect(h.lvl1)->toBe("Guide") + expect(h.lvl2)->toEqual(Some("Chapter")) + expect(h.lvl3)->toEqual(Some("Section")) + expect(h.lvl4)->toEqual(Some("Sub A")) + expect(h.lvl5)->toEqual(Some("Sub B")) + expect(h.lvl6)->toEqual(Some("Sub C")) +}) + +test("makeHierarchy creates a hierarchy with partial optional fields", async () => { + let h = SearchIndex.makeHierarchy(~lvl0="API", ~lvl1="Array", ~lvl2="map", ()) + expect(h.lvl2)->toEqual(Some("map")) + expect(h.lvl3)->toEqual(None) }) // --------------------------------------------------------------------------- // optionToJson // --------------------------------------------------------------------------- -describe("optionToJson", () => { - test("converts Some to JSON string", async () => { - expect(SearchIndex.optionToJson(Some("hello")))->toEqual(JSON.String("hello")) - }) +test("optionToJson converts Some to a JSON string", async () => { + expect(SearchIndex.optionToJson(Some("hello")))->toEqual(JSON.String("hello")) +}) - test("converts None to JSON null", async () => { - expect(SearchIndex.optionToJson(None))->toEqual(JSON.Null) - }) +test("optionToJson converts None to JSON null", async () => { + expect(SearchIndex.optionToJson(None))->toEqual(JSON.Null) +}) - test("converts Some empty string to JSON string", async () => { - expect(SearchIndex.optionToJson(Some("")))->toEqual(JSON.String("")) - }) +test("optionToJson converts Some empty string to a JSON string", async () => { + expect(SearchIndex.optionToJson(Some("")))->toEqual(JSON.String("")) }) // --------------------------------------------------------------------------- // hierarchyToJson // --------------------------------------------------------------------------- -describe("hierarchyToJson", () => { - test("serializes hierarchy with only required fields", async () => { - let h = SearchIndex.makeHierarchy(~lvl0="Docs", ~lvl1="Page", ()) - let json = SearchIndex.hierarchyToJson(h) - let expected = { - let d = Dict.make() - d->Dict.set("lvl0", JSON.String("Docs")) - d->Dict.set("lvl1", JSON.String("Page")) - d->Dict.set("lvl2", JSON.Null) - d->Dict.set("lvl3", JSON.Null) - d->Dict.set("lvl4", JSON.Null) - d->Dict.set("lvl5", JSON.Null) - d->Dict.set("lvl6", JSON.Null) - JSON.Object(d) - } - expect(json)->toEqual(expected) - }) - - test("serializes hierarchy with optional fields as JSON strings", async () => { - let h = SearchIndex.makeHierarchy(~lvl0="API", ~lvl1="Array", ~lvl2="map", ()) - let json = SearchIndex.hierarchyToJson(h) - let expected = { - let d = Dict.make() - d->Dict.set("lvl0", JSON.String("API")) - d->Dict.set("lvl1", JSON.String("Array")) - d->Dict.set("lvl2", JSON.String("map")) - d->Dict.set("lvl3", JSON.Null) - d->Dict.set("lvl4", JSON.Null) - d->Dict.set("lvl5", JSON.Null) - d->Dict.set("lvl6", JSON.Null) - JSON.Object(d) - } - expect(json)->toEqual(expected) - }) +test("hierarchyToJson serializes a hierarchy with only required fields", async () => { + let h = SearchIndex.makeHierarchy(~lvl0="Docs", ~lvl1="Page", ()) + let json = SearchIndex.hierarchyToJson(h) + let expected = { + let d = Dict.make() + d->Dict.set("lvl0", JSON.String("Docs")) + d->Dict.set("lvl1", JSON.String("Page")) + d->Dict.set("lvl2", JSON.Null) + d->Dict.set("lvl3", JSON.Null) + d->Dict.set("lvl4", JSON.Null) + d->Dict.set("lvl5", JSON.Null) + d->Dict.set("lvl6", JSON.Null) + JSON.Object(d) + } + expect(json)->toEqual(expected) +}) + +test("hierarchyToJson serializes optional fields as JSON strings", async () => { + let h = SearchIndex.makeHierarchy(~lvl0="API", ~lvl1="Array", ~lvl2="map", ()) + let json = SearchIndex.hierarchyToJson(h) + let expected = { + let d = Dict.make() + d->Dict.set("lvl0", JSON.String("API")) + d->Dict.set("lvl1", JSON.String("Array")) + d->Dict.set("lvl2", JSON.String("map")) + d->Dict.set("lvl3", JSON.Null) + d->Dict.set("lvl4", JSON.Null) + d->Dict.set("lvl5", JSON.Null) + d->Dict.set("lvl6", JSON.Null) + JSON.Object(d) + } + expect(json)->toEqual(expected) }) // --------------------------------------------------------------------------- // weightToJson // --------------------------------------------------------------------------- -describe("weightToJson", () => { - test("serializes weight to JSON object with number values", async () => { - let w: SearchIndex.weight = {pageRank: 10, level: 80, position: 3} - let json = SearchIndex.weightToJson(w) - let expected = { - let d = Dict.make() - d->Dict.set("pageRank", JSON.Number(10.0)) - d->Dict.set("level", JSON.Number(80.0)) - d->Dict.set("position", JSON.Number(3.0)) - JSON.Object(d) - } - expect(json)->toEqual(expected) - }) - - test("serializes zero values correctly", async () => { - let w: SearchIndex.weight = {pageRank: 0, level: 0, position: 0} - let json = SearchIndex.weightToJson(w) - let expected = { - let d = Dict.make() - d->Dict.set("pageRank", JSON.Number(0.0)) - d->Dict.set("level", JSON.Number(0.0)) - d->Dict.set("position", JSON.Number(0.0)) - JSON.Object(d) - } - expect(json)->toEqual(expected) - }) +test("weightToJson serializes weight to a JSON object with number values", async () => { + let w: SearchIndex.weight = {pageRank: 10, level: 80, position: 3} + let json = SearchIndex.weightToJson(w) + let expected = { + let d = Dict.make() + d->Dict.set("pageRank", JSON.Number(10.0)) + d->Dict.set("level", JSON.Number(80.0)) + d->Dict.set("position", JSON.Number(3.0)) + JSON.Object(d) + } + expect(json)->toEqual(expected) +}) + +test("weightToJson serializes zero values correctly", async () => { + let w: SearchIndex.weight = {pageRank: 0, level: 0, position: 0} + let json = SearchIndex.weightToJson(w) + let expected = { + let d = Dict.make() + d->Dict.set("pageRank", JSON.Number(0.0)) + d->Dict.set("level", JSON.Number(0.0)) + d->Dict.set("position", JSON.Number(0.0)) + JSON.Object(d) + } + expect(json)->toEqual(expected) }) // --------------------------------------------------------------------------- // toJson // --------------------------------------------------------------------------- -describe("toJson", () => { - test("serializes a full record with all fields", async () => { - let r: SearchIndex.record = { - objectID: "docs/overview", - url: "/docs/overview#intro", - url_without_anchor: "/docs/overview", - anchor: Some("intro"), - content: Some("Introduction text"), - type_: "lvl2", - hierarchy: SearchIndex.makeHierarchy(~lvl0="Docs", ~lvl1="Overview", ~lvl2="Intro", ()), - weight: {pageRank: 5, level: 80, position: 1}, - } - let json = SearchIndex.toJson(r) - - let expected = { - let d = Dict.make() - d->Dict.set("objectID", JSON.String("docs/overview")) - d->Dict.set("url", JSON.String("/docs/overview#intro")) - d->Dict.set("url_without_anchor", JSON.String("/docs/overview")) - d->Dict.set("anchor", JSON.String("intro")) - d->Dict.set("content", JSON.String("Introduction text")) - d->Dict.set("type", JSON.String("lvl2")) - d->Dict.set( - "hierarchy", - { - let hd = Dict.make() - hd->Dict.set("lvl0", JSON.String("Docs")) - hd->Dict.set("lvl1", JSON.String("Overview")) - hd->Dict.set("lvl2", JSON.String("Intro")) - hd->Dict.set("lvl3", JSON.Null) - hd->Dict.set("lvl4", JSON.Null) - hd->Dict.set("lvl5", JSON.Null) - hd->Dict.set("lvl6", JSON.Null) - JSON.Object(hd) - }, - ) - d->Dict.set( - "weight", - { - let wd = Dict.make() - wd->Dict.set("pageRank", JSON.Number(5.0)) - wd->Dict.set("level", JSON.Number(80.0)) - wd->Dict.set("position", JSON.Number(1.0)) - JSON.Object(wd) - }, - ) - JSON.Object(d) - } - expect(json)->toEqual(expected) - }) - - test("serializes a record with None optional fields as null", async () => { - let r: SearchIndex.record = { - objectID: "page", - url: "/page", - url_without_anchor: "/page", - anchor: None, - content: None, - type_: "lvl1", - hierarchy: SearchIndex.makeHierarchy(~lvl0="Cat", ~lvl1="Page", ()), - weight: {pageRank: 1, level: 100, position: 0}, - } - let json = SearchIndex.toJson(r) - - let expected = { - let d = Dict.make() - d->Dict.set("objectID", JSON.String("page")) - d->Dict.set("url", JSON.String("/page")) - d->Dict.set("url_without_anchor", JSON.String("/page")) - d->Dict.set("anchor", JSON.Null) - d->Dict.set("content", JSON.Null) - d->Dict.set("type", JSON.String("lvl1")) - d->Dict.set( - "hierarchy", - { - let hd = Dict.make() - hd->Dict.set("lvl0", JSON.String("Cat")) - hd->Dict.set("lvl1", JSON.String("Page")) - hd->Dict.set("lvl2", JSON.Null) - hd->Dict.set("lvl3", JSON.Null) - hd->Dict.set("lvl4", JSON.Null) - hd->Dict.set("lvl5", JSON.Null) - hd->Dict.set("lvl6", JSON.Null) - JSON.Object(hd) - }, - ) - d->Dict.set( - "weight", - { - let wd = Dict.make() - wd->Dict.set("pageRank", JSON.Number(1.0)) - wd->Dict.set("level", JSON.Number(100.0)) - wd->Dict.set("position", JSON.Number(0.0)) - JSON.Object(wd) - }, - ) - JSON.Object(d) - } - expect(json)->toEqual(expected) - }) +test("toJson serializes a full record with all fields", async () => { + let r: SearchIndex.record = { + objectID: "docs/overview", + url: "/docs/overview#intro", + url_without_anchor: "/docs/overview", + anchor: Some("intro"), + content: Some("Introduction text"), + type_: "lvl2", + hierarchy: SearchIndex.makeHierarchy(~lvl0="Docs", ~lvl1="Overview", ~lvl2="Intro", ()), + weight: {pageRank: 5, level: 80, position: 1}, + } + let json = SearchIndex.toJson(r) + + let expected = { + let d = Dict.make() + d->Dict.set("objectID", JSON.String("docs/overview")) + d->Dict.set("url", JSON.String("/docs/overview#intro")) + d->Dict.set("url_without_anchor", JSON.String("/docs/overview")) + d->Dict.set("anchor", JSON.String("intro")) + d->Dict.set("content", JSON.String("Introduction text")) + d->Dict.set("type", JSON.String("lvl2")) + d->Dict.set( + "hierarchy", + { + let hd = Dict.make() + hd->Dict.set("lvl0", JSON.String("Docs")) + hd->Dict.set("lvl1", JSON.String("Overview")) + hd->Dict.set("lvl2", JSON.String("Intro")) + hd->Dict.set("lvl3", JSON.Null) + hd->Dict.set("lvl4", JSON.Null) + hd->Dict.set("lvl5", JSON.Null) + hd->Dict.set("lvl6", JSON.Null) + JSON.Object(hd) + }, + ) + d->Dict.set( + "weight", + { + let wd = Dict.make() + wd->Dict.set("pageRank", JSON.Number(5.0)) + wd->Dict.set("level", JSON.Number(80.0)) + wd->Dict.set("position", JSON.Number(1.0)) + JSON.Object(wd) + }, + ) + JSON.Object(d) + } + expect(json)->toEqual(expected) +}) + +test("toJson serializes a record with None optional fields as null", async () => { + let r: SearchIndex.record = { + objectID: "page", + url: "/page", + url_without_anchor: "/page", + anchor: None, + content: None, + type_: "lvl1", + hierarchy: SearchIndex.makeHierarchy(~lvl0="Cat", ~lvl1="Page", ()), + weight: {pageRank: 1, level: 100, position: 0}, + } + let json = SearchIndex.toJson(r) + + let expected = { + let d = Dict.make() + d->Dict.set("objectID", JSON.String("page")) + d->Dict.set("url", JSON.String("/page")) + d->Dict.set("url_without_anchor", JSON.String("/page")) + d->Dict.set("anchor", JSON.Null) + d->Dict.set("content", JSON.Null) + d->Dict.set("type", JSON.String("lvl1")) + d->Dict.set( + "hierarchy", + { + let hd = Dict.make() + hd->Dict.set("lvl0", JSON.String("Cat")) + hd->Dict.set("lvl1", JSON.String("Page")) + hd->Dict.set("lvl2", JSON.Null) + hd->Dict.set("lvl3", JSON.Null) + hd->Dict.set("lvl4", JSON.Null) + hd->Dict.set("lvl5", JSON.Null) + hd->Dict.set("lvl6", JSON.Null) + JSON.Object(hd) + }, + ) + d->Dict.set( + "weight", + { + let wd = Dict.make() + wd->Dict.set("pageRank", JSON.Number(1.0)) + wd->Dict.set("level", JSON.Number(100.0)) + wd->Dict.set("position", JSON.Number(0.0)) + JSON.Object(wd) + }, + ) + JSON.Object(d) + } + expect(json)->toEqual(expected) }) diff --git a/__tests__/Search_.test.res b/__tests__/Search_.test.res index e6529d8db..c30ea51c5 100644 --- a/__tests__/Search_.test.res +++ b/__tests__/Search_.test.res @@ -29,400 +29,209 @@ let makeHit = (~type_: DocSearch.contentType, ~url: string): DocSearch.docSearch // markdownToHtml // --------------------------------------------------------------------------- -describe("markdownToHtml", () => { - // --- backslash stripping --- - - describe("backslash stripping", () => { - test( - "strips leading backslash + whitespace", - async () => { - expect(Search.markdownToHtml("\\ hello"))->toBe("hello") - }, - ) - - test( - "replaces interior backslash + whitespace with a space", - async () => { - expect(Search.markdownToHtml("foo\\ bar"))->toBe("foo bar") - }, - ) - - test( - "handles multiple interior backslashes", - async () => { - expect(Search.markdownToHtml("a\\ b\\ c"))->toBe("a b c") - }, - ) - - test( - "strips leading and replaces interior backslashes together", - async () => { - expect(Search.markdownToHtml("\\ a\\ b"))->toBe("a b") - }, - ) - }) - - // --- MDN reference link removal --- - - describe("MDN reference removal", () => { - test( - "removes MDN reference with markdown link and trailing period", - async () => { - expect( - Search.markdownToHtml( - "Some text. See [Array](https://developer.mozilla.org/array) on MDN.", - ), - )->toBe("Some text.") - }, - ) - - test( - "removes MDN reference with markdown link without trailing period", - async () => { - expect( - Search.markdownToHtml( - "Some text. See [Array](https://developer.mozilla.org/array) on MDN", - ), - )->toBe("Some text.") - }, - ) - - test( - "removes MDN plain URL reference with trailing period", - async () => { - expect( - Search.markdownToHtml("Read more. See https://developer.mozilla.org/foo on MDN."), - )->toBe("Read more.") - }, - ) - - test( - "removes MDN plain URL reference without trailing period", - async () => { - expect( - Search.markdownToHtml("Read more. See https://developer.mozilla.org/foo on MDN"), - )->toBe("Read more.") - }, - ) - }) - - // --- markdown link stripping --- - - describe("markdown link stripping", () => { - test( - "converts markdown link to plain text", - async () => { - expect(Search.markdownToHtml("[click here](https://example.com)"))->toBe("click here") - }, - ) - - test( - "converts multiple markdown links", - async () => { - expect(Search.markdownToHtml("[foo](http://a.com) and [bar](http://b.com)"))->toBe( - "foo and bar", - ) - }, - ) - - test( - "passes through link with empty text (regex requires non-empty text)", - async () => { - expect(Search.markdownToHtml("[](https://example.com)"))->toBe("[](https://example.com)") - }, - ) - }) - - // --- inline code --- - - describe("backtick code", () => { - test( - "converts backtick code to tags", - async () => { - expect(Search.markdownToHtml("`Array.map`"))->toBe("Array.map") - }, - ) - - test( - "converts multiple backtick spans", - async () => { - expect(Search.markdownToHtml("Use `map` and `filter`"))->toBe( - "Use map and filter", - ) - }, - ) - }) - - // --- bold --- - - describe("bold", () => { - test( - "converts **text** to tags", - async () => { - expect(Search.markdownToHtml("**important**"))->toBe("important") - }, - ) - - test( - "converts bold within a sentence", - async () => { - expect(Search.markdownToHtml("This is **very** important"))->toBe( - "This is very important", - ) - }, - ) - }) - - // --- italic --- - - describe("italic", () => { - test( - "converts *text* to tags", - async () => { - expect(Search.markdownToHtml("*emphasis*"))->toBe("emphasis") - }, - ) - - test( - "converts italic within a sentence", - async () => { - expect(Search.markdownToHtml("This is *quite* nice"))->toBe("This is quite nice") - }, - ) - }) - - // --- newlines --- - - describe("newlines", () => { - test( - "converts double newline to
", - async () => { - expect(Search.markdownToHtml("first\n\nsecond"))->toBe("first
second") - }, - ) - - test( - "converts triple+ newlines to single
", - async () => { - expect(Search.markdownToHtml("first\n\n\nsecond"))->toBe("first
second") - }, - ) - - test( - "converts single newline to space", - async () => { - expect(Search.markdownToHtml("first\nsecond"))->toBe("first second") - }, - ) - }) - - // --- trimming --- - - describe("trimming", () => { - test( - "trims leading whitespace", - async () => { - expect(Search.markdownToHtml(" hello"))->toBe("hello") - }, - ) - - test( - "trims trailing whitespace", - async () => { - expect(Search.markdownToHtml("hello "))->toBe("hello") - }, - ) - - test( - "trims both sides", - async () => { - expect(Search.markdownToHtml(" hello "))->toBe("hello") - }, - ) - }) - - // --- combined / edge cases --- - - describe("combined transformations", () => { - test( - "handles empty string", - async () => { - expect(Search.markdownToHtml(""))->toBe("") - }, - ) - - test( - "plain text passes through unchanged", - async () => { - expect(Search.markdownToHtml("just plain text"))->toBe("just plain text") - }, - ) - - test( - "applies multiple transformations together", - async () => { - expect( - Search.markdownToHtml( - "Use `map` on **arrays**.\n\nSee [docs](http://x.com) for *details*.", - ), - )->toBe( - "Use map on arrays.
See docs for details.", - ) - }, - ) - - test( - "bold inside code still gets converted (sequential regex application)", - async () => { - expect(Search.markdownToHtml("`**notbold**`"))->toBe( - "notbold", - ) - }, - ) - }) +test("markdownToHtml strips leading backslash + whitespace", async () => { + expect(Search.markdownToHtml("\\ hello"))->toBe("hello") }) +test("markdownToHtml replaces interior backslash + whitespace with a space", async () => { + expect(Search.markdownToHtml("foo\\ bar"))->toBe("foo bar") +}) + +test("markdownToHtml handles multiple interior backslashes", async () => { + expect(Search.markdownToHtml("a\\ b\\ c"))->toBe("a b c") +}) + +test("markdownToHtml strips leading and replaces interior backslashes together", async () => { + expect(Search.markdownToHtml("\\ a\\ b"))->toBe("a b") +}) + +test( + "markdownToHtml removes an MDN reference with a markdown link and trailing period", + async () => { + expect( + Search.markdownToHtml("Some text. See [Array](https://developer.mozilla.org/array) on MDN."), + )->toBe("Some text.") + }, +) + +test( + "markdownToHtml removes an MDN reference with a markdown link without trailing period", + async () => { + expect( + Search.markdownToHtml("Some text. See [Array](https://developer.mozilla.org/array) on MDN"), + )->toBe("Some text.") + }, +) + +test("markdownToHtml removes an MDN plain URL reference with trailing period", async () => { + expect(Search.markdownToHtml("Read more. See https://developer.mozilla.org/foo on MDN."))->toBe( + "Read more.", + ) +}) + +test("markdownToHtml removes an MDN plain URL reference without trailing period", async () => { + expect(Search.markdownToHtml("Read more. See https://developer.mozilla.org/foo on MDN"))->toBe( + "Read more.", + ) +}) + +test("markdownToHtml converts a markdown link to plain text", async () => { + expect(Search.markdownToHtml("[click here](https://example.com)"))->toBe("click here") +}) + +test("markdownToHtml converts multiple markdown links", async () => { + expect(Search.markdownToHtml("[foo](http://a.com) and [bar](http://b.com)"))->toBe("foo and bar") +}) + +test("markdownToHtml passes through a link with empty text", async () => { + expect(Search.markdownToHtml("[](https://example.com)"))->toBe("[](https://example.com)") +}) + +test("markdownToHtml converts backtick code to tags", async () => { + expect(Search.markdownToHtml("`Array.map`"))->toBe("Array.map") +}) + +test("markdownToHtml converts multiple backtick spans", async () => { + expect(Search.markdownToHtml("Use `map` and `filter`"))->toBe( + "Use map and filter", + ) +}) + +test("markdownToHtml converts **text** to tags", async () => { + expect(Search.markdownToHtml("**important**"))->toBe("important") +}) + +test("markdownToHtml converts bold within a sentence", async () => { + expect(Search.markdownToHtml("This is **very** important"))->toBe( + "This is very important", + ) +}) + +test("markdownToHtml converts *text* to tags", async () => { + expect(Search.markdownToHtml("*emphasis*"))->toBe("emphasis") +}) + +test("markdownToHtml converts italic within a sentence", async () => { + expect(Search.markdownToHtml("This is *quite* nice"))->toBe("This is quite nice") +}) + +test("markdownToHtml converts double newline to
", async () => { + expect(Search.markdownToHtml("first\n\nsecond"))->toBe("first
second") +}) + +test("markdownToHtml converts triple+ newlines to a single
", async () => { + expect(Search.markdownToHtml("first\n\n\nsecond"))->toBe("first
second") +}) + +test("markdownToHtml converts single newline to a space", async () => { + expect(Search.markdownToHtml("first\nsecond"))->toBe("first second") +}) + +test("markdownToHtml trims leading whitespace", async () => { + expect(Search.markdownToHtml(" hello"))->toBe("hello") +}) + +test("markdownToHtml trims trailing whitespace", async () => { + expect(Search.markdownToHtml("hello "))->toBe("hello") +}) + +test("markdownToHtml trims both sides", async () => { + expect(Search.markdownToHtml(" hello "))->toBe("hello") +}) + +test("markdownToHtml handles empty string", async () => { + expect(Search.markdownToHtml(""))->toBe("") +}) + +test("markdownToHtml passes plain text through unchanged", async () => { + expect(Search.markdownToHtml("just plain text"))->toBe("just plain text") +}) + +test("markdownToHtml applies multiple transformations together", async () => { + expect( + Search.markdownToHtml("Use `map` on **arrays**.\n\nSee [docs](http://x.com) for *details*."), + )->toBe("Use map on arrays.
See docs for details.") +}) + +test( + "markdownToHtml still converts bold inside code because regexes run sequentially", + async () => { + expect(Search.markdownToHtml("`**notbold**`"))->toBe("notbold") + }, +) + // --------------------------------------------------------------------------- // isChildHit // --------------------------------------------------------------------------- -describe("isChildHit", () => { - // --- child-level types (always true) --- - - describe("child-level types", () => { - test( - "Lvl2 is a child hit", - async () => { - expect(Search.isChildHit(makeHit(~type_=Lvl2, ~url="https://example.com/page")))->toBe(true) - }, - ) - - test( - "Lvl3 is a child hit", - async () => { - expect(Search.isChildHit(makeHit(~type_=Lvl3, ~url="https://example.com/page")))->toBe(true) - }, - ) - - test( - "Lvl4 is a child hit", - async () => { - expect(Search.isChildHit(makeHit(~type_=Lvl4, ~url="https://example.com/page")))->toBe(true) - }, - ) - - test( - "Lvl5 is a child hit", - async () => { - expect(Search.isChildHit(makeHit(~type_=Lvl5, ~url="https://example.com/page")))->toBe(true) - }, - ) - - test( - "Lvl6 is a child hit", - async () => { - expect(Search.isChildHit(makeHit(~type_=Lvl6, ~url="https://example.com/page")))->toBe(true) - }, - ) - - test( - "Content is a child hit", - async () => { - expect(Search.isChildHit(makeHit(~type_=Content, ~url="https://example.com/page")))->toBe( - true, - ) - }, - ) - - test( - "Lvl2 is a child hit even without hash in URL", - async () => { - expect(Search.isChildHit(makeHit(~type_=Lvl2, ~url="https://example.com/no-hash")))->toBe( - true, - ) - }, - ) - - test( - "Content is a child hit even with hash in URL", - async () => { - expect( - Search.isChildHit(makeHit(~type_=Content, ~url="https://example.com/page#section")), - )->toBe(true) - }, - ) - }) - - // --- Lvl0 --- - - describe("Lvl0", () => { - test( - "Lvl0 without hash is not a child hit", - async () => { - expect(Search.isChildHit(makeHit(~type_=Lvl0, ~url="https://example.com/page")))->toBe( - false, - ) - }, - ) - - test( - "Lvl0 with hash is a child hit", - async () => { - expect( - Search.isChildHit(makeHit(~type_=Lvl0, ~url="https://example.com/page#section")), - )->toBe(true) - }, - ) - - test( - "Lvl0 with hash at end of URL is a child hit", - async () => { - expect(Search.isChildHit(makeHit(~type_=Lvl0, ~url="https://example.com/page#")))->toBe( - true, - ) - }, - ) - }) - - // --- Lvl1 --- - - describe("Lvl1", () => { - test( - "Lvl1 without hash is not a child hit", - async () => { - expect(Search.isChildHit(makeHit(~type_=Lvl1, ~url="https://example.com/page")))->toBe( - false, - ) - }, - ) - - test( - "Lvl1 with hash is a child hit", - async () => { - expect( - Search.isChildHit(makeHit(~type_=Lvl1, ~url="https://example.com/page#heading")), - )->toBe(true) - }, - ) - - test( - "Lvl1 with deeply nested hash anchor is a child hit", - async () => { - expect( - Search.isChildHit( - makeHit(~type_=Lvl1, ~url="https://example.com/docs/manual/api#some-section"), - ), - )->toBe(true) - }, - ) - - test( - "Lvl1 with empty URL is not a child hit", - async () => { - expect(Search.isChildHit(makeHit(~type_=Lvl1, ~url="")))->toBe(false) - }, - ) - }) +test("isChildHit treats Lvl2 as a child hit", async () => { + expect(Search.isChildHit(makeHit(~type_=Lvl2, ~url="https://example.com/page")))->toBe(true) +}) + +test("isChildHit treats Lvl3 as a child hit", async () => { + expect(Search.isChildHit(makeHit(~type_=Lvl3, ~url="https://example.com/page")))->toBe(true) +}) + +test("isChildHit treats Lvl4 as a child hit", async () => { + expect(Search.isChildHit(makeHit(~type_=Lvl4, ~url="https://example.com/page")))->toBe(true) +}) + +test("isChildHit treats Lvl5 as a child hit", async () => { + expect(Search.isChildHit(makeHit(~type_=Lvl5, ~url="https://example.com/page")))->toBe(true) +}) + +test("isChildHit treats Lvl6 as a child hit", async () => { + expect(Search.isChildHit(makeHit(~type_=Lvl6, ~url="https://example.com/page")))->toBe(true) +}) + +test("isChildHit treats Content as a child hit", async () => { + expect(Search.isChildHit(makeHit(~type_=Content, ~url="https://example.com/page")))->toBe(true) +}) + +test("isChildHit treats Lvl2 as a child hit even without a hash in the URL", async () => { + expect(Search.isChildHit(makeHit(~type_=Lvl2, ~url="https://example.com/no-hash")))->toBe(true) +}) + +test("isChildHit treats Content as a child hit even with a hash in the URL", async () => { + expect(Search.isChildHit(makeHit(~type_=Content, ~url="https://example.com/page#section")))->toBe( + true, + ) +}) + +test("isChildHit treats Lvl0 without a hash as not a child hit", async () => { + expect(Search.isChildHit(makeHit(~type_=Lvl0, ~url="https://example.com/page")))->toBe(false) +}) + +test("isChildHit treats Lvl0 with a hash as a child hit", async () => { + expect(Search.isChildHit(makeHit(~type_=Lvl0, ~url="https://example.com/page#section")))->toBe( + true, + ) +}) + +test("isChildHit treats Lvl0 with a trailing # as a child hit", async () => { + expect(Search.isChildHit(makeHit(~type_=Lvl0, ~url="https://example.com/page#")))->toBe(true) +}) + +test("isChildHit treats Lvl1 without a hash as not a child hit", async () => { + expect(Search.isChildHit(makeHit(~type_=Lvl1, ~url="https://example.com/page")))->toBe(false) +}) + +test("isChildHit treats Lvl1 with a hash as a child hit", async () => { + expect(Search.isChildHit(makeHit(~type_=Lvl1, ~url="https://example.com/page#heading")))->toBe( + true, + ) +}) + +test("isChildHit treats Lvl1 with a deeply nested hash anchor as a child hit", async () => { + expect( + Search.isChildHit( + makeHit(~type_=Lvl1, ~url="https://example.com/docs/manual/api#some-section"), + ), + )->toBe(true) +}) + +test("isChildHit treats Lvl1 with an empty URL as not a child hit", async () => { + expect(Search.isChildHit(makeHit(~type_=Lvl1, ~url="")))->toBe(false) }) test("renders disabled search copy when Algolia config is missing", async () => { diff --git a/__tests__/Url_.test.res b/__tests__/Url_.test.res index 5cf00796f..f7a0683cc 100644 --- a/__tests__/Url_.test.res +++ b/__tests__/Url_.test.res @@ -1,75 +1,72 @@ open Vitest -// --------------------------------------------------------------------------- -// Url.parse – version detection -// --------------------------------------------------------------------------- - -describe("Url.parse version detection", () => { - test("parses v-prefixed semver version", async () => { - let result = Url.parse("/docs/manual/v12.0.0/introduction") - expect(result.version)->toEqual(Url.Version("v12.0.0")) - expect(result.base)->toEqual(["docs", "manual"]) - expect(result.pagepath)->toEqual(["introduction"]) - expect(result.fullpath)->toEqual(["docs", "manual", "v12.0.0", "introduction"]) - }) +test("Url.parse parses v-prefixed semver version", async () => { + let result = Url.parse("/docs/manual/v12.0.0/introduction") + expect(result.version)->toEqual(Url.Version("v12.0.0")) + expect(result.base)->toEqual(["docs", "manual"]) + expect(result.pagepath)->toEqual(["introduction"]) + expect(result.fullpath)->toEqual(["docs", "manual", "v12.0.0", "introduction"]) +}) - test("parses version without v prefix matching latest (PR #1231)", async () => { - let result = Url.parse("/docs/manual/12.0.0/introduction") - // 12.0.0 matches Constants.versions.latest, so it becomes Latest - expect(result.version)->toEqual(Url.Latest) - expect(result.base)->toEqual(["docs", "manual"]) - expect(result.pagepath)->toEqual(["introduction"]) - expect(result.fullpath)->toEqual(["docs", "manual", "12.0.0", "introduction"]) - }) +test("Url.parse parses version without v prefix matching latest (PR #1231)", async () => { + let result = Url.parse("/docs/manual/12.0.0/introduction") + // 12.0.0 matches Constants.versions.latest, so it becomes Latest + expect(result.version)->toEqual(Url.Latest) + expect(result.base)->toEqual(["docs", "manual"]) + expect(result.pagepath)->toEqual(["introduction"]) + expect(result.fullpath)->toEqual(["docs", "manual", "12.0.0", "introduction"]) +}) - test("parses latest keyword", async () => { - let result = Url.parse("/docs/manual/latest/arrays") - expect(result.version)->toEqual(Url.Latest) - expect(result.base)->toEqual(["docs", "manual"]) - expect(result.pagepath)->toEqual(["arrays"]) - }) +test("Url.parse parses latest keyword", async () => { + let result = Url.parse("/docs/manual/latest/arrays") + expect(result.version)->toEqual(Url.Latest) + expect(result.base)->toEqual(["docs", "manual"]) + expect(result.pagepath)->toEqual(["arrays"]) +}) - test("parses 'next' string in URL (does not match env-based Next version)", async () => { +test( + "Url.parse parses 'next' string in URL when it does not match env-based Next version", + async () => { // "next" is matched by the regex, but Constants.versions.next is "13.0.0", not "next" let result = Url.parse("/docs/manual/next/arrays") expect(result.version)->toEqual(Url.Version("next")) expect(result.base)->toEqual(["docs", "manual"]) expect(result.pagepath)->toEqual(["arrays"]) - }) + }, +) - test("parses actual next version from env as Next", async () => { - let nextVer = Constants.versions.next - let result = Url.parse("/docs/manual/" ++ nextVer ++ "/arrays") - expect(result.version)->toEqual(Url.Next) - expect(result.base)->toEqual(["docs", "manual"]) - expect(result.pagepath)->toEqual(["arrays"]) - }) +test("Url.parse parses actual next version from env as Next", async () => { + let nextVer = Constants.versions.next + let result = Url.parse("/docs/manual/" ++ nextVer ++ "/arrays") + expect(result.version)->toEqual(Url.Next) + expect(result.base)->toEqual(["docs", "manual"]) + expect(result.pagepath)->toEqual(["arrays"]) +}) - test("parses route with no version as NoVersion", async () => { - let result = Url.parse("/community/overview") - expect(result.version)->toEqual(Url.NoVersion) - expect(result.base)->toEqual(["community", "overview"]) - expect(result.pagepath)->toEqual([]) - }) +test("Url.parse parses route with no version as NoVersion", async () => { + let result = Url.parse("/community/overview") + expect(result.version)->toEqual(Url.NoVersion) + expect(result.base)->toEqual(["community", "overview"]) + expect(result.pagepath)->toEqual([]) +}) - test("parses short v-prefixed version (major.minor)", async () => { - let result = Url.parse("/apis/javascript/v7.1/node") - expect(result.version)->toEqual(Url.Version("v7.1")) - expect(result.base)->toEqual(["apis", "javascript"]) - expect(result.pagepath)->toEqual(["node"]) - }) +test("Url.parse parses short v-prefixed version (major.minor)", async () => { + let result = Url.parse("/apis/javascript/v7.1/node") + expect(result.version)->toEqual(Url.Version("v7.1")) + expect(result.base)->toEqual(["apis", "javascript"]) + expect(result.pagepath)->toEqual(["node"]) +}) - test("parses short version without v prefix (major.minor, PR #1231)", async () => { - let result = Url.parse("/apis/javascript/7.1/node") - expect(result.version)->toEqual(Url.Version("7.1")) - expect(result.base)->toEqual(["apis", "javascript"]) - expect(result.pagepath)->toEqual(["node"]) - }) +test("Url.parse parses short version without v prefix (major.minor, PR #1231)", async () => { + let result = Url.parse("/apis/javascript/7.1/node") + expect(result.version)->toEqual(Url.Version("7.1")) + expect(result.base)->toEqual(["apis", "javascript"]) + expect(result.pagepath)->toEqual(["node"]) +}) - test("parses major-only version without v prefix (PR #1231)", async () => { - let result = Url.parse("/docs/manual/12/getting-started") - expect(result.version)->toEqual(Url.Version("12")) - expect(result.base)->toEqual(["docs", "manual"]) - expect(result.pagepath)->toEqual(["getting-started"]) - }) +test("Url.parse parses major-only version without v prefix (PR #1231)", async () => { + let result = Url.parse("/docs/manual/12/getting-started") + expect(result.version)->toEqual(Url.Version("12")) + expect(result.base)->toEqual(["docs", "manual"]) + expect(result.pagepath)->toEqual(["getting-started"]) }) diff --git a/src/bindings/Vitest.res b/src/bindings/Vitest.res index c6b7cbe74..9ec449715 100644 --- a/src/bindings/Vitest.res +++ b/src/bindings/Vitest.res @@ -9,9 +9,6 @@ type mock @module("vitest") external test: (string, unit => promise) => unit = "test" -@module("vitest") -external describe: (string, unit => unit) => unit = "describe" - @module("vitest") @scope("vi") external fn: unit => 'a => 'b = "fn" From 25de3845590d5f15a24d948d3b7cafae6c18d613 Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Sat, 25 Apr 2026 13:23:51 -0400 Subject: [PATCH 16/32] fix: normalize docs search urls Store absolute site URLs in the Algolia index so search records can be parsed outside the app. Normalize those URLs back to relative paths in the search UI and remove legacy versioned manual path handling from docs links and URL parsing. --- .env | 4 +- __tests__/DocsOverview_.test.res | 33 ++++++++++++++ __tests__/SearchIndex_.test.res | 40 +++++++++++++++++ __tests__/Search_.test.res | 24 ++++++++++ __tests__/Url_.test.res | 75 +++++++------------------------ app/routes/ApiRoute.res | 37 ++++++++------- app/routes/DocsOverview.res | 9 +--- scripts/generate_search_index.res | 9 +++- src/common/Constants.res | 14 +++--- src/common/SearchIndex.res | 17 +++++++ src/common/SearchIndex.resi | 2 + src/common/Url.res | 73 +++--------------------------- src/common/Url.resi | 9 ---- src/components/Search.res | 30 +++++++++++-- 14 files changed, 202 insertions(+), 174 deletions(-) diff --git a/.env b/.env index 2fbb85e5b..267bdf204 100644 --- a/.env +++ b/.env @@ -1,2 +1,2 @@ -VITE_VERSION_LATEST=12.0.0 -VITE_VERSION_NEXT=13.0.0 +VITE_VERSION_LATEST="v12.0.0" +VITE_VERSION_NEXT="v13.0.0" diff --git a/__tests__/DocsOverview_.test.res b/__tests__/DocsOverview_.test.res index b8e6eb05f..916af7861 100644 --- a/__tests__/DocsOverview_.test.res +++ b/__tests__/DocsOverview_.test.res @@ -49,6 +49,39 @@ test("desktop docs overview shows ecosystem links", async () => { await element(wrapper)->toMatchScreenshot("desktop-docs-overview-ecosystem") }) +test("docs overview uses unversioned docs links", async () => { + await viewport(1440, 900) + + let _screen = await render( + +
+ +
+
, + ) + + let overviewLink = switch document->WebAPI.Document.querySelector( + "a[href='/docs/manual/introduction']", + ) { + | Value(link) => link + | Null => failwith("expected docs overview to link to the unversioned manual introduction") + } + await element(overviewLink)->toBeVisible + + let genTypeLink = switch document->WebAPI.Document.querySelector( + "a[href='/docs/manual/typescript-integration']", + ) { + | Value(link) => link + | Null => failwith("expected docs overview to link to the unversioned GenType docs page") + } + await element(genTypeLink)->toBeVisible + + switch document->WebAPI.Document.querySelector("a[href*='/docs/manual/v']") { + | Value(_) => failwith("expected docs overview to avoid versioned manual links") + | Null => () + } +}) + test("mobile docs overview", async () => { await viewport(600, 1200) diff --git a/__tests__/SearchIndex_.test.res b/__tests__/SearchIndex_.test.res index 8cd765ba5..0504f980f 100644 --- a/__tests__/SearchIndex_.test.res +++ b/__tests__/SearchIndex_.test.res @@ -410,6 +410,46 @@ test("weightToJson serializes zero values correctly", async () => { expect(json)->toEqual(expected) }) +// --------------------------------------------------------------------------- +// withBaseUrl +// --------------------------------------------------------------------------- + +test("withBaseUrl prepends the site URL to relative record URLs", async () => { + let record: SearchIndex.record = { + objectID: "docs/manual/introduction", + url: "/docs/manual/introduction#what-is-rescript", + url_without_anchor: "/docs/manual/introduction", + anchor: Some("what-is-rescript"), + content: Some("Intro"), + type_: "lvl2", + hierarchy: SearchIndex.makeHierarchy(~lvl0="Docs", ~lvl1="Introduction", ()), + weight: {pageRank: 5, level: 80, position: 1}, + } + + let result = SearchIndex.withBaseUrl(record, ~siteUrl="https://rescript-lang.org") + + expect(result.url)->toBe("https://rescript-lang.org/docs/manual/introduction#what-is-rescript") + expect(result.url_without_anchor)->toBe("https://rescript-lang.org/docs/manual/introduction") +}) + +test("withBaseUrl avoids double slashes when the site URL ends with /", async () => { + let record: SearchIndex.record = { + objectID: "docs/manual/api", + url: "/docs/manual/api", + url_without_anchor: "/docs/manual/api", + anchor: None, + content: None, + type_: "lvl1", + hierarchy: SearchIndex.makeHierarchy(~lvl0="Docs", ~lvl1="API", ()), + weight: {pageRank: 5, level: 100, position: 0}, + } + + let result = SearchIndex.withBaseUrl(record, ~siteUrl="https://rescript-lang.org/") + + expect(result.url)->toBe("https://rescript-lang.org/docs/manual/api") + expect(result.url_without_anchor)->toBe("https://rescript-lang.org/docs/manual/api") +}) + // --------------------------------------------------------------------------- // toJson // --------------------------------------------------------------------------- diff --git a/__tests__/Search_.test.res b/__tests__/Search_.test.res index c30ea51c5..fdb906e88 100644 --- a/__tests__/Search_.test.res +++ b/__tests__/Search_.test.res @@ -234,6 +234,30 @@ test("isChildHit treats Lvl1 with an empty URL as not a child hit", async () => expect(Search.isChildHit(makeHit(~type_=Lvl1, ~url="")))->toBe(false) }) +test("toRelativeSiteUrl strips the site origin from an absolute URL", async () => { + let result = Search.toRelativeSiteUrl( + "https://rescript-lang.org/docs/manual/introduction#what-is-rescript", + ~siteUrl="https://rescript-lang.org/", + ) + + expect(result)->toBe("/docs/manual/introduction#what-is-rescript") +}) + +test("normalizeHitUrls rewrites absolute site URLs to relative paths", async () => { + let hit = makeHit( + ~type_=Lvl1, + ~url="https://rescript-lang.org/docs/manual/typescript-integration#gentype", + ) + let result = Search.normalizeHitUrls([hit], ~siteUrl="https://rescript-lang.org/") + + expect(result[0]->Option.map(hit => hit.url))->toEqual( + Some("/docs/manual/typescript-integration#gentype"), + ) + expect(result[0]->Option.map(hit => hit.url_without_anchor))->toEqual( + Some("/docs/manual/typescript-integration#gentype"), + ) +}) + test("renders disabled search copy when Algolia config is missing", async () => { await viewport(1440, 500) diff --git a/__tests__/Url_.test.res b/__tests__/Url_.test.res index f7a0683cc..fd29c2185 100644 --- a/__tests__/Url_.test.res +++ b/__tests__/Url_.test.res @@ -1,72 +1,29 @@ open Vitest -test("Url.parse parses v-prefixed semver version", async () => { - let result = Url.parse("/docs/manual/v12.0.0/introduction") - expect(result.version)->toEqual(Url.Version("v12.0.0")) - expect(result.base)->toEqual(["docs", "manual"]) - expect(result.pagepath)->toEqual(["introduction"]) - expect(result.fullpath)->toEqual(["docs", "manual", "v12.0.0", "introduction"]) +test("Url.parse splits an unversioned route into path segments", async () => { + let result = Url.parse("/docs/manual/introduction") + expect(result.base)->toEqual(["docs", "manual", "introduction"]) + expect(result.pagepath)->toEqual([]) + expect(result.fullpath)->toEqual(["docs", "manual", "introduction"]) }) -test("Url.parse parses version without v prefix matching latest (PR #1231)", async () => { - let result = Url.parse("/docs/manual/12.0.0/introduction") - // 12.0.0 matches Constants.versions.latest, so it becomes Latest - expect(result.version)->toEqual(Url.Latest) - expect(result.base)->toEqual(["docs", "manual"]) - expect(result.pagepath)->toEqual(["introduction"]) - expect(result.fullpath)->toEqual(["docs", "manual", "12.0.0", "introduction"]) +test("Url.parse treats version-like segments as ordinary path content", async () => { + let result = Url.parse("/docs/manual/v12.0.0/introduction") + expect(result.base)->toEqual(["docs", "manual", "v12.0.0", "introduction"]) + expect(result.pagepath)->toEqual([]) + expect(result.fullpath)->toEqual(["docs", "manual", "v12.0.0", "introduction"]) }) -test("Url.parse parses latest keyword", async () => { +test("Url.parse treats latest as ordinary path content", async () => { let result = Url.parse("/docs/manual/latest/arrays") - expect(result.version)->toEqual(Url.Latest) - expect(result.base)->toEqual(["docs", "manual"]) - expect(result.pagepath)->toEqual(["arrays"]) -}) - -test( - "Url.parse parses 'next' string in URL when it does not match env-based Next version", - async () => { - // "next" is matched by the regex, but Constants.versions.next is "13.0.0", not "next" - let result = Url.parse("/docs/manual/next/arrays") - expect(result.version)->toEqual(Url.Version("next")) - expect(result.base)->toEqual(["docs", "manual"]) - expect(result.pagepath)->toEqual(["arrays"]) - }, -) - -test("Url.parse parses actual next version from env as Next", async () => { - let nextVer = Constants.versions.next - let result = Url.parse("/docs/manual/" ++ nextVer ++ "/arrays") - expect(result.version)->toEqual(Url.Next) - expect(result.base)->toEqual(["docs", "manual"]) - expect(result.pagepath)->toEqual(["arrays"]) + expect(result.base)->toEqual(["docs", "manual", "latest", "arrays"]) + expect(result.pagepath)->toEqual([]) + expect(result.fullpath)->toEqual(["docs", "manual", "latest", "arrays"]) }) -test("Url.parse parses route with no version as NoVersion", async () => { +test("Url.parse parses routes outside docs without special handling", async () => { let result = Url.parse("/community/overview") - expect(result.version)->toEqual(Url.NoVersion) expect(result.base)->toEqual(["community", "overview"]) expect(result.pagepath)->toEqual([]) -}) - -test("Url.parse parses short v-prefixed version (major.minor)", async () => { - let result = Url.parse("/apis/javascript/v7.1/node") - expect(result.version)->toEqual(Url.Version("v7.1")) - expect(result.base)->toEqual(["apis", "javascript"]) - expect(result.pagepath)->toEqual(["node"]) -}) - -test("Url.parse parses short version without v prefix (major.minor, PR #1231)", async () => { - let result = Url.parse("/apis/javascript/7.1/node") - expect(result.version)->toEqual(Url.Version("7.1")) - expect(result.base)->toEqual(["apis", "javascript"]) - expect(result.pagepath)->toEqual(["node"]) -}) - -test("Url.parse parses major-only version without v prefix (PR #1231)", async () => { - let result = Url.parse("/docs/manual/12/getting-started") - expect(result.version)->toEqual(Url.Version("12")) - expect(result.base)->toEqual(["docs", "manual"]) - expect(result.pagepath)->toEqual(["getting-started"]) + expect(result.fullpath)->toEqual(["community", "overview"]) }) diff --git a/app/routes/ApiRoute.res b/app/routes/ApiRoute.res index 1f8ef40cb..894ea7e8f 100644 --- a/app/routes/ApiRoute.res +++ b/app/routes/ApiRoute.res @@ -98,23 +98,26 @@ let groupItems = apiDocs => { } let makeBreadcrumbs = (~prefix: Url.breadcrumb, route: Path.t): list => { - let url = Url.parse((route :> string)) - - let (_, rest) = // Strip the "api" part of the url before creating the rest of the breadcrumbs - Array.slice(url.pagepath, ~start=1)->Array.reduce((prefix.href, []), (acc, path) => { - let (baseHref, ret) = acc - - let href = baseHref ++ ("/" ++ path) - - Array.push( - ret, - { - Url.name: Url.prettyString(path), - href, - }, - )->ignore - (href, ret) - }) + let (_, rest) = + // Strip the "/docs/manual/api" base path before creating the rest of the breadcrumbs + (route :> string) + ->String.split("/") + ->Array.filter(s => s !== "") + ->Array.slice(~start=3) + ->Array.reduce((prefix.href, []), (acc, path) => { + let (baseHref, ret) = acc + + let href = baseHref ++ ("/" ++ path) + + Array.push( + ret, + { + Url.name: Url.prettyString(path), + href, + }, + )->ignore + (href, ret) + }) Array.concat([prefix], rest)->List.fromArray } diff --git a/app/routes/DocsOverview.res b/app/routes/DocsOverview.res index e5df82e57..dbffbd735 100644 --- a/app/routes/DocsOverview.res +++ b/app/routes/DocsOverview.res @@ -16,17 +16,12 @@ module Card = { @react.component let default = (~showVersionSelect=true) => { - let {pathname} = ReactRouter.useLocation() - let url = (pathname :> string)->Url.parse - - let version = url->Url.getVersionString - - let languageManual = Constants.languageManual(version) + let languageManual = Constants.languageManual let ecosystem = [ ("Package Index", "/packages"), ("rescript-react", "/docs/react/introduction"), - ("GenType", `/docs/manual/${version}/typescript-integration`), + ("GenType", "/docs/manual/typescript-integration"), ("Reanalyze", "https://github.com/rescript-lang/reanalyze"), ] diff --git a/scripts/generate_search_index.res b/scripts/generate_search_index.res index 309986eb4..57051a34c 100644 --- a/scripts/generate_search_index.res +++ b/scripts/generate_search_index.res @@ -71,6 +71,9 @@ let resolveApiDir = (): option => { } } +let resolveSiteUrl = (): string => + getEnv("VITE_DEPLOYMENT_URL")->Option.getOr("https://rescript-lang.org") + let main = async () => { let appId = getEnv("ALGOLIA_APP_ID") let adminApiKey = getEnv("ALGOLIA_ADMIN_API_KEY") @@ -82,6 +85,7 @@ let main = async () => { Console.log("[search-index] Building search index records...") let apiDir = resolveApiDir()->Option.getOr("markdown-pages/docs/api") + let siteUrl = resolveSiteUrl() // 1. Build records from all content sources let manualRecords = SearchIndex.buildMarkdownRecords( @@ -175,7 +179,10 @@ let main = async () => { Console.log(`[search-index] Total: ${Int.toString(totalCount)} records`) // 3. Convert to JSON for Algolia - let jsonRecords = allRecords->Array.map(SearchIndex.toJson) + let jsonRecords = + allRecords + ->Array.map(record => SearchIndex.withBaseUrl(record, ~siteUrl)) + ->Array.map(SearchIndex.toJson) // 4. Initialize Algolia client and upload let client = Algolia.make(appId, adminApiKey) diff --git a/src/common/Constants.res b/src/common/Constants.res index 3e1e23462..ec4129cc4 100644 --- a/src/common/Constants.res +++ b/src/common/Constants.res @@ -46,14 +46,12 @@ let dropdownLabelNext = "--- Next ---" let dropdownLabelReleased = "--- Released ---" // Used for the DocsOverview and collapsible navigation -let languageManual = version => { - [ - ("Overview", `/docs/manual/${version}/introduction`), - ("Language Features", `/docs/manual/${version}/overview`), - ("JS Interop", `/docs/manual/${version}/embed-raw-javascript`), - ("Build System", `/docs/manual/${version}/build-overview`), - ] -} +let languageManual = [ + ("Overview", "/docs/manual/introduction"), + ("Language Features", "/docs/manual/overview"), + ("JS Interop", "/docs/manual/embed-raw-javascript"), + ("Build System", "/docs/manual/build-overview"), +] let tools = [("Syntax Lookup", "/syntax-lookup")] diff --git a/src/common/SearchIndex.res b/src/common/SearchIndex.res index 79d4260fa..8b653e5c6 100644 --- a/src/common/SearchIndex.res +++ b/src/common/SearchIndex.res @@ -491,6 +491,23 @@ let weightToJson = (w: weight): JSON.t => { JSON.Object(dict) } +let withBaseUrl = (record: record, ~siteUrl: string): record => { + let normalizedSiteUrl = siteUrl->String.replaceRegExp(RegExp.fromString("/+$", ~flags=""), "") + let absolutize = (url: string) => + if RegExp.test(RegExp.fromString("^https?://", ~flags=""), url) { + url + } else { + let normalizedPath = String.startsWith(url, "/") ? url : "/" ++ url + normalizedSiteUrl ++ normalizedPath + } + + { + ...record, + url: absolutize(record.url), + url_without_anchor: absolutize(record.url_without_anchor), + } +} + let toJson = (r: record): JSON.t => { let dict = Dict.make() dict->Dict.set("objectID", JSON.String(r.objectID)) diff --git a/src/common/SearchIndex.resi b/src/common/SearchIndex.resi index 435e81eb2..5e27feb6d 100644 --- a/src/common/SearchIndex.resi +++ b/src/common/SearchIndex.resi @@ -62,6 +62,8 @@ let hierarchyToJson: hierarchy => JSON.t let weightToJson: weight => JSON.t +let withBaseUrl: (record, ~siteUrl: string) => record + let buildMarkdownRecords: ( ~category: string, ~basePath: string, diff --git a/src/common/Url.res b/src/common/Url.res index 0c7538b6d..9122cb867 100644 --- a/src/common/Url.res +++ b/src/common/Url.res @@ -1,46 +1,6 @@ -type version = - | Latest - | Next - | NoVersion - | Version(string) - -/* - Example 1: - Url: "/docs/manual/latest/advanced/introduction" - - Results in: - fullpath: ["docs", "manual", "latest", "advanced", "introduction"] - base: ["docs", "manual"] - version: Latest - pagepath: ["advanced", "introduction"] - */ - -/* - Example 2: - Url: "/apis/" - - Results in: - fullpath: ["apis"] - base: ["apis"] - version: NoVersion - pagepath: [] - */ - -/* - Example 3: - Url: "/apis/javascript/v7.1.1/node" - - Results in: - fullpath: ["apis", "javascript", "v7.1.1", "node"] - base: ["apis", "javascript"] - version: Version("v7.1.1"), - pagepath: ["node"] - */ - type t = { fullpath: array, base: array, - version: version, pagepath: array, } @@ -56,29 +16,13 @@ let prettyString = (str: string) => { } let parse = (route: string): t => { - let fullpath = route->String.split("/")->Array.filter(s => s !== "") - let foundVersionIndex = Array.findIndex(fullpath, chunk => { - RegExp.test(/latest|next|v?\d+(\.\d+)?(\.\d+)?/, chunk) - }) + let routePath = route->String.split("/")->Array.filter(s => s !== "") - let (version, base, pagepath) = if foundVersionIndex == -1 { - (NoVersion, fullpath, []) - } else { - let version = switch fullpath[foundVersionIndex] { - | Some(version) if version === Constants.versions.next => Next - | Some(version) if version === Constants.versions.latest => Latest - | Some("latest") => Latest // still used for React docs - | Some(v) => Version(v) - | None => NoVersion - } - ( - version, - fullpath->Array.slice(~start=0, ~end=foundVersionIndex), - fullpath->Array.slice(~start=foundVersionIndex + 1, ~end=Array.length(fullpath)), - ) + { + fullpath: routePath, + base: routePath, + pagepath: [], } - - {fullpath, base, version, pagepath} } @unboxed @@ -95,13 +39,6 @@ let getVersionFromStorage = (key: storageKey) => { } } -let getVersionString = url => - switch url.version { - | Next => Constants.versions.next - | Latest | NoVersion => Constants.versions.latest - | Version(version) => version - } - let normalizePath = string => { string->String.replaceRegExp(/\/$/, "")->String.toLocaleLowerCase } diff --git a/src/common/Url.resi b/src/common/Url.resi index afbeb91c2..991ca39cf 100644 --- a/src/common/Url.resi +++ b/src/common/Url.resi @@ -1,13 +1,6 @@ -type version = - | Latest - | Next - | NoVersion - | Version(string) - type t = { fullpath: array, base: array, - version: version, pagepath: array, } @@ -29,8 +22,6 @@ type storageKey = let getVersionFromStorage: storageKey => option -let getVersionString: t => string - let normalizePath: string => string let normalizeAnchor: string => string diff --git a/src/components/Search.res b/src/components/Search.res index a5bd3d854..41ff20e4c 100644 --- a/src/components/Search.res +++ b/src/components/Search.res @@ -3,9 +3,32 @@ type state = Active | Inactive let unavailableText = "Search unavailable" let unavailableLabel = "Search unavailable for this build" -let navigator: DocSearch.navigator = { +let toRelativeSiteUrl = (url: string, ~siteUrl: string): string => { + let normalizedSiteUrl = siteUrl->String.replaceRegExp(RegExp.fromString("/+$", ~flags=""), "") + if String.startsWith(url, normalizedSiteUrl) { + let relativePath = String.slice(url, ~start=String.length(normalizedSiteUrl)) + if relativePath === "" { + "/" + } else if String.startsWith(relativePath, "/") { + relativePath + } else { + "/" ++ relativePath + } + } else { + url + } +} + +let normalizeHitUrls = (items: array, ~siteUrl: string) => + items->Array.map(hit => { + let url = toRelativeSiteUrl(hit.url, ~siteUrl) + let url_without_anchor = toRelativeSiteUrl(hit.url_without_anchor, ~siteUrl) + {...hit, url, url_without_anchor} + }) + +let navigator = (~siteUrl: string): DocSearch.navigator => { navigate: ({itemUrl}) => { - ReactRouter.navigate(itemUrl) + ReactRouter.navigate(toRelativeSiteUrl(itemUrl, ~siteUrl)) }, } @@ -181,7 +204,8 @@ let make = () => { apiKey=searchApiKey appId indexName - navigator + navigator={navigator(~siteUrl=Env.root_url)} + transformItems={items => normalizeHitUrls(items, ~siteUrl=Env.root_url)} hitComponent onClose initialScrollY={window.scrollY->Float.toInt} From 47a4ad6097ed6fc014c6d3cca4b9ffa496d6a0b8 Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Sat, 25 Apr 2026 13:34:52 -0400 Subject: [PATCH 17/32] refactor: move algolia env check to rescript Replace the hand-written JS Algolia env status script and its Node test with ReScript modules and a ReScript test. Run the check from the compiled _scripts output so the build uses the same script pipeline as the rest of the repo. --- .gitignore | 1 + __tests__/AlgoliaEnvStatus_.test.res | 19 +++++++++++++++ package.json | 6 ++--- scripts/LogAlgoliaEnvStatus.res | 19 +++++++++++++++ .../__tests__/log_algolia_env_status.test.mjs | 24 ------------------- scripts/log_algolia_env_status.mjs | 23 ------------------ src/common/AlgoliaEnvStatus.res | 12 ++++++++++ 7 files changed, 54 insertions(+), 50 deletions(-) create mode 100644 __tests__/AlgoliaEnvStatus_.test.res create mode 100644 scripts/LogAlgoliaEnvStatus.res delete mode 100644 scripts/__tests__/log_algolia_env_status.test.mjs delete mode 100644 scripts/log_algolia_env_status.mjs create mode 100644 src/common/AlgoliaEnvStatus.res diff --git a/.gitignore b/.gitignore index 4e1e38164..b01b1b5aa 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ scripts/gendocs.mjs scripts/generate_*.mjs scripts/gendocs.jsx scripts/generate_*.jsx +scripts/LogAlgoliaEnvStatus.jsx # Generated via generate-llms script public/llms/manual/**/llm*.txt diff --git a/__tests__/AlgoliaEnvStatus_.test.res b/__tests__/AlgoliaEnvStatus_.test.res new file mode 100644 index 000000000..7c5784ae6 --- /dev/null +++ b/__tests__/AlgoliaEnvStatus_.test.res @@ -0,0 +1,19 @@ +open Vitest + +test("reports missing public vars in declaration order", async () => { + let env = Dict.fromArray([ + ("VITE_ALGOLIA_APP_ID", ""), + ("VITE_ALGOLIA_INDEX_NAME", "dev_rescript_lang"), + ]) + + expect(AlgoliaEnvStatus.getMissingPublicAlgoliaVars(~env))->toEqual([ + "VITE_ALGOLIA_APP_ID", + "VITE_ALGOLIA_SEARCH_API_KEY", + ]) +}) + +test("formats the disabled search warning", async () => { + expect(AlgoliaEnvStatus.formatDisabledMessage(["VITE_ALGOLIA_APP_ID"]))->toBe( + "Algolia search disabled: missing VITE_ALGOLIA_APP_ID", + ) +}) diff --git a/package.json b/package.json index 9684327aa..100235bf6 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,8 @@ "build:search-index": "node --env-file-if-exists=.env --env-file-if-exists=.env.local _scripts/generate_search_index.mjs", "build:update-index": "yarn build:generate-llms && node _scripts/generate_feed.mjs > public/blog/feed.xml && yarn build:search-index", "build:vite": "react-router build", - "check:algolia-public-env": "node scripts/log_algolia_env_status.mjs", - "build": "yarn check:algolia-public-env && yarn build:res && yarn build:scripts && yarn build:update-index && yarn build:vite", + "check:algolia-public-env": "node _scripts/LogAlgoliaEnvStatus.mjs", + "build": "yarn build:res && yarn build:scripts && yarn check:algolia-public-env && yarn build:update-index && yarn build:vite", "ci:format": "oxfmt --check", "ci:test": "yarn vitest --run --browser.headless", "clean:res": "rescript clean", @@ -24,7 +24,7 @@ "dev:wrangler": "yarn wrangler pages dev build/client", "dev": "yarn prepare && yarn dev:res & yarn dev:vite & yarn dev:wrangler", "format": "oxfmt && rescript format", - "prepare": "yarn check:algolia-public-env && yarn build:res && yarn build:scripts && yarn build:update-index", + "prepare": "yarn build:res && yarn build:scripts && yarn check:algolia-public-env && yarn build:update-index", "preview": "yarn build && static-server build/client", "reanalyze": "rescript-tools reanalyze -all-cmt .", "test": "node scripts/test.mjs", diff --git a/scripts/LogAlgoliaEnvStatus.res b/scripts/LogAlgoliaEnvStatus.res new file mode 100644 index 000000000..887c08825 --- /dev/null +++ b/scripts/LogAlgoliaEnvStatus.res @@ -0,0 +1,19 @@ +@val @scope(("import", "meta")) external url: string = "url" + +let run = () => { + let missing = AlgoliaEnvStatus.getMissingPublicAlgoliaVars(~env=Node.Process.env) + if Array.length(missing) > 0 { + Console.warn(AlgoliaEnvStatus.formatDisabledMessage(missing)) + } +} + +let isMainModule = () => + switch Node.Process.argv[1] { + | Some(entrypoint) => + Node.URL.fileURLToPath(url) === Node.Path.resolve(Node.Process.cwd(), entrypoint) + | None => false + } + +let _ = if isMainModule() { + run() +} diff --git a/scripts/__tests__/log_algolia_env_status.test.mjs b/scripts/__tests__/log_algolia_env_status.test.mjs deleted file mode 100644 index cbb4fe82f..000000000 --- a/scripts/__tests__/log_algolia_env_status.test.mjs +++ /dev/null @@ -1,24 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { - formatDisabledMessage, - getMissingPublicAlgoliaVars, -} from "../log_algolia_env_status.mjs"; - -test("reports missing public vars in declaration order", () => { - assert.deepEqual( - getMissingPublicAlgoliaVars({ - VITE_ALGOLIA_APP_ID: "", - VITE_ALGOLIA_INDEX_NAME: "dev_rescript_lang", - VITE_ALGOLIA_SEARCH_API_KEY: undefined, - }), - ["VITE_ALGOLIA_APP_ID", "VITE_ALGOLIA_SEARCH_API_KEY"], - ); -}); - -test("formats the disabled search warning", () => { - assert.equal( - formatDisabledMessage(["VITE_ALGOLIA_APP_ID"]), - "Algolia search disabled: missing VITE_ALGOLIA_APP_ID", - ); -}); diff --git a/scripts/log_algolia_env_status.mjs b/scripts/log_algolia_env_status.mjs deleted file mode 100644 index 3631bba05..000000000 --- a/scripts/log_algolia_env_status.mjs +++ /dev/null @@ -1,23 +0,0 @@ -const PUBLIC_KEYS = [ - "VITE_ALGOLIA_APP_ID", - "VITE_ALGOLIA_INDEX_NAME", - "VITE_ALGOLIA_SEARCH_API_KEY", -]; - -export function getMissingPublicAlgoliaVars(env = process.env) { - return PUBLIC_KEYS.filter((key) => { - const value = env[key]; - return value == null || value === ""; - }); -} - -export function formatDisabledMessage(missing) { - return `Algolia search disabled: missing ${missing.join(", ")}`; -} - -if (import.meta.url === `file://${process.argv[1]}`) { - const missing = getMissingPublicAlgoliaVars(); - if (missing.length > 0) { - console.warn(formatDisabledMessage(missing)); - } -} diff --git a/src/common/AlgoliaEnvStatus.res b/src/common/AlgoliaEnvStatus.res new file mode 100644 index 000000000..20c52ceca --- /dev/null +++ b/src/common/AlgoliaEnvStatus.res @@ -0,0 +1,12 @@ +let publicKeys = ["VITE_ALGOLIA_APP_ID", "VITE_ALGOLIA_INDEX_NAME", "VITE_ALGOLIA_SEARCH_API_KEY"] + +let getMissingPublicAlgoliaVars = (~env: Dict.t): array => + publicKeys->Array.filter(key => + switch env->Dict.get(key) { + | None | Some("") => true + | Some(_) => false + } + ) + +let formatDisabledMessage = (missing: array) => + `Algolia search disabled: missing ${missing->Array.join(", ")}` From 1f3b47ef92ec7a02798a7b7210298c02faf2807b Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Mon, 27 Apr 2026 11:27:58 -0400 Subject: [PATCH 18/32] fix: switch Algolia indexing to DocSearch crawler Remove the build-time Algolia write API pipeline and document the crawler-owned setup. Add crawler-visible DocSearch content markers, section-level lvl0 markers, stable heading IDs, and metadata so the site HTML is the index source. --- .github/workflows/deploy.yml | 34 +- README.md | 34 ++ __tests__/AlgoliaConfig_.test.res | 10 - __tests__/BlogArticle_.test.res | 26 + __tests__/DocsLayout_.test.res | 37 ++ __tests__/MarkdownComponents_.test.res | 28 + __tests__/Meta_.test.res | 19 + __tests__/SearchIndex_.test.res | 555 ------------------ __tests__/Search_.test.res | 9 + __tests__/SyntaxLookup_.test.res | 26 + ...s-multiple-spaces-into-single-hyphen-1.png | Bin 3973 -> 0 bytes app/routes/ApiDocs.res | 4 +- app/routes/ApiOverviewRoute.res | 2 +- app/routes/BlogArticle.res | 5 +- app/routes/DocsGuidelinesRoute.res | 2 +- app/routes/DocsManualRoute.res | 2 +- app/routes/DocsReactRoute.res | 2 +- app/routes/SyntaxLookup.res | 6 +- .../2026-04-27-docsearch-crawler-indexing.md | 273 +++++++++ package.json | 4 +- scripts/generate_search_index.res | 232 -------- src/bindings/Algolia.res | 54 -- src/bindings/Env.res | 3 +- src/bindings/Mdast.res | 6 + src/common/AlgoliaConfig.res | 32 - src/common/Constants.res | 5 + src/common/SearchIndex.res | 522 ---------------- src/common/SearchIndex.resi | 86 --- src/common/Url.res | 12 + src/common/Url.resi | 6 + src/components/Markdown.res | 4 +- src/components/Markdown.resi | 2 +- src/components/MarkdownComponents.res | 2 +- src/components/Meta.res | 2 + src/components/Search.res | 2 +- src/layouts/CommunityLayout.res | 1 + src/layouts/DocsLayout.res | 5 +- src/layouts/SidebarLayout.res | 10 +- src/layouts/SidebarLayout.resi | 1 + src/markdown/Mdx.res | 17 +- src/markdown/TocUtils.res | 22 +- yarn.lock | 177 ------ 42 files changed, 547 insertions(+), 1734 deletions(-) create mode 100644 __tests__/Meta_.test.res delete mode 100644 __tests__/SearchIndex_.test.res delete mode 100644 __tests__/__screenshots__/SearchIndex_.test.jsx/slugify-collapses-multiple-spaces-into-single-hyphen-1.png create mode 100644 docs/superpowers/plans/2026-04-27-docsearch-crawler-indexing.md delete mode 100644 scripts/generate_search_index.res delete mode 100644 src/bindings/Algolia.res delete mode 100644 src/common/SearchIndex.res delete mode 100644 src/common/SearchIndex.resi diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 2b8c29b1a..618d46755 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -53,35 +53,13 @@ jobs: - name: Set Algolia env shell: bash env: - ALGOLIA_APP_ID: ${{ vars.ALGOLIA_APP_ID }} - ALGOLIA_INDEX_BASENAME: ${{ vars.ALGOLIA_INDEX_BASENAME }} - ALGOLIA_SEARCH_API_KEY_DEV: ${{ vars.ALGOLIA_SEARCH_API_KEY_DEV }} - ALGOLIA_SEARCH_API_KEY_PROD: ${{ vars.ALGOLIA_SEARCH_API_KEY_PROD }} - ALGOLIA_ADMIN_API_KEY_DEV: ${{ secrets.ALGOLIA_ADMIN_API_KEY_DEV }} - ALGOLIA_ADMIN_API_KEY_PROD: ${{ secrets.ALGOLIA_ADMIN_API_KEY_PROD }} + VITE_ALGOLIA_APP_ID: ${{ vars.VITE_ALGOLIA_APP_ID }} + VITE_ALGOLIA_INDEX_NAME: ${{ vars.VITE_ALGOLIA_INDEX_NAME }} + VITE_ALGOLIA_SEARCH_API_KEY: ${{ vars.VITE_ALGOLIA_SEARCH_API_KEY }} run: | - if [[ "${{ github.event_name }}" == "push" && "${{ github.ref_name }}" == "master" ]]; then - INDEX_PREFIX="prod" - SEARCH_KEY="$ALGOLIA_SEARCH_API_KEY_PROD" - ADMIN_KEY="$ALGOLIA_ADMIN_API_KEY_PROD" - elif [[ "${{ github.event_name }}" == "workflow_dispatch" && "${{ inputs.environment }}" == "production" ]]; then - INDEX_PREFIX="prod" - SEARCH_KEY="$ALGOLIA_SEARCH_API_KEY_PROD" - ADMIN_KEY="$ALGOLIA_ADMIN_API_KEY_PROD" - else - INDEX_PREFIX="dev" - SEARCH_KEY="$ALGOLIA_SEARCH_API_KEY_DEV" - ADMIN_KEY="$ALGOLIA_ADMIN_API_KEY_DEV" - fi - - INDEX_NAME="${INDEX_PREFIX}_${ALGOLIA_INDEX_BASENAME}" - - echo "VITE_ALGOLIA_APP_ID=$ALGOLIA_APP_ID" >> "$GITHUB_ENV" - echo "VITE_ALGOLIA_INDEX_NAME=$INDEX_NAME" >> "$GITHUB_ENV" - echo "VITE_ALGOLIA_SEARCH_API_KEY=$SEARCH_KEY" >> "$GITHUB_ENV" - echo "ALGOLIA_APP_ID=$ALGOLIA_APP_ID" >> "$GITHUB_ENV" - echo "ALGOLIA_INDEX_NAME=$INDEX_NAME" >> "$GITHUB_ENV" - echo "ALGOLIA_ADMIN_API_KEY=$ADMIN_KEY" >> "$GITHUB_ENV" + echo "VITE_ALGOLIA_APP_ID=$VITE_ALGOLIA_APP_ID" >> "$GITHUB_ENV" + echo "VITE_ALGOLIA_INDEX_NAME=$VITE_ALGOLIA_INDEX_NAME" >> "$GITHUB_ENV" + echo "VITE_ALGOLIA_SEARCH_API_KEY=$VITE_ALGOLIA_SEARCH_API_KEY" >> "$GITHUB_ENV" - name: Build run: yarn build env: diff --git a/README.md b/README.md index 243907764..34e3444d8 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,40 @@ yarn dev `yarn dev` prepares generated assets, starts the ReScript watcher, runs the React Router/Vite dev server, and serves the built client through Wrangler Pages. +## Search and DocSearch + +Search is powered by Algolia DocSearch. The DocSearch crawler owns indexing and index settings; site builds and deployments do not upload records, replace indexes, or use an Algolia admin/write key. + +The frontend only needs public DocSearch runtime variables: + +```sh +VITE_ALGOLIA_APP_ID="..." +VITE_ALGOLIA_INDEX_NAME="..." +VITE_ALGOLIA_SEARCH_API_KEY="..." +``` + +DocSearch crawl quality comes from the generated HTML. Searchable page bodies use `DocSearch-content`, each crawlable section provides a hidden `DocSearch-lvl0` marker such as `Manual`, `API`, `React`, `Syntax Lookup`, `Community`, or `Blog`, and headings own unique `id` attributes for section links. + +The crawler configuration should use selectors shaped like this: + +```js +recordProps: { + lvl0: { + selectors: ".DocSearch-lvl0", + defaultValue: "Documentation", + }, + lvl1: [".DocSearch-content h1", "main h1", "h1", "head > title"], + lvl2: [".DocSearch-content h2", "main h2", "h2"], + lvl3: [".DocSearch-content h3", "main h3", "h3"], + lvl4: [".DocSearch-content h4", "main h4", "h4"], + lvl5: [".DocSearch-content h5", "main h5", "h5"], + lvl6: [".DocSearch-content h6", "main h6", "h6"], + content: [".DocSearch-content p, .DocSearch-content li"], +} +``` + +Production crawler start URLs, sitemap settings, ranking, and crawler schedules live in the Algolia dashboard. + ## Project Structure Overview - `app/`: React Router app shell, layouts, route definitions, and route modules diff --git a/__tests__/AlgoliaConfig_.test.res b/__tests__/AlgoliaConfig_.test.res index 8a826bb22..35f416c65 100644 --- a/__tests__/AlgoliaConfig_.test.res +++ b/__tests__/AlgoliaConfig_.test.res @@ -25,13 +25,3 @@ test("publicConfigFrom reports missing public vars in declaration order", async expect(result)->toEqual(["VITE_ALGOLIA_APP_ID", "VITE_ALGOLIA_SEARCH_API_KEY"]) }) - -test("publisherConfigFrom reports missing publisher vars in declaration order", async () => { - let result = AlgoliaConfig.missingPublisherVars( - ~appId=Some("app_123"), - ~indexName=None, - ~adminApiKey=None, - ) - - expect(result)->toEqual(["ALGOLIA_INDEX_NAME", "ALGOLIA_ADMIN_API_KEY"]) -}) diff --git a/__tests__/BlogArticle_.test.res b/__tests__/BlogArticle_.test.res index c71e37459..7c6507efd 100644 --- a/__tests__/BlogArticle_.test.res +++ b/__tests__/BlogArticle_.test.res @@ -1,6 +1,8 @@ open ReactRouter open Vitest +@get external textContent: WebAPI.DOMAPI.element => string = "textContent" + let mockAuthor: BlogFrontmatter.author = { username: "test-author", fullname: "Test Author", @@ -46,6 +48,30 @@ test("desktop blog article renders header, author, date, and body", async () => await element(wrapper)->toMatchScreenshot("desktop-blog-article") }) +test("blog article marks body content for DocSearch crawling", async () => { + await viewport(1440, 900) + + let _screen = await render( + + +

{React.string("This is the blog post body content for testing.")}

+
+
, + ) + + switch document->WebAPI.Document.querySelector("article.DocSearch-content") { + | Value(_) => () + | Null => failwith("expected blog article body to be marked as DocSearch content") + } + + let lvl0 = switch document->WebAPI.Document.querySelector("article .DocSearch-lvl0") { + | Value(element) => element + | Null => failwith("expected blog article to render a DocSearch lvl0 marker") + } + + expect(lvl0->textContent)->toBe("Blog") +}) + test("desktop archived blog article shows warning banner", async () => { await viewport(1440, 900) diff --git a/__tests__/DocsLayout_.test.res b/__tests__/DocsLayout_.test.res index bbd06523c..f8756339f 100644 --- a/__tests__/DocsLayout_.test.res +++ b/__tests__/DocsLayout_.test.res @@ -1,6 +1,8 @@ open ReactRouter open Vitest +@get external textContent: WebAPI.DOMAPI.element => string = "textContent" + let mockCategories: array = [ { name: "Overview", @@ -57,6 +59,41 @@ test("desktop docs layout shows sidebar with categories", async () => { await element(wrapper)->toMatchScreenshot("desktop-docs-layout") }) +test("docs layout marks the textual content for DocSearch crawling", async () => { + await viewport(1440, 900) + + let screen = await render( + + +
{React.string("This is the documentation content.")}
+
+
, + ) + + let _mainContent = await screen->getByTestId("side-layout-children") + + let mainContent = switch document->WebAPI.Document.querySelector( + "[data-testid='side-layout-children']", + ) { + | Value(element) => element + | Null => failwith("expected docs layout main content") + } + + let className = switch mainContent->WebAPI.Element.getAttribute("class") { + | Value(value) => value + | Null => "" + } + + expect(className->String.includes("DocSearch-content"))->toBe(true) + + let lvl0 = switch document->WebAPI.Document.querySelector(".DocSearch-lvl0") { + | Value(element) => element + | Null => failwith("expected docs layout to render a DocSearch lvl0 marker") + } + + expect(lvl0->textContent)->toBe("Manual") +}) + test("desktop docs layout shows table of contents entries", async () => { await viewport(1440, 900) diff --git a/__tests__/MarkdownComponents_.test.res b/__tests__/MarkdownComponents_.test.res index 62ffc75c1..c88dded7c 100644 --- a/__tests__/MarkdownComponents_.test.res +++ b/__tests__/MarkdownComponents_.test.res @@ -21,6 +21,34 @@ test("renders headings h1 through h5", async () => { await element(wrapper)->toMatchScreenshot("markdown-headings") }) +test("h1 keeps the generated markdown id for DocSearch", async () => { + await viewport(1440, 900) + + let _screen = await render( +
+ {React.string("Heading Level 1")} +
, + ) + + switch document->WebAPI.Document.querySelector("h1#heading-level-1") { + | Value(_) => () + | Null => failwith("expected markdown h1 to keep the generated id") + } +}) + +test("heading anchor links do not duplicate heading ids", async () => { + await viewport(1440, 900) + + let _screen = await render( +
+ {React.string("Duplicate Check")} +
, + ) + + let matches = document->WebAPI.Document.querySelectorAll("[id='duplicate-check']") + expect(matches.length)->toBe(1) +}) + test("renders paragraph, strong, and intro", async () => { await viewport(1440, 900) diff --git a/__tests__/Meta_.test.res b/__tests__/Meta_.test.res new file mode 100644 index 000000000..21d063495 --- /dev/null +++ b/__tests__/Meta_.test.res @@ -0,0 +1,19 @@ +open Vitest + +let getMetaContent = name => { + switch document->WebAPI.Document.querySelector(`meta[name='${name}']`) { + | Value(element) => + switch element->WebAPI.Element.getAttribute("content") { + | Value(content) => content + | Null => failwith(`expected ${name} meta tag to have content`) + } + | Null => failwith(`expected ${name} meta tag`) + } +} + +test("renders DocSearch crawler meta tags", async () => { + let _screen = await render() + + expect(getMetaContent("docsearch:language"))->toBe("en") + expect(getMetaContent("docsearch:version"))->toBe("v12,latest") +}) diff --git a/__tests__/SearchIndex_.test.res b/__tests__/SearchIndex_.test.res deleted file mode 100644 index 0504f980f..000000000 --- a/__tests__/SearchIndex_.test.res +++ /dev/null @@ -1,555 +0,0 @@ -open Vitest - -// --------------------------------------------------------------------------- -// maxContentLength -// --------------------------------------------------------------------------- - -test("maxContentLength is 500", async () => { - expect(SearchIndex.maxContentLength)->toBe(500) -}) - -// --------------------------------------------------------------------------- -// truncate -// --------------------------------------------------------------------------- - -test("truncate returns string as-is when shorter than maxLen", async () => { - expect(SearchIndex.truncate("hello", ~maxLen=10))->toBe("hello") -}) - -test("truncate returns string as-is when exactly maxLen", async () => { - expect(SearchIndex.truncate("hello", ~maxLen=5))->toBe("hello") -}) - -test("truncate truncates and adds ellipsis when longer than maxLen", async () => { - expect(SearchIndex.truncate("hello world", ~maxLen=5))->toBe("hello...") -}) - -test("truncate handles empty string", async () => { - expect(SearchIndex.truncate("", ~maxLen=5))->toBe("") -}) - -test("truncate handles maxLen=0 with ellipsis", async () => { - expect(SearchIndex.truncate("abc", ~maxLen=0))->toBe("...") -}) - -test("truncate truncates to single character with ellipsis", async () => { - expect(SearchIndex.truncate("abcdef", ~maxLen=1))->toBe("a...") -}) - -// --------------------------------------------------------------------------- -// slugify -// --------------------------------------------------------------------------- - -test("slugify lowercases text", async () => { - expect(SearchIndex.slugify("Hello World"))->toBe("hello-world") -}) - -test("slugify replaces spaces with hyphens", async () => { - expect(SearchIndex.slugify("foo bar baz"))->toBe("foo-bar-baz") -}) - -test("slugify removes non-alphanumeric characters", async () => { - expect(SearchIndex.slugify("Hello, World!"))->toBe("hello-world") -}) - -test("slugify collapses multiple spaces into a single hyphen", async () => { - expect(SearchIndex.slugify("foo bar"))->toBe("foo-bar") -}) - -test("slugify handles empty string", async () => { - expect(SearchIndex.slugify(""))->toBe("") -}) - -test("slugify preserves numbers", async () => { - expect(SearchIndex.slugify("Section 42"))->toBe("section-42") -}) - -test("slugify removes special characters like parentheses and dots", async () => { - expect(SearchIndex.slugify("Array.map()"))->toBe("arraymap") -}) - -test("slugify handles already-slugified text", async () => { - expect(SearchIndex.slugify("already-slugified"))->toBe("already-slugified") -}) - -// --------------------------------------------------------------------------- -// stripMdxTags -// --------------------------------------------------------------------------- - -test("stripMdxTags removes CodeTab blocks", async () => { - let input = "before\n\nsome code\n\nafter" - expect(SearchIndex.stripMdxTags(input))->toBe("before\nafter") -}) - -test("stripMdxTags removes HTML tags", async () => { - expect(SearchIndex.stripMdxTags("
hello
"))->toBe("hello") -}) - -test("stripMdxTags removes fenced code blocks", async () => { - let input = "before\n```rescript\nlet x = 1\n```\nafter" - expect(SearchIndex.stripMdxTags(input))->toBe("before\nafter") -}) - -test("stripMdxTags strips inline code backticks", async () => { - expect(SearchIndex.stripMdxTags("use `Array.map` here"))->toBe("use Array.map here") -}) - -test("stripMdxTags strips bold markers", async () => { - expect(SearchIndex.stripMdxTags("this is **bold** text"))->toBe("this is bold text") -}) - -test("stripMdxTags strips italic markers", async () => { - expect(SearchIndex.stripMdxTags("this is *italic* text"))->toBe("this is italic text") -}) - -test("stripMdxTags strips markdown links while keeping link text", async () => { - expect(SearchIndex.stripMdxTags("click [here](https://example.com) now"))->toBe("click here now") -}) - -test("stripMdxTags removes heading markers", async () => { - expect(SearchIndex.stripMdxTags("## My Heading"))->toBe("My Heading") -}) - -test("stripMdxTags removes h1 through h6 markers", async () => { - let input = "# H1\n## H2\n### H3\n#### H4\n##### H5\n###### H6" - expect(SearchIndex.stripMdxTags(input))->toBe("H1\nH2\nH3\nH4\nH5\nH6") -}) - -test("stripMdxTags collapses multiple newlines to a single newline", async () => { - expect(SearchIndex.stripMdxTags("a\n\n\nb"))->toBe("a\nb") -}) - -test("stripMdxTags handles empty string", async () => { - expect(SearchIndex.stripMdxTags(""))->toBe("") -}) - -test("stripMdxTags handles combined markdown formatting", async () => { - let input = "Use **`Array.map`** to [transform](http://x.com) items." - let result = SearchIndex.stripMdxTags(input) - expect(result)->toBe("Use Array.map to transform items.") -}) - -// --------------------------------------------------------------------------- -// cleanDocstring -// --------------------------------------------------------------------------- - -test("cleanDocstring returns simple text as-is", async () => { - expect(SearchIndex.cleanDocstring("Simple description"))->toBe("Simple description") -}) - -test("cleanDocstring takes content before first ## heading", async () => { - let input = "Intro text\n## Details\nMore info" - expect(SearchIndex.cleanDocstring(input))->toBe("Intro text") -}) - -test("cleanDocstring takes content before first code block", async () => { - let input = "Intro text\n```rescript\nlet x = 1\n```" - expect(SearchIndex.cleanDocstring(input))->toBe("Intro text") -}) - -test("cleanDocstring strips inline code backticks", async () => { - expect(SearchIndex.cleanDocstring("Returns `true` or `false`"))->toBe("Returns true or false") -}) - -test("cleanDocstring strips bold formatting", async () => { - expect(SearchIndex.cleanDocstring("This is **important**"))->toBe("This is important") -}) - -test("cleanDocstring strips italic formatting", async () => { - expect(SearchIndex.cleanDocstring("This is *emphasized*"))->toBe("This is emphasized") -}) - -test("cleanDocstring strips markdown links", async () => { - expect(SearchIndex.cleanDocstring("See [docs](http://example.com)"))->toBe("See docs") -}) - -test("cleanDocstring collapses multiple newlines to spaces", async () => { - let input = "line one\n\nline two\n\nline three" - expect(SearchIndex.cleanDocstring(input))->toBe("line one line two line three") -}) - -test("cleanDocstring replaces single newlines with spaces", async () => { - let input = "line one\nline two" - expect(SearchIndex.cleanDocstring(input))->toBe("line one line two") -}) - -test("cleanDocstring handles empty string", async () => { - expect(SearchIndex.cleanDocstring(""))->toBe("") -}) - -test("cleanDocstring lets headings take priority over code blocks", async () => { - let input = "Intro\n## Section\nText\n```\ncode\n```" - expect(SearchIndex.cleanDocstring(input))->toBe("Intro") -}) - -// --------------------------------------------------------------------------- -// extractIntro -// --------------------------------------------------------------------------- - -test("extractIntro extracts text before first ## heading", async () => { - let input = "Some intro text.\n## First Section\nDetails here." - let result = SearchIndex.extractIntro(input) - expect(result)->toBe("Some intro text.") -}) - -test("extractIntro removes an H1 heading at the start", async () => { - let input = "# Page Title\nIntro paragraph.\n## Section" - let result = SearchIndex.extractIntro(input) - expect(result)->toBe("Intro paragraph.") -}) - -test("extractIntro returns stripped content when there are no headings", async () => { - let input = "Just some plain text content." - expect(SearchIndex.extractIntro(input))->toBe("Just some plain text content.") -}) - -test("extractIntro handles empty string", async () => { - expect(SearchIndex.extractIntro(""))->toBe("") -}) - -test("extractIntro strips MDX tags from the intro", async () => { - let input = "Use **bold** and `code`.\n## Section" - expect(SearchIndex.extractIntro(input))->toBe("Use bold and code.") -}) - -test("extractIntro removes H1 but preserves the rest of the content", async () => { - let input = "# Title\nFirst paragraph.\nSecond paragraph." - expect(SearchIndex.extractIntro(input))->toBe("First paragraph.\nSecond paragraph.") -}) - -// --------------------------------------------------------------------------- -// extractHeadings -// --------------------------------------------------------------------------- - -test("extractHeadings extracts h2 headings", async () => { - let input = "Intro\n## First\nContent one.\n## Second\nContent two." - let headings = SearchIndex.extractHeadings(input) - expect(Array.length(headings))->toBe(2) - expect(headings[0]->Option.map(h => h.level))->toEqual(Some(2)) - expect(headings[0]->Option.map(h => h.text))->toEqual(Some("First")) - expect(headings[1]->Option.map(h => h.text))->toEqual(Some("Second")) -}) - -test("extractHeadings extracts h3 headings", async () => { - let input = "## Parent\n### Child\nSub content." - let headings = SearchIndex.extractHeadings(input) - expect(headings[0]->Option.map(h => h.level))->toEqual(Some(2)) - expect(headings[1]->Option.map(h => h.level))->toEqual(Some(3)) - expect(headings[1]->Option.map(h => h.text))->toEqual(Some("Child")) -}) - -test("extractHeadings does not extract h1 headings", async () => { - let input = "# Title\nSome text\n## Real Heading\nContent." - let headings = SearchIndex.extractHeadings(input) - expect(Array.length(headings))->toBe(1) - expect(headings[0]->Option.map(h => h.text))->toEqual(Some("Real Heading")) -}) - -test("extractHeadings returns an empty array when there are no headings", async () => { - let input = "Just plain text with no headings." - let headings = SearchIndex.extractHeadings(input) - expect(Array.length(headings))->toBe(0) -}) - -test("extractHeadings includes section content between headings", async () => { - let input = "## Heading\nThis is the content of the section." - let headings = SearchIndex.extractHeadings(input) - expect(headings[0]->Option.map(h => h.content))->toEqual( - Some("This is the content of the section."), - ) -}) - -test("extractHeadings strips MDX tags from section content", async () => { - let input = "## Heading\nUse **bold** and `code` here." - let headings = SearchIndex.extractHeadings(input) - expect(headings[0]->Option.map(h => h.content))->toEqual(Some("Use bold and code here.")) -}) - -test("extractHeadings truncates section content to maxContentLength", async () => { - let longContent = String.repeat("a", 600) - let input = "## Heading\n" ++ longContent - let headings = SearchIndex.extractHeadings(input) - let contentLen = headings[0]->Option.map(h => String.length(h.content))->Option.getOr(0) - // 500 chars + "..." = 503 - expect(contentLen)->toBe(503) -}) - -test("extractHeadings handles multiple heading levels", async () => { - let input = "## H2\nA\n### H3\nB\n#### H4\nC\n##### H5\nD\n###### H6\nE" - let headings = SearchIndex.extractHeadings(input) - expect(Array.length(headings))->toBe(5) - expect(headings[0]->Option.map(h => h.level))->toEqual(Some(2)) - expect(headings[1]->Option.map(h => h.level))->toEqual(Some(3)) - expect(headings[2]->Option.map(h => h.level))->toEqual(Some(4)) - expect(headings[3]->Option.map(h => h.level))->toEqual(Some(5)) - expect(headings[4]->Option.map(h => h.level))->toEqual(Some(6)) -}) - -// --------------------------------------------------------------------------- -// makeHierarchy -// --------------------------------------------------------------------------- - -test("makeHierarchy creates a hierarchy with only required fields", async () => { - let h = SearchIndex.makeHierarchy(~lvl0="Docs", ~lvl1="Overview", ()) - expect(h.lvl0)->toBe("Docs") - expect(h.lvl1)->toBe("Overview") - expect(h.lvl2)->toEqual(None) - expect(h.lvl3)->toEqual(None) - expect(h.lvl4)->toEqual(None) - expect(h.lvl5)->toEqual(None) - expect(h.lvl6)->toEqual(None) -}) - -test("makeHierarchy creates a hierarchy with all optional fields", async () => { - let h = SearchIndex.makeHierarchy( - ~lvl0="Docs", - ~lvl1="Guide", - ~lvl2="Chapter", - ~lvl3="Section", - ~lvl4="Sub A", - ~lvl5="Sub B", - ~lvl6="Sub C", - (), - ) - expect(h.lvl0)->toBe("Docs") - expect(h.lvl1)->toBe("Guide") - expect(h.lvl2)->toEqual(Some("Chapter")) - expect(h.lvl3)->toEqual(Some("Section")) - expect(h.lvl4)->toEqual(Some("Sub A")) - expect(h.lvl5)->toEqual(Some("Sub B")) - expect(h.lvl6)->toEqual(Some("Sub C")) -}) - -test("makeHierarchy creates a hierarchy with partial optional fields", async () => { - let h = SearchIndex.makeHierarchy(~lvl0="API", ~lvl1="Array", ~lvl2="map", ()) - expect(h.lvl2)->toEqual(Some("map")) - expect(h.lvl3)->toEqual(None) -}) - -// --------------------------------------------------------------------------- -// optionToJson -// --------------------------------------------------------------------------- - -test("optionToJson converts Some to a JSON string", async () => { - expect(SearchIndex.optionToJson(Some("hello")))->toEqual(JSON.String("hello")) -}) - -test("optionToJson converts None to JSON null", async () => { - expect(SearchIndex.optionToJson(None))->toEqual(JSON.Null) -}) - -test("optionToJson converts Some empty string to a JSON string", async () => { - expect(SearchIndex.optionToJson(Some("")))->toEqual(JSON.String("")) -}) - -// --------------------------------------------------------------------------- -// hierarchyToJson -// --------------------------------------------------------------------------- - -test("hierarchyToJson serializes a hierarchy with only required fields", async () => { - let h = SearchIndex.makeHierarchy(~lvl0="Docs", ~lvl1="Page", ()) - let json = SearchIndex.hierarchyToJson(h) - let expected = { - let d = Dict.make() - d->Dict.set("lvl0", JSON.String("Docs")) - d->Dict.set("lvl1", JSON.String("Page")) - d->Dict.set("lvl2", JSON.Null) - d->Dict.set("lvl3", JSON.Null) - d->Dict.set("lvl4", JSON.Null) - d->Dict.set("lvl5", JSON.Null) - d->Dict.set("lvl6", JSON.Null) - JSON.Object(d) - } - expect(json)->toEqual(expected) -}) - -test("hierarchyToJson serializes optional fields as JSON strings", async () => { - let h = SearchIndex.makeHierarchy(~lvl0="API", ~lvl1="Array", ~lvl2="map", ()) - let json = SearchIndex.hierarchyToJson(h) - let expected = { - let d = Dict.make() - d->Dict.set("lvl0", JSON.String("API")) - d->Dict.set("lvl1", JSON.String("Array")) - d->Dict.set("lvl2", JSON.String("map")) - d->Dict.set("lvl3", JSON.Null) - d->Dict.set("lvl4", JSON.Null) - d->Dict.set("lvl5", JSON.Null) - d->Dict.set("lvl6", JSON.Null) - JSON.Object(d) - } - expect(json)->toEqual(expected) -}) - -// --------------------------------------------------------------------------- -// weightToJson -// --------------------------------------------------------------------------- - -test("weightToJson serializes weight to a JSON object with number values", async () => { - let w: SearchIndex.weight = {pageRank: 10, level: 80, position: 3} - let json = SearchIndex.weightToJson(w) - let expected = { - let d = Dict.make() - d->Dict.set("pageRank", JSON.Number(10.0)) - d->Dict.set("level", JSON.Number(80.0)) - d->Dict.set("position", JSON.Number(3.0)) - JSON.Object(d) - } - expect(json)->toEqual(expected) -}) - -test("weightToJson serializes zero values correctly", async () => { - let w: SearchIndex.weight = {pageRank: 0, level: 0, position: 0} - let json = SearchIndex.weightToJson(w) - let expected = { - let d = Dict.make() - d->Dict.set("pageRank", JSON.Number(0.0)) - d->Dict.set("level", JSON.Number(0.0)) - d->Dict.set("position", JSON.Number(0.0)) - JSON.Object(d) - } - expect(json)->toEqual(expected) -}) - -// --------------------------------------------------------------------------- -// withBaseUrl -// --------------------------------------------------------------------------- - -test("withBaseUrl prepends the site URL to relative record URLs", async () => { - let record: SearchIndex.record = { - objectID: "docs/manual/introduction", - url: "/docs/manual/introduction#what-is-rescript", - url_without_anchor: "/docs/manual/introduction", - anchor: Some("what-is-rescript"), - content: Some("Intro"), - type_: "lvl2", - hierarchy: SearchIndex.makeHierarchy(~lvl0="Docs", ~lvl1="Introduction", ()), - weight: {pageRank: 5, level: 80, position: 1}, - } - - let result = SearchIndex.withBaseUrl(record, ~siteUrl="https://rescript-lang.org") - - expect(result.url)->toBe("https://rescript-lang.org/docs/manual/introduction#what-is-rescript") - expect(result.url_without_anchor)->toBe("https://rescript-lang.org/docs/manual/introduction") -}) - -test("withBaseUrl avoids double slashes when the site URL ends with /", async () => { - let record: SearchIndex.record = { - objectID: "docs/manual/api", - url: "/docs/manual/api", - url_without_anchor: "/docs/manual/api", - anchor: None, - content: None, - type_: "lvl1", - hierarchy: SearchIndex.makeHierarchy(~lvl0="Docs", ~lvl1="API", ()), - weight: {pageRank: 5, level: 100, position: 0}, - } - - let result = SearchIndex.withBaseUrl(record, ~siteUrl="https://rescript-lang.org/") - - expect(result.url)->toBe("https://rescript-lang.org/docs/manual/api") - expect(result.url_without_anchor)->toBe("https://rescript-lang.org/docs/manual/api") -}) - -// --------------------------------------------------------------------------- -// toJson -// --------------------------------------------------------------------------- - -test("toJson serializes a full record with all fields", async () => { - let r: SearchIndex.record = { - objectID: "docs/overview", - url: "/docs/overview#intro", - url_without_anchor: "/docs/overview", - anchor: Some("intro"), - content: Some("Introduction text"), - type_: "lvl2", - hierarchy: SearchIndex.makeHierarchy(~lvl0="Docs", ~lvl1="Overview", ~lvl2="Intro", ()), - weight: {pageRank: 5, level: 80, position: 1}, - } - let json = SearchIndex.toJson(r) - - let expected = { - let d = Dict.make() - d->Dict.set("objectID", JSON.String("docs/overview")) - d->Dict.set("url", JSON.String("/docs/overview#intro")) - d->Dict.set("url_without_anchor", JSON.String("/docs/overview")) - d->Dict.set("anchor", JSON.String("intro")) - d->Dict.set("content", JSON.String("Introduction text")) - d->Dict.set("type", JSON.String("lvl2")) - d->Dict.set( - "hierarchy", - { - let hd = Dict.make() - hd->Dict.set("lvl0", JSON.String("Docs")) - hd->Dict.set("lvl1", JSON.String("Overview")) - hd->Dict.set("lvl2", JSON.String("Intro")) - hd->Dict.set("lvl3", JSON.Null) - hd->Dict.set("lvl4", JSON.Null) - hd->Dict.set("lvl5", JSON.Null) - hd->Dict.set("lvl6", JSON.Null) - JSON.Object(hd) - }, - ) - d->Dict.set( - "weight", - { - let wd = Dict.make() - wd->Dict.set("pageRank", JSON.Number(5.0)) - wd->Dict.set("level", JSON.Number(80.0)) - wd->Dict.set("position", JSON.Number(1.0)) - JSON.Object(wd) - }, - ) - JSON.Object(d) - } - expect(json)->toEqual(expected) -}) - -test("toJson serializes a record with None optional fields as null", async () => { - let r: SearchIndex.record = { - objectID: "page", - url: "/page", - url_without_anchor: "/page", - anchor: None, - content: None, - type_: "lvl1", - hierarchy: SearchIndex.makeHierarchy(~lvl0="Cat", ~lvl1="Page", ()), - weight: {pageRank: 1, level: 100, position: 0}, - } - let json = SearchIndex.toJson(r) - - let expected = { - let d = Dict.make() - d->Dict.set("objectID", JSON.String("page")) - d->Dict.set("url", JSON.String("/page")) - d->Dict.set("url_without_anchor", JSON.String("/page")) - d->Dict.set("anchor", JSON.Null) - d->Dict.set("content", JSON.Null) - d->Dict.set("type", JSON.String("lvl1")) - d->Dict.set( - "hierarchy", - { - let hd = Dict.make() - hd->Dict.set("lvl0", JSON.String("Cat")) - hd->Dict.set("lvl1", JSON.String("Page")) - hd->Dict.set("lvl2", JSON.Null) - hd->Dict.set("lvl3", JSON.Null) - hd->Dict.set("lvl4", JSON.Null) - hd->Dict.set("lvl5", JSON.Null) - hd->Dict.set("lvl6", JSON.Null) - JSON.Object(hd) - }, - ) - d->Dict.set( - "weight", - { - let wd = Dict.make() - wd->Dict.set("pageRank", JSON.Number(1.0)) - wd->Dict.set("level", JSON.Number(100.0)) - wd->Dict.set("position", JSON.Number(0.0)) - JSON.Object(wd) - }, - ) - JSON.Object(d) - } - expect(json)->toEqual(expected) -}) diff --git a/__tests__/Search_.test.res b/__tests__/Search_.test.res index fdb906e88..5ed014573 100644 --- a/__tests__/Search_.test.res +++ b/__tests__/Search_.test.res @@ -243,6 +243,15 @@ test("toRelativeSiteUrl strips the site origin from an absolute URL", async () = expect(result)->toBe("/docs/manual/introduction#what-is-rescript") }) +test("toRelativeSiteUrl leaves absolute URLs unchanged when siteUrl is empty", async () => { + let result = Search.toRelativeSiteUrl( + "https://rescript-lang.org/docs/manual/introduction#what-is-rescript", + ~siteUrl="", + ) + + expect(result)->toBe("https://rescript-lang.org/docs/manual/introduction#what-is-rescript") +}) + test("normalizeHitUrls rewrites absolute site URLs to relative paths", async () => { let hit = makeHit( ~type_=Lvl1, diff --git a/__tests__/SyntaxLookup_.test.res b/__tests__/SyntaxLookup_.test.res index 38b3bb864..6568b62c4 100644 --- a/__tests__/SyntaxLookup_.test.res +++ b/__tests__/SyntaxLookup_.test.res @@ -1,6 +1,8 @@ open ReactRouter open Vitest +@get external textContent: WebAPI.DOMAPI.element => string = "textContent" + let mockItems: array = [ { id: "as-decorator", @@ -87,6 +89,30 @@ test("desktop syntax lookup with active item shows detail box", async () => { await element(wrapper)->toMatchScreenshot("desktop-syntax-lookup-active") }) +test("syntax lookup detail marks active content for DocSearch crawling", async () => { + await viewport(1440, 900) + + let _screen = await render( + + Array.getUnsafe(0)}> +

{React.string("Detail content for @as decorator.")}

+
+
, + ) + + switch document->WebAPI.Document.querySelector(".DocSearch-content h1") { + | Value(heading) => expect(heading->textContent)->toBe("@as") + | Null => failwith("expected active syntax detail to provide a DocSearch heading") + } + + let lvl0 = switch document->WebAPI.Document.querySelector(".DocSearch-content .DocSearch-lvl0") { + | Value(element) => element + | Null => failwith("expected syntax detail to render a DocSearch lvl0 marker") + } + + expect(lvl0->textContent)->toBe("Syntax Lookup") +}) + test("mobile syntax lookup", async () => { await viewport(600, 1200) diff --git a/__tests__/__screenshots__/SearchIndex_.test.jsx/slugify-collapses-multiple-spaces-into-single-hyphen-1.png b/__tests__/__screenshots__/SearchIndex_.test.jsx/slugify-collapses-multiple-spaces-into-single-hyphen-1.png deleted file mode 100644 index a35891721ab1844b9f03a3d8781f386e165a52b4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3973 zcmeAS@N?(olHy`uVBq!ia0y~yU}<1rV7kD;1Qbc1GUosT1HYB0i(^Q|oHy4Uc^MRV z7&f*(xc@2O;NB?ly}PY}s{WL-F)%c=G%zwSxUldrFeoS`07V5EnHd;5I3ySt99)2g zFeotrRS5_h0F`!(Djf|0nuY){sY#8dx6u$74S~@R7!85Z5Eu=C(GVC7fzc2ch!9|F zXrpOcmtnNSHE2VCpMl~3f8WUhUBC`78>kn}%y2-7W5LsxcEBDA1B0ilpUXO@geCwk C-QUIl diff --git a/app/routes/ApiDocs.res b/app/routes/ApiDocs.res index d8b010fa6..cb8ff93b8 100644 --- a/app/routes/ApiDocs.res +++ b/app/routes/ApiDocs.res @@ -288,7 +288,7 @@ let make = (props: props) => { }) <> -

{name->React.string}

+

Url.normalizeAnchor}> {name->React.string}

{valuesAndType->React.array} @@ -333,7 +333,7 @@ let make = (props: props) => { } } - children + children } module Data = { diff --git a/app/routes/ApiOverviewRoute.res b/app/routes/ApiOverviewRoute.res index 36ce0d55d..58fd5cc47 100644 --- a/app/routes/ApiOverviewRoute.res +++ b/app/routes/ApiOverviewRoute.res @@ -56,7 +56,7 @@ let default = () => { }> - +
diff --git a/app/routes/BlogArticle.res b/app/routes/BlogArticle.res index 736dcd3d4..d2a343d08 100644 --- a/app/routes/BlogArticle.res +++ b/app/routes/BlogArticle.res @@ -142,7 +142,10 @@ let make = (props: props) => {
archivedNote -
children
+
+ {React.string("Blog")} + children +
diff --git a/app/routes/DocsGuidelinesRoute.res b/app/routes/DocsGuidelinesRoute.res index 235818836..124c1e703 100644 --- a/app/routes/DocsGuidelinesRoute.res +++ b/app/routes/DocsGuidelinesRoute.res @@ -49,7 +49,7 @@ let default = () => { {React.string("Edit")} - +
diff --git a/app/routes/DocsManualRoute.res b/app/routes/DocsManualRoute.res index 1a96977da..4fcbd8aa6 100644 --- a/app/routes/DocsManualRoute.res +++ b/app/routes/DocsManualRoute.res @@ -85,7 +85,7 @@ let default = () => { {React.string("Edit")} - +
diff --git a/app/routes/DocsReactRoute.res b/app/routes/DocsReactRoute.res index e70cb1d57..a1e046338 100644 --- a/app/routes/DocsReactRoute.res +++ b/app/routes/DocsReactRoute.res @@ -78,7 +78,7 @@ let default = () => { {React.string("Edit")} - +
diff --git a/app/routes/SyntaxLookup.res b/app/routes/SyntaxLookup.res index 2c60d52b2..fdddb1a60 100644 --- a/app/routes/SyntaxLookup.res +++ b/app/routes/SyntaxLookup.res @@ -310,7 +310,11 @@ let make = ( | ShowFiltered(_, _) | ShowAll => React.null | ShowDetails(item) => -
+
+ + {React.string("Syntax Lookup")} + +

{React.string(item.name)}

children
} diff --git a/docs/superpowers/plans/2026-04-27-docsearch-crawler-indexing.md b/docs/superpowers/plans/2026-04-27-docsearch-crawler-indexing.md new file mode 100644 index 000000000..d3449ddd8 --- /dev/null +++ b/docs/superpowers/plans/2026-04-27-docsearch-crawler-indexing.md @@ -0,0 +1,273 @@ +# DocSearch Crawler Indexing Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Convert PR #1231 from build-time Algolia write API indexing to DocSearch crawler-compatible static HTML while preserving the public DocSearch search UI. + +**Architecture:** Algolia owns indexing through the DocSearch crawler. The website build should generate only static HTML and existing static artifacts; it must not upload records or set index settings. Search runtime uses public DocSearch credentials, and crawl quality comes from stable `.DocSearch-content` containers, unique heading anchors, atomic paragraph/list content, docsearch meta tags, and crawler documentation. + +**Tech Stack:** ReScript v12, React 19, React Router v7 pre-rendering, MDX, `@docsearch/react` v4, Vitest browser mode with Playwright, Yarn 4. + +--- + +## Context + +PR: https://github.com/rescript-lang/rescript-lang.org/pull/1231 + +DocSearch requirement source: https://docsearch.algolia.com/docs/required-configuration/ + +DocSearch crawler requirements that matter for this repo: + +- Use a static `DocSearch-content` class on the main textual content container. +- Use heading selectors for `lvl1` through `lvl6`; every matched heading needs a unique `id` or `name`. +- Searchable body content should be in atomic `

` or `

  • ` elements. +- Optional `docsearch:*` meta tags can apply record attributes such as language and version. +- Sitemap coverage is recommended so the crawler can find updated pages. + +## Recommended Approach + +Use the crawler-compliant HTML approach. + +Alternative 1, minimal revert, would only delete the write API script and leave the current HTML alone. That avoids code work but leaves crawl quality accidental and keeps known heading-id problems. + +Alternative 2, crawler-compliant HTML, removes write credentials and actively shapes the rendered HTML for DocSearch. This is the recommended path because it matches the DocSearch plan constraints and keeps the improved UI independent from indexing. + +Alternative 3, keep a local generator for validation only, would retain most of `SearchIndex.res` but stop uploading. That creates two sources of truth for ranking and records, so it should be avoided unless we later need a diagnostics-only script. + +## File Map + +Remove write API publishing: + +- Delete `scripts/generate_search_index.res`. +- Delete `src/bindings/Algolia.res`. +- Delete `src/common/SearchIndex.res`. +- Delete `src/common/SearchIndex.resi`. +- Delete `__tests__/SearchIndex_.test.res`. +- Delete SearchIndex visual snapshots under `__tests__/__screenshots__/SearchIndex_.test.jsx/`. +- Modify `package.json` and `yarn.lock` to remove `algoliasearch` and the `build:search-index` command. +- Modify `.github/workflows/deploy.yml` to stop exporting private Algolia admin variables. +- Modify `.gitignore` only if deleted generated script patterns leave a one-off entry unnecessary. + +Keep public DocSearch runtime: + +- Keep `@docsearch/react`. +- Keep `src/bindings/DocSearch.res`. +- Keep `src/components/Search.res`. +- Keep public env handling in `src/bindings/Env.res`, but remove publisher-only config from `src/common/AlgoliaConfig.res`. +- Keep `scripts/LogAlgoliaEnvStatus.res` and `src/common/AlgoliaEnvStatus.res` if the public-config warning remains useful. + +Make HTML crawler-compatible: + +- Modify `src/layouts/SidebarLayout.res` so docs/community/API pages expose `DocSearch-content` on the central `
    `. +- Modify `app/routes/BlogArticle.res` so article body content is wrapped in an `
    `. +- Modify `app/routes/SyntaxLookup.res` so syntax detail content is crawlable without indexing the whole interactive picker. +- Modify `src/components/Markdown.res` to preserve H1 ids and remove duplicate ids from anchor-link icons. +- Modify `src/markdown/Mdx.res` to generate collision-free heading ids. +- Modify `src/markdown/TocUtils.res` if needed so sidebar links match collision-free rendered heading ids. +- Modify `app/routes/ApiDocs.res` so programmatic API H1/H2 headings have unique ids that match crawl targets. +- Modify `src/components/Meta.res` to emit DocSearch language/version meta tags from existing version constants. + +Add or adjust tests: + +- Add `__tests__/DocSearchCrawlerMarkup_.test.res` or extend existing layout tests. +- Update `__tests__/AlgoliaConfig_.test.res` to remove publisher-only expectations. +- Update `__tests__/AlgoliaEnvStatus_.test.res` if public env names change. +- Update `__tests__/Search_.test.res` for same-origin absolute crawler URLs and empty `siteUrl` behavior. +- Update or remove generated screenshots only after explicit snapshot approval. + +## Implementation Tasks + +### Task 1: Add crawler-markup regression coverage + +**Files:** + +- Modify: `__tests__/DocsLayout_.test.res` +- Modify: `__tests__/MarkdownComponents_.test.res` or add `__tests__/DocSearchCrawlerMarkup_.test.res` +- Modify: `__tests__/Search_.test.res` + +- [ ] Add a test that renders `` and asserts the central main element has `DocSearch-content`. +- [ ] Add a test that renders `Markdown.H1` with an id and asserts the `

    ` keeps that id. +- [ ] Add a test that renders `Markdown.H2` and asserts exactly one element in the document has that heading id. +- [ ] Add a search URL test for an empty `siteUrl`: `Search.toRelativeSiteUrl("https://rescript-lang.org/docs/manual/introduction", ~siteUrl="")` should return the original URL. +- [ ] Add a search URL test for a normal same-origin crawler hit: absolute `https://rescript-lang.org/docs/manual/introduction#what-is-rescript` should normalize to `/docs/manual/introduction#what-is-rescript`. +- [ ] Run `yarn build:res`. +- [ ] Run `yarn vitest --browser.headless --run __tests__/DocsLayout_.test.jsx __tests__/MarkdownComponents_.test.jsx __tests__/Search_.test.jsx`. +- [ ] Confirm the new tests fail for the expected reasons before implementation. + +### Task 2: Remove Algolia write API publishing + +**Files:** + +- Delete: `scripts/generate_search_index.res` +- Delete: `src/bindings/Algolia.res` +- Delete: `src/common/SearchIndex.res` +- Delete: `src/common/SearchIndex.resi` +- Delete: `__tests__/SearchIndex_.test.res` +- Delete: `__tests__/__screenshots__/SearchIndex_.test.jsx/*` +- Modify: `package.json` +- Modify: `yarn.lock` +- Modify: `.github/workflows/deploy.yml` +- Modify: `.gitignore` +- Modify: `src/common/AlgoliaConfig.res` +- Modify: `__tests__/AlgoliaConfig_.test.res` + +- [ ] Remove `build:search-index` from `package.json`. +- [ ] Restore `build:update-index` to only generate LLM files and the blog feed: + +```json +"build:update-index": "yarn build:generate-llms && node _scripts/generate_feed.mjs > public/blog/feed.xml" +``` + +- [ ] Keep `build` and `prepare` pointed at `build:update-index`; they should not invoke any Algolia uploader. +- [ ] Remove the `algoliasearch` dependency with Yarn so `package.json` and `yarn.lock` stay consistent. +- [ ] Remove `publisherConfig`, `missingPublisherVars`, and `publisherConfigFrom` from `AlgoliaConfig.res`. +- [ ] Update `AlgoliaConfig_.test.res` to cover only public DocSearch config. +- [ ] In `.github/workflows/deploy.yml`, remove `ALGOLIA_ADMIN_API_KEY_DEV`, `ALGOLIA_ADMIN_API_KEY_PROD`, and all `ALGOLIA_*` private exports. +- [ ] Replace the derived basename flow with direct public variables: + +```yaml +VITE_ALGOLIA_APP_ID: ${{ vars.VITE_ALGOLIA_APP_ID }} +VITE_ALGOLIA_INDEX_NAME: ${{ vars.VITE_ALGOLIA_INDEX_NAME }} +VITE_ALGOLIA_SEARCH_API_KEY: ${{ vars.VITE_ALGOLIA_SEARCH_API_KEY }} +``` + +- [ ] Remove `.gitignore` entries that only existed for deleted generated Algolia publishing files. +- [ ] Run `yarn install --immutable` or the repository-approved Yarn flow after dependency removal. +- [ ] Run `yarn build:res`. + +### Task 3: Keep the DocSearch UI, but make URL handling crawler-safe + +**Files:** + +- Modify: `src/components/Search.res` +- Modify: `src/bindings/DocSearch.res` +- Modify: `src/bindings/Env.res` +- Modify: `__tests__/Search_.test.res` + +- [ ] Keep the custom hit component and URL normalization, because crawler hits can be absolute URLs. +- [ ] Guard `toRelativeSiteUrl` so an empty `siteUrl` never matches every URL. +- [ ] Treat `Env.root_url` as absent when `VITE_DEPLOYMENT_URL` is `Some("")`, or keep the guard entirely inside `Search.toRelativeSiteUrl`. +- [ ] Do not move ranking or crawler record settings into the client; DocSearch crawler/dashboard owns index settings. +- [ ] Keep `searchParameters` only for UI query behavior, such as `hitsPerPage`, snippets, distinct, and optional facet filters. +- [ ] Run `yarn vitest --browser.headless --run __tests__/Search_.test.jsx`. + +### Task 4: Add DocSearch-content containers + +**Files:** + +- Modify: `src/layouts/SidebarLayout.res` +- Modify: `app/routes/BlogArticle.res` +- Modify: `app/routes/SyntaxLookup.res` +- Modify: `__tests__/DocsLayout_.test.res` +- Modify: `__tests__/BlogArticle_.test.res` if it already covers body markup +- Modify: `__tests__/SyntaxLookup_.test.res` if it already covers detail pages + +- [ ] Add `DocSearch-content` to the central `
    ` in `SidebarLayout.res`; this covers manual docs, React docs, guidelines, community pages, API overview, and API detail pages. +- [ ] Wrap blog article body content in an `article` with `DocSearch-content markdown-body`. +- [ ] For syntax lookup detail routes, put `DocSearch-content` on the detail body, not on the whole search-picker UI. +- [ ] Keep visual class order stable and avoid changing layout styles. +- [ ] Run the focused browser tests for touched layouts. + +### Task 5: Make heading anchors unique and crawler-readable + +**Files:** + +- Modify: `src/components/Markdown.res` +- Modify: `src/markdown/Mdx.res` +- Modify: `src/markdown/TocUtils.res` +- Modify: `app/routes/ApiDocs.res` +- Test: `__tests__/MarkdownComponents_.test.res` +- Test: `__tests__/DocsLayout_.test.res` + +- [ ] Update `Markdown.H1.make` to accept optional `~id` and `~title` props and render the `id` on the `

    ` when MDX provides it. +- [ ] Update `Markdown.Anchor.make` so the decorative anchor icon does not render a second element with the same `id`. +- [ ] Add a small heading-id helper in `src/markdown/` if needed so both MDX rendering and table-of-contents generation can use the same collision rules. +- [ ] Update `Mdx.anchorLinkPlugin` so repeated headings get deterministic suffixes such as `usage`, `usage-1`, `usage-2`. +- [ ] Update `TocUtils.buildEntries` to produce hrefs matching the rendered heading ids for duplicate headings. +- [ ] Add ids to programmatic API H1 headings in `ApiDocs.res`; H2 ids already use `type-` and `value-`. +- [ ] Run the heading and layout tests. + +### Task 6: Add DocSearch meta tags + +**Files:** + +- Modify: `src/components/Meta.res` +- Modify: `src/common/Constants.res` +- Test: `__tests__/MetaDescription_.test.res` or add a focused meta test if one exists for head tags + +- [ ] Add ``. +- [ ] Add `Array.join(",")} />` using the current major version from `Constants.versions.latest`; include `latest` when the build serves the latest docs. +- [ ] Keep the tag generic enough that older-version subdomain builds can provide their own version token through existing version constants. +- [ ] Do not hardcode stale version text in route components. + +### Task 7: Document crawler-owned indexing + +**Files:** + +- Modify: `README.md` or add `docs/docsearch.md` if the repo maintainers prefer a focused doc. + +- [ ] Document that the DocSearch crawler owns indexing and index settings. +- [ ] Document only public runtime variables: + +```txt +VITE_ALGOLIA_APP_ID +VITE_ALGOLIA_INDEX_NAME +VITE_ALGOLIA_SEARCH_API_KEY +``` + +- [ ] Explicitly state that no admin/write key is used during builds or deployments. +- [ ] Include the expected crawler selectors: + +```js +recordProps: { + lvl0: { + selectors: ".DocSearch-lvl0", + defaultValue: "Documentation", + }, + lvl1: [".DocSearch-content h1", "main h1", "h1", "head > title"], + lvl2: [".DocSearch-content h2", "main h2", "h2"], + lvl3: [".DocSearch-content h3", "main h3", "h3"], + lvl4: [".DocSearch-content h4", "main h4", "h4"], + lvl5: [".DocSearch-content h5", "main h5", "h5"], + lvl6: [".DocSearch-content h6", "main h6", "h6"], + content: [".DocSearch-content p, .DocSearch-content li"], +} +``` + +- [ ] Note that production crawler start URLs and sitemap configuration live in the Algolia dashboard, not in the site build. + +### Task 8: Consider sitemap generation as a follow-up or include it if scope allows + +**Files if included now:** + +- Create: `scripts/generate_sitemap.res` +- Modify: `package.json` +- Modify: `public/robots.txt` +- Modify: `react-router.config.mjs` only if route data is needed from there + +- [ ] Decide whether sitemap generation belongs in this PR or a follow-up. +- [ ] If included, generate `public/sitemap.xml` or `out/sitemap.xml` from the same route sources used by React Router prerendering. +- [ ] Include docs, API detail pages, community pages, syntax lookup detail pages, and blog posts. +- [ ] Add `Sitemap: https://rescript-lang.org/sitemap.xml` to `public/robots.txt`. +- [ ] Verify `yarn build` copies the sitemap into `out/`. + +## Final Verification + +- [ ] Run `yarn build:res`. +- [ ] Run `yarn vitest --browser.headless --run`. +- [ ] Run `yarn test`. +- [ ] Run `yarn build`. +- [ ] After `yarn build`, inspect representative generated HTML: + +```sh +grep -R "DocSearch-content" out/docs/manual/introduction out/docs/react/introduction out/docs/manual/api/stdlib out/blog | head +grep -R 'name="docsearch:version"' out/docs/manual/introduction | head +grep -R 'id="javascript-interop"' out/docs/manual/introduction | head +``` + +- [ ] Confirm build logs do not invoke `generate_search_index` and do not require any `ALGOLIA_ADMIN_*` variables. +- [ ] Do not update screenshots unless the user confirms snapshot updates. + +## Open Decision + +Sitemap generation is the only scope question. The DocSearch page calls it "nice to have" but also says it is key for crawler freshness. I would include markup and uploader removal in this PR, then add sitemap generation only if we want crawler discovery guarantees in the same change. diff --git a/package.json b/package.json index 100235bf6..d26ee9708 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,7 @@ "build:generate-llms": "node _scripts/generate_llms.mjs", "build:res": "rescript build --warn-error +3+8+11+12+26+27+31+32+33+34+35+39+44+45+110", "build:sync-bundles": "node scripts/sync-playground-bundles.mjs", - "build:search-index": "node --env-file-if-exists=.env --env-file-if-exists=.env.local _scripts/generate_search_index.mjs", - "build:update-index": "yarn build:generate-llms && node _scripts/generate_feed.mjs > public/blog/feed.xml && yarn build:search-index", + "build:update-index": "yarn build:generate-llms && node _scripts/generate_feed.mjs > public/blog/feed.xml", "build:vite": "react-router build", "check:algolia-public-env": "node _scripts/LogAlgoliaEnvStatus.mjs", "build": "yarn build:res && yarn build:scripts && yarn check:algolia-public-env && yarn build:update-index && yarn build:vite", @@ -56,7 +55,6 @@ "@rescript/react": "^0.14.2", "@rescript/webapi": "0.1.0-experimental-29db5f4", "@tsnobip/rescript-lezer": "^0.8.0", - "algoliasearch": "^5.50.1", "docson": "^2.1.0", "fuse.js": "^6.6.2", "highlight.js": "^11.11.1", diff --git a/scripts/generate_search_index.res b/scripts/generate_search_index.res deleted file mode 100644 index 57051a34c..000000000 --- a/scripts/generate_search_index.res +++ /dev/null @@ -1,232 +0,0 @@ -// Build script: reads all site content, builds Algolia search records, and uploads them. -// Runs as a standalone Node script via: node --env-file-if-exists=.env --env-file-if-exists=.env.local _scripts/generate_search_index.mjs -// -// Required env vars: -// ALGOLIA_APP_ID -- Algolia application ID -// ALGOLIA_ADMIN_API_KEY -- API key with addObject/deleteObject/editSettings ACLs -// ALGOLIA_INDEX_NAME -- e.g. "rescript-lang-dev" or "rescript-lang" -// -// If any are missing, the script logs a warning and exits 0 (graceful skip). - -let getEnv = (key: string): option => - Node.Process.env - ->Dict.get(key) - ->Option.flatMap(v => - switch v { - | "" => None - | s => Some(s) - } - ) - -let compareVersions = (a: string, b: string): float => { - let parse = (v: string) => - v - ->String.replaceRegExp(RegExp.fromString("^v", ~flags=""), "") - ->String.split(".") - ->Array.map(s => Int.fromString(s)->Option.getOr(0)) - let partsA = parse(a) - let partsB = parse(b) - switch (partsA[0], partsB[0]) { - | (Some(a0), Some(b0)) if a0 !== b0 => Int.toFloat(a0 - b0) - | _ => - switch (partsA[1], partsB[1]) { - | (Some(a1), Some(b1)) if a1 !== b1 => Int.toFloat(a1 - b1) - | _ => - switch (partsA[2], partsB[2]) { - | (Some(a2), Some(b2)) => Int.toFloat(a2 - b2) - | _ => 0.0 - } - } - } -} - -let resolveApiDir = (): option => { - let majorVersion = - getEnv("VITE_VERSION_LATEST") - ->Option.map(v => v->String.replaceRegExp(RegExp.fromString("^v", ~flags=""), "")) - ->Option.flatMap(v => v->String.split(".")->Array.get(0)) - switch majorVersion { - | None => { - Console.log("[search-index] VITE_VERSION_LATEST not set, cannot resolve API version.") - None - } - | Some(major) => { - let prefix = "v" ++ major ++ "." - let entries = Node.Fs.readdirSync("data/api") - let matching = - entries - ->Array.filter(entry => String.startsWith(entry, prefix)) - ->Array.toSorted(compareVersions) - switch matching->Array.at(-1) { - | Some(dir) => { - Console.log(`[search-index] Resolved API version: ${dir}`) - Some("data/api/" ++ dir) - } - | None => { - Console.log(`[search-index] No API version found matching v${major}.*`) - None - } - } - } - } -} - -let resolveSiteUrl = (): string => - getEnv("VITE_DEPLOYMENT_URL")->Option.getOr("https://rescript-lang.org") - -let main = async () => { - let appId = getEnv("ALGOLIA_APP_ID") - let adminApiKey = getEnv("ALGOLIA_ADMIN_API_KEY") - let indexName = getEnv("ALGOLIA_INDEX_NAME") - let publisherConfig = AlgoliaConfig.publisherConfigFrom(~appId, ~indexName, ~adminApiKey) - - switch publisherConfig { - | Some({appId, indexName, adminApiKey}) => { - Console.log("[search-index] Building search index records...") - - let apiDir = resolveApiDir()->Option.getOr("markdown-pages/docs/api") - let siteUrl = resolveSiteUrl() - - // 1. Build records from all content sources - let manualRecords = SearchIndex.buildMarkdownRecords( - ~category="Manual", - ~basePath="/docs/manual", - ~dirPath="markdown-pages/docs/manual", - ~pageRank=100, - ) - Console.log( - `[search-index] Manual docs: ${Int.toString(Array.length(manualRecords))} records`, - ) - - let reactRecords = SearchIndex.buildMarkdownRecords( - ~category="React", - ~basePath="/docs/react", - ~dirPath="markdown-pages/docs/react", - ~pageRank=90, - ) - Console.log( - `[search-index] React docs: ${Int.toString(Array.length(reactRecords))} records`, - ) - - let communityRecords = SearchIndex.buildMarkdownRecords( - ~category="Community", - ~basePath="/community", - ~dirPath="markdown-pages/community", - ~pageRank=50, - ) - Console.log( - `[search-index] Community: ${Int.toString(Array.length(communityRecords))} records`, - ) - - let blogRecords = SearchIndex.buildBlogRecords(~dirPath="markdown-pages/blog", ~pageRank=40) - Console.log(`[search-index] Blog: ${Int.toString(Array.length(blogRecords))} records`) - - let syntaxRecords = SearchIndex.buildSyntaxLookupRecords( - ~dirPath="markdown-pages/syntax-lookup", - ~pageRank=70, - ) - Console.log( - `[search-index] Syntax lookup: ${Int.toString(Array.length(syntaxRecords))} records`, - ) - - let stdlibApiRecords = SearchIndex.buildApiRecords( - ~basePath="/docs/manual/api", - ~dirPath=apiDir, - ~pageRank=80, - ~category="API / StdLib", - ~files=["stdlib.json"], - ) - Console.log( - `[search-index] API / StdLib: ${Int.toString(Array.length(stdlibApiRecords))} records`, - ) - - let beltApiRecords = SearchIndex.buildApiRecords( - ~basePath="/docs/manual/api", - ~dirPath=apiDir, - ~pageRank=75, - ~category="API / Belt", - ~files=["belt.json"], - ) - Console.log( - `[search-index] API / Belt: ${Int.toString(Array.length(beltApiRecords))} records`, - ) - - let domApiRecords = SearchIndex.buildApiRecords( - ~basePath="/docs/manual/api", - ~dirPath=apiDir, - ~pageRank=70, - ~category="API / DOM", - ~files=["dom.json"], - ) - Console.log( - `[search-index] API / DOM: ${Int.toString(Array.length(domApiRecords))} records`, - ) - - // 2. Concatenate all records - let allRecords = - [ - manualRecords, - reactRecords, - communityRecords, - blogRecords, - syntaxRecords, - stdlibApiRecords, - beltApiRecords, - domApiRecords, - ]->Array.flat - - let totalCount = Array.length(allRecords) - Console.log(`[search-index] Total: ${Int.toString(totalCount)} records`) - - // 3. Convert to JSON for Algolia - let jsonRecords = - allRecords - ->Array.map(record => SearchIndex.withBaseUrl(record, ~siteUrl)) - ->Array.map(SearchIndex.toJson) - - // 4. Initialize Algolia client and upload - let client = Algolia.make(appId, adminApiKey) - - Console.log(`[search-index] Uploading to index "${indexName}"...`) - let _ = await client->Algolia.replaceAllObjects({ - indexName, - objects: jsonRecords, - batchSize: 1000, - }) - Console.log("[search-index] Records uploaded successfully.") - - // 5. Configure index settings - Console.log("[search-index] Updating index settings...") - let _ = await client->Algolia.setSettings({ - indexName, - indexSettings: { - searchableAttributes: [ - "hierarchy.lvl0", - "hierarchy.lvl1", - "hierarchy.lvl2", - "hierarchy.lvl3", - "hierarchy.lvl4", - "hierarchy.lvl5", - "hierarchy.lvl6", - "content", - ], - ranking: ["typo", "words", "attribute", "exact", "custom", "proximity", "filters"], - exactOnSingleWordQuery: "word", - attributesForFaceting: ["type"], - customRanking: ["desc(weight.pageRank)", "desc(weight.level)", "asc(weight.position)"], - attributesToSnippet: [], - attributeForDistinct: "hierarchy.lvl0", - }, - }) - Console.log("[search-index] Index settings updated.") - - Console.log("[search-index] Done.") - } - | None => - AlgoliaConfig.missingPublisherVars(~appId, ~indexName, ~adminApiKey)->Array.forEach(name => { - Console.log(`[search-index] ${name} not set, skipping index upload.`) - }) - } -} - -let _ = main() diff --git a/src/bindings/Algolia.res b/src/bindings/Algolia.res deleted file mode 100644 index 30cf205ca..000000000 --- a/src/bindings/Algolia.res +++ /dev/null @@ -1,54 +0,0 @@ -// Bindings for algoliasearch v5 SDK -// https://github.com/algolia/algoliasearch-client-javascript - -module SearchClient = { - type t -} - -module BatchResponse = { - type t -} - -module SetSettingsResponse = { - type t -} - -module IndexSettings = { - type t = { - searchableAttributes?: array, - attributesForFaceting?: array, - customRanking?: array, - ranking?: array, - attributesToSnippet?: array, - attributeForDistinct?: string, - exactOnSingleWordQuery?: string, - } -} - -module ReplaceAllObjectsOptions = { - type t = { - indexName: string, - objects: array, - batchSize?: int, - } -} - -module SetSettingsOptions = { - type t = { - indexName: string, - indexSettings: IndexSettings.t, - } -} - -@module("algoliasearch") -external make: (string, string) => SearchClient.t = "algoliasearch" - -@send -external replaceAllObjects: ( - SearchClient.t, - ReplaceAllObjectsOptions.t, -) => promise> = "replaceAllObjects" - -@send -external setSettings: (SearchClient.t, SetSettingsOptions.t) => promise = - "setSettings" diff --git a/src/bindings/Env.res b/src/bindings/Env.res index 615215de5..29e21321d 100644 --- a/src/bindings/Env.res +++ b/src/bindings/Env.res @@ -6,8 +6,9 @@ external deployment_url: option = "import.meta.env.VITE_DEPLOYMENT_URL" // the root url of the site, e.g. "https://rescript-lang.org/" or "http://localhost:5173/" let root_url = switch deployment_url { -| Some(url) => url +| Some(url) if url !== "" => url | None => dev ? "http://localhost:5173/" : "https://rescript-lang.org/" +| Some(_) => "https://rescript-lang.org/" } // Algolia search configuration (read from .env via Vite) diff --git a/src/bindings/Mdast.res b/src/bindings/Mdast.res index 565117d31..c9fd2c016 100644 --- a/src/bindings/Mdast.res +++ b/src/bindings/Mdast.res @@ -3,6 +3,12 @@ type tree @module("mdast-util-from-markdown") external fromMarkdown: string => tree = "fromMarkdown" +@module("mdast-util-to-string") +external toString: {..} => string = "toString" + +@module("unist-util-visit") +external visit: (tree, string, {..} => unit) => unit = "visit" + @unboxed type listType = | @as("list") List diff --git a/src/common/AlgoliaConfig.res b/src/common/AlgoliaConfig.res index f499d9b9a..e486ee425 100644 --- a/src/common/AlgoliaConfig.res +++ b/src/common/AlgoliaConfig.res @@ -4,12 +4,6 @@ type publicConfig = { searchApiKey: string, } -type publisherConfig = { - appId: string, - indexName: string, - adminApiKey: string, -} - let isPresent = value => switch value { | Some(v) => v !== "" @@ -41,29 +35,3 @@ let publicConfigFrom = (~appId, ~indexName, ~searchApiKey): option Some({appId, indexName, searchApiKey}) | _ => None } - -let missingPublisherVars = (~appId, ~indexName, ~adminApiKey): array => { - let missing = [] - if !isPresent(appId) { - missing->Array.push("ALGOLIA_APP_ID") - } - if !isPresent(indexName) { - missing->Array.push("ALGOLIA_INDEX_NAME") - } - if !isPresent(adminApiKey) { - missing->Array.push("ALGOLIA_ADMIN_API_KEY") - } - missing -} - -let publisherConfigFrom = (~appId, ~indexName, ~adminApiKey): option => - switch (appId, indexName, adminApiKey) { - | (Some(appId), Some(indexName), Some(adminApiKey)) - if missingPublisherVars( - ~appId=Some(appId), - ~indexName=Some(indexName), - ~adminApiKey=Some(adminApiKey), - )->Array.length === 0 => - Some({appId, indexName, adminApiKey}) - | _ => None - } diff --git a/src/common/Constants.res b/src/common/Constants.res index ec4129cc4..9183573f6 100644 --- a/src/common/Constants.res +++ b/src/common/Constants.res @@ -19,6 +19,11 @@ let versions = { let latestVersion = (versions.latest, versions.latest->Semver.tryGetMajorString) +let docSearchVersionTokens = { + let (_, majorVersion) = latestVersion + [majorVersion, "latest"] +} + // This is used for the version dropdown in the manual layouts let allManualVersions = [ latestVersion, diff --git a/src/common/SearchIndex.res b/src/common/SearchIndex.res deleted file mode 100644 index 8b653e5c6..000000000 --- a/src/common/SearchIndex.res +++ /dev/null @@ -1,522 +0,0 @@ -type hierarchy = { - lvl0: string, - lvl1: string, - lvl2: option, - lvl3: option, - lvl4: option, - lvl5: option, - lvl6: option, -} - -type weight = { - pageRank: int, - level: int, - position: int, -} - -type record = { - objectID: string, - url: string, - url_without_anchor: string, - anchor: option, - content: option, - @as("type") type_: string, - hierarchy: hierarchy, - weight: weight, -} - -type heading = { - level: int, - text: string, - content: string, -} - -let maxContentLength = 500 - -let makeHierarchy = (~lvl0, ~lvl1, ~lvl2=?, ~lvl3=?, ~lvl4=?, ~lvl5=?, ~lvl6=?, ()) => { - lvl0, - lvl1, - lvl2, - lvl3, - lvl4, - lvl5, - lvl6, -} - -let truncate = (str: string, ~maxLen: int): string => - switch String.length(str) > maxLen { - | true => String.slice(str, ~start=0, ~end=maxLen) ++ "..." - | false => str - } - -// --- Helpers --- - -let slugify = (text: string): string => - text - ->String.toLowerCase - ->String.replaceRegExp(RegExp.fromString("\\s+", ~flags="g"), "-") - ->String.replaceRegExp(RegExp.fromString("[^a-z0-9\\-]", ~flags="g"), "") - -let stripMdxTags = (text: string): string => - text - ->String.replaceRegExp(RegExp.fromString("", ~flags="g"), "") - ->String.replaceRegExp(RegExp.fromString("<[^>]+>", ~flags="g"), "") - ->String.replaceRegExp(RegExp.fromString("```[\\s\\S]*?```", ~flags="g"), "") - ->String.replaceRegExp(RegExp.fromString("`([^`]+)`", ~flags="g"), "$1") - ->String.replaceRegExp(RegExp.fromString("\\*\\*([^*]+)\\*\\*", ~flags="g"), "$1") - ->String.replaceRegExp(RegExp.fromString("\\*([^*]+)\\*", ~flags="g"), "$1") - ->String.replaceRegExp(RegExp.fromString("\\[([^\\]]+)\\]\\([^)]*\\)", ~flags="g"), "$1") - ->String.replaceRegExp(RegExp.fromString("^#{1,6}\\s+", ~flags="gm"), "") - ->String.replaceRegExp(RegExp.fromString("\\n{2,}", ~flags="g"), "\n") - ->String.trim - -let cleanDocstring = (text: string): string => - text - // Take content before first heading - ->String.split("\n## ") - ->Array.get(0) - ->Option.getOr(text) - // Take content before first code block - ->String.split("\n```") - ->Array.get(0) - ->Option.getOr(text) - // Strip inline code backticks - ->String.replaceRegExp(RegExp.fromString("`([^`]+)`", ~flags="g"), "$1") - // Strip bold - ->String.replaceRegExp(RegExp.fromString("\\*\\*([^*]+)\\*\\*", ~flags="g"), "$1") - // Strip italic - ->String.replaceRegExp(RegExp.fromString("\\*([^*]+)\\*", ~flags="g"), "$1") - // Strip links - ->String.replaceRegExp(RegExp.fromString("\\[([^\\]]+)\\]\\([^)]*\\)", ~flags="g"), "$1") - // Collapse multiple newlines into space - ->String.replaceRegExp(RegExp.fromString("\\n{2,}", ~flags="g"), " ") - // Replace remaining newlines with space - ->String.replaceRegExp(RegExp.fromString("\\n", ~flags="g"), " ") - ->String.trim - -let extractIntro = (content: string): string => { - let parts = content->String.split("\n## ") - let intro = parts[0]->Option.getOr("") - intro - // Remove the # H1 heading line if present at the start - ->String.replaceRegExp(RegExp.fromString("^#[^#].*\\n", ~flags=""), "") - ->stripMdxTags - ->String.trim -} - -let findHeadingMatches: string => array<{..}> = %raw(` - function(content) { - var regex = /^(#{2,6})\s+(.+)$/gm; - var results = []; - var match; - while ((match = regex.exec(content)) !== null) { - results.push({ index: match.index, level: match[1].length, text: match[2] }); - } - return results; - } -`) - -let extractHeadings = (content: string): array => { - let matches = findHeadingMatches(content) - - matches->Array.mapWithIndex((m, i) => { - let startIdx = m["index"] + String.length(m["text"]) + m["level"] + 2 - let endIdx = switch matches[i + 1] { - | Some(next) => next["index"] - | None => String.length(content) - } - let sectionContent = - content - ->String.slice(~start=startIdx, ~end=endIdx) - ->stripMdxTags - ->String.trim - ->truncate(~maxLen=maxContentLength) - - { - level: m["level"], - text: m["text"], - content: sectionContent, - } - }) -} - -// --- File collection --- - -let rec collectFiles = (dirPath: string): array => { - let entries = Node.Fs.readdirSync(dirPath) - entries->Array.reduce([], (acc, entry) => { - let fullPath = Node.Path.join([dirPath, entry]) - let stats = Node.Fs.statSync(fullPath) - switch stats["isDirectory"]() { - | true => acc->Array.concat(collectFiles(fullPath)) - | false => { - acc->Array.push(fullPath) - acc - } - } - }) -} - -let isMdxFile = (path: string): bool => Node.Path.extname(path) === ".mdx" - -let filenameWithoutExt = (path: string): string => - Node.Path.basename(path)->String.replace(".mdx", "") - -// --- Record builders --- - -let buildMarkdownRecords = ( - ~category: string, - ~basePath: string, - ~dirPath: string, - ~pageRank: int, -): array => { - collectFiles(dirPath) - ->Array.filter(isMdxFile) - ->Array.flatMap(filePath => { - let fileContent = Node.Fs.readFileSync2(filePath, "utf8") - let parsed = MarkdownParser.parseSync(fileContent) - - switch DocFrontmatter.decode(parsed.frontmatter) { - | None => [] - | Some(fm) => { - let pageUrl = switch fm.canonical->Null.toOption { - | Some(canonical) => canonical - | None => basePath ++ "/" ++ filenameWithoutExt(filePath) - } - - let introText = parsed.content->extractIntro->truncate(~maxLen=maxContentLength) - let pageContent = switch introText { - | "" => fm.description->Null.toOption->Option.getOr("") - | text => text - } - - let pageRecord = { - objectID: pageUrl, - url: pageUrl, - url_without_anchor: pageUrl, - anchor: None, - content: Some(pageContent->truncate(~maxLen=maxContentLength)), - type_: "lvl1", - hierarchy: makeHierarchy(~lvl0=category, ~lvl1=fm.title, ()), - weight: {pageRank, level: 100, position: 0}, - } - - let headingRecords = - parsed.content - ->extractHeadings - ->Array.mapWithIndex((heading, i) => { - let anchor = slugify(heading.text) - let headingUrl = pageUrl ++ "#" ++ anchor - let typeLvl = switch heading.level { - | 2 => "lvl2" - | 3 => "lvl3" - | 4 => "lvl4" - | 5 => "lvl5" - | _ => "lvl6" - } - let weightLevel = switch heading.level { - | 2 => 80 - | 3 => 60 - | 4 => 40 - | 5 => 20 - | _ => 10 - } - let hierarchy = switch heading.level { - | 2 => makeHierarchy(~lvl0=category, ~lvl1=fm.title, ~lvl2=heading.text, ()) - | 3 => - makeHierarchy( - ~lvl0=category, - ~lvl1=fm.title, - ~lvl2=heading.text, - ~lvl3=heading.text, - (), - ) - | 4 => - makeHierarchy( - ~lvl0=category, - ~lvl1=fm.title, - ~lvl2=heading.text, - ~lvl3=heading.text, - ~lvl4=heading.text, - (), - ) - | _ => makeHierarchy(~lvl0=category, ~lvl1=fm.title, ~lvl2=heading.text, ()) - } - - { - objectID: headingUrl, - url: headingUrl, - url_without_anchor: pageUrl, - anchor: Some(anchor), - content: switch heading.content { - | "" => None - | c => Some(c) - }, - type_: typeLvl, - hierarchy, - weight: {pageRank, level: weightLevel, position: i + 1}, - } - }) - - [pageRecord]->Array.concat(headingRecords) - } - } - }) -} - -let buildBlogRecords = (~dirPath: string, ~pageRank: int): array => { - open JSON - Node.Fs.readdirSync(dirPath) - ->Array.filter(entry => isMdxFile(entry) && entry !== "archived") - ->Array.filterMap(entry => { - let fullPath = Node.Path.join([dirPath, entry]) - let stats = Node.Fs.statSync(fullPath) - switch stats["isDirectory"]() { - | true => None - | false => { - let fileContent = Node.Fs.readFileSync2(fullPath, "utf8") - let parsed = MarkdownParser.parseSync(fileContent) - - switch parsed.frontmatter { - | Object(dict{"title": String(title), "description": ?description}) => { - let slug = filenameWithoutExt(fullPath) - let url = "/blog/" ++ slug - let desc = switch description { - | Some(String(d)) => Some(d->truncate(~maxLen=maxContentLength)) - | _ => None - } - - Some({ - objectID: url, - url, - url_without_anchor: url, - anchor: None, - content: desc, - type_: "lvl1", - hierarchy: makeHierarchy(~lvl0="Blog", ~lvl1=title, ()), - weight: {pageRank, level: 100, position: 0}, - }) - } - | _ => None - } - } - } - }) -} - -let buildSyntaxLookupRecords = (~dirPath: string, ~pageRank: int): array => { - open JSON - Node.Fs.readdirSync(dirPath) - ->Array.filter(isMdxFile) - ->Array.filterMap(entry => { - let fullPath = Node.Path.join([dirPath, entry]) - let fileContent = Node.Fs.readFileSync2(fullPath, "utf8") - let parsed = MarkdownParser.parseSync(fileContent) - - switch parsed.frontmatter { - | Object(dict{ - "id": String(id), - "name": String(name), - "summary": String(summary), - "keywords": ?_keywords, - }) => - Some({ - objectID: "syntax-" ++ id, - url: "/syntax-lookup", - url_without_anchor: "/syntax-lookup", - anchor: None, - content: Some(summary->truncate(~maxLen=maxContentLength)), - type_: "lvl1", - hierarchy: makeHierarchy(~lvl0="Syntax", ~lvl1=name, ()), - weight: {pageRank, level: 100, position: 0}, - }) - | _ => None - } - }) -} - -let buildApiRecords = ( - ~basePath: string, - ~dirPath: string, - ~pageRank: int, - ~category: string, - ~files: option>=?, -): array => { - open JSON - Node.Fs.readdirSync(dirPath) - ->Array.filter(entry => { - let isJson = String.endsWith(entry, ".json") && entry !== "toc_tree.json" - switch files { - | Some(allowed) => isJson && allowed->Array.includes(entry) - | None => isJson - } - }) - ->Array.flatMap(entry => { - let fullPath = Node.Path.join([dirPath, entry]) - let fileContent = Node.Fs.readFileSync2(fullPath, "utf8") - - switch JSON.parseOrThrow(fileContent) { - | Object(modules) => - modules - ->Dict.toArray - ->Array.flatMap(((key, moduleJson)) => { - switch moduleJson { - | Object(dict{ - "id": String(id), - "name": String(name), - "docstrings": Array(docstrings), - "items": Array(items), - }) => { - let moduleUrl = basePath ++ "/" ++ key - let moduleDocstring = switch docstrings[0] { - | Some(String(d)) => Some(d->cleanDocstring->truncate(~maxLen=maxContentLength)) - | _ => None - } - - let moduleRecord = { - objectID: id, - url: moduleUrl, - url_without_anchor: moduleUrl, - anchor: None, - content: moduleDocstring, - type_: "lvl1", - hierarchy: makeHierarchy(~lvl0=category, ~lvl1=name, ()), - weight: {pageRank, level: 90, position: 0}, - } - - let sortedItems = items->Array.toSorted( - (a, b) => { - switch (a, b) { - | (Object(dict{"name": String(nameA)}), Object(dict{"name": String(nameB)})) => - nameA->String.localeCompare(nameB) - | _ => 0. - } - }, - ) - - let itemRecords = sortedItems->Array.filterMapWithIndex( - (item, i) => { - switch item { - | Object(dict{ - "id": String(itemId), - "name": String(itemName), - "docstrings": Array(itemDocstrings), - "signature": ?signature, - "kind": String(kind), - }) => { - let kindPrefix = switch kind { - | "type" => "type-" - | _ => "value-" - } - let itemAnchor = kindPrefix ++ itemName - let itemUrl = moduleUrl ++ "#" ++ itemAnchor - let qualifiedName = name ++ "." ++ itemName - let docstringIntro = switch itemDocstrings[0] { - | Some(String(d)) if String.length(d) > 0 => { - // Take content before first heading or code block - let intro = - d - ->String.split("\n## ") - ->Array.get(0) - ->Option.getOr(d) - ->String.split("\n```") - ->Array.get(0) - ->Option.getOr(d) - ->String.trim - Some(intro->truncate(~maxLen=2000)) - } - | _ => None - } - let content = switch docstringIntro { - | Some(d) if String.length(d) > 0 => Some(d) - | _ => - switch signature { - | Some(String(s)) => Some(s) - | _ => None - } - } - - Some({ - objectID: itemId, - url: itemUrl, - url_without_anchor: moduleUrl, - anchor: Some(itemAnchor), - content, - type_: "lvl1", - hierarchy: makeHierarchy(~lvl0=category, ~lvl1=qualifiedName, ()), - weight: {pageRank, level: 70, position: i}, - }) - } - | _ => None - } - }, - ) - - [moduleRecord]->Array.concat(itemRecords) - } - | _ => [] - } - }) - | _ => [] - | exception _ => [] - } - }) -} - -// --- JSON serialization --- - -let optionToJson = (opt: option): JSON.t => - switch opt { - | Some(s) => JSON.String(s) - | None => JSON.Null - } - -let hierarchyToJson = (h: hierarchy): JSON.t => { - let dict = Dict.make() - dict->Dict.set("lvl0", JSON.String(h.lvl0)) - dict->Dict.set("lvl1", JSON.String(h.lvl1)) - dict->Dict.set("lvl2", optionToJson(h.lvl2)) - dict->Dict.set("lvl3", optionToJson(h.lvl3)) - dict->Dict.set("lvl4", optionToJson(h.lvl4)) - dict->Dict.set("lvl5", optionToJson(h.lvl5)) - dict->Dict.set("lvl6", optionToJson(h.lvl6)) - JSON.Object(dict) -} - -let weightToJson = (w: weight): JSON.t => { - let dict = Dict.make() - dict->Dict.set("pageRank", JSON.Number(Int.toFloat(w.pageRank))) - dict->Dict.set("level", JSON.Number(Int.toFloat(w.level))) - dict->Dict.set("position", JSON.Number(Int.toFloat(w.position))) - JSON.Object(dict) -} - -let withBaseUrl = (record: record, ~siteUrl: string): record => { - let normalizedSiteUrl = siteUrl->String.replaceRegExp(RegExp.fromString("/+$", ~flags=""), "") - let absolutize = (url: string) => - if RegExp.test(RegExp.fromString("^https?://", ~flags=""), url) { - url - } else { - let normalizedPath = String.startsWith(url, "/") ? url : "/" ++ url - normalizedSiteUrl ++ normalizedPath - } - - { - ...record, - url: absolutize(record.url), - url_without_anchor: absolutize(record.url_without_anchor), - } -} - -let toJson = (r: record): JSON.t => { - let dict = Dict.make() - dict->Dict.set("objectID", JSON.String(r.objectID)) - dict->Dict.set("url", JSON.String(r.url)) - dict->Dict.set("url_without_anchor", JSON.String(r.url_without_anchor)) - dict->Dict.set("anchor", optionToJson(r.anchor)) - dict->Dict.set("content", optionToJson(r.content)) - dict->Dict.set("type", JSON.String(r.type_)) - dict->Dict.set("hierarchy", hierarchyToJson(r.hierarchy)) - dict->Dict.set("weight", weightToJson(r.weight)) - JSON.Object(dict) -} diff --git a/src/common/SearchIndex.resi b/src/common/SearchIndex.resi deleted file mode 100644 index 5e27feb6d..000000000 --- a/src/common/SearchIndex.resi +++ /dev/null @@ -1,86 +0,0 @@ -type hierarchy = { - lvl0: string, - lvl1: string, - lvl2: option, - lvl3: option, - lvl4: option, - lvl5: option, - lvl6: option, -} - -type weight = { - pageRank: int, - level: int, - position: int, -} - -type record = { - objectID: string, - url: string, - url_without_anchor: string, - anchor: option, - content: option, - @as("type") type_: string, - hierarchy: hierarchy, - weight: weight, -} - -type heading = { - level: int, - text: string, - content: string, -} - -let maxContentLength: int - -let makeHierarchy: ( - ~lvl0: string, - ~lvl1: string, - ~lvl2: string=?, - ~lvl3: string=?, - ~lvl4: string=?, - ~lvl5: string=?, - ~lvl6: string=?, - unit, -) => hierarchy - -let truncate: (string, ~maxLen: int) => string - -let slugify: string => string - -let stripMdxTags: string => string - -let cleanDocstring: string => string - -let extractIntro: string => string - -let extractHeadings: string => array - -let optionToJson: option => JSON.t - -let hierarchyToJson: hierarchy => JSON.t - -let weightToJson: weight => JSON.t - -let withBaseUrl: (record, ~siteUrl: string) => record - -let buildMarkdownRecords: ( - ~category: string, - ~basePath: string, - ~dirPath: string, - ~pageRank: int, -) => array - -let buildBlogRecords: (~dirPath: string, ~pageRank: int) => array - -let buildSyntaxLookupRecords: (~dirPath: string, ~pageRank: int) => array - -let buildApiRecords: ( - ~basePath: string, - ~dirPath: string, - ~pageRank: int, - ~category: string, - ~files: array=?, -) => array - -let toJson: record => JSON.t diff --git a/src/common/Url.res b/src/common/Url.res index 9122cb867..7f56de3fc 100644 --- a/src/common/Url.res +++ b/src/common/Url.res @@ -53,3 +53,15 @@ let normalizeAnchor = string => { ->String.replaceAllRegExp(/[^a-zA-Z0-9-]/g, "") ->String.toLocaleLowerCase } + +type anchorIdState = Dict.t + +let makeAnchorIdState = () => Dict.make() + +let makeUniqueAnchorId = (~state, ~title) => { + let baseId = title->normalizeAnchor + let count = state->Dict.get(baseId)->Option.getOr(0) + state->Dict.set(baseId, count + 1) + + count === 0 ? baseId : `${baseId}-${count->Int.toString}` +} diff --git a/src/common/Url.resi b/src/common/Url.resi index 991ca39cf..a8954e9a5 100644 --- a/src/common/Url.resi +++ b/src/common/Url.resi @@ -25,3 +25,9 @@ let getVersionFromStorage: storageKey => option let normalizePath: string => string let normalizeAnchor: string => string + +type anchorIdState + +let makeAnchorIdState: unit => anchorIdState + +let makeUniqueAnchorId: (~state: anchorIdState, ~title: string) => string diff --git a/src/components/Markdown.res b/src/components/Markdown.res index 5222f5fae..06e4785fd 100644 --- a/src/components/Markdown.res +++ b/src/components/Markdown.res @@ -116,7 +116,6 @@ module Anchor = { title=?title className="scroll-mt-30 invisible text-gray-60 opacity-50 hover:opacity-100 hover:text-gray-60 hover:cursor-pointer group-hover:visible" href={"#" ++ id} - id={id} > @@ -127,7 +126,8 @@ module Anchor = { module H1 = { @react.component - let make = (~children) =>

    children

    + let make = (~id=?, ~title=?, ~children) => +

    children

    } module H2 = { diff --git a/src/components/Markdown.resi b/src/components/Markdown.resi index 6234ea9dd..4c42ad8ff 100644 --- a/src/components/Markdown.resi +++ b/src/components/Markdown.resi @@ -40,7 +40,7 @@ module Anchor: { module H1: { @react.component - let make: (~children: React.element) => React.element + let make: (~id: string=?, ~title: string=?, ~children: React.element) => React.element } module H2: { diff --git a/src/components/MarkdownComponents.res b/src/components/MarkdownComponents.res index 67a5a272a..145d118c7 100644 --- a/src/components/MarkdownComponents.res +++ b/src/components/MarkdownComponents.res @@ -27,7 +27,7 @@ type t = { /* Common markdown elements */ p?: P.props => React.element, li?: Li.props => React.element, - h1?: H1.props => React.element, + h1?: H1.props => React.element, h2?: H2.props => React.element, h3?: H3.props => React.element, h4?: H4.props => React.element, diff --git a/src/components/Meta.res b/src/components/Meta.res index 62eda84b4..bcaa140d8 100644 --- a/src/components/Meta.res +++ b/src/components/Meta.res @@ -72,6 +72,8 @@ let make = ( + + Array.join(",")} /> // Robots meta tag diff --git a/src/components/Search.res b/src/components/Search.res index 41ff20e4c..5ec4b7c52 100644 --- a/src/components/Search.res +++ b/src/components/Search.res @@ -5,7 +5,7 @@ let unavailableLabel = "Search unavailable for this build" let toRelativeSiteUrl = (url: string, ~siteUrl: string): string => { let normalizedSiteUrl = siteUrl->String.replaceRegExp(RegExp.fromString("/+$", ~flags=""), "") - if String.startsWith(url, normalizedSiteUrl) { + if normalizedSiteUrl !== "" && String.startsWith(url, normalizedSiteUrl) { let relativePath = String.slice(url, ~start=String.length(normalizedSiteUrl)) if relativePath === "" { "/" diff --git a/src/layouts/CommunityLayout.res b/src/layouts/CommunityLayout.res index d95cc201d..ec8e213e8 100644 --- a/src/layouts/CommunityLayout.res +++ b/src/layouts/CommunityLayout.res @@ -24,6 +24,7 @@ let make = (~children, ~categories, ~entries) => { />} sidebarState=(isSidebarOpen, setSidebarOpen) theme=#Reason + docSearchLvl0="Community" > children diff --git a/src/layouts/DocsLayout.res b/src/layouts/DocsLayout.res index 82901e7db..e319484c8 100644 --- a/src/layouts/DocsLayout.res +++ b/src/layouts/DocsLayout.res @@ -8,6 +8,7 @@ let make = ( ~activeToc: option=?, ~categories: array, ~components=MarkdownComponents.default, + ~docSearchLvl0=?, ~theme=#Reason, ~children, ) => { @@ -24,7 +25,9 @@ let make = ( let sidebar = - + children } diff --git a/src/layouts/SidebarLayout.res b/src/layouts/SidebarLayout.res index 85e69f4f0..00bbb71cf 100644 --- a/src/layouts/SidebarLayout.res +++ b/src/layouts/SidebarLayout.res @@ -250,6 +250,7 @@ let make = ( ~sidebar: React.element, ~rightSidebar: option=?, ~categories: option>=?, + ~docSearchLvl0: option=?, ~children, ) => { let location = ReactRouter.useLocation() @@ -303,8 +304,15 @@ let make = ( sidebar
    + {switch docSearchLvl0 { + | Some(value) => + + {React.string(value)} + + | None => React.null + }} children pagination
    diff --git a/src/layouts/SidebarLayout.resi b/src/layouts/SidebarLayout.resi index 6498b5a16..039316094 100644 --- a/src/layouts/SidebarLayout.resi +++ b/src/layouts/SidebarLayout.resi @@ -66,5 +66,6 @@ let make: ( ~sidebar: React.element, ~rightSidebar: React.element=?, ~categories: array=?, + ~docSearchLvl0: string=?, ~children: React.element, ) => React.element diff --git a/src/markdown/Mdx.res b/src/markdown/Mdx.res index cadf676c5..dbb9bc9c5 100644 --- a/src/markdown/Mdx.res +++ b/src/markdown/Mdx.res @@ -188,18 +188,17 @@ let remarkLinkPlugin = makePlugin(_options => (tree, vfile) => remarkLinkPlugin( // converts the inner text of headings to kebab-case IDs let anchorLinkPlugin = (tree, _vfile) => { + let headingIds = Url.makeAnchorIdState() + visit(tree, "heading", node => { let planText = childrenToString(node) - let nodeData = switch node["data"] { - | Some(data) => data - | None => { - "hProperties": { - "id": planText->Url.normalizeAnchor, - "title": planText, - }, - } + let id = Url.makeUniqueAnchorId(~state=headingIds, ~title=planText) + node["data"] = { + "hProperties": { + "id": id, + "title": planText, + }, } - node["data"] = nodeData }) } diff --git a/src/markdown/TocUtils.res b/src/markdown/TocUtils.res index b09a83382..e070b3529 100644 --- a/src/markdown/TocUtils.res +++ b/src/markdown/TocUtils.res @@ -1,15 +1,17 @@ let buildEntries = (raw: string) => { let markdownTree = Mdast.fromMarkdown(raw) - let tocResult = Mdast.toc(markdownTree, {maxDepth: 2}) + let headingIds = Url.makeAnchorIdState() + let entries: array = [] - let headers = Dict.make() - Mdast.reduceHeaders(tocResult.map, headers) - - headers - ->Dict.toArray - ->Array.map(((header, url)): TableOfContents.entry => { - header, - href: (url :> string), + Mdast.visit(markdownTree, "heading", node => { + if node["depth"] <= 2 { + let header = Mdast.toString(node) + entries->Array.push({ + header, + href: "#" ++ Url.makeUniqueAnchorId(~state=headingIds, ~title=header), + }) + } }) - ->Array.slice(~start=2) + + entries->Array.slice(~start=2) } diff --git a/yarn.lock b/yarn.lock index 0f8ee4b83..a727cc1ab 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5,18 +5,6 @@ __metadata: version: 8 cacheKey: 10c0 -"@algolia/abtesting@npm:1.16.1": - version: 1.16.1 - resolution: "@algolia/abtesting@npm:1.16.1" - dependencies: - "@algolia/client-common": "npm:5.50.1" - "@algolia/requester-browser-xhr": "npm:5.50.1" - "@algolia/requester-fetch": "npm:5.50.1" - "@algolia/requester-node-http": "npm:5.50.1" - checksum: 10c0/0ca113338a447693b4827bdf87f37490ccd81bc1bbbe39b02c338ff79582379a68853c3d35fb2297fd5636fa43818dac9e04b59965a8b47851e8b1da041b45e8 - languageName: node - linkType: hard - "@algolia/autocomplete-core@npm:1.19.2": version: 1.19.2 resolution: "@algolia/autocomplete-core@npm:1.19.2" @@ -48,148 +36,6 @@ __metadata: languageName: node linkType: hard -"@algolia/client-abtesting@npm:5.50.1": - version: 5.50.1 - resolution: "@algolia/client-abtesting@npm:5.50.1" - dependencies: - "@algolia/client-common": "npm:5.50.1" - "@algolia/requester-browser-xhr": "npm:5.50.1" - "@algolia/requester-fetch": "npm:5.50.1" - "@algolia/requester-node-http": "npm:5.50.1" - checksum: 10c0/a3fb097e72acc5f1b009694774c0b23e1a7701ec4f54bbf4b20114f9adc73565f8d8c7fba492d769b6f5becd1ef4bf6b92073fb289cd06bfb3e12b2f0989f9ae - languageName: node - linkType: hard - -"@algolia/client-analytics@npm:5.50.1": - version: 5.50.1 - resolution: "@algolia/client-analytics@npm:5.50.1" - dependencies: - "@algolia/client-common": "npm:5.50.1" - "@algolia/requester-browser-xhr": "npm:5.50.1" - "@algolia/requester-fetch": "npm:5.50.1" - "@algolia/requester-node-http": "npm:5.50.1" - checksum: 10c0/ade9f7ee8e8872f0c54149a9292fc32bad9e0b189068ca283f7110ce3f638b14c5078ce43d2c00c2bf752d3aa96e6bea63e4f1184cbe5bc36501074d96595d05 - languageName: node - linkType: hard - -"@algolia/client-common@npm:5.50.1": - version: 5.50.1 - resolution: "@algolia/client-common@npm:5.50.1" - checksum: 10c0/4750773473748fec73a7a9be3081274e21f2c4ccac463618b2ec470113c44c1f6961a991382c999acf04bd83e074547cd57c6304c4218d31bb0089b5c1099bf3 - languageName: node - linkType: hard - -"@algolia/client-insights@npm:5.50.1": - version: 5.50.1 - resolution: "@algolia/client-insights@npm:5.50.1" - dependencies: - "@algolia/client-common": "npm:5.50.1" - "@algolia/requester-browser-xhr": "npm:5.50.1" - "@algolia/requester-fetch": "npm:5.50.1" - "@algolia/requester-node-http": "npm:5.50.1" - checksum: 10c0/62ca243328f38e9a245e2860c12d1e76529e9bf68d5a30881a053adf5cbaddda27af631edd33e23d879a9e5445c66e2654f0149695cd1b75b09b42ea57ef575f - languageName: node - linkType: hard - -"@algolia/client-personalization@npm:5.50.1": - version: 5.50.1 - resolution: "@algolia/client-personalization@npm:5.50.1" - dependencies: - "@algolia/client-common": "npm:5.50.1" - "@algolia/requester-browser-xhr": "npm:5.50.1" - "@algolia/requester-fetch": "npm:5.50.1" - "@algolia/requester-node-http": "npm:5.50.1" - checksum: 10c0/cbc099bd7a5f8ccefd4135a59dfa2b6136b751ed35d451a0c89738c8ad404195348d5553630ab8e59f056f17b8a284e918151696050b740d96e304c8f40174fd - languageName: node - linkType: hard - -"@algolia/client-query-suggestions@npm:5.50.1": - version: 5.50.1 - resolution: "@algolia/client-query-suggestions@npm:5.50.1" - dependencies: - "@algolia/client-common": "npm:5.50.1" - "@algolia/requester-browser-xhr": "npm:5.50.1" - "@algolia/requester-fetch": "npm:5.50.1" - "@algolia/requester-node-http": "npm:5.50.1" - checksum: 10c0/345e0ecaf587aec2a956c2039da817fd26e203c8689fe8e0d428baf6ab03f0809a936099ae420e779d3ec252bbcaf3061c6e8670c660d7a9d66e98627d8938df - languageName: node - linkType: hard - -"@algolia/client-search@npm:5.50.1": - version: 5.50.1 - resolution: "@algolia/client-search@npm:5.50.1" - dependencies: - "@algolia/client-common": "npm:5.50.1" - "@algolia/requester-browser-xhr": "npm:5.50.1" - "@algolia/requester-fetch": "npm:5.50.1" - "@algolia/requester-node-http": "npm:5.50.1" - checksum: 10c0/7910c074aa7b4fbbad2af082a7623d7d65ba0c19e0933d4658e43d588cd87ed2e851aad0c5428ce2a00a3e3248349fcda20ed5abb7700b93d03a475e2ce7a378 - languageName: node - linkType: hard - -"@algolia/ingestion@npm:1.50.1": - version: 1.50.1 - resolution: "@algolia/ingestion@npm:1.50.1" - dependencies: - "@algolia/client-common": "npm:5.50.1" - "@algolia/requester-browser-xhr": "npm:5.50.1" - "@algolia/requester-fetch": "npm:5.50.1" - "@algolia/requester-node-http": "npm:5.50.1" - checksum: 10c0/0d5264db46783d648246406349fe88dbc6fa1cdd74ed16500bb8a4e5efb1bdfd7174780065566fcb7317f7ba8ac858677ffb0d5194a1315c0ce6003bd4219d87 - languageName: node - linkType: hard - -"@algolia/monitoring@npm:1.50.1": - version: 1.50.1 - resolution: "@algolia/monitoring@npm:1.50.1" - dependencies: - "@algolia/client-common": "npm:5.50.1" - "@algolia/requester-browser-xhr": "npm:5.50.1" - "@algolia/requester-fetch": "npm:5.50.1" - "@algolia/requester-node-http": "npm:5.50.1" - checksum: 10c0/378076310011c77c91378a597d86d791d4821d1d00e3c500ec8828e72b9036bb974abb09bd0c10aa05fc75a50aa443be26985104ca78524a0a0cf34707536c70 - languageName: node - linkType: hard - -"@algolia/recommend@npm:5.50.1": - version: 5.50.1 - resolution: "@algolia/recommend@npm:5.50.1" - dependencies: - "@algolia/client-common": "npm:5.50.1" - "@algolia/requester-browser-xhr": "npm:5.50.1" - "@algolia/requester-fetch": "npm:5.50.1" - "@algolia/requester-node-http": "npm:5.50.1" - checksum: 10c0/0cf061bf2fc46240d93c6fe032693e143a5eb61a3fc27f619141ebea735b7e7f6c5c38b31b152e9ef074b61373549a1f72a76399d80ed55840251cc71438f829 - languageName: node - linkType: hard - -"@algolia/requester-browser-xhr@npm:5.50.1": - version: 5.50.1 - resolution: "@algolia/requester-browser-xhr@npm:5.50.1" - dependencies: - "@algolia/client-common": "npm:5.50.1" - checksum: 10c0/aa55122f483a0d1572da20b71b0b533493960894460ad545a6a50e1c73780affd4764d68aa5a1687894d23c31a972cc92886a0d8ed3324b6f5457efd58b424af - languageName: node - linkType: hard - -"@algolia/requester-fetch@npm:5.50.1": - version: 5.50.1 - resolution: "@algolia/requester-fetch@npm:5.50.1" - dependencies: - "@algolia/client-common": "npm:5.50.1" - checksum: 10c0/07232c12ff0a5b25e5e6dfeeed8e46765f347926f263774e9ae061e65bd1ddce029f78fd5feaa34e23c80e80b0a84874d8799f817368e924cc904aef4f8f8181 - languageName: node - linkType: hard - -"@algolia/requester-node-http@npm:5.50.1": - version: 5.50.1 - resolution: "@algolia/requester-node-http@npm:5.50.1" - dependencies: - "@algolia/client-common": "npm:5.50.1" - checksum: 10c0/51be1452a28d4aeb97306121d164a3161fb55b775189df631f968bc752e00538a9872d0e0a2ad97744f8ca87c39f8352b526b8b290805ddaf5a2d4f43ae3360f - languageName: node - linkType: hard - "@asamuzakjp/css-color@npm:^3.2.0": version: 3.2.0 resolution: "@asamuzakjp/css-color@npm:3.2.0" @@ -3552,28 +3398,6 @@ __metadata: languageName: node linkType: hard -"algoliasearch@npm:^5.50.1": - version: 5.50.1 - resolution: "algoliasearch@npm:5.50.1" - dependencies: - "@algolia/abtesting": "npm:1.16.1" - "@algolia/client-abtesting": "npm:5.50.1" - "@algolia/client-analytics": "npm:5.50.1" - "@algolia/client-common": "npm:5.50.1" - "@algolia/client-insights": "npm:5.50.1" - "@algolia/client-personalization": "npm:5.50.1" - "@algolia/client-query-suggestions": "npm:5.50.1" - "@algolia/client-search": "npm:5.50.1" - "@algolia/ingestion": "npm:1.50.1" - "@algolia/monitoring": "npm:1.50.1" - "@algolia/recommend": "npm:5.50.1" - "@algolia/requester-browser-xhr": "npm:5.50.1" - "@algolia/requester-fetch": "npm:5.50.1" - "@algolia/requester-node-http": "npm:5.50.1" - checksum: 10c0/4b91f019c89324786e23f90b7773eb82b142e8075c95f204cf6fc07f320fcbbf623ca338509647d93b9776f4645a1f72debb2800627c4bf1b80e3ed8f2b398b1 - languageName: node - linkType: hard - "ansi-align@npm:^3.0.1": version: 3.0.1 resolution: "ansi-align@npm:3.0.1" @@ -9873,7 +9697,6 @@ __metadata: "@types/react": "npm:^19.2.14" "@vitejs/plugin-react": "npm:^6.0.1" "@vitest/browser-playwright": "npm:^4.1.2" - algoliasearch: "npm:^5.50.1" auto-image-converter: "npm:^2.2.0" chokidar: "npm:^4.0.3" cypress: "npm:^15.13.1" From f6287fae2fa736a3171079cf759ac7114a318ccf Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Mon, 27 Apr 2026 12:05:17 -0400 Subject: [PATCH 19/32] fix: restore runtime Algolia env mapping Keep the existing public Algolia repository variables as deploy inputs and export VITE_* values for the Vite build. Do not export admin/write keys or reintroduce the build-time index writer. --- .github/workflows/deploy.yml | 26 ++++++++++++++++++++------ README.md | 2 ++ 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 618d46755..44c51501a 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -53,13 +53,27 @@ jobs: - name: Set Algolia env shell: bash env: - VITE_ALGOLIA_APP_ID: ${{ vars.VITE_ALGOLIA_APP_ID }} - VITE_ALGOLIA_INDEX_NAME: ${{ vars.VITE_ALGOLIA_INDEX_NAME }} - VITE_ALGOLIA_SEARCH_API_KEY: ${{ vars.VITE_ALGOLIA_SEARCH_API_KEY }} + ALGOLIA_APP_ID: ${{ vars.ALGOLIA_APP_ID }} + ALGOLIA_INDEX_BASENAME: ${{ vars.ALGOLIA_INDEX_BASENAME }} + ALGOLIA_SEARCH_API_KEY_DEV: ${{ vars.ALGOLIA_SEARCH_API_KEY_DEV }} + ALGOLIA_SEARCH_API_KEY_PROD: ${{ vars.ALGOLIA_SEARCH_API_KEY_PROD }} run: | - echo "VITE_ALGOLIA_APP_ID=$VITE_ALGOLIA_APP_ID" >> "$GITHUB_ENV" - echo "VITE_ALGOLIA_INDEX_NAME=$VITE_ALGOLIA_INDEX_NAME" >> "$GITHUB_ENV" - echo "VITE_ALGOLIA_SEARCH_API_KEY=$VITE_ALGOLIA_SEARCH_API_KEY" >> "$GITHUB_ENV" + if [[ "${{ github.event_name }}" == "push" && "${{ github.ref_name }}" == "master" ]]; then + INDEX_PREFIX="prod" + SEARCH_KEY="$ALGOLIA_SEARCH_API_KEY_PROD" + elif [[ "${{ github.event_name }}" == "workflow_dispatch" && "${{ inputs.environment }}" == "production" ]]; then + INDEX_PREFIX="prod" + SEARCH_KEY="$ALGOLIA_SEARCH_API_KEY_PROD" + else + INDEX_PREFIX="dev" + SEARCH_KEY="$ALGOLIA_SEARCH_API_KEY_DEV" + fi + + INDEX_NAME="${INDEX_PREFIX}_${ALGOLIA_INDEX_BASENAME}" + + echo "VITE_ALGOLIA_APP_ID=$ALGOLIA_APP_ID" >> "$GITHUB_ENV" + echo "VITE_ALGOLIA_INDEX_NAME=$INDEX_NAME" >> "$GITHUB_ENV" + echo "VITE_ALGOLIA_SEARCH_API_KEY=$SEARCH_KEY" >> "$GITHUB_ENV" - name: Build run: yarn build env: diff --git a/README.md b/README.md index 34e3444d8..124fc849a 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,8 @@ VITE_ALGOLIA_INDEX_NAME="..." VITE_ALGOLIA_SEARCH_API_KEY="..." ``` +The GitHub deploy workflow maps the existing public repository variables (`ALGOLIA_APP_ID`, `ALGOLIA_INDEX_BASENAME`, `ALGOLIA_SEARCH_API_KEY_DEV`, and `ALGOLIA_SEARCH_API_KEY_PROD`) into those `VITE_` variables at build time. Builds and deployments should not configure or export Algolia admin/write keys. + DocSearch crawl quality comes from the generated HTML. Searchable page bodies use `DocSearch-content`, each crawlable section provides a hidden `DocSearch-lvl0` marker such as `Manual`, `API`, `React`, `Syntax Lookup`, `Community`, or `Blog`, and headings own unique `id` attributes for section links. The crawler configuration should use selectors shaped like this: From 97edadf5f37e985915edd6b5881e1b5f52864159 Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Mon, 27 Apr 2026 12:13:27 -0400 Subject: [PATCH 20/32] fix: align DocSearch index and sitemap output Pass the configured Algolia index name through without prod/dev prefixes. Generate sitemap.xml from the prerendered build output for deployed crawler discovery. --- .github/workflows/deploy.yml | 7 +-- README.md | 4 +- __tests__/AlgoliaConfig_.test.res | 6 +-- __tests__/AlgoliaEnvStatus_.test.res | 2 +- __tests__/Sitemap_.test.res | 22 +++++++++ package.json | 3 +- scripts/generate_sitemap.res | 47 ++++++++++++++++++ src/common/Sitemap.res | 73 ++++++++++++++++++++++++++++ 8 files changed, 151 insertions(+), 13 deletions(-) create mode 100644 __tests__/Sitemap_.test.res create mode 100644 scripts/generate_sitemap.res create mode 100644 src/common/Sitemap.res diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 44c51501a..1e5bfffa3 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -59,20 +59,15 @@ jobs: ALGOLIA_SEARCH_API_KEY_PROD: ${{ vars.ALGOLIA_SEARCH_API_KEY_PROD }} run: | if [[ "${{ github.event_name }}" == "push" && "${{ github.ref_name }}" == "master" ]]; then - INDEX_PREFIX="prod" SEARCH_KEY="$ALGOLIA_SEARCH_API_KEY_PROD" elif [[ "${{ github.event_name }}" == "workflow_dispatch" && "${{ inputs.environment }}" == "production" ]]; then - INDEX_PREFIX="prod" SEARCH_KEY="$ALGOLIA_SEARCH_API_KEY_PROD" else - INDEX_PREFIX="dev" SEARCH_KEY="$ALGOLIA_SEARCH_API_KEY_DEV" fi - INDEX_NAME="${INDEX_PREFIX}_${ALGOLIA_INDEX_BASENAME}" - echo "VITE_ALGOLIA_APP_ID=$ALGOLIA_APP_ID" >> "$GITHUB_ENV" - echo "VITE_ALGOLIA_INDEX_NAME=$INDEX_NAME" >> "$GITHUB_ENV" + echo "VITE_ALGOLIA_INDEX_NAME=$ALGOLIA_INDEX_BASENAME" >> "$GITHUB_ENV" echo "VITE_ALGOLIA_SEARCH_API_KEY=$SEARCH_KEY" >> "$GITHUB_ENV" - name: Build run: yarn build diff --git a/README.md b/README.md index 124fc849a..3ce344683 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ VITE_ALGOLIA_INDEX_NAME="..." VITE_ALGOLIA_SEARCH_API_KEY="..." ``` -The GitHub deploy workflow maps the existing public repository variables (`ALGOLIA_APP_ID`, `ALGOLIA_INDEX_BASENAME`, `ALGOLIA_SEARCH_API_KEY_DEV`, and `ALGOLIA_SEARCH_API_KEY_PROD`) into those `VITE_` variables at build time. Builds and deployments should not configure or export Algolia admin/write keys. +The GitHub deploy workflow maps the existing public repository variables (`ALGOLIA_APP_ID`, `ALGOLIA_INDEX_BASENAME`, `ALGOLIA_SEARCH_API_KEY_DEV`, and `ALGOLIA_SEARCH_API_KEY_PROD`) into those `VITE_` variables at build time. `ALGOLIA_INDEX_BASENAME` is passed through as the full runtime index name; the workflow does not add `prod_` or `dev_` prefixes. Builds and deployments should not configure or export Algolia admin/write keys. DocSearch crawl quality comes from the generated HTML. Searchable page bodies use `DocSearch-content`, each crawlable section provides a hidden `DocSearch-lvl0` marker such as `Manual`, `API`, `React`, `Syntax Lookup`, `Community`, or `Blog`, and headings own unique `id` attributes for section links. @@ -67,7 +67,7 @@ recordProps: { } ``` -Production crawler start URLs, sitemap settings, ranking, and crawler schedules live in the Algolia dashboard. +Production crawler start URLs, ranking, and crawler schedules live in the Algolia dashboard. The build generates `sitemap.xml` from the prerendered HTML pages so the crawler can use the deployed sitemap. ## Project Structure Overview diff --git a/__tests__/AlgoliaConfig_.test.res b/__tests__/AlgoliaConfig_.test.res index 35f416c65..df81667b0 100644 --- a/__tests__/AlgoliaConfig_.test.res +++ b/__tests__/AlgoliaConfig_.test.res @@ -3,13 +3,13 @@ open Vitest test("publicConfigFrom returns config when all public vars are present", async () => { let result = AlgoliaConfig.publicConfigFrom( ~appId=Some("app_123"), - ~indexName=Some("dev_rescript_lang"), + ~indexName=Some("rescript_lang"), ~searchApiKey=Some("search_123"), ) let expected: AlgoliaConfig.publicConfig = { appId: "app_123", - indexName: "dev_rescript_lang", + indexName: "rescript_lang", searchApiKey: "search_123", } @@ -19,7 +19,7 @@ test("publicConfigFrom returns config when all public vars are present", async ( test("publicConfigFrom reports missing public vars in declaration order", async () => { let result = AlgoliaConfig.missingPublicVars( ~appId=None, - ~indexName=Some("dev_rescript_lang"), + ~indexName=Some("rescript_lang"), ~searchApiKey=None, ) diff --git a/__tests__/AlgoliaEnvStatus_.test.res b/__tests__/AlgoliaEnvStatus_.test.res index 7c5784ae6..3a7e58ee4 100644 --- a/__tests__/AlgoliaEnvStatus_.test.res +++ b/__tests__/AlgoliaEnvStatus_.test.res @@ -3,7 +3,7 @@ open Vitest test("reports missing public vars in declaration order", async () => { let env = Dict.fromArray([ ("VITE_ALGOLIA_APP_ID", ""), - ("VITE_ALGOLIA_INDEX_NAME", "dev_rescript_lang"), + ("VITE_ALGOLIA_INDEX_NAME", "rescript_lang"), ]) expect(AlgoliaEnvStatus.getMissingPublicAlgoliaVars(~env))->toEqual([ diff --git a/__tests__/Sitemap_.test.res b/__tests__/Sitemap_.test.res new file mode 100644 index 000000000..210c1263a --- /dev/null +++ b/__tests__/Sitemap_.test.res @@ -0,0 +1,22 @@ +open Vitest + +test("renders sorted unique sitemap URLs with a normalized base URL", async () => { + let xml = Sitemap.render( + ~baseUrl="https://preview.example.com/", + ["/docs/manual/introduction", "blog", "/", "/docs/manual/introduction"], + ) + + expect(xml)->toBe(` + + + https://preview.example.com/ + + + https://preview.example.com/blog + + + https://preview.example.com/docs/manual/introduction + + +`) +}) diff --git a/package.json b/package.json index d26ee9708..6908308f9 100644 --- a/package.json +++ b/package.json @@ -8,12 +8,13 @@ "scripts": { "build:scripts": "yarn dlx tsdown@0.21.7 scripts/*.jsx -d _scripts --no-clean --ext .mjs", "build:generate-llms": "node _scripts/generate_llms.mjs", + "build:generate-sitemap": "node _scripts/generate_sitemap.mjs build/client out", "build:res": "rescript build --warn-error +3+8+11+12+26+27+31+32+33+34+35+39+44+45+110", "build:sync-bundles": "node scripts/sync-playground-bundles.mjs", "build:update-index": "yarn build:generate-llms && node _scripts/generate_feed.mjs > public/blog/feed.xml", "build:vite": "react-router build", "check:algolia-public-env": "node _scripts/LogAlgoliaEnvStatus.mjs", - "build": "yarn build:res && yarn build:scripts && yarn check:algolia-public-env && yarn build:update-index && yarn build:vite", + "build": "yarn build:res && yarn build:scripts && yarn check:algolia-public-env && yarn build:update-index && yarn build:vite && yarn build:generate-sitemap", "ci:format": "oxfmt --check", "ci:test": "yarn vitest --run --browser.headless", "clean:res": "rescript clean", diff --git a/scripts/generate_sitemap.res b/scripts/generate_sitemap.res new file mode 100644 index 000000000..d85dc9f44 --- /dev/null +++ b/scripts/generate_sitemap.res @@ -0,0 +1,47 @@ +let rec collectPagePaths = (dirPath, urlPath) => { + Node.Fs.readdirSync(dirPath)->Array.flatMap(entry => { + let fullPath = Node.Path.join2(dirPath, entry) + let stats = Node.Fs.statSync(fullPath) + + if stats["isDirectory"]() { + let nextUrlPath = if urlPath === "" { + entry + } else { + urlPath ++ "/" ++ entry + } + + collectPagePaths(fullPath, nextUrlPath) + } else if entry === "index.html" { + [urlPath === "" ? "/" : "/" ++ urlPath] + } else { + [] + } + }) +} + +let outputDirs = { + let args = Node.Process.argv->Array.slice(~start=2) + + switch args->Array.length { + | 0 => ["build/client"] + | _ => args + } +} + +let sourceDir = outputDirs->Array.get(0)->Option.getOr("build/client") + +if !Node.Fs.existsSync(sourceDir) { + Console.error(`Cannot generate sitemap: ${sourceDir} does not exist`) + Node.Process.exit(1) +} + +let baseUrl = Node.Process.env->Dict.get("VITE_DEPLOYMENT_URL")->Option.getOr("") +let sitemap = sourceDir->collectPagePaths("")->Sitemap.render(~baseUrl) + +outputDirs->Array.forEach(outputDir => { + if Node.Fs.existsSync(outputDir) { + let filePath = Node.Path.join2(outputDir, "sitemap.xml") + Node.Fs.writeFileSync(filePath, sitemap, ~encoding="utf8") + Console.log(`Generated ${filePath}`) + } +}) diff --git a/src/common/Sitemap.res b/src/common/Sitemap.res new file mode 100644 index 000000000..70713935d --- /dev/null +++ b/src/common/Sitemap.res @@ -0,0 +1,73 @@ +let defaultBaseUrl = "https://rescript-lang.org" + +let normalizeBaseUrl = baseUrl => { + let trimmed = baseUrl->String.trim + + let baseUrl = if trimmed === "" { + defaultBaseUrl + } else { + trimmed + } + + if baseUrl->String.endsWith("/") { + baseUrl->String.slice(~start=0, ~end=baseUrl->String.length - 1) + } else { + baseUrl + } +} + +let normalizePath = path => { + let trimmed = path->String.trim + + if trimmed === "" || trimmed === "/" { + "/" + } else if trimmed->String.startsWith("/") { + trimmed + } else { + "/" ++ trimmed + } +} + +let escapeXml = value => + value + ->String.replaceAll("&", "&") + ->String.replaceAll("<", "<") + ->String.replaceAll(">", ">") + ->String.replaceAll("\"", """) + ->String.replaceAll("'", "'") + +let normalizePaths = paths => + paths + ->Array.map(normalizePath) + ->Array.toSorted(String.compare) + ->Array.reduce([], (acc, path) => { + if acc->Array.includes(path) { + acc + } else { + acc->Array.push(path) + acc + } + }) + +let renderUrl = (~baseUrl, path) => { + let loc = baseUrl ++ path + + ` + ${loc->escapeXml} + ` +} + +let render = (~baseUrl, paths) => { + let baseUrl = normalizeBaseUrl(baseUrl) + let urls = + paths + ->normalizePaths + ->Array.map(path => renderUrl(~baseUrl, path)) + ->Array.join("\n") + + ` + +${urls} + +` +} From 2a0d9caf3be97de831752d8ea8cbf47988fbeeb3 Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Mon, 27 Apr 2026 12:27:47 -0400 Subject: [PATCH 21/32] fix: use public Algolia deploy secrets Read the configured VITE_ALGOLIA_* GitHub secrets during deploy builds, keep matching repository-variable fallbacks, and fail the deploy when the public runtime search env is missing instead of publishing a disabled search build. --- .github/workflows/deploy.yml | 36 +++++++++++++++++++++++------------- README.md | 2 +- 2 files changed, 24 insertions(+), 14 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 1e5bfffa3..2a9b3ccb1 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -53,22 +53,32 @@ jobs: - name: Set Algolia env shell: bash env: - ALGOLIA_APP_ID: ${{ vars.ALGOLIA_APP_ID }} - ALGOLIA_INDEX_BASENAME: ${{ vars.ALGOLIA_INDEX_BASENAME }} - ALGOLIA_SEARCH_API_KEY_DEV: ${{ vars.ALGOLIA_SEARCH_API_KEY_DEV }} - ALGOLIA_SEARCH_API_KEY_PROD: ${{ vars.ALGOLIA_SEARCH_API_KEY_PROD }} + VITE_ALGOLIA_APP_ID: ${{ vars.VITE_ALGOLIA_APP_ID || secrets.VITE_ALGOLIA_APP_ID }} + VITE_ALGOLIA_INDEX_NAME: ${{ vars.VITE_ALGOLIA_INDEX_NAME || secrets.VITE_ALGOLIA_INDEX_NAME }} + VITE_ALGOLIA_SEARCH_API_KEY: ${{ vars.VITE_ALGOLIA_SEARCH_API_KEY || secrets.VITE_ALGOLIA_SEARCH_API_KEY }} run: | - if [[ "${{ github.event_name }}" == "push" && "${{ github.ref_name }}" == "master" ]]; then - SEARCH_KEY="$ALGOLIA_SEARCH_API_KEY_PROD" - elif [[ "${{ github.event_name }}" == "workflow_dispatch" && "${{ inputs.environment }}" == "production" ]]; then - SEARCH_KEY="$ALGOLIA_SEARCH_API_KEY_PROD" - else - SEARCH_KEY="$ALGOLIA_SEARCH_API_KEY_DEV" + missing=() + + if [[ -z "$VITE_ALGOLIA_APP_ID" ]]; then + missing+=("VITE_ALGOLIA_APP_ID") + fi + + if [[ -z "$VITE_ALGOLIA_INDEX_NAME" ]]; then + missing+=("VITE_ALGOLIA_INDEX_NAME") + fi + + if [[ -z "$VITE_ALGOLIA_SEARCH_API_KEY" ]]; then + missing+=("VITE_ALGOLIA_SEARCH_API_KEY") + fi + + if (( ${#missing[@]} > 0 )); then + echo "Missing Algolia public deploy env: ${missing[*]}" >&2 + exit 1 fi - echo "VITE_ALGOLIA_APP_ID=$ALGOLIA_APP_ID" >> "$GITHUB_ENV" - echo "VITE_ALGOLIA_INDEX_NAME=$ALGOLIA_INDEX_BASENAME" >> "$GITHUB_ENV" - echo "VITE_ALGOLIA_SEARCH_API_KEY=$SEARCH_KEY" >> "$GITHUB_ENV" + echo "VITE_ALGOLIA_APP_ID=$VITE_ALGOLIA_APP_ID" >> "$GITHUB_ENV" + echo "VITE_ALGOLIA_INDEX_NAME=$VITE_ALGOLIA_INDEX_NAME" >> "$GITHUB_ENV" + echo "VITE_ALGOLIA_SEARCH_API_KEY=$VITE_ALGOLIA_SEARCH_API_KEY" >> "$GITHUB_ENV" - name: Build run: yarn build env: diff --git a/README.md b/README.md index 3ce344683..ace904042 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ VITE_ALGOLIA_INDEX_NAME="..." VITE_ALGOLIA_SEARCH_API_KEY="..." ``` -The GitHub deploy workflow maps the existing public repository variables (`ALGOLIA_APP_ID`, `ALGOLIA_INDEX_BASENAME`, `ALGOLIA_SEARCH_API_KEY_DEV`, and `ALGOLIA_SEARCH_API_KEY_PROD`) into those `VITE_` variables at build time. `ALGOLIA_INDEX_BASENAME` is passed through as the full runtime index name; the workflow does not add `prod_` or `dev_` prefixes. Builds and deployments should not configure or export Algolia admin/write keys. +The GitHub deploy workflow maps the public GitHub secrets named `VITE_ALGOLIA_APP_ID`, `VITE_ALGOLIA_INDEX_NAME`, and `VITE_ALGOLIA_SEARCH_API_KEY` into the build environment. Repository variables with the same names may also be used. `VITE_ALGOLIA_INDEX_NAME` is the full runtime index name; the workflow does not add `prod_` or `dev_` prefixes. Builds and deployments should not configure or export Algolia admin/write keys. DocSearch crawl quality comes from the generated HTML. Searchable page bodies use `DocSearch-content`, each crawlable section provides a hidden `DocSearch-lvl0` marker such as `Manual`, `API`, `React`, `Syntax Lookup`, `Community`, or `Blog`, and headings own unique `id` attributes for section links. From ccc9d1f8af856c11d1008049baeeb234d1ff6af7 Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Mon, 27 Apr 2026 12:33:10 -0400 Subject: [PATCH 22/32] fix: pass Algolia secrets to deploy build Remove the separate Algolia env export step and pass VITE_ALGOLIA_* secrets directly to yarn build, alongside VITE_DEPLOYMENT_URL. --- .github/workflows/deploy.yml | 32 +++----------------------------- README.md | 2 +- 2 files changed, 4 insertions(+), 30 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 2a9b3ccb1..bd76c8adb 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -50,39 +50,13 @@ jobs: echo "SAFE_BRANCH=$SAFE_BRANCH" >> "$GITHUB_ENV" echo "VITE_DEPLOYMENT_URL=https://${SAFE_BRANCH}.rescript-lang.pages.dev" >> "$GITHUB_ENV" fi - - name: Set Algolia env - shell: bash - env: - VITE_ALGOLIA_APP_ID: ${{ vars.VITE_ALGOLIA_APP_ID || secrets.VITE_ALGOLIA_APP_ID }} - VITE_ALGOLIA_INDEX_NAME: ${{ vars.VITE_ALGOLIA_INDEX_NAME || secrets.VITE_ALGOLIA_INDEX_NAME }} - VITE_ALGOLIA_SEARCH_API_KEY: ${{ vars.VITE_ALGOLIA_SEARCH_API_KEY || secrets.VITE_ALGOLIA_SEARCH_API_KEY }} - run: | - missing=() - - if [[ -z "$VITE_ALGOLIA_APP_ID" ]]; then - missing+=("VITE_ALGOLIA_APP_ID") - fi - - if [[ -z "$VITE_ALGOLIA_INDEX_NAME" ]]; then - missing+=("VITE_ALGOLIA_INDEX_NAME") - fi - - if [[ -z "$VITE_ALGOLIA_SEARCH_API_KEY" ]]; then - missing+=("VITE_ALGOLIA_SEARCH_API_KEY") - fi - - if (( ${#missing[@]} > 0 )); then - echo "Missing Algolia public deploy env: ${missing[*]}" >&2 - exit 1 - fi - - echo "VITE_ALGOLIA_APP_ID=$VITE_ALGOLIA_APP_ID" >> "$GITHUB_ENV" - echo "VITE_ALGOLIA_INDEX_NAME=$VITE_ALGOLIA_INDEX_NAME" >> "$GITHUB_ENV" - echo "VITE_ALGOLIA_SEARCH_API_KEY=$VITE_ALGOLIA_SEARCH_API_KEY" >> "$GITHUB_ENV" - name: Build run: yarn build env: VITE_DEPLOYMENT_URL: ${{ env.VITE_DEPLOYMENT_URL }} + VITE_ALGOLIA_APP_ID: ${{ secrets.VITE_ALGOLIA_APP_ID }} + VITE_ALGOLIA_INDEX_NAME: ${{ secrets.VITE_ALGOLIA_INDEX_NAME }} + VITE_ALGOLIA_SEARCH_API_KEY: ${{ secrets.VITE_ALGOLIA_SEARCH_API_KEY }} - name: Deploy if: ${{ github.actor != 'dependabot[bot]' }} id: deploy diff --git a/README.md b/README.md index ace904042..692e2f65b 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ VITE_ALGOLIA_INDEX_NAME="..." VITE_ALGOLIA_SEARCH_API_KEY="..." ``` -The GitHub deploy workflow maps the public GitHub secrets named `VITE_ALGOLIA_APP_ID`, `VITE_ALGOLIA_INDEX_NAME`, and `VITE_ALGOLIA_SEARCH_API_KEY` into the build environment. Repository variables with the same names may also be used. `VITE_ALGOLIA_INDEX_NAME` is the full runtime index name; the workflow does not add `prod_` or `dev_` prefixes. Builds and deployments should not configure or export Algolia admin/write keys. +The GitHub deploy workflow passes the public GitHub secrets named `VITE_ALGOLIA_APP_ID`, `VITE_ALGOLIA_INDEX_NAME`, and `VITE_ALGOLIA_SEARCH_API_KEY` to the `yarn build` step. `VITE_ALGOLIA_INDEX_NAME` is the full runtime index name; the workflow does not add `prod_` or `dev_` prefixes. Builds and deployments should not configure or export Algolia admin/write keys. DocSearch crawl quality comes from the generated HTML. Searchable page bodies use `DocSearch-content`, each crawlable section provides a hidden `DocSearch-lvl0` marker such as `Manual`, `API`, `React`, `Syntax Lookup`, `Community`, or `Blog`, and headings own unique `id` attributes for section links. From f7c82270d327808c53d6549759a3ed797c8c6ad3 Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Mon, 27 Apr 2026 12:41:46 -0400 Subject: [PATCH 23/32] fix: handle crawler search hits without url_without_anchor Treat DocSearch crawler hit url_without_anchor as optional and derive it from the hit URL when absent so transformItems does not throw before rendering results. --- __tests__/Search_.test.res | 24 ++++++++++++++++++++++-- src/bindings/DocSearch.res | 2 +- src/components/Search.res | 6 +++++- 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/__tests__/Search_.test.res b/__tests__/Search_.test.res index 5ed014573..09cceb2fe 100644 --- a/__tests__/Search_.test.res +++ b/__tests__/Search_.test.res @@ -8,7 +8,7 @@ let makeHit = (~type_: DocSearch.contentType, ~url: string): DocSearch.docSearch objectID: "test", content: Nullable.null, url, - url_without_anchor: url, + url_without_anchor: Nullable.make(url), type_, anchor: Nullable.null, hierarchy: { @@ -262,11 +262,31 @@ test("normalizeHitUrls rewrites absolute site URLs to relative paths", async () expect(result[0]->Option.map(hit => hit.url))->toEqual( Some("/docs/manual/typescript-integration#gentype"), ) - expect(result[0]->Option.map(hit => hit.url_without_anchor))->toEqual( + expect(result[0]->Option.flatMap(hit => hit.url_without_anchor->Nullable.toOption))->toEqual( Some("/docs/manual/typescript-integration#gentype"), ) }) +test("normalizeHitUrls tolerates crawler hits without url_without_anchor", async () => { + let hit: DocSearch.docSearchHit = Obj.magic( + Dict.fromArray([ + ("objectID", "crawler-hit"), + ("content", "map(array, fn) returns a new array."), + ("url", "https://rescript-lang.org/docs/manual/api/stdlib/array/#value-map"), + ("type", "content"), + ]), + ) + + let result = Search.normalizeHitUrls([hit], ~siteUrl="https://rescript-lang.org/") + + expect(result[0]->Option.map(hit => hit.url))->toEqual( + Some("/docs/manual/api/stdlib/array/#value-map"), + ) + expect(result[0]->Option.flatMap(hit => hit.url_without_anchor->Nullable.toOption))->toEqual( + Some("/docs/manual/api/stdlib/array/"), + ) +}) + test("renders disabled search copy when Algolia config is missing", async () => { await viewport(1440, 500) diff --git a/src/bindings/DocSearch.res b/src/bindings/DocSearch.res index a97c2540f..79faf2313 100644 --- a/src/bindings/DocSearch.res +++ b/src/bindings/DocSearch.res @@ -23,7 +23,7 @@ type docSearchHit = { objectID: string, content: Nullable.t, url: string, - url_without_anchor: string, + url_without_anchor: Nullable.t, @as("type") type_: contentType, anchor: Nullable.t, hierarchy: hierarchy, diff --git a/src/components/Search.res b/src/components/Search.res index 5ec4b7c52..b4d3e26bb 100644 --- a/src/components/Search.res +++ b/src/components/Search.res @@ -22,7 +22,11 @@ let toRelativeSiteUrl = (url: string, ~siteUrl: string): string => { let normalizeHitUrls = (items: array, ~siteUrl: string) => items->Array.map(hit => { let url = toRelativeSiteUrl(hit.url, ~siteUrl) - let url_without_anchor = toRelativeSiteUrl(hit.url_without_anchor, ~siteUrl) + let urlWithoutAnchor = + hit.url_without_anchor + ->Nullable.toOption + ->Option.getOr(hit.url->String.split("#")->Array.get(0)->Option.getOr(hit.url)) + let url_without_anchor = toRelativeSiteUrl(urlWithoutAnchor, ~siteUrl)->Nullable.make {...hit, url, url_without_anchor} }) From e7c911eb3eabfe6f8b1a8b1f1041824675850468 Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Mon, 27 Apr 2026 12:45:50 -0400 Subject: [PATCH 24/32] fix: run Cypress against preview deploy Compute the Cloudflare Pages branch alias in the E2E job and pass it directly to Cypress. This avoids relying on a deploy job output that GitHub can drop as secret-like, which left Cypress with an empty baseUrl and made it fall back to localhost. --- .github/workflows/deploy.yml | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index bd76c8adb..8e8b89486 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -20,8 +20,6 @@ jobs: contents: read deployments: write pull-requests: write - outputs: - deployment-url: ${{ steps.deploy.outputs.deployment-url }} steps: - uses: actions/checkout@v6.0.2 - name: Setup Node.js environment @@ -100,9 +98,22 @@ jobs: run: yarn install - name: Build ReScript run: yarn build:res + - name: Set Cypress base URL + shell: bash + run: | + RAW_BRANCH="${{ github.head_ref || github.ref_name }}" + + if [[ "$RAW_BRANCH" == "master" ]]; then + echo "CYPRESS_BASE_URL=https://rescript-lang.org" >> "$GITHUB_ENV" + else + SAFE_BRANCH="${RAW_BRANCH//\//-}" + + SAFE_BRANCH=$(echo "$SAFE_BRANCH" | tr '[:upper:]' '[:lower:]') + + echo "CYPRESS_BASE_URL=https://${SAFE_BRANCH}.rescript-lang.pages.dev" >> "$GITHUB_ENV" + fi - name: Cypress E2E tests uses: cypress-io/github-action@v7 with: install: false - browser: chrome - config: baseUrl=${{ needs.deploy.outputs.deployment-url }} + command: yarn cypress run --browser chrome --config baseUrl="$CYPRESS_BASE_URL" From cb64fde97c2f7a3b0bfdd6bcc8cf620bc1990141 Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Mon, 27 Apr 2026 12:56:18 -0400 Subject: [PATCH 25/32] fix: restore crawler search highlights Read crawler snippet highlight markup from _snippetResult.content.value instead of the plain content field. Rebuild API result titles from crawler hierarchy values so Array.map-style matches can still be highlighted in the custom DocSearch hit renderer. --- __tests__/Search_.test.res | 64 ++++++++++++++++- src/bindings/DocSearch.res | 24 ++++++- src/components/Search.res | 138 ++++++++++++++++++++++++++++++++----- 3 files changed, 205 insertions(+), 21 deletions(-) diff --git a/__tests__/Search_.test.res b/__tests__/Search_.test.res index 09cceb2fe..b0cd8a132 100644 --- a/__tests__/Search_.test.res +++ b/__tests__/Search_.test.res @@ -4,6 +4,8 @@ open Vitest // Helper // --------------------------------------------------------------------------- +let highlightedValue = (value: string): DocSearch.highlightedValue => {value: value} + let makeHit = (~type_: DocSearch.contentType, ~url: string): DocSearch.docSearchHit => { objectID: "test", content: Nullable.null, @@ -21,8 +23,8 @@ let makeHit = (~type_: DocSearch.contentType, ~url: string): DocSearch.docSearch lvl6: Nullable.null, }, deprecated: None, - _highlightResult: Obj.magic(Dict.make()), - _snippetResult: Obj.magic(Dict.make()), + _highlightResult: {hierarchy: Nullable.null}, + _snippetResult: {content: Nullable.null}, } // --------------------------------------------------------------------------- @@ -160,6 +162,64 @@ test( }, ) +test("getHighlightedTitle rebuilds crawler API titles and preserves marked prefixes", async () => { + let hit = { + ...makeHit( + ~type_=Content, + ~url="https://rescript-lang.org/docs/manual/api/stdlib/array/#value-mapWithIndex", + ), + hierarchy: { + lvl0: Nullable.make("Array"), + lvl1: Nullable.make("mapWithIndex"), + lvl2: Nullable.null, + lvl3: Nullable.null, + lvl4: Nullable.null, + lvl5: Nullable.null, + lvl6: Nullable.null, + }, + _snippetResult: { + content: Nullable.make(highlightedValue("See Array.map on MDN.")), + }, + } + + expect(Search.getHighlightedTitle(hit))->toBe("Array.mapWithIndex") +}) + +test( + "getHighlightedTitle prefers real hierarchy highlights when Algolia returns them", + async () => { + let highlightedHierarchy: DocSearch.highlightedHierarchy = { + lvl0: Nullable.null, + lvl1: Nullable.null, + lvl2: Nullable.make(highlightedValue("Section title")), + lvl3: Nullable.null, + lvl4: Nullable.null, + lvl5: Nullable.null, + lvl6: Nullable.null, + } + let hit = { + ...makeHit(~type_=Lvl2, ~url="https://rescript-lang.org/docs/manual/page#section"), + _highlightResult: {hierarchy: Nullable.make(highlightedHierarchy)}, + } + + expect(Search.getHighlightedTitle(hit))->toBe("Section title") + }, +) + +test("getContentHtml prefers crawler snippet markup over plain content", async () => { + let hit = { + ...makeHit(~type_=Content, ~url="https://rescript-lang.org/docs/manual/api/stdlib/array/"), + content: Nullable.make("map(array, fn) returns a new array."), + _snippetResult: { + content: Nullable.make(highlightedValue("map(array, fn) returns a new array.")), + }, + } + + expect(Search.getContentHtml(hit))->toEqual( + Some("map(array, fn) returns a new array."), + ) +}) + // --------------------------------------------------------------------------- // isChildHit // --------------------------------------------------------------------------- diff --git a/src/bindings/DocSearch.res b/src/bindings/DocSearch.res index 79faf2313..1abb4663e 100644 --- a/src/bindings/DocSearch.res +++ b/src/bindings/DocSearch.res @@ -19,6 +19,26 @@ type hierarchy = { lvl6: Nullable.t, } +type highlightedValue = {value: string} + +type highlightedHierarchy = { + lvl0: Nullable.t, + lvl1: Nullable.t, + lvl2: Nullable.t, + lvl3: Nullable.t, + lvl4: Nullable.t, + lvl5: Nullable.t, + lvl6: Nullable.t, +} + +type highlightResult = { + hierarchy: Nullable.t, +} + +type snippetResult = { + content: Nullable.t, +} + type docSearchHit = { objectID: string, content: Nullable.t, @@ -30,8 +50,8 @@ type docSearchHit = { // Additional field for deprecation information deprecated: option, // NOTE: docsearch need these two fields to highlight results - _highlightResult: {.}, - _snippetResult: {.}, + _highlightResult: highlightResult, + _snippetResult: snippetResult, } type transformItems = array diff --git a/src/components/Search.res b/src/components/Search.res index b4d3e26bb..d9148ef72 100644 --- a/src/components/Search.res +++ b/src/components/Search.res @@ -36,22 +36,6 @@ let navigator = (~siteUrl: string): DocSearch.navigator => { }, } -let getHighlightedTitle: DocSearch.docSearchHit => string = %raw(` - function(hit) { - var type = hit.type; - var h = hit._highlightResult && hit._highlightResult.hierarchy; - var raw = hit.hierarchy; - try { - if (type && type !== 'lvl1' && type !== 'lvl0') { - var lvl = h && h[type] && h[type].value; - if (lvl) return lvl; - } - if (h && h.lvl1 && h.lvl1.value) return h.lvl1.value; - } catch(e) {} - return (raw && raw.lvl1) || ''; - } -`) - let getSubtitle: DocSearch.docSearchHit => option = %raw(` function(hit) { var type = hit.type; @@ -63,6 +47,120 @@ let getSubtitle: DocSearch.docSearchHit => option = %raw(` } `) +let highlightedValue = (value: Nullable.t): option => + value->Nullable.toOption->Option.map(value => value.value) + +let highlightedValueWithMarkup = (value: Nullable.t): option => + switch highlightedValue(value) { + | Some(value) if value->String.includes("") => Some(value) + | _ => None + } + +let highlightedHierarchyValue = ( + hierarchy: DocSearch.highlightedHierarchy, + type_: DocSearch.contentType, +): option => + switch type_ { + | Lvl0 => hierarchy.lvl0->highlightedValue + | Lvl1 => hierarchy.lvl1->highlightedValue + | Lvl2 => hierarchy.lvl2->highlightedValue + | Lvl3 => hierarchy.lvl3->highlightedValue + | Lvl4 => hierarchy.lvl4->highlightedValue + | Lvl5 => hierarchy.lvl5->highlightedValue + | Lvl6 => hierarchy.lvl6->highlightedValue + | Content => None + } + +let highlightedHierarchyValueWithMarkup = ( + hierarchy: DocSearch.highlightedHierarchy, + type_: DocSearch.contentType, +): option => + switch type_ { + | Lvl0 => hierarchy.lvl0->highlightedValueWithMarkup + | Lvl1 => hierarchy.lvl1->highlightedValueWithMarkup + | Lvl2 => hierarchy.lvl2->highlightedValueWithMarkup + | Lvl3 => hierarchy.lvl3->highlightedValueWithMarkup + | Lvl4 => hierarchy.lvl4->highlightedValueWithMarkup + | Lvl5 => hierarchy.lvl5->highlightedValueWithMarkup + | Lvl6 => hierarchy.lvl6->highlightedValueWithMarkup + | Content => None + } + +let firstMarkedText = (html: string): option => { + switch RegExp.exec(/([^<]+)<\/mark>/, html) { + | Some(result) => + let matches = RegExp.Result.matches(result) + switch matches[0] { + | Some(Some(markedText)) => Some(markedText) + | _ => None + } + | None => None + } +} + +let markTitlePrefix = (title: string, markedText: string): string => { + let markedLength = String.length(markedText) + if ( + markedLength > 0 && title->String.toLowerCase->String.startsWith(markedText->String.toLowerCase) + ) { + let prefix = String.slice(title, ~start=0, ~end=markedLength) + let suffix = String.slice(title, ~start=markedLength) + `${prefix}${suffix}` + } else { + title + } +} + +let getSnippetContent = (hit: DocSearch.docSearchHit): option => + hit._snippetResult.content->highlightedValue + +let getApiTitle = (hit: DocSearch.docSearchHit): option => { + if hit.url->String.includes("/docs/manual/api/") { + switch (hit.hierarchy.lvl0->Nullable.toOption, hit.hierarchy.lvl1->Nullable.toOption) { + | (Some(moduleName), Some(valueName)) if moduleName !== "" && valueName !== "" => + let title = `${moduleName}.${valueName}` + switch hit->getSnippetContent->Option.flatMap(firstMarkedText) { + | Some(markedText) => Some(markTitlePrefix(title, markedText)) + | None => Some(title) + } + | _ => None + } + } else { + None + } +} + +let getHighlightedTitle = (hit: DocSearch.docSearchHit): string => { + let highlightedHierarchy = hit._highlightResult.hierarchy->Nullable.toOption + let highlightedTitleWithMarkup = highlightedHierarchy->Option.flatMap(hierarchy => + switch hit.type_ { + | Lvl0 | Lvl1 => None + | _ => highlightedHierarchyValueWithMarkup(hierarchy, hit.type_) + } + ) + + switch highlightedTitleWithMarkup { + | Some(title) => title + | None => + switch highlightedHierarchy->Option.flatMap(hierarchy => + hierarchy.lvl1->highlightedValueWithMarkup + ) { + | Some(title) => title + | None => + switch getApiTitle(hit) { + | Some(title) => title + | None => + switch highlightedHierarchy->Option.flatMap(hierarchy => + highlightedHierarchyValue(hierarchy, hit.type_) + ) { + | Some(title) => title + | None => hit.hierarchy.lvl1->Nullable.toOption->Option.getOr("") + } + } + } + } +} + let markdownToHtml = (text: string): string => text // Strip stray backslashes from MDX processing @@ -90,10 +188,16 @@ let isChildHit = (hit: DocSearch.docSearchHit) => | Lvl0 | Lvl1 => hit.url->String.includes("#") } +let getContentHtml = (hit: DocSearch.docSearchHit): option => + switch getSnippetContent(hit) { + | Some(content) => Some(content->markdownToHtml) + | None => hit.content->Nullable.toOption->Option.map(markdownToHtml) + } + let hitComponent = ({hit, children: _}: DocSearch.hitComponent): React.element => { let titleHtml = getHighlightedTitle(hit) let subtitle = getSubtitle(hit) - let contentHtml = hit.content->Nullable.toOption->Option.map(markdownToHtml) + let contentHtml = getContentHtml(hit) let isChild = isChildHit(hit) From d66c31221a404e2a11c14e3a8c3e6e459e16c6f3 Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Mon, 27 Apr 2026 12:57:41 -0400 Subject: [PATCH 26/32] fix: pass Cypress preview URL within e2e job Use an E2E-job step output for the Cloudflare Pages branch alias and feed that into the Cypress action config. This avoids the deploy job output being dropped as secret-like while still passing Cypress a concrete baseUrl. --- .github/workflows/deploy.yml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 8e8b89486..0fddbaa84 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -99,21 +99,25 @@ jobs: - name: Build ReScript run: yarn build:res - name: Set Cypress base URL + id: cypress-base-url shell: bash run: | RAW_BRANCH="${{ github.head_ref || github.ref_name }}" if [[ "$RAW_BRANCH" == "master" ]]; then - echo "CYPRESS_BASE_URL=https://rescript-lang.org" >> "$GITHUB_ENV" + CYPRESS_BASE_URL="https://rescript-lang.org" else SAFE_BRANCH="${RAW_BRANCH//\//-}" SAFE_BRANCH=$(echo "$SAFE_BRANCH" | tr '[:upper:]' '[:lower:]') - echo "CYPRESS_BASE_URL=https://${SAFE_BRANCH}.rescript-lang.pages.dev" >> "$GITHUB_ENV" + CYPRESS_BASE_URL="https://${SAFE_BRANCH}.rescript-lang.pages.dev" fi + + echo "url=$CYPRESS_BASE_URL" >> "$GITHUB_OUTPUT" - name: Cypress E2E tests uses: cypress-io/github-action@v7 with: install: false - command: yarn cypress run --browser chrome --config baseUrl="$CYPRESS_BASE_URL" + browser: chrome + config: baseUrl=${{ steps.cypress-base-url.outputs.url }} From 3e0ac90734d30a341d36b562331fb311a05b723a Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Mon, 27 Apr 2026 13:47:33 -0400 Subject: [PATCH 27/32] fix: tolerate search hits without snippet metadata Treat DocSearch highlight and snippet payloads as optional crawler fields before reading nested content. This keeps the custom hit renderer from crashing when Algolia returns plain hits without _snippetResult. --- __tests__/Search_.test.res | 30 ++++++++++++++++++++++++++++++ src/bindings/DocSearch.res | 4 ++-- src/components/Search.res | 10 ++++++++-- 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/__tests__/Search_.test.res b/__tests__/Search_.test.res index b0cd8a132..4a8c74d4a 100644 --- a/__tests__/Search_.test.res +++ b/__tests__/Search_.test.res @@ -220,6 +220,36 @@ test("getContentHtml prefers crawler snippet markup over plain content", async ( ) }) +test( + "getContentHtml falls back to plain content when crawler hit has no snippet result", + async () => { + let hit: DocSearch.docSearchHit = Obj.magic( + Dict.fromArray([ + ("objectID", "crawler-hit"), + ("content", "map(array, fn) returns a new array."), + ("url", "https://rescript-lang.org/docs/manual/api/stdlib/array/#value-map"), + ("type", "content"), + ( + "hierarchy", + Obj.magic( + Dict.fromArray([ + ("lvl0", Obj.magic("Array")), + ("lvl1", Obj.magic("map")), + ("lvl2", Obj.magic(Nullable.null)), + ("lvl3", Obj.magic(Nullable.null)), + ("lvl4", Obj.magic(Nullable.null)), + ("lvl5", Obj.magic(Nullable.null)), + ("lvl6", Obj.magic(Nullable.null)), + ]), + ), + ), + ]), + ) + + expect(Search.getContentHtml(hit))->toEqual(Some("map(array, fn) returns a new array.")) + }, +) + // --------------------------------------------------------------------------- // isChildHit // --------------------------------------------------------------------------- diff --git a/src/bindings/DocSearch.res b/src/bindings/DocSearch.res index 1abb4663e..d51f22c5a 100644 --- a/src/bindings/DocSearch.res +++ b/src/bindings/DocSearch.res @@ -50,8 +50,8 @@ type docSearchHit = { // Additional field for deprecation information deprecated: option, // NOTE: docsearch need these two fields to highlight results - _highlightResult: highlightResult, - _snippetResult: snippetResult, + _highlightResult?: highlightResult, + _snippetResult?: snippetResult, } type transformItems = array diff --git a/src/components/Search.res b/src/components/Search.res index d9148ef72..6f8ac9ef3 100644 --- a/src/components/Search.res +++ b/src/components/Search.res @@ -112,7 +112,10 @@ let markTitlePrefix = (title: string, markedText: string): string => { } let getSnippetContent = (hit: DocSearch.docSearchHit): option => - hit._snippetResult.content->highlightedValue + switch hit._snippetResult { + | Some(snippetResult) => snippetResult.content->highlightedValue + | None => None + } let getApiTitle = (hit: DocSearch.docSearchHit): option => { if hit.url->String.includes("/docs/manual/api/") { @@ -131,7 +134,10 @@ let getApiTitle = (hit: DocSearch.docSearchHit): option => { } let getHighlightedTitle = (hit: DocSearch.docSearchHit): string => { - let highlightedHierarchy = hit._highlightResult.hierarchy->Nullable.toOption + let highlightedHierarchy = + hit._highlightResult->Option.flatMap(highlightResult => + highlightResult.hierarchy->Nullable.toOption + ) let highlightedTitleWithMarkup = highlightedHierarchy->Option.flatMap(hierarchy => switch hit.type_ { | Lvl0 | Lvl1 => None From 4ef439ec634bfa02f627e8167fe9b234e02bb84d Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Mon, 27 Apr 2026 13:51:47 -0400 Subject: [PATCH 28/32] fix: isolate DocSearch render errors Wrap the DocSearch portal in a search-specific error boundary so result rendering crashes show a local search fallback instead of tripping the app route error boundary. Add a regression test that verifies surrounding page content stays rendered. --- __tests__/Search_.test.res | 21 ++++++++++++ src/components/Search.res | 69 +++++++++++++++++++++++++++++--------- 2 files changed, 74 insertions(+), 16 deletions(-) diff --git a/__tests__/Search_.test.res b/__tests__/Search_.test.res index 4a8c74d4a..5e9468112 100644 --- a/__tests__/Search_.test.res +++ b/__tests__/Search_.test.res @@ -27,6 +27,11 @@ let makeHit = (~type_: DocSearch.contentType, ~url: string): DocSearch.docSearch _snippetResult: {content: Nullable.null}, } +module ThrowsOnRender = { + @react.component + let make = (): React.element => failwith("search render exploded") +} + // --------------------------------------------------------------------------- // markdownToHtml // --------------------------------------------------------------------------- @@ -250,6 +255,22 @@ test( }, ) +test("search error boundary catches render errors without replacing surrounding page", async () => { + await viewport(1440, 500) + + let screen = await render( +
    + {React.string("Docs page stays rendered")} + ()}> + + +
    , + ) + + await element(await screen->getByText("Docs page stays rendered"))->toBeVisible + await element(await screen->getByText("Search unavailable"))->toBeVisible +}) + // --------------------------------------------------------------------------- // isChildHit // --------------------------------------------------------------------------- diff --git a/src/components/Search.res b/src/components/Search.res index 6f8ac9ef3..6c34a89b2 100644 --- a/src/components/Search.res +++ b/src/components/Search.res @@ -227,11 +227,46 @@ let hitComponent = ({hit, children: _}: DocSearch.hitComponent): React.element =
    } +module ErrorBoundary = { + @react.component + let make = (~children: React.element, ~onClose: unit => unit) => { + +
    + + {React.string(unavailableText)} + + +
    } + > + children +
    + } +} + @react.component let make = () => { let (state, setState) = React.useState(_ => Inactive) let algoliaConfig = Env.algoliaPublicConfig + let deactivateSearch = () => { + switch WebAPI.Document.querySelector(document, "body") { + | Value(body) => WebAPI.DOMTokenList.remove(body.classList, "DocSearch--active") + | Null => () + } + setState(_ => Inactive) + } + let handleCloseModal = () => { let () = switch WebAPI.Document.querySelector(document, ".DocSearch-Modal") { | Value(modal) => @@ -243,7 +278,7 @@ let make = () => { }) | Null => setState(_ => Inactive) } - | Null => () + | Null => deactivateSearch() } } @@ -314,21 +349,23 @@ let make = () => { switch ReactDOM.querySelector("body") { | Some(element) => ReactDOM.createPortal( - normalizeHitUrls(items, ~siteUrl=Env.root_url)} - hitComponent - onClose - initialScrollY={window.scrollY->Float.toInt} - searchParameters={ - distinct: 3, - hitsPerPage: 20, - attributesToSnippet: ["content:9999"], - } - />, + + normalizeHitUrls(items, ~siteUrl=Env.root_url)} + hitComponent + onClose + initialScrollY={window.scrollY->Float.toInt} + searchParameters={ + distinct: 3, + hitsPerPage: 20, + attributesToSnippet: ["content:9999"], + } + /> + , element, ) | None => React.null From c59e1c5edff37d55c7352668c06c65850aad36c9 Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Mon, 27 Apr 2026 14:27:03 -0400 Subject: [PATCH 29/32] fix: separate Belt search result groups Normalize API search hierarchy labels so Stdlib keeps module names like Array while Belt renders as Belt.Array without reordering Algolia results. Render API hit titles as value names and strip module prefixes before applying highlight markup. --- __tests__/Search_.test.res | 81 ++++++++++++++++++++++++++++++++------ src/components/Search.res | 53 +++++++++++++++++++++++-- 2 files changed, 118 insertions(+), 16 deletions(-) diff --git a/__tests__/Search_.test.res b/__tests__/Search_.test.res index 5e9468112..626cf9372 100644 --- a/__tests__/Search_.test.res +++ b/__tests__/Search_.test.res @@ -27,6 +27,15 @@ let makeHit = (~type_: DocSearch.contentType, ~url: string): DocSearch.docSearch _snippetResult: {content: Nullable.null}, } +let withHierarchy = ( + hit: DocSearch.docSearchHit, + ~lvl0: string, + ~lvl1: string, +): DocSearch.docSearchHit => { + ...hit, + hierarchy: {...hit.hierarchy, lvl0: Nullable.make(lvl0), lvl1: Nullable.make(lvl1)}, +} + module ThrowsOnRender = { @react.component let make = (): React.element => failwith("search render exploded") @@ -167,27 +176,23 @@ test( }, ) -test("getHighlightedTitle rebuilds crawler API titles and preserves marked prefixes", async () => { - let hit = { - ...makeHit( +test("getHighlightedTitle renders crawler API titles as value names", async () => { + let hit = withHierarchy( + makeHit( ~type_=Content, ~url="https://rescript-lang.org/docs/manual/api/stdlib/array/#value-mapWithIndex", ), - hierarchy: { - lvl0: Nullable.make("Array"), - lvl1: Nullable.make("mapWithIndex"), - lvl2: Nullable.null, - lvl3: Nullable.null, - lvl4: Nullable.null, - lvl5: Nullable.null, - lvl6: Nullable.null, - }, + ~lvl0="Array", + ~lvl1="mapWithIndex", + ) + let hit = { + ...hit, _snippetResult: { content: Nullable.make(highlightedValue("See Array.map on MDN.")), }, } - expect(Search.getHighlightedTitle(hit))->toBe("Array.mapWithIndex") + expect(Search.getHighlightedTitle(hit))->toBe("mapWithIndex") }) test( @@ -385,6 +390,20 @@ test("normalizeHitUrls tolerates crawler hits without url_without_anchor", async ("content", "map(array, fn) returns a new array."), ("url", "https://rescript-lang.org/docs/manual/api/stdlib/array/#value-map"), ("type", "content"), + ( + "hierarchy", + Obj.magic( + Dict.fromArray([ + ("lvl0", Obj.magic("Array")), + ("lvl1", Obj.magic("map")), + ("lvl2", Obj.magic(Nullable.null)), + ("lvl3", Obj.magic(Nullable.null)), + ("lvl4", Obj.magic(Nullable.null)), + ("lvl5", Obj.magic(Nullable.null)), + ("lvl6", Obj.magic(Nullable.null)), + ]), + ), + ), ]), ) @@ -398,6 +417,42 @@ test("normalizeHitUrls tolerates crawler hits without url_without_anchor", async ) }) +test("normalizeHitUrls keeps API hit order while separating Belt group labels", async () => { + let beltHit = withHierarchy( + { + ...makeHit( + ~type_=Content, + ~url="https://rescript-lang.org/docs/manual/api/belt/array/#value-map", + ), + objectID: "belt-array-map", + }, + ~lvl0="Array", + ~lvl1="map", + ) + let stdlibHit = withHierarchy( + { + ...makeHit( + ~type_=Content, + ~url="https://rescript-lang.org/docs/manual/api/stdlib/array/#value-map", + ), + objectID: "stdlib-array-map", + }, + ~lvl0="Array", + ~lvl1="map", + ) + + let result = Search.normalizeHitUrls([beltHit, stdlibHit], ~siteUrl="https://rescript-lang.org/") + + expect(result[0]->Option.map(hit => hit.objectID))->toEqual(Some("belt-array-map")) + expect(result[0]->Option.flatMap(hit => hit.hierarchy.lvl0->Nullable.toOption))->toEqual( + Some("Belt.Array"), + ) + expect(result[1]->Option.map(hit => hit.objectID))->toEqual(Some("stdlib-array-map")) + expect(result[1]->Option.flatMap(hit => hit.hierarchy.lvl0->Nullable.toOption))->toEqual( + Some("Array"), + ) +}) + test("renders disabled search copy when Algolia config is missing", async () => { await viewport(1440, 500) diff --git a/src/components/Search.res b/src/components/Search.res index 6c34a89b2..6fe61a730 100644 --- a/src/components/Search.res +++ b/src/components/Search.res @@ -19,6 +19,40 @@ let toRelativeSiteUrl = (url: string, ~siteUrl: string): string => { } } +type apiNamespace = StdlibApi | BeltApi + +let apiNamespaceForUrl = (url: string): option => + if url->String.includes("/docs/manual/api/stdlib/") { + Some(StdlibApi) + } else if url->String.includes("/docs/manual/api/belt/") { + Some(BeltApi) + } else { + None + } + +let stripCaseInsensitivePrefix = (value: string, prefix: string): string => { + if ( + prefix->String.length > 0 && + value->String.toLowerCase->String.startsWith(prefix->String.toLowerCase) + ) { + String.slice(value, ~start=String.length(prefix)) + } else { + value + } +} + +let baseApiModuleName = (moduleName: string): string => + moduleName->stripCaseInsensitivePrefix("Stdlib.")->stripCaseInsensitivePrefix("Belt.") + +let apiGroupName = (hit: DocSearch.docSearchHit): option => + switch (apiNamespaceForUrl(hit.url), hit.hierarchy.lvl0->Nullable.toOption) { + | (Some(StdlibApi), Some(moduleName)) if moduleName !== "" => + Some(moduleName->stripCaseInsensitivePrefix("Stdlib.")) + | (Some(BeltApi), Some(moduleName)) if moduleName !== "" => + Some(`Belt.${moduleName->baseApiModuleName}`) + | _ => None + } + let normalizeHitUrls = (items: array, ~siteUrl: string) => items->Array.map(hit => { let url = toRelativeSiteUrl(hit.url, ~siteUrl) @@ -27,7 +61,11 @@ let normalizeHitUrls = (items: array, ~siteUrl: string) ->Nullable.toOption ->Option.getOr(hit.url->String.split("#")->Array.get(0)->Option.getOr(hit.url)) let url_without_anchor = toRelativeSiteUrl(urlWithoutAnchor, ~siteUrl)->Nullable.make - {...hit, url, url_without_anchor} + let hierarchy = switch hit->apiGroupName { + | Some(lvl0) => {...hit.hierarchy, lvl0: Nullable.make(lvl0)} + | None => hit.hierarchy + } + {...hit, url, url_without_anchor, hierarchy} }) let navigator = (~siteUrl: string): DocSearch.navigator => { @@ -111,6 +149,14 @@ let markTitlePrefix = (title: string, markedText: string): string => { } } +let stripApiModulePrefix = (markedText: string, moduleName: string): string => { + let moduleName = moduleName->baseApiModuleName + markedText + ->stripCaseInsensitivePrefix(`Stdlib.${moduleName}.`) + ->stripCaseInsensitivePrefix(`Belt.${moduleName}.`) + ->stripCaseInsensitivePrefix(`${moduleName}.`) +} + let getSnippetContent = (hit: DocSearch.docSearchHit): option => switch hit._snippetResult { | Some(snippetResult) => snippetResult.content->highlightedValue @@ -121,9 +167,10 @@ let getApiTitle = (hit: DocSearch.docSearchHit): option => { if hit.url->String.includes("/docs/manual/api/") { switch (hit.hierarchy.lvl0->Nullable.toOption, hit.hierarchy.lvl1->Nullable.toOption) { | (Some(moduleName), Some(valueName)) if moduleName !== "" && valueName !== "" => - let title = `${moduleName}.${valueName}` + let title = valueName switch hit->getSnippetContent->Option.flatMap(firstMarkedText) { - | Some(markedText) => Some(markTitlePrefix(title, markedText)) + | Some(markedText) => + Some(markTitlePrefix(title, stripApiModulePrefix(markedText, moduleName))) | None => Some(title) } | _ => None From bf9ee3f2d7f91f551348c49f42215e29713617f1 Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Mon, 27 Apr 2026 14:37:11 -0400 Subject: [PATCH 30/32] fix: add DocSearch heading markers Add explicit DocSearch hierarchy classes to shared Markdown heading components so crawler dashboard selectors can use stable lvl1-lvl5 markers across manual, React, and API docs. --- __tests__/MarkdownComponents_.test.res | 35 ++++++++++++++++++++++++++ src/components/Markdown.res | 10 ++++---- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/__tests__/MarkdownComponents_.test.res b/__tests__/MarkdownComponents_.test.res index c88dded7c..ee650ef62 100644 --- a/__tests__/MarkdownComponents_.test.res +++ b/__tests__/MarkdownComponents_.test.res @@ -36,6 +36,41 @@ test("h1 keeps the generated markdown id for DocSearch", async () => { } }) +test("markdown headings expose explicit DocSearch hierarchy markers", async () => { + await viewport(1440, 900) + + let _screen = await render( +
    + {React.string("Heading Level 1")} + {React.string("Heading Level 2")} + {React.string("Heading Level 3")} + {React.string("Heading Level 4")} + {React.string("Heading Level 5")} +
    , + ) + + switch document->WebAPI.Document.querySelector("h1.DocSearch-lvl1#heading-level-1") { + | Value(_) => () + | Null => failwith("expected h1 to expose DocSearch lvl1") + } + switch document->WebAPI.Document.querySelector("h2.DocSearch-lvl2#heading-level-2") { + | Value(_) => () + | Null => failwith("expected h2 to expose DocSearch lvl2") + } + switch document->WebAPI.Document.querySelector("h3.DocSearch-lvl3#heading-level-3") { + | Value(_) => () + | Null => failwith("expected h3 to expose DocSearch lvl3") + } + switch document->WebAPI.Document.querySelector("h4.DocSearch-lvl4#heading-level-4") { + | Value(_) => () + | Null => failwith("expected h4 to expose DocSearch lvl4") + } + switch document->WebAPI.Document.querySelector("h5.DocSearch-lvl5#heading-level-5") { + | Value(_) => () + | Null => failwith("expected h5 to expose DocSearch lvl5") + } +}) + test("heading anchor links do not duplicate heading ids", async () => { await viewport(1440, 900) diff --git a/src/components/Markdown.res b/src/components/Markdown.res index 06e4785fd..b075e5efb 100644 --- a/src/components/Markdown.res +++ b/src/components/Markdown.res @@ -127,7 +127,7 @@ module Anchor = { module H1 = { @react.component let make = (~id=?, ~title=?, ~children) => -

    children

    +

    children

    } module H2 = { @@ -136,7 +136,7 @@ module H2 = { // Children may not be a string <> -

    +

    children @@ -149,7 +149,7 @@ module H2 = { module H3 = { @react.component let make = (~id, ~children, ~title=?) => { -

    +

    children @@ -161,7 +161,7 @@ module H3 = { module H4 = { @react.component let make = (~id, ~children, ~title=?) => { -

    +

    children @@ -175,7 +175,7 @@ module H5 = { let make = (~id, ~children, ~title=?) => {
    children From 465e2b07de803a9a38638ab69d2d50d65d9b04ea Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Mon, 27 Apr 2026 14:48:39 -0400 Subject: [PATCH 31/32] fix: add trailing slashes to sitemap URLs Ensure sitemap loc entries use Cloudflare's canonical trailing-slash page URLs so crawlers do not pay for redirect hops on every route. --- __tests__/Sitemap_.test.res | 4 ++-- src/common/Sitemap.res | 10 +++++++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/__tests__/Sitemap_.test.res b/__tests__/Sitemap_.test.res index 210c1263a..f35927387 100644 --- a/__tests__/Sitemap_.test.res +++ b/__tests__/Sitemap_.test.res @@ -12,10 +12,10 @@ test("renders sorted unique sitemap URLs with a normalized base URL", async () = https://preview.example.com/ - https://preview.example.com/blog + https://preview.example.com/blog/ - https://preview.example.com/docs/manual/introduction + https://preview.example.com/docs/manual/introduction/ `) diff --git a/src/common/Sitemap.res b/src/common/Sitemap.res index 70713935d..2dfacc6b1 100644 --- a/src/common/Sitemap.res +++ b/src/common/Sitemap.res @@ -49,8 +49,16 @@ let normalizePaths = paths => } }) +let withTrailingSlash = path => { + if path === "/" || path->String.endsWith("/") { + path + } else { + path ++ "/" + } +} + let renderUrl = (~baseUrl, path) => { - let loc = baseUrl ++ path + let loc = baseUrl ++ path->withTrailingSlash ` ${loc->escapeXml} From 0cdb18a54cc85b76aed0265a5de80e494ebb4449 Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Mon, 27 Apr 2026 16:47:02 -0400 Subject: [PATCH 32/32] fix: route search hits through React Router Render DocSearch hits with React Router links so Algolia's absolute URLs can be normalized to relative paths and selected without reloading the app. --- __tests__/Search_.test.res | 25 +++++++++++++++++++++++++ src/components/Search.res | 4 ++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/__tests__/Search_.test.res b/__tests__/Search_.test.res index 626cf9372..887d021c6 100644 --- a/__tests__/Search_.test.res +++ b/__tests__/Search_.test.res @@ -41,6 +41,14 @@ module ThrowsOnRender = { let make = (): React.element => failwith("search render exploded") } +module CurrentPath = { + @react.component + let make = () => { + let location = ReactRouter.useLocation() + {React.string((location.pathname :> string))} + } +} + // --------------------------------------------------------------------------- // markdownToHtml // --------------------------------------------------------------------------- @@ -230,6 +238,23 @@ test("getContentHtml prefers crawler snippet markup over plain content", async ( ) }) +test("hitComponent routes relative hit URLs through React Router", async () => { + await viewport(1440, 500) + + let hit = makeHit(~type_=Lvl1, ~url="/docs/manual/api/stdlib/list/") + + let screen = await render( + + + {Search.hitComponent({hit, children: React.null})} + , + ) + + await element(await screen->getByText("/"))->toBeVisible + await (await screen->getByText("Test Page"))->click + await element(await screen->getByText("/docs/manual/api/stdlib/list/"))->toBeVisible +}) + test( "getContentHtml falls back to plain content when crawler hit has no snippet result", async () => { diff --git a/src/components/Search.res b/src/components/Search.res index 6fe61a730..42c5e7c41 100644 --- a/src/components/Search.res +++ b/src/components/Search.res @@ -253,7 +253,7 @@ let hitComponent = ({hit, children: _}: DocSearch.hitComponent): React.element = let contentHtml = getContentHtml(hit) let isChild = isChildHit(hit) - +
    {isChild ? : React.null} {isChild ? : } @@ -271,7 +271,7 @@ let hitComponent = ({hit, children: _}: DocSearch.hitComponent): React.element =

  • - + } module ErrorBoundary = {