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

@ -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);