Artifact 26ea3d1651f75564e160a32fcc38b84027a771fbd48ff83fc7cbf73053d9d94f:
- File
internal/markup/markup.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: 6695) [annotate] [blame] [check-ins using]
// Package markup renders wiki/ticket/chat text to safe HTML (0002 §2): // goldmark for markdown, a CLEANROOM bluemonday policy for // sanitization — written fresh, copied from neither the Gitea nor the // Forgejo lineage — and one Resolver seam for zit reference resolution // (artifact hashes, [[wiki]] links) so the markdown assembly stays free // of index knowledge. // // The policy's conformance gate is the two-lineage vector corpus under // third_party/sanitizer-corpus (inputs are data, recorded in the // provenance ledger); markup_test runs it, and FuzzSanitize hammers the // policy beyond it. package markup import ( "bytes" "regexp" chromahtml "github.com/alecthomas/chroma/v2/formatters/html" "github.com/microcosm-cc/bluemonday" "github.com/yuin/goldmark" gast "github.com/yuin/goldmark/ast" "github.com/yuin/goldmark/extension" "github.com/yuin/goldmark/parser" "github.com/yuin/goldmark/renderer/html" "github.com/yuin/goldmark/text" "github.com/yuin/goldmark/util" highlighting "github.com/yuin/goldmark-highlighting/v2" ) // Resolver turns zit references found in text into links. Empty return // means "not resolvable — leave as plain text". Implementations sit on // the index; markup never touches it directly. type Resolver interface { // ResolveHash resolves a hex token (artifact-name shaped, 10–64 // chars) to a URL — a check-in page, a ticket page, whatever the // index says the artifact is. ResolveHash(prefix string) string // ResolveWiki resolves a [[Wiki Name]] reference to a URL. ResolveWiki(name string) string } // sanitize is the cleanroom policy. Everything here is written from the // question "what does markdown rendered by goldmark plus quarry's own // features actually need", not from either lineage's list. var sanitize = func() *bluemonday.Policy { p := bluemonday.NewPolicy() // Block structure. p.AllowElements("p", "br", "hr", "h1", "h2", "h3", "h4", "h5", "h6", "blockquote", "pre", "ul", "ol", "li", "dl", "dt", "dd", "table", "thead", "tbody", "tr", "th", "td", "details", "summary", "picture", "source") // Inline structure. p.AllowElements("em", "strong", "b", "i", "s", "del", "ins", "u", "code", "kbd", "sub", "sup", "mark", "q", "cite", "abbr") // Links: safe schemes plus relative URLs; rel=nofollow is forced on // every absolute link. p.AllowAttrs("href").OnElements("a") p.AllowStandardURLs() p.RequireNoFollowOnLinks(true) // Images. p.AllowAttrs("src", "alt", "title", "width", "height").OnElements("img") p.AllowAttrs("loading").Matching(regexp.MustCompile(`^lazy$`)).OnElements("img") p.AllowAttrs("media", "srcset").OnElements("source") // Syntax-highlight hooks: language classes on code, chroma's short // token classes on span, and the chroma wrapper class on pre — // nothing framework-shaped gets through (the class-injection attacks // in the corpus). p.AllowAttrs("class").Matching(regexp.MustCompile(`^language-[a-zA-Z0-9_+#.-]+$`)).OnElements("code") p.AllowAttrs("class").Matching(regexp.MustCompile(`^[a-z][a-z0-9]{0,3}$`)).OnElements("span") p.AllowAttrs("class").Matching(regexp.MustCompile(`^chroma$`)).OnElements("pre") // Task lists. p.AllowAttrs("type").Matching(regexp.MustCompile(`^checkbox$`)).OnElements("input") p.AllowAttrs("checked", "disabled").Matching(regexp.MustCompile(`^$|^checked$|^disabled$`)).OnElements("input") p.AllowAttrs("class").Matching(regexp.MustCompile(`^(checked|unchecked|indeterminate)$`)).OnElements("li") // Emoji spans and the two color properties, on non-code elements. p.AllowAttrs("class").Matching(regexp.MustCompile(`^emoji$`)).OnElements("span") p.AllowAttrs("aria-label", "data-alias").OnElements("span") p.AllowStyles("color", "background-color").OnElements("span", "p", "th", "td", "div") // Table alignment emitted by the GFM extension. p.AllowAttrs("align").Matching(regexp.MustCompile(`^(left|center|right)$`)).OnElements("th", "td") // Heading anchors (WithAutoHeadingID) and footnote plumbing. The id // pattern is deliberately narrow — a slug or goldmark's fnref form — // so an id cannot be shaped to collide with page chrome or a // framework hook. Footnote back-references need href (already // allowed on a), role, and the section wrapper. slug := regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_:.-]{0,127}$`) p.AllowAttrs("id").Matching(slug).OnElements("h1", "h2", "h3", "h4", "h5", "h6", "li", "sup", "a") // goldmark wraps footnotes in <div class="footnotes" role="doc-endnotes"> // (not <section>), with role/class on the ref and backref anchors. p.AllowElements("div") p.AllowAttrs("class").Matching(regexp.MustCompile(`^footnotes$`)).OnElements("div") p.AllowAttrs("class").Matching(regexp.MustCompile(`^footnote-(ref|backref)$`)).OnElements("a") p.AllowAttrs("role").Matching(regexp.MustCompile(`^doc-(endnotes|backlink|noteref)$`)).OnElements("div", "a") return p }() // demoteHeadings shifts every markdown heading one level down (h1→h2, // capped at h6): the page template owns the single h1, so content // headings slot beneath it and the document outline stays unambiguous. type demoteHeadings struct{} func (demoteHeadings) Transform(doc *gast.Document, _ text.Reader, _ parser.Context) { gast.Walk(doc, func(n gast.Node, entering bool) (gast.WalkStatus, error) { if h, ok := n.(*gast.Heading); ok && entering && h.Level < 6 { h.Level++ } return gast.WalkContinue, nil }) } var md = goldmark.New( goldmark.WithExtensions( // GFM covers tables, autolinks, strikethrough and task lists; // footnotes are also GFM but ship as their own extension. extension.GFM, extension.Strikethrough, extension.TaskList, extension.Footnote, highlighting.NewHighlighting( highlighting.WithStyle("github"), highlighting.WithFormatOptions(chromahtml.WithClasses(true)), ), ), goldmark.WithParserOptions( // Heading anchors, as GitHub emits them, so deep links into long // documents work. parser.WithAutoHeadingID(), parser.WithASTTransformers( util.Prioritized(demoteHeadings{}, 100), ), ), goldmark.WithRendererOptions(html.WithUnsafe()), // sanitizer is the gate, not the renderer ) // Render converts markdown to sanitized HTML. r may be nil (no // reference resolution). func Render(src []byte, r Resolver) ([]byte, error) { var buf bytes.Buffer if err := md.Convert(src, &buf); err != nil { return nil, err } out := sanitize.SanitizeBytes(buf.Bytes()) if r != nil { out = resolveRefs(out, r) } return out, nil } // Sanitize applies the policy alone — the surface the conformance // corpus and the fuzz target exercise directly. func Sanitize(in string) string { return sanitize.Sanitize(in) }