@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
package/src/normalize.js
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NORMALIZE stage: canonical per-mode IR with alias resolution and provenance
|
|
3
|
+
* (docs/architecture/pipeline.md#2-normalize).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { collectTokens, collectRoleArchetypes, mergeTrees, aliasTarget, comboKey, expandModeMatrix, PROVENANCE, COLOR_ROLES } from '@transtyle/ir';
|
|
7
|
+
import { parseColor } from './color.js';
|
|
8
|
+
|
|
9
|
+
/** Matches `semantic.color.<role>.solid` — the anchor cell an entire role grid
|
|
10
|
+
* (hover/active/tint/outline/on-colors, ~16 slots) fans out from. */
|
|
11
|
+
const ROLE_SOLID = /^semantic\.color\.([\w-]+)\.solid$/;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @returns {{ modes: Record<string, Map<string, Entry>>, modeDimension: string }}
|
|
15
|
+
* Entry = { type, value, provenance }
|
|
16
|
+
* Color values are parsed to { l, c, h, alpha }; other types kept as authored.
|
|
17
|
+
*
|
|
18
|
+
* Multi-dimension modes (T8, docs/architecture/ir.md#modes): every configured
|
|
19
|
+
* dimension is resolved independently, then combos are the cross-product,
|
|
20
|
+
* keyed `dim1val+dim2val+...` (dimension-declaration order). Most exporters
|
|
21
|
+
* only know about the *first* declared dimension (conventionally
|
|
22
|
+
* `color-scheme`) — they've always read `normalized.modes.light`/`.dark`, so
|
|
23
|
+
* those single-dimension-value keys stay as aliases into the combo whose
|
|
24
|
+
* every OTHER dimension sits at its own default. This is the whole back-compat
|
|
25
|
+
* story: a single-dimension config (today's Acme/Cathode) degenerates to
|
|
26
|
+
* exactly the old behavior (combo keys equal old mode names 1:1).
|
|
27
|
+
*/
|
|
28
|
+
export function normalize(tokenTrees, config, diagnostics) {
|
|
29
|
+
const dimEntries = Object.entries(config.modes ?? {});
|
|
30
|
+
if (dimEntries.length === 0) dimEntries.push(['color-scheme', { values: ['light'], default: 'light' }]);
|
|
31
|
+
const dimDefaults = new Map(dimEntries);
|
|
32
|
+
const primaryDimName = dimEntries[0][0];
|
|
33
|
+
|
|
34
|
+
// AL5 mode-shape sweep: the FIRST dimension is the polarity axis — derive.js
|
|
35
|
+
// reads dark/light off it (`isDark` keys on `modeDimension`), and exporters
|
|
36
|
+
// bind the `modes.light`/`modes.dark` aliases, which only exist for the
|
|
37
|
+
// primary dimension's values. So `color-scheme` declared anywhere but first
|
|
38
|
+
// silently drops dark mode: the authored dark values still land in their
|
|
39
|
+
// combos, but no exporter can reach them, and nothing warned. Found by
|
|
40
|
+
// compiling a density-first config against every exporter — all emitted a
|
|
41
|
+
// dark block filled with light values.
|
|
42
|
+
//
|
|
43
|
+
// This is an ERROR, not a warning: the output is guaranteed wrong (a dark
|
|
44
|
+
// block filled with light values), and a warning ships that under the default
|
|
45
|
+
// `failOn: error`. There is no coherent "make it work" fix — even a corrected
|
|
46
|
+
// alias would leave `isDark` false for a non-primary color-scheme, so derived
|
|
47
|
+
// dark values compute as light. Reordering `modes` is the only fix, so the
|
|
48
|
+
// build must stop. Gated on `color-scheme` carrying more than one value: with
|
|
49
|
+
// a single value there is no non-default scheme to drop, so nothing is wrong
|
|
50
|
+
// and erroring would be a false failure.
|
|
51
|
+
if (
|
|
52
|
+
dimEntries.length > 1 &&
|
|
53
|
+
dimDefaults.has('color-scheme') &&
|
|
54
|
+
dimDefaults.get('color-scheme').values.length > 1 &&
|
|
55
|
+
primaryDimName !== 'color-scheme'
|
|
56
|
+
) {
|
|
57
|
+
diagnostics.error(
|
|
58
|
+
'TST1112',
|
|
59
|
+
`"color-scheme" is declared but "${primaryDimName}" is the first mode dimension — light/dark is bound to the first dimension, so this design system's dark mode will not reach any exporter.`,
|
|
60
|
+
{ hint: 'List "color-scheme" first in `modes`. Only the first dimension carries light/dark polarity; the others are extra axes exporters mostly drop.' },
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Base layers merge into the token forest; mode-scoped layers inject values
|
|
65
|
+
// into the same modeValues structure that inline $extensions produce — the
|
|
66
|
+
// two authoring forms are equivalent by construction (ADR-0009).
|
|
67
|
+
const merged = mergeTrees(
|
|
68
|
+
tokenTrees.filter((t) => !t.modeScope).map((t) => t.tree),
|
|
69
|
+
(p) => diagnostics.warn('TST1103', `Token defined more than once (last wins): ${p}`),
|
|
70
|
+
);
|
|
71
|
+
const raw = collectTokens(merged);
|
|
72
|
+
const roleArchetypes = collectRoleArchetypes(merged, diagnostics);
|
|
73
|
+
|
|
74
|
+
for (const layer of tokenTrees.filter((t) => t.modeScope)) {
|
|
75
|
+
const scopeEntries = Object.entries(layer.modeScope);
|
|
76
|
+
if (scopeEntries.length !== 1) {
|
|
77
|
+
diagnostics.error('TST1110', `${layer.file}: a mode-scoped layer must target exactly one dimension`);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const [scopeDim, scopeMode] = scopeEntries[0];
|
|
81
|
+
if (!config.modes?.[scopeDim]?.values.includes(scopeMode)) {
|
|
82
|
+
diagnostics.error('TST1109', `${layer.file}: unknown mode "${scopeDim}: ${scopeMode}" (not declared in config.modes)`);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
for (const [tokenPath, tok] of collectTokens(layer.tree)) {
|
|
86
|
+
const base = raw.get(tokenPath);
|
|
87
|
+
if (!base) {
|
|
88
|
+
diagnostics.warn('TST1107', `${layer.file}: mode value for unknown token "${tokenPath}" (no default-mode value exists) — skipped`);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
base.modeValues[scopeDim] ??= {};
|
|
92
|
+
if (base.modeValues[scopeDim][scopeMode] !== undefined) {
|
|
93
|
+
diagnostics.warn('TST1108', `${tokenPath}: ${scopeDim}=${scopeMode} value overridden by later layer ${layer.file}`);
|
|
94
|
+
}
|
|
95
|
+
base.modeValues[scopeDim][scopeMode] = tok.value;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// `autoDark` reclassifies a cross-mode carry-over's provenance (see
|
|
100
|
+
// reportModeCarryOver below for the diagnostic half, and the note there on
|
|
101
|
+
// why it can only be judged after aliases resolve).
|
|
102
|
+
const autoDark = Boolean(config?.derivation?.autoDark);
|
|
103
|
+
|
|
104
|
+
const combos = expandModeMatrix(dimEntries);
|
|
105
|
+
const modes = {};
|
|
106
|
+
const comboDims = {};
|
|
107
|
+
for (const { key, values } of combos) {
|
|
108
|
+
const map = new Map();
|
|
109
|
+
for (const [tokenPath, tok] of raw) {
|
|
110
|
+
// Per-dimension resolution, applied independently and left-to-right
|
|
111
|
+
// (docs/architecture/ir.md#modes "resolved per-dimension independently"):
|
|
112
|
+
// a token overriding on more than one non-default dimension at once is
|
|
113
|
+
// the rare pathological pair the spec defers; last dimension wins there.
|
|
114
|
+
let value = tok.value;
|
|
115
|
+
let overriddenMode = null;
|
|
116
|
+
let autoDarkCarried = false;
|
|
117
|
+
for (const [dimName] of dimEntries) {
|
|
118
|
+
const v = values[dimName];
|
|
119
|
+
if (v === dimDefaults.get(dimName).default) continue;
|
|
120
|
+
const override = tok.modeValues?.[dimName]?.[v];
|
|
121
|
+
if (override !== undefined) { value = override; overriddenMode = `${dimName}=${v}`; }
|
|
122
|
+
else if (dimName === 'color-scheme' && autoDark) {
|
|
123
|
+
const role = tokenPath.match(ROLE_SOLID)?.[1];
|
|
124
|
+
if (role && (COLOR_ROLES.includes(role) || roleArchetypes.has(role))) autoDarkCarried = true;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
map.set(tokenPath, {
|
|
128
|
+
type: tok.type,
|
|
129
|
+
rawValue: value,
|
|
130
|
+
provenance: {
|
|
131
|
+
kind: autoDarkCarried ? PROVENANCE.DERIVED : PROVENANCE.AUTHORED,
|
|
132
|
+
mode: overriddenMode ?? key,
|
|
133
|
+
...(autoDarkCarried ? { rule: 'auto-dark-carry(constant)@standard@1' } : {}),
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
// Resolve aliases with cycle detection, then parse values.
|
|
138
|
+
for (const tokenPath of map.keys()) resolveEntry(map, tokenPath, [], diagnostics);
|
|
139
|
+
modes[key] = map;
|
|
140
|
+
comboDims[key] = values;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Back-compat aliases: `modes.light` / `modes.dark` (or whatever the first
|
|
144
|
+
// dimension's values are) point at the combo where every OTHER dimension
|
|
145
|
+
// sits at ITS OWN default — exactly what every pre-T8 exporter means.
|
|
146
|
+
const otherDefaults = Object.fromEntries(dimEntries.slice(1).map(([n, d]) => [n, d.default]));
|
|
147
|
+
const dimNames = dimEntries.map(([n]) => n);
|
|
148
|
+
for (const v of dimDefaults.get(primaryDimName).values) {
|
|
149
|
+
modes[v] = modes[comboKey(dimNames, { [primaryDimName]: v, ...otherDefaults })];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
modes,
|
|
154
|
+
modeDimension: primaryDimName,
|
|
155
|
+
defaultMode: dimDefaults.get(primaryDimName).default,
|
|
156
|
+
modeValues: dimDefaults.get(primaryDimName).values,
|
|
157
|
+
dimensions: Object.fromEntries(dimEntries),
|
|
158
|
+
dimensionNames: dimNames,
|
|
159
|
+
comboDims,
|
|
160
|
+
allCombos: combos.map((c) => c.key),
|
|
161
|
+
roleArchetypes,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Structural equality for resolved values (colors are `{l,c,h,alpha}`). */
|
|
166
|
+
const sameValue = (a, b) =>
|
|
167
|
+
a === b || (a !== null && b !== null && typeof a === 'object' && typeof b === 'object' && JSON.stringify(a) === JSON.stringify(b));
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* TST1204: a role's `.solid` anchor drives its whole grid (~16 derived slots —
|
|
171
|
+
* hover/active/tint/outline/on-colors), so when the default-mode color reaches
|
|
172
|
+
* a non-default `color-scheme` value unchanged, the ENTIRE grid is that scheme's
|
|
173
|
+
* theme. Not an absence (TST1201/1203 cover that): a value that's present, looks
|
|
174
|
+
* complete, and never changed. Documented, default behavior (`diagnostics.md`,
|
|
175
|
+
* "My brand color is identical in dark mode") — `info`, not a mistake — but
|
|
176
|
+
* nothing surfaced it except the docs page.
|
|
177
|
+
*
|
|
178
|
+
* **Runs after aliases resolve, and compares resolved values.** It used to run
|
|
179
|
+
* inside NORMALIZE's per-mode loop, testing only whether the catalog slot itself
|
|
180
|
+
* carried a mode override — which is false for every design system adopted the
|
|
181
|
+
* way this project recommends. Carbon binds `semantic.color.danger.solid` to
|
|
182
|
+
* `{semantic.color.carbon.support-error}`, and it is the *alias target* that
|
|
183
|
+
* carries the dark value: the alias string is identical in both modes, so the
|
|
184
|
+
* old check called it a silent carry-over while the emitted dark theme was in
|
|
185
|
+
* fact a different red. Four of Carbon's seven notes were wrong that way. The
|
|
186
|
+
* condition is now both halves — no per-mode value authored ON the slot (so an
|
|
187
|
+
* explicit, deliberately identical dark value stays silent) AND the resolved
|
|
188
|
+
* colors actually being equal.
|
|
189
|
+
*
|
|
190
|
+
* Reported once per (role, scheme value) regardless of how many OTHER dimensions
|
|
191
|
+
* multiply the combos sharing that fact (`dark+comfortable` and `dark+compact`
|
|
192
|
+
* are one carry-over, not two). Fires whether or not `autoDark` is on: autoDark
|
|
193
|
+
* does not compute a distinct color for this slot (docs/exercises/
|
|
194
|
+
* phase0-shadcn.md F7 — `darkBrandAdjust` is a still-open research question),
|
|
195
|
+
* so the color genuinely is the carried-over one either way. What autoDark
|
|
196
|
+
* changes is provenance: without it the carry-over is misclassified `authored`;
|
|
197
|
+
* with it it's `derived`, so `report.json` shows synthetic dark-theme coverage
|
|
198
|
+
* honestly. (That reclassification only reaches non-aliased slots — an aliased
|
|
199
|
+
* carry-over stays `aliased`, which is accurate about where the value came from
|
|
200
|
+
* but doesn't get autoDark's coverage honesty. Separate question, left alone.)
|
|
201
|
+
*/
|
|
202
|
+
export function reportModeCarryOver(normalized, config, diagnostics) {
|
|
203
|
+
const DIM = 'color-scheme';
|
|
204
|
+
const dim = normalized.dimensions?.[DIM];
|
|
205
|
+
if (!dim) return;
|
|
206
|
+
const autoDark = Boolean(config?.derivation?.autoDark);
|
|
207
|
+
const reported = new Set();
|
|
208
|
+
|
|
209
|
+
for (const key of normalized.allCombos) {
|
|
210
|
+
const dims = normalized.comboDims[key];
|
|
211
|
+
const scheme = dims[DIM];
|
|
212
|
+
if (scheme === dim.default) continue;
|
|
213
|
+
const map = normalized.modes[key];
|
|
214
|
+
const base = normalized.modes[comboKey(normalized.dimensionNames, { ...dims, [DIM]: dim.default })];
|
|
215
|
+
if (!map || !base) continue;
|
|
216
|
+
|
|
217
|
+
for (const [tokenPath, entry] of map) {
|
|
218
|
+
const role = tokenPath.match(ROLE_SOLID)?.[1];
|
|
219
|
+
if (!role || !(COLOR_ROLES.includes(role) || normalized.roleArchetypes.has(role))) continue;
|
|
220
|
+
// Only roles the user actually supplied. Running after DERIVE means every
|
|
221
|
+
// derived role anchor (`accent.solid` aliasing primary, `danger.solid`
|
|
222
|
+
// hue-anchored from it) is in the map too, and each of them carries over
|
|
223
|
+
// for exactly one reason: the authored anchor did. Reporting them would
|
|
224
|
+
// print eight consequences of one cause — the noise AL5 removed
|
|
225
|
+
// everywhere else.
|
|
226
|
+
if (!['authored', 'aliased'].includes(entry.provenance?.kind)) continue;
|
|
227
|
+
// An explicit per-mode value on the slot itself is a decision, however it
|
|
228
|
+
// compares — never second-guessed here.
|
|
229
|
+
if (entry.provenance?.mode === `${DIM}=${scheme}`) continue;
|
|
230
|
+
const baseline = base.get(tokenPath);
|
|
231
|
+
if (!baseline || !sameValue(entry.value, baseline.value)) continue;
|
|
232
|
+
|
|
233
|
+
const dedupeKey = `${tokenPath}|${scheme}`;
|
|
234
|
+
if (reported.has(dedupeKey)) continue;
|
|
235
|
+
reported.add(dedupeKey);
|
|
236
|
+
diagnostics.info(
|
|
237
|
+
'TST1204',
|
|
238
|
+
`${tokenPath} has no authored value for ${DIM}=${scheme} — the ${dim.default}-mode value carries over unchanged, and so does its whole derived grid`,
|
|
239
|
+
autoDark
|
|
240
|
+
? { hint: `Author ${tokenPath} for ${DIM}=${scheme} if this role should differ in that mode. \`derivation.autoDark\` is on, so this carry-over is now classified "derived" in coverage — but it does not yet compute a distinct color (that transform is still an open research question; see the roadmap).` }
|
|
241
|
+
: { hint: `Author ${tokenPath} for ${DIM}=${scheme} if this role should differ in that mode. This is default behavior — nothing is broken.` },
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* An alias whose target isn't in the map *yet*. Catalog slots the DERIVE stage
|
|
249
|
+
* materializes (`radius.full`, the role grid, the elevation ladder) don't exist
|
|
250
|
+
* at NORMALIZE time, so authoring `{semantic.radius.full}` — the very style
|
|
251
|
+
* ir.md's component-layer sketch uses — must not be judged dangling here.
|
|
252
|
+
* These entries are re-resolved by resolveDeferredAliases() after DERIVE; only
|
|
253
|
+
* then, if the target still doesn't exist, is it a real dangling alias.
|
|
254
|
+
*/
|
|
255
|
+
const DEFERRED = Symbol('deferred-alias');
|
|
256
|
+
/** Resolution failed because of an alias cycle, already reported as TST1104. */
|
|
257
|
+
const CYCLE = Symbol('alias-cycle');
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Report one cycle once (AL5). The resolver reaches a two-token loop from both
|
|
261
|
+
* ends, so the same cycle was printed twice with the chain rotated — two
|
|
262
|
+
* different message strings describing one mistake, which de-duplication by
|
|
263
|
+
* message cannot catch. Keying on the sorted member set makes any rotation of
|
|
264
|
+
* the same loop a single report; the chain is still printed in traversal order,
|
|
265
|
+
* because that is what shows the user how the loop closes.
|
|
266
|
+
*/
|
|
267
|
+
const reportedCycles = new WeakMap();
|
|
268
|
+
function reportCycle(diagnostics, chain) {
|
|
269
|
+
let seen = reportedCycles.get(diagnostics);
|
|
270
|
+
if (!seen) reportedCycles.set(diagnostics, (seen = new Set()));
|
|
271
|
+
const key = [...new Set(chain)].sort().join('|');
|
|
272
|
+
if (seen.has(key)) return;
|
|
273
|
+
seen.add(key);
|
|
274
|
+
diagnostics.error('TST1104', `Alias cycle: ${chain.join(' → ')}`, {
|
|
275
|
+
hint: 'Break the loop: one of these tokens has to hold a literal value.',
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function resolveEntry(map, tokenPath, stack, diagnostics) {
|
|
280
|
+
const entry = map.get(tokenPath);
|
|
281
|
+
if (!entry) return undefined;
|
|
282
|
+
if (entry.value !== undefined) return entry;
|
|
283
|
+
if (entry.pendingAlias) return DEFERRED;
|
|
284
|
+
if (stack.includes(tokenPath)) {
|
|
285
|
+
reportCycle(diagnostics, [...stack, tokenPath]);
|
|
286
|
+
// AL5: a distinct sentinel, not `undefined`. Returning `undefined` made the
|
|
287
|
+
// caller report TST1105 "dangling alias" on top of the cycle — which is
|
|
288
|
+
// false (the target exists; it just loops) and doubled the output on the
|
|
289
|
+
// exact error where the chain is already printed in full.
|
|
290
|
+
return CYCLE;
|
|
291
|
+
}
|
|
292
|
+
let raw = entry.rawValue;
|
|
293
|
+
const target = aliasTarget(raw);
|
|
294
|
+
if (target) {
|
|
295
|
+
// Absent target: possibly derived later — defer rather than erroring.
|
|
296
|
+
// A target that IS present but failed to resolve (bad color syntax, cycle)
|
|
297
|
+
// is a genuine failure now, exactly as before.
|
|
298
|
+
if (!map.has(target)) {
|
|
299
|
+
entry.pendingAlias = target;
|
|
300
|
+
return DEFERRED;
|
|
301
|
+
}
|
|
302
|
+
const resolved = resolveEntry(map, target, [...stack, tokenPath], diagnostics);
|
|
303
|
+
if (resolved === DEFERRED) {
|
|
304
|
+
entry.pendingAlias = target;
|
|
305
|
+
return DEFERRED;
|
|
306
|
+
}
|
|
307
|
+
if (resolved === CYCLE) return CYCLE; // already reported as TST1104
|
|
308
|
+
if (!resolved) {
|
|
309
|
+
diagnostics.error('TST1105', `Dangling alias in ${tokenPath}: {${target}}`, {
|
|
310
|
+
hint: `Nothing resolves to "${target}". Check the tier prefix (option./semantic./component.) and the spelling.`,
|
|
311
|
+
});
|
|
312
|
+
return undefined;
|
|
313
|
+
}
|
|
314
|
+
entry.type = entry.type ?? resolved.type;
|
|
315
|
+
entry.value = resolved.value;
|
|
316
|
+
entry.provenance = { kind: 'aliased', target, mode: entry.provenance.mode };
|
|
317
|
+
return entry;
|
|
318
|
+
}
|
|
319
|
+
try {
|
|
320
|
+
entry.value = entry.type === 'color' ? parseColor(raw) : raw;
|
|
321
|
+
} catch (e) {
|
|
322
|
+
diagnostics.error('TST1106', `${tokenPath}: ${e.message}`);
|
|
323
|
+
return undefined;
|
|
324
|
+
}
|
|
325
|
+
return entry;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Post-DERIVE pass: resolve every alias deferred at NORMALIZE time, now that
|
|
330
|
+
* the derived catalog slots exist. Still-missing targets are the real dangling
|
|
331
|
+
* aliases and get TST1105 here — same code, same message, just diagnosed after
|
|
332
|
+
* the stage that could legitimately have supplied the target.
|
|
333
|
+
*/
|
|
334
|
+
export function resolveDeferredAliases(normalized, diagnostics) {
|
|
335
|
+
const seen = new Set();
|
|
336
|
+
for (const map of Object.values(normalized.modes)) {
|
|
337
|
+
if (!map || seen.has(map)) continue; // modes.light/dark alias the combo maps
|
|
338
|
+
seen.add(map);
|
|
339
|
+
for (const tokenPath of [...map.keys()]) resolvePending(map, tokenPath, [], diagnostics);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function resolvePending(map, tokenPath, stack, diagnostics) {
|
|
344
|
+
const entry = map.get(tokenPath);
|
|
345
|
+
if (!entry || !entry.pendingAlias) return entry;
|
|
346
|
+
if (stack.includes(tokenPath)) {
|
|
347
|
+
reportCycle(diagnostics, [...stack, tokenPath]);
|
|
348
|
+
delete entry.pendingAlias;
|
|
349
|
+
return CYCLE;
|
|
350
|
+
}
|
|
351
|
+
const target = entry.pendingAlias;
|
|
352
|
+
const resolved = map.has(target)
|
|
353
|
+
? resolvePending(map, target, [...stack, tokenPath], diagnostics)
|
|
354
|
+
: undefined;
|
|
355
|
+
delete entry.pendingAlias;
|
|
356
|
+
if (resolved === CYCLE) return CYCLE; // already reported as TST1104
|
|
357
|
+
if (!resolved || resolved.value === undefined) {
|
|
358
|
+
diagnostics.error('TST1105', `Dangling alias in ${tokenPath}: {${target}}`, {
|
|
359
|
+
hint: `Nothing resolves to "${target}" — not authored, and not produced by derivation. Check the tier prefix (option./semantic./component.) and the spelling.`,
|
|
360
|
+
});
|
|
361
|
+
return undefined;
|
|
362
|
+
}
|
|
363
|
+
entry.type = entry.type ?? resolved.type;
|
|
364
|
+
entry.value = resolved.value;
|
|
365
|
+
entry.provenance = { kind: 'aliased', target, mode: entry.provenance.mode };
|
|
366
|
+
return entry;
|
|
367
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `transtyle.config.json` schema — source of truth for BOTH the runtime
|
|
3
|
+
* validator (validate.js, used in compile()) and the published editor schema
|
|
4
|
+
* (scripts/gen-schemas.mjs → website/public/schemas/config/v0.json). Written in
|
|
5
|
+
* the JSON Schema subset validate.js understands; scripts/check-schemas.mjs
|
|
6
|
+
* proves the published file and this object stay identical.
|
|
7
|
+
*
|
|
8
|
+
* Matches docs/specs/configuration.md. `additionalProperties: false` at the top
|
|
9
|
+
* level and inside each target is what makes a typo an error (audit A8) instead
|
|
10
|
+
* of a silently-ignored key.
|
|
11
|
+
*
|
|
12
|
+
* NOTE: exporter `options` are intentionally `additionalProperties: true` here —
|
|
13
|
+
* each exporter validates its own options against its own schema at load time
|
|
14
|
+
* (index.js + exporter `optionsSchema`), because the shape depends on which
|
|
15
|
+
* exporter the instance selects, which this static schema can't know.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const tokenLayer = {
|
|
19
|
+
anyOf: [
|
|
20
|
+
{ type: 'string' },
|
|
21
|
+
{
|
|
22
|
+
type: 'object',
|
|
23
|
+
required: ['files', 'mode'],
|
|
24
|
+
additionalProperties: false,
|
|
25
|
+
properties: {
|
|
26
|
+
files: { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }] },
|
|
27
|
+
mode: { type: 'object', additionalProperties: { type: 'string' } },
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
],
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const modeDimension = {
|
|
34
|
+
type: 'object',
|
|
35
|
+
required: ['values'],
|
|
36
|
+
additionalProperties: false,
|
|
37
|
+
properties: {
|
|
38
|
+
values: { type: 'array', minItems: 1, items: { type: 'string' } },
|
|
39
|
+
default: { type: 'string' },
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const target = {
|
|
44
|
+
type: 'object',
|
|
45
|
+
additionalProperties: false,
|
|
46
|
+
properties: {
|
|
47
|
+
output: { type: 'string' },
|
|
48
|
+
exporter: { type: 'string' },
|
|
49
|
+
options: { type: 'object' }, // validated per-exporter at load time; see note above
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const configSchema = {
|
|
54
|
+
type: 'object',
|
|
55
|
+
required: ['tokens'],
|
|
56
|
+
additionalProperties: false,
|
|
57
|
+
properties: {
|
|
58
|
+
$schema: { type: 'string' },
|
|
59
|
+
name: { type: 'string' },
|
|
60
|
+
tokens: { type: 'array', minItems: 1, items: tokenLayer },
|
|
61
|
+
modes: { type: 'object', additionalProperties: modeDimension },
|
|
62
|
+
derivation: {
|
|
63
|
+
type: 'object',
|
|
64
|
+
additionalProperties: false,
|
|
65
|
+
properties: {
|
|
66
|
+
rules: { type: 'string' },
|
|
67
|
+
autoDark: { type: 'boolean' },
|
|
68
|
+
require: { type: 'array', items: { type: 'string' } },
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
targets: { type: 'object', additionalProperties: target },
|
|
72
|
+
check: {
|
|
73
|
+
type: 'object',
|
|
74
|
+
additionalProperties: false,
|
|
75
|
+
properties: {
|
|
76
|
+
failOn: { type: 'string', enum: ['error', 'warning', 'approximation'] },
|
|
77
|
+
contrast: {
|
|
78
|
+
type: 'object',
|
|
79
|
+
additionalProperties: false,
|
|
80
|
+
properties: { standard: { type: 'string', enum: ['wcag21-aa', 'wcag21-aaa'] } },
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/** Metadata added only to the *published* file (gen-schemas.mjs), not used at runtime. */
|
|
88
|
+
export const configSchemaMeta = {
|
|
89
|
+
$id: 'https://transtyle.dev/schemas/config/v0.json',
|
|
90
|
+
title: 'Transtyle config (transtyle.config.json)',
|
|
91
|
+
description: 'Schema for a Transtyle project configuration file. See https://transtyle.dev/docs/configuration/.',
|
|
92
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `report.json` schema — the machine-readable build report core emits per
|
|
3
|
+
* target (docs/specs/validation-and-coverage.md). Source of truth for the
|
|
4
|
+
* published file (scripts/gen-schemas.mjs → website/public/schemas/report/v0.json).
|
|
5
|
+
* Unlike the config schema this is not enforced on user input — core *produces*
|
|
6
|
+
* reports — but scripts/check-schemas.mjs validates every generated report
|
|
7
|
+
* against it, so the published schema can never drift from what we actually emit.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const coverageItem = {
|
|
11
|
+
type: 'object',
|
|
12
|
+
required: ['variable', 'slot', 'class'],
|
|
13
|
+
additionalProperties: false,
|
|
14
|
+
properties: {
|
|
15
|
+
variable: { type: 'string' },
|
|
16
|
+
slot: { type: 'string' },
|
|
17
|
+
class: { type: 'string', enum: ['native', 'derived', 'approximated', 'dropped', 'unsupported'] },
|
|
18
|
+
provenance: { type: 'string', enum: ['authored', 'aliased', 'derived', 'defaulted'] },
|
|
19
|
+
note: { type: 'string' },
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const diagnostic = {
|
|
24
|
+
type: 'object',
|
|
25
|
+
required: ['severity', 'code', 'message'],
|
|
26
|
+
additionalProperties: true,
|
|
27
|
+
properties: {
|
|
28
|
+
severity: { type: 'string', enum: ['error', 'warning', 'info'] },
|
|
29
|
+
code: { type: 'string' },
|
|
30
|
+
message: { type: 'string' },
|
|
31
|
+
// AL5: optional, and deliberately separate from `message` — what is wrong
|
|
32
|
+
// and what to change are different sentences, and tools consuming the
|
|
33
|
+
// report (editors, CI annotations) want to place them differently.
|
|
34
|
+
hint: { type: 'string' },
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const reportSchema = {
|
|
39
|
+
type: 'object',
|
|
40
|
+
required: ['target', 'generatedBy', 'coverage', 'diagnostics', 'files'],
|
|
41
|
+
additionalProperties: false,
|
|
42
|
+
properties: {
|
|
43
|
+
$schema: { type: 'string' },
|
|
44
|
+
target: { type: 'string' },
|
|
45
|
+
options: { type: 'object' },
|
|
46
|
+
generatedBy: { type: 'string' },
|
|
47
|
+
coverage: {
|
|
48
|
+
type: 'object',
|
|
49
|
+
required: ['counts', 'items'],
|
|
50
|
+
additionalProperties: false,
|
|
51
|
+
properties: {
|
|
52
|
+
counts: { type: 'object', additionalProperties: { type: 'integer' } },
|
|
53
|
+
items: { type: 'array', items: coverageItem },
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
diagnostics: { type: 'array', items: diagnostic },
|
|
57
|
+
files: { type: 'array', items: { type: 'string' } },
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export const reportSchemaMeta = {
|
|
62
|
+
$id: 'https://transtyle.dev/schemas/report/v0.json',
|
|
63
|
+
title: 'Transtyle build report (report.json)',
|
|
64
|
+
description: 'Schema for a Transtyle per-target build report. See https://transtyle.dev/docs/diagnostics/.',
|
|
65
|
+
};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A tiny, zero-dependency validator for the JSON Schema *subset* Transtyle's
|
|
3
|
+
* own schemas use (docs/specs/configuration.md, audit A7/A8). We do NOT ship a
|
|
4
|
+
* full JSON Schema engine (ajv) — core has zero external dependencies by policy
|
|
5
|
+
* (VISION non-goal / package.json). Instead, the config and report schemas are
|
|
6
|
+
* written in that subset (see config.schema.js / report.schema.js), this walker
|
|
7
|
+
* validates against them, and `scripts/gen-schemas.mjs` publishes the very same
|
|
8
|
+
* objects as real draft-2020-12 files for editors and the website. One source of
|
|
9
|
+
* truth, two consumers — `scripts/check-schemas.mjs` asserts they never diverge.
|
|
10
|
+
*
|
|
11
|
+
* Supported keywords: type, enum, const, required, properties,
|
|
12
|
+
* additionalProperties (boolean | schema), items, minItems, anyOf. That is
|
|
13
|
+
* exactly what our schemas need and no more — extend deliberately.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { nearestName } from '../nearest.js';
|
|
17
|
+
|
|
18
|
+
const typeOf = (v) =>
|
|
19
|
+
v === null ? 'null'
|
|
20
|
+
: Array.isArray(v) ? 'array'
|
|
21
|
+
: typeof v === 'object' ? 'object'
|
|
22
|
+
: typeof v === 'number' ? (Number.isInteger(v) ? 'integer' : 'number')
|
|
23
|
+
: typeof v; // 'string' | 'boolean'
|
|
24
|
+
|
|
25
|
+
/** integer also satisfies number; everything else is exact. */
|
|
26
|
+
function typeMatches(value, type) {
|
|
27
|
+
const actual = typeOf(value);
|
|
28
|
+
const types = Array.isArray(type) ? type : [type];
|
|
29
|
+
return types.some((t) => t === actual || (t === 'number' && actual === 'integer'));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Validate `value` against `schema`. Returns an array of { path, message }
|
|
34
|
+
* (empty = valid). `path` is a dotted JSON path from the document root.
|
|
35
|
+
*/
|
|
36
|
+
export function validate(value, schema, path = '') {
|
|
37
|
+
const errors = [];
|
|
38
|
+
const push = (p, message) => errors.push({ path: p || '(root)', message });
|
|
39
|
+
|
|
40
|
+
if (schema.const !== undefined && value !== schema.const) {
|
|
41
|
+
push(path, `must equal ${JSON.stringify(schema.const)}`);
|
|
42
|
+
}
|
|
43
|
+
if (schema.enum && !schema.enum.some((e) => e === value)) {
|
|
44
|
+
push(path, `must be one of ${schema.enum.map((e) => JSON.stringify(e)).join(', ')}`);
|
|
45
|
+
return errors; // an out-of-enum value fails nothing else usefully
|
|
46
|
+
}
|
|
47
|
+
if (schema.type && !typeMatches(value, schema.type)) {
|
|
48
|
+
push(path, `must be ${Array.isArray(schema.type) ? schema.type.join(' or ') : schema.type}, got ${typeOf(value)}`);
|
|
49
|
+
return errors; // wrong type → downstream keyword checks are noise
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (schema.anyOf) {
|
|
53
|
+
const branchErrors = schema.anyOf.map((s) => validate(value, s, path));
|
|
54
|
+
if (!branchErrors.some((e) => e.length === 0)) {
|
|
55
|
+
push(path, `does not match any allowed shape`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (typeOf(value) === 'object' && (schema.properties || schema.required || 'additionalProperties' in schema)) {
|
|
60
|
+
for (const key of schema.required ?? []) {
|
|
61
|
+
if (!(key in value)) push(path, `missing required property "${key}"`);
|
|
62
|
+
}
|
|
63
|
+
const props = schema.properties ?? {};
|
|
64
|
+
const addl = schema.additionalProperties;
|
|
65
|
+
for (const [key, v] of Object.entries(value)) {
|
|
66
|
+
const childPath = path ? `${path}.${key}` : key;
|
|
67
|
+
if (props[key]) {
|
|
68
|
+
errors.push(...validate(v, props[key], childPath));
|
|
69
|
+
} else if (addl === false) {
|
|
70
|
+
// AL5: pushed at the PARENT path, not childPath — the message already
|
|
71
|
+
// names the key, and prefixing the child path rendered as
|
|
72
|
+
// `targts unknown property "targts"`. The near-miss suggestion is the
|
|
73
|
+
// whole point of rejecting unknown keys instead of ignoring them: a
|
|
74
|
+
// typo'd `targts` is otherwise a config that silently does nothing.
|
|
75
|
+
const near = nearestName(key, Object.keys(props));
|
|
76
|
+
push(path, `unknown property "${key}"${near ? ` — did you mean "${near}"?` : ''}`);
|
|
77
|
+
} else if (addl && typeof addl === 'object') {
|
|
78
|
+
errors.push(...validate(v, addl, childPath));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (typeOf(value) === 'array') {
|
|
84
|
+
if (schema.minItems != null && value.length < schema.minItems) {
|
|
85
|
+
push(path, `must have at least ${schema.minItems} item${schema.minItems === 1 ? '' : 's'}`);
|
|
86
|
+
}
|
|
87
|
+
if (schema.items) {
|
|
88
|
+
value.forEach((item, i) => errors.push(...validate(item, schema.items, `${path}[${i}]`)));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return errors;
|
|
93
|
+
}
|