@xingwangzhe/stalux 1.34.0 → 1.35.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -93,6 +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
97
  - 📐 **Math formula rendering** (Temml → MathML)
97
98
  - 💬 **Waline** comment system
98
99
  - 🤖 **LLM discovery files** (llms.txt / llms-full.txt)
@@ -301,3 +302,11 @@ Article bodies and summary cards are up to 840px wide. Summaries use the existin
301
302
  Visitor weather is optional: set `weather: { enabled: true }` in `stalux/config/site.yml` to show it as plain text in the footer (with visitor IP and approximate location) and below the desktop article clock. Omitted or `false` disables it. It automatically requests an approximate city from IP.SB (with IPinfo as fallback) and weather from Open-Meteo, only while visible; no browser GPS permission is requested. These external services receive the visitor IP, and Open-Meteo receives rounded coordinates. A 20-minute session cache stores the city, visitor IP and weather, without coordinates. Visitors behind VPNs may see the exit city. Hidden tabs, offscreen cards and Astro navigation stop work; unavailable services show a retry action and retain previous weather. Open-Meteo's free endpoint is for non-commercial use subject to its limits; the card links to its attribution licence. No server endpoint or extra package is needed.
302
303
 
303
304
  Archives keep a transparent, single-column year/month timeline with all articles expanded. Each month gently fades and moves into place once when it enters the viewport (280ms), using native browser APIs without extra dependencies. Reduced motion, printing, missing browser APIs and disabled JavaScript retain the complete static list. Mobile dates sit above wrapping article titles. No new configuration is required. Article titles retain a single hover underline; hovering or keyboard-focusing a title previews its existing `desc` on the right, falling below near viewport edges. Empty descriptions show no preview, and touch navigation stays direct. The preview uses the browser top layer (with a high stacking-level fallback) to avoid clipping or overlap.
305
+
306
+ ### Production style compaction (1.35.0)
307
+
308
+ Development keeps readable source names. Production builds compact private Stalux classes, custom properties and keyframes before Astro compilation and asset hashing, using a version-local mapping shipped with the package. Templates, client selectors, inline styles and dynamic server-generated animations share the mapping; its contents and transformer source participate in incremental cache invalidation.
309
+
310
+ Stable `data-*` hooks, article anchors, public font properties (`--font-body`, `--font-code`, plus the legacy override hooks `--accent` and `--foreground-60p`), third-party interfaces and user content keep their meaning. Use your own classes and public component interfaces for extensions; the generated `s*`, `--s*` and `k*` names are reserved for the theme. Existing CSS import paths remain available and consumers need no additional configuration or UnoCSS installation.
311
+
312
+ Theme CSS uses native nesting, modern RGB syntax, `overflow-wrap: anywhere` and clipped one-pixel accessible content with `clip-path`. Lightning CSS targets modern browser engines instead of generating obsolete compatibility expansions. Necessary multiline-clamping and third-party MathML browser adaptations remain. Production source maps are disabled to avoid exposing the original names. Build assertions check semantic CSS/HTML/JS surfaces and resource integrity without modifying article text or code examples; detector results always identify the rule or scanner version used.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xingwangzhe/stalux",
3
- "version": "1.34.0",
3
+ "version": "1.35.1",
4
4
  "description": "A powerful, modern Astro blog theme — use as template or install as plugin",
5
5
  "keywords": [
6
6
  "astro",
@@ -91,26 +91,30 @@
91
91
  "prepare": "git config core.hooksPath scripts/git-hooks",
92
92
  "dev:debug": "STALUX_DEBUG=1 astro dev --verbose",
93
93
  "build:debug": "STALUX_DEBUG=1 astro build --verbose && node scripts/verify-build.ts",
94
- "styles:build": "node scripts/build-styles.ts",
94
+ "styles:build": "node scripts/build-styles.ts && node scripts/build-style-names.ts",
95
95
  "styles:watch": "node scripts/build-styles.ts --watch"
96
96
  },
