214 lines
5.0 KiB
Go
214 lines
5.0 KiB
Go
package markdown
|
|
|
|
import (
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
const (
|
|
blockSeparator = "\n\n"
|
|
hardBreak = "\\\n"
|
|
bulletMarker = "- "
|
|
codeFence = "```"
|
|
alignOpen = `<div style="text-align: `
|
|
alignOpenEnd = `">`
|
|
alignClose = "</div>"
|
|
)
|
|
|
|
var textEscaper = strings.NewReplacer(
|
|
"\\", "\\\\", "*", "\\*", "_", "\\_", "`", "\\`", "[", "\\[", "]", "\\]", "<", "\\<",
|
|
)
|
|
|
|
var (
|
|
numberedLineStart = regexp.MustCompile(`^(\d+)([.)])(\s|$)`)
|
|
markerLineStart = regexp.MustCompile(`^(>|#{1,6}(\s|$)|[+-](\s|$|-)|=+\s*$)`)
|
|
)
|
|
|
|
var hrefEscaper = strings.NewReplacer(" ", "%20", "(", "%28", ")", "%29", "<", "%3C", ">", "%3E")
|
|
|
|
func renderMarkdown(blocks []block) string {
|
|
return renderBlocks(blocks, blockSeparator)
|
|
}
|
|
|
|
func renderBlocks(blocks []block, separator string) string {
|
|
parts := make([]string, 0, len(blocks))
|
|
for _, b := range blocks {
|
|
if rendered := renderBlock(b); rendered != "" {
|
|
parts = append(parts, rendered)
|
|
}
|
|
}
|
|
return strings.Join(parts, separator)
|
|
}
|
|
|
|
func renderBlock(b block) string {
|
|
rendered := renderPlainBlock(b)
|
|
if b.align == "" || rendered == "" {
|
|
return rendered
|
|
}
|
|
return alignOpen + b.align + alignOpenEnd + blockSeparator + rendered + blockSeparator + alignClose
|
|
}
|
|
|
|
func renderPlainBlock(b block) string {
|
|
switch b.kind {
|
|
case kindHeading:
|
|
return strings.Repeat("#", b.level) + " " + renderInlines(b.inlines)
|
|
case kindParagraph:
|
|
return escapeLineStarts(renderInlines(b.inlines))
|
|
case kindList:
|
|
return renderList(b)
|
|
case kindBlockquote:
|
|
return prefixLines(renderBlocks(b.children, blockSeparator), "> ", ">")
|
|
case kindCode:
|
|
return renderCode(b)
|
|
case kindImage:
|
|
return " + ")"
|
|
}
|
|
return "---"
|
|
}
|
|
|
|
func escapeLineStarts(text string) string {
|
|
lines := strings.Split(text, "\n")
|
|
for index, line := range lines {
|
|
line = numberedLineStart.ReplaceAllString(line, `$1\$2$3`)
|
|
if markerLineStart.MatchString(line) {
|
|
line = `\` + line
|
|
}
|
|
lines[index] = line
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func renderCode(b block) string {
|
|
fence := codeFence
|
|
for strings.Contains(b.text, fence) {
|
|
fence += "`"
|
|
}
|
|
return fence + b.language + "\n" + b.text + "\n" + fence
|
|
}
|
|
|
|
func escapeAlt(alt string) string {
|
|
return strings.NewReplacer("[", "\\[", "]", "\\]", "\n", " ").Replace(alt)
|
|
}
|
|
|
|
func renderList(b block) string {
|
|
lines := make([]string, 0, len(b.items))
|
|
for index, item := range b.items {
|
|
marker := bulletMarker
|
|
if b.ordered {
|
|
marker = strconv.Itoa(index+1) + ". "
|
|
}
|
|
content := renderBlocks(item, itemSeparator(item))
|
|
lines = append(lines, marker+indentContinuation(content, len(marker)))
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func itemSeparator(item []block) string {
|
|
textBlocks := 0
|
|
for _, b := range item {
|
|
if b.kind != kindList {
|
|
textBlocks++
|
|
}
|
|
}
|
|
if textBlocks > 1 {
|
|
return blockSeparator
|
|
}
|
|
return "\n"
|
|
}
|
|
|
|
func indentContinuation(content string, width int) string {
|
|
indent := strings.Repeat(" ", width)
|
|
lines := strings.Split(content, "\n")
|
|
for index := 1; index < len(lines); index++ {
|
|
if lines[index] != "" {
|
|
lines[index] = indent + lines[index]
|
|
}
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func prefixLines(content, prefix, emptyPrefix string) string {
|
|
lines := strings.Split(content, "\n")
|
|
for index, line := range lines {
|
|
if line == "" {
|
|
lines[index] = emptyPrefix
|
|
continue
|
|
}
|
|
lines[index] = prefix + line
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func renderInlines(inlines []inline) string {
|
|
var builder strings.Builder
|
|
for _, item := range inlines {
|
|
builder.WriteString(renderInline(item))
|
|
}
|
|
return builder.String()
|
|
}
|
|
|
|
func renderInline(item inline) string {
|
|
isCode := hasMarkKind(item.marks, markCode)
|
|
lines := strings.Split(item.text, "\n")
|
|
for index, line := range lines {
|
|
lines[index] = wrapMarks(item.marks, line, isCode)
|
|
}
|
|
return strings.Join(lines, hardBreak)
|
|
}
|
|
|
|
func hasMarkKind(marks []mark, kind string) bool {
|
|
for _, m := range marks {
|
|
if m.kind == kind {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func wrapMarks(marks []mark, text string, isCode bool) string {
|
|
core := strings.TrimSpace(text)
|
|
if core == "" {
|
|
return text
|
|
}
|
|
start := strings.Index(text, core)
|
|
leading, trailing := text[:start], text[start+len(core):]
|
|
var wrapped string
|
|
if isCode {
|
|
wrapped = inlineCode(core)
|
|
} else {
|
|
wrapped = escapeText(core)
|
|
}
|
|
for index := len(marks) - 1; index >= 0; index-- {
|
|
wrapped = applyMark(marks[index], wrapped)
|
|
}
|
|
return leading + wrapped + trailing
|
|
}
|
|
|
|
func inlineCode(text string) string {
|
|
fence := "`"
|
|
for strings.Contains(text, fence) {
|
|
fence += "`"
|
|
}
|
|
if strings.HasPrefix(text, "`") || strings.HasSuffix(text, "`") {
|
|
return fence + " " + text + " " + fence
|
|
}
|
|
return fence + text + fence
|
|
}
|
|
|
|
func applyMark(m mark, text string) string {
|
|
switch m.kind {
|
|
case markBold:
|
|
return "**" + text + "**"
|
|
case markItalic:
|
|
return "*" + text + "*"
|
|
case markLink:
|
|
return "[" + text + "](" + hrefEscaper.Replace(m.href) + ")"
|
|
}
|
|
return text
|
|
}
|
|
|
|
func escapeText(text string) string {
|
|
return textEscaper.Replace(text)
|
|
}
|