uifolio 0.7.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.
Files changed (3) hide show
  1. package/README.md +28 -5
  2. package/cli/uifolio.mjs +339 -221
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -39,13 +39,36 @@ Options: `--cwd <dir>`, `--dir <path>` (default `src/components/ui`), `--animati
39
39
  `src/components/animations` (override with `--animations`), and the page imports
40
40
  them through the `@/` alias. Without `--page`, the CLI asks where to use them:
41
41
 
42
- 1. *Do you already have a file ready to receive the component (…component…) (y/N) ?* `yes` → *In which file should the component (…component…) be copied (e.g. src/pages/calendar.tsx) ?*: the import and a ready-to-render example are added without duplication, and the file is created when missing;
42
+ 1. *Do you already have a file ready to receive the component (…component…) (y/N) ?* `yes` → *In which file should the component (…component…) be copied (e.g. src/pages/calendar.tsx) ?*: the import and a ready-to-render usage are added without duplication, and the file is created when missing. When the file already exists, the CLI also asks *At which line should the component (…component…) start (the content on that line moves after it) ?* (an empty answer keeps the automatic placement);
43
43
  2. `no` → *Would you like to create a new folder and an import file (Y/n) ?* `yes` → *Please specify the new path (e.g. src/pages/calendar.tsx) ?*: the folder and the file are created; `no` → install only, no file is touched.
44
44
 
45
- The example is a small component (`<CalendarExample />` plus its definition)
46
- that carries the data and props each component needs (items for `Stories` and
47
- `CommandMenu`, a source for the players, children for the resizable panels…),
48
- so the component is visible immediately instead of an empty `<Component />`.
45
+ The usage is written **inside the page's own component**, not in a separate
46
+ `<CalendarExample />` helper: the element, its props and the state or data it
47
+ needs (items for `Stories` and `CommandMenu`, a source for the players,
48
+ children for the resizable panels…) are inlined where the component is added,
49
+ so it is visible immediately instead of an empty `<Component />`. The state and
50
+ data go at the top of the component body, before its `return`.
51
+
52
+ ```tsx
53
+ 'use client'
54
+
55
+ import { useState } from 'react'
56
+ import { Choice } from '@/components/ui/choice'
57
+
58
+ export default function Home() {
59
+ const [checked, setChecked] = useState(true)
60
+ return (
61
+ <main>
62
+ <Choice
63
+ checked={checked}
64
+ onChange={setChecked}
65
+ label="Email alerts"
66
+ description="Get a summary every week."
67
+ />
68
+ </main>
69
+ )
70
+ }
71
+ ```
49
72
 
50
73
  Answering `-` to a path question installs the component without creating or
51
74
  modifying any page.
package/cli/uifolio.mjs CHANGED
@@ -815,226 +815,205 @@ function aliasImport(cwd, componentsDir, name, alias) {
815
815
  * the animated wrappers that require children, so these examples carry the
816
816
  * props and data each one needs.
817
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
+ */
818
826
  const USAGE_EXAMPLES = {
819
827
  'audio-player': {
820
- body: ` return (
821
- <AudioPlayer
822
- src="/sample.wav"
823
- title="Test signal"
824
- subtitle="Generated loop · 8 s · WAV"
825
- accent="#10B981"
826
- />
827
- )
828
+ jsx: `
829
+ <AudioPlayer
830
+ src="/sample.wav"
831
+ title="Test signal"
832
+ subtitle="Generated loop · 8 s · WAV"
833
+ accent="#10B981"
834
+ />
828
835
  `,
829
836
  },
830
837
  'video-player': {
831
- body: ` return <VideoPlayer sources="/sample-720.mp4" aspectRatio="16/9" muted loop />
832
- `,
838
+ jsx: '<VideoPlayer sources="/sample-720.mp4" aspectRatio="16/9" muted loop />',
833
839
  },
834
840
  'file-upload': {
835
- body: ` return <FileUpload accept="image/*,video/*" maxSizeMb={25} maxFiles={3} />
836
- `,
841
+ jsx: '<FileUpload accept="image/*,video/*" maxSizeMb={25} maxFiles={3} />',
837
842
  },
