basalt-ui 1.19.0 → 1.20.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/AGENTS.md +15 -1
- package/CHANGELOG.md +27 -0
- package/MIGRATING.md +228 -0
- package/README.md +53 -9
- package/agent/rules/basalt-app.md +43 -0
- package/agent/rules/basalt-charts.md +26 -14
- package/agent/rules/basalt-mantine.md +25 -10
- package/agent/rules/basalt-router.md +22 -10
- package/agent/rules/basalt-tokens.md +75 -6
- package/agent/skills/basalt-app/SKILL.md +42 -6
- package/configs/check.yml +11 -4
- package/configs/lefthook.yml +21 -2
- package/configs/oxlint-plugin.js +515 -25
- package/configs/oxlint-plugin.test.ts +383 -10
- package/configs/oxlint.json +10 -1
- package/dist/agent/stick-to-bottom.js +1 -1
- package/dist/agent-chat/thread-detail-panel.js +1 -1
- package/dist/agent-chat/thread-message.js +2 -2
- package/dist/agent-chat/tool-chip.js +1 -1
- package/dist/charts/kinds/Donut.d.ts.map +1 -1
- package/dist/charts/kinds/Donut.js +1 -1
- package/dist/charts/primitives/ChartLegend.js +2 -2
- package/dist/charts/primitives/ChartTooltip.js +1 -1
- package/dist/cli/index.d.ts +107 -15
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +610 -72
- package/dist/cli/toolchain-wiring.test.d.ts +2 -0
- package/dist/cli/toolchain-wiring.test.d.ts.map +1 -0
- package/dist/dashboard/delta-badge.js +2 -2
- package/dist/dashboard/stat-card.js +1 -1
- package/dist/data/virtual-list.js +5 -5
- package/dist/guard/index.d.ts +56 -2
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +326 -59
- package/dist/guard/types.d.ts +25 -5
- package/dist/guard/types.d.ts.map +1 -1
- package/dist/register.d.ts +25 -0
- package/dist/register.d.ts.map +1 -1
- package/dist/shell/app-mobile-nav.d.ts.map +1 -1
- package/dist/shell/app-mobile-nav.js +14 -14
- package/dist/shell/app-mobile-nav.module.css +44 -11
- package/dist/shell/app-mobile-nav.module.css.d.ts +1 -1
- package/dist/surfaces.d.ts +1 -1
- package/dist/surfaces.js +1 -1
- package/dist/tokens/index.d.ts +3 -3
- package/dist/tokens/index.d.ts.map +1 -1
- package/dist/tokens/index.js +2 -0
- package/dist/tokens/palette.d.ts +35 -27
- package/dist/tokens/palette.d.ts.map +1 -1
- package/dist/tokens/palette.js +24 -18
- package/dist/tokens.css +19 -17
- package/dist/vite-color-mix.js +2 -2
- package/llms.txt +3 -3
- package/package.json +4 -3
- package/src/agent/stick-to-bottom.tsx +1 -1
- package/src/agent-chat/thread-detail-panel.tsx +1 -1
- package/src/agent-chat/thread-message.tsx +3 -3
- package/src/agent-chat/tool-chip.tsx +1 -1
- package/src/charts/kinds/Donut.tsx +2 -1
- package/src/charts/primitives/ChartLegend.tsx +2 -2
- package/src/charts/primitives/ChartTooltip.tsx +1 -1
- package/src/cli/index.ts +1070 -99
- package/src/cli/init-sync.test.ts +19 -3
- package/src/cli/tokens-css.test.ts +27 -14
- package/src/cli/toolchain-wiring.test.ts +694 -0
- package/src/dashboard/delta-badge.tsx +3 -3
- package/src/dashboard/stat-card.tsx +1 -1
- package/src/data/virtual-list.tsx +8 -8
- package/src/guard/check-source.test.ts +663 -11
- package/src/guard/index.ts +727 -94
- package/src/guard/types.ts +29 -4
- package/src/register.ts +32 -0
- package/src/shell/app-mobile-nav.module.css +44 -11
- package/src/shell/app-mobile-nav.module.css.d.ts +1 -1
- package/src/shell/app-mobile-nav.test.tsx +34 -0
- package/src/shell/app-mobile-nav.tsx +16 -5
- package/src/surfaces.ts +1 -1
- package/src/theme/density-relations.test.ts +8 -5
- package/src/theme/spacing.test.ts +2 -0
- package/src/theme-toggle/index.tsx +2 -2
- package/src/tokens/index.ts +5 -3
- package/src/tokens/palette.ts +24 -18
- package/src/vite-color-mix.ts +2 -2
package/dist/cli/index.js
CHANGED
|
@@ -10,7 +10,13 @@ import {
|
|
|
10
10
|
} from "node:fs";
|
|
11
11
|
import { dirname, isAbsolute, relative, resolve } from "node:path";
|
|
12
12
|
import { fileURLToPath } from "node:url";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
checkSource,
|
|
15
|
+
DEFAULT_GUARD_CONFIG,
|
|
16
|
+
GENERATED_HEADER_LINE,
|
|
17
|
+
GUARD_RULES,
|
|
18
|
+
TOKENS_ONLY_DISABLED_KINDS
|
|
19
|
+
} from "../guard/index.js";
|
|
14
20
|
import { evaluateGuardHook } from "../guard/guard-hook.js";
|
|
15
21
|
import { RULE_NAMES, SKILL_NAMES, SURFACES, TOKEN_LAYER_BOUNDARY_SURFACES } from "../surfaces.js";
|
|
16
22
|
import { buildPaletteCss, deriveSpacing } from "../tokens/index.js";
|
|
@@ -61,6 +67,7 @@ function readBasaltConfig(cwd) {
|
|
|
61
67
|
return {};
|
|
62
68
|
}
|
|
63
69
|
}
|
|
70
|
+
const SCANNABLE_EXT = /\.(?:tsx?|css|html?|webmanifest)$/;
|
|
64
71
|
function walkSourceFiles(dir, out = []) {
|
|
65
72
|
let entries;
|
|
66
73
|
try {
|
|
@@ -78,7 +85,7 @@ function walkSourceFiles(dir, out = []) {
|
|
|
78
85
|
continue;
|
|
79
86
|
}
|
|
80
87
|
if (isDir) walkSourceFiles(abs, out);
|
|
81
|
-
else if (
|
|
88
|
+
else if (SCANNABLE_EXT.test(name)) out.push(abs);
|
|
82
89
|
}
|
|
83
90
|
return out;
|
|
84
91
|
}
|
|
@@ -101,10 +108,145 @@ const GUARD_KIND_EXPLANATIONS = {
|
|
|
101
108
|
"sub-16-input-font": "sub-16-input-font: fontSize below 16 on a form control is dead code against the `!important` iOS floor in styles.css \u2014 drop the override or be honest about 16px.",
|
|
102
109
|
"raw-font-family": "raw-font-family: route the font stack through createBasaltTheme(overrides, { fonts }) / the --basalt-font-sans|head|mono vars instead of a hardcoded fontFamily literal."
|
|
103
110
|
};
|
|
104
|
-
function
|
|
105
|
-
const
|
|
106
|
-
|
|
111
|
+
function relativePosix(fromDir, toPath) {
|
|
112
|
+
const rel = relative(fromDir, toPath).replace(/\\/g, "/");
|
|
113
|
+
if (rel === "") return ".";
|
|
114
|
+
return rel.startsWith(".") ? rel : `./${rel}`;
|
|
115
|
+
}
|
|
116
|
+
const UNRESOLVED_INSTALL = { dir: null, version: null, how: null };
|
|
117
|
+
function basaltInstallAt(dir) {
|
|
118
|
+
const installDir = resolve(dir, "node_modules", "basalt-ui");
|
|
119
|
+
const raw = readIfExists(resolve(installDir, "package.json"));
|
|
120
|
+
if (raw === null) return null;
|
|
121
|
+
try {
|
|
122
|
+
const pkg = JSON.parse(raw);
|
|
123
|
+
return { dir: installDir, version: pkg.version ?? null };
|
|
124
|
+
} catch {
|
|
125
|
+
return { dir: installDir, version: null };
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function findBasaltInstall(cwd) {
|
|
129
|
+
const own = basaltInstallAt(cwd);
|
|
130
|
+
if (own !== null) return { ...own, how: "cwd" };
|
|
131
|
+
let current = dirname(cwd);
|
|
132
|
+
for (; ; ) {
|
|
133
|
+
const found = basaltInstallAt(current);
|
|
134
|
+
if (found !== null) return { ...found, how: "ancestor" };
|
|
135
|
+
const parent = dirname(current);
|
|
136
|
+
if (parent === current) break;
|
|
137
|
+
current = parent;
|
|
138
|
+
}
|
|
139
|
+
for (const pkg of collectWorkspacePackages(cwd).packages) {
|
|
140
|
+
if (pkg.dir === cwd) continue;
|
|
141
|
+
const found = basaltInstallAt(pkg.dir);
|
|
142
|
+
if (found !== null) return { ...found, how: "workspace" };
|
|
143
|
+
}
|
|
144
|
+
return UNRESOLVED_INSTALL;
|
|
145
|
+
}
|
|
146
|
+
function shippedAssetPath(install, fromDir, asset) {
|
|
147
|
+
if (install.dir === null) return `./node_modules/basalt-ui/${asset}`;
|
|
148
|
+
return `${relativePosix(fromDir, install.dir)}/${asset}`;
|
|
149
|
+
}
|
|
150
|
+
function hasBasaltProject(dir) {
|
|
151
|
+
if (existsSync(resolve(dir, MANIFEST_PATH))) return true;
|
|
152
|
+
const raw = readIfExists(resolve(dir, "package.json"));
|
|
153
|
+
if (raw === null) return false;
|
|
154
|
+
try {
|
|
155
|
+
return JSON.parse(raw).basalt !== void 0;
|
|
156
|
+
} catch {
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
function resolveProjectDir(cwd) {
|
|
161
|
+
const override = process.env["BASALT_CWD"];
|
|
162
|
+
if (override !== void 0 && override.length > 0) {
|
|
163
|
+
const dir = isAbsolute(override) ? override : resolve(cwd, override);
|
|
164
|
+
return { dir, relocatedFrom: dir === cwd ? null : cwd, ambiguous: null };
|
|
165
|
+
}
|
|
166
|
+
if (hasBasaltProject(cwd)) return { dir: cwd, relocatedFrom: null, ambiguous: null };
|
|
167
|
+
const candidates = collectWorkspacePackages(cwd).packages.filter((pkg) => pkg.dir !== cwd && hasBasaltProject(pkg.dir)).map((pkg) => pkg.dir);
|
|
168
|
+
if (candidates.length === 1) {
|
|
169
|
+
return { dir: candidates[0], relocatedFrom: cwd, ambiguous: null };
|
|
170
|
+
}
|
|
171
|
+
if (candidates.length > 1) return { dir: cwd, relocatedFrom: null, ambiguous: candidates };
|
|
172
|
+
return { dir: cwd, relocatedFrom: null, ambiguous: null };
|
|
173
|
+
}
|
|
174
|
+
function declaredProfile(cfg, flags) {
|
|
175
|
+
if (flags.includes("--tokens-only")) return "tokens-only";
|
|
176
|
+
if (flags.includes("--framework")) return "framework";
|
|
177
|
+
return cfg.profile === "tokens-only" ? "tokens-only" : "framework";
|
|
178
|
+
}
|
|
179
|
+
function conflictingProfileFlags(flags) {
|
|
180
|
+
return flags.includes("--tokens-only") && flags.includes("--framework");
|
|
181
|
+
}
|
|
182
|
+
function workspaceHasMantine(cwd) {
|
|
183
|
+
if (hasDependency(cwd, "@mantine/core")) return true;
|
|
184
|
+
return collectWorkspacePackages(cwd).packages.some(
|
|
185
|
+
(pkg) => hasDependency(pkg.dir, "@mantine/core")
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
function inferredProfile(cwd, cfg, flags) {
|
|
189
|
+
if (flags.includes("--tokens-only") || flags.includes("--framework") || cfg.profile !== void 0) {
|
|
190
|
+
return declaredProfile(cfg, flags);
|
|
191
|
+
}
|
|
192
|
+
return !existsSync(resolve(cwd, MANIFEST_PATH)) && !workspaceHasMantine(cwd) ? "tokens-only" : "framework";
|
|
193
|
+
}
|
|
194
|
+
function scannableFiles(cwd, cfg) {
|
|
107
195
|
const exempt = new Set(cfg.exempt ?? defaultExempt(cfg));
|
|
196
|
+
const seen = /* @__PURE__ */ new Set();
|
|
197
|
+
const out = [];
|
|
198
|
+
const add = (abs) => {
|
|
199
|
+
const rel = relative(cwd, abs).replace(/\\/g, "/");
|
|
200
|
+
if (rel.startsWith("..") || SKIP.test(rel) || exempt.has(rel) || seen.has(rel)) return;
|
|
201
|
+
seen.add(rel);
|
|
202
|
+
out.push(rel);
|
|
203
|
+
};
|
|
204
|
+
for (const root of resolveRoots(cfg)) {
|
|
205
|
+
const rootAbs = resolve(cwd, root);
|
|
206
|
+
for (const f of walkSourceFiles(rootAbs)) add(f);
|
|
207
|
+
for (const f of appShellFiles(rootAbs)) add(f);
|
|
208
|
+
}
|
|
209
|
+
for (const rel of cfg.include ?? []) add(resolve(cwd, rel));
|
|
210
|
+
return out;
|
|
211
|
+
}
|
|
212
|
+
function appShellFiles(rootAbs) {
|
|
213
|
+
const appDir = dirname(rootAbs);
|
|
214
|
+
const out = [];
|
|
215
|
+
for (const name of ["index.html", "index.htm"]) {
|
|
216
|
+
const abs = resolve(appDir, name);
|
|
217
|
+
if (existsSync(abs)) out.push(abs);
|
|
218
|
+
}
|
|
219
|
+
out.push(...walkSourceFiles(resolve(appDir, "public")));
|
|
220
|
+
return out;
|
|
221
|
+
}
|
|
222
|
+
function checkTheme(invocationCwd = process.cwd(), flags = []) {
|
|
223
|
+
if (conflictingProfileFlags(flags)) {
|
|
224
|
+
console.error(
|
|
225
|
+
"basalt-ui check-theme: --tokens-only and --framework are alternatives \u2014 pass one."
|
|
226
|
+
);
|
|
227
|
+
return 1;
|
|
228
|
+
}
|
|
229
|
+
const project = resolveProjectDir(invocationCwd);
|
|
230
|
+
if (project.ambiguous !== null) {
|
|
231
|
+
console.error(
|
|
232
|
+
`\u2716 basalt-ui check-theme: no basalt config at ${invocationCwd}, and ${project.ambiguous.length} workspace packages carry one (${project.ambiguous.map((d) => relativePosix(invocationCwd, d)).join(", ")}) \u2014 run it from one of them, or set BASALT_CWD to pick.`
|
|
233
|
+
);
|
|
234
|
+
return 1;
|
|
235
|
+
}
|
|
236
|
+
const cwd = project.dir;
|
|
237
|
+
if (project.relocatedFrom !== null) {
|
|
238
|
+
console.log(
|
|
239
|
+
`basalt-ui check-theme: no basalt config at ${project.relocatedFrom} \u2014 running in ${relativePosix(project.relocatedFrom, cwd)}, where it lives.`
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
const cfg = readBasaltConfig(cwd);
|
|
243
|
+
const roots = resolveRoots(cfg);
|
|
244
|
+
const profile = declaredProfile(cfg, flags);
|
|
245
|
+
if (profile === "tokens-only") {
|
|
246
|
+
console.log(
|
|
247
|
+
`basalt-ui check-theme: tokens-only profile \u2014 ${TOKENS_ONLY_DISABLED_KINDS.size} Mantine-coupled kinds are off; the color and typography kinds still apply. Pass --framework to force the full set.`
|
|
248
|
+
);
|
|
249
|
+
}
|
|
108
250
|
const guardCfg = {
|
|
109
251
|
spacingSteps: cfg.spacingSteps ?? DEFAULT_GUARD_CONFIG.spacingSteps,
|
|
110
252
|
rawRadius: cfg.rawRadius ?? DEFAULT_GUARD_CONFIG.rawRadius,
|
|
@@ -124,20 +266,15 @@ function checkTheme(cwd = process.cwd()) {
|
|
|
124
266
|
sub16InputFont: cfg.sub16InputFont ?? DEFAULT_GUARD_CONFIG.sub16InputFont,
|
|
125
267
|
allowComment: "theme-allow",
|
|
126
268
|
exemptRules: cfg.exemptRules ?? DEFAULT_GUARD_CONFIG.exemptRules,
|
|
127
|
-
severity: cfg.severity ?? DEFAULT_GUARD_CONFIG.severity
|
|
269
|
+
severity: cfg.severity ?? DEFAULT_GUARD_CONFIG.severity,
|
|
270
|
+
...profile === "tokens-only" ? { profile: "tokens-only" } : {}
|
|
128
271
|
};
|
|
129
272
|
const findings = [];
|
|
130
|
-
|
|
131
|
-
for (const
|
|
132
|
-
|
|
133
|
-
const rel = relative(cwd, f).replace(/\\/g, "/");
|
|
134
|
-
if (SKIP.test(rel) || exempt.has(rel)) continue;
|
|
135
|
-
scannedCount += 1;
|
|
136
|
-
const text = readFileSync(f, "utf8");
|
|
137
|
-
findings.push(...checkSource(text, rel, guardCfg));
|
|
138
|
-
}
|
|
273
|
+
const scanned = scannableFiles(cwd, cfg);
|
|
274
|
+
for (const rel of scanned) {
|
|
275
|
+
findings.push(...checkSource(readFileSync(resolve(cwd, rel), "utf8"), rel, guardCfg));
|
|
139
276
|
}
|
|
140
|
-
if (
|
|
277
|
+
if (scanned.length === 0) {
|
|
141
278
|
if (cfg.roots === void 0) {
|
|
142
279
|
console.error(
|
|
143
280
|
`\u2716 basalt-ui check-theme: 0 files scanned \u2014 no "basalt.roots" configured in package.json, and the built-in default roots (${DEFAULT_ROOTS.join(", ")}) matched zero files. Set "basalt": { "roots": [...] } in package.json to point at your source directories.`
|
|
@@ -230,12 +367,17 @@ function readFrameworkVersion(pkgRoot) {
|
|
|
230
367
|
}
|
|
231
368
|
}
|
|
232
369
|
function buildTemplateVars(pkgRoot, cwd, cfg) {
|
|
370
|
+
const install = findBasaltInstall(cwd);
|
|
371
|
+
const binDir = install.dir === null ? null : resolve(install.dir, "..", ".bin");
|
|
233
372
|
return {
|
|
234
373
|
APP_NAME: readPackageName(cwd),
|
|
235
374
|
BASALT_VERSION: readFrameworkVersion(pkgRoot),
|
|
236
375
|
ACCENT_HUE: cfg.accentHue ?? "blue",
|
|
237
376
|
SERIES_MODULE_PATH: resolveSeriesModulePath(cfg),
|
|
238
|
-
ROOTS_GLOBS: resolveRoots(cfg).map(toRootGlob).join(" ")
|
|
377
|
+
ROOTS_GLOBS: resolveRoots(cfg).map(toRootGlob).join(" "),
|
|
378
|
+
OXLINT_PRESET_PATH: shippedAssetPath(install, cwd, "configs/oxlint.json"),
|
|
379
|
+
LEFTHOOK_PRESET_PATH: shippedAssetPath(install, cwd, "configs/lefthook.yml"),
|
|
380
|
+
BASALT_BIN: binDir === null ? "bunx basalt-ui" : `${relativePosix(cwd, binDir).replace(/^\.\//, "")}/basalt-ui`
|
|
239
381
|
};
|
|
240
382
|
}
|
|
241
383
|
function fillTemplate(tpl, vars) {
|
|
@@ -383,7 +525,15 @@ function managedFiles(peers, placement = ROOT_PLACEMENT) {
|
|
|
383
525
|
dest: "lefthook.yml",
|
|
384
526
|
mode: "seed",
|
|
385
527
|
source: "configs/lefthook.yml",
|
|
386
|
-
render: () =>
|
|
528
|
+
render: (ctx) => `# Seeded by basalt-ui init \u2014 you own this file; sync never touches it again.
|
|
529
|
+
# The extends target supplies oxlint + oxfmt + check-theme pre-commit jobs and
|
|
530
|
+
# auto-updates with the basalt-ui package. Add your own commands alongside.
|
|
531
|
+
# The path is RESOLVED from where basalt-ui actually installed, not assumed at the
|
|
532
|
+
# repo root \u2014 under an isolated linker it lives beside the package that depends on it.
|
|
533
|
+
# Run \`lefthook install\` once: the file is inert until the git hooks are written.
|
|
534
|
+
extends:
|
|
535
|
+
- ${ctx.vars.LEFTHOOK_PRESET_PATH.replace(/^\.\//, "")}
|
|
536
|
+
`
|
|
387
537
|
};
|
|
388
538
|
const ci = {
|
|
389
539
|
dest: ".github/workflows/check.yml",
|
|
@@ -399,7 +549,10 @@ function managedFiles(peers, placement = ROOT_PLACEMENT) {
|
|
|
399
549
|
dest: ".oxlintrc.json",
|
|
400
550
|
mode: "seed",
|
|
401
551
|
source: "configs/oxlint.json",
|
|
402
|
-
render: () =>
|
|
552
|
+
render: (ctx) => `{
|
|
553
|
+
"extends": ["${ctx.vars.OXLINT_PRESET_PATH}"]
|
|
554
|
+
}
|
|
555
|
+
`
|
|
403
556
|
};
|
|
404
557
|
const queryClient = {
|
|
405
558
|
dest: "src/query-client.ts",
|
|
@@ -510,15 +663,142 @@ function renderContext(cwd) {
|
|
|
510
663
|
const pkgRoot = packageRoot();
|
|
511
664
|
return { pkgRoot, vars: buildTemplateVars(pkgRoot, cwd, readBasaltConfig(cwd)) };
|
|
512
665
|
}
|
|
666
|
+
function patchPackageJson(cwd, mutate) {
|
|
667
|
+
const abs = resolve(cwd, "package.json");
|
|
668
|
+
const raw = readIfExists(abs);
|
|
669
|
+
if (raw === null) return false;
|
|
670
|
+
let pkg;
|
|
671
|
+
try {
|
|
672
|
+
pkg = JSON.parse(raw);
|
|
673
|
+
} catch {
|
|
674
|
+
return false;
|
|
675
|
+
}
|
|
676
|
+
if (!mutate(pkg)) return false;
|
|
677
|
+
const indent = /\n([ \t]+)"/.exec(raw)?.[1] ?? " ";
|
|
678
|
+
writeFileSync(abs, JSON.stringify(pkg, null, indent) + (raw.endsWith("\n") ? "\n" : ""));
|
|
679
|
+
return true;
|
|
680
|
+
}
|
|
681
|
+
function detectRoots(cwd) {
|
|
682
|
+
const workspacePackages = collectWorkspacePackages(cwd).packages.filter((pkg) => pkg.dir !== cwd);
|
|
683
|
+
const withSrc = workspacePackages.filter((pkg) => existsSync(resolve(pkg.dir, "src")));
|
|
684
|
+
const uiPackages = withSrc.filter(
|
|
685
|
+
(pkg) => hasDependency(pkg.dir, "basalt-ui") || hasDependency(pkg.dir, "@mantine/core")
|
|
686
|
+
);
|
|
687
|
+
const chosen = uiPackages.length > 0 ? uiPackages : withSrc;
|
|
688
|
+
if (chosen.length > 0) {
|
|
689
|
+
return chosen.map((pkg) => relativePosix(cwd, resolve(pkg.dir, "src")).replace(/^\.\//, ""));
|
|
690
|
+
}
|
|
691
|
+
return existsSync(resolve(cwd, DEFAULT_ROOT)) ? [DEFAULT_ROOT] : [];
|
|
692
|
+
}
|
|
693
|
+
function stripJsonc(text) {
|
|
694
|
+
let out = "";
|
|
695
|
+
let inString = false;
|
|
696
|
+
let escaped = false;
|
|
697
|
+
let i = 0;
|
|
698
|
+
while (i < text.length) {
|
|
699
|
+
const ch = text[i];
|
|
700
|
+
if (inString) {
|
|
701
|
+
out += ch;
|
|
702
|
+
if (escaped) escaped = false;
|
|
703
|
+
else if (ch === "\\") escaped = true;
|
|
704
|
+
else if (ch === '"') inString = false;
|
|
705
|
+
i += 1;
|
|
706
|
+
continue;
|
|
707
|
+
}
|
|
708
|
+
if (ch === '"') {
|
|
709
|
+
inString = true;
|
|
710
|
+
out += ch;
|
|
711
|
+
i += 1;
|
|
712
|
+
continue;
|
|
713
|
+
}
|
|
714
|
+
if (ch === "/" && text[i + 1] === "/") {
|
|
715
|
+
while (i < text.length && text[i] !== "\n") i += 1;
|
|
716
|
+
continue;
|
|
717
|
+
}
|
|
718
|
+
if (ch === "/" && text[i + 1] === "*") {
|
|
719
|
+
i += 2;
|
|
720
|
+
while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) i += 1;
|
|
721
|
+
i += 2;
|
|
722
|
+
continue;
|
|
723
|
+
}
|
|
724
|
+
out += ch;
|
|
725
|
+
i += 1;
|
|
726
|
+
}
|
|
727
|
+
return out.replace(/,(\s*[}\]])/g, "$1");
|
|
728
|
+
}
|
|
729
|
+
function parseJsonc(text) {
|
|
730
|
+
try {
|
|
731
|
+
return JSON.parse(stripJsonc(text));
|
|
732
|
+
} catch {
|
|
733
|
+
return null;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
function extendsBasaltPreset(entries) {
|
|
737
|
+
return Array.isArray(entries) && entries.some(
|
|
738
|
+
(entry) => typeof entry === "string" && entry.endsWith("basalt-ui/configs/oxlint.json")
|
|
739
|
+
);
|
|
740
|
+
}
|
|
741
|
+
function mergeOxlintExtends(cwd, presetPath) {
|
|
742
|
+
const abs = resolve(cwd, ".oxlintrc.json");
|
|
743
|
+
const raw = readIfExists(abs);
|
|
744
|
+
if (raw === null) return "absent";
|
|
745
|
+
const cfg = parseJsonc(raw);
|
|
746
|
+
if (cfg === null) return "unreadable";
|
|
747
|
+
if (extendsBasaltPreset(cfg["extends"])) return "already";
|
|
748
|
+
if (stripJsonc(raw) !== raw) return "has-comments";
|
|
749
|
+
const existing = Array.isArray(cfg["extends"]) ? cfg["extends"].filter((entry) => typeof entry === "string") : [];
|
|
750
|
+
const merged = "extends" in cfg ? {} : { extends: [presetPath] };
|
|
751
|
+
for (const [key, value] of Object.entries(cfg)) {
|
|
752
|
+
merged[key] = key === "extends" ? [presetPath, ...existing] : value;
|
|
753
|
+
}
|
|
754
|
+
const indent = /\n([ \t]+)"/.exec(raw)?.[1] ?? " ";
|
|
755
|
+
writeFileSync(abs, JSON.stringify(merged, null, indent) + (raw.endsWith("\n") ? "\n" : ""));
|
|
756
|
+
return "added";
|
|
757
|
+
}
|
|
758
|
+
const KEPT_FILE_COST = {
|
|
759
|
+
".oxlintrc.json": "the shipped oxlint preset (the basalt/* design rules AND the jsx-a11y / import / promise / unicorn plugins) is NOT active \u2014 nothing else turns it on, and every gate stays green without it. Re-run with `--merge-lint` to splice the extends in, or add it by hand.",
|
|
760
|
+
"lefthook.yml": "the pre-commit oxlint / oxfmt / check-theme jobs are not wired \u2014 add `extends: [<preset>]` to your own file.",
|
|
761
|
+
".github/workflows/check.yml": "CI does not run check-theme or the sync drift gate \u2014 copy those steps into your own workflow.",
|
|
762
|
+
".oxfmtrc.json": "your existing formatter config stands; basalt formats nothing on its own.",
|
|
763
|
+
"DESIGN.md": "the design brief Claude reads is yours, not the seeded one."
|
|
764
|
+
};
|
|
513
765
|
function init(cwd = process.cwd(), scaffoldFlags = {}) {
|
|
766
|
+
const install = findBasaltInstall(cwd);
|
|
767
|
+
if (install.dir === null) {
|
|
768
|
+
console.error(
|
|
769
|
+
`\u26A0 basalt-ui init: basalt-ui does not resolve from ${cwd} or any directory above it \u2014 the seeded \`extends\` paths and CI steps are being written against the repo root by assumption. Install basalt-ui here (or at the repo root) and re-run \`basalt-ui init\` so they resolve.`
|
|
770
|
+
);
|
|
771
|
+
}
|
|
772
|
+
let seededRoots = null;
|
|
773
|
+
let rootsUndetectable = false;
|
|
774
|
+
if (readBasaltConfig(cwd).roots === void 0) {
|
|
775
|
+
const detected = detectRoots(cwd);
|
|
776
|
+
if (detected.length === 0) {
|
|
777
|
+
rootsUndetectable = true;
|
|
778
|
+
} else if (patchPackageJson(cwd, (pkg) => {
|
|
779
|
+
const basalt = pkg["basalt"] ?? {};
|
|
780
|
+
if (basalt["roots"] !== void 0) return false;
|
|
781
|
+
pkg["basalt"] = { ...basalt, roots: detected };
|
|
782
|
+
return true;
|
|
783
|
+
})) {
|
|
784
|
+
seededRoots = detected;
|
|
785
|
+
}
|
|
786
|
+
}
|
|
514
787
|
const ctx = renderContext(cwd);
|
|
788
|
+
const lintScript = `oxlint . && ${ctx.vars.BASALT_BIN} check-theme`;
|
|
789
|
+
const seededLintScript = patchPackageJson(cwd, (pkg) => {
|
|
790
|
+
const scripts = pkg["scripts"] ?? {};
|
|
791
|
+
if (scripts["lint:basalt"] !== void 0) return false;
|
|
792
|
+
pkg["scripts"] = { ...scripts, "lint:basalt": lintScript };
|
|
793
|
+
return true;
|
|
794
|
+
});
|
|
515
795
|
const manifest = readManifest(cwd);
|
|
516
796
|
migrateLegacyOxfmt(cwd, ctx.pkgRoot, manifest);
|
|
517
797
|
const peers = resolvePeerFlags(cwd, scaffoldFlags);
|
|
518
798
|
const placement = resolvePlacement(cwd);
|
|
519
799
|
const files = managedFiles(peers, placement);
|
|
520
|
-
|
|
521
|
-
|
|
800
|
+
const writtenFiles = [];
|
|
801
|
+
const keptFiles = [];
|
|
522
802
|
const missingSources = [];
|
|
523
803
|
for (const file of files) {
|
|
524
804
|
const state = unitState(file, cwd, ctx);
|
|
@@ -529,29 +809,63 @@ function init(cwd = process.cwd(), scaffoldFlags = {}) {
|
|
|
529
809
|
const destExists = existsSync(resolve(cwd, file.dest));
|
|
530
810
|
if (!file.markers && destExists) {
|
|
531
811
|
manifest.files[file.dest] = sha256(normalizeForLedger(state.desired));
|
|
532
|
-
|
|
812
|
+
keptFiles.push(file.dest);
|
|
533
813
|
continue;
|
|
534
814
|
}
|
|
535
815
|
const hash = writeUnit(file, cwd, state.desired);
|
|
536
816
|
manifest.files[file.dest] = hash;
|
|
537
|
-
|
|
817
|
+
writtenFiles.push(file.dest);
|
|
538
818
|
}
|
|
819
|
+
const mergeLint = scaffoldFlags.mergeLint === true ? mergeOxlintExtends(cwd, ctx.vars.OXLINT_PRESET_PATH) : null;
|
|
539
820
|
manifest.basaltVersion = ctx.vars.BASALT_VERSION;
|
|
540
821
|
manifest.spacingScale = { ...deriveSpacing(0).scale };
|
|
541
822
|
writeFileEnsuringDir(resolve(cwd, MANIFEST_PATH), `${JSON.stringify(manifest, null, 2)}
|
|
542
823
|
`);
|
|
543
|
-
console.log(
|
|
824
|
+
console.log(
|
|
825
|
+
`basalt-ui init: ${writtenFiles.length} written, ${keptFiles.length} kept, manifest at ${MANIFEST_PATH}`
|
|
826
|
+
);
|
|
544
827
|
if (missingSources.length > 0) {
|
|
545
828
|
console.log(
|
|
546
829
|
`basalt-ui init: ${missingSources.length} shipped asset(s) not present, skipped: ${missingSources.join(", ")}`
|
|
547
830
|
);
|
|
548
831
|
}
|
|
832
|
+
if (keptFiles.length > 0) {
|
|
833
|
+
console.log(`
|
|
834
|
+
Kept (already present \u2014 basalt did not touch them):`);
|
|
835
|
+
for (const dest of keptFiles) {
|
|
836
|
+
const cost = KEPT_FILE_COST[dest];
|
|
837
|
+
console.log(cost === void 0 ? ` \xB7 ${dest}` : ` \xB7 ${dest} \u2014 ${cost}`);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
if (mergeLint !== null) {
|
|
841
|
+
const mergeMessage = {
|
|
842
|
+
added: `spliced "${ctx.vars.OXLINT_PRESET_PATH}" into the existing .oxlintrc.json extends`,
|
|
843
|
+
already: ".oxlintrc.json already extends the shipped preset \u2014 nothing to do",
|
|
844
|
+
absent: "no existing .oxlintrc.json to merge into (the seeded one already extends the preset)",
|
|
845
|
+
unreadable: ".oxlintrc.json is not valid JSON \u2014 merge the extends entry by hand",
|
|
846
|
+
"has-comments": `.oxlintrc.json carries comments; rewriting it would delete them (usually the reason each rule is off). Add "extends": ["${ctx.vars.OXLINT_PRESET_PATH}"] as the first entry by hand.`
|
|
847
|
+
};
|
|
848
|
+
console.log(`basalt-ui init: --merge-lint \u2014 ${mergeMessage[mergeLint]}`);
|
|
849
|
+
}
|
|
850
|
+
if (seededRoots !== null) {
|
|
851
|
+
console.log(
|
|
852
|
+
`basalt-ui init: wrote "basalt": { "roots": ${JSON.stringify(seededRoots)} } to package.json \u2014 check-theme, the CI oxfmt globs and the default scan exemption all derive from it. Correct it if your sources live elsewhere.`
|
|
853
|
+
);
|
|
854
|
+
}
|
|
855
|
+
if (rootsUndetectable) {
|
|
856
|
+
console.error(
|
|
857
|
+
'\u26A0 basalt-ui init: could not infer "basalt.roots" \u2014 no src/ here and no workspace package with one. Set "basalt": { "roots": [...] } in package.json by hand, or check-theme will fail with "0 files scanned".'
|
|
858
|
+
);
|
|
859
|
+
}
|
|
860
|
+
if (seededLintScript) {
|
|
861
|
+
console.log(`basalt-ui init: added the "lint:basalt" script (${lintScript}) to package.json.`);
|
|
862
|
+
}
|
|
549
863
|
if (!placement.isPackageRepoRoot) {
|
|
550
864
|
console.log(
|
|
551
|
-
`basalt-ui init: skipped lefthook.yml (this package is not the repo root \u2014 repo root detected at ${placement.repoRoot}) \u2014 lefthook only reads config at the repo root; extend
|
|
865
|
+
`basalt-ui init: skipped lefthook.yml (this package is not the repo root \u2014 repo root detected at ${placement.repoRoot}) \u2014 lefthook only reads config at the repo root; extend ${ctx.vars.LEFTHOOK_PRESET_PATH} from your root lefthook.yml instead, and give its check-theme command \`env: { BASALT_CWD: ${relativePosix(placement.repoRoot, cwd)} }\` so the guard runs where your basalt config actually lives.`
|
|
552
866
|
);
|
|
553
867
|
console.log(
|
|
554
|
-
`basalt-ui init: skipped .github/workflows/check.yml (this package is not the repo root \u2014 repo root detected at ${placement.repoRoot}) \u2014 GitHub Actions only reads .github/ at the repo root;
|
|
868
|
+
`basalt-ui init: skipped .github/workflows/check.yml (this package is not the repo root \u2014 repo root detected at ${placement.repoRoot}) \u2014 GitHub Actions only reads .github/ at the repo root; copy the steps from ${shippedAssetPath(install, placement.repoRoot, "configs/check.yml")} into your root CI workflow instead.`
|
|
555
869
|
);
|
|
556
870
|
}
|
|
557
871
|
if (placement.relocatedQueryClient !== null) {
|
|
@@ -581,11 +895,32 @@ Theme guard hook: add to .claude/settings.json \u2192 hooks.PreToolUse to catch
|
|
|
581
895
|
]
|
|
582
896
|
}`
|
|
583
897
|
);
|
|
898
|
+
const presetPlugins = readOxlintPresetPlugins(ctx.pkgRoot);
|
|
899
|
+
if (presetPlugins.length > 0) {
|
|
900
|
+
console.log(
|
|
901
|
+
`
|
|
902
|
+
Lint debt: adopting the shipped oxlint preset on an EXISTING app is not a no-op \u2014 it turns on ${presetPlugins.length} plugins (${presetPlugins.join(", ")}) plus the basalt/* design rules, on code never linted against them. Run \`oxlint .\` now and triage the count before your next commit; turn a rule off in your own .oxlintrc.json with a written reason rather than blanket-disabling a plugin.`
|
|
903
|
+
);
|
|
904
|
+
}
|
|
905
|
+
console.log(
|
|
906
|
+
"Activate the hooks: `lefthook install` \u2014 the seeded lefthook.yml is inert until the git hooks are written, and looks configured either way."
|
|
907
|
+
);
|
|
584
908
|
console.log(
|
|
585
|
-
|
|
909
|
+
`
|
|
910
|
+
First run: run \`${ctx.vars.BASALT_BIN} check-theme\` next (or \`bun run lint:basalt\`), then tune the per-rule \`basalt.*\` config keys in package.json for anything that fires \u2014 do not mass-\`theme-allow\` findings. Then \`basalt-ui doctor\` to confirm the wiring actually took.`
|
|
586
911
|
);
|
|
587
912
|
return 0;
|
|
588
913
|
}
|
|
914
|
+
function readOxlintPresetPlugins(pkgRoot) {
|
|
915
|
+
const raw = readSource(pkgRoot, "configs/oxlint.json");
|
|
916
|
+
if (raw === null) return [];
|
|
917
|
+
try {
|
|
918
|
+
const plugins = JSON.parse(raw).plugins;
|
|
919
|
+
return isStringArray(plugins) ? plugins : [];
|
|
920
|
+
} catch {
|
|
921
|
+
return [];
|
|
922
|
+
}
|
|
923
|
+
}
|
|
589
924
|
function sync(opts = {}, cwd = process.cwd()) {
|
|
590
925
|
const ctx = renderContext(cwd);
|
|
591
926
|
const manifest = readManifest(cwd);
|
|
@@ -725,7 +1060,7 @@ function checkCoverage() {
|
|
|
725
1060
|
for (const exportKey of pkgExports) {
|
|
726
1061
|
if (exportKey === "." || exportKey === "./styles.css" || exportKey.startsWith("./configs/"))
|
|
727
1062
|
continue;
|
|
728
|
-
if (!(exportKey
|
|
1063
|
+
if (!Object.hasOwn(SURFACES, exportKey)) {
|
|
729
1064
|
failures.push(`package.json exports key '${exportKey}' has no matching SURFACES entry`);
|
|
730
1065
|
}
|
|
731
1066
|
}
|
|
@@ -880,9 +1215,27 @@ function resolveAiMajorSkewReason(cfg) {
|
|
|
880
1215
|
}
|
|
881
1216
|
return { reason: null, presentButInvalid: true };
|
|
882
1217
|
}
|
|
883
|
-
function doctor(
|
|
1218
|
+
function doctor(invocationCwd = process.cwd(), flags = []) {
|
|
1219
|
+
if (conflictingProfileFlags(flags)) {
|
|
1220
|
+
console.error("basalt-ui doctor: --tokens-only and --framework are alternatives \u2014 pass one.");
|
|
1221
|
+
return 1;
|
|
1222
|
+
}
|
|
1223
|
+
const project = resolveProjectDir(invocationCwd);
|
|
1224
|
+
if (project.ambiguous !== null) {
|
|
1225
|
+
console.error(
|
|
1226
|
+
`
|
|
1227
|
+
basalt-ui doctor \u2014 ${invocationCwd}
|
|
1228
|
+
|
|
1229
|
+
\u2716 no basalt config at ${invocationCwd}, and ${project.ambiguous.length} workspace packages carry one (${project.ambiguous.map((d) => relativePosix(invocationCwd, d)).join(", ")}) \u2014 run doctor from one of them, or set BASALT_CWD to pick.
|
|
1230
|
+
|
|
1231
|
+
1 hard failure(s), 0 warning(s).
|
|
1232
|
+
`
|
|
1233
|
+
);
|
|
1234
|
+
return 1;
|
|
1235
|
+
}
|
|
1236
|
+
const cwd = project.dir;
|
|
884
1237
|
const cfg = readBasaltConfig(cwd);
|
|
885
|
-
const result = { hardFailures: 0, warnings: 0 };
|
|
1238
|
+
const result = { hardFailures: 0, warnings: 0, skipped: 0 };
|
|
886
1239
|
const lines = [`
|
|
887
1240
|
basalt-ui doctor \u2014 ${cwd}
|
|
888
1241
|
`];
|
|
@@ -893,26 +1246,59 @@ basalt-ui doctor \u2014 ${cwd}
|
|
|
893
1246
|
lines.push(` \u26A0 ${msg}`);
|
|
894
1247
|
result.warnings++;
|
|
895
1248
|
}
|
|
1249
|
+
function skip(msg) {
|
|
1250
|
+
lines.push(` \u2298 SKIPPED \u2014 ${msg}`);
|
|
1251
|
+
result.skipped++;
|
|
1252
|
+
}
|
|
896
1253
|
function fail(msg) {
|
|
897
1254
|
lines.push(` \u2716 ${msg}`);
|
|
898
1255
|
result.hardFailures++;
|
|
899
1256
|
}
|
|
1257
|
+
if (project.relocatedFrom !== null) {
|
|
1258
|
+
lines.push(
|
|
1259
|
+
` \u2192 no basalt config at ${project.relocatedFrom} \u2014 reporting on ${relativePosix(project.relocatedFrom, cwd)}, where it lives.
|
|
1260
|
+
`
|
|
1261
|
+
);
|
|
1262
|
+
}
|
|
900
1263
|
const manifestAbs = resolve(cwd, MANIFEST_PATH);
|
|
901
1264
|
const manifestExists = existsSync(manifestAbs);
|
|
902
|
-
|
|
1265
|
+
const profile = inferredProfile(cwd, cfg, flags);
|
|
1266
|
+
if (profile === "tokens-only") {
|
|
1267
|
+
lines.push(
|
|
1268
|
+
" profile: tokens-only \u2014 checking the token layer only. The scaffold checks below are not\n applicable; `basalt-ui init` is NOT the fix here, it places a Mantine doctrine you have no\n use for. Pass --framework to force the full profile.\n"
|
|
1269
|
+
);
|
|
1270
|
+
if (cfg.profile === void 0) {
|
|
1271
|
+
lines.push(
|
|
1272
|
+
' \u2192 inferred (no .basalt/manifest.json and no @mantine/core in this workspace). Write it\n down as "basalt": { "profile": "tokens-only" } in package.json \u2014 check-theme does not\n infer it, so today it still reports the Mantine-only kinds against this repo.\n'
|
|
1273
|
+
);
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
if (profile === "tokens-only") {
|
|
1277
|
+
pass(`${MANIFEST_PATH}: n/a \u2014 a tokens-only consumer has no scaffold to reconcile`);
|
|
1278
|
+
} else if (manifestExists) {
|
|
903
1279
|
pass(`${MANIFEST_PATH} exists`);
|
|
904
1280
|
} else {
|
|
905
1281
|
fail(`${MANIFEST_PATH} missing \u2014 run \`basalt-ui init\` to scaffold the consumer repo`);
|
|
906
1282
|
}
|
|
907
|
-
const
|
|
908
|
-
|
|
909
|
-
if (
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
1283
|
+
const install = findBasaltInstall(cwd);
|
|
1284
|
+
const installedVersion = install.version;
|
|
1285
|
+
if (install.dir === null) {
|
|
1286
|
+
fail(
|
|
1287
|
+
`basalt-ui does not resolve from ${cwd}, any directory above it, or any workspace package \u2014 so every version check below is unrunnable, \`extends: [<preset>]\` cannot resolve, and \`bunx basalt-ui\` will silently fetch a DIFFERENT copy from npm instead of your pinned one. Add basalt-ui as a dependency here (or at the repo root) and re-run.`
|
|
1288
|
+
);
|
|
1289
|
+
} else if (install.how === "cwd") {
|
|
1290
|
+
pass(
|
|
1291
|
+
`basalt-ui resolves at ./node_modules/basalt-ui (${installedVersion ?? "unknown version"})`
|
|
1292
|
+
);
|
|
1293
|
+
} else {
|
|
1294
|
+
pass(
|
|
1295
|
+
`basalt-ui resolves at ${relativePosix(cwd, install.dir)} (${installedVersion ?? "unknown version"}, found via the ${install.how}) \u2014 seeded \`extends\` paths and CI steps must use that path, not ./node_modules/basalt-ui.`
|
|
1296
|
+
);
|
|
1297
|
+
}
|
|
1298
|
+
if (profile === "framework" && manifestExists && installedVersion === null) {
|
|
1299
|
+
skip(
|
|
1300
|
+
"installed-vs-manifest version \u2014 basalt-ui did not resolve, so there is nothing to compare"
|
|
1301
|
+
);
|
|
916
1302
|
}
|
|
917
1303
|
if (manifestExists && installedVersion !== null) {
|
|
918
1304
|
const manifestVersion = readManifest(cwd).basaltVersion;
|
|
@@ -930,11 +1316,17 @@ basalt-ui doctor \u2014 ${cwd}
|
|
|
930
1316
|
}
|
|
931
1317
|
const cliVersion = readFrameworkVersion(packageRoot());
|
|
932
1318
|
const cliIsTheInstall = installedVersion === null || installedVersion === cliVersion;
|
|
933
|
-
if (
|
|
1319
|
+
if (profile === "tokens-only") {
|
|
1320
|
+
pass("spacing scale: n/a \u2014 a tokens-only consumer renders no basalt Mantine theme");
|
|
1321
|
+
} else if (!manifestExists) {
|
|
1322
|
+
skip(
|
|
1323
|
+
`spacing scale \u2014 no ${MANIFEST_PATH} records the scale this app last synced with, so there is nothing to compare against`
|
|
1324
|
+
);
|
|
1325
|
+
} else if (!cliIsTheInstall) {
|
|
934
1326
|
pass(
|
|
935
1327
|
`spacing scale not compared \u2014 this CLI (${cliVersion}) is not the installed basalt-ui (${installedVersion}), so its scale is not the one the app renders with`
|
|
936
1328
|
);
|
|
937
|
-
} else
|
|
1329
|
+
} else {
|
|
938
1330
|
const recorded = readManifest(cwd).spacingScale;
|
|
939
1331
|
const current = deriveSpacing(0).scale;
|
|
940
1332
|
if (recorded === void 0) {
|
|
@@ -951,17 +1343,54 @@ basalt-ui doctor \u2014 ${cwd}
|
|
|
951
1343
|
}
|
|
952
1344
|
}
|
|
953
1345
|
}
|
|
954
|
-
if (installedVersion
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
1346
|
+
if (installedVersion === null) {
|
|
1347
|
+
skip("CLI-vs-installed version \u2014 basalt-ui did not resolve, so there is no installed version");
|
|
1348
|
+
} else if (installedVersion !== cliVersion) {
|
|
1349
|
+
warn(
|
|
1350
|
+
`running CLI version (${cliVersion}) differs from the installed basalt-ui version at ${install.dir === null ? "node_modules" : relativePosix(cwd, install.dir)} (${installedVersion}) \u2014 likely a stale \`bunx basalt-ui\` fetch from npm; run the local bin (\`node_modules/.bin/basalt-ui\`, or a package.json script) so the pinned copy is the one that runs.`
|
|
1351
|
+
);
|
|
1352
|
+
} else {
|
|
1353
|
+
pass(`CLI version (${cliVersion}) matches the installed basalt-ui`);
|
|
1354
|
+
}
|
|
1355
|
+
if (profile === "tokens-only") {
|
|
1356
|
+
pass("guard-scan: n/a \u2014 the theme guard is a Mantine-app check");
|
|
1357
|
+
} else {
|
|
1358
|
+
const scanned = scannableFiles(cwd, cfg);
|
|
1359
|
+
if (scanned.length === 0) {
|
|
1360
|
+
fail(
|
|
1361
|
+
`guard-scan: check-theme would scan 0 files \u2014 the roots (${resolveRoots(cfg).join(", ")}) match nothing under ${cwd}, so the palette guard is a no-op that still exits green in every gate that does not run it. Set "basalt": { "roots": [...] } in package.json.`
|
|
958
1362
|
);
|
|
959
1363
|
} else {
|
|
960
|
-
pass(
|
|
1364
|
+
pass(
|
|
1365
|
+
`guard-scan: check-theme covers ${scanned.length} file(s) under ${resolveRoots(cfg).join(", ")}`
|
|
1366
|
+
);
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
if (profile === "tokens-only") {
|
|
1370
|
+
pass("oxlint-preset: n/a \u2014 the shipped preset is a Mantine/React preset");
|
|
1371
|
+
} else {
|
|
1372
|
+
const oxlintrcRaw = readIfExists(resolve(cwd, ".oxlintrc.json"));
|
|
1373
|
+
if (oxlintrcRaw === null) {
|
|
1374
|
+
fail(
|
|
1375
|
+
".oxlintrc.json missing \u2014 the shipped oxlint preset (the basalt/* design rules) is not active. Run `basalt-ui init` to seed it."
|
|
1376
|
+
);
|
|
1377
|
+
} else {
|
|
1378
|
+
const parsed = parseJsonc(oxlintrcRaw);
|
|
1379
|
+
if (parsed === null) {
|
|
1380
|
+
skip(
|
|
1381
|
+
".oxlintrc.json does not parse as JSON/JSONC \u2014 cannot tell whether it extends the preset"
|
|
1382
|
+
);
|
|
1383
|
+
} else if (extendsBasaltPreset(parsed["extends"])) {
|
|
1384
|
+
pass(".oxlintrc.json extends the shipped basalt-ui oxlint preset");
|
|
1385
|
+
} else {
|
|
1386
|
+
fail(
|
|
1387
|
+
`.oxlintrc.json does NOT extend the shipped preset \u2014 every basalt/* design rule is off and oxlint reports green without them. Add "extends": ["${shippedAssetPath(install, cwd, "configs/oxlint.json")}"], or re-run \`basalt-ui init --merge-lint\` to splice it in.`
|
|
1388
|
+
);
|
|
1389
|
+
}
|
|
961
1390
|
}
|
|
962
1391
|
}
|
|
963
1392
|
const publicDir = resolve(cwd, "public");
|
|
964
|
-
if (existsSync(publicDir)) {
|
|
1393
|
+
if (profile === "framework" && existsSync(publicDir)) {
|
|
965
1394
|
const iconFiles = [
|
|
966
1395
|
"favicon.ico",
|
|
967
1396
|
"favicon.svg",
|
|
@@ -1013,15 +1442,17 @@ basalt-ui doctor \u2014 ${cwd}
|
|
|
1013
1442
|
pass("ai-major-parity: no workspace package declares the ai dependency \u2014 nothing to compare");
|
|
1014
1443
|
}
|
|
1015
1444
|
lines.push("");
|
|
1016
|
-
if (result.hardFailures === 0 && result.warnings === 0) {
|
|
1445
|
+
if (result.hardFailures === 0 && result.warnings === 0 && result.skipped === 0) {
|
|
1017
1446
|
lines.push("All checks passed.");
|
|
1018
1447
|
} else {
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1448
|
+
const parts = [];
|
|
1449
|
+
if (result.hardFailures > 0) parts.push(`${result.hardFailures} hard failure(s)`);
|
|
1450
|
+
if (result.skipped > 0) parts.push(`${result.skipped} check(s) SKIPPED (could not run)`);
|
|
1451
|
+
parts.push(`${result.warnings} warning(s)`);
|
|
1452
|
+
lines.push(`${parts.join(", ")}.`);
|
|
1022
1453
|
}
|
|
1023
1454
|
lines.push("");
|
|
1024
|
-
if (result.hardFailures > 0) {
|
|
1455
|
+
if (result.hardFailures > 0 || result.skipped > 0) {
|
|
1025
1456
|
console.error(lines.join("\n"));
|
|
1026
1457
|
return 1;
|
|
1027
1458
|
}
|
|
@@ -1101,8 +1532,61 @@ function flagValue(flags, name) {
|
|
|
1101
1532
|
const value = flags[i + 1];
|
|
1102
1533
|
return value === void 0 || value.startsWith("--") ? void 0 : value;
|
|
1103
1534
|
}
|
|
1535
|
+
function generatedHeader(version, command, flags) {
|
|
1536
|
+
const shown = flags.filter((f) => f !== "--check");
|
|
1537
|
+
const invocation = shown.length === 0 ? command : `${command} ${shown.join(" ")}`;
|
|
1538
|
+
return `${GENERATED_HEADER_LINE}
|
|
1539
|
+
/* basalt-ui ${version} \u2014 \`basalt-ui ${invocation}\` */
|
|
1540
|
+
`;
|
|
1541
|
+
}
|
|
1542
|
+
function normalizeColorFunctions(css) {
|
|
1543
|
+
return css.replace(/\b(rgba?|hsla?)\(([^()]*)\)/g, (_match, fn, args) => {
|
|
1544
|
+
const parts = args.split(",").map((part) => part.trim());
|
|
1545
|
+
return `${fn}(${parts.join(", ")})`;
|
|
1546
|
+
});
|
|
1547
|
+
}
|
|
1548
|
+
const SCHEME_CLASS_SENTINEL = "data-basalt-scheme-class";
|
|
1549
|
+
function emitGeneratedCss(content, flags, cwd, command) {
|
|
1550
|
+
const out = flagValue(flags, "--out");
|
|
1551
|
+
const check = flags.includes("--check");
|
|
1552
|
+
if (out === void 0) {
|
|
1553
|
+
if (check) {
|
|
1554
|
+
console.error(
|
|
1555
|
+
`${command}: --check needs --out <path> \u2014 there is nothing to compare stdout to.`
|
|
1556
|
+
);
|
|
1557
|
+
return 1;
|
|
1558
|
+
}
|
|
1559
|
+
process.stdout.write(content);
|
|
1560
|
+
return 0;
|
|
1561
|
+
}
|
|
1562
|
+
const target = isAbsolute(out) ? out : resolve(cwd, out);
|
|
1563
|
+
const shown = relative(cwd, target) || target;
|
|
1564
|
+
if (check) {
|
|
1565
|
+
const onDisk = readIfExists(target);
|
|
1566
|
+
if (onDisk === null) {
|
|
1567
|
+
console.error(
|
|
1568
|
+
`\u2716 ${command} --check: ${shown} does not exist \u2014 run without --check to write it.`
|
|
1569
|
+
);
|
|
1570
|
+
return 1;
|
|
1571
|
+
}
|
|
1572
|
+
if (onDisk === content) {
|
|
1573
|
+
console.log(`\u2713 ${command} --check: ${shown} is up to date.`);
|
|
1574
|
+
return 0;
|
|
1575
|
+
}
|
|
1576
|
+
console.error(
|
|
1577
|
+
`\u2716 ${command} --check: ${shown} differs from what \`basalt-ui ${command}\` emits today (on disk ${onDisk.split("\n").length} lines, emitted ${content.split("\n").length}) \u2014 re-run the same command without --check and commit the result.`
|
|
1578
|
+
);
|
|
1579
|
+
return 1;
|
|
1580
|
+
}
|
|
1581
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
1582
|
+
writeFileSync(target, content);
|
|
1583
|
+
console.log(`wrote ${content.split("\n").length} lines \u2192 ${shown}`);
|
|
1584
|
+
return 0;
|
|
1585
|
+
}
|
|
1104
1586
|
function tokensCss(flags, cwd = process.cwd()) {
|
|
1105
1587
|
const attribute = flagValue(flags, "--selector-attribute");
|
|
1588
|
+
const schemeClass = flagValue(flags, "--selector-class");
|
|
1589
|
+
const lightClass = flagValue(flags, "--light-class");
|
|
1106
1590
|
const darkValue = flagValue(flags, "--dark-value");
|
|
1107
1591
|
const lightValue = flagValue(flags, "--light-value");
|
|
1108
1592
|
const defaultScheme = flagValue(flags, "--default-scheme");
|
|
@@ -1117,12 +1601,31 @@ function tokensCss(flags, cwd = process.cwd()) {
|
|
|
1117
1601
|
console.error(`tokens:css: --only must be core or all (got '${only}')`);
|
|
1118
1602
|
return 1;
|
|
1119
1603
|
}
|
|
1604
|
+
if (schemeClass !== void 0 && attribute !== void 0) {
|
|
1605
|
+
console.error(
|
|
1606
|
+
"tokens:css: --selector-class and --selector-attribute are alternatives \u2014 pass one."
|
|
1607
|
+
);
|
|
1608
|
+
return 1;
|
|
1609
|
+
}
|
|
1610
|
+
if (schemeClass !== void 0 && /[^\w-]/.test(schemeClass)) {
|
|
1611
|
+
console.error(
|
|
1612
|
+
`tokens:css: --selector-class must be a plain CSS class name (got '${schemeClass}')`
|
|
1613
|
+
);
|
|
1614
|
+
return 1;
|
|
1615
|
+
}
|
|
1616
|
+
if (lightClass !== void 0 && /[^\w-]/.test(lightClass)) {
|
|
1617
|
+
console.error(`tokens:css: --light-class must be a plain CSS class name (got '${lightClass}')`);
|
|
1618
|
+
return 1;
|
|
1619
|
+
}
|
|
1620
|
+
const resolvedDarkValue = schemeClass !== void 0 ? schemeClass : darkValue;
|
|
1621
|
+
const resolvedLightValue = schemeClass !== void 0 ? lightClass ?? "light" : lightValue;
|
|
1622
|
+
const resolvedAttribute = schemeClass !== void 0 ? SCHEME_CLASS_SENTINEL : attribute;
|
|
1120
1623
|
const scheme = {
|
|
1121
|
-
...
|
|
1122
|
-
...
|
|
1123
|
-
...
|
|
1624
|
+
...resolvedAttribute === void 0 ? {} : { attribute: resolvedAttribute },
|
|
1625
|
+
...resolvedDarkValue === void 0 ? {} : { darkValue: resolvedDarkValue },
|
|
1626
|
+
...resolvedLightValue === void 0 ? {} : { lightValue: resolvedLightValue }
|
|
1124
1627
|
};
|
|
1125
|
-
|
|
1628
|
+
let css = buildPaletteCss({
|
|
1126
1629
|
...Object.keys(scheme).length === 0 ? {} : { scheme },
|
|
1127
1630
|
...defaultScheme === void 0 ? {} : { defaultScheme },
|
|
1128
1631
|
...flags.includes("--media-fallback") ? { mediaFallback: true } : {},
|
|
@@ -1133,17 +1636,44 @@ function tokensCss(flags, cwd = process.cwd()) {
|
|
|
1133
1636
|
// are, which is the one thing this command is documented never to allow.
|
|
1134
1637
|
...flags.includes("--no-legacy-aliases") ? { legacyAliases: false } : {}
|
|
1135
1638
|
});
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
`
|
|
1140
|
-
|
|
1639
|
+
if (schemeClass !== void 0) {
|
|
1640
|
+
css = css.replace(
|
|
1641
|
+
new RegExp(`\\[${SCHEME_CLASS_SENTINEL}='([\\w-]+)'\\]`, "g"),
|
|
1642
|
+
(_match, value) => `.${value}`
|
|
1643
|
+
);
|
|
1141
1644
|
}
|
|
1142
|
-
const
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1645
|
+
const version = readFrameworkVersion(packageRoot());
|
|
1646
|
+
const content = `${generatedHeader(version, "tokens:css", flags)}${normalizeColorFunctions(css)}
|
|
1647
|
+
`;
|
|
1648
|
+
return emitGeneratedCss(content, flags, cwd, "tokens:css");
|
|
1649
|
+
}
|
|
1650
|
+
function readShippedFontDecls(pkgRoot) {
|
|
1651
|
+
const css = readSource(pkgRoot, "dist/styles.css") ?? readSource(pkgRoot, "src/styles.css");
|
|
1652
|
+
if (css === null) return [];
|
|
1653
|
+
const decls = [];
|
|
1654
|
+
for (const match of css.matchAll(/(--basalt-font[\w-]*)\s*:\s*([^;]+);/g)) {
|
|
1655
|
+
decls.push({
|
|
1656
|
+
name: match[1],
|
|
1657
|
+
value: match[2].replace(/\s+/g, " ").trim()
|
|
1658
|
+
});
|
|
1659
|
+
}
|
|
1660
|
+
return decls;
|
|
1661
|
+
}
|
|
1662
|
+
function fontsCss(flags, cwd = process.cwd()) {
|
|
1663
|
+
const pkgRoot = packageRoot();
|
|
1664
|
+
const decls = readShippedFontDecls(pkgRoot);
|
|
1665
|
+
if (decls.length === 0) {
|
|
1666
|
+
console.error(
|
|
1667
|
+
"fonts:css: could not read the shipped font stacks from styles.css \u2014 this is a packaging bug in basalt-ui, not a config error."
|
|
1668
|
+
);
|
|
1669
|
+
return 1;
|
|
1670
|
+
}
|
|
1671
|
+
const body = `:root {
|
|
1672
|
+
${decls.map((d) => ` ${d.name}: ${d.value};`).join("\n")}
|
|
1673
|
+
}
|
|
1674
|
+
`;
|
|
1675
|
+
const header = generatedHeader(readFrameworkVersion(pkgRoot), "fonts:css", flags);
|
|
1676
|
+
return emitGeneratedCss(`${header}${body}`, flags, cwd, "fonts:css");
|
|
1147
1677
|
}
|
|
1148
1678
|
async function guardHook(cwd = process.cwd()) {
|
|
1149
1679
|
let raw;
|
|
@@ -1190,7 +1720,10 @@ async function guardHook(cwd = process.cwd()) {
|
|
|
1190
1720
|
sub16InputFont: cfg.sub16InputFont ?? DEFAULT_GUARD_CONFIG.sub16InputFont,
|
|
1191
1721
|
allowComment: "theme-allow",
|
|
1192
1722
|
exemptRules: cfg.exemptRules ?? DEFAULT_GUARD_CONFIG.exemptRules,
|
|
1193
|
-
severity: cfg.severity ?? DEFAULT_GUARD_CONFIG.severity
|
|
1723
|
+
severity: cfg.severity ?? DEFAULT_GUARD_CONFIG.severity,
|
|
1724
|
+
// Same detection check-theme uses: the hook must never block an edit over advice the app
|
|
1725
|
+
// cannot take ("use @mantine/core's Select") in a repo with no Mantine.
|
|
1726
|
+
...declaredProfile(cfg, []) === "tokens-only" ? { profile: "tokens-only" } : {}
|
|
1194
1727
|
};
|
|
1195
1728
|
const roots = cfg.roots ?? DEFAULT_ROOTS;
|
|
1196
1729
|
const exempt = new Set(cfg.exempt ?? defaultExempt(cfg));
|
|
@@ -1222,7 +1755,7 @@ async function guardHook(cwd = process.cwd()) {
|
|
|
1222
1755
|
}
|
|
1223
1756
|
return 0;
|
|
1224
1757
|
}
|
|
1225
|
-
const USAGE = "Usage: basalt-ui <init [--with-router] [--with-query] | sync [--force] [--check]
|
|
1758
|
+
const USAGE = "Usage: basalt-ui <init [--with-router] [--with-query] [--merge-lint] | sync [--force] [--check] |\n check-theme | check-coverage | info [--json] | doctor [--tokens-only|--framework] |\n guard-hook | tokens:css | fonts:css | help>\n\ntokens:css [--out <path>] [--check] [--selector-attribute <attr> | --selector-class <class>]\n [--light-class <class>] [--dark-value <v>] [--light-value <v>]\n [--default-scheme <dark|light|none>] [--media-fallback] [--only <core|all>]\n [--no-legacy-aliases]\n Emit the --vx-* stylesheet (stdout unless --out). Defaults reproduce basalt-ui/tokens.css.\n --selector-class emits `:root.dark` instead of an attribute selector (the Tailwind convention).\n --check writes nothing and exits 1 when --out differs from what would be emitted (a CI gate).\n --no-legacy-aliases drops the deprecated camelCase spellings (--vx-accentFill and friends),\n which are emitted by default as aliases of the canonical kebab-case names.\n\nfonts:css [--out <path>] [--check]\n Emit the shipped --basalt-font-* stacks as plain CSS \u2014 the typeface half of the token layer,\n otherwise reachable only by importing styles.css.\n\ndoctor [--tokens-only|--framework]\n Auto-detects a tokens-only consumer (no manifest + no @mantine/core) and checks only what\n applies. A check that cannot RUN is reported as SKIPPED and exits non-zero.\n\ncheck-theme / doctor honour BASALT_CWD, and relocate to the single workspace package carrying a\nbasalt config when invoked from a repo root that has none \u2014 several carrying one is ambiguous\nand exits 1 in BOTH. --tokens-only and --framework are alternatives; passing both is an error.\n\nEvery subcommand accepts --help / -h to print this message and exit without running.";
|
|
1226
1759
|
function run(argv, cwd = process.cwd()) {
|
|
1227
1760
|
const [cmd, ...flags] = argv;
|
|
1228
1761
|
if (cmd === "help" || cmd === "--help" || cmd === "-h" || flags.includes("--help") || flags.includes("-h")) {
|
|
@@ -1233,22 +1766,25 @@ function run(argv, cwd = process.cwd()) {
|
|
|
1233
1766
|
case "init":
|
|
1234
1767
|
return init(cwd, {
|
|
1235
1768
|
withRouter: flags.includes("--with-router"),
|
|
1236
|
-
withQuery: flags.includes("--with-query")
|
|
1769
|
+
withQuery: flags.includes("--with-query"),
|
|
1770
|
+
mergeLint: flags.includes("--merge-lint")
|
|
1237
1771
|
});
|
|
1238
1772
|
case "sync":
|
|
1239
1773
|
return sync({ force: flags.includes("--force"), check: flags.includes("--check") }, cwd);
|
|
1240
1774
|
case "check-theme":
|
|
1241
|
-
return checkTheme(cwd);
|
|
1775
|
+
return checkTheme(cwd, flags);
|
|
1242
1776
|
case "check-coverage":
|
|
1243
1777
|
return checkCoverage();
|
|
1244
1778
|
case "info":
|
|
1245
1779
|
return info(flags);
|
|
1246
1780
|
case "doctor":
|
|
1247
|
-
return doctor(cwd);
|
|
1781
|
+
return doctor(cwd, flags);
|
|
1248
1782
|
case "guard-hook":
|
|
1249
1783
|
return guardHook(cwd);
|
|
1250
1784
|
case "tokens:css":
|
|
1251
1785
|
return tokensCss(flags, cwd);
|
|
1786
|
+
case "fonts:css":
|
|
1787
|
+
return fontsCss(flags, cwd);
|
|
1252
1788
|
default:
|
|
1253
1789
|
console.error(USAGE);
|
|
1254
1790
|
return 1;
|
|
@@ -1261,10 +1797,12 @@ export {
|
|
|
1261
1797
|
checkCoverage,
|
|
1262
1798
|
checkTheme,
|
|
1263
1799
|
doctor,
|
|
1800
|
+
fontsCss,
|
|
1264
1801
|
guardHook,
|
|
1265
1802
|
info,
|
|
1266
1803
|
init,
|
|
1267
1804
|
managedFiles,
|
|
1805
|
+
normalizeColorFunctions,
|
|
1268
1806
|
normalizeForLedger,
|
|
1269
1807
|
run,
|
|
1270
1808
|
sync,
|