posts and avif
Some checks failed
Deploy / build-deploy (push) Failing after 5m49s

This commit is contained in:
Jorijn van der Graaf 2026-08-10 01:37:26 +02:00
commit 6841623e23
17 changed files with 2306 additions and 148 deletions

View file

@ -0,0 +1,588 @@
/*
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<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;
}
}
// ![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"(<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;
}
}
}
++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;
}
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<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.
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"(<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;
}
// ── 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<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

View file

@ -0,0 +1,192 @@
/*
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 markup for one mirrored file — image or video.
//
// This exists as its own partition because two callers need identical output:
// :Views renders a post's headline media on the card and at the top of the post
// page, and :Markdown renders whatever the body embeds. They were separate
// copies of the same element-building code, which is how the AV1 fallback logic
// came to be written twice; a format tier added to one and forgotten in the
// other shows up as the same picture served two different ways on two pages.
//
// FORMAT LADDER. Every file the mirror stores is served in the best form the
// browser will take, with a form every browser will take underneath it:
//
// video AV1 in MP4, falling back to H.264 in MP4. Two <source> elements;
// the codecs parameter on the first is what lets a browser without
// AV1 skip it rather than fail on a file it cannot decode.
// image AVIF, falling back to the mirrored original (usually WebP), falling
// back to PNG. A <picture>, so the browser fetches exactly one of
// them — the tiers cost nothing to the visitors who do not need them.
//
// The middle image tier is free: it is the file the mirror already downloaded,
// so it adds no encode and no disk. It matters because it is what stands
// between "no AVIF" and the PNG, and PNG is lossless — for a photograph that is
// an order of magnitude larger than the WebP beside it. With the middle tier
// the PNG is reached only by a browser that supports neither AVIF nor WebP.
//
// Any tier can be absent (the transcode was unavailable, or the file was never
// mirrored at all) and the markup degrades a step at a time, down to a bare
// <img src> — which is what the site emitted before any of this existed.
export module Catcrafts.Shared:Media;
import std;
import :Html;
import :Model;
namespace Catcrafts::Media {
using Html::SafeHtml;
using Html::Escape;
using Html::Attr;
using Html::Format;
using Html::Join;
using Html::Url;
bool EndsWithNoCase(std::string_view s, std::string_view suffix) {
if (s.size() < suffix.size()) return false;
const std::size_t off = s.size() - suffix.size();
for (std::size_t i = 0; i < suffix.size(); ++i) {
char a = s[off + i];
if (a >= 'A' && a <= 'Z') a = static_cast<char>(a - 'A' + 'a');
if (a != suffix[i]) return false;
}
return true;
}
// True when the path names something the <video> path should handle. Used only
// to guess for a file that was never mirrored, so there is no record to ask.
export bool LooksLikeVideo(std::string_view src) {
return EndsWithNoCase(src, ".mp4") || EndsWithNoCase(src, ".webm")
|| EndsWithNoCase(src, ".mov");
}
// The type= a <source> should advertise. Empty for anything not recognised, in
// which case the tier is dropped rather than guessed: a wrong type is worse
// than a missing source, because the browser believes it.
std::string_view MimeFor(std::string_view src) {
if (EndsWithNoCase(src, ".avif")) return "image/avif";
if (EndsWithNoCase(src, ".webp")) return "image/webp";
if (EndsWithNoCase(src, ".png")) return "image/png";
if (EndsWithNoCase(src, ".jpg") || EndsWithNoCase(src, ".jpeg")) return "image/jpeg";
if (EndsWithNoCase(src, ".gif")) return "image/gif";
return {};
}
// Width and height, or nothing when the mirror could not probe them. Both or
// neither: a lone dimension is worse than none, because the browser derives the
// other from it and gets the aspect ratio wrong.
SafeHtml Dimensions(const PostMedia& m) {
if (m.width <= 0 || m.height <= 0) return SafeHtml{};
return Format("{}{}", Attr("width", std::to_string(m.width)),
Attr("height", std::to_string(m.height)));
}
SafeHtml ImageTag(const PostMedia& m, std::string_view alt) {
// What every browser reads, and the only element that is always present.
// The PNG when there is one, because that is the tier nothing can refuse.
const std::string_view imgSrc = m.fallback.empty() ? m.src : m.fallback;
std::vector<SafeHtml> sources;
if (!m.avif.empty()) {
sources.push_back(Format(R"(<source{} type="image/avif">)", Url("srcset", m.avif)));
}
// The mirrored original, sitting between the AVIF and the PNG. Skipped when
// it IS what the <img> points at or what the AVIF tier already offered
// (nothing left to say), or when its type cannot be named.
if (m.src != imgSrc && m.src != m.avif) {
if (const std::string_view mime = MimeFor(m.src); !mime.empty()) {
sources.push_back(Format(R"(<source{}{}>)",
Url("srcset", m.src), Attr("type", mime)));
}
}
// alt is whatever the author wrote, which for a post's headline media is
// nothing: those are screenshots whose meaning is already in the title and
// the excerpt, and inventing descriptive alt text here would be making up
// what the picture shows. Emitted explicitly even when empty — alt="" is
// skipped cleanly by a screen reader, a missing alt makes it read the file
// name out loud.
const SafeHtml img = Format(
R"(<img class="post-media__item" loading="lazy" decoding="async" alt="{}"{}{}>)",
Escape(alt), Url("src", imgSrc), Dimensions(m));
if (sources.empty()) return img;
return Format("<picture>{}{}</picture>", Join(sources), img);
}
SafeHtml VideoTag(const PostMedia& m) {
// preload="metadata", not "auto": a page with several 5 MB recordings must
// not pull them all on load.
const SafeHtml poster = m.poster.empty() ? SafeHtml{} : Url("poster", m.poster);
const SafeHtml dims = Dimensions(m);
if (m.fallback.empty() || m.fallback == m.src) {
return Format(
R"(<video class="post-media__item" controls preload="metadata" playsinline{}{}{}></video>)",
Url("src", m.src), poster, dims);
}
// An AV1 video with its H.264 rendition. Both are .mp4, so the container
// alone cannot tell them apart: the codecs parameter on the first <source>
// is what lets a browser without AV1 (Safari before 17, Apple hardware
// without the decoder) skip it and take the H.264 instead of failing on a
// file it cannot decode. The string is advisory and used only for
// selection — once a source is picked the browser reads the actual stream —
// so the canonical profile-0 8-bit form is right for anything
// publish-media.sh emits (yuv420p is pinned there).
return Format(
R"(<video class="post-media__item" controls preload="metadata" playsinline{}{}>)"
R"(<source{} type="video/mp4; codecs=av01.0.08M.08">)"
R"(<source{} type="video/mp4">)"
R"(</video>)",
poster, dims, Url("src", m.src), Url("src", m.fallback));
}
// The element for one mirrored file.
export SafeHtml Tag(const PostMedia& m, std::string_view alt = {}) {
return m.kind == "video" ? VideoTag(m) : ImageTag(m, alt);
}
// The mirrored record for `src`, or nullptr when this path was never mirrored.
//
// Matching is on the exact string because tools/fetch-media.sh rewrites the
// body text and the body_media list from the same mapping in the same pass —
// they cannot disagree about a path without the mirror step itself being wrong.
export const PostMedia* Find(std::span<const PostMedia> media, std::string_view src) {
for (const PostMedia& m : media) {
if (m.src == src) return &m;
}
return nullptr;
}
// The record for `src`, or one invented from the path alone.
//
// The invented case is a file whose download failed, so the body still points
// at its original URL: there are no dimensions and no renditions to offer, and
// the markup degrades to the bare element. Rendering something beats dropping
// the picture the paragraph is talking about.
export PostMedia Describe(std::span<const PostMedia> media, std::string_view src) {
if (const PostMedia* hit = Find(media, src)) return *hit;
PostMedia m;
m.src = std::string(src);
m.kind = LooksLikeVideo(src) ? "video" : "image";
return m;
}
// A post's media as one block. More than one file gets a two-up grid via CSS,
// so a post with four screenshots does not become a mile of scrolling.
export SafeHtml Block(std::span<const PostMedia> media) {
if (media.empty()) return SafeHtml{};
std::vector<SafeHtml> items;
items.reserve(media.size());
for (const PostMedia& m : media) items.push_back(Tag(m));
return Format(R"(<div class="post-media">{}</div>)", Join(items));
}
} // namespace Catcrafts::Media

View file

@ -30,6 +30,11 @@ No permission is granted to copy, modify, distribute, or create derivative works
export module Catcrafts.Shared:Model;
import std;
import :Json;
// For IsValidSlug. A post slug becomes a URL, so it is checked here on the way
// in rather than trusted because CI wrote it: an unparseable slug would render
// a "read more" link to a route that can never match, which is a 404 the site
// links to itself. Dropping it instead degrades to a post with no page.
import :Route;
namespace Catcrafts {
@ -50,34 +55,69 @@ export struct PostMedia {
// play — and these posts are their video, so the black box is the page.
// Empty for images, and empty when the instance generated no thumbnail.
std::string poster;
// H.264 rendition of an AV1 video, when tools/publish-media.sh uploaded one
// beside it. Non-empty means the renderer emits a <source> pair instead of
// a bare src, so a browser without AV1 (Safari before 17, Apple hardware
// without the decoder) gets a file it can play. Empty for images and for
// videos that need no fallback.
// The rendition every browser can take, and the bottom of the format
// ladder :Media builds. Empty means there is only `src`.
//
// video H.264 in MP4, uploaded beside the AV1 by
// tools/publish-media.sh. Without it a browser lacking AV1
// (Safari before 17, Apple hardware without the decoder) has an
// element it cannot play.
// image PNG, transcoded by tools/fetch-media.sh. It is what the <img>
// itself points at, so a browser that understands no <source>
// type at all still gets the picture.
std::string fallback;
// AVIF rendition of an image, transcoded by tools/fetch-media.sh. Offered
// above `src` — it is consistently smaller than the WebP the instances
// serve, at 4:4:4 chroma so coloured text in a screenshot does not fringe.
// Empty for videos, and for an image the transcode could not produce.
std::string avif;
std::int64_t width = 0;
std::int64_t height = 0;
};
// A post mirrored from the fediverse (Lemmy). Deliberately not the full post:
// the body stays on the community's instance, where the comments are. We show
// enough to be worth clicking and then hand off — no crawling, no comment
// mirroring, no markdown pipeline.
// A post mirrored from the fediverse (Lemmy).
//
// The body IS mirrored — the comments are not. That split is the whole policy:
// the writing is the work and belongs on the site that is about the work (and
// is the only version a search engine can be pointed at as canonical), while
// the discussion belongs to the community that is having it. So every post
// page carries the full text and then hands off to the thread. No crawling, no
// comment mirroring, no runtime dependency on the instance.
//
// `permalink` is resolved at fetch time to the COMMUNITY's instance, not the
// author's. Both host a federated copy; the community's is where the discussion
// actually is, and it is what the site should be pointing readers at.
export struct Post {
std::string title;
// URL-safe name of this post's own page at /posts/<slug>, derived from the
// title by tools/fetch-posts.sh. Empty only if the title reduced to nothing
// a slug can be made of, which is what HasPage() below tests for.
std::string slug;
std::string permalink; // canonical ap_id on the instance; where "discuss" goes
std::string linkUrl; // for link posts, the linked target; empty otherwise
std::string community; // e.g. "linux@lemmy.ml"
std::string published; // ISO-8601, as emitted by the API
std::string excerpt; // plain text, truncated by CI — never markdown
// The full post, still as Markdown. Rendered by :Markdown at page-render
// time rather than converted to HTML at fetch time, because the renderer is
// where this codebase's escaping guarantee lives — text that arrives from
// someone else's server must not become markup anywhere but there.
std::string body;
std::int64_t score = 0;
std::int64_t comments = 0;
// The post's headline media — `post.url` upstream, the recording or
// screenshot the post is *about*. Shown on the card and above the body.
std::vector<PostMedia> media;
// Files embedded inside the body, mirrored by the same pass. The body text
// already points at these local paths; this list exists so the renderer can
// attach dimensions (and a poster, and the H.264 fallback) to them, which
// Markdown syntax has nowhere to carry.
std::vector<PostMedia> bodyMedia;
// Whether this post gets its own page. A post with no body has nothing to
// put on one — a page consisting of a title and a link out is thin content
// that should not be minted as a URL, indexed, or linked to as "read more".
bool HasPage() const { return !slug.empty() && !body.empty(); }
};
// An entry on the projects page. Repo content, not a database — these change
@ -294,6 +334,32 @@ export struct LegalPage {
// ── loaders ───────────────────────────────────────────────────────────
// The media array shape, shared by a post's headline media and its body media —
// they are the same records produced by the same mirror pass, so they are read
// by one function rather than two that could drift.
std::vector<PostMedia> LoadPostMedia(const Json::Value* array) {
std::vector<PostMedia> out;
if (!array || !array->IsArray()) return out;
for (const Json::Value& mv : array->array) {
if (!mv.IsObject()) continue;
PostMedia pm;
pm.src = std::string(mv.Str("src"));
pm.kind = std::string(mv.Str("kind", "image"));
pm.poster = std::string(mv.Str("poster"));
pm.fallback = std::string(mv.Str("fallback"));
pm.avif = std::string(mv.Str("avif"));
pm.width = mv.Int("w");
pm.height = mv.Int("h");
// Only the two kinds the renderer knows how to emit. Anything else
// would fall through to an <img> for a file that is not an image, so
// treat it as image only when it says so.
if (pm.kind != "image" && pm.kind != "video") pm.kind = "image";
if (pm.src.empty()) continue;
out.push_back(std::move(pm));
}
return out;
}
export std::vector<Post> LoadPosts(std::string_view json) {
std::vector<Post> out;
auto doc = Json::Parse(json);
@ -303,31 +369,19 @@ export std::vector<Post> LoadPosts(std::string_view json) {
if (!item.IsObject()) continue;
Post p;
p.title = std::string(item.Str("title"));
if (const std::string_view slug = item.Str("slug"); IsValidSlug(slug)) {
p.slug = std::string(slug);
}
p.permalink = std::string(item.Str("permalink"));
p.linkUrl = std::string(item.Str("url"));
p.community = std::string(item.Str("community"));
p.published = std::string(item.Str("published"));
p.excerpt = std::string(item.Str("excerpt"));
p.body = std::string(item.Str("body"));
p.score = item.Int("score");
p.comments = item.Int("comments");
if (const Json::Value* m = item.Find("media"); m && m->IsArray()) {
for (const Json::Value& mv : m->array) {
if (!mv.IsObject()) continue;
PostMedia pm;
pm.src = std::string(mv.Str("src"));
pm.kind = std::string(mv.Str("kind", "image"));
pm.poster = std::string(mv.Str("poster"));
pm.fallback = std::string(mv.Str("fallback"));
pm.width = mv.Int("w");
pm.height = mv.Int("h");
// Only the two kinds the renderer knows how to emit. Anything
// else would fall through to an <img> for a file that is not an
// image, so treat it as image only when it says so.
if (pm.kind != "image" && pm.kind != "video") pm.kind = "image";
if (pm.src.empty()) continue;
p.media.push_back(std::move(pm));
}
}
p.media = LoadPostMedia(item.Find("media"));
p.bodyMedia = LoadPostMedia(item.Find("body_media"));
// A post with no title and nowhere to click is not renderable; drop it
// rather than emit an empty card.
if (p.title.empty() && p.permalink.empty()) continue;

View file

@ -23,6 +23,7 @@ export enum class RouteKind {
About, // /about — the person behind the company
Projects,
Posts,
Post, // /posts/<slug> — one post, in full
Demos, // /demos — the list
Demo, // /demos/<slug>
Shop, // /shop — the (currently single-item) product list
@ -45,7 +46,8 @@ export struct Route {
std::string query; // raw, including leading '?', or empty
// Non-empty when the request should be canonicalised to a different URL.
std::string canonicalRedirect;
// The <slug> of /shop/<slug>, empty for every other route.
// The <slug> of /shop/<slug>, /demos/<slug>, /legal/<slug> or /posts/<slug>
// (and the token of /order/<token>); empty for every other route.
std::string slug;
};
@ -134,6 +136,20 @@ export Route ParseRoute(std::string_view path, std::string_view query = {}) {
return r;
}
// /posts/<slug>. Whether a slug names a post the site actually has is the
// dispatcher's question, exactly as it is for /shop/<slug>; this only
// decides that the URL is shaped like a post page at all.
if (p.starts_with("/posts/")) {
const std::string_view slug = p.substr(7);
if (IsValidSlug(slug)) {
r.kind = RouteKind::Post;
r.slug = std::string(slug);
return r;
}
r.kind = RouteKind::NotFound;
return r;
}
if (p.starts_with("/demos/")) {
const std::string_view slug = p.substr(7);
if (IsValidSlug(slug)) {
@ -183,6 +199,20 @@ export struct NavItem {
RouteKind kind;
};
// Which nav entry a route highlights. A route that is not itself a nav entry
// borrows its section's: an individual post and the retired /blog URL both sit
// under Posts. Shared so the three call sites (the server, the CLI renderer and
// the wasm router) cannot disagree about where the reader is — they each used
// to spell the /blog case out, and the third one to be added would have been
// the third chance to forget it.
export RouteKind NavKindFor(RouteKind kind) {
switch (kind) {
case RouteKind::Post:
case RouteKind::LegacyBlog: return RouteKind::Posts;
default: return kind;
}
}
export std::span<const NavItem> NavItems() {
static constexpr std::array<NavItem, 6> items{{
{ "Home", "/", RouteKind::Home },

View file

@ -23,6 +23,8 @@ import std;
import :Content;
import :Html;
import :Form;
import :Markdown;
import :Media;
import :Model;
import :Money;
import :Route;
@ -189,7 +191,11 @@ export RenderedPage RenderHome(std::span<const Project> projects,
const Post& post = posts[i];
recent.push_back(Format(
R"(<li class="recent__item"><a{}>{}</a><span class="recent__date">{}</span></li>)",
Url("href", post.permalink),
// The on-site page when the post has one, the thread otherwise.
// Same rule as the cards on /posts: a link from the home page is
// worth more pointing at a page this site owns than at someone
// else's copy of it, and the post page links onward to the thread.
Url("href", post.HasPage() ? "/posts/" + post.slug : post.permalink),
Escape(post.title),
Escape(DateOnly(post.published))));
}
@ -341,65 +347,62 @@ export RenderedPage RenderProjects(std::span<const Project> projects) {
// keeps the privacy notice's "everything comes from catcrafts.net" true and
// stops every visitor's IP reaching whichever instance hosted the file.
//
// Video is preload="metadata", not "auto": a page with several 5 MB recordings
// must not pull them all on load. Dimensions come from the content file so the
// browser reserves the right box and nothing jumps as files arrive.
// The element itself — the format ladder, the dimensions, the poster — is
// :Media's job, shared with the body renderer so a post's headline screenshot
// and one embedded in its prose cannot be served differently.
SafeHtml RenderPostMedia(std::span<const PostMedia> media) {
if (media.empty()) return SafeHtml{};
std::vector<SafeHtml> items;
for (const PostMedia& m : media) {
SafeHtml dims = (m.width > 0 && m.height > 0)
? Format("{}{}", Attr("width", std::to_string(m.width)),
Attr("height", std::to_string(m.height)))
: SafeHtml{};
if (m.kind == "video") {
SafeHtml poster = m.poster.empty() ? SafeHtml{} : Url("poster", m.poster);
if (m.fallback.empty() || m.fallback == m.src) {
items.push_back(Format(
R"(<video class="post-media__item" controls preload="metadata" )"
R"(playsinline{}{}{}></video>)",
Url("src", m.src), poster, dims));
} else {
// An AV1 video with its H.264 rendition. Both are .mp4, so the
// container alone cannot tell them apart: the codecs parameter
// on the first <source> is what lets a browser without AV1
// (Safari before 17, Apple hardware without the decoder) skip
// it and take the H.264 instead of failing on a file it cannot
// decode. The string is advisory and used only for selection —
// once a source is picked the browser reads the actual stream —
// so the canonical profile-0 8-bit form is right for anything
// publish-media.sh emits (yuv420p is pinned there).
items.push_back(Format(
R"(<video class="post-media__item" controls preload="metadata" )"
R"(playsinline{}{}>)"
R"(<source{} type="video/mp4; codecs=av01.0.08M.08">)"
R"(<source{} type="video/mp4">)"
R"(</video>)",
poster, dims, Url("src", m.src), Url("src", m.fallback)));
}
} else {
// alt is empty and aria-hidden is absent on purpose: these are
// screenshots whose meaning is already in the post title and
// excerpt, and inventing descriptive alt text here would be making
// up what the picture shows.
items.push_back(Format(
R"(<img class="post-media__item" loading="lazy" decoding="async" )"
R"(alt=""{}{}>)",
Url("src", m.src), dims));
}
}
return Format(R"(<div class="post-media">{}</div>)", Join(items));
return Media::Block(media);
}
// A link post gets a second, clearly-labelled outbound link. Without the label
// the two links are indistinguishable and one of them silently leaves the site.
SafeHtml RenderPostLinkRow(const Post& p) {
if (p.linkUrl.empty()) return SafeHtml{};
return Format(
R"(<p class="post-card__link"><a{} rel="nofollow noopener">{}</a></p>)",
Url("href", p.linkUrl), Escape(p.linkUrl));
}
// Points and the thread link, identical on a card and at the foot of a post
// page — the reader is being offered the same two things in both places, and
// they should not look like two different components.
SafeHtml RenderPostStats(const Post& p) {
return Format(
R"(<footer class="post-card__footer">)"
R"(<span class="stat">{} points</span>)"
R"(<a class="stat stat--link"{} rel="noopener">Discuss on the fediverse ({} comments) &rarr;</a>)"
R"(</footer>)",
Num(p.score), Url("href", p.permalink), Num(p.comments));
}
export RenderedPage RenderPosts(std::span<const Post> posts) {
std::vector<SafeHtml> cards;
for (const Post& p : posts) {
// A link post gets a second, clearly-labelled outbound link. Without
// the label the two links are indistinguishable and one of them
// silently leaves the site.
SafeHtml linkRow = p.linkUrl.empty() ? SafeHtml{} : Format(
R"(<p class="post-card__link"><a{} rel="nofollow noopener">{}</a></p>)",
Url("href", p.linkUrl), Escape(p.linkUrl));
// The title goes to this site's copy when there is one. Before post
// pages existed it could only go to the thread, which meant the list
// of everything this site is about was a list of links off it.
const std::string titleHref = p.HasPage() ? "/posts/" + p.slug : p.permalink;
// No "read more" without a page to read more on — a post whose body
// never arrived would otherwise offer a link to its own 404.
//
// It goes inline at the end of the excerpt, immediately after the
// ellipsis the truncation left: that is where the sentence stops and
// where the reader is already looking for the rest of it. Below the
// media it was the same offer made a screen further down, after the
// reader had already decided.
const SafeHtml more = !p.HasPage() ? SafeHtml{} : Format(
R"( <a class="link-more"{}>Read the full post</a>)",
Url("href", "/posts/" + p.slug));
// With no excerpt there is no sentence to continue, so the link falls
// back to a row of its own rather than disappearing along with it.
const SafeHtml excerptRow =
!p.excerpt.empty()
? Format(R"(<p class="post-card__excerpt">{}{}</p>)", Escape(p.excerpt), more)
: more.Empty()
? SafeHtml{}
: Format(R"(<p class="post-card__more">{}</p>)", more);
cards.push_back(Format(
R"(<article class="post-card">)"
@ -412,32 +415,28 @@ export RenderedPage RenderPosts(std::span<const Post> posts) {
R"({})"
R"({})"
R"({})"
R"(<footer class="post-card__footer">)"
R"(<span class="stat">{} points</span>)"
R"(<a class="stat stat--link"{} rel="noopener">Discuss on the fediverse ({} comments) &rarr;</a>)"
R"(</footer>)"
R"({})"
R"(</article>)",
Url("href", p.permalink), Escape(p.title),
Url("href", titleHref), Escape(p.title),
Attr("datetime", p.published), Escape(DateOnly(p.published)),
Escape(p.community),
p.excerpt.empty() ? SafeHtml{}
: Format(R"(<p class="post-card__excerpt">{}</p>)", Escape(p.excerpt)),
excerptRow,
RenderPostMedia(p.media),
linkRow,
Num(p.score),
Url("href", p.permalink), Num(p.comments)));
RenderPostLinkRow(p),
RenderPostStats(p)));
}
RenderedPage page;
page.meta.title = "Posts — Catcrafts";
page.meta.description = "Posts from the fediverse; discussion happens there.";
page.meta.description = "Posts about mobile Linux, kernel work and open hardware, "
"written for the fediverse and archived here in full.";
page.meta.canonical = "/posts";
page.main = Format(
R"(<header class="page-header">)"
R"(<h1 class="page-header__title">Posts</h1>)"
R"(<p class="page-header__lede">Catcrafts posts on the fediverse rather than keeping a blog here. )"
R"(Each of these links to the original thread on whichever instance it lives on. )"
R"(Follow one to read it and join the discussion there.</p>)"
R"(Each one is kept here in full, and links to the original thread on whichever )"
R"(instance it lives on — that is where the discussion is.</p>)"
R"(</header>)"
R"(<div class="post-list">{}</div>)",
cards.empty()
@ -446,6 +445,88 @@ export RenderedPage RenderPosts(std::span<const Post> posts) {
return page;
}
// ── one post, in full ─────────────────────────────────────────────────
// The whole point of hosting the body: a page a search engine can index, a
// reader can link to, and this site can claim as canonical. The card on /posts
// is a summary of this; the thread on the instance is where the comments are.
//
// The body arrives as Markdown and is rendered HERE rather than converted at
// fetch time, because :Markdown is inside the escaping guarantee and a shell
// script writing HTML into a content file would not be. See that module for
// what it does and does not accept.
export RenderedPage RenderPost(const Post& p) {
// Whatever the post leads with, for the link-preview card. A video has no
// still of its own to offer, so its poster frame stands in. Only our own
// mirrored copies qualify: an og:image on someone else's instance is the
// same third-party fetch the mirror exists to avoid, just performed by a
// crawler instead of a reader.
std::string ogImage;
for (const PostMedia& m : p.media) {
const std::string& candidate = m.kind == "video" ? m.poster : m.src;
if (candidate.starts_with("/")) { ogImage = candidate; break; }
}
const std::string canonical = "/posts/" + p.slug;
RenderedPage page;
page.meta.title = p.title + " — Catcrafts";
page.meta.description = p.excerpt;
page.meta.canonical = canonical;
page.meta.ogType = "article";
page.meta.ogImage = ogImage;
// BlogPosting, joined to the same two nodes every other page names: the
// Person on /about is the author and the Organization is the publisher.
// Without those @ids each post would introduce a fourth unrelated
// "Catcrafts" to a search index that already has trouble telling this one
// from the name-twins — see RenderHome for the whole argument.
//
// discussionUrl is the honest way to say what the fediverse link is: the
// comments belong to the thread, and this page is not pretending to mirror
// them.
page.meta.jsonLd = std::format(
R"({{"@context":"https://schema.org","@type":"BlogPosting",)"
R"("headline":{},"datePublished":{},"url":{},)"
R"("mainEntityOfPage":{{"@type":"WebPage","@id":{}}},)"
R"("author":{{"@id":"https://catcrafts.net/about#person",)"
R"("@type":"Person","name":"Jorijn van der Graaf"}},)"
R"("publisher":{{"@id":"https://catcrafts.net/#organization",)"
R"("@type":"Organization","name":"Catcrafts"}},)"
R"("discussionUrl":{}{}{}}})",
JsonStr(p.title), JsonStr(p.published),
JsonStr("https://catcrafts.net" + canonical),
JsonStr("https://catcrafts.net" + canonical),
JsonStr(p.permalink),
p.excerpt.empty() ? std::string{} : ",\"description\":" + JsonStr(p.excerpt),
ogImage.empty() ? std::string{}
: ",\"image\":" + JsonStr("https://catcrafts.net" + ogImage));
page.main = Format(
R"(<article class="post">)"
R"(<header class="page-header">)"
R"(<h1 class="page-header__title">{}</h1>)"
R"(<p class="post-card__meta">)"
R"(<time{}>{}</time><span class="post-card__community">{}</span>)"
R"(</p>)"
R"(</header>)"
R"({})"
R"({})"
R"(<div class="post-body">{}</div>)"
R"({})"
R"(<p class="post__back"><a class="link-more"{}>All posts</a></p>)"
R"(</article>)",
Escape(p.title),
Attr("datetime", p.published), Escape(DateOnly(p.published)),
Escape(p.community),
RenderPostMedia(p.media),
RenderPostLinkRow(p),
Markdown::Render(p.body, p.bodyMedia),
RenderPostStats(p),
Url("href", "/posts"));
return page;
}
// ── shop ──────────────────────────────────────────────────────────────
// The product page's price block: the same single headline number as the shop
@ -1329,6 +1410,16 @@ export struct SiteContent {
}
return nullptr;
}
// Only posts that have a page. A post with no body has a slug but nothing
// to show, so finding it here would mint an indexable URL for a title and
// a link — see Post::HasPage.
const Post* FindPost(std::string_view slug) const {
for (const Post& p : posts) {
if (p.HasPage() && p.slug == slug) return &p;
}
return nullptr;
}
};
RenderedPage RenderRouteBody(const Route& route, const SiteContent& content);
@ -1357,6 +1448,13 @@ RenderedPage RenderRouteBody(const Route& route, const SiteContent& content) {
case RouteKind::About: return RenderAbout(Content::AboutPage());
case RouteKind::Projects: return RenderProjects(content.projects);
case RouteKind::Posts: return RenderPosts(content.posts);
case RouteKind::Post: {
// A slug that parsed but names no post is a 404, for the same
// reason an unknown product slug is: otherwise every typo and
// every retired post becomes an indexable empty page.
if (const Post* p = content.FindPost(route.slug)) return RenderPost(*p);
break;
}
case RouteKind::Demos: return RenderDemos(content.demos);
case RouteKind::Demo: {
if (const Demo* d = content.FindDemo(route.slug)) return RenderDemo(*d);

View file

@ -35,6 +35,8 @@ export import :Html;
export import :Json;
export import :Form;
export import :Model;
export import :Media;
export import :Markdown;
export import :Content;
export import :Money;
export import :Route;