synthesisui 0.16.113 → 0.16.118
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/anatomy-read.js +23 -0
- package/dist/commands/component.js +29 -0
- package/dist/commands/import.js +170 -4
- package/dist/doctor/css-modules.js +35 -1
- package/dist/doctor/public-api.js +119 -0
- package/dist/doctor/style-island.js +143 -0
- package/dist/doctor/transcribe.js +382 -1
- package/package.json +1 -1
package/dist/anatomy-read.js
CHANGED
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
* of the three outcomes this pipeline has paid for twice.
|
|
31
31
|
*/
|
|
32
32
|
import { transcribe, } from "./doctor/transcribe.js";
|
|
33
|
+
import { frontierKind } from "./frontier-kind.js";
|
|
33
34
|
/** The ten forms, closed. A renderer that accepts any tag draws anything. */
|
|
34
35
|
const FORMS = new Set([
|
|
35
36
|
"image",
|
|
@@ -295,6 +296,28 @@ sketch) {
|
|
|
295
296
|
if (typeof node.refName === "string" && node.refName.trim())
|
|
296
297
|
node_.refName = node.refName.trim();
|
|
297
298
|
}
|
|
299
|
+
else if (typeof node.at === "number") {
|
|
300
|
+
/**
|
|
301
|
+
* OR OFF THE SKETCH, WHICH IS THE CASE THAT ACTUALLY HAPPENS.
|
|
302
|
+
*
|
|
303
|
+
* The deriver stamps this on the read; an AUTHORED reading never does - it names
|
|
304
|
+
* `trigger` at index 1 and says nothing about `Collapsible.Trigger`, because it
|
|
305
|
+
* has no reason to. So the snippet kept emitting `<div>` on the very path the
|
|
306
|
+
* owner runs, and I read my own probe as proof of the opposite (dono, 04/08).
|
|
307
|
+
*
|
|
308
|
+
* The sketch measured the tag and the package, so this is the same lookup
|
|
309
|
+
* `when` already does one line up. Gated on HEADLESS: an opaque library is named
|
|
310
|
+
* and stopped at, and a plain `div` is nobody's element.
|
|
311
|
+
*/
|
|
312
|
+
const measured = sketch?.[node.at];
|
|
313
|
+
if (measured?.from &&
|
|
314
|
+
measured.tag &&
|
|
315
|
+
/^[A-Z]/.test(measured.tag) &&
|
|
316
|
+
frontierKind(measured.from) === "headless") {
|
|
317
|
+
node_.from = measured.from;
|
|
318
|
+
node_.refName = measured.tag;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
298
321
|
// The NAME is what carries the styles, so it is claimed even when nothing
|
|
299
322
|
// resolved: the tree triggers on presence, and structure is most of the
|
|
300
323
|
// value. A node with no name is pure structure and that is legitimate.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
1
2
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
3
|
import { join } from "node:path";
|
|
3
4
|
import { emitCn, readVocabulary } from "../cn-codegen.js";
|
|
@@ -84,6 +85,34 @@ export async function component(slug, name, opts) {
|
|
|
84
85
|
.filter(Boolean)
|
|
85
86
|
.map((p) => p[0].toUpperCase() + p.slice(1))
|
|
86
87
|
.join("");
|
|
88
|
+
/**
|
|
89
|
+
* THEY ALREADY HAVE THIS COMPONENT, and the recipe says where.
|
|
90
|
+
*
|
|
91
|
+
* `findCollision` guards against shadowing by walking `packages` three levels down;
|
|
92
|
+
* their `CollapsibleCard` sits six levels below it, so the guard never reached it and
|
|
93
|
+
* `component` would write a second, worse copy of a component in their own
|
|
94
|
+
* `packages/ui`. The recipe carries the path now - repo-relative, so this is one
|
|
95
|
+
* `stat` rather than a walk - and the import line their own manifest declares.
|
|
96
|
+
*
|
|
97
|
+
* Only refuses when the file is actually HERE: the same system installed in another
|
|
98
|
+
* project has no such component, and there generating is exactly right.
|
|
99
|
+
*/
|
|
100
|
+
const theirs = res.recipe?.api;
|
|
101
|
+
const already = theirs?.file && existsSync(join(root, theirs.file)) ? theirs : null;
|
|
102
|
+
if (already && !opts.force) {
|
|
103
|
+
console.log(section("You already have this component"));
|
|
104
|
+
console.log(body(`\`${already.name}\` lives in ${already.file} - writing another one would leave you with two.`));
|
|
105
|
+
if (already.from) {
|
|
106
|
+
console.log("");
|
|
107
|
+
console.log(snippet([`import { ${already.name} } from "${already.from}";`]));
|
|
108
|
+
}
|
|
109
|
+
else if (already.exported === false) {
|
|
110
|
+
console.log(body(`It is not re-exported from that folder's entry, so import it by path.`));
|
|
111
|
+
}
|
|
112
|
+
console.log("");
|
|
113
|
+
console.log(body("The recipe and the compiled CSS are on disk either way - only the .tsx was not written. Pass --force to write it anyway."));
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
87
116
|
const clash = await findCollision(root, config.componentsDir, res.name, pascalName);
|
|
88
117
|
if (!opts.force && (clash.exported || (clash.file && !clash.ours))) {
|
|
89
118
|
console.log(section("This name is already taken in your project"));
|
package/dist/commands/import.js
CHANGED
|
@@ -13,17 +13,20 @@ import { describeGate, gateComponent, groupSkips, SCREENS_FOR_SYSTEM, } from "..
|
|
|
13
13
|
import { emptyTally, internalSpecifiers, scanComponentsInto, tallyToInventory, } from "../doctor/components-scan.js";
|
|
14
14
|
import { countShape, describeCoverage, summarizeCoverage, } from "../doctor/coverage.js";
|
|
15
15
|
import { crosswalk, floorSize, isLibrary, observedRules, useLiveCatalogue, } from "../doctor/crosswalk.js";
|
|
16
|
-
import { moduleImports, readModuleCss, readModuleUsage, transcribeModule, } from "../doctor/css-modules.js";
|
|
16
|
+
import { keyframeOffsets, moduleImports, readModuleCss, readModuleUsage, transcribeModule, } from "../doctor/css-modules.js";
|
|
17
17
|
import { reconcile, scanDefinitions, } from "../doctor/definitions-scan.js";
|
|
18
18
|
import { describeConvention, describeRemainder, detectConventions, } from "../doctor/idiom.js";
|
|
19
19
|
import { callSiteNode, placeLayers, } from "../doctor/place-layers.js";
|
|
20
|
+
import { publicApi, requiredProps } from "../doctor/public-api.js";
|
|
20
21
|
import { diagnose, scanSource } from "../doctor/scan.js";
|
|
21
22
|
import { parseSchemeBlocks } from "../doctor/scheme-blocks.js";
|
|
22
23
|
import { describeSignals, emptySignals, finishSignals, readSignalsInto, } from "../doctor/signals.js";
|
|
23
24
|
import { sketchOf } from "../doctor/sketch.js";
|
|
25
|
+
import { describeForeign, readStyleIslands, } from "../doctor/style-island.js";
|
|
24
26
|
import { buildTable } from "../doctor/tokens.js";
|
|
25
27
|
import { readInlineStyle, rootClasses, rootTag, transcribe, } from "../doctor/transcribe.js";
|
|
26
28
|
import { transcribeVariants } from "../doctor/variant-read.js";
|
|
29
|
+
import { frontierKind } from "../frontier-kind.js";
|
|
27
30
|
import { withLibraryStructure } from "../library-structure.js";
|
|
28
31
|
import { body, paint, section } from "../output.js";
|
|
29
32
|
import { phase, startProgress } from "../progress.js";
|
|
@@ -305,7 +308,20 @@ export async function takeCensus(root, opts) {
|
|
|
305
308
|
/** How much came out of CSS Modules, for the coverage report. */
|
|
306
309
|
let moduleFiles = 0;
|
|
307
310
|
let moduleDeclarations = 0;
|
|
308
|
-
|
|
311
|
+
/**
|
|
312
|
+
* THE KEYFRAMES THE READING NEEDS, by name - and this was collected and dropped.
|
|
313
|
+
*
|
|
314
|
+
* Nothing read `moduleKeyframes`: an app whose animation lives in a CSS module lost it
|
|
315
|
+
* in silence, and the four Tailwind ships had nowhere to travel either, so
|
|
316
|
+
* `animation: var(--animate-spin)` would have pointed at a keyframe the system does not
|
|
317
|
+
* define (dono, 04/08). The document's shape is offset → declarations, which is what
|
|
318
|
+
* `keyframeOffsets` turns their raw body into and what `BUILTIN_KEYFRAMES` already is.
|
|
319
|
+
*/
|
|
320
|
+
const keyframes = {};
|
|
321
|
+
/** Names their `animate-*` utilities point at - see `Transcription.animations`. */
|
|
322
|
+
const animations = new Set();
|
|
323
|
+
/** Per component, the rules a `<style>` island aims at somebody else's markup. */
|
|
324
|
+
const islands = new Map();
|
|
309
325
|
/** How each component expresses style, so the census can state its own coverage. */
|
|
310
326
|
const shapes = new Map();
|
|
311
327
|
/** Component FILES the gate accepted - the honest denominator for coverage. A first
|
|
@@ -483,8 +499,9 @@ export async function takeCensus(root, opts) {
|
|
|
483
499
|
moduleFiles += 1;
|
|
484
500
|
moduleDeclarations += readModule.read;
|
|
485
501
|
for (const [name, frames] of Object.entries(readModule.keyframes)) {
|
|
486
|
-
|
|
487
|
-
|
|
502
|
+
const offsets = keyframeOffsets(frames);
|
|
503
|
+
if (!keyframes[name] && Object.keys(offsets).length > 0)
|
|
504
|
+
keyframes[name] = offsets;
|
|
488
505
|
}
|
|
489
506
|
// One module per component is the shape every one of the 530 follows.
|
|
490
507
|
break;
|
|
@@ -526,6 +543,83 @@ export async function takeCensus(root, opts) {
|
|
|
526
543
|
* the owner's rule, adopted verbatim (dono, 01/08).
|
|
527
544
|
*/
|
|
528
545
|
const sketch = sketchOf(src, found[0].name);
|
|
546
|
+
/**
|
|
547
|
+
* THE CSS THE COMPONENT CARRIES INSIDE ITSELF.
|
|
548
|
+
*
|
|
549
|
+
* Their `TypingIndicator` styles with a `<style>` element and nothing else: read as
|
|
550
|
+
* utilities it arrived with three declarations and no animation, so the component
|
|
551
|
+
* that IS an animation previewed as three static circles (dono, 04/08). Three kinds
|
|
552
|
+
* of content, three destinations - see `readStyleIslands`.
|
|
553
|
+
*/
|
|
554
|
+
const writes = new Set();
|
|
555
|
+
for (const node of sketch)
|
|
556
|
+
for (const cls of (node.classes ?? "").split(/\s+/))
|
|
557
|
+
if (cls)
|
|
558
|
+
writes.add(cls);
|
|
559
|
+
const island = readStyleIslands(src, writes);
|
|
560
|
+
/** Sketch index → what the island declares for that element. */
|
|
561
|
+
const islandStyle = {};
|
|
562
|
+
for (const [name, frames] of Object.entries(island.keyframes)) {
|
|
563
|
+
keyframes[name] ??= frames;
|
|
564
|
+
animations.add(name);
|
|
565
|
+
}
|
|
566
|
+
/**
|
|
567
|
+
* A PATCH OVER SOMEBODY ELSE'S MARKUP IS A LAW, never a part: giving
|
|
568
|
+
* `.sketch-picker input` a part of ours would claim we drew a colour wheel.
|
|
569
|
+
*/
|
|
570
|
+
/**
|
|
571
|
+
* A RULE ON A CLASS THEY WRITE IS THE LOOK OF THE PART THAT WEARS IT.
|
|
572
|
+
*
|
|
573
|
+
* Their three dots are three sketch nodes all carrying `class="dot"`, and the
|
|
574
|
+
* anatomy already named them `dot`, `dot-2`, `dot-3` - three parts with EMPTY
|
|
575
|
+
* bases, because a utility reader has nothing to read on them. The island's `.dot`
|
|
576
|
+
* rule is their shared look and `:nth-child(1..3)` is the delay that makes the
|
|
577
|
+
* wave, one per part, in the order the nodes appear.
|
|
578
|
+
*
|
|
579
|
+
* Carried on the look and merged when the anatomy resolves, which is the only
|
|
580
|
+
* moment a class maps to a part name - the same seam the per-part layers use.
|
|
581
|
+
*/
|
|
582
|
+
if (island.own.length > 0) {
|
|
583
|
+
const seen = new Map();
|
|
584
|
+
for (const [index, node] of sketch.entries()) {
|
|
585
|
+
for (const cls of (node.classes ?? "").split(/\s+/)) {
|
|
586
|
+
if (!cls)
|
|
587
|
+
continue;
|
|
588
|
+
const nth = (seen.get(cls) ?? 0) + 1;
|
|
589
|
+
seen.set(cls, nth);
|
|
590
|
+
const mine = island.own.filter((rule) => rule.class === cls && (rule.nth ?? nth) === nth);
|
|
591
|
+
if (mine.length === 0)
|
|
592
|
+
continue;
|
|
593
|
+
islandStyle[index] = Object.assign(islandStyle[index] ?? {}, ...mine.map((rule) => rule.declarations));
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
if (island.foreign.length > 0) {
|
|
598
|
+
/**
|
|
599
|
+
* WHOSE MARKUP IS BEING PATCHED, measured rather than guessed: the opaque
|
|
600
|
+
* package this component composes. A headless one renders no markup of its own
|
|
601
|
+
* to patch.
|
|
602
|
+
*/
|
|
603
|
+
const drawn = sketch.find((node) => node.from && frontierKind(node.from) === "opaque")?.from;
|
|
604
|
+
islands.set(found[0].name, { foreign: island.foreign, drawn });
|
|
605
|
+
}
|
|
606
|
+
/**
|
|
607
|
+
* THE ANIMATIONS THIS COMPONENT NAMES, from the whole sketch rather than from the
|
|
608
|
+
* root's transcription - an `animate-ping` lives on a part three levels down and
|
|
609
|
+
* the resolver that reads a part does not report back up.
|
|
610
|
+
*
|
|
611
|
+
* The NAME only. The platform owns a library of frames and gives each one this
|
|
612
|
+
* system's own clock; a name it cannot define becomes a dangling reference the
|
|
613
|
+
* screen already knows how to say out loud, which beats a silent `animation` that
|
|
614
|
+
* animates nothing.
|
|
615
|
+
*/
|
|
616
|
+
for (const node of sketch) {
|
|
617
|
+
for (const raw of (node.classes ?? "").split(/\s+/)) {
|
|
618
|
+
const named = /^(?:[\w-]+:)*animate-([a-z][\w-]*)$/.exec(raw);
|
|
619
|
+
if (named && named[1] !== "none")
|
|
620
|
+
animations.add(named[1]);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
529
623
|
/**
|
|
530
624
|
* A PER-PART LAYER FINDS ITS NODE, by the class string of the call site that
|
|
531
625
|
* consumes the field. See `ReadLayer.onClasses`: the reader knows a
|
|
@@ -566,6 +660,7 @@ export async function takeCensus(root, opts) {
|
|
|
566
660
|
...(tag ? { rootTag: tag } : {}),
|
|
567
661
|
...rootPackage(tag, sketch),
|
|
568
662
|
...(placed.length > 0 ? { layers: placed } : {}),
|
|
663
|
+
...(Object.keys(islandStyle).length > 0 ? { islandStyle } : {}),
|
|
569
664
|
...(v.unslotted.length > 0
|
|
570
665
|
? { unplaced: [...new Set(v.unslotted)] }
|
|
571
666
|
: {}),
|
|
@@ -1013,6 +1108,19 @@ export async function takeCensus(root, opts) {
|
|
|
1013
1108
|
* happens, which is the whole reason `--usage` exists.
|
|
1014
1109
|
*/
|
|
1015
1110
|
const laws = new Map();
|
|
1111
|
+
/**
|
|
1112
|
+
* A PATCH OVER SOMEBODY ELSE'S MARKUP IS A LAW, and it is written here because this is
|
|
1113
|
+
* where laws are collected - giving `.sketch-picker input` a part of ours would claim we
|
|
1114
|
+
* drew a colour wheel. See `readStyleIslands`.
|
|
1115
|
+
*/
|
|
1116
|
+
for (const [name, read] of islands) {
|
|
1117
|
+
const line = describeForeign(read.foreign, read.drawn);
|
|
1118
|
+
if (!line)
|
|
1119
|
+
continue;
|
|
1120
|
+
const own = laws.get(name) ?? [];
|
|
1121
|
+
own.push(line);
|
|
1122
|
+
laws.set(name, own);
|
|
1123
|
+
}
|
|
1016
1124
|
for (const r of observedRules(merged)) {
|
|
1017
1125
|
laws.set(r.component, [...(laws.get(r.component) ?? []), r.text]);
|
|
1018
1126
|
}
|
|
@@ -1083,6 +1191,42 @@ export async function takeCensus(root, opts) {
|
|
|
1083
1191
|
for (const m of css.matchAll(/(--[a-zA-Z0-9_-]+)\s*:/g)) {
|
|
1084
1192
|
declaredNames.add(m[1]);
|
|
1085
1193
|
}
|
|
1194
|
+
/**
|
|
1195
|
+
* HOW SOMEBODY CALLS THE COMPONENT THEY ALREADY HAVE.
|
|
1196
|
+
*
|
|
1197
|
+
* The usage panel was handing people the markup of a component rebuilt out of our
|
|
1198
|
+
* compiled classes, for a component sitting in their own `packages/ui` with a name and
|
|
1199
|
+
* props - "você inventou diversas classes que não existem" (dono, 04/08), and the half
|
|
1200
|
+
* that lands is that those classes appear nowhere in his repo. He has
|
|
1201
|
+
* `<CollapsibleCard>`; the usage is its props.
|
|
1202
|
+
*
|
|
1203
|
+
* Measured, never inferred: the specifier is the name in THEIR manifest for the folder
|
|
1204
|
+
* being imported, and a component only carries it when the entry actually re-exports
|
|
1205
|
+
* the name - 35 of their 36, with `TypingIndicator` internal to `Message` and reported
|
|
1206
|
+
* as not exported. See `publicApi`.
|
|
1207
|
+
*/
|
|
1208
|
+
const api = await publicApi(root, scopeLabel);
|
|
1209
|
+
if (api) {
|
|
1210
|
+
for (const c of components) {
|
|
1211
|
+
const file = defined.find((d) => d.name === c.name)?.file;
|
|
1212
|
+
const source = file
|
|
1213
|
+
? sources.find((s) => s.file.endsWith(file))?.source
|
|
1214
|
+
: undefined;
|
|
1215
|
+
const required = source ? requiredProps(source, c.name) : [];
|
|
1216
|
+
c.api = {
|
|
1217
|
+
...(api.exports.has(c.name) ? { from: api.from } : { exported: false }),
|
|
1218
|
+
/**
|
|
1219
|
+
* REPO-RELATIVE, so the answer needs no second search. `defined[].file` is
|
|
1220
|
+
* relative to the scope; joined with the scope it is a path anybody can check
|
|
1221
|
+
* with one `stat` - which is how `component` knows they already have it without
|
|
1222
|
+
* walking a monorepo (their component sits six levels below `packages/`, and the
|
|
1223
|
+
* shallow walk that guards against shadowing never reached it).
|
|
1224
|
+
*/
|
|
1225
|
+
...(file ? { file: `${scopeLabel}/${file}` } : {}),
|
|
1226
|
+
...(required.length > 0 ? { required } : {}),
|
|
1227
|
+
};
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1086
1230
|
const brokenRefs = findBrokenRefs(sources, declaredNames);
|
|
1087
1231
|
const conventions = detectConventions(sources);
|
|
1088
1232
|
const classStyle = detectClassStyle(sources);
|
|
@@ -1090,6 +1234,8 @@ export async function takeCensus(root, opts) {
|
|
|
1090
1234
|
census: 1,
|
|
1091
1235
|
project: { name, stack: await detectStack(root) },
|
|
1092
1236
|
declared: Object.fromEntries(table.byName),
|
|
1237
|
+
...(Object.keys(keyframes).length > 0 ? { keyframes } : {}),
|
|
1238
|
+
...(animations.size > 0 ? { animations: [...animations].sort() } : {}),
|
|
1093
1239
|
...(brokenRefs.length > 0 ? { brokenRefs } : {}),
|
|
1094
1240
|
...(conventions.length > 0 ? { conventions } : {}),
|
|
1095
1241
|
classStyle,
|
|
@@ -1866,6 +2012,26 @@ async function resolveReadParts(census, root) {
|
|
|
1866
2012
|
const split = placeLayers((look?.layers ?? []), partAt, (look?.sketch ?? []));
|
|
1867
2013
|
for (const line of split.unplaced)
|
|
1868
2014
|
notes.add(line);
|
|
2015
|
+
/**
|
|
2016
|
+
* WHAT A `<style>` ISLAND DECLARED, ONTO THE PART THAT WEARS IT.
|
|
2017
|
+
*
|
|
2018
|
+
* Their three dots share `class="dot"` and the anatomy named them `dot`, `dot-2`,
|
|
2019
|
+
* `dot-3` - three parts with empty bases, because a utility reader has nothing to read
|
|
2020
|
+
* on a component that styles itself in CSS. This is the first moment an index maps to a
|
|
2021
|
+
* part name, which is why it happens here and not where the island was read.
|
|
2022
|
+
*
|
|
2023
|
+
* A utility of theirs on the same element still wins: the island only fills what the
|
|
2024
|
+
* class string did not already say.
|
|
2025
|
+
*/
|
|
2026
|
+
for (const [index, style] of Object.entries(look?.islandStyle ?? {})) {
|
|
2027
|
+
const part = partAt[Number(index)];
|
|
2028
|
+
const target = part ? resolved.parts[part] : undefined;
|
|
2029
|
+
if (!target) {
|
|
2030
|
+
notes.add("a `<style>` element declares a look for an element the anatomy does not name - it is not applied, because that would paint the wrong thing");
|
|
2031
|
+
continue;
|
|
2032
|
+
}
|
|
2033
|
+
target.base = { ...style, ...target.base };
|
|
2034
|
+
}
|
|
1869
2035
|
/**
|
|
1870
2036
|
* REPLACED, NOT APPENDED - because this runs more than once.
|
|
1871
2037
|
*
|
|
@@ -74,7 +74,7 @@ export function partName(authored) {
|
|
|
74
74
|
.replace(/^-|-$/g, "");
|
|
75
75
|
}
|
|
76
76
|
/** Comments out, so a `{` inside one cannot end a block early. */
|
|
77
|
-
function stripCssComments(css) {
|
|
77
|
+
export function stripCssComments(css) {
|
|
78
78
|
return css.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
79
79
|
}
|
|
80
80
|
function rules(css, media = null, out = []) {
|
|
@@ -168,6 +168,40 @@ function emptyClass() {
|
|
|
168
168
|
* same way the utility reader does - so the value travels as `{radius.md}` rather than
|
|
169
169
|
* as a literal, and the recipe re-themes.
|
|
170
170
|
*/
|
|
171
|
+
/**
|
|
172
|
+
* A KEYFRAME'S OWN OFFSETS, so the document can hold it.
|
|
173
|
+
*
|
|
174
|
+
* `keyframes` carried the body as raw CSS text and it was collected and then dropped -
|
|
175
|
+
* `moduleKeyframes` in `import.ts` had no reader at all, so an app whose animation lives
|
|
176
|
+
* in a CSS module lost it in silence (dono, 04/08). The document's shape is offset →
|
|
177
|
+
* declarations, which is the same rule parser one level down.
|
|
178
|
+
*/
|
|
179
|
+
export function keyframeOffsets(body) {
|
|
180
|
+
const out = {};
|
|
181
|
+
for (const rule of rules(stripCssComments(body))) {
|
|
182
|
+
/**
|
|
183
|
+
* `0%,\n 100%` is one offset written across two lines. Kept verbatim it becomes a
|
|
184
|
+
* selector with a newline in it, which the compiler emits and the browser ignores.
|
|
185
|
+
*/
|
|
186
|
+
const offset = rule.selector.replace(/\s+/g, " ").trim();
|
|
187
|
+
if (!offset)
|
|
188
|
+
continue;
|
|
189
|
+
const block = {};
|
|
190
|
+
for (const line of rule.body.split(";")) {
|
|
191
|
+
const at = line.indexOf(":");
|
|
192
|
+
if (at === -1)
|
|
193
|
+
continue;
|
|
194
|
+
const prop = line.slice(0, at).trim();
|
|
195
|
+
const value = line.slice(at + 1).trim();
|
|
196
|
+
if (!prop || !value)
|
|
197
|
+
continue;
|
|
198
|
+
block[prop.replace(/-([a-z])/g, (_, c) => c.toUpperCase())] = value;
|
|
199
|
+
}
|
|
200
|
+
if (Object.keys(block).length > 0)
|
|
201
|
+
out[offset] = block;
|
|
202
|
+
}
|
|
203
|
+
return out;
|
|
204
|
+
}
|
|
171
205
|
export function readModuleCss(css) {
|
|
172
206
|
const out = { classes: {}, keyframes: {}, unslotted: [] };
|
|
173
207
|
const clean = stripCssComments(css);
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HOW SOMEBODY CALLS THE COMPONENT THEY ALREADY HAVE.
|
|
3
|
+
*
|
|
4
|
+
* The usage panel was emitting the markup of a component rebuilt out of our compiled
|
|
5
|
+
* classes - `<div className="ds-collapsible-card-trigger">` and twenty lines like it. The
|
|
6
|
+
* owner's objection was exact (dono, 04/08): "você inventou diversas classes que não
|
|
7
|
+
* existem". They exist in our stylesheet and they exist NOWHERE in his repo, which is the
|
|
8
|
+
* half that matters. He has `<CollapsibleCard>`; the usage is its props.
|
|
9
|
+
*
|
|
10
|
+
* That question has a measured answer and we were not reading it:
|
|
11
|
+
*
|
|
12
|
+
* the package name their own `package.json` for the scope being imported
|
|
13
|
+
* is it exported follow `export * from` out of the entry - 35 of their 36
|
|
14
|
+
* which props the destructuring, already read (36 of 36)
|
|
15
|
+
* which are required their `interface XProps` - 23 of 38 files declare one
|
|
16
|
+
*
|
|
17
|
+
* Nothing here is inferred. A component the barrel does not reach is reported as not
|
|
18
|
+
* exported, which is a fact worth having - their `TypingIndicator` is internal to
|
|
19
|
+
* `Message`, and telling somebody to import it would be the invention.
|
|
20
|
+
*/
|
|
21
|
+
import { readFile } from "node:fs/promises";
|
|
22
|
+
import { dirname, join, normalize } from "node:path";
|
|
23
|
+
const NAMED = /export\s*\{([^}]*)\}\s*from\s*["']([^"']+)["']/g;
|
|
24
|
+
const DECLARED = /export\s+(?:const|function|class)\s+([A-Za-z_$][\w$]*)/g;
|
|
25
|
+
const STAR = /export\s+\*\s+from\s*["']([^"']+)["']/g;
|
|
26
|
+
async function readAt(base, rel) {
|
|
27
|
+
for (const candidate of [
|
|
28
|
+
rel,
|
|
29
|
+
`${rel}.ts`,
|
|
30
|
+
`${rel}.tsx`,
|
|
31
|
+
join(rel, "index.ts"),
|
|
32
|
+
join(rel, "index.tsx"),
|
|
33
|
+
]) {
|
|
34
|
+
const path = normalize(join(base, candidate));
|
|
35
|
+
const source = await readFile(path, "utf8").catch(() => null);
|
|
36
|
+
if (source != null)
|
|
37
|
+
return [path, source];
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Every name the entry exposes, following `export *` as far as it goes.
|
|
43
|
+
*
|
|
44
|
+
* Depth-capped and cycle-guarded: a barrel chain is somebody's folder layout, and
|
|
45
|
+
* `packages/ui` reaches its components through three of them.
|
|
46
|
+
*/
|
|
47
|
+
async function exportsOf(path, source, depth = 0, seen = new Set()) {
|
|
48
|
+
const out = new Set();
|
|
49
|
+
if (depth > 6 || seen.has(path))
|
|
50
|
+
return out;
|
|
51
|
+
seen.add(path);
|
|
52
|
+
NAMED.lastIndex = 0;
|
|
53
|
+
for (const m of source.matchAll(NAMED)) {
|
|
54
|
+
for (const part of m[1].split(",")) {
|
|
55
|
+
const name = part.split(" as ").pop()?.trim();
|
|
56
|
+
if (name && name !== "default")
|
|
57
|
+
out.add(name);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
DECLARED.lastIndex = 0;
|
|
61
|
+
for (const m of source.matchAll(DECLARED))
|
|
62
|
+
out.add(m[1]);
|
|
63
|
+
STAR.lastIndex = 0;
|
|
64
|
+
for (const m of source.matchAll(STAR)) {
|
|
65
|
+
const next = await readAt(dirname(path), m[1]);
|
|
66
|
+
if (!next)
|
|
67
|
+
continue;
|
|
68
|
+
for (const name of await exportsOf(next[0], next[1], depth + 1, seen))
|
|
69
|
+
out.add(name);
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* The public API of the folder being imported, or null when it has no manifest.
|
|
75
|
+
*
|
|
76
|
+
* `root` is the repository, `scope` the folder the census measured (`packages/ui`). A
|
|
77
|
+
* scope with no `package.json` of its own is not a package and there is no specifier to
|
|
78
|
+
* offer - an app's `src/components` is imported by path, which is theirs to know and not
|
|
79
|
+
* ours to guess.
|
|
80
|
+
*/
|
|
81
|
+
export async function publicApi(root, scope) {
|
|
82
|
+
if (!scope)
|
|
83
|
+
return null;
|
|
84
|
+
const manifest = await readFile(join(root, scope, "package.json"), "utf8")
|
|
85
|
+
.then((raw) => JSON.parse(raw))
|
|
86
|
+
.catch(() => null);
|
|
87
|
+
if (!manifest?.name)
|
|
88
|
+
return null;
|
|
89
|
+
const entry = manifest.main ?? manifest.module ?? "src/index.ts";
|
|
90
|
+
const found = await readAt(join(root, scope), entry);
|
|
91
|
+
if (!found)
|
|
92
|
+
return { from: manifest.name, exports: new Set() };
|
|
93
|
+
return {
|
|
94
|
+
from: manifest.name,
|
|
95
|
+
exports: await exportsOf(found[0], found[1]),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
const PROPS_BLOCK = /(?:export\s+)?(?:interface|type)\s+(\w*Props)\s*(?:=\s*)?\{([\s\S]*?)\n\}/;
|
|
99
|
+
/**
|
|
100
|
+
* WHICH PROPS THEIR TYPE MARKS AS REQUIRED.
|
|
101
|
+
*
|
|
102
|
+
* The one thing that makes a call snippet copy-pasteable: `title: string` has to be
|
|
103
|
+
* filled and `subtitle?: string` does not. 23 of their 38 files declare a readable
|
|
104
|
+
* `interface XProps`; the rest extend a library's type or use generics, and there we say
|
|
105
|
+
* nothing rather than guessing which props somebody must pass.
|
|
106
|
+
*/
|
|
107
|
+
export function requiredProps(source, name) {
|
|
108
|
+
const scoped = new RegExp(`(?:export\\s+)?(?:interface|type)\\s+${name}Props\\s*(?:=\\s*)?\\{([\\s\\S]*?)\\n\\}`).exec(source);
|
|
109
|
+
const body = scoped?.[1] ?? PROPS_BLOCK.exec(source)?.[2];
|
|
110
|
+
if (!body)
|
|
111
|
+
return [];
|
|
112
|
+
const out = [];
|
|
113
|
+
for (const line of body.split("\n")) {
|
|
114
|
+
const m = /^\s*(?:readonly\s+)?([A-Za-z_$][\w$]*)(\?)?\s*:/.exec(line);
|
|
115
|
+
if (m && !m[2])
|
|
116
|
+
out.push(m[1]);
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE CSS A COMPONENT CARRIES INSIDE ITSELF.
|
|
3
|
+
*
|
|
4
|
+
* Some components do not style with utilities at all - they render a `<style>` element
|
|
5
|
+
* with real CSS in it. Their `TypingIndicator` is the whole case: three dots, one
|
|
6
|
+
* `@keyframes wave-bounce`, and a `.dot` rule with a per-dot delay. Read as utilities it
|
|
7
|
+
* arrived with three declarations and no animation, so the component that IS an animation
|
|
8
|
+
* previewed as three static circles (dono, 04/08).
|
|
9
|
+
*
|
|
10
|
+
* Measured across their repository: 2 islands, both in `packages/ui`, and none in any of
|
|
11
|
+
* the three apps - 1 keyframe, 6 rules on classes the component itself writes, 3 on a
|
|
12
|
+
* third party's internals, and 12 `!important`.
|
|
13
|
+
*
|
|
14
|
+
* THREE KINDS OF CONTENT, THREE DESTINATIONS, and telling them apart is the whole job:
|
|
15
|
+
*
|
|
16
|
+
* @keyframes the system's motion vocabulary, by name
|
|
17
|
+
* a rule on a class THEY write the look of the part that wears that class
|
|
18
|
+
* a rule on somebody else's a patch over a third party: prose, never a look
|
|
19
|
+
*
|
|
20
|
+
* The last one is the line. `.sketch-picker input { background: … !important }` is not
|
|
21
|
+
* their component's anatomy - it is a re-skin of `react-color`'s own markup - and giving
|
|
22
|
+
* it a part would claim we drew something we did not. It travels as a law instead, with
|
|
23
|
+
* the package named and the `!important` said out loud.
|
|
24
|
+
*/
|
|
25
|
+
import { keyframeOffsets, readModuleCss, stripCssComments, } from "./css-modules.js";
|
|
26
|
+
/** `<style>{`…`}</style>` and `<style>…</style>` - the two spellings in JSX. */
|
|
27
|
+
const ISLAND = /<style[^>]*>\s*\{?\s*(?:`([\s\S]*?)`|([\s\S]*?))\s*\}?\s*<\/style>/g;
|
|
28
|
+
/** `.dot:nth-child(2)` → the class and which one. A compound selector is foreign. */
|
|
29
|
+
const OWN_SELECTOR = /^\.([\w-]+)(?::nth-child\((\d+)\))?$/;
|
|
30
|
+
/**
|
|
31
|
+
* The CSS a component keeps inside itself, split by where each rule belongs.
|
|
32
|
+
*
|
|
33
|
+
* `writes` is the set of classes the component's own markup carries - from the sketch,
|
|
34
|
+
* which measured them. A selector naming one of those is about an element we know; any
|
|
35
|
+
* other selector is about markup somebody else renders.
|
|
36
|
+
*/
|
|
37
|
+
export function readStyleIslands(source, writes) {
|
|
38
|
+
const out = { keyframes: {}, own: [], foreign: [] };
|
|
39
|
+
ISLAND.lastIndex = 0;
|
|
40
|
+
for (const island of source.matchAll(ISLAND)) {
|
|
41
|
+
const raw = island[1] ?? island[2] ?? "";
|
|
42
|
+
if (!raw.trim())
|
|
43
|
+
continue;
|
|
44
|
+
/**
|
|
45
|
+
* COMMENTS OUT FIRST. Their `.dot` rule carries `/* Cor ocean-600 *``/` between two
|
|
46
|
+
* declarations, and the split-on-semicolon read it as part of the NEXT property's
|
|
47
|
+
* name - so `borderRadius` arrived as `/* Cor … *``/\n borderRadius`.
|
|
48
|
+
*/
|
|
49
|
+
const body = stripCssComments(raw);
|
|
50
|
+
const read = readModuleCss(body);
|
|
51
|
+
for (const [name, frames] of Object.entries(read.keyframes)) {
|
|
52
|
+
const offsets = keyframeOffsets(frames);
|
|
53
|
+
if (Object.keys(offsets).length > 0)
|
|
54
|
+
out.keyframes[name] ??= offsets;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* `readModuleCss` files a rule under each class it names, which is what a CSS module
|
|
58
|
+
* needs - a class there IS the export. Here the SELECTOR decides, because
|
|
59
|
+
* `.dark .sketch-picker span` names a class we write (`dark`) and is still about
|
|
60
|
+
* somebody else's span. So the rules are re-read off the body.
|
|
61
|
+
*/
|
|
62
|
+
for (const rule of body.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
|
|
63
|
+
const selector = rule[1].trim().split("\n").pop()?.trim() ?? "";
|
|
64
|
+
if (!selector || selector.startsWith("@"))
|
|
65
|
+
continue;
|
|
66
|
+
// An offset inside a keyframe body is not a rule about an element.
|
|
67
|
+
if (/^(?:from|to|\d+%)(?:\s*,\s*(?:from|to|\d+%))*$/.test(selector))
|
|
68
|
+
continue;
|
|
69
|
+
const declarations = {};
|
|
70
|
+
let important = 0;
|
|
71
|
+
for (const line of rule[2].split(";")) {
|
|
72
|
+
const at = line.indexOf(":");
|
|
73
|
+
if (at === -1)
|
|
74
|
+
continue;
|
|
75
|
+
const property = line.slice(0, at).trim();
|
|
76
|
+
let value = line.slice(at + 1).trim();
|
|
77
|
+
if (!property || !value)
|
|
78
|
+
continue;
|
|
79
|
+
if (/!important$/.test(value)) {
|
|
80
|
+
important += 1;
|
|
81
|
+
value = value.replace(/\s*!important$/, "");
|
|
82
|
+
}
|
|
83
|
+
declarations[property.replace(/-([a-z])/g, (_m, c) => c.toUpperCase())] = value;
|
|
84
|
+
}
|
|
85
|
+
if (Object.keys(declarations).length === 0)
|
|
86
|
+
continue;
|
|
87
|
+
/**
|
|
88
|
+
* THEIR `animation: wave-bounce 1s infinite` NAMES A KEYFRAME THE SYSTEM OWNS.
|
|
89
|
+
*
|
|
90
|
+
* The compiler scopes every keyframe and mints `--animate-<name>` for it, so a raw
|
|
91
|
+
* name in a recipe points at nothing. The reference replaces the name and their own
|
|
92
|
+
* timing stays as longhands beside it - which is what makes the wave a wave.
|
|
93
|
+
*/
|
|
94
|
+
if (declarations.animation) {
|
|
95
|
+
const named = /^([\w-]+)\s*(.*)$/.exec(declarations.animation.trim());
|
|
96
|
+
if (named && out.keyframes[named[1]]) {
|
|
97
|
+
declarations.animation = `var(--animate-${named[1]})`;
|
|
98
|
+
for (const part of named[2].split(/\s+/).filter(Boolean)) {
|
|
99
|
+
if (/^\d+(?:\.\d+)?m?s$/.test(part))
|
|
100
|
+
declarations.animationDuration ??= part;
|
|
101
|
+
else if (part === "infinite" || /^\d+$/.test(part))
|
|
102
|
+
declarations.animationIterationCount ??= part;
|
|
103
|
+
else if (/^(linear|ease|ease-in|ease-out|ease-in-out|steps\(|cubic-bezier\()/.test(part))
|
|
104
|
+
declarations.animationTimingFunction ??= part;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const own = OWN_SELECTOR.exec(selector);
|
|
109
|
+
if (own && writes.has(own[1])) {
|
|
110
|
+
out.own.push({
|
|
111
|
+
class: own[1],
|
|
112
|
+
...(own[2] ? { nth: Number(own[2]) } : {}),
|
|
113
|
+
declarations,
|
|
114
|
+
});
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
out.foreign.push({
|
|
118
|
+
selector,
|
|
119
|
+
declarations: Object.keys(declarations).length,
|
|
120
|
+
important,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* What a patch over somebody else's component is, said in one line.
|
|
128
|
+
*
|
|
129
|
+
* A law rather than a look: an agent reading this system needs to know the re-skin exists
|
|
130
|
+
* and that it leans on `!important`, and no part of ours should claim to have drawn it.
|
|
131
|
+
*/
|
|
132
|
+
export function describeForeign(rules, library) {
|
|
133
|
+
if (rules.length === 0)
|
|
134
|
+
return null;
|
|
135
|
+
const declarations = rules.reduce((n, r) => n + r.declarations, 0);
|
|
136
|
+
const important = rules.reduce((n, r) => n + r.important, 0);
|
|
137
|
+
const selectors = [...new Set(rules.map((r) => r.selector))].slice(0, 3);
|
|
138
|
+
return (`This component re-skins ${library ?? "a third party's markup"} from the inside: ` +
|
|
139
|
+
`${declarations} declaration${declarations === 1 ? "" : "s"} across ${rules.length} rule${rules.length === 1 ? "" : "s"} ` +
|
|
140
|
+
`in a \`<style>\` element it renders itself, on selectors that are not ours ` +
|
|
141
|
+
`(${selectors.join(", ")})${important > 0 ? `, ${important} of them \`!important\`` : ""}. ` +
|
|
142
|
+
`Those are not part styles and editing this system's tokens will not move them.`);
|
|
143
|
+
}
|
|
@@ -90,7 +90,36 @@ const SPACING_PROPERTY = {
|
|
|
90
90
|
m: "margin",
|
|
91
91
|
mx: "marginInline",
|
|
92
92
|
my: "marginBlock",
|
|
93
|
+
/**
|
|
94
|
+
* THE FOUR SIDES, which had no entry - so `mt-1` read as nothing.
|
|
95
|
+
*
|
|
96
|
+
* On their card it is the 4px between the eyebrow and the title; across the library it
|
|
97
|
+
* is 20 utilities in 12 components. A margin is a spacing decision and the contract
|
|
98
|
+
* always held it; only the table was short (dono, 04/08).
|
|
99
|
+
*/
|
|
100
|
+
mt: "marginTop",
|
|
101
|
+
mr: "marginRight",
|
|
102
|
+
mb: "marginBottom",
|
|
103
|
+
ml: "marginLeft",
|
|
93
104
|
gap: "gap",
|
|
105
|
+
"gap-x": "columnGap",
|
|
106
|
+
"gap-y": "rowGap",
|
|
107
|
+
/**
|
|
108
|
+
* `space-y-1` sets a margin on the CHILDREN, which is not a declaration on the parent.
|
|
109
|
+
* As a row/column gap it is exact wherever the parent arranges - which is every one of
|
|
110
|
+
* their uses - and inert where it does not, so it never paints a wrong pixel.
|
|
111
|
+
*/
|
|
112
|
+
"space-y": "rowGap",
|
|
113
|
+
"space-x": "columnGap",
|
|
114
|
+
/** Position offsets - what makes `absolute inset-0` cover its parent. */
|
|
115
|
+
inset: "inset",
|
|
116
|
+
"inset-x": "insetInline",
|
|
117
|
+
"inset-y": "insetBlock",
|
|
118
|
+
top: "top",
|
|
119
|
+
right: "right",
|
|
120
|
+
bottom: "bottom",
|
|
121
|
+
left: "left",
|
|
122
|
+
basis: "flexBasis",
|
|
94
123
|
/**
|
|
95
124
|
* SIZE, which the first version had no entry for at all.
|
|
96
125
|
*
|
|
@@ -330,6 +359,149 @@ const TYPE_KEYWORD = {
|
|
|
330
359
|
italic: { property: "fontStyle", value: "italic" },
|
|
331
360
|
underline: { property: "textDecoration", value: "underline" },
|
|
332
361
|
};
|
|
362
|
+
/**
|
|
363
|
+
* THE DECLARATIONS THAT ARE A WHOLE WORD, and were being dropped as layout.
|
|
364
|
+
*
|
|
365
|
+
* The rule that keeps layout out of a recipe is right about `flex` and `gap` - identical
|
|
366
|
+
* in every design system, and a recipe full of them is a recipe about nothing. It was
|
|
367
|
+
* wrong about everything that came along for the ride. Swept across their 36 components,
|
|
368
|
+
* 564 of 1443 utilities produced no declaration at all, and the biggest groups were not
|
|
369
|
+
* arrangement but PLACE: 116 distribution utilities in 25 components, 69 position ones in
|
|
370
|
+
* 17 (dono, 04/08).
|
|
371
|
+
*
|
|
372
|
+
* The cost was visible on one card: the loading veil is `absolute inset-0 bg-white/50`,
|
|
373
|
+
* and without the position it stopped being a veil and became a block in the flow; the
|
|
374
|
+
* chevron's `justify-between` is what puts it at the far edge of the header.
|
|
375
|
+
*
|
|
376
|
+
* `flex` and `flex-col` stay OUT on purpose - they travel as the node's own form
|
|
377
|
+
* (`row`/`stack`), and declaring the same thing twice is how two sources of truth start
|
|
378
|
+
* disagreeing. What comes in here is the DIMENSION (`flex-1`, `shrink-0`) and the PLACE.
|
|
379
|
+
*/
|
|
380
|
+
const PLACE_KEYWORD = {
|
|
381
|
+
// Position: what makes an overlay an overlay.
|
|
382
|
+
relative: { property: "position", value: "relative" },
|
|
383
|
+
absolute: { property: "position", value: "absolute" },
|
|
384
|
+
fixed: { property: "position", value: "fixed" },
|
|
385
|
+
sticky: { property: "position", value: "sticky" },
|
|
386
|
+
static: { property: "position", value: "static" },
|
|
387
|
+
// Flex dimension. The direction is the node's form; how it grows is a decision.
|
|
388
|
+
"flex-1": { property: "flex", value: "1 1 0%" },
|
|
389
|
+
"flex-auto": { property: "flex", value: "1 1 auto" },
|
|
390
|
+
"flex-initial": { property: "flex", value: "0 1 auto" },
|
|
391
|
+
"flex-none": { property: "flex", value: "none" },
|
|
392
|
+
grow: { property: "flexGrow", value: "1" },
|
|
393
|
+
"grow-0": { property: "flexGrow", value: "0" },
|
|
394
|
+
"flex-grow": { property: "flexGrow", value: "1" },
|
|
395
|
+
"flex-grow-0": { property: "flexGrow", value: "0" },
|
|
396
|
+
shrink: { property: "flexShrink", value: "1" },
|
|
397
|
+
"shrink-0": { property: "flexShrink", value: "0" },
|
|
398
|
+
"flex-shrink": { property: "flexShrink", value: "1" },
|
|
399
|
+
"flex-shrink-0": { property: "flexShrink", value: "0" },
|
|
400
|
+
// Overflow: what makes a clip clip, and a scroller scroll.
|
|
401
|
+
"overflow-hidden": { property: "overflow", value: "hidden" },
|
|
402
|
+
"overflow-auto": { property: "overflow", value: "auto" },
|
|
403
|
+
"overflow-visible": { property: "overflow", value: "visible" },
|
|
404
|
+
"overflow-scroll": { property: "overflow", value: "scroll" },
|
|
405
|
+
"overflow-x-auto": { property: "overflowX", value: "auto" },
|
|
406
|
+
"overflow-y-auto": { property: "overflowY", value: "auto" },
|
|
407
|
+
"overflow-x-hidden": { property: "overflowX", value: "hidden" },
|
|
408
|
+
"overflow-y-hidden": { property: "overflowY", value: "hidden" },
|
|
409
|
+
"overflow-x-scroll": { property: "overflowX", value: "scroll" },
|
|
410
|
+
"overflow-y-scroll": { property: "overflowY", value: "scroll" },
|
|
411
|
+
// Display, when it is not the arrangement we already carry as a form.
|
|
412
|
+
"inline-flex": { property: "display", value: "inline-flex" },
|
|
413
|
+
"inline-block": { property: "display", value: "inline-block" },
|
|
414
|
+
inline: { property: "display", value: "inline" },
|
|
415
|
+
block: { property: "display", value: "block" },
|
|
416
|
+
hidden: { property: "display", value: "none" },
|
|
417
|
+
// Focus and the pointer: real decisions, and `outline-none` is 21 of theirs.
|
|
418
|
+
"outline-none": { property: "outline", value: "none" },
|
|
419
|
+
"cursor-pointer": { property: "cursor", value: "pointer" },
|
|
420
|
+
"cursor-default": { property: "cursor", value: "default" },
|
|
421
|
+
"cursor-not-allowed": { property: "cursor", value: "not-allowed" },
|
|
422
|
+
"cursor-text": { property: "cursor", value: "text" },
|
|
423
|
+
"cursor-grab": { property: "cursor", value: "grab" },
|
|
424
|
+
"cursor-wait": { property: "cursor", value: "wait" },
|
|
425
|
+
"pointer-events-none": { property: "pointerEvents", value: "none" },
|
|
426
|
+
"pointer-events-auto": { property: "pointerEvents", value: "auto" },
|
|
427
|
+
"select-none": { property: "userSelect", value: "none" },
|
|
428
|
+
"select-text": { property: "userSelect", value: "text" },
|
|
429
|
+
"select-all": { property: "userSelect", value: "all" },
|
|
430
|
+
// Text that has to fit, and numbers that have to line up.
|
|
431
|
+
"whitespace-nowrap": { property: "whiteSpace", value: "nowrap" },
|
|
432
|
+
"whitespace-pre": { property: "whiteSpace", value: "pre" },
|
|
433
|
+
"whitespace-pre-wrap": { property: "whiteSpace", value: "pre-wrap" },
|
|
434
|
+
"break-words": { property: "overflowWrap", value: "break-word" },
|
|
435
|
+
"break-all": { property: "wordBreak", value: "break-all" },
|
|
436
|
+
"tabular-nums": { property: "fontVariantNumeric", value: "tabular-nums" },
|
|
437
|
+
// How an image fills its frame.
|
|
438
|
+
"object-cover": { property: "objectFit", value: "cover" },
|
|
439
|
+
"object-contain": { property: "objectFit", value: "contain" },
|
|
440
|
+
"object-fill": { property: "objectFit", value: "fill" },
|
|
441
|
+
"object-none": { property: "objectFit", value: "none" },
|
|
442
|
+
"object-scale-down": { property: "objectFit", value: "scale-down" },
|
|
443
|
+
/**
|
|
444
|
+
* ONE UTILITY, THREE DECLARATIONS - and any one of them alone paints nothing, which is
|
|
445
|
+
* why this needed the `prop:value|…` form rather than the pair form.
|
|
446
|
+
*/
|
|
447
|
+
truncate: {
|
|
448
|
+
property: "overflow+textOverflow+whiteSpace",
|
|
449
|
+
value: "overflow:hidden|textOverflow:ellipsis|whiteSpace:nowrap",
|
|
450
|
+
},
|
|
451
|
+
};
|
|
452
|
+
/**
|
|
453
|
+
* MOVEMENT IS A DECLARATION TOO, and 91 utilities of it were being dropped.
|
|
454
|
+
*
|
|
455
|
+
* Measured across their 36 components (dono, 04/08): 34 `transition-*` in 20 components,
|
|
456
|
+
* 23 `duration-*` in 16, 2 `ease-*`, 18 `animate-*` in 10. Their chevron turns over 300ms
|
|
457
|
+
* and their spinner spins - and the recipe said neither, so a rebuilt component snapped
|
|
458
|
+
* between states and sat still where it should turn.
|
|
459
|
+
*
|
|
460
|
+
* READ AS THE FOUR LONGHANDS, never as the shorthand. `transition-colors duration-200
|
|
461
|
+
* ease-out` is three utilities and three properties: composing them into one `transition`
|
|
462
|
+
* would mean guessing what the other two will be, and whichever landed last would erase
|
|
463
|
+
* the others. Longhands compose the way their own utilities do.
|
|
464
|
+
*/
|
|
465
|
+
const TRANSITION_PROPERTY = {
|
|
466
|
+
all: "all",
|
|
467
|
+
none: "none",
|
|
468
|
+
colors: "color, background-color, border-color, text-decoration-color, fill, stroke",
|
|
469
|
+
opacity: "opacity",
|
|
470
|
+
shadow: "box-shadow",
|
|
471
|
+
transform: "transform, translate, scale, rotate",
|
|
472
|
+
// Tailwind's bare `transition` is this set, and it is what 16 of theirs write.
|
|
473
|
+
"": "color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter",
|
|
474
|
+
};
|
|
475
|
+
/** Tailwind's easing scale, verbatim - a curve nobody should retype. */
|
|
476
|
+
const EASING = {
|
|
477
|
+
linear: "linear",
|
|
478
|
+
in: "cubic-bezier(0.4, 0, 1, 1)",
|
|
479
|
+
out: "cubic-bezier(0, 0, 0.2, 1)",
|
|
480
|
+
"in-out": "cubic-bezier(0.4, 0, 0.2, 1)",
|
|
481
|
+
};
|
|
482
|
+
/** `justify-between` → `space-between`, and the four that are not a straight copy. */
|
|
483
|
+
const ALIGNMENT = {
|
|
484
|
+
start: "flex-start",
|
|
485
|
+
end: "flex-end",
|
|
486
|
+
between: "space-between",
|
|
487
|
+
around: "space-around",
|
|
488
|
+
evenly: "space-evenly",
|
|
489
|
+
center: "center",
|
|
490
|
+
stretch: "stretch",
|
|
491
|
+
baseline: "baseline",
|
|
492
|
+
};
|
|
493
|
+
/** Which property each alignment prefix sets. */
|
|
494
|
+
const ALIGN_PROP = {
|
|
495
|
+
justify: "justifyContent",
|
|
496
|
+
items: "alignItems",
|
|
497
|
+
self: "alignSelf",
|
|
498
|
+
content: "alignContent",
|
|
499
|
+
"justify-items": "justifyItems",
|
|
500
|
+
"justify-self": "justifySelf",
|
|
501
|
+
"place-items": "placeItems",
|
|
502
|
+
"place-content": "placeContent",
|
|
503
|
+
"place-self": "placeSelf",
|
|
504
|
+
};
|
|
333
505
|
/** States we recognise. Anything else is skipped rather than invented. */
|
|
334
506
|
/**
|
|
335
507
|
* A MODIFIER'S STATE, IN THE CONTRACT'S SPELLING.
|
|
@@ -419,7 +591,22 @@ function withAlpha(d, alpha) {
|
|
|
419
591
|
return d;
|
|
420
592
|
return { ...d, value: `color-mix(in srgb, ${d.value} ${pct}%, transparent)` };
|
|
421
593
|
}
|
|
594
|
+
/** `w-1/2`, `basis-2/3` - a fraction is a width, and the `/` is not an alpha. */
|
|
595
|
+
const FRACTION = /^(w|h|basis|max-w|max-h|min-w|min-h|top|right|bottom|left|inset)-(\d+)\/(\d+)$/;
|
|
422
596
|
export function readUtility(utility, declared) {
|
|
597
|
+
/**
|
|
598
|
+
* BEFORE THE ALPHA SPLIT, because the two share a slash and only one of them is a
|
|
599
|
+
* colour. `w-1/2` was reaching the size reader as `w-1` and coming back `0.25rem` -
|
|
600
|
+
* silently wrong, which is worse than unread. Their `packages/ui` has none; two of
|
|
601
|
+
* their apps do (dono, 04/08).
|
|
602
|
+
*/
|
|
603
|
+
const fraction = FRACTION.exec(utility);
|
|
604
|
+
if (fraction) {
|
|
605
|
+
const property = SPACING_PROPERTY[fraction[1]];
|
|
606
|
+
const pct = (Number(fraction[2]) / Number(fraction[3])) * 100;
|
|
607
|
+
if (property)
|
|
608
|
+
return { property, value: `${Number(pct.toFixed(4))}%` };
|
|
609
|
+
}
|
|
423
610
|
const read = readUtilityCore(utility, declared);
|
|
424
611
|
return read ? withAlpha(read, utility.split("/")[1]) : read;
|
|
425
612
|
}
|
|
@@ -442,9 +629,79 @@ function readUtilityCore(utility, declared) {
|
|
|
442
629
|
const [core] = utility.split("/");
|
|
443
630
|
// A bare keyword carries a declaration with no scale behind it, so it is read
|
|
444
631
|
// before the dash split that everything else needs.
|
|
445
|
-
const keyword = TYPE_KEYWORD[core];
|
|
632
|
+
const keyword = TYPE_KEYWORD[core] ?? PLACE_KEYWORD[core];
|
|
446
633
|
if (keyword)
|
|
447
634
|
return keyword;
|
|
635
|
+
/**
|
|
636
|
+
* A BARE `rounded` IS TAILWIND'S OWN DEFAULT RADIUS, and it read as nothing - so six
|
|
637
|
+
* elements across their library arrived square. Their token wins if they declared one
|
|
638
|
+
* under that name, exactly as the scaled form already does.
|
|
639
|
+
*/
|
|
640
|
+
if (core === "rounded") {
|
|
641
|
+
return declared.has("--radius-DEFAULT")
|
|
642
|
+
? {
|
|
643
|
+
property: "borderRadius",
|
|
644
|
+
value: "{radius.DEFAULT}",
|
|
645
|
+
token: "--radius-DEFAULT",
|
|
646
|
+
}
|
|
647
|
+
: { property: "borderRadius", value: "0.25rem" };
|
|
648
|
+
}
|
|
649
|
+
/**
|
|
650
|
+
* TRANSITION, DURATION, EASING, DELAY - four utilities, four properties.
|
|
651
|
+
*
|
|
652
|
+
* 59 of these across their library, and the recipe carried none: a rebuilt component
|
|
653
|
+
* snapped between every state their own code eases through (dono, 04/08).
|
|
654
|
+
*/
|
|
655
|
+
const bareTransition = /^transition(?:-(.+))?$/.exec(core);
|
|
656
|
+
if (bareTransition) {
|
|
657
|
+
const arg = bareTransition[1] ?? "";
|
|
658
|
+
const arbitrary = /^\[(.+)\]$/.exec(arg);
|
|
659
|
+
const value = arbitrary
|
|
660
|
+
? arbitrary[1].replace(/_/g, " ")
|
|
661
|
+
: TRANSITION_PROPERTY[arg];
|
|
662
|
+
if (value)
|
|
663
|
+
return { property: "transitionProperty", value };
|
|
664
|
+
}
|
|
665
|
+
const ms = /^(duration|delay)-(\d+)$/.exec(core);
|
|
666
|
+
if (ms) {
|
|
667
|
+
return {
|
|
668
|
+
property: ms[1] === "duration" ? "transitionDuration" : "transitionDelay",
|
|
669
|
+
value: `${ms[2]}ms`,
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
const ease = /^ease-(.+)$/.exec(core);
|
|
673
|
+
if (ease && EASING[ease[1]]) {
|
|
674
|
+
return { property: "transitionTimingFunction", value: EASING[ease[1]] };
|
|
675
|
+
}
|
|
676
|
+
/**
|
|
677
|
+
* `animate-spin` - the animation, by the name the system defines it under.
|
|
678
|
+
*
|
|
679
|
+
* `var(--animate-spin)` rather than a shorthand of ours: the compiler mints that
|
|
680
|
+
* variable from the document's own keyframes and gives it this system's clock. The
|
|
681
|
+
* frames themselves travel beside the reading - see `BUILTIN_KEYFRAMES` - because a
|
|
682
|
+
* recipe cannot point at a keyframe the system does not define.
|
|
683
|
+
*/
|
|
684
|
+
const animate = /^animate-([a-z][\w-]*)$/.exec(core);
|
|
685
|
+
if (animate && animate[1] !== "none") {
|
|
686
|
+
return {
|
|
687
|
+
property: "animation",
|
|
688
|
+
value: `var(--animate-${animate[1]})`,
|
|
689
|
+
keyframe: animate[1],
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
/**
|
|
693
|
+
* ALIGNMENT - `justify-between`, `items-center`, `self-end`.
|
|
694
|
+
*
|
|
695
|
+
* 116 of these across 25 of their components, and they are not arrangement: they are
|
|
696
|
+
* WHERE the thing sits. The chevron on their card is at the far edge because of one.
|
|
697
|
+
*/
|
|
698
|
+
const align = /^(place-items|place-content|place-self|justify-items|justify-self|justify|items|self|content)-([a-z]+)$/.exec(core);
|
|
699
|
+
if (align) {
|
|
700
|
+
const property = ALIGN_PROP[align[1]];
|
|
701
|
+
const value = ALIGNMENT[align[2]];
|
|
702
|
+
if (property && value)
|
|
703
|
+
return { property, value };
|
|
704
|
+
}
|
|
448
705
|
/**
|
|
449
706
|
* BORDER WIDTH - `border`, `border-2`, `border-b`, `border-t-4`.
|
|
450
707
|
*
|
|
@@ -533,6 +790,18 @@ function readUtilityCore(utility, declared) {
|
|
|
533
790
|
return { property: "borderCollapse", value: rest };
|
|
534
791
|
}
|
|
535
792
|
}
|
|
793
|
+
/**
|
|
794
|
+
* `ring-1` IS A WIDTH, and it reached the colour reader first - which looked for a
|
|
795
|
+
* colour called `1`, found none, and returned null for all three of theirs. Its colour
|
|
796
|
+
* already reads as `outlineColor`, so the width belongs on the same family; a bare
|
|
797
|
+
* `ring` is 3px, Tailwind's own default.
|
|
798
|
+
*/
|
|
799
|
+
if (prefix === "ring" && (rest === "" || /^\d+$/.test(rest))) {
|
|
800
|
+
return { property: "outlineWidth", value: `${rest || 3}px` };
|
|
801
|
+
}
|
|
802
|
+
if (core === "ring") {
|
|
803
|
+
return { property: "outlineWidth", value: "3px" };
|
|
804
|
+
}
|
|
536
805
|
const colorProp = COLOR_PROPERTY[prefix];
|
|
537
806
|
if (colorProp) {
|
|
538
807
|
/**
|
|
@@ -677,6 +946,14 @@ function readUtilityCore(utility, declared) {
|
|
|
677
946
|
sizeRest = axis[2];
|
|
678
947
|
}
|
|
679
948
|
}
|
|
949
|
+
/** The same recombination for the axis-suffixed families: `space-y-1`, `inset-x-0`. */
|
|
950
|
+
if (prefix === "space" || prefix === "inset") {
|
|
951
|
+
const axis = /^([xy])-(.+)$/.exec(rest);
|
|
952
|
+
if (axis) {
|
|
953
|
+
sizePrefix = `${prefix}-${axis[1]}`;
|
|
954
|
+
sizeRest = axis[2];
|
|
955
|
+
}
|
|
956
|
+
}
|
|
680
957
|
const spaceProp = SPACING_PROPERTY[sizePrefix];
|
|
681
958
|
if (spaceProp) {
|
|
682
959
|
/** `gap-x-6` / `gap-y-2` - the two halves of gap, which one prefix cannot say. */
|
|
@@ -716,7 +993,87 @@ function readUtilityCore(utility, declared) {
|
|
|
716
993
|
if (MAX_WIDTH[sizeRest] && /^(max-w|max-h)$/.test(sizePrefix)) {
|
|
717
994
|
return { property: spaceProp, value: MAX_WIDTH[sizeRest] };
|
|
718
995
|
}
|
|
996
|
+
/**
|
|
997
|
+
* `top-full`, `inset-0`, `basis-auto` - the words an offset takes and a padding does
|
|
998
|
+
* not. Their Menu positions its popup with `top-full`, and a positioner that does not
|
|
999
|
+
* position is not the component.
|
|
1000
|
+
*/
|
|
1001
|
+
const OFFSET_WORD = {
|
|
1002
|
+
full: "100%",
|
|
1003
|
+
auto: "auto",
|
|
1004
|
+
px: "1px",
|
|
1005
|
+
};
|
|
1006
|
+
if (OFFSET_WORD[sizeRest] &&
|
|
1007
|
+
/^(inset|inset-x|inset-y|top|right|bottom|left|basis)$/.test(sizePrefix)) {
|
|
1008
|
+
return { property: spaceProp, value: OFFSET_WORD[sizeRest] };
|
|
1009
|
+
}
|
|
719
1010
|
}
|
|
1011
|
+
/**
|
|
1012
|
+
* `z-50` - the stacking order, which is the difference between an overlay above the
|
|
1013
|
+
* content and one behind it. Tailwind's own scale is the numbers themselves.
|
|
1014
|
+
*/
|
|
1015
|
+
if (prefix === "z" && (/^\d+$/.test(rest) || rest === "auto")) {
|
|
1016
|
+
return { property: "zIndex", value: rest };
|
|
1017
|
+
}
|
|
1018
|
+
/**
|
|
1019
|
+
* `backdrop-blur-[1px]`, `backdrop-blur-sm` - the frosted layer behind an overlay.
|
|
1020
|
+
* Their loading veil is `bg-white/50 backdrop-blur-[1px]`, and without the blur the
|
|
1021
|
+
* veil is a flat wash.
|
|
1022
|
+
*/
|
|
1023
|
+
const BLUR = {
|
|
1024
|
+
none: "0",
|
|
1025
|
+
sm: "4px",
|
|
1026
|
+
"": "8px",
|
|
1027
|
+
md: "12px",
|
|
1028
|
+
lg: "16px",
|
|
1029
|
+
xl: "24px",
|
|
1030
|
+
};
|
|
1031
|
+
const backdrop = /^backdrop-blur(?:-(.+))?$/.exec(core);
|
|
1032
|
+
if (backdrop) {
|
|
1033
|
+
const arg = backdrop[1] ?? "";
|
|
1034
|
+
const arbitrary = /^\[(.+)\]$/.exec(arg);
|
|
1035
|
+
const value = arbitrary ? arbitrary[1] : BLUR[arg];
|
|
1036
|
+
if (value)
|
|
1037
|
+
return { property: "backdropFilter", value: `blur(${value})` };
|
|
1038
|
+
}
|
|
1039
|
+
const blur = /^blur(?:-(.+))?$/.exec(core);
|
|
1040
|
+
if (blur) {
|
|
1041
|
+
const arg = blur[1] ?? "";
|
|
1042
|
+
const arbitrary = /^\[(.+)\]$/.exec(arg);
|
|
1043
|
+
const value = arbitrary ? arbitrary[1] : BLUR[arg];
|
|
1044
|
+
if (value)
|
|
1045
|
+
return { property: "filter", value: `blur(${value})` };
|
|
1046
|
+
}
|
|
1047
|
+
/**
|
|
1048
|
+
* `rotate-180`, `scale-[0.98]` - a static transform, which is a state and not motion:
|
|
1049
|
+
* their chevron points up when the card is open. Each one lands on `transform` whole,
|
|
1050
|
+
* so two on the same element would overwrite - none of their 3 co-occur, and saying
|
|
1051
|
+
* one of them is better than saying neither.
|
|
1052
|
+
*/
|
|
1053
|
+
const rotate = /^-?rotate-(.+)$/.exec(core);
|
|
1054
|
+
if (rotate) {
|
|
1055
|
+
const arbitrary = /^\[(.+)\]$/.exec(rotate[1]);
|
|
1056
|
+
const deg = arbitrary ? arbitrary[1] : `${rotate[1]}deg`;
|
|
1057
|
+
return {
|
|
1058
|
+
property: "transform",
|
|
1059
|
+
value: `rotate(${core.startsWith("-") ? `-${deg}` : deg})`,
|
|
1060
|
+
};
|
|
1061
|
+
}
|
|
1062
|
+
const scale = /^scale-(.+)$/.exec(core);
|
|
1063
|
+
if (scale) {
|
|
1064
|
+
const arbitrary = /^\[(.+)\]$/.exec(scale[1]);
|
|
1065
|
+
const factor = arbitrary
|
|
1066
|
+
? arbitrary[1]
|
|
1067
|
+
: /^\d+$/.test(scale[1])
|
|
1068
|
+
? String(Number(scale[1]) / 100)
|
|
1069
|
+
: null;
|
|
1070
|
+
if (factor)
|
|
1071
|
+
return { property: "transform", value: `scale(${factor})` };
|
|
1072
|
+
}
|
|
1073
|
+
/** `line-clamp-2` - how many lines survive before the ellipsis. */
|
|
1074
|
+
const clamp = /^line-clamp-(\d+)$/.exec(core);
|
|
1075
|
+
if (clamp)
|
|
1076
|
+
return { property: "WebkitLineClamp", value: clamp[1] };
|
|
720
1077
|
if (prefix === "rounded") {
|
|
721
1078
|
/**
|
|
722
1079
|
* `rounded-t-lg` - one side, two corners. The side names which half; the scale is
|
|
@@ -1239,6 +1596,25 @@ export function transcribe(classes, declared) {
|
|
|
1239
1596
|
// `rounded-t-lg` names two corners: the reader returns them joined with `+`, and
|
|
1240
1597
|
// the write splits - the same shape the `size` expansion above uses.
|
|
1241
1598
|
if (decl.property.includes("+")) {
|
|
1599
|
+
/**
|
|
1600
|
+
* AND WITH ITS OWN VALUE EACH, when one utility means three things.
|
|
1601
|
+
*
|
|
1602
|
+
* `truncate` is `overflow:hidden; text-overflow:ellipsis; white-space:nowrap` - any
|
|
1603
|
+
* one of the three alone paints nothing, so the pair form (same value, two
|
|
1604
|
+
* properties) could not carry it. `prop:value|prop:value` says both halves.
|
|
1605
|
+
*/
|
|
1606
|
+
if (decl.value.includes("|")) {
|
|
1607
|
+
for (const pair of decl.value.split("|")) {
|
|
1608
|
+
const [property, value] = pair.split(":");
|
|
1609
|
+
if (property && value && target[property] == null)
|
|
1610
|
+
target[property] = value;
|
|
1611
|
+
}
|
|
1612
|
+
if (decl.token)
|
|
1613
|
+
out.fromToken += 1;
|
|
1614
|
+
else
|
|
1615
|
+
out.fromLiteral += 1;
|
|
1616
|
+
continue;
|
|
1617
|
+
}
|
|
1242
1618
|
for (const property of decl.property.split("+")) {
|
|
1243
1619
|
if (target[property] == null)
|
|
1244
1620
|
target[property] = decl.value;
|
|
@@ -1249,6 +1625,11 @@ export function transcribe(classes, declared) {
|
|
|
1249
1625
|
out.fromLiteral += 1;
|
|
1250
1626
|
continue;
|
|
1251
1627
|
}
|
|
1628
|
+
if (decl.keyframe) {
|
|
1629
|
+
out.animations ??= [];
|
|
1630
|
+
if (!out.animations.includes(decl.keyframe))
|
|
1631
|
+
out.animations.push(decl.keyframe);
|
|
1632
|
+
}
|
|
1252
1633
|
if (target[decl.property] != null)
|
|
1253
1634
|
continue;
|
|
1254
1635
|
target[decl.property] = decl.value;
|
package/package.json
CHANGED