diff --git a/lib/design-lint/html.ts b/lib/design-lint/html.ts
index 157f910e8..6cc80ef73 100644
--- a/lib/design-lint/html.ts
+++ b/lib/design-lint/html.ts
@@ -15,15 +15,7 @@ import { lineIndex } from './css';
export type DocKind = 'html' | 'jsx' | 'vue' | 'svelte' | 'astro';
-export interface Tag {
- close: boolean;
- name: string;
- attrs: string;
- pos: number;
- end: number;
- selfClosing: boolean;
- line: number;
-}
+export interface Tag { close: boolean; name: string; attrs: string; pos: number; end: number; selfClosing: boolean; line: number }
/** A string as a literal inside a RegExp source. */
export const escapeRe = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@@ -70,10 +62,11 @@ interface Span { start: number; bodyStart: number; bodyEnd: number; end: number
/**
* Every `open … close` span of lower in one linear pass, so a file of
- * unclosed openers costs its length and not its square. An opener with no
- * closer after it is left alone, which is what the lazy regex it replaces did.
- * For an element, open is `` (spaces before the `>` allowed).
+ * unclosed openers costs its length and not its square. An unclosed comment
+ * runs to the end of the file, as a browser reads it; an element opener with
+ * no closer after it is left alone, as a tag. For an element, open is
+ * `` (spaces before the `>` allowed).
*/
function spansOf(lower: string, open: string, close: string, element: boolean): Span[] {
const out: Span[] = [];
@@ -84,7 +77,7 @@ function spansOf(lower: string, open: string, close: string, element: boolean):
const after = start + open.length;
if (!element) {
const c = lower.indexOf(close, after);
- if (c < 0) break;
+ if (c < 0) { out.push({ start, bodyStart: after, bodyEnd: lower.length, end: lower.length }); break; }
out.push({ start, bodyStart: after, bodyEnd: c, end: c + close.length });
i = c + close.length;
continue;
@@ -140,19 +133,25 @@ const MAX_UNQUOTED_ATTRS = 8192;
* ``, with quoted attribute values skipped whole. A `<` that never
* reaches a `>`, or a quote that never closes, is text; a `<` that opens a
* tag inside unquoted attribute text starts the next tag instead, as a
- * browser reads ``.
+ * browser reads ``. Every `<` that starts no tag is a stray, except
+ * a `` after it.
*/
function scanTags(s: string, onTag: (close: boolean, name: string, attrs: string, pos: number, end: number) => void, strays?: number[]): void {
const n = s.length;
const noQuoteFrom: Record = { '"': n, "'": n };
- let i = 0;
+ let i = 0, nextGt = -1;
while (i < n) {
const lt = s.indexOf('<', i);
if (lt < 0) break;
let j = lt + 1;
const close = s[j] === '/';
if (close) j++;
- if (!/[a-zA-Z]/.test(s[j] || '')) { i = lt + 1; continue; }
+ if (!/[a-zA-Z]/.test(s[j] || '')) {
+ if (nextGt < lt) { const g = s.indexOf('>', lt); nextGt = g < 0 ? n : g; }
+ if (!/[!?]/.test(s[j] || '') || nextGt >= n) strays?.push(lt);
+ i = lt + 1;
+ continue;
+ }
let k = j + 1;
while (k < n && /[a-zA-Z0-9-]/.test(s[k])) k++;
let p = k, unquoted = 0, next = lt + 1, gt = -1;
@@ -176,7 +175,7 @@ function scanTags(s: string, onTag: (close: boolean, name: string, attrs: string
}
}
-/** The text with each stray `<` (a tag-shaped start the scanner found no `>` for) replaced by a space, so a `]*>` regex over it always finds its `>` within the tag and a page whose tail is ten thousand unclosed `
`, a `<[^>]*>` regex over it costs its length, and a page whose tail is ten thousand unclosed `
({ label: `${w}px`, width: w, height: 800, call: w === 1280 ? 'P.layout(P, true).concat(P.contrast(P), P.accent(P))' : 'P.layout(P, false)' }));
- m.push({ label: '1440px', ...LAPTOP, call: 'P.space(P, true)' });
+ const m: Measure[] = WIDTHS.map((w) => ({ label: `${w}px`, width: w, height: CONTRAST_VIEW.height, call: w === CONTRAST_VIEW.width ? 'P.layout(P, true).concat(P.contrast(P), P.accent(P))' : 'P.layout(P, false)' }));
+ m.push({ label: `${LAPTOP.width}px`, ...LAPTOP, call: 'P.space(P, true)' });
m.push({ label: '1920px', width: 1920, height: 1080, call: 'P.space(P, false)' });
- for (const call of ['P.quality(P)', 'P.occlusion(P)', 'P.focus(P)']) m.push({ label: '1440px', ...LAPTOP, call });
+ for (const call of ['P.quality(P)', 'P.occlusion(P)', 'P.focus(P)']) m.push({ label: `${LAPTOP.width}px`, ...LAPTOP, call });
return m;
}
@@ -105,16 +107,19 @@ function parsed(text: string | undefined): Parsed {
}
}
-/** The source line of the element a finding names: its tag, first class, a distinguishing attribute and its opening text. Best effort; 1 when nothing matches. */
+/** A probe's locator field: a string no longer than a class name or an opening line could be; anything else locates nothing. */
+const SHORT = (v: unknown): v is string => typeof v === 'string' && v.length <= 400;
+
+/** The source line of the element a finding names: its tag, first class, a distinguishing attribute and its opening text. Best effort; 1 when nothing matches, or when the locator is not shaped like one the probes emit. */
export function lineOf(src: string | null, at?: ProbeAt, lineAt: (i: number) => number = src ? lineIndex(src) : () => 1): number {
- if (!src || !at || typeof at.tag !== 'string' || typeof at.text !== 'string') return 1;
- const cls = typeof at.cls === 'string' && at.cls ? new RegExp(`class=["'](?:[^"']*\\s)?${escapeRe(at.cls)}(?:\\s[^"']*)?["']`, 'i') : null;
- const attr = typeof at.attr === 'string' && at.attr ? at.attr : null;
+ if (!src || !at || !SHORT(at.tag) || !/^[a-z]/i.test(at.tag) || !SHORT(at.text)) return 1;
+ const cls = SHORT(at.cls) && at.cls ? at.cls : null;
+ const attr = SHORT(at.attr) && at.attr ? at.attr : null;
const want = at.text.toLowerCase();
let fallback = -1;
for (const m of src.matchAll(new RegExp(`<${escapeRe(at.tag)}\\b[^>]*>`, 'gi'))) {
const pos = m.index ?? 0;
- if (cls && !cls.test(m[0])) continue;
+ if (cls && !(/\bclass\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i.exec(m[0]) ?? ['', '']).slice(1).join(' ').split(/\s+/).includes(cls)) continue;
if (attr && !m[0].includes(attr)) continue;
if (!want) { fallback = pos; break; }
if (plainText(src.slice(pos + m[0].length, pos + m[0].length + 600)).toLowerCase().startsWith(want)) { fallback = pos; break; }
@@ -197,7 +202,7 @@ async function lintPage(target: string, r: Reporter, run: Runner): Promise
for (const f of rest) found.splice(found.indexOf(f), 1);
}
const bare = rings.filter((x) => x.bare);
- if (bare.length) report({ rule: 'missing-focus-visible', grade: 'fail', evidence: `[1440px] nothing shows keyboard focus on ${bare.slice(0, 4).map((x) => x.key).join(', ')}`, fix: 'give every control a :focus-visible ring; never remove an outline without putting one back', at: bare[0].at }, src);
+ if (bare.length) report({ rule: 'missing-focus-visible', grade: 'fail', evidence: `[${LAPTOP.width}px] nothing shows keyboard focus on ${bare.slice(0, 4).map((x) => x.key).join(', ')}`, fix: 'give every control a :focus-visible ring; never remove an outline without putting one back', at: bare[0].at }, src);
const textProbes = found.filter((f) => f.probe);
const weakRings = rings.filter((x) => x.ratio != null && x.ratio < RING_FLOOR);
@@ -212,7 +217,7 @@ async function lintPage(target: string, r: Reporter, run: Runner): Promise
for (const f of found) if (!(f.probe && dropped.has(f.probe.path))) report(f, src);
for (const ring of weakRings) {
const real = confirmed?.ring.get(ring.path) ?? ring.ratio!;
- if (real < RING_FLOOR) report({ rule: 'missing-focus-visible', grade: 'fail', evidence: `[1440px] focus ring ${real.toFixed(2)}:1 against what is behind it on ${ring.key}`, fix: 'a ring at 3:1 or better against the surface it is drawn over', at: ring.at }, src);
+ if (real < RING_FLOOR) report({ rule: 'missing-focus-visible', grade: 'fail', evidence: `[${LAPTOP.width}px] focus ring ${real.toFixed(2)}:1 against what is behind it on ${ring.key}`, fix: 'a ring at 3:1 or better against the surface it is drawn over', at: ring.at }, src);
}
}
@@ -225,7 +230,7 @@ async function confirmPixels(file: string, run: Runner, textProbes: Found[], wea
const textItems: PixelItem[] = [...new Map(textProbes.map((f) => [f.probe!.path, { path: f.probe!.path, kind: 'text' as const }])).values()];
const ringItems: PixelItem[] = weakRings.map((x) => ({ path: x.path, kind: 'ring' as const, pad: x.pad }));
const groups: Array<{ items: PixelItem[]; width: number; height: number; shot: string; evalIdx: number }> = [];
- if (textItems.length) groups.push({ items: textItems, width: 1280, height: 800, shot: path.join(dir, 'text.png'), evalIdx: -1 });
+ if (textItems.length) groups.push({ items: textItems, ...CONTRAST_VIEW, shot: path.join(dir, 'text.png'), evalIdx: -1 });
if (ringItems.length) groups.push({ items: ringItems, width: LAPTOP.width, height: LAPTOP.height, shot: path.join(dir, 'ring.png'), evalIdx: -1 });
const steps: RenderStep[] = openingSteps();
for (const g of groups) {
diff --git a/lib/design-lint/rules/assets.ts b/lib/design-lint/rules/assets.ts
index 302795feb..5385c00f2 100644
--- a/lib/design-lint/rules/assets.ts
+++ b/lib/design-lint/rules/assets.ts
@@ -9,22 +9,24 @@
import { contrastRatio, oklchOf, parseColor, type Color } from '../color';
import { mergedRules, type LintContext } from '../context';
import { CONTRAST_SLACK, isTokenRule, resolveVars, pxOf, type MergedRule, type Tokens } from '../css';
-import { ancestorsOf, getAttr, hasAttr, innerOf, textOf, treeOf } from '../html';
-import { trunc, type Reporter } from '../report';
+import { ancestorsOf, getAttr, hasAttr, innerOf, stripTags, textOf, treeOf } from '../html';
+import { trunc, type Finding, type Reporter } from '../report';
const EMOJI = /(?![©®™‼⁉←-⇿])\p{Extended_Pictographic}/u;
-const GLYPH = /(?![©®™‼⁉←-⇿✓-✖])(?:\p{Emoji_Presentation}|\p{Extended_Pictographic}️)/u;
+const GLYPH = /(?![©®™‼⁉←-⇿✓-✖])(?:\p{Emoji_Presentation}|\p{Extended_Pictographic}\uFE0F)/u;
// sloplint check30 (gate 30a)
function checkEmojiIcons(ctx: LintContext, r: Reporter): void {
for (const doc of ctx.docs) {
const lines = new Set();
let n = 0;
- const flag = (line: number, what: string): void => {
- if (lines.has(line) || n >= 8) return;
+ const icons = (doc.cache.emojiIcons ??= new Map()) as Map;
+ const flag = (line: number, what: string): boolean => {
+ if (lines.has(line) || n >= 8) return false;
lines.add(line);
n++;
r.report('emoji-decoration', doc.file, line, what);
+ return true;
};
for (let i = 0; i < doc.tags.length; i++) {
const t = doc.tags[i];
@@ -33,9 +35,9 @@ function checkEmojiIcons(ctx: LintContext, r: Reporter): void {
const iconish = /icon|card|step|tier|feature|value|prop|badge/i.test(cls);
const inner = innerOf(doc, i);
if (!inner) continue;
- const content = inner.inner.replace(/<[^>]*>/g, '').trim();
+ const content = stripTags(inner.inner).replace(/\s+/g, ' ').trim();
if (content && content.length <= 4 && EMOJI.test(content) && (iconish || !/<[a-z]/.test(inner.inner))) {
- flag(t.line, 'an emoji icon "' + content + '" in <' + t.name + ' class="' + trunc(cls, 28) + '">');
+ if (flag(t.line, 'an emoji icon "' + content + '" in <' + t.name + ' class="' + trunc(cls, 28) + '">')) icons.set(content, r.findings[r.findings.length - 1]);
lines.add(doc.lineOf(t.end + inner.inner.search(EMOJI)));
continue;
}
@@ -47,9 +49,9 @@ function checkEmojiIcons(ctx: LintContext, r: Reporter): void {
}
const raw = doc.raw;
const body = doc.masked;
- for (const m of body.matchAll(/>\s*((?:\p{Emoji_Presentation}|\p{Extended_Pictographic}️)[^<>{}\n]{0,40})/gu)) flag(doc.lineOf(m.index! + m[0].length - m[1].length), 'text opens with an emoji: "' + trunc(m[1].trim(), 32) + '"');
+ for (const m of body.matchAll(/>\s*((?:\p{Emoji_Presentation}|\p{Extended_Pictographic}\uFE0F)[^<>{}\n]{0,40})/gu)) flag(doc.lineOf(m.index! + m[0].length - m[1].length), 'text opens with an emoji: "' + trunc(m[1].trim(), 32) + '"');
for (const m of body.matchAll(/placeholder\s*=\s*["']([^"']*)["']/g)) if (GLYPH.test(m[1])) flag(doc.lineOf(m.index!), 'an emoji in a placeholder "' + trunc(m[1], 28) + '"');
- if (doc.kind !== 'html') for (const m of raw.matchAll(/(["'`])\s*((?:(?:\p{Emoji_Presentation}|\p{Extended_Pictographic}️?)?){1,3})\s*\1/gu)) flag(doc.lineOf(m.index!), 'an emoji passed as an icon: ' + m[2]);
+ if (doc.kind !== 'html') for (const m of raw.matchAll(/(["'`])\s*((?:(?:\p{Emoji_Presentation}|\p{Extended_Pictographic}\uFE0F?)\u200D?){1,3})\s*\1/gu)) flag(doc.lineOf(m.index!), 'an emoji passed as an icon: ' + m[2]);
}
}
@@ -76,7 +78,7 @@ function iconSurfaces(ctx: LintContext): string {
}
for (const call of doc.virtuals) out.push(call.toks.join(' '));
for (const toks of doc.classAt.values()) out.push(toks.join(' '));
- for (const m of doc.scripts.matchAll(/\b(?:import|require|from)\b[^\n;]*['"]([^'"]+)['"]/g)) out.push(m[1]);
+ for (const m of doc.scripts.matchAll(/\b(?:import\s*\(?\s*|require\s*\(\s*|from\s+)['"]([^'"\n]+)['"]/g)) out.push(m[1]);
}
for (const f of ctx.cssFiles) for (const m of f.raw.matchAll(/@import\s+(?:url\()?['"]?([^'")\s;]+)|url\(\s*['"]?([^'")]+)/g)) out.push(m[1] ?? m[2]);
return out.join('\n');
@@ -205,7 +207,7 @@ function checkWrappingLabels(ctx: LintContext, r: Reporter): void {
if (!button && !link) continue;
const inner = innerOf(doc, i);
if (!inner) continue;
- const label = inner.inner.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
+ const label = stripTags(inner.inner).replace(/\s+/g, ' ').trim();
if (label.length <= 24 || covered(cls, t.name)) continue;
if (button) r.report('button-label-wraps', doc.file, t.line, 'the long label "' + trunc(label, 36) + '" on <' + t.name + '> can wrap, and nothing sets nowrap', { suspected: true });
else r.report('link-wraps', doc.file, t.line, 'the long link "' + trunc(label, 36) + '" can wrap, and nothing sets nowrap');
diff --git a/lib/design-lint/rules/chrome.ts b/lib/design-lint/rules/chrome.ts
index e562103cd..be0670926 100644
--- a/lib/design-lint/rules/chrome.ts
+++ b/lib/design-lint/rules/chrome.ts
@@ -125,18 +125,15 @@ const TICKER = /marquee|ticker|tape|crawl/i;
function checkTemplatePunctuation(ctx: LintContext, r: Reporter): void {
for (const doc of ctx.docs) {
const dotLines = new Set(), arrowLines = new Set();
- const tickerRanges: Array<[number, number]> = [];
+ const { parent } = treeOf(doc);
+ const inTicker = new Uint8Array(doc.tags.length);
for (let i = 0; i < doc.tags.length; i++) {
const t = doc.tags[i];
- if (t.close || t.selfClosing || !TICKER.test(getAttr(t.attrs, 'class') || '')) continue;
- const inner = innerOf(doc, i);
- if (inner) tickerRanges.push([t.pos, inner.endPos]);
+ inTicker[i] = (parent[i] >= 0 && inTicker[parent[i]]) || (!t.close && !t.selfClosing && TICKER.test(getAttr(t.attrs, 'class') || '') ? 1 : 0);
}
for (let i = 0; i < doc.tags.length; i++) {
const t = doc.tags[i];
- if (t.close || t.selfClosing || !TEXT_TAGS.has(t.name)) continue;
- const cls = getAttr(t.attrs, 'class') || '';
- if (TICKER.test(cls) || tickerRanges.some(([a, b]) => t.pos > a && t.pos < b)) continue;
+ if (t.close || t.selfClosing || !TEXT_TAGS.has(t.name) || inTicker[i]) continue;
const inner = innerOf(doc, i);
if (!inner || /<(p|div|li|section|article|ul|ol|table)\b/i.test(inner.inner)) continue;
const text = textOf(doc, i);
diff --git a/lib/design-lint/rules/component.ts b/lib/design-lint/rules/component.ts
index 2ee252853..c9b0ceae8 100644
--- a/lib/design-lint/rules/component.ts
+++ b/lib/design-lint/rules/component.ts
@@ -6,7 +6,7 @@
* emoji icons and template punctuation (30a, 63) in component text.
*/
import type { LintContext } from '../context';
-import { oneLine, relPath, trunc, type Reporter } from '../report';
+import { oneLine, relPath, trunc, type Finding, 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;
@@ -93,7 +93,6 @@ function checkTailwind(ctx: LintContext, r: Reporter): void {
// sloplint checkComponentText (gates 63 30a)
function checkComponentText(ctx: LintContext, r: Reporter): void {
for (const doc of ctx.docs) {
- const file = oneLine(relPath(doc.file, r.cwd));
for (let c = 0; c < doc.comps.length; c++) {
const t = doc.comps[c];
if (t.close || t.selfClosing) continue;
@@ -104,7 +103,8 @@ function checkComponentText(ctx: LintContext, r: Reporter): void {
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)) {
- const outer = r.findings.findIndex((f) => f.rule === 'emoji-decoration' && f.file === file && f.evidence.includes(`"${text}"`));
+ const icon = (doc.cache.emojiIcons as Map | undefined)?.get(text);
+ const outer = icon ? r.findings.indexOf(icon) : -1;
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/copy.ts b/lib/design-lint/rules/copy.ts
index d9629229a..a41d598fa 100644
--- a/lib/design-lint/rules/copy.ts
+++ b/lib/design-lint/rules/copy.ts
@@ -9,15 +9,32 @@
*/
import { checkCraft } from '../../design-craft';
import type { LintContext } from '../context';
+import type { Doc } from '../html';
import type { Reporter } from '../report';
import { ruleMeta } from './ids';
const STATIC_OWNS = new Set(['transition-all', 'vh-height']);
+/** The elements the craft check reads from open tag to close tag with a lazy scan. */
+const SPANNED = new Set(['script', 'style', 'template', 'nav']);
+
+/** The page as the craft check reads it: an open script, style, template or nav tag that nothing closes after it is blanked, so each lazy scan to its close finds one and the page costs its length. */
+function craftSource(doc: Doc): string {
+ const lastClose = new Map();
+ for (const t of doc.tags) if (t.close && SPANNED.has(t.name)) lastClose.set(t.name, t.pos);
+ let out = '', from = 0;
+ for (const t of doc.tags) {
+ if (t.close || !SPANNED.has(t.name) || t.pos < (lastClose.get(t.name) ?? -1)) continue;
+ out += doc.src.slice(from, t.pos) + ' ';
+ from = t.pos + 1;
+ }
+ return out + doc.src.slice(from);
+}
+
export function run(ctx: LintContext, r: Reporter): void {
for (const doc of ctx.docs) {
if (doc.kind !== 'html') continue;
- for (const f of checkCraft(doc.src)) {
+ for (const f of checkCraft(craftSource(doc))) {
if (STATIC_OWNS.has(f.id) || !ruleMeta(f.id)) continue;
const title = ruleMeta(f.id)!.title;
r.report(f.id, doc.file, 1, f.count === 1 ? title : `${title}, ${f.count} times`, { fix: f.fix, source: 'copy' });
diff --git a/lib/design-lint/rules/ids.ts b/lib/design-lint/rules/ids.ts
index 3d8aa5b83..26455db1b 100644
--- a/lib/design-lint/rules/ids.ts
+++ b/lib/design-lint/rules/ids.ts
@@ -66,7 +66,7 @@ export const RULES: Record = {
'overflow-x-unclipped': { tier: 'ask', gate: '34', title: 'overflow-x missing on html and body, or a 100vw width', fix: 'overflow-x: clip on both; width: 100%', pageOnly: true },
'horizontal-scroll': { tier: 'blocks', gate: '34', title: 'horizontal scroll at a width between 320 and 1920', fix: 'find the element wider than its viewport and cap it', render: true, pageOnly: true },
'hero-balance': { tier: 'ask', gate: '44a/44c', title: 'the hero\'s content sits off centre for its screen, or its visual stands apart from the text', fix: 'centre the content in its screen; put the visual beside the text', render: true, pageOnly: true },
- 'hero-fold': { tier: 'blocks', gate: '44b', title: 'headline, lede, primary action and visual not all visible at 1280x800', fix: 'pull the display clamp down, tighten leading, a two-line lede, trim padding', render: true, pageOnly: true },
+ 'hero-fold': { tier: 'blocks', gate: '44b', title: "the headline's bottom or the hero's first action below the 800px fold at 1280 wide; the lede and the visual are not measured", fix: 'pull the display clamp down, tighten leading, a two-line lede, trim padding', render: true, pageOnly: true },
'wide-container': { tier: 'blocks', gate: '44d', title: 'at 1920x1080 the container has no max-width, gutters under 120px, or a grid gains columns', fix: 'cap 1360 to 1520px and centre; hold column counts; cap vw visuals', pageOnly: true },
'bare-fr-track': { tier: 'blocks', gate: '50', title: 'an image-bearing grid track on bare 1fr', fix: 'minmax(0, 1fr)', pageOnly: true },
'display-head-unwrapped': { tier: 'blocks', gate: '50', title: 'a display head without overflow-wrap: break-word and min-width: 0', fix: 'overflow-wrap: break-word; min-width: 0 (never anywhere)', pageOnly: true },
diff --git a/lib/design-lint/rules/motion.ts b/lib/design-lint/rules/motion.ts
index 62ca53f76..f2e8721ed 100644
--- a/lib/design-lint/rules/motion.ts
+++ b/lib/design-lint/rules/motion.ts
@@ -162,7 +162,7 @@ function tooltipDelay(ctx: LintContext, r: Reporter): void {
const delayed = (sel: RegExp): { file: string; line: number } | null => {
for (const rule of tips) {
if (!sel.test(rule.selector)) continue;
- const d = rule.decls.find((x) => /^transition(-delay)?$/.test(x.prop) && /(\d{3,})ms|([1-9][\d.]*)s\b/.test(x.value));
+ const d = rule.decls.find((x) => /^transition(-delay)?$/.test(x.prop) && /(? 0) { if (css[k] === '{') depth++; else if (css[k] === '}') depth--; k++; }
+ taken = k;
out += css.slice(m.index!, k) + ' ';
const open = css.lastIndexOf('{', m.index! - 1), close = css.lastIndexOf('}', m.index! - 1);
if (open > close) out += css.slice(Math.max(close + 1, 0), open) + ' ';
diff --git a/lib/design-lint/rules/structure.ts b/lib/design-lint/rules/structure.ts
index cf3f05eb5..a5675af7b 100644
--- a/lib/design-lint/rules/structure.ts
+++ b/lib/design-lint/rules/structure.ts
@@ -8,7 +8,7 @@
import { COLOR_LITERAL, oklchOf, parseColor } from '../color';
import { isTokenRule, pxOf, resolveVars, type MergedRule } from '../css';
import { memo, mergedRules, type LintContext } from '../context';
-import { getAttr, innerOf, textOf, treeOf, wordsOf, type Doc } from '../html';
+import { getAttr, innerOf, stripTags, textOf, treeOf, wordsOf, type Doc } from '../html';
import { trunc, type Reporter } from '../report';
import { twGroup } from '../tailwind';
import { heroIndexOf } from './chrome-copy';
@@ -258,7 +258,7 @@ function displayHeadUnwrapped(ctx: LintContext, r: Reporter): void {
if (!/^h[12]$/.test(t.name) && !/hero__(title|display)|section__title|__display/.test(cls)) continue;
const inner = innerOf(doc, i);
if (!inner) continue;
- const text = inner.inner.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
+ const text = stripTags(inner.inner).replace(/\s+/g, ' ').trim();
const risky = text.split(' ').find((word) => word.split('-').some((seg) => seg.replace(/[^\w]/g, '').length >= 16));
if (risky && !wrapCovered(cls, t.name)) {
r.report('display-head-unwrapped', doc.file, t.line, `the display head holds "${trunc(risky, 30)}" with no overflow-wrap: at 320px that word overflows`);
@@ -351,13 +351,13 @@ function minWidthPx(media: string | null): number | null {
/** How many columns a grid-template-columns value lays down; null when the count depends on the viewport (auto-fit, auto-fill) or on a var. */
function columnCount(value: string): number | null {
if (/auto-fi(t|ll)|var\(/.test(value)) return null;
- let v = value;
- for (const m of value.matchAll(/repeat\(\s*(\d+)\s*,\s*((?:[^()]|\([^()]*\))*)\)/g)) {
- const inner = m[2].replace(/\([^()]*\)/g, 'X').trim().split(/\s+/).length;
- v = v.replace(m[0], Array(parseInt(m[1], 10) * inner).fill('X').join(' '));
- }
+ let repeated = 0;
+ const v = value.replace(/repeat\(\s*(\d+)\s*,\s*((?:[^()]|\([^()]*\))*)\)/g, (_, count: string, tracks: string) => {
+ repeated += parseInt(count, 10) * tracks.replace(/\([^()]*\)/g, 'X').trim().split(/\s+/).length;
+ return ' ';
+ });
if (/repeat\(/.test(v)) return null;
- return v.replace(/\([^()]*\)/g, 'X').replace(/\[[^\]]*\]/g, ' ').trim().split(/\s+/).filter(Boolean).length;
+ return repeated + v.replace(/\([^()]*\)/g, 'X').replace(/\[[^\]]*\]/g, ' ').trim().split(/\s+/).filter(Boolean).length;
}
/** Whether some container is capped: a max-width paired with auto margins, a max-width of 900px or more, or a Tailwind container or max-w utility. */
diff --git a/lib/design-lint/static.ts b/lib/design-lint/static.ts
index f6977e832..b6a722431 100644
--- a/lib/design-lint/static.ts
+++ b/lib/design-lint/static.ts
@@ -293,13 +293,14 @@ export function shareProjects(contexts: LintContext[]): void {
/**
* The waiver comments of a context, in its documents and every stylesheet it reads, weighed against what its rules found.
- * A stylesheet's claims answer for that stylesheet on every page that links it. In a directory of components (under
- * either scope) each file's claims answer for that file alone, weighed against its own text and its own suspected
- * rules, with the house-style count per file.
+ * A stylesheet's claims answer for that stylesheet on every page that links it. A context that is one html page keeps
+ * page semantics under either scope: its claims answer for the page and the stylesheets it links. In a directory of
+ * components (under either scope) each file's claims answer for that file alone, weighed against its own text and its
+ * own suspected rules, with the house-style count per file.
*/
export function contextWaivers(ctx: LintContext, cwd: string, findings: Finding[] = []): WaiverSet {
const sheets = ctx.cssFiles.flatMap((c) => findWaivers(c.file, c.raw));
- if (ctx.scope !== 'component' && ctx.docs.length <= 1) return resolveWaivers([...ctx.docs.flatMap((d) => findWaivers(d.file, d.raw)), ...sheets], ctx.docs.map((d) => d.text).join(' '), cwd, suspectedRules(findings));
+ if (ctx.docs.length <= 1 && ctx.docs.every((d) => d.kind === 'html')) return resolveWaivers([...ctx.docs.flatMap((d) => findWaivers(d.file, d.raw)), ...sheets], ctx.docs.map((d) => d.text).join(' '), cwd, suspectedRules(findings));
const sets = [
...ctx.docs.map((d) => { const file = oneLine(relPath(d.file, cwd)); return resolveWaivers(findWaivers(d.file, d.raw), d.text, cwd, suspectedRules(findings.filter((f) => f.file === file)), true); }),
resolveWaivers(sheets, '', cwd, suspectedRules(findings)),
diff --git a/lib/design-lint/tailwind.ts b/lib/design-lint/tailwind.ts
index 48d72e483..23f089ff2 100644
--- a/lib/design-lint/tailwind.ts
+++ b/lib/design-lint/tailwind.ts
@@ -373,9 +373,7 @@ export function twTokensOf(doc: Doc, t: Tag): string[] {
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 t of dyn ? clsLits(val) : val.includes('{') ? twInterp(val) : val.split(/\s+/)) out.push(t);
}
for (const d of tag.matchAll(/(?:^|\s)class:(?!list\b)([\w-]+)/g)) out.push(d[1]);
return out.filter(Boolean);
@@ -491,18 +489,27 @@ export function twWhere(it: TwItem): string {
return it.virtual ? `${it.tag}() class string` : `<${it.tag} class="${trunc(it.info.classes.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++) {
+/** Component tags paired once per document: for each open tag the index of its own close tag, -1 when none closes it. */
+function compCloses(doc: Doc): Int32Array {
+ const cached = doc.cache.compCloses as Int32Array | undefined;
+ if (cached) return cached;
+ const closes = new Int32Array(doc.comps.length).fill(-1);
+ const open = new Map();
+ for (let i = 0; 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);
+ if (t.selfClosing) continue;
+ if (!t.close) { const s = open.get(t.name); if (s) s.push(i); else open.set(t.name, [i]); continue; }
+ const s = open.get(t.name);
+ if (s?.length) closes[s.pop()!] = i;
}
- return '';
+ doc.cache.compCloses = closes;
+ return closes;
+}
+
+/** The source between a component tag and its matching close; empty when nothing closes it. */
+export function compInner(doc: Doc, c: number): string {
+ const k = compCloses(doc)[c];
+ return k < 0 ? '' : doc.src.slice(doc.comps[c].end, doc.comps[k].pos);
}
/** Visible text of a fragment, with template expressions blanked in Vue, Svelte and Astro files. */
diff --git a/lib/design-lint/waivers.ts b/lib/design-lint/waivers.ts
index 7744fd2cd..4f05c1f32 100644
--- a/lib/design-lint/waivers.ts
+++ b/lib/design-lint/waivers.ts
@@ -15,8 +15,9 @@
* which a waiver answers like any ask); a reason under 24 characters,
* boilerplate ("by design", "intentional"), reused word for word on another
* waiver, or sharing no word of four letters or more with the page's own
- * text; a second waiver of one rule. More than three waivers on one page is a
- * house style and belongs in DESIGN.md: reported at polish.
+ * text; a second waiver of one rule; a claim on one of the lint's own rules
+ * (waiver, house-style, render, internal-error). More than three waivers on
+ * one page is a house style and belongs in DESIGN.md: reported at polish.
*
* Adapted from Nutlope/hallmark's sloplint.mjs applyWaivers (MIT, see
* NOTICE.md); the guard evidence field is dropped because gstack has no theme