155 lines
3.8 KiB
Go
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(
|
|
"&", "&",
|
|
"<", "<",
|
|
">", ">",
|
|
)
|
|
return r.Replace(s)
|
|
}
|