@dialpad/dialtone-css 8.80.0-next.6 → 8.80.0-next.7

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 (134) hide show
  1. package/lib/build/js/dialtone_migrate_border_radius/index.mjs +273 -0
  2. package/lib/build/js/dialtone_migrate_border_radius/test.mjs +422 -0
  3. package/lib/build/js/dialtone_migrate_typography/index.mjs +1628 -0
  4. package/lib/build/js/dialtone_migrate_typography/test.mjs +1020 -0
  5. package/lib/build/js/dialtone_migration_helper/configs/theme-to-mode.mjs +108 -0
  6. package/lib/build/js/dialtone_migration_helper/tests/theme-to-mode-test-examples.vue +24 -0
  7. package/lib/build/js/dialtone_migration_helper/tests/theme-to-mode.test.mjs +177 -0
  8. package/lib/build/less/components/button.less +2 -0
  9. package/lib/build/less/components/emoji-picker.less +10 -11
  10. package/lib/build/less/components/forms.less +22 -16
  11. package/lib/build/less/components/modal.less +8 -2
  12. package/lib/build/less/components/notice.less +4 -0
  13. package/lib/build/less/components/popover.less +1 -1
  14. package/lib/build/less/components/presence.less +23 -3
  15. package/lib/build/less/recipes/leftbar_row.less +1 -0
  16. package/lib/dist/dialtone-default-theme.css +67 -34
  17. package/lib/dist/dialtone-default-theme.min.css +1 -1
  18. package/lib/dist/dialtone-docs.json +1 -1
  19. package/lib/dist/dialtone.css +66 -34
  20. package/lib/dist/dialtone.min.css +1 -1
  21. package/lib/dist/js/dialtone_migrate_border_radius/index.mjs +273 -0
  22. package/lib/dist/js/dialtone_migrate_border_radius/test.mjs +422 -0
  23. package/lib/dist/js/dialtone_migrate_typography/index.mjs +1628 -0
  24. package/lib/dist/js/dialtone_migrate_typography/test.mjs +1020 -0
  25. package/lib/dist/js/dialtone_migration_helper/configs/theme-to-mode.mjs +108 -0
  26. package/lib/dist/js/dialtone_migration_helper/tests/theme-to-mode-test-examples.vue +24 -0
  27. package/lib/dist/js/dialtone_migration_helper/tests/theme-to-mode.test.mjs +177 -0
  28. package/lib/dist/tokens/tokens-101-dark.css +1 -0
  29. package/lib/dist/tokens/tokens-101-light.css +1 -0
  30. package/lib/dist/tokens/tokens-102-dark.css +1 -0
  31. package/lib/dist/tokens/tokens-102-light.css +1 -0
  32. package/lib/dist/tokens/tokens-103-dark.css +1 -0
  33. package/lib/dist/tokens/tokens-103-light.css +1 -0
  34. package/lib/dist/tokens/tokens-104-dark.css +1 -0
  35. package/lib/dist/tokens/tokens-104-light.css +1 -0
  36. package/lib/dist/tokens/tokens-105-dark.css +1 -0
  37. package/lib/dist/tokens/tokens-105-light.css +1 -0
  38. package/lib/dist/tokens/tokens-106-dark.css +1 -0
  39. package/lib/dist/tokens/tokens-106-light.css +1 -0
  40. package/lib/dist/tokens/tokens-107-dark.css +1 -0
  41. package/lib/dist/tokens/tokens-107-light.css +1 -0
  42. package/lib/dist/tokens/tokens-108-dark.css +1 -0
  43. package/lib/dist/tokens/tokens-108-light.css +1 -0
  44. package/lib/dist/tokens/tokens-109-dark.css +1 -0
  45. package/lib/dist/tokens/tokens-109-light.css +1 -0
  46. package/lib/dist/tokens/tokens-110-dark.css +1 -0
  47. package/lib/dist/tokens/tokens-110-light.css +1 -0
  48. package/lib/dist/tokens/tokens-111-dark.css +1 -0
  49. package/lib/dist/tokens/tokens-111-light.css +1 -0
  50. package/lib/dist/tokens/tokens-112-dark.css +1 -0
  51. package/lib/dist/tokens/tokens-112-light.css +1 -0
  52. package/lib/dist/tokens/tokens-113-dark.css +1 -0
  53. package/lib/dist/tokens/tokens-113-light.css +1 -0
  54. package/lib/dist/tokens/tokens-114-dark.css +1 -0
  55. package/lib/dist/tokens/tokens-114-light.css +1 -0
  56. package/lib/dist/tokens/tokens-115-dark.css +1 -0
  57. package/lib/dist/tokens/tokens-115-light.css +1 -0
  58. package/lib/dist/tokens/tokens-116-dark.css +1 -0
  59. package/lib/dist/tokens/tokens-116-light.css +1 -0
  60. package/lib/dist/tokens/tokens-117-dark.css +1 -0
  61. package/lib/dist/tokens/tokens-117-light.css +1 -0
  62. package/lib/dist/tokens/tokens-118-dark.css +1 -0
  63. package/lib/dist/tokens/tokens-118-light.css +1 -0
  64. package/lib/dist/tokens/tokens-119-dark.css +1 -0
  65. package/lib/dist/tokens/tokens-119-light.css +1 -0
  66. package/lib/dist/tokens/tokens-120-dark.css +1 -0
  67. package/lib/dist/tokens/tokens-120-light.css +1 -0
  68. package/lib/dist/tokens/tokens-121-dark.css +1 -0
  69. package/lib/dist/tokens/tokens-121-light.css +1 -0
  70. package/lib/dist/tokens/tokens-122-dark.css +1 -0
  71. package/lib/dist/tokens/tokens-122-light.css +1 -0
  72. package/lib/dist/tokens/tokens-123-dark.css +1 -0
  73. package/lib/dist/tokens/tokens-123-light.css +1 -0
  74. package/lib/dist/tokens/tokens-124-dark.css +1 -0
  75. package/lib/dist/tokens/tokens-124-light.css +1 -0
  76. package/lib/dist/tokens/tokens-125-dark.css +1 -0
  77. package/lib/dist/tokens/tokens-125-light.css +1 -0
  78. package/lib/dist/tokens/tokens-126-dark.css +1 -0
  79. package/lib/dist/tokens/tokens-126-light.css +1 -0
  80. package/lib/dist/tokens/tokens-127-dark.css +1 -0
  81. package/lib/dist/tokens/tokens-127-light.css +1 -0
  82. package/lib/dist/tokens/tokens-128-dark.css +1 -0
  83. package/lib/dist/tokens/tokens-128-light.css +1 -0
  84. package/lib/dist/tokens/tokens-129-dark.css +1 -0
  85. package/lib/dist/tokens/tokens-129-light.css +1 -0
  86. package/lib/dist/tokens/tokens-130-dark.css +1 -0
  87. package/lib/dist/tokens/tokens-130-light.css +1 -0
  88. package/lib/dist/tokens/tokens-131-dark.css +1 -0
  89. package/lib/dist/tokens/tokens-131-light.css +1 -0
  90. package/lib/dist/tokens/tokens-132-dark.css +1 -0
  91. package/lib/dist/tokens/tokens-132-light.css +1 -0
  92. package/lib/dist/tokens/tokens-133-dark.css +1 -0
  93. package/lib/dist/tokens/tokens-133-light.css +1 -0
  94. package/lib/dist/tokens/tokens-134-dark.css +1 -0
  95. package/lib/dist/tokens/tokens-134-light.css +1 -0
  96. package/lib/dist/tokens/tokens-135-dark.css +1 -0
  97. package/lib/dist/tokens/tokens-135-light.css +1 -0
  98. package/lib/dist/tokens/tokens-136-dark.css +1 -0
  99. package/lib/dist/tokens/tokens-136-light.css +1 -0
  100. package/lib/dist/tokens/tokens-137-dark.css +1 -0
  101. package/lib/dist/tokens/tokens-137-light.css +1 -0
  102. package/lib/dist/tokens/tokens-aegean-dark.css +1 -0
  103. package/lib/dist/tokens/tokens-aegean-light.css +1 -0
  104. package/lib/dist/tokens/tokens-botany-dark.css +1 -0
  105. package/lib/dist/tokens/tokens-botany-light.css +1 -0
  106. package/lib/dist/tokens/tokens-buttercream-dark.css +1 -0
  107. package/lib/dist/tokens/tokens-buttercream-light.css +1 -0
  108. package/lib/dist/tokens/tokens-ceruleo-dark.css +1 -0
  109. package/lib/dist/tokens/tokens-ceruleo-light.css +1 -0
  110. package/lib/dist/tokens/tokens-debug-dp.css +1 -0
  111. package/lib/dist/tokens/tokens-dp-dark.css +1 -0
  112. package/lib/dist/tokens/tokens-dp-light.css +1 -0
  113. package/lib/dist/tokens/tokens-expressive-dark.css +1 -0
  114. package/lib/dist/tokens/tokens-expressive-light.css +1 -0
  115. package/lib/dist/tokens/tokens-expressive-sm-dark.css +1 -0
  116. package/lib/dist/tokens/tokens-expressive-sm-light.css +1 -0
  117. package/lib/dist/tokens/tokens-high-desert-dark.css +1 -0
  118. package/lib/dist/tokens/tokens-high-desert-light.css +1 -0
  119. package/lib/dist/tokens/tokens-melon-dark.css +1 -0
  120. package/lib/dist/tokens/tokens-melon-light.css +1 -0
  121. package/lib/dist/tokens/tokens-plum-dark.css +1 -0
  122. package/lib/dist/tokens/tokens-plum-light.css +1 -0
  123. package/lib/dist/tokens/tokens-prota-deuter-dark.css +1 -0
  124. package/lib/dist/tokens/tokens-prota-deuter-light.css +1 -0
  125. package/lib/dist/tokens/tokens-sunflower-dark.css +1 -0
  126. package/lib/dist/tokens/tokens-sunflower-light.css +1 -0
  127. package/lib/dist/tokens/tokens-tmo-dark.css +1 -0
  128. package/lib/dist/tokens/tokens-tmo-light.css +1 -0
  129. package/lib/dist/tokens/tokens-trita-dark.css +1 -0
  130. package/lib/dist/tokens/tokens-trita-light.css +1 -0
  131. package/lib/dist/tokens/tokens-verdant-haze-dark.css +1 -0
  132. package/lib/dist/tokens/tokens-verdant-haze-light.css +1 -0
  133. package/lib/dist/tokens-docs.json +1 -1
  134. package/package.json +5 -3