838
843
  choice: {
839
844
  imports: ["import { useState } from 'react'"],
840
- body: ` const [checked, setChecked] = useState(true)
841
-
842
- return (
843
- <Choice
844
- checked={checked}
845
- onChange={setChecked}
846
- label="Email alerts"
847
- description="Get a summary every week."
848
- />
849
- )
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
+ />
850
853
  `,
851
854
  },
852
855
  'command-menu': {
853
856
  imports: ["import { useState } from 'react'"],
854
- body: ` const [open, setOpen] = useState(false)
855
-
856
- return (
857
- <div className="space-y-3">
858
- <button
859
- type="button"
860
- onClick={() => setOpen(true)}
861
- className="rounded-full bg-primary px-4 py-2 text-xs font-medium text-primary-foreground"
862
- >
863
- Open the palette
864
- </button>
865
- <CommandMenu
866
- open={open}
867
- onOpenChange={setOpen}
868
- items={[
869
- { id: 'home', label: 'Home', group: 'Navigation', action: () => {} },
870
- { id: 'components', label: 'Components', group: 'Navigation', action: () => {} },
871
- ]}
872
- />
873
- </div>
874
- )
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>
875
876
  `,
876
877
  },
877
878
  stories: {
878
879
  imports: ["import { useState } from 'react'"],
879
880
  types: ['StoryItem'],
880
- body: ` const [open, setOpen] = useState(false)
881
- const items: StoryItem[] = [
882
- { type: 'image', src: '/story-1.svg', title: 'Dawn', duration: 5000 },
883
- { type: 'text', title: 'Text only', text: 'A story can be plain text.', duration: 5000 },
884
- ]
885
-
886
- return (
887
- <div className="space-y-3">
888
- <button
889
- type="button"
890
- onClick={() => setOpen(true)}
891
- className="rounded-full bg-primary px-4 py-2 text-xs font-medium text-primary-foreground"
892
- >
893
- Open the stories
894
- </button>
895
- <Stories items={items} open={open} onOpenChange={setOpen} />
896
- </div>
897
- )
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>
898
899
  `,
899
900
  },
900
901
  resizable: {
901
902
  members: ['ResizablePanel', 'ResizableHandle'],
902
- body: ` return (
903
- <ResizablePanelGroup className="h-64 rounded-xl border">
904
- <ResizablePanel className="p-4">Left panel</ResizablePanel>
905
- <ResizableHandle />
906
- <ResizablePanel className="p-4">Right panel</ResizablePanel>
907
- </ResizablePanelGroup>
908
- )
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>
909
909
  `,
910
910
  },
911
911
  motion: {
912
- body: ` return (
913
- <FadeIn>
914
- <p className="text-sm">Fade in on mount.</p>
915
- </FadeIn>
916
- )
912
+ jsx: `
913
+ <FadeIn>
914
+ <p className="text-sm">Fade in on mount.</p>
915
+ </FadeIn>
917
916
  `,
918
917
  },
919
918
  'horizontal-accordion': {
920
- body: ` return <HorizontalAccordion count={6} />
921
- `,
919
+ jsx: '<HorizontalAccordion count={6} />',
922
920
  },
923
921
  'horizontal-story': {
924
- body: ` return (
925
- <HorizontalStory
926
- count={6}
927
- palette={{ mode: 'gradient', from: '#0EA5E9', to: '#8B5CF6' }}
928
- />
929
- )
922
+ jsx: `
923
+ <HorizontalStory
924
+ count={6}
925
+ palette={{ mode: 'gradient', from: '#0EA5E9', to: '#8B5CF6' }}
926
+ />
930
927
  `,
931
928
  },
932
929
  'stacking-cards': {
933
- body: ` return (
934
- <StackingCards count={6} palette={{ mode: 'solid', color: '#10B981' }} />
935
- )
936
- `,
930
+ jsx: `<StackingCards count={6} palette={{ mode: 'solid', color: '#10B981' }} />`,
937
931
  },
