uifolio 0.3.0 → 0.5.1

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/cli/uifolio.mjs CHANGED
@@ -6,26 +6,34 @@
6
6
  * Commandes :
7
7
  * uifolio list liste des composants disponibles
8
8
  * uifolio init [--cwd dir] écrit uifolio.json (config cible)
9
- * uifolio add <noms...> [options] copie les composants + helpers
9
+ * uifolio add <noms...> [options] copie les composants + helpers,
10
+ * écrit les tokens Tailwind et utilise
11
+ * le composant dans une page
10
12
  *
11
13
  * Options :
12
14
  * --cwd <dir> projet cible (défaut : répertoire courant)
13
15
  * --dir <chemin> dossier des composants (défaut : src/components/ui)
14
16
  * --lib <chemin> dossier des helpers (défaut : src/lib)
15
17
  * --alias <alias> alias d'import cible (défaut : @)
16
- * --color <hex> intègre cette couleur aux fichiers copiés (plus de --primary)
18
+ * --color <hex> couleur primaire, écrite dans les tokens Tailwind (@theme)
17
19
  * --radius <px> écrit --radius dans uifolio-theme.css
20
+ * --page <chemin> fichier où utiliser le composant (créé s'il n'existe pas)
21
+ * --interactive force les questions même quand stdin n'est pas un terminal
18
22
  * --force écrase les fichiers existants
19
23
  * --dry-run affiche ce qui serait copié sans rien écrire
20
24
  *
25
+ * Sans --dir / --page, la CLI demande interactivement (terminal) dans quel
26
+ * dossier copier les composants et dans quel fichier les utiliser.
27
+ *
21
28
  * Exemples :
22
29
  * node cli/uifolio.mjs add calendar otp --cwd ../mon-projet
23
- * node cli/uifolio.mjs add calendar --color "#10B981"
30
+ * node cli/uifolio.mjs add calendar --color "#10B981" --page src/pages/calendar.tsx
24
31
  * node cli/uifolio.mjs add video-player --alias "#lib" --dry-run
25
32
  */
26
33
 
27
34
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
28
- import { dirname, join, relative, resolve } from 'node:path'
35
+ import { basename, dirname, join, relative, resolve } from 'node:path'
36
+ import { createInterface } from 'node:readline/promises'
29
37
  import { fileURLToPath } from 'node:url'
30
38
 
31
39
  const HERE = dirname(fileURLToPath(import.meta.url))
@@ -35,6 +43,21 @@ const CONFIG_NAME = 'uifolio.json'
35
43
 
36
44
  const LIB_DEPS = ['clsx', 'tailwind-merge'] // cn() — toujours copié avec les helpers
37
45
 
46
+ const DEFAULT_ACCENT = '#10b981'
47
+ const DEFAULT_COMPONENTS_DIR = 'src/components/ui'
48
+ const THEME_FILE = 'uifolio-theme.css'
49
+
50
+ /** Fichiers CSS globaux candidats pour brancher le thème Tailwind. */
51
+ const GLOBAL_CSS_CANDIDATES = [
52
+ 'src/index.css',
53
+ 'src/app.css',
54
+ 'src/globals.css',
55
+ 'src/styles.css',
56
+ 'src/main.css',
57
+ 'app/globals.css',
58
+ 'styles/globals.css',
59
+ ]
60
+
38
61
  /* -------------------------------------------------------------------------- */
39
62
  /* Utilitaires */
40
63
  /* -------------------------------------------------------------------------- */
@@ -120,6 +143,88 @@ function externalDeps(source) {
120
143
  return [...deps]
121
144
  }
122
145
 
