/* catcrafts.net Copyright (C) 2026 Catcrafts The source code of this website is made available for viewing purposes only. No permission is granted to copy, modify, distribute, or create derivative works. */ // The page renderers — the reason Catcrafts.Shared exists. // // Each returns SafeHtml for the inner content of
, plus the PageMeta the // document head needs. The wasm app feeds the markup to SetInnerHTML; the // native server will wrap it in RenderDocument and send it as the response // body. One template per page, so a crawler and the app can never be looking // at different markup. // // Every renderer is a pure function of its inputs. No I/O, no globals, no DOM. // That is what makes them testable on the host (see server/implementations) // rather than only observable in a browser tab. export module Catcrafts.Shared:Views; import std; import :Content; import :Html; import :Form; import :Model; import :Money; import :Route; namespace Catcrafts::Views { using Html::SafeHtml; using Html::Escape; using Html::Autolink; using Html::Format; using Html::Num; using Html::Raw; using Html::Url; using Html::Attr; using Html::Join; export struct RenderedPage { PageMeta meta; SafeHtml main; // 404 for NotFound, 301 for a legacy redirect, 200 otherwise. The wasm app // ignores this; the server uses it as the response status. int status = 200; }; // ── chrome ──────────────────────────────────────────────────────────── // Nav is emitted as real links, not click-only elements. // // The previous version used `` with no href, because // Crafter.Graphics could not cancel a click and any real link would trigger a // full page load. That made the nav invisible to crawlers, un-middle-clickable // and unreachable by keyboard. The preventDefault support added to // AddClickListener is what lets these be honest links that the app upgrades to // client-side navigation. export SafeHtml RenderNav(RouteKind current) { std::vector items; for (const NavItem& item : NavItems()) { const bool active = item.kind == current; items.push_back(Format( R"(
  • {}
  • )", active ? Raw(" nav-link--active") : SafeHtml{}, Url("href", item.href), // aria-current is how a screen reader conveys "you are here"; // the active class alone is a purely visual signal. active ? Attr("aria-current", "page") : SafeHtml{}, Escape(item.label))); } // The mark: an ASCII cat drawn as paths — ^ ^ caret eyes, and the old ">_" // prompt kept as the mouth. Inline rather than an so it takes its // colours from the CSS variables (face = currentColor via .logo__mark, // prompt = --text) and costs no extra request. The underscore carries // class="logo-cursor", which styles.css blinks like a real terminal cursor // (and stops under prefers-reduced-motion). // // Same geometry as favicon.svg with hardcoded colours — change one, change // both, or the header and the tab icon drift apart. // // aria-label on the rather than text: on narrow viewports .logo__text // is display:none, which would leave the link with no accessible name. return Format( R"()", Url("href", "/"), Join(items)); } // Deliberately no link to the fediverse account. // // The account is used for plenty that has nothing to do with this work, so // pointing visitors at the profile would send them somewhere mostly irrelevant. // The posts page carries the individual posts instead, each linking to its own // thread — which is the part that is actually about the work. export SafeHtml RenderFooter() { return Format( R"()", Url("href", "https://forgejo.catcrafts.net/Catcrafts/"), Url("href", "https://forgejo.catcrafts.net/Catcrafts/catcrafts.net"), Url("href", "/legal/privacy"), Url("href", "/legal/terms"), Url("href", "/legal/imprint")); } // ── schema.org JSON-LD ──────────────────────────────────────────────── // // Machine-readable facts for crawlers and AI answer engines. This exists // because two name-twins (a Minecraft server on the singular domain, a cat // conservation program) kept absorbing the brand in search results and AI // overviews — one overview flatly asserted that nobody named Catcrafts sells // Fairphone hardware. Prose can be paraphrased away; typed identity and // offer data are what those systems quote. // JSON string escaping. Its own function rather than the HTML escaper // because the rules are different (backslash and quote, not ampersand). std::string JsonStr(std::string_view s) { std::string out; out.reserve(s.size() + 2); out += '"'; for (char c : s) { switch (c) { case '"': out += "\\\""; break; case '\\': out += "\\\\"; break; case '\n': out += "\\n"; break; case '\r': out += "\\r"; break; case '\t': out += "\\t"; break; // '<' so "" can never appear inside the ld+json block: // the HTML parser ends a \n"; // Wraps a rendered page in a complete HTML document. // // `bootScripts` is the \n"; } return std::format( "\n" "\n\n" "\n" // Only on pages that boot the wasm, and load-bearing there. // // Crafter.Build's runtime.js resolves everything it needs relative to the // DOCUMENT url: fetch("variants.json"), fetch("files.json"), every VFS // entry, and the .wasm named by variants.json. At "/" that is correct; at // /demos/raytracer it asks for /demos/variants.json, gets Caddy's // try_files fallback (index.html), and the whole boot collapses — four // NS_ERROR_CORRUPTED_CONTENT failures and a blank demo. // // A fixes all of them at once because a bare relative fetch() in a // module resolves against the document base URL. Safe here only because // every href/src/action this renderer emits is already absolute, so // nothing else changes meaning — there is an e2e check pinning that. "{}" "\n" // Tells catcrafts-head.js that the server already set the head, so it // does not overwrite an SSR'd title with the generic one. "\n" "{}\n" "{}{}{}{}{}" "\n" "\n" "\n" "{}" "\n\n" "
    \n" "
    {}
    \n" "
    {}
    \n" "
    {}
    \n" "
    \n" "\n\n", bootScripts.empty() ? "" : "\n", Html::Escape(page.meta.title).Str(), page.meta.description.empty() ? std::string{} : std::format("\n", Html::Attr("content", page.meta.description).Str()), page.meta.noindex ? "\n" : "", page.meta.refreshSeconds > 0 ? std::format("\n", page.meta.refreshSeconds) : std::string{}, page.meta.geoPriceHint ? kGeoPriceHintScript : std::string_view{}, (canonical.empty() ? std::string{} : canonical + "\n") + headExtras, cssHref, bootScripts, nav.Str(), page.main.Str(), footer.Str()); } } // namespace Catcrafts::Views