Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions src/pseudo-selectors/filters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,31 @@ import { cacheParentResults } from "../helpers/cache.js";
import { getElementParent } from "../helpers/querying.js";
import type { CompiledQuery, InternalOptions } from "../types.js";

/**
* RFC 4647 extended filtering with pre-split subtags.
* @param tag - Lowercased subtags of the element's language value.
* @param range - Lowercased subtags of the language range to match against.
*/
function extendedFilter(tag: string[], range: string[]): boolean {
if (range[0] !== "*" && range[0] !== tag[0]) return false;

let tagIndex = 1;

for (let rangeIndex = 1; rangeIndex < range.length; rangeIndex++) {
if (range[rangeIndex] === "*") continue;

// Skip non-singleton tag subtags until we find a match.
while (tagIndex < tag.length && tag[tagIndex] !== range[rangeIndex]) {
if (tag[tagIndex++].length <= 1) return false;
}

if (tagIndex >= tag.length) return false;
tagIndex++;
}

return true;
}

type Filter = <Node, ElementNode extends Node>(
next: CompiledQuery<ElementNode>,
text: string,
Expand Down Expand Up @@ -175,6 +200,49 @@ export const filters: Record<string, Filter> = {
return (element) => context.includes(element) && next(element);
},

lang(next, code, { adapter }) {
const ranges = code
.split(",")
.map((r) => r.trim())
.filter((r) => r.length > 0)
.map((r) =>
r
.replace(/^['"]|['"]$/g, "")
.toLowerCase()
.split("-"),
);

return function lang(element) {
let node: typeof element | null = element;

while (node != null) {
const value =
adapter.getAttributeValue(node, "xml:lang") ??
Comment thread
fb55 marked this conversation as resolved.
adapter.getAttributeValue(node, "lang");

if (value != null) {
if (!value) {
return ranges.some((r) => r[0] === "") && next(element);
}
Comment thread
fb55 marked this conversation as resolved.

const tag = value.toLowerCase().split("-");
return (
ranges.some((r) => extendedFilter(tag, r)) &&
next(element)
);
}

const parent = adapter.getParent(node);
node =
parent != null && adapter.isTag(parent)
? (parent as typeof element)
: null;
}

return ranges.some((r) => r[0] === "") && next(element);
};
Comment thread
fb55 marked this conversation as resolved.
},

hover: dynamicStatePseudo("isHovered"),
visited: dynamicStatePseudo("isVisited"),
active: dynamicStatePseudo("isActive"),
Expand Down
58 changes: 58 additions & 0 deletions test/pseudo-classes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,64 @@ describe(":has", () => {
});
});

describe(":lang", () => {
// Single fixture covering inheritance, override, and untagged elements.
const langFixture = parseDocument(
'<div lang="en"><p id="a">A</p><div lang="fr-BE"><p id="b">B</p></div></div><p id="c">C</p>',
);

it.each([
// [selector, expected ids]
[":lang(en)", ["a"]],
[":lang(EN)", ["a"]],
[":lang(fr)", ["b"]],
[":lang(fr-BE)", ["b"]],
[":lang(en, fr)", ["a", "b"]],
[":lang(de)", []],
])("%s matches %j", (selector, expectedIds) => {
const matches = CSSselect.selectAll<AnyNode, Element>(
`p${selector}`,
langFixture,
);
expect(matches.map((element) => element.attribs["id"])).toStrictEqual(
expectedIds,
);
});

it("should not match untagged elements", () => {
expect(
CSSselect.selectAll<AnyNode, Element>("p:lang(en)", langFixture),
).toHaveLength(1);
});

it("should use extended filtering", () => {
const dom = parseDocument(
'<p lang="de-DE">a</p><p lang="de-Latn-DE">b</p><p lang="de-Latn-DE-1996">c</p>',
);
expect(
CSSselect.selectAll<AnyNode, Element>(":lang(de-DE)", dom),
).toHaveLength(3);
});

it("should support wildcard primary subtag", () => {
const dom = parseDocument(
'<p lang="de-CH">a</p><p lang="fr-CH">b</p><p lang="fr-FR">c</p>',
);
expect(
CSSselect.selectAll<AnyNode, Element>(":lang(\\*-CH)", dom),
).toHaveLength(2);
});

it("should support xml:lang", () => {
const dom = parseDocument('<div xml:lang="ja"><p>x</p></div>', {
xmlMode: true,
});
expect(
CSSselect.selectAll<AnyNode, Element>(":lang(ja)", dom),
).toHaveLength(2);
});
});

describe(":read-only and :read-write", () => {
it("should match", () => {
const dom = parseDocument(`
Expand Down
Loading