banhaten 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (365) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/CONTRIBUTING.md +44 -0
  3. package/README.md +145 -328
  4. package/SECURITY.md +15 -0
  5. package/banhaten.config.example.json +1 -3
  6. package/docs/design-system/README.md +20 -0
  7. package/docs/design-system/adoption-quality-remediation.md +87 -0
  8. package/docs/design-system/agent-usage.md +13 -0
  9. package/docs/design-system/appearance-presets.md +189 -0
  10. package/docs/design-system/appearances/default.md +94 -0
  11. package/docs/design-system/appearances/rounded.md +95 -0
  12. package/docs/design-system/appearances/sharp.md +95 -0
  13. package/docs/design-system/component-docs-pattern.md +81 -0
  14. package/docs/design-system/component-showcase-consistency-report.md +217 -0
  15. package/docs/design-system/component-token-consistency-audit.md +173 -0
  16. package/docs/design-system/components/README.md +75 -0
  17. package/docs/design-system/components/accordion.md +67 -0
  18. package/docs/design-system/components/activity-feed.md +106 -0
  19. package/docs/design-system/components/alert-dialog.md +87 -0
  20. package/docs/design-system/components/alert.md +111 -0
  21. package/docs/design-system/components/aspect-ratio.md +59 -0
  22. package/docs/design-system/components/attribute.md +103 -0
  23. package/docs/design-system/components/autocomplete.md +93 -0
  24. package/docs/design-system/components/avatar.md +70 -0
  25. package/docs/design-system/components/badge.md +72 -0
  26. package/docs/design-system/components/banner.md +99 -0
  27. package/docs/design-system/components/breadcrumbs.md +188 -0
  28. package/docs/design-system/components/button-group.md +117 -0
  29. package/docs/design-system/components/button.md +95 -0
  30. package/docs/design-system/components/card.md +95 -0
  31. package/docs/design-system/components/carousel.md +61 -0
  32. package/docs/design-system/components/catalog-components.md +59 -0
  33. package/docs/design-system/components/chart.md +58 -0
  34. package/docs/design-system/components/checkbox.md +68 -0
  35. package/docs/design-system/components/collapsible.md +63 -0
  36. package/docs/design-system/components/command.md +75 -0
  37. package/docs/design-system/components/context-menu.md +59 -0
  38. package/docs/design-system/components/date-picker.md +97 -0
  39. package/docs/design-system/components/divider.md +117 -0
  40. package/docs/design-system/components/empty-state.md +72 -0
  41. package/docs/design-system/components/field.md +86 -0
  42. package/docs/design-system/components/file-upload.md +199 -0
  43. package/docs/design-system/components/hover-card.md +61 -0
  44. package/docs/design-system/components/icons.md +48 -0
  45. package/docs/design-system/components/input-group.md +73 -0
  46. package/docs/design-system/components/input-otp.md +73 -0
  47. package/docs/design-system/components/input.md +84 -0
  48. package/docs/design-system/components/item.md +72 -0
  49. package/docs/design-system/components/kbd.md +62 -0
  50. package/docs/design-system/components/label.md +64 -0
  51. package/docs/design-system/components/menu.md +76 -0
  52. package/docs/design-system/components/menubar.md +60 -0
  53. package/docs/design-system/components/modal.md +119 -0
  54. package/docs/design-system/components/native-select.md +70 -0
  55. package/docs/design-system/components/navigation-menu.md +63 -0
  56. package/docs/design-system/components/onboarding-step-list-item.md +94 -0
  57. package/docs/design-system/components/page-header.md +98 -0
  58. package/docs/design-system/components/pagination.md +71 -0
  59. package/docs/design-system/components/popover.md +77 -0
  60. package/docs/design-system/components/progress-slider.md +66 -0
  61. package/docs/design-system/components/progress.md +92 -0
  62. package/docs/design-system/components/radio-card.md +65 -0
  63. package/docs/design-system/components/radio-group.md +72 -0
  64. package/docs/design-system/components/resizable.md +57 -0
  65. package/docs/design-system/components/scroll-area.md +60 -0
  66. package/docs/design-system/components/segmented-control.md +71 -0
  67. package/docs/design-system/components/select.md +87 -0
  68. package/docs/design-system/components/sheet.md +82 -0
  69. package/docs/design-system/components/sidebar.md +83 -0
  70. package/docs/design-system/components/skeleton.md +91 -0
  71. package/docs/design-system/components/slideout.md +77 -0
  72. package/docs/design-system/components/slider.md +81 -0
  73. package/docs/design-system/components/social-button.md +65 -0
  74. package/docs/design-system/components/spinner.md +77 -0
  75. package/docs/design-system/components/steps.md +77 -0
  76. package/docs/design-system/components/table.md +411 -0
  77. package/docs/design-system/components/tabs.md +69 -0
  78. package/docs/design-system/components/tag.md +95 -0
  79. package/docs/design-system/components/textarea.md +64 -0
  80. package/docs/design-system/components/timeline.md +95 -0
  81. package/docs/design-system/components/toast.md +73 -0
  82. package/docs/design-system/components/toggle.md +96 -0
  83. package/docs/design-system/components/toolbar.md +105 -0
  84. package/docs/design-system/components/tooltip.md +107 -0
  85. package/docs/design-system/components/typography.md +18 -0
  86. package/docs/design-system/density-and-elevation.md +44 -0
  87. package/docs/design-system/design-system-test-missing-items.md +368 -0
  88. package/docs/design-system/direction-and-localization.md +23 -0
  89. package/docs/design-system/form-recipes.md +73 -0
  90. package/docs/design-system/gap-promotion-policy.md +21 -0
  91. package/docs/design-system/gap-promotion-report.md +166 -0
  92. package/docs/design-system/icons.md +69 -0
  93. package/docs/design-system/installation.md +68 -0
  94. package/docs/design-system/launch-comparison-shadcn.md +98 -0
  95. package/docs/design-system/registry-and-cli.md +64 -0
  96. package/docs/design-system/release-policy.md +30 -0
  97. package/docs/design-system/roadmap.md +14 -0
  98. package/docs/design-system/support-matrix.md +23 -0
  99. package/docs/design-system/tabs.md +53 -0
  100. package/docs/design-system/token-governance.md +60 -0
  101. package/package.json +97 -65
  102. package/registry/component-contract.json +40 -0
  103. package/registry/components/alert-dialog.tsx +297 -0
  104. package/registry/components/alert.tsx +86 -65
  105. package/registry/components/aspect-ratio.tsx +30 -0
  106. package/registry/components/autocomplete.tsx +23 -9
  107. package/registry/components/avatar.tsx +7 -7
  108. package/registry/components/badge.tsx +1 -1
  109. package/registry/components/button-group.tsx +321 -4
  110. package/registry/components/button.tsx +38 -17
  111. package/registry/components/card.tsx +68 -59
  112. package/registry/components/carousel.tsx +282 -0
  113. package/registry/components/chart.tsx +203 -0
  114. package/registry/components/checkbox.tsx +3 -3
  115. package/registry/components/collapsible.tsx +69 -0
  116. package/registry/components/command.tsx +174 -0
  117. package/registry/components/context-menu.tsx +236 -0
  118. package/registry/components/date-picker.tsx +246 -105
  119. package/registry/components/expanded/ActivityFeed.tsx +6 -15
  120. package/registry/components/expanded/Banner.tsx +3 -3
  121. package/registry/components/expanded/Breadcrumbs.tsx +5 -5
  122. package/registry/components/expanded/CatalogComponentsShowcase.tsx +13 -13
  123. package/registry/components/expanded/CatalogDivider.tsx +1 -1
  124. package/registry/components/expanded/EmptyState.tsx +1 -1
  125. package/registry/components/expanded/FileUpload.tsx +9 -2
  126. package/registry/components/expanded/OnboardingStepListItem.tsx +2 -4
  127. package/registry/components/expanded/PageHeader.tsx +14 -14
  128. package/registry/components/expanded/Slideout.tsx +15 -415
  129. package/registry/components/expanded/Steps.tsx +4 -4
  130. package/registry/components/expanded/Table.tsx +116 -69
  131. package/registry/components/expanded/Timeline.tsx +3 -3
  132. package/registry/components/expanded/activityFeed.css +1 -1
  133. package/registry/components/expanded/breadcrumbs.css +139 -139
  134. package/registry/components/expanded/catalogComponentsShowcase.css +83 -83
  135. package/registry/components/expanded/divider.css +1 -1
  136. package/registry/components/expanded/emptyState.css +1 -1
  137. package/registry/components/expanded/fileUpload.css +6 -6
  138. package/registry/components/expanded/slideout.css +9 -685
  139. package/registry/components/expanded/steps.css +274 -274
  140. package/registry/components/expanded/table.css +2 -2
  141. package/registry/components/expanded/timeline.css +264 -264
  142. package/registry/components/field.tsx +230 -0
  143. package/registry/components/hover-card.tsx +48 -0
  144. package/registry/components/input-content.ts +1 -0
  145. package/registry/components/input-group.tsx +130 -0
  146. package/registry/components/input-otp.tsx +13 -3
  147. package/registry/components/input.tsx +70 -45
  148. package/registry/components/item.tsx +189 -0
  149. package/registry/components/kbd.tsx +151 -0
  150. package/registry/components/label.tsx +78 -0
  151. package/registry/components/menu.tsx +34 -246
  152. package/registry/components/menubar.tsx +226 -0
  153. package/registry/components/modal.tsx +216 -143
  154. package/registry/components/native-select.tsx +205 -0
  155. package/registry/components/navigation-menu.tsx +171 -0
  156. package/registry/components/pagination.tsx +80 -58
  157. package/registry/components/popover.tsx +162 -664
  158. package/registry/components/radio-card.tsx +116 -87
  159. package/registry/components/radio-group.tsx +1 -1
  160. package/registry/components/resizable.tsx +74 -0
  161. package/registry/components/scroll-area.tsx +67 -0
  162. package/registry/components/segmented-control.tsx +87 -0
  163. package/registry/components/select-content.tsx +2 -2
  164. package/registry/components/select.tsx +54 -20
  165. package/registry/components/sheet.tsx +305 -0
  166. package/registry/components/sidebar.tsx +352 -0
  167. package/registry/components/skeleton.css +0 -3
  168. package/registry/components/social-button.tsx +116 -30
  169. package/registry/components/spinner.tsx +18 -55
  170. package/registry/components/{expanded/tabs.css → tabs.css} +127 -106
  171. package/registry/components/tabs.tsx +246 -0
  172. package/registry/components/tag.tsx +2 -2
  173. package/registry/components/textarea.tsx +4 -4
  174. package/registry/components/toast.tsx +136 -0
  175. package/registry/components/toggle.tsx +2 -2
  176. package/registry/components/toolbar.tsx +89 -17
  177. package/registry/components/tooltip.tsx +1 -1
  178. package/registry/examples/alert-demo.tsx +25 -5
  179. package/registry/examples/alert-dialog-demo.tsx +42 -0
  180. package/registry/examples/aspect-ratio-demo.tsx +11 -0
  181. package/registry/examples/attribute-demo.tsx +1 -1
  182. package/registry/examples/autocomplete-demo.tsx +3 -3
  183. package/registry/examples/avatar-demo.tsx +7 -7
  184. package/registry/examples/badge-demo.tsx +1 -1
  185. package/registry/examples/button-demo.tsx +6 -6
  186. package/registry/examples/button-group-demo.tsx +23 -2
  187. package/registry/examples/card-demo.tsx +46 -8
  188. package/registry/examples/carousel-demo.tsx +43 -0
  189. package/registry/examples/chart-demo.tsx +33 -0
  190. package/registry/examples/checkbox-demo.tsx +5 -5
  191. package/registry/examples/collapsible-demo.tsx +16 -0
  192. package/registry/examples/collection-filter-bar-demo.tsx +135 -0
  193. package/registry/examples/command-demo.tsx +42 -0
  194. package/registry/examples/{expanded/command-bar-demo.tsx → command-palette-demo.tsx} +5 -15
  195. package/registry/examples/context-menu-demo.tsx +29 -0
  196. package/registry/examples/control-density-contract-demo.tsx +61 -0
  197. package/registry/examples/date-picker-demo.tsx +12 -12
  198. package/registry/examples/divider-demo.tsx +3 -3
  199. package/registry/examples/expanded/banner-demo.tsx +1 -1
  200. package/registry/examples/expanded/breadcrumbs-demo.tsx +1 -1
  201. package/registry/examples/expanded/empty-state-demo.tsx +1 -1
  202. package/registry/examples/expanded/file-upload-demo.tsx +1 -1
  203. package/registry/examples/expanded/onboarding-step-demo.tsx +1 -1
  204. package/registry/examples/expanded/slideout-demo.tsx +2 -2
  205. package/registry/examples/expanded/steps-demo.tsx +1 -1
  206. package/registry/examples/expanded/tabs-demo.tsx +2 -2
  207. package/registry/examples/expanded/timeline-demo.tsx +1 -1
  208. package/registry/examples/field-demo.tsx +51 -0
  209. package/registry/examples/hover-card-demo.tsx +23 -0
  210. package/registry/examples/input-demo.tsx +6 -6
  211. package/registry/examples/input-group-demo.tsx +16 -0
  212. package/registry/examples/input-otp-demo.tsx +4 -4
  213. package/registry/examples/item-demo.tsx +46 -0
  214. package/registry/examples/kbd-demo.tsx +11 -0
  215. package/registry/examples/label-demo.tsx +20 -0
  216. package/registry/examples/menu-demo.tsx +1 -1
  217. package/registry/examples/menubar-demo.tsx +34 -0
  218. package/registry/examples/modal-demo.tsx +67 -9
  219. package/registry/examples/native-select-demo.tsx +16 -0
  220. package/registry/examples/navigation-menu-demo.tsx +29 -0
  221. package/registry/examples/pagination-demo.tsx +6 -3
  222. package/registry/examples/popover-demo.tsx +19 -19
  223. package/registry/examples/progress-demo.tsx +1 -1
  224. package/registry/examples/progress-slider-demo.tsx +1 -1
  225. package/registry/examples/radio-card-demo.tsx +4 -4
  226. package/registry/examples/radio-group-demo.tsx +2 -2
  227. package/registry/examples/resizable-demo.tsx +22 -0
  228. package/registry/examples/results-toolbar-demo.tsx +44 -0
  229. package/registry/examples/scroll-area-demo.tsx +15 -0
  230. package/registry/examples/segmented-control-demo.tsx +39 -0
  231. package/registry/examples/select-demo.tsx +1 -1
  232. package/registry/examples/sheet-demo.tsx +47 -0
  233. package/registry/examples/sidebar-demo.tsx +55 -0
  234. package/registry/examples/slider-demo.tsx +3 -3
  235. package/registry/examples/social-button-demo.tsx +18 -8
  236. package/registry/examples/spinner-demo.tsx +13 -13
  237. package/registry/examples/tabs-demo.tsx +13 -0
  238. package/registry/examples/tag-demo.tsx +1 -1
  239. package/registry/examples/textarea-demo.tsx +4 -4
  240. package/registry/examples/toast-demo.tsx +35 -0
  241. package/registry/examples/toggle-demo.tsx +6 -6
  242. package/registry/examples/tooltip-demo.tsx +4 -4
  243. package/registry/guidance/USAGE.md +27 -0
  244. package/registry/hooks/use-direction.ts +193 -13
  245. package/registry/index.json +6010 -432
  246. package/registry/styles/globals.css +3112 -4690
  247. package/registry.json +1582 -0
  248. package/schema/config.schema.json +51 -0
  249. package/schema/registry.schema.json +190 -0
  250. package/schema/tokens.schema.json +163 -0
  251. package/src/cli/command-line.js +194 -0
  252. package/src/cli/config-editors.js +9 -0
  253. package/src/cli/doctor.js +399 -0
  254. package/src/cli/file-transaction.js +295 -0
  255. package/src/cli/global-css.js +153 -0
  256. package/src/cli/index.js +639 -1391
  257. package/src/cli/install-plan.js +592 -0
  258. package/src/cli/install-receipt.js +171 -0
  259. package/src/cli/jsonc.js +203 -0
  260. package/src/cli/package-install-state.js +254 -0
  261. package/src/cli/package-manager.js +238 -0
  262. package/src/cli/package-plan.js +185 -0
  263. package/src/cli/package-version.js +97 -0
  264. package/src/cli/registry-query.js +207 -0
  265. package/src/cli/reporting.js +55 -0
  266. package/src/cli/schema-validation.js +19 -0
  267. package/src/cli/source-scanner.js +686 -0
  268. package/src/cli/vite-config.js +478 -0
  269. package/tokens/banhaten.tokens.json +4313 -310
  270. package/registry/assets/avatars/avatar-02.jpg +0 -0
  271. package/registry/assets/avatars/avatar-03.jpg +0 -0
  272. package/registry/assets/avatars/avatar-04.jpg +0 -0
  273. package/registry/assets/avatars/avatar-05.jpg +0 -0
  274. package/registry/assets/avatars/avatar-06.jpg +0 -0
  275. package/registry/assets/avatars/avatar-07.jpg +0 -0
  276. package/registry/assets/avatars/avatar-08.jpg +0 -0
  277. package/registry/assets/avatars/avatar-09.jpg +0 -0
  278. package/registry/assets/avatars/avatar-10.jpg +0 -0
  279. package/registry/assets/avatars/avatar-11.jpg +0 -0
  280. package/registry/assets/avatars/avatar-12.jpg +0 -0
  281. package/registry/assets/avatars/avatar-13.jpg +0 -0
  282. package/registry/assets/avatars/avatar-14.jpg +0 -0
  283. package/registry/assets/avatars/avatar-15.jpg +0 -0
  284. package/registry/assets/avatars/avatar-16.jpg +0 -0
  285. package/registry/assets/avatars/avatar-17.jpg +0 -0
  286. package/registry/assets/avatars/avatar-18.jpg +0 -0
  287. package/registry/assets/avatars/avatar-19.jpg +0 -0
  288. package/registry/assets/avatars/avatar-20.jpg +0 -0
  289. package/registry/assets/avatars/avatar-21.jpg +0 -0
  290. package/registry/assets/avatars/avatar-22.jpg +0 -0
  291. package/registry/assets/avatars/avatar-23.jpg +0 -0
  292. package/registry/assets/avatars/avatar-24.jpg +0 -0
  293. package/registry/assets/avatars/avatar-25.jpg +0 -0
  294. package/registry/assets/avatars/avatar-26.jpg +0 -0
  295. package/registry/assets/avatars/avatar-27.jpg +0 -0
  296. package/registry/assets/avatars/avatar-28.jpg +0 -0
  297. package/registry/assets/avatars/avatar-29.jpg +0 -0
  298. package/registry/assets/avatars/avatar-30.jpg +0 -0
  299. package/registry/assets/avatars/avatar-31.jpg +0 -0
  300. package/registry/assets/avatars/avatar-32.jpg +0 -0
  301. package/registry/assets/avatars/avatar-33.jpg +0 -0
  302. package/registry/assets/avatars/avatar-34.jpg +0 -0
  303. package/registry/assets/avatars/avatar-35.jpg +0 -0
  304. package/registry/assets/image-assets.json +0 -744
  305. package/registry/assets/images/art-02.jpg +0 -0
  306. package/registry/assets/images/art-03.jpg +0 -0
  307. package/registry/assets/images/art-04.jpg +0 -0
  308. package/registry/assets/images/art-05.jpg +0 -0
  309. package/registry/assets/images/art-06.jpg +0 -0
  310. package/registry/assets/images/art-07.jpg +0 -0
  311. package/registry/assets/images/art-08.jpg +0 -0
  312. package/registry/assets/images/art-09.jpg +0 -0
  313. package/registry/assets/images/art-10.jpg +0 -0
  314. package/registry/assets/images/art-11.jpg +0 -0
  315. package/registry/assets/images/art-12.jpg +0 -0
  316. package/registry/assets/images/art-13.jpg +0 -0
  317. package/registry/assets/images/art-14.jpg +0 -0
  318. package/registry/assets/images/art-15.jpg +0 -0
  319. package/registry/assets/images/art-16.jpg +0 -0
  320. package/registry/assets/images/art-17.jpg +0 -0
  321. package/registry/assets/images/art-18.jpg +0 -0
  322. package/registry/assets/images/art-19.jpg +0 -0
  323. package/registry/assets/images/art-20.jpg +0 -0
  324. package/registry/assets/images/art-21.jpg +0 -0
  325. package/registry/assets/images/art-22.jpg +0 -0
  326. package/registry/assets/images/art-23.jpg +0 -0
  327. package/registry/assets/images/art-24.jpg +0 -0
  328. package/registry/assets/images/art-25.jpg +0 -0
  329. package/registry/assets/images/art-26.jpg +0 -0
  330. package/registry/assets/images/art-27.jpg +0 -0
  331. package/registry/assets/images/nature-01.jpg +0 -0
  332. package/registry/assets/images/nature-02.jpg +0 -0
  333. package/registry/assets/images/nature-03.jpg +0 -0
  334. package/registry/assets/images/nature-04.jpg +0 -0
  335. package/registry/assets/images/nature-05.jpg +0 -0
  336. package/registry/assets/images/nature-06.jpg +0 -0
  337. package/registry/assets/images/nature-07.jpg +0 -0
  338. package/registry/assets/images/nature-08.jpg +0 -0
  339. package/registry/assets/images/nature-09.jpg +0 -0
  340. package/registry/assets/images/nature-10.jpg +0 -0
  341. package/registry/assets/images/nature-11.jpg +0 -0
  342. package/registry/assets/images/nature-12.jpg +0 -0
  343. package/registry/assets/images/nature-13.jpg +0 -0
  344. package/registry/assets/images/nature-14.jpg +0 -0
  345. package/registry/assets/images/nature-15.jpg +0 -0
  346. package/registry/assets/images/nature-16.jpg +0 -0
  347. package/registry/assets/images/nature-17.jpg +0 -0
  348. package/registry/assets/images/nature-18.jpg +0 -0
  349. package/registry/assets/images/nature-19.jpg +0 -0
  350. package/registry/assets/images/nature-20.jpg +0 -0
  351. package/registry/components/expanded/BannerBoard.tsx +0 -225
  352. package/registry/components/expanded/CatalogTag.tsx +0 -85
  353. package/registry/components/expanded/CommandBar.tsx +0 -386
  354. package/registry/components/expanded/IconExplorer.tsx +0 -612
  355. package/registry/components/expanded/Tabs.tsx +0 -86
  356. package/registry/components/expanded/TimelineShowcase.tsx +0 -158
  357. package/registry/components/expanded/commandBar.css +0 -470
  358. package/registry/components/expanded/iconExplorer.css +0 -764
  359. package/registry/components/expanded/iconPacks.ts +0 -866
  360. package/registry/components/expanded/tag.css +0 -148
  361. package/registry/examples/expanded/icons-demo.tsx +0 -5
  362. package/tokens/Color mode.zip +0 -0
  363. package/tokens/Numbers.zip +0 -0
  364. package/tokens/Radius.zip +0 -0
  365. package/tokens/Theme.zip +0 -0
