From 7d8b9cf28e63d9d4c1532dbeef7a5ec3dfbd2f57 Mon Sep 17 00:00:00 2001 From: Mardav Wala Date: Thu, 21 Aug 2025 13:06:03 -0400 Subject: [PATCH] feat: Implement App Router integration and 404 handling (#56915) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/app/404/page.tsx | 105 +++++++++++ src/app/_not-found/page.tsx | 7 + src/app/client-layout.tsx | 126 +++++++++++++ src/app/components/AppRouterMainContext.tsx | 52 ++++++ src/app/components/MainContextProvider.tsx | 17 ++ src/app/components/NotFoundContent.tsx | 119 ++++++++++++ src/app/layout.tsx | 54 ++++++ src/app/lib/app-router-context.ts | 87 +++++++++ src/app/lib/locale-context.tsx | 121 ++++++++++++ src/app/lib/main-context-adapter.ts | 108 +++++++++++ src/app/lib/routing-patterns.ts | 118 ++++++++++++ src/app/lib/use-detect-locale.tsx | 61 ++++++ src/app/not-found.tsx | 27 +++ src/app/types.ts | 83 +++++++++ src/app/validators.ts | 16 ++ src/frame/lib/header-utils.ts | 84 +++++++++ src/frame/middleware/app-router-gateway.ts | 114 ++++++++++++ src/frame/middleware/helmet.ts | 31 ++- src/frame/middleware/index.ts | 4 + src/frame/middleware/render-page.ts | 70 +++---- src/languages/components/useTranslation.ts | 81 +------- src/languages/lib/client-languages.ts | 67 +++++++ .../lib/correct-translation-content.ts | 26 +++ src/languages/lib/translation-utils.ts | 176 ++++++++++++++++++ src/pages/404.tsx | 36 ---- src/pages/_notfound.tsx | 5 - tsconfig.json | 24 ++- 27 files changed, 1654 insertions(+), 165 deletions(-) create mode 100644 src/app/404/page.tsx create mode 100644 src/app/_not-found/page.tsx create mode 100644 src/app/client-layout.tsx create mode 100644 src/app/components/AppRouterMainContext.tsx create mode 100644 src/app/components/MainContextProvider.tsx create mode 100644 src/app/components/NotFoundContent.tsx create mode 100644 src/app/layout.tsx create mode 100644 src/app/lib/app-router-context.ts create mode 100644 src/app/lib/locale-context.tsx create mode 100644 src/app/lib/main-context-adapter.ts create mode 100644 src/app/lib/routing-patterns.ts create mode 100644 src/app/lib/use-detect-locale.tsx create mode 100644 src/app/not-found.tsx create mode 100644 src/app/types.ts create mode 100644 src/app/validators.ts create mode 100644 src/frame/lib/header-utils.ts create mode 100644 src/frame/middleware/app-router-gateway.ts create mode 100644 src/languages/lib/client-languages.ts create mode 100644 src/languages/lib/translation-utils.ts delete mode 100644 src/pages/404.tsx delete mode 100644 src/pages/_notfound.tsx diff --git a/src/app/404/page.tsx b/src/app/404/page.tsx new file mode 100644 index 0000000000..20eed3fcc8 --- /dev/null +++ b/src/app/404/page.tsx @@ -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 ( + +
+ {/* Simple Header */} +
+
+ +
+
+ + {/* Main Content */} +
+
+

{oopsTitle}

+
+ It looks like this page doesn't exist. +
+

+ We track these errors automatically, but if the problem persists please feel free to + contact us. +

+ + + Contact support + +
+
+ + +
+
+ ) +} diff --git a/src/app/_not-found/page.tsx b/src/app/_not-found/page.tsx new file mode 100644 index 0000000000..562bf64ed8 --- /dev/null +++ b/src/app/_not-found/page.tsx @@ -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() +} diff --git a/src/app/client-layout.tsx b/src/app/client-layout.tsx new file mode 100644 index 0000000000..60c80ec8c9 --- /dev/null +++ b/src/app/client-layout.tsx @@ -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(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 => { + 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 ( +
+
+

Something went wrong

+

Please try refreshing the page.

+ +
+
+ ) + } + + return ( + + + + + + +
{children}
+
+
+
+
+
+
+ ) +} diff --git a/src/app/components/AppRouterMainContext.tsx b/src/app/components/AppRouterMainContext.tsx new file mode 100644 index 0000000000..74b440ff20 --- /dev/null +++ b/src/app/components/AppRouterMainContext.tsx @@ -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(null) + +// Provides MainContext-compatible data +export const AppRouterCompatMainContext = createContext(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 ( + + + {children} + + + ) +} + +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 +} diff --git a/src/app/components/MainContextProvider.tsx b/src/app/components/MainContextProvider.tsx new file mode 100644 index 0000000000..708e6e5889 --- /dev/null +++ b/src/app/components/MainContextProvider.tsx @@ -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 {children} +} diff --git a/src/app/components/NotFoundContent.tsx b/src/app/components/NotFoundContent.tsx new file mode 100644 index 0000000000..e70a8385c6 --- /dev/null +++ b/src/app/components/NotFoundContent.tsx @@ -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 ( + + ) +} + +function SimpleFooter() { + return ( + + ) +} + +function SimpleLead({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ) +} + +export function NotFoundContent() { + const context = useAppRouterMainContext() + + const { t } = useMemo( + () => createTranslationFunctions(context.site.data.ui, ['meta']), + [context.site.data.ui], + ) + + return ( +
+ + +
+
+

{t('oops')}

+ It looks like this page doesn't exist. +

+ We track these errors automatically, but if the problem persists please feel free to + contact us. +

