synthesisui 0.16.77 → 0.16.78
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 +231 -0
- package/dist/commands/add.js +23 -1
- package/dist/commands/component.js +11 -23
- package/dist/commands/doctor.js +49 -1
- package/dist/commands/generate.js +5 -1
- package/dist/commands/import.js +71 -15
- package/dist/commands/refit.js +2 -1
- package/dist/commands/upgrade.js +6 -1
- package/dist/component-codegen.js +243 -25
- package/dist/doctor/dependencies.js +90 -0
- package/dist/doctor/transcribe.js +117 -0
- package/dist/project-facts.js +89 -0
- package/dist/skill-import.js +149 -34
- 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)
|
|
@@ -478,10 +486,91 @@ const joinCls = (parts) => `[${parts.join(", ")}].filter(Boolean).join(" ")`;
|
|
|
478
486
|
* applies, which is what the call site reads like.
|
|
479
487
|
*/
|
|
480
488
|
const resolveCls = (parts) => `cn(${parts.join(", ")})`;
|
|
489
|
+
/**
|
|
490
|
+
* WHAT ELEMENT EACH FORM IS, and this is where the anatomy stops being a picture.
|
|
491
|
+
*
|
|
492
|
+
* Every part came out as a `<div>` - a title, a value, a cover image, all divs -
|
|
493
|
+
* because a flat list of names cannot say what a part IS. The tree says, so a
|
|
494
|
+
* heading generates an `<h3>`, an image an `<img>`, a field an `<input>`. That is
|
|
495
|
+
* not cosmetic: it decides what a screen reader announces and what a browser lets
|
|
496
|
+
* you tab to.
|
|
497
|
+
*/
|
|
498
|
+
const FORM_TAG = {
|
|
499
|
+
image: { tag: "img", voidEl: true },
|
|
500
|
+
heading: { tag: "h3" },
|
|
501
|
+
text: { tag: "span" },
|
|
502
|
+
button: { tag: "button" },
|
|
503
|
+
field: { tag: "input", voidEl: true },
|
|
504
|
+
icon: { tag: "span" },
|
|
505
|
+
row: { tag: "div" },
|
|
506
|
+
stack: { tag: "div" },
|
|
507
|
+
};
|
|
508
|
+
/** Only these two arrange; the rest are leaves. */
|
|
509
|
+
const ARRANGES = new Set(["row", "stack"]);
|
|
510
|
+
/** Every part the tree names, in tree order - so the generated part components
|
|
511
|
+
* come out in the order somebody reading the file expects to find them. */
|
|
512
|
+
function treeParts(nodes, out = []) {
|
|
513
|
+
for (const node of nodes ?? []) {
|
|
514
|
+
if (node.part && !out.includes(node.part))
|
|
515
|
+
out.push(node.part);
|
|
516
|
+
if (node.children)
|
|
517
|
+
treeParts(node.children, out);
|
|
518
|
+
}
|
|
519
|
+
return out;
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
522
|
+
* THE COMPOSITION, EMITTED AS JSX RATHER THAN DESCRIBED IN A COMMENT.
|
|
523
|
+
*
|
|
524
|
+
* This is the third thing the anatomy was supposed to buy, in the owner's own
|
|
525
|
+
* words: speed, and fewer tokens spent in Claude Code. `<ArticleCard />` used to
|
|
526
|
+
* render an empty shell, and assembling it meant an agent reading a JSDoc example
|
|
527
|
+
* and writing the tree by hand - every time, for every component. Now the tree is
|
|
528
|
+
* in the file.
|
|
529
|
+
*
|
|
530
|
+
* A frontier is a COMMENT, never invented markup. A component of theirs has a
|
|
531
|
+
* recipe of its own and the generated sibling may not exist yet; a third-party
|
|
532
|
+
* library is not ours to render. Both say what belongs there and leave the slot
|
|
533
|
+
* open, which is the honest instruction.
|
|
534
|
+
*/
|
|
535
|
+
function emitTree(nodes, comp, indent) {
|
|
536
|
+
const lines = [];
|
|
537
|
+
for (const node of nodes) {
|
|
538
|
+
if (node.as === "component") {
|
|
539
|
+
lines.push(`${indent}{/* your <${pascal(node.ref ?? "")} /> goes here - it has a recipe of its own, so it is not inlined */}`);
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
if (node.as === "external") {
|
|
543
|
+
lines.push(`${indent}{/* ${node.from} renders here - a third party's component, so its markup is theirs */}`);
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
546
|
+
if (!node.part) {
|
|
547
|
+
// Pure structure with no styles of its own: keep the arrangement, skip the
|
|
548
|
+
// element - a div that carries nothing is a div nobody needs.
|
|
549
|
+
if (node.children)
|
|
550
|
+
lines.push(emitTree(node.children, comp, indent));
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
const partComp = `${comp}${pascal(node.part)}`;
|
|
554
|
+
if (ARRANGES.has(node.as) && node.children && node.children.length > 0) {
|
|
555
|
+
lines.push(`${indent}<${partComp}>`);
|
|
556
|
+
lines.push(emitTree(node.children, comp, `${indent} `));
|
|
557
|
+
lines.push(`${indent}</${partComp}>`);
|
|
558
|
+
continue;
|
|
559
|
+
}
|
|
560
|
+
const { voidEl } = FORM_TAG[node.as] ?? {};
|
|
561
|
+
if (voidEl) {
|
|
562
|
+
lines.push(`${indent}<${partComp} />`);
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
lines.push(`${indent}<${partComp}>${node.text ?? `{/* ${node.part} */}`}</${partComp}>`);
|
|
566
|
+
}
|
|
567
|
+
return lines.filter(Boolean).join("\n");
|
|
568
|
+
}
|
|
481
569
|
/** JSDoc showing how to compose the component with its parts + content, so the
|
|
482
570
|
* materialized code doesn't read as "a bare shell renders nothing" (dogfood
|
|
483
571
|
* #5). Built from the recipe's parts. */
|
|
484
572
|
function compositionHint(comp, name, recipe, voidEl = false) {
|
|
573
|
+
const tree = recipe.preview?.parts;
|
|
485
574
|
const partNames = Object.keys(recipe.parts ?? {});
|
|
486
575
|
// An `<input>` or `<hr>` takes no children, and telling somebody to put
|
|
487
576
|
// content inside one is an instruction that throws. With parts, the element
|
|
@@ -489,6 +578,16 @@ function compositionHint(comp, name, recipe, voidEl = false) {
|
|
|
489
578
|
if (voidEl) {
|
|
490
579
|
return `/** Wears the "${name}" recipe. Takes no children - it renders a single void element. */`;
|
|
491
580
|
}
|
|
581
|
+
if (tree && tree.length > 0) {
|
|
582
|
+
// The shape is IN the file below, so the comment stops teaching assembly and
|
|
583
|
+
// starts saying what is already there and how to change it.
|
|
584
|
+
return `/**
|
|
585
|
+
* Wears the "${name}" recipe, composed as your own code composes it - the shape
|
|
586
|
+
* below was read out of your JSX, so it renders as the component rather than as
|
|
587
|
+
* an empty shell. Replace the placeholders with your content; every part is also
|
|
588
|
+
* exported on its own if you need a different arrangement.
|
|
589
|
+
*/`;
|
|
590
|
+
}
|
|
492
591
|
if (partNames.length === 0) {
|
|
493
592
|
return `/** Wears the "${name}" recipe. Put your content inside: <${comp}>…</${comp}>. */`;
|
|
494
593
|
}
|
|
@@ -503,37 +602,110 @@ ${inner}
|
|
|
503
602
|
* </${comp}>
|
|
504
603
|
*/`;
|
|
505
604
|
}
|
|
506
|
-
|
|
605
|
+
/**
|
|
606
|
+
* WHICH TAG A PART GETS, and the tree is what finally lets this be right.
|
|
607
|
+
*
|
|
608
|
+
* A part that styles focus is a control and has always been promoted to a button
|
|
609
|
+
* - that check stays and still wins, because a focus ring is a stronger statement
|
|
610
|
+
* than a name. Below it, the tree's `as` decides: a heading is an `<h3>`, an image
|
|
611
|
+
* an `<img>`, a field an `<input>`. Without a tree everything falls back to `div`,
|
|
612
|
+
* which is exactly what every part used to be.
|
|
613
|
+
*/
|
|
614
|
+
function partTagFor(part, form) {
|
|
615
|
+
if (partIsInteractive(part)) {
|
|
616
|
+
return { tag: "button", attrs: ' type="button"', voidEl: false };
|
|
617
|
+
}
|
|
618
|
+
const mapped = form ? FORM_TAG[form] : undefined;
|
|
619
|
+
if (!mapped)
|
|
620
|
+
return { tag: "div", attrs: "", voidEl: false };
|
|
621
|
+
return {
|
|
622
|
+
tag: mapped.tag,
|
|
623
|
+
/**
|
|
624
|
+
* `type="button"` IS NOT OPTIONAL ON A BUTTON.
|
|
625
|
+
*
|
|
626
|
+
* A `<button>` with no type defaults to `submit`, so the moment one of these
|
|
627
|
+
* parts sits inside a form, clicking it submits the form. `asElement` already
|
|
628
|
+
* knows how to carry this through the `as` escape hatch - it just needs to be
|
|
629
|
+
* told, and the interactive branch above was the only caller telling it.
|
|
630
|
+
*
|
|
631
|
+
* An `<img>` with no `alt` is an accessibility failure the generator would be
|
|
632
|
+
* authoring, so it ships with an empty one: decorative by default, and the
|
|
633
|
+
* caller overrides it through `...props` the moment it carries meaning.
|
|
634
|
+
*/
|
|
635
|
+
attrs: mapped.tag === "button"
|
|
636
|
+
? ' type="button"'
|
|
637
|
+
: mapped.tag === "img"
|
|
638
|
+
? ' alt=""'
|
|
639
|
+
: "",
|
|
640
|
+
voidEl: Boolean(mapped.voidEl),
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
function emitCssMode(slug, name, recipe, version, props, convention) {
|
|
507
644
|
const { tag, attrs, voidEl } = elementFor(name, recipe);
|
|
508
645
|
const el = asElement(tag, attrs, voidEl);
|
|
509
646
|
const axes = axesOf(recipe.variants);
|
|
510
647
|
const comp = pascal(name);
|
|
511
648
|
const propNames = axes.map((a) => a.prop);
|
|
649
|
+
const tree = recipe.preview?.parts ?? [];
|
|
650
|
+
// With a shape the root RENDERS its children, so it needs `children` in the
|
|
651
|
+
// destructure and a real closing tag rather than a self-closing one.
|
|
652
|
+
const hasShape = tree.length > 0 && !voidEl;
|
|
512
653
|
const destructure = [
|
|
513
654
|
...propNames,
|
|
514
655
|
...(el.offersAs ? ["as"] : []),
|
|
656
|
+
...(hasShape ? ["children"] : []),
|
|
515
657
|
"className",
|
|
516
658
|
"...props",
|
|
517
659
|
].join(", ");
|
|
518
|
-
const
|
|
519
|
-
const
|
|
660
|
+
const rootOpen = ` <${el.jsxTag}${el.jsxAttrs}\n className={${joinCls([`"${elementClass(name, convention)}"`, "className"])}}\n${dataAttrLines(axes)}${axes.length ? "\n" : ""} {...props}\n `;
|
|
661
|
+
const rootJsx = hasShape
|
|
662
|
+
? `${rootOpen}>\n${emitTree(tree, comp, " ")}\n {children}\n </${el.jsxTag}>`
|
|
663
|
+
: `${rootOpen}/>`;
|
|
664
|
+
// Tree order first, because that is the order somebody reads them in the file;
|
|
665
|
+
// anything the tree does not mention still gets its component.
|
|
666
|
+
const ordered = [
|
|
667
|
+
...treeParts(recipe.preview?.parts).filter((p) => recipe.parts?.[p]),
|
|
668
|
+
...Object.keys(recipe.parts ?? {}).filter((p) => !treeParts(recipe.preview?.parts).includes(p)),
|
|
669
|
+
];
|
|
670
|
+
const formOf = new Map();
|
|
671
|
+
const collect = (nodes) => {
|
|
672
|
+
for (const node of nodes) {
|
|
673
|
+
if (node.part)
|
|
674
|
+
formOf.set(node.part, node.as);
|
|
675
|
+
if (node.children)
|
|
676
|
+
collect(node.children);
|
|
677
|
+
}
|
|
678
|
+
};
|
|
679
|
+
collect(tree);
|
|
680
|
+
const parts = ordered.map((partName) => {
|
|
681
|
+
const part = recipe.parts?.[partName];
|
|
682
|
+
if (!part)
|
|
683
|
+
return "";
|
|
520
684
|
const partAxes = axesOf(part.variants ?? {});
|
|
521
685
|
const partComp = `${comp}${pascal(partName)}`;
|
|
522
|
-
const
|
|
523
|
-
const
|
|
524
|
-
|
|
686
|
+
const { tag: partTag, attrs: partAttrs, voidEl: partVoid, } = partTagFor(part, formOf.get(partName));
|
|
687
|
+
const partEl = asElement(partTag, partAttrs, partVoid);
|
|
688
|
+
/**
|
|
689
|
+
* `as` ONLY WHERE THE ELEMENT HONOURS IT.
|
|
690
|
+
*
|
|
691
|
+
* A void element ignores it - `asElement` says so with `offersAs: false` - so
|
|
692
|
+
* typing and destructuring it anyway would declare a prop that silently does
|
|
693
|
+
* nothing. This file already paid for that exact shape once, on a different
|
|
694
|
+
* prop, and no part could be void until the tree started naming images and
|
|
695
|
+
* fields.
|
|
696
|
+
*/
|
|
525
697
|
const partDestructure = [
|
|
526
698
|
...partAxes.map((a) => a.prop),
|
|
527
|
-
"as",
|
|
699
|
+
...(partEl.offersAs ? ["as"] : []),
|
|
528
700
|
"className",
|
|
529
701
|
"...props",
|
|
530
702
|
].join(", ");
|
|
531
703
|
return `
|
|
532
|
-
/** Part "${partName}" of ${comp} - compose it inside <${comp}>. */
|
|
533
|
-
export function ${partComp}({ ${partDestructure} }: ${propsType(partAxes, partTag, props,
|
|
704
|
+
/** Part "${partName}" of ${comp}${formOf.get(partName) ? ` (${formOf.get(partName)})` : ""} - compose it inside <${comp}>. */
|
|
705
|
+
export function ${partComp}({ ${partDestructure} }: ${propsType(partAxes, partTag, props, partEl.offersAs)}) {
|
|
534
706
|
${partEl.setup} return (
|
|
535
707
|
<${partEl.jsxTag}${partEl.jsxAttrs}
|
|
536
|
-
className={${joinCls([`"
|
|
708
|
+
className={${joinCls([`"${partClassName(name, partName, convention)}"`, "className"])}}
|
|
537
709
|
${dataAttrLines(partAxes)}${partAxes.length ? "\n" : ""} {...props}
|
|
538
710
|
/>
|
|
539
711
|
);
|
|
@@ -553,7 +725,7 @@ ${el.setup} return (
|
|
|
553
725
|
${rootJsx}
|
|
554
726
|
);
|
|
555
727
|
}
|
|
556
|
-
${parts.join("\n")}`;
|
|
728
|
+
${parts.filter(Boolean).join("\n")}`;
|
|
557
729
|
}
|
|
558
730
|
function emitTailwindMode(slug, name, recipe, version, props) {
|
|
559
731
|
const { tag, attrs, voidEl } = elementFor(name, recipe);
|
|
@@ -584,13 +756,37 @@ function emitTailwindMode(slug, name, recipe, version, props) {
|
|
|
584
756
|
: `${a.prop} ? ${a.prop.toUpperCase()}[${a.prop}] : ${fallbackFor(a)}`),
|
|
585
757
|
"className",
|
|
586
758
|
];
|
|
759
|
+
const tree = recipe.preview?.parts ?? [];
|
|
760
|
+
// Same as css mode: with a shape the root renders its children, so it needs
|
|
761
|
+
// `children` and a real closing tag. The CLASSES differ between flavours; the
|
|
762
|
+
// SHAPE is the same fact about their component either way.
|
|
763
|
+
const hasShape = tree.length > 0 && !voidEl;
|
|
587
764
|
const destructure = [
|
|
588
765
|
...axes.map((a) => a.prop),
|
|
589
766
|
...(el.offersAs ? ["as"] : []),
|
|
767
|
+
...(hasShape ? ["children"] : []),
|
|
590
768
|
"className",
|
|
591
769
|
"...props",
|
|
592
770
|
].join(", ");
|
|
593
771
|
const needsElementType = el.offersAs || Object.keys(recipe.parts ?? {}).length > 0;
|
|
772
|
+
const formOf = new Map();
|
|
773
|
+
const collect = (nodes) => {
|
|
774
|
+
for (const node of nodes) {
|
|
775
|
+
if (node.part)
|
|
776
|
+
formOf.set(node.part, node.as);
|
|
777
|
+
if (node.children)
|
|
778
|
+
collect(node.children);
|
|
779
|
+
}
|
|
780
|
+
};
|
|
781
|
+
collect(tree);
|
|
782
|
+
const treeOrder = treeParts(recipe.preview?.parts);
|
|
783
|
+
const orderedParts = [
|
|
784
|
+
...treeOrder.filter((p) => recipe.parts?.[p]),
|
|
785
|
+
...Object.keys(recipe.parts ?? {}).filter((p) => !treeOrder.includes(p)),
|
|
786
|
+
];
|
|
787
|
+
const rootOpen = ` <${el.jsxTag}${el.jsxAttrs}
|
|
788
|
+
className={${resolveCls(clsParts)}}
|
|
789
|
+
${dataAttrLines(axes)}${axes.length ? "\n" : ""} `;
|
|
594
790
|
return `${header(slug, name, version, "tailwind")}
|
|
595
791
|
|
|
596
792
|
import type { ${needsElementType ? `ElementType, ${props}` : props} } from "react";
|
|
@@ -604,19 +800,25 @@ type ${comp}Props = ${propsType(axes, tag, props, el.offersAs)};
|
|
|
604
800
|
${compositionHint(comp, name, recipe, voidEl)}
|
|
605
801
|
export function ${comp}({ ${destructure} }: ${comp}Props) {
|
|
606
802
|
${el.setup} return (
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
803
|
+
${hasShape
|
|
804
|
+
? `${rootOpen} {...props}
|
|
805
|
+
>
|
|
806
|
+
${emitTree(tree, comp, " ")}
|
|
807
|
+
{children}
|
|
808
|
+
</${el.jsxTag}>`
|
|
809
|
+
: `${rootOpen} {...props}
|
|
810
|
+
/>`}
|
|
611
811
|
);
|
|
612
812
|
}
|
|
613
|
-
${
|
|
614
|
-
.map((
|
|
813
|
+
${orderedParts
|
|
814
|
+
.map((partName) => {
|
|
815
|
+
const part = recipe.parts?.[partName];
|
|
816
|
+
if (!part)
|
|
817
|
+
return "";
|
|
615
818
|
const partComp = `${comp}${pascal(partName)}`;
|
|
616
819
|
const partAxes = axesOf(part.variants ?? {});
|
|
617
|
-
const
|
|
618
|
-
const
|
|
619
|
-
const partEl = asElement(partTag, control ? ' type="button"' : "", false);
|
|
820
|
+
const { tag: partTag, attrs: partAttrs, voidEl: partVoid, } = partTagFor(part, formOf.get(partName));
|
|
821
|
+
const partEl = asElement(partTag, partAttrs, partVoid);
|
|
620
822
|
/**
|
|
621
823
|
* THE STATE THE CATALOGUE DOCUMENTS, DELIVERED (1kro2o, 29/07).
|
|
622
824
|
*
|
|
@@ -637,15 +839,24 @@ ${Object.entries(recipe.parts ?? {})
|
|
|
637
839
|
const partCls = [tailwindClassList(part), ...partVariantClasses]
|
|
638
840
|
.filter(Boolean)
|
|
639
841
|
.join(" ");
|
|
842
|
+
/**
|
|
843
|
+
* `as` ONLY WHERE THE ELEMENT HONOURS IT.
|
|
844
|
+
*
|
|
845
|
+
* A void element ignores it - `asElement` says so with `offersAs: false` - so
|
|
846
|
+
* typing and destructuring it anyway would declare a prop that silently does
|
|
847
|
+
* nothing. This file already paid for that exact shape once, on a different
|
|
848
|
+
* prop, and no part could be void until the tree started naming images and
|
|
849
|
+
* fields.
|
|
850
|
+
*/
|
|
640
851
|
const partDestructure = [
|
|
641
852
|
...partAxes.map((a) => a.prop),
|
|
642
|
-
"as",
|
|
853
|
+
...(partEl.offersAs ? ["as"] : []),
|
|
643
854
|
"className",
|
|
644
855
|
"...props",
|
|
645
856
|
].join(", ");
|
|
646
857
|
return `
|
|
647
|
-
/** Part "${partName}" of ${comp} - compose it inside <${comp}>. */
|
|
648
|
-
export function ${partComp}({ ${partDestructure} }: ${propsType(partAxes, partTag, props,
|
|
858
|
+
/** Part "${partName}" of ${comp}${formOf.get(partName) ? ` (${formOf.get(partName)})` : ""} - compose it inside <${comp}>. */
|
|
859
|
+
export function ${partComp}({ ${partDestructure} }: ${propsType(partAxes, partTag, props, partEl.offersAs)}) {
|
|
649
860
|
${partEl.setup} return (
|
|
650
861
|
<${partEl.jsxTag}${partEl.jsxAttrs}
|
|
651
862
|
className={${resolveCls([JSON.stringify(partCls), "className"])}}
|
|
@@ -654,20 +865,27 @@ ${dataAttrLines(partAxes)}${partAxes.length ? "\n" : ""} {...props}
|
|
|
654
865
|
);
|
|
655
866
|
}`;
|
|
656
867
|
})
|
|
868
|
+
.filter(Boolean)
|
|
657
869
|
.join("\n")}`;
|
|
658
870
|
}
|
|
659
871
|
/** All files for one component, under `<componentsDir>/<name>/`. */
|
|
660
872
|
export function generateComponentFiles(slug, name, recipe, css, version, styles,
|
|
661
873
|
/** Consumer's React major, read from its package.json. Null = unknown, which
|
|
662
874
|
* keeps the ref-less type rather than guessing in the unsafe direction. */
|
|
663
|
-
reactMajor = null
|
|
875
|
+
reactMajor = null,
|
|
876
|
+
/**
|
|
877
|
+
* HOW THE SYSTEM SPELLS A CLASS, from the registry. Absent = ours, which is
|
|
878
|
+
* every caller that predates the convention being the user's - and getting this
|
|
879
|
+
* wrong shipped a component wearing classes its own stylesheet never emits.
|
|
880
|
+
*/
|
|
881
|
+
convention = DEFAULT_CONVENTION) {
|
|
664
882
|
const comp = pascal(name);
|
|
665
883
|
const files = [];
|
|
666
884
|
const props = propsTypeName(reactMajor);
|
|
667
885
|
if (styles === "css") {
|
|
668
886
|
files.push({
|
|
669
887
|
filename: `${name}.tsx`,
|
|
670
|
-
code: `${emitCssMode(slug, name, recipe, version, props)}\n`,
|
|
888
|
+
code: `${emitCssMode(slug, name, recipe, version, props, convention)}\n`,
|
|
671
889
|
});
|
|
672
890
|
files.push({ filename: `${name}.css`, code: `${css}\n` });
|
|
673
891
|
}
|
|
@@ -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];
|