@xingwangzhe/stalux 1.35.2 → 1.35.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -116,6 +116,10 @@ The subset CSS uses an exact per-character `unicode-range` and `font-display: sw
116
116
 
117
117
  The persistent BLAKE3 cache key includes font bytes, face index, normalized character set, and algorithm version. Cache files live under `node_modules/.astro/` and are reused across pages and incremental builds. Font inputs are read locally; builds do not fetch fonts from the network.
118
118
 
119
+ Multi-page builds reuse prepared font data and hashing state through `FontSubsetter`, with up to four native worker tasks for cold subsets. Font containers are decoded/extracted to SFNT before reusable HarfBuzz preprocessing preserves original outlines; Google Brotli encodes WOFF2 at quality 8. Image dimensions, image loading hints, linked CSS text, and font character extraction share one HTML parse. Shared image dimensions and CSS-generated text are also reused within each build, and image-tag edits are assembled in a single pass.
120
+
121
+ Processed pages are cached under `node_modules/.astro/stalux-page-output/`. The cache checks source HTML and page paths, referenced local CSS/image content, font inputs, and processing code. Changed inputs, missing font files, and corrupt records trigger reprocessing. Unchanged pages restore their processed output; Pagefind still rebuilds the full search index on every build. Preserve `node_modules/.astro/` to reuse caches across builds; removing it safely rebuilds them.
122
+
119
123
  ### Visibility-aware loading and updates
120
124
 
121
125
  The desktop tag cloud loads dynamically near the viewport. Switching to mobile, leaving the viewport or hiding the document destroys it and stops animation scheduling; becoming visible recreates it. Tag clicks use Astro client navigation. Clocks and the footer runtime update only while visible, and the date changes only on a new day. Comments load within half a viewport (capped at 400px). Tag groups, archive months and category cards retain static HTML while deferring offscreen rendering.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xingwangzhe/stalux",
3
- "version": "1.35.2",
3
+ "version": "1.35.3",
4
4
  "description": "A powerful, modern Astro blog theme — use as template or install as plugin",
5
5
  "keywords": [
6
6
  "astro",
@@ -106,7 +106,7 @@
106
106
  "@mcp-b/webmcp-polyfill": "^5.1.0",
107
107
  "@pagefind/component-ui": "^1.5.2",
108
108
  "@waline/client": "^3.16.0",
109
- "@xingwangzhe/cjk-font-split-native": "^0.2.0",
109
+ "@xingwangzhe/cjk-font-split-native": "^0.4.2",
110
110
  "@xingwangzhe/satteri-mermaid": "^0.8.1",
111
111
  "@xingwangzhe/satteri-photoswipe": "^0.2.6",
112
112
  "@xingwangzhe/tags-cloud": "^1.2.9",
package/src/index.ts CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  readdirSync,
20
20
  readFileSync,
21
21
  } from "node:fs";
22
+ import { createRequire } from "node:module";
22
23
  import path from "node:path";
23
24
  import { fileURLToPath } from "node:url";
24
25
  import partytown from "@astrojs/partytown";
@@ -30,6 +31,7 @@ import { fontProviders } from "astro/config";
30
31
  // pagefind 是 ESM-only 包,需要在模块顶层导入
31
32
  // 因为 astro:build:done 钩子中 Vite module runner 已关闭,无法动态 import
32
33
  import { createIndex as pagefindCreateIndex } from "pagefind";
34
+ import { parse } from "parse5";
33
35
  import { parse as parseYaml } from "yaml";
34
36
  import type { StaluxOptions } from "./config";
35
37
  import { expressiveCode } from "./expressive-code";
@@ -38,10 +40,12 @@ import {
38
40
  clearPageFontSubsets,
39
41
  readLinkedStylesheetText,
40
42
  resolveFontInputs,
41
- writePageFontSubset,
43
+ writePageFontSubsetAsync,
42
44
  } from "./internal/font-slices";
43
- import { addLocalImageDimensions } from "./internal/image-dimensions";
45
+ import { findHtmlImages } from "./internal/html-images";
46
+ import { addLocalImageDimensions, type ImageDimensions } from "./internal/image-dimensions";
44
47
  import { createInjectedRoutes } from "./internal/injected-routes";
48
+ import { createPageOutputCache } from "./internal/page-output-cache";
45
49
  import { createRuntimeCacheKey } from "./internal/runtime-cache-key";
46
50
  import {
47
51
  appendUniquePlugin,
@@ -49,11 +53,7 @@ import {
49
53
  prepareSatteriProcessor,
50
54
  } from "./internal/satteri-config";
51
55
  import { createViteAliases } from "./internal/vite-aliases";
52
- import {
53
- applyHtmlImageLoadingPolicy,
54
- featureFlagsHast,
55
- featureFlagsMdast,
56
- } from "./plugins/feature-flags";
56
+ import { featureFlagsHast, featureFlagsMdast } from "./plugins/feature-flags";
57
57
  import { temml } from "./plugins/satteri-temml";
58
58
  import { describeError } from "./utils/diagnostics";
59
59
 
@@ -386,53 +386,127 @@ export function stalux(options: StaluxOptions = {}): AstroIntegration[] {
386
386
  // Astro's Markdown image transform drops arbitrary lazy-loading hints
387
387
  // added during HAST processing. Reapply the policy to emitted HTML.
388
388
  const htmlFiles = await fs.readdir(outDir, { recursive: true });
389
+ const sourceDir = fileURLToPath(new URL(".", import.meta.url));
390
+ const resolveModule = createRequire(import.meta.url).resolve;
391
+ const parserEntry = resolveModule("parse5");
392
+ const nativeEntry = resolveModule("@xingwangzhe/cjk-font-split-native");
393
+ const outputCache = await createPageOutputCache(
394
+ path.resolve("node_modules/.astro/stalux-page-output"),
395
+ [
396
+ ...[
397
+ "index.ts",
398
+ "internal/font-slices.ts",
399
+ "internal/image-dimensions.ts",
400
+ "internal/html-images.ts",
401
+ "internal/page-output-cache.ts",
402
+ "internal/style-names.generated.ts",
403
+ "plugins/feature-flags.ts",
404
+ "../package.json",
405
+ ].map((entry) => path.join(sourceDir, entry)),
406
+ path.join(path.dirname(nativeEntry), "package.json"),
407
+ path.resolve(path.dirname(parserEntry), "../package.json"),
408
+ ],
409
+ pageFontData?.fontBuffer,
410
+ htmlFiles
411
+ .filter((entry) => /\.(?:css|png|jpe?g|gif|webp)$/iu.test(entry))
412
+ .map((entry) => path.join(outDir, entry)),
413
+ );
414
+ let cacheHits = 0;
389
415
  let optimizedImagePages = 0;
390
416
  let pageFontPages = 0;
391
417
  if (pageFontData) await fs.mkdir(pageFontData.outputDir, { recursive: true });
392
418
  if (pageFontData) await fs.mkdir(pageFontData.cacheDir, { recursive: true });
393
419
  const referencedPageFonts = new Set<string>();
394
420
  const linkedCssTextCache = new Map<string, string>();
395
- for (const entry of htmlFiles) {
396
- if (typeof entry !== "string" || !entry.endsWith(".html")) continue;
421
+ const dimensionsCache = new Map<string, Promise<ImageDimensions | undefined>>();
422
+ const pageEntries = htmlFiles.filter((entry) => entry.endsWith(".html"));
423
+ const processPage = async (entry: string) => {
397
424
  const output = path.join(outDir, entry);
398
425
  const before = await fs.readFile(output, "utf8");
399
426
  const withoutPageFont = before.replace(
400
427
  /<style data-stalux-page-font>[\s\S]*?<\/style>/gi,
401
428
  "",
402
429
  );
403
- const withImageDimensions = await addLocalImageDimensions(
404
- withoutPageFont,
405
- outDir,
406
- );
407
- let after = applyHtmlImageLoadingPolicy(withImageDimensions);
430
+ const cached = await outputCache.get(entry, withoutPageFont);
431
+ if (cached) {
432
+ if (cached.font && pageFontData) {
433
+ if (!referencedPageFonts.has(cached.font.filename)) {
434
+ copyFileSync(
435
+ cached.font.sourcePath,
436
+ path.join(pageFontData.outputDir, cached.font.filename),
437
+ );
438
+ }
439
+ referencedPageFonts.add(cached.font.filename);
440
+ pageFontPages++;
441
+ }
442
+ if (cached.html !== before) {
443
+ await fs.writeFile(output, cached.html);
444
+ optimizedImagePages++;
445
+ }
446
+ cacheHits++;
447
+ return;
448
+ }
449
+ let cachedFont: { filename: string; sourcePath: string } | undefined;
450
+ const dependencies = new Set<string>();
451
+ const document = parse(withoutPageFont, { sourceCodeLocationInfo: true });
452
+ const images = findHtmlImages(withoutPageFont, document);
453
+ let after = await addLocalImageDimensions(withoutPageFont, outDir, {
454
+ images,
455
+ cache: dimensionsCache,
456
+ loadingPolicy: true,
457
+ dependencies,
458
+ });
408
459
  if (pageFontData) {
409
460
  const linkedStylesheetText = readLinkedStylesheetText(
410
461
  after,
411
462
  output,
412
463
  outDir,
413
464
  linkedCssTextCache,
465
+ document,
466
+ dependencies,
414
467
  );
415
- const pageFont = writePageFontSubset(
468
+ const pageFont = await writePageFontSubsetAsync(
416
469
  after,
417
470
  pageFontData.fontBuffer,
418
471
  pageFontData.cacheDir,
419
472
  "/_astro/fonts/",
420
473
  linkedStylesheetText,
474
+ "",
475
+ document,
421
476
  );
422
477
  if (pageFont) {
423
478
  after = pageFont.html;
424
479
  pageFontPages++;
480
+ cachedFont = {
481
+ filename: pageFont.filename,
482
+ sourcePath: pageFont.sourcePath,
483
+ };
484
+ if (!referencedPageFonts.has(pageFont.filename)) {
485
+ copyFileSync(
486
+ pageFont.sourcePath,
487
+ path.join(pageFontData.outputDir, pageFont.filename),
488
+ );
489
+ }
425
490
  referencedPageFonts.add(pageFont.filename);
426
- copyFileSync(
427
- pageFont.sourcePath,
428
- path.join(pageFontData.outputDir, pageFont.filename),
429
- );
430
491
  }
431
492
  }
432
493
  if (after !== before) {
433
494
  await fs.writeFile(output, after);
434
495
  optimizedImagePages++;
435
496
  }
497
+ await outputCache.set(
498
+ entry,
499
+ withoutPageFont,
500
+ {
501
+ html: after,
502
+ font: cachedFont,
503
+ },
504
+ [...dependencies],
505
+ );
506
+ };
507
+ // Bound I/O concurrency without retaining the entire site's HTML in memory.
508
+ for (let offset = 0; offset < pageEntries.length; offset += 4) {
509
+ await Promise.all(pageEntries.slice(offset, offset + 4).map(processPage));
436
510
  }
437
511
  if (pageFontData) {
438
512
  clearPageFontSubsets(pageFontData.outputDir, referencedPageFonts);
@@ -441,11 +515,15 @@ export function stalux(options: StaluxOptions = {}): AstroIntegration[] {
441
515
  logger.info(
442
516
  `generated page-specific body font subsets for ${pageFontPages} HTML pages`,
443
517
  );
518
+ logger.info(
519
+ `HTML postprocessing ${(performance.now() - started).toFixed(1)}ms; cache ${cacheHits}/${htmlFiles.filter((entry) => entry.endsWith(".html")).length}`,
520
+ );
444
521
 
445
522
  // 后处理:Pagefind 搜索索引。启用时任何失败都必须让构建失败,
446
523
  // 避免发布一个页面正常但搜索已损坏的产物。
447
524
  if (opt.pagefind) {
448
525
  logger.info("Running Pagefind indexer...");
526
+ const pagefindStarted = performance.now();
449
527
 
450
528
  try {
451
529
  const { index, errors } = await pagefindCreateIndex();
@@ -466,7 +544,7 @@ export function stalux(options: StaluxOptions = {}): AstroIntegration[] {
466
544
  });
467
545
 
468
546
  logger.info(
469
- `indexed ${page_count} pages → ${outputPath} (${(performance.now() - started).toFixed(1)}ms)`,
547
+ `indexed ${page_count} pages → ${outputPath} (${(performance.now() - pagefindStarted).toFixed(1)}ms)`,
470
548
  );
471
549
  } catch (error) {
472
550
  logger.error(`Pagefind indexing failed: ${describeError(error)}`);
@@ -18,7 +18,7 @@ import { styleNames } from "./style-names.generated.ts";
18
18
  import { existsSync, readdirSync, readFileSync, unlinkSync } from "node:fs";
19
19
  import { dirname, join, resolve, sep } from "node:path";
20
20
  import { fileURLToPath, pathToFileURL } from "node:url";
21
- import { subsetFont } from "@xingwangzhe/cjk-font-split-native";
21
+ import { FontSubsetter } from "@xingwangzhe/cjk-font-split-native";
22
22
  import type { AstroIntegrationLogger } from "astro";
23
23
  import { type DefaultTreeAdapterMap, parse } from "parse5";
24
24
 
@@ -46,6 +46,8 @@ const PAGE_SLICE_OUT_DIR = "node_modules/.astro/stalux-page-fonts";
46
46
  */
47
47
  export const RESERVED_GLYPHS = "0123456789";
48
48
 
49
+ const preparedFonts = new WeakMap<Buffer, FontSubsetter>();
50
+
49
51
  // ---------------------------------------------------------------------------
50
52
  // Types
51
53
  // ---------------------------------------------------------------------------
@@ -164,10 +166,12 @@ export function readLinkedStylesheetText(
164
166
  pagePath: string,
165
167
  outputDir: string,
166
168
  cache = new Map<string, string>(),
169
+ document = parse(html),
170
+ dependencies?: Set<string>,
167
171
  ): string {
168
172
  const root = resolve(outputDir);
169
173
  const links: string[] = [];
170
- collectStylesheetLinks(parse(html), links);
174
+ collectStylesheetLinks(document, links);
171
175
  const content: string[] = [];
172
176
  for (const href of links) {
173
177
  if (/^(?:[a-z][a-z\d+.-]*:|\/\/|data:)/iu.test(href)) continue;
@@ -180,14 +184,15 @@ export function readLinkedStylesheetText(
180
184
  continue;
181
185
  }
182
186
  if (cssPath !== root && !cssPath.startsWith(`${root}${sep}`)) continue;
187
+ dependencies?.add(cssPath);
183
188
  if (!cache.has(cssPath)) {
184
189
  try {
185
- cache.set(cssPath, readFileSync(cssPath, "utf8"));
190
+ cache.set(cssPath, cssGeneratedText(readFileSync(cssPath, "utf8")));
186
191
  } catch {
187
192
  cache.set(cssPath, "");
188
193
  }
189
194
  }
190
- content.push(cssGeneratedText(cache.get(cssPath) ?? ""));
195
+ content.push(cache.get(cssPath) ?? "");
191
196
  }
192
197
  return content.join(" ");
193
198
  }
@@ -256,8 +261,40 @@ export function writePageFontSubset(
256
261
  publicUrlPrefix = "/_astro/fonts/",
257
262
  linkedStylesheetText = "",
258
263
  extraChars = "",
264
+ document = parse(html),
259
265
  ): PageFontSubset | undefined {
260
- const document = parse(html);
266
+ const input = preparePageFont(html, fontBuffer, linkedStylesheetText, extraChars, document);
267
+ if (!input) return undefined;
268
+ return renderPageFont(
269
+ html,
270
+ publicUrlPrefix,
271
+ input.chars,
272
+ input.prepared.subset(input.chars.join(""), cacheDir),
273
+ );
274
+ }
275
+
276
+ export async function writePageFontSubsetAsync(
277
+ html: string,
278
+ fontBuffer: Buffer,
279
+ cacheDir: string,
280
+ publicUrlPrefix = "/_astro/fonts/",
281
+ linkedStylesheetText = "",
282
+ extraChars = "",
283
+ document = parse(html),
284
+ ): Promise<PageFontSubset | undefined> {
285
+ const input = preparePageFont(html, fontBuffer, linkedStylesheetText, extraChars, document);
286
+ if (!input) return undefined;
287
+ const result = await input.prepared.subsetAsync(input.chars.join(""), cacheDir);
288
+ return renderPageFont(html, publicUrlPrefix, input.chars, result);
289
+ }
290
+
291
+ function preparePageFont(
292
+ html: string,
293
+ fontBuffer: Buffer,
294
+ linkedStylesheetText: string,
295
+ extraChars: string,
296
+ document: DefaultTreeAdapterMap["document"],
297
+ ) {
261
298
  const body = findBody(document);
262
299
  if (!body) return undefined;
263
300
 
@@ -274,7 +311,20 @@ export function writePageFontSubset(
274
311
  );
275
312
  if (chars.length === 0) return undefined;
276
313
 
277
- const result = subsetFont(fontBuffer, chars.join(""), cacheDir);
314
+ let prepared = preparedFonts.get(fontBuffer);
315
+ if (!prepared) {
316
+ prepared = new FontSubsetter(fontBuffer);
317
+ preparedFonts.set(fontBuffer, prepared);
318
+ }
319
+ return { chars, prepared };
320
+ }
321
+
322
+ function renderPageFont(
323
+ html: string,
324
+ publicUrlPrefix: string,
325
+ chars: string[],
326
+ result: { hash: string; path: string; cacheHit: boolean },
327
+ ): PageFontSubset {
278
328
  const filename = `page-${result.hash}.woff2`;
279
329
 
280
330
  const rel = `${publicUrlPrefix}${filename}`;
@@ -10,7 +10,10 @@ export interface HtmlImage {
10
10
  }
11
11
 
12
12
  /** Source ranges preserve scripts and code-copy attributes byte-for-byte. */
13
- export function findHtmlImages(html: string): HtmlImage[] {
13
+ export function findHtmlImages(
14
+ html: string,
15
+ document = parse(html, { sourceCodeLocationInfo: true }),
16
+ ): HtmlImage[] {
14
17
  const images: HtmlImage[] = [];
15
18
  function visit(node: DefaultTreeAdapterMap["node"], body?: number) {
16
19
  if ("tagName" in node) {
@@ -35,10 +38,28 @@ export function findHtmlImages(html: string): HtmlImage[] {
35
38
  }
36
39
  if ("childNodes" in node) for (const child of node.childNodes) visit(child, body);
37
40
  }
38
- visit(parse(html, { sourceCodeLocationInfo: true }));
41
+ visit(document);
39
42
  return images.sort((left, right) => left.start - right.start);
40
43
  }
41
44
 
45
+ export function imageLoadingUpdates(images: HtmlImage[]): Map<HtmlImage, Record<string, string>> {
46
+ const firstSeen = new Set<number>();
47
+ const changes = new Map<HtmlImage, Record<string, string>>();
48
+ for (const image of images) {
49
+ if (image.body === undefined) continue;
50
+ const first = !firstSeen.has(image.body);
51
+ firstSeen.add(image.body);
52
+ const updates: Record<string, string> = {
53
+ decoding: image.attributes.get("decoding") || "async",
54
+ loading: first ? "eager" : "lazy",
55
+ };
56
+ if (first) updates.fetchpriority = "high";
57
+ else if (image.attributes.get("fetchpriority") === "high") updates.fetchpriority = "auto";
58
+ changes.set(image, updates);
59
+ }
60
+ return changes;
61
+ }
62
+
42
63
  export function updateImageTag(image: HtmlImage, updates: Record<string, string>): string {
43
64
  let tag = image.tag;
44
65
  const edits: Array<{ start: number; end: number; value: string }> = [];
@@ -1,6 +1,6 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { findHtmlImages, updateImageTag } from "./html-images";
3
+ import { findHtmlImages, type HtmlImage, imageLoadingUpdates, updateImageTag } from "./html-images";
4
4
 
5
5
  export type ImageDimensions = { width: number; height: number };
6
6
 
@@ -72,41 +72,68 @@ export function readImageDimensions(data: Buffer): ImageDimensions | undefined {
72
72
  }
73
73
 
74
74
  /** Add width/height to same-origin emitted images so their space is reserved before download. */
75
- export async function addLocalImageDimensions(html: string, outputDir: string): Promise<string> {
76
- const dimensionsCache = new Map<string, Promise<ImageDimensions | undefined>>();
75
+ export async function addLocalImageDimensions(
76
+ html: string,
77
+ outputDir: string,
78
+ options: {
79
+ images?: HtmlImage[];
80
+ cache?: Map<string, Promise<ImageDimensions | undefined>>;
81
+ loadingPolicy?: boolean;
82
+ dependencies?: Set<string>;
83
+ } = {},
84
+ ): Promise<string> {
85
+ const dimensionsCache =
86
+ options.cache ?? new Map<string, Promise<ImageDimensions | undefined>>();
77
87
  const root = path.resolve(outputDir);
78
- const tags = findHtmlImages(html);
79
-
80
- for (const image of tags.reverse()) {
81
- if (image.attributes.has("width") && image.attributes.has("height")) continue;
88
+ const images = options.images ?? findHtmlImages(html);
89
+ const loading = options.loadingPolicy ? imageLoadingUpdates(images) : new Map();
90
+ const replacements: Array<{ start: number; end: number; tag: string }> = [];
91
+ for (const image of images) {
92
+ const updates: Record<string, string> = { ...loading.get(image) };
82
93
  const src = image.attributes.get("src");
83
- if (!src) continue;
84
-
85
- let filePath: string;
86
- try {
87
- const url = new URL(src, "https://stalux.invalid");
88
- if (url.origin !== "https://stalux.invalid") continue;
89
- filePath = path.resolve(root, `.${decodeURIComponent(url.pathname)}`);
90
- } catch {
91
- continue;
94
+ if (src && !(image.attributes.has("width") && image.attributes.has("height"))) {
95
+ let filePath: string | undefined;
96
+ try {
97
+ const url = new URL(src, "https://stalux.invalid");
98
+ if (url.origin === "https://stalux.invalid") {
99
+ const candidate = path.resolve(root, `.${decodeURIComponent(url.pathname)}`);
100
+ if (candidate === root || candidate.startsWith(`${root}${path.sep}`))
101
+ filePath = candidate;
102
+ }
103
+ } catch {
104
+ // Invalid image URLs still receive the loading policy.
105
+ }
106
+ if (filePath) {
107
+ options.dependencies?.add(filePath);
108
+ let dimensions = dimensionsCache.get(filePath);
109
+ if (!dimensions) {
110
+ dimensions = readFile(filePath)
111
+ .then(readImageDimensions)
112
+ .catch(() => undefined);
113
+ dimensionsCache.set(filePath, dimensions);
114
+ }
115
+ const size = await dimensions;
116
+ if (size && size.width > 0 && size.height > 0) {
117
+ if (!image.attributes.has("width")) updates.width = String(size.width);
118
+ if (!image.attributes.has("height")) updates.height = String(size.height);
119
+ }
120
+ }
92
121
  }
93
- if (filePath !== root && !filePath.startsWith(`${root}${path.sep}`)) continue;
94
-
95
- let dimensions = dimensionsCache.get(filePath);
96
- if (!dimensions) {
97
- dimensions = readFile(filePath)
98
- .then(readImageDimensions)
99
- .catch(() => undefined);
100
- dimensionsCache.set(filePath, dimensions);
122
+ if (Object.keys(updates).length) {
123
+ replacements.push({
124
+ start: image.start,
125
+ end: image.end,
126
+ tag: updateImageTag(image, updates),
127
+ });
101
128
  }
102
- const size = await dimensions;
103
- if (!size || size.width <= 0 || size.height <= 0) continue;
104
-
105
- const updates: Record<string, string> = {};
106
- if (!image.attributes.has("width")) updates.width = String(size.width);
107
- if (!image.attributes.has("height")) updates.height = String(size.height);
108
- const updated = updateImageTag(image, updates);
109
- html = html.slice(0, image.start) + updated + html.slice(image.end);
110
129
  }
111
- return html;
130
+ if (!replacements.length) return html;
131
+ const parts: string[] = [];
132
+ let offset = 0;
133
+ for (const replacement of replacements) {
134
+ parts.push(html.slice(offset, replacement.start), replacement.tag);
135
+ offset = replacement.end;
136
+ }
137
+ parts.push(html.slice(offset));
138
+ return parts.join("");
112
139
  }
@@ -0,0 +1,78 @@
1
+ import { createHash } from "node:crypto";
2
+ import { promises as fs } from "node:fs";
3
+ import path from "node:path";
4
+
5
+ export interface CachedPageOutput {
6
+ html: string;
7
+ font?: { filename: string; sourcePath: string };
8
+ }
9
+
10
+ const digest = (value: string | Buffer) => createHash("sha256").update(value).digest("hex");
11
+
12
+ /** Content hashes include dependencies even when their public URL has not changed. */
13
+ export async function createPageOutputCache(
14
+ directory: string,
15
+ dependencies: readonly string[],
16
+ font?: Buffer,
17
+ assets: readonly string[] = [],
18
+ ) {
19
+ const hash = createHash("sha256").update("stalux-page-output-v1\0");
20
+ if (font) hash.update(font);
21
+ for (const dependency of [...dependencies].sort()) {
22
+ hash.update(dependency).update("\0");
23
+ hash.update(await fs.readFile(dependency)).update("\0");
24
+ }
25
+ const namespace = path.join(directory, hash.digest("hex"));
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}`);
30
+ return {
31
+ async get(entry: string, html: string): Promise<CachedPageOutput | undefined> {
32
+ try {
33
+ const record = JSON.parse(
34
+ await fs.readFile(path.join(namespace, `${key(entry, html)}.json`), "utf8"),
35
+ );
36
+ if (typeof record.html !== "string" || digest(record.html) !== record.digest)
37
+ return undefined;
38
+ if (!Array.isArray(record.assets)) return undefined;
39
+ for (const [asset, expected] of record.assets) {
40
+ if ((assetDigests.get(asset) ?? null) !== expected) return undefined;
41
+ }
42
+ if (record.font) {
43
+ if (
44
+ !/^page-[a-f\d]+\.woff2$/u.test(record.font.filename) ||
45
+ typeof record.font.sourcePath !== "string" ||
46
+ !(await fs.stat(record.font.sourcePath)).isFile()
47
+ )
48
+ return undefined;
49
+ }
50
+ return { html: record.html, font: record.font };
51
+ } catch {
52
+ // Missing, truncated, or invalid cache entries are rebuilt from source.
53
+ return undefined;
54
+ }
55
+ },
56
+ async set(
57
+ entry: string,
58
+ html: string,
59
+ output: CachedPageOutput,
60
+ referencedAssets: readonly string[] = [],
61
+ ): Promise<void> {
62
+ const target = path.join(namespace, `${key(entry, html)}.json`);
63
+ const temporary = `${target}.${process.pid}.tmp`;
64
+ await fs.writeFile(
65
+ temporary,
66
+ JSON.stringify({
67
+ ...output,
68
+ digest: digest(output.html),
69
+ assets: referencedAssets.map((asset) => [
70
+ asset,
71
+ assetDigests.get(asset) ?? null,
72
+ ]),
73
+ }),
74
+ );
75
+ await fs.rename(temporary, target);
76
+ },
77
+ };
78
+ }
@@ -16,7 +16,7 @@ import type { HastVisitorContext, MdastNode, MdastVisitorContext } from "satteri
16
16
  * - 数学公式由 satteri-temml 插件直接输出 MathML,无需额外标记
