mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-01 22:45:02 +01:00
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>
This commit is contained in:
1 parent
1ba2ae0425
commit
69ac84f2e4
3 files changed
+1098
-12
No files matched your search
@@ -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<string, TwItem>();
|
||||
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<number, TwItem>();
|
||||
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<string, TwItem[]>();
|
||||
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);
|
||||
}
|
||||
+642
-8
@@ -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<string, number> = { 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<string, number> = { tighter: -0.05, tight: -0.025, normal: 0, wide: 0.025, wider: 0.05, widest: 0.1 };
|
||||
const TW_HUE: Record<string, number> = { 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<string, TwParsed>();
|
||||
|
||||
/** 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<string>;
|
||||
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<string>): 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<string>(),
|
||||
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<string> }
|
||||
export interface TwGroup {
|
||||
items: TwItem[];
|
||||
variants: Set<string>;
|
||||
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<string>() }));
|
||||
return memo(ctx, 'tw', () => {
|
||||
const tw: TwGroup = { items: [], variants: new Set<string>(), 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<string> {
|
||||
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) || /<br\b|<time\b/i.test(inner)) return null;
|
||||
const href = /<a\b[^>]*\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, TwItem>): number {
|
||||
const doc = it.doc;
|
||||
let tiles = 0;
|
||||
for (const k of directChildren(doc, it.idx)) {
|
||||
if (!/<h3\b/i.test(k.html)) continue;
|
||||
const end = closeIndex(doc, k.idx);
|
||||
for (let j = k.idx + 1; j < end; j++) {
|
||||
const t = doc.tags[j];
|
||||
if (t.close) continue;
|
||||
if (/^h[1-6]$/.test(t.name)) break;
|
||||
const ti = byIdx.get(j);
|
||||
if (!ti || !ti.info.rounded || !ti.info.fill) continue;
|
||||
const inner = innerOf(doc, j);
|
||||
if (inner && /<svg\b|<[A-Z][\w.]*Icon\b|<Icon\b|<i\b[^>]*class/.test(inner.inner) && !twPlain(doc, inner.inner)) { tiles++; break; }
|
||||
}
|
||||
}
|
||||
return tiles;
|
||||
}
|
||||
@@ -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, string>): 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<string, string>, opts: { scope?: 'page' | 'component' } = {}): Promise<Finding[]> {
|
||||
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 => ['<!doctype html>', '<html lang="en">', `<head><meta charset="utf-8"><title>Fieldnote</title>${head}</head>`, '<body>', ...body, '</body></html>'].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 <a className={cn("px-3", on ? "hover:bg-accent focus-visible:outline-2" : "")}>Go</a>;',
|
||||
'}',
|
||||
].join('\n'),
|
||||
'Card.vue': '<template>\n <div :class="{ \'motion-reduce:transition-none\': true }" class="p-4"></div>\n</template>',
|
||||
'Band.astro': '---\nconst x = 1;\n---\n<div class:list={["bg-paper", { "disabled:opacity-50": true }]} class:active={x}></div>\n<p class="mt-2 {x ? \'text-sm\' : \'\'} tracking-wide">Hi</p>',
|
||||
});
|
||||
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 <span className="text-label tracking-label">Field kit</span>;',
|
||||
'}',
|
||||
'export function Band() {',
|
||||
' return <div aria-hidden="true" className="h-2 bg-linear-to-r from-accent to-sky-500" />;',
|
||||
'}',
|
||||
].join('\n'),
|
||||
});
|
||||
const result = await lint([dir], { cwd: dir, warn: () => {} });
|
||||
expect(lines(result.findings, 'wide-tracking')).toEqual([2]);
|
||||
expect(only(result.findings, 'wide-tracking')[0].evidence).toContain('letter-spaced at 12px tracked 0.1em');
|
||||
expect(lines(result.findings, 'purple-blue-gradient')).toEqual([5]);
|
||||
expect(only(result.findings, 'purple-blue-gradient')[0].evidence).toContain('purple-to-blue gradient (from-accent to-sky-500)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('design-lint tailwind: motion rules', () => {
|
||||
test('layout-transition: transition-all and a transition naming a layout property block; transition-colors does not', async () => {
|
||||
const f = await lintFiles({ 'index.html': page([
|
||||
'<a href="/a" class="rounded-md px-3 transition-colors">Fine</a>',
|
||||
'<a href="/b" class="rounded-md px-3 transition-all">Everything</a>',
|
||||
'<div class="transition-[width,height]">Grows</div>',
|
||||
]) });
|
||||
expect(only(f, 'layout-transition').map((x) => [x.line, x.grade])).toEqual([[BODY + 1, 'blocks'], [BODY + 2, 'blocks']]);
|
||||
expect(only(f, 'layout-transition')[0].evidence).toBe('transition-all on <a class="rounded-md px-3 transition-all">');
|
||||
});
|
||||
|
||||
test('bounce-easing: animate-bounce and an overshooting cubic-bezier ask; animate-pulse does not', async () => {
|
||||
const f = await lintFiles({ 'index.html': page([
|
||||
'<div aria-hidden="true" class="h-2 w-2 animate-pulse"></div>',
|
||||
'<div aria-hidden="true" class="h-2 w-2 animate-bounce"></div>',
|
||||
'<button type="button" class="px-3 transition-transform ease-[cubic-bezier(0.34,1.56,0.64,1)]">Go</button>',
|
||||
]) });
|
||||
expect(only(f, 'bounce-easing').map((x) => [x.line, x.grade])).toEqual([[BODY + 1, 'ask'], [BODY + 2, 'ask']]);
|
||||
expect(only(f, 'bounce-easing')[1].evidence).toContain('overshoot easing ease-[cubic-bezier(0.34,1.56,0.64,1)]');
|
||||
});
|
||||
|
||||
test('hover-tells: an image that scales on hover, or hover scale on three elements in one file; a colour change does not', async () => {
|
||||
const clean = await lintFiles({ 'index.html': page([
|
||||
'<a href="/a" class="px-3 hover:bg-accent">One</a>',
|
||||
'<div class="p-4 hover:scale-105">A</div><div class="p-4 hover:scale-105">B</div>',
|
||||
'<img src="a.jpg" alt="The harbour" class="rounded-lg hover:opacity-90">',
|
||||
]) });
|
||||
expect(mine(clean)).toEqual([]);
|
||||
const f = await lintFiles({ 'index.html': page([
|
||||
'<img src="a.jpg" alt="The harbour" class="rounded-lg hover:rotate-2">',
|
||||
'<div class="p-4 hover:scale-105">A</div>',
|
||||
'<div class="p-4 hover:scale-105">B</div>',
|
||||
'<div class="p-4 hover:scale-105">C</div>',
|
||||
]) });
|
||||
expect(only(f, 'hover-tells').map((x) => [x.line, x.grade, x.evidence])).toEqual([
|
||||
[BODY, 'ask', 'hover:rotate-2 on <img class="rounded-lg hover:rotate-2">: the image moves on hover'],
|
||||
[BODY + 3, 'ask', 'hover scale on 3 elements in this file (hover:scale-105, hover:scale-105, hover:scale-105)'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('design-lint tailwind: colour and surface rules', () => {
|
||||
test('purple-blue-gradient: purple-to-blue and purple-to-pink palette ramps block, as does cyan on a dark paper; an amber ramp does not', async () => {
|
||||
const dark = '<style>:root { --color-paper: oklch(18% 0.02 270); }</style>';
|
||||
const f = await lintFiles({ 'index.html': page([
|
||||
'<div aria-hidden="true" class="h-2 bg-gradient-to-r from-amber-400 to-orange-600"></div>',
|
||||
'<div aria-hidden="true" class="h-2 bg-gradient-to-r from-purple-500 to-blue-500"></div>',
|
||||
'<div aria-hidden="true" class="h-2 bg-gradient-to-r from-violet-500 via-fuchsia-500 to-pink-500"></div>',
|
||||
'<div aria-hidden="true" class="h-2 bg-gradient-to-r from-cyan-400 to-cyan-600"></div>',
|
||||
], dark) });
|
||||
expect(only(f, 'purple-blue-gradient').map((x) => [x.line, x.grade])).toEqual([[BODY + 1, 'blocks'], [BODY + 2, 'blocks'], [BODY + 3, 'blocks']]);
|
||||
expect(only(f, 'purple-blue-gradient').map((x) => x.evidence.split(' gradient')[0])).toEqual(['purple-to-blue', 'purple-to-pink', 'cyan-on-dark']);
|
||||
});
|
||||
|
||||
test('gradient-text: a ramp clipped to transparent text asks; solid ink and a banned ramp (reported as the gradient) do not', async () => {
|
||||
const f = await lintFiles({ 'index.html': page([
|
||||
'<h1 class="text-5xl text-ink">Logbooks your crew will keep</h1>',
|
||||
'<h1 class="text-5xl"><span class="bg-gradient-to-r from-amber-400 to-orange-600 bg-clip-text text-transparent">Field notes</span></h1>',
|
||||
'<h2 class="text-3xl bg-gradient-to-r from-purple-500 to-blue-500 bg-clip-text text-transparent">Banned</h2>',
|
||||
]) });
|
||||
expect(only(f, 'gradient-text').map((x) => [x.line, x.grade])).toEqual([[BODY + 1, 'ask']]);
|
||||
expect(only(f, 'gradient-text')[0].evidence).toBe('gradient text (from-amber-400 to-orange-600) on <span class="bg-gradient-to-r from-amber-400…">');
|
||||
expect(only(f, 'purple-blue-gradient')[0].evidence).toContain('purple-to-blue gradient text');
|
||||
});
|
||||
|
||||
test('glow-wash: glass (backdrop blur over a translucent fill), a blurred filled orb and a zero-offset coloured shadow ask; an offset grey shadow does not', async () => {
|
||||
const f = await lintFiles({ 'index.html': page([
|
||||
'<div class="rounded-lg shadow-lg">Card</div>',
|
||||
'<div class="rounded-lg shadow-[0_4px_12px_rgba(0,0,0,0.2)]">Card</div>',
|
||||
'<nav class="sticky top-0 bg-white/10 backdrop-blur-md px-6">Nav</nav>',
|
||||
'<div aria-hidden="true" class="absolute h-72 w-72 rounded-full bg-fuchsia-500 blur-3xl"></div>',
|
||||
'<button type="button" class="px-4 shadow-[0_0_40px_oklch(62%_0.25_300)]">Preview</button>',
|
||||
'<button type="button" class="px-4 shadow-[0_0_24px] shadow-violet-500">Glow by class</button>',
|
||||
]) });
|
||||
expect(only(f, 'glow-wash').map((x) => [x.line, x.grade])).toEqual([[BODY + 2, 'ask'], [BODY + 3, 'ask'], [BODY + 4, 'ask'], [BODY + 5, 'ask']]);
|
||||
expect(only(f, 'glow-wash').map((x) => x.evidence.split(' on <')[0])).toEqual([
|
||||
'glass: backdrop-blur-md over bg-white/10',
|
||||
'blurred orb: blur-3xl on a filled absolute element',
|
||||
'glow: shadow-[0_0_40px_oklch(62%_0.25_300)]',
|
||||
'glow: shadow-[0_0_24px] with shadow-violet-500',
|
||||
]);
|
||||
});
|
||||
|
||||
test('literal-outside-tokens: a hex colour or a font family in a class blocks, an oklch literal lands at ask for a person to confirm; var() and url() do not fire', async () => {
|
||||
const f = await lintFiles({ 'index.html': page([
|
||||
'<div class="bg-[var(--color-well)] bg-[url(/paper.png)] font-[family-name:var(--font-display)]">Fine</div>',
|
||||
'<p class="bg-[#0b0b12] p-4">Hex</p>',
|
||||
'<p class="bg-[oklch(62%_0.25_300)] p-4">Oklch</p>',
|
||||
"<p class=\"font-['Inter'] p-4\">Family</p>",
|
||||
]) });
|
||||
const byLine = only(f, 'literal-outside-tokens').sort((a, b) => a.line - b.line);
|
||||
expect(byLine.map((x) => [x.line, x.grade])).toEqual([[BODY + 1, 'blocks'], [BODY + 2, 'ask'], [BODY + 3, 'blocks']]);
|
||||
expect(byLine[2].evidence).toBe("font-family literal font-['Inter'] in a class on <p class=\"font-['Inter'] p-4\">");
|
||||
});
|
||||
|
||||
test('framework-palette: five distinct palette classes in one file ask; four do not', async () => {
|
||||
const four = await lintFiles({ 'index.html': page(['<section class="bg-slate-950 text-slate-100 border-slate-800"><a href="/x" class="text-sky-400">Go</a></section>']) });
|
||||
expect(only(four, 'framework-palette')).toEqual([]);
|
||||
const f = await lintFiles({ 'index.html': page([
|
||||
'<section class="bg-slate-950 text-slate-100 border-slate-800">',
|
||||
'<a href="/x" class="text-sky-400 ring-indigo-500/50">Go</a>',
|
||||
'</section>',
|
||||
]) });
|
||||
expect(only(f, 'framework-palette').map((x) => [x.line, x.grade])).toEqual([[BODY + 1, 'ask']]);
|
||||
expect(only(f, 'framework-palette')[0].evidence).toStartWith("a framework default palette instead of the page's tokens: 5 distinct palette classes (bg-slate-950 text-slate-100");
|
||||
});
|
||||
});
|
||||
|
||||
describe('design-lint tailwind: type rules', () => {
|
||||
test('wide-tracking: small uppercase or tracked text blocks; a display head, a logo and a tracked line at 16px without caps do not', async () => {
|
||||
const f = await lintFiles({ 'index.html': page([
|
||||
'<h1 class="text-5xl uppercase tracking-tight">Field notes</h1>',
|
||||
'<a href="/" aria-label="Fieldnote logo" class="text-xs uppercase tracking-widest">FN</a>',
|
||||
'<p class="text-base tracking-wide">Sixteen pixels, lightly tracked</p>',
|
||||
'<span class="text-xs uppercase tracking-widest">Field kit</span>',
|
||||
'<div class="text-sm"><small class="uppercase">Inherited size</small></div>',
|
||||
'<p class="text-[11px] [font-variant-caps:small-caps]">Small caps label</p>',
|
||||
]) });
|
||||
expect(only(f, 'wide-tracking').map((x) => [x.line, x.grade])).toEqual([[BODY + 3, 'blocks'], [BODY + 4, 'blocks'], [BODY + 5, 'blocks']]);
|
||||
expect(only(f, 'wide-tracking').map((x) => x.evidence)).toEqual([
|
||||
'"Field kit" uppercase at 12px tracked 0.1em on <span class="text-xs uppercase tracking-wide…">',
|
||||
'"Inherited size" uppercase at 14px on <small class="uppercase">',
|
||||
'"Small caps label" small caps at 11px on <p class="text-[11px] [font-variant-caps:…">',
|
||||
]);
|
||||
});
|
||||
|
||||
test('kicker-above-heading: a small uppercase, tracked or accent line right before an h1-h3 asks; a plain lede, a dated line and a component scope do not', async () => {
|
||||
const body = [
|
||||
'<section>',
|
||||
'<p class="text-sm text-muted">A plain line above the head</p>',
|
||||
'<h2 class="text-3xl">One log per watch</h2>',
|
||||
'<p class="text-sm uppercase">Features</p>',
|
||||
'<h2 class="text-3xl">What the crew gets</h2>',
|
||||
'<p class="text-sm text-indigo-600">Fewer handoffs</p>',
|
||||
'<h3 class="text-xl">Tide tables</h3>',
|
||||
'<p class="text-sm uppercase"><time datetime="2026-10-04">4 October</time></p>',
|
||||
'<h2 class="text-3xl">Dated, so not an eyebrow</h2>',
|
||||
'</section>',
|
||||
];
|
||||
const f = await lintFiles({ 'index.html': page(body) });
|
||||
expect(only(f, 'kicker-above-heading').map((x) => [x.line, x.grade])).toEqual([[BODY + 3, 'ask'], [BODY + 5, 'ask']]);
|
||||
expect(only(f, 'kicker-above-heading').map((x) => x.evidence)).toEqual([
|
||||
'eyebrow "Features" before <h2> (uppercase at 14px)',
|
||||
'eyebrow "Fewer handoffs" before <h3> (text-indigo-600 at 14px)',
|
||||
]);
|
||||
expect(only(await lintFiles({ 'index.html': page(body) }, { scope: 'component' }), 'kicker-above-heading')).toEqual([]);
|
||||
});
|
||||
|
||||
test('monospace-costume: font-mono on a small prose label asks; code, data text, a large line and a page whose body face is mono do not', async () => {
|
||||
const f = await lintFiles({ 'index.html': page([
|
||||
'<code class="font-mono text-xs">bun run lint</code>',
|
||||
'<p class="font-mono text-xs">v1.2.3</p>',
|
||||
'<p class="font-mono text-base">Sixteen pixels is not a label</p>',
|
||||
'<p class="font-mono text-xs">Updated weekly by the crew</p>',
|
||||
]) });
|
||||
expect(only(f, 'monospace-costume').map((x) => [x.line, x.grade])).toEqual([[BODY + 3, 'ask']]);
|
||||
expect(only(f, 'monospace-costume')[0].evidence).toBe('font-mono on "Updated weekly by the c…" at 12px (<p class="font-mono text-xs">)');
|
||||
const material = await lintFiles({ 'index.html': page(['<p class="font-mono text-xs">Updated weekly by the crew</p>'], '<style>:root { --font-body: "JetBrains Mono", monospace; }</style>') });
|
||||
expect(only(material, 'monospace-costume')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('design-lint tailwind: structure rules', () => {
|
||||
test('vh-height: min-h-screen on the first section or a hero blocks; min-h-dvh and a screen-tall inner panel do not', async () => {
|
||||
const clean = await lintFiles({ 'index.html': page([
|
||||
'<section class="min-h-dvh px-6">Hero</section>',
|
||||
'<section class="px-6"><div class="h-screen overflow-auto">A tall panel, not the hero</div></section>',
|
||||
]) });
|
||||
expect(only(clean, 'vh-height')).toEqual([]);
|
||||
const f = await lintFiles({ 'index.html': page([
|
||||
'<section class="relative min-h-screen px-6">Hero</section>',
|
||||
'<div class="hero min-h-[100vh]">Also the hero</div>',
|
||||
]) });
|
||||
expect(only(f, 'vh-height').map((x) => [x.line, x.grade, x.evidence])).toEqual([
|
||||
[BODY, 'blocks', 'hero sized in vh: min-h-screen on <section class="relative min-h-screen px-6">'],
|
||||
[BODY + 1, 'blocks', 'hero sized in vh: min-h-[100vh] on <div class="hero min-h-[100vh]">'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('template-cards: three grid children opening on an icon in a rounded filled tile above an h3 ask; tiles with inline icons do not', async () => {
|
||||
const tile = (icon: string) => `<div class="rounded-xl p-6">${icon}<h3 class="mt-4 font-semibold">Tide tables</h3><p class="mt-2">Every reading on one chart.</p></div>`;
|
||||
const coin = '<div class="flex h-10 w-10 rounded-lg bg-indigo-600"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M4 12h16"/></svg></div>';
|
||||
const inline = '<svg aria-hidden="true" viewBox="0 0 24 24" class="h-5 w-5"><path d="M4 12h16"/></svg>';
|
||||
const clean = await lintFiles({ 'index.html': page([`<div class="grid md:grid-cols-3">${tile(inline)}${tile(inline)}${tile(inline)}</div>`]) });
|
||||
expect(only(clean, 'template-cards')).toEqual([]);
|
||||
const f = await lintFiles({ 'index.html': page(['<h2>What the crew gets</h2>', `<div class="grid gap-8 md:grid-cols-3">${tile(coin)}${tile(coin)}${tile(coin)}</div>`]) });
|
||||
expect(only(f, 'template-cards').map((x) => [x.line, x.grade])).toEqual([[BODY + 1, 'ask']]);
|
||||
expect(only(f, 'template-cards')[0].evidence).toStartWith('icon-tile row: 3 children of <div class="grid gap-8 md:grid-cols-3"> open on an icon in a rounded filled tile');
|
||||
});
|
||||
});
|
||||
|
||||
describe('design-lint tailwind: component text', () => {
|
||||
const tsx = (jsx: string[]) => ['import { Link, Badge, Meta } from "./ui";', 'export function Page() {', ' return (', ' <section>', ...jsx.map((l) => ' ' + l), ' </section>', ' );', '}'].join('\n');
|
||||
const JSX = 5;
|
||||
|
||||
test('template-punctuation: an arrow glued to a link label and a middle-dot meta string in a component block; a verb label and one dot do not', async () => {
|
||||
const clean = await lintFiles({ 'package.json': '{}', 'Page.tsx': tsx(['<Link href="/docs">Read the docs</Link>', '<Meta>3 min read · Design</Meta>']) });
|
||||
expect(only(clean, 'template-punctuation')).toEqual([]);
|
||||
const f = await lintFiles({ 'package.json': '{}', 'Page.tsx': tsx(['<Link href="/docs">Read more →</Link>', '<Meta>3 min · Oct 4 · Design</Meta>']) });
|
||||
expect(only(f, 'template-punctuation').map((x) => [x.line, x.grade, x.evidence])).toEqual([
|
||||
[JSX, 'blocks', 'arrow glued to the label "Read more →" (<Link>)'],
|
||||
[JSX + 1, 'blocks', 'middle-dot meta string "3 min · Oct 4 · Design"'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('emoji-decoration: an emoji alone inside a component blocks; a word does not', async () => {
|
||||
const clean = await lintFiles({ 'package.json': '{}', 'Page.tsx': tsx(['<Badge>New</Badge>']) });
|
||||
expect(only(clean, 'emoji-decoration')).toEqual([]);
|
||||
const f = await lintFiles({ 'package.json': '{}', 'Page.tsx': tsx(['<Badge>✨</Badge>']) });
|
||||
expect(only(f, 'emoji-decoration').map((x) => [x.line, x.grade, x.evidence])).toEqual([[JSX, 'blocks', 'emoji icon "✨" in <Badge>']]);
|
||||
});
|
||||
|
||||
test('component files: cn()/clsx()/cva() in a .tsx, :class in a .vue and class:list in an .astro are read for the same tells', async () => {
|
||||
const f = await lintFiles({
|
||||
'package.json': '{}',
|
||||
'Hero.tsx': [
|
||||
'import { cva } from "cva";',
|
||||
'import { cn } from "./cn";',
|
||||
'const badge = cva("rounded-full px-2 text-xs uppercase tracking-widest", { variants: { tone: { loud: "font-semibold" } } });',
|
||||
'export function Hero({ on }: { on: boolean }) {',
|
||||
' return <a href="/x" className={cn("rounded-md px-3", on && "transition-all")}>Go</a>;',
|
||||
'}',
|
||||
].join('\n'),
|
||||
'Feature.vue': '<template>\n <div aria-hidden="true" :class="{ \'animate-bounce\': pulse }" class="h-4 w-4"></div>\n</template>',
|
||||
'Band.astro': '---\nconst on = true;\n---\n<div aria-hidden="true" class:list={["h-2 bg-gradient-to-r", on && "from-purple-500 to-blue-500"]}></div>',
|
||||
});
|
||||
expect(only(f, 'wide-tracking').map((x) => [path.basename(x.file), x.line, x.evidence])).toEqual([['Hero.tsx', 3, 'uppercase at 12px tracked 0.1em on cva() class string']]);
|
||||
expect(only(f, 'layout-transition').map((x) => [path.basename(x.file), x.line])).toEqual([['Hero.tsx', 5]]);
|
||||
expect(only(f, 'bounce-easing').map((x) => [path.basename(x.file), x.line])).toEqual([['Feature.vue', 2]]);
|
||||
expect(only(f, 'purple-blue-gradient').map((x) => [path.basename(x.file), x.line])).toEqual([['Band.astro', 4]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('design-lint tailwind: fixtures', () => {
|
||||
test('tailwind-fail: every utility tell planted in the fixture is found at its line', async () => {
|
||||
const result = await lint([path.join(FIXTURES, 'tailwind-fail')], { cwd: ROOT, warn: () => {} });
|
||||
const got = result.findings.filter((f) => MY_RULES.includes(f.rule)).map((f) => `${f.grade} ${f.rule} ${path.basename(f.file)}:${f.line}`).sort();
|
||||
expect(got).toEqual([
|
||||
'ask bounce-easing Feature.vue:7', 'ask bounce-easing Hero.tsx:29', 'ask bounce-easing index.html:39',
|
||||
'ask framework-palette Hero.tsx:22', 'ask framework-palette index.html:30',
|
||||
'ask glow-wash Feature.vue:8', 'ask glow-wash index.html:16', 'ask glow-wash index.html:25', 'ask glow-wash index.html:37',
|
||||
'ask gradient-text index.html:30',
|
||||
'ask hover-tells Feature.vue:5', 'ask hover-tells index.html:59', 'ask hover-tells index.html:69',
|
||||
'ask kicker-above-heading Hero.tsx:17', 'ask kicker-above-heading index.html:27',
|
||||
'ask monospace-costume Hero.tsx:21', 'ask monospace-costume index.html:33',
|
||||
'ask template-cards index.html:48',
|
||||
'blocks layout-transition Feature.vue:6', 'blocks layout-transition Hero.tsx:22', 'blocks layout-transition index.html:35',
|
||||
'blocks literal-outside-tokens Hero.tsx:26', 'blocks literal-outside-tokens index.html:71',
|
||||
'blocks purple-blue-gradient Feature.vue:8', 'blocks purple-blue-gradient Hero.tsx:18', 'blocks purple-blue-gradient index.html:43',
|
||||
'blocks vh-height Hero.tsx:16', 'blocks vh-height index.html:23',
|
||||
'blocks wide-tracking Feature.vue:4', 'blocks wide-tracking Hero.tsx:6', 'blocks wide-tracking index.html:19',
|
||||
].sort());
|
||||
});
|
||||
|
||||
test('tailwind-pass and the clean page raise nothing from the component family', async () => {
|
||||
for (const fixture of ['tailwind-pass', 'clean']) {
|
||||
const result = await lint([path.join(FIXTURES, fixture)], { cwd: ROOT, warn: () => {} });
|
||||
expect(mine(result.findings)).toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user