@assure-one/design-system 1.31.0 → 1.33.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 +61 -6
- package/codemods/0.2.0-radix-migration.mjs +315 -0
- package/codemods/README.md +365 -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/forms.mjs +253 -0
- package/codemods/lib/jsx.mjs +0 -0
- package/codemods/lib/ledger.mjs +84 -0
- package/codemods/lib/registry.mjs +32 -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-14-hidden-mirrors.mjs +275 -0
- package/codemods/transforms/cm-15-dom-selectors.mjs +573 -0
- package/codemods/transforms/cm-16-globals-css.mjs +487 -0
- package/codemods/transforms/cm-20-select-sentinels.mjs +442 -0
- package/dist/css/base.css +60 -0
- package/dist/css/components.css +7 -0
- package/dist/css/legacy-aliases.css +665 -0
- package/dist/css/shadcn.css +155 -0
- package/dist/css/tailwind.css +296 -0
- package/dist/css/tokens.css +630 -0
- package/dist/index.d.ts +807 -27
- package/dist/index.js +2038 -683
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/dist/system-BDU18fVg.d.ts +559 -0
- package/dist/testing/index.cjs +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 +50 -439
- package/dist/tokens/index.js +557 -50
- package/dist/tokens/index.js.map +1 -1
- package/package.json +74 -5
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Form-shaped source facts, shared by the two report-only finders of the
|
|
3
|
+
* forms wave (plan §16): CM-14 (hidden-input mirrors) and CM-20 (Select
|
|
4
|
+
* sentinels).
|
|
5
|
+
*
|
|
6
|
+
* `lib/jsx.mjs` answers "which class tokens sit on which design-system
|
|
7
|
+
* component" and therefore only records elements that carry a class
|
|
8
|
+
* attribute. A hidden `<input type="hidden" name="x" value={x} />` carries
|
|
9
|
+
* none, and a sentinel lives in a prop value, not in a class token — so the
|
|
10
|
+
* forms codemods need a different view of the same tree:
|
|
11
|
+
*
|
|
12
|
+
* - **every** JSX element, design-system and raw alike, in document order;
|
|
13
|
+
* - each element's attributes, with the string literals and the identifiers
|
|
14
|
+
* of the expression behind them (the identifiers are what tie a mirror to
|
|
15
|
+
* the control it mirrors);
|
|
16
|
+
* - the nearest `<form>` ancestor, because the applications do not put a
|
|
17
|
+
* mirror next to its control — they collect the mirrors at the top of the
|
|
18
|
+
* form ([CU §18]);
|
|
19
|
+
* - top-level `const NAME = "literal"` declarations, because the sentinels
|
|
20
|
+
* are named constants (`const NONE_VALUE = "__none__"`).
|
|
21
|
+
*
|
|
22
|
+
* As everywhere in `codemods/lib`, the TypeScript module is passed in: a
|
|
23
|
+
* codemod never imports a parser itself (`lib/environment.mjs`).
|
|
24
|
+
*/
|
|
25
|
+
import { DS_PACKAGE, createSourceFile } from "./jsx.mjs";
|
|
26
|
+
|
|
27
|
+
export { DS_PACKAGE };
|
|
28
|
+
|
|
29
|
+
const isDsSpecifier = (s) => s === DS_PACKAGE || s.startsWith(`${DS_PACKAGE}/`);
|
|
30
|
+
|
|
31
|
+
/** Attributes whose value is the control's current value, in binding order. */
|
|
32
|
+
export const VALUE_PROPS = ["value", "defaultValue", "checked", "defaultChecked", "selected"];
|
|
33
|
+
|
|
34
|
+
/** Attributes through which a control writes its value back. */
|
|
35
|
+
export const CHANGE_PROPS = [
|
|
36
|
+
"onValueChange",
|
|
37
|
+
"onCheckedChange",
|
|
38
|
+
"onChange",
|
|
39
|
+
"onSelect",
|
|
40
|
+
"onRemove",
|
|
41
|
+
"onSelectionChange",
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Parses one file into form facts.
|
|
46
|
+
*
|
|
47
|
+
* Returns:
|
|
48
|
+
* - `sf`, `lineOf` — the source file and a node → line helper
|
|
49
|
+
* - `imports` — design-system local name → exported name
|
|
50
|
+
* - `namespaces` — local names of `import * as X from the package`
|
|
51
|
+
* - `elements` — every JSX element, in document order, as described below
|
|
52
|
+
* - `constants` — top-level `const` name → `{ text, line }` for string consts
|
|
53
|
+
* - `componentsUsed` — the design-system base names used as JSX
|
|
54
|
+
* - `parseErrors`
|
|
55
|
+
*
|
|
56
|
+
* An element is
|
|
57
|
+
* `{ index, parent, tag, base, component, isDs, line, props, spread, form }`
|
|
58
|
+
* where `props` is a Map of attribute name → `{ literals, identifiers, text,
|
|
59
|
+
* line, expression }` and `form` is the index of the nearest `<form>`
|
|
60
|
+
* ancestor (or `null`).
|
|
61
|
+
*/
|
|
62
|
+
export function analyseForms(ts, text, rel) {
|
|
63
|
+
const sf = createSourceFile(ts, text, rel);
|
|
64
|
+
const lineOf = (node) => sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1;
|
|
65
|
+
|
|
66
|
+
const imports = new Map();
|
|
67
|
+
const namespaces = new Set();
|
|
68
|
+
for (const stmt of sf.statements) {
|
|
69
|
+
if (!ts.isImportDeclaration(stmt) || !ts.isStringLiteral(stmt.moduleSpecifier)) continue;
|
|
70
|
+
if (!isDsSpecifier(stmt.moduleSpecifier.text)) continue;
|
|
71
|
+
const clause = stmt.importClause;
|
|
72
|
+
if (!clause || clause.isTypeOnly) continue;
|
|
73
|
+
const bindings = clause.namedBindings;
|
|
74
|
+
if (bindings && ts.isNamespaceImport(bindings)) namespaces.add(bindings.name.text);
|
|
75
|
+
else if (bindings && ts.isNamedImports(bindings)) {
|
|
76
|
+
for (const el of bindings.elements) {
|
|
77
|
+
if (!el.isTypeOnly) imports.set(el.name.text, (el.propertyName ?? el.name).text);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Top-level `const NAME = "text"`, the shape the sentinels are declared in. */
|
|
83
|
+
const constants = new Map();
|
|
84
|
+
for (const stmt of sf.statements) {
|
|
85
|
+
const list = ts.isVariableStatement(stmt) ? stmt.declarationList : null;
|
|
86
|
+
if (!list || !(list.flags & ts.NodeFlags.Const)) continue;
|
|
87
|
+
for (const decl of list.declarations) {
|
|
88
|
+
if (!ts.isIdentifier(decl.name) || !decl.initializer) continue;
|
|
89
|
+
const init = decl.initializer;
|
|
90
|
+
if (ts.isStringLiteral(init) || ts.isNoSubstitutionTemplateLiteral(init)) {
|
|
91
|
+
constants.set(decl.name.text, { text: init.text, line: lineOf(init) });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** String literals inside an expression, at any depth below JSX. */
|
|
97
|
+
const literalsIn = (node, out = []) => {
|
|
98
|
+
if (!node) return out;
|
|
99
|
+
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
|
|
100
|
+
out.push(node.text);
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node)) {
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
// A block body: `forEachChild` stops at the first truthy return, and the
|
|
107
|
+
// accumulator is always truthy.
|
|
108
|
+
ts.forEachChild(node, (child) => {
|
|
109
|
+
literalsIn(child, out);
|
|
110
|
+
});
|
|
111
|
+
return out;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Identifier names an expression reads. Member names (`a.b`), object keys
|
|
116
|
+
* and JSX subtrees are excluded: what matters is which bindings the value
|
|
117
|
+
* depends on, so that two attributes reading `entityType` can be recognised
|
|
118
|
+
* as two views of the same state.
|
|
119
|
+
*/
|
|
120
|
+
const identifiersIn = (node, out = new Set()) => {
|
|
121
|
+
if (!node) return out;
|
|
122
|
+
if (ts.isIdentifier(node)) {
|
|
123
|
+
const parent = node.parent;
|
|
124
|
+
const isMemberName = parent && ts.isPropertyAccessExpression(parent) && parent.name === node;
|
|
125
|
+
const isKey =
|
|
126
|
+
parent &&
|
|
127
|
+
(ts.isPropertyAssignment(parent) || ts.isPropertySignature(parent)) &&
|
|
128
|
+
parent.name === node;
|
|
129
|
+
if (!isMemberName && !isKey) out.add(node.text);
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node)) {
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
ts.forEachChild(node, (child) => {
|
|
136
|
+
identifiersIn(child, out);
|
|
137
|
+
});
|
|
138
|
+
return out;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const resolveTag = (tag) => {
|
|
142
|
+
const members = [];
|
|
143
|
+
let current = tag;
|
|
144
|
+
while (ts.isPropertyAccessExpression(current)) {
|
|
145
|
+
members.unshift(current.name.text);
|
|
146
|
+
current = current.expression;
|
|
147
|
+
}
|
|
148
|
+
if (!ts.isIdentifier(current)) return null;
|
|
149
|
+
const root = current.text;
|
|
150
|
+
if (namespaces.has(root) && members.length) {
|
|
151
|
+
return { base: members[0], component: members.join(".") };
|
|
152
|
+
}
|
|
153
|
+
const base = imports.get(root);
|
|
154
|
+
if (!base) return null;
|
|
155
|
+
return { base, component: [base, ...members].join(".") };
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const elements = [];
|
|
159
|
+
const openingOf = (node) => (ts.isJsxElement(node) ? node.openingElement : node);
|
|
160
|
+
|
|
161
|
+
const record = (node, parent, form) => {
|
|
162
|
+
const opening = openingOf(node);
|
|
163
|
+
const info = resolveTag(opening.tagName);
|
|
164
|
+
const tag = opening.tagName.getText(sf);
|
|
165
|
+
const props = new Map();
|
|
166
|
+
let spread = false;
|
|
167
|
+
for (const attr of opening.attributes.properties) {
|
|
168
|
+
if (ts.isJsxSpreadAttribute(attr)) {
|
|
169
|
+
spread = true;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (!ts.isJsxAttribute(attr)) continue;
|
|
173
|
+
const name = attr.name.getText(sf);
|
|
174
|
+
const init = attr.initializer;
|
|
175
|
+
if (init === undefined) {
|
|
176
|
+
// A bare attribute (`required`) is the boolean `true`.
|
|
177
|
+
props.set(name, {
|
|
178
|
+
literals: [],
|
|
179
|
+
identifiers: [],
|
|
180
|
+
text: "true",
|
|
181
|
+
line: lineOf(attr),
|
|
182
|
+
expression: false,
|
|
183
|
+
});
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
const inner = ts.isJsxExpression(init) ? (init.expression ?? null) : init;
|
|
187
|
+
const isLiteral =
|
|
188
|
+
inner !== null && (ts.isStringLiteral(inner) || ts.isNoSubstitutionTemplateLiteral(inner));
|
|
189
|
+
props.set(name, {
|
|
190
|
+
literals: inner ? literalsIn(inner) : [],
|
|
191
|
+
identifiers: inner ? [...identifiersIn(inner)] : [],
|
|
192
|
+
text: inner ? inner.getText(sf) : null,
|
|
193
|
+
line: lineOf(attr),
|
|
194
|
+
expression: Boolean(inner) && !isLiteral,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
const entry = {
|
|
198
|
+
index: elements.length,
|
|
199
|
+
parent,
|
|
200
|
+
tag,
|
|
201
|
+
base: info?.base ?? null,
|
|
202
|
+
component: info?.component ?? null,
|
|
203
|
+
isDs: Boolean(info),
|
|
204
|
+
line: lineOf(opening),
|
|
205
|
+
props,
|
|
206
|
+
spread,
|
|
207
|
+
form,
|
|
208
|
+
node,
|
|
209
|
+
};
|
|
210
|
+
elements.push(entry);
|
|
211
|
+
return entry;
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const walk = (node, parent, form) => {
|
|
215
|
+
if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) {
|
|
216
|
+
const entry = record(node, parent, form);
|
|
217
|
+
const nextForm = /^(?:form|Form)$/.test(entry.tag) ? entry.index : form;
|
|
218
|
+
ts.forEachChild(node, (child) => walk(child, entry.index, nextForm));
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
ts.forEachChild(node, (child) => walk(child, parent, form));
|
|
222
|
+
};
|
|
223
|
+
walk(sf, null, null);
|
|
224
|
+
|
|
225
|
+
const parseErrors = (sf.parseDiagnostics ?? []).map((d) => ({
|
|
226
|
+
line: d.start === undefined ? null : sf.getLineAndCharacterOfPosition(d.start).line + 1,
|
|
227
|
+
message: ts.flattenDiagnosticMessageText(d.messageText, " "),
|
|
228
|
+
}));
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
sf,
|
|
232
|
+
lineOf,
|
|
233
|
+
literalsIn,
|
|
234
|
+
identifiersIn: (node) => [...identifiersIn(node)],
|
|
235
|
+
imports,
|
|
236
|
+
namespaces,
|
|
237
|
+
elements,
|
|
238
|
+
constants,
|
|
239
|
+
componentsUsed: new Set(elements.filter((e) => e.isDs).map((e) => e.base)),
|
|
240
|
+
parseErrors,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** The literal value of an attribute when it is a single string, else `null`. */
|
|
245
|
+
export const literalValue = (prop) =>
|
|
246
|
+
prop && !prop.expression && prop.literals.length === 1 ? prop.literals[0] : null;
|
|
247
|
+
|
|
248
|
+
/** Whether `inner` is `outer` or sits below it in the element tree. */
|
|
249
|
+
export function isWithin(elements, inner, outer) {
|
|
250
|
+
if (outer === null || inner === null) return false;
|
|
251
|
+
for (let i = inner; i !== null; i = elements[i].parent) if (i === outer) return true;
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
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
|
+
}
|