package/src/cli/index.js CHANGED
@@ -1,9 +1,54 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import { createHash } from "node:crypto"
3
4
  import fs from "node:fs/promises"
4
5
  import path from "node:path"
5
6
  import process from "node:process"
6
7
  import { fileURLToPath } from "node:url"
8
+ import { parseCommandLine } from "./command-line.js"
9
+ import {
10
+ inspectBanhatenProject,
11
+ printBanhatenDoctor,
12
+ } from "./doctor.js"
13
+ import {
14
+ assertFileUnchanged,
15
+ deferUntilApplied,
16
+ formatUnifiedDiff,
17
+ runFileTransaction,
18
+ writeFile,
19
+ } from "./file-transaction.js"
20
+ import {
21
+ applyPackageDependencyPlan,
22
+ createPackageDependencyPlan,
23
+ mergeDependencyRequirements,
24
+ } from "./package-plan.js"
25
+ import { inspectPackageInstallState } from "./package-install-state.js"
26
+ import {
27
+ createInstallPlan,
28
+ resolveInstallTarget,
29
+ } from "./install-plan.js"
30
+ import {
31
+ createInstallPlanReceipt,
32
+ normalizeReceiptComponents,
33
+ normalizeReceiptTarget,
34
+ validateReceiptDestinations,
35
+ } from "./install-receipt.js"
36
+ import {
37
+ ensureGlobalCssImports,
38
+ removeExistingTokenBlock,
39
+ } from "./global-css.js"
40
+ import {
41
+ printInstallHint,
42
+ printResult,
43
+ resolveInstallAction,
44
+ } from "./reporting.js"
45
+ import { assertSchema } from "./schema-validation.js"
46
+ import {
47
+ listComponents,
48
+ searchComponents,
49
+ showComponentDocs,
50
+ viewComponent,
51
+ } from "./registry-query.js"
7
52
 
