package doc import ( "crypto/sha256" "encoding/hex" "strings" ) // Block kinds. const ( KindParagraph = "paragraph" KindHeading = "heading" KindCode = "code" KindListItem = "list_item" KindQuote = "quote" KindHTML = "html" KindTable = "table" KindBreak = "break" ) // Block is the unit of the block-level diff: a paragraph, a heading, one // list item with whatever is nested under it, a whole code block, quote or // table. type Block struct { Kind string `json:"kind"` Source string `json:"source"` Text string `json:"text"` Hash string `json:"-"` } // SplitBlocks cuts Markdown into blocks with a line scanner instead of a full // CommonMark parse. A diff needs stable block boundaries, not a render tree, // and a parser's tree loses the exact source span of fences and thematic // breaks. The rules follow CommonMark where it matters for boundaries. func SplitBlocks(body string) []Block { lines := strings.Split(body, "\n") n := len(lines) var blocks []Block emit := func(kind string, start, end int) { for end > start && isBlank(lines[end-1]) { end-- } src := strings.Join(lines[start:end], "\n") blocks = append(blocks, Block{Kind: kind, Source: src, Text: plainText(kind, src), Hash: blockHash(src)}) } for i := 0; i < n; { l := lines[i] if isBlank(l) { i++ continue } start := i switch { case fenceMarker(l) != "": f := fenceMarker(l) for i++; i < n && !closesFence(lines[i], f); i++ { } if i < n { i++ } emit(KindCode, start, i) case isATXHeading(l): i++ emit(KindHeading, start, i) case isThematicBreak(l): i++ emit(KindBreak, start, i) case isQuote(l): for i++; i < n && !isBlank(lines[i]) && (isQuote(lines[i]) || !interrupts(lines[i])); i++ { } emit(KindQuote, start, i) case isListItem(l): markerIndent, contentIndent, _ := listMarker(l) i++ for i < n { if isBlank(lines[i]) { j := i for j < n && isBlank(lines[j]) { j++ } if j < n && indentOf(lines[j]) >= contentIndent { i = j continue } break } if mi, _, ok := listMarker(lines[i]); ok && mi <= markerIndent && !isThematicBreak(lines[i]) { break } if indentOf(lines[i]) < contentIndent && interruptsList(lines[i]) { break } i++ } emit(KindListItem, start, i) case isHTMLStart(l): for i++; i < n && !isBlank(lines[i]); i++ { } emit(KindHTML, start, i) case i+1 < n && strings.Contains(l, "|") && isDelimiterRow(lines[i+1]): for i += 2; i < n && !isBlank(lines[i]) && strings.Contains(lines[i], "|"); i++ { } emit(KindTable, start, i) case indentOf(l) >= 4: for i++; i < n; { if isBlank(lines[i]) { j := i for j < n && isBlank(lines[j]) { j++ } if j < n && indentOf(lines[j]) >= 4 { i = j continue } break } if indentOf(lines[i]) < 4 { break } i++ } emit(KindCode, start, i) default: kind := KindParagraph for i++; i < n && !isBlank(lines[i]); i++ { if isSetextUnderline(lines[i]) { i++ kind = KindHeading break } if interrupts(lines[i]) { break } } emit(kind, start, i) } } return blocks } func blockHash(src string) string { sum := sha256.Sum256([]byte(strings.Join(strings.Fields(src), " "))) return hex.EncodeToString(sum[:8]) } func isBlank(l string) bool { return strings.TrimSpace(l) == "" } // indentOf counts leading columns, with tabs advancing to the next stop of 4. func indentOf(l string) int { col := 0 for _, c := range l { switch c { case ' ': col++ case '\t': col += 4 - col%4 default: return col } } return col } func fenceMarker(l string) string { if indentOf(l) > 3 { return "" } t := strings.TrimLeft(l, " \t") if len(t) < 3 || (t[0] != '`' && t[0] != '~') { return "" } n := 0 for n < len(t) && t[n] == t[0] { n++ } if n < 3 || (t[0] == '`' && strings.Contains(t[n:], "`")) { return "" } return t[:n] } func closesFence(l, open string) bool { if indentOf(l) > 3 { return false } t := strings.TrimLeft(l, " \t") n := 0 for n < len(t) && t[n] == open[0] { n++ } return n >= len(open) && strings.TrimSpace(t[n:]) == "" } func isATXHeading(l string) bool { if indentOf(l) > 3 { return false } t := strings.TrimLeft(l, " \t") n := 0 for n < len(t) && t[n] == '#' { n++ } return n >= 1 && n <= 6 && (n == len(t) || t[n] == ' ' || t[n] == '\t') } func isThematicBreak(l string) bool { if indentOf(l) > 3 { return false } t := strings.TrimSpace(l) if t == "" || !strings.ContainsRune("-*_", rune(t[0])) { return false } count := 0 for _, c := range t { switch { case c == rune(t[0]): count++ case c == ' ' || c == '\t': default: return false } } return count >= 3 } func isSetextUnderline(l string) bool { if indentOf(l) > 3 { return false } t := strings.TrimSpace(l) return t != "" && (strings.Trim(t, "=") == "" || strings.Trim(t, "-") == "") } func isQuote(l string) bool { return indentOf(l) <= 3 && strings.HasPrefix(strings.TrimLeft(l, " \t"), ">") } func isHTMLStart(l string) bool { if indentOf(l) > 3 { return false } t := strings.TrimLeft(l, " \t") if len(t) < 2 || t[0] != '<' { return false } c := t[1] return c == '/' || c == '!' || c == '?' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') } func isDelimiterRow(l string) bool { t := strings.TrimSpace(l) t = strings.TrimPrefix(t, "|") t = strings.TrimSuffix(t, "|") if t == "" { return false } for _, cell := range strings.Split(t, "|") { c := strings.TrimSpace(cell) c = strings.TrimPrefix(c, ":") c = strings.TrimSuffix(c, ":") if c == "" || strings.Trim(c, "-") != "" { return false } } return true } // listMarker reports a list item start: "- ", "* ", "+ ", "1. " or "1) ". // contentIndent is the column where the item's text begins, which nested // content must reach to stay inside the item. func listMarker(l string) (markerIndent, contentIndent int, ok bool) { markerIndent = indentOf(l) if markerIndent > 3 { return 0, 0, false } t := strings.TrimLeft(l, " \t") m := 0 switch { case t != "" && strings.ContainsRune("-*+", rune(t[0])): m = 1 default: for m < len(t) && m < 9 && t[m] >= '0' && t[m] <= '9' { m++ } if m == 0 || m >= len(t) || (t[m] != '.' && t[m] != ')') { return 0, 0, false } m++ } if m < len(t) && t[m] != ' ' && t[m] != '\t' { return 0, 0, false } spaces := 0 for m+spaces < len(t) && t[m+spaces] == ' ' { spaces++ } if spaces == 0 || spaces > 4 || m+spaces == len(t) { spaces = 1 } return markerIndent, markerIndent + m + spaces, true } func isListItem(l string) bool { _, _, ok := listMarker(l) return ok && !isThematicBreak(l) } // interrupts reports whether a line ends a paragraph without a blank line. // An ordered item interrupts only when it starts at 1, as in CommonMark, so // "2026. was a long year" stays inside its paragraph. func interrupts(l string) bool { if isATXHeading(l) || fenceMarker(l) != "" || isThematicBreak(l) || isQuote(l) || isHTMLStart(l) { return true } if !isListItem(l) { return false } t := strings.TrimLeft(l, " \t") if strings.ContainsRune("-*+", rune(t[0])) { return true } return strings.HasPrefix(t, "1.") || strings.HasPrefix(t, "1)") } func interruptsList(l string) bool { return isATXHeading(l) || fenceMarker(l) != "" || isThematicBreak(l) || isQuote(l) || isHTMLStart(l) }