@@ -0,0 +1,1628 @@
1
+ #!/usr/bin/env node
2
+ /* eslint-disable max-lines */
3
+ /* eslint-disable complexity */
4
+
5
+ /**
6
+ * @fileoverview Migration script to convert legacy typography utility classes to <dt-text> components.
7
+ *
8
+ * Usage:
9
+ * npx dialtone-migrate-typography [options]
10
+ *
11
+ * Options:
12
+ * --cwd <path> Working directory (default: current directory)
13
+ * --dry-run Show changes without applying them
14
+ * --yes Apply all changes without prompting
15
+ * --file <path> Specific file to process (repeatable)
16
+ * --remove-markers Strip all dt-text-migrate review comments
17
+ * --help Show help
18
+ *
19
+ * Examples:
20
+ * npx dialtone-migrate-typography --dry-run --cwd ./src
21
+ * npx dialtone-migrate-typography --yes
22
+ * npx dialtone-migrate-typography --remove-markers --cwd ./src
23
+ */
24
+
25
+ import fs from 'fs/promises';
26
+ import { realpathSync } from 'node:fs';
27
+ import path from 'path';
28
+ import readline from 'readline';
29
+ import { fileURLToPath } from 'node:url';
30
+
31
+ //------------------------------------------------------------------------------
32
+ // Class-to-Prop Mapping Tables
33
+ // Validated against packages/dialtone-tokens/tokens/theme/dp/default.json
34
+ // and packages/dialtone-vue/components/Text/TextConstants.js (2026-05-19)
35
+ //------------------------------------------------------------------------------
36
+
37
+ /**
38
+ * Composed typography class → DtText props.
39
+ * `flag` marks classes that cannot be safely auto-migrated.
40
+ * - 'eyebrow' : uses text-transform:uppercase — no DtText prop
41
+ * - 'code-sm' : font-size below DtText minimum code size
42
+ * - 'helper' : approximated as body+density — outputs review marker too
43
+ */
44
+ const COMPOSED_CLASS_MAP = {
45
+ // headline
46
+ 'd-headline--sm': { kind: 'headline', size: '100' },
47
+ 'd-headline-small': { kind: 'headline', size: '100' },
48
+ 'd-headline--md': { kind: 'headline', size: '300' },
49
+ 'd-headline-medium': { kind: 'headline', size: '300' },
50
+ 'd-headline--lg': { kind: 'headline', size: '500' },
51
+ 'd-headline-large': { kind: 'headline', size: '500' },
52
+ 'd-headline--xl': { kind: 'headline', size: '600' },
53
+ 'd-headline-extra-large': { kind: 'headline', size: '600' },
54
+ 'd-headline--xxl': { kind: 'headline', size: '700' },
55
+ 'd-headline-extra-extra-large': { kind: 'headline', size: '700' },
56
+
57
+ // headline — eyebrow (unmappable: text-transform:uppercase)
58
+ 'd-headline--eyebrow': { flag: 'eyebrow' },
59
+ 'd-headline-eyebrow': { flag: 'eyebrow' },
60
+
61
+ // headline — soft variants (strength=medium per token)
62
+ 'd-headline--sm-soft': { kind: 'headline', size: '100', strength: 'medium' },
63
+ 'd-headline-soft-small': { kind: 'headline', size: '100', strength: 'medium' },
64
+ 'd-headline--lg-soft': { kind: 'headline', size: '500', strength: 'medium' },
65
+
66
+ // headline — compact variants
67
+ 'd-headline--sm-compact': { kind: 'headline', size: '100', density: '200' },
68
+ 'd-headline-compact-small': { kind: 'headline', size: '100', density: '200' },
69
+ 'd-headline--md-compact': { kind: 'headline', size: '300', density: '300' },
70
+ 'd-headline-compact-medium': { kind: 'headline', size: '300', density: '300' },
71
+ 'd-headline--lg-compact': { kind: 'headline', size: '500', density: '200' },
72
+ 'd-headline-compact-large': { kind: 'headline', size: '500', density: '200' },
73
+ 'd-headline--xl-compact': { kind: 'headline', size: '600', density: '100' },
74
+ 'd-headline--xxl-compact': { kind: 'headline', size: '700' }, // same lh=200 as base, no density
75
+
76
+ // headline — soft-compact
77
+ 'd-headline--sm-soft-compact': { kind: 'headline', size: '100', strength: 'medium', density: '200' },
78
+ 'd-headline-compact-soft-small': { kind: 'headline', size: '100', strength: 'medium', density: '200' },
79
+ 'd-headline--lg-soft-compact': { kind: 'headline', size: '500', strength: 'medium', density: '200' },
80
+
81
+ // body
82
+ 'd-body--md': { kind: 'body', size: '300' },
83
+ 'd-body-base': { kind: 'body', size: '300' },
84
+ 'd-body--sm': { kind: 'body', size: '100' },
85
+ 'd-body-small': { kind: 'body', size: '100' },
86
+ 'd-body--md-compact': { kind: 'body', size: '300', density: '300' },
87
+ 'd-body-compact': { kind: 'body', size: '300', density: '300' },
88
+ 'd-body--sm-compact': { kind: 'body', size: '100', density: '200' },
89
+ 'd-body-compact-small': { kind: 'body', size: '100', density: '200' },
90
+
91
+ // label
92
+ 'd-label--md': { kind: 'label', size: '300' },
93
+ 'd-label-base': { kind: 'label', size: '300' },
94
+ 'd-label--sm': { kind: 'label', size: '100' },
95
+ 'd-label-small': { kind: 'label', size: '100' },
96
+ 'd-label--md-compact': { kind: 'label', size: '300', density: '300' },
97
+ 'd-label-compact': { kind: 'label', size: '300', density: '300' },
98
+ 'd-label--sm-compact': { kind: 'label', size: '100', density: '200' },
99
+ 'd-label-compact-small': { kind: 'label', size: '100', density: '200' },
100
+ 'd-label--md-plain': { kind: 'label', size: '300', strength: 'normal' },
101
+ 'd-label-plain': { kind: 'label', size: '300', strength: 'normal' },
102
+ 'd-label--md-plain-compact': { kind: 'label', size: '300', strength: 'normal', density: '300' },
103
+ 'd-label-compact-plain': { kind: 'label', size: '300', strength: 'normal', density: '300' },
104
+ 'd-label--sm-plain': { kind: 'label', size: '100', strength: 'normal' },
105
+ 'd-label-plain-small': { kind: 'label', size: '100', strength: 'normal' },
106
+ 'd-label--sm-plain-compact': { kind: 'label', size: '100', strength: 'normal', density: '200' },
107
+ 'd-label-compact-plain-small': { kind: 'label', size: '100', strength: 'normal', density: '200' },
108
+
109
+ // code
110
+ 'd-code--md': { kind: 'code', size: '200' },
111
+ 'd-code-base': { kind: 'code', size: '200' },
112
+ 'd-code--sm': { flag: 'code-sm' }, // font-size below DtText minimum
113
+ 'd-code-small': { flag: 'code-sm' },
114
+
115
+ // helper — approximated as body+density, flagged for review
116
+ 'd-helper--md': { kind: 'body', size: '300', density: '300', flag: 'helper' },
117
+ 'd-helper-base': { kind: 'body', size: '300', density: '300', flag: 'helper' },
118
+ 'd-helper--sm': { kind: 'body', size: '100', density: '200', flag: 'helper' },
119
+ 'd-helper-small': { kind: 'body', size: '100', density: '200', flag: 'helper' },
120
+ };
121
+
122
+ /**
123
+ * Override utility class → DtText prop name and value.
124
+ * These apply on top of (or independently from) composed class rewrites.
125
+ */
126
+ const OVERRIDE_CLASS_MAP = {
127
+ // font-weight → strength
128
+ 'd-fw-bold': { prop: 'strength', value: 'bold' },
129
+ 'd-fw-semibold': { prop: 'strength', value: 'semibold' },
130
+ 'd-fw-medium': { prop: 'strength', value: 'medium' },
131
+ 'd-fw-normal': { prop: 'strength', value: 'normal' },
132
+
133
+ // line-height → density
134
+ 'd-lh-100': { prop: 'density', value: '100' },
135
+ 'd-lh-200': { prop: 'density', value: '200' },
136
+ 'd-lh-300': { prop: 'density', value: '300' },
137
+ 'd-lh-400': { prop: 'density', value: '400' },
138
+ 'd-lh-500': { prop: 'density', value: '500' },
139
+ 'd-lh-600': { prop: 'density', value: '600' },
140
+
141
+ // foreground color → tone (1:1 map against TEXT_TONE_MODIFIERS keys)
142
+ 'd-fc-primary': { prop: 'tone', value: 'primary' },
143
+ 'd-fc-secondary': { prop: 'tone', value: 'secondary' },
144
+ 'd-fc-tertiary': { prop: 'tone', value: 'tertiary' },
145
+ 'd-fc-muted': { prop: 'tone', value: 'muted' },
146
+ 'd-fc-disabled': { prop: 'tone', value: 'disabled' },
147
+ 'd-fc-placeholder': { prop: 'tone', value: 'placeholder' },
148
+ 'd-fc-critical': { prop: 'tone', value: 'critical' },
149
+ 'd-fc-critical-strong': { prop: 'tone', value: 'critical-strong' },
150
+ 'd-fc-positive': { prop: 'tone', value: 'positive' },
151
+ 'd-fc-positive-strong': { prop: 'tone', value: 'positive-strong' },
152
+ 'd-fc-warning': { prop: 'tone', value: 'warning' },
153
+ 'd-fc-info': { prop: 'tone', value: 'info' },
154
+ 'd-fc-info-strong': { prop: 'tone', value: 'info-strong' },
155
+ 'd-fc-neutral-black': { prop: 'tone', value: 'neutral-black' },
156
+ 'd-fc-neutral-white': { prop: 'tone', value: 'neutral-white' },
157
+
158
+ // truncate
159
+ 'd-truncate': { prop: 'truncate', value: null }, // boolean prop — no value
160
+
161
+ // text-align → align (logical naming: left→start, right→end)
162
+ 'd-ta-left': { prop: 'align', value: 'start' },
163
+ 'd-ta-right': { prop: 'align', value: 'end' },
164
+ 'd-ta-center': { prop: 'align', value: 'center' },
165
+ 'd-ta-justify': { prop: 'align', value: 'justify' },
166
+
167
+ // font-family → kind (special: establishes monospace text kind)
168
+ 'd-ff-mono': { prop: 'kind', value: 'code' },
169
+ };
170
+
171
+ // Rewriteable element tags (only these get turned into <dt-text>)
172
+ const REWRITEABLE_TAGS = new Set(['p', 'span', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'label']);
173
+
174
+ // Inline-phrasing children that DO NOT disqualify an element from being a text leaf.
175
+ // Anything outside this set as a child (block elements, custom components, interactive
176
+ // controls, headings, another <p>) signals the element is a wrapper, not text.
177
+ const INLINE_PHRASING_TAGS = new Set([
178
+ 'span', 'br', 'em', 'strong', 'code', 'i', 'b', 'u', 's',
179
+ 'sub', 'sup', 'kbd', 'samp', 'small', 'mark', 'wbr',
180
+ 'time', 'data', 'abbr', 'cite', 'q', 'dfn', 'var',
181
+ ]);
182
+
183
+ // All recognised class names combined (for safe-to-collapse predicate in Task 4)
184
+ const ALL_KNOWN_CLASSES = new Set([
185
+ ...Object.keys(COMPOSED_CLASS_MAP),
186
+ ...Object.keys(OVERRIDE_CLASS_MAP),
187
+ ]);
188
+
189
+ // Fast-path: only process files that contain at least one relevant class prefix
190
+ const FAST_PATH_RE = /d-headline|d-body|d-label|d-code|d-helper|d-fw-|d-fc-|d-lh-|d-truncate|d-ta-|d-fs-|d-ff-mono/;
191
+
192
+ //------------------------------------------------------------------------------
193
+ // Console helpers (no external deps — mirrors sibling scripts)
194
+ //------------------------------------------------------------------------------
195
+
196
+ const colors = {
197
+ reset: '\x1b[0m',
198
+ red: '\x1b[31m',
199
+ green: '\x1b[32m',
200
+ yellow: '\x1b[33m',
201
+ cyan: '\x1b[36m',
202
+ gray: '\x1b[90m',
203
+ bold: '\x1b[1m',
204
+ };
205
+
206
+ const log = {
207
+ cyan: (msg) => console.log(`${colors.cyan}${msg}${colors.reset}`),
208
+ gray: (msg) => console.log(`${colors.gray}${msg}${colors.reset}`),
209
+ red: (msg) => `${colors.red}${msg}${colors.reset}`,
210
+ green: (msg) => `${colors.green}${msg}${colors.reset}`,
211
+ yellow: (msg) => `${colors.yellow}${msg}${colors.reset}`,
212
+ bold: (msg) => console.log(`${colors.bold}${msg}${colors.reset}`),
213
+ };
214
+
215
+ //------------------------------------------------------------------------------
216
+ // Inert content masking — prevent transforms inside <script>, <style>, comments
217
+ //------------------------------------------------------------------------------
218
+
219
+ /**
220
+ * Replace <script>, <style>, and HTML comment regions with compact placeholders
221
+ * so regex passes don't alter their contents. Placeholders are inert tokens that
222
+ * don't collide with any HTML/Vue syntax we care about, and never truncated.
223
+ */
224
+ function maskInertContent (content) {
225
+ const segments = [];
226
+ let masked = content;
227
+ const token = `\x00MASK_${Date.now()}_`;
228
+ const endToken = '_KSAM\x00';
229
+
230
+ const inertPatterns = [
231
+ /<script[\s\S]*?<\/script>/gi,
232
+ /<style[\s\S]*?<\/style>/gi,
233
+ /<!--[\s\S]*?-->/g,
234
+ ];
235
+
236
+ for (const pattern of inertPatterns) {
237
+ masked = masked.replace(pattern, (match) => {
238
+ const idx = segments.length;
239
+ segments.push(match);
240
+ return `${token}${idx}${endToken}`;
241
+ });
242
+ }
243
+
244
+ return { masked, segments, token, endToken };
245
+ }
246
+
247
+ /**
248
+ * Restore masked segments back to their original content.
249
+ */
250
+ function unmaskInertContent (content, segments, token, endToken) {
251
+ return content.replace(
252
+ new RegExp(`${escapeRe(token)}(\\d+)${escapeRe(endToken)}`, 'g'),
253
+ (_, idx) => segments[Number(idx)],
254
+ );
255
+ }
256
+
257
+ function escapeRe (str) {
258
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
259
+ }
260
+
261
+ //------------------------------------------------------------------------------
262
+ // Core transform (exported for testing)
263
+ //------------------------------------------------------------------------------
264
+
265
+ /**
266
+ * Transform typography utility classes in a file's content.
267
+ *
268
+ * @param {string} content - Raw file content (.vue or .html)
269
+ * @param {object} opts
270
+ * @param {string} [opts.filePath] - For warnings/notes
271
+ * @returns {{ transformed: string, warnings: string[], notes: string[] }}
272
+ */
273
+ export function transformContent (content, opts = {}) {
274
+ const filePath = opts.filePath || '<input>';
275
+
276
+ if (!FAST_PATH_RE.test(content)) {
277
+ return { transformed: content, warnings: [], notes: [] };
278
+ }
279
+
280
+ const warnings = [];
281
+ const notes = [];
282
+
283
+ const { masked, segments, token, endToken } = maskInertContent(content);
284
+
285
+ let out = masked;
286
+
287
+ // Task 2: composed class rewrite — implemented in subsequent tasks
288
+ out = rewriteComposedClasses(out, filePath, warnings, notes);
289
+
290
+ // Task 3: override utility extraction + already-DtText residual lift
291
+ out = liftResidualOverrides(out, filePath, warnings, notes);
292
+
293
+ // Task 4: nested-span collapse + dynamic class / d-fs-* flagging
294
+ out = collapseNestedSpans(out, filePath, warnings, notes);
295
+ out = flagDynamicClasses(out, filePath, warnings, notes);
296
+ // Task 4b: composed typography classes on tags outside our rewrite scope
297
+ // (dt-* components, <a>, <button>, custom elements). Emit marker to surface them.
298
+ out = flagComposedOnWrapperTags(out, filePath, warnings, notes);
299
+ // Legacy heading hint runs BEFORE flagFontSizeClasses — when both fire, the richer
300
+ // hint wins (flagFontSizeClasses skips if any dt-text-migrate comment already exists nearby).
301
+ out = flagLegacyHeadings(out, filePath, warnings, notes);
302
+ out = flagFontSizeClasses(out, filePath, warnings, notes);
303
+
304
+ const unmasked = unmaskInertContent(out, segments, token, endToken);
305
+
306
+ // Idempotency: collapse consecutive identical dt-text-migrate own-line markers.
307
+ // This handles the re-run case where a marker emitted by a prior run gets masked
308
+ // (since it's an HTML comment) and the same marker is re-emitted by flagDynamicClasses
309
+ // / flagLegacyHeadings on the same target line.
310
+ const transformed = deduplicateAdjacentMarkers(unmasked);
311
+
312
+ return { transformed, warnings, notes };
313
+ }
314
+
315
+ function deduplicateAdjacentMarkers (content) {
316
+ const lines = content.split('\n');
317
+ const out = [];
318
+ const markerRe = /^\s*<!--\s*dt-text-migrate:[\s\S]*?-->\s*$/;
319
+ for (const line of lines) {
320
+ const prev = out[out.length - 1];
321
+ // Drop this line if it's a dt-text-migrate marker identical (trimmed) to the previous emitted line
322
+ if (prev && markerRe.test(line) && markerRe.test(prev) && line.trim() === prev.trim()) continue;
323
+ out.push(line);
324
+ }
325
+ return out.join('\n');
326
+ }
327
+
328
+ //------------------------------------------------------------------------------
329
+ // Task 2 — composed class rewrite
330
+ //------------------------------------------------------------------------------
331
+
332
+ // Quote-aware attribute body — matches non-`>` non-quote chars, or a complete "..." string,
333
+ // or a complete '...' string. Used in place of `[^>]*?` for opening-tag matching so that
334
+ // expressions like `:disabled="i > total"` or `:title="a > b"` don't terminate the tag at
335
+ // the inner `>`. The `*?` keeps it non-greedy.
336
+ const ATTR_BODY = `(?:[^>"']|"[^"]*"|'[^']*')*?`;
337
+
338
+ /**
339
+ * Regex: matches rewriteable opening tags carrying a static class attribute.
340
+ * Groups: 1=tag, 2=attrs-before-class, 3=class-value, 4=attrs-after-class, 5=self-closing
341
+ * Skips elements with a dynamic :class or v-bind:class (handled in Task 4).
342
+ *
343
+ * Uses ATTR_BODY (quote-aware) to skip over `>` inside quoted attr values.
344
+ * Uses `\sclass=` (not `\bclass=`) — `\b` matches inside attrs like `font-size-class=`,
345
+ * `content-class=`, `heading-class=` because `-c` is a word boundary. Requiring a leading
346
+ * whitespace anchors `class` as a standalone attribute name.
347
+ */
348
+ const ELEMENT_RE = new RegExp(
349
+ `<(p|span|div|h[1-6]|label|dt-text|DtText)(${ATTR_BODY})\\sclass="([^"]*)"(${ATTR_BODY})(\\/?)>`,
350
+ 'g',
351
+ );
352
+
353
+ /**
354
+ * Find the matching closing tag for an element, depth-counting nested same tags.
355
+ * Ported from dialtone_migrate_flex_to_stack/index.mjs.
356
+ */
357
+ function findMatchingClosingTag (content, startPos, tagName) {
358
+ let depth = 1;
359
+ let pos = startPos;
360
+
361
+ const openPattern = new RegExp(`<${escapeRe(tagName)}(?:\\s[^>]*?)?>`);
362
+ const selfClosePattern = new RegExp(`<${escapeRe(tagName)}(?:\\s[^>]*?)?/>`);
363
+ const closePattern = new RegExp(`</${escapeRe(tagName)}>`);
364
+
365
+ while (depth > 0 && pos < content.length) {
366
+ const slice = content.slice(pos);
367
+ const openMatch = slice.match(openPattern);
368
+ const selfCloseMatch = slice.match(selfClosePattern);
369
+ const closeMatch = slice.match(closePattern);
370
+
371
+ if (!closeMatch) return null;
372
+
373
+ const closePos = pos + closeMatch.index;
374
+ let openPos = openMatch ? pos + openMatch.index : Infinity;
375
+
376
+ if (selfCloseMatch && openMatch && selfCloseMatch.index === openMatch.index) {
377
+ openPos = Infinity;
378
+ }
379
+
380
+ if (openPos < closePos) {
381
+ depth++;
382
+ pos = openPos + openMatch[0].length;
383
+ } else {
384
+ depth--;
385
+ if (depth === 0) {
386
+ return { index: closePos, length: closeMatch[0].length };
387
+ }
388
+ pos = closePos + closeMatch[0].length;
389
+ }
390
+ }
391
+
392
+ return null;
393
+ }
394
+
395
+ /**
396
+ * Split a class list into override props + remaining classes.
397
+ * Returns { overrideProps: [{prop, value}], remaining: string[], conflictClasses: string[] }
398
+ * `conflictClasses` are override classes whose prop already exists in existingProps.
399
+ */
400
+ function extractOverrideProps (classes, existingProps = {}) {
401
+ const overrideProps = [];
402
+ const remaining = [];
403
+ const conflictClasses = [];
404
+
405
+ for (const cls of classes) {
406
+ const override = OVERRIDE_CLASS_MAP[cls];
407
+ if (!override) {
408
+ remaining.push(cls);
409
+ continue;
410
+ }
411
+ const { prop, value } = override;
412
+ if (prop in existingProps) {
413
+ // Conflict — explicit prop takes precedence, flag the class
414
+ conflictClasses.push(cls);
415
+ continue;
416
+ }
417
+ // Avoid duplicates
418
+ if (!overrideProps.some(p => p.prop === prop)) {
419
+ overrideProps.push({ prop, value });
420
+ }
421
+ }
422
+
423
+ return { overrideProps, remaining, conflictClasses };
424
+ }
425
+
426
+ /**
427
+ * Build a <dt-text> opening tag from the resolved props + retained classes + other attrs.
428
+ * Also extracts any override utility classes from retainedClasses and lifts them to props.
429
+ */
430
+ function buildDtTextTag (opts) {
431
+ const { originalTag, kind, size, density, strength, retainedClasses, attrsBefore, attrsAfter, selfClosing } = opts;
432
+
433
+ // Extract the existing props so we can detect conflicts
434
+ const existingProps = {};
435
+ if (kind) existingProps.kind = kind;
436
+ if (size) existingProps.size = size;
437
+ if (strength) existingProps.strength = strength;
438
+ if (density) existingProps.density = density;
439
+
440
+ const { overrideProps, remaining, conflictClasses } = extractOverrideProps(retainedClasses, existingProps);
441
+
442
+ // Consolidate props into canonical order matching beacon's convention:
443
+ // as → kind → size → strength → density → tone → align → boolean (truncate, numeric) → class
444
+ // (directives/data-attrs/events flow through attrsBefore/attrsAfter from the original tag)
445
+ const finalProps = {};
446
+ if (kind) finalProps.kind = kind;
447
+ if (size) finalProps.size = size;
448
+ if (strength) finalProps.strength = strength;
449
+ if (density) finalProps.density = density;
450
+ for (const { prop, value } of overrideProps) {
451
+ // Override classes win over composed defaults if conflicting (e.g. d-fw-bold overrides headline-soft's medium)
452
+ finalProps[prop] = value;
453
+ }
454
+
455
+ let tag = '<dt-text';
456
+
457
+ // `as` prop — omit when original tag is span or dt-text (DtText default)
458
+ if (originalTag !== 'span' && originalTag !== 'dt-text' && originalTag !== 'DtText') {
459
+ tag += ` as="${originalTag}"`;
460
+ }
461
+
462
+ // Canonical order
463
+ const PROP_ORDER = ['kind', 'size', 'strength', 'density', 'tone', 'align', 'truncate', 'numeric'];
464
+ for (const prop of PROP_ORDER) {
465
+ if (!(prop in finalProps)) continue;
466
+ const value = finalProps[prop];
467
+ if (value === null) {
468
+ tag += ` ${prop}`; // boolean prop (e.g. truncate)
469
+ } else {
470
+ tag += ` ${prop}="${value}"`;
471
+ }
472
+ }
473
+
474
+ if (remaining.length > 0) {
475
+ tag += ` class="${remaining.join(' ')}"`;
476
+ }
477
+
478
+ if (attrsBefore) tag += ` ${attrsBefore}`;
479
+ if (attrsAfter) tag += ` ${attrsAfter}`;
480
+
481
+ const openTag = tag + (selfClosing ? ' />' : '>');
482
+ const conflictComment = conflictClasses.length > 0
483
+ ? '<!-- dt-text-migrate: review conflicting class -->'
484
+ : '';
485
+
486
+ return conflictComment + openTag;
487
+ }
488
+
489
+ // True if any class signals layout intent (display utility).
490
+ // `<div class="d-d-flex d-body--md">` is a flex container, not a text element.
491
+ function hasLayoutDisplaySignal (classes) {
492
+ return classes.some(c => /^d-d-/.test(c));
493
+ }
494
+
495
+ // True if the element body contains a child tag that disqualifies the parent
496
+ // from being a text leaf. Block elements, custom components (dt-*, kebab-case),
497
+ // interactive controls, headings, another <p>, and <dt-text> all count.
498
+ // Inline phrasing children (span, em, strong, br, etc.) do NOT disqualify.
499
+ // Strips quoted attribute values first so a tag-like string in an attr (e.g.
500
+ // `<span title="<dt-button>">`) does not false-match as a real child.
501
+ function hasBlockOrComponentChild (body) {
502
+ if (!body) return false;
503
+ const stripped = body.replace(/"[^"]*"|'[^']*'/g, '');
504
+ const tagOpens = stripped.matchAll(/<([a-zA-Z][a-zA-Z0-9-]*)\b/g);
505
+ for (const m of tagOpens) {
506
+ if (!INLINE_PHRASING_TAGS.has(m[1].toLowerCase())) return true;
507
+ }
508
+ return false;
509
+ }
510
+
511
+ function rewriteComposedClasses (content) {
512
+ const elementRe = new RegExp(ELEMENT_RE.source, 'g');
513
+ const matches = [];
514
+
515
+ let m;
516
+ while ((m = elementRe.exec(content)) !== null) {
517
+ const [fullMatch, tagName, attrsBefore, classValue, attrsAfter, selfClosing] = m;
518
+
519
+ // Skip elements that already have a dynamic :class binding — Task 4 handles those
520
+ if (/(?:^|\s):class\b|(?:^|\s)v-bind:class\b/.test(attrsBefore + attrsAfter)) continue;
521
+
522
+ const classes = classValue.split(/\s+/).filter(Boolean);
523
+
524
+ // Find the first composed class in the class list
525
+ const composedEntry = classes.reduce((found, cls) => {
526
+ return found || (COMPOSED_CLASS_MAP[cls] ? { cls, entry: COMPOSED_CLASS_MAP[cls] } : null);
527
+ }, null);
528
+
529
+ if (!composedEntry) continue;
530
+
531
+ const { cls: composedCls, entry } = composedEntry;
532
+
533
+ // Only rewrite native rewriteable tags; skip if already dt-text (handled in Task 3)
534
+ const isNativeRewriteable = REWRITEABLE_TAGS.has(tagName.toLowerCase());
535
+ const isDtText = tagName === 'dt-text' || tagName === 'DtText';
536
+ if (!isNativeRewriteable && !isDtText) continue;
537
+
538
+ const retainedClasses = classes.filter(c => c !== composedCls);
539
+ const isSelfClosing = selfClosing === '/';
540
+ const openStart = m.index;
541
+ const openEnd = m.index + fullMatch.length;
542
+
543
+ let closeStart = null;
544
+ let closeEnd = null;
545
+ let closeReplacement = null;
546
+
547
+ if (!isSelfClosing) {
548
+ const closing = findMatchingClosingTag(content, openEnd, tagName);
549
+ if (closing) {
550
+ closeStart = closing.index;
551
+ closeEnd = closing.index + closing.length;
552
+ closeReplacement = '</dt-text>';
553
+ }
554
+ }
555
+
556
+ // Wrapper safety: skip auto-convert when the element looks like a layout container
557
+ // or contains non-text children. Emit a review marker so the legacy class still
558
+ // surfaces in the migration diff. Only applies to native HTML rewriteable tags
559
+ // — dt-text residual lifts go through their own path in liftResidualOverrides.
560
+ const elementBody = (!isSelfClosing && closeStart !== null)
561
+ ? content.slice(openEnd, closeStart)
562
+ : '';
563
+ const isWrapper = isNativeRewriteable
564
+ && (hasLayoutDisplaySignal(retainedClasses) || hasBlockOrComponentChild(elementBody));
565
+
566
+ if (isWrapper) {
567
+ matches.push({
568
+ entry: { flag: 'wrapper' },
569
+ composedCls,
570
+ tagName,
571
+ attrsBefore: attrsBefore.trim(),
572
+ attrsAfter: attrsAfter.trim(),
573
+ retainedClasses,
574
+ isSelfClosing,
575
+ openStart,
576
+ openEnd,
577
+ closeStart: null,
578
+ closeEnd: null,
579
+ closeReplacement: null,
580
+ });
581
+ continue;
582
+ }
583
+
584
+ matches.push({
585
+ entry,
586
+ composedCls,
587
+ tagName,
588
+ attrsBefore: attrsBefore.trim(),
589
+ attrsAfter: attrsAfter.trim(),
590
+ retainedClasses,
591
+ isSelfClosing,
592
+ openStart,
593
+ openEnd,
594
+ closeStart,
595
+ closeEnd,
596
+ closeReplacement,
597
+ });
598
+ }
599
+
600
+ if (matches.length === 0) return content;
601
+
602
+ // Apply in reverse order to preserve positions
603
+ const replacements = [];
604
+
605
+ for (const match of matches) {
606
+ const {
607
+ entry, tagName, attrsBefore, attrsAfter, retainedClasses,
608
+ isSelfClosing, openStart, openEnd, closeStart, closeEnd,
609
+ } = match;
610
+
611
+ if (entry.flag === 'eyebrow' || entry.flag === 'code-sm') {
612
+ // Prepend a review comment before the element — no rewrite
613
+ replacements.push({
614
+ start: openStart,
615
+ end: openStart,
616
+ replacement: '<!-- dt-text-migrate: review -->',
617
+ });
618
+ continue;
619
+ }
620
+
621
+ if (entry.flag === 'wrapper') {
622
+ // Layout container or wrapper with non-text children — emit a review
623
+ // marker so the legacy composed class surfaces in the diff. Tag and
624
+ // classes are left intact for the consumer to migrate manually.
625
+ replacements.push({
626
+ start: openStart,
627
+ end: openStart,
628
+ replacement: '<!-- dt-text-migrate: review composed class on wrapper -->',
629
+ });
630
+ continue;
631
+ }
632
+
633
+ if (entry.flag === 'helper') {
634
+ // Rewrite to body+density approximation AND prepend helper review comment (single replacement)
635
+ const newTag = buildDtTextTag({
636
+ originalTag: tagName,
637
+ kind: entry.kind,
638
+ size: entry.size,
639
+ density: entry.density,
640
+ strength: entry.strength,
641
+ retainedClasses,
642
+ attrsBefore,
643
+ attrsAfter,
644
+ selfClosing: isSelfClosing,
645
+ });
646
+ replacements.push({
647
+ start: openStart,
648
+ end: openEnd,
649
+ replacement: '<!-- dt-text-migrate: review helper -->' + newTag,
650
+ });
651
+ if (!isSelfClosing && closeStart !== null) {
652
+ replacements.push({ start: closeStart, end: closeEnd, replacement: '</dt-text>' });
653
+ }
654
+ continue;
655
+ }
656
+
657
+ // Normal rewrite
658
+ const newTag = buildDtTextTag({
659
+ originalTag: tagName,
660
+ kind: entry.kind,
661
+ size: entry.size,
662
+ density: entry.density,
663
+ strength: entry.strength,
664
+ retainedClasses,
665
+ attrsBefore,
666
+ attrsAfter,
667
+ selfClosing: isSelfClosing,
668
+ });
669
+
670
+ replacements.push({ start: openStart, end: openEnd, replacement: newTag });
671
+ if (!isSelfClosing && closeStart !== null) {
672
+ replacements.push({ start: closeStart, end: closeEnd, replacement: '</dt-text>' });
673
+ }
674
+ }
675
+
676
+ // Sort descending by start position, apply
677
+ replacements.sort((a, b) => b.start - a.start);
678
+
679
+ let out = content;
680
+ for (const r of replacements) {
681
+ out = out.slice(0, r.start) + r.replacement + out.slice(r.end);
682
+ }
683
+
684
+ return out;
685
+ }
686
+
687
+ //------------------------------------------------------------------------------
688
+ // Task 3 — override extraction + already-DtText residual lift
689
+ //------------------------------------------------------------------------------
690
+
691
+ /**
692
+ * Parse the known prop key-value pairs already present in an element's attribute string.
693
+ * Returns { [propName]: value } for string props and { [propName]: true } for boolean props.
694
+ */
695
+ function parseExistingProps (attrStr) {
696
+ const props = {};
697
+ // Anchor on whitespace/start so we don't match suffix attrs like `font-kind=`,
698
+ // `wrapper-truncate=`, `label-size=` etc. — `\b` is a word boundary between `-` and `k`,
699
+ // so `\b(kind|...)` matches inside `font-kind=`. Requiring `\s` (or start of attrStr,
700
+ // which often begins with whitespace) anchors the prop as a real attribute name.
701
+ // Allow `:` or `v-bind:` prefix for bound props (e.g. `:size="…"` counts as an existing size).
702
+ const propRe = /(?:^|\s)(?::|v-bind:)?(kind|size|strength|density|tone|align|truncate|as)\s*=\s*"([^"]*)"/g;
703
+ const boolRe = /(?:^|\s)truncate\b(?!\s*[=:])/g;
704
+ let m;
705
+ while ((m = propRe.exec(attrStr)) !== null) {
706
+ props[m[1]] = m[2];
707
+ }
708
+ while ((m = boolRe.exec(attrStr)) !== null) {
709
+ props.truncate = true;
710
+ }
711
+ return props;
712
+ }
713
+
714
+ /**
715
+ * Build the attr string for the lifted props + remaining classes + preserved other attrs.
716
+ * Used for residual lift on existing <dt-text> elements.
717
+ */
718
+ function buildResidualTag (tagName, existingAttrs, overrideProps, remaining, conflictClasses) {
719
+ // Strip the original class attribute — we'll rebuild it if remaining is non-empty
720
+ let attrs = existingAttrs.replace(/\sclass="[^"]*"/, '').trimEnd();
721
+
722
+ for (const { prop, value } of overrideProps) {
723
+ if (value === null) {
724
+ attrs += ` ${prop}`;
725
+ } else {
726
+ attrs += ` ${prop}="${value}"`;
727
+ }
728
+ }
729
+
730
+ if (remaining.length > 0) {
731
+ attrs += ` class="${remaining.join(' ')}"`;
732
+ }
733
+
734
+ const conflictComment = conflictClasses.length > 0
735
+ ? '<!-- dt-text-migrate: review conflicting class -->'
736
+ : '';
737
+
738
+ return conflictComment + `<${tagName}${attrs}>`;
739
+ }
740
+
741
+ function liftResidualOverrides (content) {
742
+ // Match <dt-text>, <DtText>, and rewriteable tags that survived Task 2 (override-only elements)
743
+ // Quote-aware via ATTR_BODY — same reasoning as ELEMENT_RE above
744
+ const re = new RegExp(
745
+ `<(dt-text|DtText|p|span|div|h[1-6]|label)(${ATTR_BODY})\\sclass="([^"]*)"(${ATTR_BODY})(\\/?)>`,
746
+ 'g',
747
+ );
748
+ const replacements = [];
749
+
750
+ let m;
751
+ while ((m = re.exec(content)) !== null) {
752
+ const [fullMatch, tagName, attrsBefore, classValue, attrsAfter, selfClosing] = m;
753
+ const isSelfClosing = selfClosing === '/';
754
+ const openStart = m.index;
755
+ const openEnd = m.index + fullMatch.length;
756
+
757
+ const isDtText = tagName === 'dt-text' || tagName === 'DtText';
758
+ const isNative = REWRITEABLE_TAGS.has(tagName.toLowerCase());
759
+
760
+ // Skip elements with dynamic :class binding
761
+ if (/(?:^|\s):class\b|(?:^|\s)v-bind:class\b/.test(attrsBefore + attrsAfter)) continue;
762
+
763
+ const classes = classValue.split(/\s+/).filter(Boolean);
764
+
765
+ // Check if any class is an override utility
766
+ const hasOverride = classes.some(c => OVERRIDE_CLASS_MAP[c]);
767
+ if (!hasOverride) continue;
768
+
769
+ if (isDtText) {
770
+ // Residual lift: parse existing props to detect conflicts
771
+ const allAttrs = (attrsBefore + ' ' + attrsAfter).trim();
772
+ const existingProps = parseExistingProps(allAttrs);
773
+ const { overrideProps, remaining, conflictClasses } = extractOverrideProps(classes, existingProps);
774
+
775
+ if (overrideProps.length === 0 && conflictClasses.length === 0) continue;
776
+
777
+ replacements.push({
778
+ start: openStart,
779
+ end: openEnd,
780
+ replacement: buildResidualTag(tagName, attrsBefore + attrsAfter, overrideProps, remaining, conflictClasses),
781
+ });
782
+
783
+ } else if (isNative) {
784
+ // Override-only on rewriteable tag: check no composed class (Task 2 already handled those)
785
+ const hasComposed = classes.some(c => COMPOSED_CLASS_MAP[c]);
786
+ if (hasComposed) continue; // Already handled by rewriteComposedClasses
787
+
788
+ // āš ļø Without a composed class, `kind`/`size` are not deducible. Converting <div>/<h*>
789
+ // to <dt-text> would attach the .d-text base class which changes baseline typography —
790
+ // problematic for layout wrappers (e.g. <div class="d-fc-neutral-white"> around a stack).
791
+ // Restrict override-only rewriting to <span>/<p>/<label> where text-as-default is safe.
792
+ const OVERRIDE_ONLY_REWRITEABLE = new Set(['span', 'p', 'label']);
793
+ if (!OVERRIDE_ONLY_REWRITEABLE.has(tagName.toLowerCase())) continue;
794
+
795
+ // Skip elements with behavioral attrs — directives, events, id, ref, data-*
796
+ // mean the element has significance beyond typography (e.g. <span @click>, <span id="x">)
797
+ const allAttrs = (attrsBefore + ' ' + attrsAfter).trim();
798
+ if (/(?:v-|@\w|:(?!class)|\bid=|\bref=|\bdata-)/.test(allAttrs)) continue;
799
+
800
+ // Skip if any class is unrecognised — custom CSS may target it, unsafe to auto-convert
801
+ if (classes.some(c => !ALL_KNOWN_CLASSES.has(c))) continue;
802
+
803
+ // Wrapper safety: skip if element body contains block/component children.
804
+ // <span class="d-fw-bold"><dt-button>x</dt-button></span> should NOT become
805
+ // <dt-text strength="bold"><dt-button>…</dt-text>. (Mirrors the composed-path safety.)
806
+ if (!isSelfClosing) {
807
+ const closing = findMatchingClosingTag(content, openEnd, tagName);
808
+ if (closing) {
809
+ const body = content.slice(openEnd, closing.index);
810
+ if (hasBlockOrComponentChild(body)) continue;
811
+ }
812
+ }
813
+
814
+ // Check no dynamic :class conflicts
815
+ const { overrideProps, remaining } = extractOverrideProps(classes, {});
816
+ if (overrideProps.length === 0) continue;
817
+
818
+ let newTag = '<dt-text';
819
+ if (tagName !== 'span') newTag += ` as="${tagName}"`;
820
+
821
+ for (const { prop, value } of overrideProps) {
822
+ if (value === null) {
823
+ newTag += ` ${prop}`;
824
+ } else {
825
+ newTag += ` ${prop}="${value}"`;
826
+ }
827
+ }
828
+
829
+ if (remaining.length > 0) newTag += ` class="${remaining.join(' ')}"`;
830
+ if (attrsBefore.trim()) newTag += ` ${attrsBefore.trim()}`;
831
+ if (attrsAfter.trim()) newTag += ` ${attrsAfter.trim()}`;
832
+ newTag += isSelfClosing ? ' />' : '>';
833
+
834
+ replacements.push({ start: openStart, end: openEnd, replacement: newTag });
835
+
836
+ if (!isSelfClosing) {
837
+ const closing = findMatchingClosingTag(content, openEnd, tagName);
838
+ if (closing) {
839
+ replacements.push({
840
+ start: closing.index,
841
+ end: closing.index + closing.length,
842
+ replacement: '</dt-text>',
843
+ });
844
+ }
845
+ }
846
+ }
847
+ }
848
+
849
+ if (replacements.length === 0) return content;
850
+
851
+ replacements.sort((a, b) => b.start - a.start);
852
+ let out = content;
853
+ for (const r of replacements) {
854
+ out = out.slice(0, r.start) + r.replacement + out.slice(r.end);
855
+ }
856
+ return out;
857
+ }
858
+
859
+ //------------------------------------------------------------------------------
860
+ // Task 4 — nested-span collapse + flagging
861
+ //------------------------------------------------------------------------------
862
+
863
+ /**
864
+ * Determine if a <span> opening tag is safe to collapse.
865
+ * Safe = exactly one attribute (class), all classes are in ALL_KNOWN_CLASSES,
866
+ * no Vue directives or event handlers.
867
+ */
868
+ function isSafeToCollapseSpan (fullTag) {
869
+ // Must not have any directive, event, binding, id, ref, or data attr
870
+ const unsafePattern = /\bv-|@\w|:class\b|v-bind:class\b|\bid=|\bref=|\bdata-/;
871
+ if (unsafePattern.test(fullTag)) return false;
872
+
873
+ // Extract class value (anchored on whitespace so we don't match label-class=, etc.)
874
+ const classMatch = fullTag.match(/\sclass="([^"]*)"/);
875
+ if (!classMatch) return false;
876
+
877
+ // Must have ONLY a class attribute (besides whitespace)
878
+ const withoutClass = fullTag
879
+ .replace(/\sclass="[^"]*"/, '')
880
+ .replace(/<\/?span/g, '')
881
+ .replace(/\s*\/?>/, '')
882
+ .trim();
883
+ if (withoutClass.length > 0) return false;
884
+
885
+ const classes = classMatch[1].split(/\s+/).filter(Boolean);
886
+ if (classes.length === 0 || !classes.every(c => ALL_KNOWN_CLASSES.has(c))) return false;
887
+
888
+ // Reject if any composed class is flagged (eyebrow / code-sm / helper) — those are
889
+ // explicitly out-of-scope-for-auto-rewrite per the PRD and Task 2 already emits a
890
+ // review marker for them. Rewriting here would contradict the flag.
891
+ return !classes.some(c => COMPOSED_CLASS_MAP[c] && COMPOSED_CLASS_MAP[c].flag);
892
+ }
893
+
894
+ /**
895
+ * Rewrite a safe child span tag (with only recognized classes) to <dt-text>.
896
+ */
897
+ function rewriteChildSpan (spanTag) {
898
+ const classMatch = spanTag.match(/\sclass="([^"]*)"/);
899
+ if (!classMatch) return spanTag;
900
+
901
+ const classes = classMatch[1].split(/\s+/).filter(Boolean);
902
+ const existingProps = {};
903
+ const { overrideProps, remaining } = extractOverrideProps(classes, existingProps);
904
+ const composedEntry = classes.reduce(
905
+ (f, c) => f || (COMPOSED_CLASS_MAP[c] ? { cls: c, entry: COMPOSED_CLASS_MAP[c] } : null),
906
+ null,
907
+ );
908
+
909
+ let tag = '<dt-text';
910
+ if (composedEntry && !composedEntry.entry.flag) {
911
+ const { kind, size } = composedEntry.entry;
912
+ if (kind) tag += ` kind="${kind}"`;
913
+ if (size) tag += ` size="${size}"`;
914
+ }
915
+ for (const { prop, value } of overrideProps) {
916
+ tag += value === null ? ` ${prop}` : ` ${prop}="${value}"`;
917
+ }
918
+ if (remaining.length > 0) tag += ` class="${remaining.join(' ')}"`;
919
+ return tag + '>';
920
+ }
921
+
922
+ function collapseNestedSpans (content) {
923
+ // Match opening dt-text tags (quote-aware), then process the body up to the
924
+ // DEPTH-MATCHED closing </dt-text>. The body boundary is critical when a parent
925
+ // dt-text contains another dt-text — naive first-match would truncate the parent
926
+ // body at the inner close, silently skipping spans that follow.
927
+ const dtTextRe = new RegExp(`<dt-text(${ATTR_BODY})>`, 'g');
928
+ let m;
929
+ const replacements = [];
930
+
931
+ while ((m = dtTextRe.exec(content)) !== null) {
932
+ const openEnd = m.index + m[0].length;
933
+ const closing = findMatchingClosingTag(content, openEnd, 'dt-text');
934
+ if (!closing) continue;
935
+ const bodyStart = openEnd;
936
+ const bodyEnd = closing.index;
937
+ const body = content.slice(bodyStart, bodyEnd);
938
+
939
+ // Find direct child <span> tags in the body (quote-aware)
940
+ const childSpanRe = new RegExp(`<span(${ATTR_BODY})>`, 'g');
941
+ let sm;
942
+ const spanReplacements = [];
943
+
944
+ while ((sm = childSpanRe.exec(body)) !== null) {
945
+ const spanTag = sm[0];
946
+ const spanOpenEnd = sm.index + spanTag.length;
947
+ if (isSafeToCollapseSpan(spanTag)) {
948
+ spanReplacements.push({
949
+ start: sm.index,
950
+ end: spanOpenEnd,
951
+ replacement: rewriteChildSpan(spanTag),
952
+ });
953
+ // Find the depth-matched </span> (handles nested <span>...<span>...</span>...</span>)
954
+ const closingSpan = findMatchingClosingTag(body, spanOpenEnd, 'span');
955
+ if (closingSpan) {
956
+ spanReplacements.push({
957
+ start: closingSpan.index,
958
+ end: closingSpan.index + closingSpan.length,
959
+ replacement: '</dt-text>',
960
+ });
961
+ }
962
+ } else {
963
+ // Check if it has any typography classes — if so, flag it
964
+ const classM = spanTag.match(/\sclass="([^"]*)"/);
965
+ if (classM) {
966
+ const hasTypo = classM[1].split(/\s+/).some(c => ALL_KNOWN_CLASSES.has(c));
967
+ if (hasTypo) {
968
+ spanReplacements.push({
969
+ start: sm.index,
970
+ end: sm.index,
971
+ replacement: '<!-- dt-text-migrate: review nested span -->',
972
+ });
973
+ }
974
+ }
975
+ }
976
+ }
977
+
978
+ if (spanReplacements.length > 0) {
979
+ spanReplacements.sort((a, b) => b.start - a.start);
980
+ let newBody = body;
981
+ for (const r of spanReplacements) {
982
+ newBody = newBody.slice(0, r.start) + r.replacement + newBody.slice(r.end);
983
+ }
984
+ replacements.push({ start: bodyStart, end: bodyEnd, replacement: newBody });
985
+ }
986
+ }
987
+
988
+ if (replacements.length === 0) return content;
989
+ replacements.sort((a, b) => b.start - a.start);
990
+ let out = content;
991
+ for (const r of replacements) {
992
+ out = out.slice(0, r.start) + r.replacement + out.slice(r.end);
993
+ }
994
+ return out;
995
+ }
996
+
997
+ // Note: this pattern must be created fresh (new RegExp) in the function below — never
998
+ // use a module-level /g regex with .test(), as lastIndex state persists across calls.
999
+ const DYNAMIC_CLASS_PATTERN = '(?::class|v-bind:class)="[^"]*(?:d-headline|d-body|d-label|d-code|d-helper|d-fw-|d-fc-|d-lh-|d-truncate|d-ta-)[^"]*"';
1000
+
1001
+ function flagDynamicClasses (content) {
1002
+ const re = new RegExp(DYNAMIC_CLASS_PATTERN);
1003
+ if (!re.test(content)) return content;
1004
+
1005
+ const lines = content.split('\n');
1006
+ const out = lines.map(line => {
1007
+ if (!/:class=|v-bind:class=/.test(line)) return line;
1008
+ if (!new RegExp(DYNAMIC_CLASS_PATTERN).test(line)) return line;
1009
+ // Preserve the line's indentation; put the marker on its own line above.
1010
+ const indentMatch = line.match(/^[ \t]*/);
1011
+ const indent = indentMatch ? indentMatch[0] : '';
1012
+ return `${indent}<!-- dt-text-migrate: review dynamic class -->\n${line}`;
1013
+ });
1014
+
1015
+ return out.join('\n');
1016
+ }
1017
+
1018
+ // Find composed typography classes on tags outside the rewrite scope
1019
+ // (dt-* components, <a>, <button>, other custom elements). Emit a marker
1020
+ // so the legacy class surfaces in the migration diff instead of being silently
1021
+ // passed over. Tags ARE in the rewrite scope (p/span/div/h1-6/label/dt-text)
1022
+ // are handled by rewriteComposedClasses + liftResidualOverrides.
1023
+ function flagComposedOnWrapperTags (content) {
1024
+ const tagRe = new RegExp(
1025
+ `<([a-zA-Z][a-zA-Z0-9-]*)(${ATTR_BODY})\\sclass="([^"]*)"(${ATTR_BODY})\\/?>`,
1026
+ 'g',
1027
+ );
1028
+ const insertions = [];
1029
+ let m;
1030
+ while ((m = tagRe.exec(content)) !== null) {
1031
+ const [, tagName, , classValue] = m;
1032
+ const tagLower = tagName.toLowerCase();
1033
+ if (REWRITEABLE_TAGS.has(tagLower)) continue;
1034
+ if (tagLower === 'dt-text' || tagName === 'DtText') continue;
1035
+ const classes = classValue.split(/\s+/).filter(Boolean);
1036
+ const hasComposed = classes.some(c => COMPOSED_CLASS_MAP[c]);
1037
+ if (!hasComposed) continue;
1038
+ // Idempotency: skip if our marker is already on the preceding line/inline
1039
+ const before = content.slice(Math.max(0, m.index - 80), m.index);
1040
+ if (/<!--\s*dt-text-migrate:\s*review composed class on wrapper tag\s*-->/.test(before)) continue;
1041
+ insertions.push(m.index);
1042
+ }
1043
+ if (insertions.length === 0) return content;
1044
+ let out = content;
1045
+ for (const idx of insertions.reverse()) {
1046
+ // Place marker on its own line above, preserving indentation
1047
+ const lineStart = out.lastIndexOf('\n', idx - 1) + 1;
1048
+ const indent = out.slice(lineStart, idx).match(/^[ \t]*/)[0];
1049
+ const marker = `${indent}<!-- dt-text-migrate: review composed class on wrapper tag -->\n`;
1050
+ out = out.slice(0, lineStart) + marker + out.slice(lineStart);
1051
+ }
1052
+ return out;
1053
+ }
1054
+
1055
+ // On-menu d-fs-N → DtText size mapping hint (body/label size + headline size if applicable)
1056
+ // fs.N values that align with DtText's scale (matches dialtone-tokens font.size token stops)
1057
+ const ON_MENU_FS_HINTS = {
1058
+ '100': 'body/label size="100"',
1059
+ '200': 'body/label size="300" OR headline size="100"',
1060
+ '300': 'headline size="300"',
1061
+ '400': 'headline size="500"',
1062
+ '500': 'headline size="600"',
1063
+ '600': 'headline size="700"',
1064
+ };
1065
+
1066
+ function flagFontSizeClasses (content) {
1067
+ // Iterate over real opening tags using a quote-aware regex so we never land
1068
+ // inside a quoted attribute value (e.g. `title="a < b"`). Walking back with
1069
+ // lastIndexOf('<') from a `class=` match is unsafe because `<` can appear
1070
+ // inside `v-if="rowCount < total"` style bindings.
1071
+ const tagRe = new RegExp(`<[a-zA-Z][\\w-]*(${ATTR_BODY})\\sclass="([^"]*)"`, 'g');
1072
+ const replacements = [];
1073
+ let m;
1074
+
1075
+ while ((m = tagRe.exec(content)) !== null) {
1076
+ const tagStart = m.index;
1077
+ const classValue = m[2];
1078
+
1079
+ // Only act on class attributes containing d-fs-N
1080
+ const fsMatch = classValue.match(/\bd-fs-(\d+)\b/);
1081
+ if (!fsMatch) continue;
1082
+
1083
+ const fsN = fsMatch[1];
1084
+ const hint = ON_MENU_FS_HINTS[fsN]
1085
+ ? `<!-- dt-text-migrate: review d-fs-${fsN} (on-menu — maps to ${ON_MENU_FS_HINTS[fsN]}) -->`
1086
+ : `<!-- dt-text-migrate: review d-fs-${fsN} (off-menu — no clean DtText equivalent, keep class) -->`;
1087
+
1088
+ // Suppress when a dt-text-migrate comment ends immediately before this tag
1089
+ // (legacy-heading hints share the same target position — don't double up).
1090
+ const immediatelyBefore = content.slice(Math.max(0, tagStart - 500), tagStart);
1091
+ const alreadyFlagged = /<!--\s*dt-text-migrate:[\s\S]*?-->\s*$/.test(immediatelyBefore);
1092
+ if (!alreadyFlagged) {
1093
+ replacements.push({ start: tagStart, end: tagStart, replacement: hint });
1094
+ }
1095
+ }
1096
+
1097
+ if (replacements.length === 0) return content;
1098
+ replacements.sort((a, b) => b.start - a.start);
1099
+ let out = content;
1100
+ for (const r of replacements) {
1101
+ out = out.slice(0, r.start) + r.replacement + out.slice(r.end);
1102
+ }
1103
+ return out;
1104
+ }
1105
+
1106
+ /**
1107
+ * Detect the legacy raw-utility heading pattern (no composed class, but d-fw-* +
1108
+ * d-fc-* + d-fs-N built up by hand on a rewriteable native tag) and emit a hint
1109
+ * comment with the proposed DtText migration. This is firespotter's dominant pattern
1110
+ * — most uc_client headings predate composed classes.
1111
+ *
1112
+ * The hint comment includes the proposed `<dt-text>` form. The element is NOT rewritten;
1113
+ * the developer reviews and applies. Cleanly removed by --remove-markers.
1114
+ */
1115
+ function flagLegacyHeadings (content) {
1116
+ // Match rewriteable native elements with a static class attribute
1117
+ // Quote-aware via ATTR_BODY — same reasoning as ELEMENT_RE above
1118
+ const re = new RegExp(
1119
+ `<(p|span|div|h[1-6]|label)(${ATTR_BODY})\\sclass="([^"]*)"(${ATTR_BODY})(\\/?)>`,
1120
+ 'g',
1121
+ );
1122
+ const replacements = [];
1123
+ let m;
1124
+
1125
+ while ((m = re.exec(content)) !== null) {
1126
+ const [, tagName, attrsBefore, classValue, attrsAfter] = m;
1127
+ const classes = classValue.split(/\s+/).filter(Boolean);
1128
+
1129
+ // Skip if there's a composed class (Task 2 handled it)
1130
+ if (classes.some(c => COMPOSED_CLASS_MAP[c])) continue;
1131
+
1132
+ // Skip if dynamic class binding present
1133
+ if (/(?:^|\s):class\b|(?:^|\s)v-bind:class\b/.test(attrsBefore + attrsAfter)) continue;
1134
+
1135
+ // The signature: at least one d-fw-* AND one d-fs-N together (heading-builder pattern)
1136
+ const fwMatch = classes.find(c => /^d-fw-(bold|semibold|medium|normal)$/.test(c));
1137
+ const fsMatch = classes.find(c => /^d-fs-\d+$/.test(c));
1138
+ if (!fwMatch || !fsMatch) continue;
1139
+
1140
+ // Skip if the element was already rewritten/flagged just before this position
1141
+ const beforeTag = content.slice(Math.max(0, m.index - 120), m.index);
1142
+ if (beforeTag.includes('dt-text-migrate:')) continue;
1143
+
1144
+ // Build hint
1145
+ const strength = fwMatch.replace('d-fw-', '');
1146
+ const fsN = fsMatch.replace('d-fs-', '');
1147
+ const fcMatch = classes.find(c => OVERRIDE_CLASS_MAP[c] && OVERRIDE_CLASS_MAP[c].prop === 'tone');
1148
+ const tone = fcMatch ? OVERRIDE_CLASS_MAP[fcMatch].value : null;
1149
+
1150
+ // Decide kind: explicit headings get headline; ambiguous tags get a "verify kind" note
1151
+ const isHeadingTag = /^h[1-6]$/.test(tagName);
1152
+ const kindHint = isHeadingTag ? 'kind=headline' : 'kind=body|label|headline (VERIFY)';
1153
+ const sizeHint = ON_MENU_FS_HINTS[fsN]
1154
+ ? `size: ${ON_MENU_FS_HINTS[fsN]}`
1155
+ : `size: d-fs-${fsN} is off-menu — keep class`;
1156
+
1157
+ const parts = [];
1158
+ if (!isHeadingTag) parts.push(`as=${tagName}`);
1159
+ parts.push(kindHint, sizeHint, `strength=${strength}`);
1160
+ if (tone) parts.push(`tone=${tone}`);
1161
+
1162
+ const hint = `<!-- dt-text-migrate: legacy heading — ${parts.join(' | ')} -->`;
1163
+
1164
+ replacements.push({ start: m.index, end: m.index, replacement: hint });
1165
+ }
1166
+
1167
+ if (replacements.length === 0) return content;
1168
+ replacements.sort((a, b) => b.start - a.start);
1169
+ let out = content;
1170
+ for (const r of replacements) {
1171
+ out = out.slice(0, r.start) + r.replacement + out.slice(r.end);
1172
+ }
1173
+ return out;
1174
+ }
1175
+
1176
+ //------------------------------------------------------------------------------
1177
+ // File utilities
1178
+ //------------------------------------------------------------------------------
1179
+
1180
+ async function findFiles (dir, extensions, ignore = []) {
1181
+ const results = [];
1182
+
1183
+ async function walk (currentDir) {
1184
+ try {
1185
+ const entries = await fs.readdir(currentDir, { withFileTypes: true });
1186
+ for (const entry of entries) {
1187
+ const fullPath = path.join(currentDir, entry.name);
1188
+ if (ignore.some(ig => fullPath.includes(ig))) continue;
1189
+ if (entry.isDirectory()) {
1190
+ await walk(fullPath);
1191
+ } else if (entry.isFile() && extensions.some(ext => entry.name.endsWith(ext))) {
1192
+ results.push(fullPath);
1193
+ }
1194
+ }
1195
+ } catch {
1196
+ // skip unreadable dirs
1197
+ }
1198
+ }
1199
+
1200
+ await walk(dir);
1201
+ return results;
1202
+ }
1203
+
1204
+ async function validateAndResolveFiles (filePaths, extensions) {
1205
+ const resolved = [];
1206
+ for (const fp of filePaths) {
1207
+ const abs = path.isAbsolute(fp) ? fp : path.resolve(process.cwd(), fp);
1208
+ try {
1209
+ const stat = await fs.stat(abs);
1210
+ if (!stat.isFile()) continue;
1211
+ if (!extensions.some(ext => abs.endsWith(ext))) continue;
1212
+ resolved.push(abs);
1213
+ } catch {
1214
+ // ignore missing
1215
+ }
1216
+ }
1217
+ return resolved;
1218
+ }
1219
+
1220
+ //------------------------------------------------------------------------------
1221
+ // Import detection (ported from flex-to-stack)
1222
+ //------------------------------------------------------------------------------
1223
+
1224
+ export function detectImportPathFor (content) {
1225
+ return detectImportPattern(content);
1226
+ }
1227
+
1228
+ function detectImportPattern (content) {
1229
+ if (content.includes('from \'@/components/')) return '@/components/text';
1230
+ if (content.includes('from \'./\'')) return './';
1231
+ if (content.includes('from \'@dialpad/dialtone-vue') || content.includes('from \'@dialpad/dialtone-icons')) {
1232
+ return '@dialpad/dialtone-vue';
1233
+ }
1234
+ return '@/components/text';
1235
+ }
1236
+
1237
+ function detectMissingDtTextImport (content, usesText) {
1238
+ if (!usesText) return null;
1239
+ const hasImport = /import\s+(?:\{[^}]*\bDtText\b[^}]*\}|DtText)\s+from/.test(content);
1240
+ if (hasImport) return null;
1241
+ return {
1242
+ needsImport: true,
1243
+ suggestedPath: detectImportPattern(content),
1244
+ hasComponentsObject: /components:\s*\{/.test(content),
1245
+ };
1246
+ }
1247
+
1248
+ function printImportInstructions (filePath, importCheck) {
1249
+ console.log(log.yellow('\nāš ļø ACTION REQUIRED: Add DtText import and registration'));
1250
+ log.cyan(` File: ${filePath}`);
1251
+ console.log();
1252
+ log.gray(' Add this import to your <script> block:');
1253
+ console.log(log.green(` import { DtText } from '${importCheck.suggestedPath}';`));
1254
+ console.log();
1255
+ if (importCheck.hasComponentsObject) {
1256
+ log.gray(' Add to your components object:');
1257
+ console.log(log.green(' components: {'));
1258
+ console.log(log.green(' // ... existing components'));
1259
+ console.log(log.green(' DtText,'));
1260
+ console.log(log.green(' },'));
1261
+ } else {
1262
+ log.gray(' Create or update your components object:');
1263
+ console.log(log.green(' export default {'));
1264
+ console.log(log.green(' components: { DtText },'));
1265
+ console.log(log.green(' // ... rest of your component'));
1266
+ console.log(log.green(' };'));
1267
+ }
1268
+ console.log();
1269
+ }
1270
+
1271
+ //------------------------------------------------------------------------------
1272
+ // --validate mode: detect post-migration DtText prop bugs
1273
+ //------------------------------------------------------------------------------
1274
+
1275
+ const VALID_KINDS = new Set(['headline', 'body', 'label', 'code']);
1276
+ const VALID_SIZES = new Set([
1277
+ '100', '200', '300', '400', '500', '600', '700',
1278
+ 'xs', 'sm', 'md', 'lg', 'xl', '2xl', '3xl',
1279
+ ]);
1280
+ const VALID_STRENGTHS = new Set(['bold', 'semibold', 'medium', 'normal']);
1281
+ const VALID_DENSITIES = new Set(['100', '200', '300', '400', '500', '600']);
1282
+ const VALID_ALIGNS = new Set(['start', 'center', 'end', 'justify']);
1283
+
1284
+ /**
1285
+ * Scan a file's content for DtText prop bugs. Returns array of issues with line numbers.
1286
+ * Issue types:
1287
+ * - object-syntax — `:tone="{ muted: cond }"` style (CSS-class binding misapplied to prop)
1288
+ * - invalid-value — `density="160"`, `kind="title"`, etc.
1289
+ * - mixed-class — DtText with `class="d-fw-bold"` or another typography utility
1290
+ */
1291
+ export function validateDtTextProps (content) {
1292
+ const issues = [];
1293
+
1294
+ // Compute inert regions (script/style/comments) over the ORIGINAL content so we
1295
+ // can both skip false positives AND keep accurate line numbers. maskInertContent
1296
+ // emits compact tokens that change offsets — using its masked output would produce
1297
+ // wrong line numbers when a large script/comment precedes a real <dt-text> tag.
1298
+ const inertRanges = [];
1299
+ for (const pattern of [/<script[\s\S]*?<\/script>/gi, /<style[\s\S]*?<\/style>/gi, /<!--[\s\S]*?-->/g]) {
1300
+ let im;
1301
+ while ((im = pattern.exec(content)) !== null) {
1302
+ inertRanges.push([im.index, im.index + im[0].length]);
1303
+ }
1304
+ }
1305
+ const isInsideInert = (idx) => inertRanges.some(([s, e]) => idx >= s && idx < e);
1306
+
1307
+ // Quote-aware tag scan so `:title="a > b"` doesn't truncate the attr capture
1308
+ const tagRe = new RegExp(`<(?:dt-text|DtText)\\b(${ATTR_BODY})>`, 'g');
1309
+ let m;
1310
+ while ((m = tagRe.exec(content)) !== null) {
1311
+ if (isInsideInert(m.index)) continue;
1312
+ const attrStr = m[1];
1313
+ const lineNum = content.slice(0, m.index).split('\n').length;
1314
+ const tagSnippet = m[0].length > 120 ? m[0].slice(0, 117) + '...' : m[0];
1315
+
1316
+ // Object syntax detection: any prop bound with `:prop="{...}"`
1317
+ const objectSyntaxRe = /:(\w[\w-]*)="\s*\{/g;
1318
+ let osm;
1319
+ while ((osm = objectSyntaxRe.exec(attrStr)) !== null) {
1320
+ const propName = osm[1];
1321
+ // Only flag known string props (where object syntax is wrong)
1322
+ if (['tone', 'kind', 'size', 'strength', 'density', 'align', 'as'].includes(propName)) {
1323
+ issues.push({
1324
+ type: 'object-syntax',
1325
+ line: lineNum,
1326
+ message: `:${propName}="{ ... }" uses CSS-class syntax — DtText expects a string. Use :${propName}="cond ? 'value' : undefined" instead.`,
1327
+ snippet: tagSnippet,
1328
+ });
1329
+ }
1330
+ }
1331
+
1332
+ // Static-value validation — anchor on whitespace so we don't match `:prop=` (bound)
1333
+ const staticPropRe = /\s(kind|size|strength|density|align)\s*=\s*"([^"]+)"/g;
1334
+ let spm;
1335
+ while ((spm = staticPropRe.exec(attrStr)) !== null) {
1336
+ const [, propName, value] = spm;
1337
+ const validSet = {
1338
+ kind: VALID_KINDS, size: VALID_SIZES, strength: VALID_STRENGTHS,
1339
+ density: VALID_DENSITIES, align: VALID_ALIGNS,
1340
+ }[propName];
1341
+ if (validSet && !validSet.has(value)) {
1342
+ issues.push({
1343
+ type: 'invalid-value',
1344
+ line: lineNum,
1345
+ message: `${propName}="${value}" is not a valid value (expected one of: ${[...validSet].join(', ')}).`,
1346
+ snippet: tagSnippet,
1347
+ });
1348
+ }
1349
+ }
1350
+
1351
+ // Mixed class detection: DtText with class= containing typography utilities
1352
+ const classM = attrStr.match(/\sclass="([^"]*)"/);
1353
+ if (classM) {
1354
+ const offenders = classM[1].split(/\s+/).filter(c => ALL_KNOWN_CLASSES.has(c));
1355
+ if (offenders.length > 0) {
1356
+ issues.push({
1357
+ type: 'mixed-class',
1358
+ line: lineNum,
1359
+ message: `DtText carries typography utility classes [${offenders.join(', ')}] — lift to props instead.`,
1360
+ snippet: tagSnippet,
1361
+ });
1362
+ }
1363
+ }
1364
+ }
1365
+
1366
+ return issues;
1367
+ }
1368
+
1369
+ //------------------------------------------------------------------------------
1370
+ // --remove-markers cleanup mode
1371
+ //------------------------------------------------------------------------------
1372
+
1373
+ export const removeMarkersForTest = removeMarkers;
1374
+
1375
+ function removeMarkers (content) {
1376
+ // Strip dt-text-migrate comment markers.
1377
+ // Own-line: marker + its trailing newline becomes empty (also drop leading indent on that line).
1378
+ // Inline: marker is removed in-place, preserving surrounding whitespace.
1379
+ // Use [\s\S]*? (non-greedy) so the legacy-heading hint's inline `<dt-text ...>` markup
1380
+ // inside the comment doesn't terminate the match early at the first `>`.
1381
+ return content
1382
+ .replace(/^[ \t]*<!--\s*dt-text-migrate:[\s\S]*?-->[ \t]*\n/gm, '')
1383
+ .replace(/<!--\s*dt-text-migrate:[\s\S]*?-->/g, '');
1384
+ }
1385
+
1386
+ //------------------------------------------------------------------------------
1387
+ // File processing
1388
+ //------------------------------------------------------------------------------
1389
+
1390
+ async function prompt (question) {
1391
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1392
+ return new Promise((resolve) => {
1393
+ rl.question(question, (answer) => {
1394
+ rl.close();
1395
+ resolve(answer.toLowerCase().trim());
1396
+ });
1397
+ });
1398
+ }
1399
+
1400
+ async function processFile (filePath, options) {
1401
+ const content = await fs.readFile(filePath, 'utf-8');
1402
+
1403
+ if (options.validate) {
1404
+ // Read-only: scan for post-migration DtText prop bugs
1405
+ const issues = validateDtTextProps(content);
1406
+ if (issues.length === 0) return { changes: 0, needsImport: false, validateIssues: 0 };
1407
+ log.cyan(`\nšŸ“„ ${filePath}`);
1408
+ for (const issue of issues) {
1409
+ const color = issue.type === 'object-syntax' || issue.type === 'invalid-value' ? log.red : log.yellow;
1410
+ console.log(color(` [${issue.type}] line ${issue.line}: ${issue.message}`));
1411
+ log.gray(` ${issue.snippet}`);
1412
+ }
1413
+ return { changes: 0, needsImport: false, validateIssues: issues.length };
1414
+ }
1415
+
1416
+ if (options.removeMarkers) {
1417
+ const cleaned = removeMarkers(content);
1418
+ if (cleaned === content) return { changes: 0, needsImport: false };
1419
+ log.cyan(`\nšŸ“„ ${filePath}`);
1420
+ if (!options.dryRun) {
1421
+ await fs.writeFile(filePath, cleaned, 'utf-8');
1422
+ console.log(log.green(' āœ“ Markers removed'));
1423
+ }
1424
+ return { changes: 1, needsImport: false };
1425
+ }
1426
+
1427
+ if (!FAST_PATH_RE.test(content)) return { changes: 0, needsImport: false };
1428
+
1429
+ const { transformed, notes } = transformContent(content, {
1430
+ filePath: path.relative(options.cwd || process.cwd(), filePath),
1431
+ });
1432
+
1433
+ if (transformed === content) return { changes: 0, needsImport: false };
1434
+
1435
+ log.cyan(`\nšŸ“„ ${filePath}`);
1436
+
1437
+ if (options.dryRun) {
1438
+ log.gray(` Would apply changes`);
1439
+ return { changes: 1, needsImport: false };
1440
+ }
1441
+
1442
+ let shouldApply = options.yes;
1443
+
1444
+ if (!shouldApply) {
1445
+ const answer = await prompt(' Apply? [y]es / [n]o / [a]ll / [q]uit: ');
1446
+ if (answer === 'q' || answer === 'quit') process.exit(0);
1447
+ if (answer === 'a' || answer === 'all') { options.yes = true; shouldApply = true; }
1448
+ if (answer === 'y' || answer === 'yes') shouldApply = true;
1449
+ }
1450
+
1451
+ if (!shouldApply) return { changes: 0, needsImport: false };
1452
+
1453
+ await fs.writeFile(filePath, transformed, 'utf-8');
1454
+ console.log(log.green(' āœ“ Saved'));
1455
+
1456
+ // Only warn about missing DtText import when we actually INSERTED new <dt-text> elements
1457
+ // — review-marker-only changes (eyebrow/d-code--sm/d-fs-* flags) don't require an import.
1458
+ // Use a count delta (not boolean presence) so partial migrations on a file that already
1459
+ // had a <dt-text> still warn when more are added.
1460
+ const beforeCount = (content.match(/<dt-text\b/g) || []).length;
1461
+ const afterCount = (transformed.match(/<dt-text\b/g) || []).length;
1462
+ const addedDtText = afterCount > beforeCount;
1463
+ const importCheck = detectMissingDtTextImport(transformed, addedDtText);
1464
+ if (importCheck?.needsImport) printImportInstructions(filePath, importCheck);
1465
+
1466
+ if (notes.length > 0) {
1467
+ for (const note of notes) log.gray(` ℹ ${note}`);
1468
+ }
1469
+
1470
+ return { changes: 1, needsImport: !!importCheck?.needsImport };
1471
+ }
1472
+
1473
+ //------------------------------------------------------------------------------
1474
+ // Argument parsing
1475
+ //------------------------------------------------------------------------------
1476
+
1477
+ function parseArgs () {
1478
+ const args = process.argv.slice(2);
1479
+ const options = {
1480
+ cwd: process.cwd(),
1481
+ dryRun: false,
1482
+ yes: false,
1483
+ extensions: ['.vue', '.html'],
1484
+ files: [],
1485
+ removeMarkers: false,
1486
+ validate: false,
1487
+ };
1488
+
1489
+ for (let i = 0; i < args.length; i++) {
1490
+ const arg = args[i];
1491
+
1492
+ if (arg === '--help' || arg === '-h') {
1493
+ console.log(`
1494
+ Usage: npx dialtone-migrate-typography [options]
1495
+
1496
+ Migrates legacy typography utility classes to <dt-text> components.
1497
+
1498
+ Options:
1499
+ --cwd <path> Working directory (default: current directory)
1500
+ --file <path> Specific file to process (repeatable)
1501
+ --dry-run Show changes without applying them
1502
+ --yes, -y Apply all changes without prompting
1503
+ --remove-markers Strip all <!-- dt-text-migrate: review ... --> comments
1504
+ --validate Read-only mode: scan existing <dt-text> for prop bugs
1505
+ (object syntax, invalid values, mixed CSS classes)
1506
+ --help, -h Show help
1507
+
1508
+ Examples:
1509
+ npx dialtone-migrate-typography --dry-run --cwd ./src
1510
+ npx dialtone-migrate-typography --yes
1511
+ npx dialtone-migrate-typography --file src/Foo.vue --dry-run
1512
+ npx dialtone-migrate-typography --remove-markers --cwd ./src
1513
+
1514
+ Post-Migration Steps:
1515
+ 1. Add DtText imports as instructed by the script
1516
+ 2. Review files marked with <!-- dt-text-migrate: review --> comments
1517
+ 3. Run with --remove-markers to clean up all markers after manual review
1518
+ `);
1519
+ process.exit(0);
1520
+ }
1521
+
1522
+ if (arg === '--cwd' && args[i + 1]) {
1523
+ options.cwd = path.resolve(args[++i]);
1524
+ } else if (arg === '--dry-run') {
1525
+ options.dryRun = true;
1526
+ } else if (arg === '--yes' || arg === '-y') {
1527
+ options.yes = true;
1528
+ } else if (arg === '--file' && args[i + 1]) {
1529
+ options.files.push(args[++i]);
1530
+ } else if (arg === '--remove-markers') {
1531
+ options.removeMarkers = true;
1532
+ } else if (arg === '--validate') {
1533
+ options.validate = true;
1534
+ }
1535
+ }
1536
+
1537
+ return options;
1538
+ }
1539
+
1540
+ //------------------------------------------------------------------------------
1541
+ // Main
1542
+ //------------------------------------------------------------------------------
1543
+
1544
+ async function main () {
1545
+ const options = parseArgs();
1546
+
1547
+ log.bold('\nšŸ”„ Typography Migration Tool\n');
1548
+
1549
+ if (options.removeMarkers) {
1550
+ log.gray('Mode: Remove dt-text-migrate markers');
1551
+ } else if (options.dryRun) {
1552
+ console.log(log.yellow('DRY RUN - no files will be modified'));
1553
+ } else if (options.yes) {
1554
+ console.log(log.yellow('AUTO-APPLY - all changes applied without prompts'));
1555
+ }
1556
+
1557
+ let files;
1558
+ if (options.files.length > 0) {
1559
+ files = await validateAndResolveFiles(options.files, options.extensions);
1560
+ } else {
1561
+ log.gray(`Working directory: ${options.cwd}`);
1562
+ files = await findFiles(options.cwd, options.extensions, ['node_modules', 'dist', 'coverage', '.git']);
1563
+ }
1564
+
1565
+ log.gray(`Found ${files.length} file(s) to scan\n`);
1566
+
1567
+ if (files.length === 0) {
1568
+ console.log(log.yellow('No files found.'));
1569
+ return;
1570
+ }
1571
+
1572
+ let totalChanges = 0;
1573
+ let totalValidateIssues = 0;
1574
+ let filesWithIssues = 0;
1575
+ let filesNeedingImports = 0;
1576
+ const importList = [];
1577
+
1578
+ for (const file of files) {
1579
+ const result = await processFile(file, { ...options });
1580
+ totalChanges += result.changes;
1581
+ if (result.validateIssues) {
1582
+ totalValidateIssues += result.validateIssues;
1583
+ filesWithIssues++;
1584
+ }
1585
+ if (result.needsImport) {
1586
+ filesNeedingImports++;
1587
+ importList.push(file);
1588
+ }
1589
+ }
1590
+
1591
+ log.bold('\nšŸ“Š Summary\n');
1592
+ console.log(` Files scanned: ${files.length}`);
1593
+ if (options.validate) {
1594
+ console.log(` Files with issues: ${filesWithIssues}`);
1595
+ console.log(` Total issues: ${totalValidateIssues}`);
1596
+ if (totalValidateIssues === 0) console.log(log.green(' āœ“ No DtText prop bugs detected'));
1597
+ } else if (options.removeMarkers) {
1598
+ console.log(` Files cleaned: ${totalChanges}`);
1599
+ } else {
1600
+ console.log(` Files changed: ${totalChanges}`);
1601
+ }
1602
+
1603
+ if (filesNeedingImports > 0 && !options.dryRun) {
1604
+ console.log(log.yellow(`\nāš ļø ${filesNeedingImports} file(s) need DtText import/registration`));
1605
+ importList.forEach(f => log.gray(` [ ] ${f}`));
1606
+ }
1607
+
1608
+ if (options.dryRun && totalChanges > 0) {
1609
+ console.log(log.yellow('\n Run without --dry-run to apply changes.'));
1610
+ }
1611
+
1612
+ console.log();
1613
+ }
1614
+
1615
+ const isDirectRun = (() => {
1616
+ try {
1617
+ return realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
1618
+ } catch {
1619
+ return false;
1620
+ }
1621
+ })();
1622
+
1623
+ if (isDirectRun) {
1624
+ main().catch((error) => {
1625
+ console.error(`${colors.red}Error:${colors.reset}`, error.message);
1626
+ process.exit(1);
1627
+ });
1628
+ }