Commit 6e3d8371 authored by David Sveningsson's avatar David Sveningsson
Browse files

feat: add `:scope` pseudoselector

fixes #114
parent 8de7b43d
Pipeline #299627470 passed with stages
in 10 minutes and 13 seconds
......@@ -3,42 +3,48 @@ import { Combinator, parseCombinator } from "./combinator";
describe("DOM Combinator", () => {
it("should default to descendant combinator", () => {
expect.assertions(1);
const result = parseCombinator("");
const result = parseCombinator("", "div");
expect(result).toEqual(Combinator.DESCENDANT);
});
it("should parse > as child combinator", () => {
expect.assertions(1);
const result = parseCombinator(">");
const result = parseCombinator(">", "> div");
expect(result).toEqual(Combinator.CHILD);
});
it("should parse + as adjacent sibling combinator", () => {
expect.assertions(1);
const result = parseCombinator("+");
const result = parseCombinator("+", "+ div");
expect(result).toEqual(Combinator.ADJACENT_SIBLING);
});
it("should parse + as general sibling combinator", () => {
expect.assertions(1);
const result = parseCombinator("~");
const result = parseCombinator("~", "~ div");
expect(result).toEqual(Combinator.GENERAL_SIBLING);
});
it("should parse :scope pseudo class", () => {
expect.assertions(1);
const result = parseCombinator("", ":scope");
expect(result).toEqual(Combinator.SCOPE);
});
it("should handle undefined as descendant", () => {
expect.assertions(1);
const result = parseCombinator(undefined);
const result = parseCombinator(undefined, "div");
expect(result).toEqual(Combinator.DESCENDANT);
});
it("should handle null as descendant", () => {
expect.assertions(1);
const result = parseCombinator(null);
const result = parseCombinator(null, "div");
expect(result).toEqual(Combinator.DESCENDANT);
});
it("should throw error on invalid combinator", () => {
expect.assertions(1);
expect(() => parseCombinator("a")).toThrow();
expect(() => parseCombinator("a", "")).toThrow();
});
});
export enum Combinator {
DESCENDANT,
DESCENDANT = 1,
CHILD,
ADJACENT_SIBLING,
GENERAL_SIBLING,
/* special cases */
SCOPE,
}
export function parseCombinator(combinator: string | undefined | null): Combinator {
export function parseCombinator(
combinator: string | undefined | null,
pattern: string
): Combinator {
/* special case, when pattern is :scope [[Selector]] will handle this
* "combinator" to match itself instead of descendants */
if (pattern === ":scope") {
return Combinator.SCOPE;
}
switch (combinator) {
case undefined:
case null:
......
......@@ -748,6 +748,23 @@ describe("HtmlElement", () => {
expect(el.getAttributeValue("class")).toEqual("baz");
});
it("should find element with :scope", () => {
expect.assertions(1);
const markup = `
<h1 id="first"></h1>
<section>
<div><h1 id="second"></h1></div>
<h1 id="third"></h1>
<div><h1 id="forth"></h1></div>
</section>
<h1 id="fifth"></h1>`;
const parser = new Parser(Config.empty().resolve());
const document = parser.parseHtml(markup);
const section = document.querySelector("section");
const el = section.querySelectorAll(":scope > h1");
expect(el.map((it) => it.id)).toEqual(["third"]);
});
it("should return null if nothing matches", () => {
expect.assertions(1);
const el = document.querySelector("foobar");
......
import { HtmlElement } from "../htmlelement";
import { SelectorContext } from "../selector-context";
import { firstChild } from "./first-child";
import { lastChild } from "./last-child";
import { nthChild } from "./nth-child";
import { scope } from "./scope";
type PseudoClassFunction = (node: HtmlElement, args?: string) => boolean;
type PseudoClassFunction = (this: SelectorContext, node: HtmlElement, args?: string) => boolean;
type PseudoClassTable = Record<string, PseudoClassFunction>;
const table: PseudoClassTable = {
"first-child": firstChild,
"last-child": lastChild,
"nth-child": nthChild,
scope: scope,
};
export function factory(name: string): PseudoClassFunction {
export function factory(
name: string,
context: SelectorContext
): OmitThisParameter<PseudoClassFunction> {
const fn = table[name];
if (fn) {
return fn;
return fn.bind(context);
} else {
throw new Error(`Pseudo-class "${name}" is not implemented`);
}
......
import { Location } from "../../context";
import { HtmlElement, NodeClosed } from "../htmlelement";
import { SelectorContext } from "../selector-context";
import { scope } from "./scope";
const location: Location = {
filename: "inline",
line: 1,
column: 1,
offset: 0,
size: 1,
};
it("should return true if matching itself", () => {
expect.assertions(2);
const parent = new HtmlElement("parent", null, NodeClosed.EndTag, null, location);
const a = new HtmlElement("a", parent, NodeClosed.EndTag, null, location);
const b = new HtmlElement("b", parent, NodeClosed.EndTag, null, location);
const context: SelectorContext = {
scope: a,
};
expect(scope.call(context, a)).toBeTruthy();
expect(scope.call(context, b)).toBeFalsy();
});
import { HtmlElement } from "../htmlelement";
import { SelectorContext } from "../selector-context";
export function scope(this: SelectorContext, node: HtmlElement): boolean {
return node.isSameNode(this.scope);
}
import { HtmlElement } from "./htmlelement";
export interface SelectorContext {
/** Scope element */
scope: HtmlElement;
}
......@@ -221,6 +221,15 @@ describe("Selector", () => {
]);
});
it("should match with :scope", () => {
expect.assertions(1);
const element = doc.querySelector("bar");
const selector = new Selector(":scope > foo");
expect(fetch(selector.match(element))).toEqual([
expect.objectContaining({ tagName: "foo", testId: "foo-4" }),
]);
});
it("should throw error for missing pseudo-class", () => {
expect.assertions(1);
expect(() => new Selector("foo:")).toThrow(
......
......@@ -2,6 +2,7 @@ import { Attribute } from "./attribute";
import { Combinator, parseCombinator } from "./combinator";
import { HtmlElement } from "./htmlelement";
import { factory as pseudoClassFunction } from "./pseudoclass";
import { SelectorContext } from "./selector-context";
/**
* Homage to PHP: unescapes slashes.
......@@ -16,7 +17,7 @@ abstract class Matcher {
/**
* Returns `true` if given node matches.
*/
public abstract match(node: HtmlElement): boolean;
public abstract match(node: HtmlElement, context: SelectorContext): boolean;
}
class ClassMatcher extends Matcher {
......@@ -88,8 +89,8 @@ class PseudoClassMatcher extends Matcher {
this.args = args;
}
public match(node: HtmlElement): boolean {
const fn = pseudoClassFunction(this.name);
public match(node: HtmlElement, context: SelectorContext): boolean {
const fn = pseudoClassFunction(this.name, context);
return fn(node, this.args);
}
}
......@@ -104,14 +105,14 @@ export class Pattern {
const match = pattern.match(/^([~+\->]?)((?:[*]|[^.#[:]+)?)(.*)$/) as RegExpMatchArray;
match.shift(); /* remove full matched string */
this.selector = pattern;
this.combinator = parseCombinator(match.shift());
this.combinator = parseCombinator(match.shift(), pattern);
this.tagName = match.shift() || "*";
const p = match[0] ? match[0].split(/(?=(?<!\\)[.#[:])/) : [];
this.pattern = p.map((cur: string) => this.createMatcher(cur));
}
public match(node: HtmlElement): boolean {
return node.is(this.tagName) && this.pattern.every((cur: Matcher) => cur.match(node));
public match(node: HtmlElement, context: SelectorContext): boolean {
return node.is(this.tagName) && this.pattern.every((cur: Matcher) => cur.match(node, context));
}
private createMatcher(pattern: string): Matcher {
......@@ -149,10 +150,15 @@ export class Selector {
* @returns Iterator with matched elements.
*/
public *match(root: HtmlElement): IterableIterator<HtmlElement> {
yield* this.matchInternal(root, 0);
const context: SelectorContext = { scope: root };
yield* this.matchInternal(root, 0, context);
}
private *matchInternal(root: HtmlElement, level: number): IterableIterator<HtmlElement> {
private *matchInternal(
root: HtmlElement,
level: number,
context: SelectorContext
): IterableIterator<HtmlElement> {
if (level >= this.pattern.length) {
yield root;
return;
......@@ -162,11 +168,11 @@ export class Selector {
const matches = Selector.findCandidates(root, pattern);
for (const node of matches) {
if (!pattern.match(node)) {
if (!pattern.match(node, context)) {
continue;
}
yield* this.matchInternal(node, level + 1);
yield* this.matchInternal(node, level + 1, context);
}
}
......@@ -189,6 +195,8 @@ export class Selector {
return Selector.findAdjacentSibling(root);
case Combinator.GENERAL_SIBLING:
return Selector.findGeneralSibling(root);
case Combinator.SCOPE:
return [root];
}
/* istanbul ignore next: fallback solution, the switch cases should cover
* everything and there is no known way to trigger this fallback */
......
import { sliceLocation } from "../context";
import { HtmlElement, Pattern } from "../dom";
import { DOMInternalID } from "../dom/domnode";
import { SelectorContext } from "../dom/selector-context";
import { TagCloseEvent, TagReadyEvent, TagStartEvent } from "../event";
import { Rule, RuleDocumentation, ruleDocumentationUrl, SchemaObject } from "../rule";
......@@ -159,6 +160,9 @@ export default class HeadingLevel extends Rule<void, RuleOptions> {
}
private isSectioningRoot(node: HtmlElement): boolean {
return this.sectionRoots.some((it) => it.match(node));
const context: SelectorContext = {
scope: node,
};
return this.sectionRoots.some((it) => it.match(node, context));
}
}
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment