xml

js/format/xml.ts

fino:format/xml - XML 1.0 + Namespaces parser and serializer.

XML is a structured markup format used for documents, feeds, config files, protocols, and interchange with older systems. This module parses XML into a compact document tree, streams SAX-style events from async byte sources, and serializes document trees back to XML text.

The parser enforces XML well-formedness and XML Namespaces rules. It parses internal DTD entity definitions for expansion, but it is not a validating DTD processor. External entities are disabled by default to avoid XXE vulnerabilities. Entity expansion and nesting depth are bounded to reduce billion-laughs style attacks. Callers that supply resolveExternalEntities are responsible for their own network, filesystem, and trust boundaries.

Serialization is structural, not text-exact. The serializer emits the document root, escapes text and attributes, and can add an XML declaration, but it does not preserve prolog nodes, trailing comments or processing instructions, original entity spelling, or namespace declaration attributes consumed during namespace resolution. Use namespaces: false when a caller needs namespace declaration attributes to remain ordinary attributes.

parseStream() is a convenience SAX-style surface over async byte sources. It reparses accumulated input until a complete document is available and is therefore not a true bounded-memory streaming parser for very large XML.

XML 1.0 / Namespaces conformance matrix:

Area Status
Document/root structure Enforces exactly one root element and rejects element content after the root.
Names and QNames Validates XML names, namespace QNames, unbound prefixes, and reserved xml/xmlns namespace use.
Duplicate attributes Rejects duplicate raw attributes and duplicate expanded names after namespace resolution.
Entity/reference handling Expands predefined, numeric, and internal DTD entities; rejects undefined, recursive, invalid-character, and external entities unless explicitly resolved.
Comments, CDATA, PI, and prolog Parses comments, CDATA, processing instructions, and supported prolog nodes while enforcing XML character validity and comment/CDATA delimiters.
XML declaration Parses and skips a leading XML declaration; xml processing-instruction targets outside that declaration path are rejected.
Serializer normalization Emits structural XML, escapes text and attributes, and can add a declaration; it does not preserve entity spelling, consumed namespace declarations, or text-exact prolog/trailer nodes.
parseStream() limits Emits SAX-style events after reparsing accumulated input; it is not a bounded-memory streaming parser.
XXE/entity/depth safety External entities are disabled by default, entity expansion is capped, recursive entities are rejected, and element depth is bounded.
Intentional non-goals DTD validation and text-exact prolog round-tripping are outside the supported surface.

Two output surfaces: - Tree (DOM-lite): parse(input) -> XmlDocument - SAX/streaming: parseStream(src) -> AsyncIterableIterator<XmlEvent>

import { parse, stringify } from 'fino:format/xml';

const doc = parse('<root attr="v"><child>text</child></root>');
doc.root.name;             // 'root'
doc.root.children[0].type; // 'element'
const xml = stringify(doc, { xmlDeclaration: true });
import { parseStream } from 'fino:format/xml';

for await (const event of parseStream(byteSource)) {
  if (event.type === 'startElement') console.log(event.name);
}

Useful references: - XML 1.0: https://www.w3.org/TR/xml/ - Namespaces in XML: https://www.w3.org/TR/xml-names/ - OWASP XXE guidance: https://owasp.org/www-community/vulnerabilities/XML_External_Entity_(XXE)_Processing

Classes

