@xingwangzhe/stalux 1.46.2 → 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.
Files changed (60) hide show
  1. package/README.md +4 -0
  2. package/package.json +3 -2
  3. package/src/components/stalux/archives/archivesList.astro +1 -1
  4. package/src/components/stalux/categories/categoriesList.astro +6 -1
  5. package/src/components/stalux/common/Weather.astro +1 -1
  6. package/src/components/stalux/common/date.astro +1 -1
  7. package/src/components/stalux/common/search.astro +2 -0
  8. package/src/components/stalux/footer/FooterBeian.astro +2 -0
  9. package/src/components/stalux/layout/head.astro +1 -1
  10. package/src/components/stalux/links/linkList.astro +1 -1
  11. package/src/components/stalux/posts/ArticleSummary.astro +8 -1
  12. package/src/components/stalux/posts/postContent.astro +20 -2
  13. package/src/components/stalux/posts/postList.astro +6 -1
  14. package/src/components/stalux/posts/postRight.astro +11 -4
  15. package/src/components/stalux/words/wordList.astro +11 -8
  16. package/src/index.ts +16 -4
  17. package/src/internal/cjk-search-index.ts +130 -0
  18. package/src/internal/compact-styles.ts +1 -1
  19. package/src/internal/font-slices.ts +3 -3
  20. package/src/internal/page-output-cache.ts +6 -7
  21. package/src/internal/search-style.ts +16 -0
  22. package/src/internal/social-card-renderer.ts +3 -5
  23. package/src/layouts/PostLayout.astro +2 -0
  24. package/src/pages/og/[image].[format].ts +1 -1
  25. package/src/pages/posts/[post].astro +1 -0
  26. package/src/schemas/collections.ts +1 -0
  27. package/src/schemas/config.ts +5 -5
  28. package/src/scripts/analytics-runtime.ts +1 -1
  29. package/src/scripts/archive-descriptions.ts +1 -1
  30. package/src/scripts/back-to-top.ts +7 -6
  31. package/src/scripts/background.ts +2 -2
  32. package/src/scripts/countdown.ts +1 -1
  33. package/src/scripts/date-time.ts +1 -1
  34. package/src/scripts/list-reveal.ts +39 -18
  35. package/src/scripts/module-motion.ts +5 -3
  36. package/src/scripts/navigation-font.ts +4 -1
  37. package/src/scripts/navigation.ts +1 -1
  38. package/src/scripts/page-runtime.ts +6 -4
  39. package/src/scripts/pagefind-api.ts +1 -1
  40. package/src/scripts/random-posts.ts +2 -2
  41. package/src/scripts/route-motion.ts +67 -56
  42. package/src/scripts/route-out.ts +12 -14
  43. package/src/scripts/runtime-counter.ts +1 -1
  44. package/src/scripts/search-engine-entry.ts +48 -0
  45. package/src/scripts/search-engine.ts +219 -0
  46. package/src/scripts/shell-hydration.ts +60 -0
  47. package/src/scripts/smooth-scroll.ts +4 -4
  48. package/src/scripts/swup-internals.d.ts +31 -0
  49. package/src/scripts/swup-router.ts +26 -11
  50. package/src/scripts/waline.ts +1 -1
  51. package/src/scripts/weather.ts +148 -82
  52. package/src/scripts/webmcp.ts +24 -20
  53. package/src/utils/ai-discovery.ts +1 -1
  54. package/src/utils/cc.ts +2 -2
  55. package/src/utils/cjk-search.ts +40 -0
  56. package/src/utils/collections-stats.ts +4 -2
  57. package/src/utils/dayjs.ts +2 -2
  58. package/src/utils/i18n.ts +2 -2
  59. package/src/utils/seo-document.ts +4 -4
  60. package/src/utils/taxonomy.ts +10 -12
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.2",
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",
@@ -91,7 +91,7 @@
91
91
  "knip": "knip",
92
92
  "verify:build": "node scripts/verify-build.ts",
93
93
  "validate": "bun run styles:build && bun run check && bun run node:check && bun run astro:check && bun run knip && bun run test:coverage && bun run build && bun run build:cli",
94
- "prepare": "git config core.hooksPath scripts/git-hooks",
94
+ "prepare": "sh -c 'if [ -d scripts/git-hooks ] && git rev-parse --git-dir >/dev/null 2>&1; then git config core.hooksPath scripts/git-hooks; fi'",
95
95
  "dev:debug": "STALUX_DEBUG=1 astro dev --verbose",
96
96
  "build:debug": "STALUX_DEBUG=1 astro build --timings --verbose && node scripts/verify-build.ts",
97
97
  "styles:build": "node scripts/build-styles.ts && node scripts/build-style-names.ts",
@@ -145,6 +145,7 @@
145
145
  "@unocss/astro": "^66.11.0",
