Artifact 02511c95e9c092ff4d2b54fef32911fd9937be623088268ec007a94f49c3ea2c:
- File
internal/markup/fossilwiki.go
— part of check-in
[45c96a506b]
at
2026-08-16 12:45:03
on branch trunk
— markup: render Fossil wiki format; fix mimetype dispatch; GFM completeness
An absent N card means text/x-fossil-wiki, not markdown — fossil's wiki_render_by_mimetype is explicit — but quarry rendered those as markdown, mangling 217 of libfossil's 443 wiki artifacts ([target|label] links came out as literal text). Adds a Fossil wiki renderer (the seven rules from fossil's own wiki.wiki: paragraphs, single-level * and # lists, indented paragraphs, [target|label] links through the existing Resolver, verbatim/nowiki, HTML pass-through gated by the sanitizer), dispatches on mimetype exactly as fossil does, and stores forum posts' N card so they dispatch too. Fossil site-relative targets quarry cannot serve render their label as text rather than showing wiki source.
Also GFM completeness: footnotes and auto heading IDs, with the sanitizer policy widened to admit what goldmark actually emits (a footnotes div, not section — caught by test). (user: dmestas, size: 6629) [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. [/dir?ci=trunk|files]). // quarry has no equivalent route for most of these, so they are // left literal rather than pointing somewhere wrong. return "" } 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) }