diff --git a/.changeset/four-parents-jog.md b/.changeset/four-parents-jog.md new file mode 100644 index 0000000000..4e0760b34a --- /dev/null +++ b/.changeset/four-parents-jog.md @@ -0,0 +1,9 @@ +--- +"@siteimprove/alfa-cascade": minor +--- + +**Breaking:** `RuleTree.add` and `RuleTree.Node.add` have been made internal. + +These have heavy assumptions on arguments in order to build a correct rule tree and are not intended for external use. Use `Cascade.of` to build correct cascade and rule trees. + +In addition, `RuleTree.Node.add` has been moved to an instance method, and its arguments have been simplified. diff --git a/.changeset/pink-walls-remain.md b/.changeset/pink-walls-remain.md new file mode 100644 index 0000000000..4cedf75880 --- /dev/null +++ b/.changeset/pink-walls-remain.md @@ -0,0 +1,5 @@ +--- +"@siteimprove/alfa-selector": minor +--- + +**Added:** Selectors now contain a "key selector" which is the leftmost simple selector in a compound one, or the rightmost in a complex one. diff --git a/.changeset/tiny-eyes-attack.md b/.changeset/tiny-eyes-attack.md new file mode 100644 index 0000000000..5eda9824fe --- /dev/null +++ b/.changeset/tiny-eyes-attack.md @@ -0,0 +1,7 @@ +--- +"@siteimprove/alfa-cascade": minor +--- + +**Breaking:** `Cascade.get()` now returns a `RuleTree.Node` instead of an `Option`. + +`RuleTree` now have a fake root with no declarations, if no rule matches the current element, `Cascade.get(element)` will return that fake root. diff --git a/docs/review/api/alfa-cascade.api.md b/docs/review/api/alfa-cascade.api.md index b94813bf71..407bd13c3d 100644 --- a/docs/review/api/alfa-cascade.api.md +++ b/docs/review/api/alfa-cascade.api.md @@ -23,7 +23,7 @@ export class Cascade implements Serializable { // Warning: (ae-forgotten-export) The symbol "AncestorFilter" needs to be exported by the entry point index.d.ts // // (undocumented) - get(element: Element, context?: Context, filter?: Option): Option; + get(element: Element, context?: Context, filter?: Option): RuleTree.Node; // (undocumented) static of(node: Document | Shadow, device: Device): Cascade; // (undocumented) @@ -51,12 +51,8 @@ export namespace Cascade { // @public export class RuleTree implements Serializable { - // (undocumented) - add(rules: Iterable_2<{ - rule: Rule; - selector: Selector; - declarations: Iterable_2; - }>): Option; + // @internal + add(rules: Iterable_2): RuleTree.Node; // (undocumented) static empty(): RuleTree; // (undocumented) @@ -65,12 +61,35 @@ export class RuleTree implements Serializable { // @public (undocumented) export namespace RuleTree { + // @internal + export interface Item { + // (undocumented) + declarations: Iterable_2; + // (undocumented) + rule: Rule; + // (undocumented) + selector: Selector; + } + // (undocumented) + export namespace Item { + // (undocumented) + export interface JSON { + // (undocumented) + [key: string]: json.JSON; + // (undocumented) + declarations: Array; + // (undocumented) + rule: Rule.JSON; + // (undocumented) + selector: Selector.JSON; + } + } // (undocumented) export type JSON = Array; // (undocumented) export class Node implements Serializable { - // (undocumented) - static add(rule: Rule, selector: Selector, declarations: Iterable_2, children: Array, parent: Option): Node; + // @internal + add(item: Item): Node; // (undocumented) ancestors(): Iterable_2; // (undocumented) @@ -80,7 +99,7 @@ export namespace RuleTree { // (undocumented) inclusiveAncestors(): Iterable_2; // (undocumented) - static of(rule: Rule, selector: Selector, declarations: Iterable_2, children: Array, parent: Option): Node; + static of({ rule, selector, declarations }: Item, children: Array, parent: Option): Node; // (undocumented) get parent(): Option; // (undocumented) @@ -99,11 +118,7 @@ export namespace RuleTree { // (undocumented) children: Array; // (undocumented) - declarations: Array; - // (undocumented) - rule: Rule.JSON; - // (undocumented) - selector: Selector.JSON; + item: Item.JSON; } } } diff --git a/docs/review/api/alfa-selector.api.md b/docs/review/api/alfa-selector.api.md index 51b103c2e4..052ae4790e 100644 --- a/docs/review/api/alfa-selector.api.md +++ b/docs/review/api/alfa-selector.api.md @@ -110,6 +110,8 @@ export class Class extends WithName<"class"> { // (undocumented) equals(value: unknown): value is this; // (undocumented) + protected readonly _key: Option; + // (undocumented) matches(element: Element): boolean; // (undocumented) static of(name: string): Class; @@ -161,6 +163,8 @@ export class Complex extends Selector_2<"complex"> { // (undocumented) equals(value: unknown): value is this; // (undocumented) + protected readonly _key: Option; + // (undocumented) get left(): Simple | Compound | Complex; // (undocumented) matches(element: Element, context?: Context): boolean; @@ -200,6 +204,8 @@ export class Compound extends Selector_2<"compound"> { // (undocumented) equals(value: unknown): value is this; // (undocumented) + protected readonly _key: Option; + // (undocumented) get length(): number; // (undocumented) matches(element: Element, context?: Context): boolean; @@ -296,6 +302,8 @@ export class Id extends WithName<"id"> { // (undocumented) equals(value: unknown): value is this; // (undocumented) + protected readonly _key: Option; + // (undocumented) matches(element: Element): boolean; // (undocumented) static of(name: string): Id; @@ -495,6 +503,8 @@ export class Type extends WithName<"type"> { // (undocumented) equals(value: unknown): value is this; // (undocumented) + protected readonly _key: Option; + // (undocumented) matches(element: Element): boolean; // (undocumented) get namespace(): Option; diff --git a/docs/review/api/alfa-style.api.md b/docs/review/api/alfa-style.api.md index 006f52590b..a504a47d4f 100644 --- a/docs/review/api/alfa-style.api.md +++ b/docs/review/api/alfa-style.api.md @@ -352,7 +352,6 @@ export namespace Style { export type Computed = Longhands.Computed; // (undocumented) export type Declared = Longhands.Declared; - // (undocumented) export function from(element: Element, device: Device, context?: Context): Style; // (undocumented) export type Inherited = Longhands.Inherited; diff --git a/packages/alfa-cascade/README.md b/packages/alfa-cascade/README.md new file mode 100644 index 0000000000..d11480bd87 --- /dev/null +++ b/packages/alfa-cascade/README.md @@ -0,0 +1,35 @@ +# Alfa Cascade + +This package builds the cascade which is then used by `@siteimprove/alfa-style` in order to find the cascaded value of each property. + +While resolving the cascade is in theory somewhat easy (for each element and property, find the highest precedence declaration), it requires a lot of work. A typical page contain hundreds of elements and possibly thousands of style rules, and we support nearly 150 properties. So, we cannot just brute force our way through this and need structures to help eliminate quickly most of the selectors. This is especially true for the descendants and sibling selectors whose matching require traversing the DOM tree, potentially at a far away place for something like `main .foo` + +## Ancestor filter + +The ancestor filter is a structure to optimize matching of descendants selectors. It is build during a depth-first traversal of the DOM tree. While inspecting each element (and trying to match selectors), we keep a list of the ancestors we've found. + +In order to be compact and efficient, we just count the number of each type, class, and id on the path to the element. So, for example, a `div.foo .bar` selector cannot match if there is no `div` type or `.foo` class along the path. We cannot just keep a boolean because we want to be able to update the ancestor filter during the "upward moves" of the traversal, which requires removing elements from it, so we need a precise count to figure out when it reaches 0. + +The ancestor filter only allows for guaranteed "won't match" answers, because the type, class and id have been separated for the sake of compactness. For example, a `div.foo .bar` selector won't match if the `div` and `.foo` ancestors are different, but the ancestor filter doesn't hold that information. However, the filter greatly reduce the actual number of elements to test against each descendant selector and thus the amount of work to be done. + +## Key selector and Selector map + +The other tool to speed up matching of complex (and compound) selectors is the selector map. + +Each selector is associated to a _key selector_ which is the part which is going to be matched against the element itself (not against its siblings or ancestors). For complex selectors, the key selector is thus the rightmost selector. For compound selectors, it could be any selector; we take the leftmost one (mostly to limit the risk of key selector being pseudo-classes or -elements; key selectors are not really built for these). + +That is, in a `div.foo .bar` selector, the key selector is `.bar`. Any element that matches the full `div.foo .bar` selector must necessarily be a `.bar` itself (plus some DOM tree context). For anything else, we don't need to look at DOM structure. Similarly, in the `div.foo` selector, the key selector is `div`. + +Conversely, an element can only match selectors if it matches their key selector. So, a `` can only match selectors whose key selector is either `span`, `.bar`, `.baz`, or `#my-id`. + +The selector map groups selectors by their key selector. Thus, when searching for selectors that may match a given element, we only ask the selector map for selectors that have one of the possible key selectors and greatly reduce the search space. + +## Rule tree + +The rule tree (actually a forest) is a representation of the association between elements and the list of selectors (actually, rules) that they match, in decreasing precedence (according to cascade sorting). + +Using a tree, rather than a separated list for each element allows to share the selectors that are matching several elements and reduce the memory usage. + +## Cascade + +The cascade itself is a rule tree associated with a map from elements to nodes in it. Each element is mapped to its highest precedence selector in the rule tree. Thus, in order to find the cascaded value of any property for a given element, we can simply walk up the rule tree until we find a selector (and associated rule) declaring that property. Since we've walk up the tree from the highest possible precedence to the lowest, this will be the cascaded value, no matter if more rules up the tree also define this property. diff --git a/packages/alfa-cascade/package.json b/packages/alfa-cascade/package.json index 9f12a1c675..e254083a00 100644 --- a/packages/alfa-cascade/package.json +++ b/packages/alfa-cascade/package.json @@ -31,6 +31,9 @@ "@siteimprove/alfa-refinement": "workspace:^0.70.0", "@siteimprove/alfa-selector": "workspace:^0.70.0" }, + "devDependencies": { + "@siteimprove/alfa-test": "workspace:^0.70.0" + }, "publishConfig": { "access": "public", "registry": "https://npm.pkg.github.com/" diff --git a/packages/alfa-cascade/src/ancestor-filter.ts b/packages/alfa-cascade/src/ancestor-filter.ts index 19125a0693..74914a0b1c 100644 --- a/packages/alfa-cascade/src/ancestor-filter.ts +++ b/packages/alfa-cascade/src/ancestor-filter.ts @@ -1,35 +1,47 @@ import { Element } from "@siteimprove/alfa-dom"; +import { Serializable } from "@siteimprove/alfa-json"; import { Class, Id, Selector, Type } from "@siteimprove/alfa-selector"; +import * as json from "@siteimprove/alfa-json"; + /** * The ancestor filter is a data structure used for optimising selector matching - * in the case of descendant selectors. When traversing down through the DOM - * tree during selector matching, the ancestor filter stores information about - * the ancestor elements that are found up the path from the element that is - * currently being visited. Given an element and a descendant selector, we can - * therefore quickly determine if the selector might match an ancestor of the - * current element. + * in the case of descendant selectors. + * + * @remarks + * When traversing down through the DOM tree during selector matching, the + * ancestor filter stores information about the ancestor elements that are + * found up the path from the element that is currently being visited. + * Given an element and a descendant selector, we can therefore quickly + * determine if the selector might match an ancestor of the current element. + * + * The ancestor filter simply count the number of each ID, class, and type + * amongst the path walked so far. When a descendant selector is encountered, we + * can quickly see if the ancestor filter contains the ID, class, or type of the + * ancestor part, without walking up the full tree again. * - * The information stored about elements includes their ID, classes, and type - * which are what the majority of selectors make use of. A bucket exists for - * each of these and whenever an element is added to the filter, its associated - * ID, classes, and type are added to the three buckets. The buckets also keep - * count of how many elements in the current path match a given ID, class, or - * type, in order to evict these from the filter when the last element with a - * given ID, class, or type is removed from the filter. + * We need to remember exact count rather than just existence because the + * initial build of the cascade traverses the tree in depth-first order and + * therefore needs to be able to *remove* item from the filter when going up. * - * For example, consider the following tree: + * For example, consider the following DOM tree: * * section#content * +-- blockquote * +-- p.highlight * +-- b * - * If we assume that we're currently visiting the `` element, the ancestor - * filter would contain the `section` and `p` types, the `#content` ID, - * and the `.highlight` class. Given a selector `main b`, we can therefore - * reject that the selector would match `` as the ancestor filter does not - * contain an entry for the type `main`. + * For the `` element, the ancestor filter would be: + * \{ ids: [["content", 1]], + * classes: [["highlight", 1]], + * types: [["p", 1], ["section", 1]]\} + * Given a selector `main b`, we can therefore reject that the selector would + * match the `` as the ancestor filter does not contain the type `main`. + * + * However, given a selector `section.highlight`, the ancestor filter can only + * tell that it **may** match the `` element. In this case, it doesn't. So, + * the filter acts as a quick guaranteed rejection mechanism, but actual match + * test is needed to have an accurate final result. * * NB: None of the operations of the ancestor filter are idempotent to avoid * keeping track of more information than strictly necessary. This is however @@ -43,7 +55,7 @@ import { Class, Id, Selector, Type } from "@siteimprove/alfa-selector"; * * @internal */ -export class AncestorFilter { +export class AncestorFilter implements Serializable { public static empty(): AncestorFilter { return new AncestorFilter(); } @@ -79,20 +91,40 @@ export class AncestorFilter { } public matches(selector: Selector): boolean { - if (selector instanceof Id) { + if (Id.isId(selector)) { return this._ids.has(selector.name); } - if (selector instanceof Class) { + if (Class.isClass(selector)) { return this._classes.has(selector.name); } - if (selector instanceof Type) { + if (Type.isType(selector)) { return this._types.has(selector.name); } return false; } + + public toJSON(): AncestorFilter.JSON { + return { + ids: this._ids.toJSON(), + classes: this._classes.toJSON(), + types: this._types.toJSON(), + }; + } +} + +/** + * @internal + */ +export namespace AncestorFilter { + export interface JSON { + [key: string]: json.JSON; + ids: Bucket.JSON; + classes: Bucket.JSON; + types: Bucket.JSON; + } } /** @@ -106,8 +138,10 @@ export class AncestorFilter { * as we only ever compute cascade once for every context, and native maps are * actually much faster than any bloom filter we might be able to cook up in * plain JavaScript. + * + * @internal */ -class Bucket { +export class Bucket implements Serializable { public static empty(): Bucket { return new Bucket(); } @@ -143,4 +177,15 @@ class Bucket { this._entries.set(entry, count - 1); } } + + public toJSON(): Bucket.JSON { + return [...this._entries]; + } +} + +/** + * @internal + */ +export namespace Bucket { + export type JSON = Array<[string, number]>; } diff --git a/packages/alfa-cascade/src/cascade.ts b/packages/alfa-cascade/src/cascade.ts index 7400213d90..e316123d82 100644 --- a/packages/alfa-cascade/src/cascade.ts +++ b/packages/alfa-cascade/src/cascade.ts @@ -14,7 +14,7 @@ import { SelectorMap } from "./selector-map"; import { UserAgent } from "./user-agent"; /** - * {@link https://drafts.csswg.org/css-cascade/} + * {@link https://drafts.csswg.org/css-cascade-5/} * * @public */ @@ -37,7 +37,7 @@ export class Cascade implements Serializable { private readonly _entries = Cache.empty< Element, - Cache> + Cache >(); private constructor(root: Document | Shadow, device: Device) { @@ -76,7 +76,7 @@ export class Cascade implements Serializable { element: Element, context: Context = Context.empty(), filter: Option = None, - ): Option { + ): RuleTree.Node { return this._entries .get(element, Cache.empty) .get(context, () => @@ -110,7 +110,7 @@ export namespace Cascade { } /** - * {@link https://drafts.csswg.org/css-cascade/#cascade-sort} + * {@link https://drafts.csswg.org/css-cascade-5/#cascade-sort} */ const compare: Comparer = (a, b) => { // First priority: Origin @@ -123,8 +123,8 @@ const compare: Comparer = (a, b) => { return a.specificity < b.specificity ? -1 : a.specificity > b.specificity - ? 1 - : 0; + ? 1 + : 0; } // Third priority: Order. diff --git a/packages/alfa-cascade/src/rule-tree.ts b/packages/alfa-cascade/src/rule-tree.ts index 60c3db108f..ec2592e7a9 100644 --- a/packages/alfa-cascade/src/rule-tree.ts +++ b/packages/alfa-cascade/src/rule-tree.ts @@ -1,42 +1,45 @@ -import { Declaration, Rule } from "@siteimprove/alfa-dom"; +import { Declaration, h, Rule } from "@siteimprove/alfa-dom"; import { Iterable } from "@siteimprove/alfa-iterable"; import { Serializable } from "@siteimprove/alfa-json"; import { None, Option } from "@siteimprove/alfa-option"; -import { Selector } from "@siteimprove/alfa-selector"; +import { Selector, Universal } from "@siteimprove/alfa-selector"; import * as json from "@siteimprove/alfa-json"; /** * The rule tree is a data structure used for storing the rules that match each - * element when computing cascade for a document. Rules are stored in order - * from most to least specific; rules lower in the tree are therefore more - * specific than rules higher in the tree. Each element gets a pointer to the - * most specific rule it matched and can then follow pointers up the rule tree - * to locate decreasingly specific rules that the element also matches. This - * allows elements that share matched rules to also share a path in the rule - * tree. + * element when computing cascade for a document. * - * As an example, consider elements A and B. Element A matches rules `div`, - * `.foo` and `.foo[href]` whereas element B matches rules `div`, `.foo` and - * `.bar`. The naïve approach to associating these matched rules with elements + * @remarks + * Rules are stored in order from most to least precedence (according to cascade + * sorting order); rules lower in the tree have therefore higher precedence than + * rules higher in the tree. Each element gets a pointer to the highest + * precedence rule it matched and can then follow pointers up the rule tree to + * locate rules of decreasing precedence that the element also matches. This + * allows elements that share matched rules to also share a path in the rule tree. + * + * As an example, consider elements `A =
` and + * `B =
`. Element A matches rules `div`, `.foo` and + * `.foo[href]` whereas element B matches rules `div`, `.foo` and `.bar`. The + * naïve approach to associating these matched rules with elements * would be to associate an array of `[".foo[href]", ".foo", "div"]` with - * element A and an array of `[".foo", "div"]` with element B. With the rule - * tree, we instead start by inserting the matched rules for element A into the - * tree: + * element A and an array of `[".bar", ".foo", "div"]` with element B. With the + * rule tree, we instead start by inserting the matched rules for element A into + * the tree: * * "div" * +-- ".foo" - * +-- ".foo[href]" + * +-- ".foo[href]" (A) * * We then associate rule `".foo[href]"` with element A and insert the matched * rules for element B into the tree: * * "div" * +-- ".foo" - * +-- ".foo[href]" - * +-- ".bar" + * +-- ".foo[href]" (A) + * +-- ".bar" (B) * - * We then associate the rule `".bar"` with element B and we're done. Notice how + * We then associate the rule `".bar"` with element B, and we're done. Notice how * the tree branches at rule `".foo"`, allowing the two elements to share the * path in the rule tree that they have in common. This approach is conceptually * similar to associating arrays of matched rules with elements with the @@ -46,8 +49,28 @@ import * as json from "@siteimprove/alfa-json"; * rules that match most elements, such as the universal selector or `html` and * `body`. * + * Note that the resulting rule tree depends greatly on the order in which + * rules are inserted, which must then be by increasing precedence. The `.foo` + * and `.bar` selectors are not directly comparable; the example above assumes + * that the `.bar` rule came later in the style sheet order and therefore wins + * the cascade sort by "Order of Appearance". This information is not available + * for the rule tree which relies on rules being fed to it in increasing + * precedence for each element. If `.bar` came before `.foo`, the resulting tree + * would be (notice that `.foo` is not sharable anymore): + * + * "div" + * +-- ".foo" + * +-- ".foo[href]" (A) + * +-- ".bar" + * +-- ".foo" (B) + * * {@link http://doc.servo.org/style/rule_tree/struct.RuleTree.html} * + * @privateRemarks + * The rules tree is actually a forest of nodes since many elements do not share + * any matched selector. We artificially root it at a fake node with no + * declarations, hence no impact on style. The fake root is not serialized. + * * @public */ export class RuleTree implements Serializable { @@ -55,38 +78,55 @@ export class RuleTree implements Serializable { return new RuleTree(); } - private readonly _children: Array = []; + // Rooting the forest at a fake node with no declaration. + private readonly _root: RuleTree.Node = RuleTree.Node.of( + { + rule: h.rule.style("*", []), + selector: Universal.of(None), + declarations: [], + }, + [], + None, + ); private constructor() {} - public add( - rules: Iterable<{ - rule: Rule; - selector: Selector; - declarations: Iterable; - }>, - ): Option { - let parent: Option = None; - let children = this._children; - - for (const { rule, selector, declarations } of rules) { + /** + * Add a bunch of items to the tree. Returns the last node created, which is + * the highest precedence node for that list of items. + * + * @remarks + * The rules are assumed to be: + * 1. all matching the same element; and + * 2. ordered in increasing cascade sort order (lower precedence rule first); and + * 3. be all the rules matching that element. + * + * It is up to the caller to ensure this is true, as the tree itself cannot + * check that (notably, it has no access to the DOM tree to ensure the rules + * match the same element; nor to the origin or order of the rules to check + * cascade order). + * + * @privateRemarks + * This is stateful. Adding rules to a rule tree does mutate it! + * + * @internal + */ + public add(rules: Iterable): RuleTree.Node { + let parent = this._root; + + for (const item of rules) { // Insert the next rule into the current parent, using the returned rule // entry as the parent of the next rule to insert. This way, we gradually // build up a path of rule entries and then return the final entry to the // caller. - parent = Option.of( - RuleTree.Node.add(rule, selector, declarations, children, parent), - ); - - // parent was just build as a non-None Option. - children = parent.getUnsafe().children; + parent = parent.add(item); } return parent; } public toJSON(): RuleTree.JSON { - return this._children.map((node) => node.toJSON()); + return this._root.children.map((node) => node.toJSON()); } } @@ -96,11 +136,35 @@ export class RuleTree implements Serializable { export namespace RuleTree { export type JSON = Array; + /** + * Items stored in rule tree nodes. + * + * @remarks + * Only the selector is used to actually build the structure. The rule and + * declarations are just data passed along to be used when resolving style. + * + * If the selector does not match the one in the rule, behavior is not specified. + * + * @internal + */ + export interface Item { + rule: Rule; + selector: Selector; + declarations: Iterable; + } + + export namespace Item { + export interface JSON { + [key: string]: json.JSON; + rule: Rule.JSON; + selector: Selector.JSON; + declarations: Array; + } + } + export class Node implements Serializable { public static of( - rule: Rule, - selector: Selector, - declarations: Iterable, + { rule, selector, declarations }: Item, children: Array, parent: Option, ): Node { @@ -159,43 +223,58 @@ export namespace RuleTree { yield* this.ancestors(); } - public static add( - rule: Rule, - selector: Selector, - declarations: Iterable, - children: Array, - parent: Option, - ): Node { - if (parent.some((parent) => parent._selector === selector)) { - return parent.get(); + /** + * Adds style rule to a node in the tree. Returns the node where the rule + * was added. + * + * @privateRemarks + * This is stateful. Adding a rule to a node mutates the node! + * + * @internal + */ + public add(item: Item): Node { + // If we have already encountered the exact same selector (physical identity), + // we're done. + // This occurs when the exact same style rule matches several elements. + // The first element added to the rule tree will add that rule, subsequent + // ones will just reuse it (if the path so far in the rule tree has + // completely been shared). + // Notably, because it is the exact same selector, it controls the exact + // same rules, so all the information is already in the tree. + if (this._selector === item.selector) { + return this; } - for (const child of children) { - if (child._selector.equals(selector)) { - return this.add( - rule, - selector, - declarations, - child._children, - Option.of(child), - ); + // Otherwise, if there is a child with an identical but separate selector, + // recursively add to it. + // This happens, e.g., when encountering two ".foo" selectors. They are + // then sorted by order of appearance (by assumption) and the later must + // be a descendant of the former as it has higher precedence. + for (const child of this._children) { + if (child._selector.equals(item.selector)) { + return child.add(item); } } - const node = Node.of(rule, selector, declarations, [], parent); + // Otherwise, the selector is brand new (for this branch of the tree). + // Add it as a new child and return it (further rules in the same batch, + // matching the same element, should be added as its child. + const node = Node.of(item, [], Option.of(this)); - children.push(node); + this._children.push(node); return node; } public toJSON(): Node.JSON { return { - rule: this._rule.toJSON(), - selector: this._selector.toJSON(), - declarations: [...this._declarations].map((declaration) => - declaration.toJSON(), - ), + item: { + rule: this._rule.toJSON(), + selector: this._selector.toJSON(), + declarations: [...this._declarations].map((declaration) => + declaration.toJSON(), + ), + }, children: this._children.map((node) => node.toJSON()), }; } @@ -204,9 +283,7 @@ export namespace RuleTree { export namespace Node { export interface JSON { [key: string]: json.JSON; - rule: Rule.JSON; - selector: Selector.JSON; - declarations: Array; + item: Item.JSON; children: Array; } } diff --git a/packages/alfa-cascade/src/selector-map.ts b/packages/alfa-cascade/src/selector-map.ts index 5cf302e62d..f72df8416e 100644 --- a/packages/alfa-cascade/src/selector-map.ts +++ b/packages/alfa-cascade/src/selector-map.ts @@ -16,15 +16,12 @@ import { Option } from "@siteimprove/alfa-option"; import { Predicate } from "@siteimprove/alfa-predicate"; import { Refinement } from "@siteimprove/alfa-refinement"; import { - Attribute, Class, Combinator, Complex, Compound, Context, Id, - PseudoClass, - PseudoElement, Selector, Type, } from "@siteimprove/alfa-selector"; @@ -37,13 +34,10 @@ import { AncestorFilter } from "./ancestor-filter"; const { equals, property } = Predicate; const { and } = Refinement; -const { isAttribute } = Attribute; const { isClass } = Class; const { isComplex } = Complex; const { isCompound } = Compound; const { isId } = Id; -const { isPseudoClass } = PseudoClass; -const { isPseudoElement } = PseudoElement; const { isType } = Type; const isDescendantSelector = and( @@ -58,16 +52,16 @@ const isDescendantSelector = and( * Cascading origins defined in ascending order; origins defined first have * lower precedence than origins defined later. * - * {@link https://www.w3.org/TR/css-cascade/#cascading-origins} + * {@link https://www.w3.org/TR/css-cascade-5/#cascading-origins} */ enum Origin { /** - * {@link https://www.w3.org/TR/css-cascade/#cascade-origin-ua} + * {@link https://www.w3.org/TR/css-cascade-5/#cascade-origin-ua} */ UserAgent = 1, /** - * {@link https://www.w3.org/TR/css-cascade/#cascade-origin-author} + * {@link https://www.w3.org/TR/css-cascade-5/#cascade-origin-author} */ Author = 2, } @@ -208,16 +202,14 @@ export namespace SelectorMap { ): void => { const node = Node.of(rule, selector, declarations, origin, order); - const keySelector = getKeySelector(selector); + const keySelector = selector.key; - if (keySelector === null) { + if (!keySelector.isSome()) { other.push(node); - } else if (isId(keySelector)) { - ids.add(keySelector.name, node); - } else if (isClass(keySelector)) { - classes.add(keySelector.name, node); } else { - types.add(keySelector.name, node); + const key = keySelector.get(); + const buckets = { id: ids, class: classes, type: types }; + buckets[key.type].add(key.name, node); } }; @@ -430,30 +422,6 @@ export namespace SelectorMap { } } -/** - * Given a selector, get the right-most ID, class, or type selector, i.e. the - * key selector. If the right-most selector is a compound selector, then the - * left-most ID, class, or type selector of the compound selector is returned. - */ -function getKeySelector(selector: Selector): Id | Class | Type | null { - if (isId(selector) || isClass(selector) || isType(selector)) { - return selector; - } - - if (isCompound(selector)) { - return Iterable.find( - Iterable.map(selector.selectors, getKeySelector), - (selector) => selector !== null, - ).getOr(null); - } - - if (isComplex(selector)) { - return getKeySelector(selector.right); - } - - return null; -} - /** * Check if a selector can be rejected based on an ancestor filter. */ diff --git a/packages/alfa-cascade/test/ancestor-filter.spec.tsx b/packages/alfa-cascade/test/ancestor-filter.spec.tsx new file mode 100644 index 0000000000..be1c86e3d1 --- /dev/null +++ b/packages/alfa-cascade/test/ancestor-filter.spec.tsx @@ -0,0 +1,304 @@ +import { Assertions, test } from "@siteimprove/alfa-test"; + +import { parse } from "@siteimprove/alfa-selector/test/parser"; + +import { AncestorFilter, Bucket } from "../src/ancestor-filter"; + +test("Buckets behave as expected", (t) => { + const bucket = Bucket.empty(); + t.deepEqual(bucket.toJSON(), []); + t(!bucket.has("a")); + t(!bucket.has("b")); + + bucket.add("a"); + t.deepEqual(bucket.toJSON(), [["a", 1]]); + t(bucket.has("a")); + t(!bucket.has("b")); + + bucket.add("b"); + t.deepEqual(bucket.toJSON(), [ + ["a", 1], + ["b", 1], + ]); + t(bucket.has("a")); + t(bucket.has("b")); + + bucket.add("a"); + t.deepEqual(bucket.toJSON(), [ + ["a", 2], + ["b", 1], + ]); + t(bucket.has("a")); + t(bucket.has("b")); + + bucket.add("a"); + t.deepEqual(bucket.toJSON(), [ + ["a", 3], + ["b", 1], + ]); + t(bucket.has("a")); + t(bucket.has("b")); + + bucket.remove("a"); + t.deepEqual(bucket.toJSON(), [ + ["a", 2], + ["b", 1], + ]); + t(bucket.has("a")); + t(bucket.has("b")); + + bucket.remove("a"); + t.deepEqual(bucket.toJSON(), [ + ["a", 1], + ["b", 1], + ]); + t(bucket.has("a")); + t(bucket.has("b")); + + bucket.remove("b"); + t.deepEqual(bucket.toJSON(), [["a", 1]]); + t(bucket.has("a")); + t(!bucket.has("b")); + + bucket.remove("a"); + t.deepEqual(bucket.toJSON(), []); + t(!bucket.has("a")); + t(!bucket.has("b")); + + bucket.remove("a"); + t.deepEqual(bucket.toJSON(), []); + t(!bucket.has("a")); + t(!bucket.has("b")); +}); + +const selectors = { + divSel: parse("div"), + spanSel: parse("span"), + dotFooSel: parse(".foo"), + dotBarSel: parse(".bar"), + hashFooSel: parse("#foo"), + hashBarSel: parse("#bar"), +}; + +function match( + t: Assertions, + filter: AncestorFilter, + expected: AncestorFilter.JSON, + matching: Array, +) { + t.deepEqual(filter.toJSON(), expected); + for (const sel of [ + "divSel", + "spanSel", + "dotFooSel", + "dotBarSel", + "hashFooSel", + "hashBarSel", + ] as const) { + t.equal(filter.matches(selectors[sel]), matching.includes(sel)); + } +} + +/** + * In this test, we are walking the following DOM tree: + * div + * +-- div.foo + * | +-- div + * | | +-- span#foo + * | | +-- span.foo + * | | +-- span.bar + * +-- div + * | +-- span#bar + * +-- div.bar + * | +-- span.foo + * | +-- span.bar + * span + * + * and check the composition of the ancestor filter at each step. + * Since only element's name, id and class matter (not the actual element), we + * create them on the fly and remove a structurally identical one rather than the + * exact one that has been added. This simplifies the test a bit. Actual cascading + * walks through the actual DOM tree and removes the exact same element when + * moving up. + */ +test("Ancestor filter behaves as expected", (t) => { + const filter = AncestorFilter.empty(); + match(t, filter, { ids: [], classes: [], types: [] }, []); + + filter.add(
); + match(t, filter, { ids: [], classes: [], types: [["div", 1]] }, ["divSel"]); + + filter.add(
); + match(t, filter, { ids: [], classes: [["foo", 1]], types: [["div", 2]] }, [ + "divSel", + "dotFooSel", + ]); + + filter.add(
); + match(t, filter, { ids: [], classes: [["foo", 1]], types: [["div", 3]] }, [ + "divSel", + "dotFooSel", + ]); + + filter.add(); + match( + t, + filter, + { + ids: [["foo", 1]], + classes: [["foo", 1]], + types: [ + ["div", 3], + ["span", 1], + ], + }, + ["divSel", "dotFooSel", "spanSel", "hashFooSel"], + ); + + filter.remove(); + match(t, filter, { ids: [], classes: [["foo", 1]], types: [["div", 3]] }, [ + "divSel", + "dotFooSel", + ]); + + filter.add(); + match( + t, + filter, + { + ids: [], + classes: [["foo", 2]], + types: [ + ["div", 3], + ["span", 1], + ], + }, + ["divSel", "dotFooSel", "spanSel"], + ); + + filter.remove(); + match(t, filter, { ids: [], classes: [["foo", 1]], types: [["div", 3]] }, [ + "divSel", + "dotFooSel", + ]); + + filter.add(); + match( + t, + filter, + { + ids: [], + classes: [ + ["foo", 1], + ["bar", 1], + ], + types: [ + ["div", 3], + ["span", 1], + ], + }, + ["divSel", "dotFooSel", "spanSel", "dotBarSel"], + ); + + filter.remove(); + match(t, filter, { ids: [], classes: [["foo", 1]], types: [["div", 3]] }, [ + "divSel", + "dotFooSel", + ]); + + filter.remove(
); + match(t, filter, { ids: [], classes: [["foo", 1]], types: [["div", 2]] }, [ + "divSel", + "dotFooSel", + ]); + + filter.remove(
); + match(t, filter, { ids: [], classes: [], types: [["div", 1]] }, ["divSel"]); + + filter.add(
); + match(t, filter, { ids: [], classes: [], types: [["div", 2]] }, ["divSel"]); + + filter.add(); + match( + t, + filter, + { + ids: [["bar", 1]], + classes: [], + types: [ + ["div", 2], + ["span", 1], + ], + }, + ["divSel", "spanSel", "hashBarSel"], + ); + + filter.remove(); + match(t, filter, { ids: [], classes: [], types: [["div", 2]] }, ["divSel"]); + + filter.remove(
); + match(t, filter, { ids: [], classes: [], types: [["div", 1]] }, ["divSel"]); + + filter.add(
); + match(t, filter, { ids: [], classes: [["bar", 1]], types: [["div", 2]] }, [ + "divSel", + "dotBarSel", + ]); + + filter.add(); + match( + t, + filter, + { + ids: [], + classes: [ + ["bar", 1], + ["foo", 1], + ], + types: [ + ["div", 2], + ["span", 1], + ], + }, + ["divSel", "dotFooSel", "spanSel", "dotBarSel"], + ); + + filter.remove(); + match(t, filter, { ids: [], classes: [["bar", 1]], types: [["div", 2]] }, [ + "divSel", + "dotBarSel", + ]); + + filter.add(); + match( + t, + filter, + { + ids: [], + classes: [["bar", 2]], + types: [ + ["div", 2], + ["span", 1], + ], + }, + ["divSel", "spanSel", "dotBarSel"], + ); + + filter.remove(); + match(t, filter, { ids: [], classes: [["bar", 1]], types: [["div", 2]] }, [ + "divSel", + "dotBarSel", + ]); + + filter.remove(
); + match(t, filter, { ids: [], classes: [], types: [["div", 1]] }, ["divSel"]); + + filter.remove(
); + match(t, filter, { ids: [], classes: [], types: [] }, []); + + filter.add(); + match(t, filter, { ids: [], classes: [], types: [["span", 1]] }, ["spanSel"]); + + filter.remove(); + match(t, filter, { ids: [], classes: [], types: [] }, []); +}); diff --git a/packages/alfa-cascade/test/rule-tree.spec.ts b/packages/alfa-cascade/test/rule-tree.spec.ts new file mode 100644 index 0000000000..87dec15065 --- /dev/null +++ b/packages/alfa-cascade/test/rule-tree.spec.ts @@ -0,0 +1,244 @@ +import { h } from "@siteimprove/alfa-dom"; +import { None } from "@siteimprove/alfa-option"; +import { test } from "@siteimprove/alfa-test"; + +import { parse } from "@siteimprove/alfa-selector/test/parser"; + +import { RuleTree } from "../src"; + +function fakeItem(selector: string): RuleTree.Item { + return { + rule: h.rule.style(selector, []), + selector: parse(selector), + declarations: [], + }; +} + +function fakeJSON(selector: string): RuleTree.Item.JSON { + const item = fakeItem(selector); + + return { + rule: item.rule.toJSON(), + selector: item.selector.toJSON(), + declarations: [], + }; +} + +/** + * Node tests + */ +test(".of() builds a node", (t) => { + const node = RuleTree.Node.of(fakeItem("div"), [], None); + + t.deepEqual(node.toJSON(), { + item: fakeJSON("div"), + children: [], + }); +}); + +test(".add() doesn't change a tree that already has the exact same selector", (t) => { + const item1 = fakeItem("div"); + const item2 = fakeItem("div"); + const node = RuleTree.Node.of(item1, [], None); + node.add({ ...item2, selector: item1.selector }); + + t.deepEqual(node.toJSON(), { + item: fakeJSON("div"), + children: [], + }); +}); + +test(".add() adds a child upon inserting identical selector", (t) => { + const node = RuleTree.Node.of(fakeItem("div"), [], None); + node.add(fakeItem("div")); + + t.deepEqual(node.toJSON(), { + item: fakeJSON("div"), + children: [{ item: fakeJSON("div"), children: [] }], + }); +}); + +test("Chaining .add() creates a single branch in the tree", (t) => { + // Selectors `div`, `.foo`, `#bar`, matching, e.g., `
` + // and inserted in increasing specificity. + const node = RuleTree.Node.of(fakeItem("div"), [], None); + node.add(fakeItem(".foo")).add(fakeItem("#bar")); + + t.deepEqual(node.toJSON(), { + item: fakeJSON("div"), + children: [ + { + item: fakeJSON(".foo"), + children: [{ item: fakeJSON("#bar"), children: [] }], + }, + ], + }); +}); + +/** + * Full rule tree tests + * + * Since rule tree are stateful, we need to recreate one for each test. + */ +test(".add() creates a single branch in the rule tree", (t) => { + const tree = RuleTree.empty(); + tree.add([fakeItem("div"), fakeItem(".foo"), fakeItem("#bar")]); + + t.deepEqual(tree.toJSON(), [ + { + item: fakeJSON("div"), + children: [ + { + item: fakeJSON(".foo"), + children: [{ item: fakeJSON("#bar"), children: [] }], + }, + ], + }, + ]); +}); + +test(".add() does not change the order of items", (t) => { + const tree = RuleTree.empty(); + // Items are not correctly ordered (#bar has higher specificity). Rule tree + // doesn't care. + tree.add([fakeItem("#bar"), fakeItem("div"), fakeItem(".foo")]); + + t.deepEqual(tree.toJSON(), [ + { + item: fakeJSON("#bar"), + children: [ + { + item: fakeJSON("div"), + children: [{ item: fakeJSON(".foo"), children: [] }], + }, + ], + }, + ]); +}); + +test(".add() duplicate identical but distinct selectors", (t) => { + const tree = RuleTree.empty(); + // Presumably two rules with selector `div` at different place in the sheet. + tree.add([fakeItem("div"), fakeItem("div")]); + + t.deepEqual(tree.toJSON(), [ + { + item: fakeJSON("div"), + children: [{ item: fakeJSON("div"), children: [] }], + }, + ]); +}); + +test(".add() creates separate trees for entries that don't share initial selectors", (t) => { + const tree = RuleTree.empty(); + // Matching `
+ tree.add([fakeItem("div"), fakeItem(".foo"), fakeItem("#bar")]); + // Matching `` + // Since the first selector differ, we cannot share any part of the tree + tree.add([fakeItem("span"), fakeItem(".foo"), fakeItem("#bar")]); + + t.deepEqual(tree.toJSON(), [ + { + item: fakeJSON("div"), + children: [ + { + item: fakeJSON(".foo"), + children: [{ item: fakeJSON("#bar"), children: [] }], + }, + ], + }, + { + item: fakeJSON("span"), + children: [ + { + item: fakeJSON(".foo"), + children: [{ item: fakeJSON("#bar"), children: [] }], + }, + ], + }, + ]); +}); + +test(".add() share branches as long as selectors are the same", (t) => { + const div = fakeItem("div"); + const foo = fakeItem(".foo"); + const tree = RuleTree.empty(); + tree.add([div, foo, fakeItem("#bar")]); + tree.add([div, foo, fakeItem(".baz")]); + + t.deepEqual(tree.toJSON(), [ + { + item: fakeJSON("div"), + children: [ + { + item: fakeJSON(".foo"), + children: [ + { item: fakeJSON("#bar"), children: [] }, + { item: fakeJSON(".baz"), children: [] }, + ], + }, + ], + }, + ]); +}); + +test(".add() adds descendants when selectors are merely identical", (t) => { + const div = fakeItem("div"); + const tree = RuleTree.empty(); + tree.add([div, fakeItem(".foo"), fakeItem("#bar")]); + // This is not an actual possible case. This corresponds to two `.foo` + // selectors but each matches different elements, which is impossible. + // Hence, the adding of the .foo / .baz branch under the initial .foo + // looks very wrong but is actually the correct thing to do. In an actual + // case, both .add would contain both .foo selector, since this rules with + // identical selectors match the same elements. + tree.add([div, fakeItem(".foo"), fakeItem(".baz")]); + + t.deepEqual(tree.toJSON(), [ + { + item: fakeJSON("div"), + children: [ + { + item: fakeJSON(".foo"), + children: [ + { item: fakeJSON("#bar"), children: [] }, + { + item: fakeJSON(".foo"), + children: [{ item: fakeJSON(".baz"), children: [] }], + }, + ], + }, + ], + }, + ]); +}); + +test(".add() branches as soon as selectors differ", (t) => { + const div = fakeItem("div"); + const foo = fakeItem(".foo"); + const tree = RuleTree.empty(); + tree.add([div, foo, fakeItem("#bar")]); + // Even if the selector is the same, the tree doesn't try to merge the branches. + tree.add([div, fakeItem(".baz"), foo]); + + t.deepEqual(tree.toJSON(), [ + { + item: fakeJSON("div"), + children: [ + { + item: fakeJSON(".foo"), + children: [{ item: fakeJSON("#bar"), children: [] }], + }, + { + item: fakeJSON(".baz"), + children: [ + { + item: fakeJSON(".foo"), + children: [], + }, + ], + }, + ], + }, + ]); +}); diff --git a/packages/alfa-cascade/tsconfig.json b/packages/alfa-cascade/tsconfig.json index 594fff846f..28454a283a 100644 --- a/packages/alfa-cascade/tsconfig.json +++ b/packages/alfa-cascade/tsconfig.json @@ -1,13 +1,19 @@ { "$schema": "http://json.schemastore.org/tsconfig", "extends": "../tsconfig.json", + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "@siteimprove/alfa-dom" + }, "files": [ "src/ancestor-filter.ts", "src/cascade.ts", "src/index.ts", "src/rule-tree.ts", "src/selector-map.ts", - "src/user-agent.ts" + "src/user-agent.ts", + "test/ancestor-filter.spec.tsx", + "test/rule-tree.spec.ts" ], "references": [ { "path": "../alfa-cache" }, @@ -21,6 +27,7 @@ { "path": "../alfa-option" }, { "path": "../alfa-predicate" }, { "path": "../alfa-refinement" }, - { "path": "../alfa-selector" } + { "path": "../alfa-selector" }, + { "path": "../alfa-test" } ] } diff --git a/packages/alfa-rules/src/sia-r83/rule.ts b/packages/alfa-rules/src/sia-r83/rule.ts index 593f654db4..479f88eeb6 100644 --- a/packages/alfa-rules/src/sia-r83/rule.ts +++ b/packages/alfa-rules/src/sia-r83/rule.ts @@ -4,12 +4,12 @@ import { Cascade, RuleTree } from "@siteimprove/alfa-cascade"; import { Length } from "@siteimprove/alfa-css"; import { Device } from "@siteimprove/alfa-device"; import { - Rule as CSSRule, Document, Element, MediaRule, Namespace, Node, + Rule as CSSRule, Text, } from "@siteimprove/alfa-dom"; import { Hash } from "@siteimprove/alfa-hash"; @@ -485,10 +485,7 @@ const ruleTreeCache = Cache.empty>(); function ancestorsInRuleTree(rule: RuleTree.Node): Sequence { return ruleTreeCache.get(rule, () => - rule.parent - .map((parent) => ancestorsInRuleTree(parent)) - .getOrElse>(Sequence.empty) - .prepend(rule), + Sequence.from(rule.inclusiveAncestors()), ); } @@ -503,16 +500,11 @@ function getUsedMediaRules( return Sequence.empty(); } - return Cascade.of(root, device) - .get(element, context) - .map((node) => - // Get all nodes (style rules) in the RuleTree that affect the element; - // for each of these rules, get all ancestor media rules in the CSS tree. - ancestorsInRuleTree(node).flatMap((node) => - ancestorMediaRules(node.rule), - ), - ) - .getOrElse(Sequence.empty); + // Get all nodes (style rules) in the RuleTree that affect the element; + // for each of these rules, get all ancestor media rules in the CSS tree. + return ancestorsInRuleTree( + Cascade.of(root, device).get(element, context), + ).flatMap((node) => ancestorMediaRules(node.rule)); } function usesMediaRule( diff --git a/packages/alfa-selector/src/selector/complex.ts b/packages/alfa-selector/src/selector/complex.ts index 4f0a8bc46b..fd5ef53f85 100644 --- a/packages/alfa-selector/src/selector/complex.ts +++ b/packages/alfa-selector/src/selector/complex.ts @@ -1,6 +1,7 @@ import type { Parser as CSSParser } from "@siteimprove/alfa-css"; import { Element } from "@siteimprove/alfa-dom"; import { Iterable } from "@siteimprove/alfa-iterable"; +import { Option } from "@siteimprove/alfa-option"; import { Parser } from "@siteimprove/alfa-parser"; import { Thunk } from "@siteimprove/alfa-thunk"; @@ -11,7 +12,7 @@ import { Specificity } from "../specificity"; import { Combinator } from "./combinator"; import { Compound } from "./compound"; import { Selector } from "./selector"; -import type { Simple } from "./simple"; +import type { Class, Id, Simple, Type } from "./simple"; const { isElement } = Element; const { map, pair, zeroOrMore } = Parser; @@ -33,6 +34,7 @@ export class Complex extends Selector<"complex"> { private readonly _combinator: Combinator; private readonly _left: Simple | Compound | Complex; private readonly _right: Simple | Compound; + protected readonly _key: Option; private constructor( combinator: Combinator, @@ -43,6 +45,8 @@ export class Complex extends Selector<"complex"> { this._combinator = combinator; this._left = left; this._right = right; + + this._key = right.key; } public get combinator(): Combinator { diff --git a/packages/alfa-selector/src/selector/compound.ts b/packages/alfa-selector/src/selector/compound.ts index 34ccfa19a6..5a1f8f8d96 100644 --- a/packages/alfa-selector/src/selector/compound.ts +++ b/packages/alfa-selector/src/selector/compound.ts @@ -2,6 +2,7 @@ import { Array } from "@siteimprove/alfa-array"; import { Token } from "@siteimprove/alfa-css"; import type { Element } from "@siteimprove/alfa-dom"; import { Iterable } from "@siteimprove/alfa-iterable"; +import { None, Option } from "@siteimprove/alfa-option"; import { Parser } from "@siteimprove/alfa-parser"; import { Slice } from "@siteimprove/alfa-slice"; @@ -10,7 +11,7 @@ import { Specificity } from "../specificity"; import type { Absolute } from "./index"; import { Selector } from "./selector"; -import { Simple } from "./simple"; +import { type Class, type Id, Simple, type Type } from "./simple"; const { map, oneOrMore } = Parser; @@ -26,6 +27,7 @@ export class Compound extends Selector<"compound"> { private readonly _selectors: Array; private readonly _length: number; + protected readonly _key: Option; private constructor(selectors: Array) { super( @@ -34,6 +36,8 @@ export class Compound extends Selector<"compound"> { ); this._selectors = selectors; this._length = selectors.length; + + this._key = selectors[0]?.key ?? None; } public get selectors(): Iterable { diff --git a/packages/alfa-selector/src/selector/selector.ts b/packages/alfa-selector/src/selector/selector.ts index fc70608963..e3dc2d63a1 100644 --- a/packages/alfa-selector/src/selector/selector.ts +++ b/packages/alfa-selector/src/selector/selector.ts @@ -4,6 +4,7 @@ import { Iterable } from "@siteimprove/alfa-iterable"; import { Serializable } from "@siteimprove/alfa-json"; import * as json from "@siteimprove/alfa-json"; +import { None, Option } from "@siteimprove/alfa-option"; import type { Context } from "../context"; import { Specificity } from "../specificity"; @@ -11,7 +12,7 @@ import { Specificity } from "../specificity"; import type { Complex } from "./complex"; import type { Compound } from "./compound"; import type { Relative } from "./relative"; -import type { Simple } from "./simple"; +import type { Class, Id, Simple, Type } from "./simple"; /** * @internal @@ -25,6 +26,37 @@ export abstract class Selector private readonly _type: T; private readonly _specificity: Specificity; + /** + * The key selector is used to optimise matching of complex (and compound) + * selectors. + * + * @remarks + * The key selector is the rightmost simple selector in a complex selector, + * or the leftmost simple selector in a compound selector. In order for an + * element to match a complex selector, it must match the key selector. + * + * For example, consider selector `main .foo + div`. Any element matching it + * must necessarily be a `
`, and for other elements there is no need to + * waste time traversing the DOM tree to check siblings or ancestors. + * + * For compound selectors, e.g. `.foo.bar`, any part could be taken, and we + * arbitrarily pick the leftmost. + * + * Conversely, an `` can only match selectors + * whose key selector is `img`, `#image`, `.foo`, or `.bar`. So we can + * pre-filter these when attempting matching. + * + * @privateRemarks + * Key selectors are not part of the CSS specification, but are a useful tool + * for optimising selector matching. + * + * Key selectors relate to cascading more than selector syntax and matching, + * but they only depend on selector and thus make sense as instance properties. + * + * {@link http://doc.servo.org/style/selector_map/struct.SelectorMap.html} + */ + protected readonly _key: Option = None; + protected constructor(type: T, specificity: Specificity) { this._type = type; this._specificity = specificity; @@ -39,6 +71,10 @@ export abstract class Selector return this._specificity; } + public get key(): Option { + return this._key; + } + /** * {@link https://drafts.csswg.org/selectors/#match} */ @@ -65,6 +101,7 @@ export abstract class Selector return { type: this._type, specificity: this._specificity.toJSON(), + ...(this._key.isSome() ? { key: `${this._key.get()}` } : {}), }; } } @@ -75,6 +112,9 @@ export namespace Selector { type: T; specificity: Specificity.JSON; + // Since the key selector may be the selector itself, we only return its + // string representation to avoid infinite recursion. + key?: string; } } diff --git a/packages/alfa-selector/src/selector/simple/class.ts b/packages/alfa-selector/src/selector/simple/class.ts index 04ac9325e8..f574fb9c14 100644 --- a/packages/alfa-selector/src/selector/simple/class.ts +++ b/packages/alfa-selector/src/selector/simple/class.ts @@ -1,6 +1,7 @@ import { Token } from "@siteimprove/alfa-css"; import type { Element } from "@siteimprove/alfa-dom"; import { Iterable } from "@siteimprove/alfa-iterable"; +import { Option } from "@siteimprove/alfa-option"; import { Parser } from "@siteimprove/alfa-parser"; import { Specificity } from "../../specificity"; @@ -18,8 +19,12 @@ export class Class extends WithName<"class"> { public static of(name: string): Class { return new Class(name); } + + protected readonly _key: Option; private constructor(name: string) { super("class", name, Specificity.of(0, 1, 0)); + + this._key = Option.of(this); } public matches(element: Element): boolean { diff --git a/packages/alfa-selector/src/selector/simple/id.ts b/packages/alfa-selector/src/selector/simple/id.ts index 6c8d2a882d..bde2d124d4 100644 --- a/packages/alfa-selector/src/selector/simple/id.ts +++ b/packages/alfa-selector/src/selector/simple/id.ts @@ -1,5 +1,6 @@ import { Token } from "@siteimprove/alfa-css"; import type { Element } from "@siteimprove/alfa-dom"; +import { Option } from "@siteimprove/alfa-option"; import { Parser } from "@siteimprove/alfa-parser"; import { Specificity } from "../../specificity"; @@ -18,8 +19,12 @@ export class Id extends WithName<"id"> { return new Id(name); } + protected readonly _key: Option; + private constructor(name: string) { super("id", name, Specificity.of(1, 0, 0)); + + this._key = Option.of(this); } public matches(element: Element): boolean { diff --git a/packages/alfa-selector/src/selector/simple/type.ts b/packages/alfa-selector/src/selector/simple/type.ts index 45196a3506..2839afb9ee 100644 --- a/packages/alfa-selector/src/selector/simple/type.ts +++ b/packages/alfa-selector/src/selector/simple/type.ts @@ -21,10 +21,13 @@ export class Type extends WithName<"type"> { } private readonly _namespace: Option; + protected readonly _key: Option; private constructor(namespace: Option, name: string) { super("type", name, Specificity.of(0, 0, 1)); this._namespace = namespace; + + this._key = Option.of(this); } public get namespace(): Option { diff --git a/packages/alfa-selector/test/basic.spec.ts b/packages/alfa-selector/test/basic.spec.ts index feeeb034d6..ff60af7ed9 100644 --- a/packages/alfa-selector/test/basic.spec.ts +++ b/packages/alfa-selector/test/basic.spec.ts @@ -9,6 +9,7 @@ test(".parse() parses a type selector", (t) => { name: "div", namespace: null, specificity: { a: 0, b: 0, c: 1 }, + key: "div", }); }); @@ -18,6 +19,7 @@ test(".parse() parses an uppercase type selector", (t) => { name: "DIV", namespace: null, specificity: { a: 0, b: 0, c: 1 }, + key: "DIV", }); }); @@ -27,6 +29,7 @@ test(".parse() parses a type selector with a namespace", (t) => { name: "a", namespace: "svg", specificity: { a: 0, b: 0, c: 1 }, + key: "svg|a", }); }); @@ -36,6 +39,7 @@ test(".parse() parses a type selector with an empty namespace", (t) => { name: "a", namespace: "", specificity: { a: 0, b: 0, c: 1 }, + key: "|a", }); }); @@ -45,6 +49,7 @@ test(".parse() parses a type selector with the universal namespace", (t) => { name: "a", namespace: "*", specificity: { a: 0, b: 0, c: 1 }, + key: "*|a", }); }); @@ -77,6 +82,7 @@ test(".parse() parses a class selector", (t) => { type: "class", name: "foo", specificity: { a: 0, b: 1, c: 0 }, + key: ".foo", }); }); @@ -85,6 +91,7 @@ test(".parse() parses an ID selector", (t) => { type: "id", name: "foo", specificity: { a: 1, b: 0, c: 0 }, + key: "#foo", }); }); diff --git a/packages/alfa-selector/test/complex.spec.ts b/packages/alfa-selector/test/complex.spec.ts index 6059241165..ca85ea55a9 100644 --- a/packages/alfa-selector/test/complex.spec.ts +++ b/packages/alfa-selector/test/complex.spec.ts @@ -12,9 +12,16 @@ test(".parse() parses a single descendant selector", (t) => { name: "div", namespace: null, specificity: { a: 0, b: 0, c: 1 }, + key: "div", + }, + right: { + type: "class", + name: "foo", + specificity: { a: 0, b: 1, c: 0 }, + key: ".foo", }, - right: { type: "class", name: "foo", specificity: { a: 0, b: 1, c: 0 } }, specificity: { a: 0, b: 1, c: 1 }, + key: ".foo", }); }); @@ -27,14 +34,17 @@ test(".parse() parses a single descendant selector with a right-hand type select name: "div", namespace: null, specificity: { a: 0, b: 0, c: 1 }, + key: "div", }, right: { type: "type", name: "span", namespace: null, specificity: { a: 0, b: 0, c: 1 }, + key: "span", }, specificity: { a: 0, b: 0, c: 2 }, + key: "span", }); }); @@ -50,12 +60,25 @@ test(".parse() parses a double descendant selector", (t) => { name: "div", namespace: null, specificity: { a: 0, b: 0, c: 1 }, + key: "div", + }, + right: { + type: "class", + name: "foo", + specificity: { a: 0, b: 1, c: 0 }, + key: ".foo", }, - right: { type: "class", name: "foo", specificity: { a: 0, b: 1, c: 0 } }, specificity: { a: 0, b: 1, c: 1 }, + key: ".foo", + }, + right: { + type: "id", + name: "bar", + specificity: { a: 1, b: 0, c: 0 }, + key: "#bar", }, - right: { type: "id", name: "bar", specificity: { a: 1, b: 0, c: 0 } }, specificity: { a: 1, b: 1, c: 1 }, + key: "#bar", }); }); @@ -68,9 +91,16 @@ test(".parse() parses a direct descendant selector", (t) => { name: "div", namespace: null, specificity: { a: 0, b: 0, c: 1 }, + key: "div", + }, + right: { + type: "class", + name: "foo", + specificity: { a: 0, b: 1, c: 0 }, + key: ".foo", }, - right: { type: "class", name: "foo", specificity: { a: 0, b: 1, c: 0 } }, specificity: { a: 0, b: 1, c: 1 }, + key: ".foo", }); }); @@ -83,9 +113,16 @@ test(".parse() parses a sibling selector", (t) => { name: "div", namespace: null, specificity: { a: 0, b: 0, c: 1 }, + key: "div", + }, + right: { + type: "class", + name: "foo", + specificity: { a: 0, b: 1, c: 0 }, + key: ".foo", }, - right: { type: "class", name: "foo", specificity: { a: 0, b: 1, c: 0 } }, specificity: { a: 0, b: 1, c: 1 }, + key: ".foo", }); }); @@ -98,9 +135,16 @@ test(".parse() parses a direct sibling selector", (t) => { name: "div", namespace: null, specificity: { a: 0, b: 0, c: 1 }, + key: "div", + }, + right: { + type: "class", + name: "foo", + specificity: { a: 0, b: 1, c: 0 }, + key: ".foo", }, - right: { type: "class", name: "foo", specificity: { a: 0, b: 1, c: 0 } }, specificity: { a: 0, b: 1, c: 1 }, + key: ".foo", }); }); @@ -108,7 +152,12 @@ test(".parse() parses a compound selector relative to a class selector", (t) => t.deepEqual(serialize(".foo div.bar"), { type: "complex", combinator: Combinator.Descendant, - left: { type: "class", name: "foo", specificity: { a: 0, b: 1, c: 0 } }, + left: { + type: "class", + name: "foo", + specificity: { a: 0, b: 1, c: 0 }, + key: ".foo", + }, right: { type: "compound", selectors: [ @@ -117,12 +166,20 @@ test(".parse() parses a compound selector relative to a class selector", (t) => name: "div", namespace: null, specificity: { a: 0, b: 0, c: 1 }, + key: "div", + }, + { + type: "class", + name: "bar", + specificity: { a: 0, b: 1, c: 0 }, + key: ".bar", }, - { type: "class", name: "bar", specificity: { a: 0, b: 1, c: 0 } }, ], specificity: { a: 0, b: 1, c: 1 }, + key: "div", }, specificity: { a: 0, b: 2, c: 1 }, + key: "div", }); }); @@ -138,10 +195,17 @@ test(".parse() parses a compound selector relative to a compound selector", (t) name: "span", namespace: null, specificity: { a: 0, b: 0, c: 1 }, + key: "span", + }, + { + type: "class", + name: "foo", + specificity: { a: 0, b: 1, c: 0 }, + key: ".foo", }, - { type: "class", name: "foo", specificity: { a: 0, b: 1, c: 0 } }, ], specificity: { a: 0, b: 1, c: 1 }, + key: "span", }, right: { type: "compound", @@ -151,12 +215,20 @@ test(".parse() parses a compound selector relative to a compound selector", (t) name: "div", namespace: null, specificity: { a: 0, b: 0, c: 1 }, + key: "div", + }, + { + type: "class", + name: "bar", + specificity: { a: 0, b: 1, c: 0 }, + key: ".bar", }, - { type: "class", name: "bar", specificity: { a: 0, b: 1, c: 0 } }, ], specificity: { a: 0, b: 1, c: 1 }, + key: "div", }, specificity: { a: 0, b: 2, c: 2 }, + key: "div", }); }); @@ -172,17 +244,26 @@ test(".parse() parses a descendant selector relative to a sibling selector", (t) name: "div", namespace: null, specificity: { a: 0, b: 0, c: 1 }, + key: "div", }, right: { type: "type", name: "span", namespace: null, specificity: { a: 0, b: 0, c: 1 }, + key: "span", }, specificity: { a: 0, b: 0, c: 2 }, + key: "span", + }, + right: { + type: "class", + name: "foo", + specificity: { a: 0, b: 1, c: 0 }, + key: ".foo", }, - right: { type: "class", name: "foo", specificity: { a: 0, b: 1, c: 0 } }, specificity: { a: 0, b: 1, c: 2 }, + key: ".foo", }); }); @@ -195,6 +276,7 @@ test(".parse() parses an attribute selector when part of a descendant selector", name: "div", namespace: null, specificity: { a: 0, b: 0, c: 1 }, + key: "div", }, right: { type: "attribute", @@ -217,6 +299,7 @@ test(".parse() parses an attribute selector when part of a compound selector rel type: "class", name: "foo", specificity: { a: 0, b: 1, c: 0 }, + key: ".foo", }, right: { type: "compound", @@ -226,6 +309,7 @@ test(".parse() parses an attribute selector when part of a compound selector rel name: "div", namespace: null, specificity: { a: 0, b: 0, c: 1 }, + key: "div", }, { type: "attribute", @@ -238,8 +322,10 @@ test(".parse() parses an attribute selector when part of a compound selector rel }, ], specificity: { a: 0, b: 1, c: 1 }, + key: "div", }, specificity: { a: 0, b: 2, c: 1 }, + key: "div", }); }); @@ -252,6 +338,7 @@ test(".parse() parses a pseudo-element selector when part of a descendant select name: "div", namespace: null, specificity: { a: 0, b: 0, c: 1 }, + key: "div", }, right: { type: "pseudo-element", @@ -266,7 +353,12 @@ test(".parse() parses a pseudo-element selector when part of a compound selector t.deepEqual(serialize(".foo div::before"), { type: "complex", combinator: Combinator.Descendant, - left: { type: "class", name: "foo", specificity: { a: 0, b: 1, c: 0 } }, + left: { + type: "class", + name: "foo", + specificity: { a: 0, b: 1, c: 0 }, + key: ".foo", + }, right: { type: "compound", selectors: [ @@ -275,6 +367,7 @@ test(".parse() parses a pseudo-element selector when part of a compound selector name: "div", namespace: null, specificity: { a: 0, b: 0, c: 1 }, + key: "div", }, { type: "pseudo-element", @@ -283,8 +376,10 @@ test(".parse() parses a pseudo-element selector when part of a compound selector }, ], specificity: { a: 0, b: 0, c: 2 }, + key: "div", }, specificity: { a: 0, b: 1, c: 2 }, + key: "div", }); }); @@ -292,7 +387,12 @@ test(".parse() parses a pseudo-class selector when part of a compound selector r t.deepEqual(serialize(".foo div:hover"), { type: "complex", combinator: Combinator.Descendant, - left: { type: "class", name: "foo", specificity: { a: 0, b: 1, c: 0 } }, + left: { + type: "class", + name: "foo", + specificity: { a: 0, b: 1, c: 0 }, + key: ".foo", + }, right: { type: "compound", selectors: [ @@ -301,6 +401,7 @@ test(".parse() parses a pseudo-class selector when part of a compound selector r name: "div", namespace: null, specificity: { a: 0, b: 0, c: 1 }, + key: "div", }, { type: "pseudo-class", @@ -309,8 +410,10 @@ test(".parse() parses a pseudo-class selector when part of a compound selector r }, ], specificity: { a: 0, b: 1, c: 1 }, + key: "div", }, specificity: { a: 0, b: 2, c: 1 }, + key: "div", }); }); @@ -318,7 +421,12 @@ test(".parse() parses a compound type, class, and pseudo-class selector relative t.deepEqual(serialize(".foo div.bar:hover"), { type: "complex", combinator: Combinator.Descendant, - left: { type: "class", name: "foo", specificity: { a: 0, b: 1, c: 0 } }, + left: { + type: "class", + name: "foo", + specificity: { a: 0, b: 1, c: 0 }, + key: ".foo", + }, right: { type: "compound", selectors: [ @@ -327,8 +435,14 @@ test(".parse() parses a compound type, class, and pseudo-class selector relative name: "div", namespace: null, specificity: { a: 0, b: 0, c: 1 }, + key: "div", + }, + { + type: "class", + name: "bar", + specificity: { a: 0, b: 1, c: 0 }, + key: ".bar", }, - { type: "class", name: "bar", specificity: { a: 0, b: 1, c: 0 } }, { type: "pseudo-class", name: "hover", @@ -336,8 +450,10 @@ test(".parse() parses a compound type, class, and pseudo-class selector relative }, ], specificity: { a: 0, b: 2, c: 1 }, + key: "div", }, specificity: { a: 0, b: 3, c: 1 }, + key: "div", }); }); @@ -345,7 +461,12 @@ test(".parse() parses a simple selector relative to a compound selector", (t) => t.deepEqual(serialize(".foo > div.bar"), { type: "complex", combinator: Combinator.DirectDescendant, - left: { type: "class", name: "foo", specificity: { a: 0, b: 1, c: 0 } }, + left: { + type: "class", + name: "foo", + specificity: { a: 0, b: 1, c: 0 }, + key: ".foo", + }, right: { type: "compound", selectors: [ @@ -354,12 +475,20 @@ test(".parse() parses a simple selector relative to a compound selector", (t) => name: "div", namespace: null, specificity: { a: 0, b: 0, c: 1 }, + key: "div", + }, + { + type: "class", + name: "bar", + specificity: { a: 0, b: 1, c: 0 }, + key: ".bar", }, - { type: "class", name: "bar", specificity: { a: 0, b: 1, c: 0 } }, ], specificity: { a: 0, b: 1, c: 1 }, + key: "div", }, specificity: { a: 0, b: 2, c: 1 }, + key: "div", }); }); @@ -370,9 +499,20 @@ test(".parse() parses a relative selector relative to a compound selector", (t) left: { type: "complex", combinator: Combinator.DirectDescendant, - left: { type: "class", name: "foo", specificity: { a: 0, b: 1, c: 0 } }, - right: { type: "class", name: "bar", specificity: { a: 0, b: 1, c: 0 } }, + left: { + type: "class", + name: "foo", + specificity: { a: 0, b: 1, c: 0 }, + key: ".foo", + }, + right: { + type: "class", + name: "bar", + specificity: { a: 0, b: 1, c: 0 }, + key: ".bar", + }, specificity: { a: 0, b: 2, c: 0 }, + key: ".bar", }, right: { type: "compound", @@ -382,11 +522,19 @@ test(".parse() parses a relative selector relative to a compound selector", (t) name: "div", namespace: null, specificity: { a: 0, b: 0, c: 1 }, + key: "div", + }, + { + type: "class", + name: "baz", + specificity: { a: 0, b: 1, c: 0 }, + key: ".baz", }, - { type: "class", name: "baz", specificity: { a: 0, b: 1, c: 0 } }, ], specificity: { a: 0, b: 1, c: 1 }, + key: "div", }, specificity: { a: 0, b: 3, c: 1 }, + key: "div", }); }); diff --git a/packages/alfa-selector/test/compound.spec.ts b/packages/alfa-selector/test/compound.spec.ts index b8104062d7..c414e34621 100644 --- a/packages/alfa-selector/test/compound.spec.ts +++ b/packages/alfa-selector/test/compound.spec.ts @@ -6,10 +6,21 @@ test(".parse() parses a compound selector", (t) => { t.deepEqual(serialize("#foo.bar"), { type: "compound", selectors: [ - { type: "id", name: "foo", specificity: { a: 1, b: 0, c: 0 } }, - { type: "class", name: "bar", specificity: { a: 0, b: 1, c: 0 } }, + { + type: "id", + name: "foo", + specificity: { a: 1, b: 0, c: 0 }, + key: "#foo", + }, + { + type: "class", + name: "bar", + specificity: { a: 0, b: 1, c: 0 }, + key: ".bar", + }, ], specificity: { a: 1, b: 1, c: 0 }, + key: "#foo", }); }); @@ -22,10 +33,17 @@ test(".parse() parses a compound selector with a type in prefix position", (t) = name: "div", namespace: null, specificity: { a: 0, b: 0, c: 1 }, + key: "div", + }, + { + type: "class", + name: "foo", + specificity: { a: 0, b: 1, c: 0 }, + key: ".foo", }, - { type: "class", name: "foo", specificity: { a: 0, b: 1, c: 0 } }, ], specificity: { a: 0, b: 1, c: 1 }, + key: "div", }); }); @@ -33,7 +51,12 @@ test(".parse() parses an attribute selector when part of a compound selector", ( t.deepEqual(serialize(".foo[foo]"), { type: "compound", selectors: [ - { type: "class", name: "foo", specificity: { a: 0, b: 1, c: 0 } }, + { + type: "class", + name: "foo", + specificity: { a: 0, b: 1, c: 0 }, + key: ".foo", + }, { type: "attribute", name: "foo", @@ -45,6 +68,7 @@ test(".parse() parses an attribute selector when part of a compound selector", ( }, ], specificity: { a: 0, b: 2, c: 0 }, + key: ".foo", }); }); @@ -52,7 +76,12 @@ test(".parse() parses a pseudo-element selector when part of a compound selector t.deepEqual(serialize(".foo::before"), { type: "compound", selectors: [ - { type: "class", name: "foo", specificity: { a: 0, b: 1, c: 0 } }, + { + type: "class", + name: "foo", + specificity: { a: 0, b: 1, c: 0 }, + key: ".foo", + }, { type: "pseudo-element", name: "before", @@ -60,6 +89,7 @@ test(".parse() parses a pseudo-element selector when part of a compound selector }, ], specificity: { a: 0, b: 1, c: 1 }, + key: ".foo", }); }); @@ -72,6 +102,7 @@ test(".parse() parses a pseudo-class selector when part of a compound selector", name: "div", namespace: null, specificity: { a: 0, b: 0, c: 1 }, + key: "div", }, { type: "pseudo-class", @@ -80,5 +111,6 @@ test(".parse() parses a pseudo-class selector when part of a compound selector", }, ], specificity: { a: 0, b: 1, c: 1 }, + key: "div", }); }); diff --git a/packages/alfa-selector/test/list.spec.ts b/packages/alfa-selector/test/list.spec.ts index 4020dea579..dd1e3761cb 100644 --- a/packages/alfa-selector/test/list.spec.ts +++ b/packages/alfa-selector/test/list.spec.ts @@ -7,9 +7,24 @@ test(".parse() parses a list of simple selectors", (t) => { t.deepEqual(serialize(".foo, .bar, .baz"), { type: "list", selectors: [ - { type: "class", name: "foo", specificity: { a: 0, b: 1, c: 0 } }, - { type: "class", name: "bar", specificity: { a: 0, b: 1, c: 0 } }, - { type: "class", name: "baz", specificity: { a: 0, b: 1, c: 0 } }, + { + type: "class", + name: "foo", + specificity: { a: 0, b: 1, c: 0 }, + key: ".foo", + }, + { + type: "class", + name: "bar", + specificity: { a: 0, b: 1, c: 0 }, + key: ".bar", + }, + { + type: "class", + name: "baz", + specificity: { a: 0, b: 1, c: 0 }, + key: ".baz", + }, ], specificity: { a: 0, b: 1, c: 0 }, }); @@ -19,14 +34,30 @@ test(".parse() parses a list of simple and compound selectors", (t) => { t.deepEqual(serialize(".foo, #bar.baz"), { type: "list", selectors: [ - { type: "class", name: "foo", specificity: { a: 0, b: 1, c: 0 } }, + { + type: "class", + name: "foo", + specificity: { a: 0, b: 1, c: 0 }, + key: ".foo", + }, { type: "compound", selectors: [ - { type: "id", name: "bar", specificity: { a: 1, b: 0, c: 0 } }, - { type: "class", name: "baz", specificity: { a: 0, b: 1, c: 0 } }, + { + type: "id", + name: "bar", + specificity: { a: 1, b: 0, c: 0 }, + key: "#bar", + }, + { + type: "class", + name: "baz", + specificity: { a: 0, b: 1, c: 0 }, + key: ".baz", + }, ], specificity: { a: 1, b: 1, c: 0 }, + key: "#bar", }, ], specificity: { a: 1, b: 1, c: 0 }, @@ -45,13 +76,16 @@ test(".parse() parses a list of descendant selectors", (t) => { name: "div", namespace: null, specificity: { a: 0, b: 0, c: 1 }, + key: "div", }, right: { type: "class", name: "foo", specificity: { a: 0, b: 1, c: 0 }, + key: ".foo", }, specificity: { a: 0, b: 1, c: 1 }, + key: ".foo", }, { type: "complex", @@ -61,13 +95,16 @@ test(".parse() parses a list of descendant selectors", (t) => { name: "span", namespace: null, specificity: { a: 0, b: 0, c: 1 }, + key: "span", }, right: { type: "class", name: "baz", specificity: { a: 0, b: 1, c: 0 }, + key: ".baz", }, specificity: { a: 0, b: 1, c: 1 }, + key: ".baz", }, ], specificity: { a: 0, b: 1, c: 1 }, @@ -86,13 +123,16 @@ test(".parse() parses a list of sibling selectors", (t) => { name: "div", namespace: null, specificity: { a: 0, b: 0, c: 1 }, + key: "div", }, right: { type: "class", name: "foo", specificity: { a: 0, b: 1, c: 0 }, + key: ".foo", }, specificity: { a: 0, b: 1, c: 1 }, + key: ".foo", }, { type: "complex", @@ -102,13 +142,16 @@ test(".parse() parses a list of sibling selectors", (t) => { name: "span", namespace: null, specificity: { a: 0, b: 0, c: 1 }, + key: "span", }, right: { type: "class", name: "baz", specificity: { a: 0, b: 1, c: 0 }, + key: ".baz", }, specificity: { a: 0, b: 1, c: 1 }, + key: ".baz", }, ], specificity: { a: 0, b: 1, c: 1 }, @@ -119,8 +162,18 @@ test(".parse() parses a list of selectors with no whitespace", (t) => { t.deepEqual(serialize(".foo,.bar"), { type: "list", selectors: [ - { type: "class", name: "foo", specificity: { a: 0, b: 1, c: 0 } }, - { type: "class", name: "bar", specificity: { a: 0, b: 1, c: 0 } }, + { + type: "class", + name: "foo", + specificity: { a: 0, b: 1, c: 0 }, + key: ".foo", + }, + { + type: "class", + name: "bar", + specificity: { a: 0, b: 1, c: 0 }, + key: ".bar", + }, ], specificity: { a: 0, b: 1, c: 0 }, }); diff --git a/packages/alfa-selector/test/nth-[last]-child.spec.tsx b/packages/alfa-selector/test/nth-[last]-child.spec.tsx index 5508432abe..472139ac9c 100644 --- a/packages/alfa-selector/test/nth-[last]-child.spec.tsx +++ b/packages/alfa-selector/test/nth-[last]-child.spec.tsx @@ -23,6 +23,7 @@ test(".parse() accepts the `of selector` syntax", (t) => { namespace: null, name: "div", specificity: { a: 0, b: 0, c: 1 }, + key: "div", }, specificity: { a: 0, b: 1, c: 1 }, }); diff --git a/packages/alfa-selector/test/pseudo-class.spec.tsx b/packages/alfa-selector/test/pseudo-class.spec.tsx index 21612cabf5..c7ad1110e6 100644 --- a/packages/alfa-selector/test/pseudo-class.spec.tsx +++ b/packages/alfa-selector/test/pseudo-class.spec.tsx @@ -27,6 +27,7 @@ test(".parse() parses a functional pseudo-class selector", (t) => { type: "class", name: "foo", specificity: { a: 0, b: 1, c: 0 }, + key: ".foo", }, specificity: { a: 0, b: 1, c: 0 }, }); diff --git a/packages/alfa-style/src/style.ts b/packages/alfa-style/src/style.ts index 4164a33288..e06e72b8c4 100644 --- a/packages/alfa-style/src/style.ts +++ b/packages/alfa-style/src/style.ts @@ -284,6 +284,14 @@ export namespace Style { const cache = Cache.empty>>(); + /** + * Build the style of an element. + * + * @remarks + * This gather all style declarations that apply to the element, in decreasing + * precedence (according to cascade sort order) and delegate the rest of the + * work to `Style.of`. + */ export function from( element: Element, device: Device, @@ -293,6 +301,17 @@ export namespace Style { .get(device, Cache.empty) .get(element.freeze(), Cache.empty) .get(context, () => { + // First, get all declarations on the `style` attribute. They win + // cascade sort at priority 3, trumping everything but origin and + // (shadow tree) context + // * origin is de-facto handled by the fact that these are author + // declarations, trumping non-important UA declaration at 1.6 vs 1.8. + // important UA declarations will win back through shouldOverride. + // important `style` attribute declarations incorrectly trump + // important UA declarations. + // {@link https://github.com/Siteimprove/alfa/issues/1532} + // * (shadow tree) context is not currently handled. + // {@link https://github.com/Siteimprove/alfa/issues/1533} const declarations: Array = element.style .map((block) => [...block.declarations].reverse()) .getOr([]); @@ -302,13 +321,18 @@ export namespace Style { if (Document.isDocument(root) || Shadow.isShadow(root)) { const cascade = Cascade.of(root, device); - let next = cascade.get(element, context); - - while (next.isSome()) { - const node = next.get(); - + // Walk up the cascade, starting from the node associated to the + // element, and gather all declarations met on the way. + // The cascade has been build in decreasing precedence as we move up + // (highest precedence rules are at the bottom), thus the declarations + // are seen in decreasing precedence and pushed to the end of the + // existing list which is thus also ordered in decreasing precedence. + // Cascade doesn't handle importance of declaration, hence this will + // still have to be done here (through `shouldOverride`). + for (const node of cascade + .get(element, context) + .inclusiveAncestors()) { declarations.push(...[...node.declarations].reverse()); - next = node.parent; } } @@ -362,8 +386,13 @@ export namespace Style { * The "next" declaration should override the previous if: * - either there is no previous; or * - next is important and previous isn't. - * This suppose that the declarations have been pre--ordered in decreasing - * specificity. + * This supposes that the declarations have been pre--ordered in otherwise + * decreasing precedence. + * + * @privateRemarks + * This is not correct since importance of declaration reverses precedence of UA + * and author origins. + * {@link https://github.com/Siteimprove/alfa/issues/1532} * * @internal */ diff --git a/yarn.lock b/yarn.lock index 1440a32f57..42915ddc57 100644 --- a/yarn.lock +++ b/yarn.lock @@ -956,6 +956,7 @@ __metadata: "@siteimprove/alfa-predicate": "workspace:^0.70.0" "@siteimprove/alfa-refinement": "workspace:^0.70.0" "@siteimprove/alfa-selector": "workspace:^0.70.0" + "@siteimprove/alfa-test": "workspace:^0.70.0" languageName: unknown linkType: soft