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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/dom-basic-creation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clack/ui': minor
---

Add DOM element, text, and comment creation with string conversion, element name validation, ASCII-only HTML name casing, and node ownership and metadata. Add the CharacterData base class and enable the existing WPT creation tests.
7 changes: 7 additions & 0 deletions packages/ui/src/ascii.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export function asciiLowercase(s: string): string {
return s.replace(/[A-Z]/g, (c) => c.toLowerCase());
}

export function asciiUppercase(s: string): string {
return s.replace(/[a-z]/g, (c) => c.toUpperCase());
}
121 changes: 105 additions & 16 deletions packages/ui/src/dom.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { asciiUppercase, asciiLowercase } from './ascii.ts';

/**
* Container type for valid names of createElement()
*/
Expand All @@ -11,58 +13,113 @@ export class Document {

constructor(notify: () => void) {
this.notify = notify;
this.documentElement = new Element('document', notify);
this.documentElement = new Element(this, 'document', notify);
}

createElement(tagName: keyof Elements) {
return new Element(tagName, this.notify);
createElement(tagName: keyof Elements): Element {
if (arguments.length === 0) {
// oxlint-disable-next-line bombshell-dev/no-generic-error -- Web IDL requires TypeError for missing arguments.
throw new TypeError('createElement requires a tag name');
}
if (
!/^(?:[A-Za-z][^\0\t\n\f\r\u0020/>]*|[:_\u0080-\u{10FFFF}][A-Za-z0-9_.:\-\u0080-\u{10FFFF}]*)$/u.test(
tagName,
)
) {
throw new DOMException('Invalid element name', 'InvalidCharacterError');
}
return new Element(this, asciiLowercase(String(tagName)), this.notify);
}

createTextNode(data: string): Text {
return new Text(data, this.notify);
return new Text(this, String(data), this.notify);
}

createComment(data: string): Text {
return new Comment(this, String(data), this.notify);
}
}

export class Node {
readonly nodeName: string;
export abstract class Node {
static readonly ELEMENT_NODE = 1;
static readonly ATTRIBUTE_NODE = 2;
static readonly TEXT_NODE = 3;
static readonly COMMENT_NODE = 8;

readonly childNodes: Node[];
readonly ownerDocument: Document;

abstract readonly nodeType: number;
abstract readonly nodeName: string;

protected readonly notify: () => void;

constructor(nodeName: string, notify: Node['notify']) {
constructor(owner: Document, notify: Node['notify']) {
this.notify = notify;
this.nodeName = nodeName;
this.childNodes = [];
this.ownerDocument = owner;
}

hasChildNodes(): boolean {
return this.childNodes.length > 0;
}

get firstChild(): Node | null {
return this.childNodes[0] ?? null;
}

get lastChild(): Node | null {
return this.childNodes[this.childNodes.length - 1] ?? null;
}

appendChild(node: Node): Node {
this.childNodes.push(node);
this.notify();
return node;
}

get nodeValue(): string | null {
return null;
}
}

export class Element extends Node {
readonly tagName: string;
readonly localName: string;
readonly nodeName: string;

constructor(tagName: keyof Elements, notify: Node['notify']) {
super(tagName.toUpperCase(), notify);
this.tagName = tagName;
constructor(owner: Document, localName: string, notify: Node['notify']) {
super(owner, notify);
this.localName = localName;
this.nodeName = asciiUppercase(String(localName));
}

get nodeType(): number {
return Node.ELEMENT_NODE;
}

get tagName(): string {
return this.nodeName;
}

get children(): Element[] {
return this.childNodes.filter((node) => node instanceof Element);
}
get prefix(): null {
return null;
}
get namespaceURI(): string {
return 'http://www.w3.org/1999/xhtml';
}
}

export class Text extends Node {
export abstract class CharacterData extends Node {
#data: string;
constructor(data: string, notify: Node['notify']) {
super('#text', notify);
constructor(owner: Document, data: string, notify: Node['notify']) {
super(owner, notify);
this.#data = data;
}

get data() {
get data(): string {
return this.#data;
}

Expand All @@ -72,4 +129,36 @@ export class Text extends Node {
this.notify();
}
}

override get nodeValue(): string {
return this.data;
}

get textContent(): string {
return this.data;
}

get length(): number {
return this.data.length;
}
}

export class Text extends CharacterData {
get nodeType(): number {
return Node.TEXT_NODE;
}

get nodeName(): string {
return '#text';
}
}

export class Comment extends CharacterData {
get nodeType(): number {
return Node.COMMENT_NODE;
}

get nodeName(): string {
return '#comment';
}
}
17 changes: 15 additions & 2 deletions packages/ui/test/wpt.config.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,21 @@
import type { WPTConfig } from '@bomb.sh/web-platform-tests';
import { Document, Element, Node, Text } from '../src/dom.ts';
import { CharacterData, Comment, Document, Element, Node, Text } from '../src/dom.ts';

export default {
globals: { Document, Element, Node, Text },
globals: {
CharacterData,
Comment,
Document,
DOMException: globalThis.DOMException,
Element,
Node,
Text,
},
include: {
'dom/nodes/Document-createTextNode.html': true,
'dom/nodes/Document-createElement.html': true,
'dom/nodes/Document-createComment.html': true,
},
exclude: {
'css/**': true,
'custom-elements/**': true,
Expand Down
Loading