1
0
mirror of synced 2025-12-19 09:57:42 -05:00

feat: Implement App Router integration and 404 handling (#56915)

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Mardav Wala
2025-08-21 13:06:03 -04:00
committed by GitHub
parent 1f7fff816f
commit 7d8b9cf28e
27 changed files with 1654 additions and 165 deletions

105
src/app/404/page.tsx Normal file
View 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>
)
}

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

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

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

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

View 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: '',
}
}

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

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

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

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

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

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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

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

View 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

View File

@@ -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 %}',

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

View File

@@ -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

View File

@@ -1,5 +0,0 @@
import Custom404 from './404'
export default function NotFound() {
return <Custom404 />
}

View File

@@ -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"
]
}