diff --git a/.eslintrc b/.eslintrc index 2af52fb5361..f8b03f98a19 100644 --- a/.eslintrc +++ b/.eslintrc @@ -8,9 +8,7 @@ "@typescript-eslint/no-unused-vars": ["error", {"varsIgnorePattern": "^_"}], "react-hooks/exhaustive-deps": "error", "react/no-unknown-property": ["error", {"ignore": ["meta"]}], - "react-compiler/react-compiler": "error", - "@next/next/no-img-element": "off", - "@next/next/no-html-link-for-pages": "off" + "react-compiler/react-compiler": "error" }, "env": { "node": true, diff --git a/next-env.d.ts b/next-env.d.ts index 1b3be0840f3..52e831b4342 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -2,4 +2,4 @@ /// // NOTE: This file should not be edited -// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. +// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information. diff --git a/next.config.js b/next.config.js index bf45dd5b382..861792c8e53 100644 --- a/next.config.js +++ b/next.config.js @@ -11,11 +11,8 @@ const nextConfig = { experimental: { scrollRestoration: true, reactCompiler: true, - newDevOverlay: true, }, - env: {}, - serverExternalPackages: [], webpack: (config, {dev, isServer, ...options}) => { if (process.env.ANALYZE) { const {BundleAnalyzerPlugin} = require('webpack-bundle-analyzer'); diff --git a/package.json b/package.json index 3fd49e67c80..6d6b53f92de 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "license": "CC", "scripts": { "analyze": "ANALYZE=true next build", - "dev": "next dev", + "dev": "next-remote-watch ./src/content", "build": "next build && node --experimental-modules ./scripts/downloadFonts.mjs", "lint": "next lint", "lint:fix": "next lint --fix", @@ -15,11 +15,12 @@ "prettier:diff": "yarn nit:source", "lint-heading-ids": "node scripts/headingIdLinter.js", "fix-headings": "node scripts/headingIdLinter.js --fix", - "ci-check": "npm-run-all prettier:diff --parallel lint tsc lint-heading-ids", + "ci-check": "npm-run-all prettier:diff --parallel lint tsc lint-heading-ids rss", "tsc": "tsc --noEmit", "start": "next start", "postinstall": "is-ci || husky install .husky", - "check-all": "npm-run-all prettier lint:fix tsc" + "check-all": "npm-run-all prettier lint:fix tsc rss", + "rss": "node scripts/generateRss.js" }, "dependencies": { "@codesandbox/sandpack-react": "2.13.5", @@ -27,21 +28,19 @@ "@docsearch/react": "^3.8.3", "@headlessui/react": "^1.7.0", "@radix-ui/react-context-menu": "^2.1.5", - "@types/mdast": "^4.0.4", "body-scroll-lock": "^3.1.3", "classnames": "^2.2.6", "date-fns": "^2.16.1", "debounce": "^1.2.1", "github-slugger": "^1.3.0", - "next": "^15.2.0-canary.33", + "next": "15.1.0", "next-remote-watch": "^1.0.0", "parse-numeric-range": "^1.2.0", "react": "^19.0.0", "react-collapsed": "4.0.4", "react-dom": "^19.0.0", "remark-frontmatter": "^4.0.1", - "remark-gfm": "^3.0.1", - "unist-builder": "^4.0.0" + "remark-gfm": "^3.0.1" }, "devDependencies": { "@babel/core": "^7.12.9", @@ -63,7 +62,6 @@ "autoprefixer": "^10.4.2", "babel-eslint": "10.x", "babel-plugin-react-compiler": "19.0.0-beta-e552027-20250112", - "chokidar": "^4.0.3", "eslint": "7.x", "eslint-config-next": "12.0.3", "eslint-config-react-app": "^5.2.1", diff --git a/scripts/generateRss.js b/scripts/generateRss.js new file mode 100644 index 00000000000..e0f3d5561dd --- /dev/null +++ b/scripts/generateRss.js @@ -0,0 +1,6 @@ +/* + * Copyright (c) Facebook, Inc. and its affiliates. + */ +const {generateRssFeed} = require('../src/utils/rss'); + +generateRssFeed(); diff --git a/src/app/[[...markdownPath]]/page.tsx b/src/app/[[...markdownPath]]/page.tsx deleted file mode 100644 index 85dc607008b..00000000000 --- a/src/app/[[...markdownPath]]/page.tsx +++ /dev/null @@ -1,172 +0,0 @@ -import fs from 'fs/promises'; -import path from 'path'; -import {Page} from 'components/Layout/Page'; -import sidebarHome from '../../sidebarHome.json'; -import sidebarLearn from '../../sidebarLearn.json'; -import sidebarReference from '../../sidebarReference.json'; -import sidebarCommunity from '../../sidebarCommunity.json'; -import sidebarBlog from '../../sidebarBlog.json'; -import {generateMDX} from '../../utils/generateMDX'; - -import {generateMetadata as generateSeoMetadata} from '../../utils/generateMetadata'; - -import {getRouteMeta, RouteItem} from 'components/Layout/getRouteMeta'; -import {LanguageItem} from 'components/MDX/LanguagesContext'; -import {cache} from 'react'; - -function getActiveSection(pathname: string) { - if (pathname === '/') { - return 'home'; - } else if (pathname.startsWith('/reference')) { - return 'reference'; - } else if (pathname.startsWith('/learn')) { - return 'learn'; - } else if (pathname.startsWith('/community')) { - return 'community'; - } else if (pathname.startsWith('/blog')) { - return 'blog'; - } else { - return 'unknown'; - } -} - -async function getRouteTree(section: string) { - switch (section) { - case 'home': - case 'unknown': - return sidebarHome; - case 'learn': - return sidebarLearn; - case 'reference': - return sidebarReference; - case 'community': - return sidebarCommunity; - case 'blog': - return sidebarBlog; - default: - throw new Error(`Unknown section: ${section}`); - } -} - -const getPageContent = cache(async function getPageContent( - markdownPath: any[] -) { - const rootDir = path.join(process.cwd(), 'src/content'); - let mdxPath = markdownPath?.join('/') || 'index'; - let mdx; - - try { - mdx = await fs.readFile(path.join(rootDir, mdxPath + '.md'), 'utf8'); - } catch { - mdx = await fs.readFile(path.join(rootDir, mdxPath, 'index.md'), 'utf8'); - } - - return await generateMDX(mdx, mdxPath, {}); -}); - -// This replaces getStaticPaths -export async function generateStaticParams() { - const rootDir = path.join(process.cwd(), 'src/content'); - - async function getFiles(dir: string): Promise { - const entries = await fs.readdir(dir, {withFileTypes: true}); - const files = await Promise.all( - entries.map(async (entry) => { - const res = path.resolve(dir, entry.name); - return entry.isDirectory() - ? getFiles(res) - : res.slice(rootDir.length + 1); - }) - ); - - return files - .flat() - .filter( - (file: string) => file.endsWith('.md') && !file.startsWith('errors/') - ); - } - - function getSegments(file: string) { - let segments = file.slice(0, -3).replace(/\\/g, '/').split('/'); - if (segments[segments.length - 1] === 'index') { - segments.pop(); - } - return segments; - } - - const files = await getFiles(rootDir); - - return files.map((file: any) => ({ - markdownPath: getSegments(file), - })); -} - -export default async function WrapperPage({ - params, -}: { - params: Promise<{markdownPath: string[]}>; -}) { - const {markdownPath} = await params; - - // Get the MDX content and associated data - const {content, toc, meta} = await getPageContent(markdownPath); - - const pathname = '/' + (markdownPath?.join('/') || ''); - const section = getActiveSection(pathname); - const routeTree = await getRouteTree(section); - - // Load the list of translated languages conditionally. - let languages: Array | null = null; - if (pathname.endsWith('/translations')) { - languages = await ( - await fetch( - 'https://raw.githubusercontent.com/reactjs/translations.react.dev/main/langs/langs.json' - ) - ).json(); // { code: string; name: string; enName: string}[] - } - - // Pass the content and TOC directly, as `getPageContent` should already return them in the correct format - return ( - - {content} - - ); -} -// Configure dynamic segments to be statically generated -export const dynamicParams = false; - -export async function generateMetadata({ - params, -}: { - params: Promise<{markdownPath: string[]}>; -}) { - const {markdownPath} = await params; - const pathname = '/' + (markdownPath?.join('/') || ''); - const section = getActiveSection(pathname); - const routeTree = await getRouteTree(section); - const {route, order} = getRouteMeta(pathname, routeTree as RouteItem); - const { - title = route?.title || '', - description = route?.description || '', - titleForTitleTag, - } = await getPageContent(markdownPath).then(({meta}) => meta); - - return generateSeoMetadata({ - title, - isHomePage: pathname === '/', - path: pathname, - description, - titleForTitleTag, - image: `/images/og-${section}.png`, - searchOrder: - section === 'learn' || (section === 'blog' && pathname !== '/blog') - ? order - : undefined, - }); -} diff --git a/src/app/errors/[[...errorCode]]/page.tsx b/src/app/errors/[[...errorCode]]/page.tsx deleted file mode 100644 index b9158d0a54a..00000000000 --- a/src/app/errors/[[...errorCode]]/page.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import {Page} from 'components/Layout/Page'; -import sidebarLearn from '../../../sidebarLearn.json'; -import type {RouteItem} from 'components/Layout/getRouteMeta'; -import {generateMDX} from 'utils/generateMDX'; -import fs from 'fs/promises'; -import path from 'path'; -import {ErrorDecoderProvider} from 'components/ErrorDecoderProvider'; -import {notFound} from 'next/navigation'; -import {generateMetadata as generateSeoMetadata} from 'utils/generateMetadata'; - -let errorCodesCache: Record | null = null; - -async function getErrorCodes() { - if (!errorCodesCache) { - const response = await fetch( - 'https://raw.githubusercontent.com/facebook/react/main/scripts/error-codes/codes.json' - ); - errorCodesCache = await response.json(); - } - return errorCodesCache; -} - -export async function generateStaticParams() { - const errorCodes = await getErrorCodes(); - - const staticParams = Object.keys(errorCodes!).map((code) => ({ - errorCode: [code], - })) as Array<{errorCode: string[] | undefined}>; - - staticParams.push({errorCode: undefined}); - - return staticParams; -} - -async function getErrorPageContent(params: {errorCode: string[]}) { - if (params.errorCode?.length > 1) { - notFound(); - } - - const code = params.errorCode?.[0]; - - const errorCodes = await getErrorCodes(); - - if (code && !errorCodes?.[code]) { - notFound(); - } - - const rootDir = path.join(process.cwd(), 'src/content/errors'); - let mdxPath = params?.errorCode || 'index'; - let mdx; - - try { - mdx = await fs.readFile(path.join(rootDir, mdxPath + '.md'), 'utf8'); - } catch { - mdx = await fs.readFile(path.join(rootDir, 'generic.md'), 'utf8'); - } - - const {content, toc, meta} = await generateMDX(mdx, mdxPath, { - code, - errorCodes, - }); - - return { - content, - toc, - meta, - errorCode: code, - errorMessage: code ? errorCodes![code] : null, - }; -} - -export default async function ErrorDecoderPage({ - params, -}: { - params: Promise<{errorCode: string[]}>; -}) { - const {content, errorMessage, errorCode} = await getErrorPageContent( - await params - ); - - return ( - - -
{content}
-
-
- ); -} - -// Disable dynamic params to ensure all pages are statically generated -export const dynamicParams = false; - -export async function generateMetadata({ - params, -}: { - params: Promise<{errorCode: string[]}>; -}) { - const {errorCode} = await params; - - const title = errorCode - ? `Minified React error #${errorCode}` - : 'Minified Error Decoder'; - - return generateSeoMetadata({ - title, - path: `errors/${errorCode}`, - isHomePage: false, - }); -} diff --git a/src/app/layout.tsx b/src/app/layout.tsx deleted file mode 100644 index 079a0a8740c..00000000000 --- a/src/app/layout.tsx +++ /dev/null @@ -1,157 +0,0 @@ -import {siteConfig} from '../siteConfig'; -import {Analytics} from 'components/Analytics'; -import {ScrollHandler} from 'components/SafariScrollHandler'; - -import '@docsearch/css'; -import '../styles/algolia.css'; -import '../styles/index.css'; -import '../styles/sandpack.css'; - -import {Suspense} from 'react'; -import {DevContentRefresher} from 'components/DevContentRefresher'; -import {ThemeScript} from 'components/ThemeScript'; -import {UwuScript} from 'components/UwuScript'; -import {Metadata} from 'next'; - -export const viewport = { - themeColor: [ - {media: '(prefers-color-scheme: light)', color: '#23272f'}, - {media: '(prefers-color-scheme: dark)', color: '#23272f'}, - ], -}; - -export const metadata: Metadata = { - description: - 'React is the library for web and native user interfaces. Build user interfaces out of individual pieces called components written in JavaScript.', - openGraph: { - siteName: 'React', - type: 'website', - images: [{url: '/images/og-default.png'}], - }, - twitter: { - card: 'summary_large_image', - site: '@reactjs', - creator: '@reactjs', - images: ['/images/og-default.png'], - }, - verification: { - google: 'sIlAGs48RulR4DdP95YSWNKZIEtCqQmRjzn-Zq-CcD0', - }, - other: { - 'msapplication-TileColor': '#2b5797', - 'fb:app_id': '623268441017527', - }, - icons: { - icon: [ - {url: '/favicon-16x16.png', sizes: '16x16', type: 'image/png'}, - {url: '/favicon-32x32.png', sizes: '32x32', type: 'image/png'}, - ], - apple: [ - {url: '/apple-touch-icon.png', sizes: '180x180', type: 'image/png'}, - ], - other: [ - {rel: 'mask-icon', url: '/safari-pinned-tab.svg', color: '#404756'}, - ], - }, - manifest: '/site.webmanifest', -}; - -function FontPreload() { - return ( - <> - - - - - - - - - - - - ); -} - -export default function RootLayout({children}: {children: React.ReactNode}) { - return ( - - - - - - - - - {process.env.NODE_ENV !== 'production' && } - {children} - - - - - - ); -} diff --git a/src/app/rss.xml/route.js b/src/app/rss.xml/route.js deleted file mode 100644 index 9ade331e77d..00000000000 --- a/src/app/rss.xml/route.js +++ /dev/null @@ -1,69 +0,0 @@ -import Feed from 'rss'; -import fs from 'fs'; -import path from 'path'; -import matter from 'gray-matter'; - -const getAllFiles = (dirPath, arrayOfFiles = []) => { - const files = fs.readdirSync(dirPath); - - files.forEach((file) => { - const filePath = path.join(dirPath, file); - if (fs.statSync(filePath).isDirectory()) { - arrayOfFiles = getAllFiles(filePath, arrayOfFiles); - } else { - arrayOfFiles.push(filePath); - } - }); - - return arrayOfFiles; -}; - -export async function GET() { - const feed = new Feed({ - title: 'React Blog', - description: - 'This blog is the official source for the updates from the React team. Anything important, including release notes or deprecation notices, will be posted here first.', - feed_url: 'https://react.dev/rss.xml', - site_url: 'https://react.dev/', - language: 'en', - favicon: 'https://react.dev/favicon.ico', - pubDate: new Date(), - generator: 'react.dev rss module', - }); - - const dirPath = path.join(process.cwd(), 'src/content/blog'); - const filesByOldest = getAllFiles(dirPath); - const files = filesByOldest.reverse(); - - for (const filePath of files) { - const id = path.basename(filePath); - if (id !== 'index.md') { - const content = fs.readFileSync(filePath, 'utf-8'); - const {data} = matter(content); - const slug = filePath.split('/').slice(-4).join('/').replace('.md', ''); - - if (!data.title || !data.author || !data.date || !data.description) { - throw new Error( - `${id}: Blog posts must include title, author, date, and description in metadata.` - ); - } - - feed.item({ - id, - title: data.title, - author: data.author, - date: new Date(data.date), - url: `https://react.dev/blog/${slug}`, - description: data.description, - }); - } - } - - return new Response(feed.xml({indent: true}), { - headers: { - 'Content-Type': 'application/rss+xml', - }, - }); -} - -export const dynamic = 'force-static'; diff --git a/src/components/Analytics.tsx b/src/components/Analytics.tsx deleted file mode 100644 index 07d637e86c0..00000000000 --- a/src/components/Analytics.tsx +++ /dev/null @@ -1,63 +0,0 @@ -'use client'; - -import {useEffect} from 'react'; -import Script from 'next/script'; - -declare global { - interface Window { - gtag: (...args: any[]) => void; - } -} - -export function Analytics() { - useEffect(() => { - if (typeof window !== 'undefined') { - const terminationEvent = 'onpagehide' in window ? 'pagehide' : 'unload'; - const handleTermination = () => { - window.gtag?.('event', 'timing', { - event_label: 'JS Dependencies', - event: 'unload', - }); - }; - window.addEventListener(terminationEvent, handleTermination); - return () => - window.removeEventListener(terminationEvent, handleTermination); - } - }, []); - - useEffect(() => { - // If only we had router events. But patching pushState is what Vercel Analytics does. - // https://va.vercel-scripts.com/v1/script.debug.js - const originalPushState = history.pushState; - - history.pushState = function (...args) { - const oldCleanedUrl = window.location.href.split(/[\?\#]/)[0]; - originalPushState.apply(history, args); - const newCleanedUrl = window.location.href.split(/[\?\#]/)[0]; - if (oldCleanedUrl !== newCleanedUrl) { - window?.gtag('set', 'page', newCleanedUrl); - window?.gtag('send', 'pageview'); - } - }; - return () => { - history.pushState = originalPushState; - }; - }, []); - - return ( - <> - - - ); -} diff --git a/src/components/DevContentRefresher.tsx b/src/components/DevContentRefresher.tsx deleted file mode 100644 index 31a0961ed8f..00000000000 --- a/src/components/DevContentRefresher.tsx +++ /dev/null @@ -1,29 +0,0 @@ -'use client'; - -import {useRouter} from 'next/navigation'; -import {useRef, useEffect} from 'react'; - -export function DevContentRefresher() { - const router = useRouter(); - const wsRef = useRef(null); - - useEffect(() => { - wsRef.current = new WebSocket('ws://localhost:3001'); - - wsRef.current.onmessage = (event) => { - const message = JSON.parse(event.data); - - if (message.event === 'refresh') { - console.log('Refreshing content...'); - // @ts-ignore - router.hmrRefresh(); // Triggers client-side refresh - } - }; - - return () => { - wsRef.current?.close(); - }; - }, [router]); - - return null; -} diff --git a/src/components/ErrorDecoderProvider.tsx b/src/components/ErrorDecoderProvider.tsx deleted file mode 100644 index bad1ed2d081..00000000000 --- a/src/components/ErrorDecoderProvider.tsx +++ /dev/null @@ -1,19 +0,0 @@ -'use client'; - -import {ErrorDecoderContext} from './ErrorDecoderContext'; - -export function ErrorDecoderProvider({ - children, - errorMessage, - errorCode, -}: { - children: React.ReactNode; - errorMessage: string | null; - errorCode: string | null; -}) { - return ( - - {children} - - ); -} diff --git a/src/components/Layout/Feedback.tsx b/src/components/Layout/Feedback.tsx index 16b974c10dd..34db728ced2 100644 --- a/src/components/Layout/Feedback.tsx +++ b/src/components/Layout/Feedback.tsx @@ -3,12 +3,12 @@ */ import {useState} from 'react'; +import {useRouter} from 'next/router'; import cn from 'classnames'; -import {usePathname} from 'next/navigation'; export function Feedback({onSubmit = () => {}}: {onSubmit?: () => void}) { - const pathname = usePathname(); - const cleanedPath = pathname.split(/[\?\#]/)[0]; + const {asPath} = useRouter(); + const cleanedPath = asPath.split(/[\?\#]/)[0]; // Reset on route changes. return ; } diff --git a/src/components/Layout/Page.tsx b/src/components/Layout/Page.tsx index ea0c53e1cfd..24d379589de 100644 --- a/src/components/Layout/Page.tsx +++ b/src/components/Layout/Page.tsx @@ -1,16 +1,16 @@ -'use client'; - /* * Copyright (c) Facebook, Inc. and its affiliates. */ -import * as React from 'react'; import {Suspense} from 'react'; +import * as React from 'react'; +import {useRouter} from 'next/router'; import {SidebarNav} from './SidebarNav'; import {Footer} from './Footer'; import {Toc} from './Toc'; +// import SocialBanner from '../SocialBanner'; import {DocsPageFooter} from 'components/DocsFooter'; - +import {Seo} from 'components/Seo'; import PageHeading from 'components/PageHeading'; import {getRouteMeta} from './getRouteMeta'; import {TocContext} from '../MDX/TocContext'; @@ -20,8 +20,8 @@ import type {RouteItem} from 'components/Layout/getRouteMeta'; import {HomeContent} from './HomeContent'; import {TopNav} from './TopNav'; import cn from 'classnames'; +import Head from 'next/head'; -// Prefetch the code block component import(/* webpackPrefetch: true */ '../MDX/CodeBlock/CodeBlock'); interface PageProps { @@ -36,7 +36,6 @@ interface PageProps { }; section: 'learn' | 'reference' | 'community' | 'blog' | 'home' | 'unknown'; languages?: Languages | null; - pathname: string; } export function Page({ @@ -45,11 +44,11 @@ export function Page({ routeTree, meta, section, - pathname, languages = null, }: PageProps) { - const cleanedPath = pathname.split(/[\?\#]/)[0]; - const {route, nextRoute, prevRoute, breadcrumbs} = getRouteMeta( + const {asPath} = useRouter(); + const cleanedPath = asPath.split(/[\?\#]/)[0]; + const {route, nextRoute, prevRoute, breadcrumbs, order} = getRouteMeta( cleanedPath, routeTree ); @@ -114,17 +113,31 @@ export function Page({ showSidebar = false; } + let searchOrder; + if (section === 'learn' || (section === 'blog' && !isBlogIndex)) { + searchOrder = order; + } + return ( <> + {(isHomePage || isBlogIndex) && ( - // RSS Feed link is now handled by metadata in layout.tsx - + + + )} + {/**/}
-
+
{content}
- {showToc && toc.length > 0 && } + {showToc && toc.length > 0 && }
diff --git a/src/components/Layout/Sidebar/SidebarRouteTree.tsx b/src/components/Layout/Sidebar/SidebarRouteTree.tsx index f67b0ed2b41..72003df74f2 100644 --- a/src/components/Layout/Sidebar/SidebarRouteTree.tsx +++ b/src/components/Layout/Sidebar/SidebarRouteTree.tsx @@ -5,12 +5,12 @@ import {useRef, useLayoutEffect, Fragment} from 'react'; import cn from 'classnames'; +import {useRouter} from 'next/router'; import {SidebarLink} from './SidebarLink'; import {useCollapse} from 'react-collapsed'; import usePendingRoute from 'hooks/usePendingRoute'; import type {RouteItem} from 'components/Layout/getRouteMeta'; import {siteConfig} from 'siteConfig'; -import {usePathname} from 'next/navigation'; interface SidebarRouteTreeProps { isForceExpanded: boolean; @@ -77,7 +77,7 @@ export function SidebarRouteTree({ routeTree, level = 0, }: SidebarRouteTreeProps) { - const slug = usePathname().split(/[\?\#]/)[0]; + const slug = useRouter().asPath.split(/[\?\#]/)[0]; const pendingRoute = usePendingRoute(); const currentRoutes = routeTree.routes as RouteItem[]; return ( diff --git a/src/components/Layout/Toc.tsx b/src/components/Layout/Toc.tsx index a8d26989874..5308c602ce5 100644 --- a/src/components/Layout/Toc.tsx +++ b/src/components/Layout/Toc.tsx @@ -11,11 +11,7 @@ export function Toc({headings}: {headings: Toc}) { // TODO: We currently have a mismatch between the headings in the document // and the headings we find in MarkdownPage (i.e. we don't find Recap or Challenges). // Select the max TOC item we have here for now, but remove this after the fix. - const selectedIndex = - currentIndex !== undefined - ? Math.min(currentIndex, headings.length - 1) - : -1; - + const selectedIndex = Math.min(currentIndex, headings.length - 1); return (