97
97
  "dependencies": {
98
+ "@astrojs/compiler": "2.13.1",
98
99
  "@astrojs/markdown-satteri": "0.4.2",
99
100
  "@astrojs/partytown": "2.1.8",
100
101
  "@astrojs/rss": "^4.0.19",
101
102
  "@astrojs/sitemap": "^3.7.4",
103
+ "@babel/parser": "7.29.9",
102
104
  "@expressive-code/plugin-line-numbers": "^0.44.2",
103
105
  "@lucide/astro": "^1.47.0",
104
106
  "@mcp-b/webmcp-polyfill": "^5.1.0",
105
107
  "@pagefind/component-ui": "^1.5.2",
106
108
  "@waline/client": "3.16.0",
107
109
  "@xingwangzhe/cjk-font-split-native": "0.2.0",
108
- "@xingwangzhe/satteri-mermaid": "0.7.8",
110
+ "@xingwangzhe/satteri-mermaid": "0.8.1",
109
111
  "@xingwangzhe/satteri-photoswipe": "^0.2.5",
110
112
  "@xingwangzhe/tags-cloud": "^1.2.8",
111
113
  "astro-expressive-code": "^0.44.2",
112
114
  "astro-seo": "^1.2.0",
113
115
  "badge-maker": "^6.0.0",
116
+ "css-tree": "3.2.1",
117
+ "magic-string": "0.30.21",
114
118
  "pagefind": "^1.5.2",
115
119
  "parse5": "8.0.1",
116
120
  "photoswipe": "^5.4.4",
@@ -122,6 +126,7 @@
122
126
  "devDependencies": {
123
127
  "@astrojs/check": "0.9.10",
124
128
  "@biomejs/biome": "2.5.14",
129
+ "@types/css-tree": "^3.2.0",
125
130
  "@types/node": "^26.6.2",
126
131
  "@unocss/astro": "^66.10.5",
127
132
  "@vitest/coverage-v8": "5.0.1",
@@ -33,7 +33,7 @@ registerPageLifecycle("typewriter", mountTypewriters);
33
33
  padding: 0;
34
34
  margin: -1px;
35
35
  overflow: hidden;
36
- clip: rect(0, 0, 0, 0);
36
+ clip-path: inset(50%);
37
37
  white-space: nowrap;
38
38
  border: 0;
39
39
  }
@@ -80,7 +80,7 @@ registerPageLifecycle("typewriter", mountTypewriters);
80
80
  height: auto;
81
81
  margin: 0;
82
82
  overflow: visible;
83
- clip: auto;
83
+ clip-path: none;
84
84
  white-space: normal;
85
85
  }
86
86
  }
