uifolio 0.4.0 → 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,35 +1,39 @@
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
- * --page <chemin> crée ou adapte la page cible
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
11
12
  *
12
- * Options :
13
- * --cwd <dir> projet cible (défaut : répertoire courant)
14
- * --dir <chemin> dossier des composants (défaut : src/components/ui)
15
- * --lib <chemin> dossier des helpers (défaut : src/lib)
16
- * --alias <alias> alias d'import cible (défaut : @)
17
- * --color <hex> intègre cette couleur aux fichiers copiés (plus de --primary)
18
- * --radius <px> écrit --radius dans uifolio-theme.css
19
- * --force écrase les fichiers existants
20
- * --page <chemin> chemin de la page (ex : src/pages/calendar.tsx)
21
- * crée la page si elle n'existe pas, sinon y ajoute
22
- * l'import et un appel de composant
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
- * Exemples :
26
- * node cli/uifolio.mjs add calendar otp --cwd ../mon-projet
25
+ * Without --dir / --page, the CLI asks interactively (terminal) which folder
26
+ * receives the components and where to use them.
27
+ *
28
+ * Examples:
29
+ * node cli/uifolio.mjs add calendar otp --cwd ../my-project
27
30
  * node cli/uifolio.mjs add calendar --color "#10B981" --page src/pages/calendar.tsx
28
31
  * node cli/uifolio.mjs add video-player --alias "#lib" --dry-run
29
32
  */
30
33
 
31
- import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
32
- import { dirname, join, relative, resolve } from 'node:path'
34
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
35
+ import { basename, dirname, join, relative, resolve } from 'node:path'
36
+ import { createInterface } from 'node:readline/promises'
33
37
  import { fileURLToPath } from 'node:url'
34
38
 
35
39
  const HERE = dirname(fileURLToPath(import.meta.url))
@@ -37,7 +41,18 @@ const REGISTRY_SRC = resolve(HERE, '..', 'src')
37
41
  const REGISTRY_UI = join(REGISTRY_SRC, 'components', 'ui')
38
42
  const CONFIG_NAME = 'uifolio.json'
39
43
 
40
- 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
+
46
+ const DEFAULT_ACCENT = '#10b981'
47
+ const DEFAULT_COMPONENTS_DIR = 'src/components/ui'
48
+ const THEME_FILE = 'uifolio-theme.css'
49
+
50
+ /** Global CSS candidates for wiring the Tailwind theme. */
51
+ const GLOBAL_CSS_CANDIDATES = [
52
+ 'src/index.css',
53
+ 'src/app.css',
54
+ 'src/globals.css',
55
+ ]
41
56
 
42
57
  /* -------------------------------------------------------------------------- */
43
58
  /* Utilitaires */
@@ -77,6 +92,31 @@ function parseArgs(argv) {
77
92
  return { positional, flags }
78
93
  }