146
146
  "@vitest/coverage-v8": "^5.0.4",
147
147
  "astro": "^7.4.2",
148
+ "happy-dom": "^21.0.4",
148
149
  "knip": "^6.41.0",
149
150
  "sharp": "0.35.5",
150
151
  "sonda": "^0.14.0",
@@ -56,7 +56,7 @@ const archiveYears = [...archivesByYearMonth.entries()]
56
56
  }));
57
57
  ---
58
58
 
59
- <section class="stalux-archive-container" data-stalux-motion-content>
59
+ <section class="stalux-archive-container" data-stalux-motion-content data-stalux-motion-stage>
60
60
  <div class="stalux-archive-page-header">
61
61
  <div class="stalux-archive-timeline-container">
62
62
  {archiveYears.length > 0 ? (
@@ -7,7 +7,12 @@ import { getCategoryCountList } from "@utils/collections-stats";
7
7
  const categoryList = await getCategoryCountList(Astro.logger);
8
8
  ---
9
9
 
10
- <ul class="stalux-categories" data-stalux-motion-content data-stalux-reveal>
10
+ <ul
11
+ class="stalux-categories"
12
+ data-stalux-motion-content
13
+ data-stalux-motion-stage
14
+ data-stalux-reveal
15
+ >
11
16
  {categoryList.map((category) => (
12
17
  <li data-stalux-reveal-item>
13
18
  <CategoriesCard category={category} />
@@ -31,7 +31,7 @@ const zh = (site.lang || "zh-CN").startsWith("zh");
31
31
  {zh ? "访客 IP 与位置加载中…" : "Loading visitor IP and location…"}
32
32
  </p>
33
33
  )}
34
- <p class="stalux-weather-status" data-weather-status></p>
34
+ <p class="stalux-weather-status" data-weather-status />
35
35
  <button type="button" data-weather-retry hidden>
36
36
  {zh ? "重试" : "Retry"}
37
37
  </button>
@@ -7,7 +7,7 @@
7
7
  (() => {
8
8
  const dateElement = document.querySelector('[data-ref="date"]');
9
9
  const timeElement = document.querySelector('[data-ref="time"]');
10
- if (!dateElement || !timeElement) return;
10
+ if (!(dateElement && timeElement)) return;
11
11
  const now = new Date();
12
12
  const pad = (value) => String(value).padStart(2, "0");
13
13
  const lang = document.documentElement.lang || "zh-CN";
@@ -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>
@@ -35,6 +35,8 @@ const showBeian = !!(icp || pubsec);
35
35
  <img
36
36
  src="https://beian.mps.gov.cn/img/logo01.dd7ff50e.png"
37
37
  alt={t("footer.securityIcon")}
38
+ width="15"
39
+ height="15"
38
40
  />
39
41
  {pubsec}
40
42
  </a>
@@ -18,7 +18,7 @@ import {
18
18
  import { toMachineDateTime } from "@utils/semantic-time";
19
19
  import { buildSeoDocument, type SeoPageInput } from "@utils/seo-document";
20
20
  import { SEO } from "astro-seo";
21
- import packageJson from "../../../../package.json";
21
+ import packageJson from "../../../../package.json" with { type: "json" };
22
22
 
23
23
  const configEntries = await getCollection("config");
24
24
  const siteConfig = getSiteData(configEntries);
@@ -10,7 +10,7 @@ const linksConfig = getLinksData(configEntries);
10
10
  const links = linksConfig?.sites ?? [];
11
11
  ---
12
12
 
13
- <ul class="stalux-links" data-stalux-motion-content data-stalux-reveal>
13
+ <ul class="stalux-links" data-stalux-motion-content data-stalux-motion-stage data-stalux-reveal>
14
14
  {links.map((link) => (
15
15
  <li class="stalux-link-list-item" data-stalux-reveal-item>
16
16
  <LinkCard link={link} />
@@ -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";
@@ -38,10 +39,19 @@ const exportMd = Boolean(promoteConfig?.export_md) && !isAboutPage;
38
39
  const mdSourceUrl = exportMd ? `${Astro.url.pathname.replace(/\/$/, "")}.md` : "";
39
40
  ---
40
41
 
41
- <article class="stalux-article" data-stalux-gallery={props.hasImage ? "true" : undefined}>
42
+ <article
43
+ class="stalux-article"
44
+ data-stalux-motion-stage
45
+ data-stalux-gallery={props.hasImage ? "true" : undefined}
46
+ >
42
47
  <!-- Article Header -->
43
48
  <header class="stalux-post-article-header" data-stalux-motion-region>
44
- <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
+ >
45
55
  {props.title}
46
56
  </h1>
47
57
  <div class="stalux-post-metadata">
@@ -107,6 +117,14 @@ const mdSourceUrl = exportMd ? `${Astro.url.pathname.replace(/\/$/, "")}.md` : "
107
117
  transitionName={`${transitionPrefix}-summary`}
108
118
  />
109
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
+
110
128
  <!-- Article Body -->
111
129
  <section
112
130
  class="stalux-post-body stalux-prose"
@@ -11,7 +11,12 @@ const props = Astro.props as Props;
11
11
  const posts = props.posts ?? (await getCollection("posts", ({ data }) => !data.draft));
12
12
  ---
13
13
 
14
- <div data-stalux="postlist" class="stalux-post-list-container" data-stalux-motion-content>
14
+ <div
15
+ data-stalux="postlist"
16
+ class="stalux-post-list-container"
17
+ data-stalux-motion-content
18
+ data-stalux-motion-stage
19
+ >
15
20
  <ul class="post-list" data-stalux-reveal>
16
21
  {posts.map((post, index) => (
17
22
  <li data-stalux-reveal-item>
@@ -14,7 +14,14 @@ interface Props {
14
14
  const { headings = [], tags = [], categories = [] } = Astro.props;
15
15
  ---
16
16
 
17
- <aside class="stalux-sidebar-right" data-stalux-scroll-pane>
17
+ <!--
18
+ The right column is a shell. Between two articles it keeps its node, its
19
+ scroll position and its frame; only the three article-specific lists inside
20
+ the slots are refreshed (see scripts/shell-hydration.ts). Rebuilding the whole
21
+ aside on every article change made the reader watch the sidebar disappear and
22
+ reflow, and it cost a full layout of the new article to do it.
23
+ -->
24
+ <aside class="stalux-sidebar-right" data-stalux-scroll-pane data-swup-persist="post-sidebar-right">
18
25
  <div
19
26
  class="stalux-sidebar-container"
20
27
  data-stalux-module="sidebar"
@@ -23,13 +30,13 @@ const { headings = [], tags = [], categories = [] } = Astro.props;
23
30
  <PostDate />
24
31
  <Weather />
25
32
  </div>
26
- <div class="stalux-sidebar-container">
33
+ <div class="stalux-sidebar-container" data-stalux-shell-slot="toc">
27
34
  <PostToc headings={headings} />
28
35
  </div>
29
- <div class="stalux-sidebar-container">
36
+ <div class="stalux-sidebar-container" data-stalux-shell-slot="tags">
30
37
  <PostTaxonomy kind="tag" items={tags} />
31
38
  </div>
32
- <div class="stalux-sidebar-container">
39
+ <div class="stalux-sidebar-container" data-stalux-shell-slot="categories">
33
40
  <PostTaxonomy kind="category" items={categories} />
34
41
  </div>
35
42
  </aside>
@@ -19,14 +19,17 @@ const sorted = [...entries].sort((a, b) => {
19
19
  });
20
20
  ---
21
21
 
22
- <ul class="stalux-word-list-list" data-stalux-motion-content data-stalux-reveal>
23
- {sorted.map((entry) => {
24
- return (
25
- <li class="stalux-word-list-item" data-stalux-reveal-item>
26
- <WordCard entry={entry} timezone={timezone} />
27
- </li>
28
- );
29
- })}
22
+ <ul
23
+ class="stalux-word-list-list"
24
+ data-stalux-motion-content
25
+ data-stalux-motion-stage
26
+ data-stalux-reveal
27
+ >
28
+ {sorted.map((entry) => (
29
+ <li class="stalux-word-list-item" data-stalux-reveal-item>
30
+ <WordCard entry={entry} timezone={timezone} />
31
+ </li>
32
+ ))}
30
33
  </ul>
31
34
 
32
35
  <script src="../../../scripts/word-list.ts"></script>
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
  );
@@ -678,7 +690,7 @@ export function stalux(options: StaluxOptions = {}): AstroIntegration[] {
678
690
  const postsDir = path.resolve(process.cwd(), contentDir, "posts");
679
691
  if (existsSync(postsDir)) {
680
692
  for (const filename of readdirSync(postsDir)) {
681
- if (!filename.endsWith(".md") && !filename.endsWith(".mdx")) {
693
+ if (!(filename.endsWith(".md") || filename.endsWith(".mdx"))) {
682
694
  continue;
683
695
  }
684
696
  const source = readFileSync(path.join(postsDir, filename), "utf8");
@@ -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
+ }
@@ -80,7 +80,7 @@ function markupEdits(value: string, names: Names): Edit[] {
80
80
  if (["pre", "code", "script", "style"].includes(node.tagName)) return;
81
81
  for (const attr of node.attrs) {
82
82
  const loc = node.sourceCodeLocation?.attrs?.[attr.name];
83
- if (!loc || !["class", "style", "data-toc-active-class"].includes(attr.name))
83
+ if (!(loc && ["class", "style", "data-toc-active-class"].includes(attr.name)))
84
84
  continue;
85
85
  const raw = value.slice(loc.startOffset, loc.endOffset);
86
86
  const offset = loc.startOffset + raw.indexOf("=") + 1;
@@ -258,7 +258,7 @@ function isHiddenElement(attrs: Array<{ name: string; value: string }>): boolean
258
258
  const CONTROL_CHARACTER = /\p{C}/u;
259
259
  function addGlyphs(text: string, output: Set<string>): void {
260
260
  for (const char of text)
261
- if (!output.has(char) && !CONTROL_CHARACTER.test(char)) output.add(char);
261
+ if (!(output.has(char) || CONTROL_CHARACTER.test(char))) output.add(char);
262
262
  }
263
263
 
264
264
  function collectBodyText(node: HtmlNode, output: Set<string>, skipSharedComponents = false): void {
@@ -410,7 +410,7 @@ function preparePageFont(
410
410
  excludedChars: ReadonlySet<string> = new Set(),
411
411
  ) {
412
412
  const body = findBody(document);
413
- if (!body) return undefined;
413
+ if (!body) return;
414
414
 
415
415
  const glyphs = new Set<string>();
416
416
  addGlyphs(RESERVED_GLYPHS, glyphs);
@@ -425,7 +425,7 @@ function preparePageFont(
425
425
  const chars = [...glyphs].sort(
426
426
  (a, b) => (a.codePointAt(0) as number) - (b.codePointAt(0) as number),
427
427
  );
428
- if (chars.length === 0) return undefined;
428
+ if (chars.length === 0) return;
429
429
 
430
430
  let prepared = preparedFonts.get(fontBuffer);
431
431
  if (!prepared) {
@@ -59,14 +59,13 @@ export async function createPageOutputCache(
59
59
  for (const [asset, expected] of record.assets) {
60
60
  if ((await assetDigest(asset)) !== expected) return undefined;
61
61
  }
62
- if (record.font) {
63
- if (
64
- !/^page-[a-f\d]+\.woff2$/u.test(record.font.filename) ||
62
+ if (
63
+ record.font &&
64
+ (!/^page-[a-f\d]+\.woff2$/u.test(record.font.filename) ||
65
65
  typeof record.font.sourcePath !== "string" ||
66
- !(await fs.stat(record.font.sourcePath)).isFile()
67
- )
68
- return undefined;
69
- }
66
+ !(await fs.stat(record.font.sourcePath)).isFile())
67
+ )
68
+ return undefined;
70
69
  return { html: record.html, font: record.font };
71
70
  } catch {
72
71
  // Missing, truncated, or invalid cache entries are rebuilt from source.
@@ -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);
@@ -87,7 +87,7 @@ async function loadCoverUncached(cover: string, paths: SocialCardPaths): Promise
87
87
  if (cachedStat && Date.now() - cachedStat.mtimeMs < 86_400_000)
88
88
  return readFile(remoteCache);
89
89
  const response = await fetch(url, { signal: AbortSignal.timeout(8000) });
90
- if (!response.ok || !response.headers.get("content-type")?.startsWith("image/"))
90
+ if (!(response.ok && response.headers.get("content-type")?.startsWith("image/")))
91
91
  throw new Error(`cover returned HTTP ${response.status} or non-image content`);
92
92
  const chunks: Uint8Array[] = [];
93
93
  let size = 0;
@@ -100,8 +100,7 @@ async function loadCoverUncached(cover: string, paths: SocialCardPaths): Promise
100
100
  bytes = Buffer.concat(chunks);
101
101
  const dimensions = imageSize(bytes);
102
102
  if (
103
- !dimensions.width ||
104
- !dimensions.height ||
103
+ !(dimensions.width && dimensions.height) ||
105
104
  dimensions.width * dimensions.height > 32_000_000
106
105
  )
107
106
  throw new Error("invalid cover dimensions");
@@ -110,8 +109,7 @@ async function loadCoverUncached(cover: string, paths: SocialCardPaths): Promise
110
109
  }
111
110
  const dimensions = imageSize(bytes);
112
111
  if (
113
- !dimensions.width ||
114
- !dimensions.height ||
112
+ !(dimensions.width && dimensions.height) ||
115
113
  dimensions.width * dimensions.height > 32_000_000
116
114
  )
117
115
  throw new Error("invalid or oversized cover dimensions");
@@ -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)}
@@ -15,7 +15,7 @@ export async function getStaticPaths() {
15
15
  return posts
16
16
  .filter(
17
17
  (post) =>
18
- !post.data["og:image"]?.trim() || !isSocialImageUrl(post.data["og:image"].trim()),
18
+ !(post.data["og:image"]?.trim() && isSocialImageUrl(post.data["og:image"].trim())),
19
19
  )
20
20
  .flatMap((post) => {
21
21
  const card = createSocialCardData(post.data, site, author);
@@ -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(),