frappe-ui 1.0.0-beta.67 → 1.0.0-beta.69

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 (139) hide show
  1. package/experimental/Calendar/Calendar.vue +2 -2
  2. package/experimental/Calendar/stories/CustomHeader.vue +2 -2
  3. package/package.json +10 -1
  4. package/scripts/migrate-base-props-v1.js +379 -0
  5. package/scripts/migrate-list-v1.js +436 -0
  6. package/scripts/migrate-navigation-v1.js +300 -0
  7. package/scripts/migrate-overlays-v1.js +519 -0
  8. package/src/components/Badge/Badge.api.md +1 -1
  9. package/src/components/Badge/types.ts +1 -5
  10. package/src/components/Combobox/Combobox.cy.ts +2 -6
  11. package/src/components/Combobox/Combobox.md +4 -0
  12. package/src/components/Combobox/Combobox.vue +6 -1
  13. package/src/components/Combobox/types.ts +2 -0
  14. package/src/components/ContextMenu/ContextMenu.api.md +36 -0
  15. package/src/components/ContextMenu/ContextMenu.cy.ts +141 -29
  16. package/src/components/ContextMenu/ContextMenu.md +5 -0
  17. package/src/components/ContextMenu/ContextMenu.vue +81 -8
  18. package/src/components/ContextMenu/types.ts +19 -7
  19. package/src/components/DatePicker/DatePicker.api.md +0 -7
  20. package/src/components/DatePicker/DatePicker.cy.ts +11 -15
  21. package/src/components/DatePicker/DatePicker.md +4 -0
  22. package/src/components/DatePicker/DatePicker.vue +17 -6
  23. package/src/components/DatePicker/DateRangePicker.cy.ts +22 -8
  24. package/src/components/DatePicker/DateRangePicker.vue +4 -1
  25. package/src/components/DatePicker/DateTimePicker.cy.ts +4 -4
  26. package/src/components/DatePicker/DateTimePicker.vue +5 -3
  27. package/src/components/DatePicker/stories/Examples.vue +2 -2
  28. package/src/components/DatePicker/stories/Range.vue +3 -3
  29. package/src/components/DatePicker/types.ts +15 -5
  30. package/src/components/DesktopShell/stories/Default.vue +1 -1
  31. package/src/components/Divider/Divider.api.md +1 -1
  32. package/src/components/Divider/Divider.cy.ts +23 -0
  33. package/src/components/Divider/Divider.md +3 -0
  34. package/src/components/Divider/Divider.playground.vue +4 -4
  35. package/src/components/Divider/Divider.vue +11 -7
  36. package/src/components/Divider/stories/Action.vue +1 -1
  37. package/src/components/Divider/types.ts +4 -11
  38. package/src/components/Dropdown/Dropdown.api.md +30 -0
  39. package/src/components/Dropdown/Dropdown.cy.ts +23 -3
  40. package/src/components/Dropdown/Dropdown.md +3 -0
  41. package/src/components/Dropdown/Dropdown.vue +11 -4
  42. package/src/components/Dropdown/types.ts +20 -9
  43. package/src/components/HoverCard/HoverCard.api.md +5 -5
  44. package/src/components/HoverCard/HoverCard.cy.ts +88 -1
  45. package/src/components/HoverCard/HoverCard.md +2 -2
  46. package/src/components/HoverCard/HoverCard.vue +65 -18
  47. package/src/components/HoverCard/index.ts +1 -0
  48. package/src/components/HoverCard/stories/Delays.vue +1 -1
  49. package/src/components/HoverCard/types.ts +11 -4
  50. package/src/components/Icon/Icon.api.md +7 -1
  51. package/src/components/Icon/Icon.cy.ts +43 -11
  52. package/src/components/Icon/Icon.md +11 -0
  53. package/src/components/Icon/Icon.vue +15 -8
  54. package/src/components/Icon/stories/ComponentIcon.vue +1 -1
  55. package/src/components/Icon/stories/Lucide.vue +4 -4
  56. package/src/components/Icon/types.ts +6 -0
  57. package/src/components/Menu/Menu.api.md +1 -1
  58. package/src/components/Menu/Menu.vue +3 -3
  59. package/src/components/Menu/MenuItemContent.vue +2 -2
  60. package/src/components/Menu/types.test.ts +49 -0
  61. package/src/components/Menu/types.ts +9 -10
  62. package/src/components/MobileNav/MobileNavItem.vue +5 -20
  63. package/src/components/MultiSelect/MultiSelect.cy.ts +4 -1
  64. package/src/components/MultiSelect/MultiSelect.md +2 -2
  65. package/src/components/MultiSelect/MultiSelect.vue +6 -1
  66. package/src/components/MultiSelect/types.ts +2 -0
  67. package/src/components/Popover/Popover.api.md +5 -5
  68. package/src/components/Popover/Popover.cy.ts +9 -6
  69. package/src/components/Popover/Popover.md +3 -3
  70. package/src/components/Popover/Popover.vue +4 -9
  71. package/src/components/Popover/types.ts +2 -2
  72. package/src/components/Progress/Progress.api.md +2 -10
  73. package/src/components/Progress/Progress.cy.ts +16 -6
  74. package/src/components/Progress/Progress.md +2 -2
  75. package/src/components/Progress/Progress.playground.vue +4 -9
  76. package/src/components/Progress/Progress.vue +3 -5
  77. package/src/components/Progress/stories/MultiStepForm.vue +1 -6
  78. package/src/components/Progress/types.ts +2 -5
  79. package/src/components/Select/Select.cy.ts +2 -6
  80. package/src/components/Select/Select.md +2 -2
  81. package/src/components/Select/Select.vue +6 -0
  82. package/src/components/Select/types.ts +2 -0
  83. package/src/components/SettingsDialog/SettingsDialog.api.md +4 -4
  84. package/src/components/SettingsDialog/SettingsDialog.cy.ts +84 -1
  85. package/src/components/SettingsDialog/SettingsDialog.vue +15 -15
  86. package/src/components/SettingsDialog/index.ts +1 -0
  87. package/src/components/SettingsDialog/types.ts +3 -2
  88. package/src/components/Sidebar/Sidebar.api.md +5 -4
  89. package/src/components/Sidebar/Sidebar.cy.ts +22 -8
  90. package/src/components/Sidebar/Sidebar.md +2 -2
  91. package/src/components/Sidebar/Sidebar.vue +2 -2
  92. package/src/components/Sidebar/SidebarItem.vue +2 -2
  93. package/src/components/Sidebar/stories/Default.vue +1 -1
  94. package/src/components/Sidebar/stories/Section.vue +1 -1
  95. package/src/components/Sidebar/types.ts +4 -7
  96. package/src/components/SidebarRail/SidebarRail.api.md +3 -3
  97. package/src/components/SidebarRail/SidebarRail.cy.ts +88 -14
  98. package/src/components/SidebarRail/SidebarRail.md +4 -2
  99. package/src/components/SidebarRail/SidebarRailItem.vue +46 -41
  100. package/src/components/SidebarRail/types.ts +2 -2
  101. package/src/components/TabButtons/TabButtons.api.md +2 -2
  102. package/src/components/TabButtons/TabButtons.cy.ts +6 -6
  103. package/src/components/TabButtons/TabButtons.md +1 -1
  104. package/src/components/TabButtons/TabButtons.vue +4 -4
  105. package/src/components/TabButtons/types.ts +2 -2
  106. package/src/components/Tabs/TabTrigger.vue +2 -4
  107. package/src/components/Tabs/Tabs.cy.ts +3 -3
  108. package/src/components/Tabs/Tabs.md +1 -1
  109. package/src/components/Tabs/types.ts +1 -1
  110. package/src/components/TimePicker/TimePicker.api.md +1 -1
  111. package/src/components/TimePicker/TimePicker.cy.ts +48 -62
  112. package/src/components/TimePicker/TimePicker.md +4 -1
  113. package/src/components/TimePicker/TimePicker.vue +27 -15
  114. package/src/components/Tooltip/Tooltip.api.md +6 -6
  115. package/src/components/Tooltip/Tooltip.cy.ts +5 -5
  116. package/src/components/Tooltip/Tooltip.playground.vue +2 -2
  117. package/src/components/Tooltip/Tooltip.vue +6 -4
  118. package/src/components/Tooltip/TooltipProvider.vue +5 -5
  119. package/src/components/Tooltip/stories/Group.vue +1 -1
  120. package/src/components/Tooltip/types.ts +1 -1
  121. package/src/components/shared/action.ts +5 -5
  122. package/src/components/shared/picker/PickerShell.vue +11 -14
  123. package/src/components/shared/picker/types.ts +7 -3
  124. package/src/components/shared/tabs/Pill.vue +2 -2
  125. package/src/composables/useKeyboardShortcut.spec.ts +40 -1
  126. package/src/composables/useKeyboardShortcut.ts +19 -10
  127. package/src/molecules/list/List.cy.ts +146 -11
  128. package/src/molecules/list/ListGroup.vue +5 -5
  129. package/src/molecules/list/ListHeaderCellSort.vue +11 -9
  130. package/src/molecules/list/ListRowBase.vue +2 -2
  131. package/src/molecules/list/ListRows.vue +41 -18
  132. package/src/molecules/list/index.ts +0 -4
  133. package/src/molecules/list/list.api.md +14 -8
  134. package/src/molecules/list/list.md +17 -10
  135. package/src/molecules/list/stories/Columns.vue +3 -3
  136. package/src/molecules/list/stories/Feed.vue +5 -2
  137. package/src/molecules/list/style.css +2 -2
  138. package/src/molecules/list/types.ts +0 -8
  139. package/src/molecules/list/useVirtualRows.ts +46 -27
