synthesisui 0.16.82 → 0.16.84
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 +219 -8
- package/dist/commands/mcp.js +148 -0
- package/dist/doctor/catalogue-fetch.js +88 -0
- package/dist/doctor/component-gate.js +40 -0
- package/dist/doctor/coverage.js +129 -0
- package/dist/doctor/crosswalk.js +34 -1
- package/dist/doctor/css-modules.js +535 -0
- package/dist/doctor/transcribe.js +87 -1
- package/dist/doctor/variant-read.js +121 -1
- package/dist/skill-import.js +46 -0
- package/package.json +1 -1
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSS MODULES - the largest source of style in a real repo, and the one nothing read.
|
|
3
|
+
*
|
|
4
|
+
* Enumerated before a line of this was written, which is the rule that produced it
|
|
5
|
+
* (dono, 01/08). In `apps/web-dashboard`:
|
|
6
|
+
*
|
|
7
|
+
* 530 module stylesheets, 32,947 lines
|
|
8
|
+
* 3,978 simple class selectors each one a part, under the author's own name
|
|
9
|
+
* 5,777 real CSS declarations against 0.2 per component read before this
|
|
10
|
+
* 1,623 (28%) point at var(--their-token)
|
|
11
|
+
* 51 dark rules via [data-theme], all in one stylesheet
|
|
12
|
+
* 154 keyframes
|
|
13
|
+
* 47 descendant selectors
|
|
14
|
+
* 45 media queries
|
|
15
|
+
* 29 states
|
|
16
|
+
* 0 `composes:` - no inheritance to resolve
|
|
17
|
+
*
|
|
18
|
+
* 239 of 317 components (75%) style this way, and they arrived with ZERO declarations.
|
|
19
|
+
* A person importing that app would get 283 empty components and conclude the product
|
|
20
|
+
* is broken, which is a reasonable conclusion to reach.
|
|
21
|
+
*
|
|
22
|
+
* IT IS MORE RELIABLE THAN READING TAILWIND, not less. There is no utility table to
|
|
23
|
+
* maintain and nothing is inferred from a name: the declaration is already CSS, and the
|
|
24
|
+
* part name is the word the author typed. The mapping is arithmetic:
|
|
25
|
+
*
|
|
26
|
+
* Loader.tsx import styles from './Chat.module.css'
|
|
27
|
+
* <div className={styles.loader}> → the base
|
|
28
|
+
* <div className={styles.spinner}> → part "spinner"
|
|
29
|
+
* <p className={styles.loadingMessage}> → part "loading-message"
|
|
30
|
+
*
|
|
31
|
+
* Chat.module.css .loader { display:flex; gap:8px }
|
|
32
|
+
* .spinner { width:20px; animation:spinPulse 4s }
|
|
33
|
+
* .loadingMessage { … }
|
|
34
|
+
*
|
|
35
|
+
* THE INTERPOLATED TEMPLATE IS THE SAME FAMILY, which the enumeration also settled. Of
|
|
36
|
+
* 200 real `className={`…`}` occurrences, 165 are a literal plus `${styles.x}` and 85
|
|
37
|
+
* are `${cond ? styles.a : styles.b}` - a variant, with the condition naming the prop
|
|
38
|
+
* and both branches naming a class in this file. Exactly one is a ternary between two
|
|
39
|
+
* string literals. So this reader covers a family I had written off as impossible.
|
|
40
|
+
*/
|
|
41
|
+
/** A CSS pseudo-class or attribute, mapped to the state name the contract uses. */
|
|
42
|
+
const STATE_OF = [
|
|
43
|
+
[/:hover\b/, "hover"],
|
|
44
|
+
[/:focus-visible\b/, "focusVisible"],
|
|
45
|
+
[/:focus-within\b/, "focus"],
|
|
46
|
+
[/:focus\b/, "focus"],
|
|
47
|
+
[/:active\b/, "active"],
|
|
48
|
+
[/:disabled\b|\[disabled\]/, "disabled"],
|
|
49
|
+
[/:checked\b|\[data-checked\]|\[aria-checked="?true"?\]/, "checked"],
|
|
50
|
+
[/\[aria-selected="?true"?\]|\[data-selected\]/, "selected"],
|
|
51
|
+
[/\[open\]|\[aria-expanded="?true"?\]|\[data-state="?open"?\]/, "open"],
|
|
52
|
+
[/:invalid\b|\[aria-invalid="?true"?\]/, "invalid"],
|
|
53
|
+
[/:read-only\b|\[readonly\]/, "readOnly"],
|
|
54
|
+
[/::placeholder\b/, "placeholder"],
|
|
55
|
+
[/:nth-child\(even\)/, "even"],
|
|
56
|
+
[/:nth-child\(odd\)/, "odd"],
|
|
57
|
+
[/:first-child\b/, "first"],
|
|
58
|
+
[/:last-child\b/, "last"],
|
|
59
|
+
[/:empty\b/, "empty"],
|
|
60
|
+
];
|
|
61
|
+
/** `[data-theme="dark"]`, `.dark`, `[data-scheme=dark]` - how a project says dark. */
|
|
62
|
+
const DARK = /\[data-theme=["']?dark["']?\]|\[data-scheme=["']?dark["']?\]|(^|\s)\.dark(\s|$)/;
|
|
63
|
+
/** `kebab-case` → `camelCase`, which is the only spelling the contract accepts. */
|
|
64
|
+
export function camel(prop) {
|
|
65
|
+
return prop.trim().replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
66
|
+
}
|
|
67
|
+
/** `.loadingMessage` → `loading-message`. THEIR word, only re-spelled for a class. */
|
|
68
|
+
export function partName(authored) {
|
|
69
|
+
return authored
|
|
70
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
|
|
71
|
+
.toLowerCase()
|
|
72
|
+
.replace(/[^a-z0-9-]/g, "-")
|
|
73
|
+
.replace(/-+/g, "-")
|
|
74
|
+
.replace(/^-|-$/g, "");
|
|
75
|
+
}
|
|
76
|
+
/** Comments out, so a `{` inside one cannot end a block early. */
|
|
77
|
+
function stripCssComments(css) {
|
|
78
|
+
return css.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
79
|
+
}
|
|
80
|
+
function rules(css, media = null, out = []) {
|
|
81
|
+
let i = 0;
|
|
82
|
+
while (i < css.length) {
|
|
83
|
+
const open = css.indexOf("{", i);
|
|
84
|
+
if (open === -1)
|
|
85
|
+
break;
|
|
86
|
+
const head = css.slice(i, open).trim();
|
|
87
|
+
// Balance to the matching close, because an at-rule contains whole rules.
|
|
88
|
+
let depth = 0;
|
|
89
|
+
let close = -1;
|
|
90
|
+
for (let j = open; j < css.length; j++) {
|
|
91
|
+
if (css[j] === "{")
|
|
92
|
+
depth += 1;
|
|
93
|
+
else if (css[j] === "}") {
|
|
94
|
+
depth -= 1;
|
|
95
|
+
if (depth === 0) {
|
|
96
|
+
close = j;
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (close === -1)
|
|
102
|
+
break;
|
|
103
|
+
const body = css.slice(open + 1, close);
|
|
104
|
+
if (head.startsWith("@")) {
|
|
105
|
+
const at = /^@media\b/.test(head) ? head : null;
|
|
106
|
+
if (/^@(?:media|supports|layer)\b/.test(head)) {
|
|
107
|
+
rules(body, at ?? media, out);
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
// `@keyframes` and friends: the caller reads them whole.
|
|
111
|
+
out.push({ selector: head, body, media });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
else if (head) {
|
|
115
|
+
out.push({ selector: head, body, media });
|
|
116
|
+
}
|
|
117
|
+
i = close + 1;
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
/** `prop: value;` pairs, in the camelCase the contract uses. */
|
|
122
|
+
function declarations(body) {
|
|
123
|
+
const out = {};
|
|
124
|
+
// Only the top level: a nested rule's declarations belong to that rule.
|
|
125
|
+
const flat = body.replace(/\{[^{}]*\}/g, "").replace(/[^;]*\{/g, "");
|
|
126
|
+
for (const piece of flat.split(";")) {
|
|
127
|
+
const colon = piece.indexOf(":");
|
|
128
|
+
if (colon === -1)
|
|
129
|
+
continue;
|
|
130
|
+
const prop = piece.slice(0, colon).trim();
|
|
131
|
+
const value = piece.slice(colon + 1).trim();
|
|
132
|
+
if (!/^-?[a-z][a-z-]*$/.test(prop) || !value)
|
|
133
|
+
continue;
|
|
134
|
+
// A value that could break out of the stylesheet is not a value we carry.
|
|
135
|
+
if (/[<>{}@]/.test(value))
|
|
136
|
+
continue;
|
|
137
|
+
out[camel(prop)] = value;
|
|
138
|
+
}
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
/** `min-width: 768px` out of a media query, or null for anything else. */
|
|
142
|
+
function minWidthOf(media) {
|
|
143
|
+
if (!media)
|
|
144
|
+
return null;
|
|
145
|
+
const m = /min-width\s*:\s*([\d.]+(?:px|rem|em))/i.exec(media);
|
|
146
|
+
return m ? m[1] : null;
|
|
147
|
+
}
|
|
148
|
+
const CLASS_TOKEN = /\.(-?[_a-zA-Z][\w-]*)/g;
|
|
149
|
+
/** Every class the selector names, in order - the last one is the target. */
|
|
150
|
+
function classesIn(selector) {
|
|
151
|
+
CLASS_TOKEN.lastIndex = 0;
|
|
152
|
+
return [...selector.matchAll(CLASS_TOKEN)].map((m) => m[1]);
|
|
153
|
+
}
|
|
154
|
+
function emptyClass() {
|
|
155
|
+
return {
|
|
156
|
+
base: {},
|
|
157
|
+
states: {},
|
|
158
|
+
dark: {},
|
|
159
|
+
at: {},
|
|
160
|
+
darkStates: {},
|
|
161
|
+
children: [],
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* One module stylesheet, read.
|
|
166
|
+
*
|
|
167
|
+
* `declared` resolves a `var(--their-token)` to the ref for the token they named, the
|
|
168
|
+
* same way the utility reader does - so the value travels as `{radius.md}` rather than
|
|
169
|
+
* as a literal, and the recipe re-themes.
|
|
170
|
+
*/
|
|
171
|
+
export function readModuleCss(css) {
|
|
172
|
+
const out = { classes: {}, keyframes: {}, unslotted: [] };
|
|
173
|
+
const clean = stripCssComments(css);
|
|
174
|
+
for (const rule of rules(clean)) {
|
|
175
|
+
if (rule.selector.startsWith("@keyframes")) {
|
|
176
|
+
const name = rule.selector.replace(/^@keyframes\s+/, "").trim();
|
|
177
|
+
if (name)
|
|
178
|
+
out.keyframes[name] = rule.body.trim();
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (rule.selector.startsWith("@")) {
|
|
182
|
+
out.unslotted.push(rule.selector);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
// A comma-separated selector is several rules with one body.
|
|
186
|
+
for (const one of rule.selector.split(",")) {
|
|
187
|
+
const selector = one.trim();
|
|
188
|
+
if (!selector)
|
|
189
|
+
continue;
|
|
190
|
+
const names = classesIn(selector);
|
|
191
|
+
if (names.length === 0) {
|
|
192
|
+
// An element or `:root` selector styles something this reader cannot attach to
|
|
193
|
+
// a component. Said out loud rather than dropped.
|
|
194
|
+
out.unslotted.push(selector);
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
const target = names[names.length - 1];
|
|
198
|
+
const entry = out.classes[target] ?? emptyClass();
|
|
199
|
+
out.classes[target] = entry;
|
|
200
|
+
// A descendant selector records the RELATION as well as the styles: `.panel
|
|
201
|
+
// .title` says title lives inside panel, which is the anatomy for free.
|
|
202
|
+
if (names.length > 1) {
|
|
203
|
+
const parent = names[names.length - 2];
|
|
204
|
+
const above = out.classes[parent] ?? emptyClass();
|
|
205
|
+
if (!above.children.includes(target))
|
|
206
|
+
above.children.push(target);
|
|
207
|
+
out.classes[parent] = above;
|
|
208
|
+
}
|
|
209
|
+
const block = declarations(rule.body);
|
|
210
|
+
if (Object.keys(block).length === 0)
|
|
211
|
+
continue;
|
|
212
|
+
const isDark = DARK.test(selector);
|
|
213
|
+
const state = STATE_OF.find(([re]) => re.test(selector))?.[1] ?? null;
|
|
214
|
+
const width = minWidthOf(rule.media);
|
|
215
|
+
if (width) {
|
|
216
|
+
// A breakpoint layer wins over the state split: `@media … { .x:hover }` is rare
|
|
217
|
+
// enough that carrying it as the breakpoint's block is the honest simplification,
|
|
218
|
+
// and the selector is reported when it is neither.
|
|
219
|
+
entry.at[width] = { ...entry.at[width], ...block };
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
if (isDark && state) {
|
|
223
|
+
entry.darkStates[state] = { ...entry.darkStates[state], ...block };
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
if (isDark) {
|
|
227
|
+
entry.dark = { ...entry.dark, ...block };
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
if (state) {
|
|
231
|
+
entry.states[state] = { ...entry.states[state], ...block };
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
entry.base = { ...entry.base, ...block };
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return out;
|
|
238
|
+
}
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
// The other half: which element wears which class
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
/** `import styles from "./X.module.css"` - the local name and the file. */
|
|
243
|
+
const MODULE_IMPORT = /import\s+([A-Za-z_$][\w$]*)\s+from\s+["']([^"']*\.module\.(?:css|scss|sass|less))["']/g;
|
|
244
|
+
export function moduleImports(source) {
|
|
245
|
+
MODULE_IMPORT.lastIndex = 0;
|
|
246
|
+
return [...source.matchAll(MODULE_IMPORT)].map((m) => ({
|
|
247
|
+
local: m[1],
|
|
248
|
+
specifier: m[2],
|
|
249
|
+
}));
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* A generic in type position, not an element.
|
|
253
|
+
*
|
|
254
|
+
* The same discriminator the utility reader uses, for the same reason: JSX follows `(`,
|
|
255
|
+
* `{`, `>`, a newline, a keyword or nothing, while a type argument follows an
|
|
256
|
+
* identifier. `return <Foo />` is the keyword case, so the keyword list is here too.
|
|
257
|
+
*/
|
|
258
|
+
const JSX_BEFORE = /\b(return|case|default|await|yield|typeof|in|of|do|else|new)$/;
|
|
259
|
+
function looksLikeTypeArg(source, at) {
|
|
260
|
+
for (let i = at - 1; i >= 0; i--) {
|
|
261
|
+
const c = source[i];
|
|
262
|
+
if (c === " " || c === "\t")
|
|
263
|
+
continue;
|
|
264
|
+
if (!/[A-Za-z0-9_)\]]/.test(c))
|
|
265
|
+
return false;
|
|
266
|
+
return !JSX_BEFORE.test(source.slice(Math.max(0, i - 11), i + 1));
|
|
267
|
+
}
|
|
268
|
+
return false;
|
|
269
|
+
}
|
|
270
|
+
function scanTags(source) {
|
|
271
|
+
const out = [];
|
|
272
|
+
for (let i = 0; i < source.length; i++) {
|
|
273
|
+
if (source[i] !== "<")
|
|
274
|
+
continue;
|
|
275
|
+
const closing = source[i + 1] === "/";
|
|
276
|
+
const nameAt = i + (closing ? 2 : 1);
|
|
277
|
+
const name = /^[A-Za-z][\w.]*/.exec(source.slice(nameAt, nameAt + 64))?.[0];
|
|
278
|
+
if (!name)
|
|
279
|
+
continue;
|
|
280
|
+
if (!closing && looksLikeTypeArg(source, i))
|
|
281
|
+
continue;
|
|
282
|
+
let depth = 0;
|
|
283
|
+
let quote = null;
|
|
284
|
+
let end = -1;
|
|
285
|
+
for (let j = nameAt + name.length; j < source.length; j++) {
|
|
286
|
+
const ch = source[j];
|
|
287
|
+
if (quote) {
|
|
288
|
+
if (ch === "\\")
|
|
289
|
+
j += 1;
|
|
290
|
+
else if (ch === quote)
|
|
291
|
+
quote = null;
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
if (ch === '"' || ch === "'" || ch === "`") {
|
|
295
|
+
quote = ch;
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
if (ch === "{")
|
|
299
|
+
depth += 1;
|
|
300
|
+
else if (ch === "}")
|
|
301
|
+
depth -= 1;
|
|
302
|
+
else if (ch === ">" && depth === 0) {
|
|
303
|
+
end = j;
|
|
304
|
+
break;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if (end === -1)
|
|
308
|
+
continue;
|
|
309
|
+
const body = source.slice(nameAt + name.length, end);
|
|
310
|
+
if (closing) {
|
|
311
|
+
out.push({ at: i, kind: "close", tag: name, body: "" });
|
|
312
|
+
}
|
|
313
|
+
else {
|
|
314
|
+
out.push({ at: i, kind: "open", tag: name, body });
|
|
315
|
+
// Self-closing opens and closes at once.
|
|
316
|
+
if (body.trimEnd().endsWith("/")) {
|
|
317
|
+
out.push({ at: i + 1, kind: "close", tag: name, body: "" });
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
i = end;
|
|
321
|
+
}
|
|
322
|
+
return out;
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Which class each element wears, and how deep it sits.
|
|
326
|
+
*
|
|
327
|
+
* The FIRST element carrying a module class is the component's own surface; everything
|
|
328
|
+
* below it is a part. That is the same rule the utility reader follows with
|
|
329
|
+
* `rootClasses`, applied to a different way of spelling a class.
|
|
330
|
+
*/
|
|
331
|
+
export function readModuleUsage(source, local) {
|
|
332
|
+
const out = [];
|
|
333
|
+
const ref = new RegExp(`\\b${local}\\.([A-Za-z_$][\\w$]*)`, "g");
|
|
334
|
+
const bracket = new RegExp(`\\b${local}\\[`, "g");
|
|
335
|
+
// Depth by counting opens and closes as they appear, which is enough for well-formed
|
|
336
|
+
// JSX and degrades to a flat list rather than to a wrong tree.
|
|
337
|
+
const events = scanTags(source);
|
|
338
|
+
let depth = 0;
|
|
339
|
+
for (const event of events) {
|
|
340
|
+
if (event.kind === "close") {
|
|
341
|
+
depth = Math.max(0, depth - 1);
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
const body = event.body;
|
|
345
|
+
if (bracket.test(body)) {
|
|
346
|
+
// `styles[key]` picks a class at runtime, and the class is not in the source.
|
|
347
|
+
// Impossible rather than hard, so it is not attempted.
|
|
348
|
+
bracket.lastIndex = 0;
|
|
349
|
+
}
|
|
350
|
+
ref.lastIndex = 0;
|
|
351
|
+
const named = [...body.matchAll(ref)].map((m) => m[1]);
|
|
352
|
+
if (named.length > 0) {
|
|
353
|
+
/**
|
|
354
|
+
* THE TERNARY IS A VARIANT, and the enumeration is why this is here: 85 of 200
|
|
355
|
+
* real interpolated templates are `${cond ? styles.a : styles.b}`. The condition
|
|
356
|
+
* names the prop; each branch names a class in this file.
|
|
357
|
+
*/
|
|
358
|
+
const variants = [];
|
|
359
|
+
const TERNARY = new RegExp(`([!A-Za-z_$][\\w$.!]*)\\s*\\?\\s*(?:${local}\\.([\\w$]+)|["'][^"']*["'])\\s*:\\s*(?:${local}\\.([\\w$]+)|["'][^"']*["'])`, "g");
|
|
360
|
+
for (const t of body.matchAll(TERNARY)) {
|
|
361
|
+
const prop = t[1].replace(/^!/, "").split(".").pop() ?? "";
|
|
362
|
+
if (!prop)
|
|
363
|
+
continue;
|
|
364
|
+
variants.push({
|
|
365
|
+
prop,
|
|
366
|
+
whenTrue: t[2] ?? null,
|
|
367
|
+
whenFalse: t[3] ?? null,
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
// The resting class is the first one that is not a ternary branch.
|
|
371
|
+
const inTernary = new Set(variants.flatMap((v) => [v.whenTrue, v.whenFalse].filter(Boolean)));
|
|
372
|
+
const resting = named.find((n) => !inTernary.has(n)) ?? named[0];
|
|
373
|
+
out.push({ className: resting, depth, tag: event.tag, variants });
|
|
374
|
+
}
|
|
375
|
+
depth += 1;
|
|
376
|
+
}
|
|
377
|
+
return out;
|
|
378
|
+
}
|
|
379
|
+
// ---------------------------------------------------------------------------
|
|
380
|
+
// The bridge: a module read + its usage, as one Transcription
|
|
381
|
+
// ---------------------------------------------------------------------------
|
|
382
|
+
/**
|
|
383
|
+
* ONE FUNNEL, so a module-styled component arrives in exactly the shape a
|
|
384
|
+
* utility-styled one does.
|
|
385
|
+
*
|
|
386
|
+
* Everything downstream - the anatomy tree, the floor, the compiler, the vitrine -
|
|
387
|
+
* already knows how to read a `Transcription`. Adding a second shape beside it would
|
|
388
|
+
* mean every consumer learning a second one, and a consumer that learns half is how
|
|
389
|
+
* "valid and unread" happens.
|
|
390
|
+
*
|
|
391
|
+
* `declared` resolves `var(--their-token)` to the ref for the token they named, the same
|
|
392
|
+
* way the utility reader does, so the value re-themes instead of being frozen.
|
|
393
|
+
*/
|
|
394
|
+
export function transcribeModule(read, usage, declared) {
|
|
395
|
+
const resolve = (block) => {
|
|
396
|
+
const out = {};
|
|
397
|
+
for (const [prop, value] of Object.entries(block)) {
|
|
398
|
+
out[prop] = value.replace(/var\(\s*(--[a-zA-Z0-9_-]+)\s*(?:,[^)]*)?\)/g, (whole, name) => declared.has(name) ? tokenRefFor(name) : whole);
|
|
399
|
+
}
|
|
400
|
+
return out;
|
|
401
|
+
};
|
|
402
|
+
const sorted = [...usage].sort((a, b) => a.depth - b.depth);
|
|
403
|
+
const root = sorted[0];
|
|
404
|
+
const parts = {};
|
|
405
|
+
const layers = [];
|
|
406
|
+
let count = 0;
|
|
407
|
+
const take = (className) => {
|
|
408
|
+
const c = read.classes[className];
|
|
409
|
+
if (!c)
|
|
410
|
+
return null;
|
|
411
|
+
const base = resolve(c.base);
|
|
412
|
+
const dark = resolve(c.dark);
|
|
413
|
+
const states = {};
|
|
414
|
+
for (const [state, block] of Object.entries(c.states)) {
|
|
415
|
+
states[state] = resolve(block);
|
|
416
|
+
}
|
|
417
|
+
count +=
|
|
418
|
+
Object.keys(base).length +
|
|
419
|
+
Object.keys(dark).length +
|
|
420
|
+
Object.values(states).reduce((n, b) => n + Object.keys(b).length, 0);
|
|
421
|
+
return { base, dark, states, own: c };
|
|
422
|
+
};
|
|
423
|
+
const rootTaken = root ? take(root.className) : null;
|
|
424
|
+
for (const u of sorted) {
|
|
425
|
+
const taken = u === root ? rootTaken : take(u.className);
|
|
426
|
+
if (!taken)
|
|
427
|
+
continue;
|
|
428
|
+
if (u !== root) {
|
|
429
|
+
parts[partName(u.className)] = {
|
|
430
|
+
base: taken.base,
|
|
431
|
+
dark: taken.dark,
|
|
432
|
+
states: taken.states,
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
// A breakpoint block and a dark state are LAYERS, because neither the flat map nor
|
|
436
|
+
// the dark half can hold a condition of two halves.
|
|
437
|
+
for (const [width, block] of Object.entries(taken.own.at)) {
|
|
438
|
+
const style = resolve(block);
|
|
439
|
+
if (Object.keys(style).length === 0)
|
|
440
|
+
continue;
|
|
441
|
+
count += Object.keys(style).length;
|
|
442
|
+
layers.push({ at: width, style });
|
|
443
|
+
}
|
|
444
|
+
for (const [state, block] of Object.entries(taken.own.darkStates)) {
|
|
445
|
+
const style = resolve(block);
|
|
446
|
+
if (Object.keys(style).length === 0)
|
|
447
|
+
continue;
|
|
448
|
+
count += Object.keys(style).length;
|
|
449
|
+
layers.push({ when: { state, scheme: "dark" }, style });
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* `${selected ? styles.selected : styles.notSelected}` - a variant, read from the
|
|
453
|
+
* markup. Both branches name a class in this file, so both become a layer under the
|
|
454
|
+
* prop the condition names.
|
|
455
|
+
*/
|
|
456
|
+
for (const v of u.variants) {
|
|
457
|
+
for (const [option, className] of [
|
|
458
|
+
["true", v.whenTrue],
|
|
459
|
+
["false", v.whenFalse],
|
|
460
|
+
]) {
|
|
461
|
+
if (!className)
|
|
462
|
+
continue;
|
|
463
|
+
const branch = read.classes[className];
|
|
464
|
+
if (!branch)
|
|
465
|
+
continue;
|
|
466
|
+
const style = resolve(branch.base);
|
|
467
|
+
if (Object.keys(style).length === 0)
|
|
468
|
+
continue;
|
|
469
|
+
count += Object.keys(style).length;
|
|
470
|
+
layers.push({ when: { variant: { [v.prop]: option } }, style });
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* The tree, from the depth the markup put each element at. `children` of the root are
|
|
476
|
+
* everything one level down; deeper than that is folded up rather than guessed, because
|
|
477
|
+
* the depth counter degrades to a flat list on malformed JSX and a wrong tree is worse
|
|
478
|
+
* than a shallow one.
|
|
479
|
+
*/
|
|
480
|
+
const tree = sorted
|
|
481
|
+
.filter((u) => u !== root)
|
|
482
|
+
.map((u) => ({ as: formFor(u.tag), part: partName(u.className) }));
|
|
483
|
+
return {
|
|
484
|
+
base: rootTaken?.base ?? {},
|
|
485
|
+
dark: rootTaken?.dark ?? {},
|
|
486
|
+
states: rootTaken?.states ?? {},
|
|
487
|
+
layers,
|
|
488
|
+
parts,
|
|
489
|
+
tree,
|
|
490
|
+
keyframes: read.keyframes,
|
|
491
|
+
read: count,
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
/** The html tag → the preview form. The same nine the anatomy contract accepts. */
|
|
495
|
+
function formFor(tag) {
|
|
496
|
+
if (/^(img|picture|video|canvas|svg)$/.test(tag))
|
|
497
|
+
return "image";
|
|
498
|
+
if (/^h[1-6]$/.test(tag))
|
|
499
|
+
return "heading";
|
|
500
|
+
if (/^(button|a)$/.test(tag))
|
|
501
|
+
return "button";
|
|
502
|
+
if (/^(input|textarea|select)$/.test(tag))
|
|
503
|
+
return "field";
|
|
504
|
+
if (/^(span|p|label|strong|em|small|li|td|th|dt|dd)$/.test(tag))
|
|
505
|
+
return "text";
|
|
506
|
+
if (/^(ul|ol|table|tbody|thead|tr|section|article|nav|aside|header|footer|form|main|div)$/.test(tag)) {
|
|
507
|
+
return "stack";
|
|
508
|
+
}
|
|
509
|
+
// A capitalised tag is a component of theirs, and the frontier reader decides what it
|
|
510
|
+
// reaches - not this.
|
|
511
|
+
return /^[A-Z]/.test(tag) ? "component" : "stack";
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* A declared custom property as a token ref in the document's namespace. Their name is
|
|
515
|
+
* the path, which is "your names travel unchanged" one level deeper.
|
|
516
|
+
*/
|
|
517
|
+
function tokenRefFor(name) {
|
|
518
|
+
const bare = name.replace(/^--/, "");
|
|
519
|
+
if (bare.startsWith("color-")) {
|
|
520
|
+
const rest = bare.slice("color-".length);
|
|
521
|
+
const m = /^(.*)-(\d{2,4})$/.exec(rest);
|
|
522
|
+
return m ? `{color.${m[1]}.${m[2]}}` : `{color.${rest}}`;
|
|
523
|
+
}
|
|
524
|
+
if (bare.startsWith("radius-"))
|
|
525
|
+
return `{radius.${bare.slice(7)}}`;
|
|
526
|
+
if (bare.startsWith("spacing-"))
|
|
527
|
+
return `{spacing.${bare.slice(8)}}`;
|
|
528
|
+
if (bare.startsWith("shadow-"))
|
|
529
|
+
return `{shadow.${bare.slice(7)}}`;
|
|
530
|
+
if (bare.startsWith("text-"))
|
|
531
|
+
return `{typography.scale.${bare.slice(5)}.fontSize}`;
|
|
532
|
+
if (bare.startsWith("font-"))
|
|
533
|
+
return `{typography.families.${bare.slice(5)}}`;
|
|
534
|
+
return `var(${name})`;
|
|
535
|
+
}
|
|
@@ -235,6 +235,20 @@ export function readUtility(utility, declared) {
|
|
|
235
235
|
const keyword = TYPE_KEYWORD[core];
|
|
236
236
|
if (keyword)
|
|
237
237
|
return keyword;
|
|
238
|
+
/**
|
|
239
|
+
* BORDER WIDTH - `border`, `border-2`, `border-b`, `border-t-4`.
|
|
240
|
+
*
|
|
241
|
+
* `border-b border-lightgray-300` on a real table row gave up the colour and lost the
|
|
242
|
+
* WIDTH, so the row reproduced its border colour and no border (spec, 01/08). Tailwind
|
|
243
|
+
* splits the two across separate utilities and only one of them was in a table.
|
|
244
|
+
*/
|
|
245
|
+
const width = BORDER_WIDTH.exec(core);
|
|
246
|
+
if (width) {
|
|
247
|
+
const side = width[1] ? BORDER_SIDE[width[1]] : "";
|
|
248
|
+
const property = side ? `border${side}Width` : "borderWidth";
|
|
249
|
+
const value = width[2] ? `${width[2]}px` : "1px";
|
|
250
|
+
return { property, value };
|
|
251
|
+
}
|
|
238
252
|
/**
|
|
239
253
|
* AN ARBITRARY VALUE POINTING AT ONE OF THEIR OWN TOKENS.
|
|
240
254
|
*
|
|
@@ -258,8 +272,22 @@ export function readUtility(utility, declared) {
|
|
|
258
272
|
if (bracket !== -1 && core.endsWith("]")) {
|
|
259
273
|
const head = core.slice(0, bracket);
|
|
260
274
|
const inner = core.slice(bracket + 2, -1);
|
|
261
|
-
const property = COLOR_PROPERTY[head] ?? SPACING_PROPERTY[head] ?? BRACKET_PROPERTY[head];
|
|
262
275
|
const value = resolveArbitrary(inner, declared);
|
|
276
|
+
/**
|
|
277
|
+
* `text-[…]` IS AMBIGUOUS AND THE VALUE DECIDES.
|
|
278
|
+
*
|
|
279
|
+
* `text-[1.8em]` is a font size and `text-[#fff]` is a colour, and the head alone
|
|
280
|
+
* cannot tell them apart. Reading the head first put `fontSize: 1.8em` into `color`
|
|
281
|
+
* on a real `Text` component - a wrong value, which is worse than a missing one
|
|
282
|
+
* (probe, 01/08). The same ambiguity the scale branch below already resolves.
|
|
283
|
+
*/
|
|
284
|
+
const property = head === "text"
|
|
285
|
+
? value && LOOKS_LIKE_LENGTH.test(value.value)
|
|
286
|
+
? "fontSize"
|
|
287
|
+
: COLOR_PROPERTY.text
|
|
288
|
+
: (COLOR_PROPERTY[head] ??
|
|
289
|
+
SPACING_PROPERTY[head] ??
|
|
290
|
+
BRACKET_PROPERTY[head]);
|
|
263
291
|
if (property && value) {
|
|
264
292
|
return { property, value: value.value, token: value.token };
|
|
265
293
|
}
|
|
@@ -380,6 +408,19 @@ export function readUtility(utility, declared) {
|
|
|
380
408
|
* `rounded-[…]` is a radius, `shadow-[…]` an elevation, `text-[…]` ambiguous by design
|
|
381
409
|
* (a colour or a size) and resolved by what the value looks like.
|
|
382
410
|
*/
|
|
411
|
+
/** `border`, `border-2`, `border-b`, `border-b-2` - a width, never a colour. The colour
|
|
412
|
+
* is a separate utility (`border-ocean-500`) and the colour table already reads it. */
|
|
413
|
+
const BORDER_WIDTH = /^border(?:-([xytrbl]))?(?:-(\d+))?$/;
|
|
414
|
+
const BORDER_SIDE = {
|
|
415
|
+
t: "Top",
|
|
416
|
+
r: "Right",
|
|
417
|
+
b: "Bottom",
|
|
418
|
+
l: "Left",
|
|
419
|
+
x: "Inline",
|
|
420
|
+
y: "Block",
|
|
421
|
+
};
|
|
422
|
+
/** A length or a token ref to one - not a colour. `1.8em`, `20px`, `{typography.…}`. */
|
|
423
|
+
const LOOKS_LIKE_LENGTH = /^-?[\d.]+(?:px|rem|em|ch|ex|vw|vh|vmin|vmax|%|pt|cm|mm|in)$|^\{(?:typography|spacing|radius)\.|^(?:calc|clamp|min|max)\(/;
|
|
383
424
|
const BRACKET_PROPERTY = {
|
|
384
425
|
rounded: "borderRadius",
|
|
385
426
|
shadow: "boxShadow",
|
|
@@ -454,6 +495,51 @@ function refFor(name) {
|
|
|
454
495
|
* `group-data-[checked]:` value written into the resting state would be a look
|
|
455
496
|
* the component never has.
|
|
456
497
|
*/
|
|
498
|
+
/**
|
|
499
|
+
* `style={{ … }}` - 7 of 35 components in a real library and 73 of 317 in a real app.
|
|
500
|
+
*
|
|
501
|
+
* A literal, so it re-themes nowhere - and that is exactly why it travels. v1 is a
|
|
502
|
+
* MIRROR and v2 is where a literal becomes a token they approve, which is the rule this
|
|
503
|
+
* pipeline already runs on everywhere else. The alternative is the component arriving
|
|
504
|
+
* empty, and an empty component is not more honest than a literal one (dono, 01/08).
|
|
505
|
+
*
|
|
506
|
+
* Only the FIRST inline style in the file, matching what `rootClasses` does: it is the
|
|
507
|
+
* element the component returns, and everything deeper is a part this reader does not
|
|
508
|
+
* attempt.
|
|
509
|
+
*/
|
|
510
|
+
const INLINE_STYLE = /style=\{\{([^{}]*(?:\{[^{}]*\}[^{}]*)*)\}\}/;
|
|
511
|
+
export function readInlineStyle(source) {
|
|
512
|
+
const m = INLINE_STYLE.exec(source);
|
|
513
|
+
if (!m)
|
|
514
|
+
return {};
|
|
515
|
+
const out = {};
|
|
516
|
+
for (const piece of m[1].split(",")) {
|
|
517
|
+
const colon = piece.indexOf(":");
|
|
518
|
+
if (colon === -1)
|
|
519
|
+
continue;
|
|
520
|
+
const prop = piece
|
|
521
|
+
.slice(0, colon)
|
|
522
|
+
.trim()
|
|
523
|
+
.replace(/^["']|["']$/g, "");
|
|
524
|
+
let value = piece.slice(colon + 1).trim();
|
|
525
|
+
// A value computed at runtime is not in the source, so there is nothing to read.
|
|
526
|
+
if (!value || /[`$]|\?|=>|\(/.test(value))
|
|
527
|
+
continue;
|
|
528
|
+
value = value.replace(/^["']|["']$/g, "");
|
|
529
|
+
if (!/^[a-zA-Z][\w]*$/.test(prop))
|
|
530
|
+
continue;
|
|
531
|
+
if (/[<>{}@;]/.test(value))
|
|
532
|
+
continue;
|
|
533
|
+
// React accepts a bare number for a length property and means pixels.
|
|
534
|
+
if (/^-?\d+(\.\d+)?$/.test(value) && !UNITLESS.test(prop)) {
|
|
535
|
+
value = `${value}px`;
|
|
536
|
+
}
|
|
537
|
+
out[prop] = value;
|
|
538
|
+
}
|
|
539
|
+
return out;
|
|
540
|
+
}
|
|
541
|
+
/** Properties React leaves unitless, so a bare number is not pixels. */
|
|
542
|
+
const UNITLESS = /^(opacity|zIndex|flex|flexGrow|flexShrink|order|lineHeight|fontWeight|zoom|columnCount|fillOpacity|strokeOpacity|animationIterationCount)$/;
|
|
457
543
|
export function transcribe(classes, declared) {
|
|
458
544
|
const out = {
|
|
459
545
|
base: {},
|