Skip to content
quarry Artifact [08d944a20e]
Login

Artifact [08d944a20e]

Artifact 08d944a20eed77ec4e1331129d5ff5d507f8baef786ca0bd5b5af3a4a7248492:


// fossilwiki.go renders Fossil wiki markup — the format an artifact
// carries when its N card is absent, which is what Fossil's own
// wiki_render_by_mimetype does with a null mimetype. Half of every
// fossil-born corpus is in this format (libfossil: 217 of 443 wiki
// artifacts), so quarry cannot serve fossil repositories honestly
// without it.
//
// The language is deliberately small (fossil's src/wiki.wiki states the
// whole grammar in seven rules): blank lines break paragraphs, a
// leading "*" or "#" makes a single-level list, a two-space or tab
// indent makes an indented paragraph, [target] and [target|label] make
// links, <verbatim>/<nowiki> escape it, and ordinary HTML passes
// through. Passing HTML through is safe here for the same reason it is
// safe in the markdown path: the cleanroom sanitizer is the gate.
package markup

import (
	"regexp"
	"strings"
)

// fossilLink matches [target] and [target|label]. The target may not
// contain a newline or a closing bracket; the label runs to the bracket.
var fossilLink = regexp.MustCompile(`\[([^\]\n|]+)(?:\|([^\]\n]*))?\]`)

// verbatimBlock matches <verbatim>…</verbatim> and <nowiki>…</nowiki>,
// the two constructs that suspend wiki interpretation.
var verbatimBlock = regexp.MustCompile(`(?is)<(verbatim|nowiki)>(.*?)</(?:verbatim|nowiki)>`)

// RenderFossilWiki converts Fossil wiki markup to sanitized HTML. r may
// be nil (no reference resolution), matching Render's contract.
func RenderFossilWiki(src []byte, r Resolver) ([]byte, error) {
	text := string(src)

	// Lift verbatim/nowiki out before any wiki interpretation, leaving a
	// placeholder that cannot occur in user text; restore after.
	var held []string
	text = verbatimBlock.ReplaceAllStringFunc(text, func(m string) string {
		sub := verbatimBlock.FindStringSubmatch(m)
		body := sub[2]
		var rendered string
		if strings.EqualFold(sub[1], "verbatim") {
			// <verbatim> is preformatted; <nowiki> only suspends markup.
			rendered = "<pre>" + escapeHTML(body) + "</pre>"
		} else {
			rendered = escapeHTML(body)
		}
		held = append(held, rendered)
		return "\x00hold" + itoa(len(held)-1) + "\x00"
	})

	var out strings.Builder
	var list string // "ul", "ol", or "" when not in a list
	closeList := func() {
		if list != "" {
			out.WriteString("</" + list + ">\n")
			list = ""
		}
	}
	openList := func(kind string) {
		if list != kind {
			closeList()
			out.WriteString("<" + kind + ">\n")
			list = kind
		}
	}

	// A paragraph whose content is block-level HTML must not be wrapped
	// in <p> — "most ordinary HTML works" (rule 6) includes block tags,
	// and <p><h1>..</h1></p> is invalid.
	blockStart := regexp.MustCompile(`(?i)^\s*</?(h[1-6]|div|table|thead|tbody|tr|td|th|ul|ol|li|dl|dt|dd|pre|blockquote|hr|section|details|summary|p)\b`)

	para := []string{}
	indented := false
	flush := func() {
		if len(para) == 0 {
			return
		}
		joined := strings.Join(para, "\n")
		body := fossilInline(joined, r)
		switch {
		case blockStart.MatchString(joined):
			out.WriteString(body + "\n")
		case indented:
			out.WriteString("<blockquote><p>" + body + "</p></blockquote>\n")
		default:
			out.WriteString("<p>" + body + "</p>\n")
		}
		para = nil
		indented = false
	}

	for _, line := range strings.Split(text, "\n") {
		trimmed := strings.TrimSpace(line)
		switch {
		case trimmed == "":
			flush()
			closeList()
		case strings.HasPrefix(trimmed, "*") && !strings.HasPrefix(trimmed, "**"):
			flush()
			openList("ul")
			out.WriteString("<li>" + fossilInline(strings.TrimSpace(trimmed[1:]), r) + "</li>\n")
		case strings.HasPrefix(trimmed, "#") && !strings.HasPrefix(trimmed, "##"):
			flush()
			openList("ol")
			out.WriteString("<li>" + fossilInline(strings.TrimSpace(trimmed[1:]), r) + "</li>\n")
		default:
			closeList()
			// A paragraph's first line decides whether it is indented
			// (tab or two spaces), per rule 4.
			if len(para) == 0 && (strings.HasPrefix(line, "\t") || strings.HasPrefix(line, "  ")) {
				indented = true
			}
			para = append(para, trimmed)
		}
	}
	flush()
	closeList()

	html := out.String()
	for i, h := range held {
		html = strings.ReplaceAll(html, "\x00hold"+itoa(i)+"\x00", h)
	}
	return sanitize.SanitizeBytes([]byte(html)), nil
}

// fossilInline renders one line's inline constructs: [links] first, then
// whatever HTML the author wrote passes through to the sanitizer.
func fossilInline(s string, r Resolver) string {
	return fossilLink.ReplaceAllStringFunc(s, func(m string) string {
		sub := fossilLink.FindStringSubmatch(m)
		target, label := strings.TrimSpace(sub[1]), sub[2]
		if label == "" {
			label = target
		}
		href := resolveFossilTarget(target, r)
		if href == "" {
			if strings.HasPrefix(target, "/") {
				// A fossil site-relative route quarry has no equivalent
				// for: show the human label, not the wiki source. The
				// reader loses a link, never gains visible markup.
				return escapeHTML(strings.TrimSpace(label))
			}
			// An unknown wiki page stays literal — red-link semantics,
			// the same discipline the markdown path uses.
			return m
		}
		return `<a href="` + escapeAttr(href) + `">` + escapeHTML(strings.TrimSpace(label)) + `</a>`
	})
}

// resolveFossilTarget maps a link target to a URL: absolute URLs and
// site-relative paths pass through, hex tokens resolve as artifacts, and
// anything else is treated as a wiki page name.
func resolveFossilTarget(target string, r Resolver) string {
	switch {
	case strings.HasPrefix(target, "http://"), strings.HasPrefix(target, "https://"),
		strings.HasPrefix(target, "mailto:"):
		return target
	case strings.HasPrefix(target, "/"):
		// Fossil's own site-relative form (e.g. [/doc/trunk/README.md]).
		// Mapped onto a quarry route where one exists; "" when it does
		// not, and the caller then shows the label as text.
		if r == nil {
			return ""
		}
		return r.ResolvePath(target)
	}
	if r == nil {
		return ""
	}
	if isHexToken(target) {
		if u := r.ResolveHash(target); u != "" {
			return u
		}
	}
	return r.ResolveWiki(target)
}

func isHexToken(s string) bool {
	if len(s) < 10 || len(s) > 64 {
		return false
	}
	for _, c := range s {
		if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f' || c >= 'A' && c <= 'F') {
			return false
		}
	}
	return true
}

func escapeHTML(s string) string {
	return strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;").Replace(s)
}

func escapeAttr(s string) string {
	return strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", `"`, "&quot;").Replace(s)
}

func itoa(n int) string {
	if n == 0 {
		return "0"
	}
	var b []byte
	for n > 0 {
		b = append([]byte{byte('0' + n%10)}, b...)
		n /= 10
	}
	return string(b)
}