ajhahn.de
← eeco
Go 366 lines
package guide

import (
	"regexp"
	"strings"
	"unicode/utf8"
)

// ANSI styling. Kept to two attributes — bold for headings, table
// headers, and **bold** spans; faint for rule lines, code spans, link
// URLs, and fenced bodies. Layout (boxes, indents, rules) is identical
// with or without colour; only these escapes are added or omitted.
const (
	ansiBold  = "\x1b[1m"
	ansiFaint = "\x1b[2m"
	ansiReset = "\x1b[0m"
)

var (
	reCode     = regexp.MustCompile("`([^`]+)`")
	reBold     = regexp.MustCompile(`\*\*([^*]+)\*\*`)
	reLink     = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`)
	reAutolink = regexp.MustCompile(`<(https?://[^>]+)>`)
	reBullet   = regexp.MustCompile(`^(\s*)[-*] (.*)$`)
)

// Render transforms the embedded Markdown manual into a terminal-
// friendly form: box-drawing tables, styled headings, and rendered
// inline markup. colour toggles ANSI styling. Layout is identical with
// or without colour, so stripping the escapes from Render(true) yields
// Render(false). Unrecognised lines pass through verbatim, so a future
// docs/USAGE.md edit can never break the guide — at worst a new
// construct renders as plain text. The source embed is untouched;
// Text() still returns the byte-identical mirror.
func Render(colour bool) string {
	return renderManual(usage, colour)
}

func renderManual(src string, colour bool) string {
	// Normalise line endings so a CRLF checkout (Windows) renders
	// identically to an LF one — the golden is LF.
	src = strings.ReplaceAll(src, "\r\n", "\n")
	lines := strings.Split(src, "\n")
	lines = stripTopHTMLBlock(lines)
	lines = stripTrailingNavFooter(lines)
	out := make([]string, 0, len(lines))
	inFence := false
	for i := 0; i < len(lines); i++ {
		line := lines[i]
		trimmed := strings.TrimSpace(line)

		// Fence markers toggle verbatim mode and are dropped; bodies
		// are emitted as indented blocks with no inline processing.
		if strings.HasPrefix(trimmed, "```") {
			inFence = !inFence
			continue
		}
		if inFence {
			out = append(out, renderFenceLine(line, colour))
			continue
		}

		if h, ok := renderHeading(line, colour); ok {
			out = append(out, h...)
			continue
		}

		if isTableHeader(lines, i) {
			block, consumed := renderTable(lines, i, colour)
			out = append(out, block...)
			i += consumed - 1
			continue
		}

		out = append(out, renderProse(line, colour))
	}
	return strings.Join(out, "\n")
}

// stripTopHTMLBlock drops the GitHub-only header block that every
// cross-repo-fingerprint doc opens with — the centred logo + page
// title + horizontal doc-nav bar inside a `<div align="center">` —
// plus its trailing `---` separator. The block is structural noise in
// a terminal renderer (raw HTML tags would otherwise pass through as
// plain text). Anything before the first non-empty line is kept
// untouched; if the first non-empty line is not the marker, the input
// is returned unchanged.
func stripTopHTMLBlock(lines []string) []string {
	first := -1
	for i, l := range lines {
		if strings.TrimSpace(l) != "" {
			first = i
			break
		}
	}
	if first < 0 {
		return lines
	}
	if strings.TrimSpace(lines[first]) != `<div align="center">` {
		return lines
	}
	end := -1
	for i := first + 1; i < len(lines); i++ {
		if strings.TrimSpace(lines[i]) == `</div>` {
			end = i
			break
		}
	}
	if end < 0 {
		return lines
	}
	tail := end + 1
	for tail < len(lines) && strings.TrimSpace(lines[tail]) == "" {
		tail++
	}
	if tail < len(lines) && strings.TrimSpace(lines[tail]) == "---" {
		tail++
	}
	return append(lines[:first:first], lines[tail:]...)
}

// stripTrailingNavFooter drops the GitHub-only `---` + `[← Prev: X] ·
// [Next: Y →]` footer the cross-repo-fingerprint doc set closes with.
// It is structural noise in the terminal renderer (the same `eeco
// guide` user has nothing to click). The check looks at the last
// non-empty content line: if it matches the Prev/Next pattern, drop
// it together with the preceding `---` rule and any blank lines.
func stripTrailingNavFooter(lines []string) []string {
	last := len(lines) - 1
	for last >= 0 && strings.TrimSpace(lines[last]) == "" {
		last--
	}
	if last < 0 {
		return lines
	}
	footer := strings.TrimSpace(lines[last])
	if !strings.Contains(footer, "Prev:") && !strings.Contains(footer, "Next:") && !strings.Contains(footer, "Back to start") {
		return lines
	}
	cut := last
	for cut-1 >= 0 && strings.TrimSpace(lines[cut-1]) == "" {
		cut--
	}
	if cut-1 >= 0 && strings.TrimSpace(lines[cut-1]) == "---" {
		cut--
	}
	return lines[:cut]
}

// renderHeading styles an ATX heading. Level 1 and 2 gain a rule line
// underneath sized to the title; level 3+ is bold only. Returns false
// for any line that is not a `# ` heading.
func renderHeading(line string, colour bool) ([]string, bool) {
	level := 0
	for level < len(line) && line[level] == '#' {
		level++
	}
	if level == 0 || level >= len(line) || line[level] != ' ' {
		return nil, false
	}
	text := visible(strings.TrimSpace(line[level+1:]))
	styled := text
	if colour {
		styled = ansiBold + text + ansiReset
	}
	switch level {
	case 1:
		return []string{styled, dim(strings.Repeat("═", utf8.RuneCountInString(text)), colour)}, true
	case 2:
		return []string{styled, dim(strings.Repeat("─", utf8.RuneCountInString(text)), colour)}, true
	default:
		return []string{styled}, true
	}
}

// renderProse renders a non-heading, non-table, non-fenced line:
// bullet markers become a `•` glyph (indent preserved) and inline
// markup is applied. Blank and unrecognised lines pass through.
func renderProse(line string, colour bool) string {
	if m := reBullet.FindStringSubmatch(line); m != nil {
		return m[1] + "• " + renderInline(m[2], colour)
	}
	return renderInline(line, colour)
}

// renderFenceLine emits a fenced-block line verbatim, indented four
// spaces and faint when colour is on. A blank line stays blank.
func renderFenceLine(line string, colour bool) string {
	if strings.TrimSpace(line) == "" {
		return ""
	}
	indented := "    " + line
	if colour {
		return ansiFaint + indented + ansiReset
	}
	return indented
}

// renderInline applies inline Markdown: `code`, **bold**, [text](url),
// and <autolink>. Code is rendered first so markup inside a code span
// is left literal.
func renderInline(s string, colour bool) string {
	s = reCode.ReplaceAllStringFunc(s, func(m string) string {
		inner := m[1 : len(m)-1]
		if colour {
			return ansiFaint + inner + ansiReset
		}
		return inner
	})
	s = reBold.ReplaceAllStringFunc(s, func(m string) string {
		inner := m[2 : len(m)-2]
		if colour {
			return ansiBold + inner + ansiReset
		}
		return inner
	})
	s = reLink.ReplaceAllStringFunc(s, func(m string) string {
		sub := reLink.FindStringSubmatch(m)
		text, url := sub[1], sub[2]
		if colour {
			return text + " " + ansiFaint + "(" + url + ")" + ansiReset
		}
		return text + " (" + url + ")"
	})
	return reAutolink.ReplaceAllString(s, "$1")
}

// visible returns the plain visible text of an inline-markup string —
// renderInline with colour off — used for width measurement.
func visible(s string) string {
	return renderInline(s, false)
}

func dim(s string, colour bool) string {
	if colour {
		return ansiFaint + s + ansiReset
	}
	return s
}

// isTableHeader reports whether line i begins a GitHub-style table: a
// pipe row immediately followed by a `| --- |` separator.
func isTableHeader(lines []string, i int) bool {
	return i+1 < len(lines) && isTableRow(lines[i]) && isTableSep(lines[i+1])
}

func isTableRow(s string) bool {
	t := strings.TrimSpace(s)
	return len(t) >= 2 && strings.HasPrefix(t, "|") && strings.HasSuffix(t, "|")
}

func isTableSep(s string) bool {
	t := strings.TrimSpace(s)
	if !strings.HasPrefix(t, "|") || !strings.ContainsRune(t, '-') {
		return false
	}
	for _, r := range t {
		switch r {
		case '|', '-', ':', ' ', '\t':
		default:
			return false
		}
	}
	return true
}

// renderTable redraws a table block starting at lines[start] as a
// box-drawing grid. Returns the rendered lines and how many source
// lines it consumed (header + separator + body rows).
func renderTable(lines []string, start int, colour bool) ([]string, int) {
	header := parseRow(lines[start])
	rows := [][]string{header}
	consumed := 2 // header + separator
	for j := start + 2; j < len(lines) && isTableRow(lines[j]); j++ {
		rows = append(rows, parseRow(lines[j]))
		consumed++
	}

	ncols := 0
	for _, r := range rows {
		if len(r) > ncols {
			ncols = len(r)
		}
	}
	widths := make([]int, ncols)
	for _, r := range rows {
		for c := 0; c < ncols; c++ {
			if c < len(r) {
				if w := utf8.RuneCountInString(visible(r[c])); w > widths[c] {
					widths[c] = w
				}
			}
		}
	}

	border := func(left, mid, right string) string {
		var b strings.Builder
		b.WriteString(left)
		for c := 0; c < ncols; c++ {
			b.WriteString(strings.Repeat("─", widths[c]+2))
			if c < ncols-1 {
				b.WriteString(mid)
			}
		}
		b.WriteString(right)
		return b.String()
	}
	row := func(cells []string, bold bool) string {
		var b strings.Builder
		b.WriteString("│")
		for c := 0; c < ncols; c++ {
			cell := ""
			if c < len(cells) {
				cell = cells[c]
			}
			disp := renderInline(cell, colour)
			if bold && colour {
				disp = ansiBold + disp + ansiReset
			}
			padding := widths[c] - utf8.RuneCountInString(visible(cell))
			b.WriteString(" " + disp + strings.Repeat(" ", padding) + " │")
		}
		return b.String()
	}

	out := []string{border("┌", "┬", "┐"), row(rows[0], true), border("├", "┼", "┤")}
	for _, r := range rows[1:] {
		out = append(out, row(r, false))
	}
	out = append(out, border("└", "┴", "┘"))
	return out, consumed
}

// parseRow splits a table row into trimmed cells, honouring `\|` as a
// literal pipe rather than a column separator.
func parseRow(line string) []string {
	t := strings.TrimSpace(line)
	t = strings.TrimPrefix(t, "|")
	t = strings.TrimSuffix(t, "|")
	cells := splitUnescapedPipe(t)
	for i := range cells {
		cells[i] = strings.ReplaceAll(strings.TrimSpace(cells[i]), `\|`, "|")
	}
	return cells
}

func splitUnescapedPipe(s string) []string {
	var cells []string
	var b strings.Builder
	for i := 0; i < len(s); i++ {
		if s[i] == '\\' && i+1 < len(s) && s[i+1] == '|' {
			b.WriteByte('\\')
			b.WriteByte('|')
			i++
			continue
		}
		if s[i] == '|' {
			cells = append(cells, b.String())
			b.Reset()
			continue
		}
		b.WriteByte(s[i])
	}
	return append(cells, b.String())
}