diff --git a/.agents/skills/webjs/references/styling.md b/.agents/skills/webjs/references/styling.md index 40fff09a6..05fd421ec 100644 --- a/.agents/skills/webjs/references/styling.md +++ b/.agents/skills/webjs/references/styling.md @@ -115,7 +115,7 @@ So near an island the fragment is the smaller and more predictable choice, not a ### A design system for repeated PRIMITIVES: class helpers built on `@webjsdev/ui` -An `html`-fragment helper is right for a repeated CHUNK of markup (the rubric above). For a repeated UI PRIMITIVE (button, input, card, badge) that needs variants and sizes, use a class helper instead: a function that returns a Tailwind class STRING you spread onto a native element. That is exactly what `@webjsdev/ui` ships (`buttonClass({ variant, size })`, `cardClass()`, `inputClass()`, `badgeClass({ variant })`), and it is what the scaffold gallery uses in `components/ui/`. To style a ONE-OFF that a variant does not cover (a circular icon button, a pill), compose the helper and override the bespoke bits with `cn()`: `cn(buttonClass({ variant: 'secondary', size: 'none' }), 'w-9 h-9 rounded-full')`. `cn` resolves Tailwind conflicts so a later class wins, including a shorthand over the axis it subsumes (`p-0` beats an earlier `px-4 py-2`), so an override just works. Conflicts are keyed on the CSS PROPERTY wherever `cn` can tell the properties apart, rather than on the shared class prefix, so the common prefix collisions do NOT evict: `cn('border-2', 'border-primary')` keeps both (a width and a colour), `cn('flex', 'flex-1')` keeps both (a `display` and a `flex-grow`, the shape an element that is both a flex container and a flex child needs), `cn('shadow-lg', 'shadow-red-500')` keeps both (a box-shadow and its colour), `cn('bg-clip-text', 'bg-primary')` keeps both (a clip and a colour, so the gradient-text idiom survives a later background), and an arbitrary value carrying a type hint is read as the property the hint names (`cn('shadow-lg', 'shadow-[color:red]')` keeps both). It is a small hand-rolled merger, not `tailwind-merge`, so it is still coarse in two ways. A prefix outside the families it knows is not grouped at all, so both classes are emitted and the winner is left to compiled stylesheet order (`inset-shadow-sm` against `inset-shadow-red-500`, `ring-2` against `ring-red-500`). And where one prefix carries two properties it reads the value against Tailwind's DEFAULT scales, so a `@theme`-extended name it cannot know about can still be misread and evict the wrong class: a custom `--shadow-card` makes `shadow-card` a box-shadow, but `cn` sees an unfamiliar name under a prefix whose bare names are usually colours and treats it as one. When an override has to win and you are unsure, pass the one class rather than layering, or install `clsx` + `tailwind-merge` and replace the helper (its header comment shows the swap). For an icon button prefer `size: 'none'` (it states "I supply my own box" by dropping the helper's padding + radius) over layering a `p-0` on top of the default size. +An `html`-fragment helper is right for a repeated CHUNK of markup (the rubric above). For a repeated UI PRIMITIVE (button, input, card, badge) that needs variants and sizes, use a class helper instead: a function that returns a Tailwind class STRING you spread onto a native element. That is exactly what `@webjsdev/ui` ships (`buttonClass({ variant, size })`, `cardClass()`, `inputClass()`, `badgeClass({ variant })`), and it is what the scaffold gallery uses in `components/ui/`. To style a ONE-OFF that a variant does not cover (a circular icon button, a pill), compose the helper and override the bespoke bits with `cn()`: `cn(buttonClass({ variant: 'secondary', size: 'none' }), 'w-9 h-9 rounded-full')`. `cn` resolves Tailwind conflicts so a later class wins, including a shorthand over the axis it subsumes (`p-0` beats an earlier `px-4 py-2`), so an override just works. Conflicts are keyed on the CSS PROPERTY wherever `cn` can tell the properties apart, rather than on the shared class prefix, so the common prefix collisions do NOT evict: `cn('border-2', 'border-primary')` keeps both (a width and a colour), `cn('flex', 'flex-1')` keeps both (a `display` and a `flex-grow`, the shape an element that is both a flex container and a flex child needs), `cn('shadow-lg', 'shadow-red-500')` keeps both (a box-shadow and its colour), `cn('bg-clip-text', 'bg-primary')` keeps both (a clip and a colour, so the gradient-text idiom survives a later background), and an arbitrary value carrying a type hint is read as the property the hint names (`cn('shadow-lg', 'shadow-[color:red]')` keeps both). It is a small hand-rolled merger, not `tailwind-merge`, so it is still coarse in two ways. A prefix outside the families it knows is not grouped at all, so both classes are emitted and the winner is left to compiled stylesheet order (`inset-shadow-sm` against `inset-shadow-red-500`, `ring-2` against `ring-red-500`). And where one prefix carries two properties it reads the value against Tailwind's DEFAULT scales, so a `@theme`-extended name it cannot know about can still be misread and evict the wrong class: a custom `--shadow-card` makes `shadow-card` a box-shadow, but `cn` sees an unfamiliar name under a prefix whose bare names are usually colours and treats it as one. When an override has to win and you are unsure, pass the one class rather than layering, or install `clsx` + `tailwind-merge` and replace the helper (its header comment shows the swap). For an icon button prefer `size: 'none'` (it states "I supply my own box" by dropping the helper's padding + radius) over layering a `p-0` on top of the default size. Under the opt-in `webjsui lint` (configured by a `lint` block in `components.json`, see `references/ui-kit.md`), `w-9 h-9` is layout and `rounded-full` is shape in the shadcn category taxonomy, so an app running it sets `no-restyle` to `allow: ["layout", "rounded"]` for this idiom to pass: the plain radius group is granted by name without opening the whole `shape` category, and `border-2` beside a helper still fires. ```ts // components/ui/button.ts (npx webjsdev ui add button, themed to your app) diff --git a/.agents/skills/webjs/references/ui-kit.md b/.agents/skills/webjs/references/ui-kit.md index 08a13e621..ccebddb9e 100644 --- a/.agents/skills/webjs/references/ui-kit.md +++ b/.agents/skills/webjs/references/ui-kit.md @@ -52,6 +52,31 @@ So the loop is: `add` the component, then query `ui ` (MCP) or that ships inside the installed `@webjsdev/ui`, with no network. This pins you to the installed version; run `npx webjsdev ui diff` to see where your local copies drift from the upstream (that command alone compares against the live registry). +- `npx webjsdev ui lint` is an OPT-IN design-system linter over the app's own + source. It reads the Tailwind classes in `html` templates, `cn()` calls and + `class=${...}` holes and reports, at the line, a raw palette colour where the + theme declares a role token (`no-raw-colors`, with a message naming only the + `--color-*` tokens the configured `tailwind.css` actually declares), an + arbitrary value such as `p-[13px]` (`no-arbitrary-values`; an arbitrary + VARIANT like `[&_svg]:size-4` never fires), and a class composed over a kit + helper (`no-restyle`, naming the helper's real variants and sizes read from + the app's copied `components/ui/*.ts`). It is off until `components.json` + carries a `lint` block, and with no block it reports nothing and exits 0. + `components/ui/**` is skipped by default (a copied primitive owns structural + values no variant expresses), and `allow` uses shadcn's category taxonomy + (`layout`, `color`, `typography`, `spacing`, `shape`, `effects`, `motion`) or + a class-group id such as `rounded`. `--json` emits `{ violations, summary }` + for an agent loop; `--max-warnings ` pins a count. + + ```json + "lint": { + "rules": { + "no-raw-colors": "warn", + "no-arbitrary-values": { "severity": "warn", "allow": ["layout"] }, + "no-restyle": { "severity": "error", "allow": ["layout", "rounded"] } + } + } + ``` ## Inventory (run `npx webjsdev ui list` or the MCP `ui` tool for the authoritative, current set) diff --git a/AGENTS.md b/AGENTS.md index 0aa912923..c474bc77b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -581,7 +581,7 @@ webjs help [command] # full usage banner, or per-command usage + O webjs typecheck [tsc args...] # the project's own tsc --noEmit webjs create [--template api] [--skip-ci] # --skip-ci omits the GitHub workflow (rails new parity, #1471); the local webjs.ci list and `ci` script always ship webjs db [args] # wraps drizzle-kit by default (+ runs db/seed.server.ts). Bring your own ORM (#1468): a `"webjs": { "db": { "": "" } }` block in package.json runs that shell command instead (node_modules/.bin on PATH, extra args appended), any key is a verb, an unmapped verb keeps its default, so `webjs db migrate` is one spelling across ORMs and the scaffolded start.before / Dockerfile / CI keep working after a swap -webjs ui init | add | list | view +webjs ui init | add | list | view | diff | info | lint # lint is the opt-in design-system linter (#1478), configured by a `lint` block in components.json; no block reports nothing webjs vendor pin|unpin|list|audit|outdated|update [--from PROVIDER] # importmap pinning, .webjs/vendor/importmap.json ``` diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index a85e96474..687a72c41 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -199,7 +199,7 @@ README.md npm-facing package readme. | `webjs typecheck [tsc args]` | Resolves the project's own `typescript/bin/tsc` (via `createRequire` from the app cwd) and spawns it with `--noEmit`, passing extra args through. Exits non-zero on a type error (a CI gate). A clear message + non-zero exit when typescript is not installed (#265). The framework runs the standard compiler, it does not embed one | | `webjs create [--template …] [--db …] [--runtime node\|bun]` | `scaffoldApp()` from `lib/create.js`. `` is validated by `lib/app-name.js` BEFORE any file is written (#1066; npm package-name rules minus the lowercase-only clause, which never protected anything and which `webjs create MyApp` relied on), at all three entries (this bin, `scaffoldApp()`, and the `create-webjs` wrapper). `--runtime bun` (or `bun create webjs`, auto-detected) emits a Bun-flavored app (#541): `dev`/`start` scripts force `bun --bun`, `bun.lock`, a pure `oven/bun:1` Dockerfile + bun-install CI, and bun-command agent docs. Orthogonal to `--template` (invariant 1 stays exactly 3 templates). | | `webjs db ` | Runs the app's resolved `drizzle-kit` bin via `process.execPath` (no codegen step; `generate` is schema-to-SQL). Resolves the bin from the app's node_modules + spawns it with the current runtime (no `npx`, #570), so it works on Node and Bun, including a Node-less `oven/bun` image. `webjs db seed` runs the app's `db/seed.server.ts` directly. | -| `webjs ui ` | Proxies to `@webjsdev/ui` (see "UI subcommand" below) | +| `webjs ui ` | Proxies to `@webjsdev/ui` (see "UI subcommand" below) | | `webjs version` | Prints the installed `@webjsdev/cli` version (#975, `readCliVersion()` reads the package's own package.json). Also reachable as the top-level `webjs --version` / `-v` flag, handled at the top of `main()` before the Node preflight so it works on an old Node. Tests: `test/cli/help.test.mjs` | | `webjs help [command]` | Bare: the full USAGE banner. `webjs help ` prints that command's usage line, a one-line summary, an **Options** table (each flag + a universal `-h, --help` row, matching the Remix CLI's per-command Options section), and an Examples block from the `HELP` map in `bin/webjs.js` (#975), so an agent reads the exact invocation instead of guessing flags. An unknown help topic prints an error + the banner and **exits 1** (`printCommandHelp` returns false). The `--help` / `-h` FLAG forms are equivalent and handled at the top of `main()` (before the Node preflight, so they work on an old Node): `webjs --help` / `-h` prints the banner; `webjs --help` / `-h` prints that command's help and short-circuits the body. Commands that forward args to an external CLI (`HELP_FLAG_PASSTHROUGH` = `typecheck` to tsc, `db` to drizzle-kit, `ui` to `@webjsdev/ui`) are excluded so the wrapped tool's own `--help` reaches it; an unrecognised command is not intercepted either, so it hits the Unknown-command error (exit 1). Tests: `test/cli/help.test.mjs` | @@ -219,7 +219,7 @@ with the AI-first component CLI out of the box, no separate install. where the user installed `@webjsdev/ui` directly without going through `@webjsdev/cli`. -The subcommands (`init`, `add`, `list`, `view`, `diff`, `info`) are owned +The subcommands (`init`, `add`, `list`, `view`, `diff`, `info`, `lint`) are owned and documented by `@webjsdev/ui`. See [`../ui/AGENTS.md`](../ui/AGENTS.md) for the surface. The CLI does not wrap or transform args; everything after `webjs ui` is forwarded diff --git a/packages/cli/bin/webjs.js b/packages/cli/bin/webjs.js index f9dc2df62..b315e80ac 100755 --- a/packages/cli/bin/webjs.js +++ b/packages/cli/bin/webjs.js @@ -290,7 +290,7 @@ const HELP = { examples: ['webjs db generate', 'webjs db migrate', 'webjs db studio', 'webjs db seed'], }, ui: { - usage: 'webjs ui [names...]', + usage: 'webjs ui [names...]', summary: 'AI-first component library CLI. Requires @webjsdev/ui installed in the project.', examples: ['webjs ui init', 'webjs ui add button card', 'webjs ui list'], }, diff --git a/packages/ui/AGENTS.md b/packages/ui/AGENTS.md index 6ae5ea39b..5f1486d5b 100644 --- a/packages/ui/AGENTS.md +++ b/packages/ui/AGENTS.md @@ -1,7 +1,7 @@ # AGENTS.md : @webjsdev/ui The webjs **AI-first component library + CLI**, `webjsui init` / `add` / -`list` / `view` / `diff` / `info` / `build`. Ships 32 primitives across two +`list` / `view` / `diff` / `info` / `build` / `lint`. Ships 32 primitives across two tiers: class-helper functions for visual components, custom elements only where state matters. Variant names, sizes, and data-attribute conventions mirror shadcn so existing shadcn knowledge transfers directly. @@ -92,12 +92,19 @@ packages/ui/ diff.js diff, compare local vs registry info.js info, project diagnostics build.js build, compile a custom registry (for registry authors) + lint.js lint, the opt-in design-system linter (runLint() is the pure core the test calls) + lint/ + index.js lintApp(): walks the D7 scope, runs the scanner, dispatches sites to the enabled rules (the one filesystem-touching module) + grammar.js `webjsui lint` token grammar: parseToken() (arbitrary VALUE vs VARIANT), groupOf(), GROUP_CATEGORY (shadcn-ui/lint's taxonomy, verbatim) + scan.js the class-site scanner (html-template attributes, cn() args, class=${} holes), pure over source + rules/ the three rules, each pure `(site, ctx) => violations`: no-raw-colors, no-arbitrary-values, no-restyle + theme-tokens.js READS the app's `--color-*` tokens from its @theme / @theme inline blocks (utils/theme.js WRITES them) registry/ - schema.js zod schemas (wire-compatible with shadcn's) + schema.js zod schemas (wire-compatible with shadcn's) + the opt-in `lint` block of components.json local.js LOCAL-FIRST composer: read the packaged registry from disk (no network) fetcher.js network GET + cache; local-vs-network dispatch (getRegistryItem/Index) example.js extract / strip the module-JSDoc @example block - extract.js shared kit projector (view + MCP `ui` tool): inventory + per-component helpers/example/deps + extract.js shared kit projector (view + MCP `ui` tool): inventory + per-component helpers/example/deps, plus extractHelperAxes() (the variant / size VALUES a `webjsui lint` no-restyle message names) resolver.js walk registryDependencies transitively utils/ get-config.js read components.json @@ -291,6 +298,119 @@ later change cannot quietly make the test non-discriminating. | `webjsui diff [name]` | Show diffs between local and registry (against the LIVE upstream) | | `webjsui info` | Print cwd + config + registry URL | | `webjsui build [file]` | Compile a custom registry (for registry authors) | +| `webjsui lint` | Opt-in design-system linter over the app's own source (see the section below). Reports nothing and exits 0 with no `lint` block in `components.json`. `--json` emits `{ violations, summary }`, `--max-warnings ` caps warnings, exit is 1 on an error-level violation, an exceeded cap, or a config it cannot run against. | + +### `webjsui lint`: the opt-in design-system linter (#1478) + +The kit's styling guidance is prose, and prose is what an agent skips. The +linter fires at the exact line where an app drifts off its design system, with +a message built from the app's OWN tokens and helper variants (the +`@shadcn/lint` thesis: a diagnostic naming the real alternative converges an +agent in one correction round where rules text takes more). It is NOT part of +`webjs check`, which stays correctness-only (a sensible app can legitimately +want `bg-pink-500`), and it is off until an app opts in. + +**Config.** A `lint` block at the top level of `components.json`, beside +`tailwind` and `aliases`. A rule value is a bare severity (`"off"` / `"warn"` / +`"error"`) or `{ severity, allow }`. A rule absent from `rules` is off. The +schema is strict (`lintConfigSchema` in `registry/schema.js`), so a typo is a +config error rather than a silent no-op. + +```json +"lint": { + "ignore": ["app/legacy/**"], + "rules": { + "no-raw-colors": "warn", + "no-arbitrary-values": { "severity": "warn", "allow": ["layout"] }, + "no-restyle": { "severity": "error", "allow": ["layout", "rounded"] } + } +} +``` + +**The three rules**, each pure `(site, ctx) => violations` in `src/lint/rules/`: + +- `no-raw-colors`: a Tailwind palette utility (`text-red-600`) where the theme + declares a role token. The message lists only utilities the app's configured + `tailwind.css` declares (`--color-*` inside `@theme` OR `@theme inline`, both + live in this repo) and adds a role match only where unambiguous (`red` / + `rose` to `destructive`; the neutral families to `muted-foreground` under + `text-`, `muted` otherwise) and only when that token exists. A role match + also sets `fix` in the `--json` output, as a drop-in for the reported `class` + (`hover:text-red-600/50` gets `hover:text-destructive/50`, keeping the + variants, the opacity modifier and a `!`). CSS comments are stripped before + the theme is read, so a commented-out token is never named. A theme file + yielding no tokens turns the rule OFF for the run with one warning naming the + path, because the rule's whole value is naming the alternative. +- `no-arbitrary-values`: a token whose UTILITY segment carries a `[` + (`p-[13px]`, `ring-[3px]`, `[padding:13px]`). An arbitrary VARIANT + (`[&_svg]:size-4`, `has-[>svg]:px-3`, `data-[state=open]:flex`) and the + `(--var)` shorthand never fire. That single rule, in `grammar.js` + `parseToken`, decides every case. +- `no-restyle`: a class composed beside a kit helper, in either shape an app + writes (`cn(buttonClass(), 'rounded-full')`, or a `class` attribute holding a + `${buttonClass()}` hole plus static text). The message names the helper's + REAL variants and sizes through `extractHelperAxes` in `registry/extract.js`, + read from the APP's copied `components/ui/*.ts` (an app may add or remove a + variant), never from a second parser; a helper matching neither authored + shape gets no value list rather than an invented one. Drift guard: + `test/lint-message-drift.test.js`. + +**`allow` is shadcn's category taxonomy, verbatim.** `grammar.js` +`GROUP_CATEGORY` transcribes `grammar/categories.ts` from `shadcn-ui/lint` in +upstream order so it diffs cleanly. Six named categories (`color`, +`typography`, `spacing`, `shape`, `effects`, `motion`) and everything else is +`layout`. Two placements are counterintuitive and both are kept: padding is +`spacing`, margin is `layout`. `allow` takes a category name OR a class-group +id, so `["layout", "rounded"]` admits `w-9 h-9 rounded-full` (the skill's +sanctioned icon-button one-off) without opening the whole `shape` category +(`border-2` still fires). Do NOT redefine the taxonomy locally. #1116 was +reverted for inventing vocabulary shadcn does not ship. + +**Where it reads classes** (`src/lint/scan.js`, the three CLASS SITES, the +complete set): a `class=` attribute inside an OPEN TAG inside an `html` tagged +template (nested templates in holes recursed); every string literal inside a +recognized `cn(` call; every string literal inside a `class=${...}` hole. A +plain template literal counts as a string literal in both, read at its static +text and split at its holes. A +helper is recognized only by its IMPORT resolving inside `aliases.ui`, and `cn` +only when imported from `aliases.utils`, so a local `fooClass()` is never +mistaken for a kit helper. Recognition keys on the EXPORTED name, so an aliased +`buttonClass as bc` is still a helper and its axes are still found. The open-tag requirement is what keeps an +entity-escaped docs code sample (`<p class="text-red-600">`) inert +without any docs-page heuristic, and commented-out markup (``) +inside a template opens no tag either. A token touching a hole with no +whitespace between is dropped as a fragment (`class="text-${size} p-2"` yields +`p-2`). A literal that is a comparison operand (`kind === 'primary'`) or a +`case` label is not collected; any other literal in a class hole or `cn()` call +is read as a class, and an unknown token falls to `layout`, so a stray one is +admitted by the recommended config rather than reported. `no-restyle` names +every helper a site composes and reads the axes from the LAST one, which is +the argument `cn` lets win. + +**Scope.** `app/**`, `components/**`, `modules/**`, `lib/**` over +`.ts .tsx .js .jsx .mts .mjs`, never `node_modules`, `.webjs`, `dist`, +`public`. **`components/ui/**` (the resolved `aliases.ui`) is skipped by +default**: a copied primitive legitimately owns structural values no variant +can express (the kit button's `focus-visible:ring-[3px]` and +`[&_svg:not([class*='size-'])]:size-4`), and it is what other files' variants +are measured against. This is also why the sonner raw palette colors +(`sonner.ts` success / info / warning icons) are LEFT ALONE. There is no token +to move them to, and inventing a `--success` is what got #1116 reverted. Widen +the scope with a negated entry, which un-ignores whatever it MATCHES +(`"ignore": ["!components/ui/**"]` for the whole dir, +`"!components/ui/button.ts"` for one file), narrow it with more globs. +Entries match the path relative to the app root, and an entry also covers its +own subtree, so a bare directory (`app/legacy`, `app/legacy/`, `./app/legacy`) +ignores everything under it. + +**Phases.** Phase 1 (this) ships the command and the docs that DESCRIBE it. +Nothing tells an agent to run it in its loop, and nothing adds it to the +scaffold's generated `components.json` or a `webjs.ci` list, until the +three-arm eval in `test/evals/` (before, after with diagnostics, and a +rules-only control, run through the `claude` CLI on a scratch copy of +`gallery`) shows diagnostics converge in fewer rounds than rules text on two of +three models. That is the #1116 lesson: shipping the guidance before the gate +ran is what let it land measuring nothing. ### Where the shared helpers land (#1129) diff --git a/packages/ui/README.md b/packages/ui/README.md index d12e37f73..cec6ea41d 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -127,6 +127,7 @@ always compares against the live upstream. | `webjsui diff [name]` | Show diff between your local copy and the live registry | | `webjsui info` | Print project diagnostics | | `webjsui build` | (For registry authors) Compile a custom registry | +| `webjsui lint` | Opt-in design-system linter. Reports raw palette colors, arbitrary values and classes composed over a kit helper, at the line, with a message built from your own theme tokens and helper variants. Configured by a `lint` block in `components.json`; with no block it reports nothing and exits 0. `--json` for an agent loop, `--max-warnings ` to pin a count. | ## Tag convention diff --git a/packages/ui/src/commands/lint.js b/packages/ui/src/commands/lint.js new file mode 100644 index 000000000..754c1a320 --- /dev/null +++ b/packages/ui/src/commands/lint.js @@ -0,0 +1,93 @@ +import { Command } from 'commander'; +import { getConfig } from '../utils/get-config.js'; +import { lintApp } from '../lint/index.js'; +import { logger } from '../utils/logger.js'; + +/** + * Run the linter and shape its report, with no printing and no exit, so the + * command test calls this directly. `code` is 0 or 1 only, matching `webjs + * check`'s posture that an agent gates on non-zero: 1 when any error-level + * violation exists, when the warning count exceeds `maxWarnings` (-1 is no + * cap), or when the command cannot run at all (no `components.json`, or a + * config that fails the schema). + * + * @param {{ cwd?: string, json?: boolean, maxWarnings?: number|string }} [opts] + * @returns {{ lines: string[], code: number, report: null | { violations: any[], summary: any, warnings: string[] } }} + */ +export function runLint(opts = {}) { + const cwd = opts.cwd ?? process.cwd(); + const maxWarnings = Number(opts.maxWarnings ?? -1); + /** @type {string[]} */ + const lines = []; + // A run that cannot start still answers in the requested shape, so an agent + // loop parsing stdout under --json receives an error document, never text. + const refuse = (error) => { + if (opts.json) { + lines.push(JSON.stringify({ error, violations: [], summary: { count: 0, errors: 0, warnings: 0, byRule: {} }, warnings: [], configured: false }, null, 2)); + } else { + lines.push(`webjsui lint: ${error}`); + } + return { lines, code: 1, report: null }; + }; + if (!Number.isInteger(maxWarnings) || maxWarnings < -1) { + return refuse(`--max-warnings expects an integer (-1 for no cap), got ${JSON.stringify(String(opts.maxWarnings))}`); + } + let config; + try { + config = getConfig(cwd); + } catch (e) { + return refuse(`components.json is invalid: ${firstIssue(e)}`); + } + if (!config) return refuse('components.json not found (run `npx @webjsdev/ui init`)'); + + const { violations, warnings, configured } = lintApp(cwd, config); + const errors = violations.filter((v) => v.severity === 'error').length; + const warns = violations.length - errors; + /** @type {Record} */ + const byRule = {}; + for (const v of violations) byRule[v.rule] = (byRule[v.rule] ?? 0) + 1; + const report = { + violations, + summary: { count: violations.length, errors, warnings: warns, byRule }, + warnings, + configured, + }; + + if (opts.json) { + lines.push(JSON.stringify(report, null, 2)); + } else if (!configured) { + lines.push('webjsui lint: no rules configured (add a "lint" block to components.json)'); + } else { + for (const w of warnings) lines.push(`⚠ ${w}`); + if (violations.length === 0) { + lines.push('webjsui lint: all checks pass ✓'); + } else { + lines.push(`webjsui lint: ${violations.length} problem(s) found (${errors} error${errors === 1 ? '' : 's'}, ${warns} warning${warns === 1 ? '' : 's'})`); + for (const v of violations) { + lines.push(''); + lines.push(` ${v.severity === 'error' ? '✗' : '⚠'} [${v.rule}] ${v.file}:${v.line}:${v.column}`); + lines.push(` ${v.message}`); + } + } + } + const code = errors > 0 || (maxWarnings >= 0 && warns > maxWarnings) ? 1 : 0; + return { lines, code, report }; +} + +function firstIssue(e) { + const issue = e?.issues?.[0]; + if (issue) return `${issue.path?.join('.') || '(root)'}: ${issue.message}`; + return String(e?.message ?? e); +} + +export const lint = new Command() + .name('lint') + .description('Check the app against its design system (opt-in, configured in components.json)') + .option('-c, --cwd ', 'the working directory', process.cwd()) + .option('--json', 'emit structured violations for an agent loop') + .option('--max-warnings ', 'fail when warnings exceed this count', '-1') + .action((opts) => { + const { lines, code } = runLint(opts); + for (const l of lines) logger.info(l); + if (code !== 0) process.exitCode = code; + }); diff --git a/packages/ui/src/index.js b/packages/ui/src/index.js index 4018cb621..66938ceca 100644 --- a/packages/ui/src/index.js +++ b/packages/ui/src/index.js @@ -9,6 +9,7 @@ import { view } from './commands/view.js'; import { diff } from './commands/diff.js'; import { info } from './commands/info.js'; import { build } from './commands/build.js'; +import { lint } from './commands/lint.js'; const pkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8')); @@ -27,6 +28,7 @@ program .addCommand(view) .addCommand(diff) .addCommand(info) - .addCommand(build); + .addCommand(build) + .addCommand(lint); program.parse(); diff --git a/packages/ui/src/lint/grammar.js b/packages/ui/src/lint/grammar.js new file mode 100644 index 000000000..b2ecb75c4 --- /dev/null +++ b/packages/ui/src/lint/grammar.js @@ -0,0 +1,839 @@ +/** + * The class grammar behind `webjsui lint`: how one Tailwind token is parsed, + * which class GROUP it belongs to, and which appearance CATEGORY that group + * maps to. + * + * `GROUP_CATEGORY` is a verbatim transcription of `packages/lint/src/grammar/ + * categories.ts` from `shadcn-ui/lint` (https://github.com/shadcn-ui/lint), + * same group ids in the same order, so it can be diffed against upstream. The + * taxonomy is adopted as-is on purpose: `@webjsdev/ui` exists for shadcn + * parity, and a WebJs-local redefinition would make `allow: ["layout"]` mean + * two different things in two tools an agent runs side by side. Two placements + * are counterintuitive and both are shadcn's: padding is `spacing` and margin + * is layout (`null`). + * + * `groupOf(utility)` is WebJs's own resolver from a utility to one of those + * group ids. It is a prefix table rather than `tailwind-merge`'s config, which + * the linter deliberately does not depend on (a runtime dependency for a + * dev-time analysis). A utility it cannot place resolves to `null`, which is + * layout, the permissive direction. + * + * @module lint/grammar + */ + +/** @type {Record} */ +export const GROUP_CATEGORY = { + aspect: null, + container: null, + 'container-type': null, + 'container-named': null, + contain: null, + 'contain-size': null, + 'contain-layout': null, + 'contain-paint': null, + 'contain-style': null, + columns: null, + 'break-after': null, + 'break-before': null, + 'break-inside': null, + 'box-decoration': null, + box: null, + display: null, + sr: null, + float: null, + clear: null, + isolation: null, + 'object-fit': null, + 'object-position': null, + overflow: null, + 'overflow-x': null, + 'overflow-y': null, + overscroll: null, + 'overscroll-x': null, + 'overscroll-y': null, + position: null, + inset: null, + 'inset-x': null, + 'inset-y': null, + start: null, + end: null, + 'inset-bs': null, + 'inset-be': null, + top: null, + right: null, + bottom: null, + left: null, + visibility: null, + z: null, + basis: null, + 'flex-direction': null, + 'flex-wrap': null, + flex: null, + grow: null, + shrink: null, + order: null, + 'grid-cols': null, + 'col-start-end': null, + 'col-start': null, + 'col-end': null, + 'grid-rows': null, + 'row-start-end': null, + 'row-start': null, + 'row-end': null, + 'grid-flow': null, + 'auto-cols': null, + 'auto-rows': null, + gap: 'spacing', + 'gap-x': 'spacing', + 'gap-y': 'spacing', + 'justify-content': null, + 'justify-items': null, + 'justify-self': null, + 'align-content': null, + 'align-items': null, + 'align-self': null, + 'place-content': null, + 'place-items': null, + 'place-self': null, + p: 'spacing', + px: 'spacing', + py: 'spacing', + ps: 'spacing', + pe: 'spacing', + pbs: 'spacing', + pbe: 'spacing', + pt: 'spacing', + pr: 'spacing', + pb: 'spacing', + pl: 'spacing', + m: null, + mx: null, + my: null, + ms: null, + me: null, + mbs: null, + mbe: null, + mt: null, + mr: null, + mb: null, + ml: null, + 'space-x': 'spacing', + 'space-x-reverse': 'spacing', + 'space-y': 'spacing', + 'space-y-reverse': 'spacing', + size: null, + 'inline-size': null, + 'min-inline-size': null, + 'max-inline-size': null, + 'block-size': null, + 'min-block-size': null, + 'max-block-size': null, + w: null, + 'min-w': null, + 'max-w': null, + h: null, + 'min-h': null, + 'max-h': null, + 'font-size': 'typography', + 'font-smoothing': 'typography', + 'font-style': 'typography', + 'font-weight': 'typography', + 'font-stretch': 'typography', + 'font-family': 'typography', + 'font-features': 'typography', + 'fvn-normal': 'typography', + 'fvn-ordinal': 'typography', + 'fvn-slashed-zero': 'typography', + 'fvn-figure': 'typography', + 'fvn-spacing': 'typography', + 'fvn-fraction': 'typography', + tracking: 'typography', + 'line-clamp': 'typography', + leading: 'typography', + 'list-image': 'typography', + 'list-style-position': 'typography', + 'list-style-type': 'typography', + 'text-alignment': null, + 'placeholder-color': 'color', + 'text-color': 'color', + 'text-decoration': 'typography', + 'text-decoration-style': 'typography', + 'text-decoration-thickness': 'typography', + 'text-decoration-color': 'color', + 'underline-offset': 'typography', + 'text-transform': 'typography', + 'text-overflow': 'typography', + 'text-wrap': 'typography', + indent: 'typography', + 'tab-size': null, + 'vertical-align': null, + whitespace: null, + break: null, + wrap: null, + hyphens: 'typography', + content: null, + 'bg-attachment': 'effects', + 'bg-clip': 'effects', + 'bg-origin': 'effects', + 'bg-position': 'effects', + 'bg-repeat': 'effects', + 'bg-size': 'effects', + 'bg-image': 'effects', + 'bg-color': 'color', + 'gradient-from-pos': 'effects', + 'gradient-via-pos': 'effects', + 'gradient-to-pos': 'effects', + 'gradient-from': 'color', + 'gradient-via': 'color', + 'gradient-to': 'color', + rounded: 'shape', + 'rounded-s': 'shape', + 'rounded-e': 'shape', + 'rounded-t': 'shape', + 'rounded-r': 'shape', + 'rounded-b': 'shape', + 'rounded-l': 'shape', + 'rounded-ss': 'shape', + 'rounded-se': 'shape', + 'rounded-ee': 'shape', + 'rounded-es': 'shape', + 'rounded-tl': 'shape', + 'rounded-tr': 'shape', + 'rounded-br': 'shape', + 'rounded-bl': 'shape', + 'border-w': 'shape', + 'border-w-x': 'shape', + 'border-w-y': 'shape', + 'border-w-s': 'shape', + 'border-w-e': 'shape', + 'border-w-bs': 'shape', + 'border-w-be': 'shape', + 'border-w-t': 'shape', + 'border-w-r': 'shape', + 'border-w-b': 'shape', + 'border-w-l': 'shape', + 'divide-x': 'shape', + 'divide-x-reverse': 'shape', + 'divide-y': 'shape', + 'divide-y-reverse': 'shape', + 'border-style': 'shape', + 'divide-style': 'shape', + 'border-color': 'color', + 'border-color-x': 'color', + 'border-color-y': 'color', + 'border-color-s': 'color', + 'border-color-e': 'color', + 'border-color-bs': 'color', + 'border-color-be': 'color', + 'border-color-t': 'color', + 'border-color-r': 'color', + 'border-color-b': 'color', + 'border-color-l': 'color', + 'divide-color': 'color', + 'outline-style': 'shape', + 'outline-offset': 'shape', + 'outline-w': 'shape', + 'outline-color': 'color', + shadow: 'effects', + 'shadow-color': 'color', + 'inset-shadow': 'effects', + 'inset-shadow-color': 'color', + 'ring-w': 'shape', + 'ring-w-inset': 'shape', + 'ring-color': 'color', + 'ring-offset-w': 'shape', + 'ring-offset-color': 'color', + 'inset-ring-w': 'shape', + 'inset-ring-color': 'color', + 'text-shadow': 'effects', + 'text-shadow-color': 'color', + opacity: 'effects', + 'mix-blend': 'effects', + 'bg-blend': 'effects', + 'mask-clip': 'effects', + 'mask-composite': 'effects', + 'mask-image-linear-pos': 'effects', + 'mask-image-linear-from-pos': 'effects', + 'mask-image-linear-to-pos': 'effects', + 'mask-image-linear-from-color': 'color', + 'mask-image-linear-to-color': 'color', + 'mask-image-t-from-pos': 'effects', + 'mask-image-t-to-pos': 'effects', + 'mask-image-t-from-color': 'color', + 'mask-image-t-to-color': 'color', + 'mask-image-r-from-pos': 'effects', + 'mask-image-r-to-pos': 'effects', + 'mask-image-r-from-color': 'color', + 'mask-image-r-to-color': 'color', + 'mask-image-b-from-pos': 'effects', + 'mask-image-b-to-pos': 'effects', + 'mask-image-b-from-color': 'color', + 'mask-image-b-to-color': 'color', + 'mask-image-l-from-pos': 'effects', + 'mask-image-l-to-pos': 'effects', + 'mask-image-l-from-color': 'color', + 'mask-image-l-to-color': 'color', + 'mask-image-x-from-pos': 'effects', + 'mask-image-x-to-pos': 'effects', + 'mask-image-x-from-color': 'color', + 'mask-image-x-to-color': 'color', + 'mask-image-y-from-pos': 'effects', + 'mask-image-y-to-pos': 'effects', + 'mask-image-y-from-color': 'color', + 'mask-image-y-to-color': 'color', + 'mask-image-radial': 'effects', + 'mask-image-radial-from-pos': 'effects', + 'mask-image-radial-to-pos': 'effects', + 'mask-image-radial-from-color': 'color', + 'mask-image-radial-to-color': 'color', + 'mask-image-radial-shape': 'effects', + 'mask-image-radial-size': 'effects', + 'mask-image-radial-pos': 'effects', + 'mask-image-conic-pos': 'effects', + 'mask-image-conic-from-pos': 'effects', + 'mask-image-conic-to-pos': 'effects', + 'mask-image-conic-from-color': 'color', + 'mask-image-conic-to-color': 'color', + 'mask-mode': 'effects', + 'mask-origin': 'effects', + 'mask-position': 'effects', + 'mask-repeat': 'effects', + 'mask-size': 'effects', + 'mask-type': 'effects', + 'mask-image': 'effects', + filter: 'effects', + blur: 'effects', + brightness: 'effects', + contrast: 'effects', + 'drop-shadow': 'effects', + 'drop-shadow-color': 'color', + grayscale: 'effects', + 'hue-rotate': 'effects', + invert: 'effects', + saturate: 'effects', + sepia: 'effects', + 'backdrop-filter': 'effects', + 'backdrop-blur': 'effects', + 'backdrop-brightness': 'effects', + 'backdrop-contrast': 'effects', + 'backdrop-grayscale': 'effects', + 'backdrop-hue-rotate': 'effects', + 'backdrop-invert': 'effects', + 'backdrop-opacity': 'effects', + 'backdrop-saturate': 'effects', + 'backdrop-sepia': 'effects', + 'border-collapse': null, + 'border-spacing': 'spacing', + 'border-spacing-x': 'spacing', + 'border-spacing-y': 'spacing', + 'table-layout': null, + caption: null, + transition: 'motion', + 'transition-behavior': 'motion', + duration: 'motion', + ease: 'motion', + delay: 'motion', + animate: 'motion', + backface: null, + perspective: null, + 'perspective-origin': null, + rotate: null, + 'rotate-x': null, + 'rotate-y': null, + 'rotate-z': null, + scale: null, + 'scale-x': null, + 'scale-y': null, + 'scale-z': null, + 'scale-3d': null, + skew: null, + 'skew-x': null, + 'skew-y': null, + transform: null, + 'transform-origin': null, + 'transform-style': null, + translate: null, + 'translate-x': null, + 'translate-y': null, + 'translate-z': null, + 'translate-none': null, + zoom: null, + accent: 'color', + appearance: null, + 'caret-color': 'color', + 'color-scheme': null, + cursor: null, + 'field-sizing': null, + 'pointer-events': null, + resize: null, + 'scroll-behavior': null, + 'scrollbar-thumb-color': 'color', + 'scrollbar-track-color': 'color', + 'scrollbar-gutter': null, + 'scrollbar-w': null, + 'scroll-m': null, + 'scroll-mx': null, + 'scroll-my': null, + 'scroll-ms': null, + 'scroll-me': null, + 'scroll-mbs': null, + 'scroll-mbe': null, + 'scroll-mt': null, + 'scroll-mr': null, + 'scroll-mb': null, + 'scroll-ml': null, + 'scroll-p': null, + 'scroll-px': null, + 'scroll-py': null, + 'scroll-ps': null, + 'scroll-pe': null, + 'scroll-pbs': null, + 'scroll-pbe': null, + 'scroll-pt': null, + 'scroll-pr': null, + 'scroll-pb': null, + 'scroll-pl': null, + 'snap-align': null, + 'snap-stop': null, + 'snap-type': null, + 'snap-strictness': null, + touch: null, + 'touch-x': null, + 'touch-y': null, + 'touch-pz': null, + select: null, + 'will-change': null, + fill: 'color', + 'stroke-w': 'shape', + stroke: 'color', + 'forced-color-adjust': null, +}; + +/** + * Arbitrary properties (`[color:red]`) are categorized by CSS property name + * instead, first match winning (upstream: `ARBITRARY_PROPERTY_RULES`). + * @type {Array<[RegExp, 'color'|'typography'|'spacing'|'shape'|'effects'|'motion']>} + */ +const ARBITRARY_PROPERTY_RULES = [ + [/(?:^|-)color$|^(?:background|fill|stroke|--tw-(?:gradient-(?:from|via|to)|shadow-color|ring-color|inset-ring-color|inset-shadow-color)|--tw-.*-color)$/, 'color'], + [/^(?:padding|gap$|row-gap$|column-gap$)/, 'spacing'], + [/^(?:font|letter-spacing$|line-height$|text-decoration|text-transform$|text-indent$|text-underline|word-spacing$|list-style)/, 'typography'], + [/^(?:border(?:-(?:top|right|bottom|left|inline|block)(?:-(?:start|end))?)?(?:-(?:width|style|radius))?$|border-.*-radius$|outline|--tw-ring-width$|--tw-ring-inset$)/, 'shape'], + [/^(?:box-shadow|text-shadow|opacity|filter|backdrop-filter|mix-blend-mode|background-blend-mode|--tw-(?:shadow|inset-shadow|drop-shadow|blur|brightness|contrast|grayscale|hue-rotate|invert|saturate|sepia|backdrop-.*)$)/, 'effects'], + [/^(?:transition|animation|--tw-(?:duration|ease|delay)$)/, 'motion'], +]; + +const ARBITRARY_PREFIX = 'arbitrary..'; + +export const CATEGORIES = ['color', 'typography', 'spacing', 'shape', 'effects', 'motion']; + +/** + * The appearance category of a group id (`null` is layout). + * @param {string|null} groupId + */ +export function categoryOf(groupId) { + if (groupId === null) return null; + if (groupId.startsWith(ARBITRARY_PREFIX)) { + const property = groupId.slice(ARBITRARY_PREFIX.length); + for (const [pattern, category] of ARBITRARY_PROPERTY_RULES) if (pattern.test(property)) return category; + return null; + } + return GROUP_CATEGORY[groupId] ?? null; +} + +// --------------------------------------------------------------------------- +// Utility -> group resolution +// --------------------------------------------------------------------------- + +const T_SHIRT = /^(?:\d*xs|sm|md|lg|\d*xl)$/; +const NUMERIC = /^-?\d+(?:\.\d+)?$/; +const FRACTION = /^\d+\/\d+$/; +const LENGTH_HINT = /^(?:length|size|percentage|number):/; +const COLOR_HINT = /^color:/; +const COLOR_VALUE = /^(?:#|rgba?\(|hsla?\(|oklch\(|oklab\(|lab\(|lch\(|color\(|color-mix\(|var\(--color|--color-)/; + +/** `[...]` or `(...)` shorthand inner text, or null when the value is neither. */ +function arbitraryInner(value) { + if (value.startsWith('[') && value.endsWith(']')) return value.slice(1, -1); + if (value.startsWith('(') && value.endsWith(')')) return value.slice(1, -1); + return null; +} + +/** Whether an arbitrary value reads as a colour (a hint, a colour function or a colour variable). */ +function isArbitraryColor(value) { + const inner = arbitraryInner(value); + if (inner === null) return false; + return COLOR_HINT.test(inner) || COLOR_VALUE.test(inner); +} + +/** Whether an arbitrary value reads as a length / number (the non-colour direction). */ +function isArbitraryLength(value) { + const inner = arbitraryInner(value); + if (inner === null) return false; + if (LENGTH_HINT.test(inner)) return true; + if (COLOR_HINT.test(inner) || COLOR_VALUE.test(inner)) return false; + return /^(?:-?\d|calc\(|min\(|max\(|clamp\(|var\()/.test(inner); +} + +function isNumberish(value) { + return NUMERIC.test(value) || FRACTION.test(value) || value === 'px' || value === 'full' || value === 'auto'; +} + +/** + * Split `utility` at its first `-` into `[head, rest]` where `head` is the + * longest prefix in `heads`. Returns null when no head matches. + * @param {string} utility + * @param {string[]} heads + */ +function splitHead(utility, heads) { + for (const h of heads) { + if (utility === h) return [h, '']; + if (utility.startsWith(h + '-')) return [h, utility.slice(h.length + 1)]; + } + return null; +} + +/** Groups that take `` or `-` and need no value disambiguation. Longest first. */ +const SIMPLE_GROUPS = [ + 'container-type', 'contain', 'columns', 'break-after', 'break-before', 'break-inside', 'box-decoration', + 'overflow-x', 'overflow-y', 'overflow', 'overscroll-x', 'overscroll-y', 'overscroll', + 'inset-x', 'inset-y', 'inset-bs', 'inset-be', 'inset', 'start', 'end', 'top', 'right', 'bottom', 'left', 'z', + 'basis', 'grow', 'shrink', 'order', 'grid-cols', 'grid-rows', 'grid-flow', 'auto-cols', 'auto-rows', + 'gap-x', 'gap-y', 'gap', + 'justify-items', 'justify-self', 'place-content', 'place-items', 'place-self', + 'px', 'py', 'ps', 'pe', 'pbs', 'pbe', 'pt', 'pr', 'pb', 'pl', 'p', + 'mx', 'my', 'ms', 'me', 'mbs', 'mbe', 'mt', 'mr', 'mb', 'ml', 'm', + 'space-x-reverse', 'space-y-reverse', 'space-x', 'space-y', + 'size', 'min-inline-size', 'max-inline-size', 'inline-size', 'min-block-size', 'max-block-size', 'block-size', + 'min-w', 'max-w', 'w', 'min-h', 'max-h', 'h', + 'font-stretch', 'tracking', 'line-clamp', 'leading', 'underline-offset', 'indent', 'tab-size', 'whitespace', 'hyphens', + 'gradient-from-pos', 'gradient-via-pos', 'gradient-to-pos', + 'outline-offset', 'opacity', 'mix-blend', 'bg-blend', + 'blur', 'brightness', 'contrast', 'grayscale', 'hue-rotate', 'invert', 'saturate', 'sepia', + 'backdrop-blur', 'backdrop-brightness', 'backdrop-contrast', 'backdrop-grayscale', 'backdrop-hue-rotate', + 'backdrop-invert', 'backdrop-opacity', 'backdrop-saturate', 'backdrop-sepia', 'backdrop-filter', + 'border-spacing-x', 'border-spacing-y', 'border-spacing', 'caption', + 'transition-behavior', 'transition', 'duration', 'ease', 'delay', 'animate', + 'backface', 'perspective-origin', 'perspective', + 'rotate-x', 'rotate-y', 'rotate-z', 'rotate', 'scale-3d', 'scale-x', 'scale-y', 'scale-z', 'scale', + 'skew-x', 'skew-y', 'skew', 'translate-none', 'translate-x', 'translate-y', 'translate-z', 'translate', 'zoom', + 'accent', 'appearance', 'cursor', 'field-sizing', 'pointer-events', 'resize', 'scroll-behavior', + 'scrollbar-gutter', 'scrollbar-w', + 'scroll-mx', 'scroll-my', 'scroll-ms', 'scroll-me', 'scroll-mbs', 'scroll-mbe', 'scroll-mt', 'scroll-mr', 'scroll-mb', 'scroll-ml', 'scroll-m', + 'scroll-px', 'scroll-py', 'scroll-ps', 'scroll-pe', 'scroll-pbs', 'scroll-pbe', 'scroll-pt', 'scroll-pr', 'scroll-pb', 'scroll-pl', 'scroll-p', + 'snap-align', 'snap-stop', 'snap-type', 'snap-strictness', 'touch-x', 'touch-y', 'touch-pz', 'touch', + 'select', 'will-change', 'forced-color-adjust', 'aspect', 'container', 'float', 'clear', 'filter', + 'mask-clip', 'mask-composite', 'mask-mode', 'mask-origin', 'mask-position', 'mask-repeat', 'mask-size', 'mask-type', + 'placeholder', 'caret', 'fill', 'stroke', +]; + +const DISPLAY = new Set(['block', 'inline-block', 'inline', 'flex', 'inline-flex', 'table', 'inline-table', + 'table-caption', 'table-cell', 'table-column', 'table-column-group', 'table-footer-group', 'table-header-group', + 'table-row-group', 'table-row', 'flow-root', 'grid', 'inline-grid', 'contents', 'list-item', 'hidden']); +const POSITION = new Set(['static', 'fixed', 'absolute', 'relative', 'sticky']); +const KEYWORDS = { + 'sr-only': 'sr', 'not-sr-only': 'sr', isolate: 'isolation', 'isolation-auto': 'isolation', + visible: 'visibility', invisible: 'visibility', collapse: 'visibility', + 'box-border': 'box', 'box-content': 'box', + italic: 'font-style', 'not-italic': 'font-style', antialiased: 'font-smoothing', 'subpixel-antialiased': 'font-smoothing', + 'normal-nums': 'fvn-normal', ordinal: 'fvn-ordinal', 'slashed-zero': 'fvn-slashed-zero', + 'lining-nums': 'fvn-figure', 'oldstyle-nums': 'fvn-figure', 'proportional-nums': 'fvn-spacing', 'tabular-nums': 'fvn-spacing', + 'diagonal-fractions': 'fvn-fraction', 'stacked-fractions': 'fvn-fraction', + underline: 'text-decoration', overline: 'text-decoration', 'line-through': 'text-decoration', 'no-underline': 'text-decoration', + uppercase: 'text-transform', lowercase: 'text-transform', capitalize: 'text-transform', 'normal-case': 'text-transform', + truncate: 'text-overflow', 'break-normal': 'break', 'break-words': 'break', 'break-all': 'break', 'break-keep': 'break', + 'border-collapse': 'border-collapse', 'border-separate': 'border-collapse', + 'table-auto': 'table-layout', 'table-fixed': 'table-layout', + 'transform-none': 'transform', 'transform-gpu': 'transform', 'transform-cpu': 'transform', + 'ring-inset': 'ring-w-inset', 'flex-wrap': 'flex-wrap', 'flex-nowrap': 'flex-wrap', 'flex-wrap-reverse': 'flex-wrap', + 'flex-row': 'flex-direction', 'flex-row-reverse': 'flex-direction', 'flex-col': 'flex-direction', 'flex-col-reverse': 'flex-direction', +}; +const BORDER_STYLES = new Set(['solid', 'dashed', 'dotted', 'double', 'hidden', 'none']); +const RADIUS_SIDES = ['ss', 'se', 'ee', 'es', 'tl', 'tr', 'br', 'bl', 's', 'e', 't', 'r', 'b', 'l']; +const BORDER_SIDES = ['bs', 'be', 'x', 'y', 's', 'e', 't', 'r', 'b', 'l']; +const SHADOW_SIZES = /^(?:none|2xs|xs|sm|md|lg|xl|2xl|inner)$/; + +/** + * Resolve a utility (variants, negative, important and opacity already + * stripped) to a class-group id, or `null` for one the table cannot place. + * @param {string} utility + * @returns {string|null} + */ +export function groupOf(utility) { + if (!utility) return null; + // Arbitrary property: `[padding:13px]`. + if (utility.startsWith('[') && utility.endsWith(']')) { + const colon = utility.indexOf(':'); + if (colon > 1) return ARBITRARY_PREFIX + utility.slice(1, colon); + return null; + } + if (DISPLAY.has(utility)) return 'display'; + if (POSITION.has(utility)) return 'position'; + if (Object.hasOwn(KEYWORDS, utility)) return KEYWORDS[utility]; + + let s; + // text-shadow-* (before text-*, whose head would otherwise swallow it) + if ((s = splitHead(utility, ['text-shadow']))) { + const v = s[1]; + if (v === '' || SHADOW_SIZES.test(v) || isArbitraryLength(v)) return 'text-shadow'; + return 'text-shadow-color'; + } + // text-* + if ((s = splitHead(utility, ['text']))) { + const v = s[1]; + if (/^(?:left|center|right|justify|start|end)$/.test(v)) return 'text-alignment'; + if (/^(?:wrap|nowrap|balance|pretty)$/.test(v)) return 'text-wrap'; + if (/^(?:ellipsis|clip)$/.test(v)) return 'text-overflow'; + if (v === 'base' || T_SHIRT.test(v) || isArbitraryLength(v)) return 'font-size'; + // A size with a line-height modifier, in every spelling of the modifier: + // `text-sm/6`, `text-sm/[17px]`, `text-sm/(--lh)`. + if (/^(?:base|xs|sm|lg|\dxl|xl)\/(?:[\w.]+|\[.+\]|\(.+\))$/.test(v)) return 'font-size'; + return 'text-color'; + } + // font-* + if ((s = splitHead(utility, ['font']))) { + const v = s[1]; + if (/^(?:thin|extralight|light|normal|medium|semibold|bold|extrabold|black)$/.test(v) || NUMERIC.test(v)) return 'font-weight'; + if (arbitraryInner(v)?.startsWith('weight:') || (arbitraryInner(v) && NUMERIC.test(arbitraryInner(v)))) return 'font-weight'; + return 'font-family'; + } + // bg-* + if ((s = splitHead(utility, ['bg']))) { + const v = s[1]; + if (/^blend-/.test(v)) return 'bg-blend'; + if (/^(?:fixed|local|scroll)$/.test(v)) return 'bg-attachment'; + if (/^clip-/.test(v)) return 'bg-clip'; + if (/^origin-/.test(v)) return 'bg-origin'; + if (/^(?:top|bottom|left|right|center)(?:-(?:top|bottom|left|right))?$/.test(v) || /^position-/.test(v)) return 'bg-position'; + if (/^(?:repeat|no-repeat|repeat-x|repeat-y|repeat-round|repeat-space)$/.test(v)) return 'bg-repeat'; + if (/^(?:auto|cover|contain)$/.test(v) || /^size-/.test(v)) return 'bg-size'; + if (v === 'none' || /^(?:linear|radial|conic|gradient)-/.test(v)) return 'bg-image'; + const inner = arbitraryInner(v); + if (inner !== null && /^(?:url\(|image:|linear-gradient|radial-gradient|conic-gradient)/.test(inner)) return 'bg-image'; + // A typed arbitrary value names its own property, so it is not a colour. + if (inner !== null && /^(?:length|size|percentage):/.test(inner)) return 'bg-size'; + if (inner !== null && /^position:/.test(inner)) return 'bg-position'; + return 'bg-color'; + } + // gradient stops + for (const stop of ['from', 'via', 'to']) { + if ((s = splitHead(utility, [stop]))) { + const v = s[1]; + if (/^\d+%$/.test(v) || NUMERIC.test(v) || isArbitraryLength(v)) return `gradient-${stop}-pos`; + return `gradient-${stop}`; + } + } + // rounded + if ((s = splitHead(utility, ['rounded']))) { + const v = s[1]; + if (v === '') return 'rounded'; + const side = RADIUS_SIDES.find((x) => v === x || v.startsWith(x + '-')); + return side ? `rounded-${side}` : 'rounded'; + } + // border + if ((s = splitHead(utility, ['border']))) { + const v = s[1]; + if (v === '') return 'border-w'; + if (BORDER_STYLES.has(v)) return 'border-style'; + if (v === 'collapse' || v === 'separate') return 'border-collapse'; + if (v.startsWith('spacing')) { + // The group is the AXIS (`border-spacing-x`), never the value after it. + const axis = /^spacing-([xy])(?:-|$)/.exec(v); + return axis ? `border-spacing-${axis[1]}` : 'border-spacing'; + } + const side = BORDER_SIDES.find((x) => v === x || v.startsWith(x + '-')); + const rest = side ? v.slice(side.length + 1) : v; + const suffix = side ? `-${side}` : ''; + if (rest === '' || NUMERIC.test(rest) || isArbitraryLength(rest)) return `border-w${suffix}`; + if (BORDER_STYLES.has(rest)) return 'border-style'; + return `border-color${suffix}`; + } + // divide + if ((s = splitHead(utility, ['divide']))) { + const v = s[1]; + if (/^(?:x|y)(?:-reverse)?$/.test(v) || /^(?:x|y)-/.test(v)) { + const axis = v[0]; + if (v.endsWith('-reverse')) return `divide-${axis}-reverse`; + return `divide-${axis}`; + } + if (BORDER_STYLES.has(v)) return 'divide-style'; + return 'divide-color'; + } + // outline + if ((s = splitHead(utility, ['outline']))) { + const v = s[1]; + if (v === '' || NUMERIC.test(v) || isArbitraryLength(v)) return 'outline-w'; + if (BORDER_STYLES.has(v)) return 'outline-style'; + if (v.startsWith('offset')) return 'outline-offset'; + return 'outline-color'; + } + // ring / inset-ring + for (const [head, group] of [['inset-ring', 'inset-ring'], ['ring', 'ring']]) { + if ((s = splitHead(utility, [head]))) { + const v = s[1]; + if (head === 'ring' && v.startsWith('offset')) { + const rest = v.slice(6).replace(/^-/, ''); + return rest === '' || NUMERIC.test(rest) || isArbitraryLength(rest) ? 'ring-offset-w' : 'ring-offset-color'; + } + if (v === '' || NUMERIC.test(v) || isArbitraryLength(v)) return `${group}-w`; + return `${group}-color`; + } + } + // shadow / inset-shadow / drop-shadow + for (const head of ['inset-shadow', 'drop-shadow', 'shadow']) { + if ((s = splitHead(utility, [head]))) { + const v = s[1]; + if (v === '' || SHADOW_SIZES.test(v) || (arbitraryInner(v) !== null && !isArbitraryColor(v))) return head; + return `${head}-color`; + } + } + // decoration + if ((s = splitHead(utility, ['decoration']))) { + const v = s[1]; + if (/^(?:solid|double|dotted|dashed|wavy)$/.test(v)) return 'text-decoration-style'; + if (/^(?:auto|from-font)$/.test(v) || NUMERIC.test(v) || isArbitraryLength(v)) return 'text-decoration-thickness'; + return 'text-decoration-color'; + } + // stroke width vs colour + if ((s = splitHead(utility, ['stroke']))) { + const v = s[1]; + if (NUMERIC.test(v) || isArbitraryLength(v)) return 'stroke-w'; + return 'stroke'; + } + if (utility.startsWith('placeholder-')) return 'placeholder-color'; + if (utility.startsWith('caret-')) return 'caret-color'; + if (utility.startsWith('scheme-')) return 'color-scheme'; + if (utility.startsWith('list-')) { + const v = utility.slice(5); + if (v === 'inside' || v === 'outside') return 'list-style-position'; + if (v.startsWith('image-')) return 'list-image'; + return 'list-style-type'; + } + if (utility.startsWith('object-')) { + return /^object-(?:contain|cover|fill|none|scale-down)$/.test(utility) ? 'object-fit' : 'object-position'; + } + if (utility.startsWith('justify-')) return 'justify-content'; + if (utility.startsWith('items-')) return 'align-items'; + if (utility.startsWith('self-')) return 'align-self'; + if (utility.startsWith('content-')) { + return /^content-(?:normal|center|start|end|between|around|evenly|baseline|stretch)$/.test(utility) ? 'align-content' : 'content'; + } + if (utility.startsWith('align-')) return 'vertical-align'; + if (utility.startsWith('wrap-')) return 'wrap'; + if (utility.startsWith('origin-')) return 'transform-origin'; + if (utility.startsWith('transform-')) return 'transform-style'; + if (utility === 'transform') return 'transform'; + if (utility.startsWith('col-')) { + if (utility.startsWith('col-start-')) return 'col-start'; + if (utility.startsWith('col-end-')) return 'col-end'; + return 'col-start-end'; + } + if (utility.startsWith('row-')) { + if (utility.startsWith('row-start-')) return 'row-start'; + if (utility.startsWith('row-end-')) return 'row-end'; + return 'row-start-end'; + } + if ((s = splitHead(utility, ['flex']))) { + // `flex` alone is display (handled above); `flex-1` / `flex-auto` / `flex-[..]` is the flex shorthand. + return 'flex'; + } + if (utility.startsWith('mask-')) return 'mask-image'; + // `scrollbar-gutter-*` and `scrollbar-w-*` are layout groups resolved by + // SIMPLE_GROUPS below; only the remaining `scrollbar-*` names are colours. + if (utility.startsWith('scrollbar-') && !/^scrollbar-(?:gutter|w)(?:-|$)/.test(utility)) { + return utility.includes('thumb') ? 'scrollbar-thumb-color' : 'scrollbar-track-color'; + } + if ((s = splitHead(utility, SIMPLE_GROUPS))) { + if (s[0] === 'placeholder') return 'placeholder-color'; + if (s[0] === 'caret') return 'caret-color'; + return s[0]; + } + return null; +} + +// --------------------------------------------------------------------------- +// Token parsing +// --------------------------------------------------------------------------- + +/** + * @typedef {{ + * raw: string, variants: string[], utility: string, base: string, + * group: string|null, category: string|null, + * arbitraryValue: boolean, negative: boolean, important: boolean, + * opacity: string|null, + * }} ParsedClass + */ + +/** Split `s` on `sep` at bracket and paren depth zero. */ +function splitTopLevel(s, sep) { + const out = []; + let depth = 0; + let cur = ''; + for (let i = 0; i < s.length; i++) { + const c = s[i]; + if (c === '[' || c === '(') depth++; + else if (c === ']' || c === ')') depth = Math.max(0, depth - 1); + if (c === sep && depth === 0) { out.push(cur); cur = ''; continue; } + cur += c; + } + out.push(cur); + return out; +} + +/** + * Parse one class token. Variants are every top-level `:` segment but the last; + * the last is the utility. A token is an arbitrary VALUE when, and only when, + * its utility segment contains a `[`, so `[&_svg]:size-4` (an arbitrary + * variant) is not one and `[padding:13px]` (an arbitrary property) is. + * + * @param {string} token + * @returns {ParsedClass} + */ +export function parseToken(token) { + const segments = splitTopLevel(token, ':'); + let utility = segments.pop() ?? ''; + const variants = segments; + let negative = false; + let important = false; + if (utility.startsWith('!')) { important = true; utility = utility.slice(1); } + if (utility.endsWith('!')) { important = true; utility = utility.slice(0, -1); } + if (utility.startsWith('-') && utility.length > 1) { negative = true; utility = utility.slice(1); } + + let base = utility; + let opacity = null; + const parts = splitTopLevel(utility, '/'); + if (parts.length > 1) { + const candidate = parts.slice(0, -1).join('/'); + const g = groupOf(candidate); + if (categoryOf(g) === 'color') { base = candidate; opacity = parts[parts.length - 1]; } + } + const group = groupOf(base); + return { + raw: token, + variants, + utility, + base, + group, + category: categoryOf(group), + arbitraryValue: utility.includes('['), + negative, + important, + opacity, + }; +} + +/** + * Whether `allow` grants this class: it names the class's category (`layout` + * for the null category) or its exact group id (`rounded`, which covers the + * plain radius group and not the corner groups). + * + * @param {ParsedClass} parsed + * @param {string[]|undefined} allow + */ +export function isAllowed(parsed, allow) { + if (!allow || allow.length === 0) return false; + const category = parsed.category ?? 'layout'; + if (allow.includes(category)) return true; + if (parsed.group !== null && allow.includes(parsed.group)) return true; + return false; +} diff --git a/packages/ui/src/lint/index.js b/packages/ui/src/lint/index.js new file mode 100644 index 000000000..5043fc8e6 --- /dev/null +++ b/packages/ui/src/lint/index.js @@ -0,0 +1,205 @@ +/** + * The `webjsui lint` orchestrator: walks the app, reads each module, runs the + * scanner, dispatches every class site to every enabled rule, and returns the + * violations. It does the filesystem work so the rules stay pure (the same + * split `@webjsdev/server`'s check runner uses: collect first, then hand pure + * rule functions the collected set). + * + * Scope (D7): `app/**`, `components/**`, `modules/**` and `lib/**` under the + * config's cwd, over `.ts .tsx .js .jsx .mts .mjs`; `node_modules`, `.webjs`, + * `dist` and `public` are never walked. `resolvedPaths.ui` (default + * `components/ui`) is skipped by default: a copied primitive legitimately owns + * structural values no variant can express (`ring-[3px]`, `[&_svg]:size-4`) + * and is what every other file's variants are measured against, so linting it + * with the app's rules is backwards. An app widens the scope with a negated + * ignore entry (`"!components/ui/**"`) or narrows it with more globs. + * + * @module lint + */ + +import { readdirSync, readFileSync, statSync, existsSync } from 'node:fs'; +import { join, relative, resolve, sep } from 'node:path'; +import { scanClassSites, collectHelperImports } from './scan.js'; +import { readThemeTokens } from './theme-tokens.js'; +import { extractHelperAxes } from '../registry/extract.js'; +import { RULES, RULE_NAMES } from './rules/index.js'; + +const SCAN_DIRS = ['app', 'components', 'modules', 'lib']; +const EXTENSIONS = /\.(?:ts|tsx|js|jsx|mts|mjs)$/; +const NEVER_WALK = new Set(['node_modules', '.webjs', 'dist', 'public']); + +/** + * @typedef {{ + * rule: string, severity: 'warn'|'error', file: string, + * line: number, column: number, class: string, message: string, fix?: string, + * }} Violation + */ + +/** + * Normalize `config.lint.rules` into `{ name: { severity, allow } }` for the + * rules that are on. A bare severity string and the object form both land + * here; an absent rule is off. + * + * @param {any} lint the parsed `lint` block, or undefined + * @returns {Record} + */ +export function enabledRules(lint) { + /** @type {Record} */ + const out = {}; + const rules = lint?.rules ?? {}; + for (const name of RULE_NAMES) { + const raw = rules[name]; + if (raw === undefined) continue; + const severity = typeof raw === 'string' ? raw : raw.severity; + if (severity === 'off') continue; + out[name] = { severity, allow: typeof raw === 'string' ? [] : (raw.allow ?? []) }; + } + return out; +} + +/** A minimal glob (`**`, `*`, `?`) to RegExp over a posix relative path. */ +export function globToRegExp(glob) { + let re = ''; + for (let i = 0; i < glob.length; i++) { + const c = glob[i]; + if (c === '*') { + if (glob[i + 1] === '*') { + i++; + if (glob[i + 1] === '/') { i++; re += '(?:.*/)?'; } else re += '.*'; + } else re += '[^/]*'; + } else if (c === '?') re += '[^/]'; + else re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&'); + } + return new RegExp(`^${re}$`); +} + +function toPosix(p) { + return p.split(sep).join('/'); +} + +/** + * Walk the D7 scope and return every lintable file, relative to `cwd` (posix). + * @param {string} cwd + */ +function collectFiles(cwd) { + /** @type {string[]} */ + const files = []; + const walk = (dir) => { + let entries; + try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; } + for (const e of entries) { + if (NEVER_WALK.has(e.name)) continue; + const full = join(dir, e.name); + if (e.isDirectory()) walk(full); + else if (e.isFile() && EXTENSIONS.test(e.name)) files.push(toPosix(relative(cwd, full))); + } + }; + for (const d of SCAN_DIRS) { + const full = join(cwd, d); + if (existsSync(full) && statSync(full).isDirectory()) walk(full); + } + return files.sort(); +} + +/** + * Lint the app at `cwd` against the parsed `components.json` config. + * + * @param {string} cwd + * @param {ReturnType} config + * @returns {{ violations: Violation[], warnings: string[], configured: boolean }} + * `warnings` are non-violation notices (an unreadable theme path); + * `configured` is false when no rule is on, the opt-in guarantee. + */ +export function lintApp(cwd, config) { + const root = resolve(cwd); + const rules = enabledRules(config.lint); + /** @type {string[]} */ + const warnings = []; + if (Object.keys(rules).length === 0) return { violations: [], warnings, configured: false }; + + // Ignore set: the ui dir by default, plus the app's entries. A `!` entry + // un-ignores whatever it MATCHES, so `!components/ui/**` widens the scope + // over the whole ui dir and `!components/ui/button.ts` over one file. + const uiRel = toPosix(relative(root, config.resolvedPaths.ui)); + const ignore = [`${uiRel}/**`]; + const unignore = []; + for (const entry of config.lint?.ignore ?? []) { + if (entry.startsWith('!')) unignore.push(entry.slice(1)); + else ignore.push(entry); + } + // An entry also covers its own subtree, and a leading `./` or a trailing `/` + // is dropped, so the directory spellings (`app/legacy`, `app/legacy/`, + // `./app/legacy/**`) work instead of silently matching no file. + const toRes = (glob) => { + const g = glob.replace(/^\.\//, '').replace(/\/+$/, ''); + return [globToRegExp(g), globToRegExp(`${g}/**`)]; + }; + const ignoreRes = ignore.flatMap(toRes); + const unignoreRes = unignore.flatMap(toRes); + const isIgnored = (rel) => ignoreRes.some((re) => re.test(rel)) && !unignoreRes.some((re) => re.test(rel)); + + // Theme tokens, read once; no tokens disables no-raw-colors for the run. + let tokens = []; + let themePath = toPosix(relative(root, config.resolvedPaths.tailwindCss)); + if (rules['no-raw-colors']) { + const theme = readThemeTokens(config.resolvedPaths.tailwindCss); + tokens = theme.tokens; + if (tokens.length === 0) { + warnings.push(`no-raw-colors is off for this run: no --color-* tokens found in a @theme block of ${themePath} (tailwind.css in components.json)`); + delete rules['no-raw-colors']; + if (Object.keys(rules).length === 0) return { violations: [], warnings, configured: true }; + } + } + + /** @type {Map>>} axes per helper file */ + const axesCache = new Map(); + const axesForFile = (absPath) => { + const candidates = [absPath, `${absPath}.ts`, `${absPath}.js`]; + for (const p of candidates) { + if (axesCache.has(p)) return { axes: axesCache.get(p), file: toPosix(relative(root, p)) }; + let src; + try { src = readFileSync(p, 'utf8'); } catch { continue; } + const axes = extractHelperAxes(src); + axesCache.set(p, axes); + return { axes, file: toPosix(relative(root, p)) }; + } + return { axes: {}, file: null }; + }; + + /** @type {Violation[]} */ + const violations = []; + for (const rel of collectFiles(root)) { + if (isIgnored(rel)) continue; + const abs = join(root, rel); + let src; + try { src = readFileSync(abs, 'utf8'); } catch { continue; } + const imports = collectHelperImports(src, { + filePath: abs, + appRoot: root, + uiDir: config.resolvedPaths.ui, + utilsPath: config.resolvedPaths.utils, + }); + // `cnNames` is passed even when empty ON PURPOSE: the scanner's own default + // recognizes a bare `cn`, but here `cn` counts only when imported from the + // configured utils alias, so an unrecognized `cn` never opens a call site. + const sites = scanClassSites(src, { helpers: imports.helpers, cnNames: imports.cnNames }); + if (!sites.length) continue; + const axesFor = (helper) => { + const target = imports.helperFiles[helper]; + if (!target) return { axes: {}, file: null }; + const r = axesForFile(target); + return { axes: r.axes[imports.helperExports[helper] ?? helper] ?? {}, file: r.file }; + }; + for (const site of sites) { + for (const [name, conf] of Object.entries(rules)) { + const ctx = { tokens, themePath, allow: conf.allow, axesFor }; + for (const v of RULES[name](site, ctx)) { + violations.push({ rule: name, severity: conf.severity, file: rel, ...v }); + } + } + } + } + // Two rules can flag one token (an arbitrary colour beside a helper); keep both, ordered. + violations.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.column - b.column || a.rule.localeCompare(b.rule)); + return { violations, warnings, configured: true }; +} diff --git a/packages/ui/src/lint/rules/index.js b/packages/ui/src/lint/rules/index.js new file mode 100644 index 000000000..dec80abf1 --- /dev/null +++ b/packages/ui/src/lint/rules/index.js @@ -0,0 +1,12 @@ +/** The rule table `webjsui lint` dispatches over. Each rule is pure: `(site, ctx) => violations`. */ +import { noRawColors } from './no-raw-colors.js'; +import { noArbitraryValues } from './no-arbitrary-values.js'; +import { noRestyle } from './no-restyle.js'; + +export const RULES = { + 'no-raw-colors': noRawColors, + 'no-arbitrary-values': noArbitraryValues, + 'no-restyle': noRestyle, +}; + +export const RULE_NAMES = Object.keys(RULES); diff --git a/packages/ui/src/lint/rules/no-arbitrary-values.js b/packages/ui/src/lint/rules/no-arbitrary-values.js new file mode 100644 index 000000000..ac453347c --- /dev/null +++ b/packages/ui/src/lint/rules/no-arbitrary-values.js @@ -0,0 +1,33 @@ +/** + * `no-arbitrary-values`: a token whose UTILITY carries a bracket (`p-[13px]`, + * `ring-[3px]`, `[padding:13px]`), which sidesteps the theme's scale. An + * arbitrary VARIANT (`[&_svg]:size-4`, `has-[>svg]:px-3`) never fires, and the + * `(--var)` shorthand never fires, per the grammar. `allow` takes category + * names (`layout`, `spacing`) and class-group ids (`rounded`). + * + * @module lint/rules/no-arbitrary-values + */ + +import { parseToken, isAllowed } from '../grammar.js'; + +/** + * @param {import('../scan.js').ClassSite} site + * @param {{ allow?: string[] }} ctx + * @returns {Array<{ line: number, column: number, class: string, message: string }>} + */ +export function noArbitraryValues(site, ctx) { + const out = []; + for (const token of site.classes) { + const parsed = parseToken(token.name); + if (!parsed.arbitraryValue) continue; + if (isAllowed(parsed, ctx.allow)) continue; + const category = parsed.category ?? 'layout'; + out.push({ + line: token.line, + column: token.column, + class: token.name, + message: `${parsed.utility} is an arbitrary value (${category}${parsed.group ? `, group ${parsed.group}` : ''}). Use a theme scale step instead, or allow "${category}"${parsed.group && !parsed.group.startsWith('arbitrary..') ? ` or "${parsed.group}"` : ''} in the rule's allow list.`, + }); + } + return out; +} diff --git a/packages/ui/src/lint/rules/no-raw-colors.js b/packages/ui/src/lint/rules/no-raw-colors.js new file mode 100644 index 000000000..428ab58d5 --- /dev/null +++ b/packages/ui/src/lint/rules/no-raw-colors.js @@ -0,0 +1,86 @@ +/** + * `no-raw-colors`: a Tailwind palette utility (`text-red-600`, `bg-sky-500`) + * in a class site, where the app's theme declares a token that should carry + * the role instead. + * + * The message names ONLY utilities the app's own theme declares (read from the + * configured Tailwind CSS by `theme-tokens.js`), joined to the offender's own + * prefix, so `--color-destructive` offers `text-destructive` for a `text-` + * offender. A nearest-role suggestion is added only where the mapping is + * unambiguous and the role is actually declared. When the theme yields no + * tokens the orchestrator never calls this rule, because a message that + * cannot name an alternative reproduces the prose-guidance failure at higher + * volume. + * + * @module lint/rules/no-raw-colors + */ + +import { parseToken } from '../grammar.js'; + +const PREFIXES = ['text', 'bg', 'border', 'ring', 'divide', 'outline', 'fill', 'stroke', 'from', 'via', 'to', 'shadow', 'decoration', 'placeholder', 'caret', 'accent']; +const FAMILIES = ['red', 'orange', 'amber', 'yellow', 'lime', 'green', 'emerald', 'teal', 'cyan', 'sky', 'blue', 'indigo', 'violet', 'purple', 'fuchsia', 'pink', 'rose', 'slate', 'gray', 'zinc', 'neutral', 'stone']; +const STEPS = ['50', '100', '200', '300', '400', '500', '600', '700', '800', '900', '950']; + +const RAW = new RegExp(`^(${PREFIXES.join('|')})-(${FAMILIES.join('|')})-(${STEPS.join('|')})$`); + +/** Families whose role match is unambiguous. */ +const ROLE = { + red: () => 'destructive', + rose: () => 'destructive', + slate: (prefix) => (prefix === 'text' ? 'muted-foreground' : 'muted'), + gray: (prefix) => (prefix === 'text' ? 'muted-foreground' : 'muted'), + zinc: (prefix) => (prefix === 'text' ? 'muted-foreground' : 'muted'), + neutral: (prefix) => (prefix === 'text' ? 'muted-foreground' : 'muted'), + stone: (prefix) => (prefix === 'text' ? 'muted-foreground' : 'muted'), +}; + +/** The tokens worth listing first for a given prefix, in this order, then the rest of the theme. */ +const LEAD = ['destructive', 'muted-foreground', 'primary', 'foreground', 'muted', 'accent', 'secondary', 'background', 'border']; +const LIST_MAX = 6; + +/** + * @param {import('../scan.js').ClassSite} site + * @param {{ tokens: string[], themePath: string }} ctx + * @returns {Array<{ line: number, column: number, class: string, message: string, fix?: string }>} + */ +export function noRawColors(site, ctx) { + const out = []; + for (const token of site.classes) { + const parsed = parseToken(token.name); + const m = RAW.exec(parsed.base); + if (!m) continue; + const [, prefix, family] = m; + const role = ROLE[family]?.(prefix); + const roleDeclared = role !== undefined && ctx.tokens.includes(role); + const ordered = [...LEAD.filter((t) => ctx.tokens.includes(t)), ...ctx.tokens.filter((t) => !LEAD.includes(t))]; + const list = ordered.slice(0, LIST_MAX).map((t) => `${prefix}-${t}`); + let message = `${parsed.base} is a raw palette color. Use a theme token: ${list.join(', ')} (declared in ${ctx.themePath}).`; + if (roleDeclared) { + const what = family === 'red' || family === 'rose' ? 'error' : 'muted'; + const noun = prefix === 'bg' ? 'surfaces' : prefix === 'text' ? 'text' : `${prefix} colors`; + message += ` For ${what} ${noun}, ${prefix}-${role} is the role match`; + message += role === 'destructive' && prefix === 'text' ? ', or use errorClass() from the kit.' : '.'; + } + out.push({ + line: token.line, + column: token.column, + class: token.name, + message, + ...(roleDeclared ? { fix: swapBase(token.name, parsed.base, `${prefix}-${role}`) } : {}), + }); + } + return out; +} + +/** + * `raw` with its base utility swapped and everything else kept, so `fix` is a + * drop-in replacement for the reported `class`: the variants, the opacity + * modifier and a `!` all survive (`hover:text-red-600/50` to + * `hover:text-destructive/50`). The LAST occurrence is the utility, since an + * arbitrary variant ahead of it can spell the same text. + */ +function swapBase(raw, base, replacement) { + const at = raw.lastIndexOf(base); + if (at === -1) return replacement; + return raw.slice(0, at) + replacement + raw.slice(at + base.length); +} diff --git a/packages/ui/src/lint/rules/no-restyle.js b/packages/ui/src/lint/rules/no-restyle.js new file mode 100644 index 000000000..9ba91aa37 --- /dev/null +++ b/packages/ui/src/lint/rules/no-restyle.js @@ -0,0 +1,66 @@ +/** + * `no-restyle`: a class composed NEXT TO a kit helper, in either shape WebJs + * writes: a `cn()` call whose arguments include both a `*Class()` call and a + * string literal, or a `class` attribute holding both a `${helperCall()}` hole + * and static text. The scanner records the helpers a site is composed with, + * so the rule fires on every class of a site that has at least one. + * + * The message names the helper's REAL variant and size values, read from the + * app's own copied helper source through `extractHelperAxes` (the shared + * projector in `registry/extract.js`), never from a second parser and never + * invented: a helper matching neither authored shape yields no value list. + * + * `allow` takes category names and class-group ids. The documented starting + * configuration is `["layout", "rounded"]`, which is what lets the skill's + * sanctioned one-off (`cn(buttonClass({ size: 'none' }), 'w-9 h-9 + * rounded-full')`) pass: `w-9 h-9` is layout, and `rounded-full` is the plain + * radius group granted by name without opening the whole `shape` category. + * + * @module lint/rules/no-restyle + */ + +import { parseToken, isAllowed } from '../grammar.js'; + +/** + * @param {import('../scan.js').ClassSite} site + * @param {{ allow?: string[], axesFor: (helper: string) => { axes: Record, file: string|null } }} ctx + * @returns {Array<{ line: number, column: number, class: string, message: string }>} + */ +export function noRestyle(site, ctx) { + if (!site.helpers.length) return []; + const out = []; + // Every composed helper is named; the axes come from the LAST one, since a + // later argument is what `cn` lets win. + const helper = site.helpers[site.helpers.length - 1]; + const named = site.helpers.length === 1 ? helper : `${site.helpers.slice(0, -1).join(', ')} and ${helper}`; + const { axes, file } = ctx.axesFor(helper); + for (const token of site.classes) { + const parsed = parseToken(token.name); + if (isAllowed(parsed, ctx.allow)) continue; + let message = `${parsed.utility} overrides what ${named} already ${site.helpers.length === 1 ? 'sets' : 'set'}.`; + const axisNames = Object.keys(axes); + if (axisNames.length) { + const axis = pickAxis(parsed, axisNames); + message += ` Use a ${helper} ${axis}: ${axes[axis].join(', ')}`; + message += file ? ` (declared in ${file}).` : '.'; + } else { + message += ` Pick a variant ${helper} exposes${file ? ` (declared in ${file})` : ''}, or allow "${parsed.category ?? 'layout'}"${parsed.group ? ` or "${parsed.group}"` : ''} in the rule's allow list.`; + } + out.push({ line: token.line, column: token.column, class: token.name, message }); + } + return out; +} + +/** + * A colour or typography override reads as a variant, and a size-ish one as a + * size when the helper has one. Font size and line height are the two + * typography groups a kit SIZE carries (`text-xs` in the button's `xs`), so they + * read as a size, while a weight or an underline stays a variant. + */ +function pickAxis(parsed, axisNames) { + const cat = parsed.category; + const sizeLike = cat === null || cat === 'spacing' || cat === 'shape' || parsed.group === 'font-size' || parsed.group === 'leading'; + if (sizeLike && axisNames.includes('size')) return 'size'; + if (axisNames.includes('variant')) return 'variant'; + return axisNames[0]; +} diff --git a/packages/ui/src/lint/scan.js b/packages/ui/src/lint/scan.js new file mode 100644 index 000000000..d82b4dbd6 --- /dev/null +++ b/packages/ui/src/lint/scan.js @@ -0,0 +1,446 @@ +/** + * The class-site scanner behind `webjsui lint`: where in a module the linter + * reads Tailwind classes from. Pure over `(source, { helpers, cnNames })`, no + * filesystem, so every rule test is a string in and an array out. + * + * A CLASS SITE is one of three shapes, and they are the complete set: + * + * 1. Template attribute site. A `class=` attribute inside an OPEN TAG inside + * an `html` tagged template. Nested `html` templates inside holes are + * recursed into (the blog's positives all sit inside + * `${cond ? html\`...\` : ''}`). + * 2. Helper argument site. Every string literal (a plain template literal + * included, split at its holes) lexically inside a call to a recognized + * `cn(` (the app's utils alias) anywhere in the module. A + * recognized `*Class(` helper call contributes its NAME (the class beside + * it is composed with that helper) and its own arguments are never read + * as classes, since `buttonClass({ variant: 'secondary' })` carries option + * values, not classes. + * 3. Hole site. Every string literal inside a `class=${...}` hole. + * + * Sites 2 and 3 overlap (`class=${cn(buttonClass(), 'w-9')}`) and a `cn` call + * inside a class hole feeds the hole's site rather than opening a second one, + * so one string is never reported twice. + * + * The TAG-REGION requirement is what makes an escaped code sample inert: a + * `class=` is a site only when a literal `<` followed by a tag-name character + * opened a tag that a `>` has not yet closed. In a docs page the markup is + * written `<p class="...">`, so no tag is ever open and nothing is read. + * This is a structural rule, not a "does this look like a docs page" guess. + * + * A class string spanning a hole is split at the hole boundary. Each static + * run is tokenized on whitespace, and a token touching a hole with no + * intervening whitespace is DROPPED as a fragment (`class="text-${size} p-2"` + * yields only `p-2`). Nothing is reconstructed across a hole. + * + * The lexer is hand-rolled, borrowing the regex-versus-division and nested + * `${...}` handling of `@webjsdev/server`'s `js-scan.js`. It is NOT imported: + * this package must not depend on `@webjsdev/server`, and that module blanks + * template bodies while this one must read them. + * + * @module lint/scan + */ + +import { dirname, resolve, sep } from 'node:path'; + +/** + * @typedef {{ name: string, offset: number, line: number, column: number }} ClassToken + * @typedef {{ + * kind: 'attribute'|'hole'|'call', + * offset: number, line: number, column: number, + * classes: ClassToken[], + * helpers: string[], + * }} ClassSite + */ + +const REGEX_PRECEDING_KEYWORDS = new Set([ + 'return', 'typeof', 'instanceof', 'in', 'of', 'new', 'delete', 'void', + 'do', 'else', 'case', 'yield', 'await', 'throw', +]); + +/** + * Scan one module's source for class sites. + * + * @param {string} src + * @param {{ helpers?: Iterable, cnNames?: Iterable }} [opts] + * @returns {ClassSite[]} + */ +export function scanClassSites(src, opts = {}) { + const helpers = new Set(opts.helpers ?? []); + const cnNames = new Set(opts.cnNames ?? ['cn']); + const n = src.length; + /** @type {ClassSite[]} */ + const sites = []; + const lineStarts = [0]; + for (let k = 0; k < n; k++) if (src[k] === '\n') lineStarts.push(k + 1); + const pos = (offset) => { + let lo = 0, hi = lineStarts.length - 1; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (lineStarts[mid] <= offset) lo = mid; else hi = mid - 1; + } + return { line: lo + 1, column: offset - lineStarts[lo] + 1 }; + }; + const newSite = (kind, offset) => ({ kind, offset, ...pos(offset), classes: [], helpers: [] }); + // A site is dropped while muted (inside a helper's own arguments, or inside + // a hole that sits in commented-out markup). + const emit = (site) => { if (site.classes.length && mute === 0) sites.push(site); }; + + /** @type {ClassSite[]} the active collector stack (innermost last) */ + const collectors = []; + let mute = 0; + const active = () => (collectors.length ? collectors[collectors.length - 1] : null); + const pushTokens = (site, text, base) => { + const re = /\S+/g; + let m; + while ((m = re.exec(text)) !== null) { + site.classes.push({ name: m[0], offset: base + m.index, ...pos(base + m.index) }); + } + }; + + // One static run of a class string that holes may interrupt. A token touching + // a hole with no whitespace between is a fragment and is dropped, on either + // side, so nothing is ever reconstructed across a hole. + const pushRun = (site, text, base, holeBefore, holeAfter) => { + if (!text.length) return; + if (holeBefore && !/^\s/.test(text)) { + const m = /^\S+/.exec(text); + base += m[0].length; text = text.slice(m[0].length); + } + if (holeAfter && !/\s$/.test(text)) text = text.replace(/\S+$/, ''); + pushTokens(site, text, base); + }; + + let i = 0; + let lastSig = ''; + let lastWord = ''; + let lastWordIsProp = false; + let lastWasIncDec = false; + const markValue = () => { lastSig = 'x'; lastWord = ''; lastWordIsProp = false; lastWasIncDec = false; }; + const isRegex = () => { + if (lastSig === '') return true; + if (lastSig === ')' || lastSig === ']') return false; + if (lastSig === "'" || lastSig === '"' || lastSig === '`') return false; + if (lastWasIncDec) return false; + if (/[\w$]/.test(lastSig)) return !lastWordIsProp && REGEX_PRECEDING_KEYWORDS.has(lastWord); + return true; + }; + + const scanLineComment = () => { i += 2; while (i < n && src[i] !== '\n') i++; }; + const scanBlockComment = () => { + i += 2; + while (i < n) { if (src[i] === '*' && src[i + 1] === '/') { i += 2; return; } i++; } + }; + const scanRegex = () => { + i++; + let inClass = false; + while (i < n) { + const d = src[i]; + if (d === '\\' && i + 1 < n) { i += 2; continue; } + if (d === '\n') break; + if (d === '[') inClass = true; + else if (d === ']') inClass = false; + else if (d === '/' && !inClass) { i++; break; } + i++; + } + markValue(); + }; + // A literal that is a comparison operand (`kind === 'primary'`), or a + // `case 'x':` label, is a value the code compares, not a class, so it is not + // collected. Any other literal in a class hole or cn() call is read as a + // class; an unknown token falls to the layout category, so a stray one is + // admitted by the recommended config rather than reported. + const isComparisonOperand = (before, after) => { + if (lastWord === 'case') return true; + let j = before - 1; + while (j >= 0 && /\s/.test(src[j])) j--; + if (j >= 1 && src[j] === '=' && (src[j - 1] === '=' || src[j - 1] === '!')) return true; + let k = after; + while (k < n && /\s/.test(src[k])) k++; + return (src[k] === '=' && src[k + 1] === '=') || (src[k] === '!' && src[k + 1] === '='); + }; + const scanString = (q) => { + const quoteAt = i; + const start = i + 1; + i++; + let body = ''; + while (i < n) { + if (src[i] === '\\' && i + 1 < n) { body += src[i] + src[i + 1]; i += 2; continue; } + if (src[i] === q) { i++; break; } + if (src[i] === '\n') { i++; break; } + body += src[i]; i++; + } + const site = active(); + if (site && mute === 0 && !isComparisonOperand(quoteAt, i)) pushTokens(site, body, start); + markValue(); + }; + // A plain template literal is a string literal too, so inside a collecting + // site its static text is read like one (`cn(buttonClass(), \`bg-pink-500\`)`), + // split at its holes by the same fragment rule an attribute site uses. + const scanPlainTemplate = () => { + const quoteAt = i; + const site = mute === 0 ? active() : null; + /** @type {Array<{ text: string, base: number, holeBefore: boolean, holeAfter: boolean }>} */ + const runs = []; + let run = ''; + let runStart = -1; + let holeBefore = false; + const endRun = (holeAfter) => { + if (run.length) runs.push({ text: run, base: runStart, holeBefore, holeAfter }); + run = ''; runStart = -1; + }; + i++; + while (i < n) { + const c = src[i]; + if (c === '\\' && i + 1 < n) { + if (runStart === -1) runStart = i; + run += c + src[i + 1]; + i += 2; + continue; + } + if (c === '`') { i++; break; } + if (c === '$' && src[i + 1] === '{') { + endRun(true); + holeBefore = true; + i += 2; + scanCode('hole'); + if (i < n && src[i] === '}') i++; + continue; + } + if (runStart === -1) runStart = i; + run += c; + i++; + } + endRun(false); + if (site && !isComparisonOperand(quoteAt, i)) { + for (const r of runs) pushRun(site, r.text, r.base, r.holeBefore, r.holeAfter); + } + markValue(); + }; + + // An `html` tagged template: read the TEXT for open tags and `class=` + // attributes, recurse into holes as code. + const scanHtmlTemplate = () => { + i++; + let inTag = false; + /** @type {{ site: ClassSite|null, quote: string|null, run: string, runStart: number, holeBefore: boolean }|null} */ + let attr = null; + let pendingClassHole = false; + let inComment = false; + const flushRun = (touchesHoleRight) => { + if (!attr || !attr.site) return; + pushRun(attr.site, attr.run, attr.runStart, attr.holeBefore, touchesHoleRight); + attr.run = ''; attr.runStart = -1; attr.holeBefore = false; + }; + const closeAttr = () => { flushRun(false); if (attr.site) emit(attr.site); attr = null; }; + while (i < n) { + const c = src[i]; + if (c === '\\' && i + 1 < n) { + if (attr && attr.site) { if (attr.runStart === -1) attr.runStart = i; attr.run += src[i] + src[i + 1]; } + i += 2; + continue; + } + if (c === '`') { i++; break; } + // Commented-out markup (``) opens no tag. A hole + // inside the comment is still lexed as code so the template's real end is + // found, but it is muted: its output lands inside the comment, so nothing + // in it is collected. + if (inComment) { + if (src.startsWith('-->', i)) { inComment = false; i += 3; continue; } + if (c === '$' && src[i + 1] === '{') { i += 2; mute++; scanCode('hole'); mute--; if (i < n && src[i] === '}') i++; continue; } + i++; + continue; + } + if (!inTag && !attr && src.startsWith('

