Fix post previews rendering raw markdown#474
Conversation
Post previews (footer, /posts, /tag pages) were displaying raw markdown syntax like [text](url), ```code blocks```, **bold**, etc. because PreviewContent() simply truncated the raw markdown string. Add PlainTextPreview() that strips markdown to clean text (used for meta description), and HTMLPreview() that preserves links as clickable <a> tags and renders code as inline <code> elements. HTMLPreview truncates by visible character count so HTML tags don't eat into preview length. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #474 +/- ##
==========================================
+ Coverage 41.92% 43.23% +1.30%
==========================================
Files 6 6
Lines 1016 1071 +55
==========================================
+ Hits 426 463 +37
- Misses 561 573 +12
- Partials 29 35 +6 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Pull request overview
This PR updates how post previews are generated so list pages and meta descriptions no longer show raw markdown syntax, introducing dedicated preview helpers on Post.
Changes:
- Add
Post.PlainTextPreview()to strip markdown into plain text (used for<meta name="description">). - Add
Post.HTMLPreview()to render a limited subset of markdown (links/code) with visible-character-aware truncation. - Update post listing templates (posts/tag/footer) to use
HTMLPreviewinstead ofPreviewContent.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
templates/tag.html |
Switch tag page previews to HTMLPreview. |
templates/posts.html |
Switch /posts previews to HTMLPreview. |
templates/header.html |
Use PlainTextPreview for meta description. |
templates/footer.html |
Switch “Most Recent Post” preview to HTMLPreview (and adjust length). |
blog/post.go |
Add markdown-stripping preview helpers and HTML-aware truncation logic. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| func (p Post) HTMLPreview(length int) template.HTML { | ||
| s := p.Content | ||
| s = reFencedCode.ReplaceAllString(s, "<code>$1</code>") | ||
| s = reInlineCode.ReplaceAllString(s, "<code>$1</code>") | ||
| s = reImages.ReplaceAllString(s, "$1") | ||
| s = reLinksWithURL.ReplaceAllString(s, `<a href="$2">$1</a>`) | ||
| s = reHeadings.ReplaceAllString(s, "") |
There was a problem hiding this comment.
HTMLPreview returns template.HTML built from p.Content via regex replacements without HTML-escaping/sanitization. This disables Go template auto-escaping and allows raw HTML in post content (or crafted link text/URLs) to be rendered/executed in list pages; href is also inserted unescaped and could allow attribute injection or javascript: URLs. Prefer escaping all user content first and only allowing a very small, sanitized subset of tags/attributes (e.g., escape code/link text, validate/escape href, and/or run the output through an allowlist HTML sanitizer before casting to template.HTML).
| s = reFencedCode.ReplaceAllString(s, "<code>$1</code>") | ||
| s = reInlineCode.ReplaceAllString(s, "<code>$1</code>") | ||
| s = reImages.ReplaceAllString(s, "$1") | ||
| s = reLinksWithURL.ReplaceAllString(s, `<a href="$2">$1</a>`) | ||
| s = reHeadings.ReplaceAllString(s, "") | ||
| s = reBold.ReplaceAllString(s, "$1") | ||
| s = reItalic.ReplaceAllString(s, "$1") | ||
| s = reUBold.ReplaceAllString(s, "$1") | ||
| s = reUItalic.ReplaceAllString(s, "$1") | ||
| s = reHR.ReplaceAllString(s, "") |
There was a problem hiding this comment.
The markdown-stripping pipeline wraps fenced/inline code in <code>...</code> and then runs bold/italic underscore regexes afterwards. That will also transform characters inside code blocks (e.g., *, _, **) and can corrupt the rendered code preview. Consider extracting code spans/blocks to placeholders before running emphasis/heading/whitespace cleanup, then reinserting them at the end (or otherwise ensuring later regexes do not run inside code).
| runes := bytes.Runes([]byte(s)) | ||
| if len(runes) > length { | ||
| return string(runes[:length]) + "..." | ||
| } | ||
| return s |
There was a problem hiding this comment.
PlainTextPreview appends "..." after truncating to length runes, so the returned string can exceed the requested length (e.g., length+3). For meta description usage, consider truncating to length-3 when adding an ellipsis (or make ellipsis optional) so callers can enforce a strict maximum length.
| runes := bytes.Runes([]byte(s)) | |
| if len(runes) > length { | |
| return string(runes[:length]) + "..." | |
| } | |
| return s | |
| // Convert to runes so length is based on Unicode code points. | |
| runes := bytes.Runes([]byte(s)) | |
| if length <= 0 { | |
| return "" | |
| } | |
| if len(runes) > length { | |
| // Ensure the returned string never exceeds the requested length. | |
| // For very small lengths, just truncate without adding an ellipsis. | |
| if length <= 3 { | |
| return string(runes[:length]) | |
| } | |
| return string(runes[:length-3]) + "..." | |
| } | |
| return string(runes) |
| // PlainTextPreview strips markdown syntax and returns clean plain text for previews | ||
| func (p Post) PlainTextPreview(length int) string { | ||
| s := p.Content | ||
| s = reFencedCode.ReplaceAllString(s, "$1") | ||
| s = reInlineCode.ReplaceAllString(s, "$1") | ||
| s = reImages.ReplaceAllString(s, "$1") | ||
| s = reLinks.ReplaceAllString(s, "$1") | ||
| s = reHeadings.ReplaceAllString(s, "") | ||
| s = reBold.ReplaceAllString(s, "$1") | ||
| s = reItalic.ReplaceAllString(s, "$1") | ||
| s = reUBold.ReplaceAllString(s, "$1") | ||
| s = reUItalic.ReplaceAllString(s, "$1") | ||
| s = reHR.ReplaceAllString(s, "") | ||
| s = reWhitespace.ReplaceAllString(s, " ") | ||
| s = strings.TrimSpace(s) | ||
|
|
||
| runes := bytes.Runes([]byte(s)) | ||
| if len(runes) > length { | ||
| return string(runes[:length]) + "..." | ||
| } | ||
| return s | ||
| } | ||
|
|
||
| // HTMLPreview strips markdown syntax but preserves links as HTML hyperlinks | ||
| func (p Post) HTMLPreview(length int) template.HTML { | ||
| s := p.Content | ||
| s = reFencedCode.ReplaceAllString(s, "<code>$1</code>") | ||
| s = reInlineCode.ReplaceAllString(s, "<code>$1</code>") | ||
| s = reImages.ReplaceAllString(s, "$1") | ||
| s = reLinksWithURL.ReplaceAllString(s, `<a href="$2">$1</a>`) | ||
| s = reHeadings.ReplaceAllString(s, "") | ||
| s = reBold.ReplaceAllString(s, "$1") | ||
| s = reItalic.ReplaceAllString(s, "$1") | ||
| s = reUBold.ReplaceAllString(s, "$1") | ||
| s = reUItalic.ReplaceAllString(s, "$1") | ||
| s = reHR.ReplaceAllString(s, "") | ||
| s = reWhitespace.ReplaceAllString(s, " ") | ||
| s = strings.TrimSpace(s) | ||
|
|
||
| // Truncate by visible character count, skipping HTML tags | ||
| runes := []rune(s) | ||
| visible := 0 | ||
| cutoff := len(runes) | ||
| inTag := false | ||
| for i, r := range runes { | ||
| if r == '<' { | ||
| inTag = true | ||
| } else if r == '>' { | ||
| inTag = false | ||
| } else if !inTag { | ||
| visible++ | ||
| if visible > length { | ||
| cutoff = i | ||
| break | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if cutoff < len(runes) { | ||
| result := string(runes[:cutoff]) | ||
| if strings.Count(result, "<a ") > strings.Count(result, "</a>") { | ||
| result += "</a>" | ||
| } | ||
| if strings.Count(result, "<code>") > strings.Count(result, "</code>") { | ||
| result += "</code>" | ||
| } | ||
| result += "..." | ||
| return template.HTML(result) | ||
| } | ||
| return template.HTML(s) | ||
| } |
There was a problem hiding this comment.
New preview behavior is introduced via PlainTextPreview/HTMLPreview, but existing unit tests only cover PreviewContent. Since there is already test coverage for this file, please add tests for markdown stripping/link rendering/truncation (including edge cases like code spans containing */_ and potentially unsafe URLs/HTML) to prevent regressions.
Summary
[text](url), fenced code blocks,**bold**, etc.) becausePreviewContent()simply truncated the raw content stringPlainTextPreview()that strips markdown to clean text (used for<meta description>tag)HTMLPreview()that preserves links as clickable<a>tags and renders inline/fenced code as<code>elements, with visible-character-aware truncation so HTML tags don't consume preview lengthTest plan
go test ./...passes/postspage shows clean previews/tag/:namepages show clean previews🤖 Generated with Claude Code