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 .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,8 @@ node
# Playwright
playwright-report/
test-results/

# Local documentation (ignored)
LOCAL_LINK_GUIDE.md
WEB_GENERATOR_GUIDE.md
CLIENT_LINK_GUIDE.md
39 changes: 39 additions & 0 deletions src/generators/web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,3 +128,42 @@ Since the template supports arbitrary JS expressions, you can use conditionals a
${importMap}
</script>
```

### Client-Side SPA Navigation (`ClientLink` & `navigate`)

`doc-kit` provides a built-in zero-reload navigation solution for static web output without requiring browser storage (`sessionStorage`/`localStorage`).

#### Features
- **Zero-Reload Navigation**: Intercepts internal link clicks (`<a>`), fetches target page HTML in the background, and updates the `#root` container.
- **Scroll Position Preservation**: Retains the sidebar (`<aside>`) scroll position in module memory across page transitions without resetting or requiring storage APIs.
- **View Transitions API Integration**: Automatically utilizes `document.startViewTransition()` if supported for smooth page crossfades.
- **TOC & Hash Link Support**: Smoothly handles in-page anchor links (`#heading-id`) and Table of Contents (TOC) navigation via `scrollIntoView`.
- **History & Popstate Handling**: Listens for `popstate` events to support browser Back and Forward buttons seamlessly.
- **Dynamic Preact Re-Hydration**: Automatically identifies and imports the new page's entrypoint module script (`<script type="module" src="...">`) with a timestamp query parameter after swapping `#root`. This triggers Preact's `hydrate()` on the freshly injected DOM, seamlessly re-attaching all synthetic event listeners (e.g., `CodeBox` copy buttons, tab switchers, interactive pickers) without any manual DOM manipulation or storage dependencies.

#### Usage

##### 1. Using `ClientLink` in Custom Components
Import `ClientLink` and pass it as the `as` prop to `<SideBar>` or use it directly as a drop-in replacement for `<a>`:

```jsx
import ClientLink, { navigate } from '@node-core/doc-kit/src/generators/web/ui/components/ClientLink/index.jsx';

export default ({ metadata }) => (
<SideBar
pathname={`/learn${metadata.path.replace('/index', '')}`}
groups={sidebar}
onSelect={navigate}
as={ClientLink}
title="Navigation"
/>
);
```

##### 2. Programmatic Navigation with `navigate()`
```javascript
import { navigate } from '@node-core/doc-kit/src/generators/web/ui/components/ClientLink/index.jsx';

// Navigate programmatically without a full page reload
navigate('/learn/getting-started.html');
```
203 changes: 203 additions & 0 deletions src/generators/web/ui/components/ClientLink/index.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
/**
* ClientLink – zero-reload navigation for doc-kit static sites.
*
* How it works:
* 1. Intercepts clicks on internal `<a>` links (via the component AND
* a global event-delegation listener so links inside swapped DOM
* keep working).
* 2. Fetches the target page's HTML in the background.
* 3. Replaces `#root` innerHTML with the new page's `#root` content,
* preserving the already-loaded CSS / JS assets in `<head>`.
* 4. Saves & restores the sidebar (`<aside>`) scroll position in a
* plain JS variable – no sessionStorage / localStorage needed.
* 5. Handles browser Back / Forward buttons via `popstate`.
*
* @module ClientLink
*/

// ---------------------------------------------------------------------------
// State kept in module scope (survives DOM replacements, no storage needed)
// ---------------------------------------------------------------------------
let sidebarScrollTop = 0;

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

