@xingwangzhe/stalux 1.46.5 → 1.47.1
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/README.md +8 -0
- package/package.json +4 -4
- package/src/components/stalux/common/search.astro +2 -0
- package/src/components/stalux/posts/ArticleSummary.astro +8 -1
- package/src/components/stalux/posts/postContent.astro +15 -1
- package/src/index.ts +15 -3
- package/src/internal/cjk-search-index.ts +130 -0
- package/src/internal/search-style.ts +16 -0
- package/src/layouts/PostLayout.astro +2 -0
- package/src/pages/posts/[post].astro +1 -0
- package/src/schemas/collections.ts +1 -0
- package/src/scripts/pagefind-api.ts +1 -1
- package/src/scripts/search-engine-entry.ts +48 -0
- package/src/scripts/search-engine.ts +219 -0
- package/src/utils/cjk-search.ts +40 -0
package/README.md
CHANGED
|
@@ -142,6 +142,14 @@ The desktop tag cloud loads dynamically near the viewport. Switching to mobile,
|
|
|
142
142
|
|
|
143
143
|
TypeScript uses ESNext. The theme defaults `vite.build.target` to `"esnext"` to retain modern syntax with minimal compatibility transforms; an explicit consumer target takes precedence. This targets modern browsers, does not polyfill missing Web APIs, and does not move ordinary scripts onto the GPU. Biome enforces `noVar` and `useConst` as errors in maintained source; dependencies and generated bundles are outside this source policy.
|
|
144
144
|
|
|
145
|
+
### EdgeOne build environment
|
|
146
|
+
|
|
147
|
+
The repository `edgeone.json` runs install and build through `bunx --bun bun@1.4.4`. Installation uses `--frozen-lockfile`, preventing an older preinstalled Bun from ignoring the v2 lockfile and resolving different dependencies. Both development and peer dependencies require Astro `^7.4.4`. Consuming projects should use the same pinned commands when deploying; their existing build script remains unchanged.
|
|
148
|
+
|
|
149
|
+
### CJK search enhancement
|
|
150
|
+
|
|
151
|
+
Builds generate both Pagefind and a sharded CJK substring index shared by the dialog and WebMCP. Titles, summaries, tags and categories are searchable. Optional post frontmatter `searchKeywords: [alias, abbreviation]` defaults to an empty array; keywords are published in HTML and static search files, so never put private information there. Unquoted Chinese, Japanese and Korean queries add verified contiguous substring matches, including single characters, NFKC normalization and space-separated terms. Title matches rank first; native results are retained and deduplicated. Quoted queries and active filters retain native Pagefind semantics. Automatic Traditional/Simplified conversion, pinyin, typo correction and semantic search are not included. Search files load on demand, failures fall back to Pagefind and can be retried, with no external search service or new telemetry.
|
|
152
|
+
|
|
145
153
|
### Initial CSS and search styles
|
|
146
154
|
|
|
147
155
|
The theme defaults to Astro's `build.inlineStylesheets: "never"`: hashed shared CSS can be cached across routes, while component styles load on the pages that use them. Normal blocking stylesheets prevent an unstyled first paint. An explicit consumer setting of `build.inlineStylesheets: "always"` restores inline styles at the cost of larger HTML responses.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xingwangzhe/stalux",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.47.1",
|
|
4
4
|
"description": "A powerful, modern Astro blog theme — use as template or install as plugin",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"astro",
|
|
@@ -144,17 +144,17 @@
|
|
|
144
144
|
"@types/node": "^26.6.5",
|
|
145
145
|
"@unocss/astro": "^66.11.0",
|
|
146
146
|
"@vitest/coverage-v8": "^5.0.4",
|
|
147
|
-
"astro": "^7.4.
|
|
147
|
+
"astro": "^7.4.4",
|
|
148
148
|
"happy-dom": "^21.0.4",
|
|
149
149
|
"knip": "^6.41.0",
|
|
150
150
|
"sharp": "0.35.5",
|
|
151
151
|
"sonda": "^0.14.0",
|
|
152
152
|
"typescript": "npm:@typescript/typescript6@6.0.2",
|
|
153
153
|
"unocss": "^66.11.0",
|
|
154
|
-
"vite": "^8.3.
|
|
154
|
+
"vite": "^8.3.5",
|
|
155
155
|
"vitest": "^5.0.4"
|
|
156
156
|
},
|
|
157
157
|
"peerDependencies": {
|
|
158
|
-
"astro": "^7.4.
|
|
158
|
+
"astro": "^7.4.4"
|
|
159
159
|
}
|
|
160
160
|
}
|
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
// Pagefind component-ui search modal.
|
|
3
3
|
// Docs: https://pagefind.app/llms-component-ui.txt
|
|
4
4
|
// data-pf-theme must be on a DIRECT parent wrapper of the component.
|
|
5
|
+
import { sitePath } from "../../../utils/site-path";
|
|
5
6
|
---
|
|
6
7
|
|
|
7
8
|
<div data-pf-theme="dark">
|
|
9
|
+
<pagefind-config bundle-path={sitePath("pagefind-enhanced/")}></pagefind-config>
|
|
8
10
|
<pagefind-modal id="search"></pagefind-modal>
|
|
9
11
|
</div>
|
|
@@ -16,7 +16,14 @@ const summary = description?.trim();
|
|
|
16
16
|
aria-label={label}
|
|
17
17
|
>
|
|
18
18
|
<p class="stalux-article-summary-label">{label}</p>
|
|
19
|
-
<p
|
|
19
|
+
<p
|
|
20
|
+
class="stalux-article-summary-text"
|
|
21
|
+
data-pagefind-body
|
|
22
|
+
data-pagefind-weight="2"
|
|
23
|
+
data-stalux-search-description
|
|
24
|
+
>
|
|
25
|
+
{summary}
|
|
26
|
+
</p>
|
|
20
27
|
</aside>
|
|
21
28
|
)}
|
|
22
29
|
|
|
@@ -13,6 +13,7 @@ interface Props {
|
|
|
13
13
|
postId?: string | number;
|
|
14
14
|
title: string;
|
|
15
15
|
description?: string;
|
|
16
|
+
searchKeywords?: string[];
|
|
16
17
|
transitionName?: string;
|
|
17
18
|
titleTransitionName?: string;
|
|
18
19
|
transitionScope?: "article" | "about";
|
|
@@ -45,7 +46,12 @@ const mdSourceUrl = exportMd ? `${Astro.url.pathname.replace(/\/$/, "")}.md` : "
|
|
|
45
46
|
>
|
|
46
47
|
<!-- Article Header -->
|
|
47
48
|
<header class="stalux-post-article-header" data-stalux-motion-region>
|
|
48
|
-
<h1
|
|
49
|
+
<h1
|
|
50
|
+
class="stalux-post-title"
|
|
51
|
+
data-stalux-motion-focus
|
|
52
|
+
data-pagefind-body
|
|
53
|
+
data-pagefind-meta="title"
|
|
54
|
+
>
|
|
49
55
|
{props.title}
|
|
50
56
|
</h1>
|
|
51
57
|
<div class="stalux-post-metadata">
|
|
@@ -111,6 +117,14 @@ const mdSourceUrl = exportMd ? `${Astro.url.pathname.replace(/\/$/, "")}.md` : "
|
|
|
111
117
|
transitionName={`${transitionPrefix}-summary`}
|
|
112
118
|
/>
|
|
113
119
|
|
|
120
|
+
<span
|
|
121
|
+
hidden
|
|
122
|
+
data-pagefind-body
|
|
123
|
+
data-stalux-search-keywords
|
|
124
|
+
data-pagefind-index-attrs="data-search-keywords"
|
|
125
|
+
data-search-keywords={(props.searchKeywords ?? []).join(" ")}
|
|
126
|
+
></span>
|
|
127
|
+
|
|
114
128
|
<!-- Article Body -->
|
|
115
129
|
<section
|
|
116
130
|
class="stalux-post-body stalux-prose"
|
package/src/index.ts
CHANGED
|
@@ -29,6 +29,7 @@ import { parse } from "parse5";
|
|
|
29
29
|
import { parse as parseYaml } from "yaml";
|
|
30
30
|
import type { StaluxOptions } from "./config";
|
|
31
31
|
import { expressiveCode } from "./expressive-code";
|
|
32
|
+
import { writeCjkSearchIndex } from "./internal/cjk-search-index";
|
|
32
33
|
import { staluxComponentsAlias } from "./internal/components-plugin";
|
|
33
34
|
import {
|
|
34
35
|
clearPageFontSubsets,
|
|
@@ -126,6 +127,7 @@ export function stalux(options: StaluxOptions = {}): AstroIntegration[] {
|
|
|
126
127
|
// is served from the page-output cache still finds its file on disk.
|
|
127
128
|
let coverCacheDir = "";
|
|
128
129
|
|
|
130
|
+
let searchBase = "/";
|
|
129
131
|
let pageFontData: { fontBuffer: Buffer; outputDir: string; cacheDir: string } | undefined;
|
|
130
132
|
|
|
131
133
|
const coreIntegration: AstroIntegration = {
|
|
@@ -140,6 +142,7 @@ export function stalux(options: StaluxOptions = {}): AstroIntegration[] {
|
|
|
140
142
|
command,
|
|
141
143
|
logger,
|
|
142
144
|
}) => {
|
|
145
|
+
searchBase = config.base;
|
|
143
146
|
const started = performance.now();
|
|
144
147
|
const isPluginMode = isPluginProject(config.root, new URL("../", import.meta.url));
|
|
145
148
|
const configLogger = logger.fork("stalux/config");
|
|
@@ -619,7 +622,9 @@ export function stalux(options: StaluxOptions = {}): AstroIntegration[] {
|
|
|
619
622
|
const pagefindStarted = performance.now();
|
|
620
623
|
|
|
621
624
|
try {
|
|
622
|
-
const { index, errors } = await pagefindCreateIndex(
|
|
625
|
+
const { index, errors } = await pagefindCreateIndex({
|
|
626
|
+
includeCharacters: "+#._",
|
|
627
|
+
});
|
|
623
628
|
|
|
624
629
|
if (!index) {
|
|
625
630
|
throw new Error(
|
|
@@ -627,15 +632,22 @@ export function stalux(options: StaluxOptions = {}): AstroIntegration[] {
|
|
|
627
632
|
);
|
|
628
633
|
}
|
|
629
634
|
|
|
630
|
-
const { page_count } = await index.addDirectory({
|
|
635
|
+
const { page_count, errors: crawlErrors } = await index.addDirectory({
|
|
636
|
+
path: outDir,
|
|
637
|
+
});
|
|
638
|
+
if (crawlErrors.length) throw new Error(crawlErrors.join("; "));
|
|
631
639
|
if (page_count === 0) {
|
|
632
640
|
throw new Error("Pagefind did not index any pages");
|
|
633
641
|
}
|
|
634
642
|
|
|
635
|
-
const { outputPath } = await index.writeFiles({
|
|
643
|
+
const { outputPath, errors: writeErrors } = await index.writeFiles({
|
|
636
644
|
outputPath: path.join(outDir, "pagefind"),
|
|
637
645
|
});
|
|
638
646
|
|
|
647
|
+
if (writeErrors.length) throw new Error(writeErrors.join("; "));
|
|
648
|
+
const cjkPages = await writeCjkSearchIndex(outDir, pageEntries, searchBase);
|
|
649
|
+
logger.info(`CJK substring index: ${cjkPages} pages`);
|
|
650
|
+
|
|
639
651
|
logger.info(
|
|
640
652
|
`indexed ${page_count} pages → ${outputPath} (${(performance.now() - pagefindStarted).toFixed(1)}ms)`,
|
|
641
653
|
);
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { type DefaultTreeAdapterMap, parse } from "parse5";
|
|
5
|
+
import { type CjkDocument, cjkGrams, gramShard, normalizeSearch } from "../utils/cjk-search";
|
|
6
|
+
import { forEachBounded } from "./bounded-tasks";
|
|
7
|
+
|
|
8
|
+
type Node = DefaultTreeAdapterMap["node"];
|
|
9
|
+
const skipped = new Set(["script", "style", "template", "noscript", "nav", "footer", "form"]);
|
|
10
|
+
const blocks = new Set([
|
|
11
|
+
"p",
|
|
12
|
+
"div",
|
|
13
|
+
"section",
|
|
14
|
+
"article",
|
|
15
|
+
"aside",
|
|
16
|
+
"li",
|
|
17
|
+
"br",
|
|
18
|
+
"pre",
|
|
19
|
+
"tr",
|
|
20
|
+
"h1",
|
|
21
|
+
"h2",
|
|
22
|
+
"h3",
|
|
23
|
+
"h4",
|
|
24
|
+
"h5",
|
|
25
|
+
"h6",
|
|
26
|
+
]);
|
|
27
|
+
const attr = (node: Node, name: string) =>
|
|
28
|
+
"attrs" in node ? node.attrs.find((item) => item.name === name)?.value : undefined;
|
|
29
|
+
|
|
30
|
+
/** Extract only real indexed DOM, preserving adjacency across inline emphasis. */
|
|
31
|
+
export function extractCjkDocument(html: string, url: string) {
|
|
32
|
+
const document = parse(html);
|
|
33
|
+
let language = "";
|
|
34
|
+
let title = "";
|
|
35
|
+
let documentTitle = "";
|
|
36
|
+
let description = "";
|
|
37
|
+
let keywords = "";
|
|
38
|
+
let hasBody = false;
|
|
39
|
+
const filters: Record<string, string[]> = {};
|
|
40
|
+
function text(node: Node): string {
|
|
41
|
+
if (
|
|
42
|
+
"tagName" in node &&
|
|
43
|
+
(skipped.has(node.tagName) || attr(node, "data-pagefind-ignore") !== undefined)
|
|
44
|
+
)
|
|
45
|
+
return "";
|
|
46
|
+
if ("value" in node) return node.value;
|
|
47
|
+
const body = "childNodes" in node ? node.childNodes.map(text).join("") : "";
|
|
48
|
+
return "tagName" in node && blocks.has(node.tagName) ? ` ${body} ` : body;
|
|
49
|
+
}
|
|
50
|
+
const parts: string[] = [];
|
|
51
|
+
function visit(node: Node, insideBody = false) {
|
|
52
|
+
if ("tagName" in node) {
|
|
53
|
+
if (skipped.has(node.tagName) || attr(node, "data-pagefind-ignore") !== undefined)
|
|
54
|
+
return;
|
|
55
|
+
if (node.tagName === "title") documentTitle = text(node).trim();
|
|
56
|
+
if (node.tagName === "html") language = attr(node, "lang") ?? "";
|
|
57
|
+
if (node.tagName === "h1" && !title) title = text(node).trim();
|
|
58
|
+
if (attr(node, "data-stalux-search-description") !== undefined)
|
|
59
|
+
description = text(node).trim();
|
|
60
|
+
if (attr(node, "data-stalux-search-keywords") !== undefined)
|
|
61
|
+
keywords = attr(node, "data-search-keywords") ?? "";
|
|
62
|
+
const filter = attr(node, "data-pagefind-filter");
|
|
63
|
+
if (filter) {
|
|
64
|
+
const match = /^([^[\]:]+)\[([^\]]+)\]$/u.exec(filter);
|
|
65
|
+
if (match) filters[match[1] as string] = [attr(node, match[2] as string) ?? ""];
|
|
66
|
+
}
|
|
67
|
+
const body = attr(node, "data-pagefind-body") !== undefined;
|
|
68
|
+
if (body) {
|
|
69
|
+
hasBody = true;
|
|
70
|
+
if (!insideBody) parts.push(text(node));
|
|
71
|
+
const attrs = attr(node, "data-pagefind-index-attrs")?.split(",") ?? [];
|
|
72
|
+
for (const name of attrs) parts.push(attr(node, name.trim()) ?? "");
|
|
73
|
+
}
|
|
74
|
+
insideBody ||= body;
|
|
75
|
+
}
|
|
76
|
+
if ("childNodes" in node) for (const child of node.childNodes) visit(child, insideBody);
|
|
77
|
+
}
|
|
78
|
+
visit(document);
|
|
79
|
+
if (!hasBody) return null;
|
|
80
|
+
const content = parts.join(" ").replace(/\s+/gu, " ").trim();
|
|
81
|
+
return {
|
|
82
|
+
url,
|
|
83
|
+
title: title || documentTitle || url,
|
|
84
|
+
description,
|
|
85
|
+
keywords,
|
|
86
|
+
language,
|
|
87
|
+
filters,
|
|
88
|
+
content,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Content-addressed shards prevent mixed old/new deployments and keep downloads query-specific. */
|
|
93
|
+
export async function writeCjkSearchIndex(outDir: string, htmlFiles: string[], base: string) {
|
|
94
|
+
const directory = path.join(outDir, "pagefind", "cjk");
|
|
95
|
+
await mkdir(directory, { recursive: true });
|
|
96
|
+
async function asset(value: unknown) {
|
|
97
|
+
const json = JSON.stringify(value);
|
|
98
|
+
const hash = createHash("sha256").update(json).digest("hex").slice(0, 20);
|
|
99
|
+
const file = `${hash}.json`;
|
|
100
|
+
await writeFile(path.join(directory, file), json);
|
|
101
|
+
return file;
|
|
102
|
+
}
|
|
103
|
+
const extracted = new Map<string, NonNullable<ReturnType<typeof extractCjkDocument>>>();
|
|
104
|
+
await forEachBounded(htmlFiles, 6, async (file) => {
|
|
105
|
+
const html = await readFile(path.join(outDir, file), "utf8");
|
|
106
|
+
// Most output pages are archives/tags: don't parse pages without indexed sections.
|
|
107
|
+
if (!html.includes("data-pagefind-body")) return;
|
|
108
|
+
const url = `${base.replace(/\/$/u, "")}/${file.replace(/index\.html$/u, "")}`;
|
|
109
|
+
const record = extractCjkDocument(html, url);
|
|
110
|
+
if (record) extracted.set(file, record);
|
|
111
|
+
});
|
|
112
|
+
const documents: CjkDocument[] = [];
|
|
113
|
+
const postings: Record<string, Record<string, number[]>> = {};
|
|
114
|
+
for (const [, record] of [...extracted].sort(([a], [b]) => a.localeCompare(b))) {
|
|
115
|
+
const { content, ...meta } = record;
|
|
116
|
+
const id = documents.length;
|
|
117
|
+
documents.push({ ...meta, file: await asset({ content }) });
|
|
118
|
+
for (const gram of cjkGrams(normalizeSearch(content))) {
|
|
119
|
+
const key = gramShard(gram);
|
|
120
|
+
postings[key] ??= {};
|
|
121
|
+
const shard = postings[key];
|
|
122
|
+
shard[gram] ??= [];
|
|
123
|
+
shard[gram].push(id);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const shards: Record<string, string> = {};
|
|
127
|
+
for (const [key, value] of Object.entries(postings)) shards[key] = await asset(value);
|
|
128
|
+
await writeFile(path.join(directory, "manifest.json"), JSON.stringify({ documents, shards }));
|
|
129
|
+
return documents.length;
|
|
130
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
2
3
|
import type { Plugin } from "vite";
|
|
3
4
|
|
|
4
5
|
const publicId = "virtual:stalux-search-style";
|
|
@@ -9,8 +10,23 @@ export function searchStylePlugin(development: boolean, base: string): Plugin {
|
|
|
9
10
|
const stylesheet = createRequire(import.meta.url)
|
|
10
11
|
.resolve("@pagefind/component-ui/css")
|
|
11
12
|
.replaceAll("\\", "/");
|
|
13
|
+
let building = false;
|
|
12
14
|
return {
|
|
13
15
|
name: "stalux:search-style",
|
|
16
|
+
configResolved(config) {
|
|
17
|
+
building = config.command === "build";
|
|
18
|
+
},
|
|
19
|
+
buildStart() {
|
|
20
|
+
if (building && this.environment.name === "client")
|
|
21
|
+
this.emitFile({
|
|
22
|
+
type: "chunk",
|
|
23
|
+
id: fileURLToPath(
|
|
24
|
+
new URL("../scripts/search-engine-entry.ts", import.meta.url),
|
|
25
|
+
),
|
|
26
|
+
fileName: "pagefind-enhanced/pagefind.js",
|
|
27
|
+
preserveSignature: "strict",
|
|
28
|
+
});
|
|
29
|
+
},
|
|
14
30
|
configureServer(server) {
|
|
15
31
|
// A linked package can be outside the consumer's workspace root.
|
|
16
32
|
server.config.server.fs.allow.push(stylesheet);
|
|
@@ -14,6 +14,7 @@ import { buildWalineProps } from "@utils/waline";
|
|
|
14
14
|
interface Props {
|
|
15
15
|
title: string;
|
|
16
16
|
description?: string;
|
|
17
|
+
searchKeywords?: string[];
|
|
17
18
|
postId?: string | number;
|
|
18
19
|
date?: string;
|
|
19
20
|
updated?: string;
|
|
@@ -61,6 +62,7 @@ const walineConfig = walineSection?.serverURL
|
|
|
61
62
|
postId={props.postId}
|
|
62
63
|
title={props.title}
|
|
63
64
|
description={props.description}
|
|
65
|
+
searchKeywords={[...tags, ...categories, ...(props.searchKeywords ?? [])]}
|
|
64
66
|
titleTransitionName={props.postId === undefined
|
|
65
67
|
? undefined
|
|
66
68
|
: getPostTitleTransitionName(props.postId)}
|
|
@@ -75,6 +75,7 @@ export async function getStaticPaths() {
|
|
|
75
75
|
<PostLayout
|
|
76
76
|
title={props.post.data.title}
|
|
77
77
|
description={autoDesc}
|
|
78
|
+
searchKeywords={props.post.data.searchKeywords ?? []}
|
|
78
79
|
postId={props.post.data.abbrlink}
|
|
79
80
|
date={props.post.data.date}
|
|
80
81
|
updated={props.post.data.updated}
|
|
@@ -88,6 +88,7 @@ const postSchema = z.object({
|
|
|
88
88
|
date: datetimeField("date"),
|
|
89
89
|
updated: datetimeField("updated").optional(),
|
|
90
90
|
draft: z.boolean().optional().default(false),
|
|
91
|
+
searchKeywords: z.array(z.string().trim().min(1)).optional(),
|
|
91
92
|
tags: z.preprocess(
|
|
92
93
|
(val) => (typeof val === "string" ? [val] : val),
|
|
93
94
|
z.array(z.string()).optional(),
|
|
@@ -30,7 +30,7 @@ export function createPagefindLoader(
|
|
|
30
30
|
if (!pending) {
|
|
31
31
|
pending = (async () => {
|
|
32
32
|
try {
|
|
33
|
-
const url = new URL(sitePath("pagefind/pagefind.js", base), origin());
|
|
33
|
+
const url = new URL(sitePath("pagefind-enhanced/pagefind.js", base), origin());
|
|
34
34
|
// Browsers can retain a failed ESM fetch; retry with a fresh module URL.
|
|
35
35
|
if (failedAttempts)
|
|
36
36
|
url.searchParams.set("stalux_retry", String(failedAttempts));
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { sitePath } from "../utils/site-path";
|
|
2
|
+
import { createEnhancedSearch, type NativeSearch } from "./search-engine";
|
|
3
|
+
|
|
4
|
+
const base = import.meta.env.BASE_URL;
|
|
5
|
+
interface PagefindModule {
|
|
6
|
+
createInstance: (options: Record<string, unknown>) => NativeSearch;
|
|
7
|
+
}
|
|
8
|
+
const moduleUrl = sitePath("pagefind/pagefind.js", base);
|
|
9
|
+
let modulePromise: Promise<PagefindModule> | undefined;
|
|
10
|
+
function loadNative(): Promise<PagefindModule> {
|
|
11
|
+
if (!modulePromise) {
|
|
12
|
+
modulePromise = import(/* @vite-ignore */ moduleUrl);
|
|
13
|
+
void modulePromise?.catch(() => {
|
|
14
|
+
modulePromise = undefined;
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
return modulePromise as Promise<PagefindModule>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Public Component UI bundle contract; no private instance fields or hooks are patched. */
|
|
21
|
+
export function createInstance(options: Record<string, unknown> = {}) {
|
|
22
|
+
let native: Promise<NativeSearch> | undefined;
|
|
23
|
+
const load = () => {
|
|
24
|
+
if (!native) {
|
|
25
|
+
native = loadNative().then((module) =>
|
|
26
|
+
module.createInstance({
|
|
27
|
+
ranking: { pageLength: 0.4, termFrequency: 0.8 },
|
|
28
|
+
...options,
|
|
29
|
+
}),
|
|
30
|
+
);
|
|
31
|
+
void native.catch(() => {
|
|
32
|
+
native = undefined;
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
return native;
|
|
36
|
+
};
|
|
37
|
+
return createEnhancedSearch(load, base);
|
|
38
|
+
}
|
|
39
|
+
let instance: ReturnType<typeof createInstance> | undefined;
|
|
40
|
+
const shared = () => {
|
|
41
|
+
instance ??= createInstance();
|
|
42
|
+
return instance;
|
|
43
|
+
};
|
|
44
|
+
export const search = (...args: Parameters<NativeSearch["search"]>) => shared().search(...args);
|
|
45
|
+
export const options = (...args: Parameters<NativeSearch["options"]>) => shared().options(...args);
|
|
46
|
+
export const filters = () => shared().filters();
|
|
47
|
+
export const mergeIndex = (...args: Parameters<NativeSearch["mergeIndex"]>) =>
|
|
48
|
+
shared().mergeIndex(...args);
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CJK,
|
|
3
|
+
type CjkDocument,
|
|
4
|
+
type CjkManifest,
|
|
5
|
+
cjkGrams,
|
|
6
|
+
gramShard,
|
|
7
|
+
normalizeSearch,
|
|
8
|
+
} from "../utils/cjk-search";
|
|
9
|
+
import { sitePath } from "../utils/site-path";
|
|
10
|
+
import { createClientLogger } from "./logger";
|
|
11
|
+
|
|
12
|
+
export interface SearchData {
|
|
13
|
+
url: string;
|
|
14
|
+
meta?: Record<string, string>;
|
|
15
|
+
content?: string;
|
|
16
|
+
excerpt?: string;
|
|
17
|
+
plain_excerpt?: string;
|
|
18
|
+
filters?: Record<string, string[]>;
|
|
19
|
+
}
|
|
20
|
+
export interface SearchResult {
|
|
21
|
+
id: string;
|
|
22
|
+
data: () => Promise<SearchData>;
|
|
23
|
+
}
|
|
24
|
+
export interface SearchResponse {
|
|
25
|
+
results: SearchResult[];
|
|
26
|
+
filters?: Record<string, Record<string, number>>;
|
|
27
|
+
totalFilters?: Record<string, Record<string, number>>;
|
|
28
|
+
unfilteredTotalCount?: number;
|
|
29
|
+
}
|
|
30
|
+
export interface SearchOptions {
|
|
31
|
+
filters?: Record<string, string | string[]>;
|
|
32
|
+
}
|
|
33
|
+
export interface NativeSearch {
|
|
34
|
+
search: (query: string | null, options?: SearchOptions) => Promise<SearchResponse>;
|
|
35
|
+
options: (options: Record<string, unknown>) => Promise<void>;
|
|
36
|
+
filters: () => Promise<Record<string, Record<string, number>>>;
|
|
37
|
+
mergeIndex: (url: string, options?: Record<string, unknown>) => Promise<void>;
|
|
38
|
+
}
|
|
39
|
+
const logger = createClientLogger("search/cjk");
|
|
40
|
+
const escapeHtml = (text: string) =>
|
|
41
|
+
text.replace(
|
|
42
|
+
/[&<>"']/gu,
|
|
43
|
+
(char) =>
|
|
44
|
+
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[char] ?? char,
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
function excerpt(content: string, terms: string[]) {
|
|
48
|
+
const position = Math.max(0, normalizeSearch(content).indexOf(terms[0] ?? ""));
|
|
49
|
+
const start = Math.max(0, position - 45);
|
|
50
|
+
const plain = `${start ? "…" : ""}${content.slice(start, start + 180)}${content.length > start + 180 ? "…" : ""}`;
|
|
51
|
+
const pattern = terms
|
|
52
|
+
.map((term) => term.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"))
|
|
53
|
+
.sort((a, b) => b.length - a.length)
|
|
54
|
+
.join("|");
|
|
55
|
+
const regex = new RegExp(pattern, "giu");
|
|
56
|
+
let end = 0;
|
|
57
|
+
let highlighted = "";
|
|
58
|
+
for (const match of plain.matchAll(regex)) {
|
|
59
|
+
highlighted +=
|
|
60
|
+
escapeHtml(plain.slice(end, match.index)) + `<mark>${escapeHtml(match[0])}</mark>`;
|
|
61
|
+
end = match.index + match[0].length;
|
|
62
|
+
}
|
|
63
|
+
return { plain_excerpt: plain, excerpt: highlighted + escapeHtml(plain.slice(end)) };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function createEnhancedSearch(
|
|
67
|
+
native: Promise<NativeSearch> | (() => Promise<NativeSearch>),
|
|
68
|
+
base = import.meta.env.BASE_URL,
|
|
69
|
+
request: typeof fetch = fetch,
|
|
70
|
+
language = () => document.documentElement.lang,
|
|
71
|
+
) {
|
|
72
|
+
const getNative = () => (typeof native === "function" ? native() : native);
|
|
73
|
+
const directory = sitePath("pagefind/cjk/", base);
|
|
74
|
+
const cache = new Map<string, Promise<unknown>>();
|
|
75
|
+
function json<T>(file: string): Promise<T> {
|
|
76
|
+
let pending = cache.get(file);
|
|
77
|
+
if (!pending) {
|
|
78
|
+
pending = request(directory + file).then((response) => {
|
|
79
|
+
if (!response.ok) throw new Error(`CJK index HTTP ${response.status}`);
|
|
80
|
+
return response.json();
|
|
81
|
+
});
|
|
82
|
+
cache.set(file, pending);
|
|
83
|
+
void pending.catch(() => cache.delete(file));
|
|
84
|
+
}
|
|
85
|
+
return pending as Promise<T>;
|
|
86
|
+
}
|
|
87
|
+
const ids = new Map<string, Promise<string | undefined>>();
|
|
88
|
+
async function nativeId(api: NativeSearch, record: CjkDocument, original: SearchResult[]) {
|
|
89
|
+
const post = record.filters.post?.[0];
|
|
90
|
+
if (post) {
|
|
91
|
+
let pending = ids.get(record.url);
|
|
92
|
+
if (!pending) {
|
|
93
|
+
pending = api
|
|
94
|
+
.search(null, { filters: { post } })
|
|
95
|
+
.then((result) => result.results[0]?.id);
|
|
96
|
+
ids.set(record.url, pending);
|
|
97
|
+
void pending.catch(() => ids.delete(record.url));
|
|
98
|
+
}
|
|
99
|
+
return pending;
|
|
100
|
+
}
|
|
101
|
+
// Non-post pages are uncommon; resolve existing hits only to avoid duplicate URLs.
|
|
102
|
+
for (const result of original) {
|
|
103
|
+
if ((await result.data()).url.replace(/\/$/u, "") === record.url.replace(/\/$/u, ""))
|
|
104
|
+
return result.id;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
async function search(query: string | null, options?: SearchOptions): Promise<SearchResponse> {
|
|
108
|
+
const api = await getNative();
|
|
109
|
+
if (query === null || !query.trim()) return api.search(query, options);
|
|
110
|
+
const normalized = normalizeSearch(query);
|
|
111
|
+
let original = await api.search(query, options);
|
|
112
|
+
// Keep Pagefind's quoted phrase semantics and compound filter semantics intact.
|
|
113
|
+
if (
|
|
114
|
+
/["“”]/u.test(query) ||
|
|
115
|
+
Object.values(options?.filters ?? {}).some((value) => value.length)
|
|
116
|
+
)
|
|
117
|
+
return original;
|
|
118
|
+
if (normalized !== query) {
|
|
119
|
+
const variant = await api.search(normalized, options);
|
|
120
|
+
const originalIds = new Set(original.results.map((result) => result.id));
|
|
121
|
+
const results = [
|
|
122
|
+
...original.results,
|
|
123
|
+
...variant.results.filter((result) => !originalIds.has(result.id)),
|
|
124
|
+
];
|
|
125
|
+
original = { ...original, results, unfilteredTotalCount: results.length };
|
|
126
|
+
}
|
|
127
|
+
if (!CJK.test(normalized)) return original;
|
|
128
|
+
try {
|
|
129
|
+
const manifest = await json<CjkManifest>("manifest.json");
|
|
130
|
+
const grams = cjkGrams(normalized);
|
|
131
|
+
const shardKeys = [...new Set(grams.map(gramShard))];
|
|
132
|
+
const shards = new Map(
|
|
133
|
+
await Promise.all(
|
|
134
|
+
shardKeys.map(
|
|
135
|
+
async (key) =>
|
|
136
|
+
[
|
|
137
|
+
key,
|
|
138
|
+
manifest.shards[key]
|
|
139
|
+
? await json<Record<string, number[]>>(
|
|
140
|
+
manifest.shards[key] as string,
|
|
141
|
+
)
|
|
142
|
+
: {},
|
|
143
|
+
] as const,
|
|
144
|
+
),
|
|
145
|
+
),
|
|
146
|
+
);
|
|
147
|
+
const lists = grams
|
|
148
|
+
.map((gram) => shards.get(gramShard(gram))?.[gram] ?? [])
|
|
149
|
+
.sort((a, b) => a.length - b.length);
|
|
150
|
+
let candidates = new Set(lists[0] ?? []);
|
|
151
|
+
for (const list of lists.slice(1)) {
|
|
152
|
+
const allowed = new Set(list);
|
|
153
|
+
candidates = new Set([...candidates].filter((id) => allowed.has(id)));
|
|
154
|
+
}
|
|
155
|
+
const terms = normalized.split(" ").filter(Boolean);
|
|
156
|
+
const matches: Array<{ record: CjkDocument; content: string; score: number }> = [];
|
|
157
|
+
// Bound text downloads independently of result count. Text is fetched only for n-gram candidates.
|
|
158
|
+
const pending = [...candidates];
|
|
159
|
+
let cursor = 0;
|
|
160
|
+
await Promise.all(
|
|
161
|
+
Array.from({ length: Math.min(6, pending.length) }, async () => {
|
|
162
|
+
while (cursor < pending.length) {
|
|
163
|
+
const record = manifest.documents[pending[cursor++] as number];
|
|
164
|
+
if (!record || record.language.toLowerCase() !== language().toLowerCase())
|
|
165
|
+
continue;
|
|
166
|
+
const { content } = await json<{ content: string }>(record.file);
|
|
167
|
+
const normalizedContent = normalizeSearch(content);
|
|
168
|
+
if (!terms.every((term) => normalizedContent.includes(term))) continue;
|
|
169
|
+
const title = normalizeSearch(record.title);
|
|
170
|
+
const description = normalizeSearch(record.description);
|
|
171
|
+
const score =
|
|
172
|
+
title === normalized
|
|
173
|
+
? 100
|
|
174
|
+
: title.includes(normalized)
|
|
175
|
+
? 80
|
|
176
|
+
: terms.every((term) => title.includes(term))
|
|
177
|
+
? 60
|
|
178
|
+
: terms.every((term) => description.includes(term))
|
|
179
|
+
? 40
|
|
180
|
+
: 20;
|
|
181
|
+
matches.push({ record, content, score });
|
|
182
|
+
}
|
|
183
|
+
}),
|
|
184
|
+
);
|
|
185
|
+
matches.sort((a, b) => b.score - a.score || a.record.url.localeCompare(b.record.url));
|
|
186
|
+
const supplemental: SearchResult[] = [];
|
|
187
|
+
for (const { record, content } of matches) {
|
|
188
|
+
const id = await nativeId(api, record, original.results);
|
|
189
|
+
supplemental.push({
|
|
190
|
+
id: id ?? `cjk:${record.url}`,
|
|
191
|
+
data: () =>
|
|
192
|
+
Promise.resolve({
|
|
193
|
+
url: record.url,
|
|
194
|
+
meta: { title: record.title },
|
|
195
|
+
content,
|
|
196
|
+
filters: record.filters,
|
|
197
|
+
...excerpt(content, terms),
|
|
198
|
+
}),
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
const seen = new Set(supplemental.map((result) => result.id));
|
|
202
|
+
const results = [
|
|
203
|
+
...supplemental,
|
|
204
|
+
...original.results.filter((result) => !seen.has(result.id)),
|
|
205
|
+
];
|
|
206
|
+
return { ...original, results, unfilteredTotalCount: results.length };
|
|
207
|
+
} catch {
|
|
208
|
+
logger.warn("CJK substring index unavailable; using Pagefind");
|
|
209
|
+
return original;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return {
|
|
213
|
+
search,
|
|
214
|
+
options: async (options: Record<string, unknown>) => (await getNative()).options(options),
|
|
215
|
+
filters: async () => (await getNative()).filters(),
|
|
216
|
+
mergeIndex: async (url: string, options?: Record<string, unknown>) =>
|
|
217
|
+
(await getNative()).mergeIndex(url, options),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** Shared by the indexer and browser: keep Unicode normalization identical. */
|
|
2
|
+
export const CJK = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u;
|
|
3
|
+
|
|
4
|
+
export function normalizeSearch(text: string): string {
|
|
5
|
+
return text.normalize("NFKC").toLowerCase().replace(/\s+/gu, " ").trim();
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function cjkGrams(text: string): string[] {
|
|
9
|
+
const grams = new Set<string>();
|
|
10
|
+
let previous = "";
|
|
11
|
+
for (const char of normalizeSearch(text)) {
|
|
12
|
+
if (CJK.test(char)) {
|
|
13
|
+
grams.add(char);
|
|
14
|
+
if (previous) grams.add(previous + char);
|
|
15
|
+
previous = char;
|
|
16
|
+
} else previous = "";
|
|
17
|
+
}
|
|
18
|
+
return [...grams];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function gramShard(gram: string): string {
|
|
22
|
+
let hash = 0;
|
|
23
|
+
for (const char of gram) hash = (hash * 31 + (char.codePointAt(0) ?? 0)) >>> 0;
|
|
24
|
+
return (hash % 32).toString(16);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface CjkDocument {
|
|
28
|
+
url: string;
|
|
29
|
+
title: string;
|
|
30
|
+
description: string;
|
|
31
|
+
keywords: string;
|
|
32
|
+
language: string;
|
|
33
|
+
filters: Record<string, string[]>;
|
|
34
|
+
file: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface CjkManifest {
|
|
38
|
+
documents: CjkDocument[];
|
|
39
|
+
shards: Record<string, string>;
|
|
40
|
+
}
|