mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-15 15:46:30 +03:00
Fix: resolve root-relative linked stylesheets in static detect (#652)
Root-relative hrefs like /static/app.css were treated as OS-absolute and silently dropped, hiding contrast findings. AI assistance: implemented with Cursor Grok 4.6. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -964,6 +964,30 @@ function buildStaticWindow(staticDoc) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const warnedMissingStylesheets = new Set();
|
||||||
|
|
||||||
|
function resolveLinkedCssPath(fileDir, href) {
|
||||||
|
const stripped = href.split(/[?#]/)[0];
|
||||||
|
const rootRelative = stripped.startsWith('/') && !stripped.startsWith('//');
|
||||||
|
if (!rootRelative) return path.resolve(fileDir, stripped);
|
||||||
|
const rel = stripped.replace(/^\/+/, '');
|
||||||
|
let dir = fileDir;
|
||||||
|
for (;;) {
|
||||||
|
const parent = path.dirname(dir);
|
||||||
|
if (parent === dir) break; // never use the filesystem root as document root
|
||||||
|
try {
|
||||||
|
const candidate = path.join(dir, rel);
|
||||||
|
if (fs.statSync(candidate).isFile()) return candidate;
|
||||||
|
} catch { /* missing or unreadable candidate */ }
|
||||||
|
// Stop at the project root so a coincidental ~/static/app.css cannot win.
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(path.join(dir, 'package.json')) || fs.existsSync(path.join(dir, '.git'))) break;
|
||||||
|
} catch { /* unreadable marker */ }
|
||||||
|
dir = parent;
|
||||||
|
}
|
||||||
|
return path.join(fileDir, rel);
|
||||||
|
}
|
||||||
|
|
||||||
function collectStaticCssText(root, fileDir, profile, filePath, modules) {
|
function collectStaticCssText(root, fileDir, profile, filePath, modules) {
|
||||||
const styleTexts = [];
|
const styleTexts = [];
|
||||||
for (const styleEl of modules.selectAll('style', root.children || [])) {
|
for (const styleEl of modules.selectAll('style', root.children || [])) {
|
||||||
@@ -974,10 +998,10 @@ function collectStaticCssText(root, fileDir, profile, filePath, modules) {
|
|||||||
const rel = link.attribs?.rel || '';
|
const rel = link.attribs?.rel || '';
|
||||||
const href = link.attribs?.href || '';
|
const href = link.attribs?.href || '';
|
||||||
if (!/\bstylesheet\b/i.test(rel) || !href || /^(https?:)?\/\//i.test(href)) continue;
|
if (!/\bstylesheet\b/i.test(rel) || !href || /^(https?:)?\/\//i.test(href)) continue;
|
||||||
// Cache-busting hrefs (styles.css?v=3) resolve to the file, not to a
|
// Cache-busting (styles.css?v=3) and root-relative (/static/app.css) hrefs
|
||||||
// literal path with the query in it; a versioned link otherwise made the
|
// must not resolve as OS-absolute paths; otherwise the whole stylesheet is
|
||||||
// whole stylesheet invisible to every element-level check.
|
// invisible to every element-level check.
|
||||||
const cssPath = path.resolve(fileDir, href.split(/[?#]/)[0]);
|
const cssPath = resolveLinkedCssPath(fileDir, href);
|
||||||
try {
|
try {
|
||||||
const css = profileStep(profile, {
|
const css = profileStep(profile, {
|
||||||
engine: 'static-html',
|
engine: 'static-html',
|
||||||
@@ -987,7 +1011,14 @@ function collectStaticCssText(root, fileDir, profile, filePath, modules) {
|
|||||||
detail: href,
|
detail: href,
|
||||||
}, () => fs.readFileSync(cssPath, 'utf-8'));
|
}, () => fs.readFileSync(cssPath, 'utf-8'));
|
||||||
styleTexts.push(css);
|
styleTexts.push(css);
|
||||||
} catch { /* skip unreadable */ }
|
} catch {
|
||||||
|
if (!warnedMissingStylesheets.has(cssPath)) {
|
||||||
|
warnedMissingStylesheets.add(cssPath);
|
||||||
|
process.stderr.write(
|
||||||
|
`impeccable detect: could not read linked stylesheet ${href} (resolved to ${cssPath}); color and custom-property rules will be incomplete\n`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return styleTexts.join('\n');
|
return styleTexts.join('\n');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1321,6 +1321,66 @@ describe('detectHtml — static HTML/CSS engine', () => {
|
|||||||
expect(findingIds(f)).toContain('side-tab');
|
expect(findingIds(f)).toContain('side-tab');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('resolves root-relative linked stylesheets with cache-busting query', async () => {
|
||||||
|
await withStaticFixture({
|
||||||
|
'index.html': `<!DOCTYPE html><html><head>
|
||||||
|
<link rel="stylesheet" href="/static/app.css?v=3">
|
||||||
|
</head><body><div class="card">Card</div></body></html>`,
|
||||||
|
'static/app.css': '.card { border-left: 5px solid #3b82f6; border-radius: 4px; }',
|
||||||
|
}, async ({ file }) => {
|
||||||
|
const f = await detectHtml(file);
|
||||||
|
expect(findingIds(f)).toContain('side-tab');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolves root-relative linked stylesheets from nested pages via ancestor walk', async () => {
|
||||||
|
await withStaticFixture({
|
||||||
|
'pages/about.html': `<!DOCTYPE html><html><head>
|
||||||
|
<link rel="stylesheet" href="/static/app.css">
|
||||||
|
</head><body><div class="card">Card</div></body></html>`,
|
||||||
|
'static/app.css': '.card { border-left: 5px solid #3b82f6; border-radius: 4px; }',
|
||||||
|
}, async ({ dir }) => {
|
||||||
|
const f = await detectHtml(path.join(dir, 'pages', 'about.html'));
|
||||||
|
expect(findingIds(f)).toContain('side-tab');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not resolve root-relative sheets above the project root', async () => {
|
||||||
|
await withStaticFixture({
|
||||||
|
'project/package.json': '{}',
|
||||||
|
'project/index.html': `<!DOCTYPE html><html><head>
|
||||||
|
<link rel="stylesheet" href="/static/app.css">
|
||||||
|
</head><body><div class="card">Card</div></body></html>`,
|
||||||
|
'static/app.css': '.card { border-left: 5px solid #3b82f6; border-radius: 4px; }',
|
||||||
|
}, async ({ dir }) => {
|
||||||
|
const f = await detectHtml(path.join(dir, 'project', 'index.html'));
|
||||||
|
expect(findingIds(f)).not.toContain('side-tab');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('warns when a linked stylesheet cannot be read', async () => {
|
||||||
|
const writes = [];
|
||||||
|
const origWrite = process.stderr.write.bind(process.stderr);
|
||||||
|
process.stderr.write = (chunk, ...args) => {
|
||||||
|
writes.push(String(chunk));
|
||||||
|
return origWrite(chunk, ...args);
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await withStaticFixture({
|
||||||
|
'index.html': `<!DOCTYPE html><html><head>
|
||||||
|
<link rel="stylesheet" href="/missing/app.css">
|
||||||
|
</head><body><div>Page</div></body></html>`,
|
||||||
|
}, async ({ file, dir }) => {
|
||||||
|
await detectHtml(file);
|
||||||
|
const msg = writes.join('');
|
||||||
|
expect(msg).toContain('could not read linked stylesheet /missing/app.css');
|
||||||
|
expect(msg).toContain(`resolved to ${path.join(dir, 'missing', 'app.css')}`);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
process.stderr.write = origWrite;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('gradient-text: a style="" attribute alone carries the page-level flag', async () => {
|
test('gradient-text: a style="" attribute alone carries the page-level flag', async () => {
|
||||||
await withStaticFixture({
|
await withStaticFixture({
|
||||||
'index.html': `<!DOCTYPE html><html><head><title>t</title></head><body>
|
'index.html': `<!DOCTYPE html><html><head><title>t</title></head><body>
|
||||||
|
|||||||
Reference in New Issue
Block a user