From 8918ce7ea3e239fe4ed96b590f9cf99af2363bd5 Mon Sep 17 00:00:00 2001 From: Vivek Date: Wed, 16 Sep 2026 20:19:08 +0530 Subject: [PATCH 1/9] feat(ui): accept a lint block in components.json and project helper axes rawConfigSchema is strict, so an app that adds a lint key today makes getConfig throw. The new lintConfigSchema makes the block optional and keeps it strict, so a typo in a rule name or severity is a config error rather than a silent no-op. extractHelperAxes reads the variant and size VALUES a helper exposes from its own source text, which is what a no-restyle message has to name, and it lives beside extractHelperSignatures so the two projections cannot drift. --- packages/ui/AGENTS.md | 4 +- packages/ui/src/registry/extract.js | 134 ++++++++++++++++++++++++++++ packages/ui/src/registry/schema.js | 38 ++++++++ packages/ui/test/extract.test.js | 30 +++++++ packages/ui/test/get-config.test.js | 41 +++++++++ packages/ui/test/schema.test.js | 27 ++++++ 6 files changed, 272 insertions(+), 2 deletions(-) diff --git a/packages/ui/AGENTS.md b/packages/ui/AGENTS.md index 6ae5ea39b..260626c23 100644 --- a/packages/ui/AGENTS.md +++ b/packages/ui/AGENTS.md @@ -93,11 +93,11 @@ packages/ui/ info.js info, project diagnostics build.js build, compile a custom registry (for registry authors) 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 diff --git a/packages/ui/src/registry/extract.js b/packages/ui/src/registry/extract.js index 063b89736..1cd524f9f 100644 --- a/packages/ui/src/registry/extract.js +++ b/packages/ui/src/registry/extract.js @@ -48,6 +48,140 @@ export function extractHelperSignatures(src) { return out; } +/** + * The option AXES a Tier-1 class helper exposes, e.g. + * `{ buttonClass: { variant: ['default', ...], size: ['default', 'xs', ...] } }`. + * + * `extractHelperSignatures` returns signature TEXT, which is right for `view` + * and the MCP `ui` tool but cannot answer "which sizes exist", the question a + * `webjsui lint` no-restyle message has to answer. Same lexical, parser-free + * approach, same module, so the two cannot drift. Pure over SOURCE TEXT so the + * linter can feed it the APP's copied `components/ui/*.ts` (which may have + * added or removed a variant) rather than the packaged registry. + * + * Resolves the two shapes the registry actually writes: + * const size = opts.size ?? 'default'; ... SIZES[size] (button.ts) + * VARIANTS[opts.variant ?? 'default'] (badge.ts) + * Two objects feeding one axis are unioned (switch.ts: TRACK_SIZES[size] and + * THUMB_SIZES[size]). A helper matching neither shape yields no axes, and the + * caller then omits the value list rather than inventing one. + * + * @param {string} src + * @returns {Record>} + */ +export function extractHelperAxes(src) { + // 1. Every `const NAME(: T)? = { ... }` object literal and its top-level keys. + /** @type {Map} */ + const objects = new Map(); + const objRe = /\bconst\s+([A-Za-z_$][\w$]*)\s*(?::[^=]*?)?=\s*\{/g; + let m; + while ((m = objRe.exec(src)) !== null) { + const open = m.index + m[0].length - 1; + const close = matchBrace(src, open); + if (close === -1) continue; + objects.set(m[1], topLevelKeys(src.slice(open + 1, close))); + objRe.lastIndex = close; + } + if (objects.size === 0) return {}; + + // 2. Every exported helper, by start offset, so a read below is attributed to + // the nearest preceding declaration (helpers are sequential in a module). + /** @type {{ name: string, at: number }[]} */ + const helpers = []; + const fnRe = + /export\s+(?:const\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?\([^)]*\)\s*(?::[^=]+?)?=>|function\s+([A-Za-z_$][\w$]*)\s*\()/g; + while ((m = fnRe.exec(src)) !== null) helpers.push({ name: m[1] || m[2], at: m.index }); + const helperAt = (/** @type {number} */ offset) => { + let h = null; + for (const c of helpers) if (c.at <= offset) h = c.name; + return h; + }; + + // 3. `const = . ?? ...` bindings, scoped to their helper. + /** @type {Map>} */ + const bindings = new Map(); + const bindRe = /\bconst\s+([A-Za-z_$][\w$]*)\s*=\s*[A-Za-z_$][\w$]*\.([A-Za-z_$][\w$]*)\s*\?\?/g; + while ((m = bindRe.exec(src)) !== null) { + const h = helperAt(m.index); + if (!h) continue; + if (!bindings.has(h)) bindings.set(h, new Map()); + bindings.get(h).set(m[1], m[2]); + } + + // 4. Every `OBJ[]` read: the axis is `.` in the index, or + // the axis the bare local was bound to. Union per helper + axis, first-seen + // order, so the message lists `default` first as the source does. + /** @type {Record>} */ + const out = {}; + const readRe = /\b([A-Za-z_$][\w$]*)\[([^\]]+)\]/g; + while ((m = readRe.exec(src)) !== null) { + const keys = objects.get(m[1]); + if (!keys) continue; + const h = helperAt(m.index); + if (!h) continue; + const index = m[2]; + let axis = null; + const member = /[A-Za-z_$][\w$]*\.([A-Za-z_$][\w$]*)/.exec(index); + if (member) axis = member[1]; + else { + const local = index.trim(); + axis = bindings.get(h)?.get(local) ?? null; + } + if (!axis) continue; + const forHelper = (out[h] ??= {}); + const values = (forHelper[axis] ??= []); + for (const k of keys) if (!values.includes(k)) values.push(k); + } + return out; +} + +/** Index of the `}` matching the `{` at `open`, string-aware. -1 when unbalanced. */ +function matchBrace(s, open) { + let depth = 0; + for (let i = open; i < s.length; i++) { + const c = s[i]; + if (c === "'" || c === '"' || c === '`') { + i++; + while (i < s.length && s[i] !== c) { if (s[i] === '\\') i++; i++; } + continue; + } + if (c === '{') depth++; + else if (c === '}') { depth--; if (depth === 0) return i; } + } + return -1; +} + +/** The top-level keys of an object-literal body (bare identifiers and quoted strings). */ +function topLevelKeys(body) { + /** @type {string[]} */ + const keys = []; + let depth = 0; + let atKey = true; + for (let i = 0; i < body.length; i++) { + const c = body[i]; + if (c === "'" || c === '"' || c === '`') { + let j = i + 1; + let text = ''; + while (j < body.length && body[j] !== c) { if (body[j] === '\\') j++; text += body[j]; j++; } + if (depth === 0 && atKey) { keys.push(text); atKey = false; } + i = j; + continue; + } + if (c === '{' || c === '[' || c === '(') { depth++; continue; } + if (c === '}' || c === ']' || c === ')') { depth--; continue; } + if (depth === 0 && c === ',') { atKey = true; continue; } + if (depth === 0 && atKey && /[A-Za-z_$]/.test(c)) { + let j = i; + let word = ''; + while (j < body.length && /[\w$]/.test(body[j])) word += body[j++]; + keys.push(word); + atKey = false; + i = j - 1; + } + } + return keys; +} + /** * The JSDoc header text (description + a11y obligations + token notes), with the * `@example` block and the `@module`/`@param`-style tags dropped. This is the diff --git a/packages/ui/src/registry/schema.js b/packages/ui/src/registry/schema.js index 9cc1d66ec..82ab05fab 100644 --- a/packages/ui/src/registry/schema.js +++ b/packages/ui/src/registry/schema.js @@ -82,6 +82,43 @@ export const registryItemCommonSchema = z.object({ categories: z.array(z.string()).optional(), }); +/** A rule's severity. Absent from `lint.rules` means `off`. */ +export const lintSeveritySchema = z.enum(['off', 'warn', 'error']); + +/** + * One rule's configuration: a bare severity, or an object adding `allow` + * (category names such as `layout` / `spacing`, or class-group names such + * as `rounded`). Kept `.strict()` so a typo is a config error, not a silent + * no-op. + */ +export const lintRuleSchema = z.union([ + lintSeveritySchema, + z + .object({ + severity: lintSeveritySchema.default('warn'), + allow: z.array(z.string()).optional(), + }) + .strict(), +]); + +/** + * The opt-in `lint` block of `components.json`, read by `webjsui lint`. No + * block means every rule is off and the command reports nothing. + */ +export const lintConfigSchema = z + .object({ + ignore: z.array(z.string()).default([]), + rules: z + .object({ + 'no-raw-colors': lintRuleSchema.optional(), + 'no-arbitrary-values': lintRuleSchema.optional(), + 'no-restyle': lintRuleSchema.optional(), + }) + .strict() + .default({}), + }) + .strict(); + export const rawConfigSchema = z .object({ $schema: z.string().optional(), @@ -94,6 +131,7 @@ export const rawConfigSchema = z prefix: z.string().default('').optional(), }), iconLibrary: z.string().optional().default('lucide'), + lint: lintConfigSchema.optional(), aliases: z.object({ components: z.string().default('components'), utils: z.string().default('lib/utils'), diff --git a/packages/ui/test/extract.test.js b/packages/ui/test/extract.test.js index 9aa15ecf8..972e1a3f1 100644 --- a/packages/ui/test/extract.test.js +++ b/packages/ui/test/extract.test.js @@ -7,10 +7,12 @@ */ import { test } from 'node:test'; import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; import { uiComponent, uiInventory, extractHelperSignatures, + extractHelperAxes, extractDocHeader, renderComponentText, } from '../src/registry/extract.js'; @@ -69,3 +71,31 @@ test('renderComponentText: includes tier, helpers, and deps for a Tier-1 compone assert.match(text, /cardClass/); assert.match(text, /npm: @webjsdev\/core/); }); + +test('extractHelperAxes: resolves the local-binding shape (button.ts) into variant + size values', () => { + const src = readFileSync(new URL('../packages/registry/components/button.ts', import.meta.url), 'utf8'); + const axes = extractHelperAxes(src); + assert.deepEqual(axes.buttonClass.variant, ['default', 'destructive', 'outline', 'secondary', 'ghost', 'link']); + assert.deepEqual(axes.buttonClass.size, ['default', 'xs', 'sm', 'lg', 'icon', 'icon-xs', 'icon-sm', 'icon-lg']); +}); + +test('extractHelperAxes: resolves the inline shape (badge.ts) and unions two objects on one axis (switch.ts)', () => { + const badge = readFileSync(new URL('../packages/registry/components/badge.ts', import.meta.url), 'utf8'); + assert.deepEqual(extractHelperAxes(badge).badgeClass.variant, ['default', 'secondary', 'destructive', 'outline', 'ghost', 'link']); + const sw = readFileSync(new URL('../packages/registry/components/switch.ts', import.meta.url), 'utf8'); + // TRACK_SIZES[size] and THUMB_SIZES[size] carry the same keys, unioned + deduplicated. + assert.deepEqual(extractHelperAxes(sw).switchTrackClass.size, ['default', 'sm']); + const unioned = extractHelperAxes( + "const A = { default: 'x', big: 'y' } as const;\nconst B = { default: 'z', huge: 'w' } as const;\n" + + "export function fooClass(opts: { size?: string } = {}) { const size = opts.size ?? 'default'; return [A[size], B[size]].join(' '); }\n", + ); + assert.deepEqual(unioned.fooClass.size, ['default', 'big', 'huge']); +}); + +test('extractHelperAxes: a helper matching neither shape yields no axes rather than a wrong list', () => { + const src = "const BASE = 'rounded-xl border';\nexport const cardClass = (): string => BASE;\n"; + assert.deepEqual(extractHelperAxes(src), {}); + // An object read through an unrelated index (not an option) is not an axis. + const other = "const MAP = { a: 1, b: 2 };\nexport function fooClass() { const k = compute(); return String(MAP[k]); }\n"; + assert.deepEqual(extractHelperAxes(other), {}); +}); diff --git a/packages/ui/test/get-config.test.js b/packages/ui/test/get-config.test.js index 249ef1759..68a50bacd 100644 --- a/packages/ui/test/get-config.test.js +++ b/packages/ui/test/get-config.test.js @@ -40,3 +40,44 @@ test('getConfig: rejects invalid config', () => { assert.throws(() => getConfig(d)); } finally { rmSync(d, { recursive: true }); } }); + +test('getConfig: a `lint` block round-trips with no get-config change', () => { + const d = tmp(); + try { + writeConfig(d, { + style: 'default', + tailwind: { css: 'public/input.css', baseColor: 'neutral', cssVariables: true }, + aliases: { components: 'components', utils: 'lib/utils/cn', ui: 'components/ui', lib: 'lib' }, + lint: { + ignore: ['app/legacy/**'], + rules: { + 'no-raw-colors': 'warn', + 'no-restyle': { severity: 'error', allow: ['layout', 'rounded'] }, + }, + }, + }); + const parsed = getConfig(d); + assert.equal(parsed.lint.rules['no-restyle'].severity, 'error'); + assert.deepEqual(parsed.lint.rules['no-restyle'].allow, ['layout', 'rounded']); + assert.equal(parsed.lint.rules['no-raw-colors'], 'warn'); + assert.deepEqual(parsed.lint.ignore, ['app/legacy/**']); + assert.ok(parsed.resolvedPaths.tailwindCss.endsWith('public/input.css')); + } finally { rmSync(d, { recursive: true }); } +}); + +test('getConfig: a `lint` block with an unknown rule or severity throws (strict schemas)', () => { + const base = { + style: 'default', + tailwind: { css: 'public/input.css' }, + aliases: { components: 'components', utils: 'lib/utils/cn' }, + }; + const d = tmp(); + try { + writeConfig(d, { ...base, lint: { rules: { 'no-such-rule': 'warn' } } }); + assert.throws(() => getConfig(d)); + writeConfig(d, { ...base, lint: { rules: { 'no-raw-colors': 'loud' } } }); + assert.throws(() => getConfig(d)); + writeConfig(d, { ...base, lint: { rules: { 'no-raw-colors': { severity: 'warn', alow: [] } } } }); + assert.throws(() => getConfig(d)); + } finally { rmSync(d, { recursive: true }); } +}); 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, {}); +}); From 2978ecca0f2b1262c60682b6f92059cf365fb2b9 Mon Sep 17 00:00:00 2001 From: Vivek Date: Wed, 16 Sep 2026 20:30:49 +0530 Subject: [PATCH 2/9] feat(ui): add the lint grammar, class-site scanner and theme reader The grammar parses one token into variants and a utility, decides arbitrary VALUE against arbitrary VARIANT on whether the utility segment carries a bracket, and resolves the utility to a class group and to the category taxonomy transcribed verbatim from shadcn-ui/lint. The scanner reads class sites from html tagged templates, cn() arguments and class=${} holes with a hand-rolled lexer, and only inside an open tag, so an entity-escaped code sample in a docs page is never a site. The theme reader parses --color-* tokens from both @theme and @theme inline. --- packages/ui/AGENTS.md | 4 + packages/ui/src/lint/grammar.js | 824 +++++++++++++++++++++ packages/ui/src/lint/scan.js | 368 +++++++++ packages/ui/src/lint/theme-tokens.js | 71 ++ packages/ui/test/lint-grammar.test.js | 107 +++ packages/ui/test/lint-scan.test.js | 114 +++ packages/ui/test/lint-theme-tokens.test.js | 41 + 7 files changed, 1529 insertions(+) create mode 100644 packages/ui/src/lint/grammar.js create mode 100644 packages/ui/src/lint/scan.js create mode 100644 packages/ui/src/lint/theme-tokens.js create mode 100644 packages/ui/test/lint-grammar.test.js create mode 100644 packages/ui/test/lint-scan.test.js create mode 100644 packages/ui/test/lint-theme-tokens.test.js diff --git a/packages/ui/AGENTS.md b/packages/ui/AGENTS.md index 260626c23..68681ee95 100644 --- a/packages/ui/AGENTS.md +++ b/packages/ui/AGENTS.md @@ -92,6 +92,10 @@ 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/ + 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 + 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) + the opt-in `lint` block of components.json local.js LOCAL-FIRST composer: read the packaged registry from disk (no network) diff --git a/packages/ui/src/lint/grammar.js b/packages/ui/src/lint/grammar.js new file mode 100644 index 000000000..cc49f1410 --- /dev/null +++ b/packages/ui/src/lint/grammar.js @@ -0,0 +1,824 @@ +/** + * 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-* + 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'; + if (/^(?:base|xs|sm|lg|\dxl|xl)\/[\w.]+$/.test(v)) return 'font-size'; + return 'text-color'; + } + 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'; + } + // 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 (/^(?: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'; + 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')) return v === 'spacing' ? 'border-spacing' : `border-spacing-${v.slice(8)}`; + 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'; + if (utility.startsWith('scrollbar-')) 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/scan.js b/packages/ui/src/lint/scan.js new file mode 100644 index 000000000..7dc1d6ab9 --- /dev/null +++ b/packages/ui/src/lint/scan.js @@ -0,0 +1,368 @@ +/** + * 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 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: [] }); + const emit = (site) => { if (site.classes.length) 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) }); + } + }; + + 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(); + }; + const scanString = (q) => { + 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) pushTokens(site, body, start); + markValue(); + }; + const scanPlainTemplate = () => { + i++; + while (i < n) { + const c = src[i]; + if (c === '\\' && i + 1 < n) { i += 2; continue; } + if (c === '`') { i++; break; } + if (c === '$' && src[i + 1] === '{') { + i += 2; + scanCode('hole'); + if (i < n && src[i] === '}') i++; + continue; + } + i++; + } + 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; + const flushRun = (touchesHoleRight) => { + if (!attr || !attr.site) return; + let text = attr.run; + let base = attr.runStart; + if (text.length) { + if (attr.holeBefore && !/^\s/.test(text)) { + const m = /^\S+/.exec(text); + base += m[0].length; text = text.slice(m[0].length); + } + if (touchesHoleRight && !/\s$/.test(text)) { + text = text.replace(/\S+$/, ''); + } + pushTokens(attr.site, text, base); + } + 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; } + if (c === '$' && src[i + 1] === '{') { + const at = i; + i += 2; + if (attr && attr.site) { + flushRun(true); + attr.holeBefore = true; + collectors.push(attr.site); + scanCode('hole'); + collectors.pop(); + } else if (pendingClassHole) { + pendingClassHole = false; + const site = newSite('hole', at); + collectors.push(site); + scanCode('hole'); + collectors.pop(); + emit(site); + } else { + scanCode('hole'); + } + if (i < n && src[i] === '}') i++; + continue; + } + if (attr) { + if (attr.quote ? c === attr.quote : (/\s/.test(c) || c === '>')) { + const consume = attr.quote !== null; + closeAttr(); + if (consume) i++; + continue; + } + if (attr.site) { if (attr.runStart === -1) attr.runStart = i; attr.run += c; } + i++; + continue; + } + if (!inTag) { + if (c === '<' && /[A-Za-z]/.test(src[i + 1] || '')) { inTag = true; i += 2; continue; } + i++; + continue; + } + if (c === '>') { inTag = false; pendingClassHole = false; i++; continue; } + if (c === '"' || c === "'") { + // Another attribute's quoted value: skip it, still scanning holes as code. + attr = { site: null, quote: c, run: '', runStart: -1, holeBefore: false }; + i++; + continue; + } + if (src.startsWith('class=', i) && /[\s]/.test(src[i - 1] || '')) { + i += 6; + const q = src[i]; + if (q === '"' || q === "'") { + attr = { site: newSite('attribute', i), quote: q, run: '', runStart: -1, holeBefore: false }; + i++; + } else if (q === '$' && src[i + 1] === '{') { + pendingClassHole = true; + } else { + attr = { site: newSite('attribute', i), quote: null, run: '', runStart: -1, holeBefore: false }; + } + continue; + } + i++; + } + if (attr) closeAttr(); + markValue(); + }; + + // A recognized call: `cn(` opens a call site unless one is already + // collecting; a `*Class(` helper contributes its name and mutes its args. + const scanCall = (word, wordAt) => { + // i sits at `(` + i++; + if (helpers.has(word)) { + const site = active(); + if (site && mute === 0 && !site.helpers.includes(word)) site.helpers.push(word); + mute++; + scanCode('paren'); + mute--; + } else if (active() || mute > 0) { + scanCode('paren'); + } else { + const site = newSite('call', wordAt); + collectors.push(site); + scanCode('paren'); + collectors.pop(); + emit(site); + } + if (i < n && src[i] === ')') i++; + lastSig = ')'; lastWord = ''; lastWordIsProp = false; lastWasIncDec = false; + }; + + /** @param {'hole'|'paren'|null} stop */ + function scanCode(stop) { + let brace = 0; + let paren = 0; + while (i < n) { + const c = src[i], next = src[i + 1]; + if (stop === 'hole' && c === '}' && brace === 0) return; + if (stop === 'paren' && c === ')' && paren === 0) return; + if (c === '/' && next === '/') { scanLineComment(); continue; } + if (c === '/' && next === '*') { scanBlockComment(); continue; } + if (c === '/' && isRegex()) { scanRegex(); continue; } + if (c === "'" || c === '"') { scanString(c); continue; } + if (c === '`') { + if (lastWord === 'html' && /[\w$]/.test(lastSig) && !lastWordIsProp) scanHtmlTemplate(); + else scanPlainTemplate(); + continue; + } + if (c === '{') { brace++; lastSig = '{'; lastWord = ''; lastWasIncDec = false; i++; continue; } + if (c === '}') { brace--; lastSig = '}'; lastWord = ''; lastWasIncDec = false; i++; continue; } + if (c === '(') { paren++; lastSig = '('; lastWord = ''; lastWasIncDec = false; i++; continue; } + if (c === ')') { paren--; lastSig = ')'; lastWord = ''; lastWasIncDec = false; i++; continue; } + if (/[A-Za-z_$]/.test(c)) { + const prop = lastSig === '.'; + const at = i; + let w = ''; + while (i < n && /[\w$]/.test(src[i])) { w += src[i]; i++; } + lastWord = w; lastSig = w[w.length - 1]; lastWordIsProp = prop; lastWasIncDec = false; + if (!prop && (cnNames.has(w) || helpers.has(w))) { + let j = i; + while (j < n && /[ \t]/.test(src[j])) j++; + if (src[j] === '(') { i = j; scanCall(w, at); } + } + continue; + } + if (/\s/.test(c)) { i++; continue; } + lastWasIncDec = (c === '+' || c === '-') && c === lastSig; + lastSig = c; lastWord = ''; i++; + } + } + + scanCode(null); + return sites; +} + +/** + * The recognized helper and `cn` identifiers of one module, read from its + * imports. An identifier ending in `Class` is a kit helper only when imported + * from a path resolving inside `uiDir`; `cn` only when imported from + * `utilsPath` (the config's `aliases.utils`). A `#` specifier has its sigil + * stripped and resolves against `appRoot`; a relative one against the file. + * An unrelated local `fooClass()` is therefore never mistaken for a helper. + * + * @param {string} src + * @param {{ filePath: string, appRoot: string, uiDir: string, utilsPath: string }} paths + * @returns {{ helpers: string[], cnNames: string[] }} + */ +export function collectHelperImports(src, { filePath, appRoot, uiDir, utilsPath }) { + /** @type {string[]} */ + const helpers = []; + /** @type {string[]} */ + const cnNames = []; + const stripExt = (p) => p.replace(/\.(?:ts|tsx|js|jsx|mts|mjs)$/, ''); + const ui = resolve(uiDir); + const utils = stripExt(resolve(utilsPath)); + const re = /\bimport\s*(?:type\s+)?\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]/g; + let m; + while ((m = re.exec(src)) !== null) { + const spec = m[2]; + let target; + if (spec.startsWith('#')) target = resolve(appRoot, spec.slice(1)); + else if (spec.startsWith('.')) target = resolve(dirname(filePath), spec); + else continue; + const inUi = target === ui || target.startsWith(ui + sep); + const isUtils = stripExt(target) === utils; + if (!inUi && !isUtils) continue; + for (const part of m[1].split(',')) { + const piece = part.trim().replace(/^type\s+/, ''); + if (!piece) continue; + const [imported, local = imported] = piece.split(/\s+as\s+/).map((s) => s.trim()); + if (inUi && /Class$/.test(local)) helpers.push(local); + if (isUtils && imported === 'cn') cnNames.push(local); + } + } + return { helpers, cnNames }; +} diff --git a/packages/ui/src/lint/theme-tokens.js b/packages/ui/src/lint/theme-tokens.js new file mode 100644 index 000000000..ff9731749 --- /dev/null +++ b/packages/ui/src/lint/theme-tokens.js @@ -0,0 +1,71 @@ +/** + * The theme-token READER behind `webjsui lint`: the `--color-` tokens an + * app's configured Tailwind CSS file declares, which is what a `no-raw-colors` + * message names as the alternative to a raw palette utility. + * + * Distinct from `utils/theme.js`, which WRITES the token block for `init` and + * `add`. The two are not merged and this one does not look for the + * `THEME_MARKER`, because an app's theme file is its own and need not carry + * the marker (the blog's `public/input.css` opens a plain `@theme {` and + * declares tokens the kit does not ship). + * + * Both `@theme {` and `@theme inline {` are read: the kit's `themes/index.css` + * uses the inline form, the blog the plain one. + * + * @module lint/theme-tokens + */ + +import { readFileSync } from 'node:fs'; + +/** + * Parse the `--color-` declarations out of CSS text, inside `@theme` and + * `@theme inline` blocks only. Pure over the text so it is testable without a + * file. + * + * @param {string} css + * @returns {string[]} token names without the `--color-` prefix, in declaration order, deduplicated + */ +export function parseThemeTokens(css) { + /** @type {string[]} */ + const tokens = []; + const re = /@theme\b[^{;]*\{/g; + let m; + while ((m = re.exec(css)) !== null) { + const open = m.index + m[0].length - 1; + let depth = 0; + let close = -1; + for (let i = open; i < css.length; i++) { + if (css[i] === '{') depth++; + else if (css[i] === '}') { depth--; if (depth === 0) { close = i; break; } } + } + if (close === -1) break; + const body = css.slice(open + 1, close); + const declRe = /--color-([A-Za-z0-9_-]+)\s*:/g; + let d; + while ((d = declRe.exec(body)) !== null) { + if (!tokens.includes(d[1])) tokens.push(d[1]); + } + re.lastIndex = close; + } + return tokens; +} + +/** + * Read the theme tokens from a CSS file. A missing or unreadable file, or one + * with no `--color-*` declaration inside a `@theme` block, yields an empty + * `tokens` array rather than throwing; the caller then disables `no-raw-colors` + * for the run and names the path, since a message that cannot offer an + * alternative has no value. + * + * @param {string} cssPath + * @returns {{ tokens: string[], path: string }} + */ +export function readThemeTokens(cssPath) { + let css = ''; + try { + css = readFileSync(cssPath, 'utf8'); + } catch { + return { tokens: [], path: cssPath }; + } + return { tokens: parseThemeTokens(css), path: cssPath }; +} diff --git a/packages/ui/test/lint-grammar.test.js b/packages/ui/test/lint-grammar.test.js new file mode 100644 index 000000000..4e67f6443 --- /dev/null +++ b/packages/ui/test/lint-grammar.test.js @@ -0,0 +1,107 @@ +/** + * `webjsui lint` token grammar (`src/lint/grammar.js`): how one class is + * parsed, the arbitrary VALUE versus arbitrary VARIANT rule, and the + * category taxonomy transcribed from shadcn-ui/lint. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { parseToken, isAllowed, GROUP_CATEGORY, categoryOf } from '../src/lint/grammar.js'; + +test('parseToken: arbitrary VARIANTS are not arbitrary values', () => { + for (const [token, variants, utility] of [ + ['[&_svg]:size-4', ['[&_svg]'], 'size-4'], + ["[&_svg:not([class*='size-'])]:size-4", ["[&_svg:not([class*='size-'])]"], 'size-4'], + ['has-[>svg]:px-3', ['has-[>svg]'], 'px-3'], + ['data-[state=open]:flex', ['data-[state=open]'], 'flex'], + ]) { + const p = parseToken(token); + assert.deepEqual(p.variants, variants, token); + assert.equal(p.utility, utility, token); + assert.equal(p.arbitraryValue, false, token); + } +}); + +test('parseToken: arbitrary VALUES and arbitrary PROPERTIES are', () => { + for (const [token, variants, utility] of [ + ['focus-visible:ring-[3px]', ['focus-visible'], 'ring-[3px]'], + ['p-[13px]', [], 'p-[13px]'], + ['[padding:13px]', [], '[padding:13px]'], + ]) { + const p = parseToken(token); + assert.deepEqual(p.variants, variants, token); + assert.equal(p.utility, utility, token); + assert.equal(p.arbitraryValue, true, token); + } + // The only `:` in `[padding:13px]` is inside the brackets, so it is the utility whole. + assert.equal(parseToken('[padding:13px]').category, 'spacing'); +}); + +test('parseToken: the `(--var)` shorthand is not an arbitrary value', () => { + const p = parseToken('bg-(--brand)'); + assert.equal(p.arbitraryValue, false); + assert.equal(p.group, 'bg-color'); +}); + +test('parseToken: variants, base, opacity, negative, important', () => { + const p = parseToken('dark:hover:bg-primary/90'); + assert.deepEqual(p.variants, ['dark', 'hover']); + assert.equal(p.base, 'bg-primary'); + assert.equal(p.opacity, '90'); + assert.equal(p.category, 'color'); + assert.equal(parseToken('-mt-4').negative, true); + assert.equal(parseToken('-mt-4').base, 'mt-4'); + assert.equal(parseToken('p-4!').important, true); + assert.equal(parseToken('p-4!').base, 'p-4'); + // A fraction is not an opacity: only a colour group takes the suffix. + assert.equal(parseToken('w-1/2').opacity, null); + assert.equal(parseToken('w-1/2').base, 'w-1/2'); +}); + +test('parseToken: colour versus size disambiguation on shared prefixes', () => { + assert.equal(parseToken('text-sm').group, 'font-size'); + assert.equal(parseToken('text-red-600').group, 'text-color'); + assert.equal(parseToken('text-[13px]').group, 'font-size'); + assert.equal(parseToken('text-[#333]').group, 'text-color'); + assert.equal(parseToken('border-2').group, 'border-w'); + assert.equal(parseToken('border-t-red-500').group, 'border-color-t'); + assert.equal(parseToken('ring-2').group, 'ring-w'); + assert.equal(parseToken('ring-red-500').group, 'ring-color'); + assert.equal(parseToken('shadow-lg').group, 'shadow'); + assert.equal(parseToken('shadow-red-500').group, 'shadow-color'); + assert.equal(parseToken('rounded-full').group, 'rounded'); + assert.equal(parseToken('rounded-t-lg').group, 'rounded-t'); + assert.equal(parseToken('flex').group, 'display'); + assert.equal(parseToken('flex-1').group, 'flex'); +}); + +test('isAllowed: a category grants the category, a group id grants only that group', () => { + assert.equal(isAllowed(parseToken('w-9'), ['layout']), true); + assert.equal(isAllowed(parseToken('h-9'), ['layout']), true); + assert.equal(isAllowed(parseToken('rounded-full'), ['layout']), false); + assert.equal(isAllowed(parseToken('rounded-full'), ['layout', 'rounded']), true); + assert.equal(isAllowed(parseToken('w-9'), ['layout', 'rounded']), true); + // `rounded` is narrower than the `shape` category it sits in. + assert.equal(isAllowed(parseToken('border-2'), ['layout']), false); + assert.equal(isAllowed(parseToken('border-2'), ['layout', 'rounded']), false); + assert.equal(isAllowed(parseToken('border-2'), ['shape']), true); + // The corner groups are not covered by the plain radius grant. + assert.equal(isAllowed(parseToken('rounded-t-lg'), ['rounded']), false); + assert.equal(isAllowed(parseToken('p-4'), undefined), false); +}); + +test('GROUP_CATEGORY: keeps shadcn placements (padding is spacing, margin is layout)', () => { + assert.equal(GROUP_CATEGORY.p, 'spacing'); + assert.equal(GROUP_CATEGORY.m, null); + assert.equal(GROUP_CATEGORY.rounded, 'shape'); + assert.equal(GROUP_CATEGORY['border-w'], 'shape'); + assert.equal(GROUP_CATEGORY['text-color'], 'color'); + assert.equal(GROUP_CATEGORY['font-size'], 'typography'); + assert.equal(GROUP_CATEGORY.shadow, 'effects'); + assert.equal(GROUP_CATEGORY.transition, 'motion'); + // Upstream carries exactly six named categories; everything else is null. + const cats = new Set(Object.values(GROUP_CATEGORY).filter(Boolean)); + assert.deepEqual([...cats].sort(), ['color', 'effects', 'motion', 'shape', 'spacing', 'typography']); + assert.equal(categoryOf('arbitrary..color'), 'color'); + assert.equal(categoryOf('arbitrary..transition-duration'), 'motion'); + assert.equal(categoryOf('arbitrary..grid-template-areas'), null); +}); diff --git a/packages/ui/test/lint-scan.test.js b/packages/ui/test/lint-scan.test.js new file mode 100644 index 000000000..b1600ea8a --- /dev/null +++ b/packages/ui/test/lint-scan.test.js @@ -0,0 +1,114 @@ +/** + * `webjsui lint` class-site scanner (`src/lint/scan.js`): the three site + * shapes, the hole-fragment rule, and the tag-region rule that keeps an + * escaped code sample from being read as a live class. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { scanClassSites, collectHelperImports } from '../src/lint/scan.js'; + +const names = (sites) => sites.flatMap((s) => s.classes.map((c) => c.name)); + +test('scan: a static class attribute inside an html template is one site with 1-based positions', () => { + const src = 'const x = 1;\nconst t = html`

x

`;'; + const sites = scanClassSites(src); + assert.equal(sites.length, 1); + assert.equal(sites[0].kind, 'attribute'); + assert.deepEqual(names(sites), ['text-sm', 'text-red-600']); + const red = sites[0].classes[1]; + assert.equal(red.line, 2); + assert.equal(red.column, 34); + assert.equal(src.slice(red.offset, red.offset + red.name.length), 'text-red-600'); +}); + +test('scan: the blog nested-template shape yields a site', () => { + const src = "html`
\n ${err ? html`

${err}

` : ''}\n
`"; + const sites = scanClassSites(src); + assert.deepEqual(names(sites), ['text-sm', 'text-red-600']); + assert.equal(sites[0].classes[1].line, 2); +}); + +test('scan: the escaped code sample in website/app/docs/file-storage/page.ts yields ZERO sites', () => { + const src = readFileSync(new URL('../../../website/app/docs/file-storage/page.ts', import.meta.url), 'utf8'); + assert.match(src, /<p class="text-sm text-red-600">/); // the fixture is still there + assert.deepEqual(scanClassSites(src), []); + // Prove the scanner walked the whole file rather than bailing: a real tagged + // site appended after it is still found, and the escaped sample still is not. + const sites = scanClassSites(src + '\nexport const probe = html`

x

`;'); + assert.deepEqual(names(sites), ['p-2', 'text-red-600']); + // The same shape in isolation: no `<` opens a tag, so nothing is read. + const isolated = 'html`<p class="text-sm text-red-600">`'; + assert.deepEqual(scanClassSites(isolated), []); +}); + +test('scan: a token touching a hole is dropped as a fragment', () => { + assert.deepEqual(names(scanClassSites('html`

`')), ['p-2']); + assert.deepEqual(names(scanClassSites('html`

`')), ['p-2', 'mt-1']); + assert.deepEqual(names(scanClassSites('html`

`')), ['p-2']); +}); + +test('scan: a cn() hole yields the literal classes plus the helper identity, never the option values', () => { + const src = "html`