Initial commit
Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s

Assisted-by: GLM 5.3
This commit is contained in:
2026-09-29 10:03:32 +02:00
commit f8ed33df83
206 changed files with 44165 additions and 0 deletions
+34
View File
@@ -0,0 +1,34 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package markdown
import (
stdhtml "html"
"regexp"
"sourcedock.dev/petrbalvin/scriptorium"
)
// mermaidBlockRe matches the fenced mermaid blocks the renderer writes.
// The diagram source arrives HTML-escaped, as code content always does.
var mermaidBlockRe = regexp.MustCompile(`(?s)<pre><code class="language-mermaid">(.*?)</code></pre>`)
// renderDiagrams replaces every fenced mermaid block with the SVG
// scriptorium draws from it, wrapped so a style sheet can tell diagrams
// from prose. It runs on sanitised HTML: the SVG is machine-drawn output
// whose vocabulary the library itself bounds, and the sanitiser's
// HTML-only parser would corrupt its case-sensitive attributes. A
// diagram the library does not carry, or a line it cannot parse, keeps
// its code block: the author sees the source that was not understood,
// and the reader never a half-drawn figure.
func renderDiagrams(html string) string {
return mermaidBlockRe.ReplaceAllStringFunc(html, func(block string) string {
src := stdhtml.UnescapeString(mermaidBlockRe.FindStringSubmatch(block)[1])
svg, err := scriptorium.RenderDiagram([]byte(src))
if err != nil {
return block
}
return `<div class="diagram">` + "\n" + string(svg) + "\n</div>"
})
}
+56
View File
@@ -0,0 +1,56 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package markdown
import (
"flag"
"os"
"path/filepath"
"strings"
"testing"
)
var update = flag.Bool("update", false, "rewrite golden files")
// TestGoldenCorpus renders every corpus file and compares against the
// committed golden output. Run `go test ./internal/markdown -update` to
// regenerate after an intentional rendering change.
func TestGoldenCorpus(t *testing.T) {
corpusDir := filepath.Join("testdata", "corpus")
entries, err := os.ReadDir(corpusDir)
if err != nil {
t.Fatalf("read corpus: %v", err)
}
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") {
continue
}
name := strings.TrimSuffix(entry.Name(), ".md")
t.Run(name, func(t *testing.T) {
src, err := os.ReadFile(filepath.Join(corpusDir, entry.Name()))
if err != nil {
t.Fatalf("read corpus file: %v", err)
}
htmlOut, toc, err := RenderWithTOC(string(src))
if err != nil {
t.Fatalf("RenderWithTOC: %v", err)
}
got := "=== HTML ===\n" + htmlOut + "=== TOC ===\n" + toc
goldenPath := filepath.Join("testdata", name+".html")
if *update {
if err := os.WriteFile(goldenPath, []byte(got), 0o644); err != nil {
t.Fatalf("write golden: %v", err)
}
return
}
want, err := os.ReadFile(goldenPath)
if err != nil {
t.Fatalf("read golden (run with -update): %v", err)
}
if got != string(want) {
t.Fatalf("output differs from golden:\n--- got ---\n%s\n--- want ---\n%s", got, want)
}
})
}
}
+341
View File
@@ -0,0 +1,341 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Package markdown renders Markdown posts to sanitised HTML.
//
// scriptorium renders the body (CommonMark with the GFM extensions,
// footnotes and definition lists). Mathematics and Mermaid diagrams, which
// scriptorium renders only when a consumer asks, are recognised here: a
// pre-render scan lifts $$…$$ and $…$ runs out of the source into
// placeholders and splices the MathML back after rendering, and fenced
// mermaid blocks are replaced by their SVG. Images titled with a caption
// are wrapped in <figure>/<figcaption>, headings gain id attributes and a
// table of contents, and bluemonday strips everything outside a narrow
// tag/attribute allowlist.
package markdown
import (
"fmt"
stdhtml "html"
"regexp"
"strconv"
"strings"
"sync"
"github.com/microcosm-cc/bluemonday"
"sourcedock.dev/petrbalvin/scriptorium"
)
// MaxBodyLength caps the Markdown source size (1 MiB).
const MaxBodyLength = 1_048_576
// MaxQuoteDepth bounds how deeply one line may nest blockquote markers.
// Measured: rendering cost grows superlinearly with depth (100 000
// levels take seconds, and a 1 MiB body of nothing but markers could
// reach hundreds of thousands), while legitimate prose never approaches
// the bound. It turns the worst case from an unbounded CPU burn into a
// rejection.
const MaxQuoteDepth = 100
var (
policyOnce sync.Once
sanitizer *bluemonday.Policy
)
// tocHeading is one entry of the rendered table of contents.
type tocHeading struct {
level int
id string
text string
}
func getPolicy() *bluemonday.Policy {
policyOnce.Do(func() {
sanitizer = bluemonday.NewPolicy()
sanitizer.AllowElements(
"a", "abbr", "blockquote", "br", "caption", "code", "del",
"dd", "div", "dl", "dt", "em", "figcaption", "figure",
"h1", "h2", "h3", "h4", "h5", "h6", "hr", "img", "input",
"li", "ol", "p", "pre", "section", "span", "strong", "sub", "sup",
"table", "tbody", "td", "th", "thead", "tr", "ul",
// SVG, the output of the Mermaid diagram renderer.
"svg", "line", "path", "polygon", "rect", "text",
// MathML Core, the output of the mathematics renderer.
"math", "mi", "mn", "mo", "ms", "mtext", "mspace", "mrow",
"mfrac", "msqrt", "mroot", "msub", "msup", "msubsup",
"munder", "mover", "munderover", "merror", "mpadded",
"mphantom", "mstyle", "mtable", "mtr", "mtd",
)
// MathML leaves most elements bare: an <mi> carries no attribute
// at all, and without this the policy admits only elements that
// do.
sanitizer.AllowNoAttrs().OnElements(
"math", "mi", "mn", "mo", "ms", "mtext", "mspace", "mrow",
"mfrac", "msqrt", "mroot", "msub", "msup", "msubsup",
"munder", "mover", "munderover", "merror", "mpadded",
"mphantom", "mstyle", "mtable", "mtr", "mtd",
)
sanitizer.AllowAttrs("href", "title", "class", "id", "aria-label", "data-footnote-ref").OnElements("a")
sanitizer.AllowAttrs("src", "alt", "title", "width", "height", "loading").OnElements("img")
sanitizer.AllowAttrs("class").OnElements("div", "span", "code", "pre", "figure", "figcaption", "section", "li", "sup")
sanitizer.AllowAttrs("id").OnElements("h1", "h2", "h3", "h4", "h5", "h6", "section", "li")
sanitizer.AllowAttrs("align").OnElements("th", "td")
sanitizer.AllowAttrs("type", "checked", "disabled").OnElements("input")
sanitizer.AllowAttrs("display", "xmlns").OnElements("math")
sanitizer.AllowAttrs(
"mathvariant", "stretchy", "accent", "accentunder", "separator",
"form", "fence", "lspace", "rspace", "mathcolor",
).OnElements("mi", "mn", "mo", "ms", "mtext")
sanitizer.AllowAttrs("width").OnElements("mspace")
sanitizer.AllowAttrs("displaystyle", "scriptlevel", "mathcolor").OnElements("mstyle")
sanitizer.AllowAttrs("columnalign").OnElements("mtable")
sanitizer.AllowAttrs("data-footnotes").OnElements("section")
// The attributes of the diagram SVG: geometry and paint, the
// shapes the renderer draws and the styles a classDef or a
// linkStyle statement asks for.
sanitizer.AllowAttrs("xmlns", "viewBox", "width", "height", "font-family").OnElements("svg")
sanitizer.AllowAttrs("x", "y", "width", "height", "rx").OnElements("rect")
sanitizer.AllowAttrs("x1", "y1", "x2", "y2").OnElements("line")
sanitizer.AllowAttrs("d").OnElements("path")
sanitizer.AllowAttrs("x", "y", "text-anchor").OnElements("text")
sanitizer.AllowAttrs("points").OnElements("polygon")
sanitizer.AllowAttrs(
"fill", "stroke", "stroke-width", "stroke-dasharray",
"font-size", "opacity", "style",
).OnElements("svg", "line", "path", "polygon", "rect", "text")
sanitizer.AllowURLSchemes("http", "https", "mailto")
sanitizer.AllowRelativeURLs(true)
})
return sanitizer
}
// Render renders Markdown to sanitised HTML.
func Render(text string) (string, error) {
out, _, err := RenderWithTOC(text)
return out, err
}
// RenderWithTOC renders Markdown to sanitised HTML and returns
// (html, toc_html). toc_html is the sanitised table-of-contents markup,
// or an empty string when the body has no headings.
func RenderWithTOC(src string) (string, string, error) {
if src == "" {
return "", "", nil
}
if len(src) > MaxBodyLength {
return "", "", fmt.Errorf("body exceeds %d bytes", MaxBodyLength)
}
if quoteDepthTooDeep(src) {
return "", "", fmt.Errorf("body nests blockquotes deeper than %d levels", MaxQuoteDepth)
}
rewritten, spans := extractMath(src)
html := string(scriptorium.Render([]byte(rewritten)))
html = spliceMath(html, spans)
html, toc := addHeadingIDs(html)
raw := unwrapFigureParagraphs(wrapFigures(html))
out := sanitize(raw)
// The diagrams are drawn after sanitisation: the SVG is scriptorium's
// own output, not authored markup, and the HTML policy's parser
// rewrites the case-sensitive viewBox attribute into a form no browser
// reads. A diagram the library refuses keeps its code block, which the
// sanitiser above has already cleaned like every other one.
out = renderDiagrams(out)
return out, sanitize(toc), nil
}
// quoteDepthTooDeep reports whether any line nests blockquote markers
// beyond MaxQuoteDepth. The markers may be written with or without
// spaces between them, so both spellings are counted.
func quoteDepthTooDeep(src string) bool {
for line := range strings.SplitSeq(src, "\n") {
rest := strings.TrimLeft(line, " \t")
depth := 0
for strings.HasPrefix(rest, ">") {
depth++
if depth > MaxQuoteDepth {
return true
}
rest = strings.TrimLeft(rest[1:], " \t")
}
}
return false
}
var figureParaRe = regexp.MustCompile(`(?s)<p>(<figure>.*?</figure>)</p>`)
// unwrapFigureParagraphs lifts figures out of their wrapping paragraph
// (<p> cannot contain <figure>).
func unwrapFigureParagraphs(in string) string {
return figureParaRe.ReplaceAllString(in, "<p></p>$1<p></p>")
}
func sanitize(in string) string {
if in == "" {
return in
}
out := getPolicy().Sanitize(in)
return addLinkRel(out)
}
// linkTagRe matches anchor start tags in sanitised output.
var linkTagRe = regexp.MustCompile(`<a\s[^>]*>`)
// addLinkRel forces rel="noopener noreferrer" onto every link, matching
// the shape consumers expect.
func addLinkRel(in string) string {
return linkTagRe.ReplaceAllStringFunc(in, func(tag string) string {
if strings.Contains(tag, "rel=") {
return tag
}
return `<a rel="noopener noreferrer" ` + strings.TrimPrefix(tag, "<a ")
})
}
var (
imgTitleRe = regexp.MustCompile(`(?is)<img\s[^>]*\stitle=(?:"[^"]*"|'[^']*')[^>]*/?>`)
titleAttrRe = regexp.MustCompile(`(?is)\s+title=(?:"[^"]*"|'[^']*')`)
titleValRe = regexp.MustCompile(`(?is)^<img\s[^>]*\stitle=(?:"([^"]*)"|'([^']*)')`)
)
// wrapFigures wraps every <img title="…"> in a <figure> with a
// <figcaption>, on the raw pre-sanitisation output so the title
// attribute is still present.
func wrapFigures(in string) string {
return imgTitleRe.ReplaceAllStringFunc(in, func(imgTag string) string {
title := ""
if m := titleValRe.FindStringSubmatch(imgTag); m != nil {
title = m[1]
if title == "" {
title = m[2]
}
}
// The captured attribute value is HTML-escaped (the renderer
// escapes what it writes), so it is unescaped first: escaping it
// again would show "&amp;amp;" for a title containing "&".
// Unescaping leaves a raw-HTML title the author wrote verbatim,
// and the re-escape puts both forms back in canonical shape.
caption := stdhtml.EscapeString(stdhtml.UnescapeString(title))
cleanImg := titleAttrRe.ReplaceAllString(imgTag, "")
return "<figure>" +
cleanImg +
`<div class="fig-info" aria-hidden="true">i</div>` +
"<figcaption>" + caption + "</figcaption>" +
"</figure>"
})
}
// headingTagRe matches the headings the renderer writes: scriptorium
// emits them without attributes, so the pass below is the only source of
// their id attributes. RE2 has no backreference, so the func below
// checks that the opening and closing levels agree.
var headingTagRe = regexp.MustCompile(`(?s)<h([1-6])>(.*?)</h([1-6])>`)
// innerTagRe strips the inline markup of a heading, leaving its text.
var innerTagRe = regexp.MustCompile(`(?s)<[^>]*>`)
// addHeadingIDs gives every heading an id attribute derived from its
// text and returns the table of contents built from the same walk.
func addHeadingIDs(in string) (string, string) {
used := make(map[string]int)
var headings []tocHeading
out := headingTagRe.ReplaceAllStringFunc(in, func(m string) string {
sub := headingTagRe.FindStringSubmatch(m)
level, inner, closeLevel := sub[1], sub[2], sub[3]
if level != closeLevel {
return m
}
label := strings.TrimSpace(stdhtml.UnescapeString(innerTagRe.ReplaceAllString(inner, "")))
if label == "" {
return m
}
id := headingSlug(label, used)
levelNum, _ := strconv.Atoi(level)
headings = append(headings, tocHeading{level: levelNum, id: id, text: label})
return "<h" + level + ` id="` + id + `">` + inner + "</h" + level + ">"
})
return out, renderTOC(headings)
}
// headingSlug turns a heading label into a unique id by the rule the
// previous renderer established, so the anchors the published pages
// already carry keep resolving: ASCII letters and digits, lowercased;
// spaces, dashes and underscores as dashes; every other byte, the
// diacritics of Czech prose included, dropped. Collisions are told
// apart by a numeric suffix.
func headingSlug(label string, used map[string]int) string {
var b strings.Builder
for i := 0; i < len(label); i++ {
c := label[i]
switch {
case c >= 0x80:
// A multi-byte rune is dropped whole.
continue
case 'A' <= c && c <= 'Z':
b.WriteByte(c + 'a' - 'A')
case 'a' <= c && c <= 'z' || '0' <= c && c <= '9':
b.WriteByte(c)
case c == ' ' || c == '\t' || c == '-' || c == '_':
b.WriteByte('-')
}
}
id := b.String()
if id == "" {
id = "heading"
}
if n, ok := used[id]; ok {
used[id] = n + 1
id = id + "-" + strconv.Itoa(n)
}
used[id] = 1
return id
}
// renderTOC renders the headings as a table of contents in the shape the
// API documents:
// <div class="toc"><ul><li><a href="#id">Title</a></li></ul></div>.
// The wrapper is emitted even with an empty list, so a consumer can rely
// on its presence.
func renderTOC(headings []tocHeading) string {
var b strings.Builder
b.WriteString(`<div class="toc">` + "\n")
if len(headings) == 0 {
b.WriteString("<ul></ul>\n")
} else {
b.WriteString(renderTOCList(headings))
}
b.WriteString("</div>\n")
return b.String()
}
// renderTOCList renders the headings as nested lists. A run of deeper
// headings becomes a sub-list of the heading above it, and a heading
// that returns to a shallower level closes the lists it left behind and
// continues as a sibling, so a body that opens with a second-level
// heading and later uses a first-level one keeps both in the list.
func renderTOCList(headings []tocHeading) string {
var b strings.Builder
b.WriteString("<ul>\n")
for i := 0; i < len(headings); i++ {
h := headings[i]
b.WriteString(`<li><a href="#` + stdhtml.EscapeString(h.id) + `">` +
stdhtml.EscapeString(h.text) + "</a>")
if j := deeperRun(headings, i+1, h.level); j > i+1 {
b.WriteString("\n")
b.WriteString(renderTOCList(headings[i+1 : j]))
i = j - 1
}
b.WriteString("</li>\n")
}
b.WriteString("</ul>\n")
return b.String()
}
// deeperRun returns the end index of the contiguous run of headings
// deeper than level, starting at start.
func deeperRun(headings []tocHeading, start, level int) int {
end := start
for end < len(headings) && headings[end].level > level {
end++
}
return end
}
+583
View File
@@ -0,0 +1,583 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package markdown
import (
"regexp"
"strings"
"testing"
)
func TestRenderEmpty(t *testing.T) {
out, err := Render("")
if err != nil || out != "" {
t.Fatalf("Render(\"\") = %q, %v", out, err)
}
}
func TestRenderRejectsOversizedBody(t *testing.T) {
huge := strings.Repeat("a", MaxBodyLength+1)
if _, err := Render(huge); err == nil {
t.Fatal("want error for oversized body")
}
}
func TestRenderBasicParagraph(t *testing.T) {
out, err := Render("Hello *world*")
if err != nil {
t.Fatalf("Render: %v", err)
}
if !strings.Contains(out, "<em>world</em>") {
t.Fatalf("out = %q", out)
}
}
func TestRenderStripsScript(t *testing.T) {
out, err := Render(`<script>alert("x")</script>`)
if err != nil {
t.Fatalf("Render: %v", err)
}
if strings.Contains(out, "<script") || strings.Contains(out, "alert") {
t.Fatalf("script survived: %q", out)
}
}
func TestRenderStripsEventHandlers(t *testing.T) {
out, err := Render(`<img src="/media/x.webp" onerror="alert(1)">`)
if err != nil {
t.Fatalf("Render: %v", err)
}
if strings.Contains(out, "onerror") {
t.Fatalf("onerror survived: %q", out)
}
}
func TestRenderBlocksJavascriptURL(t *testing.T) {
out, err := Render(`[click](javascript:alert(1))`)
if err != nil {
t.Fatalf("Render: %v", err)
}
if strings.Contains(out, "javascript:") {
t.Fatalf("javascript: URL survived: %q", out)
}
}
func TestRenderAddsLinkRel(t *testing.T) {
out, err := Render(`[link](https://example.com)`)
if err != nil {
t.Fatalf("Render: %v", err)
}
if !strings.Contains(out, `rel="noopener noreferrer"`) {
t.Fatalf("rel missing: %q", out)
}
}
func TestRenderCodeBlockLanguageClass(t *testing.T) {
out, err := Render("```go\nfmt.Println(1)\n```\n")
if err != nil {
t.Fatalf("Render: %v", err)
}
if !strings.Contains(out, `class="language-go"`) {
t.Fatalf("language class missing: %q", out)
}
}
func TestRenderTaskList(t *testing.T) {
out, err := Render("- [x] done\n- [ ] todo\n")
if err != nil {
t.Fatalf("Render: %v", err)
}
if !strings.Contains(out, `type="checkbox"`) {
t.Fatalf("checkbox missing: %q", out)
}
if strings.Count(out, "checked") < 1 {
t.Fatalf("checked state missing: %q", out)
}
}
func TestRenderStrikethrough(t *testing.T) {
out, err := Render("~~gone~~")
if err != nil {
t.Fatalf("Render: %v", err)
}
if !strings.Contains(out, "<del>gone</del>") {
t.Fatalf("out = %q", out)
}
}
func TestRenderTable(t *testing.T) {
out, err := Render("| a | b |\n|---|---|\n| 1 | 2 |\n")
if err != nil {
t.Fatalf("Render: %v", err)
}
if !strings.Contains(out, "<table>") || !strings.Contains(out, "<td") {
t.Fatalf("table missing: %q", out)
}
}
func TestWrapFigures(t *testing.T) {
out, err := Render(`![alt](/media/pic.webp "Popisek")`)
if err != nil {
t.Fatalf("Render: %v", err)
}
for _, want := range []string{"<figure>", "<figcaption>Popisek</figcaption>", `class="fig-info"`} {
if !strings.Contains(out, want) {
t.Fatalf("missing %q in %q", want, out)
}
}
if strings.Contains(out, "title=") {
t.Fatalf("title attribute survived on figure image: %q", out)
}
}
func TestWrapFiguresEscapesCaption(t *testing.T) {
out, err := Render(`![alt](/media/pic.webp "<b>x</b>")`)
if err != nil {
t.Fatalf("Render: %v", err)
}
if strings.Contains(out, "<figcaption><b>") {
t.Fatalf("caption not escaped: %q", out)
}
}
func TestRenderWithTOC(t *testing.T) {
src := "# First\n\n## Second\n\n### Third\n\n## Another\n"
out, toc, err := RenderWithTOC(src)
if err != nil {
t.Fatalf("RenderWithTOC: %v", err)
}
if !strings.Contains(out, `id="first"`) {
t.Fatalf("heading id missing: %q", out)
}
for _, want := range []string{`<div class="toc">`, `href="#first"`, `href="#second"`, `href="#third"`, `href="#another"`} {
if !strings.Contains(toc, want) {
t.Fatalf("toc missing %q: %q", want, toc)
}
}
// "Third" nests under "Second".
secondAt := strings.Index(toc, `href="#second"`)
thirdAt := strings.Index(toc, `href="#third"`)
anotherAt := strings.Index(toc, `href="#another"`)
if !(secondAt < thirdAt && thirdAt < anotherAt) {
t.Fatalf("toc order wrong: %q", toc)
}
if strings.Count(toc, "<ul>") < 2 {
t.Fatalf("nested list missing: %q", toc)
}
}
func TestRenderWithTOCNoHeadings(t *testing.T) {
_, toc, err := RenderWithTOC("just text")
if err != nil {
t.Fatalf("RenderWithTOC: %v", err)
}
// The wrapper is present even with an empty list.
want := `<div class="toc">` + "\n<ul></ul>\n</div>\n"
if toc != want {
t.Fatalf("toc = %q, want %q", toc, want)
}
}
func TestRenderAllowsRelativeImage(t *testing.T) {
out, err := Render(`![](/media/pic.webp)`)
if err != nil {
t.Fatalf("Render: %v", err)
}
if !strings.Contains(out, `src="/media/pic.webp"`) {
t.Fatalf("relative image stripped: %q", out)
}
}
// BenchmarkRender measures the rendering pipeline for a medium body
// (2.4 KB) and a large one (43 KB), which bracket the posts the engine is
// built for.
func BenchmarkRender(b *testing.B) {
medium := strings.Repeat("Some **markdown** text with a [link](https://example.com).\n\n", 40)
large := strings.Repeat(medium, 18)
for name, src := range map[string]string{"medium": medium, "large": large} {
b.Run(name, func(b *testing.B) {
b.SetBytes(int64(len(src)))
for b.Loop() {
if _, _, err := RenderWithTOC(src); err != nil {
b.Fatal(err)
}
}
})
}
}
// A body that opens with a second-level heading and later uses a
// first-level one must keep both in the table of contents: the shallower
// heading closes the list it was nested in rather than ending the walk.
func TestTOCKeepsShallowerHeadings(t *testing.T) {
_, toc, err := RenderWithTOC("## Intro\n\n### Detail\n\n# Later\n\ntext\n")
if err != nil {
t.Fatalf("RenderWithTOC: %v", err)
}
for _, want := range []string{"Intro", "Detail", "Later"} {
if !strings.Contains(toc, want) {
t.Fatalf("toc is missing %q:\n%s", want, toc)
}
}
if got := strings.Count(toc, "<li>"); got != 3 {
t.Fatalf("toc lists %d headings, want 3:\n%s", got, toc)
}
// Detail is nested one list deeper than Intro, and Later sits beside
// Intro rather than inside it.
nested := strings.Index(toc, `href="#detail"`)
intro := strings.Index(toc, `href="#intro"`)
later := strings.Index(toc, `href="#later"`)
if !(intro < nested && nested < later) {
t.Fatalf("headings are out of order in the toc:\n%s", toc)
}
if strings.Count(toc, "<ul>") != 2 {
t.Fatalf("want one nested list, got %d lists:\n%s", strings.Count(toc, "<ul>"), toc)
}
}
// A caption containing "&" is escaped once: goldmark writes the title
// attribute HTML-escaped, so escaping the captured value again would
// publish "&amp;amp;".
func TestFigureCaptionEscapesOnce(t *testing.T) {
out, _, err := RenderWithTOC(`![alt](/media/p.webp "Tom & Jerry")`)
if err != nil {
t.Fatalf("render: %v", err)
}
if !strings.Contains(out, "<figcaption>Tom &amp; Jerry</figcaption>") {
t.Fatalf("caption = %s", out)
}
if strings.Contains(out, "&amp;amp;") {
t.Fatalf("caption double-escaped: %s", out)
}
// The raw-HTML form (author-written, unescaped by goldmark) produces
// the same caption.
raw, _, err := RenderWithTOC(`<img src="/media/p.webp" title="Tom & Jerry">`)
if err != nil {
t.Fatalf("render: %v", err)
}
if !strings.Contains(raw, "<figcaption>Tom &amp; Jerry</figcaption>") {
t.Fatalf("raw caption = %s", raw)
}
}
// A heading written with an entity reference and the TOC entry for it
// agree: the TOC shows the decoded text, as the rendered heading does.
func TestTOCResolvesEntityReferences(t *testing.T) {
_, toc, err := RenderWithTOC("## Caf&eacute;\n\ntext")
if err != nil {
t.Fatalf("render: %v", err)
}
if !strings.Contains(toc, ">Café</a>") {
t.Fatalf("toc = %s", toc)
}
if strings.Contains(toc, "&amp;eacute;") {
t.Fatalf("toc double-escaped: %s", toc)
}
}
// A body of nothing but blockquote markers is bounded: rendering cost
// grows superlinearly with depth, so an absurd nest is rejected instead
// of rendered.
func TestQuoteDepthIsBounded(t *testing.T) {
tooDeep := strings.Repeat(">", MaxQuoteDepth+1) + " text"
if _, _, err := RenderWithTOC(tooDeep); err == nil {
t.Fatal("an absurdly nested body was rendered")
}
// Spelled with spaces it is the same nest.
spaced := strings.Repeat("> ", MaxQuoteDepth+1) + "text"
if _, _, err := RenderWithTOC(spaced); err == nil {
t.Fatal("the spaced form slipped through")
}
// Legitimate nesting still renders, and a quoted block that merely
// mentions the marker in prose is not counted.
deep := strings.Repeat("> ", 50) + "text"
if _, _, err := RenderWithTOC(deep); err != nil {
t.Fatalf("legitimate nesting rejected: %v", err)
}
if _, _, err := RenderWithTOC("The `>` in `>>> /dev/null` is code."); err != nil {
t.Fatalf("prose with markers rejected: %v", err)
}
}
// An inline equation the author broke across lines does not swallow the
// prose after it into mathematics: the run that spans the break contains
// a dollar inside, and such a run is refused, so the broken fragments
// stay the text they look like and the next whole equation still
// renders.
func TestBrokenInlineRunKeepsProseOut(t *testing.T) {
src := "5. Čtení nese **rovnici** $\\nabla^2 h =\n (8\\pi/\\kappa a)u$: vazba je pružná síla buňky $\\kappa a = c^4/G$,\n zdroj je energie.\n"
out, err := Render(src)
if err != nil {
t.Fatalf("render: %v", err)
}
if strings.Contains(out, "<merror>") {
t.Fatalf("the broken run degraded inside math: %q", out)
}
// The prose never becomes mathematics: ž occurs only in prose.
if strings.Contains(out, "<mi>ž</mi>") {
t.Fatalf("prose was swallowed into math: %q", out)
}
// The whole equation after the prose still renders.
if !strings.Contains(out, "<mi>κ</mi>") {
t.Fatalf("the clean equation did not render: %q", out)
}
// The broken fragments stay visible as their source.
if !strings.Contains(out, `$\nabla^2 h =`) {
t.Fatalf("the opening fragment did not stay text: %q", out)
}
}
// A display equation may span lines: the $$ opens on the line that
// starts the mathematics and closes on a later one, the shape the
// papers in the corpus are written in.
func TestMultiLineDisplayMath(t *testing.T) {
src := "Text above.\n\n$$r_h = \\frac{\\sigma}{\\sqrt{2\\pi G \\rho_{\\text{amb}}}},\n\\qquad M_h = \\frac{2\\sigma^2 r_h}{G}. \\quad (7)$$\n\ntext below\n"
out, err := Render(src)
if err != nil {
t.Fatalf("render: %v", err)
}
if strings.Contains(out, "$$") {
t.Fatalf("the markers survived: %q", out)
}
for _, want := range []string{
"<p>Text above.</p>",
`<div class="math math-display">`, `display="block"`,
"<mi>σ</mi>", "<mi>M</mi>", "<mn>7</mn>",
"<p>text below</p>",
} {
if !strings.Contains(out, want) {
t.Fatalf("missing %q in %q", want, out)
}
}
}
// A block that opens on a line of its own collects until a closing $$.
func TestMultiLineDisplayMathBareCloser(t *testing.T) {
src := "$$\nE = mc^2\n$$\n"
out, err := Render(src)
if err != nil {
t.Fatalf("render: %v", err)
}
if strings.Contains(out, "$$") || !strings.Contains(out, `display="block"`) {
t.Fatalf("out = %q", out)
}
}
// An unclosed $$ stays text: the search for the closer stops at a blank
// line or the line bound, so a stray marker cannot swallow the body.
func TestUnclosedDisplayMathStaysText(t *testing.T) {
src := "$$x = 1,\nstill prose\n\nmore prose\n"
out, err := Render(src)
if err != nil {
t.Fatalf("render: %v", err)
}
if strings.Contains(out, "<math") {
t.Fatalf("an unclosed block became mathematics: %q", out)
}
if !strings.Contains(out, "$$x = 1,") {
t.Fatalf("the stray marker did not survive as text: %q", out)
}
}
// A display equation set right below the sentence that introduces it,
// without a blank line, becomes its own block: the paragraph above ends,
// the equation stands alone, and the sentence after it opens a new
// paragraph.
func TestDisplayMathInterruptsParagraph(t *testing.T) {
src := "The metric reads\n$$g_{tt} = 1$$\nand continues.\n"
out, err := Render(src)
if err != nil {
t.Fatalf("render: %v", err)
}
for _, want := range []string{
"<p>The metric reads</p>",
`<div class="math math-display">`,
`display="block"`,
"<p>and continues.</p>",
} {
if !strings.Contains(out, want) {
t.Fatalf("missing %q in %q", want, out)
}
}
}
// Mathematics is a prose construct: a dollar inside a fenced block, an
// indented one or a code span stays the literal byte it is, and a
// backslash-escaped dollar never opens a run. The escape itself the
// renderer consumes, so the escaped dollar reaches the reader as a bare
// one that still opens no mathematics.
func TestMathSkipsCode(t *testing.T) {
src := "```tex\n$x^2$\n```\n\n $x^2$\n\nInline `$x$` code, and \\$x\\$ escaped.\n"
out, err := Render(src)
if err != nil {
t.Fatalf("render: %v", err)
}
if strings.Contains(out, "<math") {
t.Fatalf("code was turned into mathematics: %q", out)
}
for _, want := range []string{"$x^2$", "$x$"} {
if !strings.Contains(out, want) {
t.Fatalf("literal %q missing in %q", want, out)
}
}
}
// Two dollar amounts in a sentence are not a phantom equation. A $$…$$
// run that shares its line with text keeps the historical shape: the
// first dollar stays text, the inner $…$ is inline mathematics and the
// closing dollar follows it, exactly as the engine this pass replaces
// rendered it.
func TestCurrencyGuards(t *testing.T) {
out, err := Render("Costs $5 and $10 per group.\n\nSplit $$x$$ mid line.\n")
if err != nil {
t.Fatalf("render: %v", err)
}
if !strings.Contains(out, "<p>Costs $5 and $10 per group.</p>") {
t.Fatalf("amounts became mathematics: %q", out)
}
if !strings.Contains(out, "Split $<math") || !strings.Contains(out, "</math>$ mid line.") {
t.Fatalf("the mid-line run changed shape: %q", out)
}
}
// A construct outside the mappable surface is not refused: it degrades
// in place, its source visible in an merror element.
func TestMathDegradesInPlace(t *testing.T) {
out, err := Render("$$\\raisebox{1em}{E}$$\n")
if err != nil {
t.Fatalf("render: %v", err)
}
if !strings.Contains(out, "<merror>") || !strings.Contains(out, `\raisebox`) {
t.Fatalf("no honest degradation: %q", out)
}
}
// A body that already carries the private-use sentinel runes is left
// alone rather than spliced into the wrong place.
func TestSentinelRunesDisableMath(t *testing.T) {
src := "Text \uE000" + "0" + "\uE001 with $x$ inside.\n"
out, err := Render(src)
if err != nil {
t.Fatalf("render: %v", err)
}
if strings.Contains(out, "<math") {
t.Fatalf("a sentinel-bearing body was spliced: %q", out)
}
}
// A flowchart or a sequence diagram renders to an inline SVG in a
// wrapper a style sheet can address.
func TestMermaidRendersDiagram(t *testing.T) {
out, err := Render("```mermaid\nflowchart LR\n A --> B\n```\n")
if err != nil {
t.Fatalf("render: %v", err)
}
for _, want := range []string{
`<div class="diagram">`, "<svg", `viewBox=`, "</svg>",
} {
if !strings.Contains(out, want) {
t.Fatalf("missing %q in %q", want, out)
}
}
if strings.Contains(out, "<pre") {
t.Fatalf("the code block survived the rendered diagram: %q", out)
}
}
// A diagram type outside the two families the library carries keeps its
// code block, so the author sees the source that was refused.
func TestMermaidRefusalKeepsCode(t *testing.T) {
src := "```mermaid\nstateDiagram-v2\n [*] --> calm\n```\n"
out, err := Render(src)
if err != nil {
t.Fatalf("render: %v", err)
}
if !strings.Contains(out, `class="language-mermaid"`) || strings.Contains(out, "<svg") {
t.Fatalf("the refused diagram did not stay source: %q", out)
}
}
// The diagram SVG is spliced in after the sanitiser, so the diagram
// source must not be able to smuggle markup the policy would have
// refused: a hostile label or interaction line may at worst break the
// drawing, never open an element, name an event handler inside a tag or
// plant a javascript: URL. Label text itself is escaped by the renderer
// and stays inert, so the assertions look at markup positions, not at
// the mere presence of a word.
func TestMermaidSourceCannotInjectMarkup(t *testing.T) {
// handlerInTag matches an event handler attribute inside a tag, and
// scriptURL an attribute whose URL is a javascript: one.
handlerInTag := regexp.MustCompile(`(?is)<[a-z][^>]*\bon[a-z]+\s*=`)
scriptURL := regexp.MustCompile(`(?is)<[a-z][^>]*(?:href|src)\s*=\s*["']\s*javascript:`)
sources := []string{
"flowchart LR\n A[\"<img src=x onerror=alert(1)>\"] --> B\n",
"flowchart LR\n A[\"<script>alert(1)</script>\"] --> B\n",
"flowchart LR\n A[\"x\" onmouseover=\"alert(1)\"] --> B\n",
"flowchart LR\n A --> B\n click B \"javascript:alert(1)\"\n",
}
for _, src := range sources {
out, err := Render("```mermaid\n" + src + "```\n")
if err != nil {
t.Fatalf("render %q: %v", src, err)
}
lower := strings.ToLower(out)
for _, banned := range []string{"<img", "<script"} {
if strings.Contains(lower, banned) {
t.Fatalf("%q reached the page through the diagram: %q", banned, out)
}
}
if loc := handlerInTag.FindString(lower); loc != "" {
t.Fatalf("an event handler reached a tag through the diagram (%q): %q", loc, out)
}
if loc := scriptURL.FindString(lower); loc != "" {
t.Fatalf("a javascript URL reached a tag through the diagram (%q): %q", loc, out)
}
}
}
// Definition lists survive sanitisation as themselves: the terms stay
// dt, the definitions dd.
func TestDefinitionListSurvives(t *testing.T) {
out, err := Render("Term\n: definition\n")
if err != nil {
t.Fatalf("render: %v", err)
}
for _, want := range []string{"<dl>", "<dt>Term</dt>", "<dd>definition</dd>", "</dl>"} {
if !strings.Contains(out, want) {
t.Fatalf("missing %q in %q", want, out)
}
}
}
// The footnote round trip keeps its ids and markers: the reference
// carries the id the back reference points back to.
func TestFootnoteMarkersSurvive(t *testing.T) {
out, err := Render("Text[^1].\n\n[^1]: The note.\n")
if err != nil {
t.Fatalf("render: %v", err)
}
for _, want := range []string{
`href="#fn-1"`, `id="fnref-1"`, `data-footnote-ref`, `id="fn-1"`,
} {
if !strings.Contains(out, want) {
t.Fatalf("missing %q in %q", want, out)
}
}
}
// Two headings of the same text are told apart by a numeric suffix. A
// heading of Czech prose keeps the id the previous renderer gave it:
// the diacritics are dropped, exactly as the anchors already published
// spell them.
func TestHeadingSlugs(t *testing.T) {
out, _, err := RenderWithTOC("## Same\n\ntext\n\n## Same\n\n## Čeština pro vědce\n")
if err != nil {
t.Fatalf("render: %v", err)
}
for _, want := range []string{`id="same"`, `id="same-1"`, `id="etina-pro-vdce"`} {
if !strings.Contains(out, want) {
t.Fatalf("missing %q in %q", want, out)
}
}
}
+538
View File
@@ -0,0 +1,538 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package markdown
import (
"regexp"
"strconv"
"strings"
"sourcedock.dev/petrbalvin/scriptorium"
)
// The mathematics pass turns TeX runs into MathML: $$…$$ occupying a
// whole line becomes a display block, $…$ within one line becomes an
// inline element. scriptorium's Markdown grammar knows nothing about
// dollars, so the runs are lifted out of the source before rendering,
// each replaced by a placeholder of two private-use runes around its
// index, and the MathML is spliced back into the rendered HTML in the
// placeholder's place. A run outside the mappable surface is not
// refused: scriptorium degrades it in place, its verbatim source inside
// an merror element, so nothing is silently mistranslated.
// mathSpan is one equation lifted out of the source.
type mathSpan struct {
display bool
src string
}
// The placeholder runes come from Unicode's private-use area, so no
// authored body contains them; a body that somehow does is left without
// mathematics rather than spliced into the wrong place.
const (
sentinelOpen = '\uE000'
sentinelClose = '\uE001'
)
func mathToken(i int) string {
return string(sentinelOpen) + strconv.Itoa(i) + string(sentinelClose)
}
// lineKind tells how the previous emitted line ended, which is what the
// display rules need: an equation that follows text needs a separating
// blank line, or the renderer keeps it inside the paragraph above it.
type lineKind int
const (
prevBlank lineKind = iota
prevText
prevDisplay
)
// extractMath rewrites the source with placeholders and returns the
// equations in the order their placeholders appear.
func extractMath(src string) (string, []mathSpan) {
if strings.ContainsRune(src, sentinelOpen) || strings.ContainsRune(src, sentinelClose) {
return src, nil
}
var spans []mathSpan
next := func(display bool, src string) string {
spans = append(spans, mathSpan{display: display, src: src})
return mathToken(len(spans) - 1)
}
var out strings.Builder
emit := func(line string) {
out.WriteString(line)
out.WriteByte('\n')
}
fenceChar := byte(0)
fenceLen := 0
inCode := false
inHTML := false
pendingTick := 0
prev := prevBlank
// The lines are indexed rather than streamed: the multi-line display
// collector looks ahead from the line it stands on.
lines := strings.Split(src, "\n")
for i := 0; i < len(lines); i++ {
line := lines[i]
indent := len(line) - len(strings.TrimLeft(line, " \t"))
blank := strings.TrimSpace(line) == ""
// A fenced code block passes every line through verbatim.
if fenceChar != 0 {
emit(line)
if isClosingFence(line, fenceChar, fenceLen) {
fenceChar = 0
prev = prevBlank
}
continue
}
// The code-span cover of the line, carrying any run an earlier
// line left open. A span that has not closed keeps the whole
// line out of every other rule.
covered, open := codeSpans(line, pendingTick)
if open > 0 {
emit(line)
pendingTick = open
prev = prevText
continue
}
pendingTick = 0
startsCovered := len(covered) > 0 && covered[0]
if !startsCovered {
if c, n, ok := openingFence(line); ok {
fenceChar, fenceLen = c, n
emit(line)
continue
}
}
// An indented code block: entered from a blank line, left by the
// first line that is blank or carries less indentation.
if inCode {
if blank || indent >= 4 {
emit(line)
continue
}
inCode = false
} else if indent >= 4 && prev == prevBlank && !blank {
inCode = true
emit(line)
continue
}
// A raw HTML block runs to the next blank line, and its dollars
// are markup, not mathematics.
if inHTML {
emit(line)
if blank {
inHTML = false
prev = prevBlank
}
continue
}
if !startsCovered && startsHTMLBlock(line) {
inHTML = true
emit(line)
continue
}
if blank {
emit(line)
prev = prevBlank
continue
}
quotePrefix, rest := stripQuoteMarkers(line)
listPrefix, item, inList := stripListMarker(rest)
if inner, ok := displayMath(item); ok {
if !inList && prev != prevBlank {
emit(strings.TrimRight(quotePrefix, " \t"))
}
emit(quotePrefix + listPrefix + next(true, inner))
prev = prevDisplay
continue
}
if parts, end, ok := collectDisplayBlock(lines, i, item); ok {
if !inList && prev != prevBlank {
emit("")
}
emit(quotePrefix + listPrefix + next(true, strings.Join(parts, "\n")))
prev = prevDisplay
i = end
continue
}
if prev == prevDisplay && !inList {
emit(strings.TrimRight(quotePrefix, " \t"))
}
off := len(quotePrefix) + len(listPrefix)
emit(quotePrefix + listPrefix + renderInlineMath(item, covered, off, next))
prev = prevText
}
return out.String(), spans
}
// maxMathBlockLines bounds how far a multi-line display block may reach
// for its closing $$. A block the author never closed then falls back to
// literal text instead of swallowing the rest of the body.
const maxMathBlockLines = 64
// collectDisplayBlock looks ahead from lines[i], whose content opens a
// $$ block it does not close on the same line, for the line that closes
// it. The content lines between become the parts of one display
// equation. The collection stays inside one paragraph: a blank line, a
// code fence, a blockquote marker or the line bound ends the search and
// the block is refused, so a stray $$ stays the text it looks like.
func collectDisplayBlock(lines []string, i int, item string) (parts []string, end int, ok bool) {
if !strings.HasPrefix(item, "$$") {
return nil, 0, false
}
first := item[2:]
if strings.Contains(first, "$$") {
return nil, 0, false
}
if strings.TrimSpace(first) != "" {
parts = append(parts, first)
}
for j := i + 1; j < len(lines) && j-i <= maxMathBlockLines; j++ {
t := strings.TrimSpace(lines[j])
if t == "" || strings.HasPrefix(t, ">") {
return nil, 0, false
}
if _, _, fenced := openingFence(lines[j]); fenced {
return nil, 0, false
}
if strings.HasSuffix(t, "$$") && backslashRun(t, len(t)-2)%2 == 0 {
tail := t[:len(t)-2]
if strings.Contains(tail, "$$") {
return nil, 0, false
}
if strings.TrimSpace(tail) != "" {
parts = append(parts, tail)
}
if len(parts) == 0 || strings.TrimSpace(strings.Join(parts, "")) == "" {
return nil, 0, false
}
return parts, j, true
}
parts = append(parts, t)
}
return nil, 0, false
}
// renderInlineMath replaces the $…$ runs of one line with placeholders,
// leaving the bytes inside backtick code spans and the dollars written
// \$ alone. The cover was computed for the whole source line, so off
// tells where the line's remaining content begins in it.
func renderInlineMath(line string, covered []bool, off int, next func(bool, string) string) string {
var b strings.Builder
i := 0
for i < len(line) {
if i+off < len(covered) && covered[i+off] {
b.WriteByte(line[i])
i++
continue
}
if line[i] == '$' && backslashRun(line, i)%2 == 0 {
if value, consumed, ok := matchInlineMath(line[i:]); ok {
b.WriteString(next(false, value))
i += consumed
continue
}
}
b.WriteByte(line[i])
i++
}
return b.String()
}
// displayMath reports whether the whole of a line's content is one
// $$…$$ run, and returns the mathematics between the fences. A run that
// is empty, that hides another $$ or that shares its line with anything
// else is not a display equation.
func displayMath(t string) (string, bool) {
if len(t) < 5 || !strings.HasPrefix(t, "$$") || !strings.HasSuffix(t, "$$") {
return "", false
}
inner := t[2 : len(t)-2]
if strings.Contains(inner, "$$") || strings.TrimSpace(inner) == "" {
return "", false
}
return inner, true
}
// matchInlineMath matches one $…$ run at the head of line. The guards
// mirror the shape of real prose: the run must not be empty, may not
// start or end with a space, may not close before a digit, and may not
// contain a dollar inside, so a sentence with two dollar amounts does
// not become a phantom equation, and a run whose opening dollar is a
// closer of an equation broken across lines never swallows the prose
// around it into mathematics.
func matchInlineMath(line string) (value string, consumed int, ok bool) {
if len(line) < 3 || line[0] != '$' || line[1] == '$' || line[1] == ' ' {
return "", 0, false
}
for i := 1; i < len(line); i++ {
if line[i] == '\\' {
i++ // an escaped character is never the closer
continue
}
if line[i] != '$' || i == 1 {
continue
}
if line[i-1] == ' ' {
continue
}
if i+1 < len(line) && isDigit(line[i+1]) {
continue // currency: the next run of digits belongs outside
}
value := line[1:i]
if strings.ContainsRune(value, '$') {
return "", 0, false
}
return value, i + 1, true
}
return "", 0, false
}
func isDigit(b byte) bool { return '0' <= b && b <= '9' }
// backslashRun counts the backslashes immediately before line[i]; an
// odd count means the byte is escaped.
func backslashRun(line string, i int) int {
n := 0
for j := i - 1; j >= 0 && line[j] == '\\'; j-- {
n++
}
return n
}
// codeSpans marks the bytes of line that sit inside a backtick code
// span and reports the length of a run the line leaves open. A span
// opens with a run of n backticks and closes with the next run of
// exactly n; a run left open is carried to the next line by the
// pending state, and while it is open no dollar on the line starts
// mathematics.
func codeSpans(line string, pending int) (covered []bool, open int) {
covered = make([]bool, len(line))
open = pending
i := 0
if pending > 0 {
end := findTickRun(line, pending)
if end < 0 {
for j := range covered {
covered[j] = true
}
return covered, pending
}
for j := 0; j < end+pending; j++ {
covered[j] = true
}
i = end + pending
open = 0
}
for i < len(line) {
if line[i] != '`' {
i++
continue
}
n := 0
for i+n < len(line) && line[i+n] == '`' {
n++
}
end := findTickRun(line[i+n:], n)
if end < 0 {
if open == 0 {
open = n
}
i += n
continue
}
closeAt := i + n + end
for j := i; j < closeAt+n; j++ {
covered[j] = true
}
i = closeAt + n
}
return covered, open
}
// findTickRun returns the index in line where a run of exactly n
// backticks begins, or -1 when there is none.
func findTickRun(line string, n int) int {
for i := 0; i < len(line); {
if line[i] != '`' {
i++
continue
}
run := 0
for i+run < len(line) && line[i+run] == '`' {
run++
}
if run == n {
return i
}
i += run
}
return -1
}
// openingFence reports whether the line opens a fenced code block, and
// with which character and length.
func openingFence(line string) (byte, int, bool) {
t := strings.TrimLeft(line, " \t")
if len(line)-len(t) > 3 {
return 0, 0, false
}
if n := fenceRun(t, '`'); n > 0 {
return '`', n, true
}
if n := fenceRun(t, '~'); n > 0 {
return '~', n, true
}
return 0, 0, false
}
// fenceRun returns the length of a fence of c at the head of t, which
// the rest of the line may follow only with spaces, or 0 when this is
// not a fence.
func fenceRun(t string, c byte) int {
n := 0
for n < len(t) && t[n] == c {
n++
}
if n < 3 {
return 0
}
for _, r := range t[n:] {
if r != ' ' && r != '\t' {
return 0
}
}
return n
}
// isClosingFence reports whether the line closes an open fence.
func isClosingFence(line string, c byte, n int) bool {
t := strings.TrimLeft(line, " \t")
if len(line)-len(t) > 3 {
return false
}
run := 0
for run < len(t) && t[run] == c {
run++
}
if run < n {
return false
}
for _, r := range t[run:] {
if r != ' ' && r != '\t' {
return false
}
}
return true
}
// startsHTMLBlock approximates the CommonMark HTML block: a line that
// opens with a tag, a closing tag, a comment or a declaration starts
// one, and the block then runs to the next blank line.
func startsHTMLBlock(line string) bool {
t := strings.TrimLeft(line, " \t")
if len(t) < 2 || t[0] != '<' {
return false
}
c := t[1]
return c == '/' || c == '!' || c == '?' ||
('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z')
}
// stripQuoteMarkers removes the blockquote markers from the head of the
// line and returns everything consumed with what remains.
func stripQuoteMarkers(line string) (prefix, rest string) {
rest = line
for {
j := 0
for j < len(rest) && (rest[j] == ' ' || rest[j] == '\t') {
j++
}
if j >= len(rest) || rest[j] != '>' {
break
}
rest = rest[j+1:]
}
return line[:len(line)-len(rest)], rest
}
// stripListMarker removes one list marker from the head of the line, so
// an equation that is a list item's whole content is still recognised
// as display mathematics inside the item.
func stripListMarker(line string) (prefix, rest string, ok bool) {
j := 0
for j < len(line) && (line[j] == ' ' || line[j] == '\t') {
j++
}
rest = line[j:]
if strings.HasPrefix(rest, "- ") || strings.HasPrefix(rest, "* ") || strings.HasPrefix(rest, "+ ") {
rest = rest[2:]
} else {
digits := 0
for digits < len(rest) && digits < 9 && isDigit(rest[digits]) {
digits++
}
if digits == 0 || digits+1 >= len(rest) {
return "", line, false
}
if (rest[digits] == '.' || rest[digits] == ')') && rest[digits+1] == ' ' {
rest = rest[digits+2:]
} else {
return "", line, false
}
}
rest = strings.TrimLeft(rest, " \t")
if rest == "" {
return "", line, false
}
return line[:len(line)-len(rest)], rest, true
}
// spliceMath puts the rendered MathML into the HTML in each
// placeholder's place. A placeholder alone in its paragraph becomes the
// display wrapper; any other position takes the math element as it
// stands, which keeps the HTML valid where a display equation shares
// its paragraph with text or sits in a tight list item.
func spliceMath(html string, spans []mathSpan) string {
for i, span := range spans {
math := renderMathSpan(span)
alone := regexp.MustCompile(`(?s)<p>\s*` + mathToken(i) + `\s*</p>`)
if alone.MatchString(html) {
html = alone.ReplaceAllString(html, regexpEscapeRepl(math))
continue
}
html = strings.Replace(html, mathToken(i), math, 1)
}
return html
}
// regexpEscapeRepl guards the replacement text of ReplaceAllString,
// where a dollar sign would otherwise read as a capture group.
func regexpEscapeRepl(s string) string {
return strings.ReplaceAll(s, "$", "$$")
}
// renderMathSpan renders one equation through scriptorium. Rendering
// never fails: a construct outside the mappable surface degrades to its
// verbatim source inside an merror element, in place.
func renderMathSpan(span mathSpan) string {
if span.display {
return `<div class="math math-display">` + "\n" +
string(scriptorium.RenderMathDisplay([]byte(span.src))) + "\n</div>"
}
return string(scriptorium.RenderMath([]byte(span.src)))
}
+6
View File
@@ -0,0 +1,6 @@
=== HTML ===
<p>Hello <em>world</em> and <strong>bold</strong></p>
=== TOC ===
<div class="toc">
<ul></ul>
</div>
+11
View File
@@ -0,0 +1,11 @@
=== HTML ===
<p>Prose before.</p>
<pre><code class="language-go">fmt.Println(&#34;hi&#34;)
</code></pre>
<pre><code>indented code
</code></pre>
<p>Inline <code>code</code> too.</p>
=== TOC ===
<div class="toc">
<ul></ul>
</div>
+1
View File
@@ -0,0 +1 @@
Hello *world* and **bold**
+9
View File
@@ -0,0 +1,9 @@
Prose before.
```go
fmt.Println("hi")
```
indented code
Inline `code` too.
+3
View File
@@ -0,0 +1,3 @@
![alt text](/media/pic.webp "Caption here")
![plain](/media/other.webp)
+9
View File
@@ -0,0 +1,9 @@
# Title One
Some [link](https://example.com) here.
## Sub & "head"
### Deep
Text.
+7
View File
@@ -0,0 +1,7 @@
<script>alert(1)</script>
<img src="/media/x.webp" onerror="alert(1)">
[click](javascript:alert(1))
<iframe src="https://evil"></iframe>
+28
View File
@@ -0,0 +1,28 @@
# Math
Inline radiation: $E = h\nu$ redshifted by $\sqrt{g_{tt}}$.
$$E = h\nu\,\sqrt{g_{tt}(r)} = \text{konst.} \quad (1)$$
The metric reads $ds^2 = -g_{tt}(r)c^2dt^2 + dr^2 + r^2d\Omega^2$, and a
photon loses energy as $\frac{E(t)}{E_0} = e^{-Ht}$.
$$1+z = \frac{\nu(r_1)}{\nu(r_2)} = \sqrt{\frac{g_{tt}(r_1)}{g_{tt}(r_2)}}. \quad (2)$$
Prices are not math: the ticket costs $5 and the guide $10 per group.
The metric in prose right above its display form
$$g_{tt}(r) = 1 - \frac{r_s}{r}$$
continues in a sentence after it.
$$r_h = \frac{\sigma}{\sqrt{2\pi G \rho_{\text{amb}}}},
\qquad M_h = \frac{2\sigma^2 r_h}{G}. \quad (7)$$
An equation broken across lines keeps its prose out of mathematics
$\nabla^2 h =
(8\pi/\kappa a)u$: the binding is the force of a cell $\kappa a = c^4/G$,
and the source is the energy.
$$\raisebox{1em}{E} = h\nu$$
Inline degradation too: $\raisebox{1em}{E}$ stays visible where it stands.
+28
View File
@@ -0,0 +1,28 @@
# Diagrams
A flowchart of the observed cycle:
```mermaid
flowchart LR
A[Observation] --> B{Hypothesis}
B -->|confirmed| C[Theory]
B -->|refuted| D[Revised model]
```
A sequence of a measurement:
```mermaid
sequenceDiagram
participant O as Observer
participant S as Source
O->>S: request spectrum
S-->>O: redshifted light
Note over S: proper time runs slow
```
A type the renderer does not carry stays source:
```mermaid
stateDiagram-v2
[*] --> calm
```
+10
View File
@@ -0,0 +1,10 @@
~~struck~~ text
> quote
Footnote[^1]
[^1]: note text
Term
: definition
+3
View File
@@ -0,0 +1,3 @@
| a | b |
|---|---|
| 1 | 2 |
+5
View File
@@ -0,0 +1,5 @@
- [x] done
- [ ] todo
1. ordered
2. items
+7
View File
@@ -0,0 +1,7 @@
=== HTML ===
<p></p><figure><img src="/media/pic.webp" alt="alt text"/><div class="fig-info">i</div><figcaption>Caption here</figcaption></figure><p></p>
<p><img src="/media/other.webp" alt="plain"/></p>
=== TOC ===
<div class="toc">
<ul></ul>
</div>
+20
View File
@@ -0,0 +1,20 @@
=== HTML ===
<h1 id="title-one">Title One</h1>
<p>Some <a rel="noopener noreferrer" href="https://example.com">link</a> here.</p>
<h2 id="sub--head">Sub &amp; &#34;head&#34;</h2>
<h3 id="deep">Deep</h3>
<p>Text.</p>
=== TOC ===
<div class="toc">
<ul>
<li><a rel="noopener noreferrer" href="#title-one">Title One</a>
<ul>
<li><a rel="noopener noreferrer" href="#sub--head">Sub &amp; &#34;head&#34;</a>
<ul>
<li><a rel="noopener noreferrer" href="#deep">Deep</a></li>
</ul>
</li>
</ul>
</li>
</ul>
</div>
+9
View File
@@ -0,0 +1,9 @@
=== HTML ===
<img src="/media/x.webp">
<p>click</p>
=== TOC ===
<div class="toc">
<ul></ul>
</div>
+34
View File
@@ -0,0 +1,34 @@
=== HTML ===
<h1 id="math">Math</h1>
<p>Inline radiation: <math xmlns="http://www.w3.org/1998/Math/MathML"><mi>E</mi><mo>=</mo><mi>h</mi><mi>ν</mi></math> redshifted by <math xmlns="http://www.w3.org/1998/Math/MathML"><msqrt><msub><mi>g</mi><mrow><mi>t</mi><mi>t</mi></mrow></msub></msqrt></math>.</p>
<div class="math math-display">
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"><mi>E</mi><mo>=</mo><mi>h</mi><mi>ν</mi><mspace width="0.1667em"></mspace><msqrt><mrow><msub><mi>g</mi><mrow><mi>t</mi><mi>t</mi></mrow></msub><mo>(</mo><mi>r</mi><mo>)</mo></mrow></msqrt><mo>=</mo><mtext>konst.</mtext><mspace width="1em"></mspace><mo>(</mo><mn>1</mn><mo>)</mo></math>
</div>
<p>The metric reads <math xmlns="http://www.w3.org/1998/Math/MathML"><mi>d</mi><msup><mi>s</mi><mn>2</mn></msup><mo>=</mo><mo>-</mo><msub><mi>g</mi><mrow><mi>t</mi><mi>t</mi></mrow></msub><mo>(</mo><mi>r</mi><mo>)</mo><msup><mi>c</mi><mn>2</mn></msup><mi>d</mi><msup><mi>t</mi><mn>2</mn></msup><mo>+</mo><mi>d</mi><msup><mi>r</mi><mn>2</mn></msup><mo>+</mo><msup><mi>r</mi><mn>2</mn></msup><mi>d</mi><msup><mi>Ω</mi><mn>2</mn></msup></math>, and a
photon loses energy as <math xmlns="http://www.w3.org/1998/Math/MathML"><mfrac><mrow><mi>E</mi><mo>(</mo><mi>t</mi><mo>)</mo></mrow><msub><mi>E</mi><mn>0</mn></msub></mfrac><mo>=</mo><msup><mi>e</mi><mrow><mo>-</mo><mi>H</mi><mi>t</mi></mrow></msup></math>.</p>
<div class="math math-display">
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"><mn>1</mn><mo>+</mo><mi>z</mi><mo>=</mo><mfrac><mrow><mi>ν</mi><mo>(</mo><msub><mi>r</mi><mn>1</mn></msub><mo>)</mo></mrow><mrow><mi>ν</mi><mo>(</mo><msub><mi>r</mi><mn>2</mn></msub><mo>)</mo></mrow></mfrac><mo>=</mo><msqrt><mfrac><mrow><msub><mi>g</mi><mrow><mi>t</mi><mi>t</mi></mrow></msub><mo>(</mo><msub><mi>r</mi><mn>1</mn></msub><mo>)</mo></mrow><mrow><msub><mi>g</mi><mrow><mi>t</mi><mi>t</mi></mrow></msub><mo>(</mo><msub><mi>r</mi><mn>2</mn></msub><mo>)</mo></mrow></mfrac></msqrt><mo>.</mo><mspace width="1em"></mspace><mo>(</mo><mn>2</mn><mo>)</mo></math>
</div>
<p>Prices are not math: the ticket costs $5 and the guide $10 per group.</p>
<p>The metric in prose right above its display form</p>
<div class="math math-display">
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"><msub><mi>g</mi><mrow><mi>t</mi><mi>t</mi></mrow></msub><mo>(</mo><mi>r</mi><mo>)</mo><mo>=</mo><mn>1</mn><mo>-</mo><mfrac><msub><mi>r</mi><mi>s</mi></msub><mi>r</mi></mfrac></math>
</div>
<p>continues in a sentence after it.</p>
<div class="math math-display">
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"><msub><mi>r</mi><mi>h</mi></msub><mo>=</mo><mfrac><mi>σ</mi><msqrt><mrow><mn>2</mn><mi>π</mi><mi>G</mi><msub><mi>ρ</mi><mtext>amb</mtext></msub></mrow></msqrt></mfrac><mo>,</mo><mspace width="2em"></mspace><msub><mi>M</mi><mi>h</mi></msub><mo>=</mo><mfrac><mrow><mn>2</mn><msup><mi>σ</mi><mn>2</mn></msup><msub><mi>r</mi><mi>h</mi></msub></mrow><mi>G</mi></mfrac><mo>.</mo><mspace width="1em"></mspace><mo>(</mo><mn>7</mn><mo>)</mo></math>
</div>
<p>An equation broken across lines keeps its prose out of mathematics
$\nabla^2 h =
(8\pi/\kappa a)u$: the binding is the force of a cell <math xmlns="http://www.w3.org/1998/Math/MathML"><mi>κ</mi><mi>a</mi><mo>=</mo><msup><mi>c</mi><mn>4</mn></msup><mo>/</mo><mi>G</mi></math>,
and the source is the energy.</p>
<div class="math math-display">
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"><merror><mtext>\raisebox</mtext></merror><mrow><mn>1</mn><mi>e</mi><mi>m</mi></mrow><mi>E</mi><mo>=</mo><mi>h</mi><mi>ν</mi></math>
</div>
<p>Inline degradation too: <math xmlns="http://www.w3.org/1998/Math/MathML"><merror><mtext>\raisebox</mtext></merror><mrow><mn>1</mn><mi>e</mi><mi>m</mi></mrow><mi>E</mi></math> stays visible where it stands.</p>
=== TOC ===
<div class="toc">
<ul>
<li><a rel="noopener noreferrer" href="#math">Math</a></li>
</ul>
</div>
+20
View File
@@ -0,0 +1,20 @@
=== HTML ===
<h1 id="diagrams">Diagrams</h1>
<p>A flowchart of the observed cycle:</p>
<div class="diagram">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 388 340" font-family="sans-serif"><path d="M 76 194 C 108 194, 108 194, 140 194" fill="none" stroke="#555" stroke-width="2"/><path d="M 248 194 C 280 194, 280 104, 312 104" fill="none" stroke="#555" stroke-width="2"/><path d="M 248 194 C 280 194, 280 242, 312 242" fill="none" stroke="#555" stroke-width="2"/><polygon points="140,194 129,199 129,189" fill="#555"/><polygon points="312,104 301,109 301,99" fill="#555"/><polygon points="312,242 301,247 301,237" fill="#555"/><rect x="32" y="136" width="44" height="116" fill="#ffffff" stroke="#333" stroke-width="1.5"/><text x="54" y="199" text-anchor="middle" font-size="14">Observation</text><polygon points="194,86 248,194 194,302 140,194" fill="#ffffff" stroke="#333" stroke-width="1.5"/><text x="194" y="199" text-anchor="middle" font-size="14">Hypothesis</text><rect x="312" y="56" width="44" height="96" fill="#ffffff" stroke="#333" stroke-width="1.5"/><text x="334" y="109" text-anchor="middle" font-size="14">Theory</text><rect x="312" y="176" width="44" height="132" fill="#ffffff" stroke="#333" stroke-width="1.5"/><text x="334" y="247" text-anchor="middle" font-size="14">Revised model</text><text x="280" y="142" text-anchor="middle" font-size="12">confirmed</text><text x="280" y="211" text-anchor="middle" font-size="12">refuted</text></svg>
</div>
<p>A sequence of a measurement:</p>
<div class="diagram">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 348 272" font-family="sans-serif"><line x1="99" y1="68" x2="99" y2="248" stroke="#999" stroke-dasharray="5 5"/><line x1="249" y1="68" x2="249" y2="248" stroke="#999" stroke-dasharray="5 5"/><rect x="36" y="24" width="126" height="44" rx="8" fill="#eef2f7" stroke="#333" stroke-width="1.5"/><text x="99" y="51" text-anchor="middle" font-size="14" font-weight="bold">Observer</text><rect x="186" y="24" width="126" height="44" rx="8" fill="#eef2f7" stroke="#333" stroke-width="1.5"/><text x="249" y="51" text-anchor="middle" font-size="14" font-weight="bold">Source</text><line x1="99" y1="126" x2="249" y2="126" stroke="#333" stroke-width="1.8"/><polygon points="249,126 238,121 238,131" fill="#333"/><text x="174" y="119" text-anchor="middle" font-size="13">request spectrum</text><line x1="249" y1="174" x2="99" y2="174" stroke="#333" stroke-width="1.8" stroke-dasharray="7 5"/><polygon points="99,174 110,169 110,179" fill="#333"/><text x="174" y="167" text-anchor="middle" font-size="13">redshifted light</text><rect x="219" y="196" width="60" height="30" rx="4" fill="#fffbe0" stroke="#999"/><text x="249" y="216" text-anchor="middle" font-size="13">proper time runs slow</text></svg>
</div>
<p>A type the renderer does not carry stays source:</p>
<pre><code class="language-mermaid">stateDiagram-v2
[*] --&gt; calm
</code></pre>
=== TOC ===
<div class="toc">
<ul>
<li><a rel="noopener noreferrer" href="#diagrams">Diagrams</a></li>
</ul>
</div>
+21
View File
@@ -0,0 +1,21 @@
=== HTML ===
<p><del>struck</del> text</p>
<blockquote>
<p>quote</p>
</blockquote>
<p>Footnote<sup class="footnote-ref"><a rel="noopener noreferrer" href="#fn-1" id="fnref-1" data-footnote-ref="">1</a></sup></p>
<dl>
<dt>Term</dt>
<dd>definition</dd>
</dl>
<section class="footnotes" data-footnotes="">
<ol>
<li id="fn-1">
<p>note text <a rel="noopener noreferrer" href="#fnref-1" class="data-footnote-backref" aria-label="Back to reference 1">↩</a></p>
</li>
</ol>
</section>
=== TOC ===
<div class="toc">
<ul></ul>
</div>
+19
View File
@@ -0,0 +1,19 @@
=== HTML ===
<table>
<thead>
<tr>
<th>a</th>
<th>b</th>
</tr>
</thead>
<tbody>
<tr>
<td>1</td>
<td>2</td>
</tr>
</tbody>
</table>
=== TOC ===
<div class="toc">
<ul></ul>
</div>
+13
View File
@@ -0,0 +1,13 @@
=== HTML ===
<ul>
<li><input checked="" disabled="" type="checkbox"> done</li>
<li><input disabled="" type="checkbox"> todo</li>
</ul>
<ol>
<li>ordered</li>
<li>items</li>
</ol>
=== TOC ===
<div class="toc">
<ul></ul>
</div>