@caelo-cms/shared 0.10.21 → 0.10.23
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/dist/ai-tools.d.ts +291 -231
- package/dist/ai-tools.d.ts.map +1 -1
- package/dist/ai-tools.js +349 -281
- package/dist/ai-tools.js.map +1 -1
- package/dist/auth-forms.d.ts.map +1 -1
- package/dist/auth-forms.js +4 -1
- package/dist/auth-forms.js.map +1 -1
- package/dist/base-css.d.ts +24 -0
- package/dist/base-css.d.ts.map +1 -0
- package/dist/base-css.js +28 -0
- package/dist/base-css.js.map +1 -0
- package/dist/build-page.d.ts +330 -0
- package/dist/build-page.d.ts.map +1 -0
- package/dist/build-page.js +282 -0
- package/dist/build-page.js.map +1 -0
- package/dist/content.d.ts +322 -9
- package/dist/content.d.ts.map +1 -1
- package/dist/content.js +354 -11
- package/dist/content.js.map +1 -1
- package/dist/css-gradient-scan.d.ts +14 -0
- package/dist/css-gradient-scan.d.ts.map +1 -0
- package/dist/css-gradient-scan.js +81 -0
- package/dist/css-gradient-scan.js.map +1 -0
- package/dist/css-var-scan.d.ts +56 -0
- package/dist/css-var-scan.d.ts.map +1 -0
- package/dist/css-var-scan.js +97 -0
- package/dist/css-var-scan.js.map +1 -0
- package/dist/design-manifest.d.ts +36 -0
- package/dist/design-manifest.d.ts.map +1 -0
- package/dist/design-manifest.js +90 -0
- package/dist/design-manifest.js.map +1 -0
- package/dist/fonts.d.ts +89 -0
- package/dist/fonts.d.ts.map +1 -0
- package/dist/fonts.js +241 -0
- package/dist/fonts.js.map +1 -0
- package/dist/genesis-inventory.d.ts +32 -0
- package/dist/genesis-inventory.d.ts.map +1 -0
- package/dist/genesis-inventory.js +186 -0
- package/dist/genesis-inventory.js.map +1 -0
- package/dist/genesis.d.ts +62 -0
- package/dist/genesis.d.ts.map +1 -0
- package/dist/genesis.js +78 -0
- package/dist/genesis.js.map +1 -0
- package/dist/i18n.d.ts +44 -1
- package/dist/i18n.d.ts.map +1 -1
- package/dist/i18n.js +72 -6
- package/dist/i18n.js.map +1 -1
- package/dist/index.d.ts +27 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +27 -0
- package/dist/index.js.map +1 -1
- package/dist/interactions.d.ts +23 -0
- package/dist/interactions.d.ts.map +1 -0
- package/dist/interactions.js +44 -0
- package/dist/interactions.js.map +1 -0
- package/dist/media.d.ts +101 -16
- package/dist/media.d.ts.map +1 -1
- package/dist/media.js +126 -15
- package/dist/media.js.map +1 -1
- package/dist/page-log.d.ts +94 -0
- package/dist/page-log.d.ts.map +1 -0
- package/dist/page-log.js +111 -0
- package/dist/page-log.js.map +1 -0
- package/dist/preview-compose.d.ts +79 -0
- package/dist/preview-compose.d.ts.map +1 -1
- package/dist/preview-compose.js +155 -25
- package/dist/preview-compose.js.map +1 -1
- package/dist/proposal-status.d.ts +40 -0
- package/dist/proposal-status.d.ts.map +1 -0
- package/dist/proposal-status.js +34 -0
- package/dist/proposal-status.js.map +1 -0
- package/dist/responsive-images.d.ts +64 -0
- package/dist/responsive-images.d.ts.map +1 -0
- package/dist/responsive-images.js +98 -0
- package/dist/responsive-images.js.map +1 -0
- package/dist/safe-keys.d.ts +9 -0
- package/dist/safe-keys.d.ts.map +1 -0
- package/dist/safe-keys.js +20 -0
- package/dist/safe-keys.js.map +1 -0
- package/dist/seo.d.ts +8 -0
- package/dist/seo.d.ts.map +1 -1
- package/dist/seo.js +3 -1
- package/dist/seo.js.map +1 -1
- package/dist/skills.d.ts +14 -68
- package/dist/skills.d.ts.map +1 -1
- package/dist/skills.js +19 -113
- package/dist/skills.js.map +1 -1
- package/dist/strip-cdata.d.ts +7 -0
- package/dist/strip-cdata.d.ts.map +1 -0
- package/dist/strip-cdata.js +48 -0
- package/dist/strip-cdata.js.map +1 -0
- package/dist/structured-sets.d.ts +6 -16
- package/dist/structured-sets.d.ts.map +1 -1
- package/dist/structured-sets.js +5 -16
- package/dist/structured-sets.js.map +1 -1
- package/dist/subagents.d.ts +105 -3
- package/dist/subagents.d.ts.map +1 -1
- package/dist/subagents.js +224 -41
- package/dist/subagents.js.map +1 -1
- package/dist/template-engine.d.ts +85 -0
- package/dist/template-engine.d.ts.map +1 -0
- package/dist/template-engine.js +403 -0
- package/dist/template-engine.js.map +1 -0
- package/dist/theme-importers/auto-detect.d.ts +26 -0
- package/dist/theme-importers/auto-detect.d.ts.map +1 -0
- package/dist/theme-importers/auto-detect.js +42 -0
- package/dist/theme-importers/auto-detect.js.map +1 -0
- package/dist/theme-importers/css-comments.d.ts +12 -0
- package/dist/theme-importers/css-comments.d.ts.map +1 -0
- package/dist/theme-importers/css-comments.js +15 -0
- package/dist/theme-importers/css-comments.js.map +1 -0
- package/dist/theme-importers/dtcg.d.ts +46 -0
- package/dist/theme-importers/dtcg.d.ts.map +1 -0
- package/dist/theme-importers/dtcg.js +111 -0
- package/dist/theme-importers/dtcg.js.map +1 -0
- package/dist/theme-importers/loose.d.ts +3 -0
- package/dist/theme-importers/loose.d.ts.map +1 -0
- package/dist/theme-importers/loose.js +76 -0
- package/dist/theme-importers/loose.js.map +1 -0
- package/dist/theme-importers/shadcn.d.ts +24 -0
- package/dist/theme-importers/shadcn.d.ts.map +1 -0
- package/dist/theme-importers/shadcn.js +135 -0
- package/dist/theme-importers/shadcn.js.map +1 -0
- package/dist/theme-importers/style-dictionary.d.ts +17 -0
- package/dist/theme-importers/style-dictionary.d.ts.map +1 -0
- package/dist/theme-importers/style-dictionary.js +125 -0
- package/dist/theme-importers/style-dictionary.js.map +1 -0
- package/dist/theme-importers/tailwind.d.ts +3 -0
- package/dist/theme-importers/tailwind.d.ts.map +1 -0
- package/dist/theme-importers/tailwind.js +218 -0
- package/dist/theme-importers/tailwind.js.map +1 -0
- package/dist/theme-literal-binding.d.ts +37 -0
- package/dist/theme-literal-binding.d.ts.map +1 -0
- package/dist/theme-literal-binding.js +138 -0
- package/dist/theme-literal-binding.js.map +1 -0
- package/dist/theme-normalize.d.ts +31 -0
- package/dist/theme-normalize.d.ts.map +1 -0
- package/dist/theme-normalize.js +587 -0
- package/dist/theme-normalize.js.map +1 -0
- package/dist/theme-ramp.d.ts +55 -0
- package/dist/theme-ramp.d.ts.map +1 -0
- package/dist/theme-ramp.js +149 -0
- package/dist/theme-ramp.js.map +1 -0
- package/dist/theme-render.d.ts +105 -0
- package/dist/theme-render.d.ts.map +1 -0
- package/dist/theme-render.js +441 -0
- package/dist/theme-render.js.map +1 -0
- package/dist/themes-errors.d.ts +109 -0
- package/dist/themes-errors.d.ts.map +1 -0
- package/dist/themes-errors.js +170 -0
- package/dist/themes-errors.js.map +1 -0
- package/dist/themes.d.ts +343 -0
- package/dist/themes.d.ts.map +1 -0
- package/dist/themes.js +697 -0
- package/dist/themes.js.map +1 -0
- package/dist/version.d.ts +7 -4
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +6 -3
- package/dist/version.js.map +1 -1
- package/package.json +10 -2
- package/src/__tests__/redos-hardening.test.ts +160 -0
- package/src/ai-tools-add-module-modes.test.ts +106 -0
- package/src/ai-tools-position.test.ts +134 -0
- package/src/ai-tools.test.ts +81 -0
- package/src/ai-tools.ts +1179 -0
- package/src/auth-forms.ts +36 -0
- package/src/base-css.ts +30 -0
- package/src/build-page.test.ts +228 -0
- package/src/build-page.ts +319 -0
- package/src/cap-failures.ts +67 -0
- package/src/content.test.ts +170 -0
- package/src/content.ts +620 -0
- package/src/context.ts +43 -0
- package/src/css-gradient-scan.ts +88 -0
- package/src/css-var-scan.test.ts +96 -0
- package/src/css-var-scan.ts +144 -0
- package/src/derive-module-type.test.ts +80 -0
- package/src/design-manifest.ts +93 -0
- package/src/fonts.test.ts +157 -0
- package/src/fonts.ts +296 -0
- package/src/genesis-inventory.test.ts +86 -0
- package/src/genesis-inventory.ts +215 -0
- package/src/genesis-sanitize.test.ts +35 -0
- package/src/genesis.ts +87 -0
- package/src/i18n.test.ts +274 -0
- package/src/i18n.ts +269 -0
- package/src/index.test.ts +10 -0
- package/src/index.ts +59 -0
- package/src/interactions.ts +48 -0
- package/src/logger.ts +147 -0
- package/src/media.test.ts +160 -0
- package/src/media.ts +355 -0
- package/src/page-log.test.ts +163 -0
- package/src/page-log.ts +124 -0
- package/src/preview-compose.test.ts +602 -0
- package/src/preview-compose.ts +709 -0
- package/src/preview-scanner.test.ts +96 -0
- package/src/preview-scanner.ts +214 -0
- package/src/proposal-status.test.ts +69 -0
- package/src/proposal-status.ts +40 -0
- package/src/responsive-images.test.ts +104 -0
- package/src/responsive-images.ts +151 -0
- package/src/result.ts +29 -0
- package/src/safe-keys.ts +21 -0
- package/src/seo.test.ts +234 -0
- package/src/seo.ts +261 -0
- package/src/skills.ts +48 -0
- package/src/snapshots.test.ts +80 -0
- package/src/snapshots.ts +81 -0
- package/src/strip-cdata.test.ts +41 -0
- package/src/strip-cdata.ts +50 -0
- package/src/structured-sets.ts +180 -0
- package/src/subagents.test.ts +262 -0
- package/src/subagents.ts +432 -0
- package/src/template-engine.test.ts +379 -0
- package/src/template-engine.ts +520 -0
- package/src/theme-gradient.test.ts +92 -0
- package/src/theme-importers/__tests__/proto-pollution.test.ts +54 -0
- package/src/theme-importers/auto-detect.ts +84 -0
- package/src/theme-importers/css-comments.ts +15 -0
- package/src/theme-importers/dtcg.ts +106 -0
- package/src/theme-importers/loose.ts +76 -0
- package/src/theme-importers/shadcn.ts +133 -0
- package/src/theme-importers/style-dictionary.ts +125 -0
- package/src/theme-importers/tailwind.ts +217 -0
- package/src/theme-literal-binding.test.ts +71 -0
- package/src/theme-literal-binding.ts +159 -0
- package/src/theme-motion.test.ts +115 -0
- package/src/theme-normalize-envelope.test.ts +43 -0
- package/src/theme-normalize-gradient.test.ts +135 -0
- package/src/theme-normalize.ts +661 -0
- package/src/theme-ramp.ts +187 -0
- package/src/theme-render-sanitize.test.ts +45 -0
- package/src/theme-render.test.ts +119 -0
- package/src/theme-render.ts +487 -0
- package/src/theme-shadow.test.ts +56 -0
- package/src/themes-errors.ts +199 -0
- package/src/themes.ts +842 -0
- package/src/translation.test.ts +160 -0
- package/src/translation.ts +295 -0
- package/src/version.ts +66 -0
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* v0.11.0 — DTCG-aligned theme renderer (#45 Phase 3).
|
|
5
|
+
*
|
|
6
|
+
* Walks a `ThemeDocument` (DTCG tokens jsonb) and emits CSS custom
|
|
7
|
+
* properties under the Tailwind 4 namespace convention so module CSS
|
|
8
|
+
* can `var(--color-primary)` / `var(--spacing-lg)` etc. without
|
|
9
|
+
* mental-translation from DTCG paths.
|
|
10
|
+
*
|
|
11
|
+
* Namespace mapping (matches Tailwind 4's `@theme { … }` directive):
|
|
12
|
+
*
|
|
13
|
+
* color.X → --color-X
|
|
14
|
+
* typography.X → --font-X (fontFamily) + --text-X (fontSize)
|
|
15
|
+
* + --font-weight-X + --leading-X + --tracking-X
|
|
16
|
+
* spacing.X → --spacing-X
|
|
17
|
+
* radius.X → --radius-X
|
|
18
|
+
* shadow.X → --shadow-X (expanded to `<x> <y> <blur> [spread] <color> [inset]`)
|
|
19
|
+
* breakpoint.X → --breakpoint-X
|
|
20
|
+
* duration.X → --duration-X
|
|
21
|
+
* ease.X → --ease-X (cubic-bezier expansion)
|
|
22
|
+
*
|
|
23
|
+
* Color tokens carrying `{ light, dark }` emit the light value inside
|
|
24
|
+
* `:root { … }` AND the dark value inside `:root.dark { … }` so a
|
|
25
|
+
* single declaration drives both surfaces.
|
|
26
|
+
*
|
|
27
|
+
* DTCG aliases (`"{group.token}"`) are resolved before emission.
|
|
28
|
+
* Cyclic aliases produce a structured error rather than infinite-
|
|
29
|
+
* looping. Pure function — no IO, dependency-free, testable in unit
|
|
30
|
+
* isolation.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { type AnyThemeToken, flattenTokens, type ThemeDocument } from "./themes.js";
|
|
34
|
+
|
|
35
|
+
/** Renderer-level error for cyclic / unresolved aliases. */
|
|
36
|
+
export class ThemeRenderError extends Error {
|
|
37
|
+
readonly kind: "cyclic-alias" | "missing-alias";
|
|
38
|
+
readonly path: string;
|
|
39
|
+
constructor(kind: "cyclic-alias" | "missing-alias", path: string, message: string) {
|
|
40
|
+
super(message);
|
|
41
|
+
this.name = "ThemeRenderError";
|
|
42
|
+
this.kind = kind;
|
|
43
|
+
this.path = path;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Optional render options. The `selector` parameter (v0.11.1, issue #76)
|
|
49
|
+
* scopes the emitted CSS variables under a custom selector so the admin
|
|
50
|
+
* live-preview pane can render the byte-identical CSS without leaking
|
|
51
|
+
* the theme's tokens onto the surrounding admin chrome. Default `:root`
|
|
52
|
+
* keeps the static-gen + chat-runner paths unchanged.
|
|
53
|
+
*/
|
|
54
|
+
export interface RenderThemeCssOptions {
|
|
55
|
+
/**
|
|
56
|
+
* Selector to wrap the emitted rule blocks. Defaults to `:root`. The
|
|
57
|
+
* dark-variant block becomes `${selector}.dark` so a single `.dark`
|
|
58
|
+
* class toggle on the scoping element flips both surfaces — same
|
|
59
|
+
* behaviour as the public site's `:root` / `:root.dark` pairing.
|
|
60
|
+
*/
|
|
61
|
+
readonly selector?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Emit the `:root { … }` (+ optional `:root.dark { … }`) CSS for the
|
|
66
|
+
* supplied tokens document. Returns the bare CSS body — caller wraps
|
|
67
|
+
* it in `<style data-source="theme">…</style>` and injects it ahead of
|
|
68
|
+
* module CSS in the document head.
|
|
69
|
+
*
|
|
70
|
+
* Pass `options.selector` (v0.11.1) to scope the rule blocks under a
|
|
71
|
+
* custom selector instead of `:root`. The admin live preview uses
|
|
72
|
+
* `.theme-preview` so the operator's edits don't leak onto the admin
|
|
73
|
+
* UI. Public + chat paths omit the option and get the default.
|
|
74
|
+
*/
|
|
75
|
+
export function renderThemeCss(tokens: ThemeDocument, options: RenderThemeCssOptions = {}): string {
|
|
76
|
+
const selector = options.selector ?? ":root";
|
|
77
|
+
const flat = flattenTokens(tokens);
|
|
78
|
+
const resolveCache = new Map<string, unknown>();
|
|
79
|
+
const lightLines: string[] = [];
|
|
80
|
+
const darkLines: string[] = [];
|
|
81
|
+
|
|
82
|
+
for (const { path, token } of flat) {
|
|
83
|
+
const category = path.split(".")[0] ?? "";
|
|
84
|
+
const value = resolveTokenValue(token, tokens, resolveCache, new Set([path]));
|
|
85
|
+
emitTokenLines(category, path, value, lightLines, darkLines);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const lightBlock = `${selector}{${lightLines.join("")}}`;
|
|
89
|
+
if (darkLines.length === 0) return lightBlock;
|
|
90
|
+
return `${lightBlock}${selector}.dark{${darkLines.join("")}}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ────────────────────────────────────────────────────────────────────
|
|
94
|
+
// v0.11.1 (issue #76) — `get_theme as` format helpers
|
|
95
|
+
// ────────────────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Named export of the `:root { … }` CSS-vars form. Alias for
|
|
99
|
+
* `renderThemeCss(tokens)` — exists so the AI tool's `as: "css-vars"`
|
|
100
|
+
* dispatch table reads symmetrically with the other format names.
|
|
101
|
+
*/
|
|
102
|
+
export function formatThemeAsCssVars(tokens: ThemeDocument): string {
|
|
103
|
+
return renderThemeCss(tokens);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* v0.11.4 (issue #76 follow-up) — return JUST the CSS variable names
|
|
108
|
+
* the renderer would emit for this token document. The AI uses this in
|
|
109
|
+
* the system-prompt `## Theme` block so it knows exactly which
|
|
110
|
+
* `var(--…)` names exist before authoring module CSS.
|
|
111
|
+
*
|
|
112
|
+
* Without this surface the AI guesses var names (e.g. `--color-text`,
|
|
113
|
+
* `--color-surface`) that look reasonable but don't exist in the
|
|
114
|
+
* shadcn-style theme — its CSS then falls through to hardcoded
|
|
115
|
+
* fallbacks and the page renders monochrome regardless of token values.
|
|
116
|
+
*
|
|
117
|
+
* Returns a sorted, de-duplicated array. Emits the same names as the
|
|
118
|
+
* `renderThemeCss` light branch (the dark variant uses the same names
|
|
119
|
+
* with different values, so listing them once is correct).
|
|
120
|
+
*/
|
|
121
|
+
export function listThemeCssVarNames(tokens: ThemeDocument): readonly string[] {
|
|
122
|
+
const flat = flattenTokens(tokens);
|
|
123
|
+
if (flat.length === 0) return [];
|
|
124
|
+
const resolveCache = new Map<string, unknown>();
|
|
125
|
+
const lines: string[] = [];
|
|
126
|
+
for (const { path, token } of flat) {
|
|
127
|
+
const category = path.split(".")[0] ?? "";
|
|
128
|
+
const value = resolveTokenValue(token, tokens, resolveCache, new Set([path]));
|
|
129
|
+
emitTokenLines(category, path, value, lines, []);
|
|
130
|
+
}
|
|
131
|
+
// Each `lines` entry is `--name:value;`. Strip the value to get the name.
|
|
132
|
+
const names = new Set<string>();
|
|
133
|
+
for (const line of lines) {
|
|
134
|
+
const colon = line.indexOf(":");
|
|
135
|
+
if (colon <= 0) continue;
|
|
136
|
+
const name = line.slice(0, colon).trim();
|
|
137
|
+
if (name.startsWith("--")) names.add(name);
|
|
138
|
+
}
|
|
139
|
+
return [...names].sort();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Emit a Tailwind 4 `@theme inline { … }` block. The body re-uses the
|
|
144
|
+
* same per-category emission as `renderThemeCss` so the variable names
|
|
145
|
+
* match what `app.css` exposes — feeding this output into the Tailwind
|
|
146
|
+
* importer round-trips to the same DTCG document.
|
|
147
|
+
*/
|
|
148
|
+
export function formatThemeAsTailwind(tokens: ThemeDocument): string {
|
|
149
|
+
const flat = flattenTokens(tokens);
|
|
150
|
+
const resolveCache = new Map<string, unknown>();
|
|
151
|
+
const lightLines: string[] = [];
|
|
152
|
+
const darkLines: string[] = [];
|
|
153
|
+
for (const { path, token } of flat) {
|
|
154
|
+
const category = path.split(".")[0] ?? "";
|
|
155
|
+
const value = resolveTokenValue(token, tokens, resolveCache, new Set([path]));
|
|
156
|
+
emitTokenLines(category, path, value, lightLines, darkLines);
|
|
157
|
+
}
|
|
158
|
+
// Tailwind 4 `@theme` doesn't have a built-in dark variant; emit the
|
|
159
|
+
// light values in the `@theme inline` block and append a `.dark { … }`
|
|
160
|
+
// override block at module top-level (matches Caelo's app.css setup).
|
|
161
|
+
const themeBody = lightLines.join("");
|
|
162
|
+
if (darkLines.length === 0) return `@theme inline {${themeBody}}`;
|
|
163
|
+
return `@theme inline {${themeBody}}\n.dark{${darkLines.join("")}}`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Emit a terse one-line summary of the tokens document — primary color
|
|
168
|
+
* shorthand, body font, default radius, and category counts. Used by
|
|
169
|
+
* the system-prompt `## Theme` block so the AI sees the theme's feel
|
|
170
|
+
* without spending tokens on the full DTCG document.
|
|
171
|
+
*
|
|
172
|
+
* Format: `primary=<hex|oklch>, body=<fontFamily-first-name>, radius=<value>, <category counts>`.
|
|
173
|
+
* Falls back to category counts only when the canonical paths aren't
|
|
174
|
+
* populated (e.g. an empty or unusual theme).
|
|
175
|
+
*/
|
|
176
|
+
export function formatThemeSummary(tokens: ThemeDocument): string {
|
|
177
|
+
const flat = flattenTokens(tokens);
|
|
178
|
+
if (flat.length === 0) return "no tokens";
|
|
179
|
+
|
|
180
|
+
const resolveCache = new Map<string, unknown>();
|
|
181
|
+
let primary: string | undefined;
|
|
182
|
+
let body: string | undefined;
|
|
183
|
+
let radiusDefault: string | undefined;
|
|
184
|
+
const counts = new Map<string, number>();
|
|
185
|
+
for (const { path, token } of flat) {
|
|
186
|
+
const category = path.split(".")[0] ?? "(uncategorised)";
|
|
187
|
+
counts.set(category, (counts.get(category) ?? 0) + 1);
|
|
188
|
+
if (
|
|
189
|
+
path === "color.primary" ||
|
|
190
|
+
path === "color.primary.500" ||
|
|
191
|
+
path === "color.primary.DEFAULT"
|
|
192
|
+
) {
|
|
193
|
+
const v = resolveTokenValue(token, tokens, resolveCache, new Set([path]));
|
|
194
|
+
const flatVal = pickColorVariant(v);
|
|
195
|
+
if (typeof flatVal === "string" && primary === undefined) primary = flatVal;
|
|
196
|
+
}
|
|
197
|
+
if (path === "typography.body") {
|
|
198
|
+
const v = resolveTokenValue(token, tokens, resolveCache, new Set([path]));
|
|
199
|
+
if (v && typeof v === "object" && "fontFamily" in v) {
|
|
200
|
+
const ff = (v as { fontFamily?: unknown }).fontFamily;
|
|
201
|
+
if (typeof ff === "string")
|
|
202
|
+
body = ff
|
|
203
|
+
.split(",")[0]
|
|
204
|
+
?.trim()
|
|
205
|
+
.replace(/^['"]|['"]$/g, "");
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (path === "radius.md") {
|
|
209
|
+
const v = resolveTokenValue(token, tokens, resolveCache, new Set([path]));
|
|
210
|
+
if (typeof v === "string") radiusDefault = v;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const parts: string[] = [];
|
|
215
|
+
if (primary) parts.push(`primary=${primary}`);
|
|
216
|
+
if (body) parts.push(`body=${body}`);
|
|
217
|
+
if (radiusDefault) parts.push(`radius=${radiusDefault}`);
|
|
218
|
+
const categoryParts: string[] = [];
|
|
219
|
+
for (const [k, n] of counts) categoryParts.push(`${n} ${k}`);
|
|
220
|
+
if (categoryParts.length > 0) parts.push(categoryParts.join("/"));
|
|
221
|
+
return parts.join(", ");
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function pickColorVariant(value: unknown): unknown {
|
|
225
|
+
if (value && typeof value === "object" && "light" in value) {
|
|
226
|
+
return (value as { light: unknown }).light;
|
|
227
|
+
}
|
|
228
|
+
return value;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ────────────────────────────────────────────────────────────────────
|
|
232
|
+
// Alias resolution
|
|
233
|
+
// ────────────────────────────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
const ALIAS_REGEX = /^\{([a-zA-Z0-9_.-]+)\}$/;
|
|
236
|
+
|
|
237
|
+
function resolveTokenValue(
|
|
238
|
+
token: AnyThemeToken,
|
|
239
|
+
doc: ThemeDocument,
|
|
240
|
+
cache: Map<string, unknown>,
|
|
241
|
+
seen: Set<string>,
|
|
242
|
+
): unknown {
|
|
243
|
+
return resolveValue(token.$value, doc, cache, seen);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function resolveValue(
|
|
247
|
+
raw: unknown,
|
|
248
|
+
doc: ThemeDocument,
|
|
249
|
+
cache: Map<string, unknown>,
|
|
250
|
+
seen: Set<string>,
|
|
251
|
+
): unknown {
|
|
252
|
+
if (typeof raw === "string") {
|
|
253
|
+
const m = ALIAS_REGEX.exec(raw);
|
|
254
|
+
if (!m) return raw;
|
|
255
|
+
const targetPath = m[1];
|
|
256
|
+
if (!targetPath) return raw;
|
|
257
|
+
if (seen.has(targetPath)) {
|
|
258
|
+
throw new ThemeRenderError(
|
|
259
|
+
"cyclic-alias",
|
|
260
|
+
targetPath,
|
|
261
|
+
`cyclic DTCG alias detected at '${targetPath}' (chain: ${[...seen].join(" → ")} → ${targetPath})`,
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
if (cache.has(targetPath)) return cache.get(targetPath);
|
|
265
|
+
const target = lookupPath(doc, targetPath);
|
|
266
|
+
if (!target) {
|
|
267
|
+
throw new ThemeRenderError(
|
|
268
|
+
"missing-alias",
|
|
269
|
+
targetPath,
|
|
270
|
+
`DTCG alias '{${targetPath}}' points at a path that doesn't exist in this theme`,
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
const resolved = resolveValue(target.$value, doc, cache, new Set([...seen, targetPath]));
|
|
274
|
+
cache.set(targetPath, resolved);
|
|
275
|
+
return resolved;
|
|
276
|
+
}
|
|
277
|
+
// Composite ({ light, dark } color, typography sub-fields, shadow sub-fields).
|
|
278
|
+
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
|
279
|
+
const obj = raw as Record<string, unknown>;
|
|
280
|
+
const out: Record<string, unknown> = {};
|
|
281
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
282
|
+
out[k] = resolveValue(v, doc, cache, seen);
|
|
283
|
+
}
|
|
284
|
+
return out;
|
|
285
|
+
}
|
|
286
|
+
return raw;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function lookupPath(doc: ThemeDocument, path: string): AnyThemeToken | null {
|
|
290
|
+
const parts = path.split(".");
|
|
291
|
+
let cur: unknown = doc;
|
|
292
|
+
for (const p of parts) {
|
|
293
|
+
if (cur === null || typeof cur !== "object") return null;
|
|
294
|
+
cur = (cur as Record<string, unknown>)[p];
|
|
295
|
+
if (cur === undefined) return null;
|
|
296
|
+
}
|
|
297
|
+
if (cur && typeof cur === "object" && "$value" in cur) return cur as AnyThemeToken;
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ────────────────────────────────────────────────────────────────────
|
|
302
|
+
// Per-category CSS emission
|
|
303
|
+
// ────────────────────────────────────────────────────────────────────
|
|
304
|
+
|
|
305
|
+
function emitTokenLines(
|
|
306
|
+
category: string,
|
|
307
|
+
path: string,
|
|
308
|
+
value: unknown,
|
|
309
|
+
lightLines: string[],
|
|
310
|
+
darkLines: string[],
|
|
311
|
+
): void {
|
|
312
|
+
// Strip the category prefix to get the rest of the path. e.g.
|
|
313
|
+
// `color.brand.primary` → `brand-primary` becomes `--color-brand-primary`.
|
|
314
|
+
//
|
|
315
|
+
// v0.11.1 (issue #76) — Tailwind/shadcn convention: a leaf at
|
|
316
|
+
// `<group>.DEFAULT` (e.g. the OKLCh ramp's `color.primary.DEFAULT`
|
|
317
|
+
// alias generated by deriveOklchPrimaryRamp) emits `--<category>-<group>`
|
|
318
|
+
// (NOT `--<category>-<group>-DEFAULT`) so module HTML using
|
|
319
|
+
// `var(--color-primary)` resolves to the brand color. Without this,
|
|
320
|
+
// the ramp's group emits `--color-primary-50` … `--color-primary-900`
|
|
321
|
+
// + `--color-primary-DEFAULT` but no bare `--color-primary` — and
|
|
322
|
+
// consumers fall back to the cascade. This special-case applies to
|
|
323
|
+
// every category (color/spacing/radius/etc.) so any future
|
|
324
|
+
// DTCG-group-with-DEFAULT-alias works the same way.
|
|
325
|
+
const restRaw = path.slice(category.length + 1);
|
|
326
|
+
const rest = restRaw.endsWith(".DEFAULT")
|
|
327
|
+
? restRaw.slice(0, -".DEFAULT".length).replace(/\./g, "-")
|
|
328
|
+
: restRaw.replace(/\./g, "-");
|
|
329
|
+
const baseName = `--${category === "ease" ? "ease" : category}-${rest}`;
|
|
330
|
+
|
|
331
|
+
switch (category) {
|
|
332
|
+
case "color":
|
|
333
|
+
emitColor(baseName, value, lightLines, darkLines);
|
|
334
|
+
break;
|
|
335
|
+
case "typography":
|
|
336
|
+
emitTypography(rest, value, lightLines);
|
|
337
|
+
break;
|
|
338
|
+
case "spacing":
|
|
339
|
+
case "radius":
|
|
340
|
+
case "breakpoint":
|
|
341
|
+
emitSimple(baseName, value, lightLines);
|
|
342
|
+
break;
|
|
343
|
+
case "shadow":
|
|
344
|
+
emitShadow(baseName, value, lightLines);
|
|
345
|
+
break;
|
|
346
|
+
case "duration":
|
|
347
|
+
emitSimple(baseName, value, lightLines);
|
|
348
|
+
break;
|
|
349
|
+
case "ease":
|
|
350
|
+
emitEase(baseName, value, lightLines);
|
|
351
|
+
break;
|
|
352
|
+
case "gradient":
|
|
353
|
+
// issue #153 — first-class since v0.13: CSS gradient string →
|
|
354
|
+
// `--gradient-<name>` (value scrubbed by sanitizeCssTokenValue
|
|
355
|
+
// like every emitted token).
|
|
356
|
+
emitSimple(baseName, value, lightLines);
|
|
357
|
+
break;
|
|
358
|
+
default:
|
|
359
|
+
// Unknown category — emit as `--<category>-<rest>: <value>` so
|
|
360
|
+
// forward-compat tokens still appear in CSS rather than silently
|
|
361
|
+
// disappearing.
|
|
362
|
+
emitSimple(baseName, value, lightLines);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function emitColor(
|
|
367
|
+
baseName: string,
|
|
368
|
+
value: unknown,
|
|
369
|
+
lightLines: string[],
|
|
370
|
+
darkLines: string[],
|
|
371
|
+
): void {
|
|
372
|
+
if (value && typeof value === "object" && "light" in value && "dark" in value) {
|
|
373
|
+
const both = value as { light: unknown; dark: unknown };
|
|
374
|
+
lightLines.push(`${baseName}:${asString(both.light)};`);
|
|
375
|
+
darkLines.push(`${baseName}:${asString(both.dark)};`);
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
lightLines.push(`${baseName}:${asString(value)};`);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function emitTypography(rest: string, value: unknown, out: string[]): void {
|
|
382
|
+
if (typeof value !== "object" || value === null) {
|
|
383
|
+
// Alias resolved to a scalar; emit on --font-<rest>.
|
|
384
|
+
out.push(`--font-${rest}:${asString(value)};`);
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
const v = value as Record<string, unknown>;
|
|
388
|
+
if (v.fontFamily !== undefined) out.push(`--font-${rest}:${asString(v.fontFamily)};`);
|
|
389
|
+
if (v.fontSize !== undefined) out.push(`--text-${rest}:${asString(v.fontSize)};`);
|
|
390
|
+
if (v.fontWeight !== undefined) out.push(`--font-weight-${rest}:${asString(v.fontWeight)};`);
|
|
391
|
+
if (v.lineHeight !== undefined) out.push(`--leading-${rest}:${asString(v.lineHeight)};`);
|
|
392
|
+
if (v.letterSpacing !== undefined) out.push(`--tracking-${rest}:${asString(v.letterSpacing)};`);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function emitShadow(baseName: string, value: unknown, out: string[]): void {
|
|
396
|
+
// Literal CSS box-shadow string (the tolerant `shadowValueString` form).
|
|
397
|
+
// Emit verbatim — the DTCG object form is expanded below.
|
|
398
|
+
if (typeof value === "string") {
|
|
399
|
+
const s = value.trim();
|
|
400
|
+
if (s.length > 0) out.push(`${baseName}:${s};`);
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
if (Array.isArray(value)) {
|
|
404
|
+
// Layered shadow — join with comma so CSS receives multi-layer.
|
|
405
|
+
const layers = value
|
|
406
|
+
.map((layer) => shadowLayerToCss(layer))
|
|
407
|
+
.filter((s): s is string => s !== null);
|
|
408
|
+
if (layers.length > 0) out.push(`${baseName}:${layers.join(",")};`);
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
const single = shadowLayerToCss(value);
|
|
412
|
+
if (single !== null) out.push(`${baseName}:${single};`);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function shadowLayerToCss(layer: unknown): string | null {
|
|
416
|
+
if (!layer || typeof layer !== "object") return null;
|
|
417
|
+
const v = layer as Record<string, unknown>;
|
|
418
|
+
const x = asString(v.offsetX ?? "0");
|
|
419
|
+
const y = asString(v.offsetY ?? "0");
|
|
420
|
+
const blur = asString(v.blur ?? "0");
|
|
421
|
+
const spread = v.spread !== undefined ? ` ${asString(v.spread)}` : "";
|
|
422
|
+
const color = asString(v.color ?? "currentColor");
|
|
423
|
+
const inset = v.inset === true ? " inset" : "";
|
|
424
|
+
return `${x} ${y} ${blur}${spread} ${color}${inset}`;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function emitEase(baseName: string, value: unknown, out: string[]): void {
|
|
428
|
+
if (Array.isArray(value) && value.length === 4) {
|
|
429
|
+
out.push(`${baseName}:cubic-bezier(${value.map((n) => asString(n)).join(", ")});`);
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
out.push(`${baseName}:${asString(value)};`);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function emitSimple(baseName: string, value: unknown, out: string[]): void {
|
|
436
|
+
out.push(`${baseName}:${asString(value)};`);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function asString(value: unknown): string {
|
|
440
|
+
if (value === null || value === undefined) return "";
|
|
441
|
+
if (typeof value === "string") return sanitizeCssTokenValue(value);
|
|
442
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
443
|
+
return sanitizeCssTokenValue(JSON.stringify(value));
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* v0.11.0 fix (#45 review thread on theme-render.ts:207 — HIGH XSS) —
|
|
448
|
+
* scrub HTML-context escape sequences from a string that's about to
|
|
449
|
+
* be injected into a `<style>` block.
|
|
450
|
+
*
|
|
451
|
+
* CSS values are otherwise unrestricted in character set (font-family
|
|
452
|
+
* names legitimately contain quotes, commas, parens, hyphens), so we
|
|
453
|
+
* don't HTML-encode broadly — that would break perfectly-valid
|
|
454
|
+
* `font-family: "Segoe UI", sans-serif` declarations. Instead, scrub
|
|
455
|
+
* the two sequences that let an attacker break out of the surrounding
|
|
456
|
+
* `<style>…</style>` context:
|
|
457
|
+
*
|
|
458
|
+
* - `</style` (case-insensitive) — premature end-tag would close
|
|
459
|
+
* the style block and let everything after it parse as HTML
|
|
460
|
+
* (`fontFamily: "Inter</style><script>alert(1)</script>"`).
|
|
461
|
+
* - `<!--` / `-->` — CDO/CDC tokens; some HTML parsers treat them
|
|
462
|
+
* specially inside `<style>` and they're never valid in a real
|
|
463
|
+
* CSS value anyway.
|
|
464
|
+
*
|
|
465
|
+
* The themeColorToken / themeDimensionToken Zod regexes already
|
|
466
|
+
* constrain those token types to character sets that exclude `<`, so
|
|
467
|
+
* this scrub is the load-bearing defence for the free-form fields
|
|
468
|
+
* (`typography.X.fontFamily`, `$description` if it ever lands in CSS).
|
|
469
|
+
*/
|
|
470
|
+
function sanitizeCssTokenValue(value: string): string {
|
|
471
|
+
// Run to a fixed point: removing one sequence can re-create another
|
|
472
|
+
// (e.g. `<!--</style>-->` → `</style` only appears after the comment
|
|
473
|
+
// tokens go), which a single pass would leave behind (CodeQL
|
|
474
|
+
// js/incomplete-multi-character-sanitization). `--!>` is the HTML5
|
|
475
|
+
// alternative comment-close and must be scrubbed alongside `-->`
|
|
476
|
+
// (CodeQL js/bad-tag-filter).
|
|
477
|
+
let out = value;
|
|
478
|
+
let prev: string;
|
|
479
|
+
do {
|
|
480
|
+
prev = out;
|
|
481
|
+
out = out
|
|
482
|
+
.replace(/<\/style/gi, "")
|
|
483
|
+
.replace(/<!--/g, "")
|
|
484
|
+
.replace(/--!?>/g, "");
|
|
485
|
+
} while (out !== prev);
|
|
486
|
+
return out;
|
|
487
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Token efficiency (run-logs/token-efficiency-analysis.md): a live build
|
|
5
|
+
* turn failed on `set_theme_tokens` because the shadow token `$value`
|
|
6
|
+
* accepted only the DTCG object form, an array, or an alias — NOT the
|
|
7
|
+
* literal CSS `box-shadow` string a model naturally emits. That forced an
|
|
8
|
+
* error + retry. This pins the tolerant string form: it validates AND
|
|
9
|
+
* renders verbatim, while the object form still expands.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, expect, it } from "bun:test";
|
|
13
|
+
import { renderThemeCss } from "./theme-render.js";
|
|
14
|
+
import { type ThemeDocument, themeDocument } from "./themes.js";
|
|
15
|
+
|
|
16
|
+
function docWithShadow(value: unknown): unknown {
|
|
17
|
+
return {
|
|
18
|
+
color: { primary: { $type: "color", $value: "#4f46e5" } },
|
|
19
|
+
shadow: { lg: { $type: "shadow", $value: value } },
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("shadow token accepts literal CSS strings (token-efficiency fix)", () => {
|
|
24
|
+
it("accepts a literal CSS box-shadow string", () => {
|
|
25
|
+
for (const v of [
|
|
26
|
+
"0 4px 6px -1px rgba(0,0,0,0.1)",
|
|
27
|
+
"0 1px 2px 0 rgba(0, 0, 0, 0.05)",
|
|
28
|
+
"0 10px 15px -3px #00000022, 0 4px 6px -4px #00000022",
|
|
29
|
+
"none",
|
|
30
|
+
"inset 0 2px 4px rgba(0,0,0,0.06)",
|
|
31
|
+
]) {
|
|
32
|
+
const r = themeDocument.safeParse(docWithShadow(v));
|
|
33
|
+
expect(r.success).toBe(true);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("still accepts the DTCG object form and aliases", () => {
|
|
38
|
+
expect(
|
|
39
|
+
themeDocument.safeParse(
|
|
40
|
+
docWithShadow({ color: "#00000022", offsetX: "0", offsetY: "4px", blur: "6px" }),
|
|
41
|
+
).success,
|
|
42
|
+
).toBe(true);
|
|
43
|
+
expect(themeDocument.safeParse(docWithShadow("{shadow.base}")).success).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("rejects a declaration-breakout attempt", () => {
|
|
47
|
+
// `;` and `{` are outside the shadow char-class → not a valid literal.
|
|
48
|
+
expect(themeDocument.safeParse(docWithShadow("0 0 0 red; color: red")).success).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("renders a literal string shadow verbatim to CSS", () => {
|
|
52
|
+
const doc = docWithShadow("0 4px 6px -1px rgba(0,0,0,0.1)") as ThemeDocument;
|
|
53
|
+
const css = renderThemeCss(doc);
|
|
54
|
+
expect(css).toContain("--shadow-lg:0 4px 6px -1px rgba(0,0,0,0.1);");
|
|
55
|
+
});
|
|
56
|
+
});
|