2026-08-10 01:37:26 +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.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
// A deliberately small Markdown renderer, for fediverse post bodies only.
|
|
|
|
|
//
|
|
|
|
|
// The site used to have no markdown pipeline at all, on the grounds that the
|
|
|
|
|
// posts page only ever showed a 280-character preview and the body stayed on
|
|
|
|
|
// the instance. Hosting the full body changes that calculation: a post IS
|
|
|
|
|
// prose with headings, quotes, code blocks and screenshots, and rendering it
|
|
|
|
|
// as one flat paragraph of literal `**asterisks**` would be worse than not
|
|
|
|
|
// hosting it. So: a parser, but only as much of one as these bodies use.
|
|
|
|
|
//
|
|
|
|
|
// WHAT IT SUPPORTS — everything observed in the real bodies, and nothing else:
|
|
|
|
|
//
|
|
|
|
|
// blocks ATX headings, fenced code, blockquotes (nested), ordered and
|
2026-08-18 11:12:24 +02:00
|
|
|
// unordered lists, pipe tables, thematic breaks, paragraphs
|
|
|
|
|
// inline links, images, code spans, ** strong **, * emphasis *,
|
|
|
|
|
// ~~ strikethrough ~~, and bare URLs via Html::Autolink
|
2026-08-10 01:37:26 +02:00
|
|
|
//
|
|
|
|
|
// WHAT IT DELIBERATELY DOES NOT SUPPORT:
|
|
|
|
|
//
|
|
|
|
|
// * Raw HTML. Never. A post body is text fetched from someone else's server,
|
|
|
|
|
// so the ONE thing this renderer must guarantee is that no byte of it can
|
|
|
|
|
// become markup. Every character of body text leaves here through
|
|
|
|
|
// Html::Escape or Html::Autolink, and the only SafeHtml built from a raw
|
|
|
|
|
// string is the fixed structural markup written in this file. That is why
|
|
|
|
|
// `<` in a body renders as a less-than sign rather than opening a tag.
|
|
|
|
|
// * Underscore emphasis. `_` is common inside identifiers that appear in
|
|
|
|
|
// these posts unquoted (kworker/u16:8-qc_ufs_qos_swq), and mangling half a
|
|
|
|
|
// symbol name into italics is a worse failure than not italicising a word
|
|
|
|
|
// that used the underscore form. Asterisks are unambiguous here.
|
2026-08-18 11:12:24 +02:00
|
|
|
// * Single-tilde strikethrough, for the same reason. GFM accepts one tilde or
|
|
|
|
|
// two; a lone `~` in these posts is a home directory (`~/.local`) or an
|
|
|
|
|
// approximation (`~5 minutes`), so only the doubled form strikes anything.
|
|
|
|
|
// * Tilde-fenced code. `~~~` would be ambiguous with the above and no body
|
|
|
|
|
// uses it; a fence here is written with backticks.
|
|
|
|
|
// * Setext headings, reference links, footnotes, HTML entities. None appear;
|
|
|
|
|
// adding them speculatively is parser surface with no reader. (Tables did
|
|
|
|
|
// appear — the carrier compatibility list and the GPU price comparison are
|
|
|
|
|
// written as pipe tables — which is why they are in the list above now.)
|
2026-08-10 01:37:26 +02:00
|
|
|
// * Trailing-double-space hard breaks. An invisible two-character difference
|
|
|
|
|
// is not something a reader can see in the source or a writer can rely on
|
|
|
|
|
// having typed; the lines of a paragraph join with a space, and a break
|
|
|
|
|
// that was meant is written as a blank line.
|
|
|
|
|
//
|
|
|
|
|
// Anything unrecognised degrades to text rather than being dropped, so a
|
|
|
|
|
// construct this parser does not know shows up as visibly odd prose instead of
|
|
|
|
|
// silently vanishing from the page.
|
|
|
|
|
|
|
|
|
|
export module Catcrafts.Shared:Markdown;
|
|
|
|
|
import std;
|
|
|
|
|
import :Html;
|
|
|
|
|
import :Media;
|
|
|
|
|
import :Model;
|
|
|
|
|
|
|
|
|
|
namespace Catcrafts::Markdown {
|
|
|
|
|
|
|
|
|
|
using Html::SafeHtml;
|
|
|
|
|
using Html::Escape;
|
|
|
|
|
using Html::Autolink;
|
|
|
|
|
using Html::Attr;
|
|
|
|
|
using Html::Format;
|
|
|
|
|
using Html::Join;
|
|
|
|
|
using Html::Raw;
|
|
|
|
|
using Html::Url;
|
|
|
|
|
|
|
|
|
|
// Blockquotes recurse, and a body is untrusted input, so the recursion needs a
|
|
|
|
|
// bound that does not depend on the input being sane. Four is past anything
|
|
|
|
|
// these posts do (a quote inside a list item inside a quote) and far short of
|
|
|
|
|
// anything that could trouble the stack.
|
|
|
|
|
constexpr int kMaxDepth = 4;
|
|
|
|
|
|
|
|
|
|
// ── small string helpers ──────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
bool IsSpace(char c) { return c == ' ' || c == '\t'; }
|
|
|
|
|
|
|
|
|
|
std::string_view TrimRight(std::string_view s) {
|
|
|
|
|
while (!s.empty() && (IsSpace(s.back()) || s.back() == '\r')) s.remove_suffix(1);
|
|
|
|
|
return s;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
std::string_view TrimLeft(std::string_view s) {
|
|
|
|
|
while (!s.empty() && IsSpace(s.front())) s.remove_prefix(1);
|
|
|
|
|
return s;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
std::string_view Trim(std::string_view s) { return TrimLeft(TrimRight(s)); }
|
|
|
|
|
|
|
|
|
|
bool Blank(std::string_view line) { return Trim(line).empty(); }
|
|
|
|
|
|
|
|
|
|
// Up to three leading spaces are indentation a block marker is still allowed
|
|
|
|
|
// to carry; four or more would be a code block in real Markdown, which these
|
|
|
|
|
// bodies never use (they fence instead).
|
|
|
|
|
std::string_view Undent(std::string_view line) {
|
|
|
|
|
std::size_t n = 0;
|
|
|
|
|
while (n < line.size() && n < 3 && line[n] == ' ') ++n;
|
|
|
|
|
return line.substr(n);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── media ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
// One embedded file, in the same shape (and CSS) the posts page uses for a
|
|
|
|
|
// post's headline media — an inline screenshot and a post's headline recording
|
|
|
|
|
// are the same kind of thing to a reader, so they should not look like two
|
|
|
|
|
// different components. :Media is what guarantees that: both go through it.
|
|
|
|
|
//
|
|
|
|
|
// A src with no mirrored record (its download failed, so the body still points
|
|
|
|
|
// at the original URL) still renders, just without dimensions or format tiers.
|
|
|
|
|
SafeHtml MediaTag(std::string_view src, std::string_view alt,
|
|
|
|
|
std::span<const PostMedia> media) {
|
|
|
|
|
return Media::Tag(Media::Describe(media, src), alt);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── inline ────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
SafeHtml RenderInline(std::string_view text, std::span<const PostMedia> media, int depth);
|
|
|
|
|
|
|
|
|
|
// The span between `open` and its matching close delimiter, honouring nesting,
|
|
|
|
|
// or npos when it never closes. Used for [link text] and (link target), both of
|
|
|
|
|
// which can legitimately contain their own brackets — the Fairphone manual link
|
|
|
|
|
// in one of these posts has a parenthesised sentence as its text.
|
|
|
|
|
std::size_t MatchingDelimiter(std::string_view s, std::size_t from, char open, char close) {
|
|
|
|
|
int depth = 0;
|
|
|
|
|
for (std::size_t i = from; i < s.size(); ++i) {
|
|
|
|
|
if (s[i] == '\\') { ++i; continue; }
|
|
|
|
|
if (s[i] == open) { ++depth; continue; }
|
|
|
|
|
if (s[i] == close) {
|
|
|
|
|
if (depth == 0) return i;
|
|
|
|
|
--depth;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return std::string_view::npos;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// A run of ordinary prose, escaped and with bare URLs linked.
|
|
|
|
|
//
|
|
|
|
|
// Autolink rather than Escape because these bodies paste addresses constantly —
|
|
|
|
|
// mailing-list archives, merge requests, the shop — as bare text with no
|
|
|
|
|
// Markdown link syntax around them. Rendering those inert would strip most of
|
|
|
|
|
// the outbound value out of the post. Autolink escapes everything itself and
|
|
|
|
|
// puts each address through Html::Url, so this adds no new trusted path.
|
|
|
|
|
SafeHtml PlainRun(std::string_view text) {
|
|
|
|
|
return text.empty() ? SafeHtml{} : Autolink(text);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// True when the delimiter at `i` opens (rather than closes) an emphasis run:
|
|
|
|
|
// it must be followed by something that is not whitespace. Combined with the
|
|
|
|
|
// closing test below this is what keeps a lone asterisk — a multiplication
|
|
|
|
|
// sign, a footnote marker, a shell glob — from swallowing the rest of a
|
|
|
|
|
// paragraph into italics.
|
|
|
|
|
bool OpensEmphasis(std::string_view s, std::size_t after) {
|
|
|
|
|
return after < s.size() && !IsSpace(s[after]) && s[after] != '\n';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The closing delimiter for an emphasis run opened at `from`, or npos. The
|
|
|
|
|
// character before it must not be whitespace, so "a * b * c" stays literal.
|
|
|
|
|
std::size_t FindEmphasisClose(std::string_view s, std::size_t from, std::string_view delim) {
|
|
|
|
|
std::size_t i = from;
|
|
|
|
|
while (i < s.size()) {
|
|
|
|
|
const std::size_t at = s.find(delim, i);
|
|
|
|
|
if (at == std::string_view::npos) return std::string_view::npos;
|
|
|
|
|
if (at > from && !IsSpace(s[at - 1])) return at;
|
|
|
|
|
i = at + delim.size();
|
|
|
|
|
}
|
|
|
|
|
return std::string_view::npos;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
SafeHtml RenderInline(std::string_view text, std::span<const PostMedia> media, int depth) {
|
|
|
|
|
std::vector<SafeHtml> out;
|
|
|
|
|
std::size_t run = 0; // start of the pending plain-text run
|
|
|
|
|
|
|
|
|
|
auto flush = [&](std::size_t upto) {
|
|
|
|
|
if (upto > run) out.push_back(PlainRun(text.substr(run, upto - run)));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
std::size_t i = 0;
|
|
|
|
|
while (i < text.size()) {
|
|
|
|
|
const char c = text[i];
|
|
|
|
|
|
|
|
|
|
// A backslash escape hides the next character from this parser. The
|
|
|
|
|
// pair is emitted as the second character alone, which is what lets a
|
|
|
|
|
// post write a literal asterisk.
|
|
|
|
|
if (c == '\\' && i + 1 < text.size()) {
|
|
|
|
|
flush(i);
|
|
|
|
|
out.push_back(Escape(text.substr(i + 1, 1)));
|
|
|
|
|
i += 2;
|
|
|
|
|
run = i;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// `code` — highest precedence, so an asterisk inside a code span is a
|
|
|
|
|
// literal asterisk and not an emphasis delimiter.
|
|
|
|
|
if (c == '`') {
|
|
|
|
|
const std::size_t close = text.find('`', i + 1);
|
|
|
|
|
if (close != std::string_view::npos) {
|
|
|
|
|
flush(i);
|
|
|
|
|
out.push_back(Format(R"(<code>{}</code>)",
|
|
|
|
|
Escape(text.substr(i + 1, close - i - 1))));
|
|
|
|
|
i = close + 1;
|
|
|
|
|
run = i;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
//  — an image. Checked before the link case, since the
|
|
|
|
|
// bracket that follows would otherwise parse as one.
|
|
|
|
|
if (c == '!' && i + 1 < text.size() && text[i + 1] == '[' && depth < kMaxDepth) {
|
|
|
|
|
const std::size_t altEnd = MatchingDelimiter(text, i + 2, '[', ']');
|
|
|
|
|
if (altEnd != std::string_view::npos && altEnd + 1 < text.size()
|
|
|
|
|
&& text[altEnd + 1] == '(') {
|
|
|
|
|
const std::size_t srcEnd = MatchingDelimiter(text, altEnd + 2, '(', ')');
|
|
|
|
|
if (srcEnd != std::string_view::npos) {
|
|
|
|
|
flush(i);
|
|
|
|
|
out.push_back(MediaTag(Trim(text.substr(altEnd + 2, srcEnd - altEnd - 2)),
|
|
|
|
|
text.substr(i + 2, altEnd - i - 2),
|
|
|
|
|
media));
|
|
|
|
|
i = srcEnd + 1;
|
|
|
|
|
run = i;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// [text](href)
|
|
|
|
|
if (c == '[' && depth < kMaxDepth) {
|
|
|
|
|
const std::size_t textEnd = MatchingDelimiter(text, i + 1, '[', ']');
|
|
|
|
|
if (textEnd != std::string_view::npos && textEnd + 1 < text.size()
|
|
|
|
|
&& text[textEnd + 1] == '(') {
|
|
|
|
|
const std::size_t hrefEnd = MatchingDelimiter(text, textEnd + 2, '(', ')');
|
|
|
|
|
if (hrefEnd != std::string_view::npos) {
|
|
|
|
|
const std::string_view href =
|
|
|
|
|
Trim(text.substr(textEnd + 2, hrefEnd - textEnd - 2));
|
|
|
|
|
flush(i);
|
|
|
|
|
// The label is rendered rather than escaped flat, because
|
|
|
|
|
// these posts bold inside link text. Depth-guarded, so a
|
|
|
|
|
// link whose label contains a link cannot recurse forever.
|
|
|
|
|
out.push_back(Format(
|
|
|
|
|
R"(<a{} rel="noopener">{}</a>)",
|
|
|
|
|
Url("href", href),
|
|
|
|
|
RenderInline(text.substr(i + 1, textEnd - i - 1), media, depth + 1)));
|
|
|
|
|
i = hrefEnd + 1;
|
|
|
|
|
run = i;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// **strong** before *emphasis*: the longer delimiter has to win, or
|
|
|
|
|
// every bold run parses as an empty italic followed by loose text.
|
|
|
|
|
if (c == '*') {
|
|
|
|
|
const bool doubled = i + 1 < text.size() && text[i + 1] == '*';
|
|
|
|
|
const std::string_view delim = doubled ? "**" : "*";
|
|
|
|
|
const std::size_t inner = i + delim.size();
|
|
|
|
|
if (depth < kMaxDepth && OpensEmphasis(text, inner)) {
|
|
|
|
|
const std::size_t close = FindEmphasisClose(text, inner, delim);
|
|
|
|
|
if (close != std::string_view::npos) {
|
|
|
|
|
flush(i);
|
|
|
|
|
const SafeHtml body =
|
|
|
|
|
RenderInline(text.substr(inner, close - inner), media, depth + 1);
|
|
|
|
|
out.push_back(doubled ? Format(R"(<strong>{}</strong>)", body)
|
|
|
|
|
: Format(R"(<em>{}</em>)", body));
|
|
|
|
|
i = close + delim.size();
|
|
|
|
|
run = i;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-18 11:12:24 +02:00
|
|
|
// ~~struck~~, structurally the same as **strong** and sharing its
|
|
|
|
|
// open/close tests, so "a ~~ b ~~ c" stays literal the same way
|
|
|
|
|
// "a ** b ** c" does.
|
|
|
|
|
//
|
|
|
|
|
// <s> rather than GFM's <del>: nothing was removed from this document.
|
|
|
|
|
// <del> is a claim that an edit happened, and the one body that uses
|
|
|
|
|
// this is striking a joke through for effect — which is exactly what
|
|
|
|
|
// <s> is for ("no longer accurate or no longer relevant").
|
|
|
|
|
if (c == '~' && i + 1 < text.size() && text[i + 1] == '~') {
|
|
|
|
|
const std::size_t inner = i + 2;
|
|
|
|
|
if (depth < kMaxDepth && OpensEmphasis(text, inner)) {
|
|
|
|
|
const std::size_t close = FindEmphasisClose(text, inner, "~~");
|
|
|
|
|
if (close != std::string_view::npos) {
|
|
|
|
|
flush(i);
|
|
|
|
|
out.push_back(Format(
|
|
|
|
|
R"(<s>{}</s>)",
|
|
|
|
|
RenderInline(text.substr(inner, close - inner), media, depth + 1)));
|
|
|
|
|
i = close + 2;
|
|
|
|
|
run = i;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-10 01:37:26 +02:00
|
|
|
++i;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
flush(text.size());
|
|
|
|
|
return Join(out);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── blocks ────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
// A line's leading run of '#', when it is an ATX heading marker.
|
|
|
|
|
// Returns 0 when the line is not a heading.
|
|
|
|
|
int HeadingLevel(std::string_view line) {
|
|
|
|
|
std::size_t n = 0;
|
|
|
|
|
while (n < line.size() && line[n] == '#') ++n;
|
|
|
|
|
if (n == 0 || n > 6) return 0;
|
|
|
|
|
// "#tag" is not a heading; a marker has to be followed by space or be the
|
|
|
|
|
// whole line.
|
|
|
|
|
if (n < line.size() && !IsSpace(line[n])) return 0;
|
|
|
|
|
return static_cast<int>(n);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool IsFence(std::string_view line) { return TrimLeft(line).starts_with("```"); }
|
|
|
|
|
|
|
|
|
|
// `---`, `***`, `___`: three or more of one character, nothing else but spaces.
|
|
|
|
|
bool IsThematicBreak(std::string_view line) {
|
|
|
|
|
const std::string_view s = Trim(line);
|
|
|
|
|
if (s.size() < 3) return false;
|
|
|
|
|
const char c = s.front();
|
|
|
|
|
if (c != '-' && c != '*' && c != '_') return false;
|
|
|
|
|
int count = 0;
|
|
|
|
|
for (const char ch : s) {
|
|
|
|
|
if (ch == c) { ++count; continue; }
|
|
|
|
|
if (!IsSpace(ch)) return false;
|
|
|
|
|
}
|
|
|
|
|
return count >= 3;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct ListMarker {
|
|
|
|
|
bool ok = false;
|
|
|
|
|
bool ordered = false;
|
|
|
|
|
std::int64_t start = 1; // the number an ordered item announced
|
|
|
|
|
std::size_t contentAt = 0; // offset of the item's text within the line
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
ListMarker ParseListMarker(std::string_view line) {
|
|
|
|
|
ListMarker m;
|
|
|
|
|
const std::string_view s = Undent(line);
|
|
|
|
|
const std::size_t indent = line.size() - s.size();
|
|
|
|
|
if (s.empty()) return m;
|
|
|
|
|
|
|
|
|
|
if ((s[0] == '-' || s[0] == '*' || s[0] == '+') && s.size() > 1 && IsSpace(s[1])) {
|
|
|
|
|
// A thematic break is also a run of dashes; it wins, because "- - -"
|
|
|
|
|
// is a rule everywhere and a three-item list nowhere.
|
|
|
|
|
if (IsThematicBreak(line)) return m;
|
|
|
|
|
m.ok = true;
|
|
|
|
|
m.contentAt = indent + 2;
|
|
|
|
|
return m;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
std::size_t n = 0;
|
|
|
|
|
while (n < s.size() && s[n] >= '0' && s[n] <= '9') ++n;
|
|
|
|
|
// Bounded so a line starting with a long number is not mistaken for a list.
|
|
|
|
|
if (n == 0 || n > 9) return m;
|
|
|
|
|
if (n + 1 >= s.size() || (s[n] != '.' && s[n] != ')') || !IsSpace(s[n + 1])) return m;
|
|
|
|
|
|
|
|
|
|
std::int64_t value = 0;
|
|
|
|
|
std::from_chars(s.data(), s.data() + n, value);
|
|
|
|
|
m.ok = true;
|
|
|
|
|
m.ordered = true;
|
|
|
|
|
m.start = value;
|
|
|
|
|
m.contentAt = indent + n + 2;
|
|
|
|
|
return m;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-18 11:12:24 +02:00
|
|
|
// ── tables ────────────────────────────────────────────────────────────
|
|
|
|
|
//
|
|
|
|
|
// GFM pipe tables. These arrived after the parser did: the carrier
|
|
|
|
|
// compatibility list in the camera post and the GPU price comparison in
|
|
|
|
|
// another are written as tables, and with no table support a table is the
|
|
|
|
|
// worst-degrading construct there is — the rows join into one run-on
|
|
|
|
|
// paragraph of pipes and dashes, which is neither the data nor prose.
|
|
|
|
|
//
|
|
|
|
|
// Recognition deliberately needs TWO lines: a row, and under it an alignment
|
|
|
|
|
// row agreeing about the number of columns. That is what keeps ordinary prose
|
|
|
|
|
// safe, because a pipe by itself is common in these posts (`dmesg | grep`, an
|
|
|
|
|
// or-list, a pasted command) and nothing under those lines is `|---|---|`.
|
|
|
|
|
|
|
|
|
|
// A column's alignment, as the delimiter row asked for it. Left is absent
|
|
|
|
|
// because it is what the CSS already does — `:---` and a bare `---` produce
|
|
|
|
|
// identical markup, so only the two that change something carry a class.
|
|
|
|
|
enum class Align { Default, Center, Right };
|
|
|
|
|
|
|
|
|
|
// A row split into cells on unescaped pipes, with the optional outer pipes
|
|
|
|
|
// dropped. Both are optional independently, which is not pedantry: the real
|
|
|
|
|
// carrier table is written `| Device | OS | Carrier` — leading pipe, no
|
|
|
|
|
// trailing one — and requiring both would leave it as prose.
|
|
|
|
|
//
|
|
|
|
|
// `\|` is left in the cell text for RenderInline to turn into a literal pipe;
|
|
|
|
|
// that is how a cell contains one.
|
|
|
|
|
std::vector<std::string_view> SplitRow(std::string_view line) {
|
|
|
|
|
std::string_view s = Trim(Undent(line));
|
|
|
|
|
if (s.starts_with('|')) s.remove_prefix(1);
|
|
|
|
|
if (s.size() > 1 && s.ends_with('|') && !s.ends_with("\\|")) s.remove_suffix(1);
|
|
|
|
|
|
|
|
|
|
std::vector<std::string_view> cells;
|
|
|
|
|
std::size_t start = 0;
|
|
|
|
|
for (std::size_t i = 0; i < s.size(); ++i) {
|
|
|
|
|
if (s[i] == '\\') { ++i; continue; }
|
|
|
|
|
if (s[i] != '|') continue;
|
|
|
|
|
cells.push_back(Trim(s.substr(start, i - start)));
|
|
|
|
|
start = i + 1;
|
|
|
|
|
}
|
|
|
|
|
cells.push_back(Trim(s.substr(start)));
|
|
|
|
|
return cells;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// One cell of the delimiter row: `---`, `:---`, `---:`, `:---:` and nothing
|
|
|
|
|
// else. A single dash is enough — `| - |` is a table people write.
|
|
|
|
|
bool IsDelimiterCell(std::string_view cell, Align& align) {
|
|
|
|
|
const bool left = cell.starts_with(':');
|
|
|
|
|
if (left) cell.remove_prefix(1);
|
|
|
|
|
const bool right = cell.ends_with(':');
|
|
|
|
|
if (right) cell.remove_suffix(1);
|
|
|
|
|
if (cell.empty()) return false;
|
|
|
|
|
for (const char c : cell) {
|
|
|
|
|
if (c != '-') return false;
|
|
|
|
|
}
|
|
|
|
|
align = (left && right) ? Align::Center : right ? Align::Right : Align::Default;
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The alignment of each column, or an empty vector when `line` is not the
|
|
|
|
|
// delimiter row of a table whose header had `columns` cells.
|
|
|
|
|
std::vector<Align> ParseDelimiterRow(std::string_view line, std::size_t columns) {
|
|
|
|
|
// A pipe is required, so `---` under a one-cell row stays the thematic
|
|
|
|
|
// break it looks like — the same call this file already makes for `- - -`
|
|
|
|
|
// over a one-item list.
|
|
|
|
|
if (Trim(line).find('|') == std::string_view::npos) return {};
|
|
|
|
|
|
|
|
|
|
std::vector<Align> aligns;
|
|
|
|
|
for (const std::string_view cell : SplitRow(line)) {
|
|
|
|
|
Align align = Align::Default;
|
|
|
|
|
if (!IsDelimiterCell(cell, align)) return {};
|
|
|
|
|
aligns.push_back(align);
|
|
|
|
|
}
|
|
|
|
|
// GFM's rule, and a useful one: a mismatched count is far more likely to be
|
|
|
|
|
// prose that happens to contain pipes than a table its author miscounted.
|
|
|
|
|
return aligns.size() == columns ? aligns : std::vector<Align>{};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool IsTableStart(std::span<const std::string_view> lines, std::size_t at) {
|
|
|
|
|
if (at + 1 >= lines.size()) return false;
|
|
|
|
|
if (Trim(Undent(lines[at])).find('|') == std::string_view::npos) return false;
|
|
|
|
|
return !ParseDelimiterRow(lines[at + 1], SplitRow(lines[at]).size()).empty();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// A row's cells, as <th> or <td>. Rendered per cell rather than per line, so a
|
|
|
|
|
// cell holds links, code spans and emphasis like any other prose.
|
|
|
|
|
//
|
|
|
|
|
// A row shorter than the header is padded with empty cells so the grid stays
|
|
|
|
|
// rectangular; a row LONGER than it keeps its extras rather than having them
|
|
|
|
|
// dropped, which follows the rule the rest of this file follows — content that
|
|
|
|
|
// confuses the parser shows up looking odd instead of disappearing.
|
|
|
|
|
SafeHtml RenderRow(std::span<const std::string_view> cells, std::span<const Align> aligns,
|
|
|
|
|
bool header, std::span<const PostMedia> media, int depth) {
|
|
|
|
|
std::vector<SafeHtml> out;
|
|
|
|
|
const std::size_t columns = std::max(cells.size(), aligns.size());
|
|
|
|
|
for (std::size_t c = 0; c < columns; ++c) {
|
|
|
|
|
const Align align = c < aligns.size() ? aligns[c] : Align::Default;
|
|
|
|
|
const SafeHtml cls =
|
|
|
|
|
align == Align::Center ? Attr("class", "post-body__cell--center")
|
|
|
|
|
: align == Align::Right ? Attr("class", "post-body__cell--right")
|
|
|
|
|
: SafeHtml{};
|
|
|
|
|
const SafeHtml inner =
|
|
|
|
|
RenderInline(c < cells.size() ? cells[c] : std::string_view{}, media, depth);
|
|
|
|
|
out.push_back(header ? Format("<th{}>{}</th>", cls, inner)
|
|
|
|
|
: Format("<td{}>{}</td>", cls, inner));
|
|
|
|
|
}
|
|
|
|
|
return Format("<tr>{}</tr>", Join(out));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── block dispatch ────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
// Whether a new block begins at `at`, so a paragraph is interrupted by one
|
|
|
|
|
// rather than swallowing it as text. Takes the whole span because a table is
|
|
|
|
|
// the one construct that cannot be recognised from a single line.
|
|
|
|
|
bool StartsBlock(std::span<const std::string_view> lines, std::size_t at) {
|
|
|
|
|
const std::string_view line = lines[at];
|
2026-08-10 01:37:26 +02:00
|
|
|
return HeadingLevel(Undent(line)) > 0 || IsFence(line) || IsThematicBreak(line)
|
2026-08-18 11:12:24 +02:00
|
|
|
|| Undent(line).starts_with('>') || ParseListMarker(line).ok
|
|
|
|
|
|| IsTableStart(lines, at);
|
2026-08-10 01:37:26 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
SafeHtml RenderBlocks(std::span<const std::string_view> lines,
|
|
|
|
|
std::span<const PostMedia> media, int depth);
|
|
|
|
|
|
|
|
|
|
// A paragraph's lines, joined and rendered.
|
|
|
|
|
//
|
|
|
|
|
// A paragraph whose entire content is embedded files becomes the same
|
|
|
|
|
// .post-media block the cards use rather than a <p> of images: that is what
|
|
|
|
|
// gives a run of screenshots the two-up grid instead of a column of full-width
|
|
|
|
|
// pictures with paragraph spacing between them.
|
|
|
|
|
SafeHtml RenderParagraph(std::span<const std::string_view> lines,
|
|
|
|
|
std::span<const PostMedia> media, int depth) {
|
|
|
|
|
// Lines join with a space: a body wraps its prose at whatever width the
|
|
|
|
|
// author's editor used, and those wraps are not meaningful.
|
|
|
|
|
std::string joined;
|
|
|
|
|
for (std::size_t i = 0; i < lines.size(); ++i) {
|
|
|
|
|
joined += Trim(lines[i]);
|
|
|
|
|
if (i + 1 < lines.size()) joined += ' ';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Only-images test: strip every  span and see whether anything but
|
|
|
|
|
// whitespace is left.
|
|
|
|
|
bool onlyMedia = false;
|
|
|
|
|
{
|
|
|
|
|
std::string rest;
|
|
|
|
|
std::size_t i = 0;
|
|
|
|
|
std::size_t found = 0;
|
|
|
|
|
const std::string_view s = joined;
|
|
|
|
|
while (i < s.size()) {
|
|
|
|
|
if (s[i] == '!' && i + 1 < s.size() && s[i + 1] == '[') {
|
|
|
|
|
const std::size_t altEnd = MatchingDelimiter(s, i + 2, '[', ']');
|
|
|
|
|
if (altEnd != std::string_view::npos && altEnd + 1 < s.size()
|
|
|
|
|
&& s[altEnd + 1] == '(') {
|
|
|
|
|
const std::size_t srcEnd = MatchingDelimiter(s, altEnd + 2, '(', ')');
|
|
|
|
|
if (srcEnd != std::string_view::npos) {
|
|
|
|
|
++found;
|
|
|
|
|
i = srcEnd + 1;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
rest += s[i];
|
|
|
|
|
++i;
|
|
|
|
|
}
|
|
|
|
|
onlyMedia = found > 0 && Trim(rest).empty();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const SafeHtml inner = RenderInline(joined, media, depth);
|
|
|
|
|
if (inner.Empty()) return SafeHtml{};
|
|
|
|
|
if (onlyMedia) return Format(R"(<div class="post-media">{}</div>)", inner);
|
|
|
|
|
return Format(R"(<p>{}</p>)", inner);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
SafeHtml RenderBlocks(std::span<const std::string_view> lines,
|
|
|
|
|
std::span<const PostMedia> media, int depth) {
|
|
|
|
|
std::vector<SafeHtml> out;
|
|
|
|
|
std::size_t i = 0;
|
|
|
|
|
|
|
|
|
|
while (i < lines.size()) {
|
|
|
|
|
if (Blank(lines[i])) { ++i; continue; }
|
|
|
|
|
|
|
|
|
|
const std::string_view line = lines[i];
|
|
|
|
|
const std::string_view body = Undent(line);
|
|
|
|
|
|
|
|
|
|
// ── fenced code ───────────────────────────────────────────────
|
|
|
|
|
//
|
|
|
|
|
// Taken verbatim and escaped: the battery-measurement tables and the
|
|
|
|
|
// android top output in these posts are the one place where every
|
|
|
|
|
// space matters and nothing inside should be interpreted at all.
|
|
|
|
|
if (IsFence(line)) {
|
|
|
|
|
std::size_t j = i + 1;
|
|
|
|
|
std::string code;
|
|
|
|
|
while (j < lines.size() && !IsFence(lines[j])) {
|
|
|
|
|
code += TrimRight(lines[j]);
|
|
|
|
|
code += '\n';
|
|
|
|
|
++j;
|
|
|
|
|
}
|
|
|
|
|
out.push_back(Format(R"(<pre class="post-body__code"><code>{}</code></pre>)",
|
|
|
|
|
Escape(code)));
|
|
|
|
|
// Past the closing fence, or to the end when it never closed —
|
|
|
|
|
// an unterminated fence renders as code rather than swallowing
|
|
|
|
|
// the rest of the post into a parse failure.
|
|
|
|
|
i = (j < lines.size()) ? j + 1 : j;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── heading ───────────────────────────────────────────────────
|
|
|
|
|
//
|
|
|
|
|
// Demoted by one: the page's <h1> is the post title, so a body's own
|
|
|
|
|
// top-level heading is a section inside it. Without the shift every
|
|
|
|
|
// post would carry two h1s and the document outline would be wrong on
|
|
|
|
|
// exactly the pages this whole change exists to make indexable.
|
|
|
|
|
if (const int level = HeadingLevel(body); level > 0) {
|
|
|
|
|
std::string_view textPart = body.substr(static_cast<std::size_t>(level));
|
|
|
|
|
// A closing run of #s is decoration, not content.
|
|
|
|
|
textPart = TrimRight(textPart);
|
|
|
|
|
while (!textPart.empty() && textPart.back() == '#') textPart.remove_suffix(1);
|
|
|
|
|
const int tag = std::min(level + 1, 6);
|
|
|
|
|
out.push_back(Format("<h{}>{}</h{}>",
|
|
|
|
|
Html::Num(tag),
|
|
|
|
|
RenderInline(Trim(textPart), media, depth),
|
|
|
|
|
Html::Num(tag)));
|
|
|
|
|
++i;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (IsThematicBreak(line)) {
|
|
|
|
|
out.push_back(Raw("<hr>"));
|
|
|
|
|
++i;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── blockquote ────────────────────────────────────────────────
|
|
|
|
|
//
|
|
|
|
|
// The quoted lines are re-parsed as blocks, so a quote keeps its own
|
|
|
|
|
// paragraphs and headings — which matters here, because the longest
|
|
|
|
|
// quotes in these posts are multi-paragraph company copy being taken
|
|
|
|
|
// apart line by line.
|
|
|
|
|
if (body.starts_with('>')) {
|
|
|
|
|
std::vector<std::string_view> quoted;
|
|
|
|
|
std::size_t j = i;
|
|
|
|
|
while (j < lines.size() && Undent(lines[j]).starts_with('>')) {
|
|
|
|
|
std::string_view q = Undent(lines[j]).substr(1);
|
|
|
|
|
if (!q.empty() && q.front() == ' ') q.remove_prefix(1);
|
|
|
|
|
quoted.push_back(q);
|
|
|
|
|
++j;
|
|
|
|
|
}
|
|
|
|
|
// At the depth limit the quote is still shown, just flattened —
|
|
|
|
|
// dropping it would lose content, which is the worse failure.
|
|
|
|
|
out.push_back(Format(
|
|
|
|
|
R"(<blockquote class="post-body__quote">{}</blockquote>)",
|
|
|
|
|
depth < kMaxDepth ? RenderBlocks(quoted, media, depth + 1)
|
|
|
|
|
: RenderParagraph(quoted, media, depth)));
|
|
|
|
|
i = j;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── list ──────────────────────────────────────────────────────
|
|
|
|
|
if (const ListMarker first = ParseListMarker(line); first.ok) {
|
|
|
|
|
std::vector<SafeHtml> items;
|
|
|
|
|
std::vector<std::string_view> current;
|
|
|
|
|
std::size_t j = i;
|
|
|
|
|
|
|
|
|
|
auto flushItem = [&] {
|
|
|
|
|
if (current.empty()) return;
|
|
|
|
|
items.push_back(Format(R"(<li>{}</li>)",
|
|
|
|
|
RenderInline([&] {
|
|
|
|
|
std::string joined;
|
|
|
|
|
for (std::size_t k = 0; k < current.size(); ++k) {
|
|
|
|
|
joined += Trim(current[k]);
|
|
|
|
|
if (k + 1 < current.size()) joined += ' ';
|
|
|
|
|
}
|
|
|
|
|
return joined;
|
|
|
|
|
}(), media, depth)));
|
|
|
|
|
current.clear();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
while (j < lines.size()) {
|
|
|
|
|
if (Blank(lines[j])) {
|
|
|
|
|
// A blank line ends the list UNLESS the next non-blank line
|
|
|
|
|
// is another item of the same kind. These posts space their
|
|
|
|
|
// numbered steps apart, and treating that as seven separate
|
|
|
|
|
// one-item lists would restart the numbering at every gap.
|
|
|
|
|
std::size_t peek = j;
|
|
|
|
|
while (peek < lines.size() && Blank(lines[peek])) ++peek;
|
|
|
|
|
const ListMarker next =
|
|
|
|
|
peek < lines.size() ? ParseListMarker(lines[peek]) : ListMarker{};
|
|
|
|
|
if (!next.ok || next.ordered != first.ordered) break;
|
|
|
|
|
j = peek;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if (const ListMarker m = ParseListMarker(lines[j]); m.ok) {
|
|
|
|
|
if (m.ordered != first.ordered) break;
|
|
|
|
|
flushItem();
|
|
|
|
|
current.push_back(lines[j].substr(
|
|
|
|
|
std::min(m.contentAt, lines[j].size())));
|
|
|
|
|
++j;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
// A non-marker line that would start some other block ends the
|
|
|
|
|
// list; anything else is this item's text continuing onto the
|
|
|
|
|
// next line.
|
2026-08-18 11:12:24 +02:00
|
|
|
if (StartsBlock(lines, j)) break;
|
2026-08-10 01:37:26 +02:00
|
|
|
current.push_back(lines[j]);
|
|
|
|
|
++j;
|
|
|
|
|
}
|
|
|
|
|
flushItem();
|
|
|
|
|
|
|
|
|
|
if (first.ordered) {
|
|
|
|
|
// start= only when it is not 1, so the common case stays clean
|
|
|
|
|
// markup — and so a list resumed after an interrupting
|
|
|
|
|
// paragraph continues its numbering instead of starting over.
|
|
|
|
|
out.push_back(Format(R"(<ol class="post-body__list"{}>{}</ol>)",
|
|
|
|
|
first.start == 1 ? SafeHtml{}
|
|
|
|
|
: Attr("start", std::to_string(first.start)),
|
|
|
|
|
Join(items)));
|
|
|
|
|
} else {
|
|
|
|
|
out.push_back(Format(R"(<ul class="post-body__list">{}</ul>)", Join(items)));
|
|
|
|
|
}
|
|
|
|
|
i = j;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-18 11:12:24 +02:00
|
|
|
// ── table ─────────────────────────────────────────────────────
|
|
|
|
|
//
|
|
|
|
|
// Below the list branch on purpose: `- a | b` over a `|---|---|`
|
|
|
|
|
// satisfies both tests, and a line that opens with a list marker is a
|
|
|
|
|
// list item. Ordinary tables are unaffected — a pipe is never a list
|
|
|
|
|
// marker, so they reach here either way.
|
|
|
|
|
if (IsTableStart(lines, i)) {
|
|
|
|
|
const std::vector<std::string_view> header = SplitRow(line);
|
|
|
|
|
const std::vector<Align> aligns = ParseDelimiterRow(lines[i + 1], header.size());
|
|
|
|
|
|
|
|
|
|
std::vector<SafeHtml> rows;
|
|
|
|
|
std::size_t j = i + 2;
|
|
|
|
|
while (j < lines.size() && !Blank(lines[j])) {
|
|
|
|
|
// A row has to have a pipe in it. A line without one directly
|
|
|
|
|
// under a table is prose whose author forgot the blank line —
|
|
|
|
|
// rendering it as a lone one-column row would be worse than
|
|
|
|
|
// ending the table and letting it be the paragraph it is.
|
|
|
|
|
if (Trim(Undent(lines[j])).find('|') == std::string_view::npos) break;
|
|
|
|
|
if (StartsBlock(lines, j)) break;
|
|
|
|
|
rows.push_back(RenderRow(SplitRow(lines[j]), aligns, false, media, depth));
|
|
|
|
|
++j;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The wrapper is what scrolls. A table cannot be narrowed below its
|
|
|
|
|
// content, so without a container around it a nine-column price
|
|
|
|
|
// comparison scrolls the PAGE sideways on a phone — the one thing
|
|
|
|
|
// wide content must never do (same reasoning as the code block).
|
|
|
|
|
out.push_back(Format(
|
|
|
|
|
R"(<div class="post-body__table"><table>)"
|
|
|
|
|
R"(<thead>{}</thead><tbody>{}</tbody></table></div>)",
|
|
|
|
|
RenderRow(header, aligns, true, media, depth), Join(rows)));
|
|
|
|
|
i = j;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-10 01:37:26 +02:00
|
|
|
// ── paragraph ─────────────────────────────────────────────────
|
|
|
|
|
{
|
|
|
|
|
std::size_t j = i;
|
|
|
|
|
while (j < lines.size() && !Blank(lines[j])) {
|
|
|
|
|
// A block marker on a later line interrupts the paragraph
|
|
|
|
|
// rather than being absorbed into it as text.
|
2026-08-18 11:12:24 +02:00
|
|
|
if (j > i && StartsBlock(lines, j)) break;
|
2026-08-10 01:37:26 +02:00
|
|
|
++j;
|
|
|
|
|
}
|
|
|
|
|
out.push_back(RenderParagraph(lines.subspan(i, j - i), media, depth));
|
|
|
|
|
i = j;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Join(out);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── entry point ───────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
// Render a post body to the inner markup of the article element.
|
|
|
|
|
//
|
|
|
|
|
// `media` is the post's mirrored inline files, used to attach dimensions (and
|
|
|
|
|
// a poster, and the H.264 fallback) to whatever the body embeds. Passing an
|
|
|
|
|
// empty span is fine: the markup then simply carries no dimensions, exactly
|
|
|
|
|
// like a file whose mirror failed.
|
|
|
|
|
export SafeHtml Render(std::string_view text, std::span<const PostMedia> media = {}) {
|
|
|
|
|
std::vector<std::string_view> lines;
|
|
|
|
|
std::size_t start = 0;
|
|
|
|
|
while (start <= text.size()) {
|
|
|
|
|
const std::size_t nl = text.find('\n', start);
|
|
|
|
|
if (nl == std::string_view::npos) {
|
|
|
|
|
lines.push_back(text.substr(start));
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
lines.push_back(text.substr(start, nl - start));
|
|
|
|
|
start = nl + 1;
|
|
|
|
|
}
|
|
|
|
|
return RenderBlocks(lines, media, 0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
} // namespace Catcrafts::Markdown
|