Add a parameterised e2e suite that runs the same 22 scenarios against
both router implementations (Navigation API and legacy History API).
The legacy strategy is forced on via `window.__VP_USE_LEGACY_ROUTER__`,
set via Playwright `addInitScript` on a second browser context — the
shared browser and default `page` / `goto` from vitestSetup.ts are
reused for the Navigation API run, so we don't relaunch Playwright.
Scenarios covered, for each mode:
- strategy selection reflects the flag
- link click performs SPA navigation (no full reload)
- hash anchor updates URL, scrolls into view, focuses target heading
- back / forward navigate between entries
- scroll position restored on back traversal
- onBeforeRouteChange / onAfterRouteChange fire for full and hash navs
- onBeforeRouteChange returning false cancels the nav
- programmatic router.go
- router.go with { replace: true }
- same-URL click scrolls without pushing a new history entry
- onBeforePageLoad / onAfterPageLoad fire
- onBeforePageLoad returning false cancels page load
- links inside .vp-raw trigger a full reload
- external / download / target / non-HTML links are not intercepted
- clicks on buttons inside links are not intercepted
- route.path / route.hash / route.query stay in sync
- unknown paths fall back to the 404 page
- route.data.relativePath is populated from the loaded page
A small init-script-installed `resolveRouterOrThrow()` finds the router
via the Vue app's provides, so `page.evaluate` call sites stay terse and
typed against the public `Router` interface — no `any` in test code.
To support the flag, `hasNavigationApi()` now honours
`window.__VP_USE_LEGACY_ROUTER__` as a forced opt-out; this is documented
in-source as a test-only hook.
`event.downloadRequest != null` already catches links with a `download`
attribute (including `download=""`), so the `sourceElement.hasAttribute('download')`
check was dead. Keep the `target` check — `target="_self"` and named
targets still fire the navigate event in the current window and should
not be intercepted.
On the Navigation API path, hash-only navigations no longer run through
our manual scroll helper. We still `intercept()` them — but with a
minimal handler that only:
- runs `onBeforeRouteChange` / `onAfterRouteChange` (so hook semantics
stay consistent with the legacy path and with path-level navs)
- syncs `route.hash` / `route.query`
- moves focus to the target heading for a11y
Scroll is left to the browser's built-in post-handler behaviour, which
already handles the fragment scroll (push/replace) and scroll-position
restoration (traverse), both respecting `scroll-margin`.
Text-fragment navigations (`#:~:text=…`) remain un-intercepted so the
browser's native fragment-directive processing runs; the `hashchange`
listener keeps state in sync for that case.
Verified: push-hash, programmatic `router.go('/foo#bar')`, hash traversal
via back/forward, and `onBeforeRouteChange` cancellation all behave
correctly, with focus landing on the target heading.
On the Navigation API path we previously opted out with `scroll: 'manual'`
and drove the scroll ourselves (including `updateCurrentEntry`-based
scroll-position tracking for traverses), mostly so our `scrollTo` helper
could focus the hash target for accessibility. With `await nextTick()`
inside the intercept handler, the browser's built-in post-handler scroll
already lands on the right element — we only need to keep focus manual.
- keep `focusReset: 'manual'`; drop `scroll: 'manual'` from `intercept`
- `await nextTick()` after `loadPage` so the new DOM exists when the
browser scrolls on handler resolution
- drop `updateCurrentEntry` / `destination.getState()`; traverse scroll
restoration is now handled by the browser
- split `scrollTo` into shared helpers and export `focusHashTarget`,
used by the Navigation API strategy for the a11y focus step
As a side effect, `loadPage` no longer carries scroll responsibility:
- remove `scrollPosition` / `initialLoad` from its options (and the
`PageLoadOptions` type — `LoadPage` is just `(href) => Promise<void>`)
- the legacy strategy now does its own `await nextTick()` + `scrollTo`
after `loadPage` in `go()` and the popstate listener
Verified: push to new page scrolls to top, push with hash scrolls to
target + focuses it, back traversal restores prior scroll position, and
link-click interception still works.
Implement the client router against the Navigation API and keep the
existing History API + click/popstate implementation as a fallback.
Feature detection gates on `NavigateEvent.prototype.sourceElement` —
its presence implies a sufficiently complete implementation.
On the Navigation API path, a single `navigate` listener replaces the
global click/popstate/hashchange listeners:
- link-click filters (`.vp-raw`, button wrappers, `download`/`target`,
cross-origin, non-HTML paths) are reproduced via `sourceElement`
- `scroll: 'manual'` + `focusReset: 'manual'` preserve the existing
`scrollTo` helper's a11y focus handling
- scroll position is persisted on the outgoing entry via
`updateCurrentEntry` and restored on `traverse`
- fragment-directive (`#:~:text=…`) hash navigations fall through to
native browser handling
- `router.go` calls `navigation.navigate(...).finished`; a `__vpFromGo`
info flag prevents `onBeforeRouteChange` from running twice
- a `skipInternalNavigate` guard prevents re-entering the listener
during the same-document `history.replaceState` URL fix-up in
`loadPage`
Split the router into a small folder with two composable strategies
sharing the same `{ go, replaceUrl }` shape:
src/client/app/router/
router.ts - public API, picks a strategy, wires loadPage
shared.ts - types, fakeHost, normalizeHref, scrollTo, …
legacy.ts - createLegacyRouterStrategy (History API)
navigationApi.ts - hasNavigationApi + Navigation API strategy
`loadPage` delegates same-document URL fix-up to `strategy.replaceUrl`
so each strategy handles its own re-entry semantics.
Replaces the JS-based scroll offset logic with native CSS `scroll-margin-top`. The default theme sets it on headings using `--vp-nav-height` and `--vp-layout-top-height`, and `scrollTo` now uses `scrollIntoView` which respects it natively.
BREAKING CHANGE: `scrollOffset` from config is removed. Users wanting to customize scroll offset should customize `scroll-margin-top` via CSS instead. `smoothScroll` support from `router.go` is also removed as it didn't work as expected for most users. Users wanting smooth scrolling should set `scroll-behavior: smooth` in CSS, ideally inside a `@media (prefers-reduced-motion: no-preference)` block.
x-ref: https://github.com/faker-js/faker/pull/3755
Local tests suggest that `zlib.createBrotliCompress` is around 50x slower than `zlib.createGzip`. Although brotli produces 25% smaller for the test subject, such low speed is not desirable for `vitepress preview` as the command is meant for local servers.
This aligns the preview compression with vite.
**BREAKING CHANGE:**
The previous `<!-- @include: ./path/to/file -->` syntax silently ignored errors when files did not exist. This behavior was originally intended as an escape hatch while documenting includes, but better solutions now exist using Shiki transformers.
For most users, no code changes are required. If you now see errors, it means your includes are broken and were previously not being reported.
Users who intentionally reference non-existent files or want to document includes without resolving them can configure `markdown.codeTransformers` with a `postprocess` hook. See `docs/.vitepress/config.ts` in this repo for an example.