//SPDX-License-Identifier: LGPL-3.0-only //SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // Router implementation. The popState handler table lives in // Crafter.Graphics-Dom.cpp (alongside every other event-kind table); // we reach it through the opaque `PopStateRegister`/`PopStateUnregister` // helpers it exposes, avoiding any cross-TU duplication of the // HandlerTable definition. module; module Crafter.Graphics; import std; namespace Crafter::DomBindings { __attribute__((import_module("env"), import_name("pushState"))) void PushState(const char* data, std::int32_t dataLength, const char* title, std::int32_t titleLength, const char* url, std::int32_t urlLength); __attribute__((import_module("env"), import_name("addPopStateListener"))) void AddPopStateListener(std::int32_t id); __attribute__((import_module("env"), import_name("removePopStateListener"))) void RemovePopStateListener(std::int32_t id); __attribute__((import_module("env"), import_name("replaceState"))) void ReplaceState(const char* data, std::int32_t dataLength, const char* title, std::int32_t titleLength, const char* url, std::int32_t urlLength); __attribute__((import_module("env"), import_name("getPathName"))) const char* GetPathName(); __attribute__((import_module("env"), import_name("getSearch"))) const char* GetSearch(); __attribute__((import_module("env"), import_name("getHash"))) const char* GetHash(); __attribute__((import_module("env"), import_name("getHref"))) const char* GetHref(); __attribute__((import_module("env"), import_name("navigate"))) void Navigate(const char* url, std::int32_t urlLength, bool replace); // Defined in Crafter.Graphics-Dom.cpp. std::int32_t PopStateRegister(std::function cb); void PopStateUnregister(std::int32_t id); } namespace Crafter::Router { void PushState(std::string_view data, std::string_view title, std::string_view url) { Crafter::DomBindings::PushState( data.data(), static_cast(data.size()), title.data(), static_cast(title.size()), url.data(), static_cast(url.size())); } std::int32_t AddPopStateListener(std::function callback) { std::int32_t id = Crafter::DomBindings::PopStateRegister(std::move(callback)); Crafter::DomBindings::AddPopStateListener(id); return id; } void RemovePopStateListener(std::int32_t id) { Crafter::DomBindings::RemovePopStateListener(id); Crafter::DomBindings::PopStateUnregister(id); } void ReplaceState(std::string_view data, std::string_view title, std::string_view url) { Crafter::DomBindings::ReplaceState( data.data(), static_cast(data.size()), title.data(), static_cast(title.size()), url.data(), static_cast(url.size())); } // The four location accessors share the same shape: the JS side // WasmAllocs a NUL-terminated UTF-8 copy, we adopt it into a // std::string and free the raw buffer. A null return means the JS // allocation failed. namespace { std::string AdoptJsString(const char* raw) { if (!raw) return {}; std::string out(raw); std::free(const_cast(raw)); return out; } } std::string GetPath() { return AdoptJsString(Crafter::DomBindings::GetPathName()); } std::string GetSearch() { return AdoptJsString(Crafter::DomBindings::GetSearch()); } std::string GetHash() { return AdoptJsString(Crafter::DomBindings::GetHash()); } std::string GetHref() { return AdoptJsString(Crafter::DomBindings::GetHref()); } void Navigate(std::string_view url, bool replace) { Crafter::DomBindings::Navigate( url.data(), static_cast(url.size()), replace); } // ─── Query-string parsing ───────────────────────────────────────── // // Pure string work, no JS round-trip. Kept here rather than left to // callers because every consumer would otherwise hand-roll percent // decoding, and getting `+` vs `%20` wrong is the usual bug. namespace { int HexVal(char c) { if (c >= '0' && c <= '9') return c - '0'; if (c >= 'a' && c <= 'f') return c - 'a' + 10; if (c >= 'A' && c <= 'F') return c - 'A' + 10; return -1; } } std::string PercentDecode(std::string_view in) { std::string out; out.reserve(in.size()); for (std::size_t i = 0; i < in.size(); ++i) { const char c = in[i]; if (c == '+') { // application/x-www-form-urlencoded encodes space as '+'. out.push_back(' '); } else if (c == '%' && i + 2 < in.size()) { const int hi = HexVal(in[i + 1]); const int lo = HexVal(in[i + 2]); if (hi >= 0 && lo >= 0) { out.push_back(static_cast(hi * 16 + lo)); i += 2; } else { // Malformed escape: pass the '%' through rather than // dropping input. Lets a stray '%' in a search box // survive a round trip. out.push_back(c); } } else { out.push_back(c); } } return out; } std::vector> ParseQuery(std::string_view query) { std::vector> out; if (!query.empty() && query.front() == '?') query.remove_prefix(1); while (!query.empty()) { const std::size_t amp = query.find('&'); std::string_view pair = query.substr(0, amp); query = (amp == std::string_view::npos) ? std::string_view{} : query.substr(amp + 1); if (pair.empty()) continue; // tolerate "a=1&&b=2" and a trailing '&' const std::size_t eq = pair.find('='); if (eq == std::string_view::npos) { // Valueless key ("?debug") — present, empty value. out.emplace_back(PercentDecode(pair), std::string{}); } else { out.emplace_back(PercentDecode(pair.substr(0, eq)), PercentDecode(pair.substr(eq + 1))); } } return out; } std::optional QueryGet(std::string_view query, std::string_view key) { for (auto& [k, v] : ParseQuery(query)) { if (k == key) return v; } return std::nullopt; } }