banhaten 0.1.1 → 0.1.3

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 (258) hide show
  1. package/README.md +86 -309
  2. package/banhaten.config.example.json +1 -1
  3. package/docs/design-system/README.md +11 -0
  4. package/docs/design-system/appearance-presets.md +184 -0
  5. package/docs/design-system/appearances/default.md +94 -0
  6. package/docs/design-system/appearances/rounded.md +95 -0
  7. package/docs/design-system/appearances/sharp.md +95 -0
  8. package/docs/design-system/component-showcase-consistency-report.md +217 -0
  9. package/docs/design-system/component-token-consistency-audit.md +163 -0
  10. package/docs/design-system/components/README.md +74 -0
  11. package/docs/design-system/components/accordion.md +51 -0
  12. package/docs/design-system/components/activity-feed.md +92 -0
  13. package/docs/design-system/components/alert-dialog.md +70 -0
  14. package/docs/design-system/components/alert.md +79 -0
  15. package/docs/design-system/components/aspect-ratio.md +44 -0
  16. package/docs/design-system/components/attribute.md +87 -0
  17. package/docs/design-system/components/autocomplete.md +74 -0
  18. package/docs/design-system/components/avatar.md +52 -0
  19. package/docs/design-system/components/badge.md +53 -0
  20. package/docs/design-system/components/banner.md +85 -0
  21. package/docs/design-system/components/breadcrumbs.md +174 -0
  22. package/docs/design-system/components/button-group.md +83 -0
  23. package/docs/design-system/components/button.md +77 -0
  24. package/docs/design-system/components/card.md +78 -0
  25. package/docs/design-system/components/carousel.md +44 -0
  26. package/docs/design-system/components/catalog-components.md +45 -0
  27. package/docs/design-system/components/chart.md +43 -0
  28. package/docs/design-system/components/checkbox.md +52 -0
  29. package/docs/design-system/components/collapsible.md +48 -0
  30. package/docs/design-system/components/command-bar.md +57 -0
  31. package/docs/design-system/components/command.md +60 -0
  32. package/docs/design-system/components/context-menu.md +44 -0
  33. package/docs/design-system/components/date-picker.md +77 -0
  34. package/docs/design-system/components/divider.md +101 -0
  35. package/docs/design-system/components/empty-state.md +55 -0
  36. package/docs/design-system/components/field.md +69 -0
  37. package/docs/design-system/components/file-upload.md +185 -0
  38. package/docs/design-system/components/hover-card.md +46 -0
  39. package/docs/design-system/components/icons.md +48 -0
  40. package/docs/design-system/components/input-group.md +56 -0
  41. package/docs/design-system/components/input-otp.md +55 -0
  42. package/docs/design-system/components/input.md +48 -0
  43. package/docs/design-system/components/kbd.md +44 -0
  44. package/docs/design-system/components/label.md +48 -0
  45. package/docs/design-system/components/menu.md +59 -0
  46. package/docs/design-system/components/menubar.md +45 -0
  47. package/docs/design-system/components/modal.md +98 -0
  48. package/docs/design-system/components/native-select.md +52 -0
  49. package/docs/design-system/components/navigation-menu.md +48 -0
  50. package/docs/design-system/components/onboarding-step-list-item.md +80 -0
  51. package/docs/design-system/components/page-header.md +84 -0
  52. package/docs/design-system/components/pagination.md +49 -0
  53. package/docs/design-system/components/popover.md +58 -0
  54. package/docs/design-system/components/progress-slider.md +48 -0
  55. package/docs/design-system/components/progress.md +75 -0
  56. package/docs/design-system/components/radio-card.md +49 -0
  57. package/docs/design-system/components/radio-group.md +55 -0
  58. package/docs/design-system/components/resizable.md +42 -0
  59. package/docs/design-system/components/scroll-area.md +45 -0
  60. package/docs/design-system/components/select.md +50 -0
  61. package/docs/design-system/components/sheet.md +65 -0
  62. package/docs/design-system/components/sidebar.md +68 -0
  63. package/docs/design-system/components/skeleton.md +73 -0
  64. package/docs/design-system/components/slideout.md +63 -0
  65. package/docs/design-system/components/slider.md +61 -0
  66. package/docs/design-system/components/social-button.md +47 -0
  67. package/docs/design-system/components/spinner.md +61 -0
  68. package/docs/design-system/components/steps.md +63 -0
  69. package/docs/design-system/components/table.md +397 -0
  70. package/docs/design-system/components/tabs.md +52 -0
  71. package/docs/design-system/components/tag.md +78 -0
  72. package/docs/design-system/components/textarea.md +48 -0
  73. package/docs/design-system/components/timeline.md +81 -0
  74. package/docs/design-system/components/toast.md +56 -0
  75. package/docs/design-system/components/toggle.md +79 -0
  76. package/docs/design-system/components/toolbar.md +85 -0
  77. package/docs/design-system/components/tooltip.md +90 -0
  78. package/docs/design-system/components/typography.md +18 -0
  79. package/docs/design-system/design-system-test-missing-items.md +368 -0
  80. package/docs/design-system/icons.md +69 -0
  81. package/docs/design-system/registry-and-cli.md +41 -0
  82. package/docs/design-system/tabs.md +53 -0
  83. package/docs/design-system/token-governance.md +38 -0
  84. package/package.json +83 -59
  85. package/registry/components/alert-dialog.tsx +297 -0
  86. package/registry/components/aspect-ratio.tsx +30 -0
  87. package/registry/components/autocomplete.tsx +637 -0
  88. package/registry/components/avatar.tsx +258 -22
  89. package/registry/components/badge.tsx +97 -35
  90. package/registry/components/carousel.tsx +234 -0
  91. package/registry/components/chart.tsx +170 -0
  92. package/registry/components/collapsible.tsx +69 -0
  93. package/registry/components/command.tsx +174 -0
  94. package/registry/components/context-menu.tsx +236 -0
  95. package/registry/components/date-picker-state.ts +253 -0
  96. package/registry/components/date-picker.tsx +116 -159
  97. package/registry/components/expanded/EmptyState.tsx +155 -0
  98. package/registry/components/expanded/PageHeader.tsx +1 -1
  99. package/registry/components/expanded/breadcrumbs.css +139 -139
  100. package/registry/components/expanded/catalogComponentsShowcase.css +83 -83
  101. package/registry/components/expanded/emptyState.css +111 -0
  102. package/registry/components/expanded/slideout.css +1 -0
  103. package/registry/components/expanded/steps.css +274 -274
  104. package/registry/components/expanded/table.css +1 -0
  105. package/registry/components/expanded/timeline.css +264 -264
  106. package/registry/components/field.tsx +230 -0
  107. package/registry/components/hover-card.tsx +48 -0
  108. package/registry/components/input-group.tsx +130 -0
  109. package/registry/components/input-otp.tsx +574 -0
  110. package/registry/components/input.tsx +22 -12
  111. package/registry/components/kbd.tsx +44 -0
  112. package/registry/components/label.tsx +78 -0
  113. package/registry/components/menu.tsx +373 -8
  114. package/registry/components/menubar.tsx +226 -0
  115. package/registry/components/modal.tsx +109 -76
  116. package/registry/components/native-select.tsx +205 -0
  117. package/registry/components/navigation-menu.tsx +171 -0
  118. package/registry/components/popover.tsx +840 -0
  119. package/registry/components/radio-group.tsx +1 -1
  120. package/registry/components/resizable.tsx +74 -0
  121. package/registry/components/scroll-area.tsx +67 -0
  122. package/registry/components/select.tsx +6 -4
  123. package/registry/components/sheet.tsx +305 -0
  124. package/registry/components/sidebar.tsx +352 -0
  125. package/registry/components/skeleton.css +57 -0
  126. package/registry/components/skeleton.tsx +482 -0
  127. package/registry/components/social-button.tsx +74 -10
  128. package/registry/components/spinner.tsx +79 -11
  129. package/registry/components/{expanded/tabs.css → tabs.css} +127 -106
  130. package/registry/components/tabs.tsx +242 -0
  131. package/registry/components/textarea.tsx +2 -2
  132. package/registry/components/toast.tsx +131 -0
  133. package/registry/components/tooltip.tsx +4 -0
  134. package/registry/examples/alert-dialog-demo.tsx +42 -0
  135. package/registry/examples/aspect-ratio-demo.tsx +11 -0
  136. package/registry/examples/autocomplete-demo.tsx +109 -0
  137. package/registry/examples/avatar-demo.tsx +102 -47
  138. package/registry/examples/badge-demo.tsx +16 -0
  139. package/registry/examples/carousel-demo.tsx +25 -0
  140. package/registry/examples/chart-demo.tsx +33 -0
  141. package/registry/examples/collapsible-demo.tsx +16 -0
  142. package/registry/examples/command-demo.tsx +42 -0
  143. package/registry/examples/context-menu-demo.tsx +29 -0
  144. package/registry/examples/expanded/command-bar-demo.tsx +236 -0
  145. package/registry/examples/expanded/empty-state-demo.tsx +39 -0
  146. package/registry/examples/expanded/tabs-demo.tsx +1 -1
  147. package/registry/examples/field-demo.tsx +51 -0
  148. package/registry/examples/hover-card-demo.tsx +23 -0
  149. package/registry/examples/input-demo.tsx +1 -1
  150. package/registry/examples/input-group-demo.tsx +16 -0
  151. package/registry/examples/input-otp-demo.tsx +72 -0
  152. package/registry/examples/kbd-demo.tsx +11 -0
  153. package/registry/examples/label-demo.tsx +20 -0
  154. package/registry/examples/menu-demo.tsx +101 -88
  155. package/registry/examples/menubar-demo.tsx +34 -0
  156. package/registry/examples/native-select-demo.tsx +16 -0
  157. package/registry/examples/navigation-menu-demo.tsx +29 -0
  158. package/registry/examples/popover-demo.tsx +546 -0
  159. package/registry/examples/resizable-demo.tsx +22 -0
  160. package/registry/examples/scroll-area-demo.tsx +15 -0
  161. package/registry/examples/select-demo.tsx +1 -1
  162. package/registry/examples/sheet-demo.tsx +47 -0
  163. package/registry/examples/sidebar-demo.tsx +55 -0
  164. package/registry/examples/skeleton-demo.tsx +56 -0
  165. package/registry/examples/spinner-demo.tsx +23 -1
  166. package/registry/examples/tabs-demo.tsx +13 -0
  167. package/registry/examples/textarea-demo.tsx +1 -1
  168. package/registry/examples/toast-demo.tsx +35 -0
  169. package/registry/index.json +895 -19
  170. package/registry/styles/globals.css +4733 -4602
  171. package/registry.json +1612 -0
  172. package/schema/config.schema.json +48 -0
  173. package/schema/registry.schema.json +85 -0
  174. package/schema/tokens.schema.json +63 -0
  175. package/src/cli/index.js +1413 -184
  176. package/tokens/banhaten.tokens.json +1 -1
  177. package/registry/assets/avatars/avatar-02.jpg +0 -0
  178. package/registry/assets/avatars/avatar-03.jpg +0 -0
  179. package/registry/assets/avatars/avatar-04.jpg +0 -0
  180. package/registry/assets/avatars/avatar-05.jpg +0 -0
  181. package/registry/assets/avatars/avatar-06.jpg +0 -0
  182. package/registry/assets/avatars/avatar-07.jpg +0 -0
  183. package/registry/assets/avatars/avatar-08.jpg +0 -0
  184. package/registry/assets/avatars/avatar-09.jpg +0 -0
  185. package/registry/assets/avatars/avatar-10.jpg +0 -0
  186. package/registry/assets/avatars/avatar-11.jpg +0 -0
  187. package/registry/assets/avatars/avatar-12.jpg +0 -0
  188. package/registry/assets/avatars/avatar-13.jpg +0 -0
  189. package/registry/assets/avatars/avatar-14.jpg +0 -0
  190. package/registry/assets/avatars/avatar-15.jpg +0 -0
  191. package/registry/assets/avatars/avatar-16.jpg +0 -0
  192. package/registry/assets/avatars/avatar-17.jpg +0 -0
  193. package/registry/assets/avatars/avatar-18.jpg +0 -0
  194. package/registry/assets/avatars/avatar-19.jpg +0 -0
  195. package/registry/assets/avatars/avatar-20.jpg +0 -0
  196. package/registry/assets/avatars/avatar-21.jpg +0 -0
  197. package/registry/assets/avatars/avatar-22.jpg +0 -0
  198. package/registry/assets/avatars/avatar-23.jpg +0 -0
  199. package/registry/assets/avatars/avatar-24.jpg +0 -0
  200. package/registry/assets/avatars/avatar-25.jpg +0 -0
  201. package/registry/assets/avatars/avatar-26.jpg +0 -0
  202. package/registry/assets/avatars/avatar-27.jpg +0 -0
  203. package/registry/assets/avatars/avatar-28.jpg +0 -0
  204. package/registry/assets/avatars/avatar-29.jpg +0 -0
  205. package/registry/assets/avatars/avatar-30.jpg +0 -0
  206. package/registry/assets/avatars/avatar-31.jpg +0 -0
  207. package/registry/assets/avatars/avatar-32.jpg +0 -0
  208. package/registry/assets/avatars/avatar-33.jpg +0 -0
  209. package/registry/assets/avatars/avatar-34.jpg +0 -0
  210. package/registry/assets/avatars/avatar-35.jpg +0 -0
  211. package/registry/assets/image-assets.json +0 -744
  212. package/registry/assets/images/art-02.jpg +0 -0
  213. package/registry/assets/images/art-03.jpg +0 -0
  214. package/registry/assets/images/art-04.jpg +0 -0
  215. package/registry/assets/images/art-05.jpg +0 -0
  216. package/registry/assets/images/art-06.jpg +0 -0
  217. package/registry/assets/images/art-07.jpg +0 -0
  218. package/registry/assets/images/art-08.jpg +0 -0
  219. package/registry/assets/images/art-09.jpg +0 -0
  220. package/registry/assets/images/art-10.jpg +0 -0
  221. package/registry/assets/images/art-11.jpg +0 -0
  222. package/registry/assets/images/art-12.jpg +0 -0
  223. package/registry/assets/images/art-13.jpg +0 -0
  224. package/registry/assets/images/art-14.jpg +0 -0
  225. package/registry/assets/images/art-15.jpg +0 -0
  226. package/registry/assets/images/art-16.jpg +0 -0
  227. package/registry/assets/images/art-17.jpg +0 -0
  228. package/registry/assets/images/art-18.jpg +0 -0
  229. package/registry/assets/images/art-19.jpg +0 -0
  230. package/registry/assets/images/art-20.jpg +0 -0
  231. package/registry/assets/images/art-21.jpg +0 -0
  232. package/registry/assets/images/art-22.jpg +0 -0
  233. package/registry/assets/images/art-23.jpg +0 -0
  234. package/registry/assets/images/art-24.jpg +0 -0
  235. package/registry/assets/images/art-25.jpg +0 -0
  236. package/registry/assets/images/art-26.jpg +0 -0
  237. package/registry/assets/images/art-27.jpg +0 -0
  238. package/registry/assets/images/nature-01.jpg +0 -0
  239. package/registry/assets/images/nature-02.jpg +0 -0
  240. package/registry/assets/images/nature-03.jpg +0 -0
  241. package/registry/assets/images/nature-04.jpg +0 -0
  242. package/registry/assets/images/nature-05.jpg +0 -0
  243. package/registry/assets/images/nature-06.jpg +0 -0
  244. package/registry/assets/images/nature-07.jpg +0 -0
  245. package/registry/assets/images/nature-08.jpg +0 -0
  246. package/registry/assets/images/nature-09.jpg +0 -0
  247. package/registry/assets/images/nature-10.jpg +0 -0
  248. package/registry/assets/images/nature-11.jpg +0 -0
  249. package/registry/assets/images/nature-12.jpg +0 -0
  250. package/registry/assets/images/nature-13.jpg +0 -0
  251. package/registry/assets/images/nature-14.jpg +0 -0
  252. package/registry/assets/images/nature-15.jpg +0 -0
  253. package/registry/assets/images/nature-16.jpg +0 -0
  254. package/registry/assets/images/nature-17.jpg +0 -0
  255. package/registry/assets/images/nature-18.jpg +0 -0
  256. package/registry/assets/images/nature-19.jpg +0 -0
  257. package/registry/assets/images/nature-20.jpg +0 -0
  258. package/registry/components/expanded/Tabs.tsx +0 -86
