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
105 changes: 103 additions & 2 deletions src/core/operations/XMLBeautify.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -39,7 +140,7 @@ class XMLBeautify extends Operation {
*/
run(input, args) {
const indentStr = args[0];
return vkbeautify.xml(input, indentStr);
return xmlBeautify(input, indentStr);
}

}
Expand Down
59 changes: 59 additions & 0 deletions tests/operations/tests/XMLBeautify.mjs
Original file line number Diff line number Diff line change
@@ -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: [" "] }],
},
]);