Artifact 2d490b304c5b849d9c9c80b733fe35f343c965b01eb5cba9fcb5c033ddd2077c:
- File
internal/markup/markup.go
— part of check-in
[0ba22dea9e]
at
2026-08-23 22:31:40
on branch trunk
— markdown: mentions carry the identity mark, quotes are visible
An @mention now renders the same glyph as the person's byline, through the resolver seam so markup still knows nothing about accounts. The identicon moved to internal/idn — web and api each hashed the name separately and agreed by luck. Blockquotes had no rule at all. (user: dmestas, size: 11229) [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" gohtml "html" "craftdesign.group/quarry/internal/highlight" "regexp" "strings" chromahtml "github.com/alecthomas/chroma/v2/formatters/html" "github.com/microcosm-cc/bluemonday" "github.com/yuin/goldmark" highlighting "github.com/yuin/goldmark-highlighting/v2" gast "github.com/yuin/goldmark/ast" "github.com/yuin/goldmark/extension" "github.com/yuin/goldmark/parser" "github.com/yuin/goldmark/renderer" "github.com/yuin/goldmark/renderer/html" "github.com/yuin/goldmark/text" "github.com/yuin/goldmark/util" ) // 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 // ResolvePath maps a Fossil site-relative URL found in imported // content (/doc/trunk/README.md, /info/<hash>, /timeline…) to the // equivalent quarry path. Empty means "quarry has no equivalent", // and the caller drops the link rather than leaving a dead one. ResolvePath(fossilPath string) string // UserMark is the identity mark for a mentioned account, as inline // SVG — the same glyph the forge draws beside a byline, so a person // named in prose looks like the same person named anywhere else. // // Through the seam rather than computed here: markup knows nothing // about accounts, and drawing the mark is not its job. Empty is a // valid answer and renders the mention as a plain link. UserMark(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 }) } // codeBlockHighlighter renders INDENTED code blocks (ast.CodeBlock) // through chroma's content sniffing. The highlighting extension only // handles fenced blocks, so a mailing-list-style post — "the patch::" // plus indentation, which is how forum bodies in this corpus carry // diffs — rendered flat next to colored fenced blocks. A block the // sniffer cannot identify stays a plain <pre><code>: no guess is // better than a wrong one. // looksLikeDiff recognizes unified-diff shape: a hunk header, or the // ---/+++ file pair. Nothing else writes lines like these at column 0. func looksLikeDiff(b []byte) bool { var minus, plus bool for _, line := range bytes.Split(b, []byte("\n")) { switch { case bytes.HasPrefix(line, []byte("@@ -")): return true case bytes.HasPrefix(line, []byte("--- ")): minus = true case bytes.HasPrefix(line, []byte("+++ ")): plus = true } if minus && plus { return true } } return false } type codeBlockHighlighter struct{} func (codeBlockHighlighter) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { reg.Register(gast.KindCodeBlock, renderCodeBlock) } func renderCodeBlock(w util.BufWriter, source []byte, node gast.Node, entering bool) (gast.WalkStatus, error) { if !entering { return gast.WalkContinue, nil } var body bytes.Buffer for i := 0; i < node.Lines().Len(); i++ { line := node.Lines().At(i) body.Write(line.Value(source)) } // chroma's Analyse has no analyzer for diffs — the one format these // blocks nearly always carry — so the unmistakable shape is named // outright and everything else may still sniff. lang := "" if looksLikeDiff(body.Bytes()) { lang = "diff" } if hl, ok := highlight.Code(body.Bytes(), lang, ""); ok { _, _ = w.WriteString(hl) return gast.WalkSkipChildren, nil } _, _ = w.WriteString("<pre><code>") _, _ = w.WriteString(gohtml.EscapeString(body.String())) _, _ = w.WriteString("</code></pre>\n") return gast.WalkSkipChildren, 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), ), parser.WithInlineParsers(util.Prioritized(spacedLink{}, 150)), ), goldmark.WithRendererOptions(html.WithUnsafe(), // sanitizer is the gate, not the renderer renderer.WithNodeRenderers(util.Prioritized(codeBlockHighlighter{}, 100))), ) // Render converts markdown to sanitized HTML. r may be nil (no // reference resolution). func Render(src []byte, r Resolver) ([]byte, error) { // Pikchr fences are lifted before goldmark sees them and restored // after sanitizing: the diagram needs the narrow SVG policy, not the // document policy (which has no business admitting <svg> generally). src, diagrams, nonce := liftPikchrFences(src) var buf bytes.Buffer if err := md.Convert(src, &buf); err != nil { return nil, err } out := sanitize.SanitizeBytes(buf.Bytes()) if r != nil { // Order matters: rewrite the author's own hrefs BEFORE // resolveRefs mints quarry links, so the two passes never see // each other's output. out = rewriteFossilHrefs(out, r) out = resolveRefs(out, r) } return restorePikchrFences(out, diagrams, nonce), 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) } // siteRelHref matches a rendered link whose target is a site-relative // path — in imported content those are Fossil's own routes, which mean // nothing under quarry's URL space. var siteRelHref = regexp.MustCompile(`<a href="(/[^"]*)"([^>]*)>(.*?)</a>`) // rewriteFossilHrefs maps Fossil site-relative links onto quarry routes, // and unwraps the ones quarry cannot serve into plain text — a reader // should never be handed a link that 404s. func rewriteFossilHrefs(html []byte, r Resolver) []byte { return siteRelHref.ReplaceAllFunc(html, func(m []byte) []byte { sub := siteRelHref.FindSubmatch(m) href, rest, label := string(sub[1]), string(sub[2]), string(sub[3]) // quarry's own routes are already correct; only /verb paths that // quarry does not serve need mapping. if strings.HasPrefix(href, "/r/") || strings.HasPrefix(href, "/docs") || strings.HasPrefix(href, "#") { return m } if mapped := r.ResolvePath(href); mapped != "" { return []byte(`<a href="` + mapped + `"` + rest + `>` + label + `</a>`) } return []byte(label) }) }