@@ -27,13 +27,13 @@
27
27
  @update:modelValue="(val) => onMonthYearChange(val)"
28
28
  :clearable="false"
29
29
  >
30
- <template #trigger="{ toggle }">
30
+ <template #trigger="{ open, setOpen }">
31
31
  <Button
32
32
  variant="ghost"
33
33
  class="text-lg-medium text-ink-gray-7"
34
34
  :label="currentMonthYear"
35
35
  iconRight="lucide-chevron-down"
36
- @click="toggle"
36
+ @click="setOpen(!open)"
37
37
  />
38
38
  </template>
39
39
  </DatePicker>
@@ -110,13 +110,13 @@ const events = ref([
110
110
  @update:modelValue="(val) => headerProps.onMonthYearChange(val)"
111
111
  :clearable="false"
112
112
  >
113
- <template #trigger="{ toggle }">
113
+ <template #trigger="{ setOpen, open }">
114
114
  <Button
115
115
  variant="ghost"
116
116
  class="text-lg-medium text-ink-gray-7"
117
117
  :label="headerProps.currentMonthYear"
118
118
  iconRight="lucide-chevron-down"
119
- @click="toggle"
119
+ @click="setOpen(!open)"
120
120
  />
121
121
  </template>
122
122
  </DatePicker>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "frappe-ui",
3
- "version": "1.0.0-beta.67",
3
+ "version": "1.0.0-beta.69",
4
4
  "description": "A set of components and utilities for rapid UI development",
5
5
  "engines": {
6
6
  "node": ">=20.19.0"
@@ -19,6 +19,10 @@
19
19
  "#utils/*": "./src/utils/*"
20
20
  },
21
21
  "bin": {
22
+ "base-props-v1": "./scripts/migrate-base-props-v1.js",
23
+ "list-v1": "./scripts/migrate-list-v1.js",
24
+ "navigation-v1": "./scripts/migrate-navigation-v1.js",
25
+ "overlays-v1": "./scripts/migrate-overlays-v1.js",
22
26
  "tokens-v2": "./tailwind/migrate-tokens-v2.js",
23
27
  "shortcuts-v1": "./scripts/migrate-shortcuts-v1.js",
24
28
  "editor-v1": "./scripts/migrate-editor-v1.js",
@@ -104,7 +108,11 @@
104
108
  "tailwind",
105
109
  "vitepress",
106
110
  "scripts/migrate-shortcuts-v1.js",
111
+ "scripts/migrate-base-props-v1.js",
107
112
  "scripts/migrate-editor-v1.js",
113
+ "scripts/migrate-list-v1.js",
114
+ "scripts/migrate-navigation-v1.js",
115
+ "scripts/migrate-overlays-v1.js",
108
116
  "experimental",
109
117
  "experimental.ts",
110
118
  "tsconfig.base.json"
@@ -116,6 +124,7 @@
116
124
  "author": "Frappe Technologies Pvt. Ltd.",
117
125
  "license": "MIT",
118
126
  "dependencies": {
127
+ "@vue/compiler-sfc": "^3.5.13",
119
128
  "@codemirror/lang-css": "^6.3.0",
120
129
  "@codemirror/lang-html": "^6.4.9",
121
130
  "@codemirror/lang-javascript": "^6.2.2",
@@ -0,0 +1,379 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from 'node:fs'
4
+ import path from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+ import { parse as parseSfc } from '@vue/compiler-sfc'
7
+
8
+ const NodeTypes = {
9
+ ELEMENT: 1,
10
+ SIMPLE_EXPRESSION: 4,
11
+ ATTRIBUTE: 6,
12
+ DIRECTIVE: 7,
13
+ }
14
+
15
+ const USAGE = `Usage: base-props-v1 [--dry-run] <dir-or-file...>
16
+
17
+ Renames the v1 base-component props on statically named Vue component tags:
18
+ Icon name -> icon (optional canonical spelling)
19
+ Divider position -> align
20
+ Progress intervals + intervalCount -> numeric intervals`
21
+
22
+ const COMPONENTS = new Set(['Icon', 'Divider', 'Progress'])
23
+
24
+ function walk(target, visited = new Set()) {
25
+ if (fs.lstatSync(target).isSymbolicLink()) return []
26
+ const resolved = fs.realpathSync(target)
27
+ if (visited.has(resolved)) return []
28
+ visited.add(resolved)
29
+ const stat = fs.statSync(resolved)
30
+ if (stat.isFile()) return path.extname(target) === '.vue' ? [target] : []
31
+ return fs.readdirSync(resolved, { withFileTypes: true }).flatMap((entry) => {
32
+ if (entry.name === 'node_modules' || entry.name === '.git') return []
33
+ return walk(path.join(resolved, entry.name), visited)
34
+ })
35
+ }
36
+
37
+ function kebabCase(name) {
38
+ return name.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase()
39
+ }
40
+
41
+ function setAlias(aliases, binding, component) {
42
+ aliases.set(binding, component)
43
+ aliases.set(kebabCase(binding), component)
44
+ }
45
+
46
+ function importedComponentAliases(source) {
47
+ const aliases = new Map()
48
+ for (const name of COMPONENTS) setAlias(aliases, name, name)
49
+ const importPattern = /import\s*\{([\s\S]*?)\}\s*from\s*['"]([^'"]+)['"]/g
50
+ for (const match of source.matchAll(importPattern)) {
51
+ for (const part of match[1].split(',')) {
52
+ const binding = part
53
+ .trim()
54
+ .match(/^(Icon|Divider|Progress)(?:\s+as\s+([A-Za-z_$][\w$]*))?$/)
55
+ if (!binding) continue
56
+ const name = binding[2] || binding[1]
57
+ if (match[2] === 'frappe-ui') setAlias(aliases, name, binding[1])
58
+ else {
59
+ aliases.delete(name)
60
+ aliases.delete(kebabCase(name))
61
+ }
62
+ }
63
+ }
64
+ for (const match of source.matchAll(
65
+ /import\s+([A-Za-z_$][\w$]*)\s+from\s*['"]([^'"]+)['"]/g,
66
+ )) {
67
+ const [, binding, specifier] = match
68
+ const component = [...COMPONENTS].find((name) =>
69
+ specifier.endsWith(`/${name}/${name}.vue`),
70
+ )
71
+ if (component) setAlias(aliases, binding, component)
72
+ else {
73
+ aliases.delete(binding)
74
+ aliases.delete(kebabCase(binding))
75
+ }
76
+ }
77
+ return aliases
78
+ }
79
+
80
+ function propName(prop) {
81
+ if (prop.type === NodeTypes.ATTRIBUTE) return prop.name
82
+ if (
83
+ prop.type === NodeTypes.DIRECTIVE &&
84
+ prop.name === 'bind' &&
85
+ prop.arg?.type === NodeTypes.SIMPLE_EXPRESSION &&
86
+ prop.arg.isStatic
87
+ ) {
88
+ return prop.arg.content
89
+ }
90
+ }
91
+
92
+ function renamePropEdit(prop, from, to, offset) {
93
+ if (
94
+ prop.type === NodeTypes.DIRECTIVE &&
95
+ prop.name === 'bind' &&
96
+ prop.exp === undefined
97
+ ) {
98
+ const renamed = prop.loc.source.replace(from, to)
99
+ return {
100
+ start: offset + prop.loc.start.offset,
101
+ end: offset + prop.loc.end.offset,
102
+ text: `${renamed}="${from}"`,
103
+ description: `${from} -> ${to}`,
104
+ }
105
+ }
106
+ const relative = prop.loc.source.indexOf(from)
107
+ return {
108
+ start: offset + prop.loc.start.offset + relative,
109
+ end: offset + prop.loc.start.offset + relative + from.length,
110
+ text: to,
111
+ description: `${from} -> ${to}`,
112
+ }
113
+ }
114
+
115
+ function boundExpression(prop) {
116
+ if (prop?.type !== NodeTypes.DIRECTIVE || prop.name !== 'bind')
117
+ return undefined
118
+ const match = prop.loc.source.match(/^(?::|v-bind:)[^=]+=(['"])([\s\S]*)\1$/)
119
+ return match?.[2]
120
+ }
121
+
122
+ function countExpression(prop) {
123
+ if (prop.type === NodeTypes.ATTRIBUTE) return prop.value?.content
124
+ return boundExpression(prop)
125
+ }
126
+
127
+ function looksNumericExpression(expression) {
128
+ return (
129
+ /^\d+(?:\.\d+)?$/.test(expression) ||
130
+ /(?:^|\.)(?:length|size)$/.test(expression) ||
131
+ /^(?:Number|parseInt|parseFloat)\s*\(/.test(expression) ||
132
+ /[+*/%]|\?[^:]+:\s*(?:undefined|\d)/.test(expression)
133
+ )
134
+ }
135
+
136
+ function progressEdit(element, offset, refusals) {
137
+ const intervals = element.props.find((prop) => propName(prop) === 'intervals')
138
+ const count = element.props.find((prop) =>
139
+ ['intervalCount', 'interval-count'].includes(propName(prop)),
140
+ )
141
+ if (!count && !intervals) return []
142
+
143
+ if (count && !intervals) {
144
+ refusals.push({
145
+ line: count.loc.start.line,
146
+ message:
147
+ 'Progress has intervalCount without intervals; choose continuous or interval mode',
148
+ })
149
+ return []
150
+ }
151
+
152
+ const condition = boundExpression(intervals)
153
+ const staticValue =
154
+ intervals.type === NodeTypes.ATTRIBUTE
155
+ ? intervals.value?.content
156
+ : undefined
157
+ const bareBoolean =
158
+ intervals.type === NodeTypes.ATTRIBUTE && intervals.value === undefined
159
+ const numericStatic =
160
+ staticValue !== undefined && /^\d+(?:\.\d+)?$/.test(staticValue)
161
+ const shorthandBinding =
162
+ intervals.type === NodeTypes.DIRECTIVE && condition === undefined
163
+ // A dynamic `:intervals` without `intervalCount` may already be the v1
164
+ // numeric API. Leaving it alone makes repeat runs idempotent.
165
+ if (!count && shorthandBinding) {
166
+ refusals.push({
167
+ line: intervals.loc.start.line,
168
+ message:
169
+ 'Progress shorthand :intervals may still be the v0 boolean mode; replace it with a segment count',
170
+ })
171
+ return []
172
+ }
173
+ if (
174
+ !count &&
175
+ !bareBoolean &&
176
+ staticValue !== 'true' &&
177
+ !numericStatic &&
178
+ condition !== 'true' &&
179
+ condition !== 'false'
180
+ ) {
181
+ if (!looksNumericExpression(condition)) {
182
+ refusals.push({
183
+ line: intervals.loc.start.line,
184
+ message: `Progress ${intervals.loc.source} may still be the v0 boolean mode; replace it with a segment count`,
185
+ })
186
+ }
187
+ return []
188
+ }
189
+
190
+ if (condition === 'false') {
191
+ const removeProp = (prop, description) => {
192
+ let start = prop.loc.start.offset
193
+ while (
194
+ start > element.loc.start.offset &&
195
+ /\s/.test(
196
+ element.loc.source[start - element.loc.start.offset - 1] || '',
197
+ )
198
+ )
199
+ start--
200
+ return {
201
+ start: offset + start,
202
+ end: offset + prop.loc.end.offset,
203
+ text: '',
204
+ description,
205
+ }
206
+ }
207
+ return [
208
+ removeProp(intervals, 'remove disabled Progress interval mode'),
209
+ ...(count
210
+ ? [removeProp(count, 'remove unused Progress intervalCount')]
211
+ : []),
212
+ ]
213
+ }
214
+
215
+ let replacement
216
+ if (!count && numericStatic) {
217
+ replacement = `:intervals="${staticValue}"`
218
+ } else {
219
+ const countValue = count ? countExpression(count) : '6'
220
+ if (!countValue) {
221
+ refusals.push({
222
+ line: count.loc.start.line,
223
+ message: 'Progress intervalCount has no static value to migrate',
224
+ })
225
+ return []
226
+ }
227
+ if (condition && condition !== 'true') {
228
+ replacement = `:intervals="(${condition}) ? (${countValue}) : undefined"`
229
+ } else {
230
+ replacement = `:intervals="${countValue}"`
231
+ }
232
+ }
233
+
234
+ const edits = [
235
+ {
236
+ start: offset + intervals.loc.start.offset,
237
+ end: offset + intervals.loc.end.offset,
238
+ text: replacement,
239
+ description: 'Progress intervals + intervalCount -> numeric intervals',
240
+ },
241
+ ]
242
+ if (count) {
243
+ let countStart = count.loc.start.offset
244
+ let countEnd = count.loc.end.offset
245
+ if (countStart > intervals.loc.end.offset) {
246
+ while (
247
+ countStart > intervals.loc.end.offset &&
248
+ /\s/.test(
249
+ element.loc.source[countStart - element.loc.start.offset - 1] || '',
250
+ )
251
+ )
252
+ countStart--
253
+ } else {
254
+ while (
255
+ countEnd < intervals.loc.start.offset &&
256
+ /\s/.test(element.loc.source[countEnd - element.loc.start.offset] || '')
257
+ )
258
+ countEnd++
259
+ }
260
+ edits.push({
261
+ start: offset + countStart,
262
+ end: offset + countEnd,
263
+ text: '',
264
+ description: 'remove Progress intervalCount',
265
+ })
266
+ }
267
+ return edits
268
+ }
269
+
270
+ export function migrateBaseProps(source) {
271
+ const parsed = parseSfc(source, { filename: 'component.vue' })
272
+ if (parsed.errors.length) {
273
+ return {
274
+ migrated: source,
275
+ changes: [],
276
+ refusals: [
277
+ { line: 1, message: `Vue parse error: ${String(parsed.errors[0])}` },
278
+ ],
279
+ }
280
+ }
281
+ const block = parsed.descriptor.template
282
+ if (!block) return { migrated: source, changes: [], refusals: [] }
283
+
284
+ const aliases = importedComponentAliases(source)
285
+ const ast = block.ast
286
+ const edits = []
287
+ const refusals = []
288
+
289
+ function visit(node) {
290
+ if (node.type === NodeTypes.ELEMENT) {
291
+ const component = aliases.get(node.tag)
292
+ if (component === 'Icon') {
293
+ const name = node.props.find((prop) => propName(prop) === 'name')
294
+ const icon = node.props.find((prop) => propName(prop) === 'icon')
295
+ // Both props are a supported contract. Leave the lower-precedence name
296
+ // intact instead of creating a duplicate icon binding.
297
+ if (name && !icon) {
298
+ edits.push(renamePropEdit(name, 'name', 'icon', 0))
299
+ }
300
+ } else if (component === 'Divider') {
301
+ for (const prop of node.props) {
302
+ if (propName(prop) === 'position')
303
+ edits.push(renamePropEdit(prop, 'position', 'align', 0))
304
+ }
305
+ } else if (component === 'Progress') {
306
+ edits.push(...progressEdit(node, 0, refusals))
307
+ }
308
+ }
309
+ for (const child of node.children || []) visit(child)
310
+ }
311
+ visit(ast)
312
+
313
+ if (refusals.length) return { migrated: source, changes: [], refusals }
314
+ const migrated = [...edits]
315
+ .sort((a, b) => b.start - a.start)
316
+ .reduce(
317
+ (text, edit) =>
318
+ text.slice(0, edit.start) + edit.text + text.slice(edit.end),
319
+ source,
320
+ )
321
+ return { migrated, changes: edits, refusals }
322
+ }
323
+
324
+ function main() {
325
+ const args = process.argv.slice(2)
326
+ if (args.includes('--help') || args.includes('-h')) {
327
+ console.log(USAGE)
328
+ return
329
+ }
330
+ const unknown = args.filter(
331
+ (arg) => arg.startsWith('-') && arg !== '--dry-run',
332
+ )
333
+ const targets = args.filter((arg) => !arg.startsWith('-'))
334
+ if (unknown.length || !targets.length) {
335
+ if (unknown.length) console.error(`Unknown option: ${unknown.join(' ')}`)
336
+ console.error(USAGE)
337
+ process.exit(1)
338
+ }
339
+
340
+ for (const target of targets) {
341
+ if (!fs.existsSync(target)) {
342
+ console.error(`Invalid path: ${target}`)
343
+ process.exit(1)
344
+ }
345
+ }
346
+
347
+ const dryRun = args.includes('--dry-run')
348
+ const files = [...new Set(targets.flatMap((target) => walk(target)))]
349
+ let changed = 0
350
+ const refusals = []
351
+ for (const file of files) {
352
+ const source = fs.readFileSync(file, 'utf8')
353
+ const result = migrateBaseProps(source)
354
+ if (result.refusals.length) {
355
+ for (const refusal of result.refusals) refusals.push({ file, ...refusal })
356
+ continue
357
+ }
358
+ if (!result.changes.length) continue
359
+ changed++
360
+ if (!dryRun) fs.writeFileSync(file, result.migrated)
361
+ console.log(`${file} (${result.changes.length})`)
362
+ }
363
+ console.log(
364
+ `\n${dryRun ? '[dry-run] would update' : 'Updated'} ${changed} files`,
365
+ )
366
+ if (refusals.length) {
367
+ console.error(
368
+ `\nNot converted — ${refusals.length} sites need a decision; affected files were left unchanged:`,
369
+ )
370
+ for (const refusal of refusals)
371
+ console.error(` ${refusal.file}:L${refusal.line} ${refusal.message}`)
372
+ process.exit(1)
373
+ }
374
+ }
375
+
376
+ const scriptPath = fileURLToPath(import.meta.url)
377
+ const invokedPath = process.argv[1]
378
+ if (invokedPath && fs.realpathSync(invokedPath) === fs.realpathSync(scriptPath))
379
+ main()