2024-03-31 19:01:47 +00:00
|
|
|
package markdown
|
|
|
|
|
2024-03-31 21:08:01 +00:00
|
|
|
import (
|
|
|
|
"html"
|
|
|
|
"strings"
|
|
|
|
)
|
|
|
|
|
|
|
|
var (
|
|
|
|
headerStart = []string{"<h1>", "<h2>", "<h3>", "<h4>", "<h5>", "<h6>"}
|
|
|
|
headerEnd = []string{"</h1>", "</h2>", "</h3>", "</h4>", "</h5>", "</h6>"}
|
|
|
|
)
|
|
|
|
|
2024-04-01 16:10:52 +00:00
|
|
|
type renderer struct {
|
2024-04-01 18:59:32 +00:00
|
|
|
out strings.Builder
|
|
|
|
paragraphLevel int
|
|
|
|
quoteLevel int
|
|
|
|
listLevel int
|
|
|
|
tableLevel int
|
2024-04-01 19:27:10 +00:00
|
|
|
codeLines int
|
2024-04-01 18:59:32 +00:00
|
|
|
tableHeaderWritten bool
|
2024-04-01 19:27:10 +00:00
|
|
|
inCodeBlock bool
|
2024-04-01 16:10:52 +00:00
|
|
|
}
|
|
|
|
|
2024-03-31 19:01:47 +00:00
|
|
|
// Render creates HTML from the supplied markdown text.
|
|
|
|
func Render(markdown string) string {
|
2024-03-31 21:08:01 +00:00
|
|
|
var (
|
2024-04-01 16:10:52 +00:00
|
|
|
r renderer
|
2024-03-31 21:08:01 +00:00
|
|
|
i = 0
|
|
|
|
lineStart = 0
|
|
|
|
)
|
|
|
|
|
|
|
|
for {
|
|
|
|
if i > len(markdown) {
|
2024-04-01 18:04:20 +00:00
|
|
|
r.closeAll()
|
2024-04-01 16:10:52 +00:00
|
|
|
|
|
|
|
for range r.quoteLevel {
|
|
|
|
r.out.WriteString("</blockquote>")
|
|
|
|
}
|
|
|
|
|
|
|
|
return r.out.String()
|
2024-03-31 21:08:01 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
if i != len(markdown) && markdown[i] != '\n' {
|
|
|
|
i++
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
line := markdown[lineStart:i]
|
|
|
|
lineStart = i + 1
|
|
|
|
i++
|
|
|
|
|
2024-04-01 16:10:52 +00:00
|
|
|
r.processLine(line)
|
|
|
|
}
|
|
|
|
}
|
2024-03-31 21:08:01 +00:00
|
|
|
|
2024-04-01 16:10:52 +00:00
|
|
|
func (r *renderer) processLine(line string) {
|
2024-04-01 19:27:10 +00:00
|
|
|
if r.inCodeBlock {
|
|
|
|
if strings.HasPrefix(line, "```") {
|
|
|
|
r.out.WriteString("</code></pre>")
|
|
|
|
r.inCodeBlock = false
|
|
|
|
r.codeLines = 0
|
|
|
|
} else {
|
|
|
|
if r.codeLines != 0 {
|
|
|
|
r.out.WriteByte('\n')
|
|
|
|
}
|
|
|
|
|
|
|
|
r.out.WriteString(html.EscapeString(line))
|
|
|
|
r.codeLines++
|
|
|
|
}
|
|
|
|
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2024-04-01 16:10:52 +00:00
|
|
|
newQuoteLevel := 0
|
2024-03-31 21:08:01 +00:00
|
|
|
|
2024-04-01 16:10:52 +00:00
|
|
|
for strings.HasPrefix(line, ">") {
|
|
|
|
line = strings.TrimSpace(line[1:])
|
|
|
|
newQuoteLevel++
|
|
|
|
}
|
2024-03-31 21:08:01 +00:00
|
|
|
|
2024-04-01 16:10:52 +00:00
|
|
|
if newQuoteLevel > r.quoteLevel {
|
|
|
|
r.closeParagraphs()
|
2024-03-31 21:08:01 +00:00
|
|
|
|
2024-04-01 16:10:52 +00:00
|
|
|
for range newQuoteLevel - r.quoteLevel {
|
|
|
|
r.out.WriteString("<blockquote>")
|
2024-03-31 21:08:01 +00:00
|
|
|
}
|
2024-04-01 16:10:52 +00:00
|
|
|
} else if newQuoteLevel < r.quoteLevel {
|
|
|
|
r.closeParagraphs()
|
|
|
|
|
|
|
|
for range r.quoteLevel - newQuoteLevel {
|
|
|
|
r.out.WriteString("</blockquote>")
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
r.quoteLevel = newQuoteLevel
|
|
|
|
|
2024-04-01 18:04:20 +00:00
|
|
|
if len(line) == 0 {
|
|
|
|
r.closeAll()
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
switch line[0] {
|
|
|
|
case '#':
|
|
|
|
r.closeAll()
|
2024-04-01 16:10:52 +00:00
|
|
|
space := strings.IndexByte(line, ' ')
|
|
|
|
|
|
|
|
if space > 0 && space <= 6 {
|
|
|
|
r.out.WriteString(headerStart[space-1])
|
|
|
|
r.writeText(line[space+1:])
|
|
|
|
r.out.WriteString(headerEnd[space-1])
|
|
|
|
}
|
|
|
|
|
|
|
|
return
|
|
|
|
|
2024-04-01 18:04:20 +00:00
|
|
|
case '-', '*':
|
2024-04-01 16:10:52 +00:00
|
|
|
r.closeParagraphs()
|
2024-04-01 18:04:20 +00:00
|
|
|
line = strings.TrimSpace(line[1:])
|
|
|
|
|
|
|
|
if r.listLevel == 0 {
|
|
|
|
r.out.WriteString("<ul>")
|
|
|
|
r.listLevel++
|
|
|
|
}
|
|
|
|
|
|
|
|
r.out.WriteString("<li>")
|
|
|
|
r.writeText(line)
|
|
|
|
r.out.WriteString("</li>")
|
2024-04-01 16:10:52 +00:00
|
|
|
return
|
2024-04-01 18:59:32 +00:00
|
|
|
|
2024-04-01 19:27:10 +00:00
|
|
|
case '`':
|
|
|
|
if strings.HasPrefix(line, "```") {
|
|
|
|
language := line[3:]
|
|
|
|
|
|
|
|
if !r.inCodeBlock {
|
|
|
|
if language != "" {
|
|
|
|
r.out.WriteString("<pre><code class=\"language-")
|
|
|
|
r.out.WriteString(html.EscapeString(language))
|
|
|
|
r.out.WriteString("\">")
|
|
|
|
} else {
|
|
|
|
r.out.WriteString("<pre><code>")
|
|
|
|
}
|
|
|
|
|
|
|
|
r.inCodeBlock = true
|
|
|
|
}
|
|
|
|
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2024-04-01 18:59:32 +00:00
|
|
|
case '|':
|
|
|
|
r.closeParagraphs()
|
|
|
|
line = line[1:]
|
|
|
|
|
|
|
|
if r.tableLevel == 0 {
|
|
|
|
r.out.WriteString("<table><thead>")
|
|
|
|
r.tableLevel++
|
|
|
|
}
|
|
|
|
|
|
|
|
column := 0
|
|
|
|
|
|
|
|
for {
|
|
|
|
pipe := strings.IndexByte(line, '|')
|
|
|
|
|
|
|
|
if pipe == -1 {
|
|
|
|
r.out.WriteString("</tr>")
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
content := strings.TrimSpace(line[:pipe])
|
|
|
|
|
|
|
|
if strings.HasPrefix(content, "---") {
|
|
|
|
r.out.WriteString("</thead><tbody>")
|
|
|
|
r.tableHeaderWritten = true
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
if column == 0 {
|
|
|
|
r.out.WriteString("<tr>")
|
|
|
|
}
|
|
|
|
|
|
|
|
if r.tableHeaderWritten {
|
|
|
|
r.out.WriteString("<td>")
|
|
|
|
r.writeText(content)
|
|
|
|
r.out.WriteString("</td>")
|
|
|
|
} else {
|
|
|
|
r.out.WriteString("<th>")
|
|
|
|
r.writeText(content)
|
|
|
|
r.out.WriteString("</th>")
|
|
|
|
}
|
|
|
|
|
|
|
|
line = line[pipe+1:]
|
|
|
|
column++
|
|
|
|
}
|
2024-04-01 16:10:52 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
if r.paragraphLevel == 0 {
|
|
|
|
r.out.WriteString("<p>")
|
|
|
|
r.paragraphLevel++
|
|
|
|
r.writeText(line)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
r.out.WriteByte(' ')
|
|
|
|
r.writeText(line)
|
|
|
|
}
|
|
|
|
|
2024-04-01 18:04:20 +00:00
|
|
|
// closeAll closes all open tags.
|
|
|
|
func (r *renderer) closeAll() {
|
|
|
|
r.closeLists()
|
|
|
|
r.closeParagraphs()
|
2024-04-01 18:59:32 +00:00
|
|
|
r.closeTables()
|
2024-04-01 18:04:20 +00:00
|
|
|
}
|
|
|
|
|
2024-04-01 16:10:52 +00:00
|
|
|
// closeParagraphs closes open paragraphs.
|
|
|
|
func (r *renderer) closeParagraphs() {
|
|
|
|
for range r.paragraphLevel {
|
|
|
|
r.out.WriteString("</p>")
|
2024-03-31 21:08:01 +00:00
|
|
|
}
|
2024-04-01 16:10:52 +00:00
|
|
|
|
|
|
|
r.paragraphLevel = 0
|
2024-03-31 19:01:47 +00:00
|
|
|
}
|
2024-04-01 09:54:14 +00:00
|
|
|
|
2024-04-01 18:04:20 +00:00
|
|
|
// closeLists closes open lists.
|
|
|
|
func (r *renderer) closeLists() {
|
|
|
|
for range r.listLevel {
|
|
|
|
r.out.WriteString("</ul>")
|
|
|
|
}
|
|
|
|
|
|
|
|
r.listLevel = 0
|
|
|
|
}
|
|
|
|
|
2024-04-01 18:59:32 +00:00
|
|
|
// closeTables closes open tables.
|
|
|
|
func (r *renderer) closeTables() {
|
|
|
|
for range r.tableLevel {
|
|
|
|
r.out.WriteString("</tbody></table>")
|
|
|
|
}
|
|
|
|
|
|
|
|
r.tableLevel = 0
|
|
|
|
r.tableHeaderWritten = false
|
|
|
|
}
|
|
|
|
|
2024-04-01 16:10:52 +00:00
|
|
|
// writeText converts inline markdown to HTML.
|
|
|
|
func (r *renderer) writeText(markdown string) {
|
2024-04-01 09:54:14 +00:00
|
|
|
var (
|
|
|
|
i = 0
|
|
|
|
tokenStart = 0
|
|
|
|
)
|
|
|
|
|
|
|
|
var (
|
|
|
|
textStart = -1
|
|
|
|
textEnd = -1
|
|
|
|
urlStart = -1
|
|
|
|
parentheses = 0
|
|
|
|
)
|
|
|
|
|
|
|
|
for {
|
2024-04-01 16:10:52 +00:00
|
|
|
if i == len(markdown) {
|
|
|
|
r.out.WriteString(html.EscapeString(markdown[tokenStart:]))
|
2024-04-01 09:54:14 +00:00
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2024-04-01 16:10:52 +00:00
|
|
|
c := markdown[i]
|
2024-04-01 09:54:14 +00:00
|
|
|
|
|
|
|
switch c {
|
|
|
|
case '[':
|
2024-04-01 16:10:52 +00:00
|
|
|
r.out.WriteString(html.EscapeString(markdown[tokenStart:i]))
|
2024-04-01 09:54:14 +00:00
|
|
|
tokenStart = i
|
|
|
|
textStart = i
|
|
|
|
case ']':
|
|
|
|
textEnd = i
|
|
|
|
case '(':
|
|
|
|
if parentheses == 0 {
|
|
|
|
urlStart = i
|
|
|
|
}
|
|
|
|
|
|
|
|
parentheses++
|
|
|
|
case ')':
|
|
|
|
parentheses--
|
|
|
|
|
|
|
|
if parentheses == 0 && textStart >= 0 && textEnd >= 0 && urlStart >= 0 {
|
2024-04-01 16:10:52 +00:00
|
|
|
linkText := markdown[textStart+1 : textEnd]
|
|
|
|
linkURL := markdown[urlStart+1 : i]
|
2024-04-01 09:54:14 +00:00
|
|
|
|
2024-04-01 16:10:52 +00:00
|
|
|
r.out.WriteString("<a href=\"")
|
|
|
|
r.out.WriteString(sanitizeURL(linkURL))
|
|
|
|
r.out.WriteString("\">")
|
|
|
|
r.out.WriteString(html.EscapeString(linkText))
|
|
|
|
r.out.WriteString("</a>")
|
2024-04-01 09:54:14 +00:00
|
|
|
|
|
|
|
textStart = -1
|
|
|
|
textEnd = -1
|
|
|
|
urlStart = -1
|
|
|
|
|
|
|
|
tokenStart = i + 1
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
i++
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-04-01 18:04:20 +00:00
|
|
|
// sanitizeURL makes a URL safe to use as the value for a `href` attribute.
|
2024-04-01 16:10:52 +00:00
|
|
|
func sanitizeURL(linkURL string) string {
|
2024-04-01 17:34:02 +00:00
|
|
|
linkURL = strings.TrimSpace(linkURL)
|
|
|
|
|
2024-04-01 09:54:14 +00:00
|
|
|
if strings.HasPrefix(strings.ToLower(linkURL), "javascript:") {
|
|
|
|
return ""
|
|
|
|
}
|
|
|
|
|
|
|
|
return html.EscapeString(linkURL)
|
|
|
|
}
|