uifolio 0.4.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 +513 -306
- package/package.json +2 -1
- 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 +5 -5
- package/src/components/ui/motion.tsx +10 -8
- package/src/components/ui/otp-input.tsx +1 -1
- package/src/components/ui/resizable.tsx +5 -5
- package/src/components/ui/rich-text-editor.tsx +3 -3
- package/src/components/ui/stories.tsx +1 -1
- package/src/components/ui/time-picker.tsx +1 -1
- package/src/components/ui/video-player.tsx +1 -1
- package/src/components/ui/stories-tokens.ts +0 -18
package/README.md
CHANGED
|
@@ -72,13 +72,23 @@ node cli/uifolio.mjs add file-upload --alias "#lib" --dry-run # simulate
|
|
|
72
72
|
|
|
73
73
|
Options : `--cwd <dir>`, `--dir <chemin>` (défaut `src/components/ui`),
|
|
74
74
|
`--lib <chemin>` (défaut `src/lib`), `--alias <alias>` (défaut `@`),
|
|
75
|
+
`--page <chemin>` (créé s'il n'existe pas), `--interactive` (forcer les questions),
|
|
75
76
|
`--force` (écraser), `--dry-run` (simuler), `--color <hex>` et `--radius <px>`.
|
|
76
77
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
78
|
+
`add` ne s'arrête pas à la copie : sans `--dir` ni `--page`, la CLI demande
|
|
79
|
+
**dans quel dossier** copier les composants, puis **dans quel fichier** les
|
|
80
|
+
utiliser. La page est créée si elle manque ; si elle existe, l'import et le
|
|
81
|
+
`<Composant />` sont ajoutés dans son JSX (sans doublon). Réponds `-` à la
|
|
82
|
+
seconde question pour installer sans toucher à une page.
|
|
83
|
+
|
|
84
|
+
Les couleurs passent par des **tokens Tailwind CSS 4** : la CLI écrit
|
|
85
|
+
`uifolio-theme.css` (`:root`, `.dark`, puis `@theme inline` qui expose
|
|
86
|
+
`--color-primary`, `--color-muted-foreground`, …) et l'importe automatiquement
|
|
87
|
+
dans le CSS global du projet (celui qui fait `@import 'tailwindcss'`). Les
|
|
88
|
+
composants gardent donc `bg-primary`, `border-primary/50`, `ring-primary/40` :
|
|
89
|
+
aucune couleur en dur, `--color` change `--primary` dans le thème et `--radius`
|
|
90
|
+
y ajoute `--radius`. Sans CSS global Tailwind détecté, le fichier est écrit à la
|
|
91
|
+
racine et l'import à ajouter est affiché.
|
|
82
92
|
|
|
83
93
|
À la fin, la CLI affiche les dépendances npm détectées, par ex. :
|
|
84
94
|
|
|
@@ -86,9 +96,6 @@ la couleur choisie même si le projet cible ne définit pas `--primary`. `--radi
|
|
|
86
96
|
npm install clsx tailwind-merge lucide-react hls.js
|
|
87
97
|
```
|
|
88
98
|
|
|
89
|
-
Le projet cible doit fournir les tokens du thème (`bg-background`, `border`, `text-muted-foreground`…) :
|
|
90
|
-
copie les variables CSS de `src/index.css` (`:root`, `.dark`, `@theme`) si ton projet ne les a pas.
|
|
91
|
-
|
|
92
99
|
## Thèmes clair/sombre
|
|
93
100
|
|
|
94
101
|
- Tokens CSS : `:root` (clair) et `.dark` (sombre) dans `src/index.css`,
|
package/cli/uifolio.mjs
CHANGED
|
@@ -6,22 +6,25 @@
|
|
|
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
|
|
10
|
-
*
|
|
9
|
+
* uifolio add <noms...> [options] copie les composants + helpers,
|
|
10
|
+
* écrit les tokens Tailwind et utilise
|
|
11
|
+
* le composant dans une page
|
|
11
12
|
*
|
|
12
13
|
* Options :
|
|
13
14
|
* --cwd <dir> projet cible (défaut : répertoire courant)
|
|
14
15
|
* --dir <chemin> dossier des composants (défaut : src/components/ui)
|
|
15
16
|
* --lib <chemin> dossier des helpers (défaut : src/lib)
|
|
16
17
|
* --alias <alias> alias d'import cible (défaut : @)
|
|
17
|
-
* --color <hex>
|
|
18
|
+
* --color <hex> couleur primaire, écrite dans les tokens Tailwind (@theme)
|
|
18
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
|
|
19
22
|
* --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
23
|
* --dry-run affiche ce qui serait copié sans rien écrire
|
|
24
24
|
*
|
|
25
|
+
* Sans --dir / --page, la CLI demande interactivement (terminal) dans quel
|
|
26
|
+
* dossier copier les composants et dans quel fichier les utiliser.
|
|
27
|
+
*
|
|
25
28
|
* Exemples :
|
|
26
29
|
* node cli/uifolio.mjs add calendar otp --cwd ../mon-projet
|
|
27
30
|
* node cli/uifolio.mjs add calendar --color "#10B981" --page src/pages/calendar.tsx
|
|
@@ -29,7 +32,8 @@
|
|
|
29
32
|
*/
|
|
30
33
|
|
|
31
34
|
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
32
|
-
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'
|
|
33
37
|
import { fileURLToPath } from 'node:url'
|
|
34
38
|
|
|
35
39
|
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
@@ -39,6 +43,21 @@ const CONFIG_NAME = 'uifolio.json'
|
|
|
39
43
|
|
|
40
44
|
const LIB_DEPS = ['clsx', 'tailwind-merge'] // cn() — toujours copié avec les helpers
|
|
41
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
|
+
|
|
42
61
|
/* -------------------------------------------------------------------------- */
|
|
43
62
|
/* Utilitaires */
|
|
44
63
|
/* -------------------------------------------------------------------------- */
|
|
@@ -124,21 +143,19 @@ function externalDeps(source) {
|
|
|
124
143
|
return [...deps]
|
|
125
144
|
}
|
|
126
145
|
|
|
127
|
-
/**
|
|
128
|
-
|
|
129
|
-
|
|
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. */
|
|
130
153
|
function listPagesInCwd(cwd) {
|
|
131
|
-
if (!cwd || typeof cwd !== 'string') return []
|
|
132
154
|
try {
|
|
133
|
-
const
|
|
134
|
-
const srcRoot = join(cwdPath, 'src')
|
|
135
|
-
if (!existsSync(srcRoot)) return []
|
|
136
|
-
const pagesDir = join(srcRoot, 'pages')
|
|
155
|
+
const pagesDir = join(resolve(cwd), 'src', 'pages')
|
|
137
156
|
if (!existsSync(pagesDir)) return []
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
return entries
|
|
141
|
-
.filter((name) => typeof name === 'string' && name.endsWith('.tsx'))
|
|
157
|
+
return readdirSync(pagesDir)
|
|
158
|
+
.filter((name) => name.endsWith('.tsx'))
|
|
142
159
|
.map((name) => join('src', 'pages', name))
|
|
143
160
|
.sort()
|
|
144
161
|
} catch {
|
|
@@ -146,71 +163,65 @@ function listPagesInCwd(cwd) {
|
|
|
146
163
|
}
|
|
147
164
|
}
|
|
148
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
|
+
|
|
149
173
|
/**
|
|
150
|
-
*
|
|
151
|
-
*
|
|
174
|
+
* Saisie interactive : uniquement sur un terminal (ou avec --interactive),
|
|
175
|
+
* jamais en CI / pipe sans données, pour ne pas bloquer l'installation.
|
|
152
176
|
*/
|
|
153
|
-
function
|
|
154
|
-
|
|
155
|
-
if (
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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)
|
|
161
200
|
}
|
|
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
201
|
})
|
|
174
|
-
console.log(' 0. Ne pas importer dans une page')
|
|
175
202
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|
195
|
-
}
|
|
196
|
-
}
|
|
203
|
+
return {
|
|
204
|
+
async ask(question, fallback) {
|
|
205
|
+
const suffix = fallback ? ` (${fallback})` : ''
|
|
206
|
+
process.stdout.write(`\n \x1b[36m?\x1b[0m ${question}${suffix} : `)
|
|
197
207
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
+
},
|
|
214
225
|
}
|
|
215
226
|
}
|
|
216
227
|
|
|
@@ -250,47 +261,6 @@ function rewriteAlias(source, targetAlias) {
|
|
|
250
261
|
return source.replace(/(['"])@\//g, `$1${targetAlias}/`)
|
|
251
262
|
}
|
|
252
263
|
|
|
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
264
|
function normalizeHex(value) {
|
|
295
265
|
let next = String(value).trim()
|
|
296
266
|
if (!next.startsWith('#')) next = `#${next}`
|
|
@@ -311,35 +281,309 @@ function isLightHex(hex) {
|
|
|
311
281
|
return 0.299 * r + 0.587 * g + 0.114 * b > 160
|
|
312
282
|
}
|
|
313
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
|
+
|
|
314
396
|
function writeTheme({ cwd, accent, radius, dryRun }) {
|
|
315
|
-
|
|
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
|
+
}
|
|
316
412
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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).`)
|
|
320
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
|
+
}
|
|
321
437
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
|
333
459
|
}
|
|
334
|
-
if (rounded !== null) tokens.push(` --radius: ${rounded}px;`)
|
|
335
|
-
if (tokens.length === 0) return
|
|
336
460
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
+
}
|
|
484
|
+
}
|
|
485
|
+
return null
|
|
486
|
+
}
|
|
487
|
+
|
|
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',
|
|
343
587
|
}
|
|
344
588
|
|
|
345
589
|
function commandList() {
|
|
@@ -359,7 +603,7 @@ function commandInit({ flags }) {
|
|
|
359
603
|
|
|
360
604
|
const config = {
|
|
361
605
|
$schema: 'https://uifolio.dev/schema.json',
|
|
362
|
-
dir: typeof flags.dir === 'string' ? flags.dir :
|
|
606
|
+
dir: typeof flags.dir === 'string' ? flags.dir : DEFAULT_COMPONENTS_DIR,
|
|
363
607
|
lib: typeof flags.lib === 'string' ? flags.lib : 'src/lib',
|
|
364
608
|
alias: typeof flags.alias === 'string' ? flags.alias : '@',
|
|
365
609
|
}
|
|
@@ -369,159 +613,130 @@ function commandInit({ flags }) {
|
|
|
369
613
|
info('Puis : uifolio add video-player calendar …')
|
|
370
614
|
}
|
|
371
615
|
|
|
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
|
+
}
|
|
372
635
|
|
|
373
|
-
function commandAdd({ positional, flags }) {
|
|
636
|
+
async function commandAdd({ positional, flags }) {
|
|
374
637
|
const names = positional.map((name) => ALIASES[name] ?? name)
|
|
375
|
-
|
|
376
638
|
if (names.length === 0) fail('Aucun composant demandé. Ex. : uifolio add calendar otp')
|
|
377
639
|
|
|
378
640
|
const cwd = resolve(flags.cwd ?? process.cwd())
|
|
379
641
|
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
642
|
const dryRun = Boolean(flags['dry-run'])
|
|
399
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
|
+
|
|
400
647
|
const colorFlag = typeof flags.color === 'string' ? flags.color : undefined
|
|
401
648
|
const accent = colorFlag === undefined ? null : normalizeHex(colorFlag)
|
|
402
649
|
if (colorFlag !== undefined && !accent) {
|
|
403
650
|
fail(`Couleur invalide : ${colorFlag}. Utilise un hex comme #10B981.`)
|
|
404
651
|
}
|
|
405
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
|
+
|
|
406
661
|
const known = listComponents()
|
|
407
662
|
const unknown = names.filter((name) => !known.includes(name))
|
|
408
663
|
if (unknown.length > 0) {
|
|
409
664
|
fail(`Composant(s) inconnu(s) : ${unknown.join(', ')}. Lance « uifolio list ».`)
|
|
410
665
|
}
|
|
411
|
-
|
|
412
666
|
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}.`)
|
|
416
|
-
}
|
|
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)
|
|
436
|
-
|
|
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)\//, ''))
|
|
441
|
-
|
|
442
|
-
if (written.has(destination)) continue
|
|
443
|
-
|
|
444
|
-
const source = readFileSync(absolute, 'utf8')
|
|
445
|
-
for (const dep of externalDeps(source)) deps.add(dep)
|
|
446
667
|
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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
|
+
}
|
|
459
712
|
}
|
|
460
|
-
}
|
|
461
713
|
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
}
|
|
468
|
-
if (accent && written.size > 0) {
|
|
469
|
-
info(`couleur ${accent} intégrée (bg-primary → bg-[${accent}]), aucun --primary requis`)
|
|
470
|
-
}
|
|
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)`)
|
|
718
|
+
}
|
|
471
719
|
|
|
472
|
-
|
|
473
|
-
cwd,
|
|
474
|
-
accent,
|
|
475
|
-
radius: typeof flags.radius === 'string' ? flags.radius : undefined,
|
|
476
|
-
dryRun,
|
|
477
|
-
})
|
|
720
|
+
// 3. Tokens Tailwind : les classes bg-primary & co fonctionnent partout.
|
|
721
|
+
writeTheme({ cwd, accent, radius, dryRun })
|
|
478
722
|
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
info(`Pense à aligner l'alias ${targetAlias} dans ton tsconfig/vite (remplace de @/).`)
|
|
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 })
|
|
484
727
|
}
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
if (
|
|
491
|
-
|
|
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')
|
|
728
|
+
if (pageTarget) writeUsage({ cwd, target: pageTarget, componentsDir, components, dryRun })
|
|
729
|
+
|
|
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 @/).`)
|
|
522
735
|
}
|
|
523
|
-
|
|
736
|
+
console.log('')
|
|
524
737
|
}
|
|
738
|
+
} finally {
|
|
739
|
+
prompter?.close()
|
|
525
740
|
}
|
|
526
741
|
}
|
|
527
742
|
|
|
@@ -542,55 +757,47 @@ Options :
|
|
|
542
757
|
--dir <chemin> dossier composants (défaut : src/components/ui)
|
|
543
758
|
--lib <chemin> dossier helpers (défaut : src/lib)
|
|
544
759
|
--alias <alias> alias d'import cible (défaut : @)
|
|
545
|
-
--color <hex>
|
|
760
|
+
--color <hex> couleur primaire, écrite dans les tokens Tailwind (@theme)
|
|
546
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
|
|
547
765
|
--force écrase les fichiers existants
|
|
548
766
|
--dry-run simule sans écrire
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
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.
|
|
553
772
|
|
|
554
773
|
Exemples :
|
|
555
774
|
node cli/uifolio.mjs add video-player calendar otp --cwd ../mon-projet
|
|
556
775
|
node cli/uifolio.mjs add otp --color "#10B981" --radius 8
|
|
557
|
-
node cli/uifolio.mjs add otp --alias "#lib" --dry-run
|
|
558
776
|
node cli/uifolio.mjs add calendar --page src/pages/calendar.tsx
|
|
559
777
|
`)
|
|
560
778
|
}
|
|
561
779
|
|
|
562
|
-
|
|
563
|
-
const
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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.`)
|
|
577
800
|
}
|
|
578
801
|
}
|
|
579
802
|
|
|
580
|
-
|
|
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
|
-
}
|
|
803
|
+
await main()
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uifolio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "CLI qui copie des composants React (calendrier, lecteurs vidéo/audio, OTP, éditeur, upload) et leurs helpers dans ton projet, avec des tokens Tailwind CSS 4.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -53,6 +53,7 @@
|
|
|
53
53
|
"files": [
|
|
54
54
|
"cli",
|
|
55
55
|
"src/components/ui",
|
|
56
|
+
"!src/components/ui/stories-tokens.ts",
|
|
56
57
|
"src/lib/color.ts",
|
|
57
58
|
"src/lib/date.ts",
|
|
58
59
|
"src/lib/uploader.ts",
|
|
@@ -30,7 +30,7 @@ export function AudioPlayer({
|
|
|
30
30
|
variant = 'card',
|
|
31
31
|
loop = false,
|
|
32
32
|
className,
|
|
33
|
-
}: AudioPlayerProps) {
|
|
33
|
+
}: Readonly<AudioPlayerProps>) {
|
|
34
34
|
const audioRef = useRef<HTMLAudioElement>(null)
|
|
35
35
|
const [playing, setPlaying] = useState(false)
|
|
36
36
|
const [currentTime, setCurrentTime] = useState(0)
|
|
@@ -54,7 +54,7 @@ export function Calendar({
|
|
|
54
54
|
disabled,
|
|
55
55
|
manualInput = true,
|
|
56
56
|
className,
|
|
57
|
-
}: CalendarProps) {
|
|
57
|
+
}: Readonly<CalendarProps>) {
|
|
58
58
|
const [internalValue, setInternalValue] = useState<CalendarValue>(defaultValue ?? null)
|
|
59
59
|
const currentValue = value !== undefined ? value : internalValue
|
|
60
60
|
|
|
@@ -43,12 +43,12 @@ function ChannelField({
|
|
|
43
43
|
value,
|
|
44
44
|
disabled,
|
|
45
45
|
onChange,
|
|
46
|
-
}: {
|
|
46
|
+
}: Readonly<{
|
|
47
47
|
label: string
|
|
48
48
|
value: number
|
|
49
49
|
disabled: boolean
|
|
50
50
|
onChange: (value: number) => void
|
|
51
|
-
}) {
|
|
51
|
+
}>) {
|
|
52
52
|
const [draft, setDraft] = useState<string | null>(null)
|
|
53
53
|
|
|
54
54
|
return (
|
|
@@ -123,7 +123,7 @@ export function ColorPicker({
|
|
|
123
123
|
open,
|
|
124
124
|
defaultOpen = false,
|
|
125
125
|
onOpenChange,
|
|
126
|
-
}: ColorPickerProps) {
|
|
126
|
+
}: Readonly<ColorPickerProps>) {
|
|
127
127
|
const generatedId = useId()
|
|
128
128
|
const inputId = id ?? generatedId
|
|
129
129
|
const panelId = `${inputId}-panel`
|
|
@@ -37,7 +37,7 @@ export function CommandMenu({
|
|
|
37
37
|
emptyText = 'Aucun résultat.',
|
|
38
38
|
emptyState,
|
|
39
39
|
className,
|
|
40
|
-
}: CommandMenuProps) {
|
|
40
|
+
}: Readonly<CommandMenuProps>) {
|
|
41
41
|
const [internalOpen, setInternalOpen] = useState(defaultOpen)
|
|
42
42
|
const isOpen = open ?? internalOpen
|
|
43
43
|
const [query, setQuery] = useState('')
|
|
@@ -43,7 +43,7 @@ export function FileUpload({
|
|
|
43
43
|
uploader,
|
|
44
44
|
onChange,
|
|
45
45
|
className,
|
|
46
|
-
}: FileUploadProps) {
|
|
46
|
+
}: Readonly<FileUploadProps>) {
|
|
47
47
|
const inputId = useId()
|
|
48
48
|
const [items, setItems] = useState<UploadItem[]>([])
|
|
49
49
|
const [dragging, setDragging] = useState(false)
|
|
@@ -126,11 +126,11 @@ function IconSvg({
|
|
|
126
126
|
data,
|
|
127
127
|
color,
|
|
128
128
|
className,
|
|
129
|
-
}: {
|
|
129
|
+
}: Readonly<{
|
|
130
130
|
data: IconData | null
|
|
131
131
|
color: string
|
|
132
132
|
className?: string
|
|
133
|
-
}) {
|
|
133
|
+
}>) {
|
|
134
134
|
if (!data) {
|
|
135
135
|
return <span className={cn('inline-block rounded bg-muted', className)} />
|
|
136
136
|
}
|
|
@@ -149,11 +149,11 @@ export function IconGlyph({
|
|
|
149
149
|
icon,
|
|
150
150
|
color = 'currentColor',
|
|
151
151
|
className,
|
|
152
|
-
}: {
|
|
152
|
+
}: Readonly<{
|
|
153
153
|
icon: string
|
|
154
154
|
color?: string
|
|
155
155
|
className?: string
|
|
156
|
-
}) {
|
|
156
|
+
}>) {
|
|
157
157
|
const [data, setData] = useState<IconData | null>(() => iconCache.get(icon) ?? null)
|
|
158
158
|
const [prevIcon, setPrevIcon] = useState(icon)
|
|
159
159
|
const loadRef = useRef<Promise<void> | null>(null)
|
|
@@ -186,7 +186,7 @@ export function IconPicker({
|
|
|
186
186
|
onChange,
|
|
187
187
|
placeholder = 'Choisir une icône',
|
|
188
188
|
className,
|
|
189
|
-
}: IconPickerProps) {
|
|
189
|
+
}: Readonly<IconPickerProps>) {
|
|
190
190
|
const [open, setOpen] = useState(false)
|
|
191
191
|
const [search, setSearch] = useState('')
|
|
192
192
|
const [icons, setIcons] = useState<string[]>([])
|
|
@@ -21,7 +21,7 @@ export function FadeIn({
|
|
|
21
21
|
duration = 520,
|
|
22
22
|
className,
|
|
23
23
|
style,
|
|
24
|
-
}: MotionProps) {
|
|
24
|
+
}: Readonly<MotionProps>) {
|
|
25
25
|
const reduced = useReducedMotion()
|
|
26
26
|
return (
|
|
27
27
|
<div
|
|
@@ -45,10 +45,12 @@ export function SlideIn({
|
|
|
45
45
|
style,
|
|
46
46
|
direction = 'up',
|
|
47
47
|
distance = 20,
|
|
48
|
-
}:
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
48
|
+
}: Readonly<
|
|
49
|
+
MotionProps & {
|
|
50
|
+
direction?: 'up' | 'down' | 'left' | 'right'
|
|
51
|
+
distance?: number
|
|
52
|
+
}
|
|
53
|
+
>) {
|
|
52
54
|
const reduced = useReducedMotion()
|
|
53
55
|
const offset = {
|
|
54
56
|
up: `translate3d(0, ${distance}px, 0)`,
|
|
@@ -82,7 +84,7 @@ export function ScaleIn({
|
|
|
82
84
|
className,
|
|
83
85
|
style,
|
|
84
86
|
from = 0.92,
|
|
85
|
-
}: MotionProps & { from?: number }) {
|
|
87
|
+
}: Readonly<MotionProps & { from?: number }>) {
|
|
86
88
|
const reduced = useReducedMotion()
|
|
87
89
|
return (
|
|
88
90
|
<div
|
|
@@ -108,13 +110,13 @@ export function Stagger({
|
|
|
108
110
|
duration = 460,
|
|
109
111
|
className,
|
|
110
112
|
itemClassName,
|
|
111
|
-
}: {
|
|
113
|
+
}: Readonly<{
|
|
112
114
|
children: ReactNode
|
|
113
115
|
delay?: number
|
|
114
116
|
duration?: number
|
|
115
117
|
className?: string
|
|
116
118
|
itemClassName?: string
|
|
117
|
-
}) {
|
|
119
|
+
}>) {
|
|
118
120
|
return (
|
|
119
121
|
<div className={className}>
|
|
120
122
|
{Children.map(children, (child, index) => (
|
|
@@ -39,7 +39,7 @@ export function OtpInput({
|
|
|
39
39
|
error = false,
|
|
40
40
|
className,
|
|
41
41
|
inputClassName,
|
|
42
|
-
}: OtpInputProps) {
|
|
42
|
+
}: Readonly<OtpInputProps>) {
|
|
43
43
|
const [internalValue, setInternalValue] = useState(defaultValue)
|
|
44
44
|
const current = value ?? internalValue
|
|
45
45
|
const complete = current.length === length && /^\d+$/.test(current)
|
|
@@ -10,11 +10,11 @@ export function ResizablePanelGroup({
|
|
|
10
10
|
direction = 'horizontal',
|
|
11
11
|
className,
|
|
12
12
|
children,
|
|
13
|
-
}: {
|
|
13
|
+
}: Readonly<{
|
|
14
14
|
direction?: ResizableDirection
|
|
15
15
|
className?: string
|
|
16
16
|
children: ReactNode
|
|
17
|
-
}) {
|
|
17
|
+
}>) {
|
|
18
18
|
return (
|
|
19
19
|
<div
|
|
20
20
|
data-resizable-group
|
|
@@ -35,12 +35,12 @@ export function ResizablePanel({
|
|
|
35
35
|
minSize = 10,
|
|
36
36
|
className,
|
|
37
37
|
children,
|
|
38
|
-
}: {
|
|
38
|
+
}: Readonly<{
|
|
39
39
|
defaultSize?: number
|
|
40
40
|
minSize?: number
|
|
41
41
|
className?: string
|
|
42
42
|
children: ReactNode
|
|
43
|
-
}) {
|
|
43
|
+
}>) {
|
|
44
44
|
return (
|
|
45
45
|
<div
|
|
46
46
|
data-resizable-panel
|
|
@@ -54,7 +54,7 @@ export function ResizablePanel({
|
|
|
54
54
|
)
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
export function ResizableHandle({ className }: { className?: string }) {
|
|
57
|
+
export function ResizableHandle({ className }: Readonly<{ className?: string }>) {
|
|
58
58
|
const ref = useRef<HTMLDivElement>(null)
|
|
59
59
|
const [orientation, setOrientation] = useState<'horizontal' | 'vertical'>('vertical')
|
|
60
60
|
|
|
@@ -43,7 +43,7 @@ export function RichTextEditor({
|
|
|
43
43
|
toolbar = true,
|
|
44
44
|
minHeight = 220,
|
|
45
45
|
className,
|
|
46
|
-
}: RichTextEditorProps) {
|
|
46
|
+
}: Readonly<RichTextEditorProps>) {
|
|
47
47
|
const editorRef = useRef<HTMLDivElement>(null)
|
|
48
48
|
const [internalValue, setInternalValue] = useState(defaultValue)
|
|
49
49
|
const html = value ?? internalValue
|
|
@@ -254,12 +254,12 @@ function ToolButton({
|
|
|
254
254
|
active,
|
|
255
255
|
onClick,
|
|
256
256
|
children,
|
|
257
|
-
}: {
|
|
257
|
+
}: Readonly<{
|
|
258
258
|
label: string
|
|
259
259
|
active?: boolean
|
|
260
260
|
onClick: () => void
|
|
261
261
|
children: ReactNode
|
|
262
|
-
}) {
|
|
262
|
+
}>) {
|
|
263
263
|
return (
|
|
264
264
|
<button
|
|
265
265
|
type="button"
|
|
@@ -37,7 +37,7 @@ export function Stories({
|
|
|
37
37
|
duration = 5000,
|
|
38
38
|
className,
|
|
39
39
|
textBackground = 'linear-gradient(160deg, color-mix(in oklab, var(--primary) 35%, #000) 0%, var(--primary) 55%, color-mix(in oklab, var(--primary) 65%, #fff) 100%)',
|
|
40
|
-
}: StoriesProps) {
|
|
40
|
+
}: Readonly<StoriesProps>) {
|
|
41
41
|
const [internalOpen, setInternalOpen] = useState(defaultOpen)
|
|
42
42
|
const isOpen = open ?? internalOpen
|
|
43
43
|
const [index, setIndex] = useState(initialIndex)
|
|
@@ -45,7 +45,7 @@ export function TimePicker({
|
|
|
45
45
|
disabled = false,
|
|
46
46
|
label,
|
|
47
47
|
className,
|
|
48
|
-
}: TimePickerProps) {
|
|
48
|
+
}: Readonly<TimePickerProps>) {
|
|
49
49
|
const [internalValue, setInternalValue] = useState(defaultValue)
|
|
50
50
|
const current = value ?? internalValue
|
|
51
51
|
const inputValue = toInputValue(current, withSeconds)
|
|
@@ -284,7 +284,7 @@ export function VideoPlayer({
|
|
|
284
284
|
onTimeUpdate,
|
|
285
285
|
onError,
|
|
286
286
|
ref,
|
|
287
|
-
}: VideoPlayerProps) {
|
|
287
|
+
}: Readonly<VideoPlayerProps>) {
|
|
288
288
|
const { sources, signature } = useResolvedSources(sourceInput)
|
|
289
289
|
|
|
290
290
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
const accentToScheme: Record<string, { dark: string; light: string; foreground: string; background: string }> = {
|
|
2
|
-
'#10b981': {
|
|
3
|
-
dark: '#0f172a',
|
|
4
|
-
light: '#0f172a',
|
|
5
|
-
foreground: '#f1f5f9',
|
|
6
|
-
background: '#ecfdf5',
|
|
7
|
-
},
|
|
8
|
-
default: {
|
|
9
|
-
dark: '#f8fafc',
|
|
10
|
-
light: '#0f172a',
|
|
11
|
-
foreground: '#0f172a',
|
|
12
|
-
background: '#ffffff',
|
|
13
|
-
},
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export function accentTokens(accent: string) {
|
|
17
|
-
return accentToScheme[accent] ?? accentToScheme.default
|
|
18
|
-
}
|