synthesisui 0.4.13 → 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 +63 -0
- package/dist/commands/doctor.js +280 -0
- package/dist/doctor/overrides.js +189 -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,69 @@ 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
|
+
### 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
|
+
|
|
94
|
+
The last column is the point: not "you hardcoded a colour", but **the name
|
|
95
|
+
your own system already has for it**. Exact matches only - a tool that guesses
|
|
96
|
+
a near colour invites a silent visual change, and a diagnosis nobody trusts is
|
|
97
|
+
worse than none.
|
|
98
|
+
|
|
99
|
+
With no system installed it still finds every hand-written value and counts
|
|
100
|
+
the distinct ones. Runs offline, needs no account, writes nothing.
|
|
101
|
+
|
|
102
|
+
`--strict` exits 1 when drift is found, for CI. `--all` lists everything
|
|
103
|
+
instead of the loudest files.
|
|
41
104
|
|
|
42
105
|
## What `add` materializes
|
|
43
106
|
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
2
|
+
import { join, relative, resolve } from "node:path";
|
|
3
|
+
import { bindingsFromDocument, findOverrides, } from "../doctor/overrides.js";
|
|
4
|
+
import { diagnose, scanSource, } from "../doctor/scan.js";
|
|
5
|
+
import { buildTable, EMPTY_TABLE } from "../doctor/tokens.js";
|
|
6
|
+
import { body, section, snippet } from "../output.js";
|
|
7
|
+
/**
|
|
8
|
+
* `synthesisui doctor` - the check nobody else ships.
|
|
9
|
+
*
|
|
10
|
+
* Every generator in this market now promises "brand consistent, no drift".
|
|
11
|
+
* Not one of them verifies it. This does, in the only place it can be true:
|
|
12
|
+
* the code that actually shipped.
|
|
13
|
+
*
|
|
14
|
+
* Two audiences, one command:
|
|
15
|
+
*
|
|
16
|
+
* - With a system installed, it does the thing that cannot be faked - it names
|
|
17
|
+
* the project's OWN token for the value someone hardcoded. "#2563eb, which
|
|
18
|
+
* your system calls --ds-color-semantic-info." Nothing generic; the
|
|
19
|
+
* diagnosis is in their vocabulary.
|
|
20
|
+
* - With no system installed, it still finds every hand-written design value
|
|
21
|
+
* and counts the distinct ones. That number IS the pitch, and it costs the
|
|
22
|
+
* reader nothing to get.
|
|
23
|
+
*
|
|
24
|
+
* Runs offline, needs no account, and touches nothing. A tool that asks for a
|
|
25
|
+
* signup before it tells you anything is a tool nobody runs twice.
|
|
26
|
+
*/
|
|
27
|
+
const EXTS = [".tsx", ".ts", ".jsx", ".js", ".css", ".scss", ".vue", ".svelte"];
|
|
28
|
+
const SKIP = new Set([
|
|
29
|
+
"node_modules",
|
|
30
|
+
".next",
|
|
31
|
+
".git",
|
|
32
|
+
"dist",
|
|
33
|
+
"build",
|
|
34
|
+
"out",
|
|
35
|
+
"coverage",
|
|
36
|
+
".turbo",
|
|
37
|
+
".vercel",
|
|
38
|
+
// Our own installed artifacts are the answer, not the problem.
|
|
39
|
+
"_synthesisui",
|
|
40
|
+
]);
|
|
41
|
+
async function* walk(dir) {
|
|
42
|
+
// `readdir`'s overloads infer a Buffer-named Dirent without an explicit
|
|
43
|
+
// encoding; naming it keeps `e.name` a string.
|
|
44
|
+
const entries = await readdir(dir, {
|
|
45
|
+
withFileTypes: true,
|
|
46
|
+
encoding: "utf8",
|
|
47
|
+
}).catch(() => []);
|
|
48
|
+
for (const e of entries) {
|
|
49
|
+
if (e.name.startsWith(".") && e.name !== ".")
|
|
50
|
+
continue;
|
|
51
|
+
const full = join(dir, e.name);
|
|
52
|
+
if (e.isDirectory()) {
|
|
53
|
+
if (SKIP.has(e.name))
|
|
54
|
+
continue;
|
|
55
|
+
yield* walk(full);
|
|
56
|
+
}
|
|
57
|
+
else if (EXTS.some((x) => e.name.endsWith(x))) {
|
|
58
|
+
yield full;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
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. */
|
|
66
|
+
async function loadSystem(root) {
|
|
67
|
+
const dsDir = join(root, "_synthesisui", "ds");
|
|
68
|
+
let slugs;
|
|
69
|
+
try {
|
|
70
|
+
slugs = (await readdir(dsDir, { withFileTypes: true }))
|
|
71
|
+
.filter((e) => e.isDirectory())
|
|
72
|
+
.map((e) => e.name);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return { table: EMPTY_TABLE, recipes: new Map() };
|
|
76
|
+
}
|
|
77
|
+
// Several systems can live side by side; every token is prefixed --ds-, so
|
|
78
|
+
// reading them all is both correct and what the running app actually sees.
|
|
79
|
+
let css = "";
|
|
80
|
+
let lock = null;
|
|
81
|
+
const recipes = new Map();
|
|
82
|
+
for (const slug of slugs) {
|
|
83
|
+
const dir = join(dsDir, slug);
|
|
84
|
+
const raw = await readFile(join(dir, ".lock"), "utf8").catch(() => "");
|
|
85
|
+
let mine = null;
|
|
86
|
+
try {
|
|
87
|
+
mine = raw ? JSON.parse(raw) : null;
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
mine = null;
|
|
91
|
+
}
|
|
92
|
+
lock ??= mine;
|
|
93
|
+
// The file at the root is a POINTER - `@import "./v1/tokens.css"` - so the
|
|
94
|
+
// pinned folder is where the declarations actually live. Read that first,
|
|
95
|
+
// and fall back to following whatever the root imports, for a project that
|
|
96
|
+
// pinned by hand.
|
|
97
|
+
const root = await readFile(join(dir, "tokens.css"), "utf8").catch(() => "");
|
|
98
|
+
let real = mine?.version
|
|
99
|
+
? await readFile(join(dir, `v${mine.version}`, "tokens.css"), "utf8").catch(() => "")
|
|
100
|
+
: "";
|
|
101
|
+
if (!real) {
|
|
102
|
+
for (const m of root.matchAll(/@import\s+["']([^"']+)["']/g)) {
|
|
103
|
+
real += `\n${await readFile(join(dir, m[1]), "utf8").catch(() => "")}`;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
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
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return { table: buildTable({ css, lock }), recipes };
|
|
123
|
+
}
|
|
124
|
+
const KIND_LABEL = {
|
|
125
|
+
color: "colour",
|
|
126
|
+
radius: "radius",
|
|
127
|
+
spacing: "spacing",
|
|
128
|
+
font: "type",
|
|
129
|
+
};
|
|
130
|
+
/** A bar you can read at a glance, in a font that is always monospace. */
|
|
131
|
+
function meter(pct, width = 24) {
|
|
132
|
+
const filled = Math.round((pct / 100) * width);
|
|
133
|
+
return `${"█".repeat(filled)}${"░".repeat(width - filled)}`;
|
|
134
|
+
}
|
|
135
|
+
function verdict(d, hasSystem) {
|
|
136
|
+
// Nothing found and nothing installed: a utility package, a config folder,
|
|
137
|
+
// the wrong directory. Selling a design system here would be noise.
|
|
138
|
+
if (!hasSystem && d.findings.length === 0) {
|
|
139
|
+
return [
|
|
140
|
+
body("No design values are written by hand here, and no system is"),
|
|
141
|
+
body("installed. Nothing to fix, and nothing to compare against."),
|
|
142
|
+
];
|
|
143
|
+
}
|
|
144
|
+
if (!hasSystem) {
|
|
145
|
+
const distinct = new Set(d.findings.map((f) => f.literal.toLowerCase()));
|
|
146
|
+
return [
|
|
147
|
+
body(`${distinct.size} distinct design values are written by hand here.`),
|
|
148
|
+
body("No design system is installed, so none of them has a name yet."),
|
|
149
|
+
"",
|
|
150
|
+
body("Give them one:"),
|
|
151
|
+
snippet(["npx synthesisui@latest init --ds <slug>"]),
|
|
152
|
+
body("Browse systems at https://www.synthesisui.com/gallery"),
|
|
153
|
+
];
|
|
154
|
+
}
|
|
155
|
+
if (d.findings.length === 0) {
|
|
156
|
+
return [
|
|
157
|
+
body("No drift. Every design value in this project comes from the"),
|
|
158
|
+
body("system. That is a rarer sentence than it sounds."),
|
|
159
|
+
];
|
|
160
|
+
}
|
|
161
|
+
const lines = [
|
|
162
|
+
body(`${d.named} of ${d.findings.length} already have a name in your system.`),
|
|
163
|
+
body("Those are the cheap ones: swap the literal for the token."),
|
|
164
|
+
];
|
|
165
|
+
if (d.findings.length > d.named) {
|
|
166
|
+
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."));
|
|
167
|
+
}
|
|
168
|
+
return lines;
|
|
169
|
+
}
|
|
170
|
+
export async function doctor(opts) {
|
|
171
|
+
const root = resolve(opts.dir ?? process.cwd());
|
|
172
|
+
const { table, recipes } = await loadSystem(root);
|
|
173
|
+
const hasSystem = table.byName.size > 0;
|
|
174
|
+
const reports = [];
|
|
175
|
+
const overrides = [];
|
|
176
|
+
for await (const file of walk(root)) {
|
|
177
|
+
const src = await readFile(file, "utf8").catch(() => "");
|
|
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
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (reports.length === 0) {
|
|
188
|
+
console.log(`\nNothing to read in ${root}.\n`);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const d = diagnose(reports);
|
|
192
|
+
console.log(section("Doctor"));
|
|
193
|
+
console.log(body(hasSystem
|
|
194
|
+
? `${table.name ?? table.slug} v${table.version ?? "?"} - ${table.byName.size} tokens, ${d.scanned} files read`
|
|
195
|
+
: `No system installed - ${d.scanned} files read`));
|
|
196
|
+
if (hasSystem) {
|
|
197
|
+
console.log("");
|
|
198
|
+
console.log(body(`Token coverage ${meter(d.coverage)} ${String(d.coverage).padStart(3)}%`));
|
|
199
|
+
console.log(body(` ${d.tokenUses} from the system, ${d.findings.length} by hand`));
|
|
200
|
+
}
|
|
201
|
+
if (d.findings.length > 0) {
|
|
202
|
+
console.log(section("Drift"));
|
|
203
|
+
const order = ["color", "radius", "spacing", "font"].filter((k) => d.counts[k] > 0);
|
|
204
|
+
for (const kind of order) {
|
|
205
|
+
console.log(body(`${d.counts[kind]} ${KIND_LABEL[kind]}`));
|
|
206
|
+
}
|
|
207
|
+
// What a person actually acts on first: the value repeated everywhere.
|
|
208
|
+
// One decision here retires dozens of sites, and a list sorted by file
|
|
209
|
+
// never tells you that.
|
|
210
|
+
const repeats = d.repeats.slice(0, 5);
|
|
211
|
+
if (repeats.length > 0) {
|
|
212
|
+
console.log("");
|
|
213
|
+
console.log(body("Most repeated"));
|
|
214
|
+
const w = Math.max(...repeats.map((r) => r.literal.length));
|
|
215
|
+
for (const r of repeats) {
|
|
216
|
+
const where = `${r.count}\u00d7 in ${r.files} file${r.files === 1 ? "" : "s"}`;
|
|
217
|
+
const named = r.token ? ` → ${r.token}` : "";
|
|
218
|
+
console.log(` ${r.literal.padEnd(w)} ${where}${named}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
// Loudest files first: drift concentrates, and the fix is usually one
|
|
222
|
+
// shared component rather than two hundred call sites.
|
|
223
|
+
const files = [...d.files].sort((a, b) => b.findings.length - a.findings.length);
|
|
224
|
+
const shownFiles = opts.all ? files : files.slice(0, 8);
|
|
225
|
+
console.log("");
|
|
226
|
+
for (const f of shownFiles) {
|
|
227
|
+
console.log(body(`${f.file}`));
|
|
228
|
+
const shown = opts.all ? f.findings : f.findings.slice(0, 3);
|
|
229
|
+
for (const x of shown) {
|
|
230
|
+
const named = x.token
|
|
231
|
+
? `→ ${x.token}`
|
|
232
|
+
: "→ no token holds this value yet";
|
|
233
|
+
console.log(` ${String(x.line).padStart(4)} ${x.literal} ${named}`);
|
|
234
|
+
}
|
|
235
|
+
if (f.findings.length > shown.length) {
|
|
236
|
+
console.log(` +${f.findings.length - shown.length} more`);
|
|
237
|
+
}
|
|
238
|
+
console.log("");
|
|
239
|
+
}
|
|
240
|
+
if (files.length > shownFiles.length) {
|
|
241
|
+
console.log(body(`+${files.length - shownFiles.length} more files. Run with --all to see everything.`));
|
|
242
|
+
}
|
|
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
|
+
}
|
|
274
|
+
console.log(section("What this means"));
|
|
275
|
+
for (const line of verdict(d, hasSystem))
|
|
276
|
+
console.log(line);
|
|
277
|
+
console.log("");
|
|
278
|
+
if (opts.strict && (d.findings.length > 0 || overrides.length > 0))
|
|
279
|
+
process.exitCode = 1;
|
|
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
|
+
}
|
|
@@ -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.6.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
|
-
}
|