css-is-awesome 1.16.0 → 1.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +13 -8
- package/CHANGELOG.md +33 -123
- package/CONTRACT.md +1 -0
- package/MIGRATION.md +148 -0
- package/README.md +13 -7
- package/VERSIONING.md +4 -0
- package/bin/cia.cjs +19 -0
- package/bin/theme-from-tokens.cjs +139 -0
- package/dist/tokens.d.ts +2 -4
- package/figma-tokens/README.md +15 -0
- package/llm.txt +4 -4
- package/mcp/server.cjs +35 -1
- package/package.json +13 -3
- package/scripts/theme-contract.json +3 -3
- package/scripts/theme-validator.js +32 -2
- package/scripts/tokens-to-theme.cjs +668 -0
- package/ROADMAP.md +0 -717
|
@@ -0,0 +1,668 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ============================================================================
|
|
3
|
+
// tokens-to-theme.cjs — design-tokens JSON → validated cia theme.css
|
|
4
|
+
// ============================================================================
|
|
5
|
+
// The supported path from a design tool to a cia theme. Ships in the npm
|
|
6
|
+
// package (see `files`), zero dependencies, Node ≥ 20. Three front doors share
|
|
7
|
+
// this one module:
|
|
8
|
+
//
|
|
9
|
+
// CLI npx cia theme from-tokens tokens.json --name acme (bin/theme-from-tokens.cjs)
|
|
10
|
+
// MCP theme_from_tokens({ tokens, name }) (mcp/server.cjs)
|
|
11
|
+
// In-proc require('css-is-awesome/mcp/server.cjs').handlers.theme_from_tokens(...)
|
|
12
|
+
//
|
|
13
|
+
// INPUT FORMATS (`format`)
|
|
14
|
+
// auto detect: keys starting with `--` → cia-flat; any `$value` → dtcg;
|
|
15
|
+
// otherwise tokens-studio.
|
|
16
|
+
// dtcg DTCG v2025.10 — nested groups, leaves carry `$value` (+ `$type`).
|
|
17
|
+
// Aliases `{group.path}` resolve recursively (cycle-safe; an
|
|
18
|
+
// unresolved alias is an error, never a silent drop). `$extensions`
|
|
19
|
+
// and other `$`-keys are ignored. 2025.10 composite values are
|
|
20
|
+
// understood: color objects ({hex} or {colorSpace, components}),
|
|
21
|
+
// dimension/duration objects ({value, unit}), shadow objects/arrays,
|
|
22
|
+
// fontFamily arrays, cubicBezier arrays.
|
|
23
|
+
// tokens-studio Tokens Studio for Figma export — leaves carry `value` + `type`.
|
|
24
|
+
// Either a single set (like figma-tokens/tokens.json here) or the
|
|
25
|
+
// multi-set export with top-level `$themes` / `$metadata`; sets merge
|
|
26
|
+
// in `$metadata.tokenSetOrder` (later sets win). Aliases `{path}`
|
|
27
|
+
// (and legacy `$path`) resolve the same way.
|
|
28
|
+
// cia-flat `{ "--brand-primary": "#3A5FCD", ... }` — passthrough.
|
|
29
|
+
//
|
|
30
|
+
// PATH → TOKEN RESOLUTION (in order; first hit wins)
|
|
31
|
+
// 1. TOKEN_MAP — the explicit table below for Figma-style paths that don't
|
|
32
|
+
// spell the cia name (typography.font.body → --font-sans, …).
|
|
33
|
+
// 2. Generic rule — strip a leading `color.`/`colors.`, rewrite a few common
|
|
34
|
+
// group names (PATH_ALIASES: spacing.→space., border-radius.→radius., …),
|
|
35
|
+
// then join the segments with `-`, prefix `--`, and accept the name if it
|
|
36
|
+
// is in the contract (required or optional). So `space.4` → `--space-4`,
|
|
37
|
+
// `brand.primary` → `--brand-primary`, `text.primary` → `--text-primary`.
|
|
38
|
+
// 3. Anything else is emitted VERBATIM as `--<joined-path>` and listed in
|
|
39
|
+
// report.unmapped — never dropped silently.
|
|
40
|
+
//
|
|
41
|
+
// MINIMUM CONTENT
|
|
42
|
+
// None beyond what you have. Every REQUIRED token the file does not supply is
|
|
43
|
+
// inherited from a shipped base theme (`base`, default boilerplate) and listed
|
|
44
|
+
// in report.inherited; optional tokens are never inherited (the library
|
|
45
|
+
// default applies). The output is always a complete, contract-valid theme —
|
|
46
|
+
// the validator + WCAG audit run on it and come back as `validation`.
|
|
47
|
+
//
|
|
48
|
+
// LIGHT + DARK
|
|
49
|
+
// Pass `dark` (a second tokens object, same format) and every colour token
|
|
50
|
+
// that differs becomes `light-dark(light, dark)` with `color-scheme: light dark`.
|
|
51
|
+
// A single Tokens Studio file with paired top-level groups (`color-light` +
|
|
52
|
+
// `color-dark`, or `light` + `dark`) is split the same way automatically.
|
|
53
|
+
// Without a dark side the block is single-mode: `color-scheme: <mode>`
|
|
54
|
+
// (`mode`, default light).
|
|
55
|
+
// ============================================================================
|
|
56
|
+
'use strict';
|
|
57
|
+
|
|
58
|
+
const fs = require('fs');
|
|
59
|
+
const path = require('path');
|
|
60
|
+
|
|
61
|
+
const CIA_ROOT_DEFAULT = path.resolve(__dirname, '..');
|
|
62
|
+
const GENERATOR_VERSION = (() => {
|
|
63
|
+
try { return require(path.join(CIA_ROOT_DEFAULT, 'package.json')).version; } catch { return 'unknown'; }
|
|
64
|
+
})();
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// 1. Explicit table — Figma-style paths whose cia name is not the joined path.
|
|
68
|
+
// (Everything that IS the joined path — brand.primary, space.4, shadow.md,
|
|
69
|
+
// duration.fast, z.modal, code.bg, … — needs no entry: rule 2 covers it.)
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
const TOKEN_MAP = {
|
|
72
|
+
// Surfaces / backgrounds — original table (kept verbatim)
|
|
73
|
+
'color.background.default': '--background-default',
|
|
74
|
+
'color.background.subtle': '--background-subtle',
|
|
75
|
+
'color.background.elevated': '--background-elevated',
|
|
76
|
+
'color.background.overlay': '--background-overlay',
|
|
77
|
+
'color.background.hero': '--background-hero',
|
|
78
|
+
'color.background.scrim': '--background-scrim',
|
|
79
|
+
'color.surface.default': '--surface-default',
|
|
80
|
+
'color.surface.raised': '--surface-raised',
|
|
81
|
+
'color.surface.muted': '--surface-muted',
|
|
82
|
+
// Text
|
|
83
|
+
'color.text.primary': '--text-primary',
|
|
84
|
+
'color.text.secondary': '--text-secondary',
|
|
85
|
+
'color.text.muted': '--text-muted',
|
|
86
|
+
'color.text.inverse': '--text-inverse',
|
|
87
|
+
'color.text.link': '--text-link',
|
|
88
|
+
// Action
|
|
89
|
+
'color.action.primary.default': '--action-primary-default',
|
|
90
|
+
'color.action.primary.hover': '--action-primary-hover',
|
|
91
|
+
'color.action.primary.active': '--action-primary-active',
|
|
92
|
+
'color.action.secondary.default': '--action-secondary-default',
|
|
93
|
+
'color.action.secondary.hover': '--action-secondary-hover',
|
|
94
|
+
'color.action.secondary.active': '--action-secondary-active',
|
|
95
|
+
// Status
|
|
96
|
+
'color.success.default': '--success-default',
|
|
97
|
+
'color.success.subtle': '--success-subtle',
|
|
98
|
+
'color.warning.default': '--warning-default',
|
|
99
|
+
'color.warning.subtle': '--warning-subtle',
|
|
100
|
+
'color.error.default': '--error-default',
|
|
101
|
+
'color.error.subtle': '--error-subtle',
|
|
102
|
+
// Border
|
|
103
|
+
'color.border.default': '--border-default',
|
|
104
|
+
'color.border.subtle': '--border-subtle',
|
|
105
|
+
'color.border.focus': '--border-focus',
|
|
106
|
+
// Type
|
|
107
|
+
'typography.font.display': '--font-display',
|
|
108
|
+
'typography.font.body': '--font-sans',
|
|
109
|
+
'typography.font.mono': '--font-mono',
|
|
110
|
+
'typography.font.serif': '--font-serif',
|
|
111
|
+
// Shape
|
|
112
|
+
'shape.radius.sm': '--radius-sm',
|
|
113
|
+
'shape.radius.md': '--radius-md',
|
|
114
|
+
'shape.radius.lg': '--radius-lg',
|
|
115
|
+
'shape.radius.full': '--radius-full',
|
|
116
|
+
// Spacing aliases
|
|
117
|
+
'space.xs': '--space-xs',
|
|
118
|
+
'space.sm': '--space-sm',
|
|
119
|
+
'space.md': '--space-md',
|
|
120
|
+
'space.lg': '--space-lg',
|
|
121
|
+
'space.xl': '--space-xl',
|
|
122
|
+
|
|
123
|
+
// ── Additions 2026-09-18 (Gremlin Forge item 1) — every REQUIRED token
|
|
124
|
+
// whose natural Figma path does not spell the cia name ─────────────
|
|
125
|
+
// Type — Figma tools group families/sizes/weights under typography.*
|
|
126
|
+
'typography.font.primary': '--font-primary',
|
|
127
|
+
'typography.font.sans': '--font-sans',
|
|
128
|
+
'typography.font.script': '--font-script',
|
|
129
|
+
'typography.family.display': '--font-display',
|
|
130
|
+
'typography.family.body': '--font-sans',
|
|
131
|
+
'typography.family.sans': '--font-sans',
|
|
132
|
+
'typography.family.serif': '--font-serif',
|
|
133
|
+
'typography.family.mono': '--font-mono',
|
|
134
|
+
'typography.family.script': '--font-script',
|
|
135
|
+
'typography.family.primary': '--font-primary',
|
|
136
|
+
'typography.size.base': '--font-size-base',
|
|
137
|
+
'typography.fontSize.base': '--font-size-base',
|
|
138
|
+
'typography.weight.medium': '--font-weight-medium',
|
|
139
|
+
'typography.fontWeight.medium': '--font-weight-medium',
|
|
140
|
+
'typography.lineHeight.normal': '--line-height-normal',
|
|
141
|
+
'typography.line-height.normal': '--line-height-normal',
|
|
142
|
+
// Shape — the fifth radius step + the short r-* aliases some kits export
|
|
143
|
+
'shape.radius.xl': '--radius-xl',
|
|
144
|
+
'shape.radius.r-sm': '--r-sm',
|
|
145
|
+
'shape.radius.r-md': '--r-md',
|
|
146
|
+
'shape.radius.r-lg': '--r-lg',
|
|
147
|
+
// Colour ramps — the tertiary action, washes, interactive states
|
|
148
|
+
'color.action.tertiary.default': '--action-tertiary-default',
|
|
149
|
+
'color.action.tertiary.hover': '--action-tertiary-hover',
|
|
150
|
+
'color.action.tertiary.active': '--action-tertiary-active',
|
|
151
|
+
'color.action.primary.wash': '--action-primary-wash',
|
|
152
|
+
'color.action.secondary.wash': '--action-secondary-wash',
|
|
153
|
+
'color.action.tertiary.wash': '--action-tertiary-wash',
|
|
154
|
+
'color.interactive.hover': '--interactive-hover',
|
|
155
|
+
'color.interactive.active': '--interactive-active',
|
|
156
|
+
'color.text.tertiary': '--text-tertiary',
|
|
157
|
+
'color.text.link.hover': '--text-link-hover',
|
|
158
|
+
'color.text.linkHover': '--text-link-hover',
|
|
159
|
+
'color.border.emphasis': '--border-emphasis',
|
|
160
|
+
'color.surface.subtle': '--surface-subtle',
|
|
161
|
+
'color.surface.emphasis': '--surface-emphasis',
|
|
162
|
+
'color.surface.glass': '--surface-glass',
|
|
163
|
+
'color.surface.sunk': '--surface-sunk',
|
|
164
|
+
'color.background.navbar': '--background-navbar',
|
|
165
|
+
'color.success.text': '--success-text',
|
|
166
|
+
'color.warning.text': '--warning-text',
|
|
167
|
+
'color.error.text': '--error-text',
|
|
168
|
+
'color.info.default': '--info-default',
|
|
169
|
+
'color.info.subtle': '--info-subtle',
|
|
170
|
+
'color.info.text': '--info-text',
|
|
171
|
+
'color.feedback.success': '--feedback-success',
|
|
172
|
+
'color.feedback.warning': '--feedback-warning',
|
|
173
|
+
'color.feedback.error': '--feedback-error',
|
|
174
|
+
'color.feedback.info': '--feedback-info',
|
|
175
|
+
'color.brand.primary': '--brand-primary',
|
|
176
|
+
'color.brand.primary.hover': '--brand-primary-hover',
|
|
177
|
+
'color.brand.primaryHover': '--brand-primary-hover',
|
|
178
|
+
// Motion
|
|
179
|
+
'motion.duration.fast': '--duration-fast',
|
|
180
|
+
'motion.duration.normal': '--duration-normal',
|
|
181
|
+
'motion.duration.slow': '--duration-slow',
|
|
182
|
+
'motion.easing': '--ease',
|
|
183
|
+
'motion.ease': '--ease',
|
|
184
|
+
'motion.easing.default': '--ease',
|
|
185
|
+
// Effects
|
|
186
|
+
'effect.shadow.sm': '--shadow-sm',
|
|
187
|
+
'effect.shadow.md': '--shadow-md',
|
|
188
|
+
'effect.shadow.lg': '--shadow-lg',
|
|
189
|
+
'effect.shadow.xl': '--shadow-xl',
|
|
190
|
+
'effect.shadow.2xl': '--shadow-2xl',
|
|
191
|
+
'effect.blur.sm': '--blur-sm',
|
|
192
|
+
'effect.blur.md': '--blur-md',
|
|
193
|
+
'effect.blur.lg': '--blur-lg',
|
|
194
|
+
'effect.glow.sm': '--glow-sm',
|
|
195
|
+
'effect.glow.md': '--glow-md',
|
|
196
|
+
'effect.glow.lg': '--glow-lg',
|
|
197
|
+
// Layering
|
|
198
|
+
'layer.dropdown': '--z-dropdown',
|
|
199
|
+
'layer.sticky': '--z-sticky',
|
|
200
|
+
'layer.backdrop': '--z-backdrop',
|
|
201
|
+
'layer.modal': '--z-modal',
|
|
202
|
+
'layer.popover': '--z-popover',
|
|
203
|
+
'layer.tooltip': '--z-tooltip',
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
// Group-name rewrites applied before the generic rule (rule 2). Case-insensitive
|
|
207
|
+
// on the first segment only; the rest of the path is joined verbatim.
|
|
208
|
+
const PATH_ALIASES = [
|
|
209
|
+
[/^colors?\./i, ''],
|
|
210
|
+
[/^spacing\./i, 'space.'],
|
|
211
|
+
[/^border-?radius\./i, 'radius.'],
|
|
212
|
+
[/^radii\./i, 'radius.'],
|
|
213
|
+
[/^elevation\./i, 'shadow.'],
|
|
214
|
+
[/^shadows\./i, 'shadow.'],
|
|
215
|
+
[/^z-?index\./i, 'z.'],
|
|
216
|
+
[/^zindex\./i, 'z.'],
|
|
217
|
+
[/^layer\./i, 'z.'],
|
|
218
|
+
[/^font-?family\./i, 'font.'],
|
|
219
|
+
[/^fontFamilies\./i, 'font.'],
|
|
220
|
+
[/^fonts\./i, 'font.'],
|
|
221
|
+
[/^font-?size\./i, 'font-size.'],
|
|
222
|
+
[/^fontSizes?\./i, 'font-size.'],
|
|
223
|
+
[/^font-?weight\./i, 'font-weight.'],
|
|
224
|
+
[/^fontWeights?\./i, 'font-weight.'],
|
|
225
|
+
[/^line-?height\./i, 'line-height.'],
|
|
226
|
+
[/^lineHeights?\./i, 'line-height.'],
|
|
227
|
+
[/^durations?\./i, 'duration.'],
|
|
228
|
+
[/^motion\.duration\./i, 'duration.'],
|
|
229
|
+
];
|
|
230
|
+
|
|
231
|
+
// Token families that are unitless by contract — a bare number stays bare.
|
|
232
|
+
const UNITLESS_RE = /^--(line-height|font-weight|z-|opacity)/;
|
|
233
|
+
|
|
234
|
+
// ---------------------------------------------------------------------------
|
|
235
|
+
// Format detection + flattening
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
function isPlainObject(v) { return v !== null && typeof v === 'object' && !Array.isArray(v); }
|
|
238
|
+
|
|
239
|
+
function detectFormat(tokens) {
|
|
240
|
+
if (!isPlainObject(tokens)) throw new Error('tokens must be a JSON object');
|
|
241
|
+
const keys = Object.keys(tokens);
|
|
242
|
+
if (keys.length && keys.every((k) => k.startsWith('--'))) return 'cia-flat';
|
|
243
|
+
if (hasKeyDeep(tokens, '$value')) return 'dtcg';
|
|
244
|
+
return 'tokens-studio';
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function hasKeyDeep(obj, key, depth = 0) {
|
|
248
|
+
if (!isPlainObject(obj) || depth > 12) return false;
|
|
249
|
+
if (Object.prototype.hasOwnProperty.call(obj, key)) return true;
|
|
250
|
+
return Object.values(obj).some((v) => hasKeyDeep(v, key, depth + 1));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** DTCG: leaves are objects carrying `$value`; `$`-prefixed keys are metadata. */
|
|
254
|
+
function flattenDtcg(obj, prefix = '', out = []) {
|
|
255
|
+
for (const [key, val] of Object.entries(obj)) {
|
|
256
|
+
if (key.startsWith('$')) continue;
|
|
257
|
+
const p = prefix ? `${prefix}.${key}` : key;
|
|
258
|
+
if (isPlainObject(val)) {
|
|
259
|
+
if ('$value' in val) out.push({ path: p, value: val.$value, type: val.$type ? String(val.$type).toLowerCase() : undefined });
|
|
260
|
+
else flattenDtcg(val, p, out);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return out;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Tokens Studio: leaves are objects carrying `value` (+ `type`). */
|
|
267
|
+
function flattenTokensStudio(obj, prefix = '', out = []) {
|
|
268
|
+
for (const [key, val] of Object.entries(obj)) {
|
|
269
|
+
if (key.startsWith('$')) continue;
|
|
270
|
+
const p = prefix ? `${prefix}.${key}` : key;
|
|
271
|
+
if (isPlainObject(val)) {
|
|
272
|
+
if ('value' in val && !isPlainObject(val.value) || ('value' in val && 'type' in val)) {
|
|
273
|
+
out.push({ path: p, value: val.value, type: val.type ? String(val.type).toLowerCase() : undefined });
|
|
274
|
+
} else {
|
|
275
|
+
flattenTokensStudio(val, p, out);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return out;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Tokens Studio multi-set export → one merged set (later sets override). */
|
|
283
|
+
function mergeTokensStudioSets(root) {
|
|
284
|
+
const meta = root.$metadata;
|
|
285
|
+
const setNames = Object.keys(root).filter((k) => !k.startsWith('$'));
|
|
286
|
+
const looksMultiSet = isPlainObject(meta) && Array.isArray(meta.tokenSetOrder) || Array.isArray(root.$themes);
|
|
287
|
+
if (!looksMultiSet) return { set: root, sets: null };
|
|
288
|
+
const order = isPlainObject(meta) && Array.isArray(meta.tokenSetOrder) ? meta.tokenSetOrder.filter((n) => setNames.includes(n)) : setNames;
|
|
289
|
+
const missing = setNames.filter((n) => !order.includes(n));
|
|
290
|
+
const merged = {};
|
|
291
|
+
for (const name of [...order, ...missing]) deepMerge(merged, root[name]);
|
|
292
|
+
return { set: merged, sets: [...order, ...missing] };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function deepMerge(target, src) {
|
|
296
|
+
if (!isPlainObject(src)) return target;
|
|
297
|
+
for (const [k, v] of Object.entries(src)) {
|
|
298
|
+
if (isPlainObject(v) && isPlainObject(target[k]) && !('value' in v) && !('$value' in v)) deepMerge(target[k], v);
|
|
299
|
+
else target[k] = v;
|
|
300
|
+
}
|
|
301
|
+
return target;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ---------------------------------------------------------------------------
|
|
305
|
+
// Alias resolution — `{group.path}` anywhere in a string, or a whole-value
|
|
306
|
+
// legacy `$group.path`. Recursive, cycle-safe, loud on failure.
|
|
307
|
+
// ---------------------------------------------------------------------------
|
|
308
|
+
function resolveAliases(entries) {
|
|
309
|
+
const byPath = new Map(entries.map((e) => [e.path, e]));
|
|
310
|
+
const resolved = new Map();
|
|
311
|
+
|
|
312
|
+
function resolveValue(value, stack) {
|
|
313
|
+
if (typeof value === 'string') {
|
|
314
|
+
const whole = value.match(/^\$([A-Za-z0-9_.-]+)$/);
|
|
315
|
+
if (whole) return resolvePath(whole[1], stack);
|
|
316
|
+
return value.replace(/\{([^{}]+)\}/g, (_m, ref) => {
|
|
317
|
+
const v = resolvePath(ref.trim(), stack);
|
|
318
|
+
return typeof v === 'string' || typeof v === 'number' ? String(v) : JSON.stringify(v);
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
if (Array.isArray(value)) return value.map((v) => resolveValue(v, stack));
|
|
322
|
+
if (isPlainObject(value)) {
|
|
323
|
+
const out = {};
|
|
324
|
+
for (const [k, v] of Object.entries(value)) out[k] = resolveValue(v, stack);
|
|
325
|
+
return out;
|
|
326
|
+
}
|
|
327
|
+
return value;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function resolvePath(ref, stack) {
|
|
331
|
+
if (resolved.has(ref)) return resolved.get(ref);
|
|
332
|
+
if (stack.includes(ref)) throw new Error(`alias cycle: ${[...stack, ref].join(' → ')}`);
|
|
333
|
+
const target = byPath.get(ref);
|
|
334
|
+
if (!target) throw new Error(`unresolved alias {${ref}} (referenced from ${stack[stack.length - 1] || 'top level'})`);
|
|
335
|
+
const v = resolveValue(target.value, [...stack, ref]);
|
|
336
|
+
resolved.set(ref, v);
|
|
337
|
+
return v;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return entries.map((e) => ({ ...e, value: resolveValue(e.value, [e.path]) }));
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ---------------------------------------------------------------------------
|
|
344
|
+
// Value normalisation — every leaf becomes one CSS value string, or is skipped
|
|
345
|
+
// with a reason (composites such as `typography` have no single-token home).
|
|
346
|
+
// ---------------------------------------------------------------------------
|
|
347
|
+
function unitOf(type) {
|
|
348
|
+
const t = (type || '').toLowerCase();
|
|
349
|
+
if (['duration', 'durations'].includes(t)) return 'ms';
|
|
350
|
+
if (['dimension', 'spacing', 'sizing', 'borderradius', 'borderwidth', 'fontsize', 'fontsizes', 'letterspacing', 'paragraphspacing', 'space', 'radius'].includes(t)) return 'px';
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function dimToString(v, fallbackUnit) {
|
|
355
|
+
if (isPlainObject(v) && 'value' in v) return `${v.value}${v.unit || fallbackUnit || ''}`;
|
|
356
|
+
if (typeof v === 'number') return `${v}${fallbackUnit || ''}`;
|
|
357
|
+
// Tokens Studio exports numbers as strings ("4"); a bare numeric string takes the unit too.
|
|
358
|
+
if (typeof v === 'string' && /^-?\d+(\.\d+)?$/.test(v.trim())) return `${v.trim()}${fallbackUnit || ''}`;
|
|
359
|
+
return String(v);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function colorToString(v) {
|
|
363
|
+
if (typeof v === 'string') return v.trim();
|
|
364
|
+
if (isPlainObject(v)) {
|
|
365
|
+
if (v.hex) return String(v.hex);
|
|
366
|
+
const space = String(v.colorSpace || 'srgb').toLowerCase();
|
|
367
|
+
const c = Array.isArray(v.components) ? v.components : [];
|
|
368
|
+
const a = v.alpha == null ? 1 : Number(v.alpha);
|
|
369
|
+
if (space === 'srgb' && c.length >= 3) {
|
|
370
|
+
const [r, g, b] = c.map((x) => Math.round(Number(x) * 255));
|
|
371
|
+
return a < 1 ? `rgba(${r}, ${g}, ${b}, ${a})` : `rgb(${r}, ${g}, ${b})`;
|
|
372
|
+
}
|
|
373
|
+
return `color(${space} ${c.join(' ')}${a < 1 ? ` / ${a}` : ''})`;
|
|
374
|
+
}
|
|
375
|
+
return String(v);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function shadowToString(v) {
|
|
379
|
+
const one = (s) => {
|
|
380
|
+
if (typeof s === 'string') return s;
|
|
381
|
+
if (!isPlainObject(s)) return String(s);
|
|
382
|
+
const x = dimToString(s.offsetX ?? s.x ?? 0, 'px');
|
|
383
|
+
const y = dimToString(s.offsetY ?? s.y ?? 0, 'px');
|
|
384
|
+
const blur = dimToString(s.blur ?? 0, 'px');
|
|
385
|
+
const spread = dimToString(s.spread ?? 0, 'px');
|
|
386
|
+
const color = colorToString(s.color ?? 'rgba(0,0,0,0.2)');
|
|
387
|
+
const inset = s.inset || String(s.type || '').toLowerCase() === 'innershadow' ? 'inset ' : '';
|
|
388
|
+
return `${inset}${x} ${y} ${blur} ${spread} ${color}`;
|
|
389
|
+
};
|
|
390
|
+
return (Array.isArray(v) ? v : [v]).map(one).join(', ');
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function fontFamilyToString(v) {
|
|
394
|
+
const list = Array.isArray(v) ? v : String(v).split(',').map((s) => s.trim());
|
|
395
|
+
return list.map((f) => (/[\s]/.test(f) && !/^["']/.test(f) ? `"${f}"` : f)).join(', ');
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** Returns { value } or { skip: reason }. */
|
|
399
|
+
function normalizeValue(entry, tokenName) {
|
|
400
|
+
const { value, type } = entry;
|
|
401
|
+
const t = (type || '').toLowerCase();
|
|
402
|
+
if (value == null) return { skip: 'empty value' };
|
|
403
|
+
if (['typography', 'border', 'composition', 'strokestyle', 'transition', 'gradient'].includes(t)) return { skip: `composite type "${t}" has no single-token home` };
|
|
404
|
+
if (t === 'color') return { value: colorToString(value) };
|
|
405
|
+
if (t === 'shadow' || t === 'boxshadow') return { value: shadowToString(value) };
|
|
406
|
+
if (t === 'fontfamily' || t === 'fontfamilies') return { value: fontFamilyToString(value) };
|
|
407
|
+
if (t === 'cubicbezier' && Array.isArray(value)) return { value: `cubic-bezier(${value.join(', ')})` };
|
|
408
|
+
const unit = unitOf(t);
|
|
409
|
+
if (unit) {
|
|
410
|
+
if (isPlainObject(value)) return { value: dimToString(value, unit) };
|
|
411
|
+
if (typeof value === 'number' || /^-?\d+(\.\d+)?$/.test(String(value).trim())) {
|
|
412
|
+
const n = Number(value);
|
|
413
|
+
return { value: n === 0 ? '0' : `${n}${UNITLESS_RE.test(tokenName) ? '' : unit}` };
|
|
414
|
+
}
|
|
415
|
+
return { value: String(value).trim() };
|
|
416
|
+
}
|
|
417
|
+
if (isPlainObject(value)) {
|
|
418
|
+
if ('hex' in value || 'colorSpace' in value) return { value: colorToString(value) };
|
|
419
|
+
if ('value' in value && 'unit' in value) return { value: dimToString(value) };
|
|
420
|
+
return { skip: 'object value of unknown shape' };
|
|
421
|
+
}
|
|
422
|
+
if (Array.isArray(value)) return { value: value.map(String).join(', ') };
|
|
423
|
+
return { value: String(value).trim() };
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// ---------------------------------------------------------------------------
|
|
427
|
+
// Path → cia token name
|
|
428
|
+
// ---------------------------------------------------------------------------
|
|
429
|
+
function loadContract(ciaRoot) {
|
|
430
|
+
const p = path.join(ciaRoot, 'scripts', 'theme-contract.json');
|
|
431
|
+
const c = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
432
|
+
return {
|
|
433
|
+
version: c.version,
|
|
434
|
+
required: c.required || [],
|
|
435
|
+
optional: c.optional || [],
|
|
436
|
+
all: new Set([...(c.required || []), ...(c.optional || [])]),
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function applyPathAliases(p) {
|
|
441
|
+
let out = p;
|
|
442
|
+
for (const [re, to] of PATH_ALIASES) {
|
|
443
|
+
if (re.test(out)) { out = out.replace(re, to); break; }
|
|
444
|
+
}
|
|
445
|
+
return out;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** Returns { token, mapped: true } or { token, mapped: false } (verbatim fallback). */
|
|
449
|
+
function mapPath(tokenPath, contract) {
|
|
450
|
+
if (TOKEN_MAP[tokenPath]) return { token: TOKEN_MAP[tokenPath], mapped: true };
|
|
451
|
+
const aliased = applyPathAliases(tokenPath);
|
|
452
|
+
if (TOKEN_MAP[aliased]) return { token: TOKEN_MAP[aliased], mapped: true };
|
|
453
|
+
const generic = `--${aliased.replace(/\./g, '-').toLowerCase()}`;
|
|
454
|
+
if (contract.all.has(generic)) return { token: generic, mapped: true };
|
|
455
|
+
// camelCase segments → kebab (linkHover → link-hover), one more try.
|
|
456
|
+
const kebab = `--${aliased.replace(/([a-z0-9])([A-Z])/g, '$1-$2').replace(/\./g, '-').toLowerCase()}`;
|
|
457
|
+
if (contract.all.has(kebab)) return { token: kebab, mapped: true };
|
|
458
|
+
return { token: generic, mapped: false };
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// ---------------------------------------------------------------------------
|
|
462
|
+
// Base theme — the shipped theme.css a partial file inherits from
|
|
463
|
+
// ---------------------------------------------------------------------------
|
|
464
|
+
function listBases(ciaRoot) {
|
|
465
|
+
const dir = path.join(ciaRoot, 'public', 'themes');
|
|
466
|
+
try {
|
|
467
|
+
return fs.readdirSync(dir, { withFileTypes: true })
|
|
468
|
+
.filter((d) => d.isDirectory() && fs.existsSync(path.join(dir, d.name, 'theme.css')))
|
|
469
|
+
.map((d) => d.name)
|
|
470
|
+
.sort();
|
|
471
|
+
} catch { return []; }
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function loadBase(ciaRoot, base) {
|
|
475
|
+
const file = path.join(ciaRoot, 'public', 'themes', base, 'theme.css');
|
|
476
|
+
if (!fs.existsSync(file)) {
|
|
477
|
+
const bases = listBases(ciaRoot);
|
|
478
|
+
throw new Error(`unknown base theme "${base}". Available: ${bases.join(', ') || '(none found under public/themes)'}`);
|
|
479
|
+
}
|
|
480
|
+
const validator = require(path.join(ciaRoot, 'scripts', 'theme-validator.js'));
|
|
481
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
482
|
+
const blocks = validator.extractDataThemeBlocks(text);
|
|
483
|
+
const values = blocks.length ? blocks[0].values : validator.extractRootBlock(text).values;
|
|
484
|
+
return { name: base, file, values }; // Map<token, rawValue>
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// ---------------------------------------------------------------------------
|
|
488
|
+
// Paired-mode detection for a single Tokens Studio / DTCG file
|
|
489
|
+
// ---------------------------------------------------------------------------
|
|
490
|
+
const PAIRS = [['color-light', 'color-dark'], ['light', 'dark'], ['colors-light', 'colors-dark']];
|
|
491
|
+
|
|
492
|
+
function splitPairedModes(tokens) {
|
|
493
|
+
if (!isPlainObject(tokens)) return null;
|
|
494
|
+
for (const [l, d] of PAIRS) {
|
|
495
|
+
if (isPlainObject(tokens[l]) && isPlainObject(tokens[d])) {
|
|
496
|
+
const rest = {};
|
|
497
|
+
for (const [k, v] of Object.entries(tokens)) if (k !== l && k !== d) rest[k] = v;
|
|
498
|
+
const light = { ...rest, color: tokens[l] };
|
|
499
|
+
const dark = { ...rest, color: tokens[d] };
|
|
500
|
+
return { light, dark, groups: [l, d] };
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
return null;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// ---------------------------------------------------------------------------
|
|
507
|
+
// One tokens object → Map<token, value> (+ bookkeeping)
|
|
508
|
+
// ---------------------------------------------------------------------------
|
|
509
|
+
function collect(tokens, format, contract) {
|
|
510
|
+
const declared = new Map(); // token → value
|
|
511
|
+
const unmapped = []; // { path, emittedAs }
|
|
512
|
+
const skipped = []; // { path, reason }
|
|
513
|
+
const fromPaths = new Map(); // token → path
|
|
514
|
+
|
|
515
|
+
if (format === 'cia-flat') {
|
|
516
|
+
for (const [k, v] of Object.entries(tokens)) {
|
|
517
|
+
if (!k.startsWith('--')) throw new Error(`cia-flat: key "${k}" is not a custom property`);
|
|
518
|
+
declared.set(k, String(v));
|
|
519
|
+
if (!contract.all.has(k)) unmapped.push({ path: k, emittedAs: k });
|
|
520
|
+
}
|
|
521
|
+
return { declared, unmapped, skipped, fromPaths };
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
let entries;
|
|
525
|
+
if (format === 'dtcg') entries = flattenDtcg(tokens);
|
|
526
|
+
else {
|
|
527
|
+
const { set } = mergeTokensStudioSets(tokens);
|
|
528
|
+
entries = flattenTokensStudio(set);
|
|
529
|
+
}
|
|
530
|
+
entries = resolveAliases(entries);
|
|
531
|
+
|
|
532
|
+
for (const e of entries) {
|
|
533
|
+
const { token, mapped } = mapPath(e.path, contract);
|
|
534
|
+
const norm = normalizeValue(e, token);
|
|
535
|
+
if (norm.skip) { skipped.push({ path: e.path, reason: norm.skip }); continue; }
|
|
536
|
+
declared.set(token, norm.value);
|
|
537
|
+
fromPaths.set(token, e.path);
|
|
538
|
+
if (!mapped) unmapped.push({ path: e.path, emittedAs: token });
|
|
539
|
+
}
|
|
540
|
+
return { declared, unmapped, skipped, fromPaths };
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// ---------------------------------------------------------------------------
|
|
544
|
+
// Public API
|
|
545
|
+
// ---------------------------------------------------------------------------
|
|
546
|
+
const NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
547
|
+
|
|
548
|
+
function themeFromTokens(opts = {}) {
|
|
549
|
+
const ciaRoot = opts.ciaRoot || CIA_ROOT_DEFAULT;
|
|
550
|
+
const name = String(opts.name || '').trim();
|
|
551
|
+
if (!NAME_RE.test(name)) throw new Error(`name must be a kebab-case slug (got "${opts.name}")`);
|
|
552
|
+
let tokens = typeof opts.tokens === 'string' ? JSON.parse(opts.tokens) : opts.tokens;
|
|
553
|
+
let dark = typeof opts.dark === 'string' ? JSON.parse(opts.dark) : opts.dark || null;
|
|
554
|
+
if (!isPlainObject(tokens)) throw new Error('tokens must be a JSON object (or a JSON string)');
|
|
555
|
+
|
|
556
|
+
const contract = loadContract(ciaRoot);
|
|
557
|
+
const base = loadBase(ciaRoot, opts.base || 'boilerplate');
|
|
558
|
+
const mode = opts.mode === 'dark' ? 'dark' : 'light';
|
|
559
|
+
|
|
560
|
+
let format = opts.format && opts.format !== 'auto' ? String(opts.format) : detectFormat(tokens);
|
|
561
|
+
if (!['dtcg', 'tokens-studio', 'cia-flat'].includes(format)) throw new Error(`format must be auto | dtcg | tokens-studio | cia-flat (got "${format}")`);
|
|
562
|
+
|
|
563
|
+
// A single file with paired light/dark groups is split automatically.
|
|
564
|
+
let pairedGroups = null;
|
|
565
|
+
if (!dark && format !== 'cia-flat') {
|
|
566
|
+
const pair = splitPairedModes(tokens);
|
|
567
|
+
if (pair) { tokens = pair.light; dark = pair.dark; pairedGroups = pair.groups; }
|
|
568
|
+
}
|
|
569
|
+
if (dark && !isPlainObject(dark)) throw new Error('dark must be a JSON object (or a JSON string)');
|
|
570
|
+
const darkFormat = dark ? (opts.format && opts.format !== 'auto' ? format : detectFormat(dark)) : null;
|
|
571
|
+
|
|
572
|
+
const light = collect(tokens, format, contract);
|
|
573
|
+
const darkSide = dark ? collect(dark, darkFormat, contract) : null;
|
|
574
|
+
const darkMode = Boolean(darkSide);
|
|
575
|
+
|
|
576
|
+
const isColorish = (v) => /^(#|rgb|hsl|hwb|lab|lch|oklab|oklch|color\(|light-dark\()/i.test(String(v).trim()) || /^[a-z]+$/i.test(String(v).trim());
|
|
577
|
+
const valueFor = (token) => {
|
|
578
|
+
const l = light.declared.get(token);
|
|
579
|
+
const d = darkSide ? darkSide.declared.get(token) : undefined;
|
|
580
|
+
if (l == null && d == null) return undefined;
|
|
581
|
+
if (d != null && l != null && d !== l && (isColorish(l) || isColorish(d))) return `light-dark(${l}, ${d})`;
|
|
582
|
+
if (l == null) return darkMode ? `light-dark(${d}, ${d})` : d;
|
|
583
|
+
return l;
|
|
584
|
+
};
|
|
585
|
+
|
|
586
|
+
const lines = [];
|
|
587
|
+
const fromTokens = [];
|
|
588
|
+
const inherited = [];
|
|
589
|
+
const optionalDeclared = [];
|
|
590
|
+
|
|
591
|
+
for (const token of contract.required) {
|
|
592
|
+
const v = valueFor(token);
|
|
593
|
+
if (v !== undefined) { lines.push([token, v]); fromTokens.push(token); continue; }
|
|
594
|
+
const b = base.values.get(token);
|
|
595
|
+
if (b === undefined) throw new Error(`base theme "${base.name}" does not declare required token ${token}; refusing to emit an incomplete theme`);
|
|
596
|
+
lines.push([token, b]);
|
|
597
|
+
inherited.push(token);
|
|
598
|
+
}
|
|
599
|
+
for (const token of contract.optional) {
|
|
600
|
+
const v = valueFor(token);
|
|
601
|
+
if (v !== undefined) { lines.push([token, v]); fromTokens.push(token); optionalDeclared.push(token); }
|
|
602
|
+
}
|
|
603
|
+
const seen = new Set(lines.map(([t]) => t));
|
|
604
|
+
const extras = [];
|
|
605
|
+
for (const [token, v] of light.declared) if (!seen.has(token)) { extras.push([token, valueFor(token)]); seen.add(token); }
|
|
606
|
+
if (darkSide) for (const [token] of darkSide.declared) if (!seen.has(token)) { extras.push([token, valueFor(token)]); seen.add(token); }
|
|
607
|
+
extras.sort((a, b) => a[0].localeCompare(b[0]));
|
|
608
|
+
|
|
609
|
+
const scheme = darkMode ? 'light dark' : mode;
|
|
610
|
+
const header =
|
|
611
|
+
`/* css-is-awesome theme "${name}" — generated by tokens-to-theme ${GENERATOR_VERSION}\n` +
|
|
612
|
+
` source: ${format}${darkSide ? ` (+ dark side${pairedGroups ? `, paired groups ${pairedGroups.join('/')}` : ''})` : ''} · base: ${base.name} · contract ${contract.version}\n` +
|
|
613
|
+
` ${fromTokens.length} token(s) from the design file, ${inherited.length} inherited from the base, ${extras.length} passed through verbatim */\n`;
|
|
614
|
+
const body =
|
|
615
|
+
`:root, :root[data-theme="${name}"] {\n` +
|
|
616
|
+
` color-scheme: ${scheme};\n` +
|
|
617
|
+
[...lines, ...extras].map(([t, v]) => ` ${t}: ${v};`).join('\n') +
|
|
618
|
+
`\n}\n`;
|
|
619
|
+
const css = header + body;
|
|
620
|
+
|
|
621
|
+
const optionalMissing = contract.optional.filter((t) => !seen.has(t));
|
|
622
|
+
const report = {
|
|
623
|
+
name,
|
|
624
|
+
format,
|
|
625
|
+
base: base.name,
|
|
626
|
+
darkMode,
|
|
627
|
+
pairedGroups,
|
|
628
|
+
fromTokens,
|
|
629
|
+
inherited,
|
|
630
|
+
unmapped: [...light.unmapped, ...(darkSide ? darkSide.unmapped.filter((u) => !light.unmapped.some((x) => x.emittedAs === u.emittedAs)) : [])],
|
|
631
|
+
skipped: [...light.skipped, ...(darkSide ? darkSide.skipped : [])],
|
|
632
|
+
optionalDeclared,
|
|
633
|
+
optionalMissing,
|
|
634
|
+
counts: { fromTokens: fromTokens.length, inherited: inherited.length, unmapped: light.unmapped.length, skipped: light.skipped.length },
|
|
635
|
+
};
|
|
636
|
+
|
|
637
|
+
let validation = null;
|
|
638
|
+
if (opts.validate !== false) {
|
|
639
|
+
const validator = require(path.join(ciaRoot, 'scripts', 'theme-validator.js'));
|
|
640
|
+
validation = validator.validateText(css, validator.loadContract(), { label: name });
|
|
641
|
+
validation.a11ySummary = summarizeA11y(validation);
|
|
642
|
+
}
|
|
643
|
+
return { css, report, validation };
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function summarizeA11y(validation) {
|
|
647
|
+
const audits = validation.mode === 'consolidated'
|
|
648
|
+
? (validation.themes || []).flatMap((t) => t.a11y || [])
|
|
649
|
+
: validation.a11y || [];
|
|
650
|
+
const s = { fail: 0, warn: 0, pass: 0, info: 0, skip: 0 };
|
|
651
|
+
for (const r of audits) if (s[r.status] != null) s[r.status]++;
|
|
652
|
+
return s;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
module.exports = {
|
|
656
|
+
themeFromTokens,
|
|
657
|
+
detectFormat,
|
|
658
|
+
flattenDtcg,
|
|
659
|
+
flattenTokensStudio,
|
|
660
|
+
mergeTokensStudioSets,
|
|
661
|
+
resolveAliases,
|
|
662
|
+
normalizeValue,
|
|
663
|
+
mapPath,
|
|
664
|
+
listBases,
|
|
665
|
+
TOKEN_MAP,
|
|
666
|
+
PATH_ALIASES,
|
|
667
|
+
GENERATOR_VERSION,
|
|
668
|
+
};
|