feat: Implement App Router integration and 404 handling (#56915)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
105
src/app/404/page.tsx
Normal file
105
src/app/404/page.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
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 type { Metadata } from 'next'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: '404 - Page not found',
|
||||
other: { status: '404' },
|
||||
}
|
||||
|
||||
export default async function Page404() {
|
||||
// Get context with UI data
|
||||
const appContext = await getAppRouterContext()
|
||||
|
||||
const siteTitle = translate(appContext.site.data.ui, 'header.github_docs', 'GitHub Docs')
|
||||
const oopsTitle = translate(appContext.site.data.ui, 'meta.oops', 'Ooops!')
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
7
src/app/_not-found/page.tsx
Normal file
7
src/app/_not-found/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { notFound } from 'next/navigation'
|
||||
|
||||
// This page handles internal /_not-found redirects from Express middleware
|
||||
export default function InternalNotFound() {
|
||||
// This will trigger Next.js to render the not-found.tsx page
|
||||
notFound()
|
||||
}
|
||||
126
src/app/client-layout.tsx
Normal file
126
src/app/client-layout.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
'use client'
|
||||
|
||||
import { ThemeProvider } from '@primer/react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { LocaleProvider } from '@/app/lib/locale-context'
|
||||
import { useDetectLocale } from '@/app/lib/use-detect-locale'
|
||||
import { useTheme } from '@/color-schemes/components/useTheme'
|
||||
import { initializeEvents } from '@/events/components/events'
|
||||
import { CTAPopoverProvider } from '@/frame/components/context/CTAContext'
|
||||
import { SharedUIContextProvider } from '@/frame/components/context/SharedUIContext'
|
||||
import { LanguagesContext, LanguagesContextT } from '@/languages/components/LanguagesContext'
|
||||
import { clientLanguages, type ClientLanguageCode } from '@/languages/lib/client-languages'
|
||||
import { MainContextProvider } from '@/app/components/MainContextProvider'
|
||||
import { createMinimalMainContext } from '@/app/lib/main-context-adapter'
|
||||
import type { AppRouterContext } from '@/app/lib/app-router-context'
|
||||
|
||||
interface ClientLayoutProps {
|
||||
readonly children: React.ReactNode
|
||||
readonly appContext?: AppRouterContext
|
||||
readonly pageData?: {
|
||||
title?: string
|
||||
fullTitle?: string
|
||||
introPlainText?: string
|
||||
topics?: string[]
|
||||
documentType?: string
|
||||
type?: string
|
||||
hidden?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export function ClientLayout({ children, appContext, pageData }: ClientLayoutProps): JSX.Element {
|
||||
const { theme } = useTheme()
|
||||
const locale: ClientLanguageCode = useDetectLocale()
|
||||
const [isInitialized, setIsInitialized] = useState(false)
|
||||
const [initializationError, setInitializationError] = useState<Error | null>(null)
|
||||
|
||||
const languagesContext: LanguagesContextT = useMemo(
|
||||
() => ({
|
||||
languages: clientLanguages,
|
||||
}),
|
||||
[],
|
||||
)
|
||||
|
||||
// Create MainContext-compatible data for App Router
|
||||
const mainContext = useMemo(
|
||||
() => createMinimalMainContext(pageData, appContext),
|
||||
[pageData, appContext],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const initializeTheme = async (): Promise<void> => {
|
||||
try {
|
||||
const html = document.documentElement
|
||||
|
||||
if (theme.css?.colorMode) {
|
||||
html.setAttribute('data-color-mode', theme.css.colorMode)
|
||||
}
|
||||
|
||||
if (theme.css?.darkTheme) {
|
||||
html.setAttribute('data-dark-theme', theme.css.darkTheme)
|
||||
}
|
||||
|
||||
if (theme.css?.lightTheme) {
|
||||
html.setAttribute('data-light-theme', theme.css.lightTheme)
|
||||
}
|
||||
|
||||
if (!isInitialized) {
|
||||
await initializeEvents()
|
||||
setIsInitialized(true)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize theme:', error)
|
||||
setInitializationError(error as Error)
|
||||
}
|
||||
}
|
||||
|
||||
initializeTheme()
|
||||
}, [theme, isInitialized])
|
||||
|
||||
if (initializationError) {
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="min-h-screen flex items-center justify-center bg-canvas-default p-4"
|
||||
>
|
||||
<div className="max-w-md text-center">
|
||||
<h2 className="text-xl font-semibold mb-4 text-danger-fg">Something went wrong</h2>
|
||||
<p className="text-fg-muted mb-4">Please try refreshing the page.</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setInitializationError(null)
|
||||
setIsInitialized(false)
|
||||
}}
|
||||
className="btn btn-primary"
|
||||
type="button"
|
||||
aria-label="Try again"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<LocaleProvider locale={locale}>
|
||||
<LanguagesContext.Provider value={languagesContext}>
|
||||
<MainContextProvider value={mainContext}>
|
||||
<ThemeProvider
|
||||
colorMode={theme.component.colorMode}
|
||||
dayScheme={theme.component.dayScheme}
|
||||
nightScheme={theme.component.nightScheme}
|
||||
preventSSRMismatch
|
||||
>
|
||||
<SharedUIContextProvider>
|
||||
<CTAPopoverProvider>
|
||||
<div className="min-h-screen flex flex-col">{children}</div>
|
||||
</CTAPopoverProvider>
|
||||
</SharedUIContextProvider>
|
||||
</ThemeProvider>
|
||||
</MainContextProvider>
|
||||
</LanguagesContext.Provider>
|
||||
</LocaleProvider>
|
||||
)
|
||||
}
|
||||
52
src/app/components/AppRouterMainContext.tsx
Normal file
52
src/app/components/AppRouterMainContext.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
'use client'
|
||||
import type { AppRouterContext } from '@/app/lib/app-router-context'
|
||||
import type { MainContextT } from '@/frame/components/context/MainContext'
|
||||
import { adaptAppRouterContextToMainContext } from '@/app/lib/main-context-adapter'
|
||||
import { createContext, ReactNode, useContext, useMemo } from 'react'
|
||||
|
||||
export const AppRouterMainContext = createContext<AppRouterContext | null>(null)
|
||||
|
||||
// Provides MainContext-compatible data
|
||||
export const AppRouterCompatMainContext = createContext<MainContextT | null>(null)
|
||||
|
||||
export function AppRouterMainContextProvider({
|
||||
children,
|
||||
context,
|
||||
}: {
|
||||
children: ReactNode
|
||||
context: AppRouterContext
|
||||
}) {
|
||||
// Create a MainContext-compatible version for existing components
|
||||
const mainContextCompat = useMemo(() => adaptAppRouterContextToMainContext(context), [context])
|
||||
|
||||
return (
|
||||
<AppRouterMainContext.Provider value={context}>
|
||||
<AppRouterCompatMainContext.Provider value={mainContextCompat}>
|
||||
{children}
|
||||
</AppRouterCompatMainContext.Provider>
|
||||
</AppRouterMainContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useAppRouterMainContext(): AppRouterContext {
|
||||
const context = useContext(AppRouterMainContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useAppRouterMainContext must be used within AppRouterMainContextProvider')
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
// Hook for components that need MainContext compatibility
|
||||
export function useAppRouterCompatMainContext(): MainContextT {
|
||||
const context = useContext(AppRouterCompatMainContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'useAppRouterCompatMainContext must be used within AppRouterMainContextProvider',
|
||||
)
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
17
src/app/components/MainContextProvider.tsx
Normal file
17
src/app/components/MainContextProvider.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { MainContext, type MainContextT } from '@/frame/components/context/MainContext'
|
||||
|
||||
interface MainContextProviderProps {
|
||||
children: ReactNode
|
||||
value: MainContextT
|
||||
}
|
||||
|
||||
/**
|
||||
* App Router compatible MainContext provider
|
||||
* This allows reusing existing components that depend on MainContext
|
||||
*/
|
||||
export function MainContextProvider({ children, value }: MainContextProviderProps) {
|
||||
return <MainContext.Provider value={value}>{children}</MainContext.Provider>
|
||||
}
|
||||
119
src/app/components/NotFoundContent.tsx
Normal file
119
src/app/components/NotFoundContent.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
'use client'
|
||||
import { useAppRouterMainContext } from '@/app/components/AppRouterMainContext'
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
export function NotFoundContent() {
|
||||
const context = useAppRouterMainContext()
|
||||
|
||||
const { t } = useMemo(
|
||||
() => createTranslationFunctions(context.site.data.ui, ['meta']),
|
||||
[context.site.data.ui],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen d-flex flex-column">
|
||||
<SimpleHeader />
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<SimpleFooter />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
54
src/app/layout.tsx
Normal file
54
src/app/layout.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import '@/frame/stylesheets/index.scss'
|
||||
import type { Metadata, Viewport } from 'next'
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
template: '%s | GitHub Docs',
|
||||
default: 'GitHub Docs',
|
||||
},
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: '/assets/cb-345/images/site/favicon.png', sizes: '32x32', type: 'image/png' },
|
||||
{ url: '/assets/cb-345/images/site/favicon.ico', sizes: '48x48', type: 'image/x-icon' },
|
||||
],
|
||||
shortcut: '/assets/cb-345/images/site/favicon.ico',
|
||||
apple: '/assets/cb-345/images/site/favicon.png',
|
||||
},
|
||||
verification: {
|
||||
google: [
|
||||
'OgdQc0GZfjDI52wDv1bkMT-SLpBUo_h5nn9mI9L22xQ',
|
||||
'c1kuD-K2HIVF635lypcsWPoD4kilo5-jA_wBFyT4uMY',
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
}
|
||||
|
||||
interface RootLayoutProps {
|
||||
readonly children: ReactNode
|
||||
}
|
||||
|
||||
// Root layout for App Router pages
|
||||
export default function RootLayout({ children }: RootLayoutProps): JSX.Element {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
suppressHydrationWarning
|
||||
data-color-mode="auto"
|
||||
data-light-theme="light"
|
||||
data-dark-theme="dark"
|
||||
>
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
{/* DNS prefetch for performance */}
|
||||
<link rel="dns-prefetch" href="//github.com" />
|
||||
<link rel="dns-prefetch" href="//api.github.com" />
|
||||
</head>
|
||||
<body className="min-h-screen bg-canvas-default text-fg-default">{children}</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
87
src/app/lib/app-router-context.ts
Normal file
87
src/app/lib/app-router-context.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
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'
|
||||
|
||||
export interface AppRouterContext {
|
||||
currentLanguage: string
|
||||
currentVersion: string
|
||||
sitename: string
|
||||
site: {
|
||||
data: {
|
||||
ui: any
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAppRouterContext(): Promise<AppRouterContext> {
|
||||
const headersList = await headers()
|
||||
|
||||
// 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'
|
||||
|
||||
// Load UI data directly from data directory the same way as Pages Router does it
|
||||
const uiData = getUIDataMerged(language)
|
||||
|
||||
const siteName = translate(uiData, 'header.github_docs', 'GitHub Docs')
|
||||
|
||||
return {
|
||||
currentLanguage: language,
|
||||
currentVersion: version,
|
||||
sitename: siteName,
|
||||
site: {
|
||||
data: {
|
||||
ui: uiData,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
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: '',
|
||||
}
|
||||
}
|
||||
121
src/app/lib/locale-context.tsx
Normal file
121
src/app/lib/locale-context.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, useContext, ReactNode, useMemo } from 'react'
|
||||
import {
|
||||
clientLanguages,
|
||||
clientLanguageKeys,
|
||||
type ClientLanguageCode,
|
||||
} from '@/languages/lib/client-languages'
|
||||
|
||||
interface LocaleContextType {
|
||||
readonly locale: ClientLanguageCode
|
||||
readonly isValidLocale: (locale: string) => locale is ClientLanguageCode
|
||||
readonly getSupportedLocales: () => readonly ClientLanguageCode[]
|
||||
readonly getLocaleDisplayName: (locale: ClientLanguageCode) => string
|
||||
readonly getLocaleNativeName: (locale: ClientLanguageCode) => string
|
||||
}
|
||||
|
||||
const LocaleContext = createContext<LocaleContextType | null>(null)
|
||||
|
||||
interface LocaleProviderProps {
|
||||
readonly children: ReactNode
|
||||
readonly locale: ClientLanguageCode
|
||||
}
|
||||
|
||||
// Use client languages as the source of truth for supported locales
|
||||
const SUPPORTED_LOCALES: readonly ClientLanguageCode[] = clientLanguageKeys as ClientLanguageCode[]
|
||||
|
||||
/**
|
||||
* Validates if a string is a supported locale
|
||||
*/
|
||||
function isValidLocale(locale: string): locale is ClientLanguageCode {
|
||||
return clientLanguageKeys.includes(locale)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets display name for a locale from client languages data
|
||||
*/
|
||||
function getLocaleDisplayName(locale: ClientLanguageCode): string {
|
||||
return clientLanguages[locale]?.name || locale
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets native name for a locale from client languages data
|
||||
*/
|
||||
function getLocaleNativeName(locale: ClientLanguageCode): string {
|
||||
return clientLanguages[locale]?.nativeName || clientLanguages[locale]?.name || locale
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets browser language preference as a valid locale
|
||||
*/
|
||||
function getBrowserLocale(): ClientLanguageCode {
|
||||
if (typeof window === 'undefined') return 'en'
|
||||
|
||||
const browserLang = window.navigator.language.split('-')[0]
|
||||
return isValidLocale(browserLang) ? browserLang : 'en'
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced locale provider with validation and error handling
|
||||
*/
|
||||
export function LocaleProvider({ children, locale }: LocaleProviderProps): JSX.Element {
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
locale: isValidLocale(locale) ? locale : 'en',
|
||||
isValidLocale,
|
||||
getSupportedLocales: () => SUPPORTED_LOCALES,
|
||||
getLocaleDisplayName,
|
||||
getLocaleNativeName,
|
||||
}),
|
||||
[locale],
|
||||
)
|
||||
|
||||
return <LocaleContext.Provider value={contextValue}>{children}</LocaleContext.Provider>
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get current locale with enhanced error handling
|
||||
*/
|
||||
export function useLocale(): ClientLanguageCode {
|
||||
const context = useContext(LocaleContext)
|
||||
|
||||
if (context) {
|
||||
return context.locale
|
||||
}
|
||||
|
||||
// Fallback for when not within LocaleProvider
|
||||
// This handles cases where the hook is used outside of the provider
|
||||
console.warn('useLocale called outside of LocaleProvider, using fallback')
|
||||
return getBrowserLocale()
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to validate locales
|
||||
*/
|
||||
export function useLocaleValidation() {
|
||||
const context = useContext(LocaleContext)
|
||||
|
||||
return {
|
||||
isValidLocale: context?.isValidLocale ?? isValidLocale,
|
||||
getSupportedLocales: context?.getSupportedLocales ?? (() => SUPPORTED_LOCALES),
|
||||
getLocaleDisplayName: context?.getLocaleDisplayName ?? getLocaleDisplayName,
|
||||
getLocaleNativeName: context?.getLocaleNativeName ?? getLocaleNativeName,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get locale context (for advanced use cases)
|
||||
*/
|
||||
export function useLocaleContext(): LocaleContextType {
|
||||
const context = useContext(LocaleContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useLocaleContext must be used within a LocaleProvider')
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
export { isValidLocale, getLocaleDisplayName, getLocaleNativeName }
|
||||
export type { LocaleContextType, ClientLanguageCode }
|
||||
108
src/app/lib/main-context-adapter.ts
Normal file
108
src/app/lib/main-context-adapter.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import type { MainContextT } from '@/frame/components/context/MainContext'
|
||||
import type { AppRouterContext } from '@/app/lib/app-router-context'
|
||||
|
||||
/**
|
||||
* Adapter to create MainContext-compatible data from App Router context
|
||||
* Allows reusing existing components that depend on MainContext
|
||||
*/
|
||||
export function adaptAppRouterContextToMainContext(
|
||||
appContext: AppRouterContext,
|
||||
overrides: Partial<MainContextT> = {},
|
||||
): MainContextT {
|
||||
const baseContext: MainContextT = {
|
||||
data: {
|
||||
ui: appContext.site.data.ui,
|
||||
reusables: {},
|
||||
variables: {
|
||||
release_candidate: { version: null },
|
||||
},
|
||||
},
|
||||
|
||||
// Default/fallback values that can be overridden
|
||||
allVersions: {},
|
||||
breadcrumbs: {
|
||||
product: {
|
||||
title: '',
|
||||
href: undefined,
|
||||
},
|
||||
},
|
||||
communityRedirect: {
|
||||
name: '',
|
||||
href: '',
|
||||
},
|
||||
currentPathWithoutLanguage: '',
|
||||
currentProduct: undefined,
|
||||
currentProductName: '',
|
||||
currentProductTree: null,
|
||||
currentVersion: appContext.currentVersion,
|
||||
enterpriseServerReleases: {
|
||||
isOldestReleaseDeprecated: false,
|
||||
oldestSupported: '',
|
||||
nextDeprecationDate: '',
|
||||
supported: [],
|
||||
},
|
||||
enterpriseServerVersions: [],
|
||||
error: '',
|
||||
featureFlags: {},
|
||||
fullUrl: '',
|
||||
isHomepageVersion: false,
|
||||
nonEnterpriseDefaultVersion: 'free-pro-team@latest',
|
||||
page: null,
|
||||
relativePath: undefined,
|
||||
sidebarTree: null,
|
||||
status: 200,
|
||||
xHost: '',
|
||||
|
||||
// Apply any overrides
|
||||
...overrides,
|
||||
}
|
||||
|
||||
return baseContext
|
||||
}
|
||||
|
||||
/**
|
||||
* For components that need MainContext data in App Router,
|
||||
* this helper provides a minimal compatible context
|
||||
*/
|
||||
export function createMinimalMainContext(
|
||||
pageData?: {
|
||||
title?: string
|
||||
fullTitle?: string
|
||||
introPlainText?: string
|
||||
topics?: string[]
|
||||
documentType?: string
|
||||
type?: string
|
||||
hidden?: boolean
|
||||
},
|
||||
appContext?: AppRouterContext,
|
||||
): MainContextT {
|
||||
const defaultAppContext: AppRouterContext = appContext || {
|
||||
currentLanguage: 'en',
|
||||
currentVersion: 'free-pro-team@latest',
|
||||
sitename: 'GitHub Docs',
|
||||
site: {
|
||||
data: {
|
||||
ui: {
|
||||
header: { github_docs: 'GitHub Docs' },
|
||||
footer: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return adaptAppRouterContextToMainContext(defaultAppContext, {
|
||||
page: pageData
|
||||
? {
|
||||
documentType: pageData.documentType || 'article',
|
||||
type: pageData.type,
|
||||
topics: pageData.topics || [],
|
||||
title: pageData.title || 'Page Not Found',
|
||||
fullTitle: pageData.fullTitle || pageData.title,
|
||||
introPlainText: pageData.introPlainText,
|
||||
hidden: pageData.hidden || false,
|
||||
noEarlyAccessBanner: false,
|
||||
applicableVersions: [],
|
||||
}
|
||||
: null,
|
||||
})
|
||||
}
|
||||
118
src/app/lib/routing-patterns.ts
Normal file
118
src/app/lib/routing-patterns.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Shared routing patterns for determining App Router vs Pages Router routing decisions.
|
||||
* This module centralizes pattern definitions to ensure consistency between
|
||||
* app-router-gateway.ts and render-page.ts
|
||||
*/
|
||||
|
||||
// Define which routes should use App Router (without locale prefix)
|
||||
const APP_ROUTER_ROUTES = new Set([
|
||||
'/_not-found',
|
||||
'/404',
|
||||
// Add more routes as you migrate them
|
||||
])
|
||||
|
||||
/**
|
||||
* Determines if a given path should be handled by the App Router
|
||||
* @param path - The request path to check
|
||||
* @param pageFound - Whether a page was found by the findPage middleware
|
||||
* @returns true if the path should use App Router, false for Pages Router
|
||||
*/
|
||||
export function shouldUseAppRouter(path: string, pageFound: boolean = true): boolean {
|
||||
// Strip locale prefix before checking
|
||||
const strippedPath = stripLocalePrefix(path)
|
||||
|
||||
// Check exact matches on the stripped path for specific App Router routes
|
||||
if (APP_ROUTER_ROUTES.has(strippedPath)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Special case: paths ending with /empty-categories should always 404 via App Router
|
||||
// This handles translation tests where certain versioned paths should not exist
|
||||
if (strippedPath.endsWith('/empty-categories')) {
|
||||
return true
|
||||
}
|
||||
|
||||
// For 404 migration: If no page was found, use App Router for 404 handling
|
||||
if (!pageFound) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a path looks like a "junk path" that should be handled by shielding middleware
|
||||
* These are paths like WordPress attacks, config files, etc. that need specific 404 handling
|
||||
*/
|
||||
export function isJunkPath(path: string): boolean {
|
||||
// Common attack patterns and junk paths that should be handled by shielding
|
||||
const junkPatterns = [
|
||||
/^\/wp-/, // WordPress paths: /wp-content, /wp-login.php, etc.
|
||||
/^\/[^/]*\.php$/, // PHP files in root: xmlrpc.php, wp-login.php (but not /en/delicious-snacks/donuts.php)
|
||||
/^\/~\w+/, // User directory paths: /~root, /~admin, etc.
|
||||
/\/\.env/, // Environment files: /.env, /.env.local, etc.
|
||||
/\/package(-lock)?\.json$/, // Node.js package files
|
||||
/^\/_{2,}/, // Multiple underscores: ///, /\\, etc.
|
||||
/^\/\\+/, // Backslash attacks
|
||||
]
|
||||
|
||||
return junkPatterns.some((pattern) => pattern.test(path))
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a path contains a version identifier (e.g., enterprise-server@3.16, enterprise-cloud@latest)
|
||||
* This helps distinguish versioned docs paths from regular paths that should potentially use App Router
|
||||
*/
|
||||
export function isVersionedPath(path: string): boolean {
|
||||
const strippedPath = stripLocalePrefix(path)
|
||||
|
||||
// Check for version patterns: plan@release
|
||||
// Examples: enterprise-server@3.16, enterprise-server@latest, enterprise-cloud@latest, free-pro-team@latest
|
||||
const versionPattern =
|
||||
/(enterprise-server@[\d.]+|enterprise-server@latest|enterprise-cloud@latest|free-pro-team@latest)/
|
||||
return versionPattern.test(strippedPath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a versioned path contains invalid segments that should result in 404
|
||||
* These should be routed to App Router for proper 404 handling
|
||||
*/
|
||||
export function isInvalidVersionedPath(path: string): boolean {
|
||||
const strippedPath = stripLocalePrefix(path)
|
||||
|
||||
// Check for obviously invalid paths that should 404
|
||||
// Example: /enterprise-server@latest/ANY/admin or /enterprise-server@12345/anything
|
||||
return (
|
||||
strippedPath.includes('/ANY/') ||
|
||||
/enterprise-server@12345/.test(strippedPath) ||
|
||||
// Add other invalid patterns as needed
|
||||
false
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes a URL path, handling URL-encoded characters like %40 -> @
|
||||
*/
|
||||
export function decodePathSafely(path: string): string {
|
||||
try {
|
||||
return decodeURIComponent(path)
|
||||
} catch {
|
||||
// If decoding fails, use original path
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips the locale prefix from the path if present
|
||||
* e.g., /en/search -> /search
|
||||
* e.g., /en/enterprise-server@3.17 -> /enterprise-server@3.17
|
||||
*/
|
||||
export function stripLocalePrefix(path: string): string {
|
||||
const decodedPath = decodePathSafely(path)
|
||||
|
||||
const localeMatch = decodedPath.match(/^\/([a-z]{2})(\/.*)?$/)
|
||||
if (localeMatch) {
|
||||
return localeMatch[2] || '/'
|
||||
}
|
||||
return decodedPath
|
||||
}
|
||||
61
src/app/lib/use-detect-locale.tsx
Normal file
61
src/app/lib/use-detect-locale.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
'use client'
|
||||
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useMemo } from 'react'
|
||||
import { clientLanguageKeys, type ClientLanguageCode } from '@/languages/lib/client-languages'
|
||||
|
||||
/**
|
||||
* Validates if a string is a supported locale using client languages
|
||||
*/
|
||||
function isValidLocale(locale: string): locale is ClientLanguageCode {
|
||||
return clientLanguageKeys.includes(locale)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to detect locale from various sources with fallback logic
|
||||
*/
|
||||
export function useDetectLocale(): ClientLanguageCode {
|
||||
const pathname = usePathname()
|
||||
|
||||
return useMemo(() => {
|
||||
// Extract locale from pathname (e.g., /es/search -> 'es')
|
||||
if (pathname) {
|
||||
const pathSegments = pathname.split('/')
|
||||
const firstSegment = pathSegments[1]
|
||||
|
||||
if (firstSegment && isValidLocale(firstSegment)) {
|
||||
return firstSegment
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
return 'en'
|
||||
}, [pathname])
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to detect locale from pathname (for server-side use)
|
||||
*/
|
||||
export function detectLocaleFromPath(pathname: string): ClientLanguageCode {
|
||||
const pathSegments = pathname.split('/')
|
||||
const firstSegment = pathSegments[1]
|
||||
|
||||
if (firstSegment && isValidLocale(firstSegment)) {
|
||||
return firstSegment
|
||||
}
|
||||
|
||||
return 'en'
|
||||
}
|
||||
|
||||
export function getSupportedLocales(): readonly ClientLanguageCode[] {
|
||||
return clientLanguageKeys as ClientLanguageCode[]
|
||||
}
|
||||
|
||||
export { isValidLocale }
|
||||
27
src/app/not-found.tsx
Normal file
27
src/app/not-found.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { AppRouterMainContextProvider } from '@/app/components/AppRouterMainContext'
|
||||
import { NotFoundContent } from '@/app/components/NotFoundContent'
|
||||
import { getAppRouterContext } from '@/app/lib/app-router-context'
|
||||
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',
|
||||
},
|
||||
}
|
||||
|
||||
async function NotFoundPage() {
|
||||
// Get context from headers set by gateway middleware
|
||||
const appContext = await getAppRouterContext()
|
||||
|
||||
return (
|
||||
<AppRouterMainContextProvider context={appContext}>
|
||||
<NotFoundContent />
|
||||
</AppRouterMainContextProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default NotFoundPage
|
||||
83
src/app/types.ts
Normal file
83
src/app/types.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Enhanced type definitions for the app router with strict validation
|
||||
*/
|
||||
|
||||
import type { ClientLanguageCode } from '@/languages/lib/client-languages'
|
||||
|
||||
// Core theme types with strict validation
|
||||
export type Theme = 'light' | 'dark' | 'auto'
|
||||
export type ColorMode = 'light' | 'dark'
|
||||
|
||||
// Re-export locale type from client-languages for consistency
|
||||
export type Locale = ClientLanguageCode
|
||||
|
||||
// Version and product identifiers with validation
|
||||
export type VersionId = string
|
||||
export type ProductId = string
|
||||
export type CategoryId = string
|
||||
|
||||
// Enhanced page parameters with validation
|
||||
export interface PageParams {
|
||||
readonly versionId?: VersionId
|
||||
readonly productId?: ProductId
|
||||
readonly categoryId?: CategoryId
|
||||
}
|
||||
|
||||
// Search parameters with better typing
|
||||
export interface SearchParams {
|
||||
readonly [key: string]: string | string[] | undefined
|
||||
}
|
||||
|
||||
// Route context with comprehensive typing
|
||||
export interface RouteContext {
|
||||
readonly locale: Locale
|
||||
readonly versionId?: VersionId
|
||||
readonly productId?: ProductId
|
||||
readonly categoryId?: CategoryId
|
||||
}
|
||||
|
||||
// Theme configuration with complete typing
|
||||
export interface ThemeConfig {
|
||||
readonly theme: Theme
|
||||
readonly colorMode: ColorMode
|
||||
readonly component: {
|
||||
readonly colorMode: ColorMode
|
||||
readonly dayScheme: string
|
||||
readonly nightScheme: string
|
||||
}
|
||||
}
|
||||
|
||||
// Error types for better error handling
|
||||
export interface AppError {
|
||||
readonly message: string
|
||||
readonly code: string
|
||||
readonly statusCode: number
|
||||
readonly context?: Record<string, unknown>
|
||||
}
|
||||
|
||||
// Navigation item with accessibility support
|
||||
export interface NavigationItem {
|
||||
readonly href: string
|
||||
readonly title: string
|
||||
readonly isActive?: boolean
|
||||
readonly ariaLabel?: string
|
||||
readonly children?: readonly NavigationItem[]
|
||||
}
|
||||
|
||||
// Layout props with enhanced typing
|
||||
export interface LayoutProps {
|
||||
readonly children: React.ReactNode
|
||||
readonly className?: string
|
||||
}
|
||||
|
||||
// Component props with better composition
|
||||
export interface ComponentProps {
|
||||
readonly className?: string
|
||||
readonly children?: React.ReactNode
|
||||
readonly 'data-testid'?: string
|
||||
}
|
||||
|
||||
// Utility types for better type safety
|
||||
export type NonEmptyArray<T> = [T, ...T[]]
|
||||
export type RequiredFields<T, K extends keyof T> = T & Required<Pick<T, K>>
|
||||
export type OptionalFields<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>
|
||||
16
src/app/validators.ts
Normal file
16
src/app/validators.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { isValidLocale } from '@/app/lib/use-detect-locale'
|
||||
import { PageParams, SearchParams, Theme } from './types'
|
||||
|
||||
// Runtime validation helpers (theme-specific only, locale validation moved to use-detect-locale)
|
||||
export const isValidTheme = (theme: string): theme is Theme =>
|
||||
['light', 'dark', 'auto'].includes(theme)
|
||||
|
||||
// Type guards for runtime validation
|
||||
export const isPageParams = (obj: unknown): obj is PageParams =>
|
||||
typeof obj === 'object' && obj !== null
|
||||
|
||||
export const isSearchParams = (obj: unknown): obj is SearchParams =>
|
||||
typeof obj === 'object' && obj !== null
|
||||
|
||||
// Re-export locale validation
|
||||
export { isValidLocale }
|
||||
84
src/frame/lib/header-utils.ts
Normal file
84
src/frame/lib/header-utils.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* 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: {},
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
114
src/frame/middleware/app-router-gateway.ts
Normal file
114
src/frame/middleware/app-router-gateway.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import {
|
||||
isInvalidVersionedPath,
|
||||
isJunkPath,
|
||||
isVersionedPath,
|
||||
shouldUseAppRouter,
|
||||
stripLocalePrefix,
|
||||
} from '@/app/lib/routing-patterns'
|
||||
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
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
return next()
|
||||
}
|
||||
|
||||
// Special case: Always intercept /empty-categories paths regardless of method
|
||||
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)
|
||||
|
||||
res.locals = res.locals || {}
|
||||
res.locals.handledByAppRouter = true
|
||||
return nextApp.getRequestHandler()(req, res)
|
||||
}
|
||||
}
|
||||
|
||||
// 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('/assets/') ||
|
||||
path.startsWith('/public/') ||
|
||||
path.startsWith('/api/') ||
|
||||
path === '/api' ||
|
||||
isJunkPath(path) ||
|
||||
(isVersionedPath(path) &&
|
||||
!isInvalidVersionedPath(path) &&
|
||||
!strippedPathForExclusion.endsWith('/empty-categories')) ||
|
||||
path.includes('.css') ||
|
||||
path.includes('.js') ||
|
||||
path.includes('.map') ||
|
||||
path.includes('.ico') ||
|
||||
path.includes('.png') ||
|
||||
path.includes('.svg') ||
|
||||
path.endsWith('/manifest.json') ||
|
||||
path.endsWith('/robots.txt') ||
|
||||
path.endsWith('/llms.txt') ||
|
||||
path.endsWith('/_500')
|
||||
) {
|
||||
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
|
||||
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
|
||||
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
|
||||
const originalUrl = req.url
|
||||
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
|
||||
|
||||
// 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()
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { shouldUseAppRouter, isVersionedPath } from '@/app/lib/routing-patterns'
|
||||
import { isArchivedVersion } from '@/archives/lib/is-archived-version'
|
||||
import { languagePrefixPathRegex } from '@/languages/lib/languages'
|
||||
import versionSatisfiesRange from '@/versions/lib/version-satisfies-range'
|
||||
import type { NextFunction, Request, Response } from 'express'
|
||||
import helmet from 'helmet'
|
||||
import { isArchivedVersion } from '@/archives/lib/is-archived-version'
|
||||
import versionSatisfiesRange from '@/versions/lib/version-satisfies-range'
|
||||
import { languagePrefixPathRegex } from '@/languages/lib/languages'
|
||||
|
||||
const isDev = process.env.NODE_ENV === 'development'
|
||||
const GITHUB_DOMAINS = [
|
||||
@@ -80,10 +81,16 @@ devDirs.scriptSrcAttr.push("'unsafe-inline'")
|
||||
const STATIC_DEPRECATED_OPTIONS = structuredClone(DEFAULT_OPTIONS)
|
||||
STATIC_DEPRECATED_OPTIONS.contentSecurityPolicy.directives.scriptSrc.push("'unsafe-inline'")
|
||||
|
||||
// App Router specific CSP that allows inline scripts for NextJS hydration
|
||||
const APP_ROUTER_OPTIONS = structuredClone(DEFAULT_OPTIONS)
|
||||
const appRouterDirs = APP_ROUTER_OPTIONS.contentSecurityPolicy.directives
|
||||
appRouterDirs.scriptSrc.push("'unsafe-inline'") // Required for NextJS App Router hydration
|
||||
|
||||
const defaultHelmet = helmet(DEFAULT_OPTIONS)
|
||||
const nodeDeprecatedHelmet = helmet(NODE_DEPRECATED_OPTIONS)
|
||||
const staticDeprecatedHelmet = helmet(STATIC_DEPRECATED_OPTIONS)
|
||||
const developerDeprecatedHelmet = helmet(DEVELOPER_DEPRECATED_OPTIONS)
|
||||
const appRouterHelmet = helmet(APP_ROUTER_OPTIONS)
|
||||
|
||||
export default function helmetMiddleware(req: Request, res: Response, next: NextFunction) {
|
||||
// Enable CORS
|
||||
@@ -91,6 +98,24 @@ export default function helmetMiddleware(req: Request, res: Response, next: Next
|
||||
res.set('access-control-allow-origin', '*')
|
||||
}
|
||||
|
||||
// Check if this is an explicit App Router route
|
||||
if (shouldUseAppRouter(req.path, true)) {
|
||||
return appRouterHelmet(req, res, next)
|
||||
}
|
||||
|
||||
// For potential 404s that might be handled by App Router, use App Router CSP
|
||||
// This is a safe fallback since App Router CSP includes all necessary permissions
|
||||
// Apply to any path that could potentially be a 404, regardless of locale prefix
|
||||
if (
|
||||
!req.path.startsWith('/_next/') &&
|
||||
!req.path.startsWith('/assets/') &&
|
||||
!req.path.startsWith('/api/') &&
|
||||
!isVersionedPath(req.path)
|
||||
) {
|
||||
// This might be a 404 that gets routed to App Router, so use App Router CSP
|
||||
return appRouterHelmet(req, res, next)
|
||||
}
|
||||
|
||||
// Determine version for exceptions
|
||||
const { requestedVersion } = isArchivedVersion(req)
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@ import shielding from '@/shielding/middleware'
|
||||
import { MAX_REQUEST_TIMEOUT } from '@/frame/lib/constants'
|
||||
import { initLoggerContext } from '@/observability/logger/lib/logger-context'
|
||||
import { getAutomaticRequestLogger } from '@/observability/logger/middleware/get-automatic-request-logger'
|
||||
import appRouterGateway from './app-router-gateway'
|
||||
|
||||
const { NODE_ENV } = process.env
|
||||
const isTest = NODE_ENV === 'test' || process.env.GITHUB_ACTIONS === 'true'
|
||||
@@ -221,6 +222,9 @@ export default function (app: Express) {
|
||||
// Check for a dropped connection before proceeding
|
||||
app.use(haltOnDroppedConnection)
|
||||
|
||||
// *** Add App Router Gateway here - before heavy contextualizers ***
|
||||
app.use(asyncMiddleware(appRouterGateway))
|
||||
|
||||
// *** Rendering, 2xx responses ***
|
||||
app.use('/api', api)
|
||||
app.use('/llms.txt', llmsTxt)
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import http from 'http'
|
||||
|
||||
import { get } from 'lodash-es'
|
||||
import type { Response } from 'express'
|
||||
import type { Failbot } from '@github/failbot'
|
||||
|
||||
import type { ExtendedRequest } from '@/types'
|
||||
import FailBot from '@/observability/lib/failbot'
|
||||
import patterns from '@/frame/lib/patterns'
|
||||
import type { Failbot } from '@github/failbot'
|
||||
import { get } from 'lodash-es'
|
||||
|
||||
import getMiniTocItems from '@/frame/lib/get-mini-toc-items'
|
||||
import patterns from '@/frame/lib/patterns'
|
||||
import { pathLanguagePrefixed } from '@/languages/lib/languages'
|
||||
import FailBot from '@/observability/lib/failbot'
|
||||
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'
|
||||
import { defaultCacheControl } from './cache-control'
|
||||
import { minimumNotFoundHtml } from '../lib/constants'
|
||||
|
||||
const STATSD_KEY_RENDER = 'middleware.render_page'
|
||||
const STATSD_KEY_404 = 'middleware.render_404'
|
||||
@@ -45,6 +45,11 @@ 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
|
||||
}
|
||||
|
||||
const { context } = req
|
||||
|
||||
// This is a contextualizing the request so that when this `req` is
|
||||
@@ -79,43 +84,22 @@ export default async function renderPage(req: ExtendedRequest, res: Response) {
|
||||
`referer:${req.headers.referer || ''}`,
|
||||
])
|
||||
|
||||
// This means, we allow the CDN to cache it, but to be purged at the
|
||||
// next deploy. The length isn't very important as long as it gets
|
||||
// a new chance after the next deploy + purge.
|
||||
// This way, we only have to respond with this 404 once per deploy
|
||||
// and the CDN can cache it.
|
||||
defaultCacheControl(res)
|
||||
|
||||
// The reason we're *NOT* using `nextApp.render404` is because, in
|
||||
// Next v13, is for two reasons:
|
||||
//
|
||||
// 1. You cannot control the `cache-control` header. It always
|
||||
// gets set to `private, no-cache, no-store, max-age=0, must-revalidate`.
|
||||
// which is causing problems with Fastly because then we can't
|
||||
// let Fastly cache it till the next purge, even if we do set a
|
||||
// `Surrogate-Control` header.
|
||||
// 2. In local development, it will always hang and never respond.
|
||||
// Eventually you get a timeout error (503) after 10 seconds.
|
||||
//
|
||||
// The solution is to render a custom page (which is the
|
||||
// src/pages/404.tsx) but control the status code (and the Cache-Control).
|
||||
//
|
||||
// Create a new request for a real one.
|
||||
const tempReq = new http.IncomingMessage(req as any) as ExtendedRequest
|
||||
tempReq.method = 'GET'
|
||||
// There is a `src/pages/_notfound.txt`. That's why this will render
|
||||
// a working and valid React component.
|
||||
// It's important to not use `src/pages/404.txt` (or `/404` as the path)
|
||||
// here because then it will set the wrong Cache-Control header.
|
||||
tempReq.url = '/_notfound'
|
||||
Object.defineProperty(tempReq, 'path', { value: '/_notfound', writable: true })
|
||||
tempReq.cookies = {}
|
||||
tempReq.headers = {}
|
||||
// By default, since the lookup for a `src/pages/*.tsx` file will work,
|
||||
// inside the `nextHandleRequest` function, by default it will
|
||||
// think it all worked with a 200 OK.
|
||||
// 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)
|
||||
|
||||
// Import nextApp and handle directly
|
||||
const { nextApp } = await import('./next')
|
||||
res.status(404)
|
||||
return nextHandleRequest(tempReq, res)
|
||||
return nextApp.getRequestHandler()(mockReq, res)
|
||||
}
|
||||
|
||||
// Just finish fast without all the details like Content-Length
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { UIStrings } from '@/frame/components/context/MainContext'
|
||||
import { useMainContext } from '@/frame/components/context/MainContext'
|
||||
|
||||
class TranslationNamespaceError extends Error {}
|
||||
class UngettableError extends Error {}
|
||||
import { createTranslationFunctions } from '@/languages/lib/translation-utils'
|
||||
|
||||
// Used to pull translation UI strings from the page props into
|
||||
// React components. When you instantiate the hook you can pass
|
||||
@@ -35,79 +33,14 @@ class UngettableError extends Error {}
|
||||
// ...will throw because of the typo 'sav_changes' instead of 'save_changes'.
|
||||
export const useTranslation = (namespaces: string | Array<string>) => {
|
||||
const { data } = useMainContext()
|
||||
|
||||
const loadedData = data.ui
|
||||
|
||||
const namespacesArray = Array.isArray(namespaces) ? namespaces : [namespaces]
|
||||
|
||||
for (const namespace of namespacesArray) {
|
||||
if (!(namespace in loadedData)) {
|
||||
console.warn(
|
||||
'The following namespaces in data.ui have been loaded: ' +
|
||||
JSON.stringify(Object.keys(loadedData).sort()),
|
||||
)
|
||||
throw new TranslationNamespaceError(
|
||||
`Namespace "${namespace}" not found in data. ` +
|
||||
'Follow the stack trace to see which useTranslation(...) call is ' +
|
||||
'causing this error. If the namespace is present in data/ui.yml ' +
|
||||
'but this error is happening, find the related component ' +
|
||||
'getServerSideProps() it goes through and make sure it calls ' +
|
||||
`addUINamespaces() with "${namespace}".`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function carefulGetWrapper(path: string) {
|
||||
for (const namespace of namespacesArray) {
|
||||
if (!(namespace in loadedData)) {
|
||||
throw new TranslationNamespaceError(`Namespace "${namespace}" not found in data. `)
|
||||
}
|
||||
const deeper = loadedData[namespace]
|
||||
if (typeof deeper === 'string') {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
return carefulGet(deeper, path)
|
||||
} catch (error) {
|
||||
if (!(error instanceof UngettableError)) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return carefulGet(loadedData, path)
|
||||
}
|
||||
|
||||
return {
|
||||
tObject: (strings: TemplateStringsArray | string) => {
|
||||
const key = typeof strings === 'string' ? strings : String.raw(strings)
|
||||
return carefulGetWrapper(key) as UIStrings
|
||||
},
|
||||
t: (strings: TemplateStringsArray | string, ...values: Array<any>) => {
|
||||
const key = typeof strings === 'string' ? strings : String.raw(strings, ...values)
|
||||
return carefulGetWrapper(key) as string
|
||||
},
|
||||
}
|
||||
return createTranslationFunctions(loadedData, namespaces)
|
||||
}
|
||||
|
||||
function carefulGet(uiData: UIStrings, dottedPath: string) {
|
||||
const splitPath = dottedPath.split('.')
|
||||
const start = splitPath[0]
|
||||
if (!(start in uiData)) {
|
||||
throw new UngettableError(
|
||||
`Namespace "${start}" not found in loaded data (not one of: ${Object.keys(uiData).sort()})`,
|
||||
)
|
||||
}
|
||||
if (splitPath.length > 1) {
|
||||
const deeper = uiData[start]
|
||||
if (typeof deeper === 'string') {
|
||||
throw new Error(`Namespace "${start}" is a string, not an object`)
|
||||
}
|
||||
return carefulGet(deeper, splitPath.slice(1).join('.'))
|
||||
} else {
|
||||
if (!(start in uiData)) {
|
||||
throw new UngettableError(`Key "${start}" not found in loaded data`)
|
||||
}
|
||||
return uiData[start]
|
||||
}
|
||||
/**
|
||||
* Hook for App Router contexts that don't use MainContext
|
||||
*/
|
||||
export const useAppTranslation = (uiData: UIStrings, namespaces: string | Array<string>) => {
|
||||
return createTranslationFunctions(uiData, namespaces)
|
||||
}
|
||||
|
||||
67
src/languages/lib/client-languages.ts
Normal file
67
src/languages/lib/client-languages.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { LanguageItem } from '@/languages/components/LanguagesContext'
|
||||
|
||||
/**
|
||||
* Client-safe language data extracted from src/languages/lib/languages.ts.
|
||||
* Only used by frontend components.
|
||||
* Does not include server-side logic or Node.js-specific fs or path operations.
|
||||
*/
|
||||
export const clientLanguages: Record<string, LanguageItem> = {
|
||||
en: {
|
||||
name: 'English',
|
||||
code: 'en',
|
||||
nativeName: 'English',
|
||||
hreflang: 'en',
|
||||
},
|
||||
es: {
|
||||
name: 'Spanish',
|
||||
code: 'es',
|
||||
nativeName: 'Español',
|
||||
hreflang: 'es',
|
||||
},
|
||||
ja: {
|
||||
name: 'Japanese',
|
||||
code: 'ja',
|
||||
nativeName: '日本語',
|
||||
hreflang: 'ja',
|
||||
},
|
||||
pt: {
|
||||
name: 'Portuguese',
|
||||
code: 'pt',
|
||||
nativeName: 'Português do Brasil',
|
||||
hreflang: 'pt',
|
||||
},
|
||||
zh: {
|
||||
name: 'Simplified Chinese',
|
||||
code: 'zh',
|
||||
nativeName: '简体中文',
|
||||
hreflang: 'zh-Hans',
|
||||
},
|
||||
ru: {
|
||||
name: 'Russian',
|
||||
code: 'ru',
|
||||
nativeName: 'Русский',
|
||||
hreflang: 'ru',
|
||||
},
|
||||
fr: {
|
||||
name: 'French',
|
||||
code: 'fr',
|
||||
nativeName: 'Français',
|
||||
hreflang: 'fr',
|
||||
},
|
||||
ko: {
|
||||
name: 'Korean',
|
||||
code: 'ko',
|
||||
nativeName: '한국어',
|
||||
hreflang: 'ko',
|
||||
},
|
||||
de: {
|
||||
name: 'German',
|
||||
code: 'de',
|
||||
nativeName: 'Deutsch',
|
||||
hreflang: 'de',
|
||||
},
|
||||
}
|
||||
|
||||
export const clientLanguageKeys: string[] = Object.keys(clientLanguages)
|
||||
|
||||
export type ClientLanguageCode = keyof typeof clientLanguages
|
||||
@@ -74,6 +74,25 @@ export function correctTranslatedContentStrings(
|
||||
content = content.replaceAll('{% необработанные %}', '{% raw %}')
|
||||
content = content.replaceAll('{% подсказки %}', '{% tip %}')
|
||||
|
||||
// Fix YAML quote issues in UI files. Specifically the disclaimer href attribute
|
||||
// href="...}> -> href="...">
|
||||
content = content.replace(/href="([^"]*)}>/g, 'href="$1">')
|
||||
|
||||
// Fix double quotes in Russian YAML files that cause parsing errors
|
||||
// ""https:// -> "https://
|
||||
content = content.replace(/href=""https:\/\//g, 'href="https://')
|
||||
|
||||
// Fix empty HTML tags that cause YAML parsing issues
|
||||
content = content.replaceAll('<b></b>', '')
|
||||
content = content.replaceAll('<u></u>', '')
|
||||
|
||||
// Fix specific Russian UI YAML issues causing 502 errors
|
||||
// Remove empty bold tags from early_access notice
|
||||
content = content.replace(/early_access:\s*"([^"]*)<b><\/b>([^"]*)"/, 'early_access: "$1$2"')
|
||||
|
||||
// Remove empty underline tags from privacy disclaimer
|
||||
content = content.replace(/(privacy_disclaimer:[^<]*)<u><\/u>/g, '$1')
|
||||
|
||||
// For the rather custom Russian translation of
|
||||
// the content/get-started/learning-about-github/github-glossary.md page
|
||||
// These string replacements speak for themselves.
|
||||
@@ -90,6 +109,13 @@ export function correctTranslatedContentStrings(
|
||||
content = content.replaceAll('{% データ variables', '{% data variables')
|
||||
content = content.replaceAll('{% データvariables', '{% data variables')
|
||||
|
||||
// Fix specific issue likely causing 502 errors
|
||||
// Remove trailing quote from the problematic translation
|
||||
content = content.replace(
|
||||
/asked_too_many_times:\s*申し訳ありません。短い時間に質問が多すぎます。\s*しばらく待ってからもう一度やり直してください。"\s*$/gm,
|
||||
'asked_too_many_times: 申し訳ありません。短い時間に質問が多すぎます。 しばらく待ってからもう一度やり直してください。',
|
||||
)
|
||||
|
||||
// Internal issue #4160
|
||||
content = content.replaceAll(
|
||||
'- % data variables.product.prodname_copilot_enterprise %}',
|
||||
|
||||
176
src/languages/lib/translation-utils.ts
Normal file
176
src/languages/lib/translation-utils.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import type { UIStrings } from '@/frame/components/context/MainContext'
|
||||
|
||||
class UngettableError extends Error {}
|
||||
|
||||
/**
|
||||
* Generic translation function that works with both client and server components
|
||||
* With error handling for missing namespaces in CJK/Cyrillic languages
|
||||
*/
|
||||
export function createTranslationFunctions(uiData: UIStrings, namespaces: string | Array<string>) {
|
||||
const namespacesArray = Array.isArray(namespaces) ? namespaces : [namespaces]
|
||||
|
||||
const missingNamespaces: string[] = []
|
||||
for (const namespace of namespacesArray) {
|
||||
if (!(namespace in uiData)) {
|
||||
missingNamespaces.push(namespace)
|
||||
}
|
||||
}
|
||||
|
||||
if (missingNamespaces.length > 0) {
|
||||
console.warn(
|
||||
`Missing namespaces [${missingNamespaces.join(', ')}] in UI data. ` +
|
||||
'Available namespaces: ' +
|
||||
Object.keys(uiData).sort().join(', '),
|
||||
)
|
||||
|
||||
// For 404 pages, we can't afford to throw errors; create defensive fallbacks
|
||||
if (missingNamespaces.includes('meta')) {
|
||||
console.warn('Creating fallback meta namespace for 404 page rendering')
|
||||
uiData = {
|
||||
...uiData,
|
||||
meta: {
|
||||
oops: 'Ooops!',
|
||||
...(typeof uiData.meta === 'object' && uiData.meta !== null ? uiData.meta : {}),
|
||||
},
|
||||
} as UIStrings
|
||||
}
|
||||
|
||||
// Still missing critical namespaces? Create minimal fallbacks
|
||||
for (const namespace of missingNamespaces) {
|
||||
if (!(namespace in uiData)) {
|
||||
uiData = {
|
||||
...uiData,
|
||||
[namespace]: {} as UIStrings,
|
||||
} as UIStrings
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function carefulGetWrapper(path: string, fallback?: string) {
|
||||
try {
|
||||
// Try each namespace in order
|
||||
for (const namespace of namespacesArray) {
|
||||
if (!(namespace in uiData)) {
|
||||
continue // Skip missing namespaces
|
||||
}
|
||||
const deeper = uiData[namespace]
|
||||
if (typeof deeper === 'string') {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
return carefulGet(deeper, path)
|
||||
} catch (error) {
|
||||
if (!(error instanceof UngettableError)) {
|
||||
console.warn(`Translation error in namespace "${namespace}" for path "${path}":`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to searching the full UI data
|
||||
return carefulGet(uiData, path)
|
||||
} catch {
|
||||
// Never let translation failures break the app
|
||||
const finalFallback = fallback || path.split('.').pop() || 'Missing translation'
|
||||
console.warn(
|
||||
`Translation completely failed for "${path}", using fallback: "${finalFallback}"`,
|
||||
)
|
||||
return finalFallback
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tObject: (strings: TemplateStringsArray | string) => {
|
||||
const key = typeof strings === 'string' ? strings : String.raw(strings)
|
||||
try {
|
||||
return carefulGetWrapper(key) as UIStrings
|
||||
} catch (error) {
|
||||
console.warn(`tObject failed for "${key}":`, error)
|
||||
return {} as UIStrings
|
||||
}
|
||||
},
|
||||
t: (strings: TemplateStringsArray | string, ...values: Array<any>) => {
|
||||
const key = typeof strings === 'string' ? strings : String.raw(strings, ...values)
|
||||
// Provide specific fallbacks for common 404 page keys
|
||||
const commonFallbacks: Record<string, string> = {
|
||||
oops: 'Ooops!',
|
||||
github_docs: 'GitHub Docs',
|
||||
}
|
||||
const fallback = commonFallbacks[key] || commonFallbacks[key.split('.').pop() || '']
|
||||
return carefulGetWrapper(key, fallback) as string
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side translation function for App Router pages
|
||||
* Enhanced with better error handling for missing keys and defensive fallbacks
|
||||
*/
|
||||
export function translate(uiData: UIStrings, key: string, fallback?: string): string {
|
||||
// Defensive check for completely missing data
|
||||
if (!uiData || typeof uiData !== 'object') {
|
||||
console.warn(`UI data is missing or corrupted for key "${key}", using fallback`)
|
||||
return getCommonFallback(key, fallback)
|
||||
}
|
||||
|
||||
try {
|
||||
return carefulGet(uiData, key) as string
|
||||
} catch (error) {
|
||||
const finalFallback = getCommonFallback(key, fallback)
|
||||
|
||||
// Only warn in development
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.warn(
|
||||
`Server translation failed for "${key}":`,
|
||||
error instanceof Error ? error.message : error,
|
||||
`Using fallback: "${finalFallback}"`,
|
||||
)
|
||||
}
|
||||
|
||||
return finalFallback
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get common fallback values for essential UI keys
|
||||
*/
|
||||
function getCommonFallback(key: string, providedFallback?: string): string {
|
||||
const commonFallbacks: Record<string, string> = {
|
||||
'meta.oops': 'Ooops!',
|
||||
'header.github_docs': 'GitHub Docs',
|
||||
'meta.default_description': 'Get started, troubleshoot, and make the most of GitHub.',
|
||||
'footer.terms': 'Terms',
|
||||
'footer.privacy': 'Privacy',
|
||||
'footer.status': 'Status',
|
||||
'support.contact_support': 'Contact support',
|
||||
}
|
||||
|
||||
return (
|
||||
commonFallbacks[key] ||
|
||||
providedFallback ||
|
||||
commonFallbacks[key.split('.').pop() || ''] ||
|
||||
key.split('.').pop() ||
|
||||
key
|
||||
)
|
||||
}
|
||||
|
||||
function carefulGet(uiData: UIStrings, dottedPath: string) {
|
||||
const splitPath = dottedPath.split('.')
|
||||
const start = splitPath[0]
|
||||
if (!(start in uiData)) {
|
||||
throw new UngettableError(
|
||||
`Namespace "${start}" not found in loaded data (available: ${Object.keys(uiData).sort()})`,
|
||||
)
|
||||
}
|
||||
if (splitPath.length > 1) {
|
||||
const deeper = uiData[start]
|
||||
if (typeof deeper === 'string') {
|
||||
throw new Error(`Namespace "${start}" is a string, not an object`)
|
||||
}
|
||||
return carefulGet(deeper, splitPath.slice(1).join('.'))
|
||||
} else {
|
||||
if (!(start in uiData)) {
|
||||
throw new UngettableError(`Key "${start}" not found in loaded data`)
|
||||
}
|
||||
return uiData[start]
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { SimpleHeader, SimpleFooter } from '@/frame/components/GenericError'
|
||||
import Head from 'next/head'
|
||||
import { CommentDiscussionIcon } from '@primer/octicons-react'
|
||||
import { Lead } from '@/frame/components/ui/Lead'
|
||||
|
||||
const Custom404 = () => {
|
||||
return (
|
||||
<div className="min-h-screen d-flex flex-column">
|
||||
<Head>
|
||||
<title>404 - Page not found</title>
|
||||
<meta name="status" content="404" />
|
||||
</Head>
|
||||
|
||||
<SimpleHeader />
|
||||
|
||||
<div className="container-xl p-responsive py-6 width-full flex-1">
|
||||
<article className="col-md-10 col-lg-7 mx-auto">
|
||||
<h1>Ooops!</h1>
|
||||
<Lead>It looks like this page doesn't exist.</Lead>
|
||||
<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>
|
||||
|
||||
<SimpleFooter />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Custom404
|
||||
@@ -1,5 +0,0 @@
|
||||
import Custom404 from './404'
|
||||
|
||||
export default function NotFound() {
|
||||
return <Custom404 />
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
@@ -18,13 +22,25 @@
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"incremental": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
]
|
||||
},
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"docs-internal-data",
|
||||
"src/code-scanning/scripts/generate-code-scanning-query-list.ts"
|
||||
],
|
||||
"include": ["*.d.ts", "**/*.ts", "**/*.tsx"]
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
"*.d.ts",
|
||||
".next/types/**/*.ts"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user