Skip to content

Commit 9fad747

Browse files
committed
Merge remote-tracking branch 'origin/master' into nth-of
2 parents fb825e0 + b77508c commit 9fad747

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
@@ -5,6 +5,31 @@ import { cacheParentResults } from "../helpers/cache.js";
55
import { getElementParent } from "../helpers/querying.js";
66
import type { CompiledQuery, CompileToken, InternalOptions } from "../types.js";
77

8+
/**
9+
* RFC 4647 extended filtering with pre-split subtags.
10+
* @param tag - Lowercased subtags of the element's language value.
11+
* @param range - Lowercased subtags of the language range to match against.
12+
*/
13+
function extendedFilter(tag: string[], range: string[]): boolean {
14+
if (range[0] !== "*" && range[0] !== tag[0]) return false;
15+
16+
let tagIndex = 1;
17+
18+
for (let rangeIndex = 1; rangeIndex < range.length; rangeIndex++) {
19+
if (range[rangeIndex] === "*") continue;
20+
21+
// Skip non-singleton tag subtags until we find a match.
22+
while (tagIndex < tag.length && tag[tagIndex] !== range[rangeIndex]) {
23+
if (tag[tagIndex++].length <= 1) return false;
24+
}
25+
26+
if (tagIndex >= tag.length) return false;
27+
tagIndex++;
28+
}
29+
30+
return true;
31+
}
32+
833
/** @see {@link https://www.w3.org/TR/selectors-4/#the-nth-child-pseudo} */
934
const nthOfRegex = /^(.+?)\s+of\s+(.+)$/is;
1035

@@ -129,6 +154,49 @@ export const filters: Record<string, Filter> = {
129154
return (element) => context.includes(element) && next(element);
130155
},
131156

157+
lang(next, code, { adapter }) {
158+
const ranges = code
159+
.split(",")
160+
.map((r) => r.trim())
161+
.filter((r) => r.length > 0)
162+
.map((r) =>
163+
r
164+
.replace(/^['"]|['"]$/g, "")
165+
.toLowerCase()
166+
.split("-"),
167+
);
168+
169+
return function lang(element) {
170+
let node: typeof element | null = element;
171+
172+
while (node != null) {
173+
const value =
174+
adapter.getAttributeValue(node, "xml:lang") ??
175+
adapter.getAttributeValue(node, "lang");
176+
177+
if (value != null) {
178+
if (!value) {
179+
return ranges.some((r) => r[0] === "") && next(element);
180+
}
181+
182+
const tag = value.toLowerCase().split("-");
183+
return (
184+
ranges.some((r) => extendedFilter(tag, r)) &&
185+
next(element)
186+
);
187+
}
188+
189+
const parent = adapter.getParent(node);
190+
node =
191+
parent != null && adapter.isTag(parent)
192+
? (parent as typeof element)
193+
: null;
194+
}
195+
196+
return ranges.some((r) => r[0] === "") && next(element);
197+
};
198+
},
199+
132200
hover: dynamicStatePseudo("isHovered"),
133201
visited: dynamicStatePseudo("isVisited"),
134202
active: dynamicStatePseudo("isActive"),

test/pseudo-classes.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,64 @@ describe(":nth-child(An+B of S)", () => {
298298
});
299299
});
300300

301+
describe(":lang", () => {
302+
// Single fixture covering inheritance, override, and untagged elements.
303+
const langFixture = parseDocument(
304+
'<div lang="en"><p id="a">A</p><div lang="fr-BE"><p id="b">B</p></div></div><p id="c">C</p>',
305+
);
306+
307+
it.each([
308+
// [selector, expected ids]
309+
[":lang(en)", ["a"]],
310+
[":lang(EN)", ["a"]],
311+
[":lang(fr)", ["b"]],
312+
[":lang(fr-BE)", ["b"]],
313+
[":lang(en, fr)", ["a", "b"]],
314+
[":lang(de)", []],
315+
])("%s matches %j", (selector, expectedIds) => {
316+
const matches = CSSselect.selectAll<AnyNode, Element>(
317+
`p${selector}`,
318+
langFixture,
319+
);
320+
expect(matches.map((element) => element.attribs["id"])).toStrictEqual(
321+
expectedIds,
322+
);
323+
});
324+
325+
it("should not match untagged elements", () => {
326+
expect(
327+
CSSselect.selectAll<AnyNode, Element>("p:lang(en)", langFixture),
328+
).toHaveLength(1);
329+
});
330+
331+
it("should use extended filtering", () => {
332+
const dom = parseDocument(
333+
'<p lang="de-DE">a</p><p lang="de-Latn-DE">b</p><p lang="de-Latn-DE-1996">c</p>',
334+
);
335+
expect(
336+
CSSselect.selectAll<AnyNode, Element>(":lang(de-DE)", dom),
337+
).toHaveLength(3);
338+
});
339+
340+
it("should support wildcard primary subtag", () => {
341+
const dom = parseDocument(
342+
'<p lang="de-CH">a</p><p lang="fr-CH">b</p><p lang="fr-FR">c</p>',
343+
);
344+
expect(
345+
CSSselect.selectAll<AnyNode, Element>(":lang(\\*-CH)", dom),
346+
).toHaveLength(2);
347+
});
348+
349+
it("should support xml:lang", () => {
350+
const dom = parseDocument('<div xml:lang="ja"><p>x</p></div>', {
351+
xmlMode: true,
352+
});
353+
expect(
354+
CSSselect.selectAll<AnyNode, Element>(":lang(ja)", dom),
355+
).toHaveLength(2);
356+
});
357+
});
358+
301359
describe(":read-only and :read-write", () => {
302360
it("should match", () => {
303361
const dom = parseDocument(`

0 commit comments

Comments
 (0)