Files
Summerize/internal/output/spotify.go
2026-05-10 13:37:17 -06:00

155 lines
3.8 KiB
Go

// Package output renders summaries to user-visible formats. Markdown is
// passed through; Spotify HTML uses the small tag subset that Spotify for
// Podcasters' show-notes editor accepts (b, i, a, ul/ol/li, paragraphs).
package output
import (
"regexp"
"strings"
)
var (
reBoldStar = regexp.MustCompile(`\*\*([^*\n]+)\*\*`)
reBoldUnder = regexp.MustCompile(`__([^_\n]+)__`)
reItalicStar = regexp.MustCompile(`\*([^*\n]+)\*`)
reItalicUnder = regexp.MustCompile(`(^|[\s(])_([^_\n]+)_($|[\s).,!?;:])`)
reLink = regexp.MustCompile(`\[([^\]]+)\]\(([^)\s]+)\)`)
reInlineCode = regexp.MustCompile("`([^`\n]+)`")
)
// MarkdownToSpotifyHTML converts a markdown summary into the limited HTML
// subset Spotify for Podcasters renders. Unknown markdown structures degrade
// to plain text rather than producing rejected tags.
func MarkdownToSpotifyHTML(md string) string {
lines := strings.Split(strings.ReplaceAll(md, "\r\n", "\n"), "\n")
var out strings.Builder
listKind := "" // "ul" or "ol" while we're inside a list
flushList := func() {
if listKind != "" {
out.WriteString("</" + listKind + ">\n")
listKind = ""
}
}
openList := func(kind string) {
if listKind != kind {
flushList()
out.WriteString("<" + kind + ">\n")
listKind = kind
}
}
paragraph := []string{}
flushPara := func() {
if len(paragraph) == 0 {
return
}
text := strings.Join(paragraph, " ")
out.WriteString("<p>" + inline(text) + "</p>\n")
paragraph = paragraph[:0]
}
for _, raw := range lines {
line := strings.TrimRight(raw, " \t")
trim := strings.TrimSpace(line)
// Blank line: end current paragraph/list block.
if trim == "" {
flushPara()
flushList()
continue
}
// Horizontal rule.
if trim == "---" || trim == "***" || trim == "___" {
flushPara()
flushList()
continue
}
// Heading -> bold paragraph.
if h := headingText(trim); h != "" {
flushPara()
flushList()
out.WriteString("<p><b>" + inline(h) + "</b></p>\n")
continue
}
// Blockquote -> italic paragraph.
if strings.HasPrefix(trim, "> ") {
flushPara()
flushList()
out.WriteString("<p><i>" + inline(strings.TrimPrefix(trim, "> ")) + "</i></p>\n")
continue
}
// Unordered list item.
if strings.HasPrefix(trim, "- ") || strings.HasPrefix(trim, "* ") || strings.HasPrefix(trim, "+ ") {
flushPara()
openList("ul")
out.WriteString(" <li>" + inline(trim[2:]) + "</li>\n")
continue
}
// Ordered list item like "1. text".
if item, ok := orderedItem(trim); ok {
flushPara()
openList("ol")
out.WriteString(" <li>" + inline(item) + "</li>\n")
continue
}
// Anything else: append to current paragraph.
flushList()
paragraph = append(paragraph, trim)
}
flushPara()
flushList()
return strings.TrimRight(out.String(), "\n")
}
func headingText(s string) string {
// Up to 6 leading '#' followed by a space.
hashes := 0
for hashes < len(s) && s[hashes] == '#' {
hashes++
}
if hashes == 0 || hashes > 6 || hashes >= len(s) || s[hashes] != ' ' {
return ""
}
return strings.TrimSpace(s[hashes+1:])
}
func orderedItem(s string) (string, bool) {
i := 0
for i < len(s) && s[i] >= '0' && s[i] <= '9' {
i++
}
if i == 0 || i+1 >= len(s) || s[i] != '.' || s[i+1] != ' ' {
return "", false
}
return strings.TrimSpace(s[i+2:]), true
}
func inline(s string) string {
s = escapeHTML(s)
s = reInlineCode.ReplaceAllString(s, "$1")
s = reBoldStar.ReplaceAllString(s, "<b>$1</b>")
s = reBoldUnder.ReplaceAllString(s, "<b>$1</b>")
s = reItalicStar.ReplaceAllString(s, "<i>$1</i>")
s = reItalicUnder.ReplaceAllString(s, "$1<i>$2</i>$3")
s = reLink.ReplaceAllString(s, `<a href="$2">$1</a>`)
return s
}
func escapeHTML(s string) string {
r := strings.NewReplacer(
"&", "&amp;",
"<", "&lt;",
">", "&gt;",
)
return r.Replace(s)
}