@maggioli-design-system/magma-codemods 2.0.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +193 -0
  2. package/dist/cli.d.ts +2 -0
  3. package/dist/cli.js +77 -0
  4. package/dist/generate/diff-docs.d.ts +36 -0
  5. package/dist/generate/diff-docs.js +180 -0
  6. package/dist/index.d.ts +39 -0
  7. package/dist/index.js +166 -0
  8. package/dist/manifest/manifest.d.ts +3 -0
  9. package/dist/manifest/manifest.generated.d.ts +2 -0
  10. package/dist/manifest/manifest.generated.js +1901 -0
  11. package/dist/manifest/manifest.js +546 -0
  12. package/dist/manifest/registry.d.ts +32 -0
  13. package/dist/manifest/registry.js +102 -0
  14. package/dist/manifest/schema.d.ts +245 -0
  15. package/dist/manifest/schema.js +9 -0
  16. package/dist/report/diff.d.ts +2 -0
  17. package/dist/report/diff.js +95 -0
  18. package/dist/report/reporter.d.ts +27 -0
  19. package/dist/report/reporter.js +118 -0
  20. package/dist/report/types.d.ts +51 -0
  21. package/dist/report/types.js +2 -0
  22. package/dist/surfaces/angular.d.ts +3 -0
  23. package/dist/surfaces/angular.js +489 -0
  24. package/dist/surfaces/css.d.ts +5 -0
  25. package/dist/surfaces/css.js +287 -0
  26. package/dist/surfaces/html.d.ts +3 -0
  27. package/dist/surfaces/html.js +402 -0
  28. package/dist/surfaces/inline-templates.d.ts +3 -0
  29. package/dist/surfaces/inline-templates.js +98 -0
  30. package/dist/surfaces/react.d.ts +3 -0
  31. package/dist/surfaces/react.js +496 -0
  32. package/dist/surfaces/shared/attribute-ops.d.ts +56 -0
  33. package/dist/surfaces/shared/attribute-ops.js +62 -0
  34. package/dist/surfaces/shared/class-ops.d.ts +55 -0
  35. package/dist/surfaces/shared/class-ops.js +91 -0
  36. package/dist/surfaces/shared/edits.d.ts +19 -0
  37. package/dist/surfaces/shared/edits.js +30 -0
  38. package/dist/surfaces/shared/negate.d.ts +13 -0
  39. package/dist/surfaces/shared/negate.js +51 -0
  40. package/dist/surfaces/shared/transform.d.ts +17 -0
  41. package/dist/surfaces/shared/transform.js +8 -0
  42. package/dist/surfaces/shared/value-model.d.ts +49 -0
  43. package/dist/surfaces/shared/value-model.js +18 -0
  44. package/package.json +61 -0
