@xingwangzhe/stalux 1.35.11 → 1.35.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -11
- package/package.json +5 -2
- package/src/components/stalux/common/Typewriter.astro +32 -1
- package/src/components/stalux/posts/postContent.astro +7 -1
- package/src/index.ts +51 -104
- package/src/internal/bounded-tasks.ts +28 -0
- package/src/internal/compact-styles.ts +55 -13
- package/src/internal/font-slices.ts +150 -47
- package/src/internal/html-images.ts +2 -0
- package/src/internal/html-output.ts +98 -0
- package/src/internal/image-dimensions.ts +99 -74
- package/src/internal/page-output-cache.ts +26 -9
- package/src/internal/public-assets.ts +59 -0
- package/src/internal/runtime-cache-key.ts +14 -3
- package/src/internal/style-names.generated.ts +27 -26
- package/src/layouts/PostLayout.astro +1 -0
- package/src/layouts/Stalux.astro +3 -1
- package/src/pages/api/post.abbrlink.json.ts +0 -3
- package/src/pages/api/posts.json.ts +16 -19
- package/src/pages/openapi.json.ts +3 -44
- package/src/pages/posts/[post].md.ts +4 -40
- package/src/plugins/feature-flags.ts +8 -1
- package/src/scripts/navigation-font.ts +28 -0
- package/src/scripts/navigation.ts +4 -2
- package/src/scripts/page-runtime.ts +20 -6
- package/src/scripts/pagefind-api.ts +63 -0
- package/src/scripts/photoswipe.ts +13 -3
- package/src/scripts/random-posts.ts +12 -10
- package/src/scripts/search-dialog.ts +2 -1
- package/src/scripts/view-transitions.ts +12 -1
- package/src/scripts/webmcp.ts +397 -141
- package/src/scripts/word-list.ts +13 -3
- package/src/styles/base.css +7 -0
- package/src/styles/generated.css +1 -1
- package/src/utils/collections-stats.ts +9 -22
- package/src/utils/content-index.ts +13 -4
- package/src/utils/dayjs.ts +26 -11
- package/src/utils/feed.ts +30 -29
- package/src/utils/post-lookup.ts +51 -0
- package/src/utils/post-markdown.ts +31 -0
- package/src/utils/site-path.ts +4 -0
- package/src/utils/toc-scrollspy.ts +20 -6
- package/src/utils/word-count-utils.ts +45 -41
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.
|
|
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
|
|
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
|
|
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` |
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
38
|
+
writePageFontSubsetIntoDocumentAsync,
|
|
44
39
|
} from "./internal/font-slices";
|
|
45
40
|
import { findHtmlImages } from "./internal/html-images";
|
|
46
|
-
import {
|
|
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
|
-
|
|
427
|
-
|
|
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
|
|
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(
|
|
468
|
-
const
|
|
469
|
-
|
|
470
|
-
|
|
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
|
-
|
|
428
|
+
"",
|
|
478
429
|
output,
|
|
479
430
|
outDir,
|
|
480
431
|
linkedCssTextCache,
|
|
481
432
|
document,
|
|
482
433
|
dependencies,
|
|
483
434
|
);
|
|
484
|
-
const pageFont = await
|
|
485
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
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
|
-
|
|
240
|
-
|
|
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
|
-
|
|
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" ||
|