mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-01 22:45:02 +01:00
feat(design-lint): chrome, assets and finish families merged; maskedProse moves to html.ts; isTokenRule takes a Pick; ratchet watches every lint module
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
69a7ec0e82
commit
22d39449d1
11 files changed
+52
-40
No files matched your search
@@ -155,7 +155,7 @@ export type Tokens = Record<string, string>;
|
||||
const TOKEN_SEL = /^(?:html|:root)?(?:\[data-(?:theme|mode|scheme)[^\]]*\]|:root|:not\([^)]*\))*$/i;
|
||||
|
||||
/** Whether a rule belongs to the token block: :root / html / a data-theme scope, @font-face, @theme, or anything in tokens.css. */
|
||||
export function isTokenRule(rule: CssRule): boolean {
|
||||
export function isTokenRule(rule: Pick<CssRule, 'selector' | 'file'>): boolean {
|
||||
if (rule.selector === '@font-face' || rule.selector === '@theme' || path.basename(rule.file).toLowerCase() === 'tokens.css') return true;
|
||||
return rule.selector.split(',').every((p) => { const t = p.trim(); return t && TOKEN_SEL.test(t); });
|
||||
}
|
||||
|
||||
@@ -572,4 +572,25 @@ export function closeIndex(doc: Doc, idx: number): number {
|
||||
return doc.tags.length - 1;
|
||||
}
|
||||
|
||||
// sloplint proseRun
|
||||
const proseRun = (run: string): string => {
|
||||
let r = run;
|
||||
for (let k = 0; k < 3 && /\{[^{}]*\}/.test(r); k++) r = r.replace(/\{[^{}]*\}/g, pad);
|
||||
return /[;=(){}]|\breturn\b/.test(r) ? pad(r) : r;
|
||||
};
|
||||
|
||||
/** The document's prose: tags, comments, scripts, styles and code blanked with positions preserved; component text runs that read as code are blanked too. */
|
||||
// sloplint maskedProse
|
||||
export function maskedProse(doc: Doc): string {
|
||||
const cached = doc.cache.prose as string | undefined;
|
||||
if (cached != null) return cached;
|
||||
const src = doc.kind !== 'html' ? doc.src.replace(/(^|>)([^<>]*)(?=<|$)/g, (_m0, a: string, run: string) => a + proseRun(run)) : doc.src;
|
||||
const prose = src
|
||||
.replace(/<(script|style|pre|code|kbd|samp|textarea)\b[^>]*>[\s\S]*?<\/\1>/gi, pad)
|
||||
.replace(/<!--[\s\S]*?-->/g, pad)
|
||||
.replace(/<[^>]*>/g, pad);
|
||||
doc.cache.prose = prose;
|
||||
return prose;
|
||||
}
|
||||
|
||||
export const wordsOf = (t: string): string[] => plainText(t).split(/\s+/).filter((w) => /[A-Za-z0-9]/.test(w));
|
||||
@@ -9,31 +9,9 @@
|
||||
*/
|
||||
import { styleFor, type LintContext } from '../context';
|
||||
import { resolveVars } from '../css';
|
||||
import { ancestorMap, getAttr, innerOf, plainText, type Doc, type Tag } from '../html';
|
||||
import { ancestorMap, getAttr, innerOf, maskedProse, plainText, type Doc, type Tag } from '../html';
|
||||
import { trunc, type Reporter } from '../report';
|
||||
|
||||
const pad = (m: string): string => m.replace(/[^\n]/g, ' ');
|
||||
|
||||
// sloplint proseRun
|
||||
const proseRun = (run: string): string => {
|
||||
let r = run;
|
||||
for (let k = 0; k < 3 && /\{[^{}]*\}/.test(r); k++) r = r.replace(/\{[^{}]*\}/g, pad);
|
||||
return /[;=(){}]|\breturn\b/.test(r) ? pad(r) : r;
|
||||
};
|
||||
|
||||
/** The document's prose: tags, comments, scripts, styles and code blanked with positions preserved; component text runs that read as code are blanked too. */
|
||||
// sloplint maskedProse
|
||||
export function maskedProse(doc: Doc): string {
|
||||
const cached = doc.cache.prose as string | undefined;
|
||||
if (cached != null) return cached;
|
||||
const src = doc.kind !== 'html' ? doc.src.replace(/(^|>)([^<>]*)(?=<|$)/g, (_m0, a: string, run: string) => a + proseRun(run)) : doc.src;
|
||||
const prose = src
|
||||
.replace(/<(script|style|pre|code|kbd|samp|textarea)\b[^>]*>[\s\S]*?<\/\1>/gi, pad)
|
||||
.replace(/<!--[\s\S]*?-->/g, pad)
|
||||
.replace(/<[^>]*>/g, pad);
|
||||
doc.cache.prose = prose;
|
||||
return prose;
|
||||
}
|
||||
|
||||
// sloplint TELLS19 (gate 19), less the lines the copy tier owns
|
||||
const HERO_TELLS: Array<[RegExp, string]> = [
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
*/
|
||||
import type { LintContext } from '../context';
|
||||
import { lineIndex } from '../css';
|
||||
import { getAttr, innerOf, plainText, type Doc, type Tag } from '../html';
|
||||
import { getAttr, innerOf, maskedProse, plainText, type Doc, type Tag } from '../html';
|
||||
import { trunc, type Reporter } from '../report';
|
||||
import { maskedProse, runChromeCopy } from './chrome-copy';
|
||||
import { runChromeCopy } from './chrome-copy';
|
||||
import { runEyebrow } from './chrome-eyebrow';
|
||||
|
||||
// sloplint check42 (gate 42)
|
||||
|
||||
@@ -172,7 +172,7 @@ function check29Glow(ctx: LintContext, r: Reporter): void {
|
||||
r.report('glow-wash', file, line, ev, { fix });
|
||||
};
|
||||
for (const rule of mergedRules(ctx)) {
|
||||
if (isTokenRule({ ...rule, keyframes: null })) continue;
|
||||
if (isTokenRule(rule)) continue;
|
||||
const local: Tokens = {};
|
||||
for (const d of rule.decls) if (d.prop.startsWith('--')) local[d.prop] = d.value;
|
||||
const name = trunc(rule.selector, 36);
|
||||
|
||||
@@ -93,7 +93,11 @@ function checkComponentText(ctx: LintContext, r: Reporter): void {
|
||||
if (!text) continue;
|
||||
if (/(?:link|button|btn|cta)$/i.test(t.name) && /(→|->|›|»|⟶)\s*$/.test(text)) r.report('template-punctuation', doc.file, t.line, `arrow glued to the label "${trunc(text, 40)}" (<${t.name}>)`, { fix: 'a verb as the label; the link already says it goes somewhere' });
|
||||
if ((text.match(/\s[·•]\s/g) || []).length >= 2) r.report('template-punctuation', doc.file, t.line, `middle-dot meta string "${trunc(text, 50)}"`, { fix: 'line breaks, columns or hairlines; one dot in a byline at most' });
|
||||
if (text.length <= 4 && EMOJI_ICON.test(text)) r.report('emoji-decoration', doc.file, t.line, `emoji icon "${text}" in <${t.name}>`, { fix: 'one icon library or custom SVG' });
|
||||
if (text.length <= 4 && EMOJI_ICON.test(text)) {
|
||||
const outer = r.findings.findIndex((f) => f.rule === 'emoji-decoration' && f.file === relPath(doc.file, r.cwd) && f.evidence.includes(`"${text}"`));
|
||||
if (outer >= 0) r.findings.splice(outer, 1);
|
||||
r.report('emoji-decoration', doc.file, t.line, `emoji icon "${text}" in <${t.name}>`, { fix: 'one icon library or custom SVG' });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,9 @@
|
||||
*/
|
||||
import { mergedRules, type LintContext } from '../context';
|
||||
import { HEADING_SEL, isTokenRule, lineIndex, pxOf, resolveVars, type MergedRule } from '../css';
|
||||
import { getAttr } from '../html';
|
||||
import { getAttr, maskedProse } from '../html';
|
||||
import { trunc, type Reporter } from '../report';
|
||||
import { twVariants } from '../tailwind';
|
||||
import { maskedProse } from './chrome-copy';
|
||||
|
||||
/** The largest px, rem or em length in a value, at a 16px root; null when it has none. */
|
||||
// sloplint maxPxIn
|
||||
@@ -27,8 +26,6 @@ const baseSelector = (sel: string): string => sel.replace(/::?[a-z-]+(\([^)]*\))
|
||||
|
||||
const firstFile = (ctx: LintContext): string | null => ctx.cssFiles[0]?.file || ctx.docs[0]?.file || null;
|
||||
|
||||
/** isTokenRule over a merged rule (merging already dropped keyframes). */
|
||||
const tokenRule = (rule: MergedRule): boolean => isTokenRule({ ...rule, keyframes: null });
|
||||
|
||||
// sloplint checkF1 (F1)
|
||||
function checkStraightQuotes(ctx: LintContext, r: Reporter): void {
|
||||
@@ -75,7 +72,7 @@ function checkPrimaryRecipes(ctx: LintContext, r: Reporter): void {
|
||||
const BTN = /\b(btn|button|cta)\b|__(btn|button|cta)\b/i;
|
||||
const filled = new Map<string, { rule: MergedRule; line: number }>();
|
||||
for (const rule of mergedRules(ctx)) {
|
||||
if (tokenRule(rule) || /:hover|:active|:focus|:disabled|\.is-|\[disabled/i.test(rule.selector)) continue;
|
||||
if (isTokenRule(rule) || /:hover|:active|:focus|:disabled|\.is-|\[disabled/i.test(rule.selector)) continue;
|
||||
const first = baseSelector(rule.selector.split(',')[0]);
|
||||
if (!BTN.test(first)) continue;
|
||||
for (const d of rule.decls) {
|
||||
@@ -93,7 +90,7 @@ function checkPrimaryRecipes(ctx: LintContext, r: Reporter): void {
|
||||
// sloplint checkF5 (F5)
|
||||
function checkIconCoins(ctx: LintContext, r: Reporter): void {
|
||||
for (const rule of mergedRules(ctx)) {
|
||||
if (tokenRule(rule) || !/icon|\bico\b/i.test(rule.selector)) continue;
|
||||
if (isTokenRule(rule) || !/icon|\bico\b/i.test(rule.selector)) continue;
|
||||
const radius = rule.decls.find((d) => d.prop === 'border-radius');
|
||||
if (!radius) continue;
|
||||
const rv = resolveVars(radius.value, ctx.tokens);
|
||||
@@ -115,7 +112,7 @@ function checkTabularFigures(ctx: LintContext, r: Reporter): void {
|
||||
const covered = new Set(bySel.filter(hasTnum).map((rule) => baseSelector(rule.selector)));
|
||||
const warned = new Set<string>();
|
||||
for (const rule of bySel) {
|
||||
if (tokenRule(rule) || !NUM_SEL.test(rule.selector)) continue;
|
||||
if (isTokenRule(rule) || !NUM_SEL.test(rule.selector)) continue;
|
||||
const base = baseSelector(rule.selector);
|
||||
if (warned.has(base) || covered.has(base)) continue;
|
||||
const size = rule.decls.find((d) => d.prop === 'font-size');
|
||||
@@ -129,7 +126,7 @@ function checkTabularFigures(ctx: LintContext, r: Reporter): void {
|
||||
function checkStampedRecipe(ctx: LintContext, r: Reporter): void {
|
||||
const byKey = new Map<string, { sels: Set<string>; rule: MergedRule }>();
|
||||
for (const rule of mergedRules(ctx)) {
|
||||
if (tokenRule(rule) || /::(before|after)/.test(rule.selector)) continue;
|
||||
if (isTokenRule(rule) || /::(before|after)/.test(rule.selector)) continue;
|
||||
const get = (re: RegExp): string => {
|
||||
const d = rule.decls.filter((x) => re.test(x.prop)).pop();
|
||||
return d ? resolveVars(d.value, ctx.tokens).replace(/\s+/g, ' ').trim() : '';
|
||||
@@ -155,7 +152,7 @@ function checkPillRadius(ctx: LintContext, r: Reporter): void {
|
||||
const sels = new Set<string>();
|
||||
let where: { rule: MergedRule; line: number } | null = null;
|
||||
for (const rule of mergedRules(ctx)) {
|
||||
if (tokenRule(rule)) continue;
|
||||
if (isTokenRule(rule)) continue;
|
||||
const d = rule.decls.filter((x) => x.prop === 'border-radius').pop();
|
||||
if (!d || !/^(9999px|999px|50rem)$/.test(resolveVars(d.value, ctx.tokens).trim())) continue;
|
||||
for (const part of rule.selector.split(',')) {
|
||||
|
||||
@@ -293,7 +293,7 @@ function check67(ctx: LintContext, r: Reporter): void {
|
||||
const upperDisplay = bySel.some((rule) => displayish(rule) && !LABEL67.test(rule.selector) && rule.decls.some((d) => d.prop === 'text-transform' && /uppercase/.test(d.value)));
|
||||
const seen = new Set<string>();
|
||||
for (const rule of bySel) {
|
||||
if (isTokenRule({ ...rule, keyframes: null }) || exempt67(rule.selector) || /::first-letter\s*(,|$)/.test(rule.selector)) continue;
|
||||
if (isTokenRule(rule) || exempt67(rule.selector) || /::first-letter\s*(,|$)/.test(rule.selector)) continue;
|
||||
const caps = caps67(ctx, bySel, rule);
|
||||
if (!caps) continue;
|
||||
const { upper, smallCaps, em, px } = caps;
|
||||
|
||||
@@ -237,7 +237,7 @@ describe('design-lint tailwind: type rules', () => {
|
||||
const f = await lintFiles({ 'index.html': page(body) });
|
||||
expect(only(f, 'kicker-above-heading').map((x) => [x.line, x.grade])).toEqual([[BODY + 3, 'ask'], [BODY + 5, 'ask']]);
|
||||
expect(only(f, 'kicker-above-heading').map((x) => x.evidence)).toEqual([
|
||||
'eyebrow "Features" before <h2> (uppercase at 14px)',
|
||||
'the eyebrow "Features" before <h2>',
|
||||
'eyebrow "Fewer handoffs" before <h3> (text-indigo-600 at 14px)',
|
||||
]);
|
||||
expect(only(await lintFiles({ 'index.html': page(body) }, { scope: 'component' }), 'kicker-above-heading')).toEqual([]);
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@
|
||||
<title>Gate 54 carve-out fixture (must pass)</title>
|
||||
<meta name="description" content="Fragments that sit near a heading without announcing it; gate 54 must stay quiet on every one.">
|
||||
<style>
|
||||
/* gstack-lint-disable skipped-heading: unrelated fragments in one file, not a single document outline */
|
||||
/* gstack-lint-disable skipped-heading: unrelated fragments in one file, not a single document outline; each heading opens its own crossing */
|
||||
:root {
|
||||
--color-paper: oklch(97% 0.006 250); --color-ink: oklch(20% 0.02 250);
|
||||
--color-muted: oklch(52% 0.015 250); --color-accent: oklch(52% 0.14 250); --color-accent-ink: oklch(98% 0.005 250);
|
||||
|
||||
+12
@@ -1,5 +1,17 @@
|
||||
{
|
||||
"newModules": [
|
||||
{ "file": "lib/design-lint/render.ts", "movedFrom": "(new: the bundled design lint, GSTA-105; ported from hallmark sloplint.mjs)" },
|
||||
{ "file": "lib/design-lint/rules/assets.ts", "movedFrom": "(new: the bundled design lint, GSTA-105; ported from hallmark sloplint.mjs)" },
|
||||
{ "file": "lib/design-lint/rules/chrome-copy.ts", "movedFrom": "(new: the bundled design lint, GSTA-105; ported from hallmark sloplint.mjs)" },
|
||||
{ "file": "lib/design-lint/rules/chrome-eyebrow.ts", "movedFrom": "(new: the bundled design lint, GSTA-105; ported from hallmark sloplint.mjs)" },
|
||||
{ "file": "lib/design-lint/rules/chrome.ts", "movedFrom": "(new: the bundled design lint, GSTA-105; ported from hallmark sloplint.mjs)" },
|
||||
{ "file": "lib/design-lint/rules/color.ts", "movedFrom": "(new: the bundled design lint, GSTA-105; ported from hallmark sloplint.mjs)" },
|
||||
{ "file": "lib/design-lint/rules/component.ts", "movedFrom": "(new: the bundled design lint, GSTA-105; ported from hallmark sloplint.mjs)" },
|
||||
{ "file": "lib/design-lint/rules/finish.ts", "movedFrom": "(new: the bundled design lint, GSTA-105; ported from hallmark sloplint.mjs)" },
|
||||
{ "file": "lib/design-lint/rules/motion.ts", "movedFrom": "(new: the bundled design lint, GSTA-105; ported from hallmark sloplint.mjs)" },
|
||||
{ "file": "lib/design-lint/rules/structure.ts", "movedFrom": "(new: the bundled design lint, GSTA-105; ported from hallmark sloplint.mjs)" },
|
||||
{ "file": "lib/design-lint/rules/type.ts", "movedFrom": "(new: the bundled design lint, GSTA-105; ported from hallmark sloplint.mjs)" },
|
||||
{ "file": "lib/design-lint/tailwind.ts", "movedFrom": "(new: the bundled design lint, GSTA-105; ported from hallmark sloplint.mjs)" },
|
||||
{ "file": "lib/design-lint/css.ts", "movedFrom": "(new: the bundled design lint, GSTA-105; ported from hallmark sloplint.mjs)" },
|
||||
{ "file": "lib/design-lint/color.ts", "movedFrom": "(new: the bundled design lint, GSTA-105; ported from hallmark sloplint.mjs)" },
|
||||
{ "file": "lib/design-lint/html.ts", "movedFrom": "(new: the bundled design lint, GSTA-105; ported from hallmark sloplint.mjs)" },
|
||||
|
||||
Reference in new issue
Block a user