From 6fb48d878da19515113e05e3e959a57ff7e9bcea Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Tue, 10 Nov 2026 08:49:53 -0600 Subject: [PATCH] Implement basic DOM creation --- .changeset/dom-basic-creation.md | 5 ++ packages/ui/src/ascii.ts | 7 ++ packages/ui/src/dom.ts | 121 +++++++++++++++++++++++++++---- packages/ui/test/wpt.config.ts | 17 ++++- 4 files changed, 132 insertions(+), 18 deletions(-) create mode 100644 .changeset/dom-basic-creation.md create mode 100644 packages/ui/src/ascii.ts diff --git a/.changeset/dom-basic-creation.md b/.changeset/dom-basic-creation.md new file mode 100644 index 0000000..35ede76 --- /dev/null +++ b/.changeset/dom-basic-creation.md @@ -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. diff --git a/packages/ui/src/ascii.ts b/packages/ui/src/ascii.ts new file mode 100644 index 0000000..628e7d9 --- /dev/null +++ b/packages/ui/src/ascii.ts @@ -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()); +} diff --git a/packages/ui/src/dom.ts b/packages/ui/src/dom.ts index e56606e..bccb240 100644 --- a/packages/ui/src/dom.ts +++ b/packages/ui/src/dom.ts @@ -1,3 +1,5 @@ +import { asciiUppercase, asciiLowercase } from './ascii.ts'; + /** * Container type for valid names of createElement() */ @@ -11,28 +13,63 @@ 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 { @@ -40,29 +77,49 @@ export class 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; } @@ -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'; + } } diff --git a/packages/ui/test/wpt.config.ts b/packages/ui/test/wpt.config.ts index c967da8..fe88a3c 100644 --- a/packages/ui/test/wpt.config.ts +++ b/packages/ui/test/wpt.config.ts @@ -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,