2026-08-15 00:54:05 +02:00
|
|
|
/*
|
|
|
|
|
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><script>alert(1)</script></p>", "md: html is text, never markup");
|
|
|
|
|
CheckEq(md(")"),
|
|
|
|
|
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 " b & 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><b></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");
|
|
|
|
|
|
2026-08-18 11:12:24 +02:00
|
|
|
// ── 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><b></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());
|
|
|
|
|
|
2026-08-15 00:54:05 +02:00
|
|
|
// ── 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");
|
2026-08-18 11:12:24 +02:00
|
|
|
// <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");
|
2026-08-15 00:54:05 +02:00
|
|
|
// 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(""),
|
|
|
|
|
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 ").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("", 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);
|
|
|
|
|
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");
|
2026-08-18 11:12:24 +02:00
|
|
|
Check(!md("~~unclosed").View().empty(), "md: unclosed strikethrough terminates");
|
2026-08-15 00:54:05 +02:00
|
|
|
Check(!md("> > > > > > > > deep").View().empty(), "md: over-deep nesting terminates");
|
2026-08-18 11:12:24 +02:00
|
|
|
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");
|
2026-08-15 00:54:05 +02:00
|
|
|
|
|
|
|
|
if (failures != 0) {
|
|
|
|
|
std::println(std::cerr, "{} check(s) failed", failures);
|
|
|
|
|
return 1;
|
|
|
|
|
}
|
|
|
|
|
return 0;
|
|
|
|
|
}
|