synthesisui 0.1.23 → 0.2.0
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/commands/component.js +70 -0
- package/dist/commands/init.js +2 -0
- package/dist/component-codegen.js +382 -0
- package/dist/config.js +2 -0
- package/dist/guide.js +12 -0
- package/dist/index.js +40 -1
- package/dist/registry.js +21 -0
- package/package.json +1 -1
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { generateComponentFiles } from "../component-codegen.js";
|
|
4
|
+
import { readProjectConfig, resolveRegistry } from "../config.js";
|
|
5
|
+
import { fetchComponent, RegistryError } from "../registry.js";
|
|
6
|
+
/** Slugs/names are kebab-case by contract; reject anything else before it ever
|
|
7
|
+
* reaches a filesystem path (defense-in-depth against `../` traversal). */
|
|
8
|
+
const SAFE_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
9
|
+
/**
|
|
10
|
+
* Brings ONE component from a design system into the project (granular "bring
|
|
11
|
+
* specific", INS-18 fatia 3):
|
|
12
|
+
*
|
|
13
|
+
* 1. Artifacts (source of truth) → `_synthesisui/ds/<slug>/components/`:
|
|
14
|
+
* the recipe (.json, for agents/tooling) + compiled CSS (.css).
|
|
15
|
+
* 2. YOUR component (unless --artifacts-only, target "next") →
|
|
16
|
+
* `<componentsDir>/<name>/` from `_synthesisui/config.json`: a real
|
|
17
|
+
* `export function <Pascal>()` with variants as typed props, in the
|
|
18
|
+
* project's chosen flavor (`styles: "css" | "tailwind"`).
|
|
19
|
+
*
|
|
20
|
+
* The component's styles reference the DS tokens, so the system itself must be
|
|
21
|
+
* installed (`synthesisui add <slug>`) for `tokens.css`/`theme.css` to resolve.
|
|
22
|
+
*/
|
|
23
|
+
export async function component(slug, name, opts) {
|
|
24
|
+
const base = resolveRegistry(opts.registry);
|
|
25
|
+
const root = opts.dir ?? process.cwd();
|
|
26
|
+
if (!SAFE_NAME.test(slug)) {
|
|
27
|
+
throw new RegistryError(`Invalid slug "${slug}".`);
|
|
28
|
+
}
|
|
29
|
+
console.log(`→ fetching "${name}" from "${slug}" …`);
|
|
30
|
+
const res = await fetchComponent(base, slug, name, opts.version);
|
|
31
|
+
// The server should only ever return a kebab-case name, but never trust a
|
|
32
|
+
// network value as a path segment.
|
|
33
|
+
if (!SAFE_NAME.test(res.name)) {
|
|
34
|
+
throw new RegistryError(`Registry returned an unsafe component name.`);
|
|
35
|
+
}
|
|
36
|
+
const dir = join(root, "_synthesisui", "ds", slug, "components");
|
|
37
|
+
await mkdir(dir, { recursive: true });
|
|
38
|
+
await writeFile(join(dir, `${res.name}.json`), `${JSON.stringify(res.recipe, null, 2)}\n`, "utf8");
|
|
39
|
+
await writeFile(join(dir, `${res.name}.css`), `${res.css}\n`, "utf8");
|
|
40
|
+
console.log(`✓ ${res.name} → _synthesisui/ds/${slug}/components/${res.name}.{json,css} (${slug} v${res.version})`);
|
|
41
|
+
// 2. YOUR component - a real, importable `export function <Pascal>()` in the
|
|
42
|
+
// project's flavor (config: styles css|tailwind), under componentsDir.
|
|
43
|
+
const config = await readProjectConfig(root);
|
|
44
|
+
if (!opts.artifactsOnly && config.target === "next") {
|
|
45
|
+
const compDir = join(root, config.componentsDir, res.name);
|
|
46
|
+
await mkdir(compDir, { recursive: true });
|
|
47
|
+
const files = generateComponentFiles(slug, res.name, res.recipe, res.css, res.version, config.styles);
|
|
48
|
+
for (const file of files) {
|
|
49
|
+
await writeFile(join(compDir, file.filename), file.code, "utf8");
|
|
50
|
+
}
|
|
51
|
+
const names = files.map((f) => f.filename).join(", ");
|
|
52
|
+
console.log(`✓ ${config.componentsDir}/${res.name}/ → ${names} (styles: ${config.styles})`);
|
|
53
|
+
}
|
|
54
|
+
console.log("");
|
|
55
|
+
console.log("Use it:");
|
|
56
|
+
console.log(` • once per app: synthesisui add ${slug} (tokens.css${config.styles === "tailwind" ? " + theme.css" : ""}), import it globally,`);
|
|
57
|
+
console.log(` and put data-ds="${slug}" on a root element (e.g. <body data-ds="${slug}">)`);
|
|
58
|
+
if (!opts.artifactsOnly && config.target === "next") {
|
|
59
|
+
const pascalName = res.name
|
|
60
|
+
.split(/[^a-zA-Z0-9]+/)
|
|
61
|
+
.filter(Boolean)
|
|
62
|
+
.map((p) => p[0].toUpperCase() + p.slice(1))
|
|
63
|
+
.join("");
|
|
64
|
+
console.log(` • import { ${pascalName} } from "./${config.componentsDir}/${res.name}" and render <${pascalName} />`);
|
|
65
|
+
console.log(` • or ask your agent: "use the ${pascalName} component from ${config.componentsDir}/${res.name} (SynthesisUI ${slug})"`);
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
console.log(` • @import "_synthesisui/ds/${slug}/components/${res.name}.css" and use <div class="ds-${res.name}">…</div>`);
|
|
69
|
+
}
|
|
70
|
+
}
|
package/dist/commands/init.js
CHANGED
|
@@ -14,12 +14,14 @@ export async function init(opts) {
|
|
|
14
14
|
target,
|
|
15
15
|
pagesDir: opts.pagesDir ?? (target === "next" ? "app" : DEFAULT_CONFIG.pagesDir),
|
|
16
16
|
componentsDir: opts.componentsDir ?? DEFAULT_CONFIG.componentsDir,
|
|
17
|
+
styles: opts.styles === "tailwind" ? "tailwind" : "css",
|
|
17
18
|
};
|
|
18
19
|
await writeProjectConfig(root, config);
|
|
19
20
|
console.log("✓ wrote _synthesisui/config.json");
|
|
20
21
|
console.log(` target: ${config.target}`);
|
|
21
22
|
console.log(` pagesDir: ${config.pagesDir}`);
|
|
22
23
|
console.log(` componentsDir: ${config.componentsDir}`);
|
|
24
|
+
console.log(` styles: ${config.styles}`);
|
|
23
25
|
// --ds bootstraps the project with a system in one step (tokens + philosophy
|
|
24
26
|
// + rules + CLAUDE.md all arrive via `add`).
|
|
25
27
|
if (opts.ds) {
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
const kebab = (v) => v.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
|
|
2
|
+
const pascal = (name) => name
|
|
3
|
+
.split(/[^a-zA-Z0-9]+/)
|
|
4
|
+
.filter(Boolean)
|
|
5
|
+
.map((p) => p[0].toUpperCase() + p.slice(1))
|
|
6
|
+
.join("");
|
|
7
|
+
const camel = (name) => {
|
|
8
|
+
const p = pascal(name);
|
|
9
|
+
return p[0].toLowerCase() + p.slice(1);
|
|
10
|
+
};
|
|
11
|
+
/** Intrinsic element + extra attrs per component, chosen like the platform
|
|
12
|
+
* renderer does (name first, then preview.kind). Fallback: div + children. */
|
|
13
|
+
function elementFor(name, recipe) {
|
|
14
|
+
const byName = {
|
|
15
|
+
input: { tag: "input" },
|
|
16
|
+
textarea: { tag: "textarea" },
|
|
17
|
+
select: { tag: "select" },
|
|
18
|
+
checkbox: { tag: "input", attrs: ' type="checkbox"' },
|
|
19
|
+
radio: { tag: "input", attrs: ' type="radio"' },
|
|
20
|
+
switch: { tag: "input", attrs: ' type="checkbox" role="switch"' },
|
|
21
|
+
slider: { tag: "input", attrs: ' type="range"' },
|
|
22
|
+
button: { tag: "button", attrs: ' type="button"' },
|
|
23
|
+
"icon-button": { tag: "button", attrs: ' type="button"' },
|
|
24
|
+
badge: { tag: "span" },
|
|
25
|
+
tag: { tag: "span" },
|
|
26
|
+
avatar: { tag: "span" },
|
|
27
|
+
divider: { tag: "hr" },
|
|
28
|
+
link: { tag: "a" },
|
|
29
|
+
};
|
|
30
|
+
const hit = byName[name] ??
|
|
31
|
+
(recipe.preview?.kind === "action"
|
|
32
|
+
? { tag: "button", attrs: ' type="button"' }
|
|
33
|
+
: undefined);
|
|
34
|
+
const tag = hit?.tag ?? "div";
|
|
35
|
+
return {
|
|
36
|
+
tag,
|
|
37
|
+
attrs: hit?.attrs ?? "",
|
|
38
|
+
voidEl: tag === "input" || tag === "hr",
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/** Variant axes → typed props. An axis whose options ⊆ {true,false} is a
|
|
42
|
+
* boolean prop; empty axes (no visual effect) are skipped. */
|
|
43
|
+
function axesOf(variants) {
|
|
44
|
+
const axes = [];
|
|
45
|
+
for (const [axis, options] of Object.entries(variants ?? {})) {
|
|
46
|
+
const keys = Object.keys(options).filter((k) => Object.keys(options[k] ?? {}).length > 0);
|
|
47
|
+
if (keys.length === 0)
|
|
48
|
+
continue;
|
|
49
|
+
axes.push({
|
|
50
|
+
key: axis,
|
|
51
|
+
prop: camel(axis),
|
|
52
|
+
attr: kebab(axis),
|
|
53
|
+
boolean: keys.every((k) => k === "true" || k === "false"),
|
|
54
|
+
styledFalse: keys.includes("false"),
|
|
55
|
+
options: keys,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
return axes;
|
|
59
|
+
}
|
|
60
|
+
function propsType(axes, tag) {
|
|
61
|
+
const extras = axes.map((a) => a.boolean
|
|
62
|
+
? ` ${a.prop}?: boolean;`
|
|
63
|
+
: ` ${a.prop}?: ${a.options.map((o) => `"${o}"`).join(" | ")};`);
|
|
64
|
+
if (extras.length === 0)
|
|
65
|
+
return `ComponentPropsWithoutRef<"${tag}">`;
|
|
66
|
+
return `ComponentPropsWithoutRef<"${tag}"> & {\n${extras.join("\n")}\n}`;
|
|
67
|
+
}
|
|
68
|
+
function dataAttrLines(axes) {
|
|
69
|
+
return axes
|
|
70
|
+
.map((a) => a.boolean
|
|
71
|
+
? a.styledFalse
|
|
72
|
+
? ` data-${a.attr}={${a.prop} ? "true" : "false"}`
|
|
73
|
+
: ` data-${a.attr}={${a.prop} ? "true" : undefined}`
|
|
74
|
+
: ` data-${a.attr}={${a.prop}}`)
|
|
75
|
+
.join("\n");
|
|
76
|
+
}
|
|
77
|
+
// ── Tailwind translation ─────────────────────────────────────────────────────
|
|
78
|
+
/**
|
|
79
|
+
* Namespace-aware token key: returns the utility suffix ONLY when the ref
|
|
80
|
+
* lives in a namespace the `theme.css` @theme adapter actually maps
|
|
81
|
+
* (semantic/series colors, spacing, radius, shadow, families, weights, type
|
|
82
|
+
* scale). Anything else (e.g. color primitives) must use the --ds-* fallback.
|
|
83
|
+
*/
|
|
84
|
+
const nsKey = (v, ns) => {
|
|
85
|
+
const m = v.match(new RegExp(`^\\{${ns.replace(/\./g, "\\.")}\\.([a-zA-Z0-9-]+)\\}$`));
|
|
86
|
+
return m ? kebab(m[1]) : null;
|
|
87
|
+
};
|
|
88
|
+
/** Color refs the adapter maps: semantic → <role>, series → series-<n>. */
|
|
89
|
+
const colorKey = (v) => {
|
|
90
|
+
const semantic = nsKey(v, "color.semantic");
|
|
91
|
+
if (semantic)
|
|
92
|
+
return semantic;
|
|
93
|
+
const series = nsKey(v, "color.series");
|
|
94
|
+
if (series)
|
|
95
|
+
return `series-${series}`;
|
|
96
|
+
return null;
|
|
97
|
+
};
|
|
98
|
+
/** "{typography.scale.sm.fontSize}" → "sm" (the --text-<key> utility). */
|
|
99
|
+
const scaleKey = (v) => {
|
|
100
|
+
const m = v.match(/^\{typography\.scale\.([a-zA-Z0-9-]+)\.fontSize\}$/);
|
|
101
|
+
return m ? kebab(m[1]) : null;
|
|
102
|
+
};
|
|
103
|
+
/** "{color.semantic.primary}" → "var(--ds-color-semantic-primary)" - the raw
|
|
104
|
+
* scoped vars always exist, so arbitrary-property fallbacks never dangle. */
|
|
105
|
+
const refToDsVar = (v) => v.replace(/\{([a-z0-9.-]+)\}/gi, (_, path) => {
|
|
106
|
+
return `var(--ds-${path.split(".").map(kebab).join("-")})`;
|
|
107
|
+
});
|
|
108
|
+
/** Arbitrary-property escape hatch: guaranteed-faithful when no pretty utility
|
|
109
|
+
* exists. Spaces become underscores per Tailwind's arbitrary syntax. */
|
|
110
|
+
const arbitrary = (prop, value) => `[${kebab(prop)}:${refToDsVar(value).replace(/\s+/g, "_")}]`;
|
|
111
|
+
const STATIC = {
|
|
112
|
+
display: {
|
|
113
|
+
flex: "flex",
|
|
114
|
+
"inline-flex": "inline-flex",
|
|
115
|
+
grid: "grid",
|
|
116
|
+
block: "block",
|
|
117
|
+
"inline-block": "inline-block",
|
|
118
|
+
none: "hidden",
|
|
119
|
+
},
|
|
120
|
+
alignItems: {
|
|
121
|
+
center: "items-center",
|
|
122
|
+
"flex-start": "items-start",
|
|
123
|
+
"flex-end": "items-end",
|
|
124
|
+
baseline: "items-baseline",
|
|
125
|
+
stretch: "items-stretch",
|
|
126
|
+
},
|
|
127
|
+
justifyContent: {
|
|
128
|
+
center: "justify-center",
|
|
129
|
+
"space-between": "justify-between",
|
|
130
|
+
"flex-start": "justify-start",
|
|
131
|
+
"flex-end": "justify-end",
|
|
132
|
+
},
|
|
133
|
+
flexDirection: { column: "flex-col", row: "flex-row" },
|
|
134
|
+
textAlign: { center: "text-center", left: "text-left", right: "text-right" },
|
|
135
|
+
cursor: { pointer: "cursor-pointer", "not-allowed": "cursor-not-allowed" },
|
|
136
|
+
width: { "100%": "w-full" },
|
|
137
|
+
height: { "100%": "h-full" },
|
|
138
|
+
textDecoration: { none: "no-underline", underline: "underline" },
|
|
139
|
+
};
|
|
140
|
+
/** One declaration → Tailwind classes (pretty when mappable, arbitrary-property
|
|
141
|
+
* otherwise - never dropped). */
|
|
142
|
+
function declToTailwind(prop, value) {
|
|
143
|
+
const stat = STATIC[prop]?.[value];
|
|
144
|
+
if (stat)
|
|
145
|
+
return [stat];
|
|
146
|
+
switch (prop) {
|
|
147
|
+
case "backgroundColor": {
|
|
148
|
+
if (value === "transparent")
|
|
149
|
+
return ["bg-transparent"];
|
|
150
|
+
const key = colorKey(value);
|
|
151
|
+
if (key)
|
|
152
|
+
return [`bg-${key}`];
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
case "color": {
|
|
156
|
+
const key = colorKey(value);
|
|
157
|
+
if (key)
|
|
158
|
+
return [`text-${key}`];
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
case "borderColor": {
|
|
162
|
+
const key = colorKey(value);
|
|
163
|
+
if (key)
|
|
164
|
+
return [`border-${key}`];
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
case "border": {
|
|
168
|
+
// "1px solid {color.semantic.x}" → border + border-<x>
|
|
169
|
+
const m = value.match(/^1px\s+solid\s+(\{[^}]+\})$/);
|
|
170
|
+
if (m) {
|
|
171
|
+
const key = colorKey(m[1]);
|
|
172
|
+
if (key)
|
|
173
|
+
return ["border", `border-${key}`];
|
|
174
|
+
}
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
case "borderRadius": {
|
|
178
|
+
const key = nsKey(value, "radius");
|
|
179
|
+
if (key)
|
|
180
|
+
return [`rounded-${key}`];
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
case "gap": {
|
|
184
|
+
const key = nsKey(value, "spacing");
|
|
185
|
+
if (key)
|
|
186
|
+
return [`gap-${key}`];
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
case "padding": {
|
|
190
|
+
const keys = value.split(/\s+/).map((v) => nsKey(v, "spacing"));
|
|
191
|
+
if (keys.length === 1 && keys[0])
|
|
192
|
+
return [`p-${keys[0]}`];
|
|
193
|
+
if (keys.length === 2 && keys[0] && keys[1])
|
|
194
|
+
return [`py-${keys[0]}`, `px-${keys[1]}`];
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
case "fontSize": {
|
|
198
|
+
const key = scaleKey(value);
|
|
199
|
+
if (key)
|
|
200
|
+
return [`text-${key}`];
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
case "fontFamily": {
|
|
204
|
+
const key = nsKey(value, "typography.families");
|
|
205
|
+
if (key)
|
|
206
|
+
return [`font-${key}`];
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
case "fontWeight": {
|
|
210
|
+
const key = nsKey(value, "typography.weights");
|
|
211
|
+
if (key)
|
|
212
|
+
return [`font-${key}`];
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
case "boxShadow": {
|
|
216
|
+
const key = nsKey(value, "shadow");
|
|
217
|
+
if (key)
|
|
218
|
+
return [`shadow-${key}`];
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
case "lineHeight":
|
|
222
|
+
// usually paired with the same scale's fontSize (text-<key> carries the
|
|
223
|
+
// scale's line-height via --text-<key>--line-height)
|
|
224
|
+
if (/^\{typography\.scale\./.test(value))
|
|
225
|
+
return [];
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
return [arbitrary(prop, value)];
|
|
229
|
+
}
|
|
230
|
+
const STATE_PREFIX = {
|
|
231
|
+
hover: "hover:",
|
|
232
|
+
focus: "focus:",
|
|
233
|
+
focusVisible: "focus-visible:",
|
|
234
|
+
active: "active:",
|
|
235
|
+
disabled: "disabled:",
|
|
236
|
+
};
|
|
237
|
+
function blockToTailwind(block, prefix = "") {
|
|
238
|
+
return Object.entries(block).flatMap(([prop, value]) => declToTailwind(prop, value).map((cls) => `${prefix}${cls}`));
|
|
239
|
+
}
|
|
240
|
+
function tailwindClassList(recipe) {
|
|
241
|
+
const classes = [
|
|
242
|
+
...blockToTailwind(recipe.base),
|
|
243
|
+
...Object.entries(recipe.states ?? {}).flatMap(([state, block]) => STATE_PREFIX[state] ? blockToTailwind(block, STATE_PREFIX[state]) : []),
|
|
244
|
+
];
|
|
245
|
+
return classes.join(" ");
|
|
246
|
+
}
|
|
247
|
+
// ── Emission ─────────────────────────────────────────────────────────────────
|
|
248
|
+
function header(slug, name, version, mode) {
|
|
249
|
+
const setup = mode === "tailwind"
|
|
250
|
+
? `import _synthesisui/ds/${slug}/theme.css (Tailwind adapter) + tokens.css`
|
|
251
|
+
: `import _synthesisui/ds/${slug}/tokens.css`;
|
|
252
|
+
return [
|
|
253
|
+
`// Generated by SynthesisUI - "${name}" from the "${slug}" design system (v${version}).`,
|
|
254
|
+
`// On-system by construction: every style resolves to the DS tokens.`,
|
|
255
|
+
`// Global setup (once per app): ${setup}`,
|
|
256
|
+
`// and put data-ds="${slug}" on a root element (e.g. <body data-ds="${slug}">).`,
|
|
257
|
+
].join("\n");
|
|
258
|
+
}
|
|
259
|
+
const joinCls = (parts) => `[${parts.join(", ")}].filter(Boolean).join(" ")`;
|
|
260
|
+
function emitCssMode(slug, name, recipe, version) {
|
|
261
|
+
const { tag, attrs, voidEl } = elementFor(name, recipe);
|
|
262
|
+
const axes = axesOf(recipe.variants);
|
|
263
|
+
const comp = pascal(name);
|
|
264
|
+
const propNames = axes.map((a) => a.prop);
|
|
265
|
+
const destructure = [...propNames, "className", "...props"].join(", ");
|
|
266
|
+
void voidEl; // both void and container elements self-close ({...props} carries children)
|
|
267
|
+
const rootJsx = ` <${tag}${attrs}\n className={${joinCls([`"ds-${name}"`, "className"])}}\n${dataAttrLines(axes)}${axes.length ? "\n" : ""} {...props}\n />`;
|
|
268
|
+
const parts = Object.entries(recipe.parts ?? {}).map(([partName, part]) => {
|
|
269
|
+
const partAxes = axesOf(part.variants ?? {});
|
|
270
|
+
const partComp = `${comp}${pascal(partName)}`;
|
|
271
|
+
const partDestructure = [
|
|
272
|
+
...partAxes.map((a) => a.prop),
|
|
273
|
+
"className",
|
|
274
|
+
"...props",
|
|
275
|
+
].join(", ");
|
|
276
|
+
return `
|
|
277
|
+
/** Part "${partName}" of ${comp} - compose it inside <${comp}>. */
|
|
278
|
+
export function ${partComp}({ ${partDestructure} }: ${propsType(partAxes, "div")}) {
|
|
279
|
+
return (
|
|
280
|
+
<div
|
|
281
|
+
className={${joinCls([`"ds-${name}-${kebab(partName)}"`, "className"])}}
|
|
282
|
+
${dataAttrLines(partAxes)}${partAxes.length ? "\n" : ""} {...props}
|
|
283
|
+
/>
|
|
284
|
+
);
|
|
285
|
+
}`;
|
|
286
|
+
});
|
|
287
|
+
return `${header(slug, name, version, "css")}
|
|
288
|
+
import "./${name}.css";
|
|
289
|
+
|
|
290
|
+
import type { ComponentPropsWithoutRef } from "react";
|
|
291
|
+
|
|
292
|
+
type ${comp}Props = ${propsType(axes, tag)};
|
|
293
|
+
|
|
294
|
+
export function ${comp}({ ${destructure} }: ${comp}Props) {
|
|
295
|
+
return (
|
|
296
|
+
${rootJsx}
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
${parts.join("\n")}`;
|
|
300
|
+
}
|
|
301
|
+
function emitTailwindMode(slug, name, recipe, version) {
|
|
302
|
+
const { tag, attrs, voidEl } = elementFor(name, recipe);
|
|
303
|
+
const axes = axesOf(recipe.variants);
|
|
304
|
+
const comp = pascal(name);
|
|
305
|
+
const variantConsts = axes
|
|
306
|
+
.filter((a) => !a.boolean)
|
|
307
|
+
.map((a) => {
|
|
308
|
+
const entries = a.options
|
|
309
|
+
.map((o) => ` ${JSON.stringify(o)}: ${JSON.stringify(blockToTailwind(recipe.variants[a.key]?.[o] ?? {}).join(" "))},`)
|
|
310
|
+
.join("\n");
|
|
311
|
+
return `const ${a.prop.toUpperCase()}: Record<string, string> = {\n${entries}\n};`;
|
|
312
|
+
});
|
|
313
|
+
const booleanConsts = axes
|
|
314
|
+
.filter((a) => a.boolean)
|
|
315
|
+
.map((a) => `const ${a.prop.toUpperCase()} = ${JSON.stringify(blockToTailwind(recipe.variants[a.key]?.true ?? {}).join(" "))};`);
|
|
316
|
+
const clsParts = [
|
|
317
|
+
"BASE",
|
|
318
|
+
...axes.map((a) => a.boolean
|
|
319
|
+
? `${a.prop} ? ${a.prop.toUpperCase()} : ""`
|
|
320
|
+
: `${a.prop} ? ${a.prop.toUpperCase()}[${a.prop}] : ""`),
|
|
321
|
+
"className",
|
|
322
|
+
];
|
|
323
|
+
const destructure = [
|
|
324
|
+
...axes.map((a) => a.prop),
|
|
325
|
+
"className",
|
|
326
|
+
"...props",
|
|
327
|
+
].join(", ");
|
|
328
|
+
void voidEl;
|
|
329
|
+
return `${header(slug, name, version, "tailwind")}
|
|
330
|
+
|
|
331
|
+
import type { ComponentPropsWithoutRef } from "react";
|
|
332
|
+
|
|
333
|
+
const BASE = ${JSON.stringify(tailwindClassList(recipe))};
|
|
334
|
+
${[...variantConsts, ...booleanConsts].join("\n")}
|
|
335
|
+
|
|
336
|
+
type ${comp}Props = ${propsType(axes, tag)};
|
|
337
|
+
|
|
338
|
+
export function ${comp}({ ${destructure} }: ${comp}Props) {
|
|
339
|
+
return (
|
|
340
|
+
<${tag}${attrs}
|
|
341
|
+
className={${joinCls(clsParts)}}
|
|
342
|
+
{...props}
|
|
343
|
+
/>
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
${Object.entries(recipe.parts ?? {})
|
|
347
|
+
.map(([partName, part]) => {
|
|
348
|
+
const partComp = `${comp}${pascal(partName)}`;
|
|
349
|
+
return `
|
|
350
|
+
/** Part "${partName}" of ${comp} - compose it inside <${comp}>. */
|
|
351
|
+
export function ${partComp}({ className, ...props }: ComponentPropsWithoutRef<"div">) {
|
|
352
|
+
return (
|
|
353
|
+
<div className={${joinCls([JSON.stringify(tailwindClassList(part)), "className"])}} {...props} />
|
|
354
|
+
);
|
|
355
|
+
}`;
|
|
356
|
+
})
|
|
357
|
+
.join("\n")}`;
|
|
358
|
+
}
|
|
359
|
+
/** All files for one component, under `<componentsDir>/<name>/`. */
|
|
360
|
+
export function generateComponentFiles(slug, name, recipe, css, version, styles) {
|
|
361
|
+
const comp = pascal(name);
|
|
362
|
+
const files = [];
|
|
363
|
+
if (styles === "css") {
|
|
364
|
+
files.push({
|
|
365
|
+
filename: `${name}.tsx`,
|
|
366
|
+
code: `${emitCssMode(slug, name, recipe, version)}\n`,
|
|
367
|
+
});
|
|
368
|
+
files.push({ filename: `${name}.css`, code: `${css}\n` });
|
|
369
|
+
}
|
|
370
|
+
else {
|
|
371
|
+
files.push({
|
|
372
|
+
filename: `${name}.tsx`,
|
|
373
|
+
code: `${emitTailwindMode(slug, name, recipe, version)}\n`,
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
files.push({
|
|
377
|
+
filename: "index.ts",
|
|
378
|
+
code: `export * from "./${name}";\n`,
|
|
379
|
+
});
|
|
380
|
+
void comp;
|
|
381
|
+
return files;
|
|
382
|
+
}
|
package/dist/config.js
CHANGED
|
@@ -40,6 +40,7 @@ export const DEFAULT_CONFIG = {
|
|
|
40
40
|
target: "next",
|
|
41
41
|
pagesDir: "app",
|
|
42
42
|
componentsDir: "components",
|
|
43
|
+
styles: "css",
|
|
43
44
|
};
|
|
44
45
|
const projectConfigPath = (root) => join(root, "_synthesisui", "config.json");
|
|
45
46
|
/** Reads the project config, falling back to defaults when absent/invalid. */
|
|
@@ -55,6 +56,7 @@ export async function readProjectConfig(root) {
|
|
|
55
56
|
componentsDir: typeof parsed.componentsDir === "string" && parsed.componentsDir
|
|
56
57
|
? parsed.componentsDir
|
|
57
58
|
: DEFAULT_CONFIG.componentsDir,
|
|
59
|
+
styles: parsed.styles === "tailwind" ? "tailwind" : "css",
|
|
58
60
|
};
|
|
59
61
|
}
|
|
60
62
|
catch {
|
package/dist/guide.js
CHANGED
|
@@ -145,6 +145,18 @@ place** - wire real data, split into components, swap the chart/icon/media place
|
|
|
145
145
|
\`data-ds="${slug}"\` wrapper and the \`.ds-*\` / layout classes so it stays on-system. Run
|
|
146
146
|
\`synthesisui init\` once to set the target (next/general) and the output folder.
|
|
147
147
|
|
|
148
|
+
## Single components as YOUR code
|
|
149
|
+
|
|
150
|
+
Bring one component in as a real, typed React component (variants become props):
|
|
151
|
+
\`\`\`bash
|
|
152
|
+
synthesisui component ${slug} button
|
|
153
|
+
\`\`\`
|
|
154
|
+
It writes \`<componentsDir>/button/\` with \`button.tsx\` (+ colocated \`button.css\` in the default
|
|
155
|
+
\`styles: "css"\` flavor, or Tailwind utilities inline with \`styles: "tailwind"\` - set once via
|
|
156
|
+
\`synthesisui init --styles tailwind\`). Import and render: \`import { Button } from "components/button"\`.
|
|
157
|
+
The boundary: tokens are global (\`tokens.css\` + the \`data-ds\` root attribute, once per app);
|
|
158
|
+
everything a component owns lives in its own folder.
|
|
159
|
+
|
|
148
160
|
---
|
|
149
161
|
`
|
|
150
162
|
: "";
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { add } from "./commands/add.js";
|
|
3
3
|
import { advise } from "./commands/advise.js";
|
|
4
|
+
import { component } from "./commands/component.js";
|
|
4
5
|
import { generate } from "./commands/generate.js";
|
|
5
6
|
import { init } from "./commands/init.js";
|
|
6
7
|
import { list } from "./commands/list.js";
|
|
@@ -16,6 +17,7 @@ Usage:
|
|
|
16
17
|
synthesisui list [options] list the published design systems
|
|
17
18
|
synthesisui add <slug> [options] materialize a DS into _synthesisui/ds/<slug>/
|
|
18
19
|
synthesisui template <slug> <name> materialize a whole page from a DS template
|
|
20
|
+
synthesisui component <slug> <name> bring one component in - artifacts + YOUR <Pascal>.tsx in componentsDir
|
|
19
21
|
synthesisui use <slug> "<intent>" print a ready-to-paste agent prompt to build/modify on-system
|
|
20
22
|
synthesisui advise "<value prop>" engagement-pattern proposals for this project (login required)
|
|
21
23
|
synthesisui generate "<desc>" generate a token-only component recipe for your DS (login required)
|
|
@@ -29,6 +31,8 @@ Options:
|
|
|
29
31
|
--target <t> template/init target: next | general (default: next)
|
|
30
32
|
--pages-dir <dir> init: folder for generated pages (default: app)
|
|
31
33
|
--components-dir <dir> init: folder where components live (default: components)
|
|
34
|
+
--styles <s> init: component code flavor: css | tailwind (default: css)
|
|
35
|
+
--artifacts-only component: skip the .tsx materialization (recipe + css only)
|
|
32
36
|
--out <path> output path for the generated template (default: <pagesDir>/<file>)
|
|
33
37
|
-h, --help this help
|
|
34
38
|
|
|
@@ -41,6 +45,7 @@ Examples:
|
|
|
41
45
|
synthesisui add halogen --version 3
|
|
42
46
|
synthesisui template halogen dashboard-sidebar
|
|
43
47
|
synthesisui template halogen landing --out app/page.tsx
|
|
48
|
+
synthesisui component halogen pricing-tier
|
|
44
49
|
synthesisui use halogen "a pricing section with three tiers and a highlighted plan"
|
|
45
50
|
synthesisui use halogen "make the card shadow softer in components/StatCard.tsx"
|
|
46
51
|
synthesisui advise "habit-building app for tracking personal finances"
|
|
@@ -122,7 +127,16 @@ async function main() {
|
|
|
122
127
|
? flags["components-dir"]
|
|
123
128
|
: undefined;
|
|
124
129
|
const ds = typeof flags.ds === "string" ? flags.ds : undefined;
|
|
125
|
-
|
|
130
|
+
const styles = typeof flags.styles === "string" ? flags.styles : undefined;
|
|
131
|
+
await init({
|
|
132
|
+
dir,
|
|
133
|
+
registry,
|
|
134
|
+
target,
|
|
135
|
+
pagesDir,
|
|
136
|
+
componentsDir,
|
|
137
|
+
styles,
|
|
138
|
+
ds,
|
|
139
|
+
});
|
|
126
140
|
break;
|
|
127
141
|
}
|
|
128
142
|
// `page` is the legacy alias (renamed to `template`); it still works so
|
|
@@ -153,6 +167,31 @@ async function main() {
|
|
|
153
167
|
await template(slug, name, { registry, dir, out, target, version });
|
|
154
168
|
break;
|
|
155
169
|
}
|
|
170
|
+
case "component": {
|
|
171
|
+
const slug = args[0];
|
|
172
|
+
const name = args[1];
|
|
173
|
+
if (!slug || !name) {
|
|
174
|
+
console.error("error: provide slug and component name - `synthesisui component <slug> <name>`");
|
|
175
|
+
process.exitCode = 1;
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
let version;
|
|
179
|
+
if (typeof flags.version === "string") {
|
|
180
|
+
version = Number.parseInt(flags.version.replace(/^v/i, ""), 10);
|
|
181
|
+
if (!Number.isInteger(version) || version < 1) {
|
|
182
|
+
console.error(`error: invalid --version "${flags.version}" - use an integer ≥ 1`);
|
|
183
|
+
process.exitCode = 1;
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
await component(slug, name, {
|
|
188
|
+
registry,
|
|
189
|
+
dir,
|
|
190
|
+
version,
|
|
191
|
+
artifactsOnly: flags["artifacts-only"] === true,
|
|
192
|
+
});
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
156
195
|
case "use": {
|
|
157
196
|
const slug = args[0];
|
|
158
197
|
if (!slug) {
|
package/dist/registry.js
CHANGED
|
@@ -63,6 +63,27 @@ export async function fetchTemplate(base, slug, template, target, version) {
|
|
|
63
63
|
}
|
|
64
64
|
return (await res.json());
|
|
65
65
|
}
|
|
66
|
+
/**
|
|
67
|
+
* Fetches ONE component from a DS (`?component=<name>`): its recipe + compiled
|
|
68
|
+
* CSS. Granular "bring specific" - the system must exist; works for public DS
|
|
69
|
+
* without login (private/owned needs the token).
|
|
70
|
+
*/
|
|
71
|
+
export async function fetchComponent(base, slug, name, version) {
|
|
72
|
+
const url = new URL(`${base}/api/registry/ds/${encodeURIComponent(slug)}`);
|
|
73
|
+
url.searchParams.set("component", name);
|
|
74
|
+
if (version != null)
|
|
75
|
+
url.searchParams.set("version", String(version));
|
|
76
|
+
const res = await request(url.toString());
|
|
77
|
+
if (res.status === 404) {
|
|
78
|
+
const body = (await res.json().catch(() => ({})));
|
|
79
|
+
throw new RegistryError(body.message ??
|
|
80
|
+
`No component "${name}" in "${slug}". Run \`synthesisui add ${slug}\` and check its components.`);
|
|
81
|
+
}
|
|
82
|
+
if (!res.ok) {
|
|
83
|
+
throw new RegistryError(`Registry responded ${res.status} while fetching "${name}".`);
|
|
84
|
+
}
|
|
85
|
+
return (await res.json());
|
|
86
|
+
}
|
|
66
87
|
/**
|
|
67
88
|
* Calls the hosted advisor (`POST /api/ai/advisor`). Gated + metered server-side:
|
|
68
89
|
* 401 = not logged in, 429 = daily quota reached. Sends the Bearer token if present.
|