synthesisui 0.16.118 → 0.16.120
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 +12 -2
- package/dist/commands/import.js +12 -3
- package/dist/doctor/public-api.js +13 -9
- package/dist/doctor/sketch.js +52 -1
- package/dist/guide.js +143 -1
- package/package.json +1 -1
package/dist/anatomy-read.js
CHANGED
|
@@ -310,13 +310,23 @@ sketch) {
|
|
|
310
310
|
* and stopped at, and a plain `div` is nobody's element.
|
|
311
311
|
*/
|
|
312
312
|
const measured = sketch?.[node.at];
|
|
313
|
+
const kind = measured?.from ? frontierKind(measured.from) : "own";
|
|
314
|
+
/**
|
|
315
|
+
* A MOTION ELEMENT IS THEIRS TO NAME TOO. `motion.div` is the element their panel
|
|
316
|
+
* opens with, and the props that make it open have no CSS to translate to - so the
|
|
317
|
+
* snippet emits their element and their props, verbatim. Headless for the
|
|
318
|
+
* behaviour, motion for the animation; an opaque library is still named and
|
|
319
|
+
* stopped at.
|
|
320
|
+
*/
|
|
313
321
|
if (measured?.from &&
|
|
314
322
|
measured.tag &&
|
|
315
|
-
/^[A-
|
|
316
|
-
|
|
323
|
+
/^[A-Za-z]/.test(measured.tag) &&
|
|
324
|
+
(kind === "headless" || kind === "motion")) {
|
|
317
325
|
node_.from = measured.from;
|
|
318
326
|
node_.refName = measured.tag;
|
|
319
327
|
}
|
|
328
|
+
if (measured?.motionProps)
|
|
329
|
+
node_.motionProps = measured.motionProps;
|
|
320
330
|
}
|
|
321
331
|
// The NAME is what carries the styles, so it is claimed even when nothing
|
|
322
332
|
// resolved: the tree triggers on presence, and structure is most of the
|
package/dist/commands/import.js
CHANGED
|
@@ -1205,7 +1205,7 @@ export async function takeCensus(root, opts) {
|
|
|
1205
1205
|
* the name - 35 of their 36, with `TypingIndicator` internal to `Message` and reported
|
|
1206
1206
|
* as not exported. See `publicApi`.
|
|
1207
1207
|
*/
|
|
1208
|
-
const api = await publicApi(root
|
|
1208
|
+
const api = await publicApi(root);
|
|
1209
1209
|
if (api) {
|
|
1210
1210
|
for (const c of components) {
|
|
1211
1211
|
const file = defined.find((d) => d.name === c.name)?.file;
|
|
@@ -1214,7 +1214,16 @@ export async function takeCensus(root, opts) {
|
|
|
1214
1214
|
: undefined;
|
|
1215
1215
|
const required = source ? requiredProps(source, c.name) : [];
|
|
1216
1216
|
c.api = {
|
|
1217
|
-
|
|
1217
|
+
/**
|
|
1218
|
+
* SILENCE WHEN THE ENTRY WAS NOT READ. An unreadable entry gives an empty export
|
|
1219
|
+
* set, and calling that "internal" would have marked every component internal at
|
|
1220
|
+
* once - see `PublicApi.read`.
|
|
1221
|
+
*/
|
|
1222
|
+
...(!api.read
|
|
1223
|
+
? {}
|
|
1224
|
+
: api.exports.has(c.name)
|
|
1225
|
+
? { from: api.from }
|
|
1226
|
+
: { exported: false }),
|
|
1218
1227
|
/**
|
|
1219
1228
|
* REPO-RELATIVE, so the answer needs no second search. `defined[].file` is
|
|
1220
1229
|
* relative to the scope; joined with the scope it is a path anybody can check
|
|
@@ -1222,7 +1231,7 @@ export async function takeCensus(root, opts) {
|
|
|
1222
1231
|
* walking a monorepo (their component sits six levels below `packages/`, and the
|
|
1223
1232
|
* shallow walk that guards against shadowing never reached it).
|
|
1224
1233
|
*/
|
|
1225
|
-
...(file ? { file: `${scopeLabel}/${file}` } : {}),
|
|
1234
|
+
...(file ? { file: scopeLabel ? `${scopeLabel}/${file}` : file } : {}),
|
|
1226
1235
|
...(required.length > 0 ? { required } : {}),
|
|
1227
1236
|
};
|
|
1228
1237
|
}
|
|
@@ -71,28 +71,32 @@ async function exportsOf(path, source, depth = 0, seen = new Set()) {
|
|
|
71
71
|
return out;
|
|
72
72
|
}
|
|
73
73
|
/**
|
|
74
|
-
* The public API of the folder being
|
|
74
|
+
* The public API of the folder being read, or null when it has no manifest.
|
|
75
75
|
*
|
|
76
|
-
* `
|
|
77
|
-
*
|
|
76
|
+
* `dir` is the folder the census measured - which is ALREADY `<repo>/packages/ui` on a
|
|
77
|
+
* scoped run. Joining the scope on again looked for `packages/ui/packages/ui/package.json`,
|
|
78
|
+
* found nothing and returned null, so the specifier and the required props were absent on
|
|
79
|
+
* all 36 of their components while the reading that needed them sat right there
|
|
80
|
+
* (dono, 04/08).
|
|
81
|
+
*
|
|
82
|
+
* A folder with no `package.json` of its own is not a package and there is no specifier to
|
|
78
83
|
* offer - an app's `src/components` is imported by path, which is theirs to know and not
|
|
79
84
|
* ours to guess.
|
|
80
85
|
*/
|
|
81
|
-
export async function publicApi(
|
|
82
|
-
|
|
83
|
-
return null;
|
|
84
|
-
const manifest = await readFile(join(root, scope, "package.json"), "utf8")
|
|
86
|
+
export async function publicApi(dir) {
|
|
87
|
+
const manifest = await readFile(join(dir, "package.json"), "utf8")
|
|
85
88
|
.then((raw) => JSON.parse(raw))
|
|
86
89
|
.catch(() => null);
|
|
87
90
|
if (!manifest?.name)
|
|
88
91
|
return null;
|
|
89
92
|
const entry = manifest.main ?? manifest.module ?? "src/index.ts";
|
|
90
|
-
const found = await readAt(
|
|
93
|
+
const found = await readAt(dir, entry);
|
|
91
94
|
if (!found)
|
|
92
|
-
return { from: manifest.name, exports: new Set() };
|
|
95
|
+
return { from: manifest.name, exports: new Set(), read: false };
|
|
93
96
|
return {
|
|
94
97
|
from: manifest.name,
|
|
95
98
|
exports: await exportsOf(found[0], found[1]),
|
|
99
|
+
read: true,
|
|
96
100
|
};
|
|
97
101
|
}
|
|
98
102
|
const PROPS_BLOCK = /(?:export\s+)?(?:interface|type)\s+(\w*Props)\s*(?:=\s*)?\{([\s\S]*?)\n\}/;
|
package/dist/doctor/sketch.js
CHANGED
|
@@ -243,6 +243,43 @@ export function guardAt(source, at) {
|
|
|
243
243
|
return null;
|
|
244
244
|
return negated ? `!(${cond})` : cond;
|
|
245
245
|
}
|
|
246
|
+
/** The motion props a tag carries, as their own text. See `SketchNode.motionProps`. */
|
|
247
|
+
const MOTION_PROP = /\b(initial|animate|exit|transition|whileHover|whileTap|whileFocus|whileDrag|layoutId)=\{/g;
|
|
248
|
+
export function motionPropsOf(body) {
|
|
249
|
+
const parts = [];
|
|
250
|
+
MOTION_PROP.lastIndex = 0;
|
|
251
|
+
for (const m of body.matchAll(MOTION_PROP)) {
|
|
252
|
+
const open = (m.index ?? 0) + m[0].length - 1;
|
|
253
|
+
let depth = 0;
|
|
254
|
+
let end = -1;
|
|
255
|
+
for (let i = open; i < body.length; i++) {
|
|
256
|
+
if (body[i] === "{")
|
|
257
|
+
depth++;
|
|
258
|
+
else if (body[i] === "}") {
|
|
259
|
+
depth--;
|
|
260
|
+
if (depth === 0) {
|
|
261
|
+
end = i;
|
|
262
|
+
break;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
if (end === -1)
|
|
267
|
+
continue;
|
|
268
|
+
const value = body
|
|
269
|
+
.slice(open, end + 1)
|
|
270
|
+
.replace(/\s+/g, " ")
|
|
271
|
+
.trim();
|
|
272
|
+
// A value that reaches outside the tag - a variable, a hook, a function - is theirs
|
|
273
|
+
// and copying its NAME would point the snippet at something it cannot see.
|
|
274
|
+
if (!/^\{\s*[[{"'\d]/.test(value))
|
|
275
|
+
continue;
|
|
276
|
+
parts.push(`${m[1]}=${value}`);
|
|
277
|
+
}
|
|
278
|
+
if (parts.length === 0)
|
|
279
|
+
return null;
|
|
280
|
+
const out = parts.join(" ");
|
|
281
|
+
return out.length > 400 ? null : out;
|
|
282
|
+
}
|
|
246
283
|
export function sketchOf(source, name) {
|
|
247
284
|
/**
|
|
248
285
|
* READ THE IMPORTS BEFORE SLICING - they live at the top of the file, and the
|
|
@@ -296,11 +333,25 @@ export function sketchOf(source, name) {
|
|
|
296
333
|
if (rootAt !== -1 && event.at === rootAt)
|
|
297
334
|
node.root = true;
|
|
298
335
|
// What a capitalised tag IS, from the file's own imports - see `SketchNode.from`.
|
|
299
|
-
|
|
336
|
+
/**
|
|
337
|
+
* A DOT IS A NAMESPACE, and a namespace is an import - `motion.div` is lowercase and
|
|
338
|
+
* still comes from `motion/react`. Only capitalised tags were stamped, so the element
|
|
339
|
+
* their animated panel is built on had no package and the snippet emitted a plain
|
|
340
|
+
* `<div>` with their animation props on it (dono, 04/08).
|
|
341
|
+
*/
|
|
342
|
+
if (/^[A-Z]/.test(event.tag) || event.tag.includes(".")) {
|
|
300
343
|
const pkg = packageOfTag(event.tag, imports);
|
|
301
344
|
if (pkg)
|
|
302
345
|
node.from = pkg;
|
|
303
346
|
}
|
|
347
|
+
/**
|
|
348
|
+
* THE MOTION PROPS, off the opening tag - see `SketchNode.motionProps`. Kept whole and
|
|
349
|
+
* short: a prop whose value is a function or a variable is theirs to write, and a copy
|
|
350
|
+
* of `animate={controls}` would name something the snippet cannot reach.
|
|
351
|
+
*/
|
|
352
|
+
const motion = motionPropsOf(event.body);
|
|
353
|
+
if (motion)
|
|
354
|
+
node.motionProps = motion;
|
|
304
355
|
// What has to be true for this element to exist at all - see `guardAt`.
|
|
305
356
|
const guard = guardAt(source, event.at);
|
|
306
357
|
if (guard) {
|
package/dist/guide.js
CHANGED
|
@@ -33,6 +33,90 @@ function customFontFamilies(families) {
|
|
|
33
33
|
}
|
|
34
34
|
return out;
|
|
35
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* THE STRUCTURE, WITH WHAT EACH PART IS FOR AND WHEN IT EXISTS.
|
|
38
|
+
*
|
|
39
|
+
* A flat list of class names is enough to STYLE and not enough to BUILD: an agent in
|
|
40
|
+
* another project reading fifteen of them cannot tell which is the thing you press, which
|
|
41
|
+
* one holds the revealed content, or which two are not always there. The recipe carries
|
|
42
|
+
* both facts per node and the guide was printing neither (dono, 04/08).
|
|
43
|
+
*/
|
|
44
|
+
function anatomyLines(cname, nodes, depth = 1) {
|
|
45
|
+
const out = [];
|
|
46
|
+
for (const node of nodes ?? []) {
|
|
47
|
+
if (depth > 6)
|
|
48
|
+
break;
|
|
49
|
+
const pad = " ".repeat(depth);
|
|
50
|
+
const name = node.part
|
|
51
|
+
? `\`.ds-${kebab(cname)}-${kebab(node.part)}\``
|
|
52
|
+
: node.refName
|
|
53
|
+
? `\`<${node.refName}>\``
|
|
54
|
+
: node.ref
|
|
55
|
+
? `your \`${node.refName ?? node.ref}\``
|
|
56
|
+
: node.as;
|
|
57
|
+
const facts = [node.as];
|
|
58
|
+
if (node.role && node.role !== node.as)
|
|
59
|
+
facts.push(node.role);
|
|
60
|
+
if (node.refName && node.from)
|
|
61
|
+
facts.push(`from \`${node.from}\``);
|
|
62
|
+
if (node.expects)
|
|
63
|
+
facts.push(`holds ${node.expects}`);
|
|
64
|
+
/**
|
|
65
|
+
* WHEN IT EXISTS, in the recipe's own words rather than in theirs - see
|
|
66
|
+
* `PreviewNode.only`. Their `isLoading` means nothing in another project; "only in a
|
|
67
|
+
* state this component has" is actionable anywhere.
|
|
68
|
+
*/
|
|
69
|
+
const only = node.only;
|
|
70
|
+
if (only && "axis" in only)
|
|
71
|
+
facts.push(`only when \`data-${kebab(only.axis)}="${only.option}"\``);
|
|
72
|
+
else if (only && "state" in only)
|
|
73
|
+
facts.push("only in that state");
|
|
74
|
+
else if (only && "caller" in only)
|
|
75
|
+
facts.push("only when you pass it");
|
|
76
|
+
out.push(`${pad}- ${name} - ${facts.join(" · ")}`);
|
|
77
|
+
out.push(...anatomyLines(cname, node.children, depth + 1));
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* WHAT THE COMPONENT DOES, said without naming a package.
|
|
83
|
+
*
|
|
84
|
+
* The recipe says `@base-ui/react/collapsible`. In a project that uses Radix - or nothing -
|
|
85
|
+
* that is evidence and not an instruction. What travels is the SHAPE of the interaction,
|
|
86
|
+
* and it is derivable from two facts the recipe already carries: the behaviour belongs to
|
|
87
|
+
* a headless library, and one part has the `action` role while another holds the content.
|
|
88
|
+
*
|
|
89
|
+
* Derived, never invented: with no `action` part and no headless library there is nothing
|
|
90
|
+
* to say, and this says nothing.
|
|
91
|
+
*/
|
|
92
|
+
function behaviourLine(cname, recipe) {
|
|
93
|
+
const headless = (recipe.libraries ?? []).some((l) => l.kind === "headless");
|
|
94
|
+
if (!headless)
|
|
95
|
+
return null;
|
|
96
|
+
const flat = [];
|
|
97
|
+
const walk = (nodes) => {
|
|
98
|
+
for (const node of nodes ?? []) {
|
|
99
|
+
flat.push(node);
|
|
100
|
+
walk(node.children);
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
walk(recipe.preview?.parts);
|
|
104
|
+
const action = flat.find((n) => n.role === "action");
|
|
105
|
+
if (!action)
|
|
106
|
+
return null;
|
|
107
|
+
/**
|
|
108
|
+
* A part that exists only in a STATE, or a slot, is the region an action reveals - which
|
|
109
|
+
* is what separates a disclosure from a button that merely has a name.
|
|
110
|
+
*/
|
|
111
|
+
const revealed = flat.find((n) => n.as === "slot" || (n.only && "state" in n.only));
|
|
112
|
+
const press = action.part
|
|
113
|
+
? `\`.ds-${kebab(cname)}-${kebab(action.part)}\``
|
|
114
|
+
: "the action";
|
|
115
|
+
if (!revealed) {
|
|
116
|
+
return `the interaction: ${press} is the pressable one - wire it yourself or with any headless primitive, and give it the ARIA the pattern calls for.`;
|
|
117
|
+
}
|
|
118
|
+
return `the interaction: ${press} is pressed and reveals the region below it. Whatever drives that - a headless library or your own state - the pressable element announces \`aria-expanded\` and points at the region with \`aria-controls\`; the region carries the matching \`id\`. This recipe styles both and drives neither.`;
|
|
119
|
+
}
|
|
36
120
|
/** One entry per component: class, variant data-*, states, and multi-part anatomy. */
|
|
37
121
|
function componentEntry(cname, recipe) {
|
|
38
122
|
const cls = `.ds-${kebab(cname)}`;
|
|
@@ -50,7 +134,65 @@ function componentEntry(cname, recipe) {
|
|
|
50
134
|
});
|
|
51
135
|
partsText = `\n parts: ${items.join(", ")}`;
|
|
52
136
|
}
|
|
53
|
-
|
|
137
|
+
/**
|
|
138
|
+
* EVERYTHING ELSE THE RECIPE ALREADY KNOWS, and this entry was throwing away.
|
|
139
|
+
*
|
|
140
|
+
* Measured on a real system: the recipe holds 15 fields and the guide read 4 of them, so
|
|
141
|
+
* an agent in another project got a list of class names and had to invent the rest -
|
|
142
|
+
* which is the difference between styling this component and building one that behaves
|
|
143
|
+
* like it (dono, 04/08).
|
|
144
|
+
*/
|
|
145
|
+
const facts = [];
|
|
146
|
+
if (recipe.tier)
|
|
147
|
+
facts.push(`a ${recipe.tier}`);
|
|
148
|
+
if (recipe.api?.name) {
|
|
149
|
+
facts.push(recipe.api.from
|
|
150
|
+
? `their own \`${recipe.api.name}\`, imported from \`${recipe.api.from}\``
|
|
151
|
+
: `their own \`${recipe.api.name}\``);
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* WHO OWNS THE STATE, which is the first question anybody rebuilding it asks. A recipe
|
|
155
|
+
* that manages itself needs no props to work; one the caller drives needs the pair.
|
|
156
|
+
*/
|
|
157
|
+
const runtime = recipe.runtime;
|
|
158
|
+
if (runtime?.stateOwner) {
|
|
159
|
+
const owner = runtime.stateOwner === "self"
|
|
160
|
+
? "manages its own state"
|
|
161
|
+
: runtime.stateOwner === "caller"
|
|
162
|
+
? "the caller owns its state"
|
|
163
|
+
: "works controlled or uncontrolled";
|
|
164
|
+
const pairs = (runtime.controlled ?? [])
|
|
165
|
+
.map((c) => `\`${c.prop}\` + \`${c.event}\``)
|
|
166
|
+
.join(", ");
|
|
167
|
+
facts.push(pairs ? `${owner} (${pairs})` : owner);
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* WHOSE BEHAVIOUR IT IS, and what that means for somebody rebuilding it. `headless` is
|
|
171
|
+
* the one that changes the answer: the pixels are in this recipe and the interaction is
|
|
172
|
+
* a library's, so ANY headless primitive of that shape will do - it does not have to be
|
|
173
|
+
* the one they used.
|
|
174
|
+
*/
|
|
175
|
+
for (const lib of recipe.libraries ?? []) {
|
|
176
|
+
if (lib.kind === "headless")
|
|
177
|
+
facts.push(`behaviour from \`${lib.from}\` - a headless library: every pixel is in this recipe, the interaction is not. Any equivalent primitive works`);
|
|
178
|
+
else if (lib.kind === "opaque")
|
|
179
|
+
facts.push(`\`${lib.from}\` draws part of it, and that part is not in this recipe`);
|
|
180
|
+
}
|
|
181
|
+
const defaults = Object.entries(recipe.defaults ?? {})
|
|
182
|
+
.map(([axis, option]) => `\`data-${kebab(axis)}="${option}"\``)
|
|
183
|
+
.join(", ");
|
|
184
|
+
const defaultsText = defaults ? `\n at rest: ${defaults}` : "";
|
|
185
|
+
const factsText = facts.length ? `\n ${facts.join("\n ")}` : "";
|
|
186
|
+
/** Its own laws, computed from its own usage - and nothing read them (dono, 04/08). */
|
|
187
|
+
const laws = (recipe.usage ?? []).map((law) => `\n - ${law}`).join("");
|
|
188
|
+
const lawsText = laws ? `\n it obeys:${laws}` : "";
|
|
189
|
+
const behaviour = behaviourLine(cname, recipe);
|
|
190
|
+
const behaviourText = behaviour ? `\n ${behaviour}` : "";
|
|
191
|
+
const anatomy = anatomyLines(cname, recipe.preview?.parts);
|
|
192
|
+
const anatomyText = anatomy.length
|
|
193
|
+
? `\n anatomy, in this nesting:\n${anatomy.join("\n")}`
|
|
194
|
+
: "";
|
|
195
|
+
return `- **${cname}** (\`${cls}\`)${variantsText}\n ${recipe.description}${factsText}${defaultsText}${partsText}${statesText}${behaviourText}${anatomyText}${lawsText}`;
|
|
54
196
|
}
|
|
55
197
|
/**
|
|
56
198
|
* Builds GUIDE.md - instructions *for the agent* on how to build components
|
package/package.json
CHANGED