uifolio 0.5.1 → 0.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +171 -164
- package/cli/uifolio.mjs +219 -136
- package/package.json +2 -2
- package/src/components/ui/calendar.tsx +5 -5
- package/src/components/ui/color-picker.tsx +11 -11
- package/src/components/ui/command-menu.tsx +1 -1
- package/src/components/ui/file-upload.tsx +9 -9
- package/src/components/ui/icon-picker.tsx +8 -8
- package/src/components/ui/resizable.tsx +1 -1
- package/src/components/ui/rich-text-editor.tsx +5 -5
- package/src/components/ui/stories.tsx +2 -2
- package/src/components/ui/video-player.tsx +49 -49
- 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/cli/uifolio.mjs
CHANGED
|
@@ -1,37 +1,37 @@
|
|
|
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
|
-
*
|
|
11
|
-
*
|
|
6
|
+
* Commands:
|
|
7
|
+
* uifolio list list available components
|
|
8
|
+
* uifolio init [--cwd dir] write uifolio.json (target config)
|
|
9
|
+
* uifolio add <names...> [options] copy components + helpers, write the
|
|
10
|
+
* Tailwind tokens and use the
|
|
11
|
+
* component in a page
|
|
12
12
|
*
|
|
13
|
-
* Options
|
|
14
|
-
* --cwd <dir>
|
|
15
|
-
* --dir <
|
|
16
|
-
* --lib <
|
|
17
|
-
* --alias <alias>
|
|
18
|
-
* --color <hex>
|
|
19
|
-
* --radius <px>
|
|
20
|
-
* --page <
|
|
21
|
-
* --interactive
|
|
22
|
-
* --force
|
|
23
|
-
* --dry-run
|
|
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
27
|
*
|
|
28
|
-
*
|
|
29
|
-
* node cli/uifolio.mjs add calendar otp --cwd ../
|
|
28
|
+
* Examples:
|
|
29
|
+
* node cli/uifolio.mjs add calendar otp --cwd ../my-project
|
|
30
30
|
* node cli/uifolio.mjs add calendar --color "#10B981" --page src/pages/calendar.tsx
|
|
31
31
|
* node cli/uifolio.mjs add video-player --alias "#lib" --dry-run
|
|
32
32
|
*/
|
|
33
33
|
|
|
34
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
34
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
|
|
35
35
|
import { basename, dirname, join, relative, resolve } from 'node:path'
|
|
36
36
|
import { createInterface } from 'node:readline/promises'
|
|
37
37
|
import { fileURLToPath } from 'node:url'
|
|
@@ -41,21 +41,17 @@ const REGISTRY_SRC = resolve(HERE, '..', 'src')
|
|
|
41
41
|
const REGISTRY_UI = join(REGISTRY_SRC, 'components', 'ui')
|
|
42
42
|
const CONFIG_NAME = 'uifolio.json'
|
|
43
43
|
|
|
44
|
-
const LIB_DEPS = ['clsx', 'tailwind-merge'] // cn() —
|
|
44
|
+
const LIB_DEPS = ['clsx', 'tailwind-merge'] // cn() — always copied with the helpers
|
|
45
45
|
|
|
46
46
|
const DEFAULT_ACCENT = '#10b981'
|
|
47
47
|
const DEFAULT_COMPONENTS_DIR = 'src/components/ui'
|
|
48
48
|
const THEME_FILE = 'uifolio-theme.css'
|
|
49
49
|
|
|
50
|
-
/**
|
|
50
|
+
/** Global CSS candidates for wiring the Tailwind theme. */
|
|
51
51
|
const GLOBAL_CSS_CANDIDATES = [
|
|
52
52
|
'src/index.css',
|
|
53
53
|
'src/app.css',
|
|
54
54
|
'src/globals.css',
|
|
55
|
-
'src/styles.css',
|
|
56
|
-
'src/main.css',
|
|
57
|
-
'app/globals.css',
|
|
58
|
-
'styles/globals.css',
|
|
59
55
|
]
|
|
60
56
|
|
|
61
57
|
/* -------------------------------------------------------------------------- */
|
|
@@ -96,6 +92,31 @@ function parseArgs(argv) {
|
|
|
96
92
|
return { positional, flags }
|
|
97
93
|
}
|
|
98
94
|
|
|
95
|
+
/** A “folder” answer must not be a file path (page.tsx, index.css…). */
|
|
96
|
+
const FILE_LIKE = /\.(?:[cm]?[jt]sx?|css|scss|sass|less|html|json|md|vue|svelte)$/i
|
|
97
|
+
|
|
98
|
+
function looksLikeFile(value) {
|
|
99
|
+
return FILE_LIKE.test(value.trim())
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** « ./src/ui/ » → « src/ui » */
|
|
103
|
+
function normalizeComponentsDir(value) {
|
|
104
|
+
return value.trim().replace(/^\.\//, '').replace(/\/+$/, '')
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Readable write error instead of a stack trace (EACCES, ENOTDIR…). */
|
|
108
|
+
function writeFailure(error, target) {
|
|
109
|
+
const reasons = {
|
|
110
|
+
EACCES: 'permission denied',
|
|
111
|
+
EPERM: 'permission denied',
|
|
112
|
+
EROFS: 'read-only filesystem',
|
|
113
|
+
ENOSPC: 'no space left on device',
|
|
114
|
+
ENOTDIR: 'a file is in the way',
|
|
115
|
+
ENOENT: 'path not found',
|
|
116
|
+
}
|
|
117
|
+
fail(`Cannot write ${target}: ${reasons[error?.code] ?? error?.message ?? error}.`)
|
|
118
|
+
}
|
|
119
|
+
|
|
99
120
|
function listComponents() {
|
|
100
121
|
if (!existsSync(REGISTRY_UI)) return []
|
|
101
122
|
return readdirSync(REGISTRY_UI)
|
|
@@ -128,7 +149,7 @@ function localImports(source) {
|
|
|
128
149
|
return [...specifiers]
|
|
129
150
|
}
|
|
130
151
|
|
|
131
|
-
/**
|
|
152
|
+
/** External npm dependencies (excluding React) of a source file. */
|
|
132
153
|
function externalDeps(source) {
|
|
133
154
|
const deps = new Set()
|
|
134
155
|
const importRe = /from\s+['"]([^'"]+)['"]/g
|
|
@@ -143,13 +164,13 @@ function externalDeps(source) {
|
|
|
143
164
|
return [...deps]
|
|
144
165
|
}
|
|
145
166
|
|
|
146
|
-
/**
|
|
167
|
+
/** Real exported name of the file (otp-input → OtpInput). */
|
|
147
168
|
function componentExportName(source, fallback) {
|
|
148
169
|
const matches = [...source.matchAll(/export\s+function\s+([A-Z][A-Za-z0-9_]*)/g)].map((match) => match[1])
|
|
149
170
|
return matches[0] ?? fallback
|
|
150
171
|
}
|
|
151
172
|
|
|
152
|
-
/**
|
|
173
|
+
/** .tsx files under src/pages of the target project, sorted. */
|
|
153
174
|
function listPagesInCwd(cwd) {
|
|
154
175
|
try {
|
|
155
176
|
const pagesDir = join(resolve(cwd), 'src', 'pages')
|
|
@@ -163,7 +184,7 @@ function listPagesInCwd(cwd) {
|
|
|
163
184
|
}
|
|
164
185
|
}
|
|
165
186
|
|
|
166
|
-
/**
|
|
187
|
+
/** Files where the component usage may land (flags shown). */
|
|
167
188
|
function usageCandidates(cwd) {
|
|
168
189
|
const app = existsSync(join(resolve(cwd), 'src', 'App.tsx')) ? 'src/App.tsx' : null
|
|
169
190
|
const pages = listPagesInCwd(cwd).filter((page) => page !== app)
|
|
@@ -172,7 +193,7 @@ function usageCandidates(cwd) {
|
|
|
172
193
|
|
|
173
194
|
/**
|
|
174
195
|
* Saisie interactive : uniquement sur un terminal (ou avec --interactive),
|
|
175
|
-
*
|
|
196
|
+
* never in CI / pipe without data, to avoid blocking the installation.
|
|
176
197
|
*/
|
|
177
198
|
function createPrompter(flags) {
|
|
178
199
|
const allowed = Boolean(flags.interactive) || process.stdin.isTTY === true
|
|
@@ -200,22 +221,35 @@ function createPrompter(flags) {
|
|
|
200
221
|
}
|
|
201
222
|
})
|
|
202
223
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
224
|
+
async function ask(question, fallback) {
|
|
225
|
+
const suffix = fallback ? ` (${fallback})` : ''
|
|
226
|
+
process.stdout.write(`\n \x1b[36m?\x1b[0m ${question}${suffix} : `)
|
|
227
|
+
|
|
228
|
+
let answer
|
|
229
|
+
if (queued.length > 0) answer = queued.shift()
|
|
230
|
+
else if (closed) answer = null
|
|
231
|
+
else
|
|
232
|
+
answer = await new Promise((resolveLine) => {
|
|
212
233
|
waiting = resolveLine
|
|
213
234
|
})
|
|
214
235
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
236
|
+
// stdin closed (pipe without data): keep the default value.
|
|
237
|
+
if (answer === null) return fallback
|
|
238
|
+
const trimmed = answer.trim()
|
|
239
|
+
return trimmed === '' ? fallback : trimmed
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
ask,
|
|
244
|
+
/** y / yes / n / no (French values tolerated as fallback). */
|
|
245
|
+
async confirm(question, fallback = false) {
|
|
246
|
+
const answer = await ask(`${question} (y/n)`)
|
|
247
|
+
const value = String(answer ?? '')
|
|
248
|
+
.trim()
|
|
249
|
+
.toLowerCase()
|
|
250
|
+
if (['y', 'yes', 'o', 'oui'].includes(value)) return true
|
|
251
|
+
if (['n', 'no', 'non'].includes(value)) return false
|
|
252
|
+
return fallback
|
|
219
253
|
},
|
|
220
254
|
close() {
|
|
221
255
|
if (closed) return
|
|
@@ -226,12 +260,12 @@ function createPrompter(flags) {
|
|
|
226
260
|
}
|
|
227
261
|
|
|
228
262
|
/**
|
|
229
|
-
*
|
|
230
|
-
*
|
|
263
|
+
* Transitive resolution: a component imports helpers (@/lib/…)
|
|
264
|
+
* which may themselves import other local files.
|
|
231
265
|
*/
|
|
232
266
|
function collectFiles(entryPath, srcRoot) {
|
|
233
267
|
const queue = [entryPath]
|
|
234
|
-
const visited = new Map() //
|
|
268
|
+
const visited = new Map() // absolute path -> relative path from src/
|
|
235
269
|
|
|
236
270
|
while (queue.length > 0) {
|
|
237
271
|
const current = queue.shift()
|
|
@@ -249,7 +283,7 @@ function collectFiles(entryPath, srcRoot) {
|
|
|
249
283
|
]
|
|
250
284
|
const found = candidates.find((candidate) => existsSync(candidate))
|
|
251
285
|
if (found) queue.push(found)
|
|
252
|
-
else fail(`
|
|
286
|
+
else fail(`Local import not found: ${specifier} (from ${relative(srcRoot, current)})`)
|
|
253
287
|
}
|
|
254
288
|
}
|
|
255
289
|
|
|
@@ -282,14 +316,14 @@ function isLightHex(hex) {
|
|
|
282
316
|
}
|
|
283
317
|
|
|
284
318
|
/* -------------------------------------------------------------------------- */
|
|
285
|
-
/*
|
|
319
|
+
/* Tailwind CSS 4 theme */
|
|
286
320
|
/* -------------------------------------------------------------------------- */
|
|
287
321
|
|
|
288
322
|
/**
|
|
289
323
|
* Les composants utilisent uniquement des classes Tailwind (`bg-primary`,
|
|
290
324
|
* `text-muted-foreground`, `border-border`…). Pour que ces classes existent,
|
|
291
|
-
* Tailwind 4
|
|
292
|
-
*
|
|
325
|
+
* Tailwind 4 must discover the tokens via `@theme`: a plain `:root`
|
|
326
|
+
* generates no utility class.
|
|
293
327
|
*/
|
|
294
328
|
function buildThemeCss({ accent, radius }) {
|
|
295
329
|
const foreground = isLightHex(accent) ? '#0f172a' : '#ffffff'
|
|
@@ -325,7 +359,7 @@ function buildThemeCss({ accent, radius }) {
|
|
|
325
359
|
' color-scheme: dark;',
|
|
326
360
|
]
|
|
327
361
|
|
|
328
|
-
return `/* uifolio —
|
|
362
|
+
return `/* uifolio — Tailwind CSS 4 tokens: bg-primary, text-muted-foreground… come from here. */
|
|
329
363
|
:root {
|
|
330
364
|
${rootTokens.join('\n')}
|
|
331
365
|
}
|
|
@@ -368,7 +402,7 @@ function findGlobalCss(cwd) {
|
|
|
368
402
|
return null
|
|
369
403
|
}
|
|
370
404
|
|
|
371
|
-
/**
|
|
405
|
+
/** Adds `@import './uifolio-theme.css';` right after the Tailwind import. */
|
|
372
406
|
function wireThemeImport(source, specifier) {
|
|
373
407
|
const line = `@import '${specifier}';`
|
|
374
408
|
if (source.includes(specifier)) return source
|
|
@@ -379,14 +413,14 @@ function wireThemeImport(source, specifier) {
|
|
|
379
413
|
return lines.join('\n')
|
|
380
414
|
}
|
|
381
415
|
|
|
382
|
-
/** `--primary`
|
|
416
|
+
/** `--primary` already defined in a CSS (hex only), otherwise null. */
|
|
383
417
|
function existingAccent(file) {
|
|
384
418
|
if (!file || !existsSync(file)) return null
|
|
385
419
|
const match = /--primary:\s*(#[0-9a-f]{6})\s*;/i.exec(readFileSync(file, 'utf8'))
|
|
386
420
|
return match ? match[1].toLowerCase() : null
|
|
387
421
|
}
|
|
388
422
|
|
|
389
|
-
/** `--radius`
|
|
423
|
+
/** `--radius` already defined in a CSS, otherwise undefined. */
|
|
390
424
|
function existingRadius(file) {
|
|
391
425
|
if (!file || !existsSync(file)) return undefined
|
|
392
426
|
const match = /--radius:\s*(\d+(?:\.\d+)?)px\s*;/.exec(readFileSync(file, 'utf8'))
|
|
@@ -398,20 +432,20 @@ function writeTheme({ cwd, accent, radius, dryRun }) {
|
|
|
398
432
|
const themeFile = globalCss
|
|
399
433
|
? join(dirname(globalCss), THEME_FILE)
|
|
400
434
|
: join(cwd, THEME_FILE)
|
|
401
|
-
//
|
|
435
|
+
// Without --color / --radius, keep the theme already in place (project or UIfolio).
|
|
402
436
|
const finalAccent = accent ?? existingAccent(themeFile) ?? existingAccent(globalCss) ?? DEFAULT_ACCENT
|
|
403
437
|
const finalRadius = radius ?? existingRadius(themeFile)
|
|
404
438
|
const css = buildThemeCss({ accent: finalAccent, radius: finalRadius })
|
|
405
439
|
const label = relative(cwd, themeFile)
|
|
406
440
|
|
|
407
441
|
if (dryRun) {
|
|
408
|
-
ok(`[dry-run]
|
|
409
|
-
if (globalCss) info(`[dry-run]
|
|
442
|
+
ok(`[dry-run] Tailwind theme would have been written to ${label}`)
|
|
443
|
+
if (globalCss) info(`[dry-run] imported in ${relative(cwd, globalCss)}`)
|
|
410
444
|
return
|
|
411
445
|
}
|
|
412
446
|
|
|
413
447
|
writeFileSync(themeFile, css, 'utf8')
|
|
414
|
-
ok(`
|
|
448
|
+
ok(`Tailwind theme written to ${label}`)
|
|
415
449
|
info(`--primary: ${finalAccent}${finalRadius === undefined ? '' : ` · --radius: ${finalRadius}px`}`)
|
|
416
450
|
|
|
417
451
|
if (globalCss) {
|
|
@@ -419,9 +453,9 @@ function writeTheme({ cwd, accent, radius, dryRun }) {
|
|
|
419
453
|
const specifier = `./${basename(themeFile)}`
|
|
420
454
|
const updated = wireThemeImport(source, specifier)
|
|
421
455
|
if (updated !== source) writeFileSync(globalCss, updated, 'utf8')
|
|
422
|
-
info(`
|
|
456
|
+
info(`imported in ${relative(cwd, globalCss)} — no hard-coded colors in the components`)
|
|
423
457
|
} else {
|
|
424
|
-
info(`
|
|
458
|
+
info(`Add "@import './${THEME_FILE}';", at the top of your global CSS (the Tailwind one).`)
|
|
425
459
|
}
|
|
426
460
|
}
|
|
427
461
|
|
|
@@ -435,7 +469,7 @@ function importSpecifier(fromDir, target) {
|
|
|
435
469
|
return rel.startsWith('.') ? rel : `./${rel}`
|
|
436
470
|
}
|
|
437
471
|
|
|
438
|
-
/**
|
|
472
|
+
/** Inserts the imports after the last import of the file. */
|
|
439
473
|
function addImports(source, lines) {
|
|
440
474
|
const missing = lines.filter((line) => !source.includes(line))
|
|
441
475
|
if (missing.length === 0) return source
|
|
@@ -447,7 +481,7 @@ function addImports(source, lines) {
|
|
|
447
481
|
}
|
|
448
482
|
if (last === -1) return `${missing.join('\n')}\n\n${source}`
|
|
449
483
|
|
|
450
|
-
//
|
|
484
|
+
// An import can span several lines: advance to the end of it.
|
|
451
485
|
let end = last
|
|
452
486
|
while (
|
|
453
487
|
end < all.length - 1 &&
|
|
@@ -463,7 +497,7 @@ function addImports(source, lines) {
|
|
|
463
497
|
}
|
|
464
498
|
|
|
465
499
|
/**
|
|
466
|
-
*
|
|
500
|
+
* End of the opening tag from `<` to `start`, without being tricked by the
|
|
467
501
|
* `>`/apostrophes des attributs et des textes JSX.
|
|
468
502
|
*/
|
|
469
503
|
function findOpeningTagEnd(source, start) {
|
|
@@ -485,7 +519,7 @@ function findOpeningTagEnd(source, start) {
|
|
|
485
519
|
return null
|
|
486
520
|
}
|
|
487
521
|
|
|
488
|
-
/**
|
|
522
|
+
/** Adds `<Composant />` in the returned JSX, otherwise an exported demo. */
|
|
489
523
|
function addJsxUsage(source, exportNames) {
|
|
490
524
|
const missing = exportNames.filter((name) => !new RegExp(`<${name}[\\s/>]`).test(source))
|
|
491
525
|
if (missing.length === 0) return { source, added: [] }
|
|
@@ -502,7 +536,7 @@ function addJsxUsage(source, exportNames) {
|
|
|
502
536
|
const childIndent = `${indent} `
|
|
503
537
|
const children = missing.map((name) => `${childIndent}<${name} />`).join('\n')
|
|
504
538
|
|
|
505
|
-
// Fragment
|
|
539
|
+
// Fragment already present: insert right after `<>`.
|
|
506
540
|
if (source.startsWith('<>', rootIndex)) {
|
|
507
541
|
const next = `${source.slice(0, rootIndex + 2)}\n${children}${source.slice(rootIndex + 2)}`
|
|
508
542
|
return { source: next, added: missing }
|
|
@@ -516,7 +550,7 @@ function addJsxUsage(source, exportNames) {
|
|
|
516
550
|
return { source: next, added: missing }
|
|
517
551
|
}
|
|
518
552
|
|
|
519
|
-
//
|
|
553
|
+
// Opening element: the components become its first children.
|
|
520
554
|
const next = `${source.slice(0, tag.end + 1)}\n${children}${source.slice(tag.end + 1)}`
|
|
521
555
|
return { source: next, added: missing }
|
|
522
556
|
}
|
|
@@ -529,10 +563,13 @@ function addJsxUsage(source, exportNames) {
|
|
|
529
563
|
}
|
|
530
564
|
|
|
531
565
|
/**
|
|
532
|
-
*
|
|
566
|
+
* Writes (or completes) the chosen file: imports from the real folder of the
|
|
533
567
|
* composants + appel `<Composant />` dans le JSX.
|
|
534
568
|
*/
|
|
535
569
|
function writeUsage({ cwd, target, componentsDir, components, dryRun }) {
|
|
570
|
+
if (existsSync(target) && statSync(target).isDirectory()) {
|
|
571
|
+
fail(`${relative(cwd, target)} is a folder: give a file (e.g. src/pages/${components[0].name}.tsx).`)
|
|
572
|
+
}
|
|
536
573
|
const created = !existsSync(target)
|
|
537
574
|
const lines = components.map(({ name, exportName }) =>
|
|
538
575
|
`import { ${exportName} } from '${importSpecifier(dirname(target), join(resolve(cwd, componentsDir), name))}'`,
|
|
@@ -554,19 +591,29 @@ function writeUsage({ cwd, target, componentsDir, components, dryRun }) {
|
|
|
554
591
|
'',
|
|
555
592
|
].join('\n')
|
|
556
593
|
if (!dryRun) {
|
|
557
|
-
|
|
558
|
-
|
|
594
|
+
try {
|
|
595
|
+
mkdirSync(dirname(target), { recursive: true })
|
|
596
|
+
writeFileSync(target, body, 'utf8')
|
|
597
|
+
} catch (error) {
|
|
598
|
+
writeFailure(error, label)
|
|
599
|
+
}
|
|
559
600
|
}
|
|
560
|
-
ok(`${dryRun ? '[dry-run] ' : ''}page
|
|
601
|
+
ok(`${dryRun ? '[dry-run] ' : ''}page created: ${label}`)
|
|
561
602
|
} else {
|
|
562
603
|
const source = readFileSync(target, 'utf8')
|
|
563
604
|
const withImports = addImports(source, lines)
|
|
564
605
|
const { source: updated, added } = addJsxUsage(withImports, names)
|
|
565
|
-
if (!dryRun)
|
|
566
|
-
|
|
606
|
+
if (!dryRun) {
|
|
607
|
+
try {
|
|
608
|
+
writeFileSync(target, updated, 'utf8')
|
|
609
|
+
} catch (error) {
|
|
610
|
+
writeFailure(error, label)
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
ok(`${dryRun ? '[dry-run] ' : ''}usage added to ${label}`)
|
|
567
614
|
if (added.length < names.length) {
|
|
568
615
|
const already = names.filter((name) => !added.includes(name))
|
|
569
|
-
info(`
|
|
616
|
+
info(`already present, not duplicated: ${already.join(', ')}`)
|
|
570
617
|
}
|
|
571
618
|
}
|
|
572
619
|
|
|
@@ -588,18 +635,18 @@ const ALIASES = {
|
|
|
588
635
|
|
|
589
636
|
function commandList() {
|
|
590
637
|
const components = listComponents()
|
|
591
|
-
if (components.length === 0) fail('
|
|
592
|
-
console.log('\
|
|
638
|
+
if (components.length === 0) fail('No component found in the registry.')
|
|
639
|
+
console.log('\nAvailable components:\n')
|
|
593
640
|
for (const name of components) {
|
|
594
641
|
console.log(` \x1b[36m${name.padEnd(20)}\x1b[0m src/components/ui/${name}.tsx`)
|
|
595
642
|
}
|
|
596
|
-
console.log('\nUsage
|
|
643
|
+
console.log('\nUsage: uifolio add <name> [--cwd <target project>]\n')
|
|
597
644
|
}
|
|
598
645
|
|
|
599
646
|
function commandInit({ flags }) {
|
|
600
647
|
const cwd = resolve(flags.cwd ?? process.cwd())
|
|
601
648
|
const file = join(cwd, CONFIG_NAME)
|
|
602
|
-
if (existsSync(file) && !flags.force) fail(`${CONFIG_NAME}
|
|
649
|
+
if (existsSync(file) && !flags.force) fail(`${CONFIG_NAME} already exists (use --force).`)
|
|
603
650
|
|
|
604
651
|
const config = {
|
|
605
652
|
$schema: 'https://uifolio.dev/schema.json',
|
|
@@ -608,26 +655,42 @@ function commandInit({ flags }) {
|
|
|
608
655
|
alias: typeof flags.alias === 'string' ? flags.alias : '@',
|
|
609
656
|
}
|
|
610
657
|
writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`, 'utf8')
|
|
611
|
-
ok(`${CONFIG_NAME}
|
|
658
|
+
ok(`${CONFIG_NAME} written to ${cwd}`)
|
|
612
659
|
info(`dir: ${config.dir} · lib: ${config.lib} · alias: ${config.alias}`)
|
|
613
|
-
info('
|
|
660
|
+
info('Next: uifolio add video-player calendar …')
|
|
614
661
|
}
|
|
615
662
|
|
|
616
663
|
/**
|
|
617
|
-
*
|
|
618
|
-
*
|
|
664
|
+
* Asks where the component should be used, right after installation:
|
|
665
|
+
* an existing file, a new folder + import file, or no file at all.
|
|
619
666
|
*/
|
|
620
|
-
async function chooseUsageTarget({ prompter, cwd, names }) {
|
|
667
|
+
async function chooseUsageTarget({ prompter, cwd, names, exportNames }) {
|
|
621
668
|
const suggestion = `src/pages/${names[0]}.tsx`
|
|
669
|
+
const pathFormat = `e.g. ${suggestion}`
|
|
670
|
+
const label = exportNames.join(', ')
|
|
622
671
|
const candidates = usageCandidates(cwd)
|
|
623
|
-
if (candidates.length > 0) info(`
|
|
672
|
+
if (candidates.length > 0) info(`existing files: ${candidates.join(', ')}`)
|
|
624
673
|
|
|
625
|
-
const
|
|
626
|
-
`
|
|
627
|
-
|
|
674
|
+
const ready = await prompter.confirm(
|
|
675
|
+
`Do you already have a file ready to receive the component (${label})?`,
|
|
676
|
+
false,
|
|
628
677
|
)
|
|
678
|
+
|
|
679
|
+
let answer
|
|
680
|
+
if (ready) {
|
|
681
|
+
answer = await prompter.ask(`In which file should the component (${label}) be copied? (${pathFormat})`, suggestion)
|
|
682
|
+
} else {
|
|
683
|
+
const create = await prompter.confirm('Would you like to create a new folder and an import file?', true)
|
|
684
|
+
if (!create) {
|
|
685
|
+
info('the component is installed but not used in any file.')
|
|
686
|
+
return null
|
|
687
|
+
}
|
|
688
|
+
answer = await prompter.ask(`Please specify the new path (${pathFormat})`, suggestion)
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// “ - ”: installation only, like the “no” answer.
|
|
629
692
|
if (!answer || answer === '-') {
|
|
630
|
-
info('
|
|
693
|
+
info('the component is installed but not used in any file.')
|
|
631
694
|
return null
|
|
632
695
|
}
|
|
633
696
|
return resolve(cwd, answer)
|
|
@@ -635,7 +698,7 @@ async function chooseUsageTarget({ prompter, cwd, names }) {
|
|
|
635
698
|
|
|
636
699
|
async function commandAdd({ positional, flags }) {
|
|
637
700
|
const names = positional.map((name) => ALIASES[name] ?? name)
|
|
638
|
-
if (names.length === 0) fail('
|
|
701
|
+
if (names.length === 0) fail('No component requested. E.g.: uifolio add calendar otp')
|
|
639
702
|
|
|
640
703
|
const cwd = resolve(flags.cwd ?? process.cwd())
|
|
641
704
|
const config = readConfig(cwd) ?? {}
|
|
@@ -647,33 +710,43 @@ async function commandAdd({ positional, flags }) {
|
|
|
647
710
|
const colorFlag = typeof flags.color === 'string' ? flags.color : undefined
|
|
648
711
|
const accent = colorFlag === undefined ? null : normalizeHex(colorFlag)
|
|
649
712
|
if (colorFlag !== undefined && !accent) {
|
|
650
|
-
fail(`
|
|
713
|
+
fail(`Invalid color: ${colorFlag}. Use a hex value like #10B981.`)
|
|
651
714
|
}
|
|
652
715
|
|
|
653
716
|
let radius
|
|
654
717
|
if (typeof flags.radius === 'string') {
|
|
655
718
|
radius = Math.round(Number(flags.radius))
|
|
656
719
|
if (!Number.isFinite(radius) || radius < 0 || radius > 24) {
|
|
657
|
-
fail(`
|
|
720
|
+
fail(`Invalid radius: ${flags.radius}. Use a value between 0 and 24.`)
|
|
658
721
|
}
|
|
659
722
|
}
|
|
660
723
|
|
|
661
724
|
const known = listComponents()
|
|
662
725
|
const unknown = names.filter((name) => !known.includes(name))
|
|
663
726
|
if (unknown.length > 0) {
|
|
664
|
-
fail(`
|
|
727
|
+
fail(`Unknown component(s): ${unknown.join(', ')}. Run "uifolio list".`)
|
|
665
728
|
}
|
|
666
|
-
if (!dryRun && !existsSync(cwd)) fail(`
|
|
729
|
+
if (!dryRun && !existsSync(cwd)) fail(`Target directory not found: ${cwd}`)
|
|
667
730
|
|
|
668
731
|
const prompter = createPrompter(flags)
|
|
669
732
|
try {
|
|
670
733
|
// 1. Dossier d'import des composants.
|
|
671
|
-
let componentsDir =
|
|
734
|
+
let componentsDir = normalizeComponentsDir(
|
|
735
|
+
typeof flags.dir === 'string' ? flags.dir : config.dir ?? DEFAULT_COMPONENTS_DIR,
|
|
736
|
+
)
|
|
672
737
|
if (prompter && typeof flags.dir !== 'string') {
|
|
673
|
-
|
|
674
|
-
|
|
738
|
+
// A file path was already accepted here (mkdir relative to the root).
|
|
739
|
+
for (let attempts = 0; ; attempts += 1) {
|
|
740
|
+
const fallback = attempts === 0 ? componentsDir : DEFAULT_COMPONENTS_DIR
|
|
741
|
+
const answer = await prompter.ask('In which folder should the components be copied?', fallback)
|
|
742
|
+
componentsDir = normalizeComponentsDir(answer ?? DEFAULT_COMPONENTS_DIR)
|
|
743
|
+
if (!looksLikeFile(componentsDir) || attempts >= 2) break
|
|
744
|
+
info(`"${componentsDir}" is a file, not a folder: give a folder, e.g. ${DEFAULT_COMPONENTS_DIR}.`)
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
if (looksLikeFile(componentsDir)) {
|
|
748
|
+
fail(`"${componentsDir}" is a file: give a folder (e.g. ${DEFAULT_COMPONENTS_DIR}).`)
|
|
675
749
|
}
|
|
676
|
-
componentsDir = componentsDir.replace(/^\.\//, '').replace(/\/+$/, '')
|
|
677
750
|
|
|
678
751
|
// 2. Copie des fichiers + helpers.
|
|
679
752
|
const deps = new Set(LIB_DEPS)
|
|
@@ -701,8 +774,12 @@ async function commandAdd({ positional, flags }) {
|
|
|
701
774
|
const shouldWrite = force || !existsSync(destination)
|
|
702
775
|
if (shouldWrite) {
|
|
703
776
|
if (!dryRun) {
|
|
704
|
-
|
|
705
|
-
|
|
777
|
+
try {
|
|
778
|
+
mkdirSync(dirname(destination), { recursive: true })
|
|
779
|
+
writeFileSync(destination, rewriteAlias(source, targetAlias), 'utf8')
|
|
780
|
+
} catch (error) {
|
|
781
|
+
writeFailure(error, label)
|
|
782
|
+
}
|
|
706
783
|
}
|
|
707
784
|
written.add(label)
|
|
708
785
|
} else {
|
|
@@ -711,27 +788,32 @@ async function commandAdd({ positional, flags }) {
|
|
|
711
788
|
}
|
|
712
789
|
}
|
|
713
790
|
|
|
714
|
-
ok(`${written.size}
|
|
791
|
+
ok(`${written.size} file(s) ${dryRun ? 'would be copied [dry-run]' : 'copied'}:`)
|
|
715
792
|
for (const label of [...written].sort()) info(`· ${label}`)
|
|
716
793
|
if (skipped.length > 0) {
|
|
717
|
-
info(`${skipped.length}
|
|
794
|
+
info(`${skipped.length} file(s) already present, skipped: ${skipped.join(', ')} (use --force to overwrite)`)
|
|
718
795
|
}
|
|
719
796
|
|
|
720
797
|
// 3. Tokens Tailwind : les classes bg-primary & co fonctionnent partout.
|
|
721
798
|
writeTheme({ cwd, accent, radius, dryRun })
|
|
722
799
|
|
|
723
|
-
// 4.
|
|
800
|
+
// 4. Component usage, after installation.
|
|
724
801
|
let pageTarget = typeof flags.page === 'string' ? resolve(cwd, flags.page) : null
|
|
725
802
|
if (!pageTarget && prompter) {
|
|
726
|
-
pageTarget = await chooseUsageTarget({
|
|
803
|
+
pageTarget = await chooseUsageTarget({
|
|
804
|
+
prompter,
|
|
805
|
+
cwd,
|
|
806
|
+
names,
|
|
807
|
+
exportNames: components.map((component) => component.exportName),
|
|
808
|
+
})
|
|
727
809
|
}
|
|
728
810
|
if (pageTarget) writeUsage({ cwd, target: pageTarget, componentsDir, components, dryRun })
|
|
729
811
|
|
|
730
812
|
if (written.size > 0 || dryRun) {
|
|
731
|
-
console.log('\n
|
|
813
|
+
console.log('\n Detected npm dependencies:')
|
|
732
814
|
info(`npm install ${[...deps].sort().join(' ')}`)
|
|
733
815
|
if (targetAlias !== '@') {
|
|
734
|
-
info(`
|
|
816
|
+
info(`Remember to align the ${targetAlias} alias in your tsconfig/vite (instead of @/).`)
|
|
735
817
|
}
|
|
736
818
|
console.log('')
|
|
737
819
|
}
|
|
@@ -741,37 +823,38 @@ async function commandAdd({ positional, flags }) {
|
|
|
741
823
|
}
|
|
742
824
|
|
|
743
825
|
/* -------------------------------------------------------------------------- */
|
|
744
|
-
/*
|
|
826
|
+
/* Prompting */
|
|
745
827
|
/* -------------------------------------------------------------------------- */
|
|
746
828
|
|
|
747
829
|
function usage() {
|
|
748
830
|
console.log(`
|
|
749
|
-
\x1b[1muifolio\x1b[0m —
|
|
750
|
-
|
|
751
|
-
\x1b[36muifolio list\x1b[0m
|
|
752
|
-
\x1b[36muifolio init [options]\x1b[0m
|
|
753
|
-
\x1b[36muifolio add <
|
|
754
|
-
|
|
755
|
-
Options
|
|
756
|
-
--cwd <dir>
|
|
757
|
-
--dir <
|
|
758
|
-
--lib <
|
|
759
|
-
--alias <alias>
|
|
760
|
-
--color <hex>
|
|
761
|
-
--radius <px>
|
|
762
|
-
--page <
|
|
763
|
-
|
|
764
|
-
--interactive
|
|
765
|
-
--force
|
|
766
|
-
--dry-run
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
831
|
+
\x1b[1muifolio\x1b[0m — installs the UIfolio components into a project
|
|
832
|
+
|
|
833
|
+
\x1b[36muifolio list\x1b[0m list available components
|
|
834
|
+
\x1b[36muifolio init [options]\x1b[0m write uifolio.json in the target project
|
|
835
|
+
\x1b[36muifolio add <names...> [options]\x1b[0m copy the components + their helpers
|
|
836
|
+
|
|
837
|
+
Options:
|
|
838
|
+
--cwd <dir> target project (default: current directory)
|
|
839
|
+
--dir <path> components folder (default: src/components/ui)
|
|
840
|
+
--lib <path> helpers folder (default: src/lib)
|
|
841
|
+
--alias <alias> target import alias (default: @)
|
|
842
|
+
--color <hex> primary color, written in the Tailwind tokens (@theme)
|
|
843
|
+
--radius <px> global radius (0–24) in uifolio-theme.css
|
|
844
|
+
--page <path> file where the component is used (e.g. src/pages/calendar.tsx);
|
|
845
|
+
created if missing, otherwise the import + <Component /> are added
|
|
846
|
+
--interactive ask the questions outside a terminal
|
|
847
|
+
--force overwrite existing files
|
|
848
|
+
--dry-run simulate without writing
|
|
849
|
+
|
|
850
|
+
Without --dir or --page, the CLI asks in which folder to copy the components,
|
|
851
|
+
then whether a file is already ready to receive them. The component is either
|
|
852
|
+
added to an existing file, added to a new folder + import file, or installed
|
|
853
|
+
only. Colors go through a Tailwind CSS 4 theme (uifolio-theme.css, @theme), so
|
|
854
|
+
classes like bg-primary and text-muted-foreground stay in the code.
|
|
855
|
+
|
|
856
|
+
Examples:
|
|
857
|
+
node cli/uifolio.mjs add video-player calendar otp --cwd ../my-project
|
|
775
858
|
node cli/uifolio.mjs add otp --color "#10B981" --radius 8
|
|
776
859
|
node cli/uifolio.mjs add calendar --page src/pages/calendar.tsx
|
|
777
860
|
`)
|