Nice writeup, and you picked the right event, which is the part that usually trips people up here.
onPagesInitialized fires in PagesProcessor before $grav['page'] is resolved, and that service is what calls $pages->dispatch(). Since find() and dispatch() both hand back the same object out of the pages index, flipping routable(true) in your hook is genuinely seen by the dispatch that follows. Worth saying explicitly, because if you'd hooked onPageInitialized instead it would already be too late.
A few things I'd change before running this on a live site.
Pass true to find(). With the default $all = false, Pages::find() does something you don't want here: when the page isn't routable (exactly the state a module is in when your hook runs) it falls through to findSiteBasedRoute(), so a site redirect or wildcard matching that path can hand you back a completely different page. Your isModule() check then fails and the whole thing silently does nothing. $pages->find($uri->path(), true) skips that lookup.
Guard $grav['login']. If the Login plugin is disabled or missing, $this->grav['login'] throws and takes the site down on every non-GET request to any URL.
htmx.super has to actually exist. isAuthenticated($permission) runs the string through $user->authorize(), so unless you've added htmx.super: true to a group or an account it returns false for everyone except super admins, who pass everything. That may be what you meant, but the name reads like "super admins only" when it's really a custom permission you have to define yourself. Something like htmx.write is less confusing.
On your TODO about the 403: I wouldn't. Leaving the page non-routable means Grav serves its normal 404, which doesn't confirm the fragment is there. A 403 tells an unauthenticated caller "something exists at this URL, come back with credentials." For a fragment endpoint the 404 is the better answer. And returning from the hook wouldn't give you a 403 anyway, you'd have to throw a RequestException and let the error handler take it.
Hx-Request isn't a security boundary. Anyone can send it, so a plain curl with that header set gets your fragment. That's fine, and your frontmatter opt-in is the real gate, which is the right design. Just don't put anything in an htmx: true module you wouldn't put on a public URL.
The one that will actually bite you: full page caching. You now have a single URL returning two different bodies depending on a request header, and nothing varies the cache on it. Grav core is fine, since it caches parsed page content rather than final output, but advanced-pagecache keys on language, route and username only. So the first HTMX fetch caches the bare fragment against that route and the next normal browser hit to the same URL gets the fragment back. Same story with Cloudflare, Varnish or any CDN in front. If you run any of them, either exclude these routes or add Vary: HX-Request.
Here's the version I'd run:
public function onPagesInitialized(): void
{
$request = $this->grav['request'];
if (strtolower($request->getHeaderLine('Hx-Request')) !== 'true') {
return;
}
$uri = $this->grav['uri'];
// $all = true, so a site redirect matching this path can't substitute a different page
$page = $this->grav['pages']->find($uri->path(), true);
if (!$page || !$page->isModule() || empty($page->header()->htmx)) {
return;
}
// Writes need a real permission. Staying non-routable means Grav 404s,
// which doesn't confirm the fragment exists.
if (!in_array($uri->method(), ['GET', 'HEAD'], true)) {
$login = $this->grav['login'] ?? null;
if (!$login || !$login->isAuthenticated('htmx.write')) {
return;
}
}
$page->routable(true);
}
I've folded HEAD in with GET as well, since a HEAD is a read and your version was treating it as a write.
Thanks for posting this. It's a genuinely useful pattern and not one I've seen written up before.