@caelo-cms/shared 0.10.22 → 0.10.24
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 +289 -273
- package/dist/ai-tools.d.ts.map +1 -1
- package/dist/ai-tools.js +342 -323
- 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 +35 -0
- package/dist/base-css.d.ts.map +1 -0
- package/dist/base-css.js +40 -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-draft-shell.d.ts +21 -0
- package/dist/design-draft-shell.d.ts.map +1 -0
- package/dist/design-draft-shell.js +81 -0
- package/dist/design-draft-shell.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 +102 -0
- package/dist/genesis.d.ts.map +1 -0
- package/dist/genesis.js +145 -0
- package/dist/genesis.js.map +1 -0
- package/dist/i18n.d.ts +29 -36
- package/dist/i18n.d.ts.map +1 -1
- package/dist/i18n.js +53 -128
- package/dist/i18n.js.map +1 -1
- package/dist/index.d.ts +28 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +28 -1
- 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 +85 -12
- package/dist/preview-compose.d.ts.map +1 -1
- package/dist/preview-compose.js +157 -67
- 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 +11 -16
- package/dist/seo.d.ts.map +1 -1
- package/dist/seo.js +7 -23
- 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 -52
- package/dist/structured-sets.d.ts.map +1 -1
- package/dist/structured-sets.js +6 -68
- 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 +1105 -0
- package/src/auth-forms.ts +36 -0
- package/src/base-css.ts +42 -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-draft-shell.test.ts +85 -0
- package/src/design-draft-shell.ts +109 -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 +158 -0
- package/src/i18n.test.ts +58 -0
- package/src/i18n.ts +91 -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 +637 -0
- package/src/preview-compose.ts +656 -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 +194 -0
- package/src/seo.ts +233 -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 +114 -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/version.ts +66 -0
- package/dist/translation.d.ts +0 -127
- package/dist/translation.d.ts.map +0 -1
- package/dist/translation.js +0 -208
- package/dist/translation.js.map +0 -1
package/dist/themes.js
ADDED
|
@@ -0,0 +1,697 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
/**
|
|
3
|
+
* v0.11.0 — DTCG-aligned Zod schemas for the `themes` primitive (#45).
|
|
4
|
+
*
|
|
5
|
+
* Mirrors the W3C Design Tokens Format spec (2025.10 stable). Storage
|
|
6
|
+
* is a single jsonb document grouped by category (color / typography /
|
|
7
|
+
* spacing / radius / shadow / motion / breakpoint); each leaf carries a
|
|
8
|
+
* `$value` (required), optional `$type`, optional `$description`, and
|
|
9
|
+
* may use DTCG aliasing (`{group.token}`) to reference another token.
|
|
10
|
+
*
|
|
11
|
+
* Why DTCG: import/export from Figma / Tokens Studio / Style Dictionary
|
|
12
|
+
* works out of the box. The admin UI doesn't show raw DTCG to
|
|
13
|
+
* operators — it renders categorized panels — but the storage layer is
|
|
14
|
+
* the lingua franca so design tooling round-trips cleanly.
|
|
15
|
+
*
|
|
16
|
+
* Caelo extensions over plain DTCG:
|
|
17
|
+
* - Color tokens may carry `{ light, dark }` instead of a flat `$value`
|
|
18
|
+
* so the renderer can emit `:root { … }` + `:root.dark { … }` from
|
|
19
|
+
* one declaration.
|
|
20
|
+
* - The `$extensions` namespace is tolerated (DTCG-spec-compatible)
|
|
21
|
+
* for tooling-specific metadata; we never inspect it server-side.
|
|
22
|
+
*/
|
|
23
|
+
import { z } from "zod";
|
|
24
|
+
import { isUnsafeKey } from "./safe-keys.js";
|
|
25
|
+
// ────────────────────────────────────────────────────────────────────
|
|
26
|
+
// Primitives
|
|
27
|
+
// ────────────────────────────────────────────────────────────────────
|
|
28
|
+
/** DTCG alias: `"{group.token}"` references another token by path. */
|
|
29
|
+
const aliasRegex = /^\{[a-zA-Z0-9_.-]+\}$/;
|
|
30
|
+
const aliasString = z.string().regex(aliasRegex, "DTCG alias must look like '{group.token}'");
|
|
31
|
+
/**
|
|
32
|
+
* Color value: hex, oklch(...), rgb(...), hsl(...), or a named color.
|
|
33
|
+
* Loose enough to accept anything a browser would render; strict
|
|
34
|
+
* rejection lives at the loose-name normalizer where the AI can
|
|
35
|
+
* recover with a structured error.
|
|
36
|
+
*/
|
|
37
|
+
const colorValueString = z
|
|
38
|
+
.string()
|
|
39
|
+
.min(1)
|
|
40
|
+
.max(200)
|
|
41
|
+
.regex(/^(#[0-9a-fA-F]{3,8}|(oklch|rgb|rgba|hsl|hsla|lab|lch|color|hwb)\(.+\)|transparent|currentColor|[a-zA-Z]+)$/, "color value must be #hex, oklch(...), rgb(...), or a CSS named color");
|
|
42
|
+
/** Dimension: a CSS length / percentage / numeric value. */
|
|
43
|
+
const dimensionValueString = z
|
|
44
|
+
.string()
|
|
45
|
+
.min(1)
|
|
46
|
+
.max(80)
|
|
47
|
+
.regex(/^-?\d+(\.\d+)?(rem|em|px|%|vh|vw|vmin|vmax|pt|pc|ch|ex|fr|deg|rad|turn|s|ms)?$|^auto$|^0$/, "dimension must be a CSS length / number / percentage");
|
|
48
|
+
/** Font-weight: keyword or 1-1000 number. */
|
|
49
|
+
const fontWeightValue = z.union([
|
|
50
|
+
z.number().int().min(1).max(1000),
|
|
51
|
+
z.enum(["normal", "bold", "lighter", "bolder"]),
|
|
52
|
+
]);
|
|
53
|
+
const optionalDescription = z.string().max(500).optional();
|
|
54
|
+
// ────────────────────────────────────────────────────────────────────
|
|
55
|
+
// Per-type token shapes
|
|
56
|
+
// ────────────────────────────────────────────────────────────────────
|
|
57
|
+
const flatOrAliasColor = z.union([colorValueString, aliasString]);
|
|
58
|
+
const flatOrAliasDimension = z.union([dimensionValueString, aliasString]);
|
|
59
|
+
/**
|
|
60
|
+
* Color token. Either a flat `$value` or a `{light, dark}` pair where
|
|
61
|
+
* the renderer emits the light value in `:root { … }` and the dark
|
|
62
|
+
* value in `:root.dark { … }`.
|
|
63
|
+
*/
|
|
64
|
+
export const themeColorToken = z
|
|
65
|
+
.object({
|
|
66
|
+
$value: z.union([
|
|
67
|
+
flatOrAliasColor,
|
|
68
|
+
z
|
|
69
|
+
.object({
|
|
70
|
+
light: flatOrAliasColor,
|
|
71
|
+
dark: flatOrAliasColor,
|
|
72
|
+
})
|
|
73
|
+
.strict(),
|
|
74
|
+
]),
|
|
75
|
+
$type: z.literal("color").optional(),
|
|
76
|
+
$description: optionalDescription,
|
|
77
|
+
$extensions: z.record(z.string(), z.unknown()).optional(),
|
|
78
|
+
})
|
|
79
|
+
.strict();
|
|
80
|
+
export const themeDimensionToken = z
|
|
81
|
+
.object({
|
|
82
|
+
$value: flatOrAliasDimension,
|
|
83
|
+
$type: z.literal("dimension").optional(),
|
|
84
|
+
$description: optionalDescription,
|
|
85
|
+
$extensions: z.record(z.string(), z.unknown()).optional(),
|
|
86
|
+
})
|
|
87
|
+
.strict();
|
|
88
|
+
/**
|
|
89
|
+
* Typography composite. Each sub-field is independently optional so a
|
|
90
|
+
* document can ship a heading family without forcing a size, and vice
|
|
91
|
+
* versa. The renderer emits one CSS variable per declared sub-field
|
|
92
|
+
* (`--font-<name>`, `--text-<name>`, `--font-weight-<name>`, ...).
|
|
93
|
+
*/
|
|
94
|
+
export const themeTypographyComposite = z
|
|
95
|
+
.object({
|
|
96
|
+
$value: z.union([
|
|
97
|
+
z
|
|
98
|
+
.object({
|
|
99
|
+
fontFamily: z.string().min(1).max(200).optional(),
|
|
100
|
+
fontSize: dimensionValueString.optional(),
|
|
101
|
+
fontWeight: fontWeightValue.optional(),
|
|
102
|
+
lineHeight: z.union([z.number().positive(), dimensionValueString]).optional(),
|
|
103
|
+
letterSpacing: dimensionValueString.optional(),
|
|
104
|
+
})
|
|
105
|
+
.strict(),
|
|
106
|
+
aliasString,
|
|
107
|
+
]),
|
|
108
|
+
$type: z.literal("typography").optional(),
|
|
109
|
+
$description: optionalDescription,
|
|
110
|
+
$extensions: z.record(z.string(), z.unknown()).optional(),
|
|
111
|
+
})
|
|
112
|
+
.strict();
|
|
113
|
+
/** Shadow composite (single or layered). */
|
|
114
|
+
const shadowValueObject = z
|
|
115
|
+
.object({
|
|
116
|
+
color: flatOrAliasColor,
|
|
117
|
+
offsetX: dimensionValueString,
|
|
118
|
+
offsetY: dimensionValueString,
|
|
119
|
+
blur: dimensionValueString,
|
|
120
|
+
spread: dimensionValueString.optional(),
|
|
121
|
+
inset: z.boolean().optional(),
|
|
122
|
+
})
|
|
123
|
+
.strict()
|
|
124
|
+
.refine((v) => {
|
|
125
|
+
// Reject negative blur — physically meaningless, signals AI confusion.
|
|
126
|
+
const m = /^-?(\d+(\.\d+)?)/.exec(v.blur);
|
|
127
|
+
return !m || Number(m[0]) >= 0;
|
|
128
|
+
}, { message: "shadow blur must be ≥ 0", path: ["blur"] });
|
|
129
|
+
/**
|
|
130
|
+
* Literal CSS `box-shadow` string — the form a model naturally emits
|
|
131
|
+
* ("0 4px 6px -1px rgba(0,0,0,0.1)", "none", comma-separated layers). The
|
|
132
|
+
* DTCG object form (`shadowValueObject`) is the canonical shape; this is the
|
|
133
|
+
* tolerant fallback so a correct CSS shadow isn't rejected as "must be a
|
|
134
|
+
* DTCG alias" (the same tolerance `gradientValueString` gives gradients).
|
|
135
|
+
* Char-class only (no nested quantifiers) → ReDoS-safe; the renderer emits
|
|
136
|
+
* the string verbatim.
|
|
137
|
+
*/
|
|
138
|
+
const shadowValueString = z
|
|
139
|
+
.string()
|
|
140
|
+
.min(3)
|
|
141
|
+
.max(600)
|
|
142
|
+
.regex(/^(none|[\w\s.,%#()/-]+)$/i, "shadow must be a CSS box-shadow value, the DTCG object form, or an alias");
|
|
143
|
+
export const themeShadowComposite = z
|
|
144
|
+
.object({
|
|
145
|
+
$value: z.union([
|
|
146
|
+
shadowValueObject,
|
|
147
|
+
z.array(shadowValueObject).min(1),
|
|
148
|
+
aliasString,
|
|
149
|
+
shadowValueString,
|
|
150
|
+
]),
|
|
151
|
+
$type: z.literal("shadow").optional(),
|
|
152
|
+
$description: optionalDescription,
|
|
153
|
+
$extensions: z.record(z.string(), z.unknown()).optional(),
|
|
154
|
+
})
|
|
155
|
+
.strict();
|
|
156
|
+
export const themeDurationToken = z
|
|
157
|
+
.object({
|
|
158
|
+
$value: z.union([
|
|
159
|
+
z.string().regex(/^\d+(\.\d+)?(ms|s)$/, "duration must end in ms or s"),
|
|
160
|
+
aliasString,
|
|
161
|
+
]),
|
|
162
|
+
$type: z.literal("duration").optional(),
|
|
163
|
+
$description: optionalDescription,
|
|
164
|
+
$extensions: z.record(z.string(), z.unknown()).optional(),
|
|
165
|
+
})
|
|
166
|
+
.strict();
|
|
167
|
+
export const themeCubicBezierToken = z
|
|
168
|
+
.object({
|
|
169
|
+
$value: z.union([
|
|
170
|
+
z
|
|
171
|
+
.tuple([z.number().min(0).max(1), z.number(), z.number().min(0).max(1), z.number()])
|
|
172
|
+
.describe("[x1, y1, x2, y2]"),
|
|
173
|
+
aliasString,
|
|
174
|
+
]),
|
|
175
|
+
$type: z.literal("cubicBezier").optional(),
|
|
176
|
+
$description: optionalDescription,
|
|
177
|
+
$extensions: z.record(z.string(), z.unknown()).optional(),
|
|
178
|
+
})
|
|
179
|
+
.strict();
|
|
180
|
+
/** The seven CSS `<easing-function>` keywords, in `transition-timing-function` grammar. */
|
|
181
|
+
const EASING_KEYWORDS = [
|
|
182
|
+
"linear",
|
|
183
|
+
"ease",
|
|
184
|
+
"ease-in",
|
|
185
|
+
"ease-out",
|
|
186
|
+
"ease-in-out",
|
|
187
|
+
"step-start",
|
|
188
|
+
"step-end",
|
|
189
|
+
];
|
|
190
|
+
/**
|
|
191
|
+
* Easing value as a literal CSS string — the form a model naturally
|
|
192
|
+
* reaches for on `motion.easing`: a timing keyword (`ease-in-out`) or a
|
|
193
|
+
* literal `cubic-bezier(a, b, c, d)` / `steps(...)`. The DTCG
|
|
194
|
+
* `cubicBezier` *tuple* (`themeCubicBezierToken`) is the canonical
|
|
195
|
+
* structured shape; this is the tolerant fallback so a correct CSS
|
|
196
|
+
* easing isn't rejected as "Invalid input" (the same tolerance
|
|
197
|
+
* `shadowValueString` gives shadows and `gradientValueString` gradients).
|
|
198
|
+
*
|
|
199
|
+
* ReDoS-safe: the function-call forms use a bounded char class with no
|
|
200
|
+
* nested quantifiers, and exclude `;<>{}` so a value can never break out
|
|
201
|
+
* of the emitted declaration. The renderer emits the string verbatim.
|
|
202
|
+
*/
|
|
203
|
+
// Longest-first so the alternation prefers `ease-in-out` over `ease`.
|
|
204
|
+
const EASING_KEYWORD_ALT = [...EASING_KEYWORDS].sort((a, b) => b.length - a.length).join("|");
|
|
205
|
+
const easingValueString = z
|
|
206
|
+
.string()
|
|
207
|
+
.min(1)
|
|
208
|
+
.max(80)
|
|
209
|
+
.regex(new RegExp(`^(?:${EASING_KEYWORD_ALT}|cubic-bezier\\([\\d\\s.,+-]+\\)|steps\\([\\w\\s.,+-]+\\))$`, "i"), `easing must be a keyword (${EASING_KEYWORDS.join("|")}) or a cubic-bezier(...) / steps(...) value`);
|
|
210
|
+
/**
|
|
211
|
+
* Easing token for `motion.easing`. Accepts, in order of how models
|
|
212
|
+
* emit them: a CSS easing keyword / `cubic-bezier(...)` string, the DTCG
|
|
213
|
+
* `cubicBezier` `[x1,y1,x2,y2]` tuple, or a DTCG alias. `$type` may be
|
|
214
|
+
* omitted, `cubicBezier` (DTCG), or `easing` (Caelo shorthand).
|
|
215
|
+
*/
|
|
216
|
+
export const themeEasingToken = z
|
|
217
|
+
.object({
|
|
218
|
+
$value: z.union([
|
|
219
|
+
easingValueString,
|
|
220
|
+
z
|
|
221
|
+
.tuple([z.number().min(0).max(1), z.number(), z.number().min(0).max(1), z.number()])
|
|
222
|
+
.describe("[x1, y1, x2, y2]"),
|
|
223
|
+
aliasString,
|
|
224
|
+
]),
|
|
225
|
+
$type: z.union([z.literal("cubicBezier"), z.literal("easing")]).optional(),
|
|
226
|
+
$description: optionalDescription,
|
|
227
|
+
$extensions: z.record(z.string(), z.unknown()).optional(),
|
|
228
|
+
})
|
|
229
|
+
.strict();
|
|
230
|
+
/**
|
|
231
|
+
* issue #153 — first-class gradient token. Pragmatic CSS-string form
|
|
232
|
+
* (`linear-gradient(135deg, #4f46e5, #7c3aed)`) rather than DTCG's
|
|
233
|
+
* structured-stops draft: the renderer emits values verbatim into
|
|
234
|
+
* `--gradient-<name>` and module CSS consumes them via `var()`, so the
|
|
235
|
+
* CSS string IS the useful representation. The value regex uses an
|
|
236
|
+
* unambiguous char class (ReDoS-aware per issue #113) and excludes
|
|
237
|
+
* `;<>{}` so a token can never break out of the emitted declaration;
|
|
238
|
+
* `sanitizeCssTokenValue` still runs at render as defense-in-depth.
|
|
239
|
+
*/
|
|
240
|
+
const gradientValueString = z
|
|
241
|
+
.string()
|
|
242
|
+
.min(12)
|
|
243
|
+
.max(600)
|
|
244
|
+
.regex(/^(?:repeating-)?(?:linear|radial|conic)-gradient\([^;<>{}]*\)$/i, "gradient must be a CSS *-gradient(...) value (no url(), no declarations)")
|
|
245
|
+
.refine((v) => !/url\s*\(/i.test(v), "gradient must not contain url()");
|
|
246
|
+
export const themeGradientToken = z
|
|
247
|
+
.object({
|
|
248
|
+
$value: z.union([gradientValueString, aliasString]),
|
|
249
|
+
$type: z.literal("gradient").optional(),
|
|
250
|
+
$description: optionalDescription,
|
|
251
|
+
$extensions: z.record(z.string(), z.unknown()).optional(),
|
|
252
|
+
})
|
|
253
|
+
.strict();
|
|
254
|
+
/**
|
|
255
|
+
* Any token, in any category. The discriminator is the parent group
|
|
256
|
+
* key (`color.*` → color, `spacing.*` → dimension, ...), but DTCG
|
|
257
|
+
* doesn't strictly require `$type` so we accept any leaf carrying a
|
|
258
|
+
* valid `$value`. Per-category strictness is enforced at the
|
|
259
|
+
* normalizer + the renderer (which know which namespace the token
|
|
260
|
+
* is being emitted into).
|
|
261
|
+
*/
|
|
262
|
+
const anyThemeToken = z.union([
|
|
263
|
+
themeColorToken,
|
|
264
|
+
themeDimensionToken,
|
|
265
|
+
themeTypographyComposite,
|
|
266
|
+
themeShadowComposite,
|
|
267
|
+
themeDurationToken,
|
|
268
|
+
themeCubicBezierToken,
|
|
269
|
+
themeEasingToken,
|
|
270
|
+
themeGradientToken,
|
|
271
|
+
]);
|
|
272
|
+
const tokenGroupSchema = z.lazy(() => z.record(z.string(), z.unknown()).superRefine((obj, ctx) => {
|
|
273
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
274
|
+
if (k.startsWith("$")) {
|
|
275
|
+
// DTCG group-level metadata. $description must be a string;
|
|
276
|
+
// every other $-prefixed key is tooling extension data and
|
|
277
|
+
// passes through unmodified (the spec leaves these open).
|
|
278
|
+
if (k === "$description" && typeof v !== "string") {
|
|
279
|
+
ctx.addIssue({
|
|
280
|
+
code: "custom",
|
|
281
|
+
path: [k],
|
|
282
|
+
message: "$description must be a string",
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
const sub = z.union([anyThemeToken, tokenGroupSchema]).safeParse(v);
|
|
288
|
+
if (!sub.success) {
|
|
289
|
+
for (const issue of sub.error.issues) {
|
|
290
|
+
ctx.addIssue({
|
|
291
|
+
code: "custom",
|
|
292
|
+
path: [k, ...issue.path],
|
|
293
|
+
message: issue.message,
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}));
|
|
299
|
+
/**
|
|
300
|
+
* issue #153 — per-category leaf enforcement for the KNOWN vocabulary.
|
|
301
|
+
*
|
|
302
|
+
* The recursive group walker deliberately tolerates unknown shapes
|
|
303
|
+
* (DTCG leaves the category vocabulary open), but that tolerance had a
|
|
304
|
+
* hole: a leaf whose `$value` failed every token schema fell back to
|
|
305
|
+
* validating as a "group" whose `$`-keys pass through — so an invalid
|
|
306
|
+
* `color.primary` (or a `url()`-smuggling `gradient.hero`) passed the
|
|
307
|
+
* document boundary silently and STILL got emitted into CSS by the
|
|
308
|
+
* renderer. That is the exact silent-acceptance CLAUDE.md §2 forbids.
|
|
309
|
+
*
|
|
310
|
+
* Fix: inside the known categories, any object carrying `$value` IS a
|
|
311
|
+
* token and must match that category's schema. Unknown root categories
|
|
312
|
+
* keep the open-vocabulary tolerance (Figma / Tokens Studio imports).
|
|
313
|
+
*/
|
|
314
|
+
const CATEGORY_TOKEN_SCHEMAS = {
|
|
315
|
+
color: themeColorToken,
|
|
316
|
+
gradient: themeGradientToken,
|
|
317
|
+
spacing: themeDimensionToken,
|
|
318
|
+
radius: themeDimensionToken,
|
|
319
|
+
breakpoint: themeDimensionToken,
|
|
320
|
+
typography: themeTypographyComposite,
|
|
321
|
+
shadow: themeShadowComposite,
|
|
322
|
+
// motion mixes duration + easing leaves (duration.fast, ease.smooth,
|
|
323
|
+
// easing.standard nested under motion or flat); accept either shape.
|
|
324
|
+
// themeEasingToken covers the CSS-string easings (`ease-in-out`,
|
|
325
|
+
// `cubic-bezier(...)`) a model naturally emits; themeCubicBezierToken
|
|
326
|
+
// covers the DTCG tuple; themeDurationToken/themeDimensionToken the
|
|
327
|
+
// timing leaves.
|
|
328
|
+
motion: z.union([
|
|
329
|
+
themeDurationToken,
|
|
330
|
+
themeCubicBezierToken,
|
|
331
|
+
themeEasingToken,
|
|
332
|
+
themeDimensionToken,
|
|
333
|
+
]),
|
|
334
|
+
duration: themeDurationToken,
|
|
335
|
+
ease: z.union([themeCubicBezierToken, themeEasingToken]),
|
|
336
|
+
};
|
|
337
|
+
/**
|
|
338
|
+
* Per-category "what shape is accepted" clause, spliced into the
|
|
339
|
+
* rejection message so the AI can self-correct in one step (CLAUDE.md
|
|
340
|
+
* §11 — AI-actionable errors) instead of seeing a bare "Invalid input".
|
|
341
|
+
* Categories absent here fall back to the raw Zod issue message.
|
|
342
|
+
*/
|
|
343
|
+
const CATEGORY_EXPECTATIONS = {
|
|
344
|
+
motion: 'a duration (e.g. "200ms"), a cubic-bezier(...) or easing keyword ' +
|
|
345
|
+
"(linear|ease|ease-in|ease-out|ease-in-out|step-start|step-end), or a dimension token",
|
|
346
|
+
ease: 'a cubic-bezier(...) string, an easing keyword (linear|ease|ease-in|ease-out|ease-in-out|step-start|step-end), a DTCG [x1,y1,x2,y2] tuple, or an alias like "{motion.ease}"',
|
|
347
|
+
duration: 'a duration string ending in ms or s (e.g. "200ms", "0.5s") or an alias',
|
|
348
|
+
};
|
|
349
|
+
/** Compact, human-readable render of a rejected `$value` for the error tail. */
|
|
350
|
+
function describeValue(value) {
|
|
351
|
+
if (typeof value === "string")
|
|
352
|
+
return JSON.stringify(value);
|
|
353
|
+
if (value === undefined)
|
|
354
|
+
return "undefined";
|
|
355
|
+
const s = JSON.stringify(value);
|
|
356
|
+
if (s === undefined)
|
|
357
|
+
return String(value);
|
|
358
|
+
return s.length > 80 ? `${s.slice(0, 77)}...` : s;
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Name the mistake when a value looks like a CSS shorthand packing several
|
|
362
|
+
* design tokens into one string. Design tokens are single-valued by
|
|
363
|
+
* definition, so `"180ms ease"` is not a malformed duration — it is a
|
|
364
|
+
* duration and an easing that belong in separate tokens, and saying so is
|
|
365
|
+
* what lets the caller fix it on the first try.
|
|
366
|
+
*/
|
|
367
|
+
function shorthandHint(category, value) {
|
|
368
|
+
if (typeof value !== "string")
|
|
369
|
+
return "";
|
|
370
|
+
const v = value.trim();
|
|
371
|
+
if (category === "duration" || category === "motion") {
|
|
372
|
+
// A duration followed by anything else: "180ms ease", "0.2s ease-in-out".
|
|
373
|
+
if (/^\d*\.?\d+\s*(ms|s)\s+\S/i.test(v)) {
|
|
374
|
+
return (` — that looks like a CSS transition shorthand (duration + easing) in ONE token. ` +
|
|
375
|
+
`Design tokens hold a single value: put "${v.split(/\s+/)[0]}" in a duration token ` +
|
|
376
|
+
`and "${v.split(/\s+/).slice(1).join(" ")}" in an easing token.`);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
return "";
|
|
380
|
+
}
|
|
381
|
+
function refineKnownCategoryLeaves(category, schema, node, path, ctx) {
|
|
382
|
+
if (node === null || typeof node !== "object" || Array.isArray(node))
|
|
383
|
+
return;
|
|
384
|
+
const obj = node;
|
|
385
|
+
if ("$value" in obj) {
|
|
386
|
+
const sub = schema.safeParse(obj);
|
|
387
|
+
if (!sub.success) {
|
|
388
|
+
const name = path[path.length - 1];
|
|
389
|
+
const expectation = CATEGORY_EXPECTATIONS[category];
|
|
390
|
+
if (expectation) {
|
|
391
|
+
// Union-based categories surface a single, actionable message
|
|
392
|
+
// (naming the token, the accepted shapes, and the offending
|
|
393
|
+
// value) instead of one "Invalid input" issue per union member.
|
|
394
|
+
//
|
|
395
|
+
// 2026-07-28: listing accepted shapes was not enough. A model wrote
|
|
396
|
+
// `motion` as the CSS shorthand `"180ms ease"` — idiomatic in a
|
|
397
|
+
// `transition:` declaration, but two design tokens in one string. The
|
|
398
|
+
// message enumerated the legal forms without naming the actual
|
|
399
|
+
// mistake, so the fix took three attempts (and, because gated
|
|
400
|
+
// proposals were validated only after approval, three operator
|
|
401
|
+
// clicks). Naming the likely mistake turns that into one attempt.
|
|
402
|
+
ctx.addIssue({
|
|
403
|
+
code: "custom",
|
|
404
|
+
path: [...path],
|
|
405
|
+
message: `${category} token "${String(name)}" invalid: expected ${expectation}; got ${describeValue(obj.$value)}` +
|
|
406
|
+
shorthandHint(category, obj.$value),
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
else {
|
|
410
|
+
for (const issue of sub.error.issues) {
|
|
411
|
+
ctx.addIssue({
|
|
412
|
+
code: "custom",
|
|
413
|
+
path: [...path, ...issue.path],
|
|
414
|
+
message: `${category} token "${String(name)}" invalid: ${issue.message} (got ${describeValue(obj.$value)})`,
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
422
|
+
if (k.startsWith("$"))
|
|
423
|
+
continue;
|
|
424
|
+
refineKnownCategoryLeaves(category, schema, v, [...path, k], ctx);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* Top-level DTCG document. Known category keys:
|
|
429
|
+
*
|
|
430
|
+
* color, typography, spacing, radius, shadow, motion (duration +
|
|
431
|
+
* cubicBezier sub-groups), breakpoint, gradient (issue #153 — CSS
|
|
432
|
+
* gradient strings emitted as `--gradient-<name>`).
|
|
433
|
+
*
|
|
434
|
+
* Unknown root keys are tolerated because DTCG explicitly leaves the
|
|
435
|
+
* category vocabulary open — we don't want to reject a future
|
|
436
|
+
* `effect` category that operators bring from Figma.
|
|
437
|
+
* Root-level `$description` / `$extensions` are also tolerated (DTCG
|
|
438
|
+
* documents routinely carry these — the seeded default theme and
|
|
439
|
+
* Figma/Tokens Studio exports all have a root `$description`).
|
|
440
|
+
*/
|
|
441
|
+
export const themeDocument = tokenGroupSchema.superRefine((doc, ctx) => {
|
|
442
|
+
for (const [category, schema] of Object.entries(CATEGORY_TOKEN_SCHEMAS)) {
|
|
443
|
+
if (category in doc) {
|
|
444
|
+
refineKnownCategoryLeaves(category, schema, doc[category], [category], ctx);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
});
|
|
448
|
+
/**
|
|
449
|
+
* Validate a tokens document. Returns the parsed tree on success;
|
|
450
|
+
* throws a ZodError on failure (caller wraps into the op's HandlerError
|
|
451
|
+
* shape per CLAUDE.md §4).
|
|
452
|
+
*/
|
|
453
|
+
export function validateThemeTokens(tokens) {
|
|
454
|
+
return themeDocument.parse(tokens);
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* v0.11.1 (issue #76) — Zod schema for `propose_create_theme.overrides`.
|
|
458
|
+
*
|
|
459
|
+
* The base shape is a permissive `Record<string, unknown>` (same as
|
|
460
|
+
* v0.11.0) so loose names like `primaryColor` / `fontHeading` /
|
|
461
|
+
* `spacing-lg` continue to flow through `normalizeTokens`. The explicit
|
|
462
|
+
* `primaryColor` recognition (v0.11.1) is documented here — when set,
|
|
463
|
+
* the propose-create handler derives a `color.primary.{50..900}`
|
|
464
|
+
* OKLCh ramp from the value (each stop `_derived: true`) instead of
|
|
465
|
+
* landing a single `color.primary` leaf via the normalizer.
|
|
466
|
+
*
|
|
467
|
+
* Operator-supplied `color.primary.<stop>` paths in the same overrides
|
|
468
|
+
* map layer over the derived ramp (explicit-wins precedence).
|
|
469
|
+
*/
|
|
470
|
+
export const createThemeOverridesSchema = z.record(z.string(), z.unknown());
|
|
471
|
+
/**
|
|
472
|
+
* v0.11.1 (issue #76) — extract the `primaryColor` sentinel from a
|
|
473
|
+
* loose-name overrides map. Returns `{primaryColor, rest}` where `rest`
|
|
474
|
+
* is the same map minus the `primaryColor` key. Used by the propose-
|
|
475
|
+
* create handler to split the ramp-seed off before normalizing the
|
|
476
|
+
* remaining loose names.
|
|
477
|
+
*/
|
|
478
|
+
export function extractPrimaryColorSeed(overrides) {
|
|
479
|
+
if (!overrides)
|
|
480
|
+
return { primaryColor: undefined, rest: {} };
|
|
481
|
+
const { primaryColor, ...rest } = overrides;
|
|
482
|
+
return {
|
|
483
|
+
primaryColor: typeof primaryColor === "string" ? primaryColor : undefined,
|
|
484
|
+
rest,
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
// ────────────────────────────────────────────────────────────────────
|
|
488
|
+
// Helpers
|
|
489
|
+
// ────────────────────────────────────────────────────────────────────
|
|
490
|
+
/**
|
|
491
|
+
* Walk a tokens document and produce a flat list of `(path, token)`
|
|
492
|
+
* pairs. Used by the renderer + alias resolver + summary formatter.
|
|
493
|
+
* Paths use dot-notation: `color.primary`, `typography.heading`,
|
|
494
|
+
* `color.brand.primary`, ...
|
|
495
|
+
*/
|
|
496
|
+
export function flattenTokens(tokens) {
|
|
497
|
+
const out = [];
|
|
498
|
+
walk(tokens, [], out);
|
|
499
|
+
return out;
|
|
500
|
+
}
|
|
501
|
+
function walk(node, prefix, out) {
|
|
502
|
+
if (node === null || typeof node !== "object")
|
|
503
|
+
return;
|
|
504
|
+
const obj = node;
|
|
505
|
+
// Leaf: carries `$value`.
|
|
506
|
+
if ("$value" in obj) {
|
|
507
|
+
out.push({ path: prefix.join("."), token: obj });
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
// Group: recurse.
|
|
511
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
512
|
+
if (k.startsWith("$"))
|
|
513
|
+
continue; // tolerate DTCG group-level metadata
|
|
514
|
+
walk(v, [...prefix, k], out);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* Build a one-line category summary for the system-prompt `## Theme`
|
|
519
|
+
* block ("8 colors, 5 typography, 6 spacing, 5 radii, 3 shadows").
|
|
520
|
+
*/
|
|
521
|
+
export function summarizeTokens(tokens) {
|
|
522
|
+
const counts = new Map();
|
|
523
|
+
for (const { path } of flattenTokens(tokens)) {
|
|
524
|
+
const category = path.split(".")[0] ?? "(uncategorised)";
|
|
525
|
+
counts.set(category, (counts.get(category) ?? 0) + 1);
|
|
526
|
+
}
|
|
527
|
+
if (counts.size === 0)
|
|
528
|
+
return "no tokens";
|
|
529
|
+
const parts = [];
|
|
530
|
+
// Stable order so cached system prompts hit.
|
|
531
|
+
for (const k of [
|
|
532
|
+
"color",
|
|
533
|
+
"typography",
|
|
534
|
+
"spacing",
|
|
535
|
+
"radius",
|
|
536
|
+
"shadow",
|
|
537
|
+
"duration",
|
|
538
|
+
"ease",
|
|
539
|
+
"breakpoint",
|
|
540
|
+
]) {
|
|
541
|
+
const n = counts.get(k);
|
|
542
|
+
if (n)
|
|
543
|
+
parts.push(`${n} ${pluralise(k, n)}`);
|
|
544
|
+
}
|
|
545
|
+
for (const [k, n] of counts) {
|
|
546
|
+
if (![
|
|
547
|
+
"color",
|
|
548
|
+
"typography",
|
|
549
|
+
"spacing",
|
|
550
|
+
"radius",
|
|
551
|
+
"shadow",
|
|
552
|
+
"duration",
|
|
553
|
+
"ease",
|
|
554
|
+
"breakpoint",
|
|
555
|
+
].includes(k)) {
|
|
556
|
+
parts.push(`${n} ${k}`);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
return parts.join(", ");
|
|
560
|
+
}
|
|
561
|
+
function pluralise(category, n) {
|
|
562
|
+
if (n === 1)
|
|
563
|
+
return category;
|
|
564
|
+
if (category === "radius")
|
|
565
|
+
return "radii";
|
|
566
|
+
if (category.endsWith("y"))
|
|
567
|
+
return `${category.slice(0, -1)}ies`;
|
|
568
|
+
return `${category}s`;
|
|
569
|
+
}
|
|
570
|
+
// ────────────────────────────────────────────────────────────────────
|
|
571
|
+
// Patch helpers (shared between themes.update_tokens + themes.execute_proposal)
|
|
572
|
+
// ────────────────────────────────────────────────────────────────────
|
|
573
|
+
/**
|
|
574
|
+
* v0.11.0 (#45, step-11 opt §5) — apply a canonical-path → value patch
|
|
575
|
+
* to a DTCG document. Returns a fresh document; the input is not
|
|
576
|
+
* mutated.
|
|
577
|
+
*
|
|
578
|
+
* Each entry in `writes` becomes a `{$value, $type}` leaf at the dotted
|
|
579
|
+
* canonical path. The `types` map carries the inferred DTCG `$type` for
|
|
580
|
+
* each path (already known to the normalizer) so the leaf advertises
|
|
581
|
+
* the right shape to consumers.
|
|
582
|
+
*
|
|
583
|
+
* Both themes.update_tokens (loose-input set path) and
|
|
584
|
+
* themes.propose_create's execute branch (tokens + overrides merge)
|
|
585
|
+
* use this same logic — extracted so the dotted-path merge lives in
|
|
586
|
+
* one place and v0.11.1's OKLCH auto-ramp can extend it without
|
|
587
|
+
* forking.
|
|
588
|
+
*/
|
|
589
|
+
export function applyDtcgWrites(current, writes, types) {
|
|
590
|
+
const out = JSON.parse(JSON.stringify(current));
|
|
591
|
+
for (const [path, value] of Object.entries(writes)) {
|
|
592
|
+
const inferredType = types[path];
|
|
593
|
+
// v0.11.0 fix (#45 review thread on theme-normalize.ts:149) — when
|
|
594
|
+
// the incoming value is a partial composite object (e.g. typography's
|
|
595
|
+
// `{fontFamily: "Inter"}` from a loose `fontHeading` input), MERGE
|
|
596
|
+
// it into any existing leaf's `$value` instead of overwriting. This
|
|
597
|
+
// is the only sensible semantics for typography: setting fontFamily
|
|
598
|
+
// must not erase fontSize / fontWeight / etc. set previously.
|
|
599
|
+
const existing = readLeafAtPath(out, path);
|
|
600
|
+
let nextValue = value;
|
|
601
|
+
if (existing &&
|
|
602
|
+
typeof existing === "object" &&
|
|
603
|
+
typeof existing.$value === "object" &&
|
|
604
|
+
existing.$value !== null &&
|
|
605
|
+
typeof value === "object" &&
|
|
606
|
+
value !== null &&
|
|
607
|
+
!Array.isArray(value)) {
|
|
608
|
+
nextValue = {
|
|
609
|
+
...existing.$value,
|
|
610
|
+
...value,
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
setLeafAtPath(out, path, {
|
|
614
|
+
$value: nextValue,
|
|
615
|
+
...(inferredType ? { $type: inferredType } : {}),
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
return out;
|
|
619
|
+
}
|
|
620
|
+
/**
|
|
621
|
+
* Read the existing leaf at a dotted DTCG path, returning the node if
|
|
622
|
+
* it already carries `$value` (i.e. is a token leaf) and `undefined`
|
|
623
|
+
* otherwise. Used by `applyDtcgWrites` to detect composite leaves
|
|
624
|
+
* where the new value should MERGE into the existing `$value` instead
|
|
625
|
+
* of replacing it.
|
|
626
|
+
*/
|
|
627
|
+
function readLeafAtPath(doc, path) {
|
|
628
|
+
const parts = path.split(".");
|
|
629
|
+
let cur = doc;
|
|
630
|
+
for (const k of parts) {
|
|
631
|
+
if (!k)
|
|
632
|
+
return undefined;
|
|
633
|
+
if (cur === null || typeof cur !== "object")
|
|
634
|
+
return undefined;
|
|
635
|
+
cur = cur[k];
|
|
636
|
+
}
|
|
637
|
+
if (cur && typeof cur === "object" && "$value" in cur) {
|
|
638
|
+
return cur;
|
|
639
|
+
}
|
|
640
|
+
return undefined;
|
|
641
|
+
}
|
|
642
|
+
/**
|
|
643
|
+
* Walk a dotted DTCG path and set the leaf in-place. Caller passes a
|
|
644
|
+
* cloned document (never the original) — `applyDtcgWrites` enforces
|
|
645
|
+
* that for the public surface.
|
|
646
|
+
*/
|
|
647
|
+
function setLeafAtPath(doc, path, leaf) {
|
|
648
|
+
const parts = path.split(".");
|
|
649
|
+
// The dotted path comes from the caller's `writes` map (form / AI input),
|
|
650
|
+
// so a segment like `__proto__` or `constructor` would let the walk below
|
|
651
|
+
// (`cur[k] = {}`, `cur[last] = leaf`) pollute the prototype chain (CodeQL
|
|
652
|
+
// js/prototype-pollution-utility). Reject the whole write on any unsafe
|
|
653
|
+
// segment rather than partially applying it.
|
|
654
|
+
if (parts.some(isUnsafeKey))
|
|
655
|
+
return;
|
|
656
|
+
let cur = doc;
|
|
657
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
658
|
+
const k = parts[i];
|
|
659
|
+
if (!k)
|
|
660
|
+
continue;
|
|
661
|
+
if (cur[k] === undefined || cur[k] === null || typeof cur[k] !== "object") {
|
|
662
|
+
cur[k] = {};
|
|
663
|
+
}
|
|
664
|
+
cur = cur[k];
|
|
665
|
+
}
|
|
666
|
+
const last = parts[parts.length - 1];
|
|
667
|
+
if (last)
|
|
668
|
+
cur[last] = leaf;
|
|
669
|
+
}
|
|
670
|
+
/**
|
|
671
|
+
* Companion to `applyDtcgWrites` — drop the leaf at a canonical path
|
|
672
|
+
* if it exists. Returns `{tokens, removed}` so the caller can report
|
|
673
|
+
* which paths were actually removed (silent on missing paths so a
|
|
674
|
+
* remove-list that includes already-absent keys is idempotent).
|
|
675
|
+
*/
|
|
676
|
+
export function removeDtcgPath(doc, path) {
|
|
677
|
+
const out = JSON.parse(JSON.stringify(doc));
|
|
678
|
+
const parts = path.split(".");
|
|
679
|
+
let cur = out;
|
|
680
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
681
|
+
const k = parts[i];
|
|
682
|
+
if (!k)
|
|
683
|
+
continue;
|
|
684
|
+
const next = cur[k];
|
|
685
|
+
if (!next || typeof next !== "object")
|
|
686
|
+
return { tokens: doc, removed: false };
|
|
687
|
+
cur = next;
|
|
688
|
+
}
|
|
689
|
+
const last = parts[parts.length - 1];
|
|
690
|
+
if (!last)
|
|
691
|
+
return { tokens: doc, removed: false };
|
|
692
|
+
if (!(last in cur))
|
|
693
|
+
return { tokens: doc, removed: false };
|
|
694
|
+
delete cur[last];
|
|
695
|
+
return { tokens: out, removed: true };
|
|
696
|
+
}
|
|
697
|
+
//# sourceMappingURL=themes.js.map
|