@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.
- package/README.md +193 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +77 -0
- package/dist/generate/diff-docs.d.ts +36 -0
- package/dist/generate/diff-docs.js +180 -0
- package/dist/index.d.ts +39 -0
- package/dist/index.js +166 -0
- package/dist/manifest/manifest.d.ts +3 -0
- package/dist/manifest/manifest.generated.d.ts +2 -0
- package/dist/manifest/manifest.generated.js +1901 -0
- package/dist/manifest/manifest.js +546 -0
- package/dist/manifest/registry.d.ts +32 -0
- package/dist/manifest/registry.js +102 -0
- package/dist/manifest/schema.d.ts +245 -0
- package/dist/manifest/schema.js +9 -0
- package/dist/report/diff.d.ts +2 -0
- package/dist/report/diff.js +95 -0
- package/dist/report/reporter.d.ts +27 -0
- package/dist/report/reporter.js +118 -0
- package/dist/report/types.d.ts +51 -0
- package/dist/report/types.js +2 -0
- package/dist/surfaces/angular.d.ts +3 -0
- package/dist/surfaces/angular.js +489 -0
- package/dist/surfaces/css.d.ts +5 -0
- package/dist/surfaces/css.js +287 -0
- package/dist/surfaces/html.d.ts +3 -0
- package/dist/surfaces/html.js +402 -0
- package/dist/surfaces/inline-templates.d.ts +3 -0
- package/dist/surfaces/inline-templates.js +98 -0
- package/dist/surfaces/react.d.ts +3 -0
- package/dist/surfaces/react.js +496 -0
- package/dist/surfaces/shared/attribute-ops.d.ts +56 -0
- package/dist/surfaces/shared/attribute-ops.js +62 -0
- package/dist/surfaces/shared/class-ops.d.ts +55 -0
- package/dist/surfaces/shared/class-ops.js +91 -0
- package/dist/surfaces/shared/edits.d.ts +19 -0
- package/dist/surfaces/shared/edits.js +30 -0
- package/dist/surfaces/shared/negate.d.ts +13 -0
- package/dist/surfaces/shared/negate.js +51 -0
- package/dist/surfaces/shared/transform.d.ts +17 -0
- package/dist/surfaces/shared/transform.js +8 -0
- package/dist/surfaces/shared/value-model.d.ts +49 -0
- package/dist/surfaces/shared/value-model.js +18 -0
- 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,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, '&').replace(/"/g, '"').replace(/</g, '<');
|
|
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 (`[>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
|
+
};
|