Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 54 additions & 6 deletions xml/_parse_sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,12 @@ import type {
XmlCDataNode,
XmlCommentNode,
XmlDeclaration,
XmlDoctype,
XmlDocument,
XmlElement,
XmlName,
XmlNode,
XmlProcessingInstructionNode,
XmlTextNode,
} from "./types.ts";
import { XmlSyntaxError } from "./types.ts";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<XmlProcessingInstructionNode | XmlCommentNode> = [];
const epilog: Array<XmlProcessingInstructionNode | XmlCommentNode> = [];

// Namespace tracking (lazy initialization for performance)
// Only created when first namespace prefix is encountered
Expand Down Expand Up @@ -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
// ===========================================================================
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 '<!DOCTYPE'
if (root) {
pos = doctypeStart;
error(
"Cannot have DOCTYPE declaration after the root element (XML 1.0 §2.8)",
);
}
if (doctype) {
pos = doctypeStart;
error("Cannot have multiple DOCTYPE declarations (XML 1.0 §2.8)");
}
pos += 7; // Skip 'DOCTYPE'

// Skip whitespace before name (required)
Expand All @@ -1020,10 +1052,13 @@ export function parseSync(xml: string, options?: ParseOptions): XmlDocument {
if (pos === nameStart) {
error("Missing name in DOCTYPE declaration");
}
const doctypeName = input.slice(nameStart, pos);

// Skip whitespace and handle PUBLIC/SYSTEM or internal subset
let expectPubidLiteral = false;
let sawExternalIdKeyword = false; // Track if we've seen PUBLIC or SYSTEM
let doctypePublicId: string | undefined;
let doctypeSystemId: string | undefined;
while (pos < len && input.charCodeAt(pos) !== CC_GT) {
const dc = input.charCodeAt(pos);

Expand All @@ -1050,13 +1085,14 @@ export function parseSync(xml: string, options?: ParseOptions): XmlDocument {
if (pos >= 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 (
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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 }),
};
}
134 changes: 134 additions & 0 deletions xml/_parse_sync_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("<!DOCTYPE root><root/>", { 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(`<!DOCTYPE html SYSTEM "about:legacy-compat"><html/>`, {
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(
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"><html/>`,
{ 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(`<!DOCTYPE root PUBLIC "pub-id"><root/>`, {
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(`<!DOCTYPE root SYSTEM 'sys.dtd'><root/>`, {
disallowDoctype: false,
});

assertEquals(doc.doctype?.systemId, "sys.dtd");
});

Deno.test("parseSync() exposes doctype identifiers when internal subset follows", () => {
const doc = parseSync(
`<!DOCTYPE root SYSTEM "sys.dtd" [<!ELEMENT root (#PCDATA)>]><root/>`,
{ 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(
`<!DOCTYPE root [<!ENTITY e "v">]><root/>`,
{ 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(
`<?xml version="1.0"?>\n<!DOCTYPE root><root/>`,
{ 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("<!DOCTYPE root><root/>", {
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("<root/>", { disallowDoctype: false });

assertEquals("doctype" in doc, false);
});

Deno.test("parseSync() rejects DOCTYPE after the root element", () => {
assertThrows(
() => parseSync("<root/><!DOCTYPE root>", { 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("<!DOCTYPE root><!DOCTYPE root><root/>", {
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("<!DOCTYPE foo><bar/>", { disallowDoctype: false });

assertEquals(doc.doctype?.name, "foo");
assertEquals(doc.root.name.local, "bar");
});

// =============================================================================
// Empty Text Node Handling
// =============================================================================
Expand Down
7 changes: 4 additions & 3 deletions xml/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,10 @@
*
* `<!DOCTYPE ...>` 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
*
Expand Down
80 changes: 77 additions & 3 deletions xml/parse_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("<?pi content?><root><?another pi?></root>");

// 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("<?pi content?><root><?another pi?></root><?trailing?>", {
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(`<root/><?xml-stylesheet href="style.css"?>`);

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("<!-- license --><root/><!-- trailer -->");

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("<root/>");

assertEquals("prolog" in doc, false);
assertEquals("epilog" in doc, false);
});

Deno.test("parse() excludes prolog comments when ignoreComments is true", () => {
const doc = parse("<!-- a --><?pi?><root/>", { ignoreComments: true });

assertEquals(doc.prolog, [
{ type: "processing_instruction", target: "pi", content: "" },
]);
});

Deno.test("parse() keeps prolog nodes in document order", () => {
const doc = parse("<?first?><!-- second --><?third x?><root/>");

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<?pi?>\n<root/>\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(`<!DOCTYPE root [<?dtd-pi content?>]><root/>`, {
disallowDoctype: false,
});

assertEquals(doc.prolog, undefined);
assertEquals(doc.root.children.length, 0);
});

Expand Down
Loading
Loading