Skip to content
quarry Artifact [e1db07629a]
Login

Artifact [e1db07629a]

Artifact e1db07629aa2218d5d41941c361e375a4cd89524fbc2741378c4f82ef01d21c3:


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
}