From 53a20f95fe1519bc3b0a2962d9372c4e16cf5886 Mon Sep 17 00:00:00 2001 From: Tomas Zijdemans Date: Fri, 31 Jul 2026 12:42:40 +0200 Subject: [PATCH 1/2] BREAKING(xml): expose doctype from parse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sync parser walked the whole DOCTYPE declaration but stored nothing. Capture the name and PUBLIC/SYSTEM identifiers into a new XmlDoctype type exposed as XmlDocument.doctype (only reachable with disallowDoctype: false), and teach stringify to emit it behind a new doctype option (default true). Breaking: a DOCTYPE after the root element or a second DOCTYPE now throws (XML 1.0 §2.8); both were silently accepted before, which becomes observable now that the doctype is stored. The DTD internal subset is deliberately not captured and is dropped on round-trip. Co-Authored-By: Claude Fable 5 --- xml/_parse_sync.ts | 34 ++++++- xml/_parse_sync_test.ts | 134 ++++++++++++++++++++++++++++ xml/mod.ts | 7 +- xml/stringify.ts | 54 ++++++++++-- xml/stringify_test.ts | 190 ++++++++++++++++++++++++++++++++++++++++ xml/types.ts | 40 +++++++++ 6 files changed, 446 insertions(+), 13 deletions(-) diff --git a/xml/_parse_sync.ts b/xml/_parse_sync.ts index 6f4dd77beec5..0fddae6d4e87 100644 --- a/xml/_parse_sync.ts +++ b/xml/_parse_sync.ts @@ -18,6 +18,7 @@ import type { XmlCDataNode, XmlCommentNode, XmlDeclaration, + XmlDoctype, XmlDocument, XmlElement, XmlName, @@ -115,6 +116,7 @@ export function parseSync(xml: string, options?: ParseOptions): XmlDocument { const stack: MutableElement[] = []; let root: MutableElement | undefined; let declaration: XmlDeclaration | undefined; + let doctype: XmlDoctype | undefined; let rootClosed = false; // Track whether root element has been closed // Namespace tracking (lazy initialization for performance) @@ -1001,6 +1003,17 @@ export function parseSync(xml: string, options?: ParseOptions): XmlDocument { if (disallowDoctype) { error("DOCTYPE declarations are not allowed"); } + const doctypeStart = pos - 2; // Offset of '<' in '= len) { error("Unterminated quoted string in DOCTYPE"); } + const literal = input.slice(valueStart, pos); if (expectPubidLiteral) { - const pubidError = validatePubidLiteral( - input.slice(valueStart, pos), - quoteChar, - ); + const pubidError = validatePubidLiteral(literal, quoteChar); if (pubidError) error(pubidError); expectPubidLiteral = false; + doctypePublicId = literal; + } else if (doctypeSystemId === undefined) { + doctypeSystemId = literal; } pos++; } else if ( @@ -1095,6 +1112,14 @@ export function parseSync(xml: string, options?: ParseOptions): XmlDocument { } } if (pos < len) pos++; + + doctype = { + type: "doctype", + name: doctypeName, + ...(doctypePublicId !== undefined && { publicId: doctypePublicId }), + ...(doctypeSystemId !== undefined && { systemId: doctypeSystemId }), + ...computePosition(doctypeStart), + }; continue; } @@ -1466,6 +1491,7 @@ export function parseSync(xml: string, options?: ParseOptions): XmlDocument { return { ...(declaration !== undefined && { declaration }), + ...(doctype !== undefined && { doctype }), root: root as XmlElement, }; } diff --git a/xml/_parse_sync_test.ts b/xml/_parse_sync_test.ts index 7940d3d7cdc9..b1315ca953b4 100644 --- a/xml/_parse_sync_test.ts +++ b/xml/_parse_sync_test.ts @@ -58,6 +58,140 @@ Deno.test("parseSync() handles DOCTYPE with nested brackets in internal subset", assertEquals(doc.root.name.local, "root"); }); +Deno.test("parseSync() exposes doctype when disallowDoctype is false", () => { + const doc = parseSync("", { disallowDoctype: false }); + + assertEquals(doc.doctype, { + type: "doctype", + name: "root", + line: 1, + column: 1, + offset: 0, + }); +}); + +Deno.test("parseSync() exposes doctype system identifier", () => { + const doc = parseSync(``, { + disallowDoctype: false, + }); + + assertEquals(doc.doctype?.name, "html"); + assertEquals(doc.doctype?.publicId, undefined); + assertEquals(doc.doctype?.systemId, "about:legacy-compat"); +}); + +Deno.test("parseSync() exposes doctype public and system identifiers", () => { + const doc = parseSync( + ``, + { disallowDoctype: false }, + ); + + assertEquals(doc.doctype?.publicId, "-//W3C//DTD XHTML 1.0 Strict//EN"); + assertEquals( + doc.doctype?.systemId, + "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd", + ); +}); + +Deno.test("parseSync() exposes doctype public identifier without system identifier", () => { + // Leniently accepted: PUBLIC without a following system literal + const doc = parseSync(``, { + disallowDoctype: false, + }); + + assertEquals(doc.doctype?.publicId, "pub-id"); + assertEquals(doc.doctype?.systemId, undefined); +}); + +Deno.test("parseSync() exposes doctype system identifier in single quotes", () => { + const doc = parseSync(``, { + disallowDoctype: false, + }); + + assertEquals(doc.doctype?.systemId, "sys.dtd"); +}); + +Deno.test("parseSync() exposes doctype identifiers when internal subset follows", () => { + const doc = parseSync( + `]>`, + { disallowDoctype: false }, + ); + + assertEquals(doc.doctype?.name, "root"); + assertEquals(doc.doctype?.systemId, "sys.dtd"); +}); + +Deno.test("parseSync() does not capture doctype internal subset", () => { + const doc = parseSync( + `]>`, + { disallowDoctype: false }, + ); + + // Only name, ids, and position are exposed; the subset is discarded + assertEquals(Object.keys(doc.doctype!).toSorted(), [ + "column", + "line", + "name", + "offset", + "type", + ]); +}); + +Deno.test("parseSync() tracks doctype position after declaration", () => { + const doc = parseSync( + `\n`, + { disallowDoctype: false }, + ); + + assertEquals(doc.doctype?.line, 2); + assertEquals(doc.doctype?.column, 1); + assertEquals(doc.doctype?.offset, 22); +}); + +Deno.test("parseSync() zeroes doctype position when trackPosition is false", () => { + const doc = parseSync("", { + disallowDoctype: false, + trackPosition: false, + }); + + assertEquals(doc.doctype?.line, 0); + assertEquals(doc.doctype?.column, 0); + assertEquals(doc.doctype?.offset, 0); +}); + +Deno.test("parseSync() omits doctype when document has none", () => { + const doc = parseSync("", { disallowDoctype: false }); + + assertEquals("doctype" in doc, false); +}); + +Deno.test("parseSync() rejects DOCTYPE after the root element", () => { + assertThrows( + () => parseSync("", { disallowDoctype: false }), + XmlSyntaxError, + "Cannot have DOCTYPE declaration after the root element (XML 1.0 §2.8)", + ); +}); + +Deno.test("parseSync() rejects multiple DOCTYPE declarations", () => { + assertThrows( + () => + parseSync("", { + disallowDoctype: false, + }), + XmlSyntaxError, + "Cannot have multiple DOCTYPE declarations (XML 1.0 §2.8)", + ); +}); + +Deno.test("parseSync() accepts doctype name that differs from root element", () => { + // Root Element Type is a validity constraint, not well-formedness + const doc = parseSync("", { disallowDoctype: false }); + + assertEquals(doc.doctype?.name, "foo"); + assertEquals(doc.root.name.local, "bar"); +}); + // ============================================================================= // Empty Text Node Handling // ============================================================================= diff --git a/xml/mod.ts b/xml/mod.ts index 1b64f5ae5f3b..89d877965d4e 100644 --- a/xml/mod.ts +++ b/xml/mod.ts @@ -86,9 +86,10 @@ * * `` declarations are rejected by default to avoid processing * hostile DTD content. Pass `disallowDoctype: false` to tolerate them in - * trusted input (e.g. legacy XHTML or RSS feeds). DTD contents are still - * ignored — only the five predefined entities (`lt`, `gt`, `amp`, `apos`, - * `quot`) are ever expanded. + * trusted input (e.g. legacy XHTML or RSS feeds); {@linkcode parse} then + * exposes the declaration as {@linkcode XmlDocument.doctype}. DTD contents + * are still ignored — only the five predefined entities (`lt`, `gt`, `amp`, + * `apos`, `quot`) are ever expanded. * * ## Position Tracking * diff --git a/xml/stringify.ts b/xml/stringify.ts index cb4ebc747f86..d7854955d1ce 100644 --- a/xml/stringify.ts +++ b/xml/stringify.ts @@ -10,6 +10,7 @@ import type { StringifyOptions, XmlDeclaration, + XmlDoctype, XmlDocument, XmlElement, XmlNode, @@ -44,18 +45,20 @@ export function stringify( node: XmlDocument | XmlElement, options?: StringifyOptions, ): string { - const { indent, declaration = true } = options ?? {}; + const { indent, declaration = true, doctype = true } = options ?? {}; // Check if it's a document (has 'root' property) or an element if ("root" in node) { + const newline = indent !== undefined ? "\n" : ""; + const indentFn = createIndentCache(indent); let result = ""; if (declaration && node.declaration) { - result += serializeDeclaration(node.declaration); - if (indent !== undefined) { - result += "\n"; - } + result += serializeDeclaration(node.declaration) + newline; } - result += serializeElement(node.root, indent, 0); + if (doctype && node.doctype) { + result += serializeDoctype(node.doctype) + newline; + } + result += serializeElement(node.root, indent, 0, indentFn); return result; } @@ -74,6 +77,45 @@ function serializeDeclaration(decl: XmlDeclaration): string { return ``; } +/** + * Serializes a DOCTYPE declaration to a string. + * + * A `publicId` without a `systemId` is emitted as-is + * (``), matching the DOM `XMLSerializer`: + * the parser leniently accepts that form, and round-tripping + * parser-produced data must never throw. + */ +function serializeDoctype(doctype: XmlDoctype): string { + let result = ``; +} + +/** + * Quotes a system identifier, preferring double quotes and falling back to + * single quotes. + * @throws {TypeError} If the system identifier contains both quote kinds. + */ +function quoteSystemId(systemId: string): string { + if (!systemId.includes('"')) return `"${systemId}"`; + if (!systemId.includes("'")) return `'${systemId}'`; + throw new TypeError( + "Cannot serialize DOCTYPE: system identifier contains both single and double quotes", + ); +} + /** * Creates a memoized indent getter to avoid recomputing indent.repeat(depth). */ diff --git a/xml/stringify_test.ts b/xml/stringify_test.ts index e1d6169d1c69..2ed6d276c4cd 100644 --- a/xml/stringify_test.ts +++ b/xml/stringify_test.ts @@ -1,6 +1,7 @@ // Copyright 2018-2026 the Deno authors. MIT license. import { assertEquals, assertThrows } from "@std/assert"; +import { parse } from "./parse.ts"; import { stringify } from "./stringify.ts"; import type { XmlDocument, XmlElement } from "./types.ts"; @@ -310,6 +311,165 @@ Deno.test("stringify() handles document without declaration", () => { assertEquals(stringify(doc), ""); }); +// ============================================================================= +// DOCTYPE Declaration +// ============================================================================= + +const EMPTY_ROOT: XmlElement = { + type: "element", + name: { raw: "root", local: "root" }, + attributes: {}, + children: [], +}; + +Deno.test("stringify() serializes doctype with name only", () => { + const doc: XmlDocument = { + doctype: { type: "doctype", name: "root", line: 1, column: 1, offset: 0 }, + root: EMPTY_ROOT, + }; + + assertEquals(stringify(doc), ""); +}); + +Deno.test("stringify() serializes doctype with system identifier", () => { + const doc: XmlDocument = { + doctype: { + type: "doctype", + name: "root", + systemId: "about:legacy-compat", + line: 1, + column: 1, + offset: 0, + }, + root: EMPTY_ROOT, + }; + + assertEquals( + stringify(doc), + ``, + ); +}); + +Deno.test("stringify() serializes doctype with public and system identifiers", () => { + const doc: XmlDocument = { + doctype: { + type: "doctype", + name: "root", + publicId: "-//EXAMPLE//DTD Root//EN", + systemId: "root.dtd", + line: 1, + column: 1, + offset: 0, + }, + root: EMPTY_ROOT, + }; + + assertEquals( + stringify(doc), + ``, + ); +}); + +Deno.test("stringify() serializes doctype with public identifier only", () => { + const doc: XmlDocument = { + doctype: { + type: "doctype", + name: "root", + publicId: "pub-id", + line: 1, + column: 1, + offset: 0, + }, + root: EMPTY_ROOT, + }; + + assertEquals(stringify(doc), ``); +}); + +Deno.test("stringify() omits doctype when option is false", () => { + const doc: XmlDocument = { + doctype: { type: "doctype", name: "root", line: 1, column: 1, offset: 0 }, + root: EMPTY_ROOT, + }; + + assertEquals(stringify(doc, { doctype: false }), ""); +}); + +Deno.test("stringify() emits declaration before doctype on separate lines when indenting", () => { + const doc: XmlDocument = { + declaration: { + type: "declaration", + version: "1.0", + line: 1, + column: 1, + offset: 0, + }, + doctype: { type: "doctype", name: "root", line: 1, column: 22, offset: 21 }, + root: EMPTY_ROOT, + }; + + assertEquals( + stringify(doc, { indent: " " }), + `\n\n`, + ); +}); + +Deno.test("stringify() single-quotes system identifier containing double quote", () => { + const doc: XmlDocument = { + doctype: { + type: "doctype", + name: "root", + systemId: `a"b`, + line: 1, + column: 1, + offset: 0, + }, + root: EMPTY_ROOT, + }; + + assertEquals(stringify(doc), ``); +}); + +Deno.test("stringify() throws when system identifier contains both quote kinds", () => { + const doc: XmlDocument = { + doctype: { + type: "doctype", + name: "root", + systemId: `a"b'c`, + line: 1, + column: 1, + offset: 0, + }, + root: EMPTY_ROOT, + }; + + assertThrows( + () => stringify(doc), + TypeError, + "Cannot serialize DOCTYPE: system identifier contains both single and double quotes", + ); +}); + +Deno.test("stringify() throws when public identifier contains double quote", () => { + const doc: XmlDocument = { + doctype: { + type: "doctype", + name: "root", + publicId: `a"b`, + line: 1, + column: 1, + offset: 0, + }, + root: EMPTY_ROOT, + }; + + assertThrows( + () => stringify(doc), + TypeError, + `Cannot serialize DOCTYPE: public identifier contains '"'`, + ); +}); + // ============================================================================= // Pretty Printing // ============================================================================= @@ -598,3 +758,33 @@ Deno.test("stringify() allows single hyphen in comment", () => { "", ); }); + +// ============================================================================= +// Round-Trip with parse() +// ============================================================================= + +Deno.test("stringify() round-trips doctype from parse()", () => { + const xml = ``; + + const doc = parse(xml, { disallowDoctype: false }); + + assertEquals(stringify(doc), xml); +}); + +Deno.test("stringify() round-trips doctype with public identifier from parse()", () => { + const xml = + ``; + + const doc = parse(xml, { disallowDoctype: false }); + + assertEquals(stringify(doc), xml); +}); + +Deno.test("stringify() drops doctype internal subset on round-trip", () => { + // Documented fidelity limitation: the DTD internal subset is not captured + const doc = parse(`]>`, { + disallowDoctype: false, + }); + + assertEquals(stringify(doc), ""); +}); diff --git a/xml/types.ts b/xml/types.ts index e7c985996cd7..3dc2458ec70f 100644 --- a/xml/types.ts +++ b/xml/types.ts @@ -136,6 +136,32 @@ export interface XmlDeclaration extends XmlPosition { readonly standalone?: "yes" | "no"; } +/** + * The document type declaration of a document, exposed as + * {@linkcode XmlDocument.doctype}. + * + * Only produced when parsing with the + * {@linkcode BaseParseOptions.disallowDoctype} option set to `false`; with + * the default (`true`), a DOCTYPE declaration throws an + * {@linkcode XmlSyntaxError} instead. + * + * The DTD internal subset is not captured: its contents are validated but + * deliberately not processed, so it is lost when re-serializing with + * {@linkcode stringify}. + * + * @see {@link https://www.w3.org/TR/xml/#dt-doctype | XML 1.0 §2.8 Prolog} + */ +export interface XmlDoctype extends XmlPosition { + /** The type discriminant. */ + readonly type: "doctype"; + /** The root element name as declared in the DOCTYPE. */ + readonly name: string; + /** The public identifier, if declared with `PUBLIC`. */ + readonly publicId?: string; + /** The system identifier, if declared with `SYSTEM` or `PUBLIC`. */ + readonly systemId?: string; +} + /** * Base options shared by parsing functions. */ @@ -253,6 +279,14 @@ export interface StringifyOptions { * @default {true} */ readonly declaration?: boolean; + + /** + * If true, include the DOCTYPE declaration when stringifying a document. + * Only applies when the input is an XmlDocument with a doctype. + * + * @default {true} + */ + readonly doctype?: boolean; } // ============================================================================ @@ -322,6 +356,12 @@ export type XmlNode = export interface XmlDocument { /** The XML declaration, if present. */ readonly declaration?: XmlDeclaration; + /** + * The document type declaration, if present. Only produced when parsing + * with the {@linkcode BaseParseOptions.disallowDoctype} option set to + * `false`. + */ + readonly doctype?: XmlDoctype; /** The root element of the document. */ readonly root: XmlElement; } From 5031c330cfceaadd89fd44eb21a4fa6d641233d7 Mon Sep 17 00:00:00 2001 From: Tomas Zijdemans Date: Fri, 31 Jul 2026 12:43:29 +0200 Subject: [PATCH 2/2] BREAKING(xml): expose processing instructions from parse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sync parser validated processing instructions and then discarded them, even though XML 1.0 §2.6 requires PIs to be passed through to the application. Comments outside the root element were silently dropped the same way. Add XmlProcessingInstructionNode to the XmlNode union, collect PIs and comments outside the root into new prolog/epilog fields on XmlDocument (omitted when empty), and teach stringify to emit all of it. Move the ignoreProcessingInstructions option from ParseStreamOptions to BaseParseOptions so the sync parser accepts it too; streaming behavior is unchanged. Add the isProcessingInstruction type guard. Breaking: exhaustive switches over XmlNode gain a case, and PIs now appear in children by default for documents that contain them. Co-Authored-By: Claude Fable 5 --- xml/_parse_sync.ts | 26 +++++++- xml/parse_test.ts | 80 +++++++++++++++++++++- xml/stringify.ts | 51 ++++++++++++++ xml/stringify_test.ts | 150 ++++++++++++++++++++++++++++++++++++++++++ xml/types.ts | 74 ++++++++++++++++++--- xml/types_test.ts | 27 ++++++++ 6 files changed, 395 insertions(+), 13 deletions(-) diff --git a/xml/_parse_sync.ts b/xml/_parse_sync.ts index 0fddae6d4e87..8c476977a11c 100644 --- a/xml/_parse_sync.ts +++ b/xml/_parse_sync.ts @@ -23,6 +23,7 @@ import type { XmlElement, XmlName, XmlNode, + XmlProcessingInstructionNode, XmlTextNode, } from "./types.ts"; import { XmlSyntaxError } from "./types.ts"; @@ -89,6 +90,8 @@ type MutableElement = { export function parseSync(xml: string, options?: ParseOptions): XmlDocument { const ignoreWhitespace = options?.ignoreWhitespace ?? false; const ignoreComments = options?.ignoreComments ?? false; + const ignoreProcessingInstructions = options?.ignoreProcessingInstructions ?? + false; const trackPosition = options?.trackPosition ?? true; const disallowDoctype = options?.disallowDoctype ?? true; const maxDepth = options?.maxDepth ?? Infinity; @@ -118,6 +121,8 @@ export function parseSync(xml: string, options?: ParseOptions): XmlDocument { let declaration: XmlDeclaration | undefined; let doctype: XmlDoctype | undefined; let rootClosed = false; // Track whether root element has been closed + const prolog: Array = []; + const epilog: Array = []; // Namespace tracking (lazy initialization for performance) // Only created when first namespace prefix is encountered @@ -808,6 +813,20 @@ export function parseSync(xml: string, options?: ParseOptions): XmlDocument { } } + /** + * Add a Misc node (XML 1.0 §2.8): inside an element it becomes a child; + * outside the root it goes to the prolog or epilog. + */ + function addMisc(node: XmlProcessingInstructionNode | XmlCommentNode): void { + if (stack.length > 0) { + stack[stack.length - 1]!.children.push(node); + } else if (root) { + epilog.push(node); + } else { + prolog.push(node); + } + } + // =========================================================================== // MAIN PARSING LOOP // =========================================================================== @@ -953,7 +972,7 @@ export function parseSync(xml: string, options?: ParseOptions): XmlDocument { } if (!ignoreComments) { - addNode({ type: "comment", text: content }); + addMisc({ type: "comment", text: content }); } pos = endIdx + 3; continue; @@ -1219,8 +1238,9 @@ export function parseSync(xml: string, options?: ParseOptions): XmlDocument { error( `Processing instruction target '${target}' is reserved; 'xml' must be lowercase (XML 1.0 §2.6)`, ); + } else if (!ignoreProcessingInstructions) { + addMisc({ type: "processing_instruction", target, content }); } - // Other PIs are ignored for tree building continue; } @@ -1492,6 +1512,8 @@ export function parseSync(xml: string, options?: ParseOptions): XmlDocument { return { ...(declaration !== undefined && { declaration }), ...(doctype !== undefined && { doctype }), + ...(prolog.length > 0 && { prolog }), root: root as XmlElement, + ...(epilog.length > 0 && { epilog }), }; } diff --git a/xml/parse_test.ts b/xml/parse_test.ts index af2cdc4102bb..6025d69c7bd4 100644 --- a/xml/parse_test.ts +++ b/xml/parse_test.ts @@ -158,13 +158,87 @@ Deno.test("parse() excludes comments when ignoreComments is true", () => { }); // ============================================================================= -// Additional coverage: Processing instructions not in tree +// Processing Instructions in the Tree // ============================================================================= -Deno.test("parse() excludes processing instructions from tree", () => { +Deno.test("parse() includes processing instructions in the tree", () => { const doc = parse(""); - // PIs are not included in the tree - root should have no children + assertEquals(doc.prolog, [ + { type: "processing_instruction", target: "pi", content: "content" }, + ]); + assertEquals(doc.root.children, [ + { type: "processing_instruction", target: "another", content: "pi" }, + ]); +}); + +Deno.test("parse() excludes processing instructions when ignoreProcessingInstructions is true", () => { + const doc = parse("", { + ignoreProcessingInstructions: true, + }); + + assertEquals(doc.prolog, undefined); + assertEquals(doc.root.children.length, 0); + assertEquals(doc.epilog, undefined); +}); + +Deno.test("parse() places processing instructions after the root in epilog", () => { + const doc = parse(``); + + assertEquals(doc.epilog, [{ + type: "processing_instruction", + target: "xml-stylesheet", + content: `href="style.css"`, + }]); +}); + +Deno.test("parse() places comments outside the root in prolog and epilog", () => { + const doc = parse(""); + + assertEquals(doc.prolog, [{ type: "comment", text: " license " }]); + assertEquals(doc.epilog, [{ type: "comment", text: " trailer " }]); +}); + +Deno.test("parse() omits prolog and epilog when empty", () => { + const doc = parse(""); + + assertEquals("prolog" in doc, false); + assertEquals("epilog" in doc, false); +}); + +Deno.test("parse() excludes prolog comments when ignoreComments is true", () => { + const doc = parse("", { ignoreComments: true }); + + assertEquals(doc.prolog, [ + { type: "processing_instruction", target: "pi", content: "" }, + ]); +}); + +Deno.test("parse() keeps prolog nodes in document order", () => { + const doc = parse(""); + + assertEquals(doc.prolog, [ + { type: "processing_instruction", target: "first", content: "" }, + { type: "comment", text: " second " }, + { type: "processing_instruction", target: "third", content: "x" }, + ]); +}); + +Deno.test("parse() does not store whitespace-only text in prolog or epilog", () => { + const doc = parse("\n\n\n"); + + assertEquals(doc.prolog, [ + { type: "processing_instruction", target: "pi", content: "" }, + ]); + assertEquals(doc.epilog, undefined); +}); + +Deno.test("parse() does not include processing instructions from DTD internal subset", () => { + const doc = parse(`]>`, { + disallowDoctype: false, + }); + + assertEquals(doc.prolog, undefined); assertEquals(doc.root.children.length, 0); }); diff --git a/xml/stringify.ts b/xml/stringify.ts index d7854955d1ce..7d01ced38b79 100644 --- a/xml/stringify.ts +++ b/xml/stringify.ts @@ -14,8 +14,10 @@ import type { XmlDocument, XmlElement, XmlNode, + XmlProcessingInstructionNode, } from "./types.ts"; import { encodeAttributeValue, encodeEntities } from "./_entities.ts"; +import { isReservedPiTarget } from "./_common.ts"; export type { StringifyOptions } from "./types.ts"; @@ -58,7 +60,13 @@ export function stringify( if (doctype && node.doctype) { result += serializeDoctype(node.doctype) + newline; } + for (const misc of node.prolog ?? []) { + result += serializeNode(misc, indent, 0, indentFn) + newline; + } result += serializeElement(node.root, indent, 0, indentFn); + for (const misc of node.epilog ?? []) { + result += newline + serializeNode(misc, indent, 0, indentFn); + } return result; } @@ -198,6 +206,13 @@ function serializeNode( const prefix = getIndent(depth); return `${prefix}`; } + case "processing_instruction": { + const prefix = getIndent(depth); + validateProcessingInstruction(node); + return node.content === "" + ? `${prefix}` + : `${prefix}`; + } } } @@ -237,3 +252,39 @@ function validateCommentText(text: string): string { } return text; } + +/** + * Validates a processing instruction per XML 1.0 §2.6. Only well-formedness + * is checked; the failing cases are only reachable via hand-built nodes, as + * the parser never produces them. + * @throws {TypeError} If the target or content is not serializable. + */ +function validateProcessingInstruction( + node: XmlProcessingInstructionNode, +): void { + if (node.target === "") { + throw new TypeError( + "Cannot serialize processing instruction: target is empty", + ); + } + if (/\s/.test(node.target)) { + throw new TypeError( + "Cannot serialize processing instruction: target contains whitespace", + ); + } + if (node.target.includes("?>")) { + throw new TypeError( + `Cannot serialize processing instruction: target contains "?>"`, + ); + } + if (isReservedPiTarget(node.target)) { + throw new TypeError( + `Cannot serialize processing instruction: target "${node.target}" is reserved (XML 1.0 §2.6)`, + ); + } + if (node.content.includes("?>")) { + throw new TypeError( + `Cannot serialize processing instruction: content contains "?>"`, + ); + } +} diff --git a/xml/stringify_test.ts b/xml/stringify_test.ts index 2ed6d276c4cd..cf2745468226 100644 --- a/xml/stringify_test.ts +++ b/xml/stringify_test.ts @@ -470,6 +470,147 @@ Deno.test("stringify() throws when public identifier contains double quote", () ); }); +// ============================================================================= +// Processing Instructions +// ============================================================================= + +Deno.test("stringify() serializes processing instruction child", () => { + const element: XmlElement = { + type: "element", + name: { raw: "root", local: "root" }, + attributes: {}, + children: [{ + type: "processing_instruction", + target: "php", + content: "echo 1;", + }], + }; + + assertEquals(stringify(element), ""); +}); + +Deno.test("stringify() serializes processing instruction without content", () => { + const element: XmlElement = { + type: "element", + name: { raw: "root", local: "root" }, + attributes: {}, + children: [{ + type: "processing_instruction", + target: "marker", + content: "", + }], + }; + + assertEquals(stringify(element), ""); +}); + +Deno.test("stringify() serializes prolog and epilog around the root", () => { + const doc: XmlDocument = { + prolog: [ + { + type: "processing_instruction", + target: "xml-stylesheet", + content: `href="style.css"`, + }, + { type: "comment", text: " license " }, + ], + root: EMPTY_ROOT, + epilog: [{ type: "comment", text: " trailer " }], + }; + + assertEquals( + stringify(doc), + ``, + ); +}); + +Deno.test("stringify() puts prolog and epilog nodes on separate lines when indenting", () => { + const doc: XmlDocument = { + doctype: { type: "doctype", name: "root", line: 1, column: 1, offset: 0 }, + prolog: [{ + type: "processing_instruction", + target: "xml-stylesheet", + content: `href="style.css"`, + }], + root: EMPTY_ROOT, + epilog: [{ type: "comment", text: " trailer " }], + }; + + assertEquals( + stringify(doc, { indent: " " }), + `\n\n\n`, + ); +}); + +Deno.test("stringify() uses block layout for element with processing instruction child", () => { + const element: XmlElement = { + type: "element", + name: { raw: "root", local: "root" }, + attributes: {}, + children: [ + { type: "text", text: "text" }, + { type: "processing_instruction", target: "pi", content: "" }, + ], + }; + + assertEquals( + stringify(element, { indent: " " }), + "\ntext\n \n", + ); +}); + +Deno.test("stringify() throws on processing instruction with empty target", () => { + const doc: XmlDocument = { + prolog: [{ type: "processing_instruction", target: "", content: "" }], + root: EMPTY_ROOT, + }; + + assertThrows( + () => stringify(doc), + TypeError, + "Cannot serialize processing instruction: target is empty", + ); +}); + +Deno.test("stringify() throws on processing instruction target with whitespace", () => { + const doc: XmlDocument = { + prolog: [{ type: "processing_instruction", target: "a b", content: "" }], + root: EMPTY_ROOT, + }; + + assertThrows( + () => stringify(doc), + TypeError, + "Cannot serialize processing instruction: target contains whitespace", + ); +}); + +Deno.test("stringify() throws on processing instruction with reserved target", () => { + const doc: XmlDocument = { + prolog: [{ type: "processing_instruction", target: "XML", content: "" }], + root: EMPTY_ROOT, + }; + + assertThrows( + () => stringify(doc), + TypeError, + `Cannot serialize processing instruction: target "XML" is reserved (XML 1.0 §2.6)`, + ); +}); + +Deno.test("stringify() throws on processing instruction content containing '?>'", () => { + const doc: XmlDocument = { + prolog: [{ type: "processing_instruction", target: "pi", content: "a?>b" }], + root: EMPTY_ROOT, + }; + + assertThrows( + () => stringify(doc), + TypeError, + `Cannot serialize processing instruction: content contains "?>"`, + ); +}); + // ============================================================================= // Pretty Printing // ============================================================================= @@ -771,6 +912,15 @@ Deno.test("stringify() round-trips doctype from parse()", () => { assertEquals(stringify(doc), xml); }); +Deno.test("stringify() round-trips doctype and processing instructions", () => { + const xml = + ``; + + const doc = parse(xml, { disallowDoctype: false }); + + assertEquals(stringify(doc), xml); +}); + Deno.test("stringify() round-trips doctype with public identifier from parse()", () => { const xml = ``; diff --git a/xml/types.ts b/xml/types.ts index 3dc2458ec70f..676fcd08b7e4 100644 --- a/xml/types.ts +++ b/xml/types.ts @@ -180,6 +180,13 @@ export interface BaseParseOptions { */ readonly ignoreComments?: boolean; + /** + * If true, processing instructions are not emitted/included. + * + * @default {false} + */ + readonly ignoreProcessingInstructions?: boolean; + /** * If true, track line/column positions for events and error messages. * Disabling improves performance but makes debugging harder. @@ -240,13 +247,6 @@ export interface BaseParseOptions { * Options for {@linkcode parseXmlStream}. */ export interface ParseStreamOptions extends BaseParseOptions { - /** - * If true, processing instruction events are not emitted. - * - * @default {false} - */ - readonly ignoreProcessingInstructions?: boolean; - /** * If true, CDATA sections are emitted as regular text events. * @@ -323,6 +323,23 @@ export interface XmlCommentNode { readonly text: string; } +/** + * A processing instruction node in the XML tree. + * + * @see {@link https://www.w3.org/TR/xml/#sec-pi | XML 1.0 §2.6 Processing Instructions} + */ +export interface XmlProcessingInstructionNode { + /** The node type discriminant. */ + readonly type: "processing_instruction"; + /** The target of the processing instruction (e.g. `xml-stylesheet`). */ + readonly target: string; + /** + * The instruction content after the target, with surrounding whitespace + * trimmed. Empty string when the instruction has no content. + */ + readonly content: string; +} + /** * An element node in the XML tree. */ @@ -348,7 +365,8 @@ export type XmlNode = | XmlElement | XmlTextNode | XmlCDataNode - | XmlCommentNode; + | XmlCommentNode + | XmlProcessingInstructionNode; /** * A parsed XML document. @@ -362,8 +380,27 @@ export interface XmlDocument { * `false`. */ readonly doctype?: XmlDoctype; + /** + * Processing instructions and comments appearing before the root element, + * in document order. Omitted when there are none. + * + * Interleaving with the doctype is not preserved (the declaration and + * doctype are separate fields): a processing instruction written before + * `` re-serializes after it, which is still valid per + * XML 1.0 §2.8. + */ + readonly prolog?: ReadonlyArray< + XmlProcessingInstructionNode | XmlCommentNode + >; /** The root element of the document. */ readonly root: XmlElement; + /** + * Processing instructions and comments appearing after the root element, + * in document order. Omitted when there are none. + */ + readonly epilog?: ReadonlyArray< + XmlProcessingInstructionNode | XmlCommentNode + >; } // ============================================================================ @@ -579,3 +616,24 @@ export function isCData(node: XmlNode): node is XmlCDataNode { export function isComment(node: XmlNode): node is XmlCommentNode { return node.type === "comment"; } + +/** + * Type guard to check if a node is a processing instruction. + * + * @example Usage + * ```ts + * import { isProcessingInstruction } from "@std/xml/types"; + * import { assertEquals } from "@std/assert"; + * + * const node = { type: "processing_instruction" as const, target: "xml-stylesheet", content: "href='style.css'" }; + * assertEquals(isProcessingInstruction(node), true); + * ``` + * + * @param node The XML node to check. + * @returns `true` if the node is a processing instruction, `false` otherwise. + */ +export function isProcessingInstruction( + node: XmlNode, +): node is XmlProcessingInstructionNode { + return node.type === "processing_instruction"; +} diff --git a/xml/types_test.ts b/xml/types_test.ts index b7b1a005351f..5c55594416f9 100644 --- a/xml/types_test.ts +++ b/xml/types_test.ts @@ -5,6 +5,7 @@ import { isCData, isComment, isElement, + isProcessingInstruction, isText, XmlSyntaxError, } from "./types.ts"; @@ -94,6 +95,32 @@ Deno.test("isComment() returns false for non-comment nodes", () => { assertEquals(isComment(cdata), false); }); +Deno.test("isProcessingInstruction() returns true for processing instruction nodes", () => { + const node: XmlNode = { + type: "processing_instruction", + target: "xml-stylesheet", + content: "href='style.css'", + }; + assertEquals(isProcessingInstruction(node), true); +}); + +Deno.test("isProcessingInstruction() returns false for other nodes", () => { + const element: XmlNode = { + type: "element", + name: { raw: "root", local: "root" }, + attributes: {}, + children: [], + }; + const text: XmlNode = { type: "text", text: "hello" }; + const cdata: XmlNode = { type: "cdata", text: "data" }; + const comment: XmlNode = { type: "comment", text: "note" }; + + assertEquals(isProcessingInstruction(element), false); + assertEquals(isProcessingInstruction(text), false); + assertEquals(isProcessingInstruction(cdata), false); + assertEquals(isProcessingInstruction(comment), false); +}); + // ============================================================================= // XmlSyntaxError // =============================================================================