baldart 4.63.0 → 4.65.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/CHANGELOG.md +39 -0
- package/README.md +3 -2
- package/VERSION +1 -1
- package/bin/baldart.js +15 -0
- package/framework/.claude/agents/REGISTRY.md +3 -0
- package/framework/.claude/agents/code-reviewer.md +25 -11
- package/framework/.claude/agents/codebase-architect.md +13 -5
- package/framework/.claude/agents/doc-reviewer.md +26 -12
- package/framework/.claude/agents/merge-conflict-resolver.md +58 -0
- package/framework/.claude/agents/ui-expert.md +18 -10
- package/framework/.claude/skills/context-primer/SKILL.md +7 -0
- package/framework/.claude/skills/design-system-init/SKILL.md +164 -156
- package/framework/.claude/skills/design-system-init/scripts/component-spec.template.md +26 -3
- package/framework/.claude/skills/design-system-init/scripts/extract-manifest.mjs +151 -0
- package/framework/.claude/skills/new/references/merge-cleanup.md +23 -18
- package/framework/.claude/skills/worktree-manager/SKILL.md +19 -0
- package/framework/.claude/skills/worktree-manager/scripts/merge-worktree.sh +518 -0
- package/framework/.claude/workflows/new2.js +14 -3
- package/framework/agents/component-manifest-schema.md +145 -0
- package/framework/agents/design-system-protocol.md +63 -23
- package/framework/agents/index.md +2 -0
- package/framework/docs/COMPONENT-MANIFEST-LAYER.md +100 -0
- package/framework/routines/ds-drift.routine.yml +19 -12
- package/framework/templates/baldart.config.template.yml +22 -0
- package/package.json +1 -1
- package/src/commands/configure.js +54 -0
- package/src/commands/doctor.js +85 -0
- package/src/commands/tokens.js +80 -0
- package/src/commands/update.js +7 -0
- package/src/utils/token-emitters/README.md +36 -0
- package/src/utils/token-emitters/css.js +18 -0
- package/src/utils/token-emitters/index.js +47 -0
- package/src/utils/token-emitters/ts.js +32 -0
- package/src/utils/tokens-generator.js +165 -0
package/src/commands/doctor.js
CHANGED
|
@@ -479,6 +479,54 @@ async function detectState(cwd, opts = {}) {
|
|
|
479
479
|
}
|
|
480
480
|
} catch (_) { /* never block doctor on i18n probe */ }
|
|
481
481
|
|
|
482
|
+
// ---- Design-system manifest + DTCG tokens (since v4.65.0) ----------
|
|
483
|
+
// Two backfills, both gated on has_design_system (no separate flag — the
|
|
484
|
+
// manifest rides on the design-system layer). (1) "default-on + nudge":
|
|
485
|
+
// when the registry exists but specs are missing or still prose-only (no
|
|
486
|
+
// machine-readable HEAD), nudge /design-system-init. (2) token SSOT: when
|
|
487
|
+
// paths.design_tokens is set, flag generated outputs that drifted from the
|
|
488
|
+
// DTCG source (or a hand-edited generated file).
|
|
489
|
+
state.dsManifestNudge = false;
|
|
490
|
+
state.dsManifestReason = '';
|
|
491
|
+
state.tokensStale = false;
|
|
492
|
+
state.tokensStalePaths = [];
|
|
493
|
+
try {
|
|
494
|
+
if (config && !config.__malformed && config.features && config.features.has_design_system === true) {
|
|
495
|
+
const dsRoot = (config.paths && config.paths.design_system) || '';
|
|
496
|
+
if (dsRoot) {
|
|
497
|
+
const compDir = path.join(cwd, dsRoot, 'components');
|
|
498
|
+
let specs = [];
|
|
499
|
+
try { specs = fs.readdirSync(compDir).filter((f) => f.endsWith('.md')); } catch (_) { specs = []; }
|
|
500
|
+
if (specs.length === 0) {
|
|
501
|
+
state.dsManifestNudge = true;
|
|
502
|
+
state.dsManifestReason = 'no per-component specs found';
|
|
503
|
+
} else {
|
|
504
|
+
// Cheap sample: does ANY spec carry a frontmatter HEAD (starts with `---`)?
|
|
505
|
+
const structured = specs.slice(0, 8).some((f) => {
|
|
506
|
+
try { return fs.readFileSync(path.join(compDir, f), 'utf8').startsWith('---'); }
|
|
507
|
+
catch (_) { return false; }
|
|
508
|
+
});
|
|
509
|
+
if (!structured) {
|
|
510
|
+
state.dsManifestNudge = true;
|
|
511
|
+
state.dsManifestReason = 'specs are prose-only (no machine-readable HEAD)';
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
// DTCG token SSOT staleness.
|
|
516
|
+
const src = (config.paths && config.paths.design_tokens) || '';
|
|
517
|
+
const outs = (config.design_tokens && config.design_tokens.outputs) || [];
|
|
518
|
+
if (src && outs.length) {
|
|
519
|
+
const TokensGenerator = require('../utils/tokens-generator');
|
|
520
|
+
const st = new TokensGenerator(cwd).isStale({ source: src, outputs: outs });
|
|
521
|
+
if (st.stale) {
|
|
522
|
+
state.tokensStale = true;
|
|
523
|
+
state.tokensStalePaths = (st.reasons || []).map((r) => r.path);
|
|
524
|
+
state.tokensStaleError = st.error || null;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
} catch (_) { /* never block doctor on design-system probe */ }
|
|
529
|
+
|
|
482
530
|
// ---- Auto-deploy allowlist presence (since v4.59.0) ----------------
|
|
483
531
|
// Purely informational: git.auto_deploy bounds what `/new -auto-ship`
|
|
484
532
|
// may execute as an OUTWARD action. An empty allowlist is the SAFE
|
|
@@ -1013,6 +1061,43 @@ function planActions(state) {
|
|
|
1013
1061
|
});
|
|
1014
1062
|
}
|
|
1015
1063
|
|
|
1064
|
+
// ---- Design-system manifest + DTCG tokens (since v4.65.0) ----------
|
|
1065
|
+
// (1) Rebuild stale generated token artifacts from the DTCG SSOT — offline,
|
|
1066
|
+
// deterministic, idempotent → autoOk. (2) Nudge the manifest bootstrap when
|
|
1067
|
+
// the registry is present but specs are missing/prose-only (the "default-on
|
|
1068
|
+
// + nudge" behavior; informational, never auto-mutates source).
|
|
1069
|
+
if (state.tokensStale) {
|
|
1070
|
+
actions.push({
|
|
1071
|
+
key: 'tokens-build',
|
|
1072
|
+
label: state.tokensStaleError
|
|
1073
|
+
? 'Design tokens: DTCG source has an error — fix then rebuild'
|
|
1074
|
+
: `Rebuild generated token artifacts from the DTCG source (${state.tokensStalePaths.join(', ')})`,
|
|
1075
|
+
why: state.tokensStaleError
|
|
1076
|
+
? `paths.design_tokens is set but the DTCG source could not be parsed: ${state.tokensStaleError}. Fix the .tokens.json, then \`baldart tokens build\`.`
|
|
1077
|
+
: 'The generated token output(s) drifted from the DTCG SSOT (a hand-edit to a generated file, or the source changed without a rebuild). `baldart tokens build` regenerates them (offline, no API cost). Edit `.tokens.json`, never the generated output.',
|
|
1078
|
+
autoOk: !state.tokensStaleError, // a parse error needs a human; a plain rebuild is safe
|
|
1079
|
+
run: async () => {
|
|
1080
|
+
if (state.tokensStaleError) {
|
|
1081
|
+
UI.warning(`DTCG source error: ${state.tokensStaleError}. Fix it, then run \`baldart tokens build\`.`);
|
|
1082
|
+
return;
|
|
1083
|
+
}
|
|
1084
|
+
const tokens = require('./tokens');
|
|
1085
|
+
await tokens.build({ cwd: state.cwd });
|
|
1086
|
+
},
|
|
1087
|
+
});
|
|
1088
|
+
}
|
|
1089
|
+
if (state.dsManifestNudge) {
|
|
1090
|
+
actions.push({
|
|
1091
|
+
key: 'ds-manifest-nudge',
|
|
1092
|
+
label: `Design-system specs need a machine-readable upgrade — run /design-system-init (${state.dsManifestReason})`,
|
|
1093
|
+
why: `features.has_design_system is true but ${state.dsManifestReason}. Agents fall back to reading the monolithic INDEX + component source (slow). Running /design-system-init (upgrade mode) generates the per-component frontmatter HEAD + a thin INDEX router so discovery reads small structured specs on demand. See framework/agents/component-manifest-schema.md.`,
|
|
1094
|
+
autoOk: false, // a skill that reads source + enriches — never run unattended from doctor
|
|
1095
|
+
run: async () => {
|
|
1096
|
+
UI.info('Run `/design-system-init` in your editor to upgrade the per-component specs to the machine-readable HEAD and regenerate the thin INDEX router. See framework/docs/COMPONENT-MANIFEST-LAYER.md.');
|
|
1097
|
+
},
|
|
1098
|
+
});
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1016
1101
|
// Auto-deploy allowlist (since v4.59.0). Advisory print-only — surfaced ONLY
|
|
1017
1102
|
// when the allowlist is non-empty (the safe default of empty/unset opens
|
|
1018
1103
|
// nothing, so it warrants no nag). Reminds the user that `/new -auto-ship`
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `baldart tokens build` — regenerate the stack-native token artifacts from the
|
|
3
|
+
* DTCG SSOT (since v4.65.0).
|
|
4
|
+
*
|
|
5
|
+
* The design-token source of truth is the DTCG `.tokens.json` at
|
|
6
|
+
* `paths.design_tokens`; the artifacts agents/components consume (tokens.ts, a
|
|
7
|
+
* CSS custom-property sheet) are GENERATED from it and carry a
|
|
8
|
+
* `baldart-generated` banner. This verb is what the `/design-system-init` skill,
|
|
9
|
+
* `baldart doctor`, and (later) a git hook CALL — none of them reimplement the
|
|
10
|
+
* generation (the SSOT for that is `src/utils/tokens-generator.js`).
|
|
11
|
+
*
|
|
12
|
+
* Config consumed (baldart.config.yml):
|
|
13
|
+
* paths.design_tokens → the DTCG source (.tokens.json)
|
|
14
|
+
* design_tokens.outputs[] → [{ format: ts|css, path: <file> }]
|
|
15
|
+
*
|
|
16
|
+
* Always safe: no source / no outputs → a clear no-op message, exit 0. A bad
|
|
17
|
+
* DTCG file or a write failure → exit 1 with the reason (it IS a build step).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const fs = require('fs');
|
|
21
|
+
const path = require('path');
|
|
22
|
+
const yaml = require('js-yaml');
|
|
23
|
+
const UI = require('../utils/ui');
|
|
24
|
+
const TokensGenerator = require('../utils/tokens-generator');
|
|
25
|
+
|
|
26
|
+
const CONFIG_FILE = 'baldart.config.yml';
|
|
27
|
+
|
|
28
|
+
function loadConfig(cwd) {
|
|
29
|
+
const full = path.join(cwd, CONFIG_FILE);
|
|
30
|
+
try { return yaml.load(fs.readFileSync(full, 'utf8')) || {}; }
|
|
31
|
+
catch (_) { return null; }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function tokensBuild(opts = {}) {
|
|
35
|
+
const cwd = opts.cwd || process.cwd();
|
|
36
|
+
const json = !!opts.json;
|
|
37
|
+
const config = loadConfig(cwd);
|
|
38
|
+
|
|
39
|
+
const emit = (obj, exit = 0) => {
|
|
40
|
+
if (json) process.stdout.write(JSON.stringify(obj) + '\n');
|
|
41
|
+
return exit;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
if (!config) {
|
|
45
|
+
if (!json) UI.warning(`No (or malformed) ${CONFIG_FILE} — run \`npx baldart configure\` first.`);
|
|
46
|
+
return emit({ schema: 'baldart.tokens-build/1', ok: false, reason: 'no-config' }, 1);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const source = config.paths && config.paths.design_tokens;
|
|
50
|
+
const outputs = (config.design_tokens && config.design_tokens.outputs) || [];
|
|
51
|
+
|
|
52
|
+
if (!source) {
|
|
53
|
+
if (!json) UI.info('No `paths.design_tokens` set — DTCG token SSOT not adopted on this project. Nothing to build.');
|
|
54
|
+
return emit({ schema: 'baldart.tokens-build/1', ok: true, skipped: 'no-source' });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const gen = new TokensGenerator(cwd);
|
|
58
|
+
if (!gen.detect(source)) {
|
|
59
|
+
if (!json) UI.warning(`design tokens source not found at \`${source}\`. Create it (e.g. via /design-system-init) then re-run.`);
|
|
60
|
+
return emit({ schema: 'baldart.tokens-build/1', ok: false, reason: 'source-missing', source }, 1);
|
|
61
|
+
}
|
|
62
|
+
if (!outputs.length) {
|
|
63
|
+
if (!json) UI.warning('`design_tokens.outputs` is empty — nothing to generate. Add at least one { format, path }.');
|
|
64
|
+
return emit({ schema: 'baldart.tokens-build/1', ok: true, skipped: 'no-outputs', source });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const res = gen.build({ source, outputs });
|
|
68
|
+
if (!res.ok) {
|
|
69
|
+
if (!json) UI.error(`Token build failed: ${res.error}`);
|
|
70
|
+
return emit({ schema: 'baldart.tokens-build/1', ok: false, reason: res.error, source }, 1);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!json) {
|
|
74
|
+
UI.success(`Tokens built from ${source} (${res.tokenCount} tokens):`);
|
|
75
|
+
for (const p of res.written) UI.info(` ✓ ${p}`);
|
|
76
|
+
}
|
|
77
|
+
return emit({ schema: 'baldart.tokens-build/1', ok: true, source, written: res.written, tokenCount: res.tokenCount });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = { build: tokensBuild };
|
package/src/commands/update.js
CHANGED
|
@@ -1320,6 +1320,12 @@ async function update(options = {}, unknownArgs = []) {
|
|
|
1320
1320
|
const missingI18n = Object.keys(tpl.i18n || {})
|
|
1321
1321
|
.filter((k) => !(k in (cur2.i18n || {})))
|
|
1322
1322
|
.map((k) => `i18n.${k}`);
|
|
1323
|
+
// `design_tokens:` (since v4.65.0) — same nested-block contract.
|
|
1324
|
+
// Its only key `outputs` is an ARRAY, so (per the v4.46.0 array
|
|
1325
|
+
// lesson) we diff presence of the key, not deep value equality.
|
|
1326
|
+
const missingDesignTokens = Object.keys(tpl.design_tokens || {})
|
|
1327
|
+
.filter((k) => !(k in (cur2.design_tokens || {})))
|
|
1328
|
+
.map((k) => `design_tokens.${k}`);
|
|
1323
1329
|
const allMissing = [
|
|
1324
1330
|
...missingPaths,
|
|
1325
1331
|
...missingFeatures,
|
|
@@ -1329,6 +1335,7 @@ async function update(options = {}, unknownArgs = []) {
|
|
|
1329
1335
|
...missingToolchain,
|
|
1330
1336
|
...missingToolchainCmds,
|
|
1331
1337
|
...missingI18n,
|
|
1338
|
+
...missingDesignTokens,
|
|
1332
1339
|
];
|
|
1333
1340
|
if (allMissing.length) {
|
|
1334
1341
|
UI.newline();
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# token-emitters
|
|
2
|
+
|
|
3
|
+
Per-format emitters for the DTCG token generator (`../tokens-generator.js`).
|
|
4
|
+
The DTCG `.tokens.json` (`paths.design_tokens` in `baldart.config.yml`) is the
|
|
5
|
+
design-token SSOT; each emitter turns the resolved token set into ONE
|
|
6
|
+
stack-native artifact. Same REGISTRY pattern as `lsp-adapters/`,
|
|
7
|
+
`routine-adapters/`, `tool-adapters/`, `toolchain-adapters/`.
|
|
8
|
+
|
|
9
|
+
## Adding a format
|
|
10
|
+
|
|
11
|
+
1. Create `<format>.js` exporting `module.exports = function emit({ resolved, nested, marker }) { return '<file contents>'; }`.
|
|
12
|
+
- `resolved`: `Map<dotId, { type, value }>` — fully ref-resolved leaf tokens, source order.
|
|
13
|
+
- `nested`: nested plain object of resolved **values** (DTCG tree minus `$type`/`$value`).
|
|
14
|
+
- `marker`: the generated-file banner line for your format (already comment-wrapped).
|
|
15
|
+
2. Register it in `index.js`'s `EMITTERS` map.
|
|
16
|
+
3. If the comment syntax differs from `//` and `/* */`, extend `markerFor()` in `index.js`.
|
|
17
|
+
|
|
18
|
+
That's it — `tokens-generator.js`, `baldart tokens build`, and `doctor` iterate
|
|
19
|
+
the registry; nothing else changes.
|
|
20
|
+
|
|
21
|
+
## Invariants
|
|
22
|
+
|
|
23
|
+
- **Deterministic, diff-stable output**: emit in source order, no timestamps, no
|
|
24
|
+
randomness — so `isStale()` (on-disk vs fresh render byte-compare) is reliable.
|
|
25
|
+
- **Always emit the `marker`** as the first line — it is how humans and the
|
|
26
|
+
`framework-edit-gate` know the file is generated and must not be hand-edited.
|
|
27
|
+
- **Pure**: emitters never touch the filesystem; the generator writes.
|
|
28
|
+
- **Zero dependency**: no Style Dictionary, no npm — keep it portable (the build
|
|
29
|
+
path must run identically under Codex).
|
|
30
|
+
|
|
31
|
+
## Shipped formats
|
|
32
|
+
|
|
33
|
+
| format | output |
|
|
34
|
+
|--------|--------|
|
|
35
|
+
| `ts` | `export const tokens = {…} as const;` + `export type Tokens` |
|
|
36
|
+
| `css` | `:root { --<dot-id-as-dashes>: <value>; }` |
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// CSS emitter: DTCG → `:root { --<dot-id-as-dashes>: <value>; }`.
|
|
2
|
+
//
|
|
3
|
+
// Token id `color.action.primary` → `--color-action-primary`. Emits resolved
|
|
4
|
+
// values (refs collapsed) in source order so the output is diff-stable.
|
|
5
|
+
|
|
6
|
+
function cssVarName(dotId) {
|
|
7
|
+
return `--${dotId.replace(/\./g, '-')}`;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
module.exports = function emitCss({ resolved, marker }) {
|
|
11
|
+
const lines = [];
|
|
12
|
+
for (const [id, tok] of resolved) {
|
|
13
|
+
lines.push(` ${cssVarName(id)}: ${tok.value};`);
|
|
14
|
+
}
|
|
15
|
+
return `${marker}\n:root {\n${lines.join('\n')}\n}\n`;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
module.exports.cssVarName = cssVarName;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Token emitter registry (since v4.65.0).
|
|
2
|
+
//
|
|
3
|
+
// DTCG (`.tokens.json`) is the design-token SSOT; each emitter turns the
|
|
4
|
+
// resolved token set into ONE stack-native artifact (a TS object, a CSS
|
|
5
|
+
// custom-property sheet, …). This is the same REGISTRY pattern as
|
|
6
|
+
// `lsp-adapters/`, `routine-adapters/`, `tool-adapters/`, `toolchain-adapters/`:
|
|
7
|
+
// every other layer iterates this map, so adding a format = drop a file +
|
|
8
|
+
// register it here. Zero npm dependency (no Style Dictionary) — see
|
|
9
|
+
// `framework/docs/COMPONENT-MANIFEST-LAYER.md`.
|
|
10
|
+
//
|
|
11
|
+
// An emitter exports a single function:
|
|
12
|
+
// emit({ resolved, nested, marker }) -> string (the file contents)
|
|
13
|
+
// where:
|
|
14
|
+
// resolved : Map<dotId, { type, value }> fully ref-resolved leaf tokens
|
|
15
|
+
// nested : nested plain object of resolved VALUES (DTCG tree minus $type/$value)
|
|
16
|
+
// marker : the "baldart-generated" banner line for this format
|
|
17
|
+
|
|
18
|
+
const ts = require('./ts');
|
|
19
|
+
const css = require('./css');
|
|
20
|
+
|
|
21
|
+
const EMITTERS = { ts, css };
|
|
22
|
+
|
|
23
|
+
/** Stable, format-appropriate generated-file banner. */
|
|
24
|
+
function markerFor(format) {
|
|
25
|
+
const text = 'baldart-generated from .tokens.json — do not edit (run `baldart tokens build`)';
|
|
26
|
+
if (format === 'css') return `/* ${text} */`;
|
|
27
|
+
return `// ${text}`; // ts / js / any C-style
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Emit one output. Returns { ok, format, content } or { ok:false, error }.
|
|
32
|
+
* Pure — does not touch the filesystem (the caller writes).
|
|
33
|
+
*/
|
|
34
|
+
function emit(format, { resolved, nested }) {
|
|
35
|
+
const fn = EMITTERS[format];
|
|
36
|
+
if (typeof fn !== 'function') {
|
|
37
|
+
return { ok: false, error: `unknown token output format: "${format}" (known: ${Object.keys(EMITTERS).join(', ')})` };
|
|
38
|
+
}
|
|
39
|
+
const marker = markerFor(format);
|
|
40
|
+
try {
|
|
41
|
+
return { ok: true, format, content: fn({ resolved, nested, marker }) };
|
|
42
|
+
} catch (e) {
|
|
43
|
+
return { ok: false, error: `emitter "${format}" failed: ${e.message}` };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
module.exports = { emit, markerFor, knownFormats: () => Object.keys(EMITTERS) };
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// TS emitter: DTCG → a typed `export const tokens = {...} as const;` object.
|
|
2
|
+
//
|
|
3
|
+
// Emits the nested tree of RESOLVED values (refs already collapsed), mirroring
|
|
4
|
+
// the DTCG group structure minus the $type/$value wrappers. Deterministic key
|
|
5
|
+
// order (insertion order of the source) so the output is diff-stable.
|
|
6
|
+
|
|
7
|
+
function literal(value) {
|
|
8
|
+
// Tokens resolve to strings (colors, dimensions, durations) or numbers.
|
|
9
|
+
if (typeof value === 'number') return String(value);
|
|
10
|
+
return JSON.stringify(String(value));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function render(node, indent) {
|
|
14
|
+
const pad = ' '.repeat(indent);
|
|
15
|
+
const padInner = ' '.repeat(indent + 1);
|
|
16
|
+
const keys = Object.keys(node);
|
|
17
|
+
const lines = keys.map((k) => {
|
|
18
|
+
const v = node[k];
|
|
19
|
+
// A safe JS identifier key can be bare; otherwise quote it (e.g. "2").
|
|
20
|
+
const key = /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(k) ? k : JSON.stringify(k);
|
|
21
|
+
if (v && typeof v === 'object') {
|
|
22
|
+
return `${padInner}${key}: ${render(v, indent + 1)},`;
|
|
23
|
+
}
|
|
24
|
+
return `${padInner}${key}: ${literal(v)},`;
|
|
25
|
+
});
|
|
26
|
+
return `{\n${lines.join('\n')}\n${pad}}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
module.exports = function emitTs({ nested, marker }) {
|
|
30
|
+
const body = render(nested, 0);
|
|
31
|
+
return `${marker}\nexport const tokens = ${body} as const;\n\nexport type Tokens = typeof tokens;\n`;
|
|
32
|
+
};
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// Tokens generator — thin orchestrator over the DTCG SSOT (since v4.65.0).
|
|
2
|
+
//
|
|
3
|
+
// DTCG (`.tokens.json`, W3C Design Tokens Community Group: `$type`/`$value`,
|
|
4
|
+
// `{ref}` aliases) is the design-token source of truth. This class reads it,
|
|
5
|
+
// resolves aliases, and fans out to the per-format emitters in
|
|
6
|
+
// `token-emitters/` to write the stack-native artifacts (tokens.ts, CSS vars).
|
|
7
|
+
// Zero npm dependency — DTCG parsing is plain JSON traversal. Mirrors the thin-
|
|
8
|
+
// wrapper shape of `graphify-installer.js` (detect / isStale / build).
|
|
9
|
+
//
|
|
10
|
+
// Authoritative design: framework/docs/COMPONENT-MANIFEST-LAYER.md.
|
|
11
|
+
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const emitters = require('./token-emitters');
|
|
15
|
+
|
|
16
|
+
class TokensGenerator {
|
|
17
|
+
constructor(cwd = process.cwd()) { this.cwd = cwd; }
|
|
18
|
+
|
|
19
|
+
/** True when the DTCG source file exists. */
|
|
20
|
+
detect(source) {
|
|
21
|
+
if (!source) return false;
|
|
22
|
+
try { return fs.statSync(path.join(this.cwd, source)).isFile(); }
|
|
23
|
+
catch (_) { return false; }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Read + JSON.parse the DTCG source. Throws a clear error on bad input. */
|
|
27
|
+
load(source) {
|
|
28
|
+
const abs = path.join(this.cwd, source);
|
|
29
|
+
let raw;
|
|
30
|
+
try { raw = fs.readFileSync(abs, 'utf8'); }
|
|
31
|
+
catch (e) { throw new Error(`cannot read design tokens at ${source}: ${e.message}`); }
|
|
32
|
+
try { return JSON.parse(raw); }
|
|
33
|
+
catch (e) { throw new Error(`${source} is not valid JSON: ${e.message}`); }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Flatten the DTCG tree to leaf tokens. A node is a token when it carries
|
|
38
|
+
* `$value`. `$type` is inherited from the nearest ancestor that declares it.
|
|
39
|
+
* Returns Map<dotId, { type, raw }> in source order.
|
|
40
|
+
*/
|
|
41
|
+
flatten(tree) {
|
|
42
|
+
const out = new Map();
|
|
43
|
+
const walk = (node, prefix, inheritedType) => {
|
|
44
|
+
if (!node || typeof node !== 'object') return;
|
|
45
|
+
const type = Object.prototype.hasOwnProperty.call(node, '$type') ? node.$type : inheritedType;
|
|
46
|
+
if (Object.prototype.hasOwnProperty.call(node, '$value')) {
|
|
47
|
+
out.set(prefix, { type, raw: node.$value });
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
for (const key of Object.keys(node)) {
|
|
51
|
+
if (key.startsWith('$')) continue; // $type / $description / $extensions
|
|
52
|
+
walk(node[key], prefix ? `${prefix}.${key}` : key, type);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
walk(tree, '', undefined);
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Resolve `{a.b.c}` aliases to concrete values (chains + cycle guard).
|
|
61
|
+
* Returns Map<dotId, { type, value }> in the same order as `flat`.
|
|
62
|
+
*/
|
|
63
|
+
resolve(flat) {
|
|
64
|
+
const resolved = new Map();
|
|
65
|
+
const resolveOne = (id, seen) => {
|
|
66
|
+
if (resolved.has(id)) return resolved.get(id);
|
|
67
|
+
const tok = flat.get(id);
|
|
68
|
+
if (!tok) throw new Error(`token reference {${id}} does not resolve to a known token`);
|
|
69
|
+
let value = tok.raw;
|
|
70
|
+
if (typeof value === 'string') {
|
|
71
|
+
const m = value.match(/^\{([^}]+)\}$/);
|
|
72
|
+
if (m) {
|
|
73
|
+
const ref = m[1];
|
|
74
|
+
if (seen.has(ref)) throw new Error(`cyclic token reference: ${[...seen, ref].join(' → ')}`);
|
|
75
|
+
const target = resolveOne(ref, new Set([...seen, id]));
|
|
76
|
+
value = target.value;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const entry = { type: tok.type, value };
|
|
80
|
+
resolved.set(id, entry);
|
|
81
|
+
return entry;
|
|
82
|
+
};
|
|
83
|
+
for (const id of flat.keys()) resolveOne(id, new Set());
|
|
84
|
+
return resolved;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Build the nested object of resolved VALUES (DTCG tree minus $wrappers). */
|
|
88
|
+
nest(resolved) {
|
|
89
|
+
const root = {};
|
|
90
|
+
for (const [id, tok] of resolved) {
|
|
91
|
+
const parts = id.split('.');
|
|
92
|
+
let cur = root;
|
|
93
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
94
|
+
cur[parts[i]] = cur[parts[i]] || {};
|
|
95
|
+
cur = cur[parts[i]];
|
|
96
|
+
}
|
|
97
|
+
cur[parts[parts.length - 1]] = tok.value;
|
|
98
|
+
}
|
|
99
|
+
return root;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Produce { outputs: [{ format, path, content }], resolved } WITHOUT writing.
|
|
104
|
+
* Used by both build() and isStale().
|
|
105
|
+
*/
|
|
106
|
+
render({ source, outputs }) {
|
|
107
|
+
const tree = this.load(source);
|
|
108
|
+
const flat = this.flatten(tree);
|
|
109
|
+
if (flat.size === 0) throw new Error(`${source} contains no DTCG tokens ($value leaves)`);
|
|
110
|
+
const resolved = this.resolve(flat);
|
|
111
|
+
const nested = this.nest(resolved);
|
|
112
|
+
const rendered = [];
|
|
113
|
+
for (const o of outputs || []) {
|
|
114
|
+
const r = emitters.emit(o.format, { resolved, nested });
|
|
115
|
+
if (!r.ok) throw new Error(r.error);
|
|
116
|
+
rendered.push({ format: o.format, path: o.path, content: r.content });
|
|
117
|
+
}
|
|
118
|
+
return { outputs: rendered, resolved };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Build: render + write every output. Returns { ok, written:[paths] } or
|
|
123
|
+
* { ok:false, error }.
|
|
124
|
+
*/
|
|
125
|
+
build({ source, outputs }) {
|
|
126
|
+
let r;
|
|
127
|
+
try { r = this.render({ source, outputs }); }
|
|
128
|
+
catch (e) { return { ok: false, error: e.message }; }
|
|
129
|
+
const written = [];
|
|
130
|
+
for (const o of r.outputs) {
|
|
131
|
+
const abs = path.join(this.cwd, o.path);
|
|
132
|
+
try {
|
|
133
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
134
|
+
fs.writeFileSync(abs, o.content, 'utf8');
|
|
135
|
+
written.push(o.path);
|
|
136
|
+
} catch (e) {
|
|
137
|
+
return { ok: false, error: `failed writing ${o.path}: ${e.message}`, written };
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return { ok: true, written, tokenCount: r.resolved.size };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* isStale: true when any output on disk differs from a fresh render (i.e. a
|
|
145
|
+
* hand-edit to a generated file, or the source changed without a rebuild).
|
|
146
|
+
* Returns { stale, reasons:[{path, reason}] } or { stale:false } / error.
|
|
147
|
+
*/
|
|
148
|
+
isStale({ source, outputs }) {
|
|
149
|
+
if (!this.detect(source)) return { stale: false, sourceMissing: true };
|
|
150
|
+
let r;
|
|
151
|
+
try { r = this.render({ source, outputs }); }
|
|
152
|
+
catch (e) { return { stale: true, error: e.message, reasons: [] }; }
|
|
153
|
+
const reasons = [];
|
|
154
|
+
for (const o of r.outputs) {
|
|
155
|
+
const abs = path.join(this.cwd, o.path);
|
|
156
|
+
let onDisk;
|
|
157
|
+
try { onDisk = fs.readFileSync(abs, 'utf8'); }
|
|
158
|
+
catch (_) { reasons.push({ path: o.path, reason: 'missing' }); continue; }
|
|
159
|
+
if (onDisk !== o.content) reasons.push({ path: o.path, reason: 'out-of-date' });
|
|
160
|
+
}
|
|
161
|
+
return { stale: reasons.length > 0, reasons };
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
module.exports = TokensGenerator;
|