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
4 changes: 4 additions & 0 deletions lib/CreateChildArgument.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import type { DocumentFragment } from './DocumentFragment.ts';
import type { Node } from './Node.ts';

export type CreateChildArgument = Node | DocumentFragment | string | boolean | number | null | undefined;
128 changes: 122 additions & 6 deletions lib/Document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,16 @@ import { JsonML, type JsonMLElement } from './JsonML.js';
import { Node } from './Node.js';
import { Element } from './Element.js';
import { appendChild } from './appendChild.js';
import { DOCUMENT_NODE } from './constants.js';
import { DOCUMENT_NODE, XML_DECLARATION } from './constants.js';
import { domQuery } from './domQuery/index.js';
import { findAll } from './findAll.js';
import { isElement } from './isElement.ts';
import { DocumentFragment } from './DocumentFragment.ts';
import { NSMap } from './NSMap.ts';
import { prettyPrint } from './prettyPrint.ts';
import { simplePrint } from './simplePrint.ts';
import type { CreateChildArgument } from './CreateChildArgument.ts';
import type { XMLAttr } from './XMLAttr.ts';

/**
* This class describes an XML document.
Expand All @@ -14,6 +20,8 @@ import { isElement } from './isElement.ts';
*/
export class Document extends Node {
root: Element | null = null;
/** @ignore */
namespaces = new NSMap();

/**
* Constructs a new Document instance.
Expand All @@ -25,6 +33,31 @@ export class Document extends Node {
this.nodeType = DOCUMENT_NODE;
}

/**
* Attach a namespace to the document.
*
* @param namespaceURI The namespace URI to attach.
* @param [prefix] Prefix to use on elements belonging to the namespace.
*/
attachNS (namespaceURI: string, prefix = ''): (name: string, attr?: XMLAttr | null, ...children: (CreateChildArgument | CreateChildArgument[])[]) => Element {
this.namespaces.add(namespaceURI, prefix);
this._updateNS();

// Return a new create function bound to the namespace
return this.createElementNS.bind(this, namespaceURI);
}

/** @ignore */
_updateNS () {
// ensure that namespaces exist on the root node
if (this.root) {
for (const [ namespaceURI, prefix ] of this.namespaces.list()) {
const key = 'xmlns' + (prefix ? ':' + prefix : '');
this.root.setAttribute(key, namespaceURI);
}
}
}

// overwrites super
get textContent () {
return this.root ? this.root.textContent : '';
Expand All @@ -37,6 +70,60 @@ export class Document extends Node {
return this.childNodes.filter(isElement);
}

/**
* Create a new element node.
*
* @param qualifiedName The local tagName of the element.
* @param attr A record of attributes to assign to the new element.
* If the value is null or undefined, the attribute will be omitted.
* @param children Nodes to insert as children.
* Strings will be converted to TextNodes and arrays will be flattened.
* @returns A new Element instance.
*/
createElement = (
qualifiedName: string,
attr: XMLAttr | null | undefined,
...children: (CreateChildArgument | CreateChildArgument[])[]
): Element => {
const element = new Element(qualifiedName);
if (attr) {
for (const [ key, val ] of Object.entries(attr)) {
if (val != null) {
element.setAttribute(key, String(val));
}
}
}
for (const child of children) {
element.append(child);
}
return element;
};

createElementNS = (
namespaceURI: string,
qualifiedName: string,
attr: XMLAttr | null | undefined,
...children: (CreateChildArgument | CreateChildArgument[])[]
): Element => {
const ns = this.namespaces.get(namespaceURI);
if (!ns) {
throw new Error('Unknown namespace ' + namespaceURI);
}
const element = new Element(ns + ':' + qualifiedName);
// can this not be solved by Element(name, attr) ... does the same thing internally, right?
if (attr) {
for (const [ key, val ] of Object.entries(attr)) {
if (val != null) {
element.setAttribute(key, String(val));
}
}
}
for (const child of children) {
element.append(child);
}
return element;
};

/**
* Return all descendant elements that have the specified tag name.
*
Expand Down Expand Up @@ -77,12 +164,26 @@ export class Document extends Node {
}

// overwrites super
appendChild (node: Element): Element {
if (this.root) {
throw new Error('A document may only have one child/root element.');
appendChild<T extends Node | DocumentFragment> (node: T): T {
if (this.root || (node instanceof DocumentFragment && node.childNodes.length > 1)) {
throw new Error('A document must have only one child element.');
}
let root: Element;
if (node instanceof DocumentFragment) {
if (!(node.childNodes[0] instanceof Element)) {
throw new Error('Document root node must be an Element');
}
root = node.childNodes[0];
}
else if (node instanceof Element) {
root = node;
}
appendChild(this, node);
this.root = node;
else {
throw new Error('Document root node must be an Element');
}
appendChild(this, root);
this.root = root;
this._updateNS();
return node;
}

Expand All @@ -94,4 +195,19 @@ export class Document extends Node {
toJS (): JsonMLElement | [] {
return this.root ? JsonML(this.root) : [];
}

/**
* Print the document as a string.
*
* @param pretty Apply automatic linebreaks and indentation to the output.
* @returns The document as an XML string.
*/
print (pretty = false): string {
if (!(this.root instanceof Element)) {
throw new Error('root element is missing');
}
return `${XML_DECLARATION}\n` + (
pretty ? prettyPrint(this.root) : simplePrint(this.root)
);
}
}
43 changes: 43 additions & 0 deletions lib/DocumentFragment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { appendChild } from './appendChild.ts';
import { DOCUMENT_FRAGMENT_NODE } from './constants.ts';
import type { Node } from './Node.ts';
import { prettyPrint } from './prettyPrint.ts';
import { simplePrint } from './simplePrint.ts';

/**
* A class describing a DocumentFragment.
*/
export class DocumentFragment {
/** The immediate children contained in the fragment. */
childNodes: Node[] = [];
/** A numerical node type identifier. */
nodeType: number = DOCUMENT_FRAGMENT_NODE;

/**
* Appends a child node into the document fragment.
*
* @param node The new child node
* @returns The same node that was passed in.
*/
appendChild<T extends Node | DocumentFragment> (node: T): T {
appendChild(this, node);
return node;
}

/** @ignore */
toString (): string {
return '#document-fragment';
}

/**
* Print the document as a string.
*
* @param pretty Apply automatic linebreaks and indentation to the output.
* @returns The document as an XML string.
*/
print (pretty = false): string {
return pretty
? prettyPrint(this)
: simplePrint(this);
}
}
83 changes: 78 additions & 5 deletions lib/Element.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { Node } from './Node.js';
import { ELEMENT_NODE } from './constants.js';
import { ELEMENT_NODE, XML_DECLARATION } from './constants.js';
import { JsonML, type JsonMLElement } from './JsonML.js';
import { domQuery } from './domQuery/index.js';
import { findAll } from './findAll.js';
import { isElement } from './isElement.ts';
import { TextNode } from './TextNode.ts';
import type { CreateChildArgument } from './CreateChildArgument.ts';
import { prettyPrint } from './prettyPrint.ts';
import { simplePrint } from './simplePrint.ts';

// eslint-disable-next-line @typescript-eslint/unbound-method
const hasOwnProperty = Object.prototype.hasOwnProperty;
Expand Down Expand Up @@ -31,7 +35,7 @@ export class Element extends Node {
* Constructs a new Element instance.
*
* @param tagName The tag name of the node.
* @param [attr={}] A collection of attributes to assign.
* @param [attr={}] A collection of attributes to assign. Values of null or undefined will be ignored.
* @param [closed=false] Was the element "self-closed" when read.
*/
constructor (tagName: string, attr: Record<string, string> = {}, closed: boolean = false) {
Expand All @@ -46,7 +50,12 @@ export class Element extends Node {
this.fullName = tagName;
this.closed = !!closed;
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
this.attr = Object.assign(Object.create(null), attr);
this.attr = Object.create(null);
for (const [ k, v ] of Object.entries(attr)) {
if (v != null) {
this.setAttribute(k, v);
}
}

// inherited instance props from Node
this.nodeName = this.tagName.toUpperCase();
Expand All @@ -72,6 +81,18 @@ export class Element extends Node {
return this.childNodes.filter(isElement);
}

/**
* Returns an element's first child Element, or null if there are no child elements
*/
get firstElementChild (): Element | null {
for (const child of this.childNodes) {
if (isElement(child)) {
return child;
}
}
return null;
}

/**
* Read an attribute from the element.
*
Expand All @@ -88,8 +109,8 @@ export class Element extends Node {
* @param name The attribute name to read.
* @param value The value to set
*/
setAttribute (name: string, value: string) {
this.attr[name] = value;
setAttribute (name: string, value: string | number | boolean) {
this.attr[name] = String(value);
}

/**
Expand All @@ -111,6 +132,46 @@ export class Element extends Node {
delete this.attr[name];
}

get className (): string {
return this.getAttribute('class') ?? '';
}

set className (val: unknown) {
this.setAttribute('class', String(val));
}

/**
* Inserts a set of Node objects or strings after the last child of the Element.
* Strings are inserted as equivalent Text nodes.
*/
append (...nodes: (CreateChildArgument | CreateChildArgument[])[]): void {
const flatNodes = nodes.flat();
for (const n of flatNodes) {
if (typeof n === 'string' || typeof n === 'number' || typeof n === 'boolean') {
this.appendChild(new TextNode(n));
}
else if (n) {
this.appendChild(n);
}
}
}

/**
* Insert a set of Node objects or strings before the first child of the Element.
* Strings are inserted as equivalent Text nodes.
*/
prepend (...nodes: (CreateChildArgument | CreateChildArgument[])[]): void {
const flatNodes = nodes.flat();
for (const n of flatNodes) {
if (typeof n === 'string' || typeof n === 'number' || typeof n === 'boolean') {
this.insertBefore(new TextNode(n), this.firstChild);
}
else if (n) {
this.insertBefore(n, this.firstChild);
}
}
}

/**
* Return all descendant elements that have the specified tag name.
*
Expand Down Expand Up @@ -159,4 +220,16 @@ export class Element extends Node {
toJS (): JsonMLElement {
return JsonML(this);
}

/**
* Print the document as a string.
*
* @param pretty Apply automatic linebreaks and indentation to the output.
* @returns The document as an XML string.
*/
print (pretty = false): string {
return `${XML_DECLARATION}\n` + (
pretty ? prettyPrint(this) : simplePrint(this)
);
}
}
27 changes: 27 additions & 0 deletions lib/NSMap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
export class NSMap {
uriToPre: Record<string, string> = {};
preToUri: Record<string, string> = {};

get (nsURI: string): string | undefined {
return this.uriToPre[nsURI];
}

getByPrefix (nsPrefix: string): string | undefined {
return this.preToUri[nsPrefix];
}

list (): [string, string][] {
return Array.from(Object.entries(this.uriToPre));
}

add (nsURI: string, nsPrefix: string) {
if ((nsURI in this.uriToPre) && (this.uriToPre[nsURI] !== nsPrefix)) {
throw new Error(nsURI + ' allready has a different prefix');
}
if ((nsPrefix in this.preToUri) && (this.preToUri[nsPrefix] !== nsPrefix)) {
throw new Error(nsPrefix + ' allready has a different URI');
}
this.uriToPre[nsURI] = nsPrefix;
this.preToUri[nsPrefix] = nsURI;
}
}
Loading