Artifact 08d944a20eed77ec4e1331129d5ff5d507f8baef786ca0bd5b5af3a4a7248492:
- File
internal/markup/fossilwiki.go
— part of check-in
[1d680372e5]
at
2026-08-16 13:44:53
on branch trunk
— markup: map fossil site-relative links; theme-neutral pikchr ink
Two bugs found by browsing real content on a phone.
Imported wiki/technote/forum bodies are full of fossil's own routes (libfossil's wiki alone: 500 /finfo, 211 /dir, 105 /timeline, 23 /doc). Quarry passed those hrefs through verbatim, so libfossil's home page offered a README.md link that went nowhere. Resolver grows ResolvePath: /doc/<ver>/<path> maps to quarry's file route, /info to the artifact, /timeline /wiki /forum /technote to their pages; a verb quarry has no page for drops the link and keeps the label, since a dead link is worse than plain text. file.get now lets zit resolve symbolic versions (trunk, tip, tags) instead of demanding a hash — that is what /doc links use, and zit is the authority on names. The href rewrite runs BEFORE reference resolution so the two passes never see each other's output.
Pikchr hardcodes rgb(0,0,0), which is invisible on quarry's dark theme — the DOM assertions all passed while the diagram was unreadable, and only a screenshot caught it. Its default ink now becomes currentColor so a diagram follows the page theme; author-specified colours are untouched. Fossil solves this with a server-side --dark-mode flag, which cannot work for a CSS-themed page. (user: dmestas, size: 6680) [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) } 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("&", "&", "<", "<", ">", ">").Replace(s) } 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) }