diff --git a/lib/design-lint/css.ts b/lib/design-lint/css.ts index a67b92f9f..b43cf105e 100644 --- a/lib/design-lint/css.ts +++ b/lib/design-lint/css.ts @@ -155,7 +155,7 @@ export type Tokens = Record; const TOKEN_SEL = /^(?:html|:root)?(?:\[data-(?:theme|mode|scheme)[^\]]*\]|:root|:not\([^)]*\))*$/i; /** Whether a rule belongs to the token block: :root / html / a data-theme scope, @font-face, @theme, or anything in tokens.css. */ -export function isTokenRule(rule: CssRule): boolean { +export function isTokenRule(rule: Pick): boolean { if (rule.selector === '@font-face' || rule.selector === '@theme' || path.basename(rule.file).toLowerCase() === 'tokens.css') return true; return rule.selector.split(',').every((p) => { const t = p.trim(); return t && TOKEN_SEL.test(t); }); } diff --git a/lib/design-lint/html.ts b/lib/design-lint/html.ts index 4ed11c14f..5627b9bfa 100644 --- a/lib/design-lint/html.ts +++ b/lib/design-lint/html.ts @@ -572,4 +572,25 @@ export function closeIndex(doc: Doc, idx: number): number { return doc.tags.length - 1; } +// sloplint proseRun +const proseRun = (run: string): string => { + let r = run; + for (let k = 0; k < 3 && /\{[^{}]*\}/.test(r); k++) r = r.replace(/\{[^{}]*\}/g, pad); + return /[;=(){}]|\breturn\b/.test(r) ? pad(r) : r; +}; + +/** The document's prose: tags, comments, scripts, styles and code blanked with positions preserved; component text runs that read as code are blanked too. */ +// sloplint maskedProse +export function maskedProse(doc: Doc): string { + const cached = doc.cache.prose as string | undefined; + if (cached != null) return cached; + const src = doc.kind !== 'html' ? doc.src.replace(/(^|>)([^<>]*)(?=<|$)/g, (_m0, a: string, run: string) => a + proseRun(run)) : doc.src; + const prose = src + .replace(/<(script|style|pre|code|kbd|samp|textarea)\b[^>]*>[\s\S]*?<\/\1>/gi, pad) + .replace(//g, pad) + .replace(/<[^>]*>/g, pad); + doc.cache.prose = prose; + return prose; +} + export const wordsOf = (t: string): string[] => plainText(t).split(/\s+/).filter((w) => /[A-Za-z0-9]/.test(w)); diff --git a/lib/design-lint/rules/chrome-copy.ts b/lib/design-lint/rules/chrome-copy.ts index 30e5c1b10..2467ab2e3 100644 --- a/lib/design-lint/rules/chrome-copy.ts +++ b/lib/design-lint/rules/chrome-copy.ts @@ -9,31 +9,9 @@ */ import { styleFor, type LintContext } from '../context'; import { resolveVars } from '../css'; -import { ancestorMap, getAttr, innerOf, plainText, type Doc, type Tag } from '../html'; +import { ancestorMap, getAttr, innerOf, maskedProse, plainText, type Doc, type Tag } from '../html'; import { trunc, type Reporter } from '../report'; -const pad = (m: string): string => m.replace(/[^\n]/g, ' '); - -// sloplint proseRun -const proseRun = (run: string): string => { - let r = run; - for (let k = 0; k < 3 && /\{[^{}]*\}/.test(r); k++) r = r.replace(/\{[^{}]*\}/g, pad); - return /[;=(){}]|\breturn\b/.test(r) ? pad(r) : r; -}; - -/** The document's prose: tags, comments, scripts, styles and code blanked with positions preserved; component text runs that read as code are blanked too. */ -// sloplint maskedProse -export function maskedProse(doc: Doc): string { - const cached = doc.cache.prose as string | undefined; - if (cached != null) return cached; - const src = doc.kind !== 'html' ? doc.src.replace(/(^|>)([^<>]*)(?=<|$)/g, (_m0, a: string, run: string) => a + proseRun(run)) : doc.src; - const prose = src - .replace(/<(script|style|pre|code|kbd|samp|textarea)\b[^>]*>[\s\S]*?<\/\1>/gi, pad) - .replace(//g, pad) - .replace(/<[^>]*>/g, pad); - doc.cache.prose = prose; - return prose; -} // sloplint TELLS19 (gate 19), less the lines the copy tier owns const HERO_TELLS: Array<[RegExp, string]> = [ diff --git a/lib/design-lint/rules/chrome.ts b/lib/design-lint/rules/chrome.ts index e2fb2d55e..42a2b4e09 100644 --- a/lib/design-lint/rules/chrome.ts +++ b/lib/design-lint/rules/chrome.ts @@ -7,9 +7,9 @@ */ import type { LintContext } from '../context'; import { lineIndex } from '../css'; -import { getAttr, innerOf, plainText, type Doc, type Tag } from '../html'; +import { getAttr, innerOf, maskedProse, plainText, type Doc, type Tag } from '../html'; import { trunc, type Reporter } from '../report'; -import { maskedProse, runChromeCopy } from './chrome-copy'; +import { runChromeCopy } from './chrome-copy'; import { runEyebrow } from './chrome-eyebrow'; // sloplint check42 (gate 42) diff --git a/lib/design-lint/rules/color.ts b/lib/design-lint/rules/color.ts index 5858904b9..c2a4b8129 100644 --- a/lib/design-lint/rules/color.ts +++ b/lib/design-lint/rules/color.ts @@ -172,7 +172,7 @@ function check29Glow(ctx: LintContext, r: Reporter): void { r.report('glow-wash', file, line, ev, { fix }); }; for (const rule of mergedRules(ctx)) { - if (isTokenRule({ ...rule, keyframes: null })) continue; + if (isTokenRule(rule)) continue; const local: Tokens = {}; for (const d of rule.decls) if (d.prop.startsWith('--')) local[d.prop] = d.value; const name = trunc(rule.selector, 36); diff --git a/lib/design-lint/rules/component.ts b/lib/design-lint/rules/component.ts index d4edd1c06..79c64e34c 100644 --- a/lib/design-lint/rules/component.ts +++ b/lib/design-lint/rules/component.ts @@ -93,7 +93,11 @@ function checkComponentText(ctx: LintContext, r: Reporter): void { 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' }); + if (text.length <= 4 && EMOJI_ICON.test(text)) { + const outer = r.findings.findIndex((f) => f.rule === 'emoji-decoration' && f.file === relPath(doc.file, r.cwd) && f.evidence.includes(`"${text}"`)); + if (outer >= 0) r.findings.splice(outer, 1); + r.report('emoji-decoration', doc.file, t.line, `emoji icon "${text}" in <${t.name}>`, { fix: 'one icon library or custom SVG' }); + } } } } diff --git a/lib/design-lint/rules/finish.ts b/lib/design-lint/rules/finish.ts index 3715f824f..fad16e5a7 100644 --- a/lib/design-lint/rules/finish.ts +++ b/lib/design-lint/rules/finish.ts @@ -6,10 +6,9 @@ */ import { mergedRules, type LintContext } from '../context'; import { HEADING_SEL, isTokenRule, lineIndex, pxOf, resolveVars, type MergedRule } from '../css'; -import { getAttr } from '../html'; +import { getAttr, maskedProse } from '../html'; import { trunc, type Reporter } from '../report'; import { twVariants } from '../tailwind'; -import { maskedProse } from './chrome-copy'; /** The largest px, rem or em length in a value, at a 16px root; null when it has none. */ // sloplint maxPxIn @@ -27,8 +26,6 @@ const baseSelector = (sel: string): string => sel.replace(/::?[a-z-]+(\([^)]*\)) const firstFile = (ctx: LintContext): string | null => ctx.cssFiles[0]?.file || ctx.docs[0]?.file || null; -/** isTokenRule over a merged rule (merging already dropped keyframes). */ -const tokenRule = (rule: MergedRule): boolean => isTokenRule({ ...rule, keyframes: null }); // sloplint checkF1 (F1) function checkStraightQuotes(ctx: LintContext, r: Reporter): void { @@ -75,7 +72,7 @@ function checkPrimaryRecipes(ctx: LintContext, r: Reporter): void { const BTN = /\b(btn|button|cta)\b|__(btn|button|cta)\b/i; const filled = new Map(); for (const rule of mergedRules(ctx)) { - if (tokenRule(rule) || /:hover|:active|:focus|:disabled|\.is-|\[disabled/i.test(rule.selector)) continue; + if (isTokenRule(rule) || /:hover|:active|:focus|:disabled|\.is-|\[disabled/i.test(rule.selector)) continue; const first = baseSelector(rule.selector.split(',')[0]); if (!BTN.test(first)) continue; for (const d of rule.decls) { @@ -93,7 +90,7 @@ function checkPrimaryRecipes(ctx: LintContext, r: Reporter): void { // sloplint checkF5 (F5) function checkIconCoins(ctx: LintContext, r: Reporter): void { for (const rule of mergedRules(ctx)) { - if (tokenRule(rule) || !/icon|\bico\b/i.test(rule.selector)) continue; + if (isTokenRule(rule) || !/icon|\bico\b/i.test(rule.selector)) continue; const radius = rule.decls.find((d) => d.prop === 'border-radius'); if (!radius) continue; const rv = resolveVars(radius.value, ctx.tokens); @@ -115,7 +112,7 @@ function checkTabularFigures(ctx: LintContext, r: Reporter): void { const covered = new Set(bySel.filter(hasTnum).map((rule) => baseSelector(rule.selector))); const warned = new Set(); for (const rule of bySel) { - if (tokenRule(rule) || !NUM_SEL.test(rule.selector)) continue; + if (isTokenRule(rule) || !NUM_SEL.test(rule.selector)) continue; const base = baseSelector(rule.selector); if (warned.has(base) || covered.has(base)) continue; const size = rule.decls.find((d) => d.prop === 'font-size'); @@ -129,7 +126,7 @@ function checkTabularFigures(ctx: LintContext, r: Reporter): void { function checkStampedRecipe(ctx: LintContext, r: Reporter): void { const byKey = new Map; rule: MergedRule }>(); for (const rule of mergedRules(ctx)) { - if (tokenRule(rule) || /::(before|after)/.test(rule.selector)) continue; + if (isTokenRule(rule) || /::(before|after)/.test(rule.selector)) continue; const get = (re: RegExp): string => { const d = rule.decls.filter((x) => re.test(x.prop)).pop(); return d ? resolveVars(d.value, ctx.tokens).replace(/\s+/g, ' ').trim() : ''; @@ -155,7 +152,7 @@ function checkPillRadius(ctx: LintContext, r: Reporter): void { const sels = new Set(); let where: { rule: MergedRule; line: number } | null = null; for (const rule of mergedRules(ctx)) { - if (tokenRule(rule)) continue; + if (isTokenRule(rule)) continue; const d = rule.decls.filter((x) => x.prop === 'border-radius').pop(); if (!d || !/^(9999px|999px|50rem)$/.test(resolveVars(d.value, ctx.tokens).trim())) continue; for (const part of rule.selector.split(',')) { diff --git a/lib/design-lint/rules/type.ts b/lib/design-lint/rules/type.ts index 2daa6c9b3..75fdaf9d8 100644 --- a/lib/design-lint/rules/type.ts +++ b/lib/design-lint/rules/type.ts @@ -293,7 +293,7 @@ function check67(ctx: LintContext, r: Reporter): void { const upperDisplay = bySel.some((rule) => displayish(rule) && !LABEL67.test(rule.selector) && rule.decls.some((d) => d.prop === 'text-transform' && /uppercase/.test(d.value))); const seen = new Set(); for (const rule of bySel) { - if (isTokenRule({ ...rule, keyframes: null }) || exempt67(rule.selector) || /::first-letter\s*(,|$)/.test(rule.selector)) continue; + if (isTokenRule(rule) || exempt67(rule.selector) || /::first-letter\s*(,|$)/.test(rule.selector)) continue; const caps = caps67(ctx, bySel, rule); if (!caps) continue; const { upper, smallCaps, em, px } = caps; diff --git a/test/design-lint-tailwind.test.ts b/test/design-lint-tailwind.test.ts index a125b6aa1..2ca79e58d 100644 --- a/test/design-lint-tailwind.test.ts +++ b/test/design-lint-tailwind.test.ts @@ -237,7 +237,7 @@ describe('design-lint tailwind: type rules', () => { 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

(uppercase at 14px)', + 'the eyebrow "Features" before

', 'eyebrow "Fewer handoffs" before

(text-indigo-600 at 14px)', ]); expect(only(await lintFiles({ 'index.html': page(body) }, { scope: 'component' }), 'kicker-above-heading')).toEqual([]); diff --git a/test/fixtures/design-lint/eyebrow-pass/index.html b/test/fixtures/design-lint/eyebrow-pass/index.html index 7781e16da..3e52fad0c 100644 --- a/test/fixtures/design-lint/eyebrow-pass/index.html +++ b/test/fixtures/design-lint/eyebrow-pass/index.html @@ -21,7 +21,7 @@ Gate 54 carve-out fixture (must pass)