938
932
  'scroll-text': {
939
- body: ` return (
940
- <ScrollText
941
- prefix="C"
942
- text="Colors light up letter by letter as you scroll."
943
- />
944
- )
933
+ jsx: `
934
+ <ScrollText
935
+ prefix="C"
936
+ text="Colors light up letter by letter as you scroll."
937
+ />
945
938
  `,
946
939
  },
947
940
  'image-crossfade': {
948
- body: ` return (
949
- <ImageCrossfade overlay="linear-gradient(to top, rgba(0,0,0,0.7), transparent)" />
950
- )
951
- `,
941
+ jsx: '<ImageCrossfade overlay="linear-gradient(to top, rgba(0,0,0,0.7), transparent)" />',
952
942
  },
953
943
  table: {
954
944
  imports: ["import { useState } from 'react'"],
955
945
  members: ['type TableColumn'],
956
- body: ` const [selected, setSelected] = useState<string[]>([])
957
- type Row = { id: string; name: string; status: string; total: number }
958
- const rows: Row[] = [
959
- { id: 'finanx', name: 'Finanx', status: 'Active', total: 0 },
960
- { id: 'facebook', name: 'Facebook', status: 'Active', total: 408221304 },
961
- ]
962
- const columns: TableColumn<Row>[] = [
963
- { key: 'name', header: 'Name', cell: (row) => row.name },
964
- { key: 'status', header: 'Status', cell: (row) => row.status },
965
- { key: 'total', header: 'Total received', cell: (row) => row.total },
966
- ]
967
-
968
- return (
969
- <Table
970
- rows={rows}
971
- columns={columns}
972
- rowKey={(row) => row.id}
973
- selectable
974
- selectedKeys={selected}
975
- onSelectionChange={setSelected}
976
- />
977
- )
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
+ />
978
968
  `,
979
969
  },
980
970
  }
981
971
 
982
- /** Example component written next to the page, plus the imports it needs. */
983
- function exampleFor({ name, exportName }) {
984
- const spec = USAGE_EXAMPLES[name]
985
- const component = `${exportName}Example`
986
- if (!spec) {
987
- return {
988
- component,
989
- imports: [],
990
- types: [],
991
- members: [],
992
- code: `function ${component}() {\n return <${exportName} />\n}\n`,
993
- }
994
- }
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] ?? {}
995
981
  return {
996
- component,
982
+ exportName,
997
983
  imports: spec.imports ?? [],
998
984
  types: spec.types ?? [],
999
985
  members: spec.members ?? [],
1000
- code: `function ${component}() {\n${spec.body}}\n`,
986
+ setup: blockLines(spec.setup),
987
+ jsx: blockLines(spec.jsx ?? `<${exportName} />`),
1001
988
  }
1002
989
  }
1003
990
 
1004
- /** Appends the example components that are not in the file yet. */
1005
- function addExamples(source, examples) {
1006
- const missing = examples.filter(
1007
- ({ component }) => !new RegExp(`function\\s+${component}\\s*\\(`).test(source),
1008
- )
1009
- if (missing.length === 0) return source
1010
- 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}`))
1011
994
  }
1012
995
 
1013
- /** Inserts the imports after the last import of the file. */
1014
- function addImports(source, lines) {
1015
- const missing = lines.filter((line) => !source.includes(line))
1016
- if (missing.length === 0) return source
1017
-
1018
- const all = source.split('\n')
1019
- let last = -1
1020
- for (let index = 0; index < all.length; index += 1) {
1021
- if (/^\s*import\s/.test(all[index])) last = index
1022
- }
1023
- if (last === -1) return `${missing.join('\n')}\n\n${source}`
1024
-
1025
- // An import can span several lines: advance to the end of it.
1026
- let end = last
1027
- while (
1028
- end < all.length - 1 &&
1029
- end - last < 20 &&
1030
- !/from\s+['"][^'"]+['"];?\s*$/.test(all[end]) &&
1031
- !/^import\s+['"][^'"]+['"];?\s*$/.test(all[end])
1032
- ) {
1033
- 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
+ }
1034
1015
  }
1035
-
1036
- all.splice(end + 1, 0, ...missing)
1037
- return all.join('\n')
1016
+ return lines
1038
1017
  }
1039
1018
 
1040
1019
  /**
@@ -1060,71 +1039,168 @@ function findOpeningTagEnd(source, start) {
1060
1039
  return null
1061
1040
  }
1062
1041
 
1063
- /** Adds `<Composant />` in the returned JSX, otherwise an exported demo. */
1064
- function addJsxUsage(source, exportNames) {
1065
- 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
+ )
1066
1107
  if (missing.length === 0) return { source, added: [] }
1067
1108
 
