@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.
- package/README.md +24 -11
- package/package.json +5 -2
- package/src/components/stalux/common/Typewriter.astro +32 -1
- package/src/components/stalux/posts/postContent.astro +7 -1
- package/src/index.ts +51 -104
- package/src/internal/bounded-tasks.ts +28 -0
- package/src/internal/compact-styles.ts +55 -13
- package/src/internal/font-slices.ts +150 -47
- package/src/internal/html-images.ts +2 -0
- package/src/internal/html-output.ts +98 -0
- package/src/internal/image-dimensions.ts +99 -74
- package/src/internal/page-output-cache.ts +26 -9
- package/src/internal/public-assets.ts +59 -0
- package/src/internal/runtime-cache-key.ts +14 -3
- package/src/internal/style-names.generated.ts +27 -26
- package/src/layouts/PostLayout.astro +1 -0
- package/src/layouts/Stalux.astro +3 -1
- package/src/pages/api/post.abbrlink.json.ts +0 -3
- package/src/pages/api/posts.json.ts +16 -19
- package/src/pages/openapi.json.ts +3 -44
- package/src/pages/posts/[post].md.ts +4 -40
- package/src/plugins/feature-flags.ts +8 -1
- package/src/scripts/navigation-font.ts +28 -0
- package/src/scripts/navigation.ts +4 -2
- package/src/scripts/page-runtime.ts +20 -6
- package/src/scripts/pagefind-api.ts +63 -0
- package/src/scripts/photoswipe.ts +13 -3
- package/src/scripts/random-posts.ts +12 -10
- package/src/scripts/search-dialog.ts +2 -1
- package/src/scripts/view-transitions.ts +12 -1
- package/src/scripts/webmcp.ts +397 -141
- package/src/scripts/word-list.ts +13 -3
- package/src/styles/base.css +7 -0
- package/src/styles/generated.css +1 -1
- package/src/utils/collections-stats.ts +9 -22
- package/src/utils/content-index.ts +13 -4
- package/src/utils/dayjs.ts +26 -11
- package/src/utils/feed.ts +30 -29
- package/src/utils/post-lookup.ts +51 -0
- package/src/utils/post-markdown.ts +31 -0
- package/src/utils/site-path.ts +4 -0
- package/src/utils/toc-scrollspy.ts +20 -6
- 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 {
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
282
|
+
const input = preparePageFont(fontBuffer, linkedStylesheetText, extraChars, document);
|
|
267
283
|
if (!input) return undefined;
|
|
268
|
-
|
|
269
|
-
|
|
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(
|
|
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
|
|
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
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
collectBodyText(body,
|
|
308
|
-
const
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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 {
|
|
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
|
-
/**
|
|
8
|
-
|
|
9
|
-
if (data.length
|
|
10
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
data
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
)
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
76
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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;
|