diff --git a/xml/_parse_sync.ts b/xml/_parse_sync.ts
index 6f4dd77beec5..8c476977a11c 100644
--- a/xml/_parse_sync.ts
+++ b/xml/_parse_sync.ts
@@ -18,10 +18,12 @@ import type {
XmlCDataNode,
XmlCommentNode,
XmlDeclaration,
+ XmlDoctype,
XmlDocument,
XmlElement,
XmlName,
XmlNode,
+ XmlProcessingInstructionNode,
XmlTextNode,
} from "./types.ts";
import { XmlSyntaxError } from "./types.ts";
@@ -88,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;
@@ -115,7 +119,10 @@ 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
+ const prolog: Array = [];
+ const epilog: Array = [];
// Namespace tracking (lazy initialization for performance)
// Only created when first namespace prefix is encountered
@@ -806,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
// ===========================================================================
@@ -951,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;
@@ -1001,6 +1022,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 +1131,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;
}
@@ -1194,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;
}
@@ -1466,6 +1511,9 @@ 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_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/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 cb4ebc747f86..7d01ced38b79 100644
--- a/xml/stringify.ts
+++ b/xml/stringify.ts
@@ -10,11 +10,14 @@
import type {
StringifyOptions,
XmlDeclaration,
+ XmlDoctype,
XmlDocument,
XmlElement,
XmlNode,
+ XmlProcessingInstructionNode,
} from "./types.ts";
import { encodeAttributeValue, encodeEntities } from "./_entities.ts";
+import { isReservedPiTarget } from "./_common.ts";
export type { StringifyOptions } from "./types.ts";
@@ -44,18 +47,26 @@ 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;
+ }
+ 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);
}
- result += serializeElement(node.root, indent, 0);
return result;
}
@@ -74,6 +85,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).
*/
@@ -156,6 +206,13 @@ function serializeNode(
const prefix = getIndent(depth);
return `${prefix}`;
}
+ case "processing_instruction": {
+ const prefix = getIndent(depth);
+ validateProcessingInstruction(node);
+ return node.content === ""
+ ? `${prefix}${node.target}?>`
+ : `${prefix}${node.target} ${node.content}?>`;
+ }
}
}
@@ -195,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 e1d6169d1c69..cf2745468226 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,306 @@ 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 '"'`,
+ );
+});
+
+// =============================================================================
+// 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
// =============================================================================
@@ -598,3 +899,42 @@ 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 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 =
+ `
`;
+
+ 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..676fcd08b7e4 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.
*/
@@ -154,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.
@@ -214,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.
*
@@ -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;
}
// ============================================================================
@@ -289,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.
*/
@@ -314,7 +365,8 @@ export type XmlNode =
| XmlElement
| XmlTextNode
| XmlCDataNode
- | XmlCommentNode;
+ | XmlCommentNode
+ | XmlProcessingInstructionNode;
/**
* A parsed XML document.
@@ -322,8 +374,33 @@ 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;
+ /**
+ * 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
+ >;
}
// ============================================================================
@@ -539,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
// =============================================================================