package markup
import "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
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 {
blocks = append(blocks, cur.String())
cur.Reset()
}
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
}
// 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
}