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
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:
@@ -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>"
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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;" 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
|
||||
}
|
||||
@@ -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(``)
|
||||
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(``)
|
||||
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(``)
|
||||
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;".
|
||||
func TestFigureCaptionEscapesOnce(t *testing.T) {
|
||||
out, _, err := RenderWithTOC(``)
|
||||
if err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
if !strings.Contains(out, "<figcaption>Tom & Jerry</figcaption>") {
|
||||
t.Fatalf("caption = %s", out)
|
||||
}
|
||||
if strings.Contains(out, "&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 & 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é\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, "&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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -0,0 +1,6 @@
|
||||
=== HTML ===
|
||||
<p>Hello <em>world</em> and <strong>bold</strong></p>
|
||||
=== TOC ===
|
||||
<div class="toc">
|
||||
<ul></ul>
|
||||
</div>
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
=== HTML ===
|
||||
<p>Prose before.</p>
|
||||
<pre><code class="language-go">fmt.Println("hi")
|
||||
</code></pre>
|
||||
<pre><code>indented code
|
||||
</code></pre>
|
||||
<p>Inline <code>code</code> too.</p>
|
||||
=== TOC ===
|
||||
<div class="toc">
|
||||
<ul></ul>
|
||||
</div>
|
||||
+1
@@ -0,0 +1 @@
|
||||
Hello *world* and **bold**
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
Prose before.
|
||||
|
||||
```go
|
||||
fmt.Println("hi")
|
||||
```
|
||||
|
||||
indented code
|
||||
|
||||
Inline `code` too.
|
||||
+3
@@ -0,0 +1,3 @@
|
||||

|
||||
|
||||

|
||||
@@ -0,0 +1,9 @@
|
||||
# Title One
|
||||
|
||||
Some [link](https://example.com) here.
|
||||
|
||||
## Sub & "head"
|
||||
|
||||
### Deep
|
||||
|
||||
Text.
|
||||
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,10 @@
|
||||
~~struck~~ text
|
||||
|
||||
> quote
|
||||
|
||||
Footnote[^1]
|
||||
|
||||
[^1]: note text
|
||||
|
||||
Term
|
||||
: definition
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
| a | b |
|
||||
|---|---|
|
||||
| 1 | 2 |
|
||||
@@ -0,0 +1,5 @@
|
||||
- [x] done
|
||||
- [ ] todo
|
||||
|
||||
1. ordered
|
||||
2. items
|
||||
+7
@@ -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
@@ -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 & "head"</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 & "head"</a>
|
||||
<ul>
|
||||
<li><a rel="noopener noreferrer" href="#deep">Deep</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
=== HTML ===
|
||||
|
||||
<img src="/media/x.webp">
|
||||
<p>click</p>
|
||||
|
||||
=== TOC ===
|
||||
<div class="toc">
|
||||
<ul></ul>
|
||||
</div>
|
||||
Vendored
+34
@@ -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
@@ -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
|
||||
[*] --> calm
|
||||
</code></pre>
|
||||
=== TOC ===
|
||||
<div class="toc">
|
||||
<ul>
|
||||
<li><a rel="noopener noreferrer" href="#diagrams">Diagrams</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
Vendored
+21
@@ -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
@@ -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
@@ -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>
|
||||
Reference in New Issue
Block a user