/* 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, About, // /about — the person behind the company Projects, Posts, Demos, // /demos — the list Demo, // /demos/ Shop, // /shop — the (currently single-item) product list Product, // /shop/ Order, // /order/ — an order's status page Invoice, // /order//invoice.md — the signed invoice download Legal, // /legal/ — privacy, imprint, terms // The blog these routes replace. sitemap.xml advertised /blog and // /blog/, 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 of /shop/, 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 == "/about") { r.kind = RouteKind::About; 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/. 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//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/. 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 NavItems() { static constexpr std::array items{{ { "Home", "/", RouteKind::Home }, { "Shop", "/shop", RouteKind::Shop }, { "Projects", "/projects", RouteKind::Projects }, { "Posts", "/posts", RouteKind::Posts }, { "Demos", "/demos", RouteKind::Demos }, { "About", "/about", RouteKind::About }, }}; 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 SitemapPaths() { // /shop/ 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 paths{ "/", "/about", "/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