synthesisui 0.16.81 → 0.16.83
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 +201 -11
- package/dist/doctor/coverage.js +129 -0
- package/dist/doctor/css-modules.js +535 -0
- package/dist/doctor/transcribe.js +87 -18
- package/dist/doctor/variant-read.js +121 -1
- package/package.json +1 -1
package/dist/commands/import.js
CHANGED
|
@@ -2,20 +2,23 @@ import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
|
2
2
|
import { basename, dirname, join, relative } from "node:path";
|
|
3
3
|
import { resolveAnatomy, resolveFlatParts, safePartName, } from "../anatomy-read.js";
|
|
4
4
|
import { readCredentials, readToken, resolveRegistry, sameRegistry, } from "../config.js";
|
|
5
|
-
import { describeArchitecture, describeChoice, detectArchitectures, } from "../doctor/architecture.js";
|
|
5
|
+
import { architectureRule, describeArchitecture, describeChoice, detectArchitectures, } from "../doctor/architecture.js";
|
|
6
6
|
import { findBrokenRefs } from "../doctor/broken-refs.js";
|
|
7
7
|
import { nestingRules, propRules, readDefinitionProps, readNesting, } from "../doctor/call-sites.js";
|
|
8
8
|
import { describeClassStyle, detectClassStyle, } from "../doctor/class-style.js";
|
|
9
9
|
import { isNearDuplicate, isNeutral, lightness, } from "../doctor/color-distance.js";
|
|
10
10
|
import { describeGate, gateComponent, groupSkips, } from "../doctor/component-gate.js";
|
|
11
11
|
import { emptyTally, internalSpecifiers, scanComponentsInto, tallyToInventory, } from "../doctor/components-scan.js";
|
|
12
|
+
import { countShape, describeCoverage, summarizeCoverage, } from "../doctor/coverage.js";
|
|
12
13
|
import { crosswalk, isLibrary, observedRules } from "../doctor/crosswalk.js";
|
|
14
|
+
import { moduleImports, readModuleCss, readModuleUsage, transcribeModule, } from "../doctor/css-modules.js";
|
|
13
15
|
import { reconcile, scanDefinitions, } from "../doctor/definitions-scan.js";
|
|
14
16
|
import { describeConvention, describeRemainder, detectConventions, } from "../doctor/idiom.js";
|
|
15
17
|
import { diagnose, scanSource } from "../doctor/scan.js";
|
|
16
18
|
import { parseSchemeBlocks } from "../doctor/scheme-blocks.js";
|
|
17
19
|
import { buildTable } from "../doctor/tokens.js";
|
|
18
|
-
import { rootClasses, rootTag, transcribe, } from "../doctor/transcribe.js";
|
|
20
|
+
import { readInlineStyle, rootClasses, rootTag, transcribe, } from "../doctor/transcribe.js";
|
|
21
|
+
import { transcribeVariants } from "../doctor/variant-read.js";
|
|
19
22
|
import { body, paint, section } from "../output.js";
|
|
20
23
|
import { startProgress } from "../progress.js";
|
|
21
24
|
import { detectStack, resolveDeps } from "../stack.js";
|
|
@@ -242,6 +245,18 @@ export async function takeCensus(root, opts) {
|
|
|
242
245
|
const nesting = new Map();
|
|
243
246
|
/** Component → what its own definition names and whether it forwards the rest. */
|
|
244
247
|
const propsOf = new Map();
|
|
248
|
+
/** Component → the axes its own `cva` declares. See `variant-read.ts`. */
|
|
249
|
+
const variantAxes = new Map();
|
|
250
|
+
/** How much came out of CSS Modules, for the coverage report. */
|
|
251
|
+
let moduleFiles = 0;
|
|
252
|
+
let moduleDeclarations = 0;
|
|
253
|
+
const moduleKeyframes = {};
|
|
254
|
+
/** How each component expresses style, so the census can state its own coverage. */
|
|
255
|
+
const shapes = new Map();
|
|
256
|
+
/** Component FILES the gate accepted - the honest denominator for coverage. A first
|
|
257
|
+
* version used the whole inventory, which includes everything they compose from a
|
|
258
|
+
* package, and reported 128 for a library of 36 (probe, 01/08). */
|
|
259
|
+
let componentFiles = 0;
|
|
245
260
|
/**
|
|
246
261
|
* A census over a real monorepo reads 2797 files, and the terminal said nothing for
|
|
247
262
|
* most of it. Twenty silent minutes is indistinguishable from twenty broken ones
|
|
@@ -306,6 +321,10 @@ export async function takeCensus(root, opts) {
|
|
|
306
321
|
for (const d of found) {
|
|
307
322
|
propsOf.set(d.name, readDefinitionProps(d.name, src));
|
|
308
323
|
}
|
|
324
|
+
if (found.length > 0) {
|
|
325
|
+
countShape(src, found[0].name, shapes);
|
|
326
|
+
componentFiles += 1;
|
|
327
|
+
}
|
|
309
328
|
defined.push(...found);
|
|
310
329
|
// THE LOOK, from their own class names. One transcription per file, keyed
|
|
311
330
|
// by the component it defines - the root element's classes are the
|
|
@@ -313,12 +332,102 @@ export async function takeCensus(root, opts) {
|
|
|
313
332
|
// not attempt.
|
|
314
333
|
if (found.length > 0) {
|
|
315
334
|
const t = transcribe(rootClasses(src), declaredValues);
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
335
|
+
/**
|
|
336
|
+
* THE VARIANT STYLES, FOLDED IN.
|
|
337
|
+
*
|
|
338
|
+
* `rootClasses` reads the class string ON the element, which for a component
|
|
339
|
+
* built with `cva` is a call - `className={cn(buttonVariants({ variant }))}` -
|
|
340
|
+
* and carries none of the nine variants' colours. The definition does, in an
|
|
341
|
+
* object literal, and reading it is deterministic (see `variant-read.ts`).
|
|
342
|
+
*
|
|
343
|
+
* Folded here rather than reported separately because this is the one funnel:
|
|
344
|
+
* the reader was computing 23 layers for a real Button and nobody was asking
|
|
345
|
+
* for the result, which is the "valid and unread" failure this pipeline has
|
|
346
|
+
* paid for twice already.
|
|
347
|
+
*/
|
|
348
|
+
const v = transcribeVariants(src, declaredValues);
|
|
349
|
+
/**
|
|
350
|
+
* THE CSS MODULE, if this component styles that way.
|
|
351
|
+
*
|
|
352
|
+
* 239 of 317 components in a real app do, and they arrived with ZERO
|
|
353
|
+
* declarations - 5,777 real CSS declarations sat in 530 stylesheets nobody
|
|
354
|
+
* read (dono, 01/08). Read here rather than in a pass of its own so a
|
|
355
|
+
* component that uses BOTH a module and utilities comes out as one look.
|
|
356
|
+
*/
|
|
357
|
+
const modules = moduleImports(src);
|
|
358
|
+
let fromModule = null;
|
|
359
|
+
for (const mod of modules) {
|
|
360
|
+
const cssPath = join(dirname(file), mod.specifier);
|
|
361
|
+
const moduleCss = await readFile(cssPath, "utf8").catch(() => "");
|
|
362
|
+
if (!moduleCss)
|
|
363
|
+
continue;
|
|
364
|
+
const usage = readModuleUsage(src, mod.local);
|
|
365
|
+
if (usage.length === 0)
|
|
366
|
+
continue;
|
|
367
|
+
const readModule = transcribeModule(readModuleCss(moduleCss), usage, declaredValues);
|
|
368
|
+
if (readModule.read === 0)
|
|
369
|
+
continue;
|
|
370
|
+
fromModule = readModule;
|
|
371
|
+
moduleFiles += 1;
|
|
372
|
+
moduleDeclarations += readModule.read;
|
|
373
|
+
for (const [name, frames] of Object.entries(readModule.keyframes)) {
|
|
374
|
+
if (!moduleKeyframes[name])
|
|
375
|
+
moduleKeyframes[name] = frames;
|
|
376
|
+
}
|
|
377
|
+
// One module per component is the shape every one of the 530 follows.
|
|
378
|
+
break;
|
|
379
|
+
}
|
|
380
|
+
// The MODULE first and the utilities on top: a component that has both is
|
|
381
|
+
// adding a utility to a module-styled element, not the other way round.
|
|
382
|
+
/**
|
|
383
|
+
* `style={{ }}` UNDER everything: a literal is the weakest source, so a class or
|
|
384
|
+
* a module rule that names the same property wins. It travels rather than being
|
|
385
|
+
* dropped because the alternative is the component arriving empty, and empty is
|
|
386
|
+
* not more honest than literal - v1 mirrors, v2 normalises.
|
|
387
|
+
*/
|
|
388
|
+
const inlineStyle = readInlineStyle(src);
|
|
389
|
+
const base = {
|
|
390
|
+
...inlineStyle,
|
|
391
|
+
...fromModule?.base,
|
|
392
|
+
...v.base.base,
|
|
393
|
+
...t.base,
|
|
394
|
+
};
|
|
395
|
+
const states = {
|
|
396
|
+
...fromModule?.states,
|
|
397
|
+
...v.base.states,
|
|
398
|
+
...t.states,
|
|
399
|
+
};
|
|
400
|
+
const dark = { ...fromModule?.dark, ...t.dark };
|
|
401
|
+
const layers = [...(fromModule?.layers ?? []), ...v.layers];
|
|
402
|
+
const parts = { ...fromModule?.parts, ...t.parts };
|
|
403
|
+
const tree = fromModule?.tree ?? [];
|
|
404
|
+
const size = Object.keys(base).length +
|
|
405
|
+
Object.keys(dark).length +
|
|
406
|
+
Object.keys(states).length +
|
|
407
|
+
Object.keys(parts).length +
|
|
408
|
+
layers.length;
|
|
319
409
|
const tag = rootTag(src);
|
|
320
410
|
if (size > 0 || tag) {
|
|
321
|
-
looks[found[0].name] =
|
|
411
|
+
looks[found[0].name] = {
|
|
412
|
+
...t,
|
|
413
|
+
base,
|
|
414
|
+
states,
|
|
415
|
+
dark,
|
|
416
|
+
...(Object.keys(parts).length > 0 ? { parts } : {}),
|
|
417
|
+
...(tree.length > 0 ? { tree } : {}),
|
|
418
|
+
...(tag ? { rootTag: tag } : {}),
|
|
419
|
+
...(layers.length > 0 ? { layers } : {}),
|
|
420
|
+
...(Object.keys(v.axes).length > 0 ? { declaredAxes: v.axes } : {}),
|
|
421
|
+
...(Object.keys(v.defaults).length > 0
|
|
422
|
+
? { defaults: v.defaults }
|
|
423
|
+
: {}),
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
// Their variant DEFINITION beats what usage happened to pick, and it is the
|
|
427
|
+
// better source: usage shows what somebody chose, the declaration shows what
|
|
428
|
+
// the author designed.
|
|
429
|
+
if (Object.keys(v.axes).length > 0) {
|
|
430
|
+
variantAxes.set(found[0].name, v.axes);
|
|
322
431
|
}
|
|
323
432
|
}
|
|
324
433
|
}
|
|
@@ -408,12 +517,25 @@ export async function takeCensus(root, opts) {
|
|
|
408
517
|
files: 0,
|
|
409
518
|
props: {
|
|
410
519
|
...Object.fromEntries(Object.entries(d.axes)),
|
|
520
|
+
...Object.fromEntries(Object.entries(variantAxes.get(d.name) ?? {})),
|
|
411
521
|
...Object.fromEntries(d.flags.map((f) => [f, ["true"]])),
|
|
412
522
|
},
|
|
413
523
|
propFiles: {},
|
|
414
524
|
// Their own rung, off the folder. It travels from the DEFINITION because
|
|
415
525
|
// that is the only reading that knows which file the component lives in.
|
|
416
526
|
...(d.tier ? { tier: d.tier } : {}),
|
|
527
|
+
/**
|
|
528
|
+
* The axes, kept BESIDE `props` as well as in it.
|
|
529
|
+
*
|
|
530
|
+
* A library composes nothing, so its components arrive through this branch and
|
|
531
|
+
* `declaredAxes` is where the contract writer looks. Leaving it out meant a
|
|
532
|
+
* Button whose `cva` declares nine variants reported none of them (probe,
|
|
533
|
+
* 01/08) - the styles reached the recipe and the axis names did not.
|
|
534
|
+
*/
|
|
535
|
+
...(Object.keys({ ...d.axes, ...(variantAxes.get(d.name) ?? {}) })
|
|
536
|
+
.length > 0
|
|
537
|
+
? { declaredAxes: { ...d.axes, ...(variantAxes.get(d.name) ?? {}) } }
|
|
538
|
+
: {}),
|
|
417
539
|
}));
|
|
418
540
|
/**
|
|
419
541
|
* A composed component that is ALSO defined keeps its usage and gains the
|
|
@@ -435,13 +557,22 @@ export async function takeCensus(root, opts) {
|
|
|
435
557
|
const declaredBy = new Map(defined.map((d) => [d.name, d]));
|
|
436
558
|
const enriched = inventory.map((c) => {
|
|
437
559
|
const d = declaredBy.get(c.name);
|
|
438
|
-
|
|
560
|
+
const fromVariants = variantAxes.get(c.name);
|
|
561
|
+
if (!d && !fromVariants)
|
|
439
562
|
return c;
|
|
440
|
-
const tier = d
|
|
441
|
-
|
|
563
|
+
const tier = d?.tier ? { tier: d.tier } : {};
|
|
564
|
+
/**
|
|
565
|
+
* THREE SOURCES FOR AN AXIS, and this is their order.
|
|
566
|
+
*
|
|
567
|
+
* The `cva` definition is the strongest: it declares the option AND what the
|
|
568
|
+
* option does. The prop type is next - it declares the closed set. Usage is last,
|
|
569
|
+
* because it shows what somebody happened to pick.
|
|
570
|
+
*/
|
|
571
|
+
const axes = { ...(d?.axes ?? {}), ...(fromVariants ?? {}) };
|
|
572
|
+
if (Object.keys(axes).length === 0) {
|
|
442
573
|
return Object.keys(tier).length > 0 ? { ...c, ...tier } : c;
|
|
443
574
|
}
|
|
444
|
-
return { ...c, declaredAxes:
|
|
575
|
+
return { ...c, declaredAxes: axes, ...tier };
|
|
445
576
|
});
|
|
446
577
|
const scoped = [...enriched, ...fromTypes];
|
|
447
578
|
/**
|
|
@@ -519,6 +650,33 @@ export async function takeCensus(root, opts) {
|
|
|
519
650
|
console.log(body(paint.faint(`(${theirsOnly.length} component${theirsOnly.length === 1 ? "" : "s"} live there and not in the system - ${shown.join(", ")}${theirsOnly.length > shown.length ? ", …" : ""}. Left out on purpose: the system is one place, and a union of two codebases is not a system. Point --scope at them if they belong in it.)`)));
|
|
520
651
|
}
|
|
521
652
|
}
|
|
653
|
+
/**
|
|
654
|
+
* WHAT THIS VERSION CAN AND CANNOT READ, on their files.
|
|
655
|
+
*
|
|
656
|
+
* Before this, importing a CSS-Modules app produced 283 components with no
|
|
657
|
+
* declarations and said nothing about why - and a person seeing that concludes the
|
|
658
|
+
* product is broken, reasoning correctly from what they were shown. The gap was never
|
|
659
|
+
* the defect; the silence was (dono, 01/08).
|
|
660
|
+
*/
|
|
661
|
+
const coverage = summarizeCoverage(shapes, componentFiles, Object.keys(looks).length, Object.values(looks).reduce((n, look) => n +
|
|
662
|
+
Object.keys(look.base).length +
|
|
663
|
+
Object.keys(look.dark).length +
|
|
664
|
+
Object.values(look.states).reduce((m, block) => m + Object.keys(block).length, 0) +
|
|
665
|
+
// The LAYERS count: the record and ternary readers produce layers rather than a
|
|
666
|
+
// base, so leaving them out reported a Tag with eight colours as three
|
|
667
|
+
// declarations (probe, 01/08).
|
|
668
|
+
(look.layers ?? []).reduce((m, layer) => m + Object.keys(layer.style).length, 0) +
|
|
669
|
+
Object.values(look.parts ?? {}).reduce((m, part) => m +
|
|
670
|
+
Object.keys(part.base).length +
|
|
671
|
+
Object.keys(part.dark).length +
|
|
672
|
+
Object.values(part.states).reduce((k, block) => k + Object.keys(block).length, 0), 0), 0));
|
|
673
|
+
const coverageLines = describeCoverage(coverage);
|
|
674
|
+
if (coverageLines.length > 0) {
|
|
675
|
+
console.log("");
|
|
676
|
+
console.log(section("What this version reads, on your files"));
|
|
677
|
+
for (const line of coverageLines)
|
|
678
|
+
console.log(body(line));
|
|
679
|
+
}
|
|
522
680
|
/**
|
|
523
681
|
* WHAT THE GATE TURNED AWAY - said out loud, grouped, with the reason.
|
|
524
682
|
*
|
|
@@ -625,8 +783,40 @@ export async function takeCensus(root, opts) {
|
|
|
625
783
|
observed: distinctValues(d),
|
|
626
784
|
...(components.length > 0 ? { components } : {}),
|
|
627
785
|
...(defined.length > 0 ? { defined } : {}),
|
|
786
|
+
/**
|
|
787
|
+
* THE SHAPE, AND THE RULE IT BECOMES.
|
|
788
|
+
*
|
|
789
|
+
* `rule` travels with each one so the platform writes it without owning a second
|
|
790
|
+
* copy of the sentence. The reader picks which architecture has PRIORITY; absent a
|
|
791
|
+
* choice the first is the one, which is the shape holding the most code.
|
|
792
|
+
*
|
|
793
|
+
* Carried rather than only printed: the whole point of the feature is that the
|
|
794
|
+
* chosen shape reaches CLAUDE.md, and a version of this that detected the shape,
|
|
795
|
+
* printed it, and never turned it into a rule is a feature that does nothing (dono,
|
|
796
|
+
* 01/08).
|
|
797
|
+
*/
|
|
798
|
+
...(coverage.components > 0
|
|
799
|
+
? {
|
|
800
|
+
coverage: {
|
|
801
|
+
components: coverage.components,
|
|
802
|
+
read: coverage.read,
|
|
803
|
+
declarations: coverage.declarations,
|
|
804
|
+
shapes: coverage.counts.map((c) => ({
|
|
805
|
+
key: c.shape.key,
|
|
806
|
+
label: c.shape.label,
|
|
807
|
+
reads: c.shape.reads,
|
|
808
|
+
files: c.files,
|
|
809
|
+
})),
|
|
810
|
+
},
|
|
811
|
+
}
|
|
812
|
+
: {}),
|
|
628
813
|
...(architectures.length > 0
|
|
629
|
-
? {
|
|
814
|
+
? {
|
|
815
|
+
architectures: architectures.slice(0, 6).map((a) => ({
|
|
816
|
+
...a,
|
|
817
|
+
rule: architectureRule(a),
|
|
818
|
+
})),
|
|
819
|
+
}
|
|
630
820
|
: {}),
|
|
631
821
|
...(skips.length > 0
|
|
632
822
|
? {
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHAT THIS VERSION CAN AND CANNOT READ, MEASURED ON THEIR OWN REPO.
|
|
3
|
+
*
|
|
4
|
+
* The most commercially important thing in the pipeline, and it took a person saying
|
|
5
|
+
* "nothing works" to see it (dono, 01/08).
|
|
6
|
+
*
|
|
7
|
+
* Before this, importing a CSS-Modules app produced 283 components with zero
|
|
8
|
+
* declarations and said nothing about why. A person seeing that concludes the product is
|
|
9
|
+
* broken, and they are reasoning correctly from what they were shown. The defect was
|
|
10
|
+
* never the gap - every reader has a boundary. The defect was the SILENCE about it.
|
|
11
|
+
*
|
|
12
|
+
* So the census measures its own coverage and says the number before anything is sent:
|
|
13
|
+
* how each component expresses style, how many of those this version reads, and which
|
|
14
|
+
* ones it does not - by name, with the count from their repo rather than a disclaimer.
|
|
15
|
+
*
|
|
16
|
+
* A governance product that cannot state what it does not govern is not trustworthy.
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* Every shape, in the order the enumeration found them. Each entry survived being
|
|
20
|
+
* counted on a real repo: a shape with no evidence is a shape I assumed.
|
|
21
|
+
*/
|
|
22
|
+
export const STYLE_SHAPES = [
|
|
23
|
+
{
|
|
24
|
+
key: "static",
|
|
25
|
+
label: "a class list written out",
|
|
26
|
+
reads: "full",
|
|
27
|
+
test: /className=["'][^"']+["']/,
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
key: "module",
|
|
31
|
+
label: "CSS Modules",
|
|
32
|
+
reads: "full",
|
|
33
|
+
test: /from\s+["'][^"']*\.module\.(?:css|scss|sass|less)["']/,
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
key: "cva",
|
|
37
|
+
label: "cva / tailwind-variants",
|
|
38
|
+
reads: "full",
|
|
39
|
+
test: /\b(?:cva|tv)\s*\(/,
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
key: "record",
|
|
43
|
+
label: "a lookup record of option to classes",
|
|
44
|
+
reads: "full",
|
|
45
|
+
test: /(?:const|let)\s+\w*(?:[Ss]tyles?|[Cc]lasses|[Vv]ariants?|[Mm]ap|[Cc]olors?|[Ss]izes?|[Tt]ones?)\w*\s*(?::\s*[^=]+)?=\s*\{/,
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
key: "ternary",
|
|
49
|
+
label: "a ternary between class literals",
|
|
50
|
+
reads: "full",
|
|
51
|
+
test: /\?\s*["'][^"']*(?:bg-|text-|border-|p-|px-|rounded-)/,
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
key: "inline",
|
|
55
|
+
label: "style={{ }}",
|
|
56
|
+
reads: "partial",
|
|
57
|
+
because: "a literal travels as a literal, so it re-themes nowhere until you promote it - and a value computed at render time is not in the source at all",
|
|
58
|
+
test: /style=\{\{/,
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
key: "interpolated",
|
|
62
|
+
label: "a template with a runtime value in it",
|
|
63
|
+
reads: "partial",
|
|
64
|
+
because: "the literal halves are read and a `${cond ? a : b}` between two known classes is read as a variant; a class assembled from a variable does not exist in the source, so there is nothing to read",
|
|
65
|
+
test: /className=\{`[^`]*\$\{/,
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
key: "styled",
|
|
69
|
+
label: "styled-components / emotion",
|
|
70
|
+
reads: "none",
|
|
71
|
+
because: "not read by this version. It was left out deliberately rather than guessed at: zero files in the repo this was measured against, and building for a shape with no evidence is the same mistake as building for one file",
|
|
72
|
+
test: /styled[.(]|@emotion/,
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
key: "sx",
|
|
76
|
+
label: "a style-prop system (Chakra, Panda, MUI sx)",
|
|
77
|
+
reads: "none",
|
|
78
|
+
because: "the styling lives in props this version does not read; a component that only forwards to that library is reported as an adapter instead",
|
|
79
|
+
test: /\bsx=\{|@chakra-ui|@pandacss/,
|
|
80
|
+
},
|
|
81
|
+
];
|
|
82
|
+
export function countShape(source, name, into) {
|
|
83
|
+
for (const shape of STYLE_SHAPES) {
|
|
84
|
+
if (!shape.test.test(source))
|
|
85
|
+
continue;
|
|
86
|
+
const at = into.get(shape.key) ?? { shape, files: 0, examples: [] };
|
|
87
|
+
at.files += 1;
|
|
88
|
+
if (at.examples.length < 4)
|
|
89
|
+
at.examples.push(name);
|
|
90
|
+
into.set(shape.key, at);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
export function summarizeCoverage(counts, components, read, declarations) {
|
|
94
|
+
return {
|
|
95
|
+
components,
|
|
96
|
+
read,
|
|
97
|
+
declarations,
|
|
98
|
+
counts: [...counts.values()].sort((a, b) => b.files - a.files),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* The report, in the words a person needs before they decide whether to import.
|
|
103
|
+
*
|
|
104
|
+
* Leads with the number that matters - how many components came out with a look - and
|
|
105
|
+
* then names every shape that is not fully read, with the reason. No percentages without
|
|
106
|
+
* the count behind them: "75%" of an unknown total is a marketing number.
|
|
107
|
+
*/
|
|
108
|
+
export function describeCoverage(c) {
|
|
109
|
+
if (c.components === 0)
|
|
110
|
+
return [];
|
|
111
|
+
const lines = [];
|
|
112
|
+
const pct = Math.round((c.read / c.components) * 100);
|
|
113
|
+
lines.push(`${c.read} of ${c.components} components came out with a look - ${pct}%, ${c.declarations} declarations in total. This is measured on your files, not an estimate.`);
|
|
114
|
+
for (const { shape, files, examples } of c.counts) {
|
|
115
|
+
const share = Math.round((files / c.components) * 100);
|
|
116
|
+
const head = ` ${files} (${share}%) ${shape.label}`;
|
|
117
|
+
if (shape.reads === "full") {
|
|
118
|
+
lines.push(`${head} - read`);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
lines.push(`${head} - ${shape.reads === "none" ? "NOT read" : "partly read"}. ${shape.because ?? ""}`);
|
|
122
|
+
lines.push(` ${examples.join(", ")}`);
|
|
123
|
+
}
|
|
124
|
+
const missing = c.components - c.read;
|
|
125
|
+
if (missing > 0) {
|
|
126
|
+
lines.push(`${missing} came out with no look at all. They still arrive as contracts - the axes their types declare, their name, their place in your tree - and the look stays yours to write. That is the design, but you should know the number before you decide.`);
|
|
127
|
+
}
|
|
128
|
+
return lines;
|
|
129
|
+
}
|
|
@@ -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: {},
|
|
@@ -598,20 +684,3 @@ export function rootClasses(source) {
|
|
|
598
684
|
.flatMap((m) => m[1].split(/\s+/))
|
|
599
685
|
.filter((c) => c.length > 0 && !c.includes("${"));
|
|
600
686
|
}
|
|
601
|
-
/**
|
|
602
|
-
* Transcribe the parts a skill named, using the same reader the root used.
|
|
603
|
-
*
|
|
604
|
-
* `declared` comes from the census itself on the `--census` path, so a run that
|
|
605
|
-
* only sends a file it was handed still resolves their tokens by name.
|
|
606
|
-
*/
|
|
607
|
-
export function transcribeParts(parts, declared) {
|
|
608
|
-
const out = {};
|
|
609
|
-
for (const part of parts) {
|
|
610
|
-
const name = part.name?.trim();
|
|
611
|
-
if (!name || typeof part.classes !== "string")
|
|
612
|
-
continue;
|
|
613
|
-
const t = transcribe(part.classes.split(/\s+/).filter(Boolean), declared);
|
|
614
|
-
out[name] = { base: t.base, dark: t.dark, states: t.states };
|
|
615
|
-
}
|
|
616
|
-
return out;
|
|
617
|
-
}
|
|
@@ -525,6 +525,116 @@ export function readInlineConditions(source) {
|
|
|
525
525
|
}
|
|
526
526
|
return layers;
|
|
527
527
|
}
|
|
528
|
+
/**
|
|
529
|
+
* A LOOKUP RECORD - the shape 16 of 35 components in a real library use, against 2 for
|
|
530
|
+
* `cva`.
|
|
531
|
+
*
|
|
532
|
+
* const colorStyles: Record<TagColor, string> = {
|
|
533
|
+
* primary: "bg-ocean-500 text-white",
|
|
534
|
+
* danger: "bg-danger-400 text-white",
|
|
535
|
+
* };
|
|
536
|
+
* className={cn("inline-flex px-2.5 rounded-full", colorStyles[color], className)}
|
|
537
|
+
*
|
|
538
|
+
* As structured as `cva` and more common by five to one. The first version of this file
|
|
539
|
+
* read `cva` and the conditional expression - the two shapes I assumed - and covered 6%
|
|
540
|
+
* of the library. Enumerating first is the rule that produced this function (dono, 01/08).
|
|
541
|
+
*
|
|
542
|
+
* THE PROP COMES FROM THE INDEX, not from the record's name. `colorStyles[color]` says
|
|
543
|
+
* the axis is `color`; the variable could be called anything. When the index is not a
|
|
544
|
+
* bare identifier - `styles[key]`, `map[a ?? b]` - the axis is unknowable and the record
|
|
545
|
+
* is skipped rather than attached to a guess.
|
|
546
|
+
*/
|
|
547
|
+
const LOOKUP_RECORD = /(?:const|let)\s+([A-Za-z_$][\w$]*)\s*(?::\s*(?:Record|Partial<Record)<[^>]*>+)?\s*=\s*\{/g;
|
|
548
|
+
/** `colorStyles[color]` - the record, and the prop that indexes it. */
|
|
549
|
+
const RECORD_INDEX = /([A-Za-z_$][\w$]*)\s*\[\s*([A-Za-z_$][\w$]*)\s*\]/g;
|
|
550
|
+
export function readLookupRecords(source,
|
|
551
|
+
/** Filled with axis → options, because the axis NAMES have to travel too: a first pass
|
|
552
|
+
* sent the styles and not the names, so a Tag with eight colours reported no axis at
|
|
553
|
+
* all (probe, 01/08). */
|
|
554
|
+
axesOut) {
|
|
555
|
+
const clean = stripComments(source);
|
|
556
|
+
// Which prop indexes which record. Read first, because a record nobody indexes is a
|
|
557
|
+
// constant rather than a variant axis.
|
|
558
|
+
const axisOf = new Map();
|
|
559
|
+
RECORD_INDEX.lastIndex = 0;
|
|
560
|
+
for (const m of clean.matchAll(RECORD_INDEX)) {
|
|
561
|
+
if (!axisOf.has(m[1]))
|
|
562
|
+
axisOf.set(m[1], m[2]);
|
|
563
|
+
}
|
|
564
|
+
const layers = [];
|
|
565
|
+
LOOKUP_RECORD.lastIndex = 0;
|
|
566
|
+
for (const m of clean.matchAll(LOOKUP_RECORD)) {
|
|
567
|
+
const symbol = m[1];
|
|
568
|
+
const axis = axisOf.get(symbol);
|
|
569
|
+
if (!axis)
|
|
570
|
+
continue;
|
|
571
|
+
const open = (m.index ?? 0) + m[0].length - 1;
|
|
572
|
+
const end = endOf(clean, open);
|
|
573
|
+
if (end === -1)
|
|
574
|
+
continue;
|
|
575
|
+
const entries = topLevelEntries(clean.slice(open + 1, end - 1));
|
|
576
|
+
// Two options is what makes a closed set somebody chose from - the same rule the
|
|
577
|
+
// contract writer and the doctor already follow.
|
|
578
|
+
const options = entries.filter((e) => literalClasses(e.value).length > 0);
|
|
579
|
+
if (options.length < 2)
|
|
580
|
+
continue;
|
|
581
|
+
if (axesOut) {
|
|
582
|
+
const names = options.map((o) => o.key);
|
|
583
|
+
axesOut[axis] = [...new Set([...(axesOut[axis] ?? []), ...names])];
|
|
584
|
+
}
|
|
585
|
+
for (const option of options) {
|
|
586
|
+
const per = splitByCondition(literalClasses(option.value), {
|
|
587
|
+
[axis]: option.key,
|
|
588
|
+
});
|
|
589
|
+
layers.push(...per.layers);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
return layers;
|
|
593
|
+
}
|
|
594
|
+
/**
|
|
595
|
+
* A TERNARY BETWEEN TWO CLASS LITERALS - 8 of 35 in the same library.
|
|
596
|
+
*
|
|
597
|
+
* className={cn("base", isActive ? "bg-ocean-500" : "bg-transparent")}
|
|
598
|
+
*
|
|
599
|
+
* Both branches are a look, so both are a layer: the truthy one under the prop being
|
|
600
|
+
* `true` and the falsy one under `false`. Reading only the truthy half would say the
|
|
601
|
+
* component has one conditional look when it has two.
|
|
602
|
+
*/
|
|
603
|
+
const CLASS_TERNARY = /([!A-Za-z_$][\w$.!]*)\s*(?:===\s*["']([^"']+)["']\s*)?\?\s*["']([^"']*)["']\s*:\s*["']([^"']*)["']/g;
|
|
604
|
+
export function readClassTernaries(source) {
|
|
605
|
+
const layers = [];
|
|
606
|
+
CLASS_TERNARY.lastIndex = 0;
|
|
607
|
+
for (const m of stripComments(source).matchAll(CLASS_TERNARY)) {
|
|
608
|
+
const negated = m[1].startsWith("!");
|
|
609
|
+
const prop = m[1].replace(/^!/, "").split(".").pop() ?? "";
|
|
610
|
+
if (!prop)
|
|
611
|
+
continue;
|
|
612
|
+
// `x === "solid" ? a : b` names the option; a bare `x ? a : b` is the boolean, and a
|
|
613
|
+
// negated condition swaps which branch means true.
|
|
614
|
+
const whenTrue = m[2] ?? (negated ? "false" : "true");
|
|
615
|
+
const whenFalse = m[2] ? null : negated ? "true" : "false";
|
|
616
|
+
for (const [option, raw] of [
|
|
617
|
+
[whenTrue, m[3]],
|
|
618
|
+
[whenFalse, m[4]],
|
|
619
|
+
]) {
|
|
620
|
+
if (!option)
|
|
621
|
+
continue;
|
|
622
|
+
const classes = raw.split(/\s+/).filter(Boolean);
|
|
623
|
+
if (classes.length === 0)
|
|
624
|
+
continue;
|
|
625
|
+
const per = splitByCondition(classes, { [prop]: option });
|
|
626
|
+
layers.push(...per.layers);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
return layers;
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* A LOOKUP INDEXED BY A TERNARY, which is how the same record serves a boolean.
|
|
633
|
+
*
|
|
634
|
+
* Left out on purpose, and said so rather than half-read: `styles[selected ? "on" : "off"]`
|
|
635
|
+
* would need both the record and the condition resolved together, and the enumeration
|
|
636
|
+
* found exactly four of them in 530 files. Four is not worth a wrong reading.
|
|
637
|
+
*/
|
|
528
638
|
/**
|
|
529
639
|
* The whole reading of one file's variant styling, transcribed - ready for a recipe.
|
|
530
640
|
*
|
|
@@ -533,7 +643,13 @@ export function readInlineConditions(source) {
|
|
|
533
643
|
*/
|
|
534
644
|
export function transcribeVariants(source, declared) {
|
|
535
645
|
const reads = readVariants(source);
|
|
536
|
-
const
|
|
646
|
+
const fromRecords = {};
|
|
647
|
+
const inline = [
|
|
648
|
+
...readInlineConditions(source),
|
|
649
|
+
// The two shapes the enumeration found to be five times more common than `cva`.
|
|
650
|
+
...readLookupRecords(source, fromRecords),
|
|
651
|
+
...readClassTernaries(source),
|
|
652
|
+
];
|
|
537
653
|
const axes = {};
|
|
538
654
|
const defaults = {};
|
|
539
655
|
const allLayers = [];
|
|
@@ -553,6 +669,10 @@ export function transcribeVariants(source, declared) {
|
|
|
553
669
|
}
|
|
554
670
|
}
|
|
555
671
|
allLayers.push(...inline);
|
|
672
|
+
for (const [axis, options] of Object.entries(fromRecords)) {
|
|
673
|
+
if (options.length >= 2)
|
|
674
|
+
axes[axis] = options;
|
|
675
|
+
}
|
|
556
676
|
const layers = allLayers
|
|
557
677
|
.map((layer) => {
|
|
558
678
|
/**
|
package/package.json
CHANGED