@xingwangzhe/stalux 1.35.15 → 1.36.0

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
@@ -14,6 +14,10 @@ All HTML routes include a circular back-to-top button in the footer. It appears
14
14
 
15
15
  The homepage typewriter is built into Stalux and continues to use the `items` string list in `stalux/config/typetexts.yml`, with no new configuration. Defaults are 100ms per typed grapheme, 33ms per deleted grapheme, a 1500ms hold, and a 500ms pause after deletion. It pauses outside the viewport or in background tabs and cleans up on soft navigation. Reduced motion displays whole messages without a blinking cursor. Without JavaScript, the first message remains visible; screen readers and print receive the complete list.
16
16
 
17
+ Standalone images written as `![Image description](url)` receive a `figure` and `figcaption` through a build-time satteri HAST plugin. Captions appear as small, centered gray text below the image, preserving image alt text and the PhotoSwipe lightbox. No configuration or browser script is required. Empty descriptions, inline images, multiple images in one paragraph, and existing figures are left unchanged. Captions are plain text with automatic escaping.
18
+
19
+ Code blocks default to the single `dark-plus` theme, with system color-scheme switching and theme selectors disabled. To opt into another theme or switching behavior, explicitly override `themes`, `useDarkModeMediaQuery`, and `themeCssSelector` through `expressiveCode`. This only changes generated code styles and adds no telemetry.
20
+
17
21
  ---
18
22
 
19
23
  ## 🚀 Quick Start
@@ -57,7 +61,7 @@ integrations: [
57
61
  stalux({
58
62
  contentDir: "stalux",
59
63
  sitemap: { filter: (page) => page.startsWith("https://example.com/posts/") },
60
- expressiveCode: { themes: ["dark-plus", "github-light"] },
64
+ expressiveCode: { themes: ["dark-plus"], useDarkModeMediaQuery: false },
61
65
  }),
62
66
  ];
63
67
  ```
@@ -183,13 +187,11 @@ id: head
183
187
  bingClarityId: "YOUR_CLARITY_PROJECT_ID"
184
188
  ```
185
189
 
186
- > The Partytown migration in this branch is an unpublished candidate. Representative route measurements show lower TBT, but LCP regresses on the homepage and category page, so performance validation is incomplete. The following describes the candidate implementation.
187
-
188
- `stalux()` includes Partytown. The Google Analytics, Clarity, and Umami scripts run in a Web Worker without an additional consumer integration. Page tracking follows `astro:page-load`; browsers with `Element.moveBefore()` preserve the worker during navigation. Vercount loads asynchronously in the main window, avoiding worker startup and DOM bridge delays for visible counters. It runs once after each `astro:page-load` to bind incoming page counters and use the current URL and first-party visitor cookie.
190
+ Google Analytics, Clarity, and Umami use low-priority native asynchronous scripts. Each SDK loads once per session, and failures do not block reading or navigation. Vercount rebinds visible counters after each `astro:page-load`. GA sends explicit pageviews. Umami disables automatic pageviews while retaining click tracking, capturing each page's URL, title, and referrer before the SDK resolves so rapid navigation cannot mix visits. An existing legacy Partytown session performs one full reload when it encounters the new runtime to prevent duplicate listeners and tracking.
189
191
 
