synthesisui 0.16.63 → 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 +90 -1
- package/dist/doctor/broken-refs.js +75 -0
- package/dist/doctor/idiom.js +132 -0
- package/dist/doctor/transcribe.js +323 -0
- package/package.json +1 -1
package/dist/commands/import.js
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
|
2
2
|
import { basename, dirname, join, relative } from "node:path";
|
|
3
3
|
import { readCredentials, readToken, resolveRegistry, sameRegistry, } from "../config.js";
|
|
4
|
+
import { findBrokenRefs } from "../doctor/broken-refs.js";
|
|
4
5
|
import { isNearDuplicate, isNeutral, lightness, } from "../doctor/color-distance.js";
|
|
5
6
|
import { emptyTally, internalSpecifiers, scanComponentsInto, tallyToInventory, } from "../doctor/components-scan.js";
|
|
6
7
|
import { crosswalk, observedRules } from "../doctor/crosswalk.js";
|
|
7
8
|
import { reconcile, scanDefinitions, } from "../doctor/definitions-scan.js";
|
|
9
|
+
import { describeConvention, detectConventions, IDIOM_LABEL, } from "../doctor/idiom.js";
|
|
8
10
|
import { diagnose, scanSource } from "../doctor/scan.js";
|
|
9
11
|
import { parseSchemeBlocks } from "../doctor/scheme-blocks.js";
|
|
10
12
|
import { buildTable } from "../doctor/tokens.js";
|
|
13
|
+
import { rootClasses, transcribe, } from "../doctor/transcribe.js";
|
|
11
14
|
import { body, paint, section } from "../output.js";
|
|
12
15
|
import { walk, walkAll } from "./doctor.js";
|
|
13
16
|
/**
|
|
@@ -273,11 +276,23 @@ export async function takeCensus(root) {
|
|
|
273
276
|
const tally = emptyTally();
|
|
274
277
|
const internal = await internalSpecifiers(root);
|
|
275
278
|
const defined = [];
|
|
279
|
+
// Kept for the reference check below, which needs every file at once: a name
|
|
280
|
+
// is only broken if NOTHING declares it, anywhere.
|
|
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 = {};
|
|
276
290
|
for await (const file of walk(root)) {
|
|
277
291
|
const src = await readFile(file, "utf8").catch(() => "");
|
|
278
292
|
if (!src)
|
|
279
293
|
continue;
|
|
280
294
|
const rel = relative(root, file);
|
|
295
|
+
sources.push({ file: rel, source: src });
|
|
281
296
|
reports.push(scanSource(rel, src, table));
|
|
282
297
|
// Stories and tests compose components to SHOW them; counting those as the
|
|
283
298
|
// product's own composition is the same lie the colour census told before
|
|
@@ -286,7 +301,20 @@ export async function takeCensus(root) {
|
|
|
286
301
|
scanComponentsInto(tally, rel, src, internal);
|
|
287
302
|
// The other half: what this file EXPORTS, with the axes its types
|
|
288
303
|
// declare. A library composes almost nothing and exports everything.
|
|
289
|
-
|
|
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
|
+
}
|
|
290
318
|
}
|
|
291
319
|
}
|
|
292
320
|
const d = diagnose(reports);
|
|
@@ -368,10 +396,22 @@ export async function takeCensus(root) {
|
|
|
368
396
|
// an unreadable package.json costs the name, not the run
|
|
369
397
|
}
|
|
370
398
|
}
|
|
399
|
+
// Every name their CSS defines, from the same harvest the token table came
|
|
400
|
+
// from - so a reference is judged against what actually exists, not against
|
|
401
|
+
// the subset we managed to read as a ramp.
|
|
402
|
+
const declaredNames = new Set();
|
|
403
|
+
for (const m of css.matchAll(/(--[a-zA-Z0-9_-]+)\s*:/g)) {
|
|
404
|
+
declaredNames.add(m[1]);
|
|
405
|
+
}
|
|
406
|
+
const brokenRefs = findBrokenRefs(sources, declaredNames);
|
|
407
|
+
const conventions = detectConventions(sources);
|
|
371
408
|
return {
|
|
372
409
|
census: 1,
|
|
373
410
|
project: { name, stack: await detectStack(root) },
|
|
374
411
|
declared: Object.fromEntries(table.byName),
|
|
412
|
+
...(brokenRefs.length > 0 ? { brokenRefs } : {}),
|
|
413
|
+
...(conventions.length > 0 ? { conventions } : {}),
|
|
414
|
+
...(Object.keys(looks).length > 0 ? { looks } : {}),
|
|
375
415
|
...(schemes.alt.size > 0
|
|
376
416
|
? { declaredAlt: Object.fromEntries(schemes.alt) }
|
|
377
417
|
: {}),
|
|
@@ -435,6 +475,55 @@ function summarize(c) {
|
|
|
435
475
|
console.log(body(`${paint.strong(v.value)} ${paint.faint(`${v.kind}, ${v.count}× in ${v.files} file${v.files === 1 ? "" : "s"}`)}`));
|
|
436
476
|
}
|
|
437
477
|
}
|
|
478
|
+
sayConventions(c);
|
|
479
|
+
sayBrokenRefs(c);
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* THEIR VOCABULARY, MEASURED. This section exists so every later finding can be
|
|
483
|
+
* phrased as "you already do X, and here you did not" instead of "adopt ours".
|
|
484
|
+
*/
|
|
485
|
+
function sayConventions(c) {
|
|
486
|
+
const list = c.conventions ?? [];
|
|
487
|
+
if (list.length === 0)
|
|
488
|
+
return;
|
|
489
|
+
console.log("");
|
|
490
|
+
console.log(section("How you reference a value"));
|
|
491
|
+
for (const conv of list) {
|
|
492
|
+
console.log(body(describeConvention(conv)));
|
|
493
|
+
const off = conv.offIdiom.filter((o) => o.count > 0).slice(0, 3);
|
|
494
|
+
if (off.length > 0) {
|
|
495
|
+
console.log(body(paint.faint(` and ${off.map((o) => `${o.count} ${IDIOM_LABEL[o.idiom]}`).join(", ")}`)));
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
console.log("");
|
|
499
|
+
console.log(body(paint.faint("Nothing here is a rule we brought. It is what your own files do most, so the rest can be named against it.")));
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* NOT DRIFT - BROKEN. A `var()` no declaration answers renders nothing at all,
|
|
503
|
+
* so this goes above everything else the census has to say. It is also the one
|
|
504
|
+
* finding that needs no agreement with us: the convention being broken is
|
|
505
|
+
* theirs.
|
|
506
|
+
*/
|
|
507
|
+
function sayBrokenRefs(c) {
|
|
508
|
+
const broken = c.brokenRefs ?? [];
|
|
509
|
+
if (broken.length === 0)
|
|
510
|
+
return;
|
|
511
|
+
const uses = broken.reduce((n, b) => n + b.count, 0);
|
|
512
|
+
console.log("");
|
|
513
|
+
console.log(section("These resolve to nothing"));
|
|
514
|
+
console.log(body(`${uses} reference${uses === 1 ? "" : "s"} to ${broken.length} token${broken.length === 1 ? "" : "s"} your CSS never declares. A ${paint.strong("var()")} with no declaration paints no colour - not a different colour.`));
|
|
515
|
+
console.log("");
|
|
516
|
+
for (const b of broken.slice(0, 8)) {
|
|
517
|
+
const where = `${b.count}× in ${b.files} file${b.files === 1 ? "" : "s"}`;
|
|
518
|
+
console.log(body(b.meant
|
|
519
|
+
? `${paint.strong(`var(${b.name})`)} ${paint.faint(where)} - you declare ${paint.strong(b.meant)}`
|
|
520
|
+
: `${paint.strong(`var(${b.name})`)} ${paint.faint(`${where}, and nothing close is declared`)}`));
|
|
521
|
+
}
|
|
522
|
+
if (broken.length > 8) {
|
|
523
|
+
console.log(body(paint.faint(`(${broken.length - 8} more)`)));
|
|
524
|
+
}
|
|
525
|
+
console.log("");
|
|
526
|
+
console.log(body(paint.faint("Yours to fix in your own words - nothing here asks you to adopt ours.")));
|
|
438
527
|
}
|
|
439
528
|
/**
|
|
440
529
|
* The contract an agent needs, printed where an agent will read it.
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TOKENS REFERENCED AND NEVER DECLARED.
|
|
3
|
+
*
|
|
4
|
+
* Not drift, not taste - a variable that resolves to nothing. `var(--ocean-50)`
|
|
5
|
+
* in a project whose token is `--color-ocean-50` renders no colour at all, and
|
|
6
|
+
* the component ships with no background.
|
|
7
|
+
*
|
|
8
|
+
* Found by accident (dono, 01/08) while sizing something else: seven of nine
|
|
9
|
+
* references in one real `ImageUploader` were missing the `color-` prefix, and
|
|
10
|
+
* it is live. Nobody noticed because a missing background looks like a design
|
|
11
|
+
* choice.
|
|
12
|
+
*
|
|
13
|
+
* This is the highest-value thing a first read can say, and it costs nothing to
|
|
14
|
+
* find: it is arithmetic over two sets. It also needs no adoption of anything of
|
|
15
|
+
* ours - the convention being violated is THEIRS. A person can act on it in
|
|
16
|
+
* their own vocabulary, in the next commit, without agreeing to a single idea
|
|
17
|
+
* this product has (dono, 01/08: "a normalização será de acordo com cada
|
|
18
|
+
* projeto").
|
|
19
|
+
*/
|
|
20
|
+
/** `var(--x)`, including inside a Tailwind arbitrary value: `bg-[var(--x)]`. */
|
|
21
|
+
const VAR_REF = /var\(\s*(--[a-zA-Z0-9_-]+)/g;
|
|
22
|
+
/**
|
|
23
|
+
* PREFIXES A DECLARATION MAY CARRY that a reference dropped.
|
|
24
|
+
*
|
|
25
|
+
* Tailwind v4's `@theme` is the reason this happens at all: declaring
|
|
26
|
+
* `--color-ocean-50` publishes the variable under that full name AND the
|
|
27
|
+
* utility `bg-ocean-50`, so a person who reads their own utilities and then
|
|
28
|
+
* reaches for `var()` writes the utility's name and gets nothing.
|
|
29
|
+
*/
|
|
30
|
+
const NAMESPACES = [
|
|
31
|
+
"color",
|
|
32
|
+
"spacing",
|
|
33
|
+
"radius",
|
|
34
|
+
"font",
|
|
35
|
+
"text",
|
|
36
|
+
"shadow",
|
|
37
|
+
"ease",
|
|
38
|
+
"animate",
|
|
39
|
+
];
|
|
40
|
+
/**
|
|
41
|
+
* Every reference no declaration answers, commonest first.
|
|
42
|
+
*
|
|
43
|
+
* `declared` is the set of names their own CSS defines, verbatim. Anything the
|
|
44
|
+
* scan could not attribute is left out rather than guessed at.
|
|
45
|
+
*/
|
|
46
|
+
export function findBrokenRefs(sources, declared) {
|
|
47
|
+
const seen = new Map();
|
|
48
|
+
for (const { file, source } of sources) {
|
|
49
|
+
for (const m of source.matchAll(VAR_REF)) {
|
|
50
|
+
const name = m[1];
|
|
51
|
+
if (declared.has(name))
|
|
52
|
+
continue;
|
|
53
|
+
const hit = seen.get(name) ?? { count: 0, files: new Set() };
|
|
54
|
+
hit.count += 1;
|
|
55
|
+
hit.files.add(file);
|
|
56
|
+
seen.set(name, hit);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const out = [];
|
|
60
|
+
for (const [name, hit] of seen) {
|
|
61
|
+
const bare = name.slice(2);
|
|
62
|
+
const meant = NAMESPACES.map((ns) => `--${ns}-${bare}`).find((c) => declared.has(c));
|
|
63
|
+
out.push({
|
|
64
|
+
name,
|
|
65
|
+
count: hit.count,
|
|
66
|
+
files: hit.files.size,
|
|
67
|
+
...(meant ? { meant } : {}),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
// A broken reference somebody typed nine times is worse than one typed once,
|
|
71
|
+
// and one we can name a target for is actionable before one we cannot.
|
|
72
|
+
return out.sort((a, b) => Number(b.meant != null) - Number(a.meant != null) ||
|
|
73
|
+
b.count - a.count ||
|
|
74
|
+
a.name.localeCompare(b.name));
|
|
75
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HOW THIS PROJECT REFERENCES A VALUE - and it is not one answer per project.
|
|
3
|
+
*
|
|
4
|
+
* Measured on a real monorepo (dono, 01/08), scoping by project looked like
|
|
5
|
+
* divergence and was not: `packages/ui` read 52% `var()` and 39% utility, which
|
|
6
|
+
* is one directory holding `.tsx` and `.scss` in the same bucket. Split by
|
|
7
|
+
* LANGUAGE the picture is unambiguous:
|
|
8
|
+
*
|
|
9
|
+
* .tsx 80% tailwind utility
|
|
10
|
+
* .scss 66% css var()
|
|
11
|
+
* .css 67% css var()
|
|
12
|
+
*
|
|
13
|
+
* So the convention belongs to the language, the project is already chosen by
|
|
14
|
+
* `--scope`, and neither needs a person to pick it - it is measured. What a
|
|
15
|
+
* person gets instead is the useful half: what does NOT follow the idiom their
|
|
16
|
+
* own files overwhelmingly use.
|
|
17
|
+
*
|
|
18
|
+
* WHY THIS MATTERS MORE THAN OUR TOKENS (dono, 01/08). A report that says "use
|
|
19
|
+
* `{color.ocean.500}`" asks somebody to restructure a repo to suit us - in a
|
|
20
|
+
* shadcn project that is a migration with real incompatibility risk and a commit
|
|
21
|
+
* nobody wants to review. A report that says "you write `bg-ocean-500` in 80% of
|
|
22
|
+
* your JSX and here you wrote a hex" asks for nothing. Same decision, their
|
|
23
|
+
* vocabulary, a commit the size of the problem.
|
|
24
|
+
*
|
|
25
|
+
* It also explained the worst bug this census has found. `bg-[var(--ocean-50)]`
|
|
26
|
+
* is not merely a dropped `color-` prefix: it is the CSS idiom used inside the
|
|
27
|
+
* JSX idiom - a change of language mid-sentence - and the `.scss` beside it
|
|
28
|
+
* spells the same token correctly, because there that language is the right one.
|
|
29
|
+
*/
|
|
30
|
+
/** How each idiom reads, for the report. */
|
|
31
|
+
export const IDIOM_LABEL = {
|
|
32
|
+
"tailwind-utility": "Tailwind utility",
|
|
33
|
+
"css-var": "var(--token)",
|
|
34
|
+
"raw-hex": "raw hex",
|
|
35
|
+
"tailwind-arbitrary-var": "var() inside a utility",
|
|
36
|
+
"shadcn-hsl": "hsl(var(--token))",
|
|
37
|
+
};
|
|
38
|
+
const PATTERNS = [
|
|
39
|
+
// Order matters where two could match the same text: the shadcn wrapper and
|
|
40
|
+
// the arbitrary-value form are both `var()` and both more specific than it.
|
|
41
|
+
["shadcn-hsl", /hsl\(\s*var\(\s*--/g],
|
|
42
|
+
[
|
|
43
|
+
"tailwind-arbitrary-var",
|
|
44
|
+
/\b(?:bg|text|border|ring|fill|stroke|shadow|from|to|via)-\[var\(\s*--/g,
|
|
45
|
+
],
|
|
46
|
+
[
|
|
47
|
+
"tailwind-utility",
|
|
48
|
+
/\b(?:bg|text|border|ring|fill|stroke|from|to|via)-(?:[a-z]+-)+\d{2,3}\b/g,
|
|
49
|
+
],
|
|
50
|
+
["css-var", /var\(\s*--/g],
|
|
51
|
+
["raw-hex", /#[0-9a-fA-F]{6}\b/g],
|
|
52
|
+
];
|
|
53
|
+
/**
|
|
54
|
+
* Extensions worth reporting separately, because they are separate languages
|
|
55
|
+
* with separate correct answers. Anything else is counted and not split out.
|
|
56
|
+
*/
|
|
57
|
+
const LANGUAGES = new Set([".tsx", ".jsx", ".scss", ".css", ".less", ".sass"]);
|
|
58
|
+
/**
|
|
59
|
+
* A custom-property declaration, whose right-hand side is a DEFINITION.
|
|
60
|
+
* Deliberately not anchored to a line start: these are written inside `:root`,
|
|
61
|
+
* `@theme` and nested blocks at every indentation.
|
|
62
|
+
*/
|
|
63
|
+
const DECLARATION = /--[a-zA-Z0-9_-]+\s*:[^;}]*/g;
|
|
64
|
+
/**
|
|
65
|
+
* Count references per language and name the dominant idiom.
|
|
66
|
+
*
|
|
67
|
+
* A language with too few references to be conclusive is left out rather than
|
|
68
|
+
* given a convention on the strength of three sightings: claiming a project's
|
|
69
|
+
* convention from noise is worse than saying nothing, because every later
|
|
70
|
+
* finding inherits the claim.
|
|
71
|
+
*/
|
|
72
|
+
export function detectConventions(sources, minReferences = 20) {
|
|
73
|
+
const byLanguage = new Map();
|
|
74
|
+
for (const { file, source } of sources) {
|
|
75
|
+
const dot = file.lastIndexOf(".");
|
|
76
|
+
const ext = dot === -1 ? "" : file.slice(dot).toLowerCase();
|
|
77
|
+
if (!LANGUAGES.has(ext))
|
|
78
|
+
continue;
|
|
79
|
+
const counts = byLanguage.get(ext) ?? new Map();
|
|
80
|
+
// A DECLARATION IS NOT A REFERENCE. `--color-ocean-500: #059aed` is where a
|
|
81
|
+
// hex belongs, and counting it made a design system's own token file read as
|
|
82
|
+
// 87% raw hex - the worst drift in the repo, when it is the opposite: the one
|
|
83
|
+
// place every other value should be pointing at (dono, 01/08).
|
|
84
|
+
let rest = source.replace(DECLARATION, "");
|
|
85
|
+
// A more specific pattern claims its matches first, so the same text is
|
|
86
|
+
// never counted twice - `bg-[var(--x)]` is one arbitrary-value reference,
|
|
87
|
+
// not also a `var()` one.
|
|
88
|
+
for (const [idiom, re] of PATTERNS) {
|
|
89
|
+
const found = rest.match(re);
|
|
90
|
+
if (!found)
|
|
91
|
+
continue;
|
|
92
|
+
counts.set(idiom, (counts.get(idiom) ?? 0) + found.length);
|
|
93
|
+
rest = rest.replace(re, "");
|
|
94
|
+
}
|
|
95
|
+
byLanguage.set(ext, counts);
|
|
96
|
+
}
|
|
97
|
+
const out = [];
|
|
98
|
+
for (const [language, counts] of byLanguage) {
|
|
99
|
+
const total = [...counts.values()].reduce((n, v) => n + v, 0);
|
|
100
|
+
if (total < minReferences)
|
|
101
|
+
continue;
|
|
102
|
+
const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1]);
|
|
103
|
+
const [idiom, top] = ranked[0];
|
|
104
|
+
out.push({
|
|
105
|
+
language,
|
|
106
|
+
idiom,
|
|
107
|
+
share: top / total,
|
|
108
|
+
total,
|
|
109
|
+
offIdiom: ranked.slice(1).map(([i, count]) => ({ idiom: i, count })),
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
return out.sort((a, b) => b.total - a.total);
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* One line per language, in the report's voice and in THEIR vocabulary.
|
|
116
|
+
*
|
|
117
|
+
* A raw hex winning is NOT a convention. It is the absence of one, and saying
|
|
118
|
+
* "this language reaches for raw hex" dignifies it into a house style somebody
|
|
119
|
+
* might defend - when what the number means is that nothing there points at
|
|
120
|
+
* anything (dono, 01/08).
|
|
121
|
+
*/
|
|
122
|
+
export function describeConvention(c) {
|
|
123
|
+
const pct = Math.round(c.share * 100);
|
|
124
|
+
if (c.idiom === "raw-hex") {
|
|
125
|
+
return `${c.language} has no convention - ${pct}% of its ${c.total} references are values typed in place`;
|
|
126
|
+
}
|
|
127
|
+
return `${c.language} reaches for ${IDIOM_LABEL[c.idiom]} - ${pct}% of the ${c.total} references in it`;
|
|
128
|
+
}
|
|
129
|
+
/** True when this language's dominant answer is "no answer". */
|
|
130
|
+
export function hasConvention(c) {
|
|
131
|
+
return c.idiom !== "raw-hex";
|
|
132
|
+
}
|
|
@@ -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