173 lines
8.9 KiB
C++
173 lines
8.9 KiB
C++
|
|
/*
|
||
|
|
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.
|
||
|
|
*/
|
||
|
|
|
||
|
|
// Post pages: the routing, the loader's guard on what becomes a URL, and the
|
||
|
|
// schema.org joins that keep every post attributed to the one Organization and
|
||
|
|
// the one Person the rest of the site describes.
|
||
|
|
|
||
|
|
import std;
|
||
|
|
import Catcrafts.Shared;
|
||
|
|
|
||
|
|
using namespace Catcrafts;
|
||
|
|
|
||
|
|
namespace {
|
||
|
|
|
||
|
|
int failures = 0;
|
||
|
|
|
||
|
|
void Check(bool ok, std::string_view what, std::string_view got = {}) {
|
||
|
|
if (ok) return;
|
||
|
|
++failures;
|
||
|
|
std::println(std::cerr, "FAIL: {}{}{}", what,
|
||
|
|
got.empty() ? "" : " got: ", got);
|
||
|
|
}
|
||
|
|
|
||
|
|
} // namespace
|
||
|
|
|
||
|
|
int main() {
|
||
|
|
// ── routing ───────────────────────────────────────────────────────
|
||
|
|
Check(ParseRoute("/posts").kind == RouteKind::Posts, "route: /posts is the list");
|
||
|
|
Check(ParseRoute("/posts/hello-world").kind == RouteKind::Post, "route: /posts/<slug>");
|
||
|
|
Check(ParseRoute("/posts/hello-world").slug == "hello-world", "route: post slug captured");
|
||
|
|
Check(ParseRoute("/posts/hello-world/").kind == RouteKind::Post,
|
||
|
|
"route: trailing slash normalised");
|
||
|
|
Check(ParseRoute("/posts/Hello").kind == RouteKind::NotFound,
|
||
|
|
"route: uppercase slug is not a post URL");
|
||
|
|
Check(ParseRoute("/posts/../etc").kind == RouteKind::NotFound,
|
||
|
|
"route: traversal never reaches a lookup");
|
||
|
|
Check(NavKindFor(RouteKind::Post) == RouteKind::Posts,
|
||
|
|
"route: a post page highlights the Posts nav entry");
|
||
|
|
Check(NavKindFor(RouteKind::LegacyBlog) == RouteKind::Posts,
|
||
|
|
"route: the retired /blog URL highlights it too");
|
||
|
|
Check(NavKindFor(RouteKind::Shop) == RouteKind::Shop, "route: a nav route is its own entry");
|
||
|
|
|
||
|
|
// ── the loader ────────────────────────────────────────────────────
|
||
|
|
{
|
||
|
|
const auto posts = LoadPosts(R"([
|
||
|
|
{"title":"Good","slug":"good-post","permalink":"https://i.example/post/1",
|
||
|
|
"body":"Hello.","published":"2026-01-01T00:00:00Z",
|
||
|
|
"body_media":[{"src":"/media/a.webp","kind":"image","w":10,"h":20}]},
|
||
|
|
{"title":"Bad slug","slug":"NOT A SLUG","permalink":"https://i.example/post/2",
|
||
|
|
"body":"Hello."},
|
||
|
|
{"title":"No body","slug":"no-body","permalink":"https://i.example/post/3"}
|
||
|
|
])");
|
||
|
|
Check(posts.size() == 3, "posts: all three load");
|
||
|
|
if (posts.size() == 3) {
|
||
|
|
Check(posts[0].HasPage() && posts[0].slug == "good-post", "posts: valid slug kept");
|
||
|
|
Check(posts[0].body == "Hello.", "posts: body loaded");
|
||
|
|
Check(posts[0].bodyMedia.size() == 1 && posts[0].bodyMedia[0].width == 10,
|
||
|
|
"posts: body media loaded with dimensions");
|
||
|
|
// A slug that could never match a route would render a "read more"
|
||
|
|
// link to a 404 this site points at itself.
|
||
|
|
Check(posts[1].slug.empty() && !posts[1].HasPage(),
|
||
|
|
"posts: malformed slug is dropped, costing the page");
|
||
|
|
// A title and a link out is not a page worth minting a URL for.
|
||
|
|
Check(!posts[2].HasPage(), "posts: no body means no page");
|
||
|
|
}
|
||
|
|
|
||
|
|
Views::SiteContent content;
|
||
|
|
content.posts = posts;
|
||
|
|
Check(content.FindPost("good-post") != nullptr, "posts: found by slug");
|
||
|
|
Check(content.FindPost("no-body") == nullptr, "posts: a pageless post is not findable");
|
||
|
|
Check(content.FindPost("nope") == nullptr, "posts: unknown slug is not found");
|
||
|
|
// Which means the route 404s rather than rendering an empty article.
|
||
|
|
Check(Views::RenderRoute(ParseRoute("/posts/no-body"), content).status == 404,
|
||
|
|
"posts: a pageless slug is a real 404");
|
||
|
|
Check(Views::RenderRoute(ParseRoute("/posts/good-post"), content).status == 200,
|
||
|
|
"posts: a real post renders");
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── the page ──────────────────────────────────────────────────────
|
||
|
|
{
|
||
|
|
Post p;
|
||
|
|
p.title = "Working GPS!";
|
||
|
|
p.slug = "working-gps";
|
||
|
|
p.permalink = "https://lemmy.example/post/42";
|
||
|
|
p.community = "linuxphones@lemmy.example";
|
||
|
|
p.published = "2026-06-26T23:16:25Z";
|
||
|
|
p.excerpt = "A short summary.";
|
||
|
|
p.body = "# How\n\nIt works.";
|
||
|
|
PostMedia m;
|
||
|
|
m.src = "/media/shot.webp";
|
||
|
|
m.kind = "image";
|
||
|
|
p.media.push_back(m);
|
||
|
|
|
||
|
|
const auto page = Views::RenderPost(p);
|
||
|
|
Check(page.meta.canonical == "/posts/working-gps",
|
||
|
|
"post page: canonical is this site, not the instance");
|
||
|
|
Check(page.meta.ogType == "article", "post page: og:type is article");
|
||
|
|
Check(page.meta.ogImage == "/media/shot.webp", "post page: og:image from the post media");
|
||
|
|
Check(page.meta.description == p.excerpt, "post page: description is the excerpt");
|
||
|
|
Check(page.main.View().find("<h2>How</h2>") != std::string_view::npos,
|
||
|
|
"post page: the body is rendered, not escaped away");
|
||
|
|
// The whole reason the body is hosted: the thread is still one click
|
||
|
|
// away, and the reader is told where the discussion is.
|
||
|
|
Check(page.main.View().find(p.permalink) != std::string_view::npos,
|
||
|
|
"post page: still links the thread");
|
||
|
|
|
||
|
|
// The identity graph, same joins every other page makes. A typo'd @id
|
||
|
|
// still renders and still validates — it just quietly splits this post
|
||
|
|
// away from the entity the rest of the site describes.
|
||
|
|
auto ld = Json::Parse(page.meta.jsonLd);
|
||
|
|
Check(ld && ld->IsObject() && ld->Str("@type") == "BlogPosting",
|
||
|
|
"post schema: parses as a BlogPosting");
|
||
|
|
if (ld && ld->IsObject()) {
|
||
|
|
Check(ld->Str("url") == "https://catcrafts.net/posts/working-gps",
|
||
|
|
"post schema: url is the on-site page");
|
||
|
|
Check(ld->Str("discussionUrl") == p.permalink,
|
||
|
|
"post schema: the thread is the discussion, not the content");
|
||
|
|
Check(ld->Str("datePublished") == p.published, "post schema: publication date");
|
||
|
|
const Json::Value* author = ld->Find("author");
|
||
|
|
const Json::Value* publisher = ld->Find("publisher");
|
||
|
|
Check(author && author->Str("@id") == "https://catcrafts.net/about#person",
|
||
|
|
"post schema: authored by the Person node on /about");
|
||
|
|
Check(publisher && publisher->Str("@id") == "https://catcrafts.net/#organization",
|
||
|
|
"post schema: published by the Organization node");
|
||
|
|
}
|
||
|
|
|
||
|
|
// A post with a page is advertised as one from the list and from home;
|
||
|
|
// one without keeps pointing at the thread, because there is nothing
|
||
|
|
// here to send the reader to.
|
||
|
|
const std::array<Post, 1> one{ p };
|
||
|
|
const auto list = Views::RenderPosts(one);
|
||
|
|
Check(list.main.View().find(R"(href="/posts/working-gps")") != std::string_view::npos,
|
||
|
|
"posts list: links the on-site page");
|
||
|
|
Check(list.main.View().find("Read the full post") != std::string_view::npos,
|
||
|
|
"posts list: offers the full post");
|
||
|
|
// Inside the excerpt paragraph, trailing the text — not a row of its
|
||
|
|
// own below the media, where it was the same offer made a screen
|
||
|
|
// further down.
|
||
|
|
Check(list.main.View().find(
|
||
|
|
R"(A short summary. <a class="link-more" href="/posts/working-gps">)"
|
||
|
|
R"(Read the full post</a></p>)") != std::string_view::npos,
|
||
|
|
"posts list: read-more trails the excerpt", list.main.View());
|
||
|
|
|
||
|
|
// With no excerpt there is no sentence to continue, so it falls back to
|
||
|
|
// a row rather than vanishing with the paragraph that would have held it.
|
||
|
|
Post unexcerpted = p;
|
||
|
|
unexcerpted.excerpt.clear();
|
||
|
|
const std::array<Post, 1> bare{ unexcerpted };
|
||
|
|
const auto listBare = Views::RenderPosts(bare);
|
||
|
|
Check(listBare.main.View().find("Read the full post") != std::string_view::npos,
|
||
|
|
"posts list: an excerptless post still offers the full post");
|
||
|
|
|
||
|
|
Post bodyless = p;
|
||
|
|
bodyless.body.clear();
|
||
|
|
const std::array<Post, 1> none{ bodyless };
|
||
|
|
const auto listNone = Views::RenderPosts(none);
|
||
|
|
Check(listNone.main.View().find("Read the full post") == std::string_view::npos,
|
||
|
|
"posts list: no read-more without a page to read");
|
||
|
|
Check(listNone.main.View().find(p.permalink) != std::string_view::npos,
|
||
|
|
"posts list: a pageless post still links its thread");
|
||
|
|
}
|
||
|
|
|
||
|
|
if (failures != 0) {
|
||
|
|
std::println(std::cerr, "{} check(s) failed", failures);
|
||
|
|
return 1;
|
||
|
|
}
|
||
|
|
return 0;
|
||
|
|
}
|