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