146
+ /** Nom réellement exporté par le fichier (otp-input → OtpInput). */
147
+ function componentExportName(source, fallback) {
148
+ const matches = [...source.matchAll(/export\s+function\s+([A-Z][A-Za-z0-9_]*)/g)].map((match) => match[1])
149
+ return matches[0] ?? fallback
150
+ }
151
+
152
+ /** Fichiers .tsx de src/pages/ du projet cible, triés. */
153
+ function listPagesInCwd(cwd) {
154
+ try {
155
+ const pagesDir = join(resolve(cwd), 'src', 'pages')
156
+ if (!existsSync(pagesDir)) return []
157
+ return readdirSync(pagesDir)
158
+ .filter((name) => name.endsWith('.tsx'))
159
+ .map((name) => join('src', 'pages', name))
160
+ .sort()
161
+ } catch {
162
+ return []
163
+ }
164
+ }
165
+
166
+ /** Fichiers où l'usage du composant peut atterrir (indices affichés). */
167
+ function usageCandidates(cwd) {
168
+ const app = existsSync(join(resolve(cwd), 'src', 'App.tsx')) ? 'src/App.tsx' : null
169
+ const pages = listPagesInCwd(cwd).filter((page) => page !== app)
170
+ return app ? [app, ...pages] : pages
171
+ }
172
+
173
+ /**
174
+ * Saisie interactive : uniquement sur un terminal (ou avec --interactive),
175
+ * jamais en CI / pipe sans données, pour ne pas bloquer l'installation.
176
+ */
177
+ function createPrompter(flags) {
178
+ const allowed = Boolean(flags.interactive) || process.stdin.isTTY === true
179
+ if (!allowed) return null
180
+ const rl = createInterface({ input: process.stdin, output: process.stdout })
181
+ const queued = []
182
+ let waiting = null
183
+ let closed = false
184
+
185
+ rl.on('line', (line) => {
186
+ if (waiting) {
187
+ const resolveLine = waiting
188
+ waiting = null
189
+ resolveLine(line)
190
+ } else {
191
+ queued.push(line)
192
+ }
193
+ })
194
+ rl.on('close', () => {
195
+ closed = true
196
+ if (waiting) {
197
+ const resolveLine = waiting
198
+ waiting = null
199
+ resolveLine(null)
200
+ }
201
+ })
202
+
203
+ return {
204
+ async ask(question, fallback) {
205
+ const suffix = fallback ? ` (${fallback})` : ''
206
+ process.stdout.write(`\n \x1b[36m?\x1b[0m ${question}${suffix} : `)
207
+
208
+ let answer
209
+ if (queued.length > 0) answer = queued.shift()
210
+ else if (closed) answer = null
211
+ else answer = await new Promise((resolveLine) => {
212
+ waiting = resolveLine
213
+ })
214
+
215
+ // stdin fermé (pipe sans données) : on garde la valeur par défaut.
216
+ if (answer === null) return fallback
217
+ const trimmed = answer.trim()
218
+ return trimmed === '' ? fallback : trimmed
219
+ },
220
+ close() {
221
+ if (closed) return
222
+ closed = true
223
+ rl.close()
224
+ },
225
+ }
226
+ }
227
+
123
228
  /**
124
229
  * Résolution transitive : un composant importe des helpers (@/lib/…)
125
230
  * qui peuvent eux-mêmes importer d'autres fichiers locaux.
@@ -156,47 +261,6 @@ function rewriteAlias(source, targetAlias) {
156
261
  return source.replace(/(['"])@\//g, `$1${targetAlias}/`)
157
262
  }
158
263
 
159
- /**
160
- * Remplace les tokens `--primary` par la couleur choisie, directement dans le code
161
- * copié : `bg-primary` → `bg-[#10b981]`, `var(--primary)` → `#10b981`.
162
- * Le composant garde alors la couleur demandée sans dépendre de la variable du projet.
163
- */
164
- function personalizeColor(source, accent) {
165
- if (!accent) return source
166
- const foreground = isLightHex(accent) ? '#0f172a' : '#ffffff'
167
- return source
168
- .replace(/var\(--primary-foreground\)/g, foreground)
169
- .replace(/var\(--primary\)/g, accent)
170
- .replace(
171
- /\b((?:[a-z0-9-]+:)*)(bg|text|border(?:-[xytrblse])?|ring(?:-offset)?|fill|stroke|from|via|to|divide|outline|shadow|accent|caret|decoration|placeholder)-primary(-foreground)?(\/\d+)?\b/g,
172
- (_match, prefix, utility, isForeground, opacity = '') =>
173
- `${prefix}${utility}-[${isForeground ? foreground : accent}]${opacity}`,
174
- )
175
- }
176
-
177
- function copyFile({ to, source, dryRun, force, targetAlias, accent }) {
178
- if (existsSync(to) && !force) return 'skipped'
179
- if (!dryRun) {
180
- mkdirSync(dirname(to), { recursive: true })
181
- writeFileSync(to, rewriteAlias(personalizeColor(source, accent), targetAlias), 'utf8')
182
- }
183
- return 'written'
184
- }
185
-
186
- /* -------------------------------------------------------------------------- */
187
- /* Commandes */
188
- /* -------------------------------------------------------------------------- */
189
-
190
- const ALIASES = {
191
- otp: 'otp-input',
192
- editor: 'rich-text-editor',
193
- panels: 'resizable',
194
- shortcuts: 'command-menu',
195
- upload: 'file-upload',
196
- video: 'video-player',
197
- }
198
-
199
-
200
264
  function normalizeHex(value) {
201
265
  let next = String(value).trim()
202
266
  if (!next.startsWith('#')) next = `#${next}`
@@ -217,35 +281,309 @@ function isLightHex(hex) {
217
281
  return 0.299 * r + 0.587 * g + 0.114 * b > 160
218
282
  }
219
283
 
284
+ /* -------------------------------------------------------------------------- */
285
+ /* Thème Tailwind CSS 4 */
286
+ /* -------------------------------------------------------------------------- */
287
+
288
+ /**
289
+ * Les composants utilisent uniquement des classes Tailwind (`bg-primary`,
290
+ * `text-muted-foreground`, `border-border`…). Pour que ces classes existent,
291
+ * Tailwind 4 doit connaître les tokens via `@theme` : un simple `:root`
292
+ * ne génère aucune classe utilitaire.
293
+ */
294
+ function buildThemeCss({ accent, radius }) {
295
+ const foreground = isLightHex(accent) ? '#0f172a' : '#ffffff'
296
+ const rootTokens = [
297
+ ' --background: #f8fafc;',
298
+ ' --foreground: #0f172a;',
299
+ ' --card: #ffffff;',
300
+ ' --card-foreground: #0f172a;',
301
+ ' --muted: #f1f5f9;',
302
+ ' --muted-foreground: #64748b;',
303
+ ' --border: #e2e8f0;',
304
+ ` --primary: ${accent};`,
305
+ ` --primary-foreground: ${foreground};`,
306
+ ` --accent: color-mix(in oklab, ${accent} 18%, var(--background));`,
307
+ ' --accent-foreground: var(--foreground);',
308
+ ` --ring: ${accent}80;`,
309
+ ]
310
+ if (radius !== undefined) rootTokens.push(` --radius: ${radius}px;`)
311
+
312
+ const darkTokens = [
313
+ ' --background: #0b1220;',
314
+ ' --foreground: #e2e8f0;',
315
+ ' --card: #101a2b;',
316
+ ' --card-foreground: #e2e8f0;',
317
+ ' --muted: #1e293b;',
318
+ ' --muted-foreground: #94a3b8;',
319
+ ' --border: rgba(255, 255, 255, 0.12);',
320
+ ` --primary: ${accent};`,
321
+ ` --primary-foreground: ${foreground};`,
322
+ ` --accent: color-mix(in oklab, ${accent} 24%, #0b1220);`,
323
+ ' --accent-foreground: #e2e8f0;',
324
+ ` --ring: ${accent}80;`,
325
+ ' color-scheme: dark;',
326
+ ]
327
+
328
+ return `/* uifolio — tokens Tailwind CSS 4 : les classes bg-primary, text-muted-foreground… viennent d'ici. */
329
+ :root {
330
+ ${rootTokens.join('\n')}
331
+ }
332
+
333
+ .dark {
334
+ ${darkTokens.join('\n')}
335
+ }
336
+
337
+ @custom-variant dark (&:is(.dark *));
338
+
339
+ @theme inline {
340
+ --color-background: var(--background);
341
+ --color-foreground: var(--foreground);
342
+ --color-card: var(--card);
343
+ --color-card-foreground: var(--card-foreground);
344
+ --color-muted: var(--muted);
345
+ --color-muted-foreground: var(--muted-foreground);
346
+ --color-border: var(--border);
347
+ --color-primary: var(--primary);
348
+ --color-primary-foreground: var(--primary-foreground);
349
+ --color-accent: var(--accent);
350
+ --color-accent-foreground: var(--accent-foreground);
351
+ --color-ring: var(--ring);
352
+ }
353
+ `
354
+ }
355
+
356
+ /** CSS global du projet (celui qui importe Tailwind), sinon null. */
357
+ function findGlobalCss(cwd) {
358
+ for (const candidate of GLOBAL_CSS_CANDIDATES) {
359
+ const file = join(cwd, candidate)
360
+ if (!existsSync(file)) continue
361
+ const source = readFileSync(file, 'utf8')
362
+ if (/@import\s+['"]tailwindcss|@tailwind\b/.test(source)) return file
363
+ }
364
+ for (const candidate of GLOBAL_CSS_CANDIDATES) {
365
+ const file = join(cwd, candidate)
366
+ if (existsSync(file)) return file
367
+ }
368
+ return null
369
+ }
370
+
371
+ /** Ajoute `@import './uifolio-theme.css';` juste après l'import de Tailwind. */
372
+ function wireThemeImport(source, specifier) {
373
+ const line = `@import '${specifier}';`
374
+ if (source.includes(specifier)) return source
375
+ const lines = source.split('\n')
376
+ const at = lines.findIndex((entry) => /@import\s+['"]tailwindcss|@tailwind\s/.test(entry))
377
+ if (at === -1) return `${line}\n${source}`
378
+ lines.splice(at + 1, 0, line)
379
+ return lines.join('\n')
380
+ }
381
+
382
+ /** `--primary` déjà défini dans un CSS (hex uniquement), sinon null. */
383
+ function existingAccent(file) {
384
+ if (!file || !existsSync(file)) return null
385
+ const match = /--primary:\s*(#[0-9a-f]{6})\s*;/i.exec(readFileSync(file, 'utf8'))
386
+ return match ? match[1].toLowerCase() : null
387
+ }
388
+
389
+ /** `--radius` déjà défini dans un CSS, sinon undefined. */
390
+ function existingRadius(file) {
391
+ if (!file || !existsSync(file)) return undefined
392
+ const match = /--radius:\s*(\d+(?:\.\d+)?)px\s*;/.exec(readFileSync(file, 'utf8'))
393
+ return match ? Math.round(Number(match[1])) : undefined
394
+ }
395
+
220
396
  function writeTheme({ cwd, accent, radius, dryRun }) {
221
- if (!accent && radius === undefined) return
397
+ const globalCss = findGlobalCss(cwd)
398
+ const themeFile = globalCss
399
+ ? join(dirname(globalCss), THEME_FILE)
400
+ : join(cwd, THEME_FILE)
401
+ // Sans --color / --radius, on garde le thème déjà en place (projet ou UIfolio).
402
+ const finalAccent = accent ?? existingAccent(themeFile) ?? existingAccent(globalCss) ?? DEFAULT_ACCENT
403
+ const finalRadius = radius ?? existingRadius(themeFile)
404
+ const css = buildThemeCss({ accent: finalAccent, radius: finalRadius })
405
+ const label = relative(cwd, themeFile)
406
+
407
+ if (dryRun) {
408
+ ok(`[dry-run] thème Tailwind aurait été écrit dans ${label}`)
409
+ if (globalCss) info(`[dry-run] importé dans ${relative(cwd, globalCss)}`)
410
+ return
411
+ }
412
+
413
+ writeFileSync(themeFile, css, 'utf8')
414
+ ok(`thème Tailwind écrit dans ${label}`)
415
+ info(`--primary: ${finalAccent}${finalRadius === undefined ? '' : ` · --radius: ${finalRadius}px`}`)
416
+
417
+ if (globalCss) {
418
+ const source = readFileSync(globalCss, 'utf8')
419
+ const specifier = `./${basename(themeFile)}`
420
+ const updated = wireThemeImport(source, specifier)
421
+ if (updated !== source) writeFileSync(globalCss, updated, 'utf8')
422
+ info(`importé dans ${relative(cwd, globalCss)} — aucune couleur en dur dans les composants`)
423
+ } else {
424
+ info(`Ajoute « @import './${THEME_FILE}'; » en haut de ton CSS global (celui de Tailwind).`)
425
+ }
426
+ }
427
+
428
+ /* -------------------------------------------------------------------------- */
429
+ /* Usage du composant dans une page */
430
+ /* -------------------------------------------------------------------------- */
431
+
432
+ /** Chemin d'import relatif, sans extension, en slashs. */
433
+ function importSpecifier(fromDir, target) {
434
+ const rel = relative(fromDir, target).replace(/\\/g, '/')
435
+ return rel.startsWith('.') ? rel : `./${rel}`
436
+ }
222
437
 
223
- const rounded = radius === undefined ? null : Math.round(Number(radius))
224
- if (rounded !== null && (!Number.isFinite(rounded) || rounded < 0 || rounded > 24)) {
225
- fail(`Arrondi invalide : ${radius}. Utilise une valeur entre 0 et 24.`)
438
+ /** Insère les imports après le dernier import du fichier. */
439
+ function addImports(source, lines) {
440
+ const missing = lines.filter((line) => !source.includes(line))
441
+ if (missing.length === 0) return source
442
+
443
+ const all = source.split('\n')
444
+ let last = -1
445
+ for (let index = 0; index < all.length; index += 1) {
446
+ if (/^\s*import\s/.test(all[index])) last = index
447
+ }
448
+ if (last === -1) return `${missing.join('\n')}\n\n${source}`
449
+
450
+ // Un import peut tenir sur plusieurs lignes : on avance jusqu'à la fin.
451
+ let end = last
452
+ while (
453
+ end < all.length - 1 &&
454
+ end - last < 20 &&
455
+ !/from\s+['"][^'"]+['"];?\s*$/.test(all[end]) &&
456
+ !/^import\s+['"][^'"]+['"];?\s*$/.test(all[end])
457
+ ) {
458
+ end += 1
226
459
  }
227
460
 
228
- const tokens = []
229
- // La couleur est déjà écrite dans les fichiers copiés : on n'écrit pas de token.
230
- if (!accent) {
231
- const fallback = '#10b981'
232
- tokens.push(
233
- ` --primary: ${fallback};`,
234
- ' --primary-foreground: #ffffff;',
235
- ` --ring: ${fallback}80;`,
236
- ` --accent: color-mix(in oklab, ${fallback} 18%, var(--background));`,
237
- ' --accent-foreground: var(--foreground);',
238
- )
461
+ all.splice(end + 1, 0, ...missing)
462
+ return all.join('\n')
463
+ }
464
+
465
+ /**
466
+ * Fin de la balise ouvrante du `<` à `start`, sans se laisser piéger par les
467
+ * `>`/apostrophes des attributs et des textes JSX.
468
+ */
469
+ function findOpeningTagEnd(source, start) {
470
+ let braceDepth = 0
471
+ for (let index = start + 1; index < source.length; index += 1) {
472
+ const char = source[index]
473
+ if (braceDepth === 0 && (char === '"' || char === "'")) {
474
+ const closing = source.indexOf(char, index + 1)
475
+ if (closing === -1) return null
476
+ index = closing
477
+ continue
478
+ }
479
+ if (char === '{') braceDepth += 1
480
+ else if (char === '}') braceDepth = Math.max(0, braceDepth - 1)
481
+ else if (char === '>' && braceDepth === 0) {
482
+ return { end: index, selfClosing: source[index - 1] === '/' }
483
+ }
239
484
  }
240
- if (rounded !== null) tokens.push(` --radius: ${rounded}px;`)
241
- if (tokens.length === 0) return
485
+ return null
486
+ }
242
487
 
243
- const file = join(cwd, 'uifolio-theme.css')
244
- if (!dryRun) writeFileSync(file, `:root {\n${tokens.join('\n')}\n}\n`, 'utf8')
245
- ok(`${dryRun ? '[dry-run] ' : ''}Thème écrit dans ${relative(cwd, file)}`)
246
- if (accent) info(`couleur ${accent} : déjà intégrée aux composants copiés`)
247
- if (rounded !== null) info(`arrondi ${rounded}px`)
248
- info(`Importe ${relative(cwd, file)} dans ton CSS global.`)
488
+ /** Ajoute `<Composant />` dans le JSX retourné, sinon un exemple exporté. */
489
+ function addJsxUsage(source, exportNames) {
490
+ const missing = exportNames.filter((name) => !new RegExp(`<${name}[\\s/>]`).test(source))
491
+ if (missing.length === 0) return { source, added: [] }
492
+
493
+ const returnMatch = /\breturn\b/.exec(source)
494
+ let cursor = returnMatch ? returnMatch.index + returnMatch[0].length : -1
495
+ while (cursor !== -1 && cursor < source.length && /[\s(]/.test(source[cursor])) cursor += 1
496
+ const rootIndex = cursor !== -1 && source[cursor] === '<' ? cursor : -1
497
+ const tag = rootIndex === -1 ? null : findOpeningTagEnd(source, rootIndex)
498
+
499
+ if (tag) {
500
+ const lineStart = source.lastIndexOf('\n', rootIndex) + 1
501
+ const indent = /^\s*/.exec(source.slice(lineStart, rootIndex))?.[0] ?? ''
502
+ const childIndent = `${indent} `
503
+ const children = missing.map((name) => `${childIndent}<${name} />`).join('\n')
504
+
505
+ // Fragment déjà présent : on insère juste après `<>`.
506
+ if (source.startsWith('<>', rootIndex)) {
507
+ const next = `${source.slice(0, rootIndex + 2)}\n${children}${source.slice(rootIndex + 2)}`
508
+ return { source: next, added: missing }
509
+ }
510
+
511
+ // Racine auto-fermante : on l'enveloppe dans un fragment.
512
+ if (tag.selfClosing) {
513
+ const element = source.slice(rootIndex, tag.end + 1)
514
+ const next =
515
+ `${source.slice(0, rootIndex)}<>\n${children}\n${indent}${element}\n${indent}</>${source.slice(tag.end + 1)}`
516
+ return { source: next, added: missing }
517
+ }
518
+
519
+ // Élément ouvrant : les composants deviennent ses premiers enfants.
520
+ const next = `${source.slice(0, tag.end + 1)}\n${children}${source.slice(tag.end + 1)}`
521
+ return { source: next, added: missing }
522
+ }
523
+
524
+ // JSX introuvable (page sans `return` JSX) : on laisse un exemple utilisable.
525
+ const example = missing
526
+ .map((name) => `\nexport function ${name}Example() {\n return <${name} />\n}\n`)
527
+ .join('\n')
528
+ return { source: `${source.trimEnd()}\n${example}`, added: missing }
529
+ }
530
+
531
+ /**
532
+ * Écrit (ou complète) le fichier choisi : imports depuis le dossier réel des
533
+ * composants + appel `<Composant />` dans le JSX.
534
+ */
535
+ function writeUsage({ cwd, target, componentsDir, components, dryRun }) {
536
+ const created = !existsSync(target)
537
+ const lines = components.map(({ name, exportName }) =>
538
+ `import { ${exportName} } from '${importSpecifier(dirname(target), join(resolve(cwd, componentsDir), name))}'`,
539
+ )
540
+ const label = relative(cwd, target)
541
+ const names = components.map((component) => component.exportName)
542
+
543
+ if (created) {
544
+ const body = [
545
+ ...lines,
546
+ '',
547
+ `export default function ${names[0]}Page() {`,
548
+ ' return (',
549
+ ' <main className="space-y-6 p-6">',
550
+ ...names.map((name) => ` <${name} />`),
551
+ ' </main>',
552
+ ' )',
553
+ '}',
554
+ '',
555
+ ].join('\n')
556
+ if (!dryRun) {
557
+ mkdirSync(dirname(target), { recursive: true })
558
+ writeFileSync(target, body, 'utf8')
559
+ }
560
+ ok(`${dryRun ? '[dry-run] ' : ''}page créée : ${label}`)
561
+ } else {
562
+ const source = readFileSync(target, 'utf8')
563
+ const withImports = addImports(source, lines)
564
+ const { source: updated, added } = addJsxUsage(withImports, names)
565
+ if (!dryRun) writeFileSync(target, updated, 'utf8')
566
+ ok(`${dryRun ? '[dry-run] ' : ''}usage ajouté dans ${label}`)
567
+ if (added.length < names.length) {
568
+ const already = names.filter((name) => !added.includes(name))
569
+ info(`déjà présent(s), non dupliqué(s) : ${already.join(', ')}`)
570
+ }
571
+ }
572
+
573
+ for (const name of names) info(`· <${name} />`)
574
+ }
575
+
576
+ /* -------------------------------------------------------------------------- */
577
+ /* Commandes */
578
+ /* -------------------------------------------------------------------------- */
579
+
580
+ const ALIASES = {
581
+ otp: 'otp-input',
582
+ editor: 'rich-text-editor',
583
+ panels: 'resizable',
584
+ shortcuts: 'command-menu',
585
+ upload: 'file-upload',
586
+ video: 'video-player',
249
587
  }
250
588
 
251
589
  function commandList() {
@@ -265,7 +603,7 @@ function commandInit({ flags }) {
265
603
 
266
604
  const config = {
267
605
  $schema: 'https://uifolio.dev/schema.json',
268
- dir: typeof flags.dir === 'string' ? flags.dir : 'src/components/ui',
606
+ dir: typeof flags.dir === 'string' ? flags.dir : DEFAULT_COMPONENTS_DIR,
269
607
  lib: typeof flags.lib === 'string' ? flags.lib : 'src/lib',
270
608
  alias: typeof flags.alias === 'string' ? flags.alias : '@',
271
609
  }
@@ -275,89 +613,130 @@ function commandInit({ flags }) {
275
613
  info('Puis : uifolio add video-player calendar …')
276
614
  }
277
615
 
278
- function commandAdd({ positional, flags }) {
616
+ /**
617
+ * Demande le fichier où utiliser le composant, après l'installation.
618
+ * La page proposée par défaut est créée si elle n'existe pas.
619
+ */
620
+ async function chooseUsageTarget({ prompter, cwd, names }) {
621
+ const suggestion = `src/pages/${names[0]}.tsx`
622
+ const candidates = usageCandidates(cwd)
623
+ if (candidates.length > 0) info(`fichiers existants : ${candidates.join(', ')}`)
624
+
625
+ const answer = await prompter.ask(
626
+ `Dans quel fichier utiliser ${names.join(', ')} ? « - » pour ne pas l'utiliser`,
627
+ suggestion,
628
+ )
629
+ if (!answer || answer === '-') {
630
+ info('le composant est installé mais n’est utilisé dans aucun fichier.')
631
+ return null
632
+ }
633
+ return resolve(cwd, answer)
634
+ }
635
+
636
+ async function commandAdd({ positional, flags }) {
279
637
  const names = positional.map((name) => ALIASES[name] ?? name)
280
638
  if (names.length === 0) fail('Aucun composant demandé. Ex. : uifolio add calendar otp')
281
639
 
282
640
  const cwd = resolve(flags.cwd ?? process.cwd())
283
641
  const config = readConfig(cwd) ?? {}
284
-
285
- const componentsDir = typeof flags.dir === 'string' ? flags.dir : config.dir ?? 'src/components/ui'
286
- const libDir = typeof flags.lib === 'string' ? flags.lib : config.lib ?? 'src/lib'
287
- const targetAlias = typeof flags.alias === 'string' ? flags.alias : config.alias ?? '@'
288
642
  const dryRun = Boolean(flags['dry-run'])
289
643
  const force = Boolean(flags.force)
644
+ const targetAlias = typeof flags.alias === 'string' ? flags.alias : config.alias ?? '@'
645
+ const libDir = typeof flags.lib === 'string' ? flags.lib : config.lib ?? 'src/lib'
646
+
290
647
  const colorFlag = typeof flags.color === 'string' ? flags.color : undefined
291
648
  const accent = colorFlag === undefined ? null : normalizeHex(colorFlag)
292
649
  if (colorFlag !== undefined && !accent) {
293
650
  fail(`Couleur invalide : ${colorFlag}. Utilise un hex comme #10B981.`)
294
651
  }
295
652
 
653
+ let radius
654
+ if (typeof flags.radius === 'string') {
655
+ radius = Math.round(Number(flags.radius))
656
+ if (!Number.isFinite(radius) || radius < 0 || radius > 24) {
657
+ fail(`Arrondi invalide : ${flags.radius}. Utilise une valeur entre 0 et 24.`)
658
+ }
659
+ }
660
+
296
661
  const known = listComponents()
297
662
  const unknown = names.filter((name) => !known.includes(name))
298
663
  if (unknown.length > 0) {
299
664
  fail(`Composant(s) inconnu(s) : ${unknown.join(', ')}. Lance « uifolio list ».`)
300
665
  }
301
-
302
666
  if (!dryRun && !existsSync(cwd)) fail(`Répertoire cible introuvable : ${cwd}`)
303
667
 
304
- const deps = new Set(LIB_DEPS)
305
- const written = new Set()
306
- const skipped = []
307
-
308
- for (const name of names) {
309
- const entry = join(REGISTRY_UI, `${name}.tsx`)
310
- const files = collectFiles(entry, REGISTRY_SRC)
311
-
312
- for (const [absolute, relativePath] of files) {
313
- const isUi = relativePath.startsWith(join('components', 'ui'))
314
- const destinationRoot = isUi ? resolve(cwd, componentsDir) : resolve(cwd, libDir)
315
- const destination = join(destinationRoot, relativePath.replace(/^(components\/ui|lib)\//, ''))
316
-
317
- if (written.has(destination)) continue
318
-
319
- const source = readFileSync(absolute, 'utf8')
320
- for (const dep of externalDeps(source)) deps.add(dep)
321
-
322
- const status = copyFile({
323
- to: destination,
324
- source,
325
- dryRun,
326
- force,
327
- targetAlias,
328
- accent,
329
- })
668
+ const prompter = createPrompter(flags)
669
+ try {
670
+ // 1. Dossier d'import des composants.
671
+ let componentsDir = typeof flags.dir === 'string' ? flags.dir : config.dir ?? DEFAULT_COMPONENTS_DIR
672
+ if (prompter && typeof flags.dir !== 'string') {
673
+ const answer = await prompter.ask('Dans quel dossier copier les composants', componentsDir)
674
+ if (answer) componentsDir = answer
675
+ }
676
+ componentsDir = componentsDir.replace(/^\.\//, '').replace(/\/+$/, '')
677
+
678
+ // 2. Copie des fichiers + helpers.
679
+ const deps = new Set(LIB_DEPS)
680
+ const written = new Set()
681
+ const destinations = new Set()
682
+ const skipped = []
683
+ const components = []
684
+
685
+ for (const name of names) {
686
+ const entry = join(REGISTRY_UI, `${name}.tsx`)
687
+ const files = collectFiles(entry, REGISTRY_SRC)
688
+ components.push({ name, exportName: componentExportName(readFileSync(entry, 'utf8'), name) })
689
+
690
+ for (const [absolute, relativePath] of files) {
691
+ const isUi = relativePath.startsWith(join('components', 'ui'))
692
+ const destinationRoot = isUi ? resolve(cwd, componentsDir) : resolve(cwd, libDir)
693
+ const destination = join(destinationRoot, relativePath.replace(/^(components\/ui|lib)\//, ''))
694
+ if (destinations.has(destination)) continue
695
+ destinations.add(destination)
696
+
697
+ const source = readFileSync(absolute, 'utf8')
698
+ for (const dep of externalDeps(source)) deps.add(dep)
699
+
700
+ const label = relative(cwd, destination)
701
+ const shouldWrite = force || !existsSync(destination)
702
+ if (shouldWrite) {
703
+ if (!dryRun) {
704
+ mkdirSync(dirname(destination), { recursive: true })
705
+ writeFileSync(destination, rewriteAlias(source, targetAlias), 'utf8')
706
+ }
707
+ written.add(label)
708
+ } else {
709
+ skipped.push(label)
710
+ }
711
+ }
712
+ }
330
713
 
331
- const label = relative(cwd, destination)
332
- if (status === 'written') written.add(label)
333
- else skipped.push(label)
714
+ ok(`${written.size} fichier(s) ${dryRun ? '[dry-run] auraient été copiés' : 'copié(s)'} :`)
715
+ for (const label of [...written].sort()) info(`· ${label}`)
716
+ if (skipped.length > 0) {
717
+ info(`${skipped.length} fichier(s) déjà présent(s), ignoré(s) : ${skipped.join(', ')} (–force pour écraser)`)
334
718
  }
335
- }
336
719
 
337
- const action = dryRun ? '[dry-run] auraient été copiés' : 'copié(s)'
338
- ok(`${written.size} fichier(s) ${action} :`)
339
- for (const label of [...written].sort()) info(`· ${label}`)
340
- if (skipped.length > 0) {
341
- info(`${skipped.length} fichier(s) déjà présent(s), ignoré(s) : ${skipped.join(', ')} (–force pour écraser)`)
342
- }
343
- if (accent && written.size > 0) {
344
- info(`couleur ${accent} intégrée (bg-primary → bg-[${accent}]), aucun --primary requis`)
345
- }
720
+ // 3. Tokens Tailwind : les classes bg-primary & co fonctionnent partout.
721
+ writeTheme({ cwd, accent, radius, dryRun })
346
722
 
347
- writeTheme({
348
- cwd,
349
- accent,
350
- radius: typeof flags.radius === 'string' ? flags.radius : undefined,
351
- dryRun,
352
- })
723
+ // 4. Utilisation du composant, après installation.
724
+ let pageTarget = typeof flags.page === 'string' ? resolve(cwd, flags.page) : null
725
+ if (!pageTarget && prompter) {
726
+ pageTarget = await chooseUsageTarget({ prompter, cwd, names })
727
+ }
728
+ if (pageTarget) writeUsage({ cwd, target: pageTarget, componentsDir, components, dryRun })
353
729
 
354
- if (written.size > 0 || dryRun) {
355
- console.log('\n Dépendances npm détectées :')
356
- info(`npm install ${[...deps].sort().join(' ')}`)
357
- if (targetAlias !== '@') {
358
- info(`Pense à aligner l'alias ${targetAlias} dans ton tsconfig/vite (remplace de @/).`)
730
+ if (written.size > 0 || dryRun) {
731
+ console.log('\n Dépendances npm détectées :')
732
+ info(`npm install ${[...deps].sort().join(' ')}`)
733
+ if (targetAlias !== '@') {
734
+ info(`Pense à aligner l'alias ${targetAlias} dans ton tsconfig/vite (remplace de @/).`)
735
+ }
736
+ console.log('')
359
737
  }
360
- console.log('')
738
+ } finally {
739
+ prompter?.close()
361
740
  }
362
741
  }
363
742
 
@@ -378,35 +757,47 @@ Options :
378
757
  --dir <chemin> dossier composants (défaut : src/components/ui)
379
758
  --lib <chemin> dossier helpers (défaut : src/lib)
380
759
  --alias <alias> alias d'import cible (défaut : @)
381
- --color <hex> intègre cette couleur aux fichiers copiés (sans --primary)
760
+ --color <hex> couleur primaire, écrite dans les tokens Tailwind (@theme)
382
761
  --radius <px> arrondi global (0–24) dans uifolio-theme.css
762
+ --page <chemin> fichier où utiliser le composant (ex : src/pages/calendar.tsx) ;
763
+ créé s'il n'existe pas, sinon l'import + <Composant /> y sont ajoutés
764
+ --interactive force les questions (dossier, fichier) hors terminal
383
765
  --force écrase les fichiers existants
384
766
  --dry-run simule sans écrire
385
767
 
768
+ Sans --dir ni --page, la CLI demande dans quel dossier copier les composants
769
+ puis dans quel fichier les utiliser — l'installation ne s'arrête pas à la copie.
770
+ Les couleurs passent par un thème Tailwind CSS 4 (uifolio-theme.css, @theme) :
771
+ les classes bg-primary, text-muted-foreground… restent dans le code.
772
+
386
773
  Exemples :
387
774
  node cli/uifolio.mjs add video-player calendar otp --cwd ../mon-projet
388
775
  node cli/uifolio.mjs add otp --color "#10B981" --radius 8
389
- node cli/uifolio.mjs add otp --alias "#lib" --dry-run
776
+ node cli/uifolio.mjs add calendar --page src/pages/calendar.tsx
390
777
  `)
391
778
  }
392
779
 
393
- const { positional, flags } = parseArgs(process.argv.slice(2))
394
- const command = positional.shift()
395
-
396
- switch (command) {
397
- case 'list':
398
- commandList()
399
- break
400
- case 'init':
401
- commandInit({ positional, flags })
402
- break
403
- case 'add':
404
- commandAdd({ positional, flags })
405
- break
406
- case undefined:
407
- case 'help':
408
- usage()
409
- break
410
- default:
411
- fail(`Commande inconnue : ${command}. Lance « uifolio » sans argument.`)
780
+ async function main() {
781
+ const { positional, flags } = parseArgs(process.argv.slice(2))
782
+ const command = positional.shift()
783
+
784
+ switch (command) {
785
+ case 'list':
786
+ commandList()
787
+ break
788
+ case 'init':
789
+ commandInit({ positional, flags })
790
+ break
791
+ case 'add':
792
+ await commandAdd({ positional, flags })
793
+ break
794
+ case undefined:
795
+ case 'help':
796
+ usage()
797
+ break
798
+ default:
799
+ fail(`Commande inconnue : ${command}. Lance « uifolio » sans argument.`)
800
+ }
412
801
  }
802
+
803
+ await main()