17
17
  */
18
18
  import { defineHastPlugin, defineMdastPlugin } from "satteri";
19
- import { findHtmlImages, updateImageTag } from "../internal/html-images";
19
+ import { findHtmlImages, imageLoadingUpdates, updateImageTag } from "../internal/html-images";
20
20
  import { logDetail } from "../utils/diagnostics";
21
21
 
22
22
  declare module "satteri" {
@@ -247,22 +247,10 @@ export function applyImageLoadingPolicy(
247
247
  }
248
248
 
249
249
  export function applyHtmlImageLoadingPolicy(html: string): string {
250
- const firstSeen = new Set<number>();
251
- const changes = findHtmlImages(html)
252
- .filter((image) => image.body !== undefined)
253
- .map((image) => {
254
- const body = image.body as number;
255
- const first = !firstSeen.has(body);
256
- firstSeen.add(body);
257
- const updates: Record<string, string> = {
258
- decoding: image.attributes.get("decoding") || "async",
259
- loading: first ? "eager" : "lazy",
260
- };
261
- if (first) updates.fetchpriority = "high";
262
- else if (image.attributes.get("fetchpriority") === "high")
263
- updates.fetchpriority = "auto";
264
- return { image, tag: updateImageTag(image, updates) };
265
- });
250
+ const changes = [...imageLoadingUpdates(findHtmlImages(html))].map(([image, updates]) => ({
251
+ image,
252
+ tag: updateImageTag(image, updates),
253
+ }));
266
254
  for (const { image, tag } of changes.reverse())
267
255
  html = html.slice(0, image.start) + tag + html.slice(image.end);
268
256
  return html;