@@ -0,0 +1,287 @@
1
+ /**
2
+ * CSS / SCSS surface (category G — CSS custom properties, category H — shadow
3
+ * parts in `::part()` selectors, and category J — utility classes in `@apply`
4
+ * at-rules). Uses postcss (+ postcss-scss) and mutates the AST in place so
5
+ * formatting is preserved.
6
+ *
7
+ * Custom-property *definitions* are renamed and, when the value format changed
8
+ * (e.g. hex → `R G B` channels for `-rgb` variables), flagged for manual
9
+ * conversion. `var(--x)` *references* are renamed everywhere they appear.
10
+ * Definitions/references of properties removed in v2 with no replacement
11
+ * (G2, `cssVarRemove`) are reported, not rewritten.
12
+ */
13
+ import postcss from 'postcss';
14
+ import postcssScss from 'postcss-scss';
15
+ import { ruleId, tagRenamesOf } from '../manifest/registry.js';
16
+ import { classRulesOf, hasClassRules, rewriteClassList } from './shared/class-ops.js';
17
+ import { ruleEnabled } from './shared/transform.js';
18
+ /**
19
+ * Tag used for the manifest-global CSS-var rules (the `--tone-*-seed` rename and
20
+ * the surface-candidate reports), which no component owns. They surface in
21
+ * `--only` / `--skip` as `global/cssVarRename/...` and
22
+ * `global/cssVarSurfaceReport/...`.
23
+ */
24
+ const GLOBAL_TAG = 'global';
25
+ /**
26
+ * A neutral tone used as a *background* is a surface candidate (spec 12): the
27
+ * `background` / `background-color` property, or a custom property whose name
28
+ * carries `background` (component `--mds-*-background*` tokens).
29
+ */
30
+ const isBackgroundContext = (prop) => prop === 'background' ||
31
+ prop === 'background-color' ||
32
+ (prop.startsWith('--') && prop.includes('background'));
33
+ const collectRules = (manifest) => {
34
+ const cssVars = new Map();
35
+ const removedVars = new Map();
36
+ const surfaceReports = new Map();
37
+ const parts = new Map();
38
+ for (const component of Object.values(manifest.components)) {
39
+ for (const rule of component.rules) {
40
+ if (rule.kind === 'cssVarRename')
41
+ cssVars.set(rule.from, { rule, id: ruleId(component.tag, rule) });
42
+ else if (rule.kind === 'cssVarRemove')
43
+ removedVars.set(rule.name, { rule, id: ruleId(component.tag, rule) });
44
+ else if (rule.kind === 'partRename')
45
+ parts.set(rule.from, { rule, id: ruleId(component.tag, rule) });
46
+ }
47
+ }
48
+ // Manifest-global CSS-var migrations (not owned by any component): the seed
49
+ // rename (rewritten) and the surface-candidate reports (reported only).
50
+ for (const rule of manifest.global.cssVars ?? []) {
51
+ if (rule.kind === 'cssVarRename')
52
+ cssVars.set(rule.from, { rule, id: ruleId(GLOBAL_TAG, rule) });
53
+ else
54
+ surfaceReports.set(rule.from, { rule, id: ruleId(GLOBAL_TAG, rule) });
55
+ }
56
+ return { cssVars, removedVars, surfaceReports, parts };
57
+ };
58
+ const PART_RE = /::part\(\s*([\w-]+)\s*\)/g;
59
+ /**
60
+ * A class (`.name`, also compounded: `html.name`) or a type selector (`name`) in
61
+ * a selector. A type selector is a name that no `.`, `#`, `-`, word character,
62
+ * `[`, `=`, quote or `:` precedes, so a class, an id, an attribute, a
63
+ * pseudo-class and the tail of a longer name are left alone.
64
+ */
65
+ const SELECTOR_NAME_RE = /\.([\w-]+)|(?<![\w.#\-[="':])([a-zA-Z][\w-]*)/g;
66
+ const VAR_TOKEN_RE = /--[\w-]+/g;
67
+ export const transformCss = (source, manifest, ctx, options = {}) => {
68
+ const { cssVars, removedVars, surfaceReports, parts } = collectRules(manifest);
69
+ const findings = [];
70
+ let changed = false;
71
+ /** Warn (once per occurrence) when a removed custom property is defined or referenced. */
72
+ const warnRemoved = (name, line) => {
73
+ const entry = removedVars.get(name);
74
+ if (!entry || !ruleEnabled(ctx, entry.id))
75
+ return;
76
+ findings.push({
77
+ kind: 'warn',
78
+ surface: 'css',
79
+ file: ctx.file,
80
+ line,
81
+ ruleId: entry.id,
82
+ message: entry.rule.message,
83
+ });
84
+ };
85
+ const root = options.scss
86
+ ? postcssScss.parse(source, { from: ctx.file })
87
+ : postcss.parse(source, { from: ctx.file });
88
+ const finding = (entry, line, extra) => ({
89
+ kind: 'change',
90
+ surface: 'css',
91
+ file: ctx.file,
92
+ line,
93
+ ruleId: entry.id,
94
+ ...extra,
95
+ });
96
+ root.walkDecls((decl) => {
97
+ const line = decl.source?.start?.line;
98
+ // Custom-property definition site: `--mds-banner-color: #fff;`.
99
+ if (decl.prop.startsWith('--')) {
100
+ const name = decl.prop.slice(2);
101
+ warnRemoved(name, line);
102
+ const entry = cssVars.get(name);
103
+ if (entry && ruleEnabled(ctx, entry.id)) {
104
+ const before = decl.prop;
105
+ decl.prop = `--${entry.rule.to}`;
106
+ changed = true;
107
+ findings.push(finding(entry, line, { message: `rename custom property`, before, after: decl.prop }));
108
+ if (entry.rule.valueFormatChanged) {
109
+ findings.push({
110
+ kind: 'flag',
111
+ surface: 'css',
112
+ file: ctx.file,
113
+ line,
114
+ ruleId: entry.id,
115
+ message: `value format changed (${entry.rule.note ?? 'e.g. hex → "R G B" channels'}): convert the value of \`${decl.prop}\` by hand`,
116
+ });
117
+ }
118
+ else if (entry.rule.note) {
119
+ findings.push({
120
+ kind: 'flag',
121
+ surface: 'css',
122
+ file: ctx.file,
123
+ line,
124
+ ruleId: entry.id,
125
+ message: entry.rule.note,
126
+ });
127
+ }
128
+ }
129
+ }
130
+ // `var(--x)` references inside any value. A surface candidate used as a
131
+ // background is REPORTED, not rewritten (the exact role is contextual); this
132
+ // takes precedence over the seed rename so a background is never
133
+ // seed-renamed. Everything else follows the plain rename.
134
+ if (decl.value && decl.value.includes('--')) {
135
+ for (const token of decl.value.match(VAR_TOKEN_RE) ?? [])
136
+ warnRemoved(token.slice(2), line);
137
+ const backgroundContext = isBackgroundContext(decl.prop);
138
+ const newValue = decl.value.replace(VAR_TOKEN_RE, (token) => {
139
+ const name = token.slice(2);
140
+ const report = backgroundContext ? surfaceReports.get(name) : undefined;
141
+ if (report && ruleEnabled(ctx, report.id)) {
142
+ findings.push({
143
+ kind: 'warn',
144
+ surface: 'css',
145
+ file: ctx.file,
146
+ line,
147
+ ruleId: report.id,
148
+ message: `\`${token}\` is used as a background here; migrate it by hand to a semantic surface role (\`--magma-surface-*\`: default / raised / overlay / sunken / muted)${report.rule.note ? ` (${report.rule.note})` : ''}`,
149
+ });
150
+ return token;
151
+ }
152
+ const entry = cssVars.get(name);
153
+ if (entry && ruleEnabled(ctx, entry.id)) {
154
+ changed = true;
155
+ findings.push(finding(entry, line, {
156
+ message: `rename custom property reference`,
157
+ before: token,
158
+ after: `--${entry.rule.to}`,
159
+ }));
160
+ return `--${entry.rule.to}`;
161
+ }
162
+ return token;
163
+ });
164
+ if (newValue !== decl.value)
165
+ decl.value = newValue;
166
+ }
167
+ });
168
+ // Utility-class migrations (J) in `@apply` at-rules (Tailwind CSS sources).
169
+ const classRules = classRulesOf(manifest);
170
+ if (hasClassRules(classRules)) {
171
+ root.walkAtRules('apply', (atRule) => {
172
+ const line = atRule.source?.start?.line;
173
+ const result = rewriteClassList(atRule.params, classRules, (id) => ruleEnabled(ctx, id), (entry, before, after) => {
174
+ findings.push({
175
+ kind: 'change',
176
+ surface: 'css',
177
+ file: ctx.file,
178
+ line,
179
+ ruleId: entry.id,
180
+ message: 'rename utility class',
181
+ before,
182
+ after,
183
+ });
184
+ if (entry.rule.note) {
185
+ findings.push({
186
+ kind: 'flag',
187
+ surface: 'css',
188
+ file: ctx.file,
189
+ line,
190
+ ruleId: entry.id,
191
+ message: entry.rule.note,
192
+ });
193
+ }
194
+ }, (entry, token) => {
195
+ findings.push({
196
+ kind: 'warn',
197
+ surface: 'css',
198
+ file: ctx.file,
199
+ line,
200
+ ruleId: entry.id,
201
+ message: `\`${token}\`: ${entry.rule.message}`,
202
+ });
203
+ });
204
+ if (result.changed) {
205
+ atRule.params = result.value;
206
+ changed = true;
207
+ }
208
+ });
209
+ }
210
+ root.walkRules((rule) => {
211
+ if (!rule.selector.includes('::part('))
212
+ return;
213
+ const line = rule.source?.start?.line;
214
+ const newSelector = rule.selector.replace(PART_RE, (match, partName) => {
215
+ const entry = parts.get(partName);
216
+ if (entry && ruleEnabled(ctx, entry.id)) {
217
+ changed = true;
218
+ findings.push(finding(entry, line, {
219
+ message: `rename shadow part`,
220
+ before: `::part(${partName})`,
221
+ after: `::part(${entry.rule.to})`,
222
+ }));
223
+ return `::part(${entry.rule.to})`;
224
+ }
225
+ return match;
226
+ });
227
+ if (newSelector !== rule.selector)
228
+ rule.selector = newSelector;
229
+ });
230
+ // K and J in selectors: a renamed element (`mds-pref-theme`) as a type
231
+ // selector, and a renamed state class that consumer stylesheets select
232
+ // (`.pref-theme-dark`, only the class rules with `selectors: true`). One
233
+ // lookup per token, so a v1 name that another one takes in v2 is renamed
234
+ // once.
235
+ const tagRenames = tagRenamesOf(manifest);
236
+ const selectorClasses = [...classRules.renames.values()].filter((e) => e.rule.selectors);
237
+ if (tagRenames.size > 0 || selectorClasses.length > 0) {
238
+ const classByName = new Map(selectorClasses.map((e) => [e.rule.from, e]));
239
+ root.walkRules((rule) => {
240
+ const line = rule.source?.start?.line;
241
+ const newSelector = rule.selector.replace(SELECTOR_NAME_RE, (match, className, typeName) => {
242
+ if (className !== undefined) {
243
+ const name = className;
244
+ const entry = classByName.get(name);
245
+ if (!entry || !ruleEnabled(ctx, entry.id))
246
+ return match;
247
+ findings.push({
248
+ kind: 'change',
249
+ surface: 'css',
250
+ file: ctx.file,
251
+ line,
252
+ ruleId: entry.id,
253
+ message: 'rename class selector',
254
+ before: `.${name}`,
255
+ after: `.${entry.rule.to}`,
256
+ });
257
+ return `.${entry.rule.to}`;
258
+ }
259
+ const name = typeName;
260
+ const tagRule = tagRenames.get(name);
261
+ const id = `${name}/tagRename`;
262
+ if (!tagRule || !ruleEnabled(ctx, id))
263
+ return match;
264
+ findings.push({
265
+ kind: 'change',
266
+ surface: 'css',
267
+ file: ctx.file,
268
+ line,
269
+ component: name,
270
+ ruleId: id,
271
+ message: 'rename type selector',
272
+ before: name,
273
+ after: tagRule.to,
274
+ });
275
+ return tagRule.to;
276
+ });
277
+ if (newSelector !== rule.selector) {
278
+ rule.selector = newSelector;
279
+ changed = true;
280
+ }
281
+ });
282
+ }
283
+ if (!changed)
284
+ return { output: source, changed: false, findings };
285
+ const output = options.scss ? root.toString(postcssScss) : root.toString();
286
+ return { output, changed: true, findings };
287
+ };
@@ -0,0 +1,3 @@
1
+ import { type Manifest } from '../manifest/schema.js';
2
+ import { type TransformContext, type TransformResult } from './shared/transform.js';
3
+ export declare const transformHtml: (source: string, manifest: Manifest, ctx: TransformContext) => TransformResult;
@@ -0,0 +1,402 @@
1
+ /**
2
+ * HTML surface. Parses with parse5 (location info on) and applies offset-based
3
+ * edits to the original source so formatting and quoting are preserved.
4
+ *
5
+ * Handles, for `mds-*` elements: boolean inversion (B), prop rename (D), prop
6
+ * removal (C, inline comment), enum remap (A/E), the preferred slot→attr
7
+ * lift (F2, e.g. `mds-button` text → `label`), and removed named slots (F,
8
+ * children using them are reported). The global `slot="default"` removal (F)
9
+ * applies to any element.
10
+ */
11
+ import { parseFragment } from 'parse5';
12
+ import { getByTag, ruleId, rulesForComponent } from '../manifest/registry.js';
13
+ import { classRulesOf, hasClassRules, rewriteClassList } from './shared/class-ops.js';
14
+ import { invertBoolean, remapEnum } from './shared/attribute-ops.js';
15
+ import { bareTrue, stringLiteral } from './shared/value-model.js';
16
+ import { applyEdits, lineIndentAt } from './shared/edits.js';
17
+ import { ruleEnabled } from './shared/transform.js';
18
+ const isElement = (node) => Array.isArray(node.attrs);
19
+ const escapeHtmlAttr = (s) => s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
20
+ const collapseWhitespace = (s) => s.replace(/\s+/g, ' ').trim();
21
+ const parseRawAttr = (raw) => {
22
+ const eq = raw.indexOf('=');
23
+ if (eq === -1)
24
+ return { name: raw.trim(), hasValue: false, quote: '"' };
25
+ const rest = raw.slice(eq + 1).trim();
26
+ const quote = rest[0] === "'" || rest[0] === '"' ? rest[0] : '"';
27
+ return { name: raw.slice(0, eq).trim(), hasValue: true, quote };
28
+ };
29
+ export const transformHtml = (source, manifest, ctx) => {
30
+ const fragment = parseFragment(source, { sourceCodeLocationInfo: true });
31
+ const edits = [];
32
+ const findings = [];
33
+ const classRules = classRulesOf(manifest);
34
+ const pushFinding = (f) => {
35
+ findings.push(f);
36
+ };
37
+ /** Remove an attribute (and one leading space) by its source location. */
38
+ const removeAttrEdit = (loc) => {
39
+ const start = loc.startOffset > 0 && source[loc.startOffset - 1] === ' '
40
+ ? loc.startOffset - 1
41
+ : loc.startOffset;
42
+ return { start, end: loc.endOffset, text: '' };
43
+ };
44
+ const visit = (node) => {
45
+ if (isElement(node))
46
+ processElement(node);
47
+ for (const child of node.childNodes ?? [])
48
+ visit(child);
49
+ if (node.content)
50
+ visit(node.content); // <template>
51
+ };
52
+ const processElement = (node) => {
53
+ const tag = node.tagName ?? '';
54
+ const loc = node.sourceCodeLocation;
55
+ const attrLocs = loc?.attrs ?? {};
56
+ const attrs = node.attrs ?? [];
57
+ const hasAttr = (name) => attrs.some((a) => a.name === name);
58
+ const getAttr = (name) => attrs.find((a) => a.name === name);
59
+ // Global: remove `slot="default"` on any element.
60
+ if (manifest.global.removeDefaultSlot) {
61
+ const slot = getAttr('slot');
62
+ if (slot && slot.value === 'default' && ruleEnabled(ctx, 'global/removeDefaultSlot')) {
63
+ const l = attrLocs['slot'];
64
+ if (l) {
65
+ edits.push(removeAttrEdit(l));
66
+ pushFinding({
67
+ kind: 'change',
68
+ surface: 'html',
69
+ file: ctx.file,
70
+ line: l.startLine,
71
+ ruleId: 'global/removeDefaultSlot',
72
+ message: 'remove slot="default"',
73
+ before: 'slot="default"',
74
+ after: '',
75
+ });
76
+ }
77
+ }
78
+ }
79
+ // Global: utility-class migrations (J) on any element's `class` attribute.
80
+ // The raw source value is rewritten (not the entity-decoded one), so
81
+ // arbitrary variants written with references (`[&gt;li]:`) round-trip.
82
+ if (hasClassRules(classRules)) {
83
+ const l = attrLocs['class'];
84
+ if (l && hasAttr('class')) {
85
+ const raw = source.slice(l.startOffset, l.endOffset);
86
+ const parsed = parseRawAttr(raw);
87
+ if (parsed.hasValue) {
88
+ const open = raw.indexOf(parsed.quote) + 1;
89
+ const close = raw.lastIndexOf(parsed.quote);
90
+ const rawValue = open > 0 && close > open ? raw.slice(open, close) : '';
91
+ const result = rewriteClassList(rawValue, classRules, (id) => ruleEnabled(ctx, id), (entry, before, after) => {
92
+ pushFinding({
93
+ kind: 'change',
94
+ surface: 'html',
95
+ file: ctx.file,
96
+ line: l.startLine,
97
+ ruleId: entry.id,
98
+ message: 'rename utility class',
99
+ before,
100
+ after,
101
+ });
102
+ if (entry.rule.note) {
103
+ pushFinding({
104
+ kind: 'flag',
105
+ surface: 'html',
106
+ file: ctx.file,
107
+ line: l.startLine,
108
+ ruleId: entry.id,
109
+ message: entry.rule.note,
110
+ });
111
+ }
112
+ }, (entry, token) => {
113
+ pushFinding({
114
+ kind: 'warn',
115
+ surface: 'html',
116
+ file: ctx.file,
117
+ line: l.startLine,
118
+ ruleId: entry.id,
119
+ message: `\`${token}\`: ${entry.rule.message}`,
120
+ });
121
+ });
122
+ if (result.changed) {
123
+ edits.push({
124
+ start: l.startOffset + open,
125
+ end: l.startOffset + close,
126
+ text: result.value,
127
+ });
128
+ }
129
+ }
130
+ }
131
+ }
132
+ const component = getByTag(manifest, tag);
133
+ if (!component)
134
+ return;
135
+ for (const rule of rulesForComponent(manifest, component)) {
136
+ const id = ruleId(tag, rule);
137
+ if (!ruleEnabled(ctx, id))
138
+ continue;
139
+ switch (rule.kind) {
140
+ case 'booleanInvert':
141
+ applyBooleanInvert(rule, id);
142
+ break;
143
+ case 'propRename':
144
+ applyPropRename(rule, id);
145
+ break;
146
+ case 'propRemove':
147
+ applyPropRemove(rule, id);
148
+ break;
149
+ case 'enumRemap':
150
+ applyEnumRemap(rule, id);
151
+ break;
152
+ case 'slotToAttr':
153
+ applySlotToAttr(rule, id);
154
+ break;
155
+ case 'ensureAttr':
156
+ applyEnsureAttr(rule, id);
157
+ break;
158
+ case 'slotRemove':
159
+ applySlotRemove(rule, id);
160
+ break;
161
+ case 'tagRename':
162
+ applyTagRename(rule, id);
163
+ break;
164
+ // slotRename/cssVarRename/cssVarRemove/partRename/eventRename: not applicable to plain HTML attributes.
165
+ default:
166
+ break;
167
+ }
168
+ }
169
+ /** Rename the start tag and, when written, the end tag; the name sits right after `<` / `</`. */
170
+ function applyTagRename(rule, id) {
171
+ if (!loc?.startTag)
172
+ return;
173
+ const nameEdit = (at) => ({ start: at, end: at + tag.length, text: rule.to });
174
+ edits.push(nameEdit(loc.startTag.startOffset + 1));
175
+ if (loc.endTag)
176
+ edits.push(nameEdit(loc.endTag.startOffset + 2));
177
+ pushFinding({
178
+ kind: 'change',
179
+ surface: 'html',
180
+ file: ctx.file,
181
+ line: loc.startLine,
182
+ component: tag,
183
+ ruleId: id,
184
+ message: `rename <${tag}> to <${rule.to}>`,
185
+ before: tag,
186
+ after: rule.to,
187
+ });
188
+ }
189
+ function attrValueOf(attr, hasValueRaw) {
190
+ return hasValueRaw ? stringLiteral(attr.value) : bareTrue();
191
+ }
192
+ function applyBooleanInvert(rule, id) {
193
+ const attr = getAttr(rule.from.attr);
194
+ const l = attrLocs[rule.from.attr];
195
+ if (!attr || !l)
196
+ return;
197
+ const raw = source.slice(l.startOffset, l.endOffset);
198
+ const parsed = parseRawAttr(raw);
199
+ const outcome = invertBoolean(attrValueOf(attr, parsed.hasValue), rule.newDefault);
200
+ let after = '';
201
+ if (outcome.action === 'omit') {
202
+ edits.push(removeAttrEdit(l));
203
+ }
204
+ else if (outcome.action === 'shorthandTrue') {
205
+ after = rule.to.attr;
206
+ edits.push({ start: l.startOffset, end: l.endOffset, text: after });
207
+ }
208
+ else if (outcome.action === 'explicitFalse') {
209
+ after = `${rule.to.attr}="false"`;
210
+ edits.push({ start: l.startOffset, end: l.endOffset, text: after });
211
+ }
212
+ pushFinding({
213
+ kind: 'change',
214
+ surface: 'html',
215
+ file: ctx.file,
216
+ line: l.startLine,
217
+ ruleId: id,
218
+ component: tag,
219
+ message: `invert ${rule.from.prop} → ${rule.to.prop}`,
220
+ before: raw,
221
+ after,
222
+ });
223
+ }
224
+ function applyPropRename(rule, id) {
225
+ const l = attrLocs[rule.from.attr];
226
+ if (!l)
227
+ return;
228
+ const raw = source.slice(l.startOffset, l.endOffset);
229
+ const parsed = parseRawAttr(raw);
230
+ edits.push({
231
+ start: l.startOffset,
232
+ end: l.startOffset + parsed.name.length,
233
+ text: rule.to.attr,
234
+ });
235
+ pushFinding({
236
+ kind: 'change',
237
+ surface: 'html',
238
+ file: ctx.file,
239
+ line: l.startLine,
240
+ ruleId: id,
241
+ component: tag,
242
+ message: `rename ${rule.from.prop} → ${rule.to.prop}`,
243
+ before: parsed.name,
244
+ after: rule.to.attr,
245
+ });
246
+ }
247
+ function applyPropRemove(rule, id) {
248
+ const l = attrLocs[rule.prop.attr];
249
+ if (!l)
250
+ return;
251
+ edits.push(removeAttrEdit(l));
252
+ if (rule.strategy === 'comment' && loc) {
253
+ const indent = lineIndentAt(source, loc.startOffset);
254
+ edits.push({
255
+ start: loc.startOffset,
256
+ end: loc.startOffset,
257
+ text: `<!-- TODO magma v2: ${rule.message} -->\n${indent}`,
258
+ });
259
+ }
260
+ pushFinding({
261
+ kind: 'warn',
262
+ surface: 'html',
263
+ file: ctx.file,
264
+ line: l.startLine,
265
+ ruleId: id,
266
+ component: tag,
267
+ message: rule.message,
268
+ });
269
+ }
270
+ function applyEnumRemap(rule, id) {
271
+ const attr = getAttr(rule.prop.attr);
272
+ const l = attrLocs[rule.prop.attr];
273
+ if (!attr || !l)
274
+ return;
275
+ const raw = source.slice(l.startOffset, l.endOffset);
276
+ const parsed = parseRawAttr(raw);
277
+ const v2set = rule.v2set ? component.v2EnumSets?.[rule.v2set] : undefined;
278
+ const outcome = remapEnum(attrValueOf(attr, parsed.hasValue), rule.map, v2set);
279
+ if (outcome.action === 'rename') {
280
+ const q = parsed.quote;
281
+ edits.push({
282
+ start: l.startOffset,
283
+ end: l.endOffset,
284
+ text: `${rule.prop.attr}=${q}${outcome.value}${q}`,
285
+ });
286
+ pushFinding({
287
+ kind: 'change',
288
+ surface: 'html',
289
+ file: ctx.file,
290
+ line: l.startLine,
291
+ ruleId: id,
292
+ component: tag,
293
+ message: `remap ${rule.prop.prop}`,
294
+ before: attr.value,
295
+ after: outcome.value,
296
+ });
297
+ }
298
+ else if (outcome.action === 'flag') {
299
+ pushFinding({
300
+ kind: 'flag',
301
+ surface: 'html',
302
+ file: ctx.file,
303
+ line: l.startLine,
304
+ ruleId: id,
305
+ component: tag,
306
+ message: `${rule.prop.prop}="${attr.value}": ${outcome.reason}`,
307
+ });
308
+ }
309
+ }
310
+ function applyEnsureAttr(rule, id) {
311
+ if (!loc?.startTag)
312
+ return;
313
+ if (rule.unless.some((p) => hasAttr(p.attr)))
314
+ return;
315
+ const insertAt = loc.startTag.endOffset - 1;
316
+ const after = rule.value === undefined
317
+ ? rule.attr.attr
318
+ : `${rule.attr.attr}="${escapeHtmlAttr(rule.value)}"`;
319
+ edits.push({ start: insertAt, end: insertAt, text: ` ${after}` });
320
+ pushFinding({
321
+ kind: 'change',
322
+ surface: 'html',
323
+ file: ctx.file,
324
+ line: loc.startLine,
325
+ ruleId: id,
326
+ component: tag,
327
+ message: rule.reason,
328
+ after,
329
+ });
330
+ }
331
+ function applySlotRemove(rule, id) {
332
+ for (const child of node.childNodes ?? []) {
333
+ if (!isElement(child))
334
+ continue;
335
+ const slot = (child.attrs ?? []).find((a) => a.name === 'slot' && a.value === rule.from);
336
+ if (!slot)
337
+ continue;
338
+ pushFinding({
339
+ kind: 'warn',
340
+ surface: 'html',
341
+ file: ctx.file,
342
+ line: child.sourceCodeLocation?.startLine,
343
+ ruleId: id,
344
+ component: tag,
345
+ message: `the \`${rule.from}\` slot of ${tag} was removed in v2; migrate the slotted content manually`,
346
+ });
347
+ }
348
+ }
349
+ function applySlotToAttr(rule, id) {
350
+ if (rule.slot !== 'default')
351
+ return; // only default-slot text supported for now
352
+ if (hasAttr(rule.to.attr))
353
+ return; // explicit attribute wins
354
+ if (!loc?.startTag || !loc.endTag)
355
+ return;
356
+ const children = node.childNodes ?? [];
357
+ const elementChildren = children.filter((c) => isElement(c) || c.content !== undefined);
358
+ const line = loc.startLine;
359
+ if (elementChildren.length > 0) {
360
+ pushFinding({
361
+ kind: 'dynamic',
362
+ surface: 'html',
363
+ file: ctx.file,
364
+ line,
365
+ ruleId: id,
366
+ component: tag,
367
+ message: `${tag} content contains markup; move the text into \`${rule.to.attr}\` manually`,
368
+ });
369
+ return;
370
+ }
371
+ const text = collapseWhitespace(children
372
+ .filter((c) => c.nodeName === '#text')
373
+ .map((c) => c.value ?? '')
374
+ .join(''));
375
+ if (text === '')
376
+ return;
377
+ // Insert the attribute just before the start tag's closing `>` and clear the content.
378
+ const insertAt = loc.startTag.endOffset - 1;
379
+ edits.push({
380
+ start: insertAt,
381
+ end: insertAt,
382
+ text: ` ${rule.to.attr}="${escapeHtmlAttr(text)}"`,
383
+ });
384
+ edits.push({ start: loc.startTag.endOffset, end: loc.endTag.startOffset, text: '' });
385
+ pushFinding({
386
+ kind: 'change',
387
+ surface: 'html',
388
+ file: ctx.file,
389
+ line,
390
+ ruleId: id,
391
+ component: tag,
392
+ message: `lift slotted text into \`${rule.to.attr}\``,
393
+ before: text,
394
+ after: `${rule.to.attr}="${text}"`,
395
+ });
396
+ }
397
+ };
398
+ visit(fragment);
399
+ if (edits.length === 0)
400
+ return { output: source, changed: false, findings };
401
+ return { output: applyEdits(source, edits), changed: true, findings };
402
+ };