@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
package/README.md ADDED
@@ -0,0 +1,193 @@
1
+ # @maggioli-design-system/magma-codemods
2
+
3
+ Codemods to migrate consumer code of [`@maggioli-design-system/magma`](https://www.npmjs.com/package/@maggioli-design-system/magma)
4
+ from **v1** to **v2**. They rewrite HTML, React (JSX/TSX), Angular templates (external `.html` and inline
5
+ `@Component({ template })`) and CSS/SCSS, applying the breaking changes automatically and reporting the cases that
6
+ need a human decision.
7
+
8
+ ESM package, Node ≥ 22.
9
+
10
+ ## Usage
11
+
12
+ ```bash
13
+ npx @maggioli-design-system/magma-codemods --path ./src
14
+ ```
15
+
16
+ By default the tool runs in **dry-run** (prints a coloured diff + a summary, writes nothing). Pass `--write` to
17
+ apply the changes in place.
18
+
19
+ ```
20
+ --framework <react|angular|html|css|auto> surface (default: auto, inferred from the extension)
21
+ --path <file|dir> file or directory to scan (repeatable; positional args also work)
22
+ --dry-run print a diff and report, write nothing (DEFAULT)
23
+ --write apply changes in place
24
+ --force allow --write on a dirty git working tree
25
+ --ignore <glob> extra ignore globs (repeatable)
26
+ --report <path> write the JSON report
27
+ --only <ruleId,...> / --skip <ruleId,...> run/skip specific rules (see the ids in the report)
28
+ --manifest <path> override the bundled manifest (JSON)
29
+ -h, --help
30
+ ```
31
+
32
+ Notes:
33
+
34
+ - `auto` maps `.css/.scss → css`, `.tsx/.jsx → react`, `.ts → Angular inline templates`, `.html → html`. For
35
+ **Angular external templates** (`.html`), pass `--framework angular`.
36
+ - `--write` refuses to run on a dirty git working tree unless `--force`, so the undo is always `git checkout`.
37
+ - `node_modules`, `dist`, `.git`, `build`, `.next` and `coverage` are ignored by default.
38
+
39
+ ## Migration matrix
40
+
41
+ | # | Category | What it does | Confidence |
42
+ | --- | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- |
43
+ | A | Enum remap (`tone`) | `ghost → outline`, `quiet → weak`; on the three components whose v2 tone set gained `text` (`mds-button`, `mds-radial-menu`, `mds-radial-menu-item`) the documented intent applies instead: `quiet → text`. Validated against each component's v2 set | safe with validation |
44
+ | B | Boolean inversion | rename + negate value: `arrow → hideArrow` (dropdown **and tooltip**), `autoPlacement → disableAutoPlacement`, `backdrop → hideBackdrop`, `cockade → hideCockade`, `showDownloadedIcon → hideDownloadedIcon`, … plus the curated pairs the name heuristic cannot see: `closable → disableClose`, `visible → dismissed`, and the `mds-calendar` set inverted after the docs snapshot (#685): `rangePicker → singlePicker`, `showPreviousButton → hidePreviousButton`, `showNextButton → hideNextButton`, `showPreselection → hidePreselection` | safe |
45
+ | C | Prop removal | warn + inline comment (HTML) / report (JSX, Angular): e.g. `mds-button hasText`, `mds-modal animating` | report |
46
+ | D | Prop rename | `mds-label labelAction → label` | curated |
47
+ | E | Misc enum shifts | remap or flag | mixed |
48
+ | F | `slot="default"` removal | drop the attribute (v2 uses the unnamed default slot) | safe |
49
+ | F2 | Slot → attribute | lift slotted text into an attribute: `Save` → `label="Save"` (`label={expr}` / `[label]="expr"` for dynamic). Preferred form on `mds-button` (v2 still reads slotted text); **mandatory** on `mds-breadcrumb-item` and `mds-tab-item`, whose v2 render dropped the slot entirely. Element/mixed content → reported | text: safe · markup: manual |
50
+ | F3 | Removed named slot | report children using a slot dropped in v2: `mds-push-notification` `slot="top"` / `slot="bottom"` | report |
51
+ | G | CSS custom property rename | `--mds-*-ghost-* → --mds-*-outline-*`, `--mds-*-color → --mds-*-color-rgb` (value hex → `R G B` flagged), plus the curated renames the docs diff saw as removals: `--mds-banner-gap → --mds-banner-content-gap`, `--mds-header-backdrop-filter → --mds-header-backdrop-blur-strength` (both value-flagged), the `shodow → shadow` / triple-dash typo fixes on `mds-filter(-item)`, `--mds-tab-item-transition-* → --mds-tab-transition-*`, and the v1 typo'd names corrected in v2 (#566, plus #328's property registration): `--mds-video-wall-noise-fitler → --mds-video-wall-noise-filter`, `--mds-file-preview-icon-bacground → --mds-file-preview-icon-background`, `--mds-stepper-bar-item-duaration → --mds-stepper-bar-item-duration` | name: safe · value: manual |
52
+ | G2 | CSS custom property removal | warn on definitions/`var()` references of the ~11 properties removed with no replacement (e.g. `--mds-entity-shadow`, `--mds-table-cell-*`) | report |
53
+ | G3 | Semantic color migration (#576) | seed rename `--tone-<family> → --tone-<family>-seed` (A2), rewritten; plus report-only surface candidates: a neutral tone (bare token or any scale step) used as a _background_ (a `background`/`background-color` property, or a `--mds-*-background*` token) is reported for manual migration to a `--magma-surface-*` role (the exact role, default/raised/overlay, is contextual) | seed: safe · surface: report |
54
+ | H | Shadow part rename | rename in `::part()` selectors | safe |
55
+ | I | Event rename | declared in the manifest schema, but **not implemented by any surface yet** — no event was renamed between v1.12 and v2.0.0-beta, so no rule currently exists | n/a |
56
+ | J | Utility-class migration | the styles-package Tailwind contract that changed between v1 and v2: the `shadow-outline-*` ring family → `shadow-ring-*`, the retuned `rounded-*` / `border-*` / named `gap-*` scales. Value-exact renames are rewritten; combos with no v2 token are reported (see below) | rename: safe · report: manual |
57
+ | K | Tag rename (mode vs theme) | the light / dark / system control `mds-pref-theme` becomes `mds-pref-mode` (#702): the tag in HTML / Angular (start and end tag) and in CSS type selectors, the React component in JSX and in the named import from `magma-react` (with its other references, e.g. `typeof MdsPrefTheme`), the mode classes `pref-theme-{light,dark,system}` -> `pref-mode-*` in markup AND in CSS selectors, `--magma-pref-theme` -> `--magma-pref-mode`, the overlay properties and class `--mds-pref-theme-overlay-*` / `.mds-pref-theme-overlay` -> `mds-pref-mode-overlay`. Code written against a v2 beta also gets `mds-pref-theme-variant(-item)` -> `mds-pref-theme(-item)` and `--magma-pref-theme-name` -> `--magma-pref-theme`, applied in the same pass| safe (run once) |
58
+
59
+ The bundled manifest is built by diffing the two `documentation.json` builds (`manifest.generated.ts`) with curated
60
+ corrections layered on top in `src/manifest/manifest.ts`.
61
+
62
+ ### Behaviour guards (preserving v1 defaults)
63
+
64
+ Some inversions also flip the _default_ behaviour. On `mds-dropdown`, v1 had auto-placement **off** by default
65
+ (`auto-placement` opt-in) while v2 has it **on** (`disable-auto-placement` opt-out). To keep the v1 behaviour, the
66
+ codemod adds `disable-auto-placement` to dropdowns that set neither prop:
67
+
68
+ | Input | Output |
69
+ | ----------------------------------------- | --------------------------------------------------- |
70
+ | `<mds-dropdown>` (auto-placement was off) | `<mds-dropdown disable-auto-placement>` (stays off) |
71
+ | `<mds-dropdown auto-placement>` (was on) | `<mds-dropdown>` (stays on — v2 default) |
72
+
73
+ (`mds-tooltip`'s auto-placement default did not change, so no guard is applied there. Likewise `mds-calendar`'s
74
+ `show-preselection → hide-preselection`: the preselection area already appeared automatically whenever the slot had
75
+ content, and still does, so dropping the v1 opt-in flips nothing.)
76
+
77
+ The same guard covers other default flips (same prop, new default — invisible to the docs diff):
78
+
79
+ | Component | v1 default | v2 default | Guard |
80
+ | ---------------------------- | ----------------- | ----------------- | ---------------------- |
81
+ | `mds-push-notification-item` | `deletable` on | off | adds `deletable` |
82
+ | `mds-banner` | `variant="light"` | `primary` | adds `variant="light"` |
83
+ | `mds-label` | no truncation | `truncate="word"` | adds `truncate="none"` |
84
+
85
+ (`mds-emoji`'s default `name` changed `hexabot → mia`; deliberately not guarded — treat it as branding.)
86
+
87
+ ### Utility-class migration (J)
88
+
89
+ The styles package's Tailwind token contract changed between v1 and v2; the codemod rewrites the classes whose
90
+ **value survives under a new name** (verified value-by-value against the two token sets) and reports the rest. It
91
+ runs on `class` attributes of **any** element (HTML, Angular templates, inline templates), `className`/`class` in
92
+ JSX — including string literals inside `clsx()`/ternaries — `[class.x]`/`[ngClass]`/`[class]` bindings in Angular,
93
+ and `@apply` in CSS/SCSS. Variant prefixes (`hover:`, `md:`, arbitrary variants) and important markers are
94
+ preserved; only the utility segment is rewritten.
95
+
96
+ | Family | Renames (value-exact) | Reported (no exact v2 token) | Unchanged |
97
+ | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
98
+ | Shadows | `shadow-sm → shadow-xs`, `shadow-sm-sharp → shadow-xs-sharp`, `shadow-inner → shadow-inset-sm` (near-exact, flagged) | — | `shadow`, `shadow-sharp`, `shadow-md/lg/xl/2xl(-sharp)`, `shadow-none` |
99
+ | Ring family (#641) | `shadow-outline → shadow-ring`, `-outline-50 → -ring-2`, `-outline-light → -ring-weak`, `-outline-light-50 → -ring-weak-2`, `-outline-strong-50 → -ring-strong-2`, `-outline-strong-100 → -ring-strong-4` | `-outline-75/-100`, `-outline-light-75/-100`, `-outline-strong(-75)` — ⚠ v2 reuses the name `shadow-outline-strong` for a **different** shadow, so leaving it is a silent restyle | — |
100
+ | Radius | `rounded → rounded-3xs`, `md → 2xs`, `lg → xs`, `xl → md`, `2xl → lg`, `3xl → 2xl` — expanded over every corner/side variant (`rounded-t-*`, `rounded-tl-*`, …) | `rounded-sm` (2px; the v2 scale starts at 4px, and v2 reuses `rounded-sm` for 10px) | `rounded-none`, `rounded-full` |
101
+ | Border width | `border-md → border-sm`, `border-lg → border-200`, `border-xl → border-800` (side variants included) | — | bare `border`, numeric steps |
102
+ | Gap | bare `gap`(`-x`/`-y`) `→ gap-lg` (flagged: skippable if it is a hand-written class), `gap-3xl → gap-2000` | — | `gap-xs`…`gap-2xl`, numeric steps |
103
+
104
+ Caveats:
105
+
106
+ - **Run it once.** The radius scale shift is a chain (`rounded-xl → rounded-md` while `rounded-md → rounded-2xs`):
107
+ a single run is single-pass and never cascades, but a second run over already-migrated code double-shifts it.
108
+ The `--write` dirty-git-tree guard is your friend here.
109
+ - Numeric steps (`p-400`, `gap-200`, `border-50`, `h-*`, `w-*`, typography, screens) kept their values everywhere
110
+ — no rules, nothing to do.
111
+ - The **generic Tailwind 3 → 4 migration** (config → CSS-first `@theme`, renamed core utilities like `shadow-sm`'s
112
+ own TW-default meaning, `outline-none`, …) is Tailwind's own upgrade guide's business, not this codemod's: only
113
+ the magma token contract is covered.
114
+
115
+ ### Mode vs theme (K)
116
+
117
+ v1 had one colour-preference control, `mds-pref-theme`, and it set the **mode** (light / dark / system). v2 calls
118
+ it `mds-pref-mode` and gives the name `mds-pref-theme` to the **named theme** chooser (`default`, `business`, ...),
119
+ which v1 never had. The swap is silent: a v1 page upgraded without the codemod renders the theme chooser where the
120
+ mode control was, with no error.
121
+
122
+ - **Run it once.** Every rename is looked up by the name as written, so one run is safe even on code that mixes a
123
+ v1 `<mds-pref-theme>` with a beta `<mds-pref-theme-variant>`. A second run over migrated code turns the v2 theme
124
+ chooser into a mode control.
125
+ - **The stored preference needs no codemod**: v2 moves a v1 `localStorage.mdsPrefTheme` (`light` / `dark` /
126
+ `system`) to `mdsPrefMode` on first load, before any control reads it.
127
+ - **Imperative code is not rewritten** (as everywhere): `querySelector('mds-pref-theme')`,
128
+ `classList.contains('pref-theme-dark')`, `getPropertyValue('--magma-pref-theme')`, a `mdsPrefChange` listener
129
+ that compares `detail.preference` with `'theme-mode'` (v2: `'mode'`). Search your scripts for `pref-theme`.
130
+ - **Beta only, not covered**: the events `mdsPrefThemeVariantChange` / `mdsPrefThemeVariantItemSelect` (v2:
131
+ `mdsPrefThemeChange` / `mdsPrefThemeItemSelect`), the `pref-theme-name-<name>` class (v2: `pref-theme-<name>`)
132
+ and `'theme-variant'` in `mdsPrefChange` (v2: `'theme'`).
133
+
134
+ ## What it cannot rewrite (reported, not changed)
135
+
136
+ These are surfaced under the **dynamic / manual** category in the report:
137
+
138
+ - React **spread props** (`<MdsButton {...props} />`), aliased components, computed prop names.
139
+ - Dynamic enum values (`tone={expr}` / `[tone]="expr"`).
140
+ - **Dynamic class lists**: a `className` template literal with `${…}` holes that mentions a migrated utility class
141
+ is reported (a hole can split a token), and an `[ngClass]="expr"` whose expression carries no string literals is
142
+ silently out of reach — only the quoted class strings inside the expression are rewritten.
143
+ - Slot content that contains **markup** (e.g. `<mds-icon>` inside `mds-button`).
144
+ - Inline templates / HTML in template literals that contain `${…}` interpolation.
145
+ - Angular `@Component({ host })` bindings are intentionally left untouched (rewriting a consumer component's own
146
+ host with `mds-*` rules is rarely correct).
147
+
148
+ Out of scope entirely (all surfaces work on markup/templates only):
149
+
150
+ - **Imperative code**: `el.backdrop = false`, `setAttribute('cockade', …)`, `addEventListener('mdsX', …)`
151
+ in plain JS/TS is never rewritten or reported.
152
+ - **`updateLang()` removal**: v2 removed the public `updateLang()` method from every localized component —
153
+ components now react automatically when `<html lang>` changes (or via `mds-pref-language`). Calls like
154
+ `el.updateLang()` live in imperative code, which these codemods do not scan: delete them by hand, no
155
+ replacement is needed. Likewise, per-element `lang` overrides on `mds-*` hosts are no longer honored
156
+ (the language is page-wide, driven by `<html lang>`), but flagging every `lang` attribute in templates
157
+ would be noise, so no rule reports it.
158
+ - **`eventRename`** exists in the manifest schema but no surface implements it — no event was renamed
159
+ between v1.12 and v2.0.0-beta, so no rule exists today. Implement it before the first real event rename.
160
+
161
+ ## Development
162
+
163
+ ```bash
164
+ npx nx run codemod:build # tsc → dist/
165
+ npx nx run codemod:test # jest (ESM)
166
+ ```
167
+
168
+ ### Regenerating the manifest from the docs
169
+
170
+ ```bash
171
+ # v2 docs come from a `dev` build; v1 docs from a one-off build of `support/v1.x` in a worktree.
172
+ FROM_VERSION=1.12.0 TO_VERSION=2.0.0 \
173
+ npm run generate.candidate -- <v1 documentation.json> <v2 documentation.json> src/manifest/manifest.candidate.json
174
+ ```
175
+
176
+ Review the candidate and merge confirmed rules into `src/manifest/manifest.ts`.
177
+
178
+ Caveats when producing the two `documentation.json`:
179
+
180
+ - A docs-only build (`stencil docs`) on `support/v1.x` does **not** extract the `styles` section (the CSS
181
+ `@prop` annotations), so every `cssVarRename` would silently drop from the candidate. Use a full
182
+ `npm run build`, or inject the `styles` arrays from the `@prop` comments before diffing.
183
+ - Both builds need the generated fixtures first: `npm run build.icons` (v1 also needs
184
+ `npm run storybook.version`).
185
+
186
+ ## Release
187
+
188
+ Two dedicated, independent GitHub Actions workflows (not part of the shared semantic-release flow):
189
+
190
+ - **`codemods-ci.yml`** — build + test, on push to `dev` and on pull requests touching `projects/codemod/**`.
191
+ - **`codemods-publish.yml`** — manual (`workflow_dispatch`): build + test → bump (`patch|minor|major|pre*`,
192
+ with a selectable prerelease id) → `npm publish` via OIDC trusted publishing → push the tag
193
+ `magma-codemods@<version>`. No commit is pushed to `main` (tag-only). Run it with `dry-run: true` first.
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * CLI wrapper around {@link runMigration}. Dry-run is the default; pass
4
+ * `--write` to apply changes (refused on a dirty git tree unless `--force`).
5
+ */
6
+ import arg from 'arg';
7
+ import chalk from 'chalk';
8
+ import { exitCode, runMigration } from './index.js';
9
+ const HELP = `magma-codemods — migrate @maggioli-design-system/magma consumer code from v1 to v2
10
+
11
+ Usage:
12
+ npx @maggioli-design-system/magma-codemods --path ./src [options]
13
+
14
+ Options:
15
+ --framework <react|angular|html|css|auto> surface (default: auto, inferred by extension)
16
+ --path <file|dir> file or directory to scan (repeatable; positional args also work)
17
+ --dry-run print a diff and report, write nothing (DEFAULT)
18
+ --write apply changes in place
19
+ --force allow --write on a dirty git working tree
20
+ --ignore <glob> extra ignore globs (repeatable)
21
+ --report <path> write the JSON report
22
+ --only <ruleId,...> run only these rules
23
+ --skip <ruleId,...> skip these rules
24
+ --manifest <path> override the bundled manifest (JSON)
25
+ -h, --help show this help
26
+ `;
27
+ const split = (value) => value
28
+ ?.split(',')
29
+ .map((s) => s.trim())
30
+ .filter(Boolean);
31
+ const main = async () => {
32
+ const args = arg({
33
+ '--framework': String,
34
+ '--path': [String],
35
+ '--write': Boolean,
36
+ '--dry-run': Boolean,
37
+ '--force': Boolean,
38
+ '--ignore': [String],
39
+ '--report': String,
40
+ '--only': String,
41
+ '--skip': String,
42
+ '--manifest': String,
43
+ '--help': Boolean,
44
+ '-h': '--help',
45
+ });
46
+ if (args['--help']) {
47
+ console.log(HELP);
48
+ return 0;
49
+ }
50
+ const paths = [...(args['--path'] ?? []), ...args._];
51
+ if (paths.length === 0) {
52
+ console.error(chalk.red('Nothing to do: pass at least one --path (or a positional path).\n'));
53
+ console.log(HELP);
54
+ return 2;
55
+ }
56
+ const { report, reporter } = await runMigration({
57
+ paths,
58
+ framework: args['--framework'] ?? 'auto',
59
+ write: args['--write'] === true,
60
+ force: args['--force'] === true,
61
+ ignore: args['--ignore'],
62
+ only: split(args['--only']),
63
+ skip: split(args['--skip']),
64
+ manifestPath: args['--manifest'],
65
+ reportPath: args['--report'],
66
+ });
67
+ console.log(reporter.renderHuman(report, { showDiff: true }));
68
+ return exitCode(report);
69
+ };
70
+ main()
71
+ .then((code) => {
72
+ process.exitCode = code;
73
+ })
74
+ .catch((error) => {
75
+ console.error(chalk.red(error instanceof Error ? error.message : String(error)));
76
+ process.exitCode = 2;
77
+ });
@@ -0,0 +1,36 @@
1
+ import { type ComponentManifest, type Manifest } from '../manifest/schema.js';
2
+ export interface DocsValue {
3
+ value?: string;
4
+ type: string;
5
+ }
6
+ export interface DocsProp {
7
+ name: string;
8
+ attr?: string;
9
+ type: string;
10
+ values?: DocsValue[];
11
+ default?: string;
12
+ }
13
+ export interface DocsComponent {
14
+ tag: string;
15
+ props?: DocsProp[];
16
+ styles?: {
17
+ name: string;
18
+ }[];
19
+ parts?: {
20
+ name: string;
21
+ }[];
22
+ slots?: {
23
+ name: string;
24
+ }[];
25
+ events?: {
26
+ event: string;
27
+ }[];
28
+ }
29
+ export interface JsonDocs {
30
+ components: DocsComponent[];
31
+ }
32
+ export declare const diffComponent: (v1: DocsComponent, v2: DocsComponent) => ComponentManifest;
33
+ export declare const generateCandidateManifest: (v1: JsonDocs, v2: JsonDocs, opts?: {
34
+ fromVersion?: string;
35
+ toVersion?: string;
36
+ }) => Manifest;
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Generate a **candidate** manifest by diffing two Stencil `documentation.json`
3
+ * payloads (v1 and v2). The deterministic outputs — attr↔prop pairs, React
4
+ * names, the per-component v2 enum sets — are reliable; the pairing heuristics
5
+ * (boolean inversion, CSS var renames, part/event renames) are best-effort
6
+ * candidates marked `review`/`manual` for a human to ratify. The runtime
7
+ * `manifest.ts` is then maintained by merging this candidate's output.
8
+ */
9
+ import { propToAttr, tagToReactName } from '../manifest/registry.js';
10
+ const cap = (s) => s.charAt(0).toUpperCase() + s.slice(1);
11
+ // Stencil emits `boolean | undefined` for optional props, so match on the union members.
12
+ const isBool = (p) => p.type.split('|').some((t) => t.trim() === 'boolean');
13
+ const enumValues = (p) => {
14
+ const values = (p.values ?? [])
15
+ .filter((v) => v.type === 'string' && typeof v.value === 'string')
16
+ .map((v) => v.value);
17
+ return values.length > 1 ? values : undefined;
18
+ };
19
+ const propId = (p) => ({ attr: p.attr ?? propToAttr(p.name), prop: p.name });
20
+ const byName = (items) => new Map(items.map((i) => [i.name, i]));
21
+ const setEq = (a, b) => a.length === b.length && a.every((v) => b.includes(v));
22
+ /** Find the v2 boolean prop that looks like the inverted form of a removed v1 prop. */
23
+ const findInverted = (removed, addedBool) => {
24
+ const name = removed.name;
25
+ const negated = (base) => [
26
+ `hide${cap(base)}`,
27
+ `disable${cap(base)}`,
28
+ `no${cap(base)}`,
29
+ ];
30
+ const candidates = negated(name);
31
+ // v1 `showX` → v2 `hideX`: the `show` prefix drops, the negation flips.
32
+ const shown = name.replace(/^show([A-Z])/, (_, c) => c.toLowerCase());
33
+ if (shown !== name)
34
+ candidates.push(...negated(shown));
35
+ // v1 already negated (e.g. `hideArrow` → v2 `arrow`): stripping the prefix flips the polarity.
36
+ // (`show` is intentionally not stripped here: `showX` → `x` keeps the polarity, so it is not an inversion.)
37
+ const stripped = name.replace(/^(hide|disable|no)([A-Z])/, (_, __, c) => c.toLowerCase());
38
+ if (stripped !== name)
39
+ candidates.push(stripped);
40
+ return addedBool.find((a) => candidates.includes(a.name));
41
+ };
42
+ export const diffComponent = (v1, v2) => {
43
+ const v1Props = byName(v1.props ?? []);
44
+ const v2Props = byName(v2.props ?? []);
45
+ const removedProps = [...v1Props.values()].filter((p) => !v2Props.has(p.name));
46
+ const addedProps = [...v2Props.values()].filter((p) => !v1Props.has(p.name));
47
+ const addedBool = addedProps.filter(isBool);
48
+ const usedAdded = new Set();
49
+ const rules = [];
50
+ // Boolean inversion (B).
51
+ for (const r of removedProps.filter(isBool)) {
52
+ const a = findInverted(r, addedBool.filter((p) => !usedAdded.has(p.name)));
53
+ if (a) {
54
+ usedAdded.add(a.name);
55
+ rules.push({
56
+ kind: 'booleanInvert',
57
+ from: propId(r),
58
+ to: propId(a),
59
+ oldDefault: r.default === 'true',
60
+ newDefault: a.default === 'true',
61
+ confidence: 'review',
62
+ });
63
+ }
64
+ }
65
+ // Enum changes on props present in both (A/E).
66
+ const v2EnumSets = {};
67
+ for (const [name, v2p] of v2Props) {
68
+ const v2set = enumValues(v2p);
69
+ if (v2set)
70
+ v2EnumSets[name] = v2set;
71
+ const v1p = v1Props.get(name);
72
+ if (!v1p)
73
+ continue;
74
+ const v1set = enumValues(v1p);
75
+ if (v1set && v2set && !setEq(v1set, v2set)) {
76
+ const removedValues = v1set.filter((v) => !v2set.includes(v));
77
+ if (removedValues.length) {
78
+ rules.push({
79
+ kind: 'enumRemap',
80
+ prop: propId(v2p),
81
+ map: Object.fromEntries(removedValues.map((v) => [v, null])),
82
+ v2set: name,
83
+ confidence: 'manual',
84
+ });
85
+ }
86
+ }
87
+ }
88
+ // Removed props with no inverted pair → removal (C).
89
+ const invertedFromNames = new Set(rules.flatMap((rule) => (rule.kind === 'booleanInvert' ? [rule.from.prop] : [])));
90
+ for (const r of removedProps) {
91
+ if (invertedFromNames.has(r.name))
92
+ continue;
93
+ rules.push({
94
+ kind: 'propRemove',
95
+ prop: propId(r),
96
+ strategy: 'comment',
97
+ message: `\`${r.name}\` was removed in v2; migrate manually.`,
98
+ });
99
+ }
100
+ // CSS custom properties: renames (G) and plain removals (G2).
101
+ const v2Styles = new Set((v2.styles ?? []).map((s) => s.name));
102
+ const v1Styles = (v1.styles ?? []).map((s) => s.name);
103
+ for (const name of v1Styles) {
104
+ if (v2Styles.has(name))
105
+ continue;
106
+ const base = name.slice(2); // strip leading --
107
+ if (v2Styles.has(`${name}-rgb`)) {
108
+ rules.push({ kind: 'cssVarRename', from: base, to: `${base}-rgb`, valueFormatChanged: true });
109
+ }
110
+ else if (name.includes('ghost') && v2Styles.has(name.replace('ghost', 'outline'))) {
111
+ rules.push({
112
+ kind: 'cssVarRename',
113
+ from: base,
114
+ to: name.replace('ghost', 'outline').slice(2),
115
+ });
116
+ }
117
+ else {
118
+ rules.push({
119
+ kind: 'cssVarRemove',
120
+ name: base,
121
+ message: `\`${name}\` was removed in v2 with no replacement; migrate manually.`,
122
+ });
123
+ }
124
+ }
125
+ // Named slots removed in v2 (F). The default slot is handled by the global rule.
126
+ const v2Slots = new Set((v2.slots ?? []).map((s) => s.name));
127
+ for (const { name } of v1.slots ?? []) {
128
+ if (name === '' || name === 'default' || v2Slots.has(name))
129
+ continue;
130
+ rules.push({ kind: 'slotRemove', from: name });
131
+ }
132
+ // Shadow parts (H) and events (I): pair when exactly one was removed and one added.
133
+ pairRename((v1.parts ?? []).map((p) => p.name), (v2.parts ?? []).map((p) => p.name), (from, to) => rules.push({ kind: 'partRename', from, to }));
134
+ pairRename((v1.events ?? []).map((e) => e.event), (v2.events ?? []).map((e) => e.event), (from, to) => rules.push({ kind: 'eventRename', from, to }));
135
+ const component = { tag: v2.tag, react: tagToReactName(v2.tag), rules };
136
+ if (Object.keys(v2EnumSets).length)
137
+ component.v2EnumSets = v2EnumSets;
138
+ return component;
139
+ };
140
+ const pairRename = (v1, v2, emit) => {
141
+ const removed = v1.filter((n) => !v2.includes(n));
142
+ const added = v2.filter((n) => !v1.includes(n));
143
+ if (removed.length === 1 && added.length === 1)
144
+ emit(removed[0], added[0]);
145
+ };
146
+ export const generateCandidateManifest = (v1, v2, opts = {}) => {
147
+ const v1ByTag = new Map(v1.components.map((c) => [c.tag, c]));
148
+ const components = {};
149
+ let hasTone = false;
150
+ let hasDefaultSlot = false;
151
+ for (const v2c of v2.components) {
152
+ const v1c = v1ByTag.get(v2c.tag);
153
+ if (!v1c)
154
+ continue; // new component, nothing to migrate
155
+ const candidate = diffComponent(v1c, v2c);
156
+ if (candidate.rules.length || candidate.v2EnumSets)
157
+ components[v2c.tag] = candidate;
158
+ if ((v2c.props ?? []).some((p) => p.name === 'tone'))
159
+ hasTone = true;
160
+ if ((v1c.slots ?? []).some((s) => s.name === 'default'))
161
+ hasDefaultSlot = true;
162
+ }
163
+ return {
164
+ fromVersion: opts.fromVersion ?? 'unknown',
165
+ toVersion: opts.toVersion ?? 'unknown',
166
+ global: {
167
+ ...(hasTone
168
+ ? {
169
+ tone: {
170
+ prop: { attr: 'tone', prop: 'tone' },
171
+ map: { ghost: 'outline', quiet: 'weak' },
172
+ toneSet: 'tone',
173
+ },
174
+ }
175
+ : {}),
176
+ removeDefaultSlot: hasDefaultSlot,
177
+ },
178
+ components,
179
+ };
180
+ };
@@ -0,0 +1,39 @@
1
+ import { type Manifest } from './manifest/schema.js';
2
+ import { Reporter } from './report/reporter.js';
3
+ import { type Report, type Surface } from './report/types.js';
4
+ import { type TransformContext, type TransformResult } from './surfaces/shared/transform.js';
5
+ export type Framework = 'react' | 'angular' | 'html' | 'css' | 'auto';
6
+ export interface MigrationOptions {
7
+ /** Files and/or directories to scan. */
8
+ paths: string[];
9
+ framework: Framework;
10
+ /** Apply changes in place. Default: dry-run. */
11
+ write?: boolean;
12
+ /** Allow `write` on a dirty git working tree. */
13
+ force?: boolean;
14
+ /** Extra ignore globs (added to the defaults). */
15
+ ignore?: string[];
16
+ only?: string[];
17
+ skip?: string[];
18
+ /** Override the bundled manifest with a JSON file. */
19
+ manifestPath?: string;
20
+ /** Write the JSON report to this path. */
21
+ reportPath?: string;
22
+ cwd?: string;
23
+ }
24
+ export interface MigrationRun {
25
+ report: Report;
26
+ reporter: Reporter;
27
+ }
28
+ interface Route {
29
+ surface: Surface;
30
+ run: (source: string, manifest: Manifest, ctx: TransformContext) => TransformResult;
31
+ }
32
+ /** Pick the transform for a file given the framework (or `auto` by extension). Returns null to skip. */
33
+ export declare const routeFile: (file: string, framework: Framework) => Route | null;
34
+ /** Expand the input paths into a concrete, de-duplicated file list. */
35
+ export declare const collectFiles: (paths: string[], cwd: string, ignore: string[]) => Promise<string[]>;
36
+ export declare const runMigration: (options: MigrationOptions) => Promise<MigrationRun>;
37
+ export * from './report/types.js';
38
+ export { exitCode } from './report/reporter.js';
39
+ export type { Manifest } from './manifest/schema.js';