@xingwangzhe/stalux 1.35.11 → 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.
Files changed (43) hide show
  1. package/README.md +24 -11
  2. package/package.json +5 -2
  3. package/src/components/stalux/common/Typewriter.astro +32 -1
  4. package/src/components/stalux/posts/postContent.astro +7 -1
  5. package/src/index.ts +51 -104
  6. package/src/internal/bounded-tasks.ts +28 -0
  7. package/src/internal/compact-styles.ts +55 -13
  8. package/src/internal/font-slices.ts +150 -47
  9. package/src/internal/html-images.ts +2 -0
  10. package/src/internal/html-output.ts +98 -0
  11. package/src/internal/image-dimensions.ts +99 -74
  12. package/src/internal/page-output-cache.ts +26 -9
  13. package/src/internal/public-assets.ts +59 -0
  14. package/src/internal/runtime-cache-key.ts +14 -3
  15. package/src/internal/style-names.generated.ts +27 -26
  16. package/src/layouts/PostLayout.astro +1 -0
  17. package/src/layouts/Stalux.astro +3 -1
  18. package/src/pages/api/post.abbrlink.json.ts +0 -3
  19. package/src/pages/api/posts.json.ts +16 -19
  20. package/src/pages/openapi.json.ts +3 -44
  21. package/src/pages/posts/[post].md.ts +4 -40
  22. package/src/plugins/feature-flags.ts +8 -1
  23. package/src/scripts/navigation-font.ts +28 -0
  24. package/src/scripts/navigation.ts +4 -2
  25. package/src/scripts/page-runtime.ts +20 -6
  26. package/src/scripts/pagefind-api.ts +63 -0
  27. package/src/scripts/photoswipe.ts +13 -3
  28. package/src/scripts/random-posts.ts +12 -10
  29. package/src/scripts/search-dialog.ts +2 -1
  30. package/src/scripts/view-transitions.ts +12 -1
  31. package/src/scripts/webmcp.ts +397 -141
  32. package/src/scripts/word-list.ts +13 -3
  33. package/src/styles/base.css +7 -0
  34. package/src/styles/generated.css +1 -1
  35. package/src/utils/collections-stats.ts +9 -22
  36. package/src/utils/content-index.ts +13 -4
  37. package/src/utils/dayjs.ts +26 -11
  38. package/src/utils/feed.ts +30 -29
  39. package/src/utils/post-lookup.ts +51 -0
  40. package/src/utils/post-markdown.ts +31 -0
  41. package/src/utils/site-path.ts +4 -0
  42. package/src/utils/toc-scrollspy.ts +20 -6
  43. package/src/utils/word-count-utils.ts +45 -41
@@ -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
@@ -209,22 +215,31 @@ function findBody(node: HtmlNode): HtmlNode | undefined {
209
215
  }
210
216
 
211
217
  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);
218
+ let className = "";
219
+ let style = "";
220
+ for (const attr of attrs) {
221
+ if (attr.name === "hidden" || attr.name === "inert") return true;
222
+ if (attr.name === "class") className = attr.value;
223
+ if (attr.name === "style") style = attr.value;
224
+ }
225
+ const classes = className.split(/\s+/u);
216
226
  if (classes.some((name) => VISUALLY_HIDDEN_CLASSES.has(name))) return true;
217
227
 
218
- const style = attributes.get("style") ?? "";
219
228
  return /(?:^|;)\s*(?:display\s*:\s*none|visibility\s*:\s*hidden|content-visibility\s*:\s*hidden)\s*(?:;|$)/iu.test(
220
229
  style,
221
230
  );
222
231
  }
223
232
 
224
233
  /** Collect renderable body text nodes, including content inside collapsed controls. */
