/** * The component family of the design lint: the Tailwind reader (class * strings in HTML, JSX, Vue, Svelte and Astro, helper calls, shared tokens) * and the rules it feeds, each with a negative control and a planted tell. */ import { afterAll, describe, expect, test } from 'bun:test'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { lint, formatFinding, type Finding } from '../lib/design-lint/index'; import { lintStatic } from '../lib/design-lint/static'; import { twGroup, twParse, twVariants } from '../lib/design-lint/tailwind'; const tmpDirs: string[] = []; afterAll(() => { for (const d of tmpDirs) fs.rmSync(d, { recursive: true, force: true }); }); const ROOT = path.resolve(import.meta.dir, '..'); const FIXTURES = path.join(ROOT, 'test', 'fixtures', 'design-lint'); const MY_RULES = ['layout-transition', 'bounce-easing', 'purple-blue-gradient', 'gradient-text', 'glow-wash', 'literal-outside-tokens', 'hover-tells', 'wide-tracking', 'kicker-above-heading', 'monospace-costume', 'framework-palette', 'vh-height', 'template-cards', 'template-punctuation', 'emoji-decoration']; function tmpDir(files: Record): string { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'design-lint-tw-')); tmpDirs.push(dir); for (const [name, text] of Object.entries(files)) { fs.mkdirSync(path.dirname(path.join(dir, name)), { recursive: true }); fs.writeFileSync(path.join(dir, name), text); } return dir; } async function lintFiles(files: Record, opts: { scope?: 'page' | 'component' } = {}): Promise { const dir = tmpDir(files); const result = await lint([dir], { cwd: dir, warn: (l: string) => { throw new Error(l); }, ...opts }); return result.findings; } const only = (findings: Finding[], rule: string) => findings.filter((f) => f.rule === rule); const lines = (findings: Finding[], rule: string) => only(findings, rule).map((f) => f.line); const mine = (findings: Finding[]) => findings.filter((f) => MY_RULES.includes(f.rule)).map(formatFinding); /** A page whose body line i (0-based) sits on line 5 + i. */ const page = (body: string[], head = ''): string => ['', '', `Fieldnote${head}`, '', ...body, ''].join('\n'); const BODY = 5; describe('design-lint tailwind: the reader', () => { test('twParse splits variants, drops group-/peer- and /names, and reads the ! and - marks', () => { expect(twParse('md:hover:bg-accent')).toEqual({ variants: ['md', 'hover'], base: 'bg-accent', neg: false }); expect(twParse('group-hover/card:-translate-y-1')).toEqual({ variants: ['hover'], base: 'translate-y-1', neg: true }); expect(twParse('!text-[length:var(--x)]')).toEqual({ variants: [], base: 'text-[length:var(--x)]', neg: false }); expect(twParse('[&:nth-child(2)]:pt-0').variants).toEqual(['[&:nth-child(2)]']); }); test('class strings are read from class, className, :class, class:list, class: directives and cn()/clsx()/cva() calls; variants are collected', async () => { const dir = tmpDir({ 'package.json': '{}', 'Hero.tsx': [ 'import { cva } from "cva";', 'import { cn } from "./cn";', 'const badge = cva("rounded-full text-xs uppercase tracking-widest");', 'export function Hero({ on }: { on: boolean }) {', ' const row = clsx("flex", on && "transition-all");', ' return Go;', '}', ].join('\n'), 'Card.vue': '', 'Band.astro': '---\nconst x = 1;\n---\n
\n

Hi

', }); const { contexts } = lintStatic([dir], { cwd: dir, warn: (l: string) => { throw new Error(l); } }); const ctx = contexts.find((c) => c.docs.length === 3)!; const tw = twGroup(ctx); const by = (tag: string) => tw.items.filter((i) => i.tag === tag).map((i) => i.info.classes.join(' ')); expect(by('cva')).toEqual(['rounded-full text-xs uppercase tracking-widest']); expect(by('clsx')).toEqual(['flex transition-all']); expect(by('a')).toEqual(['px-3 hover:bg-accent focus-visible:outline-2']); expect(by('div')).toEqual(['bg-paper disabled:opacity-50 active', 'motion-reduce:transition-none p-4']); expect(by('p')).toEqual(['mt-2 text-sm tracking-wide']); expect([...twVariants(ctx)].sort()).toEqual(['disabled', 'focus-visible', 'hover', 'motion-reduce']); }); test('a component directory borrows the project tokens from a sibling globals.css @theme block', async () => { const dir = tmpDir({ 'package.json': '{}', 'app/globals.css': '@import "tailwindcss";\n@theme {\n --color-paper: oklch(97% 0.01 80);\n --color-accent: oklch(55% 0.2 300);\n --text-label: 0.75rem;\n --tracking-label: 0.1em;\n}\n.btn:focus-visible { outline: 2px solid var(--color-accent); }', 'components/Label.tsx': [ 'export function Label() {', ' return Field kit;', '}', 'export function Band() {', ' return