@umami/shiso 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/shiso.mjs +1 -0
- package/dist/chunks/App.js +30 -4
- package/dist/chunks/local.js +12 -3
- package/dist/chunks/pagefind.js +100 -0
- package/dist/search.js +10 -1
- package/docs.schema.json +1 -1
- package/package.json +4 -1
- package/scripts/check-package.mjs +1 -0
- package/scripts/generate-icon-registry.mjs +12 -2
- package/scripts/pagefind-index.mjs +127 -0
- package/src/components/DocContent.tsx +18 -4
- package/src/components/Search.tsx +29 -2
- package/src/components/ui/command.tsx +2 -2
- package/src/lib/search/provider.ts +13 -2
- package/src/lib/search/providers/pagefind.ts +180 -0
- package/src/lib/search.ts +32 -4
- package/src/lib/types.ts +1 -1
- package/types/search.d.ts +9 -0
package/bin/shiso.mjs
CHANGED
|
@@ -116,6 +116,7 @@ async function main() {
|
|
|
116
116
|
projectRoot,
|
|
117
117
|
);
|
|
118
118
|
run(process.execPath, [path.join(PACKAGE_ROOT, 'scripts/prerender.mjs')], projectRoot);
|
|
119
|
+
run(process.execPath, [path.join(PACKAGE_ROOT, 'scripts/pagefind-index.mjs')], projectRoot);
|
|
119
120
|
return;
|
|
120
121
|
}
|
|
121
122
|
|
package/dist/chunks/App.js
CHANGED
|
@@ -25967,7 +25967,7 @@ function CommandGroup({ className, ...props }) {
|
|
|
25967
25967
|
function CommandItem({ className, children, ...props }) {
|
|
25968
25968
|
return /* @__PURE__ */ jsxs(_e.Item, {
|
|
25969
25969
|
"data-slot": "command-item",
|
|
25970
|
-
className: cn("group/command-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:*:[svg]:text-foreground", className),
|
|
25970
|
+
className: cn("group/command-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-[selected=true]:bg-muted data-[selected=true]:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[selected=true]:*:[svg]:text-foreground", className),
|
|
25971
25971
|
...props,
|
|
25972
25972
|
children: [children, /* @__PURE__ */ jsx(Check$1, { className: "ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" })]
|
|
25973
25973
|
});
|
|
@@ -25975,6 +25975,21 @@ function CommandItem({ className, children, ...props }) {
|
|
|
25975
25975
|
|
|
25976
25976
|
//#endregion
|
|
25977
25977
|
//#region src/components/Search.tsx
|
|
25978
|
+
/** Enough results to make the list scroll; the dialog caps its own height. */
|
|
25979
|
+
const RESULT_LIMIT = 30;
|
|
25980
|
+
/**
|
|
25981
|
+
* Renders a snippet, turning provider-supplied `<mark>` tags into highlight
|
|
25982
|
+
* elements. Snippets are parsed — never injected as HTML — so any other
|
|
25983
|
+
* markup in the text renders literally.
|
|
25984
|
+
*/
|
|
25985
|
+
function renderSnippet(snippet) {
|
|
25986
|
+
const parts = snippet.split(/<mark>(.*?)<\/mark>/g);
|
|
25987
|
+
if (parts.length === 1) return snippet;
|
|
25988
|
+
return parts.map((part, index) => index % 2 ? /* @__PURE__ */ jsx("mark", {
|
|
25989
|
+
className: "rounded-xs bg-primary/15 px-px text-primary",
|
|
25990
|
+
children: part
|
|
25991
|
+
}, index) : part);
|
|
25992
|
+
}
|
|
25978
25993
|
/**
|
|
25979
25994
|
* Provider-neutral search dialog. The selected provider and its index or
|
|
25980
25995
|
* client are loaded on demand, so search stays out of the initial bundle.
|
|
@@ -26011,7 +26026,7 @@ function Search({ config, labels }) {
|
|
|
26011
26026
|
try {
|
|
26012
26027
|
const activeProvider = await loadProvider();
|
|
26013
26028
|
const scope = getScopeByPathname(pathname);
|
|
26014
|
-
const nextResults = await activeProvider.search(value,
|
|
26029
|
+
const nextResults = await activeProvider.search(value, RESULT_LIMIT, {
|
|
26015
26030
|
scopeId: scope.id,
|
|
26016
26031
|
language: scope.language,
|
|
26017
26032
|
version: scope.version
|
|
@@ -26113,7 +26128,7 @@ function Search({ config, labels }) {
|
|
|
26113
26128
|
children: [result.page, result.heading ? ` › ${result.heading}` : ""]
|
|
26114
26129
|
}), result.snippet && /* @__PURE__ */ jsx("div", {
|
|
26115
26130
|
className: "mt-[0.15rem] line-clamp-2 text-[0.8rem] text-muted-foreground",
|
|
26116
|
-
children: result.snippet
|
|
26131
|
+
children: renderSnippet(result.snippet)
|
|
26117
26132
|
})]
|
|
26118
26133
|
}, result.url))
|
|
26119
26134
|
}) : null]
|
|
@@ -26651,7 +26666,8 @@ function resolveRelated(entries) {
|
|
|
26651
26666
|
return links;
|
|
26652
26667
|
}
|
|
26653
26668
|
function DocContent({ page, doc, site }) {
|
|
26654
|
-
const
|
|
26669
|
+
const scope = getScopeForPage(docsSite, page);
|
|
26670
|
+
const pagerPages = scope.docs.pages.filter((item) => !item.hidden);
|
|
26655
26671
|
const pageIndex = pagerPages.findIndex((item) => item.slug === page.slug);
|
|
26656
26672
|
const prev = pageIndex > 0 ? pagerPages[pageIndex - 1] : void 0;
|
|
26657
26673
|
const next = pageIndex >= 0 ? pagerPages[pageIndex + 1] : void 0;
|
|
@@ -26666,8 +26682,16 @@ function DocContent({ page, doc, site }) {
|
|
|
26666
26682
|
dateStyle: "medium",
|
|
26667
26683
|
timeZone: "UTC"
|
|
26668
26684
|
});
|
|
26685
|
+
const pagefindAttrs = page.hidden || scope.hidden ? {} : {
|
|
26686
|
+
"data-pagefind-body": "",
|
|
26687
|
+
...page.scopeId !== "default" ? {
|
|
26688
|
+
"data-pagefind-filter": "scope[data-scope]",
|
|
26689
|
+
"data-scope": page.scopeId
|
|
26690
|
+
} : {}
|
|
26691
|
+
};
|
|
26669
26692
|
return /* @__PURE__ */ jsxs("article", {
|
|
26670
26693
|
className: "min-w-0 grow",
|
|
26694
|
+
...pagefindAttrs,
|
|
26671
26695
|
children: [
|
|
26672
26696
|
eyebrow && /* @__PURE__ */ jsx("div", {
|
|
26673
26697
|
className: "text-sm font-bold text-primary",
|
|
@@ -26705,6 +26729,7 @@ function DocContent({ page, doc, site }) {
|
|
|
26705
26729
|
related.length > 0 && /* @__PURE__ */ jsxs("nav", {
|
|
26706
26730
|
className: "mt-8",
|
|
26707
26731
|
"aria-label": site.labels.relatedTopics,
|
|
26732
|
+
"data-pagefind-ignore": true,
|
|
26708
26733
|
children: [/* @__PURE__ */ jsx("div", {
|
|
26709
26734
|
className: "text-sm text-muted-foreground",
|
|
26710
26735
|
children: site.labels.relatedTopics
|
|
@@ -26731,6 +26756,7 @@ function DocContent({ page, doc, site }) {
|
|
|
26731
26756
|
}),
|
|
26732
26757
|
/* @__PURE__ */ jsxs("div", {
|
|
26733
26758
|
className: "mt-8 flex items-center justify-between",
|
|
26759
|
+
"data-pagefind-ignore": true,
|
|
26734
26760
|
children: [/* @__PURE__ */ jsx(NavigationButton, {
|
|
26735
26761
|
...prev,
|
|
26736
26762
|
isPrev: true
|
package/dist/chunks/local.js
CHANGED
|
@@ -8,10 +8,19 @@ function filterRecordsByScope(records, context) {
|
|
|
8
8
|
return records.filter((record) => !record.scopeId || record.scopeId === context.scopeId);
|
|
9
9
|
}
|
|
10
10
|
const SNIPPET_RADIUS = 60;
|
|
11
|
-
function
|
|
11
|
+
function escapeRegExp(value) {
|
|
12
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
13
|
+
}
|
|
14
|
+
/** Wraps every term occurrence in `<mark>` so the dialog can highlight it. */
|
|
15
|
+
function highlightTerms(snippet, terms) {
|
|
16
|
+
if (!terms.length) return snippet;
|
|
17
|
+
const pattern = new RegExp([...terms].sort((a, b) => b.length - a.length).map(escapeRegExp).join("|"), "gi");
|
|
18
|
+
return snippet.replace(pattern, "<mark>$&</mark>");
|
|
19
|
+
}
|
|
20
|
+
function makeSnippet(text, index, length, terms) {
|
|
12
21
|
const start = Math.max(0, index - SNIPPET_RADIUS);
|
|
13
22
|
const end = Math.min(text.length, index + length + SNIPPET_RADIUS);
|
|
14
|
-
return `${start > 0 ? "…" : ""}${text.slice(start, end).trim()}${end < text.length ? "…" : ""}
|
|
23
|
+
return highlightTerms(`${start > 0 ? "…" : ""}${text.slice(start, end).trim()}${end < text.length ? "…" : ""}`, terms);
|
|
15
24
|
}
|
|
16
25
|
function searchIndex(records, query, limit = 10) {
|
|
17
26
|
const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
@@ -44,7 +53,7 @@ function searchIndex(records, query, limit = 10) {
|
|
|
44
53
|
url: record.id ? `${record.url}#${record.id}` : record.url,
|
|
45
54
|
page: record.page,
|
|
46
55
|
heading: record.heading,
|
|
47
|
-
snippet: snippetAt >= 0 ? makeSnippet(record.text, snippetAt, snippetLength) : void 0,
|
|
56
|
+
snippet: snippetAt >= 0 ? makeSnippet(record.text, snippetAt, snippetLength, terms) : void 0,
|
|
48
57
|
score
|
|
49
58
|
});
|
|
50
59
|
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
//#region src/lib/search/providers/pagefind.ts
|
|
2
|
+
const DEFAULT_LIMIT = 10;
|
|
3
|
+
/**
|
|
4
|
+
* Sanitizes a Pagefind excerpt: keeps the `<mark>` highlight tags (rendered
|
|
5
|
+
* as highlights by the search dialog, never as raw HTML), strips every other
|
|
6
|
+
* tag, and decodes basic HTML entities.
|
|
7
|
+
*/
|
|
8
|
+
function sanitizePagefindExcerpt(excerpt) {
|
|
9
|
+
return excerpt.replace(/<(?!\/?mark>)[^>]*>/g, "").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, "\"").replace(/'/g, "'").replace(/&/g, "&").trim();
|
|
10
|
+
}
|
|
11
|
+
/** Converts a Pagefind result URL into a router-relative path: strips a
|
|
12
|
+
* trailing `/index.html` and trailing slash while preserving `#anchor`. */
|
|
13
|
+
function normalizePagefindUrl(url) {
|
|
14
|
+
const hashIndex = url.indexOf("#");
|
|
15
|
+
const hash = hashIndex === -1 ? "" : url.slice(hashIndex);
|
|
16
|
+
let pathname = hashIndex === -1 ? url : url.slice(0, hashIndex);
|
|
17
|
+
pathname = pathname.replace(/\/index\.html$/, "/");
|
|
18
|
+
if (pathname.length > 1) pathname = pathname.replace(/\/+$/, "") || "/";
|
|
19
|
+
return `${pathname}${hash}`;
|
|
20
|
+
}
|
|
21
|
+
/** Maps loaded Pagefind fragments to `SearchResult`s. Each sub-result (a
|
|
22
|
+
* heading-bounded section) becomes its own result; duplicate URLs are
|
|
23
|
+
* dropped because the search dialog keys items by URL. */
|
|
24
|
+
function mapPagefindResults(fragments, limit) {
|
|
25
|
+
const results = [];
|
|
26
|
+
const seen = /* @__PURE__ */ new Set();
|
|
27
|
+
for (const { fragment, score } of fragments) {
|
|
28
|
+
const page = fragment.meta?.title || fragment.url;
|
|
29
|
+
const subResults = fragment.sub_results?.length ? fragment.sub_results : [{
|
|
30
|
+
url: fragment.url,
|
|
31
|
+
excerpt: fragment.excerpt
|
|
32
|
+
}];
|
|
33
|
+
for (const subResult of subResults) {
|
|
34
|
+
const url = normalizePagefindUrl(subResult.url);
|
|
35
|
+
if (seen.has(url)) continue;
|
|
36
|
+
seen.add(url);
|
|
37
|
+
const heading = url.includes("#") && subResult.title && subResult.title !== page ? subResult.title : void 0;
|
|
38
|
+
const excerpt = subResult.excerpt || fragment.excerpt;
|
|
39
|
+
results.push({
|
|
40
|
+
url,
|
|
41
|
+
page,
|
|
42
|
+
heading,
|
|
43
|
+
snippet: excerpt ? sanitizePagefindExcerpt(excerpt) : void 0,
|
|
44
|
+
score: score ?? 0
|
|
45
|
+
});
|
|
46
|
+
if (results.length >= limit) return results;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return results;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Built-in provider backed by a Pagefind index generated during `shiso build`.
|
|
53
|
+
* When the Pagefind bundle is unavailable (for example in dev, where no
|
|
54
|
+
* prerendered HTML exists), it falls back to the local provider.
|
|
55
|
+
*/
|
|
56
|
+
function createPagefindSearchProvider(options = {}) {
|
|
57
|
+
let backend = null;
|
|
58
|
+
async function loadBackend() {
|
|
59
|
+
const base = (import.meta.env?.BASE_URL || "/").trim().replace(/\/+$/, "");
|
|
60
|
+
try {
|
|
61
|
+
const api = await import(
|
|
62
|
+
/* @vite-ignore */
|
|
63
|
+
`${base}/pagefind/pagefind.js`
|
|
64
|
+
);
|
|
65
|
+
const ranking = options.ranking;
|
|
66
|
+
await api.options({
|
|
67
|
+
baseUrl: "/",
|
|
68
|
+
...ranking && typeof ranking === "object" ? { ranking } : {}
|
|
69
|
+
});
|
|
70
|
+
await api.init();
|
|
71
|
+
return {
|
|
72
|
+
kind: "pagefind",
|
|
73
|
+
api
|
|
74
|
+
};
|
|
75
|
+
} catch (error) {
|
|
76
|
+
console.warn("[shiso] Pagefind bundle not found — falling back to local search. This is expected in dev; run \"shiso build\" to generate the Pagefind index.", error);
|
|
77
|
+
const { createLocalSearchProvider } = await import("./local.js");
|
|
78
|
+
return {
|
|
79
|
+
kind: "local",
|
|
80
|
+
provider: createLocalSearchProvider({})
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return { async search(query, limit = DEFAULT_LIMIT, context) {
|
|
85
|
+
backend ||= loadBackend();
|
|
86
|
+
const resolved = await backend;
|
|
87
|
+
if (resolved.kind === "local") return resolved.provider.search(query, limit, context);
|
|
88
|
+
const filters = context?.scopeId && context.scopeId !== "default" ? { filters: { scope: context.scopeId } } : void 0;
|
|
89
|
+
const response = await resolved.api.search(query, filters);
|
|
90
|
+
const fragments = [];
|
|
91
|
+
for (const result of response.results.slice(0, limit)) fragments.push({
|
|
92
|
+
fragment: await result.data(),
|
|
93
|
+
score: result.score
|
|
94
|
+
});
|
|
95
|
+
return mapPagefindResults(fragments, limit);
|
|
96
|
+
} };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
//#endregion
|
|
100
|
+
export { createPagefindSearchProvider };
|
package/dist/search.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
//#region src/lib/search/provider.ts
|
|
2
2
|
const factories = /* @__PURE__ */ new Map();
|
|
3
|
+
const BUILTIN_PROVIDERS = /* @__PURE__ */ new Set(["local", "pagefind"]);
|
|
3
4
|
function normalizeProviderId(id) {
|
|
4
5
|
return id.trim().toLowerCase();
|
|
5
6
|
}
|
|
@@ -9,7 +10,7 @@ function normalizeProviderId(id) {
|
|
|
9
10
|
*/
|
|
10
11
|
function registerSearchProvider(id, factory) {
|
|
11
12
|
const providerId = normalizeProviderId(id);
|
|
12
|
-
if (!providerId || providerId
|
|
13
|
+
if (!providerId || BUILTIN_PROVIDERS.has(providerId)) throw new Error("Search provider ids must be non-empty and cannot replace the built-in providers (\"local\", \"pagefind\").");
|
|
13
14
|
factories.set(providerId, factory);
|
|
14
15
|
return () => {
|
|
15
16
|
if (factories.get(providerId) === factory) factories.delete(providerId);
|
|
@@ -27,6 +28,14 @@ async function resolveSearchProvider(id, options = {}) {
|
|
|
27
28
|
providerId: "local",
|
|
28
29
|
fellBack: false
|
|
29
30
|
};
|
|
31
|
+
if (requestedId === "pagefind") {
|
|
32
|
+
const { createPagefindSearchProvider } = await import("./chunks/pagefind.js");
|
|
33
|
+
return {
|
|
34
|
+
provider: createPagefindSearchProvider(options),
|
|
35
|
+
providerId: "pagefind",
|
|
36
|
+
fellBack: false
|
|
37
|
+
};
|
|
38
|
+
}
|
|
30
39
|
const factory = factories.get(requestedId);
|
|
31
40
|
if (factory) return {
|
|
32
41
|
provider: await factory(options),
|
package/docs.schema.json
CHANGED
|
@@ -274,7 +274,7 @@
|
|
|
274
274
|
},
|
|
275
275
|
"provider": {
|
|
276
276
|
"allOf": [{ "$ref": "#/definitions/nonEmptyStringValue" }],
|
|
277
|
-
"description": "
|
|
277
|
+
"description": "Search provider id: \"local\" (default), \"pagefind\", or a provider registered at runtime."
|
|
278
278
|
},
|
|
279
279
|
"options": {
|
|
280
280
|
"type": "object",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umami/shiso",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "Open-source documentation framework for Markdown and MDX sites.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -89,6 +89,9 @@
|
|
|
89
89
|
"unified": "^11.0.5",
|
|
90
90
|
"vite": "^8.2.0"
|
|
91
91
|
},
|
|
92
|
+
"optionalDependencies": {
|
|
93
|
+
"pagefind": "^1.4.0"
|
|
94
|
+
},
|
|
92
95
|
"peerDependencies": {
|
|
93
96
|
"react": "^19.0.0",
|
|
94
97
|
"react-dom": "^19.0.0"
|
|
@@ -18,8 +18,18 @@ const DEFAULT_ROOT = process.cwd();
|
|
|
18
18
|
const SCAN_DIRS = ['content', 'src'];
|
|
19
19
|
const SCAN_EXTENSIONS = new Set(['.md', '.mdx', '.tsx']);
|
|
20
20
|
|
|
21
|
-
// Icons used by the docs component library itself, so pages always have a baseline
|
|
22
|
-
|
|
21
|
+
// Icons used by the docs component library itself, so pages always have a baseline
|
|
22
|
+
// set. `copy` and `external-link` back the built-in contextual menu options, whose
|
|
23
|
+
// names are assigned in site-model.ts rather than as `icon="..."` attributes.
|
|
24
|
+
const ALWAYS_INCLUDE = [
|
|
25
|
+
'rocket',
|
|
26
|
+
'code',
|
|
27
|
+
'book-open',
|
|
28
|
+
'circle-check',
|
|
29
|
+
'circle-alert',
|
|
30
|
+
'copy',
|
|
31
|
+
'external-link',
|
|
32
|
+
];
|
|
23
33
|
|
|
24
34
|
const ICON_ATTRIBUTE = /\bicon\s*=\s*["']([^"'{}\n]+)["']/g;
|
|
25
35
|
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generates a Pagefind index over the prerendered HTML.
|
|
3
|
+
*
|
|
4
|
+
* Runs after `prerender.mjs` as the final step of `shiso build`, from the
|
|
5
|
+
* project root. It is a no-op unless docs.json resolves `search.provider`
|
|
6
|
+
* to "pagefind". Only pages carrying `data-pagefind-body` (the doc
|
|
7
|
+
* `<article>`) are indexed, so redirect stubs and the 404 page are skipped
|
|
8
|
+
* automatically.
|
|
9
|
+
*
|
|
10
|
+
* The base subdirectory of dist/client is indexed (not dist/client itself)
|
|
11
|
+
* so the recorded URLs are base-relative — the runtime provider hands them
|
|
12
|
+
* to a router whose `basename` re-applies the deploy base.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { readFile } from 'node:fs/promises';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import process from 'node:process';
|
|
18
|
+
|
|
19
|
+
import { loadDocsConfig } from './load-docs-config.mjs';
|
|
20
|
+
|
|
21
|
+
const root = process.cwd();
|
|
22
|
+
const clientDir = path.join(root, 'dist', 'client');
|
|
23
|
+
|
|
24
|
+
const { config } = await loadDocsConfig({ root });
|
|
25
|
+
|
|
26
|
+
const search = config.search;
|
|
27
|
+
const provider =
|
|
28
|
+
search === false
|
|
29
|
+
? ''
|
|
30
|
+
: String(search?.provider || 'local')
|
|
31
|
+
.trim()
|
|
32
|
+
.toLowerCase();
|
|
33
|
+
|
|
34
|
+
if (provider !== 'pagefind') {
|
|
35
|
+
process.exit(0);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let pagefind;
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
pagefind = await import('pagefind');
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (error?.code === 'ERR_MODULE_NOT_FOUND') {
|
|
44
|
+
console.error(
|
|
45
|
+
'search.provider is "pagefind" but the "pagefind" package is not installed.\n' +
|
|
46
|
+
'It is an optional dependency of @umami/shiso — install it in your project:\n\n' +
|
|
47
|
+
' pnpm add -D pagefind\n',
|
|
48
|
+
);
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Vite's `base`, normalized to "" or "/prefix". Mirrors prerender.mjs. */
|
|
56
|
+
function readBase(template) {
|
|
57
|
+
const match = template.match(/<script[^>]+src="([^"]*)\/assets\//);
|
|
58
|
+
const base = match?.[1] ?? '';
|
|
59
|
+
return base === '/' ? '' : base;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const template = await readFile(path.join(clientDir, 'index.html'), 'utf8');
|
|
63
|
+
const base = readBase(template);
|
|
64
|
+
const indexRoot = path.join(clientDir, ...base.split('/').filter(Boolean));
|
|
65
|
+
const outputPath = path.join(indexRoot, 'pagefind');
|
|
66
|
+
|
|
67
|
+
// The permalink "#" appended to every heading (rehype-autolink-headings in
|
|
68
|
+
// mdx.config.ts) must not leak into indexed titles and excerpts.
|
|
69
|
+
const DEFAULT_EXCLUDE_SELECTORS = ['.heading-anchor'];
|
|
70
|
+
|
|
71
|
+
const searchOptions = search && typeof search === 'object' ? search.options || {} : {};
|
|
72
|
+
const excludeSelectors = [
|
|
73
|
+
...DEFAULT_EXCLUDE_SELECTORS,
|
|
74
|
+
...(Array.isArray(searchOptions.excludeSelectors) ? searchOptions.excludeSelectors : []),
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
const { index, errors: createErrors } = await pagefind.createIndex({ excludeSelectors });
|
|
78
|
+
|
|
79
|
+
if (!index) {
|
|
80
|
+
console.error(`Pagefind index creation failed:\n${(createErrors || []).join('\n')}`);
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const { page_count, errors } = await index.addDirectory({ path: indexRoot, glob: '**/*.html' });
|
|
85
|
+
|
|
86
|
+
if (errors?.length) {
|
|
87
|
+
console.error(`Pagefind indexing failed:\n${errors.join('\n')}`);
|
|
88
|
+
await pagefind.close();
|
|
89
|
+
process.exit(1);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (!page_count) {
|
|
93
|
+
console.error(
|
|
94
|
+
'Pagefind indexed 0 pages. Expected prerendered pages with a data-pagefind-body attribute ' +
|
|
95
|
+
`under ${path.relative(root, indexRoot)}.`,
|
|
96
|
+
);
|
|
97
|
+
await pagefind.close();
|
|
98
|
+
process.exit(1);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const { errors: writeErrors } = await index.writeFiles({ outputPath });
|
|
102
|
+
|
|
103
|
+
if (writeErrors?.length) {
|
|
104
|
+
console.error(`Pagefind bundle write failed:\n${writeErrors.join('\n')}`);
|
|
105
|
+
await pagefind.close();
|
|
106
|
+
process.exit(1);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
await pagefind.close();
|
|
110
|
+
|
|
111
|
+
// `addDirectory` counts scanned files; the entry manifest counts pages that
|
|
112
|
+
// actually carried `data-pagefind-body` and made it into the index.
|
|
113
|
+
const entry = JSON.parse(await readFile(path.join(outputPath, 'pagefind-entry.json'), 'utf8'));
|
|
114
|
+
const indexedPages = Object.values(entry.languages || {}).reduce(
|
|
115
|
+
(total, language) => total + (language.page_count || 0),
|
|
116
|
+
0,
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
if (!indexedPages) {
|
|
120
|
+
console.error(
|
|
121
|
+
'Pagefind indexed 0 pages. Expected prerendered pages with a data-pagefind-body attribute ' +
|
|
122
|
+
`under ${path.relative(root, indexRoot)}.`,
|
|
123
|
+
);
|
|
124
|
+
process.exit(1);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
console.log(`Pagefind indexed ${indexedPages} pages into ${path.relative(root, outputPath)}`);
|
|
@@ -59,8 +59,9 @@ export interface DocContentProps {
|
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
export function DocContent({ page, doc, site }: DocContentProps) {
|
|
62
|
+
const scope = getScopeForPage(docsSite, page);
|
|
62
63
|
// Prev/next paging never crosses a version or language boundary.
|
|
63
|
-
const pagerPages =
|
|
64
|
+
const pagerPages = scope.docs.pages.filter(item => !item.hidden);
|
|
64
65
|
const pageIndex = pagerPages.findIndex(item => item.slug === page.slug);
|
|
65
66
|
const prev = pageIndex > 0 ? pagerPages[pageIndex - 1] : undefined;
|
|
66
67
|
const next = pageIndex >= 0 ? pagerPages[pageIndex + 1] : undefined;
|
|
@@ -85,8 +86,21 @@ export function DocContent({ page, doc, site }: DocContentProps) {
|
|
|
85
86
|
timeZone: 'UTC',
|
|
86
87
|
});
|
|
87
88
|
|
|
89
|
+
// Pagefind indexing markers on the prerendered HTML. Inert unless the site
|
|
90
|
+
// uses the pagefind provider. Hidden pages/scopes mirror the local index's
|
|
91
|
+
// search visibility rules; the scope filter matches the id Search.tsx sends.
|
|
92
|
+
const pagefindAttrs =
|
|
93
|
+
page.hidden || scope.hidden
|
|
94
|
+
? {}
|
|
95
|
+
: {
|
|
96
|
+
'data-pagefind-body': '',
|
|
97
|
+
...(page.scopeId !== 'default'
|
|
98
|
+
? { 'data-pagefind-filter': 'scope[data-scope]', 'data-scope': page.scopeId }
|
|
99
|
+
: {}),
|
|
100
|
+
};
|
|
101
|
+
|
|
88
102
|
return (
|
|
89
|
-
<article className="min-w-0 grow">
|
|
103
|
+
<article className="min-w-0 grow" {...pagefindAttrs}>
|
|
90
104
|
{eyebrow && <div className="text-sm font-bold text-primary">{eyebrow}</div>}
|
|
91
105
|
<div className="flex items-start justify-between gap-4">
|
|
92
106
|
{title && (
|
|
@@ -107,7 +121,7 @@ export function DocContent({ page, doc, site }: DocContentProps) {
|
|
|
107
121
|
</div>
|
|
108
122
|
)}
|
|
109
123
|
{related.length > 0 && (
|
|
110
|
-
<nav className="mt-8" aria-label={site.labels.relatedTopics}>
|
|
124
|
+
<nav className="mt-8" aria-label={site.labels.relatedTopics} data-pagefind-ignore>
|
|
111
125
|
<div className="text-sm text-muted-foreground">{site.labels.relatedTopics}</div>
|
|
112
126
|
<ul className="mt-3 flex flex-col gap-2 text-sm">
|
|
113
127
|
{related.map(({ href, title, external }) => (
|
|
@@ -132,7 +146,7 @@ export function DocContent({ page, doc, site }: DocContentProps) {
|
|
|
132
146
|
</ul>
|
|
133
147
|
</nav>
|
|
134
148
|
)}
|
|
135
|
-
<div className="mt-8 flex items-center justify-between">
|
|
149
|
+
<div className="mt-8 flex items-center justify-between" data-pagefind-ignore>
|
|
136
150
|
<NavigationButton {...prev} isPrev />
|
|
137
151
|
<NavigationButton {...next} />
|
|
138
152
|
</div>
|
|
@@ -17,6 +17,33 @@ import { resolveSearchProvider, type SearchProvider } from '@/lib/search/provide
|
|
|
17
17
|
import { getScopeByPathname } from '@/lib/site-config';
|
|
18
18
|
import type { ThemeLabels } from '@/lib/types';
|
|
19
19
|
|
|
20
|
+
/** Enough results to make the list scroll; the dialog caps its own height. */
|
|
21
|
+
const RESULT_LIMIT = 30;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Renders a snippet, turning provider-supplied `<mark>` tags into highlight
|
|
25
|
+
* elements. Snippets are parsed — never injected as HTML — so any other
|
|
26
|
+
* markup in the text renders literally.
|
|
27
|
+
*/
|
|
28
|
+
function renderSnippet(snippet: string) {
|
|
29
|
+
const parts = snippet.split(/<mark>(.*?)<\/mark>/g);
|
|
30
|
+
|
|
31
|
+
if (parts.length === 1) {
|
|
32
|
+
return snippet;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return parts.map((part, index) =>
|
|
36
|
+
index % 2 ? (
|
|
37
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: parts are positional and never reorder.
|
|
38
|
+
<mark key={index} className="rounded-xs bg-primary/15 px-px text-primary">
|
|
39
|
+
{part}
|
|
40
|
+
</mark>
|
|
41
|
+
) : (
|
|
42
|
+
part
|
|
43
|
+
),
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
20
47
|
/**
|
|
21
48
|
* Provider-neutral search dialog. The selected provider and its index or
|
|
22
49
|
* client are loaded on demand, so search stays out of the initial bundle.
|
|
@@ -71,7 +98,7 @@ export function Search({ config, labels }: { config: ResolvedSearchConfig; label
|
|
|
71
98
|
// Search stays inside the version/language scope being browsed.
|
|
72
99
|
// Providers that predate the context argument simply ignore it.
|
|
73
100
|
const scope = getScopeByPathname(pathname);
|
|
74
|
-
const nextResults = await activeProvider.search(value,
|
|
101
|
+
const nextResults = await activeProvider.search(value, RESULT_LIMIT, {
|
|
75
102
|
scopeId: scope.id,
|
|
76
103
|
language: scope.language,
|
|
77
104
|
version: scope.version,
|
|
@@ -202,7 +229,7 @@ export function Search({ config, labels }: { config: ResolvedSearchConfig; label
|
|
|
202
229
|
</div>
|
|
203
230
|
{result.snippet && (
|
|
204
231
|
<div className="mt-[0.15rem] line-clamp-2 text-[0.8rem] text-muted-foreground">
|
|
205
|
-
{result.snippet}
|
|
232
|
+
{renderSnippet(result.snippet)}
|
|
206
233
|
</div>
|
|
207
234
|
)}
|
|
208
235
|
</CommandItem>
|
|
@@ -136,7 +136,7 @@ function CommandItem({
|
|
|
136
136
|
<CommandPrimitive.Item
|
|
137
137
|
data-slot="command-item"
|
|
138
138
|
className={cn(
|
|
139
|
-
"group/command-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:*:[svg]:text-foreground",
|
|
139
|
+
"group/command-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-[selected=true]:bg-muted data-[selected=true]:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[selected=true]:*:[svg]:text-foreground",
|
|
140
140
|
className,
|
|
141
141
|
)}
|
|
142
142
|
{...props}
|
|
@@ -152,7 +152,7 @@ function CommandShortcut({ className, ...props }: React.ComponentProps<'span'>)
|
|
|
152
152
|
<span
|
|
153
153
|
data-slot="command-shortcut"
|
|
154
154
|
className={cn(
|
|
155
|
-
'ml-auto text-xs tracking-widest text-muted-foreground group-data-selected/command-item:text-foreground',
|
|
155
|
+
'ml-auto text-xs tracking-widest text-muted-foreground group-data-[selected=true]/command-item:text-foreground',
|
|
156
156
|
className,
|
|
157
157
|
)}
|
|
158
158
|
{...props}
|
|
@@ -21,6 +21,8 @@ export interface ResolvedSearchProvider {
|
|
|
21
21
|
|
|
22
22
|
const factories = new Map<string, SearchProviderFactory>();
|
|
23
23
|
|
|
24
|
+
const BUILTIN_PROVIDERS = new Set(['local', 'pagefind']);
|
|
25
|
+
|
|
24
26
|
function normalizeProviderId(id: string): string {
|
|
25
27
|
return id.trim().toLowerCase();
|
|
26
28
|
}
|
|
@@ -32,9 +34,9 @@ function normalizeProviderId(id: string): string {
|
|
|
32
34
|
export function registerSearchProvider(id: string, factory: SearchProviderFactory): () => void {
|
|
33
35
|
const providerId = normalizeProviderId(id);
|
|
34
36
|
|
|
35
|
-
if (!providerId || providerId
|
|
37
|
+
if (!providerId || BUILTIN_PROVIDERS.has(providerId)) {
|
|
36
38
|
throw new Error(
|
|
37
|
-
'Search provider ids must be non-empty and cannot replace the built-in "local"
|
|
39
|
+
'Search provider ids must be non-empty and cannot replace the built-in providers ("local", "pagefind").',
|
|
38
40
|
);
|
|
39
41
|
}
|
|
40
42
|
|
|
@@ -67,6 +69,15 @@ export async function resolveSearchProvider(
|
|
|
67
69
|
};
|
|
68
70
|
}
|
|
69
71
|
|
|
72
|
+
if (requestedId === 'pagefind') {
|
|
73
|
+
const { createPagefindSearchProvider } = await import('@/lib/search/providers/pagefind');
|
|
74
|
+
return {
|
|
75
|
+
provider: createPagefindSearchProvider(options),
|
|
76
|
+
providerId: 'pagefind',
|
|
77
|
+
fellBack: false,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
70
81
|
const factory = factories.get(requestedId);
|
|
71
82
|
|
|
72
83
|
if (factory) {
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import type { SearchContext, SearchResult } from '@/lib/search';
|
|
2
|
+
import type { SearchProvider } from '@/lib/search/provider';
|
|
3
|
+
|
|
4
|
+
/** Subset of the Pagefind browser API used by this provider. The types are
|
|
5
|
+
* declared locally because the `pagefind` package is an optional dependency
|
|
6
|
+
* and its browser bundle only exists in built output. */
|
|
7
|
+
interface PagefindSubResult {
|
|
8
|
+
title?: string;
|
|
9
|
+
url: string;
|
|
10
|
+
excerpt?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface PagefindFragment {
|
|
14
|
+
url: string;
|
|
15
|
+
excerpt?: string;
|
|
16
|
+
meta?: { title?: string };
|
|
17
|
+
sub_results?: PagefindSubResult[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface PagefindResult {
|
|
21
|
+
id: string;
|
|
22
|
+
score?: number;
|
|
23
|
+
data(): Promise<PagefindFragment>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface PagefindApi {
|
|
27
|
+
options(options: Record<string, unknown>): Promise<void>;
|
|
28
|
+
init(): Promise<void>;
|
|
29
|
+
search(query: string, options?: Record<string, unknown>): Promise<{ results: PagefindResult[] }>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type Backend = { kind: 'pagefind'; api: PagefindApi } | { kind: 'local'; provider: SearchProvider };
|
|
33
|
+
|
|
34
|
+
const DEFAULT_LIMIT = 10;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Sanitizes a Pagefind excerpt: keeps the `<mark>` highlight tags (rendered
|
|
38
|
+
* as highlights by the search dialog, never as raw HTML), strips every other
|
|
39
|
+
* tag, and decodes basic HTML entities.
|
|
40
|
+
*/
|
|
41
|
+
export function sanitizePagefindExcerpt(excerpt: string): string {
|
|
42
|
+
return excerpt
|
|
43
|
+
.replace(/<(?!\/?mark>)[^>]*>/g, '')
|
|
44
|
+
.replace(/</g, '<')
|
|
45
|
+
.replace(/>/g, '>')
|
|
46
|
+
.replace(/"/g, '"')
|
|
47
|
+
.replace(/'/g, "'")
|
|
48
|
+
.replace(/&/g, '&')
|
|
49
|
+
.trim();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Converts a Pagefind result URL into a router-relative path: strips a
|
|
53
|
+
* trailing `/index.html` and trailing slash while preserving `#anchor`. */
|
|
54
|
+
export function normalizePagefindUrl(url: string): string {
|
|
55
|
+
const hashIndex = url.indexOf('#');
|
|
56
|
+
const hash = hashIndex === -1 ? '' : url.slice(hashIndex);
|
|
57
|
+
let pathname = hashIndex === -1 ? url : url.slice(0, hashIndex);
|
|
58
|
+
|
|
59
|
+
pathname = pathname.replace(/\/index\.html$/, '/');
|
|
60
|
+
|
|
61
|
+
if (pathname.length > 1) {
|
|
62
|
+
pathname = pathname.replace(/\/+$/, '') || '/';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return `${pathname}${hash}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Maps loaded Pagefind fragments to `SearchResult`s. Each sub-result (a
|
|
69
|
+
* heading-bounded section) becomes its own result; duplicate URLs are
|
|
70
|
+
* dropped because the search dialog keys items by URL. */
|
|
71
|
+
export function mapPagefindResults(
|
|
72
|
+
fragments: { fragment: PagefindFragment; score?: number }[],
|
|
73
|
+
limit: number,
|
|
74
|
+
): SearchResult[] {
|
|
75
|
+
const results: SearchResult[] = [];
|
|
76
|
+
const seen = new Set<string>();
|
|
77
|
+
|
|
78
|
+
for (const { fragment, score } of fragments) {
|
|
79
|
+
const page = fragment.meta?.title || fragment.url;
|
|
80
|
+
const subResults = fragment.sub_results?.length
|
|
81
|
+
? fragment.sub_results
|
|
82
|
+
: [{ url: fragment.url, excerpt: fragment.excerpt }];
|
|
83
|
+
|
|
84
|
+
for (const subResult of subResults) {
|
|
85
|
+
const url = normalizePagefindUrl(subResult.url);
|
|
86
|
+
|
|
87
|
+
if (seen.has(url)) {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
seen.add(url);
|
|
92
|
+
|
|
93
|
+
const hasAnchor = url.includes('#');
|
|
94
|
+
const heading =
|
|
95
|
+
hasAnchor && subResult.title && subResult.title !== page ? subResult.title : undefined;
|
|
96
|
+
const excerpt = subResult.excerpt || fragment.excerpt;
|
|
97
|
+
|
|
98
|
+
results.push({
|
|
99
|
+
url,
|
|
100
|
+
page,
|
|
101
|
+
heading,
|
|
102
|
+
snippet: excerpt ? sanitizePagefindExcerpt(excerpt) : undefined,
|
|
103
|
+
score: score ?? 0,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
if (results.length >= limit) {
|
|
107
|
+
return results;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return results;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Built-in provider backed by a Pagefind index generated during `shiso build`.
|
|
117
|
+
* When the Pagefind bundle is unavailable (for example in dev, where no
|
|
118
|
+
* prerendered HTML exists), it falls back to the local provider.
|
|
119
|
+
*/
|
|
120
|
+
export function createPagefindSearchProvider(
|
|
121
|
+
options: Record<string, unknown> = {},
|
|
122
|
+
): SearchProvider {
|
|
123
|
+
let backend: Promise<Backend> | null = null;
|
|
124
|
+
|
|
125
|
+
async function loadBackend(): Promise<Backend> {
|
|
126
|
+
// Mirrors the BASE_URL normalization in `@/lib/paths` without importing
|
|
127
|
+
// it — that module depends on `virtual:shiso-docs-config`, which is not
|
|
128
|
+
// available to the standalone `@umami/shiso/search` bundle.
|
|
129
|
+
const base = (import.meta.env?.BASE_URL || '/').trim().replace(/\/+$/, '');
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
const api: PagefindApi = await import(/* @vite-ignore */ `${base}/pagefind/pagefind.js`);
|
|
133
|
+
const ranking = options.ranking;
|
|
134
|
+
|
|
135
|
+
await api.options({
|
|
136
|
+
baseUrl: '/',
|
|
137
|
+
...(ranking && typeof ranking === 'object' ? { ranking } : {}),
|
|
138
|
+
});
|
|
139
|
+
await api.init();
|
|
140
|
+
|
|
141
|
+
return { kind: 'pagefind', api };
|
|
142
|
+
} catch (error) {
|
|
143
|
+
console.warn(
|
|
144
|
+
'[shiso] Pagefind bundle not found — falling back to local search. ' +
|
|
145
|
+
'This is expected in dev; run "shiso build" to generate the Pagefind index.',
|
|
146
|
+
error,
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
const { createLocalSearchProvider } = await import('@/lib/search/providers/local');
|
|
150
|
+
|
|
151
|
+
return { kind: 'local', provider: createLocalSearchProvider({}) };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
async search(query: string, limit = DEFAULT_LIMIT, context?: SearchContext) {
|
|
157
|
+
backend ||= loadBackend();
|
|
158
|
+
const resolved = await backend;
|
|
159
|
+
|
|
160
|
+
if (resolved.kind === 'local') {
|
|
161
|
+
return resolved.provider.search(query, limit, context);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const filters =
|
|
165
|
+
context?.scopeId && context.scopeId !== 'default'
|
|
166
|
+
? { filters: { scope: context.scopeId } }
|
|
167
|
+
: undefined;
|
|
168
|
+
const response = await resolved.api.search(query, filters);
|
|
169
|
+
const fragments: { fragment: PagefindFragment; score?: number }[] = [];
|
|
170
|
+
|
|
171
|
+
// Hydrate fragments lazily; each page yields at least one result, so
|
|
172
|
+
// `limit` pages is always enough to fill `limit` results.
|
|
173
|
+
for (const result of response.results.slice(0, limit)) {
|
|
174
|
+
fragments.push({ fragment: await result.data(), score: result.score });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return mapPagefindResults(fragments, limit);
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
package/src/lib/search.ts
CHANGED
|
@@ -55,7 +55,11 @@ export interface SearchResult {
|
|
|
55
55
|
page: string;
|
|
56
56
|
/** Section heading, absent for the page intro. */
|
|
57
57
|
heading?: string;
|
|
58
|
-
/**
|
|
58
|
+
/**
|
|
59
|
+
* Snippet of section text around the first match, when the text matched.
|
|
60
|
+
* Matched terms may be wrapped in `<mark>` tags; the search dialog renders
|
|
61
|
+
* them as highlights (never as raw HTML).
|
|
62
|
+
*/
|
|
59
63
|
snippet?: string;
|
|
60
64
|
/** Provider-specific relevance score. Use 0 when a provider does not expose one. */
|
|
61
65
|
score: number;
|
|
@@ -63,11 +67,34 @@ export interface SearchResult {
|
|
|
63
67
|
|
|
64
68
|
const SNIPPET_RADIUS = 60;
|
|
65
69
|
|
|
66
|
-
function
|
|
70
|
+
function escapeRegExp(value: string): string {
|
|
71
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Wraps every term occurrence in `<mark>` so the dialog can highlight it. */
|
|
75
|
+
export function highlightTerms(snippet: string, terms: string[]): string {
|
|
76
|
+
if (!terms.length) {
|
|
77
|
+
return snippet;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Longer terms first, so overlapping terms highlight the longest match.
|
|
81
|
+
const pattern = new RegExp(
|
|
82
|
+
[...terms]
|
|
83
|
+
.sort((a, b) => b.length - a.length)
|
|
84
|
+
.map(escapeRegExp)
|
|
85
|
+
.join('|'),
|
|
86
|
+
'gi',
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
return snippet.replace(pattern, '<mark>$&</mark>');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function makeSnippet(text: string, index: number, length: number, terms: string[]): string {
|
|
67
93
|
const start = Math.max(0, index - SNIPPET_RADIUS);
|
|
68
94
|
const end = Math.min(text.length, index + length + SNIPPET_RADIUS);
|
|
95
|
+
const excerpt = `${start > 0 ? '…' : ''}${text.slice(start, end).trim()}${end < text.length ? '…' : ''}`;
|
|
69
96
|
|
|
70
|
-
return
|
|
97
|
+
return highlightTerms(excerpt, terms);
|
|
71
98
|
}
|
|
72
99
|
|
|
73
100
|
export function searchIndex(records: SearchRecord[], query: string, limit = 10): SearchResult[] {
|
|
@@ -119,7 +146,8 @@ export function searchIndex(records: SearchRecord[], query: string, limit = 10):
|
|
|
119
146
|
url: record.id ? `${record.url}#${record.id}` : record.url,
|
|
120
147
|
page: record.page,
|
|
121
148
|
heading: record.heading,
|
|
122
|
-
snippet:
|
|
149
|
+
snippet:
|
|
150
|
+
snippetAt >= 0 ? makeSnippet(record.text, snippetAt, snippetLength, terms) : undefined,
|
|
123
151
|
score,
|
|
124
152
|
});
|
|
125
153
|
}
|
package/src/lib/types.ts
CHANGED
|
@@ -219,7 +219,7 @@ export interface FontsConfig extends FontSpec {
|
|
|
219
219
|
export interface SearchConfig {
|
|
220
220
|
/** Placeholder text for the search input. */
|
|
221
221
|
prompt?: string;
|
|
222
|
-
/**
|
|
222
|
+
/** Provider id: "local" (default), "pagefind", or a runtime-registered id. */
|
|
223
223
|
provider?: string;
|
|
224
224
|
/** Provider-specific configuration. */
|
|
225
225
|
options?: Record<string, unknown>;
|
package/types/search.d.ts
CHANGED
|
@@ -3,6 +3,10 @@ export interface SearchResult {
|
|
|
3
3
|
page: string;
|
|
4
4
|
score: number;
|
|
5
5
|
heading?: string;
|
|
6
|
+
/**
|
|
7
|
+
* Matched terms may be wrapped in `<mark>` tags; the search dialog renders
|
|
8
|
+
* them as highlights (never as raw HTML).
|
|
9
|
+
*/
|
|
6
10
|
snippet?: string;
|
|
7
11
|
}
|
|
8
12
|
|
|
@@ -24,4 +28,9 @@ export type SearchProviderFactory = (
|
|
|
24
28
|
options: Record<string, unknown>,
|
|
25
29
|
) => SearchProvider | Promise<SearchProvider>;
|
|
26
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Registers a runtime search provider. Call this before rendering the app.
|
|
33
|
+
* The ids "local" and "pagefind" are reserved for the built-in providers.
|
|
34
|
+
* The returned cleanup function only removes this exact registration.
|
|
35
|
+
*/
|
|
27
36
|
export function registerSearchProvider(id: string, factory: SearchProviderFactory): () => void;
|