uifolio 0.5.1 → 0.5.4
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 +70 -269
- package/cli/uifolio.mjs +446 -164
- package/package.json +3 -2
- package/src/components/ui/audio-player.tsx +7 -7
- package/src/components/ui/calendar.tsx +5 -5
- package/src/components/ui/color-picker.tsx +17 -17
- package/src/components/ui/command-menu.tsx +5 -5
- package/src/components/ui/file-upload.tsx +11 -11
- package/src/components/ui/icon-picker.tsx +12 -12
- package/src/components/ui/resizable.tsx +1 -1
- package/src/components/ui/rich-text-editor.tsx +17 -18
- package/src/components/ui/stories.tsx +4 -4
- package/src/components/ui/table.tsx +164 -0
- 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 --page, the CLI asks interactively (terminal) where to use them.
|
|
26
|
+
* The components always land in src/components/ui unless --dir overrides it.
|
|
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,14 @@ function externalDeps(source) {
|
|
|
143
164
|
return [...deps]
|
|
144
165
|
}
|
|
145
166
|
|
|
146
|
-
/**
|
|
167
|
+
/** Real exported name of the file (otp-input → OtpInput, icon-picker → IconPicker). */
|
|
147
168
|
function componentExportName(source, fallback) {
|
|
148
|
-
const
|
|
149
|
-
|
|
169
|
+
const exported = [...source.matchAll(/export\s+function\s+([A-Z][A-Za-z0-9_]*)/g)].map((match) => match[1])
|
|
170
|
+
const pascal = fallback.replace(/(^|-)([a-z0-9])/g, (_, __, char) => char.toUpperCase())
|
|
171
|
+
return exported.find((name) => name === pascal) ?? exported[0] ?? fallback
|
|
150
172
|
}
|
|
151
173
|
|
|
152
|
-
/**
|
|
174
|
+
/** .tsx files under src/pages of the target project, sorted. */
|
|
153
175
|
function listPagesInCwd(cwd) {
|
|
154
176
|
try {
|
|
155
177
|
const pagesDir = join(resolve(cwd), 'src', 'pages')
|
|
@@ -163,7 +185,7 @@ function listPagesInCwd(cwd) {
|
|
|
163
185
|
}
|
|
164
186
|
}
|
|
165
187
|
|
|
166
|
-
/**
|
|
188
|
+
/** Files where the component usage may land (flags shown). */
|
|
167
189
|
function usageCandidates(cwd) {
|
|
168
190
|
const app = existsSync(join(resolve(cwd), 'src', 'App.tsx')) ? 'src/App.tsx' : null
|
|
169
191
|
const pages = listPagesInCwd(cwd).filter((page) => page !== app)
|
|
@@ -172,7 +194,7 @@ function usageCandidates(cwd) {
|
|
|
172
194
|
|
|
173
195
|
/**
|
|
174
196
|
* Saisie interactive : uniquement sur un terminal (ou avec --interactive),
|
|
175
|
-
*
|
|
197
|
+
* never in CI / pipe without data, to avoid blocking the installation.
|
|
176
198
|
*/
|
|
177
199
|
function createPrompter(flags) {
|
|
178
200
|
const allowed = Boolean(flags.interactive) || process.stdin.isTTY === true
|
|
@@ -200,22 +222,36 @@ function createPrompter(flags) {
|
|
|
200
222
|
}
|
|
201
223
|
})
|
|
202
224
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
225
|
+
async function ask(question, fallback) {
|
|
226
|
+
const text = question.trim().replace(/\?+$/, '')
|
|
227
|
+
const suffix = fallback ? ` (${fallback})` : ''
|
|
228
|
+
process.stdout.write(`\n \x1b[36m${text}${suffix} ?\x1b[0m `)
|
|
229
|
+
|
|
230
|
+
let answer
|
|
231
|
+
if (queued.length > 0) answer = queued.shift()
|
|
232
|
+
else if (closed) answer = null
|
|
233
|
+
else
|
|
234
|
+
answer = await new Promise((resolveLine) => {
|
|
212
235
|
waiting = resolveLine
|
|
213
236
|
})
|
|
214
237
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
238
|
+
// stdin closed (pipe without data): keep the default value.
|
|
239
|
+
if (answer === null) return fallback
|
|
240
|
+
const trimmed = answer.trim()
|
|
241
|
+
return trimmed === '' ? fallback : trimmed
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
ask,
|
|
246
|
+
/** y / yes / n / no (French values tolerated as fallback). */
|
|
247
|
+
async confirm(question, fallback = false) {
|
|
248
|
+
const answer = await ask(question, fallback ? 'Y/n' : 'y/N')
|
|
249
|
+
const value = String(answer ?? '')
|
|
250
|
+
.trim()
|
|
251
|
+
.toLowerCase()
|
|
252
|
+
if (['y', 'yes', 'o', 'oui'].includes(value)) return true
|
|
253
|
+
if (['n', 'no', 'non'].includes(value)) return false
|
|
254
|
+
return fallback
|
|
219
255
|
},
|
|
220
256
|
close() {
|
|
221
257
|
if (closed) return
|
|
@@ -226,12 +262,12 @@ function createPrompter(flags) {
|
|
|
226
262
|
}
|
|
227
263
|
|
|
228
264
|
/**
|
|
229
|
-
*
|
|
230
|
-
*
|
|
265
|
+
* Transitive resolution: a component imports helpers (@/lib/…)
|
|
266
|
+
* which may themselves import other local files.
|
|
231
267
|
*/
|
|
232
268
|
function collectFiles(entryPath, srcRoot) {
|
|
233
269
|
const queue = [entryPath]
|
|
234
|
-
const visited = new Map() //
|
|
270
|
+
const visited = new Map() // absolute path -> relative path from src/
|
|
235
271
|
|
|
236
272
|
while (queue.length > 0) {
|
|
237
273
|
const current = queue.shift()
|
|
@@ -249,7 +285,7 @@ function collectFiles(entryPath, srcRoot) {
|
|
|
249
285
|
]
|
|
250
286
|
const found = candidates.find((candidate) => existsSync(candidate))
|
|
251
287
|
if (found) queue.push(found)
|
|
252
|
-
else fail(`
|
|
288
|
+
else fail(`Local import not found: ${specifier} (from ${relative(srcRoot, current)})`)
|
|
253
289
|
}
|
|
254
290
|
}
|
|
255
291
|
|
|
@@ -282,14 +318,14 @@ function isLightHex(hex) {
|
|
|
282
318
|
}
|
|
283
319
|
|
|
284
320
|
/* -------------------------------------------------------------------------- */
|
|
285
|
-
/*
|
|
321
|
+
/* Tailwind CSS 4 theme */
|
|
286
322
|
/* -------------------------------------------------------------------------- */
|
|
287
323
|
|
|
288
324
|
/**
|
|
289
325
|
* Les composants utilisent uniquement des classes Tailwind (`bg-primary`,
|
|
290
326
|
* `text-muted-foreground`, `border-border`…). Pour que ces classes existent,
|
|
291
|
-
* Tailwind 4
|
|
292
|
-
*
|
|
327
|
+
* Tailwind 4 must discover the tokens via `@theme`: a plain `:root`
|
|
328
|
+
* generates no utility class.
|
|
293
329
|
*/
|
|
294
330
|
function buildThemeCss({ accent, radius }) {
|
|
295
331
|
const foreground = isLightHex(accent) ? '#0f172a' : '#ffffff'
|
|
@@ -325,7 +361,7 @@ function buildThemeCss({ accent, radius }) {
|
|
|
325
361
|
' color-scheme: dark;',
|
|
326
362
|
]
|
|
327
363
|
|
|
328
|
-
return `/* uifolio —
|
|
364
|
+
return `/* uifolio — Tailwind CSS 4 tokens: bg-primary, text-muted-foreground… come from here. */
|
|
329
365
|
:root {
|
|
330
366
|
${rootTokens.join('\n')}
|
|
331
367
|
}
|
|
@@ -368,7 +404,7 @@ function findGlobalCss(cwd) {
|
|
|
368
404
|
return null
|
|
369
405
|
}
|
|
370
406
|
|
|
371
|
-
/**
|
|
407
|
+
/** Adds `@import './uifolio-theme.css';` right after the Tailwind import. */
|
|
372
408
|
function wireThemeImport(source, specifier) {
|
|
373
409
|
const line = `@import '${specifier}';`
|
|
374
410
|
if (source.includes(specifier)) return source
|
|
@@ -379,14 +415,14 @@ function wireThemeImport(source, specifier) {
|
|
|
379
415
|
return lines.join('\n')
|
|
380
416
|
}
|
|
381
417
|
|
|
382
|
-
/** `--primary`
|
|
418
|
+
/** `--primary` already defined in a CSS (hex only), otherwise null. */
|
|
383
419
|
function existingAccent(file) {
|
|
384
420
|
if (!file || !existsSync(file)) return null
|
|
385
421
|
const match = /--primary:\s*(#[0-9a-f]{6})\s*;/i.exec(readFileSync(file, 'utf8'))
|
|
386
422
|
return match ? match[1].toLowerCase() : null
|
|
387
423
|
}
|
|
388
424
|
|
|
389
|
-
/** `--radius`
|
|
425
|
+
/** `--radius` already defined in a CSS, otherwise undefined. */
|
|
390
426
|
function existingRadius(file) {
|
|
391
427
|
if (!file || !existsSync(file)) return undefined
|
|
392
428
|
const match = /--radius:\s*(\d+(?:\.\d+)?)px\s*;/.exec(readFileSync(file, 'utf8'))
|
|
@@ -398,20 +434,20 @@ function writeTheme({ cwd, accent, radius, dryRun }) {
|
|
|
398
434
|
const themeFile = globalCss
|
|
399
435
|
? join(dirname(globalCss), THEME_FILE)
|
|
400
436
|
: join(cwd, THEME_FILE)
|
|
401
|
-
//
|
|
437
|
+
// Without --color / --radius, keep the theme already in place (project or UIfolio).
|
|
402
438
|
const finalAccent = accent ?? existingAccent(themeFile) ?? existingAccent(globalCss) ?? DEFAULT_ACCENT
|
|
403
439
|
const finalRadius = radius ?? existingRadius(themeFile)
|
|
404
440
|
const css = buildThemeCss({ accent: finalAccent, radius: finalRadius })
|
|
405
441
|
const label = relative(cwd, themeFile)
|
|
406
442
|
|
|
407
443
|
if (dryRun) {
|
|
408
|
-
ok(`[dry-run]
|
|
409
|
-
if (globalCss) info(`[dry-run]
|
|
444
|
+
ok(`[dry-run] Tailwind theme would have been written to ${label}`)
|
|
445
|
+
if (globalCss) info(`[dry-run] imported in ${relative(cwd, globalCss)}`)
|
|
410
446
|
return
|
|
411
447
|
}
|
|
412
448
|
|
|
413
449
|
writeFileSync(themeFile, css, 'utf8')
|
|
414
|
-
ok(`
|
|
450
|
+
ok(`Tailwind theme written to ${label}`)
|
|
415
451
|
info(`--primary: ${finalAccent}${finalRadius === undefined ? '' : ` · --radius: ${finalRadius}px`}`)
|
|
416
452
|
|
|
417
453
|
if (globalCss) {
|
|
@@ -419,9 +455,9 @@ function writeTheme({ cwd, accent, radius, dryRun }) {
|
|
|
419
455
|
const specifier = `./${basename(themeFile)}`
|
|
420
456
|
const updated = wireThemeImport(source, specifier)
|
|
421
457
|
if (updated !== source) writeFileSync(globalCss, updated, 'utf8')
|
|
422
|
-
info(`
|
|
458
|
+
info(`imported in ${relative(cwd, globalCss)} — no hard-coded colors in the components`)
|
|
423
459
|
} else {
|
|
424
|
-
info(`
|
|
460
|
+
info(`Add "@import './${THEME_FILE}';", at the top of your global CSS (the Tailwind one).`)
|
|
425
461
|
}
|
|
426
462
|
}
|
|
427
463
|
|
|
@@ -429,13 +465,182 @@ function writeTheme({ cwd, accent, radius, dryRun }) {
|
|
|
429
465
|
/* Usage du composant dans une page */
|
|
430
466
|
/* -------------------------------------------------------------------------- */
|
|
431
467
|
|
|
432
|
-
/**
|
|
433
|
-
function
|
|
434
|
-
const
|
|
435
|
-
|
|
468
|
+
/** Import specifier through the project alias: @/components/ui/calendar. */
|
|
469
|
+
function aliasImport(cwd, componentsDir, name, alias) {
|
|
470
|
+
const relativePath = relative(resolve(cwd), join(resolve(cwd, componentsDir), name))
|
|
471
|
+
.replace(/\\/g, '/')
|
|
472
|
+
.replace(/^src\//, '')
|
|
473
|
+
return `${alias}/${relativePath}`
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Example generated with the page: an instance that renders right away.
|
|
478
|
+
* A bare `<Component />` shows nothing for dialogs, players, radio groups or
|
|
479
|
+
* the animated wrappers that require children, so these examples carry the
|
|
480
|
+
* props and data each one needs.
|
|
481
|
+
*/
|
|
482
|
+
const USAGE_EXAMPLES = {
|
|
483
|
+
'audio-player': {
|
|
484
|
+
body: ` return (
|
|
485
|
+
<AudioPlayer
|
|
486
|
+
src="/sample.wav"
|
|
487
|
+
title="Test signal"
|
|
488
|
+
subtitle="Generated loop · 8 s · WAV"
|
|
489
|
+
accent="#10B981"
|
|
490
|
+
/>
|
|
491
|
+
)
|
|
492
|
+
`,
|
|
493
|
+
},
|
|
494
|
+
'video-player': {
|
|
495
|
+
body: ` return <VideoPlayer sources="/sample-720.mp4" aspectRatio="16/9" muted loop />
|
|
496
|
+
`,
|
|
497
|
+
},
|
|
498
|
+
'file-upload': {
|
|
499
|
+
body: ` return <FileUpload accept="image/*,video/*" maxSizeMb={25} maxFiles={3} />
|
|
500
|
+
`,
|
|
501
|
+
},
|
|
502
|
+
choice: {
|
|
503
|
+
imports: ["import { useState } from 'react'"],
|
|
504
|
+
body: ` const [checked, setChecked] = useState(true)
|
|
505
|
+
|
|
506
|
+
return (
|
|
507
|
+
<Choice
|
|
508
|
+
checked={checked}
|
|
509
|
+
onChange={setChecked}
|
|
510
|
+
label="Email alerts"
|
|
511
|
+
description="Get a summary every week."
|
|
512
|
+
/>
|
|
513
|
+
)
|
|
514
|
+
`,
|
|
515
|
+
},
|
|
516
|
+
'command-menu': {
|
|
517
|
+
imports: ["import { useState } from 'react'"],
|
|
518
|
+
body: ` const [open, setOpen] = useState(false)
|
|
519
|
+
|
|
520
|
+
return (
|
|
521
|
+
<div className="space-y-3">
|
|
522
|
+
<button
|
|
523
|
+
type="button"
|
|
524
|
+
onClick={() => setOpen(true)}
|
|
525
|
+
className="rounded-full bg-primary px-4 py-2 text-xs font-medium text-primary-foreground"
|
|
526
|
+
>
|
|
527
|
+
Open the palette
|
|
528
|
+
</button>
|
|
529
|
+
<CommandMenu
|
|
530
|
+
open={open}
|
|
531
|
+
onOpenChange={setOpen}
|
|
532
|
+
items={[
|
|
533
|
+
{ id: 'home', label: 'Home', group: 'Navigation', action: () => {} },
|
|
534
|
+
{ id: 'components', label: 'Components', group: 'Navigation', action: () => {} },
|
|
535
|
+
]}
|
|
536
|
+
/>
|
|
537
|
+
</div>
|
|
538
|
+
)
|
|
539
|
+
`,
|
|
540
|
+
},
|
|
541
|
+
stories: {
|
|
542
|
+
imports: ["import { useState } from 'react'"],
|
|
543
|
+
types: ['StoryItem'],
|
|
544
|
+
body: ` const [open, setOpen] = useState(false)
|
|
545
|
+
const items: StoryItem[] = [
|
|
546
|
+
{ type: 'image', src: '/story-1.svg', title: 'Dawn', duration: 5000 },
|
|
547
|
+
{ type: 'text', title: 'Text only', text: 'A story can be plain text.', duration: 5000 },
|
|
548
|
+
]
|
|
549
|
+
|
|
550
|
+
return (
|
|
551
|
+
<div className="space-y-3">
|
|
552
|
+
<button
|
|
553
|
+
type="button"
|
|
554
|
+
onClick={() => setOpen(true)}
|
|
555
|
+
className="rounded-full bg-primary px-4 py-2 text-xs font-medium text-primary-foreground"
|
|
556
|
+
>
|
|
557
|
+
Open the stories
|
|
558
|
+
</button>
|
|
559
|
+
<Stories items={items} open={open} onOpenChange={setOpen} />
|
|
560
|
+
</div>
|
|
561
|
+
)
|
|
562
|
+
`,
|
|
563
|
+
},
|
|
564
|
+
resizable: {
|
|
565
|
+
members: ['ResizablePanel', 'ResizableHandle'],
|
|
566
|
+
body: ` return (
|
|
567
|
+
<ResizablePanelGroup className="h-64 rounded-xl border">
|
|
568
|
+
<ResizablePanel className="p-4">Left panel</ResizablePanel>
|
|
569
|
+
<ResizableHandle />
|
|
570
|
+
<ResizablePanel className="p-4">Right panel</ResizablePanel>
|
|
571
|
+
</ResizablePanelGroup>
|
|
572
|
+
)
|
|
573
|
+
`,
|
|
574
|
+
},
|
|
575
|
+
motion: {
|
|
576
|
+
body: ` return (
|
|
577
|
+
<FadeIn>
|
|
578
|
+
<p className="text-sm">Fade in on mount.</p>
|
|
579
|
+
</FadeIn>
|
|
580
|
+
)
|
|
581
|
+
`,
|
|
582
|
+
},
|
|
583
|
+
table: {
|
|
584
|
+
imports: ["import { useState } from 'react'"],
|
|
585
|
+
members: ['type TableColumn'],
|
|
586
|
+
body: ` const [selected, setSelected] = useState<string[]>([])
|
|
587
|
+
type Row = { id: string; name: string; status: string; total: number }
|
|
588
|
+
const rows: Row[] = [
|
|
589
|
+
{ id: 'finanx', name: 'Finanx', status: 'Active', total: 0 },
|
|
590
|
+
{ id: 'facebook', name: 'Facebook', status: 'Active', total: 408221304 },
|
|
591
|
+
]
|
|
592
|
+
const columns: TableColumn<Row>[] = [
|
|
593
|
+
{ key: 'name', header: 'Name', cell: (row) => row.name },
|
|
594
|
+
{ key: 'status', header: 'Status', cell: (row) => row.status },
|
|
595
|
+
{ key: 'total', header: 'Total received', cell: (row) => row.total },
|
|
596
|
+
]
|
|
597
|
+
|
|
598
|
+
return (
|
|
599
|
+
<Table
|
|
600
|
+
rows={rows}
|
|
601
|
+
columns={columns}
|
|
602
|
+
rowKey={(row) => row.id}
|
|
603
|
+
selectable
|
|
604
|
+
selectedKeys={selected}
|
|
605
|
+
onSelectionChange={setSelected}
|
|
606
|
+
/>
|
|
607
|
+
)
|
|
608
|
+
`,
|
|
609
|
+
},
|
|
436
610
|
}
|
|
437
611
|
|
|
438
|
-
/**
|
|
612
|
+
/** Example component written next to the page, plus the imports it needs. */
|
|
613
|
+
function exampleFor({ name, exportName }) {
|
|
614
|
+
const spec = USAGE_EXAMPLES[name]
|
|
615
|
+
const component = `${exportName}Example`
|
|
616
|
+
if (!spec) {
|
|
617
|
+
return {
|
|
618
|
+
component,
|
|
619
|
+
imports: [],
|
|
620
|
+
types: [],
|
|
621
|
+
members: [],
|
|
622
|
+
code: `function ${component}() {\n return <${exportName} />\n}\n`,
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
return {
|
|
626
|
+
component,
|
|
627
|
+
imports: spec.imports ?? [],
|
|
628
|
+
types: spec.types ?? [],
|
|
629
|
+
members: spec.members ?? [],
|
|
630
|
+
code: `function ${component}() {\n${spec.body}}\n`,
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/** Appends the example components that are not in the file yet. */
|
|
635
|
+
function addExamples(source, examples) {
|
|
636
|
+
const missing = examples.filter(
|
|
637
|
+
({ component }) => !new RegExp(`function\\s+${component}\\s*\\(`).test(source),
|
|
638
|
+
)
|
|
639
|
+
if (missing.length === 0) return source
|
|
640
|
+
return `${source.trimEnd()}\n\n${missing.map(({ code }) => code.trimEnd()).join('\n\n')}\n`
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/** Inserts the imports after the last import of the file. */
|
|
439
644
|
function addImports(source, lines) {
|
|
440
645
|
const missing = lines.filter((line) => !source.includes(line))
|
|
441
646
|
if (missing.length === 0) return source
|
|
@@ -447,7 +652,7 @@ function addImports(source, lines) {
|
|
|
447
652
|
}
|
|
448
653
|
if (last === -1) return `${missing.join('\n')}\n\n${source}`
|
|
449
654
|
|
|
450
|
-
//
|
|
655
|
+
// An import can span several lines: advance to the end of it.
|
|
451
656
|
let end = last
|
|
452
657
|
while (
|
|
453
658
|
end < all.length - 1 &&
|
|
@@ -463,7 +668,7 @@ function addImports(source, lines) {
|
|
|
463
668
|
}
|
|
464
669
|
|
|
465
670
|
/**
|
|
466
|
-
*
|
|
671
|
+
* End of the opening tag from `<` to `start`, without being tricked by the
|
|
467
672
|
* `>`/apostrophes des attributs et des textes JSX.
|
|
468
673
|
*/
|
|
469
674
|
function findOpeningTagEnd(source, start) {
|
|
@@ -485,7 +690,7 @@ function findOpeningTagEnd(source, start) {
|
|
|
485
690
|
return null
|
|
486
691
|
}
|
|
487
692
|
|
|
488
|
-
/**
|
|
693
|
+
/** Adds `<Composant />` in the returned JSX, otherwise an exported demo. */
|
|
489
694
|
function addJsxUsage(source, exportNames) {
|
|
490
695
|
const missing = exportNames.filter((name) => !new RegExp(`<${name}[\\s/>]`).test(source))
|
|
491
696
|
if (missing.length === 0) return { source, added: [] }
|
|
@@ -502,7 +707,7 @@ function addJsxUsage(source, exportNames) {
|
|
|
502
707
|
const childIndent = `${indent} `
|
|
503
708
|
const children = missing.map((name) => `${childIndent}<${name} />`).join('\n')
|
|
504
709
|
|
|
505
|
-
// Fragment
|
|
710
|
+
// Fragment already present: insert right after `<>`.
|
|
506
711
|
if (source.startsWith('<>', rootIndex)) {
|
|
507
712
|
const next = `${source.slice(0, rootIndex + 2)}\n${children}${source.slice(rootIndex + 2)}`
|
|
508
713
|
return { source: next, added: missing }
|
|
@@ -516,7 +721,7 @@ function addJsxUsage(source, exportNames) {
|
|
|
516
721
|
return { source: next, added: missing }
|
|
517
722
|
}
|
|
518
723
|
|
|
519
|
-
//
|
|
724
|
+
// Opening element: the components become its first children.
|
|
520
725
|
const next = `${source.slice(0, tag.end + 1)}\n${children}${source.slice(tag.end + 1)}`
|
|
521
726
|
return { source: next, added: missing }
|
|
522
727
|
}
|
|
@@ -529,48 +734,82 @@ function addJsxUsage(source, exportNames) {
|
|
|
529
734
|
}
|
|
530
735
|
|
|
531
736
|
/**
|
|
532
|
-
*
|
|
533
|
-
*
|
|
737
|
+
* Writes (or completes) the chosen file: `@/` imports from the real folder of
|
|
738
|
+
* the components, a working example (`<CalendarExample />`) and its definition.
|
|
534
739
|
*/
|
|
535
|
-
function writeUsage({ cwd, target, componentsDir, components, dryRun }) {
|
|
740
|
+
function writeUsage({ cwd, target, componentsDir, components, alias, dryRun }) {
|
|
741
|
+
if (existsSync(target) && statSync(target).isDirectory()) {
|
|
742
|
+
fail(`${relative(cwd, target)} is a folder: give a file (e.g. src/pages/${components[0].name}.tsx).`)
|
|
743
|
+
}
|
|
536
744
|
const created = !existsSync(target)
|
|
537
|
-
const
|
|
538
|
-
|
|
539
|
-
|
|
745
|
+
const entries = components.map((component) => ({
|
|
746
|
+
exportName: component.exportName,
|
|
747
|
+
specifier: aliasImport(cwd, componentsDir, component.name, alias),
|
|
748
|
+
example: exampleFor(component),
|
|
749
|
+
}))
|
|
540
750
|
const label = relative(cwd, target)
|
|
541
|
-
|
|
751
|
+
|
|
752
|
+
const imports = new Set()
|
|
753
|
+
for (const { exportName, specifier, example } of entries) {
|
|
754
|
+
for (const line of example.imports) imports.add(line)
|
|
755
|
+
imports.add(`import { ${[exportName, ...example.members].join(', ')} } from '${specifier}'`)
|
|
756
|
+
if (example.types.length > 0) {
|
|
757
|
+
imports.add(`import type { ${example.types.join(', ')} } from '${specifier}'`)
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
const orderedImports = [...imports].sort(
|
|
762
|
+
(left, right) => Number(left.includes(`'${alias}/`)) - Number(right.includes(`'${alias}/`)),
|
|
763
|
+
)
|
|
542
764
|
|
|
543
765
|
if (created) {
|
|
544
766
|
const body = [
|
|
545
|
-
|
|
546
|
-
'',
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
767
|
+
orderedImports.join('\n'),
|
|
768
|
+
entries.map(({ example }) => example.code.trimEnd()).join('\n\n'),
|
|
769
|
+
[
|
|
770
|
+
`export default function ${entries[0].exportName}Page() {`,
|
|
771
|
+
' return (',
|
|
772
|
+
' <main className="space-y-6 p-6">',
|
|
773
|
+
...entries.map(({ example }) => ` <${example.component} />`),
|
|
774
|
+
' </main>',
|
|
775
|
+
' )',
|
|
776
|
+
'}',
|
|
777
|
+
].join('\n'),
|
|
554
778
|
'',
|
|
555
|
-
].join('\n')
|
|
779
|
+
].join('\n\n')
|
|
556
780
|
if (!dryRun) {
|
|
557
|
-
|
|
558
|
-
|
|
781
|
+
try {
|
|
782
|
+
mkdirSync(dirname(target), { recursive: true })
|
|
783
|
+
writeFileSync(target, body, 'utf8')
|
|
784
|
+
} catch (error) {
|
|
785
|
+
writeFailure(error, label)
|
|
786
|
+
}
|
|
559
787
|
}
|
|
560
|
-
ok(`${dryRun ? '[dry-run] ' : ''}page
|
|
788
|
+
ok(`${dryRun ? '[dry-run] ' : ''}page created: ${label}`)
|
|
561
789
|
} else {
|
|
562
790
|
const source = readFileSync(target, 'utf8')
|
|
563
|
-
const withImports = addImports(source,
|
|
564
|
-
const
|
|
565
|
-
|
|
566
|
-
|
|
791
|
+
const withImports = addImports(source, [...imports])
|
|
792
|
+
const names = entries.map(({ example }) => example.component)
|
|
793
|
+
const { source: withUsage, added } = addJsxUsage(withImports, names)
|
|
794
|
+
const updated = addExamples(
|
|
795
|
+
withUsage,
|
|
796
|
+
entries.map(({ example }) => example),
|
|
797
|
+
)
|
|
798
|
+
if (!dryRun) {
|
|
799
|
+
try {
|
|
800
|
+
writeFileSync(target, updated, 'utf8')
|
|
801
|
+
} catch (error) {
|
|
802
|
+
writeFailure(error, label)
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
ok(`${dryRun ? '[dry-run] ' : ''}usage added to ${label}`)
|
|
567
806
|
if (added.length < names.length) {
|
|
568
807
|
const already = names.filter((name) => !added.includes(name))
|
|
569
|
-
info(`
|
|
808
|
+
info(`already present, not duplicated: ${already.join(', ')}`)
|
|
570
809
|
}
|
|
571
810
|
}
|
|
572
811
|
|
|
573
|
-
for (const
|
|
812
|
+
for (const { example } of entries) info(`· <${example.component} />`)
|
|
574
813
|
}
|
|
575
814
|
|
|
576
815
|
/* -------------------------------------------------------------------------- */
|
|
@@ -588,18 +827,18 @@ const ALIASES = {
|
|
|
588
827
|
|
|
589
828
|
function commandList() {
|
|
590
829
|
const components = listComponents()
|
|
591
|
-
if (components.length === 0) fail('
|
|
592
|
-
console.log('\
|
|
830
|
+
if (components.length === 0) fail('No component found in the registry.')
|
|
831
|
+
console.log('\nAvailable components:\n')
|
|
593
832
|
for (const name of components) {
|
|
594
833
|
console.log(` \x1b[36m${name.padEnd(20)}\x1b[0m src/components/ui/${name}.tsx`)
|
|
595
834
|
}
|
|
596
|
-
console.log('\nUsage
|
|
835
|
+
console.log('\nUsage: uifolio add <name> [--cwd <target project>]\n')
|
|
597
836
|
}
|
|
598
837
|
|
|
599
838
|
function commandInit({ flags }) {
|
|
600
839
|
const cwd = resolve(flags.cwd ?? process.cwd())
|
|
601
840
|
const file = join(cwd, CONFIG_NAME)
|
|
602
|
-
if (existsSync(file) && !flags.force) fail(`${CONFIG_NAME}
|
|
841
|
+
if (existsSync(file) && !flags.force) fail(`${CONFIG_NAME} already exists (use --force).`)
|
|
603
842
|
|
|
604
843
|
const config = {
|
|
605
844
|
$schema: 'https://uifolio.dev/schema.json',
|
|
@@ -608,26 +847,48 @@ function commandInit({ flags }) {
|
|
|
608
847
|
alias: typeof flags.alias === 'string' ? flags.alias : '@',
|
|
609
848
|
}
|
|
610
849
|
writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`, 'utf8')
|
|
611
|
-
ok(`${CONFIG_NAME}
|
|
850
|
+
ok(`${CONFIG_NAME} written to ${cwd}`)
|
|
612
851
|
info(`dir: ${config.dir} · lib: ${config.lib} · alias: ${config.alias}`)
|
|
613
|
-
info('
|
|
852
|
+
info('Next: uifolio add video-player calendar …')
|
|
614
853
|
}
|
|
615
854
|
|
|
616
855
|
/**
|
|
617
|
-
*
|
|
618
|
-
*
|
|
856
|
+
* Asks where the component should be used, right after installation:
|
|
857
|
+
* an existing file, a new folder + import file, or no file at all.
|
|
619
858
|
*/
|
|
620
|
-
async function chooseUsageTarget({ prompter, cwd, names }) {
|
|
859
|
+
async function chooseUsageTarget({ prompter, cwd, names, exportNames }) {
|
|
621
860
|
const suggestion = `src/pages/${names[0]}.tsx`
|
|
861
|
+
const pathFormat = `e.g. ${suggestion}`
|
|
862
|
+
const label = exportNames.join(', ')
|
|
622
863
|
const candidates = usageCandidates(cwd)
|
|
623
|
-
if (candidates.length > 0) info(`
|
|
864
|
+
if (candidates.length > 0) info(`existing files: ${candidates.join(', ')}`)
|
|
624
865
|
|
|
625
|
-
const
|
|
626
|
-
`
|
|
627
|
-
|
|
866
|
+
const ready = await prompter.confirm(
|
|
867
|
+
`Do you already have a file ready to receive the component (${label})`,
|
|
868
|
+
false,
|
|
628
869
|
)
|
|
870
|
+
|
|
871
|
+
let answer
|
|
872
|
+
if (ready) {
|
|
873
|
+
answer = await prompter.ask(
|
|
874
|
+
`In which file should the component (${label}) be copied`,
|
|
875
|
+
pathFormat,
|
|
876
|
+
)
|
|
877
|
+
} else {
|
|
878
|
+
const create = await prompter.confirm(
|
|
879
|
+
'Would you like to create a new folder and an import file',
|
|
880
|
+
true,
|
|
881
|
+
)
|
|
882
|
+
if (!create) {
|
|
883
|
+
info('the component is installed but not used in any file.')
|
|
884
|
+
return null
|
|
885
|
+
}
|
|
886
|
+
answer = await prompter.ask('Please specify the new path', pathFormat)
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
// “ - ”: installation only, like the “no” answer.
|
|
629
890
|
if (!answer || answer === '-') {
|
|
630
|
-
info('
|
|
891
|
+
info('the component is installed but not used in any file.')
|
|
631
892
|
return null
|
|
632
893
|
}
|
|
633
894
|
return resolve(cwd, answer)
|
|
@@ -635,7 +896,7 @@ async function chooseUsageTarget({ prompter, cwd, names }) {
|
|
|
635
896
|
|
|
636
897
|
async function commandAdd({ positional, flags }) {
|
|
637
898
|
const names = positional.map((name) => ALIASES[name] ?? name)
|
|
638
|
-
if (names.length === 0) fail('
|
|
899
|
+
if (names.length === 0) fail('No component requested. E.g.: uifolio add calendar otp')
|
|
639
900
|
|
|
640
901
|
const cwd = resolve(flags.cwd ?? process.cwd())
|
|
641
902
|
const config = readConfig(cwd) ?? {}
|
|
@@ -647,33 +908,33 @@ async function commandAdd({ positional, flags }) {
|
|
|
647
908
|
const colorFlag = typeof flags.color === 'string' ? flags.color : undefined
|
|
648
909
|
const accent = colorFlag === undefined ? null : normalizeHex(colorFlag)
|
|
649
910
|
if (colorFlag !== undefined && !accent) {
|
|
650
|
-
fail(`
|
|
911
|
+
fail(`Invalid color: ${colorFlag}. Use a hex value like #10B981.`)
|
|
651
912
|
}
|
|
652
913
|
|
|
653
914
|
let radius
|
|
654
915
|
if (typeof flags.radius === 'string') {
|
|
655
916
|
radius = Math.round(Number(flags.radius))
|
|
656
917
|
if (!Number.isFinite(radius) || radius < 0 || radius > 24) {
|
|
657
|
-
fail(`
|
|
918
|
+
fail(`Invalid radius: ${flags.radius}. Use a value between 0 and 24.`)
|
|
658
919
|
}
|
|
659
920
|
}
|
|
660
921
|
|
|
661
922
|
const known = listComponents()
|
|
662
923
|
const unknown = names.filter((name) => !known.includes(name))
|
|
663
924
|
if (unknown.length > 0) {
|
|
664
|
-
fail(`
|
|
925
|
+
fail(`Unknown component(s): ${unknown.join(', ')}. Run "uifolio list".`)
|
|
665
926
|
}
|
|
666
|
-
if (!dryRun && !existsSync(cwd)) fail(`
|
|
927
|
+
if (!dryRun && !existsSync(cwd)) fail(`Target directory not found: ${cwd}`)
|
|
667
928
|
|
|
668
929
|
const prompter = createPrompter(flags)
|
|
669
930
|
try {
|
|
670
|
-
// 1. Dossier d'import des composants.
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
931
|
+
// 1. Dossier d'import des composants : toujours src/components/ui.
|
|
932
|
+
const componentsDir = normalizeComponentsDir(
|
|
933
|
+
typeof flags.dir === 'string' ? flags.dir : config.dir ?? DEFAULT_COMPONENTS_DIR,
|
|
934
|
+
)
|
|
935
|
+
if (looksLikeFile(componentsDir)) {
|
|
936
|
+
fail(`"${componentsDir}" is a file: give a folder (e.g. ${DEFAULT_COMPONENTS_DIR}).`)
|
|
675
937
|
}
|
|
676
|
-
componentsDir = componentsDir.replace(/^\.\//, '').replace(/\/+$/, '')
|
|
677
938
|
|
|
678
939
|
// 2. Copie des fichiers + helpers.
|
|
679
940
|
const deps = new Set(LIB_DEPS)
|
|
@@ -701,8 +962,12 @@ async function commandAdd({ positional, flags }) {
|
|
|
701
962
|
const shouldWrite = force || !existsSync(destination)
|
|
702
963
|
if (shouldWrite) {
|
|
703
964
|
if (!dryRun) {
|
|
704
|
-
|
|
705
|
-
|
|
965
|
+
try {
|
|
966
|
+
mkdirSync(dirname(destination), { recursive: true })
|
|
967
|
+
writeFileSync(destination, rewriteAlias(source, targetAlias), 'utf8')
|
|
968
|
+
} catch (error) {
|
|
969
|
+
writeFailure(error, label)
|
|
970
|
+
}
|
|
706
971
|
}
|
|
707
972
|
written.add(label)
|
|
708
973
|
} else {
|
|
@@ -711,27 +976,41 @@ async function commandAdd({ positional, flags }) {
|
|
|
711
976
|
}
|
|
712
977
|
}
|
|
713
978
|
|
|
714
|
-
ok(`${written.size}
|
|
979
|
+
ok(`${written.size} file(s) ${dryRun ? 'would be copied [dry-run]' : 'copied'}:`)
|
|
715
980
|
for (const label of [...written].sort()) info(`· ${label}`)
|
|
716
981
|
if (skipped.length > 0) {
|
|
717
|
-
info(`${skipped.length}
|
|
982
|
+
info(`${skipped.length} file(s) already present, skipped: ${skipped.join(', ')} (use --force to overwrite)`)
|
|
718
983
|
}
|
|
719
984
|
|
|
720
985
|
// 3. Tokens Tailwind : les classes bg-primary & co fonctionnent partout.
|
|
721
986
|
writeTheme({ cwd, accent, radius, dryRun })
|
|
722
987
|
|
|
723
|
-
// 4.
|
|
988
|
+
// 4. Component usage, after installation.
|
|
724
989
|
let pageTarget = typeof flags.page === 'string' ? resolve(cwd, flags.page) : null
|
|
725
990
|
if (!pageTarget && prompter) {
|
|
726
|
-
pageTarget = await chooseUsageTarget({
|
|
991
|
+
pageTarget = await chooseUsageTarget({
|
|
992
|
+
prompter,
|
|
993
|
+
cwd,
|
|
994
|
+
names,
|
|
995
|
+
exportNames: components.map((component) => component.exportName),
|
|
996
|
+
})
|
|
997
|
+
}
|
|
998
|
+
if (pageTarget) {
|
|
999
|
+
writeUsage({
|
|
1000
|
+
cwd,
|
|
1001
|
+
target: pageTarget,
|
|
1002
|
+
componentsDir,
|
|
1003
|
+
components,
|
|
1004
|
+
alias: targetAlias,
|
|
1005
|
+
dryRun,
|
|
1006
|
+
})
|
|
727
1007
|
}
|
|
728
|
-
if (pageTarget) writeUsage({ cwd, target: pageTarget, componentsDir, components, dryRun })
|
|
729
1008
|
|
|
730
1009
|
if (written.size > 0 || dryRun) {
|
|
731
|
-
console.log('\n
|
|
1010
|
+
console.log('\n Detected npm dependencies:')
|
|
732
1011
|
info(`npm install ${[...deps].sort().join(' ')}`)
|
|
733
1012
|
if (targetAlias !== '@') {
|
|
734
|
-
info(`
|
|
1013
|
+
info(`Remember to align the ${targetAlias} alias in your tsconfig/vite (instead of @/).`)
|
|
735
1014
|
}
|
|
736
1015
|
console.log('')
|
|
737
1016
|
}
|
|
@@ -741,37 +1020,40 @@ async function commandAdd({ positional, flags }) {
|
|
|
741
1020
|
}
|
|
742
1021
|
|
|
743
1022
|
/* -------------------------------------------------------------------------- */
|
|
744
|
-
/*
|
|
1023
|
+
/* Prompting */
|
|
745
1024
|
/* -------------------------------------------------------------------------- */
|
|
746
1025
|
|
|
747
1026
|
function usage() {
|
|
748
1027
|
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
|
-
|
|
1028
|
+
\x1b[1muifolio\x1b[0m — installs the UIfolio components into a project
|
|
1029
|
+
|
|
1030
|
+
\x1b[36muifolio list\x1b[0m list available components
|
|
1031
|
+
\x1b[36muifolio init [options]\x1b[0m write uifolio.json in the target project
|
|
1032
|
+
\x1b[36muifolio add <names...> [options]\x1b[0m copy the components + their helpers
|
|
1033
|
+
|
|
1034
|
+
Options:
|
|
1035
|
+
--cwd <dir> target project (default: current directory)
|
|
1036
|
+
--dir <path> components folder (default: src/components/ui)
|
|
1037
|
+
--lib <path> helpers folder (default: src/lib)
|
|
1038
|
+
--alias <alias> target import alias (default: @)
|
|
1039
|
+
--color <hex> primary color, written in the Tailwind tokens (@theme)
|
|
1040
|
+
--radius <px> global radius (0–24) in uifolio-theme.css
|
|
1041
|
+
--page <path> file where the component is used (e.g. src/pages/calendar.tsx);
|
|
1042
|
+
created if missing, otherwise the import + <Component /> are added
|
|
1043
|
+
--interactive ask the questions outside a terminal
|
|
1044
|
+
--force overwrite existing files
|
|
1045
|
+
--dry-run simulate without writing
|
|
1046
|
+
|
|
1047
|
+
Without --page, the CLI asks whether a file is already ready to receive the
|
|
1048
|
+
components: they are added to that file, to a new folder + file, or installed
|
|
1049
|
+
only. Files go to src/components/ui (override with --dir) and the page imports
|
|
1050
|
+
them through the alias (@/components/ui/...). The page also gets a working
|
|
1051
|
+
example, with the data and props each component needs, so it shows immediately.
|
|
1052
|
+
Colors go through a Tailwind CSS 4 theme (uifolio-theme.css, @theme), so classes
|
|
1053
|
+
like bg-primary and text-muted-foreground stay in the code.
|
|
1054
|
+
|
|
1055
|
+
Examples:
|
|
1056
|
+
node cli/uifolio.mjs add video-player calendar otp --cwd ../my-project
|
|
775
1057
|
node cli/uifolio.mjs add otp --color "#10B981" --radius 8
|
|
776
1058
|
node cli/uifolio.mjs add calendar --page src/pages/calendar.tsx
|
|
777
1059
|
`)
|