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.
\nCCC.
"; 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
\nPara 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: "\nReview the chart",
+ want: `
Review the chart
`,
+ },
+ {
+ name: "wrapped list item keeps its structure",
+ md: "- First line\n second line",
+ want: "\n- First line
second line \n
",
+ },
+ {
+ name: "wrapped blockquote keeps its structure",
+ md: "> First line\n> second line",
+ want: "\nFirst 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
\nmake 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.
\nCCC.
"
+
+ 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)
}