79
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
+
80
120
  function listComponents() {
81
121
  if (!existsSync(REGISTRY_UI)) return []
82
122
  return readdirSync(REGISTRY_UI)
@@ -109,7 +149,7 @@ function localImports(source) {
109
149
  return [...specifiers]
110
150
  }
111
151
 
112
- /** Dépendances npm externes (hors React) d'un fichier source. */
152
+ /** External npm dependencies (excluding React) of a source file. */
113
153
  function externalDeps(source) {
114
154
  const deps = new Set()
115
155
  const importRe = /from\s+['"]([^'"]+)['"]/g
@@ -124,21 +164,19 @@ function externalDeps(source) {
124
164
  return [...deps]
125
165
  }
126
166
 
127
- /**
128
- * Liste les pages cx (fichiers .tsx dans src/pages/) du projet cible.
129
- */
167
+ /** Real exported name of the file (otp-input → OtpInput). */
168
+ function componentExportName(source, fallback) {
169
+ const matches = [...source.matchAll(/export\s+function\s+([A-Z][A-Za-z0-9_]*)/g)].map((match) => match[1])
170
+ return matches[0] ?? fallback
171
+ }
172
+
173
+ /** .tsx files under src/pages of the target project, sorted. */
130
174
  function listPagesInCwd(cwd) {
131
- if (!cwd || typeof cwd !== 'string') return []
132
175
  try {
133
- const cwdPath = resolve(cwd)
134
- const srcRoot = join(cwdPath, 'src')
135
- if (!existsSync(srcRoot)) return []
136
- const pagesDir = join(srcRoot, 'pages')
176
+ const pagesDir = join(resolve(cwd), 'src', 'pages')
137
177
  if (!existsSync(pagesDir)) return []
138
- const entries = readdirSync(pagesDir)
139
- if (!Array.isArray(entries)) return []
140
- return entries
141
- .filter((name) => typeof name === 'string' && name.endsWith('.tsx'))
178
+ return readdirSync(pagesDir)
179
+ .filter((name) => name.endsWith('.tsx'))
142
180
  .map((name) => join('src', 'pages', name))
143
181
  .sort()
144
182
  } catch {
@@ -146,81 +184,88 @@ function listPagesInCwd(cwd) {
146
184
  }
147
185
  }
148
186
 
187
+ /** Files where the component usage may land (flags shown). */
188
+ function usageCandidates(cwd) {
189
+ const app = existsSync(join(resolve(cwd), 'src', 'App.tsx')) ? 'src/App.tsx' : null
190
+ const pages = listPagesInCwd(cwd).filter((page) => page !== app)
191
+ return app ? [app, ...pages] : pages
192
+ }
193
+
149
194
  /**
150
- * Demande interactivement à l'utilisateur quelle page utiliser, ou retourne
151
- * null si l'entrée n'est pas valide / la saisie est vide.
195
+ * Saisie interactive : uniquement sur un terminal (ou avec --interactive),
196
+ * never in CI / pipe without data, to avoid blocking the installation.
152
197
  */
153
- function choosePageInteraction(pages, cwd) {
154
- if (!Array.isArray(pages) || pages.length === 0) return null
155
- if (pages.length === 1) {
156
- info(`Une page existe déjà : ${pages[0]} (utilisée par défaut)`)
157
- try {
158
- return join(resolve(cwd), pages[0])
159
- } catch {
160
- return null
198
+ function createPrompter(flags) {
199
+ const allowed = Boolean(flags.interactive) || process.stdin.isTTY === true
200
+ if (!allowed) return null
201
+ const rl = createInterface({ input: process.stdin, output: process.stdout })
202
+ const queued = []
203
+ let waiting = null
204
+ let closed = false
205
+
206
+ rl.on('line', (line) => {
207
+ if (waiting) {
208
+ const resolveLine = waiting
209
+ waiting = null
210
+ resolveLine(line)
211
+ } else {
212
+ queued.push(line)
213
+ }
214
+ })
215
+ rl.on('close', () => {
216
+ closed = true
217
+ if (waiting) {
218
+ const resolveLine = waiting
219
+ waiting = null
220
+ resolveLine(null)
161
221
  }
162
- }
163
- let cwdPath = cwd
164
- try {
165
- cwdPath = resolve(cwd)
166
- } catch {
167
- cwdPath = process.cwd()
168
- }
169
-
170
- console.log('\nPlusieurs pages existent dans src/pages/. Choisis celle où importer le/des composant(s) :')
171
- pages.forEach((p, index) => {
172
- console.log(` ${index + 1}. ${p}`)
173
222
  })
174
- console.log(' 0. Ne pas importer dans une page')
175
223
 
176
- const answer = promptPageChoice()
177
- if (answer === null) {
178
- info('Aucune page sélectionnée : les composants seront copiés sans être importés dans une page.')
179
- return null
180
- }
181
- if (answer === 0) {
182
- info('Import dans une page annulé.')
183
- return null
184
- }
185
- const selected = pages[answer - 1]
186
- if (!selected) {
187
- info('Choix invalide : aucune page sélectionnée.')
188
- return null
189
- }
190
- info(`Page retenue : ${selected}`)
191
- try {
192
- return join(cwdPath, selected)
193
- } catch {
194
- return null
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) => {
233
+ waiting = resolveLine
234
+ })
235
+
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
195
240
  }
196
- }
197
241
 
198
- /**
199
- * Lecture synchrone d'une ligne depuis stdin (pour l'interaction CLI).
200
- * Retourne null en cas d'erreur de lecture ou d'EOF.
201
- */
202
- function promptPageChoice() {
203
- const chunk = Buffer.alloc(1024)
204
- try {
205
- const n = process.stdin.read(0, chunk.length, chunk)
206
- if (n <= 0) return null
207
- const text = chunk.toString('utf8', 0, n).trim()
208
- if (text === '') return null
209
- const parsed = Number(text)
210
- if (!Number.isInteger(parsed)) return null
211
- return parsed
212
- } catch {
213
- return null
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
253
+ },
254
+ close() {
255
+ if (closed) return
256
+ closed = true
257
+ rl.close()
258
+ },
214
259
  }
215
260
  }
216
261
 
217
262
  /**
218
- * Résolution transitive : un composant importe des helpers (@/lib/…)
219
- * 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.
220
265
  */
221
266
  function collectFiles(entryPath, srcRoot) {
222
267
  const queue = [entryPath]
223
- const visited = new Map() // chemin absolu -> chemin relatif à src/
268
+ const visited = new Map() // absolute path -> relative path from src/
224
269
 
225
270
  while (queue.length > 0) {
226
271
  const current = queue.shift()
@@ -238,7 +283,7 @@ function collectFiles(entryPath, srcRoot) {
238
283
  ]
239
284
  const found = candidates.find((candidate) => existsSync(candidate))
240
285
  if (found) queue.push(found)
241
- else fail(`Import local introuvable : ${specifier} (depuis ${relative(srcRoot, current)})`)
286
+ else fail(`Local import not found: ${specifier} (from ${relative(srcRoot, current)})`)
242
287
  }
243
288
  }
244
289
 
@@ -250,47 +295,6 @@ function rewriteAlias(source, targetAlias) {
250
295
  return source.replace(/(['"])@\//g, `$1${targetAlias}/`)
251
296
  }
252
297
 
253
- /**
254
- * Remplace les tokens `--primary` par la couleur choisie, directement dans le code
255
- * copié : `bg-primary` → `bg-[#10b981]`, `var(--primary)` → `#10b981`.
256
- * Le composant garde alors la couleur demandée sans dépendre de la variable du projet.
257
- */
258
- function personalizeColor(source, accent) {
259
- if (!accent) return source
260
- const foreground = isLightHex(accent) ? '#0f172a' : '#ffffff'
261
- return source
262
- .replace(/var\(--primary-foreground\)/g, foreground)
263
- .replace(/var\(--primary\)/g, accent)
264
- .replace(
265
- /\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,
266
- (_match, prefix, utility, isForeground, opacity = '') =>
267
- `${prefix}${utility}-[${isForeground ? foreground : accent}]${opacity}`,
268
- )
269
- }
270
-
271
- function copyFile({ to, source, dryRun, force, targetAlias, accent }) {
272
- if (existsSync(to) && !force) return 'skipped'
273
- if (!dryRun) {
274
- mkdirSync(dirname(to), { recursive: true })
275
- writeFileSync(to, rewriteAlias(personalizeColor(source, accent), targetAlias), 'utf8')
276
- }
277
- return 'written'
278
- }
279
-
280
- /* -------------------------------------------------------------------------- */
281
- /* Commandes */
282
- /* -------------------------------------------------------------------------- */
283
-
284
- const ALIASES = {
285
- otp: 'otp-input',
286
- editor: 'rich-text-editor',
287
- panels: 'resizable',
288
- shortcuts: 'command-menu',
289
- upload: 'file-upload',
290
- video: 'video-player',
291
- }
292
-
293
-
294
298
  function normalizeHex(value) {
295
299
  let next = String(value).trim()
296
300
  if (!next.startsWith('#')) next = `#${next}`
@@ -311,286 +315,572 @@ function isLightHex(hex) {
311
315
  return 0.299 * r + 0.587 * g + 0.114 * b > 160
312
316
  }
313
317
 
318
+ /* -------------------------------------------------------------------------- */
319
+ /* Tailwind CSS 4 theme */
320
+ /* -------------------------------------------------------------------------- */
321
+
322
+ /**
323
+ * Les composants utilisent uniquement des classes Tailwind (`bg-primary`,
324
+ * `text-muted-foreground`, `border-border`…). Pour que ces classes existent,
325
+ * Tailwind 4 must discover the tokens via `@theme`: a plain `:root`
326
+ * generates no utility class.
327
+ */
328
+ function buildThemeCss({ accent, radius }) {
329
+ const foreground = isLightHex(accent) ? '#0f172a' : '#ffffff'
330
+ const rootTokens = [
331
+ ' --background: #f8fafc;',
332
+ ' --foreground: #0f172a;',
333
+ ' --card: #ffffff;',
334
+ ' --card-foreground: #0f172a;',
335
+ ' --muted: #f1f5f9;',
336
+ ' --muted-foreground: #64748b;',
337
+ ' --border: #e2e8f0;',
338
+ ` --primary: ${accent};`,
339
+ ` --primary-foreground: ${foreground};`,
340
+ ` --accent: color-mix(in oklab, ${accent} 18%, var(--background));`,
341
+ ' --accent-foreground: var(--foreground);',
342
+ ` --ring: ${accent}80;`,
343
+ ]
344
+ if (radius !== undefined) rootTokens.push(` --radius: ${radius}px;`)
345
+
346
+ const darkTokens = [
347
+ ' --background: #0b1220;',
348
+ ' --foreground: #e2e8f0;',
349
+ ' --card: #101a2b;',
350
+ ' --card-foreground: #e2e8f0;',
351
+ ' --muted: #1e293b;',
352
+ ' --muted-foreground: #94a3b8;',
353
+ ' --border: rgba(255, 255, 255, 0.12);',
354
+ ` --primary: ${accent};`,
355
+ ` --primary-foreground: ${foreground};`,
356
+ ` --accent: color-mix(in oklab, ${accent} 24%, #0b1220);`,
357
+ ' --accent-foreground: #e2e8f0;',
358
+ ` --ring: ${accent}80;`,
359
+ ' color-scheme: dark;',
360
+ ]
361
+
362
+ return `/* uifolio — Tailwind CSS 4 tokens: bg-primary, text-muted-foreground… come from here. */
363
+ :root {
364
+ ${rootTokens.join('\n')}
365
+ }
366
+
367
+ .dark {
368
+ ${darkTokens.join('\n')}
369
+ }
370
+
371
+ @custom-variant dark (&:is(.dark *));
372
+
373
+ @theme inline {
374
+ --color-background: var(--background);
375
+ --color-foreground: var(--foreground);
376
+ --color-card: var(--card);
377
+ --color-card-foreground: var(--card-foreground);
378
+ --color-muted: var(--muted);
379
+ --color-muted-foreground: var(--muted-foreground);
380
+ --color-border: var(--border);
381
+ --color-primary: var(--primary);
382
+ --color-primary-foreground: var(--primary-foreground);
383
+ --color-accent: var(--accent);
384
+ --color-accent-foreground: var(--accent-foreground);
385
+ --color-ring: var(--ring);
386
+ }
387
+ `
388
+ }
389
+
390
+ /** CSS global du projet (celui qui importe Tailwind), sinon null. */
391
+ function findGlobalCss(cwd) {
392
+ for (const candidate of GLOBAL_CSS_CANDIDATES) {
393
+ const file = join(cwd, candidate)
394
+ if (!existsSync(file)) continue
395
+ const source = readFileSync(file, 'utf8')
396
+ if (/@import\s+['"]tailwindcss|@tailwind\b/.test(source)) return file
397
+ }
398
+ for (const candidate of GLOBAL_CSS_CANDIDATES) {
399
+ const file = join(cwd, candidate)
400
+ if (existsSync(file)) return file
401
+ }
402
+ return null
403
+ }
404
+
405
+ /** Adds `@import './uifolio-theme.css';` right after the Tailwind import. */
406
+ function wireThemeImport(source, specifier) {
407
+ const line = `@import '${specifier}';`
408
+ if (source.includes(specifier)) return source
409
+ const lines = source.split('\n')
410
+ const at = lines.findIndex((entry) => /@import\s+['"]tailwindcss|@tailwind\s/.test(entry))
411
+ if (at === -1) return `${line}\n${source}`
412
+ lines.splice(at + 1, 0, line)
413
+ return lines.join('\n')
414
+ }
415
+
416
+ /** `--primary` already defined in a CSS (hex only), otherwise null. */
417
+ function existingAccent(file) {
418
+ if (!file || !existsSync(file)) return null
419
+ const match = /--primary:\s*(#[0-9a-f]{6})\s*;/i.exec(readFileSync(file, 'utf8'))
420
+ return match ? match[1].toLowerCase() : null
421
+ }
422
+
423
+ /** `--radius` already defined in a CSS, otherwise undefined. */
424
+ function existingRadius(file) {
425
+ if (!file || !existsSync(file)) return undefined
426
+ const match = /--radius:\s*(\d+(?:\.\d+)?)px\s*;/.exec(readFileSync(file, 'utf8'))
427
+ return match ? Math.round(Number(match[1])) : undefined
428
+ }
429
+
314
430
  function writeTheme({ cwd, accent, radius, dryRun }) {
315
- if (!accent && radius === undefined) return
431
+ const globalCss = findGlobalCss(cwd)
432
+ const themeFile = globalCss
433
+ ? join(dirname(globalCss), THEME_FILE)
434
+ : join(cwd, THEME_FILE)
435
+ // Without --color / --radius, keep the theme already in place (project or UIfolio).
436
+ const finalAccent = accent ?? existingAccent(themeFile) ?? existingAccent(globalCss) ?? DEFAULT_ACCENT
437
+ const finalRadius = radius ?? existingRadius(themeFile)
438
+ const css = buildThemeCss({ accent: finalAccent, radius: finalRadius })
439
+ const label = relative(cwd, themeFile)
440
+
441
+ if (dryRun) {
442
+ ok(`[dry-run] Tailwind theme would have been written to ${label}`)
443
+ if (globalCss) info(`[dry-run] imported in ${relative(cwd, globalCss)}`)
444
+ return
445
+ }
316
446
 
317
- const rounded = radius === undefined ? null : Math.round(Number(radius))
318
- if (rounded !== null && (!Number.isFinite(rounded) || rounded < 0 || rounded > 24)) {
319
- fail(`Arrondi invalide : ${radius}. Utilise une valeur entre 0 et 24.`)
447
+ writeFileSync(themeFile, css, 'utf8')
448
+ ok(`Tailwind theme written to ${label}`)
449
+ info(`--primary: ${finalAccent}${finalRadius === undefined ? '' : ` · --radius: ${finalRadius}px`}`)
450
+
451
+ if (globalCss) {
452
+ const source = readFileSync(globalCss, 'utf8')
453
+ const specifier = `./${basename(themeFile)}`
454
+ const updated = wireThemeImport(source, specifier)
455
+ if (updated !== source) writeFileSync(globalCss, updated, 'utf8')
456
+ info(`imported in ${relative(cwd, globalCss)} — no hard-coded colors in the components`)
457
+ } else {
458
+ info(`Add "@import './${THEME_FILE}';", at the top of your global CSS (the Tailwind one).`)
320
459
  }
460
+ }
321
461
 
322
- const tokens = []
323
- // La couleur est déjà écrite dans les fichiers copiés : on n'écrit pas de token.
324
- if (!accent) {
325
- const fallback = '#10b981'
326
- tokens.push(
327
- ` --primary: ${fallback};`,
328
- ' --primary-foreground: #ffffff;',
329
- ` --ring: ${fallback}80;`,
330
- ` --accent: color-mix(in oklab, ${fallback} 18%, var(--background));`,
331
- ' --accent-foreground: var(--foreground);',
332
- )
462
+ /* -------------------------------------------------------------------------- */
463
+ /* Usage du composant dans une page */
464
+ /* -------------------------------------------------------------------------- */
465
+
466
+ /** Chemin d'import relatif, sans extension, en slashs. */
467
+ function importSpecifier(fromDir, target) {
468
+ const rel = relative(fromDir, target).replace(/\\/g, '/')
469
+ return rel.startsWith('.') ? rel : `./${rel}`
470
+ }
471
+
472
+ /** Inserts the imports after the last import of the file. */
473
+ function addImports(source, lines) {
474
+ const missing = lines.filter((line) => !source.includes(line))
475
+ if (missing.length === 0) return source
476
+
477
+ const all = source.split('\n')
478
+ let last = -1
479
+ for (let index = 0; index < all.length; index += 1) {
480
+ if (/^\s*import\s/.test(all[index])) last = index
481
+ }
482
+ if (last === -1) return `${missing.join('\n')}\n\n${source}`
483
+
484
+ // An import can span several lines: advance to the end of it.
485
+ let end = last
486
+ while (
487
+ end < all.length - 1 &&
488
+ end - last < 20 &&
489
+ !/from\s+['"][^'"]+['"];?\s*$/.test(all[end]) &&
490
+ !/^import\s+['"][^'"]+['"];?\s*$/.test(all[end])
491
+ ) {
492
+ end += 1
333
493
  }
334
- if (rounded !== null) tokens.push(` --radius: ${rounded}px;`)
335
- if (tokens.length === 0) return
336
494
 
337
- const file = join(cwd, 'uifolio-theme.css')
338
- if (!dryRun) writeFileSync(file, `:root {\n${tokens.join('\n')}\n}\n`, 'utf8')
339
- ok(`${dryRun ? '[dry-run] ' : ''}Thème écrit dans ${relative(cwd, file)}`)
340
- if (accent) info(`couleur ${accent} : déjà intégrée aux composants copiés`)
341
- if (rounded !== null) info(`arrondi ${rounded}px`)
342
- info(`Importe ${relative(cwd, file)} dans ton CSS global.`)
495
+ all.splice(end + 1, 0, ...missing)
496
+ return all.join('\n')
497
+ }
498
+
499
+ /**
500
+ * End of the opening tag from `<` to `start`, without being tricked by the
501
+ * `>`/apostrophes des attributs et des textes JSX.
502
+ */
503
+ function findOpeningTagEnd(source, start) {
504
+ let braceDepth = 0
505
+ for (let index = start + 1; index < source.length; index += 1) {
506
+ const char = source[index]
507
+ if (braceDepth === 0 && (char === '"' || char === "'")) {
508
+ const closing = source.indexOf(char, index + 1)
509
+ if (closing === -1) return null
510
+ index = closing
511
+ continue
512
+ }
513
+ if (char === '{') braceDepth += 1
514
+ else if (char === '}') braceDepth = Math.max(0, braceDepth - 1)
515
+ else if (char === '>' && braceDepth === 0) {
516
+ return { end: index, selfClosing: source[index - 1] === '/' }
517
+ }
518
+ }
519
+ return null
520
+ }
521
+
522
+ /** Adds `<Composant />` in the returned JSX, otherwise an exported demo. */
523
+ function addJsxUsage(source, exportNames) {
524
+ const missing = exportNames.filter((name) => !new RegExp(`<${name}[\\s/>]`).test(source))
525
+ if (missing.length === 0) return { source, added: [] }
526
+
527
+ const returnMatch = /\breturn\b/.exec(source)
528
+ let cursor = returnMatch ? returnMatch.index + returnMatch[0].length : -1
529
+ while (cursor !== -1 && cursor < source.length && /[\s(]/.test(source[cursor])) cursor += 1
530
+ const rootIndex = cursor !== -1 && source[cursor] === '<' ? cursor : -1
531
+ const tag = rootIndex === -1 ? null : findOpeningTagEnd(source, rootIndex)
532
+
533
+ if (tag) {
534
+ const lineStart = source.lastIndexOf('\n', rootIndex) + 1
535
+ const indent = /^\s*/.exec(source.slice(lineStart, rootIndex))?.[0] ?? ''
536
+ const childIndent = `${indent} `
537
+ const children = missing.map((name) => `${childIndent}<${name} />`).join('\n')
538
+
539
+ // Fragment already present: insert right after `<>`.
540
+ if (source.startsWith('<>', rootIndex)) {
541
+ const next = `${source.slice(0, rootIndex + 2)}\n${children}${source.slice(rootIndex + 2)}`
542
+ return { source: next, added: missing }
543
+ }
544
+
545
+ // Racine auto-fermante : on l'enveloppe dans un fragment.
546
+ if (tag.selfClosing) {
547
+ const element = source.slice(rootIndex, tag.end + 1)
548
+ const next =
549
+ `${source.slice(0, rootIndex)}<>\n${children}\n${indent}${element}\n${indent}</>${source.slice(tag.end + 1)}`
550
+ return { source: next, added: missing }
551
+ }
552
+
553
+ // Opening element: the components become its first children.
554
+ const next = `${source.slice(0, tag.end + 1)}\n${children}${source.slice(tag.end + 1)}`
555
+ return { source: next, added: missing }
556
+ }
557
+
558
+ // JSX introuvable (page sans `return` JSX) : on laisse un exemple utilisable.
559
+ const example = missing
560
+ .map((name) => `\nexport function ${name}Example() {\n return <${name} />\n}\n`)
561
+ .join('\n')
562
+ return { source: `${source.trimEnd()}\n${example}`, added: missing }
563
+ }
564
+
565
+ /**
566
+ * Writes (or completes) the chosen file: imports from the real folder of the
567
+ * composants + appel `<Composant />` dans le JSX.
568
+ */
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
+ }
573
+ const created = !existsSync(target)
574
+ const lines = components.map(({ name, exportName }) =>
575
+ `import { ${exportName} } from '${importSpecifier(dirname(target), join(resolve(cwd, componentsDir), name))}'`,
576
+ )
577
+ const label = relative(cwd, target)
578
+ const names = components.map((component) => component.exportName)
579
+
580
+ if (created) {
581
+ const body = [
582
+ ...lines,
583
+ '',
584
+ `export default function ${names[0]}Page() {`,
585
+ ' return (',
586
+ ' <main className="space-y-6 p-6">',
587
+ ...names.map((name) => ` <${name} />`),
588
+ ' </main>',
589
+ ' )',
590
+ '}',
591
+ '',
592
+ ].join('\n')
593
+ if (!dryRun) {
594
+ try {
595
+ mkdirSync(dirname(target), { recursive: true })
596
+ writeFileSync(target, body, 'utf8')
597
+ } catch (error) {
598
+ writeFailure(error, label)
599
+ }
600
+ }
601
+ ok(`${dryRun ? '[dry-run] ' : ''}page created: ${label}`)
602
+ } else {
603
+ const source = readFileSync(target, 'utf8')
604
+ const withImports = addImports(source, lines)
605
+ const { source: updated, added } = addJsxUsage(withImports, names)
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}`)
614
+ if (added.length < names.length) {
615
+ const already = names.filter((name) => !added.includes(name))
616
+ info(`already present, not duplicated: ${already.join(', ')}`)
617
+ }
618
+ }
619
+
620
+ for (const name of names) info(`· <${name} />`)
621
+ }
622
+
623
+ /* -------------------------------------------------------------------------- */
624
+ /* Commandes */
625
+ /* -------------------------------------------------------------------------- */
626
+
627
+ const ALIASES = {
628
+ otp: 'otp-input',
629
+ editor: 'rich-text-editor',
630
+ panels: 'resizable',
631
+ shortcuts: 'command-menu',
632
+ upload: 'file-upload',
633
+ video: 'video-player',
343
634
  }
344
635
 
345
636
  function commandList() {
346
637
  const components = listComponents()
347
- if (components.length === 0) fail('Aucun composant trouvé dans le registre.')
348
- console.log('\nComposants disponibles :\n')
638
+ if (components.length === 0) fail('No component found in the registry.')
639
+ console.log('\nAvailable components:\n')
349
640
  for (const name of components) {
350
641
  console.log(` \x1b[36m${name.padEnd(20)}\x1b[0m src/components/ui/${name}.tsx`)
351
642
  }
352
- console.log('\nUsage : uifolio add <nom> [--cwd <projet cible>]\n')
643
+ console.log('\nUsage: uifolio add <name> [--cwd <target project>]\n')
353
644
  }
354
645
 
355
646
  function commandInit({ flags }) {
356
647
  const cwd = resolve(flags.cwd ?? process.cwd())
357
648
  const file = join(cwd, CONFIG_NAME)
358
- 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).`)
359
650
 
360
651
  const config = {
361
652
  $schema: 'https://uifolio.dev/schema.json',
362
- dir: typeof flags.dir === 'string' ? flags.dir : 'src/components/ui',
653
+ dir: typeof flags.dir === 'string' ? flags.dir : DEFAULT_COMPONENTS_DIR,
363
654
  lib: typeof flags.lib === 'string' ? flags.lib : 'src/lib',
364
655
  alias: typeof flags.alias === 'string' ? flags.alias : '@',
365
656
  }
366
657
  writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`, 'utf8')
367
- ok(`${CONFIG_NAME} écrit dans ${cwd}`)
658
+ ok(`${CONFIG_NAME} written to ${cwd}`)
368
659
  info(`dir: ${config.dir} · lib: ${config.lib} · alias: ${config.alias}`)
369
- info('Puis : uifolio add video-player calendar …')
660
+ info('Next: uifolio add video-player calendar …')
370
661
  }
371
662
 
663
+ /**
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.
666
+ */
667
+ async function chooseUsageTarget({ prompter, cwd, names, exportNames }) {
668
+ const suggestion = `src/pages/${names[0]}.tsx`
669
+ const pathFormat = `e.g. ${suggestion}`
670
+ const label = exportNames.join(', ')
671
+ const candidates = usageCandidates(cwd)
672
+ if (candidates.length > 0) info(`existing files: ${candidates.join(', ')}`)
673
+
674
+ const ready = await prompter.confirm(
675
+ `Do you already have a file ready to receive the component (${label})?`,
676
+ false,
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
+ }
372
690
 
373
- function commandAdd({ positional, flags }) {
374
- const names = positional.map((name) => ALIASES[name] ?? name)
691
+ // “ - ”: installation only, like the “no” answer.
692
+ if (!answer || answer === '-') {
693
+ info('the component is installed but not used in any file.')
694
+ return null
695
+ }
696
+ return resolve(cwd, answer)
697
+ }
375
698
 
376
- if (names.length === 0) fail('Aucun composant demandé. Ex. : uifolio add calendar otp')
699
+ async function commandAdd({ positional, flags }) {
700
+ const names = positional.map((name) => ALIASES[name] ?? name)
701
+ if (names.length === 0) fail('No component requested. E.g.: uifolio add calendar otp')
377
702
 
378
703
  const cwd = resolve(flags.cwd ?? process.cwd())
379
704
  const config = readConfig(cwd) ?? {}
380
- const pageFlag = typeof flags.page === 'string' ? flags.page : undefined
381
- let pageTarget = pageFlag ? resolve(cwd, pageFlag) : null
382
-
383
- // Détermine la page cible effective : --page explicite, choix interactif, ou aucune.
384
- if (!pageTarget) {
385
- const pages = listPagesInCwd(cwd)
386
- if (pages.length > 0) {
387
- const chosen = choosePageInteraction(pages, cwd)
388
- if (chosen) pageTarget = chosen
389
- }
390
- }
391
-
392
- const createPage = Boolean(pageTarget) && !existsSync(pageTarget)
393
- let pageImports = []
394
-
395
- const componentsDir = typeof flags.dir === 'string' ? flags.dir : config.dir ?? 'src/components/ui'
396
- const libDir = typeof flags.lib === 'string' ? flags.lib : config.lib ?? 'src/lib'
397
- const targetAlias = typeof flags.alias === 'string' ? flags.alias : config.alias ?? '@'
398
705
  const dryRun = Boolean(flags['dry-run'])
399
706
  const force = Boolean(flags.force)
707
+ const targetAlias = typeof flags.alias === 'string' ? flags.alias : config.alias ?? '@'
708
+ const libDir = typeof flags.lib === 'string' ? flags.lib : config.lib ?? 'src/lib'
709
+
400
710
  const colorFlag = typeof flags.color === 'string' ? flags.color : undefined
401
711
  const accent = colorFlag === undefined ? null : normalizeHex(colorFlag)
402
712
  if (colorFlag !== undefined && !accent) {
403
- fail(`Couleur invalide : ${colorFlag}. Utilise un hex comme #10B981.`)
713
+ fail(`Invalid color: ${colorFlag}. Use a hex value like #10B981.`)
714
+ }
715
+
716
+ let radius
717
+ if (typeof flags.radius === 'string') {
718
+ radius = Math.round(Number(flags.radius))
719
+ if (!Number.isFinite(radius) || radius < 0 || radius > 24) {
720
+ fail(`Invalid radius: ${flags.radius}. Use a value between 0 and 24.`)
721
+ }
404
722
  }
405
723
 
406
724
  const known = listComponents()
407
725
  const unknown = names.filter((name) => !known.includes(name))
408
726
  if (unknown.length > 0) {
409
- fail(`Composant(s) inconnu(s) : ${unknown.join(', ')}. Lance « uifolio list ».`)
410
- }
411
-
412
- if (!dryRun && !existsSync(cwd)) fail(`Répertoire cible introuvable : ${cwd}`)
413
- if (pageTarget && !existsSync(pageTarget) && !dryRun) {
414
- const bad = pageFlag ?? relative(cwd, pageTarget)
415
- fail(`Page cible introuvable : ${bad}.`)
727
+ fail(`Unknown component(s): ${unknown.join(', ')}. Run "uifolio list".`)
416
728
  }
417
- if (pageTarget && !existsSync(pageTarget) && dryRun) {
418
- info(`[dry-run] page cible introuvable : ${pageFlag ?? relative(cwd, pageTarget)} (simulation uniquement).`)
419
- pageTarget = null
420
- }
421
-
422
- const deps = new Set(LIB_DEPS)
423
- const written = new Set()
424
- const skipped = []
425
-
426
- // Prépare les imports qui seront injectés dans la page cible.
427
- for (const name of names) {
428
- if (!pageTarget) continue
429
- const importPath = relative(cwd, join(componentsDir, `${name}.tsx`))
430
- pageImports.push({ name, importPath })
431
- }
432
-
433
- for (const name of names) {
434
- const entry = join(REGISTRY_UI, `${name}.tsx`)
435
- const files = collectFiles(entry, REGISTRY_SRC)
729
+ if (!dryRun && !existsSync(cwd)) fail(`Target directory not found: ${cwd}`)
436
730
 
437
- for (const [absolute, relativePath] of files) {
438
- const isUi = relativePath.startsWith(join('components', 'ui'))
439
- const destinationRoot = isUi ? resolve(cwd, componentsDir) : resolve(cwd, libDir)
440
- const destination = join(destinationRoot, relativePath.replace(/^(components\/ui|lib)\//, ''))
731
+ const prompter = createPrompter(flags)
732
+ try {
733
+ // 1. Dossier d'import des composants.
734
+ let componentsDir = normalizeComponentsDir(
735
+ typeof flags.dir === 'string' ? flags.dir : config.dir ?? DEFAULT_COMPONENTS_DIR,
736
+ )
737
+ if (prompter && typeof flags.dir !== 'string') {
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}).`)
749
+ }
441
750
 
442
- if (written.has(destination)) continue
751
+ // 2. Copie des fichiers + helpers.
752
+ const deps = new Set(LIB_DEPS)
753
+ const written = new Set()
754
+ const destinations = new Set()
755
+ const skipped = []
756
+ const components = []
757
+
758
+ for (const name of names) {
759
+ const entry = join(REGISTRY_UI, `${name}.tsx`)
760
+ const files = collectFiles(entry, REGISTRY_SRC)
761
+ components.push({ name, exportName: componentExportName(readFileSync(entry, 'utf8'), name) })
762
+
763
+ for (const [absolute, relativePath] of files) {
764
+ const isUi = relativePath.startsWith(join('components', 'ui'))
765
+ const destinationRoot = isUi ? resolve(cwd, componentsDir) : resolve(cwd, libDir)
766
+ const destination = join(destinationRoot, relativePath.replace(/^(components\/ui|lib)\//, ''))
767
+ if (destinations.has(destination)) continue
768
+ destinations.add(destination)
769
+
770
+ const source = readFileSync(absolute, 'utf8')
771
+ for (const dep of externalDeps(source)) deps.add(dep)
772
+
773
+ const label = relative(cwd, destination)
774
+ const shouldWrite = force || !existsSync(destination)
775
+ if (shouldWrite) {
776
+ if (!dryRun) {
777
+ try {
778
+ mkdirSync(dirname(destination), { recursive: true })
779
+ writeFileSync(destination, rewriteAlias(source, targetAlias), 'utf8')
780
+ } catch (error) {
781
+ writeFailure(error, label)
782
+ }
783
+ }
784
+ written.add(label)
785
+ } else {
786
+ skipped.push(label)
787
+ }
788
+ }
789
+ }
443
790
 
444
- const source = readFileSync(absolute, 'utf8')
445
- for (const dep of externalDeps(source)) deps.add(dep)
791
+ ok(`${written.size} file(s) ${dryRun ? 'would be copied [dry-run]' : 'copied'}:`)
792
+ for (const label of [...written].sort()) info(`· ${label}`)
793
+ if (skipped.length > 0) {
794
+ info(`${skipped.length} file(s) already present, skipped: ${skipped.join(', ')} (use --force to overwrite)`)
795
+ }
446
796
 
447
- const status = copyFile({
448
- to: destination,
449
- source,
450
- dryRun,
451
- force,
452
- targetAlias,
453
- accent,
797
+ // 3. Tokens Tailwind : les classes bg-primary & co fonctionnent partout.
798
+ writeTheme({ cwd, accent, radius, dryRun })
799
+
800
+ // 4. Component usage, after installation.
801
+ let pageTarget = typeof flags.page === 'string' ? resolve(cwd, flags.page) : null
802
+ if (!pageTarget && prompter) {
803
+ pageTarget = await chooseUsageTarget({
804
+ prompter,
805
+ cwd,
806
+ names,
807
+ exportNames: components.map((component) => component.exportName),
454
808
  })
455
-
456
- const label = relative(cwd, destination)
457
- if (status === 'written') written.add(label)
458
- else skipped.push(label)
459
809
  }
460
- }
461
-
462
- const action = dryRun ? '[dry-run] auraient été copiés' : 'copié(s)'
463
- ok(`${written.size} fichier(s) ${action} :`)
464
- for (const label of [...written].sort()) info(`· ${label}`)
465
- if (skipped.length > 0) {
466
- info(`${skipped.length} fichier(s) déjà présent(s), ignoré(s) : ${skipped.join(', ')} (–force pour écraser)`)
467
- }
468
- if (accent && written.size > 0) {
469
- info(`couleur ${accent} intégrée (bg-primary → bg-[${accent}]), aucun --primary requis`)
470
- }
810
+ if (pageTarget) writeUsage({ cwd, target: pageTarget, componentsDir, components, dryRun })
471
811
 
472
- writeTheme({
473
- cwd,
474
- accent,
475
- radius: typeof flags.radius === 'string' ? flags.radius : undefined,
476
- dryRun,
477
- })
478
-
479
- if (written.size > 0 || dryRun) {
480
- console.log('\n Dépendances npm détectées :')
481
- info(`npm install ${[...deps].sort().join(' ')}`)
482
- if (targetAlias !== '@') {
483
- info(`Pense à aligner l'alias ${targetAlias} dans ton tsconfig/vite (remplace de @/).`)
484
- }
485
- console.log('')
486
- } // Crée ou adapte la page cible si une page cible a été sélectionnée.
487
- if (pageTarget) {
488
- const dir = dirname(pageTarget)
489
- if (createPage) {
490
- if (!dryRun) {
491
- mkdirSync(dir, { recursive: true })
492
- writeFileSync(
493
- pageTarget,
494
- `import { ${pageImports.map((p) => p.name).join(', ')} } from '${relative(dir, join(cwd, componentsDir)).replace(/\\/g, '/')}'\n'\n` +
495
- `export default function Page() {\n` +
496
- ` return (<>\n` +
497
- pageImports.map((p) => ` <${p.name} />`).join('\n') +
498
- `\n </>)\n` +
499
- `}\n`,
500
- 'utf8',
501
- )
502
- }
503
- ok(`${dryRun ? '[dry-run] ' : ''}Page créée : ${relative(cwd, pageTarget)}`)
504
- for (const p of pageImports) {
505
- info(`· <${p.name} /> importé depuis ${relative(cwd, join(componentsDir, `${p.name}.tsx`))}`)
506
- }
507
- } else {
508
- if (dryRun) {
509
- info(`[dry-run] page existante détectée : ${relative(cwd, pageTarget)}`)
510
- } else {
511
- const source = readFileSync(pageTarget, 'utf8')
512
- const rendered =
513
- pageImports.map((p) => `import { ${p.name} } from '${relative(dirname(pageTarget), join(cwd, componentsDir)).replace(/\\/g, '/')}';`).join('\n')
514
- const functionPoint = source.indexOf('function ')
515
- const insertPoint =
516
- functionPoint !== -1 ? functionPoint : source.lastIndexOf('</')
517
- const updatedSource =
518
- insertPoint === -1
519
- ? source + '\n' + rendered + '\n' + pageImports.map((p) => `<${p.name} />`).join('\n')
520
- : source.slice(0, insertPoint) + '\n' + rendered + '\n' + source.slice(insertPoint)
521
- writeFileSync(pageTarget, updatedSource, 'utf8')
812
+ if (written.size > 0 || dryRun) {
813
+ console.log('\n Detected npm dependencies:')
814
+ info(`npm install ${[...deps].sort().join(' ')}`)
815
+ if (targetAlias !== '@') {
816
+ info(`Remember to align the ${targetAlias} alias in your tsconfig/vite (instead of @/).`)
522
817
  }
523
- ok(`Page existante mise à jour : ${relative(cwd, pageTarget)}`)
818
+ console.log('')
524
819
  }
820
+ } finally {
821
+ prompter?.close()
525
822
  }
526
823
  }
527
824
 
528
825
  /* -------------------------------------------------------------------------- */
529
- /* Entrée */
826
+ /* Prompting */
530
827
  /* -------------------------------------------------------------------------- */
531
828
 
532
829
  function usage() {
533
830
  console.log(`
534
- \x1b[1muifolio\x1b[0m — installe les composants UIfolio dans un projet
535
-
536
- \x1b[36muifolio list\x1b[0m composants disponibles
537
- \x1b[36muifolio init [options]\x1b[0m écrit uifolio.json dans le projet cible
538
- \x1b[36muifolio add <noms...> [options]\x1b[0m copie les composants + leurs helpers
539
-
540
- Options :
541
- --cwd <dir> projet cible (défaut : répertoire courant)
542
- --dir <chemin> dossier composants (défaut : src/components/ui)
543
- --lib <chemin> dossier helpers (défaut : src/lib)
544
- --alias <alias> alias d'import cible (défaut : @)
545
- --color <hex> intègre cette couleur aux fichiers copiés (sans --primary)
546
- --radius <px> arrondi global (0–24) dans uifolio-theme.css
547
- --force écrase les fichiers existants
548
- --dry-run simule sans écrire
549
- --page <chemin> page cible (ex : src/pages/calendar.tsx). Avec --page, la CLI crée la page
550
- si elle n'existe pas, ou y ajoute l'import + l'appel du/des composant(s).
551
- Sans --page et quand des pages existent déjà dans src/pages, la CLI demande
552
- interactivement laquelle utiliser avant d'importer.
553
-
554
- Exemples :
555
- 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
556
858
  node cli/uifolio.mjs add otp --color "#10B981" --radius 8
557
- node cli/uifolio.mjs add otp --alias "#lib" --dry-run
558
859
  node cli/uifolio.mjs add calendar --page src/pages/calendar.tsx
559
860
  `)
560
861
  }
561
862
 
562
- const { positional, flags } = parseArgs(process.argv.slice(2))
563
- const command = positional.shift()
564
-
565
- // Requête interactive de la page cible avant toute écriture, sauf si --page est déjà passé.
566
- if (command === 'add' && typeof flags.page !== 'string') {
567
- let cwd
568
- try {
569
- cwd = resolve(flags.cwd ?? process.cwd())
570
- } catch {
571
- cwd = process.cwd()
572
- }
573
- const pages = listPagesInCwd(cwd)
574
- if (pages.length > 0) {
575
- const target = choosePageInteraction(pages, cwd)
576
- if (target) flags.page = target
863
+ async function main() {
864
+ const { positional, flags } = parseArgs(process.argv.slice(2))
865
+ const command = positional.shift()
866
+
867
+ switch (command) {
868
+ case 'list':
869
+ commandList()
870
+ break
871
+ case 'init':
872
+ commandInit({ positional, flags })
873
+ break
874
+ case 'add':
875
+ await commandAdd({ positional, flags })
876
+ break
877
+ case undefined:
878
+ case 'help':
879
+ usage()
880
+ break
881
+ default:
882
+ fail(`Commande inconnue : ${command}. Lance « uifolio » sans argument.`)
577
883
  }
578
884
  }
579
885
 
580
- switch (command) {
581
- case 'list':
582
- commandList()
583
- break
584
- case 'init':
585
- commandInit({ positional, flags })
586
- break
587
- case 'add':
588
- commandAdd({ positional, flags })
589
- break
590
- case undefined:
591
- case 'help':
592
- usage()
593
- break
594
- default:
595
- fail(`Commande inconnue : ${command}. Lance « uifolio » sans argument.`)
596
- }
886
+ await main()