From f6c846a1f16d1f6d0045c92a1c0f24429e6b901f Mon Sep 17 00:00:00 2001
From: Eugene Blikh ... `. `ensureSingleNewline()` writes `\n` only when the buffer doesn't already end in one, preventing the double newline that appeared when a ` …): a lone-space text node was always treated as
indentation. Keep it mid-line; drop it only at a block boundary.
- fmt's line-wrapper dropped a word's trailing space before a tag
(strings.Fields discards it) and broke inside ...,
injecting indent that xml2md collapses into a spurious in-span space.
Rewrote wrapLine around an atom model with a per-atom spaceBefore flag
and keeps an inline code span whole.
- verify.normalizeForVerify's reBareTextAfterHeading used \S for its
"visible char" token; \S matches '<', so the capture swallowed a
following `, 2 for `
` / task-list); `itemIndent()` sums all *ancestor* contributions. Using a fixed `2*(depth-1)` collapses nested ordered lists into siblings. *Tests:* `TestRoundTrip_NestedOrderedList`, `TestRoundTrip_NestedMixedList`, `TestRoundTrip_DoubleNestedUnorderedList`.
- **Tight lists are one stray `\n` away from loose** — a blank line anywhere in a list turns it loose, which makes goldmark wrap every `
…` whole** — breaking a line inside an inline code span injects the continuation indent into the span's text, which `xml2md` collapses into a spurious space *inside* the span (`` ` app.manifest.lock` ``). `format.splitAtoms` emits an entire `…` as one unbreakable atom. Significant whitespace between adjacent inline elements (` `) is carried on each atom's `spaceBefore` flag — never via `strings.Fields`, which drops trailing space. *Tests:* `TestPrettyXML_WrapKeepsCodeSpanWhole`, `TestPrettyXML_WrapPreservesSpaceBeforeCodeAfterWord`, `TestPrettyXML_SpaceBetweenInlineElements`.
+
### Verifying real documents
`mdcx verify ` formats the input, round-trips it through `xml2md → md2xml`, formats the output, and diffs them. The justfile in `~/data/1` runs this against five real Confluence pages — exercise it after any change to converter or renderer code, since the unit tests can miss multi-feature interactions (e.g. a task list inside a `` inside a table cell).
diff --git a/cmd/mdcx/verify.go b/cmd/mdcx/verify.go
index 075f3d1baf06c3ec48e30fc56329545842b6140a..5965513e5d172a1faa2b7eab2d31de8b1af01401 100644
--- a/cmd/mdcx/verify.go
+++ b/cmd/mdcx/verify.go
@@ -124,7 +124,12 @@ var (
// reBareTextAfterHeading wraps bare text appearing directly between a
// heading close and a following block tag in ...
. Confluence accepts
// both forms but markdown round-trip always paragraphs it.
- reBareTextAfterHeading = regexp.MustCompile(`(?s)()([^<]*?\S[^<]*?)(\s*<(?:table|ul|ol|h[1-6]|ac:|p|hr))`)
+ //
+ // The "at least one visible char" token is [^\s<], not \S: \S also matches
+ // '<', so it would let the group swallow a following tag's opening bracket
+ // (e.g. ) and wrap a structural element in ...
—
+ // which splits a code macro open from its body and destroys round-trip.
+ reBareTextAfterHeading = regexp.MustCompile(`(?s)()([^<]*?[^\s<][^<]*?)(\s*<(?:table|ul|ol|h[1-6]|ac:|p|hr))`)
)
// normalizeForVerify strips XML patterns that cannot survive a round-trip
diff --git a/cmd/mdcx/verify_test.go b/cmd/mdcx/verify_test.go
index 6b5fca38ee510aef698420f8cb381085dce8173f..0aa9ecc18368af621af082ffdb46bab8c9aac0c4 100644
--- a/cmd/mdcx/verify_test.go
+++ b/cmd/mdcx/verify_test.go
@@ -6,6 +6,9 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
+
+ "go.bigb.es/confluence-md-utilities/converter"
+ "go.bigb.es/confluence-md-utilities/format"
)
// --- normalizeForVerify ---
@@ -117,6 +120,57 @@ func TestNormalizeForVerify_AdjacentCodeMerged(t *testing.T) {
}
}
+func TestNormalizeForVerify_BareTextAfterHeading(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ want string
+ }{
+ {
+ name: "genuine bare text is wrapped",
+ input: `Title
bare textx
`,
+ want: `Title
bare text
x
`,
+ },
+ {
+ // Regression: \S used to match the '<' of the following tag, so the
+ // capture swallowed and wrapped it in ...
,
+ // splitting a code macro from its body and breaking round-trip.
+ name: "structured-macro after heading is left intact",
+ input: "Heading
\n ",
+ want: "Heading
\n ",
+ },
+ {
+ name: "paragraph after heading is left intact",
+ input: "T
\nalready a paragraph
",
+ want: "T
\nalready a paragraph
",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ assert.Equal(t, tt.want, normalizeForVerify(tt.input))
+ })
+ }
+}
+
+// TestVerifyRoundTrip_CodeBlockAfterHeading is the end-to-end regression for the
+// no-language fenced code block immediately after a heading: it must survive
+// xml2md → md2xml with its body intact.
+func TestVerifyRoundTrip_CodeBlockAfterHeading(t *testing.T) {
+ xml := "Heading
\n" +
+ `` +
+ ` ` +
+ ` ` + "\nAfter.
"
+ norm := normalizeForVerify(xml)
+ md, err := converter.ConfluenceToMarkdown(norm)
+ require.NoError(t, err)
+ rt, err := converter.MarkdownToConfluence([]byte(md))
+ require.NoError(t, err)
+ assert.Equal(t,
+ format.PrettyXML(norm, " "),
+ format.PrettyXML(normalizeForVerify(rt), " "),
+ "code-block body must survive the round-trip")
+}
+
// --- computeDiffOps ---
func TestComputeDiffOps_IdenticalInputs(t *testing.T) {
diff --git a/format/pretty.go b/format/pretty.go
index e0e7a031b5ab79549a22298bb697a4df09331a49..21decd941073495dc35121212c623e46d822d1f5 100644
--- a/format/pretty.go
+++ b/format/pretty.go
@@ -153,10 +153,13 @@ func PrettyXML(input string, indent string) string {
continue
}
text := collapseWS(tok.raw)
- if text == "" || text == " " {
+ if text == "" {
i++
continue
}
+ // A lone space is an indentation artifact only at a block boundary
+ // (line start); mid-line it sits between two inline elements
+ // (e.g. ) and is a significant space — keep it.
if atLineStart {
text = strings.TrimLeft(text, " ")
if text == "" {
@@ -273,63 +276,38 @@ func wrapLine(line string, maxWidth int) []string {
indentStr := line[:len(line)-len(trimmed)]
contIndent := indentStr + " " // continuation lines get extra indent
- // Split into segments: tags (unsplittable) and text (splittable at spaces)
- segments := splitSegments(trimmed)
+ // Split into atoms: tags and words, never broken internally. Whitespace
+ // between atoms is collapsed to a single significant space recorded in
+ // spaceBefore — including space between a word and an adjacent tag
+ // (e.g. " "), which must survive wrapping.
+ atoms := splitAtoms(trimmed)
var lines []string
var cur strings.Builder
cur.WriteString(indentStr)
curWidth := runeWidth(indentStr)
- for _, seg := range segments {
- segW := runeWidth(seg)
-
- if seg == "" {
- continue
+ for _, a := range atoms {
+ atomW := runeWidth(a.text)
+ spaceW := 0
+ if a.spaceBefore {
+ spaceW = 1
}
- // Tags and non-space text: never break inside
- if strings.HasPrefix(seg, "<") {
- // If adding this tag exceeds limit and we have content, wrap
- if curWidth+segW > maxWidth && curWidth > runeWidth(indentStr) {
- lines = append(lines, strings.TrimRight(cur.String(), " "))
- cur.Reset()
- cur.WriteString(contIndent)
- curWidth = runeWidth(contIndent)
- }
- cur.WriteString(seg)
- curWidth += segW
- continue
+ // Wrap before this atom if it would overflow and the current line
+ // already holds content beyond its indentation.
+ if curWidth+spaceW+atomW > maxWidth && curWidth > runeWidth(contIndent) {
+ lines = append(lines, strings.TrimRight(cur.String(), " "))
+ cur.Reset()
+ cur.WriteString(contIndent)
+ curWidth = runeWidth(contIndent)
+ } else if a.spaceBefore && curWidth > runeWidth(indentStr) {
+ cur.WriteByte(' ')
+ curWidth++
}
- // Text segment: split at word boundaries
- words := strings.Fields(seg)
- // Preserve leading space if original had one
- needSpace := len(seg) > 0 && seg[0] == ' '
-
- for _, word := range words {
- wordW := runeWidth(word)
- spaceW := 0
- if needSpace {
- spaceW = 1
- }
-
- if curWidth+spaceW+wordW > maxWidth && curWidth > runeWidth(contIndent) {
- lines = append(lines, strings.TrimRight(cur.String(), " "))
- cur.Reset()
- cur.WriteString(contIndent)
- curWidth = runeWidth(contIndent)
- needSpace = false
- }
-
- if needSpace {
- cur.WriteByte(' ')
- curWidth++
- }
- cur.WriteString(word)
- curWidth += wordW
- needSpace = true
- }
+ cur.WriteString(a.text)
+ curWidth += atomW
}
if cur.Len() > 0 {
@@ -345,28 +323,59 @@ func wrapLine(line string, maxWidth int) []string {
return lines
}
-// splitSegments breaks text into alternating tag and text segments.
-// E.g. "Hello world end" -> ["Hello ", "", "world", "", " end"]
-func splitSegments(s string) []string {
- var segs []string
+// atom is an unbreakable unit of a line — a tag (<...>) or a word — together
+// with whether whitespace separated it from the previous atom.
+type atom struct {
+ text string
+ spaceBefore bool
+}
+
+// splitAtoms breaks a line into tags and words, recording the inter-atom
+// whitespace as a single space flag. E.g. "Hello world end"
+// -> {Hello}, { }, {world}, {}, { end}.
+func splitAtoms(s string) []atom {
+ var atoms []atom
+ pendingSpace := false
for len(s) > 0 {
- lt := strings.Index(s, "<")
- if lt == -1 {
- segs = append(segs, s)
- break
- }
- if lt > 0 {
- segs = append(segs, s[:lt])
- }
- gt := strings.Index(s[lt:], ">")
- if gt == -1 {
- segs = append(segs, s[lt:])
- break
+ r := s[0]
+ switch {
+ case r == ' ' || r == '\t' || r == '\n' || r == '\r':
+ pendingSpace = true
+ s = s[1:]
+ case r == '<':
+ end := strings.Index(s, ">")
+ if end == -1 {
+ atoms = append(atoms, atom{s, pendingSpace})
+ return atoms
+ }
+ // Keep an inline code span whole. A line break inside
+ // ... would inject the continuation indent into the
+ // code's text content, which xml2md then collapses into a spurious
+ // space inside the span (e.g. "` app.manifest.lock`").
+ if strings.HasPrefix(s, "") || strings.HasPrefix(s, ""); c != -1 {
+ spanEnd := c + len("")
+ atoms = append(atoms, atom{s[:spanEnd], pendingSpace})
+ pendingSpace = false
+ s = s[spanEnd:]
+ continue
+ }
+ }
+ atoms = append(atoms, atom{s[:end+1], pendingSpace})
+ pendingSpace = false
+ s = s[end+1:]
+ default:
+ // A word runs until the next space or tag start.
+ i := strings.IndexAny(s, " \t\n\r<")
+ if i == -1 {
+ i = len(s)
+ }
+ atoms = append(atoms, atom{s[:i], pendingSpace})
+ pendingSpace = false
+ s = s[i:]
}
- segs = append(segs, s[lt:lt+gt+1])
- s = s[lt+gt+1:]
}
- return segs
+ return atoms
}
func runeWidth(s string) int {
diff --git a/format/pretty_test.go b/format/pretty_test.go
index cbfa77d81bf9570ddbc24e8c22246fab68b09d4e..bae201baecd46178694f4e1bacf028044df72fa6 100644
--- a/format/pretty_test.go
+++ b/format/pretty_test.go
@@ -161,3 +161,64 @@ func TestPrettyXML_LongLinePreservesTagIntegrity(t *testing.T) {
assert.NotContains(t, result, ""). Regression for the
+// "missing space before inline code" report.
+func TestPrettyXML_SpaceBetweenInlineElements(t *testing.T) {
+ cases := []struct{ input, want string }{
+ {`Bold word code here.
`, " "},
+ {`Link t code here.
`, " "},
+ {`Word em code.
`, " "},
+ }
+ for _, tc := range cases {
+ result := PrettyXML(tc.input, " ")
+ assert.Contains(t, result, tc.want, tc.input)
+ }
+}
+
+// The same significant space must also survive when the line is long enough to
+// be wrapped: wrapLine used to drop whitespace that sat between a word/tag and
+// an adjacent tag, because strings.Fields discarded trailing space and tags
+// ignored any pending space.
+func TestPrettyXML_WrapPreservesSpaceBeforeTag(t *testing.T) {
+ long := strings.Repeat("слово ", 25) // push past the 120-rune wrap width
+ input := `` + long + `жирный код хвост предложения.
`
+ result := PrettyXML(input, " ")
+ assert.NotContains(t, result, "")
+ assert.Contains(t, result, " ")
+}
+
+// The literal reported pattern: a plain word followed by inline code on a line
+// long enough to wrap. wrapLine's strings.Fields dropped the word's trailing
+// space, gluing it to ("манифеста" instead of "манифеста ").
+func TestPrettyXML_WrapPreservesSpaceBeforeCodeAfterWord(t *testing.T) {
+ long := strings.Repeat("текст ", 25) // push past the 120-rune wrap width
+ input := `` + long + `манифеста app.manifest.toml и далее.
`
+ result := PrettyXML(input, " ")
+ assert.NotContains(t, result, "манифеста")
+ assert.Contains(t, result, "манифеста ")
+}
+
+// A wrap must never fall inside ...: doing so injects the
+// continuation indent into the code text, which round-trips as a spurious space
+// inside the span. The whole span is kept on one line regardless of where the
+// boundary lands. Probes several lengths so the wrap point sweeps across the
+// code span's open tag.
+func TestPrettyXML_WrapKeepsCodeSpanWhole(t *testing.T) {
+ for n := 18; n <= 24; n++ {
+ input := `` + strings.Repeat("word ", n) + `tail app.manifest.lock rest of it.
`
+ result := PrettyXML(input, " ")
+ for _, line := range strings.Split(result, "\n") {
+ trimmed := strings.TrimSpace(line)
+ // No line may start or end mid-span (open without close, or vice versa).
+ assert.False(t, strings.HasPrefix(trimmed, "app.manifest.lock"),
+ "code content split onto its own line at n=%d: %q", n, line)
+ if strings.Contains(line, "") {
+ assert.Contains(t, line, "",
+ " opened but not closed on same line at n=%d: %q", n, line)
+ }
+ }
+ }
+}