// 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("\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("

" + inline(text) + "

\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("

" + inline(h) + "

\n") continue } // Blockquote -> italic paragraph. if strings.HasPrefix(trim, "> ") { flushPara() flushList() out.WriteString("

" + inline(strings.TrimPrefix(trim, "> ")) + "

\n") continue } // Unordered list item. if strings.HasPrefix(trim, "- ") || strings.HasPrefix(trim, "* ") || strings.HasPrefix(trim, "+ ") { flushPara() openList("ul") out.WriteString("
  • " + inline(trim[2:]) + "
  • \n") continue } // Ordered list item like "1. text". if item, ok := orderedItem(trim); ok { flushPara() openList("ol") out.WriteString("
  • " + inline(item) + "
  • \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, "$1") s = reBoldUnder.ReplaceAllString(s, "$1") s = reItalicStar.ReplaceAllString(s, "$1") s = reItalicUnder.ReplaceAllString(s, "$1$2$3") s = reLink.ReplaceAllString(s, `$1`) return s } func escapeHTML(s string) string { r := strings.NewReplacer( "&", "&", "<", "<", ">", ">", ) return r.Replace(s) }