@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,661 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* v0.11.0 — Loose-name → canonical-DTCG-path normalizer for theme
|
|
5
|
+
* tokens (#45, follow-up comment §1).
|
|
6
|
+
*
|
|
7
|
+
* The AI naturally sends prose-shaped inputs ("set primary color to
|
|
8
|
+
* #ff6600", "use Inter for headings") — translating that into
|
|
9
|
+
* `{ primaryColor: "#ff6600", fontHeading: "Inter" }`. The canonical
|
|
10
|
+
* DTCG path is `color.primary.$value` / `typography.heading.fontFamily`.
|
|
11
|
+
* The server normalizes loose names to canonical paths so the AI never
|
|
12
|
+
* has to know the canonical surface up front.
|
|
13
|
+
*
|
|
14
|
+
* Coverage (from the issue's normalization table):
|
|
15
|
+
*
|
|
16
|
+
* primaryColor, colorPrimary, primary + color value → color.primary
|
|
17
|
+
* fontHeading, headingFont, heading-font → typography.heading.fontFamily
|
|
18
|
+
* spacingLg, spacing-lg, lg + CSS length → spacing.lg
|
|
19
|
+
* radiusMd, borderRadius, rounded-md → radius.md
|
|
20
|
+
* shadowSm, shadow-sm, boxShadow → shadow.sm
|
|
21
|
+
* gradient, heroGradient, gradientHero, gradient.hero → gradient.hero
|
|
22
|
+
* color.primary.$value (canonical DTCG path) → passthrough
|
|
23
|
+
* --color-primary (CSS-var) → color.primary
|
|
24
|
+
* "{color.brand.primary}" (DTCG alias) → passthrough at value layer
|
|
25
|
+
*
|
|
26
|
+
* Ambiguity (no value-shape signal) returns `UnknownTokenName` with
|
|
27
|
+
* did-you-mean suggestions, per CLAUDE.md §11 "Failure surfaces are
|
|
28
|
+
* AI-actionable".
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { InvalidColorValue, TokenCategoryMismatch, UnknownTokenName } from "./themes-errors.js";
|
|
32
|
+
|
|
33
|
+
export type CanonicalPath = string;
|
|
34
|
+
|
|
35
|
+
export interface NormalizeResult {
|
|
36
|
+
/**
|
|
37
|
+
* Map from canonical DTCG path (e.g. "color.primary") to the value
|
|
38
|
+
* the caller supplied. The ops layer rebuilds the DTCG leaf
|
|
39
|
+
* (`{$value: <value>, $type: <inferred>}`) from this.
|
|
40
|
+
*/
|
|
41
|
+
readonly set: Record<CanonicalPath, unknown>;
|
|
42
|
+
/**
|
|
43
|
+
* Inferred `$type` per canonical path. The renderer also knows the
|
|
44
|
+
* category from the path prefix, but the ops layer uses this to set
|
|
45
|
+
* `$type` on the stored token.
|
|
46
|
+
*/
|
|
47
|
+
readonly types: Record<
|
|
48
|
+
CanonicalPath,
|
|
49
|
+
"color" | "dimension" | "typography" | "shadow" | "duration" | "cubicBezier" | "gradient"
|
|
50
|
+
>;
|
|
51
|
+
/** Echo-back list for the AI tool's result content. */
|
|
52
|
+
readonly canonicalPaths: readonly CanonicalPath[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const COLOR_VALUE_REGEX =
|
|
56
|
+
/^(#[0-9a-fA-F]{3,8}|(oklch|rgb|rgba|hsl|hsla|lab|lch|color|hwb)\(.+\)|transparent|currentColor)$/;
|
|
57
|
+
const CSS_LENGTH_REGEX = /^-?\d+(\.\d+)?(rem|em|px|%|vh|vw|vmin|vmax|pt|pc|ch|ex)?$/;
|
|
58
|
+
const DURATION_REGEX = /^\d+(\.\d+)?(ms|s)$/;
|
|
59
|
+
const ALIAS_REGEX = /^\{[a-zA-Z0-9_.-]+\}$/;
|
|
60
|
+
const SHADOW_VALUE_REGEX = /^(-?\d+(\.\d+)?(rem|em|px|%)? *){2,5}(.+)?$/;
|
|
61
|
+
// A CSS `*-gradient(...)` value. Mirrors (loosely) the stricter Zod
|
|
62
|
+
// `gradientValueString` in themes.ts — the normalizer only needs to
|
|
63
|
+
// RECOGNISE gradient strings to route them to the gradient category and
|
|
64
|
+
// infer `$type: "gradient"`; the document validator enforces the exact
|
|
65
|
+
// safe shape (no url(), no `;<>{}` breakout) downstream.
|
|
66
|
+
const GRADIENT_VALUE_REGEX = /^(?:repeating-)?(?:linear|radial|conic)-gradient\(.+\)$/i;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The canonical "well-known" tokens the AI is most likely to mean when
|
|
70
|
+
* it sends a bare or category-prefixed name. Used both as the
|
|
71
|
+
* normalization target AND as the suggestion pool when an input is
|
|
72
|
+
* ambiguous.
|
|
73
|
+
*/
|
|
74
|
+
const KNOWN_CANONICAL_PATHS: readonly CanonicalPath[] = [
|
|
75
|
+
// colors (shadcn-inspired semantic set)
|
|
76
|
+
"color.background",
|
|
77
|
+
"color.foreground",
|
|
78
|
+
"color.primary",
|
|
79
|
+
"color.primary-foreground",
|
|
80
|
+
"color.secondary",
|
|
81
|
+
"color.secondary-foreground",
|
|
82
|
+
"color.accent",
|
|
83
|
+
"color.accent-foreground",
|
|
84
|
+
"color.muted",
|
|
85
|
+
"color.muted-foreground",
|
|
86
|
+
"color.card",
|
|
87
|
+
"color.card-foreground",
|
|
88
|
+
"color.border",
|
|
89
|
+
"color.ring",
|
|
90
|
+
"color.destructive",
|
|
91
|
+
"color.destructive-foreground",
|
|
92
|
+
"color.warning",
|
|
93
|
+
"color.success",
|
|
94
|
+
// typography (heading + body + display, each with fontFamily as the
|
|
95
|
+
// primary loose-name target; fontSize / fontWeight are addressable
|
|
96
|
+
// via direct DTCG paths)
|
|
97
|
+
"typography.heading.fontFamily",
|
|
98
|
+
"typography.body.fontFamily",
|
|
99
|
+
"typography.display.fontFamily",
|
|
100
|
+
"typography.mono.fontFamily",
|
|
101
|
+
// spacing scale (Tailwind-shaped)
|
|
102
|
+
"spacing.xs",
|
|
103
|
+
"spacing.sm",
|
|
104
|
+
"spacing.md",
|
|
105
|
+
"spacing.lg",
|
|
106
|
+
"spacing.xl",
|
|
107
|
+
"spacing.2xl",
|
|
108
|
+
// radius
|
|
109
|
+
"radius.sm",
|
|
110
|
+
"radius.md",
|
|
111
|
+
"radius.lg",
|
|
112
|
+
"radius.full",
|
|
113
|
+
// shadow
|
|
114
|
+
"shadow.sm",
|
|
115
|
+
"shadow.md",
|
|
116
|
+
"shadow.lg",
|
|
117
|
+
"shadow.xl",
|
|
118
|
+
// gradient (issue #153 — CSS gradient strings emitted as
|
|
119
|
+
// `--gradient-<name>`; `hero` + `subtle` are the seeded slots)
|
|
120
|
+
"gradient.hero",
|
|
121
|
+
"gradient.subtle",
|
|
122
|
+
];
|
|
123
|
+
|
|
124
|
+
interface CategoryDef {
|
|
125
|
+
readonly category:
|
|
126
|
+
| "color"
|
|
127
|
+
| "typography"
|
|
128
|
+
| "spacing"
|
|
129
|
+
| "radius"
|
|
130
|
+
| "shadow"
|
|
131
|
+
| "duration"
|
|
132
|
+
| "gradient";
|
|
133
|
+
/** Name-shape heuristics that hint at this category. */
|
|
134
|
+
readonly nameHints: readonly RegExp[];
|
|
135
|
+
/** Type that gets stored at the leaf's `$type` field. */
|
|
136
|
+
readonly inferredType: "color" | "dimension" | "typography" | "shadow" | "duration" | "gradient";
|
|
137
|
+
/** Builds the canonical DTCG path from the basename. */
|
|
138
|
+
readonly buildPath: (basename: string) => CanonicalPath;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const CATEGORIES: readonly CategoryDef[] = [
|
|
142
|
+
{
|
|
143
|
+
category: "color",
|
|
144
|
+
nameHints: [
|
|
145
|
+
/color/i,
|
|
146
|
+
/^bg$/i,
|
|
147
|
+
/background/i,
|
|
148
|
+
/foreground/i,
|
|
149
|
+
/destructive/i,
|
|
150
|
+
/primary$/i,
|
|
151
|
+
/secondary$/i,
|
|
152
|
+
/accent$/i,
|
|
153
|
+
/muted$/i,
|
|
154
|
+
/ring$/i,
|
|
155
|
+
/border$/i,
|
|
156
|
+
],
|
|
157
|
+
inferredType: "color",
|
|
158
|
+
buildPath: (basename) => `color.${basename}`,
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
category: "typography",
|
|
162
|
+
nameHints: [/font/i, /typography/i, /heading/i, /body/i, /display/i, /mono/i],
|
|
163
|
+
inferredType: "typography",
|
|
164
|
+
// v0.11.0 fix (#45 review thread): loose typography names target
|
|
165
|
+
// the composite ROOT (`typography.<name>`) — themeTypographyComposite
|
|
166
|
+
// Zod requires `$value` to be an object. The value gets wrapped as
|
|
167
|
+
// `{fontFamily: <input>}` in normalizeTokens (see compositeWrap
|
|
168
|
+
// below). The original sub-path shape (`typography.X.fontFamily`)
|
|
169
|
+
// produced documents Zod rejected.
|
|
170
|
+
buildPath: (basename) => `typography.${basename}`,
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
category: "spacing",
|
|
174
|
+
nameHints: [/^space/i, /^spacing/i, /^gap/i, /^padding/i, /^margin/i],
|
|
175
|
+
inferredType: "dimension",
|
|
176
|
+
buildPath: (basename) => `spacing.${basename}`,
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
category: "radius",
|
|
180
|
+
nameHints: [/radius/i, /rounded/i, /corner/i, /^border-?radius$/i],
|
|
181
|
+
inferredType: "dimension",
|
|
182
|
+
buildPath: (basename) => `radius.${basename}`,
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
category: "shadow",
|
|
186
|
+
nameHints: [/shadow/i, /elevation/i, /^box-?shadow$/i],
|
|
187
|
+
inferredType: "shadow",
|
|
188
|
+
buildPath: (basename) => `shadow.${basename}`,
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
// issue #153 — gradient tokens. Loose names (`gradient`,
|
|
192
|
+
// `heroGradient`, `gradientHero`) + the direct DTCG path
|
|
193
|
+
// (`gradient.hero`) all land in `gradient.<name>` with
|
|
194
|
+
// `$type: "gradient"`. Without this category the normalizer threw
|
|
195
|
+
// `UnknownTokenName` on every loose gradient name (the #1 red-error
|
|
196
|
+
// class in migration chat).
|
|
197
|
+
category: "gradient",
|
|
198
|
+
nameHints: [/gradient/i],
|
|
199
|
+
inferredType: "gradient",
|
|
200
|
+
buildPath: (basename) => `gradient.${basename}`,
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
category: "duration",
|
|
204
|
+
nameHints: [/duration/i, /timing/i],
|
|
205
|
+
inferredType: "duration",
|
|
206
|
+
buildPath: (basename) => `duration.${basename}`,
|
|
207
|
+
},
|
|
208
|
+
];
|
|
209
|
+
|
|
210
|
+
const DEFAULT_TIER = "sm"; // shadowSm / radiusMd default
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Normalize a record of loose-name → value pairs into canonical DTCG
|
|
214
|
+
* paths. Throws `UnknownTokenName` on the first ambiguous entry so the
|
|
215
|
+
* AI gets a single concrete next-step rather than a wall of partial
|
|
216
|
+
* results.
|
|
217
|
+
*/
|
|
218
|
+
export function normalizeTokens(input: Record<string, unknown>): NormalizeResult {
|
|
219
|
+
const set: Record<CanonicalPath, unknown> = {};
|
|
220
|
+
const types: Record<CanonicalPath, NormalizeResult["types"][string]> = {};
|
|
221
|
+
const paths: CanonicalPath[] = [];
|
|
222
|
+
|
|
223
|
+
for (const [rawName, rawValueIn] of Object.entries(input)) {
|
|
224
|
+
// issue #242 (F11) — tolerate the two encodings the AI actually
|
|
225
|
+
// sends for composite values, then validate content strictly:
|
|
226
|
+
// 1. a JSON-encoded object string → parse it;
|
|
227
|
+
// 2. the full DTCG envelope `{$type?, $value}` → unwrap to $value
|
|
228
|
+
// (storing the envelope verbatim would nest `$value.$value`
|
|
229
|
+
// and bounce off the document schema).
|
|
230
|
+
let rawValue = rawValueIn;
|
|
231
|
+
if (typeof rawValue === "string" && rawValue.trim().startsWith("{")) {
|
|
232
|
+
try {
|
|
233
|
+
rawValue = JSON.parse(rawValue) as unknown;
|
|
234
|
+
} catch {
|
|
235
|
+
// Not JSON after all — keep the literal string.
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
if (
|
|
239
|
+
rawValue !== null &&
|
|
240
|
+
typeof rawValue === "object" &&
|
|
241
|
+
!Array.isArray(rawValue) &&
|
|
242
|
+
"$value" in (rawValue as Record<string, unknown>)
|
|
243
|
+
) {
|
|
244
|
+
rawValue = (rawValue as Record<string, unknown>).$value;
|
|
245
|
+
}
|
|
246
|
+
const resolved = resolveOne(rawName, rawValue);
|
|
247
|
+
// v0.11.0 fix (#45 review thread on theme-normalize.ts:149) —
|
|
248
|
+
// typography is a composite; `$value` MUST be an object per the
|
|
249
|
+
// Zod schema. When the loose input (or a direct `typography.X.<sub>`
|
|
250
|
+
// DTCG path, or a `--font-X` / `--text-X` CSS-var) implied a single
|
|
251
|
+
// sub-field, wrap the scalar value as `{[subField]: value}` so the
|
|
252
|
+
// resulting document validates. applyDtcgWrites merges this into
|
|
253
|
+
// any existing typography composite at the path so partial updates
|
|
254
|
+
// don't erase sibling sub-fields.
|
|
255
|
+
let storedValue: unknown = rawValue;
|
|
256
|
+
if (resolved.compositeWrap && typeof rawValue === "string" && !ALIAS_REGEX.test(rawValue)) {
|
|
257
|
+
storedValue = { [resolved.compositeWrap]: rawValue };
|
|
258
|
+
} else if (
|
|
259
|
+
resolved.inferredType === "gradient" &&
|
|
260
|
+
typeof rawValue === "object" &&
|
|
261
|
+
rawValue !== null &&
|
|
262
|
+
!Array.isArray(rawValue)
|
|
263
|
+
) {
|
|
264
|
+
// issue #153 — the AI sometimes sends a structured gradient
|
|
265
|
+
// (`{type, angle, stops}`) instead of the CSS string the DTCG
|
|
266
|
+
// gradient token stores. Fold it into the canonical
|
|
267
|
+
// `linear-gradient(...)` form so the document validates and the
|
|
268
|
+
// renderer emits a real `--gradient-<name>`. Unrecognised shapes
|
|
269
|
+
// pass through untouched so the document validator produces the
|
|
270
|
+
// actionable "gradient must be a CSS *-gradient(...) value" error
|
|
271
|
+
// rather than us silently inventing a gradient (CLAUDE.md §2).
|
|
272
|
+
storedValue = gradientObjectToCss(rawValue as Record<string, unknown>) ?? rawValue;
|
|
273
|
+
}
|
|
274
|
+
set[resolved.path] = storedValue;
|
|
275
|
+
types[resolved.path] = resolved.inferredType;
|
|
276
|
+
paths.push(resolved.path);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return { set, types, canonicalPaths: paths };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
interface ResolvedToken {
|
|
283
|
+
readonly path: CanonicalPath;
|
|
284
|
+
readonly inferredType: NormalizeResult["types"][string];
|
|
285
|
+
/**
|
|
286
|
+
* When set, normalizeTokens wraps the raw value as
|
|
287
|
+
* `{[compositeWrap]: rawValue}` so the resulting leaf matches the
|
|
288
|
+
* typography (or future) composite `$value` shape. Unset for flat
|
|
289
|
+
* categories (color / dimension / shadow / duration / cubicBezier).
|
|
290
|
+
*/
|
|
291
|
+
readonly compositeWrap?: string;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function resolveOne(rawName: string, rawValue: unknown): ResolvedToken {
|
|
295
|
+
// 1. Direct canonical DTCG paths: pass through unchanged. Typography
|
|
296
|
+
// sub-paths (`typography.X.fontFamily` / `.fontSize` / …) collapse
|
|
297
|
+
// onto the composite root so the leaf carries `$value: {sub: val}`.
|
|
298
|
+
if (/^[a-z][a-z0-9_-]*(\.[a-z0-9_-]+)+(\.\$value)?$/i.test(rawName)) {
|
|
299
|
+
const path = rawName.replace(/\.\$value$/, "");
|
|
300
|
+
const parts = path.split(".");
|
|
301
|
+
const category = parts[0] ?? "";
|
|
302
|
+
if (
|
|
303
|
+
category === "typography" &&
|
|
304
|
+
parts.length === 3 &&
|
|
305
|
+
isTypographyCompositeSubField(parts[2])
|
|
306
|
+
) {
|
|
307
|
+
return {
|
|
308
|
+
path: `${parts[0]}.${parts[1]}`,
|
|
309
|
+
inferredType: "typography",
|
|
310
|
+
compositeWrap: parts[2],
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
validateValueShape(category, rawValue, path);
|
|
314
|
+
return { path, inferredType: inferTypeFromPath(path, rawValue) };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// 2. CSS-var form: `--color-primary` → `color.primary`.
|
|
318
|
+
if (rawName.startsWith("--")) {
|
|
319
|
+
const stripped = rawName.slice(2);
|
|
320
|
+
const firstHyphen = stripped.indexOf("-");
|
|
321
|
+
if (firstHyphen > 0) {
|
|
322
|
+
const category = stripped.slice(0, firstHyphen);
|
|
323
|
+
const rest = stripped.slice(firstHyphen + 1);
|
|
324
|
+
// Tailwind 4 typography split (--font + --text) maps onto the
|
|
325
|
+
// composite root with the right sub-field set; the value gets
|
|
326
|
+
// wrapped into `{fontFamily}` / `{fontSize}` by normalizeTokens
|
|
327
|
+
// so the leaf matches themeTypographyComposite.
|
|
328
|
+
if (category === "font") {
|
|
329
|
+
return {
|
|
330
|
+
path: `typography.${rest}`,
|
|
331
|
+
inferredType: "typography",
|
|
332
|
+
compositeWrap: "fontFamily",
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
if (category === "text") {
|
|
336
|
+
validateValueShape("spacing", rawValue, `typography.${rest}`);
|
|
337
|
+
return {
|
|
338
|
+
path: `typography.${rest}`,
|
|
339
|
+
inferredType: "typography",
|
|
340
|
+
compositeWrap: "fontSize",
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
const path = `${category}.${rest}`;
|
|
344
|
+
validateValueShape(category, rawValue, path);
|
|
345
|
+
return { path, inferredType: inferTypeFromPath(path, rawValue) };
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// 3. Name-shape category hint.
|
|
350
|
+
const matched: CategoryDef[] = [];
|
|
351
|
+
for (const cat of CATEGORIES) {
|
|
352
|
+
if (cat.nameHints.some((re) => re.test(rawName))) matched.push(cat);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// 4. Value-shape signal — disambiguates when the name is bare or
|
|
356
|
+
// matched multiple categories.
|
|
357
|
+
const valueCategory = sniffCategoryFromValue(rawValue);
|
|
358
|
+
|
|
359
|
+
let chosen: CategoryDef | undefined;
|
|
360
|
+
if (matched.length === 1) {
|
|
361
|
+
chosen = matched[0];
|
|
362
|
+
} else if (matched.length > 1) {
|
|
363
|
+
// Prefer the category whose inferred type matches the value shape.
|
|
364
|
+
chosen = matched.find((m) => m.inferredType === valueCategory) ?? matched[0];
|
|
365
|
+
} else if (valueCategory) {
|
|
366
|
+
// Bare name but value hints — pick category by value.
|
|
367
|
+
chosen = CATEGORIES.find((c) => c.inferredType === valueCategory);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (!chosen) {
|
|
371
|
+
throw new UnknownTokenName(rawName, didYouMean(rawName));
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// 5. Extract basename: strip category prefix + camel/kebab.
|
|
375
|
+
const basename = extractBasename(rawName, chosen.category);
|
|
376
|
+
const path = basename
|
|
377
|
+
? chosen.buildPath(basename)
|
|
378
|
+
: // Bare category word ("color" / "shadow") — pick the default tier
|
|
379
|
+
// (`primary` for color, `sm` for shadow/radius, etc.).
|
|
380
|
+
chosen.buildPath(defaultTier(chosen.category));
|
|
381
|
+
validateValueShape(chosen.category, rawValue, path);
|
|
382
|
+
// Typography is a composite — loose names imply a single sub-field
|
|
383
|
+
// (fontFamily by convention). The wrap happens in normalizeTokens.
|
|
384
|
+
if (chosen.category === "typography") {
|
|
385
|
+
return { path, inferredType: chosen.inferredType, compositeWrap: "fontFamily" };
|
|
386
|
+
}
|
|
387
|
+
return { path, inferredType: chosen.inferredType };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Sub-field keys recognised by `themeTypographyComposite`. Used to
|
|
392
|
+
* detect direct DTCG sub-paths like `typography.heading.fontFamily`
|
|
393
|
+
* and collapse them onto the composite root with a wrapped value.
|
|
394
|
+
*/
|
|
395
|
+
function isTypographyCompositeSubField(segment: string | undefined): segment is string {
|
|
396
|
+
return (
|
|
397
|
+
segment === "fontFamily" ||
|
|
398
|
+
segment === "fontSize" ||
|
|
399
|
+
segment === "fontWeight" ||
|
|
400
|
+
segment === "lineHeight" ||
|
|
401
|
+
segment === "letterSpacing"
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* v0.11.0 (#45 AC #7) — fail-fast value-shape validation for the
|
|
407
|
+
* AI-actionable error surface. Catches:
|
|
408
|
+
*
|
|
409
|
+
* - color slot + string value that isn't a valid CSS color
|
|
410
|
+
* → InvalidColorValue (carries supportedFormats list).
|
|
411
|
+
* - any slot + value whose sniffed category disagrees with the
|
|
412
|
+
* resolved slot's category → TokenCategoryMismatch (carries
|
|
413
|
+
* expected vs got).
|
|
414
|
+
*
|
|
415
|
+
* Aliases (`{color.primary}`) bypass — they're resolved at render
|
|
416
|
+
* time and may legitimately point at a value of any shape. Non-string
|
|
417
|
+
* values (numbers, composite objects) also bypass because the AI
|
|
418
|
+
* passes those only on direct DTCG paths where the Zod composite
|
|
419
|
+
* schema validates the shape downstream.
|
|
420
|
+
*
|
|
421
|
+
* Typography sub-paths (`typography.<name>.fontFamily`) skip the
|
|
422
|
+
* check because fontFamily accepts free-form strings.
|
|
423
|
+
*/
|
|
424
|
+
function validateValueShape(category: string, value: unknown, canonicalPath: string): void {
|
|
425
|
+
if (typeof value !== "string") return;
|
|
426
|
+
if (ALIAS_REGEX.test(value)) return;
|
|
427
|
+
|
|
428
|
+
if (category === "color") {
|
|
429
|
+
if (COLOR_VALUE_REGEX.test(value)) return;
|
|
430
|
+
const sniffed = sniffCategoryFromValue(value);
|
|
431
|
+
if (sniffed && sniffed !== "color") {
|
|
432
|
+
throw new TokenCategoryMismatch(canonicalPath, "color", sniffed);
|
|
433
|
+
}
|
|
434
|
+
throw new InvalidColorValue(value);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
if (category === "spacing" || category === "radius" || category === "breakpoint") {
|
|
438
|
+
const sniffed = sniffCategoryFromValue(value);
|
|
439
|
+
if (sniffed && sniffed !== "dimension") {
|
|
440
|
+
throw new TokenCategoryMismatch(canonicalPath, "dimension", sniffed);
|
|
441
|
+
}
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (category === "duration") {
|
|
446
|
+
const sniffed = sniffCategoryFromValue(value);
|
|
447
|
+
if (sniffed && sniffed !== "duration") {
|
|
448
|
+
throw new TokenCategoryMismatch(canonicalPath, "duration", sniffed);
|
|
449
|
+
}
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
if (category === "gradient") {
|
|
454
|
+
if (GRADIENT_VALUE_REGEX.test(value)) return;
|
|
455
|
+
const sniffed = sniffCategoryFromValue(value);
|
|
456
|
+
if (sniffed && sniffed !== "gradient") {
|
|
457
|
+
// e.g. a flat color string sent to `gradient.hero` — name the
|
|
458
|
+
// mismatch instead of letting Zod reject with a vaguer message.
|
|
459
|
+
throw new TokenCategoryMismatch(canonicalPath, "gradient", sniffed);
|
|
460
|
+
}
|
|
461
|
+
// Not a recognised gradient and not clearly another category — defer
|
|
462
|
+
// to the document validator's precise "gradient must be a CSS
|
|
463
|
+
// *-gradient(...) value" message rather than guessing here.
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// shadow / typography composites + unknown categories: caller's
|
|
468
|
+
// responsibility to pass a structurally-correct value; Zod catches
|
|
469
|
+
// the rest at validateThemeTokens time.
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function inferTypeFromPath(path: string, value: unknown): NormalizeResult["types"][string] {
|
|
473
|
+
const head = path.split(".")[0] ?? "";
|
|
474
|
+
switch (head) {
|
|
475
|
+
case "color":
|
|
476
|
+
return "color";
|
|
477
|
+
case "typography":
|
|
478
|
+
// Composite vs sub-field. A `.fontFamily` / `.fontSize` etc.
|
|
479
|
+
// suffix lives inside the composite — type stays "typography"
|
|
480
|
+
// for top-level + "dimension" / "color" / etc. for sub-fields
|
|
481
|
+
// depending on which slot. Caller stores at the composite root
|
|
482
|
+
// anyway, so this stays "typography" for any typography.* path.
|
|
483
|
+
return "typography";
|
|
484
|
+
case "spacing":
|
|
485
|
+
case "radius":
|
|
486
|
+
case "breakpoint":
|
|
487
|
+
return "dimension";
|
|
488
|
+
case "shadow":
|
|
489
|
+
return "shadow";
|
|
490
|
+
case "gradient":
|
|
491
|
+
// issue #153 — MUST return "gradient" (not the sniffed value
|
|
492
|
+
// category). The gradient token's `$type` is `z.literal("gradient")`;
|
|
493
|
+
// before this case, `gradient.hero` fell through to `default`, the
|
|
494
|
+
// gradient string sniffed as no known category → "dimension", and
|
|
495
|
+
// the stamped `$type: "dimension"` bounced off the document
|
|
496
|
+
// validator ("gradient token invalid: expected \"gradient\"").
|
|
497
|
+
return "gradient";
|
|
498
|
+
case "duration":
|
|
499
|
+
return "duration";
|
|
500
|
+
case "ease":
|
|
501
|
+
return "cubicBezier";
|
|
502
|
+
default: {
|
|
503
|
+
const sniffed = sniffCategoryFromValue(value);
|
|
504
|
+
return sniffed ?? "dimension";
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function sniffCategoryFromValue(value: unknown): NormalizeResult["types"][string] | undefined {
|
|
510
|
+
if (typeof value !== "string") return undefined;
|
|
511
|
+
if (ALIAS_REGEX.test(value)) return undefined; // alias has no value shape
|
|
512
|
+
// Gradient before color: a `linear-gradient(...)` string could loosely
|
|
513
|
+
// read as a `color(...)`-shaped function otherwise.
|
|
514
|
+
if (GRADIENT_VALUE_REGEX.test(value)) return "gradient";
|
|
515
|
+
if (COLOR_VALUE_REGEX.test(value)) return "color";
|
|
516
|
+
if (DURATION_REGEX.test(value)) return "duration";
|
|
517
|
+
if (SHADOW_VALUE_REGEX.test(value) && /\s/.test(value)) return "shadow";
|
|
518
|
+
if (CSS_LENGTH_REGEX.test(value)) return "dimension";
|
|
519
|
+
return undefined;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Pull the meaningful basename out of a loose name. `primaryColor` /
|
|
524
|
+
* `colorPrimary` / `primary-color` all reduce to `primary`. Plural
|
|
525
|
+
* forms (`spacingLg`) lose the category prefix to leave the tier (`lg`).
|
|
526
|
+
*
|
|
527
|
+
* Returns null when the input IS the category word alone.
|
|
528
|
+
*/
|
|
529
|
+
function extractBasename(rawName: string, category: string): string | null {
|
|
530
|
+
// Lowercase camelCase → kebab-case for uniform tokenisation.
|
|
531
|
+
const kebab = rawName.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
|
|
532
|
+
const parts = kebab.split(/[-_]+/);
|
|
533
|
+
|
|
534
|
+
// Filter out category aliases (font / typography / heading-related
|
|
535
|
+
// names) AND the literal category itself.
|
|
536
|
+
const aliasesFor: Record<string, readonly string[]> = {
|
|
537
|
+
color: ["color"],
|
|
538
|
+
typography: ["font", "typography"],
|
|
539
|
+
spacing: ["space", "spacing", "gap", "padding", "margin"],
|
|
540
|
+
radius: ["radius", "rounded", "border", "borderradius", "border-radius", "corner"],
|
|
541
|
+
shadow: ["shadow", "box", "boxshadow", "box-shadow", "elevation"],
|
|
542
|
+
duration: ["duration", "timing"],
|
|
543
|
+
gradient: ["gradient"],
|
|
544
|
+
};
|
|
545
|
+
const aliases = new Set(aliasesFor[category] ?? [category]);
|
|
546
|
+
const meaningful = parts.filter((p) => p.length > 0 && !aliases.has(p));
|
|
547
|
+
if (meaningful.length === 0) return null;
|
|
548
|
+
return meaningful.join("-");
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function defaultTier(category: string): string {
|
|
552
|
+
switch (category) {
|
|
553
|
+
case "color":
|
|
554
|
+
return "primary";
|
|
555
|
+
case "typography":
|
|
556
|
+
return "body";
|
|
557
|
+
case "spacing":
|
|
558
|
+
return "md";
|
|
559
|
+
case "radius":
|
|
560
|
+
return "md";
|
|
561
|
+
case "shadow":
|
|
562
|
+
return DEFAULT_TIER;
|
|
563
|
+
case "duration":
|
|
564
|
+
return "fast";
|
|
565
|
+
case "gradient":
|
|
566
|
+
// The seeded default theme ships `gradient.hero` + `gradient.subtle`;
|
|
567
|
+
// a bare `gradient` almost always means the primary/hero surface.
|
|
568
|
+
return "hero";
|
|
569
|
+
default:
|
|
570
|
+
return "default";
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* issue #153 — best-effort structured-gradient → CSS-string converter.
|
|
576
|
+
*
|
|
577
|
+
* The DTCG gradient token stores a CSS `*-gradient(...)` string (see
|
|
578
|
+
* `themeGradientToken` in themes.ts). When the AI sends a structured
|
|
579
|
+
* shape instead, fold the RECOGNISED subset into that string:
|
|
580
|
+
*
|
|
581
|
+
* `{ type?: "linear"|"radial"|"conic", angle?: string|number,
|
|
582
|
+
* direction?: string, stops: Array<string | {color, position?}> }`
|
|
583
|
+
*
|
|
584
|
+
* → `linear-gradient(135deg, #4f46e5, #7c3aed 100%)`
|
|
585
|
+
*
|
|
586
|
+
* Returns `undefined` (caller keeps the raw value so the document
|
|
587
|
+
* validator emits its precise error) when the shape carries no usable
|
|
588
|
+
* stops — we never fabricate a gradient from nothing (CLAUDE.md §2).
|
|
589
|
+
*/
|
|
590
|
+
function gradientObjectToCss(obj: Record<string, unknown>): string | undefined {
|
|
591
|
+
const rawStops = obj.stops;
|
|
592
|
+
if (!Array.isArray(rawStops) || rawStops.length < 2) return undefined;
|
|
593
|
+
|
|
594
|
+
const stops: string[] = [];
|
|
595
|
+
for (const s of rawStops) {
|
|
596
|
+
if (typeof s === "string" && s.trim().length > 0) {
|
|
597
|
+
stops.push(s.trim());
|
|
598
|
+
continue;
|
|
599
|
+
}
|
|
600
|
+
if (s !== null && typeof s === "object" && !Array.isArray(s)) {
|
|
601
|
+
const stop = s as Record<string, unknown>;
|
|
602
|
+
const color = typeof stop.color === "string" ? stop.color.trim() : undefined;
|
|
603
|
+
if (!color) return undefined;
|
|
604
|
+
const pos =
|
|
605
|
+
typeof stop.position === "string"
|
|
606
|
+
? stop.position.trim()
|
|
607
|
+
: typeof stop.position === "number"
|
|
608
|
+
? `${stop.position}%`
|
|
609
|
+
: undefined;
|
|
610
|
+
stops.push(pos ? `${color} ${pos}` : color);
|
|
611
|
+
continue;
|
|
612
|
+
}
|
|
613
|
+
return undefined; // unrecognised stop entry — don't guess
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
const type =
|
|
617
|
+
obj.type === "radial" || obj.type === "conic" || obj.type === "linear" ? obj.type : "linear";
|
|
618
|
+
|
|
619
|
+
// Leading orientation argument. Linear/conic accept an angle; radial
|
|
620
|
+
// accepts a shape/extent hint via `direction`. Only linear gets a
|
|
621
|
+
// default angle so a bare `{stops}` still reads as a diagonal sweep.
|
|
622
|
+
let head = "";
|
|
623
|
+
if (typeof obj.direction === "string" && obj.direction.trim().length > 0) {
|
|
624
|
+
head = obj.direction.trim();
|
|
625
|
+
} else if (obj.angle !== undefined && obj.angle !== null) {
|
|
626
|
+
const a = obj.angle;
|
|
627
|
+
head = typeof a === "number" ? `${a}deg` : String(a).trim();
|
|
628
|
+
} else if (type === "linear") {
|
|
629
|
+
head = "135deg";
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
const args = head ? `${head}, ${stops.join(", ")}` : stops.join(", ");
|
|
633
|
+
return `${type}-gradient(${args})`;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* Closest-match suggestions for an unknown input. Cheap edit-distance
|
|
638
|
+
* over the canonical-paths set — good enough that the AI gets a useful
|
|
639
|
+
* "did you mean X?" without pulling a fuzzy-search dep.
|
|
640
|
+
*/
|
|
641
|
+
export function didYouMean(input: string): readonly string[] {
|
|
642
|
+
const lower = input.toLowerCase();
|
|
643
|
+
return KNOWN_CANONICAL_PATHS.map((p) => ({ path: p, score: similarity(lower, p.toLowerCase()) }))
|
|
644
|
+
.filter((c) => c.score >= 0.4)
|
|
645
|
+
.sort((a, b) => b.score - a.score)
|
|
646
|
+
.slice(0, 3)
|
|
647
|
+
.map((c) => c.path);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function similarity(a: string, b: string): number {
|
|
651
|
+
const tokensA = new Set(tokenise(a));
|
|
652
|
+
const tokensB = new Set(tokenise(b));
|
|
653
|
+
let hits = 0;
|
|
654
|
+
for (const t of tokensA) if (tokensB.has(t)) hits++;
|
|
655
|
+
const max = Math.max(tokensA.size, tokensB.size);
|
|
656
|
+
return max === 0 ? 0 : hits / max;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function tokenise(s: string): string[] {
|
|
660
|
+
return s.split(/[.\-_]/).filter((p) => p.length > 0);
|
|
661
|
+
}
|