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