synthesisui 0.16.9 → 0.16.11
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/hook.js +76 -0
- package/dist/doctor/tokens.js +76 -4
- package/dist/index.js +6 -0
- package/package.json +1 -1
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { relative, resolve } from "node:path";
|
|
3
|
+
import { diagnose, scanSource } from "../doctor/scan.js";
|
|
4
|
+
import { loadSystem } from "./doctor.js";
|
|
5
|
+
const pass = () => ({ continue: true });
|
|
6
|
+
const speak = (context) => ({
|
|
7
|
+
continue: true,
|
|
8
|
+
hookSpecificOutput: {
|
|
9
|
+
hookEventName: "PostToolUse",
|
|
10
|
+
additionalContext: context,
|
|
11
|
+
},
|
|
12
|
+
});
|
|
13
|
+
/** Files where a design value can even appear. A hook that parses a README on
|
|
14
|
+
* every edit is paying for nothing. */
|
|
15
|
+
const UI_FILE = /\.(tsx|jsx|ts|js|css|scss|vue|svelte)$/i;
|
|
16
|
+
async function report(root, filePath) {
|
|
17
|
+
const { table } = await loadSystem(root);
|
|
18
|
+
if (table.byName.size === 0)
|
|
19
|
+
return null;
|
|
20
|
+
const src = await readFile(filePath, "utf8").catch(() => "");
|
|
21
|
+
if (!src)
|
|
22
|
+
return null;
|
|
23
|
+
const rel = relative(root, filePath);
|
|
24
|
+
const d = diagnose([scanSource(rel, src, table)]);
|
|
25
|
+
const named = d.findings.filter((f) => f.token);
|
|
26
|
+
const phantoms = d.files.flatMap((f) => f.phantoms ?? []);
|
|
27
|
+
// Unnamed drift alone is deliberately NOT worth interrupting for. There is
|
|
28
|
+
// no token to move to, so the only honest advice is "ask a person" - and
|
|
29
|
+
// saying that after every edit trains the reader to skip the block.
|
|
30
|
+
if (named.length === 0 && phantoms.length === 0)
|
|
31
|
+
return null;
|
|
32
|
+
const lines = [`${rel} - checked against ${table.name ?? table.slug}.`];
|
|
33
|
+
if (named.length > 0) {
|
|
34
|
+
lines.push("", "Values written by hand that this system already has a name for:", ...named
|
|
35
|
+
.slice(0, 20)
|
|
36
|
+
.map((f) => ` line ${f.line} ${f.literal} → ${f.token}`), "", "Replace them now, while you still have this file in mind.");
|
|
37
|
+
}
|
|
38
|
+
if (phantoms.length > 0) {
|
|
39
|
+
lines.push("", "Names this system does not declare. These look tokenized and apply nothing at all:", ...phantoms.slice(0, 20).map((p) => ` line ${p.line} ${p.name}`), "", "Use a name the system has, or say which value you need and what you would call it. Do NOT invent a token.");
|
|
40
|
+
}
|
|
41
|
+
return lines.join("\n");
|
|
42
|
+
}
|
|
43
|
+
export async function hook(opts) {
|
|
44
|
+
const root = resolve(opts.dir ?? process.cwd());
|
|
45
|
+
let raw = "";
|
|
46
|
+
for await (const chunk of process.stdin)
|
|
47
|
+
raw += chunk;
|
|
48
|
+
let input;
|
|
49
|
+
try {
|
|
50
|
+
input = JSON.parse(raw);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// Anything we cannot read must let the edit through untouched. A hook that
|
|
54
|
+
// fails loudly on a payload change is worse than no hook.
|
|
55
|
+
process.stdout.write(`${JSON.stringify(pass())}\n`);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const writes = input.tool_name === "Write" ||
|
|
59
|
+
input.tool_name === "Edit" ||
|
|
60
|
+
input.tool_name === "MultiEdit";
|
|
61
|
+
const file = input.tool_input?.file_path;
|
|
62
|
+
if (input.hook_event_name !== "PostToolUse" || !writes || !file) {
|
|
63
|
+
process.stdout.write(`${JSON.stringify(pass())}\n`);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const abs = resolve(root, file);
|
|
67
|
+
// Our own installed artifacts are the answer, not the problem - and a file
|
|
68
|
+
// outside the project is none of our business.
|
|
69
|
+
const inside = !relative(root, abs).startsWith("..");
|
|
70
|
+
if (!UI_FILE.test(abs) || !inside || abs.includes("_synthesisui")) {
|
|
71
|
+
process.stdout.write(`${JSON.stringify(pass())}\n`);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const body = await report(root, abs).catch(() => null);
|
|
75
|
+
process.stdout.write(`${JSON.stringify(body ? speak(body) : pass())}\n`);
|
|
76
|
+
}
|
package/dist/doctor/tokens.js
CHANGED
|
@@ -180,6 +180,57 @@ export function parseTokens(css) {
|
|
|
180
180
|
* Harmless while nothing asked about existence. The moment the phantom check
|
|
181
181
|
* did, it called them invented (caught before shipping, 27/07).
|
|
182
182
|
*/
|
|
183
|
+
/**
|
|
184
|
+
* AN ALIAS IS NOT A DEAD END. IT IS USUALLY THE ANSWER.
|
|
185
|
+
*
|
|
186
|
+
* Every real system is written in two layers - `--ds-color-blue-600: #2563eb`
|
|
187
|
+
* on the shelf, `--ds-color-semantic-info: var(--ds-color-blue-600)` for the
|
|
188
|
+
* intent - and the value table only ever held the first. So `tokenFor` answered
|
|
189
|
+
* `#2563eb → --ds-color-blue-600`, the shelf, and its own rule two lines below
|
|
190
|
+
* ("semantic roles name intent; prefer intent") could never fire, because no
|
|
191
|
+
* semantic name was in the table to prefer.
|
|
192
|
+
*
|
|
193
|
+
* That is the one sentence the product is sold on: not "you have a hardcoded
|
|
194
|
+
* colour" but "your system already calls it this". It was naming the wrong
|
|
195
|
+
* thing on every system that layers its tokens, which is all of them. Found
|
|
196
|
+
* when an agent using the MCP tools read the answer, checked it against
|
|
197
|
+
* CLAUDE.md, and corrected the tool (my-test4, 27/07).
|
|
198
|
+
*
|
|
199
|
+
* Following the chain costs one pass and needs no CSS engine: a token either
|
|
200
|
+
* holds a literal or points at exactly one other token.
|
|
201
|
+
*/
|
|
202
|
+
export function resolveAliases(raw) {
|
|
203
|
+
const out = new Map();
|
|
204
|
+
const literal = (name, depth) => {
|
|
205
|
+
// A cycle is malformed CSS, not something to crash on. Ten hops is far
|
|
206
|
+
// past any real system and stops the recursion dead.
|
|
207
|
+
if (depth > 10)
|
|
208
|
+
return null;
|
|
209
|
+
const value = raw.get(name);
|
|
210
|
+
if (value === undefined)
|
|
211
|
+
return null;
|
|
212
|
+
const alias = /^var\(\s*(--[a-z0-9_-]+)/i.exec(value.trim());
|
|
213
|
+
return alias ? literal(alias[1].toLowerCase(), depth + 1) : value;
|
|
214
|
+
};
|
|
215
|
+
for (const name of raw.keys()) {
|
|
216
|
+
const value = literal(name, 0);
|
|
217
|
+
if (value !== null)
|
|
218
|
+
out.set(name, value);
|
|
219
|
+
}
|
|
220
|
+
return out;
|
|
221
|
+
}
|
|
222
|
+
/** Every `--ds-*` declaration, aliases kept - the input `resolveAliases` needs.
|
|
223
|
+
* `parseTokens` drops them, which is right for a table of literals and wrong
|
|
224
|
+
* for a table of what things are called. */
|
|
225
|
+
export function parseTokensWithAliases(css) {
|
|
226
|
+
const out = new Map();
|
|
227
|
+
for (const m of css.matchAll(/(--ds-[a-z0-9-]+)\s*:\s*([^;}]+)[;}]/gi)) {
|
|
228
|
+
const name = m[1].toLowerCase();
|
|
229
|
+
if (!out.has(name))
|
|
230
|
+
out.set(name, m[2].trim());
|
|
231
|
+
}
|
|
232
|
+
return out;
|
|
233
|
+
}
|
|
183
234
|
export function parseDeclaredNames(css) {
|
|
184
235
|
const out = new Set();
|
|
185
236
|
for (const m of css.matchAll(/(--[a-z0-9_-]+)\s*:\s*[^;}]+[;}]/gi))
|
|
@@ -280,14 +331,35 @@ export function buildTable(input) {
|
|
|
280
331
|
const byName = source === "installed"
|
|
281
332
|
? parseTokens(input.css)
|
|
282
333
|
: parseRootTokens(input.css);
|
|
334
|
+
/**
|
|
335
|
+
* Built from the RESOLVED map, not from `byName`. A semantic role that
|
|
336
|
+
* aliases a primitive has to be findable by the primitive's value, or the
|
|
337
|
+
* "prefer intent" rule below is unreachable.
|
|
338
|
+
*/
|
|
339
|
+
const resolved = source === "installed"
|
|
340
|
+
? resolveAliases(parseTokensWithAliases(input.css))
|
|
341
|
+
: byName;
|
|
342
|
+
/**
|
|
343
|
+
* Aliases first in every list.
|
|
344
|
+
*
|
|
345
|
+
* `tokenFor` preferred a name containing `-semantic-`, which covers colour
|
|
346
|
+
* and nothing else: `--ds-spacing-md: var(--ds-spacing-4)` has no such
|
|
347
|
+
* marker, so the answer came back as the raw step rather than the scale name
|
|
348
|
+
* anyone actually writes. The general rule is structural, not lexical - in a
|
|
349
|
+
* layered system the name that POINTS at another name is the layer above.
|
|
350
|
+
*/
|
|
351
|
+
const raw = source === "installed" ? parseTokensWithAliases(input.css) : new Map();
|
|
352
|
+
const isAlias = (name) => /^var\(/.test((raw.get(name) ?? "").trim());
|
|
283
353
|
const byValue = new Map();
|
|
284
|
-
for (const [name, value] of
|
|
354
|
+
for (const [name, value] of resolved) {
|
|
285
355
|
const key = normalizeValue(value);
|
|
286
356
|
const list = byValue.get(key);
|
|
287
|
-
if (list)
|
|
288
|
-
list.push(name);
|
|
289
|
-
else
|
|
357
|
+
if (!list)
|
|
290
358
|
byValue.set(key, [name]);
|
|
359
|
+
else if (isAlias(name))
|
|
360
|
+
list.unshift(name);
|
|
361
|
+
else
|
|
362
|
+
list.push(name);
|
|
291
363
|
}
|
|
292
364
|
return {
|
|
293
365
|
source: byName.size > 0 ? source : null,
|
package/dist/index.js
CHANGED
|
@@ -6,6 +6,7 @@ import { clean } from "./commands/clean.js";
|
|
|
6
6
|
import { component } from "./commands/component.js";
|
|
7
7
|
import { doctor } from "./commands/doctor.js";
|
|
8
8
|
import { generate } from "./commands/generate.js";
|
|
9
|
+
import { hook } from "./commands/hook.js";
|
|
9
10
|
import { init } from "./commands/init.js";
|
|
10
11
|
import { list } from "./commands/list.js";
|
|
11
12
|
import { login } from "./commands/login.js";
|
|
@@ -134,6 +135,11 @@ async function main() {
|
|
|
134
135
|
slug: typeof flags.slug === "string" ? flags.slug : undefined,
|
|
135
136
|
});
|
|
136
137
|
break;
|
|
138
|
+
// Both of these read stdin and write a protocol frame to stdout, so
|
|
139
|
+
// nothing that prints may run near them.
|
|
140
|
+
case "hook":
|
|
141
|
+
await hook({ dir });
|
|
142
|
+
return;
|
|
137
143
|
// Long-lived: it owns stdin/stdout until the client closes the pipe, so
|
|
138
144
|
// it must not be reached by anything that prints.
|
|
139
145
|
case "mcp":
|
package/package.json
CHANGED