@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,489 @@
1
+ /**
2
+ * Angular template surface. Uses `@angular/compiler`'s `parseTemplate` (which
3
+ * preserves case and gives precise source spans for static attributes
4
+ * `name="x"`, property bindings `[name]="expr"`, event bindings `(name)="…"`
5
+ * and interpolations `{{ x }}`) and emits offset edits on the original source.
6
+ *
7
+ * Static attributes use the v2 dash-case attribute name; property bindings use
8
+ * the camelCase prop. Dynamic enum values and element/mixed slot content are
9
+ * reported under `dynamic` instead of being rewritten.
10
+ */
11
+ import { parseTemplate, TmplAstBoundText, TmplAstElement, TmplAstTemplate, TmplAstText, } from '@angular/compiler';
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, dynamic, stringLiteral } from './shared/value-model.js';
16
+ import { applyEdits } from './shared/edits.js';
17
+ import { ruleEnabled } from './shared/transform.js';
18
+ const collapseWhitespace = (s) => s.replace(/\s+/g, ' ').trim();
19
+ const escapeAttr = (s) => s.replace(/&/g, '&').replace(/"/g, '"');
20
+ export const transformAngular = (source, manifest, ctx) => {
21
+ let parsed;
22
+ try {
23
+ parsed = parseTemplate(source, ctx.file, { preserveWhitespaces: true });
24
+ }
25
+ catch {
26
+ return { output: source, changed: false, findings: [] };
27
+ }
28
+ const edits = [];
29
+ const findings = [];
30
+ const classRules = classRulesOf(manifest);
31
+ const text = (span) => source.slice(span.start.offset, span.end.offset);
32
+ const removeSpanEdit = (span) => {
33
+ const start = span.start.offset > 0 && source[span.start.offset - 1] === ' '
34
+ ? span.start.offset - 1
35
+ : span.start.offset;
36
+ return { start, end: span.end.offset, text: '' };
37
+ };
38
+ const visit = (node) => {
39
+ if (node instanceof TmplAstElement) {
40
+ processElement(node);
41
+ for (const child of node.children)
42
+ visit(child);
43
+ }
44
+ else if (node instanceof TmplAstTemplate) {
45
+ for (const child of node.children)
46
+ visit(child);
47
+ }
48
+ };
49
+ const processElement = (el) => {
50
+ const tag = el.name;
51
+ const statics = el.attributes;
52
+ const inputs = el.inputs;
53
+ const line = el.startSourceSpan.start.line;
54
+ const findStatic = (name) => statics.find((a) => a.name === name);
55
+ const findInput = (name) => inputs.find((a) => a.name === name);
56
+ // Global: remove slot="default".
57
+ if (manifest.global.removeDefaultSlot && ruleEnabled(ctx, 'global/removeDefaultSlot')) {
58
+ const slot = findStatic('slot');
59
+ if (slot && slot.value === 'default') {
60
+ edits.push(removeSpanEdit(slot.sourceSpan));
61
+ findings.push({
62
+ kind: 'change',
63
+ surface: 'angular',
64
+ file: ctx.file,
65
+ line,
66
+ ruleId: 'global/removeDefaultSlot',
67
+ message: 'remove slot="default"',
68
+ });
69
+ }
70
+ }
71
+ // Global: utility-class migrations (J) on any element — the static `class`
72
+ // attribute, `[class.x]` bindings (bare class in the key) and the string
73
+ // literals inside `[ngClass]` / `[class]` / `[attr.class]` expressions.
74
+ if (hasClassRules(classRules)) {
75
+ const enabled = (id) => ruleEnabled(ctx, id);
76
+ const emitRename = (atLine, entry, before, after) => {
77
+ findings.push({
78
+ kind: 'change',
79
+ surface: 'angular',
80
+ file: ctx.file,
81
+ line: atLine,
82
+ ruleId: entry.id,
83
+ message: 'rename utility class',
84
+ before,
85
+ after,
86
+ });
87
+ if (entry.rule.note) {
88
+ findings.push({
89
+ kind: 'flag',
90
+ surface: 'angular',
91
+ file: ctx.file,
92
+ line: atLine,
93
+ ruleId: entry.id,
94
+ message: entry.rule.note,
95
+ });
96
+ }
97
+ };
98
+ const emitReport = (atLine, entry, token) => {
99
+ findings.push({
100
+ kind: 'warn',
101
+ surface: 'angular',
102
+ file: ctx.file,
103
+ line: atLine,
104
+ ruleId: entry.id,
105
+ message: `\`${token}\`: ${entry.rule.message}`,
106
+ });
107
+ };
108
+ const classAttr = findStatic('class');
109
+ if (classAttr?.valueSpan) {
110
+ const span = classAttr.valueSpan;
111
+ const atLine = classAttr.sourceSpan.start.line;
112
+ const result = rewriteClassList(text(span), classRules, enabled, (entry, before, after) => emitRename(atLine, entry, before, after), (entry, token) => emitReport(atLine, entry, token));
113
+ if (result.changed)
114
+ edits.push({ start: span.start.offset, end: span.end.offset, text: result.value });
115
+ }
116
+ for (const input of inputs) {
117
+ const keySpan = input.keySpan;
118
+ if (!keySpan)
119
+ continue;
120
+ const key = text(keySpan);
121
+ const atLine = input.sourceSpan.start.line;
122
+ if (key.startsWith('class.')) {
123
+ const token = key.slice('class.'.length);
124
+ const rename = classRules.renames.get(token);
125
+ if (rename && enabled(rename.id)) {
126
+ edits.push({
127
+ start: keySpan.start.offset,
128
+ end: keySpan.end.offset,
129
+ text: `class.${rename.rule.to}`,
130
+ });
131
+ emitRename(atLine, rename, key, `class.${rename.rule.to}`);
132
+ continue;
133
+ }
134
+ const report = classRules.reports.get(token);
135
+ if (report && enabled(report.id))
136
+ emitReport(atLine, report, token);
137
+ continue;
138
+ }
139
+ if (key !== 'ngClass' && key !== 'class' && key !== 'attr.class')
140
+ continue;
141
+ const span = input.valueSpan;
142
+ if (!span)
143
+ continue;
144
+ const raw = text(span);
145
+ if (!classRules.candidateRe?.test(raw))
146
+ continue;
147
+ let exprChanged = false;
148
+ const newRaw = raw.replace(/(['"`])((?:\\.|(?!\1)[^\\])*)\1/g, (match, quote, body) => {
149
+ const result = rewriteClassList(body, classRules, enabled, (entry, before, after) => {
150
+ exprChanged = true;
151
+ emitRename(atLine, entry, before, after);
152
+ }, (entry, token) => emitReport(atLine, entry, token));
153
+ return result.changed ? `${quote}${result.value}${quote}` : match;
154
+ });
155
+ if (exprChanged)
156
+ edits.push({ start: span.start.offset, end: span.end.offset, text: newRaw });
157
+ }
158
+ }
159
+ const component = getByTag(manifest, tag);
160
+ if (!component)
161
+ return;
162
+ for (const rule of rulesForComponent(manifest, component)) {
163
+ const id = ruleId(tag, rule);
164
+ if (!ruleEnabled(ctx, id))
165
+ continue;
166
+ switch (rule.kind) {
167
+ case 'booleanInvert':
168
+ applyBooleanInvert(rule, id);
169
+ break;
170
+ case 'propRename':
171
+ applyPropRename(rule, id);
172
+ break;
173
+ case 'propRemove':
174
+ applyPropRemove(rule, id);
175
+ break;
176
+ case 'enumRemap':
177
+ applyEnumRemap(rule, id);
178
+ break;
179
+ case 'slotToAttr':
180
+ applySlotToAttr(rule, id);
181
+ break;
182
+ case 'ensureAttr':
183
+ applyEnsureAttr(rule, id);
184
+ break;
185
+ case 'slotRemove':
186
+ applySlotRemove(rule, id);
187
+ break;
188
+ case 'tagRename':
189
+ applyTagRename(rule, id);
190
+ break;
191
+ default:
192
+ break;
193
+ }
194
+ }
195
+ /**
196
+ * Rename the start tag and the end tag. The end span is only an end tag when
197
+ * the source there reads `</tag`: a self-closing element reports its start
198
+ * span again.
199
+ */
200
+ function applyTagRename(rule, id) {
201
+ const nameEdit = (at) => ({ start: at, end: at + tag.length, text: rule.to });
202
+ const startSpan = el.startSourceSpan;
203
+ edits.push(nameEdit(startSpan.start.offset + 1));
204
+ const endSpan = el.endSourceSpan;
205
+ if (endSpan && source.startsWith(`</${tag}`, endSpan.start.offset))
206
+ edits.push(nameEdit(endSpan.start.offset + 2));
207
+ findings.push({
208
+ kind: 'change',
209
+ surface: 'angular',
210
+ file: ctx.file,
211
+ line,
212
+ ruleId: id,
213
+ component: tag,
214
+ message: `rename <${tag}> to <${rule.to}>`,
215
+ before: tag,
216
+ after: rule.to,
217
+ });
218
+ }
219
+ function applyEnsureAttr(rule, id) {
220
+ const startSpan = el.startSourceSpan;
221
+ if (rule.unless.some((p) => findStatic(p.attr) || findInput(p.prop)))
222
+ return;
223
+ const insertAt = startSpan.end.offset - 1;
224
+ const after = rule.value === undefined ? rule.attr.attr : `${rule.attr.attr}="${escapeAttr(rule.value)}"`;
225
+ edits.push({ start: insertAt, end: insertAt, text: ` ${after}` });
226
+ findings.push({
227
+ kind: 'change',
228
+ surface: 'angular',
229
+ file: ctx.file,
230
+ line,
231
+ ruleId: id,
232
+ component: tag,
233
+ message: rule.reason,
234
+ after,
235
+ });
236
+ }
237
+ function applySlotRemove(rule, id) {
238
+ for (const child of el.children) {
239
+ if (!(child instanceof TmplAstElement))
240
+ continue;
241
+ const slot = child.attributes.find((a) => a.name === 'slot' && a.value === rule.from);
242
+ if (!slot)
243
+ continue;
244
+ findings.push({
245
+ kind: 'warn',
246
+ surface: 'angular',
247
+ file: ctx.file,
248
+ line: child.startSourceSpan.start.line,
249
+ ruleId: id,
250
+ component: tag,
251
+ message: `the \`${rule.from}\` slot of ${tag} was removed in v2; migrate the slotted content manually`,
252
+ });
253
+ }
254
+ }
255
+ function replaceStaticBoolean(rule, attr, id) {
256
+ const value = attr.valueSpan ? stringLiteral(attr.value) : bareTrue();
257
+ const outcome = invertBoolean(value, rule.newDefault);
258
+ const span = attr.sourceSpan;
259
+ if (outcome.action === 'omit')
260
+ edits.push(removeSpanEdit(span));
261
+ else if (outcome.action === 'shorthandTrue')
262
+ edits.push({ start: span.start.offset, end: span.end.offset, text: rule.to.attr });
263
+ else if (outcome.action === 'explicitFalse')
264
+ edits.push({
265
+ start: span.start.offset,
266
+ end: span.end.offset,
267
+ text: `${rule.to.attr}="false"`,
268
+ });
269
+ findings.push({
270
+ kind: 'change',
271
+ surface: 'angular',
272
+ file: ctx.file,
273
+ line,
274
+ ruleId: id,
275
+ component: tag,
276
+ message: `invert ${rule.from.prop} → ${rule.to.prop}`,
277
+ before: text(span),
278
+ });
279
+ }
280
+ function replaceInputBoolean(rule, input, id) {
281
+ const expr = input.valueSpan ? text(input.valueSpan) : '';
282
+ const outcome = invertBoolean(dynamic(expr), rule.newDefault);
283
+ const span = input.sourceSpan;
284
+ let after = '';
285
+ if (outcome.action === 'omit')
286
+ edits.push(removeSpanEdit(span));
287
+ else if (outcome.action === 'shorthandTrue') {
288
+ after = rule.to.attr;
289
+ edits.push({ start: span.start.offset, end: span.end.offset, text: after });
290
+ }
291
+ else if (outcome.action === 'explicitFalse') {
292
+ after = `${rule.to.attr}="false"`;
293
+ edits.push({ start: span.start.offset, end: span.end.offset, text: after });
294
+ }
295
+ else {
296
+ after = `[${rule.to.prop}]="${outcome.expr}"`;
297
+ edits.push({ start: span.start.offset, end: span.end.offset, text: after });
298
+ }
299
+ findings.push({
300
+ kind: 'change',
301
+ surface: 'angular',
302
+ file: ctx.file,
303
+ line,
304
+ ruleId: id,
305
+ component: tag,
306
+ message: `invert ${rule.from.prop} → ${rule.to.prop}`,
307
+ before: text(span),
308
+ after,
309
+ });
310
+ }
311
+ function applyBooleanInvert(rule, id) {
312
+ const s = findStatic(rule.from.attr);
313
+ if (s)
314
+ replaceStaticBoolean(rule, s, id);
315
+ const i = findInput(rule.from.prop);
316
+ if (i)
317
+ replaceInputBoolean(rule, i, id);
318
+ }
319
+ function applyPropRename(rule, id) {
320
+ const s = findStatic(rule.from.attr);
321
+ if (s) {
322
+ const span = s.sourceSpan;
323
+ const text2 = s.valueSpan ? `${rule.to.attr}="${s.value}"` : rule.to.attr;
324
+ edits.push({ start: span.start.offset, end: span.end.offset, text: text2 });
325
+ findings.push({
326
+ kind: 'change',
327
+ surface: 'angular',
328
+ file: ctx.file,
329
+ line,
330
+ ruleId: id,
331
+ component: tag,
332
+ message: `rename ${rule.from.prop} → ${rule.to.prop}`,
333
+ });
334
+ }
335
+ const i = findInput(rule.from.prop);
336
+ if (i && i.valueSpan) {
337
+ const span = i.sourceSpan;
338
+ edits.push({
339
+ start: span.start.offset,
340
+ end: span.end.offset,
341
+ text: `[${rule.to.prop}]="${text(i.valueSpan)}"`,
342
+ });
343
+ findings.push({
344
+ kind: 'change',
345
+ surface: 'angular',
346
+ file: ctx.file,
347
+ line,
348
+ ruleId: id,
349
+ component: tag,
350
+ message: `rename [${rule.from.prop}] → [${rule.to.prop}]`,
351
+ });
352
+ }
353
+ }
354
+ function applyPropRemove(rule, id) {
355
+ const s = findStatic(rule.prop.attr);
356
+ const i = findInput(rule.prop.prop);
357
+ const span = (s?.sourceSpan ?? i?.sourceSpan);
358
+ if (!span)
359
+ return;
360
+ edits.push(removeSpanEdit(span));
361
+ findings.push({
362
+ kind: 'warn',
363
+ surface: 'angular',
364
+ file: ctx.file,
365
+ line,
366
+ ruleId: id,
367
+ component: tag,
368
+ message: rule.message,
369
+ });
370
+ }
371
+ function applyEnumRemap(rule, id) {
372
+ const v2set = rule.v2set ? component.v2EnumSets?.[rule.v2set] : undefined;
373
+ const s = findStatic(rule.prop.attr);
374
+ if (s) {
375
+ const outcome = remapEnum(s.valueSpan ? stringLiteral(s.value) : bareTrue(), rule.map, v2set);
376
+ if (outcome.action === 'rename') {
377
+ edits.push({
378
+ start: s.sourceSpan.start.offset,
379
+ end: s.sourceSpan.end.offset,
380
+ text: `${rule.prop.attr}="${outcome.value}"`,
381
+ });
382
+ findings.push({
383
+ kind: 'change',
384
+ surface: 'angular',
385
+ file: ctx.file,
386
+ line,
387
+ ruleId: id,
388
+ component: tag,
389
+ message: `remap ${rule.prop.prop}`,
390
+ after: outcome.value,
391
+ });
392
+ }
393
+ else if (outcome.action === 'flag') {
394
+ findings.push({
395
+ kind: 'flag',
396
+ surface: 'angular',
397
+ file: ctx.file,
398
+ line,
399
+ ruleId: id,
400
+ component: tag,
401
+ message: `${rule.prop.prop}="${s.value}": ${outcome.reason}`,
402
+ });
403
+ }
404
+ }
405
+ const i = findInput(rule.prop.prop);
406
+ if (i)
407
+ findings.push({
408
+ kind: 'dynamic',
409
+ surface: 'angular',
410
+ file: ctx.file,
411
+ line,
412
+ ruleId: id,
413
+ component: tag,
414
+ message: `[${rule.prop.prop}] is dynamic; remap the enum value by hand`,
415
+ });
416
+ }
417
+ function applySlotToAttr(rule, id) {
418
+ if (rule.slot !== 'default')
419
+ return;
420
+ if (findStatic(rule.to.attr) || findInput(rule.to.prop))
421
+ return;
422
+ const startSpan = el.startSourceSpan;
423
+ const endSpan = el.endSourceSpan;
424
+ if (!endSpan)
425
+ return;
426
+ const elementChildren = el.children.filter((c) => c instanceof TmplAstElement || c instanceof TmplAstTemplate);
427
+ const textChildren = el.children.filter((c) => c instanceof TmplAstText);
428
+ const boundChildren = el.children.filter((c) => c instanceof TmplAstBoundText);
429
+ const staticText = collapseWhitespace(textChildren.map((c) => c.value).join(''));
430
+ const flag = (message) => {
431
+ findings.push({
432
+ kind: 'dynamic',
433
+ surface: 'angular',
434
+ file: ctx.file,
435
+ line,
436
+ ruleId: id,
437
+ component: tag,
438
+ message,
439
+ });
440
+ };
441
+ let attrText;
442
+ let before = '';
443
+ if (elementChildren.length > 0) {
444
+ flag(`<${tag}> content contains elements; move the text into \`${rule.to.attr}\` manually`);
445
+ return;
446
+ }
447
+ else if (boundChildren.length === 0 && staticText !== '') {
448
+ attrText = `${rule.to.attr}="${escapeAttr(staticText)}"`;
449
+ before = staticText;
450
+ }
451
+ else if (boundChildren.length === 1 && staticText === '') {
452
+ const raw = text(boundChildren[0].sourceSpan).trim();
453
+ const single = raw.startsWith('{{') && raw.endsWith('}}') && raw.indexOf('{{', 2) === -1;
454
+ if (!single) {
455
+ flag(`<${tag}> has complex interpolation; move it into \`${rule.to.attr}\` manually`);
456
+ return;
457
+ }
458
+ attrText = `[${rule.to.prop}]="${raw.slice(2, -2).trim()}"`;
459
+ before = raw;
460
+ }
461
+ else if (boundChildren.length === 0 && staticText === '') {
462
+ return;
463
+ }
464
+ else {
465
+ flag(`<${tag}> has mixed content; move it into \`${rule.to.attr}\` manually`);
466
+ return;
467
+ }
468
+ const insertAt = startSpan.end.offset - 1;
469
+ edits.push({ start: insertAt, end: insertAt, text: ` ${attrText}` });
470
+ edits.push({ start: startSpan.end.offset, end: endSpan.start.offset, text: '' });
471
+ findings.push({
472
+ kind: 'change',
473
+ surface: 'angular',
474
+ file: ctx.file,
475
+ line,
476
+ ruleId: id,
477
+ component: tag,
478
+ message: `lift content into \`${rule.to.attr ?? rule.to.prop}\``,
479
+ before,
480
+ after: attrText,
481
+ });
482
+ }
483
+ };
484
+ for (const node of parsed.nodes)
485
+ visit(node);
486
+ if (edits.length === 0)
487
+ return { output: source, changed: false, findings };
488
+ return { output: applyEdits(source, edits), changed: true, findings };
489
+ };
@@ -0,0 +1,5 @@
1
+ import { type Manifest } from '../manifest/schema.js';
2
+ import { type TransformContext, type TransformResult } from './shared/transform.js';
3
+ export declare const transformCss: (source: string, manifest: Manifest, ctx: TransformContext, options?: {
4
+ scss?: boolean;
5
+ }) => TransformResult;