1
0
mirror of synced 2025-12-19 18:10:59 -05:00

App router 404 localization (#57230)

This commit is contained in:
Mardav Wala
2025-08-25 11:41:26 -04:00
committed by GitHub
parent 6997049575
commit 8cdf15a116
21 changed files with 576 additions and 395 deletions

View File

@@ -343,3 +343,9 @@ cookbook_landing:
search_articles: Search articles
category: Category
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

View File

@@ -1,7 +1,6 @@
import { getAppRouterContext } from '@/app/lib/app-router-context'
import { AppRouterMainContextProvider } from '@/app/components/AppRouterMainContext'
import { translate } from '@/languages/lib/translation-utils'
import { CommentDiscussionIcon, MarkGithubIcon } from '@primer/octicons-react'
import { Client404Wrapper } from '@/app/components/Client404Wrapper'
import { createServerAppRouterContext } from '@/app/lib/server-context-utils'
import { headers } from 'next/headers'
import type { Metadata } from 'next'
export const dynamic = 'force-dynamic'
@@ -12,94 +11,10 @@ export const metadata: Metadata = {
}
export default async function Page404() {
// Get context with UI data
const appContext = await getAppRouterContext()
const headersList = await headers()
const pathname = headersList.get('x-pathname') || '/404'
const siteTitle = translate(appContext.site.data.ui, 'header.github_docs', 'GitHub Docs')
const oopsTitle = translate(appContext.site.data.ui, 'meta.oops', 'Ooops!')
const appContext = createServerAppRouterContext(pathname)
return (
<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>
)
return <Client404Wrapper appContext={appContext} />
}

View 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">&copy; {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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -1,119 +1,33 @@
'use client'
import { AppRouterHeader } from '@/app/components/AppRouterHeader'
import { useAppRouterMainContext } from '@/app/components/AppRouterMainContext'
import { ServerFooter } from '@/app/components/ServerFooter'
import { createTranslationFunctions } from '@/languages/lib/translation-utils'
import { CommentDiscussionIcon, MarkGithubIcon } 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>
)
}
import { CommentDiscussionIcon } from '@primer/octicons-react'
export function NotFoundContent() {
const context = useAppRouterMainContext()
const { t } = useMemo(
() => createTranslationFunctions(context.site.data.ui, ['meta']),
[context.site.data.ui],
)
const { t } = createTranslationFunctions(context.site.data.ui, 'not_found')
return (
<div className="min-h-screen d-flex flex-column">
<SimpleHeader />
<AppRouterHeader />
<div className="container-xl p-responsive py-6 width-full flex-1">
<article className="col-md-10 col-lg-7 mx-auto">
<h1>{t('oops')}</h1>
<SimpleLead>It looks like this page doesn't exist.</SimpleLead>
<p className="f3">
We track these errors automatically, but if the problem persists please feel free to
contact us.
</p>
<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" />
Contact support
{t('contact_cta')}
</a>
</article>
</div>
<SimpleFooter />
<ServerFooter currentLanguage={context.currentLanguage} />
</div>
)
}

View 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">&copy; {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>
)
}

View 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 ?? '')
}

View File

