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.
@@ -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
+ }
@@ -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
- await init({ dir, registry, target, pagesDir, componentsDir, ds });
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.1.23",
3
+ "version": "0.2.0",
4
4
  "description": "Traz design systems do SynthesisUI para qualquer projeto (materializa em _local/ds/).",
5
5
  "type": "module",
6
6
  "bin": {