mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-06 08:54:04 +01:00
design-lint: eighth review cycle fixes
Waivers follow the context's shape, not the --scope flag: one html page keeps page semantics under either scope (its claim reaches the sheet it links) and a lone component beside an unlinked stylesheet answers for its own file only. Every '<' that starts no tag is a stray spaced out of doc.src (a '<!' or '<?' with a '>' ahead stays), an unclosed comment runs to the end of the file, the three bare /<[^>]*>/ strips use stripTags, the copy tier drops open script, style, template and nav tags nothing closes, component tags are paired once per document, columnCount counts repeat() arithmetically, an unclosed @starting-style block is read once, the import scan matches import shapes only, the render locator refuses a non-letter tag and reads the class attribute linearly, tickers are answered from the tree, a tooltip duration is not a delay, class tokens are appended in a loop, the emoji dedupe is keyed by the icon, the emoji classes spell U+FE0F and U+200D as escapes, the contrast and focus viewports are named once, and the hero-fold title matches what the probe measures. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
0fb23304a6
commit
a84b030ca0
12 files changed
+113
-82
No files matched your search
+17
-18
@@ -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 `<name`, which must end the name, and the span runs
|
||||
* from that open tag through `</name>` (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
|
||||
* `<name`, which must end the name, and the span runs from that open tag
|
||||
* through `</name>` (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;
|
||||
* `</name>`, 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 `<a <b>`.
|
||||
* browser reads `<a <b>`. Every `<` that starts no tag is a stray, except
|
||||
* a `<!` or `<?` (a doctype, a declaration) with some `>` 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<string, number> = { '"': 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 `<tag\b[^>]*>` regex over it always finds its `>` within the tag and a page whose tail is ten thousand unclosed `<img ` costs its length. */
|
||||
/** The text with each stray `<` replaced by a space, so every `<` left starts a tag with its own `>`, a `<[^>]*>` regex over it costs its length, and a page whose tail is ten thousand unclosed `<img ` or `<1` costs its length. */
|
||||
function blankStrays(s: string, strays: number[]): string {
|
||||
let out = '', from = 0;
|
||||
for (const i of strays) { out += s.slice(from, i) + ' '; from = i + 1; }
|
||||
|
||||
+16
-11
@@ -41,6 +41,8 @@ export interface RenderResult {
|
||||
|
||||
const WIDTHS = [320, 375, 414, 768, 1024, 1280, 1920];
|
||||
const LAPTOP = { width: 1440, height: 900 };
|
||||
/** The viewport of the contrast walk and of the pixel pass that confirms it. */
|
||||
const CONTRAST_VIEW = { width: 1280, height: 800 };
|
||||
const RING_FLOOR = 2.95;
|
||||
const RENDER_TIMEOUT_MS = 120_000;
|
||||
|
||||
@@ -78,10 +80,10 @@ interface Measure { label: string; width: number; height: number; call: string }
|
||||
|
||||
/** The first pass: one eval per viewport, the 1280 one carrying the fold, contrast and accent walks. */
|
||||
function measures(): Measure[] {
|
||||
const m: Measure[] = WIDTHS.map((w) => ({ 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<T>(text: string | undefined): Parsed<T> {
|
||||
}
|
||||
}
|
||||
|
||||
/** 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<void>
|
||||
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<void>
|
||||
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) {
|
||||
|
||||
@@ -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<number>();
|
||||
let n = 0;
|
||||
const flag = (line: number, what: string): void => {
|
||||
if (lines.has(line) || n >= 8) return;
|
||||
const icons = (doc.cache.emojiIcons ??= new Map<string, Finding>()) as Map<string, Finding>;
|
||||
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');
|
||||
|
||||
@@ -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<number>(), arrowLines = new Set<number>();
|
||||
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);
|
||||
|
||||
@@ -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<string, Finding> | 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' });
|
||||
}
|
||||
|
||||
@@ -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<string, number>();
|
||||
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' });
|
||||
|
||||
@@ -66,7 +66,7 @@ export const RULES: Record<string, RuleMeta> = {
|
||||
'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 },
|
||||
|
||||
@@ -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) && /(?<![\d.])(?:\d{3,}ms|[1-9]\d*(?:\.\d+)?s)\b/.test(x.value));
|
||||
if (d) return { file: rule.file, line: d.line };
|
||||
}
|
||||
return null;
|
||||
@@ -329,12 +329,14 @@ function controlHeight(ctx: LintContext, r: Reporter): void {
|
||||
const HIDE_OK = /tooltip|popover|menu|dropdown|modal|dialog|toast|overlay|backdrop|sheet|drawer|panel|:hover|:focus|:checked|:target|\[open|\[aria-(?:expanded|selected|current|pressed)|::before|::after|\.is-|\[hidden|\[aria-hidden|sr-only|visually-hidden|skip|placeholder|::marker|\[data-slot/i;
|
||||
const REVEALISH = /reveal|fade|animate|aos|in-view|inview|appear|enter|rise|slide|stagger|observe|lazy/i;
|
||||
|
||||
// sloplint startingStyleText (gate 62): every @starting-style block with the selector it sits under
|
||||
// sloplint startingStyleText (gate 62): every @starting-style block with the selector it sits under; a block inside one already taken is part of it, so an unclosed block costs the rest of the sheet once
|
||||
function startingStyleText(css: string): string {
|
||||
let out = '';
|
||||
let out = '', taken = 0;
|
||||
for (const m of css.matchAll(/@starting-style\s*\{/gi)) {
|
||||
if (m.index! < taken) continue;
|
||||
let depth = 1, k = m.index! + m[0].length;
|
||||
while (k < css.length && depth > 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) + ' ';
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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)),
|
||||
|
||||
+20
-13
@@ -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<string, number[]>();
|
||||
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. */
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user