Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/client-side-navigation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@doc-kit/generator-react': minor
---

feat(html): navigate between pages client-side with the Navigation API, prefetching them on hover, keep the search index and remote config in memory across pages, scope speculation rules to links that leave the site, and hash fonts (reported through the bundler's new `fonts`) so every asset can be cached immutably
3 changes: 2 additions & 1 deletion .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,8 @@
{
"files": [
"packages/node-legacy/src/legacy-html/assets/*.js",
"packages/react/src/html/ui/**/*"
"packages/react/src/html/ui/**/*",
"e2e/**/*.spec.js"
],
"globals": {
"AsyncDisposableStack": "readonly",
Expand Down
33 changes: 33 additions & 0 deletions docs/publishing.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,39 @@ convention). Two things to know about the result:
index alongside the pages by targeting both generators — `target: ['html',
'orama-db']`, so the search box has data to query.

## Cache the assets

Every file the `html` generator writes to `assets/` (scripts, stylesheets,
fonts) is named after a hash of its content, so a changed file always gets a
new name. Serve that directory with a long-lived, immutable cache, and let
everything else (the pages, the search index) revalidate:

```
/assets/* Cache-Control: public, max-age=31536000, immutable
```

Most hosts default to revalidating every file on every load instead, which
costs a request per asset each time a new tab opens the site. On Vercel:

```json displayName="vercel.json"
{
"headers": [
{
"source": "/assets/(.*)",
"headers": [
{
"key": "Cache-Control",
"value": "public, max-age=31536000, immutable"
}
]
}
]
}
```

Within a visit, moving between pages loads no assets at all: the site
navigates client-side, swapping in the next page's content.

## Tell doc-kit its public URL

Set `baseURL` to where the site will live. Generators that emit absolute
Expand Down
124 changes: 124 additions & 0 deletions e2e/client-side-navigation.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { expect, test } from '@playwright/test';

const REMOTE_CONFIG_URL = 'https://nodejs.org/site.json';

/**
* Navigates the way following a link does, and waits for the navigation to
* finish.
*/
const navigate = (page, url) =>
page.evaluate(url => navigation.navigate(url).finished.then(() => {}), url);

test.describe('Client-side navigation', () => {
test.beforeEach(async ({ page }) => {
await page.route(REMOTE_CONFIG_URL, route =>
route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
websiteBanners: { index: { text: 'Important announcement' } },
}),
})
);

await page.goto('/assert.html');

// A full load would start a new document, and lose this
await page.evaluate(() => (window.__document = 'first'));
});

test('swaps the next page in without loading assets again', async ({
page,
}) => {
const loaded = await page.evaluate(() =>
[
...document.querySelectorAll(
'script[src], link[rel="stylesheet"], link[as="font"]'
),
].map(element => element.src || element.href)
);

const requests = [];
page.on('request', request => requests.push(request.url()));

await navigate(page, 'all.html');

await expect(page).toHaveURL(/\/all\.html$/);
await expect(page).toHaveTitle(/^All \|/);
await expect(page.locator('meta[property="og:title"]')).toHaveAttribute(
'content',
/^All \|/
);
expect(await page.evaluate(() => window.__document)).toBe('first');

// The scripts, stylesheets and fonts are still loaded
expect(requests.filter(url => loaded.includes(url))).toEqual([]);
});

test('goes back to the previous page, where it was scrolled to', async ({
page,
}) => {
await page.evaluate(() => scrollTo(0, 2000));
await navigate(page, 'all.html');
await page.evaluate(() => navigation.back().finished.then(() => {}));

// Hosts with clean URLs redirect the first page to one without `.html`
await expect(page).toHaveURL(/\/assert(\.html)?$/);
await expect(page).toHaveTitle(/^Assert \|/);
expect(await page.evaluate(() => scrollY)).toBe(2000);
expect(await page.evaluate(() => window.__document)).toBe('first');
});

test('prefetches a page as its link is hovered', async ({ page }) => {
await page.evaluate(() =>
document
.querySelector('main')
.insertAdjacentHTML('beforeend', '<a id="all" href="all.html">All</a>')
);

// Hosts with clean URLs answer the prefetch with a redirect first
const prefetched = page.waitForResponse(
response => /\/all(\.html)?$/.test(response.url()) && response.ok()
);

await page.hover('#all');
await prefetched;

const requests = [];
page.on('request', request => requests.push(request.url()));

await page.click('#all');

await expect(page).toHaveTitle(/^All \|/);
expect(requests).toEqual([]);
});