225
- function collectBodyText(node: HtmlNode, output: string[]): void {
234
+ const CONTROL_CHARACTER = /\p{C}/u;
235
+ function addGlyphs(text: string, output: Set<string>): void {
236
+ for (const char of text)
237
+ if (!output.has(char) && !CONTROL_CHARACTER.test(char)) output.add(char);
238
+ }
239
+
240
+ function collectBodyText(node: HtmlNode, output: Set<string>): void {
226
241
  if (node.nodeName === "#text" && "value" in node) {
227
- output.push(node.value);
242
+ addGlyphs(node.value, output);
228
243
  return;
229
244
  }
230
245
 
@@ -240,9 +255,10 @@ function collectBodyText(node: HtmlNode, output: string[]): void {
240
255
  }
241
256
 
242
257
  function formatUnicodeRange(chars: string[]): string {
243
- const points = chars.map((char) => char.codePointAt(0) as number).sort((a, b) => a - b);
244
258
  const ranges: Array<[number, number]> = [];
245
- for (const point of points) {
259
+ // preparePageFont already sorts the unique glyphs by code point.
260
+ for (const char of chars) {
261
+ const point = char.codePointAt(0) as number;
246
262
  const last = ranges.at(-1);
247
263
  if (last && point === last[1] + 1) last[1] = point;
248
264
  else ranges.push([point, point]);
@@ -263,14 +279,15 @@ export function writePageFontSubset(
263
279
  extraChars = "",
264
280
  document = parse(html),
265
281
  ): PageFontSubset | undefined {
266
- const input = preparePageFont(html, fontBuffer, linkedStylesheetText, extraChars, document);
282
+ const input = preparePageFont(fontBuffer, linkedStylesheetText, extraChars, document);
267
283
  if (!input) return undefined;
268
- return renderPageFont(
269
- html,
284
+ const result = renderPageFont(
285
+ document,
270
286
  publicUrlPrefix,
271
287
  input.chars,
272
288
  input.prepared.subset(input.chars.join(""), cacheDir),
273
289
  );
290
+ return { ...result, html: serialize(document) };
274
291
  }
275
292
 
276
293
  export async function writePageFontSubsetAsync(
@@ -282,14 +299,16 @@ export async function writePageFontSubsetAsync(
282
299
  extraChars = "",
283
300
  document = parse(html),
284
301
  ): Promise<PageFontSubset | undefined> {
285
- const input = preparePageFont(html, fontBuffer, linkedStylesheetText, extraChars, document);
302
+ const input = preparePageFont(fontBuffer, linkedStylesheetText, extraChars, document);
286
303
  if (!input) return undefined;
287
304
  const result = await input.prepared.subsetAsync(input.chars.join(""), cacheDir);
288
- return renderPageFont(html, publicUrlPrefix, input.chars, result);
305
+ return {
306
+ ...renderPageFont(document, publicUrlPrefix, input.chars, result),
307
+ html: serialize(document),
308
+ };
289
309
  }
290
310
 
291
311
  function preparePageFont(
292
- html: string,
293
312
  fontBuffer: Buffer,
294
313
  linkedStylesheetText: string,
295
314
  extraChars: string,
@@ -298,15 +317,14 @@ function preparePageFont(
298
317
  const body = findBody(document);
299
318
  if (!body) return undefined;
300
319
 
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(
320
+ const glyphs = new Set<string>();
321
+ addGlyphs(RESERVED_GLYPHS, glyphs);
322
+ addGlyphs(extraChars, glyphs);
323
+ addGlyphs(linkedStylesheetText, glyphs);
324
+ for (const style of collectInlineStyles(document))
325
+ addGlyphs(cssGeneratedText(styleText(style)), glyphs);
326
+ collectBodyText(body, glyphs);
327
+ const chars = [...glyphs].sort(
310
328
  (a, b) => (a.codePointAt(0) as number) - (b.codePointAt(0) as number),
311
329
  );
312
330
  if (chars.length === 0) return undefined;
@@ -320,44 +338,129 @@ function preparePageFont(
320
338
  }
321
339
 
322
340
  function renderPageFont(
323
- html: string,
341
+ document: DefaultTreeAdapterMap["document"],
324
342
  publicUrlPrefix: string,
325
343
  chars: string[],
326
344
  result: { hash: string; path: string; cacheHit: boolean },
327
- ): PageFontSubset {
345
+ ): Omit<PageFontSubset, "html"> {
328
346
  const filename = `page-${result.hash}.woff2`;
329
347
 
330
348
  const rel = `${publicUrlPrefix}${filename}`;
331
349
  // Keep Astro's broad unicode-range font out of the fallback stack; otherwise
332
350
  // a character outside this page's subset would download multiple 300 KB slices.
333
- 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);
334
356
  const fallbackStack =
335
357
  originalFontStack || '"Noto Sans SC","Noto Sans CJK SC",system-ui,sans-serif';
336
- 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);
337
362
  const codeStack = originalCodeStack
338
363
  ? `${originalCodeStack},"LXGW WenKai-Page Subset"`
339
364
  : `"LXGW WenKai-Page Subset",${fallbackStack}`;
340
365
  // The source font is regular; advertise a weight range so browsers synthesize
341
366
  // bold weights from these same glyphs instead of falling back to system CJK.
342
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}}`;
343
- const withoutBroadBodyFonts = html.replace(
344
- /@font-face\{[^}]*font-family:"LXGW WenKai-[^"]+"[^}]*\}/g,
345
- (face) => {
346
- const unicodeRange = face.match(/unicode-range:([^;}]+)/i)?.[1] ?? "";
347
- const hasCjkRange = unicodeRange.split(",").some((range) => {
348
- const start = range.match(/U\+([\da-f]+)/i)?.[1];
349
- if (!start) return false;
350
- const codePoint = Number.parseInt(start, 16);
351
- return codePoint >= 0x4e00 && codePoint <= 0x9fff;
352
- });
353
- return hasCjkRange ? "" : face;
354
- },
355
- );
356
- const outputHtml = withoutBroadBodyFonts.replace(
357
- /<\/head\s*>/i,
358
- `<style data-stalux-page-font>${css}</style></head>`,
359
- );
360
- 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
+ };
361
464
  }
362
465
 
363
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;