diff --git a/cli/engine/browser/injected/index.mjs b/cli/engine/browser/injected/index.mjs index 1eb350e74..66515ef3a 100644 --- a/cli/engine/browser/injected/index.mjs +++ b/cli/engine/browser/injected/index.mjs @@ -1280,14 +1280,95 @@ if (IS_BROWSER) { else groupMap.set(el, [...kept]); } + function pseudoElementHostSelector(selector) { + const raw = String(selector || ''); + const legacyNames = new Set(['before', 'after', 'first-letter', 'first-line']); + const isNameChar = char => /[a-zA-Z0-9_-]/.test(char || ''); + const consumeFunction = (start) => { + let depth = 0; + let quote = ''; + for (let i = start; i < raw.length; i += 1) { + const char = raw[i]; + if (char === '\\') { + i += 1; + continue; + } + if (quote) { + if (char === quote) quote = ''; + continue; + } + if (char === '"' || char === "'") { + quote = char; + continue; + } + if (char === '(') depth += 1; + if (char === ')' && --depth === 0) return i + 1; + } + return raw.length; + }; + + let output = ''; + let found = false; + for (let i = 0; i < raw.length;) { + const char = raw[i]; + if (char === '\\') { + output += raw.slice(i, Math.min(raw.length, i + 2)); + i += 2; + continue; + } + if (char === '"' || char === "'") { + const quote = char; + const start = i; + i += 1; + while (i < raw.length) { + if (raw[i] === '\\') { + i += 2; + continue; + } + const value = raw[i]; + i += 1; + if (value === quote) break; + } + output += raw.slice(start, i); + continue; + } + if (char !== ':') { + output += char; + i += 1; + continue; + } + + let end = i + 1; + let isPseudoElement = false; + if (raw[end] === ':') { + end += 1; + const nameStart = end; + while (isNameChar(raw[end])) end += 1; + isPseudoElement = end > nameStart; + } else { + const nameStart = end; + while (isNameChar(raw[end])) end += 1; + isPseudoElement = legacyNames.has(raw.slice(nameStart, end).toLowerCase()); + } + if (!isPseudoElement) { + output += char; + i += 1; + continue; + } + if (raw[end] === '(') end = consumeFunction(end); + found = true; + if (!output || /[\s>+~,]/.test(output[output.length - 1])) output += '*'; + i = end; + } + if (!found) return null; + return output.trim().replace(/,\s*(?=,|$)/g, ''); + } + function selectorNodesForLiveDom(root, selector) { const raw = String(selector || '').trim(); if (!raw) return null; - - const hasPseudoElement = ( - /::[a-zA-Z-]+(?:\([^)]*\))?|:(?:before|after|first-letter|first-line)\b/i.test(raw) - ); - if (!hasPseudoElement) { + const fallback = pseudoElementHostSelector(raw); + if (fallback == null) { // An empty result from a valid full selector is authoritative. In // particular, do not broaden inactive :hover/:focus/:not() rules to // their host element by stripping pseudo-classes. @@ -1302,11 +1383,6 @@ if (IS_BROWSER) { // pseudo indiscriminately with an empty string leaves the latter as the // invalid selector `main >` and makes absent hosts indistinguishable from // selectors the DOM API cannot parse. - const fallback = raw - .replace(/(^|[\s>+~,])(?:::[a-zA-Z-]+(?:\([^)]*\))?|:(?:before|after|first-letter|first-line)\b)/gi, '$1*') - .replace(/::[a-zA-Z-]+(?:\([^)]*\))?|:(?:before|after|first-letter|first-line)\b/gi, '') - .trim() - .replace(/,\s*(?=,|$)/g, ''); if (!fallback || /^[,\s]*$/.test(fallback)) return null; try { return Array.from(root.querySelectorAll(fallback)); } catch { return null; } diff --git a/cli/engine/detect-antipatterns-browser.js b/cli/engine/detect-antipatterns-browser.js index 82c211984..bef290e11 100644 --- a/cli/engine/detect-antipatterns-browser.js +++ b/cli/engine/detect-antipatterns-browser.js @@ -8184,14 +8184,95 @@ if (IS_BROWSER) { else groupMap.set(el, [...kept]); } + function pseudoElementHostSelector(selector) { + const raw = String(selector || ''); + const legacyNames = new Set(['before', 'after', 'first-letter', 'first-line']); + const isNameChar = char => /[a-zA-Z0-9_-]/.test(char || ''); + const consumeFunction = (start) => { + let depth = 0; + let quote = ''; + for (let i = start; i < raw.length; i += 1) { + const char = raw[i]; + if (char === '\\') { + i += 1; + continue; + } + if (quote) { + if (char === quote) quote = ''; + continue; + } + if (char === '"' || char === "'") { + quote = char; + continue; + } + if (char === '(') depth += 1; + if (char === ')' && --depth === 0) return i + 1; + } + return raw.length; + }; + + let output = ''; + let found = false; + for (let i = 0; i < raw.length;) { + const char = raw[i]; + if (char === '\\') { + output += raw.slice(i, Math.min(raw.length, i + 2)); + i += 2; + continue; + } + if (char === '"' || char === "'") { + const quote = char; + const start = i; + i += 1; + while (i < raw.length) { + if (raw[i] === '\\') { + i += 2; + continue; + } + const value = raw[i]; + i += 1; + if (value === quote) break; + } + output += raw.slice(start, i); + continue; + } + if (char !== ':') { + output += char; + i += 1; + continue; + } + + let end = i + 1; + let isPseudoElement = false; + if (raw[end] === ':') { + end += 1; + const nameStart = end; + while (isNameChar(raw[end])) end += 1; + isPseudoElement = end > nameStart; + } else { + const nameStart = end; + while (isNameChar(raw[end])) end += 1; + isPseudoElement = legacyNames.has(raw.slice(nameStart, end).toLowerCase()); + } + if (!isPseudoElement) { + output += char; + i += 1; + continue; + } + if (raw[end] === '(') end = consumeFunction(end); + found = true; + if (!output || /[\s>+~,]/.test(output[output.length - 1])) output += '*'; + i = end; + } + if (!found) return null; + return output.trim().replace(/,\s*(?=,|$)/g, ''); + } + function selectorNodesForLiveDom(root, selector) { const raw = String(selector || '').trim(); if (!raw) return null; - - const hasPseudoElement = ( - /::[a-zA-Z-]+(?:\([^)]*\))?|:(?:before|after|first-letter|first-line)\b/i.test(raw) - ); - if (!hasPseudoElement) { + const fallback = pseudoElementHostSelector(raw); + if (fallback == null) { // An empty result from a valid full selector is authoritative. In // particular, do not broaden inactive :hover/:focus/:not() rules to // their host element by stripping pseudo-classes. @@ -8206,11 +8287,6 @@ if (IS_BROWSER) { // pseudo indiscriminately with an empty string leaves the latter as the // invalid selector `main >` and makes absent hosts indistinguishable from // selectors the DOM API cannot parse. - const fallback = raw - .replace(/(^|[\s>+~,])(?:::[a-zA-Z-]+(?:\([^)]*\))?|:(?:before|after|first-letter|first-line)\b)/gi, '$1*') - .replace(/::[a-zA-Z-]+(?:\([^)]*\))?|:(?:before|after|first-letter|first-line)\b/gi, '') - .trim() - .replace(/,\s*(?=,|$)/g, ''); if (!fallback || /^[,\s]*$/.test(fallback)) return null; try { return Array.from(root.querySelectorAll(fallback)); } catch { return null; } diff --git a/tests/detect-antipatterns-browser.test.mjs b/tests/detect-antipatterns-browser.test.mjs index 9f5131eb3..333b91532 100644 --- a/tests/detect-antipatterns-browser.test.mjs +++ b/tests/detect-antipatterns-browser.test.mjs @@ -1434,6 +1434,12 @@ describe('detectUrl — browser-only fixtures', () => { JSON.stringify({ linkedFindings, linkedCssom, containerBackgrounds }), ); assert.equal(linkedFindings.some(finding => finding.type === 'layout-transition'), false); + assert.equal(linkedFindings.some(finding => finding.type === 'ai-color-palette'), false); + assert.equal( + linkedFindings.some(finding => finding.type === 'organic-clip-path'), + true, + JSON.stringify({ linkedFindings, linkedCssom }), + ); await linkedPage.close(); const fontPage = await browser.newPage(); diff --git a/tests/fixtures/antipatterns/linked-url-patterns.css b/tests/fixtures/antipatterns/linked-url-patterns.css index 36afdbc65..a97ac77c4 100644 --- a/tests/fixtures/antipatterns/linked-url-patterns.css +++ b/tests/fixtures/antipatterns/linked-url-patterns.css @@ -1,5 +1,5 @@ @media (min-width: 1px) { - .flag-linked-grid { + [data-token="::before"] { width: 160px; height: 80px; background: linear-gradient(90deg, #d9d9d9 1px, transparent 1px), linear-gradient(180deg, #d9d9d9 1px, transparent 1px); @@ -12,6 +12,19 @@ body { color: #fff; } +/* Literal pseudo-element text inside an attribute value is data, not selector + syntax. This rule has no live match even though an empty-value decoy does. */ +[data-decoy="::before"] { + color: #7c3aed; +} + +/* Escaped colons are identifier data, not a legacy pseudo-element. */ +.\:\:before { + width: 240px; + height: 160px; + clip-path: polygon(2% 4%, 17% 1%, 31% 7%, 47% 3%, 62% 9%, 79% 2%, 96% 13%, 91% 31%, 98% 49%, 89% 68%, 95% 87%, 74% 96%, 51% 91%, 29% 98%, 8% 84%, 3% 61%); +} + /* A valid hostless pseudo-element selector cannot be queried through the DOM selector API. It must remain in the corpus rather than count as unused. */ main > ::before { diff --git a/tests/fixtures/antipatterns/linked-url-patterns.html b/tests/fixtures/antipatterns/linked-url-patterns.html index 8fdf9f975..9e9ed9b91 100644 --- a/tests/fixtures/antipatterns/linked-url-patterns.html +++ b/tests/fixtures/antipatterns/linked-url-patterns.html @@ -8,7 +8,9 @@