synthesisui 0.16.65 → 0.16.66
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/import.js +24 -1
- package/dist/doctor/transcribe.js +323 -0
- package/package.json +1 -1
package/dist/commands/import.js
CHANGED
|
@@ -10,6 +10,7 @@ import { describeConvention, detectConventions, IDIOM_LABEL, } from "../doctor/i
|
|
|
10
10
|
import { diagnose, scanSource } from "../doctor/scan.js";
|
|
11
11
|
import { parseSchemeBlocks } from "../doctor/scheme-blocks.js";
|
|
12
12
|
import { buildTable } from "../doctor/tokens.js";
|
|
13
|
+
import { rootClasses, transcribe, } from "../doctor/transcribe.js";
|
|
13
14
|
import { body, paint, section } from "../output.js";
|
|
14
15
|
import { walk, walkAll } from "./doctor.js";
|
|
15
16
|
/**
|
|
@@ -278,6 +279,14 @@ export async function takeCensus(root) {
|
|
|
278
279
|
// Kept for the reference check below, which needs every file at once: a name
|
|
279
280
|
// is only broken if NOTHING declares it, anywhere.
|
|
280
281
|
const sources = [];
|
|
282
|
+
// Name → value for every custom property their CSS declares, which is what
|
|
283
|
+
// turns `bg-ocean-500` into a token ref instead of a hex we looked up.
|
|
284
|
+
const declaredValues = new Map();
|
|
285
|
+
for (const m of css.matchAll(/(--[a-zA-Z0-9_-]+)\s*:\s*([^;}]+)/g)) {
|
|
286
|
+
if (!declaredValues.has(m[1]))
|
|
287
|
+
declaredValues.set(m[1], m[2].trim());
|
|
288
|
+
}
|
|
289
|
+
const looks = {};
|
|
281
290
|
for await (const file of walk(root)) {
|
|
282
291
|
const src = await readFile(file, "utf8").catch(() => "");
|
|
283
292
|
if (!src)
|
|
@@ -292,7 +301,20 @@ export async function takeCensus(root) {
|
|
|
292
301
|
scanComponentsInto(tally, rel, src, internal);
|
|
293
302
|
// The other half: what this file EXPORTS, with the axes its types
|
|
294
303
|
// declare. A library composes almost nothing and exports everything.
|
|
295
|
-
|
|
304
|
+
const found = scanDefinitions(rel, src);
|
|
305
|
+
defined.push(...found);
|
|
306
|
+
// THE LOOK, from their own class names. One transcription per file, keyed
|
|
307
|
+
// by the component it defines - the root element's classes are the
|
|
308
|
+
// component's own look, and everything below it is a part this slice does
|
|
309
|
+
// not attempt.
|
|
310
|
+
if (found.length > 0) {
|
|
311
|
+
const t = transcribe(rootClasses(src), declaredValues);
|
|
312
|
+
const size = Object.keys(t.base).length +
|
|
313
|
+
Object.keys(t.dark).length +
|
|
314
|
+
Object.keys(t.states).length;
|
|
315
|
+
if (size > 0)
|
|
316
|
+
looks[found[0].name] = t;
|
|
317
|
+
}
|
|
296
318
|
}
|
|
297
319
|
}
|
|
298
320
|
const d = diagnose(reports);
|
|
@@ -389,6 +411,7 @@ export async function takeCensus(root) {
|
|
|
389
411
|
declared: Object.fromEntries(table.byName),
|
|
390
412
|
...(brokenRefs.length > 0 ? { brokenRefs } : {}),
|
|
391
413
|
...(conventions.length > 0 ? { conventions } : {}),
|
|
414
|
+
...(Object.keys(looks).length > 0 ? { looks } : {}),
|
|
392
415
|
...(schemes.alt.size > 0
|
|
393
416
|
? { declaredAlt: Object.fromEntries(schemes.alt) }
|
|
394
417
|
: {}),
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TRANSCRIBE A COMPONENT'S LOOK - deterministically, from their own class names.
|
|
3
|
+
*
|
|
4
|
+
* The import brings exclusive components in as contracts: axes declared, every
|
|
5
|
+
* style block empty. That was right when the alternative was inventing a look
|
|
6
|
+
* from a name. It is not right when the look is sitting in the file, spelled in
|
|
7
|
+
* a vocabulary we can read exactly.
|
|
8
|
+
*
|
|
9
|
+
* Measured before building any of this (dono, 01/08): across 23 exclusive
|
|
10
|
+
* components, 217 of 326 colour values resolve directly against their own
|
|
11
|
+
* declared tokens, and another 106 are Tailwind defaults - a fixed, published
|
|
12
|
+
* table. 99% is arithmetic. Nine of the 23 are entirely resolvable, and a single
|
|
13
|
+
* `TextEditor` holds 72% of all the ambiguity there is.
|
|
14
|
+
*
|
|
15
|
+
* So this is a transcriber, not a generator. No AI, no cost, no quota, and no
|
|
16
|
+
* chance of a value nobody wrote: every declaration it emits either points at a
|
|
17
|
+
* token they declared or carries a literal they typed.
|
|
18
|
+
*
|
|
19
|
+
* WHY NOT `refit`. That endpoint already turns arbitrary component code into a
|
|
20
|
+
* token-only recipe, and it is the right tool for its own job - bringing in
|
|
21
|
+
* code from OUTSIDE and dressing it in a system. But it maps every value to the
|
|
22
|
+
* NEAREST allowed token and never emits a raw one, which is normalisation, and
|
|
23
|
+
* v1 is a mirror. It also costs credits and has a daily quota, and a first
|
|
24
|
+
* import would spend 23 of them before anybody has seen value. It stays where
|
|
25
|
+
* it is; this runs first, for free, on what is already theirs.
|
|
26
|
+
*
|
|
27
|
+
* THE MODIFIER SAYS WHERE THE VALUE GOES, which is what makes this tractable:
|
|
28
|
+
*
|
|
29
|
+
* bg-white → base
|
|
30
|
+
* dark:bg-darkgray-500 → the dark side of base
|
|
31
|
+
* hover:shadow-md → states.hover
|
|
32
|
+
* data-[checked]:bg-ocean-50 → states.checked
|
|
33
|
+
*/
|
|
34
|
+
/**
|
|
35
|
+
* Utility prefix → CSS property. Only the ones that carry DESIGN, which is the
|
|
36
|
+
* whole point: `flex`, `items-center` and `w-full` are structure, they are
|
|
37
|
+
* identical in every design system, and a recipe carrying them would be
|
|
38
|
+
* transcribing layout rather than a look.
|
|
39
|
+
*/
|
|
40
|
+
const COLOR_PROPERTY = {
|
|
41
|
+
bg: "background-color",
|
|
42
|
+
text: "color",
|
|
43
|
+
border: "border-color",
|
|
44
|
+
ring: "outline-color",
|
|
45
|
+
fill: "fill",
|
|
46
|
+
stroke: "stroke",
|
|
47
|
+
decoration: "text-decoration-color",
|
|
48
|
+
};
|
|
49
|
+
/** Tailwind's own spacing scale, in rem - the fixed part of the fixed table. */
|
|
50
|
+
const SPACING = {
|
|
51
|
+
"0": "0",
|
|
52
|
+
px: "1px",
|
|
53
|
+
"0.5": "0.125rem",
|
|
54
|
+
"1": "0.25rem",
|
|
55
|
+
"1.5": "0.375rem",
|
|
56
|
+
"2": "0.5rem",
|
|
57
|
+
"2.5": "0.625rem",
|
|
58
|
+
"3": "0.75rem",
|
|
59
|
+
"3.5": "0.875rem",
|
|
60
|
+
"4": "1rem",
|
|
61
|
+
"5": "1.25rem",
|
|
62
|
+
"6": "1.5rem",
|
|
63
|
+
"7": "1.75rem",
|
|
64
|
+
"8": "2rem",
|
|
65
|
+
"9": "2.25rem",
|
|
66
|
+
"10": "2.5rem",
|
|
67
|
+
"11": "2.75rem",
|
|
68
|
+
"12": "3rem",
|
|
69
|
+
"14": "3.5rem",
|
|
70
|
+
"16": "4rem",
|
|
71
|
+
"20": "5rem",
|
|
72
|
+
"24": "6rem",
|
|
73
|
+
};
|
|
74
|
+
const SPACING_PROPERTY = {
|
|
75
|
+
p: "padding",
|
|
76
|
+
px: "padding-inline",
|
|
77
|
+
py: "padding-block",
|
|
78
|
+
pt: "padding-top",
|
|
79
|
+
pr: "padding-right",
|
|
80
|
+
pb: "padding-bottom",
|
|
81
|
+
pl: "padding-left",
|
|
82
|
+
m: "margin",
|
|
83
|
+
mx: "margin-inline",
|
|
84
|
+
my: "margin-block",
|
|
85
|
+
gap: "gap",
|
|
86
|
+
};
|
|
87
|
+
/** Tailwind's radius scale. */
|
|
88
|
+
const RADIUS = {
|
|
89
|
+
none: "0",
|
|
90
|
+
sm: "0.125rem",
|
|
91
|
+
"": "0.25rem",
|
|
92
|
+
md: "0.375rem",
|
|
93
|
+
lg: "0.5rem",
|
|
94
|
+
xl: "0.75rem",
|
|
95
|
+
"2xl": "1rem",
|
|
96
|
+
"3xl": "1.5rem",
|
|
97
|
+
full: "9999px",
|
|
98
|
+
};
|
|
99
|
+
/** Tailwind's own colour table, for the values a project uses without declaring.
|
|
100
|
+
* Only the ones that actually turn up: white, black, and the neutral ramp a
|
|
101
|
+
* real project reached for 106 times. */
|
|
102
|
+
const TAILWIND_COLOR = {
|
|
103
|
+
white: "#ffffff",
|
|
104
|
+
black: "#000000",
|
|
105
|
+
transparent: "transparent",
|
|
106
|
+
"neutral-50": "#fafafa",
|
|
107
|
+
"neutral-100": "#f5f5f5",
|
|
108
|
+
"neutral-200": "#e5e5e5",
|
|
109
|
+
"neutral-300": "#d4d4d4",
|
|
110
|
+
"neutral-400": "#a3a3a3",
|
|
111
|
+
"neutral-500": "#737373",
|
|
112
|
+
"neutral-600": "#525252",
|
|
113
|
+
"neutral-700": "#404040",
|
|
114
|
+
"neutral-800": "#262626",
|
|
115
|
+
"neutral-900": "#171717",
|
|
116
|
+
"neutral-950": "#0a0a0a",
|
|
117
|
+
};
|
|
118
|
+
/** States we recognise. Anything else is skipped rather than invented. */
|
|
119
|
+
const STATE = /^(hover|focus|focus-visible|active|disabled|checked)$/;
|
|
120
|
+
const DATA_STATE = /^data-\[([a-z-]+)\]$/;
|
|
121
|
+
/**
|
|
122
|
+
* Split a class into its modifiers and the utility itself.
|
|
123
|
+
*
|
|
124
|
+
* Arbitrary values can contain colons (`bg-[url(a:b)]`), so the split stops at
|
|
125
|
+
* the first bracket - a naive split on `:` would shred them.
|
|
126
|
+
*/
|
|
127
|
+
export function parseClass(cls) {
|
|
128
|
+
// Bracket-aware, because BOTH sides use them: `data-[checked]:` is a modifier
|
|
129
|
+
// that contains one and `bg-[url(a:b)]` is a value that contains a colon. A
|
|
130
|
+
// first attempt stopped at the first `[`, which swallowed every `data-[…]:`
|
|
131
|
+
// modifier whole and dropped the state it named.
|
|
132
|
+
const modifiers = [];
|
|
133
|
+
let depth = 0;
|
|
134
|
+
let start = 0;
|
|
135
|
+
for (let i = 0; i < cls.length; i++) {
|
|
136
|
+
const ch = cls[i];
|
|
137
|
+
if (ch === "[")
|
|
138
|
+
depth += 1;
|
|
139
|
+
else if (ch === "]")
|
|
140
|
+
depth -= 1;
|
|
141
|
+
else if (ch === ":" && depth === 0) {
|
|
142
|
+
modifiers.push(cls.slice(start, i));
|
|
143
|
+
start = i + 1;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return { modifiers, utility: cls.slice(start) };
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* One utility → one declaration, or nothing.
|
|
150
|
+
*
|
|
151
|
+
* `declared` maps a token NAME to its value, so a resolved colour can be
|
|
152
|
+
* reported in their vocabulary rather than as a hex we looked up.
|
|
153
|
+
*/
|
|
154
|
+
export function readUtility(utility, declared) {
|
|
155
|
+
// An opacity modifier changes the value, not the property, and honouring it
|
|
156
|
+
// would mean computing a colour they never wrote. The property still belongs
|
|
157
|
+
// in the recipe, so the base colour travels and the alpha does not.
|
|
158
|
+
const [core] = utility.split("/");
|
|
159
|
+
const dash = core.indexOf("-");
|
|
160
|
+
if (dash === -1)
|
|
161
|
+
return null;
|
|
162
|
+
const prefix = core.slice(0, dash);
|
|
163
|
+
const rest = core.slice(dash + 1);
|
|
164
|
+
const colorProp = COLOR_PROPERTY[prefix];
|
|
165
|
+
if (colorProp) {
|
|
166
|
+
// Their own token first: `--color-ocean-500` for `bg-ocean-500`, which is
|
|
167
|
+
// exactly how Tailwind v4's `@theme` publishes it.
|
|
168
|
+
const own = `--color-${rest}`;
|
|
169
|
+
if (declared.has(own)) {
|
|
170
|
+
return { property: colorProp, value: refFor(rest), token: own };
|
|
171
|
+
}
|
|
172
|
+
const builtin = TAILWIND_COLOR[rest];
|
|
173
|
+
if (builtin)
|
|
174
|
+
return { property: colorProp, value: builtin };
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
const spaceProp = SPACING_PROPERTY[prefix];
|
|
178
|
+
if (spaceProp && SPACING[rest]) {
|
|
179
|
+
return { property: spaceProp, value: SPACING[rest] };
|
|
180
|
+
}
|
|
181
|
+
if (prefix === "rounded") {
|
|
182
|
+
const own = `--radius-${rest}`;
|
|
183
|
+
if (declared.has(own)) {
|
|
184
|
+
return {
|
|
185
|
+
property: "border-radius",
|
|
186
|
+
value: `{radius.${rest}}`,
|
|
187
|
+
token: own,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
if (RADIUS[rest])
|
|
191
|
+
return { property: "border-radius", value: RADIUS[rest] };
|
|
192
|
+
}
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* A colour token ref in the document's own spelling.
|
|
197
|
+
*
|
|
198
|
+
* `ocean-500` → `{color.ocean.500}`. The step is the trailing number; anything
|
|
199
|
+
* before it is the family, hyphens intact, because `royal-blue-500` is one
|
|
200
|
+
* family called `royal-blue`.
|
|
201
|
+
*/
|
|
202
|
+
function refFor(name) {
|
|
203
|
+
const m = /^(.*)-(\d{2,4})$/.exec(name);
|
|
204
|
+
if (!m)
|
|
205
|
+
return `{color.${name}}`;
|
|
206
|
+
return `{color.${m[1]}.${m[2]}}`;
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Read every class in a component's root element and route each declaration by
|
|
210
|
+
* its modifier.
|
|
211
|
+
*
|
|
212
|
+
* Unknown modifiers are skipped, not flattened into `base`: a `sm:` or
|
|
213
|
+
* `group-data-[checked]:` value written into the resting state would be a look
|
|
214
|
+
* the component never has.
|
|
215
|
+
*/
|
|
216
|
+
export function transcribe(classes, declared) {
|
|
217
|
+
const out = {
|
|
218
|
+
base: {},
|
|
219
|
+
states: {},
|
|
220
|
+
dark: {},
|
|
221
|
+
skipped: [],
|
|
222
|
+
fromToken: 0,
|
|
223
|
+
fromLiteral: 0,
|
|
224
|
+
};
|
|
225
|
+
for (const cls of classes) {
|
|
226
|
+
const { modifiers, utility } = parseClass(cls);
|
|
227
|
+
const isDark = modifiers.includes("dark");
|
|
228
|
+
const rest = modifiers.filter((m) => m !== "dark");
|
|
229
|
+
// THE SLOT IS DECIDED BEFORE THE VALUE IS READ. Resolving first meant a
|
|
230
|
+
// `dark:hover:` whose colour happened to be undeclared vanished in silence
|
|
231
|
+
// rather than being reported as a look we have nowhere to put - two
|
|
232
|
+
// different problems, and only one of them is theirs to hear about.
|
|
233
|
+
let target = null;
|
|
234
|
+
let unslotted = false;
|
|
235
|
+
if (rest.length === 0) {
|
|
236
|
+
target = isDark ? out.dark : out.base;
|
|
237
|
+
}
|
|
238
|
+
else if (rest.length === 1 && !isDark) {
|
|
239
|
+
const state = STATE.test(rest[0])
|
|
240
|
+
? rest[0]
|
|
241
|
+
: (DATA_STATE.exec(rest[0])?.[1] ?? null);
|
|
242
|
+
if (state)
|
|
243
|
+
target = out.states[state] ?? (out.states[state] = {});
|
|
244
|
+
else
|
|
245
|
+
unslotted = true;
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
// A state under `dark:` is the dark scheme's version of that state, and a
|
|
249
|
+
// recipe has no slot for it.
|
|
250
|
+
unslotted = true;
|
|
251
|
+
}
|
|
252
|
+
const decl = readUtility(utility, declared);
|
|
253
|
+
if (unslotted) {
|
|
254
|
+
// Only worth saying when it IS a design value; an unslotted `sm:flex` is
|
|
255
|
+
// layout and nobody needs to hear about it.
|
|
256
|
+
if (decl)
|
|
257
|
+
out.skipped.push(cls);
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
if (!decl || !target)
|
|
261
|
+
continue;
|
|
262
|
+
// First write wins, matching how a class list resolves for the properties
|
|
263
|
+
// we read: later duplicates in the same list are almost always a merge
|
|
264
|
+
// artefact rather than an override.
|
|
265
|
+
if (target[decl.property] != null)
|
|
266
|
+
continue;
|
|
267
|
+
target[decl.property] = decl.value;
|
|
268
|
+
if (decl.token)
|
|
269
|
+
out.fromToken += 1;
|
|
270
|
+
else
|
|
271
|
+
out.fromLiteral += 1;
|
|
272
|
+
}
|
|
273
|
+
return out;
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* THE ROOT ELEMENT'S CLASSES, from a component file.
|
|
277
|
+
*
|
|
278
|
+
* A component's look lives on the outermost element it returns, and everything
|
|
279
|
+
* below it is a part - which a recipe has slots for and this slice does not
|
|
280
|
+
* attempt. Finding the root without an AST: the first JSX tag after `return (`
|
|
281
|
+
* is it, and its class list runs to the first `>` at depth zero.
|
|
282
|
+
*
|
|
283
|
+
* Deliberately conservative. A file this cannot read confidently returns
|
|
284
|
+
* nothing, and nothing is a contract with an empty base - which is exactly what
|
|
285
|
+
* the import already produces, so failing here costs no ground.
|
|
286
|
+
*/
|
|
287
|
+
export function rootClasses(source) {
|
|
288
|
+
const ret = source.search(/return\s*\(/);
|
|
289
|
+
if (ret === -1)
|
|
290
|
+
return [];
|
|
291
|
+
const open = source.indexOf("<", ret);
|
|
292
|
+
if (open === -1)
|
|
293
|
+
return [];
|
|
294
|
+
// Walk to the end of the opening tag, tracking brackets so a `className={cn(
|
|
295
|
+
// "a", cond ? "b" : "c")}` block is read whole rather than cut at its first
|
|
296
|
+
// `>` inside a comparison.
|
|
297
|
+
let depth = 0;
|
|
298
|
+
let end = -1;
|
|
299
|
+
for (let i = open + 1; i < source.length; i++) {
|
|
300
|
+
const ch = source[i];
|
|
301
|
+
if (ch === "{" || ch === "(" || ch === "[")
|
|
302
|
+
depth += 1;
|
|
303
|
+
else if (ch === "}" || ch === ")" || ch === "]")
|
|
304
|
+
depth -= 1;
|
|
305
|
+
else if (ch === ">" && depth <= 0) {
|
|
306
|
+
end = i;
|
|
307
|
+
break;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
if (end === -1)
|
|
311
|
+
return [];
|
|
312
|
+
const tag = source.slice(open, end);
|
|
313
|
+
const at = tag.search(/className\s*=/);
|
|
314
|
+
if (at === -1)
|
|
315
|
+
return [];
|
|
316
|
+
// Every string literal inside the className expression. Template literals and
|
|
317
|
+
// computed values are skipped on purpose: a class we cannot see in full is a
|
|
318
|
+
// class we would be guessing at.
|
|
319
|
+
const expr = tag.slice(at);
|
|
320
|
+
return [...expr.matchAll(/["']([^"']+)["']/g)]
|
|
321
|
+
.flatMap((m) => m[1].split(/\s+/))
|
|
322
|
+
.filter((c) => c.length > 0 && !c.includes("${"));
|
|
323
|
+
}
|
package/package.json
CHANGED