diff --git a/src/core/operations/XMLBeautify.mjs b/src/core/operations/XMLBeautify.mjs
index 2cabb9b2..db2c6b64 100644
--- a/src/core/operations/XMLBeautify.mjs
+++ b/src/core/operations/XMLBeautify.mjs
@@ -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 `\s{0,}<")
+ .replace(/ or -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
+ } else if (ar[ix].search(/-->/) > -1 || ar[ix].search(/\]>/) > -1) {
+ str += ar[ix];
+ inComment = false;
+ //
+ } 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--;
+ //
+ } 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];
+ // ...
+ } else if (ar[ix].search(/<\w/) > -1 && ar[ix].search(/<\//) > -1) {
+ str = !inComment ? str + shift[deep] + ar[ix] : str + ar[ix];
+ //
+ } 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. ) 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--;
+ //
+ } 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);
}
}
diff --git a/tests/operations/tests/XMLBeautify.mjs b/tests/operations/tests/XMLBeautify.mjs
new file mode 100644
index 00000000..31624ba4
--- /dev/null
+++ b/tests/operations/tests/XMLBeautify.mjs
@@ -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: "",
+ expectedOutput: "\n\t\n\t\t\n\t\n",
+ recipeConfig: [{ op: "XML Beautify", args: ["\t"] }],
+ },
+ {
+ name: "XML Beautify: self-closing element without xmlns",
+ input: "",
+ expectedOutput: "\n\t\n",
+ recipeConfig: [{ op: "XML Beautify", args: ["\t"] }],
+ },
+ {
+ name: "XML Beautify: self-closing element with xmlns (issue #2501)",
+ input: "",
+ expectedOutput: "\n\t\n",
+ recipeConfig: [{ op: "XML Beautify", args: ["\t"] }],
+ },
+ {
+ name: "XML Beautify: self-closing element with multiple xmlns attributes (issue #2501)",
+ input: "",
+ expectedOutput: "\n\t\n",
+ recipeConfig: [{ op: "XML Beautify", args: ["\t"] }],
+ },
+ {
+ name: "XML Beautify: xmlns on opening (non-self-closing) tag",
+ input: "text",
+ expectedOutput: "\n\ttext\n\t\n",
+ recipeConfig: [{ op: "XML Beautify", args: ["\t"] }],
+ },
+ {
+ name: "XML Beautify: siblings after xmlns self-closing are correctly indented",
+ input: "",
+ expectedOutput: "\n\t\n\t\n\t\n",
+ recipeConfig: [{ op: "XML Beautify", args: ["\t"] }],
+ },
+ {
+ name: "XML Beautify: xml declaration preserved",
+ input: "",
+ expectedOutput: "\n\n\t\n",
+ recipeConfig: [{ op: "XML Beautify", args: ["\t"] }],
+ },
+ {
+ name: "XML Beautify: 4-space indent",
+ input: "",
+ expectedOutput: "\n \n",
+ recipeConfig: [{ op: "XML Beautify", args: [" "] }],
+ },
+]);