${x}

`'; + assert.deepEqual(names(scanClassSites(src)), ['p-2']); + // A hole inside the comment is still lexed, so a backtick in it cannot end the template early. + const tricky = 'html`

`'; + assert.deepEqual(names(scanClassSites(tricky)), ['p-3']); +}); + +test('collectHelperImports: a comment inside a multi-line import list is not a binding', () => { + const paths = { filePath: '/app/components/x.ts', appRoot: '/app', uiDir: '/app/components/ui', utilsPath: '/app/lib/utils/cn.ts' }; + const src = "import {\n buttonClass, // the primary\n /* badges */ badgeClass,\n} from '#components/ui/button.ts';"; + assert.deepEqual(collectHelperImports(src, paths).helpers, ['buttonClass', 'badgeClass']); +}); + +test('scan: a plain template literal in a cn() call or a class hole is read, split at its holes', () => { + assert.deepEqual(names(scanClassSites("const c = cn('a', `bg-pink-500 p-[3px]`);")), ['a', 'bg-pink-500', 'p-[3px]']); + const composed = scanClassSites('const t = html``;', { helpers: ['buttonClass'] }); + assert.deepEqual(names(composed), ['w-9', 'h-9']); + assert.deepEqual(composed[0].helpers, ['buttonClass']); + const hole = scanClassSites('const t = html``;'); + assert.deepEqual(names(hole), ['p-4', 'bg-red-500']); + assert.equal(hole[0].classes[1].column, 'const t = html` { + const r = collectHelperImports( + "import { buttonClass as bc, badge } from '#components/ui/button.ts';\nimport Def, { cn } from '#lib/utils/cn.ts';", + { filePath: '/app/app/page.ts', appRoot: '/app', uiDir: '/app/components/ui', utilsPath: '/app/lib/utils/cn.ts' }, + ); + assert.deepEqual(r.helpers, ['bc']); + assert.deepEqual(r.helperExports, { bc: 'buttonClass' }); + assert.deepEqual(r.cnNames, ['cn']); +}); + +test('scan: a hole starts a fresh expression, so a regex leading it is not lexed as a division', () => { + // Read as a division, the quote inside the regex opens a string that runs to + // the end of the line and swallows the template's closing backtick. + const src = 'const t = html`

${/"/.test(s) ? 1 : 2}

`;\nconst u = html`

`;'; + assert.deepEqual(names(scanClassSites(src)), ['a', 'b']); +}); diff --git a/packages/ui/test/lint-theme-tokens.test.js b/packages/ui/test/lint-theme-tokens.test.js new file mode 100644 index 000000000..786e7242a --- /dev/null +++ b/packages/ui/test/lint-theme-tokens.test.js @@ -0,0 +1,46 @@ +/** + * `webjsui lint` theme-token reader (`src/lint/theme-tokens.js`). + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { parseThemeTokens, readThemeTokens } from '../src/lint/theme-tokens.js'; + +const KIT_THEME = new URL('../packages/registry/themes/index.css', import.meta.url); + +test('parseThemeTokens: reads both `@theme inline` and plain `@theme`', () => { + assert.deepEqual( + parseThemeTokens('@theme inline {\n --radius-sm: 1px;\n --color-destructive: var(--destructive);\n}'), + ['destructive'], + ); + assert.deepEqual( + parseThemeTokens('@import "tailwindcss";\n@theme {\n --color-primary: var(--primary);\n --color-success: var(--success);\n --font-sans: x;\n}\n:root { --color-not-a-token: red; }'), + ['primary', 'success'], + ); +}); + +test('readThemeTokens: the kit theme declares 32 colour tokens', () => { + const { tokens } = readThemeTokens(KIT_THEME); + assert.equal(tokens.length, 32); + for (const t of ['destructive', 'muted-foreground', 'primary', 'background']) assert.ok(tokens.includes(t), t); +}); + +test('readThemeTokens: missing, unparsable and token-less files yield an empty array', () => { + const d = mkdtempSync(join(tmpdir(), 'webjsui-lint-theme-')); + try { + assert.deepEqual(readThemeTokens(join(d, 'nope.css')).tokens, []); + writeFileSync(join(d, 'broken.css'), '@theme {\n --color-x: 1'); + assert.deepEqual(readThemeTokens(join(d, 'broken.css')).tokens, []); + writeFileSync(join(d, 'empty.css'), '@import "tailwindcss";\n.x { color: red; }'); + const r = readThemeTokens(join(d, 'empty.css')); + assert.deepEqual(r.tokens, []); + assert.equal(r.path, join(d, 'empty.css')); + } finally { rmSync(d, { recursive: true }); } +}); + +test('parseThemeTokens: a commented-out token is not a token, and a brace in a comment does not close the block', () => { + const css = '/* @theme { --color-fake: red; } */\n@theme inline {\n --color-a: red;\n /* --color-old: blue; } */\n --color-b: blue;\n}'; + assert.deepEqual(parseThemeTokens(css), ['a', 'b']); +}); diff --git a/packages/ui/test/schema.test.js b/packages/ui/test/schema.test.js index c0148ceed..d745bf2a2 100644 --- a/packages/ui/test/schema.test.js +++ b/packages/ui/test/schema.test.js @@ -55,3 +55,30 @@ test('rawConfigSchema: rejects missing tailwind', () => { rawConfigSchema.parse({ style: 'default', aliases: { components: 'c', utils: 'u' } }), ); }); + +test('rawConfigSchema: `lint` is optional, and a rule takes a bare severity or the object form', () => { + const base = { + style: 'default', + tailwind: { css: 'app/globals.css' }, + aliases: { components: 'components', utils: 'lib/utils' }, + }; + // Every existing components.json (no `lint` key) still validates. + assert.equal(rawConfigSchema.parse(base).lint, undefined); + const parsed = rawConfigSchema.parse({ + ...base, + lint: { + rules: { + 'no-raw-colors': 'error', + 'no-arbitrary-values': { allow: ['layout'] }, + 'no-restyle': { severity: 'error', allow: ['layout', 'rounded'] }, + }, + }, + }); + assert.equal(parsed.lint.rules['no-raw-colors'], 'error'); + // The object form defaults `severity` to warn. + assert.equal(parsed.lint.rules['no-arbitrary-values'].severity, 'warn'); + assert.deepEqual(parsed.lint.rules['no-arbitrary-values'].allow, ['layout']); + assert.deepEqual(parsed.lint.ignore, []); + // An empty block is valid: every rule is absent, so every rule is off. + assert.deepEqual(rawConfigSchema.parse({ ...base, lint: {} }).lint.rules, {}); +}); diff --git a/website/app/docs/styling/page.ts b/website/app/docs/styling/page.ts index 8363949d1..7900f5eeb 100644 --- a/website/app/docs/styling/page.ts +++ b/website/app/docs/styling/page.ts @@ -224,6 +224,27 @@ export default function Post({ params }) {

Why not @apply? @apply hides which utilities a class uses and creates a second source of truth. JS helpers keep the class bundle visible at the definition site and compose naturally with conditional classes and active states.

+

Keeping to the design system: the opt-in linter

+

Guidance about tokens and helpers is prose, and prose is easy to skip, so @webjsdev/ui ships webjs ui lint, a linter that reports at the exact line where a page or component drifts off the app's own design system. It reads the Tailwind classes inside html templates, cn() calls and class=\${...} holes, and every message is built from what the app actually declares: the --color-* tokens in the configured tailwind.css, and the variants and sizes read from the app's copied components/ui/*.ts. It is not part of webjs check, which stays correctness-only, and it is off until components.json carries a lint block. With no block it reports nothing and exits 0.

+ { + "tailwind": { "css": "public/input.css" }, + "aliases": { "utils": "lib/utils/cn", "ui": "components/ui" }, + "lint": { + "ignore": ["app/legacy/**"], + "rules": { + "no-raw-colors": "warn", + "no-arbitrary-values": { "severity": "warn", "allow": ["layout"] }, + "no-restyle": { "severity": "error", "allow": ["layout", "rounded"] } + } + } +} +
    +
  • no-raw-colors fires on a palette utility such as text-red-600 and names the theme's role tokens instead (text-destructive, text-muted-foreground, ...). It never names a token the theme does not declare, and a theme with no tokens turns the rule off for the run with one warning.
  • +
  • no-arbitrary-values fires on a value in brackets such as p-[13px] or ring-[3px]. An arbitrary variant such as [&_svg]:size-4 or has-[>svg]:px-3 never fires.
  • +
  • no-restyle fires on a class composed over a kit helper, in either shape: cn(buttonClass(), 'bg-pink-500'), or a class attribute holding a \${buttonClass()} hole plus static text. The message lists the helper's real variants and sizes.
  • +
+

allow takes a category from shadcn's taxonomy (layout, color, typography, spacing, shape, effects, motion; padding is spacing and margin is layout, as upstream has it) or a class-group id such as rounded. That is why ["layout", "rounded"] is the recommended no-restyle setting: it admits the circular icon-button one-off cn(buttonClass({ size: 'none' }), 'w-9 h-9 rounded-full') without opening the whole shape category. components/ui/** is skipped by default, since a copied primitive legitimately owns values no variant can express; widen the scope with a negated entry, "ignore": ["!components/ui/**"]. webjs ui lint --json emits { violations, summary } for an agent loop, and --max-warnings <n> pins a count you lower over time.

+

Global styles and pseudo-elements

Some CSS can't be expressed as utility classes: body defaults, ::selection, ::-webkit-scrollbar, body::before decorative overlays. Put these in a plain <style> block in the root layout:

diff --git a/website/app/ui/page.ts b/website/app/ui/page.ts index e6c96c2c5..489a2a1b2 100644 --- a/website/app/ui/page.ts +++ b/website/app/ui/page.ts @@ -113,6 +113,7 @@ webjs ui add button card dialog input label view <name>Prints a component's helpers, its paste-ready example, and its full source. diff [name]Compares your local copy against the live registry. infoProject diagnostics: the resolved config and registry URL. + lintOpt-in design-system linter. Reports a raw palette colour, an arbitrary value, or a class composed over a kit helper, at the line, with a message built from your own theme tokens and helper variants. Off until components.json carries a lint block; with none it reports nothing and exits 0. --json for an agent loop. See the styling docs.