// Copyright (c) 2026 Petr Balvín (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, "world") { t.Fatalf("out = %q", out) } } func TestRenderStripsScript(t *testing.T) { out, err := Render(``) if err != nil { t.Fatalf("Render: %v", err) } if strings.Contains(out, "`) 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, "gone") { 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, "") || !strings.Contains(out, "", "
Popisek
", `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 "x")`) if err != nil { t.Fatalf("Render: %v", err) } if strings.Contains(out, "
") { 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{`
`, `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, "
    ") < 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 := `
    ` + "\n
      \n
      \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, "
    • "); 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, "
        ") != 2 { t.Fatalf("want one nested list, got %d lists:\n%s", strings.Count(toc, "
          "), 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(`![alt](/media/p.webp "Tom & Jerry")`) if err != nil { t.Fatalf("render: %v", err) } if !strings.Contains(out, "
          Tom & Jerry
          ") { 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(``) if err != nil { t.Fatalf("render: %v", err) } if !strings.Contains(raw, "
          Tom & Jerry
          ") { 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é") { 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, "") { t.Fatalf("the broken run degraded inside math: %q", out) } // The prose never becomes mathematics: ž occurs only in prose. if strings.Contains(out, "ž") { t.Fatalf("prose was swallowed into math: %q", out) } // The whole equation after the prose still renders. if !strings.Contains(out, "κ") { 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{ "

          Text above.

          ", `
          `, `display="block"`, "σ", "M", "7", "

          text below

          ", } { 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, "The metric reads

          ", `
          `, `display="block"`, "

          and continues.

          ", } { 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, "Costs $5 and $10 per group.

          ") { t.Fatalf("amounts became mathematics: %q", out) } if !strings.Contains(out, "Split $$ 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, "") || !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, " B\n```\n") if err != nil { t.Fatalf("render: %v", err) } for _, want := range []string{ `
          `, "", } { if !strings.Contains(out, want) { t.Fatalf("missing %q in %q", want, out) } } if strings.Contains(out, "]*\bon[a-z]+\s*=`) scriptURL := regexp.MustCompile(`(?is)<[a-z][^>]*(?:href|src)\s*=\s*["']\s*javascript:`) sources := []string{ "flowchart LR\n A[\"\"] --> B\n", "flowchart LR\n A[\"\"] --> 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{"", "
          Term
          ", "
          definition
          ", ""} { 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) } } }