@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
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
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
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -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';
|