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}` + : `${prefix}`; + } } } @@ -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 // =============================================================================