Skip to content
quarry Artifact [3c6e926f92]
Login

Artifact [3c6e926f92]

Artifact 3c6e926f92517d119ae863dd69ecb5f09557c27d8b6c7dcdbbeb1d09e8391ba0:


// outline.go derives a document's heading structure from ALREADY-RENDERED,
// already-sanitized HTML, and gives every heading an anchor on the way
// past. It works on the output rather than on either parser because the
// two wiki formats disagree about headings: goldmark emits ids for
// markdown (WithAutoHeadingID), while Fossil wiki passes raw HTML
// through untouched, so `<h1>Cookbook</h1>` written by hand arrives with
// no id at all. Half of a fossil-born corpus is that second kind — 28 of
// fossil's own 83 wiki pages — and a page whose headings cannot be
// linked to cannot have a table of contents either.
package markup

import (
	"html"
	"regexp"
	"strconv"
	"strings"
)

// Heading is one entry in a document's outline.
type Heading struct {
	Level int    `json:"level"`
	ID    string `json:"id"`
	Text  string `json:"text"`
}

var (
	// Opening tag only: Go's regexp is RE2 and has no backreference, so
	// the closing tag is found by scanning for the level this one
	// opened rather than by matching \1. That is the more honest read
	// anyway — it cannot pair an <h2> with a stray </h3>.
	headingOpen = regexp.MustCompile(`(?i)<h([1-6])([^>]*)>`)
	idAttr      = regexp.MustCompile(`(?i)\bid\s*=\s*"([^"]*)"`)
	anyTag      = regexp.MustCompile(`(?s)<[^>]*>`)
	notSlug     = regexp.MustCompile(`[^a-z0-9]+`)
)

// Outline returns h with every heading carrying an id, plus the outline
// those ids address. An id already present is kept: goldmark's slugs are
// what existing links point at, and rewriting them would break every
// deep link ever shared into a markdown page.
//
// Levels are reported as written. Callers decide which to show; this
// does not collapse or renumber them, because a document that opens at
// h3 is still a document with a shape.
func Outline(h []byte) ([]byte, []Heading) {
	src := string(h)
	lower := strings.ToLower(src)
	var out []Heading
	var b strings.Builder
	b.Grow(len(src) + 256)
	// One namespace for minted AND pre-existing ids, so a hand-written
	// heading cannot be handed the slug goldmark already used above it.
	seen := map[string]bool{}

	for pos := 0; pos < len(src); {
		loc := headingOpen.FindStringSubmatchIndex(src[pos:])
		if loc == nil {
			b.WriteString(src[pos:])
			break
		}
		open, afterOpen := pos+loc[0], pos+loc[1]
		level, _ := strconv.Atoi(src[pos+loc[2] : pos+loc[3]])
		attrs := src[pos+loc[4] : pos+loc[5]]

		shut := strings.Index(lower[afterOpen:], "</h"+strconv.Itoa(level))
		if shut < 0 {
			// An unclosed heading is not a heading we can address.
			b.WriteString(src[pos:afterOpen])
			pos = afterOpen
			continue
		}
		inner := src[afterOpen : afterOpen+shut]

		// The label is the heading's text with any markup inside it
		// stripped: a heading may legitimately hold <code> or <em>, and
		// a contents entry wants the words, not the tags.
		text := strings.TrimSpace(html.UnescapeString(anyTag.ReplaceAllString(inner, "")))

		b.WriteString(src[pos:open])
		if got := idAttr.FindStringSubmatch(attrs); got != nil && got[1] != "" {
			// An id already present is kept whatever else claims it:
			// existing deep links point at it.
			seen[got[1]] = true
			out = append(out, Heading{Level: level, ID: got[1], Text: text})
			b.WriteString(src[open:afterOpen])
		} else {
			id := uniqueID(slugify(text), seen)
			seen[id] = true
			out = append(out, Heading{Level: level, ID: id, Text: text})
			b.WriteString("<h" + strconv.Itoa(level) + ` id="` + id + `"` + attrs + ">")
		}
		b.WriteString(inner)
		pos = afterOpen + shut
	}
	return []byte(b.String()), out
}

// uniqueID suffixes until the slug is free, GitHub's -1, -2 convention.
func uniqueID(base string, seen map[string]bool) string {
	if base == "" {
		// A heading of punctuation, or an image alone, still needs a
		// stable target to be linked by.
		base = "section"
	}
	id := base
	for n := 1; seen[id]; n++ {
		id = base + "-" + strconv.Itoa(n)
	}
	return id
}

// slugify is GitHub's heading-anchor shape, which is also goldmark's, so
// a hand-written fossil heading and a markdown one of the same words
// address alike.
func slugify(s string) string {
	s = strings.ToLower(strings.TrimSpace(s))
	s = notSlug.ReplaceAllString(s, "-")
	s = strings.Trim(s, "-")
	if len(s) > 120 {
		s = strings.Trim(s[:120], "-")
	}
	// The sanitizer's id pattern demands a leading alphanumeric, and a
	// heading that begins with a digit-free symbol would otherwise mint
	// an id the policy would reject on any later pass.
	if s != "" && (s[0] < 'a' || s[0] > 'z') && (s[0] < '0' || s[0] > '9') {
		s = "s" + s
	}
	return s
}