synthesisui 0.7.0 → 0.8.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/dist/commands/doctor.js +28 -1
- package/dist/doctor/overrides.js +26 -1
- package/dist/doctor/scan.js +52 -2
- package/dist/document-diff.js +30 -4
- package/dist/index.js +5 -1
- package/package.json +1 -1
package/dist/commands/doctor.js
CHANGED
|
@@ -59,6 +59,17 @@ async function* walk(dir) {
|
|
|
59
59
|
}
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
|
+
/** The same walk over several roots, in the order the caller named them. A
|
|
63
|
+
* single file passed as a scope is read as itself. */
|
|
64
|
+
async function* walkAll(roots) {
|
|
65
|
+
for (const r of roots) {
|
|
66
|
+
if (EXTS.some((x) => r.endsWith(x))) {
|
|
67
|
+
yield r;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
yield* walk(r);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
62
73
|
/** Find the installed system: `_synthesisui/ds/<slug>/tokens.css` + `.lock`,
|
|
63
74
|
* and the recipes `add` put next to them in `design-system.json`. Those
|
|
64
75
|
* recipes are why the component pass can exist at all - a linter has no idea
|
|
@@ -185,11 +196,12 @@ export async function doctor(opts) {
|
|
|
185
196
|
const root = resolve(opts.dir ?? process.cwd());
|
|
186
197
|
const { table, recipes } = await loadSystem(root);
|
|
187
198
|
const hasSystem = table.byName.size > 0;
|
|
199
|
+
const scopes = (opts.scopes ?? []).map((s) => resolve(root, s));
|
|
188
200
|
const reports = [];
|
|
189
201
|
const overrides = [];
|
|
190
202
|
const used = new Map();
|
|
191
203
|
const known = new Set(recipes.keys());
|
|
192
|
-
for await (const file of walk(root)) {
|
|
204
|
+
for await (const file of scopes.length > 0 ? walkAll(scopes) : walk(root)) {
|
|
193
205
|
const src = await readFile(file, "utf8").catch(() => "");
|
|
194
206
|
if (!src)
|
|
195
207
|
continue;
|
|
@@ -212,6 +224,21 @@ export async function doctor(opts) {
|
|
|
212
224
|
console.log(body(hasSystem
|
|
213
225
|
? `${table.name ?? table.slug} v${table.version ?? "?"} - ${table.byName.size} tokens, ${d.scanned} files read`
|
|
214
226
|
: `No system installed - ${d.scanned} files read`));
|
|
227
|
+
if (scopes.length > 0) {
|
|
228
|
+
console.log(body(`scope: ${opts.scopes?.join(", ")}`));
|
|
229
|
+
}
|
|
230
|
+
// What a token could never have held, said out loud. A report that quietly
|
|
231
|
+
// drops things is only trustworthy until someone notices, and then it is
|
|
232
|
+
// worth less than one that says too much.
|
|
233
|
+
const aside = new Map();
|
|
234
|
+
for (const r of reports) {
|
|
235
|
+
if (!r.setAside)
|
|
236
|
+
continue;
|
|
237
|
+
aside.set(r.setAside.reason, (aside.get(r.setAside.reason) ?? 0) + r.setAside.count);
|
|
238
|
+
}
|
|
239
|
+
for (const [reason, count] of aside) {
|
|
240
|
+
console.log(body(`set aside: ${count} value(s) in ${reason}`));
|
|
241
|
+
}
|
|
215
242
|
// 0 of 0 is not a perfect score, it is an empty measurement - printing a
|
|
216
243
|
// full bar there would be the report's first lie.
|
|
217
244
|
const measurable = d.tokenUses + d.findings.length > 0;
|
package/dist/doctor/overrides.js
CHANGED
|
@@ -117,6 +117,28 @@ function enclosingTag(src, at) {
|
|
|
117
117
|
return null;
|
|
118
118
|
}
|
|
119
119
|
const lineAt = (src, index) => src.slice(0, index).split("\n").length;
|
|
120
|
+
/**
|
|
121
|
+
* The literal a style property was given, or null when it was given an
|
|
122
|
+
* expression instead.
|
|
123
|
+
*
|
|
124
|
+
* `style={{ width: w }}` passes a variable; `transform: shown ? "a" : "b"`
|
|
125
|
+
* passes a ternary. Neither is a value the author hardcoded over the recipe,
|
|
126
|
+
* and reporting them as "the recipe binds 100%" was noise on a real project
|
|
127
|
+
* (investidorez, 25/07). Only a quoted string or a bare number is an override
|
|
128
|
+
* anyone can act on.
|
|
129
|
+
*/
|
|
130
|
+
function literalValue(raw) {
|
|
131
|
+
const v = raw.trim();
|
|
132
|
+
const quoted = /^(["'])([\s\S]*)\1$/.exec(v);
|
|
133
|
+
if (quoted) {
|
|
134
|
+
// a template that interpolates is decided at runtime, not here
|
|
135
|
+
return quoted[2].includes("${") ? null : quoted[2];
|
|
136
|
+
}
|
|
137
|
+
if (v.startsWith("`"))
|
|
138
|
+
return null;
|
|
139
|
+
// bare numbers are valid in a JSX style object (padding: 8)
|
|
140
|
+
return /^-?\d*\.?\d+(px|rem|em|%|vh|vw|ch|s|ms)?$/.test(v) ? v : null;
|
|
141
|
+
}
|
|
120
142
|
export function findOverrides(source, recipes) {
|
|
121
143
|
const out = [];
|
|
122
144
|
const lines = source.split("\n");
|
|
@@ -142,10 +164,13 @@ export function findOverrides(source, recipes) {
|
|
|
142
164
|
const prop = kebab(s[1]);
|
|
143
165
|
if (!bind.props.has(prop))
|
|
144
166
|
continue;
|
|
167
|
+
const wrote = literalValue(s[2]);
|
|
168
|
+
if (wrote === null)
|
|
169
|
+
continue;
|
|
145
170
|
record({
|
|
146
171
|
component: name,
|
|
147
172
|
prop,
|
|
148
|
-
wrote
|
|
173
|
+
wrote,
|
|
149
174
|
recipe: bind.baseValues.get(prop) ?? null,
|
|
150
175
|
line: lineAt(source, tag.start + (s.index ?? 0)),
|
|
151
176
|
excerpt: clip(lines[lineAt(source, tag.start + (s.index ?? 0)) - 1]?.trim() ?? ""),
|
package/dist/doctor/scan.js
CHANGED
|
@@ -10,6 +10,17 @@
|
|
|
10
10
|
* takes eight seconds and needs a build step is a diagnosis nobody runs.
|
|
11
11
|
*/
|
|
12
12
|
import { tokenFor } from "./tokens.js";
|
|
13
|
+
/**
|
|
14
|
+
* `next/og` renders JSX to a PNG on the server. There is no document, so there
|
|
15
|
+
* is no `var(--ds-*)` to read: every colour in such a file MUST be a literal.
|
|
16
|
+
* Telling the author to tokenize it is telling them to break the build
|
|
17
|
+
* (investidorez, 25/07 - two files, nineteen impossible findings).
|
|
18
|
+
*/
|
|
19
|
+
const RENDERS_IMAGE = /from\s+["']next\/og["']|\bnew\s+ImageResponse\b/;
|
|
20
|
+
/** A colour sitting on an SVG paint attribute is artwork - a brand mark, an
|
|
21
|
+
* illustration. Google's logo contributed four "fix me" lines to a real
|
|
22
|
+
* report; no design system should own those four hexes. */
|
|
23
|
+
const SVG_PAINT = /(?:fill|stroke)\s*[=:]\s*["']?$/;
|
|
13
24
|
const clip = (s) => (s.length > 84 ? `${s.slice(0, 81)}...` : s);
|
|
14
25
|
/** Lines we must not read as authorship: imports, and our own installed CSS. */
|
|
15
26
|
const IGNORE_LINE = /^\s*(import|@import|\/\/|\*|\/\*)/;
|
|
@@ -34,10 +45,32 @@ const IDIOM = new Set(["0", "0px", "1px", "9999px", "100%", "50%"]);
|
|
|
34
45
|
export function scanSource(file, source, table) {
|
|
35
46
|
const findings = [];
|
|
36
47
|
let tokenUses = 0;
|
|
48
|
+
let aside = 0;
|
|
49
|
+
if (RENDERS_IMAGE.test(source)) {
|
|
50
|
+
return {
|
|
51
|
+
file,
|
|
52
|
+
findings: [],
|
|
53
|
+
tokenUses: 0,
|
|
54
|
+
setAside: {
|
|
55
|
+
reason: "renders to an image, where CSS variables do not exist",
|
|
56
|
+
count: (source.match(COLOR) ?? []).length,
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
let svgDepth = 0;
|
|
37
61
|
source.split("\n").forEach((raw, i) => {
|
|
38
62
|
const line = raw.trim();
|
|
39
63
|
const at = i + 1;
|
|
40
64
|
tokenUses += (line.match(TOKEN_USE) ?? []).length;
|
|
65
|
+
// Depth at the START of this line, carried before the early return so a
|
|
66
|
+
// blank line inside an <svg> cannot close the region by accident. The
|
|
67
|
+
// per-match depth is recomputed below, because an icon is often written on
|
|
68
|
+
// one line - `<svg><path fill="#4285F4"/></svg>` opens and closes around
|
|
69
|
+
// the value, and a line-level counter reads zero right where it matters.
|
|
70
|
+
const lineStart = svgDepth;
|
|
71
|
+
svgDepth = Math.max(0, svgDepth +
|
|
72
|
+
(line.match(/<svg\b/g) ?? []).length -
|
|
73
|
+
(line.match(/<\/svg>/g) ?? []).length);
|
|
41
74
|
if (!line || IGNORE_LINE.test(line))
|
|
42
75
|
return;
|
|
43
76
|
// `rgba(${r}, ${g}, ${b}, ${a})` is code computing a colour, not a colour
|
|
@@ -60,8 +93,18 @@ export function scanSource(file, source, table) {
|
|
|
60
93
|
excerpt: clip(line),
|
|
61
94
|
});
|
|
62
95
|
};
|
|
63
|
-
for (const m of line.matchAll(COLOR))
|
|
96
|
+
for (const m of line.matchAll(COLOR)) {
|
|
97
|
+
const before = line.slice(0, m.index ?? 0);
|
|
98
|
+
const depthHere = lineStart +
|
|
99
|
+
(before.match(/<svg\b/g) ?? []).length -
|
|
100
|
+
(before.match(/<\/svg>/g) ?? []).length;
|
|
101
|
+
// inside an <svg>, a colour on fill= or stroke= is paint, not surface
|
|
102
|
+
if (depthHere > 0 && SVG_PAINT.test(before)) {
|
|
103
|
+
aside++;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
64
106
|
push("color", m[0]);
|
|
107
|
+
}
|
|
65
108
|
for (const m of line.matchAll(RADIUS)) {
|
|
66
109
|
const value = `${m[1]}${m[2]}`;
|
|
67
110
|
if (!IDIOM.has(value))
|
|
@@ -80,7 +123,14 @@ export function scanSource(file, source, table) {
|
|
|
80
123
|
push("font", stack);
|
|
81
124
|
}
|
|
82
125
|
});
|
|
83
|
-
return {
|
|
126
|
+
return {
|
|
127
|
+
file,
|
|
128
|
+
findings,
|
|
129
|
+
tokenUses,
|
|
130
|
+
...(aside > 0
|
|
131
|
+
? { setAside: { reason: "SVG artwork", count: aside } }
|
|
132
|
+
: null),
|
|
133
|
+
};
|
|
84
134
|
}
|
|
85
135
|
export function diagnose(files) {
|
|
86
136
|
const flat = files.flatMap((f) => f.findings.map((x) => ({ ...x, file: f.file })));
|
package/dist/document-diff.js
CHANGED
|
@@ -46,7 +46,30 @@ function deepEqual(a, b) {
|
|
|
46
46
|
}
|
|
47
47
|
return false;
|
|
48
48
|
}
|
|
49
|
-
/**
|
|
49
|
+
/**
|
|
50
|
+
* Did this variant option ever put a declaration on the page? An option like
|
|
51
|
+
* `{ "tone": { "default": {} } }` compiles to nothing, so nothing in the
|
|
52
|
+
* consumer's app can depend on it.
|
|
53
|
+
*/
|
|
54
|
+
function declaresAnything(node) {
|
|
55
|
+
if (node === null || node === undefined)
|
|
56
|
+
return false;
|
|
57
|
+
if (typeof node !== "object")
|
|
58
|
+
return String(node).trim().length > 0;
|
|
59
|
+
if (Array.isArray(node))
|
|
60
|
+
return node.some(declaresAnything);
|
|
61
|
+
return Object.values(node).some(declaresAnything);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Variant options that existed, disappeared, AND used to render something.
|
|
65
|
+
*
|
|
66
|
+
* Vesper v2 → v15 announced ten breaking changes ("input no longer supports
|
|
67
|
+
* tone", and nine more). Every one of them was `{ tone: { default: {} } }` - an
|
|
68
|
+
* axis with a single empty option that emitted zero CSS. Removing it could not
|
|
69
|
+
* break an app, but the brief sent the consumer's agent hunting for `tone=`
|
|
70
|
+
* props that never existed (investidorez, 25/07). Housekeeping and a breaking
|
|
71
|
+
* change now read differently, because they are different.
|
|
72
|
+
*/
|
|
50
73
|
function removedVariantOptions(before, after) {
|
|
51
74
|
const out = [];
|
|
52
75
|
const prevVariants = (before.variants ?? {});
|
|
@@ -54,11 +77,14 @@ function removedVariantOptions(before, after) {
|
|
|
54
77
|
for (const [axis, options] of Object.entries(prevVariants)) {
|
|
55
78
|
const nextAxis = nextVariants[axis];
|
|
56
79
|
if (!nextAxis) {
|
|
57
|
-
|
|
80
|
+
if (declaresAnything(options))
|
|
81
|
+
out.push(axis);
|
|
58
82
|
continue;
|
|
59
83
|
}
|
|
60
|
-
for (const option of Object.
|
|
61
|
-
if (
|
|
84
|
+
for (const [option, decls] of Object.entries(options)) {
|
|
85
|
+
if (option in nextAxis)
|
|
86
|
+
continue;
|
|
87
|
+
if (declaresAnything(decls))
|
|
62
88
|
out.push(`${axis}="${option}"`);
|
|
63
89
|
}
|
|
64
90
|
}
|
package/dist/index.js
CHANGED
|
@@ -26,7 +26,7 @@ Usage - deterministic, FREE:
|
|
|
26
26
|
synthesisui upgrade <slug> update an installed DS + regenerate your components + migration brief
|
|
27
27
|
synthesisui use <slug> "<intent>" print a ready-to-paste agent prompt to build/modify on-system
|
|
28
28
|
synthesisui clean [--force] strip create-next-app boilerplate (dry run without --force)
|
|
29
|
-
synthesisui doctor [
|
|
29
|
+
synthesisui doctor [paths…] [--all] audit for DRIFT: every design value written by
|
|
30
30
|
hand, the token your system already has for it, and the
|
|
31
31
|
laws your system carries for what you use
|
|
32
32
|
|
|
@@ -63,6 +63,7 @@ Examples:
|
|
|
63
63
|
synthesisui init --target next
|
|
64
64
|
synthesisui init --target next --ds halogen bootstrap + bring a system in
|
|
65
65
|
synthesisui doctor
|
|
66
|
+
synthesisui doctor apps/web packages/ui # scope the read in a monorepo
|
|
66
67
|
synthesisui doctor --strict
|
|
67
68
|
synthesisui list
|
|
68
69
|
synthesisui add halogen
|
|
@@ -120,8 +121,11 @@ async function main() {
|
|
|
120
121
|
const dir = typeof flags.dir === "string" ? flags.dir : undefined;
|
|
121
122
|
switch (command) {
|
|
122
123
|
case "doctor":
|
|
124
|
+
// positional paths scope the READING (the system is still found from the
|
|
125
|
+
// root): `doctor apps/web packages/ui` in a monorepo
|
|
123
126
|
await doctor({
|
|
124
127
|
dir,
|
|
128
|
+
scopes: args,
|
|
125
129
|
strict: flags.strict === true,
|
|
126
130
|
all: flags.all === true,
|
|
127
131
|
laws: flags.laws === true,
|
package/package.json
CHANGED