video seo
All checks were successful
Deploy / build-deploy (push) Successful in 2m33s

This commit is contained in:
Jorijn van der Graaf 2026-08-19 00:02:32 +02:00
commit a8bc400371
3 changed files with 128 additions and 7 deletions

View file

@ -446,6 +446,57 @@ export RenderedPage RenderPosts(std::span<const Post> posts) {
return page;
}
// The post's video, as a schema.org VideoObject hung off the BlogPosting.
//
// Google already indexed and carouselled these videos on the strength of the
// bare <video> element; this makes the placement deliberate rather than lucky,
// and a typed thumbnailUrl is what earns the still on the text result too.
//
// thumbnailUrl and uploadDate are what the video rich result requires, so a
// video whose mirror produced no poster is skipped rather than emitted
// incomplete — the same both-or-neither rule Dimensions() applies to width and
// height. Only our own mirrored paths qualify, for the reason ogImage below
// gives: an absolute src or a third-party poster is the off-site fetch the
// mirror exists to avoid, just performed by a crawler.
//
// Headline media only. That IS the post — these posts are their recording —
// and it is the one video a search result should be pointing at.
//
// No encodingFormat and no duration: neither is required, the mirror records no
// duration, and naming a container guessed from a file extension would assert
// something we do not know. A wrong type is worse than a missing one, for the
// same reason MimeFor() refuses to guess.
std::string PostVideoLd(const Post& p) {
std::vector<std::string> videos;
for (const PostMedia& m : p.media) {
if (m.kind != "video") continue;
if (!m.src.starts_with("/") || !m.poster.starts_with("/")) continue;
std::string node = std::format(
R"({{"@type":"VideoObject","name":{},"uploadDate":{},)"
R"("thumbnailUrl":{},"contentUrl":{})",
JsonStr(p.title), JsonStr(p.published),
JsonStr("https://catcrafts.net" + m.poster),
JsonStr("https://catcrafts.net" + m.src));
if (!p.excerpt.empty()) node += ",\"description\":" + JsonStr(p.excerpt);
if (m.width > 0 && m.height > 0) {
node += std::format(R"(,"width":{},"height":{})", m.width, m.height);
}
node += "}";
videos.push_back(std::move(node));
}
if (videos.empty()) return {};
if (videos.size() == 1) return ",\"video\":" + videos.front();
std::string out = ",\"video\":[";
for (std::size_t i = 0; i < videos.size(); ++i) {
if (i) out += ',';
out += videos[i];
}
out += ']';
return out;
}
// ── one post, in full ─────────────────────────────────────────────────
// The whole point of hosting the body: a page a search engine can index, a
@ -494,14 +545,15 @@ export RenderedPage RenderPost(const Post& p) {
R"("@type":"Person","name":"Jorijn van der Graaf"}},)"
R"("publisher":{{"@id":"https://catcrafts.net/#organization",)"
R"("@type":"Organization","name":"Catcrafts"}},)"
R"("discussionUrl":{}{}{}}})",
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));
: ",\"image\":" + JsonStr("https://catcrafts.net" + ogImage),
PostVideoLd(p));
page.main = Format(
R"(<article class="post">)"