catcrafts.net/tests/ShouldRenderMarkdown/main.cpp
Jorijn van der Graaf 4666c1995f
All checks were successful
Deploy / build-deploy (push) Successful in 2m23s
markdown fix
2026-08-18 11:12:24 +02:00

270 lines
15 KiB
C++

/*
catcrafts.net
Copyright (C) 2026 Catcrafts
The source code of this website is made available for viewing purposes only.
No permission is granted to copy, modify, distribute, or create derivative works.
*/
// The Markdown renderer, which is the newest place untrusted text becomes
// markup — post bodies are fetched from someone else's server, so every one of
// these assertions is ultimately about the same thing: nothing in a body can
// escape into the document. The structural cases are here too, because a parser
// that silently drops a construct loses content invisibly.
import std;
import Catcrafts.Shared;
using namespace Catcrafts;
namespace {
int failures = 0;
void Check(bool ok, std::string_view what, std::string_view got = {}) {
if (ok) return;
++failures;
std::println(std::cerr, "FAIL: {}{}{}", what,
got.empty() ? "" : " got: ", got);
}
void CheckEq(const Html::SafeHtml& actual, std::string_view expected, std::string_view what) {
Check(actual.View() == expected, what, actual.View());
}
} // namespace
int main() {
auto md = [](std::string_view text,
std::span<const PostMedia> media = {}) {
return Markdown::Render(text, media);
};
// ── the guarantee ─────────────────────────────────────────────────
CheckEq(md("<script>alert(1)</script>"),
"<p>&lt;script&gt;alert(1)&lt;/script&gt;</p>", "md: html is text, never markup");
CheckEq(md("![x](javascript:alert(1))"),
R"(<div class="post-media"><img class="post-media__item" loading="lazy" )"
R"(decoding="async" alt="x" src="#"></div>)",
"md: javascript: image source neutralised");
CheckEq(md("[x](javascript:alert(1))"),
R"(<p><a href="#" rel="noopener">x</a></p>)",
"md: javascript: link neutralised");
CheckEq(md("a \" b & c"), "<p>a &quot; b &amp; c</p>", "md: quotes and ampersands escaped");
// A code span is verbatim text, and verbatim is exactly where an escaper
// is most often forgotten.
CheckEq(md("`<b>`"), "<p><code>&lt;b&gt;</code></p>", "md: code span escaped");
// ── blocks ────────────────────────────────────────────────────────
CheckEq(md(""), "", "md: empty body renders nothing");
CheckEq(md("plain text"), "<p>plain text</p>", "md: paragraph");
// Demotion by one: the page h1 is the post title, so a body's own top-level
// heading is a section within it.
CheckEq(md("# Heading"), "<h2>Heading</h2>", "md: h1 demoted to h2");
CheckEq(md("### Heading"), "<h4>Heading</h4>", "md: h3 demoted to h4");
CheckEq(md("#nothashtag"), "<p>#nothashtag</p>", "md: # without a space is not a heading");
CheckEq(md("> quoted"),
R"(<blockquote class="post-body__quote"><p>quoted</p></blockquote>)",
"md: blockquote");
// The quoted lines are re-parsed, so a multi-paragraph quote keeps its
// paragraphs instead of collapsing into one run-on line.
CheckEq(md("> one\n>\n> two"),
R"(<blockquote class="post-body__quote"><p>one</p><p>two</p></blockquote>)",
"md: blockquote keeps its paragraphs");
CheckEq(md("- a\n- b"),
R"(<ul class="post-body__list"><li>a</li><li>b</li></ul>)", "md: unordered list");
CheckEq(md("1. a\n2. b"),
R"(<ol class="post-body__list"><li>a</li><li>b</li></ol>)", "md: ordered list");
// A list resumed after an interrupting paragraph continues its numbering.
// Without the start attribute the mini-guide in one of these posts renders
// as steps 1-4 followed by steps 1, 2, 3.
CheckEq(md("5. e"),
R"(<ol class="post-body__list" start="5"><li>e</li></ol>)",
"md: ordered list keeps the number it announced");
// Blank lines between items are spacing, not seven one-item lists.
CheckEq(md("1. a\n\n2. b"),
R"(<ol class="post-body__list"><li>a</li><li>b</li></ol>)",
"md: blank line inside a list does not split it");
CheckEq(md("---"), "<hr>", "md: thematic break");
CheckEq(md("- - -"), "<hr>", "md: spaced rule is not a one-item list");
// Whitespace in pasted terminal output is the content.
CheckEq(md("```\n a\tb\n```"),
"<pre class=\"post-body__code\"><code> a\tb\n</code></pre>",
"md: fenced code is verbatim");
// An unterminated fence must not swallow the document into nothing.
Check(md("```\nx").View().find("<code>x") != std::string_view::npos,
"md: unterminated fence still renders its content");
// ── tables ────────────────────────────────────────────────────────
CheckEq(md("| a | b |\n|---|---|\n| 1 | 2 |"),
R"(<div class="post-body__table"><table>)"
R"(<thead><tr><th>a</th><th>b</th></tr></thead>)"
R"(<tbody><tr><td>1</td><td>2</td></tr></tbody>)"
R"(</table></div>)",
"md: pipe table");
// The shape the carrier table in the camera post is actually written in:
// leading pipe, no trailing one. Requiring both would leave it as prose.
CheckEq(md("| Device | OS\n|---|---|\n| FP6 | pmOS |"),
R"(<div class="post-body__table"><table>)"
R"(<thead><tr><th>Device</th><th>OS</th></tr></thead>)"
R"(<tbody><tr><td>FP6</td><td>pmOS</td></tr></tbody>)"
R"(</table></div>)",
"md: outer pipes are optional independently");
// The whole reason recognition needs two lines. These posts paste pipelines
// and or-lists into prose constantly; none of them is a table.
CheckEq(md("dmesg | grep ufs"), "<p>dmesg | grep ufs</p>",
"md: a pipe in prose is not a table");
// A mismatched column count is far more likely to be prose with pipes in it
// than a table whose author miscounted — so it stays prose, visibly odd.
Check(md("| a | b |\n|---|").View().starts_with("<p>"),
"md: delimiter row must agree about the column count",
md("| a | b |\n|---|").View());
// `---` is a rule, as it is everywhere else in this file. A one-column
// table has to write the pipe.
CheckEq(md("| Device\n---"), "<p>| Device</p><hr>",
"md: a bare dash row is still a thematic break");
CheckEq(md("| a | b | c |\n|:---|:---:|---:|\n| 1 | 2 | 3 |"),
R"(<div class="post-body__table"><table>)"
R"(<thead><tr><th>a</th>)"
R"(<th class="post-body__cell--center">b</th>)"
R"(<th class="post-body__cell--right">c</th></tr></thead>)"
R"(<tbody><tr><td>1</td>)"
R"(<td class="post-body__cell--center">2</td>)"
R"(<td class="post-body__cell--right">3</td></tr></tbody>)"
R"(</table></div>)",
"md: alignment row moves the column, left needs no class");
// A short row is padded so the grid stays rectangular.
Check(md("| a | b |\n|---|---|\n| 1 |").View().find("<td>1</td><td></td>")
!= std::string_view::npos,
"md: a short row is padded to the header width",
md("| a | b |\n|---|---|\n| 1 |").View());
// Cells are prose, so everything inline works in them — and everything
// inline is still escaped in them.
Check(md("| [x](https://e.example) | `<b>` |\n|---|---|").View().find(
R"(<th><a href="https://e.example" rel="noopener">x</a></th>)"
R"(<th><code>&lt;b&gt;</code></th>)") != std::string_view::npos,
"md: cells render inline markup and stay escaped",
md("| [x](https://e.example) | `<b>` |\n|---|---|").View());
// `\|` is how a cell contains a pipe.
Check(md("| a \\| b | c |\n|---|---|").View().find("<th>a | b</th><th>c</th>")
!= std::string_view::npos,
"md: an escaped pipe is cell content, not a cell boundary",
md("| a \\| b | c |\n|---|---|").View());
// The GPU comparison in one of these posts is a table inside a quote.
Check(md("> | a | b |\n> |---|---|\n> | 1 | 2 |").View().starts_with(
R"(<blockquote class="post-body__quote"><div class="post-body__table">)"),
"md: a table inside a blockquote is still a table",
md("> | a | b |\n> |---|---|\n> | 1 | 2 |").View());
// A table under a prose line with no blank line between is still a table:
// absorbing it into the paragraph is the exact failure this all fixes.
Check(md("text\n| a | b |\n|---|---|").View().starts_with("<p>text</p><div"),
"md: a table interrupts a paragraph",
md("text\n| a | b |\n|---|---|").View());
// Prose resumes after the table rather than being eaten as a one-cell row.
Check(md("| a | b |\n|---|---|\n| 1 | 2 |\nafter").View().ends_with("<p>after</p>"),
"md: a line with no pipe ends the table",
md("| a | b |\n|---|---|\n| 1 | 2 |\nafter").View());
// A list marker wins over a table: a line that opens one is a list item.
Check(md("- a | b\n|---|---|").View().starts_with("<ul"),
"md: a list item is not a table header",
md("- a | b\n|---|---|").View());
// ── inline ────────────────────────────────────────────────────────
CheckEq(md("**bold**"), "<p><strong>bold</strong></p>", "md: strong");
CheckEq(md("*em*"), "<p><em>em</em></p>", "md: emphasis");
CheckEq(md("2 * 3 * 4"), "<p>2 * 3 * 4</p>", "md: spaced asterisks stay literal");
// <s>, not GFM's <del>: nothing was removed from this document, and the one
// body that uses this is striking a joke through for effect.
CheckEq(md("~~struck~~"), "<p><s>struck</s></p>", "md: strikethrough");
CheckEq(md("~~**both**~~"), "<p><s><strong>both</strong></s></p>",
"md: strikethrough nests");
// A lone tilde is a home directory or an approximation, never a delimiter.
CheckEq(md("~/.local and ~5 minutes"), "<p>~/.local and ~5 minutes</p>",
"md: a single tilde strikes nothing");
CheckEq(md("a ~~ b ~~ c"), "<p>a ~~ b ~~ c</p>",
"md: spaced tildes stay literal");
// Underscores are deliberately inert: these posts paste kernel symbol
// names into prose, and italicising half of one is worse than not
// italicising a word that used the underscore form.
CheckEq(md("kworker/u16:8-qc_ufs_qos_swq"),
"<p>kworker/u16:8-qc_ufs_qos_swq</p>", "md: underscores are not emphasis");
CheckEq(md("\\*literal\\*"), "<p>*literal*</p>", "md: backslash escape");
CheckEq(md("[label](https://x.example/y)"),
R"(<p><a href="https://x.example/y" rel="noopener">label</a></p>)", "md: link");
// Bare addresses are pasted constantly in these posts; leaving them inert
// would strip most of the outbound value out of the page.
CheckEq(md("see https://x.example/y"),
R"(<p>see <a href="https://x.example/y">https://x.example/y</a></p>)",
"md: bare URL autolinked");
// ── embedded media ────────────────────────────────────────────────
// A paragraph that is nothing but images becomes the same media block the
// cards use, rather than a <p> of pictures.
CheckEq(md("![a](/media/x.webp)"),
R"(<div class="post-media"><img class="post-media__item" loading="lazy" )"
R"(decoding="async" alt="a" src="/media/x.webp"></div>)",
"md: image-only paragraph is a media block");
Check(md("text ![a](/media/x.webp)").View().starts_with("<p>text <img"),
"md: an image inside a sentence stays inline");
// Dimensions come from the sidecar list, because Markdown syntax has
// nowhere to carry them — and without them the prose below every
// screenshot jumps as the file arrives.
{
std::vector<PostMedia> media;
PostMedia img;
img.src = "/media/x.webp";
img.kind = "image";
img.avif = "/media/x.avif";
img.fallback = "/media/x.png";
img.width = 800;
img.height = 600;
media.push_back(img);
PostMedia vid;
vid.src = "/media/v.mp4";
vid.kind = "video";
vid.poster = "/media/v.poster.webp";
vid.fallback = "/media/v.h264.mp4";
vid.width = 1080;
vid.height = 1920;
media.push_back(vid);
const auto out = md("![a](/media/x.webp)", media);
Check(out.View().find(R"(width="800" height="600")") != std::string_view::npos,
"md: inline image carries its dimensions", out.View());
// Routed through :Media, so a body image gets the same format ladder a
// card image does rather than a second, plainer implementation.
Check(out.View().find(R"(<source srcset="/media/x.avif" type="image/avif">)")
!= std::string_view::npos
&& out.View().find(R"(src="/media/x.png")") != std::string_view::npos,
"md: inline image gets the avif/png ladder", out.View());
// An inline video gets the same treatment a headline one does,
// fallback source and all.
const auto vout = md("![](/media/v.mp4)", media);
Check(vout.View().find(R"(poster="/media/v.poster.webp")") != std::string_view::npos
&& vout.View().find("codecs=av01") != std::string_view::npos
&& vout.View().find(R"(<source src="/media/v.h264.mp4")") != std::string_view::npos,
"md: inline video gets poster and H.264 fallback", vout.View());
}
// ── termination ───────────────────────────────────────────────────
// Unbalanced delimiters are the classic way to hang a hand-written
// parser, and a body is input from someone else's server.
Check(!md("**unclosed").View().empty(), "md: unclosed strong terminates");
Check(!md("[unclosed](").View().empty(), "md: unclosed link terminates");
Check(!md("![](").View().empty(), "md: unclosed image terminates");
Check(!md("`unclosed").View().empty(), "md: unclosed code span terminates");
Check(!md("~~unclosed").View().empty(), "md: unclosed strikethrough terminates");
Check(!md("> > > > > > > > deep").View().empty(), "md: over-deep nesting terminates");
Check(!md("|||\n|||").View().empty(), "md: degenerate table terminates");
Check(!md("|---|---|").View().empty(), "md: a lone delimiter row terminates");
Check(!md("| a |\n| - |\n|").View().empty(), "md: a ragged table terminates");
if (failures != 0) {
std::println(std::cerr, "{} check(s) failed", failures);
return 1;
}
return 0;
}