@@ -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 { type ClientLanguageCode } from '@/languages/lib/client-languages'
import { translate } from '@/languages/lib/translation-utils'
import { extractLanguageFromPath } from '@/app/lib/language-utils'
export interface AppRouterContext {
currentLanguage: string
currentLanguage: ClientLanguageCode
currentVersion: string
sitename: string
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
const language =
headersList.get('x-docs-language') ||
extractLanguageFromHeader(headersList.get('accept-language') || 'en')
const version = headersList.get('x-docs-version') || 'free-pro-team@latest'
// Use fallback if provided and URL doesn't specify language
if (language === 'en' && fallbackLanguage && fallbackLanguage !== 'en') {
language = fallbackLanguage
}
// 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 siteName = translate(uiData, 'header.github_docs', 'GitHub Docs')
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
View 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>

View 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}`
}

View 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 || {},
}
}

View File

@@ -1,8 +1,10 @@
'use client'
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 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
@@ -16,8 +18,32 @@ function isValidLocale(locale: string): locale is ClientLanguageCode {
*/
export function useDetectLocale(): ClientLanguageCode {
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(() => {
// 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')
if (pathname) {
const pathSegments = pathname.split('/')
@@ -28,16 +54,18 @@ export function useDetectLocale(): ClientLanguageCode {
}
}
// Fallback to browser locale if available
if (typeof window !== 'undefined' && window.navigator?.language) {
const browserLocale = window.navigator.language.split('-')[0]
if (isValidLocale(browserLocale)) {
return browserLocale
}
// Use cookie language if available
if (cookieLanguage) {
return cookieLanguage
}
// Use browser language if available
if (browserLanguage) {
return browserLanguage
}
return 'en'
}, [pathname])
}, [pathname, cookieLanguage, browserLanguage])
}
/**

View 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)
}

View File

@@ -1,27 +1,21 @@
import { AppRouterMainContextProvider } from '@/app/components/AppRouterMainContext'
import { NotFoundContent } from '@/app/components/NotFoundContent'
import { getAppRouterContext } from '@/app/lib/app-router-context'
import { ClientNotFoundWrapper } from '@/app/components/ClientNotFoundWrapper'
import { createServerAppRouterContext } from '@/app/lib/server-context-utils'
import { headers } from 'next/headers'
import type { Metadata } from 'next'
// Force this page to be dynamic so it can access headers()
export const dynamic = 'force-dynamic'
export const metadata: Metadata = {
title: '404 - Page not found',
other: {
status: '404',
},
other: { status: '404' },
}
async function NotFoundPage() {
// Get context from headers set by gateway middleware
const appContext = await getAppRouterContext()
export default async function NotFoundPage() {
const headersList = await headers()
const pathname = headersList.get('x-pathname') || '/'
return (
<AppRouterMainContextProvider context={appContext}>
<NotFoundContent />
</AppRouterMainContextProvider>
)
// Create server context using utility function
const appContext = createServerAppRouterContext(pathname)
return <ClientNotFoundWrapper appContext={appContext} />
}
export default NotFoundPage

View File

@@ -343,3 +343,9 @@ cookbook_landing:
search_articles: Search articles
category: Category
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

View File

@@ -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: {},
}),
)
}
}

View File

@@ -5,34 +5,31 @@ import {
shouldUseAppRouter,
stripLocalePrefix,
} from '@/app/lib/routing-patterns'
import { defaultCacheControl } from './cache-control'
import type { ExtendedRequest } from '@/types'
import type { NextFunction, Response } from 'express'
import { setAppRouterContextHeaders } from '../lib/header-utils'
import { defaultCacheControl } from './cache-control'
import { nextApp } from './next'
export default function appRouterGateway(req: ExtendedRequest, res: Response, next: NextFunction) {
const path = req.path || req.url
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') {
return next()
}
// Special case: Always intercept /empty-categories paths regardless of method
// Special case: Always intercept /empty-categories paths
if (strippedPath.endsWith('/empty-categories')) {
// Skip the normal exclusion logic and go straight to App Router routing
const pageFound = !!(req.context && req.context.page)
if (shouldUseAppRouter(path, pageFound)) {
// Set the URL to trigger App Router's not-found.tsx since /empty-categories should 404
req.url = '/404'
res.status(404)
defaultCacheControl(res)
// Set context headers for App Router (don't preserve Fastly since this is internal routing)
setAppRouterContextHeaders(req, res, false)
// Only pass the original pathname - no other headers needed
res.setHeader('x-pathname', req.path)
res.locals = res.locals || {}
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
// 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 (
path.startsWith('/_next/') ||
path.startsWith('/_build') ||
@@ -56,7 +48,7 @@ export default function appRouterGateway(req: ExtendedRequest, res: Response, ne
isJunkPath(path) ||
(isVersionedPath(path) &&
!isInvalidVersionedPath(path) &&
!strippedPathForExclusion.endsWith('/empty-categories')) ||
!strippedPath.endsWith('/empty-categories')) ||
path.includes('.css') ||
path.includes('.js') ||
path.includes('.map') ||
@@ -71,22 +63,17 @@ export default function appRouterGateway(req: ExtendedRequest, res: Response, ne
return next()
}
// Check if a page was found by the findPage middleware
const pageFound = !!(req.context && req.context.page)
if (shouldUseAppRouter(path, pageFound)) {
console.log(`[INFO] Using App Router for path: ${path} (pageFound: ${!!pageFound})`)
// Strip locale prefix for App Router routing
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) {
// Set the URL to trigger App Router's not-found.tsx
req.url = '/404' // Use a real App Router page route
req.url = '/404'
res.status(404)
// Set proper cache headers for 404 responses to match Pages Router behavior
defaultCacheControl(res)
} else {
// 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)
}
// Set context headers for App Router (preserve Fastly headers)
setAppRouterContextHeaders(req, res, true)
// 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
// Only pass pathname for App Router context creation
res.setHeader('x-pathname', req.path)
// Mark response as handled to prevent further middleware processing
res.locals = res.locals || {}
res.locals.handledByAppRouter = true
// Use the Next.js request handler and DO NOT call next()
return nextApp.getRequestHandler()(req, res)
}
console.log(`[INFO] Using Pages Router for path: ${path}`)
// Continue with Pages Router pipeline
return next()
}

View File

@@ -11,7 +11,6 @@ import statsd from '@/observability/lib/statsd'
import type { ExtendedRequest } from '@/types'
import { allVersions } from '@/versions/lib/all-versions'
import { minimumNotFoundHtml } from '../lib/constants'
import { setAppRouterContextHeaders } from '../lib/header-utils'
import { defaultCacheControl } from './cache-control'
import { isConnectionDropped } from './halt-on-dropped-connection'
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) {
// Skip if App Router has already handled this request
if (res.locals?.handledByAppRouter) {
return // Request already handled by App Router
return
}
const { context } = req
@@ -75,9 +74,7 @@ export default async function renderPage(req: ExtendedRequest, res: Response) {
return res.status(404).type('html').send(minimumNotFoundHtml)
}
// The rest is "unhandled" requests where we don't have the page
// but the URL looks like a real page.
// For App Router migration: All language-prefixed 404s should use App Router
statsd.increment(STATSD_KEY_404, 1, [
`url:${req.url}`,
`path:${req.path}`,
@@ -86,15 +83,14 @@ export default async function renderPage(req: ExtendedRequest, res: Response) {
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
const mockReq = Object.create(req)
mockReq.url = '/404'
mockReq.path = '/404'
mockReq.method = 'GET'
// Set context headers for App Router (preserves Fastly headers)
setAppRouterContextHeaders(req, res, true)
// Only pass pathname
res.setHeader('x-pathname', req.path)
// Import nextApp and handle directly
const { nextApp } = await import('./next')