class XmlParseError extends ParseError {

Error thrown when XML input is not well-formed or violates parser limits.

The error inherits source location and diagnostic rendering from ParseError. External entity rejection, entity expansion limits, nesting depth limits, undefined entities, and mismatched close tags are reported through this type.

import { XmlParseError, parse } from 'fino:format/xml';

try {
  parse('<root>');
} catch (error) {
  if (error instanceof XmlParseError) console.error(error.render());
}

Properties

name

Error name reported by XmlParseError instances.

Always the string "XmlParseError", which distinguishes XML errors from other ParseError subtypes in logs and error.name checks without an instanceof test.

import { parse } from 'fino:format/xml';

try {
  parse('<root>');
} catch (error) {
  if ((error as Error).name === 'XmlParseError') console.error('bad XML');
}

Interfaces

interface XmlDocument {

Parsed XML document tree.

A document contains exactly one root element. Processing instructions, comments, and doctypes before the root are preserved in prolog; trailing processing instructions and comments are accepted but not retained.

import { parse, type XmlDocument } from 'fino:format/xml';

const document: XmlDocument = parse('<?xml version="1.0"?><root />');
document.root.name;

Properties

type: 'document'

Discriminator for XML document values.

Always the string literal "document".

import { parse } from 'fino:format/xml';

parse('<root />').type;
root: XmlElement

Root element of the document.

XML requires exactly one root element; missing or additional element content throws during parsing.

import { parse } from 'fino:format/xml';

const root = parse('<root><child /></root>').root;
prolog: XmlNode[]

Processing instructions, comments, and doctypes before the root element.

The XML declaration is parsed and skipped rather than added as a prolog processing instruction.

import { parse } from 'fino:format/xml';

const prolog = parse('<!-- note --><root />').prolog;

interface XmlElement {

Element node with namespace metadata, attributes, and child nodes.

With namespace processing enabled, name is the local name, prefix contains the source prefix when present, and namespace contains the resolved namespace URI or null.

import { parse, type XmlElement } from 'fino:format/xml';

const element: XmlElement = parse('<x:root xmlns:x="urn:x" />').root;
element.namespace; // 'urn:x'

Properties

type: 'element'

Discriminator for element nodes.

Always the string literal "element".

import { parse } from 'fino:format/xml';

parse('<root />').root.type;
name: string

Element name.

When namespaces are enabled, this is the local name without prefix. When namespaces: false, this preserves the qualified source name.

import { parse } from 'fino:format/xml';

parse('<x:root xmlns:x="urn:x" />').root.name;
prefix: string | null

Source namespace prefix, or null when absent or namespace processing is disabled.

Prefixes are preserved for serialization, but namespace declaration attributes themselves are not included in attributes.

import { parse } from 'fino:format/xml';

parse('<x:root xmlns:x="urn:x" />').root.prefix;
namespace: string | null

Resolved namespace URI, or null when none is in scope.

Set namespaces: false to disable namespace resolution entirely.

import { parse } from 'fino:format/xml';

parse('<root xmlns="urn:default" />').root.namespace;
attributes: Record<string, string>

Element attributes as string values.

Attribute entity references are expanded. Namespace declaration attributes are consumed for namespace resolution and omitted from this record.

import { parse } from 'fino:format/xml';

parse('<root id="a" />').root.attributes.id;
children: XmlNode[]

Child nodes in source order.

Text nodes may be omitted when trim removes all text content. Self-closing elements have an empty child array.

import { parse } from 'fino:format/xml';

parse('<root>text<child /></root>').root.children;

interface XmlText {

Text node.

Entity and character references have already been expanded. When XmlParseOptions.trim is true, all-whitespace text nodes are omitted.

import { parse, type XmlText } from 'fino:format/xml';

const node = parse('<root>hello</root>').root.children[0] as XmlText;
node.data;

Properties

type: 'text'

Discriminator for text nodes.

Always the string literal "text".

import { parse } from 'fino:format/xml';

parse('<root>hello</root>').root.children[0]?.type;
data: string

Text content after entity expansion.

The string is not HTML-escaped; escape it when embedding in another output format.

import { parse } from 'fino:format/xml';

(parse('<root>&amp;</root>').root.children[0] as { data: string }).data;

interface XmlCData {

CDATA node.

CDATA contents are returned as text data without interpreting markup inside the section. The serializer emits the data inside a CDATA section.

import { parse, type XmlCData } from 'fino:format/xml';

const node = parse('<root><![CDATA[<x>]]></root>').root.children[0] as XmlCData;
node.data;

Properties

type: 'cdata'

Discriminator for CDATA nodes.

Always the string literal "cdata".

import { parse } from 'fino:format/xml';

parse('<root><![CDATA[x]]></root>').root.children[0]?.type;
data: string

Raw CDATA section content.

The closing ]]> delimiter is not included.

import { parse } from 'fino:format/xml';

(parse('<root><![CDATA[x]]></root>').root.children[0] as { data: string }).data;

interface XmlComment {

XML comment node.

Comment text excludes the <!-- and --> delimiters. XML forbids -- inside comments, and such input throws during parsing.

import { parse, type XmlComment } from 'fino:format/xml';

const comment = parse('<!-- note --><root />').prolog[0] as XmlComment;
comment.data;

Properties

type: 'comment'

Discriminator for comment nodes.

Always the string literal "comment".

import { parse } from 'fino:format/xml';

parse('<!-- note --><root />').prolog[0]?.type;
data: string

Comment content without delimiters.

The string is not escaped. Do not insert untrusted comments into another document format without escaping.

import { parse } from 'fino:format/xml';

(parse('<!-- note --><root />').prolog[0] as { data: string }).data;

interface XmlPI {

Processing instruction node.

The XML declaration is parsed but skipped from document prolog; other processing instructions are retained with their target and data.

import { parse, type XmlPI } from 'fino:format/xml';

const pi = parse('<?xml-stylesheet href="style.css"?><root />').prolog[0] as XmlPI;
pi.target;

Properties

type: 'pi'

Discriminator for processing instruction nodes.

Always the string literal "pi".

import { parse } from 'fino:format/xml';

parse('<?go now?><root />').prolog[0]?.type;
target: string

Processing instruction target.

The target keeps its source spelling. Targets that match xml case-insensitively are reserved: they are consumed as the XML declaration at the start of a document and rejected anywhere else.

import { parse } from 'fino:format/xml';

(parse('<?go now?><root />').prolog[0] as { target: string }).target;
data: string

Processing instruction data after the target.

Surrounding whitespace is trimmed by the parser. Empty processing instructions use an empty string.

import { parse } from 'fino:format/xml';

(parse('<?go now?><root />').prolog[0] as { data: string }).data;

interface XmlDoctype {

Doctype declaration node.

Internal entity declarations are parsed for expansion. External entities are rejected unless a resolver is explicitly provided in parse options.

import { parse, type XmlDoctype } from 'fino:format/xml';

const doc = parse('<!DOCTYPE root><root />').prolog[0] as XmlDoctype;
doc.data;

Properties

type: 'doctype'

Discriminator for doctype nodes.

Always the string literal "doctype".

import { parse } from 'fino:format/xml';

parse('<!DOCTYPE root><root />').prolog[0]?.type;
data: string

Doctype declaration content without the <!DOCTYPE wrapper.

Internal subset details are preserved only as parser output text, not as a rich DTD model.

import { parse } from 'fino:format/xml';

(parse('<!DOCTYPE root><root />').prolog[0] as { data: string }).data;

interface XmlParseOptions {

Options controlling XML parsing, namespaces, and entity expansion limits.

Defaults enable namespaces, preserve text whitespace, reject external entities, cap entity expansion at 1,000,000 characters, and cap nesting depth at 500 elements.

import { parse, type XmlParseOptions } from 'fino:format/xml';

const options: XmlParseOptions = { trim: true, maxDepth: 100 };
parse('<root> text </root>', options);

Properties

namespaces?: boolean

Enable XML namespace resolution. Defaults to true.

When disabled, element names preserve their qualified source names and prefix and namespace are null.

import { parse } from 'fino:format/xml';

parse('<x:r xmlns:x="urn:x" />', { namespaces: false }).root.name;
trim?: boolean

Trim text nodes and omit empty text after trimming. Defaults to false.

CDATA content and comments are not trimmed by this option.

import { parse } from 'fino:format/xml';

parse('<root> text </root>', { trim: true }).root.children[0];
maxEntityExpansion?: number

Maximum expanded character count from XML entity references.

Defaults to 1_000_000. Lower this for untrusted inputs that should fail quickly on repeated entity expansion.

import { parse } from 'fino:format/xml';

parse('<root>&amp;</root>', { maxEntityExpansion: 10 });
maxDepth?: number

Maximum nested element depth. Defaults to 500.

Deeply nested documents throw once this limit is exceeded.

import { parse } from 'fino:format/xml';

parse('<root><child /></root>', { maxDepth: 10 });
resolveExternalEntities?: ((systemId: string) => string | null) | null

Optional resolver for external DTD entities.

Defaults to null, which rejects external entities. A resolver receives the entity's system identifier and returns the replacement text, or null to leave the entity undefined — referencing an undefined entity later still throws. Supplying a resolver opts into any filesystem, network, and trust-boundary risks it performs.

import { parse } from 'fino:format/xml';

parse('<!DOCTYPE r [<!ENTITY e SYSTEM "safe">]><r>&e;</r>', {
  resolveExternalEntities: (systemId) => systemId === 'safe' ? 'ok' : null,
});

interface XmlStringifyOptions {

Options controlling XML serialization.

Serialization escapes text and attribute values, optionally pretty-prints element children, and emits an XML declaration unless disabled.

import { stringify, parse, type XmlStringifyOptions } from 'fino:format/xml';

const options: XmlStringifyOptions = { indent: '  ', xmlDeclaration: true };
stringify(parse('<root />'), options);

Properties

indent?: string

Indentation string for nested elements. Defaults to "".

An empty string emits compact XML without added newlines between child nodes. A non-empty value inserts newlines around nested children.

import { parse, stringify } from 'fino:format/xml';

stringify(parse('<root><child /></root>'), { indent: '  ' });
xmlDeclaration?: boolean

Whether to emit the XML declaration. Defaults to true.

Set to false when embedding XML fragments or when a caller provides its own declaration.

import { parse, stringify } from 'fino:format/xml';

stringify(parse('<root />'), { xmlDeclaration: false });

Types

type XmlNode = XmlElement | XmlText | XmlCData | XmlComment | XmlPI | XmlDoctype

Any non-document XML node returned in the tree model.

Use the type discriminator before accessing node-specific fields.

import { parse, type XmlNode } from 'fino:format/xml';

const node: XmlNode | undefined = parse('<root>text</root>').root.children[0];
if (node?.type === 'text') console.log(node.data);

type XmlEvent = { type: 'startElement'; name: string; prefix: string | null; namespace: string | null; attributes: Record<string, string>; } | { type: 'endElement'; name: string; } | { type: 'text'; data: string; } | { type: 'cdata'; data: string; } | { type: 'comment'; data: string; } | { type: 'pi'; target: string; data: string; }

SAX-style parse event emitted by parseStream().

Events are yielded in document order after the prolog. Start element events contain resolved namespace metadata and attributes; end element events contain only the element name.

import { parseStream, type XmlEvent } from 'fino:format/xml';

async function handle(event: XmlEvent) {
  if (event.type === 'text') console.log(event.data);
}

Functions

function parse(input: string | Uint8Array, options: XmlParseOptions = {}): XmlDocument

Parse XML input into a document tree.

Input may be a string or UTF-8 bytes. Well-formedness, namespace resolution, entity expansion limits, and nesting depth are enforced during parsing.

import { parse } from 'fino:format/xml';

const doc = parse('<root><child /></root>');

async function* parseStream( src: AsyncIterable<Uint8Array>, options: XmlParseOptions = { } ): AsyncIterableIterator<XmlEvent>

Parse XML bytes into SAX-style events.

The stream parser reparses accumulated bytes until a complete document is available and yields only newly observed events. Errors thrown after the source is exhausted are real parse errors, while earlier chunk-boundary stalls are retried with more input. Because accumulated bytes are retained and reparsed, this is not a bounded-memory streaming parser for very large documents.

Prolog nodes (XML declaration, comments, doctype, processing instructions before the root) are consumed but not emitted as events. Entity expansion limits apply as in parse(), but the maxDepth option is not enforced on this event path.

import { parseStream } from 'fino:format/xml';

async function* source() {
  yield new TextEncoder().encode('<root>');
  yield new TextEncoder().encode('<child /></root>');
}

for await (const event of parseStream(source())) {
  console.log(event.type);
}

function stringify(doc: XmlDocument, options: XmlStringifyOptions = {}): string

Serialize an XML document tree.

The serializer emits the document root and ignores prolog nodes. Text and attribute values are escaped, CDATA and comments are emitted as stored, and an XML declaration is included unless xmlDeclaration: false is set. Serializer output is normalized: it does not preserve source entity spelling, trailing document comments or processing instructions, or namespace declaration attributes removed during namespace resolution.

import { parse, stringify } from 'fino:format/xml';

const xml = stringify(parse('<root attr="&amp;">text</root>'), {
  xmlDeclaration: false,
});