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