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.
- package/README.md +28 -5
- package/cli/uifolio.mjs +339 -221
- 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
|
|
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
|
|
46
|
-
|
|
47
|
-
`CommandMenu`, a source for the players,
|
|
48
|
-
|
|
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
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
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
|
-
|
|
832
|
-
`,
|
|
838
|
+
jsx: '<VideoPlayer sources="/sample-720.mp4" aspectRatio="16/9" muted loop />',
|
|
833
839
|
},
|
|
834
840
|
'file-upload': {
|
|
835
|
-
|
|
836
|
-
`,
|
|
841
|
+
jsx: '<FileUpload accept="image/*,video/*" maxSizeMb={25} maxFiles={3} />',
|
|
837
842
|
},
|
|
838
843
|
choice: {
|
|
839
844
|
imports: ["import { useState } from 'react'"],
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
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
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
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
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
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
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
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
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
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
|
-
|
|
921
|
-
`,
|
|
919
|
+
jsx: '<HorizontalAccordion count={6} />',
|
|
922
920
|
},
|
|
923
921
|
'horizontal-story': {
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
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
|
-
|
|
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
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
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
|
-
|
|
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
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
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
|
-
/**
|
|
983
|
-
function
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
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
|
-
|
|
982
|
+
exportName,
|
|
997
983
|
imports: spec.imports ?? [],
|
|
998
984
|
types: spec.types ?? [],
|
|
999
985
|
members: spec.members ?? [],
|
|
1000
|
-
|
|
986
|
+
setup: blockLines(spec.setup),
|
|
987
|
+
jsx: blockLines(spec.jsx ?? `<${exportName} />`),
|
|
1001
988
|
}
|
|
1002
989
|
}
|
|
1003
990
|
|
|
1004
|
-
/**
|
|
1005
|
-
function
|
|
1006
|
-
|
|
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
|
-
/**
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
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
|
-
/**
|
|
1064
|
-
|
|
1065
|
-
|
|
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
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
const
|
|
1073
|
-
|
|
1074
|
-
|
|
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
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
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
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
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
|
-
//
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
.
|
|
1103
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
1124
|
-
for (const
|
|
1125
|
-
imports.add(`import { ${[exportName, ...
|
|
1126
|
-
if (
|
|
1127
|
-
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}'`)
|
|
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
|
-
...
|
|
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
|
|
1162
|
-
const
|
|
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 <
|
|
1177
|
-
const already =
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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.
|
|
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",
|