@@ -30,6 +30,7 @@ const tagLinks = tagList.map((t) => ({
30
30
  <a
31
31
  href={`/tags/${name}/`}
32
32
  class="stalux-cloud-tag-card"
33
+ data-stalux-card
33
34
  data-stalux-tag-card
34
35
  data-count={count}
35
36
  data-astro-prefetch
package/src/index.ts CHANGED
@@ -1,3 +1,6 @@
1
+ import { compactStylesPlugin } from "./internal/compact-styles.ts";
2
+ import { modernCssTargets } from "./internal/modern-css.ts";
3
+ import { styleNames } from "./internal/style-names.generated.ts";
1
4
  /**
2
5
  * Stalux — Astro 博客主题集成入口
3
6
  *
@@ -162,6 +165,7 @@ export function stalux(options: StaluxOptions = {}): AstroIntegration[] {
162
165
  updateConfig,
163
166
  addDevToolbarApp,
164
167
  config,
168
+ command,
165
169
  logger,
166
170
  }) => {
167
171
  const started = performance.now();
@@ -176,6 +180,8 @@ export function stalux(options: StaluxOptions = {}): AstroIntegration[] {
176
180
  path.join(srcDir, "scripts"),
177
181
  path.resolve(srcDir, "../package.json"),
178
182
  path.join(srcDir, "styles"),
183
+ JSON.stringify([styleNames, modernCssTargets]) +
184
+ readFileSync(path.join(srcDir, "internal/compact-styles.ts"), "utf8"),
179
185
  );
180
186
 
181
187
  // 1. 注入 Vite 别名 + 组件覆盖插件 + Vue 特性标记(Waline 依赖)
@@ -187,14 +193,21 @@ export function stalux(options: StaluxOptions = {}): AstroIntegration[] {
187
193
  config.build.inlineStylesheets === "always" ? "always" : "never",
188
194
  },
189
195
  vite: {
190
- build: { target: config.vite.build?.target ?? "esnext" },
196
+ css: { lightningcss: { targets: modernCssTargets } },
197
+ build: {
198
+ target: config.vite.build?.target ?? "esnext",
199
+ cssMinify: "lightningcss",
200
+ sourcemap: false,
201
+ },
191
202
  resolve: {
192
203
  alias: createViteAliases(srcDir),
193
204
  },
194
205
  plugins: [
206
+ ...(command === "build" ? [compactStylesPlugin(srcDir)] : []),
195
207
  staluxComponentsAlias(opt.components, logger.fork("stalux/components")),
196
208
  ],
197
209
  define: {
210
+ __STALUX_COMPACT_STYLES__: JSON.stringify(command === "build"),
198
211
  __STALUX_DEBUG__: JSON.stringify(
199
212
  process.env.STALUX_DEBUG === "1" ||
200
213
  process.argv.includes("--verbose"),
@@ -278,6 +291,7 @@ export function stalux(options: StaluxOptions = {}): AstroIntegration[] {
278
291
  mermaidHast({
279
292
  responsive: true,
280
293
  theme: "dark",
294
+ themeVariables: { background: "transparent" },
281
295
  themeOverrides: { clusterBorder: "#cccccc" },
282
296
  }),
283
297
  seen,
@@ -0,0 +1,322 @@
1
+ import path from "node:path";
2
+ import { parse as parseAstro } from "@astrojs/compiler";
3
+ import { parseExpression, parse as parseScript } from "@babel/parser";
4
+ import { generate, ident, parse, walk } from "css-tree";
5
+ import MagicString from "magic-string";
6
+ import { parseFragment } from "parse5";
7
+ import type { Plugin } from "vite";
8
+ import { styleNameIn } from "./style-name-lookup.ts";
9
+ import { styleNames } from "./style-names.generated.ts";
10
+
11
+ type Names = typeof styleNames;
12
+
13
+ /** Rename parsed CSS tokens, preserving strings, URLs and unrelated third-party names. */
14
+ export function compactCss(css: string, names: Names = styleNames, inline = false): string {
15
+ const ast = parse(css, {
16
+ context: inline ? "declarationList" : "stylesheet",
17
+ parseCustomProperty: true,
18
+ });
19
+ walk(ast, function (node) {
20
+ if (node.type === "ClassSelector")
21
+ node.name = styleNameIn(names.classes, ident.decode(node.name)) ?? node.name;
22
+ if (node.type === "Declaration")
23
+ node.property = styleNameIn(names.variables, node.property) ?? node.property;
24
+ if (node.type === "Identifier") {
25
+ node.name =
26
+ styleNameIn(names.variables, node.name) ??
27
+ (this.atrule?.name === "keyframes" ||
28
+ this.declaration?.property === "animation" ||
29
+ this.declaration?.property === "animation-name"
30
+ ? styleNameIn(names.animations, node.name)
31
+ : undefined) ??
32
+ node.name;
33
+ }
34
+ if (node.type === "AttributeSelector" && node.name.name === "class" && node.value) {
35
+ if (node.matcher !== "=" && node.matcher !== "~=")
36
+ throw new Error(
37
+ "Theme class substring selectors must use explicit classes before compaction",
38
+ );
39
+ if (node.value.type === "String")
40
+ node.value.value = compactTokens(node.value.value, names);
41
+ if (node.value.type === "Identifier")
42
+ node.value.name = styleNameIn(names.classes, node.value.name) ?? node.value.name;
43
+ }
44
+ });
45
+ return generate(ast);
46
+ }
47
+
48
+ function compactTokens(value: string, names: Names): string {
49
+ // Selectors have two namespaces: class names are private, data attribute values are stable.
50
+ if (value.includes("[data-")) {
51
+ try {
52
+ const selector = parse(value, { context: "selectorList" });
53
+ walk(selector, (node) => {
54
+ if (node.type === "ClassSelector")
55
+ node.name = styleNameIn(names.classes, ident.decode(node.name)) ?? node.name;
56
+ });
57
+ return generate(selector);
58
+ } catch {
59
+ // A template quasi may end inside an attribute selector. Its data values stay literal.
60
+ return value;
61
+ }
62
+ }
63
+ return value.replace(
64
+ /--[\w-]+|[a-zA-Z_][\w-]*/gu,
65
+ (name) =>
66
+ styleNameIn(names.classes, name) ??
67
+ styleNameIn(names.variables, name) ??
68
+ styleNameIn(names.animations, name) ??
69
+ name,
70
+ );
71
+ }
72
+
73
+ type Edit = { start: number; end: number; text: string };
74
+
75
+ /** Parse real markup attributes; leave text and code examples untouched. */
76
+ function markupEdits(value: string, names: Names): Edit[] {
77
+ const edits: Edit[] = [];
78
+ const ast = parseFragment(value, { sourceCodeLocationInfo: true });
79
+ function visit(node: typeof ast | (typeof ast.childNodes)[number]) {
80
+ if ("tagName" in node) {
81
+ if (["pre", "code", "script", "style"].includes(node.tagName)) return;
82
+ for (const attr of node.attrs) {
83
+ const loc = node.sourceCodeLocation?.attrs?.[attr.name];
84
+ if (!loc || !["class", "style", "data-toc-active-class"].includes(attr.name))
85
+ continue;
86
+ const raw = value.slice(loc.startOffset, loc.endOffset);
87
+ const offset = loc.startOffset + raw.indexOf("=") + 1;
88
+ const body = value.slice(offset, loc.endOffset);
89
+ if (attr.name === "style") {
90
+ const leading = body.match(/^\s*(["'])?/u)?.[0] ?? "";
91
+ const quote = leading.at(-1);
92
+ const css = body.slice(
93
+ leading.length,
94
+ quote === '"' || quote === "'" ? -1 : undefined,
95
+ );
96
+ const cssAst = parse(css, {
97
+ context: "declarationList",
98
+ positions: true,
99
+ parseCustomProperty: true,
100
+ });
101
+ walk(cssAst, function (token) {
102
+ if (!token.loc) return;
103
+ let next: string | undefined;
104
+ let end = token.loc.end.offset;
105
+ if (token.type === "Declaration") {
106
+ next = styleNameIn(names.variables, token.property);
107
+ end = token.loc.start.offset + token.property.length;
108
+ }
109
+ if (token.type === "Identifier")
110
+ next =
111
+ styleNameIn(names.variables, token.name) ??
112
+ (["animation", "animation-name"].includes(
113
+ this.declaration?.property ?? "",
114
+ )
115
+ ? styleNameIn(names.animations, token.name)
116
+ : undefined);
117
+ if (next)
118
+ edits.push({
119
+ start: offset + leading.length + token.loc.start.offset,
120
+ end: offset + leading.length + end,
121
+ text: next,
122
+ });
123
+ });
124
+ } else
125
+ for (const match of body.matchAll(/[a-zA-Z_][\w-]*/gu)) {
126
+ const next = styleNameIn(names.classes, match[0]);
127
+ if (next)
128
+ edits.push({
129
+ start: offset + match.index,
130
+ end: offset + match.index + match[0].length,
131
+ text: next,
132
+ });
133
+ }
134
+ }
135
+ }
136
+ if ("childNodes" in node) for (const child of node.childNodes) visit(child);
137
+ }
138
+ visit(ast);
139
+ return edits;
140
+ }
141
+
142
+ function compactMarkup(value: string, names: Names): string {
143
+ const output = new MagicString(value);
144
+ for (const edit of markupEdits(value, names)) output.overwrite(edit.start, edit.end, edit.text);
145
+ return output.toString();
146
+ }
147
+
148
+ function visitSyntax(
149
+ value: unknown,
150
+ callback: (node: Record<string, unknown>) => boolean | undefined,
151
+ ): void {
152
+ if (!value || typeof value !== "object") return;
153
+ if (Array.isArray(value)) {
154
+ for (const entry of value) visitSyntax(entry, callback);
155
+ return;
156
+ }
157
+ const node = value as Record<string, unknown>;
158
+ if (typeof node.type === "string" && callback(node) === false) return;
159
+ for (const [key, child] of Object.entries(node))
160
+ if (key !== "loc" && key !== "extra" && key !== "comments") visitSyntax(child, callback);
161
+ }
162
+
163
+ /** Parse source literals; never rewrite generated bundles or consumer modules. */
164
+ export function compactScript(code: string, names: Names = styleNames, expression = false): string {
165
+ const ast = expression
166
+ ? parseExpression(code, { plugins: ["typescript"] })
167
+ : parseScript(code, { sourceType: "module", plugins: ["typescript"] });
168
+ const output = new MagicString(code);
169
+ visitSyntax(ast, (node) => {
170
+ if (
171
+ node.type === "ImportDeclaration" ||
172
+ node.type === "ImportExpression" ||
173
+ node.type === "ExportAllDeclaration"
174
+ )
175
+ return false;
176
+ if (
177
+ node.type === "StringLiteral" &&
178
+ typeof node.value === "string" &&
179
+ typeof node.start === "number" &&
180
+ typeof node.end === "number"
181
+ ) {
182
+ const next = node.value.includes("<")
183
+ ? compactMarkup(node.value, names)
184
+ : compactTokens(node.value, names);
185
+ if (next !== node.value) output.overwrite(node.start, node.end, JSON.stringify(next));
186
+ }
187
+ if (
188
+ node.type === "TemplateLiteral" &&
189
+ typeof node.start === "number" &&
190
+ typeof node.end === "number"
191
+ ) {
192
+ const segments = node.quasis as Array<{
193
+ start: number;
194
+ end: number;
195
+ value: { raw: string };
196
+ }>;
197
+ const bodyStart = node.start + 1;
198
+ const expressions = node.expressions as Array<{ start: number; end: number }>;
199
+ const masked = new MagicString(code.slice(bodyStart, node.end - 1));
200
+ for (const expr of expressions)
201
+ masked.overwrite(
202
+ expr.start - 2 - bodyStart,
203
+ expr.end + 1 - bodyStart,
204
+ "x".repeat(expr.end - expr.start + 3),
205
+ );
206
+ const html = masked.toString();
207
+ if (html.includes("<")) {
208
+ for (const edit of markupEdits(html, names)) {
209
+ const start = bodyStart + edit.start;
210
+ const end = bodyStart + edit.end;
211
+ if (!expressions.some((expr) => start < expr.end + 1 && end > expr.start - 2))
212
+ output.overwrite(start, end, edit.text);
213
+ }
214
+ } else
215
+ for (const segment of segments) {
216
+ const next = compactTokens(segment.value.raw, names);
217
+ if (next !== segment.value.raw)
218
+ output.overwrite(segment.start, segment.end, next);
219
+ }
220
+ }
221
+ });
222
+ return output.toString();
223
+ }
224
+
225
+ export async function compactAstro(code: string, names: Names = styleNames): Promise<string> {
226
+ const { ast } = await parseAstro(code);
227
+ const output = new MagicString(code);
228
+ const sourceBytes = Buffer.from(code);
229
+ const offsetOf = (offset: number) => sourceBytes.subarray(0, offset).toString("utf8").length;
230
+ function visit(node: typeof ast | (typeof ast.children)[number]) {
231
+ if (node.type === "frontmatter") {
232
+ const start = offsetOf(node.position?.start.offset ?? 0) + 3;
233
+ output.overwrite(start, start + node.value.length, compactScript(node.value, names));
234
+ }
235
+ if ("attributes" in node)
236
+ for (const attr of node.attributes) {
237
+ const byteOffset = attr.position?.start.offset;
238
+ const offset = byteOffset === undefined ? undefined : offsetOf(byteOffset);
239
+ if (offset === undefined || attr.kind === "empty" || attr.kind === "spread")
240
+ continue;
241
+ const equals = code.indexOf("=", offset + attr.name.length);
242
+ const start = equals + 1 + (code.slice(equals + 1).match(/^\s*/u)?.[0].length ?? 0);
243
+ if (
244
+ attr.kind === "expression" &&
245
+ (attr.name === "class" ||
246
+ attr.name === "class:list" ||
247
+ attr.name === "style" ||
248
+ attr.name === "data-toc-active-class")
249
+ ) {
250
+ const next = compactScript(attr.value, names, true);
251
+ if (next !== attr.value)
252
+ output.overwrite(start + 1, start + 1 + attr.value.length, next);
253
+ } else if (
254
+ attr.kind === "quoted" &&
255
+ ["class", "style", "data-toc-active-class"].includes(attr.name)
256
+ ) {
257
+ const next =
258
+ attr.name === "style"
259
+ ? compactCss(attr.value, names, true)
260
+ : compactTokens(attr.value, names);
261
+ if (next !== attr.value)
262
+ output.overwrite(
263
+ start,
264
+ start + (attr.raw?.length ?? attr.value.length + 2),
265
+ JSON.stringify(next),
266
+ );
267
+ }
268
+ }
269
+ if (
270
+ "name" in node &&
271
+ (node.name === "style" || node.name === "script") &&
272
+ "children" in node
273
+ ) {
274
+ for (const child of node.children)
275
+ if (child.type === "text" && child.position?.end) {
276
+ const next =
277
+ node.name === "style"
278
+ ? compactCss(child.value, names)
279
+ : compactScript(child.value, names);
280
+ if (next !== child.value)
281
+ output.overwrite(
282
+ offsetOf(child.position.start.offset),
283
+ offsetOf(child.position.end.offset),
284
+ next,
285
+ );
286
+ }
287
+ return;
288
+ }
289
+ if ("children" in node) for (const child of node.children) visit(child);
290
+ }
291
+ visit(ast);
292
+ return output.toString();
293
+ }
294
+
295
+ export function compactStylesPlugin(srcDir: string): Plugin {
296
+ const root = path.resolve(srcDir) + path.sep;
297
+ return {
298
+ name: "stalux:compact-styles",
299
+ enforce: "pre",
300
+ transform: {
301
+ order: "pre",
302
+ async handler(code, id) {
303
+ const filename = id.split("?")[0] ?? "";
304
+ if (!filename.startsWith(root)) return;
305
+ const relative = filename.slice(root.length).replaceAll(path.sep, "/");
306
+ if (
307
+ !/^(?:components|layouts|pages|scripts|utils|styles)\//u.test(relative) ||
308
+ relative === "utils/style-name.ts" ||
309
+ relative.endsWith(".md.ts")
310
+ )
311
+ return;
312
+ let next: string;
313
+ if (id.includes("astro&type=")) return;
314
+ if (filename.endsWith(".astro")) next = await compactAstro(code);
315
+ else if (filename.endsWith(".css")) next = compactCss(code);
316
+ else if (filename.endsWith(".ts")) next = compactScript(code);
317
+ else return;
318
+ return next === code ? undefined : { code: next, map: null };
319
+ },
320
+ },
321
+ };
322
+ }
@@ -1,3 +1,4 @@
1
+ import { styleNames } from "./style-names.generated.ts";
1
2
  /**
2
3
  * Build-time CJK font subsetting engine.
3
4
  *
@@ -128,6 +129,7 @@ type HtmlNode = DefaultTreeAdapterMap["node"];
128
129
  const NON_RENDERED_ELEMENTS = new Set(["script", "style", "template", "title"]);
129
130
  const VISUALLY_HIDDEN_CLASSES = new Set([
130
131
  "agent-home-summary",
132
+ styleNames.classes["agent-home-summary"] ?? "agent-home-summary",
131
133
  "screen-reader-only",
132
134
  "sr-only",
133
135
  "visually-hidden",
@@ -0,0 +1,7 @@
1
+ // Modern CSS output floor for the 1.35 release. Older fallback expansions are unnecessary.
2
+ // Acceptance uses current stable browser engines; targets intentionally retain native nesting.
3
+ export const modernCssTargets = {
4
+ chrome: 154 << 16,
5
+ firefox: 157 << 16,
6
+ safari: (26 << 16) | (6 << 8),
7
+ };
@@ -29,6 +29,7 @@ export function createRuntimeCacheKey(
29
29
  scriptsDirectory: string,
30
30
  packageJson: string,
31
31
  stylesDirectory?: string,
32
+ styleMapping?: string,
32
33
  ): string {
33
34
  return hashRuntimeSources([
34
35
  ...collectFiles(scriptsDirectory),
@@ -40,6 +41,7 @@ export function createRuntimeCacheKey(
40
41
  readFileSync(path.join(stylesDirectory, name), "utf8"),
41
42
  ])
42
43
  : []),
44
+ ...(styleMapping ? [["style-names", styleMapping] as const] : []),
43
45
  ["package.json", readFileSync(packageJson, "utf8")],
44
46
  ]);
45
47
  }
@@ -0,0 +1,94 @@
1
+ import { parse, walk } from "css-tree";
2
+ import { parse as parseHtml } from "parse5";
3
+ import { styleNameIn } from "./style-name-lookup.ts";
4
+ import { styleNames } from "./style-names.generated.ts";
5
+
6
+ // Public enthec/webappanalyzer Tailwind CSS rule snapshot, retrieved 2026-10-04.
7
+ // t.json SHA-256: aa96b868b7ad19eccd6c1337a59e14f4ff692a5bcb531a7a1021a3cfef7ab1e4
8
+ const tailwindMarkers =
9
+ /--tw-[\w-]+|@(?:apply|tailwind)\b|\/\*!\s*tailwindcss\b|(?:cdn\.)?tailwindcss\.(?:com|js)|\/npm\/@tailwindcss\/browser/iu;
10
+
11
+ /** Check semantic style surfaces, leaving article prose and code examples intact. */
12
+ export function styleArtifactProblems(filename: string, source: string): string[] {
13
+ const problems: string[] = [];
14
+ if (filename.endsWith(".css")) {
15
+ if (tailwindMarkers.test(source))
16
+ problems.push("Tailwind CSS fingerprint or uncompiled directive");
17
+ walk(parse(source, { parseCustomProperty: true }), (node) => {
18
+ if (node.type === "ClassSelector" && styleNameIn(styleNames.classes, node.name))
19
+ problems.push(`uncompacted class ${node.name}`);
20
+ if (node.type === "Declaration" && styleNameIn(styleNames.variables, node.property))
21
+ problems.push(`uncompacted variable ${node.property}`);
22
+ if (
23
+ node.type === "Identifier" &&
24
+ (styleNameIn(styleNames.variables, node.name) ||
25
+ styleNameIn(styleNames.animations, node.name))
26
+ )
27
+ problems.push(`uncompacted CSS reference ${node.name}`);
28
+ });
29
+ } else if (filename.endsWith(".html")) {
30
+ const ast = parseHtml(source);
31
+ function visit(node: typeof ast | (typeof ast.childNodes)[number]) {
32
+ if ("tagName" in node) {
33
+ if (node.tagName === "pre" || node.tagName === "code") return;
34
+ for (const attr of node.attrs) {
35
+ if (attr.name === "class")
36
+ for (const name of attr.value.split(/\s+/u)) {
37
+ if (styleNameIn(styleNames.classes, name))
38
+ problems.push(`uncompacted HTML class ${name}`);
39
+ }
40
+ if (attr.name === "style")
41
+ for (const match of attr.value.matchAll(/--[\w-]+/gu)) {
42
+ if (styleNameIn(styleNames.variables, match[0]))
43
+ problems.push(`uncompacted inline variable ${match[0]}`);
44
+ }
45
+ if (
46
+ (attr.name === "src" || attr.name === "href") &&
47
+ tailwindMarkers.test(attr.value)
48
+ )
49
+ problems.push("Tailwind CDN reference");
50
+ }
51
+ if (
52
+ node.tagName === "script" &&
53
+ "childNodes" in node &&
54
+ !node.attrs.some(
55
+ (attr) =>
56
+ attr.name === "type" &&
57
+ !["module", "text/javascript"].includes(attr.value),
58
+ )
59
+ ) {
60
+ for (const child of node.childNodes)
61
+ if ("value" in child)
62
+ problems.push(...styleArtifactProblems("inline.js", child.value));
63
+ }
64
+ if (node.tagName === "style" && "childNodes" in node)
65
+ for (const child of node.childNodes) {
66
+ if ("value" in child)
67
+ problems.push(...styleArtifactProblems("inline.css", child.value));
68
+ }
69
+ }
70
+ if ("childNodes" in node) for (const child of node.childNodes) visit(child);
71
+ }
72
+ visit(ast);
73
+ } else if (filename.endsWith(".js")) {
74
+ if (tailwindMarkers.test(source)) problems.push("Tailwind script fingerprint");
75
+ const styleReferences = source.replace(/\[data-[^\]]+\]/gu, "");
76
+ for (const match of styleReferences.matchAll(/--[\w-]+|[a-zA-Z_][\w-]*/gu)) {
77
+ if (
78
+ styleNameIn(styleNames.classes, match[0]) ||
79
+ styleNameIn(styleNames.variables, match[0]) ||
80
+ styleNameIn(styleNames.animations, match[0])
81
+ )
82
+ problems.push(`uncompacted script name ${match[0]}`);
83
+ }
84
+ } else if (filename.endsWith(".map")) {
85
+ const map = JSON.parse(source) as { sourcesContent?: string[] };
86
+ if (
87
+ map.sourcesContent?.some((content) =>
88
+ /style-names\.generated|stalux-main|--un-/u.test(content),
89
+ )
90
+ )
91
+ problems.push("public sourcemap exposes theme names");
92
+ }
93
+ return [...new Set(problems)];
94
+ }
@@ -0,0 +1,4 @@
1
+ /** Dictionary access must not resolve inherited names such as constructor or toString. */
2
+ export function styleNameIn(table: Record<string, string>, name: string): string | undefined {
3
+ return Object.hasOwn(table, name) ? table[name] : undefined;
4
+ }