diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 3408ec9c0..4410437fb 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,4 +1,5 @@ import {Providers} from '@/app/providers'; +import {FragmentRedirect} from '@/components/FragmentRedirect'; import {Layout} from '@/components/Layout'; import {TECHNICAL_CHANGELOG_RSS_URL} from '@/data/constants'; import clsx from 'clsx'; @@ -89,6 +90,7 @@ export default function RootLayout({children}: {children: React.ReactNode}) { className="flex min-h-full bg-light-bg dark:bg-dark-bg" > + {children} diff --git a/src/components/FragmentRedirect.tsx b/src/components/FragmentRedirect.tsx new file mode 100644 index 000000000..c859c9261 --- /dev/null +++ b/src/components/FragmentRedirect.tsx @@ -0,0 +1,94 @@ +'use client'; + +import {updatedRedirectsData} from '@/data/redirects'; +import {usePathname} from 'next/navigation'; +import {useEffect} from 'react'; + +const basePath = process.env.NEXT_PUBLIC_DOCS_BASE_PATH || ''; + +function splitHash(url: string): {path: string; hash: string} { + const i = url.indexOf('#'); + return i === -1 + ? {path: url, hash: ''} + : {path: url.slice(0, i), hash: url.slice(i)}; +} + +// Entries in src/data/redirects.ts whose source has a `#fragment`. The +// middleware can never match these, because browsers do not send the fragment +// to the server, so they are applied here instead. +const fragmentRedirects = updatedRedirectsData.filter(r => + r.source.includes('#') +); +const bySource = new Map(); +for (const r of fragmentRedirects) { + // First entry wins, matching the middleware's Array.find semantics. + if (!bySource.has(r.source)) bySource.set(r.source, r.destination); +} + +/** + * `pathname` excludes the basePath (as returned by usePathname()); + * `hash` includes the leading '#'. + */ +export function resolveFragmentRedirect( + pathname: string, + hash: string +): string | undefined { + if (!hash) return undefined; + const exact = bySource.get(pathname + hash); + if (exact) return exact; + // The middleware may already have redirected the path, and the browser + // carries the (now stale) fragment over to the new page. Match an entry + // that points at this page and whose source fragment is the one we have. + const carried = fragmentRedirects.find(r => { + const dest = splitHash(r.destination); + return ( + dest.path === pathname && + splitHash(r.source).hash === hash && + dest.hash !== hash + ); + }); + return carried?.destination; +} + +/** + * Applies heading redirects (`/page#old-heading`) from src/data/redirects.ts + * in the browser, after the page loads and again whenever the fragment changes. + */ +export function FragmentRedirect() { + const pathname = usePathname(); + + useEffect(() => { + const apply = () => { + const destination = resolveFragmentRedirect( + pathname, + window.location.hash + ); + if (!destination) return; + + if (destination.startsWith('http')) { + window.location.replace(destination); + return; + } + + const dest = splitHash(destination); + if (dest.path !== pathname) { + window.location.replace(basePath + destination); + return; + } + + // Same page: fix the URL in place and scroll to the new heading. + window.history.replaceState(null, '', basePath + destination); + if (dest.hash) { + document + .getElementById(decodeURIComponent(dest.hash.slice(1))) + ?.scrollIntoView(); + } + }; + + apply(); + window.addEventListener('hashchange', apply); + return () => window.removeEventListener('hashchange', apply); + }, [pathname]); + + return null; +} diff --git a/src/data/redirects.ts b/src/data/redirects.ts index e480a4195..7fd9ee61c 100644 --- a/src/data/redirects.ts +++ b/src/data/redirects.ts @@ -1,5 +1,14 @@ import {TECHNICAL_CHANGELOG_RSS_URL} from './constants'; +// Path redirects (`/old-page` -> `/new-page`) are applied server-side by +// src/middleware.ts. +// +// Heading redirects, where the source has a `#fragment` +// (`/page#old-heading` -> `/page#new-heading`), are applied in the browser by +// src/components/FragmentRedirect.tsx, because browsers never send the +// fragment to the server. +// +// For a given source, the first matching entry wins. const redirectsData = [ { source: '/integration/img/disable_extension.png', @@ -5896,7 +5905,7 @@ const redirectsData = [ }, ]; -const updatedRedirectsData = redirectsData.map(redirect => { +export const updatedRedirectsData = redirectsData.map(redirect => { return { source: String(redirect.source).replace( 'http://localhost:3000/docs', @@ -5908,7 +5917,3 @@ const updatedRedirectsData = redirectsData.map(redirect => { ) }; }); - -module.exports = { - updatedRedirectsData -}; diff --git a/src/middleware.ts b/src/middleware.ts index c852a662f..7a14204f3 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -3,8 +3,7 @@ import {NextResponse} from 'next/server'; import docsConfig from '../docs.config.js'; import {TECHNICAL_CHANGELOG_RSS_URL} from './data/constants'; - -const {updatedRedirectsData} = require('./data/redirects.ts'); +import {updatedRedirectsData} from './data/redirects'; function createRedirectUrl( request: NextRequest,