mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-01 22:45:02 +01:00
design-lint: third review cycle fixes
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
ddaeabe1f4
commit
6100d6e94d
15 files changed
+193
-123
No files matched your search
@@ -7,6 +7,8 @@
|
||||
* srgbToOklch, contrastRatio; MIT, see NOTICE.md).
|
||||
*/
|
||||
|
||||
import { resolveVars, type Tokens } from './css';
|
||||
|
||||
export interface Rgb { r: number; g: number; b: number }
|
||||
export interface Oklch { L: number; C: number; H: number }
|
||||
export interface Color extends Rgb { alpha: number; okl?: Oklch }
|
||||
@@ -134,3 +136,32 @@ export function contrastRatio(a: Rgb, b: Rgb): number {
|
||||
export const hueIn = (h: number, lo: number, hi: number): boolean => (lo <= hi ? h >= lo && h <= hi : h >= lo || h <= hi);
|
||||
|
||||
export const COLOR_LITERAL = /#[0-9a-fA-F]{3,8}\b|oklch\([^)]*\)|rgba?\([^)]*\)|hsla?\([^)]*\)|color-mix\((?:[^()]|\([^()]*\))*\)|\b(white|black|transparent)\b/i;
|
||||
|
||||
/** Whether the page's paper token resolves to a dark colour (OKLCH lightness under 0.4). */
|
||||
// sloplint paperIsDark (gate 2)
|
||||
export function paperIsDark(tokens: Tokens): boolean {
|
||||
const p = parseColor(resolveVars(tokens['--color-paper'] || '', tokens));
|
||||
return !!p && oklchOf(p).L < 0.4;
|
||||
}
|
||||
|
||||
/** Two indigo-to-violet stops this many degrees apart read as a ramp; closer is one hue shaded. */
|
||||
const INDIGO_VIOLET_SPAN = 12;
|
||||
|
||||
/**
|
||||
* The banned ramp among a gradient's stop hues: purple to blue, purple to
|
||||
* pink, indigo to violet, or cyan on a dark paper. One classifier for a
|
||||
* stylesheet gradient and for Tailwind stops, so both tiers give one answer.
|
||||
*/
|
||||
// sloplint bannedGradient (gate 2a)
|
||||
export function bannedGradient(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';
|
||||
const band = hues.filter((h) => hueIn(h, 250, 330));
|
||||
if (band.length >= 2 && Math.max(...band) - Math.min(...band) >= INDIGO_VIOLET_SPAN) return 'indigo-to-violet';
|
||||
if (cyan && paperIsDark(tokens)) return 'cyan-on-dark';
|
||||
return null;
|
||||
}
|
||||
@@ -9,8 +9,6 @@ import type { Doc } from './html';
|
||||
export interface CssFile {
|
||||
file: string;
|
||||
raw: string;
|
||||
/** Reached through @import or <link>, not named on the command line. */
|
||||
attached?: boolean;
|
||||
}
|
||||
|
||||
/** Project-wide facts shared across a directory of components, so a focus ring in globals.css counts for Hero.tsx. */
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
*/
|
||||
import { docKindOf } from './html';
|
||||
import { lintRender, type RenderOptions } from './render';
|
||||
import { exitCodeOf, finalize, formatFinding, formatSummary, relPath, summarize, type Finding, type Summary } from './report';
|
||||
import { exitCodeOf, finalize, formatFinding, formatSummary, oneLine, relPath, summarize, type Finding, type Summary } from './report';
|
||||
import { lintStaticRaw, settleContext, type StaticOptions } from './static';
|
||||
|
||||
export interface LintOptions extends StaticOptions {
|
||||
@@ -20,6 +20,8 @@ export interface LintOptions extends StaticOptions {
|
||||
urls?: string[];
|
||||
/** Replaces the render engine choice (tests inject one). */
|
||||
engine?: RenderOptions['engine'];
|
||||
/** Replaces the render page runner (tests inject one). */
|
||||
run?: RenderOptions['run'];
|
||||
}
|
||||
|
||||
export interface LintResult {
|
||||
@@ -42,13 +44,13 @@ export async function lint(paths: string[], opts: LintOptions = {}): Promise<Lin
|
||||
const byPage = new Map<string, Finding[]>();
|
||||
if (opts.render || opts.urls?.length) {
|
||||
const pages = stat.files.filter((f) => docKindOf(f) === 'html');
|
||||
const r = await lintRender([...pages, ...(opts.urls ?? [])], { cwd: opts.cwd, scope: opts.scope, engine: opts.engine });
|
||||
const r = await lintRender([...pages, ...(opts.urls ?? [])], { cwd: opts.cwd, scope: opts.scope, engine: opts.engine, run: opts.run });
|
||||
render = r.status;
|
||||
for (const f of r.findings) byPage.set(f.file, [...(byPage.get(f.file) ?? []), f]);
|
||||
}
|
||||
const findings: Finding[] = [];
|
||||
stat.contexts.forEach((ctx, i) => {
|
||||
const page = ctx.docs.length === 1 && ctx.docs[0].kind === 'html' ? relPath(ctx.key, cwd) : null;
|
||||
const page = ctx.docs.length === 1 && ctx.docs[0].kind === 'html' ? oneLine(relPath(ctx.key, cwd)) : null;
|
||||
const measured = page ? byPage.get(page) ?? [] : [];
|
||||
if (page) byPage.delete(page);
|
||||
findings.push(...settleContext(ctx, [...stat.raw[i], ...measured], cwd));
|
||||
|
||||
@@ -29,6 +29,8 @@ export interface RenderOptions {
|
||||
timeoutMs?: number;
|
||||
/** Replaces the engine choice (tests inject one); defaults to lib/aside-render's pickEngine. */
|
||||
engine?: () => EngineChoice;
|
||||
/** Replaces the page runner itself, so every answer the browser could give (none, null, malformed, a probe error) can be settled without a browser. */
|
||||
run?: Runner;
|
||||
}
|
||||
|
||||
export interface RenderResult {
|
||||
@@ -41,13 +43,16 @@ const WIDTHS = [320, 375, 414, 768, 1024, 1280, 1920];
|
||||
const LAPTOP = { width: 1440, height: 900 };
|
||||
const RING_FLOOR = 2.95;
|
||||
|
||||
type Runner = (spec: RenderSpec) => Promise<EngineResult>;
|
||||
export type Runner = (spec: RenderSpec) => Promise<EngineResult>;
|
||||
|
||||
export async function lintRender(targets: string[], opts: RenderOptions = {}): Promise<RenderResult> {
|
||||
if (!targets.length) return { findings: [], status: 'skipped: no HTML page to render' };
|
||||
const choice = (opts.engine ?? pickEngine)();
|
||||
if (!choice.engine) return { findings: [], status: `skipped: ${choice.error}` };
|
||||
const run: Runner = (spec) => (choice.engine === 'browse' ? renderWithBrowse(spec, choice.bin) : render(spec));
|
||||
let run = opts.run;
|
||||
if (!run) {
|
||||
const choice = (opts.engine ?? pickEngine)();
|
||||
if (!choice.engine) return { findings: [], status: `skipped: ${choice.error}` };
|
||||
run = (spec) => (choice.engine === 'browse' ? renderWithBrowse(spec, choice.bin) : render(spec));
|
||||
}
|
||||
const r = new Reporter(opts.scope ?? 'page', opts.cwd ?? process.cwd());
|
||||
for (const target of targets) {
|
||||
try { await lintPage(target, r, run, opts.timeoutMs ?? 120_000); } catch (e) {
|
||||
@@ -163,8 +168,10 @@ async function lintPage(target: string, r: Reporter, run: Runner, timeoutMs: num
|
||||
});
|
||||
const scrolls = found.filter((f) => f.rule === 'horizontal-scroll' && !f.at);
|
||||
if (scrolls.length > 1) {
|
||||
scrolls[0].evidence += ` (also at ${scrolls.slice(1).map((f) => f.label.replace(/px$/, '')).join(', ')}px)`;
|
||||
for (const f of scrolls.slice(1)) found.splice(found.indexOf(f), 1);
|
||||
const keep = scrolls.find((f) => f.grade === 'fail') ?? scrolls[0];
|
||||
const rest = scrolls.filter((f) => f !== keep);
|
||||
keep.evidence += ` (also at ${rest.map((f) => f.label.replace(/px$/, '')).join(', ')}px)`;
|
||||
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);
|
||||
|
||||
@@ -14,7 +14,6 @@ import { trunc, type Reporter } from '../report';
|
||||
|
||||
const EMOJI = /(?![©®™‼⁉←-⇿])\p{Extended_Pictographic}/u;
|
||||
const GLYPH = /(?![©®™‼⁉←-⇿✓-✖])(?:\p{Emoji_Presentation}|\p{Extended_Pictographic}️)/u;
|
||||
const lineAt = (text: string, pos: number): number => text.slice(0, pos).split('\n').length;
|
||||
|
||||
// sloplint check30 (gate 30a)
|
||||
function checkEmojiIcons(ctx: LintContext, r: Reporter): void {
|
||||
@@ -48,9 +47,9 @@ function checkEmojiIcons(ctx: LintContext, r: Reporter): void {
|
||||
}
|
||||
const raw = doc.raw;
|
||||
const body = doc.kind === 'html' ? doc.masked : raw;
|
||||
for (const m of body.matchAll(/>\s*((?:\p{Emoji_Presentation}|\p{Extended_Pictographic}️)[^<>{}\n]{0,40})/gu)) flag(lineAt(body, m.index! + m[0].length - m[1].length), 'text opens with an emoji: "' + trunc(m[1].trim(), 32) + '"');
|
||||
for (const m of raw.matchAll(/placeholder\s*=\s*["']([^"']*)["']/g)) if (GLYPH.test(m[1])) flag(lineAt(raw, 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(lineAt(raw, m.index!), 'an emoji passed as an icon: ' + m[2]);
|
||||
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 raw.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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,9 +64,27 @@ const ICON_LIBS = new Map<string, RegExp>([
|
||||
['bootstrap-icons', /bootstrap-icons|\bbi-[a-z]/i],
|
||||
]);
|
||||
|
||||
/** The class attributes, stylesheet and script references, and @import and url() targets of a context: where an icon library shows, prose left out. */
|
||||
function iconSurfaces(ctx: LintContext): string {
|
||||
const out: string[] = [];
|
||||
for (const doc of ctx.docs) {
|
||||
for (const t of [...doc.tags, ...doc.comps]) {
|
||||
if (t.close) continue;
|
||||
const cls = getAttr(t.attrs, 'class') ?? getAttr(t.attrs, 'className');
|
||||
if (cls) out.push(cls);
|
||||
if (t.name === 'link' || t.name === 'script' || t.name === 'use') out.push(getAttr(t.attrs, 'href') ?? getAttr(t.attrs, 'src') ?? '');
|
||||
}
|
||||
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 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');
|
||||
}
|
||||
|
||||
// sloplint check30 (gate 30b)
|
||||
function checkIconLibraries(ctx: LintContext, r: Reporter): void {
|
||||
const hay = ctx.docs.map((d) => d.src).join('\n') + ctx.cssFiles.map((f) => f.raw).join('\n');
|
||||
const hay = iconSurfaces(ctx);
|
||||
const found = [...ICON_LIBS].filter(([, re]) => re.test(hay)).map(([name]) => name);
|
||||
if (found.length < 2) return;
|
||||
const file = ctx.docs[0]?.file || ctx.cssFiles[0]?.file;
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
import { styleFor, type LintContext } from '../context';
|
||||
import { resolveVars } from '../css';
|
||||
import { ancestorsOf, getAttr, innerOf, maskedProse, plainText, type Doc, type Tag } from '../html';
|
||||
import { ancestorsOf, getAttr, innerOf, maskedProse, plainText, treeOf, type Doc, type Tag } from '../html';
|
||||
import { trunc, type Reporter } from '../report';
|
||||
|
||||
|
||||
@@ -40,9 +40,16 @@ function checkHeroCopy(ctx: LintContext, r: Reporter): void {
|
||||
lines.add(line);
|
||||
r.report(rule, doc.file, line, `${what}: "${trunc(text, 40)}"`);
|
||||
};
|
||||
const hero = heroRange(doc);
|
||||
for (const [re, what] of HERO_TELLS) {
|
||||
const m = re.exec(prose);
|
||||
if (m) tell('generic-hero-copy', m.index, what, m[0]);
|
||||
const all = [...prose.matchAll(new RegExp(re.source, re.flags + 'g'))];
|
||||
const inHero = all.find((m) => hero && m.index! >= hero[0] && m.index! < hero[1]);
|
||||
const m = inHero ?? all[0];
|
||||
if (!m) continue;
|
||||
const line = doc.lineOf(m.index!);
|
||||
if (lines.has(line)) continue;
|
||||
lines.add(line);
|
||||
r.report('generic-hero-copy', doc.file, line, `${what}: "${trunc(m[0], 40)}"`, inHero ? {} : { suspected: true, fix: 'a line in the product\'s own voice; in body copy this is a suspicion, so a waiver can answer it' });
|
||||
}
|
||||
const footer = FOOTER_LINE.exec(prose);
|
||||
if (footer) tell('template-footer-line', footer.index, 'a stock footer line', footer[0]);
|
||||
@@ -91,6 +98,17 @@ const SECOND_CTA = /^(learn more|see how it works|watch (the )?demo|get started|
|
||||
|
||||
/** The <section> or <header> that holds the h1, by tag index; -1 when none does. */
|
||||
// sloplint heroIndexOf
|
||||
/** The source range the hero copy lives in: the section or header holding the h1, else the h1's parent; null without an h1. A tell here is the Floor, elsewhere it is suspected. */
|
||||
function heroRange(doc: Doc): [number, number] | null {
|
||||
const h1 = doc.tags.findIndex((t) => !t.close && t.name === 'h1');
|
||||
if (h1 < 0) return null;
|
||||
const heroIdx = heroIndexOf(doc, h1);
|
||||
const holder = heroIdx >= 0 ? heroIdx : treeOf(doc).parent[h1];
|
||||
if (holder < 0) return null;
|
||||
const inner = innerOf(doc, holder);
|
||||
return inner ? [doc.tags[holder].end, inner.endPos] : null;
|
||||
}
|
||||
|
||||
export function heroIndexOf(doc: Doc, h1Idx: number): number {
|
||||
for (let i = h1Idx - 1; i >= 0; i--) {
|
||||
const t = doc.tags[i];
|
||||
|
||||
@@ -217,7 +217,7 @@ function checkWireframeSlots(ctx: LintContext, r: Reporter): void {
|
||||
}
|
||||
}
|
||||
|
||||
const FACT = /(?:[£$€¥]\s?\d[\d,.]*|\d[\d,.]*\s?(?:pm|am|MAD|kg|g|ml|km|mi|miles|%)\b|\b\d{1,2}:\d{2}\b)/gi;
|
||||
const FACT = /(?:[£$€¥]\s?\d[\d,.]*|\d[\d,.]*\s?(?:%|(?:pm|am|MAD|kg|g|ml|km|mi|miles)\b)|\b\d{1,2}:\d{2}\b)/gi;
|
||||
const REGION = /^(section|header|footer|article|aside|nav|form|dialog)$/;
|
||||
|
||||
// sloplint check69 (gate 69)
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* genre exempts a page, no theme is pinned by name, so every check here runs
|
||||
* in its strict form.
|
||||
*/
|
||||
import { COLOR_LITERAL, hueIn, oklchOf, parseColor } from '../color';
|
||||
import { bannedGradient, COLOR_LITERAL, hueIn, oklchOf, parseColor } from '../color';
|
||||
import type { LintContext } from '../context';
|
||||
import { mergedRules } from '../context';
|
||||
import { firstFamily, GENERIC_FAMILIES, isTokenRule, pxOf, resolveVars, splitTop, type CssDecl, type MergedRule, type Tokens } from '../css';
|
||||
@@ -27,25 +27,6 @@ function gradientHues(value: string, tokens: Tokens): number[] {
|
||||
return hues;
|
||||
}
|
||||
|
||||
// sloplint paperIsDark (gate 2)
|
||||
function paperIsDark(tokens: Tokens): boolean {
|
||||
const p = parseColor(resolveVars(tokens['--color-paper'] || '', tokens));
|
||||
return !!p && oklchOf(p).L < 0.4;
|
||||
}
|
||||
|
||||
// sloplint bannedGradient (gate 2a)
|
||||
function bannedGradient(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 (new Set(hues.filter((h) => hueIn(h, 250, 330)).map((h) => Math.round(h / 4))).size >= 2) return 'indigo-to-violet';
|
||||
if (cyan && paperIsDark(tokens)) return 'cyan-on-dark';
|
||||
return null;
|
||||
}
|
||||
|
||||
interface GradientText { r: MergedRule; clip: CssDecl; grad: CssDecl | undefined }
|
||||
|
||||
// sloplint gradientTextRules (gate 2b)
|
||||
|
||||
@@ -177,6 +177,9 @@ function autorotate(ctx: LintContext, r: Reporter): void {
|
||||
}
|
||||
|
||||
// sloplint check26 (gates 26a, 26b); ctx.shared carries a project's global state styles
|
||||
/** An outline value that takes the ring away: none, or a zero width; `0.25rem solid` is a ring. */
|
||||
const OUTLINE_REMOVED = /^(?:none|0(?:px|rem|em)?)(?![\w.%])/i;
|
||||
|
||||
function focusAndStates(ctx: LintContext, r: Reporter): void {
|
||||
const hasInteractive = ctx.docs.some((doc) => /<(a|button|input|select|textarea)\b|role\s*=\s*["']button/i.test(doc.src))
|
||||
|| ctx.rules.some((rule) => selectorIsInteractive(rule.selector));
|
||||
@@ -188,10 +191,10 @@ function focusAndStates(ctx: LintContext, r: Reporter): void {
|
||||
if (!has(':focus-visible') && !has(':focus') && !shared?.focus && ![...tv].some((v) => /^focus(?:-visible|-within)?$/.test(v)) && once(ctx, '26a')) {
|
||||
r.report('missing-focus-visible', file, 1, 'no :focus-visible style anywhere: keyboard users cannot see where they are');
|
||||
}
|
||||
const ringDecl = (d: { prop: string; value: string }) => (/^outline(-style|-width|-color)?$/.test(d.prop) && !/^(none|0|0px)\b/i.test(d.value.trim())) || (d.prop === 'box-shadow' && !/^none\b/i.test(d.value.trim()));
|
||||
const ringDecl = (d: { prop: string; value: string }) => (/^outline(-style|-width|-color)?$/.test(d.prop) && !OUTLINE_REMOVED.test(d.value.trim())) || (d.prop === 'box-shadow' && !/^none\b/i.test(d.value.trim()));
|
||||
const ringAnywhere = ctx.rules.some((rule) => /:focus/.test(rule.selector) && rule.decls.some(ringDecl)) || !!shared?.focus || [...tv].some((v) => /^focus(?:-visible|-within)?$/.test(v));
|
||||
if (!ringAnywhere) {
|
||||
const removed = ctx.rules.find((rule) => /:focus/.test(rule.selector) && rule.decls.some((d) => /^outline(-style)?$/.test(d.prop) && /^(none|0|0px)\b/i.test(d.value.trim())));
|
||||
const removed = ctx.rules.find((rule) => /:focus/.test(rule.selector) && rule.decls.some((d) => /^outline(-style)?$/.test(d.prop) && OUTLINE_REMOVED.test(d.value.trim())));
|
||||
const outlineD = removed?.decls.find((d) => /^outline(-style)?$/.test(d.prop));
|
||||
if (removed && outlineD && once(ctx, '26a:removed')) r.report('missing-focus-visible', removed.file, outlineD.line, `${outlineD.prop}: ${outlineD.value.trim()} on ${trunc(removed.selector, 40)} with nothing in its place: keyboard users cannot see where they are`, { fix: 'a :focus-visible ring at 3:1 or better, instant; never remove an outline without putting one back' });
|
||||
}
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
* double-sticky, screen-word-budget. Gate 59 (section-gap) has no static check: the render tier owns it (render-probe-space.ts glGaps), as it owns
|
||||
* the render halves of 34 (horizontal-scroll) and 44d.
|
||||
*/
|
||||
import { isTokenRule, pxOf, resolveVars } from '../css';
|
||||
import { mergedRules, type LintContext } from '../context';
|
||||
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, plainText, treeOf, wordsOf, type Doc } from '../html';
|
||||
import { trunc, type Reporter } from '../report';
|
||||
import { twGroup } from '../tailwind';
|
||||
@@ -20,18 +21,27 @@ const stripVariants = (cls: string): string => cls.replace(/^(?:[\w-]+:)+/, '');
|
||||
|
||||
/** The inner HTML of every open element in the context whose class attribute carries one of the classes. */
|
||||
function innerHtmlWithClass(ctx: LintContext, classes: string[]): string[] {
|
||||
const out: string[] = [];
|
||||
if (!classes.length) return out;
|
||||
for (const doc of ctx.docs) {
|
||||
for (let i = 0; i < doc.tags.length; i++) {
|
||||
const t = doc.tags[i];
|
||||
if (t.close || t.selfClosing) continue;
|
||||
const have = (getAttr(t.attrs, 'class') || '').split(/\s+/);
|
||||
if (!classes.some((c) => have.includes(c))) continue;
|
||||
const inner = innerOf(doc, i);
|
||||
if (inner) out.push(inner.inner);
|
||||
if (!classes.length) return [];
|
||||
const byClass = memo(ctx, 'innerByClass', () => {
|
||||
const index = new Map<string, string[]>();
|
||||
for (const doc of ctx.docs) {
|
||||
for (let i = 0; i < doc.tags.length; i++) {
|
||||
const t = doc.tags[i];
|
||||
if (t.close || t.selfClosing) continue;
|
||||
const inner = innerOf(doc, i);
|
||||
if (!inner) continue;
|
||||
for (const c of new Set((getAttr(t.attrs, 'class') || '').split(/\s+/).filter(Boolean))) {
|
||||
let list = index.get(c);
|
||||
if (!list) index.set(c, list = []);
|
||||
list.push(inner.inner);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return index;
|
||||
});
|
||||
const out: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const c of classes) for (const inner of byClass.get(c) ?? []) if (!seen.has(inner)) { seen.add(inner); out.push(inner); }
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -59,7 +69,12 @@ function sideStripes(ctx: LintContext, r: Reporter): void {
|
||||
for (const d of rule.decls) {
|
||||
if (!/^border-(left|right|inline-start|inline-end)$/.test(d.prop)) continue;
|
||||
const m = /(\d+(?:\.\d+)?)px/.exec(d.value);
|
||||
if (m && parseFloat(m[1]) >= 3 && !/transparent|none/i.test(d.value)) {
|
||||
if (!m || parseFloat(m[1]) < 3 || /transparent|none/i.test(d.value)) continue;
|
||||
const col = COLOR_LITERAL.exec(resolveVars(d.value, ctx.tokens));
|
||||
const parsed = col ? parseColor(col[0]) : null;
|
||||
const ok = parsed ? oklchOf(parsed) : null;
|
||||
if (parsed && ok && (parsed.alpha <= 0.05 || (ok.C < 0.04 && ok.L > 0.6))) continue;
|
||||
{
|
||||
r.report('template-cards', rule.file, d.line, `a ${m[1]}px coloured side stripe (${d.prop}: ${trunc(d.value, 32)}) on ${rule.selector}`, { fix: 'a hairline all round, no border, or an accent square' });
|
||||
}
|
||||
}
|
||||
@@ -270,9 +285,12 @@ function radioTabJump(ctx: LintContext, r: Reporter): void {
|
||||
|
||||
// sloplint check56 (gate 56)
|
||||
function doubleSticky(ctx: LintContext, r: Reporter): void {
|
||||
const sticky = mergedRules(ctx).filter((m) =>
|
||||
m.decls.some((d) => d.prop === 'position' && /sticky/.test(d.value))
|
||||
&& m.decls.some((d) => d.prop === 'top' && /^(0|0px|0rem|-1px)$/.test(resolveVars(d.value, ctx.tokens).trim())));
|
||||
const bySelector = new Map<string, MergedRule>();
|
||||
for (const m of mergedRules(ctx)) {
|
||||
if (m.decls.some((d) => d.prop === 'position' && /sticky/.test(d.value))
|
||||
&& m.decls.some((d) => d.prop === 'top' && /^(0|0px|0rem|-1px)$/.test(resolveVars(d.value, ctx.tokens).trim())) && !bySelector.has(m.selector)) bySelector.set(m.selector, m);
|
||||
}
|
||||
const sticky = [...bySelector.values()];
|
||||
if (sticky.length < 2) return;
|
||||
const sels = sticky.map((m) => m.selector).join(' and ');
|
||||
r.report('double-sticky', sticky[1].file, sticky[1].line, `two elements stick at top: 0 (${trunc(sels, 52)}): the second slides under the first`);
|
||||
|
||||
@@ -135,8 +135,11 @@ function check38a(ctx: LintContext, r: Reporter): void {
|
||||
for (const doc of ctx.docs) {
|
||||
for (const m of doc.src.matchAll(/<h([1-6])\b([^>]*)>([\s\S]*?)<\/h\1>/gi)) {
|
||||
const classes = ((/class\s*=\s*["']([^"']*)["']/i.exec(m[2]) || [])[1] || '').split(/\s+/).filter(Boolean);
|
||||
const tag = /<(em|i)\b/i.exec(m[3]);
|
||||
if (tag && !normalised(m[1], classes)) r.report('italic-word-in-heading', doc.file, doc.lineOf(m.index!), `an <${tag[1].toLowerCase()}> inside an otherwise roman <h${m[1]}>`);
|
||||
const tag = /<(em|i)\b[^>]*>([\s\S]*?)<\/\1\s*>/i.exec(m[3]);
|
||||
if (!tag || normalised(m[1], classes)) continue;
|
||||
const italic = plainText(tag[2]);
|
||||
if (!italic || italic.length >= 0.8 * plainText(m[3]).length) continue;
|
||||
r.report('italic-word-in-heading', doc.file, doc.lineOf(m.index!), `an <${tag[1].toLowerCase()}> inside an otherwise roman <h${m[1]}>`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+17
-12
@@ -40,22 +40,29 @@ export interface RawStaticResult {
|
||||
|
||||
const BUILD_DIRS = new Set(['dist', 'build', 'coverage']);
|
||||
|
||||
/** Every .css page and component file under the paths; node_modules, dot folders and (below the top) build output are skipped. */
|
||||
/** A source file past this size is a bundle or a generated artefact, not a page to read; it is skipped with a warning so the lint answers in bounded time. */
|
||||
export const MAX_FILE_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
/** Every .css page and component file under the paths; node_modules, dot folders and (below the top) build output are skipped, a link cycle is walked once, and a file over MAX_FILE_BYTES is skipped with a warning. */
|
||||
export function collectFiles(paths: string[], warn: (l: string) => void = () => {}): string[] {
|
||||
const files: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
const visit = (p: string, top: boolean) => {
|
||||
const abs = path.resolve(p);
|
||||
if (seen.has(abs)) return;
|
||||
let abs = path.resolve(p);
|
||||
let st: fs.Stats;
|
||||
try { st = fs.statSync(abs); } catch { warn(`design-lint: cannot read ${p}`); return; }
|
||||
try { abs = fs.realpathSync(abs); st = fs.statSync(abs); } catch { warn(`design-lint: cannot read ${p}`); return; }
|
||||
if (seen.has(abs)) return;
|
||||
if (st.isDirectory()) {
|
||||
seen.add(abs);
|
||||
const base = path.basename(abs);
|
||||
if (base === 'node_modules' || base.startsWith('.') || (!top && BUILD_DIRS.has(base))) return;
|
||||
for (const e of fs.readdirSync(abs).sort()) visit(path.join(abs, e), false);
|
||||
return;
|
||||
}
|
||||
if (path.extname(abs).toLowerCase() === '.css' || docKindOf(abs)) { seen.add(abs); files.push(abs); }
|
||||
if (path.extname(abs).toLowerCase() !== '.css' && !docKindOf(abs)) return;
|
||||
seen.add(abs);
|
||||
if (st.size > MAX_FILE_BYTES) { warn(`design-lint: skipped ${p} (${Math.round(st.size / 1024 / 1024)} MB, over the ${MAX_FILE_BYTES / 1024 / 1024} MB limit)`); return; }
|
||||
files.push(abs);
|
||||
};
|
||||
for (const p of paths) visit(p, true);
|
||||
return files;
|
||||
@@ -130,7 +137,7 @@ function attachStylesheets(cssFiles: CssFile[], rules: CssRule[], pending: strin
|
||||
inGroup.add(p);
|
||||
const got = cache.read(p);
|
||||
if (!got) continue;
|
||||
cssFiles.push({ file: p, raw: got.raw, attached: true });
|
||||
cssFiles.push({ file: p, raw: got.raw });
|
||||
rules.push(...got.rules);
|
||||
pending.push(...got.imports);
|
||||
}
|
||||
@@ -222,13 +229,11 @@ export function groupContexts(files: string[], opts: StaticOptions, warn: (l: st
|
||||
return contexts;
|
||||
}
|
||||
|
||||
const projectRoots = new Map<string, string | null>();
|
||||
function projectRoot(dir: string): string | null {
|
||||
if (projectRoots.has(dir)) return projectRoots.get(dir)!;
|
||||
const up = path.dirname(dir);
|
||||
const root = fs.existsSync(path.join(dir, 'package.json')) ? dir : up === dir ? null : projectRoot(up);
|
||||
projectRoots.set(dir, root);
|
||||
return root;
|
||||
for (let d = dir; ; d = path.dirname(d)) {
|
||||
if (fs.existsSync(path.join(d, 'package.json'))) return d;
|
||||
if (path.dirname(d) === d) return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
*/
|
||||
import type { LintContext } from './context';
|
||||
import { memo } from './context';
|
||||
import { COLOR_LITERAL, hueIn, oklchOf, parseColor, type Color } from './color';
|
||||
import { bannedGradient, COLOR_LITERAL, oklchOf, parseColor, type Color } from './color';
|
||||
import { GENERIC_FAMILIES, LABEL_PX, MONO_FACE, TRACKED_EM, 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';
|
||||
@@ -194,8 +194,6 @@ export interface TwInfo {
|
||||
animate: string | null;
|
||||
transform: boolean;
|
||||
transition: boolean;
|
||||
caret: boolean;
|
||||
scrollbar: boolean;
|
||||
hero: boolean;
|
||||
padded: boolean;
|
||||
}
|
||||
@@ -309,8 +307,7 @@ function twReadSurface(ctx: LintContext, b: string, tok: string, p: TwParsed, f:
|
||||
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;
|
||||
if (b.startsWith('caret-')) return;
|
||||
}
|
||||
|
||||
/** Read one class string into its facts; the variants it uses land in the shared set as well as on the info. */
|
||||
@@ -319,7 +316,7 @@ export function twInfo(ctx: LintContext, toks: string[], variants: Set<string>):
|
||||
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,
|
||||
colours: [], font: null, pal: [], rounded: false, cols3: false, animate: null, transform: false, transition: false, hero: false, padded: false,
|
||||
};
|
||||
const acc: TwAcc = { sizeV: null, tracks: [] };
|
||||
for (const tok of toks) {
|
||||
@@ -447,31 +444,23 @@ export interface TwItem {
|
||||
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, component tag and helper call in the context, parsed. */
|
||||
/** Give a component context the project's tokens and drop the Tailwind facts read before they were known. */
|
||||
export function setTwTokens(ctx: LintContext, tokens: Tokens): void {
|
||||
ctx.twTokens = tokens;
|
||||
delete ctx.cache.tw;
|
||||
}
|
||||
|
||||
/** Every class-bearing element, component tag and helper call in the context, parsed. */
|
||||
export function twGroup(ctx: LintContext): TwGroup {
|
||||
return memo(ctx, 'tw', () => {
|
||||
const tw: TwGroup = { items: [], variants: new Set<string>(), caret: false, scrollbar: false, motion: null, upperDisplay: false };
|
||||
const tw: TwGroup = { items: [], variants: new Set<string>(), 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;
|
||||
if (/^h[12]$/.test(it.tag) && it.info.upper) tw.upperDisplay = true;
|
||||
};
|
||||
for (const doc of ctx.docs) {
|
||||
const stack: TwFrame[] = [];
|
||||
@@ -544,23 +533,6 @@ export function twText(it: TwItem): string {
|
||||
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. */
|
||||
@@ -573,7 +545,7 @@ export function twGradient(ctx: LintContext, f: TwInfo): TwGradient | null {
|
||||
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));
|
||||
if (!banned) banned = bannedGradient(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(' ') };
|
||||
}
|
||||
|
||||
|
||||
+21
-10
@@ -35,9 +35,15 @@ export interface WaiverClaim {
|
||||
raw: string;
|
||||
}
|
||||
|
||||
export interface Waiver {
|
||||
reason: string;
|
||||
/** The stylesheet (as printed) a claim written in a stylesheet answers for; null for a claim in the page, which answers for the whole page. */
|
||||
sheet: string | null;
|
||||
}
|
||||
|
||||
export interface WaiverSet {
|
||||
/** Rule ids the page waived, each with its recorded reason. */
|
||||
accepted: Map<string, string>;
|
||||
accepted: Map<string, Waiver>;
|
||||
/** Refused waivers, already shaped as findings. */
|
||||
refused: Finding[];
|
||||
}
|
||||
@@ -45,7 +51,8 @@ export interface WaiverSet {
|
||||
export const WAIVER_MIN_REASON = 24;
|
||||
export const WAIVER_MAX = 3;
|
||||
|
||||
const WAIVER_RE = /(?:gstack-lint-disable|impeccable-disable)\s+([a-z][a-z0-9-]*)\s*:\s*([^\r\n]*)/g;
|
||||
/** The reason runs to the end of its comment or line, so two waivers on one line are both read. */
|
||||
const WAIVER_RE = /(?:gstack-lint-disable|impeccable-disable)\s+([a-z][a-z0-9-]*)\s*:\s*([^\r\n]*?)(?=-->|\*\/|[\r\n]|$)/g;
|
||||
const WAIVER_REASON_MAX = 300;
|
||||
|
||||
const WAIVER_FILLER = /\b(a|an|the|is|it|this|that|was|are|and|but|so|very|just|really|here|quite|more|most)\b/gi;
|
||||
@@ -68,7 +75,7 @@ export function findWaivers(file: string, source: string): WaiverClaim[] {
|
||||
const out: WaiverClaim[] = [];
|
||||
const lineOf = lineIndex(source);
|
||||
for (const m of source.matchAll(WAIVER_RE)) {
|
||||
const reason = m[2].replace(/(?:-->|\*\/).*$/, '').trim().slice(0, WAIVER_REASON_MAX);
|
||||
const reason = m[2].trim().slice(0, WAIVER_REASON_MAX);
|
||||
out.push({ rule: m[1], reason, file, line: lineOf(m.index!), raw: m[0] });
|
||||
}
|
||||
return out;
|
||||
@@ -78,6 +85,7 @@ const pageWordsOf = (text: string): Set<string> =>
|
||||
new Set((text.toLowerCase().replace(WAIVER_RE, ' ').match(/[a-z]{4,}/g) || []));
|
||||
|
||||
export const FLOOR_REFUSAL = 'it blocks; the Floor is never waivable';
|
||||
export const OWN_RULE_REFUSAL = "the lint's own rule, never waivable";
|
||||
|
||||
/** The `waiver` finding for a claim the lint refused. */
|
||||
function refusal(c: WaiverClaim, why: string, cwd = process.cwd()): Finding {
|
||||
@@ -107,7 +115,7 @@ export function suspectedRules(findings: Finding[]): Set<string> {
|
||||
* blocking rule, measured or not found, is refused.
|
||||
*/
|
||||
export function resolveWaivers(claims: WaiverClaim[], pageText: string, cwd = process.cwd(), suspected: ReadonlySet<string> = new Set()): WaiverSet {
|
||||
const accepted = new Map<string, string>();
|
||||
const accepted = new Map<string, Waiver>();
|
||||
const refused: Finding[] = [];
|
||||
const pageWords = pageWordsOf(pageText);
|
||||
const reasons = new Map<string, number>();
|
||||
@@ -115,15 +123,17 @@ export function resolveWaivers(claims: WaiverClaim[], pageText: string, cwd = pr
|
||||
const refuse = (c: WaiverClaim, why: string) => refused.push(refusal(c, why, cwd));
|
||||
for (const c of claims) {
|
||||
const meta = ruleMeta(c.rule);
|
||||
if (!meta || meta.gate === 'waiver' || meta.gate === 'render' || meta.gate === 'lint') { refuse(c, 'no such rule'); continue; }
|
||||
if (!meta) { refuse(c, 'no such rule'); continue; }
|
||||
if (meta.gate === 'waiver' || meta.gate === 'render' || meta.gate === 'lint') { refuse(c, OWN_RULE_REFUSAL); continue; }
|
||||
if (meta.tier === 'blocks' && !suspected.has(c.rule)) { refuse(c, FLOOR_REFUSAL); continue; }
|
||||
if (accepted.has(c.rule)) { refuse(c, 'one waiver per rule'); continue; }
|
||||
if (c.reason.length < WAIVER_MIN_REASON) { refuse(c, `reason under ${WAIVER_MIN_REASON} characters`); continue; }
|
||||
if (reasonIsBoilerplate(c.reason)) { refuse(c, 'boilerplate reason'); continue; }
|
||||
if ((reasons.get(c.reason.toLowerCase()) || 0) > 1) { refuse(c, 'reason reused on another waiver'); continue; }
|
||||
const words = c.reason.toLowerCase().match(/[a-z]{4,}/g) || [];
|
||||
if (pageWords.size && !/\.css$/i.test(c.file) && !words.some((w) => pageWords.has(w))) { refuse(c, 'reason shares no word with the page'); continue; }
|
||||
accepted.set(c.rule, c.reason);
|
||||
const sheet = /\.css$/i.test(c.file);
|
||||
if (pageWords.size && !sheet && !words.some((w) => pageWords.has(w))) { refuse(c, 'reason shares no word with the page'); continue; }
|
||||
accepted.set(c.rule, { reason: c.reason, sheet: sheet ? relPath(c.file, cwd) : null });
|
||||
}
|
||||
if (accepted.size > WAIVER_MAX) {
|
||||
const first = claims[0];
|
||||
@@ -137,11 +147,12 @@ export function resolveWaivers(claims: WaiverClaim[], pageText: string, cwd = pr
|
||||
return { accepted, refused };
|
||||
}
|
||||
|
||||
/** Mark waived ask and polish findings; blocking findings never change. Returns the findings plus the refusals. */
|
||||
/** Mark waived ask and polish findings; blocking findings never change, and a stylesheet's claim reaches only findings in that stylesheet. Returns the findings plus the refusals. */
|
||||
export function applyWaivers(findings: Finding[], set: WaiverSet): Finding[] {
|
||||
const out = findings.map((f) => {
|
||||
if (f.tier === 'blocks' || !set.accepted.has(f.rule)) return f;
|
||||
return { ...f, grade: 'waived' as const, waiver: oneLine(set.accepted.get(f.rule) ?? '') };
|
||||
const w = set.accepted.get(f.rule);
|
||||
if (f.tier === 'blocks' || !w || (w.sheet && f.file !== w.sheet)) return f;
|
||||
return { ...f, grade: 'waived' as const, waiver: oneLine(w.reason) };
|
||||
});
|
||||
return [...out, ...set.refused];
|
||||
}
|
||||
@@ -238,9 +238,9 @@ describe('design-lint: the static tier\'s files and contexts', () => {
|
||||
|
||||
const { contexts } = lintStatic([path.join(dir, 'site')], { cwd: dir, warn: (l) => { throw new Error(l); } });
|
||||
expect(contexts).toHaveLength(1);
|
||||
expect(contexts[0].cssFiles.map((c) => [path.relative(dir, c.file), c.attached ?? false])).toEqual([['site/local.css', false], ['shared/theme.css', true]]);
|
||||
expect(contexts[0].cssFiles.map((c) => path.relative(dir, c.file))).toEqual(['site/local.css', 'shared/theme.css']);
|
||||
const set = contextWaivers(contexts[0], dir);
|
||||
expect([...set.accepted.entries()]).toEqual([['section-gap', 'the ledger separates its rows with rules'], ['template-nav', 'the ledger keeps three links for its three modes']]);
|
||||
expect([...set.accepted.entries()]).toEqual([['section-gap', { reason: 'the ledger separates its rows with rules', sheet: 'site/local.css' }], ['template-nav', { reason: 'the ledger keeps three links for its three modes', sheet: 'shared/theme.css' }]]);
|
||||
expect(set.refused).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -365,7 +365,7 @@ describe('design-lint: hardening (ship review)', () => {
|
||||
});
|
||||
const { contexts, findings } = lintStatic([path.join(dir, 'site')], { cwd: dir, warn: () => {} });
|
||||
const page = contexts.find((c) => c.key.endsWith('index.html'))!;
|
||||
expect(page.cssFiles.map((c) => [path.relative(dir, c.file), c.attached ?? false])).toEqual([['site/css/theme.css', true]]);
|
||||
expect(page.cssFiles.map((c) => path.relative(dir, c.file))).toEqual(['site/css/theme.css']);
|
||||
expect(findings.filter((f) => f.rule === 'overflow-x-unclipped')).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -374,7 +374,7 @@ describe('design-lint: hardening (ship review)', () => {
|
||||
for (let i = 0; i < 12; i++) files[`pages/p${i}.html`] = `<!doctype html><html lang="en"><head><title>P${i}</title><link rel="stylesheet" href="../css/site.css"></head><body><main><h1>Page ${i}</h1><p>Hello there.</p></main></body></html>`;
|
||||
const dir = tmpTree(files);
|
||||
const r = lintStatic([dir], { cwd: dir, warn: () => {} });
|
||||
expect(r.contexts.filter((c) => c.cssFiles.some((f) => f.file.endsWith('site.css') && f.attached))).toHaveLength(12);
|
||||
expect(r.contexts.filter((c) => c.cssFiles.some((f) => f.file.endsWith('site.css')))).toHaveLength(12);
|
||||
expect(r.contexts.filter((c) => c.key.endsWith('css'))).toEqual([]);
|
||||
const rulesOf = (c: LintContext) => c.rules.filter((x) => x.file.endsWith('site.css'));
|
||||
expect(rulesOf(r.contexts[0])[0]).toBe(rulesOf(r.contexts[1])[0]);
|
||||
@@ -464,7 +464,11 @@ describe('design-lint: hardening (ship review)', () => {
|
||||
for (const rule of ['internal-error', 'waiver', 'house-style', 'render']) {
|
||||
const set = resolveWaivers([{ rule, reason: 'the manifesto sets long declarative lines', file: 'a.html', line: 1, raw: '' }], page);
|
||||
expect([...set.accepted.keys()]).toEqual([]);
|
||||
expect(set.refused.map((f) => f.evidence)).toEqual([`waiver on ${rule} refused: no such rule`]);
|
||||
expect(set.refused.map((f) => f.evidence)).toEqual([`waiver on ${rule} refused: the lint's own rule, never waivable`]);
|
||||
}
|
||||
const unknown = resolveWaivers([{ rule: 'no-such-thing', reason: 'the manifesto sets long declarative lines', file: 'a.html', line: 1, raw: '' }], page);
|
||||
expect(unknown.refused.map((f) => f.evidence)).toEqual(['waiver on no-such-thing refused: no such rule']);
|
||||
{
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user