Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions internal/cmd/compose_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,19 @@ func TestComposeSendsRawHTMLVerbatim(t *testing.T) {
}
}

func TestComposeKeepsWhitespaceInRawHTML(t *testing.T) {
server, sent := threadReplyServer(t, messageAddressedToJane, 11, 12)
body := "<pre>first<br>\n second</pre>"

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)

Expand Down
2 changes: 1 addition & 1 deletion internal/cmd/contacts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -400,7 +400,7 @@ func TestContactNoteSetKeepsLineBreaks(t *testing.T) {
if err := json.Unmarshal(requests[0].Body, &body); err != nil {
t.Fatal(err)
}
if want := "<p>First line<br>\nSecond line</p>"; body.Contact.Note != want {
if want := "<p>First line<br>Second line</p>"; body.Contact.Note != want {
t.Errorf("note = %q, want %q", body.Contact.Note, want)
}
}
Expand Down
17 changes: 17 additions & 0 deletions internal/cmd/draft_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 := "<p>AAA.<br>BBB.</p>\n<p>CCC.</p>"; 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
Expand Down
2 changes: 1 addition & 1 deletion internal/cmd/forward_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ func TestForwardSendsLatestEntryDraft(t *testing.T) {
if sent.Subject != "Fwd: Quarterly planning" {
t.Errorf("subject = %q", sent.Subject)
}
wantContent := "<p>For your review<br>\nThanks &amp; take care</p><br><div>Quoted message</div>"
wantContent := "<p>For your review<br>Thanks &amp; take care</p><br><div>Quoted message</div>"
if sent.Content != wantContent {
t.Errorf("content = %q, want %q", sent.Content, wantContent)
}
Expand Down
35 changes: 34 additions & 1 deletion internal/htmlutil/from_markdown.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <br>, 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("<br>")
}
}
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{
Expand Down
74 changes: 70 additions & 4 deletions internal/htmlutil/from_markdown_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,60 @@ func TestFromMarkdown(t *testing.T) {
want: "<p>Hello there</p>",
},
{
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: "<p>Line one<br>\nLine two</p>",
want: "<p>Line one<br>Line two</p>",
},
{
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: "<p>Line one<br>\nLine two</p>",
want: "<p>Line one<br>Line two</p>",
},
{
name: "every continuation line starts flush",
md: "Line one\nLine two\nLine three",
want: "<p>Line one<br>Line two<br>Line three</p>",
},
{
name: "two-space Markdown break starts the next line flush",
md: "Line one \nLine two",
want: "<p>Line one<br>Line two</p>",
},
{
name: "backslash Markdown break starts the next line flush",
md: "Line one\\\nLine two",
want: "<p>Line one<br>Line two</p>",
},
{
name: "blank line splits paragraphs",
md: "Para one\n\nPara two",
want: "<p>Para one</p>\n<p>Para two</p>",
},
{
name: "wrapped inline markup keeps escaping",
md: "**Bold** & safe\n[HEY](https://hey.com)",
want: `<p><strong>Bold</strong> &amp; safe<br><a href="https://hey.com">HEY</a></p>`,
},
{
name: "line after inline code starts flush",
md: "Run `hey box list`\nthen choose a box",
want: "<p>Run <code>hey box list</code><br>then choose a box</p>",
},
{
name: "line after image starts flush",
md: "![Revenue chart](https://example.com/chart.png)\nReview the chart",
want: `<p><img src="https://example.com/chart.png" alt="Revenue chart"><br>Review the chart</p>`,
},
{
name: "wrapped list item keeps its structure",
md: "- First line\n second line",
want: "<ul>\n<li>First line<br>second line</li>\n</ul>",
},
{
name: "wrapped blockquote keeps its structure",
md: "> First line\n> second line",
want: "<blockquote>\n<p>First line<br>second line</p>\n</blockquote>",
},
{
name: "inline emphasis and strikethrough",
md: "**bold** and *italic* and ~~gone~~",
Expand All @@ -53,11 +93,21 @@ func TestFromMarkdown(t *testing.T) {
md: "<div>raw <strong>html</strong></div>",
want: "<div>raw <strong>html</strong></div>",
},
{
name: "raw preformatted HTML keeps break-adjacent whitespace",
md: "<pre>first<br>\n second</pre>",
want: "<pre>first<br>\n second</pre>",
},
{
name: "code span and fence stay verbatim",
md: "`hey box list`\n\n```\nmake test\n```",
want: "<p><code>hey box list</code></p>\n<pre><code>make test\n</code></pre>",
},
{
name: "fenced code preserves newlines and literal breaks",
md: "```\nfirst<br>\n second\n```",
want: "<pre><code>first&lt;br&gt;\n second\n</code></pre>",
},
{
name: "fence language becomes HEY's pre attribute",
md: "```ruby\nputs \"hey\"\n```",
Expand Down Expand Up @@ -94,6 +144,22 @@ func TestFromMarkdown(t *testing.T) {
}
}

func TestMarkdownHardBreakRoundTripKeepsItsMeaningWithoutHTMLWhitespace(t *testing.T) {
const source = "AAA.\nBBB.\n\nCCC."
const body = "<p>AAA.<br>BBB.</p>\n<p>CCC.</p>"

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("<div>Forwarded message</div>", "<p>For <strong>your</strong> review</p>")
want := "<p>For <strong>your</strong> review</p><br><div>Forwarded message</div>"
Expand Down
2 changes: 1 addition & 1 deletion internal/tui/compose_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 := "<p><strong>Bold</strong> move<br>\nsee the list:</p>\n<ul>\n<li>budget</li>\n<li>hiring</li>\n</ul>"
want := "<p><strong>Bold</strong> move<br>see the list:</p>\n<ul>\n<li>budget</li>\n<li>hiring</li>\n</ul>"
if body != want {
t.Errorf("body = %q, want %q", body, want)
}
Expand Down
Loading