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/README.md +171 -157
- package/cli/uifolio.mjs +644 -354
- package/package.json +3 -2
- package/src/components/ui/audio-player.tsx +1 -1
- package/src/components/ui/calendar.tsx +6 -6
- package/src/components/ui/choice.tsx +1 -1
- package/src/components/ui/color-picker.tsx +14 -14
- package/src/components/ui/command-menu.tsx +2 -2
- package/src/components/ui/file-upload.tsx +10 -10
- package/src/components/ui/icon-picker.tsx +13 -13
- package/src/components/ui/motion.tsx +10 -8
- package/src/components/ui/otp-input.tsx +1 -1
- package/src/components/ui/resizable.tsx +6 -6
- package/src/components/ui/rich-text-editor.tsx +8 -8
- package/src/components/ui/stories.tsx +3 -3
- package/src/components/ui/time-picker.tsx +1 -1
- package/src/components/ui/video-player.tsx +50 -50
- package/src/lib/date.ts +30 -30
- package/src/lib/uploader.ts +13 -13
- package/src/lib/use-controllable.ts +9 -9
- package/src/lib/use-hotkeys.ts +1 -1
- package/src/lib/use-theme.ts +2 -2
- package/src/lib/video.ts +12 -12
- package/src/lib/vtt.ts +1 -1
- package/src/components/ui/stories-tokens.ts +0 -18
package/cli/uifolio.mjs
CHANGED
|
@@ -1,35 +1,39 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* uifolio —
|
|
4
|
-
*
|
|
3
|
+
* uifolio — shadcn-like CLI: copies the library components into another
|
|
4
|
+
* project, along with the helpers they depend on.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
* uifolio list
|
|
8
|
-
* uifolio init [--cwd dir]
|
|
9
|
-
* uifolio add <
|
|
10
|
-
*
|
|
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>
|
|
14
|
-
* --dir <
|
|
15
|
-
* --lib <
|
|
16
|
-
* --alias <alias>
|
|
17
|
-
* --color <hex>
|
|
18
|
-
* --radius <px>
|
|
19
|
-
* --
|
|
20
|
-
* --
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
-
*
|
|
26
|
-
*
|
|
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() —
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
139
|
-
|
|
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
|
-
*
|
|
151
|
-
*
|
|
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
|
|
154
|
-
|
|
155
|
-
if (
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
*
|
|
219
|
-
*
|
|
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() //
|
|
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(`
|
|
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
|
-
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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('
|
|
348
|
-
console.log('\
|
|
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
|
|
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}
|
|
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 :
|
|
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}
|
|
658
|
+
ok(`${CONFIG_NAME} written to ${cwd}`)
|
|
368
659
|
info(`dir: ${config.dir} · lib: ${config.lib} · alias: ${config.alias}`)
|
|
369
|
-
info('
|
|
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
|
-
|
|
374
|
-
|
|
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
|
-
|
|
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(`
|
|
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(`
|
|
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 (
|
|
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
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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
|
-
|
|
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
|
-
|
|
445
|
-
|
|
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
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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
|
-
|
|
818
|
+
console.log('')
|
|
524
819
|
}
|
|
820
|
+
} finally {
|
|
821
|
+
prompter?.close()
|
|
525
822
|
}
|
|
526
823
|
}
|
|
527
824
|
|
|
528
825
|
/* -------------------------------------------------------------------------- */
|
|
529
|
-
/*
|
|
826
|
+
/* Prompting */
|
|
530
827
|
/* -------------------------------------------------------------------------- */
|
|
531
828
|
|
|
532
829
|
function usage() {
|
|
533
830
|
console.log(`
|
|
534
|
-
\x1b[1muifolio\x1b[0m —
|
|
535
|
-
|
|
536
|
-
\x1b[36muifolio list\x1b[0m
|
|
537
|
-
\x1b[36muifolio init [options]\x1b[0m
|
|
538
|
-
\x1b[36muifolio add <
|
|
539
|
-
|
|
540
|
-
Options
|
|
541
|
-
--cwd <dir>
|
|
542
|
-
--dir <
|
|
543
|
-
--lib <
|
|
544
|
-
--alias <alias>
|
|
545
|
-
--color <hex>
|
|
546
|
-
--radius <px>
|
|
547
|
-
--
|
|
548
|
-
|
|
549
|
-
--
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
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
|
-
|
|
563
|
-
const
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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
|
-
|
|
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()
|