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/src/cli/index.ts
CHANGED
|
@@ -32,7 +32,13 @@ import {
|
|
|
32
32
|
import { dirname, isAbsolute, relative, resolve } from 'node:path'
|
|
33
33
|
import { fileURLToPath } from 'node:url'
|
|
34
34
|
|
|
35
|
-
import {
|
|
35
|
+
import {
|
|
36
|
+
checkSource,
|
|
37
|
+
DEFAULT_GUARD_CONFIG,
|
|
38
|
+
GENERATED_HEADER_LINE,
|
|
39
|
+
GUARD_RULES,
|
|
40
|
+
TOKENS_ONLY_DISABLED_KINDS,
|
|
41
|
+
} from '../guard'
|
|
36
42
|
import type { Finding, GuardConfig, GuardKind, GuardSeverity } from '../guard'
|
|
37
43
|
import { evaluateGuardHook } from '../guard/guard-hook'
|
|
38
44
|
import { RULE_NAMES, SKILL_NAMES, SURFACES, TOKEN_LAYER_BOUNDARY_SURFACES } from '../surfaces'
|
|
@@ -45,6 +51,27 @@ export type BasaltConfig = {
|
|
|
45
51
|
roots?: string[]
|
|
46
52
|
/** Files exempt from the scan (they ARE the palette source). Default: argo's exempt set. */
|
|
47
53
|
exempt?: string[]
|
|
54
|
+
/**
|
|
55
|
+
* Declares this package a tokens-only consumer: it uses the `--vx-*` layer and no Mantine. Turns
|
|
56
|
+
* off every guard kind whose remedy is a Mantine component, a Mantine prop or the React theme
|
|
57
|
+
* factory, leaving the color and typography kinds live. Default: `'mantine'`.
|
|
58
|
+
*
|
|
59
|
+
* Deliberately not inferred from a missing `@mantine/core`: silencing 16 rules is a decision, and
|
|
60
|
+
* a repo that keeps Mantine in a workspace package would otherwise switch off half its own guard
|
|
61
|
+
* without saying so. `basalt-ui doctor` detects the shape and tells you to set this.
|
|
62
|
+
*/
|
|
63
|
+
profile?: 'mantine' | 'tokens-only'
|
|
64
|
+
/**
|
|
65
|
+
* Extra files to scan, named individually and relative to this package — for a design surface
|
|
66
|
+
* that lives outside every root and outside the `index.html` / `public/` conventions the walker
|
|
67
|
+
* already derives. Also the ONLY way a `.json` is ever scanned: the guard understands JSON as
|
|
68
|
+
* markup, but a consumer repo's JSON is overwhelmingly config, fixtures and lockfiles, so
|
|
69
|
+
* blanket-scanning it is a false-positive generator rather than a guard.
|
|
70
|
+
*
|
|
71
|
+
* @example
|
|
72
|
+
* { include: ['app/manifest.json', 'emails/template.html'] }
|
|
73
|
+
*/
|
|
74
|
+
include?: string[]
|
|
48
75
|
/** Named spacing-scale steps (px) flagged when used as a raw spacing prop. Default: 10/12/16/20/32. */
|
|
49
76
|
spacingSteps?: number[]
|
|
50
77
|
/** Per-kind severity override — `'warn'` reports without failing, `'error'` fails the build.
|
|
@@ -284,10 +311,22 @@ function readBasaltConfig(cwd: string): BasaltConfig {
|
|
|
284
311
|
}
|
|
285
312
|
|
|
286
313
|
/**
|
|
287
|
-
*
|
|
288
|
-
*
|
|
314
|
+
* Extensions the walker collects. `html?`/`webmanifest` close argo's finding that raw hex ships in
|
|
315
|
+
* `index.html`'s `theme-color` and the webmanifest's `theme_color`/`background_color` — the two
|
|
316
|
+
* places whose colors nothing re-derives on a scheme change, and which the scan never reached.
|
|
317
|
+
*/
|
|
318
|
+
const SCANNABLE_EXT = /\.(?:tsx?|css|html?|webmanifest)$/
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Recursively collect scannable files under a root, skipping dependency/build dirs. Node+Bun-safe.
|
|
289
322
|
* `checkSource` is already syntax-aware per extension (`guardSyntaxFor`); the walker was the only
|
|
290
|
-
* gap.
|
|
323
|
+
* gap, twice over — `.css` (CSS modules / `styles.css`) and now `.html`/`.webmanifest`.
|
|
324
|
+
*
|
|
325
|
+
* `.json` is deliberately NOT here even though the guard resolves it as markup: a consumer repo is
|
|
326
|
+
* full of JSON that is configuration, fixtures and lockfiles, and blanket-scanning it produces
|
|
327
|
+
* exactly the class of false positive already reported against a `data.json` test fixture. A JSON
|
|
328
|
+
* that IS a design surface (a `manifest.json` rather than a `*.webmanifest`) is reachable by naming
|
|
329
|
+
* it in `basalt.include`.
|
|
291
330
|
*/
|
|
292
331
|
function walkSourceFiles(dir: string, out: string[] = []): string[] {
|
|
293
332
|
let entries: string[]
|
|
@@ -306,7 +345,7 @@ function walkSourceFiles(dir: string, out: string[] = []): string[] {
|
|
|
306
345
|
continue
|
|
307
346
|
}
|
|
308
347
|
if (isDir) walkSourceFiles(abs, out)
|
|
309
|
-
else if (
|
|
348
|
+
else if (SCANNABLE_EXT.test(name)) out.push(abs)
|
|
310
349
|
}
|
|
311
350
|
return out
|
|
312
351
|
}
|
|
@@ -362,15 +401,288 @@ const GUARD_KIND_EXPLANATIONS: Partial<Record<GuardKind, string>> = {
|
|
|
362
401
|
'--basalt-font-sans|head|mono vars instead of a hardcoded fontFamily literal.',
|
|
363
402
|
}
|
|
364
403
|
|
|
404
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
405
|
+
// Resolution — where basalt is installed, and where the consumer's basalt config lives
|
|
406
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
407
|
+
|
|
408
|
+
/** A path relative to `fromDir`, POSIX-separated and always explicitly relative (`./` / `../`). */
|
|
409
|
+
function relativePosix(fromDir: string, toPath: string): string {
|
|
410
|
+
const rel = relative(fromDir, toPath).replace(/\\/g, '/')
|
|
411
|
+
if (rel === '') return '.'
|
|
412
|
+
return rel.startsWith('.') ? rel : `./${rel}`
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Where `basalt-ui` actually resolves from, seen from a consumer directory.
|
|
417
|
+
*
|
|
418
|
+
* Every toolchain seam basalt seeds (`.oxlintrc.json`'s `extends`, `lefthook.yml`'s `extends`, the
|
|
419
|
+
* CI `run:` steps) used to hardcode `./node_modules/basalt-ui` at the repo ROOT. Under bun's
|
|
420
|
+
* isolated linker a library package that declares basalt a peer + devDependency gets
|
|
421
|
+
* `<pkg>/node_modules/basalt-ui` and NOTHING at the root, so all three silently failed to resolve
|
|
422
|
+
* and `bunx` fetched a second, different copy from npm instead. Resolution therefore walks
|
|
423
|
+
* OUTWARD from the consumer dir first, then across the workspace packages — and every caller
|
|
424
|
+
* renders its paths from the answer rather than assuming the root.
|
|
425
|
+
*/
|
|
426
|
+
type BasaltInstall = {
|
|
427
|
+
/** Absolute path of the resolved `node_modules/basalt-ui` directory, or null when unresolvable. */
|
|
428
|
+
dir: string | null
|
|
429
|
+
/** The resolved copy's own version, or null. */
|
|
430
|
+
version: string | null
|
|
431
|
+
/** How it was found — `null` when unresolved. `'workspace'` is the isolated-linker case. */
|
|
432
|
+
how: 'cwd' | 'ancestor' | 'workspace' | null
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const UNRESOLVED_INSTALL: BasaltInstall = { dir: null, version: null, how: null }
|
|
436
|
+
|
|
437
|
+
/** `<dir>/node_modules/basalt-ui` when it holds a readable package.json, else null. */
|
|
438
|
+
function basaltInstallAt(dir: string): { dir: string; version: string | null } | null {
|
|
439
|
+
const installDir = resolve(dir, 'node_modules', 'basalt-ui')
|
|
440
|
+
const raw = readIfExists(resolve(installDir, 'package.json'))
|
|
441
|
+
if (raw === null) return null
|
|
442
|
+
try {
|
|
443
|
+
const pkg = JSON.parse(raw) as { version?: string }
|
|
444
|
+
return { dir: installDir, version: pkg.version ?? null }
|
|
445
|
+
} catch {
|
|
446
|
+
return { dir: installDir, version: null }
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** Resolve the installed basalt-ui: cwd, then every ancestor, then the workspace packages. */
|
|
451
|
+
function findBasaltInstall(cwd: string): BasaltInstall {
|
|
452
|
+
const own = basaltInstallAt(cwd)
|
|
453
|
+
if (own !== null) return { ...own, how: 'cwd' }
|
|
454
|
+
|
|
455
|
+
let current = dirname(cwd)
|
|
456
|
+
for (;;) {
|
|
457
|
+
const found = basaltInstallAt(current)
|
|
458
|
+
if (found !== null) return { ...found, how: 'ancestor' }
|
|
459
|
+
const parent = dirname(current)
|
|
460
|
+
if (parent === current) break
|
|
461
|
+
current = parent
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
for (const pkg of collectWorkspacePackages(cwd).packages) {
|
|
465
|
+
if (pkg.dir === cwd) continue
|
|
466
|
+
const found = basaltInstallAt(pkg.dir)
|
|
467
|
+
if (found !== null) return { ...found, how: 'workspace' }
|
|
468
|
+
}
|
|
469
|
+
return UNRESOLVED_INSTALL
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* The `extends`/`run:` path a seeded config should carry to reach a shipped asset — rendered from
|
|
474
|
+
* where basalt ACTUALLY resolves, relative to the directory the config lives in. Falls back to the
|
|
475
|
+
* root-relative form when nothing resolves (a pre-install `init`), so the seed is still the shape a
|
|
476
|
+
* later `bun install` makes true.
|
|
477
|
+
*/
|
|
478
|
+
function shippedAssetPath(install: BasaltInstall, fromDir: string, asset: string): string {
|
|
479
|
+
if (install.dir === null) return `./node_modules/basalt-ui/${asset}`
|
|
480
|
+
return `${relativePosix(fromDir, install.dir)}/${asset}`
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** True when `dir` is a basalt-configured project — it carries the manifest or a `basalt` key. */
|
|
484
|
+
function hasBasaltProject(dir: string): boolean {
|
|
485
|
+
if (existsSync(resolve(dir, MANIFEST_PATH))) return true
|
|
486
|
+
const raw = readIfExists(resolve(dir, 'package.json'))
|
|
487
|
+
if (raw === null) return false
|
|
488
|
+
try {
|
|
489
|
+
return (JSON.parse(raw) as { basalt?: unknown }).basalt !== undefined
|
|
490
|
+
} catch {
|
|
491
|
+
return false
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Which directory a project-scoped command (`check-theme`, `doctor`) should actually read.
|
|
497
|
+
*
|
|
498
|
+
* The shipped lefthook preset and every `bunx basalt-ui …` in the seeded CI run at the REPO ROOT,
|
|
499
|
+
* while in a monorepo the `basalt` config and the manifest live in a package below it — verified to
|
|
500
|
+
* produce `0 files scanned` and a `manifest missing` failure, i.e. a gate that never gates. Rather
|
|
501
|
+
* than make every consumer hand-write a `--cwd`, a command with no basalt project at its own cwd
|
|
502
|
+
* relocates to the single workspace package that has one, and says so. Two or more candidates is
|
|
503
|
+
* genuinely ambiguous and reported as such; `BASALT_CWD` is the explicit override.
|
|
504
|
+
*/
|
|
505
|
+
type ProjectResolution = {
|
|
506
|
+
/** The directory to read. Equals the invocation cwd unless a relocation happened. */
|
|
507
|
+
dir: string
|
|
508
|
+
/** The invocation cwd when the command relocated away from it, else null. */
|
|
509
|
+
relocatedFrom: string | null
|
|
510
|
+
/** Candidate project dirs when relocation was impossible because several exist, else null. */
|
|
511
|
+
ambiguous: string[] | null
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function resolveProjectDir(cwd: string): ProjectResolution {
|
|
515
|
+
const override = process.env['BASALT_CWD']
|
|
516
|
+
if (override !== undefined && override.length > 0) {
|
|
517
|
+
const dir = isAbsolute(override) ? override : resolve(cwd, override)
|
|
518
|
+
return { dir, relocatedFrom: dir === cwd ? null : cwd, ambiguous: null }
|
|
519
|
+
}
|
|
520
|
+
if (hasBasaltProject(cwd)) return { dir: cwd, relocatedFrom: null, ambiguous: null }
|
|
521
|
+
|
|
522
|
+
const candidates = collectWorkspacePackages(cwd)
|
|
523
|
+
.packages.filter((pkg) => pkg.dir !== cwd && hasBasaltProject(pkg.dir))
|
|
524
|
+
.map((pkg) => pkg.dir)
|
|
525
|
+
if (candidates.length === 1) {
|
|
526
|
+
return { dir: candidates[0] as string, relocatedFrom: cwd, ambiguous: null }
|
|
527
|
+
}
|
|
528
|
+
if (candidates.length > 1) return { dir: cwd, relocatedFrom: null, ambiguous: candidates }
|
|
529
|
+
return { dir: cwd, relocatedFrom: null, ambiguous: null }
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Which shape of consumer a project-scoped command is looking at.
|
|
534
|
+
*
|
|
535
|
+
* A tokens-only consumer took the `--vx-*` layer and nothing else. Every guard kind whose remedy is
|
|
536
|
+
* a Mantine component or the React theme factory is meaningless there — telling a Mantine-free app
|
|
537
|
+
* to swap its `<select>` for `@mantine/core`'s `Select` is advice it must not take.
|
|
538
|
+
*
|
|
539
|
+
* DECLARED, never inferred, when the answer SILENCES something. The two callers want opposite
|
|
540
|
+
* failure directions and so do not share a default:
|
|
541
|
+
*
|
|
542
|
+
* - `check-theme` (`declaredProfile`) turns 17 kinds OFF, so it moves on an explicit signal only —
|
|
543
|
+
* `--tokens-only`, or `"basalt": { "profile": "tokens-only" }`. Inferring it from the ABSENCE of
|
|
544
|
+
* `@mantine/core` would silence the Mantine half of the guard on any repo that keeps its Mantine
|
|
545
|
+
* dependency in a workspace package rather than the one holding the basalt config, which is
|
|
546
|
+
* precisely this round's "reports green while enforcing nothing" failure with the guard's own
|
|
547
|
+
* hand on the switch.
|
|
548
|
+
* - `doctor` ({@link inferredProfile}) only changes which ADVICE it prints, never what it enforces,
|
|
549
|
+
* so it may infer from absence and then tell the consumer to write the key down.
|
|
550
|
+
*/
|
|
551
|
+
function declaredProfile(cfg: BasaltConfig, flags: readonly string[]): DoctorProfile {
|
|
552
|
+
if (flags.includes('--tokens-only')) return 'tokens-only'
|
|
553
|
+
if (flags.includes('--framework')) return 'framework'
|
|
554
|
+
return cfg.profile === 'tokens-only' ? 'tokens-only' : 'framework'
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* `--tokens-only` and `--framework` are alternatives, so passing both is a contradiction rather
|
|
559
|
+
* than a precedence question — the same reading `tokens:css` already applies to `--selector-class`
|
|
560
|
+
* vs `--selector-attribute`. Silently preferring one meant a CI step that accumulated both flags
|
|
561
|
+
* enforced whichever the code happened to test first.
|
|
562
|
+
*/
|
|
563
|
+
function conflictingProfileFlags(flags: readonly string[]): boolean {
|
|
564
|
+
return flags.includes('--tokens-only') && flags.includes('--framework')
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/** True when nothing anywhere in the workspace declares Mantine — see {@link inferredProfile}. */
|
|
568
|
+
function workspaceHasMantine(cwd: string): boolean {
|
|
569
|
+
if (hasDependency(cwd, '@mantine/core')) return true
|
|
570
|
+
return collectWorkspacePackages(cwd).packages.some((pkg) =>
|
|
571
|
+
hasDependency(pkg.dir, '@mantine/core'),
|
|
572
|
+
)
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* `doctor`'s profile: the declaration if there is one, else inferred from no scaffold manifest and
|
|
577
|
+
* no `@mantine/core` anywhere in the workspace. Advice only — see {@link declaredProfile}.
|
|
578
|
+
*/
|
|
579
|
+
function inferredProfile(cwd: string, cfg: BasaltConfig, flags: readonly string[]): DoctorProfile {
|
|
580
|
+
if (
|
|
581
|
+
flags.includes('--tokens-only') ||
|
|
582
|
+
flags.includes('--framework') ||
|
|
583
|
+
cfg.profile !== undefined
|
|
584
|
+
) {
|
|
585
|
+
return declaredProfile(cfg, flags)
|
|
586
|
+
}
|
|
587
|
+
return !existsSync(resolve(cwd, MANIFEST_PATH)) && !workspaceHasMantine(cwd)
|
|
588
|
+
? 'tokens-only'
|
|
589
|
+
: 'framework'
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* The relative paths `check-theme` would actually scan for a given config — the ONE place the walk
|
|
594
|
+
* + `SKIP` + `exempt` filtering lives, so `doctor`'s "the guard sees zero files" check can never
|
|
595
|
+
* disagree with what `check-theme` does.
|
|
596
|
+
*/
|
|
597
|
+
function scannableFiles(cwd: string, cfg: BasaltConfig): string[] {
|
|
598
|
+
const exempt = new Set(cfg.exempt ?? defaultExempt(cfg))
|
|
599
|
+
const seen = new Set<string>()
|
|
600
|
+
const out: string[] = []
|
|
601
|
+
const add = (abs: string): void => {
|
|
602
|
+
// Normalize to forward slashes so path matching (SKIP, exempt, isChartFile) is
|
|
603
|
+
// identical on Windows (where `relative` yields backslashes) and POSIX.
|
|
604
|
+
const rel = relative(cwd, abs).replace(/\\/g, '/')
|
|
605
|
+
if (rel.startsWith('..') || SKIP.test(rel) || exempt.has(rel) || seen.has(rel)) return
|
|
606
|
+
seen.add(rel)
|
|
607
|
+
out.push(rel)
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
for (const root of resolveRoots(cfg)) {
|
|
611
|
+
const rootAbs = resolve(cwd, root)
|
|
612
|
+
for (const f of walkSourceFiles(rootAbs)) add(f)
|
|
613
|
+
for (const f of appShellFiles(rootAbs)) add(f)
|
|
614
|
+
}
|
|
615
|
+
// Explicitly named files, the one route by which a `.json` is ever scanned.
|
|
616
|
+
for (const rel of cfg.include ?? []) add(resolve(cwd, rel))
|
|
617
|
+
return out
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* The app-shell files that sit BESIDE a source root rather than inside it — `index.html` and
|
|
622
|
+
* anything under `public/`.
|
|
623
|
+
*
|
|
624
|
+
* Widening the walker's extension filter alone would still have missed every one of them: argo
|
|
625
|
+
* configures `roots: ['apps/dashboard/src']`, while the raw hex lives in `apps/dashboard/index.html`
|
|
626
|
+
* and `apps/dashboard/public/site.webmanifest`. Both locations are the Vite convention basalt's own
|
|
627
|
+
* `basaltViteConfig`/`basaltAppPlugin` assume, so deriving them from each root's parent needs no
|
|
628
|
+
* config and holds for a monorepo package the same way it holds at a repo root. Nothing else in the
|
|
629
|
+
* parent is walked — a sibling `docs/` full of throwaway HTML stays unscanned.
|
|
630
|
+
*/
|
|
631
|
+
function appShellFiles(rootAbs: string): string[] {
|
|
632
|
+
const appDir = dirname(rootAbs)
|
|
633
|
+
const out: string[] = []
|
|
634
|
+
for (const name of ['index.html', 'index.htm']) {
|
|
635
|
+
const abs = resolve(appDir, name)
|
|
636
|
+
if (existsSync(abs)) out.push(abs)
|
|
637
|
+
}
|
|
638
|
+
out.push(...walkSourceFiles(resolve(appDir, 'public')))
|
|
639
|
+
return out
|
|
640
|
+
}
|
|
641
|
+
|
|
365
642
|
/**
|
|
366
643
|
* Theme guard — thin FS walker over the headless `../guard` core. Reads BasaltConfig, builds a
|
|
367
644
|
* GuardConfig, walks roots, calls checkSource per file, collects Finding[], groups/reports, returns
|
|
368
645
|
* 0 (clean) / 1 (violations). A `theme-allow` comment exempts a line.
|
|
646
|
+
*
|
|
647
|
+
* Runs against `resolveProjectDir(cwd)`, not `cwd` — see that type's doc for why a root-invoked
|
|
648
|
+
* hook or CI step has to find the package the config lives in rather than scan nothing and pass.
|
|
369
649
|
*/
|
|
370
|
-
export function checkTheme(
|
|
650
|
+
export function checkTheme(
|
|
651
|
+
invocationCwd: string = process.cwd(),
|
|
652
|
+
flags: readonly string[] = [],
|
|
653
|
+
): number {
|
|
654
|
+
if (conflictingProfileFlags(flags)) {
|
|
655
|
+
console.error(
|
|
656
|
+
'basalt-ui check-theme: --tokens-only and --framework are alternatives — pass one.',
|
|
657
|
+
)
|
|
658
|
+
return 1
|
|
659
|
+
}
|
|
660
|
+
const project = resolveProjectDir(invocationCwd)
|
|
661
|
+
if (project.ambiguous !== null) {
|
|
662
|
+
console.error(
|
|
663
|
+
`✖ basalt-ui check-theme: no basalt config at ${invocationCwd}, and ${project.ambiguous.length} ` +
|
|
664
|
+
`workspace packages carry one (${project.ambiguous.map((d) => relativePosix(invocationCwd, d)).join(', ')}) — ` +
|
|
665
|
+
'run it from one of them, or set BASALT_CWD to pick.',
|
|
666
|
+
)
|
|
667
|
+
return 1
|
|
668
|
+
}
|
|
669
|
+
const cwd = project.dir
|
|
670
|
+
if (project.relocatedFrom !== null) {
|
|
671
|
+
console.log(
|
|
672
|
+
`basalt-ui check-theme: no basalt config at ${project.relocatedFrom} — running in ` +
|
|
673
|
+
`${relativePosix(project.relocatedFrom, cwd)}, where it lives.`,
|
|
674
|
+
)
|
|
675
|
+
}
|
|
371
676
|
const cfg = readBasaltConfig(cwd)
|
|
372
|
-
const roots = cfg
|
|
373
|
-
const
|
|
677
|
+
const roots = resolveRoots(cfg)
|
|
678
|
+
const profile = declaredProfile(cfg, flags)
|
|
679
|
+
if (profile === 'tokens-only') {
|
|
680
|
+
console.log(
|
|
681
|
+
'basalt-ui check-theme: tokens-only profile — ' +
|
|
682
|
+
`${TOKENS_ONLY_DISABLED_KINDS.size} Mantine-coupled kinds are off; the color and typography ` +
|
|
683
|
+
'kinds still apply. Pass --framework to force the full set.',
|
|
684
|
+
)
|
|
685
|
+
}
|
|
374
686
|
|
|
375
687
|
const guardCfg: GuardConfig = {
|
|
376
688
|
spacingSteps: cfg.spacingSteps ?? DEFAULT_GUARD_CONFIG.spacingSteps,
|
|
@@ -392,23 +704,16 @@ export function checkTheme(cwd: string = process.cwd()): number {
|
|
|
392
704
|
allowComment: 'theme-allow',
|
|
393
705
|
exemptRules: cfg.exemptRules ?? DEFAULT_GUARD_CONFIG.exemptRules,
|
|
394
706
|
severity: cfg.severity ?? DEFAULT_GUARD_CONFIG.severity,
|
|
707
|
+
...(profile === 'tokens-only' ? { profile: 'tokens-only' as const } : {}),
|
|
395
708
|
}
|
|
396
709
|
|
|
397
710
|
const findings: Finding[] = []
|
|
398
|
-
|
|
399
|
-
for (const
|
|
400
|
-
|
|
401
|
-
// Normalize to forward slashes so path matching (SKIP, exempt, isChartFile) is
|
|
402
|
-
// identical on Windows (where `relative` yields backslashes) and POSIX.
|
|
403
|
-
const rel = relative(cwd, f).replace(/\\/g, '/')
|
|
404
|
-
if (SKIP.test(rel) || exempt.has(rel)) continue
|
|
405
|
-
scannedCount += 1
|
|
406
|
-
const text = readFileSync(f, 'utf8')
|
|
407
|
-
findings.push(...checkSource(text, rel, guardCfg))
|
|
408
|
-
}
|
|
711
|
+
const scanned = scannableFiles(cwd, cfg)
|
|
712
|
+
for (const rel of scanned) {
|
|
713
|
+
findings.push(...checkSource(readFileSync(resolve(cwd, rel), 'utf8'), rel, guardCfg))
|
|
409
714
|
}
|
|
410
715
|
|
|
411
|
-
if (
|
|
716
|
+
if (scanned.length === 0) {
|
|
412
717
|
// A configured-but-wrong root is never intentional, and silently scanning 0 files under the
|
|
413
718
|
// built-in defaults (argo's pre-migration layout) is the same failure mode for every other
|
|
414
719
|
// consumer — both cases fail loud instead of warn-plus-green.
|
|
@@ -557,6 +862,16 @@ type TemplateVars = {
|
|
|
557
862
|
SERIES_MODULE_PATH: string
|
|
558
863
|
/** The consumer's configured roots as space-separated quoted oxfmt globs, e.g. `'apps/web/src/**'`. */
|
|
559
864
|
ROOTS_GLOBS: string
|
|
865
|
+
/** Path to the shipped oxlint preset from the consumer dir — RESOLVED, never assumed at the root. */
|
|
866
|
+
OXLINT_PRESET_PATH: string
|
|
867
|
+
/** Path to the shipped lefthook preset from the consumer dir — same resolution. */
|
|
868
|
+
LEFTHOOK_PRESET_PATH: string
|
|
869
|
+
/**
|
|
870
|
+
* How a seeded script or CI step invokes the CLI. The locally-installed bin when basalt resolves
|
|
871
|
+
* (so the step can never silently run a DIFFERENT version that `bunx` fetched from npm), and
|
|
872
|
+
* `bunx basalt-ui` only as the pre-install fallback.
|
|
873
|
+
*/
|
|
874
|
+
BASALT_BIN: string
|
|
560
875
|
}
|
|
561
876
|
|
|
562
877
|
// The block markers the CLAUDE.md template emits. The begin marker carries the framework version
|
|
@@ -603,12 +918,20 @@ function readFrameworkVersion(pkgRoot: string): string {
|
|
|
603
918
|
|
|
604
919
|
/** Build the template-variable map from the framework root + consumer cwd/config. */
|
|
605
920
|
function buildTemplateVars(pkgRoot: string, cwd: string, cfg: BasaltConfig): TemplateVars {
|
|
921
|
+
const install = findBasaltInstall(cwd)
|
|
922
|
+
const binDir = install.dir === null ? null : resolve(install.dir, '..', '.bin')
|
|
606
923
|
return {
|
|
607
924
|
APP_NAME: readPackageName(cwd),
|
|
608
925
|
BASALT_VERSION: readFrameworkVersion(pkgRoot),
|
|
609
926
|
ACCENT_HUE: cfg.accentHue ?? 'blue',
|
|
610
927
|
SERIES_MODULE_PATH: resolveSeriesModulePath(cfg),
|
|
611
928
|
ROOTS_GLOBS: resolveRoots(cfg).map(toRootGlob).join(' '),
|
|
929
|
+
OXLINT_PRESET_PATH: shippedAssetPath(install, cwd, 'configs/oxlint.json'),
|
|
930
|
+
LEFTHOOK_PRESET_PATH: shippedAssetPath(install, cwd, 'configs/lefthook.yml'),
|
|
931
|
+
BASALT_BIN:
|
|
932
|
+
binDir === null
|
|
933
|
+
? 'bunx basalt-ui'
|
|
934
|
+
: `${relativePosix(cwd, binDir).replace(/^\.\//, '')}/basalt-ui`,
|
|
612
935
|
}
|
|
613
936
|
}
|
|
614
937
|
|
|
@@ -658,7 +981,12 @@ function applyBlock(host: string, block: string): string {
|
|
|
658
981
|
* Explicit `--with-router` / `--with-query` CLI flags force-include a router/query scaffold even
|
|
659
982
|
* when the peer isn't detected in the consumer's package.json yet (e.g. it's about to be added).
|
|
660
983
|
*/
|
|
661
|
-
type ScaffoldFlags = {
|
|
984
|
+
type ScaffoldFlags = {
|
|
985
|
+
withRouter?: boolean
|
|
986
|
+
withQuery?: boolean
|
|
987
|
+
/** `--merge-lint`: splice the shipped preset's `extends` into an `.oxlintrc.json` init would keep. */
|
|
988
|
+
mergeLint?: boolean
|
|
989
|
+
}
|
|
662
990
|
|
|
663
991
|
/** Router/query peer presence, resolved from detection + explicit flags. */
|
|
664
992
|
type PeerFlags = { hasRouter: boolean; hasQuery: boolean }
|
|
@@ -854,12 +1182,15 @@ function managedFiles(peers: PeerFlags, placement: PlacementFlags = ROOT_PLACEME
|
|
|
854
1182
|
dest: 'lefthook.yml',
|
|
855
1183
|
mode: 'seed',
|
|
856
1184
|
source: 'configs/lefthook.yml',
|
|
857
|
-
render: () =>
|
|
1185
|
+
render: (ctx) =>
|
|
858
1186
|
'# Seeded by basalt-ui init — you own this file; sync never touches it again.\n' +
|
|
859
1187
|
'# The extends target supplies oxlint + oxfmt + check-theme pre-commit jobs and\n' +
|
|
860
1188
|
'# auto-updates with the basalt-ui package. Add your own commands alongside.\n' +
|
|
1189
|
+
'# The path is RESOLVED from where basalt-ui actually installed, not assumed at the\n' +
|
|
1190
|
+
'# repo root — under an isolated linker it lives beside the package that depends on it.\n' +
|
|
1191
|
+
'# Run `lefthook install` once: the file is inert until the git hooks are written.\n' +
|
|
861
1192
|
'extends:\n' +
|
|
862
|
-
|
|
1193
|
+
` - ${ctx.vars.LEFTHOOK_PRESET_PATH.replace(/^\.\//, '')}\n`,
|
|
863
1194
|
}
|
|
864
1195
|
|
|
865
1196
|
// CI is inherently repo-shaped (a monorepo's check.yml needs its own scripts), and GitHub Actions
|
|
@@ -885,7 +1216,7 @@ function managedFiles(peers: PeerFlags, placement: PlacementFlags = ROOT_PLACEME
|
|
|
885
1216
|
dest: '.oxlintrc.json',
|
|
886
1217
|
mode: 'seed',
|
|
887
1218
|
source: 'configs/oxlint.json',
|
|
888
|
-
render: () =>
|
|
1219
|
+
render: (ctx) => `{\n "extends": ["${ctx.vars.OXLINT_PRESET_PATH}"]\n}\n`,
|
|
889
1220
|
}
|
|
890
1221
|
|
|
891
1222
|
// ── Seed scaffolds: written by init, never overwritten by sync ─────────────
|
|
@@ -1157,17 +1488,227 @@ function renderContext(cwd: string): RenderContext {
|
|
|
1157
1488
|
return { pkgRoot, vars: buildTemplateVars(pkgRoot, cwd, readBasaltConfig(cwd)) }
|
|
1158
1489
|
}
|
|
1159
1490
|
|
|
1491
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
1492
|
+
// init — describing the repo it is actually in
|
|
1493
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
1494
|
+
|
|
1495
|
+
/**
|
|
1496
|
+
* Read → mutate → write the consumer's package.json, preserving its own indentation and trailing
|
|
1497
|
+
* newline. `mutate` returns false to abort the write (nothing to change). Returns whether a write
|
|
1498
|
+
* happened. Deliberately a JSON round-trip rather than a textual splice: key ORDER is preserved by
|
|
1499
|
+
* `JSON.parse`/`stringify`, and anything fancier would be a formatter this CLI has no business being.
|
|
1500
|
+
*/
|
|
1501
|
+
function patchPackageJson(cwd: string, mutate: (pkg: Record<string, unknown>) => boolean): boolean {
|
|
1502
|
+
const abs = resolve(cwd, 'package.json')
|
|
1503
|
+
const raw = readIfExists(abs)
|
|
1504
|
+
if (raw === null) return false
|
|
1505
|
+
let pkg: Record<string, unknown>
|
|
1506
|
+
try {
|
|
1507
|
+
pkg = JSON.parse(raw) as Record<string, unknown>
|
|
1508
|
+
} catch {
|
|
1509
|
+
return false
|
|
1510
|
+
}
|
|
1511
|
+
if (!mutate(pkg)) return false
|
|
1512
|
+
const indent = /\n([ \t]+)"/.exec(raw)?.[1] ?? ' '
|
|
1513
|
+
writeFileSync(abs, JSON.stringify(pkg, null, indent) + (raw.endsWith('\n') ? '\n' : ''))
|
|
1514
|
+
return true
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
/**
|
|
1518
|
+
* The source roots for THIS repo, inferred from its actual layout — the fix for the single most
|
|
1519
|
+
* reported false-green: without `basalt.roots` a workspace repo scaffolds a `check-theme` that
|
|
1520
|
+
* scans zero files and a CI `oxfmt 'src/**'` that matches nothing, while `doctor` reports green.
|
|
1521
|
+
*
|
|
1522
|
+
* A workspace repo resolves to the `src/` of every workspace package that depends on basalt or
|
|
1523
|
+
* Mantine (a backend package's `src/` has nothing for a design guard to say); with no such package
|
|
1524
|
+
* it falls back to every workspace `src/`, and a single-package repo to plain `src`. Empty when
|
|
1525
|
+
* nothing plausible exists — `init` says so rather than writing a root that matches nothing.
|
|
1526
|
+
*/
|
|
1527
|
+
function detectRoots(cwd: string): string[] {
|
|
1528
|
+
const workspacePackages = collectWorkspacePackages(cwd).packages.filter((pkg) => pkg.dir !== cwd)
|
|
1529
|
+
const withSrc = workspacePackages.filter((pkg) => existsSync(resolve(pkg.dir, 'src')))
|
|
1530
|
+
const uiPackages = withSrc.filter(
|
|
1531
|
+
(pkg) => hasDependency(pkg.dir, 'basalt-ui') || hasDependency(pkg.dir, '@mantine/core'),
|
|
1532
|
+
)
|
|
1533
|
+
const chosen = uiPackages.length > 0 ? uiPackages : withSrc
|
|
1534
|
+
if (chosen.length > 0) {
|
|
1535
|
+
return chosen.map((pkg) => relativePosix(cwd, resolve(pkg.dir, 'src')).replace(/^\.\//, ''))
|
|
1536
|
+
}
|
|
1537
|
+
return existsSync(resolve(cwd, DEFAULT_ROOT)) ? [DEFAULT_ROOT] : []
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
/**
|
|
1541
|
+
* Strip JSONC comments (and trailing commas) so `.oxlintrc.json` can be READ. oxlint accepts JSONC
|
|
1542
|
+
* and real consumer configs use it — a plain `JSON.parse` there reported "not valid JSON" on a
|
|
1543
|
+
* perfectly good config. String-aware, so a `//` inside a value survives.
|
|
1544
|
+
*
|
|
1545
|
+
* Only ever used for reading. The trailing-comma pass is a plain regex and could in principle mangle
|
|
1546
|
+
* a `, }` sequence inside a string; the failure direction is a parse error, which the caller already
|
|
1547
|
+
* treats as "cannot tell", never as "passes".
|
|
1548
|
+
*/
|
|
1549
|
+
function stripJsonc(text: string): string {
|
|
1550
|
+
let out = ''
|
|
1551
|
+
let inString = false
|
|
1552
|
+
let escaped = false
|
|
1553
|
+
let i = 0
|
|
1554
|
+
while (i < text.length) {
|
|
1555
|
+
const ch = text[i] as string
|
|
1556
|
+
if (inString) {
|
|
1557
|
+
out += ch
|
|
1558
|
+
if (escaped) escaped = false
|
|
1559
|
+
else if (ch === '\\') escaped = true
|
|
1560
|
+
else if (ch === '"') inString = false
|
|
1561
|
+
i += 1
|
|
1562
|
+
continue
|
|
1563
|
+
}
|
|
1564
|
+
if (ch === '"') {
|
|
1565
|
+
inString = true
|
|
1566
|
+
out += ch
|
|
1567
|
+
i += 1
|
|
1568
|
+
continue
|
|
1569
|
+
}
|
|
1570
|
+
if (ch === '/' && text[i + 1] === '/') {
|
|
1571
|
+
while (i < text.length && text[i] !== '\n') i += 1
|
|
1572
|
+
continue
|
|
1573
|
+
}
|
|
1574
|
+
if (ch === '/' && text[i + 1] === '*') {
|
|
1575
|
+
i += 2
|
|
1576
|
+
while (i < text.length && !(text[i] === '*' && text[i + 1] === '/')) i += 1
|
|
1577
|
+
i += 2
|
|
1578
|
+
continue
|
|
1579
|
+
}
|
|
1580
|
+
out += ch
|
|
1581
|
+
i += 1
|
|
1582
|
+
}
|
|
1583
|
+
return out.replace(/,(\s*[}\]])/g, '$1')
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
/** Parse a JSONC document, or null when it does not parse at all. */
|
|
1587
|
+
function parseJsonc(text: string): Record<string, unknown> | null {
|
|
1588
|
+
try {
|
|
1589
|
+
return JSON.parse(stripJsonc(text)) as Record<string, unknown>
|
|
1590
|
+
} catch {
|
|
1591
|
+
return null
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
/** Outcome of splicing the shipped preset into an `.oxlintrc.json` the consumer already had. */
|
|
1596
|
+
type MergeLintResult = 'added' | 'already' | 'absent' | 'unreadable' | 'has-comments'
|
|
1597
|
+
|
|
1598
|
+
/** True when an `extends` entry points at the shipped preset, wherever basalt resolved from. */
|
|
1599
|
+
function extendsBasaltPreset(entries: unknown): boolean {
|
|
1600
|
+
return (
|
|
1601
|
+
Array.isArray(entries) &&
|
|
1602
|
+
entries.some(
|
|
1603
|
+
(entry) => typeof entry === 'string' && entry.endsWith('basalt-ui/configs/oxlint.json'),
|
|
1604
|
+
)
|
|
1605
|
+
)
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
/**
|
|
1609
|
+
* Splice the shipped preset into an EXISTING `.oxlintrc.json` (`init --merge-lint`). Opt-in, not
|
|
1610
|
+
* automatic: turning the framework on adds real lint debt to previously-clean code (see the
|
|
1611
|
+
* lint-debt notice `init` prints), and that is a decision, not a scaffold step. Prepends the preset
|
|
1612
|
+
* so the consumer's own `extends` entries still win.
|
|
1613
|
+
*/
|
|
1614
|
+
function mergeOxlintExtends(cwd: string, presetPath: string): MergeLintResult {
|
|
1615
|
+
const abs = resolve(cwd, '.oxlintrc.json')
|
|
1616
|
+
const raw = readIfExists(abs)
|
|
1617
|
+
if (raw === null) return 'absent'
|
|
1618
|
+
const cfg = parseJsonc(raw)
|
|
1619
|
+
if (cfg === null) return 'unreadable'
|
|
1620
|
+
if (extendsBasaltPreset(cfg['extends'])) return 'already'
|
|
1621
|
+
// Rewriting a JSONC config through JSON.stringify would silently delete the consumer's comments,
|
|
1622
|
+
// which in a lint config are usually the WHY of every disabled rule. Refuse and say so.
|
|
1623
|
+
if (stripJsonc(raw) !== raw) return 'has-comments'
|
|
1624
|
+
const existing = Array.isArray(cfg['extends'])
|
|
1625
|
+
? (cfg['extends'] as unknown[]).filter((entry) => typeof entry === 'string')
|
|
1626
|
+
: []
|
|
1627
|
+
// Rebuild rather than spread, so `extends` keeps its original position when it was already there
|
|
1628
|
+
// and lands first when it wasn't — a diff a human reviews should not reshuffle the whole file.
|
|
1629
|
+
const merged: Record<string, unknown> = 'extends' in cfg ? {} : { extends: [presetPath] }
|
|
1630
|
+
for (const [key, value] of Object.entries(cfg)) {
|
|
1631
|
+
merged[key] = key === 'extends' ? [presetPath, ...existing] : value
|
|
1632
|
+
}
|
|
1633
|
+
const indent = /\n([ \t]+)"/.exec(raw)?.[1] ?? ' '
|
|
1634
|
+
writeFileSync(abs, JSON.stringify(merged, null, indent) + (raw.endsWith('\n') ? '\n' : ''))
|
|
1635
|
+
return 'added'
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
/**
|
|
1639
|
+
* What a consumer LOSES by `init` keeping a file it already had — the half the aggregate
|
|
1640
|
+
* `20 written, 2 kept` never said. A kept `.oxlintrc.json` is the headline case: the framework's
|
|
1641
|
+
* whole lint half stays off, and every gate downstream reports green over it.
|
|
1642
|
+
*/
|
|
1643
|
+
const KEPT_FILE_COST: Record<string, string> = {
|
|
1644
|
+
'.oxlintrc.json':
|
|
1645
|
+
'the shipped oxlint preset (the basalt/* design rules AND the jsx-a11y / import / promise / ' +
|
|
1646
|
+
'unicorn plugins) is NOT active — nothing else turns it on, and every gate stays green ' +
|
|
1647
|
+
'without it. Re-run with `--merge-lint` to splice the extends in, or add it by hand.',
|
|
1648
|
+
'lefthook.yml':
|
|
1649
|
+
'the pre-commit oxlint / oxfmt / check-theme jobs are not wired — add `extends: ' +
|
|
1650
|
+
'[<preset>]` to your own file.',
|
|
1651
|
+
'.github/workflows/check.yml':
|
|
1652
|
+
'CI does not run check-theme or the sync drift gate — copy those steps into your own workflow.',
|
|
1653
|
+
'.oxfmtrc.json': 'your existing formatter config stands; basalt formats nothing on its own.',
|
|
1654
|
+
'DESIGN.md': 'the design brief Claude reads is yours, not the seeded one.',
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1160
1657
|
/** Scaffold all managed files into the consumer repo, then write the manifest. Idempotent. Returns 0. */
|
|
1161
1658
|
export function init(cwd: string = process.cwd(), scaffoldFlags: ScaffoldFlags = {}): number {
|
|
1659
|
+
// ── Describe the repo BEFORE rendering anything ───────────────────────────
|
|
1660
|
+
// Every roots-derived seed (the CI oxfmt globs, DESIGN.md's series path, the default scan
|
|
1661
|
+
// exemption) renders from `basalt.roots`, so the key has to exist first. Writing it is what
|
|
1662
|
+
// stops a workspace repo scaffolding a guard that scans zero files while doctor reports green.
|
|
1663
|
+
const install = findBasaltInstall(cwd)
|
|
1664
|
+
if (install.dir === null) {
|
|
1665
|
+
console.error(
|
|
1666
|
+
`⚠ basalt-ui init: basalt-ui does not resolve from ${cwd} or any directory above it — the ` +
|
|
1667
|
+
'seeded `extends` paths and CI steps are being written against the repo root by assumption. ' +
|
|
1668
|
+
'Install basalt-ui here (or at the repo root) and re-run `basalt-ui init` so they resolve.',
|
|
1669
|
+
)
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
let seededRoots: string[] | null = null
|
|
1673
|
+
let rootsUndetectable = false
|
|
1674
|
+
if (readBasaltConfig(cwd).roots === undefined) {
|
|
1675
|
+
const detected = detectRoots(cwd)
|
|
1676
|
+
if (detected.length === 0) {
|
|
1677
|
+
rootsUndetectable = true
|
|
1678
|
+
} else if (
|
|
1679
|
+
patchPackageJson(cwd, (pkg) => {
|
|
1680
|
+
const basalt = (pkg['basalt'] ?? {}) as Record<string, unknown>
|
|
1681
|
+
if (basalt['roots'] !== undefined) return false
|
|
1682
|
+
pkg['basalt'] = { ...basalt, roots: detected }
|
|
1683
|
+
return true
|
|
1684
|
+
})
|
|
1685
|
+
) {
|
|
1686
|
+
seededRoots = detected
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
// Built AFTER the roots patch — every roots-derived template variable reads the key just written.
|
|
1162
1691
|
const ctx = renderContext(cwd)
|
|
1692
|
+
|
|
1693
|
+
// `basalt-tokens.md` tells the consumer to wire `oxlint . && basalt-ui check-theme` into their
|
|
1694
|
+
// lint, and init's own closing message says to run check-theme next — but nothing ever added a
|
|
1695
|
+
// script, so the guard stayed manual for exactly as long as someone remembered it.
|
|
1696
|
+
const lintScript = `oxlint . && ${ctx.vars.BASALT_BIN} check-theme`
|
|
1697
|
+
const seededLintScript = patchPackageJson(cwd, (pkg) => {
|
|
1698
|
+
const scripts = (pkg['scripts'] ?? {}) as Record<string, unknown>
|
|
1699
|
+
if (scripts['lint:basalt'] !== undefined) return false
|
|
1700
|
+
pkg['scripts'] = { ...scripts, 'lint:basalt': lintScript }
|
|
1701
|
+
return true
|
|
1702
|
+
})
|
|
1703
|
+
|
|
1163
1704
|
const manifest = readManifest(cwd)
|
|
1164
1705
|
migrateLegacyOxfmt(cwd, ctx.pkgRoot, manifest)
|
|
1165
1706
|
const peers = resolvePeerFlags(cwd, scaffoldFlags)
|
|
1166
1707
|
const placement = resolvePlacement(cwd)
|
|
1167
1708
|
const files = managedFiles(peers, placement)
|
|
1168
1709
|
|
|
1169
|
-
|
|
1170
|
-
|
|
1710
|
+
const writtenFiles: string[] = []
|
|
1711
|
+
const keptFiles: string[] = []
|
|
1171
1712
|
const missingSources: string[] = []
|
|
1172
1713
|
|
|
1173
1714
|
for (const file of files) {
|
|
@@ -1186,25 +1727,70 @@ export function init(cwd: string = process.cwd(), scaffoldFlags: ScaffoldFlags =
|
|
|
1186
1727
|
// as locally drifted (skip unless --force), never silently clobbering a file the consumer
|
|
1187
1728
|
// authored before init.
|
|
1188
1729
|
manifest.files[file.dest] = sha256(normalizeForLedger(state.desired))
|
|
1189
|
-
|
|
1730
|
+
keptFiles.push(file.dest)
|
|
1190
1731
|
continue
|
|
1191
1732
|
}
|
|
1192
1733
|
|
|
1193
1734
|
const hash = writeUnit(file, cwd, state.desired)
|
|
1194
1735
|
manifest.files[file.dest] = hash
|
|
1195
|
-
|
|
1736
|
+
writtenFiles.push(file.dest)
|
|
1196
1737
|
}
|
|
1197
1738
|
|
|
1739
|
+
const mergeLint: MergeLintResult | null =
|
|
1740
|
+
scaffoldFlags.mergeLint === true ? mergeOxlintExtends(cwd, ctx.vars.OXLINT_PRESET_PATH) : null
|
|
1741
|
+
|
|
1198
1742
|
manifest.basaltVersion = ctx.vars.BASALT_VERSION
|
|
1199
1743
|
manifest.spacingScale = { ...deriveSpacing(0).scale }
|
|
1200
1744
|
writeFileEnsuringDir(resolve(cwd, MANIFEST_PATH), `${JSON.stringify(manifest, null, 2)}\n`)
|
|
1201
1745
|
|
|
1202
|
-
console.log(
|
|
1746
|
+
console.log(
|
|
1747
|
+
`basalt-ui init: ${writtenFiles.length} written, ${keptFiles.length} kept, manifest at ${MANIFEST_PATH}`,
|
|
1748
|
+
)
|
|
1203
1749
|
if (missingSources.length > 0) {
|
|
1204
1750
|
console.log(
|
|
1205
1751
|
`basalt-ui init: ${missingSources.length} shipped asset(s) not present, skipped: ${missingSources.join(', ')}`,
|
|
1206
1752
|
)
|
|
1207
1753
|
}
|
|
1754
|
+
// Naming the kept files is the fix for the report's headline false-green: `20 written, 2 kept`
|
|
1755
|
+
// named neither the file nor the consequence, so a repo that already had an `.oxlintrc.json`
|
|
1756
|
+
// ended up with the framework's lint half off and every gate downstream reporting green.
|
|
1757
|
+
if (keptFiles.length > 0) {
|
|
1758
|
+
console.log(`\nKept (already present — basalt did not touch them):`)
|
|
1759
|
+
for (const dest of keptFiles) {
|
|
1760
|
+
const cost = KEPT_FILE_COST[dest]
|
|
1761
|
+
console.log(cost === undefined ? ` · ${dest}` : ` · ${dest} — ${cost}`)
|
|
1762
|
+
}
|
|
1763
|
+
}
|
|
1764
|
+
if (mergeLint !== null) {
|
|
1765
|
+
const mergeMessage: Record<MergeLintResult, string> = {
|
|
1766
|
+
added: `spliced "${ctx.vars.OXLINT_PRESET_PATH}" into the existing .oxlintrc.json extends`,
|
|
1767
|
+
already: '.oxlintrc.json already extends the shipped preset — nothing to do',
|
|
1768
|
+
absent:
|
|
1769
|
+
'no existing .oxlintrc.json to merge into (the seeded one already extends the preset)',
|
|
1770
|
+
unreadable: '.oxlintrc.json is not valid JSON — merge the extends entry by hand',
|
|
1771
|
+
'has-comments':
|
|
1772
|
+
'.oxlintrc.json carries comments; rewriting it would delete them (usually the reason each ' +
|
|
1773
|
+
`rule is off). Add "extends": ["${ctx.vars.OXLINT_PRESET_PATH}"] as the first entry by hand.`,
|
|
1774
|
+
}
|
|
1775
|
+
console.log(`basalt-ui init: --merge-lint — ${mergeMessage[mergeLint]}`)
|
|
1776
|
+
}
|
|
1777
|
+
if (seededRoots !== null) {
|
|
1778
|
+
console.log(
|
|
1779
|
+
`basalt-ui init: wrote "basalt": { "roots": ${JSON.stringify(seededRoots)} } to package.json ` +
|
|
1780
|
+
'— check-theme, the CI oxfmt globs and the default scan exemption all derive from it. ' +
|
|
1781
|
+
'Correct it if your sources live elsewhere.',
|
|
1782
|
+
)
|
|
1783
|
+
}
|
|
1784
|
+
if (rootsUndetectable) {
|
|
1785
|
+
console.error(
|
|
1786
|
+
'⚠ basalt-ui init: could not infer "basalt.roots" — no src/ here and no workspace package ' +
|
|
1787
|
+
'with one. Set "basalt": { "roots": [...] } in package.json by hand, or check-theme will ' +
|
|
1788
|
+
'fail with "0 files scanned".',
|
|
1789
|
+
)
|
|
1790
|
+
}
|
|
1791
|
+
if (seededLintScript) {
|
|
1792
|
+
console.log(`basalt-ui init: added the "lint:basalt" script (${lintScript}) to package.json.`)
|
|
1793
|
+
}
|
|
1208
1794
|
// lefthook.yml / .github/workflows/check.yml are repo-root-shaped — neither lefthook nor GitHub
|
|
1209
1795
|
// Actions reads config from anywhere but the repo root, so a package living in a subdirectory
|
|
1210
1796
|
// skips both rather than relocating them into a spot nothing reads.
|
|
@@ -1212,12 +1798,15 @@ export function init(cwd: string = process.cwd(), scaffoldFlags: ScaffoldFlags =
|
|
|
1212
1798
|
console.log(
|
|
1213
1799
|
`basalt-ui init: skipped lefthook.yml (this package is not the repo root — repo root ` +
|
|
1214
1800
|
`detected at ${placement.repoRoot}) — lefthook only reads config at the repo root; extend ` +
|
|
1215
|
-
|
|
1801
|
+
`${ctx.vars.LEFTHOOK_PRESET_PATH} from your root lefthook.yml instead, and give its ` +
|
|
1802
|
+
`check-theme command \`env: { BASALT_CWD: ${relativePosix(placement.repoRoot, cwd)} }\` ` +
|
|
1803
|
+
'so the guard runs where your basalt config actually lives.',
|
|
1216
1804
|
)
|
|
1217
1805
|
console.log(
|
|
1218
1806
|
`basalt-ui init: skipped .github/workflows/check.yml (this package is not the repo root — ` +
|
|
1219
1807
|
`repo root detected at ${placement.repoRoot}) — GitHub Actions only reads .github/ at the ` +
|
|
1220
|
-
|
|
1808
|
+
`repo root; copy the steps from ${shippedAssetPath(install, placement.repoRoot, 'configs/check.yml')} ` +
|
|
1809
|
+
'into your root CI workflow instead.',
|
|
1221
1810
|
)
|
|
1222
1811
|
}
|
|
1223
1812
|
if (placement.relocatedQueryClient !== null) {
|
|
@@ -1254,15 +1843,46 @@ export function init(cwd: string = process.cwd(), scaffoldFlags: ScaffoldFlags =
|
|
|
1254
1843
|
` ]\n` +
|
|
1255
1844
|
` }`,
|
|
1256
1845
|
)
|
|
1846
|
+
// `init` on an existing app is a LINT-DEBT EVENT, not a no-op — the shipped preset turns on whole
|
|
1847
|
+
// oxlint plugins the repo was never linted against, so previously-clean code lands with real
|
|
1848
|
+
// findings on the first run. Naming the plugins (derived from the preset, so the count can't
|
|
1849
|
+
// drift) is what turns that from a nasty surprise into a scheduled triage.
|
|
1850
|
+
const presetPlugins = readOxlintPresetPlugins(ctx.pkgRoot)
|
|
1851
|
+
if (presetPlugins.length > 0) {
|
|
1852
|
+
console.log(
|
|
1853
|
+
`\nLint debt: adopting the shipped oxlint preset on an EXISTING app is not a no-op — it turns ` +
|
|
1854
|
+
`on ${presetPlugins.length} plugins (${presetPlugins.join(', ')}) plus the basalt/* design ` +
|
|
1855
|
+
'rules, on code never linted against them. Run `oxlint .` now and triage the count before ' +
|
|
1856
|
+
'your next commit; turn a rule off in your own .oxlintrc.json with a written reason rather ' +
|
|
1857
|
+
'than blanket-disabling a plugin.',
|
|
1858
|
+
)
|
|
1859
|
+
}
|
|
1860
|
+
console.log(
|
|
1861
|
+
'Activate the hooks: `lefthook install` — the seeded lefthook.yml is inert until the git hooks ' +
|
|
1862
|
+
'are written, and looks configured either way.',
|
|
1863
|
+
)
|
|
1257
1864
|
// First adoption on a previously guard-clean repo can surface a wall of findings (the 1.0 guard
|
|
1258
1865
|
// adds several rule kinds beyond a legacy local guard) — steer toward tuning config, not mass-allow.
|
|
1259
1866
|
console.log(
|
|
1260
|
-
|
|
1261
|
-
'in package.json for anything that fires — do not
|
|
1867
|
+
`\nFirst run: run \`${ctx.vars.BASALT_BIN} check-theme\` next (or \`bun run lint:basalt\`), then ` +
|
|
1868
|
+
'tune the per-rule `basalt.*` config keys in package.json for anything that fires — do not ' +
|
|
1869
|
+
'mass-`theme-allow` findings. Then `basalt-ui doctor` to confirm the wiring actually took.',
|
|
1262
1870
|
)
|
|
1263
1871
|
return 0
|
|
1264
1872
|
}
|
|
1265
1873
|
|
|
1874
|
+
/** The oxlint plugins the shipped preset switches on — read from the preset, never restated. */
|
|
1875
|
+
function readOxlintPresetPlugins(pkgRoot: string): string[] {
|
|
1876
|
+
const raw = readSource(pkgRoot, 'configs/oxlint.json')
|
|
1877
|
+
if (raw === null) return []
|
|
1878
|
+
try {
|
|
1879
|
+
const plugins = (JSON.parse(raw) as { plugins?: unknown }).plugins
|
|
1880
|
+
return isStringArray(plugins) ? plugins : []
|
|
1881
|
+
} catch {
|
|
1882
|
+
return []
|
|
1883
|
+
}
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1266
1886
|
type SyncOptions = { force?: boolean; check?: boolean }
|
|
1267
1887
|
|
|
1268
1888
|
/**
|
|
@@ -1480,7 +2100,7 @@ export function checkCoverage(): number {
|
|
|
1480
2100
|
for (const exportKey of pkgExports) {
|
|
1481
2101
|
if (exportKey === '.' || exportKey === './styles.css' || exportKey.startsWith('./configs/'))
|
|
1482
2102
|
continue
|
|
1483
|
-
if (!(exportKey
|
|
2103
|
+
if (!Object.hasOwn(SURFACES, exportKey)) {
|
|
1484
2104
|
failures.push(`package.json exports key '${exportKey}' has no matching SURFACES entry`)
|
|
1485
2105
|
}
|
|
1486
2106
|
}
|
|
@@ -1577,8 +2197,25 @@ type DoctorResult = {
|
|
|
1577
2197
|
hardFailures: number
|
|
1578
2198
|
/** Number of warnings (informational only). */
|
|
1579
2199
|
warnings: number
|
|
2200
|
+
/**
|
|
2201
|
+
* Number of checks that could not RUN (as opposed to ran and passed). Counted separately and
|
|
2202
|
+
* exits non-zero on its own: under bun's isolated linker `doctor` used to drop two of its five
|
|
2203
|
+
* checks and still print "All checks passed", which is the same false-green this whole surface
|
|
2204
|
+
* exists to remove. A check that cannot run is not a check that passed.
|
|
2205
|
+
*/
|
|
2206
|
+
skipped: number
|
|
1580
2207
|
}
|
|
1581
2208
|
|
|
2209
|
+
/**
|
|
2210
|
+
* Which shape of consumer `doctor` is looking at.
|
|
2211
|
+
*
|
|
2212
|
+
* `framework` — the Mantine app the scaffold is for. `tokens-only` — a consumer that took the
|
|
2213
|
+
* `--vx-*` layer and nothing else (rollhook: no Mantine, no manifest, `tokens:css` output). Telling
|
|
2214
|
+
* the second to "run `basalt-ui init`" is the wrong instruction: init scaffolds Mantine rules,
|
|
2215
|
+
* skills and a DESIGN.md it will never read. Auto-detected, forced by `--framework` / `--tokens-only`.
|
|
2216
|
+
*/
|
|
2217
|
+
type DoctorProfile = 'framework' | 'tokens-only'
|
|
2218
|
+
|
|
1582
2219
|
type WorkspacePackage = { readonly name: string; readonly dir: string }
|
|
1583
2220
|
|
|
1584
2221
|
/** {@link collectWorkspacePackages}'s return: the walked packages plus whether a `workspaces`
|
|
@@ -1783,9 +2420,20 @@ function resolveAiMajorSkewReason(cfg: BasaltConfig): {
|
|
|
1783
2420
|
* .basalt/manifest.json at the last init/sync. File-level drift is `sync --check`'s job, not
|
|
1784
2421
|
* doctor's.
|
|
1785
2422
|
*
|
|
2423
|
+
* The checks are numbered in the order they run and print — there is no gap, and the number in a
|
|
2424
|
+
* section header below is the number in the report.
|
|
2425
|
+
*
|
|
1786
2426
|
* Hard failures (exit non-zero):
|
|
1787
2427
|
* 1. .basalt/manifest.json exists (init was run).
|
|
1788
|
-
*
|
|
2428
|
+
* 2. `basalt-resolves`: basalt-ui resolves from here. Unresolvable used to make checks silently
|
|
2429
|
+
* VANISH from the report (5 checks became 3) while the footer still read "All checks passed".
|
|
2430
|
+
* It is also the moment `bunx basalt-ui` stops using the pinned copy.
|
|
2431
|
+
* 6. `guard-scan`: `check-theme` would scan MORE than zero files. `check-theme` already exits 1
|
|
2432
|
+
* on "0 files scanned"; doctor disagreeing with it in the same repo is the bug.
|
|
2433
|
+
* 7. `oxlint-preset`: the consumer's `.oxlintrc.json` actually `extends` the shipped preset.
|
|
2434
|
+
* `init` keeps an existing config, so the framework's whole lint half can be off with nothing
|
|
2435
|
+
* to say so — one repo carried six real violations invisibly across five minors that way.
|
|
2436
|
+
* 9. `ai-major-parity`: every workspace package that declares the `ai` package agrees on its
|
|
1789
2437
|
* major version. `basalt/ai-sdk-major` (the oxlint plugin rule) cannot catch this — it checks
|
|
1790
2438
|
* a linted file's NEAREST package.json, so a lint run scoped to one workspace package only
|
|
1791
2439
|
* ever sees that package's own `ai` major and is perfectly happy; the cross-package skew (one
|
|
@@ -1798,24 +2446,52 @@ function resolveAiMajorSkewReason(cfg: BasaltConfig): {
|
|
|
1798
2446
|
* or invalid (empty/non-string) still hard-fails exactly as if the key were absent.
|
|
1799
2447
|
*
|
|
1800
2448
|
* Warnings (non-fatal):
|
|
1801
|
-
*
|
|
2449
|
+
* 3. The installed node_modules/basalt-ui version matches the manifest's basaltVersion
|
|
1802
2450
|
* (a mismatch means the package was upgraded but `sync` never ran — the placed doctrine is
|
|
1803
2451
|
* stale).
|
|
1804
|
-
*
|
|
1805
|
-
* (skipped when
|
|
1806
|
-
*
|
|
2452
|
+
* 4. The spacing scale still matches the one stamped into the manifest at the last init/sync
|
|
2453
|
+
* (skipped when there is no manifest to compare against; a neutral pass when this CLI is
|
|
2454
|
+
* demonstrably not the installed package — see the check).
|
|
2455
|
+
* 5. The running CLI's own version matches the installed basalt-ui (catches a stale
|
|
1807
2456
|
* `bunx basalt-ui` npm fetch; best-effort, skipped if node_modules is absent).
|
|
1808
|
-
*
|
|
2457
|
+
* 8. `basaltAppPlugin`'s (basalt-ui/vite) default icon filenames exist under public/ (only when
|
|
1809
2458
|
* a public/ dir exists at all — skipped otherwise).
|
|
1810
|
-
*
|
|
1811
|
-
* stale and can be deleted; an exemption nobody revisits is how a real, later
|
|
1812
|
-
* through unnoticed.
|
|
2459
|
+
* 9 (second half). A declared `basalt.aiMajorSkewReason` when the ai majors currently AGREE — the
|
|
2460
|
+
* exemption is stale and can be deleted; an exemption nobody revisits is how a real, later
|
|
2461
|
+
* skew slips through unnoticed.
|
|
2462
|
+
*
|
|
2463
|
+
* An AMBIGUOUS project (no config here, several workspace packages carrying one) short-circuits
|
|
2464
|
+
* before any of them, exactly as `check-theme` does: running the remaining checks against the wrong
|
|
2465
|
+
* root buries the one real error under spurious ones.
|
|
1813
2466
|
*
|
|
1814
|
-
*
|
|
2467
|
+
* SKIPPED is now a third outcome beside pass/warn/fail and exits non-zero on its own — a check that
|
|
2468
|
+
* cannot run is not a check that passed. "Not applicable to this profile" is a pass, not a skip.
|
|
2469
|
+
*
|
|
2470
|
+
* Returns the exit code: 0 = all good, 1 = one or more hard failures or unrunnable checks.
|
|
1815
2471
|
*/
|
|
1816
|
-
export function doctor(
|
|
2472
|
+
export function doctor(invocationCwd: string = process.cwd(), flags: string[] = []): number {
|
|
2473
|
+
if (conflictingProfileFlags(flags)) {
|
|
2474
|
+
console.error('basalt-ui doctor: --tokens-only and --framework are alternatives — pass one.')
|
|
2475
|
+
return 1
|
|
2476
|
+
}
|
|
2477
|
+
const project = resolveProjectDir(invocationCwd)
|
|
2478
|
+
// Ambiguity is terminal, exactly as it is for check-theme. Falling back to `invocationCwd` ran
|
|
2479
|
+
// every remaining check against the WRONG root and buried the one real error under spurious
|
|
2480
|
+
// ones — guard-scan reporting 0 files, .oxlintrc.json "missing" — which is this round's own
|
|
2481
|
+
// false-report bug class reappearing inside the fix for it.
|
|
2482
|
+
if (project.ambiguous !== null) {
|
|
2483
|
+
console.error(
|
|
2484
|
+
`\nbasalt-ui doctor — ${invocationCwd}\n\n` +
|
|
2485
|
+
` ✖ no basalt config at ${invocationCwd}, and ${project.ambiguous.length} workspace ` +
|
|
2486
|
+
`packages carry one (${project.ambiguous.map((d) => relativePosix(invocationCwd, d)).join(', ')}) — ` +
|
|
2487
|
+
'run doctor from one of them, or set BASALT_CWD to pick.\n\n' +
|
|
2488
|
+
'1 hard failure(s), 0 warning(s).\n',
|
|
2489
|
+
)
|
|
2490
|
+
return 1
|
|
2491
|
+
}
|
|
2492
|
+
const cwd = project.dir
|
|
1817
2493
|
const cfg = readBasaltConfig(cwd)
|
|
1818
|
-
const result: DoctorResult = { hardFailures: 0, warnings: 0 }
|
|
2494
|
+
const result: DoctorResult = { hardFailures: 0, warnings: 0, skipped: 0 }
|
|
1819
2495
|
const lines: string[] = [`\nbasalt-ui doctor — ${cwd}\n`]
|
|
1820
2496
|
|
|
1821
2497
|
function pass(msg: string): void {
|
|
@@ -1825,32 +2501,85 @@ export function doctor(cwd: string = process.cwd()): number {
|
|
|
1825
2501
|
lines.push(` ⚠ ${msg}`)
|
|
1826
2502
|
result.warnings++
|
|
1827
2503
|
}
|
|
2504
|
+
function skip(msg: string): void {
|
|
2505
|
+
lines.push(` ⊘ SKIPPED — ${msg}`)
|
|
2506
|
+
result.skipped++
|
|
2507
|
+
}
|
|
1828
2508
|
function fail(msg: string): void {
|
|
1829
2509
|
lines.push(` ✖ ${msg}`)
|
|
1830
2510
|
result.hardFailures++
|
|
1831
2511
|
}
|
|
1832
2512
|
|
|
1833
|
-
|
|
2513
|
+
if (project.relocatedFrom !== null) {
|
|
2514
|
+
lines.push(
|
|
2515
|
+
` → no basalt config at ${project.relocatedFrom} — reporting on ` +
|
|
2516
|
+
`${relativePosix(project.relocatedFrom, cwd)}, where it lives.\n`,
|
|
2517
|
+
)
|
|
2518
|
+
}
|
|
2519
|
+
|
|
2520
|
+
// ── Profile ────────────────────────────────────────────────────────────────
|
|
1834
2521
|
const manifestAbs = resolve(cwd, MANIFEST_PATH)
|
|
1835
2522
|
const manifestExists = existsSync(manifestAbs)
|
|
1836
|
-
|
|
2523
|
+
const profile = inferredProfile(cwd, cfg, flags)
|
|
2524
|
+
if (profile === 'tokens-only') {
|
|
2525
|
+
lines.push(
|
|
2526
|
+
' profile: tokens-only — checking the token layer only. The scaffold checks below are not\n' +
|
|
2527
|
+
' applicable; `basalt-ui init` is NOT the fix here, it places a Mantine doctrine you have no\n' +
|
|
2528
|
+
' use for. Pass --framework to force the full profile.\n',
|
|
2529
|
+
)
|
|
2530
|
+
// check-theme will NOT infer this — it silences 17 kinds, so it moves only on a declaration.
|
|
2531
|
+
// Doctor is the surface that can safely detect the shape, so it is the one that has to say so.
|
|
2532
|
+
if (cfg.profile === undefined) {
|
|
2533
|
+
lines.push(
|
|
2534
|
+
' → inferred (no .basalt/manifest.json and no @mantine/core in this workspace). Write it\n' +
|
|
2535
|
+
' down as "basalt": { "profile": "tokens-only" } in package.json — check-theme does not\n' +
|
|
2536
|
+
' infer it, so today it still reports the Mantine-only kinds against this repo.\n',
|
|
2537
|
+
)
|
|
2538
|
+
}
|
|
2539
|
+
}
|
|
2540
|
+
|
|
2541
|
+
// ── Hard check 1: manifest exists ──────────────────────────────────────────
|
|
2542
|
+
if (profile === 'tokens-only') {
|
|
2543
|
+
pass(`${MANIFEST_PATH}: n/a — a tokens-only consumer has no scaffold to reconcile`)
|
|
2544
|
+
} else if (manifestExists) {
|
|
1837
2545
|
pass(`${MANIFEST_PATH} exists`)
|
|
1838
2546
|
} else {
|
|
1839
2547
|
fail(`${MANIFEST_PATH} missing — run \`basalt-ui init\` to scaffold the consumer repo`)
|
|
1840
2548
|
}
|
|
1841
2549
|
|
|
1842
|
-
// ──
|
|
2550
|
+
// ── Hard check 2: basalt-ui resolves from here ─────────────────────────────
|
|
2551
|
+
// Unresolvable used to make checks 3 and 5 VANISH from the report while the footer still read
|
|
2552
|
+
// "All checks passed" — 5 checks silently became 3. It is also the moment `bunx basalt-ui`
|
|
2553
|
+
// stops using the pinned copy and quietly downloads a different one from npm, so the failure
|
|
2554
|
+
// has to be loud rather than absent.
|
|
2555
|
+
const install = findBasaltInstall(cwd)
|
|
2556
|
+
const installedVersion = install.version
|
|
2557
|
+
if (install.dir === null) {
|
|
2558
|
+
fail(
|
|
2559
|
+
`basalt-ui does not resolve from ${cwd}, any directory above it, or any workspace package — ` +
|
|
2560
|
+
'so every version check below is unrunnable, `extends: [<preset>]` cannot resolve, and ' +
|
|
2561
|
+
'`bunx basalt-ui` will silently fetch a DIFFERENT copy from npm instead of your pinned ' +
|
|
2562
|
+
'one. Add basalt-ui as a dependency here (or at the repo root) and re-run.',
|
|
2563
|
+
)
|
|
2564
|
+
} else if (install.how === 'cwd') {
|
|
2565
|
+
pass(
|
|
2566
|
+
`basalt-ui resolves at ./node_modules/basalt-ui (${installedVersion ?? 'unknown version'})`,
|
|
2567
|
+
)
|
|
2568
|
+
} else {
|
|
2569
|
+
pass(
|
|
2570
|
+
`basalt-ui resolves at ${relativePosix(cwd, install.dir)} (${installedVersion ?? 'unknown version'}, ` +
|
|
2571
|
+
`found via the ${install.how}) — seeded \`extends\` paths and CI steps must use that path, ` +
|
|
2572
|
+
'not ./node_modules/basalt-ui.',
|
|
2573
|
+
)
|
|
2574
|
+
}
|
|
2575
|
+
|
|
2576
|
+
// ── Warn check 3: installed basalt-ui version matches the manifest ─────────
|
|
1843
2577
|
// THE one version axis. The manifest records the version whose init/sync placed the doctrine;
|
|
1844
2578
|
// node_modules is what the app actually resolves. A mismatch means "upgrade landed, sync didn't".
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
const consumerPkg = JSON.parse(consumerPkgRaw) as { version?: string }
|
|
1850
|
-
installedVersion = consumerPkg.version ?? null
|
|
1851
|
-
} catch {
|
|
1852
|
-
warn('could not parse node_modules/basalt-ui/package.json — version checks skipped')
|
|
1853
|
-
}
|
|
2579
|
+
if (profile === 'framework' && manifestExists && installedVersion === null) {
|
|
2580
|
+
skip(
|
|
2581
|
+
'installed-vs-manifest version — basalt-ui did not resolve, so there is nothing to compare',
|
|
2582
|
+
)
|
|
1854
2583
|
}
|
|
1855
2584
|
if (manifestExists && installedVersion !== null) {
|
|
1856
2585
|
const manifestVersion = readManifest(cwd).basaltVersion
|
|
@@ -1871,7 +2600,7 @@ export function doctor(cwd: string = process.cwd()): number {
|
|
|
1871
2600
|
|
|
1872
2601
|
const cliVersion = readFrameworkVersion(packageRoot())
|
|
1873
2602
|
|
|
1874
|
-
// ── Warn check
|
|
2603
|
+
// ── Warn check 4: the spacing scale has not moved under the app ────────────
|
|
1875
2604
|
// The only check here that reports a change in RENDERED OUTPUT rather than in placed files. A
|
|
1876
2605
|
// retune of the spacing bases moves every surface in an app calling `createBasaltTheme()` bare,
|
|
1877
2606
|
// and majors are banned by design, so nothing in the version number says so — 1.2.0's retune
|
|
@@ -1883,14 +2612,23 @@ export function doctor(cwd: string = process.cwd()): number {
|
|
|
1883
2612
|
// `deriveSpacing` here is the RUNNING CLI's, which a stale `bunx basalt-ui` fetch makes a
|
|
1884
2613
|
// different package. Rather than load the installed tokens entry (async, and doctor is sync),
|
|
1885
2614
|
// the comparison is skipped whenever the two versions are known to disagree: a false "matches"
|
|
1886
|
-
// is worse than no answer. Check
|
|
2615
|
+
// is worse than no answer. Check 5 below names that disagreement, so this stays a neutral note.
|
|
1887
2616
|
const cliIsTheInstall = installedVersion === null || installedVersion === cliVersion
|
|
1888
|
-
if (
|
|
2617
|
+
if (profile === 'tokens-only') {
|
|
2618
|
+
pass('spacing scale: n/a — a tokens-only consumer renders no basalt Mantine theme')
|
|
2619
|
+
} else if (!manifestExists) {
|
|
2620
|
+
// Printing NOTHING is the failure SKIPPED exists to end: the check simply vanished from the
|
|
2621
|
+
// report whenever there was no manifest, which is precisely when it is least safe to assume.
|
|
2622
|
+
skip(
|
|
2623
|
+
`spacing scale — no ${MANIFEST_PATH} records the scale this app last synced with, so there ` +
|
|
2624
|
+
'is nothing to compare against',
|
|
2625
|
+
)
|
|
2626
|
+
} else if (!cliIsTheInstall) {
|
|
1889
2627
|
pass(
|
|
1890
2628
|
`spacing scale not compared — this CLI (${cliVersion}) is not the installed basalt-ui ` +
|
|
1891
2629
|
`(${installedVersion}), so its scale is not the one the app renders with`,
|
|
1892
2630
|
)
|
|
1893
|
-
} else
|
|
2631
|
+
} else {
|
|
1894
2632
|
const recorded = readManifest(cwd).spacingScale
|
|
1895
2633
|
const current = deriveSpacing(0).scale as Record<string, number>
|
|
1896
2634
|
if (recorded === undefined) {
|
|
@@ -1916,29 +2654,85 @@ export function doctor(cwd: string = process.cwd()): number {
|
|
|
1916
2654
|
}
|
|
1917
2655
|
}
|
|
1918
2656
|
|
|
1919
|
-
// ── Warn check
|
|
2657
|
+
// ── Warn check 5: running CLI version matches the consumer's installed basalt-ui ────
|
|
1920
2658
|
// Catches the failure mode where bunx fetches a stale published package instead of the local
|
|
1921
2659
|
// install — the CLI that ran doctor and the package resolved from the consumer's node_modules
|
|
1922
2660
|
// silently disagree.
|
|
1923
|
-
if (installedVersion
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
2661
|
+
if (installedVersion === null) {
|
|
2662
|
+
skip('CLI-vs-installed version — basalt-ui did not resolve, so there is no installed version')
|
|
2663
|
+
} else if (installedVersion !== cliVersion) {
|
|
2664
|
+
warn(
|
|
2665
|
+
`running CLI version (${cliVersion}) differs from the installed basalt-ui version at ` +
|
|
2666
|
+
`${install.dir === null ? 'node_modules' : relativePosix(cwd, install.dir)} (${installedVersion}) — ` +
|
|
2667
|
+
'likely a stale `bunx basalt-ui` fetch from npm; run the local bin ' +
|
|
2668
|
+
'(`node_modules/.bin/basalt-ui`, or a package.json script) so the pinned copy is the one ' +
|
|
2669
|
+
'that runs.',
|
|
2670
|
+
)
|
|
2671
|
+
} else {
|
|
2672
|
+
pass(`CLI version (${cliVersion}) matches the installed basalt-ui`)
|
|
2673
|
+
}
|
|
2674
|
+
|
|
2675
|
+
// ── Hard check 6: the guard would actually scan something ──────────────────
|
|
2676
|
+
// `check-theme` exits 1 on "0 files scanned" — doctor reporting all-green in the SAME repo is
|
|
2677
|
+
// the disagreement consumers reported: init ran, doctor passed, and the palette guard was a
|
|
2678
|
+
// no-op the whole time because no `basalt.roots` described the workspace layout.
|
|
2679
|
+
if (profile === 'tokens-only') {
|
|
2680
|
+
pass('guard-scan: n/a — the theme guard is a Mantine-app check')
|
|
2681
|
+
} else {
|
|
2682
|
+
const scanned = scannableFiles(cwd, cfg)
|
|
2683
|
+
if (scanned.length === 0) {
|
|
2684
|
+
fail(
|
|
2685
|
+
`guard-scan: check-theme would scan 0 files — the roots (${resolveRoots(cfg).join(', ')}) ` +
|
|
2686
|
+
`match nothing under ${cwd}, so the palette guard is a no-op that still exits green in ` +
|
|
2687
|
+
'every gate that does not run it. Set "basalt": { "roots": [...] } in package.json.',
|
|
1929
2688
|
)
|
|
1930
2689
|
} else {
|
|
1931
|
-
pass(
|
|
2690
|
+
pass(
|
|
2691
|
+
`guard-scan: check-theme covers ${scanned.length} file(s) under ${resolveRoots(cfg).join(', ')}`,
|
|
2692
|
+
)
|
|
1932
2693
|
}
|
|
1933
2694
|
}
|
|
1934
2695
|
|
|
1935
|
-
// ──
|
|
2696
|
+
// ── Hard check 7: the consumer's oxlint config extends the shipped preset ──
|
|
2697
|
+
// `init` KEEPS an existing `.oxlintrc.json`, so a repo can carry the whole scaffold with the
|
|
2698
|
+
// framework's lint half switched off and nothing anywhere saying so. One repo ran five minors
|
|
2699
|
+
// that way and surfaced six real `basalt/no-raw-font-size` errors the moment it was wired.
|
|
2700
|
+
if (profile === 'tokens-only') {
|
|
2701
|
+
pass('oxlint-preset: n/a — the shipped preset is a Mantine/React preset')
|
|
2702
|
+
} else {
|
|
2703
|
+
const oxlintrcRaw = readIfExists(resolve(cwd, '.oxlintrc.json'))
|
|
2704
|
+
if (oxlintrcRaw === null) {
|
|
2705
|
+
fail(
|
|
2706
|
+
'.oxlintrc.json missing — the shipped oxlint preset (the basalt/* design rules) is not ' +
|
|
2707
|
+
'active. Run `basalt-ui init` to seed it.',
|
|
2708
|
+
)
|
|
2709
|
+
} else {
|
|
2710
|
+
// JSONC: oxlint accepts comments and real consumer configs carry them.
|
|
2711
|
+
const parsed = parseJsonc(oxlintrcRaw)
|
|
2712
|
+
if (parsed === null) {
|
|
2713
|
+
skip(
|
|
2714
|
+
'.oxlintrc.json does not parse as JSON/JSONC — cannot tell whether it extends the preset',
|
|
2715
|
+
)
|
|
2716
|
+
} else if (extendsBasaltPreset(parsed['extends'])) {
|
|
2717
|
+
pass('.oxlintrc.json extends the shipped basalt-ui oxlint preset')
|
|
2718
|
+
} else {
|
|
2719
|
+
fail(
|
|
2720
|
+
'.oxlintrc.json does NOT extend the shipped preset — every basalt/* design rule is off ' +
|
|
2721
|
+
'and oxlint reports green without them. Add ' +
|
|
2722
|
+
`"extends": ["${shippedAssetPath(install, cwd, 'configs/oxlint.json')}"], or re-run ` +
|
|
2723
|
+
'`basalt-ui init --merge-lint` to splice it in.',
|
|
2724
|
+
)
|
|
2725
|
+
}
|
|
2726
|
+
}
|
|
2727
|
+
}
|
|
2728
|
+
|
|
2729
|
+
// ── Warn check 8: basaltAppPlugin's default icon files exist in public/ ────
|
|
1936
2730
|
// basalt-ui/vite's basaltAppPlugin (head/manifest metadata) references these filenames by
|
|
1937
2731
|
// default (the realfavicongenerator convention). Only runs when a public/ dir exists at all —
|
|
1938
2732
|
// apps that don't use the plugin, or don't use Vite's public-dir convention, get no false
|
|
1939
2733
|
// warning. Best-effort: a custom `icons.dir` isn't visible here, so this checks the root only.
|
|
1940
2734
|
const publicDir = resolve(cwd, 'public')
|
|
1941
|
-
if (existsSync(publicDir)) {
|
|
2735
|
+
if (profile === 'framework' && existsSync(publicDir)) {
|
|
1942
2736
|
const iconFiles = [
|
|
1943
2737
|
'favicon.ico',
|
|
1944
2738
|
'favicon.svg',
|
|
@@ -1958,7 +2752,7 @@ export function doctor(cwd: string = process.cwd()): number {
|
|
|
1958
2752
|
}
|
|
1959
2753
|
}
|
|
1960
2754
|
|
|
1961
|
-
// ── Hard check
|
|
2755
|
+
// ── Hard check 9: ai package major version parity across workspace packages ─
|
|
1962
2756
|
// basalt/ai-sdk-major (the lint rule) is per-file against the NEAREST package.json, so a lint
|
|
1963
2757
|
// run scoped to one workspace package only ever sees that package's own `ai` major and is
|
|
1964
2758
|
// perfectly happy. The real defect is CROSS-package (argo defect 1: apps/api on ai@5 producing a
|
|
@@ -2014,17 +2808,22 @@ export function doctor(cwd: string = process.cwd()): number {
|
|
|
2014
2808
|
}
|
|
2015
2809
|
|
|
2016
2810
|
// ── Report ──────────────────────────────────────────────────────────────────
|
|
2811
|
+
// "All checks passed" is only printable when every check ran AND passed. A skipped check counts
|
|
2812
|
+
// against the exit code on its own — the report that dropped two of five checks and still
|
|
2813
|
+
// claimed green is the bug this footer exists to make impossible.
|
|
2017
2814
|
lines.push('')
|
|
2018
|
-
if (result.hardFailures === 0 && result.warnings === 0) {
|
|
2815
|
+
if (result.hardFailures === 0 && result.warnings === 0 && result.skipped === 0) {
|
|
2019
2816
|
lines.push('All checks passed.')
|
|
2020
2817
|
} else {
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2818
|
+
const parts: string[] = []
|
|
2819
|
+
if (result.hardFailures > 0) parts.push(`${result.hardFailures} hard failure(s)`)
|
|
2820
|
+
if (result.skipped > 0) parts.push(`${result.skipped} check(s) SKIPPED (could not run)`)
|
|
2821
|
+
parts.push(`${result.warnings} warning(s)`)
|
|
2822
|
+
lines.push(`${parts.join(', ')}.`)
|
|
2024
2823
|
}
|
|
2025
2824
|
lines.push('')
|
|
2026
2825
|
|
|
2027
|
-
if (result.hardFailures > 0) {
|
|
2826
|
+
if (result.hardFailures > 0 || result.skipped > 0) {
|
|
2028
2827
|
console.error(lines.join('\n'))
|
|
2029
2828
|
return 1
|
|
2030
2829
|
}
|
|
@@ -2164,6 +2963,87 @@ function flagValue(flags: string[], name: string): string | undefined {
|
|
|
2164
2963
|
return value === undefined || value.startsWith('--') ? undefined : value
|
|
2165
2964
|
}
|
|
2166
2965
|
|
|
2966
|
+
/**
|
|
2967
|
+
* The generated-file header: the canonical marker line (imported from the guard, which owns it),
|
|
2968
|
+
* then the version + invocation that produced the file.
|
|
2969
|
+
*
|
|
2970
|
+
* Both lines are load-bearing, not decoration — `checkSource` only exempts a stylesheet whose line
|
|
2971
|
+
* 1 is the marker verbatim AND whose line 2 parses as this provenance line. Reword either half and
|
|
2972
|
+
* basalt starts reporting its own output again (116 findings in rollhook's token sheet).
|
|
2973
|
+
*/
|
|
2974
|
+
function generatedHeader(version: string, command: string, flags: string[]): string {
|
|
2975
|
+
const shown = flags.filter((f) => f !== '--check')
|
|
2976
|
+
const invocation = shown.length === 0 ? command : `${command} ${shown.join(' ')}`
|
|
2977
|
+
return `${GENERATED_HEADER_LINE}\n/* basalt-ui ${version} — \`basalt-ui ${invocation}\` */\n`
|
|
2978
|
+
}
|
|
2979
|
+
|
|
2980
|
+
/**
|
|
2981
|
+
* Space-normalize the argument list of a legacy color function so the emitted file survives a
|
|
2982
|
+
* normal repo's formatter.
|
|
2983
|
+
*
|
|
2984
|
+
* This is FORMATTING ONLY and deliberately the CLI's single deviation from "print exactly what
|
|
2985
|
+
* `buildPaletteCss` returned": `rgba(255,255,255,0.6)` and `rgba(255, 255, 255, 0.6)` are the same
|
|
2986
|
+
* colour, the shipped palette emits the first form on the dark side and the second on the light
|
|
2987
|
+
* side, and a consumer committing the output ate a lint-ignore entry for the difference. The token
|
|
2988
|
+
* VALUES are untouched — nothing here can change what basalt's tokens are.
|
|
2989
|
+
*
|
|
2990
|
+
* Exported for tests, so the expectation can be computed rather than restated.
|
|
2991
|
+
*/
|
|
2992
|
+
export function normalizeColorFunctions(css: string): string {
|
|
2993
|
+
return css.replace(/\b(rgba?|hsla?)\(([^()]*)\)/g, (_match, fn: string, args: string) => {
|
|
2994
|
+
const parts = args.split(',').map((part) => part.trim())
|
|
2995
|
+
return `${fn}(${parts.join(', ')})`
|
|
2996
|
+
})
|
|
2997
|
+
}
|
|
2998
|
+
|
|
2999
|
+
/** A scheme-class emission uses this sentinel attribute, then rewrites it to a class selector. */
|
|
3000
|
+
const SCHEME_CLASS_SENTINEL = 'data-basalt-scheme-class'
|
|
3001
|
+
|
|
3002
|
+
/**
|
|
3003
|
+
* Write (or drift-check) a generated stylesheet. `--check` makes no writes and exits 1 when the
|
|
3004
|
+
* file on disk differs from what would be emitted — the CI gate for a committed `tokens:css`
|
|
3005
|
+
* artifact, mirroring `sync --check`. Without `--out` the content goes to stdout unchanged.
|
|
3006
|
+
*/
|
|
3007
|
+
function emitGeneratedCss(content: string, flags: string[], cwd: string, command: string): number {
|
|
3008
|
+
const out = flagValue(flags, '--out')
|
|
3009
|
+
const check = flags.includes('--check')
|
|
3010
|
+
if (out === undefined) {
|
|
3011
|
+
if (check) {
|
|
3012
|
+
console.error(
|
|
3013
|
+
`${command}: --check needs --out <path> — there is nothing to compare stdout to.`,
|
|
3014
|
+
)
|
|
3015
|
+
return 1
|
|
3016
|
+
}
|
|
3017
|
+
process.stdout.write(content)
|
|
3018
|
+
return 0
|
|
3019
|
+
}
|
|
3020
|
+
const target = isAbsolute(out) ? out : resolve(cwd, out)
|
|
3021
|
+
const shown = relative(cwd, target) || target
|
|
3022
|
+
if (check) {
|
|
3023
|
+
const onDisk = readIfExists(target)
|
|
3024
|
+
if (onDisk === null) {
|
|
3025
|
+
console.error(
|
|
3026
|
+
`✖ ${command} --check: ${shown} does not exist — run without --check to write it.`,
|
|
3027
|
+
)
|
|
3028
|
+
return 1
|
|
3029
|
+
}
|
|
3030
|
+
if (onDisk === content) {
|
|
3031
|
+
console.log(`✓ ${command} --check: ${shown} is up to date.`)
|
|
3032
|
+
return 0
|
|
3033
|
+
}
|
|
3034
|
+
console.error(
|
|
3035
|
+
`✖ ${command} --check: ${shown} differs from what \`basalt-ui ${command}\` emits today ` +
|
|
3036
|
+
`(on disk ${onDisk.split('\n').length} lines, emitted ${content.split('\n').length}) — ` +
|
|
3037
|
+
're-run the same command without --check and commit the result.',
|
|
3038
|
+
)
|
|
3039
|
+
return 1
|
|
3040
|
+
}
|
|
3041
|
+
mkdirSync(dirname(target), { recursive: true })
|
|
3042
|
+
writeFileSync(target, content)
|
|
3043
|
+
console.log(`wrote ${content.split('\n').length} lines → ${shown}`)
|
|
3044
|
+
return 0
|
|
3045
|
+
}
|
|
3046
|
+
|
|
2167
3047
|
/**
|
|
2168
3048
|
* tokens:css — emit the `--vx-*` stylesheet, optionally retargeted.
|
|
2169
3049
|
*
|
|
@@ -2173,12 +3053,16 @@ function flagValue(flags: string[], name: string): string | undefined {
|
|
|
2173
3053
|
* same artifact for a consumer that does install; this command exists for the ones that shouldn't
|
|
2174
3054
|
* have to just to change a selector.
|
|
2175
3055
|
*
|
|
2176
|
-
* It parses flags and calls `buildPaletteCss` —
|
|
2177
|
-
*
|
|
2178
|
-
*
|
|
3056
|
+
* It parses flags and calls `buildPaletteCss` for the token VALUES — the CLI and the API must not
|
|
3057
|
+
* be able to disagree about what basalt's tokens are. What it adds is strictly file framing for an
|
|
3058
|
+
* artifact a consumer COMMITS: the `@generated` header (the guard's skip marker), a trailing
|
|
3059
|
+
* newline, and `rgba()` argument spacing. All three were reported by the one framework-free
|
|
3060
|
+
* consumer as the reasons the output could not simply be committed.
|
|
2179
3061
|
*/
|
|
2180
3062
|
export function tokensCss(flags: string[], cwd: string = process.cwd()): number {
|
|
2181
3063
|
const attribute = flagValue(flags, '--selector-attribute')
|
|
3064
|
+
const schemeClass = flagValue(flags, '--selector-class')
|
|
3065
|
+
const lightClass = flagValue(flags, '--light-class')
|
|
2182
3066
|
const darkValue = flagValue(flags, '--dark-value')
|
|
2183
3067
|
const lightValue = flagValue(flags, '--light-value')
|
|
2184
3068
|
const defaultScheme = flagValue(flags, '--default-scheme')
|
|
@@ -2195,14 +3079,39 @@ export function tokensCss(flags: string[], cwd: string = process.cwd()): number
|
|
|
2195
3079
|
console.error(`tokens:css: --only must be core or all (got '${only}')`)
|
|
2196
3080
|
return 1
|
|
2197
3081
|
}
|
|
3082
|
+
if (schemeClass !== undefined && attribute !== undefined) {
|
|
3083
|
+
console.error(
|
|
3084
|
+
'tokens:css: --selector-class and --selector-attribute are alternatives — pass one.',
|
|
3085
|
+
)
|
|
3086
|
+
return 1
|
|
3087
|
+
}
|
|
3088
|
+
if (schemeClass !== undefined && /[^\w-]/.test(schemeClass)) {
|
|
3089
|
+
console.error(
|
|
3090
|
+
`tokens:css: --selector-class must be a plain CSS class name (got '${schemeClass}')`,
|
|
3091
|
+
)
|
|
3092
|
+
return 1
|
|
3093
|
+
}
|
|
3094
|
+
if (lightClass !== undefined && /[^\w-]/.test(lightClass)) {
|
|
3095
|
+
console.error(`tokens:css: --light-class must be a plain CSS class name (got '${lightClass}')`)
|
|
3096
|
+
return 1
|
|
3097
|
+
}
|
|
3098
|
+
|
|
3099
|
+
// A class selector is Tailwind's universal dark convention (`<html class="dark">`) and was
|
|
3100
|
+
// reachable before only by parking dark on the bare `:root` via --default-scheme. `buildPaletteCss`
|
|
3101
|
+
// emits attribute selectors, so the class form is produced by emitting against a sentinel
|
|
3102
|
+
// attribute this CLI chose itself and rewriting exactly those selectors — a rewrite of strings
|
|
3103
|
+
// the command generated deterministically, never of a token value.
|
|
3104
|
+
const resolvedDarkValue = schemeClass !== undefined ? schemeClass : darkValue
|
|
3105
|
+
const resolvedLightValue = schemeClass !== undefined ? (lightClass ?? 'light') : lightValue
|
|
3106
|
+
const resolvedAttribute = schemeClass !== undefined ? SCHEME_CLASS_SENTINEL : attribute
|
|
2198
3107
|
|
|
2199
3108
|
const scheme = {
|
|
2200
|
-
...(
|
|
2201
|
-
...(
|
|
2202
|
-
...(
|
|
3109
|
+
...(resolvedAttribute === undefined ? {} : { attribute: resolvedAttribute }),
|
|
3110
|
+
...(resolvedDarkValue === undefined ? {} : { darkValue: resolvedDarkValue }),
|
|
3111
|
+
...(resolvedLightValue === undefined ? {} : { lightValue: resolvedLightValue }),
|
|
2203
3112
|
}
|
|
2204
3113
|
|
|
2205
|
-
|
|
3114
|
+
let css = buildPaletteCss({
|
|
2206
3115
|
...(Object.keys(scheme).length === 0 ? {} : { scheme }),
|
|
2207
3116
|
...(defaultScheme === undefined
|
|
2208
3117
|
? {}
|
|
@@ -2216,16 +3125,59 @@ export function tokensCss(flags: string[], cwd: string = process.cwd()): number
|
|
|
2216
3125
|
...(flags.includes('--no-legacy-aliases') ? { legacyAliases: false } : {}),
|
|
2217
3126
|
})
|
|
2218
3127
|
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
3128
|
+
if (schemeClass !== undefined) {
|
|
3129
|
+
css = css.replace(
|
|
3130
|
+
new RegExp(`\\[${SCHEME_CLASS_SENTINEL}='([\\w-]+)'\\]`, 'g'),
|
|
3131
|
+
(_match, value: string) => `.${value}`,
|
|
3132
|
+
)
|
|
2223
3133
|
}
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
3134
|
+
|
|
3135
|
+
const version = readFrameworkVersion(packageRoot())
|
|
3136
|
+
const content = `${generatedHeader(version, 'tokens:css', flags)}${normalizeColorFunctions(css)}\n`
|
|
3137
|
+
return emitGeneratedCss(content, flags, cwd, 'tokens:css')
|
|
3138
|
+
}
|
|
3139
|
+
|
|
3140
|
+
/** Every `--basalt-font-*` declaration in the shipped stylesheet, in source order. */
|
|
3141
|
+
function readShippedFontDecls(pkgRoot: string): { name: string; value: string }[] {
|
|
3142
|
+
const css = readSource(pkgRoot, 'dist/styles.css') ?? readSource(pkgRoot, 'src/styles.css')
|
|
3143
|
+
if (css === null) return []
|
|
3144
|
+
const decls: { name: string; value: string }[] = []
|
|
3145
|
+
for (const match of css.matchAll(/(--basalt-font[\w-]*)\s*:\s*([^;]+);/g)) {
|
|
3146
|
+
decls.push({
|
|
3147
|
+
name: match[1] as string,
|
|
3148
|
+
value: (match[2] as string).replace(/\s+/g, ' ').trim(),
|
|
3149
|
+
})
|
|
3150
|
+
}
|
|
3151
|
+
return decls
|
|
3152
|
+
}
|
|
3153
|
+
|
|
3154
|
+
/**
|
|
3155
|
+
* fonts:css — emit the shipped `--basalt-font-*` stacks as plain CSS.
|
|
3156
|
+
*
|
|
3157
|
+
* The typeface half of the framework-free route, which had no supported path at all: `tokens.css`
|
|
3158
|
+
* emits no font vars, `styles.css` is the one place the defaults live and a framework-free consumer
|
|
3159
|
+
* is told not to import it, and `buildFontsCss` returns `''` unless the caller already knows the
|
|
3160
|
+
* stacks. The one non-Mantine consumer lost its font identity entirely and hardcoded the stacks —
|
|
3161
|
+
* which the theme guard then flagged as `raw-font-family`.
|
|
3162
|
+
*
|
|
3163
|
+
* The declarations are READ from the shipped stylesheet rather than restated here, so this command
|
|
3164
|
+
* and `basalt-ui/styles.css` can never name different typefaces. Note the fonts themselves are
|
|
3165
|
+
* `@fontsource-variable/*` packages: without them the stacks fall through to their own system
|
|
3166
|
+
* fallbacks, which is the intended framework-free behaviour.
|
|
3167
|
+
*/
|
|
3168
|
+
export function fontsCss(flags: string[], cwd: string = process.cwd()): number {
|
|
3169
|
+
const pkgRoot = packageRoot()
|
|
3170
|
+
const decls = readShippedFontDecls(pkgRoot)
|
|
3171
|
+
if (decls.length === 0) {
|
|
3172
|
+
console.error(
|
|
3173
|
+
'fonts:css: could not read the shipped font stacks from styles.css — this is a packaging ' +
|
|
3174
|
+
'bug in basalt-ui, not a config error.',
|
|
3175
|
+
)
|
|
3176
|
+
return 1
|
|
3177
|
+
}
|
|
3178
|
+
const body = `:root {\n${decls.map((d) => ` ${d.name}: ${d.value};`).join('\n')}\n}\n`
|
|
3179
|
+
const header = generatedHeader(readFrameworkVersion(pkgRoot), 'fonts:css', flags)
|
|
3180
|
+
return emitGeneratedCss(`${header}${body}`, flags, cwd, 'fonts:css')
|
|
2229
3181
|
}
|
|
2230
3182
|
|
|
2231
3183
|
/**
|
|
@@ -2286,6 +3238,9 @@ export async function guardHook(cwd: string = process.cwd()): Promise<number> {
|
|
|
2286
3238
|
allowComment: 'theme-allow',
|
|
2287
3239
|
exemptRules: cfg.exemptRules ?? DEFAULT_GUARD_CONFIG.exemptRules,
|
|
2288
3240
|
severity: cfg.severity ?? DEFAULT_GUARD_CONFIG.severity,
|
|
3241
|
+
// Same detection check-theme uses: the hook must never block an edit over advice the app
|
|
3242
|
+
// cannot take ("use @mantine/core's Select") in a repo with no Mantine.
|
|
3243
|
+
...(declaredProfile(cfg, []) === 'tokens-only' ? { profile: 'tokens-only' as const } : {}),
|
|
2289
3244
|
}
|
|
2290
3245
|
|
|
2291
3246
|
// Honor the consumer's roots / exempt / skip config so the hook never blocks edits to exempted
|
|
@@ -2325,14 +3280,27 @@ export async function guardHook(cwd: string = process.cwd()): Promise<number> {
|
|
|
2325
3280
|
|
|
2326
3281
|
/** The one usage string — printed by `basalt help` / `--help` / `-h` AND the unknown-command fallback. */
|
|
2327
3282
|
const USAGE =
|
|
2328
|
-
'Usage: basalt-ui <init [--with-router] [--with-query] | sync [--force] [--check]
|
|
2329
|
-
'check-theme | check-coverage | info [--json] | doctor
|
|
2330
|
-
'tokens:css
|
|
3283
|
+
'Usage: basalt-ui <init [--with-router] [--with-query] [--merge-lint] | sync [--force] [--check] |\n' +
|
|
3284
|
+
' check-theme | check-coverage | info [--json] | doctor [--tokens-only|--framework] |\n' +
|
|
3285
|
+
' guard-hook | tokens:css | fonts:css | help>\n\n' +
|
|
3286
|
+
'tokens:css [--out <path>] [--check] [--selector-attribute <attr> | --selector-class <class>]\n' +
|
|
3287
|
+
' [--light-class <class>] [--dark-value <v>] [--light-value <v>]\n' +
|
|
2331
3288
|
' [--default-scheme <dark|light|none>] [--media-fallback] [--only <core|all>]\n' +
|
|
2332
3289
|
' [--no-legacy-aliases]\n' +
|
|
2333
3290
|
' Emit the --vx-* stylesheet (stdout unless --out). Defaults reproduce basalt-ui/tokens.css.\n' +
|
|
3291
|
+
' --selector-class emits `:root.dark` instead of an attribute selector (the Tailwind convention).\n' +
|
|
3292
|
+
' --check writes nothing and exits 1 when --out differs from what would be emitted (a CI gate).\n' +
|
|
2334
3293
|
' --no-legacy-aliases drops the deprecated camelCase spellings (--vx-accentFill and friends),\n' +
|
|
2335
3294
|
' which are emitted by default as aliases of the canonical kebab-case names.\n\n' +
|
|
3295
|
+
'fonts:css [--out <path>] [--check]\n' +
|
|
3296
|
+
' Emit the shipped --basalt-font-* stacks as plain CSS — the typeface half of the token layer,\n' +
|
|
3297
|
+
' otherwise reachable only by importing styles.css.\n\n' +
|
|
3298
|
+
'doctor [--tokens-only|--framework]\n' +
|
|
3299
|
+
' Auto-detects a tokens-only consumer (no manifest + no @mantine/core) and checks only what\n' +
|
|
3300
|
+
' applies. A check that cannot RUN is reported as SKIPPED and exits non-zero.\n\n' +
|
|
3301
|
+
'check-theme / doctor honour BASALT_CWD, and relocate to the single workspace package carrying a\n' +
|
|
3302
|
+
'basalt config when invoked from a repo root that has none — several carrying one is ambiguous\n' +
|
|
3303
|
+
'and exits 1 in BOTH. --tokens-only and --framework are alternatives; passing both is an error.\n\n' +
|
|
2336
3304
|
'Every subcommand accepts --help / -h to print this message and exit without running.'
|
|
2337
3305
|
|
|
2338
3306
|
/**
|
|
@@ -2363,21 +3331,24 @@ export function run(argv: string[], cwd: string = process.cwd()): number | Promi
|
|
|
2363
3331
|
return init(cwd, {
|
|
2364
3332
|
withRouter: flags.includes('--with-router'),
|
|
2365
3333
|
withQuery: flags.includes('--with-query'),
|
|
3334
|
+
mergeLint: flags.includes('--merge-lint'),
|
|
2366
3335
|
})
|
|
2367
3336
|
case 'sync':
|
|
2368
3337
|
return sync({ force: flags.includes('--force'), check: flags.includes('--check') }, cwd)
|
|
2369
3338
|
case 'check-theme':
|
|
2370
|
-
return checkTheme(cwd)
|
|
3339
|
+
return checkTheme(cwd, flags)
|
|
2371
3340
|
case 'check-coverage':
|
|
2372
3341
|
return checkCoverage()
|
|
2373
3342
|
case 'info':
|
|
2374
3343
|
return info(flags)
|
|
2375
3344
|
case 'doctor':
|
|
2376
|
-
return doctor(cwd)
|
|
3345
|
+
return doctor(cwd, flags)
|
|
2377
3346
|
case 'guard-hook':
|
|
2378
3347
|
return guardHook(cwd)
|
|
2379
3348
|
case 'tokens:css':
|
|
2380
3349
|
return tokensCss(flags, cwd)
|
|
3350
|
+
case 'fonts:css':
|
|
3351
|
+
return fontsCss(flags, cwd)
|
|
2381
3352
|
default:
|
|
2382
3353
|
console.error(USAGE)
|
|
2383
3354
|
return 1
|