Artifact b672bf5f0521755e5311c2c7183c6883795a9028fb65dcc5d7a5d70c216d5eb2:
- File
internal/markup/fossilwiki.go
— part of check-in
[b00045e163]
at
2026-08-27 00:02:49
on branch trunk
— markup: fossil wiki resolves mentions and refs, like markdown does
An artifact with no N card IS fossil wiki — fossil's default, and what anything writing through the zit CLI produces. fossilInline resolved this dialect's own [target] links, but @mentions, [[wiki]] refs and bare hash refs are dialect-independent and were reaching only markdown bodies, so 62 of quarry's own 90 forum posts lost every mention in them.
The two passes run after sanitizing, as they do in Render: resolveRefs mints the identity mark and the policy would strip it. It parses HTML and skips a/code/pre, so the anchors fossilInline just built and every
block are left alone — both asserted. (user: dmestas, size: 9400) [annotate] [blame] [check-ins using]
// 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("&", "&", "<", "<", ">", ">").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) }