synthesisui 0.5.0 → 0.6.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/README.md +23 -0
- package/dist/commands/doctor.js +63 -7
- package/dist/doctor/overrides.js +189 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -68,6 +68,29 @@ npx synthesisui@latest doctor
|
|
|
68
68
|
21 #f1f3fa → --ds-color-gray-100
|
|
69
69
|
```
|
|
70
70
|
|
|
71
|
+
### Overruled
|
|
72
|
+
|
|
73
|
+
With a system installed it runs a second pass that nothing else can:
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
── Overruled ─────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
3 places where the code takes a component
|
|
79
|
+
the system defines, and then overrules it locally.
|
|
80
|
+
|
|
81
|
+
components/Hero.tsx
|
|
82
|
+
12 ds-button · border-radius: 4
|
|
83
|
+
the recipe binds {radius.md}
|
|
84
|
+
18 ds-button · padding: px-8
|
|
85
|
+
the recipe binds {spacing.2xs} {spacing.md}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
It only flags a property the recipe **actually binds**. `w-full` beside a
|
|
89
|
+
button is layout; `px-8` is drift, because the recipe already decided the
|
|
90
|
+
padding. A rule reading `var(--ds-…)` back is the opposite of overruling and
|
|
91
|
+
is left alone. This needs the recipes, which are only in the project because
|
|
92
|
+
`add` put them there - a linter has no idea what `ds-button` promised.
|
|
93
|
+
|
|
71
94
|
The last column is the point: not "you hardcoded a colour", but **the name
|
|
72
95
|
your own system already has for it**. Exact matches only - a tool that guesses
|
|
73
96
|
a near colour invites a silent visual change, and a diagnosis nobody trusts is
|
package/dist/commands/doctor.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readdir, readFile } from "node:fs/promises";
|
|
2
2
|
import { join, relative, resolve } from "node:path";
|
|
3
|
+
import { bindingsFromDocument, findOverrides, } from "../doctor/overrides.js";
|
|
3
4
|
import { diagnose, scanSource, } from "../doctor/scan.js";
|
|
4
5
|
import { buildTable, EMPTY_TABLE } from "../doctor/tokens.js";
|
|
5
6
|
import { body, section, snippet } from "../output.js";
|
|
@@ -58,7 +59,10 @@ async function* walk(dir) {
|
|
|
58
59
|
}
|
|
59
60
|
}
|
|
60
61
|
}
|
|
61
|
-
/** Find the installed system: `_synthesisui/ds/<slug>/tokens.css` + `.lock
|
|
62
|
+
/** Find the installed system: `_synthesisui/ds/<slug>/tokens.css` + `.lock`,
|
|
63
|
+
* and the recipes `add` put next to them in `design-system.json`. Those
|
|
64
|
+
* recipes are why the component pass can exist at all - a linter has no idea
|
|
65
|
+
* what `ds-button` promised. */
|
|
62
66
|
async function loadSystem(root) {
|
|
63
67
|
const dsDir = join(root, "_synthesisui", "ds");
|
|
64
68
|
let slugs;
|
|
@@ -68,12 +72,13 @@ async function loadSystem(root) {
|
|
|
68
72
|
.map((e) => e.name);
|
|
69
73
|
}
|
|
70
74
|
catch {
|
|
71
|
-
return EMPTY_TABLE;
|
|
75
|
+
return { table: EMPTY_TABLE, recipes: new Map() };
|
|
72
76
|
}
|
|
73
77
|
// Several systems can live side by side; every token is prefixed --ds-, so
|
|
74
78
|
// reading them all is both correct and what the running app actually sees.
|
|
75
79
|
let css = "";
|
|
76
80
|
let lock = null;
|
|
81
|
+
const recipes = new Map();
|
|
77
82
|
for (const slug of slugs) {
|
|
78
83
|
const dir = join(dsDir, slug);
|
|
79
84
|
const raw = await readFile(join(dir, ".lock"), "utf8").catch(() => "");
|
|
@@ -99,8 +104,22 @@ async function loadSystem(root) {
|
|
|
99
104
|
}
|
|
100
105
|
}
|
|
101
106
|
css += `\n${real || root}`;
|
|
107
|
+
const docRaw = mine?.version
|
|
108
|
+
? await readFile(join(dir, `v${mine.version}`, "design-system.json"), "utf8").catch(() => "")
|
|
109
|
+
: "";
|
|
110
|
+
if (docRaw) {
|
|
111
|
+
try {
|
|
112
|
+
for (const [k, v] of bindingsFromDocument(JSON.parse(docRaw))) {
|
|
113
|
+
if (!recipes.has(k))
|
|
114
|
+
recipes.set(k, v);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
// A document we cannot parse costs the component pass, not the run.
|
|
119
|
+
}
|
|
120
|
+
}
|
|
102
121
|
}
|
|
103
|
-
return buildTable({ css, lock });
|
|
122
|
+
return { table: buildTable({ css, lock }), recipes };
|
|
104
123
|
}
|
|
105
124
|
const KIND_LABEL = {
|
|
106
125
|
color: "colour",
|
|
@@ -150,13 +169,20 @@ function verdict(d, hasSystem) {
|
|
|
150
169
|
}
|
|
151
170
|
export async function doctor(opts) {
|
|
152
171
|
const root = resolve(opts.dir ?? process.cwd());
|
|
153
|
-
const table = await loadSystem(root);
|
|
172
|
+
const { table, recipes } = await loadSystem(root);
|
|
154
173
|
const hasSystem = table.byName.size > 0;
|
|
155
174
|
const reports = [];
|
|
175
|
+
const overrides = [];
|
|
156
176
|
for await (const file of walk(root)) {
|
|
157
177
|
const src = await readFile(file, "utf8").catch(() => "");
|
|
158
|
-
if (src)
|
|
159
|
-
|
|
178
|
+
if (!src)
|
|
179
|
+
continue;
|
|
180
|
+
const rel = relative(root, file);
|
|
181
|
+
reports.push(scanSource(rel, src, table));
|
|
182
|
+
if (recipes.size > 0) {
|
|
183
|
+
for (const o of findOverrides(src, recipes))
|
|
184
|
+
overrides.push({ ...o, file: rel });
|
|
185
|
+
}
|
|
160
186
|
}
|
|
161
187
|
if (reports.length === 0) {
|
|
162
188
|
console.log(`\nNothing to read in ${root}.\n`);
|
|
@@ -215,10 +241,40 @@ export async function doctor(opts) {
|
|
|
215
241
|
console.log(body(`+${files.length - shownFiles.length} more files. Run with --all to see everything.`));
|
|
216
242
|
}
|
|
217
243
|
}
|
|
244
|
+
if (overrides.length > 0) {
|
|
245
|
+
console.log(section("Overruled"));
|
|
246
|
+
console.log(body(`${overrides.length} place${overrides.length === 1 ? "" : "s"} where the code takes a component`));
|
|
247
|
+
console.log(body("the system defines, and then overrules it locally."));
|
|
248
|
+
console.log("");
|
|
249
|
+
const byFile = new Map();
|
|
250
|
+
for (const o of overrides) {
|
|
251
|
+
const list = byFile.get(o.file);
|
|
252
|
+
if (list)
|
|
253
|
+
list.push(o);
|
|
254
|
+
else
|
|
255
|
+
byFile.set(o.file, [o]);
|
|
256
|
+
}
|
|
257
|
+
const shown = opts.all ? [...byFile] : [...byFile].slice(0, 6);
|
|
258
|
+
for (const [file, list] of shown) {
|
|
259
|
+
console.log(body(file));
|
|
260
|
+
for (const o of opts.all ? list : list.slice(0, 3)) {
|
|
261
|
+
console.log(` ${String(o.line).padStart(4)} ds-${o.component} · ${o.prop}: ${o.wrote}`);
|
|
262
|
+
if (o.recipe)
|
|
263
|
+
console.log(` the recipe binds ${o.recipe}`);
|
|
264
|
+
}
|
|
265
|
+
if (!opts.all && list.length > 3) {
|
|
266
|
+
console.log(` +${list.length - 3} more`);
|
|
267
|
+
}
|
|
268
|
+
console.log("");
|
|
269
|
+
}
|
|
270
|
+
if (byFile.size > shown.length) {
|
|
271
|
+
console.log(body(`+${byFile.size - shown.length} more files. Run with --all.`));
|
|
272
|
+
}
|
|
273
|
+
}
|
|
218
274
|
console.log(section("What this means"));
|
|
219
275
|
for (const line of verdict(d, hasSystem))
|
|
220
276
|
console.log(line);
|
|
221
277
|
console.log("");
|
|
222
|
-
if (opts.strict && d.findings.length > 0)
|
|
278
|
+
if (opts.strict && (d.findings.length > 0 || overrides.length > 0))
|
|
223
279
|
process.exitCode = 1;
|
|
224
280
|
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DOCTOR · drift ON a component the project is already using.
|
|
3
|
+
*
|
|
4
|
+
* The generic pass finds a hardcoded value anywhere. This finds the sharper
|
|
5
|
+
* thing: a place where the code takes a component the system defines and then
|
|
6
|
+
* overrules the system locally.
|
|
7
|
+
*
|
|
8
|
+
* <button className="ds-button" style={{ borderRadius: 4 }}>
|
|
9
|
+
* → the recipe already binds border-radius to {radius.md}
|
|
10
|
+
*
|
|
11
|
+
* This is the check nothing else can run, and the reason is structural: it
|
|
12
|
+
* needs the recipes, and the recipes are only in the project because `add`
|
|
13
|
+
* put them there (`_synthesisui/ds/<slug>/v<n>/design-system.json`). A linter
|
|
14
|
+
* has no idea what `ds-button` promised.
|
|
15
|
+
*
|
|
16
|
+
* It only ever flags a property the recipe ACTUALLY binds. `w-full` next to a
|
|
17
|
+
* button is layout, not drift; `p-4` is drift, because the recipe already
|
|
18
|
+
* decided the padding.
|
|
19
|
+
*/
|
|
20
|
+
const kebab = (s) => s.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
|
|
21
|
+
/**
|
|
22
|
+
* Every property a recipe touches - base, variants, states and parts. A
|
|
23
|
+
* variant binding counts: if `intent=primary` sets the background, then
|
|
24
|
+
* writing a background by hand overrules the system just as surely.
|
|
25
|
+
*/
|
|
26
|
+
export function bindingsOf(recipe) {
|
|
27
|
+
const props = new Set();
|
|
28
|
+
const baseValues = new Map();
|
|
29
|
+
const collect = (block, isBase) => {
|
|
30
|
+
if (!block || typeof block !== "object")
|
|
31
|
+
return;
|
|
32
|
+
for (const [k, v] of Object.entries(block)) {
|
|
33
|
+
if (typeof v === "string" || typeof v === "number") {
|
|
34
|
+
const p = kebab(k);
|
|
35
|
+
props.add(p);
|
|
36
|
+
if (isBase)
|
|
37
|
+
baseValues.set(p, String(v));
|
|
38
|
+
}
|
|
39
|
+
else if (v && typeof v === "object") {
|
|
40
|
+
collect(v, false);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
const r = (recipe ?? {});
|
|
45
|
+
collect(r.base, true);
|
|
46
|
+
collect(r.variants, false);
|
|
47
|
+
collect(r.states, false);
|
|
48
|
+
collect(r.parts, false);
|
|
49
|
+
return { props, baseValues };
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Tailwind utilities that plainly set a CSS property. Deliberately short:
|
|
53
|
+
* every entry here can produce an accusation, so anything ambiguous is left
|
|
54
|
+
* out. Being quiet and right beats being loud.
|
|
55
|
+
*/
|
|
56
|
+
const UTILITY = [
|
|
57
|
+
[
|
|
58
|
+
/^rounded(-(?:sm|md|lg|xl|2xl|3xl|full|none))?$|^rounded-\[/,
|
|
59
|
+
"border-radius",
|
|
60
|
+
],
|
|
61
|
+
[/^p-|^px-|^py-|^pt-|^pr-|^pb-|^pl-/, "padding"],
|
|
62
|
+
[/^gap-/, "gap"],
|
|
63
|
+
[/^bg-\[|^bg-(?!clip|blend|origin|repeat|fixed|local|scroll)/, "background"],
|
|
64
|
+
[/^shadow(-|$)/, "box-shadow"],
|
|
65
|
+
[
|
|
66
|
+
/^font-(?:thin|light|normal|medium|semibold|bold|extrabold|black)$/,
|
|
67
|
+
"font-weight",
|
|
68
|
+
],
|
|
69
|
+
[/^(?:text|font)-\[/, "font-size"],
|
|
70
|
+
[/^tracking-/, "letter-spacing"],
|
|
71
|
+
[/^border-\[|^border-\d/, "border-width"],
|
|
72
|
+
];
|
|
73
|
+
function utilityProp(cls) {
|
|
74
|
+
for (const [re, prop] of UTILITY)
|
|
75
|
+
if (re.test(cls))
|
|
76
|
+
return prop;
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
const DS_IN_CLASS = /\bds-([a-z][a-z0-9-]*)\b/g;
|
|
80
|
+
const clip = (s) => (s.length > 84 ? `${s.slice(0, 81)}...` : s);
|
|
81
|
+
/**
|
|
82
|
+
* The tag around an offset. JSX puts `className` and `style` on separate
|
|
83
|
+
* lines constantly, so a line-scoped check would miss the common case; a
|
|
84
|
+
* bounded scan to the enclosing `<…>` is both simple and accurate.
|
|
85
|
+
*/
|
|
86
|
+
function enclosingTag(src, at) {
|
|
87
|
+
const open = src.lastIndexOf("<", at);
|
|
88
|
+
if (open === -1)
|
|
89
|
+
return null;
|
|
90
|
+
let depth = 0;
|
|
91
|
+
for (let i = open; i < src.length && i < open + 4000; i++) {
|
|
92
|
+
const c = src[i];
|
|
93
|
+
if (c === "{")
|
|
94
|
+
depth++;
|
|
95
|
+
else if (c === "}")
|
|
96
|
+
depth--;
|
|
97
|
+
else if (c === ">" && depth <= 0)
|
|
98
|
+
return { text: src.slice(open, i), start: open };
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
const lineAt = (src, index) => src.slice(0, index).split("\n").length;
|
|
103
|
+
export function findOverrides(source, recipes) {
|
|
104
|
+
const out = [];
|
|
105
|
+
const lines = source.split("\n");
|
|
106
|
+
const seen = new Set();
|
|
107
|
+
const record = (o) => {
|
|
108
|
+
const key = `${o.component}:${o.prop}:${o.line}`;
|
|
109
|
+
if (seen.has(key))
|
|
110
|
+
return;
|
|
111
|
+
seen.add(key);
|
|
112
|
+
out.push(o);
|
|
113
|
+
};
|
|
114
|
+
// ── JSX: a ds-* class and a local style on the same element ───────────────
|
|
115
|
+
for (const m of source.matchAll(DS_IN_CLASS)) {
|
|
116
|
+
const name = m[1];
|
|
117
|
+
const bind = recipes.get(name);
|
|
118
|
+
if (!bind)
|
|
119
|
+
continue;
|
|
120
|
+
const tag = enclosingTag(source, m.index ?? 0);
|
|
121
|
+
if (!tag)
|
|
122
|
+
continue;
|
|
123
|
+
// inline style={{ borderRadius: 4 }}
|
|
124
|
+
for (const s of tag.text.matchAll(/([a-zA-Z-]+)\s*:\s*("[^"]*"|'[^']*'|[^,}\n]+)/g)) {
|
|
125
|
+
const prop = kebab(s[1]);
|
|
126
|
+
if (!bind.props.has(prop))
|
|
127
|
+
continue;
|
|
128
|
+
record({
|
|
129
|
+
component: name,
|
|
130
|
+
prop,
|
|
131
|
+
wrote: s[2].trim().replace(/^["']|["']$/g, ""),
|
|
132
|
+
recipe: bind.baseValues.get(prop) ?? null,
|
|
133
|
+
line: lineAt(source, tag.start + (s.index ?? 0)),
|
|
134
|
+
excerpt: clip(lines[lineAt(source, tag.start + (s.index ?? 0)) - 1]?.trim() ?? ""),
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
// utility classes sitting beside the ds-* class
|
|
138
|
+
for (const cls of tag.text.matchAll(/[\w:[\]#().%/-]+/g)) {
|
|
139
|
+
const prop = utilityProp(cls[0]);
|
|
140
|
+
if (!prop || !bind.props.has(prop))
|
|
141
|
+
continue;
|
|
142
|
+
record({
|
|
143
|
+
component: name,
|
|
144
|
+
prop,
|
|
145
|
+
wrote: cls[0],
|
|
146
|
+
recipe: bind.baseValues.get(prop) ?? null,
|
|
147
|
+
line: lineAt(source, tag.start + (cls.index ?? 0)),
|
|
148
|
+
excerpt: clip(lines[lineAt(source, tag.start + (cls.index ?? 0)) - 1]?.trim() ?? ""),
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
// ── CSS: a rule whose selector reaches a ds-* component ───────────────────
|
|
153
|
+
for (const rule of source.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
|
|
154
|
+
const selector = rule[1];
|
|
155
|
+
const hit = /\.ds-([a-z][a-z0-9-]*)\b/.exec(selector);
|
|
156
|
+
const bind = hit ? recipes.get(hit[1]) : undefined;
|
|
157
|
+
if (!hit || !bind)
|
|
158
|
+
continue;
|
|
159
|
+
for (const decl of rule[2].matchAll(/([a-z-]+)\s*:\s*([^;]+)/g)) {
|
|
160
|
+
const prop = decl[1].trim();
|
|
161
|
+
if (!bind.props.has(prop))
|
|
162
|
+
continue;
|
|
163
|
+
// Reading the system back is the opposite of overruling it.
|
|
164
|
+
if (decl[2].includes("var(--ds-"))
|
|
165
|
+
continue;
|
|
166
|
+
const at = (rule.index ?? 0) + rule[1].length + (decl.index ?? 0);
|
|
167
|
+
record({
|
|
168
|
+
component: hit[1],
|
|
169
|
+
prop,
|
|
170
|
+
wrote: decl[2].trim(),
|
|
171
|
+
recipe: bind.baseValues.get(prop) ?? null,
|
|
172
|
+
line: lineAt(source, at),
|
|
173
|
+
excerpt: clip(lines[lineAt(source, at) - 1]?.trim() ?? ""),
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return out.sort((a, b) => a.line - b.line);
|
|
178
|
+
}
|
|
179
|
+
/** Recipes from an installed `design-system.json`, as bindings. */
|
|
180
|
+
export function bindingsFromDocument(doc) {
|
|
181
|
+
const out = new Map();
|
|
182
|
+
const comps = doc?.components;
|
|
183
|
+
if (!comps)
|
|
184
|
+
return out;
|
|
185
|
+
for (const [name, recipe] of Object.entries(comps)) {
|
|
186
|
+
out.set(name, bindingsOf(recipe));
|
|
187
|
+
}
|
|
188
|
+
return out;
|
|
189
|
+
}
|
package/package.json
CHANGED