synthesisui 0.16.78 → 0.16.80
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 +63 -8
- package/dist/commands/component.js +38 -1
- package/dist/commands/import.js +211 -14
- package/dist/commands/mcp.js +128 -0
- package/dist/component-codegen.js +52 -15
- package/dist/doctor/components-scan.js +70 -2
- package/dist/doctor/crosswalk.js +59 -2
- package/dist/doctor/definitions-scan.js +32 -0
- package/dist/index.js +35 -5
- package/dist/project-facts.js +58 -2
- package/dist/skill-import.js +215 -17
- package/package.json +1 -1
|
@@ -207,7 +207,19 @@ const colorKey = (v) => {
|
|
|
207
207
|
return `series-${series}`;
|
|
208
208
|
return null;
|
|
209
209
|
};
|
|
210
|
-
/**
|
|
210
|
+
/**
|
|
211
|
+
* "{typography.scale.sm.fontSize}" → "sm" (the --text-<key> utility).
|
|
212
|
+
*
|
|
213
|
+
* A STEP maps to a utility; a ROLE deliberately does not.
|
|
214
|
+
*
|
|
215
|
+
* `typography.scale.<step>` names a step the system actually has, and a step it
|
|
216
|
+
* got from THEIR declaration is bridged into Tailwind's namespace - so `text-h1`
|
|
217
|
+
* exists and is the prettier output. `typography.role.<slot>` is one of our seven
|
|
218
|
+
* slots, and the bridge only ever claims a name they gave us: `text-base` may not
|
|
219
|
+
* exist in an imported system at all. So a role falls through to
|
|
220
|
+
* `var(--ds-typography-role-base-font-size)`, which is always emitted and can
|
|
221
|
+
* never dangle.
|
|
222
|
+
*/
|
|
211
223
|
const scaleKey = (v) => {
|
|
212
224
|
const m = v.match(/^\{typography\.scale\.([a-zA-Z0-9-]+)\.fontSize\}$/);
|
|
213
225
|
return m ? kebab(m[1]) : null;
|
|
@@ -504,6 +516,7 @@ const FORM_TAG = {
|
|
|
504
516
|
icon: { tag: "span" },
|
|
505
517
|
row: { tag: "div" },
|
|
506
518
|
stack: { tag: "div" },
|
|
519
|
+
slot: { tag: "div" },
|
|
507
520
|
};
|
|
508
521
|
/** Only these two arrange; the rest are leaves. */
|
|
509
522
|
const ARRANGES = new Set(["row", "stack"]);
|
|
@@ -543,6 +556,16 @@ function emitTree(nodes, comp, indent) {
|
|
|
543
556
|
lines.push(`${indent}{/* ${node.from} renders here - a third party's component, so its markup is theirs */}`);
|
|
544
557
|
continue;
|
|
545
558
|
}
|
|
559
|
+
// The caller's content, so the generated code takes it as a prop rather than
|
|
560
|
+
// inventing a sample. A slot that also carries styles keeps its part element
|
|
561
|
+
// around the children; a bare one is just `{children}`.
|
|
562
|
+
if (node.as === "slot") {
|
|
563
|
+
const inner = `{children}${node.expects ? ` {/* ${node.expects} */}` : ""}`;
|
|
564
|
+
lines.push(node.part
|
|
565
|
+
? `${indent}<${comp}${pascal(node.part)}>${inner}</${comp}${pascal(node.part)}>`
|
|
566
|
+
: `${indent}${inner}`);
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
546
569
|
if (!node.part) {
|
|
547
570
|
// Pure structure with no styles of its own: keep the arrangement, skip the
|
|
548
571
|
// element - a div that carries nothing is a div nobody needs.
|
|
@@ -640,11 +663,13 @@ function partTagFor(part, form) {
|
|
|
640
663
|
voidEl: Boolean(mapped.voidEl),
|
|
641
664
|
};
|
|
642
665
|
}
|
|
643
|
-
function emitCssMode(slug, name, recipe, version, props, convention
|
|
666
|
+
function emitCssMode(slug, name, recipe, version, props, convention,
|
|
667
|
+
/** The name it takes in THEIR project. The class stays the system's. */
|
|
668
|
+
localName = name) {
|
|
644
669
|
const { tag, attrs, voidEl } = elementFor(name, recipe);
|
|
645
670
|
const el = asElement(tag, attrs, voidEl);
|
|
646
671
|
const axes = axesOf(recipe.variants);
|
|
647
|
-
const comp = pascal(
|
|
672
|
+
const comp = pascal(localName);
|
|
648
673
|
const propNames = axes.map((a) => a.prop);
|
|
649
674
|
const tree = recipe.preview?.parts ?? [];
|
|
650
675
|
// With a shape the root RENDERS its children, so it needs `children` in the
|
|
@@ -713,7 +738,7 @@ ${dataAttrLines(partAxes)}${partAxes.length ? "\n" : ""} {...props}
|
|
|
713
738
|
});
|
|
714
739
|
const needsElementType = el.offersAs || Object.keys(recipe.parts ?? {}).length > 0;
|
|
715
740
|
return `${header(slug, name, version, "css")}
|
|
716
|
-
import "./${
|
|
741
|
+
import "./${localName}.css";
|
|
717
742
|
|
|
718
743
|
import type { ${needsElementType ? `ElementType, ${props}` : props} } from "react";
|
|
719
744
|
|
|
@@ -727,11 +752,13 @@ ${rootJsx}
|
|
|
727
752
|
}
|
|
728
753
|
${parts.filter(Boolean).join("\n")}`;
|
|
729
754
|
}
|
|
730
|
-
function emitTailwindMode(slug, name, recipe, version, props
|
|
755
|
+
function emitTailwindMode(slug, name, recipe, version, props,
|
|
756
|
+
/** The name it takes in THEIR project. The utilities stay the system's. */
|
|
757
|
+
localName = name) {
|
|
731
758
|
const { tag, attrs, voidEl } = elementFor(name, recipe);
|
|
732
759
|
const el = asElement(tag, attrs, voidEl);
|
|
733
760
|
const axes = axesOf(recipe.variants);
|
|
734
|
-
const comp = pascal(
|
|
761
|
+
const comp = pascal(localName);
|
|
735
762
|
const variantConsts = axes
|
|
736
763
|
.filter((a) => !a.boolean)
|
|
737
764
|
.map((a) => {
|
|
@@ -878,27 +905,37 @@ reactMajor = null,
|
|
|
878
905
|
* every caller that predates the convention being the user's - and getting this
|
|
879
906
|
* wrong shipped a component wearing classes its own stylesheet never emits.
|
|
880
907
|
*/
|
|
881
|
-
convention = DEFAULT_CONVENTION
|
|
882
|
-
|
|
908
|
+
convention = DEFAULT_CONVENTION,
|
|
909
|
+
/**
|
|
910
|
+
* THE NAME IT TAKES IN THEIR PROJECT, when it cannot take its own.
|
|
911
|
+
*
|
|
912
|
+
* A project that already exports `Button` should not have to give the name up to
|
|
913
|
+
* install ours - we adapt to what they built. So the FILE and the EXPORT can be
|
|
914
|
+
* renamed while the CLASS stays the design system's: the recipe compiles
|
|
915
|
+
* `.ds-button`, and a `<MySystemButton>` wearing it is styled correctly and
|
|
916
|
+
* shadows nothing of theirs.
|
|
917
|
+
*
|
|
918
|
+
* Absent means the component keeps its own name, which is every caller today.
|
|
919
|
+
*/
|
|
920
|
+
localName = name) {
|
|
883
921
|
const files = [];
|
|
884
922
|
const props = propsTypeName(reactMajor);
|
|
885
923
|
if (styles === "css") {
|
|
886
924
|
files.push({
|
|
887
|
-
filename: `${
|
|
888
|
-
code: `${emitCssMode(slug, name, recipe, version, props, convention)}\n`,
|
|
925
|
+
filename: `${localName}.tsx`,
|
|
926
|
+
code: `${emitCssMode(slug, name, recipe, version, props, convention, localName)}\n`,
|
|
889
927
|
});
|
|
890
|
-
files.push({ filename: `${
|
|
928
|
+
files.push({ filename: `${localName}.css`, code: `${css}\n` });
|
|
891
929
|
}
|
|
892
930
|
else {
|
|
893
931
|
files.push({
|
|
894
|
-
filename: `${
|
|
895
|
-
code: `${emitTailwindMode(slug, name, recipe, version, props)}\n`,
|
|
932
|
+
filename: `${localName}.tsx`,
|
|
933
|
+
code: `${emitTailwindMode(slug, name, recipe, version, props, localName)}\n`,
|
|
896
934
|
});
|
|
897
935
|
}
|
|
898
936
|
files.push({
|
|
899
937
|
filename: "index.ts",
|
|
900
|
-
code: `export * from "./${
|
|
938
|
+
code: `export * from "./${localName}";\n`,
|
|
901
939
|
});
|
|
902
|
-
void comp;
|
|
903
940
|
return files;
|
|
904
941
|
}
|
|
@@ -13,17 +13,30 @@ const LITERAL_PROP = /([a-zA-Z][a-zA-Z0-9_-]*)\s*=\s*\{?\s*["']([^"']*)["']\s*\}
|
|
|
13
13
|
/** `dense` with no value is a boolean prop set to true, and that IS a literal
|
|
14
14
|
* decision worth counting. */
|
|
15
15
|
const BARE_PROP = /(^|\s)([a-z][a-zA-Z0-9_]*)(?=\s|$)/g;
|
|
16
|
+
/**
|
|
17
|
+
* A KEYWORD IS NOT AN IDENTIFIER, and the difference decides whether a `<` opens
|
|
18
|
+
* an element. `return <Card>one</Card>` is a component; `useState<Foo>()` is a
|
|
19
|
+
* type argument. Both are preceded by a word.
|
|
20
|
+
*
|
|
21
|
+
* Prettier writes `return <Foo />;` on one line whenever it fits, so without this
|
|
22
|
+
* every short component written that way read as a generic and vanished from the
|
|
23
|
+
* inventory - which is how a spec for something else caught it.
|
|
24
|
+
*/
|
|
25
|
+
const JSX_BEFORE = /\b(return|case|default|await|yield|typeof|in|of|do|else|new)$/;
|
|
16
26
|
/**
|
|
17
27
|
* A generic in type position (`useState<Foo>()`, `Array<Item>`) is not an
|
|
18
28
|
* element. The tell is what precedes the `<`: an identifier or a closing paren
|
|
19
|
-
* means a type argument, while JSX follows `(`, `{`, `>`, a newline or
|
|
29
|
+
* means a type argument, while JSX follows `(`, `{`, `>`, a newline, a keyword or
|
|
30
|
+
* nothing.
|
|
20
31
|
*/
|
|
21
32
|
function looksLikeType(source, at) {
|
|
22
33
|
for (let i = at - 1; i >= 0; i--) {
|
|
23
34
|
const c = source[i];
|
|
24
35
|
if (c === " " || c === "\t")
|
|
25
36
|
continue;
|
|
26
|
-
|
|
37
|
+
if (!/[A-Za-z0-9_)\]]/.test(c))
|
|
38
|
+
return false;
|
|
39
|
+
return !JSX_BEFORE.test(source.slice(Math.max(0, i - 11), i + 1));
|
|
27
40
|
}
|
|
28
41
|
return false;
|
|
29
42
|
}
|
|
@@ -81,6 +94,56 @@ function importedNames(source, internal = []) {
|
|
|
81
94
|
}
|
|
82
95
|
return out;
|
|
83
96
|
}
|
|
97
|
+
/**
|
|
98
|
+
* Capitalised names a file BINDS ITSELF and does not export.
|
|
99
|
+
*
|
|
100
|
+
* `<Component>` and `<CustomLegend/>` both arrived in a real library's inventory as
|
|
101
|
+
* components of the system (dono, 01/08). Neither is one:
|
|
102
|
+
*
|
|
103
|
+
* function Text({ as: Component = "p" }) // React's polymorphic idiom -
|
|
104
|
+
* // `Component` is a prop rename
|
|
105
|
+
* const CustomLegend = () => (…) // a closure inside LineChart
|
|
106
|
+
*
|
|
107
|
+
* Capitalisation is React's rule for "not an html tag", which is not the same
|
|
108
|
+
* question as "is this a component the system offers". The test that separates them
|
|
109
|
+
* is REACHABILITY: a component of the system is either imported from somewhere or
|
|
110
|
+
* exported to somewhere. A name that only exists inside one function body is that
|
|
111
|
+
* function's private wiring, and giving it a recipe invents a component nobody can
|
|
112
|
+
* import.
|
|
113
|
+
*
|
|
114
|
+
* Local AND exported stays, of course - that is just a component defined where it
|
|
115
|
+
* is used.
|
|
116
|
+
*/
|
|
117
|
+
const BOUND = /(?:const|let|var|function|class)\s+([A-Z][A-Za-z0-9_]*)|[{,]\s*\w+\s*:\s*([A-Z][A-Za-z0-9_]*)\s*(?:=[^,}]*)?\s*[,}]/g;
|
|
118
|
+
const EXPORTED_NAME = /export\s+(?:default\s+)?(?:async\s+)?(?:function|class|const|let|var)\s+([A-Z][A-Za-z0-9_]*)|export\s+default\s+([A-Z][A-Za-z0-9_]*)|export\s*\{([^}]*)\}/g;
|
|
119
|
+
export function localOnlyNames(source) {
|
|
120
|
+
const exported = new Set();
|
|
121
|
+
EXPORTED_NAME.lastIndex = 0;
|
|
122
|
+
for (const m of source.matchAll(EXPORTED_NAME)) {
|
|
123
|
+
if (m[1])
|
|
124
|
+
exported.add(m[1]);
|
|
125
|
+
if (m[2])
|
|
126
|
+
exported.add(m[2]);
|
|
127
|
+
if (m[3]) {
|
|
128
|
+
for (const raw of m[3].split(",")) {
|
|
129
|
+
const name = raw
|
|
130
|
+
.trim()
|
|
131
|
+
.split(/\s+as\s+/)[0]
|
|
132
|
+
?.trim();
|
|
133
|
+
if (name && /^[A-Z]/.test(name))
|
|
134
|
+
exported.add(name);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const out = new Set();
|
|
139
|
+
BOUND.lastIndex = 0;
|
|
140
|
+
for (const m of source.matchAll(BOUND)) {
|
|
141
|
+
const name = m[1] ?? m[2];
|
|
142
|
+
if (name && !exported.has(name))
|
|
143
|
+
out.add(name);
|
|
144
|
+
}
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
84
147
|
export function emptyTally() {
|
|
85
148
|
return new Map();
|
|
86
149
|
}
|
|
@@ -92,11 +155,16 @@ internal = []) {
|
|
|
92
155
|
if (!/\.(tsx|jsx|vue|svelte)$/i.test(file))
|
|
93
156
|
return;
|
|
94
157
|
const owners = importedNames(source, internal);
|
|
158
|
+
const private_ = localOnlyNames(source);
|
|
95
159
|
ELEMENT.lastIndex = 0;
|
|
96
160
|
for (const m of source.matchAll(ELEMENT)) {
|
|
97
161
|
if (m.index != null && looksLikeType(source, m.index))
|
|
98
162
|
continue;
|
|
99
163
|
const name = m[1];
|
|
164
|
+
// Private wiring, not a component of the system. An import wins: a file may
|
|
165
|
+
// shadow nothing and still bind a name it also imports.
|
|
166
|
+
if (!owners.has(name) && private_.has(name.split(".")[0]))
|
|
167
|
+
continue;
|
|
100
168
|
const body = m[2] ?? "";
|
|
101
169
|
let hit = tally.get(name);
|
|
102
170
|
if (!hit) {
|
package/dist/doctor/crosswalk.js
CHANGED
|
@@ -304,8 +304,45 @@ function sharedMeaning(a, b) {
|
|
|
304
304
|
n += 1;
|
|
305
305
|
return n;
|
|
306
306
|
}
|
|
307
|
-
|
|
307
|
+
/**
|
|
308
|
+
* WHEN THE SOURCE IS THE LIBRARY, EVERY COMPONENT IS THEIRS.
|
|
309
|
+
*
|
|
310
|
+
* The crosswalk was designed for an APP that uses components: "your Pill reads as
|
|
311
|
+
* our badge, use ours" is exactly right there, because it stops a duplicate. When the
|
|
312
|
+
* source IS the component library, the premise inverts - their library IS the design
|
|
313
|
+
* system, and mapping their component onto ours is us overwriting theirs with ours.
|
|
314
|
+
* The opposite of entering as a complement.
|
|
315
|
+
*
|
|
316
|
+
* Measured on a real library (dono, 01/08): of 37 components they export, 16 never
|
|
317
|
+
* became theirs. `Label`, `Pill` and `Tag` - three distinct components - collapsed
|
|
318
|
+
* into one `ds-badge` of ours, which is why their Tag previewed as the word "Pill":
|
|
319
|
+
* that is OUR recipe's sample text. `LineChart`, a real organism, simply was not in
|
|
320
|
+
* the system.
|
|
321
|
+
*
|
|
322
|
+
* The code already knew. `describeOrigin` says it in prose - "declared in
|
|
323
|
+
* `packages/ui` and composed nowhere inside it, which is what a component library
|
|
324
|
+
* looks like from the inside" - and then acted as if it were an app.
|
|
325
|
+
*
|
|
326
|
+
* THE SIGNAL, and it is not a guess: a library declares and does not compose itself.
|
|
327
|
+
* When most of what a scope declares is composed nowhere inside it, the scope is a
|
|
328
|
+
* library. The crosswalk still runs and its verdict still travels as INFORMATION -
|
|
329
|
+
* "this reads as our badge" is worth knowing - it just stops replacing anything.
|
|
330
|
+
*/
|
|
331
|
+
export function isLibrary(components) {
|
|
332
|
+
const mine = components.filter((c) => !c.from);
|
|
333
|
+
if (mine.length < 4)
|
|
334
|
+
return false;
|
|
335
|
+
const uncomposed = mine.filter((c) => (c.count ?? 0) === 0).length;
|
|
336
|
+
return uncomposed / mine.length >= 0.5;
|
|
337
|
+
}
|
|
338
|
+
export function crosswalk(components,
|
|
339
|
+
/**
|
|
340
|
+
* Their library is the system. Every component keeps its own recipe, and the
|
|
341
|
+
* catalogue match becomes a note rather than a substitution.
|
|
342
|
+
*/
|
|
343
|
+
opts) {
|
|
308
344
|
const mine = components.filter((c) => !c.from);
|
|
345
|
+
const library = opts?.library ?? false;
|
|
309
346
|
// Two of THEIR components reading as the same catalogue entry is the
|
|
310
347
|
// strongest redundancy signal there is, and it needs no threshold: it is not
|
|
311
348
|
// "these look alike", it is "you wrote this twice". The first run filed both
|
|
@@ -344,6 +381,24 @@ export function crosswalk(components) {
|
|
|
344
381
|
.sort((a, b) => b.overlap - a.overlap);
|
|
345
382
|
if (canonical && CATALOGUE[canonical]) {
|
|
346
383
|
const alsoClaiming = (claims.get(canonical) ?? []).filter((n) => n !== component.name);
|
|
384
|
+
/**
|
|
385
|
+
* LIBRARY MODE: the match is a NOTE, not a verdict.
|
|
386
|
+
*
|
|
387
|
+
* Their component stays exclusive and keeps its own recipe; `canonical` still
|
|
388
|
+
* travels so a preview can resolve a frontier and so the v2 can say "these
|
|
389
|
+
* three read alike". What stops happening is their `Label` ceasing to exist.
|
|
390
|
+
*/
|
|
391
|
+
if (library) {
|
|
392
|
+
return {
|
|
393
|
+
component,
|
|
394
|
+
canonical,
|
|
395
|
+
twins,
|
|
396
|
+
bucket: "exclusive",
|
|
397
|
+
because: alsoClaiming.length > 0
|
|
398
|
+
? `yours, and it reads like ds-${canonical} - so do ${alsoClaiming.join(", ")}, which is one decision written ${alsoClaiming.length + 1} times`
|
|
399
|
+
: `yours, and it reads like ds-${canonical} - kept as yours because this library IS the system`,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
347
402
|
if (alsoClaiming.length > 0) {
|
|
348
403
|
return {
|
|
349
404
|
component,
|
|
@@ -369,7 +424,9 @@ export function crosswalk(components) {
|
|
|
369
424
|
component,
|
|
370
425
|
canonical,
|
|
371
426
|
twins,
|
|
372
|
-
|
|
427
|
+
// A twin is a v2 observation either way; in library mode it must not cost
|
|
428
|
+
// the component its own recipe.
|
|
429
|
+
bucket: library ? "exclusive" : "nearly",
|
|
373
430
|
because: `shares ${Math.round(twins[0].overlap * 100)}% of its options with ${twins[0].name} - one decision, two components`,
|
|
374
431
|
};
|
|
375
432
|
}
|
|
@@ -20,6 +20,36 @@
|
|
|
20
20
|
* actually write and stays silent on the rest: a props type built by `Omit<>` or
|
|
21
21
|
* spread from another interface yields no axes rather than wrong ones.
|
|
22
22
|
*/
|
|
23
|
+
/**
|
|
24
|
+
* THEIR OWN LADDER, READ OFF THE PATH - never guessed from a name.
|
|
25
|
+
*
|
|
26
|
+
* A real library keeps `atoms/Text/Text.tsx`, `molecules/MetricCard/`,
|
|
27
|
+
* `organisms/DataTable/`: the author saying out loud what composes what, in the
|
|
28
|
+
* vocabulary the whole industry already shares. Twenty-three components arrived in
|
|
29
|
+
* one flat bucket and the information was sitting in the path (dono, 01/08).
|
|
30
|
+
*
|
|
31
|
+
* A path segment, not a substring: `src/atomsphere/` is a folder called
|
|
32
|
+
* atomsphere. Returns undefined for the projects that do not organise themselves
|
|
33
|
+
* this way, which is most of them - and undefined means "no ladder here", not
|
|
34
|
+
* "unclassified".
|
|
35
|
+
*/
|
|
36
|
+
const TIER_FOLDER = {
|
|
37
|
+
atoms: "atom",
|
|
38
|
+
atom: "atom",
|
|
39
|
+
primitives: "atom",
|
|
40
|
+
molecules: "molecule",
|
|
41
|
+
molecule: "molecule",
|
|
42
|
+
organisms: "organism",
|
|
43
|
+
organism: "organism",
|
|
44
|
+
};
|
|
45
|
+
export function tierOf(file) {
|
|
46
|
+
for (const segment of file.split(/[/\\]/)) {
|
|
47
|
+
const tier = TIER_FOLDER[segment.toLowerCase()];
|
|
48
|
+
if (tier)
|
|
49
|
+
return tier;
|
|
50
|
+
}
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
23
53
|
/** `export function X`, `export default function X`, `export const X =`. */
|
|
24
54
|
const EXPORTED = /export\s+(?:default\s+)?(?:async\s+)?(?:function\s+([A-Z][A-Za-z0-9_]*)|const\s+([A-Z][A-Za-z0-9_]*)\s*[:=])/g;
|
|
25
55
|
/** `type ButtonProps = { … }` or `interface ButtonProps { … }`, to its closing
|
|
@@ -78,11 +108,13 @@ export function scanDefinitions(file, source) {
|
|
|
78
108
|
continue;
|
|
79
109
|
seen.add(name);
|
|
80
110
|
const props = propsOf.get(name);
|
|
111
|
+
const tier = tierOf(file);
|
|
81
112
|
out.push({
|
|
82
113
|
name,
|
|
83
114
|
file,
|
|
84
115
|
axes: props?.axes ?? {},
|
|
85
116
|
flags: props?.flags ?? [],
|
|
117
|
+
...(tier ? { tier } : {}),
|
|
86
118
|
});
|
|
87
119
|
}
|
|
88
120
|
return out;
|
package/dist/index.js
CHANGED
|
@@ -41,7 +41,7 @@ Usage - deterministic, FREE:
|
|
|
41
41
|
|
|
42
42
|
Usage - governance (deterministic, FREE):
|
|
43
43
|
synthesisui adopt [--write] turn the design system you ALREADY have into a contract
|
|
44
|
-
synthesisui import [--
|
|
44
|
+
synthesisui import [--scope <p>] [--usage <p>] read what you already have and make it a system
|
|
45
45
|
your agent follows - without touching your CSS
|
|
46
46
|
synthesisui connect wire your agent: the check as an editor hook, the system
|
|
47
47
|
as MCP tools, and a contract that stops repeating itself
|
|
@@ -66,7 +66,8 @@ Options:
|
|
|
66
66
|
--version <n> install a specific version (default: latest)
|
|
67
67
|
--ds <slug> init: bring this DS in right away · generate: target DS (default: installed)
|
|
68
68
|
--name <name> preferred component name for generate
|
|
69
|
-
--scope <path> import:
|
|
69
|
+
--scope <path> import: the SYSTEM - tokens, components, convention. One folder.
|
|
70
|
+
--usage <path> import: the EVIDENCE - counts, chosen values, laws. Repeatable.
|
|
70
71
|
--scheme <s> dark|light - which end of your ladder is the default
|
|
71
72
|
--target <t> template/init target: next | general (default: next)
|
|
72
73
|
--pages-dir <dir> init: folder for generated pages (default: app)
|
|
@@ -111,9 +112,30 @@ Examples:
|
|
|
111
112
|
synthesisui generate "an upgrade banner with a title, message and a primary CTA"
|
|
112
113
|
`;
|
|
113
114
|
/** Extracts simple `--flag value` pairs and the remaining positionals. */
|
|
115
|
+
/**
|
|
116
|
+
* A REPEATED FLAG ACCUMULATES rather than overwriting.
|
|
117
|
+
*
|
|
118
|
+
* `--usage apps/web --usage apps/admin` is the natural way to say two, and keeping
|
|
119
|
+
* only the last would drop one silently - which is the failure mode this codebase
|
|
120
|
+
* refuses everywhere else. Every existing flag is passed once and still arrives as
|
|
121
|
+
* a string, so nothing that reads `typeof flags.x === "string"` changes.
|
|
122
|
+
*/
|
|
114
123
|
function parseFlags(argv) {
|
|
115
124
|
const positionals = [];
|
|
116
125
|
const flags = {};
|
|
126
|
+
const put = (key, value) => {
|
|
127
|
+
const had = flags[key];
|
|
128
|
+
if (had === undefined) {
|
|
129
|
+
flags[key] = value;
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (typeof value !== "string")
|
|
133
|
+
return;
|
|
134
|
+
flags[key] = [
|
|
135
|
+
...(Array.isArray(had) ? had : typeof had === "string" ? [had] : []),
|
|
136
|
+
value,
|
|
137
|
+
];
|
|
138
|
+
};
|
|
117
139
|
for (let i = 0; i < argv.length; i++) {
|
|
118
140
|
const arg = argv[i];
|
|
119
141
|
if (arg === "-h" || arg === "--help") {
|
|
@@ -124,17 +146,17 @@ function parseFlags(argv) {
|
|
|
124
146
|
const eq = body.indexOf("=");
|
|
125
147
|
if (eq !== -1) {
|
|
126
148
|
// `--key=value` form (e.g. --out=templates/page.tsx)
|
|
127
|
-
|
|
149
|
+
put(body.slice(0, eq), body.slice(eq + 1));
|
|
128
150
|
}
|
|
129
151
|
else {
|
|
130
152
|
// `--key value` form
|
|
131
153
|
const next = argv[i + 1];
|
|
132
154
|
if (next !== undefined && !next.startsWith("--")) {
|
|
133
|
-
|
|
155
|
+
put(body, next);
|
|
134
156
|
i++;
|
|
135
157
|
}
|
|
136
158
|
else {
|
|
137
|
-
|
|
159
|
+
put(body, true);
|
|
138
160
|
}
|
|
139
161
|
}
|
|
140
162
|
}
|
|
@@ -165,6 +187,14 @@ async function main() {
|
|
|
165
187
|
// invert a system, so it falls through to being measured and asked.
|
|
166
188
|
// WHAT to read. `--dir` stays WHERE the project is, in every command.
|
|
167
189
|
scope: typeof flags.scope === "string" ? flags.scope : undefined,
|
|
190
|
+
// WHERE THE EVIDENCE IS. Repeatable, and comma-separated works too - a
|
|
191
|
+
// person typing one path with a comma in it means two paths.
|
|
192
|
+
usage: [flags.usage]
|
|
193
|
+
.flat()
|
|
194
|
+
.filter((v) => typeof v === "string")
|
|
195
|
+
.flatMap((v) => v.split(","))
|
|
196
|
+
.map((v) => v.trim())
|
|
197
|
+
.filter(Boolean),
|
|
168
198
|
scheme: flags.scheme === "dark" || flags.scheme === "light"
|
|
169
199
|
? flags.scheme
|
|
170
200
|
: undefined,
|
package/dist/project-facts.js
CHANGED
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
* handed somebody a React component wearing classes their own stylesheet never
|
|
14
14
|
* emits. It renders completely unstyled, and nothing anywhere reports a problem.
|
|
15
15
|
*/
|
|
16
|
-
import { readFile } from "node:fs/promises";
|
|
17
|
-
import { join } from "node:path";
|
|
16
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
17
|
+
import { join, relative } from "node:path";
|
|
18
18
|
import { DEFAULT_CONVENTION, } from "./component-codegen.js";
|
|
19
19
|
/**
|
|
20
20
|
* Which React the consumer is on, for the ref-carrying prop type.
|
|
@@ -38,6 +38,62 @@ export async function reactMajorOf(root) {
|
|
|
38
38
|
return null;
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
|
+
export async function findCollision(root, componentsDir, name, pascal) {
|
|
42
|
+
const target = join(root, componentsDir, name, `${name}.tsx`);
|
|
43
|
+
const head = await readFile(target, "utf8")
|
|
44
|
+
.then((t) => t.slice(0, 300))
|
|
45
|
+
.catch(() => null);
|
|
46
|
+
const ours = head?.includes("Generated by SynthesisUI") ?? false;
|
|
47
|
+
/**
|
|
48
|
+
* Their own export of that name, searched where a component library lives.
|
|
49
|
+
*
|
|
50
|
+
* Deliberately shallow: `src`, `app`, `components`, `packages` and the
|
|
51
|
+
* configured folder, skipping anything generated. A deep walk of a monorepo to
|
|
52
|
+
* answer one question is not worth the seconds.
|
|
53
|
+
*/
|
|
54
|
+
const pattern = new RegExp(`export\\s+(?:default\\s+)?(?:function|const|class)\\s+${pascal}\\b`);
|
|
55
|
+
const roots = [
|
|
56
|
+
componentsDir,
|
|
57
|
+
"src/components",
|
|
58
|
+
"components",
|
|
59
|
+
"src/ui",
|
|
60
|
+
"packages",
|
|
61
|
+
];
|
|
62
|
+
let exported = null;
|
|
63
|
+
for (const rel of roots) {
|
|
64
|
+
if (exported)
|
|
65
|
+
break;
|
|
66
|
+
for await (const file of walkShallow(join(root, rel), 3)) {
|
|
67
|
+
if (!/\.(tsx|jsx|ts)$/.test(file))
|
|
68
|
+
continue;
|
|
69
|
+
if (file === target)
|
|
70
|
+
continue;
|
|
71
|
+
const source = await readFile(file, "utf8").catch(() => "");
|
|
72
|
+
if (source.includes("Generated by SynthesisUI"))
|
|
73
|
+
continue;
|
|
74
|
+
if (pattern.test(source)) {
|
|
75
|
+
exported = relative(root, file);
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return { file: head == null ? null : relative(root, target), exported, ours };
|
|
81
|
+
}
|
|
82
|
+
/** Files under `dir`, at most `depth` levels down, skipping the usual noise. */
|
|
83
|
+
async function* walkShallow(dir, depth) {
|
|
84
|
+
if (depth < 0)
|
|
85
|
+
return;
|
|
86
|
+
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
87
|
+
for (const entry of entries) {
|
|
88
|
+
if (entry.name.startsWith(".") || entry.name === "node_modules")
|
|
89
|
+
continue;
|
|
90
|
+
const full = join(dir, entry.name);
|
|
91
|
+
if (entry.isDirectory())
|
|
92
|
+
yield* walkShallow(full, depth - 1);
|
|
93
|
+
else
|
|
94
|
+
yield full;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
41
97
|
/** The pinned version of an installed system, from its `.lock`. */
|
|
42
98
|
async function pinnedVersion(dir) {
|
|
43
99
|
const raw = await readFile(join(dir, ".lock"), "utf8").catch(() => "");
|