diff --git a/internal/cmd/compose_test.go b/internal/cmd/compose_test.go index f8508e62..dee0ad43 100644 --- a/internal/cmd/compose_test.go +++ b/internal/cmd/compose_test.go @@ -130,6 +130,19 @@ func TestComposeSendsRawHTMLVerbatim(t *testing.T) { } } +func TestComposeKeepsWhitespaceInRawHTML(t *testing.T) { + server, sent := threadReplyServer(t, messageAddressedToJane, 11, 12) + body := "
first
\n second
" + + err := runCLI(t, server, "--account", "8", "compose", "--thread-id", "7", "--message-html", body) + if err != nil { + t.Fatalf("compose failed: %v", err) + } + if sent.Content != body { + t.Errorf("content = %q, want raw HTML %q", sent.Content, body) + } +} + func TestComposeRefusesMessageAndMessageHTMLTogether(t *testing.T) { server, sent := threadReplyServer(t, messageAddressedToJane, 11, 12) diff --git a/internal/cmd/contacts_test.go b/internal/cmd/contacts_test.go index 2813883a..75e56a75 100644 --- a/internal/cmd/contacts_test.go +++ b/internal/cmd/contacts_test.go @@ -400,7 +400,7 @@ func TestContactNoteSetKeepsLineBreaks(t *testing.T) { if err := json.Unmarshal(requests[0].Body, &body); err != nil { t.Fatal(err) } - if want := "

First line
\nSecond line

"; body.Contact.Note != want { + if want := "

First line
Second line

"; body.Contact.Note != want { t.Errorf("note = %q, want %q", body.Contact.Note, want) } } diff --git a/internal/cmd/draft_test.go b/internal/cmd/draft_test.go index e3e3dbcc..3c8ff5a8 100644 --- a/internal/cmd/draft_test.go +++ b/internal/cmd/draft_test.go @@ -79,6 +79,23 @@ func TestComposeDraftSavesInsteadOfSending(t *testing.T) { } } +func TestComposeDraftStartsEveryWrappedLineFlush(t *testing.T) { + var writes []draftWrite + _, err := runJSONCommand(t, draftLifecycleServer(t, draftEditJSON, &writes), + "compose", "--subject", "A short note", "-m", "AAA.\nBBB.\n\nCCC.", "--draft") + if err != nil { + t.Fatalf("compose --draft: %v", err) + } + + if len(writes) != 1 { + t.Fatalf("writes = %+v", writes) + } + message, _ := writes[0].Body["message"].(map[string]any) + if want := "

AAA.
BBB.

\n

CCC.

"; message["content"] != want { + t.Errorf("content = %q, want %q", message["content"], want) + } +} + // A draft needs nobody on it yet — only a send does. func TestComposeDraftNeedsNoRecipients(t *testing.T) { var writes []draftWrite diff --git a/internal/cmd/forward_test.go b/internal/cmd/forward_test.go index ec05a8c1..7e9ff0b7 100644 --- a/internal/cmd/forward_test.go +++ b/internal/cmd/forward_test.go @@ -108,7 +108,7 @@ func TestForwardSendsLatestEntryDraft(t *testing.T) { if sent.Subject != "Fwd: Quarterly planning" { t.Errorf("subject = %q", sent.Subject) } - wantContent := "

For your review
\nThanks & take care


Quoted message
" + wantContent := "

For your review
Thanks & take care


Quoted message
" if sent.Content != wantContent { t.Errorf("content = %q, want %q", sent.Content, wantContent) } diff --git a/internal/htmlutil/from_markdown.go b/internal/htmlutil/from_markdown.go index 6e87fdd5..3313670e 100644 --- a/internal/htmlutil/from_markdown.go +++ b/internal/htmlutil/from_markdown.go @@ -21,10 +21,43 @@ var fromMarkdown = goldmark.New( goldmark.WithRendererOptions( htmlrenderer.WithHardWraps(), htmlrenderer.WithUnsafe(), - renderer.WithNodeRenderers(util.Prioritized(&trixCodeBlockRenderer{}, 100)), + renderer.WithNodeRenderers( + util.Prioritized(&trixTextRenderer{}, 100), + util.Prioritized(&trixCodeBlockRenderer{}, 100), + ), ), ) +// trixTextRenderer writes line breaks without formatting whitespace after them, so the +// next line starts with exactly the text the author wrote. Goldmark's default renderer +// writes a newline after every
, and Trix keeps that newline with the following text. +type trixTextRenderer struct{} + +func (r *trixTextRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { + reg.Register(ast.KindText, r.renderText) +} + +func (r *trixTextRenderer) renderText(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + text, ok := node.(*ast.Text) + if !ok { + return ast.WalkContinue, nil + } + + value := text.Segment.Value(source) + if text.IsRaw() { + htmlrenderer.DefaultWriter.RawWrite(w, value) + } else { + htmlrenderer.DefaultWriter.Write(w, value) + if text.HardLineBreak() || text.SoftLineBreak() { + _, _ = w.WriteString("
") + } + } + return ast.WalkContinue, nil +} + // trixLanguages maps a fence's info string to the language names HEY's own code // blocks carry, which is the set its server-side highlighter accepts. var trixLanguages = map[string]string{ diff --git a/internal/htmlutil/from_markdown_test.go b/internal/htmlutil/from_markdown_test.go index 5a363e5b..8930c9bf 100644 --- a/internal/htmlutil/from_markdown_test.go +++ b/internal/htmlutil/from_markdown_test.go @@ -14,20 +14,60 @@ func TestFromMarkdown(t *testing.T) { want: "

Hello there

", }, { - name: "single newline becomes a hard break", + name: "single newline becomes a hard break without indenting the next line", md: "Line one\nLine two", - want: "

Line one
\nLine two

", + want: "

Line one
Line two

", }, { - name: "CRLF newline becomes a hard break", + name: "CRLF newline becomes a hard break without indenting the next line", md: "Line one\r\nLine two", - want: "

Line one
\nLine two

", + want: "

Line one
Line two

", + }, + { + name: "every continuation line starts flush", + md: "Line one\nLine two\nLine three", + want: "

Line one
Line two
Line three

", + }, + { + name: "two-space Markdown break starts the next line flush", + md: "Line one \nLine two", + want: "

Line one
Line two

", + }, + { + name: "backslash Markdown break starts the next line flush", + md: "Line one\\\nLine two", + want: "

Line one
Line two

", }, { name: "blank line splits paragraphs", md: "Para one\n\nPara two", want: "

Para one

\n

Para two

", }, + { + name: "wrapped inline markup keeps escaping", + md: "**Bold** & safe\n[HEY](https://hey.com)", + want: `

Bold & safe
HEY

`, + }, + { + name: "line after inline code starts flush", + md: "Run `hey box list`\nthen choose a box", + want: "

Run hey box list
then choose a box

", + }, + { + name: "line after image starts flush", + md: "![Revenue chart](https://example.com/chart.png)\nReview the chart", + want: `

Revenue chart
Review the chart

`, + }, + { + name: "wrapped list item keeps its structure", + md: "- First line\n second line", + want: "", + }, + { + name: "wrapped blockquote keeps its structure", + md: "> First line\n> second line", + want: "
\n

First line
second line

\n
", + }, { name: "inline emphasis and strikethrough", md: "**bold** and *italic* and ~~gone~~", @@ -53,11 +93,21 @@ func TestFromMarkdown(t *testing.T) { md: "
raw html
", want: "
raw html
", }, + { + name: "raw preformatted HTML keeps break-adjacent whitespace", + md: "
first
\n second
", + want: "
first
\n second
", + }, { name: "code span and fence stay verbatim", md: "`hey box list`\n\n```\nmake test\n```", want: "

hey box list

\n
make test\n
", }, + { + name: "fenced code preserves newlines and literal breaks", + md: "```\nfirst
\n second\n```", + want: "
first<br>\n second\n
", + }, { name: "fence language becomes HEY's pre attribute", md: "```ruby\nputs \"hey\"\n```", @@ -94,6 +144,22 @@ func TestFromMarkdown(t *testing.T) { } } +func TestMarkdownHardBreakRoundTripKeepsItsMeaningWithoutHTMLWhitespace(t *testing.T) { + const source = "AAA.\nBBB.\n\nCCC." + const body = "

AAA.
BBB.

\n

CCC.

" + + if got := FromMarkdown(source); got != body { + t.Fatalf("FromMarkdown() = %q, want %q", got, body) + } + markdown := ToMarkdown(body).String() + if want := "AAA. \nBBB.\n\nCCC."; markdown != want { + t.Fatalf("ToMarkdown() = %q, want %q", markdown, want) + } + if got := FromMarkdown(markdown); got != body { + t.Errorf("FromMarkdown(ToMarkdown()) = %q, want %q", got, body) + } +} + func TestPrependHTML(t *testing.T) { got := PrependHTML("
Forwarded message
", "

For your review

") want := "

For your review


Forwarded message
" diff --git a/internal/tui/compose_test.go b/internal/tui/compose_test.go index 74840cd8..7b07bf09 100644 --- a/internal/tui/compose_test.go +++ b/internal/tui/compose_test.go @@ -513,7 +513,7 @@ func TestComposeBodyIsMarkdown(t *testing.T) { f.body.SetValue("**Bold** move\nsee the list:\n\n- budget\n- hiring") _, _, _, _, body := f.values() - want := "

Bold move
\nsee the list:

\n" + want := "

Bold move
see the list:

\n" if body != want { t.Errorf("body = %q, want %q", body, want) }