Skip to content
quarry Artifact [b672bf5f05]
Login

Artifact [b672bf5f05]

Artifact b672bf5f0521755e5311c2c7183c6883795a9028fb65dcc5d7a5d70c216d5eb2:


// 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)
	}
	rendered := sanitize.SanitizeBytes([]byte(html))
	if r != nil {
		// The SAME two passes the markdown path runs, in the same order
		// and for the same reason: fossilInline resolves this dialect's
		// own [target] links, but @mentions, [[wiki]] refs and bare
		// hash refs are dialect-independent and were reaching only
		// markdown bodies. An artifact with no N card IS fossil wiki —
		// fossil's default, and what anything writing through the zit
		// CLI produces — so the majority of quarry's own forum posts
		// were losing every mention in them.
		//
		// After sanitizing, never before: resolveRefs mints the
		// identity mark, and the policy would strip it. It parses HTML
		// and skips a/code/pre, so the anchors fossilInline already
		// built and every <verbatim> block are left alone.
		rendered = rewriteFossilHrefs(rendered, r)
		rendered = resolveRefs(rendered, r)
	}
	return rendered, 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 escapeLabel(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) + `">` + escapeLabel(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.
// DocResolver is a Resolver whose subject is a FILE IN THE TREE rather
// than a wiki artifact. Only then does a relative target mean anything:
// fossil's own docs link each other as [./quickstart.wiki|Quick Start],
// and a wiki page — which sits at no path — has no sibling to resolve
// that against. A resolver that does not implement this leaves such a
// target literal, exactly as before.
type DocResolver interface {
	// DocRelative maps a target relative to the current document to an
	// href, or "" to leave it unlinked.
	DocRelative(target string) string
}

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 ""
	}
	// Explicitly relative only. A bare "quickstart.wiki" is ambiguous
	// with a wiki page of that name, and guessing wrong turns a working
	// page link into a 404; "./" and "../" cannot mean anything else.
	if strings.HasPrefix(target, "./") || strings.HasPrefix(target, "../") {
		if dr, ok := r.(DocResolver); ok {
			if u := dr.DocRelative(target); u != "" {
				return u
			}
		}
		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)
}

// htmlEntity matches a character reference already written out.
var htmlEntity = regexp.MustCompile(`&(#[0-9]{1,7}|#[xX][0-9a-fA-F]{1,6}|[a-zA-Z][a-zA-Z0-9]{1,31});`)

// escapeLabel escapes link-label text while leaving a reference the
// author ALREADY WROTE alone. Fossil wiki is "a safe subset of HTML"
// (its own words), so &amp; in the source is an ampersand somebody
// spelled deliberately, and escaping it a second time puts &amp;amp; on
// the page — visible in fossil's own index as "Tips &amp; Hints".
// Verbatim blocks keep the blunt escape: there, literal is the point.
func escapeLabel(s string) string {
	var b strings.Builder
	last := 0
	for _, loc := range htmlEntity.FindAllStringIndex(s, -1) {
		b.WriteString(escapeHTML(s[last:loc[0]]))
		b.WriteString(s[loc[0]:loc[1]])
		last = loc[1]
	}
	b.WriteString(escapeHTML(s[last:]))
	return b.String()
}

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