This commit is contained in:
parent
2fa6e70af1
commit
6841623e23
17 changed files with 2306 additions and 148 deletions
|
|
@ -287,8 +287,7 @@ HTTPResponse RenderPage(std::string_view target) {
|
|||
if (const Demo* d = gContent.FindDemo(route.slug)) wantsWasm = d->needsWasm;
|
||||
}
|
||||
res.body = Views::RenderDocument(page,
|
||||
Views::RenderNav(route.kind == RouteKind::LegacyBlog
|
||||
? RouteKind::Posts : route.kind),
|
||||
Views::RenderNav(NavKindFor(route.kind)),
|
||||
Views::RenderFooter(),
|
||||
wantsWasm ? gBootScripts : std::string_view{},
|
||||
gCssHref);
|
||||
|
|
@ -310,6 +309,15 @@ HTTPResponse ServeSitemap() {
|
|||
out += Html::Escape(pr.slug).Str();
|
||||
out += "</loc></url>\n";
|
||||
}
|
||||
// Post pages, from the same HasPage() test the "read more" links use — a
|
||||
// sitemap that advertised a post without a body would be pointing crawlers
|
||||
// at the 404 the dispatcher correctly returns for it.
|
||||
for (const Post& po : gContent.posts) {
|
||||
if (!po.HasPage()) continue;
|
||||
out += " <url><loc>https://catcrafts.net/posts/";
|
||||
out += Html::Escape(po.slug).Str();
|
||||
out += "</loc></url>\n";
|
||||
}
|
||||
out += "</urlset>\n";
|
||||
ApplyPageHeaders(res, "application/xml; charset=utf-8", true, false);
|
||||
res.body = std::move(out);
|
||||
|
|
|
|||
|
|
@ -109,6 +109,394 @@ void RunSelfTest() {
|
|||
CheckEq(Escape("a") + Escape("<"), "a<", "operator+: escapes preserved");
|
||||
}
|
||||
|
||||
// The format ladder. One <picture>/<video> builder serves both the cards and
|
||||
// the post bodies, so these assertions cover every image and video the site
|
||||
// emits — and the ordering ones matter: a browser takes the FIRST source it
|
||||
// understands, so a mis-ordered ladder silently serves the wrong tier to
|
||||
// everyone rather than failing visibly.
|
||||
void RunMediaSelfTest() {
|
||||
auto img = [](std::string src, std::string avif, std::string png,
|
||||
std::int64_t w = 0, std::int64_t h = 0) {
|
||||
PostMedia m;
|
||||
m.src = std::move(src);
|
||||
m.kind = "image";
|
||||
m.avif = std::move(avif);
|
||||
m.fallback = std::move(png);
|
||||
m.width = w;
|
||||
m.height = h;
|
||||
return m;
|
||||
};
|
||||
|
||||
// The full ladder: AVIF, then the mirrored original, then the PNG the <img>
|
||||
// itself points at. Exactly one of the three is ever fetched.
|
||||
CheckEq(Media::Tag(img("/media/x.webp", "/media/x.avif", "/media/x.png", 800, 600)),
|
||||
R"(<picture><source srcset="/media/x.avif" type="image/avif">)"
|
||||
R"(<source srcset="/media/x.webp" type="image/webp">)"
|
||||
R"(<img class="post-media__item" loading="lazy" decoding="async" alt="")"
|
||||
R"( src="/media/x.png" width="800" height="600"></picture>)",
|
||||
"media: image ladder is avif, original, png");
|
||||
|
||||
// Alt text reaches the <img>, not the <picture> — a screen reader reads the
|
||||
// img, and an alt on the wrapper is invisible to it.
|
||||
Check(Media::Tag(img("/media/x.webp", "/media/x.avif", "/media/x.png"), "a cat")
|
||||
.View().find(R"(alt="a cat" src="/media/x.png")") != std::string_view::npos,
|
||||
"media: alt lands on the img");
|
||||
|
||||
// Degradation, one tier at a time. Each of these is a real state: no
|
||||
// encoder on the build host, a source that was already PNG, a body image
|
||||
// whose download failed so there is nothing but the original URL.
|
||||
CheckEq(Media::Tag(img("/media/x.webp", "", "/media/x.png")),
|
||||
R"(<picture><source srcset="/media/x.webp" type="image/webp">)"
|
||||
R"(<img class="post-media__item" loading="lazy" decoding="async" alt="")"
|
||||
R"( src="/media/x.png"></picture>)",
|
||||
"media: no avif still offers the original above the png");
|
||||
CheckEq(Media::Tag(img("/media/x.webp", "/media/x.avif", "")),
|
||||
R"(<picture><source srcset="/media/x.avif" type="image/avif">)"
|
||||
R"(<img class="post-media__item" loading="lazy" decoding="async" alt="")"
|
||||
R"( src="/media/x.webp"></picture>)",
|
||||
"media: no png leaves the original as the base");
|
||||
CheckEq(Media::Tag(img("/media/x.webp", "", "")),
|
||||
R"(<img class="post-media__item" loading="lazy" decoding="async" alt="")"
|
||||
R"( src="/media/x.webp">)",
|
||||
"media: no renditions is a bare img, as before any of this existed");
|
||||
// A source that is already PNG is its own fallback, and must not be
|
||||
// offered twice — once as a <source> and once as the <img>.
|
||||
CheckEq(Media::Tag(img("/media/x.png", "/media/x.avif", "/media/x.png")),
|
||||
R"(<picture><source srcset="/media/x.avif" type="image/avif">)"
|
||||
R"(<img class="post-media__item" loading="lazy" decoding="async" alt="")"
|
||||
R"( src="/media/x.png"></picture>)",
|
||||
"media: a png source is not also listed as a source");
|
||||
// Likewise a source that is already AVIF.
|
||||
Check(Media::Tag(img("/media/x.avif", "/media/x.avif", "/media/x.png"))
|
||||
.View().find("image/avif\"><source") == std::string_view::npos,
|
||||
"media: an avif source is not listed twice");
|
||||
|
||||
// A URL is still a URL: the scheme allowlist applies to srcset exactly as
|
||||
// it does to src, or the ladder becomes a way around it.
|
||||
Check(Media::Tag(img("/media/x.webp", "javascript:alert(1)", "/media/x.png"))
|
||||
.View().find(R"(srcset="#")") != std::string_view::npos,
|
||||
"media: a hostile srcset is neutralised");
|
||||
|
||||
// Video is unchanged by any of this and must stay so.
|
||||
{
|
||||
PostMedia v;
|
||||
v.src = "/media/v.mp4";
|
||||
v.kind = "video";
|
||||
v.poster = "/media/v.webp";
|
||||
v.fallback = "/media/v.h264.mp4";
|
||||
const auto out = Media::Tag(v);
|
||||
Check(out.View().starts_with("<video class=\"post-media__item\" controls preload=\"metadata\""),
|
||||
"media: video is still a video", out.View());
|
||||
Check(out.View().find("codecs=av01") != std::string_view::npos
|
||||
&& out.View().find(R"(<source src="/media/v.h264.mp4" type="video/mp4">)")
|
||||
!= std::string_view::npos,
|
||||
"media: AV1 then H.264, in that order");
|
||||
Check(out.View().find("<picture>") == std::string_view::npos,
|
||||
"media: a video is not wrapped in a picture");
|
||||
}
|
||||
|
||||
// A path with no record renders from the path alone — the mirror failed,
|
||||
// and showing the picture beats dropping the paragraph's subject.
|
||||
{
|
||||
const std::array<PostMedia, 1> known{
|
||||
img("/media/x.webp", "/media/x.avif", "/media/x.png") };
|
||||
Check(Media::Find(known, "/media/x.webp") != nullptr, "media: found by src");
|
||||
Check(Media::Find(known, "/media/nope.webp") == nullptr, "media: unknown src");
|
||||
const PostMedia guessed = Media::Describe(known, "https://i.example/a.webp");
|
||||
Check(guessed.kind == "image" && guessed.avif.empty(),
|
||||
"media: an unmirrored image is described from its path");
|
||||
Check(Media::Describe(known, "https://i.example/a.mp4").kind == "video",
|
||||
"media: an unmirrored video is recognised as one");
|
||||
}
|
||||
}
|
||||
|
||||
// The Markdown renderer, which is the newest place untrusted text becomes
|
||||
// markup — post bodies are fetched from someone else's server, so every one of
|
||||
// these assertions is ultimately about the same thing: nothing in a body can
|
||||
// escape into the document. The structural cases are here too, because a parser
|
||||
// that silently drops a construct loses content invisibly.
|
||||
void RunMarkdownSelfTest() {
|
||||
auto md = [](std::string_view text,
|
||||
std::span<const PostMedia> media = {}) {
|
||||
return Markdown::Render(text, media);
|
||||
};
|
||||
|
||||
// ── the guarantee ─────────────────────────────────────────────────
|
||||
CheckEq(md("<script>alert(1)</script>"),
|
||||
"<p><script>alert(1)</script></p>", "md: html is text, never markup");
|
||||
CheckEq(md(")"),
|
||||
R"(<div class="post-media"><img class="post-media__item" loading="lazy" )"
|
||||
R"(decoding="async" alt="x" src="#"></div>)",
|
||||
"md: javascript: image source neutralised");
|
||||
CheckEq(md("[x](javascript:alert(1))"),
|
||||
R"(<p><a href="#" rel="noopener">x</a></p>)",
|
||||
"md: javascript: link neutralised");
|
||||
CheckEq(md("a \" b & c"), "<p>a " b & c</p>", "md: quotes and ampersands escaped");
|
||||
// A code span is verbatim text, and verbatim is exactly where an escaper
|
||||
// is most often forgotten.
|
||||
CheckEq(md("`<b>`"), "<p><code><b></code></p>", "md: code span escaped");
|
||||
|
||||
// ── blocks ────────────────────────────────────────────────────────
|
||||
CheckEq(md(""), "", "md: empty body renders nothing");
|
||||
CheckEq(md("plain text"), "<p>plain text</p>", "md: paragraph");
|
||||
// Demotion by one: the page h1 is the post title, so a body's own top-level
|
||||
// heading is a section within it.
|
||||
CheckEq(md("# Heading"), "<h2>Heading</h2>", "md: h1 demoted to h2");
|
||||
CheckEq(md("### Heading"), "<h4>Heading</h4>", "md: h3 demoted to h4");
|
||||
CheckEq(md("#nothashtag"), "<p>#nothashtag</p>", "md: # without a space is not a heading");
|
||||
CheckEq(md("> quoted"),
|
||||
R"(<blockquote class="post-body__quote"><p>quoted</p></blockquote>)",
|
||||
"md: blockquote");
|
||||
// The quoted lines are re-parsed, so a multi-paragraph quote keeps its
|
||||
// paragraphs instead of collapsing into one run-on line.
|
||||
CheckEq(md("> one\n>\n> two"),
|
||||
R"(<blockquote class="post-body__quote"><p>one</p><p>two</p></blockquote>)",
|
||||
"md: blockquote keeps its paragraphs");
|
||||
CheckEq(md("- a\n- b"),
|
||||
R"(<ul class="post-body__list"><li>a</li><li>b</li></ul>)", "md: unordered list");
|
||||
CheckEq(md("1. a\n2. b"),
|
||||
R"(<ol class="post-body__list"><li>a</li><li>b</li></ol>)", "md: ordered list");
|
||||
// A list resumed after an interrupting paragraph continues its numbering.
|
||||
// Without the start attribute the mini-guide in one of these posts renders
|
||||
// as steps 1-4 followed by steps 1, 2, 3.
|
||||
CheckEq(md("5. e"),
|
||||
R"(<ol class="post-body__list" start="5"><li>e</li></ol>)",
|
||||
"md: ordered list keeps the number it announced");
|
||||
// Blank lines between items are spacing, not seven one-item lists.
|
||||
CheckEq(md("1. a\n\n2. b"),
|
||||
R"(<ol class="post-body__list"><li>a</li><li>b</li></ol>)",
|
||||
"md: blank line inside a list does not split it");
|
||||
CheckEq(md("---"), "<hr>", "md: thematic break");
|
||||
CheckEq(md("- - -"), "<hr>", "md: spaced rule is not a one-item list");
|
||||
// Whitespace in pasted terminal output is the content.
|
||||
CheckEq(md("```\n a\tb\n```"),
|
||||
"<pre class=\"post-body__code\"><code> a\tb\n</code></pre>",
|
||||
"md: fenced code is verbatim");
|
||||
// An unterminated fence must not swallow the document into nothing.
|
||||
Check(md("```\nx").View().find("<code>x") != std::string_view::npos,
|
||||
"md: unterminated fence still renders its content");
|
||||
|
||||
// ── inline ────────────────────────────────────────────────────────
|
||||
CheckEq(md("**bold**"), "<p><strong>bold</strong></p>", "md: strong");
|
||||
CheckEq(md("*em*"), "<p><em>em</em></p>", "md: emphasis");
|
||||
CheckEq(md("2 * 3 * 4"), "<p>2 * 3 * 4</p>", "md: spaced asterisks stay literal");
|
||||
// Underscores are deliberately inert: these posts paste kernel symbol
|
||||
// names into prose, and italicising half of one is worse than not
|
||||
// italicising a word that used the underscore form.
|
||||
CheckEq(md("kworker/u16:8-qc_ufs_qos_swq"),
|
||||
"<p>kworker/u16:8-qc_ufs_qos_swq</p>", "md: underscores are not emphasis");
|
||||
CheckEq(md("\\*literal\\*"), "<p>*literal*</p>", "md: backslash escape");
|
||||
CheckEq(md("[label](https://x.example/y)"),
|
||||
R"(<p><a href="https://x.example/y" rel="noopener">label</a></p>)", "md: link");
|
||||
// Bare addresses are pasted constantly in these posts; leaving them inert
|
||||
// would strip most of the outbound value out of the page.
|
||||
CheckEq(md("see https://x.example/y"),
|
||||
R"(<p>see <a href="https://x.example/y">https://x.example/y</a></p>)",
|
||||
"md: bare URL autolinked");
|
||||
|
||||
// ── embedded media ────────────────────────────────────────────────
|
||||
// A paragraph that is nothing but images becomes the same media block the
|
||||
// cards use, rather than a <p> of pictures.
|
||||
CheckEq(md(""),
|
||||
R"(<div class="post-media"><img class="post-media__item" loading="lazy" )"
|
||||
R"(decoding="async" alt="a" src="/media/x.webp"></div>)",
|
||||
"md: image-only paragraph is a media block");
|
||||
Check(md("text ").View().starts_with("<p>text <img"),
|
||||
"md: an image inside a sentence stays inline");
|
||||
|
||||
// Dimensions come from the sidecar list, because Markdown syntax has
|
||||
// nowhere to carry them — and without them the prose below every
|
||||
// screenshot jumps as the file arrives.
|
||||
{
|
||||
std::vector<PostMedia> media;
|
||||
PostMedia img;
|
||||
img.src = "/media/x.webp";
|
||||
img.kind = "image";
|
||||
img.avif = "/media/x.avif";
|
||||
img.fallback = "/media/x.png";
|
||||
img.width = 800;
|
||||
img.height = 600;
|
||||
media.push_back(img);
|
||||
|
||||
PostMedia vid;
|
||||
vid.src = "/media/v.mp4";
|
||||
vid.kind = "video";
|
||||
vid.poster = "/media/v.poster.webp";
|
||||
vid.fallback = "/media/v.h264.mp4";
|
||||
vid.width = 1080;
|
||||
vid.height = 1920;
|
||||
media.push_back(vid);
|
||||
|
||||
const auto out = md("", media);
|
||||
Check(out.View().find(R"(width="800" height="600")") != std::string_view::npos,
|
||||
"md: inline image carries its dimensions", out.View());
|
||||
// Routed through :Media, so a body image gets the same format ladder a
|
||||
// card image does rather than a second, plainer implementation.
|
||||
Check(out.View().find(R"(<source srcset="/media/x.avif" type="image/avif">)")
|
||||
!= std::string_view::npos
|
||||
&& out.View().find(R"(src="/media/x.png")") != std::string_view::npos,
|
||||
"md: inline image gets the avif/png ladder", out.View());
|
||||
|
||||
// An inline video gets the same treatment a headline one does,
|
||||
// fallback source and all.
|
||||
const auto vout = md("", media);
|
||||
Check(vout.View().find(R"(poster="/media/v.poster.webp")") != std::string_view::npos
|
||||
&& vout.View().find("codecs=av01") != std::string_view::npos
|
||||
&& vout.View().find(R"(<source src="/media/v.h264.mp4")") != std::string_view::npos,
|
||||
"md: inline video gets poster and H.264 fallback", vout.View());
|
||||
}
|
||||
|
||||
// ── termination ───────────────────────────────────────────────────
|
||||
// Unbalanced delimiters are the classic way to hang a hand-written
|
||||
// parser, and a body is input from someone else's server.
|
||||
Check(!md("**unclosed").View().empty(), "md: unclosed strong terminates");
|
||||
Check(!md("[unclosed](").View().empty(), "md: unclosed link terminates");
|
||||
Check(!md(".View().empty(), "md: unclosed image terminates");
|
||||
Check(!md("`unclosed").View().empty(), "md: unclosed code span terminates");
|
||||
Check(!md("> > > > > > > > deep").View().empty(), "md: over-deep nesting terminates");
|
||||
}
|
||||
|
||||
// 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.
|
||||
void RunPostSelfTest() {
|
||||
// ── 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");
|
||||
}
|
||||
}
|
||||
|
||||
void RunJsonSelfTest() {
|
||||
using namespace Catcrafts::Json;
|
||||
|
||||
|
|
@ -810,6 +1198,9 @@ int main(int argc, char** argv) {
|
|||
RunJsonSelfTest();
|
||||
RunFormSelfTest();
|
||||
RunMoneySelfTest();
|
||||
RunMediaSelfTest();
|
||||
RunMarkdownSelfTest();
|
||||
RunPostSelfTest();
|
||||
if (failures == 0) {
|
||||
std::println("Catcrafts.Shared self-test: all assertions passed");
|
||||
return 0;
|
||||
|
|
@ -829,7 +1220,7 @@ int main(int argc, char** argv) {
|
|||
const Views::RenderedPage page = Views::RenderRoute(route, content);
|
||||
std::print("{}", Views::RenderDocument(
|
||||
page,
|
||||
Views::RenderNav(route.kind == RouteKind::LegacyBlog ? RouteKind::Posts : route.kind),
|
||||
Views::RenderNav(NavKindFor(route.kind)),
|
||||
Views::RenderFooter(),
|
||||
/*bootScripts=*/"", // no wasm on a plain server render
|
||||
/*cssHref=*/"/styles.css"));
|
||||
|
|
@ -859,6 +1250,14 @@ int main(int argc, char** argv) {
|
|||
std::print(" <url><loc>https://catcrafts.net/shop/{}</loc></url>\n",
|
||||
Html::Escape(pr.slug).Str());
|
||||
}
|
||||
// Same rule as the served sitemap: only posts that actually have a
|
||||
// page. Both must agree, because this is the copy baked into the wasm
|
||||
// bundle and that one is what a crawler fetches.
|
||||
for (const Post& po : content.posts) {
|
||||
if (!po.HasPage()) continue;
|
||||
std::print(" <url><loc>https://catcrafts.net/posts/{}</loc></url>\n",
|
||||
Html::Escape(po.slug).Str());
|
||||
}
|
||||
std::print("</urlset>\n");
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -877,7 +1276,7 @@ int main(int argc, char** argv) {
|
|||
"/order/not-a-token",
|
||||
"/legal/privacy", "/legal/imprint",
|
||||
"/legal/terms", "/legal/nope",
|
||||
"/projects", "/posts", "/demos",
|
||||
"/projects", "/posts", "/posts/nope", "/demos",
|
||||
"/demos/raytracer", "/demos/nope", "/demo",
|
||||
"/projects/", "/blog", "/blog/hello-world", "/nope" }) {
|
||||
const Route r = ParseRoute(p);
|
||||
|
|
|
|||
Loading…
Reference in a new issue