8
53
  const packageRoot = path.resolve(
9
54
  path.dirname(fileURLToPath(import.meta.url)),
@@ -12,29 +57,24 @@ const packageRoot = path.resolve(
12
57
  )
13
58
  const registryRoot = path.join(packageRoot, "registry")
14
59
  const registryIndexPath = path.join(registryRoot, "index.json")
60
+ const packageJsonPath = path.join(packageRoot, "package.json")
61
+ const schemaRoot = path.join(packageRoot, "schema")
15
62
 
16
63
  const CONFIG_FILE = "banhaten.config.json"
17
- const FONT_IMPORTS = [
18
- '@import "@fontsource/inter/400.css";',
19
- '@import "@fontsource/inter/500.css";',
20
- '@import "@fontsource/inter/600.css";',
21
- '@import "@fontsource/inter/700.css";',
22
- ]
23
- const TAILWIND_IMPORT = '@import "tailwindcss";'
64
+ const INSTALL_RECEIPT_FILE = ".banhaten/installed.json"
24
65
  const SETUP_DEV_DEPENDENCIES = {
25
66
  tailwindcss: "^4.0.0",
26
67
  }
27
68
  const VITE_DEV_DEPENDENCIES = {
28
69
  "@tailwindcss/vite": "^4.0.0",
29
70
  }
30
- const CSS_START = "/* Banhaten design system tokens:"
31
- const CSS_END = "/* End Banhaten design system tokens. */"
32
- const syncExistsCache = new Map()
71
+ let runtimeOptions = {
72
+ json: false,
73
+ silent: false,
74
+ }
33
75
 
34
76
  const DEFAULT_CONFIG = {
35
- $schema: "https://banhaten.dev/schema.json",
36
- style: "default",
37
- tsx: true,
77
+ $schema: ".banhaten/schema/config.schema.json",
38
78
  tailwind: {
39
79
  css: "app/globals.css",
40
80
  },
@@ -45,7 +85,28 @@ const DEFAULT_CONFIG = {
45
85
  },
46
86
  }
47
87
 
88
+ // Top-level keys from the shadcn-shaped config this CLI grew out of. They are
89
+ // deliberately migrated away rather than rejected, so an existing project can
90
+ // upgrade in place. Every *other* unknown key is a real error: without this
91
+ // list, dropping unknown keys wholesale is indistinguishable from silently
92
+ // destroying a setting the user meant to keep.
93
+ const LEGACY_CONFIG_KEYS = Object.freeze(["style", "tsx", "rsc", "iconLibrary"])
94
+
95
+ function stripLegacyConfigKeys(config) {
96
+ const next = { ...config }
97
+ for (const key of LEGACY_CONFIG_KEYS) delete next[key]
98
+ return next
99
+ }
100
+
101
+ function findLegacyConfigKeys(config) {
102
+ return LEGACY_CONFIG_KEYS.filter((key) => key in config)
103
+ }
104
+
48
105
  const registry = await readJson(registryIndexPath)
106
+ const packageManifest = await readJson(packageJsonPath)
107
+ const registrySchema = await readJson(path.join(schemaRoot, "registry.schema.json"))
108
+ const configSchema = await readJson(path.join(schemaRoot, "config.schema.json"))
109
+ assertSchema(registry, registrySchema, "registry/index.json")
49
110
 
50
111
  main().catch((error) => {
51
112
  console.error(`banhaten: ${error.message}`)
@@ -53,132 +114,165 @@ main().catch((error) => {
53
114
  })
54
115
 
55
116
  async function main() {
56
- const { command, positionals, flags } = parseArgs(process.argv.slice(2))
117
+ const { command, positionals, flags } = parseCommandLine(process.argv.slice(2))
118
+ runtimeOptions = {
119
+ json: Boolean(flags.json),
120
+ silent: Boolean(flags.silent),
121
+ }
57
122
 
58
- if (!command || command === "help" || flags.help || flags.h) {
123
+ if (command === "version" || flags.version) {
124
+ console.log(packageManifest.version)
125
+ return
126
+ }
127
+
128
+ if (!command || command === "help" || flags.help) {
59
129
  printHelp()
60
130
  return
61
131
  }
62
132
 
63
133
  if (command === "list") {
64
- listComponents()
134
+ listComponents(registry, runtimeOptions)
65
135
  return
66
136
  }
67
137
 
68
- if (command === "init") {
69
- await initProject(flags)
138
+ if (command === "search") {
139
+ searchComponents(registry, positionals, runtimeOptions)
70
140
  return
71
141
  }
72
142
 
73
- if (command === "add") {
74
- await addComponents(positionals, flags)
143
+ if (command === "docs") {
144
+ await showComponentDocs(registry, packageRoot, positionals, runtimeOptions)
75
145
  return
76
146
  }
77
147
 
78
- if (command === "diff") {
79
- await diffComponents(positionals, flags)
148
+ if (command === "view") {
149
+ await viewComponent(
150
+ registry,
151
+ registryRoot,
152
+ positionals,
153
+ flags,
154
+ runtimeOptions
155
+ )
80
156
  return
81
157
  }
82
158
 
83
- if (command === "update") {
84
- await updateComponents(positionals, flags)
159
+ if (command === "doctor") {
160
+ const report = await inspectBanhatenProject({
161
+ cwd: path.resolve(flags.cwd || process.cwd()),
162
+ registryVersion: packageManifest.version,
163
+ })
164
+ printBanhatenDoctor(report, runtimeOptions)
165
+ if (report.summary.errors > 0) process.exitCode = 1
85
166
  return
86
167
  }
87
168
 
88
- throw new Error(`unknown command "${command}"`)
89
- }
90
-
91
- function parseArgs(argv) {
92
- const flags = {}
93
- const positionals = []
94
-
95
- for (let index = 0; index < argv.length; index += 1) {
96
- const arg = argv[index]
97
-
98
- if (arg === "-h") {
99
- flags.h = true
100
- continue
101
- }
102
-
103
- if (!arg.startsWith("--")) {
104
- positionals.push(arg)
105
- continue
106
- }
107
-
108
- const [rawKey, inlineValue] = arg.slice(2).split("=")
109
- const key = rawKey.trim()
169
+ if (command === "init") {
170
+ await runFileTransaction(() => initProject(flags))
171
+ return
172
+ }
110
173
 
111
- if (inlineValue !== undefined) {
112
- flags[key] = inlineValue
113
- continue
114
- }
174
+ if (command === "add") {
175
+ await runFileTransaction(() => addComponents(positionals, flags))
176
+ return
177
+ }
115
178
 
116
- const next = argv[index + 1]
117
- if ((key === "cwd" || key === "out") && next && !next.startsWith("-")) {
118
- flags[key] = next
119
- index += 1
120
- continue
121
- }
179
+ if (command === "diff") {
180
+ await runFileTransaction(() => diffComponents(positionals, flags))
181
+ return
182
+ }
122
183
 
123
- flags[key] = true
184
+ if (command === "update") {
185
+ await runFileTransaction(() => updateComponents(positionals, flags))
186
+ return
124
187
  }
125
188
 
126
- const [command, ...rest] = positionals
127
- return { command, positionals: rest, flags }
189
+ throw new Error(`unknown command "${command}"`)
128
190
  }
129
191
 
130
192
  function printHelp() {
131
193
  console.log(`Banhaten design system
132
194
 
133
195
  Usage:
134
- banhaten init [--cwd <path>] [--force] [--dry-run]
135
- banhaten add <component...> [--cwd <path>] [--force] [--dry-run]
136
- banhaten diff [component...] [--cwd <path>] [--force]
137
- banhaten update [component...] [--cwd <path>] [--force] [--dry-run]
138
- banhaten list
196
+ banhaten init [--cwd <path>] [--force] [--dry-run] [--json]
197
+ banhaten add <component...> [--cwd <path>] [--force] [--dry-run] [--json]
198
+ banhaten diff [component...] [--cwd <path>] [--json]
199
+ banhaten update [component...] [--cwd <path>] [--force] [--dry-run] [--json]
200
+ banhaten list [--json]
201
+ banhaten search <query> [--json]
202
+ banhaten docs <component> [--json]
203
+ banhaten view <component> [--file <registry-source>] [--json]
204
+ banhaten doctor [--cwd <path>] [--json]
205
+ banhaten version
139
206
 
140
207
  Examples:
141
208
  npx banhaten init
142
209
  npx banhaten add button
143
210
  npx banhaten diff
144
211
  npx banhaten update button
212
+ npx banhaten search input
213
+ npx banhaten docs button
214
+ npx banhaten view button
215
+ npx banhaten doctor
216
+
217
+ Command flags:
218
+ --cwd <path> Run project-aware commands against a project directory.
219
+ --dry-run Preview init, add, or update writes.
220
+ --force Replace locally modified generated files after review.
221
+ --json Print machine-readable output where shown above.
222
+
223
+ Global flags:
224
+ --help, -h Print help.
225
+ --silent, -s Suppress normal command output.
226
+ --yes, -y Accepted for non-interactive scripts.
227
+ --version, -v Print the installed CLI version.
145
228
  `)
146
229
  }
147
230
 
148
- function listComponents() {
149
- const names = Object.keys(registry.components)
150
-
151
- 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}`)
155
- }
156
- }
157
-
158
231
  async function initProject(flags) {
159
- const cwd = path.resolve(String(flags.cwd || process.cwd()))
232
+ const cwd = path.resolve(flags.cwd || process.cwd())
160
233
  const dryRun = Boolean(flags["dry-run"])
161
234
  const force = Boolean(flags.force)
162
- await warmPathHints(cwd)
163
235
  const config = await loadConfig(cwd)
236
+ const installPlan = await createInstallPlan(cwd, config)
237
+ const receipt = await readInstallReceipt(cwd)
238
+ validateReceiptDestinations(installPlan, receipt, registry)
239
+ const managedOptions = { dryRun, force, receipt }
164
240
  const writes = []
165
241
 
166
242
  writes.push(
167
243
  ...(await writeConfig(cwd, config, { dryRun, force })),
168
- ...(await writeBaseFiles(cwd, config, { dryRun, force })),
169
- ...(await writeGlobalCss(cwd, config, { dryRun, force })),
170
- ...(await writeAliasConfig(cwd, config, { dryRun, force }))
244
+ ...(await writeProjectSchemaFiles(cwd, { dryRun, force })),
245
+ ...(await writeBaseFiles(installPlan, managedOptions)),
246
+ ...(await writeGlobalCss(installPlan, managedOptions)),
247
+ ...(await writeAliasConfig(installPlan, { dryRun, force }))
171
248
  )
172
249
 
173
- const dependencyWrites = await updatePackageJson(
250
+ const packageResult = await updatePackageJson(
174
251
  cwd,
175
- await getPackageChanges(cwd, registry.base.dependencies || {}),
252
+ getPackageChanges(installPlan, registry.base.dependencies || {}),
176
253
  { dryRun }
177
254
  )
178
- writes.push(...dependencyWrites)
179
-
180
- printResult("Initialized Banhaten design system", writes, dryRun)
181
- printInstallHint(writes, dryRun)
255
+ writes.push(...packageResult.writes)
256
+ writes.push(...(await writeInstallReceipt(installPlan, [], { dryRun })))
257
+ const installResolution = await resolveInstallAction(
258
+ cwd,
259
+ packageResult.installRequired
260
+ )
261
+ for (const evidence of installResolution.evidence) {
262
+ await assertFileUnchanged(evidence.path, evidence.source)
263
+ }
264
+ const { install } = installResolution
265
+
266
+ deferUntilApplied(() => {
267
+ printResult(
268
+ "Initialized Banhaten design system",
269
+ writes,
270
+ dryRun,
271
+ runtimeOptions,
272
+ install
273
+ )
274
+ printInstallHint(install, dryRun, runtimeOptions)
275
+ })
182
276
  }
183
277
 
184
278
  async function addComponents(names, flags) {
@@ -193,7 +287,7 @@ async function addComponents(names, flags) {
193
287
  }
194
288
 
195
289
  async function diffComponents(names, flags) {
196
- await writeComponents(names, { ...flags, "dry-run": true }, {
290
+ await writeComponents(names, { ...flags, "dry-run": true, "show-diff": true }, {
197
291
  inferInstalled: true,
198
292
  title: names.length
199
293
  ? `Checked ${names.join(", ")} for updates`
@@ -211,16 +305,20 @@ async function updateComponents(names, flags) {
211
305
  }
212
306
 
213
307
  async function writeComponents(requestedNames, flags, options) {
214
- const cwd = path.resolve(String(flags.cwd || process.cwd()))
308
+ const cwd = path.resolve(flags.cwd || process.cwd())
215
309
  const dryRun = Boolean(flags["dry-run"])
216
310
  const force = Boolean(flags.force)
217
- await warmPathHints(cwd)
311
+ const showDiff = Boolean(flags["show-diff"])
218
312
  const config = await loadConfig(cwd)
313
+ const installPlan = await createInstallPlan(cwd, config)
314
+ const receipt = await readInstallReceipt(cwd)
315
+ validateReceiptDestinations(installPlan, receipt, registry)
316
+ const managedOptions = { dryRun, force, receipt, showDiff }
219
317
  const selectedNames =
220
318
  requestedNames.length > 0
221
319
  ? requestedNames
222
320
  : options.inferInstalled
223
- ? await findInstalledComponents(cwd, config)
321
+ ? await findInstalledComponents(installPlan)
224
322
  : requestedNames
225
323
 
226
324
  if (selectedNames.length === 0) {
@@ -232,12 +330,13 @@ async function writeComponents(requestedNames, flags, options) {
232
330
  const names = resolveRegistryDependencies(selectedNames)
233
331
 
234
332
  const writes = []
235
- const deps = { ...(registry.base.dependencies || {}) }
333
+ const deps = {}
334
+ const components = []
236
335
 
237
- writes.push(
238
- ...(await writeConfig(cwd, config, { dryRun, force })),
239
- ...(await writeBaseFiles(cwd, config, { dryRun, force })),
240
- ...(await writeAliasConfig(cwd, config, { dryRun, force }))
336
+ mergeDependencyRequirements(
337
+ deps,
338
+ registry.base.dependencies || {},
339
+ "registry base"
241
340
  )
242
341
 
243
342
  for (const name of names) {
@@ -246,41 +345,200 @@ async function writeComponents(requestedNames, flags, options) {
246
345
  throw new Error(`unknown component "${name}". Run \`banhaten list\`.`)
247
346
  }
248
347
 
249
- Object.assign(deps, component.dependencies || {})
348
+ mergeDependencyRequirements(
349
+ deps,
350
+ component.dependencies || {},
351
+ `registry component "${name}"`
352
+ )
353
+ components.push({ component, name })
354
+ }
355
+
356
+ writes.push(
357
+ ...(await writeConfig(cwd, config, { dryRun, force })),
358
+ ...(await writeProjectSchemaFiles(cwd, { dryRun, force })),
359
+ ...(await writeBaseFiles(installPlan, managedOptions)),
360
+ ...(await writeGlobalCss(installPlan, managedOptions)),
361
+ ...(await writeAliasConfig(installPlan, { dryRun, force }))
362
+ )
363
+
364
+ for (const { component, name } of components) {
250
365
  writes.push(
251
- ...(await writeRegistryFiles(cwd, config, component.files || [], {
252
- dryRun,
253
- force,
366
+ ...(await writeRegistryFiles(installPlan, component.files || [], {
367
+ ...managedOptions,
368
+ owner: name,
254
369
  }))
255
370
  )
256
371
  }
257
372
 
258
- writes.push(...(await updatePackageJson(
373
+ const packageResult = await updatePackageJson(
259
374
  cwd,
260
- await getPackageChanges(cwd, deps),
375
+ getPackageChanges(installPlan, deps),
261
376
  { dryRun }
262
- )))
377
+ )
378
+ writes.push(...packageResult.writes)
379
+ writes.push(...(await writeInstallReceipt(installPlan, names, { dryRun })))
380
+ const installResolution = await resolveInstallAction(
381
+ cwd,
382
+ packageResult.installRequired
383
+ )
384
+ for (const evidence of installResolution.evidence) {
385
+ await assertFileUnchanged(evidence.path, evidence.source)
386
+ }
387
+ const { install } = installResolution
263
388
 
264
- printResult(options.title, writes, dryRun)
265
- printInstallHint(writes, dryRun)
389
+ deferUntilApplied(() => {
390
+ printResult(options.title, writes, dryRun, runtimeOptions, install)
391
+ printInstallHint(install, dryRun, runtimeOptions)
392
+ })
266
393
  }
267
394
 
268
- async function findInstalledComponents(cwd, config) {
395
+ async function readInstallReceipt(cwd) {
396
+ const receipt = await readJsonIfExists(path.join(cwd, INSTALL_RECEIPT_FILE))
397
+ if (!receipt) return null
398
+ if (receipt.schemaVersion !== 1 || !receipt.components) {
399
+ throw new Error(
400
+ `${INSTALL_RECEIPT_FILE} is invalid. Re-run an explicit \`banhaten add <component> --force\` command to rebuild it.`
401
+ )
402
+ }
403
+ return receipt
404
+ }
405
+
406
+ async function findInstalledComponents(installPlan) {
407
+ const { cwd } = installPlan
408
+ const receiptPath = path.join(cwd, INSTALL_RECEIPT_FILE)
409
+ const receipt = await readInstallReceipt(cwd)
410
+ if (receipt) {
411
+ const installed = []
412
+ for (const [name, record] of Object.entries(receipt.components)) {
413
+ if (!registry.components[name] || !Array.isArray(record.files)) continue
414
+ if (
415
+ record.files.length > 0 &&
416
+ (await allPathsExist(
417
+ record.files.map((file) =>
418
+ path.join(cwd, normalizeReceiptTarget(file.target))
419
+ )
420
+ ))
421
+ ) {
422
+ installed.push(name)
423
+ }
424
+ }
425
+
426
+ return installed
427
+ }
428
+
269
429
  const installed = []
270
430
 
271
431
  for (const [name, component] of Object.entries(registry.components)) {
272
- for (const file of component.files || []) {
273
- const targetPath = path.join(cwd, resolveTarget(cwd, config, file.target))
274
- if (await exists(targetPath)) {
275
- installed.push(name)
276
- break
277
- }
432
+ const files = component.files || []
433
+ if (files.length === 0) continue
434
+
435
+ const targetPaths = files.map((file) =>
436
+ path.join(cwd, resolveInstallTarget(installPlan, file.target))
437
+ )
438
+ if (await allPathsExist(targetPaths)) {
439
+ installed.push(name)
278
440
  }
279
441
  }
280
442
 
281
443
  return installed
282
444
  }
283
445
 
446
+ async function writeInstallReceipt(installPlan, names, options) {
447
+ const { cwd } = installPlan
448
+ const receiptPath = path.join(cwd, INSTALL_RECEIPT_FILE)
449
+ const current = (await readJsonIfExists(receiptPath)) || {
450
+ schemaVersion: 1,
451
+ components: {},
452
+ }
453
+ const components = normalizeReceiptComponents(current.components || {})
454
+ const managedFiles = Object.fromEntries(
455
+ Object.entries(current.managedFiles || {}).map(([target, record]) => [
456
+ normalizeReceiptTarget(target),
457
+ record,
458
+ ])
459
+ )
460
+
461
+ await addManagedFileRecords(
462
+ managedFiles,
463
+ installPlan,
464
+ registry.base.files || [],
465
+ "base"
466
+ )
467
+
468
+ for (const name of names) {
469
+ const component = registry.components[name]
470
+ await addManagedFileRecords(
471
+ managedFiles,
472
+ installPlan,
473
+ component.files || [],
474
+ name
475
+ )
476
+ components[name] = {
477
+ files: await Promise.all(
478
+ (component.files || []).map(async (file) => {
479
+ const sourcePath = path.join(registryRoot, file.source)
480
+ const source = await fs.readFile(sourcePath)
481
+ const target = resolveInstallTarget(installPlan, file.target)
482
+ const content = isTextRegistryFile(file.source)
483
+ ? applyTemplate(source.toString("utf8"), installPlan, target)
484
+ : source
485
+
486
+ return {
487
+ target,
488
+ sha256: createHash("sha256").update(content).digest("hex"),
489
+ }
490
+ })
491
+ ),
492
+ }
493
+ }
494
+
495
+ const next = {
496
+ schemaVersion: 1,
497
+ registryVersion: packageManifest.version,
498
+ installPlan: createInstallPlanReceipt(installPlan),
499
+ components: sortObject(components),
500
+ managedFiles: sortObject(managedFiles),
501
+ tokenCss: await createTokenCssReceipt(installPlan),
502
+ }
503
+ const status = await writeFile(
504
+ receiptPath,
505
+ `${JSON.stringify(next, null, 2)}\n`,
506
+ { dryRun: options.dryRun, force: true }
507
+ )
508
+
509
+ return [`${status} ${INSTALL_RECEIPT_FILE}`]
510
+ }
511
+
512
+ async function addManagedFileRecords(records, installPlan, files, owner) {
513
+ for (const file of files) {
514
+ const source = await fs.readFile(path.join(registryRoot, file.source))
515
+ const target = resolveInstallTarget(installPlan, file.target)
516
+ const content = isTextRegistryFile(file.source)
517
+ ? applyTemplate(source.toString("utf8"), installPlan, target)
518
+ : source
519
+ records[target] = {
520
+ owner,
521
+ sha256: hashContent(content),
522
+ }
523
+ }
524
+ }
525
+
526
+ async function createTokenCssReceipt(installPlan) {
527
+ const tokenCss = await fs.readFile(
528
+ path.join(registryRoot, "styles", "globals.css"),
529
+ "utf8"
530
+ )
531
+ return {
532
+ target: installPlan.destinations.css,
533
+ sha256: hashNormalizedText(tokenCss),
534
+ }
535
+ }
536
+
537
+ async function allPathsExist(paths) {
538
+ const results = await Promise.all(paths.map((targetPath) => exists(targetPath)))
539
+ return results.every(Boolean)
540
+ }
541
+
284
542
  function resolveRegistryDependencies(names) {
285
543
  const resolved = []
286
544
  const seen = new Set()
@@ -336,8 +594,14 @@ async function loadConfig(cwd) {
336
594
 
337
595
  const localConfig = await readJsonIfExists(configPath)
338
596
  if (localConfig) {
339
- config.style = localConfig.style || config.style
340
- config.tsx = localConfig.tsx ?? config.tsx
597
+ // Validate what the user actually wrote, before it is merged onto the
598
+ // defaults. Validating the merged object instead would apply
599
+ // `additionalProperties: false` to something that structurally cannot carry
600
+ // an unknown top-level key, so a stray key would pass here and then be
601
+ // silently dropped by writeConfig. Known legacy keys are stripped first so
602
+ // they migrate instead of failing.
603
+ assertSchema(stripLegacyConfigKeys(localConfig), configSchema, CONFIG_FILE)
604
+
341
605
  if (localConfig.tailwind?.css) hasExplicitCssPath = true
342
606
  config.tailwind = { ...config.tailwind, ...(localConfig.tailwind || {}) }
343
607
  config.aliases = { ...config.aliases, ...(localConfig.aliases || {}) }
@@ -347,6 +611,8 @@ async function loadConfig(cwd) {
347
611
  config.tailwind.css = await detectCssPath(cwd, config.tailwind.css)
348
612
  }
349
613
 
614
+ assertSchema(config, configSchema, CONFIG_FILE)
615
+
350
616
  return config
351
617
  }
352
618
 
@@ -371,6 +637,38 @@ async function detectCssPath(cwd, fallback) {
371
637
  async function writeConfig(cwd, config, options) {
372
638
  const configPath = path.join(cwd, CONFIG_FILE)
373
639
  if ((await exists(configPath)) && !options.force) {
640
+ const current = await fs.readFile(configPath, "utf8")
641
+ const currentConfig = JSON.parse(current)
642
+ // Spread the existing file first so any key this version does not manage
643
+ // survives the rewrite. Rebuilding from a fixed whitelist would delete it
644
+ // and still report the write as a plain "updated". Legacy keys are the one
645
+ // intentional exception, and the removal is reported rather than silent.
646
+ const legacyKeys = findLegacyConfigKeys(currentConfig)
647
+ const nextConfig = {
648
+ ...stripLegacyConfigKeys(currentConfig),
649
+ $schema: DEFAULT_CONFIG.$schema,
650
+ tailwind: config.tailwind,
651
+ aliases: config.aliases,
652
+ }
653
+
654
+ if (JSON.stringify(currentConfig) !== JSON.stringify(nextConfig)) {
655
+ const status = await writeFile(
656
+ configPath,
657
+ `${JSON.stringify(nextConfig, null, 2)}\n`,
658
+ { dryRun: options.dryRun }
659
+ )
660
+
661
+ const notes = [`${status} ${relative(cwd, configPath)}`]
662
+ if (legacyKeys.length > 0) {
663
+ notes.push(
664
+ `removed legacy config ${legacyKeys.length === 1 ? "key" : "keys"} ` +
665
+ `${legacyKeys.map((key) => JSON.stringify(key)).join(", ")} from ` +
666
+ `${relative(cwd, configPath)}`
667
+ )
668
+ }
669
+ return notes
670
+ }
671
+
374
672
  return [`kept ${relative(cwd, configPath)}`]
375
673
  }
376
674
 
@@ -382,30 +680,72 @@ async function writeConfig(cwd, config, options) {
382
680
  return [`${status} ${relative(cwd, configPath)}`]
383
681
  }
384
682
 
385
- async function writeBaseFiles(cwd, config, options) {
386
- return writeRegistryFiles(cwd, config, registry.base.files || [], options)
683
+ async function writeProjectSchemaFiles(cwd, options) {
684
+ const schemaFiles = [
685
+ "config.schema.json",
686
+ "registry.schema.json",
687
+ "tokens.schema.json",
688
+ ]
689
+ const writes = []
690
+
691
+ for (const schemaFile of schemaFiles) {
692
+ const sourcePath = path.join(schemaRoot, schemaFile)
693
+ const targetPath = path.join(cwd, ".banhaten", "schema", schemaFile)
694
+ const source = await fs.readFile(sourcePath, "utf8")
695
+ const status = await writeFile(targetPath, source, options)
696
+
697
+ writes.push(`${status} ${relative(cwd, targetPath)}`)
698
+ }
699
+
700
+ return writes
701
+ }
702
+
703
+ async function writeBaseFiles(installPlan, options) {
704
+ return writeRegistryFiles(installPlan, registry.base.files || [], {
705
+ ...options,
706
+ owner: "base",
707
+ })
387
708
  }
388
709
 
389
- async function writeRegistryFiles(cwd, config, files, options) {
710
+ async function writeRegistryFiles(installPlan, files, options) {
711
+ const { cwd } = installPlan
390
712
  const writes = []
391
713
 
392
714
  for (const file of files) {
393
715
  const sourcePath = path.join(registryRoot, file.source)
394
- const targetPath = path.join(cwd, resolveTarget(cwd, config, file.target))
716
+ const targetPath = path.join(
717
+ cwd,
718
+ resolveInstallTarget(installPlan, file.target)
719
+ )
395
720
  const source = await fs.readFile(sourcePath)
396
721
  const content = isTextRegistryFile(file.source)
397
- ? applyTemplate(source.toString("utf8"), config)
722
+ ? applyTemplate(
723
+ source.toString("utf8"),
724
+ installPlan,
725
+ relative(cwd, targetPath)
726
+ )
398
727
  : source
728
+ const target = relative(cwd, targetPath)
729
+
730
+ await assertManagedFileSafe(targetPath, content, target, options)
399
731
 
400
732
  const status = await writeFile(targetPath, content, options)
401
- writes.push(`${status} ${relative(cwd, targetPath)}`)
733
+ writes.push(`${status} ${target}`)
734
+ if (options.showDiff && status === "would update") {
735
+ const current = await fs.readFile(
736
+ targetPath,
737
+ isTextRegistryFile(file.source) ? "utf8" : undefined
738
+ )
739
+ writes.push(formatUnifiedDiff(target, current, content))
740
+ }
402
741
  }
403
742
 
404
743
  return writes
405
744
  }
406
745
 
407
- async function writeGlobalCss(cwd, config, options) {
408
- const cssPath = path.join(cwd, config.tailwind.css)
746
+ async function writeGlobalCss(installPlan, options) {
747
+ const { cwd } = installPlan
748
+ const cssPath = path.join(cwd, installPlan.destinations.css)
409
749
  const sourcePath = path.join(registryRoot, "styles", "globals.css")
410
750
  const tokenCss = await fs.readFile(sourcePath, "utf8")
411
751
  const current = await readTextIfExists(cssPath)
@@ -413,1313 +753,244 @@ async function writeGlobalCss(cwd, config, options) {
413
753
  ? removeExistingTokenBlock(current, relative(cwd, cssPath))
414
754
  : { content: "", found: false }
415
755
 
416
- if (tokenBlock.found && !options.force) {
417
- return [`kept ${relative(cwd, cssPath)}`]
418
- }
419
-
420
756
  const nextCss = current
421
757
  ? `${tokenBlock.content.trimEnd()}\n\n${tokenCss}\n`
422
758
  : `${tokenCss}\n`
423
759
  const next = ensureGlobalCssImports(nextCss)
424
760
 
425
- const status = await writeFile(cssPath, next, { ...options, force: true })
426
- return [`${status} ${relative(cwd, cssPath)}`]
427
- }
428
-
429
- async function writeAliasConfig(cwd, config, options) {
430
- if (!usesRootAlias(config, "@")) return []
761
+ if (
762
+ current &&
763
+ tokenBlock.found &&
764
+ next !== current &&
765
+ !options.dryRun &&
766
+ !options.force
767
+ ) {
768
+ const expectedHash = options.receipt?.tokenCss?.sha256
769
+ const currentHash = hashNormalizedText(tokenBlock.block)
770
+ if (!expectedHash || currentHash !== expectedHash) {
771
+ throw new Error(
772
+ `${relative(cwd, cssPath)} contains local changes inside the generated Banhaten token block. ` +
773
+ "Run `banhaten diff` to inspect them, move intentional overrides outside the generated block, or re-run with `--force`."
774
+ )
775
+ }
776
+ }
431
777
 
432
- const writes = []
433
- writes.push(...(await writeTypescriptAliasConfig(cwd, "@", options)))
434
- writes.push(...(await writeViteAliasConfig(cwd, "@", options)))
778
+ const status = await writeFile(cssPath, next, options)
779
+ const writes = [`${status} ${relative(cwd, cssPath)}`]
780
+ if (options.showDiff && status === "would update" && current !== null) {
781
+ writes.push(formatUnifiedDiff(relative(cwd, cssPath), current, next))
782
+ }
435
783
  return writes
436
784
  }
437
785
 
438
- function usesRootAlias(config, aliasRoot) {
439
- return Object.values(config.aliases || {}).some((alias) =>
440
- alias === aliasRoot || alias?.startsWith(`${aliasRoot}/`)
441
- )
442
- }
443
-
444
- async function writeTypescriptAliasConfig(cwd, aliasRoot, options) {
445
- const tsconfigPath = await findTypescriptConfigPath(cwd)
446
- if (!tsconfigPath) return []
447
-
448
- const config = await readJsonc(tsconfigPath)
449
- const compilerOptions = config.compilerOptions || {}
450
- const paths = compilerOptions.paths || {}
451
- const sourceRoot = shouldUseSrcDirectory(cwd) ? "./src" : "."
452
- const aliasPattern = `${aliasRoot}/*`
453
- const targetPattern = `${sourceRoot}/*`
454
-
455
- if (Array.isArray(paths[aliasPattern]) && paths[aliasPattern].includes(targetPattern)) {
456
- return [`kept ${relative(cwd, tsconfigPath)}`]
457
- }
786
+ async function writeAliasConfig(installPlan, options) {
787
+ const writes = []
458
788
 
459
- config.compilerOptions = {
460
- ...compilerOptions,
461
- paths: {
462
- ...paths,
463
- [aliasPattern]: [targetPattern],
464
- },
789
+ for (const plannedConfig of [installPlan.typescript, installPlan.vite]) {
790
+ if (!plannedConfig) continue
791
+ const status = await writeFile(
792
+ plannedConfig.targetPath,
793
+ plannedConfig.nextSource,
794
+ {
795
+ dryRun: options.dryRun,
796
+ expectedCurrent: plannedConfig.currentSource,
797
+ force: true,
798
+ }
799
+ )
800
+ writes.push(
801
+ `${status} ${relative(installPlan.cwd, plannedConfig.targetPath)}`
802
+ )
465
803
  }
466
804
 
467
- const status = await writeFile(tsconfigPath, `${JSON.stringify(config, null, 2)}\n`, {
468
- dryRun: options.dryRun,
469
- force: true,
470
- })
471
- return [`${status} ${relative(cwd, tsconfigPath)}`]
805
+ return writes
472
806
  }
473
807
 
474
- async function findTypescriptConfigPath(cwd) {
475
- const candidates = [
476
- "tsconfig.app.json",
477
- "tsconfig.json",
478
- "jsconfig.json",
479
- ]
480
-
481
- for (const candidate of candidates) {
482
- const target = path.join(cwd, candidate)
483
- if (await exists(target)) return target
808
+ function getPackageChanges(installPlan, dependencies) {
809
+ const devDependencies = { ...SETUP_DEV_DEPENDENCIES }
810
+ if (installPlan.vite) {
811
+ Object.assign(devDependencies, VITE_DEV_DEPENDENCIES)
484
812
  }
485
813
 
486
- return null
814
+ return {
815
+ dependencies,
816
+ devDependencies,
817
+ }
487
818
  }
488
819
 
489
- async function writeViteAliasConfig(cwd, aliasRoot, options) {
490
- const viteConfigPath = await findViteConfigPath(cwd)
491
- if (!viteConfigPath) return []
492
-
493
- const current = await fs.readFile(viteConfigPath, "utf8")
494
- const sourceRoot = shouldUseSrcDirectory(cwd) ? "./src" : "."
495
- let next
496
-
497
- try {
498
- next = updateViteConfigSource(current, { aliasRoot, sourceRoot })
499
- } catch (error) {
500
- throw new Error(`${relative(cwd, viteConfigPath)}: ${error.message}`)
820
+ async function updatePackageJson(cwd, changes, options) {
821
+ const packagePath = path.join(cwd, "package.json")
822
+ const currentSource = await readTextIfExists(packagePath)
823
+ const packageExists = currentSource !== null
824
+ const packageJson = currentSource !== null ? JSON.parse(currentSource) : {
825
+ name: path.basename(cwd).toLowerCase(),
826
+ version: "0.0.0",
827
+ private: true,
501
828
  }
829
+ const packagePlan = createPackageDependencyPlan(packageJson, changes)
502
830
 
503
- if (next === current) {
504
- return [`kept ${relative(cwd, viteConfigPath)}`]
831
+ if (packagePlan.operations.length === 0 && packageExists) {
832
+ await assertFileUnchanged(packagePath, currentSource)
833
+ const installState = await inspectPackageInstallState({
834
+ cwd,
835
+ packageJson,
836
+ requirements: packagePlan.requirements,
837
+ })
838
+ for (const evidence of installState.evidence) {
839
+ await assertFileUnchanged(evidence.path, evidence.source)
840
+ }
841
+ return {
842
+ installRequired: installState.required,
843
+ writes: [`kept ${relative(cwd, packagePath)}`],
844
+ }
505
845
  }
506
846
 
507
- const status = await writeFile(viteConfigPath, next, {
847
+ applyPackageDependencyPlan(packageJson, packagePlan.operations)
848
+
849
+ const status = await writeFile(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`, {
508
850
  dryRun: options.dryRun,
851
+ expectedCurrent: currentSource,
509
852
  force: true,
510
853
  })
511
- return [`${status} ${relative(cwd, viteConfigPath)}`]
512
- }
513
-
514
- async function findViteConfigPath(cwd) {
515
- const candidates = [
516
- "vite.config.ts",
517
- "vite.config.mts",
518
- "vite.config.js",
519
- "vite.config.mjs",
520
- ]
521
854
 
522
- for (const candidate of candidates) {
523
- const target = path.join(cwd, candidate)
524
- if (await exists(target)) return target
855
+ return {
856
+ installRequired: packagePlan.operations.length > 0,
857
+ writes: [`${status} ${relative(cwd, packagePath)}`],
525
858
  }
526
-
527
- return null
528
859
  }
529
860
 
530
- function ensureNodeUrlImport(source) {
531
- const imports = parseImportDeclarations(source)
532
- const nodeUrlImports = imports.filter((entry) => entry.moduleSpecifier === "node:url")
533
- const importedLocals = new Set(nodeUrlImports.flatMap((entry) => entry.namedLocals))
534
- const missing = ["fileURLToPath", "URL"].filter((name) => !importedLocals.has(name))
535
-
536
- if (missing.length === 0) return source
537
-
538
- return prependImportLines(
539
- source,
540
- [`import { ${missing.join(", ")} } from "node:url"`]
861
+ function applyTemplate(source, installPlan, target) {
862
+ const replacements = {
863
+ "@/components/ui": resolveTemplateImport(
864
+ installPlan,
865
+ target,
866
+ "ui"
867
+ ),
868
+ "@/lib/utils": resolveTemplateImport(
869
+ installPlan,
870
+ target,
871
+ "utils"
872
+ ),
873
+ "@/hooks": resolveTemplateImport(
874
+ installPlan,
875
+ target,
876
+ "hooks"
877
+ ),
878
+ }
879
+
880
+ return source.replace(
881
+ /@\/components\/ui|@\/lib\/utils|@\/hooks/g,
882
+ (match) => replacements[match]
541
883
  )
542
884
  }
543
885
 
544
- function ensureTailwindViteImport(source, localName) {
545
- const imports = parseImportDeclarations(source)
546
- const tailwindImport = imports.find(
547
- (entry) => entry.moduleSpecifier === "@tailwindcss/vite"
548
- )
549
-
550
- if (!tailwindImport) {
551
- return prependImportLines(
552
- source,
553
- [`import ${localName} from "@tailwindcss/vite"`]
554
- )
886
+ function resolveTemplateImport(installPlan, target, destinationName) {
887
+ const configuredAlias = installPlan.config.aliases[destinationName]
888
+ if (
889
+ configuredAlias === "@" ||
890
+ configuredAlias === "~" ||
891
+ configuredAlias.startsWith("@/") ||
892
+ configuredAlias.startsWith("~/")
893
+ ) {
894
+ return configuredAlias.replace(/\/$/, "")
555
895
  }
556
896
 
557
- if (!tailwindImport.defaultLocal) {
558
- throw new Error(
559
- "unsupported Vite config: @tailwindcss/vite must be imported with a default binding."
560
- )
561
- }
897
+ const targetDirectory = path.posix.dirname(
898
+ normalizeReceiptTarget(target)
899
+ )
900
+ const destination = installPlan.destinations[destinationName]
901
+ const relativeImport = path.posix.relative(targetDirectory, destination)
562
902
 
563
- return source
903
+ if (!relativeImport) return "."
904
+ return relativeImport.startsWith(".")
905
+ ? relativeImport
906
+ : `./${relativeImport}`
564
907
  }
565
908
 
566
- function updateViteConfigSource(source, { aliasRoot, sourceRoot }) {
567
- const tailwindLocalName = getTailwindViteLocalName(source)
568
- let next = addViteResolveAlias(source, aliasRoot, sourceRoot)
569
- next = addVitePluginCall(next, `${tailwindLocalName}()`, tailwindLocalName)
570
- next = ensureTailwindViteImport(next, tailwindLocalName)
571
- next = ensureNodeUrlImport(next)
572
- return next
909
+ function isTextRegistryFile(source) {
910
+ return !/\.(avif|eot|gif|ico|jpe?g|otf|png|ttf|webp|woff2?)$/i.test(source)
573
911
  }
574
912
 
575
- function getTailwindViteLocalName(source) {
576
- const tailwindImport = parseImportDeclarations(source).find(
577
- (entry) => entry.moduleSpecifier === "@tailwindcss/vite"
578
- )
579
-
580
- if (!tailwindImport) return "tailwindcss"
581
- if (tailwindImport.defaultLocal) return tailwindImport.defaultLocal
582
-
583
- throw new Error(
584
- "unsupported Vite config: @tailwindcss/vite must be imported with a default binding."
585
- )
913
+ async function readJson(filePath) {
914
+ return JSON.parse(await fs.readFile(filePath, "utf8"))
586
915
  }
587
916
 
588
- function addViteResolveAlias(source, aliasRoot, sourceRoot) {
589
- const configObject = findViteConfigObject(source)
590
- const resolveProperty = findObjectProperty(source, configObject, "resolve")
591
- const aliasEntry = `${JSON.stringify(aliasRoot)}: fileURLToPath(new URL(${JSON.stringify(
592
- sourceRoot
593
- )}, import.meta.url))`
594
-
595
- if (!resolveProperty) {
596
- return insertObjectProperty(
597
- source,
598
- configObject,
599
- [
600
- "resolve: {",
601
- " alias: {",
602
- ` ${aliasEntry},`,
603
- " },",
604
- "},",
605
- ].join("\n")
606
- )
607
- }
608
-
609
- const resolveObject = expectObjectLiteralValue(
610
- source,
611
- resolveProperty,
612
- "unsupported Vite config: resolve must be an object literal."
613
- )
614
- const aliasProperty = findObjectProperty(source, resolveObject, "alias")
615
-
616
- if (!aliasProperty) {
617
- return insertObjectProperty(
618
- source,
619
- resolveObject,
620
- [
621
- "alias: {",
622
- ` ${aliasEntry},`,
623
- "},",
624
- ].join("\n")
625
- )
917
+ async function readJsonIfExists(filePath) {
918
+ try {
919
+ return await readJson(filePath)
920
+ } catch (error) {
921
+ if (error.code === "ENOENT") return null
922
+ throw error
626
923
  }
924
+ }
627
925
 
628
- const aliasObject = expectObjectLiteralValue(
629
- source,
630
- aliasProperty,
631
- "unsupported Vite config: resolve.alias must be an object literal."
632
- )
633
-
634
- if (findObjectProperty(source, aliasObject, aliasRoot)) {
635
- return source
926
+ async function readTextIfExists(filePath) {
927
+ try {
928
+ return await fs.readFile(filePath, "utf8")
929
+ } catch (error) {
930
+ if (error.code === "ENOENT") return null
931
+ throw error
636
932
  }
637
-
638
- return insertObjectProperty(source, aliasObject, `${aliasEntry},`)
639
933
  }
640
934
 
641
- function addVitePluginCall(source, pluginCall, pluginLocalName) {
642
- const configObject = findViteConfigObject(source)
643
- const pluginsProperty = findObjectProperty(source, configObject, "plugins")
644
-
645
- if (!pluginsProperty) {
646
- return insertObjectProperty(source, configObject, `plugins: [${pluginCall}],`)
647
- }
935
+ async function assertManagedFileSafe(targetPath, nextContent, target, options) {
936
+ if (options.dryRun || options.force || !(await exists(targetPath))) return
648
937
 
649
- const pluginsArray = expectArrayLiteralValue(
650
- source,
651
- pluginsProperty,
652
- "unsupported Vite config: plugins must be an array literal."
653
- )
938
+ const current = await fs.readFile(targetPath)
939
+ const next = Buffer.isBuffer(nextContent)
940
+ ? nextContent
941
+ : Buffer.from(nextContent, "utf8")
942
+ if (current.equals(next)) return
654
943
 
655
- if (arrayHasCallExpression(source, pluginsArray, pluginLocalName)) {
656
- return source
657
- }
944
+ const expectedHash = findManagedFileHash(options.receipt, target)
945
+ const currentHash = hashContent(current)
946
+ if (expectedHash && currentHash === expectedHash) return
658
947
 
659
- return appendArrayElement(source, pluginsArray, pluginCall)
948
+ const owner = options.owner ? ` (${options.owner})` : ""
949
+ const reason = expectedHash
950
+ ? "has local changes since Banhaten installed it"
951
+ : "already exists without a Banhaten ownership receipt"
952
+ const diffCommand = ["banhaten", "diff", options.owner === "base" ? "" : options.owner]
953
+ .filter(Boolean)
954
+ .join(" ")
955
+ throw new Error(
956
+ `${target}${owner} ${reason}. Run \`${diffCommand}\` to inspect the registry version, ` +
957
+ "or re-run the command with `--force` to replace the file."
958
+ )
660
959
  }
661
960
 
662
- function findViteConfigObject(source) {
663
- const expressionStart = findExportDefaultExpressionStart(source)
961
+ function findManagedFileHash(receipt, target) {
962
+ const normalizedTarget = normalizeReceiptTarget(target)
963
+ const direct = Object.entries(receipt?.managedFiles || {}).find(
964
+ ([recordedTarget]) =>
965
+ normalizeReceiptTarget(recordedTarget) === normalizedTarget
966
+ )?.[1]?.sha256
967
+ if (direct) return direct
664
968
 
665
- if (expressionStart === -1) {
666
- throw new Error(
667
- "unsupported Vite config: expected `export default defineConfig({ ... })` or `export default { ... }`."
969
+ for (const component of Object.values(receipt?.components || {})) {
970
+ const match = component.files?.find(
971
+ (file) => normalizeReceiptTarget(file.target) === normalizedTarget
668
972
  )
973
+ if (match?.sha256) return match.sha256
669
974
  }
670
975
 
671
- if (source[expressionStart] === "{") {
672
- return createBracketRange(source, expressionStart, "{")
673
- }
976
+ return null
977
+ }
674
978
 
675
- const callee = readIdentifierAt(source, expressionStart)
676
- if (callee?.name !== "defineConfig") {
677
- throw new Error(
678
- "unsupported Vite config: default export must be `defineConfig({ ... })` or an object literal."
679
- )
680
- }
979
+ function hashContent(content) {
980
+ return createHash("sha256").update(content).digest("hex")
981
+ }
681
982
 
682
- let index = skipWhitespaceAndComments(source, callee.end)
683
- if (source[index] !== "(") {
684
- throw new Error("unsupported Vite config: malformed defineConfig call.")
685
- }
983
+ function hashNormalizedText(content) {
984
+ return hashContent(String(content).replace(/\r\n/g, "\n").trim())
985
+ }
686
986
 
687
- index = skipWhitespaceAndComments(source, index + 1)
688
- if (source[index] !== "{") {
689
- throw new Error(
690
- "unsupported Vite config: defineConfig must receive a config object literal."
691
- )
987
+ async function exists(filePath) {
988
+ try {
989
+ await fs.access(filePath)
990
+ return true
991
+ } catch {
992
+ return false
692
993
  }
693
-
694
- return createBracketRange(source, index, "{")
695
- }
696
-
697
- function findExportDefaultExpressionStart(source) {
698
- let index = 0
699
-
700
- while (index < source.length) {
701
- const next = skipIgnoredSyntax(source, index)
702
- if (next !== index) {
703
- index = next
704
- continue
705
- }
706
-
707
- if (!matchIdentifier(source, index, "export")) {
708
- index += 1
709
- continue
710
- }
711
-
712
- const defaultStart = skipWhitespaceAndComments(source, index + "export".length)
713
- if (!matchIdentifier(source, defaultStart, "default")) {
714
- index += "export".length
715
- continue
716
- }
717
-
718
- return skipWhitespaceAndComments(source, defaultStart + "default".length)
719
- }
720
-
721
- return -1
722
- }
723
-
724
- function createBracketRange(source, start, open) {
725
- const close = open === "{" ? "}" : open === "[" ? "]" : ")"
726
- const end = findMatchingBracket(source, start, open, close)
727
- return { start, end: end + 1 }
728
- }
729
-
730
- function expectObjectLiteralValue(source, property, message) {
731
- const valueStart = skipWhitespaceAndComments(source, property.valueStart)
732
- if (source[valueStart] !== "{") throw new Error(message)
733
- return createBracketRange(source, valueStart, "{")
734
- }
735
-
736
- function expectArrayLiteralValue(source, property, message) {
737
- const valueStart = skipWhitespaceAndComments(source, property.valueStart)
738
- if (source[valueStart] !== "[") throw new Error(message)
739
- return createBracketRange(source, valueStart, "[")
740
- }
741
-
742
- function findObjectProperty(source, objectRange, propertyName) {
743
- const bodyEnd = objectRange.end - 1
744
- let index = objectRange.start + 1
745
-
746
- while (index < bodyEnd) {
747
- index = skipWhitespaceCommentsAndCommas(source, index, bodyEnd)
748
- if (index >= bodyEnd) return null
749
-
750
- const propertyStart = index
751
- const name = readPropertyName(source, index)
752
- const propertyEnd = findTopLevelCommaOrEnd(source, propertyStart, bodyEnd)
753
-
754
- if (!name) {
755
- index = propertyEnd + 1
756
- continue
757
- }
758
-
759
- index = skipWhitespaceAndComments(source, name.end)
760
- if (source[index] !== ":") {
761
- if (name.name === propertyName) {
762
- throw new Error(
763
- `unsupported Vite config: ${propertyName} must be a normal object property.`
764
- )
765
- }
766
- index = propertyEnd + 1
767
- continue
768
- }
769
-
770
- if (name.name === propertyName) {
771
- return {
772
- name: name.name,
773
- start: propertyStart,
774
- end: propertyEnd,
775
- valueStart: skipWhitespaceAndComments(source, index + 1),
776
- valueEnd: propertyEnd,
777
- }
778
- }
779
-
780
- index = propertyEnd + 1
781
- }
782
-
783
- return null
784
- }
785
-
786
- function readPropertyName(source, index) {
787
- if (source[index] === "\"" || source[index] === "'") {
788
- const literal = readStringLiteral(source, index)
789
- return literal ? { name: literal.value, end: literal.end } : null
790
- }
791
-
792
- return readIdentifierAt(source, index)
793
- }
794
-
795
- function insertObjectProperty(source, objectRange, propertyText) {
796
- const insertAt = objectRange.start + 1
797
- const bodyEnd = objectRange.end - 1
798
- const body = source.slice(insertAt, bodyEnd)
799
- const hasContent = body.trim().length > 0
800
- const propertyIndent = detectObjectChildIndent(source, objectRange)
801
- const closeIndent = getLineIndent(source, bodyEnd)
802
- const indentedProperty = indentMultiline(propertyText, propertyIndent)
803
-
804
- if (!hasContent) {
805
- return `${source.slice(0, insertAt)}\n${indentedProperty}\n${closeIndent}${source.slice(insertAt)}`
806
- }
807
-
808
- const needsTrailingNewline = source[insertAt] !== "\n" && source[insertAt] !== "\r"
809
- const insertion = `\n${indentedProperty}${needsTrailingNewline ? "\n" : ""}`
810
- return `${source.slice(0, insertAt)}${insertion}${source.slice(insertAt)}`
811
- }
812
-
813
- function detectObjectChildIndent(source, objectRange) {
814
- const bodyEnd = objectRange.end - 1
815
- let index = objectRange.start + 1
816
-
817
- while (index < bodyEnd) {
818
- if (source[index] === "\n") {
819
- const next = skipHorizontalWhitespace(source, index + 1)
820
- if (next < bodyEnd && source[next] !== "\n" && source[next] !== "\r") {
821
- return source.slice(index + 1, next)
822
- }
823
- }
824
- index += 1
825
- }
826
-
827
- return `${getLineIndent(source, objectRange.start)} `
828
- }
829
-
830
- function appendArrayElement(source, arrayRange, elementText) {
831
- const bodyStart = arrayRange.start + 1
832
- const bodyEnd = arrayRange.end - 1
833
- const body = source.slice(bodyStart, bodyEnd)
834
-
835
- if (body.trim().length === 0) {
836
- return `${source.slice(0, bodyStart)}${elementText}${source.slice(bodyStart)}`
837
- }
838
-
839
- const contentEnd = trimEndIndex(source, bodyStart, bodyEnd)
840
- const hasNewline = bodyIncludesNewline(source, bodyStart, bodyEnd)
841
- const hasTrailingComma = previousCodeChar(source, contentEnd) === ","
842
-
843
- if (!hasNewline) {
844
- const separator = hasTrailingComma ? " " : ", "
845
- return `${source.slice(0, contentEnd)}${separator}${elementText}${source.slice(contentEnd)}`
846
- }
847
-
848
- const closeIndent = getLineIndent(source, bodyEnd)
849
- const elementIndent = `${closeIndent} `
850
- const separator = hasTrailingComma ? "\n" : ",\n"
851
- return `${source.slice(0, contentEnd)}${separator}${elementIndent}${elementText},${source.slice(contentEnd)}`
852
- }
853
-
854
- function arrayHasCallExpression(source, arrayRange, localName) {
855
- const bodyEnd = arrayRange.end - 1
856
- let index = arrayRange.start + 1
857
-
858
- while (index < bodyEnd) {
859
- index = skipWhitespaceCommentsAndCommas(source, index, bodyEnd)
860
- if (index >= bodyEnd) return false
861
-
862
- const elementEnd = findTopLevelCommaOrEnd(source, index, bodyEnd)
863
- if (startsWithCallExpression(source, index, elementEnd, localName)) {
864
- return true
865
- }
866
- index = elementEnd + 1
867
- }
868
-
869
- return false
870
- }
871
-
872
- function startsWithCallExpression(source, start, end, localName) {
873
- const index = skipWhitespaceAndComments(source, start)
874
- if (!matchIdentifier(source, index, localName)) return false
875
-
876
- const callStart = skipWhitespaceAndComments(source, index + localName.length)
877
- if (source[callStart] !== "(") return false
878
-
879
- const callEnd = createBracketRange(source, callStart, "(").end
880
- const next = skipWhitespaceAndComments(source, callEnd)
881
- return next <= end
882
- }
883
-
884
- function parseImportDeclarations(source) {
885
- const declarations = []
886
- let index = 0
887
-
888
- while (index < source.length) {
889
- const next = skipIgnoredSyntax(source, index)
890
- if (next !== index) {
891
- index = next
892
- continue
893
- }
894
-
895
- if (!matchIdentifier(source, index, "import")) {
896
- index += 1
897
- continue
898
- }
899
-
900
- const clauseStart = skipWhitespaceAndComments(source, index + "import".length)
901
- if (source[clauseStart] === ".") {
902
- index = clauseStart + 1
903
- continue
904
- }
905
- if (source[clauseStart] === "(") {
906
- index = clauseStart + 1
907
- continue
908
- }
909
-
910
- const end = findImportDeclarationEnd(source, index)
911
- const declaration = source.slice(index, end)
912
- const moduleSpecifier = getImportModuleSpecifier(declaration)
913
-
914
- if (moduleSpecifier) {
915
- declarations.push({
916
- start: index,
917
- end,
918
- moduleSpecifier,
919
- defaultLocal: getDefaultImportLocal(declaration),
920
- namedLocals: getNamedImportLocals(declaration),
921
- })
922
- }
923
-
924
- index = end
925
- }
926
-
927
- return declarations
928
- }
929
-
930
- function findImportDeclarationEnd(source, start) {
931
- const stack = []
932
- let sawModuleSpecifier = false
933
- let index = start
934
-
935
- while (index < source.length) {
936
- const skipped = skipComment(source, index)
937
- if (skipped !== index) {
938
- index = skipped
939
- continue
940
- }
941
-
942
- if (source[index] === "\"" || source[index] === "'") {
943
- const literal = readStringLiteral(source, index)
944
- if (!literal) return source.length
945
- sawModuleSpecifier = true
946
- index = literal.end
947
- continue
948
- }
949
-
950
- if (isOpeningBracket(source[index])) {
951
- stack.push(expectedClosingBracket(source[index]))
952
- index += 1
953
- continue
954
- }
955
-
956
- if (isClosingBracket(source[index])) {
957
- if (stack.at(-1) === source[index]) stack.pop()
958
- index += 1
959
- continue
960
- }
961
-
962
- if (stack.length === 0 && source[index] === ";") return index + 1
963
- if (
964
- stack.length === 0 &&
965
- sawModuleSpecifier &&
966
- (source[index] === "\n" || source[index] === "\r")
967
- ) {
968
- return index + 1
969
- }
970
-
971
- index += 1
972
- }
973
-
974
- return source.length
975
- }
976
-
977
- function getImportModuleSpecifier(declaration) {
978
- let moduleSpecifier = null
979
- let index = 0
980
-
981
- while (index < declaration.length) {
982
- const skipped = skipComment(declaration, index)
983
- if (skipped !== index) {
984
- index = skipped
985
- continue
986
- }
987
-
988
- if (declaration[index] === "\"" || declaration[index] === "'") {
989
- const literal = readStringLiteral(declaration, index)
990
- if (!literal) break
991
- moduleSpecifier = literal.value
992
- index = literal.end
993
- continue
994
- }
995
-
996
- index += 1
997
- }
998
-
999
- return moduleSpecifier
1000
- }
1001
-
1002
- function getDefaultImportLocal(declaration) {
1003
- let index = skipWhitespaceAndComments(declaration, "import".length)
1004
-
1005
- if (matchIdentifier(declaration, index, "type")) {
1006
- index = skipWhitespaceAndComments(declaration, index + "type".length)
1007
- }
1008
-
1009
- if (declaration[index] === "\"" || declaration[index] === "{" || declaration[index] === "*") {
1010
- return null
1011
- }
1012
-
1013
- const local = readIdentifierAt(declaration, index)
1014
- return local?.name ?? null
1015
- }
1016
-
1017
- function getNamedImportLocals(declaration) {
1018
- const open = declaration.indexOf("{")
1019
- if (open === -1) return []
1020
-
1021
- const close = findMatchingBracket(declaration, open, "{", "}")
1022
- const namedImportBody = declaration.slice(open + 1, close)
1023
- const locals = []
1024
-
1025
- for (const specifier of splitTopLevelList(namedImportBody)) {
1026
- const trimmed = specifier.trim()
1027
- if (!trimmed) continue
1028
-
1029
- const withoutType = trimmed.startsWith("type ")
1030
- ? trimmed.slice("type ".length).trim()
1031
- : trimmed
1032
- const asIndex = withoutType.toLowerCase().lastIndexOf(" as ")
1033
- const localName = asIndex === -1
1034
- ? withoutType
1035
- : withoutType.slice(asIndex + " as ".length).trim()
1036
- const local = readIdentifierAt(localName, 0)
1037
-
1038
- if (local) locals.push(local.name)
1039
- }
1040
-
1041
- return locals
1042
- }
1043
-
1044
- function splitTopLevelList(source) {
1045
- const items = []
1046
- let start = 0
1047
- let index = 0
1048
-
1049
- while (index < source.length) {
1050
- const skipped = skipIgnoredSyntax(source, index)
1051
- if (skipped !== index) {
1052
- index = skipped
1053
- continue
1054
- }
1055
-
1056
- if (source[index] === ",") {
1057
- items.push(source.slice(start, index))
1058
- start = index + 1
1059
- }
1060
- index += 1
1061
- }
1062
-
1063
- items.push(source.slice(start))
1064
- return items
1065
- }
1066
-
1067
- function prependImportLines(source, lines) {
1068
- if (lines.length === 0) return source
1069
-
1070
- let insertAt = 0
1071
- if (source.startsWith("#!")) {
1072
- const lineEnd = source.indexOf("\n")
1073
- insertAt = lineEnd === -1 ? source.length : lineEnd + 1
1074
- }
1075
-
1076
- const prefix = `${lines.join("\n")}\n`
1077
- return `${source.slice(0, insertAt)}${prefix}${source.slice(insertAt)}`
1078
- }
1079
-
1080
- function findTopLevelCommaOrEnd(source, start, end) {
1081
- const stack = []
1082
- let index = start
1083
-
1084
- while (index < end) {
1085
- const next = skipIgnoredSyntax(source, index)
1086
- if (next !== index) {
1087
- index = next
1088
- continue
1089
- }
1090
-
1091
- if (isOpeningBracket(source[index])) {
1092
- stack.push(expectedClosingBracket(source[index]))
1093
- index += 1
1094
- continue
1095
- }
1096
-
1097
- if (isClosingBracket(source[index])) {
1098
- if (stack.at(-1) === source[index]) stack.pop()
1099
- index += 1
1100
- continue
1101
- }
1102
-
1103
- if (stack.length === 0 && source[index] === ",") return index
1104
- index += 1
1105
- }
1106
-
1107
- return end
1108
- }
1109
-
1110
- function findMatchingBracket(source, start, open, close) {
1111
- if (source[start] !== open) {
1112
- throw new Error("unsupported Vite config: malformed JavaScript object.")
1113
- }
1114
-
1115
- const stack = []
1116
- let index = start
1117
-
1118
- while (index < source.length) {
1119
- const next = skipIgnoredSyntax(source, index)
1120
- if (next !== index) {
1121
- index = next
1122
- continue
1123
- }
1124
-
1125
- if (isOpeningBracket(source[index])) {
1126
- stack.push(expectedClosingBracket(source[index]))
1127
- index += 1
1128
- continue
1129
- }
1130
-
1131
- if (isClosingBracket(source[index])) {
1132
- const expected = stack.pop()
1133
- if (expected !== source[index]) {
1134
- throw new Error("unsupported Vite config: malformed JavaScript brackets.")
1135
- }
1136
- if (stack.length === 0) return index
1137
- index += 1
1138
- continue
1139
- }
1140
-
1141
- index += 1
1142
- }
1143
-
1144
- throw new Error(`unsupported Vite config: missing closing ${close}.`)
1145
- }
1146
-
1147
- function skipIgnoredSyntax(source, index) {
1148
- const commentEnd = skipComment(source, index)
1149
- if (commentEnd !== index) return commentEnd
1150
-
1151
- if (source[index] === "\"" || source[index] === "'" || source[index] === "`") {
1152
- return skipQuotedText(source, index)
1153
- }
1154
-
1155
- return index
1156
- }
1157
-
1158
- function skipComment(source, index) {
1159
- if (source[index] !== "/") return index
1160
-
1161
- if (source[index + 1] === "/") {
1162
- const lineEnd = source.indexOf("\n", index + 2)
1163
- return lineEnd === -1 ? source.length : lineEnd + 1
1164
- }
1165
-
1166
- if (source[index + 1] === "*") {
1167
- const blockEnd = source.indexOf("*/", index + 2)
1168
- return blockEnd === -1 ? source.length : blockEnd + 2
1169
- }
1170
-
1171
- return index
1172
- }
1173
-
1174
- function skipQuotedText(source, index) {
1175
- const quote = source[index]
1176
- index += 1
1177
-
1178
- while (index < source.length) {
1179
- if (source[index] === "\\") {
1180
- index += 2
1181
- continue
1182
- }
1183
-
1184
- if (source[index] === quote) return index + 1
1185
- index += 1
1186
- }
1187
-
1188
- return source.length
1189
- }
1190
-
1191
- function readStringLiteral(source, index) {
1192
- const quote = source[index]
1193
- if (quote !== "\"" && quote !== "'") return null
1194
-
1195
- let value = ""
1196
- index += 1
1197
-
1198
- while (index < source.length) {
1199
- if (source[index] === "\\") {
1200
- value += source[index + 1] ?? ""
1201
- index += 2
1202
- continue
1203
- }
1204
-
1205
- if (source[index] === quote) {
1206
- return { value, end: index + 1 }
1207
- }
1208
-
1209
- value += source[index]
1210
- index += 1
1211
- }
1212
-
1213
- return null
1214
- }
1215
-
1216
- function readIdentifierAt(source, index) {
1217
- if (!isIdentifierStart(source[index])) return null
1218
-
1219
- let end = index + 1
1220
- while (end < source.length && isIdentifierPart(source[end])) {
1221
- end += 1
1222
- }
1223
-
1224
- return { name: source.slice(index, end), end }
1225
- }
1226
-
1227
- function matchIdentifier(source, index, name) {
1228
- if (source.slice(index, index + name.length) !== name) return false
1229
-
1230
- const before = source[index - 1]
1231
- const after = source[index + name.length]
1232
- return !isIdentifierPart(before) && !isIdentifierPart(after)
1233
- }
1234
-
1235
- function isIdentifierStart(char) {
1236
- return Boolean(char && /[A-Za-z_$]/.test(char))
1237
- }
1238
-
1239
- function isIdentifierPart(char) {
1240
- return Boolean(char && /[A-Za-z0-9_$]/.test(char))
1241
- }
1242
-
1243
- function isOpeningBracket(char) {
1244
- return char === "{" || char === "[" || char === "("
1245
- }
1246
-
1247
- function isClosingBracket(char) {
1248
- return char === "}" || char === "]" || char === ")"
1249
- }
1250
-
1251
- function expectedClosingBracket(char) {
1252
- if (char === "{") return "}"
1253
- if (char === "[") return "]"
1254
- return ")"
1255
- }
1256
-
1257
- function skipWhitespaceAndComments(source, index) {
1258
- while (index < source.length) {
1259
- const next = skipComment(source, index)
1260
- if (next !== index) {
1261
- index = next
1262
- continue
1263
- }
1264
-
1265
- if (!isWhitespace(source[index])) return index
1266
- index += 1
1267
- }
1268
-
1269
- return index
1270
- }
1271
-
1272
- function skipWhitespaceCommentsAndCommas(source, index, end) {
1273
- while (index < end) {
1274
- const next = skipWhitespaceAndComments(source, index)
1275
- if (next !== index) {
1276
- index = next
1277
- continue
1278
- }
1279
-
1280
- if (source[index] !== ",") return index
1281
- index += 1
1282
- }
1283
-
1284
- return index
1285
- }
1286
-
1287
- function skipHorizontalWhitespace(source, index) {
1288
- while (source[index] === " " || source[index] === "\t") {
1289
- index += 1
1290
- }
1291
- return index
1292
- }
1293
-
1294
- function isWhitespace(char) {
1295
- return char === " " || char === "\t" || char === "\n" || char === "\r"
1296
- }
1297
-
1298
- function indentMultiline(text, indent) {
1299
- return text
1300
- .split("\n")
1301
- .map((line) => `${indent}${line}`)
1302
- .join("\n")
1303
- }
1304
-
1305
- function getLineIndent(source, index) {
1306
- let lineStart = index
1307
- while (lineStart > 0 && source[lineStart - 1] !== "\n") {
1308
- lineStart -= 1
1309
- }
1310
-
1311
- return source.slice(lineStart, skipHorizontalWhitespace(source, lineStart))
1312
- }
1313
-
1314
- function trimEndIndex(source, start, end) {
1315
- while (end > start && isWhitespace(source[end - 1])) {
1316
- end -= 1
1317
- }
1318
- return end
1319
- }
1320
-
1321
- function previousCodeChar(source, index) {
1322
- index -= 1
1323
- while (index >= 0 && isWhitespace(source[index])) {
1324
- index -= 1
1325
- }
1326
- return source[index]
1327
- }
1328
-
1329
- function bodyIncludesNewline(source, start, end) {
1330
- for (let index = start; index < end; index += 1) {
1331
- if (source[index] === "\n" || source[index] === "\r") return true
1332
- }
1333
- return false
1334
- }
1335
-
1336
- async function getPackageChanges(cwd, dependencies) {
1337
- const devDependencies = { ...SETUP_DEV_DEPENDENCIES }
1338
- if (await findViteConfigPath(cwd)) {
1339
- Object.assign(devDependencies, VITE_DEV_DEPENDENCIES)
1340
- }
1341
-
1342
- return {
1343
- dependencies,
1344
- devDependencies,
1345
- }
1346
- }
1347
-
1348
- async function updatePackageJson(cwd, changes, options) {
1349
- const packagePath = path.join(cwd, "package.json")
1350
- const dependencies = changes.dependencies || {}
1351
- const devDependencies = changes.devDependencies || {}
1352
- const packageJson = (await readJsonIfExists(packagePath)) || {
1353
- name: path.basename(cwd).toLowerCase(),
1354
- version: "0.0.0",
1355
- private: true,
1356
- }
1357
- const added = [
1358
- ...addPackageSection(packageJson, "dependencies", dependencies),
1359
- ...addPackageSection(packageJson, "devDependencies", devDependencies),
1360
- ]
1361
-
1362
- if (added.length === 0 && (await exists(packagePath))) {
1363
- return [`kept ${relative(cwd, packagePath)}`]
1364
- }
1365
-
1366
- const status = await writeFile(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`, {
1367
- dryRun: options.dryRun,
1368
- force: true,
1369
- })
1370
-
1371
- return [
1372
- `${status} ${relative(cwd, packagePath)}`,
1373
- ]
1374
- }
1375
-
1376
- function addPackageSection(packageJson, section, dependencies) {
1377
- const added = []
1378
- const existing = packageJson[section] || {}
1379
-
1380
- for (const [name, version] of Object.entries(dependencies)) {
1381
- if (hasPackageDependency(packageJson, name)) continue
1382
-
1383
- existing[name] = version
1384
- added.push(name)
1385
- }
1386
-
1387
- if (added.length > 0 || packageJson[section]) {
1388
- packageJson[section] = sortObject(existing)
1389
- }
1390
-
1391
- return added
1392
- }
1393
-
1394
- function hasPackageDependency(packageJson, name) {
1395
- return Boolean(
1396
- packageJson.dependencies?.[name] ||
1397
- packageJson.devDependencies?.[name] ||
1398
- packageJson.peerDependencies?.[name] ||
1399
- packageJson.optionalDependencies?.[name]
1400
- )
1401
- }
1402
-
1403
- function applyTemplate(source, config) {
1404
- return source
1405
- .replaceAll("@/lib/utils", config.aliases.utils)
1406
- .replaceAll("@/hooks", config.aliases.hooks)
1407
- }
1408
-
1409
- function isTextRegistryFile(source) {
1410
- return !/\.(avif|eot|gif|ico|jpe?g|otf|png|ttf|webp|woff2?)$/i.test(source)
1411
- }
1412
-
1413
- function resolveTarget(cwd, config, target) {
1414
- const replacements = {
1415
- "{{ui}}": aliasToPath(cwd, config.aliases.ui),
1416
- "{{utils}}": stripExtension(aliasToPath(cwd, config.aliases.utils)),
1417
- "{{hooks}}": aliasToPath(cwd, config.aliases.hooks),
1418
- }
1419
-
1420
- let resolved = target
1421
- for (const [placeholder, value] of Object.entries(replacements)) {
1422
- resolved = resolved.replaceAll(placeholder, value)
1423
- }
1424
-
1425
- return resolved
1426
- }
1427
-
1428
- function aliasToPath(cwd, alias) {
1429
- if (!alias) return ""
1430
- if (path.isAbsolute(alias)) return alias
1431
- if (alias.startsWith("./")) return alias.slice(2)
1432
- if (alias.startsWith("../")) return alias
1433
-
1434
- if (alias.startsWith("@/") || alias.startsWith("~/")) {
1435
- const rest = alias.slice(2)
1436
- return path.join(shouldUseSrcDirectory(cwd) ? "src" : "", rest)
1437
- }
1438
-
1439
- return alias
1440
- }
1441
-
1442
- function shouldUseSrcDirectory(cwd) {
1443
- return pathExistsSyncHint(cwd, "src/app") || pathExistsSyncHint(cwd, "src")
1444
- }
1445
-
1446
- function pathExistsSyncHint(cwd, segment) {
1447
- return Boolean(syncExistsCache.get(path.join(cwd, segment)))
1448
- }
1449
-
1450
- async function warmPathHints(cwd) {
1451
- for (const segment of ["src", "src/app"]) {
1452
- const target = path.join(cwd, segment)
1453
- syncExistsCache.set(target, await exists(target))
1454
- }
1455
- }
1456
-
1457
- async function writeFile(targetPath, content, options) {
1458
- const alreadyExists = await exists(targetPath)
1459
- const isBinary = Buffer.isBuffer(content)
1460
-
1461
- if (options.dryRun) {
1462
- if (!alreadyExists) return "would write"
1463
-
1464
- const current = await fs.readFile(targetPath)
1465
- const isEqual = isBinary
1466
- ? current.equals(content)
1467
- : current.toString("utf8") === content
1468
- if (isEqual) return "would keep"
1469
-
1470
- return "would update"
1471
- }
1472
-
1473
- if (alreadyExists && !options.force) {
1474
- const current = await fs.readFile(targetPath)
1475
- const isEqual = isBinary
1476
- ? current.equals(content)
1477
- : current.toString("utf8") === content
1478
- if (isEqual) return "kept"
1479
- }
1480
-
1481
- await fs.mkdir(path.dirname(targetPath), { recursive: true })
1482
- await fs.writeFile(targetPath, content, isBinary ? undefined : "utf8")
1483
- return alreadyExists ? "updated" : "wrote"
1484
- }
1485
-
1486
- async function readJson(filePath) {
1487
- return JSON.parse(await fs.readFile(filePath, "utf8"))
1488
- }
1489
-
1490
- async function readJsonc(filePath) {
1491
- const content = await fs.readFile(filePath, "utf8")
1492
- return JSON.parse(stripJsonComments(content))
1493
- }
1494
-
1495
- function stripJsonComments(content) {
1496
- let output = ""
1497
- let inString = false
1498
- let escaped = false
1499
-
1500
- for (let index = 0; index < content.length; index += 1) {
1501
- const char = content[index]
1502
- const next = content[index + 1]
1503
-
1504
- if (inString) {
1505
- output += char
1506
-
1507
- if (escaped) {
1508
- escaped = false
1509
- } else if (char === "\\") {
1510
- escaped = true
1511
- } else if (char === "\"") {
1512
- inString = false
1513
- }
1514
-
1515
- continue
1516
- }
1517
-
1518
- if (char === "\"") {
1519
- inString = true
1520
- output += char
1521
- continue
1522
- }
1523
-
1524
- if (char === "/" && next === "/") {
1525
- output += " "
1526
- index += 2
1527
-
1528
- while (index < content.length && content[index] !== "\n" && content[index] !== "\r") {
1529
- output += " "
1530
- index += 1
1531
- }
1532
-
1533
- if (index < content.length) {
1534
- output += content[index]
1535
- if (content[index] === "\r" && content[index + 1] === "\n") {
1536
- index += 1
1537
- output += content[index]
1538
- }
1539
- }
1540
-
1541
- continue
1542
- }
1543
-
1544
- if (char === "/" && next === "*") {
1545
- output += " "
1546
- index += 2
1547
-
1548
- while (index < content.length) {
1549
- if (content[index] === "*" && content[index + 1] === "/") {
1550
- output += " "
1551
- index += 1
1552
- break
1553
- }
1554
-
1555
- output += content[index] === "\n" || content[index] === "\r" ? content[index] : " "
1556
- index += 1
1557
- }
1558
-
1559
- continue
1560
- }
1561
-
1562
- output += char
1563
- }
1564
-
1565
- return output
1566
- }
1567
-
1568
- async function readJsonIfExists(filePath) {
1569
- try {
1570
- return await readJson(filePath)
1571
- } catch (error) {
1572
- if (error.code === "ENOENT") return null
1573
- throw error
1574
- }
1575
- }
1576
-
1577
- async function readTextIfExists(filePath) {
1578
- try {
1579
- return await fs.readFile(filePath, "utf8")
1580
- } catch (error) {
1581
- if (error.code === "ENOENT") return null
1582
- throw error
1583
- }
1584
- }
1585
-
1586
- async function exists(filePath) {
1587
- try {
1588
- await fs.access(filePath)
1589
- return true
1590
- } catch {
1591
- return false
1592
- }
1593
- }
1594
-
1595
- function removeExistingTokenBlock(content, fileLabel = "CSS file") {
1596
- const lines = splitLinesPreservingEndings(content)
1597
- const kept = []
1598
- let found = false
1599
- let inTokenBlock = false
1600
-
1601
- for (const line of lines) {
1602
- const trimmed = line.trimStart()
1603
-
1604
- if (trimmed.startsWith(CSS_START)) {
1605
- if (found || inTokenBlock) {
1606
- throw new Error(`${fileLabel}: multiple Banhaten token blocks found.`)
1607
- }
1608
- found = true
1609
- inTokenBlock = true
1610
- continue
1611
- }
1612
-
1613
- if (trimmed.startsWith(CSS_END)) {
1614
- if (!inTokenBlock) {
1615
- throw new Error(`${fileLabel}: Banhaten token end marker has no start marker.`)
1616
- }
1617
- inTokenBlock = false
1618
- continue
1619
- }
1620
-
1621
- if (!inTokenBlock) {
1622
- kept.push(line)
1623
- }
1624
- }
1625
-
1626
- if (inTokenBlock) {
1627
- throw new Error(`${fileLabel}: Banhaten token block is missing its end marker.`)
1628
- }
1629
-
1630
- return { content: kept.join(""), found }
1631
- }
1632
-
1633
- function ensureGlobalCssImports(content) {
1634
- const cleaned = splitLinesPreservingEndings(content)
1635
- .filter((line) => {
1636
- const importSpecifier = readCssImportSpecifier(line)
1637
- return (
1638
- importSpecifier !== "tailwindcss" &&
1639
- !isManagedInterFontImport(importSpecifier)
1640
- )
1641
- })
1642
- .join("")
1643
- .trimStart()
1644
- return `${TAILWIND_IMPORT}\n${FONT_IMPORTS.join("\n")}\n${cleaned}`
1645
- }
1646
-
1647
- function splitLinesPreservingEndings(content) {
1648
- const lines = []
1649
- let lineStart = 0
1650
- let index = 0
1651
-
1652
- while (index < content.length) {
1653
- if (content[index] === "\r" && content[index + 1] === "\n") {
1654
- lines.push(content.slice(lineStart, index + 2))
1655
- index += 2
1656
- lineStart = index
1657
- continue
1658
- }
1659
-
1660
- if (content[index] === "\n") {
1661
- lines.push(content.slice(lineStart, index + 1))
1662
- index += 1
1663
- lineStart = index
1664
- continue
1665
- }
1666
-
1667
- index += 1
1668
- }
1669
-
1670
- if (lineStart < content.length) {
1671
- lines.push(content.slice(lineStart))
1672
- }
1673
-
1674
- return lines
1675
- }
1676
-
1677
- function readCssImportSpecifier(line) {
1678
- const trimmed = line.trim()
1679
- const importPrefix = "@import"
1680
- if (!trimmed.startsWith(importPrefix)) return null
1681
-
1682
- let index = importPrefix.length
1683
- while (trimmed[index] === " " || trimmed[index] === "\t") {
1684
- index += 1
1685
- }
1686
-
1687
- const quote = trimmed[index]
1688
- if (quote !== "\"" && quote !== "'") return null
1689
-
1690
- const start = index + 1
1691
- index = start
1692
- while (index < trimmed.length && trimmed[index] !== quote) {
1693
- index += 1
1694
- }
1695
-
1696
- if (index >= trimmed.length) return null
1697
- const specifier = trimmed.slice(start, index)
1698
- index += 1
1699
-
1700
- while (trimmed[index] === " " || trimmed[index] === "\t") {
1701
- index += 1
1702
- }
1703
-
1704
- if (trimmed[index] === ";") index += 1
1705
-
1706
- while (trimmed[index] === " " || trimmed[index] === "\t") {
1707
- index += 1
1708
- }
1709
-
1710
- return index === trimmed.length ? specifier : null
1711
- }
1712
-
1713
- function isManagedInterFontImport(specifier) {
1714
- if (!specifier) return false
1715
- if (specifier === "@fontsource/inter") return true
1716
- if (specifier === "@fontsource-variable/inter") return true
1717
-
1718
- for (const weight of ["400", "500", "600", "700"]) {
1719
- if (specifier === `@fontsource/inter/${weight}.css`) return true
1720
- }
1721
-
1722
- return false
1723
994
  }
1724
995
 
1725
996
  function appendAliasSegment(alias, segment) {
@@ -1727,10 +998,6 @@ function appendAliasSegment(alias, segment) {
1727
998
  return `${alias.replace(/\/$/, "")}/${segment}`
1728
999
  }
1729
1000
 
1730
- function stripExtension(filePath) {
1731
- return filePath.replace(/\.(t|j)sx?$/, "")
1732
- }
1733
-
1734
1001
  function sortObject(value) {
1735
1002
  return Object.fromEntries(
1736
1003
  Object.entries(value).sort(([left], [right]) => left.localeCompare(right))
@@ -1740,22 +1007,3 @@ function sortObject(value) {
1740
1007
  function relative(cwd, targetPath) {
1741
1008
  return path.relative(cwd, targetPath).replaceAll("\\", "/")
1742
1009
  }
1743
-
1744
- function printResult(title, writes, dryRun) {
1745
- console.log(`${dryRun ? "Dry run: " : ""}${title}`)
1746
- for (const write of writes) {
1747
- console.log(`- ${write}`)
1748
- }
1749
- }
1750
-
1751
- function printInstallHint(writes, dryRun) {
1752
- if (dryRun) return
1753
-
1754
- const packageJsonChanged = writes.some((write) =>
1755
- /^(wrote|updated) package\.json$/.test(write)
1756
- )
1757
-
1758
- if (packageJsonChanged) {
1759
- console.log("\nNext: run npm install to install the added dependencies.")
1760
- }
1761
- }