@nexub/tokens 1.7.0 → 1.8.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/bin/README.md +31 -0
- package/bin/nexub-migrate.mjs +406 -0
- package/package.json +8 -3
package/bin/README.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# nexub-migrate
|
|
2
|
+
|
|
3
|
+
Moves app code onto current Nexub DS conventions. Ships with `@nexub/tokens`; no dependencies, Node >= 20.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npx nexub-migrate # dry run over ./src: diff + manual-review items
|
|
7
|
+
npx nexub-migrate src app --write # apply fixes
|
|
8
|
+
npx nexub-migrate --check # CI: exit 1 if anything is found
|
|
9
|
+
npx nexub-migrate --json # machine-readable
|
|
10
|
+
npx nexub-migrate --rules logical-classes --ignore 'src/legacy/**'
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
| Rule | Does |
|
|
14
|
+
| --- | --- |
|
|
15
|
+
| `legacy-tokens` | `var(--color-text-primary)` → `var(--color-on-surface)`, `--color-{leap,bookub,payub,founderub}-500` → `--color-{brand}`, `--color-brand-primary-500` → `--color-primary`. Map is built from the installed `@nexub/themes` (`base.css` + `light.css`), else an embedded snapshot. Color-mix aliases (`--color-*-border`) are reported, not rewritten. `--color-text-disabled` is a role and is never touched; declarations (`--color-x: …`) are left alone. |
|
|
16
|
+
| `logical-classes` | `ml/mr` `pl/pr` `border-l/r` `rounded-l/r/tl/tr/bl/br` `text-left/right` → `ms/me` `ps/pe` `border-s/e` `rounded-s/e/ss/se/es/ee` `text-start/end`, keeping variants, `!` and `-`. `left-*`/`right-*` positions are reported only. |
|
|
17
|
+
| `tw-animate` | Reports tailwindcss-animate classes (`animate-in`, `fade-in-0`, `zoom-in-95`, `slide-in-from-*` …). Use `nx-motion` / `nx-pop` / `nx-dialog` from `@nexub/themes/base`. |
|
|
18
|
+
| `opacity-modifier` | Reports `bg-[var(--x)]/10` (Tailwind 3 emits nothing). `--fix` rewrites to `bg-[color-mix(in_srgb,var(--x)_10%,transparent)]`. |
|
|
19
|
+
|
|
20
|
+
**Where classes are looked for.** `.html`/`.mdx`: `class=`/`className=` attributes. `.css`/`.scss`: `@apply`.
|
|
21
|
+
JS/TS: string and template literals (static parts only) that are either in an explicit class context —
|
|
22
|
+
`className=`, `class=`, the args of `cn/clsx/cva/cx/twMerge/twJoin(…)`, `tw\`…\`` — or where every
|
|
23
|
+
whitespace-separated token looks like a Tailwind class (lowercase start, no quotes/braces). So `"ml-2 pe-4"`
|
|
24
|
+
is rewritten, `"Align text-left please"` and identifiers like `ml` are not. Margin/padding values must be
|
|
25
|
+
spacing-like (`2`, `0.5`, `px`, `auto`, `[…]`), so `"pr-review"` stays.
|
|
26
|
+
|
|
27
|
+
Limits: no regex-literal or JSX-text awareness (a stray `'` can hide the rest of that line from the class
|
|
28
|
+
pass); a single-token string such as a TS union member `'text-left'` is treated as a class. Docs that
|
|
29
|
+
mention legacy token names in prose get rewritten too — `--ignore` them.
|
|
30
|
+
|
|
31
|
+
Skips `node_modules`, `dist`, `build`, `.next`, `out`, `coverage` and files over 1 MB.
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// nexub-migrate — move app code onto current Nexub DS conventions.
|
|
3
|
+
// Dependency-free (node: built-ins only), Node >= 20. See ./README.md.
|
|
4
|
+
import { readFileSync, readdirSync, realpathSync, statSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { createRequire } from 'node:module';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { parseArgs } from 'node:util';
|
|
9
|
+
|
|
10
|
+
export const RULES = ['legacy-tokens', 'logical-classes', 'tw-animate', 'opacity-modifier'];
|
|
11
|
+
const EXTS = new Set(['.ts', '.tsx', '.js', '.jsx', '.css', '.scss', '.html', '.mdx']);
|
|
12
|
+
const SKIP_DIRS = new Set(['node_modules', 'dist', 'build', '.next', 'out', 'coverage']);
|
|
13
|
+
const MAX_BYTES = 1024 * 1024;
|
|
14
|
+
|
|
15
|
+
// ─── legacy-tokens: alias map ────────────────────────────────────────────────
|
|
16
|
+
const NEVER = new Set(['--color-text-disabled']); // the disabled-state role, not a legacy alias
|
|
17
|
+
const BRAND = {
|
|
18
|
+
'--color-leap-500': '--color-leap',
|
|
19
|
+
'--color-bookub-500': '--color-bookub',
|
|
20
|
+
'--color-payub-500': '--color-payub',
|
|
21
|
+
'--color-founderub-500': '--color-founderub',
|
|
22
|
+
'--color-brand-primary-500': '--color-primary',
|
|
23
|
+
};
|
|
24
|
+
// Chains that end here are palette/scale tokens, not roles: leave them alone.
|
|
25
|
+
const NOT_ROLE = /^--color-(palette|accent|brand)-/;
|
|
26
|
+
|
|
27
|
+
/** Parse theme CSS (base.css, light.css …) into { aliases: {legacy: role}, mix: {legacy: value} }. */
|
|
28
|
+
export function buildAliasMap(cssTexts) {
|
|
29
|
+
const decl = new Map();
|
|
30
|
+
for (const css of cssTexts) {
|
|
31
|
+
for (const m of css.replace(/\/\*[\s\S]*?\*\//g, '').matchAll(/(--color-[\w-]+)\s*:\s*([^;]+);/g)) decl.set(m[1], m[2].trim());
|
|
32
|
+
}
|
|
33
|
+
const resolve = (name, seen) => {
|
|
34
|
+
if (BRAND[name]) return { to: BRAND[name] };
|
|
35
|
+
const v = decl.get(name);
|
|
36
|
+
if (v === undefined) return NOT_ROLE.test(name) ? null : { to: name };
|
|
37
|
+
if (seen.has(name)) return null; // cycle
|
|
38
|
+
seen.add(name);
|
|
39
|
+
const ref = /^var\((--color-[\w-]+)\)$/.exec(v);
|
|
40
|
+
if (ref) return resolve(ref[1], seen);
|
|
41
|
+
if (v.startsWith('color-mix(')) return { mix: v };
|
|
42
|
+
return null; // literal value: not an alias
|
|
43
|
+
};
|
|
44
|
+
const aliases = { ...BRAND };
|
|
45
|
+
const mix = {};
|
|
46
|
+
for (const name of decl.keys()) {
|
|
47
|
+
if (NEVER.has(name)) continue;
|
|
48
|
+
const r = resolve(name, new Set());
|
|
49
|
+
if (r?.to && r.to !== name) aliases[name] = r.to;
|
|
50
|
+
else if (r?.mix) mix[name] = r.mix;
|
|
51
|
+
}
|
|
52
|
+
return { aliases, mix };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Snapshot of buildAliasMap([@nexub/themes base.css, light.css]) at 1.8.0 — used when
|
|
56
|
+
// @nexub/themes is not installed. bin/nexub-migrate.test.mjs fails if it drifts from source.
|
|
57
|
+
export const SNAPSHOT = /* SNAPSHOT:BEGIN */ {
|
|
58
|
+
"aliases": {
|
|
59
|
+
"--color-leap-500": "--color-leap",
|
|
60
|
+
"--color-bookub-500": "--color-bookub",
|
|
61
|
+
"--color-payub-500": "--color-payub",
|
|
62
|
+
"--color-founderub-500": "--color-founderub",
|
|
63
|
+
"--color-brand-primary-500": "--color-primary",
|
|
64
|
+
"--color-success-500": "--color-success",
|
|
65
|
+
"--color-success-600": "--color-on-success-container",
|
|
66
|
+
"--color-success-bg": "--color-success-container",
|
|
67
|
+
"--color-error-500": "--color-error",
|
|
68
|
+
"--color-error-600": "--color-on-error-container",
|
|
69
|
+
"--color-error-bg": "--color-error-container",
|
|
70
|
+
"--color-danger-500": "--color-error",
|
|
71
|
+
"--color-danger-600": "--color-on-error-container",
|
|
72
|
+
"--color-danger-bg": "--color-error-container",
|
|
73
|
+
"--color-warning-500": "--color-warning",
|
|
74
|
+
"--color-warning-600": "--color-on-warning-container",
|
|
75
|
+
"--color-warning-bg": "--color-warning-container",
|
|
76
|
+
"--color-info-500": "--color-info",
|
|
77
|
+
"--color-info-600": "--color-on-info-container",
|
|
78
|
+
"--color-info-bg": "--color-info-container",
|
|
79
|
+
"--color-border-input": "--color-outline",
|
|
80
|
+
"--color-text-placeholder": "--color-on-surface-muted",
|
|
81
|
+
"--color-text-info": "--color-on-info-container",
|
|
82
|
+
"--color-text-success": "--color-on-success-container",
|
|
83
|
+
"--color-text-warning": "--color-on-warning-container",
|
|
84
|
+
"--color-text-danger": "--color-on-error-container",
|
|
85
|
+
"--color-accent-500": "--color-primary",
|
|
86
|
+
"--color-bg-default": "--color-surface",
|
|
87
|
+
"--color-bg-subtle": "--color-surface-container-low",
|
|
88
|
+
"--color-bg-muted": "--color-surface-container-high",
|
|
89
|
+
"--color-bg-raised": "--color-surface-elevated",
|
|
90
|
+
"--color-bg-inverse": "--color-inverse-surface",
|
|
91
|
+
"--color-text-primary": "--color-on-surface",
|
|
92
|
+
"--color-text-secondary": "--color-on-surface-variant",
|
|
93
|
+
"--color-text-muted": "--color-on-surface-muted",
|
|
94
|
+
"--color-text-subtle": "--color-outline",
|
|
95
|
+
"--color-text-inverse": "--color-inverse-on-surface",
|
|
96
|
+
"--color-text-link": "--color-primary",
|
|
97
|
+
"--color-border-default": "--color-outline-variant",
|
|
98
|
+
"--color-border-subtle": "--color-surface-container-high",
|
|
99
|
+
"--color-border-strong": "--color-outline",
|
|
100
|
+
"--color-border-inverse": "--color-inverse-on-surface",
|
|
101
|
+
"--color-ring-default": "--color-primary",
|
|
102
|
+
"--color-ring-offset": "--color-surface",
|
|
103
|
+
"--color-surface-muted": "--color-surface-container-low",
|
|
104
|
+
"--color-surface-raised": "--color-surface-elevated",
|
|
105
|
+
"--color-surface-sunken": "--color-surface-container-high",
|
|
106
|
+
"--color-surface-inverse": "--color-inverse-surface",
|
|
107
|
+
"--color-semantic-success-default": "--color-success",
|
|
108
|
+
"--color-semantic-success-strong": "--color-on-success-container",
|
|
109
|
+
"--color-semantic-success-bg": "--color-success-container",
|
|
110
|
+
"--color-semantic-error-default": "--color-error",
|
|
111
|
+
"--color-semantic-error-strong": "--color-on-error-container",
|
|
112
|
+
"--color-semantic-error-bg": "--color-error-container",
|
|
113
|
+
"--color-semantic-warning-default": "--color-warning",
|
|
114
|
+
"--color-semantic-warning-strong": "--color-on-warning-container",
|
|
115
|
+
"--color-semantic-warning-bg": "--color-warning-container",
|
|
116
|
+
"--color-semantic-info-default": "--color-info",
|
|
117
|
+
"--color-semantic-info-strong": "--color-on-info-container",
|
|
118
|
+
"--color-semantic-info-bg": "--color-info-container"
|
|
119
|
+
},
|
|
120
|
+
"mix": {
|
|
121
|
+
"--color-success-border": "color-mix(in srgb, var(--color-success) 35%, transparent)",
|
|
122
|
+
"--color-error-border": "color-mix(in srgb, var(--color-error) 35%, transparent)",
|
|
123
|
+
"--color-danger-border": "color-mix(in srgb, var(--color-error) 35%, transparent)",
|
|
124
|
+
"--color-warning-border": "color-mix(in srgb, var(--color-warning) 35%, transparent)",
|
|
125
|
+
"--color-info-border": "color-mix(in srgb, var(--color-info) 35%, transparent)",
|
|
126
|
+
"--color-semantic-success-border": "color-mix(in srgb, var(--color-success) 35%, transparent)",
|
|
127
|
+
"--color-semantic-error-border": "color-mix(in srgb, var(--color-error) 35%, transparent)",
|
|
128
|
+
"--color-semantic-warning-border": "color-mix(in srgb, var(--color-warning) 35%, transparent)",
|
|
129
|
+
"--color-semantic-info-border": "color-mix(in srgb, var(--color-info) 35%, transparent)"
|
|
130
|
+
}
|
|
131
|
+
} /* SNAPSHOT:END */;
|
|
132
|
+
|
|
133
|
+
export function loadAliasMap(cwd = process.cwd()) {
|
|
134
|
+
for (const from of [path.join(cwd, 'package.json'), import.meta.url]) {
|
|
135
|
+
try {
|
|
136
|
+
const req = createRequire(from);
|
|
137
|
+
const css = ['base', 'light'].map((f) => readFileSync(req.resolve(`@nexub/themes/${f}`), 'utf8'));
|
|
138
|
+
return { ...buildAliasMap(css), source: '@nexub/themes' };
|
|
139
|
+
} catch { /* not resolvable from here */ }
|
|
140
|
+
}
|
|
141
|
+
return { ...SNAPSHOT, source: 'snapshot' };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ─── class-string contexts ───────────────────────────────────────────────────
|
|
145
|
+
/**
|
|
146
|
+
* Find string-literal bodies in JS/TS source. Handles '…', "…", `…${…}…` (static chunks
|
|
147
|
+
* only), // and /* comments. ponytail: no regex-literal or JSX-text awareness — a stray
|
|
148
|
+
* quote (/'/ or <p>Don't</p>) can mis-pair until the end of that line.
|
|
149
|
+
*/
|
|
150
|
+
export function scanStrings(src) {
|
|
151
|
+
const out = [];
|
|
152
|
+
const stack = []; // brace depth at each open `${`
|
|
153
|
+
let depth = 0;
|
|
154
|
+
let i = 0;
|
|
155
|
+
const n = src.length;
|
|
156
|
+
const readTemplate = (j, tplStart, openLeft) => {
|
|
157
|
+
const start = j;
|
|
158
|
+
while (j < n && src[j] !== '`' && !(src[j] === '$' && src[j + 1] === '{')) j += src[j] === '\\' ? 2 : 1;
|
|
159
|
+
const openRight = src[j] === '$';
|
|
160
|
+
out.push({ start, end: j, quoteAt: tplStart, openLeft, openRight });
|
|
161
|
+
if (openRight) { stack.push({ depth, tplStart }); return j + 2; }
|
|
162
|
+
return j + 1;
|
|
163
|
+
};
|
|
164
|
+
while (i < n) {
|
|
165
|
+
const c = src[i];
|
|
166
|
+
if (c === '/' && src[i + 1] === '/') { i = src.indexOf('\n', i); if (i < 0) break; continue; }
|
|
167
|
+
if (c === '/' && src[i + 1] === '*') { i = src.indexOf('*/', i + 2); if (i < 0) break; i += 2; continue; }
|
|
168
|
+
if (c === "'" || c === '"') {
|
|
169
|
+
let j = i + 1;
|
|
170
|
+
while (j < n && src[j] !== c && src[j] !== '\n') j += src[j] === '\\' ? 2 : 1;
|
|
171
|
+
out.push({ start: i + 1, end: Math.min(j, n), quoteAt: i, openLeft: false, openRight: false });
|
|
172
|
+
i = j + 1;
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (c === '`') { i = readTemplate(i + 1, i, false); continue; }
|
|
176
|
+
if (c === '{') depth++;
|
|
177
|
+
else if (c === '}') {
|
|
178
|
+
if (stack.length && stack.at(-1).depth === depth) { i = readTemplate(i + 1, stack.pop().tplStart, true); continue; }
|
|
179
|
+
depth--;
|
|
180
|
+
}
|
|
181
|
+
i++;
|
|
182
|
+
}
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Explicit class contexts: className= / class= attributes, cn/clsx/cva/cx/twMerge/twJoin(…) args, tw`…`.
|
|
187
|
+
const CLASS_CONTEXT = /(?:\bclass(?:Name)?\s*=\s*\{?\s*|\b(?:cn|clsx|cva|cx|twMerge|twJoin)\s*\((?:[^()]*?,)?\s*|\btw)$/;
|
|
188
|
+
// Otherwise a plain string counts only when every token looks like a Tailwind class:
|
|
189
|
+
// starts lowercase/digit/[/@/!/-, and has no quotes, braces or '<'. "Align text-left" is not one.
|
|
190
|
+
const TW_TOKEN = /^[!-]?[a-z0-9@[][^\s"'`<{}]*$/;
|
|
191
|
+
const looksLikeClassList = (text) => {
|
|
192
|
+
const t = text.trim();
|
|
193
|
+
return t !== '' && t.split(/\s+/).every((tok) => TW_TOKEN.test(tok));
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
export function classSegments(src, ext) {
|
|
197
|
+
if (ext === '.css' || ext === '.scss') {
|
|
198
|
+
return [...src.matchAll(/@apply\s+([^;}]+)/dg)].map((m) => ({ start: m.indices[1][0], end: m.indices[1][1] }));
|
|
199
|
+
}
|
|
200
|
+
if (ext === '.html' || ext === '.mdx') {
|
|
201
|
+
return [...src.matchAll(/\bclass(?:Name)?\s*=\s*(["'])([^"']*)\1/dg)].map((m) => ({ start: m.indices[2][0], end: m.indices[2][1] }));
|
|
202
|
+
}
|
|
203
|
+
return scanStrings(src).filter((s) => {
|
|
204
|
+
if (CLASS_CONTEXT.test(src.slice(Math.max(0, s.quoteAt - 120), s.quoteAt))) return true;
|
|
205
|
+
return looksLikeClassList(src.slice(s.start, s.end));
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ─── class-token rules ───────────────────────────────────────────────────────
|
|
210
|
+
const SIDE = { l: 's', r: 'e' };
|
|
211
|
+
const CORNER = { l: 's', r: 'e', tl: 'ss', tr: 'se', bl: 'es', br: 'ee' };
|
|
212
|
+
const LOGICAL = [
|
|
213
|
+
// value-constrained so prose like "pr-review" never matches
|
|
214
|
+
[/^([mp])([lr])-(\d+(?:\.\d+)?|px|auto|\[[^\]]+\])$/, (_, a, s, v) => `${a}${SIDE[s]}-${v}`],
|
|
215
|
+
[/^border-([lr])(-.+)?$/, (_, s, r = '') => `border-${SIDE[s]}${r}`],
|
|
216
|
+
[/^rounded-(tl|tr|bl|br|l|r)(-.+)?$/, (_, c, r = '') => `rounded-${CORNER[c]}${r}`],
|
|
217
|
+
[/^text-(left|right)$/, (_, s) => `text-${s === 'left' ? 'start' : 'end'}`],
|
|
218
|
+
];
|
|
219
|
+
const POSITION = /^(left|right)-/;
|
|
220
|
+
const TW_ANIMATE = /^(animate-(in|out)|(fade|zoom|spin)-(in|out)(-.+)?|slide-(in-from|out-to)-.+)$/;
|
|
221
|
+
const OPACITY = /^([\w-]+)-\[var\((--[\w-]+)\)\]\/(\d+)$/;
|
|
222
|
+
|
|
223
|
+
/** Split `md:hover:!-ml-2` into { prefix: 'md:hover:', imp: '!', neg: '-', base: 'ml-2' }. */
|
|
224
|
+
function splitToken(tok) {
|
|
225
|
+
let depth = 0;
|
|
226
|
+
let cut = 0;
|
|
227
|
+
for (let i = 0; i < tok.length; i++) {
|
|
228
|
+
if (tok[i] === '[') depth++;
|
|
229
|
+
else if (tok[i] === ']') depth--;
|
|
230
|
+
else if (tok[i] === ':' && depth === 0) cut = i + 1;
|
|
231
|
+
}
|
|
232
|
+
let rest = tok.slice(cut);
|
|
233
|
+
const imp = rest.startsWith('!') ? '!' : '';
|
|
234
|
+
rest = rest.slice(imp.length);
|
|
235
|
+
const neg = rest.startsWith('-') ? '-' : '';
|
|
236
|
+
return { prefix: tok.slice(0, cut), imp, neg, base: rest.slice(neg.length) };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function rewriteClassText(text, seg, offset, rules, fix, report) {
|
|
240
|
+
return text.replace(/\S+/g, (tok, off) => {
|
|
241
|
+
if ((seg.openLeft && off === 0) || (seg.openRight && off + tok.length === text.length)) return tok; // glued to ${…}
|
|
242
|
+
const at = offset + off;
|
|
243
|
+
const { prefix, imp, neg, base } = splitToken(tok);
|
|
244
|
+
let next = base;
|
|
245
|
+
if (rules.has('logical-classes')) {
|
|
246
|
+
for (const [re, fn] of LOGICAL) if (re.test(next)) { next = next.replace(re, fn); break; }
|
|
247
|
+
if (POSITION.test(base)) report(at, 'logical-classes', `"${tok}" is a physical position; review for start-/end- (not auto-fixed)`);
|
|
248
|
+
}
|
|
249
|
+
if (rules.has('tw-animate') && TW_ANIMATE.test(base)) {
|
|
250
|
+
report(at, 'tw-animate', `"${tok}" is a tailwindcss-animate class; use nx-motion / nx-pop / nx-dialog from @nexub/themes/base`);
|
|
251
|
+
}
|
|
252
|
+
if (rules.has('opacity-modifier')) {
|
|
253
|
+
const m = OPACITY.exec(next);
|
|
254
|
+
if (m) {
|
|
255
|
+
const mixed = `${m[1]}-[color-mix(in_srgb,var(${m[2]})_${m[3]}%,transparent)]`;
|
|
256
|
+
if (fix) next = mixed;
|
|
257
|
+
else report(at, 'opacity-modifier', `"${tok}": Tailwind 3 emits nothing for /${m[3]} on a var(); use ${prefix}${imp}${neg}${mixed} (or --fix)`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return next === base ? tok : prefix + imp + neg + next;
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ─── per-file migration ──────────────────────────────────────────────────────
|
|
265
|
+
/**
|
|
266
|
+
* Migrate one file's text. Returns { output, manual: [{ line, rule, message }] }.
|
|
267
|
+
* opts: { ext, rules: Set, fix: boolean, aliasMap: { aliases, mix } }
|
|
268
|
+
*/
|
|
269
|
+
export function migrateSource(src, { ext, rules = new Set(RULES), fix = false, aliasMap = SNAPSHOT }) {
|
|
270
|
+
const items = [];
|
|
271
|
+
let text = src;
|
|
272
|
+
if (['logical-classes', 'tw-animate', 'opacity-modifier'].some((r) => rules.has(r))) {
|
|
273
|
+
let out = '';
|
|
274
|
+
let last = 0;
|
|
275
|
+
for (const seg of classSegments(src, ext)) {
|
|
276
|
+
out += src.slice(last, seg.start) + rewriteClassText(src.slice(seg.start, seg.end), seg, seg.start, rules, fix,
|
|
277
|
+
(at, rule, message) => items.push({ at, rule, message }));
|
|
278
|
+
last = seg.end;
|
|
279
|
+
}
|
|
280
|
+
text = out + src.slice(last);
|
|
281
|
+
}
|
|
282
|
+
if (rules.has('legacy-tokens')) {
|
|
283
|
+
// Skip definitions (`--color-x:` / `'--color-x':`) — those override the alias on purpose.
|
|
284
|
+
text = text.replace(/(?<![\w-])--color-[a-z0-9-]+(?![\w-])(?!['"]?\s*:)/g, (name, at) => {
|
|
285
|
+
if (aliasMap.aliases[name]) return aliasMap.aliases[name];
|
|
286
|
+
if (aliasMap.mix[name]) items.push({ at, rule: 'legacy-tokens', message: `${name} is a color-mix alias (${aliasMap.mix[name]}); rewrite by hand` });
|
|
287
|
+
return name;
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
// Rewrites never add or remove newlines, so offsets in src and text share line numbers.
|
|
291
|
+
const lineOf = (at) => src.slice(0, at).split('\n').length;
|
|
292
|
+
const manual = items.map(({ at, rule, message }) => ({ line: lineOf(at), rule, message })).sort((a, b) => a.line - b.line);
|
|
293
|
+
return { output: text, manual };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ─── file walking ────────────────────────────────────────────────────────────
|
|
297
|
+
export function globToRegExp(glob) {
|
|
298
|
+
const re = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*\*\/?|\*|\?/g, (m) => ({ '*': '[^/]*', '?': '[^/]', '**': '.*' })[m] ?? '(?:.*/)?');
|
|
299
|
+
return new RegExp(`^${re}$`);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export function collectFiles(paths, { cwd = process.cwd(), ignore = [] } = {}) {
|
|
303
|
+
const res = ignore.map(globToRegExp);
|
|
304
|
+
const ignored = (rel) => res.some((re, i) => re.test(rel) || (!ignore[i].includes('/') && re.test(path.posix.basename(rel))));
|
|
305
|
+
const files = [];
|
|
306
|
+
const walk = (abs) => {
|
|
307
|
+
const rel = path.relative(cwd, abs).split(path.sep).join('/');
|
|
308
|
+
if (rel && ignored(rel)) return;
|
|
309
|
+
let st;
|
|
310
|
+
try { st = statSync(abs); } catch { return; }
|
|
311
|
+
if (st.isDirectory()) {
|
|
312
|
+
if (SKIP_DIRS.has(path.basename(abs)) && rel !== '') return;
|
|
313
|
+
for (const e of readdirSync(abs).sort()) walk(path.join(abs, e));
|
|
314
|
+
} else if (st.isFile() && EXTS.has(path.extname(abs)) && st.size <= MAX_BYTES) {
|
|
315
|
+
files.push(abs);
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
for (const p of paths) walk(path.resolve(cwd, p));
|
|
319
|
+
return files;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ─── CLI ─────────────────────────────────────────────────────────────────────
|
|
323
|
+
export const USAGE = `Usage: nexub-migrate [paths…] [options]
|
|
324
|
+
|
|
325
|
+
Move app code onto current Nexub DS conventions. Paths default to ./src.
|
|
326
|
+
Dry run by default: prints what would change and what needs a human.
|
|
327
|
+
|
|
328
|
+
Options:
|
|
329
|
+
--write apply fixes in place
|
|
330
|
+
--check exit 1 if anything fixable or reportable is found (CI)
|
|
331
|
+
--json print machine-readable results
|
|
332
|
+
--fix also rewrite opacity-modifier hits (preview, or apply with --write)
|
|
333
|
+
--rules <a,b> run only these rules (default: all)
|
|
334
|
+
--ignore <glob> skip paths matching glob (repeatable; supports * and **)
|
|
335
|
+
-h, --help show this help
|
|
336
|
+
|
|
337
|
+
Rules:
|
|
338
|
+
legacy-tokens var(--color-text-primary) -> var(--color-on-surface), brand -500 -> role;
|
|
339
|
+
color-mix aliases (e.g. --color-semantic-success-border) reported
|
|
340
|
+
logical-classes ml/mr, pl/pr, border-l/r, rounded-l/r/tl/tr/bl/br, text-left/right
|
|
341
|
+
-> ms/me, ps/pe, border-s/e, rounded-s/e/ss/se/es/ee, text-start/end;
|
|
342
|
+
left-/right- positions reported
|
|
343
|
+
tw-animate report tailwindcss-animate enter/exit classes (use nx-motion/nx-pop/nx-dialog)
|
|
344
|
+
opacity-modifier report [var(--x)]/NN (emits nothing in Tailwind 3); --fix rewrites to color-mix
|
|
345
|
+
|
|
346
|
+
Ignored: node_modules, dist, build, .next, out, coverage, files over 1 MB.
|
|
347
|
+
Exit codes: 0 ok, 1 --check found something, 2 usage error.`;
|
|
348
|
+
|
|
349
|
+
export function run(argv, { cwd = process.cwd(), stdout = (s) => process.stdout.write(s), stderr = (s) => process.stderr.write(s) } = {}) {
|
|
350
|
+
let args;
|
|
351
|
+
try {
|
|
352
|
+
args = parseArgs({
|
|
353
|
+
args: argv,
|
|
354
|
+
allowPositionals: true,
|
|
355
|
+
options: {
|
|
356
|
+
write: { type: 'boolean' }, check: { type: 'boolean' }, json: { type: 'boolean' }, fix: { type: 'boolean' },
|
|
357
|
+
rules: { type: 'string' }, ignore: { type: 'string', multiple: true }, help: { type: 'boolean', short: 'h' },
|
|
358
|
+
},
|
|
359
|
+
});
|
|
360
|
+
} catch (e) {
|
|
361
|
+
stderr(`${e.message}\n\n${USAGE}\n`);
|
|
362
|
+
return 2;
|
|
363
|
+
}
|
|
364
|
+
const { values: o, positionals } = args;
|
|
365
|
+
if (o.help) { stdout(`${USAGE}\n`); return 0; }
|
|
366
|
+
const rules = new Set(o.rules ? o.rules.split(',').map((r) => r.trim()).filter(Boolean) : RULES);
|
|
367
|
+
const unknown = [...rules].filter((r) => !RULES.includes(r));
|
|
368
|
+
if (unknown.length) { stderr(`Unknown rule(s): ${unknown.join(', ')}. Known: ${RULES.join(', ')}\n`); return 2; }
|
|
369
|
+
|
|
370
|
+
const aliasMap = rules.has('legacy-tokens') ? loadAliasMap(cwd) : SNAPSHOT;
|
|
371
|
+
const results = [];
|
|
372
|
+
for (const abs of collectFiles(positionals.length ? positionals : ['src'], { cwd, ignore: o.ignore ?? [] })) {
|
|
373
|
+
const src = readFileSync(abs, 'utf8');
|
|
374
|
+
const { output, manual } = migrateSource(src, { ext: path.extname(abs), rules, fix: !!o.fix, aliasMap });
|
|
375
|
+
const a = src.split('\n');
|
|
376
|
+
const b = output.split('\n');
|
|
377
|
+
const changes = a.flatMap((line, i) => (line === b[i] ? [] : [{ line: i + 1, before: line, after: b[i] }]));
|
|
378
|
+
if (!changes.length && !manual.length) continue;
|
|
379
|
+
if (o.write && changes.length) writeFileSync(abs, output);
|
|
380
|
+
results.push({ file: path.relative(cwd, abs).split(path.sep).join('/'), changes, manual });
|
|
381
|
+
}
|
|
382
|
+
const totals = {
|
|
383
|
+
files: results.length,
|
|
384
|
+
changes: results.reduce((n, r) => n + r.changes.length, 0),
|
|
385
|
+
manual: results.reduce((n, r) => n + r.manual.length, 0),
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
if (o.json) {
|
|
389
|
+
stdout(`${JSON.stringify({ written: !!o.write, aliasSource: aliasMap.source ?? 'snapshot', results, totals }, null, 2)}\n`);
|
|
390
|
+
} else {
|
|
391
|
+
for (const r of results) {
|
|
392
|
+
stdout(`--- ${r.file}\n+++ ${r.file}\n`);
|
|
393
|
+
for (const c of r.changes) stdout(`@@ ${c.line} @@\n-${c.before}\n+${c.after}\n`);
|
|
394
|
+
for (const m of r.manual) stdout(`! ${r.file}:${m.line} [${m.rule}] ${m.message}\n`);
|
|
395
|
+
}
|
|
396
|
+
const verb = o.write ? 'changed' : 'to change';
|
|
397
|
+
stdout(`\n${totals.changes} line(s) ${verb} in ${results.filter((r) => r.changes.length).length} file(s); ${totals.manual} item(s) need manual review.`);
|
|
398
|
+
stdout(!o.write && totals.changes ? ' Run with --write to apply.\n' : '\n');
|
|
399
|
+
}
|
|
400
|
+
return o.check && (totals.changes || totals.manual) ? 1 : 0;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const isMain = (() => {
|
|
404
|
+
try { return realpathSync(process.argv[1]) === fileURLToPath(import.meta.url); } catch { return false; }
|
|
405
|
+
})();
|
|
406
|
+
if (isMain) process.exitCode = run(process.argv.slice(2));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nexub/tokens",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.0",
|
|
4
4
|
"description": "Design tokens for Nexub Design System — colours, typography, spacing, radii, shadows, and motion as CSS variables and Tailwind preset.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -20,8 +20,13 @@
|
|
|
20
20
|
"publishConfig": {
|
|
21
21
|
"access": "public"
|
|
22
22
|
},
|
|
23
|
+
"bin": {
|
|
24
|
+
"nexub-migrate": "./bin/nexub-migrate.mjs"
|
|
25
|
+
},
|
|
23
26
|
"files": [
|
|
24
|
-
"dist"
|
|
27
|
+
"dist",
|
|
28
|
+
"bin",
|
|
29
|
+
"!bin/*.test.mjs"
|
|
25
30
|
],
|
|
26
31
|
"sideEffects": [
|
|
27
32
|
"./dist/css/*.css"
|
|
@@ -62,6 +67,6 @@
|
|
|
62
67
|
"clean": "rm -rf dist",
|
|
63
68
|
"typecheck": "tsc --noEmit",
|
|
64
69
|
"lint": "eslint src",
|
|
65
|
-
"test": "node --test scripts/*.test.mjs"
|
|
70
|
+
"test": "node --test scripts/*.test.mjs bin/*.test.mjs"
|
|
66
71
|
}
|
|
67
72
|
}
|