From 69ac84f2e42b6d5881ca2dad27f1f4d14352e44d Mon Sep 17 00:00:00 2001 From: garrytan Date: Mon, 26 Oct 2026 05:49:02 +0000 Subject: [PATCH] feat(design-lint): port the Tailwind reader and component rules Replaces the tailwind.ts stub with sloplint's utility reader (twParse, twInfo, twGroup, variants, stops, sizes, tracking, families, shared project tokens) and the tells it feeds, and fills rules/component.ts with checkTailwind and checkComponentText: 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 and emoji-decoration over class strings in HTML, JSX, Vue, Svelte and Astro files and cn()/clsx()/cva() calls. test/design-lint-tailwind.test.ts holds a negative control and a planted tell per rule plus the tailwind-fail, tailwind-pass and clean fixtures. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- lib/design-lint/rules/component.ts | 102 ++++- lib/design-lint/tailwind.ts | 650 ++++++++++++++++++++++++++++- test/design-lint-tailwind.test.ts | 358 ++++++++++++++++ 3 files changed, 1098 insertions(+), 12 deletions(-) create mode 100644 test/design-lint-tailwind.test.ts diff --git a/lib/design-lint/rules/component.ts b/lib/design-lint/rules/component.ts index 008a5347f..d4edd1c06 100644 --- a/lib/design-lint/rules/component.ts +++ b/lib/design-lint/rules/component.ts @@ -1,10 +1,104 @@ /** - * Component rules of the design lint (sloplint gates checkTailwind, checkComponentText; Nutlope/hallmark, - * MIT, see NOTICE.md), rewritten in gstack's voice. Rule ids: the Tailwind half of 2a 3 6a 11 54 65 67 74 and kin over component class strings, plus emoji and template punctuation in component text. + * Component rules of the design lint (sloplint checkTailwind and + * checkComponentText; Nutlope/hallmark, MIT, see NOTICE.md), rewritten in + * gstack's voice: the Tailwind form of gates 2a 2b 3 6a 10a 11 12 29 48 54 64 + * 67 and 74 read off utility class strings in pages and components, plus + * emoji icons and template punctuation (30a, 63) in component text. */ import type { LintContext } from '../context'; -import type { Reporter } from '../report'; +import { relPath, trunc, type Reporter } from '../report'; +import { compInner, twEyebrow, twGlow, twGradient, twGroup, twMonoIsMaterial, twPlain, twText, twTiles, twTokens, twTrackedCaps, twWhere, twDataText, type TwItem } from '../tailwind'; + +const EMOJI_ICON = /(?![©®™‼⁉←-⇿])\p{Extended_Pictographic}/u; + +/** The tells one class string carries on its own: motion, gradient, glow, literals, a moving image, tracked caps, the eyebrow, mono costume. */ +function reportItem(ctx: LintContext, r: Reporter, it: TwItem, monoOk: boolean): void { + const { doc, info: f } = it; + const where = twWhere(it); + const had = (rule: string) => r.findings.some((x) => x.rule === rule && x.file === relPath(doc.file, r.cwd) && x.line === it.line); + if (f.tAll) r.report('layout-transition', doc.file, it.line, `${f.tAll} on ${where}`, { fix: 'name the properties: transition-colors, transition-opacity or transition-transform' }); + if (f.bounce || f.overshoot) r.report('bounce-easing', doc.file, it.line, `${f.bounce ? 'bounce' : 'overshoot easing'} ${f.bounce || f.overshoot} on ${where}`, { fix: 'an exponential ease-out on UI state; no bounce or overshoot' }); + const g = twGradient(ctx, f); + if (g && g.banned) r.report('purple-blue-gradient', doc.file, it.line, `${g.banned} gradient${g.text ? ' text' : ''} (${g.list}) on ${where}`); + else if (g && g.text) r.report('gradient-text', doc.file, it.line, `gradient text (${g.list}) on ${where}`); + const glow = f.bblur && f.glass ? `glass: ${f.bblur} over ${f.glass}` + : f.blur >= 40 && f.pos && (f.fill || f.grad || f.stops.length) ? `blurred orb: ${f.blurTok} on a filled ${f.pos} element` + : twGlow(ctx, f); + if (glow) r.report('glow-wash', doc.file, it.line, `${glow} on ${where}`); + if (f.colours.length || f.font) { + const what = [f.colours.length ? `colour literal ${f.colours[0].tok}` : null, f.font ? `font-family literal ${f.font}` : null].filter(Boolean).join(' and '); + const certain = !!f.font || f.colours.some((c) => !c.oklch); + r.report('literal-outside-tokens', doc.file, it.line, `${what} in a class on ${where}`, { fix: 'lift the value into the token block (@theme or :root) and use its utility name', suspected: !certain }); + } + if ((f.hScale || f.hRotate) && /^(?:img|picture|image)$/i.test(it.tag)) r.report('hover-tells', doc.file, it.line, `${f.hScale || f.hRotate} on ${where}: the image moves on hover`, { fix: 'imagery sits still; give the container the feedback' }); + const caps = twTrackedCaps(it, twGroup(ctx)); + if (caps && !had('wide-tracking')) { + const text = it.virtual ? '' : twText(it); + r.report('wide-tracking', doc.file, it.line, `${text ? `"${trunc(text, 24)}" ` : ''}${caps.what}${caps.size != null ? ` at ${caps.size}px` : ''}${caps.em != null ? ` tracked ${caps.em}em` : ''} on ${where}`, { fix: 'sentence case in the body face, a step smaller, untracked: drop uppercase and the tracking-* class' }); + } + const eyebrow = twEyebrow(it); + if (eyebrow && !had('kicker-above-heading')) { + r.report('kicker-above-heading', doc.file, it.line, `eyebrow ${eyebrow.text ? `"${trunc(eyebrow.text, 24)}" ` : ''}before <${eyebrow.next}> (${eyebrow.signal} at ${it.frame.size}px)`); + } + if (!monoOk && f.mono && !it.virtual && !it.frame.code && !/^(?:code|pre|kbd|samp|time|var)$/.test(it.tag) && it.frame.size != null && it.frame.size <= 14) { + const text = twText(it); + if (/[A-Za-z]/.test(text) && !twDataText(text)) r.report('monospace-costume', doc.file, it.line, `font-mono on "${trunc(text, 24)}" at ${it.frame.size}px (${where})`, { fix: 'labels in the body face at a smaller size and normal case; font-mono only for code and figures' }); + } +} + +/** The tells a file carries as a whole: a default palette, hover scale everywhere, a vh hero, three icon tiles. */ +function reportFile(ctx: LintContext, r: Reporter, list: TwItem[]): void { + const doc = list[0].doc; + const pal = new Map(); + let palAt: TwItem | null = null; + for (const it of list) for (const p of it.info.pal) if (!pal.has(p)) { pal.set(p, it); if (pal.size === 5) palAt = it; } + if (palAt) r.report('framework-palette', doc.file, palAt.line, `a framework default palette instead of the page's tokens: ${pal.size} distinct palette classes (${[...pal.keys()].slice(0, 4).join(' ')})`, { fix: "extend the Tailwind theme with the page's tokens and use those names" }); + const scaled = list.filter((it) => it.info.hScale); + if (scaled.length >= 3) r.report('hover-tells', doc.file, scaled[2].line, `hover scale on ${scaled.length} elements in this file (${scaled.slice(0, 3).map((it) => it.info.hScale).join(', ')})`, { fix: 'one hover signal per element; not every card grows' }); + const first = new Set(['section', 'header'].map((n) => doc.tags.findIndex((t) => !t.close && t.name === n)).filter((x) => x >= 0)); + const byIdx = new Map(); + for (const it of list) if (it.idx >= 0) byIdx.set(it.idx, it); + for (const it of list) { + if (it.info.vh && !it.virtual && (first.has(it.idx) || it.info.hero)) r.report('vh-height', doc.file, it.line, `hero sized in vh: ${it.info.vh} on ${twWhere(it)}`, { fix: 'min-h-dvh or min-h-svh' }); + if (it.info.cols3 && it.idx >= 0) { + const n = twTiles(it, byIdx); + if (n >= 3) r.report('template-cards', doc.file, it.line, `icon-tile row: ${n} children of ${twWhere(it)} open on an icon in a rounded filled tile above an h3`, { fix: 'vary the column widths or drop the icon tiles' }); + } + } +} + +// sloplint checkTailwind (gates 10a 12 2a 2b 29 48 11 67 54 64 74 6a 3) +function checkTailwind(ctx: LintContext, r: Reporter): void { + const tw = twGroup(ctx); + if (!tw.items.length) return; + const monoOk = twMonoIsMaterial(twTokens(ctx)); + const byDoc = new Map(); + for (const it of tw.items) { + if (!byDoc.has(it.file)) byDoc.set(it.file, []); + byDoc.get(it.file)!.push(it); + reportItem(ctx, r, it, monoOk); + } + for (const list of byDoc.values()) reportFile(ctx, r, list); +} + +// sloplint checkComponentText (gates 63 30a) +function checkComponentText(ctx: LintContext, r: Reporter): void { + for (const doc of ctx.docs) { + for (let c = 0; c < doc.comps.length; c++) { + const t = doc.comps[c]; + if (t.close || t.selfClosing) continue; + const inner = compInner(doc, c); + if (!inner || /<(?:p|div|li|section|article|ul|ol|table)\b/i.test(inner)) continue; + const text = twPlain(doc, inner); + if (!text) continue; + if (/(?:link|button|btn|cta)$/i.test(t.name) && /(→|->|›|»|⟶)\s*$/.test(text)) r.report('template-punctuation', doc.file, t.line, `arrow glued to the label "${trunc(text, 40)}" (<${t.name}>)`, { fix: 'a verb as the label; the link already says it goes somewhere' }); + if ((text.match(/\s[·•]\s/g) || []).length >= 2) r.report('template-punctuation', doc.file, t.line, `middle-dot meta string "${trunc(text, 50)}"`, { fix: 'line breaks, columns or hairlines; one dot in a byline at most' }); + if (text.length <= 4 && EMOJI_ICON.test(text)) r.report('emoji-decoration', doc.file, t.line, `emoji icon "${text}" in <${t.name}>`, { fix: 'one icon library or custom SVG' }); + } + } +} export function run(ctx: LintContext, r: Reporter): void { - void ctx; void r; + checkTailwind(ctx, r); + checkComponentText(ctx, r); } diff --git a/lib/design-lint/tailwind.ts b/lib/design-lint/tailwind.ts index 57d0ea65b..76af0d62e 100644 --- a/lib/design-lint/tailwind.ts +++ b/lib/design-lint/tailwind.ts @@ -2,22 +2,162 @@ * Tailwind reading for the design lint: utility class strings in HTML and * component files (class, className, :class, class:list and the cn/clsx/cva * helper calls), parsed into the facts the rules ask about (background and - * text colours, gradients, sizes, tracking, case, families, variants). + * text colours, gradients, sizes, tracking, case, families, variants), plus + * the tells the component rules read off them (gradient, glow, tracked caps, + * eyebrow, mono costume, icon tiles). * - * Ported from Nutlope/hallmark's sloplint.mjs (twParse, twInfo, twGroup and - * kin; MIT, see NOTICE.md). + * Ported from Nutlope/hallmark's sloplint.mjs (twParse, twInfo, twGroup, + * twGradient, twGlow, tw67, tw54, twTiles and kin; MIT, see NOTICE.md). */ import type { LintContext } from './context'; import { memo } from './context'; +import { COLOR_LITERAL, hueIn, oklchOf, parseColor, type Color } from './color'; +import { GENERIC_FAMILIES, firstFamily, pxOf, resolveVars, splitTop, type Tokens } from './css'; +import { braceEnd, closeIndex, clsLits, directChildren, getAttr, innerOf, plainText, type Doc, type Tag } from './html'; +import { trunc } from './report'; -export interface TwStop { kind: 'token' | 'palette' | 'literal' | 'other'; hue: number | null; value: string } +export const TW_SIZE: Record = { xs: 12, sm: 14, base: 16, lg: 18, xl: 20, '2xl': 24, '3xl': 30, '4xl': 36, '5xl': 48, '6xl': 60, '7xl': 72, '8xl': 96, '9xl': 128 }; +const TW_TRACK: Record = { tighter: -0.05, tight: -0.025, normal: 0, wide: 0.025, wider: 0.05, widest: 0.1 }; +const TW_HUE: Record = { red: 25, orange: 47, amber: 70, yellow: 86, lime: 131, green: 150, emerald: 163, teal: 183, cyan: 215, sky: 237, blue: 260, indigo: 277, violet: 293, purple: 304, fuchsia: 322, pink: 354, rose: 16 }; +const TW_COLOURS = 'slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose'; +export const TW_PAL = new RegExp(`^(?:bg|text|border(?:-[xytrblse])?|ring(?:-offset)?|outline|decoration|divide|from|via|to|fill|stroke|shadow|accent|caret|placeholder|inset-shadow|inset-ring|drop-shadow)-(?:${TW_COLOURS})-(?:50|[1-9]00|950)(?:\\/(?:\\d{1,3}|\\[[^\\]]*\\]))?$`); +const TW_SHADE = new RegExp(`^(${TW_COLOURS})-(?:50|[1-9]00|950)(?:\\/\\S*)?$`); +const TW_ACCENT = /^text-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:500|600|700)(?:\/\S*)?$/; +const TW_PURPLE = /^(?:purple|violet|fuchsia|indigo)$/; +const TW_BLUE = /^(?:blue|sky|cyan|indigo)$/; +const TW_PINK = /^(?:pink|rose)$/; +const TW_GREY = /^(?:slate|gray|zinc|neutral|stone)$/; +const TW_NONFILL = /^bg-(?:none|transparent|inherit|current|fixed|local|scroll|cover|contain|auto|center|top|bottom|left|right|no-repeat|repeat(?:-[\w-]+)?|(?:left|right|top|bottom)-(?:top|bottom|left|right)|clip-[\w-]+|origin-[\w-]+|blend-[\w-]+|gradient-to-[\w-]+|(?:linear|radial|conic)(?:-.*)?|size-.*|position-.*|\[(?:url|length|position|size|image|(?:repeating-)?(?:linear|radial|conic)-gradient)[^\]]*\].*)$/; +const TW_GLASS = /^bg-(?:white|black|(?:slate|gray|zinc|neutral|stone)-(?:50|100|900|950)|paper|ink|background|foreground)\/(\d{1,2}|\[(?:0?\.\d+|\d{1,2}%)\])$/; +const TW_MONO = /mono|courier|jetbrains|plex mono|geist mono|commit|fira code|space mono|dm mono|roboto mono|source code|silkscreen/i; +const TW_LOGO = /logo|brand|wordmark/i; +const TW_COLOR_LIT = /#[0-9a-fA-F]{3,8}\b|oklch\([^)]*\)|rgba?\([^)]*\)|hsla?\([^)]*\)/g; +const MONO_TOKEN = /mono|code|courier|silkscreen|jetbrains|plex mono|geist mono|commit|fira code|space mono|dm mono|roboto mono|source code|ibm plex mono|press start|vt323|share tech/i; +const EYEBROW_ANCESTOR_SKIP = /^(form|fieldset|table|thead|tbody|nav|menu|dl|select|figcaption|label|button)$/; +const EYEBROW_BLOCKY = /<(p|ul|ol|hr|h[1-6]|img|table|figure|section|article|blockquote|form|dl)\b/i; +const EYEBROW_ANNOUNCE = /^(features?|benefits?|introducing|new|how it works?|the (process|tour)|our (process|story|work|team|approach|values?)|why (us|choose|we)|pricing|plans?|faq|questions?|testimonials?|reviews?|trusted by|as seen in|what we do|about( us)?|services?|solutions?|use cases?|get started|contact|team|work|projects?|gallery|blog|news|resources?|examples?|overview)$/i; + +export interface TwParsed { variants: string[]; base: string; neg: boolean } + +const TW_PARSED = new Map(); + +/** One utility split into its variants (group-/peer- prefixes and /names dropped), its base and a leading minus. */ +export function twParse(tok: string): TwParsed { + const cached = TW_PARSED.get(tok); + if (cached) return cached; + const variants: string[] = []; + let depth = 0, cur = ''; + for (const ch of tok) { + if (ch === '[' || ch === '(') depth++; + else if ((ch === ']' || ch === ')') && depth > 0) depth--; + if (ch === ':' && depth === 0) { variants.push(cur); cur = ''; } else cur += ch; + } + let base = cur; + if (base.startsWith('!')) base = base.slice(1); + if (base.endsWith('!')) base = base.slice(0, -1); + const neg = base.length > 1 && base.startsWith('-'); + if (neg) base = base.slice(1); + const p = { variants: variants.map((v) => v.replace(/^(?:group|peer)-/, '').replace(/\/[\w-]+$/, '')), base, neg }; + TW_PARSED.set(tok, p); + return p; +} + +/** The tokens utilities resolve against: the project's shared tokens for a component directory, else the context's own. */ +export const twTokens = (ctx: LintContext): Tokens => ctx.twTokens ?? ctx.tokens; + +const twValue = (tok: Tokens, v: string): string => resolveVars(v, tok).trim().replace(/^(-?)\./, '$10.'); + +/** The px size a text-* utility sets: the scale, an arbitrary value, or a --text-* token. */ +export function twSize(ctx: LintContext, b: string): number | null { + const tok = twTokens(ctx); + let m = /^text-(xs|sm|base|lg|[2-9]?xl)(?:\/\S+)?$/.exec(b); + if (m) return TW_SIZE[m[1]]; + m = /^text-(?:\[(?:length:)?([^\]]+)\]|\((?:length:)?(--[\w-]+)\))(?:\/\S+)?$/.exec(b); + let v: string | null = m ? (m[1] ? m[1].replace(/_/g, ' ') : `var(${m[2]})`) : null; + if (!m) { + m = /^text-([a-z][\w-]*?)(?:\/\S+)?$/.exec(b); + v = m ? tok[`--text-${m[1]}`] ?? null : null; + } + if (!v) return null; + const r = twValue(tok, v); + const cl = /^clamp\(\s*([^,]+),/.exec(r); + return pxOf(cl ? twValue(tok, cl[1]) : r); +} + +export interface TwTrack { em: number | null; px: number | null } + +/** The tracking a tracking-* utility sets, in em when it says em, else in px. */ +export function twTrack(ctx: LintContext, b: string, neg: boolean): TwTrack | null { + const tok = twTokens(ctx); + let m = /^tracking-(tighter|tight|normal|wide|wider|widest)$/.exec(b); + let v: string | null = null; + if (m) v = `${TW_TRACK[m[1]]}em`; + else if ((m = /^tracking-(?:\[([^\]]+)\]|\((--[\w-]+)\))$/.exec(b))) v = m[1] ? m[1].replace(/_/g, ' ') : `var(${m[2]})`; + else if ((m = /^tracking-([a-z][\w-]*)$/.exec(b)) && tok[`--tracking-${m[1]}`]) v = tok[`--tracking-${m[1]}`]; + if (v == null) return null; + const r = twValue(tok, v); + const e = /^(-?\d*\.?\d+)em$/.exec(r); + const px = e ? null : pxOf(r); + if (e) { const em = parseFloat(e[1]); return { em: neg ? -em : em, px: null }; } + if (px == null) return null; + return { em: null, px: neg ? -px : px }; +} + +/** The OKLCH hue of a colour that has one: opaque enough and chroma past 0.04. */ +export const twHue = (c: Color | null): number | null => { + if (!c || c.alpha <= 0.05) return null; + const ok = oklchOf(c); + return ok.C > 0.04 ? ok.H : null; +}; + +export interface TwStop { + kind: 'token' | 'palette' | 'literal' | 'other'; + hue: number | null; + /** The stop's colour name or value as written after from-/via-/to-. */ + value: string; + /** The whole utility. */ + tok: string; + /** The palette family (violet, sky …) for a palette stop. */ + pal: string | null; +} + +/** One gradient stop (from-x, via-x, to-x) as a kind and a hue; a percentage position is no stop. */ +export function twStop(ctx: LintContext, v: string, tok: string): TwStop | null { + if (/^\[?-?\d*\.?\d+%\]?$/.test(v)) return null; + const tokens = twTokens(ctx); + const pm = TW_SHADE.exec(v); + if (pm) return { tok, value: v, kind: 'palette', pal: pm[1], hue: TW_GREY.test(pm[1]) ? null : TW_HUE[pm[1]] }; + if (/^(?:white|black|transparent|current|inherit)(?:\/\S*)?$/.test(v)) return { tok, value: v, kind: 'other', pal: null, hue: null }; + if (v.startsWith('(')) return { tok, value: v, kind: 'token', pal: null, hue: twHue(parseColor(twValue(tokens, `var${v.replace(/\/\S*$/, '').replace(/^\((?:color:)?/, '(')}`))) }; + if (v.startsWith('[')) { + const inner = v.slice(1, v.lastIndexOf(']')).replace(/_/g, ' '); + return { tok, value: v, kind: /var\(/.test(inner) ? 'token' : 'literal', pal: null, hue: twHue(parseColor(twValue(tokens, inner))) }; + } + const tv = tokens[`--color-${v.replace(/\/\S*$/, '')}`]; + return { tok, value: v, kind: 'token', pal: null, hue: tv ? twHue(parseColor(twValue(tokens, tv))) : null }; +} + +/** The named family inside font-[…], or null for a token, a weight or a generic family. */ +export function twFamily(v: string): string | null { + if (/var\(/.test(v)) return null; + const named = v.replace(/^family-name:/, ''); + if (named === v && !/^['"]/.test(v) && /^(?:\d|weight:|number:)/.test(v)) return null; + const fam = firstFamily(named.replace(/_/g, ' ')); + return !fam || GENERIC_FAMILIES.has(fam) ? null : fam; +} + +export interface TwColourLiteral { tok: string; oklch: boolean } +export interface TwShadow { v: string; tok: string } /** What one element's class string says. */ export interface TwInfo { classes: string[]; + /** The first bg-* fill utility. */ bg: string | null; + /** The first text-* colour utility. */ text: string | null; stops: TwStop[]; + /** Text clipped to a background and made transparent. */ gradientText: boolean; sizePx: number | null; trackingEm: number | null; @@ -25,25 +165,519 @@ export interface TwInfo { smallCaps: boolean; mono: boolean; variants: Set; + normalCase: boolean; + /** The first saturated palette text colour (text-indigo-600 and kin). */ + accent: string | null; + /** transition-all, or a transition naming a layout property. */ + tAll: string | null; + bounce: string | null; + overshoot: string | null; + hScale: string | null; + hRotate: string | null; + grad: boolean; + clip: boolean; + clear: boolean; + bblur: string | null; + glass: string | null; + blur: number; + blurTok: string | null; + pos: string | null; + fill: boolean; + shadows: TwShadow[]; + shadowCol: string | null; + vh: string | null; + oxHidden: string | null; + oxClip: boolean; + colours: TwColourLiteral[]; + font: string | null; + pal: string[]; + rounded: boolean; + cols3: boolean; + animate: string | null; + transform: boolean; + transition: boolean; + caret: boolean; + scrollbar: boolean; + hero: boolean; + padded: boolean; +} + +function twLiterals(b: string, tok: string, f: TwInfo): void { + for (const m of b.matchAll(/\[([^\]]+)\]/g)) { + const v = m[1].replace(/url\([^)]*\)/gi, ' ').replace(/_/g, ' '); + const fam = /^font-family:(.+)$/.exec(v); + if (fam && !/var\(/.test(fam[1]) && !GENERIC_FAMILIES.has(firstFamily(fam[1]))) f.font = f.font || tok; + for (const c of v.matchAll(TW_COLOR_LIT)) { + const col = parseColor(c[0]); + if (col && col.alpha > 0) f.colours.push({ tok, oklch: /^oklch/i.test(c[0]) }); + } + } +} + +interface TwAcc { sizeV: number | null; tracks: Array<{ r: TwTrack; plain: boolean }> } + +/** Case, size, tracking and family utilities; true when the token was one of them. */ +function twReadType(ctx: LintContext, b: string, tok: string, p: TwParsed, f: TwInfo, acc: TwAcc): boolean { + const plain = !p.variants.length; + if (b === 'uppercase') { f.upper = true; return true; } + if (b === 'normal-case') { if (plain) f.normalCase = true; return true; } + if (b === 'small-caps' || /^\[font-variant(?:-caps)?:[^\]]*(?:small|petite)-caps/.test(b) || /^\[font-feature-settings:[^\]]*(?:smcp|c2sc)/.test(b)) { f.smallCaps = true; return true; } + if (b.startsWith('text-')) { + const px = twSize(ctx, b); + if (px != null) { + if (plain) f.sizePx = px; else acc.sizeV = acc.sizeV == null ? px : Math.min(acc.sizeV, px); + return true; + } + if (b === 'text-transparent') { f.clear = true; return true; } + if (!/^text-(?:left|center|right|justify|start|end|wrap|nowrap|balance|pretty|ellipsis|clip)$/.test(b)) f.text = f.text || tok; + if (TW_ACCENT.test(b)) f.accent = f.accent || tok; + return true; + } + if (b.startsWith('tracking-')) { + const r = twTrack(ctx, b, p.neg); + if (r) acc.tracks.push({ r, plain }); + return true; + } + if (b === 'font-mono') { f.mono = true; return true; } + if (b.startsWith('font-')) { + const arb = /^font-\[(.+)\]$/.exec(b); + const fam = arb ? twFamily(arb[1]) : null; + if (fam) { f.font = f.font || tok; if (TW_MONO.test(fam)) f.mono = true; } + const named = !arb && /^font-([a-z][\w-]*)$/.exec(b); + const tv = named ? twTokens(ctx)[`--font-${named[1]}`] : undefined; + if (tv && /mono|courier|code/i.test(firstFamily(resolveVars(tv, twTokens(ctx))))) f.mono = true; + return true; + } + return false; +} + +/** Motion, gradient, surface, shadow, size and layout utilities. */ +function twReadSurface(ctx: LintContext, b: string, tok: string, p: TwParsed, f: TwInfo): void { + const hover = p.variants.includes('hover'); + if (b === 'transition-all' || /^transition-\[[^\]]*\b(?:all|width|height|top|left|right|bottom|inset)\b/.test(b) || /^\[transition(?:-property)?:[^\]]*\b(?:all|width|height|top|left)\b/.test(b)) { + f.tAll = f.tAll || tok; f.transition = true; + return; + } + if (/^transition(?:-transform)?$/.test(b)) { f.transition = true; if (b !== 'transition') f.transform = true; return; } + if (b.startsWith('animate-')) { + if (b !== 'animate-none') f.animate = f.animate || tok; + if (b === 'animate-bounce') f.bounce = f.bounce || tok; + return; + } + const ez = /^ease-\[cubic-bezier\(([^)]*)\)\]$/.exec(b); + if (ez) { + const n = ez[1].split(/[,_\s]+/).filter(Boolean).map(Number); + if (n.length === 4 && [n[1], n[3]].some((y) => y > 1.05 || y < -0.05)) f.overshoot = f.overshoot || tok; + return; + } + if (/^(?:scale|translate|rotate|skew)-/.test(b)) { + f.transform = true; + if (hover && /^scale-/.test(b) && !/^scale-(?:[xy]-)?100$/.test(b)) f.hScale = f.hScale || tok; + if (hover && /^rotate-/.test(b) && b !== 'rotate-0') f.hRotate = f.hRotate || tok; + return; + } + if (/^bg-(?:gradient-to-|linear-|radial|conic)|^bg-\[(?:repeating-)?(?:linear|radial|conic)-gradient/.test(b) || /^\[background(?:-image)?:[^\]]*gradient\(/.test(b)) f.grad = true; + const st = /^(?:from|via|to)-(.+)$/.exec(b); + if (st) { const s = twStop(ctx, st[1], tok); if (s) f.stops.push(s); return; } + if (b === 'bg-clip-text' || /^\[(?:-webkit-)?background-clip:text\]$/.test(b)) { f.clip = true; return; } + if (b === '[-webkit-text-fill-color:transparent]') { f.clear = true; return; } + if (/^backdrop-blur(?:-|$)/.test(b) || /^\[(?:-webkit-)?backdrop-filter:[^\]]*blur/.test(b)) { f.bblur = f.bblur || tok; return; } + const bl = /^blur-(?:(2xl|3xl)|\[(\d*\.?\d+)(px|rem)\])$/.exec(b); + if (bl) { + const px = bl[1] ? (bl[1] === '2xl' ? 40 : 64) : parseFloat(bl[2]) * (bl[3] === 'rem' ? 16 : 1); + if (px > f.blur) { f.blur = px; f.blurTok = tok; } + return; + } + if (b === 'absolute' || b === 'fixed') { f.pos = f.pos || b; return; } + if (b.startsWith('bg-')) { + const gm = TW_GLASS.exec(b); + if (gm) { + const v = gm[1].replace(/^\[|\]$/g, ''); + const a = v.endsWith('%') ? parseFloat(v) / 100 : v.includes('.') ? parseFloat(v) : parseFloat(v) / 100; + if (a < 0.95) f.glass = f.glass || tok; + } + if (!TW_NONFILL.test(b)) { f.fill = true; f.bg = f.bg || tok; } + return; + } + if (b.startsWith('shadow-')) { + const sm = /^shadow-\[(.+)\]$/.exec(b); + if (sm) f.shadows.push({ v: sm[1], tok }); + else if (!/^shadow-(?:2xs|xs|sm|md|lg|xl|2xl|inner|none)$/.test(b)) f.shadowCol = f.shadowCol || b.slice(7); + return; + } + if (/^(?:min-)?h-(?:screen|\[[^\]]*\d\s*vh[^\]]*\])$/.test(b)) { f.vh = f.vh || tok; return; } + if (b === 'overflow-x-hidden') { f.oxHidden = f.oxHidden || tok; return; } + if (b === 'overflow-x-clip' || b === 'overflow-clip') { f.oxClip = true; return; } + if (/^rounded(?:-|$)/.test(b)) { if (b !== 'rounded-none') f.rounded = true; return; } + if (b === 'grid-cols-3') { f.cols3 = true; return; } + if (/^p[xytrblse]?-/.test(b)) { f.padded = true; return; } + if (b.startsWith('caret-')) { f.caret = true; return; } + if (b.startsWith('scrollbar') || b.startsWith('[scrollbar-')) f.scrollbar = true; +} + +/** Read one class string into its facts; the variants it uses land in the shared set as well as on the info. */ +export function twInfo(ctx: LintContext, toks: string[], variants: Set): TwInfo { + const f: TwInfo = { + classes: toks, bg: null, text: null, stops: [], gradientText: false, sizePx: null, trackingEm: null, upper: false, smallCaps: false, mono: false, variants: new Set(), + normalCase: false, accent: null, tAll: null, bounce: null, overshoot: null, hScale: null, hRotate: null, grad: false, clip: false, clear: false, + bblur: null, glass: null, blur: 0, blurTok: null, pos: null, fill: false, shadows: [], shadowCol: null, vh: null, oxHidden: null, oxClip: false, + colours: [], font: null, pal: [], rounded: false, cols3: false, animate: null, transform: false, transition: false, caret: false, scrollbar: false, hero: false, padded: false, + }; + const acc: TwAcc = { sizeV: null, tracks: [] }; + for (const tok of toks) { + if (tok.includes('__')) continue; + const p = twParse(tok); + const b = p.base; + if (!b) continue; + for (const v of p.variants) { variants.add(v); f.variants.add(v); } + if (TW_PAL.test(b)) f.pal.push(b.replace(/\/.*$/, '')); + if (b.includes('[')) twLiterals(b, tok, f); + if (/hero/i.test(b)) f.hero = true; + if (!twReadType(ctx, b, tok, p, f, acc)) twReadSurface(ctx, b, tok, p, f); + } + if (f.sizePx == null) f.sizePx = acc.sizeV; + let emV: number | null = null; + for (const { r, plain } of acc.tracks) { + const em = r.em != null ? r.em : (r.px ?? 0) / (f.sizePx || 16); + if (plain) f.trackingEm = em; else emV = emV == null ? em : Math.max(emV, em); + } + if (f.trackingEm == null) f.trackingEm = emV; + f.gradientText = f.clip && f.clear; + return f; +} + +const TW_CLASS_ATTR = /(?:^|\s)(class|className|:class|v-bind:class|x-bind:class|class:list)\s*=\s*/g; + +const cutGlued = (seg: string, after: boolean, before: boolean): string => { + let s = seg; + if (after) s = s.replace(/^\S+/, ''); + if (before) s = s.replace(/\S+$/, ''); + return s; +}; + +/** Class tokens of a Svelte/Astro attribute value with {expressions} inside it, glued halves dropped. */ +function twInterp(val: string): string[] { + const parts: string[] = []; + let i = 0, seg = '', after = false; + while (i < val.length) { + if (val[i] === '{') { + parts.push(cutGlued(seg, after, true)); + const e = braceEnd(val, i); + parts.push(clsLits(val.slice(i + 1, e)).join(' ')); + i = e + 1; seg = ''; after = true; + continue; + } + seg += val[i++]; + } + parts.push(cutGlued(seg, after, false)); + return parts.join(' ').split(/\s+/).filter(Boolean); +} + +/** Every class token on a tag: the JSX scanner's record, or the class-bearing attributes of the raw tag text. */ +export function twTokensOf(doc: Doc, t: Tag): string[] { + if (doc.kind === 'jsx') return doc.classAt.get(t.pos) || []; + const tag = doc.raw.slice(t.pos, t.end); + if (!tag.includes('class')) return []; + const out: string[] = []; + for (const m of tag.matchAll(TW_CLASS_ATTR)) { + const i = (m.index as number) + m[0].length; + const q = tag[i]; + let val: string; + let dyn = m[1] !== 'class' && m[1] !== 'className'; + if (q === '"' || q === "'") { const e = tag.indexOf(q, i + 1); val = tag.slice(i + 1, e < 0 ? tag.length : e); } + else if (q === '{') { val = tag.slice(i + 1, braceEnd(tag, i)); dyn = true; } + else val = (/^[^\s>]*/.exec(tag.slice(i)) || [''])[0]; + if (dyn) out.push(...clsLits(val)); + else if (val.includes('{')) out.push(...twInterp(val)); + else out.push(...val.split(/\s+/)); + } + for (const d of tag.matchAll(/(?:^|\s)class:(?!list\b)([\w-]+)/g)) out.push(d[1]); + return out.filter(Boolean); +} + +/** What an element inherits from its ancestors: size, tracking, case, and the contexts that exempt it. */ +export interface TwFrame { + size: number | null; + em: number | null; + upper: boolean; + caps: boolean; + noLabel: boolean; + code: boolean; + eyeSkip: boolean; + inOl: boolean; + logo: boolean; + logoSelf: boolean; +} + +const TW_ROOT: TwFrame = { size: null, em: null, upper: false, caps: false, noLabel: false, code: false, eyeSkip: false, inOl: false, logo: false, logoSelf: false }; + +function twFrame(parent: TwFrame, t: { name: string; attrs: string }, f: TwInfo | null, toks: string[]): TwFrame { + const logo = toks.some((x) => TW_LOGO.test(x)) || TW_LOGO.test(getAttr(t.attrs, 'aria-label') || ''); + return { + size: f && f.sizePx != null ? f.sizePx : parent.size, + em: f && f.trackingEm != null ? f.trackingEm : parent.em, + upper: f ? f.upper || (!f.normalCase && parent.upper) : parent.upper, + caps: (f != null && f.smallCaps) || parent.caps, + noLabel: parent.noLabel || /^(?:svg|pre|code|abbr|kbd|samp)$/.test(t.name), + code: parent.code || /^(?:pre|code|kbd|samp)$/.test(t.name), + eyeSkip: parent.eyeSkip || EYEBROW_ANCESTOR_SKIP.test(t.name), + inOl: parent.inOl || t.name === 'ol', + logo: parent.logo || (t.name === 'a' && logo), + logoSelf: logo, + }; } export interface TwItem { file: string; line: number; - /** Tag index in the doc, or -1 for a helper-call site. */ + /** Tag index in the doc, or -1 for a component tag or a helper-call site. */ idx: number; + /** The tag name, the component name, or the helper's name for a call site. */ tag: string; info: TwInfo; + doc: Doc; + /** Index into doc.comps for a component tag, else null. */ + comp: number | null; + /** A cn()/clsx()/cva() call site rather than an element. */ + virtual: boolean; + attrs: string; + toks: string[]; + frame: TwFrame; + text: string | null; } -export interface TwGroup { items: TwItem[]; variants: Set } +export interface TwGroup { + items: TwItem[]; + variants: Set; + caret: boolean; + scrollbar: boolean; + /** The first element that animates or transitions a transform. */ + motion: TwItem | null; + /** An h1 or h2 set uppercase, which excuses uppercase controls. */ + upperDisplay: boolean; +} -/** Every class-bearing element and helper call in the context, parsed. */ +/** Every class-bearing element, component tag and helper call in the context, parsed. */ export function twGroup(ctx: LintContext): TwGroup { - return memo(ctx, 'tw', () => ({ items: [], variants: new Set() })); + return memo(ctx, 'tw', () => { + const tw: TwGroup = { items: [], variants: new Set(), caret: false, scrollbar: false, motion: null, upperDisplay: false }; + const add = (it: TwItem) => { + const f = it.info; + tw.items.push(it); + if (f.caret) tw.caret = true; + if (f.scrollbar) tw.scrollbar = true; + if (!tw.motion && (f.animate || (f.transition && f.transform))) tw.motion = it; + if (/^h[12]$/.test(it.tag) && f.upper) tw.upperDisplay = true; + }; + for (const doc of ctx.docs) { + const stack: TwFrame[] = []; + for (let i = 0; i < doc.tags.length; i++) { + const t = doc.tags[i]; + if (t.close) { stack.pop(); continue; } + const parent = stack.length ? stack[stack.length - 1] : TW_ROOT; + const toks = twTokensOf(doc, t); + const info = toks.length ? twInfo(ctx, toks, tw.variants) : null; + const frame = twFrame(parent, t, info, toks); + if (info) add({ file: doc.file, line: t.line, idx: i, tag: t.name, info, doc, comp: null, virtual: false, attrs: t.attrs, toks, frame, text: null }); + if (!t.selfClosing) stack.push(frame); + } + for (let c = 0; c < doc.comps.length; c++) { + const t = doc.comps[c]; + if (t.close) continue; + const toks = twTokensOf(doc, t); + if (!toks.length) continue; + const info = twInfo(ctx, toks, tw.variants); + add({ file: doc.file, line: t.line, idx: -1, tag: t.name, info, doc, comp: c, virtual: false, attrs: t.attrs, toks, frame: twFrame(TW_ROOT, t, info, toks), text: null }); + } + for (const v of doc.virtuals) { + const info = twInfo(ctx, v.toks, tw.variants); + add({ file: doc.file, line: doc.lineOf(v.pos), idx: -1, tag: v.name, info, doc, comp: null, virtual: true, attrs: '', toks: v.toks, frame: twFrame(TW_ROOT, { name: v.name, attrs: '' }, info, v.toks), text: null }); + } + } + return tw; + }); } /** The Tailwind variants (hover, focus-visible, motion-reduce …) used anywhere in the context. */ export function twVariants(ctx: LintContext): Set { return twGroup(ctx).variants; } + +/** Where a finding points: the helper call, or the element with the start of its class string. */ +export function twWhere(it: TwItem): string { + return it.virtual ? `${it.tag}() class string` : `<${it.tag} class="${trunc(it.toks.join(' '), 32)}">`; +} + +/** The source between a component tag and its matching close. */ +export function compInner(doc: Doc, c: number): string { + const open = doc.comps[c]; + if (open.selfClosing) return ''; + let depth = 1; + for (let i = c + 1; i < doc.comps.length; i++) { + const t = doc.comps[i]; + if (t.name !== open.name || t.selfClosing) continue; + if (!t.close) depth++; + else if (--depth === 0) return doc.src.slice(open.end, t.pos); + } + return ''; +} + +/** Visible text of a fragment, with template expressions blanked in Vue, Svelte and Astro files. */ +export function twPlain(doc: Doc, html: string): string { + let s = html; + if (doc.kind !== 'html' && doc.kind !== 'jsx') for (let k = 0; k < 3 && /\{[^{}]*\}/.test(s); k++) s = s.replace(/\{[^{}]*\}/g, ' '); + return plainText(s); +} + +/** The element's visible text, read once. */ +export function twText(it: TwItem): string { + if (it.text == null) { + let inner = ''; + if (it.idx >= 0) inner = innerOf(it.doc, it.idx)?.inner || ''; + else if (it.comp != null) inner = compInner(it.doc, it.comp); + it.text = twPlain(it.doc, inner); + } + return it.text; +} + +const paperIsDark = (tokens: Tokens): boolean => { + const p = parseColor(resolveVars(tokens['--color-paper'] || '', tokens)); + return !!p && oklchOf(p).L < 0.4; +}; + +/** The banned ramp among a set of stop hues: purple to blue, purple to pink, or cyan on a dark paper. */ +export function twBannedHues(hues: number[], tokens: Tokens): string | null { + const purple = hues.some((h) => hueIn(h, 265, 330)); + const blue = hues.some((h) => hueIn(h, 200, 264)); + const pink = hues.some((h) => hueIn(h, 331, 25)); + const cyan = hues.some((h) => hueIn(h, 170, 230)); + if (purple && blue) return 'purple-to-blue'; + if (purple && pink) return 'purple-to-pink'; + if (cyan && paperIsDark(tokens)) return 'cyan-on-dark'; + return null; +} + +export interface TwGradient { banned: string | null; text: boolean; list: string } + +/** The gradient an element's stops describe: its banned pairing if any, whether it is text, and the stop classes. */ +export function twGradient(ctx: LintContext, f: TwInfo): TwGradient | null { + if (!f.stops.length || (!f.grad && f.stops.length < 2)) return null; + const names = f.stops.map((s) => s.pal).filter((p): p is string => p != null); + let banned: string | null = null; + for (const a of names) { + if (!TW_PURPLE.test(a)) continue; + if (names.some((x) => x !== a && TW_BLUE.test(x))) { banned = 'purple-to-blue'; break; } + if (names.some((x) => x !== a && TW_PINK.test(x))) { banned = 'purple-to-pink'; break; } + } + if (!banned) banned = twBannedHues(f.stops.map((s) => s.hue).filter((h): h is number => h != null), twTokens(ctx)); + return { banned, text: f.clip && f.clear, list: f.stops.map((s) => s.tok).join(' ') }; +} + +const lengthPx = (x: string): number | null => (x === '0' ? 0 : pxOf(x)); + +/** A zero-offset coloured shadow of 8px blur or more: a glow. */ +export function twGlow(ctx: LintContext, f: TwInfo): string | null { + const tokens = twTokens(ctx); + for (const s of f.shadows) { + for (const part of splitTop(resolveVars(s.v.replace(/_/g, ' '), tokens))) { + const cm = COLOR_LITERAL.exec(part); + const lens = (cm ? part.replace(cm[0], ' ') : part).replace(/\binset\b/, ' ').trim().split(/\s+/).filter(Boolean).map(lengthPx); + if (lens.length < 3 || lens[0] !== 0 || lens[1] !== 0 || lens[2] == null || lens[2] < 8) continue; + let chroma = 0; + if (cm) { + const c = parseColor(cm[0]); + if (c && c.alpha > 0.05) chroma = oklchOf(c).C; + } else if (f.shadowCol) { + const name = f.shadowCol.replace(/\/\S*$/, ''); + const pm = TW_SHADE.exec(name); + const tv = tokens[`--color-${name}`]; + chroma = pm ? (TW_GREY.test(pm[1]) ? 0 : 0.2) : tv ? (twHue(parseColor(resolveVars(tv, tokens).trim())) != null ? 0.2 : 0) : 0; + } + if (chroma > 0.04) return `glow: ${s.tok}${cm ? '' : ` with shadow-${f.shadowCol}`}`; + } + } + return null; +} + +/** Whether a mono face is one of the page's declared families, so font-mono labels are material, not costume. */ +export function twMonoIsMaterial(tokens: Tokens): boolean { + const mono = (v: string | undefined) => !!v && MONO_TOKEN.test(firstFamily(resolveVars(v, tokens))); + return mono(tokens['--font-display']) || mono(tokens['--font-body']) || mono(tokens['--font-outlier']); +} + +/** Text that reads as data (a version, a shortcut, a command, a path, a time): mono is material there. */ +export function twDataText(s: string): boolean { + const t = s.trim(); + const letters = (t.match(/[A-Za-z]/g) || []).length; + const digits = (t.match(/\d/g) || []).length; + if (digits >= letters) return true; + if (/^v?\d+(?:\.\d+)+/.test(t) || /[⌘⌥⇧⌃⎋↵]|^(?:ctrl|cmd|alt|shift|option)\s*[+-]/i.test(t)) return true; + if (/[{}();=<>]|^\$\s|^(?:npm|npx|pnpm|yarn|bun|git|curl|cd|ls|brew|pip|docker|node) |^--?[a-z]|[\w-]+\.[a-z][a-z0-9]{0,3}$|^[#.]?[a-z]+(?:[-_][a-z0-9]+)+$|^[a-z]+[A-Z]\w*$|^\/[\w/.-]*$|^https?:/.test(t)) return true; + return digits > 0 && /^(?:\d{1,2}:\d{2}|(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\.?\s+\d)/i.test(t); +} + +export interface TwTrackedCaps { what: string; size: number | null; em: number | null } + +/** The tracked-caps label: small text set uppercase, small caps or letter-spaced past 0.03em, outside logos, code and display heads. */ +export function twTrackedCaps(it: TwItem, tw: TwGroup): TwTrackedCaps | null { + const f = it.info, fr = it.frame; + if (!(f.upper || f.smallCaps || (f.trackingEm != null && f.trackingEm > 0.03))) return null; + if (fr.logo || fr.logoSelf || fr.noLabel) return null; + const size = fr.size, em = fr.em; + if ((size != null && size >= 24) || (size == null && /^h[1-6]$/.test(it.tag))) return null; + const caps = fr.upper || fr.caps; + const small = size != null && size <= 15; + const tracked = em != null && em > 0.03; + const control = /^(?:button|input)$/.test(it.tag) || /(?:button|btn|cta)$/i.test(it.tag) || (it.tag === 'a' && f.fill && f.padded) || /role\s*=\s*["']?button/i.test(it.attrs); + if (tw.upperDisplay && control && (em == null || em <= 0.04)) return null; + if (!((caps && (small || tracked)) || (tracked && small))) return null; + return { what: fr.caps ? 'small caps' : fr.upper ? 'uppercase' : 'letter-spaced', size, em: tracked && em != null ? Math.round(em * 1000) / 1000 : null }; +} + +export interface TwEyebrow { text: string; next: string; signal: string } + +/** The eyebrow: a short, small, uppercase, tracked or accent-coloured line immediately before an h1 to h3. */ +export function twEyebrow(it: TwItem): TwEyebrow | null { + const f = it.info, fr = it.frame, doc = it.doc; + if (it.idx < 0 || !/^(?:p|span|div|small)$/.test(it.tag) || fr.eyeSkip) return null; + if (!(f.upper || (f.trackingEm != null && f.trackingEm > 0.03) || f.accent)) return null; + if (fr.size == null || fr.size > 14) return null; + const t = doc.tags[it.idx]; + if (/role\s*=\s*["']?status|aria-live|aria-current|aria-hidden\s*=\s*["']?true/i.test(t.attrs)) return null; + const c = closeIndex(doc, it.idx); + const nx = doc.tags[c + 1]; + if (!nx || nx.close || !/^h[1-3]$/.test(nx.name)) return null; + const inner = c > it.idx ? doc.src.slice(t.end, doc.tags[c].pos) : ''; + if (EYEBROW_BLOCKY.test(inner) || /]*\shref\s*=\s*["']([^"']*)["']/i.exec(inner); + if (href && !/^#?$/.test(href[1].trim())) return null; + const text = twPlain(doc, inner); + const signal = [f.upper ? 'uppercase' : null, f.trackingEm != null && f.trackingEm > 0.03 ? `tracked ${Math.round(f.trackingEm * 1000) / 1000}em` : null, f.accent].filter(Boolean).join(', '); + if (!text) return doc.kind !== 'html' && c > it.idx && doc.raw.slice(t.end, doc.tags[c].pos).includes('{') ? { text: '', next: nx.name, signal } : null; + const words = text.split(/[\s·/|\u2014\u2013]+/).filter(Boolean); + if (words.length > 7 || text.length > 56 || /[\w/-]+\.[a-z]{1,4}$/i.test(text)) return null; + const stripped = text.replace(/^\s*(?:no\.?\s*)?\d{1,3}(?:\s*[·./\u2014\u2013-]\s*|\s+|$)/i, '').trim(); + if ((stripped.match(/\d/g) || []).length >= 2) return null; + if (fr.inOl && stripped && !EYEBROW_ANNOUNCE.test(stripped)) return null; + return { text, next: nx.name, signal }; +} + +/** How many direct children of a grid open on an icon alone in a rounded filled tile above an h3. */ +export function twTiles(it: TwItem, byIdx: Map): number { + const doc = it.doc; + let tiles = 0; + for (const k of directChildren(doc, it.idx)) { + if (!/]*class/.test(inner.inner) && !twPlain(doc, inner.inner)) { tiles++; break; } + } + } + return tiles; +} diff --git a/test/design-lint-tailwind.test.ts b/test/design-lint-tailwind.test.ts new file mode 100644 index 000000000..a125b6aa1 --- /dev/null +++ b/test/design-lint-tailwind.test.ts @@ -0,0 +1,358 @@ +/** + * 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 { 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 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-')); + 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: () => {}, ...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: () => {} }); + 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.toks.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