@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 CHANGED
@@ -93,7 +93,7 @@ bun run dev
93
93
  - 🗺️ **Sitemap** (bundled; `.md` source endpoints auto-filtered)
94
94
  - 🖼️ **PhotoSwipe** image lightbox
95
95
  - 📊 **Mermaid** diagrams and flowcharts
96
- Uses Merman through `@xingwangzhe/satteri-mermaid@0.8.1`, with the `dark` theme and a transparent canvas by default. The plugin supports 11 theme presets; see the [configuration reference](https://github.com/xingwangzhe/satteri-mermaid/blob/v0.8.1/docs/configuration.md). Rendering failures throw by default.
96
+ Uses Merman through `@xingwangzhe/satteri-mermaid@0.8.2`, with the `dark` theme and a transparent canvas by default. The plugin supports 11 theme presets; see the [configuration reference](https://github.com/xingwangzhe/satteri-mermaid/blob/v0.8.2/docs/configuration.md). Rendering failures throw by default.
97
97
  - 📐 **Math formula rendering** (Temml → MathML)
98
98
  - 💬 **Waline** comment system
99
99
  - 🤖 **LLM discovery files** (llms.txt / llms-full.txt)
@@ -112,11 +112,13 @@ Stalux ships with LXGW WenKai and a variable code font (Google Sans Code). Durin
112
112
 
113
113
  Arabic digits `0-9` are always reserved in every subset (`RESERVED_GLYPHS`), even on pages without any visible number. Digits produced at runtime by client scripts — clocks, counters, dates, injected values — never appear in the built HTML, so reserving them keeps numbers from falling back to a system font. Callers can append more characters through the optional `extraChars` argument of `writePageFontSubset`.
114
114
 
115
- The subset CSS uses an exact per-character `unicode-range` and `font-display: swap`, so visible text and the downloaded glyphs stay aligned. The variable code font continues to use Astro's local Fonts API.
115
+ The subset CSS uses an exact per-character `unicode-range` and `font-display: swap`, so visible text and the downloaded glyphs stay aligned. The variable code font continues to use Astro's local Fonts API. A same-origin font preload in the HTML head discovers the body subset early; consumers need no manual font links.
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.
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, image and font nodes are mutated on the same parse5 AST, then changed nodes are emitted once using source locations. Untouched scripts, comments, templates and subtrees retain their original source.
120
+
121
+ Local image dimensions use `image-size`, including SVG and AVIF support. Normal file reads inspect at most 512 KiB; a JPEG compatibility fallback may read the full file when the size marker is first or beyond that window. Explicit dimensions and remote images are preserved. Referenced assets are hashed as streams, and content changes at the same URL invalidate the page cache regardless of image extension.
120
122
 
121
123
  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
124
 
@@ -214,9 +216,7 @@ Full documentation and live demo: **[stalux.needhelp.icu](https://stalux.needhel
214
216
 
215
217
  ## 🤝 WebMCP / AI Agents
216
218
 
217
- Stalux ships built-in WebMCP tools that let AI agents (browsers with
218
- `document.modelContext`, e.g. Chrome's built-in Gemini or the [Ask nekuda](https://chromewebstore.google.com/detail/ask-nekuda/amochnnbmnkjjlblolhpddkokhnalkjp)
219
- extension) interact with your blog directly — **no backend required**.
219
+ Stalux exposes page-local WebMCP tools to compatible browser agents or extensions. All data comes from static build artifacts, so **no backend is required**. This is not an HTTP `/mcp` server that a desktop MCP client can connect to directly: ordinary static hosting cannot process MCP JSON-RPC POST requests. See the [WebMCP community-group draft](https://webmachinelearning.github.io/webmcp/).
220
220
 
221
221
  When a WebMCP-aware browser opens your site, these read-only tools are registered:
222
222
 
@@ -227,17 +227,22 @@ When a WebMCP-aware browser opens your site, these read-only tools are registere
227
227
  | `stalux_current_post` | Metadata of the post currently being viewed | `/api/posts.json` |
228
228
  | `stalux_random_post` | Pick a random post's metadata | `/api/posts.json` |
229
229
  | `stalux_search_posts` | Full-text search across posts | Pagefind `/pagefind/` |
230
- | `stalux_read_post` | Fetch a post's normalized Markdown | `/posts/{abbrlink}.md` |
230
+ | `stalux_read_post` | Read Markdown in chunks, or indexed plain text when not exported | Markdown / Pagefind |
231
231
  | `stalux_site_info` | Site title, URL, description + pointers to `llms.txt` / `llms-full.txt` | `site.yml` (build-time) |
232
232
 
233
- All tools are `readOnlyHint: true` — they never modify any state.
233
+ All tools are `readOnlyHint: true` — they do not write site data or initiate navigation.
234
+
235
+ `stalux_search_posts` merges title, tag, category and description matches from the published metadata with body hits from the [Pagefind JavaScript API](https://pagefind.app/docs/api/), using `meta.title` and `plain_excerpt`. Results include `abbrlink` and absolute URL, are deduplicated, and prioritize title matches, with up to 10 posts per call. If the full-text index is unavailable, available metadata matches are labeled `source: "metadata"`, `fullText: false`; no metadata matches yields an index-unavailable error instead of pretending the full-text search succeeded with zero hits. Highlighted HTML is not returned as the tool excerpt. Failed metadata and Pagefind module loads remain retryable.
236
+
237
+ `stalux_read_post` accepts `id`, `offset` (default 0), and `maxChars` (default and maximum 50000). Offsets use UTF-16 units and never split surrogate pairs. With `promote.export_md` enabled, the result has `format: "text/markdown"` and `markdown`. If exports are disabled or the Markdown file is unavailable, it reads Pagefind using the exact `post` filter and returns `format: "text/plain"` and `content`; Markdown syntax and non-indexed content are not preserved by this fallback. Continue with `nextOffset` until it is `null`; `totalChars` and `truncated` describe the range. Disabling both Markdown exports and Pagefind returns `CONTENT_UNAVAILABLE`.
238
+
239
+ Tools initialize the polyfill and register on `astro:page-load`, then unregister and stop the outgoing batch on `astro:before-swap`. Requests only read static resources; search terms are not sent to an external search service, no model is called, and no telemetry is added. The article filter ID and Markdown export flag are public HTML attributes used to select available sources. JSON, Markdown and OpenAPI exports are static files and do not advertise dynamic quotas or fixed `RateLimit-*` balances.
234
240
 
235
241
  **Enabling / disabling:** the tools follow the `conformance` setting in
236
242
  `stalux/config/ai-discovery.yml`. Set it to `disabled` to stop registering
237
243
  tools; `essential` / `recommended` / `complete` all enable them.
238
244
 
239
- **Browser support:** WebMCP is a W3C community-group draft (Chrome 149 Origin
240
- Trial). On browsers without a native `modelContext`, Stalux loads the
245
+ **Browser support:** WebMCP is a community-group draft; native support depends on browser and version. On browsers without a native `modelContext`, Stalux loads the
241
246
  [`@mcp-b/webmcp-polyfill`](https://www.npmjs.com/package/@mcp-b/webmcp-polyfill)
242
247
  so agents still work; the polyfill becomes a no-op once native support lands.
243
248
 
@@ -265,6 +270,14 @@ jobTitle: Software Engineer # Optional; emitted as JSON-LD Person.jobTitle
265
270
  This is a build-time metadata field. It does not create a contact endpoint or expose private information; only publish a role that is already intended to be public.
266
271
 
267
272
 
273
+ ## Loading and navigation stability
274
+
275
+ The root reserves scrollbar space and uses explicit line height. The homepage typewriter reserves the maximum wrapped height of its complete messages and keeps the text origin fixed; clocks reserve line boxes and use tabular digits. Existing typing, cursor and route animations remain enabled. No additional configuration is required.
276
+
277
+ A wrapper around Astro ClientRouter's `astro:before-preparation` loader warms the destination body font before the DOM swap without installing it on the old page. It waits at most one second; failures, timeout and superseding navigation do not block the route. Initial loads on slow networks may still show the system fallback. Content authors must reserve dimensions for remote images and third-party embeds. Reduced-motion and print behavior remain supported.
278
+
279
+ See [style maintenance](docs/STYLING.md) and [lifecycle and static interfaces](docs/RUNTIME.md). Astro development documentation is available through the [official MCP endpoint](https://mcp.docs.astro.build/mcp); `.codex/config.toml`, `.mcp.json` and `AGENTS.md` record the project setup. This development MCP is separate from the blog's browser WebMCP tools.
280
+
268
281
  ## Development diagnostics
269
282
 
270
283
  Stalux routes integration, content-loader and rendering diagnostics through Astro's official logger. Normal mode keeps summaries, warnings and errors. Verbose mode adds configuration/routes, component overrides, font cache, Markdown analysis/cache, content statistics, feeds, asset sync and Pagefind stages.
@@ -297,7 +310,7 @@ Keep the TypeScript 6 alias for `astro check`, upgrade Vitest and its coverage p
297
310
 
298
311
  Stalux precompiles its UnoCSS styles; consuming projects need no UnoCSS dependency or configuration. Shared CSS is a cacheable external stylesheet by default. Article/math, archive, category, tag and quote styles load with their components. Explicit Astro `build.inlineStylesheets: "always"` restores inline output. New utilities in consumer override components require the consumer’s own compiler. See [STYLING.md](./docs/STYLING.md) for maintenance and watch mode.
299
312
 
300
- Wide article pages use 280px sidebars within a layout up to 1920px wide, with 16px outer padding. Sidebars collapse below 1600px. UnoCSS remains the style builder; prose and integration styles use standard CSS, and build checks reject Tailwind variables and uncompiled directives.
313
+ Wide article pages use 280px sidebars within a layout up to 1544px wide, with 16px outer padding. Sidebars collapse below 1600px. UnoCSS remains the style builder; prose and integration styles use standard CSS, and build checks reject Tailwind variables and uncompiled directives.
301
314
 
302
315
  The friend links list is up to 1600px wide with 16px horizontal padding. Cards fill their grid columns, with one to five columns depending on the viewport.
303
316
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xingwangzhe/stalux",
3
- "version": "1.35.12",
3
+ "version": "1.35.13",
4
4
  "description": "A powerful, modern Astro blog theme — use as template or install as plugin",
5
5
  "keywords": [
6
6
  "astro",
@@ -117,6 +117,7 @@
117
117
  "astro-seo": "^1.2.0",
118
118
  "badge-maker": "^6.0.0",
119
119
  "css-tree": "^3.2.1",
120
+ "image-size": "^2.0.4",
120
121
  "magic-string": "^1.4.3",
121
122
  "pagefind": "^1.5.2",
122
123
  "parse5": "^8.0.1",
@@ -12,6 +12,11 @@ const texts = normalizeMessages(messages);
12
12
  {texts.length > 0 && (
13
13
  <div class={className} data-stalux-typewriter data-messages={JSON.stringify(texts)}>
14
14
  <span class="stalux-typewriter-accessible">{texts.join(", ")}</span>
15
+ <span class="stalux-typewriter-reserve" aria-hidden="true">
16
+ {texts.map((text) => (
17
+ <span>{text}</span>
18
+ ))}
19
+ </span>
15
20
  <span class="stalux-typewriter-text" data-typewriter-text aria-hidden="true">
16
21
  {texts[0]}
17
22
  </span>
@@ -37,9 +42,34 @@ registerPageLifecycle("typewriter", mountTypewriters);
37
42
  white-space: nowrap;
38
43
  border: 0;
39
44
  }
45
+ [data-stalux-typewriter] {
46
+ display: grid;
47
+ grid-template-columns: minmax(0, 1fr);
48
+ line-height: 1.25;
49
+ }
50
+ .stalux-typewriter-reserve,
40
51
  .stalux-typewriter-text {
52
+ grid-area: 1 / 1;
53
+ width: 100%;
54
+ min-width: 0;
55
+ align-self: center;
41
56
  overflow-wrap: anywhere;
42
57
  }
58
+ .stalux-typewriter-text {
59
+ align-self: stretch;
60
+ }
61
+ .stalux-typewriter-reserve {
62
+ display: grid;
63
+ visibility: hidden;
64
+ pointer-events: none;
65
+ user-select: none;
66
+ }
67
+ .stalux-typewriter-reserve > span {
68
+ grid-area: 1 / 1;
69
+ }
70
+ .stalux-typewriter-reserve > span::after {
71
+ content: "\00a0";
72
+ }
43
73
  .stalux-typewriter-text::after {
44
74
  content: "";
45
75
  display: inline-block;
@@ -71,7 +101,8 @@ registerPageLifecycle("typewriter", mountTypewriters);
71
101
  }
72
102
  }
73
103
  @media print {
74
- .stalux-typewriter-text {
104
+ .stalux-typewriter-text,
105
+ .stalux-typewriter-reserve {
75
106
  display: none;
76
107
  }
77
108
  .stalux-typewriter-accessible {
@@ -10,6 +10,7 @@ import { toMachineDateTime } from "@utils/semantic-time";
10
10
  import ArticleSummary from "./ArticleSummary.astro";
11
11
 
12
12
  interface Props {
13
+ postId?: string | number;
13
14
  title: string;
14
15
  description?: string;
15
16
  transitionName?: string;
@@ -107,7 +108,12 @@ const mdSourceUrl = exportMd ? `${Astro.url.pathname.replace(/\/$/, "")}.md` : "
107
108
  <ArticleSummary description={props.description} label={t("post.summary")} />
108
109
 
109
110
  <!-- Article Body -->
110
- <section class="stalux-post-body stalux-prose" data-pagefind-body>
111
+ <section
112
+ class="stalux-post-body stalux-prose"
113
+ data-pagefind-body
114
+ data-stalux-post-id={props.postId === undefined ? undefined : String(props.postId)}
115
+ data-pagefind-filter={props.postId === undefined ? undefined : "post[data-stalux-post-id]"}
116
+ >
111
117
  <slot />
112
118
  </section>
113
119
  </article>
package/src/index.ts CHANGED
@@ -12,14 +12,7 @@ import { styleNames } from "./internal/style-names.generated.ts";
12
12
  * 基于 Astro 7.1.3 Integration API(astro:config:setup / injectRoute / injectScript)
13
13
  */
14
14
 
15
- import {
16
- copyFileSync,
17
- existsSync,
18
- promises as fs,
19
- mkdirSync,
20
- readdirSync,
21
- readFileSync,
22
- } from "node:fs";
15
+ import { copyFileSync, existsSync, promises as fs, readdirSync, readFileSync } from "node:fs";
23
16
  import { createRequire } from "node:module";
24
17
  import path from "node:path";
25
18
  import { fileURLToPath } from "node:url";
@@ -40,13 +33,16 @@ import { staluxComponentsAlias } from "./internal/components-plugin";
40
33
  import {
41
34
  clearPageFontSubsets,
42
35
  readLinkedStylesheetText,
36
+ removePageFontStyles,
43
37
  resolveFontInputs,
44
- writePageFontSubsetAsync,
38
+ writePageFontSubsetIntoDocumentAsync,
45
39
  } from "./internal/font-slices";
46
40
  import { findHtmlImages } from "./internal/html-images";
47
- import { addLocalImageDimensions, type ImageDimensions } from "./internal/image-dimensions";
41
+ import { createHtmlMutationEmitter } from "./internal/html-output";
42
+ import { type ImageDimensions, updateLocalImageDimensions } from "./internal/image-dimensions";
48
43
  import { createInjectedRoutes } from "./internal/injected-routes";
49
44
  import { createPageOutputCache } from "./internal/page-output-cache";
45
+ import { syncBackgroundSvgs, syncFontAssets } from "./internal/public-assets";
50
46
  import { createRuntimeCacheKey } from "./internal/runtime-cache-key";
51
47
  import {
52
48
  appendUniquePlugin,
@@ -85,60 +81,6 @@ function buildFontsConfig(codeNormal: string, codeItalic?: string) {
85
81
  ];
86
82
  }
87
83
 
88
- // ---------------------------------------------------------------------------
89
- // 背景 SVG 判别式同步
90
- // ---------------------------------------------------------------------------
91
-
92
- /**
93
- * 把主题包的 public/background/*.svg 同步到用户项目 public/background/。
94
- *
95
- * 判别式(非无条件覆盖):
96
- * - 同名文件:内容一致则跳过(幂等);内容不同才覆盖(主题更新了 SVG)
97
- * - 用户新增的文件:保留,不删除(避免误删用户自定义背景)
98
- * - 不做整目录 force 复制
99
- */
100
- function syncBackgroundSvgs(srcDir: string): { copied: number; skipped: number } {
101
- const srcBg = path.join(srcDir, "../public/background");
102
- if (!existsSync(srcBg)) return { copied: 0, skipped: 0 };
103
-
104
- const destBg = path.resolve(process.cwd(), "public", "background");
105
- mkdirSync(destBg, { recursive: true });
106
-
107
- let copied = 0;
108
- let skipped = 0;
109
- for (const name of readdirSync(srcBg)) {
110
- if (!name.endsWith(".svg")) continue;
111
- const srcFile = path.join(srcBg, name);
112
- const destFile = path.join(destBg, name);
113
- if (existsSync(destFile)) {
114
- const srcBuf = readFileSync(srcFile);
115
- const destBuf = readFileSync(destFile);
116
- if (srcBuf.equals(destBuf)) {
117
- skipped++; // 内容一致,跳过
118
- continue;
119
- }
120
- }
121
- copyFileSync(srcFile, destFile);
122
- copied++;
123
- }
124
- return { copied, skipped };
125
- }
126
-
127
- /** Sync packaged math fonts and licensing notices; Astro does not merge integration public directories. */
128
- function syncFontAssets(srcDir: string): void {
129
- const source = path.join(srcDir, "../public/fonts");
130
- if (!existsSync(source)) return;
131
- const destination = path.resolve(process.cwd(), "public/fonts");
132
- mkdirSync(destination, { recursive: true });
133
- for (const name of readdirSync(source)) {
134
- if (!/\.(?:woff2|txt)$/u.test(name)) continue;
135
- const from = path.join(source, name);
136
- const to = path.join(destination, name);
137
- if (existsSync(to) && readFileSync(from).equals(readFileSync(to))) continue;
138
- copyFileSync(from, to);
139
- }
140
- }
141
-
142
84
  // ---------------------------------------------------------------------------
143
85
  // 集成入口
144
86
  // ---------------------------------------------------------------------------
@@ -186,7 +128,6 @@ export function stalux(options: StaluxOptions = {}): AstroIntegration[] {
186
128
  }) => {
187
129
  const started = performance.now();
188
130
  const configLogger = logger.fork("stalux/config");
189
- const assetsLogger = logger.fork("stalux/assets");
190
131
  const routesLogger = logger.fork("stalux/routes");
191
132
  const markdownLogger = logger.fork("stalux/markdown");
192
133
  const fontsLogger = logger.fork("stalux/fonts");
@@ -198,6 +139,7 @@ export function stalux(options: StaluxOptions = {}): AstroIntegration[] {
198
139
  path.join(srcDir, "styles"),
199
140
  JSON.stringify([styleNames, modernCssTargets]) +
200
141
  readFileSync(path.join(srcDir, "internal/compact-styles.ts"), "utf8"),
142
+ path.resolve(fileURLToPath(config.root), opt.contentDir ?? "stalux", "config"),
201
143
  );
202
144
 
203
145
  // 1. 注入 Vite 别名 + 组件覆盖插件 + Vue 特性标记(Waline 依赖)
@@ -240,23 +182,6 @@ export function stalux(options: StaluxOptions = {}): AstroIntegration[] {
240
182
  injectScript("page-ssr", `import "${srcDir}/styles/generated.css";`);
241
183
  injectScript("page", `import "${srcDir}/scripts/core-runtime.ts";`);
242
184
 
243
- // 2.5 同步背景 SVG 到用户项目 public/background/
244
- // 纯客户端引入:SVG 以静态 URL(/background/pattern-*.svg)被 background.ts 硬编码引用,
245
- // 不经过 Astro 资源管线(import.meta.glob 会打上每次构建随机的 __ASTRO_ASSET_IMAGE__ 占位符,
246
- // 破坏增量构建依赖图 hash)。Astro 不会合并集成的 public 目录,这里显式同步一次。
247
- // 判别式:内容一致跳过(幂等),内容不同才覆盖,用户新增文件保留。
248
- try {
249
- syncFontAssets(srcDir);
250
- const { copied, skipped } = syncBackgroundSvgs(srcDir);
251
- if (copied > 0) {
252
- assetsLogger.info(`同步背景 SVG ${copied} 个(跳过 ${skipped} 个不变)`);
253
- } else if (skipped > 0) {
254
- assetsLogger.debug(`背景 SVG 无变化(跳过 ${skipped} 个)`);
255
- }
256
- } catch (error) {
257
- assetsLogger.warn(`同步背景 SVG 失败: ${describeError(error)}`);
258
- }
259
-
260
185
  // 3. 注入所有页面路由(仅插件模式,源码模式下使用文件路由)
261
186
  if (isPluginMode) {
262
187
  const routes = createInjectedRoutes(new URL(".", import.meta.url));
@@ -393,6 +318,32 @@ export function stalux(options: StaluxOptions = {}): AstroIntegration[] {
393
318
  configLogger.info(`initialized in ${(performance.now() - started).toFixed(1)}ms`);
394
319
  },
395
320
 
321
+ // Read final paths after every integration has finished updating configuration.
322
+ // Public assets must exist before Astro creates its Vite server/build asset pipeline.
323
+ "astro:config:done": ({ config, logger }) => {
324
+ const srcDir = fileURLToPath(new URL(".", import.meta.url));
325
+ const publicDir = fileURLToPath(config.publicDir);
326
+ const assetsLogger = logger.fork("stalux/assets");
327
+ if (pageFontData) {
328
+ pageFontData.outputDir = path.join(
329
+ fileURLToPath(config.outDir),
330
+ "_astro",
331
+ "fonts",
332
+ );
333
+ }
334
+ try {
335
+ syncFontAssets(srcDir, publicDir);
336
+ const { copied, skipped } = syncBackgroundSvgs(srcDir, publicDir);
337
+ if (copied > 0) {
338
+ assetsLogger.info(`同步背景 SVG ${copied} 个(跳过 ${skipped} 个不变)`);
339
+ } else if (skipped > 0) {
340
+ assetsLogger.debug(`背景 SVG 无变化(跳过 ${skipped} 个)`);
341
+ }
342
+ } catch (error) {
343
+ assetsLogger.warn(`同步主题静态资源失败: ${describeError(error)}`);
344
+ }
345
+ },
346
+
396
347
  "astro:build:done": async ({ dir, logger: rootLogger }) => {
397
348
  const logger = rootLogger.fork("stalux/build");
398
349
  const started = performance.now();
@@ -406,6 +357,7 @@ export function stalux(options: StaluxOptions = {}): AstroIntegration[] {
406
357
  const sourceDir = fileURLToPath(new URL(".", import.meta.url));
407
358
  const resolveModule = createRequire(import.meta.url).resolve;
408
359
  const parserEntry = resolveModule("parse5");
360
+ const imageSizeEntry = resolveModule("image-size");
409
361
  const nativeEntry = resolveModule("@xingwangzhe/cjk-font-split-native");
410
362
  const outputCache = await createPageOutputCache(
411
363
  path.resolve("node_modules/.astro/stalux-page-output"),
@@ -415,6 +367,7 @@ export function stalux(options: StaluxOptions = {}): AstroIntegration[] {
415
367
  "internal/font-slices.ts",
416
368
  "internal/image-dimensions.ts",
417
369
  "internal/html-images.ts",
370
+ "internal/html-output.ts",
418
371
  "internal/page-output-cache.ts",
419
372
  "internal/style-names.generated.ts",
420
373
  "plugins/feature-flags.ts",
@@ -422,11 +375,11 @@ export function stalux(options: StaluxOptions = {}): AstroIntegration[] {
422
375
  ].map((entry) => path.join(sourceDir, entry)),
423
376
  path.join(path.dirname(nativeEntry), "package.json"),
424
377
  path.resolve(path.dirname(parserEntry), "../package.json"),
378
+ path.resolve(path.dirname(imageSizeEntry), "../../package.json"),
425
379
  ],
426
380
  pageFontData?.fontBuffer,
427
- htmlFiles
428
- .filter((entry) => /\.(?:css|png|jpe?g|gif|webp)$/iu.test(entry))
429
- .map((entry) => path.join(outDir, entry)),
381
+ // Track referenced files regardless of extension, including SVG/AVIF.
382
+ htmlFiles.map((entry) => path.join(outDir, entry)),
430
383
  );
431
384
  let cacheHits = 0;
432
385
  let optimizedImagePages = 0;
@@ -440,11 +393,7 @@ export function stalux(options: StaluxOptions = {}): AstroIntegration[] {
440
393
  const processPage = async (entry: string) => {
441
394
  const output = path.join(outDir, entry);
442
395
  const before = await fs.readFile(output, "utf8");
443
- const withoutPageFont = before.replace(
444
- /<style data-stalux-page-font>[\s\S]*?<\/style>/gi,
445
- "",
446
- );
447
- const cached = await outputCache.get(entry, withoutPageFont);
396
+ const cached = await outputCache.get(entry, before);
448
397
  if (cached) {
449
398
  if (cached.font && pageFontData) {
450
399
  if (!referencedPageFonts.has(cached.font.filename)) {
@@ -465,34 +414,32 @@ export function stalux(options: StaluxOptions = {}): AstroIntegration[] {
465
414
  }
466
415
  let cachedFont: { filename: string; sourcePath: string } | undefined;
467
416
  const dependencies = new Set<string>();
468
- const document = parse(withoutPageFont, { sourceCodeLocationInfo: true });
469
- const images = findHtmlImages(withoutPageFont, document);
470
- let after = await addLocalImageDimensions(withoutPageFont, outDir, {
471
- images,
417
+ const document = parse(before, { sourceCodeLocationInfo: true });
418
+ const emitHtml = createHtmlMutationEmitter(before, document);
419
+ removePageFontStyles(document);
420
+ const images = findHtmlImages(before, document);
421
+ await updateLocalImageDimensions(images, outDir, {
472
422
  cache: dimensionsCache,
473
423
  loadingPolicy: true,
474
424
  dependencies,
475
425
  });
476
426
  if (pageFontData) {
477
427
  const linkedStylesheetText = readLinkedStylesheetText(
478
- after,
428
+ "",
479
429
  output,
480
430
  outDir,
481
431
  linkedCssTextCache,
482
432
  document,
483
433
  dependencies,
484
434
  );
485
- const pageFont = await writePageFontSubsetAsync(
486
- after,
435
+ const pageFont = await writePageFontSubsetIntoDocumentAsync(
436
+ document,
487
437
  pageFontData.fontBuffer,
488
438
  pageFontData.cacheDir,
489
439
  "/_astro/fonts/",
490
440
  linkedStylesheetText,
491
- "",
492
- document,
493
441
  );
494
442
  if (pageFont) {
495
- after = pageFont.html;
496
443
  pageFontPages++;
497
444
  cachedFont = {
498
445
  filename: pageFont.filename,
@@ -507,13 +454,14 @@ export function stalux(options: StaluxOptions = {}): AstroIntegration[] {
507
454
  referencedPageFonts.add(pageFont.filename);
508
455
  }
509
456
  }
457
+ const after = emitHtml();
510
458
  if (after !== before) {
511
459
  await fs.writeFile(output, after);
512
460
  optimizedImagePages++;
513
461
  }
514
462
  await outputCache.set(
515
463
  entry,
516
- withoutPageFont,
464
+ before,
517
465
  {
518
466
  html: after,
519
467
  font: cachedFont,