This commit is contained in:
parent
2fa6e70af1
commit
6841623e23
17 changed files with 2306 additions and 148 deletions
588
shared/interfaces/Catcrafts.Shared-Markdown.cppm
Normal file
588
shared/interfaces/Catcrafts.Shared-Markdown.cppm
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
//  — 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
|
||||
192
shared/interfaces/Catcrafts.Shared-Media.cppm
Normal file
192
shared/interfaces/Catcrafts.Shared-Media.cppm
Normal 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
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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) →</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) →</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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue