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
2,324 changes: 2,063 additions & 261 deletions API.md

Large diffs are not rendered by default.

41 changes: 27 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ There are some limitations:

- Comments are discarded.
- Processing instructions are discarded.
- Namespace prefixes are preserved but otherwise ignored.
- Namespace prefixes are preserved on every node; full xmlns validation is opt-in via the `ns` parse option.
- Doctypes are discarded.
- Nested doctypes are not supported and will cause errors.
- Nodes have a limited interface.
Expand All @@ -34,10 +34,10 @@ $ npm i @borgar/simple-xml
import { parseXML } from '@borgar/simple-xml';

const dom = parseXML('<root><node>Lorem ipsum</node></root>');
console.log(dom.getElementsByTagName('node').textContent);
console.log(dom.getElementsByTagName('node')[0].textContent);
```

The parse function accepts two arguments, the first is an XML source. The second is an options object. There are two options:
The parse function accepts two arguments, the first is an XML source. The second is an options object. There are three options:

Allow normally forbidden unquoted attributes with `laxAttr`:

Expand All @@ -51,7 +51,13 @@ Allow normally forbidden "rootless" documents with `emptyDoc`:
parseXML('', { emptyDoc: true });
```

As well as a parse method, the package exports Node classes: Node, Element, Document, TextNode, and CDataNode. They are pretty much what you expect but with an incomplete or altered set of DOM API functions.
Validate `xmlns` declarations and element/attribute prefixes against them with `ns`. With this flag, an unknown prefix or a re-bound prefix throws a `NamespaceError`:

```js
parseXML('<root xmlns:x="urn:example"><x:node /></root>', { ns: true });
```

As well as a parse method, the package exports the node classes (`Node`, `Element`, `Document`, `DocumentFragment`, `TextNode`, `CDataNode`), serialization helpers (`prettyPrint`, `simplePrint`, `escapeXML`), the `isElement` type guard, and an error hierarchy rooted at `XMLError` (`ParserError`, `NamespaceError`, `HierarchyError`, `NotFoundError`). The node classes are pretty much what you'd expect but with an "incomplete" or altered set of DOM API functions.

[See the API documentation.](API.md)

Expand All @@ -68,20 +74,24 @@ As well as a parse method, the package exports Node classes: Node, Element, Docu
* `.nodeType`
A node type number (e.g. `Element.ELEMENT_NODE === 1`)

* `.ns`
A namespace identifier (`foo` in the case of `<foo:bar>`)
* `.prefix`
The namespace prefix of the element, or `null` if it has none (`foo` in the case of `<foo:bar>`).

* `.localName`
The tag name with any namespace prefix stripped (`bar` in the case of `<foo:bar>`).

* `.fullName`
A full tag name with identifier (`foo:bar` in the case of `<foo:bar>`)
The full tag name including the prefix (`foo:bar` in the case of `<foo:bar>`).

**Attributes**

* `.getAttribute( attrName )`
* `.setAttribute( attrName, attrValue )`
* `.hasAttribute( attrName )`
* `.hasAttributes()`
* `.removeAttribute( attrName )`

Attributes are not stored as attribute nodes in a list, but rather they are a simple `{ name: value }` style object on the node.
* `.attributes`
A `NamedNodeMap` of `Attr` nodes — iterable, indexable by name (`el.attributes.foo` returns the `Attr`), and with DOM-style `getNamedItem` / `setNamedItem` / `removeNamedItem` methods.

**Tree & traversal**

Expand All @@ -105,6 +115,9 @@ Attributes are not stored as attribute nodes in a list, but rather they are a si
* `.getElementsByTagName( tagName )`
Lists all Elements in the target's subtree, traversal order, that have `.tagName` equal to the argument. Function is case-sensitive.

* `.querySelector( cssSelector )`
Returns the first Element in the target's subtree, traversal order, that matches the supplied CSS selector, or `null` if none match.

* `.querySelectorAll( cssSelector )`
Lists all Elements in the target's subtree, traversal order, that match the supplied CSS selector. Function should be case-sensitive but may be case insensitive for some.

