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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) a

## [Unreleased]

### Changed

- Format files whose `<!DOCTYPE>` has an internal subset (`[ ... ]`) instead of failing to parse; the subset is printed as written.

## [3.4.2] - 2025-07-08

### Changed
Expand Down
71 changes: 69 additions & 2 deletions src/parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,56 @@ function createError(message, options) {
return Object.assign(error, options);
}

// The @xml-tools/parser grammar has no rule for the internal subset of a
// DOCTYPE (`<!DOCTYPE x [ ... ]>`). Find its `[ ... ]` range so it can be
// blanked out before parsing and printed back verbatim.
function findInternalSubset(text) {
let idx = 0;

// Skip whitespace, processing instructions and comments before the DOCTYPE.
for (;;) {
while (/\s/.test(text[idx] ?? "")) idx += 1;

const close = text.startsWith("<?", idx)
? "?>"
: text.startsWith("<!--", idx)
? "-->"
: null;
const end = close ? text.indexOf(close, idx) : -1;

if (end === -1) break;
idx = end + close.length;
}

if (!text.startsWith("<!DOCTYPE", idx)) {
return null;
}

let start = -1;
let quote = null;

for (idx += "<!DOCTYPE".length; idx < text.length; idx += 1) {
const char = text[idx];

if (quote) {
if (char === quote) quote = null;
} else if (char === '"' || char === "'") {
quote = char;
} else if (start === -1) {
if (char === "[") start = idx;
else if (char === ">") return null;
} else if (text.startsWith("<!--", idx)) {
const commentEnd = text.indexOf("-->", idx);
if (commentEnd === -1) return null;
idx = commentEnd + 2;
} else if (char === "]") {
return { start, end: idx };
}
}

return null;
}

function simplifyCST(node) {
switch (node.name) {
case "attribute": {
Expand Down Expand Up @@ -68,6 +118,7 @@ function simplifyCST(node) {
DocType: DocType[0].image,
Name: Name[0].image,
externalID: externalID ? simplifyCST(externalID[0]) : null,
internalSubset: null,
CLOSE: CLOSE[0].image,
location: node.location
};
Expand Down Expand Up @@ -166,7 +217,14 @@ function simplifyCST(node) {

const parser = {
parse(text) {
const { lexErrors, parseErrors, cst } = xmlToolsParse(text);
const subset = findInternalSubset(text);
const { lexErrors, parseErrors, cst } = xmlToolsParse(
subset
? text.slice(0, subset.start) +
text.slice(subset.start, subset.end + 1).replace(/[^\r\n]/g, " ") +
text.slice(subset.end + 1)
: text
);

// If there are any lexical errors, throw the first of them as an error.
if (lexErrors.length > 0) {
Expand Down Expand Up @@ -200,7 +258,16 @@ const parser = {
}

// Otherwise return the CST.
return simplifyCST(cst);
const document = simplifyCST(cst);

if (subset) {
document.docTypeDecl.internalSubset = text.slice(
subset.start + 1,
subset.end
);
}

return document;
},
astFormat: "xml",
locStart(node) {
Expand Down
6 changes: 5 additions & 1 deletion src/printer.js
Original file line number Diff line number Diff line change
Expand Up @@ -184,13 +184,17 @@ function printContent(path, opts, print) {
}

function printDocTypeDecl(path, opts, print) {
const { DocType, Name, externalID, CLOSE } = path.node;
const { DocType, Name, externalID, internalSubset, CLOSE } = path.node;
const parts = [DocType, " ", Name];

if (externalID) {
parts.push(" ", print("externalID"));
}

if (internalSubset !== null) {
parts.push(" [", doc.utils.replaceEndOfLine(internalSubset), "]");
}

return group([...parts, CLOSE]);
}

Expand Down
10 changes: 10 additions & 0 deletions test/__snapshots__/format.test.js.snap
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing

exports[`DOCTYPE with an internal subset 1`] = `
"<?xml version="1.0" ?>
<!DOCTYPE x SYSTEM "x.dtd" [
<!ENTITY a "]>">
<!-- ] -->
]>
<x>&a;</x>
"
`;

exports[`bracketSameLine => true 1`] = `
"<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE module PUBLIC "-//Puppy Crawl//DTD Check Configuration 1.3//EN"
Expand Down
14 changes: 14 additions & 0 deletions test/format.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -104,3 +104,17 @@ test("xmlQuoteAttributes => double", async () => {

expect(formatted).toMatchSnapshot();
});

test("DOCTYPE with an internal subset", async () => {
const content = `<?xml version="1.0"?>
<!DOCTYPE x SYSTEM "x.dtd" [
<!ENTITY a "]>">
<!-- ] -->
]>
<x>&a;</x>
`;

const formatted = await format(content);
expect(formatted).toMatchSnapshot();
expect(await format(formatted)).toBe(formatted);
});
8 changes: 8 additions & 0 deletions test/parser.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,11 @@ test("parseError", () => {

expect(() => parser.parse("<foo /")).toThrow(expected);
});

test("DOCTYPE internal subset", () => {
const { docTypeDecl } = parser.parse(
'<!DOCTYPE x [<!ENTITY a "]">]>\n<x>&a;</x>'
);

expect(docTypeDecl.internalSubset).toBe('<!ENTITY a "]">');
});