@esuiss/ui 0.1.0 → 0.2.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # @esuiss/ui
2
2
 
3
- Add esuiss-ui components to your project.
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 { spawnSync } from "node:child_process"
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
- const REGISTRY = "esuiss/esuiss-ui"
13
- const COMMAND = "npx @esuiss/ui"
14
- const INIT_ITEMS = ["theme", "button"]
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 = `@esuiss/ui ${version}
13
+ const usage = `${name} ${version}
25
14
 
26
15
  Usage:
27
- ${COMMAND} init [options] set up a project with the esuiss-ui theme and button
28
- ${COMMAND} add <component...> [options] add components, e.g. ${COMMAND} add button sidebar
29
-
30
- Options for add:
31
- -o, --overwrite overwrite existing files
32
- --diff [path] show changes against the installed file
33
- --view [path] show file contents
34
- -y, --yes skip confirmation prompt
35
- -c, --cwd <cwd> working directory
36
- -p, --path <path> install location
37
- --dry-run preview changes without writing files`
38
-
39
- function exec(command, args, cwd) {
40
- const result = spawnSync(command, args, {
41
- cwd,
42
- stdio: "inherit",
43
- shell: process.platform === "win32",
44
- })
45
- if (result.status !== 0) process.exit(result.status ?? 1)
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
- const manifest = JSON.parse(
121
- readFileSync(path.join(projectDir, "package.json"), "utf8")
122
- )
123
- const installed =
124
- manifest.dependencies?.[UPSTREAM_PACKAGE] ??
125
- manifest.devDependencies?.[UPSTREAM_PACKAGE]
126
- if (installed) {
127
- const [manager, removeCommand] = packageManager(projectDir)
128
- exec(manager, [removeCommand, UPSTREAM_PACKAGE], projectDir)
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 init(args) {
133
- const baseArgs = hasFlag(args, "-b", "--base") ? [] : ["--base", "base"]
134
- run(["init", ...baseArgs, ...args])
135
-
136
- const baseDir = path.resolve(flagValue(args, "-c", "--cwd") ?? ".")
137
- const projectDir = findProject(baseDir, flagValue(args, "-n", "--name"))
138
- if (!projectDir) {
139
- console.error(
140
- `${COMMAND} init: could not find the initialized project (components.json).`
141
- )
142
- process.exit(1)
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
- const [command, ...args] = process.argv.slice(2)
157
-
158
- switch (command) {
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
+ }
@@ -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
+ 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
+ }
@@ -0,0 +1,47 @@
1
+ import { CliError } from "./output.mjs"
2
+
3
+ const DEFAULT_BASE = "https://raw.githubusercontent.com/e-suiss/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,21 @@
1
1
  {
2
2
  "name": "@esuiss/ui",
3
- "version": "0.1.0",
4
- "description": "Add esuiss-ui components to your project.",
3
+ "version": "0.2.1",
4
+ "description": "Add esuiss-ui components to Next.js and React projects.",
5
5
  "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/e-suiss/ui.git",
9
+ "directory": "packages/cli"
10
+ },
11
+ "homepage": "https://github.com/e-suiss/ui",
6
12
  "type": "module",
7
13
  "bin": {
8
14
  "esuiss": "index.mjs"
9
15
  },
10
16
  "files": [
11
- "index.mjs"
17
+ "index.mjs",
18
+ "lib"
12
19
  ],
13
20
  "engines": {
14
21
  "node": ">=18"