1068
- const returnMatch = /\breturn\b/.exec(source)
1069
- let cursor = returnMatch ? returnMatch.index + returnMatch[0].length : -1
1070
- while (cursor !== -1 && cursor < source.length && /[\s(]/.test(source[cursor])) cursor += 1
1071
- const rootIndex = cursor !== -1 && source[cursor] === '<' ? cursor : -1
1072
- const tag = rootIndex === -1 ? null : findOpeningTagEnd(source, rootIndex)
1073
-
1074
- if (tag) {
1075
- const lineStart = source.lastIndexOf('\n', rootIndex) + 1
1076
- const indent = /^\s*/.exec(source.slice(lineStart, rootIndex))?.[0] ?? ''
1077
- const childIndent = `${indent} `
1078
- const children = missing.map((name) => `${childIndent}<${name} />`).join('\n')
1079
-
1080
- // Fragment already present: insert right after `<>`.
1081
- if (source.startsWith('<>', rootIndex)) {
1082
- const next = `${source.slice(0, rootIndex + 2)}\n${children}${source.slice(rootIndex + 2)}`
1083
- return { source: next, added: missing }
1084
- }
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))
1085
1116
 
1086
- // Racine auto-fermante : on l'enveloppe dans un fragment.
1087
- if (tag.selfClosing) {
1088
- const element = source.slice(rootIndex, tag.end + 1)
1089
- const next =
1090
- `${source.slice(0, rootIndex)}<>\n${children}\n${indent}${element}\n${indent}</>${source.slice(tag.end + 1)}`
1091
- 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
+ }
1092
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
+ }
1150
+
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
1093
1159
 
1094
- // Opening element: the components become its first children.
1095
- const next = `${source.slice(0, tag.end + 1)}\n${children}${source.slice(tag.end + 1)}`
1096
- return { source: next, added: missing }
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
1097
1164
  }
1165
+ if (last === -1) return `${missing.join('\n')}\n\n${source}`
1098
1166
 
1099
- // JSX introuvable (page sans `return` JSX) : on laisse un exemple utilisable.
1100
- const example = missing
1101
- .map((name) => `\nexport function ${name}Example() {\n return <${name} />\n}\n`)
1102
- .join('\n')
1103
- return { source: `${source.trimEnd()}\n${example}`, added: missing }
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
1176
+ }
1177
+
1178
+ all.splice(end + 1, 0, ...missing)
1179
+ return all.join('\n')
1104
1180
  }
1105
1181
 
1106
1182
  /**
1107
1183
  * Writes (or completes) the chosen file: `@/` imports from the real folder of
1108
- * 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.
1109
1186
  */
