/* 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, thematic breaks, paragraphs // inline links, images, code spans, ** strong **, * emphasis *, 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. // * Setext headings, reference links, tables, footnotes, HTML entities. // None appear; adding them speculatively is parser surface with no reader. // * 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; } } } ++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; } bool StartsBlock(std::string_view line) { return HeadingLevel(Undent(line)) > 0 || IsFence(line) || IsThematicBreak(line) || Undent(line).starts_with('>') || ParseListMarker(line).ok; } 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; } // ── 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