Files

584 lines
19 KiB
Go
Raw Permalink Normal View History

2026-09-18 12:03:35 +02:00
// 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)
}
}
}