Skip to content

Commit

Permalink
Clone-replace for alfa-dom Nodes (#1523)
Browse files Browse the repository at this point in the history
* Add node cloning functions with replace capability

* Add changeset

* Clone attributes

* Clean up

* Extract API

* Update clone functions to accept `Iterable<Element>`

* 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>
  • Loading branch information
rcj-siteimprove and github-actions[bot] authored Dec 7, 2023
1 parent ff300f9 commit 2ce1a8c
Show file tree
Hide file tree
Showing 15 changed files with 455 additions and 10 deletions.
5 changes: 5 additions & 0 deletions .changeset/twelve-shrimps-call.md
Original file line number Diff line number Diff line change
@@ -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.
52 changes: 43 additions & 9 deletions docs/review/api/alfa-dom.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ export class Attribute<N extends string = string> extends Node<"attribute"> {

// @public (undocumented)
export namespace Attribute {
// @internal (undocumented)
export function cloneAttribute<N extends string = string>(attribute: Attribute<N>): Trampoline<Attribute<N | Lowercase<N>>>;
// @internal
export function foldCase<N extends string = string>(name: N, owner: Option<Element>): N | Lowercase<N>;
// @internal (undocumented)
Expand Down Expand Up @@ -132,6 +134,8 @@ export class Comment extends Node<"comment"> {

// @public (undocumented)
export namespace Comment {
// @internal (undocumented)
export function cloneComment(comment: Comment): Trampoline<Comment>;
// @internal (undocumented)
export function fromComment(json: JSON): Trampoline<Comment>;
// (undocumented)
Expand Down Expand Up @@ -222,11 +226,11 @@ export class Document extends Node<"document"> {
// @internal (undocumented)
protected _internalPath(options?: Node.Traversal): string;
// (undocumented)
static of(children: Iterable<Node>, style?: Iterable<Sheet>, externalId?: string, extraData?: any): Document;
static of(children: Iterable_2<Node>, style?: Iterable_2<Sheet>, externalId?: string, extraData?: any): Document;
// (undocumented)
parent(options?: Node.Traversal): Option<Node>;
// (undocumented)
get style(): Iterable<Sheet>;
get style(): Iterable_2<Sheet>;
// (undocumented)
toJSON(options?: Node.SerializationOptions): Document.JSON;
// (undocumented)
Expand All @@ -235,14 +239,16 @@ export class Document extends Node<"document"> {

// @public (undocumented)
export namespace Document {
// @internal (undocumented)
export function cloneDocument(options: Node.ElementReplacementOptions, device?: Device): (document: Document) => Trampoline<Document>;
// @internal (undocumented)
export function fromDocument(json: JSON, device?: Device): Trampoline<Document>;
// (undocumented)
export function isDocument(value: unknown): value is Document;
// (undocumented)
export interface JSON extends Node.JSON<"document"> {
// (undocumented)
style: Array<Sheet.JSON>;
style: Array_2<Sheet.JSON>;
}
}

Expand Down Expand Up @@ -302,6 +308,8 @@ export class Element<N extends string = string> extends Node<"element"> implemen

// @public (undocumented)
export namespace Element {
// @internal (undocumented)
export function cloneElement(options: Node.ElementReplacementOptions, device?: Device): (element: Element) => Trampoline<Element>;
// @internal (undocumented)
export function fromElement<N extends string = string>(json: JSON<N>, device?: Device): Trampoline<Element<N>>;
// (undocumented)
Expand Down Expand Up @@ -391,13 +399,15 @@ export class Fragment extends Node<"fragment"> {
// @internal (undocumented)
protected _internalPath(): string;
// (undocumented)
static of(children: Iterable<Node>, externalId?: string, extraData?: any): Fragment;
static of(children: Iterable_2<Node>, 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<Fragment>;
// @internal (undocumented)
export function fromFragment(json: JSON, device?: Device): Trampoline<Fragment>;
// (undocumented)
Expand Down Expand Up @@ -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<Node>;
// (undocumented)
export interface EARL extends earl.EARL {
// (undocumented)
Expand All @@ -775,6 +799,13 @@ export namespace Node {
};
}
// (undocumented)
export interface ElementReplacementOptions {
// (undocumented)
newElements: Iterable<Element>;
// (undocumented)
predicate: Predicate<Element>;
}
// (undocumented)
export function from(json: Element.JSON, device?: Device): Element;
// (undocumented)
export function from(json: Attribute.JSON, device?: Device): Attribute;
Expand All @@ -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)
Expand Down Expand Up @@ -956,11 +984,11 @@ export class Shadow extends Node<"shadow"> {
// (undocumented)
get mode(): Shadow.Mode;
// (undocumented)
static of(children: Iterable<Node>, style?: Iterable<Sheet>, mode?: Shadow.Mode, externalId?: string, extraData?: any): Shadow;
static of(children: Iterable_2<Node>, style?: Iterable_2<Sheet>, mode?: Shadow.Mode, externalId?: string, extraData?: any): Shadow;
// (undocumented)
parent(options?: Node.Traversal): Option<Node>;
// (undocumented)
get style(): Iterable<Sheet>;
get style(): Iterable_2<Sheet>;
// (undocumented)
toJSON(): Shadow.JSON;
// (undocumented)
Expand All @@ -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<Shadow>;
// @internal (undocumented)
export function fromShadow(json: JSON, device?: Device): Trampoline<Shadow>;
// (undocumented)
Expand Down Expand Up @@ -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<Text>;
// @internal (undocumented)
export function fromText(json: JSON): Trampoline<Text>;
// (undocumented)
Expand Down Expand Up @@ -1173,6 +1205,8 @@ export class Type<N extends string = string> extends Node<"type"> {

// @public (undocumented)
export namespace Type {
// @internal (undocumented)
export function cloneType<N extends string = string>(type: Type<N>): Trampoline<Type<N>>;
// @internal (undocumented)
export function fromType<N extends string = string>(json: JSON<N>): Trampoline<Type<N>>;
// (undocumented)
Expand Down
1 change: 1 addition & 0 deletions packages/alfa-dom/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
165 changes: 164 additions & 1 deletion packages/alfa-dom/src/node.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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";

Expand Down Expand Up @@ -380,6 +382,167 @@ export namespace Node {
}
}

export interface ElementReplacementOptions {
predicate: Predicate<Element>;
newElements: Iterable<Element>;
}

/**
* 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<Node> {
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 {
Expand Down
17 changes: 17 additions & 0 deletions packages/alfa-dom/src/node/attribute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,23 @@ export namespace Attribute {
);
}

/**
* @internal
*/
export function cloneAttribute<N extends string = string>(
attribute: Attribute<N>,
): Trampoline<Attribute<N | Lowercase<N>>> {
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.
Expand Down
Loading

0 comments on commit 2ce1a8c

Please sign in to comment.