@transtyle/core 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.
- package/package.json +25 -0
- package/src/checks.js +74 -0
- package/src/color.js +272 -0
- package/src/css-colors.js +46 -0
- package/src/derive.js +705 -0
- package/src/diagnostics.js +43 -0
- package/src/diff.js +97 -0
- package/src/index.js +187 -0
- package/src/load.js +136 -0
- package/src/nearest.js +39 -0
- package/src/normalize.js +367 -0
- package/src/schema/config.schema.js +92 -0
- package/src/schema/report.schema.js +65 -0
- package/src/schema/validate.js +93 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** Shared diagnostics collector (docs/specs/validation-and-coverage.md). */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* AL5: two behaviors that exist because of what the tool actually did when a
|
|
5
|
+
* user got something wrong, not because of a design idea.
|
|
6
|
+
*
|
|
7
|
+
* 1. `hint` — a separate, optional "here is what to do" line. Keeping it out of
|
|
8
|
+
* `message` is what makes actionability structural: the message says what is
|
|
9
|
+
* wrong, the hint says what to change, and the CLI renders them distinctly
|
|
10
|
+
* (report.json keeps both fields). Before this, advice that the docs page
|
|
11
|
+
* already gave was simply absent at the point of failure.
|
|
12
|
+
*
|
|
13
|
+
* 2. De-duplication on (severity, code, message). DERIVE and NORMALIZE both run
|
|
14
|
+
* once per mode combo, so a single authoring mistake was reported once per
|
|
15
|
+
* combo — a 2-token alias cycle printed twelve lines. Identical text repeated
|
|
16
|
+
* N times carries no information beyond the first. Anything genuinely
|
|
17
|
+
* per-mode already says so in its message (contrast warnings name the mode),
|
|
18
|
+
* so those still come through separately.
|
|
19
|
+
*/
|
|
20
|
+
export class Diagnostics {
|
|
21
|
+
constructor() {
|
|
22
|
+
this.items = [];
|
|
23
|
+
this._seen = new Set();
|
|
24
|
+
}
|
|
25
|
+
#push(severity, code, message, context) {
|
|
26
|
+
const key = `${severity} ${code} ${message}`;
|
|
27
|
+
if (this._seen.has(key)) return;
|
|
28
|
+
this._seen.add(key);
|
|
29
|
+
this.items.push({ severity, code, message, ...context });
|
|
30
|
+
}
|
|
31
|
+
error(code, message, context = {}) { this.#push('error', code, message, context); }
|
|
32
|
+
warn(code, message, context = {}) { this.#push('warning', code, message, context); }
|
|
33
|
+
info(code, message, context = {}) { this.#push('info', code, message, context); }
|
|
34
|
+
get errors() { return this.items.filter((i) => i.severity === 'error'); }
|
|
35
|
+
get warnings() { return this.items.filter((i) => i.severity === 'warning'); }
|
|
36
|
+
/** Did this code already fire? Used to suppress consequences of a root cause. */
|
|
37
|
+
has(code) { return this.items.some((i) => i.code === code); }
|
|
38
|
+
shouldFail(failOn = 'error') {
|
|
39
|
+
if (failOn === 'error') return this.errors.length > 0;
|
|
40
|
+
if (failOn === 'warning') return this.errors.length > 0 || this.warnings.length > 0;
|
|
41
|
+
return this.errors.length > 0;
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/diff.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Semantic diff of two resolved IRs (docs/specs/diff.md, ROADMAP P6). Pure and
|
|
3
|
+
* deterministic: no I/O, no git — the CLI resolves "before" (a git ref) and
|
|
4
|
+
* "after" (the working tree) via compile() and hands both here.
|
|
5
|
+
*
|
|
6
|
+
* The unit of a design-system change is a **semantic slot value in a mode**, not
|
|
7
|
+
* a token-file line: renaming an option token or restructuring layers that leaves
|
|
8
|
+
* every resolved value identical is correctly reported as no change, because the
|
|
9
|
+
* compiled themes are identical. That is the whole point of diffing the resolved
|
|
10
|
+
* graph rather than the source.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { CONTRAST_PAIRS, contrastThreshold, pairRatio } from './checks.js';
|
|
14
|
+
|
|
15
|
+
/** Canonical, stable string form of a resolved value, for equality only. */
|
|
16
|
+
function canon(value) {
|
|
17
|
+
if (value === null || value === undefined) return String(value);
|
|
18
|
+
if (typeof value !== 'object') return String(value);
|
|
19
|
+
if (Array.isArray(value)) return `[${value.map(canon).join(',')}]`;
|
|
20
|
+
// plain data objects (color components, typography/shadow composites) — sort
|
|
21
|
+
// keys so equality is independent of construction order.
|
|
22
|
+
return `{${Object.keys(value).sort().map((k) => `${k}:${canon(value[k])}`).join(',')}}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const provKind = (entry) => entry?.provenance?.kind ?? null;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @returns {{ modes: Array<{ mode, added: string[], removed: string[],
|
|
29
|
+
* changed: Array<{ slot, before, after, valueChanged, provChanged }> }>,
|
|
30
|
+
* changedSlots: Set<string>, hasChanges: boolean }}
|
|
31
|
+
*/
|
|
32
|
+
export function diffResolved(before, after) {
|
|
33
|
+
const modeNames = [...new Set([...Object.keys(before.modes), ...Object.keys(after.modes)])].sort();
|
|
34
|
+
const modes = [];
|
|
35
|
+
const changedSlots = new Set();
|
|
36
|
+
|
|
37
|
+
for (const mode of modeNames) {
|
|
38
|
+
const b = before.modes[mode] ?? new Map();
|
|
39
|
+
const a = after.modes[mode] ?? new Map();
|
|
40
|
+
const slots = [...new Set([...b.keys(), ...a.keys()])].sort();
|
|
41
|
+
const added = [], removed = [], changed = [];
|
|
42
|
+
|
|
43
|
+
for (const slot of slots) {
|
|
44
|
+
const be = b.get(slot);
|
|
45
|
+
const ae = a.get(slot);
|
|
46
|
+
if (!be && ae) { added.push(slot); changedSlots.add(slot); continue; }
|
|
47
|
+
if (be && !ae) { removed.push(slot); changedSlots.add(slot); continue; }
|
|
48
|
+
const valueChanged = canon(be.value) !== canon(ae.value);
|
|
49
|
+
const provChanged = provKind(be) !== provKind(ae);
|
|
50
|
+
if (valueChanged || provChanged) {
|
|
51
|
+
changed.push({ slot, before: be, after: ae, valueChanged, provChanged });
|
|
52
|
+
changedSlots.add(slot);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
modes.push({ mode, added, removed, changed });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const hasChanges = modes.some((m) => m.added.length || m.removed.length || m.changed.length);
|
|
59
|
+
return { modes, changedSlots, hasChanges };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Accessibility regressions introduced by the change (docs/specs/diff.md).
|
|
64
|
+
*
|
|
65
|
+
* `check` tells you the contrast is bad *now*; this tells you **this change made
|
|
66
|
+
* it bad** — which is the question a reviewer has, and the one a passing-CI
|
|
67
|
+
* baseline can regress on silently. Uses the same pairs and threshold as
|
|
68
|
+
* `runChecks`, so the two can never disagree about what "passing" means.
|
|
69
|
+
*
|
|
70
|
+
* Severities, worst first:
|
|
71
|
+
* `regressed` — passed the standard before, fails now (the headline case)
|
|
72
|
+
* `worsened` — already failing, and the ratio dropped further
|
|
73
|
+
*
|
|
74
|
+
* A pair that improves, or that fails identically on both sides, is not reported.
|
|
75
|
+
*
|
|
76
|
+
* @returns {Array<{ mode, fg, bg, before: number, after: number, status, threshold }>}
|
|
77
|
+
*/
|
|
78
|
+
export function contrastRegressions(before, after, config) {
|
|
79
|
+
const threshold = contrastThreshold(config);
|
|
80
|
+
const out = [];
|
|
81
|
+
for (const mode of Object.keys(after.modes)) {
|
|
82
|
+
const bMap = before.modes[mode];
|
|
83
|
+
const aMap = after.modes[mode];
|
|
84
|
+
if (!bMap || !aMap) continue; // mode added or removed — not a regression
|
|
85
|
+
for (const [fg, bg] of CONTRAST_PAIRS) {
|
|
86
|
+
const b = pairRatio(bMap, fg, bg);
|
|
87
|
+
const a = pairRatio(aMap, fg, bg);
|
|
88
|
+
if (b === null || a === null) continue;
|
|
89
|
+
if (b >= threshold && a < threshold) {
|
|
90
|
+
out.push({ mode, fg, bg, before: b, after: a, status: 'regressed', threshold });
|
|
91
|
+
} else if (b < threshold && a < threshold && a < b - 0.05) {
|
|
92
|
+
out.push({ mode, fg, bg, before: b, after: a, status: 'worsened', threshold });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @transtyle/core — public programmatic API (docs/architecture/overview.md:
|
|
3
|
+
* "core is a library first, CLI second").
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
8
|
+
import { loadConfig, loadTokenTrees } from './load.js';
|
|
9
|
+
import { validate } from './schema/validate.js';
|
|
10
|
+
import { configSchema } from './schema/config.schema.js';
|
|
11
|
+
import { normalize, resolveDeferredAliases, reportModeCarryOver } from './normalize.js';
|
|
12
|
+
import { derive } from './derive.js';
|
|
13
|
+
import { runChecks } from './checks.js';
|
|
14
|
+
import { Diagnostics } from './diagnostics.js';
|
|
15
|
+
import { nearestName } from './nearest.js';
|
|
16
|
+
import { formatColor, formatHslTriplet, formatHex, contrastRatio, mix } from './color.js';
|
|
17
|
+
|
|
18
|
+
export { formatColor, formatHslTriplet, formatHex, contrastRatio, mix } from './color.js';
|
|
19
|
+
export { Diagnostics } from './diagnostics.js';
|
|
20
|
+
export { diffResolved, contrastRegressions } from './diff.js';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Run the pipeline. `emit: false` = `transtyle check` (pipeline minus EMIT —
|
|
24
|
+
* same code path by design, docs/architecture/pipeline.md).
|
|
25
|
+
*/
|
|
26
|
+
/**
|
|
27
|
+
* `knownExporters` (AL5) is the caller's list of exporter names it can resolve —
|
|
28
|
+
* the CLI's OFFICIAL_EXPORTERS keys. Core stays exporter-agnostic (it never
|
|
29
|
+
* imports one), but TST1301 can then tell "you typo'd" apart from "that
|
|
30
|
+
* exporter exists, you just haven't configured it", which are opposite fixes.
|
|
31
|
+
*/
|
|
32
|
+
export async function compile({ cwd, targets, emit = true, loadExporter, knownExporters = [] }) {
|
|
33
|
+
const diagnostics = new Diagnostics();
|
|
34
|
+
const { config } = await loadConfig(cwd);
|
|
35
|
+
|
|
36
|
+
// Config schema validation (audit A8): a typo'd or mis-typed config key is an
|
|
37
|
+
// error, not a silently-ignored field. Fail before touching tokens — a broken
|
|
38
|
+
// config shape would only produce misleading downstream diagnostics.
|
|
39
|
+
for (const { path: p, message } of validate(config, configSchema)) {
|
|
40
|
+
diagnostics.error('TST1010', `transtyle.config.json: ${p === '(root)' ? '' : p + ' '}${message}`);
|
|
41
|
+
}
|
|
42
|
+
if (diagnostics.errors.length > 0) {
|
|
43
|
+
return { config, diagnostics, results: [], normalized: null };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// LOAD + NORMALIZE + DERIVE (shared across targets)
|
|
47
|
+
const trees = await loadTokenTrees(cwd, config.tokens, diagnostics);
|
|
48
|
+
const normalized = normalize(trees, config, diagnostics);
|
|
49
|
+
derive(normalized, config, diagnostics);
|
|
50
|
+
// Authored aliases pointing at slots DERIVE materializes (e.g. a component
|
|
51
|
+
// token aliasing `{semantic.radius.full}`) resolve here — see normalize.js.
|
|
52
|
+
resolveDeferredAliases(normalized, diagnostics);
|
|
53
|
+
|
|
54
|
+
// TST1204 (cross-mode carry-over) can only be judged once every alias has a
|
|
55
|
+
// value: the slot's own text is identical in both modes when the per-mode
|
|
56
|
+
// value lives on the alias target, which is exactly how the binding-layer
|
|
57
|
+
// adoption pattern works. See reportModeCarryOver.
|
|
58
|
+
reportModeCarryOver(normalized, config, diagnostics);
|
|
59
|
+
|
|
60
|
+
// The engine's one non-negotiable input (AL5 — see derive.js for why it moved
|
|
61
|
+
// here). Checked after every alias has had its chance to resolve, and only
|
|
62
|
+
// when nothing upstream already explains the absence: a dangling alias or an
|
|
63
|
+
// unparseable color makes this token missing as a *consequence*, and reporting
|
|
64
|
+
// both sends the user to fix the symptom.
|
|
65
|
+
const primaryMissing = Object.values(normalized.modes).some(
|
|
66
|
+
(m) => m.get('semantic.color.primary.solid')?.value === undefined,
|
|
67
|
+
);
|
|
68
|
+
const upstream = ['TST1002', 'TST1104', 'TST1105', 'TST1106'].some((c) => diagnostics.has(c));
|
|
69
|
+
if (primaryMissing && !upstream) {
|
|
70
|
+
diagnostics.error(
|
|
71
|
+
'TST1201',
|
|
72
|
+
'semantic.color.primary.solid is not authored — it is the one token the derivation engine cannot invent.',
|
|
73
|
+
{
|
|
74
|
+
// The old text blamed `config derivation.require`, which most configs
|
|
75
|
+
// (including `transtyle init`'s own scaffold) never set. It is an engine
|
|
76
|
+
// invariant, not a consequence of configuration.
|
|
77
|
+
hint: 'Author it as `semantic.color.primary.solid` (your brand color). A bare `semantic.color.primary` is a different path — the role grid anchors on the `.solid` cell.',
|
|
78
|
+
},
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
runChecks(normalized, config, diagnostics);
|
|
83
|
+
|
|
84
|
+
// derivation.require: listed slots must be authored, not derived. Color
|
|
85
|
+
// roles require their `.solid` anchor cell (the role grid's authored anchor,
|
|
86
|
+
// was `.base` pre-revision); other requires (e.g. radius.md) are bare paths.
|
|
87
|
+
for (const req of config.derivation?.require ?? []) {
|
|
88
|
+
const kind = normalized.modes[normalized.defaultMode].get(`${req}.solid`)?.provenance.kind
|
|
89
|
+
?? normalized.modes[normalized.defaultMode].get(req)?.provenance.kind;
|
|
90
|
+
if (kind === 'derived' || kind === undefined) {
|
|
91
|
+
diagnostics.error('TST1202', `Required token is not authored: ${req}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const targetNames = targets?.length ? targets : Object.keys(config.targets ?? {});
|
|
96
|
+
const results = [];
|
|
97
|
+
|
|
98
|
+
for (const name of targetNames) {
|
|
99
|
+
const targetConfig = config.targets?.[name];
|
|
100
|
+
if (!targetConfig) {
|
|
101
|
+
// AL5: two different mistakes reached the same dead-end message. A typo
|
|
102
|
+
// needs the near name; a correctly-spelled exporter that simply isn't in
|
|
103
|
+
// this config needs to be told to add it. Neither is "check instance
|
|
104
|
+
// names" with the names withheld.
|
|
105
|
+
const configured = Object.keys(config.targets ?? {});
|
|
106
|
+
const near = nearestName(name, configured);
|
|
107
|
+
const known = knownExporters.includes(name);
|
|
108
|
+
diagnostics.error(
|
|
109
|
+
'TST1301',
|
|
110
|
+
`Target "${name}" is not configured in transtyle.config.json`,
|
|
111
|
+
{
|
|
112
|
+
hint: near
|
|
113
|
+
? `Did you mean "${near}"? Configured targets: ${configured.join(', ') || '(none)'}`
|
|
114
|
+
: known
|
|
115
|
+
? `"${name}" is a known exporter but this config doesn't use it — add it under "targets" with an "output" directory.`
|
|
116
|
+
: `Configured targets: ${configured.join(', ') || '(none)'}`,
|
|
117
|
+
},
|
|
118
|
+
);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (diagnostics.errors.length > 0) break; // never emit with errors present
|
|
122
|
+
|
|
123
|
+
// Target instances: the config key is the instance name; `exporter` selects
|
|
124
|
+
// the plugin (defaults to the key), so one exporter can be configured twice
|
|
125
|
+
// with different options (docs/specs/configuration.md#target-instances).
|
|
126
|
+
const exporter = await loadExporter(targetConfig.exporter ?? name);
|
|
127
|
+
|
|
128
|
+
// Validate this instance's options against the exporter's own schema (audit
|
|
129
|
+
// A8): unknown or mis-typed options are errors. Exporters without options
|
|
130
|
+
// reject any options object; exporters with options declare `optionsSchema`.
|
|
131
|
+
if (targetConfig.options !== undefined) {
|
|
132
|
+
const schema = exporter.optionsSchema ?? { type: 'object', additionalProperties: false };
|
|
133
|
+
for (const { path: p, message } of validate(targetConfig.options, schema)) {
|
|
134
|
+
diagnostics.error('TST1011', `target "${name}" options: ${p === '(root)' ? '' : p + ' '}${message}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (diagnostics.errors.length > 0) break; // don't emit with invalid options
|
|
138
|
+
|
|
139
|
+
// RESOLVE + EMIT: exporter returns file descriptions; only core touches the filesystem.
|
|
140
|
+
const ctx = {
|
|
141
|
+
config, targetConfig, formatColor, formatHslTriplet, formatHex, contrastRatio, mix,
|
|
142
|
+
projectName: config.name ?? 'design-system',
|
|
143
|
+
// Sibling-target manifest (docs/specs/exporters/storybook.md#composition):
|
|
144
|
+
// name, exporter, and output dir of every configured target — never their
|
|
145
|
+
// resolutions. Lets composition-capable exporters reference sibling
|
|
146
|
+
// ARTIFACT PATHS, keeping the no-cross-target-coupling invariant.
|
|
147
|
+
siblings: Object.entries(config.targets ?? {}).map(([n, t]) => ({
|
|
148
|
+
name: n, exporter: t.exporter ?? n, output: t.output ?? `dist/${n}`,
|
|
149
|
+
})),
|
|
150
|
+
};
|
|
151
|
+
const { files, coverage } = exporter.emit(normalized, ctx);
|
|
152
|
+
|
|
153
|
+
const outDir = path.resolve(cwd, targetConfig.output ?? `dist/${name}`);
|
|
154
|
+
const written = [];
|
|
155
|
+
if (emit) {
|
|
156
|
+
await mkdir(outDir, { recursive: true });
|
|
157
|
+
for (const f of files) {
|
|
158
|
+
await writeFile(path.join(outDir, f.path), f.contents, 'utf8');
|
|
159
|
+
written.push(path.relative(cwd, path.join(outDir, f.path)));
|
|
160
|
+
}
|
|
161
|
+
// Build manifest + machine-readable report (docs/specs/validation-and-coverage.md)
|
|
162
|
+
const report = buildReport(name, targetConfig, coverage, diagnostics, written);
|
|
163
|
+
await writeFile(path.join(outDir, 'report.json'), JSON.stringify(report, null, 2) + '\n', 'utf8');
|
|
164
|
+
written.push(path.relative(cwd, path.join(outDir, 'report.json')));
|
|
165
|
+
}
|
|
166
|
+
// `emitted` carries the file *specs* (path + contents) even when emit is
|
|
167
|
+
// off — `transtyle diff` re-emits both sides in-memory to compute per-target
|
|
168
|
+
// impact without writing anything. `files` stays the written paths.
|
|
169
|
+
results.push({ target: name, files: written, coverage, emitted: files });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return { config, diagnostics, results, normalized };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function buildReport(target, targetConfig, coverage, diagnostics, files) {
|
|
176
|
+
const counts = {};
|
|
177
|
+
for (const item of coverage) counts[item.class] = (counts[item.class] ?? 0) + 1;
|
|
178
|
+
return {
|
|
179
|
+
$schema: 'https://transtyle.dev/schemas/report/v0.json',
|
|
180
|
+
target,
|
|
181
|
+
options: targetConfig.options ?? {},
|
|
182
|
+
generatedBy: 'transtyle 0.1.0 (walking skeleton)',
|
|
183
|
+
coverage: { counts, items: coverage },
|
|
184
|
+
diagnostics: diagnostics.items,
|
|
185
|
+
files,
|
|
186
|
+
};
|
|
187
|
+
}
|
package/src/load.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/** LOAD stage: config discovery + token file reading (docs/architecture/pipeline.md#1-load). */
|
|
2
|
+
|
|
3
|
+
import { readFile, readdir } from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
export async function loadConfig(cwd) {
|
|
7
|
+
const file = path.join(cwd, 'transtyle.config.json');
|
|
8
|
+
let raw;
|
|
9
|
+
try {
|
|
10
|
+
raw = await readFile(file, 'utf8');
|
|
11
|
+
} catch {
|
|
12
|
+
throw new Error(`No transtyle.config.json found in ${cwd}`);
|
|
13
|
+
}
|
|
14
|
+
const config = JSON.parse(raw);
|
|
15
|
+
if (!config.tokens?.length) throw new Error('Config error: "tokens" must list at least one glob.');
|
|
16
|
+
return { config, configPath: file };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Minimal glob: supports literal paths and single-`*` segments (e.g. "tokens/*.tokens.json"). */
|
|
20
|
+
async function expandGlob(cwd, pattern) {
|
|
21
|
+
if (pattern.includes('**')) throw new Error(`Skeleton glob does not support "**": ${pattern}`);
|
|
22
|
+
const segs = pattern.split('/');
|
|
23
|
+
let paths = [cwd];
|
|
24
|
+
for (const seg of segs) {
|
|
25
|
+
const next = [];
|
|
26
|
+
for (const p of paths) {
|
|
27
|
+
if (seg.includes('*')) {
|
|
28
|
+
const re = new RegExp('^' + seg.split('*').map(escapeRe).join('.*') + '$');
|
|
29
|
+
let entries = [];
|
|
30
|
+
try { entries = await readdir(p, { withFileTypes: true }); } catch { /* missing dir */ }
|
|
31
|
+
for (const e of entries) if (re.test(e.name)) next.push(path.join(p, e.name));
|
|
32
|
+
} else {
|
|
33
|
+
next.push(path.join(p, seg));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
paths = next;
|
|
37
|
+
}
|
|
38
|
+
return paths.sort(); // deterministic order
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Token entries are strings (globs) or objects `{ files, mode }` — the latter
|
|
45
|
+
* declares a mode-scoped layer: a pure DTCG file whose values apply to one
|
|
46
|
+
* mode of one dimension (docs/specs/configuration.md#token-layering).
|
|
47
|
+
*/
|
|
48
|
+
export async function loadTokenTrees(cwd, entries, diagnostics) {
|
|
49
|
+
const trees = [];
|
|
50
|
+
const seenExtensionNamespaces = new Set(); // compile-wide, so TST1304 fires once per namespace, not once per file
|
|
51
|
+
for (const entry of entries) {
|
|
52
|
+
const globs = typeof entry === 'string' ? [entry] : [].concat(entry.files);
|
|
53
|
+
const modeScope = typeof entry === 'string' ? undefined : entry.mode;
|
|
54
|
+
for (const g of globs) {
|
|
55
|
+
const files = await expandGlob(cwd, g);
|
|
56
|
+
if (files.length === 0)
|
|
57
|
+
diagnostics.warn('TST1001', `Token glob matched no files: ${g}`, {
|
|
58
|
+
// AL5: this is usually the whole story behind every error that
|
|
59
|
+
// follows, so it should be the one that tells you where it looked.
|
|
60
|
+
hint: `Resolved relative to ${cwd}. Check the path in "tokens" in transtyle.config.json.`,
|
|
61
|
+
});
|
|
62
|
+
for (const f of files) {
|
|
63
|
+
try {
|
|
64
|
+
const tree = JSON.parse(await readFile(f, 'utf8'));
|
|
65
|
+
const rel = path.relative(cwd, f);
|
|
66
|
+
validateTokenTree(tree, rel, diagnostics, seenExtensionNamespaces);
|
|
67
|
+
trees.push({ file: rel, tree, modeScope });
|
|
68
|
+
} catch (e) {
|
|
69
|
+
// Relative path (AL5): an absolute one buries the filename that
|
|
70
|
+
// matters at the end of a long, uninformative prefix.
|
|
71
|
+
diagnostics.error('TST1002', `Failed to parse ${path.relative(cwd, f)}: ${e.message}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return trees;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---------- structural DTCG validation (T10, docs/specs/validation-and-coverage.md) ----------
|
|
80
|
+
|
|
81
|
+
/** The DTCG $type set this IR understands (docs/architecture/ir.md#foundation-dtcg-superset). */
|
|
82
|
+
const DTCG_TYPES = new Set([
|
|
83
|
+
'color', 'dimension', 'fontFamily', 'fontWeight', 'duration', 'cubicBezier', 'number',
|
|
84
|
+
'typography', 'shadow', 'border', 'gradient', 'transition', 'strokeStyle',
|
|
85
|
+
]);
|
|
86
|
+
/** The three-tier token model (docs/architecture/ir.md#the-three-tier-token-model). */
|
|
87
|
+
const TIERS = new Set(['option', 'semantic', 'component']);
|
|
88
|
+
/** Transtyle's own reserved `$extensions` namespaces (proposal 0001 §4.4) — anything else is foreign. */
|
|
89
|
+
const KNOWN_EXTENSION_NAMESPACES = new Set(['transtyle.modes', 'transtyle.role', 'transtyle.state-mechanism']);
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Catches authoring mistakes `collectTokens()`'s permissive walk would
|
|
93
|
+
* otherwise silently swallow: a top-level group outside the three tiers, a
|
|
94
|
+
* node that clearly meant to be a token but has no `$value`, an unrecognized
|
|
95
|
+
* `$type` (still carried, just opaque to derivation), and foreign
|
|
96
|
+
* `$extensions` namespaces (carried through untouched, surfaced once).
|
|
97
|
+
* Runs per loaded file, before merging — `seenNamespaces` is shared across
|
|
98
|
+
* the whole `loadTokenTrees()` call so TST1304 fires once per compile.
|
|
99
|
+
*/
|
|
100
|
+
export function validateTokenTree(tree, file, diagnostics, seenNamespaces = new Set()) {
|
|
101
|
+
for (const key of Object.keys(tree)) {
|
|
102
|
+
if (key.startsWith('$')) continue;
|
|
103
|
+
if (!TIERS.has(key)) {
|
|
104
|
+
diagnostics.warn('TST1305', `${file}: top-level group "${key}" is not option/semantic/component`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const walk = (node, path_) => {
|
|
108
|
+
if (node === null || typeof node !== 'object' || Array.isArray(node)) return;
|
|
109
|
+
const localType = node.$type;
|
|
110
|
+
if (node.$extensions && typeof node.$extensions === 'object') {
|
|
111
|
+
for (const ns of Object.keys(node.$extensions)) {
|
|
112
|
+
if (!KNOWN_EXTENSION_NAMESPACES.has(ns) && !seenNamespaces.has(ns)) {
|
|
113
|
+
seenNamespaces.add(ns);
|
|
114
|
+
diagnostics.info('TST1304', `${file}: foreign $extensions namespace "${ns}" carried through untouched (not a transtyle namespace)`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const hasValue = '$value' in node;
|
|
119
|
+
const childKeys = Object.keys(node).filter((k) => !k.startsWith('$'));
|
|
120
|
+
if (!hasValue && childKeys.length === 0 && localType !== undefined) {
|
|
121
|
+
diagnostics.error('TST1302', `${path_.join('.')}: declares $type "${localType}" but has neither $value nor child tokens`);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (hasValue) {
|
|
125
|
+
if (localType !== undefined && !DTCG_TYPES.has(localType)) {
|
|
126
|
+
diagnostics.warn('TST1306', `${path_.join('.')}: unknown $type "${localType}" — carried through opaque (no type-specific parsing or derivation)`);
|
|
127
|
+
}
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
for (const [key, child] of Object.entries(node)) {
|
|
131
|
+
if (key.startsWith('$')) continue;
|
|
132
|
+
walk(child, [...path_, key]);
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
walk(tree, []);
|
|
136
|
+
}
|
package/src/nearest.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Did you mean …?" — shared by config validation (unknown key), target
|
|
3
|
+
* resolution (unknown target instance), and the CLI's `explain` (unknown slot).
|
|
4
|
+
*
|
|
5
|
+
* AL5 rationale: every one of those three errors used to end at "X is not
|
|
6
|
+
* valid" while the code was holding the list of things that ARE valid. A typo
|
|
7
|
+
* is the most common way each is reached, so the suggestion is not a nicety —
|
|
8
|
+
* it is usually the entire fix.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Classic Levenshtein edit distance. */
|
|
12
|
+
export function levenshtein(a, b) {
|
|
13
|
+
const dp = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
|
|
14
|
+
for (let j = 0; j <= b.length; j++) dp[0][j] = j;
|
|
15
|
+
for (let i = 1; i <= a.length; i++) {
|
|
16
|
+
for (let j = 1; j <= b.length; j++) {
|
|
17
|
+
dp[i][j] = a[i - 1] === b[j - 1]
|
|
18
|
+
? dp[i - 1][j - 1]
|
|
19
|
+
: 1 + Math.min(dp[i - 1][j - 1], dp[i - 1][j], dp[i][j - 1]);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return dp[a.length][b.length];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The closest candidate to `name`, or null when nothing is close enough.
|
|
27
|
+
* The threshold scales with the name's length (a third of it, at least 1, at
|
|
28
|
+
* most 3) so short names don't match everything and long ones still tolerate a
|
|
29
|
+
* couple of slips — guessing wildly is worse than not guessing.
|
|
30
|
+
*/
|
|
31
|
+
export function nearestName(name, candidates) {
|
|
32
|
+
const limit = Math.max(1, Math.min(3, Math.floor(name.length / 3)));
|
|
33
|
+
let best = null;
|
|
34
|
+
for (const c of candidates) {
|
|
35
|
+
const d = levenshtein(name, c);
|
|
36
|
+
if (d <= limit && (!best || d < best.d)) best = { c, d };
|
|
37
|
+
}
|
|
38
|
+
return best?.c ?? null;
|
|
39
|
+
}
|