synthesisui 0.4.13 → 0.5.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 +40 -0
- package/dist/commands/doctor.js +224 -0
- package/dist/doctor/scan.js +134 -0
- package/dist/doctor/tokens.js +92 -0
- package/dist/index.js +14 -0
- package/package.json +3 -2
- package/dist/commands/page.js +0 -42
package/README.md
CHANGED
|
@@ -38,6 +38,46 @@ your own in two minutes.
|
|
|
38
38
|
| `refit <file>` | Send an app component back into your design system |
|
|
39
39
|
| `upgrade <slug>` | Diff your `.lock` against the latest version and migrate |
|
|
40
40
|
| `clean` | Remove materialized files and the managed CLAUDE.md block |
|
|
41
|
+
| `doctor` | Audit the repo for drift: every design value written by hand, and the token your system already has for it |
|
|
42
|
+
|
|
43
|
+
## `doctor`
|
|
44
|
+
|
|
45
|
+
Every tool in this space promises output that is brand consistent and free of
|
|
46
|
+
drift. None of them checks. This checks, in the only place it can be true -
|
|
47
|
+
the code that shipped.
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
npx synthesisui@latest doctor
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
── Doctor ────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
Aluna v1 - 91 tokens, 53 files read
|
|
57
|
+
|
|
58
|
+
Token coverage ████████████████████████ 98%
|
|
59
|
+
2019 from the system, 34 by hand
|
|
60
|
+
|
|
61
|
+
── Drift ─────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
26 colour
|
|
64
|
+
8 spacing
|
|
65
|
+
|
|
66
|
+
app/welcome2/_components/signature.tsx
|
|
67
|
+
19 #8b8bf1 → --ds-color-blue-500
|
|
68
|
+
21 #f1f3fa → --ds-color-gray-100
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The last column is the point: not "you hardcoded a colour", but **the name
|
|
72
|
+
your own system already has for it**. Exact matches only - a tool that guesses
|
|
73
|
+
a near colour invites a silent visual change, and a diagnosis nobody trusts is
|
|
74
|
+
worse than none.
|
|
75
|
+
|
|
76
|
+
With no system installed it still finds every hand-written value and counts
|
|
77
|
+
the distinct ones. Runs offline, needs no account, writes nothing.
|
|
78
|
+
|
|
79
|
+
`--strict` exits 1 when drift is found, for CI. `--all` lists everything
|
|
80
|
+
instead of the loudest files.
|
|
41
81
|
|
|
42
82
|
## What `add` materializes
|
|
43
83
|
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
2
|
+
import { join, relative, resolve } from "node:path";
|
|
3
|
+
import { diagnose, scanSource, } from "../doctor/scan.js";
|
|
4
|
+
import { buildTable, EMPTY_TABLE } from "../doctor/tokens.js";
|
|
5
|
+
import { body, section, snippet } from "../output.js";
|
|
6
|
+
/**
|
|
7
|
+
* `synthesisui doctor` - the check nobody else ships.
|
|
8
|
+
*
|
|
9
|
+
* Every generator in this market now promises "brand consistent, no drift".
|
|
10
|
+
* Not one of them verifies it. This does, in the only place it can be true:
|
|
11
|
+
* the code that actually shipped.
|
|
12
|
+
*
|
|
13
|
+
* Two audiences, one command:
|
|
14
|
+
*
|
|
15
|
+
* - With a system installed, it does the thing that cannot be faked - it names
|
|
16
|
+
* the project's OWN token for the value someone hardcoded. "#2563eb, which
|
|
17
|
+
* your system calls --ds-color-semantic-info." Nothing generic; the
|
|
18
|
+
* diagnosis is in their vocabulary.
|
|
19
|
+
* - With no system installed, it still finds every hand-written design value
|
|
20
|
+
* and counts the distinct ones. That number IS the pitch, and it costs the
|
|
21
|
+
* reader nothing to get.
|
|
22
|
+
*
|
|
23
|
+
* Runs offline, needs no account, and touches nothing. A tool that asks for a
|
|
24
|
+
* signup before it tells you anything is a tool nobody runs twice.
|
|
25
|
+
*/
|
|
26
|
+
const EXTS = [".tsx", ".ts", ".jsx", ".js", ".css", ".scss", ".vue", ".svelte"];
|
|
27
|
+
const SKIP = new Set([
|
|
28
|
+
"node_modules",
|
|
29
|
+
".next",
|
|
30
|
+
".git",
|
|
31
|
+
"dist",
|
|
32
|
+
"build",
|
|
33
|
+
"out",
|
|
34
|
+
"coverage",
|
|
35
|
+
".turbo",
|
|
36
|
+
".vercel",
|
|
37
|
+
// Our own installed artifacts are the answer, not the problem.
|
|
38
|
+
"_synthesisui",
|
|
39
|
+
]);
|
|
40
|
+
async function* walk(dir) {
|
|
41
|
+
// `readdir`'s overloads infer a Buffer-named Dirent without an explicit
|
|
42
|
+
// encoding; naming it keeps `e.name` a string.
|
|
43
|
+
const entries = await readdir(dir, {
|
|
44
|
+
withFileTypes: true,
|
|
45
|
+
encoding: "utf8",
|
|
46
|
+
}).catch(() => []);
|
|
47
|
+
for (const e of entries) {
|
|
48
|
+
if (e.name.startsWith(".") && e.name !== ".")
|
|
49
|
+
continue;
|
|
50
|
+
const full = join(dir, e.name);
|
|
51
|
+
if (e.isDirectory()) {
|
|
52
|
+
if (SKIP.has(e.name))
|
|
53
|
+
continue;
|
|
54
|
+
yield* walk(full);
|
|
55
|
+
}
|
|
56
|
+
else if (EXTS.some((x) => e.name.endsWith(x))) {
|
|
57
|
+
yield full;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/** Find the installed system: `_synthesisui/ds/<slug>/tokens.css` + `.lock`. */
|
|
62
|
+
async function loadSystem(root) {
|
|
63
|
+
const dsDir = join(root, "_synthesisui", "ds");
|
|
64
|
+
let slugs;
|
|
65
|
+
try {
|
|
66
|
+
slugs = (await readdir(dsDir, { withFileTypes: true }))
|
|
67
|
+
.filter((e) => e.isDirectory())
|
|
68
|
+
.map((e) => e.name);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return EMPTY_TABLE;
|
|
72
|
+
}
|
|
73
|
+
// Several systems can live side by side; every token is prefixed --ds-, so
|
|
74
|
+
// reading them all is both correct and what the running app actually sees.
|
|
75
|
+
let css = "";
|
|
76
|
+
let lock = null;
|
|
77
|
+
for (const slug of slugs) {
|
|
78
|
+
const dir = join(dsDir, slug);
|
|
79
|
+
const raw = await readFile(join(dir, ".lock"), "utf8").catch(() => "");
|
|
80
|
+
let mine = null;
|
|
81
|
+
try {
|
|
82
|
+
mine = raw ? JSON.parse(raw) : null;
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
mine = null;
|
|
86
|
+
}
|
|
87
|
+
lock ??= mine;
|
|
88
|
+
// The file at the root is a POINTER - `@import "./v1/tokens.css"` - so the
|
|
89
|
+
// pinned folder is where the declarations actually live. Read that first,
|
|
90
|
+
// and fall back to following whatever the root imports, for a project that
|
|
91
|
+
// pinned by hand.
|
|
92
|
+
const root = await readFile(join(dir, "tokens.css"), "utf8").catch(() => "");
|
|
93
|
+
let real = mine?.version
|
|
94
|
+
? await readFile(join(dir, `v${mine.version}`, "tokens.css"), "utf8").catch(() => "")
|
|
95
|
+
: "";
|
|
96
|
+
if (!real) {
|
|
97
|
+
for (const m of root.matchAll(/@import\s+["']([^"']+)["']/g)) {
|
|
98
|
+
real += `\n${await readFile(join(dir, m[1]), "utf8").catch(() => "")}`;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
css += `\n${real || root}`;
|
|
102
|
+
}
|
|
103
|
+
return buildTable({ css, lock });
|
|
104
|
+
}
|
|
105
|
+
const KIND_LABEL = {
|
|
106
|
+
color: "colour",
|
|
107
|
+
radius: "radius",
|
|
108
|
+
spacing: "spacing",
|
|
109
|
+
font: "type",
|
|
110
|
+
};
|
|
111
|
+
/** A bar you can read at a glance, in a font that is always monospace. */
|
|
112
|
+
function meter(pct, width = 24) {
|
|
113
|
+
const filled = Math.round((pct / 100) * width);
|
|
114
|
+
return `${"█".repeat(filled)}${"░".repeat(width - filled)}`;
|
|
115
|
+
}
|
|
116
|
+
function verdict(d, hasSystem) {
|
|
117
|
+
// Nothing found and nothing installed: a utility package, a config folder,
|
|
118
|
+
// the wrong directory. Selling a design system here would be noise.
|
|
119
|
+
if (!hasSystem && d.findings.length === 0) {
|
|
120
|
+
return [
|
|
121
|
+
body("No design values are written by hand here, and no system is"),
|
|
122
|
+
body("installed. Nothing to fix, and nothing to compare against."),
|
|
123
|
+
];
|
|
124
|
+
}
|
|
125
|
+
if (!hasSystem) {
|
|
126
|
+
const distinct = new Set(d.findings.map((f) => f.literal.toLowerCase()));
|
|
127
|
+
return [
|
|
128
|
+
body(`${distinct.size} distinct design values are written by hand here.`),
|
|
129
|
+
body("No design system is installed, so none of them has a name yet."),
|
|
130
|
+
"",
|
|
131
|
+
body("Give them one:"),
|
|
132
|
+
snippet(["npx synthesisui@latest init --ds <slug>"]),
|
|
133
|
+
body("Browse systems at https://www.synthesisui.com/gallery"),
|
|
134
|
+
];
|
|
135
|
+
}
|
|
136
|
+
if (d.findings.length === 0) {
|
|
137
|
+
return [
|
|
138
|
+
body("No drift. Every design value in this project comes from the"),
|
|
139
|
+
body("system. That is a rarer sentence than it sounds."),
|
|
140
|
+
];
|
|
141
|
+
}
|
|
142
|
+
const lines = [
|
|
143
|
+
body(`${d.named} of ${d.findings.length} already have a name in your system.`),
|
|
144
|
+
body("Those are the cheap ones: swap the literal for the token."),
|
|
145
|
+
];
|
|
146
|
+
if (d.findings.length > d.named) {
|
|
147
|
+
lines.push("", body(`The other ${d.findings.length - d.named} are decisions your system has not made yet.`), body("Either they belong in it, or they should not be in the code."));
|
|
148
|
+
}
|
|
149
|
+
return lines;
|
|
150
|
+
}
|
|
151
|
+
export async function doctor(opts) {
|
|
152
|
+
const root = resolve(opts.dir ?? process.cwd());
|
|
153
|
+
const table = await loadSystem(root);
|
|
154
|
+
const hasSystem = table.byName.size > 0;
|
|
155
|
+
const reports = [];
|
|
156
|
+
for await (const file of walk(root)) {
|
|
157
|
+
const src = await readFile(file, "utf8").catch(() => "");
|
|
158
|
+
if (src)
|
|
159
|
+
reports.push(scanSource(relative(root, file), src, table));
|
|
160
|
+
}
|
|
161
|
+
if (reports.length === 0) {
|
|
162
|
+
console.log(`\nNothing to read in ${root}.\n`);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
const d = diagnose(reports);
|
|
166
|
+
console.log(section("Doctor"));
|
|
167
|
+
console.log(body(hasSystem
|
|
168
|
+
? `${table.name ?? table.slug} v${table.version ?? "?"} - ${table.byName.size} tokens, ${d.scanned} files read`
|
|
169
|
+
: `No system installed - ${d.scanned} files read`));
|
|
170
|
+
if (hasSystem) {
|
|
171
|
+
console.log("");
|
|
172
|
+
console.log(body(`Token coverage ${meter(d.coverage)} ${String(d.coverage).padStart(3)}%`));
|
|
173
|
+
console.log(body(` ${d.tokenUses} from the system, ${d.findings.length} by hand`));
|
|
174
|
+
}
|
|
175
|
+
if (d.findings.length > 0) {
|
|
176
|
+
console.log(section("Drift"));
|
|
177
|
+
const order = ["color", "radius", "spacing", "font"].filter((k) => d.counts[k] > 0);
|
|
178
|
+
for (const kind of order) {
|
|
179
|
+
console.log(body(`${d.counts[kind]} ${KIND_LABEL[kind]}`));
|
|
180
|
+
}
|
|
181
|
+
// What a person actually acts on first: the value repeated everywhere.
|
|
182
|
+
// One decision here retires dozens of sites, and a list sorted by file
|
|
183
|
+
// never tells you that.
|
|
184
|
+
const repeats = d.repeats.slice(0, 5);
|
|
185
|
+
if (repeats.length > 0) {
|
|
186
|
+
console.log("");
|
|
187
|
+
console.log(body("Most repeated"));
|
|
188
|
+
const w = Math.max(...repeats.map((r) => r.literal.length));
|
|
189
|
+
for (const r of repeats) {
|
|
190
|
+
const where = `${r.count}\u00d7 in ${r.files} file${r.files === 1 ? "" : "s"}`;
|
|
191
|
+
const named = r.token ? ` → ${r.token}` : "";
|
|
192
|
+
console.log(` ${r.literal.padEnd(w)} ${where}${named}`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
// Loudest files first: drift concentrates, and the fix is usually one
|
|
196
|
+
// shared component rather than two hundred call sites.
|
|
197
|
+
const files = [...d.files].sort((a, b) => b.findings.length - a.findings.length);
|
|
198
|
+
const shownFiles = opts.all ? files : files.slice(0, 8);
|
|
199
|
+
console.log("");
|
|
200
|
+
for (const f of shownFiles) {
|
|
201
|
+
console.log(body(`${f.file}`));
|
|
202
|
+
const shown = opts.all ? f.findings : f.findings.slice(0, 3);
|
|
203
|
+
for (const x of shown) {
|
|
204
|
+
const named = x.token
|
|
205
|
+
? `→ ${x.token}`
|
|
206
|
+
: "→ no token holds this value yet";
|
|
207
|
+
console.log(` ${String(x.line).padStart(4)} ${x.literal} ${named}`);
|
|
208
|
+
}
|
|
209
|
+
if (f.findings.length > shown.length) {
|
|
210
|
+
console.log(` +${f.findings.length - shown.length} more`);
|
|
211
|
+
}
|
|
212
|
+
console.log("");
|
|
213
|
+
}
|
|
214
|
+
if (files.length > shownFiles.length) {
|
|
215
|
+
console.log(body(`+${files.length - shownFiles.length} more files. Run with --all to see everything.`));
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
console.log(section("What this means"));
|
|
219
|
+
for (const line of verdict(d, hasSystem))
|
|
220
|
+
console.log(line);
|
|
221
|
+
console.log("");
|
|
222
|
+
if (opts.strict && d.findings.length > 0)
|
|
223
|
+
process.exitCode = 1;
|
|
224
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DOCTOR · finding the drift.
|
|
3
|
+
*
|
|
4
|
+
* Drift is the market's own word - every generator now promises "brand
|
|
5
|
+
* consistent, no drift" and not one of them checks. This is the check: a
|
|
6
|
+
* literal design value written by hand where the project's system already has
|
|
7
|
+
* a name for it.
|
|
8
|
+
*
|
|
9
|
+
* Text in, findings out. No filesystem, no AST, no network - a diagnosis that
|
|
10
|
+
* takes eight seconds and needs a build step is a diagnosis nobody runs.
|
|
11
|
+
*/
|
|
12
|
+
import { tokenFor } from "./tokens.js";
|
|
13
|
+
const clip = (s) => (s.length > 84 ? `${s.slice(0, 81)}...` : s);
|
|
14
|
+
/** Lines we must not read as authorship: imports, and our own installed CSS. */
|
|
15
|
+
const IGNORE_LINE = /^\s*(import|@import|\/\/|\*|\/\*)/;
|
|
16
|
+
/**
|
|
17
|
+
* Colour literals, in the two dialects a component file actually mixes:
|
|
18
|
+
* plain CSS values and Tailwind arbitrary values (`bg-[#2563eb]`).
|
|
19
|
+
* `#` inside a URL fragment or an id selector is excluded by requiring a full
|
|
20
|
+
* 3/6/8 digit run terminated by a non-hex character.
|
|
21
|
+
*/
|
|
22
|
+
const COLOR = /#[0-9a-fA-F]{8}\b|#[0-9a-fA-F]{6}\b|#[0-9a-fA-F]{3}\b|rgba?\([^)]*\)|hsla?\([^)]*\)/g;
|
|
23
|
+
/** `rounded-[14px]`, `border-radius: 14px`. Zero and full pills are idiom,
|
|
24
|
+
* not drift - nobody tokenizes `0` or `9999px`. */
|
|
25
|
+
const RADIUS = /(?:border-radius\s*:\s*|rounded(?:-[a-z]+)?-\[)(-?\d*\.?\d+)(px|rem|em)/g;
|
|
26
|
+
/** Arbitrary spacing: `p-[18px]`, `gap-[7px]`, `margin: 18px`. */
|
|
27
|
+
const SPACING = /(?:\b[pmg](?:[trblxy])?-\[|gap-\[|(?:padding|margin|gap)\s*:\s*)(-?\d*\.?\d+)(px|rem)/g;
|
|
28
|
+
/** A font stack written by hand rather than taken from the type scale. */
|
|
29
|
+
const FONT = /font-family\s*:\s*([^;}\n]+)/g;
|
|
30
|
+
/** Uses of the system. Coverage is meaningless without them. */
|
|
31
|
+
const TOKEN_USE = /var\(\s*--ds-[a-z0-9-]+/gi;
|
|
32
|
+
/** Under this, a radius or spacing value is idiom rather than a decision. */
|
|
33
|
+
const IDIOM = new Set(["0", "0px", "1px", "9999px", "100%", "50%"]);
|
|
34
|
+
export function scanSource(file, source, table) {
|
|
35
|
+
const findings = [];
|
|
36
|
+
let tokenUses = 0;
|
|
37
|
+
source.split("\n").forEach((raw, i) => {
|
|
38
|
+
const line = raw.trim();
|
|
39
|
+
const at = i + 1;
|
|
40
|
+
tokenUses += (line.match(TOKEN_USE) ?? []).length;
|
|
41
|
+
if (!line || IGNORE_LINE.test(line))
|
|
42
|
+
return;
|
|
43
|
+
// `rgba(${r}, ${g}, ${b}, ${a})` is code computing a colour, not a colour
|
|
44
|
+
// written by hand - flagging it would be telling someone to tokenize a
|
|
45
|
+
// variable. And the same literal twice in one declaration (a two-stop
|
|
46
|
+
// shadow) is one decision, so it is reported once.
|
|
47
|
+
const seen = new Set();
|
|
48
|
+
const push = (kind, literal) => {
|
|
49
|
+
if (literal.includes("$") || literal.includes("{"))
|
|
50
|
+
return;
|
|
51
|
+
const key = `${kind}:${literal}`;
|
|
52
|
+
if (seen.has(key))
|
|
53
|
+
return;
|
|
54
|
+
seen.add(key);
|
|
55
|
+
findings.push({
|
|
56
|
+
kind,
|
|
57
|
+
line: at,
|
|
58
|
+
literal,
|
|
59
|
+
token: tokenFor(table, literal),
|
|
60
|
+
excerpt: clip(line),
|
|
61
|
+
});
|
|
62
|
+
};
|
|
63
|
+
for (const m of line.matchAll(COLOR))
|
|
64
|
+
push("color", m[0]);
|
|
65
|
+
for (const m of line.matchAll(RADIUS)) {
|
|
66
|
+
const value = `${m[1]}${m[2]}`;
|
|
67
|
+
if (!IDIOM.has(value))
|
|
68
|
+
push("radius", value);
|
|
69
|
+
}
|
|
70
|
+
for (const m of line.matchAll(SPACING)) {
|
|
71
|
+
const value = `${m[1]}${m[2]}`;
|
|
72
|
+
if (!IDIOM.has(value))
|
|
73
|
+
push("spacing", value);
|
|
74
|
+
}
|
|
75
|
+
for (const m of line.matchAll(FONT)) {
|
|
76
|
+
const stack = m[1].trim();
|
|
77
|
+
// A stack already reading from the system is the point, not a problem.
|
|
78
|
+
if (stack.startsWith("var("))
|
|
79
|
+
continue;
|
|
80
|
+
push("font", stack);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
return { file, findings, tokenUses };
|
|
84
|
+
}
|
|
85
|
+
export function diagnose(files) {
|
|
86
|
+
const flat = files.flatMap((f) => f.findings.map((x) => ({ ...x, file: f.file })));
|
|
87
|
+
const counts = {
|
|
88
|
+
color: 0,
|
|
89
|
+
radius: 0,
|
|
90
|
+
spacing: 0,
|
|
91
|
+
font: 0,
|
|
92
|
+
};
|
|
93
|
+
for (const f of flat)
|
|
94
|
+
counts[f.kind] += 1;
|
|
95
|
+
const tokenUses = files.reduce((n, f) => n + f.tokenUses, 0);
|
|
96
|
+
const total = tokenUses + flat.length;
|
|
97
|
+
const byLiteral = new Map();
|
|
98
|
+
for (const f of flat) {
|
|
99
|
+
const key = `${f.kind}:${f.literal.toLowerCase()}`;
|
|
100
|
+
const hit = byLiteral.get(key);
|
|
101
|
+
if (hit) {
|
|
102
|
+
hit.count += 1;
|
|
103
|
+
hit.files.add(f.file);
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
byLiteral.set(key, {
|
|
107
|
+
kind: f.kind,
|
|
108
|
+
token: f.token,
|
|
109
|
+
count: 1,
|
|
110
|
+
files: new Set([f.file]),
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const repeats = [...byLiteral.entries()]
|
|
115
|
+
.map(([key, v]) => ({
|
|
116
|
+
literal: key.slice(key.indexOf(":") + 1),
|
|
117
|
+
kind: v.kind,
|
|
118
|
+
token: v.token,
|
|
119
|
+
count: v.count,
|
|
120
|
+
files: v.files.size,
|
|
121
|
+
}))
|
|
122
|
+
.filter((r) => r.count > 1)
|
|
123
|
+
.sort((a, b) => b.count - a.count);
|
|
124
|
+
return {
|
|
125
|
+
files: files.filter((f) => f.findings.length > 0),
|
|
126
|
+
findings: flat,
|
|
127
|
+
counts,
|
|
128
|
+
named: flat.filter((f) => f.token).length,
|
|
129
|
+
tokenUses,
|
|
130
|
+
coverage: total === 0 ? 100 : Math.round((tokenUses / total) * 100),
|
|
131
|
+
scanned: files.length,
|
|
132
|
+
repeats,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DOCTOR · the installed system, read back out of the project.
|
|
3
|
+
*
|
|
4
|
+
* `add` writes `_synthesisui/ds/<slug>/tokens.css` and a `.lock` naming the
|
|
5
|
+
* pinned version. Doctor reads them so it can do the thing nothing else in
|
|
6
|
+
* this market does: not merely flag a hardcoded value, but say what the
|
|
7
|
+
* project's OWN system already calls it.
|
|
8
|
+
*
|
|
9
|
+
* #2563eb → --ds-color-semantic-info
|
|
10
|
+
*
|
|
11
|
+
* Pure and dependency-free on purpose: every function here takes text and
|
|
12
|
+
* returns data, so the whole diagnosis is testable without a filesystem.
|
|
13
|
+
*/
|
|
14
|
+
export const EMPTY_TABLE = {
|
|
15
|
+
name: null,
|
|
16
|
+
slug: null,
|
|
17
|
+
version: null,
|
|
18
|
+
byName: new Map(),
|
|
19
|
+
byValue: new Map(),
|
|
20
|
+
};
|
|
21
|
+
/** Lowercase, collapse whitespace, expand #abc to #aabbcc - so `#FFF`,
|
|
22
|
+
* `#ffffff` and `# fff` all land on one key. */
|
|
23
|
+
export function normalizeValue(raw) {
|
|
24
|
+
const v = raw.trim().toLowerCase().replace(/\s+/g, " ");
|
|
25
|
+
const short = /^#([0-9a-f])([0-9a-f])([0-9a-f])$/.exec(v);
|
|
26
|
+
if (short)
|
|
27
|
+
return `#${short[1]}${short[1]}${short[2]}${short[2]}${short[3]}${short[3]}`;
|
|
28
|
+
// rgb(37 99 235) and rgb(37, 99, 235) are the same colour written twice.
|
|
29
|
+
const rgb = /^rgba?\(([^)]+)\)$/.exec(v);
|
|
30
|
+
if (rgb) {
|
|
31
|
+
const parts = rgb[1].split(/[\s,/]+/).filter(Boolean);
|
|
32
|
+
return `rgb(${parts.join(" ")})`;
|
|
33
|
+
}
|
|
34
|
+
return v;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Pull `--ds-*: value;` declarations out of a stylesheet.
|
|
38
|
+
*
|
|
39
|
+
* A regex rather than a CSS parser: tokens.css is emitted by us, one
|
|
40
|
+
* declaration per line, and a dependency-free CLI that starts instantly is
|
|
41
|
+
* worth more here than tolerating stylesheets we did not write.
|
|
42
|
+
*/
|
|
43
|
+
export function parseTokens(css) {
|
|
44
|
+
const out = new Map();
|
|
45
|
+
for (const m of css.matchAll(/(--ds-[a-z0-9-]+)\s*:\s*([^;}]+)[;}]/gi)) {
|
|
46
|
+
const name = m[1].toLowerCase();
|
|
47
|
+
const value = m[2].trim();
|
|
48
|
+
// A token pointing at another token is an alias, not a literal we can
|
|
49
|
+
// match a hardcoded colour against.
|
|
50
|
+
if (value.startsWith("var("))
|
|
51
|
+
continue;
|
|
52
|
+
if (!out.has(name))
|
|
53
|
+
out.set(name, value);
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
export function buildTable(input) {
|
|
58
|
+
const byName = parseTokens(input.css);
|
|
59
|
+
const byValue = new Map();
|
|
60
|
+
for (const [name, value] of byName) {
|
|
61
|
+
const key = normalizeValue(value);
|
|
62
|
+
const list = byValue.get(key);
|
|
63
|
+
if (list)
|
|
64
|
+
list.push(name);
|
|
65
|
+
else
|
|
66
|
+
byValue.set(key, [name]);
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
name: input.lock?.name ?? null,
|
|
70
|
+
slug: input.lock?.slug ?? null,
|
|
71
|
+
version: input.lock?.version ?? null,
|
|
72
|
+
byName,
|
|
73
|
+
byValue,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The token that already holds this literal, if the project has one.
|
|
78
|
+
*
|
|
79
|
+
* Exact match only. A "nearest colour" guess would be the single most
|
|
80
|
+
* dangerous thing this tool could do: telling somebody their `#2563ec` is
|
|
81
|
+
* "basically" `--ds-color-semantic-info` invites a silent visual change, and
|
|
82
|
+
* a diagnosis nobody can trust is worse than no diagnosis. Near misses are
|
|
83
|
+
* reported as drift, not as a fix.
|
|
84
|
+
*/
|
|
85
|
+
export function tokenFor(table, literal) {
|
|
86
|
+
const hit = table.byValue.get(normalizeValue(literal));
|
|
87
|
+
if (!hit || hit.length === 0)
|
|
88
|
+
return null;
|
|
89
|
+
// Semantic roles name intent; primitives name a shelf. Prefer intent.
|
|
90
|
+
const semantic = hit.find((n) => n.includes("-semantic-"));
|
|
91
|
+
return semantic ?? hit[0];
|
|
92
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -3,6 +3,7 @@ import { add } from "./commands/add.js";
|
|
|
3
3
|
import { advise } from "./commands/advise.js";
|
|
4
4
|
import { clean } from "./commands/clean.js";
|
|
5
5
|
import { component } from "./commands/component.js";
|
|
6
|
+
import { doctor } from "./commands/doctor.js";
|
|
6
7
|
import { generate } from "./commands/generate.js";
|
|
7
8
|
import { init } from "./commands/init.js";
|
|
8
9
|
import { list } from "./commands/list.js";
|
|
@@ -25,6 +26,8 @@ Usage - deterministic, FREE:
|
|
|
25
26
|
synthesisui upgrade <slug> update an installed DS + regenerate your components + migration brief
|
|
26
27
|
synthesisui use <slug> "<intent>" print a ready-to-paste agent prompt to build/modify on-system
|
|
27
28
|
synthesisui clean [--force] strip create-next-app boilerplate (dry run without --force)
|
|
29
|
+
synthesisui doctor [--strict] [--all] audit this repo for DRIFT: every design value written by
|
|
30
|
+
hand, and the token your system already has for it
|
|
28
31
|
|
|
29
32
|
Usage - AI, USES CREDITS (login required):
|
|
30
33
|
synthesisui generate "<desc>" AI-create a NEW component your DS doesn't have (token-only recipe)
|
|
@@ -48,6 +51,8 @@ Options:
|
|
|
48
51
|
--instruction <s> refit: extra guidance for the adaptation
|
|
49
52
|
--dry refit: adapt and print, but save nothing
|
|
50
53
|
--force clean: apply the changes (without it, dry run)
|
|
54
|
+
--strict doctor: exit 1 when drift is found (for CI)
|
|
55
|
+
--all doctor: list every finding, not just the loudest files
|
|
51
56
|
--out <path> output path for the generated template (default: <pagesDir>/<file>)
|
|
52
57
|
-h, --help this help
|
|
53
58
|
|
|
@@ -55,6 +60,8 @@ Examples:
|
|
|
55
60
|
synthesisui login
|
|
56
61
|
synthesisui init --target next
|
|
57
62
|
synthesisui init --target next --ds halogen bootstrap + bring a system in
|
|
63
|
+
synthesisui doctor
|
|
64
|
+
synthesisui doctor --strict
|
|
58
65
|
synthesisui list
|
|
59
66
|
synthesisui add halogen
|
|
60
67
|
synthesisui add halogen --version 3
|
|
@@ -110,6 +117,13 @@ async function main() {
|
|
|
110
117
|
const registry = typeof flags.registry === "string" ? flags.registry : undefined;
|
|
111
118
|
const dir = typeof flags.dir === "string" ? flags.dir : undefined;
|
|
112
119
|
switch (command) {
|
|
120
|
+
case "doctor":
|
|
121
|
+
await doctor({
|
|
122
|
+
dir,
|
|
123
|
+
strict: flags.strict === true,
|
|
124
|
+
all: flags.all === true,
|
|
125
|
+
});
|
|
126
|
+
break;
|
|
113
127
|
case "list":
|
|
114
128
|
await list({ registry });
|
|
115
129
|
break;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "synthesisui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Bring SynthesisUI design systems into any project - tokens, typed components, whole pages and an agent-ready CLAUDE.md manifest.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -31,7 +31,8 @@
|
|
|
31
31
|
"scripts": {
|
|
32
32
|
"build": "tsc -p tsconfig.json",
|
|
33
33
|
"dev": "tsx src/index.ts",
|
|
34
|
-
"prepublishOnly": "npm run build"
|
|
34
|
+
"prepublishOnly": "npm run build",
|
|
35
|
+
"test": "vitest run"
|
|
35
36
|
},
|
|
36
37
|
"license": "MIT"
|
|
37
38
|
}
|
package/dist/commands/page.js
DELETED
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
-
import { dirname, join } from "node:path";
|
|
3
|
-
import { readProjectConfig, resolveRegistry } from "../config.js";
|
|
4
|
-
import { fetchPage } from "../registry.js";
|
|
5
|
-
/**
|
|
6
|
-
* Materializes a whole page from a DS template into the project (hybrid
|
|
7
|
-
* codegen-first): the server codegens deterministic files, we write them, and
|
|
8
|
-
* the agent refines them in place. The page uses the DS's `.ds-*` classes +
|
|
9
|
-
* path classes; the co-located CSS (Next target) carries the responsive media
|
|
10
|
-
* queries + the CSS-only hamburger, so it re-vests once `tokens.css` is in.
|
|
11
|
-
*/
|
|
12
|
-
export async function page(slug, template, opts) {
|
|
13
|
-
const base = resolveRegistry(opts.registry);
|
|
14
|
-
const root = opts.dir ?? process.cwd();
|
|
15
|
-
const config = await readProjectConfig(root);
|
|
16
|
-
const target = opts.target === "general" || opts.target === "next"
|
|
17
|
-
? opts.target
|
|
18
|
-
: config.target;
|
|
19
|
-
console.log(`→ generating "${template}" from "${slug}" (${target}) …`);
|
|
20
|
-
const generated = await fetchPage(base, slug, template, target, opts.version);
|
|
21
|
-
// --out targets the page (1st file); sibling files (e.g. the CSS) land in the
|
|
22
|
-
// same directory. Without --out, everything goes under <pagesDir>.
|
|
23
|
-
const [pageFile, ...siblings] = generated.files;
|
|
24
|
-
const pageRel = opts.out ?? join(config.pagesDir, pageFile.filename);
|
|
25
|
-
const pageDir = dirname(join(root, pageRel));
|
|
26
|
-
await mkdir(pageDir, { recursive: true });
|
|
27
|
-
await writeFile(join(root, pageRel), pageFile.code, "utf8");
|
|
28
|
-
console.log(`✓ wrote ${pageRel} (${slug} v${generated.version})`);
|
|
29
|
-
for (const f of siblings) {
|
|
30
|
-
const rel = opts.out
|
|
31
|
-
? join(dirname(pageRel), f.filename)
|
|
32
|
-
: join(config.pagesDir, f.filename);
|
|
33
|
-
await writeFile(join(root, rel), f.code, "utf8");
|
|
34
|
-
console.log(`✓ wrote ${rel}`);
|
|
35
|
-
}
|
|
36
|
-
console.log("");
|
|
37
|
-
console.log("Next steps:");
|
|
38
|
-
console.log(` • ensure the DS is installed: synthesisui add ${slug} (provides tokens.css)`);
|
|
39
|
-
console.log(` • @import "_synthesisui/ds/${slug}/tokens.css" in your global CSS`);
|
|
40
|
-
console.log(" • refine the file: wire real data, split into components, swap placeholders");
|
|
41
|
-
console.log(` • keep the data-ds="${slug}" wrapper and the ds-* / layout classes (stays on-system)`);
|
|
42
|
-
}
|