uifolio 0.6.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -9
- package/cli/uifolio.mjs +446 -221
- package/package.json +2 -1
- package/src/components/animations/animation-settings-context.ts +71 -0
- package/src/components/animations/animation-settings-provider.tsx +69 -0
- package/src/components/animations/data.ts +68 -0
- package/src/components/animations/horizontal-accordion.tsx +142 -0
- package/src/components/animations/horizontal-story.tsx +204 -0
- package/src/components/animations/image-crossfade.tsx +276 -0
- package/src/components/animations/palette-context.ts +24 -0
- package/src/components/animations/palette-provider.tsx +28 -0
- package/src/components/animations/palette.ts +18 -0
- package/src/components/animations/scroll-text.tsx +161 -0
- package/src/components/animations/stacking-cards.tsx +162 -0
package/cli/uifolio.mjs
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* Options:
|
|
14
14
|
* --cwd <dir> target project (default: current directory)
|
|
15
15
|
* --dir <path> components folder (default: src/components/ui)
|
|
16
|
+
* --animations <path> animations folder (default: src/components/animations)
|
|
16
17
|
* --lib <path> helpers folder (default: src/lib)
|
|
17
18
|
* --alias <alias> target import alias (default: @)
|
|
18
19
|
* --color <hex> primary color, written in the Tailwind tokens (@theme)
|
|
@@ -33,19 +34,30 @@
|
|
|
33
34
|
|
|
34
35
|
import { execFileSync } from 'node:child_process'
|
|
35
36
|
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
|
|
36
|
-
import { basename, dirname, join, relative, resolve } from 'node:path'
|
|
37
|
+
import { basename, dirname, join, relative, resolve, sep } from 'node:path'
|
|
37
38
|
import { createInterface } from 'node:readline/promises'
|
|
38
39
|
import { fileURLToPath } from 'node:url'
|
|
39
40
|
|
|
40
41
|
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
41
42
|
const REGISTRY_SRC = resolve(HERE, '..', 'src')
|
|
42
43
|
const REGISTRY_UI = join(REGISTRY_SRC, 'components', 'ui')
|
|
44
|
+
const REGISTRY_ANIMATIONS = join(REGISTRY_SRC, 'components', 'animations')
|
|
43
45
|
const CONFIG_NAME = 'uifolio.json'
|
|
44
46
|
|
|
47
|
+
/** Stand-alone animations (the providers/contexts are helpers, not entries). */
|
|
48
|
+
const ANIMATION_NAMES = [
|
|
49
|
+
'horizontal-accordion',
|
|
50
|
+
'horizontal-story',
|
|
51
|
+
'image-crossfade',
|
|
52
|
+
'scroll-text',
|
|
53
|
+
'stacking-cards',
|
|
54
|
+
]
|
|
55
|
+
|
|
45
56
|
const LIB_DEPS = ['clsx', 'tailwind-merge'] // cn() — always copied with the helpers
|
|
46
57
|
|
|
47
58
|
const DEFAULT_ACCENT = '#10b981'
|
|
48
59
|
const DEFAULT_COMPONENTS_DIR = 'src/components/ui'
|
|
60
|
+
const DEFAULT_ANIMATIONS_DIR = 'src/components/animations'
|
|
49
61
|
const THEME_FILE = 'uifolio-theme.css'
|
|
50
62
|
|
|
51
63
|
/** Global CSS candidates for wiring the Tailwind theme. */
|
|
@@ -146,7 +158,7 @@ function writeFailure(error, target) {
|
|
|
146
158
|
fail(`Cannot write ${target}: ${reasons[error?.code] ?? error?.message ?? error}.`)
|
|
147
159
|
}
|
|
148
160
|
|
|
149
|
-
function
|
|
161
|
+
function listUiComponents() {
|
|
150
162
|
if (!existsSync(REGISTRY_UI)) return []
|
|
151
163
|
return readdirSync(REGISTRY_UI)
|
|
152
164
|
.filter((name) => name.endsWith('.tsx'))
|
|
@@ -154,6 +166,24 @@ function listComponents() {
|
|
|
154
166
|
.sort()
|
|
155
167
|
}
|
|
156
168
|
|
|
169
|
+
function listAnimationComponents() {
|
|
170
|
+
if (!existsSync(REGISTRY_ANIMATIONS)) return []
|
|
171
|
+
return ANIMATION_NAMES.filter((name) => existsSync(join(REGISTRY_ANIMATIONS, `${name}.tsx`)))
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function listComponents() {
|
|
175
|
+
return [...listUiComponents(), ...listAnimationComponents()].sort()
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Absolute entry + destination kind of a registry name. */
|
|
179
|
+
function registryEntry(name) {
|
|
180
|
+
const ui = join(REGISTRY_UI, `${name}.tsx`)
|
|
181
|
+
if (existsSync(ui)) return { path: ui, kind: 'ui' }
|
|
182
|
+
const animation = join(REGISTRY_ANIMATIONS, `${name}.tsx`)
|
|
183
|
+
if (existsSync(animation)) return { path: animation, kind: 'animations' }
|
|
184
|
+
return null
|
|
185
|
+
}
|
|
186
|
+
|
|
157
187
|
function readConfig(cwd) {
|
|
158
188
|
const file = join(cwd, CONFIG_NAME)
|
|
159
189
|
if (!existsSync(file)) return null
|
|
@@ -164,14 +194,19 @@ function readConfig(cwd) {
|
|
|
164
194
|
}
|
|
165
195
|
}
|
|
166
196
|
|
|
167
|
-
/**
|
|
197
|
+
/** Local imports (alias or relative) of a source file. */
|
|
168
198
|
function localImports(source) {
|
|
169
199
|
const specifiers = new Set()
|
|
170
200
|
const importRe = /(?:from\s+|import\s*\(\s*|import\s+)['"]([^'"]+)['"]/g
|
|
171
201
|
let match
|
|
172
202
|
while ((match = importRe.exec(source)) !== null) {
|
|
173
203
|
const specifier = match[1]
|
|
174
|
-
if (
|
|
204
|
+
if (
|
|
205
|
+
specifier.startsWith('@/') ||
|
|
206
|
+
specifier.startsWith('~/') ||
|
|
207
|
+
specifier.startsWith('./') ||
|
|
208
|
+
specifier.startsWith('../')
|
|
209
|
+
) {
|
|
175
210
|
specifiers.add(specifier)
|
|
176
211
|
}
|
|
177
212
|
}
|
|
@@ -305,12 +340,14 @@ function collectFiles(entryPath, srcRoot) {
|
|
|
305
340
|
|
|
306
341
|
const source = readFileSync(current, 'utf8')
|
|
307
342
|
for (const specifier of localImports(source)) {
|
|
308
|
-
const base = specifier.
|
|
343
|
+
const base = specifier.startsWith('.')
|
|
344
|
+
? resolve(dirname(current), specifier)
|
|
345
|
+
: join(srcRoot, specifier.replace(/^[@~]\//, ''))
|
|
309
346
|
const candidates = [
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
join(
|
|
313
|
-
join(
|
|
347
|
+
`${base}.ts`,
|
|
348
|
+
`${base}.tsx`,
|
|
349
|
+
join(base, 'index.ts'),
|
|
350
|
+
join(base, 'index.tsx'),
|
|
314
351
|
]
|
|
315
352
|
const found = candidates.find((candidate) => existsSync(candidate))
|
|
316
353
|
if (found) queue.push(found)
|
|
@@ -778,192 +815,205 @@ function aliasImport(cwd, componentsDir, name, alias) {
|
|
|
778
815
|
* the animated wrappers that require children, so these examples carry the
|
|
779
816
|
* props and data each one needs.
|
|
780
817
|
*/
|
|
818
|
+
/**
|
|
819
|
+
* Usage generated with the page: an instance that renders right away, inlined
|
|
820
|
+
* where the component is added. A bare `<Component />` shows nothing for
|
|
821
|
+
* dialogs, players, radio groups or the animated wrappers that require
|
|
822
|
+
* children, so these usages carry the props, data and state each one needs.
|
|
823
|
+
* `setup` holds the statements (hooks, data) that live in the component body,
|
|
824
|
+
* `jsx` is the element inserted at the chosen line.
|
|
825
|
+
*/
|
|
781
826
|
const USAGE_EXAMPLES = {
|
|
782
827
|
'audio-player': {
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
)
|
|
828
|
+
jsx: `
|
|
829
|
+
<AudioPlayer
|
|
830
|
+
src="/sample.wav"
|
|
831
|
+
title="Test signal"
|
|
832
|
+
subtitle="Generated loop · 8 s · WAV"
|
|
833
|
+
accent="#10B981"
|
|
834
|
+
/>
|
|
791
835
|
`,
|
|
792
836
|
},
|
|
793
837
|
'video-player': {
|
|
794
|
-
|
|
795
|
-
`,
|
|
838
|
+
jsx: '<VideoPlayer sources="/sample-720.mp4" aspectRatio="16/9" muted loop />',
|
|
796
839
|
},
|
|
797
840
|
'file-upload': {
|
|
798
|
-
|
|
799
|
-
`,
|
|
841
|
+
jsx: '<FileUpload accept="image/*,video/*" maxSizeMb={25} maxFiles={3} />',
|
|
800
842
|
},
|
|
801
843
|
choice: {
|
|
802
844
|
imports: ["import { useState } from 'react'"],
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
/>
|
|
812
|
-
)
|
|
845
|
+
setup: 'const [checked, setChecked] = useState(true)',
|
|
846
|
+
jsx: `
|
|
847
|
+
<Choice
|
|
848
|
+
checked={checked}
|
|
849
|
+
onChange={setChecked}
|
|
850
|
+
label="Email alerts"
|
|
851
|
+
description="Get a summary every week."
|
|
852
|
+
/>
|
|
813
853
|
`,
|
|
814
854
|
},
|
|
815
855
|
'command-menu': {
|
|
816
856
|
imports: ["import { useState } from 'react'"],
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
</div>
|
|
837
|
-
)
|
|
857
|
+
setup: 'const [open, setOpen] = useState(false)',
|
|
858
|
+
jsx: `
|
|
859
|
+
<div className="space-y-3">
|
|
860
|
+
<button
|
|
861
|
+
type="button"
|
|
862
|
+
onClick={() => setOpen(true)}
|
|
863
|
+
className="rounded-full bg-primary px-4 py-2 text-xs font-medium text-primary-foreground"
|
|
864
|
+
>
|
|
865
|
+
Open the palette
|
|
866
|
+
</button>
|
|
867
|
+
<CommandMenu
|
|
868
|
+
open={open}
|
|
869
|
+
onOpenChange={setOpen}
|
|
870
|
+
items={[
|
|
871
|
+
{ id: 'home', label: 'Home', group: 'Navigation', action: () => {} },
|
|
872
|
+
{ id: 'components', label: 'Components', group: 'Navigation', action: () => {} },
|
|
873
|
+
]}
|
|
874
|
+
/>
|
|
875
|
+
</div>
|
|
838
876
|
`,
|
|
839
877
|
},
|
|
840
878
|
stories: {
|
|
841
879
|
imports: ["import { useState } from 'react'"],
|
|
842
880
|
types: ['StoryItem'],
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
881
|
+
setup: `
|
|
882
|
+
const [open, setOpen] = useState(false)
|
|
883
|
+
const items: StoryItem[] = [
|
|
884
|
+
{ type: 'image', src: '/story-1.svg', title: 'Dawn', duration: 5000 },
|
|
885
|
+
{ type: 'text', title: 'Text only', text: 'A story can be plain text.', duration: 5000 },
|
|
886
|
+
]
|
|
887
|
+
`,
|
|
888
|
+
jsx: `
|
|
889
|
+
<div className="space-y-3">
|
|
890
|
+
<button
|
|
891
|
+
type="button"
|
|
892
|
+
onClick={() => setOpen(true)}
|
|
893
|
+
className="rounded-full bg-primary px-4 py-2 text-xs font-medium text-primary-foreground"
|
|
894
|
+
>
|
|
895
|
+
Open the stories
|
|
896
|
+
</button>
|
|
897
|
+
<Stories items={items} open={open} onOpenChange={setOpen} />
|
|
898
|
+
</div>
|
|
861
899
|
`,
|
|
862
900
|
},
|
|
863
901
|
resizable: {
|
|
864
902
|
members: ['ResizablePanel', 'ResizableHandle'],
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
)
|
|
903
|
+
jsx: `
|
|
904
|
+
<ResizablePanelGroup className="h-64 rounded-xl border">
|
|
905
|
+
<ResizablePanel className="p-4">Left panel</ResizablePanel>
|
|
906
|
+
<ResizableHandle />
|
|
907
|
+
<ResizablePanel className="p-4">Right panel</ResizablePanel>
|
|
908
|
+
</ResizablePanelGroup>
|
|
872
909
|
`,
|
|
873
910
|
},
|
|
874
911
|
motion: {
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
912
|
+
jsx: `
|
|
913
|
+
<FadeIn>
|
|
914
|
+
<p className="text-sm">Fade in on mount.</p>
|
|
915
|
+
</FadeIn>
|
|
916
|
+
`,
|
|
917
|
+
},
|
|
918
|
+
'horizontal-accordion': {
|
|
919
|
+
jsx: '<HorizontalAccordion count={6} />',
|
|
920
|
+
},
|
|
921
|
+
'horizontal-story': {
|
|
922
|
+
jsx: `
|
|
923
|
+
<HorizontalStory
|
|
924
|
+
count={6}
|
|
925
|
+
palette={{ mode: 'gradient', from: '#0EA5E9', to: '#8B5CF6' }}
|
|
926
|
+
/>
|
|
880
927
|
`,
|
|
881
928
|
},
|
|
929
|
+
'stacking-cards': {
|
|
930
|
+
jsx: `<StackingCards count={6} palette={{ mode: 'solid', color: '#10B981' }} />`,
|
|
931
|
+
},
|
|
932
|
+
'scroll-text': {
|
|
933
|
+
jsx: `
|
|
934
|
+
<ScrollText
|
|
935
|
+
prefix="C"
|
|
936
|
+
text="Colors light up letter by letter as you scroll."
|
|
937
|
+
/>
|
|
938
|
+
`,
|
|
939
|
+
},
|
|
940
|
+
'image-crossfade': {
|
|
941
|
+
jsx: '<ImageCrossfade overlay="linear-gradient(to top, rgba(0,0,0,0.7), transparent)" />',
|
|
942
|
+
},
|
|
882
943
|
table: {
|
|
883
944
|
imports: ["import { useState } from 'react'"],
|
|
884
945
|
members: ['type TableColumn'],
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
946
|
+
setup: `
|
|
947
|
+
const [selected, setSelected] = useState<string[]>([])
|
|
948
|
+
type Row = { id: string; name: string; status: string; total: number }
|
|
949
|
+
const rows: Row[] = [
|
|
950
|
+
{ id: 'finanx', name: 'Finanx', status: 'Active', total: 0 },
|
|
951
|
+
{ id: 'facebook', name: 'Facebook', status: 'Active', total: 408221304 },
|
|
952
|
+
]
|
|
953
|
+
const columns: TableColumn<Row>[] = [
|
|
954
|
+
{ key: 'name', header: 'Name', cell: (row) => row.name },
|
|
955
|
+
{ key: 'status', header: 'Status', cell: (row) => row.status },
|
|
956
|
+
{ key: 'total', header: 'Total received', cell: (row) => row.total },
|
|
957
|
+
]
|
|
958
|
+
`,
|
|
959
|
+
jsx: `
|
|
960
|
+
<Table
|
|
961
|
+
rows={rows}
|
|
962
|
+
columns={columns}
|
|
963
|
+
rowKey={(row) => row.id}
|
|
964
|
+
selectable
|
|
965
|
+
selectedKeys={selected}
|
|
966
|
+
onSelectionChange={setSelected}
|
|
967
|
+
/>
|
|
907
968
|
`,
|
|
908
969
|
},
|
|
909
970
|
}
|
|
910
971
|
|
|
911
|
-
/**
|
|
912
|
-
function
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
members: [],
|
|
921
|
-
code: `function ${component}() {\n return <${exportName} />\n}\n`,
|
|
922
|
-
}
|
|
923
|
-
}
|
|
972
|
+
/** Template block → lines, without the leading/trailing empty lines. */
|
|
973
|
+
function blockLines(text) {
|
|
974
|
+
if (text === undefined) return []
|
|
975
|
+
return text.replace(/^\n/, '').replace(/\n$/, '').split('\n')
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/** Usage written for a component: imports, setup statements and JSX element. */
|
|
979
|
+
function usageFor({ name, exportName }) {
|
|
980
|
+
const spec = USAGE_EXAMPLES[name] ?? {}
|
|
924
981
|
return {
|
|
925
|
-
|
|
982
|
+
exportName,
|
|
926
983
|
imports: spec.imports ?? [],
|
|
927
984
|
types: spec.types ?? [],
|
|
928
985
|
members: spec.members ?? [],
|
|
929
|
-
|
|
986
|
+
setup: blockLines(spec.setup),
|
|
987
|
+
jsx: blockLines(spec.jsx ?? `<${exportName} />`),
|
|
930
988
|
}
|
|
931
989
|
}
|
|
932
990
|
|
|
933
|
-
/**
|
|
934
|
-
function
|
|
935
|
-
|
|
936
|
-
({ component }) => !new RegExp(`function\\s+${component}\\s*\\(`).test(source),
|
|
937
|
-
)
|
|
938
|
-
if (missing.length === 0) return source
|
|
939
|
-
return `${source.trimEnd()}\n\n${missing.map(({ code }) => code.trimEnd()).join('\n\n')}\n`
|
|
991
|
+
/** Prefixes every non-empty line with `indent`. */
|
|
992
|
+
function indentLines(lines, indent) {
|
|
993
|
+
return lines.map((line) => (line.trim() === '' ? '' : `${indent}${line}`))
|
|
940
994
|
}
|
|
941
995
|
|
|
942
|
-
/**
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
) {
|
|
962
|
-
end += 1
|
|
996
|
+
/**
|
|
997
|
+
* Setup statements, deduplicated (two usages may declare the same state).
|
|
998
|
+
* Only whole top-level statements are compared: the indented continuation
|
|
999
|
+
* lines (array entries, closing brackets) always stay with their block.
|
|
1000
|
+
*/
|
|
1001
|
+
function uniqueSetup(entries) {
|
|
1002
|
+
const seen = new Set()
|
|
1003
|
+
const lines = []
|
|
1004
|
+
for (const entry of entries) {
|
|
1005
|
+
for (const line of entry.setup) {
|
|
1006
|
+
const key = line.trim()
|
|
1007
|
+
if (key === '') continue
|
|
1008
|
+
const statement = line === line.trimStart() && !/^[)\]}]/.test(key)
|
|
1009
|
+
if (statement) {
|
|
1010
|
+
if (seen.has(key)) continue
|
|
1011
|
+
seen.add(key)
|
|
1012
|
+
}
|
|
1013
|
+
lines.push(line)
|
|
1014
|
+
}
|
|
963
1015
|
}
|
|
964
|
-
|
|
965
|
-
all.splice(end + 1, 0, ...missing)
|
|
966
|
-
return all.join('\n')
|
|
1016
|
+
return lines
|
|
967
1017
|
}
|
|
968
1018
|
|
|
969
1019
|
/**
|
|
@@ -989,71 +1039,168 @@ function findOpeningTagEnd(source, start) {
|
|
|
989
1039
|
return null
|
|
990
1040
|
}
|
|
991
1041
|
|
|
992
|
-
/**
|
|
993
|
-
|
|
994
|
-
|
|
1042
|
+
/**
|
|
1043
|
+
* Default insertion point: the first child of the returned root element, so the
|
|
1044
|
+
* usage lands inside the existing layout instead of next to it. A self-closing
|
|
1045
|
+
* root cannot receive a sibling, so it is wrapped in a fragment first.
|
|
1046
|
+
*/
|
|
1047
|
+
function defaultInsertion(source) {
|
|
1048
|
+
const returnMatch = /\breturn\b/.exec(source)
|
|
1049
|
+
const lineOf = (at) => source.slice(0, at).split('\n').length - 1
|
|
1050
|
+
if (!returnMatch) return { source, index: source.split('\n').length - 1 }
|
|
1051
|
+
|
|
1052
|
+
let cursor = returnMatch.index + returnMatch[0].length
|
|
1053
|
+
while (cursor < source.length && /[\s(]/.test(source[cursor])) cursor += 1
|
|
1054
|
+
if (source[cursor] !== '<') return { source, index: lineOf(returnMatch.index) }
|
|
1055
|
+
|
|
1056
|
+
const tag = findOpeningTagEnd(source, cursor)
|
|
1057
|
+
if (!tag) return { source, index: lineOf(cursor) }
|
|
1058
|
+
|
|
1059
|
+
const rootLine = lineOf(cursor)
|
|
1060
|
+
if (source.startsWith('<>', cursor) || !tag.selfClosing) return { source, index: rootLine + 1 }
|
|
1061
|
+
|
|
1062
|
+
const lineStart = source.lastIndexOf('\n', cursor) + 1
|
|
1063
|
+
const indent = /^\s*/.exec(source.slice(lineStart, cursor))?.[0] ?? ''
|
|
1064
|
+
const element = source.slice(cursor, tag.end + 1)
|
|
1065
|
+
const childIndent = `${indent} `
|
|
1066
|
+
const wrapped = `<>\n${childIndent}${element}\n${indent}</>`
|
|
1067
|
+
return {
|
|
1068
|
+
source: `${source.slice(0, cursor)}${wrapped}${source.slice(tag.end + 1)}`,
|
|
1069
|
+
index: rootLine + 1,
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
/** A page whose component has no JSX return yet: the usage becomes a component. */
|
|
1074
|
+
function appendUsage(source, entries) {
|
|
1075
|
+
const children = []
|
|
1076
|
+
entries.forEach((entry, position) => {
|
|
1077
|
+
if (position > 0) children.push('')
|
|
1078
|
+
children.push(...indentLines(entry.jsx, ' '))
|
|
1079
|
+
})
|
|
1080
|
+
// A default export already exists: a second one would be invalid syntax.
|
|
1081
|
+
const signature = /\bexport\s+default\b/.test(source)
|
|
1082
|
+
? `export function ${entries[0].exportName}Usage() {`
|
|
1083
|
+
: 'export default function Page() {'
|
|
1084
|
+
const code = [
|
|
1085
|
+
signature,
|
|
1086
|
+
...indentLines(uniqueSetup(entries), ' '),
|
|
1087
|
+
' return (',
|
|
1088
|
+
' <main className="space-y-6 p-6">',
|
|
1089
|
+
...children,
|
|
1090
|
+
' </main>',
|
|
1091
|
+
' )',
|
|
1092
|
+
'}',
|
|
1093
|
+
].join('\n')
|
|
1094
|
+
return { source: `${source.trimEnd()}\n\n${code}\n`, added: entries.map((entry) => entry.exportName) }
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
/**
|
|
1098
|
+
* Inserts the usages at `line` (1-based, in the file as it is before the imports
|
|
1099
|
+
* are added): the content sitting on that line moves right after the inserted
|
|
1100
|
+
* blocks. The setup statements (useState, data…) go at the top of the enclosing
|
|
1101
|
+
* component body, before its `return`, where they can actually run.
|
|
1102
|
+
*/
|
|
1103
|
+
function insertUsage(source, entries, line) {
|
|
1104
|
+
const missing = entries.filter(
|
|
1105
|
+
({ exportName }) => !new RegExp(`<${exportName}[\\s/>]`).test(source),
|
|
1106
|
+
)
|
|
995
1107
|
if (missing.length === 0) return { source, added: [] }
|
|
996
1108
|
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
const
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
const lineStart = source.lastIndexOf('\n', rootIndex) + 1
|
|
1005
|
-
const indent = /^\s*/.exec(source.slice(lineStart, rootIndex))?.[0] ?? ''
|
|
1006
|
-
const childIndent = `${indent} `
|
|
1007
|
-
const children = missing.map((name) => `${childIndent}<${name} />`).join('\n')
|
|
1008
|
-
|
|
1009
|
-
// Fragment already present: insert right after `<>`.
|
|
1010
|
-
if (source.startsWith('<>', rootIndex)) {
|
|
1011
|
-
const next = `${source.slice(0, rootIndex + 2)}\n${children}${source.slice(rootIndex + 2)}`
|
|
1012
|
-
return { source: next, added: missing }
|
|
1013
|
-
}
|
|
1109
|
+
if (line === undefined && !/\breturn\b[\s\S]*?<[A-Za-z>]/.test(source)) {
|
|
1110
|
+
return appendUsage(source, missing)
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
const prepared = line === undefined ? defaultInsertion(source) : { source, index: line - 1 }
|
|
1114
|
+
const lines = prepared.source.split('\n')
|
|
1115
|
+
let index = Math.max(0, Math.min(prepared.index, lines.length))
|
|
1014
1116
|
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1117
|
+
const baseLine = lines[index] ?? lines[index - 1] ?? ''
|
|
1118
|
+
const indent = /^\s*/.exec(baseLine)?.[0] ?? ''
|
|
1119
|
+
|
|
1120
|
+
const block = []
|
|
1121
|
+
for (const entry of missing) {
|
|
1122
|
+
if (block.length > 0) block.push('')
|
|
1123
|
+
block.push(...indentLines(entry.jsx, indent))
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
const setup = uniqueSetup(missing)
|
|
1127
|
+
if (setup.length > 0) {
|
|
1128
|
+
// The enclosing `return` first, otherwise the enclosing function body.
|
|
1129
|
+
let setupIndex = -1
|
|
1130
|
+
for (let cursor = index - 1; cursor >= 0; cursor -= 1) {
|
|
1131
|
+
if (/^\s*return\b/.test(lines[cursor])) {
|
|
1132
|
+
setupIndex = cursor
|
|
1133
|
+
break
|
|
1134
|
+
}
|
|
1021
1135
|
}
|
|
1136
|
+
if (setupIndex === -1) {
|
|
1137
|
+
for (let cursor = index - 1; cursor >= 0; cursor -= 1) {
|
|
1138
|
+
if (/^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s+[\w$]+/.test(lines[cursor])) {
|
|
1139
|
+
setupIndex = cursor + 1
|
|
1140
|
+
break
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
const setupIndent =
|
|
1145
|
+
setupIndex === -1 ? indent : /^\s*/.exec(lines[setupIndex] ?? '')?.[0] ?? indent
|
|
1146
|
+
if (setupIndex === -1) setupIndex = index
|
|
1147
|
+
lines.splice(setupIndex, 0, ...indentLines(setup, setupIndent))
|
|
1148
|
+
if (setupIndex <= index) index += setup.length
|
|
1149
|
+
}
|
|
1022
1150
|
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1151
|
+
lines.splice(index, 0, ...block)
|
|
1152
|
+
return { source: lines.join('\n'), added: missing.map((entry) => entry.exportName) }
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
/** Inserts the imports after the last import of the file. */
|
|
1156
|
+
function addImports(source, lines) {
|
|
1157
|
+
const missing = lines.filter((line) => !source.includes(line))
|
|
1158
|
+
if (missing.length === 0) return source
|
|
1159
|
+
|
|
1160
|
+
const all = source.split('\n')
|
|
1161
|
+
let last = -1
|
|
1162
|
+
for (let index = 0; index < all.length; index += 1) {
|
|
1163
|
+
if (/^\s*import\s/.test(all[index])) last = index
|
|
1164
|
+
}
|
|
1165
|
+
if (last === -1) return `${missing.join('\n')}\n\n${source}`
|
|
1166
|
+
|
|
1167
|
+
// An import can span several lines: advance to the end of it.
|
|
1168
|
+
let end = last
|
|
1169
|
+
while (
|
|
1170
|
+
end < all.length - 1 &&
|
|
1171
|
+
end - last < 20 &&
|
|
1172
|
+
!/from\s+['"][^'"]+['"];?\s*$/.test(all[end]) &&
|
|
1173
|
+
!/^import\s+['"][^'"]+['"];?\s*$/.test(all[end])
|
|
1174
|
+
) {
|
|
1175
|
+
end += 1
|
|
1026
1176
|
}
|
|
1027
1177
|
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
.map((name) => `\nexport function ${name}Example() {\n return <${name} />\n}\n`)
|
|
1031
|
-
.join('\n')
|
|
1032
|
-
return { source: `${source.trimEnd()}\n${example}`, added: missing }
|
|
1178
|
+
all.splice(end + 1, 0, ...missing)
|
|
1179
|
+
return all.join('\n')
|
|
1033
1180
|
}
|
|
1034
1181
|
|
|
1035
1182
|
/**
|
|
1036
1183
|
* Writes (or completes) the chosen file: `@/` imports from the real folder of
|
|
1037
|
-
* the components,
|
|
1184
|
+
* the components, then the usage inlined in the page (JSX + state), either at
|
|
1185
|
+
* the requested line or inside the returned element.
|
|
1038
1186
|
*/
|
|
1039
|
-
function writeUsage({ cwd, target,
|
|
1187
|
+
function writeUsage({ cwd, target, components, alias, dryRun, line }) {
|
|
1040
1188
|
if (existsSync(target) && statSync(target).isDirectory()) {
|
|
1041
1189
|
fail(`${relative(cwd, target)} is a folder: give a file (e.g. src/pages/${components[0].name}.tsx).`)
|
|
1042
1190
|
}
|
|
1043
1191
|
const created = !existsSync(target)
|
|
1044
1192
|
const entries = components.map((component) => ({
|
|
1045
|
-
|
|
1046
|
-
specifier: aliasImport(cwd,
|
|
1047
|
-
example: exampleFor(component),
|
|
1193
|
+
...usageFor(component),
|
|
1194
|
+
specifier: aliasImport(cwd, component.dir, component.name, alias),
|
|
1048
1195
|
}))
|
|
1049
1196
|
const label = relative(cwd, target)
|
|
1050
1197
|
|
|
1051
1198
|
const imports = new Set()
|
|
1052
|
-
for (const { exportName, specifier,
|
|
1053
|
-
for (const
|
|
1054
|
-
imports.add(`import { ${[exportName, ...
|
|
1055
|
-
if (
|
|
1056
|
-
imports.add(`import type { ${
|
|
1199
|
+
for (const { exportName, specifier, imports: extraImports, types, members } of entries) {
|
|
1200
|
+
for (const importLine of extraImports) imports.add(importLine)
|
|
1201
|
+
imports.add(`import { ${[exportName, ...members].join(', ')} } from '${specifier}'`)
|
|
1202
|
+
if (types.length > 0) {
|
|
1203
|
+
imports.add(`import type { ${types.join(', ')} } from '${specifier}'`)
|
|
1057
1204
|
}
|
|
1058
1205
|
}
|
|
1059
1206
|
|
|
@@ -1062,14 +1209,19 @@ function writeUsage({ cwd, target, componentsDir, components, alias, dryRun }) {
|
|
|
1062
1209
|
)
|
|
1063
1210
|
|
|
1064
1211
|
if (created) {
|
|
1212
|
+
const children = []
|
|
1213
|
+
entries.forEach((entry, position) => {
|
|
1214
|
+
if (position > 0) children.push('')
|
|
1215
|
+
children.push(...indentLines(entry.jsx, ' '))
|
|
1216
|
+
})
|
|
1065
1217
|
const body = [
|
|
1066
1218
|
orderedImports.join('\n'),
|
|
1067
|
-
entries.map(({ example }) => example.code.trimEnd()).join('\n\n'),
|
|
1068
1219
|
[
|
|
1069
1220
|
`export default function ${entries[0].exportName}Page() {`,
|
|
1221
|
+
...indentLines(uniqueSetup(entries), ' '),
|
|
1070
1222
|
' return (',
|
|
1071
1223
|
' <main className="space-y-6 p-6">',
|
|
1072
|
-
...
|
|
1224
|
+
...children,
|
|
1073
1225
|
' </main>',
|
|
1074
1226
|
' )',
|
|
1075
1227
|
'}',
|
|
@@ -1087,13 +1239,8 @@ function writeUsage({ cwd, target, componentsDir, components, alias, dryRun }) {
|
|
|
1087
1239
|
ok(`${dryRun ? '[dry-run] ' : ''}page created: ${label}`)
|
|
1088
1240
|
} else {
|
|
1089
1241
|
const source = readFileSync(target, 'utf8')
|
|
1090
|
-
const
|
|
1091
|
-
const
|
|
1092
|
-
const { source: withUsage, added } = addJsxUsage(withImports, names)
|
|
1093
|
-
const updated = addExamples(
|
|
1094
|
-
withUsage,
|
|
1095
|
-
entries.map(({ example }) => example),
|
|
1096
|
-
)
|
|
1242
|
+
const { source: withUsage, added } = insertUsage(source, entries, line)
|
|
1243
|
+
const updated = addImports(withUsage, [...imports])
|
|
1097
1244
|
if (!dryRun) {
|
|
1098
1245
|
try {
|
|
1099
1246
|
writeFileSync(target, updated, 'utf8')
|
|
@@ -1102,13 +1249,15 @@ function writeUsage({ cwd, target, componentsDir, components, alias, dryRun }) {
|
|
|
1102
1249
|
}
|
|
1103
1250
|
}
|
|
1104
1251
|
ok(`${dryRun ? '[dry-run] ' : ''}usage added to ${label}`)
|
|
1105
|
-
if (added.length <
|
|
1106
|
-
const already =
|
|
1252
|
+
if (added.length < entries.length) {
|
|
1253
|
+
const already = entries
|
|
1254
|
+
.map((entry) => entry.exportName)
|
|
1255
|
+
.filter((name) => !added.includes(name))
|
|
1107
1256
|
info(`already present, not duplicated: ${already.join(', ')}`)
|
|
1108
1257
|
}
|
|
1109
1258
|
}
|
|
1110
1259
|
|
|
1111
|
-
for (const {
|
|
1260
|
+
for (const { exportName } of entries) info(`· <${exportName} />`)
|
|
1112
1261
|
}
|
|
1113
1262
|
|
|
1114
1263
|
/* -------------------------------------------------------------------------- */
|
|
@@ -1122,15 +1271,26 @@ const ALIASES = {
|
|
|
1122
1271
|
shortcuts: 'command-menu',
|
|
1123
1272
|
upload: 'file-upload',
|
|
1124
1273
|
video: 'video-player',
|
|
1274
|
+
storytelling: 'horizontal-story',
|
|
1275
|
+
stacking: 'stacking-cards',
|
|
1125
1276
|
}
|
|
1126
1277
|
|
|
1127
1278
|
function commandList() {
|
|
1128
|
-
const components =
|
|
1129
|
-
|
|
1279
|
+
const components = listUiComponents()
|
|
1280
|
+
const animations = listAnimationComponents()
|
|
1281
|
+
if (components.length === 0 && animations.length === 0) {
|
|
1282
|
+
fail('No component found in the registry.')
|
|
1283
|
+
}
|
|
1130
1284
|
console.log('\nAvailable components:\n')
|
|
1131
1285
|
for (const name of components) {
|
|
1132
1286
|
console.log(` \x1b[36m${name.padEnd(20)}\x1b[0m src/components/ui/${name}.tsx`)
|
|
1133
1287
|
}
|
|
1288
|
+
if (animations.length > 0) {
|
|
1289
|
+
console.log('\nAvailable animations:\n')
|
|
1290
|
+
for (const name of animations) {
|
|
1291
|
+
console.log(` \x1b[36m${name.padEnd(20)}\x1b[0m src/components/animations/${name}.tsx`)
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1134
1294
|
console.log('\nUsage: uifolio add <name> [--cwd <target project>]\n')
|
|
1135
1295
|
}
|
|
1136
1296
|
|
|
@@ -1142,12 +1302,14 @@ function commandInit({ flags }) {
|
|
|
1142
1302
|
const config = {
|
|
1143
1303
|
$schema: 'https://uifolio.dev/schema.json',
|
|
1144
1304
|
dir: typeof flags.dir === 'string' ? flags.dir : DEFAULT_COMPONENTS_DIR,
|
|
1305
|
+
animations:
|
|
1306
|
+
typeof flags.animations === 'string' ? flags.animations : DEFAULT_ANIMATIONS_DIR,
|
|
1145
1307
|
lib: typeof flags.lib === 'string' ? flags.lib : 'src/lib',
|
|
1146
1308
|
alias: typeof flags.alias === 'string' ? flags.alias : '@',
|
|
1147
1309
|
}
|
|
1148
1310
|
writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`, 'utf8')
|
|
1149
1311
|
ok(`${CONFIG_NAME} written to ${cwd}`)
|
|
1150
|
-
info(`dir: ${config.dir} · lib: ${config.lib} · alias: ${config.alias}`)
|
|
1312
|
+
info(`dir: ${config.dir} · animations: ${config.animations} · lib: ${config.lib} · alias: ${config.alias}`)
|
|
1151
1313
|
info('Next: uifolio add video-player calendar …')
|
|
1152
1314
|
}
|
|
1153
1315
|
|
|
@@ -1193,6 +1355,31 @@ async function chooseUsageTarget({ prompter, cwd, names, exportNames }) {
|
|
|
1193
1355
|
return resolve(cwd, answer)
|
|
1194
1356
|
}
|
|
1195
1357
|
|
|
1358
|
+
/**
|
|
1359
|
+
* Asks where the usage starts in an existing file. The content sitting on that
|
|
1360
|
+
* line moves right after the inserted component, so an empty answer keeps the
|
|
1361
|
+
* automatic placement (inside the returned element).
|
|
1362
|
+
*/
|
|
1363
|
+
async function askInsertLine({ prompter, cwd, target, label }) {
|
|
1364
|
+
const source = readFileSync(target, 'utf8')
|
|
1365
|
+
const lineCount = source.split('\n').length
|
|
1366
|
+
const suggested = defaultInsertion(source).index + 1
|
|
1367
|
+
info(`${relative(cwd, target)} has ${lineCount} lines.`)
|
|
1368
|
+
|
|
1369
|
+
for (;;) {
|
|
1370
|
+
const answer = await prompter.ask(
|
|
1371
|
+
`At which line should the component (${label}) start (the content on that line moves after it)`,
|
|
1372
|
+
String(suggested),
|
|
1373
|
+
)
|
|
1374
|
+
const text = String(answer ?? '').trim()
|
|
1375
|
+
// Empty answer (or the suggestion): automatic placement, no forced line.
|
|
1376
|
+
if (text === '' || text === String(suggested)) return undefined
|
|
1377
|
+
const value = Number.parseInt(text, 10)
|
|
1378
|
+
if (Number.isInteger(value) && value >= 1 && value <= lineCount + 1) return value
|
|
1379
|
+
warn(`Invalid line: ${text}. Use a number between 1 and ${lineCount + 1}.`)
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1196
1383
|
async function commandAdd({ positional, flags }) {
|
|
1197
1384
|
const names = positional.map((name) => ALIASES[name] ?? name)
|
|
1198
1385
|
if (names.length === 0) fail('No component requested. E.g.: uifolio add calendar otp')
|
|
@@ -1227,13 +1414,21 @@ async function commandAdd({ positional, flags }) {
|
|
|
1227
1414
|
|
|
1228
1415
|
const prompter = createPrompter(flags)
|
|
1229
1416
|
try {
|
|
1230
|
-
// 1.
|
|
1417
|
+
// 1. Dossiers d'import : composants, animations et helpers.
|
|
1231
1418
|
const componentsDir = normalizeComponentsDir(
|
|
1232
1419
|
typeof flags.dir === 'string' ? flags.dir : config.dir ?? DEFAULT_COMPONENTS_DIR,
|
|
1233
1420
|
)
|
|
1234
1421
|
if (looksLikeFile(componentsDir)) {
|
|
1235
1422
|
fail(`"${componentsDir}" is a file: give a folder (e.g. ${DEFAULT_COMPONENTS_DIR}).`)
|
|
1236
1423
|
}
|
|
1424
|
+
const animationsDir = normalizeComponentsDir(
|
|
1425
|
+
typeof flags.animations === 'string'
|
|
1426
|
+
? flags.animations
|
|
1427
|
+
: config.animations ?? DEFAULT_ANIMATIONS_DIR,
|
|
1428
|
+
)
|
|
1429
|
+
if (looksLikeFile(animationsDir)) {
|
|
1430
|
+
fail(`"${animationsDir}" is a file: give a folder (e.g. ${DEFAULT_ANIMATIONS_DIR}).`)
|
|
1431
|
+
}
|
|
1237
1432
|
|
|
1238
1433
|
// 2. Copie des fichiers + helpers.
|
|
1239
1434
|
const deps = new Set(LIB_DEPS)
|
|
@@ -1243,14 +1438,26 @@ async function commandAdd({ positional, flags }) {
|
|
|
1243
1438
|
const components = []
|
|
1244
1439
|
|
|
1245
1440
|
for (const name of names) {
|
|
1246
|
-
const entry =
|
|
1247
|
-
const files = collectFiles(entry, REGISTRY_SRC)
|
|
1248
|
-
|
|
1441
|
+
const entry = registryEntry(name)
|
|
1442
|
+
const files = collectFiles(entry.path, REGISTRY_SRC)
|
|
1443
|
+
const dir = entry.kind === 'animations' ? animationsDir : componentsDir
|
|
1444
|
+
components.push({
|
|
1445
|
+
name,
|
|
1446
|
+
exportName: componentExportName(readFileSync(entry.path, 'utf8'), name),
|
|
1447
|
+
dir,
|
|
1448
|
+
})
|
|
1249
1449
|
|
|
1250
1450
|
for (const [absolute, relativePath] of files) {
|
|
1251
|
-
const
|
|
1252
|
-
const
|
|
1253
|
-
const
|
|
1451
|
+
const segments = relativePath.split(sep)
|
|
1452
|
+
const kind = segments[0] === 'components' ? segments[1] : segments[0]
|
|
1453
|
+
const destinationRoot =
|
|
1454
|
+
kind === 'ui'
|
|
1455
|
+
? resolve(cwd, componentsDir)
|
|
1456
|
+
: kind === 'animations'
|
|
1457
|
+
? resolve(cwd, animationsDir)
|
|
1458
|
+
: resolve(cwd, libDir)
|
|
1459
|
+
const inner = kind === 'lib' ? relativePath.slice('lib'.length + 1) : segments.slice(2).join(sep)
|
|
1460
|
+
const destination = join(destinationRoot, inner)
|
|
1254
1461
|
if (destinations.has(destination)) continue
|
|
1255
1462
|
destinations.add(destination)
|
|
1256
1463
|
|
|
@@ -1298,13 +1505,24 @@ async function commandAdd({ positional, flags }) {
|
|
|
1298
1505
|
})
|
|
1299
1506
|
}
|
|
1300
1507
|
if (pageTarget) {
|
|
1508
|
+
const existing =
|
|
1509
|
+
existsSync(pageTarget) && statSync(pageTarget).isFile()
|
|
1510
|
+
let line
|
|
1511
|
+
if (existing && prompter) {
|
|
1512
|
+
line = await askInsertLine({
|
|
1513
|
+
prompter,
|
|
1514
|
+
cwd,
|
|
1515
|
+
target: pageTarget,
|
|
1516
|
+
label: components.map((component) => component.exportName).join(', '),
|
|
1517
|
+
})
|
|
1518
|
+
}
|
|
1301
1519
|
writeUsage({
|
|
1302
1520
|
cwd,
|
|
1303
1521
|
target: pageTarget,
|
|
1304
|
-
componentsDir,
|
|
1305
1522
|
components,
|
|
1306
1523
|
alias: targetAlias,
|
|
1307
1524
|
dryRun,
|
|
1525
|
+
line,
|
|
1308
1526
|
})
|
|
1309
1527
|
}
|
|
1310
1528
|
|
|
@@ -1336,21 +1554,27 @@ function usage() {
|
|
|
1336
1554
|
Options:
|
|
1337
1555
|
--cwd <dir> target project (default: current directory)
|
|
1338
1556
|
--dir <path> components folder (default: src/components/ui)
|
|
1557
|
+
--animations <path> animations folder (default: src/components/animations)
|
|
1339
1558
|
--lib <path> helpers folder (default: src/lib)
|
|
1340
1559
|
--alias <alias> target import alias (default: @)
|
|
1341
1560
|
--color <hex> primary color, written in the Tailwind tokens (@theme)
|
|
1342
1561
|
--radius <px> global radius (0–24) in uifolio-theme.css
|
|
1343
1562
|
--page <path> file where the component is used (e.g. src/pages/calendar.tsx);
|
|
1344
|
-
created if missing, otherwise the import
|
|
1563
|
+
created if missing, otherwise the import and the usage are inlined
|
|
1564
|
+
(the CLI asks on which line the component starts)
|
|
1345
1565
|
--interactive ask the questions outside a terminal
|
|
1346
1566
|
--force overwrite existing files
|
|
1347
1567
|
--dry-run simulate without writing
|
|
1348
1568
|
|
|
1349
1569
|
Without --page, the CLI asks whether a file is already ready to receive the
|
|
1350
1570
|
components: they are added to that file, to a new folder + file, or installed
|
|
1351
|
-
only.
|
|
1352
|
-
|
|
1353
|
-
|
|
1571
|
+
only. Components go to src/components/ui (override with --dir), animations to
|
|
1572
|
+
src/components/animations (override with --animations), and the page imports
|
|
1573
|
+
them through the alias (@/components/ui/..., @/components/animations/...). The
|
|
1574
|
+
usage is written inside the page's own component, with the props, data and
|
|
1575
|
+
state each component needs, so it shows immediately. When the file already
|
|
1576
|
+
exists, the CLI asks on which line the component starts: the content on that
|
|
1577
|
+
line moves right after it.
|
|
1354
1578
|
Colors go through a Tailwind CSS 4 theme (uifolio-theme.css, @theme), so classes
|
|
1355
1579
|
like bg-primary and text-muted-foreground stay in the code. When Tailwind is
|
|
1356
1580
|
missing, "add" installs it (with your lockfile's package manager), creates the
|
|
@@ -1361,6 +1585,7 @@ installed too, as runtime dependencies, when the manifest lacks them.
|
|
|
1361
1585
|
|
|
1362
1586
|
Examples:
|
|
1363
1587
|
node cli/uifolio.mjs add video-player calendar otp --cwd ../my-project
|
|
1588
|
+
node cli/uifolio.mjs add horizontal-accordion storytelling --cwd ../my-project
|
|
1364
1589
|
node cli/uifolio.mjs add otp --color "#10B981" --radius 8
|
|
1365
1590
|
node cli/uifolio.mjs add calendar --page src/pages/calendar.tsx
|
|
1366
1591
|
`)
|