1110
- function writeUsage({ cwd, target, components, alias, dryRun }) {
1187
+ function writeUsage({ cwd, target, components, alias, dryRun, line }) {
1111
1188
  if (existsSync(target) && statSync(target).isDirectory()) {
1112
1189
  fail(`${relative(cwd, target)} is a folder: give a file (e.g. src/pages/${components[0].name}.tsx).`)
1113
1190
  }
1114
1191
  const created = !existsSync(target)
1115
1192
  const entries = components.map((component) => ({
1116
- exportName: component.exportName,
1193
+ ...usageFor(component),
1117
1194
  specifier: aliasImport(cwd, component.dir, component.name, alias),
1118
- example: exampleFor(component),
1119
1195
  }))
1120
1196
  const label = relative(cwd, target)
1121
1197
 
1122
1198
  const imports = new Set()
1123
- for (const { exportName, specifier, example } of entries) {
1124
- for (const line of example.imports) imports.add(line)
1125
- imports.add(`import { ${[exportName, ...example.members].join(', ')} } from '${specifier}'`)
1126
- if (example.types.length > 0) {
1127
- 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}'`)
1128
1204
  }
1129
1205
  }
1130
1206
 
@@ -1133,14 +1209,19 @@ function writeUsage({ cwd, target, components, alias, dryRun }) {
1133
1209
  )
1134
1210
 
1135
1211
  if (created) {
1212
+ const children = []
1213
+ entries.forEach((entry, position) => {
1214
+ if (position > 0) children.push('')
1215
+ children.push(...indentLines(entry.jsx, ' '))
1216
+ })
1136
1217
  const body = [
1137
1218
  orderedImports.join('\n'),
1138
- entries.map(({ example }) => example.code.trimEnd()).join('\n\n'),
1139
1219
  [
1140
1220
  `export default function ${entries[0].exportName}Page() {`,
1221
+ ...indentLines(uniqueSetup(entries), ' '),
1141
1222
  ' return (',
1142
1223
  ' <main className="space-y-6 p-6">',
1143
- ...entries.map(({ example }) => ` <${example.component} />`),
1224
+ ...children,
1144
1225
  ' </main>',
1145
1226
  ' )',
1146
1227
  '}',
@@ -1158,13 +1239,8 @@ function writeUsage({ cwd, target, components, alias, dryRun }) {
1158
1239
  ok(`${dryRun ? '[dry-run] ' : ''}page created: ${label}`)
1159
1240
  } else {
1160
1241
  const source = readFileSync(target, 'utf8')
1161
- const withImports = addImports(source, [...imports])
1162
- const names = entries.map(({ example }) => example.component)
1163
- const { source: withUsage, added } = addJsxUsage(withImports, names)
1164
- const updated = addExamples(
1165
- withUsage,
1166
- entries.map(({ example }) => example),
1167
- )
1242
+ const { source: withUsage, added } = insertUsage(source, entries, line)
1243
+ const updated = addImports(withUsage, [...imports])
1168
1244
  if (!dryRun) {
1169
1245
  try {
1170
1246
  writeFileSync(target, updated, 'utf8')
@@ -1173,13 +1249,15 @@ function writeUsage({ cwd, target, components, alias, dryRun }) {
1173
1249
  }
1174
1250
  }
1175
1251
  ok(`${dryRun ? '[dry-run] ' : ''}usage added to ${label}`)
1176
- if (added.length < names.length) {
1177
- 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))
1178
1256
  info(`already present, not duplicated: ${already.join(', ')}`)
1179
1257
  }
1180
1258
  }
1181
1259
 
1182
- for (const { example } of entries) info(`· <${example.component} />`)
1260
+ for (const { exportName } of entries) info(`· <${exportName} />`)
1183
1261
  }
1184
1262
 
1185
1263
  /* -------------------------------------------------------------------------- */
@@ -1277,6 +1355,31 @@ async function chooseUsageTarget({ prompter, cwd, names, exportNames }) {
1277
1355
  return resolve(cwd, answer)
1278
1356
  }
1279
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
+
1280
1383
  async function commandAdd({ positional, flags }) {
1281
1384
  const names = positional.map((name) => ALIASES[name] ?? name)
1282
1385
  if (names.length === 0) fail('No component requested. E.g.: uifolio add calendar otp')
@@ -1402,12 +1505,24 @@ async function commandAdd({ positional, flags }) {
1402
1505
  })
1403
1506
  }
1404
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
+ }
1405
1519
  writeUsage({
1406
1520
  cwd,
1407
1521
  target: pageTarget,
1408
1522
  components,
1409
1523
  alias: targetAlias,
1410
1524
  dryRun,
1525
+ line,
1411
1526
  })
1412
1527
  }
1413
1528
 
@@ -1445,7 +1560,8 @@ Options:
1445
1560
  --color <hex> primary color, written in the Tailwind tokens (@theme)
1446
1561
  --radius <px> global radius (0–24) in uifolio-theme.css
1447
1562
  --page <path> file where the component is used (e.g. src/pages/calendar.tsx);
1448
- 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)
1449
1565
  --interactive ask the questions outside a terminal
1450
1566
  --force overwrite existing files
1451
1567
  --dry-run simulate without writing
@@ -1455,8 +1571,10 @@ components: they are added to that file, to a new folder + file, or installed
1455
1571
  only. Components go to src/components/ui (override with --dir), animations to
1456
1572
  src/components/animations (override with --animations), and the page imports
1457
1573
  them through the alias (@/components/ui/..., @/components/animations/...). The
1458
- page also gets a working example, with the data and props each component needs,
1459
- so it shows immediately.
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.
1460
1578
  Colors go through a Tailwind CSS 4 theme (uifolio-theme.css, @theme), so classes
1461
1579
  like bg-primary and text-muted-foreground stay in the code. When Tailwind is
1462
1580
  missing, "add" installs it (with your lockfile's package manager), creates the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uifolio",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "CLI that copies React components (calendar, video/audio players, OTP, editor, upload) and their helpers into your project, with Tailwind CSS 4 tokens.",
5
5
  "type": "module",
6
6
  "license": "MIT",