@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.
- package/lib/build/js/dialtone_migrate_border_radius/index.mjs +273 -0
- package/lib/build/js/dialtone_migrate_border_radius/test.mjs +422 -0
- package/lib/build/js/dialtone_migrate_typography/index.mjs +1628 -0
- package/lib/build/js/dialtone_migrate_typography/test.mjs +1020 -0
- package/lib/build/js/dialtone_migration_helper/configs/theme-to-mode.mjs +108 -0
- package/lib/build/js/dialtone_migration_helper/tests/theme-to-mode-test-examples.vue +24 -0
- package/lib/build/js/dialtone_migration_helper/tests/theme-to-mode.test.mjs +177 -0
- package/lib/build/less/components/button.less +2 -0
- package/lib/build/less/components/emoji-picker.less +10 -11
- package/lib/build/less/components/forms.less +22 -16
- package/lib/build/less/components/modal.less +8 -2
- package/lib/build/less/components/notice.less +4 -0
- package/lib/build/less/components/popover.less +1 -1
- package/lib/build/less/components/presence.less +23 -3
- package/lib/build/less/recipes/leftbar_row.less +1 -0
- package/lib/dist/dialtone-default-theme.css +67 -34
- package/lib/dist/dialtone-default-theme.min.css +1 -1
- package/lib/dist/dialtone-docs.json +1 -1
- package/lib/dist/dialtone.css +66 -34
- package/lib/dist/dialtone.min.css +1 -1
- package/lib/dist/js/dialtone_migrate_border_radius/index.mjs +273 -0
- package/lib/dist/js/dialtone_migrate_border_radius/test.mjs +422 -0
- package/lib/dist/js/dialtone_migrate_typography/index.mjs +1628 -0
- package/lib/dist/js/dialtone_migrate_typography/test.mjs +1020 -0
- package/lib/dist/js/dialtone_migration_helper/configs/theme-to-mode.mjs +108 -0
- package/lib/dist/js/dialtone_migration_helper/tests/theme-to-mode-test-examples.vue +24 -0
- package/lib/dist/js/dialtone_migration_helper/tests/theme-to-mode.test.mjs +177 -0
- package/lib/dist/tokens/tokens-101-dark.css +1 -0
- package/lib/dist/tokens/tokens-101-light.css +1 -0
- package/lib/dist/tokens/tokens-102-dark.css +1 -0
- package/lib/dist/tokens/tokens-102-light.css +1 -0
- package/lib/dist/tokens/tokens-103-dark.css +1 -0
- package/lib/dist/tokens/tokens-103-light.css +1 -0
- package/lib/dist/tokens/tokens-104-dark.css +1 -0
- package/lib/dist/tokens/tokens-104-light.css +1 -0
- package/lib/dist/tokens/tokens-105-dark.css +1 -0
- package/lib/dist/tokens/tokens-105-light.css +1 -0
- package/lib/dist/tokens/tokens-106-dark.css +1 -0
- package/lib/dist/tokens/tokens-106-light.css +1 -0
- package/lib/dist/tokens/tokens-107-dark.css +1 -0
- package/lib/dist/tokens/tokens-107-light.css +1 -0
- package/lib/dist/tokens/tokens-108-dark.css +1 -0
- package/lib/dist/tokens/tokens-108-light.css +1 -0
- package/lib/dist/tokens/tokens-109-dark.css +1 -0
- package/lib/dist/tokens/tokens-109-light.css +1 -0
- package/lib/dist/tokens/tokens-110-dark.css +1 -0
- package/lib/dist/tokens/tokens-110-light.css +1 -0
- package/lib/dist/tokens/tokens-111-dark.css +1 -0
- package/lib/dist/tokens/tokens-111-light.css +1 -0
- package/lib/dist/tokens/tokens-112-dark.css +1 -0
- package/lib/dist/tokens/tokens-112-light.css +1 -0
- package/lib/dist/tokens/tokens-113-dark.css +1 -0
- package/lib/dist/tokens/tokens-113-light.css +1 -0
- package/lib/dist/tokens/tokens-114-dark.css +1 -0
- package/lib/dist/tokens/tokens-114-light.css +1 -0
- package/lib/dist/tokens/tokens-115-dark.css +1 -0
- package/lib/dist/tokens/tokens-115-light.css +1 -0
- package/lib/dist/tokens/tokens-116-dark.css +1 -0
- package/lib/dist/tokens/tokens-116-light.css +1 -0
- package/lib/dist/tokens/tokens-117-dark.css +1 -0
- package/lib/dist/tokens/tokens-117-light.css +1 -0
- package/lib/dist/tokens/tokens-118-dark.css +1 -0
- package/lib/dist/tokens/tokens-118-light.css +1 -0
- package/lib/dist/tokens/tokens-119-dark.css +1 -0
- package/lib/dist/tokens/tokens-119-light.css +1 -0
- package/lib/dist/tokens/tokens-120-dark.css +1 -0
- package/lib/dist/tokens/tokens-120-light.css +1 -0
- package/lib/dist/tokens/tokens-121-dark.css +1 -0
- package/lib/dist/tokens/tokens-121-light.css +1 -0
- package/lib/dist/tokens/tokens-122-dark.css +1 -0
- package/lib/dist/tokens/tokens-122-light.css +1 -0
- package/lib/dist/tokens/tokens-123-dark.css +1 -0
- package/lib/dist/tokens/tokens-123-light.css +1 -0
- package/lib/dist/tokens/tokens-124-dark.css +1 -0
- package/lib/dist/tokens/tokens-124-light.css +1 -0
- package/lib/dist/tokens/tokens-125-dark.css +1 -0
- package/lib/dist/tokens/tokens-125-light.css +1 -0
- package/lib/dist/tokens/tokens-126-dark.css +1 -0
- package/lib/dist/tokens/tokens-126-light.css +1 -0
- package/lib/dist/tokens/tokens-127-dark.css +1 -0
- package/lib/dist/tokens/tokens-127-light.css +1 -0
- package/lib/dist/tokens/tokens-128-dark.css +1 -0
- package/lib/dist/tokens/tokens-128-light.css +1 -0
- package/lib/dist/tokens/tokens-129-dark.css +1 -0
- package/lib/dist/tokens/tokens-129-light.css +1 -0
- package/lib/dist/tokens/tokens-130-dark.css +1 -0
- package/lib/dist/tokens/tokens-130-light.css +1 -0
- package/lib/dist/tokens/tokens-131-dark.css +1 -0
- package/lib/dist/tokens/tokens-131-light.css +1 -0
- package/lib/dist/tokens/tokens-132-dark.css +1 -0
- package/lib/dist/tokens/tokens-132-light.css +1 -0
- package/lib/dist/tokens/tokens-133-dark.css +1 -0
- package/lib/dist/tokens/tokens-133-light.css +1 -0
- package/lib/dist/tokens/tokens-134-dark.css +1 -0
- package/lib/dist/tokens/tokens-134-light.css +1 -0
- package/lib/dist/tokens/tokens-135-dark.css +1 -0
- package/lib/dist/tokens/tokens-135-light.css +1 -0
- package/lib/dist/tokens/tokens-136-dark.css +1 -0
- package/lib/dist/tokens/tokens-136-light.css +1 -0
- package/lib/dist/tokens/tokens-137-dark.css +1 -0
- package/lib/dist/tokens/tokens-137-light.css +1 -0
- package/lib/dist/tokens/tokens-aegean-dark.css +1 -0
- package/lib/dist/tokens/tokens-aegean-light.css +1 -0
- package/lib/dist/tokens/tokens-botany-dark.css +1 -0
- package/lib/dist/tokens/tokens-botany-light.css +1 -0
- package/lib/dist/tokens/tokens-buttercream-dark.css +1 -0
- package/lib/dist/tokens/tokens-buttercream-light.css +1 -0
- package/lib/dist/tokens/tokens-ceruleo-dark.css +1 -0
- package/lib/dist/tokens/tokens-ceruleo-light.css +1 -0
- package/lib/dist/tokens/tokens-debug-dp.css +1 -0
- package/lib/dist/tokens/tokens-dp-dark.css +1 -0
- package/lib/dist/tokens/tokens-dp-light.css +1 -0
- package/lib/dist/tokens/tokens-expressive-dark.css +1 -0
- package/lib/dist/tokens/tokens-expressive-light.css +1 -0
- package/lib/dist/tokens/tokens-expressive-sm-dark.css +1 -0
- package/lib/dist/tokens/tokens-expressive-sm-light.css +1 -0
- package/lib/dist/tokens/tokens-high-desert-dark.css +1 -0
- package/lib/dist/tokens/tokens-high-desert-light.css +1 -0
- package/lib/dist/tokens/tokens-melon-dark.css +1 -0
- package/lib/dist/tokens/tokens-melon-light.css +1 -0
- package/lib/dist/tokens/tokens-plum-dark.css +1 -0
- package/lib/dist/tokens/tokens-plum-light.css +1 -0
- package/lib/dist/tokens/tokens-prota-deuter-dark.css +1 -0
- package/lib/dist/tokens/tokens-prota-deuter-light.css +1 -0
- package/lib/dist/tokens/tokens-sunflower-dark.css +1 -0
- package/lib/dist/tokens/tokens-sunflower-light.css +1 -0
- package/lib/dist/tokens/tokens-tmo-dark.css +1 -0
- package/lib/dist/tokens/tokens-tmo-light.css +1 -0
- package/lib/dist/tokens/tokens-trita-dark.css +1 -0
- package/lib/dist/tokens/tokens-trita-light.css +1 -0
- package/lib/dist/tokens/tokens-verdant-haze-dark.css +1 -0
- package/lib/dist/tokens/tokens-verdant-haze-light.css +1 -0
- package/lib/dist/tokens-docs.json +1 -1
- 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
|
+
}
|