+ + + Contact support + +
+
+ + +
+ ) +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 0000000000..29fe4be379 --- /dev/null +++ b/src/app/layout.tsx @@ -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 ( + + + + {/* DNS prefetch for performance */} + + + + {children} + + ) +} diff --git a/src/app/lib/app-router-context.ts b/src/app/lib/app-router-context.ts new file mode 100644 index 0000000000..800e1f1986 --- /dev/null +++ b/src/app/lib/app-router-context.ts @@ -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 { + 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: '', + } +} diff --git a/src/app/lib/locale-context.tsx b/src/app/lib/locale-context.tsx new file mode 100644 index 0000000000..7840c8af71 --- /dev/null +++ b/src/app/lib/locale-context.tsx @@ -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(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 {children} +} + +/** + * 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 } diff --git a/src/app/lib/main-context-adapter.ts b/src/app/lib/main-context-adapter.ts new file mode 100644 index 0000000000..ee79b2b626 --- /dev/null +++ b/src/app/lib/main-context-adapter.ts @@ -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 { + 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, + }) +} diff --git a/src/app/lib/routing-patterns.ts b/src/app/lib/routing-patterns.ts new file mode 100644 index 0000000000..b006abf662 --- /dev/null +++ b/src/app/lib/routing-patterns.ts @@ -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 +} diff --git a/src/app/lib/use-detect-locale.tsx b/src/app/lib/use-detect-locale.tsx new file mode 100644 index 0000000000..410c6ca026 --- /dev/null +++ b/src/app/lib/use-detect-locale.tsx @@ -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 } diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx new file mode 100644 index 0000000000..0af316061f --- /dev/null +++ b/src/app/not-found.tsx @@ -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 ( + + + + ) +} + +export default NotFoundPage diff --git a/src/app/types.ts b/src/app/types.ts new file mode 100644 index 0000000000..c86401e08c --- /dev/null +++ b/src/app/types.ts @@ -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 +} + +// 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[]] +export type RequiredFields = T & Required> +export type OptionalFields = Omit & Partial> diff --git a/src/app/validators.ts b/src/app/validators.ts new file mode 100644 index 0000000000..eaf42c0031 --- /dev/null +++ b/src/app/validators.ts @@ -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 } diff --git a/src/frame/lib/header-utils.ts b/src/frame/lib/header-utils.ts new file mode 100644 index 0000000000..5ac2d7cc00 --- /dev/null +++ b/src/frame/lib/header-utils.ts @@ -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: {}, + }), + ) + } +} diff --git a/src/frame/middleware/app-router-gateway.ts b/src/frame/middleware/app-router-gateway.ts new file mode 100644 index 0000000000..695a141942 --- /dev/null +++ b/src/frame/middleware/app-router-gateway.ts @@ -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() +} diff --git a/src/frame/middleware/helmet.ts b/src/frame/middleware/helmet.ts index 10a44572d9..9cf66d2c69 100644 --- a/src/frame/middleware/helmet.ts +++ b/src/frame/middleware/helmet.ts @@ -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) diff --git a/src/frame/middleware/index.ts b/src/frame/middleware/index.ts index 7695344ec7..8f6afe9118 100644 --- a/src/frame/middleware/index.ts +++ b/src/frame/middleware/index.ts @@ -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) diff --git a/src/frame/middleware/render-page.ts b/src/frame/middleware/render-page.ts index 50fe0b330d..c5fe1e7c38 100644 --- a/src/frame/middleware/render-page.ts +++ b/src/frame/middleware/render-page.ts @@ -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) => { 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) => { - 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) => { + return createTranslationFunctions(uiData, namespaces) } diff --git a/src/languages/lib/client-languages.ts b/src/languages/lib/client-languages.ts new file mode 100644 index 0000000000..c238b09032 --- /dev/null +++ b/src/languages/lib/client-languages.ts @@ -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 = { + 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 diff --git a/src/languages/lib/correct-translation-content.ts b/src/languages/lib/correct-translation-content.ts index 1bff3afcd8..fda0b9af99 100644 --- a/src/languages/lib/correct-translation-content.ts +++ b/src/languages/lib/correct-translation-content.ts @@ -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('', '') + content = content.replaceAll('', '') + + // Fix specific Russian UI YAML issues causing 502 errors + // Remove empty bold tags from early_access notice + content = content.replace(/early_access:\s*"([^"]*)<\/b>([^"]*)"/, 'early_access: "$1$2"') + + // Remove empty underline tags from privacy disclaimer + content = content.replace(/(privacy_disclaimer:[^<]*)<\/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 %}', diff --git a/src/languages/lib/translation-utils.ts b/src/languages/lib/translation-utils.ts new file mode 100644 index 0000000000..40acaa3b73 --- /dev/null +++ b/src/languages/lib/translation-utils.ts @@ -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) { + 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) => { + const key = typeof strings === 'string' ? strings : String.raw(strings, ...values) + // Provide specific fallbacks for common 404 page keys + const commonFallbacks: Record = { + 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 = { + '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] + } +} diff --git a/src/pages/404.tsx b/src/pages/404.tsx deleted file mode 100644 index 8fbbfedfda..0000000000 --- a/src/pages/404.tsx +++ /dev/null @@ -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 ( -
- - 404 - Page not found - - - - - -
-
-

Ooops!

- It looks like this page doesn't exist. -

- We track these errors automatically, but if the problem persists please feel free to - contact us. -

- - - Contact support - -
-
- - -
- ) -} - -export default Custom404 diff --git a/src/pages/_notfound.tsx b/src/pages/_notfound.tsx deleted file mode 100644 index fbd4a82dc2..0000000000 --- a/src/pages/_notfound.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import Custom404 from './404' - -export default function NotFound() { - return -} diff --git a/tsconfig.json b/tsconfig.json index 30546d01de..7bc98921f8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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" + ] }