Artifact f6f0414ade331570b442ddf0fa291f7217420088e096510efbbd80c54ac0c3bf:
- File
internal/markup/fossilwiki.go
— part of check-in
[44ba7c78f8]
at
2026-08-23 01:03:04
on branch doc-links
— doc: relative links between documents, and their images
Fossil's docs link each other as Quick Start and those stayed literal text, so a rendered doc was a dead end. Relative targets now resolve for tree documents only — a wiki artifact sits at no path and must not emit links relative to one — and the href is left relative so the browser resolves it against the doc's own URL. Images are served as bytes under the same route the markup already points at.
Also stops double-escaping a character reference the author wrote: fossil's own index rendered "Tips & Hints". (user: dmestas, size: 8532) [annotate] [blame] [check-ins using] [more...]
// 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 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) }