Rewrite markdown rendering to blackfriday v2 and rewrite orgmode rendering to go-org (#8560)
* Rewrite markdown rendering to blackfriday v2.0 * Fix style * Fix go mod with golang 1.13 * Fix blackfriday v2 import * Inital orgmode renderer migration to go-org * Vendor go-org dependency * Ignore errors :/ * Update go-org to latest version * Update test * Fix go-org test * Remove unneeded code * Fix comments * Fix markdown test * Fix blackfriday regression rendering HTML blocklunny/display_deleted_branch2
parent
690a8ec502
commit
086a46994a
@ -1 +0,0 @@
|
||||
.DS_Store
|
@ -1,12 +0,0 @@
|
||||
language: go
|
||||
|
||||
go:
|
||||
- 1.7
|
||||
|
||||
before_install:
|
||||
- go get golang.org/x/tools/cmd/cover
|
||||
- go get github.com/mattn/goveralls
|
||||
|
||||
script:
|
||||
- go test -v -covermode=count -coverprofile=coverage.out
|
||||
- $HOME/gopath/bin/goveralls -coverprofile=coverage.out -service=travis-ci
|
@ -1,66 +0,0 @@
|
||||
#+TITLE: chaseadamsio/goorgeous
|
||||
|
||||
[[https://travis-ci.org/chaseadamsio/goorgeous.svg?branch=master]]
|
||||
[[https://coveralls.io/repos/github/chaseadamsio/goorgeous/badge.svg?branch=master]]
|
||||
|
||||
/goorgeous is a Go Org to HTML Parser./
|
||||
|
||||
[[file:gopher_small.gif]]
|
||||
|
||||
*Pronounced: Go? Org? Yes!*
|
||||
|
||||
#+BEGIN_QUOTE
|
||||
"Org mode is for keeping notes, maintaining TODO lists, planning projects, and authoring documents with a fast and effective plain-text system."
|
||||
|
||||
- [[orgmode.org]]
|
||||
#+END_QUOTE
|
||||
|
||||
The purpose of this package is to come as close as possible as parsing an =*.org= document into HTML, the same way one might publish [[http://orgmode.org/worg/org-tutorials/org-publish-html-tutorial.html][with org-publish-html from Emacs]].
|
||||
|
||||
* Installation
|
||||
|
||||
#+BEGIN_SRC sh
|
||||
go get -u github.com/chaseadamsio/goorgeous
|
||||
#+END_SRC
|
||||
|
||||
* Usage
|
||||
|
||||
** Org Headers
|
||||
|
||||
To retrieve the headers from a =[]byte=, call =OrgHeaders= and it will return a =map[string]interface{}=:
|
||||
|
||||
#+BEGIN_SRC go
|
||||
input := "#+title: goorgeous\n* Some Headline\n"
|
||||
out := goorgeous.OrgHeaders(input)
|
||||
#+END_SRC
|
||||
|
||||
#+BEGIN_SRC go
|
||||
map[string]interface{}{
|
||||
"title": "goorgeous"
|
||||
}
|
||||
#+END_SRC
|
||||
|
||||
** Org Content
|
||||
|
||||
After importing =github.com/chaseadamsio/goorgeous=, you can call =Org= with a =[]byte= and it will return an =html= version of the content as a =[]byte=
|
||||
|
||||
#+BEGIN_SRC go
|
||||
input := "#+TITLE: goorgeous\n* Some Headline\n"
|
||||
out := goorgeous.Org(input)
|
||||
#+END_SRC
|
||||
|
||||
=out= will be:
|
||||
|
||||
#+BEGIN_SRC html
|
||||
<h1>Some Headline</h1>/n
|
||||
#+END_SRC
|
||||
|
||||
* Why?
|
||||
|
||||
First off, I've become an unapologetic user of Emacs & ever since finding =org-mode= I use it for anything having to do with writing content, organizing my life and keeping documentation of my days/weeks/months.
|
||||
|
||||
Although I like Emacs & =emacs-lisp=, I publish all of my html sites with [[https://gohugo.io][Hugo Static Site Generator]] and wanted to be able to write my content in =org-mode= in Emacs rather than markdown.
|
||||
|
||||
Hugo's implementation of templating and speed are unmatched, so the only way I knew for sure I could continue to use Hugo and write in =org-mode= seamlessly was to write a golang parser for org content and submit a PR for Hugo to use it.
|
||||
* Acknowledgements
|
||||
I leaned heavily on russross' [[https://github.com/russross/blackfriday][blackfriday markdown renderer]] as both an example of how to write a parser (with some updates to leverage the go we know today) and reusing the blackfriday HTML Renderer so I didn't have to write my own!
|
@ -1,803 +0,0 @@
|
||||
package goorgeous
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"regexp"
|
||||
|
||||
"github.com/russross/blackfriday"
|
||||
"github.com/shurcooL/sanitized_anchor_name"
|
||||
)
|
||||
|
||||
type inlineParser func(p *parser, out *bytes.Buffer, data []byte, offset int) int
|
||||
|
||||
type footnotes struct {
|
||||
id string
|
||||
def string
|
||||
}
|
||||
|
||||
type parser struct {
|
||||
r blackfriday.Renderer
|
||||
inlineCallback [256]inlineParser
|
||||
notes []footnotes
|
||||
}
|
||||
|
||||
// NewParser returns a new parser with the inlineCallbacks required for org content
|
||||
func NewParser(renderer blackfriday.Renderer) *parser {
|
||||
p := new(parser)
|
||||
p.r = renderer
|
||||
|
||||
p.inlineCallback['='] = generateVerbatim
|
||||
p.inlineCallback['~'] = generateCode
|
||||
p.inlineCallback['/'] = generateEmphasis
|
||||
p.inlineCallback['_'] = generateUnderline
|
||||
p.inlineCallback['*'] = generateBold
|
||||
p.inlineCallback['+'] = generateStrikethrough
|
||||
p.inlineCallback['['] = generateLinkOrImg
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
// OrgCommon is the easiest way to parse a byte slice of org content and makes assumptions
|
||||
// that the caller wants to use blackfriday's HTMLRenderer with XHTML
|
||||
func OrgCommon(input []byte) []byte {
|
||||
renderer := blackfriday.HtmlRenderer(blackfriday.HTML_USE_XHTML, "", "")
|
||||
return OrgOptions(input, renderer)
|
||||
}
|
||||
|
||||
// Org is a convenience name for OrgOptions
|
||||
func Org(input []byte, renderer blackfriday.Renderer) []byte {
|
||||
return OrgOptions(input, renderer)
|
||||
}
|
||||
|
||||
// OrgOptions takes an org content byte slice and a renderer to use
|
||||
func OrgOptions(input []byte, renderer blackfriday.Renderer) []byte {
|
||||
// in the case that we need to render something in isEmpty but there isn't a new line char
|
||||
input = append(input, '\n')
|
||||
var output bytes.Buffer
|
||||
|
||||
p := NewParser(renderer)
|
||||
|
||||
scanner := bufio.NewScanner(bytes.NewReader(input))
|
||||
// used to capture code blocks
|
||||
marker := ""
|
||||
syntax := ""
|
||||
listType := ""
|
||||
inParagraph := false
|
||||
inList := false
|
||||
inTable := false
|
||||
inFixedWidthArea := false
|
||||
var tmpBlock bytes.Buffer
|
||||
|
||||
for scanner.Scan() {
|
||||
data := scanner.Bytes()
|
||||
|
||||
if !isEmpty(data) && isComment(data) || IsKeyword(data) {
|
||||
switch {
|
||||
case inList:
|
||||
if tmpBlock.Len() > 0 {
|
||||
p.generateList(&output, tmpBlock.Bytes(), listType)
|
||||
}
|
||||
inList = false
|
||||
listType = ""
|
||||
tmpBlock.Reset()
|
||||
case inTable:
|
||||
if tmpBlock.Len() > 0 {
|
||||
p.generateTable(&output, tmpBlock.Bytes())
|
||||
}
|
||||
inTable = false
|
||||
tmpBlock.Reset()
|
||||
case inParagraph:
|
||||
if tmpBlock.Len() > 0 {
|
||||
p.generateParagraph(&output, tmpBlock.Bytes()[:len(tmpBlock.Bytes())-1])
|
||||
}
|
||||
inParagraph = false
|
||||
tmpBlock.Reset()
|
||||
case inFixedWidthArea:
|
||||
if tmpBlock.Len() > 0 {
|
||||
tmpBlock.WriteString("</pre>\n")
|
||||
output.Write(tmpBlock.Bytes())
|
||||
}
|
||||
inFixedWidthArea = false
|
||||
tmpBlock.Reset()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
switch {
|
||||
case isEmpty(data):
|
||||
switch {
|
||||
case inList:
|
||||
if tmpBlock.Len() > 0 {
|
||||
p.generateList(&output, tmpBlock.Bytes(), listType)
|
||||
}
|
||||
inList = false
|
||||
listType = ""
|
||||
tmpBlock.Reset()
|
||||
case inTable:
|
||||
if tmpBlock.Len() > 0 {
|
||||
p.generateTable(&output, tmpBlock.Bytes())
|
||||
}
|
||||
inTable = false
|
||||
tmpBlock.Reset()
|
||||
case inParagraph:
|
||||
if tmpBlock.Len() > 0 {
|
||||
p.generateParagraph(&output, tmpBlock.Bytes()[:len(tmpBlock.Bytes())-1])
|
||||
}
|
||||
inParagraph = false
|
||||
tmpBlock.Reset()
|
||||
case inFixedWidthArea:
|
||||
if tmpBlock.Len() > 0 {
|
||||
tmpBlock.WriteString("</pre>\n")
|
||||
output.Write(tmpBlock.Bytes())
|
||||
}
|
||||
inFixedWidthArea = false
|
||||
tmpBlock.Reset()
|
||||
case marker != "":
|
||||
tmpBlock.WriteByte('\n')
|
||||
default:
|
||||
continue
|
||||
}
|
||||
case isPropertyDrawer(data) || marker == "PROPERTIES":
|
||||
if marker == "" {
|
||||
marker = "PROPERTIES"
|
||||
}
|
||||
if bytes.Equal(data, []byte(":END:")) {
|
||||
marker = ""
|
||||
}
|
||||
continue
|
||||
case isBlock(data) || marker != "":
|
||||
matches := reBlock.FindSubmatch(data)
|
||||
if len(matches) > 0 {
|
||||
if string(matches[1]) == "END" {
|
||||
switch marker {
|
||||
case "QUOTE":
|
||||
var tmpBuf bytes.Buffer
|
||||
p.inline(&tmpBuf, tmpBlock.Bytes())
|
||||
p.r.BlockQuote(&output, tmpBuf.Bytes())
|
||||
case "CENTER":
|
||||
var tmpBuf bytes.Buffer
|
||||
output.WriteString("<center>\n")
|
||||
p.inline(&tmpBuf, tmpBlock.Bytes())
|
||||
output.Write(tmpBuf.Bytes())
|
||||
output.WriteString("</center>\n")
|
||||
default:
|
||||
tmpBlock.WriteByte('\n')
|
||||
p.r.BlockCode(&output, tmpBlock.Bytes(), syntax)
|
||||
}
|
||||
marker = ""
|
||||
tmpBlock.Reset()
|
||||
continue
|
||||
}
|
||||
|
||||
}
|
||||
if marker != "" {
|
||||
if marker != "SRC" && marker != "EXAMPLE" {
|
||||
var tmpBuf bytes.Buffer
|
||||
tmpBuf.Write([]byte("<p>\n"))
|
||||
p.inline(&tmpBuf, data)
|
||||
tmpBuf.WriteByte('\n')
|
||||
tmpBuf.Write([]byte("</p>\n"))
|
||||
tmpBlock.Write(tmpBuf.Bytes())
|
||||
|
||||
} else {
|
||||
tmpBlock.WriteByte('\n')
|
||||
tmpBlock.Write(data)
|
||||
}
|
||||
|
||||
} else {
|
||||
marker = string(matches[2])
|
||||
syntax = string(matches[3])
|
||||
}
|
||||
case isFootnoteDef(data):
|
||||
matches := reFootnoteDef.FindSubmatch(data)
|
||||
for i := range p.notes {
|
||||
if p.notes[i].id == string(matches[1]) {
|
||||
p.notes[i].def = string(matches[2])
|
||||
}
|
||||
}
|
||||
case isTable(data):
|
||||
if inTable != true {
|
||||
inTable = true
|
||||
}
|
||||
tmpBlock.Write(data)
|
||||
tmpBlock.WriteByte('\n')
|
||||
case IsKeyword(data):
|
||||
continue
|
||||
case isComment(data):
|
||||
p.generateComment(&output, data)
|
||||
case isHeadline(data):
|
||||
p.generateHeadline(&output, data)
|
||||
case isDefinitionList(data):
|
||||
if inList != true {
|
||||
listType = "dl"
|
||||
inList = true
|
||||
}
|
||||
var work bytes.Buffer
|
||||
flags := blackfriday.LIST_TYPE_DEFINITION
|
||||
matches := reDefinitionList.FindSubmatch(data)
|
||||
flags |= blackfriday.LIST_TYPE_TERM
|
||||
p.inline(&work, matches[1])
|
||||
p.r.ListItem(&tmpBlock, work.Bytes(), flags)
|
||||
work.Reset()
|
||||
flags &= ^blackfriday.LIST_TYPE_TERM
|
||||
p.inline(&work, matches[2])
|
||||
p.r.ListItem(&tmpBlock, work.Bytes(), flags)
|
||||
case isUnorderedList(data):
|
||||
if inList != true {
|
||||
listType = "ul"
|
||||
inList = true
|
||||
}
|
||||
matches := reUnorderedList.FindSubmatch(data)
|
||||
var work bytes.Buffer
|
||||
p.inline(&work, matches[2])
|
||||
p.r.ListItem(&tmpBlock, work.Bytes(), 0)
|
||||
case isOrderedList(data):
|
||||
if inList != true {
|
||||
listType = "ol"
|
||||
inList = true
|
||||
}
|
||||
matches := reOrderedList.FindSubmatch(data)
|
||||
var work bytes.Buffer
|
||||
tmpBlock.WriteString("<li")
|
||||
if len(matches[2]) > 0 {
|
||||
tmpBlock.WriteString(" value=\"")
|
||||
tmpBlock.Write(matches[2])
|
||||
tmpBlock.WriteString("\"")
|
||||
matches[3] = matches[3][1:]
|
||||
}
|
||||
p.inline(&work, matches[3])
|
||||
tmpBlock.WriteString(">")
|
||||
tmpBlock.Write(work.Bytes())
|
||||
tmpBlock.WriteString("</li>\n")
|
||||
case isHorizontalRule(data):
|
||||
p.r.HRule(&output)
|
||||
case isExampleLine(data):
|
||||
if inParagraph == true {
|
||||
if len(tmpBlock.Bytes()) > 0 {
|
||||
p.generateParagraph(&output, tmpBlock.Bytes()[:len(tmpBlock.Bytes())-1])
|
||||
inParagraph = false
|
||||
}
|
||||
tmpBlock.Reset()
|
||||
}
|
||||
if inFixedWidthArea != true {
|
||||
tmpBlock.WriteString("<pre class=\"example\">\n")
|
||||
inFixedWidthArea = true
|
||||
}
|
||||
matches := reExampleLine.FindSubmatch(data)
|
||||
tmpBlock.Write(matches[1])
|
||||
tmpBlock.WriteString("\n")
|
||||
break
|
||||
default:
|
||||
if inParagraph == false {
|
||||
inParagraph = true
|
||||
if inFixedWidthArea == true {
|
||||
if tmpBlock.Len() > 0 {
|
||||
tmpBlock.WriteString("</pre>")
|
||||
output.Write(tmpBlock.Bytes())
|
||||
}
|
||||
inFixedWidthArea = false
|
||||
tmpBlock.Reset()
|
||||
}
|
||||
}
|
||||
tmpBlock.Write(data)
|
||||
tmpBlock.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
|
||||
if len(tmpBlock.Bytes()) > 0 {
|
||||
if inParagraph == true {
|
||||
p.generateParagraph(&output, tmpBlock.Bytes()[:len(tmpBlock.Bytes())-1])
|
||||
} else if inFixedWidthArea == true {
|
||||
tmpBlock.WriteString("</pre>\n")
|
||||
output.Write(tmpBlock.Bytes())
|
||||
}
|
||||
}
|
||||
|
||||
// Writing footnote def. list
|
||||
if len(p.notes) > 0 {
|
||||
flags := blackfriday.LIST_ITEM_BEGINNING_OF_LIST
|
||||
p.r.Footnotes(&output, func() bool {
|
||||
for i := range p.notes {
|
||||
p.r.FootnoteItem(&output, []byte(p.notes[i].id), []byte(p.notes[i].def), flags)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
return output.Bytes()
|
||||
}
|
||||
|
||||
// Org Syntax has been broken up into 4 distinct sections based on
|
||||
// the org-syntax draft (http://orgmode.org/worg/dev/org-syntax.html):
|
||||
// - Headlines
|
||||
// - Greater Elements
|
||||
// - Elements
|
||||
// - Objects
|
||||
|
||||
// Headlines
|
||||
func isHeadline(data []byte) bool {
|
||||
if !charMatches(data[0], '*') {
|
||||
return false
|
||||
}
|
||||
level := 0
|
||||
for level < 6 && charMatches(data[level], '*') {
|
||||
level++
|
||||
}
|
||||
return charMatches(data[level], ' ')
|
||||
}
|
||||
|
||||
func (p *parser) generateHeadline(out *bytes.Buffer, data []byte) {
|
||||
level := 1
|
||||
status := ""
|
||||
priority := ""
|
||||
|
||||
for level < 6 && data[level] == '*' {
|
||||
level++
|
||||
}
|
||||
|
||||
start := skipChar(data, level, ' ')
|
||||
|
||||
data = data[start:]
|
||||
i := 0
|
||||
|
||||
// Check if has a status so it can be rendered as a separate span that can be hidden or
|
||||
// modified with CSS classes
|
||||
if hasStatus(data[i:4]) {
|
||||
status = string(data[i:4])
|
||||
i += 5 // one extra character for the next whitespace
|
||||
}
|
||||
|
||||
// Check if the next byte is a priority marker
|
||||
if data[i] == '[' && hasPriority(data[i+1]) {
|
||||
priority = string(data[i+1])
|
||||
i += 4 // for "[c]" + ' '
|
||||
}
|
||||
|
||||
tags, tagsFound := findTags(data, i)
|
||||
|
||||
headlineID := sanitized_anchor_name.Create(string(data[i:]))
|
||||
|
||||
generate := func() bool {
|
||||
dataEnd := len(data)
|
||||
if tagsFound > 0 {
|
||||
dataEnd = tagsFound
|
||||
}
|
||||
|
||||
headline := bytes.TrimRight(data[i:dataEnd], " \t")
|
||||
|
||||
if status != "" {
|
||||
out.WriteString("<span class=\"todo " + status + "\">" + status + "</span>")
|
||||
out.WriteByte(' ')
|
||||
}
|
||||
|
||||
if priority != "" {
|
||||
out.WriteString("<span class=\"priority " + priority + "\">[" + priority + "]</span>")
|
||||
out.WriteByte(' ')
|
||||
}
|
||||
|
||||
p.inline(out, headline)
|
||||
|
||||
if tagsFound > 0 {
|
||||
for _, tag := range tags {
|
||||
out.WriteByte(' ')
|
||||
out.WriteString("<span class=\"tags " + tag + "\">" + tag + "</span>")
|
||||
out.WriteByte(' ')
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
p.r.Header(out, generate, level, headlineID)
|
||||
}
|
||||
|
||||
func hasStatus(data []byte) bool {
|
||||
return bytes.Contains(data, []byte("TODO")) || bytes.Contains(data, []byte("DONE"))
|
||||
}
|
||||
|
||||
func hasPriority(char byte) bool {
|
||||
return (charMatches(char, 'A') || charMatches(char, 'B') || charMatches(char, 'C'))
|
||||
}
|
||||
|
||||
func findTags(data []byte, start int) ([]string, int) {
|
||||
tags := []string{}
|
||||
tagOpener := 0
|
||||
tagMarker := tagOpener
|
||||
for tIdx := start; tIdx < len(data); tIdx++ {
|
||||
if tagMarker > 0 && data[tIdx] == ':' {
|
||||
tags = append(tags, string(data[tagMarker+1:tIdx]))
|
||||
tagMarker = tIdx
|
||||
}
|
||||
if data[tIdx] == ':' && tagOpener == 0 && data[tIdx-1] == ' ' {
|
||||
tagMarker = tIdx
|
||||
tagOpener = tIdx
|
||||
}
|
||||
}
|
||||
return tags, tagOpener
|
||||
}
|
||||
|
||||
// Greater Elements
|
||||
// ~~ Definition Lists
|
||||
var reDefinitionList = regexp.MustCompile(`^\s*-\s+(.+?)\s+::\s+(.*)`)
|
||||
|
||||
func isDefinitionList(data []byte) bool {
|
||||
return reDefinitionList.Match(data)
|
||||
}
|
||||
|
||||
// ~~ Example lines
|
||||
var reExampleLine = regexp.MustCompile(`^\s*:\s(\s*.*)|^\s*:$`)
|
||||
|
||||
func isExampleLine(data []byte) bool {
|
||||
return reExampleLine.Match(data)
|
||||
}
|
||||
|
||||
// ~~ Ordered Lists
|
||||
var reOrderedList = regexp.MustCompile(`^(\s*)\d+\.\s+\[?@?(\d*)\]?(.+)`)
|
||||
|
||||
func isOrderedList(data []byte) bool {
|
||||
return reOrderedList.Match(data)
|
||||
}
|
||||
|
||||
// ~~ Unordered Lists
|
||||
var reUnorderedList = regexp.MustCompile(`^(\s*)[-\+]\s+(.+)`)
|
||||
|
||||
func isUnorderedList(data []byte) bool {
|
||||
return reUnorderedList.Match(data)
|
||||
}
|
||||
|
||||
// ~~ Tables
|
||||
var reTableHeaders |