synthesisui 0.16.77 → 0.16.79
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 +272 -0
- package/dist/commands/add.js +23 -1
- package/dist/commands/component.js +48 -23
- package/dist/commands/doctor.js +49 -1
- package/dist/commands/generate.js +5 -1
- package/dist/commands/import.js +90 -15
- package/dist/commands/mcp.js +119 -0
- package/dist/commands/refit.js +2 -1
- package/dist/commands/upgrade.js +6 -1
- package/dist/component-codegen.js +281 -37
- package/dist/doctor/dependencies.js +90 -0
- package/dist/doctor/transcribe.js +117 -0
- package/dist/project-facts.js +145 -0
- package/dist/skill-import.js +253 -36
- package/package.json +1 -1
|
@@ -1,4 +1,12 @@
|
|
|
1
|
+
export const DEFAULT_CONVENTION = {
|
|
2
|
+
prefix: "ds-",
|
|
3
|
+
partSeparator: "-",
|
|
4
|
+
};
|
|
1
5
|
const kebab = (v) => v.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
|
|
6
|
+
/** `metric-card` → `ds-metric-card`, or `metric-card`, or `sui-metric-card`. */
|
|
7
|
+
const elementClass = (name, c) => `${c.prefix}${kebab(name)}`;
|
|
8
|
+
/** `metric-card` + `title` → `ds-metric-card-title`, or `metric-card__title`. */
|
|
9
|
+
const partClassName = (name, part, c) => `${elementClass(name, c)}${c.partSeparator}${kebab(part)}`;
|
|
2
10
|
const pascal = (name) => name
|
|
3
11
|
.split(/[^a-zA-Z0-9]+/)
|
|
4
12
|
.filter(Boolean)
|
|
@@ -199,7 +207,19 @@ const colorKey = (v) => {
|
|
|
199
207
|
return `series-${series}`;
|
|
200
208
|
return null;
|
|
201
209
|
};
|
|
202
|
-
/**
|
|
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
|
+
*/
|
|
203
223
|
const scaleKey = (v) => {
|
|
204
224
|
const m = v.match(/^\{typography\.scale\.([a-zA-Z0-9-]+)\.fontSize\}$/);
|
|
205
225
|
return m ? kebab(m[1]) : null;
|
|
@@ -478,10 +498,91 @@ const joinCls = (parts) => `[${parts.join(", ")}].filter(Boolean).join(" ")`;
|
|
|
478
498
|
* applies, which is what the call site reads like.
|
|
479
499
|
*/
|
|
480
500
|
const resolveCls = (parts) => `cn(${parts.join(", ")})`;
|
|
501
|
+
/**
|
|
502
|
+
* WHAT ELEMENT EACH FORM IS, and this is where the anatomy stops being a picture.
|
|
503
|
+
*
|
|
504
|
+
* Every part came out as a `<div>` - a title, a value, a cover image, all divs -
|
|
505
|
+
* because a flat list of names cannot say what a part IS. The tree says, so a
|
|
506
|
+
* heading generates an `<h3>`, an image an `<img>`, a field an `<input>`. That is
|
|
507
|
+
* not cosmetic: it decides what a screen reader announces and what a browser lets
|
|
508
|
+
* you tab to.
|
|
509
|
+
*/
|
|
510
|
+
const FORM_TAG = {
|
|
511
|
+
image: { tag: "img", voidEl: true },
|
|
512
|
+
heading: { tag: "h3" },
|
|
513
|
+
text: { tag: "span" },
|
|
514
|
+
button: { tag: "button" },
|
|
515
|
+
field: { tag: "input", voidEl: true },
|
|
516
|
+
icon: { tag: "span" },
|
|
517
|
+
row: { tag: "div" },
|
|
518
|
+
stack: { tag: "div" },
|
|
519
|
+
};
|
|
520
|
+
/** Only these two arrange; the rest are leaves. */
|
|
521
|
+
const ARRANGES = new Set(["row", "stack"]);
|
|
522
|
+
/** Every part the tree names, in tree order - so the generated part components
|
|
523
|
+
* come out in the order somebody reading the file expects to find them. */
|
|
524
|
+
function treeParts(nodes, out = []) {
|
|
525
|
+
for (const node of nodes ?? []) {
|
|
526
|
+
if (node.part && !out.includes(node.part))
|
|
527
|
+
out.push(node.part);
|
|
528
|
+
if (node.children)
|
|
529
|
+
treeParts(node.children, out);
|
|
530
|
+
}
|
|
531
|
+
return out;
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* THE COMPOSITION, EMITTED AS JSX RATHER THAN DESCRIBED IN A COMMENT.
|
|
535
|
+
*
|
|
536
|
+
* This is the third thing the anatomy was supposed to buy, in the owner's own
|
|
537
|
+
* words: speed, and fewer tokens spent in Claude Code. `<ArticleCard />` used to
|
|
538
|
+
* render an empty shell, and assembling it meant an agent reading a JSDoc example
|
|
539
|
+
* and writing the tree by hand - every time, for every component. Now the tree is
|
|
540
|
+
* in the file.
|
|
541
|
+
*
|
|
542
|
+
* A frontier is a COMMENT, never invented markup. A component of theirs has a
|
|
543
|
+
* recipe of its own and the generated sibling may not exist yet; a third-party
|
|
544
|
+
* library is not ours to render. Both say what belongs there and leave the slot
|
|
545
|
+
* open, which is the honest instruction.
|
|
546
|
+
*/
|
|
547
|
+
function emitTree(nodes, comp, indent) {
|
|
548
|
+
const lines = [];
|
|
549
|
+
for (const node of nodes) {
|
|
550
|
+
if (node.as === "component") {
|
|
551
|
+
lines.push(`${indent}{/* your <${pascal(node.ref ?? "")} /> goes here - it has a recipe of its own, so it is not inlined */}`);
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
if (node.as === "external") {
|
|
555
|
+
lines.push(`${indent}{/* ${node.from} renders here - a third party's component, so its markup is theirs */}`);
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
if (!node.part) {
|
|
559
|
+
// Pure structure with no styles of its own: keep the arrangement, skip the
|
|
560
|
+
// element - a div that carries nothing is a div nobody needs.
|
|
561
|
+
if (node.children)
|
|
562
|
+
lines.push(emitTree(node.children, comp, indent));
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
const partComp = `${comp}${pascal(node.part)}`;
|
|
566
|
+
if (ARRANGES.has(node.as) && node.children && node.children.length > 0) {
|
|
567
|
+
lines.push(`${indent}<${partComp}>`);
|
|
568
|
+
lines.push(emitTree(node.children, comp, `${indent} `));
|
|
569
|
+
lines.push(`${indent}</${partComp}>`);
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
const { voidEl } = FORM_TAG[node.as] ?? {};
|
|
573
|
+
if (voidEl) {
|
|
574
|
+
lines.push(`${indent}<${partComp} />`);
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
lines.push(`${indent}<${partComp}>${node.text ?? `{/* ${node.part} */}`}</${partComp}>`);
|
|
578
|
+
}
|
|
579
|
+
return lines.filter(Boolean).join("\n");
|
|
580
|
+
}
|
|
481
581
|
/** JSDoc showing how to compose the component with its parts + content, so the
|
|
482
582
|
* materialized code doesn't read as "a bare shell renders nothing" (dogfood
|
|
483
583
|
* #5). Built from the recipe's parts. */
|
|
484
584
|
function compositionHint(comp, name, recipe, voidEl = false) {
|
|
585
|
+
const tree = recipe.preview?.parts;
|
|
485
586
|
const partNames = Object.keys(recipe.parts ?? {});
|
|
486
587
|
// An `<input>` or `<hr>` takes no children, and telling somebody to put
|
|
487
588
|
// content inside one is an instruction that throws. With parts, the element
|
|
@@ -489,6 +590,16 @@ function compositionHint(comp, name, recipe, voidEl = false) {
|
|
|
489
590
|
if (voidEl) {
|
|
490
591
|
return `/** Wears the "${name}" recipe. Takes no children - it renders a single void element. */`;
|
|
491
592
|
}
|
|
593
|
+
if (tree && tree.length > 0) {
|
|
594
|
+
// The shape is IN the file below, so the comment stops teaching assembly and
|
|
595
|
+
// starts saying what is already there and how to change it.
|
|
596
|
+
return `/**
|
|
597
|
+
* Wears the "${name}" recipe, composed as your own code composes it - the shape
|
|
598
|
+
* below was read out of your JSX, so it renders as the component rather than as
|
|
599
|
+
* an empty shell. Replace the placeholders with your content; every part is also
|
|
600
|
+
* exported on its own if you need a different arrangement.
|
|
601
|
+
*/`;
|
|
602
|
+
}
|
|
492
603
|
if (partNames.length === 0) {
|
|
493
604
|
return `/** Wears the "${name}" recipe. Put your content inside: <${comp}>…</${comp}>. */`;
|
|
494
605
|
}
|
|
@@ -503,37 +614,112 @@ ${inner}
|
|
|
503
614
|
* </${comp}>
|
|
504
615
|
*/`;
|
|
505
616
|
}
|
|
506
|
-
|
|
617
|
+
/**
|
|
618
|
+
* WHICH TAG A PART GETS, and the tree is what finally lets this be right.
|
|
619
|
+
*
|
|
620
|
+
* A part that styles focus is a control and has always been promoted to a button
|
|
621
|
+
* - that check stays and still wins, because a focus ring is a stronger statement
|
|
622
|
+
* than a name. Below it, the tree's `as` decides: a heading is an `<h3>`, an image
|
|
623
|
+
* an `<img>`, a field an `<input>`. Without a tree everything falls back to `div`,
|
|
624
|
+
* which is exactly what every part used to be.
|
|
625
|
+
*/
|
|
626
|
+
function partTagFor(part, form) {
|
|
627
|
+
if (partIsInteractive(part)) {
|
|
628
|
+
return { tag: "button", attrs: ' type="button"', voidEl: false };
|
|
629
|
+
}
|
|
630
|
+
const mapped = form ? FORM_TAG[form] : undefined;
|
|
631
|
+
if (!mapped)
|
|
632
|
+
return { tag: "div", attrs: "", voidEl: false };
|
|
633
|
+
return {
|
|
634
|
+
tag: mapped.tag,
|
|
635
|
+
/**
|
|
636
|
+
* `type="button"` IS NOT OPTIONAL ON A BUTTON.
|
|
637
|
+
*
|
|
638
|
+
* A `<button>` with no type defaults to `submit`, so the moment one of these
|
|
639
|
+
* parts sits inside a form, clicking it submits the form. `asElement` already
|
|
640
|
+
* knows how to carry this through the `as` escape hatch - it just needs to be
|
|
641
|
+
* told, and the interactive branch above was the only caller telling it.
|
|
642
|
+
*
|
|
643
|
+
* An `<img>` with no `alt` is an accessibility failure the generator would be
|
|
644
|
+
* authoring, so it ships with an empty one: decorative by default, and the
|
|
645
|
+
* caller overrides it through `...props` the moment it carries meaning.
|
|
646
|
+
*/
|
|
647
|
+
attrs: mapped.tag === "button"
|
|
648
|
+
? ' type="button"'
|
|
649
|
+
: mapped.tag === "img"
|
|
650
|
+
? ' alt=""'
|
|
651
|
+
: "",
|
|
652
|
+
voidEl: Boolean(mapped.voidEl),
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
function emitCssMode(slug, name, recipe, version, props, convention,
|
|
656
|
+
/** The name it takes in THEIR project. The class stays the system's. */
|
|
657
|
+
localName = name) {
|
|
507
658
|
const { tag, attrs, voidEl } = elementFor(name, recipe);
|
|
508
659
|
const el = asElement(tag, attrs, voidEl);
|
|
509
660
|
const axes = axesOf(recipe.variants);
|
|
510
|
-
const comp = pascal(
|
|
661
|
+
const comp = pascal(localName);
|
|
511
662
|
const propNames = axes.map((a) => a.prop);
|
|
663
|
+
const tree = recipe.preview?.parts ?? [];
|
|
664
|
+
// With a shape the root RENDERS its children, so it needs `children` in the
|
|
665
|
+
// destructure and a real closing tag rather than a self-closing one.
|
|
666
|
+
const hasShape = tree.length > 0 && !voidEl;
|
|
512
667
|
const destructure = [
|
|
513
668
|
...propNames,
|
|
514
669
|
...(el.offersAs ? ["as"] : []),
|
|
670
|
+
...(hasShape ? ["children"] : []),
|
|
515
671
|
"className",
|
|
516
672
|
"...props",
|
|
517
673
|
].join(", ");
|
|
518
|
-
const
|
|
519
|
-
const
|
|
674
|
+
const rootOpen = ` <${el.jsxTag}${el.jsxAttrs}\n className={${joinCls([`"${elementClass(name, convention)}"`, "className"])}}\n${dataAttrLines(axes)}${axes.length ? "\n" : ""} {...props}\n `;
|
|
675
|
+
const rootJsx = hasShape
|
|
676
|
+
? `${rootOpen}>\n${emitTree(tree, comp, " ")}\n {children}\n </${el.jsxTag}>`
|
|
677
|
+
: `${rootOpen}/>`;
|
|
678
|
+
// Tree order first, because that is the order somebody reads them in the file;
|
|
679
|
+
// anything the tree does not mention still gets its component.
|
|
680
|
+
const ordered = [
|
|
681
|
+
...treeParts(recipe.preview?.parts).filter((p) => recipe.parts?.[p]),
|
|
682
|
+
...Object.keys(recipe.parts ?? {}).filter((p) => !treeParts(recipe.preview?.parts).includes(p)),
|
|
683
|
+
];
|
|
684
|
+
const formOf = new Map();
|
|
685
|
+
const collect = (nodes) => {
|
|
686
|
+
for (const node of nodes) {
|
|
687
|
+
if (node.part)
|
|
688
|
+
formOf.set(node.part, node.as);
|
|
689
|
+
if (node.children)
|
|
690
|
+
collect(node.children);
|
|
691
|
+
}
|
|
692
|
+
};
|
|
693
|
+
collect(tree);
|
|
694
|
+
const parts = ordered.map((partName) => {
|
|
695
|
+
const part = recipe.parts?.[partName];
|
|
696
|
+
if (!part)
|
|
697
|
+
return "";
|
|
520
698
|
const partAxes = axesOf(part.variants ?? {});
|
|
521
699
|
const partComp = `${comp}${pascal(partName)}`;
|
|
522
|
-
const
|
|
523
|
-
const
|
|
524
|
-
|
|
700
|
+
const { tag: partTag, attrs: partAttrs, voidEl: partVoid, } = partTagFor(part, formOf.get(partName));
|
|
701
|
+
const partEl = asElement(partTag, partAttrs, partVoid);
|
|
702
|
+
/**
|
|
703
|
+
* `as` ONLY WHERE THE ELEMENT HONOURS IT.
|
|
704
|
+
*
|
|
705
|
+
* A void element ignores it - `asElement` says so with `offersAs: false` - so
|
|
706
|
+
* typing and destructuring it anyway would declare a prop that silently does
|
|
707
|
+
* nothing. This file already paid for that exact shape once, on a different
|
|
708
|
+
* prop, and no part could be void until the tree started naming images and
|
|
709
|
+
* fields.
|
|
710
|
+
*/
|
|
525
711
|
const partDestructure = [
|
|
526
712
|
...partAxes.map((a) => a.prop),
|
|
527
|
-
"as",
|
|
713
|
+
...(partEl.offersAs ? ["as"] : []),
|
|
528
714
|
"className",
|
|
529
715
|
"...props",
|
|
530
716
|
].join(", ");
|
|
531
717
|
return `
|
|
532
|
-
/** Part "${partName}" of ${comp} - compose it inside <${comp}>. */
|
|
533
|
-
export function ${partComp}({ ${partDestructure} }: ${propsType(partAxes, partTag, props,
|
|
718
|
+
/** Part "${partName}" of ${comp}${formOf.get(partName) ? ` (${formOf.get(partName)})` : ""} - compose it inside <${comp}>. */
|
|
719
|
+
export function ${partComp}({ ${partDestructure} }: ${propsType(partAxes, partTag, props, partEl.offersAs)}) {
|
|
534
720
|
${partEl.setup} return (
|
|
535
721
|
<${partEl.jsxTag}${partEl.jsxAttrs}
|
|
536
|
-
className={${joinCls([`"
|
|
722
|
+
className={${joinCls([`"${partClassName(name, partName, convention)}"`, "className"])}}
|
|
537
723
|
${dataAttrLines(partAxes)}${partAxes.length ? "\n" : ""} {...props}
|
|
538
724
|
/>
|
|
539
725
|
);
|
|
@@ -541,7 +727,7 @@ ${dataAttrLines(partAxes)}${partAxes.length ? "\n" : ""} {...props}
|
|
|
541
727
|
});
|
|
542
728
|
const needsElementType = el.offersAs || Object.keys(recipe.parts ?? {}).length > 0;
|
|
543
729
|
return `${header(slug, name, version, "css")}
|
|
544
|
-
import "./${
|
|
730
|
+
import "./${localName}.css";
|
|
545
731
|
|
|
546
732
|
import type { ${needsElementType ? `ElementType, ${props}` : props} } from "react";
|
|
547
733
|
|
|
@@ -553,13 +739,15 @@ ${el.setup} return (
|
|
|
553
739
|
${rootJsx}
|
|
554
740
|
);
|
|
555
741
|
}
|
|
556
|
-
${parts.join("\n")}`;
|
|
742
|
+
${parts.filter(Boolean).join("\n")}`;
|
|
557
743
|
}
|
|
558
|
-
function emitTailwindMode(slug, name, recipe, version, props
|
|
744
|
+
function emitTailwindMode(slug, name, recipe, version, props,
|
|
745
|
+
/** The name it takes in THEIR project. The utilities stay the system's. */
|
|
746
|
+
localName = name) {
|
|
559
747
|
const { tag, attrs, voidEl } = elementFor(name, recipe);
|
|
560
748
|
const el = asElement(tag, attrs, voidEl);
|
|
561
749
|
const axes = axesOf(recipe.variants);
|
|
562
|
-
const comp = pascal(
|
|
750
|
+
const comp = pascal(localName);
|
|
563
751
|
const variantConsts = axes
|
|
564
752
|
.filter((a) => !a.boolean)
|
|
565
753
|
.map((a) => {
|
|
@@ -584,13 +772,37 @@ function emitTailwindMode(slug, name, recipe, version, props) {
|
|
|
584
772
|
: `${a.prop} ? ${a.prop.toUpperCase()}[${a.prop}] : ${fallbackFor(a)}`),
|
|
585
773
|
"className",
|
|
586
774
|
];
|
|
775
|
+
const tree = recipe.preview?.parts ?? [];
|
|
776
|
+
// Same as css mode: with a shape the root renders its children, so it needs
|
|
777
|
+
// `children` and a real closing tag. The CLASSES differ between flavours; the
|
|
778
|
+
// SHAPE is the same fact about their component either way.
|
|
779
|
+
const hasShape = tree.length > 0 && !voidEl;
|
|
587
780
|
const destructure = [
|
|
588
781
|
...axes.map((a) => a.prop),
|
|
589
782
|
...(el.offersAs ? ["as"] : []),
|
|
783
|
+
...(hasShape ? ["children"] : []),
|
|
590
784
|
"className",
|
|
591
785
|
"...props",
|
|
592
786
|
].join(", ");
|
|
593
787
|
const needsElementType = el.offersAs || Object.keys(recipe.parts ?? {}).length > 0;
|
|
788
|
+
const formOf = new Map();
|
|
789
|
+
const collect = (nodes) => {
|
|
790
|
+
for (const node of nodes) {
|
|
791
|
+
if (node.part)
|
|
792
|
+
formOf.set(node.part, node.as);
|
|
793
|
+
if (node.children)
|
|
794
|
+
collect(node.children);
|
|
795
|
+
}
|
|
796
|
+
};
|
|
797
|
+
collect(tree);
|
|
798
|
+
const treeOrder = treeParts(recipe.preview?.parts);
|
|
799
|
+
const orderedParts = [
|
|
800
|
+
...treeOrder.filter((p) => recipe.parts?.[p]),
|
|
801
|
+
...Object.keys(recipe.parts ?? {}).filter((p) => !treeOrder.includes(p)),
|
|
802
|
+
];
|
|
803
|
+
const rootOpen = ` <${el.jsxTag}${el.jsxAttrs}
|
|
804
|
+
className={${resolveCls(clsParts)}}
|
|
805
|
+
${dataAttrLines(axes)}${axes.length ? "\n" : ""} `;
|
|
594
806
|
return `${header(slug, name, version, "tailwind")}
|
|
595
807
|
|
|
596
808
|
import type { ${needsElementType ? `ElementType, ${props}` : props} } from "react";
|
|
@@ -604,19 +816,25 @@ type ${comp}Props = ${propsType(axes, tag, props, el.offersAs)};
|
|
|
604
816
|
${compositionHint(comp, name, recipe, voidEl)}
|
|
605
817
|
export function ${comp}({ ${destructure} }: ${comp}Props) {
|
|
606
818
|
${el.setup} return (
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
819
|
+
${hasShape
|
|
820
|
+
? `${rootOpen} {...props}
|
|
821
|
+
>
|
|
822
|
+
${emitTree(tree, comp, " ")}
|
|
823
|
+
{children}
|
|
824
|
+
</${el.jsxTag}>`
|
|
825
|
+
: `${rootOpen} {...props}
|
|
826
|
+
/>`}
|
|
611
827
|
);
|
|
612
828
|
}
|
|
613
|
-
${
|
|
614
|
-
.map((
|
|
829
|
+
${orderedParts
|
|
830
|
+
.map((partName) => {
|
|
831
|
+
const part = recipe.parts?.[partName];
|
|
832
|
+
if (!part)
|
|
833
|
+
return "";
|
|
615
834
|
const partComp = `${comp}${pascal(partName)}`;
|
|
616
835
|
const partAxes = axesOf(part.variants ?? {});
|
|
617
|
-
const
|
|
618
|
-
const
|
|
619
|
-
const partEl = asElement(partTag, control ? ' type="button"' : "", false);
|
|
836
|
+
const { tag: partTag, attrs: partAttrs, voidEl: partVoid, } = partTagFor(part, formOf.get(partName));
|
|
837
|
+
const partEl = asElement(partTag, partAttrs, partVoid);
|
|
620
838
|
/**
|
|
621
839
|
* THE STATE THE CATALOGUE DOCUMENTS, DELIVERED (1kro2o, 29/07).
|
|
622
840
|
*
|
|
@@ -637,15 +855,24 @@ ${Object.entries(recipe.parts ?? {})
|
|
|
637
855
|
const partCls = [tailwindClassList(part), ...partVariantClasses]
|
|
638
856
|
.filter(Boolean)
|
|
639
857
|
.join(" ");
|
|
858
|
+
/**
|
|
859
|
+
* `as` ONLY WHERE THE ELEMENT HONOURS IT.
|
|
860
|
+
*
|
|
861
|
+
* A void element ignores it - `asElement` says so with `offersAs: false` - so
|
|
862
|
+
* typing and destructuring it anyway would declare a prop that silently does
|
|
863
|
+
* nothing. This file already paid for that exact shape once, on a different
|
|
864
|
+
* prop, and no part could be void until the tree started naming images and
|
|
865
|
+
* fields.
|
|
866
|
+
*/
|
|
640
867
|
const partDestructure = [
|
|
641
868
|
...partAxes.map((a) => a.prop),
|
|
642
|
-
"as",
|
|
869
|
+
...(partEl.offersAs ? ["as"] : []),
|
|
643
870
|
"className",
|
|
644
871
|
"...props",
|
|
645
872
|
].join(", ");
|
|
646
873
|
return `
|
|
647
|
-
/** Part "${partName}" of ${comp} - compose it inside <${comp}>. */
|
|
648
|
-
export function ${partComp}({ ${partDestructure} }: ${propsType(partAxes, partTag, props,
|
|
874
|
+
/** Part "${partName}" of ${comp}${formOf.get(partName) ? ` (${formOf.get(partName)})` : ""} - compose it inside <${comp}>. */
|
|
875
|
+
export function ${partComp}({ ${partDestructure} }: ${propsType(partAxes, partTag, props, partEl.offersAs)}) {
|
|
649
876
|
${partEl.setup} return (
|
|
650
877
|
<${partEl.jsxTag}${partEl.jsxAttrs}
|
|
651
878
|
className={${resolveCls([JSON.stringify(partCls), "className"])}}
|
|
@@ -654,33 +881,50 @@ ${dataAttrLines(partAxes)}${partAxes.length ? "\n" : ""} {...props}
|
|
|
654
881
|
);
|
|
655
882
|
}`;
|
|
656
883
|
})
|
|
884
|
+
.filter(Boolean)
|
|
657
885
|
.join("\n")}`;
|
|
658
886
|
}
|
|
659
887
|
/** All files for one component, under `<componentsDir>/<name>/`. */
|
|
660
888
|
export function generateComponentFiles(slug, name, recipe, css, version, styles,
|
|
661
889
|
/** Consumer's React major, read from its package.json. Null = unknown, which
|
|
662
890
|
* keeps the ref-less type rather than guessing in the unsafe direction. */
|
|
663
|
-
reactMajor = null
|
|
664
|
-
|
|
891
|
+
reactMajor = null,
|
|
892
|
+
/**
|
|
893
|
+
* HOW THE SYSTEM SPELLS A CLASS, from the registry. Absent = ours, which is
|
|
894
|
+
* every caller that predates the convention being the user's - and getting this
|
|
895
|
+
* wrong shipped a component wearing classes its own stylesheet never emits.
|
|
896
|
+
*/
|
|
897
|
+
convention = DEFAULT_CONVENTION,
|
|
898
|
+
/**
|
|
899
|
+
* THE NAME IT TAKES IN THEIR PROJECT, when it cannot take its own.
|
|
900
|
+
*
|
|
901
|
+
* A project that already exports `Button` should not have to give the name up to
|
|
902
|
+
* install ours - we adapt to what they built. So the FILE and the EXPORT can be
|
|
903
|
+
* renamed while the CLASS stays the design system's: the recipe compiles
|
|
904
|
+
* `.ds-button`, and a `<MySystemButton>` wearing it is styled correctly and
|
|
905
|
+
* shadows nothing of theirs.
|
|
906
|
+
*
|
|
907
|
+
* Absent means the component keeps its own name, which is every caller today.
|
|
908
|
+
*/
|
|
909
|
+
localName = name) {
|
|
665
910
|
const files = [];
|
|
666
911
|
const props = propsTypeName(reactMajor);
|
|
667
912
|
if (styles === "css") {
|
|
668
913
|
files.push({
|
|
669
|
-
filename: `${
|
|
670
|
-
code: `${emitCssMode(slug, name, recipe, version, props)}\n`,
|
|
914
|
+
filename: `${localName}.tsx`,
|
|
915
|
+
code: `${emitCssMode(slug, name, recipe, version, props, convention, localName)}\n`,
|
|
671
916
|
});
|
|
672
|
-
files.push({ filename: `${
|
|
917
|
+
files.push({ filename: `${localName}.css`, code: `${css}\n` });
|
|
673
918
|
}
|
|
674
919
|
else {
|
|
675
920
|
files.push({
|
|
676
|
-
filename: `${
|
|
677
|
-
code: `${emitTailwindMode(slug, name, recipe, version, props)}\n`,
|
|
921
|
+
filename: `${localName}.tsx`,
|
|
922
|
+
code: `${emitTailwindMode(slug, name, recipe, version, props, localName)}\n`,
|
|
678
923
|
});
|
|
679
924
|
}
|
|
680
925
|
files.push({
|
|
681
926
|
filename: "index.ts",
|
|
682
|
-
code: `export * from "./${
|
|
927
|
+
code: `export * from "./${localName}";\n`,
|
|
683
928
|
});
|
|
684
|
-
void comp;
|
|
685
929
|
return files;
|
|
686
930
|
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE LIBRARIES THE SYSTEM SAYS YOU NEED, CHECKED AGAINST WHAT YOU HAVE.
|
|
3
|
+
*
|
|
4
|
+
* Some of somebody's components are built on a third-party library - a
|
|
5
|
+
* `TextEditor` on tiptap, a chart on recharts. We do not have that library, will
|
|
6
|
+
* never have it, and cannot draw it, so the anatomy stops at the frontier and the
|
|
7
|
+
* value moves into a RULE: `TextEditor requires @tiptap/react`.
|
|
8
|
+
*
|
|
9
|
+
* A rule nobody checks is a sentence. This is the half that makes it governance:
|
|
10
|
+
* the rule carries the package name as a field, and here we read the consumer's
|
|
11
|
+
* own manifest and say what is missing.
|
|
12
|
+
*
|
|
13
|
+
* TWO THINGS IT DELIBERATELY DOES NOT DO.
|
|
14
|
+
*
|
|
15
|
+
* It never installs. Installing a package on somebody's behalf is exactly the
|
|
16
|
+
* class of thing this product does not do - the agent reports and ASKS, because a
|
|
17
|
+
* dependency is a decision with a licence, a bundle cost and a maintainer
|
|
18
|
+
* attached (dono, 01/08).
|
|
19
|
+
*
|
|
20
|
+
* It never checks a VERSION. The rule carries the name and the evidence carries
|
|
21
|
+
* the range their own project pinned - `^2.1.0 in packages/ui` - which is
|
|
22
|
+
* information, not a requirement. Turning it into one would mean failing somebody
|
|
23
|
+
* for upgrading a library we do not ship, and the range would be stale the day
|
|
24
|
+
* after it was measured.
|
|
25
|
+
*/
|
|
26
|
+
/**
|
|
27
|
+
* Every required package this project does not have.
|
|
28
|
+
*
|
|
29
|
+
* `deps` is the merged dependencies + devDependencies of the nearest manifests -
|
|
30
|
+
* `resolveDeps` in `stack.ts`, the same reader the stack detection uses, so a
|
|
31
|
+
* monorepo's root and package manifests both count.
|
|
32
|
+
*
|
|
33
|
+
* A package needed by three components is ONE finding with three names on it, not
|
|
34
|
+
* three findings: the action is a single install, and repeating it would make a
|
|
35
|
+
* well-factored system look like it has more problems than a badly factored one.
|
|
36
|
+
*/
|
|
37
|
+
export function missingDependencies(rules, deps) {
|
|
38
|
+
const byName = new Map();
|
|
39
|
+
for (const rule of rules) {
|
|
40
|
+
const name = rule.requires?.trim();
|
|
41
|
+
if (!name)
|
|
42
|
+
continue;
|
|
43
|
+
// Present is present. We do not compare ranges - see the note above.
|
|
44
|
+
if (Object.hasOwn(deps, name))
|
|
45
|
+
continue;
|
|
46
|
+
const held = byName.get(name);
|
|
47
|
+
// The component the rule is about, which is what the reader needs in order to
|
|
48
|
+
// decide whether they even use that part of the system.
|
|
49
|
+
const owners = (rule.applies ?? []).filter(Boolean);
|
|
50
|
+
if (held) {
|
|
51
|
+
for (const owner of owners) {
|
|
52
|
+
if (!held.neededBy.includes(owner))
|
|
53
|
+
held.neededBy.push(owner);
|
|
54
|
+
}
|
|
55
|
+
held.pinned ??= rule.pinned;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
byName.set(name, {
|
|
59
|
+
name,
|
|
60
|
+
neededBy: [...new Set(owners)],
|
|
61
|
+
...(rule.pinned ? { pinned: rule.pinned } : {}),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
return [...byName.values()];
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* What the doctor prints, and the wording is the point.
|
|
68
|
+
*
|
|
69
|
+
* This is not drift and it is not a mistake: the author's own project has the
|
|
70
|
+
* library, and the project being checked is a DIFFERENT one that has not installed
|
|
71
|
+
* it yet. So it reads as a prerequisite, names who needs it, and stops - the
|
|
72
|
+
* install command is offered, never run.
|
|
73
|
+
*/
|
|
74
|
+
export function describeMissing(missing) {
|
|
75
|
+
const lines = [];
|
|
76
|
+
for (const dep of missing) {
|
|
77
|
+
const who = dep.neededBy.length > 0
|
|
78
|
+
? `${dep.neededBy.map((n) => `ds-${n}`).join(", ")} need${dep.neededBy.length === 1 ? "s" : ""} it`
|
|
79
|
+
: "part of this system needs it";
|
|
80
|
+
lines.push(`${dep.name} not in your manifest - ${who}${dep.pinned ? ` (${dep.pinned})` : ""}`);
|
|
81
|
+
}
|
|
82
|
+
return lines;
|
|
83
|
+
}
|
|
84
|
+
/** The one sentence above the list, once. */
|
|
85
|
+
export function summarizeMissing(missing) {
|
|
86
|
+
if (missing.length === 0)
|
|
87
|
+
return null;
|
|
88
|
+
const n = missing.length;
|
|
89
|
+
return `${n} librar${n === 1 ? "y this system needs is" : "ies this system needs are"} not installed here. ${n === 1 ? "It is" : "They are"} a third party's, not ours - so nothing was installed for you, and the components that need ${n === 1 ? "it" : "them"} will not render until you decide.`;
|
|
90
|
+
}
|
|
@@ -123,6 +123,58 @@ const TAILWIND_COLOR = {
|
|
|
123
123
|
"neutral-900": "#171717",
|
|
124
124
|
"neutral-950": "#0a0a0a",
|
|
125
125
|
};
|
|
126
|
+
/**
|
|
127
|
+
* TYPOGRAPHY, and the reason it is here at all.
|
|
128
|
+
*
|
|
129
|
+
* Colour, spacing and radius were read and type was not, so every text part of
|
|
130
|
+
* every imported component arrived with an EMPTY style block. Structurally the
|
|
131
|
+
* anatomy was right and a card's title still previewed at body size - which is
|
|
132
|
+
* the same "it is not a version of the component" complaint one layer down, and
|
|
133
|
+
* the spec view's whole `type` line never fired because there was nothing to
|
|
134
|
+
* report (dono, 01/08).
|
|
135
|
+
*
|
|
136
|
+
* Same argument as the tables above: Tailwind's type scale is fixed and
|
|
137
|
+
* published, so reading it is arithmetic rather than a guess. Their own
|
|
138
|
+
* `--text-*` and `--font-weight-*` tokens win, exactly as with colour.
|
|
139
|
+
*/
|
|
140
|
+
const FONT_SIZE = {
|
|
141
|
+
xs: "0.75rem",
|
|
142
|
+
sm: "0.875rem",
|
|
143
|
+
base: "1rem",
|
|
144
|
+
lg: "1.125rem",
|
|
145
|
+
xl: "1.25rem",
|
|
146
|
+
"2xl": "1.5rem",
|
|
147
|
+
"3xl": "1.875rem",
|
|
148
|
+
"4xl": "2.25rem",
|
|
149
|
+
"5xl": "3rem",
|
|
150
|
+
"6xl": "3.75rem",
|
|
151
|
+
"7xl": "4.5rem",
|
|
152
|
+
};
|
|
153
|
+
const FONT_WEIGHT = {
|
|
154
|
+
thin: "100",
|
|
155
|
+
extralight: "200",
|
|
156
|
+
light: "300",
|
|
157
|
+
normal: "400",
|
|
158
|
+
medium: "500",
|
|
159
|
+
semibold: "600",
|
|
160
|
+
bold: "700",
|
|
161
|
+
extrabold: "800",
|
|
162
|
+
black: "900",
|
|
163
|
+
};
|
|
164
|
+
/** The three family slots the document actually has. Nothing else may be a ref. */
|
|
165
|
+
const FAMILY_SLOT = {
|
|
166
|
+
sans: "body",
|
|
167
|
+
serif: "display",
|
|
168
|
+
mono: "mono",
|
|
169
|
+
};
|
|
170
|
+
/** Type utilities with no scale behind them - a fact, not a decision deferred. */
|
|
171
|
+
const TYPE_KEYWORD = {
|
|
172
|
+
uppercase: { property: "textTransform", value: "uppercase" },
|
|
173
|
+
lowercase: { property: "textTransform", value: "lowercase" },
|
|
174
|
+
capitalize: { property: "textTransform", value: "capitalize" },
|
|
175
|
+
italic: { property: "fontStyle", value: "italic" },
|
|
176
|
+
underline: { property: "textDecoration", value: "underline" },
|
|
177
|
+
};
|
|
126
178
|
/** States we recognise. Anything else is skipped rather than invented. */
|
|
127
179
|
const STATE = /^(hover|focus|focus-visible|active|disabled|checked)$/;
|
|
128
180
|
const DATA_STATE = /^data-\[([a-z-]+)\]$/;
|
|
@@ -164,6 +216,11 @@ export function readUtility(utility, declared) {
|
|
|
164
216
|
// would mean computing a colour they never wrote. The property still belongs
|
|
165
217
|
// in the recipe, so the base colour travels and the alpha does not.
|
|
166
218
|
const [core] = utility.split("/");
|
|
219
|
+
// A bare keyword carries a declaration with no scale behind it, so it is read
|
|
220
|
+
// before the dash split that everything else needs.
|
|
221
|
+
const keyword = TYPE_KEYWORD[core];
|
|
222
|
+
if (keyword)
|
|
223
|
+
return keyword;
|
|
167
224
|
const dash = core.indexOf("-");
|
|
168
225
|
if (dash === -1)
|
|
169
226
|
return null;
|
|
@@ -180,6 +237,66 @@ export function readUtility(utility, declared) {
|
|
|
180
237
|
const builtin = TAILWIND_COLOR[rest];
|
|
181
238
|
if (builtin)
|
|
182
239
|
return { property: colorProp, value: builtin };
|
|
240
|
+
/**
|
|
241
|
+
* `text-` IS AMBIGUOUS, and only this branch knows it failed.
|
|
242
|
+
*
|
|
243
|
+
* `text-white` is a colour and `text-2xl` is a size, and both arrive with the
|
|
244
|
+
* same prefix. Returning null here dropped every font size in the codebase in
|
|
245
|
+
* silence - so a `text` that resolved to no colour falls through to the type
|
|
246
|
+
* scale instead of ending the read.
|
|
247
|
+
*/
|
|
248
|
+
if (prefix !== "text")
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
if (prefix === "text") {
|
|
252
|
+
/**
|
|
253
|
+
* A SIZE TRAVELS AS A LITERAL, and that is not a shortcut.
|
|
254
|
+
*
|
|
255
|
+
* The document holds `typography.scale` as named STYLES (size plus line
|
|
256
|
+
* height plus weight), not as a flat table of lengths - so there is no ref a
|
|
257
|
+
* `font-size` can point at, and inventing `{typography.sizes.lg}` would emit
|
|
258
|
+
* a token that resolves to nothing. That renders as no size at all, which is
|
|
259
|
+
* worse than the literal and reads as a missing style.
|
|
260
|
+
*
|
|
261
|
+
* So the value they wrote travels, and the spec view marks it as an open
|
|
262
|
+
* decision - which is exactly what it is: a real length with no step of
|
|
263
|
+
* theirs to land on yet. That is the v2's to propose, not ours to fabricate.
|
|
264
|
+
*/
|
|
265
|
+
const own = `--text-${rest}`;
|
|
266
|
+
const theirs = declared.get(own);
|
|
267
|
+
// Their own declaration outranks Tailwind's default for the same name.
|
|
268
|
+
if (theirs)
|
|
269
|
+
return { property: "fontSize", value: theirs, token: own };
|
|
270
|
+
if (FONT_SIZE[rest])
|
|
271
|
+
return { property: "fontSize", value: FONT_SIZE[rest] };
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
if (prefix === "font") {
|
|
275
|
+
// A family (`font-sans`) and a weight (`font-bold`) share this prefix, and
|
|
276
|
+
// the weight table is what tells them apart.
|
|
277
|
+
if (FONT_WEIGHT[rest]) {
|
|
278
|
+
/**
|
|
279
|
+
* ALSO A LITERAL, for a reason worth writing down: a weight ref is a valid
|
|
280
|
+
* SHAPE and still not safe. `{typography.weights.bold}` dangles on a system
|
|
281
|
+
* whose document declares `regular / medium / semibold` - and this side has
|
|
282
|
+
* no document to ask, because the CLI runs before one exists. A number
|
|
283
|
+
* renders correctly everywhere and the spec view marks it as the open
|
|
284
|
+
* decision it is.
|
|
285
|
+
*/
|
|
286
|
+
return { property: "fontWeight", value: FONT_WEIGHT[rest] };
|
|
287
|
+
}
|
|
288
|
+
// A family DOES have a safe ref: the document's `families` is a fixed
|
|
289
|
+
// display/body/mono, so only those three map and anything else is skipped
|
|
290
|
+
// rather than pointed at a slot that does not exist.
|
|
291
|
+
const slot = FAMILY_SLOT[rest];
|
|
292
|
+
const family = `--font-${rest}`;
|
|
293
|
+
if (slot && declared.has(family)) {
|
|
294
|
+
return {
|
|
295
|
+
property: "fontFamily",
|
|
296
|
+
value: `{typography.families.${slot}}`,
|
|
297
|
+
token: family,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
183
300
|
return null;
|
|
184
301
|
}
|
|
185
302
|
const spaceProp = SPACING_PROPERTY[prefix];
|