@xingwangzhe/stalux 1.46.5 → 1.47.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/README.md CHANGED
@@ -142,6 +142,10 @@ 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
+ ### CJK search enhancement
146
+
147
+ 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.
148
+
145
149
  ### Initial CSS and search styles
146
150
 
147
151
  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.46.5",
3
+ "version": "1.47.0",
4
4
  "description": "A powerful, modern Astro blog theme — use as template or install as plugin",
5
5
  "keywords": [
6
6
  "astro",
@@ -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 class="stalux-article-summary-text">{summary}</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 class="stalux-post-title" data-stalux-motion-focus>
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({ path: outDir });
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
+ ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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
+ }