synthesisui 0.16.6 → 0.16.8
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/dist/claude-md.js +61 -1
- package/dist/commands/doctor.js +46 -3
- package/dist/component-codegen.js +81 -15
- package/dist/doctor/scan.js +109 -3
- package/dist/doctor/tokens.js +21 -0
- package/package.json +1 -1
package/dist/claude-md.js
CHANGED
|
@@ -106,6 +106,51 @@ component found in a repo-wide audit next week is an archaeology exercise.
|
|
|
106
106
|
|
|
107
107
|
If it reports a value with no token, do NOT invent one. Say which value and what
|
|
108
108
|
you would call it, and let a person decide.`;
|
|
109
|
+
/**
|
|
110
|
+
* THE INTERFACE LANGUAGE, READ FROM THE PROJECT RATHER THAN ASKED FOR.
|
|
111
|
+
*
|
|
112
|
+
* Prompted in Portuguese, an agent wrote a dashboard whose menu was English
|
|
113
|
+
* (the labels the person dictated), whose content was Portuguese, and whose
|
|
114
|
+
* root element still said `lang="en"` inherited from the landing page
|
|
115
|
+
* (my-test4, 27/07). Nothing in the contract had ever mentioned language, so
|
|
116
|
+
* it was not disobedience - nobody had read that attribute out loud to it.
|
|
117
|
+
*
|
|
118
|
+
* The cruelty is that careful accessibility makes it worse: a screen reader
|
|
119
|
+
* pronounces `aria-label="Sessões por canal"` with English phonemes. The most
|
|
120
|
+
* conscientious lines in the file are the ones that break hardest.
|
|
121
|
+
*
|
|
122
|
+
* This does NOT belong to the design system. One system serves many apps, and
|
|
123
|
+
* one of them will be Spanish - a locale baked into the tokens would break the
|
|
124
|
+
* first customer with two markets. The PROJECT owns its language, it already
|
|
125
|
+
* declares it in the layout, and this only reads it back.
|
|
126
|
+
*/
|
|
127
|
+
const HTML_LANG = /<html[^>]*\slang=(?:"([a-z-]+)"|'([a-z-]+)'|\{)/i;
|
|
128
|
+
const LAYOUTS = [
|
|
129
|
+
"app/layout.tsx",
|
|
130
|
+
"app/layout.jsx",
|
|
131
|
+
"src/app/layout.tsx",
|
|
132
|
+
"src/app/layout.jsx",
|
|
133
|
+
"pages/_document.tsx",
|
|
134
|
+
"src/pages/_document.tsx",
|
|
135
|
+
"index.html",
|
|
136
|
+
"public/index.html",
|
|
137
|
+
];
|
|
138
|
+
async function readInterfaceLanguage(projectRoot) {
|
|
139
|
+
for (const rel of LAYOUTS) {
|
|
140
|
+
const src = await readFile(join(projectRoot, rel), "utf8").catch(() => "");
|
|
141
|
+
if (!src)
|
|
142
|
+
continue;
|
|
143
|
+
const m = HTML_LANG.exec(src);
|
|
144
|
+
if (!m)
|
|
145
|
+
continue;
|
|
146
|
+
const lang = m[1] ?? m[2];
|
|
147
|
+
// `lang={locale}` - the project is internationalised, and the useful
|
|
148
|
+
// instruction is the opposite one: literal copy in JSX is the mistake,
|
|
149
|
+
// whatever language it is in.
|
|
150
|
+
return lang ? { lang } : { i18n: true };
|
|
151
|
+
}
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
109
154
|
async function renderRegion(projectRoot, installed) {
|
|
110
155
|
if (installed.length === 0) {
|
|
111
156
|
return `${START}\n${END}`;
|
|
@@ -149,9 +194,24 @@ Only write something new when nothing in the manifest covers the purpose - and w
|
|
|
149
194
|
say which entry you considered and why it did not fit. To review a
|
|
150
195
|
component, create an isolated sample page (e.g. \`app/synthesisui-samples/<component>/\`) - do not
|
|
151
196
|
apply it to real production pages unless asked.${SELF_CHECK}`;
|
|
197
|
+
const locale = await readInterfaceLanguage(projectRoot);
|
|
198
|
+
const language = locale === null
|
|
199
|
+
? ""
|
|
200
|
+
: "i18n" in locale
|
|
201
|
+
? `
|
|
202
|
+
|
|
203
|
+
**This project is internationalised** - its \`<html lang>\` is set from a variable. User-facing
|
|
204
|
+
copy belongs in the message catalogue, never written literally into JSX.`
|
|
205
|
+
: `
|
|
206
|
+
|
|
207
|
+
**This project's interface language is \`${locale.lang}\`**, from the \`lang\` attribute on its root
|
|
208
|
+
element. Write every user-facing string in that language - labels, empty states, \`aria-label\`,
|
|
209
|
+
\`alt\`. A screen reader pronounces \`aria-label\` using \`lang\`, so a mixed-language interface is
|
|
210
|
+
worse than an untranslated one. If the attribute is wrong, change it rather than writing against
|
|
211
|
+
it.`;
|
|
152
212
|
const body = `## Design Systems (via SynthesisUI)
|
|
153
213
|
|
|
154
|
-
This project uses design system(s) tracked by the \`synthesisui\` CLI. ${rule}
|
|
214
|
+
This project uses design system(s) tracked by the \`synthesisui\` CLI. ${rule}${language}
|
|
155
215
|
|
|
156
216
|
${sections.join("\n")}
|
|
157
217
|
|
package/dist/commands/doctor.js
CHANGED
|
@@ -2,7 +2,7 @@ import { readdir, readFile } from "node:fs/promises";
|
|
|
2
2
|
import { join, relative, resolve } from "node:path";
|
|
3
3
|
import { findFrozenBindings } from "../doctor/frozen.js";
|
|
4
4
|
import { bindingsFromDocument, countComponents, findOverrides, } from "../doctor/overrides.js";
|
|
5
|
-
import { diagnose, scanSource, } from "../doctor/scan.js";
|
|
5
|
+
import { diagnose, scanSource, siblingTokens, } from "../doctor/scan.js";
|
|
6
6
|
import { findSelfConflicts, forbiddenProps, isReset, propMatchesLabel, } from "../doctor/self-conflict.js";
|
|
7
7
|
import { buildTable, EMPTY_TABLE, nearestToken, } from "../doctor/tokens.js";
|
|
8
8
|
import { body, section, snippet } from "../output.js";
|
|
@@ -409,7 +409,12 @@ export async function doctor(opts) {
|
|
|
409
409
|
}
|
|
410
410
|
}
|
|
411
411
|
else if (asideTotal > 0) {
|
|
412
|
-
|
|
412
|
+
// "a token could never hold" was written when every aside was an image
|
|
413
|
+
// renderer or an SVG paint. A literal sitting in a token's OWN fallback -
|
|
414
|
+
// `var(--ds-color-semantic-knob, #ffffff)` - is set aside for the opposite
|
|
415
|
+
// reason: it is already tokenized. The summary said the false half out
|
|
416
|
+
// loud and hid the true half behind a flag.
|
|
417
|
+
console.log(body(`set aside ${plural(asideTotal, "value")} that are not drift (--verbose for why)`));
|
|
413
418
|
}
|
|
414
419
|
// 0 of 0 is not a perfect score, it is an empty measurement - printing a
|
|
415
420
|
// full bar there would be the report's first lie.
|
|
@@ -459,7 +464,45 @@ export async function doctor(opts) {
|
|
|
459
464
|
if (hasSystem && measurable) {
|
|
460
465
|
console.log("");
|
|
461
466
|
console.log(body(`Token coverage ${meter(d.coverage)} ${String(d.coverage).padStart(3)}%`));
|
|
462
|
-
console.log(body(` ${d.tokenUses} from the system, ${d.findings.length} by hand`));
|
|
467
|
+
console.log(body(` ${d.tokenUses} from the system, ${d.findings.length} by hand${d.phantomUses > 0 ? `, ${d.phantomUses} naming nothing` : ""}`));
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* Its own section, above drift, because it is a worse problem wearing a
|
|
471
|
+
* better disguise.
|
|
472
|
+
*
|
|
473
|
+
* Drift is a literal where a token belongs: the screen looks right and the
|
|
474
|
+
* system lost. A name the system does not declare resolves to NOTHING - no
|
|
475
|
+
* error, no console warning, the property simply does not apply - and it
|
|
476
|
+
* reads in review as the most obedient line in the file.
|
|
477
|
+
*/
|
|
478
|
+
const phantoms = new Map();
|
|
479
|
+
for (const f of d.files) {
|
|
480
|
+
for (const p of f.phantoms ?? []) {
|
|
481
|
+
const at = phantoms.get(p.name) ?? { files: new Set(), line: p.line };
|
|
482
|
+
at.files.add(f.file);
|
|
483
|
+
phantoms.set(p.name, at);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* `console.log`, not `say`. Everything else earns its place behind
|
|
488
|
+
* `--verbose` because the reader can already see it on screen; this is the
|
|
489
|
+
* one finding whose entire nature is that NOTHING is on screen. Hiding the
|
|
490
|
+
* invisible failure behind a flag is the same bug as not reporting it.
|
|
491
|
+
*/
|
|
492
|
+
if (phantoms.size > 0) {
|
|
493
|
+
console.log(section("Names your system does not have"));
|
|
494
|
+
console.log(body(`${plural(phantoms.size, "name")} written as tokens that your system never declares.`));
|
|
495
|
+
console.log(body("An undeclared custom property applies nothing at all."));
|
|
496
|
+
console.log("");
|
|
497
|
+
for (const [name, at] of [...phantoms].sort()) {
|
|
498
|
+
const where = [...at.files][0];
|
|
499
|
+
console.log(body(`${name} ${where}:${at.line}${at.files.size > 1 ? ` +${at.files.size - 1} more` : ""}`));
|
|
500
|
+
const kin = siblingTokens(name, table);
|
|
501
|
+
if (kin.length > 0)
|
|
502
|
+
console.log(snippet([`your system has ${kin.join(", ")}`]));
|
|
503
|
+
}
|
|
504
|
+
console.log("");
|
|
505
|
+
console.log(body("Add the name to the system, or use one it has. Do not leave it."));
|
|
463
506
|
}
|
|
464
507
|
if (d.findings.length > 0) {
|
|
465
508
|
say(section("Drift"));
|
|
@@ -32,11 +32,36 @@ function elementFor(name, recipe) {
|
|
|
32
32
|
? { tag: "button", attrs: ' type="button"' }
|
|
33
33
|
: undefined);
|
|
34
34
|
const tag = hit?.tag ?? "div";
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
35
|
+
const attrs = hit?.attrs ?? "";
|
|
36
|
+
const isVoid = tag === "input" || tag === "hr";
|
|
37
|
+
/**
|
|
38
|
+
* A VOID ELEMENT CANNOT HOST THE PARTS WE TELL PEOPLE TO PUT INSIDE IT.
|
|
39
|
+
*
|
|
40
|
+
* `switch` resolved to `<input type="checkbox">`, and the recipe gives it a
|
|
41
|
+
* `thumb` part - so the generated file documented `<Switch><SwitchThumb/>
|
|
42
|
+
* </Switch>`, which React refuses at runtime: input is void and must not
|
|
43
|
+
* have children. Found by an agent building a settings page against it
|
|
44
|
+
* (my-test4, 27/07). The documented usage was impossible.
|
|
45
|
+
*
|
|
46
|
+
* The recipe was right and the tag was wrong. Its own CSS styles `.ds-switch`
|
|
47
|
+
* as an `inline-flex` track with a 16px knob inside a 24px rail - that is a
|
|
48
|
+
* container, described as one, and only the element choice disagreed.
|
|
49
|
+
*
|
|
50
|
+
* `<button role="switch">` is the accessible pattern for exactly this: it
|
|
51
|
+
* takes children, it is focusable and operable by keyboard for free, and the
|
|
52
|
+
* caller supplies `aria-checked`. Promotion happens ONLY when parts exist, so
|
|
53
|
+
* a plain input stays an input.
|
|
54
|
+
*/
|
|
55
|
+
if (isVoid && Object.keys(recipe.parts ?? {}).length > 0) {
|
|
56
|
+
// Carry the ARIA role across - it is the part of the input's meaning that
|
|
57
|
+
// survives the tag change, and dropping it would trade a runtime error for
|
|
58
|
+
// a silent accessibility regression.
|
|
59
|
+
const role = / role="[a-z]+"/.exec(attrs)?.[0] ?? "";
|
|
60
|
+
return tag === "input"
|
|
61
|
+
? { tag: "button", attrs: ` type="button"${role}`, voidEl: false }
|
|
62
|
+
: { tag: "div", attrs: role || ' role="separator"', voidEl: false };
|
|
63
|
+
}
|
|
64
|
+
return { tag, attrs, voidEl: isVoid };
|
|
40
65
|
}
|
|
41
66
|
/** Variant axes → typed props. An axis whose options ⊆ {true,false} is a
|
|
42
67
|
* boolean prop; empty axes (no visual effect) are skipped. */
|
|
@@ -272,13 +297,44 @@ const STATE_PREFIX = {
|
|
|
272
297
|
function blockToTailwind(block, prefix = "") {
|
|
273
298
|
return Object.entries(block).flatMap(([prop, value]) => declToTailwind(prop, value).map((cls) => `${prefix}${cls}`));
|
|
274
299
|
}
|
|
275
|
-
function tailwindClassList(recipe
|
|
300
|
+
function tailwindClassList(recipe,
|
|
301
|
+
/** CSS properties a variant axis owns - see `variantOwnedProps`. */
|
|
302
|
+
exclude) {
|
|
303
|
+
const base = exclude
|
|
304
|
+
? Object.fromEntries(Object.entries(recipe.base).filter(([prop]) => !exclude.has(prop)))
|
|
305
|
+
: recipe.base;
|
|
276
306
|
const classes = [
|
|
277
|
-
...blockToTailwind(
|
|
307
|
+
...blockToTailwind(base),
|
|
308
|
+
// States keep everything: `hover:` and `disabled:` cannot collide with an
|
|
309
|
+
// unprefixed variant class, so there is nothing to resolve.
|
|
278
310
|
...Object.entries(recipe.states ?? {}).flatMap(([state, block]) => STATE_PREFIX[state] ? blockToTailwind(block, STATE_PREFIX[state]) : []),
|
|
279
311
|
];
|
|
280
312
|
return classes.join(" ");
|
|
281
313
|
}
|
|
314
|
+
/**
|
|
315
|
+
* A VARIANT CANNOT OVERRIDE THE BASE WHEN BOTH ARE PLAIN UTILITIES.
|
|
316
|
+
*
|
|
317
|
+
* `Card` emitted `p-md` in BASE and `[padding:0]` for `padding="none"`. Same
|
|
318
|
+
* specificity, so which one wins is decided by the order Tailwind emits them
|
|
319
|
+
* in the stylesheet - not by the order of the class names, which is what the
|
|
320
|
+
* code looks like it controls. The prop silently did nothing (found by an
|
|
321
|
+
* agent that worked around it in a comment rather than reporting it,
|
|
322
|
+
* my-test4, 27/07).
|
|
323
|
+
*
|
|
324
|
+
* CSS mode never had this: it selects on `[data-padding="none"]`, which
|
|
325
|
+
* outranks the base class honestly. Tailwind mode has no such ladder, so the
|
|
326
|
+
* conflict has to be resolved where it is created - at generation.
|
|
327
|
+
*
|
|
328
|
+
* The property leaves BASE and the base value becomes the axis's default, so
|
|
329
|
+
* exactly one class ever sets it.
|
|
330
|
+
*/
|
|
331
|
+
function variantOwnedProps(variants, axis) {
|
|
332
|
+
const props = new Set();
|
|
333
|
+
for (const option of axis.options)
|
|
334
|
+
for (const prop of Object.keys(variants?.[axis.key]?.[option] ?? {}))
|
|
335
|
+
props.add(prop);
|
|
336
|
+
return [...props];
|
|
337
|
+
}
|
|
282
338
|
// ── Emission ─────────────────────────────────────────────────────────────────
|
|
283
339
|
function header(slug, name, version, mode) {
|
|
284
340
|
const setup = mode === "tailwind"
|
|
@@ -295,8 +351,14 @@ const joinCls = (parts) => `[${parts.join(", ")}].filter(Boolean).join(" ")`;
|
|
|
295
351
|
/** JSDoc showing how to compose the component with its parts + content, so the
|
|
296
352
|
* materialized code doesn't read as "a bare shell renders nothing" (dogfood
|
|
297
353
|
* #5). Built from the recipe's parts. */
|
|
298
|
-
function compositionHint(comp, name, recipe) {
|
|
354
|
+
function compositionHint(comp, name, recipe, voidEl = false) {
|
|
299
355
|
const partNames = Object.keys(recipe.parts ?? {});
|
|
356
|
+
// An `<input>` or `<hr>` takes no children, and telling somebody to put
|
|
357
|
+
// content inside one is an instruction that throws. With parts, the element
|
|
358
|
+
// was promoted above and this branch never runs for a void tag.
|
|
359
|
+
if (voidEl) {
|
|
360
|
+
return `/** Wears the "${name}" recipe. Takes no children - it renders a single void element. */`;
|
|
361
|
+
}
|
|
300
362
|
if (partNames.length === 0) {
|
|
301
363
|
return `/** Wears the "${name}" recipe. Put your content inside: <${comp}>…</${comp}>. */`;
|
|
302
364
|
}
|
|
@@ -317,7 +379,6 @@ function emitCssMode(slug, name, recipe, version) {
|
|
|
317
379
|
const comp = pascal(name);
|
|
318
380
|
const propNames = axes.map((a) => a.prop);
|
|
319
381
|
const destructure = [...propNames, "className", "...props"].join(", ");
|
|
320
|
-
void voidEl; // both void and container elements self-close ({...props} carries children)
|
|
321
382
|
const rootJsx = ` <${tag}${attrs}\n className={${joinCls([`"ds-${name}"`, "className"])}}\n${dataAttrLines(axes)}${axes.length ? "\n" : ""} {...props}\n />`;
|
|
322
383
|
const parts = Object.entries(recipe.parts ?? {}).map(([partName, part]) => {
|
|
323
384
|
const partAxes = axesOf(part.variants ?? {});
|
|
@@ -345,7 +406,7 @@ import type { ComponentPropsWithoutRef } from "react";
|
|
|
345
406
|
|
|
346
407
|
type ${comp}Props = ${propsType(axes, tag)};
|
|
347
408
|
|
|
348
|
-
${compositionHint(comp, name, recipe)}
|
|
409
|
+
${compositionHint(comp, name, recipe, voidEl)}
|
|
349
410
|
export function ${comp}({ ${destructure} }: ${comp}Props) {
|
|
350
411
|
return (
|
|
351
412
|
${rootJsx}
|
|
@@ -368,11 +429,17 @@ function emitTailwindMode(slug, name, recipe, version) {
|
|
|
368
429
|
const booleanConsts = axes
|
|
369
430
|
.filter((a) => a.boolean)
|
|
370
431
|
.map((a) => `const ${a.prop.toUpperCase()} = ${JSON.stringify(blockToTailwind(recipe.variants[a.key]?.true ?? {}).join(" "))};`);
|
|
432
|
+
// Every property some axis controls leaves BASE, and the base value becomes
|
|
433
|
+
// that axis's default - so exactly one class ever sets it and the prop
|
|
434
|
+
// actually wins.
|
|
435
|
+
const owned = new Map(axes.map((a) => [a.prop, variantOwnedProps(recipe.variants, a)]));
|
|
436
|
+
const excluded = new Set([...owned.values()].flat());
|
|
437
|
+
const fallbackFor = (a) => JSON.stringify(blockToTailwind(Object.fromEntries(Object.entries(recipe.base).filter(([prop]) => (owned.get(a.prop) ?? []).includes(prop)))).join(" "));
|
|
371
438
|
const clsParts = [
|
|
372
439
|
"BASE",
|
|
373
440
|
...axes.map((a) => a.boolean
|
|
374
|
-
? `${a.prop} ? ${a.prop.toUpperCase()} :
|
|
375
|
-
: `${a.prop} ? ${a.prop.toUpperCase()}[${a.prop}] :
|
|
441
|
+
? `${a.prop} ? ${a.prop.toUpperCase()} : ${fallbackFor(a)}`
|
|
442
|
+
: `${a.prop} ? ${a.prop.toUpperCase()}[${a.prop}] : ${fallbackFor(a)}`),
|
|
376
443
|
"className",
|
|
377
444
|
];
|
|
378
445
|
const destructure = [
|
|
@@ -380,17 +447,16 @@ function emitTailwindMode(slug, name, recipe, version) {
|
|
|
380
447
|
"className",
|
|
381
448
|
"...props",
|
|
382
449
|
].join(", ");
|
|
383
|
-
void voidEl;
|
|
384
450
|
return `${header(slug, name, version, "tailwind")}
|
|
385
451
|
|
|
386
452
|
import type { ComponentPropsWithoutRef } from "react";
|
|
387
453
|
|
|
388
|
-
const BASE = ${JSON.stringify(tailwindClassList(recipe))};
|
|
454
|
+
const BASE = ${JSON.stringify(tailwindClassList(recipe, excluded))};
|
|
389
455
|
${[...variantConsts, ...booleanConsts].join("\n")}
|
|
390
456
|
|
|
391
457
|
type ${comp}Props = ${propsType(axes, tag)};
|
|
392
458
|
|
|
393
|
-
${compositionHint(comp, name, recipe)}
|
|
459
|
+
${compositionHint(comp, name, recipe, voidEl)}
|
|
394
460
|
export function ${comp}({ ${destructure} }: ${comp}Props) {
|
|
395
461
|
return (
|
|
396
462
|
<${tag}${attrs}
|
package/dist/doctor/scan.js
CHANGED
|
@@ -49,16 +49,111 @@ const FONT = /font-family\s*:\s*([^;}\n]+)/g;
|
|
|
49
49
|
* seen it declared - which is exactly what the token table knows.
|
|
50
50
|
*/
|
|
51
51
|
const ANY_VAR_USE = /var\(\s*(--[a-z0-9_-]+)/gi;
|
|
52
|
+
/**
|
|
53
|
+
* `var(--ds-xp, 60%)` - a name with a fallback ALWAYS renders, so it is never
|
|
54
|
+
* a phantom. This is not a mistake pattern either: it is how a runtime knob is
|
|
55
|
+
* written, and our own generated components ship two of them, set inline by
|
|
56
|
+
* whoever uses the component. Flagging those would have accused the platform's
|
|
57
|
+
* own output on the first real run.
|
|
58
|
+
*/
|
|
59
|
+
const VAR_WITH_FALLBACK = /var\(\s*(--[a-z0-9_-]+)\s*,/gi;
|
|
52
60
|
const ANY_VAR_FALLBACK = /var\(\s*(--[a-z0-9_-]+)\s*,([^()]*)\)/gi;
|
|
53
61
|
const isKnownToken = (name, table) => name.startsWith("--ds-") || table.byName.has(name);
|
|
62
|
+
/**
|
|
63
|
+
* A NAME THAT RESOLVES TO NOTHING IS NOT COVERAGE.
|
|
64
|
+
*
|
|
65
|
+
* The prefix rule above is generous on purpose, and it had a hole underneath
|
|
66
|
+
* it: `var(--ds-color-series-99)` counted as a token use because it started
|
|
67
|
+
* with `--ds-`, whether or not the system declares it. Injected two invented
|
|
68
|
+
* names into a real project and the report said 100%, 20 from the system
|
|
69
|
+
* (my-test4, 27/07).
|
|
70
|
+
*
|
|
71
|
+
* That is the exact failure the whole product exists to prevent. An undeclared
|
|
72
|
+
* custom property is not a fallback to some default - it renders as NOTHING,
|
|
73
|
+
* with no error and no console warning. The managed block tells the agent not
|
|
74
|
+
* to invent tokens; until now the measurement could not tell whether it had
|
|
75
|
+
* obeyed.
|
|
76
|
+
*
|
|
77
|
+
* Only OUR prefix can be judged this way. `--acme-*` belongs to the project and
|
|
78
|
+
* we never see its full vocabulary, so an unknown one there means we did not
|
|
79
|
+
* read the file that declares it, not that it does not exist.
|
|
80
|
+
*/
|
|
81
|
+
const canJudgeDsNames = (table) => {
|
|
82
|
+
for (const name of table.declared)
|
|
83
|
+
if (name.startsWith("--ds-"))
|
|
84
|
+
return true;
|
|
85
|
+
// No `--ds-` in the table at all: an adopted system, a project with none, or
|
|
86
|
+
// a lock we failed to parse. Calling every reference a phantom there would
|
|
87
|
+
// turn one unreadable file into a report that condemns the whole repo.
|
|
88
|
+
return false;
|
|
89
|
+
};
|
|
90
|
+
/**
|
|
91
|
+
* Names we decline to judge, because being wrong here is worse than being
|
|
92
|
+
* quiet. Each of these was a real line in a real project, not a hypothetical.
|
|
93
|
+
*/
|
|
94
|
+
const unjudgeable = (name, line) => {
|
|
95
|
+
// Cheap and exact: the same line, re-read for `var(<name>,`.
|
|
96
|
+
for (const m of line.matchAll(VAR_WITH_FALLBACK))
|
|
97
|
+
if (m[1].toLowerCase() === name)
|
|
98
|
+
return true;
|
|
99
|
+
return (
|
|
100
|
+
// `var(--ds-color-series-${i})` and `var(--ds-${kind}-500)` both capture
|
|
101
|
+
// up to the interpolation and stop. The name is assembled at runtime; the
|
|
102
|
+
// static text is not a claim about any one token.
|
|
103
|
+
name.endsWith("-") ||
|
|
104
|
+
// A comment that documents the pattern - `var(--ds-color-series-*)` - is
|
|
105
|
+
// prose, and accusing a comment of drift is the tool losing the reader.
|
|
106
|
+
line.startsWith("//") ||
|
|
107
|
+
line.startsWith("*") ||
|
|
108
|
+
line.startsWith("/*"));
|
|
109
|
+
};
|
|
54
110
|
function countTokenUses(line, table) {
|
|
55
111
|
let n = 0;
|
|
112
|
+
const judge = canJudgeDsNames(table);
|
|
56
113
|
for (const m of line.matchAll(ANY_VAR_USE)) {
|
|
57
|
-
|
|
114
|
+
const name = m[1].toLowerCase();
|
|
115
|
+
if (judge && name.startsWith("--ds-") && !table.declared.has(name)) {
|
|
116
|
+
if (!unjudgeable(name, line))
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (isKnownToken(name, table))
|
|
58
120
|
n++;
|
|
59
121
|
}
|
|
60
122
|
return n;
|
|
61
123
|
}
|
|
124
|
+
/** The undeclared `--ds-` names on a line, for the report. Same rules as the
|
|
125
|
+
* counter above, so a name is never both uncounted and unmentioned. */
|
|
126
|
+
function findPhantoms(line, table) {
|
|
127
|
+
if (!canJudgeDsNames(table))
|
|
128
|
+
return [];
|
|
129
|
+
const out = [];
|
|
130
|
+
for (const m of line.matchAll(ANY_VAR_USE)) {
|
|
131
|
+
const name = m[1].toLowerCase();
|
|
132
|
+
if (!name.startsWith("--ds-"))
|
|
133
|
+
continue;
|
|
134
|
+
if (table.declared.has(name) || unjudgeable(name, line))
|
|
135
|
+
continue;
|
|
136
|
+
out.push(name);
|
|
137
|
+
}
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* What the author probably meant, by the longest prefix the system does hold.
|
|
142
|
+
*
|
|
143
|
+
* `--ds-color-series-99` against a system with series 1 to 5 should not answer
|
|
144
|
+
* "no idea" - the family is right there, and naming its members is the same
|
|
145
|
+
* move the drift report already makes for values.
|
|
146
|
+
*/
|
|
147
|
+
export function siblingTokens(name, table) {
|
|
148
|
+
const parts = name.split("-").filter(Boolean);
|
|
149
|
+
for (let take = parts.length - 1; take >= 3; take--) {
|
|
150
|
+
const prefix = `--${parts.slice(0, take).join("-")}-`;
|
|
151
|
+
const kin = [...table.declared].filter((k) => k.startsWith(prefix) && k !== name);
|
|
152
|
+
if (kin.length > 0)
|
|
153
|
+
return kin.sort().slice(0, 5);
|
|
154
|
+
}
|
|
155
|
+
return [];
|
|
156
|
+
}
|
|
62
157
|
/**
|
|
63
158
|
* `var(--ds-color-semantic-primary, #5266eb)` - the literal is the TOKEN'S OWN
|
|
64
159
|
* fallback, written for safety, and reporting it as drift told an author to
|
|
@@ -89,6 +184,7 @@ const DECLARES_TOKEN = /^\s*(--[a-z0-9_-]+)\s*:/i;
|
|
|
89
184
|
const IDIOM = new Set(["0", "0px", "1px", "9999px", "100%", "50%"]);
|
|
90
185
|
export function scanSource(file, source, table) {
|
|
91
186
|
const findings = [];
|
|
187
|
+
const phantoms = [];
|
|
92
188
|
let tokenUses = 0;
|
|
93
189
|
// Reason by reason. Rolling two into "A or B" was the one place the report
|
|
94
190
|
// still lumped things it had told apart everywhere else.
|
|
@@ -112,6 +208,8 @@ export function scanSource(file, source, table) {
|
|
|
112
208
|
const line = raw.trim();
|
|
113
209
|
const at = i + 1;
|
|
114
210
|
tokenUses += countTokenUses(line, table);
|
|
211
|
+
for (const name of findPhantoms(line, table))
|
|
212
|
+
phantoms.push({ name, line: at });
|
|
115
213
|
// Depth at the START of this line, carried before the early return so a
|
|
116
214
|
// blank line inside an <svg> cannot close the region by accident. The
|
|
117
215
|
// per-match depth is recomputed below, because an icon is often written on
|
|
@@ -192,6 +290,7 @@ export function scanSource(file, source, table) {
|
|
|
192
290
|
file,
|
|
193
291
|
findings,
|
|
194
292
|
tokenUses,
|
|
293
|
+
...(phantoms.length > 0 ? { phantoms } : null),
|
|
195
294
|
...(aside.size > 0
|
|
196
295
|
? {
|
|
197
296
|
setAside: [...aside].map(([reason, count]) => ({ reason, count })),
|
|
@@ -210,7 +309,11 @@ export function diagnose(files) {
|
|
|
210
309
|
for (const f of flat)
|
|
211
310
|
counts[f.kind] += 1;
|
|
212
311
|
const tokenUses = files.reduce((n, f) => n + f.tokenUses, 0);
|
|
213
|
-
|
|
312
|
+
// A name that resolves to nothing did not come from the system, so it cannot
|
|
313
|
+
// sit outside the fraction. Reporting 100% directly above "2 names your
|
|
314
|
+
// system never declares" is the report contradicting itself in six lines.
|
|
315
|
+
const phantomUses = files.reduce((n, f) => n + (f.phantoms?.length ?? 0), 0);
|
|
316
|
+
const total = tokenUses + flat.length + phantomUses;
|
|
214
317
|
const byLiteral = new Map();
|
|
215
318
|
for (const f of flat) {
|
|
216
319
|
const key = `${f.kind}:${f.literal.toLowerCase()}`;
|
|
@@ -239,11 +342,14 @@ export function diagnose(files) {
|
|
|
239
342
|
.filter((r) => r.count > 1)
|
|
240
343
|
.sort((a, b) => b.count - a.count);
|
|
241
344
|
return {
|
|
242
|
-
|
|
345
|
+
// A file with a phantom name and no drift has nothing to say by the old
|
|
346
|
+
// measure and the worst thing to say by the new one. Both keep it.
|
|
347
|
+
files: files.filter((f) => f.findings.length > 0 || (f.phantoms?.length ?? 0) > 0),
|
|
243
348
|
findings: flat,
|
|
244
349
|
counts,
|
|
245
350
|
named: flat.filter((f) => f.token).length,
|
|
246
351
|
tokenUses,
|
|
352
|
+
phantomUses,
|
|
247
353
|
coverage: total === 0 ? 100 : Math.round((tokenUses / total) * 100),
|
|
248
354
|
scanned: files.length,
|
|
249
355
|
repeats,
|
package/dist/doctor/tokens.js
CHANGED
|
@@ -18,6 +18,7 @@ export const EMPTY_TABLE = {
|
|
|
18
18
|
version: null,
|
|
19
19
|
byName: new Map(),
|
|
20
20
|
byValue: new Map(),
|
|
21
|
+
declared: new Set(),
|
|
21
22
|
};
|
|
22
23
|
const hex2 = (n) => Math.max(0, Math.min(255, Math.round(n)))
|
|
23
24
|
.toString(16)
|
|
@@ -166,6 +167,25 @@ export function parseTokens(css) {
|
|
|
166
167
|
}
|
|
167
168
|
return out;
|
|
168
169
|
}
|
|
170
|
+
/**
|
|
171
|
+
* EVERY name the stylesheet declares, aliases included.
|
|
172
|
+
*
|
|
173
|
+
* "What value does this hold" and "does this name exist" are different
|
|
174
|
+
* questions, and the table only answered the first. `parseTokens` drops
|
|
175
|
+
* aliases on purpose - you cannot match a hardcoded colour against
|
|
176
|
+
* `var(--ds-color-blue-500)` - so fifteen perfectly real tokens, the whole
|
|
177
|
+
* `--ds-color-series-*` family among them, were absent from `byName` while
|
|
178
|
+
* being declared two lines apart in the same file.
|
|
179
|
+
*
|
|
180
|
+
* Harmless while nothing asked about existence. The moment the phantom check
|
|
181
|
+
* did, it called them invented (caught before shipping, 27/07).
|
|
182
|
+
*/
|
|
183
|
+
export function parseDeclaredNames(css) {
|
|
184
|
+
const out = new Set();
|
|
185
|
+
for (const m of css.matchAll(/(--[a-z0-9_-]+)\s*:\s*[^;}]+[;}]/gi))
|
|
186
|
+
out.add(m[1].toLowerCase());
|
|
187
|
+
return out;
|
|
188
|
+
}
|
|
169
189
|
/**
|
|
170
190
|
* Custom properties the project declares AT THE ROOT, whatever it calls them.
|
|
171
191
|
*
|
|
@@ -276,6 +296,7 @@ export function buildTable(input) {
|
|
|
276
296
|
version: input.lock?.version ?? null,
|
|
277
297
|
byName,
|
|
278
298
|
byValue,
|
|
299
|
+
declared: parseDeclaredNames(input.css),
|
|
279
300
|
};
|
|
280
301
|
}
|
|
281
302
|
/**
|
package/package.json
CHANGED