test('keeps the remote config, and shows its banner at once', async ({
page,
}) => {
const banner = page.getByRole('region', { name: 'Announcement' });
await expect(banner).toBeVisible();

let fetched = 0;

await page.route(REMOTE_CONFIG_URL, route => {
fetched++;
return route.fallback();
});

await navigate(page, 'all.html');

await expect(banner).toBeVisible();
// It animates in on the first page only
await expect(banner).toHaveCSS('animation-name', 'none');
expect(fetched).toBe(0);
});

test('leaves links to files that are not pages to the browser', async ({
page,
}) => {
await page.getByRole('link', { name: 'JSON' }).click();

await expect(page).toHaveURL(/\/assert\.json$/);
expect(await page.evaluate(() => window.__document)).toBeUndefined();
});
});
58 changes: 50 additions & 8 deletions packages/react/src/html/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,10 +215,12 @@ generator's `constants.mjs`), so that the page and the library share one Preact.
`buildClient` receives `{ entry, virtualImports, config }`. The client `entry`
is a single program shared by every page. It must be bundled into
`config.output` and the call must return
`{ scripts, preloads, stylesheets }`: paths relative to the output root of the
module scripts to load, the chunks they statically import (rendered as
`modulepreload` hints), and the stylesheets. The generator renders those into
every page, resolved against the page's location.
`{ scripts, preloads, stylesheets, fonts }`: paths relative to the output root
of the module scripts to load, the chunks they statically import (rendered as
`modulepreload` hints), the stylesheets, and optionally the fonts to preload.
The generator renders those into every page, resolved against the page's
location. Name every file after its content (a hash), so hosts can cache them
indefinitely (see [Publishing](../../../docs/publishing.md#cache-the-assets)).

`config` is the resolved `html` configuration. The adapter must compile the
generated Preact JSX and CSS imports and resolve the supplied theme aliases and
Expand Down Expand Up @@ -309,9 +311,10 @@ plugins see and can transform every module of the client and server builds but
never the HTML pages. Customize the pages through the
[HTML template](#html-template) instead.

The adapter reads the client asset names from Vite's manifest. A manifest is
written either way; pass `build: { manifest: true }` (or a file name) to
`createViteBundler` to keep it in the output for another tool.
The adapter reads the client asset names from Vite's manifest, including the
hashed names of the fonts to preload. A manifest is written either way; pass
`build: { manifest: true }` (or a file name) to `createViteBundler` to keep it
in the output for another tool.

The adapter is only ever used on the main thread, so function-valued plugins
and hooks are supported. Worker threads receive the `html` configuration with
Expand Down Expand Up @@ -488,7 +491,11 @@ The HTML template file (set via `templatePath`) uses JavaScript template literal
- `dehydrated` {string} Server-rendered HTML for the page content.
- `assets` {string} The `<script>` and `<link>` tags loading the client
assets, resolved against this page's location.
- `speculationRules` {string} Speculation rules JSON for prefetching.
- `preloads` {string} The preload hints for the fonts the bundler reported.
- `speculationRules` {string} Speculation rules JSON that prefetches the
same-origin links leaving the site when they are hovered or pressed. Links
within the site navigate client-side instead (see
[Client-side navigation](#client-side-navigation)).
- `themeScript` {string} Inline script that applies the saved theme before paint.
- `root` {string} Relative or absolute path to the site root.
- `metadata` {Object} Full page metadata (frontmatter, path, heading, etc.).
Expand All @@ -505,3 +512,38 @@ Since the template supports arbitrary JS expressions, you can use conditionals a
The populated page is the final HTML: it is minified when `minify` is set and
written as is. Put `${assets}` in the `<head>`, or the page loads no script and
no stylesheet.

## Client-side navigation

The site moves between its own pages client-side, through the
[Navigation API](https://developer.mozilla.org/docs/Web/API/Navigation_API):
following a link to another page fetches that page and swaps it into the
current document instead of loading a new one. Scripts, stylesheets and fonts
stay loaded, the search index and the remote config are fetched once per visit,
and the sidebar keeps its scroll position. Back and forward, scroll restoration
and focus behave as they do for full loads, and the old page cross-fades into
the new one where view transitions are supported.

Pages are prefetched into memory when a link is hovered (unless the browser
asks to save data) or pressed, so most navigations do not wait on the network.

Everything else is a regular navigation: links outside the site (including
other versions of the docs), files that are not pages (such as the JSON and
Markdown renderings), and every navigation in a browser without the Navigation
API. So is a page that loads scripts or stylesheets the current one did not,
such as one from a newer deployment.

When customizing the site, keep in mind that:

- The whole `<body>` is replaced, and scripts in it do not run. Of the
`<head>`, only the `<title>` and the page-specific tags follow the page:
`<meta>` tags, and `<link>` tags other than stylesheets and preloads (such as
`canonical`).
- Islands are unmounted and hydrated again on every page. Anything shared
across pages belongs in module scope, which lasts for the whole visit. An
island that scrolls keeps its position when the next page renders it too.
- Once the document has navigated client-side, the `<html>` element has a
`data-navigated` attribute, for styles that only belong on the first page,
such as entry animations.
- Analytics can count client-side page views through the Navigation API's
`navigatesuccess` event.
16 changes: 16 additions & 0 deletions packages/react/src/html/__tests__/generate.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import buildContent from '../../jsx-ast/utils/buildContent.mjs';
import { buildNotFoundPage } from '../../jsx-ast/utils/synthetic/404.mjs';
import { generate as chunk } from '../../section-pages/generate.mjs';
import { compile, createViteBundler } from '../bundlers/vite.mjs';
import { FONTS } from '../constants.mjs';
import { generate } from '../generate.mjs';

/**
Expand Down Expand Up @@ -105,6 +106,18 @@ describe('web generate', () => {
assert.match(fsHTML, /on:idle[^>]*data-island-name=SearchBox/);
// The manifest the asset tags were read from does not ship
assert.equal((await readdir(output)).includes('.vite'), false);

// Fonts are hashed like every other asset, and preloaded from where the
// bundler actually wrote them
const fonts = [...fsHTML.matchAll(/href=\.\.\/(assets\/[^ ]+\.woff2)/g)];
const written = await readdir(join(output, 'assets'));

assert.strictEqual(fonts.length, FONTS.length);

for (const [, font] of fonts) {
assert.match(font, /-[\w-]{8}\.woff2$/);
assert.ok(written.includes(font.slice('assets/'.length)), font);
}
});

it('assembles all.html from the module pages, in sidebar order', async context => {
Expand Down Expand Up @@ -349,6 +362,9 @@ describe('web generate', () => {
html,
/<script type=module crossorigin src=\.\/custom\/index\.js>/
);
assert.match(html, /data-router/);
// The adapter reported no fonts, so there are none to preload
assert.doesNotMatch(html, /as=font/);
assert.match(
html,
/<link rel=modulepreload crossorigin href=\.\/custom\/shared\.js>/
Expand Down
33 changes: 21 additions & 12 deletions packages/react/src/html/bundlers/vite.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { readFile, rm, rmdir } from 'node:fs/promises';
import { dirname, join, resolve } from 'node:path';
import { dirname, join, posix, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';

import {
Expand All @@ -10,7 +10,7 @@ import {
transformWithOxc,
} from 'vite';

import { FONT_DIRECTORY, JSX_PRAGMA, JSX_PRAGMA_FRAG } from '../constants.mjs';
import { FONTS, JSX_PRAGMA, JSX_PRAGMA_FRAG } from '../constants.mjs';

const VIRTUAL_PREFIX = 'virtual:doc-kit/';
const RESOLVED_VIRTUAL_PREFIX = '\0doc-kit:';
Expand Down Expand Up @@ -234,16 +234,10 @@ export const createViteConfig = ({
...vite.build?.rolldownOptions?.output,
format: 'es',

/**
* Determine the asset names for different files
*/
assetFileNames: asset =>
asset.names.some(name => name.endsWith('.woff2'))
? // We need to know where the fonts are to preload
// them. Using a dynamic hash would make this
// difficult.
`${FONT_DIRECTORY}/[name][extname]`
: 'assets/[name]-[hash][extname]',
// Every emitted file, fonts included, is named after its content, so
// hosts can cache `assets/` indefinitely. The fonts to preload are
// found through the manifest (see `buildClient`).
assetFileNames: 'assets/[name]-[hash][extname]',

...(server
? {
Expand Down Expand Up @@ -351,6 +345,15 @@ const collectImports = (manifest, chunk, seen = new Set()) => {
return [...seen].map(key => manifest[key].file);
};

/**
* Whether a manifest entry is one of the fonts to preload. Fonts get a manifest
* entry of their own, under the source file they were emitted from.
*
* @param {{ src: string }} entry
* @returns {boolean}
*/
const isPreloadedFont = ({ src }) => FONTS.includes(posix.basename(src));

/**
* Bundles the client entry into the site and reads back, from Vite's
* manifest, the assets every page has to load.
Expand Down Expand Up @@ -411,10 +414,16 @@ export const buildClient = async ({
.map(({ file }) => file)
.filter(file => file.endsWith('.css'));

const fonts = entries
.filter(({ src }) => src)
.filter(isPreloadedFont)
.map(({ file }) => file);

return {
scripts: [chunk.file],
preloads: collectImports(manifest, chunk),
stylesheets: [...new Set(stylesheets)],
fonts,
};
};

Expand Down
Loading
Loading