122 lines
4.2 KiB
Go
122 lines
4.2 KiB
Go
// Package richtext renders the light formatting the admin panel's message
|
|
// fields allow, for the two places that text leaves the app:
|
|
//
|
|
// - ToHTML, for email bodies.
|
|
// - Plain, for push notifications and toasts, which can't render markup.
|
|
//
|
|
// The format is a handful of markers typed into a plain textarea:
|
|
//
|
|
// *bold* _italic_ __underline__ ~strike~ [label](https://…)
|
|
//
|
|
// plus bare http(s) URLs, which become links on their own. *bold* and ~strike~
|
|
// match what chat already uses, so the two read the same.
|
|
//
|
|
// Safety comes from the order of operations: the input is HTML-escaped FIRST,
|
|
// and only then are the markers turned into tags. Nothing an author types can
|
|
// therefore produce markup of its own — the only tags in the output are the
|
|
// ones this package writes. Links are limited to http and https, so a
|
|
// `javascript:` URL is never made clickable.
|
|
package richtext
|
|
|
|
import (
|
|
"fmt"
|
|
"html"
|
|
"html/template"
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
var (
|
|
// A labelled link, or a bare URL. One alternation so a URL is never
|
|
// linked twice — once as the label's target and once on its own.
|
|
linkRe = regexp.MustCompile(`\[([^\]\n]+)\]\((https?://[^\s)]+)\)|(https?://[^\s<]+)`)
|
|
|
|
underlineRe = regexp.MustCompile(`__([^_\n]+?)__`)
|
|
boldRe = regexp.MustCompile(`\*([^*\n]+?)\*`)
|
|
strikeRe = regexp.MustCompile(`~([^~\n]+?)~`)
|
|
// Italic needs a non-word character (or an edge) on each side, so the
|
|
// underscores inside snake_case or a handle aren't read as markers. RE2
|
|
// has no lookbehind, so the neighbours are captured and put back.
|
|
italicRe = regexp.MustCompile(`(^|[^\w])_([^_\n]+?)_($|[^\w])`)
|
|
|
|
// Punctuation that ends a sentence rather than belonging to the URL.
|
|
trailingPunct = ".,;:!?)"
|
|
)
|
|
|
|
// placeholder marks where a finished link sits while markers are applied, so
|
|
// the underscores and tildes common in URLs aren't read as formatting.
|
|
func placeholder(i int) string { return fmt.Sprintf("\x00%d\x00", i) }
|
|
|
|
// splitTrailing separates sentence punctuation from the end of a bare URL.
|
|
func splitTrailing(u string) (url, tail string) {
|
|
end := len(u)
|
|
for end > 0 && strings.ContainsRune(trailingPunct, rune(u[end-1])) {
|
|
end--
|
|
}
|
|
return u[:end], u[end:]
|
|
}
|
|
|
|
// ToHTML renders the markers as HTML for an email body.
|
|
func ToHTML(s string) template.HTML {
|
|
escaped := html.EscapeString(strings.ReplaceAll(s, "\r\n", "\n"))
|
|
|
|
var links []string
|
|
withHolders := linkRe.ReplaceAllStringFunc(escaped, func(m string) string {
|
|
parts := linkRe.FindStringSubmatch(m)
|
|
var label, href, tail string
|
|
if parts[1] != "" {
|
|
label, href = parts[1], parts[2]
|
|
} else {
|
|
href, tail = splitTrailing(parts[3])
|
|
label = href
|
|
}
|
|
// href is already escaped (it came from the escaped input), so it is
|
|
// safe inside the attribute as-is.
|
|
links = append(links, fmt.Sprintf(
|
|
`<a href="%s" style="color:#9a6b2f;font-weight:600">%s</a>`, href, label))
|
|
return placeholder(len(links)-1) + tail
|
|
})
|
|
|
|
out := underlineRe.ReplaceAllString(withHolders, `<u>$1</u>`)
|
|
out = boldRe.ReplaceAllString(out, `<strong>$1</strong>`)
|
|
out = strikeRe.ReplaceAllString(out, `<s>$1</s>`)
|
|
out = italicRe.ReplaceAllString(out, `$1<em>$2</em>$3`)
|
|
|
|
for i, l := range links {
|
|
out = strings.Replace(out, placeholder(i), l, 1)
|
|
}
|
|
out = strings.ReplaceAll(out, "\n", "<br>")
|
|
|
|
// Every tag here was written by this function, and all author text was
|
|
// escaped before any of them were added.
|
|
return template.HTML(out) //nolint:gosec // see above
|
|
}
|
|
|
|
// Plain strips the markers for surfaces that can't render them — a push
|
|
// notification or a toast. A labelled link keeps only its label; a bare URL
|
|
// stays as written, since it is still useful to see.
|
|
func Plain(s string) string {
|
|
s = strings.ReplaceAll(s, "\r\n", "\n")
|
|
|
|
var links []string
|
|
withHolders := linkRe.ReplaceAllStringFunc(s, func(m string) string {
|
|
parts := linkRe.FindStringSubmatch(m)
|
|
if parts[1] != "" {
|
|
links = append(links, parts[1])
|
|
} else {
|
|
links = append(links, parts[3])
|
|
}
|
|
return placeholder(len(links) - 1)
|
|
})
|
|
|
|
out := underlineRe.ReplaceAllString(withHolders, `$1`)
|
|
out = boldRe.ReplaceAllString(out, `$1`)
|
|
out = strikeRe.ReplaceAllString(out, `$1`)
|
|
out = italicRe.ReplaceAllString(out, `$1$2$3`)
|
|
|
|
for i, l := range links {
|
|
out = strings.Replace(out, placeholder(i), l, 1)
|
|
}
|
|
return out
|
|
}
|