From 2ce1a8c5321bf83485690543495d17030e1ad452 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rolf=20Christian=20J=C3=B8rgensen?= <114920418+rcj-siteimprove@users.noreply.github.com> Date: Thu, 7 Dec 2023 15:31:32 +0100 Subject: [PATCH] Clone-replace for `alfa-dom` `Node`s (#1523) * Add node cloning functions with replace capability * Add changeset * Clone attributes * Clean up * Extract API * Update clone functions to accept `Iterable` * Add interface for gathering replace predicate and elements to replace with * Use `Selective` and currify functions * Fix shadow and content not being cloned and add tests for that * Extract API * Update cloning functions to discard `extraData` and add doc strings * Update doc strings * Add remark about externalId to doc strings * Extract API * Update dependencies * Extract shadow cloning and simplify tests * Extract API * Clean up --------- Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/twelve-shrimps-call.md | 5 + docs/review/api/alfa-dom.api.md | 52 ++++++-- packages/alfa-dom/package.json | 1 + packages/alfa-dom/src/node.ts | 165 +++++++++++++++++++++++- packages/alfa-dom/src/node/attribute.ts | 17 +++ packages/alfa-dom/src/node/comment.ts | 7 + packages/alfa-dom/src/node/document.ts | 25 ++++ packages/alfa-dom/src/node/element.ts | 57 ++++++++ packages/alfa-dom/src/node/fragment.ts | 21 +++ packages/alfa-dom/src/node/shadow.ts | 25 ++++ packages/alfa-dom/src/node/text.ts | 7 + packages/alfa-dom/src/node/type.ts | 11 ++ packages/alfa-dom/test/node.spec.tsx | 70 ++++++++++ packages/alfa-dom/tsconfig.json | 1 + yarn.lock | 1 + 15 files changed, 455 insertions(+), 10 deletions(-) create mode 100644 .changeset/twelve-shrimps-call.md diff --git a/.changeset/twelve-shrimps-call.md b/.changeset/twelve-shrimps-call.md new file mode 100644 index 0000000000..c442a3c209 --- /dev/null +++ b/.changeset/twelve-shrimps-call.md @@ -0,0 +1,5 @@ +--- +"@siteimprove/alfa-dom": minor +--- + +**Added:** New functions `Node.clone` for cloning nodes and optionally replacing child elements based on a predicate. diff --git a/docs/review/api/alfa-dom.api.md b/docs/review/api/alfa-dom.api.md index c374292da1..b11dbea7f6 100644 --- a/docs/review/api/alfa-dom.api.md +++ b/docs/review/api/alfa-dom.api.md @@ -61,6 +61,8 @@ export class Attribute extends Node<"attribute"> { // @public (undocumented) export namespace Attribute { + // @internal (undocumented) + export function cloneAttribute(attribute: Attribute): Trampoline>>; // @internal export function foldCase(name: N, owner: Option): N | Lowercase; // @internal (undocumented) @@ -132,6 +134,8 @@ export class Comment extends Node<"comment"> { // @public (undocumented) export namespace Comment { + // @internal (undocumented) + export function cloneComment(comment: Comment): Trampoline; // @internal (undocumented) export function fromComment(json: JSON): Trampoline; // (undocumented) @@ -222,11 +226,11 @@ export class Document extends Node<"document"> { // @internal (undocumented) protected _internalPath(options?: Node.Traversal): string; // (undocumented) - static of(children: Iterable, style?: Iterable, externalId?: string, extraData?: any): Document; + static of(children: Iterable_2, style?: Iterable_2, externalId?: string, extraData?: any): Document; // (undocumented) parent(options?: Node.Traversal): Option; // (undocumented) - get style(): Iterable; + get style(): Iterable_2; // (undocumented) toJSON(options?: Node.SerializationOptions): Document.JSON; // (undocumented) @@ -235,6 +239,8 @@ export class Document extends Node<"document"> { // @public (undocumented) export namespace Document { + // @internal (undocumented) + export function cloneDocument(options: Node.ElementReplacementOptions, device?: Device): (document: Document) => Trampoline; // @internal (undocumented) export function fromDocument(json: JSON, device?: Device): Trampoline; // (undocumented) @@ -242,7 +248,7 @@ export namespace Document { // (undocumented) export interface JSON extends Node.JSON<"document"> { // (undocumented) - style: Array; + style: Array_2; } } @@ -302,6 +308,8 @@ export class Element extends Node<"element"> implemen // @public (undocumented) export namespace Element { + // @internal (undocumented) + export function cloneElement(options: Node.ElementReplacementOptions, device?: Device): (element: Element) => Trampoline; // @internal (undocumented) export function fromElement(json: JSON, device?: Device): Trampoline>; // (undocumented) @@ -391,13 +399,15 @@ export class Fragment extends Node<"fragment"> { // @internal (undocumented) protected _internalPath(): string; // (undocumented) - static of(children: Iterable, externalId?: string, extraData?: any): Fragment; + static of(children: Iterable_2, externalId?: string, extraData?: any): Fragment; // (undocumented) toString(): string; } // @public (undocumented) export namespace Fragment { + // @internal (undocumented) + export function cloneFragment(options: Node.ElementReplacementOptions, device?: Device): (fragment: Fragment) => Trampoline; // @internal (undocumented) export function fromFragment(json: JSON, device?: Device): Trampoline; // (undocumented) @@ -754,6 +764,20 @@ export interface Node { // @public (undocumented) export namespace Node { + export function clone(node: Element, options?: ElementReplacementOptions, device?: Device): Element; + export function clone(node: Attribute, options?: ElementReplacementOptions, device?: Device): Attribute; + export function clone(node: Text, options?: ElementReplacementOptions, device?: Device): Text; + export function clone(node: Comment, options?: ElementReplacementOptions, device?: Device): Comment; + export function clone(node: Document, options?: ElementReplacementOptions, device?: Device): Document; + export function clone(node: Type, options?: ElementReplacementOptions, device?: Device): Document; + const flatTree: Traversal; + const fullTree: Traversal; + const composedNested: Traversal; + export function clone(node: Fragment, options?: ElementReplacementOptions, device?: Device): Fragment; + export function clone(node: Shadow, options?: ElementReplacementOptions, device?: Device): Shadow; + export function clone(node: Node, options?: ElementReplacementOptions, device?: Device): Node; + // @internal (undocumented) + export function cloneNode(node: Node, options?: ElementReplacementOptions, device?: Device): Trampoline; // (undocumented) export interface EARL extends earl.EARL { // (undocumented) @@ -775,6 +799,13 @@ export namespace Node { }; } // (undocumented) + export interface ElementReplacementOptions { + // (undocumented) + newElements: Iterable; + // (undocumented) + predicate: Predicate; + } + // (undocumented) export function from(json: Element.JSON, device?: Device): Element; // (undocumented) export function from(json: Attribute.JSON, device?: Device): Attribute; @@ -784,9 +815,6 @@ export namespace Node { export function from(json: Comment.JSON, device?: Device): Comment; // (undocumented) export function from(json: Document.JSON, device?: Device): Document; - const flatTree: Traversal; - const fullTree: Traversal; - const composedNested: Traversal; // (undocumented) export function from(json: Type.JSON, device?: Device): Document; // (undocumented) @@ -956,11 +984,11 @@ export class Shadow extends Node<"shadow"> { // (undocumented) get mode(): Shadow.Mode; // (undocumented) - static of(children: Iterable, style?: Iterable, mode?: Shadow.Mode, externalId?: string, extraData?: any): Shadow; + static of(children: Iterable_2, style?: Iterable_2, mode?: Shadow.Mode, externalId?: string, extraData?: any): Shadow; // (undocumented) parent(options?: Node.Traversal): Option; // (undocumented) - get style(): Iterable; + get style(): Iterable_2; // (undocumented) toJSON(): Shadow.JSON; // (undocumented) @@ -969,6 +997,8 @@ export class Shadow extends Node<"shadow"> { // @public (undocumented) export namespace Shadow { + // @internal (undocumented) + export function cloneShadow(options: Node.ElementReplacementOptions, device?: Device): (shadow: Shadow) => Trampoline; // @internal (undocumented) export function fromShadow(json: JSON, device?: Device): Trampoline; // (undocumented) @@ -1142,6 +1172,8 @@ export class Text extends Node<"text"> implements Slotable { // @public (undocumented) export namespace Text { + // @internal (undocumented) + export function cloneText(text: Text): Trampoline; // @internal (undocumented) export function fromText(json: JSON): Trampoline; // (undocumented) @@ -1173,6 +1205,8 @@ export class Type extends Node<"type"> { // @public (undocumented) export namespace Type { + // @internal (undocumented) + export function cloneType(type: Type): Trampoline>; // @internal (undocumented) export function fromType(json: JSON): Trampoline>; // (undocumented) diff --git a/packages/alfa-dom/package.json b/packages/alfa-dom/package.json index d348a67be8..c400d12157 100644 --- a/packages/alfa-dom/package.json +++ b/packages/alfa-dom/package.json @@ -54,6 +54,7 @@ "@siteimprove/alfa-rectangle": "workspace:^0.69.0", "@siteimprove/alfa-refinement": "workspace:^0.69.0", "@siteimprove/alfa-sarif": "workspace:^0.69.0", + "@siteimprove/alfa-selective": "workspace:^0.69.0", "@siteimprove/alfa-sequence": "workspace:^0.69.0", "@siteimprove/alfa-trampoline": "workspace:^0.69.0", "@siteimprove/alfa-tree": "workspace:^0.69.0" diff --git a/packages/alfa-dom/src/node.ts b/packages/alfa-dom/src/node.ts index fe06a4e90b..9aecde7f7c 100644 --- a/packages/alfa-dom/src/node.ts +++ b/packages/alfa-dom/src/node.ts @@ -1,8 +1,10 @@ +import { Device } from "@siteimprove/alfa-device"; import { Flags } from "@siteimprove/alfa-flags"; import { Lazy } from "@siteimprove/alfa-lazy"; import { Option } from "@siteimprove/alfa-option"; import { Predicate } from "@siteimprove/alfa-predicate"; import { Refinement } from "@siteimprove/alfa-refinement"; +import { Selective } from "@siteimprove/alfa-selective"; import { Sequence } from "@siteimprove/alfa-sequence"; import { Trampoline } from "@siteimprove/alfa-trampoline"; @@ -18,12 +20,12 @@ import { Document, Element, Fragment, + Shadow, Slot, Text, Type, } from "."; -import { Device } from "@siteimprove/alfa-device"; import * as predicate from "./node/predicate"; import * as traversal from "./node/traversal"; @@ -380,6 +382,167 @@ export namespace Node { } } + export interface ElementReplacementOptions { + predicate: Predicate; + newElements: Iterable; + } + + /** + * Creates a new `Element` instance with the same value as the original and deeply referentially non-equal. + * Optionally replaces child elements based on a predicate. + * + * @remarks + * The clone will have the same `externalId` as the original. + * The clone will *not* get `extraData` from the original, instead it will be `undefined`. + */ + export function clone( + node: Element, + options?: ElementReplacementOptions, + device?: Device, + ): Element; + + /** + * Creates a new `Attribute` instance with the same value as the original and referentially non-equal. + * + * @remarks + * The clone will have the same `externalId` as the original. + * The clone will *not* get `extraData` from the original, instead it will be `undefined`. + */ + export function clone( + node: Attribute, + options?: ElementReplacementOptions, + device?: Device, + ): Attribute; + + /** + * Creates a new `Text` instance with the same value as the original and referentially non-equal. + * + * @remarks + * The clone will have the same `externalId` as the original. + * The clone will *not* get `extraData` from the original, instead it will be `undefined`. + */ + export function clone( + node: Text, + options?: ElementReplacementOptions, + device?: Device, + ): Text; + + /** + * Creates a new `Comment` instance with the same value as the original and referentially non-equal. + * + * @remarks + * The clone will have the same `externalId` as the original. + * The clone will *not* get `extraData` from the original, instead it will be `undefined`. + */ + export function clone( + node: Comment, + options?: ElementReplacementOptions, + device?: Device, + ): Comment; + + /** + * Creates a new `Document` instance with the same value as the original and deeply referentially non-equal. + * Optionally replaces child elements based on a predicate. + * + * @remarks + * The clone will have the same `externalId` as the original. + * The clone will *not* get `extraData` from the original, instead it will be `undefined`. + */ + export function clone( + node: Document, + options?: ElementReplacementOptions, + device?: Device, + ): Document; + + /** + * Creates a new `Type` instance with the same value as the original and referentially non-equal. + * + * @remarks + * The clone will have the same `externalId` as the original. + * The clone will *not* get `extraData` from the original, instead it will be `undefined`. + */ + export function clone( + node: Type, + options?: ElementReplacementOptions, + device?: Device, + ): Document; + + /** + * Creates a new `Fragment` instance with the same value as the original and deeply referentially non-equal. + * Optionally replaces child elements based on a predicate. + * + * @remarks + * The clone will have the same `externalId` as the original. + * The clone will *not* get `extraData` from the original, instead it will be `undefined`. + */ + export function clone( + node: Fragment, + options?: ElementReplacementOptions, + device?: Device, + ): Fragment; + + /** + * Creates a new `Shadow` instance with the same value as the original and deeply referentially non-equal. + * Optionally replaces child elements based on a predicate. + * + * @remarks + * The clone will have the same `externalId` as the original. + * The clone will *not* get `extraData` from the original, instead it will be `undefined`. + */ + export function clone( + node: Shadow, + options?: ElementReplacementOptions, + device?: Device, + ): Shadow; + + /** + * Creates a new `Node` instance with the same value as the original and deeply referentially non-equal. + * Optionally replaces child elements based on a predicate. + * + * @remarks + * The clone will have the same `externalId` as the original. + * The clone will *not* get `extraData` from the original, instead it will be `undefined`. + */ + export function clone( + node: Node, + options?: ElementReplacementOptions, + device?: Device, + ): Node; + + export function clone( + node: Node, + options?: ElementReplacementOptions, + device?: Device, + ): Node { + return cloneNode(node, options, device).run(); + } + + /** + * @internal + */ + export function cloneNode( + node: Node, + options: ElementReplacementOptions = { + predicate: () => false, + newElements: [], + }, + device?: Device, + ): Trampoline { + return Selective.of(node) + .if(Element.isElement, Element.cloneElement(options, device)) + .if(Attribute.isAttribute, Attribute.cloneAttribute) + .if(Text.isText, Text.cloneText) + .if(Comment.isComment, Comment.cloneComment) + .if(Document.isDocument, Document.cloneDocument(options, device)) + .if(Type.isType, Type.cloneType) + .if(Fragment.isFragment, Fragment.cloneFragment(options, device)) + .if(Shadow.isShadow, Shadow.cloneShadow(options, device)) + .else(() => { + throw new Error(`Unexpected node of type: ${node.type}`); + }) + .get(); + } + export const { getNodesBetween } = traversal; export const { diff --git a/packages/alfa-dom/src/node/attribute.ts b/packages/alfa-dom/src/node/attribute.ts index 6128285d88..eb5333ec57 100644 --- a/packages/alfa-dom/src/node/attribute.ts +++ b/packages/alfa-dom/src/node/attribute.ts @@ -213,6 +213,23 @@ export namespace Attribute { ); } + /** + * @internal + */ + export function cloneAttribute( + attribute: Attribute, + ): Trampoline>> { + return Trampoline.done( + Attribute.of( + attribute.namespace, + attribute.prefix, + attribute.name, + attribute.value, + attribute.externalId, + ), + ); + } + /** * Conditionally fold the case of an attribute name based on its owner; HTML * attributes are case insensitive while attributes in other namespaces aren't. diff --git a/packages/alfa-dom/src/node/comment.ts b/packages/alfa-dom/src/node/comment.ts index 97683921dc..9f3a5dc792 100644 --- a/packages/alfa-dom/src/node/comment.ts +++ b/packages/alfa-dom/src/node/comment.ts @@ -81,4 +81,11 @@ export namespace Comment { export function fromComment(json: JSON): Trampoline { return Trampoline.done(Comment.of(json.data)); } + + /** + * @internal + */ + export function cloneComment(comment: Comment): Trampoline { + return Trampoline.done(Comment.of(comment.data, comment.externalId)); + } } diff --git a/packages/alfa-dom/src/node/document.ts b/packages/alfa-dom/src/node/document.ts index 8451069ff7..0c55b27b5c 100644 --- a/packages/alfa-dom/src/node/document.ts +++ b/packages/alfa-dom/src/node/document.ts @@ -1,4 +1,6 @@ +import { Array } from "@siteimprove/alfa-array"; import { Device } from "@siteimprove/alfa-device"; +import { Iterable } from "@siteimprove/alfa-iterable"; import { None, Option } from "@siteimprove/alfa-option"; import { Trampoline } from "@siteimprove/alfa-trampoline"; import { Node } from "../node"; @@ -125,6 +127,29 @@ export namespace Document { Node.fromNode(child, device), ).map((children) => Document.of(children, json.style.map(Sheet.from))); } + + /** + * @internal + */ + export function cloneDocument( + options: Node.ElementReplacementOptions, + device?: Device, + ): (document: Document) => Trampoline { + return (document) => + Trampoline.traverse(document.children(), (child) => { + if (Element.isElement(child) && options.predicate(child)) { + return Trampoline.done(Array.from(options.newElements)); + } + + return Node.cloneNode(child, options, device).map((node) => [node]); + }).map((children) => { + return Document.of( + Iterable.flatten(children), + document.style, + document.externalId, + ); + }); + } } function indent(input: string): string { diff --git a/packages/alfa-dom/src/node/element.ts b/packages/alfa-dom/src/node/element.ts index 84c539a78d..1700597039 100644 --- a/packages/alfa-dom/src/node/element.ts +++ b/packages/alfa-dom/src/node/element.ts @@ -16,6 +16,7 @@ import { Shadow } from "./shadow"; import { Slot } from "./slot"; import { Slotable } from "./slotable"; +import { Declaration } from "@siteimprove/alfa-dom"; import * as helpers from "./element/input-type"; import * as predicate from "./element/predicate"; @@ -447,6 +448,62 @@ export namespace Element { }); } + /** + * @internal + */ + export function cloneElement( + options: Node.ElementReplacementOptions, + device?: Device, + ): (element: Element) => Trampoline { + return (element) => + Trampoline.traverse(element.children(), (child) => { + if (Element.isElement(child) && options.predicate(child)) { + return Trampoline.done(Array.from(options.newElements)); + } + + return Node.cloneNode(child, options, device).map((node) => [node]); + }).map((children) => { + const deviceOption = Option.from(device); + const clonedElement = Element.of( + element.namespace, + element.prefix, + element.name, + element.attributes.map((attribute) => + Attribute.clone(attribute, options, device), + ), + Iterable.flatten(children), + element.style.map((block) => { + return Block.of( + Iterable.map(block.declarations, (declaration) => + Declaration.of( + declaration.name, + declaration.value, + declaration.important, + ), + ), + ); + }), + deviceOption.flatMap((d) => element.getBoundingBox(d)), + deviceOption, + element.externalId, + ); + + if (element.shadow.isSome()) { + clonedElement._attachShadow( + Shadow.clone(element.shadow.get(), options, device), + ); + } + + if (element.content.isSome()) { + clonedElement._attachContent( + Document.clone(element.content.get(), options, device), + ); + } + + return clonedElement; + }); + } + export const { hasAttribute, hasBox, diff --git a/packages/alfa-dom/src/node/fragment.ts b/packages/alfa-dom/src/node/fragment.ts index 4ece171afb..4f6976af22 100644 --- a/packages/alfa-dom/src/node/fragment.ts +++ b/packages/alfa-dom/src/node/fragment.ts @@ -1,7 +1,9 @@ import { Device } from "@siteimprove/alfa-device"; import { Trampoline } from "@siteimprove/alfa-trampoline"; +import { Iterable } from "@siteimprove/alfa-iterable"; import { Node } from "../node"; +import { Element } from "./element"; /** * @public @@ -71,6 +73,25 @@ export namespace Fragment { Node.fromNode(child, device), ).map((children) => Fragment.of(children)); } + + /** + * @internal + */ + export function cloneFragment( + options: Node.ElementReplacementOptions, + device?: Device, + ): (fragment: Fragment) => Trampoline { + return (fragment) => + Trampoline.traverse(fragment.children(), (child) => { + if (Element.isElement(child) && options.predicate(child)) { + return Trampoline.done(Array.from(options.newElements)); + } + + return Node.cloneNode(child, options, device).map((node) => [node]); + }).map((children) => { + return Fragment.of(Iterable.flatten(children), fragment.externalId); + }); + } } function indent(input: string): string { diff --git a/packages/alfa-dom/src/node/shadow.ts b/packages/alfa-dom/src/node/shadow.ts index 69771e29d4..ee6ed5fed0 100644 --- a/packages/alfa-dom/src/node/shadow.ts +++ b/packages/alfa-dom/src/node/shadow.ts @@ -2,6 +2,7 @@ import { None, Option } from "@siteimprove/alfa-option"; import { Trampoline } from "@siteimprove/alfa-trampoline"; import { Device } from "@siteimprove/alfa-device"; +import { Iterable } from "@siteimprove/alfa-iterable"; import { Node } from "../node"; import { Sheet } from "../style/sheet"; import { Element } from "./element"; @@ -156,6 +157,30 @@ export namespace Shadow { Shadow.of(children, json.style.map(Sheet.from), json.mode as Mode), ); } + + /** + * @internal + */ + export function cloneShadow( + options: Node.ElementReplacementOptions, + device?: Device, + ): (shadow: Shadow) => Trampoline { + return (shadow) => + Trampoline.traverse(shadow.children(), (child) => { + if (Element.isElement(child) && options.predicate(child)) { + return Trampoline.done(Array.from(options.newElements)); + } + + return Node.cloneNode(child, options, device).map((node) => [node]); + }).map((children) => { + return Shadow.of( + Iterable.flatten(children), + shadow.style, + shadow.mode, + shadow.externalId, + ); + }); + } } function indent(input: string): string { diff --git a/packages/alfa-dom/src/node/text.ts b/packages/alfa-dom/src/node/text.ts index 8934651a86..db4a2e86b0 100644 --- a/packages/alfa-dom/src/node/text.ts +++ b/packages/alfa-dom/src/node/text.ts @@ -106,4 +106,11 @@ export namespace Text { export function fromText(json: JSON): Trampoline { return Trampoline.done(Text.of(json.data)); } + + /** + * @internal + */ + export function cloneText(text: Text) { + return Trampoline.done(Text.of(text.data, text.externalId)); + } } diff --git a/packages/alfa-dom/src/node/type.ts b/packages/alfa-dom/src/node/type.ts index 748cefc94e..e2f6528ad7 100644 --- a/packages/alfa-dom/src/node/type.ts +++ b/packages/alfa-dom/src/node/type.ts @@ -96,4 +96,15 @@ export namespace Type { ), ); } + + /** + * @internal + */ + export function cloneType( + type: Type, + ): Trampoline> { + return Trampoline.done( + Type.of(type.name, type.publicId, type.systemId, type.externalId), + ); + } } diff --git a/packages/alfa-dom/test/node.spec.tsx b/packages/alfa-dom/test/node.spec.tsx index 9faed03458..a01254fef1 100644 --- a/packages/alfa-dom/test/node.spec.tsx +++ b/packages/alfa-dom/test/node.spec.tsx @@ -1,6 +1,8 @@ import { test } from "@siteimprove/alfa-test"; +import { Device } from "@siteimprove/alfa-device"; import { h } from "../h"; +import { Node } from "../src"; test("#tabOrder() returns the tab order of a node", (t) => { const a =