@xingwangzhe/stalux 1.35.10 → 1.35.12
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/package.json +4 -3
- package/src/index.ts +2 -3
- package/src/internal/bounded-tasks.ts +28 -0
- package/src/internal/compact-styles.ts +76 -23
- package/src/internal/font-slices.ts +27 -18
- package/src/internal/page-output-cache.ts +17 -8
- package/src/pages/api/posts.json.ts +13 -14
- package/src/plugins/feature-flags.ts +8 -1
- package/src/scripts/navigation.ts +4 -2
- package/src/scripts/photoswipe.ts +13 -3
- package/src/scripts/random-posts.ts +12 -10
- package/src/scripts/search-dialog.ts +2 -1
- package/src/scripts/webmcp.ts +13 -6
- package/src/scripts/word-list.ts +13 -3
- package/src/utils/collections-stats.ts +9 -22
- package/src/utils/content-index.ts +13 -4
- package/src/utils/dayjs.ts +26 -11
- package/src/utils/feed.ts +30 -29
- package/src/utils/post-lookup.ts +28 -0
- package/src/utils/toc-scrollspy.ts +20 -6
- package/src/utils/word-count-utils.ts +45 -41
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xingwangzhe/stalux",
|
|
3
|
-
"version": "1.35.
|
|
3
|
+
"version": "1.35.12",
|
|
4
4
|
"description": "A powerful, modern Astro blog theme — use as template or install as plugin",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"astro",
|
|
@@ -94,7 +94,9 @@
|
|
|
94
94
|
"dev:debug": "STALUX_DEBUG=1 astro dev --verbose",
|
|
95
95
|
"build:debug": "STALUX_DEBUG=1 astro build --verbose && node scripts/verify-build.ts",
|
|
96
96
|
"styles:build": "node scripts/build-styles.ts && node scripts/build-style-names.ts",
|
|
97
|
-
"styles:watch": "node scripts/build-styles.ts --watch"
|
|
97
|
+
"styles:watch": "node scripts/build-styles.ts --watch",
|
|
98
|
+
"benchmark:performance": "node scripts/benchmark-performance.ts",
|
|
99
|
+
"benchmark:builds": "python3 scripts/benchmark-builds.py"
|
|
98
100
|
},
|
|
99
101
|
"dependencies": {
|
|
100
102
|
"@astrojs/compiler": "^4.0.0",
|
|
@@ -102,7 +104,6 @@
|
|
|
102
104
|
"@astrojs/partytown": "^2.1.8",
|
|
103
105
|
"@astrojs/rss": "^4.0.19",
|
|
104
106
|
"@astrojs/sitemap": "^3.7.4",
|
|
105
|
-
"@babel/parser": "^8.0.6",
|
|
106
107
|
"@expressive-code/plugin-line-numbers": "^0.44.2",
|
|
107
108
|
"@lucide/astro": "^1.56.0",
|
|
108
109
|
"@mcp-b/webmcp-polyfill": "^5.1.0",
|
package/src/index.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { forEachBounded } from "./internal/bounded-tasks.ts";
|
|
1
2
|
import { compactStylesPlugin } from "./internal/compact-styles.ts";
|
|
2
3
|
import { modernCssTargets } from "./internal/modern-css.ts";
|
|
3
4
|
import { styleNames } from "./internal/style-names.generated.ts";
|
|
@@ -521,9 +522,7 @@ export function stalux(options: StaluxOptions = {}): AstroIntegration[] {
|
|
|
521
522
|
);
|
|
522
523
|
};
|
|
523
524
|
// Bound I/O concurrency without retaining the entire site's HTML in memory.
|
|
524
|
-
|
|
525
|
-
await Promise.all(pageEntries.slice(offset, offset + 4).map(processPage));
|
|
526
|
-
}
|
|
525
|
+
await forEachBounded(pageEntries, 4, processPage);
|
|
527
526
|
if (pageFontData) {
|
|
528
527
|
clearPageFontSubsets(pageFontData.outputDir, referencedPageFonts);
|
|
529
528
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Work-conserving queue: one slow task never stalls the other workers. */
|
|
2
|
+
export async function forEachBounded<T>(
|
|
3
|
+
items: readonly T[],
|
|
4
|
+
concurrency: number,
|
|
5
|
+
task: (item: T) => Promise<unknown>,
|
|
6
|
+
): Promise<void> {
|
|
7
|
+
if (!Number.isInteger(concurrency) || concurrency < 1)
|
|
8
|
+
throw new RangeError("Concurrency must be a positive integer");
|
|
9
|
+
let cursor = 0;
|
|
10
|
+
let failed = false;
|
|
11
|
+
const worker = async () => {
|
|
12
|
+
while (!failed && cursor < items.length) {
|
|
13
|
+
const item = items[cursor++] as T;
|
|
14
|
+
try {
|
|
15
|
+
await task(item);
|
|
16
|
+
} catch (error) {
|
|
17
|
+
failed = true;
|
|
18
|
+
throw error;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
// Wait for active tasks to settle before returning a failure to the build.
|
|
23
|
+
const results = await Promise.allSettled(
|
|
24
|
+
Array.from({ length: Math.min(concurrency, items.length) }, worker),
|
|
25
|
+
);
|
|
26
|
+
const failure = results.find((result) => result.status === "rejected");
|
|
27
|
+
if (failure?.status === "rejected") throw failure.reason;
|
|
28
|
+
}
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { parse as parseAstro } from "@astrojs/compiler";
|
|
3
|
-
import { parseExpression, parse as parseScript } from "@babel/parser";
|
|
4
3
|
import { generate, ident, parse, walk } from "css-tree";
|
|
5
4
|
import MagicString from "magic-string";
|
|
6
5
|
import { parseFragment } from "parse5";
|
|
7
|
-
import type
|
|
6
|
+
import { type Plugin, parseSync } from "vite";
|
|
8
7
|
import { styleNameIn } from "./style-name-lookup.ts";
|
|
9
8
|
import { styleNames } from "./style-names.generated.ts";
|
|
10
9
|
|
|
@@ -162,11 +161,16 @@ function visitSyntax(
|
|
|
162
161
|
|
|
163
162
|
/** Parse source literals; never rewrite generated bundles or consumer modules. */
|
|
164
163
|
export function compactScript(code: string, names: Names = styleNames, expression = false): string {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
const
|
|
169
|
-
|
|
164
|
+
// Reuse Vite's public Oxc parser. Parentheses make Astro attribute values
|
|
165
|
+
// expressions (including object literals) while preserving source offsets.
|
|
166
|
+
const source = expression ? `(${code})` : code;
|
|
167
|
+
const { program, errors } = parseSync("stalux-theme.ts", source, {
|
|
168
|
+
lang: "ts",
|
|
169
|
+
sourceType: "module",
|
|
170
|
+
});
|
|
171
|
+
if (errors.length) throw new SyntaxError(errors.map((error) => error.message).join("\n"));
|
|
172
|
+
const output = new MagicString(source);
|
|
173
|
+
visitSyntax(program, (node) => {
|
|
170
174
|
if (
|
|
171
175
|
node.type === "ImportDeclaration" ||
|
|
172
176
|
node.type === "ImportExpression" ||
|
|
@@ -174,7 +178,7 @@ export function compactScript(code: string, names: Names = styleNames, expressio
|
|
|
174
178
|
)
|
|
175
179
|
return false;
|
|
176
180
|
if (
|
|
177
|
-
node.type === "
|
|
181
|
+
node.type === "Literal" &&
|
|
178
182
|
typeof node.value === "string" &&
|
|
179
183
|
typeof node.start === "number" &&
|
|
180
184
|
typeof node.end === "number"
|
|
@@ -192,41 +196,89 @@ export function compactScript(code: string, names: Names = styleNames, expressio
|
|
|
192
196
|
const segments = node.quasis as Array<{
|
|
193
197
|
start: number;
|
|
194
198
|
end: number;
|
|
199
|
+
tail: boolean;
|
|
195
200
|
value: { raw: string };
|
|
196
201
|
}>;
|
|
197
202
|
const bodyStart = node.start + 1;
|
|
198
203
|
const expressions = node.expressions as Array<{ start: number; end: number }>;
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
204
|
+
if (segments.some((segment) => segment.value.raw.includes("<"))) {
|
|
205
|
+
const masked = new MagicString(source.slice(bodyStart, node.end - 1));
|
|
206
|
+
for (const expr of expressions)
|
|
207
|
+
masked.overwrite(
|
|
208
|
+
expr.start - 2 - bodyStart,
|
|
209
|
+
expr.end + 1 - bodyStart,
|
|
210
|
+
"x".repeat(expr.end - expr.start + 3),
|
|
211
|
+
);
|
|
212
|
+
const html = masked.toString();
|
|
208
213
|
for (const edit of markupEdits(html, names)) {
|
|
209
214
|
const start = bodyStart + edit.start;
|
|
210
215
|
const end = bodyStart + edit.end;
|
|
211
|
-
|
|
216
|
+
let low = 0;
|
|
217
|
+
let high = expressions.length;
|
|
218
|
+
while (low < high) {
|
|
219
|
+
const middle = (low + high) >>> 1;
|
|
220
|
+
if ((expressions[middle]?.end ?? 0) + 1 <= start) low = middle + 1;
|
|
221
|
+
else high = middle;
|
|
222
|
+
}
|
|
223
|
+
const expressionAtEdit = expressions[low];
|
|
224
|
+
if (!expressionAtEdit || end <= expressionAtEdit.start - 2)
|
|
212
225
|
output.overwrite(start, end, edit.text);
|
|
213
226
|
}
|
|
214
227
|
} else
|
|
215
228
|
for (const segment of segments) {
|
|
216
229
|
const next = compactTokens(segment.value.raw, names);
|
|
217
230
|
if (next !== segment.value.raw)
|
|
218
|
-
|
|
231
|
+
// ESTree/Oxc ranges include ` or } at the start and
|
|
232
|
+
// ${ or ` at the end; rewrite only the quasi's text.
|
|
233
|
+
output.overwrite(
|
|
234
|
+
segment.start + 1,
|
|
235
|
+
segment.end - (segment.tail ? 1 : 2),
|
|
236
|
+
next,
|
|
237
|
+
);
|
|
219
238
|
}
|
|
220
239
|
}
|
|
221
240
|
});
|
|
222
|
-
return output.toString();
|
|
241
|
+
return expression ? output.toString().slice(1, -1) : output.toString();
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Map requested UTF-8 byte offsets to UTF-16 without repeatedly decoding prefixes. */
|
|
245
|
+
export function mapUtf8Offsets(code: string, locations: Iterable<number>): Map<number, number> {
|
|
246
|
+
const requested = [...locations].sort((a, b) => a - b);
|
|
247
|
+
const offsets = new Map<number, number>();
|
|
248
|
+
let index = 0;
|
|
249
|
+
let bytes = 0;
|
|
250
|
+
let units = 0;
|
|
251
|
+
for (const char of code) {
|
|
252
|
+
const point = char.codePointAt(0) as number;
|
|
253
|
+
const width = point <= 0x7f ? 1 : point <= 0x7ff ? 2 : point <= 0xffff ? 3 : 4;
|
|
254
|
+
while (index < requested.length && (requested[index] as number) < bytes + width) {
|
|
255
|
+
const offset = requested[index++] as number;
|
|
256
|
+
offsets.set(offset, units + (offset > bytes ? 1 : 0));
|
|
257
|
+
}
|
|
258
|
+
bytes += width;
|
|
259
|
+
units += char.length;
|
|
260
|
+
}
|
|
261
|
+
while (index < requested.length) offsets.set(requested[index++] as number, units);
|
|
262
|
+
return offsets;
|
|
223
263
|
}
|
|
224
264
|
|
|
225
265
|
export async function compactAstro(code: string, names: Names = styleNames): Promise<string> {
|
|
226
266
|
const { ast } = await parseAstro(code);
|
|
227
267
|
const output = new MagicString(code);
|
|
228
|
-
|
|
229
|
-
|
|
268
|
+
// Collect only requested locations: O(source + locations log locations),
|
|
269
|
+
// O(locations) storage instead of decoding every source prefix again.
|
|
270
|
+
const locations = new Set<number>([0]);
|
|
271
|
+
function collectLocations(node: typeof ast | (typeof ast.children)[number]) {
|
|
272
|
+
if (node.position?.start) locations.add(node.position.start.offset);
|
|
273
|
+
if (node.position?.end) locations.add(node.position.end.offset);
|
|
274
|
+
if ("attributes" in node)
|
|
275
|
+
for (const attr of node.attributes)
|
|
276
|
+
if (attr.position?.start) locations.add(attr.position.start.offset);
|
|
277
|
+
if ("children" in node) for (const child of node.children) collectLocations(child);
|
|
278
|
+
}
|
|
279
|
+
collectLocations(ast);
|
|
280
|
+
const offsets = mapUtf8Offsets(code, locations);
|
|
281
|
+
const offsetOf = (offset: number) => offsets.get(offset) ?? 0;
|
|
230
282
|
function visit(node: typeof ast | (typeof ast.children)[number]) {
|
|
231
283
|
if (node.type === "frontmatter") {
|
|
232
284
|
const start = offsetOf(node.position?.start.offset ?? 0) + 3;
|
|
@@ -239,7 +291,8 @@ export async function compactAstro(code: string, names: Names = styleNames): Pro
|
|
|
239
291
|
if (offset === undefined || attr.kind === "empty" || attr.kind === "spread")
|
|
240
292
|
continue;
|
|
241
293
|
const equals = code.indexOf("=", offset + attr.name.length);
|
|
242
|
-
|
|
294
|
+
let start = equals + 1;
|
|
295
|
+
while (start < code.length && /\s/u.test(code[start] as string)) start++;
|
|
243
296
|
if (
|
|
244
297
|
attr.kind === "expression" &&
|
|
245
298
|
(attr.name === "class" ||
|
|
@@ -209,22 +209,31 @@ function findBody(node: HtmlNode): HtmlNode | undefined {
|
|
|
209
209
|
}
|
|
210
210
|
|
|
211
211
|
function isHiddenElement(attrs: Array<{ name: string; value: string }>): boolean {
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
212
|
+
let className = "";
|
|
213
|
+
let style = "";
|
|
214
|
+
for (const attr of attrs) {
|
|
215
|
+
if (attr.name === "hidden" || attr.name === "inert") return true;
|
|
216
|
+
if (attr.name === "class") className = attr.value;
|
|
217
|
+
if (attr.name === "style") style = attr.value;
|
|
218
|
+
}
|
|
219
|
+
const classes = className.split(/\s+/u);
|
|
216
220
|
if (classes.some((name) => VISUALLY_HIDDEN_CLASSES.has(name))) return true;
|
|
217
221
|
|
|
218
|
-
const style = attributes.get("style") ?? "";
|
|
219
222
|
return /(?:^|;)\s*(?:display\s*:\s*none|visibility\s*:\s*hidden|content-visibility\s*:\s*hidden)\s*(?:;|$)/iu.test(
|
|
220
223
|
style,
|
|
221
224
|
);
|
|
222
225
|
}
|
|
223
226
|
|
|
224
227
|
/** Collect renderable body text nodes, including content inside collapsed controls. */
|
|
225
|
-
|
|
228
|
+
const CONTROL_CHARACTER = /\p{C}/u;
|
|
229
|
+
function addGlyphs(text: string, output: Set<string>): void {
|
|
230
|
+
for (const char of text)
|
|
231
|
+
if (!output.has(char) && !CONTROL_CHARACTER.test(char)) output.add(char);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function collectBodyText(node: HtmlNode, output: Set<string>): void {
|
|
226
235
|
if (node.nodeName === "#text" && "value" in node) {
|
|
227
|
-
|
|
236
|
+
addGlyphs(node.value, output);
|
|
228
237
|
return;
|
|
229
238
|
}
|
|
230
239
|
|
|
@@ -240,9 +249,10 @@ function collectBodyText(node: HtmlNode, output: string[]): void {
|
|
|
240
249
|
}
|
|
241
250
|
|
|
242
251
|
function formatUnicodeRange(chars: string[]): string {
|
|
243
|
-
const points = chars.map((char) => char.codePointAt(0) as number).sort((a, b) => a - b);
|
|
244
252
|
const ranges: Array<[number, number]> = [];
|
|
245
|
-
|
|
253
|
+
// preparePageFont already sorts the unique glyphs by code point.
|
|
254
|
+
for (const char of chars) {
|
|
255
|
+
const point = char.codePointAt(0) as number;
|
|
246
256
|
const last = ranges.at(-1);
|
|
247
257
|
if (last && point === last[1] + 1) last[1] = point;
|
|
248
258
|
else ranges.push([point, point]);
|
|
@@ -298,15 +308,14 @@ function preparePageFont(
|
|
|
298
308
|
const body = findBody(document);
|
|
299
309
|
if (!body) return undefined;
|
|
300
310
|
|
|
301
|
-
const
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
collectBodyText(body,
|
|
308
|
-
const
|
|
309
|
-
const chars = [...new Set([...glyphSource].filter((char) => !/\p{C}/u.test(char)))].sort(
|
|
311
|
+
const glyphs = new Set<string>();
|
|
312
|
+
addGlyphs(RESERVED_GLYPHS, glyphs);
|
|
313
|
+
addGlyphs(extraChars, glyphs);
|
|
314
|
+
addGlyphs(linkedStylesheetText, glyphs);
|
|
315
|
+
for (const [, css] of html.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style\s*>/gi))
|
|
316
|
+
addGlyphs(cssGeneratedText(css ?? ""), glyphs);
|
|
317
|
+
collectBodyText(body, glyphs);
|
|
318
|
+
const chars = [...glyphs].sort(
|
|
310
319
|
(a, b) => (a.codePointAt(0) as number) - (b.codePointAt(0) as number),
|
|
311
320
|
);
|
|
312
321
|
if (chars.length === 0) return undefined;
|
|
@@ -24,9 +24,19 @@ export async function createPageOutputCache(
|
|
|
24
24
|
}
|
|
25
25
|
const namespace = path.join(directory, hash.digest("hex"));
|
|
26
26
|
await fs.mkdir(namespace, { recursive: true });
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
const
|
|
27
|
+
const knownAssets = new Set(assets);
|
|
28
|
+
const assetDigests = new Map<string, Promise<string | null>>();
|
|
29
|
+
const assetDigest = (asset: string): Promise<string | null> => {
|
|
30
|
+
if (!knownAssets.has(asset)) return Promise.resolve(null);
|
|
31
|
+
let pending = assetDigests.get(asset);
|
|
32
|
+
if (!pending) {
|
|
33
|
+
pending = fs.readFile(asset).then(digest, () => null);
|
|
34
|
+
assetDigests.set(asset, pending);
|
|
35
|
+
}
|
|
36
|
+
return pending;
|
|
37
|
+
};
|
|
38
|
+
const key = (entry: string, html: string) =>
|
|
39
|
+
createHash("sha256").update(entry).update("\0").update(html).digest("hex");
|
|
30
40
|
return {
|
|
31
41
|
async get(entry: string, html: string): Promise<CachedPageOutput | undefined> {
|
|
32
42
|
try {
|
|
@@ -37,7 +47,7 @@ export async function createPageOutputCache(
|
|
|
37
47
|
return undefined;
|
|
38
48
|
if (!Array.isArray(record.assets)) return undefined;
|
|
39
49
|
for (const [asset, expected] of record.assets) {
|
|
40
|
-
if ((
|
|
50
|
+
if ((await assetDigest(asset)) !== expected) return undefined;
|
|
41
51
|
}
|
|
42
52
|
if (record.font) {
|
|
43
53
|
if (
|
|
@@ -66,10 +76,9 @@ export async function createPageOutputCache(
|
|
|
66
76
|
JSON.stringify({
|
|
67
77
|
...output,
|
|
68
78
|
digest: digest(output.html),
|
|
69
|
-
assets:
|
|
70
|
-
asset,
|
|
71
|
-
|
|
72
|
-
]),
|
|
79
|
+
assets: await Promise.all(
|
|
80
|
+
referencedAssets.map(async (asset) => [asset, await assetDigest(asset)]),
|
|
81
|
+
),
|
|
73
82
|
}),
|
|
74
83
|
);
|
|
75
84
|
await fs.rename(temporary, target);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { getCollection } from "astro:content";
|
|
2
|
-
import {
|
|
2
|
+
import { getPostDescriptions } from "@utils/word-count-utils";
|
|
3
3
|
import type { APIRoute } from "astro";
|
|
4
4
|
|
|
5
5
|
export const prerender = true;
|
|
@@ -13,19 +13,18 @@ export const prerender = true;
|
|
|
13
13
|
export const GET: APIRoute = async ({ logger }) => {
|
|
14
14
|
const posts = await getCollection("posts", ({ data }) => !data.draft);
|
|
15
15
|
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
);
|
|
16
|
+
const descriptions = await getPostDescriptions(logger);
|
|
17
|
+
const payload = posts.map((post) => ({
|
|
18
|
+
title: post.data.title,
|
|
19
|
+
abbrlink: String(post.data.abbrlink),
|
|
20
|
+
date: post.data.date ?? undefined,
|
|
21
|
+
updated: post.data.updated ?? undefined,
|
|
22
|
+
tags: post.data.tags ?? [],
|
|
23
|
+
categories: post.data.categories ?? [],
|
|
24
|
+
desc: post.data.desc ?? "",
|
|
25
|
+
wordCount: descriptions.get(String(post.data.abbrlink))?.wordCount ?? 0,
|
|
26
|
+
url: `/posts/${post.data.abbrlink}/`,
|
|
27
|
+
}));
|
|
29
28
|
|
|
30
29
|
return new Response(JSON.stringify(payload), {
|
|
31
30
|
status: 200,
|
|
@@ -290,7 +290,14 @@ export function analyzeFeatureFlags(
|
|
|
290
290
|
}
|
|
291
291
|
logDetail(logger, "markdown-analysis", "cache miss; analyzing feature flags");
|
|
292
292
|
|
|
293
|
-
|
|
293
|
+
if (import.meta.env.DEV && analysisCache.size >= 128) {
|
|
294
|
+
const oldest = analysisCache.keys().next().value;
|
|
295
|
+
if (oldest !== undefined) analysisCache.delete(oldest);
|
|
296
|
+
}
|
|
297
|
+
const resultPromise = analyzeFeatureFlagsUncached(content).catch((error: unknown) => {
|
|
298
|
+
if (analysisCache.get(content) === resultPromise) analysisCache.delete(content);
|
|
299
|
+
throw error;
|
|
300
|
+
});
|
|
294
301
|
analysisCache.set(content, resultPromise);
|
|
295
302
|
return resultPromise;
|
|
296
303
|
}
|
|
@@ -10,7 +10,7 @@ interface SearchDialog extends HTMLElement {
|
|
|
10
10
|
close?: () => void;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
async function openSearchDialog(): Promise<void> {
|
|
13
|
+
async function openSearchDialog(signal: AbortSignal): Promise<void> {
|
|
14
14
|
logger.debug("loading search UI");
|
|
15
15
|
await Promise.all([
|
|
16
16
|
import("@pagefind/component-ui"),
|
|
@@ -18,6 +18,7 @@ async function openSearchDialog(): Promise<void> {
|
|
|
18
18
|
document.querySelector<HTMLLinkElement>("link[data-stalux-search-style]"),
|
|
19
19
|
),
|
|
20
20
|
]);
|
|
21
|
+
if (signal.aborted) return;
|
|
21
22
|
const dialog = document.querySelector<SearchDialog>("pagefind-modal#search");
|
|
22
23
|
await upgradeAndOpenSearchDialog(
|
|
23
24
|
{
|
|
@@ -25,6 +26,7 @@ async function openSearchDialog(): Promise<void> {
|
|
|
25
26
|
upgrade: (element) => customElements.upgrade(element),
|
|
26
27
|
},
|
|
27
28
|
dialog,
|
|
29
|
+
signal,
|
|
28
30
|
);
|
|
29
31
|
}
|
|
30
32
|
|
|
@@ -105,7 +107,7 @@ registerPageLifecycle("navigation", () => {
|
|
|
105
107
|
event.preventDefault();
|
|
106
108
|
closeNav();
|
|
107
109
|
try {
|
|
108
|
-
await openSearchDialog();
|
|
110
|
+
await openSearchDialog(controller.signal);
|
|
109
111
|
logger.debug("search dialog opened");
|
|
110
112
|
} catch (error) {
|
|
111
113
|
logger.error("search dialog failed to open; next click can retry", error);
|
|
@@ -7,9 +7,10 @@ const logger = createClientLogger("photoswipe");
|
|
|
7
7
|
|
|
8
8
|
const GALLERY_SELECTOR = "a.pswp-gallery-item";
|
|
9
9
|
|
|
10
|
-
async function enrichPhotoSwipeData(container: Element): Promise<void> {
|
|
10
|
+
async function enrichPhotoSwipeData(container: Element, signal: AbortSignal): Promise<void> {
|
|
11
11
|
const links = container.querySelectorAll<HTMLAnchorElement>(GALLERY_SELECTOR);
|
|
12
12
|
for (const link of links) {
|
|
13
|
+
if (signal.aborted) return;
|
|
13
14
|
const image = link.querySelector<HTMLImageElement>("img");
|
|
14
15
|
if (!image) continue;
|
|
15
16
|
|
|
@@ -21,12 +22,18 @@ async function enrichPhotoSwipeData(container: Element): Promise<void> {
|
|
|
21
22
|
|
|
22
23
|
const temporaryImage = new Image();
|
|
23
24
|
temporaryImage.src = image.currentSrc || image.src;
|
|
25
|
+
const cancel = () => temporaryImage.removeAttribute("src");
|
|
26
|
+
signal.addEventListener("abort", cancel, { once: true });
|
|
24
27
|
try {
|
|
25
28
|
await temporaryImage.decode();
|
|
26
29
|
} catch {
|
|
30
|
+
if (signal.aborted) return;
|
|
27
31
|
logger.debug("image dimensions unavailable; skipping enrichment");
|
|
28
32
|
continue;
|
|
33
|
+
} finally {
|
|
34
|
+
signal.removeEventListener("abort", cancel);
|
|
29
35
|
}
|
|
36
|
+
if (signal.aborted) return;
|
|
30
37
|
link.dataset.pswpWidth = String(temporaryImage.naturalWidth);
|
|
31
38
|
link.dataset.pswpHeight = String(temporaryImage.naturalHeight);
|
|
32
39
|
}
|
|
@@ -41,6 +48,7 @@ registerPageLifecycle("photoswipe", () => {
|
|
|
41
48
|
const controller = new AbortController();
|
|
42
49
|
let lightbox: import("photoswipe/lightbox").default | undefined;
|
|
43
50
|
let initialized = false;
|
|
51
|
+
let disposed = false;
|
|
44
52
|
|
|
45
53
|
const initialize = async () => {
|
|
46
54
|
logger.debug("loading lightbox modules");
|
|
@@ -48,8 +56,8 @@ registerPageLifecycle("photoswipe", () => {
|
|
|
48
56
|
import("photoswipe/lightbox"),
|
|
49
57
|
import("photoswipe"),
|
|
50
58
|
]);
|
|
51
|
-
|
|
52
|
-
void enrichPhotoSwipeData(container);
|
|
59
|
+
if (disposed) return;
|
|
60
|
+
void enrichPhotoSwipeData(container, controller.signal);
|
|
53
61
|
lightbox = new PhotoSwipeLightbox({
|
|
54
62
|
gallery: container,
|
|
55
63
|
children: GALLERY_SELECTOR,
|
|
@@ -77,6 +85,7 @@ registerPageLifecycle("photoswipe", () => {
|
|
|
77
85
|
|
|
78
86
|
void ensureInitialized()
|
|
79
87
|
.then(() => {
|
|
88
|
+
if (disposed) return;
|
|
80
89
|
initialized = true;
|
|
81
90
|
logger.debug(`opening image index=${clickedIndex}`);
|
|
82
91
|
lightbox?.loadAndOpen(clickedIndex, undefined, initialPoint);
|
|
@@ -92,6 +101,7 @@ registerPageLifecycle("photoswipe", () => {
|
|
|
92
101
|
});
|
|
93
102
|
|
|
94
103
|
return () => {
|
|
104
|
+
disposed = true;
|
|
95
105
|
controller.abort();
|
|
96
106
|
lightbox?.destroy();
|
|
97
107
|
lightbox = undefined;
|
|
@@ -5,21 +5,23 @@ const logger = createClientLogger("random-posts");
|
|
|
5
5
|
|
|
6
6
|
const sleep = (ms: number, signal: AbortSignal) =>
|
|
7
7
|
new Promise<void>((resolve) => {
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
8
|
+
const finish = () => {
|
|
9
|
+
signal.removeEventListener("abort", aborted);
|
|
10
|
+
resolve();
|
|
11
|
+
};
|
|
12
|
+
const timer = window.setTimeout(finish, ms);
|
|
13
|
+
const aborted = () => {
|
|
14
|
+
window.clearTimeout(timer);
|
|
15
|
+
finish();
|
|
16
|
+
};
|
|
17
|
+
if (signal.aborted) aborted();
|
|
18
|
+
else signal.addEventListener("abort", aborted, { once: true });
|
|
17
19
|
});
|
|
18
20
|
|
|
19
21
|
function shuffle<T extends object>(items: T[]): T[] {
|
|
20
22
|
const result = [...items];
|
|
23
|
+
const bytes = new Uint32Array(1);
|
|
21
24
|
for (let i = result.length - 1; i > 0; i -= 1) {
|
|
22
|
-
const bytes = new Uint32Array(1);
|
|
23
25
|
const j = (crypto.getRandomValues(bytes)[0] ?? 0) % (i + 1);
|
|
24
26
|
const current = result[i];
|
|
25
27
|
const target = result[j];
|
|
@@ -34,9 +34,10 @@ export async function ensureSearchStyles(link: HTMLLinkElement | null): Promise<
|
|
|
34
34
|
export async function upgradeAndOpenSearchDialog<ElementType extends SearchDialogLike>(
|
|
35
35
|
registry: CustomElementRegistryLike<ElementType>,
|
|
36
36
|
dialog: ElementType | null,
|
|
37
|
+
signal?: AbortSignal,
|
|
37
38
|
): Promise<void> {
|
|
38
39
|
await registry.whenDefined("pagefind-modal");
|
|
39
|
-
if (!dialog) return;
|
|
40
|
+
if (!dialog || signal?.aborted) return;
|
|
40
41
|
registry.upgrade(dialog);
|
|
41
42
|
dialog.open?.();
|
|
42
43
|
}
|
package/src/scripts/webmcp.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createPostLookup } from "../utils/post-lookup";
|
|
1
2
|
import { createClientLogger } from "./logger";
|
|
2
3
|
|
|
3
4
|
/**
|
|
@@ -136,6 +137,16 @@ interface PostMeta {
|
|
|
136
137
|
}
|
|
137
138
|
|
|
138
139
|
let postsMetaPromise: Promise<PostMeta[] | null> | null = null;
|
|
140
|
+
const postLookups = new WeakMap<PostMeta[], ReturnType<typeof createPostLookup<PostMeta>>>();
|
|
141
|
+
|
|
142
|
+
function lookupFor(list: PostMeta[]) {
|
|
143
|
+
let lookup = postLookups.get(list);
|
|
144
|
+
if (!lookup) {
|
|
145
|
+
lookup = createPostLookup(list);
|
|
146
|
+
postLookups.set(list, lookup);
|
|
147
|
+
}
|
|
148
|
+
return lookup;
|
|
149
|
+
}
|
|
139
150
|
|
|
140
151
|
/** 加载文章元信息索引(惰性 + 缓存) */
|
|
141
152
|
function loadPostsMeta(): Promise<PostMeta[] | null> {
|
|
@@ -149,16 +160,12 @@ function loadPostsMeta(): Promise<PostMeta[] | null> {
|
|
|
149
160
|
|
|
150
161
|
/** 按 abbrlink 精确查找 */
|
|
151
162
|
function findById(list: PostMeta[], id: string): PostMeta | undefined {
|
|
152
|
-
return list.
|
|
163
|
+
return lookupFor(list).findById(id);
|
|
153
164
|
}
|
|
154
165
|
|
|
155
166
|
/** 按标题/标签关键词模糊查找(取第一条) */
|
|
156
167
|
function findByKeyword(list: PostMeta[], keyword: string): PostMeta | undefined {
|
|
157
|
-
|
|
158
|
-
return (
|
|
159
|
-
list.find((p) => p.title.toLowerCase().includes(kw)) ??
|
|
160
|
-
list.find((p) => (p.tags ?? []).some((t) => t.toLowerCase().includes(kw)))
|
|
161
|
-
);
|
|
168
|
+
return lookupFor(list).findByKeyword(keyword);
|
|
162
169
|
}
|
|
163
170
|
|
|
164
171
|
/** 简化为输出用的元信息视图 */
|
package/src/scripts/word-list.ts
CHANGED
|
@@ -14,10 +14,16 @@ function scrollToWord(hash: string): void {
|
|
|
14
14
|
registerPageLifecycle("word-list", () => {
|
|
15
15
|
if (!document.querySelector("[data-word-hash]")) return;
|
|
16
16
|
const controller = new AbortController();
|
|
17
|
+
let firstFrame = 0;
|
|
18
|
+
let secondFrame = 0;
|
|
17
19
|
|
|
18
20
|
if (location.hash.startsWith("#word-")) {
|
|
19
|
-
requestAnimationFrame(() => {
|
|
20
|
-
|
|
21
|
+
firstFrame = requestAnimationFrame(() => {
|
|
22
|
+
firstFrame = 0;
|
|
23
|
+
secondFrame = requestAnimationFrame(() => {
|
|
24
|
+
secondFrame = 0;
|
|
25
|
+
scrollToWord(location.hash);
|
|
26
|
+
});
|
|
21
27
|
});
|
|
22
28
|
}
|
|
23
29
|
|
|
@@ -35,5 +41,9 @@ registerPageLifecycle("word-list", () => {
|
|
|
35
41
|
{ signal: controller.signal },
|
|
36
42
|
);
|
|
37
43
|
|
|
38
|
-
return () =>
|
|
44
|
+
return () => {
|
|
45
|
+
controller.abort();
|
|
46
|
+
if (firstFrame) cancelAnimationFrame(firstFrame);
|
|
47
|
+
if (secondFrame) cancelAnimationFrame(secondFrame);
|
|
48
|
+
};
|
|
39
49
|
});
|
|
@@ -1,26 +1,15 @@
|
|
|
1
1
|
import type { AstroRuntimeLogger } from "astro";
|
|
2
2
|
import { getPostContentIndex, type PostEntry } from "./content-index";
|
|
3
3
|
|
|
4
|
-
/**
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
* @param keyFn 从单篇文章取出若干分类键(如标签、分类)
|
|
8
|
-
* @param defaultValue 文章没有任何分类键时的兜底分类名(可选)
|
|
9
|
-
*/
|
|
10
|
-
function countBy(
|
|
11
|
-
posts: PostEntry[],
|
|
12
|
-
keyFn: (post: PostEntry) => string[] | undefined,
|
|
13
|
-
defaultValue?: string,
|
|
14
|
-
): Array<{ name: string; count: number }> {
|
|
15
|
-
const counts = new Map<string, number>();
|
|
4
|
+
/** Count the existing uncategorized fallback without building a second taxonomy map. */
|
|
5
|
+
function countUncategorized(posts: PostEntry[]): number {
|
|
6
|
+
let count = 0;
|
|
16
7
|
for (const post of posts) {
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
for (const name of names)
|
|
20
|
-
counts.set(name, (counts.get(name) ?? 0) + 1);
|
|
21
|
-
}
|
|
8
|
+
const names = post.data.categories ?? [];
|
|
9
|
+
if (!names.length) count++;
|
|
10
|
+
else for (const name of names) if (name === "uncategorized") count++;
|
|
22
11
|
}
|
|
23
|
-
return
|
|
12
|
+
return count;
|
|
24
13
|
}
|
|
25
14
|
|
|
26
15
|
/**
|
|
@@ -42,9 +31,7 @@ export async function getCategoryCountList(logger?: AstroRuntimeLogger) {
|
|
|
42
31
|
name,
|
|
43
32
|
count: categoryPosts.length,
|
|
44
33
|
}));
|
|
45
|
-
const uncategorized =
|
|
46
|
-
|
|
47
|
-
);
|
|
48
|
-
if (uncategorized) counts.push(uncategorized);
|
|
34
|
+
const uncategorized = countUncategorized(posts);
|
|
35
|
+
if (uncategorized) counts.push({ name: "uncategorized", count: uncategorized });
|
|
49
36
|
return counts;
|
|
50
37
|
}
|
|
@@ -47,8 +47,8 @@ function addToTaxonomy<T extends IndexablePost>(
|
|
|
47
47
|
map: Map<string, MutableTaxonomyBucket<T>>,
|
|
48
48
|
post: T,
|
|
49
49
|
names: string[] | undefined,
|
|
50
|
+
timestamp: number,
|
|
50
51
|
): void {
|
|
51
|
-
const timestamp = toTimestamp(post.data.updated ?? post.data.date);
|
|
52
52
|
for (const name of new Set(names ?? [])) {
|
|
53
53
|
const existing = map.get(name);
|
|
54
54
|
if (existing) {
|
|
@@ -94,8 +94,16 @@ function finalizeTaxonomy<T extends IndexablePost>(
|
|
|
94
94
|
export function buildPostContentIndex<T extends IndexablePost>(
|
|
95
95
|
sourcePosts: readonly T[],
|
|
96
96
|
): PostContentIndex<T> {
|
|
97
|
+
const times = new Map<T, { date: number; updated: number }>();
|
|
98
|
+
for (const post of sourcePosts) {
|
|
99
|
+
const date = toTimestamp(post.data.date);
|
|
100
|
+
times.set(post, {
|
|
101
|
+
date,
|
|
102
|
+
updated: post.data.updated == null ? date : toTimestamp(post.data.updated),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
97
105
|
const posts = sourcePosts.toSorted(
|
|
98
|
-
(left, right) =>
|
|
106
|
+
(left, right) => (times.get(right)?.date ?? 0) - (times.get(left)?.date ?? 0),
|
|
99
107
|
);
|
|
100
108
|
const byAbbrlink = new Map<string, T>();
|
|
101
109
|
const tags = new Map<string, MutableTaxonomyBucket<T>>();
|
|
@@ -108,8 +116,9 @@ export function buildPostContentIndex<T extends IndexablePost>(
|
|
|
108
116
|
throw new Error(`Duplicate post abbrlink: ${abbrlink}`);
|
|
109
117
|
}
|
|
110
118
|
byAbbrlink.set(abbrlink, post);
|
|
111
|
-
|
|
112
|
-
addToTaxonomy(
|
|
119
|
+
const timestamp = times.get(post)?.updated ?? 0;
|
|
120
|
+
addToTaxonomy(tags, post, post.data.tags, timestamp);
|
|
121
|
+
addToTaxonomy(categories, post, post.data.categories, timestamp);
|
|
113
122
|
neighbors.set(abbrlink, {
|
|
114
123
|
previous: posts[index + 1],
|
|
115
124
|
next: index > 0 ? posts[index - 1] : undefined,
|
package/src/utils/dayjs.ts
CHANGED
|
@@ -18,6 +18,31 @@ export const toTimestamp = (v: unknown): number => parseDate(v).getTime();
|
|
|
18
18
|
|
|
19
19
|
const pad2 = (n: number): string => String(n).padStart(2, "0");
|
|
20
20
|
|
|
21
|
+
// Bounded across user-supplied zones; invalid zones still throw before caching.
|
|
22
|
+
const timezoneFormatters = new Map<string, Intl.DateTimeFormat>();
|
|
23
|
+
function getTimezoneFormatter(timeZone: string): Intl.DateTimeFormat {
|
|
24
|
+
let formatter = timezoneFormatters.get(timeZone);
|
|
25
|
+
if (!formatter) {
|
|
26
|
+
formatter = new Intl.DateTimeFormat("en-US", {
|
|
27
|
+
timeZone,
|
|
28
|
+
year: "numeric",
|
|
29
|
+
month: "2-digit",
|
|
30
|
+
day: "2-digit",
|
|
31
|
+
hour: "2-digit",
|
|
32
|
+
minute: "2-digit",
|
|
33
|
+
second: "2-digit",
|
|
34
|
+
hourCycle: "h23",
|
|
35
|
+
timeZoneName: "longOffset",
|
|
36
|
+
});
|
|
37
|
+
if (timezoneFormatters.size >= 16) {
|
|
38
|
+
const oldest = timezoneFormatters.keys().next().value;
|
|
39
|
+
if (oldest !== undefined) timezoneFormatters.delete(oldest);
|
|
40
|
+
}
|
|
41
|
+
timezoneFormatters.set(timeZone, formatter);
|
|
42
|
+
}
|
|
43
|
+
return formatter;
|
|
44
|
+
}
|
|
45
|
+
|
|
21
46
|
/**
|
|
22
47
|
* 轻量 format:仅覆盖本项目实际用到的 token。
|
|
23
48
|
* 支持:yyyy MM dd HH mm ss
|
|
@@ -51,17 +76,7 @@ export function formatInTimeZone(
|
|
|
51
76
|
const d = date instanceof Date ? date : new Date(date);
|
|
52
77
|
if (Number.isNaN(d.getTime())) return "";
|
|
53
78
|
|
|
54
|
-
const parts =
|
|
55
|
-
timeZone,
|
|
56
|
-
year: "numeric",
|
|
57
|
-
month: "2-digit",
|
|
58
|
-
day: "2-digit",
|
|
59
|
-
hour: "2-digit",
|
|
60
|
-
minute: "2-digit",
|
|
61
|
-
second: "2-digit",
|
|
62
|
-
hourCycle: "h23",
|
|
63
|
-
timeZoneName: "longOffset",
|
|
64
|
-
}).formatToParts(d);
|
|
79
|
+
const parts = getTimezoneFormatter(timeZone).formatToParts(d);
|
|
65
80
|
|
|
66
81
|
const get = (type: Intl.DateTimeFormatPartTypes): string =>
|
|
67
82
|
parts.find((p) => p.type === type)?.value ?? "";
|
package/src/utils/feed.ts
CHANGED
|
@@ -32,43 +32,44 @@ export async function buildFeedItems(
|
|
|
32
32
|
updatedTag: string,
|
|
33
33
|
logger?: AstroRuntimeLogger,
|
|
34
34
|
): Promise<FeedItem[]> {
|
|
35
|
+
const timestamps = new Map(
|
|
36
|
+
posts.map((post) => [post, toTimestamp(post.data.updated || post.data.date)]),
|
|
37
|
+
);
|
|
35
38
|
const sortedPosts = posts.toSorted((a, b) => {
|
|
36
39
|
// Newest first: compare post dates, use the greater of updated/date
|
|
37
|
-
const aTime =
|
|
38
|
-
const bTime =
|
|
40
|
+
const aTime = timestamps.get(a) ?? 0;
|
|
41
|
+
const bTime = timestamps.get(b) ?? 0;
|
|
39
42
|
return bTime - aTime;
|
|
40
43
|
});
|
|
41
44
|
|
|
42
45
|
const descriptions = await getPostDescriptions(logger);
|
|
43
46
|
logDetail(logger, "feed", `rendering ${sortedPosts.length} entries; format=${updatedTag}`);
|
|
44
|
-
return
|
|
45
|
-
|
|
46
|
-
const cached = descriptions.get(String(post.data.abbrlink));
|
|
47
|
+
return sortedPosts.map((post) => {
|
|
48
|
+
const cached = descriptions.get(String(post.data.abbrlink));
|
|
47
49
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
if (post.data.cc) {
|
|
61
|
-
customData += `<rights>${post.data.cc}</rights>`;
|
|
50
|
+
let customData = "";
|
|
51
|
+
if (post.data.updated) {
|
|
52
|
+
const parsed = parseDate(post.data.updated);
|
|
53
|
+
if (isValid(parsed)) {
|
|
54
|
+
const updatedIso = formatInTimeZone(
|
|
55
|
+
parsed,
|
|
56
|
+
stalux.timezone,
|
|
57
|
+
"yyyy-MM-dd'T'HH:mm:ssXXX",
|
|
58
|
+
);
|
|
59
|
+
customData += `<${updatedTag}>${updatedIso}</${updatedTag}>`;
|
|
62
60
|
}
|
|
61
|
+
}
|
|
62
|
+
if (post.data.cc) {
|
|
63
|
+
customData += `<rights>${post.data.cc}</rights>`;
|
|
64
|
+
}
|
|
63
65
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
);
|
|
66
|
+
return {
|
|
67
|
+
title: post.data.title,
|
|
68
|
+
pubDate: new Date(toMachineDateTime(post.data.date, stalux.timezone)),
|
|
69
|
+
description: cached?.desc || post.data.desc || "",
|
|
70
|
+
link: `/posts/${post.data.abbrlink}/`,
|
|
71
|
+
categories: post.data.categories || [],
|
|
72
|
+
customData,
|
|
73
|
+
};
|
|
74
|
+
});
|
|
74
75
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
interface SearchablePost {
|
|
2
|
+
abbrlink: string;
|
|
3
|
+
title: string;
|
|
4
|
+
tags?: string[];
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/** One immutable snapshot; duplicate IDs and fuzzy matches keep first-match order. */
|
|
8
|
+
export function createPostLookup<T extends SearchablePost>(posts: readonly T[]) {
|
|
9
|
+
const byId = new Map<string, T>();
|
|
10
|
+
const searchable = posts.map((post) => {
|
|
11
|
+
if (!byId.has(post.abbrlink)) byId.set(post.abbrlink, post);
|
|
12
|
+
return {
|
|
13
|
+
post,
|
|
14
|
+
title: post.title.toLowerCase(),
|
|
15
|
+
tags: post.tags?.map((tag) => tag.toLowerCase()) ?? [],
|
|
16
|
+
};
|
|
17
|
+
});
|
|
18
|
+
return {
|
|
19
|
+
findById: (id: string): T | undefined => byId.get(id),
|
|
20
|
+
findByKeyword(keyword: string): T | undefined {
|
|
21
|
+
const query = keyword.toLowerCase();
|
|
22
|
+
return (
|
|
23
|
+
searchable.find(({ title }) => title.includes(query)) ??
|
|
24
|
+
searchable.find(({ tags }) => tags.some((tag) => tag.includes(query)))
|
|
25
|
+
)?.post;
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -25,6 +25,8 @@ export function initTocScrollSpy(activeClass: string): (() => void) | undefined
|
|
|
25
25
|
let pending = false;
|
|
26
26
|
let frameId = 0;
|
|
27
27
|
let sidebarHovered = false;
|
|
28
|
+
const headingOrder = new Map(headings.map((heading, index) => [heading, index]));
|
|
29
|
+
const visibleHeadings = new Set<HTMLElement>();
|
|
28
30
|
const controller = new AbortController();
|
|
29
31
|
|
|
30
32
|
// 鼠标悬停在侧边栏上时跳过自动滚动,避免跟用户手动操作冲突
|
|
@@ -61,7 +63,12 @@ export function initTocScrollSpy(activeClass: string): (() => void) | undefined
|
|
|
61
63
|
};
|
|
62
64
|
|
|
63
65
|
const observer = new IntersectionObserver(
|
|
64
|
-
() => {
|
|
66
|
+
(entries) => {
|
|
67
|
+
for (const entry of entries) {
|
|
68
|
+
const heading = entry.target as HTMLElement;
|
|
69
|
+
if (entry.isIntersecting) visibleHeadings.add(heading);
|
|
70
|
+
else visibleHeadings.delete(heading);
|
|
71
|
+
}
|
|
65
72
|
// 用 rAF 合并同一帧内的多次回调,避免频繁触发布局计算
|
|
66
73
|
if (pending) return;
|
|
67
74
|
pending = true;
|
|
@@ -70,11 +77,17 @@ export function initTocScrollSpy(activeClass: string): (() => void) | undefined
|
|
|
70
77
|
pending = false;
|
|
71
78
|
|
|
72
79
|
// 找到视口上方 35% 区域内最靠上的标题
|
|
73
|
-
let current =
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
80
|
+
let current: HTMLElement | null = null;
|
|
81
|
+
let currentOrder = headings.length;
|
|
82
|
+
for (const heading of visibleHeadings) {
|
|
83
|
+
const order = headingOrder.get(heading) ?? headings.length;
|
|
84
|
+
if (order >= currentOrder) continue;
|
|
85
|
+
const rect = heading.getBoundingClientRect();
|
|
86
|
+
if (rect.top < window.innerHeight * 0.35 && rect.bottom > 0) {
|
|
87
|
+
current = heading;
|
|
88
|
+
currentOrder = order;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
78
91
|
|
|
79
92
|
// 兜底:滚动到文章底部时,取最后一个仍在视口上方或已经越过顶部的标题
|
|
80
93
|
current ??=
|
|
@@ -111,6 +124,7 @@ export function initTocScrollSpy(activeClass: string): (() => void) | undefined
|
|
|
111
124
|
return () => {
|
|
112
125
|
controller.abort();
|
|
113
126
|
observer.disconnect();
|
|
127
|
+
visibleHeadings.clear();
|
|
114
128
|
if (frameId) cancelAnimationFrame(frameId);
|
|
115
129
|
activeLink?.classList.remove(activeClass);
|
|
116
130
|
};
|
|
@@ -7,65 +7,69 @@
|
|
|
7
7
|
import { getCollection } from "astro:content";
|
|
8
8
|
import { analyzeFeatureFlags } from "@plugins/feature-flags";
|
|
9
9
|
import type { AstroRuntimeLogger } from "astro";
|
|
10
|
+
import { forEachBounded } from "../internal/bounded-tasks";
|
|
10
11
|
import { logDetail, logFailure } from "./diagnostics";
|
|
11
12
|
|
|
12
13
|
// ---------------------------------------------------------------------------
|
|
13
14
|
// 模块级缓存(整个构建周期只计算一次)
|
|
14
15
|
// ---------------------------------------------------------------------------
|
|
15
16
|
|
|
16
|
-
|
|
17
|
+
interface PostStatistics {
|
|
18
|
+
total: number;
|
|
19
|
+
descriptions: Map<string, { desc: string; wordCount: number }>;
|
|
20
|
+
}
|
|
17
21
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
22
|
+
let statisticsPromise: Promise<PostStatistics> | undefined;
|
|
23
|
+
|
|
24
|
+
function loadStatistics(logger?: AstroRuntimeLogger): Promise<PostStatistics> {
|
|
25
|
+
if (!statisticsPromise) {
|
|
26
|
+
statisticsPromise = (async () => {
|
|
27
|
+
const posts = await getCollection("posts", ({ data }) => !data.draft);
|
|
28
|
+
const results = new Array<{ id: string; desc: string; wordCount: number }>(
|
|
29
|
+
posts.length,
|
|
30
|
+
);
|
|
31
|
+
await forEachBounded(
|
|
32
|
+
posts.map((post, index) => ({ post, index })),
|
|
33
|
+
4,
|
|
34
|
+
async ({ post, index }) => {
|
|
35
|
+
results[index] = {
|
|
36
|
+
id: String(post.data.abbrlink ?? post.id),
|
|
37
|
+
desc: post.data.desc || "",
|
|
38
|
+
wordCount: (await analyzeFeatureFlags(post.body, logger)).wordCount,
|
|
39
|
+
};
|
|
40
|
+
},
|
|
41
|
+
);
|
|
42
|
+
const descriptions = new Map<string, { desc: string; wordCount: number }>();
|
|
43
|
+
let total = 0;
|
|
44
|
+
for (const result of results) {
|
|
45
|
+
total += result.wordCount;
|
|
46
|
+
descriptions.set(result.id, { desc: result.desc, wordCount: result.wordCount });
|
|
47
|
+
}
|
|
48
|
+
logDetail(logger, "word-count", `analyzed ${posts.length} posts`);
|
|
49
|
+
return { total, descriptions };
|
|
50
|
+
})().catch((error: unknown) => {
|
|
51
|
+
statisticsPromise = undefined;
|
|
52
|
+
throw error;
|
|
53
|
+
});
|
|
26
54
|
}
|
|
27
|
-
|
|
55
|
+
return statisticsPromise;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Shared in-flight statistics prevent concurrent pages from repeating collection work. */
|
|
59
|
+
export async function getTotalWordCount(logger?: AstroRuntimeLogger): Promise<number> {
|
|
28
60
|
try {
|
|
29
|
-
|
|
30
|
-
const results = await Promise.all(
|
|
31
|
-
posts.map((post) => analyzeFeatureFlags(post.body, logger)),
|
|
32
|
-
);
|
|
33
|
-
_cachedTotal = results.reduce((sum, result) => sum + result.wordCount, 0);
|
|
34
|
-
logDetail(logger, "word-count", `analyzed ${posts.length} posts`);
|
|
35
|
-
return _cachedTotal;
|
|
61
|
+
return (await loadStatistics(logger)).total;
|
|
36
62
|
} catch (error) {
|
|
37
63
|
logFailure(logger, "word-count", "total calculation failed; returning zero", error);
|
|
38
64
|
return 0;
|
|
39
65
|
}
|
|
40
66
|
}
|
|
41
67
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* 获取所有已发布文章的描述和字数缓存,供 RSS/Atom/LLMs feed 共享。
|
|
46
|
-
* - desc :直接从 frontmatter 读取(post.data.desc)
|
|
47
|
-
* - wordCount:从 Sätteri AST 分析 post.body
|
|
48
|
-
*/
|
|
68
|
+
/** Keep collection insertion order and frontmatter descriptions unchanged. */
|
|
49
69
|
export async function getPostDescriptions(
|
|
50
70
|
logger?: AstroRuntimeLogger,
|
|
51
71
|
): Promise<Map<string, { desc: string; wordCount: number }>> {
|
|
52
|
-
|
|
53
|
-
logDetail(logger, "word-count", "description cache hit");
|
|
54
|
-
return _cachedDescriptions;
|
|
55
|
-
}
|
|
56
|
-
logDetail(logger, "word-count", "description cache miss");
|
|
57
|
-
const posts = await getCollection("posts", ({ data }) => !data.draft);
|
|
58
|
-
const results = await Promise.all(
|
|
59
|
-
posts.map(async (post) => ({
|
|
60
|
-
id: String(post.data.abbrlink ?? post.id),
|
|
61
|
-
desc: post.data.desc || "",
|
|
62
|
-
wordCount: (await analyzeFeatureFlags(post.body, logger)).wordCount,
|
|
63
|
-
})),
|
|
64
|
-
);
|
|
65
|
-
_cachedDescriptions = new Map(
|
|
66
|
-
results.map((r) => [r.id, { desc: r.desc, wordCount: r.wordCount }]),
|
|
67
|
-
);
|
|
68
|
-
return _cachedDescriptions;
|
|
72
|
+
return (await loadStatistics(logger)).descriptions;
|
|
69
73
|
}
|
|
70
74
|
|
|
71
75
|
export function formatWordCount(count: number, lang?: string): string {
|