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/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 listComponents() {
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
- /** Extrait les imports locaux (alias) d'un fichier source. */
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 (specifier.startsWith('@/') || specifier.startsWith('~/')) {
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.replace(/^[@~]\//, '')
343
+ const base = specifier.startsWith('.')
344
+ ? resolve(dirname(current), specifier)
345
+ : join(srcRoot, specifier.replace(/^[@~]\//, ''))
309
346
  const candidates = [
310
- join(srcRoot, `${base}.ts`),
311
- join(srcRoot, `${base}.tsx`),
312
- join(srcRoot, base, 'index.ts'),
313
- join(srcRoot, base, 'index.tsx'),
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
- body: ` return (
784
- <AudioPlayer
785
- src="/sample.wav"
786
- title="Test signal"
787
- subtitle="Generated loop · 8 s · WAV"
788
- accent="#10B981"
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
- body: ` return <VideoPlayer sources="/sample-720.mp4" aspectRatio="16/9" muted loop />
795
- `,
838
+ jsx: '<VideoPlayer sources="/sample-720.mp4" aspectRatio="16/9" muted loop />',
796
839
  },
797
840
  'file-upload': {
798
- body: ` return <FileUpload accept="image/*,video/*" maxSizeMb={25} maxFiles={3} />
799
- `,
841
+ jsx: '<FileUpload accept="image/*,video/*" maxSizeMb={25} maxFiles={3} />',
800
842
  },
801
843
  choice: {
802
844
  imports: ["import { useState } from 'react'"],
803
- body: ` const [checked, setChecked] = useState(true)
804
-
805
- return (
806
- <Choice
807
- checked={checked}
808
- onChange={setChecked}
809
- label="Email alerts"
810
- description="Get a summary every week."
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
- body: ` const [open, setOpen] = useState(false)
818
-
819
- return (
820
- <div className="space-y-3">
821
- <button
822
- type="button"
823
- onClick={() => setOpen(true)}
824
- className="rounded-full bg-primary px-4 py-2 text-xs font-medium text-primary-foreground"
825
- >
826
- Open the palette
827
- </button>
828
- <CommandMenu
829
- open={open}
830
- onOpenChange={setOpen}
831
- items={[
832
- { id: 'home', label: 'Home', group: 'Navigation', action: () => {} },
833
- { id: 'components', label: 'Components', group: 'Navigation', action: () => {} },
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
- body: ` const [open, setOpen] = useState(false)
844
- const items: StoryItem[] = [
845
- { type: 'image', src: '/story-1.svg', title: 'Dawn', duration: 5000 },
846
- { type: 'text', title: 'Text only', text: 'A story can be plain text.', duration: 5000 },
847
- ]
848
-
849
- return (
850
- <div className="space-y-3">
851
- <button
852
- type="button"
853
- onClick={() => setOpen(true)}
854
- className="rounded-full bg-primary px-4 py-2 text-xs font-medium text-primary-foreground"
855
- >
856
- Open the stories
857
- </button>
858
- <Stories items={items} open={open} onOpenChange={setOpen} />
859
- </div>
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
- body: ` return (
866
- <ResizablePanelGroup className="h-64 rounded-xl border">
867
- <ResizablePanel className="p-4">Left panel</ResizablePanel>
868
- <ResizableHandle />
869
- <ResizablePanel className="p-4">Right panel</ResizablePanel>
870
- </ResizablePanelGroup>
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
- body: ` return (
876
- <FadeIn>
877
- <p className="text-sm">Fade in on mount.</p>
878
- </FadeIn>
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
- body: ` const [selected, setSelected] = useState<string[]>([])
886
- type Row = { id: string; name: string; status: string; total: number }
887
- const rows: Row[] = [
888
- { id: 'finanx', name: 'Finanx', status: 'Active', total: 0 },
889
- { id: 'facebook', name: 'Facebook', status: 'Active', total: 408221304 },
890
- ]
891
- const columns: TableColumn<Row>[] = [
892
- { key: 'name', header: 'Name', cell: (row) => row.name },
893
- { key: 'status', header: 'Status', cell: (row) => row.status },
894
- { key: 'total', header: 'Total received', cell: (row) => row.total },
895
- ]
896
-
897
- return (
898
- <Table
899
- rows={rows}
900
- columns={columns}
901
- rowKey={(row) => row.id}
902
- selectable
903
- selectedKeys={selected}
904
- onSelectionChange={setSelected}
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
- /** Example component written next to the page, plus the imports it needs. */
912
- function exampleFor({ name, exportName }) {
913
- const spec = USAGE_EXAMPLES[name]
914
- const component = `${exportName}Example`
915
- if (!spec) {
916
- return {
917
- component,
918
- imports: [],
919
- types: [],
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
- component,
982
+ exportName,
926
983
  imports: spec.imports ?? [],
927
984
  types: spec.types ?? [],
928
985
  members: spec.members ?? [],
929
- code: `function ${component}() {\n${spec.body}}\n`,
986
+ setup: blockLines(spec.setup),
987
+ jsx: blockLines(spec.jsx ?? `<${exportName} />`),
930
988
  }
931
989
  }
932
990
 
933
- /** Appends the example components that are not in the file yet. */
934
- function addExamples(source, examples) {
935
- const missing = examples.filter(
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
- /** Inserts the imports after the last import of the file. */
943
- function addImports(source, lines) {
944
- const missing = lines.filter((line) => !source.includes(line))
945
- if (missing.length === 0) return source
946
-
947
- const all = source.split('\n')
948
- let last = -1
949
- for (let index = 0; index < all.length; index += 1) {
950
- if (/^\s*import\s/.test(all[index])) last = index
951
- }
952
- if (last === -1) return `${missing.join('\n')}\n\n${source}`
953
-
954
- // An import can span several lines: advance to the end of it.
955
- let end = last
956
- while (
957
- end < all.length - 1 &&
958
- end - last < 20 &&
959
- !/from\s+['"][^'"]+['"];?\s*$/.test(all[end]) &&
960
- !/^import\s+['"][^'"]+['"];?\s*$/.test(all[end])
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
- /** Adds `<Composant />` in the returned JSX, otherwise an exported demo. */
993
- function addJsxUsage(source, exportNames) {
994
- const missing = exportNames.filter((name) => !new RegExp(`<${name}[\\s/>]`).test(source))
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
- const returnMatch = /\breturn\b/.exec(source)
998
- let cursor = returnMatch ? returnMatch.index + returnMatch[0].length : -1
999
- while (cursor !== -1 && cursor < source.length && /[\s(]/.test(source[cursor])) cursor += 1
1000
- const rootIndex = cursor !== -1 && source[cursor] === '<' ? cursor : -1
1001
- const tag = rootIndex === -1 ? null : findOpeningTagEnd(source, rootIndex)
1002
-
1003
- if (tag) {
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
- // Racine auto-fermante : on l'enveloppe dans un fragment.
1016
- if (tag.selfClosing) {
1017
- const element = source.slice(rootIndex, tag.end + 1)
1018
- const next =
1019
- `${source.slice(0, rootIndex)}<>\n${children}\n${indent}${element}\n${indent}</>${source.slice(tag.end + 1)}`
1020
- return { source: next, added: missing }
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
- // Opening element: the components become its first children.
1024
- const next = `${source.slice(0, tag.end + 1)}\n${children}${source.slice(tag.end + 1)}`
1025
- return { source: next, added: missing }
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
- // JSX introuvable (page sans `return` JSX) : on laisse un exemple utilisable.
1029
- const example = missing
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, a working example (`<CalendarExample />`) and its definition.
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, componentsDir, components, alias, dryRun }) {
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
- exportName: component.exportName,
1046
- specifier: aliasImport(cwd, componentsDir, component.name, alias),
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, example } of entries) {
1053
- for (const line of example.imports) imports.add(line)
1054
- imports.add(`import { ${[exportName, ...example.members].join(', ')} } from '${specifier}'`)
1055
- if (example.types.length > 0) {
1056
- imports.add(`import type { ${example.types.join(', ')} } from '${specifier}'`)
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
- ...entries.map(({ example }) => ` <${example.component} />`),
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 withImports = addImports(source, [...imports])
1091
- const names = entries.map(({ example }) => example.component)
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 < names.length) {
1106
- const already = names.filter((name) => !added.includes(name))
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 { example } of entries) info(`· <${example.component} />`)
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 = listComponents()
1129
- if (components.length === 0) fail('No component found in the registry.')
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. Dossier d'import des composants : toujours src/components/ui.
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 = join(REGISTRY_UI, `${name}.tsx`)
1247
- const files = collectFiles(entry, REGISTRY_SRC)
1248
- components.push({ name, exportName: componentExportName(readFileSync(entry, 'utf8'), name) })
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 isUi = relativePath.startsWith(join('components', 'ui'))
1252
- const destinationRoot = isUi ? resolve(cwd, componentsDir) : resolve(cwd, libDir)
1253
- const destination = join(destinationRoot, relativePath.replace(/^(components\/ui|lib)\//, ''))
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 + <Component /> are added
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. Files go to src/components/ui (override with --dir) and the page imports
1352
- them through the alias (@/components/ui/...). The page also gets a working
1353
- example, with the data and props each component needs, so it shows immediately.
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
  `)