@1agh/maude 0.43.0 → 0.45.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/apps/studio/acp/bridge.ts +44 -10
- package/apps/studio/acp/env.ts +23 -0
- package/apps/studio/acp/login-state.ts +263 -0
- package/apps/studio/acp/plugin-bootstrap.ts +31 -25
- package/apps/studio/acp/probe.ts +152 -1
- package/apps/studio/annotations-layer.tsx +396 -46
- package/apps/studio/api.ts +395 -2
- package/apps/studio/bin/_audio-search.mjs +140 -0
- package/apps/studio/bin/_import-asset-pdf-worker.mjs +34 -0
- package/apps/studio/bin/_import-asset.mjs +815 -0
- package/apps/studio/bin/_import-brand.mjs +635 -0
- package/apps/studio/bin/_import-tokens-alias-resolver.mjs +95 -0
- package/apps/studio/bin/_import-tokens.mjs +1201 -0
- package/apps/studio/bin/_transcribe.mjs +419 -0
- package/apps/studio/bin/_transcribe.test.mjs +80 -0
- package/apps/studio/bin/audio-search.sh +28 -0
- package/apps/studio/bin/generate.sh +154 -0
- package/apps/studio/bin/import-asset.sh +34 -0
- package/apps/studio/bin/import-brand.sh +29 -0
- package/apps/studio/bin/import-tokens.sh +33 -0
- package/apps/studio/bin/photo-bg-remove.sh +119 -21
- package/apps/studio/bin/transcribe.sh +39 -0
- package/apps/studio/canvas-edit.ts +197 -2
- package/apps/studio/canvas-lib.tsx +115 -6
- package/apps/studio/client/app.jsx +1954 -685
- package/apps/studio/client/generate-dialog.jsx +352 -0
- package/apps/studio/client/github.js +14 -0
- package/apps/studio/client/inspector-controls.jsx +781 -0
- package/apps/studio/client/panels/BrandUploadPanel.jsx +233 -0
- package/apps/studio/client/panels/ChatPanel.jsx +15 -33
- package/apps/studio/client/panels/IntroVideoDialog.jsx +44 -0
- package/apps/studio/client/panels/OnboardingWizard.jsx +12 -0
- package/apps/studio/client/panels/ReadinessList.jsx +227 -7
- package/apps/studio/client/panels/SettingsPanel.jsx +828 -0
- package/apps/studio/client/panels/SetupChecklist.jsx +223 -0
- package/apps/studio/client/photo-knobs.jsx +167 -172
- package/apps/studio/client/styles/3-shell-maude.css +334 -28
- package/apps/studio/client/styles/4-components.css +223 -0
- package/apps/studio/client/styles/6-acp-chat.css +86 -0
- package/apps/studio/client/tour/quick-setup-tour.js +39 -0
- package/apps/studio/commands/annotation-strokes-command.ts +13 -3
- package/apps/studio/config.schema.json +70 -0
- package/apps/studio/context-menu.tsx +11 -2
- package/apps/studio/context.ts +16 -0
- package/apps/studio/design-setup-readiness.ts +115 -0
- package/apps/studio/dist/client.bundle.js +2097 -2088
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/draw/palette.ts +34 -0
- package/apps/studio/footage/schema.test.ts +92 -0
- package/apps/studio/footage/schema.ts +179 -1
- package/apps/studio/generation/adapters/elevenlabs.ts +370 -0
- package/apps/studio/generation/adapters/gemini.ts +542 -0
- package/apps/studio/generation/adapters/groq.test.ts +80 -0
- package/apps/studio/generation/adapters/groq.ts +247 -0
- package/apps/studio/generation/audio-library.test.ts +95 -0
- package/apps/studio/generation/audio-library.ts +156 -0
- package/apps/studio/generation/captions.test.ts +78 -0
- package/apps/studio/generation/captions.ts +132 -0
- package/apps/studio/generation/download.ts +76 -0
- package/apps/studio/generation/elevenlabs.test.ts +223 -0
- package/apps/studio/generation/gemini.test.ts +350 -0
- package/apps/studio/generation/jobs.test.ts +128 -0
- package/apps/studio/generation/jobs.ts +243 -0
- package/apps/studio/generation/keys.test.ts +75 -0
- package/apps/studio/generation/keys.ts +162 -0
- package/apps/studio/generation/prefs.test.ts +95 -0
- package/apps/studio/generation/prefs.ts +82 -0
- package/apps/studio/generation/registry.ts +88 -0
- package/apps/studio/generation/types.test.ts +123 -0
- package/apps/studio/generation/types.ts +266 -0
- package/apps/studio/generation/whisper-models.test.ts +152 -0
- package/apps/studio/generation/whisper-models.ts +257 -0
- package/apps/studio/generation/whisper-spike-results.md +61 -0
- package/apps/studio/git/service.ts +3 -1
- package/apps/studio/http.ts +867 -9
- package/apps/studio/media/intro.mp4 +0 -0
- package/apps/studio/media-commit-chain.ts +119 -0
- package/apps/studio/paths.ts +10 -0
- package/apps/studio/readiness.ts +108 -20
- package/apps/studio/scaffold-design.ts +1145 -0
- package/apps/studio/server.ts +13 -1
- package/apps/studio/test/_helpers.ts +15 -2
- package/apps/studio/test/acp-bridge.test.ts +34 -1
- package/apps/studio/test/acp-env.test.ts +30 -0
- package/apps/studio/test/acp-plugin-bootstrap.test.ts +23 -57
- package/apps/studio/test/acp-session-plugins.test.ts +72 -1
- package/apps/studio/test/annotation-strokes-command.test.ts +17 -0
- package/apps/studio/test/annotations-layer.test.ts +174 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +37 -0
- package/apps/studio/test/csp-canvas-shell.test.ts +11 -0
- package/apps/studio/test/csrf-write-guard.test.ts +23 -1
- package/apps/studio/test/design-setup-readiness.test.ts +155 -0
- package/apps/studio/test/element-structural-edit.test.ts +108 -0
- package/apps/studio/test/fixtures/fake-claude-auth.mjs +13 -0
- package/apps/studio/test/generate-route.test.ts +106 -0
- package/apps/studio/test/import-asset-browser.test.ts +64 -0
- package/apps/studio/test/import-asset-route.test.ts +71 -0
- package/apps/studio/test/import-asset.test.ts +399 -0
- package/apps/studio/test/import-brand-browser.test.ts +88 -0
- package/apps/studio/test/import-brand-route.test.ts +120 -0
- package/apps/studio/test/import-brand.test.ts +206 -0
- package/apps/studio/test/import-tokens.test.ts +722 -0
- package/apps/studio/test/media-commit-chain.test.ts +210 -0
- package/apps/studio/test/photo-bg-remove-validation.test.ts +71 -0
- package/apps/studio/test/readiness.test.ts +13 -11
- package/apps/studio/test/scaffold-design.test.ts +122 -0
- package/apps/studio/test/tour-overlay.test.tsx +16 -0
- package/apps/studio/tool-palette.tsx +11 -6
- package/apps/studio/ui-prefs.ts +130 -0
- package/apps/studio/use-canvas-media-drop.tsx +4 -2
- package/apps/studio/use-chrome-visibility.tsx +8 -2
- package/apps/studio/whats-new.json +96 -0
- package/apps/studio/ws.ts +5 -0
- package/cli/bin/maude.mjs +6 -6
- package/cli/commands/design.mjs +33 -0
- package/cli/commands/init.mjs +7 -1
- package/cli/lib/gitignore-block.mjs +1 -0
- package/cli/lib/pkg-root.mjs +107 -0
- package/cli/lib/pkg-root.test.mjs +79 -0
- package/cli/lib/reconstruct-toolset.test.mjs +194 -0
- package/cli/lib/update-check.mjs +8 -0
- package/package.json +12 -10
- package/plugins/design/dependencies.json +18 -0
- package/plugins/flow/.claude-plugin/config.schema.json +5 -0
- package/plugins/flow/templates/ai-skeleton/scenario-guide.md +39 -0
- package/plugins/flow/templates/ai-skeleton/scenarios/README.md +2 -0
|
@@ -0,0 +1,1201 @@
|
|
|
1
|
+
// _import-tokens.mjs — token-file import: W3C design-tokens / Style-Dictionary
|
|
2
|
+
// JSON / raw CSS custom-properties → Maude's DS CSS-variable contract.
|
|
3
|
+
// Reached via `maude design import-tokens` (DDR-062), never a raw bin path.
|
|
4
|
+
//
|
|
5
|
+
// See DDR-172 for the full mapping contract + security posture. Every
|
|
6
|
+
// numbered "Decision N" comment below points at the exact DDR clause it
|
|
7
|
+
// implements. CLI-only (no HTTP route, no in-app panel — DDR-172 Instruments).
|
|
8
|
+
|
|
9
|
+
import { createHash } from 'node:crypto';
|
|
10
|
+
import {
|
|
11
|
+
existsSync,
|
|
12
|
+
lstatSync,
|
|
13
|
+
mkdirSync,
|
|
14
|
+
readFileSync,
|
|
15
|
+
realpathSync,
|
|
16
|
+
renameSync,
|
|
17
|
+
writeFileSync,
|
|
18
|
+
} from 'node:fs';
|
|
19
|
+
import { join, resolve, sep } from 'node:path';
|
|
20
|
+
import { pathToFileURL } from 'node:url';
|
|
21
|
+
import { oklchToHex, parseColor, parseOklch, rgbToOklch } from '../draw/palette.ts';
|
|
22
|
+
import { DESIGN_PLUGIN_DIR } from '../paths.ts';
|
|
23
|
+
import { resolveAliases } from './_import-tokens-alias-resolver.mjs';
|
|
24
|
+
|
|
25
|
+
export class ImportTokensError extends Error {
|
|
26
|
+
constructor(code, message) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.code = code;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Exit codes: 0 ok · 2 usage · 3 validation/mapping reject · 4 read/parse
|
|
33
|
+
// error · 5 unsupported format · 6 write/containment error · 1 other.
|
|
34
|
+
|
|
35
|
+
// ============================================================================
|
|
36
|
+
// Decision 1 — input read (mirrors DDR-167 Decision 2 step 1: realpath-then-
|
|
37
|
+
// open, symlink-component rejection, single capped read) + format-appropriate
|
|
38
|
+
// parsers (native JSON.parse; a bespoke tokenizer for raw CSS — NOT
|
|
39
|
+
// lightningcss, see the DDR's Round-1 revision, and NOT a regex).
|
|
40
|
+
// ============================================================================
|
|
41
|
+
|
|
42
|
+
export const TOKENS_MAX_BYTES = 2 * 1024 * 1024;
|
|
43
|
+
|
|
44
|
+
/** Decision 1 — realpath-then-open, symlink-component rejection, single capped read. */
|
|
45
|
+
export function readTokenFileCapped(inputPath, maxBytes = TOKENS_MAX_BYTES) {
|
|
46
|
+
// Reject when the LEAF (the file itself, pre-resolution) is a symlink —
|
|
47
|
+
// the classic check-then-read substitution window: the file getting
|
|
48
|
+
// swapped out from under this read between validation and open. Checked
|
|
49
|
+
// against the ORIGINAL path (never the realpath-resolved one, which by
|
|
50
|
+
// definition can no longer show as a symlink). Deliberately NOT walking
|
|
51
|
+
// every ANCESTOR directory component: on macOS, `/tmp`/`/var` are
|
|
52
|
+
// themselves OS-level symlinks (`-> /private/tmp`/`-> /private/var`), so a
|
|
53
|
+
// whole-chain check rejects essentially every legitimate local path and
|
|
54
|
+
// isn't the threat this closes anyway — an attacker who can plant a
|
|
55
|
+
// symlink in a PARENT directory the operator already trusts and navigated
|
|
56
|
+
// into has an easier path to the same data than racing this read (the
|
|
57
|
+
// same residual DDR-167 Decision 2 step 1 already accepts explicitly).
|
|
58
|
+
const absOriginal = resolve(inputPath);
|
|
59
|
+
let leafStat;
|
|
60
|
+
try {
|
|
61
|
+
leafStat = lstatSync(absOriginal);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
throw new ImportTokensError(4, `could not resolve path: ${err?.message ?? err}`);
|
|
64
|
+
}
|
|
65
|
+
if (leafStat.isSymbolicLink()) {
|
|
66
|
+
throw new ImportTokensError(3, `refusing a path whose target is a symlink: ${absOriginal}`);
|
|
67
|
+
}
|
|
68
|
+
let real;
|
|
69
|
+
try {
|
|
70
|
+
real = realpathSync(inputPath);
|
|
71
|
+
} catch (err) {
|
|
72
|
+
throw new ImportTokensError(4, `could not resolve path: ${err?.message ?? err}`);
|
|
73
|
+
}
|
|
74
|
+
let buf;
|
|
75
|
+
try {
|
|
76
|
+
buf = readFileSync(real);
|
|
77
|
+
} catch (err) {
|
|
78
|
+
throw new ImportTokensError(4, `could not read file: ${err?.message ?? err}`);
|
|
79
|
+
}
|
|
80
|
+
if (buf.length === 0) throw new ImportTokensError(4, 'file is empty');
|
|
81
|
+
if (buf.length > maxBytes) {
|
|
82
|
+
throw new ImportTokensError(
|
|
83
|
+
4,
|
|
84
|
+
`token file exceeds the ${Math.round(maxBytes / (1024 * 1024))} MB cap`
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
let text;
|
|
88
|
+
try {
|
|
89
|
+
text = buf.toString('utf8');
|
|
90
|
+
} catch (err) {
|
|
91
|
+
throw new ImportTokensError(4, `could not decode as UTF-8: ${err?.message ?? err}`);
|
|
92
|
+
}
|
|
93
|
+
return text;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---- Bespoke CSS structural scanner (comment/string-aware, no regex-over-
|
|
97
|
+
// adversarial-text) — shared by Decision 1's raw-CSS input tokenizer AND
|
|
98
|
+
// Decision 7's theme-block locator on the trusted OUTPUT file. ----
|
|
99
|
+
|
|
100
|
+
/** Walk `text`, returning every `{...}` rule as `{selector, bodyStart, bodyEnd, nestedInAtRule}`. */
|
|
101
|
+
function scanCssRules(text) {
|
|
102
|
+
const rules = [];
|
|
103
|
+
const n = text.length;
|
|
104
|
+
let i = 0;
|
|
105
|
+
let selectorStart = 0;
|
|
106
|
+
const stack = [];
|
|
107
|
+
while (i < n) {
|
|
108
|
+
const ch = text[i];
|
|
109
|
+
if (ch === '/' && text[i + 1] === '*') {
|
|
110
|
+
const end = text.indexOf('*/', i + 2);
|
|
111
|
+
i = end === -1 ? n : end + 2;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (ch === '"' || ch === "'") {
|
|
115
|
+
const quote = ch;
|
|
116
|
+
i += 1;
|
|
117
|
+
while (i < n) {
|
|
118
|
+
if (text[i] === '\\') {
|
|
119
|
+
i += 2;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (text[i] === quote) {
|
|
123
|
+
i += 1;
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
i += 1;
|
|
127
|
+
}
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (ch === '{') {
|
|
131
|
+
const selector = text.slice(selectorStart, i);
|
|
132
|
+
const parentAtRule = stack.length ? stack[stack.length - 1].nestedInAtRule : false;
|
|
133
|
+
const isAtRule = /^\s*@/.test(selector);
|
|
134
|
+
stack.push({ selector, bodyStart: i + 1, nestedInAtRule: parentAtRule || isAtRule });
|
|
135
|
+
i += 1;
|
|
136
|
+
selectorStart = i;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (ch === '}') {
|
|
140
|
+
const top = stack.pop();
|
|
141
|
+
if (top) {
|
|
142
|
+
rules.push({
|
|
143
|
+
selector: top.selector,
|
|
144
|
+
bodyStart: top.bodyStart,
|
|
145
|
+
bodyEnd: i,
|
|
146
|
+
nestedInAtRule: top.nestedInAtRule,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
i += 1;
|
|
150
|
+
selectorStart = i;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
i += 1;
|
|
154
|
+
}
|
|
155
|
+
return rules;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Extract `--name: value;` declarations from a rule body, comment/string-aware. `offset` shifts returned positions into the ORIGINAL text's coordinate space. */
|
|
159
|
+
function extractDeclarations(bodyText, offset = 0) {
|
|
160
|
+
const out = [];
|
|
161
|
+
const n = bodyText.length;
|
|
162
|
+
let i = 0;
|
|
163
|
+
let declStart = 0;
|
|
164
|
+
while (i < n) {
|
|
165
|
+
const ch = bodyText[i];
|
|
166
|
+
if (ch === '/' && bodyText[i + 1] === '*') {
|
|
167
|
+
const end = bodyText.indexOf('*/', i + 2);
|
|
168
|
+
i = end === -1 ? n : end + 2;
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (ch === '"' || ch === "'") {
|
|
172
|
+
const quote = ch;
|
|
173
|
+
i += 1;
|
|
174
|
+
while (i < n) {
|
|
175
|
+
if (bodyText[i] === '\\') {
|
|
176
|
+
i += 2;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (bodyText[i] === quote) {
|
|
180
|
+
i += 1;
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
i += 1;
|
|
184
|
+
}
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (ch === ';') {
|
|
188
|
+
const decl = bodyText.slice(declStart, i);
|
|
189
|
+
// A trailing `/* ... */` comment from the PREVIOUS declaration (or a
|
|
190
|
+
// section-header comment) commonly lands at the START of this slice —
|
|
191
|
+
// tolerate any number of interleaved whitespace/comment spans before
|
|
192
|
+
// the actual `--name:`, not just leading whitespace.
|
|
193
|
+
const m = /^(?:\s|\/\*[\s\S]*?\*\/)*(--[A-Za-z0-9-]+)\s*:/.exec(decl);
|
|
194
|
+
if (m) {
|
|
195
|
+
const afterColon = decl.slice(m[0].length);
|
|
196
|
+
const leadingWs = afterColon.length - afterColon.trimStart().length;
|
|
197
|
+
const trailingWs = afterColon.length - afterColon.trimEnd().length;
|
|
198
|
+
const valueStart = offset + declStart + m[0].length + leadingWs;
|
|
199
|
+
const valueEnd = offset + declStart + decl.length - trailingWs;
|
|
200
|
+
out.push({
|
|
201
|
+
name: m[1],
|
|
202
|
+
rawValue: afterColon.trim(),
|
|
203
|
+
declStart: offset + declStart,
|
|
204
|
+
declEnd: offset + i + 1,
|
|
205
|
+
valueStart,
|
|
206
|
+
valueEnd,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
declStart = i + 1;
|
|
210
|
+
i += 1;
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
i += 1;
|
|
214
|
+
}
|
|
215
|
+
return out;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Decision 1 — raw-CSS-custom-properties input tokenizer. Extracts every
|
|
220
|
+
* `--name: value;` declaration from every rule body in the file (regardless
|
|
221
|
+
* of nesting — the INPUT side has no reason to exclude @media-nested
|
|
222
|
+
* declarations; that exclusion is specific to Decision 7's locator on
|
|
223
|
+
* Maude's own OUTPUT file, to avoid matching a duplicate selector there).
|
|
224
|
+
*/
|
|
225
|
+
export function tokenizeCssCustomProperties(text) {
|
|
226
|
+
const tokens = Object.create(null);
|
|
227
|
+
for (const rule of scanCssRules(text)) {
|
|
228
|
+
for (const decl of extractDeclarations(
|
|
229
|
+
text.slice(rule.bodyStart, rule.bodyEnd),
|
|
230
|
+
rule.bodyStart
|
|
231
|
+
)) {
|
|
232
|
+
tokens[decl.name] = decl.rawValue;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return { tokens, types: Object.create(null) };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Decision 1 — parse the input as JSON (W3C design-tokens / Style-Dictionary)
|
|
240
|
+
* or, on JSON failure, as raw CSS custom properties. A failure on BOTH is a
|
|
241
|
+
* hard reject. Error messages report structural position only, never a
|
|
242
|
+
* verbatim excerpt of the input content.
|
|
243
|
+
*/
|
|
244
|
+
export function parseTokenFile(text) {
|
|
245
|
+
const trimmed = text.trimStart();
|
|
246
|
+
if (trimmed.startsWith('{')) {
|
|
247
|
+
let obj;
|
|
248
|
+
try {
|
|
249
|
+
obj = JSON.parse(text);
|
|
250
|
+
} catch (err) {
|
|
251
|
+
const pos = /position (\d+)/.exec(String(err?.message ?? ''))?.[1];
|
|
252
|
+
throw new ImportTokensError(
|
|
253
|
+
4,
|
|
254
|
+
`could not parse as JSON (design-tokens/Style-Dictionary)${pos ? ` — malformed at byte offset ${pos}` : ''}`
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
|
|
258
|
+
throw new ImportTokensError(4, 'could not parse as JSON — expected a top-level object');
|
|
259
|
+
}
|
|
260
|
+
return { format: 'json', root: obj };
|
|
261
|
+
}
|
|
262
|
+
// Raw CSS: parse-failure here just means "no declarations found" (the
|
|
263
|
+
// tokenizer never throws on malformed input — it best-effort extracts what
|
|
264
|
+
// it can scan safely) — an empty result is itself the "could not parse"
|
|
265
|
+
// signal the caller checks for.
|
|
266
|
+
const { tokens } = tokenizeCssCustomProperties(text);
|
|
267
|
+
if (Object.keys(tokens).length === 0) {
|
|
268
|
+
throw new ImportTokensError(
|
|
269
|
+
4,
|
|
270
|
+
'could not parse as JSON or as CSS custom properties — no recognized token declarations found'
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
return { format: 'css', tokens };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ============================================================================
|
|
277
|
+
// Decision 3 — prototype-pollution-safe, structurally depth-capped flatten of
|
|
278
|
+
// the parsed JSON into {path -> rawValue} + {path -> detectedType}.
|
|
279
|
+
// ============================================================================
|
|
280
|
+
|
|
281
|
+
const BANNED_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
282
|
+
const STRUCTURE_MAX_DEPTH = 32;
|
|
283
|
+
|
|
284
|
+
function isLeafToken(node) {
|
|
285
|
+
return (
|
|
286
|
+
node &&
|
|
287
|
+
typeof node === 'object' &&
|
|
288
|
+
!Array.isArray(node) &&
|
|
289
|
+
('$value' in node || 'value' in node)
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Decision 3 — flatten W3C/Style-Dictionary JSON into path->rawValue string maps. */
|
|
294
|
+
export function flattenJsonTokens(root, { maxDepth = STRUCTURE_MAX_DEPTH } = {}) {
|
|
295
|
+
const tokens = Object.create(null);
|
|
296
|
+
const types = Object.create(null);
|
|
297
|
+
|
|
298
|
+
function walk(node, pathParts, depth, inheritedType) {
|
|
299
|
+
if (depth > maxDepth) {
|
|
300
|
+
throw new ImportTokensError(3, 'structure-too-deep (nesting exceeds the 32-level cap)');
|
|
301
|
+
}
|
|
302
|
+
if (node === null || typeof node !== 'object') return;
|
|
303
|
+
if (Array.isArray(node)) {
|
|
304
|
+
for (let i = 0; i < node.length; i += 1) {
|
|
305
|
+
walk(node[i], [...pathParts, String(i)], depth + 1, inheritedType);
|
|
306
|
+
}
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
if (isLeafToken(node)) {
|
|
310
|
+
const raw = '$value' in node ? node.$value : node.value;
|
|
311
|
+
const type =
|
|
312
|
+
typeof node.$type === 'string'
|
|
313
|
+
? node.$type
|
|
314
|
+
: typeof node.type === 'string'
|
|
315
|
+
? node.type
|
|
316
|
+
: inheritedType;
|
|
317
|
+
if (typeof raw === 'string') {
|
|
318
|
+
const path = pathParts.join('.');
|
|
319
|
+
tokens[path] = raw;
|
|
320
|
+
if (type) types[path] = type;
|
|
321
|
+
}
|
|
322
|
+
// Non-string leaf $value (composite shadow/typography/border objects)
|
|
323
|
+
// is intentionally left unflattened — Decision 5: unmapped, not guessed.
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
const groupType = typeof node.$type === 'string' ? node.$type : inheritedType;
|
|
327
|
+
for (const key of Object.keys(node)) {
|
|
328
|
+
if (BANNED_KEYS.has(key)) continue;
|
|
329
|
+
if (key.startsWith('$')) continue; // $description/$extensions/etc — metadata, never consumed (Decision 8)
|
|
330
|
+
walk(node[key], [...pathParts, key], depth + 1, groupType);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
walk(root, [], 0, undefined);
|
|
335
|
+
return { tokens, types };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ============================================================================
|
|
339
|
+
// Decision 4 — value-grammar allowlist (the CSS-injection closure).
|
|
340
|
+
// Every grammar: printable-ASCII-only, no unbounded free-text capture, a
|
|
341
|
+
// literal space (never `\s`), a magnitude bound paired with every numeric
|
|
342
|
+
// grammar. See DDR-172 Decision 4 for the full Round-1/Round-2 history.
|
|
343
|
+
// ============================================================================
|
|
344
|
+
|
|
345
|
+
const ASCII_ONLY_RE = /^[\x20-\x7E]*$/;
|
|
346
|
+
|
|
347
|
+
const HEX_RES = [
|
|
348
|
+
/^#[0-9a-fA-F]{3}$/,
|
|
349
|
+
/^#[0-9a-fA-F]{4}$/,
|
|
350
|
+
/^#[0-9a-fA-F]{6}$/,
|
|
351
|
+
/^#[0-9a-fA-F]{8}$/,
|
|
352
|
+
];
|
|
353
|
+
const OKLCH_RE = /^oklch\([\d.]+%? [\d.]+ [\d.]+( ?\/ ?[\d.]+%?)?\)$/;
|
|
354
|
+
const RGB_RE = /^rgba?\( ?[\d.]+[ ,]+[\d.]+[ ,]+[\d.]+( ?[,/] ?[\d.]+%?)? ?\)$/;
|
|
355
|
+
const HSL_RE = /^hsla?\( ?[\d.]+ [\d.]+% [\d.]+%( ?\/ ?[\d.]+%?)?\)$/;
|
|
356
|
+
|
|
357
|
+
/** DDR-172 Decision 4's color-function grammar. Exported for reuse by DDR-173 Decision 1/6 (T12's brand extraction). */
|
|
358
|
+
export function isValidColorValue(v) {
|
|
359
|
+
return HEX_RES.some((re) => re.test(v)) || OKLCH_RE.test(v) || RGB_RE.test(v) || HSL_RE.test(v);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const DIMENSION_RE = /^-?[\d.]{1,6}(px|rem|em|%)$/;
|
|
363
|
+
const DIMENSION_BOUNDS = { space: 4096, radius: 512, type: 512, lh: 512 };
|
|
364
|
+
function isValidDimension(v, family) {
|
|
365
|
+
if (v === '0') return true;
|
|
366
|
+
if (!DIMENSION_RE.test(v)) return false;
|
|
367
|
+
const n = Math.abs(Number.parseFloat(v));
|
|
368
|
+
const max = DIMENSION_BOUNDS[family] ?? 512;
|
|
369
|
+
return Number.isFinite(n) && n <= max;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const DURATION_RE = /^[\d.]{1,6}m?s$/;
|
|
373
|
+
function isValidDuration(v) {
|
|
374
|
+
if (!DURATION_RE.test(v)) return false;
|
|
375
|
+
const m = /^([\d.]{1,6})(m?s)$/.exec(v);
|
|
376
|
+
const n = Number.parseFloat(m[1]);
|
|
377
|
+
const ms = m[2] === 'ms' ? n : n * 1000;
|
|
378
|
+
return Number.isFinite(ms) && ms >= 0 && ms <= 60000;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const EASING_KEYWORDS = new Set(['linear', 'ease', 'ease-in', 'ease-out', 'ease-in-out']);
|
|
382
|
+
const CUBIC_BEZIER_RE = /^cubic-bezier\( ?-?[\d.]+ ?, ?-?[\d.]+ ?, ?-?[\d.]+ ?, ?-?[\d.]+ ?\)$/;
|
|
383
|
+
function isValidEasing(v) {
|
|
384
|
+
return EASING_KEYWORDS.has(v) || CUBIC_BEZIER_RE.test(v);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const FONT_GENERIC = new Set([
|
|
388
|
+
'serif',
|
|
389
|
+
'sans-serif',
|
|
390
|
+
'monospace',
|
|
391
|
+
'system-ui',
|
|
392
|
+
'ui-monospace',
|
|
393
|
+
'ui-sans-serif',
|
|
394
|
+
'ui-serif',
|
|
395
|
+
'cursive',
|
|
396
|
+
'fantasy',
|
|
397
|
+
]);
|
|
398
|
+
const FONT_QUOTED_RE = /^"[A-Za-z0-9 _-]{1,64}"$/;
|
|
399
|
+
function isValidFontStack(v) {
|
|
400
|
+
const parts = v.split(',').map((s) => s.trim());
|
|
401
|
+
return parts.length > 0 && parts.every((p) => FONT_GENERIC.has(p) || FONT_QUOTED_RE.test(p));
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const SHADOW_RGBA = 'rgba?\\( ?[\\d.]+[ ,]+[\\d.]+[ ,]+[\\d.]+( ?[,/] ?[\\d.]+%?)? ?\\)';
|
|
405
|
+
// Each length is either a bare `0` (valid CSS shorthand — no unit needed) or
|
|
406
|
+
// a strict `px` number — matches the real maude DS's own shadow shape
|
|
407
|
+
// (`0 1px 2px rgba(...)`), never a free-text/unitless-non-zero capture.
|
|
408
|
+
const SHADOW_LENGTH = '(?:0|-?[\\d.]{1,5}px)';
|
|
409
|
+
const SHADOW_LAYER = `( ?${SHADOW_LENGTH} ){2,4}${SHADOW_RGBA}`;
|
|
410
|
+
const SHADOW_RE = new RegExp(`^${SHADOW_LAYER}( ?, ?${SHADOW_LAYER})*$`);
|
|
411
|
+
function isValidShadow(v) {
|
|
412
|
+
return SHADOW_RE.test(v);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function familyOfVariable(varName) {
|
|
416
|
+
return varName.replace(/^--/, '').split('-')[0];
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** Decision 4 — validate `value` against the grammar for `varName`'s family. */
|
|
420
|
+
export function validateTokenValue(varName, value) {
|
|
421
|
+
if (typeof value !== 'string' || !ASCII_ONLY_RE.test(value)) {
|
|
422
|
+
return { ok: false, reason: 'invalid-value-shape (non-ASCII or non-string)' };
|
|
423
|
+
}
|
|
424
|
+
const family = familyOfVariable(varName);
|
|
425
|
+
if (['bg', 'fg', 'border', 'accent', 'status'].includes(family)) {
|
|
426
|
+
return isValidColorValue(value)
|
|
427
|
+
? { ok: true }
|
|
428
|
+
: { ok: false, reason: 'invalid-value-shape (color)' };
|
|
429
|
+
}
|
|
430
|
+
if (['space', 'radius', 'type', 'lh'].includes(family)) {
|
|
431
|
+
return isValidDimension(value, family)
|
|
432
|
+
? { ok: true }
|
|
433
|
+
: { ok: false, reason: 'invalid-value-shape (dimension/magnitude)' };
|
|
434
|
+
}
|
|
435
|
+
if (family === 'dur') {
|
|
436
|
+
return isValidDuration(value)
|
|
437
|
+
? { ok: true }
|
|
438
|
+
: { ok: false, reason: 'invalid-value-shape (duration)' };
|
|
439
|
+
}
|
|
440
|
+
if (family === 'ease') {
|
|
441
|
+
return isValidEasing(value)
|
|
442
|
+
? { ok: true }
|
|
443
|
+
: { ok: false, reason: 'invalid-value-shape (easing)' };
|
|
444
|
+
}
|
|
445
|
+
if (family === 'font') {
|
|
446
|
+
return isValidFontStack(value)
|
|
447
|
+
? { ok: true }
|
|
448
|
+
: { ok: false, reason: 'invalid-value-shape (font stack)' };
|
|
449
|
+
}
|
|
450
|
+
if (family === 'shadow') {
|
|
451
|
+
return isValidShadow(value)
|
|
452
|
+
? { ok: true }
|
|
453
|
+
: { ok: false, reason: 'invalid-value-shape (shadow)' };
|
|
454
|
+
}
|
|
455
|
+
return { ok: false, reason: `ungoverned family: ${family}` };
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// ============================================================================
|
|
459
|
+
// Decision 5 — heuristic name-to-variable mapping, never silent.
|
|
460
|
+
// ============================================================================
|
|
461
|
+
|
|
462
|
+
export const KNOWN_VARIABLES = new Set([
|
|
463
|
+
'--bg-0',
|
|
464
|
+
'--bg-1',
|
|
465
|
+
'--bg-2',
|
|
466
|
+
'--bg-3',
|
|
467
|
+
'--bg-4',
|
|
468
|
+
'--fg-0',
|
|
469
|
+
'--fg-1',
|
|
470
|
+
'--fg-2',
|
|
471
|
+
'--fg-3',
|
|
472
|
+
'--border-subtle',
|
|
473
|
+
'--border-default',
|
|
474
|
+
'--border-strong',
|
|
475
|
+
'--accent',
|
|
476
|
+
'--accent-hover',
|
|
477
|
+
'--accent-active',
|
|
478
|
+
'--accent-fg',
|
|
479
|
+
'--accent-muted',
|
|
480
|
+
'--accent-tint',
|
|
481
|
+
'--status-success',
|
|
482
|
+
'--status-warn',
|
|
483
|
+
'--status-error',
|
|
484
|
+
'--status-info',
|
|
485
|
+
'--space-0',
|
|
486
|
+
'--space-1',
|
|
487
|
+
'--space-2',
|
|
488
|
+
'--space-3',
|
|
489
|
+
'--space-4',
|
|
490
|
+
'--space-5',
|
|
491
|
+
'--space-6',
|
|
492
|
+
'--space-7',
|
|
493
|
+
'--space-8',
|
|
494
|
+
'--radius-xs',
|
|
495
|
+
'--radius-sm',
|
|
496
|
+
'--radius-md',
|
|
497
|
+
'--radius-lg',
|
|
498
|
+
'--radius-xl',
|
|
499
|
+
'--radius-pill',
|
|
500
|
+
'--type-xs',
|
|
501
|
+
'--type-sm',
|
|
502
|
+
'--type-base',
|
|
503
|
+
'--type-md',
|
|
504
|
+
'--type-lg',
|
|
505
|
+
'--type-xl',
|
|
506
|
+
'--type-2xl',
|
|
507
|
+
'--type-3xl',
|
|
508
|
+
'--lh-xs',
|
|
509
|
+
'--lh-sm',
|
|
510
|
+
'--lh-base',
|
|
511
|
+
'--lh-md',
|
|
512
|
+
'--lh-lg',
|
|
513
|
+
'--lh-xl',
|
|
514
|
+
'--lh-2xl',
|
|
515
|
+
'--lh-3xl',
|
|
516
|
+
'--font-display',
|
|
517
|
+
'--font-body',
|
|
518
|
+
'--font-mono',
|
|
519
|
+
'--dur-flip',
|
|
520
|
+
'--dur-panel',
|
|
521
|
+
'--dur-route',
|
|
522
|
+
'--dur-soft',
|
|
523
|
+
'--ease-out',
|
|
524
|
+
'--ease-in-out',
|
|
525
|
+
'--shadow-sm',
|
|
526
|
+
'--shadow-md',
|
|
527
|
+
'--shadow-lg',
|
|
528
|
+
]);
|
|
529
|
+
|
|
530
|
+
function normalizeTokenPath(path) {
|
|
531
|
+
return path
|
|
532
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
533
|
+
.replace(/[/_.-]+/g, ' ')
|
|
534
|
+
.toLowerCase()
|
|
535
|
+
.trim()
|
|
536
|
+
.split(/\s+/)
|
|
537
|
+
.filter(Boolean);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function has(words, ...any) {
|
|
541
|
+
return any.some((w) => words.includes(w));
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/** Decision 5 — map a flattened token path to a KNOWN_VARIABLES member, or null (unmapped, never guessed). */
|
|
545
|
+
export function mapTokenNameToVariable(path, detectedType) {
|
|
546
|
+
const words = normalizeTokenPath(path);
|
|
547
|
+
const isColor = detectedType === 'color' || detectedType == null;
|
|
548
|
+
const isStatusWord = has(words, 'success', 'warn', 'warning', 'error', 'danger', 'info');
|
|
549
|
+
|
|
550
|
+
if (isColor && has(words, 'bg', 'background', 'surface') && !isStatusWord) {
|
|
551
|
+
if (has(words, '4', 'pressed')) return '--bg-4';
|
|
552
|
+
if (has(words, '3', 'input')) return '--bg-3';
|
|
553
|
+
if (has(words, '2', 'nested', 'popover', 'inspector')) return '--bg-2';
|
|
554
|
+
if (has(words, '1', 'card', 'panel')) return '--bg-1';
|
|
555
|
+
return '--bg-0';
|
|
556
|
+
}
|
|
557
|
+
if (isColor && has(words, 'fg', 'text', 'foreground') && !has(words, 'accent')) {
|
|
558
|
+
if (has(words, '3', 'disabled')) return '--fg-3';
|
|
559
|
+
if (has(words, '2', 'tertiary', 'muted')) return '--fg-2';
|
|
560
|
+
if (has(words, '1', 'secondary')) return '--fg-1';
|
|
561
|
+
return '--fg-0';
|
|
562
|
+
}
|
|
563
|
+
if (isColor && has(words, 'border') && !has(words, 'radius', 'width', 'corner', 'rounding')) {
|
|
564
|
+
if (has(words, 'strong')) return '--border-strong';
|
|
565
|
+
if (has(words, 'subtle')) return '--border-subtle';
|
|
566
|
+
return '--border-default';
|
|
567
|
+
}
|
|
568
|
+
if (isColor && has(words, 'success')) return '--status-success';
|
|
569
|
+
if (isColor && has(words, 'warn', 'warning')) return '--status-warn';
|
|
570
|
+
if (isColor && has(words, 'error', 'danger')) return '--status-error';
|
|
571
|
+
if (isColor && has(words, 'info')) return '--status-info';
|
|
572
|
+
if (isColor && has(words, 'accent', 'brand', 'primary')) {
|
|
573
|
+
if (has(words, 'hover')) return '--accent-hover';
|
|
574
|
+
if (has(words, 'active', 'pressed')) return '--accent-active';
|
|
575
|
+
if (has(words, 'fg', 'foreground', 'on')) return '--accent-fg';
|
|
576
|
+
if (has(words, 'muted')) return '--accent-muted';
|
|
577
|
+
if (has(words, 'tint')) return '--accent-tint';
|
|
578
|
+
return '--accent';
|
|
579
|
+
}
|
|
580
|
+
if (has(words, 'space', 'spacing', 'gap')) {
|
|
581
|
+
const idx = words.find((w) => /^\d+$/.test(w));
|
|
582
|
+
if (idx == null) return null;
|
|
583
|
+
return `--space-${Math.max(0, Math.min(8, Number(idx)))}`;
|
|
584
|
+
}
|
|
585
|
+
if (has(words, 'radius', 'corner', 'rounding')) {
|
|
586
|
+
if (has(words, 'xs')) return '--radius-xs';
|
|
587
|
+
if (has(words, 'sm', 'small')) return '--radius-sm';
|
|
588
|
+
if (has(words, 'lg', 'large')) return '--radius-lg';
|
|
589
|
+
if (has(words, 'xl')) return '--radius-xl';
|
|
590
|
+
if (has(words, 'pill', 'full', 'round')) return '--radius-pill';
|
|
591
|
+
return '--radius-md';
|
|
592
|
+
}
|
|
593
|
+
if (has(words, 'lineheight', 'leading') || (has(words, 'line') && has(words, 'height'))) {
|
|
594
|
+
for (const s of ['xs', 'sm', 'base', 'md', 'lg', 'xl']) if (has(words, s)) return `--lh-${s}`;
|
|
595
|
+
if (has(words, '2xl')) return '--lh-2xl';
|
|
596
|
+
if (has(words, '3xl')) return '--lh-3xl';
|
|
597
|
+
return null;
|
|
598
|
+
}
|
|
599
|
+
if (has(words, 'fontsize') || (has(words, 'font') && has(words, 'size'))) {
|
|
600
|
+
for (const s of ['xs', 'sm', 'base', 'md', 'lg', 'xl']) if (has(words, s)) return `--type-${s}`;
|
|
601
|
+
if (has(words, '2xl')) return '--type-2xl';
|
|
602
|
+
if (has(words, '3xl')) return '--type-3xl';
|
|
603
|
+
return null;
|
|
604
|
+
}
|
|
605
|
+
if (has(words, 'font', 'typeface', 'fontfamily') || detectedType === 'fontFamily') {
|
|
606
|
+
if (has(words, 'display', 'heading', 'headline')) return '--font-display';
|
|
607
|
+
if (has(words, 'mono', 'code', 'monospace')) return '--font-mono';
|
|
608
|
+
return '--font-body';
|
|
609
|
+
}
|
|
610
|
+
if (has(words, 'duration', 'motion') || detectedType === 'duration') {
|
|
611
|
+
if (has(words, 'flip')) return '--dur-flip';
|
|
612
|
+
if (has(words, 'panel')) return '--dur-panel';
|
|
613
|
+
if (has(words, 'route', 'page')) return '--dur-route';
|
|
614
|
+
return '--dur-soft';
|
|
615
|
+
}
|
|
616
|
+
if (has(words, 'easing', 'ease', 'curve') || detectedType === 'cubicBezier') {
|
|
617
|
+
return has(words, 'inout') || (has(words, 'in') && has(words, 'out'))
|
|
618
|
+
? '--ease-in-out'
|
|
619
|
+
: '--ease-out';
|
|
620
|
+
}
|
|
621
|
+
if (has(words, 'shadow', 'elevation')) {
|
|
622
|
+
if (has(words, 'sm', 'small')) return '--shadow-sm';
|
|
623
|
+
if (has(words, 'lg', 'large')) return '--shadow-lg';
|
|
624
|
+
return '--shadow-md';
|
|
625
|
+
}
|
|
626
|
+
return null;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// ============================================================================
|
|
630
|
+
// Decision 6 — colorspace normalization: convert when confident, report when not.
|
|
631
|
+
// ============================================================================
|
|
632
|
+
|
|
633
|
+
function detectColorFormat(v) {
|
|
634
|
+
if (typeof v !== 'string') return null;
|
|
635
|
+
if (HEX_RES.some((re) => re.test(v))) return 'hex';
|
|
636
|
+
if (RGB_RE.test(v)) return 'rgb';
|
|
637
|
+
if (OKLCH_RE.test(v)) return 'oklch';
|
|
638
|
+
if (HSL_RE.test(v)) return 'hsl';
|
|
639
|
+
return null;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/** Decision 6 — normalize a color value into `targetColorSpace`. Returns `{ value }` or `{ skip: reason }`. */
|
|
643
|
+
export function normalizeColorspace(value, targetColorSpace) {
|
|
644
|
+
const inputFormat = detectColorFormat(value);
|
|
645
|
+
if (inputFormat === null) return { skip: 'unparseable-color-value' };
|
|
646
|
+
if (targetColorSpace === 'hex') {
|
|
647
|
+
if (inputFormat === 'hex') return { value };
|
|
648
|
+
if (inputFormat === 'rgb') {
|
|
649
|
+
const rgb = parseColor(value);
|
|
650
|
+
return {
|
|
651
|
+
value: `#${[rgb.r, rgb.g, rgb.b].map((n) => n.toString(16).padStart(2, '0')).join('')}`,
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
if (inputFormat === 'oklch') return { value: oklchToHex(parseOklch(value)) };
|
|
655
|
+
return { skip: `unsupported-colorspace-conversion (${inputFormat}->hex)` };
|
|
656
|
+
}
|
|
657
|
+
if (targetColorSpace === 'oklch' || targetColorSpace == null) {
|
|
658
|
+
if (inputFormat === 'oklch') return { value };
|
|
659
|
+
if (inputFormat === 'hex' || inputFormat === 'rgb') {
|
|
660
|
+
const rgb = parseColor(value);
|
|
661
|
+
const o = rgbToOklch(rgb);
|
|
662
|
+
return { value: `oklch(${o.l.toFixed(3)} ${o.c.toFixed(3)} ${o.h.toFixed(1)})` };
|
|
663
|
+
}
|
|
664
|
+
return { skip: `unsupported-colorspace-conversion (${inputFormat}->oklch)` };
|
|
665
|
+
}
|
|
666
|
+
// hsl/lab targets — no converter in this pass (Decision 6, named gap).
|
|
667
|
+
if (inputFormat === targetColorSpace) return { value };
|
|
668
|
+
return { skip: `unsupported-colorspace-conversion (${inputFormat}->${targetColorSpace})` };
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// ============================================================================
|
|
672
|
+
// Decision 7 — write-path: theme-block-scoped patch, atomic, contained.
|
|
673
|
+
// ============================================================================
|
|
674
|
+
|
|
675
|
+
const SELECTOR_ROOT_RE = /(^|,)\s*:root\s*(,|$)/;
|
|
676
|
+
|
|
677
|
+
function selectorMatchesTheme(selector, rootClass, theme) {
|
|
678
|
+
// Tolerant of whitespace/quote-style variants a hand-edited file might
|
|
679
|
+
// legitimately carry — `[ data-theme = "dark" ]`, `[data-theme='dark']`.
|
|
680
|
+
const escapedClass = rootClass.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
681
|
+
const escapedTheme = theme.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
682
|
+
const re = new RegExp(
|
|
683
|
+
`\\.${escapedClass}\\s*\\[\\s*data-theme\\s*=\\s*["']${escapedTheme}["']\\s*\\]`
|
|
684
|
+
);
|
|
685
|
+
return re.test(selector);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* Decision 7 — locate the target theme's top-level (non-`@media`-nested)
|
|
690
|
+
* rule block. Returns `{ bodyStart, bodyEnd }` or `null` (caller MUST hard
|
|
691
|
+
* reject on null — never fall back to `:root`, the first match, or a
|
|
692
|
+
* whole-file scan).
|
|
693
|
+
*/
|
|
694
|
+
export function locateThemeBlock(cssText, { rootClass, theme, singleTheme }) {
|
|
695
|
+
const rules = scanCssRules(cssText).filter((r) => !r.nestedInAtRule);
|
|
696
|
+
const themed = rules.find((r) => selectorMatchesTheme(r.selector, rootClass, theme));
|
|
697
|
+
if (themed) return { bodyStart: themed.bodyStart, bodyEnd: themed.bodyEnd };
|
|
698
|
+
if (singleTheme) {
|
|
699
|
+
const rootRule = rules.find((r) => SELECTOR_ROOT_RE.test(r.selector));
|
|
700
|
+
if (rootRule) return { bodyStart: rootRule.bodyStart, bodyEnd: rootRule.bodyEnd };
|
|
701
|
+
}
|
|
702
|
+
return null;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* Decision 7 — patch (or, with `forceInsert`, append) ONE declaration inside
|
|
707
|
+
* the given block span. Never touches text outside `[bodyStart, bodyEnd)`.
|
|
708
|
+
*/
|
|
709
|
+
export function patchDeclarationInBlock(
|
|
710
|
+
cssText,
|
|
711
|
+
block,
|
|
712
|
+
varName,
|
|
713
|
+
newValue,
|
|
714
|
+
{ forceInsert = false } = {}
|
|
715
|
+
) {
|
|
716
|
+
const body = cssText.slice(block.bodyStart, block.bodyEnd);
|
|
717
|
+
const decls = extractDeclarations(body, block.bodyStart);
|
|
718
|
+
const existing = decls.find((d) => d.name === varName);
|
|
719
|
+
if (existing) {
|
|
720
|
+
// Replace ONLY the value span — name, colon spacing, and the trailing
|
|
721
|
+
// `;` are left byte-for-byte untouched, exactly the "replaces ONLY that
|
|
722
|
+
// declaration's value" contract (Decision 7). Returns an UPDATED block
|
|
723
|
+
// span (bodyEnd shifted by the length delta) — callers patching multiple
|
|
724
|
+
// variables in the same block in sequence must use the returned block,
|
|
725
|
+
// not the original, or subsequent lookups mis-locate against stale
|
|
726
|
+
// offsets once a value's length changes.
|
|
727
|
+
const css = cssText.slice(0, existing.valueStart) + newValue + cssText.slice(existing.valueEnd);
|
|
728
|
+
const delta = newValue.length - (existing.valueEnd - existing.valueStart);
|
|
729
|
+
return {
|
|
730
|
+
css,
|
|
731
|
+
patched: true,
|
|
732
|
+
block: { bodyStart: block.bodyStart, bodyEnd: block.bodyEnd + delta },
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
if (!forceInsert) return { css: cssText, patched: false, block };
|
|
736
|
+
// Force-insert anchor: immediately before the located block's OWN closing
|
|
737
|
+
// `}` — never at file scope (Decision 7).
|
|
738
|
+
const insertion = ` ${varName}: ${newValue};\n`;
|
|
739
|
+
const css = `${cssText.slice(0, block.bodyEnd)}${insertion}${cssText.slice(block.bodyEnd)}`;
|
|
740
|
+
return {
|
|
741
|
+
css,
|
|
742
|
+
patched: true,
|
|
743
|
+
block: { bodyStart: block.bodyStart, bodyEnd: block.bodyEnd + insertion.length },
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function atomicWrite(filePath, content) {
|
|
748
|
+
const dir = filePath.slice(0, filePath.lastIndexOf(sep));
|
|
749
|
+
const tmp = join(
|
|
750
|
+
dir,
|
|
751
|
+
`.tmp-import-tokens-${createHash('sha256')
|
|
752
|
+
.update(filePath + Date.now())
|
|
753
|
+
.digest('hex')
|
|
754
|
+
.slice(0, 12)}`
|
|
755
|
+
);
|
|
756
|
+
writeFileSync(tmp, content);
|
|
757
|
+
renameSync(tmp, filePath);
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function containSystemPath(root, designRootRel, tokensCssRel) {
|
|
761
|
+
const systemDir = resolve(root, designRootRel, 'system');
|
|
762
|
+
const target = resolve(root, designRootRel, tokensCssRel);
|
|
763
|
+
if (target !== systemDir && !target.startsWith(systemDir + sep)) {
|
|
764
|
+
throw new ImportTokensError(6, `tokensCssRel escapes system/: ${target}`);
|
|
765
|
+
}
|
|
766
|
+
return target;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function containConfigPath(root, designRootRel) {
|
|
770
|
+
const expected = resolve(root, designRootRel, 'config.json');
|
|
771
|
+
return expected;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// ============================================================================
|
|
775
|
+
// Decision 8 — new-DS metadata: sink eliminated, not bounded.
|
|
776
|
+
// ============================================================================
|
|
777
|
+
|
|
778
|
+
const DS_NAME_RE = /^[a-z0-9-]{1,64}$/;
|
|
779
|
+
|
|
780
|
+
export function assertValidDsName(name) {
|
|
781
|
+
if (!ASCII_ONLY_RE.test(name) || !DS_NAME_RE.test(name)) {
|
|
782
|
+
throw new ImportTokensError(3, `invalid-ds-name: must match ${DS_NAME_RE} — got "${name}"`);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// ============================================================================
|
|
787
|
+
// Orchestration
|
|
788
|
+
// ============================================================================
|
|
789
|
+
|
|
790
|
+
function readConfig(root, designRootRel) {
|
|
791
|
+
const path = containConfigPath(root, designRootRel);
|
|
792
|
+
if (!existsSync(path)) throw new ImportTokensError(4, `config.json not found at ${path}`);
|
|
793
|
+
try {
|
|
794
|
+
return { path, config: JSON.parse(readFileSync(path, 'utf8')) };
|
|
795
|
+
} catch (err) {
|
|
796
|
+
throw new ImportTokensError(4, `could not parse config.json: ${err?.message ?? err}`);
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
function scaffoldMinimalDs(root, designRootRel, dsName) {
|
|
801
|
+
if (!DESIGN_PLUGIN_DIR) {
|
|
802
|
+
throw new ImportTokensError(
|
|
803
|
+
4,
|
|
804
|
+
'scaffold template not found — this install layout does not bundle the design plugin templates; patch an existing DS instead, or run /design:setup-ds'
|
|
805
|
+
);
|
|
806
|
+
}
|
|
807
|
+
const tplPath = join(
|
|
808
|
+
DESIGN_PLUGIN_DIR,
|
|
809
|
+
'templates/design-system-inspiration/core/colors_and_type.css.tpl'
|
|
810
|
+
);
|
|
811
|
+
if (!existsSync(tplPath)) {
|
|
812
|
+
throw new ImportTokensError(4, `scaffold template not found at ${tplPath}`);
|
|
813
|
+
}
|
|
814
|
+
const tpl = readFileSync(tplPath, 'utf8');
|
|
815
|
+
// DDR-043 neutral-skeleton posture — grayscale, system fonts, no shadow/
|
|
816
|
+
// motion personality. Every placeholder gets a literal, inert default; the
|
|
817
|
+
// import pass immediately after this overwrites whatever it successfully
|
|
818
|
+
// mapped.
|
|
819
|
+
const NEUTRAL = {
|
|
820
|
+
project_label: dsName,
|
|
821
|
+
root_class: dsName,
|
|
822
|
+
theme_default: 'dark',
|
|
823
|
+
bg_0: '#0d0d0f',
|
|
824
|
+
bg_1: '#151517',
|
|
825
|
+
bg_2: '#1b1b1e',
|
|
826
|
+
bg_3: '#232326',
|
|
827
|
+
bg_4: '#2b2b2f',
|
|
828
|
+
border_subtle: '#232326',
|
|
829
|
+
border_default: '#333338',
|
|
830
|
+
border_strong: '#48484f',
|
|
831
|
+
fg_0: '#f2f2f3',
|
|
832
|
+
fg_1: '#c7c7cb',
|
|
833
|
+
fg_2: '#9a9aa0',
|
|
834
|
+
fg_3: '#6b6b70',
|
|
835
|
+
accent_strategy_summary: 'single',
|
|
836
|
+
accent_block:
|
|
837
|
+
' --accent: #6b6bf0;\n --accent-hover: #8080f5;\n --accent-active: #5555d0;\n --accent-fg: #0d0d0f;\n --accent-muted: #3d3d8a;\n --accent-tint: color-mix(in oklab, var(--accent) 16%, transparent);',
|
|
838
|
+
status_success: '#3fb950',
|
|
839
|
+
status_warn: '#d29922',
|
|
840
|
+
status_error: '#f85149',
|
|
841
|
+
status_info: '#58a6ff',
|
|
842
|
+
presence_online: '#3fb950',
|
|
843
|
+
presence_away: '#d29922',
|
|
844
|
+
presence_offline: '#6b6b70',
|
|
845
|
+
shadow_sm: '0 1px 2px rgba(0,0,0,0.4)',
|
|
846
|
+
shadow_md: '0 4px 14px rgba(0,0,0,0.46)',
|
|
847
|
+
shadow_lg: '0 14px 38px rgba(0,0,0,0.56)',
|
|
848
|
+
radius_xs: '3px',
|
|
849
|
+
radius_sm: '5px',
|
|
850
|
+
radius_md: '7px',
|
|
851
|
+
radius_lg: '10px',
|
|
852
|
+
radius_xl: '14px',
|
|
853
|
+
radius_pill: '999px',
|
|
854
|
+
space_0: '0',
|
|
855
|
+
space_1: '2px',
|
|
856
|
+
space_2: '4px',
|
|
857
|
+
space_3: '8px',
|
|
858
|
+
space_4: '12px',
|
|
859
|
+
space_5: '16px',
|
|
860
|
+
space_6: '24px',
|
|
861
|
+
space_7: '32px',
|
|
862
|
+
space_8: '48px',
|
|
863
|
+
font_display: 'system-ui, sans-serif',
|
|
864
|
+
font_body: 'system-ui, sans-serif',
|
|
865
|
+
font_mono: 'ui-monospace, monospace',
|
|
866
|
+
type_xs: '11px',
|
|
867
|
+
lh_xs: '16px',
|
|
868
|
+
type_sm: '12px',
|
|
869
|
+
lh_sm: '18px',
|
|
870
|
+
type_base: '14px',
|
|
871
|
+
lh_base: '20px',
|
|
872
|
+
type_md: '16px',
|
|
873
|
+
lh_md: '24px',
|
|
874
|
+
type_lg: '19px',
|
|
875
|
+
lh_lg: '26px',
|
|
876
|
+
type_xl: '23px',
|
|
877
|
+
lh_xl: '30px',
|
|
878
|
+
type_2xl: '28px',
|
|
879
|
+
lh_2xl: '34px',
|
|
880
|
+
type_3xl: '34px',
|
|
881
|
+
lh_3xl: '40px',
|
|
882
|
+
dur_flip: '140ms',
|
|
883
|
+
dur_panel: '220ms',
|
|
884
|
+
dur_route: '280ms',
|
|
885
|
+
dur_soft: '120ms',
|
|
886
|
+
ease_out_curve: 'cubic-bezier(0.2,0,0,1)',
|
|
887
|
+
ease_in_out_curve: 'cubic-bezier(0.4,0,0.2,1)',
|
|
888
|
+
layout_max_w: 'none',
|
|
889
|
+
layout_gutter: 'var(--space-5)',
|
|
890
|
+
};
|
|
891
|
+
const filled = tpl.replace(/\{\{(\w+)\}\}/g, (m, key) => (key in NEUTRAL ? NEUTRAL[key] : m));
|
|
892
|
+
const dsDir = resolve(root, designRootRel, 'system', dsName);
|
|
893
|
+
mkdirSync(dsDir, { recursive: true });
|
|
894
|
+
const tokensCssRel = `system/${dsName}/colors_and_type.css`;
|
|
895
|
+
atomicWrite(resolve(root, designRootRel, tokensCssRel), filled);
|
|
896
|
+
return { tokensCssRel, rootClass: dsName, themeDefault: 'dark', themes: ['dark'] };
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
function upsertDesignSystemConfig(root, designRootRel, dsName, scaffold, tokenCount) {
|
|
900
|
+
const { path, config } = readConfig(root, designRootRel);
|
|
901
|
+
config.designSystems = Array.isArray(config.designSystems) ? config.designSystems : [];
|
|
902
|
+
const description = `Imported via maude design import-tokens — ${tokenCount} tokens mapped on ${new Date().toISOString().slice(0, 10)}. Refine via /design:setup-ds.`;
|
|
903
|
+
const entry = {
|
|
904
|
+
name: dsName,
|
|
905
|
+
path: `system/${dsName}`,
|
|
906
|
+
description,
|
|
907
|
+
tokensCssRel: scaffold.tokensCssRel,
|
|
908
|
+
rootClass: scaffold.rootClass,
|
|
909
|
+
themeDefault: scaffold.themeDefault,
|
|
910
|
+
themes: scaffold.themes,
|
|
911
|
+
newCanvasDir: 'ui',
|
|
912
|
+
newComponentDir: `system/${dsName}/preview`,
|
|
913
|
+
};
|
|
914
|
+
const idx = config.designSystems.findIndex((d) => d?.name === dsName);
|
|
915
|
+
if (idx >= 0) config.designSystems[idx] = entry;
|
|
916
|
+
else config.designSystems.push(entry);
|
|
917
|
+
if (!config.defaultDesignSystem) config.defaultDesignSystem = dsName;
|
|
918
|
+
atomicWrite(path, `${JSON.stringify(config, null, 2)}\n`);
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
/**
|
|
922
|
+
* Full import: parse → flatten → resolve aliases → map → validate → normalize
|
|
923
|
+
* colorspace → theme-block-scoped patch → mapping report.
|
|
924
|
+
*/
|
|
925
|
+
export async function importTokens({
|
|
926
|
+
inputPath,
|
|
927
|
+
root,
|
|
928
|
+
designRootRel = '.design',
|
|
929
|
+
dsName = null,
|
|
930
|
+
newDs = null,
|
|
931
|
+
theme = null,
|
|
932
|
+
forceInsert = false,
|
|
933
|
+
}) {
|
|
934
|
+
if (newDs) assertValidDsName(newDs);
|
|
935
|
+
|
|
936
|
+
const text = readTokenFileCapped(inputPath);
|
|
937
|
+
const parsed = parseTokenFile(text);
|
|
938
|
+
const { tokens: rawTokens, types } =
|
|
939
|
+
parsed.format === 'json'
|
|
940
|
+
? flattenJsonTokens(parsed.root)
|
|
941
|
+
: { tokens: parsed.tokens, types: Object.create(null) };
|
|
942
|
+
|
|
943
|
+
const { resolved, statuses } = resolveAliases(rawTokens);
|
|
944
|
+
|
|
945
|
+
let targetName = newDs || dsName;
|
|
946
|
+
let scaffold = null;
|
|
947
|
+
let targetColorSpace = 'oklch';
|
|
948
|
+
if (newDs) {
|
|
949
|
+
scaffold = scaffoldMinimalDs(root, designRootRel, newDs);
|
|
950
|
+
targetColorSpace = 'hex'; // matches the neutral scaffold template's own literal values
|
|
951
|
+
} else {
|
|
952
|
+
const { config } = readConfig(root, designRootRel);
|
|
953
|
+
targetName = dsName || config.defaultDesignSystem;
|
|
954
|
+
const dsEntry = (config.designSystems || []).find((d) => d?.name === targetName);
|
|
955
|
+
if (!dsEntry)
|
|
956
|
+
throw new ImportTokensError(4, `design system "${targetName}" not found in config.json`);
|
|
957
|
+
scaffold = {
|
|
958
|
+
tokensCssRel: dsEntry.tokensCssRel,
|
|
959
|
+
rootClass: dsEntry.rootClass,
|
|
960
|
+
themeDefault: dsEntry.themeDefault,
|
|
961
|
+
themes: dsEntry.themes || [dsEntry.themeDefault],
|
|
962
|
+
};
|
|
963
|
+
targetColorSpace = config.colorSpace || 'oklch';
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
const targetTheme = theme || scaffold.themeDefault;
|
|
967
|
+
const singleTheme = (scaffold.themes || []).length <= 1;
|
|
968
|
+
const cssPath = containSystemPath(root, designRootRel, scaffold.tokensCssRel);
|
|
969
|
+
if (!existsSync(cssPath)) throw new ImportTokensError(4, `target CSS file not found: ${cssPath}`);
|
|
970
|
+
let cssText = readFileSync(cssPath, 'utf8');
|
|
971
|
+
|
|
972
|
+
let block = locateThemeBlock(cssText, {
|
|
973
|
+
rootClass: scaffold.rootClass,
|
|
974
|
+
theme: targetTheme,
|
|
975
|
+
singleTheme,
|
|
976
|
+
});
|
|
977
|
+
if (!block) {
|
|
978
|
+
throw new ImportTokensError(
|
|
979
|
+
3,
|
|
980
|
+
`could not locate the "${targetTheme}" theme block in ${cssPath} — this file's theme structure isn't recognized`
|
|
981
|
+
);
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
const report = {
|
|
985
|
+
generatedAt: new Date().toISOString(),
|
|
986
|
+
inputPath,
|
|
987
|
+
targetDs: targetName,
|
|
988
|
+
targetTheme,
|
|
989
|
+
tokens: [],
|
|
990
|
+
};
|
|
991
|
+
let mappedCount = 0;
|
|
992
|
+
|
|
993
|
+
for (const path of Object.keys(rawTokens)) {
|
|
994
|
+
const entry = { path, detectedType: types[path] || null };
|
|
995
|
+
if (statuses[path]) {
|
|
996
|
+
entry.status = 'unmapped';
|
|
997
|
+
entry.reason = statuses[path];
|
|
998
|
+
report.tokens.push(entry);
|
|
999
|
+
continue;
|
|
1000
|
+
}
|
|
1001
|
+
const value = resolved[path];
|
|
1002
|
+
const varName = mapTokenNameToVariable(path, types[path] || null);
|
|
1003
|
+
if (!varName || !KNOWN_VARIABLES.has(varName)) {
|
|
1004
|
+
entry.status = 'unmapped';
|
|
1005
|
+
entry.reason = 'no-recognized-pattern';
|
|
1006
|
+
report.tokens.push(entry);
|
|
1007
|
+
continue;
|
|
1008
|
+
}
|
|
1009
|
+
entry.targetVariable = varName;
|
|
1010
|
+
|
|
1011
|
+
let finalValue = value;
|
|
1012
|
+
const family = familyOfVariable(varName);
|
|
1013
|
+
if (['bg', 'fg', 'border', 'accent', 'status'].includes(family) && typeof value === 'string') {
|
|
1014
|
+
const norm = normalizeColorspace(value, targetColorSpace);
|
|
1015
|
+
if (norm.skip) {
|
|
1016
|
+
entry.status = 'skipped';
|
|
1017
|
+
entry.reason = norm.skip;
|
|
1018
|
+
report.tokens.push(entry);
|
|
1019
|
+
continue;
|
|
1020
|
+
}
|
|
1021
|
+
finalValue = norm.value;
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
const validation = validateTokenValue(varName, finalValue);
|
|
1025
|
+
if (!validation.ok) {
|
|
1026
|
+
entry.status = 'rejected';
|
|
1027
|
+
entry.reason = validation.reason;
|
|
1028
|
+
report.tokens.push(entry);
|
|
1029
|
+
continue;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
const patchResult = patchDeclarationInBlock(cssText, block, varName, finalValue, {
|
|
1033
|
+
forceInsert,
|
|
1034
|
+
});
|
|
1035
|
+
if (!patchResult.patched) {
|
|
1036
|
+
entry.status = 'skipped';
|
|
1037
|
+
entry.reason = 'family not present in target file (pass --force-insert to add it)';
|
|
1038
|
+
report.tokens.push(entry);
|
|
1039
|
+
continue;
|
|
1040
|
+
}
|
|
1041
|
+
cssText = patchResult.css;
|
|
1042
|
+
block = patchResult.block; // stale-offset fix: later patches in this same block must use the shifted span
|
|
1043
|
+
entry.status = 'mapped';
|
|
1044
|
+
entry.value = finalValue;
|
|
1045
|
+
mappedCount += 1;
|
|
1046
|
+
report.tokens.push(entry);
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
// Twin-declaration policy — report the untouched theme(s) for every mapped
|
|
1050
|
+
// variable, so the user knows those weren't silently synced (Decision 7).
|
|
1051
|
+
// Snapshot BEFORE looping — report.tokens is pushed into below, and a
|
|
1052
|
+
// live for-of over a growing array would re-visit newly pushed entries.
|
|
1053
|
+
const mappedEntries = report.tokens.filter((e) => e.status === 'mapped');
|
|
1054
|
+
for (const t of scaffold.themes || []) {
|
|
1055
|
+
if (t === targetTheme) continue;
|
|
1056
|
+
for (const entry of mappedEntries) {
|
|
1057
|
+
report.tokens.push({
|
|
1058
|
+
path: entry.path,
|
|
1059
|
+
targetVariable: entry.targetVariable,
|
|
1060
|
+
status: 'skipped',
|
|
1061
|
+
reason: `themed-token-not-patched (theme=${t}, value unchanged)`,
|
|
1062
|
+
});
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
if (mappedCount > 0) {
|
|
1067
|
+
const backupDir = resolve(root, designRootRel, '_history', '_system');
|
|
1068
|
+
mkdirSync(backupDir, { recursive: true });
|
|
1069
|
+
const ts = Date.now();
|
|
1070
|
+
atomicWrite(join(backupDir, `import-tokens-backup-${ts}.css`), readFileSync(cssPath, 'utf8'));
|
|
1071
|
+
atomicWrite(cssPath, cssText);
|
|
1072
|
+
atomicWrite(
|
|
1073
|
+
join(backupDir, `import-tokens-report-${ts}.json`),
|
|
1074
|
+
`${JSON.stringify(report, null, 2)}\n`
|
|
1075
|
+
);
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
if (newDs) upsertDesignSystemConfig(root, designRootRel, newDs, scaffold, mappedCount);
|
|
1079
|
+
|
|
1080
|
+
return { ...report, mappedCount, cssPath };
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
// ============================================================================
|
|
1084
|
+
// CLI entry
|
|
1085
|
+
// ============================================================================
|
|
1086
|
+
|
|
1087
|
+
function parseArgv(argv) {
|
|
1088
|
+
const out = {
|
|
1089
|
+
file: null,
|
|
1090
|
+
root: null,
|
|
1091
|
+
designRoot: '.design',
|
|
1092
|
+
ds: null,
|
|
1093
|
+
newDs: null,
|
|
1094
|
+
theme: null,
|
|
1095
|
+
forceInsert: false,
|
|
1096
|
+
json: false,
|
|
1097
|
+
};
|
|
1098
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
1099
|
+
const a = argv[i];
|
|
1100
|
+
switch (a) {
|
|
1101
|
+
case '--root':
|
|
1102
|
+
out.root = argv[++i];
|
|
1103
|
+
break;
|
|
1104
|
+
case '--design-root':
|
|
1105
|
+
out.designRoot = argv[++i];
|
|
1106
|
+
break;
|
|
1107
|
+
case '--ds':
|
|
1108
|
+
out.ds = argv[++i];
|
|
1109
|
+
break;
|
|
1110
|
+
case '--new-ds':
|
|
1111
|
+
out.newDs = argv[++i];
|
|
1112
|
+
break;
|
|
1113
|
+
case '--theme':
|
|
1114
|
+
out.theme = argv[++i];
|
|
1115
|
+
break;
|
|
1116
|
+
case '--force-insert':
|
|
1117
|
+
out.forceInsert = true;
|
|
1118
|
+
break;
|
|
1119
|
+
case '--json':
|
|
1120
|
+
out.json = true;
|
|
1121
|
+
break;
|
|
1122
|
+
case '--help':
|
|
1123
|
+
case '-h':
|
|
1124
|
+
out.help = true;
|
|
1125
|
+
break;
|
|
1126
|
+
default:
|
|
1127
|
+
if (a.startsWith('-')) throw new ImportTokensError(2, `unknown flag ${a}`);
|
|
1128
|
+
if (out.file === null) out.file = a;
|
|
1129
|
+
else throw new ImportTokensError(2, `unexpected extra arg ${a}`);
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
return out;
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
const HELP = `import-tokens — token-file import (reached via \`maude design import-tokens\`)
|
|
1136
|
+
|
|
1137
|
+
Usage:
|
|
1138
|
+
maude design import-tokens <token-file> --root <repo> [--design-root .design]
|
|
1139
|
+
[--ds <name> | --new-ds <name>] [--theme <name>]
|
|
1140
|
+
[--force-insert] [--json]
|
|
1141
|
+
|
|
1142
|
+
Parses a W3C design-tokens JSON, Style-Dictionary JSON, or raw CSS custom-
|
|
1143
|
+
properties file, maps recognized tokens onto the DS CSS-variable contract,
|
|
1144
|
+
and patches (or scaffolds, with --new-ds) the target design system. Emits a
|
|
1145
|
+
mapping report accounting for every input token (mapped/unmapped/rejected/
|
|
1146
|
+
skipped) — never silently drops one. See DDR-172 for the full contract.
|
|
1147
|
+
|
|
1148
|
+
Exit: 0 ok · 2 usage · 3 validation/mapping reject · 4 read/parse error ·
|
|
1149
|
+
5 unsupported format · 6 write/containment error · 1 other.`;
|
|
1150
|
+
|
|
1151
|
+
async function main() {
|
|
1152
|
+
let opts;
|
|
1153
|
+
try {
|
|
1154
|
+
opts = parseArgv(process.argv.slice(2));
|
|
1155
|
+
} catch (err) {
|
|
1156
|
+
process.stderr.write(`import-tokens: ${err.message}\n`);
|
|
1157
|
+
process.exit(err instanceof ImportTokensError ? err.code : 2);
|
|
1158
|
+
}
|
|
1159
|
+
if (opts.help) {
|
|
1160
|
+
process.stdout.write(`${HELP}\n`);
|
|
1161
|
+
return;
|
|
1162
|
+
}
|
|
1163
|
+
if (!opts.file) {
|
|
1164
|
+
process.stderr.write('import-tokens: <token-file> required\n');
|
|
1165
|
+
process.exit(2);
|
|
1166
|
+
}
|
|
1167
|
+
const root = opts.root || process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
1168
|
+
try {
|
|
1169
|
+
const result = await importTokens({
|
|
1170
|
+
inputPath: opts.file,
|
|
1171
|
+
root,
|
|
1172
|
+
designRootRel: opts.designRoot,
|
|
1173
|
+
dsName: opts.ds,
|
|
1174
|
+
newDs: opts.newDs,
|
|
1175
|
+
theme: opts.theme,
|
|
1176
|
+
forceInsert: opts.forceInsert,
|
|
1177
|
+
});
|
|
1178
|
+
if (opts.json) {
|
|
1179
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
1180
|
+
} else {
|
|
1181
|
+
process.stdout.write(
|
|
1182
|
+
`import-tokens: ${result.mappedCount} mapped, ${result.tokens.length - result.mappedCount} not mapped -> ${result.cssPath}\n`
|
|
1183
|
+
);
|
|
1184
|
+
}
|
|
1185
|
+
} catch (err) {
|
|
1186
|
+
process.stderr.write(`import-tokens: ${err.message}\n`);
|
|
1187
|
+
process.exit(err instanceof ImportTokensError ? err.code : 1);
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
// import.meta.main is the reliable "am I the entry module?" flag under bun
|
|
1192
|
+
// --compile — the argv/url compare below falsely matches inside a standalone
|
|
1193
|
+
// binary (every bundled module's import.meta.url collapses to the binary's
|
|
1194
|
+
// own path), which would hijack the process before Bun.serve ever runs (the
|
|
1195
|
+
// v0.38.0 "Starting…" hang class of bug). Fall back to the argv compare only
|
|
1196
|
+
// for the plain-`node` CLI path (Node <24 leaves import.meta.main undefined).
|
|
1197
|
+
const runDirectly =
|
|
1198
|
+
import.meta.main ?? (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href);
|
|
1199
|
+
if (runDirectly) {
|
|
1200
|
+
main();
|
|
1201
|
+
}
|