Skip to content

Commit bd9ae4e

Browse files
committed
feat(pseudo-classes): support :lang
Implementing https://www.w3.org/TR/selectors-4/#the-lang-pseudo Fixes #1302
1 parent cd66254 commit bd9ae4e

2 files changed

Lines changed: 170 additions & 0 deletions

File tree

src/pseudo-selectors/filters.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,31 @@ import { cacheParentResults } from "../helpers/cache.js";
44
import { getElementParent } from "../helpers/querying.js";
55
import type { CompiledQuery, InternalOptions } from "../types.js";
66

7+
/**
8+
* RFC 4647 extended filtering with pre-split subtags.
9+
* @param tag - Lowercased subtags of the element's language value.
10+
* @param range - Lowercased subtags of the language range to match against.
11+
*/
12+
function extendedFilter(tag: string[], range: string[]): boolean {
13+
if (range[0] !== "*" && range[0] !== tag[0]) return false;
14+
15+
let tagIndex = 1;
16+
17+
for (let rangeIndex = 1; rangeIndex < range.length; rangeIndex++) {
18+
if (range[rangeIndex] === "*") continue;
19+
20+
// Skip non-singleton tag subtags until we find a match.
21+
while (tagIndex < tag.length && tag[tagIndex] !== range[rangeIndex]) {
22+
if (tag[tagIndex++].length <= 1) return false;
23+
}
24+
25+
if (tagIndex >= tag.length) return false;
26+
tagIndex++;
27+
}
28+
29+
return true;
30+
}
31+
732
type Filter = <Node, ElementNode extends Node>(
833
next: CompiledQuery<ElementNode>,
934
text: string,
@@ -175,6 +200,46 @@ export const filters: Record<string, Filter> = {
175200
return (element) => context.includes(element) && next(element);
176201
},
177202

203+
lang(next, code, { adapter }) {
204+
const ranges = code.split(",").map((r) =>
205+
r
206+
.trim()
207+
.replace(/^['"]|['"]$/g, "")
208+
.toLowerCase()
209+
.split("-"),
210+
);
211+
212+
return function lang(element) {
213+
let node: typeof element | null = element;
214+
215+
while (node != null) {
216+
const value =
217+
adapter.getAttributeValue(node, "xml:lang") ??
218+
adapter.getAttributeValue(node, "lang");
219+
220+
if (value != null) {
221+
if (!value) {
222+
return ranges.some((r) => r[0] === "") && next(element);
223+
}
224+
225+
const tag = value.toLowerCase().split("-");
226+
return (
227+
ranges.some((r) => extendedFilter(tag, r)) &&
228+
next(element)
229+
);
230+
}
231+
232+
const parent = adapter.getParent(node);
233+
node =
234+
parent != null && adapter.isTag(parent)
235+
? (parent as typeof element)
236+
: null;
237+
}
238+
239+
return ranges.some((r) => r[0] === "") && next(element);
240+
};
241+
},
242+
178243
hover: dynamicStatePseudo("isHovered"),
179244
visited: dynamicStatePseudo("isVisited"),
180245
active: dynamicStatePseudo("isActive"),

test/pseudo-classes.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,111 @@ describe(":has", () => {
206206
});
207207
});
208208

209+
describe(":lang", () => {
210+
it("should match exact language", () => {
211+
const dom = parseDocument('<div lang="en"><p>hello</p></div>');
212+
const matches = CSSselect.selectAll<AnyNode, Element>(":lang(en)", dom);
213+
expect(matches).toHaveLength(2);
214+
});
215+
216+
it("should match language prefix", () => {
217+
const dom = parseDocument('<div lang="en-US"><p>hello</p></div>');
218+
const matches = CSSselect.selectAll<AnyNode, Element>(":lang(en)", dom);
219+
expect(matches).toHaveLength(2);
220+
});
221+
222+
it("should be case-insensitive", () => {
223+
const dom = parseDocument('<div lang="en"><p>hello</p></div>');
224+
expect(
225+
CSSselect.selectAll<AnyNode, Element>(":lang(EN)", dom),
226+
).toHaveLength(2);
227+
228+
const dom2 = parseDocument('<div lang="EN-US"><p>hello</p></div>');
229+
expect(
230+
CSSselect.selectAll<AnyNode, Element>(":lang(en)", dom2),
231+
).toHaveLength(2);
232+
});
233+
234+
it("should inherit from ancestors", () => {
235+
const dom = parseDocument(
236+
'<div lang="fr"><section><p>bonjour</p></section></div>',
237+
);
238+
const matches = CSSselect.selectAll<AnyNode, Element>(
239+
"p:lang(fr)",
240+
dom,
241+
);
242+
expect(matches).toHaveLength(1);
243+
});
244+
245+
it("should not match different languages", () => {
246+
const dom = parseDocument(
247+
'<div lang="fr"><p>bonjour</p></div><div lang="en"><p>hello</p></div>',
248+
);
249+
const matches = CSSselect.selectAll<AnyNode, Element>(":lang(en)", dom);
250+
expect(matches).toHaveLength(2);
251+
});
252+
253+
it("should not match partial non-prefix", () => {
254+
const dom = parseDocument('<div lang="enx"><p>hello</p></div>');
255+
const matches = CSSselect.selectAll<AnyNode, Element>(":lang(en)", dom);
256+
expect(matches).toHaveLength(0);
257+
});
258+
259+
it("should allow closer ancestor to override", () => {
260+
const dom = parseDocument(
261+
'<div lang="en"><div lang="fr"><p>bonjour</p></div></div>',
262+
);
263+
expect(
264+
CSSselect.selectAll<AnyNode, Element>("p:lang(fr)", dom),
265+
).toHaveLength(1);
266+
expect(
267+
CSSselect.selectAll<AnyNode, Element>("p:lang(en)", dom),
268+
).toHaveLength(0);
269+
});
270+
271+
it("should support comma-separated language ranges", () => {
272+
const dom = parseDocument(
273+
'<div lang="en"><p>hello</p></div><div lang="fr"><p>bonjour</p></div><div lang="de"><p>hallo</p></div>',
274+
);
275+
const matches = CSSselect.selectAll<AnyNode, Element>(
276+
":lang(en, fr)",
277+
dom,
278+
);
279+
expect(matches).toHaveLength(4);
280+
});
281+
282+
it("should use extended filtering (RFC 4647)", () => {
283+
// :lang(de-DE) should match de-Latn-DE (skipping single-char subtags)
284+
const dom = parseDocument(
285+
'<p lang="de-DE">a</p><p lang="de-DE-1996">b</p><p lang="de-Latn-DE">c</p><p lang="de-Latf-DE">d</p><p lang="de-Latn-DE-1996">e</p>',
286+
);
287+
const matches = CSSselect.selectAll<AnyNode, Element>(
288+
":lang(de-DE)",
289+
dom,
290+
);
291+
expect(matches).toHaveLength(5);
292+
});
293+
294+
it("should support wildcard primary subtag", () => {
295+
const dom = parseDocument(
296+
'<p lang="de-CH">a</p><p lang="it-CH">b</p><p lang="fr-CH">c</p><p lang="fr-FR">d</p>',
297+
);
298+
const matches = CSSselect.selectAll<AnyNode, Element>(
299+
":lang(\\*-CH)",
300+
dom,
301+
);
302+
expect(matches).toHaveLength(3);
303+
});
304+
305+
it("should support xml:lang attribute", () => {
306+
const dom = parseDocument('<div xml:lang="ja"><p>hello</p></div>', {
307+
xmlMode: true,
308+
});
309+
const matches = CSSselect.selectAll<AnyNode, Element>(":lang(ja)", dom);
310+
expect(matches).toHaveLength(2);
311+
});
312+
});
313+
209314
describe(":read-only and :read-write", () => {
210315
it("should match", () => {
211316
const dom = parseDocument(`

0 commit comments

Comments
 (0)