From 9f874d46dcadf1c7639d9edb592401efa0a73960 Mon Sep 17 00:00:00 2001 From: Ivan Todoroski Date: Fri, 28 Aug 2026 17:10:53 -0700 Subject: [PATCH 1/2] chore: identify fragment types for easier debugging Added `type` properties to all fragments where the type could be unambiguously determined from surrounding code. --- src/printer.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/printer.js b/src/printer.js index 753ba61..769cdf8 100644 --- a/src/printer.js +++ b/src/printer.js @@ -111,6 +111,7 @@ function printContentFragments(path, print) { ...path.map(printIToken, "Comment"), ...path.map( ({ node }) => ({ + type: "chardata", offset: node.location.startOffset, printed: print() }), @@ -118,6 +119,7 @@ function printContentFragments(path, print) { ), ...path.map( ({ node }) => ({ + type: "element", offset: node.location.startOffset, printed: print() }), @@ -126,6 +128,7 @@ function printContentFragments(path, print) { ...path.map(printIToken, "PROCESSING_INSTRUCTION"), ...path.map( ({ node }) => ({ + type: "reference", offset: node.location.startOffset, printed: print() }), @@ -200,6 +203,7 @@ function printDocument(path, opts, print) { if (docTypeDecl) { fragments.push({ + type: "doctype", offset: docTypeDecl.location.startOffset, printed: print("docTypeDecl") }); @@ -207,6 +211,7 @@ function printDocument(path, opts, print) { if (prolog) { fragments.push({ + type: "prolog", offset: prolog.location.startOffset, printed: print("prolog") }); @@ -214,6 +219,7 @@ function printDocument(path, opts, print) { path.each(({ node }) => { fragments.push({ + type: "misc", offset: node.location.startOffset, printed: print() }); @@ -221,6 +227,7 @@ function printDocument(path, opts, print) { if (element) { fragments.push({ + type: "element", offset: element.location.startOffset, printed: print("element") }); @@ -258,6 +265,7 @@ function printCharDataPreserve(path, print) { prevFragment.printed = group([prevFragment.printed, content]); } else { response.push({ + type: "chardata", offset: location.startOffset, startLine: location.startLine, endLine: location.endLine, @@ -296,6 +304,7 @@ function printCharDataIgnore(path) { const location = chardata.location; response.push({ + type: "chardata", offset: location.startOffset, startLine: location.startLine, endLine: location.endLine, @@ -326,6 +335,7 @@ function printElementFragments(path, opts, print) { response = response.concat( path.map( ({ node: { location } }) => ({ + type: "element", offset: location.startOffset, startLine: location.startLine, endLine: location.endLine, From a5df9e59b3d99850b198b5206bf4d8d93f0f167b Mon Sep 17 00:00:00 2001 From: Ivan Todoroski Date: Fri, 28 Aug 2026 18:28:38 -0700 Subject: [PATCH 2/2] fix: replacing Unicode chars with NCRs unexpectedly changes formatting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When using `xmlWhitespaceSensitivity: "ignore"`, normal Unicode text inside XML elements gets formatted as you'd expect, but if one of the chars is replaced with a Numeric Character Reference (NCR) or an XML entity, the formatting changes in unexpected ways. For example, if you have a simple element with Unicode text:

piñata

it gets formatted like this (i.e. no change):

piñata

However, if you just replace the non-ASCII char with an NCR:

piñata

it suddenly gets reformatted like this:

pi ñ ata

Not only does it add an extra indent, it also breaks apart the piñata. An NCR is just an alternate encoding for the same char, so it shouldn't change how the entire word is formatted (unless of course it causes it to exceed the `printWidth`, which is not the case here). Things get worse if you include mixed content (text side-by-side with elements). If we add an extra element to the original Unicode text:

piñata

it gets formatted like this (pretty sensible):

piñata

However, if we replace the non-ASCII char with an NCR again:

piñata

it gets formatted like this instead:

pi ñ ata

The piñata is not only broken apart, but split across separate lines. Same behavior can be observed with XML entities too, not just NCRs. For example, if we replace the apostrophe in "Let's go" with an entity:

Let's go

it gets formatted like this, similarly broken up across separate lines:

Let ' s go

The root cause is in how the original code handled some special cases. If an XML element had only text, it would get printed on a single line, but if it had mixed content it would fall back to general code that just inserted hardlines between every token. This is what caused the word breaking apart across multiple lines. In case of references it would change the delimiter to a space instead, which explains the case where the word would be broken apart by spaces instead of split across multiple lines. From what I can infer, the intent of this code was to print XML elements on separate lines, but it looks like NCRs/entities were unintentionally caught by the logic intended for elements. I removed those special cases and (hopefully) simplified the code a bit. The mixed content loop now carefully inserts hardlines only before/after elements, while leaving the other tokens undisturbed. This accomplishes the goal of printing each XML element on a separate line, while allowing the surrounding text to flow freely around the elements. There is one special case remaining, to handle elements that only have a single child element and nothing else:


Without this special case, the element above would be printed unchanged, on one line. If I understood the intent of this formatter correctly, each XML element is supposed to start on a separate line, so the element above should be formatted like this:


This is what that special case does. I added some test cases based on the examples above, and confirmed that they got formatted properly before updating the snapshots. None of the existing test cases were affected by these code changes. --- src/printer.js | 45 +-- test/__snapshots__/format.test.js.snap | 515 +++++++++++++++++++++++++ test/fixture.xml | 25 ++ 3 files changed, 554 insertions(+), 31 deletions(-) diff --git a/src/printer.js b/src/printer.js index 769cdf8..d74f60e 100644 --- a/src/printer.js +++ b/src/printer.js @@ -483,49 +483,32 @@ function printElement(path, opts, print) { return group([...parts, space, "/>"]); } - // If the only content of this tag is chardata, then use a softline so - // that we won't necessarily break (to allow bar). - if ( - fragments.length === 1 && - content.chardata.filter((chardata) => chardata.TEXT).length === 1 - ) { - return group([ - openTag, - indent([softline, fragments[0].printed]), - softline, - closeTag - ]); - } - - let delimiter = hardline; + const docs = []; + let lastNode; - // If the only content is both chardata and references, then use a softline - // so that we won't necessarily break. - if ( - fragments.length === - content.chardata.filter((chardata) => chardata.TEXT).length + - content.reference.length - ) { - delimiter = " "; + // if we have a single element as child, force it to a separate line + if (fragments.length === 1 && content.element.length === 1) { + docs.push(hardline); + } else { + docs.push(softline); } - const docs = [hardline]; - let lastLine = fragments[0].startLine; - + // insert hardlines before/after each element, so they go on separate lines from + // the surrounding text, but leave the mixed content between the elements alone fragments.forEach((node, index) => { if (index !== 0) { - if (node.startLine - lastLine >= 2) { + if (node.startLine - lastNode.endLine >= 2) { docs.push(hardline, hardline); - } else { - docs.push(delimiter); + } else if (node.type === "element" || lastNode.type === "element") { + docs.push(hardline); } } docs.push(node.printed); - lastLine = node.endLine; + lastNode = node; }); - return group([openTag, indent(docs), hardline, closeTag]); + return group([openTag, indent(docs), softline, closeTag]); } return group([openTag, indent(print("content")), closeTag]); diff --git a/test/__snapshots__/format.test.js.snap b/test/__snapshots__/format.test.js.snap index 5a89e2a..73f9cdb 100644 --- a/test/__snapshots__/format.test.js.snap +++ b/test/__snapshots__/format.test.js.snap @@ -126,6 +126,79 @@ use { slide + + +

piñata

+

piñata

+ +

+ piñata +
+

+

+ piñata +
+

+ +

+
+ piñata +

+

+
+ piñata +

+ +

+
+
+ piñata +
+
+

+

+
+
+ piñata +
+
+

+ +

Let's go

+

Let's go

+ +

+ Let's go +
+

+

+ Let's go +
+

+ +

+
+ Let's go +

+

+
+ Let's go +

+ +

+
+
+ Let's go +
+
+

+

+
+
+ Let's go +
+
+

" @@ -257,6 +330,79 @@ use { slide + + +

piñata

+

piñata

+ +

+ piñata +
+

+

+ piñata +
+

+ +

+
+ piñata +

+

+
+ piñata +

+ +

+
+
+ piñata +
+
+

+

+
+
+ piñata +
+
+

+ +

Let's go

+

Let's go

+ +

+ Let's go +
+

+

+ Let's go +
+

+ +

+
+ Let's go +

+

+
+ Let's go +

+ +

+
+
+ Let's go +
+
+

+

+
+
+ Let's go +
+
+

" @@ -384,6 +530,31 @@ use { slide + + +

piñata

+

piñata

+ +

piñata

+

piñata

+ +


piñata

+


piñata

+ +



piñata

+



piñata

+ +

Let's go

+

Let's go

+ +

Let's go

+

Let's go

+ +


Let's go

+


Let's go

+ +



Let's go

+



Let's go

" @@ -548,6 +719,79 @@ use { slide + + +

piñata

+

piñata

+ +

+ piñata +
+

+

+ piñata +
+

+ +

+
+ piñata +

+

+
+ piñata +

+ +

+
+
+ piñata +
+
+

+

+
+
+ piñata +
+
+

+ +

Let's go

+

Let's go

+ +

+ Let's go +
+

+

+ Let's go +
+

+ +

+
+ Let's go +

+

+
+ Let's go +

+ +

+
+
+ Let's go +
+
+

+

+
+
+ Let's go +
+
+

" @@ -675,6 +919,31 @@ use { slide + + +

piñata

+

piñata

+ +

piñata

+

piñata

+ +


piñata

+


piñata

+ +



piñata

+



piñata

+ +

Let's go

+

Let's go

+ +

Let's go

+

Let's go

+ +


Let's go

+


Let's go

+ +



Let's go

+



Let's go

" @@ -802,6 +1071,31 @@ use { slide + + +

piñata

+

piñata

+ +

piñata

+

piñata

+ +


piñata

+


piñata

+ +



piñata

+



piñata

+ +

Let's go

+

Let's go

+ +

Let's go

+

Let's go

+ +


Let's go

+


Let's go

+ +



Let's go

+



Let's go

" @@ -929,6 +1223,31 @@ use { slide + + +

piñata

+

piñata

+ +

piñata

+

piñata

+ +


piñata

+


piñata

+ +



piñata

+



piñata

+ +

Let's go

+

Let's go

+ +

Let's go

+

Let's go

+ +


Let's go

+


Let's go

+ +



Let's go

+



Let's go

" @@ -1065,6 +1384,79 @@ use { slide + + +

piñata

+

piñata

+ +

+ piñata +
+

+

+ piñata +
+

+ +

+
+ piñata +

+

+
+ piñata +

+ +

+
+
+ piñata +
+
+

+

+
+
+ piñata +
+
+

+ +

Let's go

+

Let's go

+ +

+ Let's go +
+

+

+ Let's go +
+

+ +

+
+ Let's go +

+

+
+ Let's go +

+ +

+
+
+ Let's go +
+
+

+

+
+
+ Let's go +
+
+

" @@ -1192,6 +1584,31 @@ use { slide + + +

piñata

+

piñata

+ +

piñata

+

piñata

+ +


piñata

+


piñata

+ +



piñata

+



piñata

+ +

Let's go

+

Let's go

+ +

Let's go

+

Let's go

+ +


Let's go

+


Let's go

+ +



Let's go

+



Let's go

" @@ -1328,6 +1745,79 @@ use { slide + + +

piñata

+

piñata

+ +

+ piñata +
+

+

+ piñata +
+

+ +

+
+ piñata +

+

+
+ piñata +

+ +

+
+
+ piñata +
+
+

+

+
+
+ piñata +
+
+

+ +

Let's go

+

Let's go

+ +

+ Let's go +
+

+

+ Let's go +
+

+ +

+
+ Let's go +

+

+
+ Let's go +

+ +

+
+
+ Let's go +
+
+

+

+
+
+ Let's go +
+
+

" @@ -1457,6 +1947,31 @@ use { slide + + +

piñata

+

piñata

+ +

piñata

+

piñata

+ +


piñata

+


piñata

+ +



piñata

+



piñata

+ +

Let's go

+

Let's go

+ +

Let's go

+

Let's go

+ +


Let's go

+


Let's go

+ +



Let's go

+



Let's go

" diff --git a/test/fixture.xml b/test/fixture.xml index d535eb8..e3e6e4e 100644 --- a/test/fixture.xml +++ b/test/fixture.xml @@ -106,5 +106,30 @@ slide + + +

piñata

+

piñata

+ +

piñata

+

piñata

+ +


piñata

+


piñata

+ +



piñata

+



piñata

+ +

Let's go

+

Let's go

+ +

Let's go

+

Let's go

+ +


Let's go

+


Let's go

+ +



Let's go

+



Let's go