Summary
getElementSelector() directly interpolates filtered HTML class names into CSS selectors. However, the current UNSAFE_CSS_CLASS_RE does not cover characters such as @, /, and ..
For example, the following valid HTML class reliably reproduces the problem:
<div hidden class="@container/main">
It produces this selector:
body > div.@container/main
When that selector is passed back to querySelector(), it throws a SyntaxError.
This prevents Defuddle from focusing the intended hidden content and can cause it to return a body-level result that includes unrelated page content.
Environment
- Defuddle commit:
6de1634
- Vitest:
3.2.6
- DOM implementation:
jsdom
- OS: Linux / WSL
Minimal reproduction
Create the following file in the repository:
tests/css-special-class-selector.test.ts
Use this complete test file:
import { describe, expect, test, vi } from 'vitest';
import Defuddle from '../src/index';
import { parseDocument } from './helpers';
const longText = `
This is the main body content of the page. It contains enough meaningful
English prose to look like genuine article content to the extraction algorithm.
We repeat this content several times so that Defuddle has a strong candidate
for the primary content element on the page.
`.repeat(8);
const html = `
<!DOCTYPE html>
<html>
<head><title>Selector reproduction</title></head>
<body>
<p>Short visible content.</p>
<div hidden class="@container/main">
<div class="group/scroll-root">
<div class="mb-5.5">
<h1>Hidden article</h1>
<p>${longText}</p>
</div>
</div>
</div>
</body>
</html>
`;
describe('CSS-special class selector regression', () => {
test('generated hidden-content selector round-trips through querySelector', () => {
const doc = parseDocument(html, 'https://example.com/');
const defuddle = new Defuddle(doc);
const selector = (defuddle as unknown as {
findLargestHiddenContentSelector(): string | undefined;
}).findLargestHiddenContentSelector();
expect(selector).toBeTruthy();
expect(() => doc.querySelector(selector!)).not.toThrow();
expect(doc.querySelector(selector!)).not.toBeNull();
});
test('public parse does not silently fall back to the whole body', () => {
const doc = parseDocument(html, 'https://example.com/');
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
const result = new Defuddle(doc).parse();
const errors = consoleError.mock.calls;
consoleError.mockRestore();
expect(errors).toEqual([]);
expect(result.content).toContain('Hidden article');
expect(result.content).not.toContain('Short visible content.');
});
});
Run:
DOM=jsdom npx vitest run \
tests/css-special-class-selector.test.ts \
--reporter=verbose
Actual behavior
Both tests fail:
Test Files 1 failed
Tests 2 failed
The first test shows that the generated selector cannot be parsed:
SyntaxError:
'body > div.@container/main' is not a valid selector
The second test shows that parse() catches the same error:
Defuddle Error processing document:
SyntaxError {
message:
"'body > div.@container/main' is not a valid selector"
}
The result then falls back to body-level content and still contains text that should be outside the extracted article:
Expected behavior
- Selectors generated by Defuddle should always be safe to pass to
querySelector().
- Class names such as
@container/main, which cannot be interpolated into a CSS selector as-is, should not be appended directly to the selector.
- The result should focus on
Hidden article and should not include Short visible content.
- Parsing should not log
Error processing document.
Root cause
The current code uses:
const UNSAFE_CSS_CLASS_RE = /[:\[\]()#>~+,]/;
It then directly appends every class that does not match this regular expression:
const safe = getClassName(current)
.trim()
.split(/\s+/)
.filter(cls => !UNSAFE_CSS_CLASS_RE.test(cls));
selector += '.' + safe.join('.');
Because @container/main does not match the current regular expression, it is incorrectly treated as safe and produces:
body > div.@container/main
HTML class attributes may contain these characters, but a class being valid in HTML does not mean that it is safe to interpolate unescaped as a CSS identifier.
Relationship to #352 / #358
This problem is related to, but distinct from, #352 and PR #358:
- Issue #352 / PR #358 handle IDs containing CSS-special characters, such as
id="S:a".
- This issue concerns class names containing CSS-special characters, such as
class="@container/main".
The code comment added in PR #358 explicitly states that classes carrying CSS syntax are intentionally dropped rather than escaped. Dropping such classes is therefore a deliberate design choice, but the current filter fails to recognize @container/main as unsafe.
I also ran the same test against the current head of PR #358, 6fdb351. The new contentSelector error handling prevents the exception from propagating, but the generated selector is still body > div.@container/main, and the final result still contains the body-level Short visible content. text.
Summary
getElementSelector()directly interpolates filtered HTML class names into CSS selectors. However, the currentUNSAFE_CSS_CLASS_REdoes not cover characters such as@,/, and..For example, the following valid HTML class reliably reproduces the problem:
It produces this selector:
When that selector is passed back to
querySelector(), it throws aSyntaxError.This prevents Defuddle from focusing the intended hidden content and can cause it to return a body-level result that includes unrelated page content.
Environment
6de16343.2.6jsdomMinimal reproduction
Create the following file in the repository:
Use this complete test file:
Run:
Actual behavior
Both tests fail:
The first test shows that the generated selector cannot be parsed:
The second test shows that
parse()catches the same error:The result then falls back to body-level content and still contains text that should be outside the extracted article:
Expected behavior
querySelector().@container/main, which cannot be interpolated into a CSS selector as-is, should not be appended directly to the selector.Hidden articleand should not includeShort visible content.Error processing document.Root cause
The current code uses:
It then directly appends every class that does not match this regular expression:
Because
@container/maindoes not match the current regular expression, it is incorrectly treated as safe and produces:HTML class attributes may contain these characters, but a class being valid in HTML does not mean that it is safe to interpolate unescaped as a CSS identifier.
Relationship to #352 / #358
This problem is related to, but distinct from, #352 and PR #358:
id="S:a".class="@container/main".The code comment added in PR #358 explicitly states that classes carrying CSS syntax are intentionally dropped rather than escaped. Dropping such classes is therefore a deliberate design choice, but the current filter fails to recognize
@container/mainas unsafe.I also ran the same test against the current head of PR #358,
6fdb351. The newcontentSelectorerror handling prevents the exception from propagating, but the generated selector is stillbody > div.@container/main, and the final result still contains the body-levelShort visible content.text.