Fix XML Beautify indentation after xmlns self-closing element

vkbeautify splits `<b xmlns="foo" />` into two fragments during
preprocessing. The `<b` fragment incorrectly incremented the indent
depth, but the corresponding decrement never fired because `/>` was
on the xmlns fragment, not a standalone self-closing token. Subsequent
siblings were therefore indented one level too deep.

Fix by moving the xmlns branch before the generic `/>` branch in the
if-else chain and decrementing depth when the xmlns fragment ends
with `/>`. The vkbeautify xml and createShiftArr functions are inlined
into XMLBeautify.mjs to avoid patching node_modules directly.

Fixes #2501

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Allan Leary 2026-06-27 09:18:56 +01:00
parent 275b594f7c
commit f533329f77
2 changed files with 162 additions and 2 deletions

View File

@ -4,9 +4,110 @@
* @license Apache-2.0
*/
import vkbeautify from "vkbeautify";
import Operation from "../Operation.mjs";
/**
* Adapted from vkBeautify (c) 2012 Vadim Kiryukhin, MIT/GPL dual licence.
*/
function createShiftArr(step) {
let space = " ";
if (isNaN(parseInt(step, 10))) {
space = step;
} else {
switch (step) {
case 1: space = " "; break;
case 2: space = " "; break;
case 3: space = " "; break;
case 4: space = " "; break;
case 5: space = " "; break;
case 6: space = " "; break;
case 7: space = " "; break;
case 8: space = " "; break;
case 9: space = " "; break;
case 10: space = " "; break;
case 11: space = " "; break;
case 12: space = " "; break;
}
}
const shift = ["\n"];
for (let ix = 0; ix < 100; ix++) {
shift.push(shift[ix] + space);
}
return shift;
}
/**
* Adapted from vkBeautify (c) 2012 Vadim Kiryukhin, MIT/GPL dual licence.
*
* Fix for issue #2501: the xmlns check is moved before the generic self-close
* check so that a fragment like `xmlns="foo" />` is caught here rather than
* by the `/>` branch. When such a fragment ends with `/>` the element is
* self-closing, so we decrement deep to undo the increment that fired when
* the preceding `<tagName` fragment was classified as an opening element.
*/
function xmlBeautify(text, step) {
const ar = text
.replace(/>\s{0,}</g, "><")
.replace(/</g, "~::~<")
.replace(/\s*xmlns:/g, "~::~xmlns:")
.replace(/\s*xmlns=/g, "~::~xmlns=")
.split("~::~");
const len = ar.length;
const shift = createShiftArr(step);
let inComment = false;
let deep = 0;
let str = "";
for (let ix = 0; ix < len; ix++) {
// start comment or <![CDATA[...]]> or <!DOCTYPE
if (ar[ix].search(/<!/) > -1) {
str += shift[deep] + ar[ix];
inComment = true;
if (ar[ix].search(/-->/) > -1 || ar[ix].search(/\]>/) > -1 || ar[ix].search(/!DOCTYPE/) > -1) {
inComment = false;
}
// end comment or <![CDATA[...]]>
} else if (ar[ix].search(/-->/) > -1 || ar[ix].search(/\]>/) > -1) {
str += ar[ix];
inComment = false;
// <elm></elm>
} else if (
/^<\w/.exec(ar[ix - 1]) && /^<\/\w/.exec(ar[ix]) &&
/^<[\w:\-.,]+/.exec(ar[ix - 1])?.[0] === /^<\/[\w:\-.,]+/.exec(ar[ix])?.[0].replace("/", "")
) {
str += ar[ix];
if (!inComment) deep--;
// <elm>
} else if (ar[ix].search(/<\w/) > -1 && ar[ix].search(/<\//) === -1 && ar[ix].search(/\/>/) === -1) {
str = !inComment ? str + shift[deep++] + ar[ix] : str + ar[ix];
// <elm>...</elm>
} else if (ar[ix].search(/<\w/) > -1 && ar[ix].search(/<\//) > -1) {
str = !inComment ? str + shift[deep] + ar[ix] : str + ar[ix];
// </elm>
} else if (ar[ix].search(/<\//) > -1) {
str = !inComment ? str + shift[--deep] + ar[ix] : str + ar[ix];
// <? xml ... ?>
} else if (ar[ix].search(/<\?/) > -1) {
str += shift[deep] + ar[ix];
// xmlns: or xmlns= — must be checked before the generic /> branch so that a self-closing
// element with a namespace attribute (e.g. <b xmlns="foo" />) is handled here.
} else if (ar[ix].search(/xmlns:/) > -1 || ar[ix].search(/xmlns=/) > -1) {
str = !inComment ? str + shift[deep] + ar[ix] : str + ar[ix];
if (ar[ix].search(/\/>/) > -1) deep--;
// <elm/>
} else if (ar[ix].search(/\/>/) > -1) {
str = !inComment ? str + shift[deep] + ar[ix] : str + ar[ix];
} else {
str += ar[ix];
}
}
return str[0] === "\n" ? str.slice(1) : str;
}
/**
* XML Beautify operation
*/
@ -39,7 +140,7 @@ class XMLBeautify extends Operation {
*/
run(input, args) {
const indentStr = args[0];
return vkbeautify.xml(input, indentStr);
return xmlBeautify(input, indentStr);
}
}

View File

@ -0,0 +1,59 @@
/**
* XML Beautify tests.
*
* @author Allan Leary
* @copyright Crown Copyright 2026
* @license Apache-2.0
*/
import TestRegister from "../../lib/TestRegister.mjs";
TestRegister.addTests([
{
name: "XML Beautify: basic nested elements",
input: "<root><parent><child/></parent></root>",
expectedOutput: "<root>\n\t<parent>\n\t\t<child/>\n\t</parent>\n</root>",
recipeConfig: [{ op: "XML Beautify", args: ["\t"] }],
},
{
name: "XML Beautify: self-closing element without xmlns",
input: "<a><b/></a>",
expectedOutput: "<a>\n\t<b/>\n</a>",
recipeConfig: [{ op: "XML Beautify", args: ["\t"] }],
},
{
name: "XML Beautify: self-closing element with xmlns (issue #2501)",
input: "<a><b xmlns=\"foo\" /></a>",
expectedOutput: "<a>\n\t<b\n\t\txmlns=\"foo\" />\n</a>",
recipeConfig: [{ op: "XML Beautify", args: ["\t"] }],
},
{
name: "XML Beautify: self-closing element with multiple xmlns attributes (issue #2501)",
input: "<a><b xmlns:foo=\"a\" xmlns:bar=\"b\" /></a>",
expectedOutput: "<a>\n\t<b\n\t\txmlns:foo=\"a\"\n\t\txmlns:bar=\"b\" />\n</a>",
recipeConfig: [{ op: "XML Beautify", args: ["\t"] }],
},
{
name: "XML Beautify: xmlns on opening (non-self-closing) tag",
input: "<a><b xmlns=\"foo\">text</b></a>",
expectedOutput: "<a>\n\t<b\n\t\txmlns=\"foo\">text\n\t</b>\n</a>",
recipeConfig: [{ op: "XML Beautify", args: ["\t"] }],
},
{
name: "XML Beautify: siblings after xmlns self-closing are correctly indented",
input: "<root><a xmlns=\"foo\" /><b/><c/></root>",
expectedOutput: "<root>\n\t<a\n\t\txmlns=\"foo\" />\n\t<b/>\n\t<c/>\n</root>",
recipeConfig: [{ op: "XML Beautify", args: ["\t"] }],
},
{
name: "XML Beautify: xml declaration preserved",
input: "<?xml version=\"1.0\"?><root><child/></root>",
expectedOutput: "<?xml version=\"1.0\"?>\n<root>\n\t<child/>\n</root>",
recipeConfig: [{ op: "XML Beautify", args: ["\t"] }],
},
{
name: "XML Beautify: 4-space indent",
input: "<root><child/></root>",
expectedOutput: "<root>\n <child/>\n</root>",
recipeConfig: [{ op: "XML Beautify", args: [" "] }],
},
]);