diff --git a/dev/vite.config.mts b/dev/vite.config.mts index e269e2b..12c4ceb 100644 --- a/dev/vite.config.mts +++ b/dev/vite.config.mts @@ -155,11 +155,7 @@ export default defineConfig({ "/": createDevSidebar("./src/routes", ["es", "fr", "router", "v1"]), }, search: { - docsearch: { - appId: "QAS0JNC31U", - indexName: "SolidBase Docs", - apiKey: "768424fdd93c7150a7a017eb556fa8a3", - }, + local: true, }, }, }), diff --git a/docs/src/routes/guide/(2)config.mdx b/docs/src/routes/guide/(2)config.mdx index db697e2..5ef8958 100644 --- a/docs/src/routes/guide/(2)config.mdx +++ b/docs/src/routes/guide/(2)config.mdx @@ -445,3 +445,17 @@ sidebar: { }, // .. ``` + +#### Search + +Enable built-in local search without an external service: + +```ts title="app.config.ts" +// .. +search: { + local: true, +}, +// .. +``` + +See the [Search reference](/reference/default-theme/search) for page exclusion, matching behavior, text overrides, and Algolia DocSearch configuration. diff --git a/docs/src/routes/reference/default-theme/components/header.mdx b/docs/src/routes/reference/default-theme/components/header.mdx index 201a8f3..2e02f05 100644 --- a/docs/src/routes/reference/default-theme/components/header.mdx +++ b/docs/src/routes/reference/default-theme/components/header.mdx @@ -25,5 +25,6 @@ Top navigation, version controls, locale controls, theme controls, and mobile na - uses a mobile nav dialog on small screens - includes [`VersionSelector`](/reference/default-theme/components/version-selector) next to the logo when multiple versions are available - includes [`LocaleSelector`](/reference/default-theme/components/locale-selector) and [`ThemeSelector`](/reference/default-theme/components/theme-selector) +- includes [local search or DocSearch](/reference/default-theme/search) when configured - shows mobile sidebar and table-of-contents toggles only when content exists - marks nav links active using `activeMatch` or the item `link` diff --git a/docs/src/routes/reference/default-theme/index.mdx b/docs/src/routes/reference/default-theme/index.mdx index 5a39ed8..82d82fd 100644 --- a/docs/src/routes/reference/default-theme/index.mdx +++ b/docs/src/routes/reference/default-theme/index.mdx @@ -34,7 +34,7 @@ Configuration lives in: - `badges`: `{ icons?: Record }` - `nav`: `Array<{ text: string; link: string; activeMatch?: string }>` - `sidebar`: `SidebarConfig` -- `search`: `object` +- `search`: [`SearchConfig`](/reference/default-theme/search) - `fonts`: `{ inter?: false; lexend?: false; jetbrainsMono?: false } | false` - `text`: `Partial` diff --git a/docs/src/routes/reference/default-theme/search.mdx b/docs/src/routes/reference/default-theme/search.mdx new file mode 100644 index 0000000..ec800fb --- /dev/null +++ b/docs/src/routes/reference/default-theme/search.mdx @@ -0,0 +1,70 @@ +--- +title: Search +--- + +# Search + +The default theme supports built-in local search and hosted Algolia DocSearch. + +## Local Search + +Enable local search without an external service: + +```ts title="app.config.ts" +themeConfig: { + search: { + local: true, + }, +}, +``` + +SolidBase uses [Pagefind](https://pagefind.app/) to build the search index from page titles, descriptions, headings, and Markdown content. Results are limited to the current locale and configured route axes, such as project and version. + +Pagefind loads its search engine and only the required index chunks when search starts. It supports prefix matching and language-aware word stemming. Use `Command+K` or `Control+K` to open the dialog. + +Exclude a page from local search with frontmatter: + +```md +--- +search: false +--- +``` + +## Algolia DocSearch + +Configure hosted DocSearch instead of local search: + +```ts title="app.config.ts" +themeConfig: { + search: { + docsearch: { + appId: "YOUR_APP_ID", + apiKey: "YOUR_SEARCH_API_KEY", + indexName: "YOUR_INDEX_NAME", + }, + }, +}, +``` + +Local search and DocSearch are mutually exclusive. + +## Content Security Policy + +Pagefind uses WebAssembly and a web worker. A strict Content Security Policy must allow `script-src 'wasm-unsafe-eval'` and `worker-src 'self' blob:`. See [Pagefind's hosting guide](https://pagefind.app/docs/hosting/) for details. + +## Text + +Override search labels through `themeConfig.text`: + +```ts title="app.config.ts" +themeConfig: { + text: { + search: "Search", + searchPlaceholder: "Search documentation", + searchClose: "Close search", + searchLoading: "Loading search…", + searchNoResults: "No results found", + searchUnavailable: "Search is unavailable", + }, +}, +``` \ No newline at end of file diff --git a/docs/src/routes/reference/frontmatter.mdx b/docs/src/routes/reference/frontmatter.mdx index 7dc9dcd..93570e5 100644 --- a/docs/src/routes/reference/frontmatter.mdx +++ b/docs/src/routes/reference/frontmatter.mdx @@ -170,3 +170,18 @@ sitemap: ``` You can also write `sitemap: false` to exclude the page entirely. + +## Search + +Type: `false | undefined` + +Controls whether the page is included in the built-in local search index. + +```md +--- +title: Internal Notes +search: false +--- +``` + +See the [Search reference](/reference/default-theme/search) for local search and DocSearch configuration. diff --git a/package.json b/package.json index 4669791..68bea45 100644 --- a/package.json +++ b/package.json @@ -186,6 +186,7 @@ "mdast-util-mdx-jsx": "^3.2.0", "mdast-util-to-string": "^4.0.0", "mdast-util-toc": "^7.1.0", + "pagefind": "1.5.2", "parse-numeric-range": "^1.3.0", "prettier": "4.0.0-alpha.13", "rehype-autolink-headings": "^7.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2fdcbb5..ea6cca4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -116,6 +116,9 @@ importers: mdast-util-toc: specifier: ^7.1.0 version: 7.1.0 + pagefind: + specifier: 1.5.2 + version: 1.5.2 parse-numeric-range: specifier: ^1.3.0 version: 1.3.0 @@ -1153,6 +1156,41 @@ packages: '@oxc-project/types@0.143.0': resolution: {integrity: sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==} + '@pagefind/darwin-arm64@1.5.2': + resolution: {integrity: sha512-MXpI+7HsAdPkvJ0gk9xj9g541BCqBZOBbdwj9g6lB5LCj6kSV6nqDSjzcAJwvOsfu0fjwvC8hQU+ecfhp+MpiQ==} + cpu: [arm64] + os: [darwin] + + '@pagefind/darwin-x64@1.5.2': + resolution: {integrity: sha512-IojxFWMEJe0RQ7PQ3KXQsPIImNsbpPYpoZ+QUDrL8fAl/O27IX+LVLs74/UzEZy5uA2LD8Nz1AiwKr72vrkZQw==} + cpu: [x64] + os: [darwin] + + '@pagefind/freebsd-x64@1.5.2': + resolution: {integrity: sha512-7EVzo9+0w+2cbe671BtMj10UlNo83I+HrLVLfRxO731svHRJKUfJ/mo05gU14pe9PCfpKNQT8FS3Xc/oDN6pOA==} + cpu: [x64] + os: [freebsd] + + '@pagefind/linux-arm64@1.5.2': + resolution: {integrity: sha512-Ovt9+K35sqzn8H3ZMXGwls4TD/wMJuvRtShHIsmUQREmaxjrDEX7gHckRCrwYJ4XE1H1p6HkLz3wukrAnsfXQw==} + cpu: [arm64] + os: [linux] + + '@pagefind/linux-x64@1.5.2': + resolution: {integrity: sha512-V+tFqHKXhQKq/WqPBD67AFy7scn1/aZID00ws4fSDd+1daSi5UHR9VVlRrOUYKxn3VuFQYRD7lYXdZK1WED1YA==} + cpu: [x64] + os: [linux] + + '@pagefind/windows-arm64@1.5.2': + resolution: {integrity: sha512-hN9Nh90fNW61nNRCW9ZyQrAj/mD0eRvmJ8NlTUzkbuW8kIzGJUi3cxjFkEcMZ5h/8FsKWD/VcouZl4yo1F7B6g==} + cpu: [arm64] + os: [win32] + + '@pagefind/windows-x64@1.5.2': + resolution: {integrity: sha512-Fa2Iyw7kaDRzGMfNYNUXNW2zbL5FQVDgSOcbDHdzBrDEdpqOqg8TcZ68F22ol6NJ9IGzvUdmeyZypLW5dyhqsg==} + cpu: [x64] + os: [win32] + '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} @@ -2644,6 +2682,10 @@ packages: package-manager-detector@1.8.0: resolution: {integrity: sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==} + pagefind@1.5.2: + resolution: {integrity: sha512-XTUaK0hXMCu2jszWE584JGQT7y284TmMV9l/HX3rnG5uo3rHI/uHU56XTyyyPFjeWEBxECbAi0CaFDJOONtG0Q==} + hasBin: true + pako@0.2.9: resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==} @@ -4194,6 +4236,27 @@ snapshots: '@oxc-project/types@0.143.0': {} + '@pagefind/darwin-arm64@1.5.2': + optional: true + + '@pagefind/darwin-x64@1.5.2': + optional: true + + '@pagefind/freebsd-x64@1.5.2': + optional: true + + '@pagefind/linux-arm64@1.5.2': + optional: true + + '@pagefind/linux-x64@1.5.2': + optional: true + + '@pagefind/windows-arm64@1.5.2': + optional: true + + '@pagefind/windows-x64@1.5.2': + optional: true + '@polka/url@1.0.0-next.29': {} '@resvg/resvg-wasm@2.4.0': {} @@ -6186,6 +6249,16 @@ snapshots: package-manager-detector@1.8.0: {} + pagefind@1.5.2: + optionalDependencies: + '@pagefind/darwin-arm64': 1.5.2 + '@pagefind/darwin-x64': 1.5.2 + '@pagefind/freebsd-x64': 1.5.2 + '@pagefind/linux-arm64': 1.5.2 + '@pagefind/linux-x64': 1.5.2 + '@pagefind/windows-arm64': 1.5.2 + '@pagefind/windows-x64': 1.5.2 + pako@0.2.9: {} parse-css-color@0.2.1: diff --git a/src/config/vite-plugin/generated-asset.ts b/src/config/vite-plugin/generated-asset.ts index 80d49ac..59c04e8 100644 --- a/src/config/vite-plugin/generated-asset.ts +++ b/src/config/vite-plugin/generated-asset.ts @@ -13,6 +13,7 @@ type GeneratedAssetPluginOptions = { source: string, importer: string, ) => Promise<{ id: string } | null>, + watch: (filePath: string) => void, ): Promise; }; @@ -26,6 +27,21 @@ export function createGeneratedAssetPlugin( ): PluginOption { let root = process.cwd(); let assetRoot = join(root, options.assetDir); + let watchedFiles = new Set(); + + async function writeAssets(context: any) { + const nextWatchedFiles = new Set(); + await options.write( + root, + (source, importer) => context.resolve(source, importer), + (filePath) => { + const normalizedPath = normalize(filePath); + nextWatchedFiles.add(normalizedPath); + context.addWatchFile(normalizedPath); + }, + ); + watchedFiles = nextWatchedFiles; + } async function serveGeneratedAsset(url: string | undefined, res: any) { if (!url || url === "/") return false; @@ -55,6 +71,8 @@ export function createGeneratedAssetPlugin( res.setHeader("Content-Disposition", "inline"); } else if (filePath.endsWith(".txt")) { res.setHeader("Content-Type", "text/plain; charset=utf-8"); + } else if (filePath.endsWith(".js") || filePath.endsWith(".mjs")) { + res.setHeader("Content-Type", "text/javascript; charset=utf-8"); } res.statusCode = 200; res.end(content); @@ -94,9 +112,10 @@ export function createGeneratedAssetPlugin( }); }, async buildStart() { - await options.write(root, (source: string, importer: string) => - this.resolve(source, importer), - ); + await writeAssets(this); + }, + async watchChange(id) { + if (watchedFiles.has(normalize(id))) await writeAssets(this); }, }; } diff --git a/src/default-theme/components/Header.tsx b/src/default-theme/components/Header.tsx index 1a61652..50fc0f4 100644 --- a/src/default-theme/components/Header.tsx +++ b/src/default-theme/components/Header.tsx @@ -18,14 +18,19 @@ import { } from "../context.jsx"; import { useRouteConfig } from "../utils.js"; import styles from "./Header.module.css"; -import DocSearch from "./vendor/DocSearch.jsx"; export default function Header() { const [tocRef, setTocRef] = createSignal(); const [navRef, setNavRef] = createSignal(); - const { ThemeSelector, LocaleSelector, VersionSelector, TableOfContents } = - useDefaultThemeComponents(); + const { + DocSearch, + LocalSearch, + ThemeSelector, + LocaleSelector, + VersionSelector, + TableOfContents, + } = useDefaultThemeComponents(); const { tocOpen, @@ -47,6 +52,18 @@ export default function Header() { sidebar()!.items.length > 0; const hasToc = () => frontmatter()?.toc !== false && tocContent() && tocContent()!.length > 0; + const SearchControl = (props: { shortcut?: boolean }) => ( + + {(docsearch) => } + + } + > + + + ); return (
@@ -109,18 +126,14 @@ export default function Header() { )}
- - {(docsearch) => } - +
- - {(docsearch) => } - + {(nav) => ( diff --git a/src/default-theme/components/LocalSearch.module.css b/src/default-theme/components/LocalSearch.module.css new file mode 100644 index 0000000..129ba34 --- /dev/null +++ b/src/default-theme/components/LocalSearch.module.css @@ -0,0 +1,200 @@ +.trigger { + display: flex; + align-items: center; + gap: 0.5rem; + width: 11rem; + min-height: 2rem; + padding: 0.35rem 0.6rem; + border: 1px solid + color-mix(in hsl, var(--sb-decoration-color) 35%, transparent); + border-radius: var(--sb-border-radius); + background: var(--sb-code-background-color); + color: var(--sb-text-color); + cursor: var(--sb-button-cursor); + + &:hover, + &:focus-visible { + color: var(--sb-heading-color); + border-color: var(--sb-active-link-color); + } + + & svg { + width: 1rem; + height: 1rem; + } + + & span { + text-align: left; + } +} + +.overlay { + position: fixed; + inset: 0; + z-index: 100; + background: color-mix(in hsl, var(--sb-heading-color) 35%, transparent); + backdrop-filter: blur(3px); +} + +.dialog { + position: fixed; + top: min(15dvh, 8rem); + left: 50%; + z-index: 101; + width: min(36rem, calc(100dvw - 2rem)); + max-height: min(70dvh, 38rem); + transform: translateX(-50%); + overflow: hidden; + border: 1px solid + color-mix(in hsl, var(--sb-decoration-color) 35%, transparent); + border-radius: calc(var(--sb-border-radius) * 2); + background: var(--sb-background-color); + box-shadow: 0 1.5rem 4rem + color-mix(in hsl, var(--sb-heading-color) 20%, transparent); +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1rem 0; +} + +.title { + margin: 0; + font-family: var(--sb-font-headings); + font-size: 1rem; +} + +.close { + display: grid; + place-items: center; + width: 2rem; + height: 2rem; + border: 0; + border-radius: var(--sb-border-radius); + background: transparent; + color: var(--sb-text-color); + cursor: var(--sb-button-cursor); + + &:hover, + &:focus-visible { + background: var(--sb-code-background-color); + color: var(--sb-heading-color); + } +} + +.label { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.control { + display: flex; + align-items: center; + gap: 0.6rem; + margin: 0.75rem 1rem; + padding: 0 0.75rem; + border: 1px solid + color-mix(in hsl, var(--sb-decoration-color) 35%, transparent); + border-radius: var(--sb-border-radius); + background: var(--sb-code-background-color); + + &:focus-within { + border-color: var(--sb-active-link-color); + box-shadow: 0 0 0 2px + color-mix(in hsl, var(--sb-active-link-color) 20%, transparent); + } +} + +.input { + width: 100%; + min-height: 3rem; + border: 0; + outline: 0; + background: transparent; + color: var(--sb-heading-color); + font: inherit; +} + +.content { + position: static; + max-height: calc(min(70dvh, 38rem) - 7rem); + overflow-y: auto; + border-top: 1px solid + color-mix(in hsl, var(--sb-decoration-color) 20%, transparent); +} + +.list { + margin: 0; + padding: 0.5rem; + list-style: none; +} + +.item { + display: grid; + gap: 0.2rem; + padding: 0.65rem 0.75rem; + border-radius: var(--sb-border-radius); + cursor: var(--sb-button-cursor); + + &[data-highlighted] { + background: color-mix(in hsl, var(--sb-active-link-color) 15%, transparent); + } +} + +.item-title { + color: var(--sb-heading-color); + font-weight: 600; +} + +.path, +.excerpt, +.status { + color: var(--sb-text-color); + font-size: 0.8rem; +} + +.path { + color: var(--sb-active-link-color); +} + +.excerpt { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.status { + padding: 1.25rem; + text-align: center; +} + +@media screen and (max-width: 500px) { + .trigger { + width: auto; + min-width: 2.75rem; + min-height: 2.75rem; + justify-content: center; + + & span { + display: none; + } + } + + .dialog { + top: 1rem; + max-height: calc(100dvh - 2rem); + } + + .content { + max-height: calc(100dvh - 9rem); + } +} diff --git a/src/default-theme/components/LocalSearch.tsx b/src/default-theme/components/LocalSearch.tsx new file mode 100644 index 0000000..6fc60ba --- /dev/null +++ b/src/default-theme/components/LocalSearch.tsx @@ -0,0 +1,244 @@ +import { Dialog } from "@kobalte/core/dialog"; +import { Search } from "@kobalte/core/search"; +import { useLocation, useNavigate } from "@solidjs/router"; +import { createSignal, onCleanup, onMount, Show } from "solid-js"; +import IconCloseLine from "~icons/ri/close-line"; +import IconSearchLine from "~icons/ri/search-line"; +import { useRouteSolidBaseConfig } from "../../client/config.js"; +import type { DefaultThemeConfig } from "../index.js"; +import { getLocalSearchScopeForPath } from "../search.js"; +import { defaultThemeTextConfig } from "../text.js"; +import styles from "./LocalSearch.module.css"; + +type LocalSearchHit = { + url: string; + title: string; + titles: string[]; + excerpt: string; +}; + +type PagefindResultData = { + url: string; + plain_excerpt: string; + meta: Record; +}; + +type PagefindApi = { + init(): Promise; + destroy(): Promise; + debouncedSearch( + query: string, + options: { filters: { scope: string } }, + debounce: number, + ): Promise<{ + results: Array<{ data(): Promise }>; + } | null>; +}; + +const PAGEFIND_PATH = "/pagefind/pagefind.js"; + +export default function LocalSearch(props: { shortcut?: boolean }) { + const config = useRouteSolidBaseConfig(); + const location = useLocation(); + const navigate = useNavigate(); + const text = { + ...defaultThemeTextConfig, + ...config().themeConfig?.text, + }; + const [open, setOpen] = createSignal(false); + const [query, setQuery] = createSignal(""); + const [results, setResults] = createSignal([]); + const [status, setStatus] = createSignal< + "idle" | "loading" | "ready" | "error" + >("idle"); + let pagefind: PagefindApi | undefined; + let pagefindLanguage: string | undefined; + let inputRef: HTMLInputElement | undefined; + let loadId = 0; + let searchId = 0; + + const scope = () => getLocalSearchScopeForPath(location.pathname, config()); + + async function loadPagefind() { + const currentLoad = ++loadId; + const language = document.documentElement.lang.toLowerCase(); + if (pagefind && pagefindLanguage === language) { + setStatus("ready"); + return; + } + setStatus("loading"); + + try { + if (pagefind) await pagefind.destroy(); + pagefind = (await import( + /* @vite-ignore */ PAGEFIND_PATH + )) as PagefindApi; + await pagefind.init(); + if (currentLoad !== loadId) return; + pagefindLanguage = language; + setStatus("ready"); + if (query()) void search(query()); + } catch { + if (currentLoad === loadId) setStatus("error"); + } + } + + async function search(value: string) { + setQuery(value); + const currentSearch = ++searchId; + if (!value) { + setResults([]); + if (pagefind) setStatus("ready"); + return; + } + if (!pagefind) return; + setStatus("loading"); + + try { + const response = await pagefind.debouncedSearch( + value, + { filters: { scope: scope() } }, + 150, + ); + if (!response || currentSearch !== searchId) return; + const data = await Promise.all( + response.results.slice(0, 10).map((result) => result.data()), + ); + if (currentSearch !== searchId) return; + setResults( + data.map((result) => ({ + url: result.url, + title: result.meta.title ?? result.url, + titles: result.meta.breadcrumb?.split(" › ") ?? [], + excerpt: result.plain_excerpt, + })), + ); + setStatus("ready"); + } catch { + if (currentSearch === searchId) setStatus("error"); + } + } + + function onOpenChange(nextOpen: boolean) { + setOpen(nextOpen); + if (nextOpen) { + void loadPagefind(); + } else { + loadId++; + searchId++; + setQuery(""); + setResults([]); + setStatus(pagefind ? "ready" : "idle"); + } + } + + onMount(() => { + if (!props.shortcut) return; + + const onKeyDown = (event: KeyboardEvent) => { + if ( + event.key.toLowerCase() !== "k" || + !(event.metaKey || event.ctrlKey) + ) { + return; + } + event.preventDefault(); + onOpenChange(true); + }; + + document.addEventListener("keydown", onKeyDown); + onCleanup(() => document.removeEventListener("keydown", onKeyDown)); + }); + + return ( + + + + {text.search} + + + + { + event.preventDefault(); + queueMicrotask(() => inputRef?.focus()); + }} + > +
+ {text.search} + + + +
+ + open + modal={false} + options={results()} + optionValue="url" + optionTextValue="title" + optionLabel="title" + placeholder={text.searchPlaceholder} + onInputChange={(value) => void search(value)} + onChange={(result) => { + if (!result) return; + onOpenChange(false); + void navigate(result.url); + }} + itemComponent={(props) => ( + + + {props.item.rawValue.title} + + 0}> +
+ {props.item.rawValue.titles.join(" › ")} +
+
+ +
+ + + )} + > + {text.search} + + + + +
+ +
+ {text.searchLoading} +
+
+ + + + + + {text.searchNoResults} + + + +
+ + + +
+ ); +} diff --git a/src/default-theme/default-components.ts b/src/default-theme/default-components.ts index 2f90014..7f472f3 100644 --- a/src/default-theme/default-components.ts +++ b/src/default-theme/default-components.ts @@ -1,3 +1,4 @@ +import { lazy } from "solid-js"; import Article from "./components/Article.jsx"; import Badges from "./components/Badges.jsx"; import Features from "./components/Features.jsx"; @@ -13,6 +14,8 @@ import ThemeSelector from "./components/ThemeSelector.jsx"; import VersionSelector from "./components/VersionSelector.jsx"; import DocSearch from "./components/vendor/DocSearch.jsx"; +const LocalSearch = lazy(() => import("./components/LocalSearch.jsx")); + export const defaultThemeComponents = { Article, Badges, @@ -21,6 +24,7 @@ export const defaultThemeComponents = { Header, LastUpdated, Link, + LocalSearch, LocaleSelector, ProjectSelector, TableOfContents, diff --git a/src/default-theme/index.ts b/src/default-theme/index.ts index ac31462..957d53c 100644 --- a/src/default-theme/index.ts +++ b/src/default-theme/index.ts @@ -6,6 +6,7 @@ import { defineTheme, type ThemeDefinition } from "../config/index.js"; import type { SidebarConfig } from "../config/sidebar.js"; import type { DefaultThemeSidebarItem } from "./sidebar.js"; import type { DefaultThemeTextConfig } from "./text.js"; +import localSearchPlugin from "./vite-local-search.js"; export type { DefaultThemeSidebarItem, @@ -104,6 +105,7 @@ const defaultTheme: ThemeDefinition = defineTheme({ } return [ + localSearchPlugin(config), { name: "solidbase-default-theme-fonts", resolveId(id) { @@ -147,9 +149,9 @@ const defaultTheme: ThemeDefinition = defineTheme({ }); export default defaultTheme; -export interface SearchConfig { - docsearch?: Omit; -} +export type SearchConfig = + | { local: true; docsearch?: never } + | { local?: false; docsearch?: Omit }; export interface NavItem { text: string; diff --git a/src/default-theme/search-index.ts b/src/default-theme/search-index.ts new file mode 100644 index 0000000..6d368e9 --- /dev/null +++ b/src/default-theme/search-index.ts @@ -0,0 +1,166 @@ +import type { Root, RootContent } from "mdast"; +import { toString as nodeToString } from "mdast-util-to-string"; +import { toc } from "mdast-util-toc"; +import type { CustomRecord } from "pagefind"; +import remarkMdx from "remark-mdx"; +import remarkParse from "remark-parse"; +import { unified } from "unified"; +import { visit } from "unist-util-visit"; +import { toDocumentMarkdown } from "../config/document-markdown.js"; +import type { SolidBaseResolvedConfig } from "../config/index.js"; +import { viteAliasCodeImports } from "../config/remark-plugins/import-code-file.js"; +import { + getRouteLocaleMetadata, + getRoutesIndex, + isRouteIncludedByConfig, +} from "../config/routes-index.js"; +import { + getLocalSearchScopeForPath, + type LocalSearchSection, +} from "./search.js"; + +type SearchFrontmatter = { + title?: string; + description?: string; + search?: false; +}; + +type SearchPage = { + routePath: string; + title: string; + description?: string; +}; + +type ViteAliasTransformer = { + transform: (code: string, id: string) => Promise; +}; + +function getHeadingAnchors(tree: Root) { + const anchors: string[] = []; + const map = toc(tree, { minDepth: 1, maxDepth: 6 }).map; + + if (map) { + visit(map, "link", (node) => { + anchors.push(node.url); + }); + } + + return anchors; +} + +function getSearchableText(nodes: RootContent[]) { + return nodes + .map((node) => nodeToString(node)) + .join(" ") + .replace(/\s+/g, " ") + .trim(); +} + +export function splitSearchSections( + markdown: string, + page: SearchPage, +): LocalSearchSection[] { + const tree = unified().use(remarkParse).use(remarkMdx).parse(markdown); + const anchors = getHeadingAnchors(tree); + const documents: LocalSearchSection[] = []; + const titles: string[] = []; + let headingIndex = 0; + let sectionTitle = page.title; + let sectionTitles: string[] = []; + let sectionId = page.routePath; + let sectionNodes: RootContent[] = []; + let description = page.description; + + const addSection = () => { + const body = getSearchableText(sectionNodes); + const text = [description, body].filter(Boolean).join(" "); + description = undefined; + documents.push({ + url: sectionId, + title: sectionTitle, + titles: sectionTitles, + content: text, + }); + }; + + for (const node of tree.children) { + if (node.type !== "heading") { + sectionNodes.push(node); + continue; + } + + if (sectionNodes.length > 0) addSection(); + + sectionTitle = nodeToString(node); + titles.length = node.depth - 1; + sectionTitles = titles.filter(Boolean); + titles[node.depth - 1] = sectionTitle; + sectionId = `${page.routePath}${anchors[headingIndex++] ?? ""}`; + sectionNodes = []; + } + + addSection(); + return documents; +} + +export async function buildLocalSearchRecords( + root: string, + config: SolidBaseResolvedConfig, + resolver: ( + source: string, + importer: string, + ) => Promise<{ id: string } | null>, + onFile?: (filePath: string) => void, +) { + const records: CustomRecord[] = []; + const routes = await getRoutesIndex(root); + const aliasTransformer = viteAliasCodeImports( + resolver, + ) as ViteAliasTransformer; + + for (const route of routes) { + onFile?.(route.filePath); + const frontmatter = route.frontmatter as SearchFrontmatter; + if (frontmatter.search === false) continue; + if (!isRouteIncludedByConfig(route.routePath, config)) continue; + + const scope = getLocalSearchScopeForPath(route.routePath, config); + const language = new Intl.Locale( + getRouteLocaleMetadata(route.routePath, config).hreflang, + ).language; + + const source = + (await aliasTransformer.transform(route.source, route.filePath)) ?? + route.source; + const markdown = await toDocumentMarkdown(source, { + config, + filePath: route.filePath, + }); + records.push( + ...splitSearchSections(markdown, { + routePath: route.routePath, + title: + typeof frontmatter.title === "string" + ? frontmatter.title + : route.routePath, + description: + typeof frontmatter.description === "string" + ? frontmatter.description + : undefined, + }).map((section) => ({ + url: section.url, + content: section.content, + language, + meta: { + title: section.title, + ...(section.titles.length > 0 + ? { breadcrumb: section.titles.join(" › ") } + : {}), + }, + filters: { scope: [scope] }, + })), + ); + } + + return records; +} diff --git a/src/default-theme/search.ts b/src/default-theme/search.ts new file mode 100644 index 0000000..790f7db --- /dev/null +++ b/src/default-theme/search.ts @@ -0,0 +1,41 @@ +import type { SolidBaseConfig } from "../config/index.js"; +import { getSolidBaseRouteMatchForPath } from "../config/route-config.js"; + +export type LocalSearchSection = { + url: string; + title: string; + titles: string[]; + content: string; +}; + +function normalizePrefix(prefix: string) { + if (prefix === "/") return prefix; + return prefix.replace(/\/$/, ""); +} + +export function getLocalSearchScopeForPath( + path: string, + config: Pick, "routes" | "locales">, +) { + const selection = getSolidBaseRouteMatchForPath( + config.routes, + path, + )?.selection; + if (!selection) { + for (const [locale, localeConfig] of Object.entries(config.locales ?? {})) { + if (locale === "root") continue; + const prefix = normalizePrefix(localeConfig.link ?? `/${locale}/`); + if (path === prefix || path.startsWith(`${prefix}/`)) return locale; + } + + return "root"; + } + + const entries = Object.entries(selection); + if (entries.length === 0) return "root"; + + return entries + .sort(([left], [right]) => left.localeCompare(right)) + .map(([axis, value]) => `${axis}:${value}`) + .join("|"); +} diff --git a/src/default-theme/text.ts b/src/default-theme/text.ts index dcc8cac..cd31673 100644 --- a/src/default-theme/text.ts +++ b/src/default-theme/text.ts @@ -3,6 +3,12 @@ export type DefaultThemeTextConfig = { copyPage: string; copiedPage: string; copyFailedPage: string; + search: string; + searchClose: string; + searchLoading: string; + searchNoResults: string; + searchPlaceholder: string; + searchUnavailable: string; }; export const defaultThemeTextConfig: DefaultThemeTextConfig = { @@ -10,4 +16,10 @@ export const defaultThemeTextConfig: DefaultThemeTextConfig = { copyPage: "Copy page", copiedPage: "Copied!", copyFailedPage: "Copy failed", + search: "Search", + searchClose: "Close search", + searchLoading: "Loading search…", + searchNoResults: "No results found", + searchPlaceholder: "Search documentation", + searchUnavailable: "Search is unavailable", }; diff --git a/src/default-theme/vite-local-search.ts b/src/default-theme/vite-local-search.ts new file mode 100644 index 0000000..386cf2d --- /dev/null +++ b/src/default-theme/vite-local-search.ts @@ -0,0 +1,77 @@ +import { join } from "node:path"; +import * as pagefind from "pagefind"; +import type { PluginOption } from "vite"; +import type { SolidBaseResolvedConfig } from "../config/index.js"; +import { + createGeneratedAssetPlugin, + emptyDir, +} from "../config/vite-plugin/generated-asset.js"; +import { buildLocalSearchRecords } from "./search-index.js"; + +const LOCAL_SEARCH_ASSETS_DIR = join( + "node_modules", + ".solidbase", + "local-search", +); +let writeQueue = Promise.resolve(); + +function assertNoPagefindErrors(errors: string[]) { + if (errors.length > 0) throw new Error(errors.join("\n")); +} + +async function writePagefindAssets( + root: string, + config: SolidBaseResolvedConfig, + resolver: ( + source: string, + importer: string, + ) => Promise<{ id: string } | null>, + watch: (filePath: string) => void, +) { + const outputRoot = join(root, LOCAL_SEARCH_ASSETS_DIR); + await emptyDir(outputRoot); + + try { + const created = await pagefind.createIndex(); + assertNoPagefindErrors(created.errors); + if (!created.index) throw new Error("Pagefind did not create an index"); + + const records = await buildLocalSearchRecords( + root, + config, + resolver, + watch, + ); + for (const record of records) { + const added = await created.index.addCustomRecord(record); + assertNoPagefindErrors(added.errors); + } + + const written = await created.index.writeFiles({ + outputPath: join(outputRoot, "pagefind"), + }); + assertNoPagefindErrors(written.errors); + } finally { + await pagefind.close(); + } +} + +export default function localSearchPlugin( + config: SolidBaseResolvedConfig, +): PluginOption { + if (config.themeConfig?.search?.local !== true) return []; + + return createGeneratedAssetPlugin({ + name: "solidbase:local-search", + assetDir: LOCAL_SEARCH_ASSETS_DIR, + write(root, resolver, watch) { + const write = () => writePagefindAssets(root, config, resolver, watch); + const queued = writeQueue.then(write, write); + writeQueue = queued.then( + () => undefined, + () => undefined, + ); + return queued; + }, + }); +} diff --git a/src/virtual.d.ts b/src/virtual.d.ts index c02fc5b..6ff37bb 100644 --- a/src/virtual.d.ts +++ b/src/virtual.d.ts @@ -14,7 +14,6 @@ declare module "virtual:solidbase/components" { declare module "virtual:solidbase/default-theme/fonts" { export const preloadFonts: Array<{ path: string; type: string }>; } - declare module "virtual:solidbase/mdx" { export const MDXProvider: typeof import("./mdx.ts").MDXProvider; } diff --git a/tests/config/generated-asset.test.ts b/tests/config/generated-asset.test.ts new file mode 100644 index 0000000..1736e9b --- /dev/null +++ b/tests/config/generated-asset.test.ts @@ -0,0 +1,70 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createGeneratedAssetPlugin } from "../../src/config/vite-plugin/generated-asset.ts"; + +const tempDirs: string[] = []; + +describe("createGeneratedAssetPlugin", () => { + afterEach(async () => { + await Promise.all( + tempDirs + .splice(0) + .map((dir) => rm(dir, { recursive: true, force: true })), + ); + }); + + it("serves generated JavaScript as an ES module", async () => { + const root = await mkdtemp(join(tmpdir(), "solidbase-assets-")); + tempDirs.push(root); + const assetDir = join("generated", "pagefind"); + await mkdir(join(root, assetDir), { recursive: true }); + await writeFile(join(root, assetDir, "pagefind.js"), "export {};", "utf8"); + + const plugin = createGeneratedAssetPlugin({ + name: "test-assets", + assetDir: "generated", + async write() {}, + }) as any; + plugin.configResolved({ root }); + + let middleware: any; + plugin.configureServer({ + middlewares: { use: (handler: any) => (middleware = handler) }, + }); + const setHeader = vi.fn(); + + await new Promise((resolve, reject) => { + middleware( + { url: "/pagefind/pagefind.js" }, + { setHeader, end: resolve }, + () => reject(new Error("Asset was not served")), + ); + }); + + expect(setHeader).toHaveBeenCalledWith( + "Content-Type", + "text/javascript; charset=utf-8", + ); + }); + + it("regenerates assets when a watched source changes", async () => { + const source = "/docs/src/routes/index.mdx"; + const write = vi.fn(async (_root, _resolve, watch) => watch(source)); + const plugin = createGeneratedAssetPlugin({ + name: "test-assets", + assetDir: "generated", + write, + }) as any; + const context = { + addWatchFile: vi.fn(), + resolve: vi.fn(), + }; + + await plugin.buildStart.call(context); + await plugin.watchChange.call(context, source); + + expect(write).toHaveBeenCalledTimes(2); + }); +}); diff --git a/tests/default-theme/local-search-index.test.ts b/tests/default-theme/local-search-index.test.ts new file mode 100644 index 0000000..9eaecf0 --- /dev/null +++ b/tests/default-theme/local-search-index.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from "vitest"; +import { getLocalSearchScopeForPath } from "../../src/default-theme/search.ts"; +import { + buildLocalSearchRecords, + splitSearchSections, +} from "../../src/default-theme/search-index.ts"; +import { fixtureSiteRoot } from "../helpers/fixtures.ts"; + +describe("splitSearchSections", () => { + it("creates searchable heading sections with stable duplicate anchors", () => { + const sections = splitSearchSections( + [ + "# Guide", + "", + "Welcome to SolidBase.", + "", + "## Install", + "", + "First install section.", + "", + "## Install", + "", + "Second install section.", + ].join("\n"), + { routePath: "/guide", title: "Guide", description: "Start here" }, + ); + + expect(sections).toEqual([ + expect.objectContaining({ + url: "/guide#guide", + title: "Guide", + content: "Start here Welcome to SolidBase.", + }), + expect.objectContaining({ + url: "/guide#install", + title: "Install", + titles: ["Guide"], + }), + expect.objectContaining({ + url: "/guide#install-1", + content: "Second install section.", + }), + ]); + }); +}); + +describe("getLocalSearchScopeForPath", () => { + it("creates stable keys for route axes", () => { + expect( + getLocalSearchScopeForPath("/v1/fr", { + routes: { + path: "/{project}/{version}/{locale}", + project: { + default: "solid", + values: { solid: { path: "" } }, + }, + version: { + default: "latest", + values: { latest: { path: "" }, v1: { path: "v1" } }, + }, + locale: { + default: "en", + values: { en: { path: "" }, fr: { path: "fr" } }, + }, + }, + }), + ).toBe("locale:fr|project:solid|version:v1"); + }); + + it("matches legacy locale landing paths with trailing-slash links", () => { + const config = { + locales: { + root: { label: "English" }, + fr: { label: "Français", link: "/fr/" }, + }, + }; + + expect(getLocalSearchScopeForPath("/fr", config)).toBe("fr"); + expect(getLocalSearchScopeForPath("/fr/guide", config)).toBe("fr"); + expect(getLocalSearchScopeForPath("/guide", config)).toBe("root"); + }); +}); + +describe("buildLocalSearchRecords", () => { + it("builds Pagefind records with language, metadata, and scope filters", async () => { + const records = await buildLocalSearchRecords( + fixtureSiteRoot, + { + themeConfig: { search: { local: true } }, + markdown: {}, + lang: "en-US", + } as any, + async () => null, + ); + + expect(records).toContainEqual({ + url: "/guide/getting-started#getting-started", + content: "Learn the basics Start with SolidBase.", + language: "en", + meta: { title: "Getting Started" }, + filters: { scope: ["root"] }, + }); + }); + + it("omits pages with search disabled", async () => { + const records = await buildLocalSearchRecords( + fixtureSiteRoot, + { + themeConfig: { search: { local: true } }, + markdown: {}, + lang: "en-US", + } as any, + async () => null, + ); + + expect(records.some((record) => record.meta?.title === "Hidden Doc")).toBe( + false, + ); + }); + + it("tags records with the current route-axis selection", async () => { + const records = await buildLocalSearchRecords( + fixtureSiteRoot, + { + themeConfig: { search: { local: true } }, + markdown: {}, + lang: "en-US", + routes: { + path: "/{project}", + project: { + default: "docs", + values: { + docs: { path: "" }, + guide: { path: "guide" }, + }, + }, + }, + } as any, + async () => null, + ); + + expect(records.find((record) => record.url === "/#home")?.filters).toEqual({ + scope: ["project:docs"], + }); + expect( + records.find( + (record) => record.url === "/guide/getting-started#getting-started", + )?.filters, + ).toEqual({ scope: ["project:guide"] }); + }); +}); diff --git a/tests/default-theme/local-search-plugin.test.ts b/tests/default-theme/local-search-plugin.test.ts new file mode 100644 index 0000000..81621af --- /dev/null +++ b/tests/default-theme/local-search-plugin.test.ts @@ -0,0 +1,38 @@ +import { access, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import localSearchPlugin from "../../src/default-theme/vite-local-search.ts"; +import { fixtureSiteRoot } from "../helpers/fixtures.ts"; + +describe("localSearchPlugin", () => { + it("returns no plugin when local search is disabled", () => { + expect(localSearchPlugin({ themeConfig: {} } as any)).toEqual([]); + }); + + it("writes a Pagefind search bundle", async () => { + const plugin = localSearchPlugin({ + themeConfig: { search: { local: true } }, + markdown: {}, + lang: "en-US", + } as any) as any; + plugin.configResolved({ root: fixtureSiteRoot }); + await plugin.buildStart.call({ + addWatchFile() {}, + resolve: async () => null, + }); + + const bundleDir = join( + fixtureSiteRoot, + "node_modules", + ".solidbase", + "local-search", + "pagefind", + ); + await expect( + access(join(bundleDir, "pagefind.js")), + ).resolves.toBeUndefined(); + expect( + await readFile(join(bundleDir, "pagefind-entry.json"), "utf8"), + ).toContain('"languages"'); + }); +}); diff --git a/tests/fixtures/src/routes/excluded.mdx b/tests/fixtures/src/routes/excluded.mdx index e404db1..550d5b5 100644 --- a/tests/fixtures/src/routes/excluded.mdx +++ b/tests/fixtures/src/routes/excluded.mdx @@ -1,6 +1,7 @@ --- title: Hidden Doc llms: false +search: false --- # Hidden Doc