Expand All @@ -123,19 +136,19 @@ It will parse to a document which has a `.root` node looking roughly like this:
{
nodeType: 1,
nodeName: 'TAG',
ns: 'x',
prefix: 'x',
localName: 'tag',
tagName: 'tag',
fullName: 'x:tag',
attr: { 'foo': 'bar' }
attributes: NamedNodeMap { /* Attr { name: 'foo', value: 'bar' } */ },
parentNode: null,
childNodes = [
childNodes: [
{
nodeName: '#text',
nodeType: 3,
value: 'Text content',
data: 'Text content',
parentNode: {...},
}
]
}
```

47 changes: 47 additions & 0 deletions lib/Attr.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { Node } from './Node.js';
import { ATTRIBUTE_NODE } from './constants.js';
import { splitTagName } from './splitTagName.ts';

/**
* A class describing an attribute.
*
* @augments Node
*/
export class Attr extends Node {
/** The namespace prefix of the element, or null' if no prefix is specified. */
prefix: string | null;
/** The name of the tag for the given element, excluding any namespace prefix. */
localName: string;

/** The node's name. */
name: string;
/** The node's data value. */
value: string;

/**
* Constructs a new Attr instance.
*
* @param name The name of the attribute.
* @param value The data of the attribute.
*/
constructor (name: string, value: any) {
super();

const [ prefix, localName ] = splitTagName(name);
this.prefix = prefix;
this.localName = localName;
this.name = localName;
this.nodeName = localName;

this.value = String(value);

this.nodeType = ATTRIBUTE_NODE;
}

/** The full name of the tag for the given element, including a namespace prefix. */
get fullName () {
return this.prefix
? this.prefix + ':' + this.localName
: this.localName;
}
}
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;
133 changes: 124 additions & 9 deletions lib/Document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,17 @@ 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';
import { HierarchyError, NamespaceError } from './errors.js';

/**
* This class describes an XML document.
Expand All @@ -14,6 +21,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 +34,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 */
private _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 +71,58 @@ 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);
element.setAttrValues(attr ?? null);
for (const child of children) {
element.append(child);
}
return element;
};

/**
* Create a new element node associated with a given namespace.
*
* @param namespaceURI The namespaceURI to associate with the element.
* @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.
*/
createElementNS = (
namespaceURI: string,
qualifiedName: string,
attr: XMLAttr | null | undefined,
...children: (CreateChildArgument | CreateChildArgument[])[]
): Element => {
const ns = this.namespaces.get(namespaceURI);
if (ns == null) {
throw new NamespaceError('Unknown namespace ' + namespaceURI);
}
const element = new Element(ns ? ns + ':' + qualifiedName : qualifiedName);
element.setAttrValues(attr ?? null);
for (const child of children) {
element.append(child);
}
return element;
};

/**
* Return all descendant elements that have the specified tag name.
*
Expand All @@ -45,7 +131,7 @@ export class Document extends Node {
*/
getElementsByTagName (tagName: string): Element[] {
if (!tagName) {
throw new Error('1 argument required, but 0 present.');
throw new TypeError('1 argument required, but 0 present.');
}
return findAll(this, tagName, []);
}
Expand All @@ -58,7 +144,7 @@ export class Document extends Node {
*/
querySelector (selector: string): Element | null {
if (!selector) {
throw new Error('1 argument required, but 0 present.');
throw new TypeError('1 argument required, but 0 present.');
}
return domQuery(this, selector)[0] || null;
}
Expand All @@ -71,18 +157,32 @@ export class Document extends Node {
*/
querySelectorAll (selector: string): Element[] {
if (!selector) {
throw new Error('1 argument required, but 0 present.');
throw new TypeError('1 argument required, but 0 present.');
}
return domQuery(this, selector);
}

// 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 HierarchyError('A document must have only one child element.');
}
let root: Element;
if (node instanceof DocumentFragment) {
if (!(node.childNodes[0] instanceof Element)) {
throw new HierarchyError('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 HierarchyError('Document root node must be an Element');
}
appendChild(this, root);
this.root = root;
this._updateNS();
return node;
}

Expand All @@ -94,4 +194,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 HierarchyError('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);
}
}
Loading