package/src/cli/index.js CHANGED
@@ -12,6 +12,8 @@ const packageRoot = path.resolve(
12
12
  )
13
13
  const registryRoot = path.join(packageRoot, "registry")
14
14
  const registryIndexPath = path.join(registryRoot, "index.json")
15
+ const packageJsonPath = path.join(packageRoot, "package.json")
16
+ const schemaRoot = path.join(packageRoot, "schema")
15
17
 
16
18
  const CONFIG_FILE = "banhaten.config.json"
17
19
  const FONT_IMPORTS = [
@@ -30,9 +32,14 @@ const VITE_DEV_DEPENDENCIES = {
30
32
  const CSS_START = "/* Banhaten design system tokens:"
31
33
  const CSS_END = "/* End Banhaten design system tokens. */"
32
34
  const syncExistsCache = new Map()
35
+ const VALUE_FLAGS = new Set(["cwd", "file", "format"])
36
+ let runtimeOptions = {
37
+ json: false,
38
+ silent: false,
39
+ }
33
40
 
34
41
  const DEFAULT_CONFIG = {
35
- $schema: "https://banhaten.dev/schema.json",
42
+ $schema: ".banhaten/schema/config.schema.json",
36
43
  style: "default",
37
44
  tsx: true,
38
45
  tailwind: {
@@ -46,6 +53,7 @@ const DEFAULT_CONFIG = {
46
53
  }
47
54
 
48
55
  const registry = await readJson(registryIndexPath)
56
+ const packageManifest = await readJson(packageJsonPath)
49
57
 
50
58
  main().catch((error) => {
51
59
  console.error(`banhaten: ${error.message}`)
@@ -54,6 +62,15 @@ main().catch((error) => {
54
62
 
55
63
  async function main() {
56
64
  const { command, positionals, flags } = parseArgs(process.argv.slice(2))
65
+ runtimeOptions = {
66
+ json: Boolean(flags.json),
67
+ silent: Boolean(flags.silent || flags.s),
68
+ }
69
+
70
+ if (command === "version" || flags.version || flags.v) {
71
+ console.log(packageManifest.version)
72
+ return
73
+ }
57
74
 
58
75
  if (!command || command === "help" || flags.help || flags.h) {
59
76
  printHelp()
@@ -61,7 +78,22 @@ async function main() {
61
78
  }
62
79
 
63
80
  if (command === "list") {
64
- listComponents()
81
+ listComponents(flags)
82
+ return
83
+ }
84
+
85
+ if (command === "search") {
86
+ searchComponents(positionals, flags)
87
+ return
88
+ }
89
+
90
+ if (command === "docs") {
91
+ showComponentDocs(positionals, flags)
92
+ return
93
+ }
94
+
95
+ if (command === "view") {
96
+ await viewComponent(positionals, flags)
65
97
  return
66
98
  }
67
99
 
@@ -100,6 +132,21 @@ function parseArgs(argv) {
100
132
  continue
101
133
  }
102
134
 
135
+ if (arg === "-s") {
136
+ flags.s = true
137
+ continue
138
+ }
139
+
140
+ if (arg === "-v") {
141
+ flags.v = true
142
+ continue
143
+ }
144
+
145
+ if (arg === "-y") {
146
+ flags.yes = true
147
+ continue
148
+ }
149
+
103
150
  if (!arg.startsWith("--")) {
104
151
  positionals.push(arg)
105
152
  continue
@@ -114,7 +161,7 @@ function parseArgs(argv) {
114
161
  }
115
162
 
116
163
  const next = argv[index + 1]
117
- if ((key === "cwd" || key === "out") && next && !next.startsWith("-")) {
164
+ if (VALUE_FLAGS.has(key) && next && !next.startsWith("-")) {
118
165
  flags[key] = next
119
166
  index += 1
120
167
  continue
@@ -135,24 +182,196 @@ Usage:
135
182
  banhaten add <component...> [--cwd <path>] [--force] [--dry-run]
136
183
  banhaten diff [component...] [--cwd <path>] [--force]
137
184
  banhaten update [component...] [--cwd <path>] [--force] [--dry-run]
138
- banhaten list
185
+ banhaten list [--json]
186
+ banhaten search <query> [--json]
187
+ banhaten docs <component> [--json]
188
+ banhaten view <component> [--file <registry-source>] [--json]
189
+ banhaten version
139
190
 
140
191
  Examples:
141
192
  npx banhaten init
142
193
  npx banhaten add button
143
194
  npx banhaten diff
144
195
  npx banhaten update button
196
+ npx banhaten search input
197
+ npx banhaten docs button
198
+ npx banhaten view button
199
+
200
+ Global flags:
201
+ --cwd <path> Run against a project directory.
202
+ --dry-run Preview writes without changing files.
203
+ --force Rewrite generated files even when already current.
204
+ --json Print machine-readable output where supported.
205
+ --silent, -s Suppress normal command output.
206
+ --yes, -y Accepted for non-interactive scripts.
207
+ --version, -v Print the installed CLI version.
145
208
  `)
146
209
  }
147
210
 
148
211
  function listComponents() {
149
- const names = Object.keys(registry.components)
212
+ const components = Object.entries(registry.components).map(([name, component]) => ({
213
+ name,
214
+ description: component.description || "",
215
+ dependencies: Object.keys(component.dependencies || {}),
216
+ registryDependencies: component.registryDependencies || [],
217
+ }))
218
+
219
+ if (runtimeOptions.json) {
220
+ writeJson({ components })
221
+ return
222
+ }
223
+
224
+ if (runtimeOptions.silent) return
150
225
 
151
226
  console.log("Available Banhaten design system components:")
152
- for (const name of names) {
153
- const component = registry.components[name]
154
- console.log(`- ${name}: ${component.description}`)
227
+ for (const { name, description } of components) {
228
+ console.log(`- ${name}: ${description}`)
229
+ }
230
+ }
231
+
232
+ function searchComponents(positionals) {
233
+ const query = positionals.join(" ").trim().toLowerCase()
234
+ if (!query) {
235
+ throw new Error("missing search query. Try `banhaten search input`.")
236
+ }
237
+
238
+ const matches = Object.entries(registry.components)
239
+ .filter(([name, component]) => {
240
+ const haystack = [
241
+ name,
242
+ component.description,
243
+ ...(component.parts || []),
244
+ ...(component.registryDependencies || []),
245
+ ]
246
+ .filter(Boolean)
247
+ .join(" ")
248
+ .toLowerCase()
249
+
250
+ return haystack.includes(query)
251
+ })
252
+ .map(([name, component]) => ({
253
+ name,
254
+ description: component.description || "",
255
+ parts: component.parts || [],
256
+ }))
257
+
258
+ if (runtimeOptions.json) {
259
+ writeJson({ query, matches })
260
+ return
261
+ }
262
+
263
+ if (runtimeOptions.silent) return
264
+
265
+ if (matches.length === 0) {
266
+ console.log(`No Banhaten components matched "${query}".`)
267
+ return
268
+ }
269
+
270
+ console.log(`Banhaten components matching "${query}":`)
271
+ for (const match of matches) {
272
+ console.log(`- ${match.name}: ${match.description}`)
273
+ }
274
+ }
275
+
276
+ function showComponentDocs(positionals) {
277
+ const [name] = positionals
278
+ const component = getComponentOrThrow(name)
279
+ const docsPath = getComponentDocPath(name)
280
+ const result = {
281
+ name,
282
+ description: component.description || "",
283
+ install: `npx banhaten add ${name}`,
284
+ docsPath,
285
+ files: component.files || [],
286
+ examples: component.examples || [],
287
+ }
288
+
289
+ if (runtimeOptions.json) {
290
+ writeJson(result)
291
+ return
292
+ }
293
+
294
+ if (runtimeOptions.silent) return
295
+
296
+ console.log(`${name}: ${result.description}`)
297
+ console.log(`Install: ${result.install}`)
298
+ console.log(`Docs: ${docsPath}`)
299
+
300
+ if (result.files.length > 0) {
301
+ console.log("Files:")
302
+ for (const file of result.files) {
303
+ console.log(`- ${file.source} -> ${file.target}`)
304
+ }
305
+ }
306
+
307
+ if (result.examples.length > 0) {
308
+ console.log("Examples:")
309
+ for (const example of result.examples) {
310
+ console.log(`- ${example}`)
311
+ }
312
+ }
313
+ }
314
+
315
+ async function viewComponent(positionals, flags) {
316
+ const [name] = positionals
317
+ const component = getComponentOrThrow(name)
318
+ const files = component.files || []
319
+ const requestedFile = typeof flags.file === "string" ? flags.file : null
320
+ const file =
321
+ (requestedFile
322
+ ? files.find((entry) => entry.source === requestedFile)
323
+ : files.find((entry) => /\.(ts|tsx|css)$/.test(entry.source)) || files[0]) ||
324
+ null
325
+
326
+ if (!file) {
327
+ throw new Error(`component "${name}" has no viewable registry files.`)
328
+ }
329
+
330
+ const sourcePath = path.join(registryRoot, file.source)
331
+ const content = await fs.readFile(
332
+ sourcePath,
333
+ isTextRegistryFile(file.source) ? "utf8" : undefined
334
+ )
335
+
336
+ if (runtimeOptions.json) {
337
+ writeJson({
338
+ name,
339
+ file,
340
+ content: Buffer.isBuffer(content) ? content.toString("base64") : content,
341
+ encoding: Buffer.isBuffer(content) ? "base64" : "utf8",
342
+ })
343
+ return
155
344
  }
345
+
346
+ if (runtimeOptions.silent) return
347
+
348
+ console.log(Buffer.isBuffer(content) ? content.toString("base64") : content)
349
+ }
350
+
351
+ function getComponentOrThrow(name) {
352
+ if (!name) {
353
+ throw new Error("missing component name. Try `banhaten docs button`.")
354
+ }
355
+
356
+ const component = registry.components[name]
357
+ if (!component) {
358
+ throw new Error(`unknown component "${name}". Run \`banhaten list\`.`)
359
+ }
360
+
361
+ return component
362
+ }
363
+
364
+ function getComponentDocPath(name) {
365
+ const docAliases = {
366
+ "onboarding-step": "onboarding-step-list-item",
367
+ }
368
+ const docId = docAliases[name] || name
369
+
370
+ return `docs/design-system/components/${docId}.md`
371
+ }
372
+
373
+ function writeJson(value) {
374
+ console.log(JSON.stringify(value, null, 2))
156
375
  }
157
376
 
158
377
  async function initProject(flags) {
@@ -165,6 +384,7 @@ async function initProject(flags) {
165
384
 
166
385
  writes.push(
167
386
  ...(await writeConfig(cwd, config, { dryRun, force })),
387
+ ...(await writeProjectSchemaFiles(cwd, { dryRun, force })),
168
388
  ...(await writeBaseFiles(cwd, config, { dryRun, force })),
169
389
  ...(await writeGlobalCss(cwd, config, { dryRun, force })),
170
390
  ...(await writeAliasConfig(cwd, config, { dryRun, force }))
@@ -178,7 +398,7 @@ async function initProject(flags) {
178
398
  writes.push(...dependencyWrites)
179
399
 
180
400
  printResult("Initialized Banhaten design system", writes, dryRun)
181
- printInstallHint(writes, dryRun)
401
+ await printInstallHint(cwd, writes, dryRun)
182
402
  }
183
403
 
184
404
  async function addComponents(names, flags) {
@@ -236,7 +456,9 @@ async function writeComponents(requestedNames, flags, options) {
236
456
 
237
457
  writes.push(
238
458
  ...(await writeConfig(cwd, config, { dryRun, force })),
459
+ ...(await writeProjectSchemaFiles(cwd, { dryRun, force })),
239
460
  ...(await writeBaseFiles(cwd, config, { dryRun, force })),
461
+ ...(await writeGlobalCss(cwd, config, { dryRun, force })),
240
462
  ...(await writeAliasConfig(cwd, config, { dryRun, force }))
241
463
  )
242
464
 
@@ -262,7 +484,7 @@ async function writeComponents(requestedNames, flags, options) {
262
484
  )))
263
485
 
264
486
  printResult(options.title, writes, dryRun)
265
- printInstallHint(writes, dryRun)
487
+ await printInstallHint(cwd, writes, dryRun)
266
488
  }
267
489
 
268
490
  async function findInstalledComponents(cwd, config) {
@@ -371,6 +593,24 @@ async function detectCssPath(cwd, fallback) {
371
593
  async function writeConfig(cwd, config, options) {
372
594
  const configPath = path.join(cwd, CONFIG_FILE)
373
595
  if ((await exists(configPath)) && !options.force) {
596
+ const current = await fs.readFile(configPath, "utf8")
597
+ const currentConfig = JSON.parse(current)
598
+
599
+ if (currentConfig.$schema !== DEFAULT_CONFIG.$schema) {
600
+ const nextConfig = {
601
+ $schema: DEFAULT_CONFIG.$schema,
602
+ ...currentConfig,
603
+ }
604
+ nextConfig.$schema = DEFAULT_CONFIG.$schema
605
+ const status = await writeFile(
606
+ configPath,
607
+ `${JSON.stringify(nextConfig, null, 2)}\n`,
608
+ { dryRun: options.dryRun }
609
+ )
610
+
611
+ return [`${status} ${relative(cwd, configPath)}`]
612
+ }
613
+
374
614
  return [`kept ${relative(cwd, configPath)}`]
375
615
  }
376
616
 
@@ -382,6 +622,26 @@ async function writeConfig(cwd, config, options) {
382
622
  return [`${status} ${relative(cwd, configPath)}`]
383
623
  }
384
624
 
625
+ async function writeProjectSchemaFiles(cwd, options) {
626
+ const schemaFiles = [
627
+ "config.schema.json",
628
+ "registry.schema.json",
629
+ "tokens.schema.json",
630
+ ]
631
+ const writes = []
632
+
633
+ for (const schemaFile of schemaFiles) {
634
+ const sourcePath = path.join(schemaRoot, schemaFile)
635
+ const targetPath = path.join(cwd, ".banhaten", "schema", schemaFile)
636
+ const source = await fs.readFile(sourcePath, "utf8")
637
+ const status = await writeFile(targetPath, source, options)
638
+
639
+ writes.push(`${status} ${relative(cwd, targetPath)}`)
640
+ }
641
+
642
+ return writes
643
+ }
644
+
385
645
  async function writeBaseFiles(cwd, config, options) {
386
646
  return writeRegistryFiles(cwd, config, registry.base.files || [], options)
387
647
  }
@@ -409,17 +669,16 @@ async function writeGlobalCss(cwd, config, options) {
409
669
  const sourcePath = path.join(registryRoot, "styles", "globals.css")
410
670
  const tokenCss = await fs.readFile(sourcePath, "utf8")
411
671
  const current = await readTextIfExists(cssPath)
412
-
413
- if (current?.includes(CSS_START) && !options.force) {
414
- return [`kept ${relative(cwd, cssPath)}`]
415
- }
672
+ const tokenBlock = current
673
+ ? removeExistingTokenBlock(current, relative(cwd, cssPath))
674
+ : { content: "", found: false }
416
675
 
417
676
  const nextCss = current
418
- ? `${removeExistingTokenBlock(current).trimEnd()}\n\n${tokenCss}\n`
677
+ ? `${tokenBlock.content.trimEnd()}\n\n${tokenCss}\n`
419
678
  : `${tokenCss}\n`
420
679
  const next = ensureGlobalCssImports(nextCss)
421
680
 
422
- const status = await writeFile(cssPath, next, { ...options, force: true })
681
+ const status = await writeFile(cssPath, next, options)
423
682
  return [`${status} ${relative(cwd, cssPath)}`]
424
683
  }
425
684
 
@@ -489,20 +748,17 @@ async function writeViteAliasConfig(cwd, aliasRoot, options) {
489
748
 
490
749
  const current = await fs.readFile(viteConfigPath, "utf8")
491
750
  const sourceRoot = shouldUseSrcDirectory(cwd) ? "./src" : "."
492
- const aliasLine = `"${aliasRoot}": fileURLToPath(new URL("${sourceRoot}", import.meta.url))`
751
+ let next
493
752
 
494
- if (
495
- current.includes(aliasLine) &&
496
- current.includes("@tailwindcss/vite") &&
497
- current.includes("tailwindcss()")
498
- ) {
499
- return [`kept ${relative(cwd, viteConfigPath)}`]
753
+ try {
754
+ next = updateViteConfigSource(current, { aliasRoot, sourceRoot })
755
+ } catch (error) {
756
+ throw new Error(`${relative(cwd, viteConfigPath)}: ${error.message}`)
500
757
  }
501
758
 
502
- let next = ensureNodeUrlImport(current)
503
- next = ensureTailwindViteImport(next)
504
- next = addViteResolveAlias(next, aliasRoot, sourceRoot)
505
- next = addVitePluginCall(next, "tailwindcss()")
759
+ if (next === current) {
760
+ return [`kept ${relative(cwd, viteConfigPath)}`]
761
+ }
506
762
 
507
763
  const status = await writeFile(viteConfigPath, next, {
508
764
  dryRun: options.dryRun,
@@ -528,198 +784,957 @@ async function findViteConfigPath(cwd) {
528
784
  }
529
785
 
530
786
  function ensureNodeUrlImport(source) {
531
- if (source.includes("fileURLToPath") && source.includes("node:url")) return source
787
+ const imports = parseImportDeclarations(source)
788
+ const nodeUrlImports = imports.filter((entry) => entry.moduleSpecifier === "node:url")
789
+ const importedLocals = new Set(nodeUrlImports.flatMap((entry) => entry.namedLocals))
790
+ const missing = ["fileURLToPath", "URL"].filter((name) => !importedLocals.has(name))
532
791
 
533
- return `import { fileURLToPath, URL } from "node:url"\n${source}`
792
+ if (missing.length === 0) return source
793
+
794
+ return prependImportLines(
795
+ source,
796
+ [`import { ${missing.join(", ")} } from "node:url"`]
797
+ )
534
798
  }
535
799
 
536
- function ensureTailwindViteImport(source) {
537
- if (source.includes("@tailwindcss/vite")) return source
800
+ function ensureTailwindViteImport(source, localName) {
801
+ const imports = parseImportDeclarations(source)
802
+ const tailwindImport = imports.find(
803
+ (entry) => entry.moduleSpecifier === "@tailwindcss/vite"
804
+ )
538
805
 
539
- return `import tailwindcss from "@tailwindcss/vite"\n${source}`
540
- }
806
+ if (!tailwindImport) {
807
+ return prependImportLines(
808
+ source,
809
+ [`import ${localName} from "@tailwindcss/vite"`]
810
+ )
811
+ }
541
812
 
542
- function addViteResolveAlias(source, aliasRoot, sourceRoot) {
543
- const aliasLine = `"${aliasRoot}": fileURLToPath(new URL("${sourceRoot}", import.meta.url))`
544
- if (source.includes(aliasLine)) return source
545
- if (!source.includes("defineConfig({")) return source
546
-
547
- const resolveBlock = [
548
- " resolve: {",
549
- " alias: {",
550
- ` "${aliasRoot}": fileURLToPath(new URL("${sourceRoot}", import.meta.url)),`,
551
- " },",
552
- " },",
553
- ].join("\n")
554
-
555
- return source.replace("defineConfig({", `defineConfig({\n${resolveBlock}`)
556
- }
557
-
558
- function addVitePluginCall(source, pluginCall) {
559
- if (source.includes(pluginCall)) return source
560
-
561
- const pluginsPattern = /plugins:\s*\[([\s\S]*?)\]/
562
- if (pluginsPattern.test(source)) {
563
- return source.replace(pluginsPattern, (_match, plugins) => {
564
- const currentPlugins = plugins.trim().replace(/,\s*$/, "")
565
- const nextPlugins = currentPlugins
566
- ? `${currentPlugins}, ${pluginCall}`
567
- : pluginCall
568
- return `plugins: [${nextPlugins}]`
569
- })
813
+ if (!tailwindImport.defaultLocal) {
814
+ throw new Error(
815
+ "unsupported Vite config: @tailwindcss/vite must be imported with a default binding."
816
+ )
570
817
  }
571
818
 
572
- if (!source.includes("defineConfig({")) return source
819
+ return source
820
+ }
573
821
 
574
- return source.replace("defineConfig({", `defineConfig({\n plugins: [${pluginCall}],`)
822
+ function updateViteConfigSource(source, { aliasRoot, sourceRoot }) {
823
+ const tailwindLocalName = getTailwindViteLocalName(source)
824
+ let next = addViteResolveAlias(source, aliasRoot, sourceRoot)
825
+ next = addVitePluginCall(next, `${tailwindLocalName}()`, tailwindLocalName)
826
+ next = ensureTailwindViteImport(next, tailwindLocalName)
827
+ next = ensureNodeUrlImport(next)
828
+ return next
575
829
  }
576
830
 
577
- async function getPackageChanges(cwd, dependencies) {
578
- const devDependencies = { ...SETUP_DEV_DEPENDENCIES }
579
- if (await findViteConfigPath(cwd)) {
580
- Object.assign(devDependencies, VITE_DEV_DEPENDENCIES)
581
- }
831
+ function getTailwindViteLocalName(source) {
832
+ const tailwindImport = parseImportDeclarations(source).find(
833
+ (entry) => entry.moduleSpecifier === "@tailwindcss/vite"
834
+ )
582
835
 
583
- return {
584
- dependencies,
585
- devDependencies,
586
- }
836
+ if (!tailwindImport) return "tailwindcss"
837
+ if (tailwindImport.defaultLocal) return tailwindImport.defaultLocal
838
+
839
+ throw new Error(
840
+ "unsupported Vite config: @tailwindcss/vite must be imported with a default binding."
841
+ )
587
842
  }
588
843
 
589
- async function updatePackageJson(cwd, changes, options) {
590
- const packagePath = path.join(cwd, "package.json")
591
- const dependencies = changes.dependencies || {}
592
- const devDependencies = changes.devDependencies || {}
593
- const packageJson = (await readJsonIfExists(packagePath)) || {
594
- name: path.basename(cwd).toLowerCase(),
595
- version: "0.0.0",
596
- private: true,
844
+ function addViteResolveAlias(source, aliasRoot, sourceRoot) {
845
+ const configObject = findViteConfigObject(source)
846
+ const resolveProperty = findObjectProperty(source, configObject, "resolve")
847
+ const aliasEntry = `${JSON.stringify(aliasRoot)}: fileURLToPath(new URL(${JSON.stringify(
848
+ sourceRoot
849
+ )}, import.meta.url))`
850
+
851
+ if (!resolveProperty) {
852
+ return insertObjectProperty(
853
+ source,
854
+ configObject,
855
+ [
856
+ "resolve: {",
857
+ " alias: {",
858
+ ` ${aliasEntry},`,
859
+ " },",
860
+ "},",
861
+ ].join("\n")
862
+ )
597
863
  }
598
- const added = [
599
- ...addPackageSection(packageJson, "dependencies", dependencies),
600
- ...addPackageSection(packageJson, "devDependencies", devDependencies),
601
- ]
602
864
 
603
- if (added.length === 0 && (await exists(packagePath))) {
604
- return [`kept ${relative(cwd, packagePath)}`]
865
+ const resolveObject = expectObjectLiteralValue(
866
+ source,
867
+ resolveProperty,
868
+ "unsupported Vite config: resolve must be an object literal."
869
+ )
870
+ const aliasProperty = findObjectProperty(source, resolveObject, "alias")
871
+
872
+ if (!aliasProperty) {
873
+ return insertObjectProperty(
874
+ source,
875
+ resolveObject,
876
+ [
877
+ "alias: {",
878
+ ` ${aliasEntry},`,
879
+ "},",
880
+ ].join("\n")
881
+ )
605
882
  }
606
883
 
607
- const status = await writeFile(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`, {
608
- dryRun: options.dryRun,
609
- force: true,
610
- })
884
+ const aliasObject = expectObjectLiteralValue(
885
+ source,
886
+ aliasProperty,
887
+ "unsupported Vite config: resolve.alias must be an object literal."
888
+ )
611
889
 
612
- return [
613
- `${status} ${relative(cwd, packagePath)}`,
614
- ]
615
- }
890
+ if (findObjectProperty(source, aliasObject, aliasRoot)) {
891
+ return source
892
+ }
616
893
 
617
- function addPackageSection(packageJson, section, dependencies) {
618
- const added = []
619
- const existing = packageJson[section] || {}
894
+ return insertObjectProperty(source, aliasObject, `${aliasEntry},`)
895
+ }
620
896
 
621
- for (const [name, version] of Object.entries(dependencies)) {
622
- if (hasPackageDependency(packageJson, name)) continue
897
+ function addVitePluginCall(source, pluginCall, pluginLocalName) {
898
+ const configObject = findViteConfigObject(source)
899
+ const pluginsProperty = findObjectProperty(source, configObject, "plugins")
623
900
 
624
- existing[name] = version
625
- added.push(name)
901
+ if (!pluginsProperty) {
902
+ return insertObjectProperty(source, configObject, `plugins: [${pluginCall}],`)
626
903
  }
627
904
 
628
- if (added.length > 0 || packageJson[section]) {
629
- packageJson[section] = sortObject(existing)
905
+ const pluginsArray = expectArrayLiteralValue(
906
+ source,
907
+ pluginsProperty,
908
+ "unsupported Vite config: plugins must be an array literal."
909
+ )
910
+
911
+ if (arrayHasCallExpression(source, pluginsArray, pluginLocalName)) {
912
+ return source
630
913
  }
631
914
 
632
- return added
915
+ return appendArrayElement(source, pluginsArray, pluginCall)
633
916
  }
634
917
 
635
- function hasPackageDependency(packageJson, name) {
636
- return Boolean(
637
- packageJson.dependencies?.[name] ||
638
- packageJson.devDependencies?.[name] ||
639
- packageJson.peerDependencies?.[name] ||
640
- packageJson.optionalDependencies?.[name]
641
- )
642
- }
918
+ function findViteConfigObject(source) {
919
+ const expressionStart = findExportDefaultExpressionStart(source)
643
920
 
644
- function applyTemplate(source, config) {
645
- return source
646
- .replaceAll("@/lib/utils", config.aliases.utils)
647
- .replaceAll("@/hooks", config.aliases.hooks)
648
- }
921
+ if (expressionStart === -1) {
922
+ throw new Error(
923
+ "unsupported Vite config: expected `export default defineConfig({ ... })` or `export default { ... }`."
924
+ )
925
+ }
649
926
 
650
- function isTextRegistryFile(source) {
651
- return !/\.(avif|eot|gif|ico|jpe?g|otf|png|ttf|webp|woff2?)$/i.test(source)
652
- }
927
+ if (source[expressionStart] === "{") {
928
+ return createBracketRange(source, expressionStart, "{")
929
+ }
653
930
 
654
- function resolveTarget(cwd, config, target) {
655
- const replacements = {
656
- "{{ui}}": aliasToPath(cwd, config.aliases.ui),
657
- "{{utils}}": stripExtension(aliasToPath(cwd, config.aliases.utils)),
658
- "{{hooks}}": aliasToPath(cwd, config.aliases.hooks),
931
+ const callee = readIdentifierAt(source, expressionStart)
932
+ if (callee?.name !== "defineConfig") {
933
+ throw new Error(
934
+ "unsupported Vite config: default export must be `defineConfig({ ... })` or an object literal."
935
+ )
659
936
  }
660
937
 
661
- let resolved = target
662
- for (const [placeholder, value] of Object.entries(replacements)) {
663
- resolved = resolved.replaceAll(placeholder, value)
938
+ let index = skipWhitespaceAndComments(source, callee.end)
939
+ if (source[index] !== "(") {
940
+ throw new Error("unsupported Vite config: malformed defineConfig call.")
664
941
  }
665
942
 
666
- return resolved
943
+ index = skipWhitespaceAndComments(source, index + 1)
944
+ if (source[index] !== "{") {
945
+ throw new Error(
946
+ "unsupported Vite config: defineConfig must receive a config object literal."
947
+ )
948
+ }
949
+
950
+ return createBracketRange(source, index, "{")
667
951
  }
668
952
 
669
- function aliasToPath(cwd, alias) {
670
- if (!alias) return ""
671
- if (path.isAbsolute(alias)) return alias
672
- if (alias.startsWith("./")) return alias.slice(2)
673
- if (alias.startsWith("../")) return alias
953
+ function findExportDefaultExpressionStart(source) {
954
+ let index = 0
674
955
 
675
- if (alias.startsWith("@/") || alias.startsWith("~/")) {
676
- const rest = alias.slice(2)
677
- return path.join(shouldUseSrcDirectory(cwd) ? "src" : "", rest)
956
+ while (index < source.length) {
957
+ const next = skipIgnoredSyntax(source, index)
958
+ if (next !== index) {
959
+ index = next
960
+ continue
961
+ }
962
+
963
+ if (!matchIdentifier(source, index, "export")) {
964
+ index += 1
965
+ continue
966
+ }
967
+
968
+ const defaultStart = skipWhitespaceAndComments(source, index + "export".length)
969
+ if (!matchIdentifier(source, defaultStart, "default")) {
970
+ index += "export".length
971
+ continue
972
+ }
973
+
974
+ return skipWhitespaceAndComments(source, defaultStart + "default".length)
678
975
  }
679
976
 
680
- return alias
977
+ return -1
681
978
  }
682
979
 
683
- function shouldUseSrcDirectory(cwd) {
684
- return pathExistsSyncHint(cwd, "src/app") || pathExistsSyncHint(cwd, "src")
980
+ function createBracketRange(source, start, open) {
981
+ const close = open === "{" ? "}" : open === "[" ? "]" : ")"
982
+ const end = findMatchingBracket(source, start, open, close)
983
+ return { start, end: end + 1 }
685
984
  }
686
985
 
687
- function pathExistsSyncHint(cwd, segment) {
688
- return Boolean(syncExistsCache.get(path.join(cwd, segment)))
986
+ function expectObjectLiteralValue(source, property, message) {
987
+ const valueStart = skipWhitespaceAndComments(source, property.valueStart)
988
+ if (source[valueStart] !== "{") throw new Error(message)
989
+ return createBracketRange(source, valueStart, "{")
689
990
  }
690
991
 
691
- async function warmPathHints(cwd) {
692
- for (const segment of ["src", "src/app"]) {
693
- const target = path.join(cwd, segment)
694
- syncExistsCache.set(target, await exists(target))
695
- }
992
+ function expectArrayLiteralValue(source, property, message) {
993
+ const valueStart = skipWhitespaceAndComments(source, property.valueStart)
994
+ if (source[valueStart] !== "[") throw new Error(message)
995
+ return createBracketRange(source, valueStart, "[")
696
996
  }
697
997
 
698
- async function writeFile(targetPath, content, options) {
699
- const alreadyExists = await exists(targetPath)
700
- const isBinary = Buffer.isBuffer(content)
998
+ function findObjectProperty(source, objectRange, propertyName) {
999
+ const bodyEnd = objectRange.end - 1
1000
+ let index = objectRange.start + 1
701
1001
 
702
- if (options.dryRun) {
703
- if (!alreadyExists) return "would write"
1002
+ while (index < bodyEnd) {
1003
+ index = skipWhitespaceCommentsAndCommas(source, index, bodyEnd)
1004
+ if (index >= bodyEnd) return null
704
1005
 
705
- const current = await fs.readFile(targetPath)
706
- const isEqual = isBinary
707
- ? current.equals(content)
708
- : current.toString("utf8") === content
709
- if (isEqual) return "would keep"
1006
+ const propertyStart = index
1007
+ const name = readPropertyName(source, index)
1008
+ const propertyEnd = findTopLevelCommaOrEnd(source, propertyStart, bodyEnd)
710
1009
 
711
- return "would update"
712
- }
1010
+ if (!name) {
1011
+ index = propertyEnd + 1
1012
+ continue
1013
+ }
713
1014
 
714
- if (alreadyExists && !options.force) {
715
- const current = await fs.readFile(targetPath)
716
- const isEqual = isBinary
717
- ? current.equals(content)
718
- : current.toString("utf8") === content
719
- if (isEqual) return "kept"
1015
+ index = skipWhitespaceAndComments(source, name.end)
1016
+ if (source[index] !== ":") {
1017
+ if (name.name === propertyName) {
1018
+ throw new Error(
1019
+ `unsupported Vite config: ${propertyName} must be a normal object property.`
1020
+ )
1021
+ }
1022
+ index = propertyEnd + 1
1023
+ continue
1024
+ }
1025
+
1026
+ if (name.name === propertyName) {
1027
+ return {
1028
+ name: name.name,
1029
+ start: propertyStart,
1030
+ end: propertyEnd,
1031
+ valueStart: skipWhitespaceAndComments(source, index + 1),
1032
+ valueEnd: propertyEnd,
1033
+ }
1034
+ }
1035
+
1036
+ index = propertyEnd + 1
720
1037
  }
721
1038
 
722
- await fs.mkdir(path.dirname(targetPath), { recursive: true })
1039
+ return null
1040
+ }
1041
+
1042
+ function readPropertyName(source, index) {
1043
+ if (source[index] === "\"" || source[index] === "'") {
1044
+ const literal = readStringLiteral(source, index)
1045
+ return literal ? { name: literal.value, end: literal.end } : null
1046
+ }
1047
+
1048
+ return readIdentifierAt(source, index)
1049
+ }
1050
+
1051
+ function insertObjectProperty(source, objectRange, propertyText) {
1052
+ const insertAt = objectRange.start + 1
1053
+ const bodyEnd = objectRange.end - 1
1054
+ const body = source.slice(insertAt, bodyEnd)
1055
+ const hasContent = body.trim().length > 0
1056
+ const propertyIndent = detectObjectChildIndent(source, objectRange)
1057
+ const closeIndent = getLineIndent(source, bodyEnd)
1058
+ const indentedProperty = indentMultiline(propertyText, propertyIndent)
1059
+
1060
+ if (!hasContent) {
1061
+ return `${source.slice(0, insertAt)}\n${indentedProperty}\n${closeIndent}${source.slice(insertAt)}`
1062
+ }
1063
+
1064
+ const needsTrailingNewline = source[insertAt] !== "\n" && source[insertAt] !== "\r"
1065
+ const insertion = `\n${indentedProperty}${needsTrailingNewline ? "\n" : ""}`
1066
+ return `${source.slice(0, insertAt)}${insertion}${source.slice(insertAt)}`
1067
+ }
1068
+
1069
+ function detectObjectChildIndent(source, objectRange) {
1070
+ const bodyEnd = objectRange.end - 1
1071
+ let index = objectRange.start + 1
1072
+
1073
+ while (index < bodyEnd) {
1074
+ if (source[index] === "\n") {
1075
+ const next = skipHorizontalWhitespace(source, index + 1)
1076
+ if (next < bodyEnd && source[next] !== "\n" && source[next] !== "\r") {
1077
+ return source.slice(index + 1, next)
1078
+ }
1079
+ }
1080
+ index += 1
1081
+ }
1082
+
1083
+ return `${getLineIndent(source, objectRange.start)} `
1084
+ }
1085
+
1086
+ function appendArrayElement(source, arrayRange, elementText) {
1087
+ const bodyStart = arrayRange.start + 1
1088
+ const bodyEnd = arrayRange.end - 1
1089
+ const body = source.slice(bodyStart, bodyEnd)
1090
+
1091
+ if (body.trim().length === 0) {
1092
+ return `${source.slice(0, bodyStart)}${elementText}${source.slice(bodyStart)}`
1093
+ }
1094
+
1095
+ const contentEnd = trimEndIndex(source, bodyStart, bodyEnd)
1096
+ const hasNewline = bodyIncludesNewline(source, bodyStart, bodyEnd)
1097
+ const hasTrailingComma = previousCodeChar(source, contentEnd) === ","
1098
+
1099
+ if (!hasNewline) {
1100
+ const separator = hasTrailingComma ? " " : ", "
1101
+ return `${source.slice(0, contentEnd)}${separator}${elementText}${source.slice(contentEnd)}`
1102
+ }
1103
+
1104
+ const closeIndent = getLineIndent(source, bodyEnd)
1105
+ const elementIndent = `${closeIndent} `
1106
+ const separator = hasTrailingComma ? "\n" : ",\n"
1107
+ return `${source.slice(0, contentEnd)}${separator}${elementIndent}${elementText},${source.slice(contentEnd)}`
1108
+ }
1109
+
1110
+ function arrayHasCallExpression(source, arrayRange, localName) {
1111
+ const bodyEnd = arrayRange.end - 1
1112
+ let index = arrayRange.start + 1
1113
+
1114
+ while (index < bodyEnd) {
1115
+ index = skipWhitespaceCommentsAndCommas(source, index, bodyEnd)
1116
+ if (index >= bodyEnd) return false
1117
+
1118
+ const elementEnd = findTopLevelCommaOrEnd(source, index, bodyEnd)
1119
+ if (startsWithCallExpression(source, index, elementEnd, localName)) {
1120
+ return true
1121
+ }
1122
+ index = elementEnd + 1
1123
+ }
1124
+
1125
+ return false
1126
+ }
1127
+
1128
+ function startsWithCallExpression(source, start, end, localName) {
1129
+ const index = skipWhitespaceAndComments(source, start)
1130
+ if (!matchIdentifier(source, index, localName)) return false
1131
+
1132
+ const callStart = skipWhitespaceAndComments(source, index + localName.length)
1133
+ if (source[callStart] !== "(") return false
1134
+
1135
+ const callEnd = createBracketRange(source, callStart, "(").end
1136
+ const next = skipWhitespaceAndComments(source, callEnd)
1137
+ return next <= end
1138
+ }
1139
+
1140
+ function parseImportDeclarations(source) {
1141
+ const declarations = []
1142
+ let index = 0
1143
+
1144
+ while (index < source.length) {
1145
+ const next = skipIgnoredSyntax(source, index)
1146
+ if (next !== index) {
1147
+ index = next
1148
+ continue
1149
+ }
1150
+
1151
+ if (!matchIdentifier(source, index, "import")) {
1152
+ index += 1
1153
+ continue
1154
+ }
1155
+
1156
+ const clauseStart = skipWhitespaceAndComments(source, index + "import".length)
1157
+ if (source[clauseStart] === ".") {
1158
+ index = clauseStart + 1
1159
+ continue
1160
+ }
1161
+ if (source[clauseStart] === "(") {
1162
+ index = clauseStart + 1
1163
+ continue
1164
+ }
1165
+
1166
+ const end = findImportDeclarationEnd(source, index)
1167
+ const declaration = source.slice(index, end)
1168
+ const moduleSpecifier = getImportModuleSpecifier(declaration)
1169
+
1170
+ if (moduleSpecifier) {
1171
+ declarations.push({
1172
+ start: index,
1173
+ end,
1174
+ moduleSpecifier,
1175
+ defaultLocal: getDefaultImportLocal(declaration),
1176
+ namedLocals: getNamedImportLocals(declaration),
1177
+ })
1178
+ }
1179
+
1180
+ index = end
1181
+ }
1182
+
1183
+ return declarations
1184
+ }
1185
+
1186
+ function findImportDeclarationEnd(source, start) {
1187
+ const stack = []
1188
+ let sawModuleSpecifier = false
1189
+ let index = start
1190
+
1191
+ while (index < source.length) {
1192
+ const skipped = skipComment(source, index)
1193
+ if (skipped !== index) {
1194
+ index = skipped
1195
+ continue
1196
+ }
1197
+
1198
+ if (source[index] === "\"" || source[index] === "'") {
1199
+ const literal = readStringLiteral(source, index)
1200
+ if (!literal) return source.length
1201
+ sawModuleSpecifier = true
1202
+ index = literal.end
1203
+ continue
1204
+ }
1205
+
1206
+ if (isOpeningBracket(source[index])) {
1207
+ stack.push(expectedClosingBracket(source[index]))
1208
+ index += 1
1209
+ continue
1210
+ }
1211
+
1212
+ if (isClosingBracket(source[index])) {
1213
+ if (stack.at(-1) === source[index]) stack.pop()
1214
+ index += 1
1215
+ continue
1216
+ }
1217
+
1218
+ if (stack.length === 0 && source[index] === ";") return index + 1
1219
+ if (
1220
+ stack.length === 0 &&
1221
+ sawModuleSpecifier &&
1222
+ (source[index] === "\n" || source[index] === "\r")
1223
+ ) {
1224
+ return index + 1
1225
+ }
1226
+
1227
+ index += 1
1228
+ }
1229
+
1230
+ return source.length
1231
+ }
1232
+
1233
+ function getImportModuleSpecifier(declaration) {
1234
+ let moduleSpecifier = null
1235
+ let index = 0
1236
+
1237
+ while (index < declaration.length) {
1238
+ const skipped = skipComment(declaration, index)
1239
+ if (skipped !== index) {
1240
+ index = skipped
1241
+ continue
1242
+ }
1243
+
1244
+ if (declaration[index] === "\"" || declaration[index] === "'") {
1245
+ const literal = readStringLiteral(declaration, index)
1246
+ if (!literal) break
1247
+ moduleSpecifier = literal.value
1248
+ index = literal.end
1249
+ continue
1250
+ }
1251
+
1252
+ index += 1
1253
+ }
1254
+
1255
+ return moduleSpecifier
1256
+ }
1257
+
1258
+ function getDefaultImportLocal(declaration) {
1259
+ let index = skipWhitespaceAndComments(declaration, "import".length)
1260
+
1261
+ if (matchIdentifier(declaration, index, "type")) {
1262
+ index = skipWhitespaceAndComments(declaration, index + "type".length)
1263
+ }
1264
+
1265
+ if (declaration[index] === "\"" || declaration[index] === "{" || declaration[index] === "*") {
1266
+ return null
1267
+ }
1268
+
1269
+ const local = readIdentifierAt(declaration, index)
1270
+ return local?.name ?? null
1271
+ }
1272
+
1273
+ function getNamedImportLocals(declaration) {
1274
+ const open = declaration.indexOf("{")
1275
+ if (open === -1) return []
1276
+
1277
+ const close = findMatchingBracket(declaration, open, "{", "}")
1278
+ const namedImportBody = declaration.slice(open + 1, close)
1279
+ const locals = []
1280
+
1281
+ for (const specifier of splitTopLevelList(namedImportBody)) {
1282
+ const trimmed = specifier.trim()
1283
+ if (!trimmed) continue
1284
+
1285
+ const withoutType = trimmed.startsWith("type ")
1286
+ ? trimmed.slice("type ".length).trim()
1287
+ : trimmed
1288
+ const asIndex = withoutType.toLowerCase().lastIndexOf(" as ")
1289
+ const localName = asIndex === -1
1290
+ ? withoutType
1291
+ : withoutType.slice(asIndex + " as ".length).trim()
1292
+ const local = readIdentifierAt(localName, 0)
1293
+
1294
+ if (local) locals.push(local.name)
1295
+ }
1296
+
1297
+ return locals
1298
+ }
1299
+
1300
+ function splitTopLevelList(source) {
1301
+ const items = []
1302
+ let start = 0
1303
+ let index = 0
1304
+
1305
+ while (index < source.length) {
1306
+ const skipped = skipIgnoredSyntax(source, index)
1307
+ if (skipped !== index) {
1308
+ index = skipped
1309
+ continue
1310
+ }
1311
+
1312
+ if (source[index] === ",") {
1313
+ items.push(source.slice(start, index))
1314
+ start = index + 1
1315
+ }
1316
+ index += 1
1317
+ }
1318
+
1319
+ items.push(source.slice(start))
1320
+ return items
1321
+ }
1322
+
1323
+ function prependImportLines(source, lines) {
1324
+ if (lines.length === 0) return source
1325
+
1326
+ let insertAt = 0
1327
+ if (source.startsWith("#!")) {
1328
+ const lineEnd = source.indexOf("\n")
1329
+ insertAt = lineEnd === -1 ? source.length : lineEnd + 1
1330
+ }
1331
+
1332
+ const prefix = `${lines.join("\n")}\n`
1333
+ return `${source.slice(0, insertAt)}${prefix}${source.slice(insertAt)}`
1334
+ }
1335
+
1336
+ function findTopLevelCommaOrEnd(source, start, end) {
1337
+ const stack = []
1338
+ let index = start
1339
+
1340
+ while (index < end) {
1341
+ const next = skipIgnoredSyntax(source, index)
1342
+ if (next !== index) {
1343
+ index = next
1344
+ continue
1345
+ }
1346
+
1347
+ if (isOpeningBracket(source[index])) {
1348
+ stack.push(expectedClosingBracket(source[index]))
1349
+ index += 1
1350
+ continue
1351
+ }
1352
+
1353
+ if (isClosingBracket(source[index])) {
1354
+ if (stack.at(-1) === source[index]) stack.pop()
1355
+ index += 1
1356
+ continue
1357
+ }
1358
+
1359
+ if (stack.length === 0 && source[index] === ",") return index
1360
+ index += 1
1361
+ }
1362
+
1363
+ return end
1364
+ }
1365
+
1366
+ function findMatchingBracket(source, start, open, close) {
1367
+ if (source[start] !== open) {
1368
+ throw new Error("unsupported Vite config: malformed JavaScript object.")
1369
+ }
1370
+
1371
+ const stack = []
1372
+ let index = start
1373
+
1374
+ while (index < source.length) {
1375
+ const next = skipIgnoredSyntax(source, index)
1376
+ if (next !== index) {
1377
+ index = next
1378
+ continue
1379
+ }
1380
+
1381
+ if (isOpeningBracket(source[index])) {
1382
+ stack.push(expectedClosingBracket(source[index]))
1383
+ index += 1
1384
+ continue
1385
+ }
1386
+
1387
+ if (isClosingBracket(source[index])) {
1388
+ const expected = stack.pop()
1389
+ if (expected !== source[index]) {
1390
+ throw new Error("unsupported Vite config: malformed JavaScript brackets.")
1391
+ }
1392
+ if (stack.length === 0) return index
1393
+ index += 1
1394
+ continue
1395
+ }
1396
+
1397
+ index += 1
1398
+ }
1399
+
1400
+ throw new Error(`unsupported Vite config: missing closing ${close}.`)
1401
+ }
1402
+
1403
+ function skipIgnoredSyntax(source, index) {
1404
+ const commentEnd = skipComment(source, index)
1405
+ if (commentEnd !== index) return commentEnd
1406
+
1407
+ if (source[index] === "\"" || source[index] === "'" || source[index] === "`") {
1408
+ return skipQuotedText(source, index)
1409
+ }
1410
+
1411
+ return index
1412
+ }
1413
+
1414
+ function skipComment(source, index) {
1415
+ if (source[index] !== "/") return index
1416
+
1417
+ if (source[index + 1] === "/") {
1418
+ const lineEnd = source.indexOf("\n", index + 2)
1419
+ return lineEnd === -1 ? source.length : lineEnd + 1
1420
+ }
1421
+
1422
+ if (source[index + 1] === "*") {
1423
+ const blockEnd = source.indexOf("*/", index + 2)
1424
+ return blockEnd === -1 ? source.length : blockEnd + 2
1425
+ }
1426
+
1427
+ return index
1428
+ }
1429
+
1430
+ function skipQuotedText(source, index) {
1431
+ const quote = source[index]
1432
+ index += 1
1433
+
1434
+ while (index < source.length) {
1435
+ if (source[index] === "\\") {
1436
+ index += 2
1437
+ continue
1438
+ }
1439
+
1440
+ if (source[index] === quote) return index + 1
1441
+ index += 1
1442
+ }
1443
+
1444
+ return source.length
1445
+ }
1446
+
1447
+ function readStringLiteral(source, index) {
1448
+ const quote = source[index]
1449
+ if (quote !== "\"" && quote !== "'") return null
1450
+
1451
+ let value = ""
1452
+ index += 1
1453
+
1454
+ while (index < source.length) {
1455
+ if (source[index] === "\\") {
1456
+ value += source[index + 1] ?? ""
1457
+ index += 2
1458
+ continue
1459
+ }
1460
+
1461
+ if (source[index] === quote) {
1462
+ return { value, end: index + 1 }
1463
+ }
1464
+
1465
+ value += source[index]
1466
+ index += 1
1467
+ }
1468
+
1469
+ return null
1470
+ }
1471
+
1472
+ function readIdentifierAt(source, index) {
1473
+ if (!isIdentifierStart(source[index])) return null
1474
+
1475
+ let end = index + 1
1476
+ while (end < source.length && isIdentifierPart(source[end])) {
1477
+ end += 1
1478
+ }
1479
+
1480
+ return { name: source.slice(index, end), end }
1481
+ }
1482
+
1483
+ function matchIdentifier(source, index, name) {
1484
+ if (source.slice(index, index + name.length) !== name) return false
1485
+
1486
+ const before = source[index - 1]
1487
+ const after = source[index + name.length]
1488
+ return !isIdentifierPart(before) && !isIdentifierPart(after)
1489
+ }
1490
+
1491
+ function isIdentifierStart(char) {
1492
+ return Boolean(char && /[A-Za-z_$]/.test(char))
1493
+ }
1494
+
1495
+ function isIdentifierPart(char) {
1496
+ return Boolean(char && /[A-Za-z0-9_$]/.test(char))
1497
+ }
1498
+
1499
+ function isOpeningBracket(char) {
1500
+ return char === "{" || char === "[" || char === "("
1501
+ }
1502
+
1503
+ function isClosingBracket(char) {
1504
+ return char === "}" || char === "]" || char === ")"
1505
+ }
1506
+
1507
+ function expectedClosingBracket(char) {
1508
+ if (char === "{") return "}"
1509
+ if (char === "[") return "]"
1510
+ return ")"
1511
+ }
1512
+
1513
+ function skipWhitespaceAndComments(source, index) {
1514
+ while (index < source.length) {
1515
+ const next = skipComment(source, index)
1516
+ if (next !== index) {
1517
+ index = next
1518
+ continue
1519
+ }
1520
+
1521
+ if (!isWhitespace(source[index])) return index
1522
+ index += 1
1523
+ }
1524
+
1525
+ return index
1526
+ }
1527
+
1528
+ function skipWhitespaceCommentsAndCommas(source, index, end) {
1529
+ while (index < end) {
1530
+ const next = skipWhitespaceAndComments(source, index)
1531
+ if (next !== index) {
1532
+ index = next
1533
+ continue
1534
+ }
1535
+
1536
+ if (source[index] !== ",") return index
1537
+ index += 1
1538
+ }
1539
+
1540
+ return index
1541
+ }
1542
+
1543
+ function skipHorizontalWhitespace(source, index) {
1544
+ while (source[index] === " " || source[index] === "\t") {
1545
+ index += 1
1546
+ }
1547
+ return index
1548
+ }
1549
+
1550
+ function isWhitespace(char) {
1551
+ return char === " " || char === "\t" || char === "\n" || char === "\r"
1552
+ }
1553
+
1554
+ function indentMultiline(text, indent) {
1555
+ return text
1556
+ .split("\n")
1557
+ .map((line) => `${indent}${line}`)
1558
+ .join("\n")
1559
+ }
1560
+
1561
+ function getLineIndent(source, index) {
1562
+ let lineStart = index
1563
+ while (lineStart > 0 && source[lineStart - 1] !== "\n") {
1564
+ lineStart -= 1
1565
+ }
1566
+
1567
+ return source.slice(lineStart, skipHorizontalWhitespace(source, lineStart))
1568
+ }
1569
+
1570
+ function trimEndIndex(source, start, end) {
1571
+ while (end > start && isWhitespace(source[end - 1])) {
1572
+ end -= 1
1573
+ }
1574
+ return end
1575
+ }
1576
+
1577
+ function previousCodeChar(source, index) {
1578
+ index -= 1
1579
+ while (index >= 0 && isWhitespace(source[index])) {
1580
+ index -= 1
1581
+ }
1582
+ return source[index]
1583
+ }
1584
+
1585
+ function bodyIncludesNewline(source, start, end) {
1586
+ for (let index = start; index < end; index += 1) {
1587
+ if (source[index] === "\n" || source[index] === "\r") return true
1588
+ }
1589
+ return false
1590
+ }
1591
+
1592
+ async function getPackageChanges(cwd, dependencies) {
1593
+ const devDependencies = { ...SETUP_DEV_DEPENDENCIES }
1594
+ if (await findViteConfigPath(cwd)) {
1595
+ Object.assign(devDependencies, VITE_DEV_DEPENDENCIES)
1596
+ }
1597
+
1598
+ return {
1599
+ dependencies,
1600
+ devDependencies,
1601
+ }
1602
+ }
1603
+
1604
+ async function updatePackageJson(cwd, changes, options) {
1605
+ const packagePath = path.join(cwd, "package.json")
1606
+ const dependencies = changes.dependencies || {}
1607
+ const devDependencies = changes.devDependencies || {}
1608
+ const packageJson = (await readJsonIfExists(packagePath)) || {
1609
+ name: path.basename(cwd).toLowerCase(),
1610
+ version: "0.0.0",
1611
+ private: true,
1612
+ }
1613
+ const added = [
1614
+ ...addPackageSection(packageJson, "dependencies", dependencies),
1615
+ ...addPackageSection(packageJson, "devDependencies", devDependencies),
1616
+ ]
1617
+
1618
+ if (added.length === 0 && (await exists(packagePath))) {
1619
+ return [`kept ${relative(cwd, packagePath)}`]
1620
+ }
1621
+
1622
+ const status = await writeFile(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`, {
1623
+ dryRun: options.dryRun,
1624
+ force: true,
1625
+ })
1626
+
1627
+ return [
1628
+ `${status} ${relative(cwd, packagePath)}`,
1629
+ ]
1630
+ }
1631
+
1632
+ function addPackageSection(packageJson, section, dependencies) {
1633
+ const added = []
1634
+ const existing = packageJson[section] || {}
1635
+
1636
+ for (const [name, version] of Object.entries(dependencies)) {
1637
+ if (hasPackageDependency(packageJson, name)) continue
1638
+
1639
+ existing[name] = version
1640
+ added.push(name)
1641
+ }
1642
+
1643
+ if (added.length > 0 || packageJson[section]) {
1644
+ packageJson[section] = sortObject(existing)
1645
+ }
1646
+
1647
+ return added
1648
+ }
1649
+
1650
+ function hasPackageDependency(packageJson, name) {
1651
+ return Boolean(
1652
+ packageJson.dependencies?.[name] ||
1653
+ packageJson.devDependencies?.[name] ||
1654
+ packageJson.peerDependencies?.[name] ||
1655
+ packageJson.optionalDependencies?.[name]
1656
+ )
1657
+ }
1658
+
1659
+ function applyTemplate(source, config) {
1660
+ return source
1661
+ .replaceAll("@/lib/utils", config.aliases.utils)
1662
+ .replaceAll("@/hooks", config.aliases.hooks)
1663
+ }
1664
+
1665
+ function isTextRegistryFile(source) {
1666
+ return !/\.(avif|eot|gif|ico|jpe?g|otf|png|ttf|webp|woff2?)$/i.test(source)
1667
+ }
1668
+
1669
+ function resolveTarget(cwd, config, target) {
1670
+ const replacements = {
1671
+ "{{ui}}": aliasToPath(cwd, config.aliases.ui),
1672
+ "{{utils}}": stripExtension(aliasToPath(cwd, config.aliases.utils)),
1673
+ "{{hooks}}": aliasToPath(cwd, config.aliases.hooks),
1674
+ }
1675
+
1676
+ let resolved = target
1677
+ for (const [placeholder, value] of Object.entries(replacements)) {
1678
+ resolved = resolved.replaceAll(placeholder, value)
1679
+ }
1680
+
1681
+ return resolved
1682
+ }
1683
+
1684
+ function aliasToPath(cwd, alias) {
1685
+ if (!alias) return ""
1686
+ if (path.isAbsolute(alias)) return alias
1687
+ if (alias.startsWith("./")) return alias.slice(2)
1688
+ if (alias.startsWith("../")) return alias
1689
+
1690
+ if (alias.startsWith("@/") || alias.startsWith("~/")) {
1691
+ const rest = alias.slice(2)
1692
+ return path.join(shouldUseSrcDirectory(cwd) ? "src" : "", rest)
1693
+ }
1694
+
1695
+ return alias
1696
+ }
1697
+
1698
+ function shouldUseSrcDirectory(cwd) {
1699
+ return pathExistsSyncHint(cwd, "src/app") || pathExistsSyncHint(cwd, "src")
1700
+ }
1701
+
1702
+ function pathExistsSyncHint(cwd, segment) {
1703
+ return Boolean(syncExistsCache.get(path.join(cwd, segment)))
1704
+ }
1705
+
1706
+ async function warmPathHints(cwd) {
1707
+ for (const segment of ["src", "src/app"]) {
1708
+ const target = path.join(cwd, segment)
1709
+ syncExistsCache.set(target, await exists(target))
1710
+ }
1711
+ }
1712
+
1713
+ async function writeFile(targetPath, content, options) {
1714
+ const alreadyExists = await exists(targetPath)
1715
+ const isBinary = Buffer.isBuffer(content)
1716
+
1717
+ if (options.dryRun) {
1718
+ if (!alreadyExists) return "would write"
1719
+
1720
+ const current = await fs.readFile(targetPath)
1721
+ const isEqual = isBinary
1722
+ ? current.equals(content)
1723
+ : current.toString("utf8") === content
1724
+ if (isEqual) return "would keep"
1725
+
1726
+ return "would update"
1727
+ }
1728
+
1729
+ if (alreadyExists && !options.force) {
1730
+ const current = await fs.readFile(targetPath)
1731
+ const isEqual = isBinary
1732
+ ? current.equals(content)
1733
+ : current.toString("utf8") === content
1734
+ if (isEqual) return "kept"
1735
+ }
1736
+
1737
+ await fs.mkdir(path.dirname(targetPath), { recursive: true })
723
1738
  await fs.writeFile(targetPath, content, isBinary ? undefined : "utf8")
724
1739
  return alreadyExists ? "updated" : "wrote"
725
1740
  }
@@ -734,9 +1749,76 @@ async function readJsonc(filePath) {
734
1749
  }
735
1750
 
736
1751
  function stripJsonComments(content) {
737
- return content
738
- .replace(/\/\*[\s\S]*?\*\//g, "")
739
- .replace(/(^|[^:])\/\/.*$/gm, "$1")
1752
+ let output = ""
1753
+ let inString = false
1754
+ let escaped = false
1755
+
1756
+ for (let index = 0; index < content.length; index += 1) {
1757
+ const char = content[index]
1758
+ const next = content[index + 1]
1759
+
1760
+ if (inString) {
1761
+ output += char
1762
+
1763
+ if (escaped) {
1764
+ escaped = false
1765
+ } else if (char === "\\") {
1766
+ escaped = true
1767
+ } else if (char === "\"") {
1768
+ inString = false
1769
+ }
1770
+
1771
+ continue
1772
+ }
1773
+
1774
+ if (char === "\"") {
1775
+ inString = true
1776
+ output += char
1777
+ continue
1778
+ }
1779
+
1780
+ if (char === "/" && next === "/") {
1781
+ output += " "
1782
+ index += 2
1783
+
1784
+ while (index < content.length && content[index] !== "\n" && content[index] !== "\r") {
1785
+ output += " "
1786
+ index += 1
1787
+ }
1788
+
1789
+ if (index < content.length) {
1790
+ output += content[index]
1791
+ if (content[index] === "\r" && content[index + 1] === "\n") {
1792
+ index += 1
1793
+ output += content[index]
1794
+ }
1795
+ }
1796
+
1797
+ continue
1798
+ }
1799
+
1800
+ if (char === "/" && next === "*") {
1801
+ output += " "
1802
+ index += 2
1803
+
1804
+ while (index < content.length) {
1805
+ if (content[index] === "*" && content[index + 1] === "/") {
1806
+ output += " "
1807
+ index += 1
1808
+ break
1809
+ }
1810
+
1811
+ output += content[index] === "\n" || content[index] === "\r" ? content[index] : " "
1812
+ index += 1
1813
+ }
1814
+
1815
+ continue
1816
+ }
1817
+
1818
+ output += char
1819
+ }
1820
+
1821
+ return output
740
1822
  }
741
1823
 
742
1824
  async function readJsonIfExists(filePath) {
@@ -766,27 +1848,136 @@ async function exists(filePath) {
766
1848
  }
767
1849
  }
768
1850
 
769
- function removeExistingTokenBlock(content) {
770
- const start = content.indexOf(CSS_START)
771
- if (start === -1) return content
1851
+ function removeExistingTokenBlock(content, fileLabel = "CSS file") {
1852
+ const lines = splitLinesPreservingEndings(content)
1853
+ const kept = []
1854
+ let found = false
1855
+ let inTokenBlock = false
772
1856
 
773
- const end = content.indexOf(CSS_END, start)
774
- if (end === -1) return content
1857
+ for (const line of lines) {
1858
+ const trimmed = line.trimStart()
775
1859
 
776
- return `${content.slice(0, start)}${content.slice(end + CSS_END.length)}`
1860
+ if (trimmed.startsWith(CSS_START)) {
1861
+ if (found || inTokenBlock) {
1862
+ throw new Error(`${fileLabel}: multiple Banhaten token blocks found.`)
1863
+ }
1864
+ found = true
1865
+ inTokenBlock = true
1866
+ continue
1867
+ }
1868
+
1869
+ if (trimmed.startsWith(CSS_END)) {
1870
+ if (!inTokenBlock) {
1871
+ throw new Error(`${fileLabel}: Banhaten token end marker has no start marker.`)
1872
+ }
1873
+ inTokenBlock = false
1874
+ continue
1875
+ }
1876
+
1877
+ if (!inTokenBlock) {
1878
+ kept.push(line)
1879
+ }
1880
+ }
1881
+
1882
+ if (inTokenBlock) {
1883
+ throw new Error(`${fileLabel}: Banhaten token block is missing its end marker.`)
1884
+ }
1885
+
1886
+ return { content: kept.join(""), found }
777
1887
  }
778
1888
 
779
1889
  function ensureGlobalCssImports(content) {
780
- const fontImportPattern =
781
- /^\s*@import\s+["']@fontsource(?:-variable)?\/inter(?:\/(?:400|500|600|700)\.css)?["'];\s*/gm
782
- const tailwindImportPattern = /^\s*@import\s+["']tailwindcss["'];\s*/gm
783
- const cleaned = content
784
- .replace(fontImportPattern, "")
785
- .replace(tailwindImportPattern, "")
1890
+ const cleaned = splitLinesPreservingEndings(content)
1891
+ .filter((line) => {
1892
+ const importSpecifier = readCssImportSpecifier(line)
1893
+ return (
1894
+ importSpecifier !== "tailwindcss" &&
1895
+ !isManagedInterFontImport(importSpecifier)
1896
+ )
1897
+ })
1898
+ .join("")
786
1899
  .trimStart()
787
1900
  return `${TAILWIND_IMPORT}\n${FONT_IMPORTS.join("\n")}\n${cleaned}`
788
1901
  }
789
1902
 
1903
+ function splitLinesPreservingEndings(content) {
1904
+ const lines = []
1905
+ let lineStart = 0
1906
+ let index = 0
1907
+
1908
+ while (index < content.length) {
1909
+ if (content[index] === "\r" && content[index + 1] === "\n") {
1910
+ lines.push(content.slice(lineStart, index + 2))
1911
+ index += 2
1912
+ lineStart = index
1913
+ continue
1914
+ }
1915
+
1916
+ if (content[index] === "\n") {
1917
+ lines.push(content.slice(lineStart, index + 1))
1918
+ index += 1
1919
+ lineStart = index
1920
+ continue
1921
+ }
1922
+
1923
+ index += 1
1924
+ }
1925
+
1926
+ if (lineStart < content.length) {
1927
+ lines.push(content.slice(lineStart))
1928
+ }
1929
+
1930
+ return lines
1931
+ }
1932
+
1933
+ function readCssImportSpecifier(line) {
1934
+ const trimmed = line.trim()
1935
+ const importPrefix = "@import"
1936
+ if (!trimmed.startsWith(importPrefix)) return null
1937
+
1938
+ let index = importPrefix.length
1939
+ while (trimmed[index] === " " || trimmed[index] === "\t") {
1940
+ index += 1
1941
+ }
1942
+
1943
+ const quote = trimmed[index]
1944
+ if (quote !== "\"" && quote !== "'") return null
1945
+
1946
+ const start = index + 1
1947
+ index = start
1948
+ while (index < trimmed.length && trimmed[index] !== quote) {
1949
+ index += 1
1950
+ }
1951
+
1952
+ if (index >= trimmed.length) return null
1953
+ const specifier = trimmed.slice(start, index)
1954
+ index += 1
1955
+
1956
+ while (trimmed[index] === " " || trimmed[index] === "\t") {
1957
+ index += 1
1958
+ }
1959
+
1960
+ if (trimmed[index] === ";") index += 1
1961
+
1962
+ while (trimmed[index] === " " || trimmed[index] === "\t") {
1963
+ index += 1
1964
+ }
1965
+
1966
+ return index === trimmed.length ? specifier : null
1967
+ }
1968
+
1969
+ function isManagedInterFontImport(specifier) {
1970
+ if (!specifier) return false
1971
+ if (specifier === "@fontsource/inter") return true
1972
+ if (specifier === "@fontsource-variable/inter") return true
1973
+
1974
+ for (const weight of ["400", "500", "600", "700"]) {
1975
+ if (specifier === `@fontsource/inter/${weight}.css`) return true
1976
+ }
1977
+
1978
+ return false
1979
+ }
1980
+
790
1981
  function appendAliasSegment(alias, segment) {
791
1982
  if (!alias) return null
792
1983
  return `${alias.replace(/\/$/, "")}/${segment}`
@@ -807,20 +1998,58 @@ function relative(cwd, targetPath) {
807
1998
  }
808
1999
 
809
2000
  function printResult(title, writes, dryRun) {
2001
+ if (runtimeOptions.json) {
2002
+ writeJson({ dryRun, title, writes })
2003
+ return
2004
+ }
2005
+
2006
+ if (runtimeOptions.silent) return
2007
+
810
2008
  console.log(`${dryRun ? "Dry run: " : ""}${title}`)
811
2009
  for (const write of writes) {
812
2010
  console.log(`- ${write}`)
813
2011
  }
814
2012
  }
815
2013
 
816
- function printInstallHint(writes, dryRun) {
817
- if (dryRun) return
2014
+ async function printInstallHint(cwd, writes, dryRun) {
2015
+ if (dryRun || runtimeOptions.json || runtimeOptions.silent) return
818
2016
 
819
2017
  const packageJsonChanged = writes.some((write) =>
820
2018
  /^(wrote|updated) package\.json$/.test(write)
821
2019
  )
822
2020
 
823
2021
  if (packageJsonChanged) {
824
- console.log("\nNext: run npm install to install the added dependencies.")
2022
+ console.log(`\nNext: run ${await getInstallCommand(cwd)} to install the added dependencies.`)
2023
+ }
2024
+ }
2025
+
2026
+ async function getInstallCommand(cwd) {
2027
+ const packageJson = await readJsonIfExists(path.join(cwd, "package.json"))
2028
+ const declaredPackageManager = parsePackageManager(packageJson?.packageManager)
2029
+
2030
+ if (declaredPackageManager) return `${declaredPackageManager} install`
2031
+
2032
+ const lockfileManagers = [
2033
+ ["pnpm-lock.yaml", "pnpm"],
2034
+ ["yarn.lock", "yarn"],
2035
+ ["bun.lockb", "bun"],
2036
+ ["bun.lock", "bun"],
2037
+ ["package-lock.json", "npm"],
2038
+ ["npm-shrinkwrap.json", "npm"],
2039
+ ]
2040
+
2041
+ for (const [lockfile, manager] of lockfileManagers) {
2042
+ if (await exists(path.join(cwd, lockfile))) return `${manager} install`
825
2043
  }
2044
+
2045
+ return "npm install"
2046
+ }
2047
+
2048
+ function parsePackageManager(packageManager) {
2049
+ if (typeof packageManager !== "string") return null
2050
+
2051
+ const name = packageManager.split("@")[0]
2052
+ if (["npm", "pnpm", "yarn", "bun"].includes(name)) return name
2053
+
2054
+ return null
826
2055
  }