2025-11-12 21:56:18 +01:00
|
|
|
/*
|
|
|
|
|
catcrafts.net
|
2026-07-18 23:27:41 +02:00
|
|
|
Copyright (C) 2026 Catcrafts
|
2025-11-12 21:56:18 +01:00
|
|
|
|
2026-07-18 23:27:41 +02:00
|
|
|
The source code of this website is made available for viewing purposes only.
|
2025-11-12 21:56:18 +01:00
|
|
|
No permission is granted to copy, modify, distribute, or create derivative works.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
export module Catcrafts:Root_impl;
|
|
|
|
|
import :Root;
|
|
|
|
|
import :Views;
|
2026-07-19 01:13:30 +02:00
|
|
|
import :Demo;
|
2026-07-18 23:27:41 +02:00
|
|
|
import Crafter.Graphics;
|
2026-08-05 04:18:37 +02:00
|
|
|
import Catcrafts.Shared;
|
2025-11-12 21:56:18 +01:00
|
|
|
import std;
|
|
|
|
|
|
2026-07-18 23:27:41 +02:00
|
|
|
using namespace Crafter;
|
|
|
|
|
|
2025-11-12 21:56:18 +01:00
|
|
|
namespace Catcrafts {
|
2026-08-05 04:18:37 +02:00
|
|
|
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<Dom::HtmlElementPtr> routeBindings;
|
|
|
|
|
std::vector<std::string> 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<std::string>& 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("<a ", i);
|
|
|
|
|
if (open == std::string_view::npos) {
|
|
|
|
|
out.append(html.substr(i));
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
out.append(html.substr(i, open - i));
|
|
|
|
|
const std::size_t close = 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("<a id=\"");
|
|
|
|
|
out.append(std::format("cc-link-{}", outTargets.size()));
|
|
|
|
|
out.append("\"");
|
|
|
|
|
out.append(tag.substr(2));
|
|
|
|
|
outTargets.push_back(std::move(target));
|
|
|
|
|
} else {
|
|
|
|
|
out.append(tag);
|
|
|
|
|
}
|
|
|
|
|
i = close + 1;
|
|
|
|
|
}
|
|
|
|
|
return out;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Mount the renderer only for the demo it actually implements.
|
|
|
|
|
//
|
|
|
|
|
// The compiled renderer IS the ray tracer — its scene, pipeline and
|
|
|
|
|
// shaders are specific to it — so it can only serve the demo whose
|
|
|
|
|
// mount element it knows. Checking the content entry's mountId against
|
|
|
|
|
// kDemoMountId means a second demo added to the compiled catalogue
|
|
|
|
|
// (Catcrafts.Shared:Content) gets its page and
|
|
|
|
|
// its card without silently hijacking this canvas, and a typo'd mountId
|
|
|
|
|
// shows up as a missing render rather than a mismatch nobody notices.
|
|
|
|
|
void MountDemoIfPresent(const Route& route);
|
|
|
|
|
|
|
|
|
|
void BindLinks() {
|
|
|
|
|
for (std::size_t k = 0; k < linkTargets.size(); ++k) {
|
|
|
|
|
Dom::HtmlElementPtr link(std::format("cc-link-{}", k));
|
|
|
|
|
if (link.ptr == 0) continue;
|
|
|
|
|
const std::string target = linkTargets[k];
|
|
|
|
|
// preventDefault = true is what makes this possible at all:
|
|
|
|
|
// without it the handler runs AND the browser performs a full
|
|
|
|
|
// page load. The links stay honest <a href> 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));
|
2025-11-12 21:56:18 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-05 04:18:37 +02:00
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
}
|
2026-07-18 23:27:41 +02:00
|
|
|
}
|