@esuiss/ui 0.1.0 → 0.2.0
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 +3 -1
- package/index.mjs +90 -169
- package/lib/add.mjs +115 -0
- package/lib/diff.mjs +61 -0
- package/lib/init.mjs +233 -0
- package/lib/output.mjs +42 -0
- package/lib/project.mjs +105 -0
- package/lib/registry.mjs +47 -0
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @esuiss/ui
|
|
2
2
|
|
|
3
|
-
Add esuiss-ui components to
|
|
3
|
+
Add esuiss-ui components to Next.js and React (Vite) projects.
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
6
|
npx @esuiss/ui@latest init
|
|
@@ -8,3 +8,5 @@ npx @esuiss/ui@latest add sidebar
|
|
|
8
8
|
npx @esuiss/ui@latest add button --diff
|
|
9
9
|
npx @esuiss/ui@latest add button --overwrite
|
|
10
10
|
```
|
|
11
|
+
|
|
12
|
+
`init` sets up Tailwind CSS v4, the `@/*` import alias, the theme stylesheet, and the button. Requires TypeScript.
|
package/index.mjs
CHANGED
|
@@ -1,186 +1,107 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
existsSync,
|
|
5
|
-
readdirSync,
|
|
6
|
-
readFileSync,
|
|
7
|
-
statSync,
|
|
8
|
-
writeFileSync,
|
|
9
|
-
} from "node:fs"
|
|
2
|
+
import { readFileSync } from "node:fs"
|
|
10
3
|
import path from "node:path"
|
|
11
4
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
const VALUE_FLAGS = new Set(["-c", "--cwd", "-p", "--path", "--diff", "--view"])
|
|
16
|
-
const UPSTREAM_CSS_IMPORT =
|
|
17
|
-
/^@import\s+["']shadcn\/tailwind\.css["'];?[^\S\n]*\n?/m
|
|
18
|
-
const UPSTREAM_PACKAGE = "shadcn"
|
|
5
|
+
import { add } from "./lib/add.mjs"
|
|
6
|
+
import { init } from "./lib/init.mjs"
|
|
7
|
+
import { CliError, color } from "./lib/output.mjs"
|
|
19
8
|
|
|
20
|
-
const { version } = JSON.parse(
|
|
9
|
+
const { name, version } = JSON.parse(
|
|
21
10
|
readFileSync(new URL("./package.json", import.meta.url), "utf8")
|
|
22
11
|
)
|
|
23
12
|
|
|
24
|
-
const usage =
|
|
13
|
+
const usage = `${name} ${version}
|
|
25
14
|
|
|
26
15
|
Usage:
|
|
27
|
-
${
|
|
28
|
-
${
|
|
29
|
-
|
|
30
|
-
Options
|
|
31
|
-
-o, --overwrite
|
|
32
|
-
--diff
|
|
33
|
-
--
|
|
34
|
-
-y, --yes
|
|
35
|
-
-c, --cwd <
|
|
36
|
-
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
function run(args) {
|
|
49
|
-
exec("npx", ["--yes", "shadcn@latest", ...args])
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function toAddresses(args) {
|
|
53
|
-
return args.map((arg, index) => {
|
|
54
|
-
const previous = args[index - 1]
|
|
55
|
-
const isValue = previous !== undefined && VALUE_FLAGS.has(previous)
|
|
56
|
-
if (arg.startsWith("-") || isValue || arg.includes("/")) return arg
|
|
57
|
-
return `${REGISTRY}/${arg}`
|
|
58
|
-
})
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function flagValue(args, ...flags) {
|
|
62
|
-
const index = args.findIndex((arg) => flags.includes(arg))
|
|
63
|
-
return index === -1 ? undefined : args[index + 1]
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function hasFlag(args, ...flags) {
|
|
67
|
-
return args.some((arg) => flags.includes(arg))
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function isProject(dir) {
|
|
71
|
-
return existsSync(path.join(dir, "components.json"))
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function findProject(baseDir, name) {
|
|
75
|
-
if (name && isProject(path.resolve(baseDir, name)))
|
|
76
|
-
return path.resolve(baseDir, name)
|
|
77
|
-
if (isProject(baseDir)) return baseDir
|
|
78
|
-
const candidates = readdirSync(baseDir, { withFileTypes: true })
|
|
79
|
-
.filter(
|
|
80
|
-
(entry) =>
|
|
81
|
-
entry.isDirectory() && isProject(path.join(baseDir, entry.name))
|
|
82
|
-
)
|
|
83
|
-
.map((entry) => path.join(baseDir, entry.name))
|
|
84
|
-
.sort(
|
|
85
|
-
(a, b) =>
|
|
86
|
-
statSync(path.join(b, "components.json")).mtimeMs -
|
|
87
|
-
statSync(path.join(a, "components.json")).mtimeMs
|
|
88
|
-
)
|
|
89
|
-
return candidates[0]
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
function packageManager(dir) {
|
|
93
|
-
const lockfiles = [
|
|
94
|
-
["pnpm-lock.yaml", "pnpm", "remove"],
|
|
95
|
-
["bun.lock", "bun", "remove"],
|
|
96
|
-
["bun.lockb", "bun", "remove"],
|
|
97
|
-
["yarn.lock", "yarn", "remove"],
|
|
98
|
-
]
|
|
99
|
-
for (let current = dir; ; current = path.dirname(current)) {
|
|
100
|
-
const match = lockfiles.find(([file]) =>
|
|
101
|
-
existsSync(path.join(current, file))
|
|
102
|
-
)
|
|
103
|
-
if (match) return match.slice(1)
|
|
104
|
-
if (path.dirname(current) === current) return ["npm", "uninstall"]
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function removeUpstreamStyles(projectDir) {
|
|
109
|
-
const config = JSON.parse(
|
|
110
|
-
readFileSync(path.join(projectDir, "components.json"), "utf8")
|
|
111
|
-
)
|
|
112
|
-
const cssFile =
|
|
113
|
-
config.tailwind?.css && path.join(projectDir, config.tailwind.css)
|
|
114
|
-
if (cssFile && existsSync(cssFile)) {
|
|
115
|
-
const css = readFileSync(cssFile, "utf8")
|
|
116
|
-
if (UPSTREAM_CSS_IMPORT.test(css))
|
|
117
|
-
writeFileSync(cssFile, css.replace(UPSTREAM_CSS_IMPORT, ""))
|
|
16
|
+
npx ${name} init [options] set up Tailwind, the theme, and the button
|
|
17
|
+
npx ${name} add <component...> [options] add components, e.g. add button sidebar
|
|
18
|
+
|
|
19
|
+
Options:
|
|
20
|
+
-o, --overwrite replace local files that differ from the registry
|
|
21
|
+
--diff show how local files differ from the registry
|
|
22
|
+
-a, --all add every component
|
|
23
|
+
-y, --yes skip confirmation prompts
|
|
24
|
+
-c, --cwd <dir> project directory (defaults to the current directory)
|
|
25
|
+
-h, --help show this help
|
|
26
|
+
-v, --version show the version
|
|
27
|
+
|
|
28
|
+
Supports Next.js and React (Vite) projects with TypeScript and Tailwind CSS v4.`
|
|
29
|
+
|
|
30
|
+
function parseArgs(args) {
|
|
31
|
+
const options = {
|
|
32
|
+
cwd: process.cwd(),
|
|
33
|
+
yes: false,
|
|
34
|
+
overwrite: false,
|
|
35
|
+
diff: false,
|
|
36
|
+
all: false,
|
|
118
37
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
38
|
+
const names = []
|
|
39
|
+
|
|
40
|
+
for (let index = 0; index < args.length; index++) {
|
|
41
|
+
const arg = args[index]
|
|
42
|
+
switch (arg) {
|
|
43
|
+
case "-y":
|
|
44
|
+
case "--yes":
|
|
45
|
+
options.yes = true
|
|
46
|
+
break
|
|
47
|
+
case "-o":
|
|
48
|
+
case "--overwrite":
|
|
49
|
+
options.overwrite = true
|
|
50
|
+
break
|
|
51
|
+
case "--diff":
|
|
52
|
+
options.diff = true
|
|
53
|
+
break
|
|
54
|
+
case "-a":
|
|
55
|
+
case "--all":
|
|
56
|
+
options.all = true
|
|
57
|
+
break
|
|
58
|
+
case "-c":
|
|
59
|
+
case "--cwd": {
|
|
60
|
+
const dir = args[++index]
|
|
61
|
+
if (!dir) throw new CliError(`${arg} needs a directory.`)
|
|
62
|
+
options.cwd = path.resolve(dir)
|
|
63
|
+
break
|
|
64
|
+
}
|
|
65
|
+
default:
|
|
66
|
+
if (arg.startsWith("-"))
|
|
67
|
+
throw new CliError(`Unknown option ${arg}\n\n${usage}`)
|
|
68
|
+
names.push(arg)
|
|
69
|
+
}
|
|
129
70
|
}
|
|
71
|
+
return { options, names }
|
|
130
72
|
}
|
|
131
73
|
|
|
132
|
-
function
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
74
|
+
async function main() {
|
|
75
|
+
const [command, ...args] = process.argv.slice(2)
|
|
76
|
+
|
|
77
|
+
switch (command) {
|
|
78
|
+
case "init":
|
|
79
|
+
await init(parseArgs(args).options)
|
|
80
|
+
break
|
|
81
|
+
case "add": {
|
|
82
|
+
const { options, names } = parseArgs(args)
|
|
83
|
+
await add(names, options)
|
|
84
|
+
break
|
|
85
|
+
}
|
|
86
|
+
case "-v":
|
|
87
|
+
case "--version":
|
|
88
|
+
console.log(version)
|
|
89
|
+
break
|
|
90
|
+
case undefined:
|
|
91
|
+
case "-h":
|
|
92
|
+
case "--help":
|
|
93
|
+
case "help":
|
|
94
|
+
console.log(usage)
|
|
95
|
+
break
|
|
96
|
+
default:
|
|
97
|
+
throw new CliError(`Unknown command "${command}"\n\n${usage}`)
|
|
143
98
|
}
|
|
144
|
-
|
|
145
|
-
run([
|
|
146
|
-
"add",
|
|
147
|
-
...toAddresses(INIT_ITEMS),
|
|
148
|
-
"--overwrite",
|
|
149
|
-
"--yes",
|
|
150
|
-
"--cwd",
|
|
151
|
-
projectDir,
|
|
152
|
-
])
|
|
153
|
-
removeUpstreamStyles(projectDir)
|
|
154
99
|
}
|
|
155
100
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
case "add": {
|
|
160
|
-
const addresses = toAddresses(args)
|
|
161
|
-
if (!addresses.some((arg) => arg.startsWith(`${REGISTRY}/`))) {
|
|
162
|
-
console.error(
|
|
163
|
-
`${COMMAND} add: name at least one component, e.g. ${COMMAND} add button`
|
|
164
|
-
)
|
|
165
|
-
process.exit(1)
|
|
166
|
-
}
|
|
167
|
-
run(["add", ...addresses])
|
|
168
|
-
break
|
|
169
|
-
}
|
|
170
|
-
case "init":
|
|
171
|
-
init(args)
|
|
172
|
-
break
|
|
173
|
-
case "-v":
|
|
174
|
-
case "--version":
|
|
175
|
-
console.log(version)
|
|
176
|
-
break
|
|
177
|
-
case undefined:
|
|
178
|
-
case "-h":
|
|
179
|
-
case "--help":
|
|
180
|
-
case "help":
|
|
181
|
-
console.log(usage)
|
|
182
|
-
break
|
|
183
|
-
default:
|
|
184
|
-
console.error(`${COMMAND}: unknown command "${command}"\n\n${usage}`)
|
|
101
|
+
main().catch((error) => {
|
|
102
|
+
if (error instanceof CliError) {
|
|
103
|
+
console.error(color.red(error.message))
|
|
185
104
|
process.exit(1)
|
|
186
|
-
}
|
|
105
|
+
}
|
|
106
|
+
throw error
|
|
107
|
+
})
|
package/lib/add.mjs
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
|
|
4
|
+
import { formatDiff } from "./diff.mjs"
|
|
5
|
+
import { CliError, color, list, note, step } from "./output.mjs"
|
|
6
|
+
import { detectProject, findAliasRoot, install } from "./project.mjs"
|
|
7
|
+
import { fetchRegistry, fetchText, resolveItems } from "./registry.mjs"
|
|
8
|
+
|
|
9
|
+
async function fetchFiles(items) {
|
|
10
|
+
return Promise.all(
|
|
11
|
+
items.flatMap((item) =>
|
|
12
|
+
item.files.map(async (file) => ({ file, content: await fetchText(file) }))
|
|
13
|
+
)
|
|
14
|
+
)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function installItems(
|
|
18
|
+
project,
|
|
19
|
+
root,
|
|
20
|
+
registry,
|
|
21
|
+
names,
|
|
22
|
+
{ overwrite = false } = {}
|
|
23
|
+
) {
|
|
24
|
+
const items = resolveItems(registry, names)
|
|
25
|
+
const files = await fetchFiles(items)
|
|
26
|
+
|
|
27
|
+
const created = []
|
|
28
|
+
const updated = []
|
|
29
|
+
const skipped = []
|
|
30
|
+
|
|
31
|
+
for (const { file, content } of files) {
|
|
32
|
+
const target = path.join(root, file)
|
|
33
|
+
const relative = path.relative(project.cwd, target)
|
|
34
|
+
if (!existsSync(target)) {
|
|
35
|
+
mkdirSync(path.dirname(target), { recursive: true })
|
|
36
|
+
writeFileSync(target, content)
|
|
37
|
+
created.push(relative)
|
|
38
|
+
} else if (readFileSync(target, "utf8") !== content) {
|
|
39
|
+
if (overwrite) {
|
|
40
|
+
writeFileSync(target, content)
|
|
41
|
+
updated.push(relative)
|
|
42
|
+
} else {
|
|
43
|
+
skipped.push(relative)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const installed = install(
|
|
49
|
+
project,
|
|
50
|
+
items.flatMap((item) => item.dependencies)
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
if (created.length) {
|
|
54
|
+
step(`Created ${created.length} file${created.length === 1 ? "" : "s"}:`)
|
|
55
|
+
list(created)
|
|
56
|
+
}
|
|
57
|
+
if (updated.length) {
|
|
58
|
+
step(`Updated ${updated.length} file${updated.length === 1 ? "" : "s"}:`)
|
|
59
|
+
list(updated)
|
|
60
|
+
}
|
|
61
|
+
if (installed.length) step(`Installed ${installed.join(", ")}`)
|
|
62
|
+
if (skipped.length) {
|
|
63
|
+
note(
|
|
64
|
+
`Kept ${skipped.length} local file${skipped.length === 1 ? " that differs" : "s that differ"} from the registry. Review with --diff, replace with --overwrite:`
|
|
65
|
+
)
|
|
66
|
+
list(skipped)
|
|
67
|
+
}
|
|
68
|
+
if (!created.length && !updated.length && !skipped.length) {
|
|
69
|
+
step("Already up to date.")
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function showDiff(project, root, registry, names) {
|
|
74
|
+
const requested = new Set(names)
|
|
75
|
+
const items = resolveItems(registry, names).filter((item) =>
|
|
76
|
+
requested.has(item.name)
|
|
77
|
+
)
|
|
78
|
+
let changes = 0
|
|
79
|
+
|
|
80
|
+
for (const { file, content } of await fetchFiles(items)) {
|
|
81
|
+
const target = path.join(root, file)
|
|
82
|
+
const relative = path.relative(project.cwd, target)
|
|
83
|
+
if (!existsSync(target)) {
|
|
84
|
+
note(`${relative} is not installed.`)
|
|
85
|
+
continue
|
|
86
|
+
}
|
|
87
|
+
const diff = formatDiff(readFileSync(target, "utf8"), content)
|
|
88
|
+
if (!diff) continue
|
|
89
|
+
changes++
|
|
90
|
+
console.log(`\n${color.bold(relative)}\n${diff}`)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (!changes) step("No differences from the registry.")
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function add(names, options) {
|
|
97
|
+
const project = detectProject(options.cwd)
|
|
98
|
+
const root = findAliasRoot(project.cwd)
|
|
99
|
+
if (!root) {
|
|
100
|
+
throw new CliError(
|
|
101
|
+
'No "@/*" import alias found in tsconfig.json. Run `npx @esuiss/ui init` first.'
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const registry = await fetchRegistry()
|
|
106
|
+
const selected = options.all ? registry.items.map((item) => item.name) : names
|
|
107
|
+
if (!selected.length) {
|
|
108
|
+
throw new CliError(
|
|
109
|
+
"Name at least one component, e.g. npx @esuiss/ui add button"
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (options.diff) await showDiff(project, root, registry, selected)
|
|
114
|
+
else await installItems(project, root, registry, selected, options)
|
|
115
|
+
}
|
package/lib/diff.mjs
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { color } from "./output.mjs"
|
|
2
|
+
|
|
3
|
+
const CONTEXT = 3
|
|
4
|
+
|
|
5
|
+
function diffLines(before, after) {
|
|
6
|
+
const table = Array.from(
|
|
7
|
+
{ length: before.length + 1 },
|
|
8
|
+
() => new Uint32Array(after.length + 1)
|
|
9
|
+
)
|
|
10
|
+
for (let i = before.length - 1; i >= 0; i--) {
|
|
11
|
+
for (let j = after.length - 1; j >= 0; j--) {
|
|
12
|
+
table[i][j] =
|
|
13
|
+
before[i] === after[j]
|
|
14
|
+
? table[i + 1][j + 1] + 1
|
|
15
|
+
: Math.max(table[i + 1][j], table[i][j + 1])
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const operations = []
|
|
20
|
+
let i = 0
|
|
21
|
+
let j = 0
|
|
22
|
+
while (i < before.length && j < after.length) {
|
|
23
|
+
if (before[i] === after[j]) {
|
|
24
|
+
operations.push([" ", before[i++]])
|
|
25
|
+
j++
|
|
26
|
+
} else if (table[i + 1][j] >= table[i][j + 1]) {
|
|
27
|
+
operations.push(["-", before[i++]])
|
|
28
|
+
} else {
|
|
29
|
+
operations.push(["+", after[j++]])
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
while (i < before.length) operations.push(["-", before[i++]])
|
|
33
|
+
while (j < after.length) operations.push(["+", after[j++]])
|
|
34
|
+
return operations
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function formatDiff(before, after) {
|
|
38
|
+
const operations = diffLines(before.split("\n"), after.split("\n"))
|
|
39
|
+
const changed = operations.flatMap(([kind], index) =>
|
|
40
|
+
kind === " " ? [] : [index]
|
|
41
|
+
)
|
|
42
|
+
if (!changed.length) return ""
|
|
43
|
+
|
|
44
|
+
const visible = new Set()
|
|
45
|
+
for (const index of changed) {
|
|
46
|
+
for (let k = index - CONTEXT; k <= index + CONTEXT; k++) visible.add(k)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const lines = []
|
|
50
|
+
let previous = -1
|
|
51
|
+
operations.forEach(([kind, text], index) => {
|
|
52
|
+
if (!visible.has(index)) return
|
|
53
|
+
if (previous !== -1 && index !== previous + 1)
|
|
54
|
+
lines.push(color.dim(" ..."))
|
|
55
|
+
previous = index
|
|
56
|
+
if (kind === "+") lines.push(color.green(`+ ${text}`))
|
|
57
|
+
else if (kind === "-") lines.push(color.red(`- ${text}`))
|
|
58
|
+
else lines.push(color.dim(` ${text}`))
|
|
59
|
+
})
|
|
60
|
+
return lines.join("\n")
|
|
61
|
+
}
|
package/lib/init.mjs
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
|
|
4
|
+
import { installItems } from "./add.mjs"
|
|
5
|
+
import { CliError, confirm, note, step } from "./output.mjs"
|
|
6
|
+
import { detectProject, findAliasRoot, install } from "./project.mjs"
|
|
7
|
+
import { fetchRegistry, fetchStylesheet } from "./registry.mjs"
|
|
8
|
+
|
|
9
|
+
const NEXT_STYLESHEETS = [
|
|
10
|
+
"src/app/globals.css",
|
|
11
|
+
"app/globals.css",
|
|
12
|
+
"src/styles/globals.css",
|
|
13
|
+
"styles/globals.css",
|
|
14
|
+
]
|
|
15
|
+
const NEXT_APP_DIRS = ["src/app", "app"]
|
|
16
|
+
const VITE_STYLESHEET = "src/index.css"
|
|
17
|
+
const VITE_CONFIGS = [
|
|
18
|
+
"vite.config.ts",
|
|
19
|
+
"vite.config.mts",
|
|
20
|
+
"vite.config.js",
|
|
21
|
+
"vite.config.mjs",
|
|
22
|
+
]
|
|
23
|
+
const POSTCSS_CONFIGS = [
|
|
24
|
+
"postcss.config.mjs",
|
|
25
|
+
"postcss.config.js",
|
|
26
|
+
"postcss.config.cjs",
|
|
27
|
+
"postcss.config.ts",
|
|
28
|
+
]
|
|
29
|
+
const POSTCSS_CONFIG = `const config = {
|
|
30
|
+
plugins: {
|
|
31
|
+
"@tailwindcss/postcss": {},
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export default config
|
|
36
|
+
`
|
|
37
|
+
const TOOLING = {
|
|
38
|
+
next: ["tailwindcss", "@tailwindcss/postcss"],
|
|
39
|
+
vite: ["tailwindcss", "@tailwindcss/vite", "@types/node"],
|
|
40
|
+
}
|
|
41
|
+
const RUNTIME = ["tw-animate-css"]
|
|
42
|
+
|
|
43
|
+
function relative(project, file) {
|
|
44
|
+
return path.relative(project.cwd, file)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function findStylesheet(project) {
|
|
48
|
+
if (project.framework === "vite")
|
|
49
|
+
return path.join(project.cwd, VITE_STYLESHEET)
|
|
50
|
+
const existing = NEXT_STYLESHEETS.map((file) =>
|
|
51
|
+
path.join(project.cwd, file)
|
|
52
|
+
).find(existsSync)
|
|
53
|
+
if (existing) return existing
|
|
54
|
+
const appDir = NEXT_APP_DIRS.map((dir) => path.join(project.cwd, dir)).find(
|
|
55
|
+
existsSync
|
|
56
|
+
)
|
|
57
|
+
if (!appDir)
|
|
58
|
+
throw new CliError("Could not find the app directory (app/ or src/app/).")
|
|
59
|
+
return path.join(appDir, "globals.css")
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function assertTailwindVersion(project) {
|
|
63
|
+
const version = project.packages.tailwindcss
|
|
64
|
+
const major = version && Number(version.match(/\d+/)?.[0])
|
|
65
|
+
if (major && major < 4) {
|
|
66
|
+
throw new CliError(
|
|
67
|
+
`Tailwind CSS v4 is required, this project uses ${version}.`
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function addAliasTo(file, target) {
|
|
73
|
+
let text = readFileSync(file, "utf8")
|
|
74
|
+
const alias = `"@/*": ["${target}"]`
|
|
75
|
+
const paths = text.match(/"paths"\s*:\s*\{/)
|
|
76
|
+
const options = text.match(/"compilerOptions"\s*:\s*\{/)
|
|
77
|
+
if (paths) {
|
|
78
|
+
text = text.replace(paths[0], `${paths[0]}\n ${alias},`)
|
|
79
|
+
} else if (options) {
|
|
80
|
+
text = text.replace(
|
|
81
|
+
options[0],
|
|
82
|
+
`${options[0]}\n "paths": {\n ${alias}\n },`
|
|
83
|
+
)
|
|
84
|
+
} else {
|
|
85
|
+
text = text.replace(
|
|
86
|
+
"{",
|
|
87
|
+
`{\n "compilerOptions": {\n "paths": {\n ${alias}\n }\n },`
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
writeFileSync(file, text)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function ensureAlias(project, sourceRoot) {
|
|
94
|
+
if (findAliasRoot(project.cwd)) return false
|
|
95
|
+
const target =
|
|
96
|
+
sourceRoot === project.cwd ? "./*" : `./${relative(project, sourceRoot)}/*`
|
|
97
|
+
const files =
|
|
98
|
+
project.framework === "vite"
|
|
99
|
+
? ["tsconfig.json", "tsconfig.app.json"]
|
|
100
|
+
: ["tsconfig.json"]
|
|
101
|
+
for (const name of files) {
|
|
102
|
+
const file = path.join(project.cwd, name)
|
|
103
|
+
if (existsSync(file)) addAliasTo(file, target)
|
|
104
|
+
}
|
|
105
|
+
return true
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function configureVite(project) {
|
|
109
|
+
const file = VITE_CONFIGS.map((name) => path.join(project.cwd, name)).find(
|
|
110
|
+
existsSync
|
|
111
|
+
)
|
|
112
|
+
if (!file)
|
|
113
|
+
return [
|
|
114
|
+
"Create a vite.config.ts with the tailwindcss() plugin and an @ alias to ./src.",
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
let text = readFileSync(file, "utf8")
|
|
118
|
+
const original = text
|
|
119
|
+
const manual = []
|
|
120
|
+
|
|
121
|
+
if (!text.includes("@tailwindcss/vite")) {
|
|
122
|
+
if (/plugins\s*:\s*\[/.test(text)) {
|
|
123
|
+
text = text.replace(
|
|
124
|
+
/plugins\s*:\s*\[/,
|
|
125
|
+
(match) => `${match}tailwindcss(), `
|
|
126
|
+
)
|
|
127
|
+
text = `import tailwindcss from "@tailwindcss/vite"\n${text}`
|
|
128
|
+
} else {
|
|
129
|
+
manual.push(
|
|
130
|
+
`Add tailwindcss() from "@tailwindcss/vite" to the plugins in ${relative(project, file)}.`
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (!/["']@["']\s*:/.test(text)) {
|
|
136
|
+
if (/defineConfig\(\{/.test(text) && !/\bresolve\s*:/.test(text)) {
|
|
137
|
+
text = text.replace(
|
|
138
|
+
/defineConfig\(\{/,
|
|
139
|
+
(match) =>
|
|
140
|
+
`${match}\n resolve: {\n alias: {\n "@": fileURLToPath(new URL("./src", import.meta.url)),\n },\n },`
|
|
141
|
+
)
|
|
142
|
+
text = `import { fileURLToPath } from "node:url"\n${text}`
|
|
143
|
+
} else {
|
|
144
|
+
manual.push(
|
|
145
|
+
`Add an "@" alias pointing to ./src in ${relative(project, file)}.`
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (text !== original) {
|
|
151
|
+
writeFileSync(file, text)
|
|
152
|
+
step(`Configured ${relative(project, file)}`)
|
|
153
|
+
}
|
|
154
|
+
return manual
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function configureNext(project) {
|
|
158
|
+
const existing = POSTCSS_CONFIGS.map((name) =>
|
|
159
|
+
path.join(project.cwd, name)
|
|
160
|
+
).find(existsSync)
|
|
161
|
+
if (!existing) {
|
|
162
|
+
writeFileSync(path.join(project.cwd, "postcss.config.mjs"), POSTCSS_CONFIG)
|
|
163
|
+
step("Created postcss.config.mjs")
|
|
164
|
+
return []
|
|
165
|
+
}
|
|
166
|
+
if (!readFileSync(existing, "utf8").includes("@tailwindcss/postcss")) {
|
|
167
|
+
return [
|
|
168
|
+
`Add "@tailwindcss/postcss" to the plugins in ${relative(project, existing)}.`,
|
|
169
|
+
]
|
|
170
|
+
}
|
|
171
|
+
return []
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export async function init(options) {
|
|
175
|
+
const project = detectProject(options.cwd)
|
|
176
|
+
assertTailwindVersion(project)
|
|
177
|
+
|
|
178
|
+
const sourceRoot = existsSync(path.join(project.cwd, "src"))
|
|
179
|
+
? path.join(project.cwd, "src")
|
|
180
|
+
: project.cwd
|
|
181
|
+
if (project.framework === "vite" && sourceRoot === project.cwd) {
|
|
182
|
+
throw new CliError("Expected a src/ directory in this Vite project.")
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const stylesheet = findStylesheet(project)
|
|
186
|
+
const stylesheetExists = existsSync(stylesheet)
|
|
187
|
+
if (
|
|
188
|
+
stylesheetExists &&
|
|
189
|
+
readFileSync(stylesheet, "utf8").trim() &&
|
|
190
|
+
!options.yes
|
|
191
|
+
) {
|
|
192
|
+
const replace = await confirm(
|
|
193
|
+
`Replace ${relative(project, stylesheet)} with the esuiss-ui stylesheet?`
|
|
194
|
+
)
|
|
195
|
+
if (!replace) {
|
|
196
|
+
throw new CliError(
|
|
197
|
+
"Cancelled. Run again with --yes to replace the stylesheet."
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const registry = await fetchRegistry()
|
|
203
|
+
const css = await fetchStylesheet(registry)
|
|
204
|
+
|
|
205
|
+
if (ensureAlias(project, sourceRoot))
|
|
206
|
+
step("Added the @/* import alias to tsconfig")
|
|
207
|
+
|
|
208
|
+
install(project, TOOLING[project.framework], { dev: true })
|
|
209
|
+
install(project, RUNTIME)
|
|
210
|
+
|
|
211
|
+
const manual =
|
|
212
|
+
project.framework === "vite"
|
|
213
|
+
? configureVite(project)
|
|
214
|
+
: configureNext(project)
|
|
215
|
+
|
|
216
|
+
writeFileSync(stylesheet, css)
|
|
217
|
+
step(`Wrote ${relative(project, stylesheet)}`)
|
|
218
|
+
if (!stylesheetExists) {
|
|
219
|
+
manual.push(
|
|
220
|
+
`Import ${relative(project, stylesheet)} in ${project.framework === "next" ? "your root layout" : "src/main.tsx"}.`
|
|
221
|
+
)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
await installItems(
|
|
225
|
+
project,
|
|
226
|
+
findAliasRoot(project.cwd) ?? sourceRoot,
|
|
227
|
+
registry,
|
|
228
|
+
["button"]
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
for (const message of manual) note(message)
|
|
232
|
+
step("Ready. Add components with npx @esuiss/ui add <name>")
|
|
233
|
+
}
|
package/lib/output.mjs
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { createInterface } from "node:readline/promises"
|
|
2
|
+
|
|
3
|
+
const useColor = process.stdout.isTTY && !process.env.NO_COLOR
|
|
4
|
+
|
|
5
|
+
function paint(code, text) {
|
|
6
|
+
return useColor ? `\x1b[${code}m${text}\x1b[0m` : text
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const color = {
|
|
10
|
+
green: (text) => paint(32, text),
|
|
11
|
+
red: (text) => paint(31, text),
|
|
12
|
+
yellow: (text) => paint(33, text),
|
|
13
|
+
dim: (text) => paint(2, text),
|
|
14
|
+
bold: (text) => paint(1, text),
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class CliError extends Error {}
|
|
18
|
+
|
|
19
|
+
export function step(message) {
|
|
20
|
+
console.log(`${color.green("✔")} ${message}`)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function note(message) {
|
|
24
|
+
console.log(`${color.yellow("•")} ${message}`)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function list(paths) {
|
|
28
|
+
for (const item of paths) console.log(color.dim(` - ${item}`))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function confirm(question) {
|
|
32
|
+
if (!process.stdin.isTTY) return false
|
|
33
|
+
const prompt = createInterface({
|
|
34
|
+
input: process.stdin,
|
|
35
|
+
output: process.stdout,
|
|
36
|
+
})
|
|
37
|
+
const answer = await prompt.question(
|
|
38
|
+
`${color.yellow("?")} ${question} ${color.dim("(y/N)")} `
|
|
39
|
+
)
|
|
40
|
+
prompt.close()
|
|
41
|
+
return /^y(es)?$/i.test(answer.trim())
|
|
42
|
+
}
|
package/lib/project.mjs
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process"
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
|
|
5
|
+
import { CliError } from "./output.mjs"
|
|
6
|
+
|
|
7
|
+
const LOCKFILES = [
|
|
8
|
+
["pnpm-lock.yaml", "pnpm"],
|
|
9
|
+
["bun.lock", "bun"],
|
|
10
|
+
["bun.lockb", "bun"],
|
|
11
|
+
["yarn.lock", "yarn"],
|
|
12
|
+
["package-lock.json", "npm"],
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
const INSTALL_ARGS = {
|
|
16
|
+
npm: { command: "install", dev: "--save-dev" },
|
|
17
|
+
pnpm: { command: "add", dev: "--save-dev" },
|
|
18
|
+
yarn: { command: "add", dev: "--dev" },
|
|
19
|
+
bun: { command: "add", dev: "--dev" },
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const TSCONFIGS = ["tsconfig.json", "tsconfig.app.json"]
|
|
23
|
+
|
|
24
|
+
export function readJsonc(file) {
|
|
25
|
+
const text = readFileSync(file, "utf8")
|
|
26
|
+
const withoutComments = text.replace(
|
|
27
|
+
/("(?:\\.|[^"\\])*")|\/\/[^\n]*|\/\*[\s\S]*?\*\//g,
|
|
28
|
+
(match, string) => string ?? ""
|
|
29
|
+
)
|
|
30
|
+
return JSON.parse(withoutComments.replace(/,(\s*[}\]])/g, "$1"))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function detectPackageManager(cwd) {
|
|
34
|
+
for (let dir = cwd; ; dir = path.dirname(dir)) {
|
|
35
|
+
const match = LOCKFILES.find(([file]) => existsSync(path.join(dir, file)))
|
|
36
|
+
if (match) return match[1]
|
|
37
|
+
if (path.dirname(dir) === dir) return "npm"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function detectProject(cwd) {
|
|
42
|
+
const manifestPath = path.join(cwd, "package.json")
|
|
43
|
+
if (!existsSync(manifestPath)) {
|
|
44
|
+
throw new CliError(
|
|
45
|
+
"No package.json found. Run this inside a Next.js or React project."
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"))
|
|
49
|
+
const packages = { ...manifest.dependencies, ...manifest.devDependencies }
|
|
50
|
+
const framework = packages.next
|
|
51
|
+
? "next"
|
|
52
|
+
: packages.vite && packages.react
|
|
53
|
+
? "vite"
|
|
54
|
+
: null
|
|
55
|
+
if (!framework) {
|
|
56
|
+
throw new CliError("Only Next.js and React (Vite) projects are supported.")
|
|
57
|
+
}
|
|
58
|
+
if (!existsSync(path.join(cwd, "tsconfig.json"))) {
|
|
59
|
+
throw new CliError(
|
|
60
|
+
"A TypeScript project is required (tsconfig.json not found)."
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
cwd,
|
|
65
|
+
framework,
|
|
66
|
+
packages,
|
|
67
|
+
packageManager: detectPackageManager(cwd),
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function findAliasRoot(cwd) {
|
|
72
|
+
for (const name of TSCONFIGS) {
|
|
73
|
+
const file = path.join(cwd, name)
|
|
74
|
+
if (!existsSync(file)) continue
|
|
75
|
+
const options = readJsonc(file).compilerOptions ?? {}
|
|
76
|
+
const target = options.paths?.["@/*"]?.[0]
|
|
77
|
+
if (target) {
|
|
78
|
+
return path.resolve(
|
|
79
|
+
cwd,
|
|
80
|
+
options.baseUrl ?? ".",
|
|
81
|
+
target.replace(/\/?\*$/, "")
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return null
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function install(project, packages, { dev = false } = {}) {
|
|
89
|
+
const missing = [...new Set(packages)].filter(
|
|
90
|
+
(name) => !project.packages[name]
|
|
91
|
+
)
|
|
92
|
+
if (!missing.length) return []
|
|
93
|
+
const { command, dev: devFlag } = INSTALL_ARGS[project.packageManager]
|
|
94
|
+
const args = [command, ...(dev ? [devFlag] : []), ...missing]
|
|
95
|
+
const result = spawnSync(project.packageManager, args, {
|
|
96
|
+
cwd: project.cwd,
|
|
97
|
+
stdio: "inherit",
|
|
98
|
+
shell: process.platform === "win32",
|
|
99
|
+
})
|
|
100
|
+
if (result.status !== 0) {
|
|
101
|
+
throw new CliError(`${project.packageManager} ${args.join(" ")} failed.`)
|
|
102
|
+
}
|
|
103
|
+
for (const name of missing) project.packages[name] = "*"
|
|
104
|
+
return missing
|
|
105
|
+
}
|
package/lib/registry.mjs
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { CliError } from "./output.mjs"
|
|
2
|
+
|
|
3
|
+
const DEFAULT_BASE = "https://raw.githubusercontent.com/esuiss/esuiss-ui/main"
|
|
4
|
+
const base = (process.env.ESUISS_REGISTRY ?? DEFAULT_BASE).replace(/\/$/, "")
|
|
5
|
+
|
|
6
|
+
export async function fetchText(file) {
|
|
7
|
+
let response
|
|
8
|
+
try {
|
|
9
|
+
response = await fetch(`${base}/${file}`)
|
|
10
|
+
} catch {
|
|
11
|
+
throw new CliError(`Could not reach the registry to download ${file}.`)
|
|
12
|
+
}
|
|
13
|
+
if (!response.ok) {
|
|
14
|
+
throw new CliError(`Could not download ${file} (HTTP ${response.status}).`)
|
|
15
|
+
}
|
|
16
|
+
return response.text()
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function fetchRegistry() {
|
|
20
|
+
return JSON.parse(await fetchText("registry.json"))
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function resolveItems(registry, names) {
|
|
24
|
+
const byName = new Map(registry.items.map((item) => [item.name, item]))
|
|
25
|
+
const unknown = names.filter((name) => !byName.has(name))
|
|
26
|
+
if (unknown.length) {
|
|
27
|
+
throw new CliError(
|
|
28
|
+
`Unknown component: ${unknown.join(", ")}.\nAvailable: ${[...byName.keys()].join(", ")}`
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const ordered = []
|
|
33
|
+
const seen = new Set()
|
|
34
|
+
const visit = (name) => {
|
|
35
|
+
if (seen.has(name)) return
|
|
36
|
+
seen.add(name)
|
|
37
|
+
const item = byName.get(name)
|
|
38
|
+
for (const required of item.requires) visit(required)
|
|
39
|
+
ordered.push(item)
|
|
40
|
+
}
|
|
41
|
+
names.forEach(visit)
|
|
42
|
+
return ordered
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function fetchStylesheet(registry) {
|
|
46
|
+
return fetchText(registry.stylesheet)
|
|
47
|
+
}
|
package/package.json
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@esuiss/ui",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Add esuiss-ui components to
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Add esuiss-ui components to Next.js and React projects.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
8
8
|
"esuiss": "index.mjs"
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
|
-
"index.mjs"
|
|
11
|
+
"index.mjs",
|
|
12
|
+
"lib"
|
|
12
13
|
],
|
|
13
14
|
"engines": {
|
|
14
15
|
"node": ">=18"
|