Skip to content
quarry Artifact [3f1279e49c]
Login

Artifact [3f1279e49c]

Artifact 3f1279e49cc83645880e00308b7fd0c4aa60055185cb573436d46d35cb204260:


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()
}