Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions docs/guide/pages-routing/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,65 @@ export default function PostPage() {
}
```

### Active links

`Link` marks the link to the current page with `aria-current="page"` and `data-void-active`. Use them to style navigation:

```css
nav a[data-void-active] {
font-weight: 600;
}
```

A link is active only on its own path by default. Set `activeMatch="prefix"` to keep it active on nested pages:

::: code-group

```tsx [React]
<nav>
<Link href="/">Home</Link>
<Link href="/users" activeMatch="prefix">
Users
</Link>
</nav>
```

```vue [Vue]
<nav>
<Link href="/">Home</Link>
<Link href="/users" active-match="prefix">Users</Link>
</nav>
```

```svelte [Svelte]
<nav>
<Link href="/">Home</Link>
<Link href="/users" activeMatch="prefix">Users</Link>
</nav>
```

```tsx [Solid]
<nav>
<Link href="/">Home</Link>
<Link href="/users" activeMatch="prefix">
Users
</Link>
</nav>
```

:::

| Current URL | `href="/users"` | `href="/users" activeMatch="prefix"` |
| --------------- | --------------- | ------------------------------------ |
| `/users` | both attributes | both attributes |
| `/users?page=2` | both attributes | both attributes |
| `/users/42` | none | `data-void-active` |
| `/users-admin` | none | none |

`aria-current="page"` marks only the exact page, so select `[aria-current="page"]` to style it differently from its section. A link to `/` is active only on the home page.

Paths match the way pages are routed: letter case and trailing slashes count, while the query string and hash are ignored. Links to other origins and non-GET links are never active. An `aria-current` or `data-void-active` you pass to `Link` replaces the computed value.

## Scroll Restoration

Void manages scroll position for Pages automatically:
Expand Down
82 changes: 41 additions & 41 deletions docs/reference/api/pages.md

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion packages/react/src/plugin-pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,12 +170,13 @@ ${generateComponentManifest(scan, pagesDir)}

const PREAMBLE_SCRIPT = ${JSON.stringify(preambleScript)};

export async function renderPage(pageObj, assetTags) {
export async function renderPage(pageObj, assetTags, requestUrl) {
return renderReactPage({
pageObj,
assetTags,
components,
layoutTree,
requestUrl,
preambleScript: PREAMBLE_SCRIPT,
});
}
Expand Down
8 changes: 8 additions & 0 deletions packages/react/src/runtime/link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
} from 'react';
import {
handleLinkClick,
linkActiveAttributes,
linkPrefetchStrategies,
observeLinkVisible,
prefetchLink,
Expand All @@ -33,6 +34,7 @@ export function Link({
viewTransition,
prefetch = false,
cacheFor,
activeMatch,
onNavigate,
children,
style,
Expand All @@ -55,6 +57,11 @@ export function Link({
() => linkPrefetchStrategies({ prefetch, reloadDocument }),
[prefetch, reloadDocument],
);
// The router context changes with the route URL, so this follows navigations.
const activeAttributes = useMemo(
() => linkActiveAttributes(router, link, activeMatch),
[activeMatch, link, router],
);
const hoverDelay = router?._hoverDelay ?? 75;

const doPrefetch = useCallback(
Expand Down Expand Up @@ -227,6 +234,7 @@ export function Link({
ref={elementRef as RefObject<HTMLAnchorElement>}
href={link.href}
style={resolvedStyle}
{...activeAttributes}
{...rest}
{...eventHandlers}
>
Expand Down
4 changes: 3 additions & 1 deletion packages/react/src/runtime/pages-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export type ReactPagesServerConfig = Readonly<{
assetTags: { css: string; preloads: string; body: string };
components: Record<string, () => Promise<ComponentModule>>;
layoutTree: Record<string, Array<string>>;
requestUrl?: string;
preambleScript?: string;
}>;

Expand Down Expand Up @@ -55,6 +56,7 @@ export async function renderReactPage({
assetTags,
components,
layoutTree,
requestUrl,
preambleScript = '',
}: ReactPagesServerConfig): Promise<ReadableStream<Uint8Array>> {
const PageComponent = (await components[pageObj.component]()).default;
Expand All @@ -71,7 +73,7 @@ export async function renderReactPage({

element = React.createElement(
RouterContext.Provider,
{ value: createSsrRouter(pageObj.url || '/', pageObj.params ?? {}) },
{ value: createSsrRouter(pageObj.url || '/', pageObj.params ?? {}, requestUrl) },
React.createElement(
NavigationContext.Provider,
{ value: idleNavigationState() },
Expand Down
33 changes: 32 additions & 1 deletion packages/react/test/unit/link.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,40 @@
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vite-plus/test';
import { Link } from '../../src/runtime/link.tsx';
import { createSsrRouter } from 'void/pages-client';
import { RouterContext } from '../../src/runtime/context.ts';
import { Link, type LinkProps } from '../../src/runtime/link.tsx';

function renderAt(url: string | null, props: LinkProps): string {
return renderToStaticMarkup(
createElement(
RouterContext.Provider,
{ value: url === null ? null : createSsrRouter(url) },
createElement(Link, props, 'Users'),
),
);
}

describe('Link', () => {
it('marks the link to the current page', () => {
const current = renderAt('/users', { href: '/users' });
expect(current).toContain('aria-current="page"');
expect(current).toContain('data-void-active=""');

const nested = renderAt('/users/1', { href: '/users', activeMatch: 'prefix' });
expect(nested).not.toContain('aria-current');
expect(nested).toContain('data-void-active=""');

expect(renderAt('/users/1', { href: '/users' })).not.toContain('data-void-active');
expect(renderAt(null, { href: '/users' })).not.toContain('data-void-active');
});

it('lets passed attributes override the active state', () => {
const markup = renderAt('/checkout', { href: '/checkout', 'aria-current': 'step' });
expect(markup).toContain('aria-current="step"');
expect(markup).toContain('data-void-active=""');
});

it('preserves dot-segment relative hrefs when merging GET data', () => {
const markup = renderToStaticMarkup(
createElement(
Expand Down
4 changes: 2 additions & 2 deletions packages/solid/src/plugin-pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ import { serializePageData } from "void/pages-server";

${generateComponentManifest(scan, pagesDir)}

export async function renderPage(pageObj, assetTags) {
export async function renderPage(pageObj, assetTags, requestUrl) {
const PageComponent = (await components[pageObj.component]()).default;
const layoutIds = layoutTree[pageObj.component] || [];
const layoutComponents = await Promise.all(
Expand All @@ -81,7 +81,7 @@ export async function renderPage(pageObj, assetTags) {
value: pageObj.errors || {},
get children() {
return createComponent(RouterContext.Provider, {
value: createSsrRouter(pageObj.url || "/", pageObj.params || {}),
value: createSsrRouter(pageObj.url || "/", pageObj.params || {}, requestUrl),
get children() {
return createComponent(NavigationContext.Provider, {
value: () => idleNavigationState(),
Expand Down
7 changes: 7 additions & 0 deletions packages/solid/src/runtime/link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createMemo, onCleanup, onMount, splitProps, useContext } from 'solid-js
import type { JSX } from 'solid-js';
import {
handleLinkClick,
linkActiveAttributes,
linkPrefetchStrategies,
observeLinkVisible,
prefetchLink,
Expand Down Expand Up @@ -51,6 +52,7 @@ export function Link(props: LinkProps): JSX.Element {
'viewTransition',
'prefetch',
'cacheFor',
'activeMatch',
'onNavigate',
'children',
'style',
Expand All @@ -66,6 +68,10 @@ export function Link(props: LinkProps): JSX.Element {
const link = createMemo(() => resolveLink(local));
const strategies = createMemo(() => linkPrefetchStrategies(local));
const resolvedStyle = createMemo(() => mergeStyle(local.style));
// Reading router.url tracks the route URL, so this follows navigations.
const activeAttributes = createMemo(() =>
linkActiveAttributes(router, link(), local.activeMatch),
);

function doPrefetch() {
prefetchLink(router, link().href, local.cacheFor);
Expand Down Expand Up @@ -169,6 +175,7 @@ export function Link(props: LinkProps): JSX.Element {
el = r;
}}
href={link().href}
{...activeAttributes()}
{...rest}
style={resolvedStyle()}
{...eventHandlers()}
Expand Down
4 changes: 2 additions & 2 deletions packages/svelte/src/plugin-pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ import { serializePageData } from "void/pages-server";

${generateComponentManifest(scan, pagesDir)}

export async function renderPage(pageObj, assetTags) {
export async function renderPage(pageObj, assetTags, requestUrl) {
const PageComponent = (await components[pageObj.component]()).default;
const layoutIds = layoutTree[pageObj.component] || [];
const layoutComponents = await Promise.all(
Expand All @@ -111,7 +111,7 @@ export async function renderPage(pageObj, assetTags) {
pageProps: pageObj.props,
shared: pageObj.shared || {},
params: pageObj.params || {},
router: createSsrRouter(pageObj.url || "/", pageObj.params || {}),
router: createSsrRouter(pageObj.url || "/", pageObj.params || {}, requestUrl),
routeUrl: pageObj.url || "/",
navigation: idleNavigationState(),
stores: null,
Expand Down
2 changes: 1 addition & 1 deletion packages/svelte/src/runtime/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
let params = $state(init.params || {});
let routeUrl = $state(init.routeUrl || "/");
let navigation = $state(init.navigation || idleNavigationState());
const router = createRouterFacade(init.router, () => routeUrl, () => params);
const router = init.router ? createRouterFacade(init.router, () => routeUrl, () => params) : null;

// Pages and layouts keep the objects returned by useShared() and useParams()
// across navigations, so update them in place.
Expand Down
5 changes: 5 additions & 0 deletions packages/svelte/src/runtime/Link.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { getContext, onDestroy, onMount } from "svelte";
import {
handleLinkClick,
linkActiveAttributes,
linkPrefetchStrategies,
observeLinkVisible,
prefetchLink,
Expand All @@ -21,6 +22,7 @@
viewTransition = undefined,
prefetch: prefetchProp = false,
cacheFor = undefined,
activeMatch = undefined,
onNavigate = undefined,
children,
onclick: onclickProp = undefined,
Expand Down Expand Up @@ -116,6 +118,8 @@
resolveLink({ href, method, data, prefetch: prefetchProp, reloadDocument }),
);
const strategies = $derived(linkPrefetchStrategies({ prefetch: prefetchProp, reloadDocument }));
// Reading router.url tracks the route URL, so this follows navigations.
const activeAttributes = $derived(linkActiveAttributes(router, link, activeMatch));
const resolvedStyle = $derived(
style ? `touch-action: manipulation; ${style}` : "touch-action: manipulation",
);
Expand All @@ -125,6 +129,7 @@
<a
bind:this={el}
href={link.href}
{...activeAttributes}
{...rest}
style={resolvedStyle}
onclick={onClick}
Expand Down
2 changes: 2 additions & 0 deletions packages/void/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ Production non-CF Pages pass a filesystem-backed `ASSETS` provider to the genera

### Runtime Framework

Pages navigation payloads keep `url` as a path and query string. The render callback receives the full request URL separately; preserve its origin through each adapter's SSR router and router facade.

**Hono** is used as the runtime framework. The generated virtual module creates a Hono app with file-based routes and middleware. Route handlers receive a Hono `Context` object (`c`) and can use the full Hono API (`c.json()`, `c.text()`, `c.req.param()`, etc.) plus the Hono middleware ecosystem.

- `defineHandler(fn)` — wraps a route handler `(c: CloudContext) => R`, returns `TypedHandler<{}, R>`
Expand Down
3 changes: 3 additions & 0 deletions packages/void/skills/void/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ the app's existing saved destination, including when the target project is new.
guides and use `void info` to preserve resource names and instance keys.
- Declare application environment keys in `env.ts` and access them through
`void/env`; follow `docs/guide/env-vars.md` for local and production values.
- In Pages navigation, style the current link with `Link`'s `aria-current` and `data-void-active`
attributes (`activeMatch="prefix"` for sections) instead of comparing `useRouter().path`; see
`docs/guide/pages-routing/overview.md#active-links`.
- For platform setup, follow the pages linked from
`docs/guide/self-hosted-platform.md`. Use `void platform list` to find the
installation slug for maintenance. For recovery, use its documented resume
Expand Down
13 changes: 10 additions & 3 deletions packages/void/src/pages/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,14 @@ import { isSameOrigin } from './url.ts';
export { getValidationErrors, isModifiedEvent, mergeDataIntoHref } from './adapter-helpers.ts';
export {
handleLinkClick,
linkActiveAttributes,
linkPrefetchStrategies,
observeLinkVisible,
prefetchLink,
resolveLink,
type BaseLinkProps,
type LinkActiveAttributes,
type LinkActiveMatch,
type LinkNavigateEvent,
type LinkPrefetchStrategy,
type ResolvedLink,
Expand Down Expand Up @@ -170,7 +173,7 @@ export function createRouterFacade(
},

get url() {
return toUrl(getCurrentUrl());
return new URL(getCurrentUrl() || '/', router.url);
},

get path() {
Expand Down Expand Up @@ -207,11 +210,15 @@ export function createRouterFacade(
};
}

export function createSsrRouter(url = '/', params: Record<string, string> = {}): VoidRouter {
export function createSsrRouter(
url = '/',
params: Record<string, string> = {},
requestUrl?: string,
): VoidRouter {
const router = {
_hoverDelay: 75,
get url() {
return toUrl(url);
return new URL(url || '/', requestUrl ?? getUrlBase());
},
get path() {
return this.url.pathname;
Expand Down
Loading
Loading