@assure-one/design-system 1.30.0 → 1.32.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 +28 -1
- package/codemods/0.2.0-radix-migration.mjs +315 -0
- package/codemods/README.md +305 -0
- package/codemods/lib/css-selectors.mjs +33 -0
- package/codemods/lib/css-values.mjs +223 -0
- package/codemods/lib/ds-stylesheet.mjs +168 -0
- package/codemods/lib/environment.mjs +72 -0
- package/codemods/lib/files.mjs +100 -0
- package/codemods/lib/jsx.mjs +0 -0
- package/codemods/lib/ledger.mjs +84 -0
- package/codemods/lib/registry.mjs +30 -0
- package/codemods/lib/report.mjs +119 -0
- package/codemods/lib/runner.mjs +164 -0
- package/codemods/run.mjs +161 -0
- package/codemods/transforms/cm-15-dom-selectors.mjs +573 -0
- package/codemods/transforms/cm-16-globals-css.mjs +487 -0
- package/dist/css/base.css +60 -0
- package/dist/css/legacy-aliases.css +489 -0
- package/dist/css/shadcn.css +155 -0
- package/dist/css/tailwind.css +233 -0
- package/dist/css/tokens.css +439 -0
- package/dist/index.d.ts +327 -33
- package/dist/index.js +1843 -706
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/dist/testing/index.cjs +458 -0
- package/dist/testing/index.d.cts +253 -0
- package/dist/testing/index.d.ts +253 -0
- package/dist/testing/index.js +452 -0
- package/dist/testing/setup.cjs +123 -0
- package/dist/testing/setup.js +121 -0
- package/dist/testing/style-stub.cjs +7 -0
- package/dist/testing/style-stub.js +5 -0
- package/dist/tokens/index.d.ts +114 -96
- package/dist/tokens/index.js +67 -48
- package/dist/tokens/index.js.map +1 -1
- package/package.json +84 -14
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rule selectors of a CSS file with their line numbers, without a CSS
|
|
3
|
+
* parser dependency. Comments and strings are blanked (keeping offsets), then
|
|
4
|
+
* every `prelude {` whose prelude is not an at-rule is a selector. Good
|
|
5
|
+
* enough for finding selectors; not a validator.
|
|
6
|
+
*/
|
|
7
|
+
export function cssSelectors(text) {
|
|
8
|
+
const blank = (s) => s.replace(/[^\n]/g, " ");
|
|
9
|
+
const clean = text
|
|
10
|
+
.replace(/\/\*[\s\S]*?\*\//g, blank)
|
|
11
|
+
.replace(
|
|
12
|
+
/"(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*'/g,
|
|
13
|
+
(m) => m[0] + blank(m.slice(1, -1)) + m[0],
|
|
14
|
+
);
|
|
15
|
+
const out = [];
|
|
16
|
+
let start = 0;
|
|
17
|
+
for (let i = 0; i < clean.length; i += 1) {
|
|
18
|
+
const ch = clean[i];
|
|
19
|
+
if (ch === "{" || ch === "}" || ch === ";") {
|
|
20
|
+
if (ch === "{") {
|
|
21
|
+
const prelude = clean.slice(start, i);
|
|
22
|
+
const offset = start + (prelude.length - prelude.trimStart().length);
|
|
23
|
+
const selector = text.slice(offset, i).trim();
|
|
24
|
+
if (selector && !selector.startsWith("@") && !/^[\d.%,\s]+$|^(?:from|to)$/.test(selector)) {
|
|
25
|
+
const line = text.slice(0, offset).split("\n").length;
|
|
26
|
+
out.push({ selector: selector.replace(/\s+/g, " "), line });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
start = i + 1;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom-property values: normalisation for comparison, and `var()`
|
|
3
|
+
* resolution. Dependency-free and pure — no file system, no CSS parser — so
|
|
4
|
+
* it can ship inside the package and run from a consumer project.
|
|
5
|
+
*
|
|
6
|
+
* It is the single implementation shared by
|
|
7
|
+
* `scripts/derive-consumer-preset` (W1-18) and CM-16: the two tools must
|
|
8
|
+
* agree on when two declarations of the same token "differ", or their counts
|
|
9
|
+
* cannot be compared. `scripts/derive-consumer-preset/lib/normalize.mjs` and
|
|
10
|
+
* `…/lib/cascade.mjs` re-export from here.
|
|
11
|
+
*
|
|
12
|
+
* Two values are "the same" when their normalised text is equal. The
|
|
13
|
+
* normalisation is conservative: it only removes differences that cannot
|
|
14
|
+
* change rendering (case of hex colours and keywords, short hex, whitespace,
|
|
15
|
+
* quotes around font names, `calc()` over px/rem only, trailing zeros). It
|
|
16
|
+
* does not convert between colour spaces: `#6c42f8` and an `oklab(...)`
|
|
17
|
+
* spelling of the same colour stay different, and the report shows both.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const ROOT_FONT_SIZE_PX = 16;
|
|
21
|
+
|
|
22
|
+
export function normalizeValue(value) {
|
|
23
|
+
if (value == null) return value;
|
|
24
|
+
let v = String(value).trim().replace(/\s+/g, " ");
|
|
25
|
+
v = v
|
|
26
|
+
.replace(/\s*,\s*/g, ",")
|
|
27
|
+
.replace(/\(\s+/g, "(")
|
|
28
|
+
.replace(/\s+\)/g, ")");
|
|
29
|
+
// Minifiers drop optional whitespace: `…) infinite` -> `…)infinite`, `16 / 9` -> `16/9`.
|
|
30
|
+
v = v.replace(/\)\s*(?=[\w#.-])/g, ") ").replace(/\s*\/\s*/g, "/");
|
|
31
|
+
v = v.replace(/#([0-9a-f]{3,8})\b/gi, (_, hex) => `#${expandHex(hex.toLowerCase())}`);
|
|
32
|
+
v = v.replace(/["']([^"',]+)["']/g, "$1");
|
|
33
|
+
v = evaluateCalcs(v);
|
|
34
|
+
v = v.replace(/(\d*\.\d*?)0+(?=[a-z%]|\b)/gi, (m, num) =>
|
|
35
|
+
num.endsWith(".") ? num.slice(0, -1) : num,
|
|
36
|
+
);
|
|
37
|
+
v = v.replace(/(^|[\s(,])0\.(\d)/g, "$1.$2");
|
|
38
|
+
if (/^[A-Za-z-]+$/.test(v)) v = v.toLowerCase();
|
|
39
|
+
if (/^-?\d*\.?\d+rem$/.test(v)) v = `${lengthToPx(v)}px`;
|
|
40
|
+
return v;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function expandHex(hex) {
|
|
44
|
+
if (hex.length === 3 || hex.length === 4) return [...hex].map((c) => c + c).join("");
|
|
45
|
+
return hex;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Evaluate innermost calc() expressions made only of px/rem/unitless terms. */
|
|
49
|
+
export function evaluateCalcs(value) {
|
|
50
|
+
let prev;
|
|
51
|
+
let v = value;
|
|
52
|
+
do {
|
|
53
|
+
prev = v;
|
|
54
|
+
v = v.replace(/calc\(([^()]*)\)/g, (whole, expr) => {
|
|
55
|
+
const result = evaluateLength(expr);
|
|
56
|
+
return result === null ? whole : result;
|
|
57
|
+
});
|
|
58
|
+
} while (v !== prev);
|
|
59
|
+
return v;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Evaluate `a op b op c` where terms are px, rem or unitless numbers.
|
|
64
|
+
* Returns a px string ("8px") or a unitless number string, or null.
|
|
65
|
+
*/
|
|
66
|
+
export function evaluateLength(expr) {
|
|
67
|
+
const tokens = expr.match(/-?\d*\.?\d+(?:px|rem)?|[+\-*/]/g);
|
|
68
|
+
if (!tokens || tokens.join("").replace(/\s/g, "") !== expr.replace(/\s/g, "")) return null;
|
|
69
|
+
// Terms: { n: number, unit: "px" | "" }
|
|
70
|
+
const terms = [];
|
|
71
|
+
const ops = [];
|
|
72
|
+
let expectTerm = true;
|
|
73
|
+
for (const t of tokens) {
|
|
74
|
+
if (expectTerm) {
|
|
75
|
+
const m = /^(-?\d*\.?\d+)(px|rem)?$/.exec(t);
|
|
76
|
+
if (!m) return null;
|
|
77
|
+
const n = Number(m[1]) * (m[2] === "rem" ? ROOT_FONT_SIZE_PX : 1);
|
|
78
|
+
terms.push({ n, unit: m[2] ? "px" : "" });
|
|
79
|
+
expectTerm = false;
|
|
80
|
+
} else {
|
|
81
|
+
if (!/^[+\-*/]$/.test(t)) return null;
|
|
82
|
+
ops.push(t);
|
|
83
|
+
expectTerm = true;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (expectTerm) return null;
|
|
87
|
+
// * and / first
|
|
88
|
+
for (let i = 0; i < ops.length; ) {
|
|
89
|
+
if (ops[i] === "*" || ops[i] === "/") {
|
|
90
|
+
const a = terms[i];
|
|
91
|
+
const b = terms[i + 1];
|
|
92
|
+
let r;
|
|
93
|
+
if (ops[i] === "*") {
|
|
94
|
+
if (a.unit && b.unit) return null;
|
|
95
|
+
r = { n: a.n * b.n, unit: a.unit || b.unit };
|
|
96
|
+
} else {
|
|
97
|
+
if (b.unit || b.n === 0) return null;
|
|
98
|
+
r = { n: a.n / b.n, unit: a.unit };
|
|
99
|
+
}
|
|
100
|
+
terms.splice(i, 2, r);
|
|
101
|
+
ops.splice(i, 1);
|
|
102
|
+
} else i++;
|
|
103
|
+
}
|
|
104
|
+
let acc = terms[0];
|
|
105
|
+
for (let i = 0; i < ops.length; i++) {
|
|
106
|
+
const b = terms[i + 1];
|
|
107
|
+
if (acc.unit !== b.unit) return null;
|
|
108
|
+
acc = { n: ops[i] === "+" ? acc.n + b.n : acc.n - b.n, unit: acc.unit };
|
|
109
|
+
}
|
|
110
|
+
const n = Math.round(acc.n * 10000) / 10000;
|
|
111
|
+
return `${n}${acc.unit}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Normalise a length-ish value to px when it is a plain rem/px value. */
|
|
115
|
+
export function lengthToPx(value) {
|
|
116
|
+
const m = /^(-?\d*\.?\d+)(px|rem)$/.exec(String(value).trim());
|
|
117
|
+
if (!m) return null;
|
|
118
|
+
return Number(m[1]) * (m[2] === "rem" ? ROOT_FONT_SIZE_PX : 1);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function sameValue(a, b) {
|
|
122
|
+
if (a === undefined || b === undefined) return a === b;
|
|
123
|
+
const na = normalizeValue(a);
|
|
124
|
+
const nb = normalizeValue(b);
|
|
125
|
+
if (na === nb) return true;
|
|
126
|
+
const pa = lengthToPx(na);
|
|
127
|
+
const pb = lengthToPx(nb);
|
|
128
|
+
return pa !== null && pa === pb;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Names referenced through `var(--name…)` in a value, in order. */
|
|
132
|
+
export function referencedVars(value) {
|
|
133
|
+
return [...String(value).matchAll(/var\(\s*(--[\w-]+)/g)].map((m) => m[1]);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const INVALID = Symbol("invalid");
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Compute custom-property values for one element.
|
|
140
|
+
* @param {Map<string, {value: string}>} winners own declarations
|
|
141
|
+
* @param {Map<string, string>} inherited parent's (or, for CM-16, the design
|
|
142
|
+
* system sheet's) computed values
|
|
143
|
+
* @returns {Map<string, string>} computed values (invalid ones omitted)
|
|
144
|
+
*/
|
|
145
|
+
export function computeCustomProperties(winners, inherited = new Map()) {
|
|
146
|
+
const computed = new Map();
|
|
147
|
+
const state = new Map(); // prop -> "visiting" | "done"
|
|
148
|
+
const visit = (prop) => {
|
|
149
|
+
if (!winners.has(prop)) return inherited.has(prop) ? inherited.get(prop) : undefined;
|
|
150
|
+
if (state.get(prop) === "done") return computed.has(prop) ? computed.get(prop) : INVALID;
|
|
151
|
+
if (state.get(prop) === "visiting") return INVALID;
|
|
152
|
+
state.set(prop, "visiting");
|
|
153
|
+
const value = substitute(winners.get(prop).value, visit);
|
|
154
|
+
state.set(prop, "done");
|
|
155
|
+
if (value === INVALID) computed.delete(prop);
|
|
156
|
+
else computed.set(prop, value);
|
|
157
|
+
return value;
|
|
158
|
+
};
|
|
159
|
+
for (const prop of winners.keys()) visit(prop);
|
|
160
|
+
const out = new Map(inherited);
|
|
161
|
+
for (const prop of winners.keys()) {
|
|
162
|
+
if (computed.has(prop)) out.set(prop, computed.get(prop));
|
|
163
|
+
else out.delete(prop); // guaranteed-invalid: behaves as unset for custom props
|
|
164
|
+
}
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Replace every var() in `value`; returns INVALID when a cycle is hit. */
|
|
169
|
+
function substitute(value, lookup) {
|
|
170
|
+
let out = "";
|
|
171
|
+
let i = 0;
|
|
172
|
+
while (i < value.length) {
|
|
173
|
+
const start = value.indexOf("var(", i);
|
|
174
|
+
if (start === -1 || (start > 0 && /[\w-]/.test(value[start - 1]))) {
|
|
175
|
+
if (start === -1) {
|
|
176
|
+
out += value.slice(i);
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
out += value.slice(i, start + 4);
|
|
180
|
+
i = start + 4;
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
out += value.slice(i, start);
|
|
184
|
+
const end = matchParen(value, start + 3);
|
|
185
|
+
if (end === -1) {
|
|
186
|
+
out += value.slice(start);
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
const inner = value.slice(start + 4, end);
|
|
190
|
+
const comma = topLevelComma(inner);
|
|
191
|
+
const name = (comma === -1 ? inner : inner.slice(0, comma)).trim();
|
|
192
|
+
const fallback = comma === -1 ? null : inner.slice(comma + 1).trim();
|
|
193
|
+
const resolved = lookup(name);
|
|
194
|
+
if (resolved === INVALID) return INVALID;
|
|
195
|
+
if (resolved !== undefined) out += resolved;
|
|
196
|
+
else if (fallback !== null) {
|
|
197
|
+
const fb = substitute(fallback, lookup);
|
|
198
|
+
if (fb === INVALID) return INVALID;
|
|
199
|
+
out += fb;
|
|
200
|
+
} else out += `var(${name})`; // external: keep it visible
|
|
201
|
+
i = end + 1;
|
|
202
|
+
}
|
|
203
|
+
return out;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function matchParen(s, open) {
|
|
207
|
+
let depth = 0;
|
|
208
|
+
for (let i = open; i < s.length; i++) {
|
|
209
|
+
if (s[i] === "(") depth++;
|
|
210
|
+
else if (s[i] === ")" && --depth === 0) return i;
|
|
211
|
+
}
|
|
212
|
+
return -1;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function topLevelComma(s) {
|
|
216
|
+
let depth = 0;
|
|
217
|
+
for (let i = 0; i < s.length; i++) {
|
|
218
|
+
if (s[i] === "(") depth++;
|
|
219
|
+
else if (s[i] === ")") depth--;
|
|
220
|
+
else if (s[i] === "," && depth === 0) return i;
|
|
221
|
+
}
|
|
222
|
+
return -1;
|
|
223
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the design system's own stylesheet declares — the reference side of
|
|
3
|
+
* every comparison CM-16 makes.
|
|
4
|
+
*
|
|
5
|
+
* Read from the installed package, so a consumer always compares its
|
|
6
|
+
* `globals.css` against the sheet it actually loads:
|
|
7
|
+
*
|
|
8
|
+
* dist/styles.css the shipped sheet (always present in
|
|
9
|
+
* an installed package)
|
|
10
|
+
* src/tokens/generated/theme.css fallback for an unbuilt checkout of
|
|
11
|
+
* this repository, where the contract
|
|
12
|
+
* tests run before `pnpm build`
|
|
13
|
+
*
|
|
14
|
+
* Only the **default root scope** is read: `@theme`, `:root`, `:host` and
|
|
15
|
+
* `html`. The `.dark` block and the `[data-brand="…"]` blocks are deliberately
|
|
16
|
+
* left out — they are unlayered and beat app variables, so a consumer that
|
|
17
|
+
* activates one (TAX sets `data-brand="tax"`) sees different values than this
|
|
18
|
+
* model reports. `derive-consumer-preset` (W1-18) takes the element model as
|
|
19
|
+
* input and does apply them; the difference is recorded in CM-16's report.
|
|
20
|
+
*/
|
|
21
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import { fileURLToPath } from "node:url";
|
|
24
|
+
|
|
25
|
+
import { computeCustomProperties } from "./css-values.mjs";
|
|
26
|
+
|
|
27
|
+
const packageRoot = fileURLToPath(new URL("../..", import.meta.url));
|
|
28
|
+
|
|
29
|
+
export const DS_SHEET_CANDIDATES = ["dist/styles.css", "src/tokens/generated/theme.css"];
|
|
30
|
+
|
|
31
|
+
/** Split a selector list on top-level commas. */
|
|
32
|
+
export function selectorParts(selector) {
|
|
33
|
+
const parts = [];
|
|
34
|
+
let depth = 0;
|
|
35
|
+
let start = 0;
|
|
36
|
+
for (let i = 0; i < selector.length; i += 1) {
|
|
37
|
+
const ch = selector[i];
|
|
38
|
+
if (ch === "(" || ch === "[") depth += 1;
|
|
39
|
+
else if (ch === ")" || ch === "]") depth -= 1;
|
|
40
|
+
else if (ch === "," && depth === 0) {
|
|
41
|
+
parts.push(selector.slice(start, i).trim());
|
|
42
|
+
start = i + 1;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
parts.push(selector.slice(start).trim());
|
|
46
|
+
return parts.filter(Boolean);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const ROOT_PART = /^(?::root|:host|html)$/;
|
|
50
|
+
|
|
51
|
+
/** Whether a selector list declares on the default document root. */
|
|
52
|
+
export const isRootSelector = (selector) =>
|
|
53
|
+
selectorParts(selector).some((p) => ROOT_PART.test(p.replace(/\s+/g, "")));
|
|
54
|
+
|
|
55
|
+
/** The cascade layer a node sits in (dot-joined for nested layers), or null. */
|
|
56
|
+
export function layerOf(node) {
|
|
57
|
+
const path = [];
|
|
58
|
+
for (let p = node; p && p.type !== "root"; p = p.parent) {
|
|
59
|
+
if (p.type === "atrule" && p.name === "layer") path.unshift(p.params.trim());
|
|
60
|
+
}
|
|
61
|
+
return path.join(".") || null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function insideAtRule(node, name) {
|
|
65
|
+
for (let p = node.parent; p && p.type !== "root"; p = p.parent) {
|
|
66
|
+
if (p.type === "atrule" && p.name === name) return true;
|
|
67
|
+
}
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function insideKeyframes(node) {
|
|
72
|
+
for (let p = node.parent; p && p.type !== "root"; p = p.parent) {
|
|
73
|
+
if (p.type === "atrule" && /keyframes$/.test(p.name)) return true;
|
|
74
|
+
}
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Class names a selector list names, without duplicates. */
|
|
79
|
+
export const classNamesOf = (selector) => [
|
|
80
|
+
...new Set([...selector.matchAll(/\.(-?[_a-zA-Z][\w-]*)/g)].map((m) => m[1])),
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Tailwind's preflight, recognised by the reset it puts in `@layer base` on
|
|
85
|
+
* `*, ::before, ::after, ::backdrop`. Stable across Tailwind 4 minor versions
|
|
86
|
+
* and survives minification.
|
|
87
|
+
*/
|
|
88
|
+
function hasPreflight(postcss, root) {
|
|
89
|
+
let found = false;
|
|
90
|
+
root.walkRules((rule) => {
|
|
91
|
+
if (found) return;
|
|
92
|
+
if (!/::backdrop/.test(rule.selector)) return;
|
|
93
|
+
if (!selectorParts(rule.selector).includes("*")) return;
|
|
94
|
+
rule.walkDecls("box-sizing", () => {
|
|
95
|
+
found = true;
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
return found;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let cached = null;
|
|
102
|
+
let overrideRoot = null;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The design system's declared surface.
|
|
106
|
+
* @param {object} postcss the parser the runner borrowed from the project
|
|
107
|
+
* @param {{ root?: string }} [options] package root, for tests
|
|
108
|
+
* @returns {{ from: string, tokens: Map<string,string>, declared: Map<string,string>,
|
|
109
|
+
* classes: Set<string>, preflight: boolean, scopes: string[] }}
|
|
110
|
+
*/
|
|
111
|
+
export function dsStylesheet(postcss, { root = overrideRoot ?? packageRoot } = {}) {
|
|
112
|
+
if (cached && cached.root === root) return cached.value;
|
|
113
|
+
const rel = DS_SHEET_CANDIDATES.find((c) => existsSync(join(root, c)));
|
|
114
|
+
if (!rel) {
|
|
115
|
+
throw new Error(
|
|
116
|
+
`CM-16 compares the application's stylesheet against the design system's own, but none of ` +
|
|
117
|
+
`${DS_SHEET_CANDIDATES.join(", ")} exists under ${root}. Run \`pnpm build\` (in this ` +
|
|
118
|
+
`repository) or reinstall the package.`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
const css = readFileSync(join(root, rel), "utf8");
|
|
122
|
+
const parsed = postcss.parse(css, { from: rel });
|
|
123
|
+
const declared = new Map(); // name -> winning declaration text (source order)
|
|
124
|
+
const classes = new Set();
|
|
125
|
+
const scopes = new Set();
|
|
126
|
+
parsed.walkDecls((decl) => {
|
|
127
|
+
if (!decl.prop.startsWith("--") || decl.prop.startsWith("--tw-")) return;
|
|
128
|
+
if (insideKeyframes(decl)) return;
|
|
129
|
+
const parent = decl.parent;
|
|
130
|
+
const inTheme = parent?.type === "atrule" && parent.name === "theme";
|
|
131
|
+
if (inTheme || insideAtRule(decl, "theme")) {
|
|
132
|
+
declared.set(decl.prop, decl.value.trim());
|
|
133
|
+
scopes.add("@theme");
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (parent?.type !== "rule") return;
|
|
137
|
+
if (isRootSelector(parent.selector)) {
|
|
138
|
+
declared.set(decl.prop, decl.value.trim());
|
|
139
|
+
scopes.add(selectorParts(parent.selector).join(", "));
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
parsed.walkRules((rule) => {
|
|
143
|
+
for (const name of classNamesOf(rule.selector)) classes.add(name);
|
|
144
|
+
});
|
|
145
|
+
const winners = new Map([...declared].map(([name, value]) => [name, { value }]));
|
|
146
|
+
const value = {
|
|
147
|
+
from: rel,
|
|
148
|
+
tokens: computeCustomProperties(winners),
|
|
149
|
+
declared,
|
|
150
|
+
classes,
|
|
151
|
+
preflight: hasPreflight(postcss, parsed),
|
|
152
|
+
scopes: [...scopes].sort(),
|
|
153
|
+
};
|
|
154
|
+
cached = { root, value };
|
|
155
|
+
return value;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Test hook: read the reference sheet from `root` instead of the installed
|
|
160
|
+
* package, and forget what was cached. The fixtures use it to compare against
|
|
161
|
+
* a small stylesheet of invented names, so that they neither depend on
|
|
162
|
+
* `pnpm build` having run nor on the value of a real token
|
|
163
|
+
* (`tests/codemods/cm-16.test.mjs`). `null` restores the installed package.
|
|
164
|
+
*/
|
|
165
|
+
export const setDsStylesheetRoot = (root) => {
|
|
166
|
+
overrideRoot = root;
|
|
167
|
+
cached = null;
|
|
168
|
+
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a codemod run needs from its surroundings: the installed design-system
|
|
3
|
+
* version and, for codemods that parse code, the TypeScript compiler.
|
|
4
|
+
*/
|
|
5
|
+
import { readFileSync } from "node:fs";
|
|
6
|
+
import { createRequire } from "node:module";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
9
|
+
|
|
10
|
+
const packageRoot = fileURLToPath(new URL("../..", import.meta.url));
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The version of the design-system package these codemods ship in. The
|
|
14
|
+
* runner is executed from the installed package, so this is the version the
|
|
15
|
+
* consumer has installed.
|
|
16
|
+
*/
|
|
17
|
+
export function installedDsVersion() {
|
|
18
|
+
return JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8")).version;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Compares `major.minor.patch[-pre]` strings; prereleases sort before the release. */
|
|
22
|
+
export function compareVersions(a, b) {
|
|
23
|
+
const parse = (v) => {
|
|
24
|
+
const [core, pre = ""] = String(v).replace(/^v/, "").split("-", 2);
|
|
25
|
+
return { parts: core.split(".").map((n) => Number.parseInt(n, 10) || 0), pre };
|
|
26
|
+
};
|
|
27
|
+
const x = parse(a);
|
|
28
|
+
const y = parse(b);
|
|
29
|
+
for (let i = 0; i < 3; i += 1) {
|
|
30
|
+
const d = (x.parts[i] ?? 0) - (y.parts[i] ?? 0);
|
|
31
|
+
if (d) return Math.sign(d);
|
|
32
|
+
}
|
|
33
|
+
if (x.pre === y.pre) return 0;
|
|
34
|
+
if (!x.pre) return 1;
|
|
35
|
+
if (!y.pre) return -1;
|
|
36
|
+
return x.pre < y.pre ? -1 : 1;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A module resolved from the consumer project first and from this package's
|
|
41
|
+
* own location second. Neither is a dependency of the design system (see
|
|
42
|
+
* codemods/README.md, "Packaging"): every consumer app already has them, and
|
|
43
|
+
* codemods are a development-time tool.
|
|
44
|
+
*/
|
|
45
|
+
async function borrow(root, name, hint) {
|
|
46
|
+
const attempts = [join(root, "package.json"), join(packageRoot, "package.json")];
|
|
47
|
+
for (const from of attempts) {
|
|
48
|
+
try {
|
|
49
|
+
const path = createRequire(from).resolve(name);
|
|
50
|
+
const mod = await import(pathToFileURL(path).href);
|
|
51
|
+
return mod.default ?? mod;
|
|
52
|
+
} catch {
|
|
53
|
+
// try the next location
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
throw new Error(
|
|
57
|
+
`This codemod ${hint}, but \`${name}\` could not be resolved from ${root}. ` +
|
|
58
|
+
`Install it in the project (\`pnpm add -D ${name}\`) and run again.`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The `typescript` module, for codemods that parse code. */
|
|
63
|
+
export const loadTypeScript = (root) =>
|
|
64
|
+
borrow(root, "typescript", "parses code with the TypeScript compiler");
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The `postcss` module, for codemods that parse stylesheets. Every consumer
|
|
68
|
+
* is a Tailwind 4 application and therefore already has it; using the same
|
|
69
|
+
* parser as the consumer scanner (W0-28) and `derive-consumer-preset`
|
|
70
|
+
* (W1-18) is also what keeps the three tools' CSS facts comparable.
|
|
71
|
+
*/
|
|
72
|
+
export const loadPostcss = (root) => borrow(root, "postcss", "parses stylesheets with PostCSS");
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File discovery for codemods. Runs inside consumer projects, so it only uses
|
|
3
|
+
* Node built-ins. The skip lists and the test-file rule match the consumer
|
|
4
|
+
* scanner (scripts/scan-consumers/lib/files.mjs) so both tools count the same
|
|
5
|
+
* files.
|
|
6
|
+
*/
|
|
7
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
8
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
9
|
+
|
|
10
|
+
export const EXCLUDED_DIRS = new Set([
|
|
11
|
+
".git",
|
|
12
|
+
".next",
|
|
13
|
+
".turbo",
|
|
14
|
+
".vercel",
|
|
15
|
+
".swc",
|
|
16
|
+
"build",
|
|
17
|
+
"coverage",
|
|
18
|
+
"dist",
|
|
19
|
+
"node_modules",
|
|
20
|
+
"out",
|
|
21
|
+
"playwright-report",
|
|
22
|
+
"storybook-static",
|
|
23
|
+
"test-results",
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
/** Root-relative directories served as-is (Next.js `public/`), never scanned. */
|
|
27
|
+
export const STATIC_DIRS = ["public"];
|
|
28
|
+
|
|
29
|
+
const CODE_EXT = /\.(?:[cm]?[jt]sx?)$/;
|
|
30
|
+
const CSS_EXT = /\.css$/;
|
|
31
|
+
const TEST_DIRS = new Set(["__tests__", "__mocks__", "__fixtures__", "e2e", "test", "tests"]);
|
|
32
|
+
const TEST_FILE =
|
|
33
|
+
/(?:\.(?:test|spec|e2e)\.[cm]?[jt]sx?$)|(?:^|\/)(?:jest|vitest|playwright)[.-][^/]*$/;
|
|
34
|
+
|
|
35
|
+
export const toPosix = (path) => path.split(sep).join("/");
|
|
36
|
+
|
|
37
|
+
/** `{ kind: "code" | "css" | "other", test }` for a root-relative posix path. */
|
|
38
|
+
export function classifyFile(rel) {
|
|
39
|
+
const kind = rel.endsWith(".d.ts")
|
|
40
|
+
? "other"
|
|
41
|
+
: CODE_EXT.test(rel)
|
|
42
|
+
? "code"
|
|
43
|
+
: CSS_EXT.test(rel)
|
|
44
|
+
? "css"
|
|
45
|
+
: "other";
|
|
46
|
+
const segments = rel.split("/");
|
|
47
|
+
const test =
|
|
48
|
+
segments.slice(0, -1).some((s) => TEST_DIRS.has(s)) ||
|
|
49
|
+
TEST_FILE.test(rel) ||
|
|
50
|
+
/\.stories\.[cm]?[jt]sx?$/.test(rel);
|
|
51
|
+
return { kind, test };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The nearest directory at or above `start` that holds a package.json. */
|
|
55
|
+
export function findProjectRoot(start) {
|
|
56
|
+
let dir = resolve(start);
|
|
57
|
+
for (;;) {
|
|
58
|
+
if (existsSync(join(dir, "package.json"))) return dir;
|
|
59
|
+
const parent = dirname(dir);
|
|
60
|
+
if (parent === dir) return resolve(start);
|
|
61
|
+
dir = parent;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Code and CSS files under `paths` (files or directories), as sorted
|
|
67
|
+
* root-relative posix paths. Paths outside `root` are refused.
|
|
68
|
+
*/
|
|
69
|
+
export function collectFiles(root, paths) {
|
|
70
|
+
const out = new Set();
|
|
71
|
+
const skipPrefixes = STATIC_DIRS;
|
|
72
|
+
const consider = (abs) => {
|
|
73
|
+
const rel = toPosix(relative(root, abs));
|
|
74
|
+
if (rel.startsWith("..")) throw new Error(`${abs} is outside the project root ${root}`);
|
|
75
|
+
return rel;
|
|
76
|
+
};
|
|
77
|
+
const visit = (abs) => {
|
|
78
|
+
const rel = consider(abs);
|
|
79
|
+
if (rel && skipPrefixes.some((p) => rel === p || rel.startsWith(`${p}/`))) return;
|
|
80
|
+
for (const entry of readdirSync(abs, { withFileTypes: true })) {
|
|
81
|
+
const child = join(abs, entry.name);
|
|
82
|
+
if (entry.isDirectory()) {
|
|
83
|
+
if (!EXCLUDED_DIRS.has(entry.name)) visit(child);
|
|
84
|
+
} else if (entry.isFile()) {
|
|
85
|
+
const childRel = consider(child);
|
|
86
|
+
if (classifyFile(childRel).kind !== "other") out.add(childRel);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
for (const path of paths.length ? paths : [root]) {
|
|
91
|
+
const abs = resolve(path);
|
|
92
|
+
if (!existsSync(abs)) throw new Error(`${path} does not exist`);
|
|
93
|
+
if (statSync(abs).isDirectory()) visit(abs);
|
|
94
|
+
else {
|
|
95
|
+
const rel = consider(abs);
|
|
96
|
+
if (classifyFile(rel).kind !== "other") out.add(rel);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return [...out].sort();
|
|
100
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Consumer-side codemod ledger (`.ds-migrations.json`, plan §29).
|
|
3
|
+
*
|
|
4
|
+
* { "schema": 1, "applied": [{ "id", "appliedAt", "dsVersion", "filesChanged" }] }
|
|
5
|
+
*
|
|
6
|
+
* The ledger lives in the consumer project root and is committed there. It
|
|
7
|
+
* records every transforming codemod that was applied (class A and R), so
|
|
8
|
+
* that one-shot codemods refuse to run twice and `upgrade` can run exactly
|
|
9
|
+
* the codemods a project is missing. Report-only codemods (class X) and dry
|
|
10
|
+
* runs never write it.
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
|
|
15
|
+
export const LEDGER_FILE = ".ds-migrations.json";
|
|
16
|
+
export const LEDGER_SCHEMA = 1;
|
|
17
|
+
|
|
18
|
+
const COMMENT =
|
|
19
|
+
"Codemods from @assure-one/design-system applied to this project. Written by the codemod runner; commit it with the codemod's changes.";
|
|
20
|
+
|
|
21
|
+
export const ledgerPath = (root) => join(root, LEDGER_FILE);
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* `{ schema, applied }`. A missing file is an empty ledger. Entries written
|
|
25
|
+
* as bare ids (`["CM-05"]`) are accepted and read as `{ id }`.
|
|
26
|
+
*/
|
|
27
|
+
export function readLedger(root) {
|
|
28
|
+
const path = ledgerPath(root);
|
|
29
|
+
if (!existsSync(path)) return { schema: LEDGER_SCHEMA, applied: [] };
|
|
30
|
+
let data;
|
|
31
|
+
try {
|
|
32
|
+
data = JSON.parse(readFileSync(path, "utf8"));
|
|
33
|
+
} catch (error) {
|
|
34
|
+
throw new Error(`${path} is not valid JSON: ${error.message}`);
|
|
35
|
+
}
|
|
36
|
+
const list = Array.isArray(data) ? data : data?.applied;
|
|
37
|
+
if (!Array.isArray(list)) throw new Error(`${path} has no "applied" list`);
|
|
38
|
+
const applied = list.map((entry) => {
|
|
39
|
+
const normalized = typeof entry === "string" ? { id: entry } : entry;
|
|
40
|
+
if (!normalized || typeof normalized.id !== "string") {
|
|
41
|
+
throw new Error(`${path} has an entry without an id: ${JSON.stringify(entry)}`);
|
|
42
|
+
}
|
|
43
|
+
return normalized;
|
|
44
|
+
});
|
|
45
|
+
return { schema: data?.schema ?? LEDGER_SCHEMA, applied };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const appliedIds = (ledger) => new Set(ledger.applied.map((e) => e.id));
|
|
49
|
+
|
|
50
|
+
/** Appends one entry and writes the file (stable key order, trailing newline). */
|
|
51
|
+
export function recordApplied(root, { id, appliedAt, dsVersion, filesChanged }) {
|
|
52
|
+
const ledger = readLedger(root);
|
|
53
|
+
const applied = [...ledger.applied, { id, appliedAt, dsVersion, filesChanged }];
|
|
54
|
+
const body = { $comment: COMMENT, schema: LEDGER_SCHEMA, applied };
|
|
55
|
+
writeFileSync(ledgerPath(root), `${JSON.stringify(body, null, 2)}\n`);
|
|
56
|
+
return { schema: LEDGER_SCHEMA, applied };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Why `meta` may not be applied to a project with this ledger and installed
|
|
61
|
+
* DS version, or `null`. Checked before any file is read.
|
|
62
|
+
*/
|
|
63
|
+
export function guardProblems(meta, ledger, dsVersion, compareVersions) {
|
|
64
|
+
const problems = [];
|
|
65
|
+
const ids = appliedIds(ledger);
|
|
66
|
+
if (meta.oneShot && ids.has(meta.id)) {
|
|
67
|
+
const when = ledger.applied.find((e) => e.id === meta.id)?.appliedAt;
|
|
68
|
+
problems.push(
|
|
69
|
+
`${meta.id} is a one-shot codemod and the ledger shows it was already applied${when ? ` (${when})` : ""}.`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
for (const required of meta.requires?.codemods ?? []) {
|
|
73
|
+
if (!ids.has(required)) {
|
|
74
|
+
problems.push(`${meta.id} needs ${required} to be applied first (not in ${LEDGER_FILE}).`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const minimum = meta.requires?.dsVersion;
|
|
78
|
+
if (minimum && compareVersions(dsVersion, minimum) < 0) {
|
|
79
|
+
problems.push(
|
|
80
|
+
`${meta.id} needs @assure-one/design-system ${minimum} or later; ${dsVersion} is installed.`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
return problems;
|
|
84
|
+
}
|