mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-01 22:45:02 +01:00
Merge remote-tracking branch 'origin/feat/design-lint-tailwind' into feat/design-lint
This commit is contained in:
commit
dfd6693d51
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