diff --git a/.changeset/client-side-navigation.md b/.changeset/client-side-navigation.md
new file mode 100644
index 000000000..2b7b0dc22
--- /dev/null
+++ b/.changeset/client-side-navigation.md
@@ -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
diff --git a/.oxlintrc.json b/.oxlintrc.json
index 41346e31b..a794496da 100644
--- a/.oxlintrc.json
+++ b/.oxlintrc.json
@@ -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",
diff --git a/docs/publishing.md b/docs/publishing.md
index 94b475804..5ab4f3233 100644
--- a/docs/publishing.md
+++ b/docs/publishing.md
@@ -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
diff --git a/e2e/client-side-navigation.spec.js b/e2e/client-side-navigation.spec.js
new file mode 100644
index 000000000..010129800
--- /dev/null
+++ b/e2e/client-side-navigation.spec.js
@@ -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', 'All')
+ );
+
+ // 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();
+ });
+});
diff --git a/packages/react/src/html/README.md b/packages/react/src/html/README.md
index 257d45171..cdb7dbc9e 100644
--- a/packages/react/src/html/README.md
+++ b/packages/react/src/html/README.md
@@ -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
@@ -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
@@ -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 ``,
'',
'',
'',
@@ -227,10 +280,10 @@ describe('buildAssetTags', () => {
);
});
- it('renders nothing for an empty asset list', () => {
+ it('renders only the router tag for an empty asset list', () => {
assert.strictEqual(
buildAssetTags({ scripts: [], preloads: [], stylesheets: [] }, './'),
- ''
+ ''
);
});
});
diff --git a/packages/react/src/html/utils/generate.mjs b/packages/react/src/html/utils/generate.mjs
index da6876d40..c8b0def57 100644
--- a/packages/react/src/html/utils/generate.mjs
+++ b/packages/react/src/html/utils/generate.mjs
@@ -158,17 +158,27 @@ export default () => {
),
createImportDeclaration(
- 'registerIslands',
+ 'registerIslands, unmountIslands, hydrated',
resolve(ROOT, './ui/islands/runtime.mjs'),
false
),
+ createImportDeclaration(
+ 'startRouter',
+ resolve(ROOT, './ui/router.mjs'),
+ false
+ ),
+
`registerIslands({${componentImports
.map(
({ name, source }) =>
`${JSON.stringify(name)}: () => import(${JSON.stringify(source)})`
)
.join(', ')}});`,
+
+ // Navigations between pages swap the page in place, so the islands of the
+ // page being left have to be unmounted rather than simply dropped
+ 'startRouter({ unmount: unmountIslands, islands: hydrated });',
].join('\n');
return { buildLibraryProgram, buildPageProgram, clientProgram };
diff --git a/packages/react/src/html/utils/processing.mjs b/packages/react/src/html/utils/processing.mjs
index 9d344e59e..fe00c81d0 100644
--- a/packages/react/src/html/utils/processing.mjs
+++ b/packages/react/src/html/utils/processing.mjs
@@ -1,7 +1,6 @@
import getConfig from '@doc-kit/core/utils/configuration/index.mjs';
import { populate } from '@doc-kit/core/utils/configuration/templates.mjs';
-import { FONT_DIRECTORY, FONTS, SPECULATION_RULES } from '../constants.mjs';
import { THEME_SCRIPT } from '../ui/theme-script.mjs';
import createConfigSource from './config.mjs';
import { relativeOrAbsolute } from './relativeOrAbsolute.mjs';
@@ -85,18 +84,63 @@ const renderTag = (tag, attrs) => {
};
/**
- * Renders the preload hints for a page
+ * The attributes of a font's preload hint. `crossorigin` is required: fonts
+ * are fetched in CORS mode, so without it the stylesheet fetches the font again
+ * instead of reusing the preloaded one.
+ *
+ * @param {string} href - The font's URL
+ * @returns {Record}
*/
-export const buildPreloads = root =>
- FONTS.map(font =>
- renderTag('link', {
- rel: 'preload',
- href: `${root}${FONT_DIRECTORY}/${font}`,
- as: 'font',
- type: 'font/woff2',
- crossorigin: true,
- })
- ).join('\n ');
+const createFontPreload = href => ({
+ rel: 'preload',
+ href,
+ as: 'font',
+ type: 'font/woff2',
+ crossorigin: true,
+});
+
+/**
+ * Renders the preload hints for a page's fonts.
+ *
+ * @param {Array} fonts - Output-relative font paths
+ * @param {string} root - The page's root (see {@link resolvePageRoot})
+ * @returns {string}
+ */
+export const buildPreloads = (fonts, root) =>
+ fonts
+ .map(font => renderTag('link', createFontPreload(`${root}${font}`)))
+ .join('\n ');
+
+/**
+ * Renders a page's speculation rules.
+ *
+ * Navigations between the site's own pages happen client-side (see
+ * `ui/router.mjs`), which prefetches those pages itself: a document the
+ * browser speculatively fetches can only serve a full navigation, so prefetching
+ * them here would download each twice. What is left are the links that leave
+ * the site for other pages on its origin (the rest of nodejs.org, for docs
+ * served under nodejs.org/docs): those are prefetched when hovered or pressed.
+ *
+ * @param {string} root - The page's root (see {@link resolvePageRoot})
+ * @returns {string} The rules, as JSON
+ */
+export const buildSpeculationRules = root => {
+ // Patterns resolve against the page, but a wildcard after a `/` takes that
+ // slash as its prefix and leaves the dot segment before it unresolved (`../*`
+ // matches nothing), while `..*` resolves to the directory, as intended.
+ const site = root.startsWith('.') ? `${root.slice(0, -1)}*` : `${root}*`;
+
+ return JSON.stringify({
+ prefetch: [
+ {
+ where: {
+ and: [{ href_matches: '/*' }, { not: { href_matches: site } }],
+ },
+ eagerness: 'moderate',
+ },
+ ],
+ });
+};
/**
* Builds the configurable `` markup shared by every page from the
@@ -119,12 +163,22 @@ export const buildHead = ({ meta = [], links = [], html = [] }) =>
* statically import as preload hints (as the bundler would inject them), and
* the stylesheets as links.
*
+ * Also emits a ``,
+ ],
scripts.map(
file => ``
),
@@ -180,9 +234,9 @@ export const populatePage = ({ template, data, dehydrated, assets }) => {
),
dehydrated,
assets: buildAssetTags(assets, root),
- speculationRules: SPECULATION_RULES,
+ speculationRules: buildSpeculationRules(root),
themeScript: THEME_SCRIPT,
- preloads: buildPreloads(root),
+ preloads: buildPreloads(assets.fonts ?? [], root),
root,
metadata: data,
config,
diff --git a/vercel.json b/vercel.json
index bd6fd8faa..deda36bcc 100644
--- a/vercel.json
+++ b/vercel.json
@@ -1,3 +1,14 @@
{
- "cleanUrls": true
+ "cleanUrls": true,
+ "headers": [
+ {
+ "source": "/assets/(.*)",
+ "headers": [
+ {
+ "key": "Cache-Control",
+ "value": "public, max-age=31536000, immutable"
+ }
+ ]
+ }
+ ]
}