/* 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. */ export module Catcrafts:Root_impl; import :Root; import :Views; import :Demo; import Crafter.Graphics; import Catcrafts.Shared; import std; using namespace Crafter; namespace Catcrafts { namespace { // Every element this render attached a listener to. // // HtmlElementPtr's destructor unregisters its handlers on both the C++ // and the JS side, so this vector IS the binding lifetime — clearing it // is the unbind step. It must be cleared BEFORE the innerHTML that owns // those elements is replaced: afterwards the handles point at detached // nodes, and their destructors call removeEventListener on orphans, // leaking a cookie in the JS handle table on every navigation. std::vector routeBindings; std::vector linkTargets; bool demoMounted = false; // Assign an id to every in-site link so getElementById can find it. The // Dom API has no querySelector, so anchors cannot be enumerated — either // the renderer emits ids or the app injects them. // // This only runs on the client-rendered fallback path (see below), where // this module produced the markup a moment earlier and knows its shape. // On a server-rendered page nothing here runs at all. std::string TagLinks(std::string_view html, std::vector& outTargets) { std::string out; out.reserve(html.size() + 64); std::size_t i = 0; while (i < html.size()) { const std::size_t open = html.find("', open); if (close == std::string_view::npos) { out.append(html.substr(open)); break; } const std::string_view tag = html.substr(open, close - open + 1); // Only same-origin paths are intercepted. External links, // mailto: and fragments keep their default behaviour — // hijacking those would break opening in a new tab and jumping // to an anchor. bool internal = false; std::string target; if (const std::size_t hrefPos = tag.find("href=\""); hrefPos != std::string_view::npos) { const std::size_t vs = hrefPos + 6; if (const std::size_t ve = tag.find('"', vs); ve != std::string_view::npos) { const std::string_view href = tag.substr(vs, ve - vs); if (!href.empty() && href[0] == '/' && !(href.size() > 1 && href[1] == '/')) { internal = true; target = std::string(href); } } } if (internal) { out.append(" elements, so // crawlers, middle-click and "copy link" all still work. link.AddClickListener([target](Dom::MouseEvent ev) { // Modified clicks and non-primary buttons keep their default // behaviour, so "open in new tab" survives. if (ev.button != 0 || ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey) { Router::Navigate(target, false); return; } NavigateTo(target); }, true); routeBindings.push_back(std::move(link)); } } } namespace { void MountDemoIfPresent(const Route& route) { if (route.kind != RouteKind::Demo) return; const Demo* d = SiteData().FindDemo(route.slug); if (!d || !d->needsWasm || d->mountId != kDemoMountId) return; MountDemo(); demoMounted = true; } } void RenderCurrentRoute() { const Route route = ParseRoute(Router::GetPath(), Router::GetSearch()); if (demoMounted) { UnmountDemo(); demoMounted = false; } // On a server-rendered page the DOM is already correct for this URL, so // the only thing left to do is mount the renderer if this is /demo. // // No re-render and no link interception. Re-rendering would replace // correct markup with identical markup and flash; intercepting links // would buy nothing, because every other route is served fully rendered // and a normal navigation is already fast. The wasm module exists on // this page for the ray tracer, not to be a router. if (AdoptedSsr()) { MountDemoIfPresent(route); return; } // ── client-rendered fallback ────────────────────────────────────── // // Only reached when the document came from the static shell — i.e. the // backend was down and Caddy served the wasm bundle's empty-bodied // index.html. Here the app is the whole site. if (!route.canonicalRedirect.empty()) { // Rewrite the address bar to the canonical path without adding a // history entry, so Back does not bounce between the old URL and // the new one. Router::ReplaceState("{}", "", route.canonicalRedirect); } // Unbind before the DOM those listeners point at is destroyed. routeBindings.clear(); linkTargets.clear(); const Views::RenderedPage page = Views::RenderRoute(route, SiteData()); MainContent().SetInnerHTML(TagLinks(page.main.View(), linkTargets)); // Re-render the nav so the active marker follows the route, tagged with // the SAME accumulator so nav ids continue the sequence instead of // restarting at cc-link-0 and colliding with the content links — // getElementById would then return whichever appeared first in the // document and half the links would navigate to the wrong place. const RouteKind navKind = route.kind == RouteKind::LegacyBlog ? RouteKind::Posts : route.kind; Dom::HtmlElementPtr header("cc-header"); if (header.ptr != 0) { header.SetInnerHTML(TagLinks(Views::RenderNav(navKind).View(), linkTargets)); } SetDocumentTitle(page.meta.title); BindLinks(); MountDemoIfPresent(route); } void NavigateTo(std::string_view path) { Router::PushState("{}", "", path); RenderCurrentRoute(); } }