@xingwangzhe/stalux 1.35.11 → 1.35.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +24 -11
  2. package/package.json +5 -2
  3. package/src/components/stalux/common/Typewriter.astro +32 -1
  4. package/src/components/stalux/posts/postContent.astro +7 -1
  5. package/src/index.ts +51 -104
  6. package/src/internal/bounded-tasks.ts +28 -0
  7. package/src/internal/compact-styles.ts +55 -13
  8. package/src/internal/font-slices.ts +150 -47
  9. package/src/internal/html-images.ts +2 -0
  10. package/src/internal/html-output.ts +98 -0
  11. package/src/internal/image-dimensions.ts +99 -74
  12. package/src/internal/page-output-cache.ts +26 -9
  13. package/src/internal/public-assets.ts +59 -0
  14. package/src/internal/runtime-cache-key.ts +14 -3
  15. package/src/internal/style-names.generated.ts +27 -26
  16. package/src/layouts/PostLayout.astro +1 -0
  17. package/src/layouts/Stalux.astro +3 -1
  18. package/src/pages/api/post.abbrlink.json.ts +0 -3
  19. package/src/pages/api/posts.json.ts +16 -19
  20. package/src/pages/openapi.json.ts +3 -44
  21. package/src/pages/posts/[post].md.ts +4 -40
  22. package/src/plugins/feature-flags.ts +8 -1
  23. package/src/scripts/navigation-font.ts +28 -0
  24. package/src/scripts/navigation.ts +4 -2
  25. package/src/scripts/page-runtime.ts +20 -6
  26. package/src/scripts/pagefind-api.ts +63 -0
  27. package/src/scripts/photoswipe.ts +13 -3
  28. package/src/scripts/random-posts.ts +12 -10
  29. package/src/scripts/search-dialog.ts +2 -1
  30. package/src/scripts/view-transitions.ts +12 -1
  31. package/src/scripts/webmcp.ts +397 -141
  32. package/src/scripts/word-list.ts +13 -3
  33. package/src/styles/base.css +7 -0
  34. package/src/styles/generated.css +1 -1
  35. package/src/utils/collections-stats.ts +9 -22
  36. package/src/utils/content-index.ts +13 -4
  37. package/src/utils/dayjs.ts +26 -11
  38. package/src/utils/feed.ts +30 -29
  39. package/src/utils/post-lookup.ts +51 -0
  40. package/src/utils/post-markdown.ts +31 -0
  41. package/src/utils/site-path.ts +4 -0
  42. package/src/utils/toc-scrollspy.ts +20 -6
  43. package/src/utils/word-count-utils.ts +45 -41
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.11",
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",
@@ -94,7 +94,9 @@
94
94
  "dev:debug": "STALUX_DEBUG=1 astro dev --verbose",
95
95
  "build:debug": "STALUX_DEBUG=1 astro build --verbose && node scripts/verify-build.ts",
96
96
  "styles:build": "node scripts/build-styles.ts && node scripts/build-style-names.ts",
97
- "styles:watch": "node scripts/build-styles.ts --watch"
97
+ "styles:watch": "node scripts/build-styles.ts --watch",
98
+ "benchmark:performance": "node scripts/benchmark-performance.ts",
99
+ "benchmark:builds": "python3 scripts/benchmark-builds.py"
98
100
  },
