@xingwangzhe/stalux 1.35.12 → 1.35.13

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.
@@ -20,7 +20,13 @@ import { dirname, join, resolve, sep } from "node:path";
20
20
  import { fileURLToPath, pathToFileURL } from "node:url";
21
21
  import { FontSubsetter } from "@xingwangzhe/cjk-font-split-native";
22
22
  import type { AstroIntegrationLogger } from "astro";
23
- import { type DefaultTreeAdapterMap, parse } from "parse5";
23
+ import {
24
+ type DefaultTreeAdapterMap,
25
+ defaultTreeAdapter,
26
+ html as htmlNamespaces,
27
+ parse,
28
+ serialize,
29
+ } from "parse5";
24
30
 
25
31
  // ---------------------------------------------------------------------------
26
32
  // Configuration
@@ -273,14 +279,15 @@ export function writePageFontSubset(
273
279
  extraChars = "",
274
280
  document = parse(html),
275
281
  ): PageFontSubset | undefined {
276
- const input = preparePageFont(html, fontBuffer, linkedStylesheetText, extraChars, document);
282
+ const input = preparePageFont(fontBuffer, linkedStylesheetText, extraChars, document);
277
283
  if (!input) return undefined;
278
- return renderPageFont(
279
- html,
284
+ const result = renderPageFont(
285
+ document,
280
286
  publicUrlPrefix,
281
287
  input.chars,
282
288
  input.prepared.subset(input.chars.join(""), cacheDir),
283
289
  );
290
+ return { ...result, html: serialize(document) };
284
291
  }
285
292
 
286
293
  export async function writePageFontSubsetAsync(
@@ -292,14 +299,16 @@ export async function writePageFontSubsetAsync(
292
299
  extraChars = "",
293
300
  document = parse(html),
294
301
  ): Promise<PageFontSubset | undefined> {
295
- const input = preparePageFont(html, fontBuffer, linkedStylesheetText, extraChars, document);
302
+ const input = preparePageFont(fontBuffer, linkedStylesheetText, extraChars, document);
296
303
  if (!input) return undefined;
297
304
  const result = await input.prepared.subsetAsync(input.chars.join(""), cacheDir);
298
- return renderPageFont(html, publicUrlPrefix, input.chars, result);
305
+ return {
306
+ ...renderPageFont(document, publicUrlPrefix, input.chars, result),
307
+ html: serialize(document),
308
+ };
299
309
  }
300
310
 
301
311
  function preparePageFont(
302
- html: string,
303
312
  fontBuffer: Buffer,
304
313
  linkedStylesheetText: string,
305
314
  extraChars: string,
@@ -312,8 +321,8 @@ function preparePageFont(
312
321
  addGlyphs(RESERVED_GLYPHS, glyphs);
313
322
  addGlyphs(extraChars, glyphs);
314
323
  addGlyphs(linkedStylesheetText, glyphs);
315
- for (const [, css] of html.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style\s*>/gi))
316
- addGlyphs(cssGeneratedText(css ?? ""), glyphs);
324
+ for (const style of collectInlineStyles(document))
325
+ addGlyphs(cssGeneratedText(styleText(style)), glyphs);
317
326
  collectBodyText(body, glyphs);
318
327
  const chars = [...glyphs].sort(
319
328
  (a, b) => (a.codePointAt(0) as number) - (b.codePointAt(0) as number),
@@ -329,44 +338,129 @@ function preparePageFont(
329
338
  }
330
339
 
331
340
  function renderPageFont(
332
- html: string,
341
+ document: DefaultTreeAdapterMap["document"],
333
342
  publicUrlPrefix: string,
334
343
  chars: string[],
335
344
  result: { hash: string; path: string; cacheHit: boolean },
336
- ): PageFontSubset {
345
+ ): Omit<PageFontSubset, "html"> {
337
346
  const filename = `page-${result.hash}.woff2`;
338
347
 
339
348
  const rel = `${publicUrlPrefix}${filename}`;
340
349
  // Keep Astro's broad unicode-range font out of the fallback stack; otherwise
341
350
  // a character outside this page's subset would download multiple 300 KB slices.
342
- const originalFontStack = html.match(/--font-body:\s*([^;}]+)/u)?.[1]?.trim();
351
+ const styles = collectInlineStyles(document);
352
+ const originalFontStack = styles
353
+ .map(styleText)
354
+ .map((css) => css.match(/--font-body:\s*([^;}]+)/u)?.[1]?.trim())
355
+ .find(Boolean);
343
356
  const fallbackStack =
344
357
  originalFontStack || '"Noto Sans SC","Noto Sans CJK SC",system-ui,sans-serif';
345
- const originalCodeStack = html.match(/--font-code:\s*([^;}]+)/u)?.[1]?.trim();
358
+ const originalCodeStack = styles
359
+ .map(styleText)
360
+ .map((css) => css.match(/--font-code:\s*([^;}]+)/u)?.[1]?.trim())
361
+ .find(Boolean);
346
362
  const codeStack = originalCodeStack
347
363
  ? `${originalCodeStack},"LXGW WenKai-Page Subset"`
348
364
  : `"LXGW WenKai-Page Subset",${fallbackStack}`;
349
365
  // The source font is regular; advertise a weight range so browsers synthesize
350
366
  // bold weights from these same glyphs instead of falling back to system CJK.
351
367
  const css = `@font-face{font-family:"LXGW WenKai-Page Subset";src:url("${rel}") format("woff2");font-style:normal;font-weight:100 900;font-display:swap;unicode-range:${formatUnicodeRange(chars)}}:root{--font-body:"LXGW WenKai-Page Subset",${fallbackStack};--font-code:${codeStack}}body{font-family:var(--font-body),${fallbackStack}}`;
352
- const withoutBroadBodyFonts = html.replace(
353
- /@font-face\{[^}]*font-family:"LXGW WenKai-[^"]+"[^}]*\}/g,
354
- (face) => {
355
- const unicodeRange = face.match(/unicode-range:([^;}]+)/i)?.[1] ?? "";
356
- const hasCjkRange = unicodeRange.split(",").some((range) => {
357
- const start = range.match(/U\+([\da-f]+)/i)?.[1];
358
- if (!start) return false;
359
- const codePoint = Number.parseInt(start, 16);
360
- return codePoint >= 0x4e00 && codePoint <= 0x9fff;
361
- });
362
- return hasCjkRange ? "" : face;
363
- },
364
- );
365
- const outputHtml = withoutBroadBodyFonts.replace(
366
- /<\/head\s*>/i,
367
- `<style data-stalux-page-font>${css}</style></head>`,
368
- );
369
- return { html: outputHtml, filename, sourcePath: result.path, cacheHit: result.cacheHit };
368
+ for (const style of styles) {
369
+ for (const node of style.childNodes) {
370
+ if (node.nodeName !== "#text" || !("value" in node)) continue;
371
+ node.value = node.value.replace(
372
+ /@font-face\{[^}]*font-family:"LXGW WenKai-[^"]+"[^}]*\}/g,
373
+ (face) => {
374
+ const unicodeRange = face.match(/unicode-range:([^;}]+)/i)?.[1] ?? "";
375
+ const hasCjkRange = unicodeRange.split(",").some((range) => {
376
+ const start = range.match(/U\+([\da-f]+)/i)?.[1];
377
+ if (!start) return false;
378
+ const point = Number.parseInt(start, 16);
379
+ return point >= 0x4e00 && point <= 0x9fff;
380
+ });
381
+ return hasCjkRange ? "" : face;
382
+ },
383
+ );
384
+ }
385
+ }
386
+ const head = document.childNodes
387
+ .flatMap((node) => ("childNodes" in node ? node.childNodes : []))
388
+ .find((node) => "tagName" in node && node.tagName === "head");
389
+ if (head && "childNodes" in head) {
390
+ const style = defaultTreeAdapter.createElement("style", htmlNamespaces.NS.HTML, [
391
+ { name: "data-stalux-page-font", value: "" },
392
+ ]);
393
+ const preload = defaultTreeAdapter.createElement("link", htmlNamespaces.NS.HTML, [
394
+ { name: "rel", value: "preload" },
395
+ { name: "as", value: "font" },
396
+ { name: "type", value: "font/woff2" },
397
+ { name: "crossorigin", value: "anonymous" },
398
+ { name: "href", value: rel },
399
+ { name: "data-stalux-page-font-preload", value: "" },
400
+ ]);
401
+ defaultTreeAdapter.appendChild(head, preload);
402
+ defaultTreeAdapter.insertText(style, css);
403
+ defaultTreeAdapter.appendChild(head, style);
404
+ }
405
+ return { filename, sourcePath: result.path, cacheHit: result.cacheHit };
406
+ }
407
+
408
+ /** Styles are selected structurally, so examples in scripts/comments/templates are untouched. */
409
+ function collectInlineStyles(document: HtmlNode): DefaultTreeAdapterMap["element"][] {
410
+ const styles: DefaultTreeAdapterMap["element"][] = [];
411
+ function visit(node: HtmlNode) {
412
+ if ("tagName" in node) {
413
+ if (node.tagName === "template") return;
414
+ if (node.tagName === "style") styles.push(node);
415
+ }
416
+ if ("childNodes" in node) for (const child of node.childNodes) visit(child);
417
+ }
418
+ visit(document);
419
+ return styles;
420
+ }
421
+
422
+ function styleText(style: DefaultTreeAdapterMap["element"]): string {
423
+ return style.childNodes.map((node) => ("value" in node ? node.value : "")).join("");
424
+ }
425
+
426
+ /** Remove previously generated styles by AST identity, never by matching HTML strings. */
427
+ export function removePageFontStyles(document: DefaultTreeAdapterMap["document"]): void {
428
+ function removePreloads(node: HtmlNode): void {
429
+ if ("tagName" in node && node.tagName === "template") return;
430
+ if (
431
+ "tagName" in node &&
432
+ node.tagName === "link" &&
433
+ node.attrs.some((attr) => attr.name === "data-stalux-page-font-preload")
434
+ ) {
435
+ defaultTreeAdapter.detachNode(node);
436
+ return;
437
+ }
438
+ if ("childNodes" in node) for (const child of [...node.childNodes]) removePreloads(child);
439
+ }
440
+ removePreloads(document);
441
+ for (const style of collectInlineStyles(document)) {
442
+ if (style.attrs.some((attribute) => attribute.name === "data-stalux-page-font"))
443
+ defaultTreeAdapter.detachNode(style);
444
+ }
445
+ }
446
+
447
+ /** Subset and inject into the caller's AST without serializing an intermediate page. */
448
+ export async function writePageFontSubsetIntoDocumentAsync(
449
+ document: DefaultTreeAdapterMap["document"],
450
+ fontBuffer: Buffer,
451
+ cacheDir: string,
452
+ publicUrlPrefix = "/_astro/fonts/",
453
+ linkedStylesheetText = "",
454
+ ): Promise<Omit<PageFontSubset, "html"> | undefined> {
455
+ const input = preparePageFont(fontBuffer, linkedStylesheetText, "", document);
456
+ if (!input) return undefined;
457
+ const result = await input.prepared.subsetAsync(input.chars.join(""), cacheDir);
458
+ const rendered = renderPageFont(document, publicUrlPrefix, input.chars, result);
459
+ return {
460
+ filename: rendered.filename,
461
+ sourcePath: rendered.sourcePath,
462
+ cacheHit: rendered.cacheHit,
463
+ };
370
464
  }
