@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,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inline-template surface for `.ts` files. Extracts and rewrites:
|
|
3
|
+
*
|
|
4
|
+
* - Angular `@Component({ template: `…` })` inline templates → {@link transformAngular}.
|
|
5
|
+
* - Generic inline HTML in no-substitution template literals that contain
|
|
6
|
+
* `<mds-…>` tags or migrated utility classes (e.g. `el.innerHTML = `…``)
|
|
7
|
+
* → {@link transformHtml}.
|
|
8
|
+
*
|
|
9
|
+
* Template literals with `${…}` substitutions are reported under `dynamic`
|
|
10
|
+
* (the holes make the markup unsafe to parse) rather than rewritten. Host
|
|
11
|
+
* bindings (`@Component({ host: {…} })`) are intentionally not rewritten:
|
|
12
|
+
* applying `mds-*` rules to a consumer component's own host is almost never
|
|
13
|
+
* correct, so it is left for manual review.
|
|
14
|
+
*/
|
|
15
|
+
import { Node, Project, SyntaxKind, ts } from 'ts-morph';
|
|
16
|
+
import { applyEdits } from './shared/edits.js';
|
|
17
|
+
import { classRulesOf } from './shared/class-ops.js';
|
|
18
|
+
import { transformAngular } from './angular.js';
|
|
19
|
+
import { transformHtml } from './html.js';
|
|
20
|
+
const MDS_TAG_RE = /<mds-[a-z-]/;
|
|
21
|
+
export const transformInlineTemplates = (source, manifest, ctx) => {
|
|
22
|
+
const project = new Project({
|
|
23
|
+
useInMemoryFileSystem: true,
|
|
24
|
+
skipFileDependencyResolution: true,
|
|
25
|
+
compilerOptions: { jsx: ts.JsxEmit.Preserve, allowJs: true },
|
|
26
|
+
});
|
|
27
|
+
const sf = project.createSourceFile('__codemod__.ts', source, { overwrite: true });
|
|
28
|
+
const edits = [];
|
|
29
|
+
const findings = [];
|
|
30
|
+
const handled = new Set();
|
|
31
|
+
// A literal is worth parsing when it contains mds-* markup OR mentions a
|
|
32
|
+
// migrated utility class (J) — those live on any element, not just mds-*.
|
|
33
|
+
const classRules = classRulesOf(manifest);
|
|
34
|
+
const relevant = (chunk) => MDS_TAG_RE.test(chunk) || (classRules.candidateRe?.test(chunk) ?? false);
|
|
35
|
+
/** Transform the raw inner text of a no-substitution template literal node. */
|
|
36
|
+
const transformLiteral = (node, run) => {
|
|
37
|
+
handled.add(node.getStart());
|
|
38
|
+
const innerStart = node.getStart() + 1;
|
|
39
|
+
const innerEnd = node.getEnd() - 1;
|
|
40
|
+
const inner = source.slice(innerStart, innerEnd);
|
|
41
|
+
if (!relevant(inner))
|
|
42
|
+
return;
|
|
43
|
+
const sub = run(inner, manifest, ctx);
|
|
44
|
+
const baseLine = node.getStartLineNumber();
|
|
45
|
+
for (const f of sub.findings) {
|
|
46
|
+
findings.push({ ...f, file: ctx.file, line: f.line ? baseLine + f.line - 1 : baseLine });
|
|
47
|
+
}
|
|
48
|
+
if (sub.changed)
|
|
49
|
+
edits.push({ start: innerStart, end: innerEnd, text: sub.output });
|
|
50
|
+
};
|
|
51
|
+
const flagDynamic = (node, message) => {
|
|
52
|
+
handled.add(node.getStart());
|
|
53
|
+
if (!relevant(node.getText()))
|
|
54
|
+
return;
|
|
55
|
+
findings.push({
|
|
56
|
+
kind: 'dynamic',
|
|
57
|
+
surface: 'html',
|
|
58
|
+
file: ctx.file,
|
|
59
|
+
line: node.getStartLineNumber(),
|
|
60
|
+
message,
|
|
61
|
+
});
|
|
62
|
+
};
|
|
63
|
+
// 1. Angular @Component({ template: `…` }) inline templates.
|
|
64
|
+
for (const dec of sf.getDescendantsOfKind(SyntaxKind.Decorator)) {
|
|
65
|
+
if (dec.getName() !== 'Component')
|
|
66
|
+
continue;
|
|
67
|
+
const call = dec.getCallExpression();
|
|
68
|
+
const arg = call?.getArguments()[0];
|
|
69
|
+
if (!arg || !Node.isObjectLiteralExpression(arg))
|
|
70
|
+
continue;
|
|
71
|
+
const templateProp = arg.getProperty('template');
|
|
72
|
+
if (!templateProp || !Node.isPropertyAssignment(templateProp))
|
|
73
|
+
continue;
|
|
74
|
+
const init = templateProp.getInitializer();
|
|
75
|
+
if (!init)
|
|
76
|
+
continue;
|
|
77
|
+
if (init.getKind() === SyntaxKind.NoSubstitutionTemplateLiteral) {
|
|
78
|
+
transformLiteral(init, transformAngular);
|
|
79
|
+
}
|
|
80
|
+
else if (init.getKind() === SyntaxKind.TemplateExpression) {
|
|
81
|
+
flagDynamic(init, 'inline Angular template contains ${} interpolation; migrate it manually');
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
// 2. Generic inline HTML in no-substitution template literals with <mds-…>.
|
|
85
|
+
for (const lit of sf.getDescendantsOfKind(SyntaxKind.NoSubstitutionTemplateLiteral)) {
|
|
86
|
+
if (handled.has(lit.getStart()))
|
|
87
|
+
continue;
|
|
88
|
+
transformLiteral(lit, transformHtml);
|
|
89
|
+
}
|
|
90
|
+
for (const te of sf.getDescendantsOfKind(SyntaxKind.TemplateExpression)) {
|
|
91
|
+
if (handled.has(te.getStart()))
|
|
92
|
+
continue;
|
|
93
|
+
flagDynamic(te, 'inline HTML template contains ${} interpolation; migrate it manually');
|
|
94
|
+
}
|
|
95
|
+
if (edits.length === 0)
|
|
96
|
+
return { output: source, changed: false, findings };
|
|
97
|
+
return { output: applyEdits(source, edits), changed: true, findings };
|
|
98
|
+
};
|
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React (JSX/TSX) surface. ts-morph is used purely as a parser to locate JSX
|
|
3
|
+
* elements and read their attribute/children shapes; transformations are
|
|
4
|
+
* emitted as offset edits on the original source (via {@link applyEdits}) so
|
|
5
|
+
* formatting is preserved and node invalidation is a non-issue.
|
|
6
|
+
*
|
|
7
|
+
* Both the PascalCase React components (`<MdsButton>`) and the custom elements
|
|
8
|
+
* used directly as intrinsic JSX tags (`<mds-button>`) are matched; intrinsic
|
|
9
|
+
* elements are matched and emitted with the kebab-case attribute spelling.
|
|
10
|
+
*
|
|
11
|
+
* Unanalyzable usage — spread props (`{...props}`), aliased components, computed
|
|
12
|
+
* names, dynamic children — is reported under the `dynamic` category instead of
|
|
13
|
+
* being rewritten.
|
|
14
|
+
*/
|
|
15
|
+
import { Project, SyntaxKind, ts, } from 'ts-morph';
|
|
16
|
+
import { getByReactName, getByTag, ruleId, rulesForComponent, tagRenamesOf, } from '../manifest/registry.js';
|
|
17
|
+
import { classRulesOf, hasClassRules, rewriteClassList } from './shared/class-ops.js';
|
|
18
|
+
import { invertBoolean, remapEnum } from './shared/attribute-ops.js';
|
|
19
|
+
import { bareTrue, boolLiteral, dynamic, stringLiteral, } from './shared/value-model.js';
|
|
20
|
+
import { applyEdits } from './shared/edits.js';
|
|
21
|
+
import { ruleEnabled } from './shared/transform.js';
|
|
22
|
+
const jsxStringFor = (text) => /["&<>\n]/.test(text) ? `{${JSON.stringify(text)}}` : `"${text}"`;
|
|
23
|
+
const collapseWhitespace = (s) => s.replace(/\s+/g, ' ').trim();
|
|
24
|
+
export const transformReact = (source, manifest, ctx) => {
|
|
25
|
+
const project = new Project({
|
|
26
|
+
useInMemoryFileSystem: true,
|
|
27
|
+
skipFileDependencyResolution: true,
|
|
28
|
+
compilerOptions: { jsx: ts.JsxEmit.Preserve, allowJs: true },
|
|
29
|
+
});
|
|
30
|
+
const sf = project.createSourceFile('__codemod__.tsx', source, { overwrite: true });
|
|
31
|
+
const edits = [];
|
|
32
|
+
const findings = [];
|
|
33
|
+
const openings = [
|
|
34
|
+
...sf.getDescendantsOfKind(SyntaxKind.JsxOpeningElement),
|
|
35
|
+
...sf.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement),
|
|
36
|
+
];
|
|
37
|
+
for (const opening of openings) {
|
|
38
|
+
const tagName = opening.getTagNameNode().getText();
|
|
39
|
+
// A dash can only appear in a plain intrinsic tag (member expressions
|
|
40
|
+
// contain `.`, namespaced names `:`), so it safely discriminates
|
|
41
|
+
// `<mds-button>` from `<MdsButton>`.
|
|
42
|
+
const isIntrinsic = tagName.includes('-');
|
|
43
|
+
const component = isIntrinsic ? getByTag(manifest, tagName) : getByReactName(manifest, tagName);
|
|
44
|
+
if (!component)
|
|
45
|
+
continue;
|
|
46
|
+
const tag = component.tag;
|
|
47
|
+
const line = opening.getStartLineNumber();
|
|
48
|
+
// Intrinsic custom elements take the kebab-case attribute spelling (React
|
|
49
|
+
// sets attributes on unknown elements); React components take the prop.
|
|
50
|
+
const nameOf = (p) => (isIntrinsic ? p.attr : p.prop);
|
|
51
|
+
const attributes = opening.getAttributes();
|
|
52
|
+
const hasSpread = attributes.some((a) => a.getKind() === SyntaxKind.JsxSpreadAttribute);
|
|
53
|
+
const jsxAttrs = attributes.filter((a) => a.getKind() === SyntaxKind.JsxAttribute);
|
|
54
|
+
const findAttr = (name) => jsxAttrs.find((a) => a.getNameNode().getText() === name);
|
|
55
|
+
const hasAttr = (name) => findAttr(name) !== undefined;
|
|
56
|
+
if (hasSpread) {
|
|
57
|
+
findings.push({
|
|
58
|
+
kind: 'dynamic',
|
|
59
|
+
surface: 'react',
|
|
60
|
+
file: ctx.file,
|
|
61
|
+
line,
|
|
62
|
+
component: tag,
|
|
63
|
+
message: `<${tagName}> uses spread props ({...}); verify v2 props by hand`,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
for (const rule of rulesForComponent(manifest, component)) {
|
|
67
|
+
const id = ruleId(tag, rule);
|
|
68
|
+
if (!ruleEnabled(ctx, id))
|
|
69
|
+
continue;
|
|
70
|
+
switch (rule.kind) {
|
|
71
|
+
case 'booleanInvert':
|
|
72
|
+
applyBooleanInvert(rule, id);
|
|
73
|
+
break;
|
|
74
|
+
case 'propRename':
|
|
75
|
+
applyPropRename(rule, id);
|
|
76
|
+
break;
|
|
77
|
+
case 'propRemove':
|
|
78
|
+
applyPropRemove(rule, id);
|
|
79
|
+
break;
|
|
80
|
+
case 'enumRemap':
|
|
81
|
+
applyEnumRemap(rule, id);
|
|
82
|
+
break;
|
|
83
|
+
case 'slotToAttr':
|
|
84
|
+
applySlotToAttr(rule, id);
|
|
85
|
+
break;
|
|
86
|
+
case 'ensureAttr':
|
|
87
|
+
applyEnsureAttr(rule, id);
|
|
88
|
+
break;
|
|
89
|
+
case 'slotRemove':
|
|
90
|
+
applySlotRemove(rule, id);
|
|
91
|
+
break;
|
|
92
|
+
default:
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function applyEnsureAttr(rule, id) {
|
|
97
|
+
if (hasSpread)
|
|
98
|
+
return; // a spread may already set the prop; don't risk a duplicate
|
|
99
|
+
if (rule.unless.some((p) => hasAttr(nameOf(p))))
|
|
100
|
+
return;
|
|
101
|
+
const nameEnd = opening.getTagNameNode().getEnd();
|
|
102
|
+
const after = rule.value === undefined ? nameOf(rule.attr) : `${nameOf(rule.attr)}="${rule.value}"`;
|
|
103
|
+
edits.push({ start: nameEnd, end: nameEnd, text: ` ${after}` });
|
|
104
|
+
findings.push({
|
|
105
|
+
kind: 'change',
|
|
106
|
+
surface: 'react',
|
|
107
|
+
file: ctx.file,
|
|
108
|
+
line,
|
|
109
|
+
ruleId: id,
|
|
110
|
+
component: tag,
|
|
111
|
+
message: rule.reason,
|
|
112
|
+
after,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
function applySlotRemove(rule, id) {
|
|
116
|
+
const element = opening.getParentIfKind(SyntaxKind.JsxElement);
|
|
117
|
+
if (!element)
|
|
118
|
+
return;
|
|
119
|
+
for (const child of element.getJsxChildren()) {
|
|
120
|
+
let childOpening;
|
|
121
|
+
if (child.getKind() === SyntaxKind.JsxElement)
|
|
122
|
+
childOpening = child.asKindOrThrow(SyntaxKind.JsxElement).getOpeningElement();
|
|
123
|
+
else if (child.getKind() === SyntaxKind.JsxSelfClosingElement)
|
|
124
|
+
childOpening = child.asKindOrThrow(SyntaxKind.JsxSelfClosingElement);
|
|
125
|
+
else
|
|
126
|
+
continue;
|
|
127
|
+
const slotAttr = childOpening
|
|
128
|
+
.getAttributes()
|
|
129
|
+
.filter((a) => a.getKind() === SyntaxKind.JsxAttribute)
|
|
130
|
+
.find((a) => a.getNameNode().getText() === 'slot');
|
|
131
|
+
const init = slotAttr?.getInitializer();
|
|
132
|
+
if (!init || init.getKind() !== SyntaxKind.StringLiteral)
|
|
133
|
+
continue;
|
|
134
|
+
if (init.getText().slice(1, -1) !== rule.from)
|
|
135
|
+
continue;
|
|
136
|
+
findings.push({
|
|
137
|
+
kind: 'warn',
|
|
138
|
+
surface: 'react',
|
|
139
|
+
file: ctx.file,
|
|
140
|
+
line: child.getStartLineNumber(),
|
|
141
|
+
ruleId: id,
|
|
142
|
+
component: tag,
|
|
143
|
+
message: `the \`${rule.from}\` slot of ${tag} was removed in v2; migrate the slotted content manually`,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function removeAttrEdit(attr) {
|
|
148
|
+
const start = attr.getStart();
|
|
149
|
+
const realStart = start > 0 && source[start - 1] === ' ' ? start - 1 : start;
|
|
150
|
+
return { start: realStart, end: attr.getEnd(), text: '' };
|
|
151
|
+
}
|
|
152
|
+
function valueOf(attr) {
|
|
153
|
+
const init = attr.getInitializer();
|
|
154
|
+
if (!init)
|
|
155
|
+
return bareTrue();
|
|
156
|
+
if (init.getKind() === SyntaxKind.StringLiteral)
|
|
157
|
+
return stringLiteral(init.getText().slice(1, -1));
|
|
158
|
+
if (init.getKind() === SyntaxKind.JsxExpression) {
|
|
159
|
+
const expr = init.getExpression();
|
|
160
|
+
if (!expr)
|
|
161
|
+
return dynamic('');
|
|
162
|
+
if (expr.getKind() === SyntaxKind.TrueKeyword)
|
|
163
|
+
return boolLiteral(true);
|
|
164
|
+
if (expr.getKind() === SyntaxKind.FalseKeyword)
|
|
165
|
+
return boolLiteral(false);
|
|
166
|
+
return dynamic(expr.getText());
|
|
167
|
+
}
|
|
168
|
+
return dynamic(init.getText());
|
|
169
|
+
}
|
|
170
|
+
function applyBooleanInvert(rule, id) {
|
|
171
|
+
const attr = findAttr(nameOf(rule.from));
|
|
172
|
+
if (!attr)
|
|
173
|
+
return;
|
|
174
|
+
const outcome = invertBoolean(valueOf(attr), rule.newDefault);
|
|
175
|
+
let after = '';
|
|
176
|
+
if (outcome.action === 'omit') {
|
|
177
|
+
edits.push(removeAttrEdit(attr));
|
|
178
|
+
}
|
|
179
|
+
else if (outcome.action === 'shorthandTrue') {
|
|
180
|
+
after = nameOf(rule.to);
|
|
181
|
+
edits.push({ start: attr.getStart(), end: attr.getEnd(), text: after });
|
|
182
|
+
}
|
|
183
|
+
else if (outcome.action === 'explicitFalse') {
|
|
184
|
+
// Stencil parses the attribute string "false" as false (see resolveBoolean).
|
|
185
|
+
after = isIntrinsic ? `${rule.to.attr}="false"` : `${rule.to.prop}={false}`;
|
|
186
|
+
edits.push({ start: attr.getStart(), end: attr.getEnd(), text: after });
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
after = `${nameOf(rule.to)}={${outcome.expr}}`;
|
|
190
|
+
edits.push({ start: attr.getStart(), end: attr.getEnd(), text: after });
|
|
191
|
+
}
|
|
192
|
+
findings.push({
|
|
193
|
+
kind: 'change',
|
|
194
|
+
surface: 'react',
|
|
195
|
+
file: ctx.file,
|
|
196
|
+
line,
|
|
197
|
+
ruleId: id,
|
|
198
|
+
component: tag,
|
|
199
|
+
message: `invert ${rule.from.prop} → ${rule.to.prop}`,
|
|
200
|
+
before: attr.getText(),
|
|
201
|
+
after,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
function applyPropRename(rule, id) {
|
|
205
|
+
const attr = findAttr(nameOf(rule.from));
|
|
206
|
+
if (!attr)
|
|
207
|
+
return;
|
|
208
|
+
const nameNode = attr.getNameNode();
|
|
209
|
+
edits.push({ start: nameNode.getStart(), end: nameNode.getEnd(), text: nameOf(rule.to) });
|
|
210
|
+
findings.push({
|
|
211
|
+
kind: 'change',
|
|
212
|
+
surface: 'react',
|
|
213
|
+
file: ctx.file,
|
|
214
|
+
line,
|
|
215
|
+
ruleId: id,
|
|
216
|
+
component: tag,
|
|
217
|
+
message: `rename ${rule.from.prop} → ${rule.to.prop}`,
|
|
218
|
+
before: nameOf(rule.from),
|
|
219
|
+
after: nameOf(rule.to),
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
function applyPropRemove(rule, id) {
|
|
223
|
+
const attr = findAttr(nameOf(rule.prop));
|
|
224
|
+
if (!attr)
|
|
225
|
+
return;
|
|
226
|
+
edits.push(removeAttrEdit(attr));
|
|
227
|
+
findings.push({
|
|
228
|
+
kind: 'warn',
|
|
229
|
+
surface: 'react',
|
|
230
|
+
file: ctx.file,
|
|
231
|
+
line,
|
|
232
|
+
ruleId: id,
|
|
233
|
+
component: tag,
|
|
234
|
+
message: rule.message,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
function applyEnumRemap(rule, id) {
|
|
238
|
+
const attr = findAttr(nameOf(rule.prop));
|
|
239
|
+
if (!attr)
|
|
240
|
+
return;
|
|
241
|
+
const v2set = rule.v2set ? component.v2EnumSets?.[rule.v2set] : undefined;
|
|
242
|
+
const outcome = remapEnum(valueOf(attr), rule.map, v2set);
|
|
243
|
+
if (outcome.action === 'rename') {
|
|
244
|
+
edits.push({
|
|
245
|
+
start: attr.getStart(),
|
|
246
|
+
end: attr.getEnd(),
|
|
247
|
+
text: `${nameOf(rule.prop)}="${outcome.value}"`,
|
|
248
|
+
});
|
|
249
|
+
findings.push({
|
|
250
|
+
kind: 'change',
|
|
251
|
+
surface: 'react',
|
|
252
|
+
file: ctx.file,
|
|
253
|
+
line,
|
|
254
|
+
ruleId: id,
|
|
255
|
+
component: tag,
|
|
256
|
+
message: `remap ${rule.prop.prop}`,
|
|
257
|
+
after: outcome.value,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
else if (outcome.action === 'flag') {
|
|
261
|
+
const kind = valueOf(attr).kind === 'dynamic' ? 'dynamic' : 'flag';
|
|
262
|
+
findings.push({
|
|
263
|
+
kind,
|
|
264
|
+
surface: 'react',
|
|
265
|
+
file: ctx.file,
|
|
266
|
+
line,
|
|
267
|
+
ruleId: id,
|
|
268
|
+
component: tag,
|
|
269
|
+
message: `${rule.prop.prop}: ${outcome.reason}`,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
function applySlotToAttr(rule, id) {
|
|
274
|
+
if (rule.slot !== 'default')
|
|
275
|
+
return;
|
|
276
|
+
if (hasAttr(nameOf(rule.to)))
|
|
277
|
+
return;
|
|
278
|
+
const element = opening.getParentIfKind(SyntaxKind.JsxElement);
|
|
279
|
+
if (!element)
|
|
280
|
+
return; // self-closing → no children to lift
|
|
281
|
+
const children = element.getJsxChildren();
|
|
282
|
+
const elems = children.filter((c) => c.getKind() === SyntaxKind.JsxElement ||
|
|
283
|
+
c.getKind() === SyntaxKind.JsxSelfClosingElement ||
|
|
284
|
+
c.getKind() === SyntaxKind.JsxFragment);
|
|
285
|
+
const exprs = children.filter((c) => c.getKind() === SyntaxKind.JsxExpression);
|
|
286
|
+
const text = collapseWhitespace(children
|
|
287
|
+
.filter((c) => c.getKind() === SyntaxKind.JsxText)
|
|
288
|
+
.map((c) => c.getText())
|
|
289
|
+
.join(''));
|
|
290
|
+
const flag = (message) => {
|
|
291
|
+
findings.push({
|
|
292
|
+
kind: 'dynamic',
|
|
293
|
+
surface: 'react',
|
|
294
|
+
file: ctx.file,
|
|
295
|
+
line,
|
|
296
|
+
ruleId: id,
|
|
297
|
+
component: tag,
|
|
298
|
+
message,
|
|
299
|
+
});
|
|
300
|
+
};
|
|
301
|
+
let labelAttr;
|
|
302
|
+
let before = '';
|
|
303
|
+
if (elems.length > 0) {
|
|
304
|
+
flag(`<${tagName}> content contains elements; move the text into \`${nameOf(rule.to)}\` manually`);
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
else if (exprs.length === 0 && text !== '') {
|
|
308
|
+
labelAttr = `${nameOf(rule.to)}=${jsxStringFor(text)}`;
|
|
309
|
+
before = text;
|
|
310
|
+
}
|
|
311
|
+
else if (exprs.length === 1 && text === '') {
|
|
312
|
+
const expr = exprs[0].getExpression();
|
|
313
|
+
if (!expr)
|
|
314
|
+
return;
|
|
315
|
+
labelAttr = `${nameOf(rule.to)}={${expr.getText()}}`;
|
|
316
|
+
before = `{${expr.getText()}}`;
|
|
317
|
+
}
|
|
318
|
+
else if (exprs.length === 0 && text === '') {
|
|
319
|
+
return; // empty element
|
|
320
|
+
}
|
|
321
|
+
else {
|
|
322
|
+
flag(`<${tagName}> has mixed text/expression content; move it into \`${nameOf(rule.to)}\` manually`);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
// Replace `>children</Tag>` with ` label=... />` (one edit; attribute edits sit before this range).
|
|
326
|
+
const start = opening.getEnd() - 1;
|
|
327
|
+
edits.push({ start, end: element.getEnd(), text: ` ${labelAttr} />` });
|
|
328
|
+
findings.push({
|
|
329
|
+
kind: 'change',
|
|
330
|
+
surface: 'react',
|
|
331
|
+
file: ctx.file,
|
|
332
|
+
line,
|
|
333
|
+
ruleId: id,
|
|
334
|
+
component: tag,
|
|
335
|
+
message: `lift content into \`${nameOf(rule.to)}\``,
|
|
336
|
+
before,
|
|
337
|
+
after: labelAttr,
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
// Global: utility-class migrations (J) on `className` / `class` of ANY JSX
|
|
342
|
+
// element (the classes are not tied to an mds-* component). String literals
|
|
343
|
+
// and no-substitution templates anywhere in the attribute expression are
|
|
344
|
+
// rewritten (covers clsx()/classnames(), ternaries, `||` fallbacks);
|
|
345
|
+
// substitution templates can split a class across chunks, so they are
|
|
346
|
+
// reported instead.
|
|
347
|
+
const classRules = classRulesOf(manifest);
|
|
348
|
+
if (hasClassRules(classRules)) {
|
|
349
|
+
const rewriteLiteralContent = (node) => {
|
|
350
|
+
const inner = source.slice(node.getStart() + 1, node.getEnd() - 1);
|
|
351
|
+
const line = node.getStartLineNumber();
|
|
352
|
+
const result = rewriteClassList(inner, classRules, (id) => ruleEnabled(ctx, id), (entry, before, after) => {
|
|
353
|
+
findings.push({
|
|
354
|
+
kind: 'change',
|
|
355
|
+
surface: 'react',
|
|
356
|
+
file: ctx.file,
|
|
357
|
+
line,
|
|
358
|
+
ruleId: entry.id,
|
|
359
|
+
message: 'rename utility class',
|
|
360
|
+
before,
|
|
361
|
+
after,
|
|
362
|
+
});
|
|
363
|
+
if (entry.rule.note) {
|
|
364
|
+
findings.push({
|
|
365
|
+
kind: 'flag',
|
|
366
|
+
surface: 'react',
|
|
367
|
+
file: ctx.file,
|
|
368
|
+
line,
|
|
369
|
+
ruleId: entry.id,
|
|
370
|
+
message: entry.rule.note,
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
}, (entry, token) => {
|
|
374
|
+
findings.push({
|
|
375
|
+
kind: 'warn',
|
|
376
|
+
surface: 'react',
|
|
377
|
+
file: ctx.file,
|
|
378
|
+
line,
|
|
379
|
+
ruleId: entry.id,
|
|
380
|
+
message: `\`${token}\`: ${entry.rule.message}`,
|
|
381
|
+
});
|
|
382
|
+
});
|
|
383
|
+
if (result.changed)
|
|
384
|
+
edits.push({ start: node.getStart() + 1, end: node.getEnd() - 1, text: result.value });
|
|
385
|
+
};
|
|
386
|
+
const isPlainLiteral = (node) => node.getKind() === SyntaxKind.StringLiteral ||
|
|
387
|
+
node.getKind() === SyntaxKind.NoSubstitutionTemplateLiteral;
|
|
388
|
+
for (const attr of sf.getDescendantsOfKind(SyntaxKind.JsxAttribute)) {
|
|
389
|
+
const name = attr.getNameNode().getText();
|
|
390
|
+
if (name !== 'className' && name !== 'class')
|
|
391
|
+
continue;
|
|
392
|
+
const init = attr.getInitializer();
|
|
393
|
+
if (!init)
|
|
394
|
+
continue;
|
|
395
|
+
if (isPlainLiteral(init)) {
|
|
396
|
+
rewriteLiteralContent(init);
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
if (init.getKind() !== SyntaxKind.JsxExpression)
|
|
400
|
+
continue;
|
|
401
|
+
const expr = init.getExpression();
|
|
402
|
+
if (!expr)
|
|
403
|
+
continue;
|
|
404
|
+
const literals = [
|
|
405
|
+
...(isPlainLiteral(expr) ? [expr] : []),
|
|
406
|
+
...expr.getDescendantsOfKind(SyntaxKind.StringLiteral),
|
|
407
|
+
...expr.getDescendantsOfKind(SyntaxKind.NoSubstitutionTemplateLiteral),
|
|
408
|
+
];
|
|
409
|
+
for (const literal of literals)
|
|
410
|
+
rewriteLiteralContent(literal);
|
|
411
|
+
const templates = [
|
|
412
|
+
...(expr.getKind() === SyntaxKind.TemplateExpression ? [expr] : []),
|
|
413
|
+
...expr.getDescendantsOfKind(SyntaxKind.TemplateExpression),
|
|
414
|
+
];
|
|
415
|
+
for (const template of templates) {
|
|
416
|
+
if (!classRules.candidateRe?.test(template.getText()))
|
|
417
|
+
continue;
|
|
418
|
+
findings.push({
|
|
419
|
+
kind: 'dynamic',
|
|
420
|
+
surface: 'react',
|
|
421
|
+
file: ctx.file,
|
|
422
|
+
line: template.getStartLineNumber(),
|
|
423
|
+
message: `template literal in \`${name}\` mentions a migrated utility class; rewrite it manually`,
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
// K: tag renames. A separate pass over the tag names, so a component the
|
|
429
|
+
// manifest does not otherwise touch is renamed too. Every rename is looked up
|
|
430
|
+
// by the name AS WRITTEN, so a v1 name that another component takes in v2 is
|
|
431
|
+
// never renamed twice.
|
|
432
|
+
const tagRenames = tagRenamesOf(manifest);
|
|
433
|
+
if (tagRenames.size > 0) {
|
|
434
|
+
const byReact = new Map();
|
|
435
|
+
for (const [tag, rule] of tagRenames) {
|
|
436
|
+
const component = getByTag(manifest, tag);
|
|
437
|
+
if (component)
|
|
438
|
+
byReact.set(component.react, { tag, rule });
|
|
439
|
+
}
|
|
440
|
+
const renamedAt = new Set();
|
|
441
|
+
const renameNode = (node, tag, from, to) => {
|
|
442
|
+
const start = node.getStart();
|
|
443
|
+
if (renamedAt.has(start))
|
|
444
|
+
return;
|
|
445
|
+
renamedAt.add(start);
|
|
446
|
+
edits.push({ start, end: node.getEnd(), text: to });
|
|
447
|
+
findings.push({
|
|
448
|
+
kind: 'change',
|
|
449
|
+
surface: 'react',
|
|
450
|
+
file: ctx.file,
|
|
451
|
+
line: node.getStartLineNumber(),
|
|
452
|
+
component: tag,
|
|
453
|
+
ruleId: `${tag}/tagRename`,
|
|
454
|
+
message: `rename ${from} to ${to}`,
|
|
455
|
+
before: from,
|
|
456
|
+
after: to,
|
|
457
|
+
});
|
|
458
|
+
};
|
|
459
|
+
const tagNodes = [...openings, ...sf.getDescendantsOfKind(SyntaxKind.JsxClosingElement)].map((el) => el.getTagNameNode());
|
|
460
|
+
for (const nameNode of tagNodes) {
|
|
461
|
+
const name = nameNode.getText();
|
|
462
|
+
const hit = name.includes('-')
|
|
463
|
+
? tagRenames.has(name)
|
|
464
|
+
? { tag: name, to: tagRenames.get(name).to }
|
|
465
|
+
: undefined
|
|
466
|
+
: byReact.has(name)
|
|
467
|
+
? { tag: byReact.get(name).tag, to: byReact.get(name).rule.toReact }
|
|
468
|
+
: undefined;
|
|
469
|
+
if (hit && ruleEnabled(ctx, `${hit.tag}/tagRename`))
|
|
470
|
+
renameNode(nameNode, hit.tag, name, hit.to);
|
|
471
|
+
}
|
|
472
|
+
// The named import from the React wrapper, and every other reference to it
|
|
473
|
+
// (`typeof MdsPrefTheme`, a re-export). An aliased import renames only the
|
|
474
|
+
// imported name: the local alias stays valid.
|
|
475
|
+
for (const decl of sf.getImportDeclarations()) {
|
|
476
|
+
if (!decl.getModuleSpecifierValue().startsWith('@maggioli-design-system/magma-react'))
|
|
477
|
+
continue;
|
|
478
|
+
for (const spec of decl.getNamedImports()) {
|
|
479
|
+
const name = spec.getName();
|
|
480
|
+
const hit = byReact.get(name);
|
|
481
|
+
if (!hit || !ruleEnabled(ctx, `${hit.tag}/tagRename`))
|
|
482
|
+
continue;
|
|
483
|
+
renameNode(spec.getNameNode(), hit.tag, name, hit.rule.toReact);
|
|
484
|
+
if (spec.getAliasNode())
|
|
485
|
+
continue;
|
|
486
|
+
for (const id of sf.getDescendantsOfKind(SyntaxKind.Identifier)) {
|
|
487
|
+
if (id.getText() === name)
|
|
488
|
+
renameNode(id, hit.tag, name, hit.rule.toReact);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
if (edits.length === 0)
|
|
494
|
+
return { output: source, changed: false, findings };
|
|
495
|
+
return { output: applyEdits(source, edits), changed: true, findings };
|
|
496
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Surface-agnostic transformation core. These functions take a parsed value
|
|
3
|
+
* (see {@link AttrValue}) plus the primitive parameters of a manifest rule and
|
|
4
|
+
* return an *outcome* describing what the surface should render — they never
|
|
5
|
+
* touch a concrete AST, so the logic is written and tested once.
|
|
6
|
+
*/
|
|
7
|
+
import { type AttrValue } from './value-model.js';
|
|
8
|
+
/** What to do with the renamed boolean prop after inverting its value. */
|
|
9
|
+
export type InvertOutcome =
|
|
10
|
+
/** New value equals the v2 default → drop the prop entirely. */
|
|
11
|
+
{
|
|
12
|
+
action: 'omit';
|
|
13
|
+
}
|
|
14
|
+
/** New value is `true` → emit the prop in shorthand form. */
|
|
15
|
+
| {
|
|
16
|
+
action: 'shorthandTrue';
|
|
17
|
+
}
|
|
18
|
+
/** New value is `false` but the v2 default is `true` → emit an explicit `false`. */
|
|
19
|
+
| {
|
|
20
|
+
action: 'explicitFalse';
|
|
21
|
+
}
|
|
22
|
+
/** New value is dynamic → emit the prop with the negated expression text. */
|
|
23
|
+
| {
|
|
24
|
+
action: 'expr';
|
|
25
|
+
expr: string;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Invert a boolean prop's value (category B). The new prop's value is the
|
|
29
|
+
* logical negation of the old one; `newDefault` decides whether that value can
|
|
30
|
+
* be omitted. `oldDefault` is intentionally unused — for a boolean prop the
|
|
31
|
+
* shorthand always means `true` regardless of the declared default.
|
|
32
|
+
*/
|
|
33
|
+
export declare const invertBoolean: (current: AttrValue, newDefault: boolean) => InvertOutcome;
|
|
34
|
+
/** Outcome of remapping an enum value (categories A and E). */
|
|
35
|
+
export type EnumRemapOutcome =
|
|
36
|
+
/** Map the literal to a new v2 value. */
|
|
37
|
+
{
|
|
38
|
+
action: 'rename';
|
|
39
|
+
value: string;
|
|
40
|
+
}
|
|
41
|
+
/** The value is already a valid v2 literal not covered by the map → leave it. */
|
|
42
|
+
| {
|
|
43
|
+
action: 'keep';
|
|
44
|
+
}
|
|
45
|
+
/** Cannot transform safely; surface should report it for manual review. */
|
|
46
|
+
| {
|
|
47
|
+
action: 'flag';
|
|
48
|
+
reason: string;
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* Remap an enum literal, validating the target against the component's v2 enum
|
|
52
|
+
* set when available. Dynamic values, map targets that are `null` (no 1:1
|
|
53
|
+
* replacement) and literals absent from the v2 set are flagged for the report
|
|
54
|
+
* instead of being rewritten blindly.
|
|
55
|
+
*/
|
|
56
|
+
export declare const remapEnum: (current: AttrValue, map: Record<string, string | null>, v2set?: readonly string[]) => EnumRemapOutcome;
|