@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.
- package/README.md +4 -0
- package/package.json +3 -2
- package/src/components/stalux/archives/archivesList.astro +1 -1
- package/src/components/stalux/categories/categoriesList.astro +6 -1
- package/src/components/stalux/common/Weather.astro +1 -1
- package/src/components/stalux/common/date.astro +1 -1
- package/src/components/stalux/common/search.astro +2 -0
- package/src/components/stalux/footer/FooterBeian.astro +2 -0
- package/src/components/stalux/layout/head.astro +1 -1
- package/src/components/stalux/links/linkList.astro +1 -1
- package/src/components/stalux/posts/ArticleSummary.astro +8 -1
- package/src/components/stalux/posts/postContent.astro +20 -2
- package/src/components/stalux/posts/postList.astro +6 -1
- package/src/components/stalux/posts/postRight.astro +11 -4
- package/src/components/stalux/words/wordList.astro +11 -8
- package/src/index.ts +16 -4
- package/src/internal/cjk-search-index.ts +130 -0
- package/src/internal/compact-styles.ts +1 -1
- package/src/internal/font-slices.ts +3 -3
- package/src/internal/page-output-cache.ts +6 -7
- package/src/internal/search-style.ts +16 -0
- package/src/internal/social-card-renderer.ts +3 -5
- package/src/layouts/PostLayout.astro +2 -0
- package/src/pages/og/[image].[format].ts +1 -1
- package/src/pages/posts/[post].astro +1 -0
- package/src/schemas/collections.ts +1 -0
- package/src/schemas/config.ts +5 -5
- package/src/scripts/analytics-runtime.ts +1 -1
- package/src/scripts/archive-descriptions.ts +1 -1
- package/src/scripts/back-to-top.ts +7 -6
- package/src/scripts/background.ts +2 -2
- package/src/scripts/countdown.ts +1 -1
- package/src/scripts/date-time.ts +1 -1
- package/src/scripts/list-reveal.ts +39 -18
- package/src/scripts/module-motion.ts +5 -3
- package/src/scripts/navigation-font.ts +4 -1
- package/src/scripts/navigation.ts +1 -1
- package/src/scripts/page-runtime.ts +6 -4
- package/src/scripts/pagefind-api.ts +1 -1
- package/src/scripts/random-posts.ts +2 -2
- package/src/scripts/route-motion.ts +67 -56
- package/src/scripts/route-out.ts +12 -14
- package/src/scripts/runtime-counter.ts +1 -1
- package/src/scripts/search-engine-entry.ts +48 -0
- package/src/scripts/search-engine.ts +219 -0
- package/src/scripts/shell-hydration.ts +60 -0
- package/src/scripts/smooth-scroll.ts +4 -4
- package/src/scripts/swup-internals.d.ts +31 -0
- package/src/scripts/swup-router.ts +26 -11
- package/src/scripts/waline.ts +1 -1
- package/src/scripts/weather.ts +148 -82
- package/src/scripts/webmcp.ts +24 -20
- package/src/utils/ai-discovery.ts +1 -1
- package/src/utils/cc.ts +2 -2
- package/src/utils/cjk-search.ts +40 -0
- package/src/utils/collections-stats.ts +4 -2
- package/src/utils/dayjs.ts +2 -2
- package/src/utils/i18n.ts +2 -2
- package/src/utils/seo-document.ts +4 -4
- 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.
|
|
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
|
|
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
|
|
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
|
|
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>
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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({
|
|
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")
|
|
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
|
|
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)
|
|
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
|
|
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
|
|
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 (
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
|
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()
|
|
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(),
|