synthesisui 0.16.17 → 0.16.18
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/claude-md.js +5 -5
- package/dist/cn-codegen.js +168 -0
- package/dist/commands/component.js +24 -0
- package/dist/component-codegen.js +28 -6
- package/package.json +1 -1
package/dist/claude-md.js
CHANGED
|
@@ -281,11 +281,11 @@ YOURSELF. It materializes that component as real typed code in this project, and
|
|
|
281
281
|
extend that. The person who asked you for a feature should never have to know these command
|
|
282
282
|
names or type them; finding the right component is your job, not theirs.
|
|
283
283
|
|
|
284
|
-
|
|
285
|
-
\`className="text-info
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
284
|
+
Overriding a style a component already sets just works - write
|
|
285
|
+
\`className="text-info"\`, with no \`!\`. The generated components resolve your class
|
|
286
|
+
against their own, so the last one written wins, which is what the call site reads like.
|
|
287
|
+
If an override is ignored, that component predates the resolver: regenerate it with
|
|
288
|
+
\`npx synthesisui@latest component <slug> <name>\` rather than reaching for \`!\`.
|
|
289
289
|
|
|
290
290
|
Only write something new when nothing in the manifest covers the purpose - and when you do,
|
|
291
291
|
say which entry you considered and why it did not fit. To review a
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cn()`: the override the consumer writes has to actually win.
|
|
3
|
+
*
|
|
4
|
+
* THE BUG. Two plain Tailwind utilities that set the same property have the
|
|
5
|
+
* same specificity, so which one applies is decided by the order Tailwind emits
|
|
6
|
+
* them into the stylesheet - not by the order of names in the string, which is
|
|
7
|
+
* the only thing the call site appears to control. A component whose base says
|
|
8
|
+
* `text-foreground` therefore ignores `className="text-info"`, silently, and
|
|
9
|
+
* the code reads as if it should work.
|
|
10
|
+
*
|
|
11
|
+
* THE MITIGATION WE SHIPPED FIRST, and why it was not enough. On 27/07 the
|
|
12
|
+
* managed block started telling agents to write `text-info!`. It works, and the
|
|
13
|
+
* test on 28/07 measured what it costs: 61 exclamation marks in 1,885 generated
|
|
14
|
+
* lines, one forced escape every thirty lines. It also has a hole - two `!`
|
|
15
|
+
* utilities on the same property tie again, so it moves the coin flip up a
|
|
16
|
+
* level rather than removing it.
|
|
17
|
+
*
|
|
18
|
+
* WHY OURS CAN BE SMALLER THAN `tailwind-merge`. That library infers at runtime
|
|
19
|
+
* and cannot see your theme, so it carries a table of every Tailwind class group
|
|
20
|
+
* and still guesses at custom names. We generate the resolver next to the
|
|
21
|
+
* system, so it is not guessing: `text-foreground` is a colour because
|
|
22
|
+
* `foreground` is in this system's colour namespace, and `text-lg` is a size
|
|
23
|
+
* because `lg` is in its type scale. Those two families are the only genuinely
|
|
24
|
+
* ambiguous ones; everything else is decided by the prefix.
|
|
25
|
+
*
|
|
26
|
+
* WHAT IT DELIBERATELY DOES NOT DO. It resolves collisions on the SAME property
|
|
27
|
+
* key - `bg-a` against `bg-b`, `p-md` against `p-lg`. It does not model the
|
|
28
|
+
* hierarchy between `p` and `px` and `pt`, because Tailwind's own emission order
|
|
29
|
+
* already resolves those correctly, and pretending to solve more would be a
|
|
30
|
+
* second thing to be wrong about.
|
|
31
|
+
*/
|
|
32
|
+
/**
|
|
33
|
+
* Reads the `@theme` namespaces out of a compiled theme.css.
|
|
34
|
+
*
|
|
35
|
+
* `--color-foreground: …` means the project has a `text-foreground`,
|
|
36
|
+
* `bg-foreground` and `border-foreground`. Suffixes like
|
|
37
|
+
* `--text-lg--line-height` are declarations ABOUT a name, not names, so the
|
|
38
|
+
* double dash is what excludes them.
|
|
39
|
+
*/
|
|
40
|
+
export function readVocabulary(themeCss) {
|
|
41
|
+
const namesIn = (ns) => {
|
|
42
|
+
const out = new Set();
|
|
43
|
+
const re = new RegExp(`^\\s*--${ns}-([a-z0-9-]+)\\s*:`, "gm");
|
|
44
|
+
for (const m of themeCss.matchAll(re)) {
|
|
45
|
+
if (!m[1].includes("--"))
|
|
46
|
+
out.add(m[1]);
|
|
47
|
+
}
|
|
48
|
+
return [...out].sort();
|
|
49
|
+
};
|
|
50
|
+
const font = namesIn("font");
|
|
51
|
+
const weights = namesIn("font-weight");
|
|
52
|
+
return {
|
|
53
|
+
colors: namesIn("color"),
|
|
54
|
+
textSizes: namesIn("text"),
|
|
55
|
+
// `--font-weight-medium` also matches the `font` namespace as
|
|
56
|
+
// `weight-medium`; the real families are what is left after removing them.
|
|
57
|
+
fontFamilies: font.filter((f) => !f.startsWith("weight-")),
|
|
58
|
+
fontWeights: weights,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
const set = (names) => `new Set([${names.map((n) => JSON.stringify(n)).join(", ")}])`;
|
|
62
|
+
/** The `cn.ts` a project gets, written against ITS system's vocabulary. */
|
|
63
|
+
export function emitCn(slug, vocab) {
|
|
64
|
+
return `/**
|
|
65
|
+
* Class resolver for the "${slug}" design system. Generated by SynthesisUI.
|
|
66
|
+
*
|
|
67
|
+
* Two plain Tailwind utilities setting the same property have the same
|
|
68
|
+
* specificity, so the winner is decided by the order Tailwind writes the
|
|
69
|
+
* stylesheet - not by the order in your className, which is what the code looks
|
|
70
|
+
* like it controls. A component whose base sets \`text-foreground\` would ignore
|
|
71
|
+
* \`className="text-info"\` without any error.
|
|
72
|
+
*
|
|
73
|
+
* This drops the earlier class when a later one sets the same property, so the
|
|
74
|
+
* last one wins, which is what the call site reads like. Pass the base first and
|
|
75
|
+
* the caller's className last.
|
|
76
|
+
*
|
|
77
|
+
* It resolves collisions on the same property key (\`bg-a\` vs \`bg-b\`, \`p-md\` vs
|
|
78
|
+
* \`p-lg\`). It does not model \`p\` against \`px\` against \`pt\`: Tailwind's own
|
|
79
|
+
* emission order already gets those right.
|
|
80
|
+
*
|
|
81
|
+
* Regenerated by \`synthesisui component\`. Safe to edit, but a new component
|
|
82
|
+
* will overwrite it.
|
|
83
|
+
*/
|
|
84
|
+
|
|
85
|
+
/** This system's own namespaces, so \`text-*\` and \`font-*\` are never a guess. */
|
|
86
|
+
const COLORS = ${set(vocab.colors)};
|
|
87
|
+
const TEXT_SIZES = ${set(vocab.textSizes)};
|
|
88
|
+
const FONT_FAMILIES = ${set(vocab.fontFamilies)};
|
|
89
|
+
const FONT_WEIGHTS = ${set(vocab.fontWeights)};
|
|
90
|
+
|
|
91
|
+
/** Prefixes where the prefix alone decides the property. Longest first, so
|
|
92
|
+
* \`border-t\` is read before \`border\`. */
|
|
93
|
+
const PREFIXES = [
|
|
94
|
+
"bg",
|
|
95
|
+
"shadow",
|
|
96
|
+
"opacity",
|
|
97
|
+
"rounded-tl", "rounded-tr", "rounded-br", "rounded-bl",
|
|
98
|
+
"rounded-t", "rounded-r", "rounded-b", "rounded-l",
|
|
99
|
+
"rounded",
|
|
100
|
+
"border-t", "border-r", "border-b", "border-l", "border-x", "border-y",
|
|
101
|
+
"border",
|
|
102
|
+
"px", "py", "pt", "pr", "pb", "pl", "ps", "pe", "p",
|
|
103
|
+
"mx", "my", "mt", "mr", "mb", "ml", "ms", "me", "m",
|
|
104
|
+
"gap-x", "gap-y", "gap",
|
|
105
|
+
"w", "h", "min-w", "min-h", "max-w", "max-h",
|
|
106
|
+
"leading", "tracking",
|
|
107
|
+
"flex", "grid", "justify", "items", "self", "order", "z",
|
|
108
|
+
];
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The property this class sets, or the class itself when we cannot tell.
|
|
112
|
+
*
|
|
113
|
+
* Returning the class itself is the safe answer: an unknown class then collides
|
|
114
|
+
* only with an identical one, so nothing is ever dropped by mistake.
|
|
115
|
+
*/
|
|
116
|
+
function groupOf(cls: string): string {
|
|
117
|
+
// A variant is part of the identity: \`hover:bg-x\` must not replace \`bg-y\`.
|
|
118
|
+
const cut = cls.lastIndexOf(":");
|
|
119
|
+
const variants = cut === -1 ? "" : cls.slice(0, cut + 1);
|
|
120
|
+
let base = cut === -1 ? cls : cls.slice(cut + 1);
|
|
121
|
+
|
|
122
|
+
// \`!\` marks importance, \`/40\` sets opacity. Neither changes the property.
|
|
123
|
+
base = base.replace(/^!|!$/g, "").replace(/\\/[^/]+$/, "");
|
|
124
|
+
const negative = base.startsWith("-");
|
|
125
|
+
if (negative) base = base.slice(1);
|
|
126
|
+
|
|
127
|
+
const dash = base.indexOf("-");
|
|
128
|
+
const head = dash === -1 ? base : base.slice(0, dash);
|
|
129
|
+
const rest = dash === -1 ? "" : base.slice(dash + 1);
|
|
130
|
+
|
|
131
|
+
// The two families a prefix cannot settle, answered by this system's names.
|
|
132
|
+
if (head === "text") {
|
|
133
|
+
if (TEXT_SIZES.has(rest)) return \`\${variants}font-size\`;
|
|
134
|
+
if (COLORS.has(rest) || rest.startsWith("[")) return \`\${variants}text-color\`;
|
|
135
|
+
return \`\${variants}\${cls}\`;
|
|
136
|
+
}
|
|
137
|
+
if (head === "font") {
|
|
138
|
+
if (FONT_WEIGHTS.has(rest)) return \`\${variants}font-weight\`;
|
|
139
|
+
if (FONT_FAMILIES.has(rest)) return \`\${variants}font-family\`;
|
|
140
|
+
return \`\${variants}\${cls}\`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
for (const p of PREFIXES) {
|
|
144
|
+
if (base === p || base.startsWith(\`\${p}-\`)) return \`\${variants}\${p}\`;
|
|
145
|
+
}
|
|
146
|
+
return \`\${variants}\${cls}\`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export type ClassValue = string | false | null | undefined;
|
|
150
|
+
|
|
151
|
+
export function cn(...parts: ClassValue[]): string {
|
|
152
|
+
const won = new Map<string, string>();
|
|
153
|
+
for (const part of parts) {
|
|
154
|
+
if (!part) continue;
|
|
155
|
+
for (const cls of part.split(/\\s+/)) {
|
|
156
|
+
if (!cls) continue;
|
|
157
|
+
const key = groupOf(cls);
|
|
158
|
+
// Delete before set so the winner also takes the later POSITION. The
|
|
159
|
+
// string order does not decide the cascade, but it is what a person reads
|
|
160
|
+
// in the DOM, and a stale leading class there is a lie.
|
|
161
|
+
won.delete(key);
|
|
162
|
+
won.set(key, cls);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return [...won.values()].join(" ");
|
|
166
|
+
}
|
|
167
|
+
`;
|
|
168
|
+
}
|
|
@@ -1,10 +1,32 @@
|
|
|
1
1
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
+
import { emitCn, readVocabulary } from "../cn-codegen.js";
|
|
3
4
|
import { generateComponentFiles } from "../component-codegen.js";
|
|
4
5
|
import { readProjectConfig, resolveRegistry } from "../config.js";
|
|
5
6
|
import { hasInteractiveTemplate, interactiveTemplate, } from "../interactive-templates.js";
|
|
6
7
|
import { body, section, snippet } from "../output.js";
|
|
7
8
|
import { fetchComponent, RegistryError } from "../registry.js";
|
|
9
|
+
/**
|
|
10
|
+
* Writes the shared `cn.ts` next to the components, built from THIS project's
|
|
11
|
+
* installed theme.
|
|
12
|
+
*
|
|
13
|
+
* Only the tailwind flavour needs it: in css mode the recipe lives in
|
|
14
|
+
* `@layer components` and every utility already outranks it. Generated from the
|
|
15
|
+
* theme on disk rather than from a table of Tailwind's own names, which is why
|
|
16
|
+
* it can tell `text-foreground` (a colour here) from `text-lg` (a size here)
|
|
17
|
+
* without guessing.
|
|
18
|
+
*/
|
|
19
|
+
async function writeCn(root, compDir, slug) {
|
|
20
|
+
const theme = await readFile(join(root, "_synthesisui", "ds", slug, "theme.css"), "utf8").catch(() => "");
|
|
21
|
+
// The root file is a pointer at the pinned version; follow it.
|
|
22
|
+
const inner = /@import\s+["']\.\/([^"']+)["']/.exec(theme)?.[1];
|
|
23
|
+
const real = inner
|
|
24
|
+
? await readFile(join(root, "_synthesisui", "ds", slug, inner), "utf8").catch(() => theme)
|
|
25
|
+
: theme;
|
|
26
|
+
if (!real)
|
|
27
|
+
return;
|
|
28
|
+
await writeFile(join(compDir, "..", "cn.ts"), emitCn(slug, readVocabulary(real)), "utf8");
|
|
29
|
+
}
|
|
8
30
|
/**
|
|
9
31
|
* The consumer's React major, or null when we cannot tell.
|
|
10
32
|
*
|
|
@@ -85,6 +107,8 @@ export async function component(slug, name, opts) {
|
|
|
85
107
|
for (const file of files) {
|
|
86
108
|
await writeFile(join(compDir, file.filename), file.code, "utf8");
|
|
87
109
|
}
|
|
110
|
+
if (config.styles === "tailwind")
|
|
111
|
+
await writeCn(root, compDir, slug);
|
|
88
112
|
filenames = files.map((f) => f.filename);
|
|
89
113
|
}
|
|
90
114
|
const flavor = wantInteractive ? "interactive" : `styles: ${config.styles}`;
|
|
@@ -417,12 +417,33 @@ function header(slug, name, version, mode) {
|
|
|
417
417
|
* which this generator has. Until it is built, the failure at least stops
|
|
418
418
|
* being silent, which is the part that actually costs people time.
|
|
419
419
|
*/
|
|
420
|
+
/**
|
|
421
|
+
* This used to tell people to write `text-info!`, which worked and cost 61
|
|
422
|
+
* exclamation marks in 1,885 generated lines when it was measured (28/07). It
|
|
423
|
+
* also had a hole: two `!` utilities on the same property tie again.
|
|
424
|
+
*
|
|
425
|
+
* `cn()` removes the tie instead of moving it, so the instruction becomes a lie
|
|
426
|
+
* the moment it ships - and an instruction that asks for work the mechanism
|
|
427
|
+
* already did is the drift this product exists to catch.
|
|
428
|
+
*/
|
|
420
429
|
const OVERRIDE_WARNING = `//
|
|
421
|
-
// Overriding a style this component already sets
|
|
422
|
-
//
|
|
423
|
-
//
|
|
424
|
-
//
|
|
430
|
+
// Overriding a style this component already sets just works: \`className\` is
|
|
431
|
+
// resolved against the base by \`cn()\`, so the last one written wins.
|
|
432
|
+
// <Thing className="text-info" /> no \`!\` needed
|
|
433
|
+
// If an override is ignored, this file predates that resolver - regenerate it
|
|
434
|
+
// with \`npx synthesisui component <slug> <name>\`.`;
|
|
425
435
|
const joinCls = (parts) => `[${parts.join(", ")}].filter(Boolean).join(" ")`;
|
|
436
|
+
/**
|
|
437
|
+
* TAILWIND FLAVOUR ONLY, and the asymmetry is the point.
|
|
438
|
+
*
|
|
439
|
+
* In css mode the recipe compiles into `@layer components`, which every utility
|
|
440
|
+
* already outranks - so a caller's `className` wins by the cascade and a
|
|
441
|
+
* resolver would be dead weight. In tailwind mode the base IS utilities, tied
|
|
442
|
+
* with the caller's at the same specificity, and the winner is decided by
|
|
443
|
+
* stylesheet order. `cn()` drops the loser so the last one written actually
|
|
444
|
+
* applies, which is what the call site reads like.
|
|
445
|
+
*/
|
|
446
|
+
const resolveCls = (parts) => `cn(${parts.join(", ")})`;
|
|
426
447
|
/** JSDoc showing how to compose the component with its parts + content, so the
|
|
427
448
|
* materialized code doesn't read as "a bare shell renders nothing" (dogfood
|
|
428
449
|
* #5). Built from the recipe's parts. */
|
|
@@ -528,6 +549,7 @@ function emitTailwindMode(slug, name, recipe, version, props) {
|
|
|
528
549
|
return `${header(slug, name, version, "tailwind")}
|
|
529
550
|
|
|
530
551
|
import type { ${props} } from "react";
|
|
552
|
+
import { cn } from "../cn";
|
|
531
553
|
|
|
532
554
|
const BASE = ${JSON.stringify(tailwindClassList(recipe, excluded))};
|
|
533
555
|
${[...variantConsts, ...booleanConsts].join("\n")}
|
|
@@ -538,7 +560,7 @@ ${compositionHint(comp, name, recipe, voidEl)}
|
|
|
538
560
|
export function ${comp}({ ${destructure} }: ${comp}Props) {
|
|
539
561
|
return (
|
|
540
562
|
<${tag}${attrs}
|
|
541
|
-
className={${
|
|
563
|
+
className={${resolveCls(clsParts)}}
|
|
542
564
|
{...props}
|
|
543
565
|
/>
|
|
544
566
|
);
|
|
@@ -553,7 +575,7 @@ ${Object.entries(recipe.parts ?? {})
|
|
|
553
575
|
/** Part "${partName}" of ${comp} - compose it inside <${comp}>. */
|
|
554
576
|
export function ${partComp}({ className, ...props }: ${props}<"${partTag}">) {
|
|
555
577
|
return (
|
|
556
|
-
<${partTag}${partAttrs} className={${
|
|
578
|
+
<${partTag}${partAttrs} className={${resolveCls([JSON.stringify(tailwindClassList(part)), "className"])}} {...props} />
|
|
557
579
|
);
|
|
558
580
|
}`;
|
|
559
581
|
})
|
package/package.json
CHANGED