All checks were successful
Deploy / build-deploy (push) Successful in 8m16s
1653 lines
82 KiB
C++
1653 lines
82 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.
|
||
*/
|
||
|
||
// The page renderers — the reason Catcrafts.Shared exists.
|
||
//
|
||
// Each returns SafeHtml for the inner content of <main>, plus the PageMeta the
|
||
// document head needs. The wasm app feeds the markup to SetInnerHTML; the
|
||
// native server will wrap it in RenderDocument and send it as the response
|
||
// body. One template per page, so a crawler and the app can never be looking
|
||
// at different markup.
|
||
//
|
||
// Every renderer is a pure function of its inputs. No I/O, no globals, no DOM.
|
||
// That is what makes them testable on the host (see server/implementations)
|
||
// rather than only observable in a browser tab.
|
||
|
||
export module Catcrafts.Shared:Views;
|
||
import std;
|
||
import :Content;
|
||
import :Html;
|
||
import :Form;
|
||
import :Model;
|
||
import :Money;
|
||
import :Route;
|
||
|
||
namespace Catcrafts::Views {
|
||
|
||
using Html::SafeHtml;
|
||
using Html::Escape;
|
||
using Html::Autolink;
|
||
using Html::Format;
|
||
using Html::Num;
|
||
using Html::Raw;
|
||
using Html::Url;
|
||
using Html::Attr;
|
||
using Html::Join;
|
||
|
||
export struct RenderedPage {
|
||
PageMeta meta;
|
||
SafeHtml main;
|
||
// 404 for NotFound, 301 for a legacy redirect, 200 otherwise. The wasm app
|
||
// ignores this; the server uses it as the response status.
|
||
int status = 200;
|
||
};
|
||
|
||
// ── chrome ────────────────────────────────────────────────────────────
|
||
|
||
// Nav is emitted as real <a href> links, not click-only elements.
|
||
//
|
||
// The previous version used `<a id="blog-nav-button">` with no href, because
|
||
// Crafter.Graphics could not cancel a click and any real link would trigger a
|
||
// full page load. That made the nav invisible to crawlers, un-middle-clickable
|
||
// and unreachable by keyboard. The preventDefault support added to
|
||
// AddClickListener is what lets these be honest links that the app upgrades to
|
||
// client-side navigation.
|
||
export SafeHtml RenderNav(RouteKind current) {
|
||
std::vector<SafeHtml> items;
|
||
for (const NavItem& item : NavItems()) {
|
||
const bool active = item.kind == current;
|
||
items.push_back(Format(
|
||
R"(<li><a class="nav-link{}"{}{}>{}</a></li>)",
|
||
active ? Raw(" nav-link--active") : SafeHtml{},
|
||
Url("href", item.href),
|
||
// aria-current is how a screen reader conveys "you are here";
|
||
// the active class alone is a purely visual signal.
|
||
active ? Attr("aria-current", "page") : SafeHtml{},
|
||
Escape(item.label)));
|
||
}
|
||
// The mark: an ASCII cat drawn as paths — ^ ^ caret eyes, and the old ">_"
|
||
// prompt kept as the mouth. Inline rather than an <img> so it takes its
|
||
// colours from the CSS variables (face = currentColor via .logo__mark,
|
||
// prompt = --text) and costs no extra request. The underscore carries
|
||
// class="logo-cursor", which styles.css blinks like a real terminal cursor
|
||
// (and stops under prefers-reduced-motion).
|
||
//
|
||
// Same geometry as favicon.svg with hardcoded colours — change one, change
|
||
// both, or the header and the tab icon drift apart.
|
||
//
|
||
// aria-label on the <a> rather than text: on narrow viewports .logo__text
|
||
// is display:none, which would leave the link with no accessible name.
|
||
return Format(
|
||
R"(<div class="nav-container">)"
|
||
R"(<a class="logo" aria-label="Catcrafts"{}>)"
|
||
R"(<span class="logo__mark" aria-hidden="true">)"
|
||
R"(<svg viewBox="0 0 64 64" fill="none">)"
|
||
R"(<path d="M9 26 L13 4 L23 15 Q32 11 41 15 L51 4 L55 26 L54 38 Q52 56 32 56 Q12 56 10 38 Z" stroke="currentColor" stroke-width="5" stroke-linejoin="round"/>)"
|
||
R"(<path d="M10.5 40 L4.5 42 M11.5 46 L6 49 M53.5 40 L59.5 42 M52.5 46 L58 49" stroke="currentColor" stroke-width="3.5" stroke-linecap="round"/>)"
|
||
R"(<path d="M17 32 L23 25 L29 32 M35 32 L41 25 L47 32" stroke="currentColor" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>)"
|
||
// R"x(...)x": stroke="var(--text)" contains the plain-delimiter
|
||
// terminator )" and would end the literal mid-attribute.
|
||
R"x(<path d="M23 38 L29 43 L23 48" stroke="var(--text)" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>)x"
|
||
R"x(<path class="logo-cursor" d="M34 48 L42 48" stroke="var(--text)" stroke-width="5" stroke-linecap="round"/>)x"
|
||
R"(</svg>)"
|
||
R"(</span>)"
|
||
R"(<span class="logo__text">Catcrafts</span></a>)"
|
||
R"(<nav aria-label="Main"><ul class="nav-list">{}</ul></nav>)"
|
||
R"(</div>)",
|
||
Url("href", "/"),
|
||
Join(items));
|
||
}
|
||
|
||
// Deliberately no link to the fediverse account.
|
||
//
|
||
// The account is used for plenty that has nothing to do with this work, so
|
||
// pointing visitors at the profile would send them somewhere mostly irrelevant.
|
||
// The posts page carries the individual posts instead, each linking to its own
|
||
// thread — which is the part that is actually about the work.
|
||
export SafeHtml RenderFooter() {
|
||
return Format(
|
||
R"(<div class="footer-content">)"
|
||
R"(<p class="footer-tagline">Open-source systems work from the Netherlands. )"
|
||
R"(This site is C++ compiled to WebAssembly.</p>)"
|
||
R"(<p class="footer-links">)"
|
||
R"(<a{}>Forgejo</a><a{}>Source</a>)"
|
||
R"(</p>)"
|
||
R"(<p class="footer-links">)"
|
||
R"(<a{}>Privacy</a><a{}>Terms</a><a{}>Imprint & contact</a>)"
|
||
R"(</p>)"
|
||
R"(<p class="footer-legal">© 2026 Catcrafts®. Crafter® and Catcrafts® )"
|
||
R"(are registered trademarks with the EUIPO.</p>)"
|
||
R"(</div>)",
|
||
Url("href", "https://forgejo.catcrafts.net/Catcrafts/"),
|
||
Url("href", "https://forgejo.catcrafts.net/Catcrafts/catcrafts.net"),
|
||
Url("href", "/legal/privacy"),
|
||
Url("href", "/legal/terms"),
|
||
Url("href", "/legal/imprint"));
|
||
}
|
||
|
||
// ── schema.org JSON-LD ────────────────────────────────────────────────
|
||
//
|
||
// Machine-readable facts for crawlers and AI answer engines. This exists
|
||
// because two name-twins (a Minecraft server on the singular domain, a cat
|
||
// conservation program) kept absorbing the brand in search results and AI
|
||
// overviews — one overview flatly asserted that nobody named Catcrafts sells
|
||
// Fairphone hardware. Prose can be paraphrased away; typed identity and
|
||
// offer data are what those systems quote.
|
||
|
||
// JSON string escaping. Its own function rather than the HTML escaper
|
||
// because the rules are different (backslash and quote, not ampersand).
|
||
std::string JsonStr(std::string_view s) {
|
||
std::string out;
|
||
out.reserve(s.size() + 2);
|
||
out += '"';
|
||
for (char c : s) {
|
||
switch (c) {
|
||
case '"': out += "\\\""; break;
|
||
case '\\': out += "\\\\"; break;
|
||
case '\n': out += "\\n"; break;
|
||
case '\r': out += "\\r"; break;
|
||
case '\t': out += "\\t"; break;
|
||
// '<' so "</script>" can never appear inside the ld+json block:
|
||
// the HTML parser ends a <script> at that byte sequence wherever
|
||
// it sits, string literal or not. < is the same character to
|
||
// every JSON consumer and invisible to the HTML parser.
|
||
case '<': out += "\\u003c"; break;
|
||
default:
|
||
if (static_cast<unsigned char>(c) < 0x20) {
|
||
out += std::format("\\u{:04x}", static_cast<unsigned char>(c));
|
||
} else {
|
||
out += c;
|
||
}
|
||
}
|
||
}
|
||
out += '"';
|
||
return out;
|
||
}
|
||
|
||
// ── home ──────────────────────────────────────────────────────────────
|
||
|
||
export RenderedPage RenderHome(std::span<const Project> projects,
|
||
std::span<const Post> posts) {
|
||
std::vector<SafeHtml> cards;
|
||
for (const Project& p : projects) {
|
||
if (!p.featured) continue;
|
||
cards.push_back(Format(
|
||
R"(<article class="mini-card">)"
|
||
R"(<h3 class="mini-card__title"><a{}>{}</a></h3>)"
|
||
R"(<p class="mini-card__blurb">{}</p>)"
|
||
R"(</article>)",
|
||
Url("href", p.url), Escape(p.name), Escape(p.blurb)));
|
||
}
|
||
|
||
std::vector<SafeHtml> recent;
|
||
for (std::size_t i = 0; i < posts.size() && i < 3; ++i) {
|
||
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),
|
||
Escape(post.title),
|
||
Escape(DateOnly(post.published))));
|
||
}
|
||
|
||
SafeHtml main = Format(
|
||
R"(<section class="hero">)"
|
||
// No slogan on purpose: a hero headline reads as marketing, which is
|
||
// not the register this site wants. Instead the mission sentence IS
|
||
// the h1 — full weight in the document outline and for crawlers —
|
||
// styled as body prose (see h1.hero__lede) so nothing on screen
|
||
// changes register. The old version hid a one-word h1 offscreen,
|
||
// which spent the strongest heading on the page saying "Catcrafts"
|
||
// to nobody.
|
||
R"(<h1 class="hero__lede">Catcrafts makes accessible, open-source software )"
|
||
R"(and hardware for the benefit of everyone.</h1>)"
|
||
R"(<p class="hero__lede">The goal: a real alternative )"
|
||
R"(to big tech that anyone can use, not just Linux nerds.</p>)"
|
||
R"(<p class="hero__actions">)"
|
||
R"(<a class="btn btn--primary"{}>Browse projects</a>)"
|
||
R"(<a class="btn"{}>Browse shop</a>)"
|
||
R"(</p>)"
|
||
R"(</section>)"
|
||
R"(<section class="section">)"
|
||
R"(<h2 class="section__title">Featured work</h2>)"
|
||
R"(<div class="mini-grid">{}</div>)"
|
||
R"(</section>)"
|
||
R"({})",
|
||
Url("href", "/projects"),
|
||
Url("href", "/shop"),
|
||
cards.empty() ? Raw(R"(<p class="empty">No featured projects yet.</p>)") : Join(cards),
|
||
recent.empty() ? SafeHtml{} : Format(
|
||
R"(<section class="section">)"
|
||
R"(<h2 class="section__title">Recent posts</h2>)"
|
||
R"(<ul class="recent">{}</ul>)"
|
||
R"(<p><a class="link-more"{}>All posts</a></p>)"
|
||
R"(</section>)",
|
||
Join(recent), Url("href", "/posts")));
|
||
|
||
RenderedPage page;
|
||
page.meta.title = "Catcrafts — open-source software and hardware";
|
||
// Deliberately the hero text, word for word: the mission sentence is the
|
||
// site's best self-description, and the search snippet, the og: link
|
||
// card and the page itself should all say the same thing. If the hero
|
||
// below changes, change this with it.
|
||
page.meta.description =
|
||
"Catcrafts makes accessible, open-source software and hardware for "
|
||
"the benefit of everyone. The goal: a real alternative to big tech "
|
||
"that anyone can use, not just Linux nerds.";
|
||
page.meta.canonical = "/";
|
||
// The identity record: name, registered identity and the Forgejo profile
|
||
// separate this Catcrafts from the name-twins by type and by registration,
|
||
// not just by prose. All literal — nothing user-supplied.
|
||
//
|
||
// Two nodes in a @graph rather than one bare Organization, joined by @id:
|
||
//
|
||
// #organization — who the company is. Referenced by @id from the other
|
||
// pages (about's worksFor, the shop's seller) so every page describes
|
||
// ONE entity instead of three same-named copies that a consumer has
|
||
// to guess are the same. That guess is exactly what goes wrong with a
|
||
// name this generic.
|
||
// #website — what this domain is. The navigational query "catcrafts" is
|
||
// answered from the site entity, not the company entity, and it is
|
||
// where name/alternateName are read for it. Without this node there
|
||
// is nothing here typed as the thing being searched for.
|
||
//
|
||
// alternateName carries the spellings people actually type. The one
|
||
// spelling deliberately absent is "Cat Crafts" with a space: that is the
|
||
// generic craft phrase owned by Etsy, Pinterest and a decade of kids'
|
||
// craft blogs, and claiming it would argue for merging this entity into
|
||
// the corpus it needs to stay distinct from.
|
||
//
|
||
// identifier restates KVK/VAT/EORI as typed PropertyValues. vatID stays
|
||
// as well — it is the property Google documents — but the registry
|
||
// numbers are the part no name-twin can produce, and a Minecraft server
|
||
// and a cat charity cannot: they are checkable against a public register.
|
||
page.meta.jsonLd =
|
||
R"({"@context":"https://schema.org","@graph":[)"
|
||
R"({"@type":"Organization","@id":"https://catcrafts.net/#organization",)"
|
||
R"("name":"Catcrafts","alternateName":["Catcrafts.net","CatCrafts"],)"
|
||
R"("url":"https://catcrafts.net/",)"
|
||
R"("logo":"https://catcrafts.net/favicon.svg",)"
|
||
R"("email":"info@catcrafts.net","vatID":"NL003329281B38",)"
|
||
R"("identifier":[)"
|
||
R"({"@type":"PropertyValue","propertyID":"KVK","value":"78437059"},)"
|
||
R"({"@type":"PropertyValue","propertyID":"VAT","value":"NL003329281B38"},)"
|
||
R"({"@type":"PropertyValue","propertyID":"EORI","value":"NL1900095326"}],)"
|
||
// The founder is a reference, exactly like the org: the Person node's
|
||
// full definition lives on /about under this @id, so both pages talk
|
||
// about ONE person — the same join, for the same reason.
|
||
R"("founder":{"@type":"Person","@id":"https://catcrafts.net/about#person",)"
|
||
R"("name":"Jorijn van der Graaf","url":"https://catcrafts.net/about"},)"
|
||
R"("description":"Catcrafts makes accessible, open-source software and hardware: )"
|
||
R"(the Crafter C++ suite, the imsd IMS/VoLTE implementation, and Fairphone 6 )"
|
||
R"(handsets sold with postmarketOS preinstalled.",)"
|
||
R"("sameAs":["https://forgejo.catcrafts.net/Catcrafts/"]},)"
|
||
R"({"@type":"WebSite","@id":"https://catcrafts.net/#website",)"
|
||
R"("name":"Catcrafts","alternateName":["Catcrafts.net","CatCrafts"],)"
|
||
R"("url":"https://catcrafts.net/","inLanguage":"en",)"
|
||
R"("publisher":{"@id":"https://catcrafts.net/#organization"}}]})";
|
||
page.main = std::move(main);
|
||
return page;
|
||
}
|
||
|
||
// ── projects ──────────────────────────────────────────────────────────
|
||
|
||
// A high-level overview on purpose: name, one sentence, a link. Each project's
|
||
// Forgejo page is the canonical source for status, docs and detail — repeating
|
||
// any of that here just gives it a second place to go stale.
|
||
export RenderedPage RenderProjects(std::span<const Project> projects) {
|
||
std::vector<SafeHtml> cards;
|
||
for (const Project& p : projects) {
|
||
cards.push_back(Format(
|
||
R"(<article class="project-card">)"
|
||
R"(<h2 class="project-card__title"><a{}>{}</a></h2>)"
|
||
R"(<p class="project-card__blurb">{}</p>)"
|
||
R"(<span class="project-card__lang">{}</span>)"
|
||
R"(</article>)",
|
||
Url("href", p.url),
|
||
Escape(p.name),
|
||
Escape(p.blurb),
|
||
Escape(p.language)));
|
||
}
|
||
|
||
RenderedPage page;
|
||
page.meta.title = "Projects — Catcrafts";
|
||
page.meta.description =
|
||
"The Crafter suite: a C++ build system, graphics engine, networking "
|
||
"stack, and an IMS/VoLTE implementation for the Fairphone 6.";
|
||
page.meta.canonical = "/projects";
|
||
page.main = Format(
|
||
R"(<header class="page-header">)"
|
||
R"(<h1 class="page-header__title">Projects</h1>)"
|
||
R"(<p class="page-header__lede">The short version of each. )"
|
||
R"(Docs, status and source live on <a{}>Forgejo</a>; )"
|
||
R"(every card links to its repository.</p>)"
|
||
R"(</header>)"
|
||
R"(<div class="project-grid">{}</div>)",
|
||
Url("href", "https://forgejo.catcrafts.net/Catcrafts/"),
|
||
cards.empty() ? Raw(R"(<p class="empty">Project list unavailable.</p>)") : Join(cards));
|
||
return page;
|
||
}
|
||
|
||
// ── posts ─────────────────────────────────────────────────────────────
|
||
|
||
// The media a post carries — usually the point of the post rather than
|
||
// decoration, since these are screen recordings of the work.
|
||
//
|
||
// Served from our own origin (tools/fetch-media.sh mirrors it), which is what
|
||
// 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.
|
||
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));
|
||
}
|
||
|
||
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));
|
||
|
||
cards.push_back(Format(
|
||
R"(<article class="post-card">)"
|
||
R"(<header class="post-card__header">)"
|
||
R"(<h2 class="post-card__title"><a{} rel="noopener">{}</a></h2>)"
|
||
R"(<p class="post-card__meta">)"
|
||
R"(<time{}>{}</time><span class="post-card__community">{}</span>)"
|
||
R"(</p>)"
|
||
R"(</header>)"
|
||
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"(</article>)",
|
||
Url("href", p.permalink), 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)),
|
||
RenderPostMedia(p.media),
|
||
linkRow,
|
||
Num(p.score),
|
||
Url("href", p.permalink), Num(p.comments)));
|
||
}
|
||
|
||
RenderedPage page;
|
||
page.meta.title = "Posts — Catcrafts";
|
||
page.meta.description = "Posts from the fediverse; discussion happens there.";
|
||
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"(</header>)"
|
||
R"(<div class="post-list">{}</div>)",
|
||
cards.empty()
|
||
? Raw(R"(<p class="empty">No posts loaded right now.</p>)")
|
||
: Join(cards));
|
||
return page;
|
||
}
|
||
|
||
// ── shop ──────────────────────────────────────────────────────────────
|
||
|
||
// The product page's price block: the same single headline number as the shop
|
||
// card (so a Canadian sees ~CA$776 at the top here too), plus one supporting
|
||
// line per region. Both lines are always in the markup — which one shows is
|
||
// CSS driven by the cc-eu/cc-noneu classes; without JS both show, which is
|
||
// complete and honest. The HTML stays identical for every visitor.
|
||
//
|
||
// "21% VAT", not "21% EU VAT": VAT rates are per member state, and this is
|
||
// the Dutch rate charged under the OSS distance-selling threshold. The terms
|
||
// page spells out "Dutch VAT"; the price label stays plain.
|
||
SafeHtml PriceSingle(const Product& p, const Rates& rates);
|
||
|
||
SafeHtml RenderPriceLine(const Product& p, const Rates& rates) {
|
||
const std::int64_t net = Money::NetFromGross(p.priceInclMinor);
|
||
return Format(
|
||
R"(<p class="price price--product">)"
|
||
R"({}{})"
|
||
R"(</p>)",
|
||
p.variants.size() > 1
|
||
? Raw(R"(<span class="price__from">from</span> )") : SafeHtml{},
|
||
PriceSingle(p, rates),
|
||
Escape(Money::FormatEuro(net)));
|
||
}
|
||
|
||
// The shop card's price: ONE easy number. The default text is the euro price
|
||
// (what no-JS visitors and crawlers read); every supported currency rides
|
||
// along as a pre-formatted data attribute, and the timezone hint script swaps
|
||
// the text to the visitor's own. "~" marks a converted amount as approximate.
|
||
//
|
||
// The conversion basis differs by membership, not by currency whim: an EU
|
||
// member's currency (SEK, PLN, …) converts the VAT-inclusive price the buyer
|
||
// will actually pay; a non-EU currency converts the ex-VAT export price. All
|
||
// arithmetic happens HERE, server-side, from the same integers the checkout
|
||
// charges — the script only ever picks a string.
|
||
SafeHtml PriceSingle(const Product& p, const Rates& rates) {
|
||
const std::int64_t net = Money::NetFromGross(p.priceInclMinor);
|
||
std::vector<SafeHtml> attrs;
|
||
attrs.push_back(Attr("data-world", Money::FormatEuro(net)));
|
||
for (const Money::CurrencyRow& row : Money::AllCurrencies()) {
|
||
const std::int64_t rate = rates.Find(row.cur.code);
|
||
if (rate <= 0) continue;
|
||
const std::int64_t base = Money::IsEuCountry(row.cc) ? p.priceInclMinor : net;
|
||
std::string name = "data-";
|
||
for (const char c : row.cur.code) name += static_cast<char>(c - 'A' + 'a');
|
||
attrs.push_back(Attr(name, std::format(
|
||
"~{}{}", row.cur.symbol, Money::ConvertIndicative(base, rate))));
|
||
}
|
||
return Format(
|
||
R"(<span class="price__single"{}>{}</span>)",
|
||
Join(attrs), Escape(Money::FormatEuro(p.priceInclMinor)));
|
||
}
|
||
|
||
SafeHtml RenderCardPrice(const Product& p, const Rates& rates) {
|
||
// With colour variants the headline is the cheapest ("from") price; the
|
||
// exact figure per colour lives in the selector and the live total.
|
||
return Format(R"(<p class="price price--card">{}{}</p>)",
|
||
p.variants.size() > 1
|
||
? Raw(R"(<span class="price__from">from</span> )") : SafeHtml{},
|
||
PriceSingle(p, rates));
|
||
}
|
||
|
||
export RenderedPage RenderShop(std::span<const Product> products, const Rates& rates) {
|
||
std::vector<SafeHtml> cards;
|
||
for (const Product& p : products) {
|
||
// width/height matching the committed image keep the card from
|
||
// reflowing as the photo arrives.
|
||
SafeHtml thumb = p.image.empty() ? SafeHtml{} : Format(
|
||
R"(<a class="product-card__media"{} tabindex="-1" aria-hidden="true">)"
|
||
R"(<img class="product-card__img" loading="lazy" decoding="async" alt=""{}></a>)",
|
||
Url("href", "/shop/" + p.slug), Url("src", p.image));
|
||
cards.push_back(Format(
|
||
R"(<article class="product-card">)"
|
||
R"({})"
|
||
R"(<div class="product-card__body">)"
|
||
R"(<h2 class="product-card__title"><a{}>{}</a></h2>)"
|
||
R"(<p class="product-card__tagline">{}</p>)"
|
||
R"({})"
|
||
R"(</div>)"
|
||
R"(</article>)",
|
||
thumb,
|
||
Url("href", "/shop/" + p.slug), Escape(p.name), Escape(p.tagline),
|
||
p.Buyable() ? RenderCardPrice(p, rates)
|
||
: p.ComingSoon()
|
||
? Format(R"({}<p class="product-card__status"><span class="badge badge--experiment">coming soon</span></p>)",
|
||
RenderCardPrice(p, rates))
|
||
: Raw(R"(<p class="product-card__status"><span class="badge badge--experiment">temporarily unavailable</span></p>)")));
|
||
}
|
||
|
||
RenderedPage page;
|
||
page.meta.title = "Shop — Catcrafts";
|
||
page.meta.description =
|
||
"Fairphone 6 handsets reflashed to run postmarketOS, with the IMS/VoLTE "
|
||
"stack Catcrafts maintains.";
|
||
page.meta.canonical = "/shop";
|
||
page.meta.geoPriceHint = true;
|
||
// The category page as typed data: an ItemList naming each product page.
|
||
// This is the shape crawlers read a listing page in, and it makes every
|
||
// product URL discoverable from markup instead of from anchor-parsing.
|
||
// Deliberately just position/name/url — the prices and offers live in
|
||
// each product page's own record, which is their single source of truth.
|
||
{
|
||
std::string items;
|
||
for (std::size_t i = 0; i < products.size(); ++i) {
|
||
if (!items.empty()) items += ',';
|
||
items += std::format(
|
||
R"({{"@type":"ListItem","position":{},"name":{},"url":{}}})",
|
||
i + 1, JsonStr(products[i].name),
|
||
JsonStr("https://catcrafts.net/shop/" + products[i].slug));
|
||
}
|
||
if (!items.empty()) {
|
||
page.meta.jsonLd = std::format(
|
||
R"({{"@context":"https://schema.org","@type":"ItemList",)"
|
||
R"("itemListElement":[{}]}})",
|
||
items);
|
||
}
|
||
}
|
||
page.main = Format(
|
||
R"(<header class="page-header">)"
|
||
R"(<h1 class="page-header__title">Shop</h1>)"
|
||
R"(<p class="page-header__lede">Hardware that runs the software from the )"
|
||
R"(projects page. Assembled to order and flashed. Please allow up to a )"
|
||
R"(week before dispatch. Payment is handled by Mollie.)"
|
||
R"(</header>)"
|
||
R"(<div class="product-grid">{}</div>)",
|
||
cards.empty() ? Raw(R"(<p class="empty">No products listed.</p>)") : Join(cards));
|
||
return page;
|
||
}
|
||
|
||
// The money terms, stated wherever prices invite a decision, not only in the
|
||
// legal pages. Two claims that must be unambiguous: converted prices are
|
||
// indicative and the charge is euros; and import costs are entirely between
|
||
// the buyer, the courier and their government — Catcrafts neither collects
|
||
// nor answers for them. Shared by the live checkout form and the coming-soon
|
||
// panel so the two can never state different terms.
|
||
SafeHtml CustomsNote() {
|
||
return Raw(
|
||
R"(<p class="checkout__customs">Regional prices shown in non euro currency is )"
|
||
R"(indicative only. The final amount charged is in euros, your bank may charge conversion fees. )"
|
||
R"(For delivery outside the EU, the price excludes import duty, )"
|
||
R"(import VAT or tariffs. Those are )"
|
||
R"(charged on arrival and are solely a matter between you, the courier and )"
|
||
R"(your customs authority. Catcrafts does not collect them, cannot )"
|
||
R"(estimate them bindingly, and is not a party to them.</p>)");
|
||
}
|
||
|
||
// The checkout form.
|
||
//
|
||
// A real <form method="post">, not a JavaScript submit handler. It works with
|
||
// the wasm module absent, blocked or still downloading — which matters more here
|
||
// than anywhere else on the site, because a silent failure here loses a sale.
|
||
//
|
||
// What submitting does — and does not — commit the buyer to is stated right
|
||
// above the button: an order is created and a payment link shown; nothing is
|
||
// owed until it is actually paid, and an unpaid order simply lapses.
|
||
//
|
||
// `errors` re-renders the form with the previous values preserved. Losing a
|
||
// filled-in form on a validation error is the fastest way to lose the person.
|
||
// `liveShipping` is the carrier rate table (country -> cents) when the server
|
||
// has one; empty otherwise. It feeds the data-cc blob below so the on-page
|
||
// total preview uses the exact numbers checkout will charge.
|
||
SafeHtml RenderCheckoutForm(const Product& product,
|
||
std::span<const std::pair<std::string, std::int64_t>> liveShipping,
|
||
std::span<const Form::FieldError> errors,
|
||
const Form::Checkout& prev) {
|
||
auto errorFor = [&](std::string_view field) -> SafeHtml {
|
||
for (const Form::FieldError& e : errors) {
|
||
if (e.field == field) {
|
||
return Format(R"(<p class="field__error">{}</p>)", Escape(e.message));
|
||
}
|
||
}
|
||
return SafeHtml{};
|
||
};
|
||
// Errors with no field name are form-level (the honeypot, for instance).
|
||
SafeHtml formError;
|
||
for (const Form::FieldError& e : errors) {
|
||
if (e.field.empty()) {
|
||
formError = Format(R"(<p class="notice notice--error">{}</p>)", Escape(e.message));
|
||
break;
|
||
}
|
||
}
|
||
|
||
// The variant selector. Option labels carry the exact price so the choice
|
||
// is priced before anything is computed. Default = cheapest = the page's
|
||
// advertised "from" price.
|
||
const Variant* selected = product.FindVariant(prev.color);
|
||
if (!selected) selected = product.CheapestVariant();
|
||
std::vector<SafeHtml> colorOpts;
|
||
for (const Variant& v : product.variants) {
|
||
colorOpts.push_back(Format(
|
||
R"(<option{}{}>{} — {}</option>)",
|
||
Attr("value", v.slug),
|
||
(selected && selected->slug == v.slug) ? Raw(" selected") : SafeHtml{},
|
||
Escape(v.label), Escape(Money::FormatEuro(v.priceInclMinor))));
|
||
}
|
||
|
||
// Everything the total preview may show, pre-computed server-side into one
|
||
// JSON attribute: per-colour unit prices, zone rates, the live carrier
|
||
// table. The script multiplies and adds — it invents no number, so the
|
||
// preview and the charge come from the same integers.
|
||
std::string cc = R"({"v":{)";
|
||
for (std::size_t i = 0; i < product.variants.size(); ++i) {
|
||
cc += std::format(R"({}"{}":{})", i ? "," : "",
|
||
product.variants[i].slug, product.variants[i].priceInclMinor);
|
||
}
|
||
cc += std::format(R"(}},"from":{},"s":{{"nl":{},"eu":{},"w":{}}},"c":{{)",
|
||
product.priceInclMinor, product.shipNlMinor,
|
||
product.shipEuMinor, product.shipWorldMinor);
|
||
for (std::size_t i = 0; i < liveShipping.size(); ++i) {
|
||
cc += std::format(R"({}"{}":{})", i ? "," : "",
|
||
liveShipping[i].first, liveShipping[i].second);
|
||
}
|
||
cc += std::format(R"(}},"q":{}}})", Form::kMaxQuantity);
|
||
|
||
return Format(
|
||
R"(<section class="checkout" id="buy">)"
|
||
R"(<h2 class="section__title">Buy one</h2>)"
|
||
R"(<p class="checkout__lede">Submitting creates the order and takes you )"
|
||
R"(straight to the payment page: iDEAL, card, or a plain bank transfer, )"
|
||
R"(handled by Mollie. Nothing is owed until you actually pay; an unpaid order )"
|
||
R"(just lapses. The address is used to ship this order and for the invoice, )"
|
||
R"(and for nothing else.</p>)"
|
||
// No static rate table: real shipping is priced per country from the
|
||
// carrier data and shown live in the total below once a country is
|
||
// entered. A three-zone summary next to exact rates was misinformation.
|
||
R"(<p class="checkout__shipnote">Shipping is priced per country at carrier )"
|
||
R"(rates. Enter your country below and the exact total appears before )"
|
||
R"(you order.</p>)"
|
||
R"({})"
|
||
R"({})"
|
||
R"(<form class="form" method="post"{}{} novalidate>)"
|
||
R"(<input type="hidden" name="product"{}>)"
|
||
R"(<div class="field">)"
|
||
R"(<label for="f-color">Colour</label>)"
|
||
R"(<select id="f-color" name="color">{}</select>)"
|
||
R"({})"
|
||
R"(</div>)"
|
||
R"(<div class="field">)"
|
||
// A free number input, not a dropdown: bulk orders are welcome. The
|
||
// min/max mirror the server's validation bounds; kMaxQuantity is a
|
||
// technical ceiling, not a sales policy.
|
||
R"(<label for="f-qty">Quantity</label>)"
|
||
R"(<input id="f-qty" name="quantity" type="number" inputmode="numeric" )"
|
||
R"(min="1"{}{}>)"
|
||
R"({})"
|
||
R"(</div>)"
|
||
R"(<div class="field">)"
|
||
R"(<label for="f-email">Email <span class="field__req">required</span></label>)"
|
||
R"(<input id="f-email" name="email" type="email" autocomplete="email" required{}>)"
|
||
R"(<p class="field__hint">Order updates go here. No newsletter exists.</p>)"
|
||
R"({})"
|
||
R"(</div>)"
|
||
R"(<div class="field">)"
|
||
R"(<label for="f-name">Recipient name <span class="field__req">required</span></label>)"
|
||
R"(<input id="f-name" name="name" type="text" autocomplete="name" maxlength="120" required{}>)"
|
||
R"({})"
|
||
R"(</div>)"
|
||
R"(<div class="field">)"
|
||
R"(<label for="f-street">Street and number <span class="field__req">required</span></label>)"
|
||
R"(<input id="f-street" name="street" type="text" autocomplete="street-address" maxlength="200" required{}>)"
|
||
R"({})"
|
||
R"(</div>)"
|
||
R"(<div class="field">)"
|
||
R"(<label for="f-postal">Postal code <span class="field__req">required</span></label>)"
|
||
R"(<input id="f-postal" name="postal" type="text" autocomplete="postal-code" maxlength="20" required{}>)"
|
||
R"({})"
|
||
R"(</div>)"
|
||
R"(<div class="field">)"
|
||
R"(<label for="f-city">City <span class="field__req">required</span></label>)"
|
||
R"(<input id="f-city" name="city" type="text" autocomplete="address-level2" maxlength="120" required{}>)"
|
||
R"({})"
|
||
R"(</div>)"
|
||
R"(<div class="field">)"
|
||
R"(<label for="f-country">Country <span class="field__req">required</span></label>)"
|
||
R"(<input id="f-country" name="country" type="text" autocomplete="country" )"
|
||
R"(maxlength="2" placeholder="NL" required{}>)"
|
||
R"(<p class="field__hint">Two-letter code. Decides shipping, and whether the )"
|
||
R"(price includes VAT.</p>)"
|
||
R"({})"
|
||
R"(</div>)"
|
||
// Honeypot: off-screen rather than display:none, because some bots skip
|
||
// hidden inputs. aria-hidden + tabindex keeps it away from screen
|
||
// readers and the keyboard, so no real person can reach it.
|
||
R"(<div class="honeypot" aria-hidden="true">)"
|
||
R"(<label for="f-website">Leave this empty</label>)"
|
||
R"(<input id="f-website" name="website" type="text" tabindex="-1" autocomplete="off">)"
|
||
R"(</div>)"
|
||
// Filled by the script once colour, quantity and country are known; the
|
||
// exact same total appears on the order page, computed by the server.
|
||
// Hidden without JS — the customs note already promises the total is
|
||
// stated on the order page before paying.
|
||
R"(<p class="checkout__total" id="cc-total" hidden></p>)"
|
||
R"(<button class="btn btn--primary" type="submit">Order — continue to payment</button>)"
|
||
R"(</form>)"
|
||
R"(</section>)",
|
||
CustomsNote(),
|
||
formError,
|
||
Url("action", "/shop/" + product.slug + "#buy"),
|
||
Attr("data-cc", cc),
|
||
Attr("value", product.slug),
|
||
Join(colorOpts), errorFor("color"),
|
||
Attr("max", std::to_string(Form::kMaxQuantity)),
|
||
Attr("value", std::to_string(prev.quantity)),
|
||
errorFor("quantity"),
|
||
Attr("value", prev.email), errorFor("email"),
|
||
Attr("value", prev.name), errorFor("name"),
|
||
Attr("value", prev.street), errorFor("street"),
|
||
Attr("value", prev.postal), errorFor("postal"),
|
||
Attr("value", prev.city), errorFor("city"),
|
||
Attr("value", prev.country), errorFor("country"));
|
||
}
|
||
|
||
export RenderedPage RenderProduct(const Product& product,
|
||
const Rates& rates,
|
||
std::span<const std::pair<std::string, std::int64_t>> liveShipping = {},
|
||
std::span<const Form::FieldError> errors = {},
|
||
const Form::Checkout& prev = {}) {
|
||
std::vector<SafeHtml> specRows;
|
||
for (const Spec& s : product.specs) {
|
||
specRows.push_back(Format(R"(<tr><th scope="row">{}</th><td>{}</td></tr>)",
|
||
Escape(s.label), Escape(s.value)));
|
||
}
|
||
|
||
RenderedPage page;
|
||
page.meta.title = product.name + " — Catcrafts";
|
||
page.meta.description = product.tagline;
|
||
page.meta.canonical = "/shop/" + product.slug;
|
||
page.meta.ogType = "product";
|
||
page.meta.ogImage = product.image;
|
||
page.meta.geoPriceHint = true;
|
||
|
||
// The commercial record: a ProductGroup with one variant Product per
|
||
// colour, each carrying its ONE offer, prices from the same integers the
|
||
// checkout charges — this markup can never advertise a number the shop
|
||
// doesn't honour. A group rather than one Product with three priced
|
||
// offers, because multiple offers on a product read as multiple sellers
|
||
// of the same thing, and which price gets quoted is then the consumer's
|
||
// guess; as variants, each colour owns its price. Availability tracks
|
||
// the status field, so launch day flips PreOrder to InStock with no edit
|
||
// here.
|
||
//
|
||
// Merchant-grade: each offer also carries shippingDetails and a return
|
||
// policy, which is what Google Merchant Center's website-crawl feed needs
|
||
// to list the product without a CSV in sight — productGroupID is what it
|
||
// maps to item_group_id. Shipping uses the STATIC zone rates on purpose:
|
||
// the checkout charges live carrier rates, which run at or below the
|
||
// zone fallbacks — a listing may overstate shipping, never understate it.
|
||
{
|
||
const std::string productUrl = "https://catcrafts.net/shop/" + product.slug;
|
||
std::string_view availability =
|
||
product.Buyable() ? "https://schema.org/InStock"
|
||
: product.ComingSoon() ? "https://schema.org/PreOrder"
|
||
: "https://schema.org/OutOfStock";
|
||
|
||
// "NL", then the other 26 EU members, as JSON string lists.
|
||
std::string euList;
|
||
for (std::string_view cc : Money::EuCountries()) {
|
||
if (cc == "NL") continue;
|
||
if (!euList.empty()) euList += ',';
|
||
euList += JsonStr(cc);
|
||
}
|
||
// The world tier can't say "everywhere else" in schema.org, so it
|
||
// names the non-EU destinations the shop actually sees demand from.
|
||
static constexpr std::string_view kWorldSample[] = {
|
||
"US", "CA", "GB", "CH", "NO", "AU", "NZ", "JP",
|
||
};
|
||
std::string worldList;
|
||
for (std::string_view cc : kWorldSample) {
|
||
if (!worldList.empty()) worldList += ',';
|
||
worldList += JsonStr(cc);
|
||
}
|
||
auto shipTier = [](std::string_view rate, const std::string& dests,
|
||
int transitMin, int transitMax) {
|
||
return std::format(
|
||
R"({{"@type":"OfferShippingDetails",)"
|
||
R"("shippingRate":{{"@type":"MonetaryAmount","value":{},"currency":"EUR"}},)"
|
||
R"("shippingDestination":{{"@type":"DefinedRegion","addressCountry":[{}]}},)"
|
||
R"("deliveryTime":{{"@type":"ShippingDeliveryTime",)"
|
||
R"("handlingTime":{{"@type":"QuantitativeValue","minValue":1,"maxValue":7,"unitCode":"DAY"}},)"
|
||
R"("transitTime":{{"@type":"QuantitativeValue","minValue":{},"maxValue":{},"unitCode":"DAY"}}}}}})",
|
||
JsonStr(rate), dests, transitMin, transitMax);
|
||
};
|
||
const std::string shippingDetails = "["
|
||
+ shipTier(Money::FormatMinor(product.shipNlMinor), JsonStr("NL"), 1, 2) + ","
|
||
+ shipTier(Money::FormatMinor(product.shipEuMinor), euList, 2, 5) + ","
|
||
+ shipTier(Money::FormatMinor(product.shipWorldMinor), worldList, 5, 14) + "]";
|
||
|
||
// Returns, matching the terms page: EU consumers get the statutory
|
||
// 14-day withdrawal (return shipping theirs); outside the EU sales
|
||
// are final except defects, which are warranty, not returns.
|
||
std::string euAll;
|
||
for (std::string_view cc : Money::EuCountries()) {
|
||
if (!euAll.empty()) euAll += ',';
|
||
euAll += JsonStr(cc);
|
||
}
|
||
const std::string returnPolicy = std::format(
|
||
R"([{{"@type":"MerchantReturnPolicy","applicableCountry":[{}],)"
|
||
R"("returnPolicyCategory":"https://schema.org/MerchantReturnFiniteReturnWindow",)"
|
||
R"("merchantReturnDays":14,"returnMethod":"https://schema.org/ReturnByMail",)"
|
||
R"("returnFees":"https://schema.org/ReturnFeesCustomerResponsibility"}},)"
|
||
R"({{"@type":"MerchantReturnPolicy","applicableCountry":[{}],)"
|
||
R"("returnPolicyCategory":"https://schema.org/MerchantReturnNotPermitted"}}])",
|
||
euAll, worldList);
|
||
|
||
const std::string offerTail = std::format(
|
||
R"("availability":"{}","itemCondition":"https://schema.org/NewCondition",)"
|
||
// Same @id as the home page's Organization: the seller of these
|
||
// offers is the KVK-registered company, and joining the nodes is
|
||
// what carries that registration onto the offer instead of
|
||
// leaving a bare name a consumer has to resolve by string match.
|
||
R"("url":{},"seller":{{"@id":"https://catcrafts.net/#organization",)"
|
||
R"("@type":"Organization","name":"Catcrafts"}},)"
|
||
R"("shippingDetails":{},"hasMerchantReturnPolicy":{}}})",
|
||
availability, JsonStr(productUrl), shippingDetails, returnPolicy);
|
||
|
||
const std::string brand = product.brand.empty()
|
||
? std::string{}
|
||
: std::format(R"("brand":{{"@type":"Brand","name":{}}},)",
|
||
JsonStr(product.brand));
|
||
// Omitted entirely when there is no photo: "image":"" is not an
|
||
// absent image, it is a broken claim about one.
|
||
const std::string image = product.image.empty()
|
||
? std::string{}
|
||
: std::format(R"("image":{},)",
|
||
JsonStr("https://catcrafts.net" + product.image));
|
||
|
||
if (product.variants.size() > 1) {
|
||
// Shared facts (brand, description, image) sit on the group and
|
||
// are inherited; each variant states only what varies — colour,
|
||
// sku, price — plus the offer terms every colour shares.
|
||
std::string variantNodes;
|
||
for (const Variant& v : product.variants) {
|
||
if (!variantNodes.empty()) variantNodes += ',';
|
||
variantNodes += std::format(
|
||
R"({{"@type":"Product","name":{},"sku":{},"color":{},)"
|
||
R"("offers":{{"@type":"Offer","price":{},"priceCurrency":"EUR",)",
|
||
JsonStr(product.name + " — " + v.label),
|
||
JsonStr(product.slug + "-" + v.slug),
|
||
JsonStr(v.label),
|
||
JsonStr(Money::FormatMinor(v.priceInclMinor)));
|
||
variantNodes += offerTail; // closes the Offer
|
||
variantNodes += '}'; // closes the variant Product
|
||
}
|
||
page.meta.jsonLd = std::format(
|
||
R"({{"@context":"https://schema.org","@type":"ProductGroup",)"
|
||
R"("name":{},{}"description":{},{}"url":{},)"
|
||
R"("productGroupID":{},"variesBy":["https://schema.org/color"],)"
|
||
R"("hasVariant":[{}]}})",
|
||
JsonStr(product.name), brand, JsonStr(product.tagline), image,
|
||
JsonStr(productUrl), JsonStr(product.slug), variantNodes);
|
||
} else {
|
||
// No colour choice, no group: a plain Product with its one offer.
|
||
const std::string sku = product.variants.empty()
|
||
? product.slug
|
||
: product.slug + "-" + product.variants[0].slug;
|
||
std::string offer;
|
||
if (product.priceInclMinor > 0) {
|
||
offer = std::format(
|
||
R"(,"offers":{{"@type":"Offer","price":{},"priceCurrency":"EUR",)",
|
||
JsonStr(Money::FormatMinor(product.priceInclMinor)));
|
||
offer += offerTail;
|
||
}
|
||
page.meta.jsonLd = std::format(
|
||
R"({{"@context":"https://schema.org","@type":"Product",)"
|
||
R"("name":{},{}"description":{},{}"url":{},"sku":{}{}}})",
|
||
JsonStr(product.name), brand, JsonStr(product.tagline), image,
|
||
JsonStr(productUrl), JsonStr(sku), offer);
|
||
}
|
||
}
|
||
|
||
SafeHtml media = product.image.empty() ? SafeHtml{} : Format(
|
||
R"(<img class="product__photo" decoding="async" alt="{}"{}>)",
|
||
Escape(product.name), Url("src", product.image));
|
||
|
||
// The safety note sits between the summary and the spec sheet — above the
|
||
// fold, before any number that might close the sale. A safety caveat as
|
||
// small print at the bottom is the pattern this shop exists to not do.
|
||
SafeHtml safety = product.safetyNote.empty() ? SafeHtml{} : Format(
|
||
R"(<p class="notice notice--warn product__safety">)"
|
||
R"(<strong>Safety warning — emergency calls.</strong> {}</p>)",
|
||
Escape(product.safetyNote));
|
||
|
||
SafeHtml buy;
|
||
if (product.Buyable()) {
|
||
buy = RenderCheckoutForm(product, liveShipping, errors, prev);
|
||
} else if (product.ComingSoon()) {
|
||
// The launch prices are already public, per colour, with the same
|
||
// money terms the live form will carry. Only the form is held back,
|
||
// so opening the shop changes nothing a visitor was already told.
|
||
std::vector<SafeHtml> colorRows;
|
||
for (const Variant& v : product.variants) {
|
||
colorRows.push_back(Format(
|
||
R"(<li>{} — {}</li>)",
|
||
Escape(v.label), Escape(Money::FormatEuro(v.priceInclMinor))));
|
||
}
|
||
buy = Format(
|
||
R"(<section class="checkout" id="buy">)"
|
||
R"(<h2 class="section__title">Buy one</h2>)"
|
||
R"(<p class="notice">Coming soon. Orders are not open yet; these are )"
|
||
R"(the launch prices. Watch the <a{}>posts</a> for the opening.</p>)"
|
||
R"(<ul class="checkout__colors">{}</ul>)"
|
||
R"({})"
|
||
R"(</section>)",
|
||
Url("href", "/posts"), Join(colorRows), CustomsNote());
|
||
} else {
|
||
buy = Raw(R"(<section class="checkout"><h2 class="section__title">Buy one</h2>)"
|
||
R"(<p class="notice">Temporarily unavailable: sourcing or pricing )"
|
||
R"(is in flux. Check back, or watch the posts.</p></section>)");
|
||
}
|
||
|
||
page.main = Format(
|
||
R"(<header class="page-header">)"
|
||
R"(<h1 class="page-header__title">{}</h1>)"
|
||
R"(<p class="page-header__lede">{}</p>)"
|
||
R"(</header>)"
|
||
R"({})"
|
||
R"({})"
|
||
R"(<p class="product__summary">{}</p>)"
|
||
R"({})"
|
||
R"(<section class="section">)"
|
||
R"(<h2 class="section__title">Specifications</h2>)"
|
||
R"(<p class="section__lede">The hardware is a stock Fairphone 6, )"
|
||
R"(unmodified. Fairphone's spec sheet is this product's spec sheet, )"
|
||
R"(and all of it works under postmarketOS. The one caveat is the )"
|
||
R"(emergency-calling warning above.</p>)"
|
||
R"(<table class="spec-table"><tbody>{}</tbody></table>)"
|
||
R"(</section>)"
|
||
R"(<section class="section">)"
|
||
R"(<h2 class="section__title">Warranty</h2>)"
|
||
R"(<p>{}</p>)"
|
||
R"(</section>)"
|
||
R"({})",
|
||
Escape(product.name), Escape(product.tagline),
|
||
media, RenderPriceLine(product, rates), Escape(product.summary),
|
||
safety, Join(specRows),
|
||
Escape(product.warranty),
|
||
buy);
|
||
return page;
|
||
}
|
||
|
||
// ── orders ────────────────────────────────────────────────────────────
|
||
|
||
// One line of an order's money breakdown.
|
||
SafeHtml MoneyRow(std::string_view label, std::int64_t minor) {
|
||
return Format(R"(<tr><th scope="row">{}</th><td class="order__amount">{}</td></tr>)",
|
||
Escape(label), Escape(Money::FormatEuro(minor)));
|
||
}
|
||
|
||
// The order status page. The token in the URL is the whole capability: no
|
||
// account, no login — bookmark the page. Rendered server-side only, because
|
||
// only the server knows the order; the shared fallback below covers the app.
|
||
//
|
||
// `indicative` is the pre-formatted national-currency approximation ("≈ CA$920
|
||
// · ECB rate 2026-08-04"), or empty. It arrives as a string on purpose: the
|
||
// renderer should not know where rates come from.
|
||
export RenderedPage RenderOrderStatus(const OrderView& o, std::string_view indicative) {
|
||
RenderedPage page;
|
||
page.meta.title = (o.status == "paid" ? std::string("Order confirmed ")
|
||
: std::string("Order "))
|
||
+ o.reference + " — Catcrafts";
|
||
// Never indexable and never cached: the URL is a capability and the
|
||
// content is personal.
|
||
page.meta.noindex = true;
|
||
|
||
const bool awaiting = o.status == "awaiting_payment";
|
||
|
||
// No badge when paid: the confirmation notice below carries the state,
|
||
// and two green "paid" pills stacked read as a rendering bug.
|
||
SafeHtml statusLine =
|
||
awaiting ? Raw(R"(<span class="badge badge--experiment">awaiting payment</span>)")
|
||
: o.status == "paid" ? SafeHtml{}
|
||
: o.status == "shipped" ? Raw(R"(<span class="badge badge--active">shipped</span>)")
|
||
: Raw(R"(<span class="badge">cancelled</span>)");
|
||
|
||
// The buyer normally never sees the awaiting state: checkout sends them
|
||
// straight to Mollie, and coming back the server has already confirmed
|
||
// the payment on arrival. Reaching it means they abandoned the payment,
|
||
// so it reads as "resume", not as an alarming limbo.
|
||
SafeHtml payBlock;
|
||
if (awaiting && !o.payUrl.empty()) {
|
||
SafeHtml indicativeLine = indicative.empty() ? SafeHtml{} : Format(
|
||
R"(<p class="order__indicative">{}, indicative only. The charge is )"
|
||
R"(the euro amount above; your bank or card sets the actual conversion )"
|
||
R"(rate.</p>)",
|
||
Escape(indicative));
|
||
payBlock = Format(
|
||
R"(<section class="section">)"
|
||
R"(<h2 class="section__title">Complete your payment</h2>)"
|
||
R"({})"
|
||
R"(<p><a class="btn btn--primary" rel="noreferrer"{}>Resume payment — {}</a></p>)"
|
||
R"(<p class="order__note">The payment page offers iDEAL, cards and a bank )"
|
||
R"(transfer; your order reference is <strong>{}</strong>. If you just paid, )"
|
||
R"(this page confirms it within seconds. A payment left uncompleted simply )"
|
||
R"(lapses the order. Nothing is owed.</p>)"
|
||
R"(</section>)",
|
||
indicativeLine,
|
||
Url("href", o.payUrl), Escape(Money::FormatEuro(o.totalMinor)),
|
||
Escape(o.reference));
|
||
} else if (o.status == "paid") {
|
||
payBlock = Format(
|
||
R"(<section class="section"><h2 class="section__title">What happens now</h2>)"
|
||
R"(<p>The device is ordered, flashed and tested, then shipped. Allow up )"
|
||
R"(to a week before dispatch. Updates land in your email.</p>)"
|
||
R"(<p><a class="btn btn--primary"{} download>Download invoice (.md)</a></p>)"
|
||
R"(<p class="order__note">GPG-clearsigned markdown. It verifies with )"
|
||
R"(gpg --verify, independent of this site.</p>)"
|
||
R"(</section>)",
|
||
Url("href", "/order/" + o.token + "/invoice.md"));
|
||
}
|
||
|
||
// The paid state IS the success page — say so before the receipt table.
|
||
const SafeHtml confirmation = o.status == "paid"
|
||
? Raw(R"(<p class="notice notice--ok">Payment received. Your order is )"
|
||
R"(confirmed. This page is your receipt.</p>)")
|
||
: SafeHtml{};
|
||
|
||
page.main = Format(
|
||
R"(<header class="page-header">)"
|
||
R"(<h1 class="page-header__title">Order {}</h1>)"
|
||
R"(<p class="page-header__lede">{} · placed {}</p>)"
|
||
R"(</header>)"
|
||
R"(<p>{}</p>)"
|
||
R"({})"
|
||
R"(<section class="section">)"
|
||
R"(<h2 class="section__title">Total</h2>)"
|
||
R"(<table class="spec-table"><tbody>)"
|
||
R"({})"
|
||
R"({})"
|
||
R"({})"
|
||
R"(</tbody></table>)"
|
||
R"(<p class="order__vat">{}</p>)"
|
||
R"(</section>)"
|
||
R"({})"
|
||
R"(<p class="order__keep">There is no account; this link is the access. )"
|
||
R"(Download the invoice and keep it. This page is not archived )"
|
||
R"(forever.</p>)",
|
||
Escape(o.reference),
|
||
Escape(o.colorLabel.empty()
|
||
? o.productName
|
||
: std::format("{} — {}", o.productName, o.colorLabel)),
|
||
Escape(o.createdAt),
|
||
statusLine,
|
||
confirmation,
|
||
MoneyRow(o.quantity > 1
|
||
? std::format("Device × {}", o.quantity)
|
||
: std::string("Device"), o.goodsMinor),
|
||
MoneyRow("Shipping", o.shippingMinor),
|
||
MoneyRow("Total", o.totalMinor),
|
||
o.vatIncluded
|
||
? Raw("Includes 21% Dutch VAT.")
|
||
: Raw("Zero-rated export: no EU VAT charged. Import duty, import "
|
||
"VAT, tariffs and any carrier handling fee are charged on arrival "
|
||
"and are solely between you, the courier and your customs "
|
||
"authority; Catcrafts does not collect them and is not a party "
|
||
"to them."),
|
||
payBlock);
|
||
// A plain <meta refresh> is the no-JavaScript way to make the page track
|
||
// the payment: the browser refetches, the server re-reads the order. Only
|
||
// while awaiting — a paid page has nothing to poll for.
|
||
if (awaiting) page.meta.refreshSeconds = 15;
|
||
return page;
|
||
}
|
||
|
||
// ── legal ─────────────────────────────────────────────────────────────
|
||
|
||
export RenderedPage RenderLegal(const LegalPage& lp) {
|
||
std::vector<SafeHtml> sections;
|
||
for (const LegalSection& sec : lp.sections) {
|
||
std::vector<SafeHtml> paras;
|
||
for (const std::string& para : sec.body) {
|
||
// Autolink, not Escape: the privacy notice points the reader at
|
||
// /analytics and at its own edit history, and a policy that says
|
||
// "go look for yourself" should not make them retype the address.
|
||
paras.push_back(Format(R"(<p>{}</p>)", Autolink(para)));
|
||
}
|
||
sections.push_back(Format(
|
||
R"(<section class="legal__section">)"
|
||
R"(<h2 class="legal__heading">{}</h2>{}</section>)",
|
||
Escape(sec.heading), Join(paras)));
|
||
}
|
||
|
||
RenderedPage page;
|
||
page.meta.title = lp.title + " — Catcrafts";
|
||
page.meta.description = lp.lede;
|
||
page.meta.canonical = "/legal/" + lp.slug;
|
||
page.main = Format(
|
||
R"(<header class="page-header">)"
|
||
R"(<h1 class="page-header__title">{}</h1>)"
|
||
R"(<p class="page-header__lede">{}</p>)"
|
||
R"(<p class="legal__updated">Last updated <time{}>{}</time></p>)"
|
||
R"(</header>)"
|
||
R"(<div class="legal">{}</div>)",
|
||
Escape(lp.title), Escape(lp.lede),
|
||
Attr("datetime", lp.updated), Escape(lp.updated),
|
||
Join(sections));
|
||
return page;
|
||
}
|
||
|
||
// ── about ─────────────────────────────────────────────────────────────
|
||
|
||
// The person behind the company, in the same headed-sections shape (and CSS)
|
||
// as the legal pages. Carries the ProfilePage/Person JSON-LD that formally
|
||
// joins "Jorijn van der Graaf" to this domain — the entity link search and
|
||
// answer engines were previously left to guess at, sometimes wrongly.
|
||
export RenderedPage RenderAbout(const LegalPage& about) {
|
||
std::vector<SafeHtml> sections;
|
||
for (const LegalSection& sec : about.sections) {
|
||
std::vector<SafeHtml> paras;
|
||
for (const std::string& para : sec.body) {
|
||
// Same prose shape as the legal pages, so the same treatment —
|
||
// otherwise a URL added here later silently renders as dead text.
|
||
paras.push_back(Format(R"(<p>{}</p>)", Autolink(para)));
|
||
}
|
||
sections.push_back(Format(
|
||
R"(<section class="legal__section">)"
|
||
R"(<h2 class="legal__heading">{}</h2>{}</section>)",
|
||
Escape(sec.heading), Join(paras)));
|
||
}
|
||
|
||
RenderedPage page;
|
||
page.meta.title = "About — Catcrafts";
|
||
page.meta.description = about.lede;
|
||
page.meta.canonical = "/about";
|
||
// dateModified is real metadata, not decoration: profile-page consumers
|
||
// read it to judge freshness, and the content already tracks the date.
|
||
page.meta.jsonLd = std::format(
|
||
R"({{"@context":"https://schema.org","@type":"ProfilePage","dateModified":{},)"
|
||
R"("mainEntity":{{)"
|
||
// This @id is the Person node's canonical home; the home page's
|
||
// founder property references it rather than redefining the person —
|
||
// the identical join the Organization gets, for the identical reason.
|
||
R"("@type":"Person","@id":"https://catcrafts.net/about#person",)"
|
||
R"("name":"Jorijn van der Graaf","alternateName":"TheMightyCat",)"
|
||
R"("url":"https://catcrafts.net/about",)"
|
||
// A typed Country, not the bare string "NL": nationality expects a
|
||
// Country, and a two-letter string reads as a name, not a code.
|
||
R"("nationality":{{"@type":"Country","name":"Netherlands"}},)"
|
||
// @id, not just name+url: this is the same node the home page defines
|
||
// in full, and saying so is what makes "Jorijn van der Graaf works for
|
||
// Catcrafts" and "Catcrafts is KVK 78437059" one fact about one
|
||
// company rather than two unlinked claims. Name and url stay so the
|
||
// page still stands alone for a consumer that never fetches the home.
|
||
R"("worksFor":{{"@id":"https://catcrafts.net/#organization",)"
|
||
R"("@type":"Organization","name":"Catcrafts","url":"https://catcrafts.net/"}},)"
|
||
// Only personal profiles. The Forgejo /Catcrafts/ org namespace is
|
||
// the COMPANY's profile — the home page's Organization claims it —
|
||
// and sameAs on two different entities asserts they are the same
|
||
// thing, which is the exact confusion this graph exists to prevent.
|
||
// The person's own Forgejo account is listed instead; the company
|
||
// one is reached through worksFor, not by claiming it. The ani.social
|
||
// account is the one the posts page's threads are fetched from —
|
||
// this line is the ONE place the site names it (posts-sources.json
|
||
// explains why the posts page itself never does).
|
||
R"("sameAs":["https://invent.kde.org/themightycat",)"
|
||
R"("https://forgejo.catcrafts.net/jorijnvdgraaf",)"
|
||
R"("https://ani.social/u/TheMightyCat"]}}}})",
|
||
JsonStr(about.updated));
|
||
page.main = Format(
|
||
R"(<header class="page-header">)"
|
||
R"(<h1 class="page-header__title">{}</h1>)"
|
||
R"(<p class="page-header__lede">{}</p>)"
|
||
R"(</header>)"
|
||
R"(<div class="legal">{}</div>)",
|
||
Escape(about.title), Escape(about.lede),
|
||
Join(sections));
|
||
return page;
|
||
}
|
||
|
||
// ── demos ─────────────────────────────────────────────────────────────
|
||
|
||
export RenderedPage RenderDemos(std::span<const Demo> demos) {
|
||
std::vector<SafeHtml> cards;
|
||
for (const Demo& d : demos) {
|
||
cards.push_back(Format(
|
||
R"(<article class="demo-card">)"
|
||
R"(<h2 class="demo-card__title"><a{}>{}</a></h2>)"
|
||
R"(<p class="demo-card__blurb">{}</p>)"
|
||
R"(<p class="demo-card__meta"><span class="demo-card__tech">{}</span></p>)"
|
||
R"(</article>)",
|
||
Url("href", "/demos/" + d.slug), Escape(d.name), Escape(d.blurb),
|
||
Escape(d.tech)));
|
||
}
|
||
|
||
RenderedPage page;
|
||
page.meta.title = "Demos — Catcrafts";
|
||
page.meta.description =
|
||
"Things the Crafter engine can do, running in the browser.";
|
||
page.meta.canonical = "/demos";
|
||
page.main = Format(
|
||
R"(<header class="page-header">)"
|
||
R"(<h1 class="page-header__title">Demos</h1>)"
|
||
R"(<p class="page-header__lede">Things the engine does, running in your browser )"
|
||
R"(rather than in a video of someone else's machine.</p>)"
|
||
R"(</header>)"
|
||
R"(<div class="demo-grid">{}</div>)",
|
||
cards.empty() ? Raw(R"(<p class="empty">No demos listed.</p>)") : Join(cards));
|
||
return page;
|
||
}
|
||
|
||
// A single demo. The mount element is the only thing the renderer needs from
|
||
// the page: Catcrafts:Demo reparents its canvas into it by id.
|
||
export RenderedPage RenderDemo(const Demo& d) {
|
||
// aspect-ratio comes from the content file, so a demo with different
|
||
// proportions does not need a CSS change. Emitted as a style attribute
|
||
// because it is per-demo data, not a reusable rule — and it goes through
|
||
// Attr() so the value cannot break out of the attribute.
|
||
SafeHtml mount = d.mountId.empty() ? SafeHtml{} : Format(
|
||
R"(<div class="demo-stage"{}{}></div>)",
|
||
Attr("id", d.mountId),
|
||
Attr("style", "aspect-ratio: " + d.aspect));
|
||
|
||
RenderedPage page;
|
||
page.meta.title = d.name + " — Catcrafts";
|
||
page.meta.description = d.blurb;
|
||
page.meta.canonical = "/demos/" + d.slug;
|
||
page.main = Format(
|
||
R"(<p class="breadcrumb"><a{}>Demos</a> / {}</p>)"
|
||
R"(<header class="page-header">)"
|
||
R"(<h1 class="page-header__title">{}</h1>)"
|
||
R"(<p class="page-header__lede">{}</p>)"
|
||
R"(</header>)"
|
||
R"(<div class="demo">{}{}</div>)"
|
||
R"(<dl class="demo-facts">)"
|
||
R"(<dt>Built with</dt><dd>{}</dd>)"
|
||
R"(</dl>)",
|
||
Url("href", "/demos"), Escape(d.name),
|
||
Escape(d.name), Escape(d.blurb),
|
||
mount,
|
||
d.needs.empty() ? SafeHtml{} : Format(
|
||
R"(<p class="demo__requires">Needs {}</p>)", Escape(d.needs)),
|
||
Escape(d.tech));
|
||
return page;
|
||
}
|
||
|
||
// ── not found ─────────────────────────────────────────────────────────
|
||
|
||
export RenderedPage RenderNotFound(std::string_view path) {
|
||
RenderedPage page;
|
||
page.meta.title = "Not found — Catcrafts";
|
||
page.meta.noindex = true;
|
||
page.status = 404;
|
||
page.main = Format(
|
||
R"(<header class="page-header">)"
|
||
R"(<h1 class="page-header__title">Not found</h1>)"
|
||
R"(<p class="page-header__lede">There's nothing at <code>{}</code>.</p>)"
|
||
R"(</header>)"
|
||
R"(<p><a class="btn btn--primary"{}>Go home</a></p>)",
|
||
Escape(path), Url("href", "/"));
|
||
return page;
|
||
}
|
||
|
||
// ── dispatch ──────────────────────────────────────────────────────────
|
||
|
||
export struct SiteContent {
|
||
std::vector<Project> projects;
|
||
std::vector<Post> posts;
|
||
std::vector<Product> products;
|
||
std::vector<LegalPage> legal;
|
||
std::vector<Demo> demos;
|
||
// ECB reference rates, for the indicative local-currency prices. Empty is
|
||
// fine — every price then shows in euros.
|
||
Rates rates;
|
||
|
||
const Demo* FindDemo(std::string_view slug) const {
|
||
for (const Demo& d : demos) {
|
||
if (d.slug == slug) return &d;
|
||
}
|
||
return nullptr;
|
||
}
|
||
|
||
const LegalPage* FindLegal(std::string_view slug) const {
|
||
for (const LegalPage& p : legal) {
|
||
if (p.slug == slug) return &p;
|
||
}
|
||
return nullptr;
|
||
}
|
||
|
||
const Product* FindProduct(std::string_view slug) const {
|
||
for (const Product& p : products) {
|
||
if (p.slug == slug) return &p;
|
||
}
|
||
return nullptr;
|
||
}
|
||
};
|
||
|
||
RenderedPage RenderRouteBody(const Route& route, const SiteContent& content);
|
||
|
||
// Single entry point both hosts call. Keeping the switch here rather than in
|
||
// each host is what guarantees a path renders identically on the server and in
|
||
// the app.
|
||
export RenderedPage RenderRoute(const Route& route, const SiteContent& content) {
|
||
// A route carrying a canonical target is a redirect, whatever it renders.
|
||
// Handled once here rather than per-case: /blog and the retired /demo both
|
||
// need it, and the next retired URL should not have to remember to set the
|
||
// status itself. The body is still rendered so a client that ignores the
|
||
// redirect sees the right content.
|
||
if (!route.canonicalRedirect.empty()) {
|
||
RenderedPage page = RenderRouteBody(route, content);
|
||
page.meta.canonical = route.canonicalRedirect;
|
||
page.status = 301;
|
||
return page;
|
||
}
|
||
return RenderRouteBody(route, content);
|
||
}
|
||
|
||
RenderedPage RenderRouteBody(const Route& route, const SiteContent& content) {
|
||
switch (route.kind) {
|
||
case RouteKind::Home: return RenderHome(content.projects, content.posts);
|
||
case RouteKind::About: return RenderAbout(Content::AboutPage());
|
||
case RouteKind::Projects: return RenderProjects(content.projects);
|
||
case RouteKind::Posts: return RenderPosts(content.posts);
|
||
case RouteKind::Demos: return RenderDemos(content.demos);
|
||
case RouteKind::Demo: {
|
||
if (const Demo* d = content.FindDemo(route.slug)) return RenderDemo(*d);
|
||
break;
|
||
}
|
||
case RouteKind::Shop: return RenderShop(content.products, content.rates);
|
||
case RouteKind::Invoice:
|
||
case RouteKind::Order: {
|
||
// Only the server knows order state, and the server intercepts this
|
||
// route before shared dispatch. Reaching this case means the wasm
|
||
// app is rendering with the backend down — say so instead of
|
||
// guessing at a status.
|
||
RenderedPage page;
|
||
page.meta.title = "Order status — Catcrafts";
|
||
page.meta.noindex = true;
|
||
page.meta.refreshSeconds = 30;
|
||
page.main = Raw(
|
||
R"(<header class="page-header">)"
|
||
R"(<h1 class="page-header__title">Order status unavailable</h1>)"
|
||
R"(<p class="page-header__lede">The status service isn't reachable right )"
|
||
R"(now. Nothing is wrong with your order. This page just can't read )"
|
||
R"(it at the moment. It retries automatically; keep the link.</p>)"
|
||
R"(</header>)");
|
||
return page;
|
||
}
|
||
case RouteKind::Legal: {
|
||
if (const LegalPage* lp = content.FindLegal(route.slug)) {
|
||
return RenderLegal(*lp);
|
||
}
|
||
break;
|
||
}
|
||
case RouteKind::Product: {
|
||
// A slug that parsed but names nothing is a 404, not an empty
|
||
// product page — otherwise every typo becomes an indexable URL.
|
||
if (const Product* p = content.FindProduct(route.slug)) {
|
||
return RenderProduct(*p, content.rates);
|
||
}
|
||
break;
|
||
}
|
||
case RouteKind::LegacyBlog:
|
||
return RenderPosts(content.posts);
|
||
case RouteKind::NotFound: break;
|
||
}
|
||
return RenderNotFound(route.path);
|
||
}
|
||
|
||
// ── Atom feed ─────────────────────────────────────────────────────────
|
||
|
||
// Html::Escape's output is valid XML text: & < > " are shared
|
||
// with XML, and it emits an apostrophe as the numeric reference ' rather
|
||
// than the HTML-only '. So no separate XML escaper is needed.
|
||
export std::string RenderAtomFeed(std::span<const Post> posts) {
|
||
// <updated> is the newest entry's timestamp rather than "now", so an
|
||
// unchanged post list produces a byte-identical feed and conditional
|
||
// requests keep working.
|
||
const std::string_view updated =
|
||
posts.empty() ? std::string_view("1970-01-01T00:00:00Z")
|
||
: std::string_view(posts.front().published);
|
||
|
||
std::string out = std::format(
|
||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
|
||
"<feed xmlns=\"http://www.w3.org/2005/Atom\">\n"
|
||
" <title>Catcrafts</title>\n"
|
||
" <subtitle>Posts from the fediverse</subtitle>\n"
|
||
" <link href=\"https://catcrafts.net/feed.xml\" rel=\"self\"/>\n"
|
||
" <link href=\"https://catcrafts.net/posts\"/>\n"
|
||
" <id>https://catcrafts.net/</id>\n"
|
||
" <updated>{}</updated>\n"
|
||
" <author><name>Catcrafts</name></author>\n",
|
||
Escape(updated).Str());
|
||
|
||
for (const Post& p : posts) {
|
||
out += std::format(
|
||
" <entry>\n"
|
||
" <title>{}</title>\n"
|
||
// ap_id is a stable, globally unique federated URL — exactly what
|
||
// an Atom <id> is meant to be.
|
||
" <id>{}</id>\n"
|
||
" <link href=\"{}\"/>\n"
|
||
" <updated>{}</updated>\n"
|
||
" <summary>{}</summary>\n"
|
||
" </entry>\n",
|
||
Escape(p.title).Str(),
|
||
Escape(p.permalink).Str(),
|
||
Escape(p.permalink).Str(),
|
||
Escape(p.published).Str(),
|
||
Escape(p.excerpt).Str());
|
||
}
|
||
out += "</feed>\n";
|
||
return out;
|
||
}
|
||
|
||
// ── full document (server-side rendering) ─────────────────────────────
|
||
|
||
// The timezone price hint — the only script a shop page carries.
|
||
//
|
||
// Why the timezone and not the IP: the question is binary (EU price or export
|
||
// price), the browser's IANA timezone answers it locally with no permission
|
||
// prompt, nothing leaves the device, and unlike an IP lookup it is not fooled
|
||
// by a VPN endpoint. The mapping is "every EU timezone" — anything else,
|
||
// including Europe/London and Europe/Zurich, correctly falls out as non-EU.
|
||
//
|
||
// Both outcomes are tagged (cc-eu / cc-noneu) rather than only one: a
|
||
// CONFIRMED region hides the inapplicable price line outright, which CSS can
|
||
// only do if it can tell "confirmed EU" apart from "nothing ran". Failure
|
||
// mode is the default: no JS, an ancient browser, or an exotic tz leaves the
|
||
// EU-first dual display, which is complete and honest on its own. The class
|
||
// goes on <html> so it all happens before first paint — no flash.
|
||
//
|
||
// UTC is deliberately "unknown", not "non-EU": Firefox's
|
||
// privacy.resistFingerprinting reports UTC for everyone, and privacy-hardened
|
||
// browsers are exactly this shop's clientele. A real person is almost never
|
||
// genuinely in UTC; a fingerprinting-resistant one frequently claims to be.
|
||
// They get the dual display, same as no-JS.
|
||
// Beyond the region classes, the script picks the visitor's display currency
|
||
// from the same timezone (a currency map of the zones each supported currency
|
||
// covers) and, once the DOM exists, swaps every .price__single's text for the
|
||
// matching pre-formatted data attribute. It computes nothing and fetches
|
||
// nothing — the server rendered every string it is allowed to choose from.
|
||
inline constexpr std::string_view kGeoPriceHintScript =
|
||
"<script>"
|
||
"(function(){try{"
|
||
"var z=Intl.DateTimeFormat().resolvedOptions().timeZone;"
|
||
"if(!z||z===\"UTC\"||z.indexOf(\"Etc/\")===0)return;"
|
||
"var eu=[\"Europe/Amsterdam\",\"Europe/Athens\",\"Europe/Berlin\",\"Europe/Bratislava\","
|
||
"\"Europe/Brussels\",\"Europe/Bucharest\",\"Europe/Budapest\",\"Europe/Busingen\","
|
||
"\"Europe/Copenhagen\",\"Europe/Dublin\",\"Europe/Helsinki\",\"Europe/Lisbon\","
|
||
"\"Europe/Ljubljana\",\"Europe/Luxembourg\",\"Europe/Madrid\",\"Europe/Malta\","
|
||
"\"Europe/Mariehamn\",\"Europe/Paris\",\"Europe/Prague\",\"Europe/Riga\","
|
||
"\"Europe/Rome\",\"Europe/Sofia\",\"Europe/Stockholm\",\"Europe/Tallinn\","
|
||
"\"Europe/Vatican\",\"Europe/Vienna\",\"Europe/Vilnius\",\"Europe/Warsaw\","
|
||
"\"Europe/Zagreb\",\"Asia/Nicosia\",\"Asia/Famagusta\",\"Atlantic/Azores\","
|
||
"\"Atlantic/Canary\",\"Atlantic/Madeira\",\"Africa/Ceuta\"];"
|
||
"var isEu=eu.indexOf(z)>-1;"
|
||
"document.documentElement.className+=isEu?\" cc-eu\":\" cc-noneu\";"
|
||
"var cur={"
|
||
"\"Europe/Stockholm\":\"sek\",\"Europe/Copenhagen\":\"dkk\",\"Europe/Warsaw\":\"pln\","
|
||
"\"Europe/Prague\":\"czk\",\"Europe/Budapest\":\"huf\",\"Europe/Bucharest\":\"ron\","
|
||
"\"Europe/Sofia\":\"bgn\","
|
||
"\"Europe/London\":\"gbp\",\"Europe/Zurich\":\"chf\",\"Europe/Oslo\":\"nok\","
|
||
"\"Atlantic/Reykjavik\":\"isk\",\"Asia/Tokyo\":\"jpy\","
|
||
"\"America/Toronto\":\"cad\",\"America/Vancouver\":\"cad\",\"America/Edmonton\":\"cad\","
|
||
"\"America/Winnipeg\":\"cad\",\"America/Halifax\":\"cad\",\"America/St_Johns\":\"cad\","
|
||
"\"America/Regina\":\"cad\",\"America/Moncton\":\"cad\",\"America/Whitehorse\":\"cad\","
|
||
"\"America/Yellowknife\":\"cad\",\"America/Iqaluit\":\"cad\","
|
||
"\"America/New_York\":\"usd\",\"America/Chicago\":\"usd\",\"America/Denver\":\"usd\","
|
||
"\"America/Los_Angeles\":\"usd\",\"America/Phoenix\":\"usd\",\"America/Anchorage\":\"usd\","
|
||
"\"America/Detroit\":\"usd\",\"America/Boise\":\"usd\",\"Pacific/Honolulu\":\"usd\","
|
||
"\"Australia/Sydney\":\"aud\",\"Australia/Melbourne\":\"aud\",\"Australia/Brisbane\":\"aud\","
|
||
"\"Australia/Perth\":\"aud\",\"Australia/Adelaide\":\"aud\",\"Australia/Hobart\":\"aud\","
|
||
"\"Australia/Darwin\":\"aud\",\"Pacific/Auckland\":\"nzd\"};"
|
||
"var c=cur[z];"
|
||
"document.addEventListener(\"DOMContentLoaded\",function(){"
|
||
"var els=document.querySelectorAll(\".price__single\");"
|
||
"for(var i=0;i<els.length;i++){"
|
||
"var v=c?els[i].getAttribute(\"data-\"+c):null;"
|
||
"if(!v&&!isEu)v=els[i].getAttribute(\"data-world\");"
|
||
"if(v)els[i].textContent=v;"
|
||
"}"
|
||
// The live checkout total. Reads only the data-cc blob the server rendered
|
||
// (unit prices per colour, zone rates, live carrier table) and mirrors
|
||
// ComputeTotals exactly: line total, floor((x*10000+6050)/12100) for the
|
||
// export net, shipping by country then zone. Same integers, same formula,
|
||
// so this preview and the charged amount cannot disagree.
|
||
"var f=document.querySelector(\"form[data-cc]\");"
|
||
"if(f){"
|
||
"var d=JSON.parse(f.getAttribute(\"data-cc\"));"
|
||
"var out=document.getElementById(\"cc-total\");"
|
||
"var ecc=[\"AT\",\"BE\",\"BG\",\"HR\",\"CY\",\"CZ\",\"DE\",\"DK\",\"EE\",\"ES\","
|
||
"\"FI\",\"FR\",\"GR\",\"HU\",\"IE\",\"IT\",\"LT\",\"LU\",\"LV\",\"MT\",\"NL\","
|
||
"\"PL\",\"PT\",\"RO\",\"SE\",\"SI\",\"SK\"];"
|
||
"var fmt=function(m){return m%100?\"\\u20ac\"+Math.floor(m/100)+\".\"+(\"0\"+m%100).slice(-2):\"\\u20ac\"+m/100};"
|
||
"var upd=function(){"
|
||
"var ce=f.querySelector(\"[name=color]\"),qe=f.querySelector(\"[name=quantity]\"),ke=f.querySelector(\"[name=country]\");"
|
||
"var unit=ce&&d.v[ce.value]?d.v[ce.value]:d.from;"
|
||
"var qty=qe?parseInt(qe.value,10)||1:1;"
|
||
"var k=(ke&&ke.value?ke.value:\"\").replace(/\\s/g,\"\").toUpperCase();"
|
||
"if(k.length!==2||!unit||qty<1||qty>d.q){if(out)out.hidden=true;return}"
|
||
"var eu=ecc.indexOf(k)>-1,line=unit*qty;"
|
||
"var goods=eu?line:Math.floor((line*10000+6050)/12100);"
|
||
"var ship=(d.c&&d.c[k])||(k===\"NL\"?d.s.nl:eu?d.s.eu:d.s.w);"
|
||
"if(out){out.textContent=\"You pay \"+fmt(goods+ship)+\" \\u2014 \"+fmt(goods)"
|
||
"+(qty>1?\" (\"+qty+\"\\u00d7)\":\"\")+\" + \"+fmt(ship)+\" shipping, \""
|
||
"+(eu?\"incl. VAT\":\"ex VAT\");out.hidden=false}"
|
||
"};"
|
||
"f.addEventListener(\"input\",upd);f.addEventListener(\"change\",upd);upd();"
|
||
"}"
|
||
"});"
|
||
"}catch(e){}})();"
|
||
"</script>\n";
|
||
|
||
// Wraps a rendered page in a complete HTML document.
|
||
//
|
||
// `bootScripts` is the <script> block Crafter.Build generated into the wasm
|
||
// bundle's index.html, passed through verbatim, or empty for a page that
|
||
// should not load the wasm at all. That per-route choice is what lets the
|
||
// shop later ship as plain HTML while /demo keeps the renderer.
|
||
export std::string RenderDocument(const RenderedPage& page,
|
||
const SafeHtml& nav,
|
||
const SafeHtml& footer,
|
||
std::string_view bootScripts,
|
||
std::string_view cssHref) {
|
||
const std::string canonical =
|
||
page.meta.canonical.empty()
|
||
? std::string{}
|
||
: Format(R"(<link rel="canonical"{}>)",
|
||
Url("href", "https://catcrafts.net" + page.meta.canonical)).Str();
|
||
|
||
// Open Graph + JSON-LD. og: tags are what the fediverse (and every chat
|
||
// app) renders as the link-preview card — and the fediverse is where this
|
||
// site's traffic actually comes from, so the cards matter more than
|
||
// usual. The JSON-LD block is inert data (type application/ld+json never
|
||
// executes), so shop pages keep their one-executable-script guarantee.
|
||
std::string headExtras;
|
||
headExtras += std::format("<meta property=\"og:title\"{}>\n",
|
||
Html::Attr("content", page.meta.title).Str());
|
||
if (!page.meta.description.empty()) {
|
||
headExtras += std::format("<meta property=\"og:description\"{}>\n",
|
||
Html::Attr("content", page.meta.description).Str());
|
||
}
|
||
headExtras += std::format("<meta property=\"og:type\"{}>\n",
|
||
Html::Attr("content", page.meta.ogType).Str());
|
||
headExtras += "<meta property=\"og:site_name\" content=\"Catcrafts\">\n";
|
||
if (!page.meta.canonical.empty()) {
|
||
headExtras += std::format("<meta property=\"og:url\"{}>\n",
|
||
Html::Attr("content", "https://catcrafts.net"
|
||
+ page.meta.canonical).Str());
|
||
}
|
||
if (!page.meta.ogImage.empty()) {
|
||
headExtras += std::format("<meta property=\"og:image\"{}>\n",
|
||
Html::Attr("content", "https://catcrafts.net"
|
||
+ page.meta.ogImage).Str());
|
||
headExtras += "<meta name=\"twitter:card\" content=\"summary_large_image\">\n";
|
||
}
|
||
if (!page.meta.jsonLd.empty()) {
|
||
headExtras += "<script type=\"application/ld+json\">"
|
||
+ page.meta.jsonLd + "</script>\n";
|
||
}
|
||
|
||
return std::format(
|
||
"<!doctype html>\n"
|
||
"<html lang=\"en\">\n<head>\n"
|
||
"<meta charset=\"utf-8\">\n"
|
||
// Only on pages that boot the wasm, and load-bearing there.
|
||
//
|
||
// Crafter.Build's runtime.js resolves everything it needs relative to the
|
||
// DOCUMENT url: fetch("variants.json"), fetch("files.json"), every VFS
|
||
// entry, and the .wasm named by variants.json. At "/" that is correct; at
|
||
// /demos/raytracer it asks for /demos/variants.json, gets Caddy's
|
||
// try_files fallback (index.html), and the whole boot collapses — four
|
||
// NS_ERROR_CORRUPTED_CONTENT failures and a blank demo.
|
||
//
|
||
// A <base> fixes all of them at once because a bare relative fetch() in a
|
||
// module resolves against the document base URL. Safe here only because
|
||
// every href/src/action this renderer emits is already absolute, so
|
||
// nothing else changes meaning — there is an e2e check pinning that.
|
||
"{}"
|
||
"<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n"
|
||
// Tells catcrafts-head.js that the server already set the head, so it
|
||
// does not overwrite an SSR'd title with the generic one.
|
||
"<meta name=\"cc-ssr\" content=\"1\">\n"
|
||
"<title>{}</title>\n"
|
||
"{}{}{}{}{}"
|
||
"<link rel=\"alternate\" type=\"application/atom+xml\" title=\"Catcrafts\" href=\"/feed.xml\">\n"
|
||
"<link rel=\"icon\" href=\"/favicon.svg\" type=\"image/svg+xml\">\n"
|
||
"<link rel=\"stylesheet\" href=\"{}\">\n"
|
||
"{}"
|
||
"</head>\n<body>\n"
|
||
"<div id=\"catcrafts-root\">\n"
|
||
"<header id=\"cc-header\">{}</header>\n"
|
||
"<main id=\"main\">{}</main>\n"
|
||
"<footer id=\"cc-footer\">{}</footer>\n"
|
||
"</div>\n"
|
||
"</body>\n</html>\n",
|
||
bootScripts.empty() ? "" : "<base href=\"/\">\n",
|
||
Html::Escape(page.meta.title).Str(),
|
||
page.meta.description.empty() ? std::string{}
|
||
: std::format("<meta name=\"description\"{}>\n",
|
||
Html::Attr("content", page.meta.description).Str()),
|
||
page.meta.noindex ? "<meta name=\"robots\" content=\"noindex, nofollow\">\n" : "",
|
||
page.meta.refreshSeconds > 0
|
||
? std::format("<meta http-equiv=\"refresh\" content=\"{}\">\n",
|
||
page.meta.refreshSeconds)
|
||
: std::string{},
|
||
page.meta.geoPriceHint ? kGeoPriceHintScript : std::string_view{},
|
||
(canonical.empty() ? std::string{} : canonical + "\n") + headExtras,
|
||
cssHref,
|
||
bootScripts,
|
||
nav.Str(),
|
||
page.main.Str(),
|
||
footer.Str());
|
||
}
|
||
|
||
} // namespace Catcrafts::Views
|