From 7e95b5ada6a1bffe321cffe2ecf28eb95f124c6e Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Sun, 5 Apr 2026 12:19:13 -0400 Subject: [PATCH 01/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] [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/17] 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/17] 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/17] 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/17] 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(", ")}`