App router 404 localization (#57230)
This commit is contained in:
@@ -343,3 +343,9 @@ cookbook_landing:
|
|||||||
search_articles: Search articles
|
search_articles: Search articles
|
||||||
category: Category
|
category: Category
|
||||||
complexity: Complexity
|
complexity: Complexity
|
||||||
|
|
||||||
|
not_found:
|
||||||
|
title: Ooops!
|
||||||
|
message: It looks like this page doesn't exist.
|
||||||
|
contact: We track these errors automatically, but if the problem persists please feel free to contact us.
|
||||||
|
contact_cta: Contact support
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { getAppRouterContext } from '@/app/lib/app-router-context'
|
import { Client404Wrapper } from '@/app/components/Client404Wrapper'
|
||||||
import { AppRouterMainContextProvider } from '@/app/components/AppRouterMainContext'
|
import { createServerAppRouterContext } from '@/app/lib/server-context-utils'
|
||||||
import { translate } from '@/languages/lib/translation-utils'
|
import { headers } from 'next/headers'
|
||||||
import { CommentDiscussionIcon, MarkGithubIcon } from '@primer/octicons-react'
|
|
||||||
import type { Metadata } from 'next'
|
import type { Metadata } from 'next'
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
@@ -12,94 +11,10 @@ export const metadata: Metadata = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function Page404() {
|
export default async function Page404() {
|
||||||
// Get context with UI data
|
const headersList = await headers()
|
||||||
const appContext = await getAppRouterContext()
|
const pathname = headersList.get('x-pathname') || '/404'
|
||||||
|
|
||||||
const siteTitle = translate(appContext.site.data.ui, 'header.github_docs', 'GitHub Docs')
|
const appContext = createServerAppRouterContext(pathname)
|
||||||
const oopsTitle = translate(appContext.site.data.ui, 'meta.oops', 'Ooops!')
|
|
||||||
|
|
||||||
return (
|
return <Client404Wrapper appContext={appContext} />
|
||||||
<AppRouterMainContextProvider context={appContext}>
|
|
||||||
<div className="min-h-screen d-flex flex-column">
|
|
||||||
{/* Simple Header */}
|
|
||||||
<div className="border-bottom color-border-muted no-print">
|
|
||||||
<header className="container-xl p-responsive py-3 position-relative d-flex width-full">
|
|
||||||
<div className="d-flex flex-1 flex-items-center">
|
|
||||||
<a
|
|
||||||
href={`/${appContext.currentLanguage}`}
|
|
||||||
className="color-fg-default no-underline d-flex flex-items-center"
|
|
||||||
>
|
|
||||||
<MarkGithubIcon size={32} className="mr-2" />
|
|
||||||
<span className="f4 text-bold">{siteTitle}</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main Content */}
|
|
||||||
<div className="container-xl p-responsive py-6 width-full flex-1">
|
|
||||||
<article className="col-md-10 col-lg-7 mx-auto">
|
|
||||||
<h1>{oopsTitle}</h1>
|
|
||||||
<div className="f2 color-fg-muted mb-3" data-container="lead">
|
|
||||||
It looks like this page doesn't exist.
|
|
||||||
</div>
|
|
||||||
<p className="f3">
|
|
||||||
We track these errors automatically, but if the problem persists please feel free to
|
|
||||||
contact us.
|
|
||||||
</p>
|
|
||||||
<a id="support" href="https://support.github.com" className="btn btn-outline mt-2">
|
|
||||||
<CommentDiscussionIcon size="small" className="octicon mr-1" />
|
|
||||||
Contact support
|
|
||||||
</a>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<footer className="py-6">
|
|
||||||
<div className="container-xl px-3 px-md-6">
|
|
||||||
<ul className="d-flex flex-wrap list-style-none">
|
|
||||||
<li className="d-flex mr-xl-3 color-fg-muted">
|
|
||||||
<span>© 2025 GitHub, Inc.</span>
|
|
||||||
</li>
|
|
||||||
<li className="ml-3">
|
|
||||||
<a
|
|
||||||
className="text-underline"
|
|
||||||
href="/site-policy/github-terms/github-terms-of-service"
|
|
||||||
>
|
|
||||||
Terms
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li className="ml-3">
|
|
||||||
<a
|
|
||||||
className="text-underline"
|
|
||||||
href="/site-policy/privacy-policies/github-privacy-statement"
|
|
||||||
>
|
|
||||||
Privacy
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li className="ml-3">
|
|
||||||
<a className="text-underline" href="https://www.githubstatus.com/">
|
|
||||||
Status
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li className="ml-3">
|
|
||||||
<a className="text-underline" href="https://github.com/pricing">
|
|
||||||
Pricing
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li className="ml-3">
|
|
||||||
<a className="text-underline" href="https://services.github.com/">
|
|
||||||
Expert services
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li className="ml-3">
|
|
||||||
<a className="text-underline" href="https://github.blog/">
|
|
||||||
Blog
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
</AppRouterMainContextProvider>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
83
src/app/components/AppRouterFooter.tsx
Normal file
83
src/app/components/AppRouterFooter.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useAppRouterMainContext } from '@/app/components/AppRouterMainContext'
|
||||||
|
import { createTranslationFunctions } from '@/languages/lib/translation-utils'
|
||||||
|
import { LinkExternalIcon } from '@primer/octicons-react'
|
||||||
|
|
||||||
|
export function AppRouterFooter() {
|
||||||
|
const context = useAppRouterMainContext()
|
||||||
|
|
||||||
|
const { t } = createTranslationFunctions(context.site.data.ui, 'footer')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="container-xl px-3 mt-6 pb-8 px-md-6 color-fg-muted">
|
||||||
|
{context.currentLanguage !== 'en' && <h2 className="f4 mb-2 col-12">{t('legal_heading')}</h2>}
|
||||||
|
|
||||||
|
{/* Machine translation notice for non-English languages */}
|
||||||
|
{context.currentLanguage !== 'en' && <p>{t('machine')}</p>}
|
||||||
|
|
||||||
|
<ul className="d-flex flex-wrap list-style-none">
|
||||||
|
<li className="mr-3">© {new Date().getFullYear()} GitHub, Inc.</li>
|
||||||
|
|
||||||
|
{/* German-specific Impressum link (legally required) */}
|
||||||
|
{context.currentLanguage === 'de' && (
|
||||||
|
<li className="mr-3">
|
||||||
|
<a
|
||||||
|
className="text-underline"
|
||||||
|
href="https://aka.ms/impressum_de"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>
|
||||||
|
{t('imprint')}
|
||||||
|
</a>
|
||||||
|
<LinkExternalIcon aria-label="(external site)" size={12} />
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<li className="mr-3">
|
||||||
|
<a
|
||||||
|
className="text-underline"
|
||||||
|
href={`/${context.currentLanguage}/site-policy/github-terms/github-terms-of-service`}
|
||||||
|
>
|
||||||
|
{t('terms')}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li className="mr-3">
|
||||||
|
<a
|
||||||
|
className={`text-underline ${
|
||||||
|
context.currentLanguage === 'ko' ? 'color-fg-attention text-bold' : ''
|
||||||
|
}`}
|
||||||
|
href={`/${context.currentLanguage}/site-policy/privacy-policies/github-privacy-statement`}
|
||||||
|
>
|
||||||
|
{t('privacy')}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li className="mr-3">
|
||||||
|
<a className="text-underline" href="https://www.githubstatus.com/">
|
||||||
|
{t('status')}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li className="mr-3">
|
||||||
|
<a className="text-underline" href="https://github.com/pricing">
|
||||||
|
{t('pricing')}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li className="mr-3">
|
||||||
|
<a className="text-underline" href="https://services.github.com">
|
||||||
|
{t('expert_services')}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li className="mr-3">
|
||||||
|
<a className="text-underline" href="https://github.blog">
|
||||||
|
{t('blog')}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
29
src/app/components/AppRouterHeader.tsx
Normal file
29
src/app/components/AppRouterHeader.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { MarkGithubIcon } from '@primer/octicons-react'
|
||||||
|
import { useAppRouterMainContext } from '@/app/components/AppRouterMainContext'
|
||||||
|
import { createTranslationFunctions } from '@/languages/lib/translation-utils'
|
||||||
|
|
||||||
|
export function AppRouterHeader() {
|
||||||
|
const context = useAppRouterMainContext()
|
||||||
|
|
||||||
|
const { t } = createTranslationFunctions(context.site.data.ui, 'header')
|
||||||
|
|
||||||
|
const siteTitle = t('github_docs')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-bottom color-border-muted no-print">
|
||||||
|
<header className="container-xl p-responsive py-3 position-relative d-flex width-full">
|
||||||
|
<div className="d-flex flex-1 flex-items-center">
|
||||||
|
<a
|
||||||
|
href={`/${context.currentLanguage}`}
|
||||||
|
className="color-fg-default no-underline d-flex flex-items-center"
|
||||||
|
>
|
||||||
|
<MarkGithubIcon size={32} className="mr-2" />
|
||||||
|
<span className="f4 text-bold">{siteTitle}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
54
src/app/components/AppRouterLanguagesContext.tsx
Normal file
54
src/app/components/AppRouterLanguagesContext.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { createContext, useContext } from 'react'
|
||||||
|
import { clientLanguages, type ClientLanguageCode } from '@/languages/lib/client-languages'
|
||||||
|
|
||||||
|
export type AppRouterLanguageItem = {
|
||||||
|
name: string
|
||||||
|
nativeName?: string
|
||||||
|
code: string
|
||||||
|
hreflang?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AppRouterLanguagesContextT = {
|
||||||
|
languages: Record<string, AppRouterLanguageItem>
|
||||||
|
currentLanguage?: ClientLanguageCode
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AppRouterLanguagesContext = createContext<AppRouterLanguagesContextT | null>(null)
|
||||||
|
|
||||||
|
export const useAppRouterLanguages = (): AppRouterLanguagesContextT => {
|
||||||
|
const context = useContext(AppRouterLanguagesContext)
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error(
|
||||||
|
'"useAppRouterLanguages" may only be used inside "AppRouterLanguagesContext.Provider"',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider component for App Router language context
|
||||||
|
*/
|
||||||
|
interface AppRouterLanguagesProviderProps {
|
||||||
|
children: React.ReactNode
|
||||||
|
currentLanguage?: ClientLanguageCode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppRouterLanguagesProvider({
|
||||||
|
children,
|
||||||
|
currentLanguage,
|
||||||
|
}: AppRouterLanguagesProviderProps) {
|
||||||
|
const value: AppRouterLanguagesContextT = {
|
||||||
|
languages: clientLanguages,
|
||||||
|
currentLanguage,
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppRouterLanguagesContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</AppRouterLanguagesContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
41
src/app/components/Client404Wrapper.tsx
Normal file
41
src/app/components/Client404Wrapper.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { AppRouterFooter } from '@/app/components/AppRouterFooter'
|
||||||
|
import { AppRouterHeader } from '@/app/components/AppRouterHeader'
|
||||||
|
import { AppRouterLanguagesProvider } from '@/app/components/AppRouterLanguagesContext'
|
||||||
|
import { AppRouterMainContextProvider } from '@/app/components/AppRouterMainContext'
|
||||||
|
import type { ServerAppRouterContext } from '@/app/lib/server-context-utils'
|
||||||
|
import { createTranslationFunctions } from '@/languages/lib/translation-utils'
|
||||||
|
import { CommentDiscussionIcon } from '@primer/octicons-react'
|
||||||
|
|
||||||
|
interface Client404WrapperProps {
|
||||||
|
appContext: ServerAppRouterContext
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Client404Wrapper({ appContext }: Client404WrapperProps) {
|
||||||
|
const { t } = createTranslationFunctions(appContext.site.data.ui, 'not_found')
|
||||||
|
return (
|
||||||
|
<AppRouterLanguagesProvider currentLanguage={appContext.currentLanguage}>
|
||||||
|
<AppRouterMainContextProvider context={appContext}>
|
||||||
|
<div className="min-h-screen d-flex flex-column">
|
||||||
|
<AppRouterHeader />
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="container-xl p-responsive py-6 width-full flex-1">
|
||||||
|
<article className="col-md-10 col-lg-7 mx-auto">
|
||||||
|
<h1>{t('title')}</h1>
|
||||||
|
<div className="f2 color-fg-muted mb-3" data-container="lead">
|
||||||
|
{t('message')}
|
||||||
|
</div>
|
||||||
|
<p className="f3">{t('contact')}</p>
|
||||||
|
<a id="support" href="https://support.github.com" className="btn btn-outline mt-2">
|
||||||
|
<CommentDiscussionIcon size="small" className="octicon mr-1" />
|
||||||
|
{t('contact_cta')}
|
||||||
|
</a>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<AppRouterFooter />
|
||||||
|
</div>
|
||||||
|
</AppRouterMainContextProvider>
|
||||||
|
</AppRouterLanguagesProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
20
src/app/components/ClientNotFoundWrapper.tsx
Normal file
20
src/app/components/ClientNotFoundWrapper.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { AppRouterMainContextProvider } from '@/app/components/AppRouterMainContext'
|
||||||
|
import { AppRouterLanguagesProvider } from '@/app/components/AppRouterLanguagesContext'
|
||||||
|
import { NotFoundContent } from '@/app/components/NotFoundContent'
|
||||||
|
import type { ServerAppRouterContext } from '@/app/lib/server-context-utils'
|
||||||
|
|
||||||
|
interface ClientNotFoundWrapperProps {
|
||||||
|
appContext: ServerAppRouterContext
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClientNotFoundWrapper({ appContext }: ClientNotFoundWrapperProps) {
|
||||||
|
return (
|
||||||
|
<AppRouterLanguagesProvider currentLanguage={appContext.currentLanguage}>
|
||||||
|
<AppRouterMainContextProvider context={appContext}>
|
||||||
|
<NotFoundContent />
|
||||||
|
</AppRouterMainContextProvider>
|
||||||
|
</AppRouterLanguagesProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,119 +1,33 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
import { AppRouterHeader } from '@/app/components/AppRouterHeader'
|
||||||
import { useAppRouterMainContext } from '@/app/components/AppRouterMainContext'
|
import { useAppRouterMainContext } from '@/app/components/AppRouterMainContext'
|
||||||
|
import { ServerFooter } from '@/app/components/ServerFooter'
|
||||||
import { createTranslationFunctions } from '@/languages/lib/translation-utils'
|
import { createTranslationFunctions } from '@/languages/lib/translation-utils'
|
||||||
import { CommentDiscussionIcon, MarkGithubIcon } from '@primer/octicons-react'
|
import { CommentDiscussionIcon } from '@primer/octicons-react'
|
||||||
import { useMemo } from 'react'
|
|
||||||
|
|
||||||
function SimpleHeader() {
|
|
||||||
const context = useAppRouterMainContext()
|
|
||||||
|
|
||||||
const { t } = useMemo(
|
|
||||||
() => createTranslationFunctions(context.site.data.ui, ['header']),
|
|
||||||
[context.site.data.ui],
|
|
||||||
)
|
|
||||||
|
|
||||||
const siteTitle = t('github_docs')
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="border-bottom color-border-muted no-print">
|
|
||||||
<header className="container-xl p-responsive py-3 position-relative d-flex width-full">
|
|
||||||
<div className="d-flex flex-1 flex-items-center">
|
|
||||||
<a
|
|
||||||
href={`/${context.currentLanguage}`}
|
|
||||||
className="color-fg-default no-underline d-flex flex-items-center"
|
|
||||||
>
|
|
||||||
<MarkGithubIcon size={32} className="mr-2" />
|
|
||||||
<span className="f4 text-bold">{siteTitle}</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SimpleFooter() {
|
|
||||||
return (
|
|
||||||
<footer className="py-6">
|
|
||||||
<div className="container-xl px-3 px-md-6">
|
|
||||||
<ul className="d-flex flex-wrap list-style-none">
|
|
||||||
<li className="d-flex mr-xl-3 color-fg-muted">
|
|
||||||
<span>© {new Date().getFullYear()} GitHub, Inc.</span>
|
|
||||||
</li>
|
|
||||||
<li className="ml-3">
|
|
||||||
<a className="text-underline" href="/site-policy/github-terms/github-terms-of-service">
|
|
||||||
Terms
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li className="ml-3">
|
|
||||||
<a
|
|
||||||
className="text-underline"
|
|
||||||
href="/site-policy/privacy-policies/github-privacy-statement"
|
|
||||||
>
|
|
||||||
Privacy
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li className="ml-3">
|
|
||||||
<a className="text-underline" href="https://www.githubstatus.com/">
|
|
||||||
Status
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li className="ml-3">
|
|
||||||
<a className="text-underline" href="https://github.com/pricing">
|
|
||||||
Pricing
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li className="ml-3">
|
|
||||||
<a className="text-underline" href="https://services.github.com/">
|
|
||||||
Expert services
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li className="ml-3">
|
|
||||||
<a className="text-underline" href="https://github.blog/">
|
|
||||||
Blog
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SimpleLead({ children }: { children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div className="f2 color-fg-muted mb-3" data-container="lead">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function NotFoundContent() {
|
export function NotFoundContent() {
|
||||||
const context = useAppRouterMainContext()
|
const context = useAppRouterMainContext()
|
||||||
|
const { t } = createTranslationFunctions(context.site.data.ui, 'not_found')
|
||||||
const { t } = useMemo(
|
|
||||||
() => createTranslationFunctions(context.site.data.ui, ['meta']),
|
|
||||||
[context.site.data.ui],
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen d-flex flex-column">
|
<div className="min-h-screen d-flex flex-column">
|
||||||
<SimpleHeader />
|
<AppRouterHeader />
|
||||||
|
|
||||||
<div className="container-xl p-responsive py-6 width-full flex-1">
|
<div className="container-xl p-responsive py-6 width-full flex-1">
|
||||||
<article className="col-md-10 col-lg-7 mx-auto">
|
<article className="col-md-10 col-lg-7 mx-auto">
|
||||||
<h1>{t('oops')}</h1>
|
<h1>{t('title')}</h1>
|
||||||
<SimpleLead>It looks like this page doesn't exist.</SimpleLead>
|
<div className="f2 color-fg-muted mb-3" data-container="lead">
|
||||||
<p className="f3">
|
{t('message')}
|
||||||
We track these errors automatically, but if the problem persists please feel free to
|
</div>
|
||||||
contact us.
|
<p className="f3">{t('contact')}</p>
|
||||||
</p>
|
|
||||||
<a id="support" href="https://support.github.com" className="btn btn-outline mt-2">
|
<a id="support" href="https://support.github.com" className="btn btn-outline mt-2">
|
||||||
<CommentDiscussionIcon size="small" className="octicon mr-1" />
|
<CommentDiscussionIcon size="small" className="octicon mr-1" />
|
||||||
Contact support
|
{t('contact_cta')}
|
||||||
</a>
|
</a>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SimpleFooter />
|
<ServerFooter currentLanguage={context.currentLanguage} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
86
src/app/components/ServerFooter.tsx
Normal file
86
src/app/components/ServerFooter.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { getUIDataMerged } from '@/data-directory/lib/get-data'
|
||||||
|
import { createTranslationFunctions } from '@/languages/lib/translation-utils'
|
||||||
|
import { LinkExternalIcon } from '@primer/octicons-react'
|
||||||
|
import type { ClientLanguageCode } from '@/languages/lib/client-languages'
|
||||||
|
|
||||||
|
interface ServerFooterProps {
|
||||||
|
currentLanguage: ClientLanguageCode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ServerFooter({ currentLanguage }: ServerFooterProps) {
|
||||||
|
// Load translations on server-side - this ensures all footer translations work
|
||||||
|
const uiData = getUIDataMerged(currentLanguage)
|
||||||
|
const { t } = createTranslationFunctions(uiData, 'footer')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="container-xl px-3 mt-6 pb-8 px-md-6 color-fg-muted">
|
||||||
|
<h2 className="f4 mb-2 col-12">{t('legal_heading')}</h2>
|
||||||
|
|
||||||
|
{/* Machine translation notice for non-English languages */}
|
||||||
|
{currentLanguage !== 'en' && <p>{t('machine')}</p>}
|
||||||
|
|
||||||
|
<ul className="d-flex flex-wrap list-style-none">
|
||||||
|
<li className="mr-3">© {new Date().getFullYear()} GitHub, Inc.</li>
|
||||||
|
|
||||||
|
{/* German-specific Impressum link (legally required) */}
|
||||||
|
{currentLanguage === 'de' && (
|
||||||
|
<li className="mr-3">
|
||||||
|
<a
|
||||||
|
className="text-underline"
|
||||||
|
href="https://aka.ms/impressum_de"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>
|
||||||
|
{t('imprint')}
|
||||||
|
</a>
|
||||||
|
<LinkExternalIcon aria-label="(external site)" size={12} />
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<li className="mr-3">
|
||||||
|
<a
|
||||||
|
className="text-underline"
|
||||||
|
href={`/${currentLanguage}/site-policy/github-terms/github-terms-of-service`}
|
||||||
|
>
|
||||||
|
{t('terms')}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li className="mr-3">
|
||||||
|
<a
|
||||||
|
className={`text-underline ${
|
||||||
|
currentLanguage === 'ko' ? 'color-fg-attention text-bold' : ''
|
||||||
|
}`}
|
||||||
|
href={`/${currentLanguage}/site-policy/privacy-policies/github-privacy-statement`}
|
||||||
|
>
|
||||||
|
{t('privacy')}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li className="mr-3">
|
||||||
|
<a className="text-underline" href="https://www.githubstatus.com/">
|
||||||
|
{t('status')}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li className="mr-3">
|
||||||
|
<a className="text-underline" href="https://github.com/pricing">
|
||||||
|
{t('pricing')}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li className="mr-3">
|
||||||
|
<a className="text-underline" href="https://services.github.com">
|
||||||
|
{t('expert_services')}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li className="mr-3">
|
||||||
|
<a className="text-underline" href="https://github.blog">
|
||||||
|
{t('blog')}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
12
src/app/components/hooks/useAppRouterVersion.ts
Normal file
12
src/app/components/hooks/useAppRouterVersion.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { usePathname } from 'next/navigation'
|
||||||
|
import { getVersionInfoFromPath } from '@/app/lib/version-utils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App Router compatible version hook
|
||||||
|
*/
|
||||||
|
export function useAppRouterVersion() {
|
||||||
|
const pathname = usePathname()
|
||||||
|
return getVersionInfoFromPath(pathname ?? '')
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import { headers } from 'next/headers'
|
|
||||||
import { translate } from '@/languages/lib/translation-utils'
|
|
||||||
import { clientLanguageKeys } from '@/languages/lib/client-languages'
|
|
||||||
import { getUIDataMerged } from '@/data-directory/lib/get-data'
|
import { getUIDataMerged } from '@/data-directory/lib/get-data'
|
||||||
|
import { type ClientLanguageCode } from '@/languages/lib/client-languages'
|
||||||
|
import { translate } from '@/languages/lib/translation-utils'
|
||||||
|
import { extractLanguageFromPath } from '@/app/lib/language-utils'
|
||||||
|
|
||||||
export interface AppRouterContext {
|
export interface AppRouterContext {
|
||||||
currentLanguage: string
|
currentLanguage: ClientLanguageCode
|
||||||
currentVersion: string
|
currentVersion: string
|
||||||
sitename: string
|
sitename: string
|
||||||
site: {
|
site: {
|
||||||
@@ -14,18 +14,24 @@ export interface AppRouterContext {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAppRouterContext(): Promise<AppRouterContext> {
|
/**
|
||||||
const headersList = await headers()
|
* Create App Router context from pathname
|
||||||
|
*/
|
||||||
|
export function createAppRouterContext(
|
||||||
|
pathname: string = '/',
|
||||||
|
fallbackLanguage?: ClientLanguageCode,
|
||||||
|
): AppRouterContext {
|
||||||
|
let language = extractLanguageFromPath(pathname)
|
||||||
|
|
||||||
// Get language and version from headers or fallbacks
|
// Use fallback if provided and URL doesn't specify language
|
||||||
const language =
|
if (language === 'en' && fallbackLanguage && fallbackLanguage !== 'en') {
|
||||||
headersList.get('x-docs-language') ||
|
language = fallbackLanguage
|
||||||
extractLanguageFromHeader(headersList.get('accept-language') || 'en')
|
}
|
||||||
const version = headersList.get('x-docs-version') || 'free-pro-team@latest'
|
|
||||||
|
|
||||||
// Load UI data directly from data directory the same way as Pages Router does it
|
const version = 'free-pro-team@latest'
|
||||||
|
|
||||||
|
// Load UI data directly from data directory
|
||||||
const uiData = getUIDataMerged(language)
|
const uiData = getUIDataMerged(language)
|
||||||
|
|
||||||
const siteName = translate(uiData, 'header.github_docs', 'GitHub Docs')
|
const siteName = translate(uiData, 'header.github_docs', 'GitHub Docs')
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -39,49 +45,3 @@ export async function getAppRouterContext(): Promise<AppRouterContext> {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractLanguageFromHeader(acceptLanguage: string): string {
|
|
||||||
// Fastly's custom VCL forces Accept-Language header to contain
|
|
||||||
// one of our supported language codes, so complex parsing isn't needed
|
|
||||||
const language = acceptLanguage.trim()
|
|
||||||
return clientLanguageKeys.includes(language) ? language : 'en'
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper to create minimal MainContext-compatible object
|
|
||||||
export function createAppRouterMainContext(appContext: AppRouterContext): any {
|
|
||||||
return {
|
|
||||||
currentLanguage: appContext.currentLanguage,
|
|
||||||
currentVersion: appContext.currentVersion,
|
|
||||||
data: {
|
|
||||||
ui: appContext.site.data.ui,
|
|
||||||
reusables: {},
|
|
||||||
variables: {
|
|
||||||
release_candidate: { version: null },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
allVersions: {},
|
|
||||||
breadcrumbs: {},
|
|
||||||
communityRedirect: {},
|
|
||||||
currentPathWithoutLanguage: '',
|
|
||||||
currentProduct: null,
|
|
||||||
currentProductName: '',
|
|
||||||
currentProductTree: null,
|
|
||||||
enterpriseServerReleases: {
|
|
||||||
isOldestReleaseDeprecated: false,
|
|
||||||
oldestSupported: '',
|
|
||||||
nextDeprecationDate: '',
|
|
||||||
supported: [],
|
|
||||||
},
|
|
||||||
enterpriseServerVersions: [],
|
|
||||||
error: '',
|
|
||||||
featureFlags: {},
|
|
||||||
fullUrl: '',
|
|
||||||
isHomepageVersion: false,
|
|
||||||
nonEnterpriseDefaultVersion: 'free-pro-team@latest',
|
|
||||||
page: null,
|
|
||||||
relativePath: null,
|
|
||||||
sidebarTree: null,
|
|
||||||
status: 404,
|
|
||||||
xHost: '',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
18
src/app/lib/constants.ts
Normal file
18
src/app/lib/constants.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* App Router compatible constants
|
||||||
|
* These can be safely imported by both server and client components
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const DEFAULT_VERSION = 'free-pro-team@latest'
|
||||||
|
|
||||||
|
// Version utility functions that don't use router hooks
|
||||||
|
export function getVersionInfo(currentVersion: string = DEFAULT_VERSION) {
|
||||||
|
return {
|
||||||
|
currentVersion,
|
||||||
|
isEnterprise: currentVersion.includes('enterprise'),
|
||||||
|
isEnterpriseCloud: currentVersion.includes('cloud'),
|
||||||
|
isEnterpriseServer: currentVersion.includes('enterprise-server'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VersionInfo = ReturnType<typeof getVersionInfo>
|
||||||
51
src/app/lib/language-utils.ts
Normal file
51
src/app/lib/language-utils.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { clientLanguageKeys, type ClientLanguageCode } from '@/languages/lib/client-languages'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract language from URL path
|
||||||
|
* Handles paths like /en/something, /es/articles, etc.
|
||||||
|
*/
|
||||||
|
export function extractLanguageFromPath(path: string): ClientLanguageCode {
|
||||||
|
try {
|
||||||
|
const pathSegments = path.split('/')
|
||||||
|
const firstSegment = pathSegments[1]
|
||||||
|
|
||||||
|
if (firstSegment && clientLanguageKeys.includes(firstSegment)) {
|
||||||
|
return firstSegment as ClientLanguageCode
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to extract language from path:', error)
|
||||||
|
}
|
||||||
|
return 'en'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a path contains a language prefix
|
||||||
|
*/
|
||||||
|
export function hasLanguagePrefix(path: string): boolean {
|
||||||
|
const pathSegments = path.split('/')
|
||||||
|
const firstSegment = pathSegments[1]
|
||||||
|
return Boolean(firstSegment && clientLanguageKeys.includes(firstSegment))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove language prefix from path
|
||||||
|
* e.g., /es/articles/example -> /articles/example
|
||||||
|
*/
|
||||||
|
export function stripLanguagePrefix(path: string): string {
|
||||||
|
if (hasLanguagePrefix(path)) {
|
||||||
|
const pathSegments = path.split('/')
|
||||||
|
return '/' + pathSegments.slice(2).join('/')
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add language prefix to path if it doesn't have one
|
||||||
|
* e.g., /articles/example + 'es' -> /es/articles/example
|
||||||
|
*/
|
||||||
|
export function addLanguagePrefix(path: string, language: ClientLanguageCode): string {
|
||||||
|
if (hasLanguagePrefix(path)) {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
return `/${language}${path === '/' ? '' : path}`
|
||||||
|
}
|
||||||
45
src/app/lib/server-context-utils.ts
Normal file
45
src/app/lib/server-context-utils.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { extractLanguageFromPath } from '@/app/lib/language-utils'
|
||||||
|
import { extractVersionFromPath } from '@/app/lib/version-utils'
|
||||||
|
import { getUIDataMerged } from '@/data-directory/lib/get-data'
|
||||||
|
import { type ClientLanguageCode } from '@/languages/lib/client-languages'
|
||||||
|
import { createTranslationFunctions, translate } from '@/languages/lib/translation-utils'
|
||||||
|
|
||||||
|
export interface ServerAppRouterContext {
|
||||||
|
currentLanguage: ClientLanguageCode
|
||||||
|
currentVersion: string
|
||||||
|
sitename: string
|
||||||
|
site: { data: { ui: any } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-side context creation using filesystem data
|
||||||
|
* Use in server components where filesystem access is available
|
||||||
|
*/
|
||||||
|
export function createServerAppRouterContext(pathname: string): ServerAppRouterContext {
|
||||||
|
const language = extractLanguageFromPath(pathname)
|
||||||
|
const currentVersion = extractVersionFromPath(pathname)
|
||||||
|
|
||||||
|
const uiData = getUIDataMerged(language)
|
||||||
|
const siteName = translate(uiData, 'header.github_docs', 'GitHub Docs')
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentLanguage: language,
|
||||||
|
currentVersion,
|
||||||
|
sitename: siteName,
|
||||||
|
site: { data: { ui: uiData } },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create server-side footer with translations
|
||||||
|
*/
|
||||||
|
export function createServerFooterContent(language: ClientLanguageCode) {
|
||||||
|
const uiData = getUIDataMerged(language)
|
||||||
|
const { t } = createTranslationFunctions(uiData, 'footer')
|
||||||
|
|
||||||
|
return {
|
||||||
|
t,
|
||||||
|
language,
|
||||||
|
footerData: uiData.footer || {},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { usePathname } from 'next/navigation'
|
import { usePathname } from 'next/navigation'
|
||||||
import { useMemo } from 'react'
|
import { useMemo, useEffect, useState } from 'react'
|
||||||
import { clientLanguageKeys, type ClientLanguageCode } from '@/languages/lib/client-languages'
|
import { clientLanguageKeys, type ClientLanguageCode } from '@/languages/lib/client-languages'
|
||||||
|
import Cookies from '@/frame/components/lib/cookies'
|
||||||
|
import { USER_LANGUAGE_COOKIE_NAME } from '@/frame/lib/constants'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates if a string is a supported locale using client languages
|
* Validates if a string is a supported locale using client languages
|
||||||
@@ -16,8 +18,32 @@ function isValidLocale(locale: string): locale is ClientLanguageCode {
|
|||||||
*/
|
*/
|
||||||
export function useDetectLocale(): ClientLanguageCode {
|
export function useDetectLocale(): ClientLanguageCode {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
|
const [cookieLanguage, setCookieLanguage] = useState<ClientLanguageCode | null>(null)
|
||||||
|
const [browserLanguage, setBrowserLanguage] = useState<ClientLanguageCode | null>(null)
|
||||||
|
|
||||||
|
// Read cookie and browser language on client-side mount
|
||||||
|
useEffect(() => {
|
||||||
|
const userLanguageCookie = Cookies.get(USER_LANGUAGE_COOKIE_NAME)
|
||||||
|
if (userLanguageCookie && isValidLocale(userLanguageCookie)) {
|
||||||
|
setCookieLanguage(userLanguageCookie)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get language from browser as fallback
|
||||||
|
if (navigator?.language) {
|
||||||
|
const browserLocale = navigator.language.split('-')[0]
|
||||||
|
if (isValidLocale(browserLocale)) {
|
||||||
|
setBrowserLanguage(browserLocale)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
|
// Priority order:
|
||||||
|
// 1. URL path
|
||||||
|
// 2. User language cookie
|
||||||
|
// 3. Browser language
|
||||||
|
// 4. Default to English
|
||||||
|
|
||||||
// Extract locale from pathname (e.g., /es/search -> 'es')
|
// Extract locale from pathname (e.g., /es/search -> 'es')
|
||||||
if (pathname) {
|
if (pathname) {
|
||||||
const pathSegments = pathname.split('/')
|
const pathSegments = pathname.split('/')
|
||||||
@@ -28,16 +54,18 @@ export function useDetectLocale(): ClientLanguageCode {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to browser locale if available
|
// Use cookie language if available
|
||||||
if (typeof window !== 'undefined' && window.navigator?.language) {
|
if (cookieLanguage) {
|
||||||
const browserLocale = window.navigator.language.split('-')[0]
|
return cookieLanguage
|
||||||
if (isValidLocale(browserLocale)) {
|
}
|
||||||
return browserLocale
|
|
||||||
}
|
// Use browser language if available
|
||||||
|
if (browserLanguage) {
|
||||||
|
return browserLanguage
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'en'
|
return 'en'
|
||||||
}, [pathname])
|
}, [pathname, cookieLanguage, browserLanguage])
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
26
src/app/lib/version-utils.ts
Normal file
26
src/app/lib/version-utils.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { DEFAULT_VERSION, getVersionInfo } from '@/app/lib/constants'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract version from pathname (works in both server and client)
|
||||||
|
*/
|
||||||
|
export function extractVersionFromPath(pathname: string): string {
|
||||||
|
const pathSegments = pathname.split('/').filter(Boolean)
|
||||||
|
|
||||||
|
const versionSegment = pathSegments.find(
|
||||||
|
(segment) =>
|
||||||
|
segment.includes('enterprise-server') ||
|
||||||
|
segment.includes('enterprise-cloud') ||
|
||||||
|
segment === 'enterprise' ||
|
||||||
|
segment === 'free-pro-team@latest',
|
||||||
|
)
|
||||||
|
|
||||||
|
return versionSegment || DEFAULT_VERSION
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get version info from pathname (works in both server and client)
|
||||||
|
*/
|
||||||
|
export function getVersionInfoFromPath(pathname: string) {
|
||||||
|
const currentVersion = extractVersionFromPath(pathname)
|
||||||
|
return getVersionInfo(currentVersion)
|
||||||
|
}
|
||||||
@@ -1,27 +1,21 @@
|
|||||||
import { AppRouterMainContextProvider } from '@/app/components/AppRouterMainContext'
|
import { ClientNotFoundWrapper } from '@/app/components/ClientNotFoundWrapper'
|
||||||
import { NotFoundContent } from '@/app/components/NotFoundContent'
|
import { createServerAppRouterContext } from '@/app/lib/server-context-utils'
|
||||||
import { getAppRouterContext } from '@/app/lib/app-router-context'
|
import { headers } from 'next/headers'
|
||||||
import type { Metadata } from 'next'
|
import type { Metadata } from 'next'
|
||||||
|
|
||||||
// Force this page to be dynamic so it can access headers()
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: '404 - Page not found',
|
title: '404 - Page not found',
|
||||||
other: {
|
other: { status: '404' },
|
||||||
status: '404',
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function NotFoundPage() {
|
export default async function NotFoundPage() {
|
||||||
// Get context from headers set by gateway middleware
|
const headersList = await headers()
|
||||||
const appContext = await getAppRouterContext()
|
const pathname = headersList.get('x-pathname') || '/'
|
||||||
|
|
||||||
return (
|
// Create server context using utility function
|
||||||
<AppRouterMainContextProvider context={appContext}>
|
const appContext = createServerAppRouterContext(pathname)
|
||||||
<NotFoundContent />
|
|
||||||
</AppRouterMainContextProvider>
|
return <ClientNotFoundWrapper appContext={appContext} />
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default NotFoundPage
|
|
||||||
|
|||||||
@@ -343,3 +343,9 @@ cookbook_landing:
|
|||||||
search_articles: Search articles
|
search_articles: Search articles
|
||||||
category: Category
|
category: Category
|
||||||
complexity: Complexity
|
complexity: Complexity
|
||||||
|
|
||||||
|
not_found:
|
||||||
|
title: Ooops!
|
||||||
|
message: It looks like this page doesn't exist.
|
||||||
|
contact: We track these errors automatically, but if the problem persists please feel free to contact us.
|
||||||
|
contact_cta: Contact support
|
||||||
|
|||||||
@@ -1,84 +0,0 @@
|
|||||||
/**
|
|
||||||
* Utilities for safely serializing data for HTTP headers
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface MinimalUIData {
|
|
||||||
header: {
|
|
||||||
github_docs?: string
|
|
||||||
}
|
|
||||||
footer?: any
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Safely serialize data to a base64-encoded JSON string for HTTP headers
|
|
||||||
* Handle encoding issues and provide fallbacks for serialization errors
|
|
||||||
*/
|
|
||||||
export function safeStringifyForHeader(data: any): string {
|
|
||||||
try {
|
|
||||||
const jsonString = JSON.stringify(data)
|
|
||||||
// Encode to base64 to avoid header character issues
|
|
||||||
return Buffer.from(jsonString, 'utf8').toString('base64')
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Failed to stringify data for header:', e)
|
|
||||||
// Return minimal fallback as base64
|
|
||||||
const fallback = JSON.stringify({
|
|
||||||
header: { github_docs: 'GitHub Docs' },
|
|
||||||
footer: {},
|
|
||||||
})
|
|
||||||
return Buffer.from(fallback, 'utf8').toString('base64')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create minimal UI data from Express context
|
|
||||||
* Provide consistent fallbacks for missing data
|
|
||||||
*/
|
|
||||||
export function createMinimalUIData(context?: any): MinimalUIData {
|
|
||||||
if (!context?.site?.data?.ui) {
|
|
||||||
return {
|
|
||||||
header: { github_docs: 'GitHub Docs' },
|
|
||||||
footer: {},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
header: context.site.data.ui.header || { github_docs: 'GitHub Docs' },
|
|
||||||
footer: context.site.data.ui.footer || {},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set context headers for App Router from Express context
|
|
||||||
* Preserve headers from Fastly
|
|
||||||
*/
|
|
||||||
export function setAppRouterContextHeaders(
|
|
||||||
req: any,
|
|
||||||
res: any,
|
|
||||||
preserveFastlyHeaders: boolean = true,
|
|
||||||
): void {
|
|
||||||
if (req.context) {
|
|
||||||
// Only set language header if Fastly hasn't already set it (or if not preserving)
|
|
||||||
if (!preserveFastlyHeaders || !req.headers['x-docs-language']) {
|
|
||||||
res.setHeader('x-docs-language', req.context.currentLanguage || 'en')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only set version header if Fastly hasn't already set it (or if not preserving)
|
|
||||||
if (!preserveFastlyHeaders || !req.headers['x-docs-version']) {
|
|
||||||
res.setHeader('x-docs-version', req.context.currentVersion || 'free-pro-team@latest')
|
|
||||||
}
|
|
||||||
|
|
||||||
const minimalUI = createMinimalUIData(req.context)
|
|
||||||
res.setHeader('x-docs-ui-data', safeStringifyForHeader(minimalUI))
|
|
||||||
} else {
|
|
||||||
// Fallback when no Express context is available
|
|
||||||
res.setHeader('x-docs-language', 'en')
|
|
||||||
res.setHeader('x-docs-version', 'free-pro-team@latest')
|
|
||||||
res.setHeader(
|
|
||||||
'x-docs-ui-data',
|
|
||||||
safeStringifyForHeader({
|
|
||||||
header: { github_docs: 'GitHub Docs' },
|
|
||||||
footer: {},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,34 +5,31 @@ import {
|
|||||||
shouldUseAppRouter,
|
shouldUseAppRouter,
|
||||||
stripLocalePrefix,
|
stripLocalePrefix,
|
||||||
} from '@/app/lib/routing-patterns'
|
} from '@/app/lib/routing-patterns'
|
||||||
|
import { defaultCacheControl } from './cache-control'
|
||||||
import type { ExtendedRequest } from '@/types'
|
import type { ExtendedRequest } from '@/types'
|
||||||
import type { NextFunction, Response } from 'express'
|
import type { NextFunction, Response } from 'express'
|
||||||
import { setAppRouterContextHeaders } from '../lib/header-utils'
|
|
||||||
import { defaultCacheControl } from './cache-control'
|
|
||||||
import { nextApp } from './next'
|
import { nextApp } from './next'
|
||||||
|
|
||||||
export default function appRouterGateway(req: ExtendedRequest, res: Response, next: NextFunction) {
|
export default function appRouterGateway(req: ExtendedRequest, res: Response, next: NextFunction) {
|
||||||
const path = req.path || req.url
|
const path = req.path || req.url
|
||||||
const strippedPath = stripLocalePrefix(path)
|
const strippedPath = stripLocalePrefix(path)
|
||||||
|
|
||||||
// Only intercept GET and HEAD requests, and prioritize /empty-categories paths
|
// Only intercept GET and HEAD requests
|
||||||
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||||
return next()
|
return next()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Special case: Always intercept /empty-categories paths regardless of method
|
// Special case: Always intercept /empty-categories paths
|
||||||
if (strippedPath.endsWith('/empty-categories')) {
|
if (strippedPath.endsWith('/empty-categories')) {
|
||||||
// Skip the normal exclusion logic and go straight to App Router routing
|
|
||||||
const pageFound = !!(req.context && req.context.page)
|
const pageFound = !!(req.context && req.context.page)
|
||||||
|
|
||||||
if (shouldUseAppRouter(path, pageFound)) {
|
if (shouldUseAppRouter(path, pageFound)) {
|
||||||
// Set the URL to trigger App Router's not-found.tsx since /empty-categories should 404
|
|
||||||
req.url = '/404'
|
req.url = '/404'
|
||||||
res.status(404)
|
res.status(404)
|
||||||
defaultCacheControl(res)
|
defaultCacheControl(res)
|
||||||
|
|
||||||
// Set context headers for App Router (don't preserve Fastly since this is internal routing)
|
// Only pass the original pathname - no other headers needed
|
||||||
setAppRouterContextHeaders(req, res, false)
|
res.setHeader('x-pathname', req.path)
|
||||||
|
|
||||||
res.locals = res.locals || {}
|
res.locals = res.locals || {}
|
||||||
res.locals.handledByAppRouter = true
|
res.locals.handledByAppRouter = true
|
||||||
@@ -41,11 +38,6 @@ export default function appRouterGateway(req: ExtendedRequest, res: Response, ne
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Don't route static assets, API routes, valid versioned docs paths, or junk paths to App Router
|
// Don't route static assets, API routes, valid versioned docs paths, or junk paths to App Router
|
||||||
// Let them be handled by Pages Router middleware (shielding, API handlers, etc.)
|
|
||||||
// However, invalid versioned paths (like paths with /ANY/ or bogus versions) should go to App Router for 404
|
|
||||||
// EXCEPTION: /empty-categories paths should always go to App Router for proper 404 handling
|
|
||||||
const strippedPathForExclusion = stripLocalePrefix(path)
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
path.startsWith('/_next/') ||
|
path.startsWith('/_next/') ||
|
||||||
path.startsWith('/_build') ||
|
path.startsWith('/_build') ||
|
||||||
@@ -56,7 +48,7 @@ export default function appRouterGateway(req: ExtendedRequest, res: Response, ne
|
|||||||
isJunkPath(path) ||
|
isJunkPath(path) ||
|
||||||
(isVersionedPath(path) &&
|
(isVersionedPath(path) &&
|
||||||
!isInvalidVersionedPath(path) &&
|
!isInvalidVersionedPath(path) &&
|
||||||
!strippedPathForExclusion.endsWith('/empty-categories')) ||
|
!strippedPath.endsWith('/empty-categories')) ||
|
||||||
path.includes('.css') ||
|
path.includes('.css') ||
|
||||||
path.includes('.js') ||
|
path.includes('.js') ||
|
||||||
path.includes('.map') ||
|
path.includes('.map') ||
|
||||||
@@ -71,22 +63,17 @@ export default function appRouterGateway(req: ExtendedRequest, res: Response, ne
|
|||||||
return next()
|
return next()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if a page was found by the findPage middleware
|
|
||||||
const pageFound = !!(req.context && req.context.page)
|
const pageFound = !!(req.context && req.context.page)
|
||||||
|
|
||||||
if (shouldUseAppRouter(path, pageFound)) {
|
if (shouldUseAppRouter(path, pageFound)) {
|
||||||
console.log(`[INFO] Using App Router for path: ${path} (pageFound: ${!!pageFound})`)
|
console.log(`[INFO] Using App Router for path: ${path} (pageFound: ${!!pageFound})`)
|
||||||
|
|
||||||
// Strip locale prefix for App Router routing
|
|
||||||
const strippedPath = stripLocalePrefix(path)
|
const strippedPath = stripLocalePrefix(path)
|
||||||
|
|
||||||
// For 404 routes (either explicit or missing pages), always route to our 404 page
|
// For 404 routes, always route to our 404 page
|
||||||
if (strippedPath === '/404' || strippedPath === '/_not-found' || !pageFound) {
|
if (strippedPath === '/404' || strippedPath === '/_not-found' || !pageFound) {
|
||||||
// Set the URL to trigger App Router's not-found.tsx
|
req.url = '/404'
|
||||||
req.url = '/404' // Use a real App Router page route
|
|
||||||
res.status(404)
|
res.status(404)
|
||||||
|
|
||||||
// Set proper cache headers for 404 responses to match Pages Router behavior
|
|
||||||
defaultCacheControl(res)
|
defaultCacheControl(res)
|
||||||
} else {
|
} else {
|
||||||
// For other App Router routes, use the stripped path
|
// For other App Router routes, use the stripped path
|
||||||
@@ -94,22 +81,16 @@ export default function appRouterGateway(req: ExtendedRequest, res: Response, ne
|
|||||||
req.url = strippedPath + originalUrl.substring(req.path.length)
|
req.url = strippedPath + originalUrl.substring(req.path.length)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set context headers for App Router (preserve Fastly headers)
|
// Only pass pathname for App Router context creation
|
||||||
setAppRouterContextHeaders(req, res, true)
|
res.setHeader('x-pathname', req.path)
|
||||||
|
|
||||||
// Use Next.js App Router to handle this request
|
|
||||||
// The App Router will use the appropriate page.tsx or not-found.tsx
|
|
||||||
// IMPORTANT: Don't call next() - this terminates the Express middleware chain
|
|
||||||
|
|
||||||
// Mark response as handled to prevent further middleware processing
|
// Mark response as handled to prevent further middleware processing
|
||||||
res.locals = res.locals || {}
|
res.locals = res.locals || {}
|
||||||
res.locals.handledByAppRouter = true
|
res.locals.handledByAppRouter = true
|
||||||
|
|
||||||
// Use the Next.js request handler and DO NOT call next()
|
|
||||||
return nextApp.getRequestHandler()(req, res)
|
return nextApp.getRequestHandler()(req, res)
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[INFO] Using Pages Router for path: ${path}`)
|
console.log(`[INFO] Using Pages Router for path: ${path}`)
|
||||||
// Continue with Pages Router pipeline
|
|
||||||
return next()
|
return next()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import statsd from '@/observability/lib/statsd'
|
|||||||
import type { ExtendedRequest } from '@/types'
|
import type { ExtendedRequest } from '@/types'
|
||||||
import { allVersions } from '@/versions/lib/all-versions'
|
import { allVersions } from '@/versions/lib/all-versions'
|
||||||
import { minimumNotFoundHtml } from '../lib/constants'
|
import { minimumNotFoundHtml } from '../lib/constants'
|
||||||
import { setAppRouterContextHeaders } from '../lib/header-utils'
|
|
||||||
import { defaultCacheControl } from './cache-control'
|
import { defaultCacheControl } from './cache-control'
|
||||||
import { isConnectionDropped } from './halt-on-dropped-connection'
|
import { isConnectionDropped } from './halt-on-dropped-connection'
|
||||||
import { nextHandleRequest } from './next'
|
import { nextHandleRequest } from './next'
|
||||||
@@ -47,7 +46,7 @@ async function buildMiniTocItems(req: ExtendedRequest): Promise<string | undefin
|
|||||||
export default async function renderPage(req: ExtendedRequest, res: Response) {
|
export default async function renderPage(req: ExtendedRequest, res: Response) {
|
||||||
// Skip if App Router has already handled this request
|
// Skip if App Router has already handled this request
|
||||||
if (res.locals?.handledByAppRouter) {
|
if (res.locals?.handledByAppRouter) {
|
||||||
return // Request already handled by App Router
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const { context } = req
|
const { context } = req
|
||||||
@@ -75,9 +74,7 @@ export default async function renderPage(req: ExtendedRequest, res: Response) {
|
|||||||
return res.status(404).type('html').send(minimumNotFoundHtml)
|
return res.status(404).type('html').send(minimumNotFoundHtml)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The rest is "unhandled" requests where we don't have the page
|
// For App Router migration: All language-prefixed 404s should use App Router
|
||||||
// but the URL looks like a real page.
|
|
||||||
|
|
||||||
statsd.increment(STATSD_KEY_404, 1, [
|
statsd.increment(STATSD_KEY_404, 1, [
|
||||||
`url:${req.url}`,
|
`url:${req.url}`,
|
||||||
`path:${req.path}`,
|
`path:${req.path}`,
|
||||||
@@ -86,15 +83,14 @@ export default async function renderPage(req: ExtendedRequest, res: Response) {
|
|||||||
|
|
||||||
defaultCacheControl(res)
|
defaultCacheControl(res)
|
||||||
|
|
||||||
// For App Router migration: All language-prefixed 404s should use App Router
|
|
||||||
// Create a mock request that will be handled by App Router
|
// Create a mock request that will be handled by App Router
|
||||||
const mockReq = Object.create(req)
|
const mockReq = Object.create(req)
|
||||||
mockReq.url = '/404'
|
mockReq.url = '/404'
|
||||||
mockReq.path = '/404'
|
mockReq.path = '/404'
|
||||||
mockReq.method = 'GET'
|
mockReq.method = 'GET'
|
||||||
|
|
||||||
// Set context headers for App Router (preserves Fastly headers)
|
// Only pass pathname
|
||||||
setAppRouterContextHeaders(req, res, true)
|
res.setHeader('x-pathname', req.path)
|
||||||
|
|
||||||
// Import nextApp and handle directly
|
// Import nextApp and handle directly
|
||||||
const { nextApp } = await import('./next')
|
const { nextApp } = await import('./next')
|
||||||
|
|||||||
Reference in New Issue
Block a user