@radicool/throughline 0.17.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 +142 -27
- package/scripts/lib/dtcg.mjs +127 -3
- package/scripts/lib/native-literal.mjs +22 -2
- package/scripts/lib/sd-native.mjs +142 -27
- 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,6 +54,8 @@ consumer's repo.
|
|
|
54
54
|
import { readFileSync } from 'node:fs';
|
|
55
55
|
import {
|
|
56
56
|
flattenDtcg,
|
|
57
|
+
flattenPipelineTypes,
|
|
58
|
+
extNamespace,
|
|
57
59
|
resolveValue,
|
|
58
60
|
findModeCollisions,
|
|
59
61
|
TEXT_UNIT_NAMES,
|
|
@@ -151,10 +153,25 @@ Both are fixed before Style Dictionary sees the tree.
|
|
|
151
153
|
// Marks a node whose AUTHORED $value was a whole-value reference, so the hoist
|
|
152
154
|
// can decline to override the type DTCG 5.2.2 rule 1 already determined from the
|
|
153
155
|
// referent. A WeakSet keyed on the node object, rather than a property written
|
|
154
|
-
// onto it,
|
|
155
|
-
// membership along with the rest of the identity, so
|
|
156
|
-
//
|
|
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).
|
|
157
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();
|
|
158
175
|
const WHOLE_REF = /^\{[^}]+\}$/;
|
|
159
176
|
|
|
160
177
|
function interpolate(value, flat) {
|
|
@@ -227,20 +244,27 @@ function hoistDualNodes(node, collisions, prefix = [], groupType = undefined) {
|
|
|
227
244
|
for (const [childKey, childVal] of Object.entries(val)) {
|
|
228
245
|
if (childKey.startsWith('$') || !childVal || typeof childVal !== 'object') continue;
|
|
229
246
|
const hoisted = key + childKey[0].toUpperCase() + childKey.slice(1);
|
|
230
|
-
const from = [...prefix, key, childKey].join('.');
|
|
247
|
+
const from = AUTHORED_PATH.get(childVal) ?? [...prefix, key, childKey].join('.');
|
|
231
248
|
if (Object.hasOwn(node, hoisted)) {
|
|
232
249
|
const existingNode = node[hoisted];
|
|
233
|
-
|
|
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);
|
|
234
256
|
collisions.push({
|
|
235
257
|
from,
|
|
236
258
|
onto: [...prefix, hoisted].join('.'),
|
|
237
259
|
isGroup,
|
|
260
|
+
isArray,
|
|
238
261
|
claimant: claimedBy.get(hoisted),
|
|
239
|
-
existing:
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
262
|
+
existing:
|
|
263
|
+
isGroup || isArray
|
|
264
|
+
? undefined
|
|
265
|
+
: existingNode && typeof existingNode === 'object'
|
|
266
|
+
? existingNode.$value
|
|
267
|
+
: existingNode,
|
|
244
268
|
});
|
|
245
269
|
continue;
|
|
246
270
|
}
|
|
@@ -263,8 +287,17 @@ function hoistDualNodes(node, collisions, prefix = [], groupType = undefined) {
|
|
|
263
287
|
// type suits the child badly, because the hoist is not entitled to
|
|
264
288
|
// improve on what the source says; but the carry firing at all is the
|
|
265
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.
|
|
266
298
|
if (
|
|
267
299
|
!('$type' in childVal) &&
|
|
300
|
+
'$value' in childVal &&
|
|
268
301
|
'$type' in val &&
|
|
269
302
|
!WAS_REF.has(childVal) &&
|
|
270
303
|
inherited === undefined
|
|
@@ -272,6 +305,7 @@ function hoistDualNodes(node, collisions, prefix = [], groupType = undefined) {
|
|
|
272
305
|
childVal.$type = val.$type;
|
|
273
306
|
}
|
|
274
307
|
node[hoisted] = childVal;
|
|
308
|
+
AUTHORED_PATH.set(childVal, from);
|
|
275
309
|
delete val[childKey];
|
|
276
310
|
claimedBy.set(hoisted, from);
|
|
277
311
|
}
|
|
@@ -342,15 +376,37 @@ export { EXT_NS };
|
|
|
342
376
|
// on is gone. Matching a suffix against the camel-joined name instead would
|
|
343
377
|
// couple the rule to the hoist's naming scheme, and case-insensitively it
|
|
344
378
|
// false-positives on names like baselineHeight.
|
|
345
|
-
|
|
379
|
+
//
|
|
380
|
+
// The type comes from `types`, a DTCG 5.2.2 resolution of the whole tree, not
|
|
381
|
+
// from the token's own literal $type. Reading val.$type made this pass blind to
|
|
382
|
+
// a source that declares $type once on the group — legal DTCG, and on such a
|
|
383
|
+
// source the reference-graph inference stamped nothing at all (#85). A token's
|
|
384
|
+
// own $type still wins; the group's applies only where the token states none.
|
|
385
|
+
//
|
|
386
|
+
// WHERE THIS MATTERS, measured rather than assumed. Style Dictionary runs
|
|
387
|
+
// global preprocessors, THEN its own typeDtcgDelegate — which is 5.2.2, pushing
|
|
388
|
+
// each group's $type onto its descendants — then platform preprocessors
|
|
389
|
+
// (StyleDictionary.js:340, :348, :440 in 4.4.0). nativePlatform registers this
|
|
390
|
+
// preprocessor at PLATFORM level, downstream of that delegation, so a build
|
|
391
|
+
// wired only through nativePlatform never saw the defect: on a group-typed
|
|
392
|
+
// re-encoding of a real system it emits the same 208 declarations and 48 sp
|
|
393
|
+
// either way. The defect reaches the build that ALSO declares this preprocessor
|
|
394
|
+
// at top level, which is the wiring the usage snippet above shows — there the
|
|
395
|
+
// first pass runs before any delegation. Resolving the type here makes both
|
|
396
|
+
// wirings agree instead of depending on which one a consumer copied.
|
|
397
|
+
function classifyTextUnits(node, types, prefix = []) {
|
|
346
398
|
for (const [key, val] of Object.entries(node)) {
|
|
347
399
|
if (key.startsWith('$') || !val || typeof val !== 'object') continue;
|
|
400
|
+
const path = [...prefix, key];
|
|
348
401
|
if (
|
|
349
402
|
TEXT_UNIT_NAMES.has(key) &&
|
|
350
403
|
'$value' in val &&
|
|
351
|
-
|
|
404
|
+
types[path.join('.')] === 'dimension' &&
|
|
352
405
|
TEXT_ROLE_UNIT.test(String(val.$value).trim())
|
|
353
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('.'));
|
|
354
410
|
val.$extensions ??= {};
|
|
355
411
|
val.$extensions[EXT_NS] ??= {};
|
|
356
412
|
const ns = val.$extensions[EXT_NS];
|
|
@@ -359,10 +415,12 @@ function classifyTextUnits(node) {
|
|
|
359
415
|
// one, so a unitless value is declined by every size transform regardless
|
|
360
416
|
// of what is stamped here (see isRatio, #52). Declining to overwrite IS
|
|
361
417
|
// the feature: it costs no configuration parameter, and it is what makes
|
|
362
|
-
//
|
|
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).
|
|
363
421
|
if (!('nativeUnit' in ns)) ns.nativeUnit = 'text';
|
|
364
422
|
}
|
|
365
|
-
classifyTextUnits(val);
|
|
423
|
+
classifyTextUnits(val, types, path);
|
|
366
424
|
}
|
|
367
425
|
return node;
|
|
368
426
|
}
|
|
@@ -373,23 +431,27 @@ function classifyTextUnits(node) {
|
|
|
373
431
|
// text.xsLineHeight and the graph's paths are written in pre-hoist names.
|
|
374
432
|
//
|
|
375
433
|
// The three gates are classifyTextUnits's, verbatim — a dimension, a value with
|
|
376
|
-
// a unit, and no role already recorded.
|
|
434
|
+
// a unit, and no role already recorded. Both passes read the SAME resolved-type
|
|
435
|
+
// map, which is what makes them agree by construction rather than by two copies
|
|
436
|
+
// of DTCG 5.2.2 staying in step. A unitless value is never stamped: no
|
|
377
437
|
// size transform claims one since #52, and stamping a ratio as text would still
|
|
378
438
|
// be a claim the source never made.
|
|
379
439
|
//
|
|
380
440
|
// A path may name no node at all. resolveInPlace deliberately leaves an
|
|
381
441
|
// unresolvable reference in place for Style Dictionary to report, so the graph
|
|
382
442
|
// can hold an edge to a token that does not exist. Skip it. This is also what
|
|
383
|
-
// keeps the second preprocess pass from throwing
|
|
384
|
-
|
|
443
|
+
// keeps the second preprocess pass from throwing — which is a precondition for
|
|
444
|
+
// idempotency, not the whole of it (#90).
|
|
445
|
+
function applyTextRoleGraph(node, typographic, types) {
|
|
385
446
|
for (const path of typographic) {
|
|
386
447
|
let target = node;
|
|
387
448
|
for (const segment of path.split('.')) {
|
|
388
449
|
target = target && typeof target === 'object' ? target[segment] : undefined;
|
|
389
450
|
}
|
|
390
451
|
if (!target || typeof target !== 'object' || !('$value' in target)) continue;
|
|
391
|
-
if (
|
|
452
|
+
if (types[path] !== 'dimension') continue;
|
|
392
453
|
if (!TEXT_ROLE_UNIT.test(String(target.$value).trim())) continue;
|
|
454
|
+
extNamespace(target, path);
|
|
393
455
|
target.$extensions ??= {};
|
|
394
456
|
target.$extensions[EXT_NS] ??= {};
|
|
395
457
|
const ns = target.$extensions[EXT_NS];
|
|
@@ -403,11 +465,19 @@ export function preprocess(dict) {
|
|
|
403
465
|
// Read from the UNRESOLVED dict, before resolveInPlace flattens the aliases
|
|
404
466
|
// the graph is made of.
|
|
405
467
|
const { typographic } = textRoleGraph(dict);
|
|
468
|
+
const resolved = resolveInPlace(structuredClone(dict), flattenDtcg(dict));
|
|
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).
|
|
473
|
+
//
|
|
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);
|
|
406
479
|
const out = hoistDualNodes(
|
|
407
|
-
applyTextRoleGraph(
|
|
408
|
-
classifyTextUnits(resolveInPlace(structuredClone(dict), flattenDtcg(dict))),
|
|
409
|
-
typographic,
|
|
410
|
-
),
|
|
480
|
+
applyTextRoleGraph(classifyTextUnits(resolved, types), typographic, types),
|
|
411
481
|
collisions,
|
|
412
482
|
);
|
|
413
483
|
if (collisions.length) {
|
|
@@ -415,6 +485,9 @@ export function preprocess(dict) {
|
|
|
415
485
|
.slice(0, 5)
|
|
416
486
|
.map((c) => {
|
|
417
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
|
+
}
|
|
418
491
|
if (c.isGroup) {
|
|
419
492
|
return line + (c.claimant ? ` (a group, already claimed by the hoist of ${c.claimant})` : ' (a group)');
|
|
420
493
|
}
|
|
@@ -601,7 +674,14 @@ const DECLINED_STOCK_TRANSFORMS = {
|
|
|
601
674
|
// Order is never compared: our lists are hand-ordered for our own reasons and
|
|
602
675
|
// do not inherit stock order. Removals are never reported: a declined name
|
|
603
676
|
// disappearing is a non-event.
|
|
604
|
-
|
|
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
|
+
) {
|
|
605
685
|
if (typeof transformGroups !== 'object' || transformGroups === null) {
|
|
606
686
|
return [
|
|
607
687
|
"throughline: could not read Style Dictionary's stock transform groups " +
|
|
@@ -610,7 +690,29 @@ export function auditStockGroups(transformGroups) {
|
|
|
610
690
|
];
|
|
611
691
|
}
|
|
612
692
|
const warnings = [];
|
|
613
|
-
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
|
+
}
|
|
614
716
|
const group = preset.stockGroup;
|
|
615
717
|
if (!group || !Array.isArray(preset.transforms)) {
|
|
616
718
|
warnings.push(
|
|
@@ -634,7 +736,7 @@ export function auditStockGroups(transformGroups) {
|
|
|
634
736
|
const unaccounted = stock.filter(
|
|
635
737
|
(name) =>
|
|
636
738
|
!preset.transforms.includes(name) &&
|
|
637
|
-
!Object.hasOwn(
|
|
739
|
+
!Object.hasOwn(declined, name),
|
|
638
740
|
);
|
|
639
741
|
if (unaccounted.length) {
|
|
640
742
|
const n = unaccounted.length;
|
|
@@ -736,8 +838,21 @@ export function nativePlatform({ platform, buildPath, className = 'Tokens', pack
|
|
|
736
838
|
// Carried here, not left to the caller: authored() reads the ORIGINAL
|
|
737
839
|
// $value, so without this preprocessor every aliased dimension still holds
|
|
738
840
|
// an unresolved {spacing.space.4}, no size transform fires, and the build
|
|
739
|
-
// emits bare px literals.
|
|
740
|
-
//
|
|
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.
|
|
741
856
|
preprocessors: ['dtcg/resolve-dual-node'],
|
|
742
857
|
buildPath,
|
|
743
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)) {
|
|
@@ -178,6 +296,12 @@ export function textRoleGraph(dict) {
|
|
|
178
296
|
// failure this module exists to prevent. A token whose source already stamps
|
|
179
297
|
// nativeUnit is closed and is not reported.
|
|
180
298
|
const inferredGroups = new Set([...typographic].map((p) => p.split('.').slice(0, -1).join('.')));
|
|
299
|
+
// DTCG 5.2.2, not the token's own literal $type: a source that declares
|
|
300
|
+
// $type once on the group and not on each token is legal DTCG, and gating on
|
|
301
|
+
// val.$type made this walk blind to it — so the advisory that exists to name
|
|
302
|
+
// a silent gap was itself silent on the shape where the whole pipeline goes
|
|
303
|
+
// quiet (#85).
|
|
304
|
+
const types = flattenDtcgTypes(dict);
|
|
181
305
|
const unreferencedSiblings = [];
|
|
182
306
|
(function walk(node, prefix) {
|
|
183
307
|
for (const [key, val] of Object.entries(node)) {
|
|
@@ -187,10 +311,10 @@ export function textRoleGraph(dict) {
|
|
|
187
311
|
const group = prefix.join('.');
|
|
188
312
|
if (
|
|
189
313
|
'$value' in val &&
|
|
190
|
-
|
|
314
|
+
types[dotted] === 'dimension' &&
|
|
191
315
|
TEXT_ROLE_UNIT.test(String(val.$value).trim()) &&
|
|
192
316
|
!referrers.has(dotted) &&
|
|
193
|
-
!('nativeUnit' in (val
|
|
317
|
+
!('nativeUnit' in (extNamespace(val, dotted) ?? {})) &&
|
|
194
318
|
inferredGroups.has(group)
|
|
195
319
|
) {
|
|
196
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,6 +12,8 @@
|
|
|
12
12
|
import { readFileSync } from 'node:fs';
|
|
13
13
|
import {
|
|
14
14
|
flattenDtcg,
|
|
15
|
+
flattenPipelineTypes,
|
|
16
|
+
extNamespace,
|
|
15
17
|
resolveValue,
|
|
16
18
|
findModeCollisions,
|
|
17
19
|
TEXT_UNIT_NAMES,
|
|
@@ -82,10 +84,25 @@ export function colorMixToHex8(value) {
|
|
|
82
84
|
// Marks a node whose AUTHORED $value was a whole-value reference, so the hoist
|
|
83
85
|
// can decline to override the type DTCG 5.2.2 rule 1 already determined from the
|
|
84
86
|
// referent. A WeakSet keyed on the node object, rather than a property written
|
|
85
|
-
// onto it,
|
|
86
|
-
// membership along with the rest of the identity, so
|
|
87
|
-
//
|
|
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).
|
|
88
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();
|
|
89
106
|
const WHOLE_REF = /^\{[^}]+\}$/;
|
|
90
107
|
|
|
91
108
|
function interpolate(value, flat) {
|
|
@@ -158,20 +175,27 @@ function hoistDualNodes(node, collisions, prefix = [], groupType = undefined) {
|
|
|
158
175
|
for (const [childKey, childVal] of Object.entries(val)) {
|
|
159
176
|
if (childKey.startsWith('$') || !childVal || typeof childVal !== 'object') continue;
|
|
160
177
|
const hoisted = key + childKey[0].toUpperCase() + childKey.slice(1);
|
|
161
|
-
const from = [...prefix, key, childKey].join('.');
|
|
178
|
+
const from = AUTHORED_PATH.get(childVal) ?? [...prefix, key, childKey].join('.');
|
|
162
179
|
if (Object.hasOwn(node, hoisted)) {
|
|
163
180
|
const existingNode = node[hoisted];
|
|
164
|
-
|
|
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);
|
|
165
187
|
collisions.push({
|
|
166
188
|
from,
|
|
167
189
|
onto: [...prefix, hoisted].join('.'),
|
|
168
190
|
isGroup,
|
|
191
|
+
isArray,
|
|
169
192
|
claimant: claimedBy.get(hoisted),
|
|
170
|
-
existing:
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
193
|
+
existing:
|
|
194
|
+
isGroup || isArray
|
|
195
|
+
? undefined
|
|
196
|
+
: existingNode && typeof existingNode === 'object'
|
|
197
|
+
? existingNode.$value
|
|
198
|
+
: existingNode,
|
|
175
199
|
});
|
|
176
200
|
continue;
|
|
177
201
|
}
|
|
@@ -194,8 +218,17 @@ function hoistDualNodes(node, collisions, prefix = [], groupType = undefined) {
|
|
|
194
218
|
// type suits the child badly, because the hoist is not entitled to
|
|
195
219
|
// improve on what the source says; but the carry firing at all is the
|
|
196
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.
|
|
197
229
|
if (
|
|
198
230
|
!('$type' in childVal) &&
|
|
231
|
+
'$value' in childVal &&
|
|
199
232
|
'$type' in val &&
|
|
200
233
|
!WAS_REF.has(childVal) &&
|
|
201
234
|
inherited === undefined
|
|
@@ -203,6 +236,7 @@ function hoistDualNodes(node, collisions, prefix = [], groupType = undefined) {
|
|
|
203
236
|
childVal.$type = val.$type;
|
|
204
237
|
}
|
|
205
238
|
node[hoisted] = childVal;
|
|
239
|
+
AUTHORED_PATH.set(childVal, from);
|
|
206
240
|
delete val[childKey];
|
|
207
241
|
claimedBy.set(hoisted, from);
|
|
208
242
|
}
|
|
@@ -273,15 +307,37 @@ export { EXT_NS };
|
|
|
273
307
|
// on is gone. Matching a suffix against the camel-joined name instead would
|
|
274
308
|
// couple the rule to the hoist's naming scheme, and case-insensitively it
|
|
275
309
|
// false-positives on names like baselineHeight.
|
|
276
|
-
|
|
310
|
+
//
|
|
311
|
+
// The type comes from `types`, a DTCG 5.2.2 resolution of the whole tree, not
|
|
312
|
+
// from the token's own literal $type. Reading val.$type made this pass blind to
|
|
313
|
+
// a source that declares $type once on the group — legal DTCG, and on such a
|
|
314
|
+
// source the reference-graph inference stamped nothing at all (#85). A token's
|
|
315
|
+
// own $type still wins; the group's applies only where the token states none.
|
|
316
|
+
//
|
|
317
|
+
// WHERE THIS MATTERS, measured rather than assumed. Style Dictionary runs
|
|
318
|
+
// global preprocessors, THEN its own typeDtcgDelegate — which is 5.2.2, pushing
|
|
319
|
+
// each group's $type onto its descendants — then platform preprocessors
|
|
320
|
+
// (StyleDictionary.js:340, :348, :440 in 4.4.0). nativePlatform registers this
|
|
321
|
+
// preprocessor at PLATFORM level, downstream of that delegation, so a build
|
|
322
|
+
// wired only through nativePlatform never saw the defect: on a group-typed
|
|
323
|
+
// re-encoding of a real system it emits the same 208 declarations and 48 sp
|
|
324
|
+
// either way. The defect reaches the build that ALSO declares this preprocessor
|
|
325
|
+
// at top level, which is the wiring the usage snippet above shows — there the
|
|
326
|
+
// first pass runs before any delegation. Resolving the type here makes both
|
|
327
|
+
// wirings agree instead of depending on which one a consumer copied.
|
|
328
|
+
function classifyTextUnits(node, types, prefix = []) {
|
|
277
329
|
for (const [key, val] of Object.entries(node)) {
|
|
278
330
|
if (key.startsWith('$') || !val || typeof val !== 'object') continue;
|
|
331
|
+
const path = [...prefix, key];
|
|
279
332
|
if (
|
|
280
333
|
TEXT_UNIT_NAMES.has(key) &&
|
|
281
334
|
'$value' in val &&
|
|
282
|
-
|
|
335
|
+
types[path.join('.')] === 'dimension' &&
|
|
283
336
|
TEXT_ROLE_UNIT.test(String(val.$value).trim())
|
|
284
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('.'));
|
|
285
341
|
val.$extensions ??= {};
|
|
286
342
|
val.$extensions[EXT_NS] ??= {};
|
|
287
343
|
const ns = val.$extensions[EXT_NS];
|
|
@@ -290,10 +346,12 @@ function classifyTextUnits(node) {
|
|
|
290
346
|
// one, so a unitless value is declined by every size transform regardless
|
|
291
347
|
// of what is stamped here (see isRatio, #52). Declining to overwrite IS
|
|
292
348
|
// the feature: it costs no configuration parameter, and it is what makes
|
|
293
|
-
//
|
|
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).
|
|
294
352
|
if (!('nativeUnit' in ns)) ns.nativeUnit = 'text';
|
|
295
353
|
}
|
|
296
|
-
classifyTextUnits(val);
|
|
354
|
+
classifyTextUnits(val, types, path);
|
|
297
355
|
}
|
|
298
356
|
return node;
|
|
299
357
|
}
|
|
@@ -304,23 +362,27 @@ function classifyTextUnits(node) {
|
|
|
304
362
|
// text.xsLineHeight and the graph's paths are written in pre-hoist names.
|
|
305
363
|
//
|
|
306
364
|
// The three gates are classifyTextUnits's, verbatim — a dimension, a value with
|
|
307
|
-
// a unit, and no role already recorded.
|
|
365
|
+
// a unit, and no role already recorded. Both passes read the SAME resolved-type
|
|
366
|
+
// map, which is what makes them agree by construction rather than by two copies
|
|
367
|
+
// of DTCG 5.2.2 staying in step. A unitless value is never stamped: no
|
|
308
368
|
// size transform claims one since #52, and stamping a ratio as text would still
|
|
309
369
|
// be a claim the source never made.
|
|
310
370
|
//
|
|
311
371
|
// A path may name no node at all. resolveInPlace deliberately leaves an
|
|
312
372
|
// unresolvable reference in place for Style Dictionary to report, so the graph
|
|
313
373
|
// can hold an edge to a token that does not exist. Skip it. This is also what
|
|
314
|
-
// keeps the second preprocess pass from throwing
|
|
315
|
-
|
|
374
|
+
// keeps the second preprocess pass from throwing — which is a precondition for
|
|
375
|
+
// idempotency, not the whole of it (#90).
|
|
376
|
+
function applyTextRoleGraph(node, typographic, types) {
|
|
316
377
|
for (const path of typographic) {
|
|
317
378
|
let target = node;
|
|
318
379
|
for (const segment of path.split('.')) {
|
|
319
380
|
target = target && typeof target === 'object' ? target[segment] : undefined;
|
|
320
381
|
}
|
|
321
382
|
if (!target || typeof target !== 'object' || !('$value' in target)) continue;
|
|
322
|
-
if (
|
|
383
|
+
if (types[path] !== 'dimension') continue;
|
|
323
384
|
if (!TEXT_ROLE_UNIT.test(String(target.$value).trim())) continue;
|
|
385
|
+
extNamespace(target, path);
|
|
324
386
|
target.$extensions ??= {};
|
|
325
387
|
target.$extensions[EXT_NS] ??= {};
|
|
326
388
|
const ns = target.$extensions[EXT_NS];
|
|
@@ -334,11 +396,19 @@ export function preprocess(dict) {
|
|
|
334
396
|
// Read from the UNRESOLVED dict, before resolveInPlace flattens the aliases
|
|
335
397
|
// the graph is made of.
|
|
336
398
|
const { typographic } = textRoleGraph(dict);
|
|
399
|
+
const resolved = resolveInPlace(structuredClone(dict), flattenDtcg(dict));
|
|
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).
|
|
404
|
+
//
|
|
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);
|
|
337
410
|
const out = hoistDualNodes(
|
|
338
|
-
applyTextRoleGraph(
|
|
339
|
-
classifyTextUnits(resolveInPlace(structuredClone(dict), flattenDtcg(dict))),
|
|
340
|
-
typographic,
|
|
341
|
-
),
|
|
411
|
+
applyTextRoleGraph(classifyTextUnits(resolved, types), typographic, types),
|
|
342
412
|
collisions,
|
|
343
413
|
);
|
|
344
414
|
if (collisions.length) {
|
|
@@ -346,6 +416,9 @@ export function preprocess(dict) {
|
|
|
346
416
|
.slice(0, 5)
|
|
347
417
|
.map((c) => {
|
|
348
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
|
+
}
|
|
349
422
|
if (c.isGroup) {
|
|
350
423
|
return line + (c.claimant ? ` (a group, already claimed by the hoist of ${c.claimant})` : ' (a group)');
|
|
351
424
|
}
|
|
@@ -473,7 +546,14 @@ const DECLINED_STOCK_TRANSFORMS = {
|
|
|
473
546
|
// Order is never compared: our lists are hand-ordered for our own reasons and
|
|
474
547
|
// do not inherit stock order. Removals are never reported: a declined name
|
|
475
548
|
// disappearing is a non-event.
|
|
476
|
-
|
|
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
|
+
) {
|
|
477
557
|
if (typeof transformGroups !== 'object' || transformGroups === null) {
|
|
478
558
|
return [
|
|
479
559
|
"throughline: could not read Style Dictionary's stock transform groups " +
|
|
@@ -482,7 +562,29 @@ export function auditStockGroups(transformGroups) {
|
|
|
482
562
|
];
|
|
483
563
|
}
|
|
484
564
|
const warnings = [];
|
|
485
|
-
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
|
+
}
|
|
486
588
|
const group = preset.stockGroup;
|
|
487
589
|
if (!group || !Array.isArray(preset.transforms)) {
|
|
488
590
|
warnings.push(
|
|
@@ -506,7 +608,7 @@ export function auditStockGroups(transformGroups) {
|
|
|
506
608
|
const unaccounted = stock.filter(
|
|
507
609
|
(name) =>
|
|
508
610
|
!preset.transforms.includes(name) &&
|
|
509
|
-
!Object.hasOwn(
|
|
611
|
+
!Object.hasOwn(declined, name),
|
|
510
612
|
);
|
|
511
613
|
if (unaccounted.length) {
|
|
512
614
|
const n = unaccounted.length;
|
|
@@ -608,8 +710,21 @@ export function nativePlatform({ platform, buildPath, className = 'Tokens', pack
|
|
|
608
710
|
// Carried here, not left to the caller: authored() reads the ORIGINAL
|
|
609
711
|
// $value, so without this preprocessor every aliased dimension still holds
|
|
610
712
|
// an unresolved {spacing.space.4}, no size transform fires, and the build
|
|
611
|
-
// emits bare px literals.
|
|
612
|
-
//
|
|
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.
|
|
613
728
|
preprocessors: ['dtcg/resolve-dual-node'],
|
|
614
729
|
buildPath,
|
|
615
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
|
}
|