/* 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 // unordered lists, pipe tables, thematic breaks, paragraphs // inline links, images, code spans, ** strong **, * emphasis *, // ~~ strikethrough ~~, and bare URLs via Html::Autolink // // 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. // * 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.) // * 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 media) { return Media::Tag(Media::Describe(media, src), alt); } // ── inline ──────────────────────────────────────────────────────────── SafeHtml RenderInline(std::string_view text, std::span 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 media, int depth) { std::vector 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"({})", Escape(text.substr(i + 1, close - i - 1)))); i = close + 1; run = i; continue; } } // ![alt](src) — 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"({})", 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"({})", body) : Format(R"({})", body)); i = close + delim.size(); run = i; continue; } } } // ~~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. // // rather than GFM's : nothing was removed from this document. // 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 // 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"({})", RenderInline(text.substr(inner, close - inner), media, depth + 1))); i = close + 2; run = i; continue; } } } ++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(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; } // ── 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 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 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 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 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{}; } bool IsTableStart(std::span 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 or . 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 cells, std::span aligns, bool header, std::span media, int depth) { std::vector 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("{}", cls, inner) : Format("{}", cls, inner)); } return Format("{}", 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 lines, std::size_t at) { const std::string_view line = lines[at]; return HeadingLevel(Undent(line)) > 0 || IsFence(line) || IsThematicBreak(line) || Undent(line).starts_with('>') || ParseListMarker(line).ok || IsTableStart(lines, at); } SafeHtml RenderBlocks(std::span lines, std::span 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

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 lines, std::span 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"(

{}
)", inner); return Format(R"(

{}

)", inner); } SafeHtml RenderBlocks(std::span lines, std::span media, int depth) { std::vector 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"(
{}
)", 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

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(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("{}", Html::Num(tag), RenderInline(Trim(textPart), media, depth), Html::Num(tag))); ++i; continue; } if (IsThematicBreak(line)) { out.push_back(Raw("
")); ++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 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"(
{}
)", 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 items; std::vector current; std::size_t j = i; auto flushItem = [&] { if (current.empty()) return; items.push_back(Format(R"(
  • {}
  • )", 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. if (StartsBlock(lines, j)) break; 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"(
      {}
    )", first.start == 1 ? SafeHtml{} : Attr("start", std::to_string(first.start)), Join(items))); } else { out.push_back(Format(R"(
      {}
    )", Join(items))); } i = j; continue; } // ── 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 header = SplitRow(line); const std::vector aligns = ParseDelimiterRow(lines[i + 1], header.size()); std::vector 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"(
    )" R"({}{}
    )", RenderRow(header, aligns, true, media, depth), Join(rows))); i = j; continue; } // ── 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. if (j > i && StartsBlock(lines, j)) break; ++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 media = {}) { std::vector 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