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