web-server/markdown.go

344 lines
9.4 KiB
Go

// this code is a fork of GitHub Flavored Markdown (https://github.com/shurcooL/github_flavored_markdown)
// with some minor alterations to support media-responsive images
/*
Package github_flavored_markdown provides a GitHub Flavored Markdown renderer
with fenced code block highlighting, clickable heading anchor links.
The functionality should be equivalent to the GitHub Markdown API endpoint specified at
https://developer.github.com/v3/markdown/#render-a-markdown-document-in-raw-mode, except
the rendering is performed locally.
See examples for how to generate a complete HTML page, including CSS styles.
*/
package main
import (
"bytes"
"fmt"
"regexp"
"sort"
"strings"
"text/template"
"github.com/microcosm-cc/bluemonday"
"github.com/russross/blackfriday"
"github.com/shurcooL/highlight_diff"
"github.com/shurcooL/highlight_go"
"github.com/shurcooL/sanitized_anchor_name"
"github.com/sourcegraph/annotate"
"github.com/sourcegraph/syntaxhighlight"
"golang.org/x/net/html"
)
// Markdown renders GitHub Flavored Markdown text.
func Markdown(text []byte, path string) []byte {
const htmlFlags = 0
renderer := &renderer{
Html: blackfriday.HtmlRenderer(htmlFlags, "", "").(*blackfriday.Html), path: path}
unsanitized := blackfriday.Markdown(text, renderer, extensions)
return unsanitized
}
// extensions for GitHub Flavored Markdown-like parsing.
const extensions = blackfriday.EXTENSION_NO_INTRA_EMPHASIS |
blackfriday.EXTENSION_TABLES |
blackfriday.EXTENSION_FENCED_CODE |
blackfriday.EXTENSION_AUTOLINK |
blackfriday.EXTENSION_STRIKETHROUGH |
blackfriday.EXTENSION_SPACE_HEADERS |
blackfriday.EXTENSION_NO_EMPTY_LINE_BEFORE_BLOCK
// policy for GitHub Flavored Markdown-like sanitization.
var policy = func() *bluemonday.Policy {
p := bluemonday.UGCPolicy()
p.AllowAttrs("class").Matching(bluemonday.SpaceSeparatedTokens).OnElements("div", "span")
p.AllowAttrs("class", "name").Matching(bluemonday.SpaceSeparatedTokens).OnElements("a")
p.AllowAttrs("rel").Matching(regexp.MustCompile(`^nofollow$`)).OnElements("a")
p.AllowAttrs("aria-hidden").Matching(regexp.MustCompile(`^true$`)).OnElements("a")
p.AllowAttrs("type").Matching(regexp.MustCompile(`^checkbox$`)).OnElements("input")
p.AllowAttrs("checked", "disabled").Matching(regexp.MustCompile(`^$`)).OnElements("input")
p.AllowDataURIImages()
return p
}()
type renderer struct {
*blackfriday.Html
path string
}
// GitHub Flavored Markdown heading with clickable and hidden anchor.
func (*renderer) Header(out *bytes.Buffer, text func() bool, level int, _ string) {
marker := out.Len()
doubleSpace(out)
if !text() {
out.Truncate(marker)
return
}
textHTML := out.String()[marker:]
out.Truncate(marker)
// Extract text content of the heading.
var textContent string
if node, err := html.Parse(strings.NewReader(textHTML)); err == nil {
textContent = extractText(node)
} else {
// Failed to parse HTML (probably can never happen), so just use the whole thing.
textContent = html.UnescapeString(textHTML)
}
anchorName := sanitized_anchor_name.Create(textContent)
out.WriteString(fmt.Sprintf(`<h%d><a name="%s" class="anchor" href="#%s" rel="nofollow" aria-hidden="true"><span class="octicon octicon-link"></span></a>`, level, anchorName, anchorName))
out.WriteString(textHTML)
out.WriteString(fmt.Sprintf("</h%d>\n", level))
}
func (r *renderer) Image(out *bytes.Buffer, link []byte, title []byte, alt []byte) {
writeImg := func() {
out.WriteString("<img src=\"/resize/")
leadingSlash := link[0] == '\\'
out.WriteString(r.path)
if !leadingSlash {
out.WriteByte('/')
}
attrEscape(out, link)
out.WriteString("\" alt=\"")
if len(alt) > 0 {
attrEscape(out, alt)
}
if len(title) > 0 {
out.WriteString("\" title=\"")
attrEscape(out, title)
}
out.WriteString("\" />")
}
writeSource := func() {
out.WriteString("<source srcset=\"")
attrEscape(out, link)
out.WriteString("\" media=\"(min-width: 800px)\">")
}
// link to outside of this website
if bytes.HasPrefix(link, []byte("http")) {
writeImg()
} else {
// local link; we can use the resized images to support phones
out.WriteString("<picture>")
writeSource()
writeImg()
out.WriteString("</picture>")
}
}
// extractText returns the recursive concatenation of the text content of an html node.
func extractText(n *html.Node) string {
var out string
for c := n.FirstChild; c != nil; c = c.NextSibling {
if c.Type == html.TextNode {
out += c.Data
} else {
out += extractText(c)
}
}
return out
}
// TODO: Clean up and improve this code.
// GitHub Flavored Markdown fenced code block with highlighting.
func (*renderer) BlockCode(out *bytes.Buffer, text []byte, lang string) {
doubleSpace(out)
// parse out the language name
count := 0
for _, elt := range strings.Fields(lang) {
if elt[0] == '.' {
elt = elt[1:]
}
if len(elt) == 0 {
continue
}
out.WriteString(`<div class="highlight highlight-`)
attrEscape(out, []byte(elt))
lang = elt
out.WriteString(`"><pre>`)
count++
break
}
if count == 0 {
out.WriteString("<pre><code>")
}
if highlightedCode, ok := highlightCode(text, lang); ok {
out.Write(highlightedCode)
} else {
attrEscape(out, text)
}
if count == 0 {
out.WriteString("</code></pre>\n")
} else {
out.WriteString("</pre></div>\n")
}
}
// Task List support.
func (r *renderer) ListItem(out *bytes.Buffer, text []byte, flags int) {
switch {
case bytes.HasPrefix(text, []byte("[ ] ")):
text = append([]byte(`<input type="checkbox" disabled="">`), text[3:]...)
case bytes.HasPrefix(text, []byte("[x] ")) || bytes.HasPrefix(text, []byte("[X] ")):
text = append([]byte(`<input type="checkbox" checked="" disabled="">`), text[3:]...)
}
r.Html.ListItem(out, text, flags)
}
var gfmHTMLConfig = syntaxhighlight.HTMLConfig{
String: "s",
Keyword: "k",
Comment: "c",
Type: "n",
Literal: "o",
Punctuation: "p",
Plaintext: "n",
Tag: "tag",
HTMLTag: "htm",
HTMLAttrName: "atn",
HTMLAttrValue: "atv",
Decimal: "m",
}
func highlightCode(src []byte, lang string) (highlightedCode []byte, ok bool) {
switch lang {
case "Go", "Go-unformatted":
var buf bytes.Buffer
err := highlight_go.Print(src, &buf, syntaxhighlight.HTMLPrinter(gfmHTMLConfig))
if err != nil {
return nil, false
}
return buf.Bytes(), true
case "diff":
anns, err := highlight_diff.Annotate(src)
if err != nil {
return nil, false
}
lines := bytes.Split(src, []byte("\n"))
lineStarts := make([]int, len(lines))
var offset int
for lineIndex := 0; lineIndex < len(lines); lineIndex++ {
lineStarts[lineIndex] = offset
offset += len(lines[lineIndex]) + 1
}
lastDel, lastIns := -1, -1
for lineIndex := 0; lineIndex < len(lines); lineIndex++ {
var lineFirstChar byte
if len(lines[lineIndex]) > 0 {
lineFirstChar = lines[lineIndex][0]
}
switch lineFirstChar {
case '+':
if lastIns == -1 {
lastIns = lineIndex
}
case '-':
if lastDel == -1 {
lastDel = lineIndex
}
default:
if lastDel != -1 || lastIns != -1 {
if lastDel == -1 {
lastDel = lastIns
} else if lastIns == -1 {
lastIns = lineIndex
}
beginOffsetLeft := lineStarts[lastDel]
endOffsetLeft := lineStarts[lastIns]
beginOffsetRight := lineStarts[lastIns]
endOffsetRight := lineStarts[lineIndex]
anns = append(anns, &annotate.Annotation{Start: beginOffsetLeft, End: endOffsetLeft, Left: []byte(`<span class="gd input-block">`), Right: []byte(`</span>`), WantInner: 0})
anns = append(anns, &annotate.Annotation{Start: beginOffsetRight, End: endOffsetRight, Left: []byte(`<span class="gi input-block">`), Right: []byte(`</span>`), WantInner: 0})
if '@' != lineFirstChar {
//leftContent := string(src[beginOffsetLeft:endOffsetLeft])
//rightContent := string(src[beginOffsetRight:endOffsetRight])
// This is needed to filter out the "-" and "+" at the beginning of each line from being highlighted.
// TODO: Still not completely filtered out.
leftContent := ""
for line := lastDel; line < lastIns; line++ {
leftContent += "\x00" + string(lines[line][1:]) + "\n"
}
rightContent := ""
for line := lastIns; line < lineIndex; line++ {
rightContent += "\x00" + string(lines[line][1:]) + "\n"
}
var sectionSegments [2][]*annotate.Annotation
highlight_diff.HighlightedDiffFunc(leftContent, rightContent, &sectionSegments, [2]int{beginOffsetLeft, beginOffsetRight})
anns = append(anns, sectionSegments[0]...)
anns = append(anns, sectionSegments[1]...)
}
}
lastDel, lastIns = -1, -1
}
}
sort.Sort(anns)
out, err := annotate.Annotate(src, anns, template.HTMLEscape)
if err != nil {
return nil, false
}
return out, true
default:
return nil, false
}
}
// Unexported blackfriday helpers.
func doubleSpace(out *bytes.Buffer) {
if out.Len() > 0 {
out.WriteByte('\n')
}
}
func escapeSingleChar(char byte) (string, bool) {
if char == '"' {
return "&quot;", true
}
if char == '&' {
return "&amp;", true
}
if char == '<' {
return "&lt;", true
}
if char == '>' {
return "&gt;", true
}
return "", false
}
func attrEscape(out *bytes.Buffer, src []byte) {
org := 0
for i, ch := range src {
if entity, ok := escapeSingleChar(ch); ok {
if i > org {
// copy all the normal characters since the last escape
out.Write(src[org:i])
}
org = i + 1
out.WriteString(entity)
}
}
if org < len(src) {
out.Write(src[org:])
}
}