From 734e07f0a5090ff78354f580d05015b5b9b257ba Mon Sep 17 00:00:00 2001 From: garrytan Date: Mon, 26 Oct 2026 13:18:22 +0000 Subject: [PATCH] design-lint: twelfth review cycle fixes Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- lib/design-lint/css.ts | 13 +++++++++---- lib/design-lint/html.ts | 13 ++++++------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/lib/design-lint/css.ts b/lib/design-lint/css.ts index f2e39c08c..667649a95 100644 --- a/lib/design-lint/css.ts +++ b/lib/design-lint/css.ts @@ -86,12 +86,14 @@ export function selectorNames(selectors: string[]): { tags: Set; classes return { tags, classes }; } -/** A selector longer than this is a nesting blow-up (comma lists nested in comma lists, a parent repeated per '&'), not a stylesheet selector; a nested selector that would pass it stands without its parent, and every stored selector is cut there, so a sheet costs its length and not parts^depth. */ +/** A selector longer than this is a nesting blow-up (comma lists nested in comma lists, a parent repeated per '&'), not a stylesheet selector; a nested selector that would pass it stands without its parent, and every stored selector is cut there. The sheet as a whole may store at most expansionBudget() selector characters: once that is spent, nested selectors stand unexpanded (their own text, `&` kept), so thousands of children under one long parent cost the sheet its length and not children × MAX_SELECTOR. */ const MAX_SELECTOR = 8192; +const expansionBudget = (src: string): number => src.length * 4 + 65536; -/** Expand a nested selector against its parent: `&` is replaced, a bare selector is descendant-joined. */ -function nestSelector(sel: string, parent: string): string { +/** Expand a nested selector against its parent: `&` is replaced, a bare selector is descendant-joined; with expand off (the sheet's budget is spent) the selector stands as written. */ +function nestSelector(sel: string, parent: string, expand = true): string { const parts = splitTop(sel).map((part) => part.trim()); + if (!expand) return parts.filter(Boolean).join(', ') || '&'; const substitutions = parts.reduce((n, part) => n + Math.max(1, part.split('&').length - 1), 0); const wrapped = parent.includes(',') ? parent.length + 5 : parent.length; if (sel.length + wrapped * substitutions > MAX_SELECTOR) return parts.map((part) => part.replace(/&/g, '').trim()).filter(Boolean).join(', ') || splitTop(parent.slice(0, 256))[0].trim(); @@ -118,6 +120,8 @@ export function parseCss(source: string, file: string, lineOffset = 0): CssRule[ const src = stripComments(source); const lineOf = lineIndex(src, lineOffset); const rules: CssRule[] = []; + const budget = expansionBudget(src); + let stored = 0; const makeRule = (selector: string, s: number, e: number, media: string | null, keyframes: string | null): CssRule => { const body = flatBody(src, s, e); const decls: CssDecl[] = []; @@ -168,8 +172,9 @@ export function parseCss(source: string, file: string, lineOffset = 0): CssRule[ if (depthLeft > 0) walk(blockStart, blockEnd, media, keyframes, null, depthLeft - 1); } } else { - const nested = parent && !keyframes ? nestSelector(selector, parent) : selector; + const nested = parent && !keyframes ? nestSelector(selector, parent, stored <= budget) : selector; const sel = nested.length > MAX_SELECTOR ? nested.slice(0, MAX_SELECTOR) : nested; + stored += sel.length; rules.push(makeRule(sel, blockStart, blockEnd, media, keyframes)); if (!keyframes && depthLeft > 0 && src.slice(blockStart, blockEnd).includes('{')) walk(blockStart, blockEnd, media, keyframes, sel, depthLeft - 1); } diff --git a/lib/design-lint/html.ts b/lib/design-lint/html.ts index cda3d3d2a..7f85d46e9 100644 --- a/lib/design-lint/html.ts +++ b/lib/design-lint/html.ts @@ -103,19 +103,18 @@ function spansOf(lower: string, open: string, close: string, element: boolean): function blankRanges(s: string, ranges: Array<[number, number]>, cut = false): string { if (!ranges.length) return s; let out = '', at = 0; - for (const [a, b] of ranges) { - if (a < at) continue; - out += s.slice(at, a) + (cut ? ' ' : pad(s.slice(a, b))); - at = b; - } + for (const [a, b] of ranges) if (a >= at) { out += s.slice(at, a) + (cut ? ' ' : pad(s.slice(a, b))); at = b; } return out + s.slice(at); } -/** s with every `<…>` run replaced by one space (or padded to spaces with keep set), in one linear pass; a `<` with no `>` after it is text. With a limit, the read stops once more than that many non-space characters are out, so a caller that only needs to know whether the text is short does not pay for an element that runs to the end of the file. */ +/** s with every `<…>` run replaced by one space (or padded to spaces with keep set), in one linear pass; a `<` with no `>` after it is text. With a limit, the read stops once more than that many non-space characters are out, or once the walk has read 64 × limit + 4096 characters of the current text run without reaching the limit (a whitespace run, which the limit cannot stop), so a caller that only needs to know whether the text is short does not pay for an element that runs to the end of the file. */ export function stripTags(s: string, keep = false, limit = Infinity): string { let out = '', i = 0, seen = 0; for (;;) { - if (limit < Infinity) for (let k = i; k < s.length && s[k] !== '<'; k++) if (!/\s/.test(s[k]) && ++seen > limit) return out + s.slice(i, k + (s.charCodeAt(k) >= 0xd800 && s.charCodeAt(k) < 0xdc00 ? 2 : 1)); + if (limit < Infinity) for (let k = i, c = 0; k < s.length && s[k] !== '<'; k++) { + if (k - i > 64 * limit + 4096) return out + s.slice(i, k); + if ((c = s.charCodeAt(k)) > 32 && (c < 128 || !/\s/.test(s[k])) && ++seen > limit) return out + s.slice(i, k + (c >= 0xd800 && c < 0xdc00 ? 2 : 1)); + } const lt = s.indexOf('<', i); if (lt < 0) break; const gt = s.indexOf('>', lt + 1);