orbiq-neural-ui-kit 0.0.1
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 +64 -0
- package/fesm2022/orbiq-neural-ui-kit.mjs +13521 -0
- package/fesm2022/orbiq-neural-ui-kit.mjs.map +1 -0
- package/package.json +45 -0
- package/src/theme.css +6 -0
- package/tailwind/colorPalette.js +193 -0
- package/tailwind/colors.json +1173 -0
- package/tailwind/content.js +58 -0
- package/tailwind/figma-tokens-to-theme.js +471 -0
- package/tailwind/figma-tokens-to-theme.test.js +22 -0
- package/tailwind/generated/colors.json +1173 -0
- package/tailwind/generated/effects.json +39 -0
- package/tailwind/generated/radius.json +14 -0
- package/tailwind/generated/typography.json +402 -0
- package/tailwind/iconPackPlugin.js +108 -0
- package/tailwind/index.js +2 -0
- package/tailwind/lucideIconsPlugin.js +23 -0
- package/tailwind/migrate-tokens-v2.js +938 -0
- package/tailwind/migrate-tokens-v2.test.js +528 -0
- package/tailwind/plugin.js +693 -0
- package/tailwind/preset.js +36 -0
- package/types/orbiq-neural-ui-kit.d.ts +1892 -0
|
@@ -0,0 +1,938 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Espresso v2 token migration codemod.
|
|
4
|
+
*
|
|
5
|
+
* Renames old semantic token names (frappe-ui v0.1.278 era) to their espresso
|
|
6
|
+
* v2 equivalents, per the "Frappe tokens v2 migration" mapping from design.
|
|
7
|
+
* Works on any text occurrence of a token name — tailwind utilities
|
|
8
|
+
* (`bg-surface-white`, `text-ink-red-2`, `border-outline-gray-modals`) and CSS
|
|
9
|
+
* variables (`var(--surface-white)`) alike — so it can be pointed at app
|
|
10
|
+
* codebases too.
|
|
11
|
+
*
|
|
12
|
+
* It also merges weight classes: a `text-<size>` + `font-<weight>` in the same
|
|
13
|
+
* static class list collapses to the combined `text-<size>-<weight>` style
|
|
14
|
+
* class, which carries the correct per-weight letter-spacing (see
|
|
15
|
+
* `mergeWeightClasses`).
|
|
16
|
+
*
|
|
17
|
+
* Usage: tokens-v2 [--dry-run] [--force] <dir-or-file...>
|
|
18
|
+
*
|
|
19
|
+
* IMPORTANT: the token replacement is single-pass/simultaneous. Several renames
|
|
20
|
+
* chain (outline red-2→3, red-3→4, red-4→5); applying them sequentially would
|
|
21
|
+
* cascade (red-2 ending up as red-5). Color renames must run exactly once per
|
|
22
|
+
* codebase — the v2 scheme reuses names (e.g. surface-gray-5 exists in both
|
|
23
|
+
* scales with different values), so a second full run would double-shift tokens.
|
|
24
|
+
* As a guard, the script detects already-migrated codebases and runs only the
|
|
25
|
+
* typography correction (`text-lg` → `text-md`, `text-xl` → `text-lg`, ...).
|
|
26
|
+
* Pass --force to run the full migration anyway.
|
|
27
|
+
*
|
|
28
|
+
* `tiny` and `13xl`-`16xl` were dropped as unused vocabulary in a pre-1.0
|
|
29
|
+
* audit (#940) — no utility ships for them anymore. A class that would have
|
|
30
|
+
* shifted onto one of them is left untouched and reported under "needs
|
|
31
|
+
* manual attention" instead of being rewritten to a dead class.
|
|
32
|
+
*
|
|
33
|
+
* Radius aliases (`rounded`, `rounded-sm/md/lg/xl/2xl`, and their directional
|
|
34
|
+
* forms) were removed in 1.0.0 per ADR-0006. The codemod renames them to the
|
|
35
|
+
* numbered tokens (`rounded-4`, `rounded-1/5/6/7/8`). These renames are
|
|
36
|
+
* idempotent and run in every mode. `rounded-none` and `rounded-full` are
|
|
37
|
+
* kept tokens and stay untouched. The bare word `rounded` is also plain
|
|
38
|
+
* English, so it is only rewritten in class-like contexts (inside a quoted
|
|
39
|
+
* string or an `@apply` rule) — prose and comments keep their words.
|
|
40
|
+
*
|
|
41
|
+
* A codebase that already ran BOTH the color migration and the typography
|
|
42
|
+
* correction must not run either again (both shift names). Pass
|
|
43
|
+
* --radius-only there: it runs only the (idempotent) radius renames and the
|
|
44
|
+
* removed-token report.
|
|
45
|
+
*
|
|
46
|
+
* The `text-<size>-black` style classes were removed in 1.0.0 (#998, zero
|
|
47
|
+
* usage; the Figma weights behind them were corrupt export data). Existing
|
|
48
|
+
* `text-*-black` usage is flagged, never renamed, and a `font-extrabold` /
|
|
49
|
+
* `font-black` next to a text size is flagged instead of merged.
|
|
50
|
+
*
|
|
51
|
+
* --ink-shift (#1016): the updated espresso v2 Figma export shifts every
|
|
52
|
+
* chromatic ink scale down one level (new `ink-red-1` = old `ink-red-2`;
|
|
53
|
+
* `ink-gray` is its own 9-step scale and does NOT shift). This mode renames
|
|
54
|
+
* `ink-<family>-N` → `ink-<family>-(N-1)` for N in 2..10, chromatic families
|
|
55
|
+
* only, and runs NOTHING else — no color renames, no typography, no radius,
|
|
56
|
+
* no weight merges. Like the color migration, there is no sentinel that can
|
|
57
|
+
* tell whether the shift already ran (`ink-red-5` is a valid name on both
|
|
58
|
+
* sides), so --ink-shift MUST run exactly once per codebase — a second run
|
|
59
|
+
* would double-shift. --ink-shift takes directory targets only; a real run
|
|
60
|
+
* writes a `.tokens-v2-ink-shift` marker file in each target directory and
|
|
61
|
+
* refuses to run again while one exists there, in any ancestor directory,
|
|
62
|
+
* or anywhere in the target subtree. Symlinks that leave the target subtree
|
|
63
|
+
* are skipped and reported, never rewritten — an external package must be
|
|
64
|
+
* migrated directly so it gets its own marker.
|
|
65
|
+
* Old `ink-<family>-1` (the neutral-white step) has no
|
|
66
|
+
* automatic destination (the new `-1` is a light tint, not white); it is
|
|
67
|
+
* flagged for manual attention, never rewritten.
|
|
68
|
+
*/
|
|
69
|
+
|
|
70
|
+
import fs from 'fs'
|
|
71
|
+
import path from 'path'
|
|
72
|
+
import { fileURLToPath } from 'url'
|
|
73
|
+
|
|
74
|
+
const USAGE = 'Usage: tokens-v2 [--dry-run] [--force] [--radius-only | --ink-shift] <dir-or-file...>'
|
|
75
|
+
|
|
76
|
+
// ---------- MAPPING ----------
|
|
77
|
+
|
|
78
|
+
// Per-category renames: old suffix → new suffix.
|
|
79
|
+
// `cards` / `gray-modals` are code-side legacy aliases of Figma's
|
|
80
|
+
// `card` / `gray-modal` — both spellings map to the same v2 token.
|
|
81
|
+
const shift = (families, pairs) =>
|
|
82
|
+
Object.fromEntries(
|
|
83
|
+
families.flatMap((f) => pairs.map(([from, to]) => [`${f}-${from}`, `${f}-${to}`])),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
const ACCENTS = ['red', 'blue', 'green', 'amber', 'violet']
|
|
87
|
+
|
|
88
|
+
const SURFACE_RENAMES = {
|
|
89
|
+
white: 'base',
|
|
90
|
+
'menu-bar': 'sidebar',
|
|
91
|
+
card: 'elevation-1',
|
|
92
|
+
cards: 'elevation-1',
|
|
93
|
+
modal: 'elevation-2',
|
|
94
|
+
selected: 'elevation-3',
|
|
95
|
+
// legacy code-side token; resolved to the same values as elevation-3 in both modes
|
|
96
|
+
'gray-2-contrast': 'elevation-3',
|
|
97
|
+
...shift(['gray'], [[5, 8], [6, 9], [7, 10]]),
|
|
98
|
+
...shift(ACCENTS, [[5, 7], [6, 8], [7, 9]]),
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const INK_RENAMES = {
|
|
102
|
+
white: 'base',
|
|
103
|
+
...shift(ACCENTS, [[2, 5], [3, 6], [4, 8]]),
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const OUTLINE_RENAMES = {
|
|
107
|
+
white: 'base',
|
|
108
|
+
'gray-modal': 'elevation-2',
|
|
109
|
+
'gray-modals': 'elevation-2',
|
|
110
|
+
...shift(['gray'], [[5, 7]]),
|
|
111
|
+
...shift(ACCENTS, [[2, 3], [3, 4], [4, 5]]),
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// The alpha categories only kept their neutral ramps in v2.
|
|
115
|
+
const SURFACE_ALPHA_RENAMES = {
|
|
116
|
+
white: 'base',
|
|
117
|
+
'menu-bar': 'sidebar',
|
|
118
|
+
card: 'elevation-1',
|
|
119
|
+
cards: 'elevation-1',
|
|
120
|
+
modal: 'elevation-2',
|
|
121
|
+
selected: 'elevation-3',
|
|
122
|
+
...shift(['gray'], [[5, 8], [6, 9], [7, 10]]),
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const OUTLINE_ALPHA_RENAMES = {
|
|
126
|
+
white: 'base',
|
|
127
|
+
'gray-modal': 'elevation-2',
|
|
128
|
+
'gray-modals': 'elevation-2',
|
|
129
|
+
...shift(['gray'], [[5, 7]]),
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ---------- TYPOGRAPHY SIZE RENAMES ----------
|
|
133
|
+
|
|
134
|
+
// The espresso text scale gained 15px (`md`) and 17px (`xl`) stops. Each
|
|
135
|
+
// physical size keeps its meaning under a new name, so existing utility classes
|
|
136
|
+
// must be renamed to render identically. These chain and so MUST run in a single
|
|
137
|
+
// simultaneous pass — a sequential pass would cascade.
|
|
138
|
+
//
|
|
139
|
+
// Caveat: these names (`text-lg` … `text-9xl`) coincide with stock Tailwind
|
|
140
|
+
// font-size utilities, so only point this at code using the espresso scale, and
|
|
141
|
+
// run exactly once (see header).
|
|
142
|
+
|
|
143
|
+
// `tiny` and `13xl`-`16xl` were removed as unused vocabulary in #940 — no
|
|
144
|
+
// utility ships for them anymore. Must stay in sync with `DROPPED_SIZES` in
|
|
145
|
+
// `figma-tokens-to-theme.js`. Filtered out of the shift tables below so the
|
|
146
|
+
// codemod can never *rename a class onto one of these dead sizes*; see the
|
|
147
|
+
// "no destination" handling further down for reporting pre-existing usage.
|
|
148
|
+
const DEAD_SIZES = ['tiny', '13xl', '14xl', '15xl', '16xl']
|
|
149
|
+
|
|
150
|
+
const UNMIGRATED_TEXT_SIZE_SHIFT_ALL = [
|
|
151
|
+
['xl', '2xl'],
|
|
152
|
+
['2xl', '3xl'],
|
|
153
|
+
['3xl', '4xl'],
|
|
154
|
+
['4xl', '5xl'],
|
|
155
|
+
['5xl', '6xl'],
|
|
156
|
+
['6xl', '7xl'],
|
|
157
|
+
['7xl', '8xl'],
|
|
158
|
+
['8xl', '9xl'],
|
|
159
|
+
['9xl', '10xl'],
|
|
160
|
+
['10xl', '11xl'],
|
|
161
|
+
['11xl', '12xl'],
|
|
162
|
+
['12xl', '13xl'],
|
|
163
|
+
['13xl', '14xl'],
|
|
164
|
+
['14xl', '15xl'],
|
|
165
|
+
['15xl', '16xl'],
|
|
166
|
+
]
|
|
167
|
+
// Old-scale `12xl`-`15xl` would have shifted onto a size dropped in #940 —
|
|
168
|
+
// capped here so they're left untouched (and flagged, see below) instead of
|
|
169
|
+
// renamed to a class that no longer emits CSS.
|
|
170
|
+
const UNMIGRATED_TEXT_SIZE_SHIFT = UNMIGRATED_TEXT_SIZE_SHIFT_ALL.filter(
|
|
171
|
+
([, to]) => !DEAD_SIZES.includes(to),
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
// Apps that already ran the original v2 codemod use the temporary typography
|
|
175
|
+
// names where `text-lg` was 15px and `text-xl` was 16px. Shift those one step
|
|
176
|
+
// down into the corrected names without touching color tokens.
|
|
177
|
+
const MIGRATED_TEXT_SIZE_SHIFT_ALL = [
|
|
178
|
+
['lg', 'md'],
|
|
179
|
+
['xl', 'lg'],
|
|
180
|
+
['2xl', 'xl'],
|
|
181
|
+
['3xl', '2xl'],
|
|
182
|
+
['4xl', '3xl'],
|
|
183
|
+
['5xl', '4xl'],
|
|
184
|
+
['6xl', '5xl'],
|
|
185
|
+
['7xl', '6xl'],
|
|
186
|
+
['8xl', '7xl'],
|
|
187
|
+
['9xl', '8xl'],
|
|
188
|
+
['10xl', '9xl'],
|
|
189
|
+
['11xl', '10xl'],
|
|
190
|
+
['12xl', '11xl'],
|
|
191
|
+
['13xl', '12xl'],
|
|
192
|
+
['14xl', '13xl'],
|
|
193
|
+
['15xl', '14xl'],
|
|
194
|
+
['16xl', '15xl'],
|
|
195
|
+
['17xl', '16xl'],
|
|
196
|
+
]
|
|
197
|
+
// Temp-scale `14xl`-`17xl` would have corrected onto a size dropped in #940 —
|
|
198
|
+
// capped for the same reason as above.
|
|
199
|
+
const MIGRATED_TEXT_SIZE_SHIFT = MIGRATED_TEXT_SIZE_SHIFT_ALL.filter(
|
|
200
|
+
([, to]) => !DEAD_SIZES.includes(to),
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
// Each size surfaces as a bare size utility, a paragraph variant, and weighted
|
|
204
|
+
// component classes. All forms shift together. `-black` is absent on purpose:
|
|
205
|
+
// the black style classes were removed in #998, so a `text-*-black` is never
|
|
206
|
+
// renamed — it's flagged (see BLACK_STYLE_TOKENS below).
|
|
207
|
+
const textSizeRenames = (shift) => Object.fromEntries(
|
|
208
|
+
shift.flatMap(([from, to]) => [
|
|
209
|
+
[`text-${from}`, `text-${to}`],
|
|
210
|
+
[`text-p-${from}`, `text-p-${to}`],
|
|
211
|
+
[`text-${from}-medium`, `text-${to}-medium`],
|
|
212
|
+
[`text-p-${from}-medium`, `text-p-${to}-medium`],
|
|
213
|
+
[`text-${from}-semibold`, `text-${to}-semibold`],
|
|
214
|
+
[`text-p-${from}-semibold`, `text-p-${to}-semibold`],
|
|
215
|
+
[`text-${from}-bold`, `text-${to}-bold`],
|
|
216
|
+
[`text-p-${from}-bold`, `text-p-${to}-bold`],
|
|
217
|
+
]),
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
const UNMIGRATED_TEXT_SIZE_RENAMES = textSizeRenames(UNMIGRATED_TEXT_SIZE_SHIFT)
|
|
221
|
+
const MIGRATED_TEXT_SIZE_RENAMES = textSizeRenames(MIGRATED_TEXT_SIZE_SHIFT)
|
|
222
|
+
|
|
223
|
+
// ---------- RADIUS RENAMES ----------
|
|
224
|
+
|
|
225
|
+
// ADR-0006 / #998: the named radius aliases are removed in 1.0.0 in favor of
|
|
226
|
+
// the numbered scale. The map is deterministic (bare `rounded` = 8px =
|
|
227
|
+
// `rounded-4`; sm→1, md→5, lg→6, xl→7, 2xl→8). `rounded-none` and
|
|
228
|
+
// `rounded-full` are kept tokens, never touched. Unlike the color renames,
|
|
229
|
+
// these are idempotent (no numbered token appears on the left), so they run in
|
|
230
|
+
// every mode.
|
|
231
|
+
const RADIUS_STEP_BY_ALIAS = { sm: '1', md: '5', lg: '6', xl: '7', '2xl': '8' }
|
|
232
|
+
const RADIUS_BARE_STEP = '4'
|
|
233
|
+
// Every side prefix Tailwind derives from the borderRadius scale, including
|
|
234
|
+
// the 3.3+ logical sides. Numbered directional utilities (`rounded-t-6`,
|
|
235
|
+
// `rounded-ss-1`, …) exist for all of them.
|
|
236
|
+
const RADIUS_SIDES = ['t', 'r', 'b', 'l', 'tl', 'tr', 'br', 'bl', 's', 'e', 'ss', 'se', 'es', 'ee']
|
|
237
|
+
|
|
238
|
+
export const RADIUS_RENAMES = {}
|
|
239
|
+
for (const side of RADIUS_SIDES.map((s) => `-${s}`)) {
|
|
240
|
+
// Bare directional alias: `rounded-t` = `rounded-t-4`. (The side-less bare
|
|
241
|
+
// `rounded` is also plain English — see BARE_ROUNDED_REGEX below.)
|
|
242
|
+
RADIUS_RENAMES[`rounded${side}`] = `rounded${side}-${RADIUS_BARE_STEP}`
|
|
243
|
+
}
|
|
244
|
+
for (const side of ['', ...RADIUS_SIDES.map((s) => `-${s}`)]) {
|
|
245
|
+
for (const [alias, step] of Object.entries(RADIUS_STEP_BY_ALIAS)) {
|
|
246
|
+
RADIUS_RENAMES[`rounded${side}-${alias}`] = `rounded${side}-${step}`
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// The bare `rounded` utility is an ordinary English word ("values are rounded
|
|
251
|
+
// to px"), so a global text rename would corrupt prose and comments. It is
|
|
252
|
+
// rewritten only in class-like contexts — see isClassContext().
|
|
253
|
+
const BARE_ROUNDED_REGEX = /(?<![a-zA-Z0-9])rounded(?![a-zA-Z0-9-])/g
|
|
254
|
+
|
|
255
|
+
// A bare `rounded` at `offset` counts as a class when its line puts it inside
|
|
256
|
+
// a quoted string (class attributes, JS class lists, template literals) or in
|
|
257
|
+
// an `@apply` rule. Prose in markdown and comments has neither. Known gap: a
|
|
258
|
+
// class list inside a multi-line template literal has no quote on its own
|
|
259
|
+
// line and is skipped — grep for bare `rounded` after running the codemod.
|
|
260
|
+
|
|
261
|
+
// An apostrophe inside a word (`row's`, `it's`) is not a string delimiter —
|
|
262
|
+
// without this, `// the row's corners are rounded when it's hovered` would
|
|
263
|
+
// count as "inside quotes" and get rewritten.
|
|
264
|
+
const stripContractions = (s) => s.replace(/(?<=\w)'(?=\w)/g, '')
|
|
265
|
+
|
|
266
|
+
function isClassContext(content, offset) {
|
|
267
|
+
const lineStart = content.lastIndexOf('\n', offset - 1) + 1
|
|
268
|
+
const lineEndIdx = content.indexOf('\n', offset)
|
|
269
|
+
const lineEnd = lineEndIdx === -1 ? content.length : lineEndIdx
|
|
270
|
+
const before = content.slice(lineStart, offset)
|
|
271
|
+
const after = content.slice(offset, lineEnd)
|
|
272
|
+
if (/@apply[^;]*$/.test(before)) return true
|
|
273
|
+
for (const quote of ['"', "'", '`']) {
|
|
274
|
+
const b = quote === "'" ? stripContractions(before) : before
|
|
275
|
+
const a = quote === "'" ? stripContractions(after) : after
|
|
276
|
+
const opensBefore = (b.split(quote).length - 1) % 2 === 1
|
|
277
|
+
if (opensBefore && a.includes(quote)) return true
|
|
278
|
+
}
|
|
279
|
+
return false
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Full old token name → full new token name. NOTE: alpha categories must come
|
|
283
|
+
// before their base category when building the alternation so that e.g.
|
|
284
|
+
// `surface-alpha-gray-5` is never half-matched by a `surface-…` rule (the
|
|
285
|
+
// longest-first sort below also guarantees this).
|
|
286
|
+
export const COLOR_TOKEN_RENAMES = {
|
|
287
|
+
...prefix('surface-alpha', SURFACE_ALPHA_RENAMES),
|
|
288
|
+
...prefix('outline-alpha', OUTLINE_ALPHA_RENAMES),
|
|
289
|
+
...prefix('surface', SURFACE_RENAMES),
|
|
290
|
+
...prefix('ink', INK_RENAMES),
|
|
291
|
+
...prefix('outline', OUTLINE_RENAMES),
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export const TOKEN_RENAMES = {
|
|
295
|
+
...COLOR_TOKEN_RENAMES,
|
|
296
|
+
...UNMIGRATED_TEXT_SIZE_RENAMES,
|
|
297
|
+
...RADIUS_RENAMES,
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Radius renames are idempotent, so they also run when the color/typography
|
|
301
|
+
// migration is already done.
|
|
302
|
+
const MIGRATED_MODE_RENAMES = {
|
|
303
|
+
...MIGRATED_TEXT_SIZE_RENAMES,
|
|
304
|
+
...RADIUS_RENAMES,
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// The full text-size vocabulary ever seen by the codemod (live, dead, and
|
|
308
|
+
// temp-scale names). Used for weight merging and the black-style flag list.
|
|
309
|
+
const TEXT_SIZES = [
|
|
310
|
+
'tiny', '2xs', 'xs', 'sm', 'base', 'md', 'lg', 'xl', '2xl', '3xl', '4xl',
|
|
311
|
+
'5xl', '6xl', '7xl', '8xl', '9xl', '10xl', '11xl', '12xl', '13xl', '14xl',
|
|
312
|
+
'15xl', '16xl', '17xl',
|
|
313
|
+
]
|
|
314
|
+
|
|
315
|
+
// A dead size surfaces as a bare size utility and weighted component classes
|
|
316
|
+
// (no `text-p-*` form — paragraph styles only ever went up to `4xl` and never
|
|
317
|
+
// had a `tiny`, so those combinations never existed).
|
|
318
|
+
const deadSizeTokens = (sizes) => sizes.flatMap((s) => [
|
|
319
|
+
`text-${s}`, `text-${s}-medium`, `text-${s}-semibold`, `text-${s}-bold`, `text-${s}-black`,
|
|
320
|
+
])
|
|
321
|
+
|
|
322
|
+
// #998: the black weight styles were removed with zero usage. Any literal
|
|
323
|
+
// `text-*-black` is dead in every mode — flagged, never renamed.
|
|
324
|
+
const BLACK_STYLE_TOKENS = TEXT_SIZES.flatMap((s) => [
|
|
325
|
+
`text-${s}-black`,
|
|
326
|
+
`text-p-${s}-black`,
|
|
327
|
+
])
|
|
328
|
+
|
|
329
|
+
// Tokens dropped in v2 with no replacement — usage is reported, never rewritten.
|
|
330
|
+
export const REMOVED_TOKENS = [
|
|
331
|
+
...[1, 2, 3, 4, 5, 6, 7].map((n) => `surface-alpha-red-${n}`),
|
|
332
|
+
...[2, 3, 4].map((n) => `outline-alpha-red-${n}`),
|
|
333
|
+
// #940: dead in every mode — either the v2 name directly, or (for `tiny`,
|
|
334
|
+
// which never shifted) the only name it ever had.
|
|
335
|
+
...deadSizeTokens(DEAD_SIZES),
|
|
336
|
+
...BLACK_STYLE_TOKENS,
|
|
337
|
+
]
|
|
338
|
+
|
|
339
|
+
// Old-scale `12xl` is only dead in *unmigrated* content: there it's the size
|
|
340
|
+
// that used to shift onto the now-dead v2 `13xl` (see the capped shift table
|
|
341
|
+
// above). In already-migrated content, literal `text-12xl` is the healthy,
|
|
342
|
+
// current v2 name and must not be flagged.
|
|
343
|
+
const UNMIGRATED_REMOVED_TOKENS = [...REMOVED_TOKENS, ...deadSizeTokens(['12xl'])]
|
|
344
|
+
// The migrated-temp-scale `17xl` never shipped as a real token in any mode —
|
|
345
|
+
// it only ever existed as the pre-correction name that used to correct onto
|
|
346
|
+
// the now-dead v2 `16xl`.
|
|
347
|
+
const MIGRATED_REMOVED_TOKENS = [...REMOVED_TOKENS, ...deadSizeTokens(['17xl'])]
|
|
348
|
+
|
|
349
|
+
// Legacy names with no v2 mapping decided yet — reported for manual review.
|
|
350
|
+
export const WATCH_TOKENS = []
|
|
351
|
+
|
|
352
|
+
// ---------- INK SCALE SHIFT (#1016) ----------
|
|
353
|
+
|
|
354
|
+
// The updated espresso v2 export drops the neutral-white `-1` step from every
|
|
355
|
+
// chromatic ink scale, so each level moves down one (new `-1` = old `-2`, …,
|
|
356
|
+
// new `-9` = old `-10`; the scales now end at `-9`). `ink-gray` keeps its own
|
|
357
|
+
// 9-step scale and does not shift. The shift is bounded to N=2..10 with
|
|
358
|
+
// whole-token maps on purpose: an unbounded numeric rewrite would corrupt a
|
|
359
|
+
// stale non-v2 token like `ink-blue-600` into `ink-blue-599`.
|
|
360
|
+
const CHROMATIC_INK_FAMILIES = [
|
|
361
|
+
'red', 'green', 'blue', 'amber', 'violet', 'yellow', 'orange', 'teal',
|
|
362
|
+
'cyan', 'purple', 'pink',
|
|
363
|
+
]
|
|
364
|
+
|
|
365
|
+
export const INK_SHIFT_RENAMES = prefix(
|
|
366
|
+
'ink',
|
|
367
|
+
shift(CHROMATIC_INK_FAMILIES, [
|
|
368
|
+
[2, 1], [3, 2], [4, 3], [5, 4], [6, 5], [7, 6], [8, 7], [9, 8], [10, 9],
|
|
369
|
+
]),
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
// Old `ink-<family>-1` was neutral/white in both themes. The new `-1` is a
|
|
373
|
+
// light tint (100), and rewriting the token text to `white` would corrupt
|
|
374
|
+
// `var(--ink-red-1)` into `var(--white)` (which doesn't exist) — so these are
|
|
375
|
+
// flagged for manual attention, never rewritten.
|
|
376
|
+
export const INK_SHIFT_FLAGGED_TOKENS = CHROMATIC_INK_FAMILIES.map(
|
|
377
|
+
(f) => `ink-${f}-1`,
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
// `ink-red-5` is a valid name before and after the shift, so file content
|
|
381
|
+
// cannot reveal a prior run. A marker file anchored to the migrated target
|
|
382
|
+
// (not the caller's working directory — the codemod is often run from
|
|
383
|
+
// elsewhere with an explicit path) is the only guard against a silent
|
|
384
|
+
// double-shift.
|
|
385
|
+
export const INK_SHIFT_MARKER = '.tokens-v2-ink-shift'
|
|
386
|
+
|
|
387
|
+
// Search the directory and every ancestor: a run on a repo root must also
|
|
388
|
+
// block a later run on one of its subdirectories.
|
|
389
|
+
// `ignore` holds the markers this run wrote itself. The claim-then-verify
|
|
390
|
+
// pass searches again after writing, and must not stop on its own marker.
|
|
391
|
+
export function findInkShiftMarker(dir, { ignore = new Set() } = {}) {
|
|
392
|
+
// realpath, not resolve: a symlink alias must share the identity of its
|
|
393
|
+
// target, or it bypasses the guard.
|
|
394
|
+
let current = fs.realpathSync(dir)
|
|
395
|
+
for (;;) {
|
|
396
|
+
const file = path.join(current, INK_SHIFT_MARKER)
|
|
397
|
+
if (fs.existsSync(file) && !ignore.has(file)) return file
|
|
398
|
+
const parent = path.dirname(current)
|
|
399
|
+
if (parent === current) return null
|
|
400
|
+
current = parent
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// True when a real path is one of the roots or sits inside one.
|
|
405
|
+
function isInsideRoots(real, roots) {
|
|
406
|
+
return roots.some((r) => real === r || real.startsWith(r + path.sep))
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Search the subtree: a run on a subdirectory must also block a later run on
|
|
410
|
+
// its parent, or the already-shifted subtree double-shifts. Mirrors walk()'s
|
|
411
|
+
// directory skip list and its symlink rule — the search must cover exactly
|
|
412
|
+
// what the run would rewrite. A link out of the target subtrees is skipped:
|
|
413
|
+
// walk() does not rewrite it, so a marker there belongs to another codebase
|
|
414
|
+
// and must not block this run.
|
|
415
|
+
export function findInkShiftMarkerBelow(
|
|
416
|
+
dir,
|
|
417
|
+
{ roots = null, seenDirs = new Set(), ignore = new Set() } = {},
|
|
418
|
+
) {
|
|
419
|
+
const resolved = fs.realpathSync(dir)
|
|
420
|
+
const bounds = roots ?? [resolved]
|
|
421
|
+
if (seenDirs.has(resolved)) return null
|
|
422
|
+
seenDirs.add(resolved)
|
|
423
|
+
const file = path.join(resolved, INK_SHIFT_MARKER)
|
|
424
|
+
if (fs.existsSync(file) && !ignore.has(file)) return file
|
|
425
|
+
for (const entry of fs.readdirSync(resolved, { withFileTypes: true })) {
|
|
426
|
+
if (SKIP_DIRS.has(entry.name)) continue
|
|
427
|
+
const full = path.join(resolved, entry.name)
|
|
428
|
+
let isDirectory = entry.isDirectory()
|
|
429
|
+
if (!isDirectory && entry.isSymbolicLink()) {
|
|
430
|
+
try {
|
|
431
|
+
isDirectory = fs.statSync(full).isDirectory()
|
|
432
|
+
} catch {
|
|
433
|
+
continue // broken symlink
|
|
434
|
+
}
|
|
435
|
+
if (isDirectory && !isInsideRoots(fs.realpathSync(full), bounds)) continue
|
|
436
|
+
}
|
|
437
|
+
if (!isDirectory) continue
|
|
438
|
+
const found = findInkShiftMarkerBelow(full, { roots: bounds, seenDirs, ignore })
|
|
439
|
+
if (found) return found
|
|
440
|
+
}
|
|
441
|
+
return null
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// `wx` fails when the file exists, so the create is the claim. Reading the
|
|
445
|
+
// guard and then writing leaves a window in which two concurrent runs both
|
|
446
|
+
// pass and both shift the same files; an exclusive create closes it for the
|
|
447
|
+
// target directories. Nested targets (a root run against a subdirectory run)
|
|
448
|
+
// still race — the ancestor and subtree searches cannot be atomic — which is
|
|
449
|
+
// why the guide says to run the shift once, per package root.
|
|
450
|
+
export function writeInkShiftMarker(dir) {
|
|
451
|
+
fs.writeFileSync(
|
|
452
|
+
path.join(dir, INK_SHIFT_MARKER),
|
|
453
|
+
`The ink scale shift (tokens-v2 --ink-shift, #1016) ran here on ${new Date().toISOString()}.\n` +
|
|
454
|
+
'A second run would double-shift every chromatic ink token.\n' +
|
|
455
|
+
'Delete this file only to re-run the shift on purpose.\n',
|
|
456
|
+
{ flag: 'wx' },
|
|
457
|
+
)
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function prefix(category, renames) {
|
|
461
|
+
return Object.fromEntries(
|
|
462
|
+
Object.entries(renames).map(([from, to]) => [
|
|
463
|
+
`${category}-${from}`,
|
|
464
|
+
`${category}-${to}`,
|
|
465
|
+
]),
|
|
466
|
+
)
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// ---------- REPLACEMENT ----------
|
|
470
|
+
|
|
471
|
+
// Token names are matched whole: not preceded by a letter/digit (a leading `-`
|
|
472
|
+
// is expected — `bg-surface-white`, `var(--surface-white)`) and not followed by
|
|
473
|
+
// anything that could extend the name (`gray-1` must not match in `gray-10`,
|
|
474
|
+
// `card` not in `cards`, `gray-modal` not in `gray-modals`,
|
|
475
|
+
// `surface-gray-2` not in `surface-gray-2-contrast`).
|
|
476
|
+
const byLengthDesc = (a, b) => b.length - a.length
|
|
477
|
+
|
|
478
|
+
const renameRegexFor = (renames) => new RegExp(
|
|
479
|
+
`(?<![a-zA-Z0-9])(${Object.keys(renames).sort(byLengthDesc).join('|')})(?![a-zA-Z0-9-])`,
|
|
480
|
+
'g',
|
|
481
|
+
)
|
|
482
|
+
const buildFlagRegex = (tokens) => new RegExp(
|
|
483
|
+
`(?<![a-zA-Z0-9])(${[...tokens, ...WATCH_TOKENS].sort(byLengthDesc).join('|')})(?![a-zA-Z0-9-])`,
|
|
484
|
+
'g',
|
|
485
|
+
)
|
|
486
|
+
// Which "no replacement" tokens to flag depends on mode — see the `12xl` /
|
|
487
|
+
// `17xl` note above. Radius-only content is presumed migrated (a literal
|
|
488
|
+
// `text-12xl` there is the healthy v2 name), so it shares the migrated list.
|
|
489
|
+
const FULL_FLAG_REGEX = buildFlagRegex(UNMIGRATED_REMOVED_TOKENS)
|
|
490
|
+
const MIGRATED_FLAG_REGEX = buildFlagRegex(MIGRATED_REMOVED_TOKENS)
|
|
491
|
+
// Ink-shift mode flags only the destination-less `ink-<family>-1` tokens —
|
|
492
|
+
// the v1→v2 removed-token report belongs to the other modes.
|
|
493
|
+
const INK_SHIFT_FLAG_REGEX = buildFlagRegex(INK_SHIFT_FLAGGED_TOKENS)
|
|
494
|
+
const flagRegexFor = (mode) => {
|
|
495
|
+
if (mode === 'ink-shift') return INK_SHIFT_FLAG_REGEX
|
|
496
|
+
return mode === 'full' ? FULL_FLAG_REGEX : MIGRATED_FLAG_REGEX
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// ---------- WEIGHT-CLASS MERGE ----------
|
|
500
|
+
|
|
501
|
+
// Collapse a `text-<size>` and a `font-<weight>` that co-occur in the same
|
|
502
|
+
// static class list into the combined `text-<size>-<weight>` style class — the
|
|
503
|
+
// canonical way to express a weighted text style now that letter-spacing is
|
|
504
|
+
// tracked per weight (so `text-base font-medium` is NOT equivalent to
|
|
505
|
+
// `text-base-medium`). The two need not be adjacent; classes in between
|
|
506
|
+
// (`px-2 text-sm font-medium text-ink-gray-7`) are preserved, and color
|
|
507
|
+
// utilities like `text-ink-gray-7` are never mistaken for a size.
|
|
508
|
+
//
|
|
509
|
+
// Only *static* `class="…"` / `className="…"` attributes are touched. Dynamic
|
|
510
|
+
// `:class` / `v-bind:class` and conditional weights are skipped on purpose:
|
|
511
|
+
// merging a conditionally-applied weight into an unconditional size would be
|
|
512
|
+
// wrong. A class list with more than one size or more than one weight is
|
|
513
|
+
// ambiguous and left untouched.
|
|
514
|
+
const WEIGHT_SUFFIX = {
|
|
515
|
+
'font-medium': 'medium',
|
|
516
|
+
'font-semibold': 'semibold',
|
|
517
|
+
'font-bold': 'bold',
|
|
518
|
+
'font-normal': '', // regular — drop the weight; the bare `text-<size>` is regular
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// #998 removed the `text-*-black` styles, so the black-ish weight utilities
|
|
522
|
+
// have no style class to merge onto. A size + one of these is flagged for
|
|
523
|
+
// manual attention instead of merged (`font-extrabold` was the old merge
|
|
524
|
+
// source for `-black`; `font-black` is Tailwind's 900, which never had an
|
|
525
|
+
// espresso style).
|
|
526
|
+
const BLACK_WEIGHT_CLASSES = ['font-extrabold', 'font-black']
|
|
527
|
+
const SIZE_CLASS = new RegExp(`^text-(?:p-)?(?:${TEXT_SIZES.join('|')})$`)
|
|
528
|
+
|
|
529
|
+
// Static class/className attribute value — not `:class` / `v-bind:class` (the
|
|
530
|
+
// negative lookbehind rejects a preceding `:` or `-`).
|
|
531
|
+
const CLASS_ATTR = /(?<![:\w-])(class(?:Name)?)(\s*=\s*)(["'])([^"']*)\3/g
|
|
532
|
+
|
|
533
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
534
|
+
// Whole-token matcher within a space-separated class list (consumes one leading
|
|
535
|
+
// space so removing a mid-list token doesn't leave a double space).
|
|
536
|
+
const tokenRe = (tok) => new RegExp(`(?:^|\\s)${escapeRe(tok)}(?=\\s|$)`)
|
|
537
|
+
|
|
538
|
+
export function mergeWeightClasses(content) {
|
|
539
|
+
const merges = []
|
|
540
|
+
const flagged = []
|
|
541
|
+
const migrated = content.replace(CLASS_ATTR, (full, name, eq, quote, value, offset) => {
|
|
542
|
+
const words = value.split(/\s+/).filter(Boolean)
|
|
543
|
+
const sizes = words.filter((w) => SIZE_CLASS.test(w))
|
|
544
|
+
const weights = words.filter((w) => w in WEIGHT_SUFFIX)
|
|
545
|
+
const blacks = words.filter((w) => BLACK_WEIGHT_CLASSES.includes(w))
|
|
546
|
+
if (sizes.length === 1 && blacks.length > 0) {
|
|
547
|
+
// Would have merged onto a removed `text-*-black` class — flag instead.
|
|
548
|
+
flagged.push({
|
|
549
|
+
token: `${sizes[0]} + ${blacks[0]}`,
|
|
550
|
+
line: lineAt(content, offset),
|
|
551
|
+
})
|
|
552
|
+
return full
|
|
553
|
+
}
|
|
554
|
+
if (sizes.length !== 1 || weights.length !== 1) return full
|
|
555
|
+
|
|
556
|
+
const size = sizes[0]
|
|
557
|
+
const weight = weights[0]
|
|
558
|
+
const suffix = WEIGHT_SUFFIX[weight]
|
|
559
|
+
const merged = suffix ? `${size}-${suffix}` : size
|
|
560
|
+
|
|
561
|
+
const newValue = value
|
|
562
|
+
.replace(new RegExp(`(^|\\s)${escapeRe(size)}(?=\\s|$)`), (m, pre) => `${pre}${merged}`)
|
|
563
|
+
.replace(tokenRe(weight), '')
|
|
564
|
+
merges.push({ from: `${size} + ${weight}`, to: merged, line: lineAt(content, offset) })
|
|
565
|
+
return `${name}${eq}${quote}${newValue}${quote}`
|
|
566
|
+
})
|
|
567
|
+
return { migrated, merges, flagged }
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// ---------- ALREADY-MIGRATED DETECTION ----------
|
|
571
|
+
|
|
572
|
+
// Tokens that exist ONLY pre-migration (renamed away) vs ONLY post-migration. A
|
|
573
|
+
// codebase with post-migration names has almost certainly been migrated already
|
|
574
|
+
// or partially. Re-running color renames is destructive because the color
|
|
575
|
+
// renames reuse names, so default to typography-only when v2 sentinels exist.
|
|
576
|
+
const PRE_MIGRATION_TOKENS = [
|
|
577
|
+
'surface-white', 'ink-white', 'outline-white', 'surface-menu-bar',
|
|
578
|
+
'surface-card', 'surface-cards', 'surface-modal', 'surface-selected',
|
|
579
|
+
'outline-gray-modal', 'outline-gray-modals',
|
|
580
|
+
]
|
|
581
|
+
const POST_MIGRATION_TOKENS = [
|
|
582
|
+
'surface-base', 'ink-base', 'outline-base', 'surface-sidebar',
|
|
583
|
+
'surface-elevation-1', 'surface-elevation-2', 'surface-elevation-3',
|
|
584
|
+
'outline-elevation-2',
|
|
585
|
+
]
|
|
586
|
+
const sentinelRegex = (tokens) =>
|
|
587
|
+
new RegExp(`(?<![a-zA-Z0-9])(?:${tokens.slice().sort(byLengthDesc).join('|')})(?![a-zA-Z0-9-])`, 'g')
|
|
588
|
+
const PRE_REGEX = sentinelRegex(PRE_MIGRATION_TOKENS)
|
|
589
|
+
const POST_REGEX = sentinelRegex(POST_MIGRATION_TOKENS)
|
|
590
|
+
|
|
591
|
+
export function detectMigrationState(files) {
|
|
592
|
+
let pre = 0
|
|
593
|
+
let post = 0
|
|
594
|
+
for (const file of files) {
|
|
595
|
+
const content = fs.readFileSync(file, 'utf8')
|
|
596
|
+
pre += (content.match(PRE_REGEX) || []).length
|
|
597
|
+
post += (content.match(POST_REGEX) || []).length
|
|
598
|
+
}
|
|
599
|
+
return { pre, post, likelyMigrated: post > 0 }
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
export function getMigrationMode(
|
|
603
|
+
{ likelyMigrated },
|
|
604
|
+
{ force = false, radiusOnly = false, inkShift = false } = {},
|
|
605
|
+
) {
|
|
606
|
+
if (inkShift) return 'ink-shift'
|
|
607
|
+
if (radiusOnly) return 'radius-only'
|
|
608
|
+
return likelyMigrated && !force ? 'migrated-typography' : 'full'
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const RENAMES_BY_MODE = {
|
|
612
|
+
full: TOKEN_RENAMES,
|
|
613
|
+
'migrated-typography': MIGRATED_MODE_RENAMES,
|
|
614
|
+
'radius-only': RADIUS_RENAMES,
|
|
615
|
+
'ink-shift': INK_SHIFT_RENAMES,
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
export function migrateTokens(content, { mode = 'full' } = {}) {
|
|
619
|
+
const tokenRenames = RENAMES_BY_MODE[mode]
|
|
620
|
+
const renameRegex = renameRegexFor(tokenRenames)
|
|
621
|
+
const replacements = []
|
|
622
|
+
let migrated = content.replace(renameRegex, (match, _token, offset) => {
|
|
623
|
+
const to = tokenRenames[match]
|
|
624
|
+
replacements.push({ from: match, to, line: lineAt(content, offset) })
|
|
625
|
+
return to
|
|
626
|
+
})
|
|
627
|
+
|
|
628
|
+
// Bare `rounded` → `rounded-4`, class contexts only (see isClassContext).
|
|
629
|
+
// Not in ink-shift mode — that mode runs ONLY the ink scale shift.
|
|
630
|
+
if (mode !== 'ink-shift') {
|
|
631
|
+
migrated = migrated.replace(BARE_ROUNDED_REGEX, (match, offset) => {
|
|
632
|
+
if (!isClassContext(migrated, offset)) return match
|
|
633
|
+
const to = `rounded-${RADIUS_BARE_STEP}`
|
|
634
|
+
replacements.push({ from: match, to, line: lineAt(migrated, offset) })
|
|
635
|
+
return to
|
|
636
|
+
})
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
let merges = []
|
|
640
|
+
const flagged = []
|
|
641
|
+
if (mode === 'full') {
|
|
642
|
+
// Merge weight classes on the post-rename text so `text-xl font-medium`
|
|
643
|
+
// becomes `text-2xl-medium` (size shift first, then merge).
|
|
644
|
+
const result = mergeWeightClasses(migrated)
|
|
645
|
+
migrated = result.migrated
|
|
646
|
+
merges = result.merges
|
|
647
|
+
flagged.push(...result.flagged)
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
for (const m of content.matchAll(flagRegexFor(mode))) {
|
|
651
|
+
flagged.push({ token: m[0], line: lineAt(content, m.index) })
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
return { migrated, replacements, merges, flagged }
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function lineAt(content, offset) {
|
|
658
|
+
let line = 1
|
|
659
|
+
for (let i = 0; i < offset; i++) if (content[i] === '\n') line++
|
|
660
|
+
return line
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// ---------- CLI ----------
|
|
664
|
+
|
|
665
|
+
const EXTENSIONS = new Set([
|
|
666
|
+
'.vue', '.ts', '.tsx', '.js', '.jsx', '.md', '.css', '.scss', '.html',
|
|
667
|
+
])
|
|
668
|
+
const SKIP_DIRS = new Set([
|
|
669
|
+
'node_modules', '.git', 'dist', 'cache', 'generated', 'espresso-v2-design-tokens',
|
|
670
|
+
])
|
|
671
|
+
|
|
672
|
+
// Symlinks — directories and files alike — are followed only while their real
|
|
673
|
+
// path stays inside one of the run's target subtrees. A link to an external
|
|
674
|
+
// package is skipped and reported instead of rewritten: rewriting it would
|
|
675
|
+
// leave it without a run-once marker of its own, and a later direct
|
|
676
|
+
// --ink-shift run on that package would double-shift it. `ctx.seenDirs` is the
|
|
677
|
+
// cycle guard — an internal link back to an ancestor must not recurse forever.
|
|
678
|
+
function makeWalkContext(targets) {
|
|
679
|
+
return {
|
|
680
|
+
roots: targets.map((t) => fs.realpathSync(t)),
|
|
681
|
+
seenDirs: new Set(),
|
|
682
|
+
externals: [],
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function* walk(target, ctx = makeWalkContext([target])) {
|
|
687
|
+
const stat = fs.statSync(target)
|
|
688
|
+
if (stat.isFile()) {
|
|
689
|
+
yield target
|
|
690
|
+
return
|
|
691
|
+
}
|
|
692
|
+
const real = fs.realpathSync(target)
|
|
693
|
+
if (ctx.seenDirs.has(real)) return
|
|
694
|
+
ctx.seenDirs.add(real)
|
|
695
|
+
for (const entry of fs.readdirSync(target, { withFileTypes: true })) {
|
|
696
|
+
const full = path.join(target, entry.name)
|
|
697
|
+
let isDirectory = entry.isDirectory()
|
|
698
|
+
if (entry.isSymbolicLink()) {
|
|
699
|
+
let real
|
|
700
|
+
try {
|
|
701
|
+
real = fs.realpathSync(full)
|
|
702
|
+
isDirectory = fs.statSync(full).isDirectory()
|
|
703
|
+
} catch {
|
|
704
|
+
continue // broken symlink
|
|
705
|
+
}
|
|
706
|
+
// Only links the run would otherwise rewrite are worth reporting.
|
|
707
|
+
const wouldRewrite = isDirectory
|
|
708
|
+
? !SKIP_DIRS.has(entry.name)
|
|
709
|
+
: EXTENSIONS.has(path.extname(entry.name))
|
|
710
|
+
if (wouldRewrite && !isInsideRoots(real, ctx.roots)) {
|
|
711
|
+
ctx.externals.push(full)
|
|
712
|
+
continue
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
if (isDirectory) {
|
|
716
|
+
if (!SKIP_DIRS.has(entry.name)) yield* walk(full, ctx)
|
|
717
|
+
} else if (EXTENSIONS.has(path.extname(entry.name))) {
|
|
718
|
+
yield full
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
function main() {
|
|
724
|
+
const args = process.argv.slice(2)
|
|
725
|
+
const help = args.includes('--help') || args.includes('-h')
|
|
726
|
+
const dryRun = args.includes('--dry-run')
|
|
727
|
+
const force = args.includes('--force')
|
|
728
|
+
const radiusOnly = args.includes('--radius-only')
|
|
729
|
+
const inkShift = args.includes('--ink-shift')
|
|
730
|
+
const targets = args.filter((a) => !a.startsWith('--'))
|
|
731
|
+
|
|
732
|
+
if (help) {
|
|
733
|
+
console.log(USAGE)
|
|
734
|
+
return
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// --ink-shift is a separate run-once pass — mixing it with the v1→v2
|
|
738
|
+
// migration flags would hide which renames ran.
|
|
739
|
+
if (inkShift && (force || radiusOnly)) {
|
|
740
|
+
console.error('--ink-shift cannot be combined with --force or --radius-only.')
|
|
741
|
+
console.error(USAGE)
|
|
742
|
+
process.exit(1)
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
if (targets.length === 0) {
|
|
746
|
+
console.error(USAGE)
|
|
747
|
+
process.exit(1)
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// Dedupe by real path: overlapping targets (`src src/components`) or a
|
|
751
|
+
// symlink alias of one must not process a shared file once per target — in
|
|
752
|
+
// ink-shift mode a second pass is a double-shift.
|
|
753
|
+
const files = []
|
|
754
|
+
const seenFiles = new Set()
|
|
755
|
+
const walkCtx = makeWalkContext(targets)
|
|
756
|
+
for (const target of targets) {
|
|
757
|
+
for (const file of walk(target, walkCtx)) {
|
|
758
|
+
const resolved = fs.realpathSync(file)
|
|
759
|
+
if (seenFiles.has(resolved)) continue
|
|
760
|
+
seenFiles.add(resolved)
|
|
761
|
+
files.push(file)
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// Guard against a destructive second full pass (the color renames reuse names).
|
|
766
|
+
const { pre, post, likelyMigrated } = detectMigrationState(files)
|
|
767
|
+
const mode = getMigrationMode({ likelyMigrated }, { force, radiusOnly, inkShift })
|
|
768
|
+
// realpath so a symlink alias and its target share one identity.
|
|
769
|
+
const inkShiftMarkerDirs =
|
|
770
|
+
mode === 'ink-shift' ? [...new Set(targets.map((t) => fs.realpathSync(t)))] : []
|
|
771
|
+
if (mode === 'ink-shift') {
|
|
772
|
+
// The marker records "this subtree shifted" — a file target would make it
|
|
773
|
+
// over-claim the whole directory, so only directory targets are allowed.
|
|
774
|
+
const fileTarget = inkShiftMarkerDirs.find((t) => !fs.statSync(t).isDirectory())
|
|
775
|
+
if (fileTarget) {
|
|
776
|
+
console.error(`\n✗ --ink-shift takes directory targets only, got a file: ${fileTarget}`)
|
|
777
|
+
console.error(` The ${INK_SHIFT_MARKER} run-once marker guards a directory subtree.\n`)
|
|
778
|
+
process.exit(1)
|
|
779
|
+
}
|
|
780
|
+
const marker =
|
|
781
|
+
inkShiftMarkerDirs.map((d) => findInkShiftMarker(d)).find(Boolean) ||
|
|
782
|
+
inkShiftMarkerDirs
|
|
783
|
+
.map((d) => findInkShiftMarkerBelow(d, { roots: inkShiftMarkerDirs }))
|
|
784
|
+
.find(Boolean)
|
|
785
|
+
// Both branches print this. Keep one source and let each pass its writer;
|
|
786
|
+
// two copies drift.
|
|
787
|
+
const printVendoredHint = (write) => {
|
|
788
|
+
write(' If the marker sits in a vendored copy outside node_modules, targeting')
|
|
789
|
+
write(` any ancestor of it finds it again — target directories that do not contain ${marker}.\n`)
|
|
790
|
+
}
|
|
791
|
+
if (marker && !dryRun) {
|
|
792
|
+
console.error(`\n✗ ${marker} found: --ink-shift already ran on this target.`)
|
|
793
|
+
console.error(' A second run would double-shift every chromatic ink token.')
|
|
794
|
+
console.error(' Delete the marker only to re-run the shift on purpose.')
|
|
795
|
+
printVendoredHint(console.error)
|
|
796
|
+
process.exit(1)
|
|
797
|
+
}
|
|
798
|
+
if (marker) {
|
|
799
|
+
console.warn(`\n⚠ ${marker} found: --ink-shift already ran on this target.`)
|
|
800
|
+
console.warn(' A real run would double-shift and will refuse to start.')
|
|
801
|
+
printVendoredHint(console.warn)
|
|
802
|
+
} else {
|
|
803
|
+
console.warn('\n⚠ Ink scale shift (#1016): this must run exactly once per codebase.')
|
|
804
|
+
console.warn(
|
|
805
|
+
dryRun
|
|
806
|
+
? ` A real run writes a ${INK_SHIFT_MARKER} marker file in each target directory; --dry-run writes nothing.\n`
|
|
807
|
+
: ` A ${INK_SHIFT_MARKER} marker file in each target directory will record this run.\n`,
|
|
808
|
+
)
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
if (likelyMigrated && !radiusOnly && !inkShift) {
|
|
812
|
+
console.warn('\n⚠ This codebase looks already or partially migrated to espresso v2.')
|
|
813
|
+
console.warn(` Found ${post} v2-only token(s) and ${pre} pre-v2 token(s).`)
|
|
814
|
+
if (force) {
|
|
815
|
+
console.warn(' --force set: running the full migration anyway. Color tokens may double-shift.\n')
|
|
816
|
+
} else {
|
|
817
|
+
if (pre > 0) {
|
|
818
|
+
console.warn(` ${pre} pre-v2 color token(s) will be left untouched.`)
|
|
819
|
+
console.warn(' Pass --force to run the color migration too. Review carefully: color tokens may double-shift.')
|
|
820
|
+
}
|
|
821
|
+
console.warn(' Running only the typography correction (`text-lg` → `text-md`, ...) and the radius renames.')
|
|
822
|
+
console.warn(' Already ran the typography correction too? Use --radius-only instead.\n')
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// Write the markers BEFORE any file rewrite so an interrupted run fails
|
|
827
|
+
// closed: the retry refuses instead of double-shifting the files that were
|
|
828
|
+
// already rewritten. Recovery: restore with git, delete the marker, re-run.
|
|
829
|
+
if (mode === 'ink-shift' && !dryRun) {
|
|
830
|
+
// All or nothing: a marker write that fails leaves no rewrite behind it,
|
|
831
|
+
// so the markers already written must go too. Keeping them would refuse a
|
|
832
|
+
// retry on targets that never shifted.
|
|
833
|
+
const written = []
|
|
834
|
+
const rollBack = () => {
|
|
835
|
+
for (const file of written) {
|
|
836
|
+
try {
|
|
837
|
+
fs.unlinkSync(file)
|
|
838
|
+
} catch {
|
|
839
|
+
console.error(` Could not remove ${file} — delete it before you re-run.`)
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
try {
|
|
844
|
+
for (const dir of inkShiftMarkerDirs) {
|
|
845
|
+
writeInkShiftMarker(dir)
|
|
846
|
+
written.push(path.join(dir, INK_SHIFT_MARKER))
|
|
847
|
+
}
|
|
848
|
+
} catch (err) {
|
|
849
|
+
rollBack()
|
|
850
|
+
if (err.code === 'EEXIST') {
|
|
851
|
+
console.error(`\n✗ Another --ink-shift run claimed ${err.path} first.`)
|
|
852
|
+
console.error(' No file was rewritten here. Let that run finish.\n')
|
|
853
|
+
} else {
|
|
854
|
+
console.error(`\n✗ Could not write the ${INK_SHIFT_MARKER} marker: ${err.message}`)
|
|
855
|
+
console.error(' No file was rewritten. Fix the permission and re-run.\n')
|
|
856
|
+
}
|
|
857
|
+
process.exit(1)
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// Claim, then verify. An exclusive create only serialises runs on the SAME
|
|
861
|
+
// directory. Nested targets — a repo root against one of its
|
|
862
|
+
// subdirectories — claim different directories, so both could pass the
|
|
863
|
+
// first check. Searching again after the claim finds the other run's
|
|
864
|
+
// marker, which the first check could not have seen. Both runs may abort;
|
|
865
|
+
// that is the safe outcome, because neither has rewritten a file yet.
|
|
866
|
+
const ours = new Set(written)
|
|
867
|
+
const rival =
|
|
868
|
+
inkShiftMarkerDirs.map((d) => findInkShiftMarker(d, { ignore: ours })).find(Boolean) ||
|
|
869
|
+
inkShiftMarkerDirs
|
|
870
|
+
.map((d) => findInkShiftMarkerBelow(d, { roots: inkShiftMarkerDirs, ignore: ours }))
|
|
871
|
+
.find(Boolean)
|
|
872
|
+
if (rival) {
|
|
873
|
+
rollBack()
|
|
874
|
+
console.error(`\n✗ ${rival} appeared while this run was claiming its targets.`)
|
|
875
|
+
console.error(' Another --ink-shift run overlaps this one. No file was rewritten.')
|
|
876
|
+
console.error(' Let it finish, then re-run only what is still unshifted.\n')
|
|
877
|
+
process.exit(1)
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
console.log(
|
|
881
|
+
`Wrote ${INK_SHIFT_MARKER} in ${inkShiftMarkerDirs.join(', ')} — it blocks an accidental second run.\n`,
|
|
882
|
+
)
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
let filesChanged = 0
|
|
886
|
+
let totalReplacements = 0
|
|
887
|
+
let totalMerges = 0
|
|
888
|
+
const allFlagged = []
|
|
889
|
+
|
|
890
|
+
for (const file of files) {
|
|
891
|
+
const content = fs.readFileSync(file, 'utf8')
|
|
892
|
+
const { migrated, replacements, merges, flagged } = migrateTokens(content, { mode })
|
|
893
|
+
|
|
894
|
+
for (const f of flagged) allFlagged.push({ file, ...f })
|
|
895
|
+
const changeCount = replacements.length + merges.length
|
|
896
|
+
if (changeCount === 0) continue
|
|
897
|
+
|
|
898
|
+
filesChanged++
|
|
899
|
+
totalReplacements += replacements.length
|
|
900
|
+
totalMerges += merges.length
|
|
901
|
+
if (dryRun) {
|
|
902
|
+
console.log(`${file} (${changeCount})`)
|
|
903
|
+
for (const r of replacements) console.log(` L${r.line}: ${r.from} -> ${r.to}`)
|
|
904
|
+
for (const m of merges) console.log(` L${m.line}: ${m.from} => ${m.to}`)
|
|
905
|
+
} else {
|
|
906
|
+
fs.writeFileSync(file, migrated)
|
|
907
|
+
console.log(`${file} (${changeCount})`)
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
console.log(
|
|
912
|
+
`\n${dryRun ? '[dry-run] would update' : 'Updated'} ${filesChanged} files, ` +
|
|
913
|
+
`${totalReplacements} token renames, ${totalMerges} weight-class merges` +
|
|
914
|
+
(mode === 'migrated-typography' ? ' (typography correction + radius renames)' : '') +
|
|
915
|
+
(mode === 'radius-only' ? ' (radius renames only)' : '') +
|
|
916
|
+
(mode === 'ink-shift' ? ' (ink scale shift only)' : ''),
|
|
917
|
+
)
|
|
918
|
+
|
|
919
|
+
if (allFlagged.length > 0) {
|
|
920
|
+
console.log('\n⚠ Tokens needing manual attention (removed in v2 or unmapped):')
|
|
921
|
+
for (const f of allFlagged) {
|
|
922
|
+
console.log(` ${f.file}:L${f.line} ${f.token}`)
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
if (walkCtx.externals.length > 0) {
|
|
927
|
+
console.log('\n⚠ Symlinks to external packages were NOT migrated:')
|
|
928
|
+
for (const link of walkCtx.externals) {
|
|
929
|
+
console.log(` ${link} -> ${fs.realpathSync(link)}`)
|
|
930
|
+
}
|
|
931
|
+
console.log(' Run the codemod on each real package root directly.')
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
const scriptPath = fileURLToPath(import.meta.url)
|
|
936
|
+
const invokedPath = process.argv[1]
|
|
937
|
+
const isCLI = invokedPath && fs.realpathSync(invokedPath) === fs.realpathSync(scriptPath)
|
|
938
|
+
if (isCLI) main()
|