/**
* Returns true when `href` points to an internal page that can be navigated
* client-side (same-origin, not a hash-only link, not a special protocol).
*
* @param {string | undefined | null} href
* @returns {boolean}
*/
const isInternalLink = href => {
if (!href) {
return false;
}
if (/^(https?:|mailto:|tel:|#)/.test(href)) {
return false;
}
return true;
};

/**
* Returns true when the click event carries modifier keys that signal
* "open in new tab / window" – we should let the browser handle those.
*/
const hasModifier = e =>
e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0;

/**
* Re-import and execute the new page's entrypoint module script after a DOM swap.
* By appending a timestamp query parameter, we bypass the browser's ES module
* evaluation cache, ensuring Preact runs `hydrate()` on the freshly injected `#root`.
* This natively re-attaches all synthetic event listeners (such as CodeBox copy
* buttons or CodeTabs switchers) without requiring browser storage or DOM hacks.
*/
function reinitPageFeatures(newDoc) {
const newScript = newDoc.querySelector('body script[type="module"][src]');
if (newScript && newScript.getAttribute('src')) {
const scriptUrl = new URL(
newScript.getAttribute('src'),
window.location.origin
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Script URL resolved against origin

High Severity

reinitPageFeatures builds the entrypoint URL with window.location.origin as the base. Relative script src values such as ../page.js therefore resolve from the domain root instead of the current page path, so the wrong module is requested (or a 404) whenever the site is not hosted at /. Preact never re-hydrates and interactive controls stop working after SPA navigation.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b091c5f. Configure here.

// Bypass module evaluation cache so Preact re-hydrates on every navigation
scriptUrl.searchParams.set('t', Date.now());
import(/* @vite-ignore */ scriptUrl.toString()).catch(() => {});
}
}

// ---------------------------------------------------------------------------
// Core navigation function
// ---------------------------------------------------------------------------

/**
* Perform client-side navigation to `url` without a full page reload.
*
* @param {string} url
* @param {boolean} [isPopState=false] – true when triggered by Back/Forward
*/
export const navigate = async (url, isPopState = false) => {
// 1. Save sidebar scroll position BEFORE touching the DOM
const sidebar = document.querySelector('aside');
if (sidebar) {
sidebarScrollTop = sidebar.scrollTop;
}

// 2. Push browser history (skip for popstate – browser already did it)
if (!isPopState) {
window.history.pushState({}, '', url);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pushState runs outside error handling

Medium Severity

history.pushState runs before the try/catch that falls back to window.location.href. For cross-origin version URLs, pushState throws a SecurityError, so the catch never runs and navigation fails with an unhandled exception instead of a full page load.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b091c5f. Configure here.


try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}

const html = await response.text();
const newDoc = new DOMParser().parseFromString(html, 'text/html');

const currentRoot = document.getElementById('root');
const newRoot = newDoc.getElementById('root');

if (currentRoot && newRoot) {
// 3. Swap the entire #root – all CSS module classes are preserved
// because they come straight from the server-rendered HTML and
// the shared styles.css is already loaded in <head>.
const updateDOM = () => {
currentRoot.innerHTML = newRoot.innerHTML;
document.title = newDoc.title;

// 4. Restore sidebar scroll position
const newSidebar = document.querySelector('aside');
if (newSidebar) {
newSidebar.scrollTop = sidebarScrollTop;
}

// 5. Re-import the new page's module script to trigger Preact hydration
// on the newly swapped DOM nodes (CodeBox copy buttons, tabs, etc.).
reinitPageFeatures(newDoc);

// 6. Scroll to hash target if URL contains a hash, otherwise to top
const hash = new URL(url, window.location.origin).hash;
if (hash) {
const target = document.getElementById(hash.slice(1));
if (target) {
target.scrollIntoView({ behavior: 'smooth' });
}
} else {
window.scrollTo({ top: 0, behavior: 'instant' });
}
};

// Use View Transitions API for a smooth crossfade when available
if (document.startViewTransition) {
document.startViewTransition(updateDOM);
} else {
updateDOM();
}
} else {
// Fallback: full page navigation
window.location.href = url;
}
} catch {
window.location.href = url;
}
};

// Global event delegation (attached once, survives any DOM replacement)
if (typeof window !== 'undefined' && !window.__dockit_client_nav) {
window.__dockit_client_nav = true;

// Handle clicks on ANY <a> inside the page – even ones injected via
// innerHTML replacement – so navigation keeps working after a swap.
document.addEventListener('click', e => {
const anchor = e.target.closest('a[href]');
if (!anchor) {
return;
}

const href = anchor.getAttribute('href');
if (hasModifier(e)) {
return;
}

// ====== PICKER-HEADER LINKS ======
// .picker-header > a toggles legacy dropdown menus.
// Never intercept these – let the attached click listener handle them.
if (anchor.closest('.picker-header')) {
return;
}

// ====== HASH-ONLY LINKS ======
// Handle #heading-id links manually because native hash navigation can
// break after innerHTML swap of #root (the element may have moved).
if (href && href.startsWith('#')) {
const target = document.getElementById(href.slice(1));
if (target) {
e.preventDefault();
target.scrollIntoView({ behavior: 'smooth' });
window.history.pushState({}, '', href);
}
return;
}

if (!isInternalLink(href)) {
return;
}

e.preventDefault();
navigate(href);
});

// Handle browser Back / Forward buttons
window.addEventListener('popstate', () => {
navigate(window.location.href, true);
});
}

export default function ClientLink({ children, ...props }) {
return <a {...props}>{children}</a>;
}
5 changes: 3 additions & 2 deletions src/generators/web/ui/components/SideBar/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import SideBar from '@node-core/ui-components/Containers/Sidebar';

import styles from './index.module.css';
import { relativeOrAbsolute } from '../../utils/relativeOrAbsolute.mjs';
import ClientLink, { navigate } from '../ClientLink';

import { project, version, versions, pages } from '#theme/config';

Expand All @@ -17,7 +18,7 @@ const getMajorVersion = v => parseInt(String(v).match(/\d+/)?.[0] ?? '0', 10);
* Redirect to a URL
* @param {string} url URL
*/
const redirect = url => (window.location.href = url);
const redirect = url => navigate(url);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Version switch uses SPA navigate

High Severity

redirect now always calls navigate(), including for the version Select onChange. Version values are absolute {baseURL}/latest-{version}/api{path}.html URLs for different builds. Soft-navigating only swaps #root and leaves the previous page’s import map and assets in <head>, so cross-version navigation loads mismatched modules and breaks the page.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b091c5f. Configure here.


/**
* Sidebar component for MDX documentation with version selection and page navigation
Expand Down Expand Up @@ -49,7 +50,7 @@ export default ({ metadata }) => {
pathname={`${metadata.basename}.html`}
groups={[{ groupName: 'API Documentation', items }]}
onSelect={redirect}
as={props => <a {...props} rel="prefetch" />}
as={ClientLink}
title="Navigation"
>
<div>
Expand Down