@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xingwangzhe/stalux",
3
- "version": "1.35.10",
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
- for (let offset = 0; offset < pageEntries.length; offset += 4) {
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 { Plugin } from "vite";
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
- const ast = expression
166
- ? parseExpression(code, { plugins: ["typescript"] })
167
- : parseScript(code, { sourceType: "module", plugins: ["typescript"] });
168
- const output = new MagicString(code);
169
- visitSyntax(ast, (node) => {
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 === "StringLiteral" &&
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
- const masked = new MagicString(code.slice(bodyStart, node.end - 1));
200
- for (const expr of expressions)
201
- masked.overwrite(
202
- expr.start - 2 - bodyStart,
203
- expr.end + 1 - bodyStart,
204
- "x".repeat(expr.end - expr.start + 3),
205
- );
206
- const html = masked.toString();
207
- if (html.includes("<")) {
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
- if (!expressions.some((expr) => start < expr.end + 1 && end > expr.start - 2))
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
- output.overwrite(segment.start, segment.end, next);
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
- const sourceBytes = Buffer.from(code);
229
- const offsetOf = (offset: number) => sourceBytes.subarray(0, offset).toString("utf8").length;
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
- const start = equals + 1 + (code.slice(equals + 1).match(/^\s*/u)?.[0].length ?? 0);
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
- const attributes = new Map(attrs.map(({ name, value }) => [name, value]));
213
- if (attributes.has("hidden") || attributes.has("inert")) return true;
214
-
215
- const classes = (attributes.get("class") ?? "").split(/\s+/u);
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
- function collectBodyText(node: HtmlNode, output: string[]): void {
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
- output.push(node.value);
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
- for (const point of points) {
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 bodyText: string[] = [
302
- ...[...html.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style\s*>/gi)].map(([, css]) =>
303
- cssGeneratedText(css ?? ""),
304
- ),
305
- linkedStylesheetText,
306
- ];
307
- collectBodyText(body, bodyText);
308
- const glyphSource = `${RESERVED_GLYPHS}${extraChars}${bodyText.join("")}`;
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 assetDigests = new Map<string, string>();
28
- for (const asset of assets) assetDigests.set(asset, digest(await fs.readFile(asset)));
29
- const key = (entry: string, html: string) => digest(`${entry}\0${html}`);
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 ((assetDigests.get(asset) ?? null) !== expected) return undefined;
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: referencedAssets.map((asset) => [
70
- asset,
71
- assetDigests.get(asset) ?? null,
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 { analyzeFeatureFlags } from "@plugins/feature-flags";
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 payload = await Promise.all(
17
- posts.map(async (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: (await analyzeFeatureFlags(post.body, logger)).wordCount,
26
- url: `/posts/${post.data.abbrlink}/`,
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
- const resultPromise = analyzeFeatureFlagsUncached(content);
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 timer = window.setTimeout(resolve, ms);
9
- signal.addEventListener(
10
- "abort",
11
- () => {
12
- window.clearTimeout(timer);
13
- resolve();
14
- },
15
- { once: true },
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
  }
@@ -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.find((p) => p.abbrlink === id);
163
+ return lookupFor(list).findById(id);
153
164
  }
154
165
 
155
166
  /** 按标题/标签关键词模糊查找(取第一条) */
156
167
  function findByKeyword(list: PostMeta[], keyword: string): PostMeta | undefined {
157
- const kw = keyword.toLowerCase();
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
  /** 简化为输出用的元信息视图 */
@@ -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
- requestAnimationFrame(() => scrollToWord(location.hash));
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 () => controller.abort();
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
- * 统计 posts 中每个分类键出现的次数。
6
- * @param posts 文章集合
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 keys = keyFn(post) ?? [];
18
- const names = keys.length > 0 ? keys : defaultValue ? [defaultValue] : [];
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 [...counts].map(([name, count]) => ({ name, count }));
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 = countBy(posts, (post) => post.data.categories, "uncategorized").find(
46
- ({ name }) => name === "uncategorized",
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) => toTimestamp(right.data.date) - toTimestamp(left.data.date),
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
- addToTaxonomy(tags, post, post.data.tags);
112
- addToTaxonomy(categories, post, post.data.categories);
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,
@@ -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 = new Intl.DateTimeFormat("en-US", {
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 = toTimestamp(a.data.updated || a.data.date);
38
- const bTime = toTimestamp(b.data.updated || b.data.date);
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 Promise.all(
45
- sortedPosts.map(async (post) => {
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
- let customData = "";
49
- if (post.data.updated) {
50
- const parsed = parseDate(post.data.updated);
51
- if (isValid(parsed)) {
52
- const updatedIso = formatInTimeZone(
53
- parsed,
54
- stalux.timezone,
55
- "yyyy-MM-dd'T'HH:mm:ssXXX",
56
- );
57
- customData += `<${updatedTag}>${updatedIso}</${updatedTag}>`;
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
- return {
65
- title: post.data.title,
66
- pubDate: new Date(toMachineDateTime(post.data.date, stalux.timezone)),
67
- description: cached?.desc || post.data.desc || "",
68
- link: `/posts/${post.data.abbrlink}/`,
69
- categories: post.data.categories || [],
70
- customData,
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
- headings.find((h) => {
75
- const rect = h.getBoundingClientRect();
76
- return rect.top < window.innerHeight * 0.35 && rect.bottom > 0;
77
- }) ?? null;
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
- let _cachedTotal: number | null = null;
17
+ interface PostStatistics {
18
+ total: number;
19
+ descriptions: Map<string, { desc: string; wordCount: number }>;
20
+ }
17
21
 
18
- /**
19
- * 获取所有已发布文章的总字数(从 body 估算)。
20
- * 结果会被缓存,同一构建周期内重复调用不会重新计算。
21
- */
22
- export async function getTotalWordCount(logger?: AstroRuntimeLogger): Promise<number> {
23
- if (_cachedTotal !== null) {
24
- logDetail(logger, "word-count", "total cache hit");
25
- return _cachedTotal;
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
- logDetail(logger, "word-count", "total cache miss");
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
- const posts = await getCollection("posts", ({ data }) => !data.draft);
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
- let _cachedDescriptions: Map<string, { desc: string; wordCount: number }> | null = null;
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
- if (_cachedDescriptions !== null) {
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 {