371
465
 
372
466
  /** Remove stale page subsets so deploy artifacts only contain the current build's referenced files. */
@@ -1,6 +1,7 @@
1
1
  import { type DefaultTreeAdapterMap, parse } from "parse5";
2
2
 
3
3
  export interface HtmlImage {
4
+ node: DefaultTreeAdapterMap["element"];
4
5
  start: number;
5
6
  end: number;
6
7
  tag: string;
@@ -27,6 +28,7 @@ export function findHtmlImages(
27
28
  if (node.tagName === "img" && location?.startTag) {
28
29
  const { startOffset: start, endOffset: end } = location.startTag;
29
30
  images.push({
31
+ node,
30
32
  start,
31
33
  end,
32
34
  tag: html.slice(start, end),
@@ -0,0 +1,98 @@
1
+ import { type DefaultTreeAdapterMap, serialize, serializeOuter } from "parse5";
2
+
3
+ type Element = DefaultTreeAdapterMap["element"];
4
+
5
+ /**
6
+ * Serialize AST mutations to image/style nodes without reserializing untouched page subtrees.
7
+ * Source locations belong to the original parse; edits are emitted together after all mutations.
8
+ */
9
+ export function createHtmlMutationEmitter(
10
+ source: string,
11
+ document: DefaultTreeAdapterMap["document"],
12
+ ) {
13
+ const snapshots: Array<{
14
+ node: Element;
15
+ start: number;
16
+ end: number;
17
+ attrs: Element["attrs"];
18
+ text: string[];
19
+ }> = [];
20
+ let head: Element | undefined;
21
+ function visit(node: DefaultTreeAdapterMap["node"]) {
22
+ if ("tagName" in node) {
23
+ if (node.tagName === "template") return;
24
+ if (node.tagName === "head") head = node;
25
+ const location = node.sourceCodeLocation;
26
+ if (
27
+ (node.tagName === "img" || node.tagName === "style" || node.tagName === "link") &&
28
+ location
29
+ ) {
30
+ snapshots.push({
31
+ node,
32
+ start: location.startOffset,
33
+ end: location.endOffset,
34
+ attrs: node.attrs.map((attribute) => ({ ...attribute })),
35
+ text: node.childNodes.map((child) => ("value" in child ? child.value : "")),
36
+ });
37
+ }
38
+ }
39
+ if ("childNodes" in node) for (const child of node.childNodes) visit(child);
40
+ }
41
+ visit(document);
42
+
43
+ return () => {
44
+ const edits: Array<{ start: number; end: number; value: string }> = [];
45
+ for (const snapshot of snapshots) {
46
+ const { node, start, end, attrs, text } = snapshot;
47
+ if (!node.parentNode) {
48
+ edits.push({ start, end, value: "" });
49
+ continue;
50
+ }
51
+ const changed =
52
+ node.attrs.length !== attrs.length ||
53
+ attrs.some((attribute, index) => {
54
+ const current = node.attrs[index];
55
+ return (
56
+ !current ||
57
+ attribute.name !== current.name ||
58
+ attribute.value !== current.value ||
59
+ attribute.namespace !== current.namespace ||
60
+ attribute.prefix !== current.prefix
61
+ );
62
+ }) ||
63
+ node.childNodes.length !== text.length ||
64
+ text.some((value, index) => {
65
+ const child = node.childNodes[index];
66
+ return !child || ("value" in child ? child.value : "") !== value;
67
+ });
68
+ if (changed) edits.push({ start, end, value: serializeOuter(node) });
69
+ }
70
+ const inserted = head?.childNodes.filter(
71
+ (node) =>
72
+ "tagName" in node &&
73
+ (node.tagName === "style" || node.tagName === "link") &&
74
+ !node.sourceCodeLocation,
75
+ );
76
+ if (inserted?.length) {
77
+ const position = head?.sourceCodeLocation?.endTag?.startOffset;
78
+ // Documents with an implicit head have no insertion range; use the standard serializer.
79
+ if (position === undefined) return serialize(document);
80
+ edits.push({
81
+ start: position,
82
+ end: position,
83
+ value: inserted.map((node) => serializeOuter(node)).join(""),
84
+ });
85
+ }
86
+ if (!edits.length) return source;
87
+ edits.sort((left, right) => left.start - right.start || left.end - right.end);
88
+ const parts: string[] = [];
89
+ let offset = 0;
90
+ for (const edit of edits) {
91
+ if (edit.start < offset) return serialize(document);
92
+ parts.push(source.slice(offset, edit.start), edit.value);
93
+ offset = edit.end;
94
+ }
95
+ parts.push(source.slice(offset));
96
+ return parts.join("");
97
+ };
98
+ }
@@ -1,93 +1,91 @@
1
- import { readFile } from "node:fs/promises";
1
+ import { open } from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { imageSize } from "image-size";
4
+ import { imageSizeFromFile } from "image-size/fromFile";
3
5
  import { findHtmlImages, type HtmlImage, imageLoadingUpdates, updateImageTag } from "./html-images";
4
6
 
5
7
  export type ImageDimensions = { width: number; height: number };
6
8
 
7
- /** Read intrinsic dimensions for common public image formats without loading an image library. */
8
- export function readImageDimensions(data: Buffer): ImageDimensions | undefined {
9
- if (data.length >= 24 && data.toString("hex", 0, 8) === "89504e470d0a1a0a") {
10
- return { width: data.readUInt32BE(16), height: data.readUInt32BE(20) };
9
+ /** Preserve JPEG layouts the library cannot recognize, including a leading SOF marker. */
10
+ function readJpegDimensions(data: Buffer): ImageDimensions | undefined {
11
+ if (data.length < 4 || data[0] !== 0xff || data[1] !== 0xd8) return undefined;
12
+ let offset = 2;
13
+ while (offset + 4 <= data.length) {
14
+ if (data[offset] !== 0xff) return undefined;
15
+ const marker = data[offset + 1] ?? 0;
16
+ const size = data.readUInt16BE(offset + 2);
17
+ if (size < 2 || offset + 2 + size > data.length) return undefined;
18
+ if (
19
+ [0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf].includes(
20
+ marker,
21
+ )
22
+ ) {
23
+ if (size < 7) return undefined;
24
+ return validDimensions({
25
+ width: data.readUInt16BE(offset + 7),
26
+ height: data.readUInt16BE(offset + 5),
27
+ });
28
+ }
29
+ offset += size + 2;
11
30
  }
31
+ return undefined;
32
+ }
12
33
 
13
- if (
14
- data.length >= 10 &&
15
- (data.toString("ascii", 0, 6) === "GIF87a" || data.toString("ascii", 0, 6) === "GIF89a")
16
- ) {
17
- return { width: data.readUInt16LE(6), height: data.readUInt16LE(8) };
18
- }
34
+ function validDimensions(size: ImageDimensions): ImageDimensions | undefined {
35
+ return Number.isFinite(size.width) &&
36
+ Number.isFinite(size.height) &&
37
+ size.width > 0 &&
38
+ size.height > 0
39
+ ? { width: size.width, height: size.height }
40
+ : undefined;
41
+ }
19
42
 
20
- if (
21
- data.length >= 30 &&
22
- data.toString("ascii", 0, 4) === "RIFF" &&
23
- data.toString("ascii", 8, 12) === "WEBP"
24
- ) {
25
- const chunk = data.toString("ascii", 12, 16);
26
- if (chunk === "VP8X") {
27
- return {
28
- width: 1 + data.readUIntLE(24, 3),
29
- height: 1 + data.readUIntLE(27, 3),
30
- };
31
- }
32
- if (chunk === "VP8 " && data[23] === 0x9d && data[24] === 0x01 && data[25] === 0x2a) {
33
- return {
34
- width: data.readUInt16LE(26) & 0x3fff,
35
- height: data.readUInt16LE(28) & 0x3fff,
36
- };
37
- }
38
- if (chunk === "VP8L" && data[20] === 0x2f) {
39
- const b1 = data[21] ?? 0;
40
- const b2 = data[22] ?? 0;
41
- const b3 = data[23] ?? 0;
42
- const b4 = data[24] ?? 0;
43
- return {
44
- width: 1 + b1 + ((b2 & 0x3f) << 8),
45
- height: 1 + (b2 >> 6) + (b3 << 2) + ((b4 & 0x0f) << 10),
46
- };
47
- }
43
+ /** Unsupported or malformed images leave the original HTML untouched. */
44
+ export function readImageDimensions(data: Buffer): ImageDimensions | undefined {
45
+ try {
46
+ return validDimensions(imageSize(data));
47
+ } catch {
48
+ return readJpegDimensions(data);
48
49
  }
50
+ }
49
51
 
50
- if (data.length >= 4 && data[0] === 0xff && data[1] === 0xd8) {
51
- let offset = 2;
52
- while (offset + 4 <= data.length) {
53
- if (data[offset] !== 0xff) break;
54
- const marker = data[offset + 1] ?? 0;
55
- const size = data.readUInt16BE(offset + 2);
56
- if (size < 7 || offset + 2 + size > data.length) break;
57
- if (
58
- [
59
- 0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf,
60
- ].includes(marker)
61
- ) {
62
- return {
63
- width: data.readUInt16BE(offset + 7),
64
- height: data.readUInt16BE(offset + 5),
65
- };
66
- }
67
- offset += size + 2;
52
+ async function readLocalImageDimensions(filename: string): Promise<ImageDimensions | undefined> {
53
+ try {
54
+ // image-size reads at most 512 KiB; the caller retains the four-page work queue.
55
+ return validDimensions(await imageSizeFromFile(filename));
56
+ } catch {
57
+ // Preserve previously supported JPEGs whose SOF is first or beyond the header window.
58
+ // Only this compatibility path reads a whole image, never an unknown or remote format.
59
+ const file = await open(filename, "r").catch(() => undefined);
60
+ if (!file) return undefined;
61
+ try {
62
+ const signature = Buffer.alloc(2);
63
+ await file.read(signature, 0, 2, 0);
64
+ if (signature[0] !== 0xff || signature[1] !== 0xd8) return undefined;
65
+ return readImageDimensions(await file.readFile());
66
+ } catch {
67
+ return undefined;
68
+ } finally {
69
+ await file.close();
68
70
  }
69
71
  }
70
-
71
- return 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(
76
- html: string,
75
+ async function resolveImageUpdates(
76
+ images: HtmlImage[],
77
77
  outputDir: string,
78
78
  options: {
79
- images?: HtmlImage[];
80
79
  cache?: Map<string, Promise<ImageDimensions | undefined>>;
81
80
  loadingPolicy?: boolean;
82
81
  dependencies?: Set<string>;
83
82
  } = {},
84
- ): Promise<string> {
83
+ ): Promise<Map<HtmlImage, Record<string, string>>> {
85
84
  const dimensionsCache =
86
85
  options.cache ?? new Map<string, Promise<ImageDimensions | undefined>>();
87
86
  const root = path.resolve(outputDir);
88
- const images = options.images ?? findHtmlImages(html);
89
87
  const loading = options.loadingPolicy ? imageLoadingUpdates(images) : new Map();
90
- const replacements: Array<{ start: number; end: number; tag: string }> = [];
88
+ const changes = new Map<HtmlImage, Record<string, string>>();
91
89
  for (const image of images) {
92
90
  const updates: Record<string, string> = { ...loading.get(image) };
93
91
  const src = image.attributes.get("src");
@@ -107,9 +105,7 @@ export async function addLocalImageDimensions(
107
105
  options.dependencies?.add(filePath);
108
106
  let dimensions = dimensionsCache.get(filePath);
109
107
  if (!dimensions) {
110
- dimensions = readFile(filePath)
111
- .then(readImageDimensions)
112
- .catch(() => undefined);
108
+ dimensions = readLocalImageDimensions(filePath).catch(() => undefined);
113
109
  dimensionsCache.set(filePath, dimensions);
114
110
  }
115
111
  const size = await dimensions;
@@ -119,14 +115,43 @@ export async function addLocalImageDimensions(
119
115
  }
120
116
  }
121
117
  }
122
- if (Object.keys(updates).length) {
123
- replacements.push({
124
- start: image.start,
125
- end: image.end,
126
- tag: updateImageTag(image, updates),
127
- });
118
+ if (Object.keys(updates).length) changes.set(image, updates);
119
+ }
120
+ return changes;
121
+ }
122
+
123
+ /** Mutate image attributes in the shared build AST; the caller serializes once. */
124
+ export async function updateLocalImageDimensions(
125
+ images: HtmlImage[],
126
+ outputDir: string,
127
+ options: Parameters<typeof resolveImageUpdates>[2] = {},
128
+ ): Promise<void> {
129
+ const changes = await resolveImageUpdates(images, outputDir, options);
130
+ for (const [image, updates] of changes) {
131
+ for (const [name, value] of Object.entries(updates)) {
132
+ const attribute = image.node.attrs.find((attribute) => attribute.name === name);
133
+ if (attribute) attribute.value = value;
134
+ else image.node.attrs.push({ name, value });
128
135
  }
129
136
  }
137
+ }
138
+
139
+ /** Compatibility string API; the production pipeline uses AST mutations above. */
140
+ export async function addLocalImageDimensions(
141
+ html: string,
142
+ outputDir: string,
143
+ options: Parameters<typeof resolveImageUpdates>[2] & { images?: HtmlImage[] } = {},
144
+ ): Promise<string> {
145
+ const changes = await resolveImageUpdates(
146
+ options.images ?? findHtmlImages(html),
147
+ outputDir,
148
+ options,
149
+ );
150
+ const replacements = [...changes].map(([image, updates]) => ({
151
+ start: image.start,
152
+ end: image.end,
153
+ tag: updateImageTag(image, updates),
154
+ }));
130
155
  if (!replacements.length) return html;
131
156
  const parts: string[] = [];
132
157
  let offset = 0;
@@ -1,5 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
- import { promises as fs } from "node:fs";
2
+ import { createReadStream, promises as fs } from "node:fs";
3
3
  import path from "node:path";
4
4
 
5
5
  export interface CachedPageOutput {
@@ -30,7 +30,15 @@ export async function createPageOutputCache(
30
30
  if (!knownAssets.has(asset)) return Promise.resolve(null);
31
31
  let pending = assetDigests.get(asset);
32
32
  if (!pending) {
33
- pending = fs.readFile(asset).then(digest, () => null);
33
+ pending = (async () => {
34
+ try {
35
+ const hash = createHash("sha256");
36
+ for await (const chunk of createReadStream(asset)) hash.update(chunk);
37
+ return hash.digest("hex");
38
+ } catch {
39
+ return null;
40
+ }
41
+ })();
34
42
  assetDigests.set(asset, pending);
35
43
  }
36
44
  return pending;
@@ -0,0 +1,59 @@
1
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // 背景 SVG 判别式同步
6
+ // ---------------------------------------------------------------------------
7
+
8
+ /**
9
+ * 把主题包的 public/background/*.svg 同步到用户项目 public/background/。
10
+ *
11
+ * 判别式(非无条件覆盖):
12
+ * - 同名文件:内容一致则跳过(幂等);内容不同才覆盖(主题更新了 SVG)
13
+ * - 用户新增的文件:保留,不删除(避免误删用户自定义背景)
14
+ * - 不做整目录 force 复制
15
+ */
16
+ export function syncBackgroundSvgs(
17
+ srcDir: string,
18
+ publicDir: string,
19
+ ): { copied: number; skipped: number } {
20
+ const srcBg = path.join(srcDir, "../public/background");
21
+ if (!existsSync(srcBg)) return { copied: 0, skipped: 0 };
22
+
23
+ const destBg = path.join(publicDir, "background");
24
+ mkdirSync(destBg, { recursive: true });
25
+
26
+ let copied = 0;
27
+ let skipped = 0;
28
+ for (const name of readdirSync(srcBg)) {
29
+ if (!name.endsWith(".svg")) continue;
30
+ const srcFile = path.join(srcBg, name);
31
+ const destFile = path.join(destBg, name);
32
+ if (existsSync(destFile)) {
33
+ const srcBuf = readFileSync(srcFile);
34
+ const destBuf = readFileSync(destFile);
35
+ if (srcBuf.equals(destBuf)) {
36
+ skipped++; // 内容一致,跳过
37
+ continue;
38
+ }
39
+ }
40
+ copyFileSync(srcFile, destFile);
41
+ copied++;
42
+ }
43
+ return { copied, skipped };
44
+ }
45
+
46
+ /** Sync packaged math fonts and licensing notices; Astro does not merge integration public directories. */
47
+ export function syncFontAssets(srcDir: string, publicDir: string): void {
48
+ const source = path.join(srcDir, "../public/fonts");
49
+ if (!existsSync(source)) return;
50
+ const destination = path.join(publicDir, "fonts");
51
+ mkdirSync(destination, { recursive: true });
52
+ for (const name of readdirSync(source)) {
53
+ if (!/\.(?:woff2|txt)$/u.test(name)) continue;
54
+ const from = path.join(source, name);
55
+ const to = path.join(destination, name);
56
+ if (existsSync(to) && readFileSync(from).equals(readFileSync(to))) continue;
57
+ copyFileSync(from, to);
58
+ }
59
+ }