@transtyle/cli 0.1.0-alpha.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.
Files changed (2) hide show
  1. package/package.json +32 -0
  2. package/src/main.js +523 -0
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@transtyle/cli",
3
+ "version": "0.1.0-alpha.0",
4
+ "description": "Transtyle CLI — compile a design system to native framework themes.",
5
+ "type": "module",
6
+ "bin": {
7
+ "transtyle": "./src/main.js"
8
+ },
9
+ "dependencies": {
10
+ "@transtyle/core": "0.1.0-alpha.0",
11
+ "@transtyle/exporter-shadcn": "0.1.0-alpha.0",
12
+ "@transtyle/exporter-echarts": "0.1.0-alpha.0",
13
+ "@transtyle/exporter-daisyui": "0.1.0-alpha.0",
14
+ "@transtyle/exporter-bootstrap": "0.1.0-alpha.0",
15
+ "@transtyle/exporter-storybook": "0.1.0-alpha.0",
16
+ "@transtyle/exporter-css-variables": "0.1.0-alpha.0",
17
+ "@transtyle/exporter-radix": "0.1.0-alpha.0",
18
+ "@transtyle/exporter-primeng": "0.1.0-alpha.0"
19
+ },
20
+ "files": [
21
+ "src"
22
+ ],
23
+ "publishConfig": { "access": "public" },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/transtyle/transtyle.git",
27
+ "directory": "packages/cli"
28
+ },
29
+ "homepage": "https://github.com/transtyle/transtyle#readme",
30
+ "bugs": "https://github.com/transtyle/transtyle/issues",
31
+ "license": "MIT"
32
+ }
package/src/main.js ADDED
@@ -0,0 +1,523 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * transtyle CLI (docs/specs/cli.md). Commands: build, check, explain, init, add.
4
+ * Human logs → stderr; exit codes: 0 ok, 1 diagnostics ≥ fail-on, 2 usage error.
5
+ */
6
+
7
+ import path from 'node:path';
8
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
9
+ import { tmpdir } from 'node:os';
10
+ import { createRequire } from 'node:module';
11
+ import { pathToFileURL } from 'node:url';
12
+ import { execSync } from 'node:child_process';
13
+ import process from 'node:process';
14
+ import { compile, diffResolved, contrastRegressions, formatColor, formatHex } from '@transtyle/core';
15
+
16
+ const OFFICIAL_EXPORTERS = {
17
+ shadcn: '@transtyle/exporter-shadcn',
18
+ echarts: '@transtyle/exporter-echarts',
19
+ daisyui: '@transtyle/exporter-daisyui',
20
+ bootstrap: '@transtyle/exporter-bootstrap',
21
+ storybook: '@transtyle/exporter-storybook',
22
+ 'css-variables': '@transtyle/exporter-css-variables',
23
+ radix: '@transtyle/exporter-radix',
24
+ primeng: '@transtyle/exporter-primeng',
25
+ };
26
+
27
+ /**
28
+ * Build a loader that resolves exporter packages **from the user's project
29
+ * first**, then from the CLI's own install.
30
+ *
31
+ * A bare `import(pkg)` resolves relative to this file, which works for the
32
+ * official exporters (they ship alongside the CLI) but makes third-party ones
33
+ * unloadable whenever the CLI isn't inside the project's own node_modules — a
34
+ * global install, a monorepo checkout, a hoisted binary. Project-first also lets
35
+ * a project deliberately pin its own fork of an official exporter.
36
+ */
37
+ function makeLoadExporter(cwd) {
38
+ const requireFromProject = createRequire(path.join(cwd, 'noop.js'));
39
+ return async function loadExporter(name) {
40
+ const pkg = OFFICIAL_EXPORTERS[name] ?? name;
41
+ const tried = [];
42
+ try {
43
+ return (await import(pathToFileURL(requireFromProject.resolve(pkg)).href)).default;
44
+ } catch (e) {
45
+ tried.push(`from the project (${cwd}): ${e.code ?? e.message}`);
46
+ }
47
+ try {
48
+ return (await import(pkg)).default;
49
+ } catch (e) {
50
+ tried.push(`from the transtyle install: ${e.code ?? e.message}`);
51
+ }
52
+ throw new Error(
53
+ `Cannot load exporter for target "${name}" (package "${pkg}"):\n - ${tried.join('\n - ')}\n` +
54
+ ` Third-party exporters must be installed in this project: npm install ${pkg}`);
55
+ };
56
+ }
57
+
58
+ function parseArgs(argv) {
59
+ const args = { command: undefined, targets: [], cwd: process.cwd() };
60
+ for (let i = 0; i < argv.length; i++) {
61
+ const a = argv[i];
62
+ if (a === '--cwd') args.cwd = path.resolve(argv[++i] ?? '.');
63
+ else if (a === '--mode') args.mode = argv[++i];
64
+ else if (a === '--json') args.json = true;
65
+ else if (a === '--help' || a === '-h') args.help = true;
66
+ else if (a.startsWith('--')) { console.error(`Unknown flag: ${a}`); process.exit(2); }
67
+ else if (!args.command) args.command = a;
68
+ else args.targets.push(a);
69
+ }
70
+ return args;
71
+ }
72
+
73
+ const HELP = `transtyle — design system compiler
74
+
75
+ Usage:
76
+ transtyle build [target...] compile configured targets (default: all)
77
+ transtyle check [target...] run the pipeline without writing files
78
+ transtyle explain <slot> show a resolved slot's value, provenance, and rule inputs
79
+ transtyle diff [ref] semantic diff of the resolved graph vs a git ref (default: HEAD), with per-target impact
80
+ transtyle init [name] scaffold transtyle.config.json + tokens/tokens.json
81
+ transtyle add <target> add a target to transtyle.config.json
82
+ Options:
83
+ --cwd <dir> project directory (with transtyle.config.json)
84
+ --mode <name> mode to resolve for (explain only; default: the DS's default mode)
85
+ --json check/diff only: also print a machine-readable report to stdout
86
+ `;
87
+
88
+ const ICONS = { error: '✖', warning: '⚠', info: 'ℹ' };
89
+
90
+ /**
91
+ * One diagnostic, rendered. The `hint` (AL5) goes on its own indented line
92
+ * rather than inside the message: what is wrong and what to change are
93
+ * different sentences, and running them together is how the old one-liners
94
+ * ended up saying neither well.
95
+ */
96
+ function printDiagnostic(d) {
97
+ console.error(`${ICONS[d.severity] ?? '·'} ${d.code} ${d.message}`);
98
+ if (d.hint) console.error(` ↳ ${d.hint}`);
99
+ }
100
+ const COMMANDS = ['build', 'check', 'explain', 'diff', 'init', 'add'];
101
+
102
+ async function main() {
103
+ const args = parseArgs(process.argv.slice(2));
104
+ if (args.help || !args.command) { console.error(HELP); process.exit(args.help ? 0 : 2); }
105
+ if (!COMMANDS.includes(args.command)) {
106
+ console.error(`Unknown command: ${args.command}\n${HELP}`);
107
+ process.exit(2);
108
+ }
109
+
110
+ if (args.command === 'explain') return cmdExplain(args);
111
+ if (args.command === 'diff') return cmdDiff(args);
112
+ if (args.command === 'init') return cmdInit(args);
113
+ if (args.command === 'add') return cmdAdd(args);
114
+ return cmdBuildOrCheck(args);
115
+ }
116
+
117
+ // ---------- build / check ----------
118
+
119
+ async function cmdBuildOrCheck(args) {
120
+ const emit = args.command === 'build';
121
+ let result;
122
+ try {
123
+ result = await compile({ cwd: args.cwd, targets: args.targets, emit, loadExporter: makeLoadExporter(args.cwd), knownExporters: Object.keys(OFFICIAL_EXPORTERS) });
124
+ } catch (e) {
125
+ console.error(`✖ ${e.message}`);
126
+ process.exit(2);
127
+ }
128
+
129
+ const { diagnostics, results, config } = result;
130
+
131
+ for (const d of diagnostics.items) printDiagnostic(d);
132
+
133
+ for (const r of results) {
134
+ const counts = {};
135
+ for (const c of r.coverage) counts[c.class] = (counts[c.class] ?? 0) + 1;
136
+ const total = r.coverage.length || 1;
137
+ const pct = (k) => (counts[k] ? `${Math.round((counts[k] / total) * 100)}% ${k}` : null);
138
+ const bar = ['native', 'derived', 'approximated', 'dropped', 'unsupported'].map(pct).filter(Boolean).join(' · ');
139
+ console.error(`\n${r.target} ${bar}`);
140
+ if (emit) for (const f of r.files) console.error(` ↳ ${f}`);
141
+ }
142
+
143
+ // Human logs → stderr (above); requested data → stdout (docs/specs/cli.md
144
+ // "Behavioral contracts"). `check --json` is the only current consumer.
145
+ if (!emit && args.json) {
146
+ console.log(JSON.stringify({
147
+ diagnostics: diagnostics.items,
148
+ targets: results.map((r) => ({ target: r.target, coverage: r.coverage })),
149
+ }, null, 2));
150
+ }
151
+
152
+ const failOn = config.check?.failOn ?? 'error';
153
+ if (diagnostics.shouldFail(failOn)) {
154
+ console.error(`\n✖ failed (fail-on: ${failOn})`);
155
+ process.exit(1);
156
+ }
157
+ console.error(emit ? '\n✔ build complete' : '\n✔ check passed');
158
+ }
159
+
160
+ // ---------- explain ----------
161
+
162
+ async function cmdExplain(args) {
163
+ const slotArg = args.targets[0];
164
+ if (!slotArg) { console.error('Usage: transtyle explain <slot> [--mode <name>]'); process.exit(2); }
165
+
166
+ let result;
167
+ try {
168
+ result = await compile({ cwd: args.cwd, targets: [], emit: false, loadExporter: makeLoadExporter(args.cwd) });
169
+ } catch (e) {
170
+ console.error(`✖ ${e.message}`);
171
+ process.exit(2);
172
+ }
173
+ const { normalized, diagnostics } = result;
174
+ for (const d of diagnostics.items) printDiagnostic(d);
175
+
176
+ const useMode = args.mode ?? normalized.defaultMode;
177
+ const map = normalized.modes[useMode];
178
+ if (!map) {
179
+ console.error(`✖ Unknown mode "${useMode}" (available: ${normalized.modeValues.join(', ')})`);
180
+ process.exit(2);
181
+ }
182
+
183
+ // A resolved provenance.inputs entry may be a bare relative path
184
+ // ("primary.solid", "radius.md") or a fully-qualified one — try both
185
+ // conventional prefixes before giving up.
186
+ const resolvePath = (raw) => {
187
+ for (const candidate of [raw, `semantic.${raw}`, `semantic.color.${raw}`]) {
188
+ if (map.has(candidate)) return candidate;
189
+ }
190
+ return null;
191
+ };
192
+
193
+ const fullPath = resolvePath(slotArg);
194
+ if (!fullPath) {
195
+ const bare = slotArg.replace(/^semantic\.(color\.)?/, '');
196
+ const closest = [...map.keys()]
197
+ .map((k) => [k, levenshtein(bare, k.replace(/^semantic\.(color\.)?/, ''))])
198
+ .sort((a, b) => a[1] - b[1])
199
+ .slice(0, 5)
200
+ .map(([k]) => k);
201
+ console.error(`✖ Unknown slot: ${slotArg}\n\nClosest matches:\n${closest.map((k) => ` ${k}`).join('\n')}`);
202
+ process.exit(2);
203
+ }
204
+
205
+ printExplain(map, fullPath, resolvePath, 0, new Set([fullPath]));
206
+ }
207
+
208
+ function formatEntryValue(entry) {
209
+ const { type, value } = entry;
210
+ if (type === 'color') {
211
+ try {
212
+ return `${formatColor(value)} [${formatHex(value).text}]`;
213
+ } catch {
214
+ return formatColor(value);
215
+ }
216
+ }
217
+ if (type === 'typography') {
218
+ return `{ family: ${value.fontFamily}, size: ${value.fontSize}, weight: ${value.fontWeight}, leading: ${value.lineHeight} }`;
219
+ }
220
+ if (type === 'shadow') {
221
+ return `${value.offsetX} ${value.offsetY} ${value.blur} ${value.spread} / ${formatColor(value.color)}`;
222
+ }
223
+ if (Array.isArray(value)) return value.join(', ');
224
+ return String(value);
225
+ }
226
+
227
+ function printExplain(map, slotPath, resolvePath, depth, seen) {
228
+ const entry = map.get(slotPath);
229
+ const indent = ' '.repeat(depth);
230
+ if (depth === 0) console.log(`${slotPath} = ${formatEntryValue(entry)}`);
231
+
232
+ const prov = entry.provenance;
233
+ if (prov.kind === 'authored') {
234
+ console.log(`${indent} └─ authored`);
235
+ return;
236
+ }
237
+ if (prov.kind === 'aliased') {
238
+ console.log(`${indent} └─ aliased → ${prov.target}`);
239
+ return;
240
+ }
241
+ // derived or defaulted
242
+ console.log(`${indent} └─ ${prov.kind} by rule ${prov.rule ?? '(catalog default)'}`);
243
+ if (!prov.inputs?.length || depth >= 6) return;
244
+ for (const rawInput of prov.inputs) {
245
+ const inputPath = resolvePath(rawInput) ?? rawInput;
246
+ const inputEntry = map.get(inputPath);
247
+ if (!inputEntry) {
248
+ console.log(`${indent} inputs: ${rawInput} (unresolved)`);
249
+ continue;
250
+ }
251
+ if (seen.has(inputPath)) {
252
+ console.log(`${indent} inputs: ${inputPath} = ${formatEntryValue(inputEntry)} (see above)`);
253
+ continue;
254
+ }
255
+ seen.add(inputPath);
256
+ console.log(`${indent} inputs: ${inputPath} = ${formatEntryValue(inputEntry)}`);
257
+ printExplain(map, inputPath, resolvePath, depth + 2, seen);
258
+ }
259
+ }
260
+
261
+ /** Levenshtein edit distance — used only for "did you mean" suggestions. */
262
+ function levenshtein(a, b) {
263
+ const dp = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
264
+ for (let j = 0; j <= b.length; j++) dp[0][j] = j;
265
+ for (let i = 1; i <= a.length; i++) {
266
+ for (let j = 1; j <= b.length; j++) {
267
+ dp[i][j] = a[i - 1] === b[j - 1]
268
+ ? dp[i - 1][j - 1]
269
+ : 1 + Math.min(dp[i - 1][j - 1], dp[i - 1][j], dp[i][j - 1]);
270
+ }
271
+ }
272
+ return dp[a.length][b.length];
273
+ }
274
+
275
+ // ---------- diff ----------
276
+
277
+ /**
278
+ * Semantic diff of the working tree against a git ref (default HEAD), plus
279
+ * per-target impact. Resolves both sides with emit:false and diffs the resolved
280
+ * graph (diffResolved) and the in-memory emitted files (docs/specs/diff.md).
281
+ * Exit 0 = no changes; exit 1 = changes found (so it composes in CI, like `git
282
+ * diff --exit-code`); exit 2 = usage/environment error.
283
+ */
284
+ async function cmdDiff(args) {
285
+ const ref = args.targets[0] ?? 'HEAD';
286
+
287
+ // "after" = the working tree as it is now.
288
+ let after;
289
+ try {
290
+ after = await compile({ cwd: args.cwd, targets: [], emit: false, loadExporter: makeLoadExporter(args.cwd) });
291
+ } catch (e) { console.error(`✖ ${e.message}`); process.exit(2); }
292
+
293
+ // "before" = the project at `ref`, materialized into a temp dir via git archive.
294
+ let repoRoot;
295
+ try {
296
+ repoRoot = execSync('git rev-parse --show-toplevel', { cwd: args.cwd, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
297
+ } catch { console.error('✖ transtyle diff requires a git repository'); process.exit(2); }
298
+ try {
299
+ execSync(`git rev-parse --verify --quiet ${ref}^{commit}`, { cwd: repoRoot, stdio: 'ignore' });
300
+ } catch { console.error(`✖ Unknown git ref: ${ref}`); process.exit(2); }
301
+
302
+ // The project's path relative to the repo root, straight from git — avoids the
303
+ // macOS /var → /private/var symlink mismatch that path.relative(toplevel, cwd)
304
+ // would produce (toplevel is realpath'd, cwd may be the symlink).
305
+ const prefix = execSync('git rev-parse --show-prefix', { cwd: args.cwd, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim().replace(/\/$/, '');
306
+ const tmp = mkdtempSync(path.join(tmpdir(), 'transtyle-diff-'));
307
+ let before;
308
+ try {
309
+ execSync(`git archive ${ref} ${prefix} | tar -x -C "${tmp}"`, { cwd: repoRoot, stdio: ['ignore', 'ignore', 'pipe'] });
310
+ const beforeCwd = path.join(tmp, prefix);
311
+ if (!existsSync(path.join(beforeCwd, 'transtyle.config.json'))) {
312
+ console.error(`ℹ No transtyle project at ${ref} — nothing to diff against.`);
313
+ rmSync(tmp, { recursive: true, force: true });
314
+ process.exit(0);
315
+ }
316
+ before = await compile({ cwd: beforeCwd, targets: [], emit: false, loadExporter: makeLoadExporter(args.cwd) });
317
+ } catch (e) {
318
+ rmSync(tmp, { recursive: true, force: true });
319
+ console.error(`✖ Could not resolve the project at ${ref}: ${e.message}`);
320
+ process.exit(2);
321
+ }
322
+ rmSync(tmp, { recursive: true, force: true });
323
+
324
+ const diff = diffResolved(before.normalized, after.normalized);
325
+ const impact = diffTargets(before.results, after.results);
326
+ const a11y = contrastRegressions(before.normalized, after.normalized, after.config);
327
+
328
+ if (args.json) {
329
+ console.log(JSON.stringify(serializeDiff(ref, diff, impact, a11y), null, 2));
330
+ } else {
331
+ printDiff(ref, diff, impact, a11y);
332
+ }
333
+ // Set exitCode rather than process.exit(): the JSON report can be tens of KB,
334
+ // and process.exit() truncates an async stdout write to a pipe mid-flush.
335
+ process.exitCode = diff.hasChanges ? 1 : 0;
336
+ }
337
+
338
+ /** Per-target impact: re-emit both sides (already done by compile) and diff file contents. */
339
+ function diffTargets(beforeResults, afterResults) {
340
+ const byName = (rs) => new Map(rs.map((r) => [r.target, r]));
341
+ const b = byName(beforeResults), a = byName(afterResults);
342
+ const names = [...new Set([...b.keys(), ...a.keys()])].sort();
343
+ const rows = [];
344
+ for (const name of names) {
345
+ const br = b.get(name), ar = a.get(name);
346
+ if (!br) { rows.push({ target: name, status: 'new-target' }); continue; }
347
+ if (!ar) { rows.push({ target: name, status: 'removed-target' }); continue; }
348
+ const bf = new Map((br.emitted ?? []).map((f) => [f.path, f.contents]));
349
+ const af = new Map((ar.emitted ?? []).map((f) => [f.path, f.contents]));
350
+ let changedLines = 0; const samples = [];
351
+ for (const [p, ac] of af) {
352
+ if (p.endsWith('usage.md')) continue; // generated docs, not the theme itself
353
+ const bc = bf.get(p);
354
+ if (bc === ac) continue;
355
+ const d = lineChanges(bc ?? '', ac);
356
+ changedLines += d.length;
357
+ for (const line of d) if (samples.length < 8) samples.push(`${p}: ${line}`);
358
+ }
359
+ rows.push({ target: name, status: 'changed', changedLines, samples });
360
+ }
361
+ return rows;
362
+ }
363
+
364
+ /** Meaningful (non-comment, non-blank) lines in `after` absent verbatim from `before`. */
365
+ function lineChanges(before, after) {
366
+ const beforeLines = new Set(before.split('\n').map((l) => l.trim()));
367
+ const out = [];
368
+ for (const raw of after.split('\n')) {
369
+ const l = raw.trim();
370
+ if (!l || l.startsWith('*') || l.startsWith('//') || l.startsWith('/*')) continue;
371
+ if (!beforeLines.has(l)) out.push(l);
372
+ }
373
+ return out;
374
+ }
375
+
376
+ function serializeDiff(ref, diff, impact, a11y = []) {
377
+ return {
378
+ ref,
379
+ hasChanges: diff.hasChanges,
380
+ contrastRegressions: a11y.map((r) => ({
381
+ mode: r.mode, pair: `${r.fg} on ${r.bg}`, status: r.status,
382
+ before: Number(r.before.toFixed(2)), after: Number(r.after.toFixed(2)), threshold: r.threshold,
383
+ })),
384
+ semantic: diff.modes.map((m) => ({
385
+ mode: m.mode,
386
+ added: m.added,
387
+ removed: m.removed,
388
+ changed: m.changed.map((c) => ({
389
+ slot: c.slot,
390
+ before: formatEntryValue(c.before),
391
+ after: formatEntryValue(c.after),
392
+ provenance: c.provChanged ? `${c.before.provenance.kind} → ${c.after.provenance.kind}` : c.after.provenance.kind,
393
+ })),
394
+ })),
395
+ impact: impact.map((r) => ({ target: r.target, status: r.status, changedLines: r.changedLines ?? 0 })),
396
+ };
397
+ }
398
+
399
+ function printDiff(ref, diff, impact, a11y = []) {
400
+ if (!diff.hasChanges) {
401
+ console.error(`No semantic changes vs ${ref} — compiled themes are identical.`);
402
+ return;
403
+ }
404
+ console.error(`Semantic diff vs ${ref}:\n`);
405
+ for (const m of diff.modes) {
406
+ if (!m.added.length && !m.removed.length && !m.changed.length) continue;
407
+ console.error(`[${m.mode}]`);
408
+ for (const s of m.added) console.error(` + ${s}`);
409
+ for (const s of m.removed) console.error(` - ${s}`);
410
+ for (const c of m.changed) {
411
+ const prov = c.provChanged ? ` (${c.before.provenance.kind} → ${c.after.provenance.kind})` : '';
412
+ console.error(` ~ ${c.slot} ${formatEntryValue(c.before)} → ${formatEntryValue(c.after)}${prov}`);
413
+ }
414
+ console.error('');
415
+ }
416
+ console.error('Per-target impact:');
417
+ for (const r of impact) {
418
+ if (r.status === 'new-target') { console.error(` ${r.target}: new target (not present at ${ref})`); continue; }
419
+ if (r.status === 'removed-target') { console.error(` ${r.target}: removed since ${ref}`); continue; }
420
+ if (!r.changedLines) { console.error(` ${r.target}: no output change`); continue; }
421
+ console.error(` ${r.target}: ${r.changedLines} line${r.changedLines === 1 ? '' : 's'} changed`);
422
+ for (const s of r.samples) console.error(` ${s}`);
423
+ }
424
+
425
+ // Last, so it stays on screen: this change's accessibility cost.
426
+ if (a11y.length) {
427
+ const regressed = a11y.filter((r) => r.status === 'regressed');
428
+ console.error(`\n⚠ Contrast ${regressed.length ? 'regressions' : 'changes'}:`);
429
+ for (const r of a11y) {
430
+ const verb = r.status === 'regressed' ? 'now FAILS' : 'still fails';
431
+ console.error(` ${r.status === 'regressed' ? '✖' : '⚠'} ${r.fg} on ${r.bg} (${r.mode}): ${r.before.toFixed(1)}:1 → ${r.after.toFixed(1)}:1 — ${verb} ${r.threshold}:1`);
432
+ }
433
+ if (regressed.length) {
434
+ console.error(`\n ${regressed.length} pair${regressed.length === 1 ? '' : 's'} passed before this change and fail${regressed.length === 1 ? 's' : ''} after it.`);
435
+ }
436
+ }
437
+ }
438
+
439
+ // ---------- init ----------
440
+
441
+ async function cmdInit(args) {
442
+ const configPath = path.join(args.cwd, 'transtyle.config.json');
443
+ if (existsSync(configPath)) {
444
+ console.error(`✖ transtyle.config.json already exists at ${configPath}`);
445
+ process.exit(2);
446
+ }
447
+ const name = args.targets[0] ?? path.basename(args.cwd) ?? 'design-system';
448
+
449
+ const config = {
450
+ $schema: 'https://transtyle.dev/schemas/config/v0.json',
451
+ name,
452
+ tokens: ['tokens/*.tokens.json'],
453
+ modes: { 'color-scheme': { values: ['light', 'dark'], default: 'light' } },
454
+ derivation: { rules: 'standard@1', autoDark: false, require: ['semantic.color.primary'] },
455
+ targets: { 'css-variables': { output: 'dist/css-variables' } },
456
+ check: { failOn: 'error', contrast: { standard: 'wcag21-aa' } },
457
+ };
458
+
459
+ const td = (value, description) => ({ $value: value, $description: description });
460
+ const tokens = {
461
+ option: {
462
+ color: { $type: 'color', brand: { 500: { $value: 'oklch(0.55 0.18 255)' } } },
463
+ },
464
+ semantic: {
465
+ color: {
466
+ $type: 'color',
467
+ primary: { solid: td('{option.color.brand.500}', 'TODO: your brand color — the one non-negotiable input') },
468
+ elevation: {
469
+ 0: { surface: td('oklch(1 0 0)', 'TODO: the page background') },
470
+ 1: { surface: td('oklch(0.98 0.003 255)', 'TODO: card/panel background') },
471
+ },
472
+ text: {
473
+ base: td('oklch(0.2 0.01 255)', 'TODO: body text color'),
474
+ muted: td('oklch(0.5 0.01 255)', 'TODO: muted/secondary text color'),
475
+ },
476
+ border: td('oklch(0.9 0.005 255)', 'TODO: default border color'),
477
+ },
478
+ radius: { md: { $type: 'dimension', $value: '0.5rem' } },
479
+ font: {
480
+ sans: { $type: 'fontFamily', $value: ['system-ui', 'sans-serif'] },
481
+ mono: { $type: 'fontFamily', $value: ['ui-monospace', 'monospace'] },
482
+ },
483
+ },
484
+ };
485
+
486
+ mkdirSync(path.join(args.cwd, 'tokens'), { recursive: true });
487
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
488
+ writeFileSync(path.join(args.cwd, 'tokens/brand.tokens.json'), JSON.stringify(tokens, null, 2) + '\n');
489
+
490
+ console.error(`✔ created transtyle.config.json + tokens/brand.tokens.json in ${args.cwd}`);
491
+ console.error(`
492
+ Next steps:
493
+ 1. Edit tokens/brand.tokens.json — replace the TODO placeholders with your brand.
494
+ 2. npx transtyle build (starts with css-variables; add more with "add")
495
+ 3. npx transtyle add <target> (${Object.keys(OFFICIAL_EXPORTERS).join(', ')})`);
496
+ }
497
+
498
+ // ---------- add ----------
499
+
500
+ async function cmdAdd(args) {
501
+ const target = args.targets[0];
502
+ if (!target) { console.error('Usage: transtyle add <target>'); process.exit(2); }
503
+ if (!(target in OFFICIAL_EXPORTERS)) {
504
+ console.error(`✖ Unknown target: ${target}\nValid targets: ${Object.keys(OFFICIAL_EXPORTERS).join(', ')}`);
505
+ process.exit(2);
506
+ }
507
+ const configPath = path.join(args.cwd, 'transtyle.config.json');
508
+ if (!existsSync(configPath)) {
509
+ console.error(`✖ No transtyle.config.json in ${args.cwd} — run "transtyle init" first`);
510
+ process.exit(2);
511
+ }
512
+ const config = JSON.parse(readFileSync(configPath, 'utf8'));
513
+ config.targets ??= {};
514
+ if (config.targets[target]) {
515
+ console.error(`✖ Target "${target}" is already configured`);
516
+ process.exit(2);
517
+ }
518
+ config.targets[target] = { output: `dist/${target}` };
519
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
520
+ console.error(`✔ added target "${target}" → dist/${target}\n\nBuild it: npx transtyle build ${target}`);
521
+ }
522
+
523
+ main();