@assure-one/design-system 1.32.0 → 1.34.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 +44 -13
- package/codemods/README.md +146 -14
- package/codemods/lib/forms.mjs +253 -0
- package/codemods/lib/jsx-edit.mjs +59 -0
- package/codemods/lib/jsx.mjs +0 -0
- package/codemods/lib/ledger.mjs +18 -6
- package/codemods/lib/registry.mjs +4 -0
- package/codemods/lib/report.mjs +1 -0
- package/codemods/lib/runner.mjs +23 -4
- package/codemods/transforms/cm-02-button-explicit-size.mjs +96 -0
- package/codemods/transforms/cm-12-button-type-submit.mjs +115 -0
- package/codemods/transforms/cm-14-hidden-mirrors.mjs +275 -0
- package/codemods/transforms/cm-20-select-sentinels.mjs +442 -0
- package/dist/css/components.css +7 -0
- package/dist/css/legacy-aliases.css +180 -4
- package/dist/css/shadcn.css +4 -4
- package/dist/css/tailwind.css +66 -3
- package/dist/css/tokens.css +205 -14
- package/dist/index.d.ts +754 -53
- package/dist/index.js +3653 -3484
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/dist/system-BDU18fVg.d.ts +559 -0
- package/dist/testing/index.cjs +12 -5
- package/dist/testing/index.js +12 -5
- package/dist/tokens/index.d.ts +50 -439
- package/dist/tokens/index.js +513 -6
- package/dist/tokens/index.js.map +1 -1
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -11,6 +11,7 @@ The single source of truth for visual design across all Assure products.
|
|
|
11
11
|
- [**`CHANGELOG.md`**](./CHANGELOG.md) — release-by-release log of what shipped.
|
|
12
12
|
- [**`CONTRIBUTING.md`**](./CONTRIBUTING.md) — dev loop, release flow, versioning, pitfalls.
|
|
13
13
|
- [**`CLAUDE.md`**](./CLAUDE.md) — invariants and conventions for AI assistants working in this repo.
|
|
14
|
+
- [**`docs/integration/css.md`**](./docs/integration/css.md) — how an application loads our CSS: layer order, import sequence, the compatibility preset, removing `@source`, runtime brand theming.
|
|
14
15
|
- [**`docs/testing.md`**](./docs/testing.md) — rendering the real package in your app's Jest tests (`@assure-one/design-system/testing`, experimental).
|
|
15
16
|
- [**`claude-skills/`**](./claude-skills) — drop-in Claude Code skill for consuming projects.
|
|
16
17
|
|
|
@@ -53,15 +54,35 @@ no component reads them, and importing them changes nothing on its own.
|
|
|
53
54
|
| `./css/tailwind.css` | A Tailwind `@theme` bridge, so `bg-surface/40`, `hover:text-fg-3` and `rounded-control` compile in *your* build instead of silently producing nothing. |
|
|
54
55
|
| `./css/shadcn.css` | The app-vocabulary bridge: `background`, `foreground`, `primary`, `muted`, `destructive`, the radius scale and the rest, each reading one `--app-*` input you can override. |
|
|
55
56
|
| `./css/base.css` | Optional document defaults: body background, ink, font and `color-scheme`. Ships no preflight, no element rules and no font import. |
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
(optional)
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
[
|
|
57
|
+
| `./css/components.css` | The component utilities compiled with Tailwind's `ds` prefix (`ds:flex`, `ds:bg-surface`), inside `@layer ds` and bound to the `--ds-*` tokens. **Unused until the class-vocabulary flip (W1-11):** no component emits a prefixed class yet, so importing it styles nothing. Published so the file, its size and its hygiene can be reviewed before the flip. |
|
|
58
|
+
|
|
59
|
+
Import order is `tokens.css` → `legacy-aliases.css` (optional) → `tailwind.css`
|
|
60
|
+
→ `shadcn.css` (optional) → `base.css` (optional) → `components.css`, after your own
|
|
61
|
+
`@import "tailwindcss"` and under the layer statement
|
|
62
|
+
`@layer theme, base, ds, components, utilities;`.
|
|
63
|
+
[**`docs/integration/css.md`**](./docs/integration/css.md) has the full
|
|
64
|
+
sequence, where the per-app preset goes, when the `@source` into our package may
|
|
65
|
+
be removed, and the fixture run behind each of those claims. The reasoning lives
|
|
66
|
+
in [ADR-004](./docs/adr/004-css-delivery-cascade.md) and
|
|
63
67
|
[ADR-005](./docs/adr/005-app-vocabulary-bridge.md).
|
|
64
68
|
|
|
69
|
+
### Runtime brand theming (experimental)
|
|
70
|
+
|
|
71
|
+
For a tenant colour known only at runtime, `@assure-one/design-system/tokens`
|
|
72
|
+
exports `createBrandTheme()`: a validated seed becomes the custom properties a
|
|
73
|
+
built-in brand scope declares, ready to spread into a `style` attribute.
|
|
74
|
+
|
|
75
|
+
```tsx
|
|
76
|
+
import { createBrandTheme } from "@assure-one/design-system/tokens";
|
|
77
|
+
|
|
78
|
+
<div style={{ ...createBrandTheme({ brand: "#2258d8", accent: "#f59e0b" }) }}>…</div>;
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
It is pure and SSR-deterministic, clamps a fill too pale to read against the
|
|
82
|
+
canvas, and guarantees WCAG AA for every foreground it emits. The seed shape is
|
|
83
|
+
gated by decision D7 and may change while that decision is open — see
|
|
84
|
+
[`docs/integration/css.md`](./docs/integration/css.md#5-runtime-brand-theming).
|
|
85
|
+
|
|
65
86
|
## Develop
|
|
66
87
|
|
|
67
88
|
```bash
|
|
@@ -76,14 +97,24 @@ See [`CONTRIBUTING.md`](./CONTRIBUTING.md) for the full dev loop, including iter
|
|
|
76
97
|
|
|
77
98
|
## Architecture
|
|
78
99
|
|
|
79
|
-
3-tier token system
|
|
100
|
+
3-tier token system, authored as DTCG JSON in [`tokens/src`](./tokens/README.md)
|
|
101
|
+
and generated into `src/tokens/` and the `./css/*` entries (ADR-003):
|
|
80
102
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
103
|
+
| Tier | Example (namespaced name → value) | Reads |
|
|
104
|
+
| --- | --- | --- |
|
|
105
|
+
| reference | `--ds-ref-brand-pro-fg: #6c42f8` | a literal; the raw ramps, one per brand |
|
|
106
|
+
| system (semantic) | `--ds-color-action-brand-bg: var(--ds-ref-brand-pro-fg)` | the reference tier; **this is the public contract** |
|
|
107
|
+
| component | `--ds-menu-item-hover-bg: var(--ds-color-canvas-sunken)` | the system tier; only where a component must be themable on its own |
|
|
108
|
+
|
|
109
|
+
Components consume **system tokens**, never reference values directly. Themes
|
|
110
|
+
(colour scheme, per-product brand, a runtime brand from `createBrandTheme()`)
|
|
111
|
+
re-map system → reference without touching component code.
|
|
85
112
|
|
|
86
|
-
|
|
113
|
+
Every token has two published spellings: the namespaced `--ds-*` name above and
|
|
114
|
+
the legacy name it ships as today (`--color-brand`, `--color-pro-fg`,
|
|
115
|
+
`--color-menu-item-hover-bg`). `css/legacy-aliases.css` declares
|
|
116
|
+
`<legacy>: var(<--ds-* name>)` in the same scopes, so both resolve to the same
|
|
117
|
+
value — proved by `pnpm tokens:ds-check`.
|
|
87
118
|
|
|
88
119
|
## Releases
|
|
89
120
|
|
package/codemods/README.md
CHANGED
|
@@ -15,7 +15,7 @@ node node_modules/@assure-one/design-system/codemods/run.mjs list
|
|
|
15
15
|
node node_modules/@assure-one/design-system/codemods/run.mjs CM-15 src --dry --print
|
|
16
16
|
|
|
17
17
|
# Apply, and write a JSON + Markdown report next to the run
|
|
18
|
-
node node_modules/@assure-one/design-system/codemods/run.mjs CM-
|
|
18
|
+
node node_modules/@assure-one/design-system/codemods/run.mjs CM-02 src --report reports/cm-02
|
|
19
19
|
|
|
20
20
|
# Everything this project is missing, in program order
|
|
21
21
|
node node_modules/@assure-one/design-system/codemods/run.mjs upgrade --dry
|
|
@@ -81,12 +81,19 @@ Commit it with the codemod's changes. It is what makes
|
|
|
81
81
|
second time (a `--dry` preview is still allowed, with a warning);
|
|
82
82
|
- **prerequisites enforceable** — `requires.codemods` refuses to run before the
|
|
83
83
|
codemods it builds on, and `requires.dsVersion` before the release that ships
|
|
84
|
-
the replacement API;
|
|
84
|
+
the replacement API; a codemod whose prerequisite depends on its own state
|
|
85
|
+
exports a `guard` hook and calls `requireLedger([…])` from it (CM-03 phase 2
|
|
86
|
+
runs only when the ledger shows phase 1 **and** CM-02);
|
|
85
87
|
- **`upgrade` exact** — a consumer several versions behind gets precisely the
|
|
86
88
|
codemods it is missing, in program order.
|
|
87
89
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
+
A transforming codemod is recorded when it ran for real: on its **first** run
|
|
91
|
+
even when every call site was already in the target shape (`filesChanged: 0` —
|
|
92
|
+
a project whose Buttons were all explicit must still be able to satisfy a
|
|
93
|
+
prerequisite on CM-02), and again whenever a later run changed a file. A run
|
|
94
|
+
that changes nothing in a project that already records the codemod adds no
|
|
95
|
+
entry. Report-only codemods and dry runs never touch it. The consumer scanner
|
|
96
|
+
reads the file and the migration dashboard shows the applied ids
|
|
90
97
|
(`migration-status/<app>.json` → `codemodLedger`).
|
|
91
98
|
|
|
92
99
|
## Reports
|
|
@@ -100,13 +107,123 @@ the list a human has to pick up — spread props, dynamic expressions, conflicti
|
|
|
100
107
|
|
|
101
108
|
## Codemods
|
|
102
109
|
|
|
103
|
-
| Id | Class | What it does
|
|
104
|
-
| ----- | ----- |
|
|
105
|
-
| CM-
|
|
106
|
-
| CM-
|
|
110
|
+
| Id | Class | What it does |
|
|
111
|
+
| ----- | ----- | ------------------------------------------------------------------------------------------------------------------------------------- |
|
|
112
|
+
| CM-02 | A | Button explicit default size: `size="md"` on every `Button`, `SubmitButton` and `LinkButton` that passes no `size` (pixel-neutral) |
|
|
113
|
+
| CM-12 | R | Button `type="submit"` where the intent is evident (inside `<form>` in the same file, no `onClick`); everything else untyped → report |
|
|
114
|
+
| CM-14 | X | hidden-input mirror finder: hidden `<input name>` a consumer added because a design-system control posts nothing |
|
|
115
|
+
| CM-15 | X | DOM-selector finder: consumer code that depends on the internal DOM of design-system components, mapped to registry `C-DOM-*` ids |
|
|
116
|
+
| CM-16 | X | `globals.css` analyser: `@source` into the package, duplicate preflight, colliding `@theme` keys, unlayered globals, legacy `var()` |
|
|
117
|
+
| CM-20 | X | Select sentinel finder: option values standing in for "no value", and the line where each is converted back |
|
|
107
118
|
|
|
108
119
|
The remaining ids of plan §29 land with the waves that ship their replacement
|
|
109
|
-
APIs
|
|
120
|
+
APIs (CM-04 and CM-05 need the Button `intent` and `iconStart`/`iconEnd` API of
|
|
121
|
+
W3-02 and W3-07).
|
|
122
|
+
|
|
123
|
+
### CM-02 — Button explicit default size
|
|
124
|
+
|
|
125
|
+
Class A. Every `Button`, `SubmitButton` and `LinkButton` imported from the
|
|
126
|
+
design system (aliased and namespace imports included) that passes no `size`
|
|
127
|
+
gets `size="md"` — the value the component resolves today
|
|
128
|
+
(`defaultVariants.size`, 40px), so nothing moves on screen. It exists because
|
|
129
|
+
a size _meaning_ can only change once no call site depends on the default:
|
|
130
|
+
after CM-02, the D5 size flip (CM-03 phase 2, W9-06) touches nothing a
|
|
131
|
+
consumer did not write down, and 2.0 gate G6 counts exactly this.
|
|
132
|
+
|
|
133
|
+
The attribute goes after `variant` when there is one (the order the
|
|
134
|
+
applications use) and first otherwise, on its own line when the element's
|
|
135
|
+
attributes are one per line — a Prettier-formatted file stays formatted.
|
|
136
|
+
|
|
137
|
+
| rule | Contract | What happens |
|
|
138
|
+
| --------------- | ---------- | -------------------- |
|
|
139
|
+
| `implicit-size` | C-BTN-SIZE | `size="md"` is added |
|
|
140
|
+
|
|
141
|
+
Not touched, and listed under "could not be transformed": an element with a
|
|
142
|
+
spread (`<Button {...props}>`) — the spread may already carry `size`. Not
|
|
143
|
+
touched and not reported: an element with any `size` (the icon sizes, a
|
|
144
|
+
dynamic `size={x}`), a local component called `Button`, test files (the
|
|
145
|
+
scanner that measures G6 counts production files only).
|
|
146
|
+
|
|
147
|
+
### CM-12 — Button `type="submit"` where evident
|
|
148
|
+
|
|
149
|
+
Class R. An untyped `Button` renders `<button>` without a `type`, which the
|
|
150
|
+
browser treats as `submit`; 2.0 flips the default to `"button"` (W9-05,
|
|
151
|
+
C-BTN-TYPE, gate G4). CM-12 writes down, before the flip, which Buttons submit
|
|
152
|
+
on purpose — and only where the file itself makes that evident:
|
|
153
|
+
|
|
154
|
+
| rule | Action | Severity | When |
|
|
155
|
+
| ----------------- | ------- | -------- | ---------------------------------------------------------------------------- |
|
|
156
|
+
| `submit-in-form` | applied | medium | inside a lower-case `<form>` in the same file, no `onClick`, no spread |
|
|
157
|
+
| `in-form-onclick` | review | high | inside a `<form>` with an `onClick` — submit with side effects, or a button? |
|
|
158
|
+
| `in-form-spread` | review | high | inside a `<form>` with `{...props}` — the spread may carry `type` |
|
|
159
|
+
| `outside-form` | review | low | no `<form>` in this file; rendered inside one elsewhere, it submits today |
|
|
160
|
+
|
|
161
|
+
The applied change states what the browser does today, so it is
|
|
162
|
+
behaviour-neutral; it is still in the report because a human has to agree that
|
|
163
|
+
submitting is what the button is for — a "Cancel" matching that shape is a
|
|
164
|
+
bug today, now visible. Nothing is followed across files (a component
|
|
165
|
+
rendered inside a `<form>` by another component is `outside-form`), and a
|
|
166
|
+
capitalised `<Form>` does not count: the react-hook-form / shadcn `Form` is a
|
|
167
|
+
context provider, not a form element. `SubmitButton`, `LinkButton`, a Button
|
|
168
|
+
with any `type`, a Button with `asChild` and test files are not reported.
|
|
169
|
+
|
|
170
|
+
### CM-14 — hidden-input mirror finder
|
|
171
|
+
|
|
172
|
+
Report-only, **for ever**: deleting a mirror changes what the server receives,
|
|
173
|
+
and plan §29 never automates that. It is the input to W4-04 (native form
|
|
174
|
+
participation) and to the registry contract `C-HIDDEN-MIRRORS`.
|
|
175
|
+
|
|
176
|
+
The applications do not put a mirror next to its control — they collect the
|
|
177
|
+
mirrors at the top of the `<form>` and bind the control far below ([CU §18]).
|
|
178
|
+
"Next to" is therefore read as _bound to the same state_:
|
|
179
|
+
|
|
180
|
+
| Rule | Contract | Found |
|
|
181
|
+
| --------------------------- | ---------------- | ------------------------------------------------------------------------------ |
|
|
182
|
+
| `mirror-shared-binding` | C-HIDDEN-MIRRORS | the hidden input's `value` reads a binding a design-system control is bound to |
|
|
183
|
+
| `mirror-in-form` | C-HIDDEN-MIRRORS | same `<form>` as a control that posts nothing today; nothing ties the two |
|
|
184
|
+
| `unregistered-hidden-input` | (unregistered) | no control to mirror — a value the server supplied (a token, an id) |
|
|
185
|
+
|
|
186
|
+
Each finding names the control, the field name and whether that control takes
|
|
187
|
+
`name` **today** (`yes`) or only after Wave 4 (`planned`); severity is `high`
|
|
188
|
+
when the control takes `name` and the call site already passes it, because the
|
|
189
|
+
form then posts the field twice.
|
|
190
|
+
|
|
191
|
+
The third rule is why the report reconciles with the audit's census of every
|
|
192
|
+
hidden `<input>` ([CU §24]): CM-14 reports the same population, classified,
|
|
193
|
+
rather than a smaller number with no explanation. It does not look across
|
|
194
|
+
files — a mirror whose control lives elsewhere lands in `mirror-in-form` or
|
|
195
|
+
`unregistered-hidden-input` and says so. An `<input type={expr}>` whose type is
|
|
196
|
+
computed appears in "could not be transformed".
|
|
197
|
+
|
|
198
|
+
The `name` capability table lives in the transform, and
|
|
199
|
+
`tests/codemods/cm-14.test.mjs` checks it against the design system's own
|
|
200
|
+
sources so it cannot describe components this version does not ship.
|
|
201
|
+
|
|
202
|
+
### CM-20 — Select sentinel finder
|
|
203
|
+
|
|
204
|
+
Report-only, **for ever**: replacing a sentinel with `null` changes what the
|
|
205
|
+
server receives. It is the input to W4-08 (`Select value: string | null`) and
|
|
206
|
+
to the registry contract `C-SELECT-EMPTY`.
|
|
207
|
+
|
|
208
|
+
The spellings are **found, not assumed**. The registry names `"none"` and
|
|
209
|
+
`"__unassigned__"`; the applications use far more, and `"none"` is also a real
|
|
210
|
+
domain value. CM-20 reports a spelling only where the file itself proves it
|
|
211
|
+
stands in for nothing:
|
|
212
|
+
|
|
213
|
+
| Rule | Contract | Found |
|
|
214
|
+
| --------------------- | -------------- | ----------------------------------------------------------------- |
|
|
215
|
+
| `empty-string-value` | C-SELECT-EMPTY | `value=""` on a Select-family control or an option |
|
|
216
|
+
| `sentinel-dunder` | C-SELECT-EMPTY | a `__…__` spelling used as a Select value |
|
|
217
|
+
| `sentinel-constant` | C-SELECT-EMPTY | a no-value-named constant used as a Select value |
|
|
218
|
+
| `sentinel-word` | C-SELECT-EMPTY | `none`/`all`/… **with** a no-value label or a round trip |
|
|
219
|
+
| `sentinel-discovered` | C-SELECT-EMPTY | any other spelling the file converts to nothing |
|
|
220
|
+
| `sentinel-conversion` | C-SELECT-EMPTY | the line that maps a reported sentinel to `null`/`undefined`/`""` |
|
|
221
|
+
|
|
222
|
+
A known word with no evidence at all is **not** reported: CM-20 would rather
|
|
223
|
+
miss a sentinel than invite someone to change a value the backend depends on.
|
|
224
|
+
Sentinels outside the Select family — grouping keys, tab ids, route segments —
|
|
225
|
+
are out of scope even when the spelling matches, and nothing is followed across
|
|
226
|
+
files.
|
|
110
227
|
|
|
111
228
|
### CM-15 — DOM-selector finder
|
|
112
229
|
|
|
@@ -211,26 +328,41 @@ export function transform(file, { ts, postcss }) {
|
|
|
211
328
|
```
|
|
212
329
|
|
|
213
330
|
`usesTypeScript` and `usesPostcss` ask the runner to resolve that parser from
|
|
214
|
-
the project and inject it; a codemod never imports one itself.
|
|
331
|
+
the project and inject it; a codemod never imports one itself. A transforming
|
|
332
|
+
codemod edits the source with `lib/jsx-edit.mjs` (`insertAttribute`,
|
|
333
|
+
`applyInsertions`), which keeps the file's own formatting; a finding it
|
|
334
|
+
applied carries `action: "applied"`, one it leaves to a human
|
|
335
|
+
`action: "review"`.
|
|
336
|
+
|
|
337
|
+
A codemod may also export `guard(context)`, run before any file is read, that
|
|
338
|
+
returns the problems that refuse the run. It receives `{ root, ledger,
|
|
339
|
+
dsVersion, dry, requireLedger }`, where `requireLedger(["CM-02"])` returns one
|
|
340
|
+
problem per prerequisite the ledger lacks. `meta.requires.codemods` is the
|
|
341
|
+
declarative form of the same check; the hook is for a prerequisite that
|
|
342
|
+
depends on the codemod's own state (CM-03 phase 2).
|
|
215
343
|
|
|
216
344
|
The runner owns everything else: the guards, which files are read, writing
|
|
217
345
|
files and the ledger, the report, and the rule that a class X codemod may not
|
|
218
346
|
change anything.
|
|
219
347
|
|
|
220
348
|
Fixtures live in `codemods/__test__/<id>/*.snap` — one file per case, holding a
|
|
221
|
-
small project
|
|
222
|
-
expected output
|
|
349
|
+
small project, the expected findings and, for transforming codemods, the
|
|
350
|
+
expected output of every file that changes:
|
|
223
351
|
|
|
224
352
|
```
|
|
225
353
|
=== file: src/thing.tsx
|
|
226
354
|
<source>
|
|
355
|
+
=== output: src/thing.tsx
|
|
356
|
+
<the source after the codemod>
|
|
227
357
|
=== expect
|
|
228
358
|
{ "findings": [ ["src/thing.tsx:12", "C-DOM-03", "class-on-component", "<class token>"] ] }
|
|
229
359
|
```
|
|
230
360
|
|
|
231
361
|
`codemods/__test__/harness.mjs` materialises a fixture in a temporary
|
|
232
|
-
directory, runs the codemod **twice** and asserts that the second run
|
|
233
|
-
|
|
362
|
+
directory, runs the codemod **twice** and asserts that the second run changes
|
|
363
|
+
nothing and finds what the first run did not apply — the idempotency
|
|
364
|
+
requirement of plan §29. A file without an `=== output:` section must come out
|
|
365
|
+
unchanged; a fixture with none is report-only and may write no ledger.
|
|
234
366
|
`tests/codemods/*.test.mjs` runs all of it as part of `pnpm test:contracts`.
|
|
235
367
|
|
|
236
368
|
Fixtures are `.snap` on purpose: Tailwind scans every other repository file for
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Form-shaped source facts, shared by the two report-only finders of the
|
|
3
|
+
* forms wave (plan §16): CM-14 (hidden-input mirrors) and CM-20 (Select
|
|
4
|
+
* sentinels).
|
|
5
|
+
*
|
|
6
|
+
* `lib/jsx.mjs` answers "which class tokens sit on which design-system
|
|
7
|
+
* component" and therefore only records elements that carry a class
|
|
8
|
+
* attribute. A hidden `<input type="hidden" name="x" value={x} />` carries
|
|
9
|
+
* none, and a sentinel lives in a prop value, not in a class token — so the
|
|
10
|
+
* forms codemods need a different view of the same tree:
|
|
11
|
+
*
|
|
12
|
+
* - **every** JSX element, design-system and raw alike, in document order;
|
|
13
|
+
* - each element's attributes, with the string literals and the identifiers
|
|
14
|
+
* of the expression behind them (the identifiers are what tie a mirror to
|
|
15
|
+
* the control it mirrors);
|
|
16
|
+
* - the nearest `<form>` ancestor, because the applications do not put a
|
|
17
|
+
* mirror next to its control — they collect the mirrors at the top of the
|
|
18
|
+
* form ([CU §18]);
|
|
19
|
+
* - top-level `const NAME = "literal"` declarations, because the sentinels
|
|
20
|
+
* are named constants (`const NONE_VALUE = "__none__"`).
|
|
21
|
+
*
|
|
22
|
+
* As everywhere in `codemods/lib`, the TypeScript module is passed in: a
|
|
23
|
+
* codemod never imports a parser itself (`lib/environment.mjs`).
|
|
24
|
+
*/
|
|
25
|
+
import { DS_PACKAGE, createSourceFile } from "./jsx.mjs";
|
|
26
|
+
|
|
27
|
+
export { DS_PACKAGE };
|
|
28
|
+
|
|
29
|
+
const isDsSpecifier = (s) => s === DS_PACKAGE || s.startsWith(`${DS_PACKAGE}/`);
|
|
30
|
+
|
|
31
|
+
/** Attributes whose value is the control's current value, in binding order. */
|
|
32
|
+
export const VALUE_PROPS = ["value", "defaultValue", "checked", "defaultChecked", "selected"];
|
|
33
|
+
|
|
34
|
+
/** Attributes through which a control writes its value back. */
|
|
35
|
+
export const CHANGE_PROPS = [
|
|
36
|
+
"onValueChange",
|
|
37
|
+
"onCheckedChange",
|
|
38
|
+
"onChange",
|
|
39
|
+
"onSelect",
|
|
40
|
+
"onRemove",
|
|
41
|
+
"onSelectionChange",
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Parses one file into form facts.
|
|
46
|
+
*
|
|
47
|
+
* Returns:
|
|
48
|
+
* - `sf`, `lineOf` — the source file and a node → line helper
|
|
49
|
+
* - `imports` — design-system local name → exported name
|
|
50
|
+
* - `namespaces` — local names of `import * as X from the package`
|
|
51
|
+
* - `elements` — every JSX element, in document order, as described below
|
|
52
|
+
* - `constants` — top-level `const` name → `{ text, line }` for string consts
|
|
53
|
+
* - `componentsUsed` — the design-system base names used as JSX
|
|
54
|
+
* - `parseErrors`
|
|
55
|
+
*
|
|
56
|
+
* An element is
|
|
57
|
+
* `{ index, parent, tag, base, component, isDs, line, props, spread, form }`
|
|
58
|
+
* where `props` is a Map of attribute name → `{ literals, identifiers, text,
|
|
59
|
+
* line, expression }` and `form` is the index of the nearest `<form>`
|
|
60
|
+
* ancestor (or `null`).
|
|
61
|
+
*/
|
|
62
|
+
export function analyseForms(ts, text, rel) {
|
|
63
|
+
const sf = createSourceFile(ts, text, rel);
|
|
64
|
+
const lineOf = (node) => sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1;
|
|
65
|
+
|
|
66
|
+
const imports = new Map();
|
|
67
|
+
const namespaces = new Set();
|
|
68
|
+
for (const stmt of sf.statements) {
|
|
69
|
+
if (!ts.isImportDeclaration(stmt) || !ts.isStringLiteral(stmt.moduleSpecifier)) continue;
|
|
70
|
+
if (!isDsSpecifier(stmt.moduleSpecifier.text)) continue;
|
|
71
|
+
const clause = stmt.importClause;
|
|
72
|
+
if (!clause || clause.isTypeOnly) continue;
|
|
73
|
+
const bindings = clause.namedBindings;
|
|
74
|
+
if (bindings && ts.isNamespaceImport(bindings)) namespaces.add(bindings.name.text);
|
|
75
|
+
else if (bindings && ts.isNamedImports(bindings)) {
|
|
76
|
+
for (const el of bindings.elements) {
|
|
77
|
+
if (!el.isTypeOnly) imports.set(el.name.text, (el.propertyName ?? el.name).text);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Top-level `const NAME = "text"`, the shape the sentinels are declared in. */
|
|
83
|
+
const constants = new Map();
|
|
84
|
+
for (const stmt of sf.statements) {
|
|
85
|
+
const list = ts.isVariableStatement(stmt) ? stmt.declarationList : null;
|
|
86
|
+
if (!list || !(list.flags & ts.NodeFlags.Const)) continue;
|
|
87
|
+
for (const decl of list.declarations) {
|
|
88
|
+
if (!ts.isIdentifier(decl.name) || !decl.initializer) continue;
|
|
89
|
+
const init = decl.initializer;
|
|
90
|
+
if (ts.isStringLiteral(init) || ts.isNoSubstitutionTemplateLiteral(init)) {
|
|
91
|
+
constants.set(decl.name.text, { text: init.text, line: lineOf(init) });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** String literals inside an expression, at any depth below JSX. */
|
|
97
|
+
const literalsIn = (node, out = []) => {
|
|
98
|
+
if (!node) return out;
|
|
99
|
+
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
|
|
100
|
+
out.push(node.text);
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node)) {
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
// A block body: `forEachChild` stops at the first truthy return, and the
|
|
107
|
+
// accumulator is always truthy.
|
|
108
|
+
ts.forEachChild(node, (child) => {
|
|
109
|
+
literalsIn(child, out);
|
|
110
|
+
});
|
|
111
|
+
return out;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Identifier names an expression reads. Member names (`a.b`), object keys
|
|
116
|
+
* and JSX subtrees are excluded: what matters is which bindings the value
|
|
117
|
+
* depends on, so that two attributes reading `entityType` can be recognised
|
|
118
|
+
* as two views of the same state.
|
|
119
|
+
*/
|
|
120
|
+
const identifiersIn = (node, out = new Set()) => {
|
|
121
|
+
if (!node) return out;
|
|
122
|
+
if (ts.isIdentifier(node)) {
|
|
123
|
+
const parent = node.parent;
|
|
124
|
+
const isMemberName = parent && ts.isPropertyAccessExpression(parent) && parent.name === node;
|
|
125
|
+
const isKey =
|
|
126
|
+
parent &&
|
|
127
|
+
(ts.isPropertyAssignment(parent) || ts.isPropertySignature(parent)) &&
|
|
128
|
+
parent.name === node;
|
|
129
|
+
if (!isMemberName && !isKey) out.add(node.text);
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node)) {
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
ts.forEachChild(node, (child) => {
|
|
136
|
+
identifiersIn(child, out);
|
|
137
|
+
});
|
|
138
|
+
return out;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const resolveTag = (tag) => {
|
|
142
|
+
const members = [];
|
|
143
|
+
let current = tag;
|
|
144
|
+
while (ts.isPropertyAccessExpression(current)) {
|
|
145
|
+
members.unshift(current.name.text);
|
|
146
|
+
current = current.expression;
|
|
147
|
+
}
|
|
148
|
+
if (!ts.isIdentifier(current)) return null;
|
|
149
|
+
const root = current.text;
|
|
150
|
+
if (namespaces.has(root) && members.length) {
|
|
151
|
+
return { base: members[0], component: members.join(".") };
|
|
152
|
+
}
|
|
153
|
+
const base = imports.get(root);
|
|
154
|
+
if (!base) return null;
|
|
155
|
+
return { base, component: [base, ...members].join(".") };
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const elements = [];
|
|
159
|
+
const openingOf = (node) => (ts.isJsxElement(node) ? node.openingElement : node);
|
|
160
|
+
|
|
161
|
+
const record = (node, parent, form) => {
|
|
162
|
+
const opening = openingOf(node);
|
|
163
|
+
const info = resolveTag(opening.tagName);
|
|
164
|
+
const tag = opening.tagName.getText(sf);
|
|
165
|
+
const props = new Map();
|
|
166
|
+
let spread = false;
|
|
167
|
+
for (const attr of opening.attributes.properties) {
|
|
168
|
+
if (ts.isJsxSpreadAttribute(attr)) {
|
|
169
|
+
spread = true;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (!ts.isJsxAttribute(attr)) continue;
|
|
173
|
+
const name = attr.name.getText(sf);
|
|
174
|
+
const init = attr.initializer;
|
|
175
|
+
if (init === undefined) {
|
|
176
|
+
// A bare attribute (`required`) is the boolean `true`.
|
|
177
|
+
props.set(name, {
|
|
178
|
+
literals: [],
|
|
179
|
+
identifiers: [],
|
|
180
|
+
text: "true",
|
|
181
|
+
line: lineOf(attr),
|
|
182
|
+
expression: false,
|
|
183
|
+
});
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
const inner = ts.isJsxExpression(init) ? (init.expression ?? null) : init;
|
|
187
|
+
const isLiteral =
|
|
188
|
+
inner !== null && (ts.isStringLiteral(inner) || ts.isNoSubstitutionTemplateLiteral(inner));
|
|
189
|
+
props.set(name, {
|
|
190
|
+
literals: inner ? literalsIn(inner) : [],
|
|
191
|
+
identifiers: inner ? [...identifiersIn(inner)] : [],
|
|
192
|
+
text: inner ? inner.getText(sf) : null,
|
|
193
|
+
line: lineOf(attr),
|
|
194
|
+
expression: Boolean(inner) && !isLiteral,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
const entry = {
|
|
198
|
+
index: elements.length,
|
|
199
|
+
parent,
|
|
200
|
+
tag,
|
|
201
|
+
base: info?.base ?? null,
|
|
202
|
+
component: info?.component ?? null,
|
|
203
|
+
isDs: Boolean(info),
|
|
204
|
+
line: lineOf(opening),
|
|
205
|
+
props,
|
|
206
|
+
spread,
|
|
207
|
+
form,
|
|
208
|
+
node,
|
|
209
|
+
};
|
|
210
|
+
elements.push(entry);
|
|
211
|
+
return entry;
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const walk = (node, parent, form) => {
|
|
215
|
+
if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) {
|
|
216
|
+
const entry = record(node, parent, form);
|
|
217
|
+
const nextForm = /^(?:form|Form)$/.test(entry.tag) ? entry.index : form;
|
|
218
|
+
ts.forEachChild(node, (child) => walk(child, entry.index, nextForm));
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
ts.forEachChild(node, (child) => walk(child, parent, form));
|
|
222
|
+
};
|
|
223
|
+
walk(sf, null, null);
|
|
224
|
+
|
|
225
|
+
const parseErrors = (sf.parseDiagnostics ?? []).map((d) => ({
|
|
226
|
+
line: d.start === undefined ? null : sf.getLineAndCharacterOfPosition(d.start).line + 1,
|
|
227
|
+
message: ts.flattenDiagnosticMessageText(d.messageText, " "),
|
|
228
|
+
}));
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
sf,
|
|
232
|
+
lineOf,
|
|
233
|
+
literalsIn,
|
|
234
|
+
identifiersIn: (node) => [...identifiersIn(node)],
|
|
235
|
+
imports,
|
|
236
|
+
namespaces,
|
|
237
|
+
elements,
|
|
238
|
+
constants,
|
|
239
|
+
componentsUsed: new Set(elements.filter((e) => e.isDs).map((e) => e.base)),
|
|
240
|
+
parseErrors,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** The literal value of an attribute when it is a single string, else `null`. */
|
|
245
|
+
export const literalValue = (prop) =>
|
|
246
|
+
prop && !prop.expression && prop.literals.length === 1 ? prop.literals[0] : null;
|
|
247
|
+
|
|
248
|
+
/** Whether `inner` is `outer` or sits below it in the element tree. */
|
|
249
|
+
export function isWithin(elements, inner, outer) {
|
|
250
|
+
if (outer === null || inner === null) return false;
|
|
251
|
+
for (let i = inner; i !== null; i = elements[i].parent) if (i === outer) return true;
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source edits for transforming codemods.
|
|
3
|
+
*
|
|
4
|
+
* A transforming codemod (class A or R) changes consumer files that are
|
|
5
|
+
* formatted by the consumer's Prettier, so an edit must leave the file in the
|
|
6
|
+
* shape Prettier would produce: a new attribute goes on its own line when the
|
|
7
|
+
* element's attributes are one per line, and inline when they are inline. The
|
|
8
|
+
* edits are computed against the parsed tree and applied to the original text
|
|
9
|
+
* from the end backwards, so nothing else in the file moves.
|
|
10
|
+
*
|
|
11
|
+
* As everywhere in `codemods/lib`, the TypeScript module is passed in.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const isWhitespace = (text) => /^\s*$/.test(text);
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The edit that inserts `text` (e.g. `size="md"`) as an attribute of the JSX
|
|
18
|
+
* opening or self-closing element `opening`.
|
|
19
|
+
*
|
|
20
|
+
* The attribute goes right after the attribute named `after` when the element
|
|
21
|
+
* has one, otherwise first. The separator copies the whitespace the element
|
|
22
|
+
* already uses before that position (a newline plus the indentation for
|
|
23
|
+
* one-attribute-per-line elements, a space otherwise).
|
|
24
|
+
*
|
|
25
|
+
* @returns {{ pos: number, text: string }}
|
|
26
|
+
*/
|
|
27
|
+
export function insertAttribute(ts, sf, opening, text, { after = null } = {}) {
|
|
28
|
+
const source = sf.text;
|
|
29
|
+
const attrs = opening.attributes.properties;
|
|
30
|
+
const attributesStart = opening.attributes.pos;
|
|
31
|
+
const separatorBefore = (index) => {
|
|
32
|
+
const from = index === 0 ? attributesStart : attrs[index - 1].getEnd();
|
|
33
|
+
const gap = source.slice(from, attrs[index].getStart(sf));
|
|
34
|
+
return gap && isWhitespace(gap) ? gap : " ";
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
if (after) {
|
|
38
|
+
const index = attrs.findIndex(
|
|
39
|
+
(attr) => ts.isJsxAttribute(attr) && attr.name.getText(sf) === after,
|
|
40
|
+
);
|
|
41
|
+
if (index >= 0) {
|
|
42
|
+
return { pos: attrs[index].getEnd(), text: `${separatorBefore(index)}${text}` };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const separator = attrs.length ? separatorBefore(0) : " ";
|
|
46
|
+
return { pos: attributesStart, text: `${separator}${text}` };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** `source` with every `{ pos, text }` insertion applied. */
|
|
50
|
+
export function applyInsertions(source, edits) {
|
|
51
|
+
let out = source;
|
|
52
|
+
for (const edit of [...edits].sort((a, b) => b.pos - a.pos)) {
|
|
53
|
+
out = `${out.slice(0, edit.pos)}${edit.text}${out.slice(edit.pos)}`;
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The opening element of a JSX element node (a self-closing one is its own). */
|
|
59
|
+
export const openingOf = (ts, node) => (ts.isJsxElement(node) ? node.openingElement : node);
|
package/codemods/lib/jsx.mjs
CHANGED
|
Binary file
|
package/codemods/lib/ledger.mjs
CHANGED
|
@@ -56,9 +56,25 @@ export function recordApplied(root, { id, appliedAt, dsVersion, filesChanged })
|
|
|
56
56
|
return { schema: LEDGER_SCHEMA, applied };
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
/**
|
|
60
|
+
* The problems that stop codemod `id` while the codemods in `ids` are not in
|
|
61
|
+
* the ledger — one line per missing prerequisite, `[]` when all are there.
|
|
62
|
+
*
|
|
63
|
+
* `meta.requires.codemods` is the declarative form and goes through here. A
|
|
64
|
+
* codemod whose prerequisite depends on its own state calls it from its
|
|
65
|
+
* `guard` hook instead: CM-03 phase 2 runs only when the ledger shows phase 1
|
|
66
|
+
* and CM-02 (`requireLedger(["CM-02"])`, plan §29).
|
|
67
|
+
*/
|
|
68
|
+
export function requireLedger(ledger, ids, { id = "This codemod" } = {}) {
|
|
69
|
+
const applied = appliedIds(ledger);
|
|
70
|
+
return ids
|
|
71
|
+
.filter((required) => !applied.has(required))
|
|
72
|
+
.map((required) => `${id} needs ${required} to be applied first (not in ${LEDGER_FILE}).`);
|
|
73
|
+
}
|
|
74
|
+
|
|
59
75
|
/**
|
|
60
76
|
* Why `meta` may not be applied to a project with this ledger and installed
|
|
61
|
-
* DS version, or `
|
|
77
|
+
* DS version, or `[]`. Checked before any file is read.
|
|
62
78
|
*/
|
|
63
79
|
export function guardProblems(meta, ledger, dsVersion, compareVersions) {
|
|
64
80
|
const problems = [];
|
|
@@ -69,11 +85,7 @@ export function guardProblems(meta, ledger, dsVersion, compareVersions) {
|
|
|
69
85
|
`${meta.id} is a one-shot codemod and the ledger shows it was already applied${when ? ` (${when})` : ""}.`,
|
|
70
86
|
);
|
|
71
87
|
}
|
|
72
|
-
|
|
73
|
-
if (!ids.has(required)) {
|
|
74
|
-
problems.push(`${meta.id} needs ${required} to be applied first (not in ${LEDGER_FILE}).`);
|
|
75
|
-
}
|
|
76
|
-
}
|
|
88
|
+
problems.push(...requireLedger(ledger, meta.requires?.codemods ?? [], { id: meta.id }));
|
|
77
89
|
const minimum = meta.requires?.dsVersion;
|
|
78
90
|
if (minimum && compareVersions(dsVersion, minimum) < 0) {
|
|
79
91
|
problems.push(
|
|
@@ -3,8 +3,12 @@
|
|
|
3
3
|
* codemods listed here can be run by id; the sequence is the plan's.
|
|
4
4
|
*/
|
|
5
5
|
export const CODEMODS = [
|
|
6
|
+
{ id: "CM-02", module: "../transforms/cm-02-button-explicit-size.mjs" },
|
|
7
|
+
{ id: "CM-12", module: "../transforms/cm-12-button-type-submit.mjs" },
|
|
8
|
+
{ id: "CM-14", module: "../transforms/cm-14-hidden-mirrors.mjs" },
|
|
6
9
|
{ id: "CM-15", module: "../transforms/cm-15-dom-selectors.mjs" },
|
|
7
10
|
{ id: "CM-16", module: "../transforms/cm-16-globals-css.mjs" },
|
|
11
|
+
{ id: "CM-20", module: "../transforms/cm-20-select-sentinels.mjs" },
|
|
8
12
|
];
|
|
9
13
|
|
|
10
14
|
export const findCodemod = (id) =>
|