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 = regexp.MustCompile(`^[|+-]*$`) func isTable(data []byte) bool { return charMatches(data[0], '|') } func (p *parser) generateTable(output *bytes.Buffer, data []byte) { var table bytes.Buffer rows := bytes.Split(bytes.Trim(data, "\n"), []byte("\n")) hasTableHeaders := len(rows) > 1 if len(rows) > 1 { hasTableHeaders = reTableHeaders.Match(rows[1]) } tbodySet := false for idx, row := range rows { var rowBuff bytes.Buffer if hasTableHeaders && idx == 0 { table.WriteString("<thead>") for _, cell := range bytes.Split(row[1:len(row)-1], []byte("|")) { p.r.TableHeaderCell(&rowBuff, bytes.Trim(cell, " \t"), 0) } p.r.TableRow(&table, rowBuff.Bytes()) table.WriteString("</thead>\n") } else if hasTableHeaders && idx == 1 { continue } else { if !tbodySet { table.WriteString("<tbody>") tbodySet = true } if !reTableHeaders.Match(row) { for _, cell := range bytes.Split(row[1:len(row)-1], []byte("|")) { var cellBuff bytes.Buffer p.inline(&cellBuff, bytes.Trim(cell, " \t")) p.r.TableCell(&rowBuff, cellBuff.Bytes(), 0) } p.r.TableRow(&table, rowBuff.Bytes()) } if tbodySet && idx == len(rows)-1 { table.WriteString("</tbody>\n") tbodySet = false } } } output.WriteString("\n<table>\n") output.Write(table.Bytes()) output.WriteString("</table>\n") } // ~~ Property Drawers func isPropertyDrawer(data []byte) bool { return bytes.Equal(data, []byte(":PROPERTIES:")) } // ~~ Dynamic Blocks var reBlock = regexp.MustCompile(`^#\+(BEGIN|END)_(\w+)\s*([0-9A-Za-z_\-]*)?`) func isBlock(data []byte) bool { return reBlock.Match(data) } // ~~ Footnotes var reFootnoteDef = regexp.MustCompile(`^\[fn:([\w]+)\] +(.+)`) func isFootnoteDef(data []byte) bool { return reFootnoteDef.Match(data) } // Elements // ~~ Keywords func IsKeyword(data []byte) bool { return len(data) > 2 && charMatches(data[0], '#') && charMatches(data[1], '+') && !charMatches(data[2], ' ') } // ~~ Comments func isComment(data []byte) bool { return charMatches(data[0], '#') && charMatches(data[1], ' ') } func (p *parser) generateComment(out *bytes.Buffer, data []byte) { var work bytes.Buffer work.WriteString("<!-- ") work.Write(data[2:]) work.WriteString(" -->") work.WriteByte('\n') out.Write(work.Bytes()) } // ~~ Horizontal Rules var reHorizontalRule = regexp.MustCompile(`^\s*?-----\s?$`) func isHorizontalRule(data []byte) bool { return reHorizontalRule.Match(data) } // ~~ Paragraphs func (p *parser) generateParagraph(out *bytes.Buffer, data []byte) { generate := func() bool { p.inline(out, bytes.Trim(data, " ")) return true } p.r.Paragraph(out, generate) } func (p *parser) generateList(output *bytes.Buffer, data []byte, listType string) { generateList := func() bool { output.WriteByte('\n') p.inline(output, bytes.Trim(data, " ")) return true } switch listType { case "ul": p.r.List(output, generateList, 0) case "ol": p.r.List(output, generateList, blackfriday.LIST_TYPE_ORDERED) case "dl": p.r.List(output, generateList, blackfriday.LIST_TYPE_DEFINITION) } } // Objects func (p *parser) inline(out *bytes.Buffer, data []byte) { i, end := 0, 0 for i < len(data) { for end < len(data) && p.inlineCallback[data[end]] == nil { end++ } p.r.Entity(out, data[i:end]) if end >= len(data) { break } i = end handler := p.inlineCallback[data[i]] if consumed := handler(p, out, data, i); consumed > 0 { i += consumed end = i continue } end = i + 1 } } func isAcceptablePreOpeningChar(dataIn, data []byte, offset int) bool { if len(dataIn) == len(data) { return true } char := dataIn[offset-1] return charMatches(char, ' ') || isPreChar(char) } func isPreChar(char byte) bool { return charMatches(char, '>') || charMatches(char, '(') || charMatches(char, '{') || charMatches(char, '[') } func isAcceptablePostClosingChar(char byte) bool { return charMatches(char, ' ') || isTerminatingChar(char) } func isTerminatingChar(char byte) bool { return charMatches(char, '.') || charMatches(char, ',') || charMatches(char, '?') || charMatches(char, '!') || charMatches(char, ')') || charMatches(char, '}') || charMatches(char, ']') } func findLastCharInInline(data []byte, char byte) int { timesFound := 0 last := 0 // Start from character after the inline indicator for i := 1; i < len(data); i++ { if timesFound == 1 { break } if data[i] == char { if len(data) == i+1 || (len(data) > i+1 && isAcceptablePostClosingChar(data[i+1])) { last = i timesFound += 1 } } } return last } func generator(p *parser, out *bytes.Buffer, dataIn []byte, offset int, char byte, doInline bool, renderer func(*bytes.Buffer, []byte)) int { data := dataIn[offset:] c := byte(char) start := 1 i := start if len(data) <= 1 { return 0 } lastCharInside := findLastCharInInline(data, c) // Org mode spec says a non-whitespace character must immediately follow. // if the current char is the marker, then there's no text between, not a candidate if isSpace(data[i]) || lastCharInside == i || !isAcceptablePreOpeningChar(dataIn, data, offset) { return 0 } if lastCharInside > 0 { var work bytes.Buffer if doInline { p.inline(&work, data[start:lastCharInside]) renderer(out, work.Bytes()) } else { renderer(out, data[start:lastCharInside]) } next := lastCharInside + 1 return next } return 0 } // ~~ Text Markup func generateVerbatim(p *parser, out *bytes.Buffer, data []byte, offset int) int { return generator(p, out, data, offset, '=', false, p.r.CodeSpan) } func generateCode(p *parser, out *bytes.Buffer, data []byte, offset int) int { return generator(p, out, data, offset, '~', false, p.r.CodeSpan) } func generateEmphasis(p *parser, out *bytes.Buffer, data []byte, offset int) int { return generator(p, out, data, offset, '/', true, p.r.Emphasis) } func generateUnderline(p *parser, out *bytes.Buffer, data []byte, offset int) int { underline := func(out *bytes.Buffer, text []byte) { out.WriteString("<span style=\"text-decoration: underline;\">") out.Write(text) out.WriteString("</span>") } return generator(p, out, data, offset, '_', true, underline) } func generateBold(p *parser, out *bytes.Buffer, data []byte, offset int) int { return generator(p, out, data, offset, '*', true, p.r.DoubleEmphasis) } func generateStrikethrough(p *parser, out *bytes.Buffer, data []byte, offset int) int { return generator(p, out, data, offset, '+', true, p.r.StrikeThrough) } // ~~ Images and Links (inc. Footnote) var reLinkOrImg = regexp.MustCompile(`\[\[(.+?)\]\[?(.*?)\]?\]`) func generateLinkOrImg(p *parser, out *bytes.Buffer, data []byte, offset int) int { data = data[offset+1:] start := 1 i := start var hyperlink []byte isImage := false isFootnote := false closedLink := false hasContent := false if bytes.Equal(data[0:3], []byte("fn:")) { isFootnote = true } else if data[0] != '[' { return 0 } if bytes.Equal(data[1:6], []byte("file:")) { isImage = true } for i < len(data) { currChar := data[i] switch { case charMatches(currChar, ']') && closedLink == false: if isImage { hyperlink = data[start+5 : i] } else if isFootnote { refid := data[start+2 : i] if bytes.Equal(refid, bytes.Trim(refid, " ")) { p.notes = append(p.notes, footnotes{string(refid), "DEFINITION NOT FOUND"}) p.r.FootnoteRef(out, refid, len(p.notes)) return i + 2 } else { return 0 } } else if bytes.Equal(data[i-4:i], []byte(".org")) { orgStart := start if bytes.Equal(data[orgStart:orgStart+2], []byte("./")) { orgStart = orgStart + 1 } hyperlink = data[orgStart : i-4] } else { hyperlink = data[start:i] } closedLink = true case charMatches(currChar, '['): start = i + 1 hasContent = true case charMatches(currChar, ']') && closedLink == true && hasContent == true && isImage == true: p.r.Image(out, hyperlink, data[start:i], data[start:i]) return i + 3 case charMatches(currChar, ']') && closedLink == true && hasContent == true: var tmpBuf bytes.Buffer p.inline(&tmpBuf, data[start:i]) p.r.Link(out, hyperlink, tmpBuf.Bytes(), tmpBuf.Bytes()) return i + 3 case charMatches(currChar, ']') && closedLink == true && hasContent == false && isImage == true: p.r.Image(out, hyperlink, hyperlink, hyperlink) return i + 2 case charMatches(currChar, ']') && closedLink == true && hasContent == false: p.r.Link(out, hyperlink, hyperlink, hyperlink) return i + 2 } i++ } return 0 } // Helpers func skipChar(data []byte, start int, char byte) int { i := start for i < len(data) && charMatches(data[i], char) { i++ } return i } func isSpace(char byte) bool { return charMatches(char, ' ') } func isEmpty(data []byte) bool { if len(data) == 0 { return true } for i := 0; i < len(data) && !charMatches(data[i], '\n'); i++ { if !charMatches(data[i], ' ') && !charMatches(data[i], '\t') { return false } } return true } func charMatches(a byte, b byte) bool { return a == b }