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