From e88a96bc2e789c7e5435ddc6bec4abccd19bbee2 Mon Sep 17 00:00:00 2001 From: Kieran Klukas Date: Sat, 24 Jan 2026 21:17:59 -0500 Subject: [PATCH] feat: handle code blocks --- email/render.go | 79 ++++++++++++++++++++++++++++++++++++++------ email/render_test.go | 47 ++++++++++++++++++++++++++ 2 files changed, 116 insertions(+), 10 deletions(-) diff --git a/email/render.go b/email/render.go index f5b0180..b466e9a 100644 --- a/email/render.go +++ b/email/render.go @@ -3,6 +3,7 @@ package email import ( "bytes" "embed" + "fmt" htmltemplate "html/template" "regexp" "strings" @@ -54,31 +55,89 @@ type templateFeedGroup struct { // emailUnsafeTags are HTML5 semantic tags not supported by most email clients (Gmail, Outlook, etc.) var emailUnsafeTags = regexp.MustCompile(`]*)?>`) +// spanTags matches span tags (used to strip syntax highlighting noise from code blocks) +var spanTags = regexp.MustCompile(`]*)?>`) + +// preTagOpen matches opening pre tags to add styling +var preTagOpen = regexp.MustCompile(`]*)?>`) + +// codeBlockStyle is inline CSS for code blocks in emails +const codeBlockStyle = `
`
+
 // sanitizeHTML sanitizes HTML content, allowing safe tags while stripping styles and unsafe elements
 func sanitizeHTML(html string) string {
 	sanitized := policy.Sanitize(html)
 	// Strip HTML5 semantic tags that email clients don't support
-	return emailUnsafeTags.ReplaceAllString(sanitized, "")
+	sanitized = emailUnsafeTags.ReplaceAllString(sanitized, "")
+	// Strip span tags (removes syntax highlighting noise from code blocks)
+	sanitized = spanTags.ReplaceAllString(sanitized, "")
+	// Add styling to pre tags for better code block appearance
+	sanitized = preTagOpen.ReplaceAllString(sanitized, codeBlockStyle)
+	return sanitized
 }
 
 // htmlTagRegex matches HTML tags for stripping
 var htmlTagRegex = regexp.MustCompile(`<[^>]*>`)
 
-// stripHTML removes all HTML tags and decodes entities for plain text output
-func stripHTML(html string) string {
-	// First sanitize to ensure we're working with clean HTML
-	sanitized := policy.Sanitize(html)
-	// Strip all remaining HTML tags
-	text := htmlTagRegex.ReplaceAllString(sanitized, "")
-	// Decode common HTML entities
+// preBlockRegex matches pre blocks including content
+var preBlockRegex = regexp.MustCompile(`(?s)]*>(.*?)
`) + +// whitespaceCollapse collapses multiple whitespace chars +var whitespaceCollapse = regexp.MustCompile(`[ \t]+`) + +// multipleNewlines collapses 3+ newlines to 2 +var multipleNewlines = regexp.MustCompile(`\n{3,}`) + +// decodeEntities decodes common HTML entities +func decodeEntities(text string) string { text = strings.ReplaceAll(text, "&", "&") text = strings.ReplaceAll(text, "<", "<") text = strings.ReplaceAll(text, ">", ">") text = strings.ReplaceAll(text, """, "\"") text = strings.ReplaceAll(text, "'", "'") text = strings.ReplaceAll(text, " ", " ") - // Collapse multiple whitespace/newlines - text = regexp.MustCompile(`\s+`).ReplaceAllString(text, " ") + return text +} + +// stripHTML removes all HTML tags and decodes entities for plain text output +func stripHTML(html string) string { + // First sanitize to ensure we're working with clean HTML + sanitized := policy.Sanitize(html) + + // Extract code blocks and replace with placeholders + var codeBlocks []string + sanitized = preBlockRegex.ReplaceAllStringFunc(sanitized, func(match string) string { + inner := preBlockRegex.FindStringSubmatch(match) + if len(inner) < 2 { + return match + } + code := inner[1] + // Strip any remaining tags (like spans for syntax highlighting) + code = htmlTagRegex.ReplaceAllString(code, "") + code = decodeEntities(code) + // Indent each line with 4 spaces + lines := strings.Split(strings.TrimSpace(code), "\n") + for i, line := range lines { + lines[i] = " " + line + } + codeBlocks = append(codeBlocks, strings.Join(lines, "\n")) + return fmt.Sprintf("\n\n__CODEBLOCK_%d__\n\n", len(codeBlocks)-1) + }) + + // Strip all remaining HTML tags + text := htmlTagRegex.ReplaceAllString(sanitized, "") + // Decode entities + text = decodeEntities(text) + // Collapse horizontal whitespace (but preserve newlines for structure) + text = whitespaceCollapse.ReplaceAllString(text, " ") + // Collapse excessive newlines + text = multipleNewlines.ReplaceAllString(text, "\n\n") + + // Restore code blocks + for i, block := range codeBlocks { + text = strings.ReplaceAll(text, fmt.Sprintf("__CODEBLOCK_%d__", i), block) + } + return strings.TrimSpace(text) } diff --git a/email/render_test.go b/email/render_test.go index 5312d53..e3fc0bc 100644 --- a/email/render_test.go +++ b/email/render_test.go @@ -134,3 +134,50 @@ func TestRenderDigest_TextOutputNoHTMLTags(t *testing.T) { t.Error("Text content was not preserved after HTML stripping") } } + +func TestRenderDigest_CodeBlockFormatting(t *testing.T) { + data := &DigestData{ + ConfigName: "Test Config", + TotalItems: 1, + FeedGroups: []FeedGroup{ + { + FeedName: "Test Feed", + FeedURL: "https://example.com/feed", + Items: []FeedItem{ + { + Title: "Test Article", + Link: "https://example.com/article", + Content: `

Code example:

# comment
+echo hello

Done.

`, + Published: time.Now(), + }, + }, + }, + }, + } + + htmlOutput, textOutput, err := RenderDigest(data, true, 30, false, false) + if err != nil { + t.Fatalf("RenderDigest failed: %v", err) + } + + // HTML: verify code block has styling + if !strings.Contains(htmlOutput, `