Skip to content

Commit b77508c

Browse files
authored
feat(pseudo-classes): support :lang (#1753)
Implementing https://www.w3.org/TR/selectors-4/#the-lang-pseudo
1 parent cd66254 commit b77508c

2 files changed

Lines changed: 126 additions & 0 deletions

File tree

src/pseudo-selectors/filters.ts

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

test/pseudo-classes.ts

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

209+
describe(":lang", () => {
210+
// Single fixture covering inheritance, override, and untagged elements.
211+
const langFixture = parseDocument(
212+
'<div lang="en"><p id="a">A</p><div lang="fr-BE"><p id="b">B</p></div></div><p id="c">C</p>',
213+
);
214+
215+
it.each([
216+
// [selector, expected ids]
217+
[":lang(en)", ["a"]],
218+
[":lang(EN)", ["a"]],
219+
[":lang(fr)", ["b"]],
220+
[":lang(fr-BE)", ["b"]],
221+
[":lang(en, fr)", ["a", "b"]],
222+
[":lang(de)", []],
223+
])("%s matches %j", (selector, expectedIds) => {
224+
const matches = CSSselect.selectAll<AnyNode, Element>(
225+
`p${selector}`,
226+
langFixture,
227+
);
228+
expect(matches.map((element) => element.attribs["id"])).toStrictEqual(
229+
expectedIds,
230+
);
231+
});
232+
233+
it("should not match untagged elements", () => {
234+
expect(
235+
CSSselect.selectAll<AnyNode, Element>("p:lang(en)", langFixture),
236+
).toHaveLength(1);
237+
});
238+
239+
it("should use extended filtering", () => {
240+
const dom = parseDocument(
241+
'<p lang="de-DE">a</p><p lang="de-Latn-DE">b</p><p lang="de-Latn-DE-1996">c</p>',
242+
);
243+
expect(
244+
CSSselect.selectAll<AnyNode, Element>(":lang(de-DE)", dom),
245+
).toHaveLength(3);
246+
});
247+
248+
it("should support wildcard primary subtag", () => {
249+
const dom = parseDocument(
250+
'<p lang="de-CH">a</p><p lang="fr-CH">b</p><p lang="fr-FR">c</p>',
251+
);
252+
expect(
253+
CSSselect.selectAll<AnyNode, Element>(":lang(\\*-CH)", dom),
254+
).toHaveLength(2);
255+
});
256+
257+
it("should support xml:lang", () => {
258+
const dom = parseDocument('<div xml:lang="ja"><p>x</p></div>', {
259+
xmlMode: true,
260+
});
261+
expect(
262+
CSSselect.selectAll<AnyNode, Element>(":lang(ja)", dom),
263+
).toHaveLength(2);
264+
});
265+
});
266+
209267
describe(":read-only and :read-write", () => {
210268
it("should match", () => {
211269
const dom = parseDocument(`

0 commit comments

Comments
 (0)