@radicool/throughline 0.18.0 → 0.19.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 +1 -1
- package/references/native-adapter-config.md +112 -25
- package/scripts/lib/dtcg.mjs +120 -2
- package/scripts/lib/native-literal.mjs +22 -2
- package/scripts/lib/sd-native.mjs +112 -25
- package/scripts/validate-token-output.mjs +174 -14
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@radicool/throughline",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"description": "Build a complete design system end to end — author in Figma, sync tokens to code, generate Storybook. Usable from Claude Code, Cursor, Codex, or any AGENTS.md agent.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -54,7 +54,8 @@ consumer's repo.
|
|
|
54
54
|
import { readFileSync } from 'node:fs';
|
|
55
55
|
import {
|
|
56
56
|
flattenDtcg,
|
|
57
|
-
|
|
57
|
+
flattenPipelineTypes,
|
|
58
|
+
extNamespace,
|
|
58
59
|
resolveValue,
|
|
59
60
|
findModeCollisions,
|
|
60
61
|
TEXT_UNIT_NAMES,
|
|
@@ -152,10 +153,25 @@ Both are fixed before Style Dictionary sees the tree.
|
|
|
152
153
|
// Marks a node whose AUTHORED $value was a whole-value reference, so the hoist
|
|
153
154
|
// can decline to override the type DTCG 5.2.2 rule 1 already determined from the
|
|
154
155
|
// referent. A WeakSet keyed on the node object, rather than a property written
|
|
155
|
-
// onto it,
|
|
156
|
-
// membership along with the rest of the identity, so
|
|
157
|
-
//
|
|
156
|
+
// onto it, keeps this mechanism from leaking across calls: structuredClone
|
|
157
|
+
// drops the membership along with the rest of the identity, so a second pass
|
|
158
|
+
// starts clean rather than inheriting the first pass's marks. That is exactly
|
|
159
|
+
// what a property written onto the node would get wrong. It is not on its own a
|
|
160
|
+
// guarantee that preprocess is idempotent — see the limit recorded at
|
|
161
|
+
// nativePlatform's preprocessors line (#90).
|
|
158
162
|
const WAS_REF = new WeakSet();
|
|
163
|
+
|
|
164
|
+
// The path the AUTHOR wrote for a node the hoist has moved. hoistDualNodes
|
|
165
|
+
// recurses depth-first, so by the time an outer frame takes Object.entries it
|
|
166
|
+
// already contains names the inner frame synthesised — and a collision reported
|
|
167
|
+
// from that frame named a path appearing nowhere in the source (#61). Recording
|
|
168
|
+
// the authored path when a node is hoisted lets the outer frame report what the
|
|
169
|
+
// author can actually search for.
|
|
170
|
+
//
|
|
171
|
+
// Keyed on node identity, like WAS_REF, and safe across calls for the same
|
|
172
|
+
// reason: preprocess structuredClones its input, so every call works on fresh
|
|
173
|
+
// objects.
|
|
174
|
+
const AUTHORED_PATH = new WeakMap();
|
|
159
175
|
const WHOLE_REF = /^\{[^}]+\}$/;
|
|
160
176
|
|
|
161
177
|
function interpolate(value, flat) {
|
|
@@ -228,20 +244,27 @@ function hoistDualNodes(node, collisions, prefix = [], groupType = undefined) {
|
|
|
228
244
|
for (const [childKey, childVal] of Object.entries(val)) {
|
|
229
245
|
if (childKey.startsWith('$') || !childVal || typeof childVal !== 'object') continue;
|
|
230
246
|
const hoisted = key + childKey[0].toUpperCase() + childKey.slice(1);
|
|
231
|
-
const from = [...prefix, key, childKey].join('.');
|
|
247
|
+
const from = AUTHORED_PATH.get(childVal) ?? [...prefix, key, childKey].join('.');
|
|
232
248
|
if (Object.hasOwn(node, hoisted)) {
|
|
233
249
|
const existingNode = node[hoisted];
|
|
234
|
-
|
|
250
|
+
// An array has no $value either, and was therefore labelled "(a group)"
|
|
251
|
+
// (#61). It is neither — an array at this position is malformed DTCG.
|
|
252
|
+
// Say so, rather than naming a shape the author will go looking for.
|
|
253
|
+
const isArray = Array.isArray(existingNode);
|
|
254
|
+
const isGroup =
|
|
255
|
+
existingNode !== null && typeof existingNode === 'object' && !isArray && !('$value' in existingNode);
|
|
235
256
|
collisions.push({
|
|
236
257
|
from,
|
|
237
258
|
onto: [...prefix, hoisted].join('.'),
|
|
238
259
|
isGroup,
|
|
260
|
+
isArray,
|
|
239
261
|
claimant: claimedBy.get(hoisted),
|
|
240
|
-
existing:
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
262
|
+
existing:
|
|
263
|
+
isGroup || isArray
|
|
264
|
+
? undefined
|
|
265
|
+
: existingNode && typeof existingNode === 'object'
|
|
266
|
+
? existingNode.$value
|
|
267
|
+
: existingNode,
|
|
245
268
|
});
|
|
246
269
|
continue;
|
|
247
270
|
}
|
|
@@ -264,8 +287,17 @@ function hoistDualNodes(node, collisions, prefix = [], groupType = undefined) {
|
|
|
264
287
|
// type suits the child badly, because the hoist is not entitled to
|
|
265
288
|
// improve on what the source says; but the carry firing at all is the
|
|
266
289
|
// hoist saying something the source did not.
|
|
290
|
+
//
|
|
291
|
+
// A GROUP child is excluded (#67). The carry's premise is that the dual
|
|
292
|
+
// node was the child's closest $type-bearing ancestor as authored, and
|
|
293
|
+
// the hoist took that away. For a group child the premise never held:
|
|
294
|
+
// 5.2.2 inherits from the closest parent GROUP, the dual node is not
|
|
295
|
+
// one, so it was never that group's inheritance source and the hoist
|
|
296
|
+
// removed nothing. Carrying there does not repair, it invents — and it
|
|
297
|
+
// invents for every token beneath the group, not just one node.
|
|
267
298
|
if (
|
|
268
299
|
!('$type' in childVal) &&
|
|
300
|
+
'$value' in childVal &&
|
|
269
301
|
'$type' in val &&
|
|
270
302
|
!WAS_REF.has(childVal) &&
|
|
271
303
|
inherited === undefined
|
|
@@ -273,6 +305,7 @@ function hoistDualNodes(node, collisions, prefix = [], groupType = undefined) {
|
|
|
273
305
|
childVal.$type = val.$type;
|
|
274
306
|
}
|
|
275
307
|
node[hoisted] = childVal;
|
|
308
|
+
AUTHORED_PATH.set(childVal, from);
|
|
276
309
|
delete val[childKey];
|
|
277
310
|
claimedBy.set(hoisted, from);
|
|
278
311
|
}
|
|
@@ -371,6 +404,9 @@ function classifyTextUnits(node, types, prefix = []) {
|
|
|
371
404
|
types[path.join('.')] === 'dimension' &&
|
|
372
405
|
TEXT_ROLE_UNIT.test(String(val.$value).trim())
|
|
373
406
|
) {
|
|
407
|
+
// extNamespace throws with the token path if the source authored either
|
|
408
|
+
// level as a primitive — legal DTCG this module does not handle (#62).
|
|
409
|
+
extNamespace(val, path.join('.'));
|
|
374
410
|
val.$extensions ??= {};
|
|
375
411
|
val.$extensions[EXT_NS] ??= {};
|
|
376
412
|
const ns = val.$extensions[EXT_NS];
|
|
@@ -379,7 +415,9 @@ function classifyTextUnits(node, types, prefix = []) {
|
|
|
379
415
|
// one, so a unitless value is declined by every size transform regardless
|
|
380
416
|
// of what is stamped here (see isRatio, #52). Declining to overwrite IS
|
|
381
417
|
// the feature: it costs no configuration parameter, and it is what makes
|
|
382
|
-
//
|
|
418
|
+
// STAMPING idempotent — a second pass never rewrites a role a first pass
|
|
419
|
+
// set. What it does not settle is whether a second pass finds the same
|
|
420
|
+
// candidates; the hoist can rename a node between the two (#90).
|
|
383
421
|
if (!('nativeUnit' in ns)) ns.nativeUnit = 'text';
|
|
384
422
|
}
|
|
385
423
|
classifyTextUnits(val, types, path);
|
|
@@ -402,7 +440,8 @@ function classifyTextUnits(node, types, prefix = []) {
|
|
|
402
440
|
// A path may name no node at all. resolveInPlace deliberately leaves an
|
|
403
441
|
// unresolvable reference in place for Style Dictionary to report, so the graph
|
|
404
442
|
// can hold an edge to a token that does not exist. Skip it. This is also what
|
|
405
|
-
// keeps the second preprocess pass from throwing
|
|
443
|
+
// keeps the second preprocess pass from throwing — which is a precondition for
|
|
444
|
+
// idempotency, not the whole of it (#90).
|
|
406
445
|
function applyTextRoleGraph(node, typographic, types) {
|
|
407
446
|
for (const path of typographic) {
|
|
408
447
|
let target = node;
|
|
@@ -412,6 +451,7 @@ function applyTextRoleGraph(node, typographic, types) {
|
|
|
412
451
|
if (!target || typeof target !== 'object' || !('$value' in target)) continue;
|
|
413
452
|
if (types[path] !== 'dimension') continue;
|
|
414
453
|
if (!TEXT_ROLE_UNIT.test(String(target.$value).trim())) continue;
|
|
454
|
+
extNamespace(target, path);
|
|
415
455
|
target.$extensions ??= {};
|
|
416
456
|
target.$extensions[EXT_NS] ??= {};
|
|
417
457
|
const ns = target.$extensions[EXT_NS];
|
|
@@ -426,14 +466,16 @@ export function preprocess(dict) {
|
|
|
426
466
|
// the graph is made of.
|
|
427
467
|
const { typographic } = textRoleGraph(dict);
|
|
428
468
|
const resolved = resolveInPlace(structuredClone(dict), flattenDtcg(dict));
|
|
429
|
-
//
|
|
430
|
-
//
|
|
431
|
-
//
|
|
432
|
-
//
|
|
469
|
+
// Types for the whole tree, walked once and shared by both passes below.
|
|
470
|
+
// Neither writes $type or moves a node, so one map is correct for both, and
|
|
471
|
+
// sharing it is what makes them agree by construction rather than by two
|
|
472
|
+
// copies of the same rule staying in step (#85).
|
|
433
473
|
//
|
|
434
|
-
// Computed on the
|
|
435
|
-
//
|
|
436
|
-
|
|
474
|
+
// Computed on the RAW dict, not the resolved clone. resolveInPlace only
|
|
475
|
+
// rewrites $value strings, so paths and types are identical between the two —
|
|
476
|
+
// except for the carry, whose rule asks whether a value WAS a whole-value
|
|
477
|
+
// reference, and resolution has already destroyed that (#89).
|
|
478
|
+
const types = flattenPipelineTypes(dict);
|
|
437
479
|
const out = hoistDualNodes(
|
|
438
480
|
applyTextRoleGraph(classifyTextUnits(resolved, types), typographic, types),
|
|
439
481
|
collisions,
|
|
@@ -443,6 +485,9 @@ export function preprocess(dict) {
|
|
|
443
485
|
.slice(0, 5)
|
|
444
486
|
.map((c) => {
|
|
445
487
|
const line = ` ${c.from} -> ${c.onto}`;
|
|
488
|
+
if (c.isArray) {
|
|
489
|
+
return line + ' (an array — neither a token nor a group, and not valid DTCG here)';
|
|
490
|
+
}
|
|
446
491
|
if (c.isGroup) {
|
|
447
492
|
return line + (c.claimant ? ` (a group, already claimed by the hoist of ${c.claimant})` : ' (a group)');
|
|
448
493
|
}
|
|
@@ -629,7 +674,14 @@ const DECLINED_STOCK_TRANSFORMS = {
|
|
|
629
674
|
// Order is never compared: our lists are hand-ordered for our own reasons and
|
|
630
675
|
// do not inherit stock order. Removals are never reported: a declined name
|
|
631
676
|
// disappearing is a non-event.
|
|
632
|
-
|
|
677
|
+
// `platforms` and `declined` are parameters with the shipped config as their
|
|
678
|
+
// default, so the contradiction branch below is reachable from a test. The
|
|
679
|
+
// single caller passes neither; the audit's contract is unchanged.
|
|
680
|
+
export function auditStockGroups(
|
|
681
|
+
transformGroups,
|
|
682
|
+
platforms = PLATFORMS,
|
|
683
|
+
declined = DECLINED_STOCK_TRANSFORMS,
|
|
684
|
+
) {
|
|
633
685
|
if (typeof transformGroups !== 'object' || transformGroups === null) {
|
|
634
686
|
return [
|
|
635
687
|
"throughline: could not read Style Dictionary's stock transform groups " +
|
|
@@ -638,7 +690,29 @@ export function auditStockGroups(transformGroups) {
|
|
|
638
690
|
];
|
|
639
691
|
}
|
|
640
692
|
const warnings = [];
|
|
641
|
-
for (const [platform, preset] of Object.entries(
|
|
693
|
+
for (const [platform, preset] of Object.entries(platforms)) {
|
|
694
|
+
// A name in BOTH lists is a contradiction the unaccounted filter below
|
|
695
|
+
// cannot see, because either membership alone suppresses the warning (#75).
|
|
696
|
+
// The config would be saying "we run this" and "we deliberately do not" at
|
|
697
|
+
// once, and whichever is wrong is silently the loser: if the decline is
|
|
698
|
+
// right the transform still runs, and if the run is right the decline is a
|
|
699
|
+
// lie the next maintainer will read as settled. Reported here rather than
|
|
700
|
+
// guarded at the definition, so it travels with the rest of the audit.
|
|
701
|
+
//
|
|
702
|
+
// Checked before stockGroup, because this is wrong regardless of whether
|
|
703
|
+
// Style Dictionary still has the group to compare against.
|
|
704
|
+
if (Array.isArray(preset.transforms)) {
|
|
705
|
+
const contradictory = preset.transforms.filter((name) => Object.hasOwn(declined, name));
|
|
706
|
+
if (contradictory.length) {
|
|
707
|
+
warnings.push(
|
|
708
|
+
`throughline: PLATFORMS['${platform}'] both runs and declines ` +
|
|
709
|
+
`${contradictory.join(', ')}. A transform cannot be in transforms and in ` +
|
|
710
|
+
'DECLINED_STOCK_TRANSFORMS at once — one of the two is wrong, and the ' +
|
|
711
|
+
'audit cannot tell which. This is a throughline packaging defect — ' +
|
|
712
|
+
'please report it.',
|
|
713
|
+
);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
642
716
|
const group = preset.stockGroup;
|
|
643
717
|
if (!group || !Array.isArray(preset.transforms)) {
|
|
644
718
|
warnings.push(
|
|
@@ -662,7 +736,7 @@ export function auditStockGroups(transformGroups) {
|
|
|
662
736
|
const unaccounted = stock.filter(
|
|
663
737
|
(name) =>
|
|
664
738
|
!preset.transforms.includes(name) &&
|
|
665
|
-
!Object.hasOwn(
|
|
739
|
+
!Object.hasOwn(declined, name),
|
|
666
740
|
);
|
|
667
741
|
if (unaccounted.length) {
|
|
668
742
|
const n = unaccounted.length;
|
|
@@ -764,8 +838,21 @@ export function nativePlatform({ platform, buildPath, className = 'Tokens', pack
|
|
|
764
838
|
// Carried here, not left to the caller: authored() reads the ORIGINAL
|
|
765
839
|
// $value, so without this preprocessor every aliased dimension still holds
|
|
766
840
|
// an unresolved {spacing.space.4}, no size transform fires, and the build
|
|
767
|
-
// emits bare px literals.
|
|
768
|
-
//
|
|
841
|
+
// emits bare px literals.
|
|
842
|
+
//
|
|
843
|
+
// A project that ALSO declares this preprocessor at top level runs it twice,
|
|
844
|
+
// which is harmless in every shape measured — and is the wiring our own
|
|
845
|
+
// usage snippet shows, so it is the common case rather than a corner.
|
|
846
|
+
//
|
|
847
|
+
// Stated no wider than it holds (#90): preprocess is idempotent except where
|
|
848
|
+
// the hoist invents a name the second pass then classifies. `a.font` with a
|
|
849
|
+
// child `size` camel-joins to `a.fontSize`; the first pass correctly declines
|
|
850
|
+
// `size`, and the second sees a typographic member name the source never
|
|
851
|
+
// authored. It changes no emitted output — Style Dictionary runs
|
|
852
|
+
// typeDtcgDelegate between the two passes and types that child anyway, so
|
|
853
|
+
// both passes reach the same file — and the repair belongs with the hoist,
|
|
854
|
+
// which has no way to record the names it invented. Pinned by test rather
|
|
855
|
+
// than papered over.
|
|
769
856
|
preprocessors: ['dtcg/resolve-dual-node'],
|
|
770
857
|
buildPath,
|
|
771
858
|
options: { outputReferences: false },
|
package/scripts/lib/dtcg.mjs
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
const REF = /^\{([^}]+)\}$/;
|
|
6
6
|
|
|
7
|
+
const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
8
|
+
|
|
7
9
|
// The typographic member names DTCG §9.8 fixes at MUST level, the unit gate a
|
|
8
10
|
// text-role dimension must pass, and this project's $extensions namespace.
|
|
9
11
|
//
|
|
@@ -16,6 +18,42 @@ export const TEXT_UNIT_NAMES = new Set(['fontSize', 'letterSpacing', 'lineHeight
|
|
|
16
18
|
export const TEXT_ROLE_UNIT = /^-?(?:\d+(?:\.\d+)?|\.\d+)(?:px|rem|em)$/;
|
|
17
19
|
export const EXT_NS = 'com.radicool.throughline';
|
|
18
20
|
|
|
21
|
+
// Read this project's $extensions namespace off a node, refusing a shape that
|
|
22
|
+
// cannot hold one.
|
|
23
|
+
//
|
|
24
|
+
// A DTCG $extensions namespace key may hold ANY JSON value, so a source that
|
|
25
|
+
// authored ours as a string is CONFORMANT input this module does not handle —
|
|
26
|
+
// not malformed input. That distinction is why it gets a diagnostic rather than
|
|
27
|
+
// a shrug: the module's contract is that it consumes conformant DTCG.
|
|
28
|
+
//
|
|
29
|
+
// Before #62 the three places that read this namespace all hit the `in`
|
|
30
|
+
// operator on a primitive and threw a bare TypeError naming no token, no path
|
|
31
|
+
// and no value — out of step with every other diagnostic here. nativeSources
|
|
32
|
+
// names the colliding path and both files; the hoist-collision throw names both
|
|
33
|
+
// paths and the value it would overwrite; nativePlatform names the unknown
|
|
34
|
+
// platform and the expected set.
|
|
35
|
+
export function extNamespace(node, path) {
|
|
36
|
+
const ext = node.$extensions;
|
|
37
|
+
if (ext === undefined) return undefined;
|
|
38
|
+
if (!isPlainObject(ext)) {
|
|
39
|
+
throw new Error(
|
|
40
|
+
`token "${path}" has a $extensions that is ${JSON.stringify(ext)}, not an object.\n` +
|
|
41
|
+
'DTCG 5.4 makes $extensions a map of namespace keys. Remove it, or give it the shape ' +
|
|
42
|
+
`{ "${EXT_NS}": { "nativeUnit": "text" } }.`,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
const ns = ext[EXT_NS];
|
|
46
|
+
if (ns === undefined) return undefined;
|
|
47
|
+
if (!isPlainObject(ns)) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`token "${path}" has $extensions["${EXT_NS}"] set to ${JSON.stringify(ns)}, not an object.\n` +
|
|
50
|
+
`The namespace holds named settings, so a bare value cannot be read. Write ` +
|
|
51
|
+
`{ "nativeUnit": ${JSON.stringify(ns)} } if that is the setting you meant.`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
return ns;
|
|
55
|
+
}
|
|
56
|
+
|
|
19
57
|
// Flatten nested DTCG groups into { "dot.path": rawValue }. Skips $-prefixed meta keys.
|
|
20
58
|
//
|
|
21
59
|
// A node carrying BOTH a $value and children yields its own value AND is descended
|
|
@@ -63,6 +101,87 @@ export function flattenDtcgTypes(obj, prefix = [], out = {}, groupType = undefin
|
|
|
63
101
|
return out;
|
|
64
102
|
}
|
|
65
103
|
|
|
104
|
+
// DTCG 5.2.2 PLUS the one repair this pipeline applies on top of it, so that
|
|
105
|
+
// anything reasoning about what the build emits reads the same types the build
|
|
106
|
+
// used. flattenDtcgTypes is the spec; this is the spec as this build resolves it.
|
|
107
|
+
//
|
|
108
|
+
// The repair is hoistDualNodes' $type carry. A dual node is a token, not a group
|
|
109
|
+
// (DTCG 6.1), so it is not its children's inheritance source and 5.2.2 gives an
|
|
110
|
+
// untyped child of one no type at all. The hoist then makes that child a sibling
|
|
111
|
+
// of the dual node, which destroys the last relationship it had — so where NO
|
|
112
|
+
// enclosing group supplies a type, the hoist stamps the dual node's own. That is
|
|
113
|
+
// a repair for what the hoist broke, not a reading of the source, which is why
|
|
114
|
+
// it does not belong in flattenDtcgTypes.
|
|
115
|
+
//
|
|
116
|
+
// It has to be modelled somewhere, though, because two things that reason about
|
|
117
|
+
// types could not see it: classification, which runs before the hoist and so
|
|
118
|
+
// declined a child the pipeline goes on to type (#89), and the unitless-dimension
|
|
119
|
+
// advisory, which reads the raw source (#71). Both were silent on shapes the
|
|
120
|
+
// build handles.
|
|
121
|
+
//
|
|
122
|
+
// The conditions mirror hoistDualNodes exactly — an untyped TOKEN child (#67
|
|
123
|
+
// restricted it to those), a dual node that has a $type, no enclosing group type,
|
|
124
|
+
// and a value that is not a whole-value reference. That last one is why this must
|
|
125
|
+
// run on the RAW tree: resolveInPlace rewrites a reference to its literal, and
|
|
126
|
+
// the WeakSet the hoist consults for it does not survive into a fresh call.
|
|
127
|
+
//
|
|
128
|
+
// Keyed on pre-hoist paths, like flattenDtcgTypes, because every consumer of this
|
|
129
|
+
// map runs before the hoist or reports against source paths.
|
|
130
|
+
export function flattenPipelineTypes(dict) {
|
|
131
|
+
const types = flattenDtcgTypes(dict);
|
|
132
|
+
(function walk(node, prefix, groupType) {
|
|
133
|
+
const inherited = '$value' in node ? groupType : (node.$type ?? groupType);
|
|
134
|
+
for (const [key, val] of Object.entries(node)) {
|
|
135
|
+
if (key.startsWith('$') || !isPlainObject(val)) continue;
|
|
136
|
+
const path = [...prefix, key];
|
|
137
|
+
if ('$value' in val && '$type' in val && inherited === undefined) {
|
|
138
|
+
for (const [childKey, childVal] of Object.entries(val)) {
|
|
139
|
+
if (childKey.startsWith('$') || !isPlainObject(childVal)) continue;
|
|
140
|
+
if ('$value' in childVal && !('$type' in childVal) && !REF.test(String(childVal.$value).trim())) {
|
|
141
|
+
types[[...path, childKey].join('.')] = val.$type;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
walk(val, path, inherited);
|
|
146
|
+
}
|
|
147
|
+
})(dict, [], undefined);
|
|
148
|
+
return types;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Collect nodes carrying BOTH a $value and child tokens or groups.
|
|
152
|
+
//
|
|
153
|
+
// This shape is invalid DTCG. Format Module, Draft Community Group Report of
|
|
154
|
+
// 30 July 2026, §6.1: "The presence of a $value property definitively identifies
|
|
155
|
+
// an object as a token. If an object contains both $value and child
|
|
156
|
+
// tokens/groups, this creates an invalid structure where the object cannot be
|
|
157
|
+
// both a token and a group simultaneously. Tools MUST report this as an error."
|
|
158
|
+
// The prohibition is deliberate rather than an oversight — §6.2 defines $root as
|
|
159
|
+
// the sanctioned way for a group to carry a base value alongside children, which
|
|
160
|
+
// is exactly what a dual node is reaching for.
|
|
161
|
+
//
|
|
162
|
+
// Collecting them is NOT a step towards rejecting them. Figma-derived sources
|
|
163
|
+
// emit dual nodes by the dozen, hoistDualNodes exists precisely to handle them,
|
|
164
|
+
// and refusing them would make this tool useless against the sources it targets.
|
|
165
|
+
// That behaviour is unchanged. What was missing is telling the author their
|
|
166
|
+
// source is non-conforming, which nothing did — so someone hand-authoring
|
|
167
|
+
// text.sm with both a value and a lineHeight child had no way to learn that
|
|
168
|
+
// $root is the blessed spelling.
|
|
169
|
+
//
|
|
170
|
+
// Lives here because flattenDtcg already walks this tree and already descends
|
|
171
|
+
// into dual nodes on purpose, so the knowledge is present and only the reporting
|
|
172
|
+
// was absent.
|
|
173
|
+
export function findDualNodes(obj, prefix = [], out = []) {
|
|
174
|
+
for (const [key, val] of Object.entries(obj)) {
|
|
175
|
+
if (key.startsWith('$') || !isPlainObject(val)) continue;
|
|
176
|
+
const path = [...prefix, key];
|
|
177
|
+
if ('$value' in val && Object.entries(val).some(([k, v]) => !k.startsWith('$') && isPlainObject(v))) {
|
|
178
|
+
out.push(path.join('.'));
|
|
179
|
+
}
|
|
180
|
+
findDualNodes(val, path, out);
|
|
181
|
+
}
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
184
|
+
|
|
66
185
|
// Follow {alias} chains to a leaf literal. Throws on missing or circular refs.
|
|
67
186
|
export function resolveValue(name, flat, seen = new Set()) {
|
|
68
187
|
if (!(name in flat)) throw new Error(`token "${name}" not found in DTCG source`);
|
|
@@ -104,7 +223,6 @@ export function findModeCollisions(sources) {
|
|
|
104
223
|
//
|
|
105
224
|
// Each source is cloned on the way in. Merging the caller's own objects would
|
|
106
225
|
// mutate the token trees it still holds, and the validator reads them again.
|
|
107
|
-
const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
108
226
|
|
|
109
227
|
function mergeInto(target, src) {
|
|
110
228
|
for (const [key, val] of Object.entries(src)) {
|
|
@@ -196,7 +314,7 @@ export function textRoleGraph(dict) {
|
|
|
196
314
|
types[dotted] === 'dimension' &&
|
|
197
315
|
TEXT_ROLE_UNIT.test(String(val.$value).trim()) &&
|
|
198
316
|
!referrers.has(dotted) &&
|
|
199
|
-
!('nativeUnit' in (val
|
|
317
|
+
!('nativeUnit' in (extNamespace(val, dotted) ?? {})) &&
|
|
200
318
|
inferredGroups.has(group)
|
|
201
319
|
) {
|
|
202
320
|
unreferencedSiblings.push({ path: dotted, group });
|
|
@@ -64,8 +64,28 @@ export const GRAMMAR = {
|
|
|
64
64
|
// `var` are valid identifiers, and `color-mix` has a rescue in sd-native.mjs
|
|
65
65
|
// that merely did not match this variant. So they must reach the output and
|
|
66
66
|
// fail loudly under no-foreign-syntax, never be silently dropped by a filter.
|
|
67
|
-
// Kept here, beside the grammar, so the build and the gate cannot drift apart
|
|
68
|
-
|
|
67
|
+
// Kept here, beside the grammar, so the build and the gate cannot drift apart —
|
|
68
|
+
// which until #57 was a promise this file made and did not keep. The gate
|
|
69
|
+
// defined its own independent copy of the same alternation, so adding a fourth
|
|
70
|
+
// name here would have taught the filter to keep a construct the gate had never
|
|
71
|
+
// been taught to name. One list now, two anchorings derived from it.
|
|
72
|
+
//
|
|
73
|
+
// The BUILD anchors: only a construct that LEADS the value is exempt from the
|
|
74
|
+
// output filter, because only then is the whole value the unimplemented rescue.
|
|
75
|
+
// The GATE does not anchor: it names the construct wherever it appears.
|
|
76
|
+
//
|
|
77
|
+
// The asymmetry is deliberate and it is a stated limit, not a closed class.
|
|
78
|
+
// `rgba(var(--brand), 0.5)` is dropped by the filter rather than kept, because
|
|
79
|
+
// the module cannot tell an unimplemented rescue nested inside a rescuable
|
|
80
|
+
// function from one nested inside `linear-gradient(...)`, which has no native
|
|
81
|
+
// form at any depth. Closing that needs a notion of which outer functions are
|
|
82
|
+
// rescuable, which does not exist here. What #57 does instead is make the drop
|
|
83
|
+
// NAMED rather than counted — see the unemitted-token report in
|
|
84
|
+
// validate-token-output.mjs.
|
|
85
|
+
export const CSS_CONSTRUCT_NAMES = ['color-mix', 'calc', 'var'];
|
|
86
|
+
const CSS_CONSTRUCT_ALT = CSS_CONSTRUCT_NAMES.join('|');
|
|
87
|
+
export const CSS_CONSTRUCT = new RegExp(`^(?:${CSS_CONSTRUCT_ALT})\\s*\\(`);
|
|
88
|
+
export const CSS_CONSTRUCT_ANYWHERE = new RegExp(`(?:${CSS_CONSTRUCT_ALT})\\s*\\(`);
|
|
69
89
|
|
|
70
90
|
export function parseLiteral(value, grammar = {}) {
|
|
71
91
|
const s = String(value);
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
import { readFileSync } from 'node:fs';
|
|
13
13
|
import {
|
|
14
14
|
flattenDtcg,
|
|
15
|
-
|
|
15
|
+
flattenPipelineTypes,
|
|
16
|
+
extNamespace,
|
|
16
17
|
resolveValue,
|
|
17
18
|
findModeCollisions,
|
|
18
19
|
TEXT_UNIT_NAMES,
|
|
@@ -83,10 +84,25 @@ export function colorMixToHex8(value) {
|
|
|
83
84
|
// Marks a node whose AUTHORED $value was a whole-value reference, so the hoist
|
|
84
85
|
// can decline to override the type DTCG 5.2.2 rule 1 already determined from the
|
|
85
86
|
// referent. A WeakSet keyed on the node object, rather than a property written
|
|
86
|
-
// onto it,
|
|
87
|
-
// membership along with the rest of the identity, so
|
|
88
|
-
//
|
|
87
|
+
// onto it, keeps this mechanism from leaking across calls: structuredClone
|
|
88
|
+
// drops the membership along with the rest of the identity, so a second pass
|
|
89
|
+
// starts clean rather than inheriting the first pass's marks. That is exactly
|
|
90
|
+
// what a property written onto the node would get wrong. It is not on its own a
|
|
91
|
+
// guarantee that preprocess is idempotent — see the limit recorded at
|
|
92
|
+
// nativePlatform's preprocessors line (#90).
|
|
89
93
|
const WAS_REF = new WeakSet();
|
|
94
|
+
|
|
95
|
+
// The path the AUTHOR wrote for a node the hoist has moved. hoistDualNodes
|
|
96
|
+
// recurses depth-first, so by the time an outer frame takes Object.entries it
|
|
97
|
+
// already contains names the inner frame synthesised — and a collision reported
|
|
98
|
+
// from that frame named a path appearing nowhere in the source (#61). Recording
|
|
99
|
+
// the authored path when a node is hoisted lets the outer frame report what the
|
|
100
|
+
// author can actually search for.
|
|
101
|
+
//
|
|
102
|
+
// Keyed on node identity, like WAS_REF, and safe across calls for the same
|
|
103
|
+
// reason: preprocess structuredClones its input, so every call works on fresh
|
|
104
|
+
// objects.
|
|
105
|
+
const AUTHORED_PATH = new WeakMap();
|
|
90
106
|
const WHOLE_REF = /^\{[^}]+\}$/;
|
|
91
107
|
|
|
92
108
|
function interpolate(value, flat) {
|
|
@@ -159,20 +175,27 @@ function hoistDualNodes(node, collisions, prefix = [], groupType = undefined) {
|
|
|
159
175
|
for (const [childKey, childVal] of Object.entries(val)) {
|
|
160
176
|
if (childKey.startsWith('$') || !childVal || typeof childVal !== 'object') continue;
|
|
161
177
|
const hoisted = key + childKey[0].toUpperCase() + childKey.slice(1);
|
|
162
|
-
const from = [...prefix, key, childKey].join('.');
|
|
178
|
+
const from = AUTHORED_PATH.get(childVal) ?? [...prefix, key, childKey].join('.');
|
|
163
179
|
if (Object.hasOwn(node, hoisted)) {
|
|
164
180
|
const existingNode = node[hoisted];
|
|
165
|
-
|
|
181
|
+
// An array has no $value either, and was therefore labelled "(a group)"
|
|
182
|
+
// (#61). It is neither — an array at this position is malformed DTCG.
|
|
183
|
+
// Say so, rather than naming a shape the author will go looking for.
|
|
184
|
+
const isArray = Array.isArray(existingNode);
|
|
185
|
+
const isGroup =
|
|
186
|
+
existingNode !== null && typeof existingNode === 'object' && !isArray && !('$value' in existingNode);
|
|
166
187
|
collisions.push({
|
|
167
188
|
from,
|
|
168
189
|
onto: [...prefix, hoisted].join('.'),
|
|
169
190
|
isGroup,
|
|
191
|
+
isArray,
|
|
170
192
|
claimant: claimedBy.get(hoisted),
|
|
171
|
-
existing:
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
193
|
+
existing:
|
|
194
|
+
isGroup || isArray
|
|
195
|
+
? undefined
|
|
196
|
+
: existingNode && typeof existingNode === 'object'
|
|
197
|
+
? existingNode.$value
|
|
198
|
+
: existingNode,
|
|
176
199
|
});
|
|
177
200
|
continue;
|
|
178
201
|
}
|
|
@@ -195,8 +218,17 @@ function hoistDualNodes(node, collisions, prefix = [], groupType = undefined) {
|
|
|
195
218
|
// type suits the child badly, because the hoist is not entitled to
|
|
196
219
|
// improve on what the source says; but the carry firing at all is the
|
|
197
220
|
// hoist saying something the source did not.
|
|
221
|
+
//
|
|
222
|
+
// A GROUP child is excluded (#67). The carry's premise is that the dual
|
|
223
|
+
// node was the child's closest $type-bearing ancestor as authored, and
|
|
224
|
+
// the hoist took that away. For a group child the premise never held:
|
|
225
|
+
// 5.2.2 inherits from the closest parent GROUP, the dual node is not
|
|
226
|
+
// one, so it was never that group's inheritance source and the hoist
|
|
227
|
+
// removed nothing. Carrying there does not repair, it invents — and it
|
|
228
|
+
// invents for every token beneath the group, not just one node.
|
|
198
229
|
if (
|
|
199
230
|
!('$type' in childVal) &&
|
|
231
|
+
'$value' in childVal &&
|
|
200
232
|
'$type' in val &&
|
|
201
233
|
!WAS_REF.has(childVal) &&
|
|
202
234
|
inherited === undefined
|
|
@@ -204,6 +236,7 @@ function hoistDualNodes(node, collisions, prefix = [], groupType = undefined) {
|
|
|
204
236
|
childVal.$type = val.$type;
|
|
205
237
|
}
|
|
206
238
|
node[hoisted] = childVal;
|
|
239
|
+
AUTHORED_PATH.set(childVal, from);
|
|
207
240
|
delete val[childKey];
|
|
208
241
|
claimedBy.set(hoisted, from);
|
|
209
242
|
}
|
|
@@ -302,6 +335,9 @@ function classifyTextUnits(node, types, prefix = []) {
|
|
|
302
335
|
types[path.join('.')] === 'dimension' &&
|
|
303
336
|
TEXT_ROLE_UNIT.test(String(val.$value).trim())
|
|
304
337
|
) {
|
|
338
|
+
// extNamespace throws with the token path if the source authored either
|
|
339
|
+
// level as a primitive — legal DTCG this module does not handle (#62).
|
|
340
|
+
extNamespace(val, path.join('.'));
|
|
305
341
|
val.$extensions ??= {};
|
|
306
342
|
val.$extensions[EXT_NS] ??= {};
|
|
307
343
|
const ns = val.$extensions[EXT_NS];
|
|
@@ -310,7 +346,9 @@ function classifyTextUnits(node, types, prefix = []) {
|
|
|
310
346
|
// one, so a unitless value is declined by every size transform regardless
|
|
311
347
|
// of what is stamped here (see isRatio, #52). Declining to overwrite IS
|
|
312
348
|
// the feature: it costs no configuration parameter, and it is what makes
|
|
313
|
-
//
|
|
349
|
+
// STAMPING idempotent — a second pass never rewrites a role a first pass
|
|
350
|
+
// set. What it does not settle is whether a second pass finds the same
|
|
351
|
+
// candidates; the hoist can rename a node between the two (#90).
|
|
314
352
|
if (!('nativeUnit' in ns)) ns.nativeUnit = 'text';
|
|
315
353
|
}
|
|
316
354
|
classifyTextUnits(val, types, path);
|
|
@@ -333,7 +371,8 @@ function classifyTextUnits(node, types, prefix = []) {
|
|
|
333
371
|
// A path may name no node at all. resolveInPlace deliberately leaves an
|
|
334
372
|
// unresolvable reference in place for Style Dictionary to report, so the graph
|
|
335
373
|
// can hold an edge to a token that does not exist. Skip it. This is also what
|
|
336
|
-
// keeps the second preprocess pass from throwing
|
|
374
|
+
// keeps the second preprocess pass from throwing — which is a precondition for
|
|
375
|
+
// idempotency, not the whole of it (#90).
|
|
337
376
|
function applyTextRoleGraph(node, typographic, types) {
|
|
338
377
|
for (const path of typographic) {
|
|
339
378
|
let target = node;
|
|
@@ -343,6 +382,7 @@ function applyTextRoleGraph(node, typographic, types) {
|
|
|
343
382
|
if (!target || typeof target !== 'object' || !('$value' in target)) continue;
|
|
344
383
|
if (types[path] !== 'dimension') continue;
|
|
345
384
|
if (!TEXT_ROLE_UNIT.test(String(target.$value).trim())) continue;
|
|
385
|
+
extNamespace(target, path);
|
|
346
386
|
target.$extensions ??= {};
|
|
347
387
|
target.$extensions[EXT_NS] ??= {};
|
|
348
388
|
const ns = target.$extensions[EXT_NS];
|
|
@@ -357,14 +397,16 @@ export function preprocess(dict) {
|
|
|
357
397
|
// the graph is made of.
|
|
358
398
|
const { typographic } = textRoleGraph(dict);
|
|
359
399
|
const resolved = resolveInPlace(structuredClone(dict), flattenDtcg(dict));
|
|
360
|
-
//
|
|
361
|
-
//
|
|
362
|
-
//
|
|
363
|
-
//
|
|
400
|
+
// Types for the whole tree, walked once and shared by both passes below.
|
|
401
|
+
// Neither writes $type or moves a node, so one map is correct for both, and
|
|
402
|
+
// sharing it is what makes them agree by construction rather than by two
|
|
403
|
+
// copies of the same rule staying in step (#85).
|
|
364
404
|
//
|
|
365
|
-
// Computed on the
|
|
366
|
-
//
|
|
367
|
-
|
|
405
|
+
// Computed on the RAW dict, not the resolved clone. resolveInPlace only
|
|
406
|
+
// rewrites $value strings, so paths and types are identical between the two —
|
|
407
|
+
// except for the carry, whose rule asks whether a value WAS a whole-value
|
|
408
|
+
// reference, and resolution has already destroyed that (#89).
|
|
409
|
+
const types = flattenPipelineTypes(dict);
|
|
368
410
|
const out = hoistDualNodes(
|
|
369
411
|
applyTextRoleGraph(classifyTextUnits(resolved, types), typographic, types),
|
|
370
412
|
collisions,
|
|
@@ -374,6 +416,9 @@ export function preprocess(dict) {
|
|
|
374
416
|
.slice(0, 5)
|
|
375
417
|
.map((c) => {
|
|
376
418
|
const line = ` ${c.from} -> ${c.onto}`;
|
|
419
|
+
if (c.isArray) {
|
|
420
|
+
return line + ' (an array — neither a token nor a group, and not valid DTCG here)';
|
|
421
|
+
}
|
|
377
422
|
if (c.isGroup) {
|
|
378
423
|
return line + (c.claimant ? ` (a group, already claimed by the hoist of ${c.claimant})` : ' (a group)');
|
|
379
424
|
}
|
|
@@ -501,7 +546,14 @@ const DECLINED_STOCK_TRANSFORMS = {
|
|
|
501
546
|
// Order is never compared: our lists are hand-ordered for our own reasons and
|
|
502
547
|
// do not inherit stock order. Removals are never reported: a declined name
|
|
503
548
|
// disappearing is a non-event.
|
|
504
|
-
|
|
549
|
+
// `platforms` and `declined` are parameters with the shipped config as their
|
|
550
|
+
// default, so the contradiction branch below is reachable from a test. The
|
|
551
|
+
// single caller passes neither; the audit's contract is unchanged.
|
|
552
|
+
export function auditStockGroups(
|
|
553
|
+
transformGroups,
|
|
554
|
+
platforms = PLATFORMS,
|
|
555
|
+
declined = DECLINED_STOCK_TRANSFORMS,
|
|
556
|
+
) {
|
|
505
557
|
if (typeof transformGroups !== 'object' || transformGroups === null) {
|
|
506
558
|
return [
|
|
507
559
|
"throughline: could not read Style Dictionary's stock transform groups " +
|
|
@@ -510,7 +562,29 @@ export function auditStockGroups(transformGroups) {
|
|
|
510
562
|
];
|
|
511
563
|
}
|
|
512
564
|
const warnings = [];
|
|
513
|
-
for (const [platform, preset] of Object.entries(
|
|
565
|
+
for (const [platform, preset] of Object.entries(platforms)) {
|
|
566
|
+
// A name in BOTH lists is a contradiction the unaccounted filter below
|
|
567
|
+
// cannot see, because either membership alone suppresses the warning (#75).
|
|
568
|
+
// The config would be saying "we run this" and "we deliberately do not" at
|
|
569
|
+
// once, and whichever is wrong is silently the loser: if the decline is
|
|
570
|
+
// right the transform still runs, and if the run is right the decline is a
|
|
571
|
+
// lie the next maintainer will read as settled. Reported here rather than
|
|
572
|
+
// guarded at the definition, so it travels with the rest of the audit.
|
|
573
|
+
//
|
|
574
|
+
// Checked before stockGroup, because this is wrong regardless of whether
|
|
575
|
+
// Style Dictionary still has the group to compare against.
|
|
576
|
+
if (Array.isArray(preset.transforms)) {
|
|
577
|
+
const contradictory = preset.transforms.filter((name) => Object.hasOwn(declined, name));
|
|
578
|
+
if (contradictory.length) {
|
|
579
|
+
warnings.push(
|
|
580
|
+
`throughline: PLATFORMS['${platform}'] both runs and declines ` +
|
|
581
|
+
`${contradictory.join(', ')}. A transform cannot be in transforms and in ` +
|
|
582
|
+
'DECLINED_STOCK_TRANSFORMS at once — one of the two is wrong, and the ' +
|
|
583
|
+
'audit cannot tell which. This is a throughline packaging defect — ' +
|
|
584
|
+
'please report it.',
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
514
588
|
const group = preset.stockGroup;
|
|
515
589
|
if (!group || !Array.isArray(preset.transforms)) {
|
|
516
590
|
warnings.push(
|
|
@@ -534,7 +608,7 @@ export function auditStockGroups(transformGroups) {
|
|
|
534
608
|
const unaccounted = stock.filter(
|
|
535
609
|
(name) =>
|
|
536
610
|
!preset.transforms.includes(name) &&
|
|
537
|
-
!Object.hasOwn(
|
|
611
|
+
!Object.hasOwn(declined, name),
|
|
538
612
|
);
|
|
539
613
|
if (unaccounted.length) {
|
|
540
614
|
const n = unaccounted.length;
|
|
@@ -636,8 +710,21 @@ export function nativePlatform({ platform, buildPath, className = 'Tokens', pack
|
|
|
636
710
|
// Carried here, not left to the caller: authored() reads the ORIGINAL
|
|
637
711
|
// $value, so without this preprocessor every aliased dimension still holds
|
|
638
712
|
// an unresolved {spacing.space.4}, no size transform fires, and the build
|
|
639
|
-
// emits bare px literals.
|
|
640
|
-
//
|
|
713
|
+
// emits bare px literals.
|
|
714
|
+
//
|
|
715
|
+
// A project that ALSO declares this preprocessor at top level runs it twice,
|
|
716
|
+
// which is harmless in every shape measured — and is the wiring our own
|
|
717
|
+
// usage snippet shows, so it is the common case rather than a corner.
|
|
718
|
+
//
|
|
719
|
+
// Stated no wider than it holds (#90): preprocess is idempotent except where
|
|
720
|
+
// the hoist invents a name the second pass then classifies. `a.font` with a
|
|
721
|
+
// child `size` camel-joins to `a.fontSize`; the first pass correctly declines
|
|
722
|
+
// `size`, and the second sees a typographic member name the source never
|
|
723
|
+
// authored. It changes no emitted output — Style Dictionary runs
|
|
724
|
+
// typeDtcgDelegate between the two passes and types that child anyway, so
|
|
725
|
+
// both passes reach the same file — and the repair belongs with the hoist,
|
|
726
|
+
// which has no way to record the names it invented. Pinned by test rather
|
|
727
|
+
// than papered over.
|
|
641
728
|
preprocessors: ['dtcg/resolve-dual-node'],
|
|
642
729
|
buildPath,
|
|
643
730
|
options: { outputReferences: false },
|
|
@@ -10,13 +10,15 @@ import { pathToFileURL } from 'node:url';
|
|
|
10
10
|
import {
|
|
11
11
|
flattenDtcg,
|
|
12
12
|
flattenDtcgTypes,
|
|
13
|
+
flattenPipelineTypes,
|
|
14
|
+
findDualNodes,
|
|
13
15
|
resolveValue,
|
|
14
16
|
findModeCollisions,
|
|
15
17
|
textRoleGraph,
|
|
16
18
|
mergeDtcg,
|
|
17
19
|
EXT_NS,
|
|
18
20
|
} from './lib/dtcg.mjs';
|
|
19
|
-
import { parseLiteral, isValidLiteral, GRAMMAR } from './lib/native-literal.mjs';
|
|
21
|
+
import { parseLiteral, isValidLiteral, GRAMMAR, CSS_CONSTRUCT_ANYWHERE } from './lib/native-literal.mjs';
|
|
20
22
|
|
|
21
23
|
// Re-exported so consumers (and the test file) keep one import surface.
|
|
22
24
|
export { flattenDtcg, flattenDtcgTypes, resolveValue, findModeCollisions };
|
|
@@ -68,6 +70,34 @@ export function normalizeKey(s) {
|
|
|
68
70
|
return String(s).toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
69
71
|
}
|
|
70
72
|
|
|
73
|
+
// Two source paths that normalize to one key, e.g. color.bg.canvas and
|
|
74
|
+
// colorBg.canvas -> colorbgcanvas. Same map-and-compare shape as
|
|
75
|
+
// findModeCollisions, keyed on the normalized form instead of the raw path.
|
|
76
|
+
//
|
|
77
|
+
// This is not only a matching problem, which is why it gates rather than
|
|
78
|
+
// advises. Measured through Style Dictionary on that exact pair: the build
|
|
79
|
+
// emits `val colorBgCanvas` TWICE and kotlinc rejects the file with
|
|
80
|
+
// "conflicting declarations". The source is ambiguous for native output, and
|
|
81
|
+
// the emitted file does not compile.
|
|
82
|
+
//
|
|
83
|
+
// Before this, the second path silently overwrote the first in byKey, with a
|
|
84
|
+
// consequence in each direction: the loser was never checked at all, and every
|
|
85
|
+
// emitted symbol sharing the key was compared against whichever path happened
|
|
86
|
+
// to sort last — which reported a unit-fidelity failure naming a token that was
|
|
87
|
+
// correct. A wrong diagnosis is worse than none, because it sends the author to
|
|
88
|
+
// the wrong file.
|
|
89
|
+
export function findNormalizationCollisions(paths) {
|
|
90
|
+
const byKey = new Map();
|
|
91
|
+
for (const path of paths) {
|
|
92
|
+
const key = normalizeKey(path);
|
|
93
|
+
if (!byKey.has(key)) byKey.set(key, []);
|
|
94
|
+
byKey.get(key).push(path);
|
|
95
|
+
}
|
|
96
|
+
return [...byKey]
|
|
97
|
+
.filter(([, ps]) => ps.length > 1)
|
|
98
|
+
.map(([key, ps]) => ({ key, paths: ps }));
|
|
99
|
+
}
|
|
100
|
+
|
|
71
101
|
const UNIT = /^(-?(?:\d+(?:\.\d+)?|\.\d+))([a-z%]*)$/;
|
|
72
102
|
|
|
73
103
|
// Expected native magnitude for an authored source value. iOS points and Android
|
|
@@ -101,7 +131,21 @@ export function expectedMagnitude(sourceValue) {
|
|
|
101
131
|
// "var(" (e.g. a $type: string value describing CSS) would also match. The
|
|
102
132
|
// isValidLiteral gate below is what tells those apart: a value the grammar
|
|
103
133
|
// accepts as a literal is not foreign syntax, whatever text it contains.
|
|
104
|
-
|
|
134
|
+
//
|
|
135
|
+
// That holds for Swift. It does NOT hold unconditionally for Kotlin (#57):
|
|
136
|
+
// `${...}` inside a Kotlin string is executable code, and the grammar accepts an
|
|
137
|
+
// unescaped `$`, so `"${calc(1)}"` parses as a valid literal and is exempted
|
|
138
|
+
// here. The exemption also covers a small set of values that are well-formed
|
|
139
|
+
// literals AND named-foreign — calc(2), var(1), and on Kotlin calc(2.dp) — which
|
|
140
|
+
// are kept, pass every rule, and do not compile. None is producible CSS, and
|
|
141
|
+
// this was shipped knowingly when the gate was added; it is written down so the
|
|
142
|
+
// next person meets it as a decision rather than a surprise.
|
|
143
|
+
// Imported, not redeclared (#57). This was an independent copy of the same
|
|
144
|
+
// alternation, while native-literal.mjs's comment promised the build and the
|
|
145
|
+
// gate could not drift apart. Adding a fourth construct name there would have
|
|
146
|
+
// taught the output filter to keep something this rule had never been taught to
|
|
147
|
+
// name — recreating exactly the unreachable-rule defect #56 fixed.
|
|
148
|
+
const FOREIGN = CSS_CONSTRUCT_ANYWHERE;
|
|
105
149
|
const BARE_UNIT = /^-?(?:\d+(?:\.\d+)?|\.\d+)(?:px|rem|em|%)$/;
|
|
106
150
|
|
|
107
151
|
// #52. A unitless value is a ratio, not a measurement: DTCG 8.2.1 requires a
|
|
@@ -117,6 +161,18 @@ const DIMENSIONAL = new Set(['dimension', 'fontSize']);
|
|
|
117
161
|
// alone would flag both the alias and its referent for the same problem.
|
|
118
162
|
const WHOLE_REF = /^\{[^}]+\}$/;
|
|
119
163
|
|
|
164
|
+
// Follow whole-value references to the token an advisory's fix belongs on.
|
|
165
|
+
// Stops at the first path that is not a whole-value reference, at an
|
|
166
|
+
// unresolvable one, and on a cycle — this reports, so it must never throw.
|
|
167
|
+
function referentOf(path, flat, seen = new Set()) {
|
|
168
|
+
const raw = String(flat[path] ?? '').trim();
|
|
169
|
+
if (!WHOLE_REF.test(raw)) return path;
|
|
170
|
+
const next = raw.slice(1, -1);
|
|
171
|
+
if (seen.has(path) || !(next in flat)) return path;
|
|
172
|
+
seen.add(path);
|
|
173
|
+
return referentOf(next, flat, seen);
|
|
174
|
+
}
|
|
175
|
+
|
|
120
176
|
// Lines that are obviously not a would-be token declaration: braces-only,
|
|
121
177
|
// comments, imports/package/annotations, or the container declarations
|
|
122
178
|
// (enum/object/class) themselves. Anything else that DECL failed to match is
|
|
@@ -149,10 +205,33 @@ export function validate({ sources, output, platform, minMatch = 0.5 }) {
|
|
|
149
205
|
for (const { dtcg } of sources) Object.assign(flat, flattenDtcg(dtcg));
|
|
150
206
|
|
|
151
207
|
const types = {};
|
|
152
|
-
|
|
208
|
+
// The PIPELINE's types, not the spec's alone (#71). flattenDtcgTypes reads the
|
|
209
|
+
// raw source, where hoistDualNodes' $type carry has not run — so a unitless,
|
|
210
|
+
// untyped child of a dimension-typed dual node was a dimension to the build
|
|
211
|
+
// and a nothing to this gate, which is the silent case this rule most exists
|
|
212
|
+
// to catch.
|
|
213
|
+
//
|
|
214
|
+
// The issue framed the only fix as running this gate against the PREPROCESSED
|
|
215
|
+
// tree, and rejected it, because the gate would stop checking emitted output
|
|
216
|
+
// against what the author actually wrote. flattenPipelineTypes is a third
|
|
217
|
+
// option that keeps that property: it reads the raw source and MODELS the
|
|
218
|
+
// carry rather than applying it. The gate still reads what the author wrote.
|
|
219
|
+
for (const { dtcg } of sources) Object.assign(types, flattenPipelineTypes(dtcg));
|
|
220
|
+
|
|
221
|
+
// Collided keys are deliberately LEFT OUT of byKey. A symbol whose key is
|
|
222
|
+
// ambiguous then matches nothing, so it falls through to `continue` before any
|
|
223
|
+
// source comparison and is not counted as matched — which is the truth: it did
|
|
224
|
+
// not match a determinate token. That removes the false unit-fidelity failure
|
|
225
|
+
// without a special case in the loop below. The literal, foreign-syntax and
|
|
226
|
+
// bare-unit rules still run on it, because none of them reads the source.
|
|
227
|
+
const normalizationCollisions = findNormalizationCollisions(Object.keys(flat));
|
|
228
|
+
const collided = new Set(normalizationCollisions.map((c) => c.key));
|
|
153
229
|
|
|
154
230
|
const byKey = new Map();
|
|
155
|
-
for (const path of Object.keys(flat))
|
|
231
|
+
for (const path of Object.keys(flat)) {
|
|
232
|
+
const key = normalizeKey(path);
|
|
233
|
+
if (!collided.has(key)) byKey.set(key, path);
|
|
234
|
+
}
|
|
156
235
|
|
|
157
236
|
const decls = extractDeclarations(output, platform);
|
|
158
237
|
const failures = [];
|
|
@@ -197,19 +276,35 @@ export function validate({ sources, output, platform, minMatch = 0.5 }) {
|
|
|
197
276
|
// Advisory, not a failure: the emitted value is correct under the ratio
|
|
198
277
|
// reading this build applies, so it compiles and its magnitude matches.
|
|
199
278
|
// What is wrong is the SOURCE's $type, which only the author can settle.
|
|
279
|
+
//
|
|
280
|
+
// An alias is skipped only when its REFERENT is itself dimension-typed, so
|
|
281
|
+
// the referent's own symbol reports it — that is #69's de-duplication, kept.
|
|
282
|
+
// A blanket skip on any whole-value reference (#72) meant an untyped base
|
|
283
|
+
// behind a typed alias was reported nowhere: the base is not dimension-typed
|
|
284
|
+
// so it never fires, and the alias was skipped for being a reference. The
|
|
285
|
+
// advisory is attributed to the referent either way, because that is the
|
|
286
|
+
// token whose $type or unit the author has to change.
|
|
287
|
+
const aliased = WHOLE_REF.test(String(flat[path]).trim());
|
|
288
|
+
const target = aliased ? referentOf(path, flat) : path;
|
|
200
289
|
if (
|
|
201
290
|
UNITLESS.test(String(source).trim()) &&
|
|
202
291
|
DIMENSIONAL.has(types[path]) &&
|
|
203
|
-
!
|
|
292
|
+
!(aliased && DIMENSIONAL.has(types[target]))
|
|
204
293
|
) {
|
|
205
|
-
advisories.push({ rule: 'unitless-dimension', symbol, token:
|
|
294
|
+
advisories.push({ rule: 'unitless-dimension', symbol, token: target, source, emitted: value });
|
|
206
295
|
}
|
|
207
296
|
|
|
208
297
|
const expected = expectedMagnitude(source);
|
|
209
298
|
if (expected.skip) continue;
|
|
210
299
|
const actual = magnitudeOf(value);
|
|
211
300
|
if (actual === null) {
|
|
212
|
-
|
|
301
|
+
// no-foreign-syntax already explains why the magnitude could not be read,
|
|
302
|
+
// and names the actual cause. "The token was never actually compared" beside
|
|
303
|
+
// it is a red herring pointing at the symptom (#57). Same suppression the
|
|
304
|
+
// three literal rules already apply to each other.
|
|
305
|
+
if (!foreign) {
|
|
306
|
+
failures.push({ rule: 'unverifiable-dimension', symbol, token: path, source, emitted: value });
|
|
307
|
+
}
|
|
213
308
|
continue;
|
|
214
309
|
}
|
|
215
310
|
if (Math.abs(actual - expected.magnitude) > 0.001) {
|
|
@@ -227,6 +322,24 @@ export function validate({ sources, output, platform, minMatch = 0.5 }) {
|
|
|
227
322
|
// build that actually ran, and a build merges with the later source winning.
|
|
228
323
|
// A union would call a token referenced when this build did not reach it,
|
|
229
324
|
// under-reporting the gap in the one direction that matters.
|
|
325
|
+
// #58. A node carrying both a $value and children is invalid DTCG (§6.1, and
|
|
326
|
+
// §6.2 defines $root as the sanctioned spelling), and nothing told the author
|
|
327
|
+
// so. ADVISORY, not a failure, and deliberately: every Figma-derived source
|
|
328
|
+
// has dozens — the real one this is validated against has 13 — so failing on
|
|
329
|
+
// it would make the gate useless on day one for exactly the people this tool
|
|
330
|
+
// targets. The build keeps handling them; the author now learns the shape is
|
|
331
|
+
// non-conforming and what to write instead.
|
|
332
|
+
//
|
|
333
|
+
// One advisory for the whole finding rather than one per node. It is a single
|
|
334
|
+
// structural fact about the source, and thirteen near-identical lines would
|
|
335
|
+
// bury the rest of the report.
|
|
336
|
+
//
|
|
337
|
+
// Read per source file, not from the merged dict: a merge can conceal a dual
|
|
338
|
+
// node whose children come from one file and whose $value comes from another,
|
|
339
|
+
// and the author fixes this file by file.
|
|
340
|
+
const dualNodes = [...new Set(sources.flatMap((s) => findDualNodes(s.dtcg)))];
|
|
341
|
+
if (dualNodes.length) advisories.push({ rule: 'dual-node', paths: dualNodes });
|
|
342
|
+
|
|
230
343
|
const graph = textRoleGraph(mergeDtcg(sources.map((s) => s.dtcg)));
|
|
231
344
|
for (const { path, group } of graph.unreferencedSiblings) {
|
|
232
345
|
advisories.push({ rule: 'unreferenced-text-sibling', token: path, group });
|
|
@@ -236,14 +349,26 @@ export function validate({ sources, output, platform, minMatch = 0.5 }) {
|
|
|
236
349
|
}
|
|
237
350
|
|
|
238
351
|
const matchRate = decls.length ? matched / decls.length : 0;
|
|
239
|
-
const ok =
|
|
352
|
+
const ok =
|
|
353
|
+
failures.length === 0 &&
|
|
354
|
+
collisions.length === 0 &&
|
|
355
|
+
normalizationCollisions.length === 0 &&
|
|
356
|
+
matched > 0 &&
|
|
357
|
+
matchRate >= minMatch;
|
|
240
358
|
|
|
241
359
|
const unparsedLines = countUnparsedLines(output, DECL[platform]);
|
|
360
|
+
// NAMED, not just counted (#57). A token with no native form is filtered out
|
|
361
|
+
// of native output, and a nested construct like rgba(var(--brand), 0.5) is one
|
|
362
|
+
// of them — the filter's exemption is anchored, deliberately, because the
|
|
363
|
+
// module cannot tell a rescuable outer function from linear-gradient(). Before
|
|
364
|
+
// this the only trace such a token left was a number, which is the same
|
|
365
|
+
// silence this release exists to remove everywhere else.
|
|
242
366
|
const emittedKeys = new Set(decls.map((d) => normalizeKey(d.symbol)));
|
|
243
|
-
|
|
244
|
-
for (const key of byKey
|
|
367
|
+
const unemittedPaths = [];
|
|
368
|
+
for (const [key, path] of byKey) if (!emittedKeys.has(key)) unemittedPaths.push(path);
|
|
369
|
+
const unemittedTokens = unemittedPaths.length;
|
|
245
370
|
|
|
246
|
-
return { total: decls.length, matched, matchRate, failures, advisories, collisions, minMatch, ok, unparsedLines, unemittedTokens };
|
|
371
|
+
return { total: decls.length, matched, matchRate, failures, advisories, collisions, normalizationCollisions, minMatch, ok, unparsedLines, unemittedTokens, unemittedPaths };
|
|
247
372
|
}
|
|
248
373
|
|
|
249
374
|
export function formatReport(r) {
|
|
@@ -256,6 +381,17 @@ export function formatReport(r) {
|
|
|
256
381
|
lines.push(` - ${c.path}: ${c.defs.map((d) => `${d.file}=${JSON.stringify(d.value)}`).join(', ')}`);
|
|
257
382
|
}
|
|
258
383
|
}
|
|
384
|
+
if (r.normalizationCollisions?.length) {
|
|
385
|
+
lines.push(
|
|
386
|
+
`\n${r.normalizationCollisions.length} name collision(s) — distinct source paths that reduce to one symbol name:`,
|
|
387
|
+
);
|
|
388
|
+
for (const c of r.normalizationCollisions) {
|
|
389
|
+
lines.push(` - ${c.key}: ${c.paths.join(' vs ')}`);
|
|
390
|
+
}
|
|
391
|
+
lines.push(
|
|
392
|
+
`\nThese emit the same symbol name, so the generated file declares it more than once and will not compile. They are also excluded from matching above, because there is no way to tell which source token an emitted symbol came from. Rename one side in source.`,
|
|
393
|
+
);
|
|
394
|
+
}
|
|
259
395
|
if (r.failures.length) {
|
|
260
396
|
lines.push(`\n${r.failures.length} rule failure(s):`);
|
|
261
397
|
for (const f of r.failures) {
|
|
@@ -275,10 +411,20 @@ export function formatReport(r) {
|
|
|
275
411
|
);
|
|
276
412
|
}
|
|
277
413
|
}
|
|
414
|
+
// The naming-convention diagnosis is wrong when collisions are what removed
|
|
415
|
+
// the tokens, and a confident wrong cause sends the author to the wrong file.
|
|
416
|
+
// Name the collisions instead, and only then fall back to the convention.
|
|
417
|
+
const collisionNote = r.normalizationCollisions?.length
|
|
418
|
+
? ` The ${r.normalizationCollisions.length} name collision(s) above were excluded from matching, which may be the whole of it — resolve those first.`
|
|
419
|
+
: '';
|
|
278
420
|
if (r.matched === 0) {
|
|
279
|
-
lines.push(
|
|
421
|
+
lines.push(
|
|
422
|
+
r.normalizationCollisions?.length
|
|
423
|
+
? `\nNo emitted symbol matched any source token, so nothing was actually verified.${collisionNote}`
|
|
424
|
+
: `\nNo emitted symbol matched any source token — the adapter's naming convention does not line up, so nothing was actually verified. A likely cause is a declaration form the DECL pattern does not match (e.g. a different accessControl such as "internal static let ...").`,
|
|
425
|
+
);
|
|
280
426
|
} else if (r.matchRate < r.minMatch) {
|
|
281
|
-
lines.push(`\nMatch rate ${pct}% is below the ${(r.minMatch * 100).toFixed(0)}% floor — most output went unchecked
|
|
427
|
+
lines.push(`\nMatch rate ${pct}% is below the ${(r.minMatch * 100).toFixed(0)}% floor — most output went unchecked.${collisionNote}`);
|
|
282
428
|
}
|
|
283
429
|
if (r.unparsedLines) {
|
|
284
430
|
lines.push(`\n${r.unparsedLines} unparsed line(s) — declaration-shaped lines the extractor could not read; they count in neither the numerator nor the denominator above.`);
|
|
@@ -292,6 +438,14 @@ export function formatReport(r) {
|
|
|
292
438
|
);
|
|
293
439
|
continue;
|
|
294
440
|
}
|
|
441
|
+
if (a.rule === 'dual-node') {
|
|
442
|
+
const shown = a.paths.slice(0, 5).join(', ');
|
|
443
|
+
const more = a.paths.length > 5 ? `, ...and ${a.paths.length - 5} more` : '';
|
|
444
|
+
lines.push(
|
|
445
|
+
` - [${a.rule}] ${a.paths.length} node(s) carry both a $value and child tokens: ${shown}${more}. DTCG §6.1 makes that invalid — an object cannot be both a token and a group — and §6.2 defines $root as the way a group carries a base value alongside children. The build handles this shape and will keep handling it; nothing here is broken. Rewrite them as $root only if you want the source to conform.`,
|
|
446
|
+
);
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
295
449
|
if (a.rule === 'ambiguous-text-role') {
|
|
296
450
|
lines.push(
|
|
297
451
|
` - [${a.rule}] ${a.token}: referenced both by typographic member(s) [${a.textLeaves.join(', ')}] and by [${a.otherLeaves.join(', ')}], so no role was inferred rather than a role being guessed. Stamp $extensions["${EXT_NS}"].nativeUnit in source to settle it.`,
|
|
@@ -304,7 +458,13 @@ export function formatReport(r) {
|
|
|
304
458
|
}
|
|
305
459
|
}
|
|
306
460
|
if (r.unemittedTokens) {
|
|
307
|
-
|
|
461
|
+
const paths = r.unemittedPaths ?? [];
|
|
462
|
+
const shown = paths.slice(0, 10).join(', ');
|
|
463
|
+
const more = paths.length > 10 ? `, ...and ${paths.length - 10} more` : '';
|
|
464
|
+
lines.push(
|
|
465
|
+
`\n${r.unemittedTokens} source token(s) had no matching emitted symbol${paths.length ? `: ${shown}${more}` : ''}.` +
|
|
466
|
+
' A value with no native form is filtered out of native output rather than emitted broken — a CSS construct nested inside another function is the common case.',
|
|
467
|
+
);
|
|
308
468
|
}
|
|
309
469
|
return lines;
|
|
310
470
|
}
|