uifolio 0.5.1 → 0.5.3

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 --dir / --page, the CLI asks interactively (terminal) which folder
26
+ * receives the components and where to use them.
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,13 @@ 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). */
147
168
  function componentExportName(source, fallback) {
148
169
  const matches = [...source.matchAll(/export\s+function\s+([A-Z][A-Za-z0-9_]*)/g)].map((match) => match[1])
149
170
  return matches[0] ?? fallback
150
171
  }
151
172
 
152
- /** Fichiers .tsx de src/pages/ du projet cible, triés. */
173
+ /** .tsx files under src/pages of the target project, sorted. */
153
174
  function listPagesInCwd(cwd) {
154
175
  try {
155
176
  const pagesDir = join(resolve(cwd), 'src', 'pages')
@@ -163,7 +184,7 @@ function listPagesInCwd(cwd) {
163
184
  }
164
185
  }
165
186
 
166
- /** Fichiers où l'usage du composant peut atterrir (indices affichés). */
187
+ /** Files where the component usage may land (flags shown). */
167
188
  function usageCandidates(cwd) {
168
189
  const app = existsSync(join(resolve(cwd), 'src', 'App.tsx')) ? 'src/App.tsx' : null
169
190
  const pages = listPagesInCwd(cwd).filter((page) => page !== app)
@@ -172,7 +193,7 @@ function usageCandidates(cwd) {
172
193
 
173
194
  /**
174
195
  * Saisie interactive : uniquement sur un terminal (ou avec --interactive),
175
- * jamais en CI / pipe sans données, pour ne pas bloquer l'installation.
196
+ * never in CI / pipe without data, to avoid blocking the installation.
176
197
  */
177
198
  function createPrompter(flags) {
178
199
  const allowed = Boolean(flags.interactive) || process.stdin.isTTY === true
@@ -200,22 +221,35 @@ function createPrompter(flags) {
200
221
  }
201
222
  })
202
223
 
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) => {
224
+ async function ask(question, fallback) {
225
+ const suffix = fallback ? ` (${fallback})` : ''
226
+ process.stdout.write(`\n \x1b[36m?\x1b[0m ${question}${suffix} : `)
227
+
228
+ let answer
229
+ if (queued.length > 0) answer = queued.shift()
230
+ else if (closed) answer = null
231
+ else
232
+ answer = await new Promise((resolveLine) => {
212
233
  waiting = resolveLine
213
234
  })
214
235
 
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
236
+ // stdin closed (pipe without data): keep the default value.
237
+ if (answer === null) return fallback
238
+ const trimmed = answer.trim()
239
+ return trimmed === '' ? fallback : trimmed
240
+ }
241
+
242
+ return {
243
+ ask,
244
+ /** y / yes / n / no (French values tolerated as fallback). */
245
+ async confirm(question, fallback = false) {
246
+ const answer = await ask(`${question} (y/n)`)
247
+ const value = String(answer ?? '')
248
+ .trim()
249
+ .toLowerCase()
250
+ if (['y', 'yes', 'o', 'oui'].includes(value)) return true
251
+ if (['n', 'no', 'non'].includes(value)) return false
252
+ return fallback
219
253
  },
220
254
  close() {
221
255
  if (closed) return
@@ -226,12 +260,12 @@ function createPrompter(flags) {
226
260
  }
227
261
 
228
262
  /**
229
- * Résolution transitive : un composant importe des helpers (@/lib/…)
230
- * qui peuvent eux-mêmes importer d'autres fichiers locaux.
263
+ * Transitive resolution: a component imports helpers (@/lib/…)
264
+ * which may themselves import other local files.
231
265
  */
232
266
  function collectFiles(entryPath, srcRoot) {
233
267
  const queue = [entryPath]
234
- const visited = new Map() // chemin absolu -> chemin relatif à src/
268
+ const visited = new Map() // absolute path -> relative path from src/
235
269
 
236
270
  while (queue.length > 0) {
237
271
  const current = queue.shift()
@@ -249,7 +283,7 @@ function collectFiles(entryPath, srcRoot) {
249
283
  ]
250
284
  const found = candidates.find((candidate) => existsSync(candidate))
251
285
  if (found) queue.push(found)
252
- else fail(`Import local introuvable : ${specifier} (depuis ${relative(srcRoot, current)})`)
286
+ else fail(`Local import not found: ${specifier} (from ${relative(srcRoot, current)})`)
253
287
  }
254
288
  }
255
289
 
@@ -282,14 +316,14 @@ function isLightHex(hex) {
282
316
  }
283
317
 
284
318
  /* -------------------------------------------------------------------------- */
285
- /* Thème Tailwind CSS 4 */
319
+ /* Tailwind CSS 4 theme */
286
320
  /* -------------------------------------------------------------------------- */
287
321
 
288
322
  /**
289
323
  * Les composants utilisent uniquement des classes Tailwind (`bg-primary`,
290
324
  * `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.
325
+ * Tailwind 4 must discover the tokens via `@theme`: a plain `:root`
326
+ * generates no utility class.
293
327
  */
294
328
  function buildThemeCss({ accent, radius }) {
295
329
  const foreground = isLightHex(accent) ? '#0f172a' : '#ffffff'
@@ -325,7 +359,7 @@ function buildThemeCss({ accent, radius }) {
325
359
  ' color-scheme: dark;',
326
360
  ]
327
361
 
328
- return `/* uifolio — tokens Tailwind CSS 4 : les classes bg-primary, text-muted-foreground… viennent d'ici. */
362
+ return `/* uifolio — Tailwind CSS 4 tokens: bg-primary, text-muted-foreground… come from here. */
329
363
  :root {
330
364
  ${rootTokens.join('\n')}
331
365
  }
@@ -368,7 +402,7 @@ function findGlobalCss(cwd) {
368
402
  return null
369
403
  }
370
404
 
371
- /** Ajoute `@import './uifolio-theme.css';` juste après l'import de Tailwind. */
405
+ /** Adds `@import './uifolio-theme.css';` right after the Tailwind import. */
372
406
  function wireThemeImport(source, specifier) {
373
407
  const line = `@import '${specifier}';`
374
408
  if (source.includes(specifier)) return source
@@ -379,14 +413,14 @@ function wireThemeImport(source, specifier) {
379
413
  return lines.join('\n')
380
414
  }
381
415
 
382
- /** `--primary` déjà défini dans un CSS (hex uniquement), sinon null. */
416
+ /** `--primary` already defined in a CSS (hex only), otherwise null. */
383
417
  function existingAccent(file) {
384
418
  if (!file || !existsSync(file)) return null
385
419
  const match = /--primary:\s*(#[0-9a-f]{6})\s*;/i.exec(readFileSync(file, 'utf8'))
386
420
  return match ? match[1].toLowerCase() : null
387
421
  }
388
422
 
389
- /** `--radius` déjà défini dans un CSS, sinon undefined. */
423
+ /** `--radius` already defined in a CSS, otherwise undefined. */
390
424
  function existingRadius(file) {
391
425
  if (!file || !existsSync(file)) return undefined
392
426
  const match = /--radius:\s*(\d+(?:\.\d+)?)px\s*;/.exec(readFileSync(file, 'utf8'))
@@ -398,20 +432,20 @@ function writeTheme({ cwd, accent, radius, dryRun }) {
398
432
  const themeFile = globalCss
399
433
  ? join(dirname(globalCss), THEME_FILE)
400
434
  : join(cwd, THEME_FILE)
401
- // Sans --color / --radius, on garde le thème déjà en place (projet ou UIfolio).
435
+ // Without --color / --radius, keep the theme already in place (project or UIfolio).
402
436
  const finalAccent = accent ?? existingAccent(themeFile) ?? existingAccent(globalCss) ?? DEFAULT_ACCENT
403
437
  const finalRadius = radius ?? existingRadius(themeFile)
404
438
  const css = buildThemeCss({ accent: finalAccent, radius: finalRadius })
405
439
  const label = relative(cwd, themeFile)
406
440
 
407
441
  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)}`)
442
+ ok(`[dry-run] Tailwind theme would have been written to ${label}`)
443
+ if (globalCss) info(`[dry-run] imported in ${relative(cwd, globalCss)}`)
410
444
  return
411
445
  }
412
446
 
413
447
  writeFileSync(themeFile, css, 'utf8')
414
- ok(`thème Tailwind écrit dans ${label}`)
448
+ ok(`Tailwind theme written to ${label}`)
415
449
  info(`--primary: ${finalAccent}${finalRadius === undefined ? '' : ` · --radius: ${finalRadius}px`}`)
416
450
 
417
451
  if (globalCss) {
@@ -419,9 +453,9 @@ function writeTheme({ cwd, accent, radius, dryRun }) {
419
453
  const specifier = `./${basename(themeFile)}`
420
454
  const updated = wireThemeImport(source, specifier)
421
455
  if (updated !== source) writeFileSync(globalCss, updated, 'utf8')
422
- info(`importé dans ${relative(cwd, globalCss)} — aucune couleur en dur dans les composants`)
456
+ info(`imported in ${relative(cwd, globalCss)} — no hard-coded colors in the components`)
423
457
  } else {
424
- info(`Ajoute « @import './${THEME_FILE}'; » en haut de ton CSS global (celui de Tailwind).`)
458
+ info(`Add "@import './${THEME_FILE}';", at the top of your global CSS (the Tailwind one).`)
425
459
  }
426
460
  }
427
461
 
@@ -435,7 +469,7 @@ function importSpecifier(fromDir, target) {
435
469
  return rel.startsWith('.') ? rel : `./${rel}`
436
470
  }
437
471
 
438
- /** Insère les imports après le dernier import du fichier. */
472
+ /** Inserts the imports after the last import of the file. */
439
473
  function addImports(source, lines) {
440
474
  const missing = lines.filter((line) => !source.includes(line))
441
475
  if (missing.length === 0) return source
@@ -447,7 +481,7 @@ function addImports(source, lines) {
447
481
  }
448
482
  if (last === -1) return `${missing.join('\n')}\n\n${source}`
449
483
 
450
- // Un import peut tenir sur plusieurs lignes : on avance jusqu'à la fin.
484
+ // An import can span several lines: advance to the end of it.
451
485
  let end = last
452
486
  while (
453
487
  end < all.length - 1 &&
@@ -463,7 +497,7 @@ function addImports(source, lines) {
463
497
  }
464
498
 
465
499
  /**
466
- * Fin de la balise ouvrante du `<` à `start`, sans se laisser piéger par les
500
+ * End of the opening tag from `<` to `start`, without being tricked by the
467
501
  * `>`/apostrophes des attributs et des textes JSX.
468
502
  */
469
503
  function findOpeningTagEnd(source, start) {
@@ -485,7 +519,7 @@ function findOpeningTagEnd(source, start) {
485
519
  return null
486
520
  }
487
521
 
488
- /** Ajoute `<Composant />` dans le JSX retourné, sinon un exemple exporté. */
522
+ /** Adds `<Composant />` in the returned JSX, otherwise an exported demo. */
489
523
  function addJsxUsage(source, exportNames) {
490
524
  const missing = exportNames.filter((name) => !new RegExp(`<${name}[\\s/>]`).test(source))
491
525
  if (missing.length === 0) return { source, added: [] }
@@ -502,7 +536,7 @@ function addJsxUsage(source, exportNames) {
502
536
  const childIndent = `${indent} `
503
537
  const children = missing.map((name) => `${childIndent}<${name} />`).join('\n')
504
538
 
505
- // Fragment déjà présent : on insère juste après `<>`.
539
+ // Fragment already present: insert right after `<>`.
506
540
  if (source.startsWith('<>', rootIndex)) {
507
541
  const next = `${source.slice(0, rootIndex + 2)}\n${children}${source.slice(rootIndex + 2)}`
508
542
  return { source: next, added: missing }
@@ -516,7 +550,7 @@ function addJsxUsage(source, exportNames) {
516
550
  return { source: next, added: missing }
517
551
  }
518
552
 
519
- // Élément ouvrant : les composants deviennent ses premiers enfants.
553
+ // Opening element: the components become its first children.
520
554
  const next = `${source.slice(0, tag.end + 1)}\n${children}${source.slice(tag.end + 1)}`
521
555
  return { source: next, added: missing }
522
556
  }
@@ -529,10 +563,13 @@ function addJsxUsage(source, exportNames) {
529
563
  }
530
564
 
531
565
  /**
532
- * Écrit (ou complète) le fichier choisi : imports depuis le dossier réel des
566
+ * Writes (or completes) the chosen file: imports from the real folder of the
533
567
  * composants + appel `<Composant />` dans le JSX.
534
568
  */
535
569
  function writeUsage({ cwd, target, componentsDir, components, dryRun }) {
570
+ if (existsSync(target) && statSync(target).isDirectory()) {
571
+ fail(`${relative(cwd, target)} is a folder: give a file (e.g. src/pages/${components[0].name}.tsx).`)
572
+ }
536
573
  const created = !existsSync(target)
537
574
  const lines = components.map(({ name, exportName }) =>
538
575
  `import { ${exportName} } from '${importSpecifier(dirname(target), join(resolve(cwd, componentsDir), name))}'`,
@@ -554,19 +591,29 @@ function writeUsage({ cwd, target, componentsDir, components, dryRun }) {
554
591
  '',
555
592
  ].join('\n')
556
593
  if (!dryRun) {
557
- mkdirSync(dirname(target), { recursive: true })
558
- writeFileSync(target, body, 'utf8')
594
+ try {
595
+ mkdirSync(dirname(target), { recursive: true })
596
+ writeFileSync(target, body, 'utf8')
597
+ } catch (error) {
598
+ writeFailure(error, label)
599
+ }
559
600
  }
560
- ok(`${dryRun ? '[dry-run] ' : ''}page créée : ${label}`)
601
+ ok(`${dryRun ? '[dry-run] ' : ''}page created: ${label}`)
561
602
  } else {
562
603
  const source = readFileSync(target, 'utf8')
563
604
  const withImports = addImports(source, lines)
564
605
  const { source: updated, added } = addJsxUsage(withImports, names)
565
- if (!dryRun) writeFileSync(target, updated, 'utf8')
566
- ok(`${dryRun ? '[dry-run] ' : ''}usage ajouté dans ${label}`)
606
+ if (!dryRun) {
607
+ try {
608
+ writeFileSync(target, updated, 'utf8')
609
+ } catch (error) {
610
+ writeFailure(error, label)
611
+ }
612
+ }
613
+ ok(`${dryRun ? '[dry-run] ' : ''}usage added to ${label}`)
567
614
  if (added.length < names.length) {
568
615
  const already = names.filter((name) => !added.includes(name))
569
- info(`déjà présent(s), non dupliqué(s) : ${already.join(', ')}`)
616
+ info(`already present, not duplicated: ${already.join(', ')}`)
570
617
  }
571
618
  }
572
619
 
@@ -588,18 +635,18 @@ const ALIASES = {
588
635
 
589
636
  function commandList() {
590
637
  const components = listComponents()
591
- if (components.length === 0) fail('Aucun composant trouvé dans le registre.')
592
- console.log('\nComposants disponibles :\n')
638
+ if (components.length === 0) fail('No component found in the registry.')
639
+ console.log('\nAvailable components:\n')
593
640
  for (const name of components) {
594
641
  console.log(` \x1b[36m${name.padEnd(20)}\x1b[0m src/components/ui/${name}.tsx`)
595
642
  }
596
- console.log('\nUsage : uifolio add <nom> [--cwd <projet cible>]\n')
643
+ console.log('\nUsage: uifolio add <name> [--cwd <target project>]\n')
597
644
  }
598
645
 
599
646
  function commandInit({ flags }) {
600
647
  const cwd = resolve(flags.cwd ?? process.cwd())
601
648
  const file = join(cwd, CONFIG_NAME)
602
- if (existsSync(file) && !flags.force) fail(`${CONFIG_NAME} existe déjà (utilise --force).`)
649
+ if (existsSync(file) && !flags.force) fail(`${CONFIG_NAME} already exists (use --force).`)
603
650
 
604
651
  const config = {
605
652
  $schema: 'https://uifolio.dev/schema.json',
@@ -608,26 +655,42 @@ function commandInit({ flags }) {
608
655
  alias: typeof flags.alias === 'string' ? flags.alias : '@',
609
656
  }
610
657
  writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`, 'utf8')
611
- ok(`${CONFIG_NAME} écrit dans ${cwd}`)
658
+ ok(`${CONFIG_NAME} written to ${cwd}`)
612
659
  info(`dir: ${config.dir} · lib: ${config.lib} · alias: ${config.alias}`)
613
- info('Puis : uifolio add video-player calendar …')
660
+ info('Next: uifolio add video-player calendar …')
614
661
  }
615
662
 
616
663
  /**
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.
664
+ * Asks where the component should be used, right after installation:
665
+ * an existing file, a new folder + import file, or no file at all.
619
666
  */
620
- async function chooseUsageTarget({ prompter, cwd, names }) {
667
+ async function chooseUsageTarget({ prompter, cwd, names, exportNames }) {
621
668
  const suggestion = `src/pages/${names[0]}.tsx`
669
+ const pathFormat = `e.g. ${suggestion}`
670
+ const label = exportNames.join(', ')
622
671
  const candidates = usageCandidates(cwd)
623
- if (candidates.length > 0) info(`fichiers existants : ${candidates.join(', ')}`)
672
+ if (candidates.length > 0) info(`existing files: ${candidates.join(', ')}`)
624
673
 
625
- const answer = await prompter.ask(
626
- `Dans quel fichier utiliser ${names.join(', ')} ? « - » pour ne pas l'utiliser`,
627
- suggestion,
674
+ const ready = await prompter.confirm(
675
+ `Do you already have a file ready to receive the component (${label})?`,
676
+ false,
628
677
  )
678
+
679
+ let answer
680
+ if (ready) {
681
+ answer = await prompter.ask(`In which file should the component (${label}) be copied? (${pathFormat})`, suggestion)
682
+ } else {
683
+ const create = await prompter.confirm('Would you like to create a new folder and an import file?', true)
684
+ if (!create) {
685
+ info('the component is installed but not used in any file.')
686
+ return null
687
+ }
688
+ answer = await prompter.ask(`Please specify the new path (${pathFormat})`, suggestion)
689
+ }
690
+
691
+ // “ - ”: installation only, like the “no” answer.
629
692
  if (!answer || answer === '-') {
630
- info('le composant est installé mais n’est utilisé dans aucun fichier.')
693
+ info('the component is installed but not used in any file.')
631
694
  return null
632
695
  }
633
696
  return resolve(cwd, answer)
@@ -635,7 +698,7 @@ async function chooseUsageTarget({ prompter, cwd, names }) {
635
698
 
636
699
  async function commandAdd({ positional, flags }) {
637
700
  const names = positional.map((name) => ALIASES[name] ?? name)
638
- if (names.length === 0) fail('Aucun composant demandé. Ex. : uifolio add calendar otp')
701
+ if (names.length === 0) fail('No component requested. E.g.: uifolio add calendar otp')
639
702
 
640
703
  const cwd = resolve(flags.cwd ?? process.cwd())
641
704
  const config = readConfig(cwd) ?? {}
@@ -647,33 +710,43 @@ async function commandAdd({ positional, flags }) {
647
710
  const colorFlag = typeof flags.color === 'string' ? flags.color : undefined
648
711
  const accent = colorFlag === undefined ? null : normalizeHex(colorFlag)
649
712
  if (colorFlag !== undefined && !accent) {
650
- fail(`Couleur invalide : ${colorFlag}. Utilise un hex comme #10B981.`)
713
+ fail(`Invalid color: ${colorFlag}. Use a hex value like #10B981.`)
651
714
  }
652
715
 
653
716
  let radius
654
717
  if (typeof flags.radius === 'string') {
655
718
  radius = Math.round(Number(flags.radius))
656
719
  if (!Number.isFinite(radius) || radius < 0 || radius > 24) {
657
- fail(`Arrondi invalide : ${flags.radius}. Utilise une valeur entre 0 et 24.`)
720
+ fail(`Invalid radius: ${flags.radius}. Use a value between 0 and 24.`)
658
721
  }
659
722
  }
660
723
 
661
724
  const known = listComponents()
662
725
  const unknown = names.filter((name) => !known.includes(name))
663
726
  if (unknown.length > 0) {
664
- fail(`Composant(s) inconnu(s) : ${unknown.join(', ')}. Lance « uifolio list ».`)
727
+ fail(`Unknown component(s): ${unknown.join(', ')}. Run "uifolio list".`)
665
728
  }
666
- if (!dryRun && !existsSync(cwd)) fail(`Répertoire cible introuvable : ${cwd}`)
729
+ if (!dryRun && !existsSync(cwd)) fail(`Target directory not found: ${cwd}`)
667
730
 
668
731
  const prompter = createPrompter(flags)
669
732
  try {
670
733
  // 1. Dossier d'import des composants.
671
- let componentsDir = typeof flags.dir === 'string' ? flags.dir : config.dir ?? DEFAULT_COMPONENTS_DIR
734
+ let componentsDir = normalizeComponentsDir(
735
+ typeof flags.dir === 'string' ? flags.dir : config.dir ?? DEFAULT_COMPONENTS_DIR,
736
+ )
672
737
  if (prompter && typeof flags.dir !== 'string') {
673
- const answer = await prompter.ask('Dans quel dossier copier les composants', componentsDir)
674
- if (answer) componentsDir = answer
738
+ // A file path was already accepted here (mkdir relative to the root).
739
+ for (let attempts = 0; ; attempts += 1) {
740
+ const fallback = attempts === 0 ? componentsDir : DEFAULT_COMPONENTS_DIR
741
+ const answer = await prompter.ask('In which folder should the components be copied?', fallback)
742
+ componentsDir = normalizeComponentsDir(answer ?? DEFAULT_COMPONENTS_DIR)
743
+ if (!looksLikeFile(componentsDir) || attempts >= 2) break
744
+ info(`"${componentsDir}" is a file, not a folder: give a folder, e.g. ${DEFAULT_COMPONENTS_DIR}.`)
745
+ }
746
+ }
747
+ if (looksLikeFile(componentsDir)) {
748
+ fail(`"${componentsDir}" is a file: give a folder (e.g. ${DEFAULT_COMPONENTS_DIR}).`)
675
749
  }
676
- componentsDir = componentsDir.replace(/^\.\//, '').replace(/\/+$/, '')
677
750
 
678
751
  // 2. Copie des fichiers + helpers.
679
752
  const deps = new Set(LIB_DEPS)
@@ -701,8 +774,12 @@ async function commandAdd({ positional, flags }) {
701
774
  const shouldWrite = force || !existsSync(destination)
702
775
  if (shouldWrite) {
703
776
  if (!dryRun) {
704
- mkdirSync(dirname(destination), { recursive: true })
705
- writeFileSync(destination, rewriteAlias(source, targetAlias), 'utf8')
777
+ try {
778
+ mkdirSync(dirname(destination), { recursive: true })
779
+ writeFileSync(destination, rewriteAlias(source, targetAlias), 'utf8')
780
+ } catch (error) {
781
+ writeFailure(error, label)
782
+ }
706
783
  }
707
784
  written.add(label)
708
785
  } else {
@@ -711,27 +788,32 @@ async function commandAdd({ positional, flags }) {
711
788
  }
712
789
  }
713
790
 
714
- ok(`${written.size} fichier(s) ${dryRun ? '[dry-run] auraient été copiés' : 'copié(s)'} :`)
791
+ ok(`${written.size} file(s) ${dryRun ? 'would be copied [dry-run]' : 'copied'}:`)
715
792
  for (const label of [...written].sort()) info(`· ${label}`)
716
793
  if (skipped.length > 0) {
717
- info(`${skipped.length} fichier(s) déjà présent(s), ignoré(s) : ${skipped.join(', ')} (–force pour écraser)`)
794
+ info(`${skipped.length} file(s) already present, skipped: ${skipped.join(', ')} (use --force to overwrite)`)
718
795
  }
719
796
 
720
797
  // 3. Tokens Tailwind : les classes bg-primary & co fonctionnent partout.
721
798
  writeTheme({ cwd, accent, radius, dryRun })
722
799
 
723
- // 4. Utilisation du composant, après installation.
800
+ // 4. Component usage, after installation.
724
801
  let pageTarget = typeof flags.page === 'string' ? resolve(cwd, flags.page) : null
725
802
  if (!pageTarget && prompter) {
726
- pageTarget = await chooseUsageTarget({ prompter, cwd, names })
803
+ pageTarget = await chooseUsageTarget({
804
+ prompter,
805
+ cwd,
806
+ names,
807
+ exportNames: components.map((component) => component.exportName),
808
+ })
727
809
  }
728
810
  if (pageTarget) writeUsage({ cwd, target: pageTarget, componentsDir, components, dryRun })
729
811
 
730
812
  if (written.size > 0 || dryRun) {
731
- console.log('\n Dépendances npm détectées :')
813
+ console.log('\n Detected npm dependencies:')
732
814
  info(`npm install ${[...deps].sort().join(' ')}`)
733
815
  if (targetAlias !== '@') {
734
- info(`Pense à aligner l'alias ${targetAlias} dans ton tsconfig/vite (remplace de @/).`)
816
+ info(`Remember to align the ${targetAlias} alias in your tsconfig/vite (instead of @/).`)
735
817
  }
736
818
  console.log('')
737
819
  }
@@ -741,37 +823,38 @@ async function commandAdd({ positional, flags }) {
741
823
  }
742
824
 
743
825
  /* -------------------------------------------------------------------------- */
744
- /* Entrée */
826
+ /* Prompting */
745
827
  /* -------------------------------------------------------------------------- */
746
828
 
747
829
  function usage() {
748
830
  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
831
+ \x1b[1muifolio\x1b[0m — installs the UIfolio components into a project
832
+
833
+ \x1b[36muifolio list\x1b[0m list available components
834
+ \x1b[36muifolio init [options]\x1b[0m write uifolio.json in the target project
835
+ \x1b[36muifolio add <names...> [options]\x1b[0m copy the components + their helpers
836
+
837
+ Options:
838
+ --cwd <dir> target project (default: current directory)
839
+ --dir <path> components folder (default: src/components/ui)
840
+ --lib <path> helpers folder (default: src/lib)
841
+ --alias <alias> target import alias (default: @)
842
+ --color <hex> primary color, written in the Tailwind tokens (@theme)
843
+ --radius <px> global radius (0–24) in uifolio-theme.css
844
+ --page <path> file where the component is used (e.g. src/pages/calendar.tsx);
845
+ created if missing, otherwise the import + <Component /> are added
846
+ --interactive ask the questions outside a terminal
847
+ --force overwrite existing files
848
+ --dry-run simulate without writing
849
+
850
+ Without --dir or --page, the CLI asks in which folder to copy the components,
851
+ then whether a file is already ready to receive them. The component is either
852
+ added to an existing file, added to a new folder + import file, or installed
853
+ only. Colors go through a Tailwind CSS 4 theme (uifolio-theme.css, @theme), so
854
+ classes like bg-primary and text-muted-foreground stay in the code.
855
+
856
+ Examples:
857
+ node cli/uifolio.mjs add video-player calendar otp --cwd ../my-project
775
858
  node cli/uifolio.mjs add otp --color "#10B981" --radius 8
776
859
  node cli/uifolio.mjs add calendar --page src/pages/calendar.tsx
777
860
  `)