rewrite
Some checks failed
Deploy / build-deploy (push) Failing after 4m56s

This commit is contained in:
Jorijn van der Graaf 2026-08-05 04:18:37 +02:00
commit 934c94cb5c
50 changed files with 10464 additions and 758 deletions

View file

@ -1,112 +0,0 @@
/*
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:Blog_impl;
import :Blog;
import :Root;
import :Views;
import :Demo;
import Crafter.Graphics;
import std;
using namespace Crafter;
namespace Catcrafts {
// Post that hosts the live ray-traced WebGPU demo. RenderBlogPost
// injects the mount container into this post and mounts the render
// canvas into it once the DOM is in place.
constexpr std::string_view kDemoPostSlug = "hello-world-2";
// Markup for the embedded demo: a fixed-height host box the render
// canvas is reparented into (see Catcrafts:Demo / WebGPU::SetCanvasMount)
// plus a caption. The id must match kDemoMountId.
constexpr std::string_view kDemoCardHtml = R"(
<div class="webgpu-demo">
<div id="webgpu-demo" class="webgpu-demo-canvas"></div>
<p class="webgpu-demo-caption">
Live above: a scene ray-traced in real time &mdash; four coloured
point lights, one soft shadow per light &mdash; running through the
Crafter.Graphics WebGPU wavefront tracer, driven from this page's
C++ compiled to WebAssembly. Not a video, not an iframe: the same
WASM module that rendered this text is tracing those pixels.
</p>
</div>)";
}
export namespace Catcrafts {
// Persistent storage for the per-post card click handlers. HtmlElementPtr
// unregisters its listeners on destruction, so the elements have to
// outlive Render(); clear() at the top of RenderBlog() detaches the
// previous render's handlers before we attach the new ones.
std::vector<Dom::HtmlElementPtr> blogButtons;
void RenderBlog() {
blogButtons.clear();
std::string html = "";
for(const BlogPost& post : posts) {
std::string previewContent = post.content;
if(previewContent.length() > 200) {
std::size_t lastSpace = previewContent.find_last_of(' ', 200);
if(lastSpace != std::string::npos) {
previewContent = previewContent.substr(0, lastSpace) + "...";
} else {
previewContent = previewContent.substr(0, 200) + "...";
}
}
html += std::format(R"(
<div class="post fade-in" id="blog-post-{}">
<div class="post-header">
<h2 class="post-title"><a>{}</a></h2>
<span class="post-date">{}</span>
</div>
<div class="post-content">
{}
</div>
<div class="post-footer">
<a class="btn">Read Full Post</a>
</div>
</div>)", post.slug, post.name, post.date, previewContent);
}
MainContent().SetInnerHTML(std::format(R"(<div class="blog-posts">{}</div>)", html));
for(const BlogPost& post : posts) {
Dom::HtmlElementPtr& cardView = blogButtons.emplace_back(std::format("blog-post-{}", post.slug));
cardView.AddClickListener([slug = post.slug](Dom::MouseEvent) {
Router::PushState("{}", "", std::format("/blog/{}", slug));
RenderRoot(std::format("/blog/{}", slug));
});
}
}
void RenderBlogPost(const std::string_view slug) {
for(const BlogPost& post : posts) {
if(post.slug == slug) {
const bool isDemo = post.slug == kDemoPostSlug;
MainContent().SetInnerHTML(std::format(R"(
<div class="blog-post-page">
<div class="post-header">
<h1 class="post-title">{}</h1>
<span class="post-date">{}</span>
</div>
<div class="post-content">
{}
</div>
{}
</div>)", post.name, post.date, post.content, isDemo ? kDemoCardHtml : std::string_view{}));
// The #webgpu-demo container now exists in the DOM, so the
// render canvas can be reparented into it and tracing can
// start. RenderRoot() already called UnmountDemo() for us.
if(isDemo) MountDemo();
return;
}
}
MainContent().SetInnerHTML("<h1>Post Not Found</h1><p>The requested blog post could not be found.</p>");
}
}

View file

@ -9,34 +9,189 @@ No permission is granted to copy, modify, distribute, or create derivative works
export module Catcrafts:Root_impl;
import :Root;
import :Views;
import :Blog;
import :Demo;
import Crafter.Graphics;
import Catcrafts.Shared;
import std;
using namespace Crafter;
namespace Catcrafts {
void RenderRoot(const std::string_view route) {
// Every route change replaces <main>'s innerHTML, which would
// orphan the demo canvas if it's currently mounted inside a post.
// Detach + hide it first; the post renderer re-mounts if needed.
UnmountDemo();
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;
std::string currentRoute = std::string(route);
bool demoMounted = false;
if(currentRoute == "/blog" || currentRoute == "/") {
RenderBlog();
} else if(currentRoute.rfind("/blog/", 0) == 0) {
std::size_t pos = currentRoute.find_last_of('/');
if(pos != std::string::npos) {
std::string postSlug = currentRoute.substr(pos + 1);
RenderBlogPost(postSlug);
} else {
MainContent().SetInnerHTML("<h1>Post Not Found</h1><p>The requested blog post could not be found.</p>");
// 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));
}
} else {
RenderBlog();
}
}
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();
}
}

View file

@ -9,43 +9,96 @@ No permission is granted to copy, modify, distribute, or create derivative works
export module Catcrafts:Views_impl;
import :Views;
import Crafter.Graphics;
import Catcrafts.Shared;
import std;
using namespace Crafter;
namespace Catcrafts {
namespace {
// Owning handle for the page-chrome root. `std::optional` lets us
// defer construction until InitializePage() runs (the CreateInBody
// call would otherwise fire at static-init time, before main()).
// Owning handle for the page chrome, ONLY when this module created it.
// std::optional defers construction until InitializePage() runs — a
// CreateInBody at static-init time would fire before main().
//
// Left empty when the server already rendered the chrome: the handle is
// owning, and destroying it would remove the server's markup from the
// document.
std::optional<Dom::HtmlElement> root;
// True when the document arrived server-rendered.
bool adoptedSsr = false;
Views::SiteContent content;
bool contentLoaded = false;
Window* activeWindow = nullptr;
}
void SetActiveWindow(Window* window) { activeWindow = window; }
void SetDocumentTitle(std::string_view title) {
// Only meaningful on the client-rendered path. On an SSR'd page the
// server already set a correct <title>, and overwriting it with the
// same string is pointless work.
if (activeWindow && !adoptedSsr) activeWindow->SetTitle(title);
}
bool AdoptedSsr() { return adoptedSsr; }
std::string ReadBundleFile(std::string_view name) {
// cfg.files flattens to the bundle root (Crafter.Build copies by
// filename, not by path), so "content/posts.json" is readable as
// "posts.json" here. runtime.js has already fetched every files.json
// entry into the VFS before _start, so this cannot block on the
// network and cannot fail for a file that is in the manifest.
std::ifstream in(std::string(name), std::ios::binary);
if (!in) return {};
std::ostringstream buf;
buf << in.rdbuf();
return buf.str();
}
const Views::SiteContent& SiteData() {
if (!contentLoaded) {
contentLoaded = true;
// Authored content is compiled into the module; only the
// pipeline-generated files ride the VFS.
content.projects = Content::Projects();
content.products = Content::Products();
content.legal = Content::LegalPages();
content.demos = Content::Demos();
content.posts = LoadPosts(ReadBundleFile("posts.json"));
content.rates = LoadRates(ReadBundleFile("rates.json"));
}
return content;
}
void InitializePage() {
// Two ways a page can arrive, and they need opposite handling.
//
// 1. Server-rendered (the normal case): #catcrafts-root already exists,
// with the chrome and the route's content in it. Building a second
// root here would duplicate the header and footer on screen, and
// re-rendering <main> would replace correct markup with identical
// markup — a wasted round of DOM work and a visible flash for
// nothing. So: adopt, and touch nothing.
//
// 2. The static fallback shell: Caddy serves the wasm bundle's
// index.html when the backend is down (see deploy/Caddyfile.example),
// and that document has an empty <body>. Here the app IS the whole
// site and has to build the chrome and render the route itself.
Dom::HtmlElementPtr existing("catcrafts-root");
if (existing.ptr != 0) {
adoptedSsr = true;
return;
}
root.emplace(Dom::HtmlElement::CreateInBody("div", "catcrafts-root"));
root->SetInnerHTML(R"(
<header>
<div class="nav-container">
<a href="/" class="logo">🐱 Catcrafts</a>
<nav>
<ul>
<li><a id="blog-nav-button" style="cursor: pointer;" class="active">Blog</a></li>
<li><a href="https://forgejo.catcrafts.net/Catcrafts/">Forgejo</a></li>
</ul>
</nav>
</div>
</header>
<main id="main"></main>
<footer>
<div class="footer-content">
<div class="footer-links">
Powered by Crafter.Graphics, Running near native with WASM!
<a href="https://forgejo.catcrafts.net/Catcrafts/catcrafts.net">View source</a>
</div>
<p>&copy; 2026 Catcrafts®. All rights reserved. Crafter® and Catcrafts® are registered trademarks with the EUIPO</p>
</div>
</footer>)");
root->SetInnerHTML(std::format(
R"(<header id="cc-header">{}</header>)"
R"(<main id="main"></main>)"
R"(<footer id="cc-footer">{}</footer>)",
Views::RenderNav(RouteKind::Home).Str(),
Views::RenderFooter().Str()));
}
}

View file

@ -22,27 +22,32 @@ int main() {
// surface to the canvas (full viewport, or the mount element).
static Window window(1280, 720, "Catcrafts");
// document.title is only reachable through Window::SetTitle, so give the
// router a way to set a per-route title without threading a Window
// through every render call.
SetActiveWindow(&window);
// Build the ray-tracing pipeline + scene now (runs StartInit/FinishInit).
// The render canvas starts hidden; the demo only traces once a route
// The render canvas starts hidden; it only traces once the /demo route
// mounts it into #webgpu-demo (see Catcrafts:Demo).
SetupDemo(window);
InitializePage();
Router::AddPopStateListener([]{
RenderRoot(Router::GetPath());
});
// Back/forward. The Router V1 callback carries no payload, so the route is
// re-read from window.location — which is the right thing regardless,
// since the location is the single source of truth for what should be on
// screen.
Router::AddPopStateListener([]{ RenderCurrentRoute(); });
static Dom::HtmlElementPtr blogButton("blog-nav-button");
blogButton.AddClickListener([](Dom::MouseEvent) {
Router::PushState("{}", "", "/blog");
RenderRoot("/blog");
});
RenderRoot(Router::GetPath());
RenderCurrentRoute();
window.Render();
window.StartUpdate();
// StartSync rather than StayAlive: /demo drives the ray tracer from the
// animation-frame loop, so the loop has to exist before that route is
// visited. A build of this site without the demo could call StayAlive
// instead and skip the permanent rAF tick entirely.
window.StartSync();
return 0;
}