This commit is contained in:
parent
2fa6e70af1
commit
6841623e23
17 changed files with 2306 additions and 148 deletions
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue