2026-08-05 04:18:37 +02:00
|
|
|
/*
|
|
|
|
|
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;
|
media: H.264 fallback beside every AV1, and the post links the H.264
The AV1-only publish lasted four hours in the wild: the first viewer on
mobile Safari got "bad media error" from the raw file, because a post's
link is fetched raw — Lemmy apps and browsers play that exact URL, with
no <source> negotiation in front of it. The previous commit's note
("--raw ... if that audience matters") had it backwards: the audience
that cannot play AV1 is not a per-post judgement call, it is whoever
happens to open the thread on an iPhone.
So publish-media.sh now emits two encodings and points the post at the
compatible one:
* Every video transcode also produces <hash>.h264.mp4 (x264 crf 23,
same denoise, same frames), uploaded beside the AV1 under the AV1's
hash — the poster's sibling-naming trick, reused, so fetch-media.sh
finds it by name with nothing to look up.
* The printed URL to paste into the post is the H.264 one. pict-rs
can thumbnail it too, so instance thumbnails come back as a bonus.
fetch-media.sh adopting an own-origin .h264.mp4 URL swaps the AV1 back
in as the page's primary when it is on the mount, and records the H.264
as `fallback` in posts.json. The renderer turns a non-empty fallback
into a <source> pair: the AV1 first with an explicit codecs parameter —
both files are video/mp4, so the parameter is the only thing that lets
a non-AV1 browser skip to the file it can play — and the H.264 second.
Browsers with AV1 keep downloading the small file; Safari before 17
gets one that plays instead of an element that will not.
e2e gains the matching conditional gate: a page offering an av01
<source> must offer an .h264.mp4 one, so an AV1 video can never again
ship without its fallback.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 03:14:51 +02:00
|
|
|
// H.264 rendition of an AV1 video, when tools/publish-media.sh uploaded one
|
|
|
|
|
// beside it. Non-empty means the renderer emits a <source> pair instead of
|
|
|
|
|
// a bare src, so a browser without AV1 (Safari before 17, Apple hardware
|
|
|
|
|
// without the decoder) gets a file it can play. Empty for images and for
|
|
|
|
|
// videos that need no fallback.
|
|
|
|
|
std::string fallback;
|
2026-08-05 04:18:37 +02:00
|
|
|
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;
|
2026-08-05 07:05:00 +02:00
|
|
|
// schema.org JSON-LD for this page, already serialized. Emitted verbatim
|
|
|
|
|
// inside <script type="application/ld+json"> — inert data, not code, so
|
|
|
|
|
// it does not count against the shop pages' one-executable-script rule.
|
2026-08-06 23:42:25 +02:00
|
|
|
// Set by RenderHome (an @graph of Organization + WebSite: who Catcrafts is
|
|
|
|
|
// and what this domain is, versus the two name-twins), RenderAbout
|
|
|
|
|
// (ProfilePage: who the founder is), RenderShop (ItemList: which product
|
|
|
|
|
// pages exist) and RenderProduct (ProductGroup: what is sold, per colour,
|
|
|
|
|
// at which prices — the same integers the checkout charges). About and
|
|
|
|
|
// the offers reference the home page's Organization by @id rather than
|
|
|
|
|
// restating it — and home's founder references about's Person the same
|
|
|
|
|
// way — so every page describes one entity; see the identity graph in
|
|
|
|
|
// RenderHome for why that join carries the weight it does.
|
2026-08-05 07:05:00 +02:00
|
|
|
std::string jsonLd;
|
2026-08-05 04:18:37 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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;
|
2026-08-05 23:52:40 +02:00
|
|
|
// The hardware manufacturer, for the Product schema's brand field.
|
|
|
|
|
// Deliberately NOT accompanied by a GTIN: the device is materially
|
|
|
|
|
// modified (different OS), and Google's product-data rules say a
|
|
|
|
|
// customized product must not carry the original manufacturer's GTIN —
|
|
|
|
|
// which also matches the legal framing that this is a Fairphone with
|
|
|
|
|
// different software, not a Catcrafts-manufactured phone. Empty = omit.
|
|
|
|
|
std::string brand;
|
2026-08-05 04:18:37 +02:00
|
|
|
// "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"));
|
media: H.264 fallback beside every AV1, and the post links the H.264
The AV1-only publish lasted four hours in the wild: the first viewer on
mobile Safari got "bad media error" from the raw file, because a post's
link is fetched raw — Lemmy apps and browsers play that exact URL, with
no <source> negotiation in front of it. The previous commit's note
("--raw ... if that audience matters") had it backwards: the audience
that cannot play AV1 is not a per-post judgement call, it is whoever
happens to open the thread on an iPhone.
So publish-media.sh now emits two encodings and points the post at the
compatible one:
* Every video transcode also produces <hash>.h264.mp4 (x264 crf 23,
same denoise, same frames), uploaded beside the AV1 under the AV1's
hash — the poster's sibling-naming trick, reused, so fetch-media.sh
finds it by name with nothing to look up.
* The printed URL to paste into the post is the H.264 one. pict-rs
can thumbnail it too, so instance thumbnails come back as a bonus.
fetch-media.sh adopting an own-origin .h264.mp4 URL swaps the AV1 back
in as the page's primary when it is on the mount, and records the H.264
as `fallback` in posts.json. The renderer turns a non-empty fallback
into a <source> pair: the AV1 first with an explicit codecs parameter —
both files are video/mp4, so the parameter is the only thing that lets
a non-AV1 browser skip to the file it can play — and the H.264 second.
Browsers with AV1 keep downloading the small file; Safari before 17
gets one that plays instead of an element that will not.
e2e gains the matching conditional gate: a page offering an av01
<source> must offer an .h264.mp4 one, so an AV1 video can never again
ship without its fallback.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 03:14:51 +02:00
|
|
|
pm.fallback = std::string(mv.Str("fallback"));
|
2026-08-05 04:18:37 +02:00
|
|
|
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
|