rewrite
Some checks failed
Deploy / build-deploy (push) Failing after 4m56s

This commit is contained in:
Jorijn van der Graaf 2026-08-05 04:18:37 +02:00
commit 934c94cb5c
50 changed files with 10464 additions and 758 deletions

View file

@ -0,0 +1,259 @@
/*
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 site's authored content, as code.
//
// This replaced content/{products,projects,legal,demos}.json and their
// runtime loaders. The reasoning: this codebase's whole style is making
// invalid states fail at COMPILE time (SafeHtml, integer money), while the
// JSON loaders did the opposite — a typo'd field silently dropped a variant,
// and one stray trailing comma once blanked every legal page at runtime while
// the server kept answering 200. Content edits go through git push and a full
// CI build regardless (there is no content-only deploy), so the "edit without
// a toolchain" argument bought nothing here. Now a broken product is a
// compile error, the wasm bundle ships four fewer files, and the duplicated
// keep-in-step loaders in main.cpp and the server module are gone.
//
// posts.json and rates.json remain data on purpose: shell pipelines write
// them at build time (fediverse fetch, ECB rates), and shell writes JSON,
// not C++.
//
// PRICING RULE (the user's): retail = supplier price + markup, exactly.
// Supplier prices are what the retailer currently charges (incl VAT);
// change one number when the supplier moves and the margin stays put.
export module Catcrafts.Shared:Content;
import std;
import :Model;
namespace Catcrafts::Content {
// The flat markup on every variant — "whatever it costs me + 50".
inline constexpr std::int64_t kMarkupMinor = 5000;
export const std::vector<Product>& Products() {
static const std::vector<Product> products = [] {
Product p;
p.slug = "fp6-pmos";
p.name = "Fairphone 6 with postmarketOS";
p.tagline = "A repairable Android phone, reflashed to run mainline Linux with a working IMS/VoLTE stack.";
// Launch day is this one line: "coming-soon" -> "available". The page
// shows launch prices either way; only the order form is held back.
p.status = "coming-soon";
p.shipNlMinor = 1500; // zone FALLBACKS — the live
p.shipEuMinor = 2500; // Sendcloud table overrides
p.shipWorldMinor = 5500; // these per country
p.image = "/fp6-pmos.jpg";
p.summary = "A Fairphone 6, reflashed by Catcrafts to run postmarketOS with the patches and IMS/VoLTE implementation Catcrafts maintains. All of that software is open source. You can download it and flash a Fairphone yourself, and you are welcome to. What you pay for here is the thing open source doesn't come with: real support. A phone that arrives working, and one email address that answers for it. Not a forum, not a git issue, but support like any other manufacturer offers. And the margin funds the development itself.";
p.warranty = "Two years from Catcrafts, worldwide, one counter: every claim goes to Catcrafts, whatever turns out to be broken. The software (postmarketOS, the patches, imsd) is Catcrafts' own work and is fixed by Catcrafts with updates, delivered over the air. Even a phone that no longer boots is normally recovered in place: as long as fastboot still comes up, Catcrafts walks you through reflashing it over a USB cable in minutes. Only a phone that shows nothing at all, not even fastboot, travels for a software fault. For a hardware fault Catcrafts takes the phone back and handles the manufacturer's process, including the temporary reflash to stock Android it requires, and returns it running postmarketOS. When a phone does have to travel, warranty shipping is paid by Catcrafts, both directions, worldwide. EU consumers hold their statutory rights on top of all this; nothing here limits them. The full terms are on the terms page.";
// The one claim on this page that is about safety rather than
// features. It stays until emergency calling has been verified on a
// real network — removing it is a decision, not a cleanup.
p.safetyNote = "Emergency calling (112/911) is implemented in imsd, including carrier-broadcast emergency numbers, but not yet verified against a live network.";
p.variants = {
// supplier €513.30 incl VAT
{ "green", "Forest Green", 51330 + kMarkupMinor },
// supplier €519.30 incl VAT
{ "black", "Black", 51930 + kMarkupMinor },
// supplier €604.88 incl VAT
{ "white", "White", 60488 + kMarkupMinor },
};
// The hardware, as Fairphone specifies it — this is a stock Fairphone
// 6, so its spec sheet is this product's spec sheet.
p.specs = {
{ "Display", "6.31″ OLED, 2484 × 1116 (FHD+), up to 120 Hz, Gorilla Glass 7i" },
{ "Processor", "Qualcomm Snapdragon 7s Gen 3, Adreno 810 GPU" },
{ "Memory", "8 GB RAM" },
{ "Storage", "256 GB, microSD slot up to 2 TB" },
{ "Rear cameras", "50 MP main with OIS + 13 MP ultra-wide" },
{ "Front camera", "32 MP" },
{ "Battery", "4415 mAh, user-replaceable, 30 W fast charging" },
{ "Connectivity", "5G, Wi-Fi 6E, Bluetooth 5.4, NFC" },
{ "SIM", "Dual: nano-SIM + eSIM" },
{ "USB", "USB-C 2.0" },
{ "Fingerprint reader", "Side-mounted, in the power button" },
{ "Durability", "IP55, 12 user-replaceable modules" },
{ "Dimensions", "156.5 × 73.3 × 9.6 mm, 193 g" },
};
// Same rule the loader used to apply: the from-price is the
// cheapest variant, derived so the two can never disagree.
if (const Variant* cheapest = p.CheapestVariant()) {
p.priceInclMinor = cheapest->priceInclMinor;
}
return std::vector<Product>{ std::move(p) };
}();
return products;
}
export const std::vector<Project>& Projects() {
static const std::vector<Project> projects = {
{
"imsd",
"IMS/VoLTE for the Fairphone 6's Qualcomm modem: calls on a mainline-Linux phone.",
"https://forgejo.catcrafts.net/Catcrafts/imsd",
"C++23",
true,
},
{
"Crafter.Graphics",
"A C++23 rendering engine: Vulkan with hardware ray tracing natively, WebGPU in the browser.",
"https://forgejo.catcrafts.net/Catcrafts/Crafter.Graphics",
"C++23 modules",
true,
},
{
"Crafter.Build",
"A build system with no DSL: the build description is a C++ file, compiled and run.",
"https://forgejo.catcrafts.net/Catcrafts/Crafter.Build",
"C++23 modules",
true,
},
{
"Crafter.Network",
"A QUIC and HTTP/3 stack; the same client code runs natively and in the browser.",
"https://forgejo.catcrafts.net/Catcrafts/Crafter.Network",
"C++23 modules",
false,
},
{
"catcrafts.net",
"This website, C++23 compiled to WebAssembly, server-rendered from the same renderers.",
"https://forgejo.catcrafts.net/Catcrafts/catcrafts.net",
"C++23 modules",
false,
},
{
"Crafter.Asset",
"Texture and mesh pipeline to GPU-friendly formats, decoded on the GPU where possible.",
"https://forgejo.catcrafts.net/Catcrafts/Crafter.Asset",
"C++23 modules",
false,
},
};
return projects;
}
export const std::vector<Demo>& Demos() {
static const std::vector<Demo> demos = {
{
.slug = "raytracer",
.name = "Real-time ray tracer",
.blurb = "Hardware-accelerated ray tracing through WebGPU, driven by the same C++23 WebAssembly module that renders this page. Four coloured lights, one shadow ray each, Reinhard tonemapping.",
.tech = "WebGPU compute + WGSL",
.needs = "WebGPU: Chrome 121+, Firefox 141+, Safari 26+",
.mountId = "webgpu-demo",
.aspect = "16 / 9",
.needsWasm = true,
},
};
return demos;
}
export const std::vector<LegalPage>& LegalPages() {
static const std::vector<LegalPage> pages = {
{
.slug = "privacy",
.title = "Privacy",
.updated = "2026-08-04",
.lede = "What this site collects, why, and how to get rid of it. Written to describe what the code actually does. If you find a discrepancy, the code is the bug and a report is very welcome.",
.sections = {
{ "Who is responsible",
{
"Catcrafts, Netherlands. Contact details are on the imprint page. Catcrafts is the controller for everything described here; no other party is involved.",
} },
{ "Orders",
{
"Placing an order stores what fulfilling it requires: your email address, the recipient name and shipping address, the country, and the order itself (product, amounts, timestamps, payment reference and status). Nothing else is asked for and nothing else is kept. The legal basis is the contract: this data is what shipping you a phone and issuing an invoice consist of.",
"Payment happens at Mollie, a Dutch licensed payment provider, on their pages. Catcrafts never sees card numbers or bank credentials. It learns only which order was paid, for how much, and by which method. What Mollie processes about you is between you and Mollie under their own privacy policy.",
"The order status page lives at an unguessable link. Anyone holding the link can read that order's status and totals, so treat it like a receipt on your desk and don't post it anywhere public.",
} },
{ "How long it is kept",
{
"Order records that belong to an invoice are kept for seven years. That is the Dutch fiscal retention obligation, and it is the one case where a deletion request cannot be honoured early. Everything in an order that the tax records do not need is deleted on request.",
"An order that is never paid lapses; its record is kept briefly for abuse spotting and then has no reason to exist.",
"The order file is stored on a machine Catcrafts runs, readable only by the service account, and encrypted before any backup leaves that machine.",
} },
{ "What this site does not do",
{
"No analytics. No cookies, none at all, which is why there is no cookie banner. No third-party scripts, no fonts loaded from anyone else's server, no embedded video, no social buttons, no advertising, no profiling, no automated decision-making.",
"Everything the browser loads comes from catcrafts.net. Following a link out (to a fediverse thread, to Forgejo, to the Mollie payment page) puts you on that site under its terms, and Catcrafts has no visibility into what happens there.",
} },
{ "Server logs",
{
"The web server keeps ordinary request logs. Those exist to debug faults and spot abuse, and are not connected to order records or used to build any kind of profile.",
} },
{ "Your rights",
{
"Under the GDPR you can ask for a copy of what Catcrafts holds about you, have it corrected or deleted, restrict how it is used, object to its use, or ask for it in a portable form. Please contact privacy@catcrafts.net for this purpose.",
} },
{ "Changes",
{
"This page has a date at the top. Anything that changes what is collected or why will be a new date and a note in the posts feed, not a silent edit.",
} },
},
},
{
.slug = "imprint",
.title = "Imprint & contact",
.updated = "2026-08-04",
.lede = "Who is behind this site and how to reach them.",
.sections = {
{ "Contact",
{
"Email info@catcrafts.net. One inbox answers everything: support, orders, privacy requests and anything else. The topical addresses named elsewhere on this site (privacy@catcrafts.net, security@catcrafts.net) reach the same person.",
"For anything about the code itself, an issue on Forgejo is usually better than email: it stays public and searchable for the next person with the same question.",
} },
{ "Business details",
{
"Catcrafts, KVK 78437059, VAT NL003329281B38.",
} },
{ "Security reports",
{
"If you find a vulnerability, please email at security@catcrafts.net before disclosing it publicly and you will be credited. This site is source-available on Forgejo, so you can read exactly what it does rather than guessing.",
} },
},
},
{
.slug = "terms",
.title = "Terms",
.updated = "2026-08-04",
.lede = "The terms for buying from this shop. Written to be read: short sections, no boilerplate imported from anywhere, and every claim checkable against what the site actually does.",
.sections = {
{ "Ordering and payment",
{
"Submitting the order form creates an order and a Mollie payment link. The order is an offer to buy; the contract forms when the payment arrives. Until then nothing is owed: an unpaid order simply lapses and can be ignored.",
"Prices are in euros, and euros are what is charged; any amount shown in another currency is indicative only, converted at the ECB reference rate of the date shown. Inside the EU the shown price includes 21% Dutch VAT. Outside the EU the sale is a zero-rated export at the derived ex-VAT price, and the price then excludes import duty, import VAT, tariffs and any carrier handling or brokerage fee. Those charges arise on arrival in your country, are levied by the carrier or your customs authority, and are solely a matter between you and them: Catcrafts does not collect them, cannot bindingly estimate them, is not a party to their assessment, and refusal to pay them does not undo the sale. Your bank or card sets the actual euro conversion rate for whatever you pay with.",
"Payment is handled by Mollie, a Dutch licensed payment institution. Catcrafts never sees your card number or bank credentials.",
"For support related to orders please contact orders@catcrafts.net"
} },
{ "Fulfilment",
{
"Devices are sourced, flashed and tested to order. There is no warehouse. Allow up to a week between payment and dispatch; the order page and email updates track it. If sourcing falls through, you get the money back, promptly and in full.",
"Shipping is tracked and insured. The tiers and prices are shown at checkout before you commit.",
} },
{ "Warranty",
{
"Everything sold here is warranted by Catcrafts for two years from delivery, worldwide. One counter: every claim goes to Catcrafts, and Catcrafts deals with whoever needs dealing with. You never have to work out whether a fault is hardware or software, or talk to a manufacturer.",
"Behind that counter the split is simple. Software Catcrafts wrote is fixed by Catcrafts with an update, delivered remotely like any other update. If a fault stops the product from starting, Catcrafts provides the tools and walks you through recovering it in place, so a software fault still does not mean sending anything anywhere. Hardware faults are handled through the manufacturer's or supplier's warranty: Catcrafts keeps the purchase paperwork those claims depend on and runs the process end to end. What that means for a specific product is described on its own page.",
"When a product does have to travel (a hardware fault, or a product so far gone it no longer responds to recovery tools at all), the shipping is paid by Catcrafts, both directions, worldwide. Every unit is tested before dispatch, so a genuine defect should be rare. When one happens anyway, it should not cost you anything. The one exception: if a returned product turns out to have no fault, or the damage is yours (a drop, water damage, a repair attempt gone wrong), the repair and the shipping are billed at cost, and you are told the price before any work happens.",
"If you are an EU consumer you additionally hold the statutory conformity guarantee: under Dutch law it lasts as long as a product of this kind may reasonably be expected to last, a defect appearing in the first year is presumed to have existed at delivery, and remedies under it are free. Nothing in this section limits those rights.",
"For warranty please contact warranty@catcrafts.net"
} },
{ "Returns",
{
"EU consumers can withdraw from the purchase within 14 days of delivery, no reason needed: send an email, send the product back, and the price plus the standard shipping you paid is refunded within 14 days of your notice, though the refund can wait until the product is back or you show it has been shipped. Return shipping is yours to arrange and pay. You may inspect the product as you would in a shop; value lost through use beyond that can be deducted from the refund.",
"Outside the EU, sales are final except for defects: the warranty above applies in full, but there is no change-of-mind window. Import duties and fees paid to your own authorities are between you and them and are never refunded by Catcrafts in any case.",
"A product that arrives broken is a warranty case, not a return: the warranty section applies, and the shipping is on Catcrafts.",
} },
},
},
};
return pages;
}
} // namespace Catcrafts::Content

View file

@ -0,0 +1,295 @@
/*
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.
*/
// application/x-www-form-urlencoded parsing and field validation.
//
// Lives in Catcrafts.Shared rather than the server because it is pure string
// work with no I/O, which means it can be exercised on the host with real
// assertions instead of only against a live socket. Checkout will reuse all of
// it.
//
// Two decisions worth stating up front:
//
// * Validation returns a list of per-field errors rather than throwing or
// returning the first failure. A form that reports one problem at a time
// makes the user resubmit repeatedly to discover the rest.
//
// * Every limit is explicit and every field is length-capped. Input arrives
// from anyone on the internet, and "how long can this be" is not a
// question to leave to whatever the caller happens to allocate.
export module Catcrafts.Shared:Form;
import std;
namespace Catcrafts::Form {
// Hard cap on a whole request body. Well above any legitimate submission here;
// the point is that an unbounded body cannot make the server allocate without
// limit before parsing even starts.
export inline constexpr std::size_t kMaxBodyBytes = 16 * 1024;
// Per-field cap, applied after decoding.
export inline constexpr std::size_t kMaxFieldBytes = 1024;
export class Fields {
public:
// First value for `name`, or empty. Duplicates keep the first: a repeated
// field in a submission is either a bug or someone probing, and taking the
// first is the predictable choice.
std::string_view Get(std::string_view name) const {
for (const auto& [k, v] : pairs_) {
if (k == name) return v;
}
return {};
}
bool Has(std::string_view name) const {
for (const auto& [k, v] : pairs_) {
if (k == name) return true;
}
return false;
}
std::size_t Size() const noexcept { return pairs_.size(); }
void Add(std::string key, std::string value) {
pairs_.emplace_back(std::move(key), std::move(value));
}
private:
std::vector<std::pair<std::string, std::string>> pairs_;
};
// Percent-decode one component, treating '+' as space per the
// urlencoded serialisation. A malformed escape is passed through literally
// rather than dropped, so a stray '%' survives a round trip instead of
// silently mangling the value.
export std::string PercentDecode(std::string_view in) {
auto hex = [](char c) -> int {
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
return -1;
};
std::string out;
out.reserve(in.size());
for (std::size_t i = 0; i < in.size(); ++i) {
const char c = in[i];
if (c == '+') {
out.push_back(' ');
} else if (c == '%' && i + 2 < in.size()) {
const int hi = hex(in[i + 1]);
const int lo = hex(in[i + 2]);
if (hi >= 0 && lo >= 0) {
out.push_back(static_cast<char>(hi * 16 + lo));
i += 2;
} else {
out.push_back(c);
}
} else {
out.push_back(c);
}
}
return out;
}
// Parse a urlencoded body. Oversized bodies yield nothing rather than a partial
// parse — a truncated form is not something to act on.
export std::optional<Fields> ParseUrlEncoded(std::string_view body) {
if (body.size() > kMaxBodyBytes) return std::nullopt;
Fields out;
while (!body.empty()) {
const std::size_t amp = body.find('&');
std::string_view pair = body.substr(0, amp);
body = (amp == std::string_view::npos) ? std::string_view{} : body.substr(amp + 1);
if (pair.empty()) continue; // tolerate "a=1&&b=2"
const std::size_t eq = pair.find('=');
std::string key = PercentDecode(eq == std::string_view::npos ? pair : pair.substr(0, eq));
std::string val = eq == std::string_view::npos ? std::string{}
: PercentDecode(pair.substr(eq + 1));
if (key.empty() || key.size() > kMaxFieldBytes || val.size() > kMaxFieldBytes) {
return std::nullopt;
}
out.Add(std::move(key), std::move(val));
}
return out;
}
// ── validation ────────────────────────────────────────────────────────
export struct FieldError {
std::string field;
std::string message;
};
// Trim ASCII whitespace. Deliberately not locale-aware: these are machine
// fields (an address, a country code), not prose.
export std::string_view Trim(std::string_view s) {
while (!s.empty() && (s.front() == ' ' || s.front() == '\t' || s.front() == '\r'
|| s.front() == '\n')) s.remove_prefix(1);
while (!s.empty() && (s.back() == ' ' || s.back() == '\t' || s.back() == '\r'
|| s.back() == '\n')) s.remove_suffix(1);
return s;
}
// Deliberately permissive email check.
//
// Not a regex from a blog post and not an RFC 5322 parser. Fully validating an
// address is impossible without sending to it, and every strict validator in
// the wild rejects addresses that genuinely work (new TLDs, tagged local parts,
// unicode domains). So this rejects only what is definitely not an address —
// no '@', nothing before or after it, a dotless domain, whitespace, control
// characters — and lets delivery be the real test.
export bool LooksLikeEmail(std::string_view s) {
if (s.size() < 3 || s.size() > 254) return false;
const std::size_t at = s.find('@');
if (at == std::string_view::npos || at == 0 || at + 1 >= s.size()) return false;
// Exactly one '@': a second one is unambiguously malformed.
if (s.find('@', at + 1) != std::string_view::npos) return false;
const std::string_view domain = s.substr(at + 1);
const std::size_t dot = domain.find('.');
if (dot == std::string_view::npos || dot == 0 || dot + 1 >= domain.size()) return false;
for (const char c : s) {
if (static_cast<unsigned char>(c) <= 0x20 || c == 0x7F) return false;
if (c == ',' || c == ';' || c == '<' || c == '>' || c == '"' || c == '\\') return false;
}
return true;
}
// ISO 3166-1 alpha-2, uppercased. Shape only — whether we actually ship there
// is a policy question answered elsewhere, not a validation one.
export bool LooksLikeCountryCode(std::string_view s) {
if (s.size() != 2) return false;
for (const char c : s) {
if (!((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z'))) return false;
}
return true;
}
export std::string Upper(std::string_view s) {
std::string out(s);
for (char& c : out) {
if (c >= 'a' && c <= 'z') c = static_cast<char>(c - 'a' + 'A');
}
return out;
}
// ── checkout ──────────────────────────────────────────────────────────
// What the buy form collects: enough to ship a parcel and send an invoice, and
// nothing more. No account, no phone number, no marketing checkbox. The amount
// is deliberately NOT a field — money never comes from the client; the server
// computes it from the product record and the country.
export struct Checkout {
std::string email;
std::string name; // recipient, as it should appear on the label
std::string street; // street + number, one line
std::string postal;
std::string city;
std::string country; // ISO-3166-1 alpha-2, uppercased
std::string color; // variant slug; whether it EXISTS is the handler's
// check against the catalogue, not a shape check
std::int64_t quantity = 1;
};
// A technical sanity bound, not a business cap — bulk orders are welcome.
// It exists because the integer math (here and mirrored in the preview
// script) and a bunq payment link both need SOME ceiling, and an order of a
// hundred phones deserves an email conversation more than a form submit.
export inline constexpr std::int64_t kMaxQuantity = 99;
export struct CheckoutResult {
Checkout value;
std::vector<FieldError> errors;
bool Ok() const { return errors.empty(); }
};
// Validate a submitted checkout.
//
// The honeypot: the form renders a field that a human never sees and never
// fills. Anything in it means an automated submission, which is reported as a
// generic failure rather than "you tripped the honeypot" — naming the trap
// teaches the next bot how to avoid it.
export CheckoutResult ValidateCheckout(const Fields& f) {
CheckoutResult r;
if (!Trim(f.Get("website")).empty()) {
r.errors.push_back({ "", "Submission rejected." });
return r;
}
// Every field is echoed back into `value` even when it fails validation, so
// the caller can re-render the form with what the visitor typed. Discarding
// a rejected field means making them retype the one thing they got wrong,
// which is how a submission gets abandoned. `value` is only ever *stored*
// when Ok() is true, so an invalid value cannot leak into the record.
const std::string_view email = Trim(f.Get("email"));
r.value.email = std::string(email);
if (email.empty()) {
r.errors.push_back({ "email", "An email address is required — order updates go there." });
} else if (!LooksLikeEmail(email)) {
r.errors.push_back({ "email", "That doesn't look like an email address." });
}
// A required free-text field: reject empty and oversize, accept everything
// else. Names, streets and cities worldwide defeat any stricter shape check
// — validating them harder only rejects real addresses.
auto requiredText = [&](std::string_view fieldName, std::string& into,
std::size_t maxLen, std::string_view emptyMsg) {
const std::string_view v = Trim(f.Get(fieldName));
into = std::string(v);
if (v.empty()) {
r.errors.push_back({ std::string(fieldName), std::string(emptyMsg) });
} else if (v.size() > maxLen) {
r.errors.push_back({ std::string(fieldName), "Too long." });
}
};
requiredText("name", r.value.name, 120, "A recipient name is required — it goes on the label.");
requiredText("street", r.value.street, 200, "A street address is required.");
requiredText("postal", r.value.postal, 20, "A postal code is required.");
requiredText("city", r.value.city, 120, "A city is required.");
const std::string_view country = Trim(f.Get("country"));
// Normalise on the way in so a valid code is stored uppercase; an invalid
// one is echoed as typed so the visitor recognises their own input.
r.value.country = LooksLikeCountryCode(country) ? Upper(country) : std::string(country);
if (country.empty()) {
r.errors.push_back({ "country", "Pick a country — it decides shipping and VAT treatment." });
} else if (!LooksLikeCountryCode(country)) {
r.errors.push_back({ "country", "Country must be a two-letter code." });
}
// Colour: shape only (slug-ish, bounded). Whether it names a variant that
// exists — and what it costs — is the catalogue's answer, in the handler.
const std::string_view color = Trim(f.Get("color"));
r.value.color = std::string(color);
if (color.size() > 32) {
r.errors.push_back({ "color", "That is not one of the colours." });
}
// Quantity: a small positive integer, nothing else. Absent means 1 (the
// no-JS form default); anything unparseable or out of range is rejected
// rather than clamped — silently changing how many phones someone buys is
// worse than asking again.
const std::string_view qty = Trim(f.Get("quantity"));
if (qty.empty()) {
r.value.quantity = 1;
} else {
std::int64_t parsed = 0;
auto [ptr, ec] = std::from_chars(qty.data(), qty.data() + qty.size(), parsed);
if (ec != std::errc{} || ptr != qty.data() + qty.size()
|| parsed < 1 || parsed > kMaxQuantity) {
r.value.quantity = 1;
r.errors.push_back({ "quantity",
std::format("Quantity must be between 1 and {}.", kMaxQuantity) });
} else {
r.value.quantity = parsed;
}
}
return r;
}
} // namespace Catcrafts::Form

View file

@ -0,0 +1,207 @@
/*
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.
*/
// HTML construction with escaping enforced by the type system.
//
// The problem this solves: Crafter.Graphics has no setAttribute-shaped API for
// most of what a page needs, so markup is built as strings and handed to
// SetInnerHTML. Any product name, post title or user-supplied field
// interpolated into one of those strings is an XSS sink, and "remember to
// escape" is not a strategy that survives a codebase.
//
// So: SafeHtml is an opaque wrapper whose constructors from string types are
// DELETED. The only ways to obtain one are Escape() (escapes), Num()/Money()
// (can't contain markup), Url() (scheme-allowlisted), and Raw() (the single
// audited escape hatch). Format() then accepts only SafeHtml arguments, so
//
// Html::Format("<h2>{}</h2>", post.title) // std::string -> COMPILE ERROR
// Html::Format("<h2>{}</h2>", Escape(title)) // ok
//
// The failure mode for forgetting to escape is a build failure, not a stored
// cross-site-scripting bug.
export module Catcrafts.Shared:Html;
import std;
namespace Catcrafts::Html {
export class SafeHtml {
public:
SafeHtml() = default;
// Deleted so no string type can become SafeHtml implicitly. Without
// these, `SafeHtml h = userInput;` would silently compile and the whole
// guarantee would be decorative.
SafeHtml(const char*) = delete;
SafeHtml(std::string) = delete;
SafeHtml(std::string_view) = delete;
// Str() returns a reference (not a copy) because Format() feeds these
// to std::make_format_args, which in C++23 binds Args&... and therefore
// needs lvalues.
const std::string& Str() const noexcept { return v_; }
std::string_view View() const noexcept { return v_; }
bool Empty() const noexcept { return v_.empty(); }
std::size_t Size() const noexcept { return v_.size(); }
SafeHtml& operator+=(const SafeHtml& r) { v_ += r.v_; return *this; }
friend SafeHtml operator+(SafeHtml l, const SafeHtml& r) { l += r; return l; }
private:
// Private tagged ctor: the ONLY path from a raw string into the type.
// Every friend below is a function that has established the string is
// safe to emit, either by escaping it or by generating it itself.
struct TrustedTag {};
SafeHtml(TrustedTag, std::string v) : v_(std::move(v)) {}
std::string v_;
friend SafeHtml Escape(std::string_view);
friend SafeHtml Raw(std::string_view);
friend SafeHtml Num(std::int64_t);
friend SafeHtml Attr(std::string_view, std::string_view);
friend SafeHtml Url(std::string_view, std::string_view);
friend SafeHtml Join(std::span<const SafeHtml>, const SafeHtml&);
template <class... Ts> friend SafeHtml FormatImpl(std::string_view, const Ts&...);
};
// Escape for both text and attribute contexts in a single pass.
//
// Quotes are escaped even though they are harmless in text content, so that
// ONE function is correct in every context. The alternative — a text escaper
// and an attribute escaper — means every call site is a chance to pick wrong,
// which is the bug this module exists to prevent.
export SafeHtml Escape(std::string_view text) {
std::string out;
out.reserve(text.size() + text.size() / 8);
for (const char c : text) {
switch (c) {
case '&': out += "&amp;"; break;
case '<': out += "&lt;"; break;
case '>': out += "&gt;"; break;
case '"': out += "&quot;"; break;
case '\'': out += "&#39;"; break;
default: out += c; break;
}
}
return SafeHtml(SafeHtml::TrustedTag{}, std::move(out));
}
// Integers can't carry markup, so they pass through unescaped.
export SafeHtml Num(std::int64_t n) {
return SafeHtml(SafeHtml::TrustedTag{}, std::to_string(n));
}
// The single escape hatch. Every call is a claim that the argument is markup
// this codebase generated. Kept greppable and lint-gated to a small allowlist
// of files — if it starts appearing in view code, the discipline has failed.
export SafeHtml Raw(std::string_view trustedMarkup) {
return SafeHtml(SafeHtml::TrustedTag{}, std::string(trustedMarkup));
}
// `name="escaped-value"`, including the leading space, or empty when the
// value is empty — so optional attributes compose without leaving stray
// whitespace or a bare `alt=""` where none was wanted.
//
// The name is validated rather than escaped: an attribute name is never
// user data in this codebase, and silently emitting a mangled one would
// hide a bug. An invalid name yields nothing.
export SafeHtml Attr(std::string_view name, std::string_view value) {
for (const char c : name) {
const bool ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
|| (c >= '0' && c <= '9') || c == '-' || c == '_' || c == ':';
if (!ok) return SafeHtml{};
}
if (name.empty() || value.empty()) return SafeHtml{};
std::string out = " ";
out += name;
out += "=\"";
out += Escape(value).Str();
out += '"';
return SafeHtml(SafeHtml::TrustedTag{}, std::move(out));
}
// href/src emission with a scheme allowlist.
//
// Escaping alone does not make a URL safe: `javascript:alert(1)` contains no
// character that needs escaping, so an escaped-but-unvalidated href is still
// script execution. Anything not clearly http/https/mailto or site-relative
// is replaced with "#" rather than dropped, so a bad link is visibly inert
// instead of silently vanishing from the markup.
export SafeHtml Url(std::string_view attrName, std::string_view href) {
auto startsWithNoCase = [](std::string_view s, std::string_view prefix) {
if (s.size() < prefix.size()) return false;
for (std::size_t i = 0; i < prefix.size(); ++i) {
char a = s[i];
if (a >= 'A' && a <= 'Z') a = static_cast<char>(a - 'A' + 'a');
if (a != prefix[i]) return false;
}
return true;
};
// Leading control characters and whitespace are stripped by browsers
// before scheme detection, so "java\tscript:" would slip past a naive
// prefix test. Strip them here first and validate what remains.
std::string cleaned;
cleaned.reserve(href.size());
for (const char c : href) {
if (static_cast<unsigned char>(c) > 0x20) cleaned += c;
}
const bool safe =
startsWithNoCase(cleaned, "https://")
|| startsWithNoCase(cleaned, "http://")
|| startsWithNoCase(cleaned, "mailto:")
// Site-relative, but NOT protocol-relative ("//evil.example" would
// leave the origin while looking like a path).
|| (cleaned.size() >= 1 && cleaned[0] == '/'
&& !(cleaned.size() >= 2 && cleaned[1] == '/'))
|| (!cleaned.empty() && cleaned[0] == '#');
return Attr(attrName, safe ? std::string_view(cleaned) : std::string_view("#"));
}
export SafeHtml Join(std::span<const SafeHtml> parts, const SafeHtml& sep = {}) {
std::string out;
std::size_t total = 0;
for (const SafeHtml& p : parts) total += p.Size() + sep.Size();
out.reserve(total);
bool first = true;
for (const SafeHtml& p : parts) {
if (!first) out += sep.Str();
out += p.Str();
first = false;
}
return SafeHtml(SafeHtml::TrustedTag{}, std::move(out));
}
// Only SafeHtml may be interpolated.
export template <class T>
concept Safe = std::same_as<std::remove_cvref_t<T>, SafeHtml>;
template <class... Ts>
SafeHtml FormatImpl(std::string_view fmt, const Ts&... args) {
return SafeHtml(SafeHtml::TrustedTag{},
std::vformat(fmt, std::make_format_args(args.Str()...)));
}
// Maps each SafeHtml parameter to std::string for the format-string check,
// so std::format_string validates placeholder count and syntax at compile
// time against the real argument list.
template <class T> using AsString = std::string;
// The gate. Two properties, both enforced by the signature:
// * std::format_string means the template must be a compile-time constant,
// so a runtime-assembled template can't be smuggled in;
// * `Safe... Ts` means every argument is already SafeHtml, so a bare
// std::string, const char*, int or string_view fails to compile.
export template <Safe... Ts>
SafeHtml Format(std::format_string<AsString<Ts>...> fmt, const Ts&... args) {
return FormatImpl(fmt.get(), args...);
}
} // namespace Catcrafts::Html

View file

@ -0,0 +1,334 @@
/*
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.
*/
// A small, strict JSON reader.
//
// Why not vendor nlohmann/json: Catcrafts.Shared may import `std` and nothing
// else (see Catcrafts.Shared.cppm for why that boundary is absolute), and
// json.hpp wants a global module fragment plus exceptions — the wasm build is
// -fno-exceptions. This reads the one document shape the site actually needs
// (content/posts.json, generated by CI from the Lemmy API) and refuses
// everything else loudly.
//
// Deliberately NOT a general-purpose parser. No streaming, no comments, no
// trailing commas, no big-number handling beyond int64. Errors are values,
// never exceptions, and a malformed document yields an error rather than a
// partial parse — a half-read post list rendering as a broken page is worse
// than an empty one.
export module Catcrafts.Shared:Json;
import std;
namespace Catcrafts::Json {
export enum class Type { Null, Bool, Number, String, Array, Object };
export class Value {
public:
Type type = Type::Null;
bool boolean = false;
double number = 0;
std::string string;
std::vector<Value> array;
// A vector rather than a map: object key order is preserved (useful when
// re-emitting) and these documents have a handful of keys, so linear
// lookup beats hashing.
std::vector<std::pair<std::string, Value>> object;
bool IsNull() const { return type == Type::Null; }
bool IsArray() const { return type == Type::Array; }
bool IsObject() const { return type == Type::Object; }
// Object lookup. Returns nullptr when absent, so callers distinguish
// "missing" from "present but null" without a second query.
const Value* Find(std::string_view key) const {
if (type != Type::Object) return nullptr;
for (const auto& [k, v] : object) {
if (k == key) return &v;
}
return nullptr;
}
// Typed accessors with a fallback. CI generates the input, so a missing
// or wrong-typed field is a bug in the generator rather than something a
// page render should abort over — default and carry on, and let the
// generator's own validation catch it.
std::string_view Str(std::string_view key, std::string_view fallback = {}) const {
const Value* v = Find(key);
return (v && v->type == Type::String) ? std::string_view(v->string) : fallback;
}
std::int64_t Int(std::string_view key, std::int64_t fallback = 0) const {
const Value* v = Find(key);
return (v && v->type == Type::Number) ? static_cast<std::int64_t>(v->number) : fallback;
}
bool Bool(std::string_view key, bool fallback = false) const {
const Value* v = Find(key);
return (v && v->type == Type::Bool) ? v->boolean : fallback;
}
};
export struct ParseError {
std::string message;
std::size_t offset = 0;
};
export using ParseResult = std::expected<Value, ParseError>;
namespace {
struct Parser {
std::string_view s;
std::size_t i = 0;
std::unexpected<ParseError> Fail(std::string msg) {
return std::unexpected(ParseError{ std::move(msg), i });
}
void SkipWhitespace() {
while (i < s.size()) {
const char c = s[i];
if (c == ' ' || c == '\t' || c == '\n' || c == '\r') ++i;
else break;
}
}
bool Literal(std::string_view lit) {
if (s.size() - i < lit.size()) return false;
if (s.compare(i, lit.size(), lit) != 0) return false;
i += lit.size();
return true;
}
// Appends the UTF-8 encoding of a code point. JSON escapes are UTF-16,
// so astral characters arrive as a surrogate pair and must be combined
// before encoding — emitting each half separately produces invalid UTF-8
// that will render as replacement characters (emoji in post titles are
// exactly this case).
static void AppendUtf8(std::string& out, char32_t cp) {
if (cp <= 0x7F) {
out += static_cast<char>(cp);
} else if (cp <= 0x7FF) {
out += static_cast<char>(0xC0 | (cp >> 6));
out += static_cast<char>(0x80 | (cp & 0x3F));
} else if (cp <= 0xFFFF) {
out += static_cast<char>(0xE0 | (cp >> 12));
out += static_cast<char>(0x80 | ((cp >> 6) & 0x3F));
out += static_cast<char>(0x80 | (cp & 0x3F));
} else {
out += static_cast<char>(0xF0 | (cp >> 18));
out += static_cast<char>(0x80 | ((cp >> 12) & 0x3F));
out += static_cast<char>(0x80 | ((cp >> 6) & 0x3F));
out += static_cast<char>(0x80 | (cp & 0x3F));
}
}
std::optional<char32_t> Hex4() {
if (s.size() - i < 4) return std::nullopt;
char32_t v = 0;
for (int k = 0; k < 4; ++k) {
const char c = s[i + k];
int d;
if (c >= '0' && c <= '9') d = c - '0';
else if (c >= 'a' && c <= 'f') d = c - 'a' + 10;
else if (c >= 'A' && c <= 'F') d = c - 'A' + 10;
else return std::nullopt;
v = v * 16 + static_cast<char32_t>(d);
}
i += 4;
return v;
}
std::expected<std::string, ParseError> ParseString() {
if (i >= s.size() || s[i] != '"') return Fail("expected string");
++i;
std::string out;
while (true) {
if (i >= s.size()) return Fail("unterminated string");
const char c = s[i];
if (c == '"') { ++i; return out; }
if (c == '\\') {
++i;
if (i >= s.size()) return Fail("unterminated escape");
const char e = s[i++];
switch (e) {
case '"': out += '"'; break;
case '\\': out += '\\'; break;
case '/': out += '/'; break;
case 'b': out += '\b'; break;
case 'f': out += '\f'; break;
case 'n': out += '\n'; break;
case 'r': out += '\r'; break;
case 't': out += '\t'; break;
case 'u': {
auto hi = Hex4();
if (!hi) return Fail("bad \\u escape");
char32_t cp = *hi;
if (cp >= 0xD800 && cp <= 0xDBFF) {
// High surrogate: a low surrogate must follow.
if (i + 1 < s.size() && s[i] == '\\' && s[i + 1] == 'u') {
const std::size_t save = i;
i += 2;
auto lo = Hex4();
if (lo && *lo >= 0xDC00 && *lo <= 0xDFFF) {
cp = 0x10000 + ((cp - 0xD800) << 10) + (*lo - 0xDC00);
} else {
i = save;
cp = 0xFFFD; // lone high surrogate
}
} else {
cp = 0xFFFD;
}
} else if (cp >= 0xDC00 && cp <= 0xDFFF) {
cp = 0xFFFD; // stray low surrogate
}
AppendUtf8(out, cp);
break;
}
default: return Fail("unknown escape");
}
continue;
}
// Unescaped control characters are invalid JSON; rejecting them
// keeps a truncated/corrupted file from parsing as valid.
if (static_cast<unsigned char>(c) < 0x20) return Fail("control character in string");
out += c;
++i;
}
}
// RFC 8259 number grammar, validated explicitly:
//
// number = [ "-" ] int [ frac ] [ exp ]
// int = "0" / ( digit1-9 *DIGIT )
// frac = "." 1*DIGIT
// exp = ("e"/"E") [ "-" / "+" ] 1*DIGIT
//
// Scanning the character set and handing the span to from_chars is NOT
// equivalent: from_chars accepts "01" and "+1", both of which are invalid
// JSON. Since the point of this parser is to reject corrupted input rather
// than guess at it, the grammar is checked before conversion.
std::expected<double, ParseError> ParseNumber() {
const std::size_t start = i;
auto digit = [&] { return i < s.size() && s[i] >= '0' && s[i] <= '9'; };
if (i < s.size() && s[i] == '-') ++i; // leading '+' is not JSON
if (!digit()) return Fail("expected digit");
if (s[i] == '0') {
++i;
// "0" may not be followed by another digit — "01" is invalid.
if (digit()) return Fail("leading zero");
} else {
while (digit()) ++i;
}
if (i < s.size() && s[i] == '.') {
++i;
if (!digit()) return Fail("expected digit after '.'");
while (digit()) ++i;
}
if (i < s.size() && (s[i] == 'e' || s[i] == 'E')) {
++i;
if (i < s.size() && (s[i] == '+' || s[i] == '-')) ++i;
if (!digit()) return Fail("expected digit in exponent");
while (digit()) ++i;
}
double out = 0;
const char* b = s.data() + start;
const char* e = s.data() + i;
const auto [ptr, ec] = std::from_chars(b, e, out);
// Out-of-range is the one case the grammar allows but the type can't
// hold (1e400). Treat it as malformed rather than silently infinite.
if (ec != std::errc{} || ptr != e) return Fail("number out of range");
return out;
}
// Recursion is bounded so a hostile or corrupted document can't blow the
// stack. Real input here nests two levels (array of flat objects).
ParseResult ParseValue(int depth) {
if (depth > 32) return Fail("nesting too deep");
SkipWhitespace();
if (i >= s.size()) return Fail("unexpected end of input");
Value v;
const char c = s[i];
if (c == '"') {
auto str = ParseString();
if (!str) return std::unexpected(str.error());
v.type = Type::String;
v.string = std::move(*str);
return v;
}
if (c == '{') {
++i;
v.type = Type::Object;
SkipWhitespace();
if (i < s.size() && s[i] == '}') { ++i; return v; }
while (true) {
SkipWhitespace();
auto key = ParseString();
if (!key) return std::unexpected(key.error());
SkipWhitespace();
if (i >= s.size() || s[i] != ':') return Fail("expected ':'");
++i;
auto val = ParseValue(depth + 1);
if (!val) return std::unexpected(val.error());
v.object.emplace_back(std::move(*key), std::move(*val));
SkipWhitespace();
if (i < s.size() && s[i] == ',') { ++i; continue; }
if (i < s.size() && s[i] == '}') { ++i; return v; }
return Fail("expected ',' or '}'");
}
}
if (c == '[') {
++i;
v.type = Type::Array;
SkipWhitespace();
if (i < s.size() && s[i] == ']') { ++i; return v; }
while (true) {
auto item = ParseValue(depth + 1);
if (!item) return std::unexpected(item.error());
v.array.push_back(std::move(*item));
SkipWhitespace();
if (i < s.size() && s[i] == ',') { ++i; continue; }
if (i < s.size() && s[i] == ']') { ++i; return v; }
return Fail("expected ',' or ']'");
}
}
if (Literal("true")) { v.type = Type::Bool; v.boolean = true; return v; }
if (Literal("false")) { v.type = Type::Bool; v.boolean = false; return v; }
if (Literal("null")) { v.type = Type::Null; return v; }
auto num = ParseNumber();
if (!num) return std::unexpected(num.error());
v.type = Type::Number;
v.number = *num;
return v;
}
};
} // namespace
// Parse a complete JSON document. Trailing content after the top-level value
// is an error rather than ignored — it usually means a truncated or
// concatenated file, and silently accepting the prefix hides that.
export ParseResult Parse(std::string_view text) {
Parser p{ text, 0 };
auto v = p.ParseValue(0);
if (!v) return v;
p.SkipWhitespace();
if (p.i != text.size()) {
return std::unexpected(ParseError{ "trailing content after JSON value", p.i });
}
return v;
}
} // namespace Catcrafts::Json

View file

@ -0,0 +1,330 @@
/*
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.
*/
// Content types, and loaders for the two files that stay data.
//
// The split: content a person authors (products, projects, legal pages,
// demos) is compiled in — see :Content — so a typo in it is a build error,
// not a silently blank page. Content a machine writes at build time stays
// JSON under content/: posts.json (fetched from the fediverse by CI) and
// rates.json (ECB rates). Those change without anyone editing C++, so their
// loaders live here.
//
// Both hosts call the same loaders on the same bytes; only where the bytes
// come from differs. The wasm build gets the files through Crafter.Build's
// VFS (cfg.files -> files.json -> fetched before _start, readable at the
// bundle root); the native server reads content/ off disk. So the loaders
// take text, not paths.
//
// Loading never fails hard. These files are generated by our own CI, so a
// missing or wrong-typed field is a generator bug, and a page rendering with
// one bad card beats a page that refuses to render at all. Malformed JSON is
// the one exception — that yields an empty list, because a half-parsed
// document is worse than none.
export module Catcrafts.Shared:Model;
import std;
import :Json;
namespace Catcrafts {
// One image or video belonging to a post.
//
// `src` is a local path under /media once tools/fetch-media.sh has mirrored it.
// It can still be an absolute URL if that download failed, which is why the
// renderer puts it through Url() rather than assuming it is site-relative.
//
// Width and height come from ffprobe at mirror time and exist to stop layout
// shift: without them the browser cannot reserve space, and the text below every
// card jumps as each file arrives.
export struct PostMedia {
std::string src;
std::string kind; // "image" | "video"
// Still frame for a video, from the instance's own thumbnail. Without it a
// <video preload="metadata"> shows a black box until the visitor presses
// play — and these posts are their video, so the black box is the page.
// Empty for images, and empty when the instance generated no thumbnail.
std::string poster;
std::int64_t width = 0;
std::int64_t height = 0;
};
// A post mirrored from the fediverse (Lemmy). Deliberately not the full post:
// the body stays on the community's instance, where the comments are. We show
// enough to be worth clicking and then hand off — no crawling, no comment
// mirroring, no markdown pipeline.
//
// `permalink` is resolved at fetch time to the COMMUNITY's instance, not the
// author's. Both host a federated copy; the community's is where the discussion
// actually is, and it is what the site should be pointing readers at.
export struct Post {
std::string title;
std::string permalink; // canonical ap_id on the instance; where "discuss" goes
std::string linkUrl; // for link posts, the linked target; empty otherwise
std::string community; // e.g. "linux@lemmy.ml"
std::string published; // ISO-8601, as emitted by the API
std::string excerpt; // plain text, truncated by CI — never markdown
std::int64_t score = 0;
std::int64_t comments = 0;
std::vector<PostMedia> media;
};
// An entry on the projects page. Repo content, not a database — these change
// on the order of months.
//
// Deliberately shallow: name, one sentence, a link. The Forgejo page is the
// canonical home for details, so anything richer here (status, feature lists)
// just drifts out of date against it.
export struct Project {
std::string name;
std::string blurb;
std::string url;
std::string language;
bool featured = false;
};
// Everything the document <head> needs. Emitted server-side once SSR lands;
// until then catcrafts-head.js sets the title and the rest is unused but
// carried so the renderers don't need changing later.
export struct PageMeta {
std::string title;
std::string description;
std::string canonical;
std::string ogType = "website";
std::string ogImage;
bool noindex = false;
// >0 emits <meta http-equiv="refresh"> — the no-JavaScript way for a page
// to track changing server state. Used by the order page while a payment
// is pending; leave 0 everywhere content is static.
int refreshSeconds = 0;
// Emits the inline timezone hint script (see RenderDocument): pages that
// show the dual EU/export price set this so the browser can move the
// emphasis onto whichever price applies locally. Presentation only — both
// prices are always in the markup, the HTML is identical for every
// visitor, and nothing is detected server-side.
bool geoPriceHint = false;
};
export struct Spec {
std::string label;
std::string value;
};
// A buyable variation of a product — currently colour. Each carries its own
// VAT-inclusive price because the supplier prices them differently (white
// costs ~€90 more wholesale than green). The ex-VAT export price is always
// DERIVED (Money::NetFromGross), never stored.
export struct Variant {
std::string slug; // "green" — form value and order-record field
std::string label; // "Green" — what the buyer reads
std::int64_t priceInclMinor = 0;
};
export struct Product {
std::string slug;
std::string name;
std::string tagline;
// "available" — buyable now. "coming-soon" — listed with launch prices,
// orders not open yet. "unavailable" — listed but not sellable (sourcing
// gap, price swing). In both closed states the page stays up, the buy
// form does not, and the checkout POST is refused server-side.
std::string status;
// The EU consumer price, VAT-inclusive, in cents. With variants present
// this is the FROM price (cheapest variant) and is kept in sync by the
// loader; without variants it is simply the price.
std::int64_t priceInclMinor = 0;
std::vector<Variant> variants;
std::string currency = "EUR";
// Flat shipping per zone, in cents, consumer-facing (VAT-inclusive where
// VAT applies). The fallback when Sendcloud has no rate for a country —
// live carrier rates take precedence wherever they exist.
std::int64_t shipNlMinor = 0;
std::int64_t shipEuMinor = 0;
std::int64_t shipWorldMinor = 0;
// Root-relative path of the product photo, e.g. "/fp6-pmos.jpg". Served
// from our own origin like every other asset — the privacy notice's
// "everything comes from catcrafts.net" applies to product images too.
std::string image;
std::string summary;
std::string warranty;
// Rendered as a prominent warning box when non-empty. Exists for exactly
// one thing today: the emergency-calling caveat — the single claim on the
// page that is about safety rather than features, which is why it gets a
// warning box instead of a table row.
std::string safetyNote;
std::vector<Spec> specs;
bool Buyable() const { return status == "available" && priceInclMinor > 0; }
bool ComingSoon() const { return status == "coming-soon"; }
// nullptr for a colour we never listed — the checkout rejects rather than
// guessing, so a tampered form value cannot buy an unpriced variant.
const Variant* FindVariant(std::string_view vslug) const {
for (const Variant& v : variants) {
if (v.slug == vslug) return &v;
}
return nullptr;
}
// The default selection: the cheapest variant, which is also what the
// "from" price shows — so the page never advertises a number the default
// choice doesn't honour.
const Variant* CheapestVariant() const {
const Variant* best = nullptr;
for (const Variant& v : variants) {
if (!best || v.priceInclMinor < best->priceInclMinor) best = &v;
}
return best;
}
};
// ECB euro reference rates, baked in at build time by tools/fetch-rates.sh.
// Values are integer micro-units of target currency per euro (1 EUR = 1.0834
// USD -> 1'083'400) — the script does the decimal-to-integer conversion so no
// float ever touches a money path here. Used ONLY for the indicative national-
// currency line on the order page; every charge is in euros.
export struct Rates {
std::string date; // ECB publication date
std::vector<std::pair<std::string, std::int64_t>> microPerEur;
std::int64_t Find(std::string_view code) const {
for (const auto& [k, v] : microPerEur) {
if (k == code) return v;
}
return 0;
}
};
export Rates LoadRates(std::string_view json) {
Rates out;
auto doc = Json::Parse(json);
if (!doc || !doc->IsObject()) return out;
out.date = std::string(doc->Str("date"));
if (const Json::Value* m = doc->Find("micro_per_eur"); m && m->IsObject()) {
for (const auto& [k, v] : m->object) {
if (v.type == Json::Type::Number && v.number > 0) {
out.microPerEur.emplace_back(k, static_cast<std::int64_t>(v.number));
}
}
}
return out;
}
// Everything the order status page needs to render — a projection of the
// server's order record, not the record itself. The renderer stays a pure
// function in Shared; the server owns storage and fills this in.
export struct OrderView {
std::string token; // the capability that IS the URL — never logged
std::string reference; // short human code, quoted in the bank transfer
std::string status; // "awaiting_payment" | "paid" | "shipped" | "cancelled"
std::string productName;
std::string colorLabel; // "Forest Green", empty for variantless products
std::int64_t quantity = 1;
std::int64_t unitMinor = 0;
std::string payUrl; // bunq.me link; empty once paid or when cancelled
std::string createdAt; // ISO 8601, shown verbatim
std::string country;
std::int64_t goodsMinor = 0;
std::int64_t shippingMinor = 0;
std::int64_t totalMinor = 0;
bool vatIncluded = false;
};
// An entry on the demos page.
//
// `needsWasm` is what decides whether a page ships the ~239 KB module, so it is
// data rather than a hardcoded route check: adding a demo that needs the
// renderer, or one that does not, should not require touching the server.
export struct Demo {
std::string slug;
std::string name;
std::string blurb;
std::string tech;
std::string needs; // what the browser must support, in plain words ("requires" is a keyword)
std::string mountId; // element id the renderer reparents its canvas into
std::string aspect; // CSS aspect-ratio for the mount box
bool needsWasm = false;
};
// A legal / informational page. Sections of headed paragraphs rather than
// markdown: these are written once and read rarely, and a prose format would
// mean carrying a markdown renderer for four pages.
export struct LegalSection {
std::string heading;
std::vector<std::string> body;
};
export struct LegalPage {
std::string slug;
std::string title;
std::string updated; // ISO date, shown to the reader
std::string lede;
std::vector<LegalSection> sections;
};
// ── loaders ───────────────────────────────────────────────────────────
export std::vector<Post> LoadPosts(std::string_view json) {
std::vector<Post> out;
auto doc = Json::Parse(json);
if (!doc || !doc->IsArray()) return out;
out.reserve(doc->array.size());
for (const Json::Value& item : doc->array) {
if (!item.IsObject()) continue;
Post p;
p.title = std::string(item.Str("title"));
p.permalink = std::string(item.Str("permalink"));
p.linkUrl = std::string(item.Str("url"));
p.community = std::string(item.Str("community"));
p.published = std::string(item.Str("published"));
p.excerpt = std::string(item.Str("excerpt"));
p.score = item.Int("score");
p.comments = item.Int("comments");
if (const Json::Value* m = item.Find("media"); m && m->IsArray()) {
for (const Json::Value& mv : m->array) {
if (!mv.IsObject()) continue;
PostMedia pm;
pm.src = std::string(mv.Str("src"));
pm.kind = std::string(mv.Str("kind", "image"));
pm.poster = std::string(mv.Str("poster"));
pm.width = mv.Int("w");
pm.height = mv.Int("h");
// Only the two kinds the renderer knows how to emit. Anything
// else would fall through to an <img> for a file that is not an
// image, so treat it as image only when it says so.
if (pm.kind != "image" && pm.kind != "video") pm.kind = "image";
if (pm.src.empty()) continue;
p.media.push_back(std::move(pm));
}
}
// A post with no title and nowhere to click is not renderable; drop it
// rather than emit an empty card.
if (p.title.empty() && p.permalink.empty()) continue;
out.push_back(std::move(p));
}
return out;
}
// ── formatting helpers ────────────────────────────────────────────────
// "2026-07-18T14:03:22.123456Z" -> "2026-07-18".
//
// Deliberately not a date parser. The only thing the UI needs is the day, the
// API always emits ISO-8601, and pulling in civil-time handling to render ten
// characters would be the wrong trade. Anything unexpected is passed through
// unchanged so a format change shows up as visibly odd text rather than a
// silently wrong date.
export std::string_view DateOnly(std::string_view iso) {
if (iso.size() >= 10 && iso[4] == '-' && iso[7] == '-') return iso.substr(0, 10);
return iso;
}
} // namespace Catcrafts

View file

@ -0,0 +1,214 @@
/*
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.
*/
// Money and VAT arithmetic, in integer minor units. No floats, ever: a double
// cannot represent 0.01 exactly, and a price that drifts by a cent between the
// page, the payment request and the invoice is a bookkeeping bug you find at
// tax time. Everything here is exact integer math with explicit rounding.
//
// Lives in Catcrafts.Shared (imports std only) so the same arithmetic renders
// the price on the page and computes the amount actually charged — one
// function, so they cannot disagree.
export module Catcrafts.Shared:Money;
import std;
namespace Catcrafts::Money {
// NL standard VAT rate, in basis points. Prices are stored VAT-inclusive (EU
// Price Indication Directive: consumers must see the final price), and the net
// is derived — not the other way round — so the advertised number is exact and
// the derived one takes the rounding.
export inline constexpr std::int64_t kVatRateBp = 2100;
// Net (ex-VAT) amount from a VAT-inclusive gross, rounding half up on the
// division. gross = net * (1 + rate) exactly when working in real numbers;
// in minor units the net absorbs the sub-cent remainder.
export constexpr std::int64_t NetFromGross(std::int64_t grossMinor,
std::int64_t rateBp = kVatRateBp) {
// net = gross * 10000 / (10000 + rate), rounded half up.
const std::int64_t denom = 10000 + rateBp;
return (grossMinor * 10000 + denom / 2) / denom;
}
// The other direction: the VAT-inclusive price that NETS a given ex-VAT cost.
// This is how "cost plus, eat nothing" survives VAT: a carrier rate of €7.13
// ex VAT must be charged as €8.63 inclusive, or the remitted VAT comes out of
// the margin. Half-up like its sibling, and the pair round-trips (GrossFromNet
// then NetFromGross returns the original cost).
export constexpr std::int64_t GrossFromNet(std::int64_t netMinor,
std::int64_t rateBp = kVatRateBp) {
return (netMinor * (10000 + rateBp) + 5000) / 10000;
}
// "580.00" — the wire format bunq's amount objects use, and the unambiguous
// way to show cents. Always two decimals, no thousands separator.
export std::string FormatMinor(std::int64_t minor) {
const bool neg = minor < 0;
if (neg) minor = -minor;
return std::format("{}{}.{:02}", neg ? "-" : "", minor / 100, minor % 100);
}
// Display form: "€580" when the cents are zero, "€479.34" otherwise. Whole
// prices are chosen deliberately (no .99 games), so showing ".00" everywhere
// would just add noise to the number that matters.
export std::string FormatEuro(std::int64_t minor) {
if (minor % 100 == 0 && minor >= 0) return std::format("€{}", minor / 100);
return "€" + FormatMinor(minor);
}
// EU membership decides VAT treatment: inside the EU the price is charged
// VAT-inclusive; outside, the sale is a zero-rated export and the buyer's own
// customs channel collects import VAT and duty. ISO 3166-1 alpha-2, uppercase.
//
// Note NIR/GB: the UK left; Northern Ireland's special goods status is not
// modelled — GB is simply non-EU here, which is the correct default for a
// consumer parcel.
export bool IsEuCountry(std::string_view cc) {
static constexpr std::array<std::string_view, 27> eu{
"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",
};
return std::ranges::find(eu, cc) != eu.end();
}
// Shipping zones. Three tiers is deliberate — real carrier pricing has more
// distinctions than anyone wants in a checkout, and the tiers only need to be
// roughly right because the rates are set per product with margin.
export enum class Zone { Nl, Eu, World };
export Zone ZoneFor(std::string_view cc) {
if (cc == "NL") return Zone::Nl;
return IsEuCountry(cc) ? Zone::Eu : Zone::World;
}
// One order's money, fully derived. `goods` is what the buyer pays for the
// device: the VAT-inclusive price inside the EU, the derived net outside it.
// `vatCharged` is what the total contains in Dutch VAT — zero for exports —
// kept because the invoice needs it, not because the page shows it.
export struct Totals {
std::int64_t goods = 0;
std::int64_t shipping = 0;
std::int64_t total = 0;
std::int64_t vatCharged = 0;
bool vatIncluded = false; // true when `goods` includes EU VAT
};
// The single authority on what an order costs. The checkout handler calls this
// with the buyer's country; nothing about the amount ever comes from the
// client. `shippingMinor` arrives already resolved (live carrier table or the
// zone fallback — the server decides which), so this function stays pure.
//
// The export net is derived from the LINE total (unit × qty), not per unit —
// rounding per line is the invoice-correct convention, and it is also the
// formula the checkout preview script mirrors, so the preview and the charge
// cannot drift by a cent.
export Totals ComputeTotals(std::int64_t unitGrossMinor, std::int64_t quantity,
std::int64_t shippingMinor,
std::string_view country) {
Totals t;
t.shipping = shippingMinor;
const std::int64_t lineGross = unitGrossMinor * quantity;
if (IsEuCountry(country)) {
t.goods = lineGross;
t.vatIncluded = true;
// VAT applies to the shipping too — it is part of the taxable supply.
const std::int64_t taxable = t.goods + t.shipping;
t.vatCharged = taxable - NetFromGross(taxable);
} else {
t.goods = NetFromGross(lineGross);
t.vatIncluded = false;
t.vatCharged = 0;
}
t.total = t.goods + t.shipping;
return t;
}
// The zone-table shipping fallback, used when no live carrier table covers the
// destination. Exported separately so the same lookup renders the shipping
// table on the product page.
export std::int64_t ZoneShipping(std::int64_t shipNl, std::int64_t shipEu,
std::int64_t shipWorld, std::string_view country) {
const Zone z = ZoneFor(country);
return z == Zone::Nl ? shipNl : z == Zone::Eu ? shipEu : shipWorld;
}
// ── indicative currency display ───────────────────────────────────────
//
// Orders are charged in euros, always — bunq collects EUR and the invoice is
// EUR. But a Canadian reading "€614" has to do mental arithmetic to know what
// their card will actually take, so the order page also shows an INDICATIVE
// conversion in the buyer's national currency, from ECB reference rates baked
// in at build time. Indicative is the whole contract: the buyer's bank sets
// the real conversion rate, and the page says so next to the number.
export struct Currency {
std::string_view code; // ISO 4217
std::string_view symbol; // display prefix, e.g. "CA$"
};
// One supported non-euro display currency and its representative country.
// `cc` matters beyond lookup: IsEuCountry(cc) decides which euro amount a
// conversion starts from — an EU member's currency (SEK, PLN, …) converts the
// VAT-inclusive price, everyone else's converts the ex-VAT export price.
export struct CurrencyRow {
std::string_view cc;
Currency cur;
};
// Only currencies the ECB publishes reference rates for; anywhere else shows
// plain euros. Euro countries are deliberately absent — converting EUR to EUR
// is noise.
export std::span<const CurrencyRow> AllCurrencies() {
static constexpr std::array<CurrencyRow, 16> rows{{
{ "US", { "USD", "US$" } },
{ "CA", { "CAD", "CA$" } },
{ "GB", { "GBP", "£" } },
{ "CH", { "CHF", "CHF " } },
{ "NO", { "NOK", "kr " } },
{ "SE", { "SEK", "kr " } },
{ "DK", { "DKK", "kr " } },
{ "PL", { "PLN", "zł " } },
{ "CZ", { "CZK", "Kč " } },
{ "HU", { "HUF", "Ft " } },
{ "RO", { "RON", "lei " } },
{ "BG", { "BGN", "лв " } },
{ "AU", { "AUD", "A$" } },
{ "NZ", { "NZD", "NZ$" } },
{ "JP", { "JPY", "¥" } },
{ "IS", { "ISK", "kr " } },
}};
return rows;
}
// Currency for a destination country, or nullopt for euro countries and
// anywhere unsupported.
export std::optional<Currency> CurrencyFor(std::string_view cc) {
for (const CurrencyRow& r : AllCurrencies()) {
if (r.cc == cc) return r.cur;
}
return std::nullopt;
}
// Convert cents-EUR to WHOLE units of the target currency, half-up. Whole
// units on purpose: a number that is explicitly approximate should not carry
// two decimals of false precision. `rateMicro` is target-per-euro in millionths
// (1 EUR = 1.0834 USD -> 1'083'400).
export constexpr std::int64_t ConvertIndicative(std::int64_t minorEur,
std::int64_t rateMicro) {
// units = minorEur/100 * rateMicro/1e6, rounded half up.
return (minorEur * rateMicro + 50'000'000) / 100'000'000;
}
// "≈ CA$920" — the display form of an indicative conversion.
export std::string FormatIndicative(const Currency& cur, std::int64_t wholeUnits) {
return std::format("≈ {}{}", cur.symbol, wholeUnits);
}
} // namespace Catcrafts::Money

View file

@ -0,0 +1,212 @@
/*
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.
*/
// URL -> route mapping, shared by both hosts.
//
// The wasm app resolves the route from window.location; the native server will
// resolve it from the request target. Same function, so a URL cannot mean one
// thing to a crawler and another to the app — which is the whole reason route
// parsing lives here instead of in an if/else chain per host.
export module Catcrafts.Shared:Route;
import std;
namespace Catcrafts {
export enum class RouteKind {
Home,
Projects,
Posts,
Demos, // /demos — the list
Demo, // /demos/<slug>
Shop, // /shop — the (currently single-item) product list
Product, // /shop/<slug>
Order, // /order/<token> — an order's status page
Invoice, // /order/<token>/invoice.md — the signed invoice download
Legal, // /legal/<slug> — privacy, imprint, terms
// The blog these routes replace. sitemap.xml advertised /blog and
// /blog/<slug>, and those URLs are in the wild — in shared links and in
// whatever the crawlers already have. They resolve to Posts and carry a
// canonical target so each host can do the right thing: the app rewrites
// the address bar, the server will answer 301.
LegacyBlog,
NotFound,
};
export struct Route {
RouteKind kind = RouteKind::NotFound;
std::string path; // normalised, no trailing slash (except "/")
std::string query; // raw, including leading '?', or empty
// Non-empty when the request should be canonicalised to a different URL.
std::string canonicalRedirect;
// The <slug> of /shop/<slug>, empty for every other route.
std::string slug;
};
// An order token is the entire capability to view that order — whoever has the
// URL sees the status page. 32 lowercase hex characters (128 bits from the
// server's CSPRNG), so it is unguessable; constraining the shape here means a
// probe like /order/../../etc never reaches a lookup.
export bool IsOrderToken(std::string_view s) {
if (s.size() != 32) return false;
for (const char c : s) {
const bool ok = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f');
if (!ok) return false;
}
return true;
}
// A slug appears in a URL, in an element id, and as a form field, so it is
// constrained rather than sanitised at each use: lowercase, digits and hyphens
// only, and bounded. Anything else is not a slug we ever generated.
export bool IsValidSlug(std::string_view s) {
if (s.empty() || s.size() > 64) return false;
for (const char c : s) {
const bool ok = (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-';
if (!ok) return false;
}
// Leading/trailing hyphens and doubled hyphens are not produced by
// anything here, so reject them rather than carry ambiguity around.
if (s.front() == '-' || s.back() == '-') return false;
return s.find("--") == std::string_view::npos;
}
// Strip a trailing slash so "/projects/" and "/projects" are one route rather
// than two URLs with identical content — which would otherwise be a duplicate
// canonical for crawlers.
export std::string_view NormalisePath(std::string_view path) {
while (path.size() > 1 && path.back() == '/') path.remove_suffix(1);
if (path.empty()) return "/";
return path;
}
export Route ParseRoute(std::string_view path, std::string_view query = {}) {
Route r;
const std::string_view p = NormalisePath(path);
r.path = std::string(p);
r.query = std::string(query);
if (p == "/") { r.kind = RouteKind::Home; return r; }
if (p == "/projects") { r.kind = RouteKind::Projects; return r; }
if (p == "/posts") { r.kind = RouteKind::Posts; return r; }
if (p == "/demos") { r.kind = RouteKind::Demos; return r; }
if (p == "/shop") { r.kind = RouteKind::Shop; return r; }
// /order/<token>. The token is validated structurally here for the same
// reason slugs are: nothing downstream should ever see one it must
// re-validate. An invalid token is a plain 404 — indistinguishable from an
// unknown one, so the URL shape leaks nothing.
if (p.starts_with("/order/")) {
std::string_view rest = p.substr(7);
// /order/<token>/invoice.md downloads the signed invoice. Parsed here
// (not in the handler) so the token shape check happens exactly once.
RouteKind kind = RouteKind::Order;
if (rest.ends_with("/invoice.md")) {
rest.remove_suffix(11);
kind = RouteKind::Invoice;
}
if (IsOrderToken(rest)) {
r.kind = kind;
r.slug = std::string(rest);
return r;
}
r.kind = RouteKind::NotFound;
return r;
}
// /shop/<slug>. An invalid slug is a 404 rather than a lookup with a
// rejected key, so nothing downstream ever sees a slug it must re-validate.
if (p.starts_with("/shop/")) {
const std::string_view slug = p.substr(6);
if (IsValidSlug(slug)) {
r.kind = RouteKind::Product;
r.slug = std::string(slug);
return r;
}
r.kind = RouteKind::NotFound;
return r;
}
if (p.starts_with("/demos/")) {
const std::string_view slug = p.substr(7);
if (IsValidSlug(slug)) {
r.kind = RouteKind::Demo;
r.slug = std::string(slug);
return r;
}
r.kind = RouteKind::NotFound;
return r;
}
// /demo was the single-demo URL before there was a list. Keep it working:
// it was linked from the home page and may be in someone's history.
if (p == "/demo") {
r.kind = RouteKind::Demos;
r.canonicalRedirect = "/demos";
return r;
}
if (p.starts_with("/legal/")) {
const std::string_view slug = p.substr(7);
if (IsValidSlug(slug)) {
r.kind = RouteKind::Legal;
r.slug = std::string(slug);
return r;
}
r.kind = RouteKind::NotFound;
return r;
}
// /blog, /blog/anything -> /posts
if (p == "/blog" || p.starts_with("/blog/")) {
r.kind = RouteKind::LegacyBlog;
r.canonicalRedirect = "/posts";
return r;
}
r.kind = RouteKind::NotFound;
return r;
}
// The nav entries, in order. Shared so the header and the sitemap cannot
// disagree about what pages exist.
export struct NavItem {
std::string_view label;
std::string_view href;
RouteKind kind;
};
export std::span<const NavItem> NavItems() {
static constexpr std::array<NavItem, 5> items{{
{ "Home", "/", RouteKind::Home },
{ "Shop", "/shop", RouteKind::Shop },
{ "Projects", "/projects", RouteKind::Projects },
{ "Posts", "/posts", RouteKind::Posts },
{ "Demos", "/demos", RouteKind::Demos },
}};
return items;
}
// Routes that belong in sitemap.xml. NotFound and LegacyBlog are excluded:
// one isn't a page, the other is a redirect, and advertising either invites
// crawlers to index a URL that isn't canonical.
export std::span<const std::string_view> SitemapPaths() {
// /shop/<slug> entries are appended by the caller from the loaded product
// list — the sitemap has to reflect what actually exists, and a hardcoded
// slug list here would be one more thing to forget to update.
static constexpr std::array<std::string_view, 8> paths{
"/", "/shop", "/projects", "/posts", "/demos",
// Legal pages are indexable on purpose: they are trust signals, and a
// buyer looking for the returns policy before purchasing should be able
// to find it from a search engine.
"/legal/privacy", "/legal/terms", "/legal/imprint",
};
return paths;
}
} // namespace Catcrafts

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,41 @@
/*
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.
*/
// Catcrafts.Shared — the target-neutral half of the site.
//
// Everything here compiles for BOTH wasm32-wasip1 (the browser app) and the
// host triple (the server that will render the same pages for crawlers and
// no-JS clients). That is the entire point: one set of page renderers, not
// two that drift.
//
// THE RULE: this module imports `std` and its own partitions. Nothing else.
//
// Not a style preference — Crafter.Build's dependency scanner does not respect
// `#ifdef` around `import` statements (Crafter.Graphics/project.cpp:116-121
// documents the same constraint), so an `#ifdef`-guarded
// `import Crafter.Graphics;` in here would still force Crafter.Graphics onto
// the native build, where it cannot compile. There is no conditional-import
// escape hatch, so the boundary has to be absolute.
//
// Consequently Catcrafts.Shared is a pure function:
//
// (route, data) -> RenderedPage
//
// Each host does its own I/O — fetch/DOM on wasm, sockets/SQLite on native —
// and calls in with plain data.
export module Catcrafts.Shared;
export import :Html;
export import :Json;
export import :Form;
export import :Model;
export import :Content;
export import :Money;
export import :Route;
export import :Views;