diff --git a/internal/convert/aclink.go b/internal/convert/aclink.go index 4229fde..4b501a8 100644 --- a/internal/convert/aclink.go +++ b/internal/convert/aclink.go @@ -412,8 +412,15 @@ func (r *mdRenderer) acLinkText(n *snode, fallback string) string { // "Nothing but plain text" is checkable rather than guessable: a text node is // an snode with an empty name, so a body whose every descendant is one carries // no markup for escaping to damage. +// +// Whitespace at the text's edges is kept inside the brackets, where Markdown +// allows it and publishes it back; trimming it joined "see here" into +// "[see](url)here" (#204). func (r *mdRenderer) inlineTextForLink(n *snode) string { - rendered := r.renderInlineChildren(n) + rendered := r.renderInlineRun(n) + if strings.TrimSpace(rendered) == "" { + return "" + } if !onlyText(n) { return rendered } diff --git a/internal/convert/storage_to_md.go b/internal/convert/storage_to_md.go index 10403f4..3183abe 100644 --- a/internal/convert/storage_to_md.go +++ b/internal/convert/storage_to_md.go @@ -15,6 +15,7 @@ package convert // aclink.go. import ( + "bytes" "encoding/json" "encoding/xml" "fmt" @@ -24,6 +25,7 @@ import ( "regexp" "sort" "strings" + "unicode" ) // calloutMacroInverse maps a Confluence callout macro back to a GitHub alert @@ -288,7 +290,10 @@ func (r *mdRenderer) renderBlock(n *snode, listIndent string) string { switch n.name { case "h1", "h2", "h3", "h4", "h5", "h6": level := int(n.name[1] - '0') - return strings.Repeat("#", level) + " " + r.renderInlineChildren(n) + // A heading is one line, so a hard break would end it and publish the + // rest as a paragraph. The break stays as the
it was. + text := strings.ReplaceAll(r.renderInlineChildren(n), hardBreak, "
") + return strings.Repeat("#", level) + " " + text case "p": return r.renderInlineChildren(n) case "ul": @@ -638,9 +643,31 @@ func (r *mdRenderer) renderCallout(n *snode, alert string) string { // renderInlineChildren renders a node's children as a single inline string. func (r *mdRenderer) renderInlineChildren(n *snode) string { - var b strings.Builder + return strings.TrimSpace(r.renderInlineRun(n)) +} + +// renderInlineRun is renderInlineChildren without the trim, for a mark, which +// must see the whitespace at its own edges to move it outside its delimiters. +func (r *mdRenderer) renderInlineRun(n *snode) string { + var buf []byte for _, k := range coalesceSplitMarks(n.kids) { part := r.renderInline(k) + // A mark moves its edge whitespace outside its delimiters, so a space + // there can meet a space in the text beside it. One is kept: storage + // collapses a run of spaces anyway, so the second would only reach the + // Markdown on this read and be gone on the next, and the Markdown would + // not be a fixed point. + if bytes.HasSuffix(buf, []byte(" ")) && strings.HasPrefix(part, " ") && !opensWithBreak(part) { + part = strings.TrimLeft(part, " ") + } + // Likewise, whitespace before a hard break is gone on the next read, + // since publishing ends the line at the break: "a
" read as + // "a " (three spaces) and read back as two. The break is written as + // exactly the two spaces that make it one. + if opensWithBreak(part) { + buf = bytes.TrimRight(buf, " \t") + part = hardBreak + strings.TrimLeft(part, " \t")[1:] + } // Storage is XHTML and its newlines are insignificant, so // "
\nSecond" is the ordinary spelling -- and that newline // normalizes to a space, landing immediately after the two-space hard @@ -651,12 +678,43 @@ func (r *mdRenderer) renderInlineChildren(n *snode) string { // *are* the next hard break. Found by the round-trip property test. // Never trim a hard break itself: two of them in a row are two blank // line-endings, and its own leading spaces are what make it one. - if k.name != "br" && strings.HasSuffix(b.String(), hardBreak) { + if !opensWithBreak(part) && bytes.HasSuffix(buf, []byte(hardBreak)) { part = strings.TrimLeft(part, " \t") } - b.WriteString(part) + buf = append(buf, part...) } - return strings.TrimSpace(b.String()) + return string(buf) +} + +// opensWithBreak reports whether an inline part begins with a hard break -- +// a
itself, or a mark that moved one out from its leading edge -- whose +// leading spaces are what make it a break and must never be trimmed. +func opensWithBreak(part string) bool { + return strings.HasPrefix(strings.TrimLeft(part, " \t"), "\n") +} + +// renderMark renders a bold, italic or strikethrough span, moving whitespace +// at its edges outside the delimiters (#204). It cannot stay inside, since +// CommonMark refuses "**bold **" as emphasis -- a closing delimiter preceded by +// whitespace does not close -- and it cannot be dropped, since the editor +// leaves a space typed at the end of a bold run inside the run, and dropping +// it published "**bold**next" as one word. Unicode whitespace counts, because +// CommonMark's flanking rule counts it: a trailing no-break space refuses the +// delimiter as surely as a space does. A mark holding only whitespace has +// nothing Markdown can mark, so it renders as that whitespace. +// +// This is the whitespace half of the flanking rule only. A mark whose text +// starts or ends with punctuation against a letter outside it ("a**(b)**") +// still fails the other half, and did before. +func (r *mdRenderer) renderMark(n *snode, delim string) string { + s := r.renderInlineRun(n) + body := strings.TrimLeftFunc(s, unicode.IsSpace) + if body == "" { + return s + } + lead := s[:len(s)-len(body)] + inner := strings.TrimRightFunc(body, unicode.IsSpace) + return lead + delim + inner + delim + body[len(inner):] } // hardBreak is Markdown's two-space line break, as renderInline emits it for a @@ -821,13 +879,13 @@ func (r *mdRenderer) renderInline(n *snode) string { } switch n.name { case "strong", "b": - return "**" + r.renderInlineChildren(n) + "**" + return r.renderMark(n, "**") case "em", "i": - return "*" + r.renderInlineChildren(n) + "*" + return r.renderMark(n, "*") case "code": return "`" + textContent(n) + "`" case "del", "s", "strike": - return "~~" + r.renderInlineChildren(n) + "~~" + return r.renderMark(n, "~~") case "br": return hardBreak case "a": @@ -845,7 +903,11 @@ func (r *mdRenderer) renderInline(n *snode) string { // would otherwise render the node and the fallback one after the other. return serialize(adfPassthrough(n)) default: - return r.renderInlineChildren(n) + // A wrapper Markdown has no syntax for (a coloured , , ) + // keeps its edge whitespace for the run around it to settle: trimming it + // here joined "bold next" into one word + // after renderMark had moved the space out. + return r.renderInlineRun(n) } } diff --git a/internal/convert/storage_to_md_test.go b/internal/convert/storage_to_md_test.go index 8b80927..ba0f83e 100644 --- a/internal/convert/storage_to_md_test.go +++ b/internal/convert/storage_to_md_test.go @@ -350,7 +350,7 @@ func TestStorageToMarkdownCoalescesSplitMarks(t *testing.T) { }, "link only partly bold does not merge": { in: `

a bc

`, - want: "**a**[b**c**](https://example.com)\n", + want: "**a** [b**c**](https://example.com)\n", }, } for name, tc := range tests { diff --git a/internal/convert/table_property_test.go b/internal/convert/table_property_test.go index 7eb2454..50dbed8 100644 --- a/internal/convert/table_property_test.go +++ b/internal/convert/table_property_test.go @@ -106,7 +106,7 @@ func newPropertyEnv(t testing.TB) *propertyEnv { } func (e *propertyEnv) check(seed uint64) string { - g := &tableGen{r: rand.New(rand.NewPCG(seed, 0x55))} + g := &tableGen{r: rand.New(rand.NewPCG(seed, 0x55)), edge: rand.New(rand.NewPCG(seed, 0x204))} return e.checkStorage(fmt.Sprintf("seed %d", seed), g.table(0)) } @@ -416,10 +416,27 @@ func modelInline(kids []*snode) string { if a, ok := inlineAlias[name]; ok { name = a } - fmt.Fprintf(&b, "<%s>%s", name, modelInline(k.kids), name) + inner := modelInline(k.kids) + // A space at the edge of a mark separates the same words inside it + // as outside, and Markdown can only put it outside (#204). Only the + // marks: a span's spaces are its text. + var lead, trail string + if name == "strong" || name == "em" || name == "del" { + body := strings.TrimLeft(inner, " ") + lead = inner[:len(inner)-len(body)] + inner = strings.TrimRight(body, " ") + trail = body[len(inner):] + } + fmt.Fprintf(&b, "%s<%s>%s%s", lead, name, inner, name, trail) } } s := whitespaceRunRE.ReplaceAllString(b.String(), " ") + // Two runs of one mark with nothing but a space between them read as one + // run: "a b" and "a b" look the same. + for _, m := range []string{"strong", "em", "del"} { + s = strings.ReplaceAll(s, "<"+m+">", "") + s = strings.ReplaceAll(s, " <"+m+">", " ") + } // Whitespace beside a line break shows nothing, and publishing writes a // newline after every
. return strings.ReplaceAll(strings.ReplaceAll(s, " ⏎", "⏎"), "⏎ ", "⏎") @@ -541,7 +558,10 @@ func modelHasElement(n *snode) bool { // which #203 covers, and inline tags read has no Markdown for (