Files
volumen/internal/markdown/markdown_test.go
T
petrbalvin f8ed33df83
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
Initial commit
Assisted-by: GLM 5.3
2026-09-29 10:03:32 +02:00

584 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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)
}
}
}