uifolio 0.5.1 → 0.5.4

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
@@ -1,37 +1,37 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * uifolio — CLI façon shadcn : copie les composants de la librairie
4
- * dans un autre projet, avec les helpers dont ils dépendent.
3
+ * uifolio — shadcn-like CLI: copies the library components into another
4
+ * project, along with the helpers they depend on.
5
5
  *
6
- * Commandes :
7
- * uifolio list liste des composants disponibles
8
- * uifolio init [--cwd dir] écrit uifolio.json (config cible)
9
- * uifolio add <noms...> [options] copie les composants + helpers,
10
- * écrit les tokens Tailwind et utilise
11
- * le composant dans une page
6
+ * Commands:
7
+ * uifolio list list available components
8
+ * uifolio init [--cwd dir] write uifolio.json (target config)
9
+ * uifolio add <names...> [options] copy components + helpers, write the
10
+ * Tailwind tokens and use the
11
+ * component in a page
12
12
  *
13
- * Options :
14
- * --cwd <dir> projet cible (défaut : répertoire courant)
15
- * --dir <chemin> dossier des composants (défaut : src/components/ui)
16
- * --lib <chemin> dossier des helpers (défaut : src/lib)
17
- * --alias <alias> alias d'import cible (défaut : @)
18
- * --color <hex> couleur primaire, écrite dans les tokens Tailwind (@theme)
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
22
- * --force écrase les fichiers existants
23
- * --dry-run affiche ce qui serait copié sans rien écrire
13
+ * Options:
14
+ * --cwd <dir> target project (default: current directory)
15
+ * --dir <path> components folder (default: src/components/ui)
16
+ * --lib <path> helpers folder (default: src/lib)
17
+ * --alias <alias> target import alias (default: @)
18
+ * --color <hex> primary color, written in the Tailwind tokens (@theme)
19
+ * --radius <px> writes --radius in uifolio-theme.css
20
+ * --page <path> file where the component is used (created if missing)
21
+ * --interactive ask the questions even when stdin is not a terminal
22
+ * --force overwrite existing files
23
+ * --dry-run print what would be copied without writing anything
24
24
  *
25
- * Sans --dir / --page, la CLI demande interactivement (terminal) dans quel
26
- * dossier copier les composants et dans quel fichier les utiliser.
25
+ * Without --page, the CLI asks interactively (terminal) where to use them.
26
+ * The components always land in src/components/ui unless --dir overrides it.
27
27
  *
28
- * Exemples :
29
- * node cli/uifolio.mjs add calendar otp --cwd ../mon-projet
28
+ * Examples:
29
+ * node cli/uifolio.mjs add calendar otp --cwd ../my-project
30
30
  * node cli/uifolio.mjs add calendar --color "#10B981" --page src/pages/calendar.tsx
31
31
  * node cli/uifolio.mjs add video-player --alias "#lib" --dry-run
32
32
  */
33
33
 
34
- import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
34
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
35
35
  import { basename, dirname, join, relative, resolve } from 'node:path'
36
36
  import { createInterface } from 'node:readline/promises'
37
37
  import { fileURLToPath } from 'node:url'
@@ -41,21 +41,17 @@ const REGISTRY_SRC = resolve(HERE, '..', 'src')
41
41
  const REGISTRY_UI = join(REGISTRY_SRC, 'components', 'ui')
42
42
  const CONFIG_NAME = 'uifolio.json'
43
43
 
44
- const LIB_DEPS = ['clsx', 'tailwind-merge'] // cn() — toujours copié avec les helpers
44
+ const LIB_DEPS = ['clsx', 'tailwind-merge'] // cn() — always copied with the helpers
45
45
 
46
46
  const DEFAULT_ACCENT = '#10b981'
47
47
  const DEFAULT_COMPONENTS_DIR = 'src/components/ui'
48
48
  const THEME_FILE = 'uifolio-theme.css'
49
49
 
50
- /** Fichiers CSS globaux candidats pour brancher le thème Tailwind. */
50
+ /** Global CSS candidates for wiring the Tailwind theme. */
51
51
  const GLOBAL_CSS_CANDIDATES = [
52
52
  'src/index.css',
53
53
  'src/app.css',
54
54
  'src/globals.css',
55
- 'src/styles.css',
56
- 'src/main.css',
57
- 'app/globals.css',
58
- 'styles/globals.css',
59
55
  ]
60
56
 
61
57
  /* -------------------------------------------------------------------------- */
@@ -96,6 +92,31 @@ function parseArgs(argv) {
96
92
  return { positional, flags }
97
93
  }
98
94
 
