synthesisui 0.16.111 → 0.16.113
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-from-sketch.js +23 -5
- package/dist/anatomy-read.js +51 -4
- package/dist/commands/import.js +38 -9
- package/dist/doctor/resting.js +225 -0
- package/dist/doctor/sketch.js +99 -0
- package/dist/doctor/transcribe.js +37 -3
- package/dist/library-structure.js +162 -0
- package/package.json +1 -1
|
@@ -308,12 +308,30 @@ versionOf) {
|
|
|
308
308
|
*/
|
|
309
309
|
const local = capitalised && !node.from;
|
|
310
310
|
/**
|
|
311
|
-
* A WRAPPER THAT RENDERS NOTHING
|
|
312
|
-
*
|
|
313
|
-
*
|
|
311
|
+
* A WRAPPER THAT RENDERS NOTHING DRAWS NOTHING AND IS STILL REQUIRED.
|
|
312
|
+
*
|
|
313
|
+
* A portal teleports and a provider is context, so keeping it as a box would draw
|
|
314
|
+
* something the client's output does not have. But `Collapsible.Panel`,
|
|
315
|
+
* `Dialog.Portal` and `Select.Positioner` are what make the component WORK, and the
|
|
316
|
+
* usage snippet was leaving them out: 27 of them across 8 of their components, so
|
|
317
|
+
* somebody pasting the snippet got a card that never opens (dono, 03/08).
|
|
318
|
+
*
|
|
319
|
+
* So it keeps its place in the tree with no `name` - no part, no styles, nothing for
|
|
320
|
+
* the Canvas to draw, and an element the snippet emits. A motion wrapper stays
|
|
321
|
+
* flattened: it is animation, and its element is genuinely absent from the output.
|
|
314
322
|
*/
|
|
315
|
-
if (
|
|
316
|
-
|
|
323
|
+
if (kind === "headless" && rendersNothing(tag, classes)) {
|
|
324
|
+
const held = children.flatMap((child) => build(child, depth + 1));
|
|
325
|
+
return [
|
|
326
|
+
{
|
|
327
|
+
as: held.length > 0 ? "stack" : "text",
|
|
328
|
+
from: String(node.from ?? tag),
|
|
329
|
+
refName: tag,
|
|
330
|
+
...(held.length > 0 ? { children: held } : {}),
|
|
331
|
+
},
|
|
332
|
+
];
|
|
333
|
+
}
|
|
334
|
+
if (kind === "motion" && rendersNothing(tag, classes)) {
|
|
317
335
|
return children.flatMap((child) => build(child, depth));
|
|
318
336
|
}
|
|
319
337
|
// `<AnimatePresence>` and friends: no element of their own.
|
package/dist/anatomy-read.js
CHANGED
|
@@ -265,6 +265,25 @@ sketch) {
|
|
|
265
265
|
continue;
|
|
266
266
|
}
|
|
267
267
|
const node_ = { as };
|
|
268
|
+
/**
|
|
269
|
+
* THE GUARD TRAVELS WITH THE NODE, off the sketch it points at - so neither a
|
|
270
|
+
* reading nor the deriver has to carry it, and both get it.
|
|
271
|
+
*/
|
|
272
|
+
if (typeof node.at === "number") {
|
|
273
|
+
const measured = sketch?.[node.at];
|
|
274
|
+
const when = node.when ?? measured?.when;
|
|
275
|
+
if (when)
|
|
276
|
+
node_.when = when;
|
|
277
|
+
const atRest = node.atRest ?? measured?.atRest;
|
|
278
|
+
if (atRest === false)
|
|
279
|
+
node_.atRest = false;
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
if (node.when)
|
|
283
|
+
node_.when = node.when;
|
|
284
|
+
if (node.atRest === false)
|
|
285
|
+
node_.atRest = false;
|
|
286
|
+
}
|
|
268
287
|
/**
|
|
269
288
|
* A PART THAT IS A LIBRARY ELEMENT CARRIES BOTH FACTS - see `AnatomyRead.from`.
|
|
270
289
|
* Inert for the renderer, which dispatches on `as`; read by the usage snippet,
|
|
@@ -395,7 +414,34 @@ sketch) {
|
|
|
395
414
|
? (frontierClasses(rootObj, sketch).classes ??
|
|
396
415
|
"")
|
|
397
416
|
: "";
|
|
398
|
-
|
|
417
|
+
/**
|
|
418
|
+
* THE OVERRIDE TRAVELS AS DECLARATIONS, AND ONLY WHEN IT IS AN OVERRIDE.
|
|
419
|
+
*
|
|
420
|
+
* Two different things were sharing one field, and both were broken by it.
|
|
421
|
+
*
|
|
422
|
+
* When the root is a FRONTIER of theirs, the class string at the call site is what
|
|
423
|
+
* the PARENT paints on it - `MetricCard` returns `<Card className="flex-1
|
|
424
|
+
* dark:bg-darkgray-300">` - and this is the whole reason the field exists. It was
|
|
425
|
+
* sent as the class NAMES, so the platform pasted `dark:bg-darkgray-300` onto its own
|
|
426
|
+
* element and the browser resolved it against OUR Tailwind, which has no such colour:
|
|
427
|
+
* the override did nothing, on 5 of 5 of their components (dono, 03/08). Transcribed,
|
|
428
|
+
* it is a declaration that applies anywhere.
|
|
429
|
+
*
|
|
430
|
+
* When the root is one we DRAW - a headless primitive, a plain element - the classes
|
|
431
|
+
* at that index are the component's OWN look and are already in `base`. Sending them
|
|
432
|
+
* again put their raw `bg-white` on our element, in a layer that outranks the compiled
|
|
433
|
+
* recipe, and two of their cards previewed white in dark mode over a recipe that said
|
|
434
|
+
* otherwise. So a drawn root sends nothing here at all.
|
|
435
|
+
*/
|
|
436
|
+
const rootStyle = rootPainted
|
|
437
|
+
? transcribe(rootPainted.split(/\s+/).filter(Boolean), declared)
|
|
438
|
+
: null;
|
|
439
|
+
const paints = rootStyle && Object.keys(rootStyle.base).length > 0
|
|
440
|
+
? { style: rootStyle.base }
|
|
441
|
+
: {};
|
|
442
|
+
const paintsDark = rootStyle && Object.keys(rootStyle.dark).length > 0
|
|
443
|
+
? { dark: rootStyle.dark }
|
|
444
|
+
: {};
|
|
399
445
|
/**
|
|
400
446
|
* A LIBRARY ROOT WHOSE NAME IS NOT DOTTED.
|
|
401
447
|
*
|
|
@@ -427,7 +473,8 @@ sketch) {
|
|
|
427
473
|
const looksLikeTag = sent !== "" && /^[A-Z]/.test(sent);
|
|
428
474
|
const rootFrom = looksLikeTag ? (rootPackage ?? "") : sent;
|
|
429
475
|
if (rootFrom) {
|
|
430
|
-
|
|
476
|
+
// A root we draw carries no override: its classes are the component's own.
|
|
477
|
+
rootOut = { from: rootFrom };
|
|
431
478
|
}
|
|
432
479
|
else if (rootRaw && /^[A-Z]/.test(rootRaw)) {
|
|
433
480
|
if (!rootRaw.includes(".")) {
|
|
@@ -439,10 +486,10 @@ sketch) {
|
|
|
439
486
|
*/
|
|
440
487
|
const slug = resolve?.(rootRaw) ?? safePartName(rootRaw);
|
|
441
488
|
if (slug)
|
|
442
|
-
rootOut = { ref: slug, name: rootRaw, ...paints };
|
|
489
|
+
rootOut = { ref: slug, name: rootRaw, ...paints, ...paintsDark };
|
|
443
490
|
}
|
|
444
491
|
else if (rootPackage) {
|
|
445
|
-
rootOut = { from: rootPackage
|
|
492
|
+
rootOut = { from: rootPackage };
|
|
446
493
|
}
|
|
447
494
|
}
|
|
448
495
|
/**
|
package/dist/commands/import.js
CHANGED
|
@@ -24,6 +24,7 @@ import { sketchOf } from "../doctor/sketch.js";
|
|
|
24
24
|
import { buildTable } from "../doctor/tokens.js";
|
|
25
25
|
import { readInlineStyle, rootClasses, rootTag, transcribe, } from "../doctor/transcribe.js";
|
|
26
26
|
import { transcribeVariants } from "../doctor/variant-read.js";
|
|
27
|
+
import { withLibraryStructure } from "../library-structure.js";
|
|
27
28
|
import { body, paint, section } from "../output.js";
|
|
28
29
|
import { phase, startProgress } from "../progress.js";
|
|
29
30
|
import { detectStack, resolveDeps } from "../stack.js";
|
|
@@ -1793,8 +1794,22 @@ async function resolveReadParts(census, root) {
|
|
|
1793
1794
|
for (const note of derived.notes)
|
|
1794
1795
|
notes.add(note);
|
|
1795
1796
|
}
|
|
1796
|
-
|
|
1797
|
-
|
|
1797
|
+
/**
|
|
1798
|
+
* THE LIBRARY'S OWN FRAME, PUT BACK BEFORE ANYTHING IS RESOLVED.
|
|
1799
|
+
*
|
|
1800
|
+
* A reading names what carries appearance; a headless library's `Portal`,
|
|
1801
|
+
* `Positioner` and `Panel` carry none and are what make the component work. 27 of
|
|
1802
|
+
* them across 8 of their components were named by nobody, so the usage snippet
|
|
1803
|
+
* handed people a card that never opens (dono, 03/08). See `withLibraryStructure` -
|
|
1804
|
+
* it is a lookup in the sketch, not a guess, and it is idempotent.
|
|
1805
|
+
*/
|
|
1806
|
+
const framed = Array.isArray(anatomy) && anatomy.length > 0
|
|
1807
|
+
? withLibraryStructure(anatomy, looks[component]?.sketch, typeof entry?.root?.at === "number"
|
|
1808
|
+
? (entry?.root).at
|
|
1809
|
+
: undefined)
|
|
1810
|
+
: anatomy;
|
|
1811
|
+
const resolved = Array.isArray(framed) && framed.length > 0
|
|
1812
|
+
? resolveAnatomy(framed, declared, deps, resolveRef, entry?.root,
|
|
1798
1813
|
// The component's own sketch, so a node naming itself by index reads
|
|
1799
1814
|
// the exact class string the census measured (dono, 01/08).
|
|
1800
1815
|
looks[component]?.sketch)
|
|
@@ -1851,20 +1866,34 @@ async function resolveReadParts(census, root) {
|
|
|
1851
1866
|
const split = placeLayers((look?.layers ?? []), partAt, (look?.sketch ?? []));
|
|
1852
1867
|
for (const line of split.unplaced)
|
|
1853
1868
|
notes.add(line);
|
|
1854
|
-
/**
|
|
1869
|
+
/**
|
|
1870
|
+
* REPLACED, NOT APPENDED - because this runs more than once.
|
|
1871
|
+
*
|
|
1872
|
+
* A measuring pass places these onto the DERIVED parts (`box-2`); the send pass
|
|
1873
|
+
* places them onto the AUTHORED ones (`status-dot`). Appending would accumulate a
|
|
1874
|
+
* generation per run, and the pruning below drops the derived names anyway.
|
|
1875
|
+
*/
|
|
1855
1876
|
for (const [part, list] of split.perPart) {
|
|
1856
1877
|
const target = resolved.parts[part];
|
|
1857
1878
|
if (!target || !list)
|
|
1858
1879
|
continue;
|
|
1859
|
-
target.layers =
|
|
1860
|
-
...(target.layers ?? []),
|
|
1861
|
-
...list,
|
|
1862
|
-
];
|
|
1880
|
+
target.layers = list;
|
|
1863
1881
|
}
|
|
1864
1882
|
const carried = {
|
|
1865
1883
|
...(hasParts ? { parts: resolved.parts } : {}),
|
|
1866
|
-
/**
|
|
1867
|
-
|
|
1884
|
+
/**
|
|
1885
|
+
* THE FULL LIST STAYS ON THE LOOK, EACH LAYER KEEPING ITS `on`.
|
|
1886
|
+
*
|
|
1887
|
+
* Writing back only the root's was destructive, and this function runs twice: the
|
|
1888
|
+
* measuring pass moved 8 of their status layers onto `box-2` and wrote the
|
|
1889
|
+
* remaining 2 back, then the send pass re-read those 2, had nothing to place, and
|
|
1890
|
+
* the one-generation pruning dropped `box-2` - so the four status colours of their
|
|
1891
|
+
* dot and label reached zero recipes, on 36 of 36 components (dono, 03/08).
|
|
1892
|
+
*
|
|
1893
|
+
* Keeping the source of truth here makes the split idempotent: every pass
|
|
1894
|
+
* recomputes both halves from the same list. The RECIPE builder is what leaves an
|
|
1895
|
+
* attributed layer off the component - see `safeLayers`.
|
|
1896
|
+
*/
|
|
1868
1897
|
...(resolved.tree.length > 0 ? { tree: resolved.tree } : {}),
|
|
1869
1898
|
...(resolved.composes.length > 0 ? { composes: resolved.composes } : {}),
|
|
1870
1899
|
...(libraries.length > 0 ? { external: libraries } : {}),
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IS THIS ELEMENT THERE WHEN NOBODY HAS TOUCHED THE COMPONENT?
|
|
3
|
+
*
|
|
4
|
+
* Their markup guards elements with conditions - `{isLoading && (…)}`,
|
|
5
|
+
* `{status === "success" && (…)}` - and the preview drew every one of them. So a card
|
|
6
|
+
* that is not loading wore a loading veil across its whole surface, and a status that is
|
|
7
|
+
* `waiting` showed the ping that belongs to `success` (dono, 03/08).
|
|
8
|
+
*
|
|
9
|
+
* ANSWERED FROM THEIR OWN DECLARATIONS, or not answered at all. Three facts, all
|
|
10
|
+
* measured in their source and none of them ours:
|
|
11
|
+
*
|
|
12
|
+
* a prop's default `{ status = "waiting" }`
|
|
13
|
+
* a local alias `const isLoading = status === "loading"`
|
|
14
|
+
* a state's first value `useState(defaultOpen)` → the default of `defaultOpen`
|
|
15
|
+
*
|
|
16
|
+
* `null` means the condition cannot be settled - `{tag && <Tag/>}` on a component whose
|
|
17
|
+
* `tag` has no default is optional CONTENT, and drawing it is right: 56 of their 79
|
|
18
|
+
* guards are that shape, and hiding them would empty the previews out. Only a condition
|
|
19
|
+
* that resolves to FALSE takes an element off the resting view, and the blueprint says
|
|
20
|
+
* every one of them out loud either way.
|
|
21
|
+
*/
|
|
22
|
+
const STATE = /const\s*\[\s*([A-Za-z_$][\w$]*)\s*,[^\]]*\]\s*=\s*(?:React\.)?useState[^(]*\(\s*([^)]*?)\s*\)/g;
|
|
23
|
+
const ALIAS = /const\s+([A-Za-z_$][\w$]*)\s*(?::[^=]+)?=\s*([^;\n]+);/g;
|
|
24
|
+
/**
|
|
25
|
+
* The aliases and first-render state values a component declares about itself.
|
|
26
|
+
*
|
|
27
|
+
* A state read is honest here for one reason: its INITIAL value is what "at rest" means.
|
|
28
|
+
* `useState(defaultOpen)` with `defaultOpen = false` says a collapsible card starts
|
|
29
|
+
* closed, which is a fact about their component rather than a guess about a user.
|
|
30
|
+
*/
|
|
31
|
+
/**
|
|
32
|
+
* EVERY DEFAULT THE DESTRUCTURING DECLARES, not only the string ones.
|
|
33
|
+
*
|
|
34
|
+
* `readDefinitionProps` reads string literals on purpose - it answers "which option is
|
|
35
|
+
* the resting one", and a boolean is not an option. Here a boolean is the whole answer:
|
|
36
|
+
* `{ defaultOpen = false }` is what says a collapsible card starts closed.
|
|
37
|
+
*/
|
|
38
|
+
function paramDefaults(body) {
|
|
39
|
+
const open = body.indexOf("({");
|
|
40
|
+
if (open === -1)
|
|
41
|
+
return {};
|
|
42
|
+
let depth = 0;
|
|
43
|
+
let end = -1;
|
|
44
|
+
for (let i = open + 1; i < body.length; i++) {
|
|
45
|
+
const c = body[i];
|
|
46
|
+
if (c === "{")
|
|
47
|
+
depth++;
|
|
48
|
+
else if (c === "}") {
|
|
49
|
+
depth--;
|
|
50
|
+
if (depth === 0) {
|
|
51
|
+
end = i;
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (end === -1)
|
|
57
|
+
return {};
|
|
58
|
+
const out = {};
|
|
59
|
+
let depth2 = 0;
|
|
60
|
+
let part = "";
|
|
61
|
+
const take = () => {
|
|
62
|
+
const eq = part.indexOf("=");
|
|
63
|
+
if (eq > 0) {
|
|
64
|
+
const key = part.slice(0, eq).split(":")[0].trim();
|
|
65
|
+
const value = part.slice(eq + 1).trim();
|
|
66
|
+
if (/^[A-Za-z_$][\w$]*$/.test(key) && value)
|
|
67
|
+
out[key] = value;
|
|
68
|
+
}
|
|
69
|
+
part = "";
|
|
70
|
+
};
|
|
71
|
+
for (const c of body.slice(open + 2, end)) {
|
|
72
|
+
if ("([{".includes(c))
|
|
73
|
+
depth2++;
|
|
74
|
+
else if (")]}".includes(c))
|
|
75
|
+
depth2--;
|
|
76
|
+
if (c === "," && depth2 === 0)
|
|
77
|
+
take();
|
|
78
|
+
else
|
|
79
|
+
part += c;
|
|
80
|
+
}
|
|
81
|
+
take();
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
export function restingFacts(body, defaults) {
|
|
85
|
+
const aliases = {};
|
|
86
|
+
ALIAS.lastIndex = 0;
|
|
87
|
+
for (const m of body.matchAll(ALIAS)) {
|
|
88
|
+
const value = m[2].trim();
|
|
89
|
+
// A function or a component is not a condition; only value-shaped reads help.
|
|
90
|
+
if (/=>|\bfunction\b/.test(value))
|
|
91
|
+
continue;
|
|
92
|
+
if (!aliases[m[1]])
|
|
93
|
+
aliases[m[1]] = value;
|
|
94
|
+
}
|
|
95
|
+
STATE.lastIndex = 0;
|
|
96
|
+
for (const m of body.matchAll(STATE)) {
|
|
97
|
+
if (!aliases[m[1]])
|
|
98
|
+
aliases[m[1]] = m[2].trim() || "undefined";
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* THE DESTRUCTURING WINS, because it kept the quotes.
|
|
102
|
+
*
|
|
103
|
+
* `readDefinitionProps` hands back `waiting` already unquoted, and an unquoted word is
|
|
104
|
+
* indistinguishable from a reference to something else - so `status === "success"` went
|
|
105
|
+
* unsettled while the answer sat in the source. Read verbatim, `"waiting"` is a value
|
|
106
|
+
* and `defaultOpen` is a name, and the shape says which.
|
|
107
|
+
*/
|
|
108
|
+
return { defaults: { ...defaults, ...paramDefaults(body) }, aliases };
|
|
109
|
+
}
|
|
110
|
+
const LITERAL = /^(?:"([^"]*)"|'([^']*)'|`([^`]*)`)$/;
|
|
111
|
+
/** A value their code wrote, as the string a comparison would see. */
|
|
112
|
+
function literalOf(text) {
|
|
113
|
+
const t = text.trim();
|
|
114
|
+
const m = LITERAL.exec(t);
|
|
115
|
+
if (m)
|
|
116
|
+
return m[1] ?? m[2] ?? m[3] ?? "";
|
|
117
|
+
if (t === "true" || t === "false" || t === "undefined" || t === "null")
|
|
118
|
+
return t;
|
|
119
|
+
if (/^-?\d+(\.\d+)?$/.test(t))
|
|
120
|
+
return t;
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
/** Split on a top-level operator, so `a && (b || c)` reads as two halves. */
|
|
124
|
+
function splitTop(text, op) {
|
|
125
|
+
let depth = 0;
|
|
126
|
+
for (let i = 0; i < text.length - 1; i++) {
|
|
127
|
+
const c = text[i];
|
|
128
|
+
if (c === "(" || c === "[" || c === "{")
|
|
129
|
+
depth++;
|
|
130
|
+
else if (c === ")" || c === "]" || c === "}")
|
|
131
|
+
depth--;
|
|
132
|
+
else if (depth === 0 && text.startsWith(op, i))
|
|
133
|
+
return [text.slice(0, i), text.slice(i + 2)];
|
|
134
|
+
}
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* True, false, or `null` when their own declarations do not settle it.
|
|
139
|
+
*
|
|
140
|
+
* `seen` guards a chain that refers back to itself - `const a = b; const b = a` is not
|
|
141
|
+
* something to crash on.
|
|
142
|
+
*/
|
|
143
|
+
export function holdsAtRest(condition, facts, seen = new Set()) {
|
|
144
|
+
const text = condition.trim();
|
|
145
|
+
if (!text)
|
|
146
|
+
return null;
|
|
147
|
+
if (text.startsWith("(") && text.endsWith(")")) {
|
|
148
|
+
const inner = text.slice(1, -1);
|
|
149
|
+
if (splitTop(inner, "&&") || splitTop(inner, "||") || !/[()]/.test(inner))
|
|
150
|
+
return holdsAtRest(inner, facts, seen);
|
|
151
|
+
}
|
|
152
|
+
const and = splitTop(text, "&&");
|
|
153
|
+
if (and) {
|
|
154
|
+
const [a, b] = [
|
|
155
|
+
holdsAtRest(and[0], facts, seen),
|
|
156
|
+
holdsAtRest(and[1], facts, seen),
|
|
157
|
+
];
|
|
158
|
+
if (a === false || b === false)
|
|
159
|
+
return false;
|
|
160
|
+
return a === true && b === true ? true : null;
|
|
161
|
+
}
|
|
162
|
+
const or = splitTop(text, "||");
|
|
163
|
+
if (or) {
|
|
164
|
+
const [a, b] = [
|
|
165
|
+
holdsAtRest(or[0], facts, seen),
|
|
166
|
+
holdsAtRest(or[1], facts, seen),
|
|
167
|
+
];
|
|
168
|
+
if (a === true || b === true)
|
|
169
|
+
return true;
|
|
170
|
+
return a === false && b === false ? false : null;
|
|
171
|
+
}
|
|
172
|
+
if (text.startsWith("!")) {
|
|
173
|
+
const inner = holdsAtRest(text.slice(1), facts, seen);
|
|
174
|
+
return inner === null ? null : !inner;
|
|
175
|
+
}
|
|
176
|
+
const cmp = /^(.+?)\s*(===|!==|==|!=)\s*(.+)$/.exec(text);
|
|
177
|
+
if (cmp) {
|
|
178
|
+
const left = restingValue(cmp[1], facts, seen);
|
|
179
|
+
const right = literalOf(cmp[3]) ?? restingValue(cmp[3], facts, seen);
|
|
180
|
+
if (left === null || right === null)
|
|
181
|
+
return null;
|
|
182
|
+
const same = left === right;
|
|
183
|
+
return cmp[2].startsWith("!") ? !same : same;
|
|
184
|
+
}
|
|
185
|
+
const value = restingValue(text, facts, seen);
|
|
186
|
+
if (value === null)
|
|
187
|
+
return null;
|
|
188
|
+
return (value !== "false" &&
|
|
189
|
+
value !== "undefined" &&
|
|
190
|
+
value !== "null" &&
|
|
191
|
+
value !== "");
|
|
192
|
+
}
|
|
193
|
+
/** What a name holds at rest, as a literal string, or null when nothing says. */
|
|
194
|
+
function restingValue(name, facts, seen) {
|
|
195
|
+
const key = name.trim();
|
|
196
|
+
if (!/^[A-Za-z_$][\w$]*$/.test(key) || seen.has(key))
|
|
197
|
+
return null;
|
|
198
|
+
const direct = facts.defaults[key];
|
|
199
|
+
if (direct != null) {
|
|
200
|
+
const asLiteral = literalOf(direct);
|
|
201
|
+
if (asLiteral != null)
|
|
202
|
+
return asLiteral;
|
|
203
|
+
/**
|
|
204
|
+
* `readDefinitionProps` hands back `waiting`, already unquoted, while the
|
|
205
|
+
* destructuring reads `defaultOpen` - a NAME to follow. One is a value and the other
|
|
206
|
+
* is a reference, and the shape says which.
|
|
207
|
+
*/
|
|
208
|
+
if (/^[A-Za-z_$][\w$]*$/.test(direct.trim()))
|
|
209
|
+
return restingValue(direct, facts, new Set([...seen, key]));
|
|
210
|
+
return direct.trim();
|
|
211
|
+
}
|
|
212
|
+
const alias = facts.aliases[key];
|
|
213
|
+
if (alias == null)
|
|
214
|
+
return null;
|
|
215
|
+
const next = new Set([...seen, key]);
|
|
216
|
+
const asLiteral = literalOf(alias);
|
|
217
|
+
if (asLiteral != null)
|
|
218
|
+
return asLiteral;
|
|
219
|
+
/**
|
|
220
|
+
* The alias is an expression - `const isLoading = status === "loading"`. Settling it is
|
|
221
|
+
* the same question one level down, and the answer comes back as a boolean.
|
|
222
|
+
*/
|
|
223
|
+
const held = holdsAtRest(alias, facts, next);
|
|
224
|
+
return held === null ? null : String(held);
|
|
225
|
+
}
|
package/dist/doctor/sketch.js
CHANGED
|
@@ -18,8 +18,10 @@
|
|
|
18
18
|
* GENERALIZED BY CONSTRUCTION: it reads JSX shape, not folder layout, so it works the
|
|
19
19
|
* same in an atomic library, a feature-foldered app, or a single flat directory.
|
|
20
20
|
*/
|
|
21
|
+
import { readDefinitionProps } from "./call-sites.js";
|
|
21
22
|
import { scanTags } from "./css-modules.js";
|
|
22
23
|
import { importMap, packageOfTag } from "./imports.js";
|
|
24
|
+
import { holdsAtRest, restingFacts } from "./resting.js";
|
|
23
25
|
import { definitionSpan, returnAt } from "./transcribe.js";
|
|
24
26
|
/** `{children}` / `{props.children}` - the caller's content, spelled either way. */
|
|
25
27
|
const CHILDREN = /\{\s*(?:props\.)?children\s*\}/;
|
|
@@ -160,6 +162,87 @@ function staticCallClasses(body) {
|
|
|
160
162
|
* sitting directly between an open and its close - dynamic children are the caller's,
|
|
161
163
|
* and the slot form already says so.
|
|
162
164
|
*/
|
|
165
|
+
/**
|
|
166
|
+
* THE CONDITION THAT HAS TO HOLD FOR THIS ELEMENT TO EXIST.
|
|
167
|
+
*
|
|
168
|
+
* Their code guards markup with `{isLoading && (…)}` and `{imageUrl ? <img/> : <box/>}`,
|
|
169
|
+
* and the sketch recorded the element without the guard - so the preview drew a loading
|
|
170
|
+
* veil over a card that is not loading and a status ping for a status it does not have
|
|
171
|
+
* (dono, 03/08). Measured on their library: 76 guards across 22 of 36 components.
|
|
172
|
+
*
|
|
173
|
+
* VERBATIM, in their own vocabulary. This is not a condition we evaluate; it is a fact
|
|
174
|
+
* about their markup that the screen can say out loud ("only when `isLoading`"). The
|
|
175
|
+
* falsy branch of a ternary reads as the negation of the same expression, which is their
|
|
176
|
+
* logic restated rather than anything of ours.
|
|
177
|
+
*/
|
|
178
|
+
export function guardAt(source, at) {
|
|
179
|
+
let i = at - 1;
|
|
180
|
+
const skip = () => {
|
|
181
|
+
while (i >= 0 && /\s/.test(source[i]))
|
|
182
|
+
i--;
|
|
183
|
+
};
|
|
184
|
+
skip();
|
|
185
|
+
// `{cond && (` and `{cond ? (` both put a paren between the operator and the tag.
|
|
186
|
+
if (source[i] === "(") {
|
|
187
|
+
i--;
|
|
188
|
+
skip();
|
|
189
|
+
}
|
|
190
|
+
let negated = false;
|
|
191
|
+
if (source[i] === "&" && source[i - 1] === "&")
|
|
192
|
+
i -= 2;
|
|
193
|
+
else if (source[i] === "?")
|
|
194
|
+
i -= 1;
|
|
195
|
+
else if (source[i] === ":") {
|
|
196
|
+
// The ELSE half of `cond ? <a/> : <b/>` - it exists when the condition does not.
|
|
197
|
+
negated = true;
|
|
198
|
+
i -= 1;
|
|
199
|
+
// Walk back over the truthy branch to reach the `?`.
|
|
200
|
+
let depth = 0;
|
|
201
|
+
while (i >= 0) {
|
|
202
|
+
const c = source[i];
|
|
203
|
+
if (c === ">" || c === ")" || c === "}")
|
|
204
|
+
depth++;
|
|
205
|
+
else if (c === "<" || c === "(" || c === "{") {
|
|
206
|
+
if (depth === 0)
|
|
207
|
+
return null;
|
|
208
|
+
depth--;
|
|
209
|
+
}
|
|
210
|
+
else if (c === "?" && depth === 0)
|
|
211
|
+
break;
|
|
212
|
+
i--;
|
|
213
|
+
}
|
|
214
|
+
if (i < 0 || source[i] !== "?")
|
|
215
|
+
return null;
|
|
216
|
+
i -= 1;
|
|
217
|
+
}
|
|
218
|
+
else
|
|
219
|
+
return null;
|
|
220
|
+
// Back to the `{` that opened the expression, minding nesting on the way.
|
|
221
|
+
let depth = 0;
|
|
222
|
+
const end = i;
|
|
223
|
+
while (i >= 0) {
|
|
224
|
+
const c = source[i];
|
|
225
|
+
if (c === ")" || c === "]" || c === "}")
|
|
226
|
+
depth++;
|
|
227
|
+
else if (c === "(" || c === "[") {
|
|
228
|
+
if (depth === 0)
|
|
229
|
+
return null;
|
|
230
|
+
depth--;
|
|
231
|
+
}
|
|
232
|
+
else if (c === "{") {
|
|
233
|
+
if (depth === 0)
|
|
234
|
+
break;
|
|
235
|
+
depth--;
|
|
236
|
+
}
|
|
237
|
+
i--;
|
|
238
|
+
}
|
|
239
|
+
if (i < 0 || source[i] !== "{")
|
|
240
|
+
return null;
|
|
241
|
+
const cond = source.slice(i + 1, end + 1).trim();
|
|
242
|
+
if (!cond || cond.length > 90 || cond.includes("\n"))
|
|
243
|
+
return null;
|
|
244
|
+
return negated ? `!(${cond})` : cond;
|
|
245
|
+
}
|
|
163
246
|
export function sketchOf(source, name) {
|
|
164
247
|
/**
|
|
165
248
|
* READ THE IMPORTS BEFORE SLICING - they live at the top of the file, and the
|
|
@@ -189,6 +272,14 @@ export function sketchOf(source, name) {
|
|
|
189
272
|
*/
|
|
190
273
|
const ownReturn = returnAt(source, name);
|
|
191
274
|
const rootAt = ownReturn === -1 ? -1 : source.indexOf("<", ownReturn);
|
|
275
|
+
/**
|
|
276
|
+
* WHAT THE COMPONENT DECLARES ABOUT ITSELF, read once for the whole sketch.
|
|
277
|
+
*
|
|
278
|
+
* Against the SLICED source, so a file that declares two components does not lend one
|
|
279
|
+
* of them the other's defaults - `RadioCard.tsx` declares `RadioCard` and
|
|
280
|
+
* `RadioCardGroup`, and that is the same slice the node indices are numbered against.
|
|
281
|
+
*/
|
|
282
|
+
const facts = restingFacts(source, readDefinitionProps(name ?? "", source).defaults);
|
|
192
283
|
const events = scanTags(source);
|
|
193
284
|
const out = [];
|
|
194
285
|
let depth = 0;
|
|
@@ -210,6 +301,14 @@ export function sketchOf(source, name) {
|
|
|
210
301
|
if (pkg)
|
|
211
302
|
node.from = pkg;
|
|
212
303
|
}
|
|
304
|
+
// What has to be true for this element to exist at all - see `guardAt`.
|
|
305
|
+
const guard = guardAt(source, event.at);
|
|
306
|
+
if (guard) {
|
|
307
|
+
node.when = guard;
|
|
308
|
+
// And whether their own defaults settle it - see `holdsAtRest`.
|
|
309
|
+
if (holdsAtRest(guard, facts) === false)
|
|
310
|
+
node.atRest = false;
|
|
311
|
+
}
|
|
213
312
|
const cls = CLASS_ATTR.exec(event.body);
|
|
214
313
|
if (cls?.[1].trim())
|
|
215
314
|
node.classes = cls[1].trim();
|
|
@@ -401,10 +401,44 @@ export function parseClass(cls) {
|
|
|
401
401
|
* `declared` maps a token NAME to its value, so a resolved colour can be
|
|
402
402
|
* reported in their vocabulary rather than as a hex we looked up.
|
|
403
403
|
*/
|
|
404
|
+
/** A hex, or a ref into their colour scale - the two shapes an alpha can wrap. */
|
|
405
|
+
const COLOUR_VALUE = /^(#[0-9a-f]{3,8}|\{color\.[^}]+\})$/i;
|
|
406
|
+
/**
|
|
407
|
+
* `#ffffff` + `50` → `color-mix(in srgb, #ffffff 50%, transparent)`.
|
|
408
|
+
*
|
|
409
|
+
* Left alone when the value is not a colour (`shadow-lg/50` is not one) or when mixing
|
|
410
|
+
* would say nothing (`transparent`, `currentColor`).
|
|
411
|
+
*/
|
|
412
|
+
function withAlpha(d, alpha) {
|
|
413
|
+
if (!alpha || !/^\d{1,3}$/.test(alpha))
|
|
414
|
+
return d;
|
|
415
|
+
const pct = Number(alpha);
|
|
416
|
+
if (pct >= 100 || pct < 0)
|
|
417
|
+
return d;
|
|
418
|
+
if (!COLOUR_VALUE.test(d.value.trim()))
|
|
419
|
+
return d;
|
|
420
|
+
return { ...d, value: `color-mix(in srgb, ${d.value} ${pct}%, transparent)` };
|
|
421
|
+
}
|
|
404
422
|
export function readUtility(utility, declared) {
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
423
|
+
const read = readUtilityCore(utility, declared);
|
|
424
|
+
return read ? withAlpha(read, utility.split("/")[1]) : read;
|
|
425
|
+
}
|
|
426
|
+
function readUtilityCore(utility, declared) {
|
|
427
|
+
/**
|
|
428
|
+
* THE ALPHA TRAVELS, and it took `color-mix` to make that honest.
|
|
429
|
+
*
|
|
430
|
+
* This dropped the modifier on purpose: honouring `bg-white/50` used to mean computing
|
|
431
|
+
* a hex they never wrote, and inventing values is the one thing this reader may not
|
|
432
|
+
* do. But `color-mix(in srgb, X 50%, transparent)` computes nothing - the browser does,
|
|
433
|
+
* at paint time - and a token REF stays a ref inside it, so Token Discipline still sees
|
|
434
|
+
* the token and a re-theme still moves it.
|
|
435
|
+
*
|
|
436
|
+
* Dropping it was not neutral. Their loading veil is `bg-white/50 absolute inset-0`:
|
|
437
|
+
* as an opaque white it covered the whole card, which is the white box the owner kept
|
|
438
|
+
* asking about. Their Modal's backdrop is `bg-darkgray-900/40` - a scrim that became a
|
|
439
|
+
* wall. Measured on their library: 11 of 11 opacity modifiers lost their alpha
|
|
440
|
+
* (dono, 03/08).
|
|
441
|
+
*/
|
|
408
442
|
const [core] = utility.split("/");
|
|
409
443
|
// A bare keyword carries a declaration with no scale behind it, so it is read
|
|
410
444
|
// before the dash split that everything else needs.
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE ELEMENTS THAT MAKE IT WORK, PUT BACK.
|
|
3
|
+
*
|
|
4
|
+
* A reading names the elements that carry APPEARANCE, because that is what a design
|
|
5
|
+
* system is for. A headless library's most important elements carry none: a
|
|
6
|
+
* `Dialog.Portal` teleports, a `Select.Positioner` measures, a `Collapsible.Panel` shows
|
|
7
|
+
* and hides. They have no class list, so nothing of ours styles them - and a reading that
|
|
8
|
+
* skips them is not wrong about the styling, it is just missing the frame.
|
|
9
|
+
*
|
|
10
|
+
* The usage snippet is where that costs something. Measured on the owner's library
|
|
11
|
+
* against the reading his own skill authored (dono, 03/08):
|
|
12
|
+
*
|
|
13
|
+
* 27 library elements with no classes, across 8 components, named by nobody
|
|
14
|
+
* Modal BaseDialog.Root, BaseDialog.Portal
|
|
15
|
+
* Select BaseSelect.Root, BaseSelect.Portal
|
|
16
|
+
* Tooltip BaseTooltip.Provider, .Root, .Portal, .Positioner
|
|
17
|
+
* TextEditor 11 (four Popovers and a Dialog)
|
|
18
|
+
* Menu, ColorPicker, CollapsibleCard, Toggle the rest
|
|
19
|
+
*
|
|
20
|
+
* Somebody pasting that snippet gets a card that never opens. So this puts them back,
|
|
21
|
+
* and it is a LOOKUP rather than a guess: the sketch already recorded every element with
|
|
22
|
+
* its depth and its package, so the chain between two named nodes is a fact about their
|
|
23
|
+
* markup. Nothing is invented - a node re-inserted here carries no name, no part and no
|
|
24
|
+
* styles, only the element and where it came from.
|
|
25
|
+
*
|
|
26
|
+
* A library element that DOES carry classes is left alone: it belongs to whoever named
|
|
27
|
+
* it, and re-inserting it would be a second opinion about a styled element.
|
|
28
|
+
*/
|
|
29
|
+
import { frontierKind } from "./frontier-kind.js";
|
|
30
|
+
/** Index → the index of its parent, or -1. Read off `depth`, which is the nesting. */
|
|
31
|
+
function parents(sketch) {
|
|
32
|
+
const out = [];
|
|
33
|
+
const open = [];
|
|
34
|
+
for (const [i, node] of sketch.entries()) {
|
|
35
|
+
while (open.length > 0 && sketch[open[open.length - 1]].depth >= node.depth)
|
|
36
|
+
open.pop();
|
|
37
|
+
out.push(open.length > 0 ? open[open.length - 1] : -1);
|
|
38
|
+
open.push(i);
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
/** Every `at` the reading already points at, at any depth. */
|
|
43
|
+
function claimed(read, into = new Set()) {
|
|
44
|
+
for (const node of read) {
|
|
45
|
+
if (typeof node.at === "number")
|
|
46
|
+
into.add(node.at);
|
|
47
|
+
if (node.children)
|
|
48
|
+
claimed(node.children, into);
|
|
49
|
+
}
|
|
50
|
+
return into;
|
|
51
|
+
}
|
|
52
|
+
/** Behaviour with no appearance: their element, no class list, nobody's part. */
|
|
53
|
+
function isBehaviour(node) {
|
|
54
|
+
if (!node)
|
|
55
|
+
return false;
|
|
56
|
+
if ((node.classes ?? "").trim())
|
|
57
|
+
return false;
|
|
58
|
+
return frontierKind(node.from) === "headless";
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Put the missing library frame back around the nodes it wraps.
|
|
62
|
+
*
|
|
63
|
+
* `boundary` is the `at` of the nearest ancestor the reading DID name (or -1 at the top),
|
|
64
|
+
* so the chain walked is exactly what sits between one named element and the next.
|
|
65
|
+
*/
|
|
66
|
+
function wrap(read, sketch, parentOf, named, boundary,
|
|
67
|
+
/**
|
|
68
|
+
* The library elements already standing on this path, by their own spelling.
|
|
69
|
+
*
|
|
70
|
+
* A restored wrapper carries no index - it is not a node of the reading - so the
|
|
71
|
+
* `named` set cannot see it, and without this a second run wrapped the same portal
|
|
72
|
+
* again. That matters because the derived anatomy already keeps these, and both
|
|
73
|
+
* anatomies go through here.
|
|
74
|
+
*/
|
|
75
|
+
wrapped) {
|
|
76
|
+
/** The missing library ancestors of `at`, outermost first. */
|
|
77
|
+
const chain = (at) => {
|
|
78
|
+
const out = [];
|
|
79
|
+
for (let p = parentOf[at]; p >= 0 && p !== boundary; p = parentOf[p]) {
|
|
80
|
+
if (named.has(p) || wrapped.has(sketch[p].tag))
|
|
81
|
+
continue;
|
|
82
|
+
if (isBehaviour(sketch[p]))
|
|
83
|
+
out.unshift(p);
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
};
|
|
87
|
+
const deep = read.map((node) => {
|
|
88
|
+
if (!node.children || node.children.length === 0)
|
|
89
|
+
return node;
|
|
90
|
+
const inside = node.refName ? new Set([...wrapped, node.refName]) : wrapped;
|
|
91
|
+
const inner = typeof node.at === "number"
|
|
92
|
+
? wrap(node.children, sketch, parentOf, named, node.at, inside)
|
|
93
|
+
: wrap(node.children, sketch, parentOf, named, boundary, inside);
|
|
94
|
+
return { ...node, children: inner };
|
|
95
|
+
});
|
|
96
|
+
/**
|
|
97
|
+
* SIBLINGS THAT SHARE A WRAPPER SHARE ONE. Their Select puts a trigger and a popup
|
|
98
|
+
* inside one `BaseSelect.Root`; two wrappers would say their code has two.
|
|
99
|
+
*/
|
|
100
|
+
const out = [];
|
|
101
|
+
let run = null;
|
|
102
|
+
const flush = () => {
|
|
103
|
+
if (!run)
|
|
104
|
+
return;
|
|
105
|
+
let built = run.nodes;
|
|
106
|
+
for (const at of [...run.keys].reverse()) {
|
|
107
|
+
const node = sketch[at];
|
|
108
|
+
built = [
|
|
109
|
+
{
|
|
110
|
+
as: "stack",
|
|
111
|
+
from: String(node.from ?? node.tag),
|
|
112
|
+
refName: node.tag,
|
|
113
|
+
children: built,
|
|
114
|
+
},
|
|
115
|
+
];
|
|
116
|
+
}
|
|
117
|
+
out.push(...built);
|
|
118
|
+
run = null;
|
|
119
|
+
};
|
|
120
|
+
for (const node of deep) {
|
|
121
|
+
const keys = typeof node.at === "number" ? chain(node.at) : [];
|
|
122
|
+
if (keys.length === 0) {
|
|
123
|
+
flush();
|
|
124
|
+
out.push(node);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (run && run.keys.join(",") === keys.join(","))
|
|
128
|
+
run.nodes.push(node);
|
|
129
|
+
else {
|
|
130
|
+
flush();
|
|
131
|
+
run = { keys, nodes: [node] };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
flush();
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* The reading with the library's own structure restored around it.
|
|
139
|
+
*
|
|
140
|
+
* Idempotent: an element the reading already names is never re-inserted, so running this
|
|
141
|
+
* over a derived anatomy - which keeps them already - changes nothing.
|
|
142
|
+
*/
|
|
143
|
+
export function withLibraryStructure(read, sketch,
|
|
144
|
+
/** The `at` the reading gave for the element the component RETURNS, when it gave one. */
|
|
145
|
+
rootAt) {
|
|
146
|
+
if (!sketch || sketch.length === 0 || read.length === 0)
|
|
147
|
+
return read;
|
|
148
|
+
/**
|
|
149
|
+
* THE ELEMENT THE COMPONENT RETURNS IS THE BOUNDARY, and it is not always given.
|
|
150
|
+
*
|
|
151
|
+
* A reading may name its root by NAME alone (`"root": "BaseDialog.Root"`), and then
|
|
152
|
+
* the walk climbed past it and restored the root as a wrapper INSIDE the anatomy -
|
|
153
|
+
* so the snippet would open `<BaseDialog.Root>` twice. The sketch stamps the returned
|
|
154
|
+
* element, so the answer is measured either way.
|
|
155
|
+
*/
|
|
156
|
+
const stamped = sketch.findIndex((node) => node.root);
|
|
157
|
+
const from = typeof rootAt === "number" ? rootAt : stamped >= 0 ? stamped : -1;
|
|
158
|
+
const named = claimed(read);
|
|
159
|
+
if (from >= 0)
|
|
160
|
+
named.add(from);
|
|
161
|
+
return wrap(read, sketch, parents(sketch), named, from, new Set());
|
|
162
|
+
}
|
package/package.json
CHANGED