// 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))
rendered = rewriteAttachments(rendered, r)
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 == "" {
// An attach reference with no label reads as the filename;
// the scheme is plumbing and a reader has no use for it.
label = strings.TrimPrefix(target, attachScheme)
}
href := resolveFossilTarget(target, r)
if href == "" {
if strings.HasPrefix(target, "/") || strings.HasPrefix(target, attachScheme) {
// 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, attachScheme):
// A file on the object this body belongs to. Always the
// download route: fossil wiki has no image syntax, so a target
// in brackets is a link by construction.
return resolveAttach(r, strings.TrimPrefix(target, attachScheme), false)
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("&", "&", "<", "<", ">", ">").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 & in the source is an ampersand somebody
// spelled deliberately, and escaping it a second time puts &amp; on
// the page — visible in fossil's own index as "Tips & 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("&", "&", "<", "<", ">", ">", `"`, """).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)
}