@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.
- package/README.md +24 -11
- package/package.json +2 -1
- package/src/components/stalux/common/Typewriter.astro +32 -1
- package/src/components/stalux/posts/postContent.astro +7 -1
- package/src/index.ts +49 -101
- package/src/internal/font-slices.ts +125 -31
- 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 +10 -2
- 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 +4 -6
- package/src/pages/openapi.json.ts +3 -44
- package/src/pages/posts/[post].md.ts +4 -40
- package/src/scripts/navigation-font.ts +28 -0
- package/src/scripts/page-runtime.ts +20 -6
- package/src/scripts/pagefind-api.ts +63 -0
- package/src/scripts/view-transitions.ts +12 -1
- package/src/scripts/webmcp.ts +385 -136
- package/src/styles/base.css +7 -0
- package/src/styles/generated.css +1 -1
- package/src/utils/post-lookup.ts +23 -0
- package/src/utils/post-markdown.ts +31 -0
- package/src/utils/site-path.ts +4 -0
|
@@ -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
|
|
@@ -273,14 +279,15 @@ export function writePageFontSubset(
|
|
|
273
279
|
extraChars = "",
|
|
274
280
|
document = parse(html),
|
|
275
281
|
): PageFontSubset | undefined {
|
|
276
|
-
const input = preparePageFont(
|
|
282
|
+
const input = preparePageFont(fontBuffer, linkedStylesheetText, extraChars, document);
|
|
277
283
|
if (!input) return undefined;
|
|
278
|
-
|
|
279
|
-
|
|
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(
|
|
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
|
|
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
|
|
316
|
-
addGlyphs(cssGeneratedText(
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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 {
|
|
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;
|
|
@@ -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 =
|
|
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
|
+
}
|