package markup
import (
"regexp"
"strings"
)
// SplitBlocks cuts markdown into the blocks a block editor edits one at
// a time: runs of lines separated by blank lines, where the blank lines
// after a block belong to it. A fenced code block (pikchr and polls
// included) and an HTML block such as <details> stay whole across the
// blank lines inside them, and an indented line after a blank one
// continues the block before it, as a list item's second paragraph
// does.
//
// The blocks always join back to src exactly, byte for byte: an edit to
// one block leaves every other byte of the document as it was, which is
// what keeps a versioned post's diff to the lines that changed.
func SplitBlocks(src string) []string {
if src == "" {
return nil
}
lines := strings.SplitAfter(src, "\n")
if lines[len(lines)-1] == "" {
lines = lines[:len(lines)-1]
}
var blocks []string
var cur strings.Builder
fence := "" // the open fence's marker run, "" outside one
html := 0 // open <details> (and like) elements
afterBlank := false
content := false // cur holds more than blank lines
list := false // cur is a list, so a later item continues it
for _, ln := range lines {
t := strings.TrimSpace(ln)
blank := t == ""
switch {
case fence != "":
if fenceClose(ln, fence) {
fence = ""
}
case blank:
default:
// A new block starts at the first non-blank line after a
// blank one, unless it is indented (a continuation) or
// something that spans blank lines is still open.
if afterBlank && html == 0 && !indented(ln) && content && !(list && listItem(ln)) {
blocks = append(blocks, cur.String())
cur.Reset()
content = false
}
if !content {
list = listItem(ln)
}
content = true
if m := fenceOpen(ln); m != "" {
fence = m
}
html += htmlDepth(t)
if html < 0 {
html = 0
}
}
afterBlank = blank && fence == ""
cur.WriteString(ln)
}
if cur.Len() > 0 {
blocks = append(blocks, cur.String())
}
return blocks
}
// listItem says a line opens a list item: -, * or + and a space, or
// a number and . or ) and a space, indented at most three spaces.
func listItem(ln string) bool {
t := strings.TrimLeft(ln, " ")
if len(ln)-len(t) > 3 || t == "" {
return false
}
if (t[0] == '-' || t[0] == '*' || t[0] == '+') && len(t) > 1 && (t[1] == ' ' || t[1] == '\t') {
return true
}
i := 0
for i < len(t) && i < 9 && t[i] >= '0' && t[i] <= '9' {
i++
}
return i > 0 && i+1 < len(t) && (t[i] == '.' || t[i] == ')') && (t[i+1] == ' ' || t[i+1] == '\t')
}
// indented says a line starts with a space or a tab.
func indented(ln string) bool {
return strings.HasPrefix(ln, " ") || strings.HasPrefix(ln, "\t")
}
// fenceOpen is the marker run of a line opening a fenced code block
// (three or more backticks or tildes, indented at most three spaces),
// or "".
func fenceOpen(ln string) string {
s := strings.TrimRight(ln, "\r\n")
trimmed := strings.TrimLeft(s, " ")
if len(s)-len(trimmed) > 3 || len(trimmed) < 3 {
return ""
}
c := trimmed[0]
if c != '`' && c != '~' {
return ""
}
n := 0
for n < len(trimmed) && trimmed[n] == c {
n++
}
if n < 3 {
return ""
}
return trimmed[:n]
}
// fenceClose says ln closes a fence opened by marker: the same
// character, at least as many, and nothing after it.
func fenceClose(ln, marker string) bool {
m := fenceOpen(ln)
return m != "" && m[0] == marker[0] && len(m) >= len(marker) &&
strings.TrimSpace(strings.TrimLeft(strings.TrimSpace(ln), string(marker[0]))) == ""
}
// htmlDepth is how many block-level HTML elements a line opens net of
// those it closes, for the elements a post wraps around blank lines.
func htmlDepth(t string) int {
if !strings.HasPrefix(t, "<") {
return 0
}
low := strings.ToLower(t)
d := 0
for _, tag := range []string{"details", "div", "table", "blockquote"} {
d += strings.Count(low, "<"+tag) - strings.Count(low, "</"+tag)
}
return d
}
// linkDef matches a link reference definition line: [label]: target.
var linkDef = regexp.MustCompile(`^ {0,3}\[[^\]]+\]:\s*\S`)
// LinkDefinitions collects the draft's link reference definitions
// (outside fenced code), so a block rendered on its own can resolve a
// [text][label] whose definition sits in another block. Appended after
// a block they render as nothing, as they do in the whole post.
func LinkDefinitions(blocks []string) string {
var defs strings.Builder
for _, b := range blocks {
fence := ""
for _, ln := range strings.SplitAfter(b, "\n") {
if fence != "" {
if fenceClose(ln, fence) {
fence = ""
}
continue
}
if m := fenceOpen(ln); m != "" {
fence = m
continue
}
if linkDef.MatchString(ln) {
defs.WriteString(strings.TrimRight(ln, "\n"))
defs.WriteString("\n")
}
}
}
return defs.String()
}