Routing
@gyral/router keeps routing inside the loop. The route is model state, derived from the URL;
navigating is a command; and every URL change arrives as a message. The route table is pure, so
the server matches requests with the same table.
npm install @gyral/routerRoute tables
// src/routes.ts
import { routes } from '@gyral/router';
export const app = routes({
home: '/',
user: '/users/:id',
settings: '/settings',
});app.match('/users/7/')returns{ name: 'user', params: { id: '7' }, path: '/users/7' }, orundefined. The result is a union discriminated byname, andparamsis typed from the pattern.pathis the route's canonical path,href(name, params): see One URL per page.app.href('user', { id: 'a b' })builds/users/a%20b.- Patterns support literal segments, required
:params, optional:params?and trailing:path*captures, with the same behavior in URLPattern and Gyral's fallback. One trailing slash is ignored, an empty segment (/users//7) never matches, params are decoded, and the first match in table order wins. - Matching never touches
window, so it's safe on a server.
Optional segments and catch-all routes
// src/content-routes.ts
import { routes } from '@gyral/router';
export const contentRoutes = routes({
product: '/products/:id?',
docs: '/docs/:path*',
notFound: '/:path*',
});
export const products = contentRoutes.href('product', {}); // /products
export const guide = contentRoutes.href('docs', { path: ['guide', 'a/b'] });
// /docs/guide/a%2Fb: an encoded slash stays in one captured segment.An omitted optional parameter is absent from params. A catch-all always returns a readonly
array, including [] when it captures no segments. Catch-all segments must be last, and the
first matching route wins: put the not-found route last and explicitly return HTTP 404 from
your server when it matches. Selecting that route alone does not change the response status.
href rejects missing required parameters, empty or dot segments, and ambiguous optional
omissions. For /:first?/:second?, { second: 'x' } would match as { first: 'x' }; use a
literal separator instead. Bare wildcards, regexes and groups are outside the route grammar.
Listening to navigation
// src/shell.ts
import { define, focus, html, type Head } from '@gyral/core';
import { listen, setHead, type RouteLocation, type RouteMatch } from '@gyral/router';
import { app } from './routes.js';
const ORIGIN = 'https://example.com';
type Route = RouteMatch<typeof app.table> | undefined;
export interface State {
readonly route: Route;
}
export type Msg = { readonly _tag: 'Routed'; readonly location: RouteLocation };
/** One pure function names every page, so the server can render the same <title>. */
export const pageTitle = (route: Route): string =>
route === undefined
? 'Not found'
: route.name === 'user'
? `User ${route.params.id}`
: route.name;
/** The page's head, used by the server's renderPage() and by setHead() after a navigation. */
export const pageHead = (route: Route): Head => ({
title: `${pageTitle(route)} · Example`,
description: 'Users and settings.',
...(route === undefined
? { robots: 'noindex' }
: { canonical: new URL(route.path, ORIGIN).href }),
});
export const Shell = define<State, Msg>()('my-shell', {
// The page itself, so its content is light DOM and it captures same-origin link clicks.
shadow: false,
init: () => ({ route: undefined }),
// Every location, for as long as the shell is on the page.
subscriptions: () => [listen((location): Msg => ({ _tag: 'Routed', location }))],
update: {
Routed: (_s, m) => {
const route = app.match(m.location.href);
const head = setHead(pageHead(route));
// After a navigation (not on the page load), move focus to the new page's heading.
return [{ route }, m.location.seq === 0 ? [head] : [head, focus('main h1')]];
},
},
view: (s) => html`
<nav aria-label="Main">
<a href=${app.href('home', {})}>Home</a>
<a href=${app.href('user', { id: '7' })}>User 7</a>
<a href=${app.href('settings', {})}>Settings</a>
</nav>
<main><h1 tabindex="-1">${pageTitle(s.route)}</h1></main>
`,
});listen(toMsg) is a long-running command, listed in the component's
subscriptions. It delivers the current location at once, then
every change (link clicks, navigate(), back and forward), while the component is on the page.
Link capture is the router's own option: the app's entry provides the router once,
provideDrivers(document.body, { router: makeRouter({ captureLinks: true }) }).
Each location carries a seq, which counts the URL changes the router has seen: 0 is the URL
the page loaded with. The view is a pure function of the route in state.
Navigating
Navigation is a command, returned from a reducer like any other:
| Command | Does |
|---|---|
navigate(url, options?) |
Push a history entry (replace: true replaces it; scroll, focusReset: see below) |
setHead(head) |
Make the document's head match a Head (see below) |
For a Back or Forward button, send the router a traversal: command(router, { _tag: 'Traverse', delta: -1 }), with router from @gyral/router.
// src/save.ts
import type { Command } from '@gyral/core';
import { navigate } from '@gyral/router';
import { app } from './routes.js';
/** After saving, go to the user's page without adding a history entry. */
export const afterSave = (id: string): Command<never> =>
navigate(app.href('user', { id }), { replace: true });The router uses the Navigation API where the browser has it and the History API elsewhere, so both behave the same. Browsers with the Navigation API never download the History API code.
Scroll and focus
After a navigation, the router does what the browser does for a page load, once the new page has rendered:
- Scroll: to the
#fragmenttarget, or to the top, after a push. Back and forward restore the position the page was left at. - Focus: to the first
[autofocus]element, or back to the start of the page, so the next Tab starts at the top of the new page instead of on a link that's gone.
A replace leaves both alone, because apps replace the entry to keep the URL in step with what
the user is doing (Updating the query); pass scroll: true or
focusReset: true to opt in. Both happen with or without the Navigation API. The browser looks
for a fragment's target in the document only, so render targets in light DOM: page-level
components with shadow: false, like the shell above.
Focus at the start of the page tells a screen reader user little about where they landed. Move it to
the new page's heading instead, as the shell does: focus('main h1') from the Routed reducer,
with tabindex="-1" on the heading so it can take focus. Skip it for the first location
(location.seq === 0), which is the page load, not a navigation. Focus that your app moves during a
navigation wins: the router doesn't reset it. If the heading appears only after data loads, focus it
from the message that brings the data.
To manage scroll or focus yourself, turn either off for one navigation, with
navigate(url, { scroll: false, focusReset: false }), or for every navigation the router
starts, link clicks and back and forward included, with
makeRouter({ scroll: false, focusReset: false }). Memory history ignores both.
Updating the query
A search box or a set of filters often keeps its state in the query string, so the URL can be
shared and survives a reload. Update the query with navigate too, not with
history.replaceState, which the router never sees: its location, and every listen(), would
be one step behind. Replace the entry, so each keystroke doesn't add one. A replace leaves
scroll and focus where they are:
// src/product-filters.ts
import { define, html } from '@gyral/core';
import { listen, navigate, type RouteLocation } from '@gyral/router';
export interface State {
readonly query: string;
}
export type Msg =
| { readonly _tag: 'Routed'; readonly location: RouteLocation }
| { readonly _tag: 'Typed'; readonly query: string };
export const ProductFilters = define<State, Msg>()('my-product-filters', {
init: () => ({ query: '' }),
subscriptions: () => [listen((location): Msg => ({ _tag: 'Routed', location }))],
intent: { Typed: ({ value }) => ({ _tag: 'Typed', query: value ?? '' }) },
update: {
// The URL is the source of truth: a shared link or back and forward set the box.
Routed: (_s, m) => ({ query: new URLSearchParams(m.location.search).get('q') ?? '' }),
Typed: (_s, m) => [
{ query: m.query },
[navigate(`?q=${encodeURIComponent(m.query)}`, { replace: true })],
],
},
view: (s, i) => html`
<search>
<label for="q">Filter products</label>
<input id="q" type="search" value=${s.query} data-intent=${i.Typed} />
</search>
`,
});The page stays where it is and focus stays in the box while the user types. A relative URL such
as ?q=… keeps the current path.
Links
Links stay plain <a href> elements. They work before JavaScript loads, open in new tabs, and
are what crawlers follow.
Link capture is opt-in: makeRouter({ captureLinks: true }) turns same-origin clicks into
in-app navigations, and only while some component is listening. That's right for an app that
owns the whole page. In a multi-page, server-rendered site where only one component routes, leave
it off, so ordinary links keep loading pages.
Captured clicks skip modified clicks (new tab), target other than _self, download,
rel="external", other origins and same-page #hash links. Anchors inside shadow roots work.
linkRoot: element limits capture to one part of the page.
The head
A page's head is one value, a Head from @gyral/core: title, description, canonical,
robots, meta (Open Graph and other name/property tags), links (alternates, icons),
jsonLd, lang and dir. Compute it with a pure function, pageHead above, and use it twice:
- On the server, spread it into
renderPage(), which writes each entry once, right after<title>, markeddata-gyral-head. - In the browser, return
setHead(pageHead(route))from theRoutedreducer. It changes only the elements it manages: entries the new head drops are removed, and head markup you wrote yourself is left alone. The firstRoutedafter hydration finds the server's head and changes nothing.
So a client navigation leaves exactly the head a page load would, and the two can't disagree. Titles that depend on data, such as a product's name, come from state the same way. The page owns its head: components don't add to it, because the server writes the head before any component renders.
canonicalis absolute: build it frommatch().pathand your origin (One URL per page).- Stylesheets, preloads and the charset aren't head entries. They belong to
renderPage()'s own options (stylesheets,modulepreload,headScripts) orextraHead, because changing them on navigation would unstyle the page or refetch modules. Development builds reject them in aHead. - JSON-LD is data, not script, so
script-srcdoesn't apply to it. Under an enforced Trusted Types policy,setHeadleaves JSON-LD as the server wrote it and warns once.
Server-side routes
On the server, match the request with the same table, choose the status, and render:
// server/app.ts
import { Hono } from 'hono';
import { html } from '@gyral/core';
import { renderPage } from '@gyral/ssr';
import { app as routes } from '../src/routes.js';
import { pageHead } from '../src/shell.js';
export const server = new Hono();
server.get('*', (c) => {
const url = new URL(c.req.url);
const route = routes.match(url);
// One URL per page: /users/7/ redirects to /users/7.
if (route && route.path !== url.pathname) {
return Response.redirect(new URL(route.path + url.search, url), 301);
}
return renderPage(
{
...pageHead(route),
body: html`<my-shell></my-shell>`,
scripts: ['/src/entry-client.ts'],
},
{ status: route === undefined ? 404 : 200 },
);
});One URL per page
The router ignores a trailing slash, so /users/7 and /users/7/ match the same route. A page
that answers at two URLs splits its links and its search ranking, and caches store it twice.
match() returns the route's canonical path (also the canonical spelling of
percent-escapes), so the server redirects every other spelling to it, as above, and keeps the
query string. path is a fixed point: matching it gives the same route and the same path.
The first render
The server doesn't run listen() (commands never run on the server), so the shell above is
server-rendered with no route and gets one only after hydration, when listen() delivers the
URL. Pass the request path as a prop and start from it in init if the first render must show
the route, as Gyral's isomorphic example does: then listen() delivers
the same URL and nothing changes.
Memory history
makeRouter({ history: 'memory', initial: '/settings' }) keeps history in memory: push, replace,
back and forward work, and the real URL and title are never touched. Use it in tests, and on
servers. driver.snapshot() returns the router's { href, head, length } for assertions, head
being the last setHead() value.