99
101
  "dependencies": {
100
102
  "@astrojs/compiler": "^4.0.0",
@@ -115,6 +117,7 @@
115
117
  "astro-seo": "^1.2.0",
116
118
  "badge-maker": "^6.0.0",
117
119
  "css-tree": "^3.2.1",
120
+ "image-size": "^2.0.4",
118
121
  "magic-string": "^1.4.3",
119
122
  "pagefind": "^1.5.2",
120
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
@@ -1,3 +1,4 @@
1
+ import { forEachBounded } from "./internal/bounded-tasks.ts";
1
2
  import { compactStylesPlugin } from "./internal/compact-styles.ts";
2
3
  import { modernCssTargets } from "./internal/modern-css.ts";
3
4
  import { styleNames } from "./internal/style-names.generated.ts";
@@ -11,14 +12,7 @@ import { styleNames } from "./internal/style-names.generated.ts";
11
12
  * 基于 Astro 7.1.3 Integration API(astro:config:setup / injectRoute / injectScript)
12
13
  */
13
14
 
14
- import {
15
- copyFileSync,
16
- existsSync,
17
- promises as fs,
18
- mkdirSync,
19
- readdirSync,
20
- readFileSync,
21
- } from "node:fs";
15
+ import { copyFileSync, existsSync, promises as fs, readdirSync, readFileSync } from "node:fs";
22
16
  import { createRequire } from "node:module";
23
17
  import path from "node:path";
24
18
  import { fileURLToPath } from "node:url";
@@ -39,13 +33,16 @@ import { staluxComponentsAlias } from "./internal/components-plugin";
39
33
  import {
40
34
  clearPageFontSubsets,
41
35
  readLinkedStylesheetText,
36
+ removePageFontStyles,
42
37
  resolveFontInputs,
43
- writePageFontSubsetAsync,
38
+ writePageFontSubsetIntoDocumentAsync,
44
39
  } from "./internal/font-slices";
45
40
  import { findHtmlImages } from "./internal/html-images";
46
- import { addLocalImageDimensions, type ImageDimensions } from "./internal/image-dimensions";
41
+ import { createHtmlMutationEmitter } from "./internal/html-output";
42
+ import { type ImageDimensions, updateLocalImageDimensions } from "./internal/image-dimensions";
47
43
  import { createInjectedRoutes } from "./internal/injected-routes";
48
44
  import { createPageOutputCache } from "./internal/page-output-cache";
45
+ import { syncBackgroundSvgs, syncFontAssets } from "./internal/public-assets";
49
46
  import { createRuntimeCacheKey } from "./internal/runtime-cache-key";
50
47
  import {
51
48
  appendUniquePlugin,
@@ -84,60 +81,6 @@ function buildFontsConfig(codeNormal: string, codeItalic?: string) {
84
81
  ];
85
82
  }
86
83
 
87
- // ---------------------------------------------------------------------------
88
- // 背景 SVG 判别式同步
89
- // ---------------------------------------------------------------------------
90
-
91
- /**
92
- * 把主题包的 public/background/*.svg 同步到用户项目 public/background/。
93
- *
94
- * 判别式(非无条件覆盖):
95
- * - 同名文件:内容一致则跳过(幂等);内容不同才覆盖(主题更新了 SVG)
96
- * - 用户新增的文件:保留,不删除(避免误删用户自定义背景)
97
- * - 不做整目录 force 复制
98
- */
99
- function syncBackgroundSvgs(srcDir: string): { copied: number; skipped: number } {
100
- const srcBg = path.join(srcDir, "../public/background");
101
- if (!existsSync(srcBg)) return { copied: 0, skipped: 0 };
102
-
103
- const destBg = path.resolve(process.cwd(), "public", "background");
104
- mkdirSync(destBg, { recursive: true });
105
-
106
- let copied = 0;
107
- let skipped = 0;
108
- for (const name of readdirSync(srcBg)) {
109
- if (!name.endsWith(".svg")) continue;
110
- const srcFile = path.join(srcBg, name);
111
- const destFile = path.join(destBg, name);
112
- if (existsSync(destFile)) {
113
- const srcBuf = readFileSync(srcFile);
114
- const destBuf = readFileSync(destFile);
115
- if (srcBuf.equals(destBuf)) {
116
- skipped++; // 内容一致,跳过
117
- continue;
118
- }
119
- }
120
- copyFileSync(srcFile, destFile);
121
- copied++;
122
- }
123
- return { copied, skipped };
124
- }
125
-
126
- /** Sync packaged math fonts and licensing notices; Astro does not merge integration public directories. */
127
- function syncFontAssets(srcDir: string): void {
128
- const source = path.join(srcDir, "../public/fonts");
129
- if (!existsSync(source)) return;
130
- const destination = path.resolve(process.cwd(), "public/fonts");
131
- mkdirSync(destination, { recursive: true });
132
- for (const name of readdirSync(source)) {
133
- if (!/\.(?:woff2|txt)$/u.test(name)) continue;
134
- const from = path.join(source, name);
135
- const to = path.join(destination, name);
136
- if (existsSync(to) && readFileSync(from).equals(readFileSync(to))) continue;
137
- copyFileSync(from, to);
138
- }
139
- }
140
-
141
84
  // ---------------------------------------------------------------------------
142
85
  // 集成入口
143
86
  // ---------------------------------------------------------------------------
@@ -185,7 +128,6 @@ export function stalux(options: StaluxOptions = {}): AstroIntegration[] {
185
128
  }) => {
186
129
  const started = performance.now();
187
130
  const configLogger = logger.fork("stalux/config");
188
- const assetsLogger = logger.fork("stalux/assets");
189
131
  const routesLogger = logger.fork("stalux/routes");
190
132
  const markdownLogger = logger.fork("stalux/markdown");
191
133
  const fontsLogger = logger.fork("stalux/fonts");
@@ -197,6 +139,7 @@ export function stalux(options: StaluxOptions = {}): AstroIntegration[] {
197
139
  path.join(srcDir, "styles"),
198
140
  JSON.stringify([styleNames, modernCssTargets]) +
199
141
  readFileSync(path.join(srcDir, "internal/compact-styles.ts"), "utf8"),
142
+ path.resolve(fileURLToPath(config.root), opt.contentDir ?? "stalux", "config"),
200
143
  );
201
144
 
202
145
  // 1. 注入 Vite 别名 + 组件覆盖插件 + Vue 特性标记(Waline 依赖)
@@ -239,23 +182,6 @@ export function stalux(options: StaluxOptions = {}): AstroIntegration[] {
239
182
  injectScript("page-ssr", `import "${srcDir}/styles/generated.css";`);
240
183
  injectScript("page", `import "${srcDir}/scripts/core-runtime.ts";`);
241
184
 
242
- // 2.5 同步背景 SVG 到用户项目 public/background/
243
- // 纯客户端引入:SVG 以静态 URL(/background/pattern-*.svg)被 background.ts 硬编码引用,
244
- // 不经过 Astro 资源管线(import.meta.glob 会打上每次构建随机的 __ASTRO_ASSET_IMAGE__ 占位符,
245
- // 破坏增量构建依赖图 hash)。Astro 不会合并集成的 public 目录,这里显式同步一次。
246
- // 判别式:内容一致跳过(幂等),内容不同才覆盖,用户新增文件保留。
247
- try {
248
- syncFontAssets(srcDir);
249
- const { copied, skipped } = syncBackgroundSvgs(srcDir);
250
- if (copied > 0) {
251
- assetsLogger.info(`同步背景 SVG ${copied} 个(跳过 ${skipped} 个不变)`);
252
- } else if (skipped > 0) {
253
- assetsLogger.debug(`背景 SVG 无变化(跳过 ${skipped} 个)`);
254
- }
255
- } catch (error) {
256
- assetsLogger.warn(`同步背景 SVG 失败: ${describeError(error)}`);
257
- }
258
-
259
185
  // 3. 注入所有页面路由(仅插件模式,源码模式下使用文件路由)
260
186
  if (isPluginMode) {
261
187
  const routes = createInjectedRoutes(new URL(".", import.meta.url));
@@ -392,6 +318,32 @@ export function stalux(options: StaluxOptions = {}): AstroIntegration[] {
392
318
  configLogger.info(`initialized in ${(performance.now() - started).toFixed(1)}ms`);
393
319
  },
394
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
+
395
347
  "astro:build:done": async ({ dir, logger: rootLogger }) => {
396
348
  const logger = rootLogger.fork("stalux/build");
397
349
  const started = performance.now();
@@ -405,6 +357,7 @@ export function stalux(options: StaluxOptions = {}): AstroIntegration[] {
405
357
  const sourceDir = fileURLToPath(new URL(".", import.meta.url));
406
358
  const resolveModule = createRequire(import.meta.url).resolve;
407
359
  const parserEntry = resolveModule("parse5");
360
+ const imageSizeEntry = resolveModule("image-size");
408
361
  const nativeEntry = resolveModule("@xingwangzhe/cjk-font-split-native");
409
362
  const outputCache = await createPageOutputCache(
410
363
  path.resolve("node_modules/.astro/stalux-page-output"),
@@ -414,6 +367,7 @@ export function stalux(options: StaluxOptions = {}): AstroIntegration[] {
414
367
  "internal/font-slices.ts",
415
368
  "internal/image-dimensions.ts",
416
369
  "internal/html-images.ts",
370
+ "internal/html-output.ts",
417
371
  "internal/page-output-cache.ts",
418
372
  "internal/style-names.generated.ts",
419
373
  "plugins/feature-flags.ts",
@@ -421,11 +375,11 @@ export function stalux(options: StaluxOptions = {}): AstroIntegration[] {
421
375
  ].map((entry) => path.join(sourceDir, entry)),
422
376
  path.join(path.dirname(nativeEntry), "package.json"),
423
377
  path.resolve(path.dirname(parserEntry), "../package.json"),
378
+ path.resolve(path.dirname(imageSizeEntry), "../../package.json"),
424
379
  ],
425
380
  pageFontData?.fontBuffer,
426
- htmlFiles
427
- .filter((entry) => /\.(?:css|png|jpe?g|gif|webp)$/iu.test(entry))
428
- .map((entry) => path.join(outDir, entry)),
381
+ // Track referenced files regardless of extension, including SVG/AVIF.
382
+ htmlFiles.map((entry) => path.join(outDir, entry)),
429
383
  );
430
384
  let cacheHits = 0;
431
385
  let optimizedImagePages = 0;
@@ -439,11 +393,7 @@ export function stalux(options: StaluxOptions = {}): AstroIntegration[] {
439
393
  const processPage = async (entry: string) => {
440
394
  const output = path.join(outDir, entry);
441
395
  const before = await fs.readFile(output, "utf8");
442
- const withoutPageFont = before.replace(
443
- /<style data-stalux-page-font>[\s\S]*?<\/style>/gi,
444
- "",
445
- );
446
- const cached = await outputCache.get(entry, withoutPageFont);
396
+ const cached = await outputCache.get(entry, before);
447
397
  if (cached) {
448
398
  if (cached.font && pageFontData) {
449
399
  if (!referencedPageFonts.has(cached.font.filename)) {
@@ -464,34 +414,32 @@ export function stalux(options: StaluxOptions = {}): AstroIntegration[] {
464
414
  }
465
415
  let cachedFont: { filename: string; sourcePath: string } | undefined;
466
416
  const dependencies = new Set<string>();
467
- const document = parse(withoutPageFont, { sourceCodeLocationInfo: true });
468
- const images = findHtmlImages(withoutPageFont, document);
469
- let after = await addLocalImageDimensions(withoutPageFont, outDir, {
470
- 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, {
471
422
  cache: dimensionsCache,
472
423
  loadingPolicy: true,
473
424
  dependencies,
474
425
  });
475
426
  if (pageFontData) {
476
427
  const linkedStylesheetText = readLinkedStylesheetText(
477
- after,
428
+ "",
478
429
  output,
479
430
  outDir,
480
431
  linkedCssTextCache,
481
432
  document,
482
433
  dependencies,
483
434
  );
484
- const pageFont = await writePageFontSubsetAsync(
485
- after,
435
+ const pageFont = await writePageFontSubsetIntoDocumentAsync(
436
+ document,
486
437
  pageFontData.fontBuffer,
487
438
  pageFontData.cacheDir,
488
439
  "/_astro/fonts/",
489
440
  linkedStylesheetText,
490
- "",
491
- document,
492
441
  );
493
442
  if (pageFont) {
494
- after = pageFont.html;
495
443
  pageFontPages++;
496
444
  cachedFont = {
497
445
  filename: pageFont.filename,
@@ -506,13 +454,14 @@ export function stalux(options: StaluxOptions = {}): AstroIntegration[] {
506
454
  referencedPageFonts.add(pageFont.filename);
507
455
  }
508
456
  }
457
+ const after = emitHtml();
509
458
  if (after !== before) {
510
459
  await fs.writeFile(output, after);
511
460
  optimizedImagePages++;
512
461
  }
513
462
  await outputCache.set(
514
463
  entry,
515
- withoutPageFont,
464
+ before,
516
465
  {
517
466
  html: after,
518
467
  font: cachedFont,
@@ -521,9 +470,7 @@ export function stalux(options: StaluxOptions = {}): AstroIntegration[] {
521
470
  );
522
471
  };
523
472
  // Bound I/O concurrency without retaining the entire site's HTML in memory.
524
- for (let offset = 0; offset < pageEntries.length; offset += 4) {
525
- await Promise.all(pageEntries.slice(offset, offset + 4).map(processPage));
526
- }
473
+ await forEachBounded(pageEntries, 4, processPage);
527
474
  if (pageFontData) {
528
475
  clearPageFontSubsets(pageFontData.outputDir, referencedPageFonts);
529
476
  }
@@ -0,0 +1,28 @@
1
+ /** Work-conserving queue: one slow task never stalls the other workers. */
2
+ export async function forEachBounded<T>(
3
+ items: readonly T[],
4
+ concurrency: number,
5
+ task: (item: T) => Promise<unknown>,
6
+ ): Promise<void> {
7
+ if (!Number.isInteger(concurrency) || concurrency < 1)
8
+ throw new RangeError("Concurrency must be a positive integer");
9
+ let cursor = 0;
10
+ let failed = false;
11
+ const worker = async () => {
12
+ while (!failed && cursor < items.length) {
13
+ const item = items[cursor++] as T;
14
+ try {
15
+ await task(item);
16
+ } catch (error) {
17
+ failed = true;
18
+ throw error;
19
+ }
20
+ }
21
+ };
22
+ // Wait for active tasks to settle before returning a failure to the build.
23
+ const results = await Promise.allSettled(
24
+ Array.from({ length: Math.min(concurrency, items.length) }, worker),
25
+ );
26
+ const failure = results.find((result) => result.status === "rejected");
27
+ if (failure?.status === "rejected") throw failure.reason;
28
+ }
@@ -201,19 +201,27 @@ export function compactScript(code: string, names: Names = styleNames, expressio
201
201
  }>;
202
202
  const bodyStart = node.start + 1;
203
203
  const expressions = node.expressions as Array<{ start: number; end: number }>;
204
- const masked = new MagicString(source.slice(bodyStart, node.end - 1));
205
- for (const expr of expressions)
206
- masked.overwrite(
207
- expr.start - 2 - bodyStart,
208
- expr.end + 1 - bodyStart,
209
- "x".repeat(expr.end - expr.start + 3),
210
- );
211
- const html = masked.toString();
212
- if (html.includes("<")) {
204
+ if (segments.some((segment) => segment.value.raw.includes("<"))) {
205
+ const masked = new MagicString(source.slice(bodyStart, node.end - 1));
206
+ for (const expr of expressions)
207
+ masked.overwrite(
208
+ expr.start - 2 - bodyStart,
209
+ expr.end + 1 - bodyStart,
210
+ "x".repeat(expr.end - expr.start + 3),
211
+ );
212
+ const html = masked.toString();
213
213
  for (const edit of markupEdits(html, names)) {
214
214
  const start = bodyStart + edit.start;
215
215
  const end = bodyStart + edit.end;
216
- if (!expressions.some((expr) => start < expr.end + 1 && end > expr.start - 2))
216
+ let low = 0;
217
+ let high = expressions.length;
218
+ while (low < high) {
219
+ const middle = (low + high) >>> 1;
220
+ if ((expressions[middle]?.end ?? 0) + 1 <= start) low = middle + 1;
221
+ else high = middle;
222
+ }
223
+ const expressionAtEdit = expressions[low];
224
+ if (!expressionAtEdit || end <= expressionAtEdit.start - 2)
217
225
  output.overwrite(start, end, edit.text);
218
226
  }
219
227
  } else
@@ -233,11 +241,44 @@ export function compactScript(code: string, names: Names = styleNames, expressio
233
241
  return expression ? output.toString().slice(1, -1) : output.toString();
234
242
  }
235
243
 
244
+ /** Map requested UTF-8 byte offsets to UTF-16 without repeatedly decoding prefixes. */
245
+ export function mapUtf8Offsets(code: string, locations: Iterable<number>): Map<number, number> {
246
+ const requested = [...locations].sort((a, b) => a - b);
247
+ const offsets = new Map<number, number>();
248
+ let index = 0;
249
+ let bytes = 0;
250
+ let units = 0;
251
+ for (const char of code) {
252
+ const point = char.codePointAt(0) as number;
253
+ const width = point <= 0x7f ? 1 : point <= 0x7ff ? 2 : point <= 0xffff ? 3 : 4;
254
+ while (index < requested.length && (requested[index] as number) < bytes + width) {
255
+ const offset = requested[index++] as number;
256
+ offsets.set(offset, units + (offset > bytes ? 1 : 0));
257
+ }
258
+ bytes += width;
259
+ units += char.length;
260
+ }
261
+ while (index < requested.length) offsets.set(requested[index++] as number, units);
262
+ return offsets;
263
+ }
264
+
236
265
  export async function compactAstro(code: string, names: Names = styleNames): Promise<string> {
237
266
  const { ast } = await parseAstro(code);
238
267
  const output = new MagicString(code);
239
- const sourceBytes = Buffer.from(code);
240
- const offsetOf = (offset: number) => sourceBytes.subarray(0, offset).toString("utf8").length;
268
+ // Collect only requested locations: O(source + locations log locations),
269
+ // O(locations) storage instead of decoding every source prefix again.
270
+ const locations = new Set<number>([0]);
271
+ function collectLocations(node: typeof ast | (typeof ast.children)[number]) {
272
+ if (node.position?.start) locations.add(node.position.start.offset);
273
+ if (node.position?.end) locations.add(node.position.end.offset);
274
+ if ("attributes" in node)
275
+ for (const attr of node.attributes)
276
+ if (attr.position?.start) locations.add(attr.position.start.offset);
277
+ if ("children" in node) for (const child of node.children) collectLocations(child);
278
+ }
279
+ collectLocations(ast);
280
+ const offsets = mapUtf8Offsets(code, locations);
281
+ const offsetOf = (offset: number) => offsets.get(offset) ?? 0;
241
282
  function visit(node: typeof ast | (typeof ast.children)[number]) {
242
283
  if (node.type === "frontmatter") {
243
284
  const start = offsetOf(node.position?.start.offset ?? 0) + 3;
@@ -250,7 +291,8 @@ export async function compactAstro(code: string, names: Names = styleNames): Pro
250
291
  if (offset === undefined || attr.kind === "empty" || attr.kind === "spread")
251
292
  continue;
252
293
  const equals = code.indexOf("=", offset + attr.name.length);
253
- const start = equals + 1 + (code.slice(equals + 1).match(/^\s*/u)?.[0].length ?? 0);
294
+ let start = equals + 1;
295
+ while (start < code.length && /\s/u.test(code[start] as string)) start++;
254
296
  if (
255
297
  attr.kind === "expression" &&
256
298
  (attr.name === "class" ||