95
+ /** A “folder” answer must not be a file path (page.tsx, index.css…). */
96
+ const FILE_LIKE = /\.(?:[cm]?[jt]sx?|css|scss|sass|less|html|json|md|vue|svelte)$/i
97
+
98
+ function looksLikeFile(value) {
99
+ return FILE_LIKE.test(value.trim())
100
+ }
101
+
102
+ /** « ./src/ui/ » → « src/ui » */
103
+ function normalizeComponentsDir(value) {
104
+ return value.trim().replace(/^\.\//, '').replace(/\/+$/, '')
105
+ }
106
+
107
+ /** Readable write error instead of a stack trace (EACCES, ENOTDIR…). */
108
+ function writeFailure(error, target) {
109
+ const reasons = {
110
+ EACCES: 'permission denied',
111
+ EPERM: 'permission denied',
112
+ EROFS: 'read-only filesystem',
113
+ ENOSPC: 'no space left on device',
114
+ ENOTDIR: 'a file is in the way',
115
+ ENOENT: 'path not found',
116
+ }
117
+ fail(`Cannot write ${target}: ${reasons[error?.code] ?? error?.message ?? error}.`)
118
+ }
119
+
99
120
  function listComponents() {
100
121
  if (!existsSync(REGISTRY_UI)) return []
101
122
  return readdirSync(REGISTRY_UI)
@@ -128,7 +149,7 @@ function localImports(source) {
128
149
  return [...specifiers]
129
150
  }
130
151
 
131
- /** Dépendances npm externes (hors React) d'un fichier source. */
152
+ /** External npm dependencies (excluding React) of a source file. */
132
153
  function externalDeps(source) {
133
154
  const deps = new Set()
134
155
  const importRe = /from\s+['"]([^'"]+)['"]/g
@@ -143,13 +164,14 @@ function externalDeps(source) {
143
164
  return [...deps]
144
165
  }
145
166
 
146
- /** Nom réellement exporté par le fichier (otp-input → OtpInput). */
167
+ /** Real exported name of the file (otp-input → OtpInput, icon-picker → IconPicker). */
147
168
  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
169
+ const exported = [...source.matchAll(/export\s+function\s+([A-Z][A-Za-z0-9_]*)/g)].map((match) => match[1])
170
+ const pascal = fallback.replace(/(^|-)([a-z0-9])/g, (_, __, char) => char.toUpperCase())
171
+ return exported.find((name) => name === pascal) ?? exported[0] ?? fallback
150
172
  }
151
173
 
152
- /** Fichiers .tsx de src/pages/ du projet cible, triés. */
174
+ /** .tsx files under src/pages of the target project, sorted. */
153
175
  function listPagesInCwd(cwd) {
154
176
  try {
155
177
  const pagesDir = join(resolve(cwd), 'src', 'pages')
@@ -163,7 +185,7 @@ function listPagesInCwd(cwd) {
163
185
  }
164
186
  }
165
187
 
166
- /** Fichiers où l'usage du composant peut atterrir (indices affichés). */
188
+ /** Files where the component usage may land (flags shown). */
167
189
  function usageCandidates(cwd) {
168
190
  const app = existsSync(join(resolve(cwd), 'src', 'App.tsx')) ? 'src/App.tsx' : null
169
191
  const pages = listPagesInCwd(cwd).filter((page) => page !== app)
@@ -172,7 +194,7 @@ function usageCandidates(cwd) {
172
194
 
173
195
  /**
174
196
  * Saisie interactive : uniquement sur un terminal (ou avec --interactive),
175
- * jamais en CI / pipe sans données, pour ne pas bloquer l'installation.
197
+ * never in CI / pipe without data, to avoid blocking the installation.
176
198
  */
177
199
  function createPrompter(flags) {
178
200
  const allowed = Boolean(flags.interactive) || process.stdin.isTTY === true
@@ -200,22 +222,36 @@ function createPrompter(flags) {
200
222
  }
201
223
  })
202
224
 
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) => {
225
+ async function ask(question, fallback) {
226
+ const text = question.trim().replace(/\?+$/, '')
227
+ const suffix = fallback ? ` (${fallback})` : ''
228
+ process.stdout.write(`\n \x1b[36m${text}${suffix} ?\x1b[0m `)
229
+
230
+ let answer
231
+ if (queued.length > 0) answer = queued.shift()
232
+ else if (closed) answer = null
233
+ else
234
+ answer = await new Promise((resolveLine) => {
212
235
  waiting = resolveLine
213
236
  })
214
237
 
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
238
+ // stdin closed (pipe without data): keep the default value.
239
+ if (answer === null) return fallback
240
+ const trimmed = answer.trim()
241
+ return trimmed === '' ? fallback : trimmed
242
+ }
243
+
244
+ return {
245
+ ask,
246
+ /** y / yes / n / no (French values tolerated as fallback). */
247
+ async confirm(question, fallback = false) {
248
+ const answer = await ask(question, fallback ? 'Y/n' : 'y/N')
249
+ const value = String(answer ?? '')
250
+ .trim()
251
+ .toLowerCase()
252
+ if (['y', 'yes', 'o', 'oui'].includes(value)) return true
253
+ if (['n', 'no', 'non'].includes(value)) return false
254
+ return fallback
219
255
  },
220
256
  close() {
221
257
  if (closed) return
@@ -226,12 +262,12 @@ function createPrompter(flags) {
226
262
  }
227
263
 
228
264
  /**
229
- * Résolution transitive : un composant importe des helpers (@/lib/…)
230
- * qui peuvent eux-mêmes importer d'autres fichiers locaux.
265
+ * Transitive resolution: a component imports helpers (@/lib/…)
266
+ * which may themselves import other local files.
231
267
  */
232
268
  function collectFiles(entryPath, srcRoot) {
233
269
  const queue = [entryPath]
234
- const visited = new Map() // chemin absolu -> chemin relatif à src/
270
+ const visited = new Map() // absolute path -> relative path from src/
235
271
 
236
272
  while (queue.length > 0) {
237
273
  const current = queue.shift()
@@ -249,7 +285,7 @@ function collectFiles(entryPath, srcRoot) {
249
285
  ]
250
286
  const found = candidates.find((candidate) => existsSync(candidate))
251
287
  if (found) queue.push(found)
252
- else fail(`Import local introuvable : ${specifier} (depuis ${relative(srcRoot, current)})`)
288
+ else fail(`Local import not found: ${specifier} (from ${relative(srcRoot, current)})`)
253
289
  }
254
290
  }
255
291
 
@@ -282,14 +318,14 @@ function isLightHex(hex) {
282
318
  }
283
319
 
284
320
  /* -------------------------------------------------------------------------- */
285
- /* Thème Tailwind CSS 4 */
321
+ /* Tailwind CSS 4 theme */
286
322
  /* -------------------------------------------------------------------------- */
287
323
 
288
324
  /**
289
325
  * Les composants utilisent uniquement des classes Tailwind (`bg-primary`,
290
326
  * `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.
327
+ * Tailwind 4 must discover the tokens via `@theme`: a plain `:root`
328
+ * generates no utility class.
293
329
  */
294
330
  function buildThemeCss({ accent, radius }) {
295
331
  const foreground = isLightHex(accent) ? '#0f172a' : '#ffffff'
@@ -325,7 +361,7 @@ function buildThemeCss({ accent, radius }) {
325
361
  ' color-scheme: dark;',
326
362
  ]
327
363
 
328
- return `/* uifolio — tokens Tailwind CSS 4 : les classes bg-primary, text-muted-foreground… viennent d'ici. */
364
+ return `/* uifolio — Tailwind CSS 4 tokens: bg-primary, text-muted-foreground… come from here. */
329
365
  :root {
330
366
  ${rootTokens.join('\n')}
331
367
  }
@@ -368,7 +404,7 @@ function findGlobalCss(cwd) {
368
404
  return null
369
405
  }
370
406
 
371
- /** Ajoute `@import './uifolio-theme.css';` juste après l'import de Tailwind. */
407
+ /** Adds `@import './uifolio-theme.css';` right after the Tailwind import. */
372
408
  function wireThemeImport(source, specifier) {
373
409
  const line = `@import '${specifier}';`
374
410
  if (source.includes(specifier)) return source
@@ -379,14 +415,14 @@ function wireThemeImport(source, specifier) {
379
415
  return lines.join('\n')
380
416
  }
381
417
 
382
- /** `--primary` déjà défini dans un CSS (hex uniquement), sinon null. */
418
+ /** `--primary` already defined in a CSS (hex only), otherwise null. */
383
419
  function existingAccent(file) {
384
420
  if (!file || !existsSync(file)) return null
385
421
  const match = /--primary:\s*(#[0-9a-f]{6})\s*;/i.exec(readFileSync(file, 'utf8'))
386
422
  return match ? match[1].toLowerCase() : null
387
423
  }
388
424
 
389
- /** `--radius` déjà défini dans un CSS, sinon undefined. */
425
+ /** `--radius` already defined in a CSS, otherwise undefined. */
390
426
  function existingRadius(file) {
391
427
  if (!file || !existsSync(file)) return undefined
392
428
  const match = /--radius:\s*(\d+(?:\.\d+)?)px\s*;/.exec(readFileSync(file, 'utf8'))
@@ -398,20 +434,20 @@ function writeTheme({ cwd, accent, radius, dryRun }) {
398
434
  const themeFile = globalCss
399
435
  ? join(dirname(globalCss), THEME_FILE)
400
436
  : join(cwd, THEME_FILE)
401
- // Sans --color / --radius, on garde le thème déjà en place (projet ou UIfolio).
437
+ // Without --color / --radius, keep the theme already in place (project or UIfolio).
402
438
  const finalAccent = accent ?? existingAccent(themeFile) ?? existingAccent(globalCss) ?? DEFAULT_ACCENT
403
439
  const finalRadius = radius ?? existingRadius(themeFile)
404
440
  const css = buildThemeCss({ accent: finalAccent, radius: finalRadius })
405
441
  const label = relative(cwd, themeFile)
406
442
 
407
443
  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)}`)
444
+ ok(`[dry-run] Tailwind theme would have been written to ${label}`)
445
+ if (globalCss) info(`[dry-run] imported in ${relative(cwd, globalCss)}`)
410
446
  return
411
447
  }
412
448
 
413
449
  writeFileSync(themeFile, css, 'utf8')
414
- ok(`thème Tailwind écrit dans ${label}`)
450
+ ok(`Tailwind theme written to ${label}`)
415
451
  info(`--primary: ${finalAccent}${finalRadius === undefined ? '' : ` · --radius: ${finalRadius}px`}`)
416
452
 
417
453
  if (globalCss) {
@@ -419,9 +455,9 @@ function writeTheme({ cwd, accent, radius, dryRun }) {
419
455
  const specifier = `./${basename(themeFile)}`
420
456
  const updated = wireThemeImport(source, specifier)
421
457
  if (updated !== source) writeFileSync(globalCss, updated, 'utf8')
422
- info(`importé dans ${relative(cwd, globalCss)} — aucune couleur en dur dans les composants`)
458
+ info(`imported in ${relative(cwd, globalCss)} — no hard-coded colors in the components`)
423
459
  } else {
424
- info(`Ajoute « @import './${THEME_FILE}'; » en haut de ton CSS global (celui de Tailwind).`)
460
+ info(`Add "@import './${THEME_FILE}';", at the top of your global CSS (the Tailwind one).`)
425
461
  }
426
462
  }
427
463
 
@@ -429,13 +465,182 @@ function writeTheme({ cwd, accent, radius, dryRun }) {
429
465
  /* Usage du composant dans une page */
430
466
  /* -------------------------------------------------------------------------- */
431
467
 
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}`
468
+ /** Import specifier through the project alias: @/components/ui/calendar. */
469
+ function aliasImport(cwd, componentsDir, name, alias) {
470
+ const relativePath = relative(resolve(cwd), join(resolve(cwd, componentsDir), name))
471
+ .replace(/\\/g, '/')
472
+ .replace(/^src\//, '')
473
+ return `${alias}/${relativePath}`
474
+ }
475
+
476
+ /**
477
+ * Example generated with the page: an instance that renders right away.
478
+ * A bare `<Component />` shows nothing for dialogs, players, radio groups or
479
+ * the animated wrappers that require children, so these examples carry the
480
+ * props and data each one needs.
481
+ */
482
+ const USAGE_EXAMPLES = {
483
+ 'audio-player': {
484
+ body: ` return (
485
+ <AudioPlayer
486
+ src="/sample.wav"
487
+ title="Test signal"
488
+ subtitle="Generated loop · 8 s · WAV"
489
+ accent="#10B981"
490
+ />
491
+ )
492
+ `,
493
+ },
494
+ 'video-player': {
495
+ body: ` return <VideoPlayer sources="/sample-720.mp4" aspectRatio="16/9" muted loop />
496
+ `,
497
+ },
498
+ 'file-upload': {
499
+ body: ` return <FileUpload accept="image/*,video/*" maxSizeMb={25} maxFiles={3} />
500
+ `,
501
+ },
502
+ choice: {
503
+ imports: ["import { useState } from 'react'"],
504
+ body: ` const [checked, setChecked] = useState(true)
505
+
506
+ return (
507
+ <Choice
508
+ checked={checked}
509
+ onChange={setChecked}
510
+ label="Email alerts"
511
+ description="Get a summary every week."
512
+ />
513
+ )
514
+ `,
515
+ },
516
+ 'command-menu': {
517
+ imports: ["import { useState } from 'react'"],
518
+ body: ` const [open, setOpen] = useState(false)
519
+
520
+ return (
521
+ <div className="space-y-3">
522
+ <button
523
+ type="button"
524
+ onClick={() => setOpen(true)}
525
+ className="rounded-full bg-primary px-4 py-2 text-xs font-medium text-primary-foreground"
526
+ >
527
+ Open the palette
528
+ </button>
529
+ <CommandMenu
530
+ open={open}
531
+ onOpenChange={setOpen}
532
+ items={[
533
+ { id: 'home', label: 'Home', group: 'Navigation', action: () => {} },
534
+ { id: 'components', label: 'Components', group: 'Navigation', action: () => {} },
535
+ ]}
536
+ />
537
+ </div>
538
+ )
539
+ `,
540
+ },
541
+ stories: {
542
+ imports: ["import { useState } from 'react'"],
543
+ types: ['StoryItem'],
544
+ body: ` const [open, setOpen] = useState(false)
545
+ const items: StoryItem[] = [
546
+ { type: 'image', src: '/story-1.svg', title: 'Dawn', duration: 5000 },
547
+ { type: 'text', title: 'Text only', text: 'A story can be plain text.', duration: 5000 },
548
+ ]
549
+
550
+ return (
551
+ <div className="space-y-3">
552
+ <button
553
+ type="button"
554
+ onClick={() => setOpen(true)}
555
+ className="rounded-full bg-primary px-4 py-2 text-xs font-medium text-primary-foreground"
556
+ >
557
+ Open the stories
558
+ </button>
559
+ <Stories items={items} open={open} onOpenChange={setOpen} />
560
+ </div>
561
+ )
562
+ `,
563
+ },
564
+ resizable: {
565
+ members: ['ResizablePanel', 'ResizableHandle'],
566
+ body: ` return (
567
+ <ResizablePanelGroup className="h-64 rounded-xl border">
568
+ <ResizablePanel className="p-4">Left panel</ResizablePanel>
569
+ <ResizableHandle />
570
+ <ResizablePanel className="p-4">Right panel</ResizablePanel>
571
+ </ResizablePanelGroup>
572
+ )
573
+ `,
574
+ },
575
+ motion: {
576
+ body: ` return (
577
+ <FadeIn>
578
+ <p className="text-sm">Fade in on mount.</p>
579
+ </FadeIn>
580
+ )
581
+ `,
582
+ },
583
+ table: {
584
+ imports: ["import { useState } from 'react'"],
585
+ members: ['type TableColumn'],
586
+ body: ` const [selected, setSelected] = useState<string[]>([])
587
+ type Row = { id: string; name: string; status: string; total: number }
588
+ const rows: Row[] = [
589
+ { id: 'finanx', name: 'Finanx', status: 'Active', total: 0 },
590
+ { id: 'facebook', name: 'Facebook', status: 'Active', total: 408221304 },
591
+ ]
592
+ const columns: TableColumn<Row>[] = [
593
+ { key: 'name', header: 'Name', cell: (row) => row.name },
594
+ { key: 'status', header: 'Status', cell: (row) => row.status },
595
+ { key: 'total', header: 'Total received', cell: (row) => row.total },
596
+ ]
597
+
598
+ return (
599
+ <Table
600
+ rows={rows}
601
+ columns={columns}
602
+ rowKey={(row) => row.id}
603
+ selectable
604
+ selectedKeys={selected}
605
+ onSelectionChange={setSelected}
606
+ />
607
+ )
608
+ `,
609
+ },
436
610
  }
437
611
 
438
- /** Insère les imports après le dernier import du fichier. */
612
+ /** Example component written next to the page, plus the imports it needs. */
613
+ function exampleFor({ name, exportName }) {
614
+ const spec = USAGE_EXAMPLES[name]
615
+ const component = `${exportName}Example`
616
+ if (!spec) {
617
+ return {
618
+ component,
619
+ imports: [],
620
+ types: [],
621
+ members: [],
622
+ code: `function ${component}() {\n return <${exportName} />\n}\n`,
623
+ }
624
+ }
625
+ return {
626
+ component,
627
+ imports: spec.imports ?? [],
628
+ types: spec.types ?? [],
629
+ members: spec.members ?? [],
630
+ code: `function ${component}() {\n${spec.body}}\n`,
631
+ }
632
+ }
633
+
634
+ /** Appends the example components that are not in the file yet. */
635
+ function addExamples(source, examples) {
636
+ const missing = examples.filter(
637
+ ({ component }) => !new RegExp(`function\\s+${component}\\s*\\(`).test(source),
638
+ )
639
+ if (missing.length === 0) return source
640
+ return `${source.trimEnd()}\n\n${missing.map(({ code }) => code.trimEnd()).join('\n\n')}\n`
641
+ }
642
+
643
+ /** Inserts the imports after the last import of the file. */
439
644
  function addImports(source, lines) {
440
645
  const missing = lines.filter((line) => !source.includes(line))
441
646
  if (missing.length === 0) return source
@@ -447,7 +652,7 @@ function addImports(source, lines) {
447
652
  }
448
653
  if (last === -1) return `${missing.join('\n')}\n\n${source}`
449
654
 
450
- // Un import peut tenir sur plusieurs lignes : on avance jusqu'à la fin.
655
+ // An import can span several lines: advance to the end of it.
451
656
  let end = last
452
657
  while (
453
658
  end < all.length - 1 &&
@@ -463,7 +668,7 @@ function addImports(source, lines) {
463
668
  }
464
669
 
465
670
  /**
466
- * Fin de la balise ouvrante du `<` à `start`, sans se laisser piéger par les
671
+ * End of the opening tag from `<` to `start`, without being tricked by the
467
672
  * `>`/apostrophes des attributs et des textes JSX.
468
673
  */
469
674
  function findOpeningTagEnd(source, start) {
@@ -485,7 +690,7 @@ function findOpeningTagEnd(source, start) {
485
690
  return null
486
691
  }
487
692
 
488
- /** Ajoute `<Composant />` dans le JSX retourné, sinon un exemple exporté. */
693
+ /** Adds `<Composant />` in the returned JSX, otherwise an exported demo. */
489
694
  function addJsxUsage(source, exportNames) {
490
695
  const missing = exportNames.filter((name) => !new RegExp(`<${name}[\\s/>]`).test(source))
491
696
  if (missing.length === 0) return { source, added: [] }
@@ -502,7 +707,7 @@ function addJsxUsage(source, exportNames) {
502
707
  const childIndent = `${indent} `
503
708
  const children = missing.map((name) => `${childIndent}<${name} />`).join('\n')
504
709
 
505
- // Fragment déjà présent : on insère juste après `<>`.
710
+ // Fragment already present: insert right after `<>`.
506
711
  if (source.startsWith('<>', rootIndex)) {
507
712
  const next = `${source.slice(0, rootIndex + 2)}\n${children}${source.slice(rootIndex + 2)}`
508
713
  return { source: next, added: missing }
@@ -516,7 +721,7 @@ function addJsxUsage(source, exportNames) {
516
721
  return { source: next, added: missing }
517
722
  }
518
723
 
519
- // Élément ouvrant : les composants deviennent ses premiers enfants.
724
+ // Opening element: the components become its first children.
520
725
  const next = `${source.slice(0, tag.end + 1)}\n${children}${source.slice(tag.end + 1)}`
521
726
  return { source: next, added: missing }
522
727
  }
@@ -529,48 +734,82 @@ function addJsxUsage(source, exportNames) {
529
734
  }
530
735
 
531
736
  /**
532
- * Écrit (ou complète) le fichier choisi : imports depuis le dossier réel des
533
- * composants + appel `<Composant />` dans le JSX.
737
+ * Writes (or completes) the chosen file: `@/` imports from the real folder of
738
+ * the components, a working example (`<CalendarExample />`) and its definition.
534
739
  */
535
- function writeUsage({ cwd, target, componentsDir, components, dryRun }) {
740
+ function writeUsage({ cwd, target, componentsDir, components, alias, dryRun }) {
741
+ if (existsSync(target) && statSync(target).isDirectory()) {
742
+ fail(`${relative(cwd, target)} is a folder: give a file (e.g. src/pages/${components[0].name}.tsx).`)
743
+ }
536
744
  const created = !existsSync(target)
537
- const lines = components.map(({ name, exportName }) =>
538
- `import { ${exportName} } from '${importSpecifier(dirname(target), join(resolve(cwd, componentsDir), name))}'`,
539
- )
745
+ const entries = components.map((component) => ({
746
+ exportName: component.exportName,
747
+ specifier: aliasImport(cwd, componentsDir, component.name, alias),
748
+ example: exampleFor(component),
749
+ }))
540
750
  const label = relative(cwd, target)
541
- const names = components.map((component) => component.exportName)
751
+
752
+ const imports = new Set()
753
+ for (const { exportName, specifier, example } of entries) {
754
+ for (const line of example.imports) imports.add(line)
755
+ imports.add(`import { ${[exportName, ...example.members].join(', ')} } from '${specifier}'`)
756
+ if (example.types.length > 0) {
757
+ imports.add(`import type { ${example.types.join(', ')} } from '${specifier}'`)
758
+ }
759
+ }
760
+
761
+ const orderedImports = [...imports].sort(
762
+ (left, right) => Number(left.includes(`'${alias}/`)) - Number(right.includes(`'${alias}/`)),
763
+ )
542
764
 
543
765
  if (created) {
544
766
  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
- '}',
767
+ orderedImports.join('\n'),
768
+ entries.map(({ example }) => example.code.trimEnd()).join('\n\n'),
769
+ [
770
+ `export default function ${entries[0].exportName}Page() {`,
771
+ ' return (',
772
+ ' <main className="space-y-6 p-6">',
773
+ ...entries.map(({ example }) => ` <${example.component} />`),
774
+ ' </main>',
775
+ ' )',
776
+ '}',
777
+ ].join('\n'),
554
778
  '',
555
- ].join('\n')
779
+ ].join('\n\n')
556
780
  if (!dryRun) {
557
- mkdirSync(dirname(target), { recursive: true })
558
- writeFileSync(target, body, 'utf8')
781
+ try {
782
+ mkdirSync(dirname(target), { recursive: true })
783
+ writeFileSync(target, body, 'utf8')
784
+ } catch (error) {
785
+ writeFailure(error, label)
786
+ }
559
787
  }
560
- ok(`${dryRun ? '[dry-run] ' : ''}page créée : ${label}`)
788
+ ok(`${dryRun ? '[dry-run] ' : ''}page created: ${label}`)
561
789
  } else {
562
790
  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}`)
791
+ const withImports = addImports(source, [...imports])
792
+ const names = entries.map(({ example }) => example.component)
793
+ const { source: withUsage, added } = addJsxUsage(withImports, names)
794
+ const updated = addExamples(
795
+ withUsage,
796
+ entries.map(({ example }) => example),
797
+ )
798
+ if (!dryRun) {
799
+ try {
800
+ writeFileSync(target, updated, 'utf8')
801
+ } catch (error) {
802
+ writeFailure(error, label)
803
+ }
804
+ }
805
+ ok(`${dryRun ? '[dry-run] ' : ''}usage added to ${label}`)
567
806
  if (added.length < names.length) {
568
807
  const already = names.filter((name) => !added.includes(name))
569
- info(`déjà présent(s), non dupliqué(s) : ${already.join(', ')}`)
808
+ info(`already present, not duplicated: ${already.join(', ')}`)
570
809
  }
571
810
  }
572
811
 
573
- for (const name of names) info(`· <${name} />`)
812
+ for (const { example } of entries) info(`· <${example.component} />`)
574
813
  }
575
814
 
576
815
  /* -------------------------------------------------------------------------- */
@@ -588,18 +827,18 @@ const ALIASES = {
588
827
 
589
828
  function commandList() {
590
829
  const components = listComponents()
591
- if (components.length === 0) fail('Aucun composant trouvé dans le registre.')
592
- console.log('\nComposants disponibles :\n')
830
+ if (components.length === 0) fail('No component found in the registry.')
831
+ console.log('\nAvailable components:\n')
593
832
  for (const name of components) {
594
833
  console.log(` \x1b[36m${name.padEnd(20)}\x1b[0m src/components/ui/${name}.tsx`)
595
834
  }
596
- console.log('\nUsage : uifolio add <nom> [--cwd <projet cible>]\n')
835
+ console.log('\nUsage: uifolio add <name> [--cwd <target project>]\n')
597
836
  }
598
837
 
599
838
  function commandInit({ flags }) {
600
839
  const cwd = resolve(flags.cwd ?? process.cwd())
601
840
  const file = join(cwd, CONFIG_NAME)
602
- if (existsSync(file) && !flags.force) fail(`${CONFIG_NAME} existe déjà (utilise --force).`)
841
+ if (existsSync(file) && !flags.force) fail(`${CONFIG_NAME} already exists (use --force).`)
603
842
 
604
843
  const config = {
605
844
  $schema: 'https://uifolio.dev/schema.json',
@@ -608,26 +847,48 @@ function commandInit({ flags }) {
608
847
  alias: typeof flags.alias === 'string' ? flags.alias : '@',
609
848
  }
610
849
  writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`, 'utf8')
611
- ok(`${CONFIG_NAME} écrit dans ${cwd}`)
850
+ ok(`${CONFIG_NAME} written to ${cwd}`)
612
851
  info(`dir: ${config.dir} · lib: ${config.lib} · alias: ${config.alias}`)
613
- info('Puis : uifolio add video-player calendar …')
852
+ info('Next: uifolio add video-player calendar …')
614
853
  }
615
854
 
616
855
  /**
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.
856
+ * Asks where the component should be used, right after installation:
857
+ * an existing file, a new folder + import file, or no file at all.
619
858
  */
620
- async function chooseUsageTarget({ prompter, cwd, names }) {
859
+ async function chooseUsageTarget({ prompter, cwd, names, exportNames }) {
621
860
  const suggestion = `src/pages/${names[0]}.tsx`
861
+ const pathFormat = `e.g. ${suggestion}`
862
+ const label = exportNames.join(', ')
622
863
  const candidates = usageCandidates(cwd)
623
- if (candidates.length > 0) info(`fichiers existants : ${candidates.join(', ')}`)
864
+ if (candidates.length > 0) info(`existing files: ${candidates.join(', ')}`)
624
865
 
625
- const answer = await prompter.ask(
626
- `Dans quel fichier utiliser ${names.join(', ')} ? « - » pour ne pas l'utiliser`,
627
- suggestion,
866
+ const ready = await prompter.confirm(
867
+ `Do you already have a file ready to receive the component (${label})`,
868
+ false,
628
869
  )
870
+
871
+ let answer
872
+ if (ready) {
873
+ answer = await prompter.ask(
874
+ `In which file should the component (${label}) be copied`,
875
+ pathFormat,
876
+ )
877
+ } else {
878
+ const create = await prompter.confirm(
879
+ 'Would you like to create a new folder and an import file',
880
+ true,
881
+ )
882
+ if (!create) {
883
+ info('the component is installed but not used in any file.')
884
+ return null
885
+ }
886
+ answer = await prompter.ask('Please specify the new path', pathFormat)
887
+ }
888
+
889
+ // “ - ”: installation only, like the “no” answer.
629
890
  if (!answer || answer === '-') {
630
- info('le composant est installé mais n’est utilisé dans aucun fichier.')
891
+ info('the component is installed but not used in any file.')
631
892
  return null
632
893
  }
633
894
  return resolve(cwd, answer)
@@ -635,7 +896,7 @@ async function chooseUsageTarget({ prompter, cwd, names }) {
635
896
 
636
897
  async function commandAdd({ positional, flags }) {
637
898
  const names = positional.map((name) => ALIASES[name] ?? name)
638
- if (names.length === 0) fail('Aucun composant demandé. Ex. : uifolio add calendar otp')
899
+ if (names.length === 0) fail('No component requested. E.g.: uifolio add calendar otp')
639
900
 
640
901
  const cwd = resolve(flags.cwd ?? process.cwd())
641
902
  const config = readConfig(cwd) ?? {}
@@ -647,33 +908,33 @@ async function commandAdd({ positional, flags }) {
647
908
  const colorFlag = typeof flags.color === 'string' ? flags.color : undefined
648
909
  const accent = colorFlag === undefined ? null : normalizeHex(colorFlag)
649
910
  if (colorFlag !== undefined && !accent) {
650
- fail(`Couleur invalide : ${colorFlag}. Utilise un hex comme #10B981.`)
911
+ fail(`Invalid color: ${colorFlag}. Use a hex value like #10B981.`)
651
912
  }
652
913
 
653
914
  let radius
654
915
  if (typeof flags.radius === 'string') {
655
916
  radius = Math.round(Number(flags.radius))
656
917
  if (!Number.isFinite(radius) || radius < 0 || radius > 24) {
657
- fail(`Arrondi invalide : ${flags.radius}. Utilise une valeur entre 0 et 24.`)
918
+ fail(`Invalid radius: ${flags.radius}. Use a value between 0 and 24.`)
658
919
  }
659
920
  }
660
921
 
661
922
  const known = listComponents()
662
923
  const unknown = names.filter((name) => !known.includes(name))
663
924
  if (unknown.length > 0) {
664
- fail(`Composant(s) inconnu(s) : ${unknown.join(', ')}. Lance « uifolio list ».`)
925
+ fail(`Unknown component(s): ${unknown.join(', ')}. Run "uifolio list".`)
665
926
  }
666
- if (!dryRun && !existsSync(cwd)) fail(`Répertoire cible introuvable : ${cwd}`)
927
+ if (!dryRun && !existsSync(cwd)) fail(`Target directory not found: ${cwd}`)
667
928
 
668
929
  const prompter = createPrompter(flags)
669
930
  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
931
+ // 1. Dossier d'import des composants : toujours src/components/ui.
932
+ const componentsDir = normalizeComponentsDir(
933
+ typeof flags.dir === 'string' ? flags.dir : config.dir ?? DEFAULT_COMPONENTS_DIR,
934
+ )
935
+ if (looksLikeFile(componentsDir)) {
936
+ fail(`"${componentsDir}" is a file: give a folder (e.g. ${DEFAULT_COMPONENTS_DIR}).`)
675
937
  }
676
- componentsDir = componentsDir.replace(/^\.\//, '').replace(/\/+$/, '')
677
938
 
678
939
  // 2. Copie des fichiers + helpers.
679
940
  const deps = new Set(LIB_DEPS)
@@ -701,8 +962,12 @@ async function commandAdd({ positional, flags }) {
701
962
  const shouldWrite = force || !existsSync(destination)
702
963
  if (shouldWrite) {
703
964
  if (!dryRun) {
704
- mkdirSync(dirname(destination), { recursive: true })
705
- writeFileSync(destination, rewriteAlias(source, targetAlias), 'utf8')
965
+ try {
966
+ mkdirSync(dirname(destination), { recursive: true })
967
+ writeFileSync(destination, rewriteAlias(source, targetAlias), 'utf8')
968
+ } catch (error) {
969
+ writeFailure(error, label)
970
+ }
706
971
  }
707
972
  written.add(label)
708
973
  } else {
@@ -711,27 +976,41 @@ async function commandAdd({ positional, flags }) {
711
976
  }
712
977
  }
713
978
 
714
- ok(`${written.size} fichier(s) ${dryRun ? '[dry-run] auraient été copiés' : 'copié(s)'} :`)
979
+ ok(`${written.size} file(s) ${dryRun ? 'would be copied [dry-run]' : 'copied'}:`)
715
980
  for (const label of [...written].sort()) info(`· ${label}`)
716
981
  if (skipped.length > 0) {
717
- info(`${skipped.length} fichier(s) déjà présent(s), ignoré(s) : ${skipped.join(', ')} (–force pour écraser)`)
982
+ info(`${skipped.length} file(s) already present, skipped: ${skipped.join(', ')} (use --force to overwrite)`)
718
983
  }
719
984
 
720
985
  // 3. Tokens Tailwind : les classes bg-primary & co fonctionnent partout.
721
986
  writeTheme({ cwd, accent, radius, dryRun })
722
987
 
723
- // 4. Utilisation du composant, après installation.
988
+ // 4. Component usage, after installation.
724
989
  let pageTarget = typeof flags.page === 'string' ? resolve(cwd, flags.page) : null
725
990
  if (!pageTarget && prompter) {
726
- pageTarget = await chooseUsageTarget({ prompter, cwd, names })
991
+ pageTarget = await chooseUsageTarget({
992
+ prompter,
993
+ cwd,
994
+ names,
995
+ exportNames: components.map((component) => component.exportName),
996
+ })
997
+ }
998
+ if (pageTarget) {
999
+ writeUsage({
1000
+ cwd,
1001
+ target: pageTarget,
1002
+ componentsDir,
1003
+ components,
1004
+ alias: targetAlias,
1005
+ dryRun,
1006
+ })
727
1007
  }
728
- if (pageTarget) writeUsage({ cwd, target: pageTarget, componentsDir, components, dryRun })
729
1008
 
730
1009
  if (written.size > 0 || dryRun) {
731
- console.log('\n Dépendances npm détectées :')
1010
+ console.log('\n Detected npm dependencies:')
732
1011
  info(`npm install ${[...deps].sort().join(' ')}`)
733
1012
  if (targetAlias !== '@') {
734
- info(`Pense à aligner l'alias ${targetAlias} dans ton tsconfig/vite (remplace de @/).`)
1013
+ info(`Remember to align the ${targetAlias} alias in your tsconfig/vite (instead of @/).`)
735
1014
  }
736
1015
  console.log('')
737
1016
  }
@@ -741,37 +1020,40 @@ async function commandAdd({ positional, flags }) {
741
1020
  }
742
1021
 
743
1022
  /* -------------------------------------------------------------------------- */
744
- /* Entrée */
1023
+ /* Prompting */
745
1024
  /* -------------------------------------------------------------------------- */
746
1025
 
747
1026
  function usage() {
748
1027
  console.log(`
749
- \x1b[1muifolio\x1b[0m — installe les composants UIfolio dans un projet
750
-
751
- \x1b[36muifolio list\x1b[0m composants disponibles
752
- \x1b[36muifolio init [options]\x1b[0m écrit uifolio.json dans le projet cible
753
- \x1b[36muifolio add <noms...> [options]\x1b[0m copie les composants + leurs helpers
754
-
755
- Options :
756
- --cwd <dir> projet cible (défaut : répertoire courant)
757
- --dir <chemin> dossier composants (défaut : src/components/ui)
758
- --lib <chemin> dossier helpers (défaut : src/lib)
759
- --alias <alias> alias d'import cible (défaut : @)
760
- --color <hex> couleur primaire, écrite dans les tokens Tailwind (@theme)
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
765
- --force écrase les fichiers existants
766
- --dry-run simule sans écrire
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
-
773
- Exemples :
774
- node cli/uifolio.mjs add video-player calendar otp --cwd ../mon-projet
1028
+ \x1b[1muifolio\x1b[0m — installs the UIfolio components into a project
1029
+
1030
+ \x1b[36muifolio list\x1b[0m list available components
1031
+ \x1b[36muifolio init [options]\x1b[0m write uifolio.json in the target project
1032
+ \x1b[36muifolio add <names...> [options]\x1b[0m copy the components + their helpers
1033
+
1034
+ Options:
1035
+ --cwd <dir> target project (default: current directory)
1036
+ --dir <path> components folder (default: src/components/ui)
1037
+ --lib <path> helpers folder (default: src/lib)
1038
+ --alias <alias> target import alias (default: @)
1039
+ --color <hex> primary color, written in the Tailwind tokens (@theme)
1040
+ --radius <px> global radius (0–24) in uifolio-theme.css
1041
+ --page <path> file where the component is used (e.g. src/pages/calendar.tsx);
1042
+ created if missing, otherwise the import + <Component /> are added
1043
+ --interactive ask the questions outside a terminal
1044
+ --force overwrite existing files
1045
+ --dry-run simulate without writing
1046
+
1047
+ Without --page, the CLI asks whether a file is already ready to receive the
1048
+ components: they are added to that file, to a new folder + file, or installed
1049
+ only. Files go to src/components/ui (override with --dir) and the page imports
1050
+ them through the alias (@/components/ui/...). The page also gets a working
1051
+ example, with the data and props each component needs, so it shows immediately.
1052
+ Colors go through a Tailwind CSS 4 theme (uifolio-theme.css, @theme), so classes
1053
+ like bg-primary and text-muted-foreground stay in the code.
1054
+
1055
+ Examples:
1056
+ node cli/uifolio.mjs add video-player calendar otp --cwd ../my-project
775
1057
  node cli/uifolio.mjs add otp --color "#10B981" --radius 8
776
1058
  node cli/uifolio.mjs add calendar --page src/pages/calendar.tsx
777
1059
  `)