190
192
  For Google Analytics, turn off **Page changes based on browser history events** under **Admin → Data streams → Enhanced measurement → Page views → Advanced settings**. Stalux sets `send_page_view: false` and sends the URL and title for each navigation explicitly. GA's history tracking must also be disabled in the property to prevent duplicate soft-navigation pageviews. Other enhanced measurement options can remain enabled. See [Google's pageview documentation](https://developers.google.com/analytics/devguides/collection/ga4/views).
191
193
 
192
- `bingClarityId` is the historical Stalux field name for a Microsoft Clarity Project ID. Get the ID from the Clarity project under **Settings → Setup → Get tracking code**. Stalux loads the tracking code through Partytown and keeps one loader in browsers that support preserving the worker. Do not install the same project again through `anyhead`, a tag manager, or another plugin.
194
+ `bingClarityId` is the historical Stalux field name for a Microsoft Clarity Project ID. Get the ID from the Clarity project under **Settings → Setup → Get tracking code**. Stalux loads the tracking code asynchronously and preserves one loader across soft navigation. The SDK handles history navigation itself. Do not install the same project again through `anyhead`, a tag manager, or another plugin.
193
195
 
194
196
  The Project ID is a public browser identifier. Never put a Clarity Data Export API token in this YAML or in client-side code. If the site uses a strict CSP, consent banner, or CMP, configure those host-site policies and signals separately; this theme does not make legal compliance decisions for the site.
195
197
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xingwangzhe/stalux",
3
- "version": "1.35.15",
3
+ "version": "1.36.0",
4
4
  "description": "A powerful, modern Astro blog theme — use as template or install as plugin",
5
5
  "keywords": [
6
6
  "astro",
@@ -26,6 +26,7 @@
26
26
  "stalux": ".release/cli.mjs"
27
27
  },
28
28
  "files": [
29
+ "src/env.d.ts",
29
30
  "src/index.ts",
30
31
  "src/config.ts",
31
32
  "src/expressive-code.ts",
@@ -101,7 +102,6 @@
101
102
  "dependencies": {
102
103
  "@astrojs/compiler": "^4.0.0",
103
104
  "@astrojs/markdown-satteri": "^0.4.3",
104
- "@astrojs/partytown": "^2.1.8",
105
105
  "@astrojs/rss": "^4.0.19",
106
106
  "@astrojs/sitemap": "^3.7.4",
107
107
  "@expressive-code/plugin-line-numbers": "^0.44.2",
@@ -66,13 +66,7 @@ const archiveYears = [...archivesByYearMonth.entries()]
66
66
  </div>
67
67
  </div>
68
68
  {archiveYear.months.map(({ month, posts: monthPosts }) => (
69
- <div
70
- class="stalux-archive-month-section"
71
- data-archive-month
72
- style={{
73
- "--archive-estimate": `${Math.ceil(monthPosts.length / 12) * 12 * 48 + 40}px`,
74
- }}
75
- >
69
+ <div class="stalux-archive-month-section" data-archive-month>
76
70
  <h3 class="stalux-archive-month-title">
77
71
  {t("archives.month", { n: month })}
78
72
  </h3>
@@ -80,12 +74,7 @@ const archiveYears = [...archivesByYearMonth.entries()]
80
74
  { length: Math.ceil(monthPosts.length / 12) },
81
75
  (_, index) => monthPosts.slice(index * 12, (index + 1) * 12),
82
76
  ).map((group) => (
83
- <ul
84
- class="stalux-archive-post-list"
85
- style={{
86
- "--archive-group-estimate": `${group.length * 48}px`,
87
- }}
88
- >
77
+ <ul class="stalux-archive-post-list">
89
78
  {group.map((post) => (
90
79
  <li class="stalux-archive-post-item">
91
80
  <time
@@ -2,13 +2,13 @@
2
2
  import { Font } from "astro:assets";
3
3
  import { getCollection } from "astro:content";
4
4
  import { ClientRouter } from "astro:transitions";
5
+ // Emit a stable asset even while the search component JavaScript stays lazy.
6
+ // The closed search dialog does not need to block the page's first paint.
7
+ import pagefindStyles from "virtual:stalux-search-style";
5
8
  import BingClarity from "@components/stalux/analytics/clarity.astro";
6
9
  import GoogleAnalytics from "@components/stalux/analytics/google.astro";
7
10
  import Umami from "@components/stalux/analytics/umami.astro";
8
11
  import VerCount from "@components/stalux/common/verCount.astro";
9
- // Emit a stable asset even while the search component JavaScript stays lazy.
10
- // The closed search dialog does not need to block the page's first paint.
11
- import pagefindStyles from "@pagefind/component-ui/css?url";
12
12
  import {
13
13
  getAuthorData,
14
14
  getHeadData,
@@ -118,4 +118,10 @@ const mdSourceUrl = exportMd ? `${Astro.url.pathname.replace(/\/$/, "")}.md` : "
118
118
  </section>
119
119
  </article>
120
120
 
121
- <script src="../../../scripts/photoswipe.ts"></script>
121
+ <script>
122
+ import "../../../scripts/photoswipe";
123
+ import { mountCodeAccessibility } from "../../../scripts/code-accessibility";
124
+ import { registerPageLifecycle } from "../../../scripts/page-runtime";
125
+
126
+ registerPageLifecycle("code-accessibility", mountCodeAccessibility);
127
+ </script>
package/src/config.ts CHANGED
@@ -66,7 +66,7 @@ export interface StaluxOptions {
66
66
  sitemap?: boolean | SitemapOptions;
67
67
 
68
68
  /**
69
- * 是否自动打包 Expressive Code(默认 true,带代码块行号插件)。
69
+ * 是否自动打包 Expressive Code(默认 true,带代码块行号插件,固定 dark-plus 暗色主题)。
70
70
  * 传 false 关闭;传对象自定义主题/样式等选项。
71
71
  */
72
72
  expressiveCode?: boolean | ExpressiveCodeOptions;
package/src/env.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ /// <reference types="astro/client" />
2
+
3
+ declare module "*.css";
4
+ declare module "@pagefind/component-ui/css";
5
+ declare module "@waline/client/style";
6
+ declare module "virtual:stalux-search-style" {
7
+ const href: string;
8
+ export default href;
9
+ }
@@ -16,6 +16,7 @@
16
16
  */
17
17
  import { pluginLineNumbers } from "@expressive-code/plugin-line-numbers";
18
18
  import expressiveCodeBase from "astro-expressive-code";
19
+ import { codeContainmentPlugin } from "./internal/offscreen-layout";
19
20
 
20
21
  export type ExpressiveCodeOptions = NonNullable<Parameters<typeof expressiveCodeBase>[0]>;
21
22
 
@@ -24,8 +25,14 @@ export type ExpressiveCodeOptions = NonNullable<Parameters<typeof expressiveCode
24
25
  * @param options - 透传 astro-expressive-code 的选项;用户传入的 plugins 会追加在行号插件之后
25
26
  */
26
27
  export function expressiveCode(options: ExpressiveCodeOptions = {}) {
27
- const plugins = [pluginLineNumbers(), ...(options.plugins ?? [])];
28
- return expressiveCodeBase({ ...options, plugins });
28
+ const plugins = [pluginLineNumbers(), ...(options.plugins ?? []), codeContainmentPlugin()];
29
+ return expressiveCodeBase({
30
+ themes: ["dark-plus"],
31
+ useDarkModeMediaQuery: false,
32
+ themeCssSelector: false,
33
+ ...options,
34
+ plugins,
35
+ });
29
36
  }
30
37
 
31
38
  /** @alias Public default-import form of the named integration export. */
package/src/index.ts CHANGED
@@ -1,3 +1,4 @@
1
+ /// <reference path="./env.d.ts" />
1
2
  import { forEachBounded } from "./internal/bounded-tasks.ts";
2
3
  import { compactStylesPlugin } from "./internal/compact-styles.ts";
3
4
  import { modernCssTargets } from "./internal/modern-css.ts";
@@ -16,7 +17,6 @@ import { copyFileSync, existsSync, promises as fs, readdirSync, readFileSync } f
16
17
  import { createRequire } from "node:module";
17
18
  import path from "node:path";
18
19
  import { fileURLToPath } from "node:url";
19
- import partytown from "@astrojs/partytown";
20
20
  import sitemap from "@astrojs/sitemap";
21
21
  import { mermaidHast, mermaidMdast } from "@xingwangzhe/satteri-mermaid";
22
22
  import { photoswipe } from "@xingwangzhe/satteri-photoswipe";
@@ -39,10 +39,13 @@ import {
39
39
  } from "./internal/font-slices";
40
40
  import { findHtmlImages } from "./internal/html-images";
41
41
  import { createHtmlMutationEmitter } from "./internal/html-output";
42
+ import { promoteCodeStyles } from "./internal/html-styles";
42
43
  import { type ImageDimensions, updateLocalImageDimensions } from "./internal/image-dimensions";
43
44
  import { createInjectedRoutes } from "./internal/injected-routes";
44
45
  import { markdownPropagationPlugin } from "./internal/markdown-propagation";
46
+ import { containMermaidSvgs } from "./internal/offscreen-layout";
45
47
  import { createPageOutputCache } from "./internal/page-output-cache";
48
+ import { isPluginProject } from "./internal/project-mode";
46
49
  import { syncBackgroundSvgs, syncFontAssets } from "./internal/public-assets";
47
50
  import { createRuntimeCacheKey } from "./internal/runtime-cache-key";
48
51
  import {
@@ -50,8 +53,11 @@ import {
50
53
  collectPluginNames,
51
54
  prepareSatteriProcessor,
52
55
  } from "./internal/satteri-config";
56
+ import { searchStylePlugin } from "./internal/search-style";
53
57
  import { createViteAliases } from "./internal/vite-aliases";
58
+ import { webMcpMutationsPlugin } from "./internal/webmcp-mutations";
54
59
  import { featureFlagsHast, featureFlagsMdast } from "./plugins/feature-flags";
60
+ import { imageCaptions } from "./plugins/image-captions";
55
61
  import { temml } from "./plugins/satteri-temml";
56
62
  import { describeError } from "./utils/diagnostics";
57
63
 
@@ -110,9 +116,6 @@ export function stalux(options: StaluxOptions = {}): AstroIntegration[] {
110
116
  ...options,
111
117
  };
112
118
 
113
- // 探测是否为插件模式(通过 npm 安装)vs 源码开发模式
114
- // 安装在 node_modules 中时,页面的文件路由不可用,需要通过 injectRoute 注入
115
- const isPluginMode = import.meta.url.includes("node_modules");
116
119
  let pageFontData: { fontBuffer: Buffer; outputDir: string; cacheDir: string } | undefined;
117
120
 
118
121
  const coreIntegration: AstroIntegration = {
@@ -128,6 +131,7 @@ export function stalux(options: StaluxOptions = {}): AstroIntegration[] {
128
131
  logger,
129
132
  }) => {
130
133
  const started = performance.now();
134
+ const isPluginMode = isPluginProject(config.root, new URL("../", import.meta.url));
131
135
  const configLogger = logger.fork("stalux/config");
132
136
  const routesLogger = logger.fork("stalux/routes");
133
137
  const markdownLogger = logger.fork("stalux/markdown");
@@ -139,7 +143,8 @@ export function stalux(options: StaluxOptions = {}): AstroIntegration[] {
139
143
  path.resolve(srcDir, "../package.json"),
140
144
  path.join(srcDir, "styles"),
141
145
  JSON.stringify([styleNames, modernCssTargets]) +
142
- readFileSync(path.join(srcDir, "internal/compact-styles.ts"), "utf8"),
146
+ readFileSync(path.join(srcDir, "internal/compact-styles.ts"), "utf8") +
147
+ readFileSync(path.join(srcDir, "internal/offscreen-layout.ts"), "utf8"),
143
148
  path.resolve(fileURLToPath(config.root), opt.contentDir ?? "stalux", "config"),
144
149
  );
145
150
 
@@ -152,6 +157,7 @@ export function stalux(options: StaluxOptions = {}): AstroIntegration[] {
152
157
  config.build.inlineStylesheets === "always" ? "always" : "never",
153
158
  },
154
159
  vite: {
160
+ optimizeDeps: { exclude: ["@mcp-b/webmcp-polyfill"] },
155
161
  css: { lightningcss: { targets: modernCssTargets } },
156
162
  build: {
157
163
  target: config.vite.build?.target ?? "esnext",
@@ -162,6 +168,8 @@ export function stalux(options: StaluxOptions = {}): AstroIntegration[] {
162
168
  alias: createViteAliases(srcDir),
163
169
  },
164
170
  plugins: [
171
+ searchStylePlugin(command === "dev", config.base),
172
+ webMcpMutationsPlugin(),
165
173
  ...(command === "build"
166
174
  ? [markdownPropagationPlugin(), compactStylesPlugin(srcDir)]
167
175
  : []),
@@ -218,7 +226,7 @@ export function stalux(options: StaluxOptions = {}): AstroIntegration[] {
218
226
  // 消费方即使完全不配置 processor,这里也会对默认 processor 的 options 做幂等合并:
219
227
  // - features:默认开启 math / frontmatter / gfm / smartPunctuation(用户显式 false 则尊重)
220
228
  // - mdastPlugins:mermaidMdast + temml(数学公式)+ featureFlagsMdast(字数统计)
221
- // - hastPlugins:photoswipe(图片灯箱)+ mermaidHast + featureFlagsHast
229
+ // - hastPlugins:photoswipe(图片灯箱)+ imageCaptions(图注)+ mermaidHast + featureFlagsHast
222
230
  // 去重是必须的:重复注册 Mermaid 或 featureFlagsMdast 会导致渲染异常或字数统计翻倍。
223
231
  try {
224
232
  const processorOptions = prepareSatteriProcessor(config.markdown?.processor);
@@ -242,9 +250,10 @@ export function stalux(options: StaluxOptions = {}): AstroIntegration[] {
242
250
  seen,
243
251
  );
244
252
  appendUniquePlugin(processorOptions.hastPlugins, photoswipe(), seen);
253
+ appendUniquePlugin(processorOptions.hastPlugins, imageCaptions, seen);
245
254
  appendUniquePlugin(processorOptions.hastPlugins, featureFlagsHast, seen);
246
255
  markdownLogger.debug(
247
- "injected satteri plugins (mermaid/temml/photoswipe/feature-flags)",
256
+ "injected satteri plugins (mermaid/temml/photoswipe/image-captions/feature-flags)",
248
257
  );
249
258
  } else {
250
259
  markdownLogger.warn(
@@ -371,6 +380,8 @@ export function stalux(options: StaluxOptions = {}): AstroIntegration[] {
371
380
  "internal/image-dimensions.ts",
372
381
  "internal/html-images.ts",
373
382
  "internal/html-output.ts",
383
+ "internal/html-styles.ts",
384
+ "internal/offscreen-layout.ts",
374
385
  "internal/page-output-cache.ts",
375
386
  "internal/style-names.generated.ts",
376
387
  "plugins/feature-flags.ts",
@@ -419,6 +430,8 @@ export function stalux(options: StaluxOptions = {}): AstroIntegration[] {
419
430
  const dependencies = new Set<string>();
420
431
  const document = parse(before, { sourceCodeLocationInfo: true });
421
432
  const emitHtml = createHtmlMutationEmitter(before, document);
433
+ promoteCodeStyles(document);
434
+ containMermaidSvgs(document);
422
435
  removePageFontStyles(document);
423
436
  const images = findHtmlImages(before, document);
424
437
  await updateLocalImageDimensions(images, outDir, {
@@ -524,13 +537,8 @@ export function stalux(options: StaluxOptions = {}): AstroIntegration[] {
524
537
  };
525
538
 
526
539
  // 打包内置集成:Astro 的 integrations 配置支持嵌套数组并自动展平(schema 里 val.flat(Infinity)),
527
- // 因此这里返回 [coreIntegration, partytown, sitemap, expressiveCode],让消费方一行 `integrations: [stalux()]` 即可完成配置。
528
- const bundled: AstroIntegration[] = [
529
- coreIntegration,
530
- partytown({
531
- config: { forward: ["dataLayer.push", "gtag"], mainWindowAccessors: ["CSS"] },
532
- }),
533
- ];
540
+ // 因此这里返回 [coreIntegration, sitemap, expressiveCode],让消费方一行 `integrations: [stalux()]` 即可完成配置。
541
+ const bundled: AstroIntegration[] = [coreIntegration];
534
542
 
535
543
  if (opt.sitemap !== false) {
536
544
  const userSitemap = opt.sitemap === true ? undefined : opt.sitemap;
@@ -24,7 +24,10 @@ export function createHtmlMutationEmitter(
24
24
  if (node.tagName === "head") head = node;
25
25
  const location = node.sourceCodeLocation;
26
26
  if (
27
- (node.tagName === "img" || node.tagName === "style" || node.tagName === "link") &&
27
+ (node.tagName === "img" ||
28
+ node.tagName === "style" ||
29
+ node.tagName === "link" ||
30
+ node.tagName === "svg") &&
28
31
  location
29
32
  ) {
30
33
  snapshots.push({
@@ -0,0 +1,39 @@
1
+ import { type DefaultTreeAdapterMap, defaultTreeAdapter } from "parse5";
2
+
3
+ type Element = DefaultTreeAdapterMap["element"];
4
+
5
+ /** Astro's router preloads head styles only; EC emits its shared CSS inside the first block. */
6
+ export function promoteCodeStyles(document: DefaultTreeAdapterMap["document"]): number {
7
+ let head: Element | undefined;
8
+ const links: Element[] = [];
9
+ function visit(node: DefaultTreeAdapterMap["node"], inCode = false) {
10
+ if ("tagName" in node) {
11
+ if (node.tagName === "template" || node.tagName === "noscript") return;
12
+ if (node.tagName === "head") head = node;
13
+ inCode ||= (
14
+ node.attrs.find((a) => a.name === "class")?.value.split(/\s+/) ?? []
15
+ ).includes("expressive-code");
16
+ if (
17
+ inCode &&
18
+ node.tagName === "link" &&
19
+ node.attrs.some((a) => a.name === "rel" && a.value === "stylesheet")
20
+ )
21
+ links.push(node);
22
+ }
23
+ if ("childNodes" in node) for (const child of node.childNodes) visit(child, inCode);
24
+ }
25
+ visit(document);
26
+ if (!head) return 0;
27
+ for (const link of links) {
28
+ // Clone metadata without source ranges so the shared mutation emitter can
29
+ // remove the original bytes and insert the reference without rewriting prose.
30
+ const promoted = defaultTreeAdapter.createElement(
31
+ "link",
32
+ link.namespaceURI,
33
+ link.attrs.map((a) => ({ ...a })),
34
+ );
35
+ defaultTreeAdapter.detachNode(link);
36
+ defaultTreeAdapter.appendChild(head, promoted);
37
+ }
38
+ return links.length;
39
+ }
@@ -0,0 +1,117 @@
1
+ import type { ExpressiveCodePlugin } from "astro-expressive-code";
2
+ import { parse as parseCss, walk as walkCss } from "css-tree";
3
+ import type { DefaultTreeAdapterMap } from "parse5";
4
+
5
+ interface AstNode {
6
+ type: string;
7
+ tagName?: string;
8
+ properties?: Record<string, unknown>;
9
+ children?: AstNode[];
10
+ }
11
+
12
+ function styleProperties(value: unknown): { text: string; names: Set<string> } | undefined {
13
+ if (value !== undefined && typeof value !== "string") return;
14
+ const text = value ?? "";
15
+ const names = new Set<string>();
16
+ try {
17
+ walkCss(parseCss(text, { context: "declarationList" }), {
18
+ visit: "Declaration",
19
+ enter(node) {
20
+ names.add(node.property.toLowerCase());
21
+ },
22
+ });
23
+ } catch {
24
+ return;
25
+ }
26
+ return { text, names };
27
+ }
28
+
29
+ const classes = (node: AstNode) => {
30
+ const value = node.properties?.className;
31
+ return Array.isArray(value) ? value.map(String) : String(value ?? "").split(/\s+/u);
32
+ };
33
+
34
+ /** Non-wrapping EC rows are exactly one inherited line-height each, including empty rows. */
35
+ export function containCodeBlocks(node: AstNode): number {
36
+ let changed = 0;
37
+ if (node.type === "element" && node.tagName === "pre" && !classes(node).includes("wrap")) {
38
+ const code = node.children?.find(
39
+ (child) => child.type === "element" && child.tagName === "code",
40
+ );
41
+ const lines = code?.children ?? [];
42
+ if (
43
+ code &&
44
+ lines.length &&
45
+ lines.every((line) => line.type === "element" && classes(line).includes("ec-line"))
46
+ ) {
47
+ code.properties ??= {};
48
+ const properties = code.properties;
49
+ const style = styleProperties(properties.style);
50
+ if (
51
+ style &&
52
+ !style.names.has("content-visibility") &&
53
+ !style.names.has("contain-intrinsic-block-size") &&
54
+ !["height", "block-size", "min-height", "max-height"].some((name) =>
55
+ style.names.has(name),
56
+ )
57
+ ) {
58
+ properties.style = `${style.text};content-visibility:auto;contain-intrinsic-block-size:auto calc(${lines.length} * 1lh)`;
59
+ properties["data-stalux-offscreen-code"] = "";
60
+ changed++;
61
+ }
62
+ }
63
+ }
64
+ for (const child of node.children ?? []) changed += containCodeBlocks(child);
65
+ return changed;
66
+ }
67
+
68
+ export function codeContainmentPlugin(): ExpressiveCodePlugin {
69
+ return {
70
+ name: "stalux-offscreen-code",
71
+ hooks: {
72
+ postprocessRenderedBlockGroup({ renderData }) {
73
+ containCodeBlocks(renderData.groupAst);
74
+ },
75
+ },
76
+ };
77
+ }
78
+
79
+ /** Mermaid SVGs have an exact viewBox ratio; skipping offscreen paint need not estimate their size. */
80
+ export function containMermaidSvgs(document: DefaultTreeAdapterMap["document"]): number {
81
+ let changed = 0;
82
+ function visit(node: DefaultTreeAdapterMap["node"]) {
83
+ if ("tagName" in node) {
84
+ if (node.tagName === "template" || node.tagName === "noscript") return;
85
+ const attribute = (name: string) =>
86
+ node.attrs.find((a) => a.name.toLowerCase() === name);
87
+ if (node.tagName === "svg" && attribute("id")?.value.startsWith("satteri-mermaid-")) {
88
+ const box = attribute("viewbox")
89
+ ?.value.trim()
90
+ .split(/[\s,]+/u)
91
+ .map(Number);
92
+ const style = styleProperties(attribute("style")?.value);
93
+ if (
94
+ box?.length === 4 &&
95
+ box.every(Number.isFinite) &&
96
+ (box[2] ?? 0) > 0 &&
97
+ (box[3] ?? 0) > 0 &&
98
+ style &&
99
+ !style.names.has("content-visibility")
100
+ ) {
101
+ const ratio = style.names.has("aspect-ratio")
102
+ ? ""
103
+ : `;aspect-ratio:${box[2]}/${box[3]}`;
104
+ const value = `${style.text};content-visibility:auto${ratio}`;
105
+ const existing = attribute("style");
106
+ if (existing) existing.value = value;
107
+ else node.attrs.push({ name: "style", value });
108
+ node.attrs.push({ name: "data-stalux-offscreen-svg", value: "" });
109
+ changed++;
110
+ }
111
+ }
112
+ }
113
+ if ("childNodes" in node) for (const child of node.childNodes) visit(child);
114
+ }
115
+ visit(document);
116
+ return changed;
117
+ }
@@ -0,0 +1,7 @@
1
+ import { realpathSync } from "node:fs";
2
+ import { fileURLToPath } from "node:url";
3
+
4
+ /** Linked consumers still need injected routes; only the theme's own root has file routes. */
5
+ export function isPluginProject(projectRoot: URL, themeRoot: URL): boolean {
6
+ return realpathSync(fileURLToPath(projectRoot)) !== realpathSync(fileURLToPath(themeRoot));
7
+ }
@@ -0,0 +1,29 @@
1
+ import { createRequire } from "node:module";
2
+ import type { Plugin } from "vite";
3
+
4
+ const publicId = "virtual:stalux-search-style";
5
+ const virtualId = `\0${publicId}`;
6
+
7
+ /** Astro dev treats a direct CSS ?url import as a module script; keep it a URL-only leaf. */
8
+ export function searchStylePlugin(development: boolean, base: string): Plugin {
9
+ const stylesheet = createRequire(import.meta.url)
10
+ .resolve("@pagefind/component-ui/css")
11
+ .replaceAll("\\", "/");
12
+ return {
13
+ name: "stalux:search-style",
14
+ configureServer(server) {
15
+ // A linked package can be outside the consumer's workspace root.
16
+ server.config.server.fs.allow.push(stylesheet);
17
+ },
18
+ async resolveId(id, importer) {
19
+ if (id !== publicId) return;
20
+ return development
21
+ ? virtualId
22
+ : this.resolve(`${stylesheet}?url`, importer, { skipSelf: true });
23
+ },
24
+ load(id) {
25
+ if (id !== virtualId) return;
26
+ return `export default ${JSON.stringify(`${base.replace(/\/$/, "")}/@fs${stylesheet}`)};`;
27
+ },
28
+ };
29
+ }
@@ -336,19 +336,18 @@ export const styleNames: { classes: Record<string, string>; variables: Record<st
336
336
  "stalux-page-enter": "k6",
337
337
  "stalux-page-enter-back": "k7",
338
338
  "stalux-page-exit": "k8",
339
- "stalux-post-content-reveal": "k9",
340
- "stalux-random-spin": "ka",
341
- "stalux-sidebar-left-back-in": "kb",
342
- "stalux-sidebar-left-back-out": "kc",
343
- "stalux-sidebar-left-in": "kd",
344
- "stalux-sidebar-left-out": "ke",
345
- "stalux-sidebar-right-back-in": "kf",
346
- "stalux-sidebar-right-back-out": "kg",
347
- "stalux-sidebar-right-in": "kh",
348
- "stalux-sidebar-right-out": "ki",
349
- "stalux-social-stalux-social-arrive": "kj",
350
- "stalux-taxonomy-enter": "kk",
351
- "stalux-taxonomy-enter-back": "kl",
352
- "stalux-word-word-glow": "km"
339
+ "stalux-random-spin": "k9",
340
+ "stalux-sidebar-left-back-in": "ka",
341
+ "stalux-sidebar-left-back-out": "kb",
342
+ "stalux-sidebar-left-in": "kc",
343
+ "stalux-sidebar-left-out": "kd",
344
+ "stalux-sidebar-right-back-in": "ke",
345
+ "stalux-sidebar-right-back-out": "kf",
346
+ "stalux-sidebar-right-in": "kg",
347
+ "stalux-sidebar-right-out": "kh",
348
+ "stalux-social-stalux-social-arrive": "ki",
349
+ "stalux-taxonomy-enter": "kj",
350
+ "stalux-taxonomy-enter-back": "kk",
351
+ "stalux-word-word-glow": "kl"
353
352
  }
354
353
  };
@@ -0,0 +1,37 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import { pathToFileURL } from "node:url";
4
+ import type { Plugin } from "vite";
5
+ import { affectsDeclarativeTools } from "../scripts/webmcp-mutations";
6
+
7
+ const observer = "const observer = new MutationObserver(sync);";
8
+
9
+ /** Upstream 5.1.0 rescans every element even for unrelated clock/background mutations. */
10
+ export function optimizeWebMcpMutations(code: string, version: string): string | undefined {
11
+ if (
12
+ version !== "5.1.0" ||
13
+ !code.includes("function installDeclarativeForms(document, context)") ||
14
+ code.split(observer).length !== 2
15
+ )
16
+ return;
17
+ return code.replace(
18
+ observer,
19
+ `const relevant = ${affectsDeclarativeTools.toString()};\nconst observer = new MutationObserver((records) => { if (relevant(records)) sync(); });`,
20
+ );
21
+ }
22
+
23
+ export function webMcpMutationsPlugin(): Plugin {
24
+ const entry = createRequire(import.meta.url).resolve("@mcp-b/webmcp-polyfill");
25
+ const version = JSON.parse(
26
+ readFileSync(new URL("../package.json", pathToFileURL(entry)), "utf8"),
27
+ ).version;
28
+ return {
29
+ name: "stalux:webmcp-mutations",
30
+ enforce: "pre",
31
+ transform(code, id) {
32
+ if (!id.replaceAll("\\", "/").endsWith("/@mcp-b/webmcp-polyfill/dist/index.js")) return;
33
+ const optimized = optimizeWebMcpMutations(code, version);
34
+ if (optimized) return { code: optimized, map: null };
35
+ },
36
+ };
37
+ }