@xingwangzhe/stalux 1.35.11 → 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.11",
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",
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
+ }
@@ -201,19 +201,27 @@ export function compactScript(code: string, names: Names = styleNames, expressio
201
201
  }>;
202
202
  const bodyStart = node.start + 1;
203
203
  const expressions = node.expressions as Array<{ start: number; end: number }>;
204
- const masked = new MagicString(source.slice(bodyStart, node.end - 1));
205
- for (const expr of expressions)
206
- masked.overwrite(
207
- expr.start - 2 - bodyStart,
208
- expr.end + 1 - bodyStart,
209
- "x".repeat(expr.end - expr.start + 3),
210
- );
211
- const html = masked.toString();
212
- 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();
213
213
  for (const edit of markupEdits(html, names)) {
214
214
  const start = bodyStart + edit.start;
215
215
  const end = bodyStart + edit.end;
216
- 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)
217
225
  output.overwrite(start, end, edit.text);
218
226
  }
219
227
  } else
@@ -233,11 +241,44 @@ export function compactScript(code: string, names: Names = styleNames, expressio
233
241
  return expression ? output.toString().slice(1, -1) : output.toString();
234
242
  }
235
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;
263
+ }
264
+
236
265
  export async function compactAstro(code: string, names: Names = styleNames): Promise<string> {
237
266
  const { ast } = await parseAstro(code);
238
267
  const output = new MagicString(code);
239
- const sourceBytes = Buffer.from(code);
240
- 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;
241
282
  function visit(node: typeof ast | (typeof ast.children)[number]) {
242
283
  if (node.type === "frontmatter") {
243
284
  const start = offsetOf(node.position?.start.offset ?? 0) + 3;
@@ -250,7 +291,8 @@ export async function compactAstro(code: string, names: Names = styleNames): Pro
250
291
  if (offset === undefined || attr.kind === "empty" || attr.kind === "spread")
251
292
  continue;
252
293
  const equals = code.indexOf("=", offset + attr.name.length);
253
- 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++;
254
296
  if (
255
297
  attr.kind === "expression" &&
256
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 {