mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 14:16:28 +03:00
Merge origin/main into fix/570-monorepo-design-root
This commit is contained in:
+41
-11
@@ -699,7 +699,7 @@ function deduplicateProviders(root, providers, scope) {
|
||||
* SKILL.md, so script-only fixes and removed files are detected.
|
||||
* Returns true if every bundle skill matches the local copy.
|
||||
*/
|
||||
function isUpToDate(root, providers, bundleDir, scope) {
|
||||
function isUpToDate(root, providers, bundleDir, scope, agentScope = scope) {
|
||||
const unique = deduplicateProviders(root, providers, scope);
|
||||
if (unique.length === 0) return false;
|
||||
|
||||
@@ -724,6 +724,8 @@ function isUpToDate(root, providers, bundleDir, scope) {
|
||||
if (bundleHash !== localHash) return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!providerAgentsUpToDate(bundleDir, root, provider, agentScope)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -745,7 +747,8 @@ async function check() {
|
||||
console.log('Checking for updates...\n');
|
||||
try {
|
||||
const bundleDir = await downloadAndExtractBundle();
|
||||
const upToDate = isUpToDate(root, providers, bundleDir);
|
||||
const agentScope = isHomeDir(root) ? 'user' : undefined;
|
||||
const upToDate = isUpToDate(root, providers, bundleDir, undefined, agentScope);
|
||||
rmSync(bundleDir, { recursive: true, force: true });
|
||||
|
||||
if (upToDate) {
|
||||
@@ -1251,7 +1254,9 @@ function copyProviderSkills(bundleDir, root, targets, { scope } = {}) {
|
||||
}
|
||||
|
||||
// Native subagent definitions that ship in the bundle next to a provider's
|
||||
// skills. GitHub Copilot's live at `.github/agents/impeccable-*.agent.md`:
|
||||
// skills. Claude Code's live at `.claude/agents/impeccable-*.md`; project
|
||||
// agents take precedence over user agents. GitHub Copilot's live at
|
||||
// `.github/agents/impeccable-*.agent.md`:
|
||||
// project installs commit them at `<repo>/.github/agents/`, user-level
|
||||
// installs go to `~/.copilot/agents/` (Copilot's user-scope dir, NOT
|
||||
// `~/.github/`). On a name conflict Copilot lets the user-level file shadow
|
||||
@@ -1261,6 +1266,11 @@ function copyProviderSkills(bundleDir, root, targets, { scope } = {}) {
|
||||
// `~/.cursor/agents/`; project agents take precedence there, so no shadow
|
||||
// warning is needed.
|
||||
const PROVIDER_AGENT_ARTIFACTS = {
|
||||
'.claude': {
|
||||
ext: '.md',
|
||||
userDir: home => join(home, '.claude', 'agents'),
|
||||
userShadowsProject: false,
|
||||
},
|
||||
'.github': {
|
||||
ext: '.agent.md',
|
||||
userDir: home => join(home, '.copilot', 'agents'),
|
||||
@@ -1273,6 +1283,23 @@ const PROVIDER_AGENT_ARTIFACTS = {
|
||||
},
|
||||
};
|
||||
|
||||
function providerAgentsUpToDate(bundleDir, root, provider, scope) {
|
||||
const artifact = PROVIDER_AGENT_ARTIFACTS[provider];
|
||||
if (!artifact) return true;
|
||||
const srcDir = join(bundleDir, provider, 'agents');
|
||||
if (!existsSync(srcDir)) return true;
|
||||
|
||||
const destDir = scope === 'user'
|
||||
? artifact.userDir(root)
|
||||
: join(root, provider, 'agents');
|
||||
const agentFiles = readdirSync(srcDir).filter(name => name.endsWith(artifact.ext));
|
||||
return agentFiles.every(name => {
|
||||
const localPath = join(destDir, name);
|
||||
return existsSync(localPath)
|
||||
&& hashSkillFile(join(srcDir, name)) === hashSkillFile(localPath);
|
||||
});
|
||||
}
|
||||
|
||||
function copyProviderAgents(bundleDir, root, providers, { scope, home = homedir() } = {}) {
|
||||
const targets = Array.isArray(providers) ? providers : [providers];
|
||||
const results = [];
|
||||
@@ -1375,7 +1402,7 @@ function hookScriptPathForProvider(skillRoot, provider) {
|
||||
if (provider === '.cursor') {
|
||||
return join(skillRoot, provider, 'skills', 'impeccable', 'scripts', 'hook-before-edit.mjs');
|
||||
}
|
||||
if (provider === '.claude' || provider === '.agents') {
|
||||
if (provider === '.claude' || provider === '.agents' || provider === '.grok') {
|
||||
return join(skillRoot, provider, 'skills', 'impeccable', 'scripts', 'hook.mjs');
|
||||
}
|
||||
return null;
|
||||
@@ -1451,8 +1478,8 @@ function guardHookCommand(quotedPath, provider) {
|
||||
// entries additionally get a `commandWindows` sibling for cmd.exe.
|
||||
function rewriteHookCommandsForSkillRoot(value, provider, { skillRoot, absolute }) {
|
||||
const hookScript = hookScriptPathForProvider(skillRoot, provider);
|
||||
// Providers we don't own a `node "PATH"` command hook for (.github, .grok)
|
||||
// carry their own portable command forms; leave them untouched.
|
||||
// Providers we don't own a `node "PATH"` command hook for (.github) carry
|
||||
// their own portable command forms; leave them untouched.
|
||||
if (!hookScript) return value;
|
||||
|
||||
// Project-scope installs derive the provider's own project-relative path
|
||||
@@ -1534,7 +1561,8 @@ function hookInstalledForProvider(root, provider) {
|
||||
|
||||
function valueHasImpeccableHookMarker(value) {
|
||||
if (typeof value === 'string') {
|
||||
return IMPECCABLE_HOOK_COMMAND_MARKERS.some(marker => value.includes(marker));
|
||||
const normalized = value.replace(/\\/g, '/');
|
||||
return IMPECCABLE_HOOK_COMMAND_MARKERS.some(marker => normalized.includes(marker));
|
||||
}
|
||||
if (Array.isArray(value)) return value.some(valueHasImpeccableHookMarker);
|
||||
if (value && typeof value === 'object') {
|
||||
@@ -2072,7 +2100,9 @@ function resolveUpdateTarget({ projectRoot, home, explicitScope }) {
|
||||
const homeRooted = isHomeDir(projectRoot);
|
||||
if (homeRooted && !explicitScope) {
|
||||
const providers = findInstalledProviders(home);
|
||||
return providers.length ? { root: home, scope: undefined, providers, scopeLabel: 'user level' } : null;
|
||||
return providers.length
|
||||
? { root: home, scope: undefined, agentScope: 'user', providers, scopeLabel: 'user level' }
|
||||
: null;
|
||||
}
|
||||
|
||||
const projectProviders = homeRooted ? [] : findImpeccableProviders(projectRoot, 'project');
|
||||
@@ -2203,7 +2233,7 @@ async function update(flags = []) {
|
||||
: { root: projectRoot, scope: 'project', providers: target.projectProviders, scopeLabel: 'this project' };
|
||||
}
|
||||
|
||||
const { root, scope } = target;
|
||||
const { root, scope, agentScope = scope } = target;
|
||||
console.log(`Updating the ${target.scopeLabel} install: ${formatPathForDisplay(root)} (${target.providers.join(', ')})`);
|
||||
const providers = target.providers;
|
||||
const linkedProviders = findLinkedProviders(root, providers, scope);
|
||||
@@ -2227,7 +2257,7 @@ async function update(flags = []) {
|
||||
}
|
||||
|
||||
// Compare local vs remote -- skip if already up to date
|
||||
if (isUpToDate(root, copyProviders, tmpDir, scope)) {
|
||||
if (isUpToDate(root, copyProviders, tmpDir, scope, agentScope)) {
|
||||
try {
|
||||
const wantHooks = installHooks && await decideHookInstall(root, copyProviders, { yes });
|
||||
const hookTargets = wantHooks ? copyProviderHooks(tmpDir, root, copyProviders, { force }) : [];
|
||||
@@ -2263,7 +2293,7 @@ async function update(flags = []) {
|
||||
if (migrated > 0) console.log('Migrated a prefixed install back to /impeccable (the i- prefix is no longer used).');
|
||||
|
||||
const updated = refreshProviderSkills(tmpDir, root, copyProviders, scope);
|
||||
reportProviderAgents(copyProviderAgents(tmpDir, root, copyProviders, { scope }));
|
||||
reportProviderAgents(copyProviderAgents(tmpDir, root, copyProviders, { scope: agentScope }));
|
||||
const wantHooks = installHooks && await decideHookInstall(root, providers, { yes });
|
||||
const hookTargets = wantHooks ? copyProviderHooks(tmpDir, root, providers, { force }) : [];
|
||||
|
||||
|
||||
@@ -626,7 +626,7 @@ if (IS_BROWSER) {
|
||||
if (currentStyle.filter && currentStyle.filter !== 'none') reasons.add('filter');
|
||||
if (currentStyle.backdropFilter && currentStyle.backdropFilter !== 'none') reasons.add('backdrop filter');
|
||||
|
||||
const solidBg = parseRgb(currentStyle.backgroundColor);
|
||||
const solidBg = parseRgb(currentStyle.backgroundColor) || parseAnyColor(currentStyle.backgroundColor);
|
||||
if (solidBg && solidBg.a >= 0.95 && (!bgImage || bgImage === 'none')) break;
|
||||
current = current.parentElement;
|
||||
}
|
||||
@@ -688,7 +688,7 @@ if (IS_BROWSER) {
|
||||
// starve the url()-backed texts this mode exists to sample.
|
||||
if (options.imageOnly && !reasons.includes('image background')) continue;
|
||||
|
||||
const textColor = parseRgb(style.color);
|
||||
const textColor = parseRgb(style.color) || parseAnyColor(style.color);
|
||||
const fontSize = parseFloat(style.fontSize) || 16;
|
||||
const fontWeight = parseInt(style.fontWeight) || 400;
|
||||
const isLargeText = fontSize >= WCAG_LARGE_TEXT_PX || (fontSize >= WCAG_LARGE_BOLD_TEXT_PX && fontWeight >= 700);
|
||||
@@ -985,7 +985,7 @@ if (IS_BROWSER) {
|
||||
return sample;
|
||||
}
|
||||
}
|
||||
const bg = parseRgb(style.backgroundColor);
|
||||
const bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
|
||||
if (bg && bg.a > 0.05) return { status: 'sampled', color: bg, method: 'solid-background' };
|
||||
return { status: 'unresolved', reason: 'no readable background' };
|
||||
}
|
||||
@@ -1115,7 +1115,7 @@ if (IS_BROWSER) {
|
||||
}
|
||||
|
||||
const style = getComputedStyle(el);
|
||||
const textColor = parseRgb(style.color) || candidate.textColor;
|
||||
const textColor = parseRgb(style.color) || parseAnyColor(style.color) || candidate.textColor;
|
||||
if (!textColor) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'unreadable text color' };
|
||||
|
||||
const rect = getDirectTextRect(el) || el.getBoundingClientRect();
|
||||
|
||||
@@ -773,14 +773,22 @@ function extractColorFunctionTokens(value) {
|
||||
function parseGradientColors(bgImage) {
|
||||
if (!bgImage || !bgImage.includes('gradient')) return [];
|
||||
const colors = [];
|
||||
const tokenSpans = [];
|
||||
let from = 0;
|
||||
// Stops arrive in whatever syntax the author wrote and the browser kept.
|
||||
// A dark ground painted as `linear-gradient(oklch(...), oklch(...))` used
|
||||
// to read as a gradient with no stops at all.
|
||||
for (const token of extractColorFunctionTokens(bgImage)) {
|
||||
const start = bgImage.indexOf(token, from);
|
||||
if (start < 0) break;
|
||||
tokenSpans.push({ start, end: start + token.length });
|
||||
from = start + token.length;
|
||||
const c = parseAnyColor(token);
|
||||
if (c) colors.push(c);
|
||||
}
|
||||
for (const m of bgImage.matchAll(/#([0-9a-f]{6}|[0-9a-f]{3})\b/gi)) {
|
||||
// Nested hex inside color-mix is an ingredient, not a stop (issue #578).
|
||||
if (tokenSpans.some(s => m.index >= s.start && m.index < s.end)) continue;
|
||||
const h = m[1];
|
||||
if (h.length === 6) {
|
||||
colors.push({ r: parseInt(h.slice(0,2),16), g: parseInt(h.slice(2,4),16), b: parseInt(h.slice(4,6),16), a: 1 });
|
||||
@@ -1955,20 +1963,19 @@ function scanCssTextForGlow(content) {
|
||||
return results;
|
||||
}
|
||||
|
||||
// Decorative grid or line-field backgrounds drawn with hairline
|
||||
// Decorative two-axis grid backgrounds drawn with hairline
|
||||
// linear-gradient layers tiled by a fixed pixel cell. Shared by the HTML
|
||||
// pattern pass and the regex source engine so standalone CSS, component
|
||||
// styles, and inline styles receive the same coverage. Both signals must
|
||||
// co-occur in one declaration block; unrelated rules must not add up across
|
||||
// the file. Returns [{ index, snippet }], capped at one finding per source to
|
||||
// match the page-level HTML check's existing behavior.
|
||||
// the file. A single hairline is a line, divider, or rail, not a grid, even
|
||||
// when tiled by a 2D px cell. Returns [{ index, snippet }], capped at one
|
||||
// finding per source to match the page-level HTML check's existing behavior.
|
||||
function scanCssTextForGridBackground(content) {
|
||||
const hairlineRe = /\b\d{1,3}px\s*,\s*transparent\s+\d{1,3}px/gi;
|
||||
const invertedHairlineRe = /transparent\s+calc\(100%\s*-\s*\d{1,3}px\)/gi;
|
||||
const sizeDeclPxRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\b/i;
|
||||
const sizeDeclPxPairRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\s+\d{1,3}px/i;
|
||||
const shorthandPxAnyRe = /\/\s*\d{1,3}px\b/;
|
||||
const shorthandPxPairRe = /\/\s*\d{1,3}px\s+\d{1,3}px/;
|
||||
const bgDeclRe = /\bbackground(?:-image)?\s*:\s*([^;{}"']*)/gi;
|
||||
const blockRe = /\{([^{}]*)\}|style\s*=\s*"([^"]*)"|style\s*=\s*'([^']*)'/gi;
|
||||
let blk;
|
||||
@@ -1985,13 +1992,10 @@ function scanCssTextForGridBackground(content) {
|
||||
}
|
||||
if (hairlineCount === 0) continue;
|
||||
const hasPxCell = sizeDeclPxRe.test(block) || shorthandPxAnyRe.test(bgJoined);
|
||||
const hasPxPairCell = sizeDeclPxPairRe.test(block) || shorthandPxPairRe.test(bgJoined);
|
||||
if ((hairlineCount >= 2 && hasPxCell) || hasPxPairCell) {
|
||||
if (hairlineCount >= 2 && hasPxCell) {
|
||||
return [{
|
||||
index: blk.index,
|
||||
snippet: hairlineCount >= 2
|
||||
? 'two-axis grid-line gradient background'
|
||||
: 'px-tiled hairline line-field background',
|
||||
snippet: 'two-axis grid-line gradient background',
|
||||
}];
|
||||
}
|
||||
}
|
||||
@@ -3986,7 +3990,7 @@ function checkElementAIPaletteDOM(el) {
|
||||
}
|
||||
|
||||
// Check for neon text (vivid cyan/purple color on dark background)
|
||||
const textColor = parseRgb(style.color);
|
||||
const textColor = parseRgb(style.color) || parseAnyColor(style.color);
|
||||
if (textColor && hasChroma(textColor, 80)) {
|
||||
const hue = getHue(textColor);
|
||||
const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
|
||||
@@ -7281,7 +7285,7 @@ if (IS_BROWSER) {
|
||||
if (currentStyle.filter && currentStyle.filter !== 'none') reasons.add('filter');
|
||||
if (currentStyle.backdropFilter && currentStyle.backdropFilter !== 'none') reasons.add('backdrop filter');
|
||||
|
||||
const solidBg = parseRgb(currentStyle.backgroundColor);
|
||||
const solidBg = parseRgb(currentStyle.backgroundColor) || parseAnyColor(currentStyle.backgroundColor);
|
||||
if (solidBg && solidBg.a >= 0.95 && (!bgImage || bgImage === 'none')) break;
|
||||
current = current.parentElement;
|
||||
}
|
||||
@@ -7343,7 +7347,7 @@ if (IS_BROWSER) {
|
||||
// starve the url()-backed texts this mode exists to sample.
|
||||
if (options.imageOnly && !reasons.includes('image background')) continue;
|
||||
|
||||
const textColor = parseRgb(style.color);
|
||||
const textColor = parseRgb(style.color) || parseAnyColor(style.color);
|
||||
const fontSize = parseFloat(style.fontSize) || 16;
|
||||
const fontWeight = parseInt(style.fontWeight) || 400;
|
||||
const isLargeText = fontSize >= WCAG_LARGE_TEXT_PX || (fontSize >= WCAG_LARGE_BOLD_TEXT_PX && fontWeight >= 700);
|
||||
@@ -7640,7 +7644,7 @@ if (IS_BROWSER) {
|
||||
return sample;
|
||||
}
|
||||
}
|
||||
const bg = parseRgb(style.backgroundColor);
|
||||
const bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
|
||||
if (bg && bg.a > 0.05) return { status: 'sampled', color: bg, method: 'solid-background' };
|
||||
return { status: 'unresolved', reason: 'no readable background' };
|
||||
}
|
||||
@@ -7770,7 +7774,7 @@ if (IS_BROWSER) {
|
||||
}
|
||||
|
||||
const style = getComputedStyle(el);
|
||||
const textColor = parseRgb(style.color) || candidate.textColor;
|
||||
const textColor = parseRgb(style.color) || parseAnyColor(style.color) || candidate.textColor;
|
||||
if (!textColor) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'unreadable text color' };
|
||||
|
||||
const rect = getDirectTextRect(el) || el.getBoundingClientRect();
|
||||
|
||||
@@ -42,6 +42,7 @@ function shouldRunPageAnalyzers(content, filePath) {
|
||||
}
|
||||
|
||||
const JS_SOURCE_EXTS = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs']);
|
||||
const STYLESHEET_EXTS = new Set(['.css', '.scss', '.sass', '.less']);
|
||||
const REGEX_PREFIX_KEYWORDS = new Set(['await', 'case', 'default', 'delete', 'do', 'else', 'in', 'instanceof', 'new', 'of', 'return', 'throw', 'typeof', 'void', 'yield']);
|
||||
const BLOCK_BRACE_PREFIX_KEYWORDS = new Set(['do', 'else', 'finally', 'try']);
|
||||
|
||||
@@ -256,6 +257,153 @@ function stripCssComments(content) {
|
||||
return content.replace(/\/\*[\s\S]*?\*\//g, comment => comment.replace(/[^\n]/g, ' '));
|
||||
}
|
||||
|
||||
function blankHtmlComments(text) {
|
||||
return text.replace(/<!--[\s\S]*?-->/g, comment => comment.replace(/[^\n]/g, ' '));
|
||||
}
|
||||
|
||||
function blankCssLineCommentsInStyleBlocks(text) {
|
||||
const re = /<style\b[^>]*>([\s\S]*?)<\/style>/gi;
|
||||
let output = '';
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
while ((match = re.exec(text)) !== null) {
|
||||
const inner = match[1];
|
||||
const openLength = match[0].length - inner.length - '</style>'.length;
|
||||
output += text.slice(lastIndex, match.index);
|
||||
output += match[0].slice(0, openLength);
|
||||
output += blankCssLineComments(inner);
|
||||
output += match[0].slice(openLength + inner.length);
|
||||
lastIndex = re.lastIndex;
|
||||
}
|
||||
return output + text.slice(lastIndex);
|
||||
}
|
||||
|
||||
function blankHtmlAndCssCommentsOutsideScripts(text) {
|
||||
const re = /<script\b[^>]*>[\s\S]*?<\/script>/gi;
|
||||
let output = '';
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
while ((match = re.exec(text)) !== null) {
|
||||
output += blankCssLineCommentsInStyleBlocks(stripCssComments(blankHtmlComments(text.slice(lastIndex, match.index))));
|
||||
output += match[0];
|
||||
lastIndex = re.lastIndex;
|
||||
}
|
||||
return output + blankCssLineCommentsInStyleBlocks(stripCssComments(blankHtmlComments(text.slice(lastIndex))));
|
||||
}
|
||||
|
||||
function blankCssLineComments(text) {
|
||||
let output = '';
|
||||
let state = 'code';
|
||||
let urlDepth = 0;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const char = text[i];
|
||||
const next = text[i + 1];
|
||||
if (state === 'line') {
|
||||
if (char === '\n') {
|
||||
output += '\n';
|
||||
state = 'code';
|
||||
} else {
|
||||
output += ' ';
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (state === 'single' || state === 'double') {
|
||||
output += char;
|
||||
if (char === '\\' && next) {
|
||||
output += next;
|
||||
i++;
|
||||
} else if ((state === 'single' && char === "'") || (state === 'double' && char === '"')) {
|
||||
state = 'code';
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const prev = output.length ? output[output.length - 1] : '';
|
||||
if (char === '/' && next === '/' && urlDepth === 0 && prev !== ':' && prev !== '(' && prev !== '\\') {
|
||||
output += ' ';
|
||||
i++;
|
||||
state = 'line';
|
||||
continue;
|
||||
}
|
||||
if (char === "'") state = 'single';
|
||||
else if (char === '"') state = 'double';
|
||||
if (char === '(') {
|
||||
const behind = output.replace(/\s+$/, '');
|
||||
if (urlDepth > 0 || /url$/i.test(behind)) urlDepth++;
|
||||
} else if (char === ')' && urlDepth) {
|
||||
urlDepth--;
|
||||
}
|
||||
output += char;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function findAstroFrontmatterClose(text) {
|
||||
if (!text.startsWith('---')) return -1;
|
||||
let cursor = text.indexOf('\n');
|
||||
if (cursor === -1) return -1;
|
||||
cursor += 1;
|
||||
while (cursor < text.length) {
|
||||
if (text[cursor - 1] === '\n' && text.startsWith('---', cursor)) {
|
||||
let end = cursor + 3;
|
||||
while (text[end] === ' ' || text[end] === '\t') end++;
|
||||
if (end >= text.length || text[end] === '\n' || text[end] === '\r') return cursor - 1;
|
||||
}
|
||||
const char = text[cursor];
|
||||
const next = text[cursor + 1];
|
||||
if (char === "'" || char === '"') {
|
||||
const close = findQuotedStringEnd(text, cursor, char);
|
||||
if (close === -1) return -1;
|
||||
cursor = close + 1;
|
||||
continue;
|
||||
}
|
||||
if (char === '`') {
|
||||
const close = findTemplateLiteralEnd(text, cursor);
|
||||
if (close === -1) return -1;
|
||||
cursor = close + 1;
|
||||
continue;
|
||||
}
|
||||
if (char === '/' && next === '/') {
|
||||
const lineEnd = text.indexOf('\n', cursor);
|
||||
if (lineEnd === -1) return -1;
|
||||
cursor = lineEnd;
|
||||
continue;
|
||||
}
|
||||
if (char === '/' && next === '*') {
|
||||
const commentEnd = text.indexOf('*/', cursor + 2);
|
||||
if (commentEnd === -1) return -1;
|
||||
cursor = commentEnd + 2;
|
||||
continue;
|
||||
}
|
||||
if (char === '/' && next !== '/' && next !== '*') {
|
||||
const close = findRegexLiteralEnd(text, cursor);
|
||||
if (close !== -1) {
|
||||
cursor = close + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
cursor++;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function blankAstroFrontmatterComments(text) {
|
||||
const close = findAstroFrontmatterClose(text);
|
||||
if (close === -1) return text;
|
||||
return stripJsComments(text.slice(0, close)) + text.slice(close);
|
||||
}
|
||||
|
||||
function blankCommentsForMatchers(text, ext) {
|
||||
if (PAGE_ANALYZER_EXTS.has(ext)) {
|
||||
const withFrontmatter = ext === '.astro' ? blankAstroFrontmatterComments(text) : text;
|
||||
return blankHtmlAndCssCommentsOutsideScripts(withFrontmatter);
|
||||
}
|
||||
if (STYLESHEET_EXTS.has(ext)) {
|
||||
const withoutBlocks = stripCssComments(text);
|
||||
return ext === '.css' ? withoutBlocks : blankCssLineComments(withoutBlocks);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function firstOverusedGoogleFont(text) {
|
||||
return extractGoogleFontFamilies(text).find(f => OVERUSED_FONTS.has(f)) || '';
|
||||
}
|
||||
@@ -1028,14 +1176,13 @@ function detectText(content, filePath, options = {}) {
|
||||
const ext = extFromFilePath(filePath);
|
||||
const commentStrippedSource = JS_SOURCE_EXTS.has(ext) ? stripJsComments(content, {
|
||||
jsx: ext === '.js' || ext === '.jsx' || ext === '.tsx',
|
||||
}) : content;
|
||||
}) : blankCommentsForMatchers(content, ext);
|
||||
const source = stripCssInJsComments(commentStrippedSource, ext);
|
||||
const lines = source.split('\n');
|
||||
|
||||
// Run regex matchers on the full file content (catches Tailwind classes, inline styles)
|
||||
// Enable block context for CSS files where related properties span multiple lines
|
||||
const cssLike = new Set(['.css', '.scss', '.sass', '.less']);
|
||||
findings.push(...runRegexMatchers(lines, filePath, 0, cssLike.has(ext) || null, {
|
||||
findings.push(...runRegexMatchers(lines, filePath, 0, STYLESHEET_EXTS.has(ext) || null, {
|
||||
profile,
|
||||
phase: 'source',
|
||||
}));
|
||||
@@ -1050,7 +1197,7 @@ function detectText(content, filePath, options = {}) {
|
||||
scanCssTextForPseudoStripe(text).map(hit =>
|
||||
finding(hit.id, filePath, hit.snippet, lineOffset + text.slice(0, hit.index).split('\n').length));
|
||||
|
||||
if (cssLike.has(ext)) {
|
||||
if (STYLESHEET_EXTS.has(ext)) {
|
||||
findings.push(...scanInsetStripeCss(content, filePath));
|
||||
findings.push(...pseudoStripeFindings(content, 0));
|
||||
}
|
||||
@@ -1078,7 +1225,8 @@ function detectText(content, filePath, options = {}) {
|
||||
}, () => extractStyleBlocks(content, ext))
|
||||
: extractStyleBlocks(content, ext);
|
||||
for (const block of styleBlocks) {
|
||||
const blockLines = block.content.split('\n');
|
||||
const blockContent = blankCssLineComments(stripCssComments(block.content));
|
||||
const blockLines = blockContent.split('\n');
|
||||
findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true, {
|
||||
profile,
|
||||
phase: 'style-block',
|
||||
@@ -1089,8 +1237,8 @@ function detectText(content, filePath, options = {}) {
|
||||
// 1-based, so the offset is startLine - 2; startLine - 1 double-counted and
|
||||
// reported every selector one line low. runRegexMatchers keeps startLine - 1
|
||||
// because it indexes its split lines from zero.
|
||||
findings.push(...scanInsetStripeCss(block.content, filePath, block.startLine - 2));
|
||||
findings.push(...pseudoStripeFindings(block.content, block.startLine - 2));
|
||||
findings.push(...scanInsetStripeCss(blockContent, filePath, block.startLine - 2));
|
||||
findings.push(...pseudoStripeFindings(blockContent, block.startLine - 2));
|
||||
}
|
||||
|
||||
// Extract and scan CSS-in-JS template literals
|
||||
|
||||
@@ -835,10 +835,11 @@ class StaticElement {
|
||||
}
|
||||
}
|
||||
closest(selector) {
|
||||
const matcher = this._doc.matcherFor(selector);
|
||||
let cur = this.node;
|
||||
while (cur && cur.type === 'tag') {
|
||||
try {
|
||||
if (this._doc.is(cur, selector)) return this._doc.wrap(cur);
|
||||
if (matcher(cur)) return this._doc.wrap(cur);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -862,9 +863,10 @@ class StaticDocument {
|
||||
this.root = root;
|
||||
this.selectAll = modules.selectAll;
|
||||
this.selectOne = modules.selectOne;
|
||||
this.is = modules.is;
|
||||
this.compile = modules.compile;
|
||||
this.domutils = modules.domutils;
|
||||
this._wrappers = new WeakMap();
|
||||
this._compiledSelectors = new Map();
|
||||
this._styleMap = new WeakMap();
|
||||
this._hoverStyleMap = new WeakMap();
|
||||
this._accentDashPseudo = new WeakSet();
|
||||
@@ -882,6 +884,20 @@ class StaticDocument {
|
||||
}
|
||||
return wrapped;
|
||||
}
|
||||
matcherFor(selector) {
|
||||
let matcher = this._compiledSelectors.get(selector);
|
||||
if (!matcher) {
|
||||
try {
|
||||
matcher = this.compile(selector);
|
||||
} catch (err) {
|
||||
// Cache the failure as a rethrower so a bad selector still reaches
|
||||
// closest()'s catch on every call, first and repeat alike.
|
||||
matcher = () => { throw err; };
|
||||
}
|
||||
this._compiledSelectors.set(selector, matcher);
|
||||
}
|
||||
return matcher;
|
||||
}
|
||||
querySelectorAll(selector) {
|
||||
try {
|
||||
return this.selectAll(selector, this.root.children || []).map(node => this.wrap(node));
|
||||
|
||||
@@ -134,7 +134,7 @@ async function detectHtml(filePath, options = {}) {
|
||||
parseDocument: htmlparser2.parseDocument,
|
||||
selectAll: cssSelect.selectAll,
|
||||
selectOne: cssSelect.selectOne,
|
||||
is: cssSelect.is,
|
||||
compile: cssSelect.compile,
|
||||
csstree,
|
||||
domutils,
|
||||
};
|
||||
|
||||
@@ -721,20 +721,19 @@ function scanCssTextForGlow(content) {
|
||||
return results;
|
||||
}
|
||||
|
||||
// Decorative grid or line-field backgrounds drawn with hairline
|
||||
// Decorative two-axis grid backgrounds drawn with hairline
|
||||
// linear-gradient layers tiled by a fixed pixel cell. Shared by the HTML
|
||||
// pattern pass and the regex source engine so standalone CSS, component
|
||||
// styles, and inline styles receive the same coverage. Both signals must
|
||||
// co-occur in one declaration block; unrelated rules must not add up across
|
||||
// the file. Returns [{ index, snippet }], capped at one finding per source to
|
||||
// match the page-level HTML check's existing behavior.
|
||||
// the file. A single hairline is a line, divider, or rail, not a grid, even
|
||||
// when tiled by a 2D px cell. Returns [{ index, snippet }], capped at one
|
||||
// finding per source to match the page-level HTML check's existing behavior.
|
||||
function scanCssTextForGridBackground(content) {
|
||||
const hairlineRe = /\b\d{1,3}px\s*,\s*transparent\s+\d{1,3}px/gi;
|
||||
const invertedHairlineRe = /transparent\s+calc\(100%\s*-\s*\d{1,3}px\)/gi;
|
||||
const sizeDeclPxRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\b/i;
|
||||
const sizeDeclPxPairRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\s+\d{1,3}px/i;
|
||||
const shorthandPxAnyRe = /\/\s*\d{1,3}px\b/;
|
||||
const shorthandPxPairRe = /\/\s*\d{1,3}px\s+\d{1,3}px/;
|
||||
const bgDeclRe = /\bbackground(?:-image)?\s*:\s*([^;{}"']*)/gi;
|
||||
const blockRe = /\{([^{}]*)\}|style\s*=\s*"([^"]*)"|style\s*=\s*'([^']*)'/gi;
|
||||
let blk;
|
||||
@@ -751,13 +750,10 @@ function scanCssTextForGridBackground(content) {
|
||||
}
|
||||
if (hairlineCount === 0) continue;
|
||||
const hasPxCell = sizeDeclPxRe.test(block) || shorthandPxAnyRe.test(bgJoined);
|
||||
const hasPxPairCell = sizeDeclPxPairRe.test(block) || shorthandPxPairRe.test(bgJoined);
|
||||
if ((hairlineCount >= 2 && hasPxCell) || hasPxPairCell) {
|
||||
if (hairlineCount >= 2 && hasPxCell) {
|
||||
return [{
|
||||
index: blk.index,
|
||||
snippet: hairlineCount >= 2
|
||||
? 'two-axis grid-line gradient background'
|
||||
: 'px-tiled hairline line-field background',
|
||||
snippet: 'two-axis grid-line gradient background',
|
||||
}];
|
||||
}
|
||||
}
|
||||
@@ -2752,7 +2748,7 @@ function checkElementAIPaletteDOM(el) {
|
||||
}
|
||||
|
||||
// Check for neon text (vivid cyan/purple color on dark background)
|
||||
const textColor = parseRgb(style.color);
|
||||
const textColor = parseRgb(style.color) || parseAnyColor(style.color);
|
||||
if (textColor && hasChroma(textColor, 80)) {
|
||||
const hue = getHue(textColor);
|
||||
const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
|
||||
|
||||
@@ -103,14 +103,22 @@ function extractColorFunctionTokens(value) {
|
||||
function parseGradientColors(bgImage) {
|
||||
if (!bgImage || !bgImage.includes('gradient')) return [];
|
||||
const colors = [];
|
||||
const tokenSpans = [];
|
||||
let from = 0;
|
||||
// Stops arrive in whatever syntax the author wrote and the browser kept.
|
||||
// A dark ground painted as `linear-gradient(oklch(...), oklch(...))` used
|
||||
// to read as a gradient with no stops at all.
|
||||
for (const token of extractColorFunctionTokens(bgImage)) {
|
||||
const start = bgImage.indexOf(token, from);
|
||||
if (start < 0) break;
|
||||
tokenSpans.push({ start, end: start + token.length });
|
||||
from = start + token.length;
|
||||
const c = parseAnyColor(token);
|
||||
if (c) colors.push(c);
|
||||
}
|
||||
for (const m of bgImage.matchAll(/#([0-9a-f]{6}|[0-9a-f]{3})\b/gi)) {
|
||||
// Nested hex inside color-mix is an ingredient, not a stop (issue #578).
|
||||
if (tokenSpans.some(s => m.index >= s.start && m.index < s.end)) continue;
|
||||
const h = m[1];
|
||||
if (h.length === 6) {
|
||||
colors.push({ r: parseInt(h.slice(0,2),16), g: parseInt(h.slice(2,4),16), b: parseInt(h.slice(4,6),16), a: 1 });
|
||||
|
||||
Reference in New Issue
Block a user