diff --git a/next.config.js b/next.config.js index fe88a09a0c..7580eb944c 100644 --- a/next.config.js +++ b/next.config.js @@ -19,6 +19,28 @@ const nextConfig = { scrollRestoration: true, reactCompiler: true, }, + async rewrites() { + return [ + // Serve markdown when Accept header prefers text/markdown + // Useful for LLM agents - https://www.skeptrune.com/posts/use-the-accept-header-to-serve-markdown-instead-of-html-to-llms/ + { + source: '/:path*', + has: [ + { + type: 'header', + key: 'accept', + value: '(.*text/markdown.*)', + }, + ], + destination: '/api/md/:path*', + }, + // Explicit .md extension also serves markdown + { + source: '/:path*.md', + destination: '/api/md/:path*', + }, + ]; + }, env: {}, webpack: (config, {dev, isServer, ...options}) => { if (process.env.ANALYZE) { diff --git a/package.json b/package.json index 55fcc0a5b7..8cb257733f 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "classnames": "^2.2.6", "debounce": "^1.2.1", "github-slugger": "^1.3.0", - "next": "15.1.11", + "next": "15.1.12", "next-remote-watch": "^1.0.0", "parse-numeric-range": "^1.2.0", "react": "^19.0.0", diff --git a/src/components/Layout/SidebarNav/SidebarNav.tsx b/src/components/Layout/SidebarNav/SidebarNav.tsx index 77beb4d729..678d483c14 100644 --- a/src/components/Layout/SidebarNav/SidebarNav.tsx +++ b/src/components/Layout/SidebarNav/SidebarNav.tsx @@ -12,7 +12,6 @@ import {Suspense} from 'react'; import * as React from 'react'; import cn from 'classnames'; -import {Feedback} from '../Feedback'; import {SidebarRouteTree} from '../Sidebar/SidebarRouteTree'; import type {RouteItem} from '../getRouteMeta'; @@ -63,9 +62,6 @@ export default function SidebarNav({
-
- -
diff --git a/src/components/Layout/TopNav/TopNav.tsx b/src/components/Layout/TopNav/TopNav.tsx index ac4bf471c8..15247bb7a7 100644 --- a/src/components/Layout/TopNav/TopNav.tsx +++ b/src/components/Layout/TopNav/TopNav.tsx @@ -29,7 +29,6 @@ import {IconHamburger} from 'components/Icon/IconHamburger'; import {IconSearch} from 'components/Icon/IconSearch'; import {Search} from 'components/Search'; import {Logo} from '../../Logo'; -import {Feedback} from '../Feedback'; import {SidebarRouteTree} from '../Sidebar'; import type {RouteItem} from '../getRouteMeta'; import {siteConfig} from 'siteConfig'; @@ -448,9 +447,6 @@ export default function TopNav({
-
- -
)} diff --git a/src/pages/api/md/[...path].ts b/src/pages/api/md/[...path].ts new file mode 100644 index 0000000000..5f80e4e88c --- /dev/null +++ b/src/pages/api/md/[...path].ts @@ -0,0 +1,53 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import type {NextApiRequest, NextApiResponse} from 'next'; +import fs from 'fs'; +import path from 'path'; + +const FOOTER = ` +--- + +## Sitemap + +[Overview of all docs pages](/llms.txt) +`; + +export default function handler(req: NextApiRequest, res: NextApiResponse) { + const pathSegments = req.query.path; + if (!pathSegments) { + return res.status(404).send('Not found'); + } + + const filePath = Array.isArray(pathSegments) + ? pathSegments.join('/') + : pathSegments; + + // Block /index.md URLs - use /foo.md instead of /foo/index.md + if (filePath.endsWith('/index') || filePath === 'index') { + return res.status(404).send('Not found'); + } + + // Try exact path first, then with /index + const candidates = [ + path.join(process.cwd(), 'src/content', filePath + '.md'), + path.join(process.cwd(), 'src/content', filePath, 'index.md'), + ]; + + for (const fullPath of candidates) { + try { + const content = fs.readFileSync(fullPath, 'utf8'); + res.setHeader('Content-Type', 'text/plain; charset=utf-8'); + res.setHeader('Cache-Control', 'public, max-age=3600'); + return res.status(200).send(content + FOOTER); + } catch { + // Try next candidate + } + } + + res.status(404).send('Not found'); +} diff --git a/src/pages/llms.txt.tsx b/src/pages/llms.txt.tsx new file mode 100644 index 0000000000..23fda9ddf1 --- /dev/null +++ b/src/pages/llms.txt.tsx @@ -0,0 +1,269 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import type {GetServerSideProps} from 'next'; +import {siteConfig} from '../siteConfig'; +import sidebarLearn from '../sidebarLearn.json'; +import sidebarReference from '../sidebarReference.json'; + +interface RouteItem { + title?: string; + path?: string; + routes?: RouteItem[]; + hasSectionHeader?: boolean; + sectionHeader?: string; +} + +interface Sidebar { + title: string; + routes: RouteItem[]; +} + +interface Page { + title: string; + url: string; +} + +interface SubGroup { + heading: string; + pages: Page[]; +} + +interface Section { + heading: string | null; + pages: Page[]; + subGroups: SubGroup[]; +} + +// Clean up section header names (remove version placeholders) +function cleanSectionHeader(header: string): string { + return header + .replace(/@\{\{version\}\}/g, '') + .replace(/-/g, ' ') + .split(' ') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' ') + .trim(); +} + +// Extract routes for sidebars that use hasSectionHeader to define major sections +// (like the API Reference sidebar) +function extractSectionedRoutes( + routes: RouteItem[], + baseUrl: string +): Section[] { + const sections: Section[] = []; + let currentSection: Section | null = null; + + for (const route of routes) { + // Skip external links + if (route.path?.startsWith('http')) { + continue; + } + + // Start a new section when we hit a section header + if (route.hasSectionHeader && route.sectionHeader) { + if (currentSection) { + sections.push(currentSection); + } + currentSection = { + heading: cleanSectionHeader(route.sectionHeader), + pages: [], + subGroups: [], + }; + continue; + } + + // If no section started yet, skip + if (!currentSection) { + continue; + } + + // Route with children - create a sub-group + if (route.title && route.routes && route.routes.length > 0) { + const subGroup: SubGroup = { + heading: route.title, + pages: [], + }; + + // Include parent page if it has a path + if (route.path) { + subGroup.pages.push({ + title: route.title, + url: `${baseUrl}${route.path}.md`, + }); + } + + // Add child pages + for (const child of route.routes) { + if (child.title && child.path && !child.path.startsWith('http')) { + subGroup.pages.push({ + title: child.title, + url: `${baseUrl}${child.path}.md`, + }); + } + } + + if (subGroup.pages.length > 0) { + currentSection.subGroups.push(subGroup); + } + } + // Single page without children + else if (route.title && route.path) { + currentSection.pages.push({ + title: route.title, + url: `${baseUrl}${route.path}.md`, + }); + } + } + + // Don't forget the last section + if (currentSection) { + sections.push(currentSection); + } + + return sections; +} + +// Extract routes for sidebars that use routes with children as the primary grouping +// (like the Learn sidebar) +function extractGroupedRoutes( + routes: RouteItem[], + baseUrl: string +): SubGroup[] { + const groups: SubGroup[] = []; + + for (const route of routes) { + // Skip section headers + if (route.hasSectionHeader) { + continue; + } + + // Skip external links + if (route.path?.startsWith('http')) { + continue; + } + + // Route with children - create a group + if (route.title && route.routes && route.routes.length > 0) { + const pages: Page[] = []; + + // Include parent page if it has a path + if (route.path) { + pages.push({ + title: route.title, + url: `${baseUrl}${route.path}.md`, + }); + } + + // Add child pages + for (const child of route.routes) { + if (child.title && child.path && !child.path.startsWith('http')) { + pages.push({ + title: child.title, + url: `${baseUrl}${child.path}.md`, + }); + } + } + + if (pages.length > 0) { + groups.push({ + heading: route.title, + pages, + }); + } + } + // Single page without children - group under its own heading + else if (route.title && route.path) { + groups.push({ + heading: route.title, + pages: [ + { + title: route.title, + url: `${baseUrl}${route.path}.md`, + }, + ], + }); + } + } + + return groups; +} + +// Check if sidebar uses section headers as primary grouping +function usesSectionHeaders(routes: RouteItem[]): boolean { + return routes.some((r) => r.hasSectionHeader && r.sectionHeader); +} + +export const getServerSideProps: GetServerSideProps = async ({res}) => { + const subdomain = + siteConfig.languageCode === 'en' ? '' : siteConfig.languageCode + '.'; + const baseUrl = 'https://' + subdomain + 'react.dev'; + + const lines = [ + '# React Documentation', + '', + '> The library for web and native user interfaces.', + ]; + + const sidebars: Sidebar[] = [ + sidebarLearn as Sidebar, + sidebarReference as Sidebar, + ]; + + for (const sidebar of sidebars) { + lines.push(''); + lines.push(`## ${sidebar.title}`); + + if (usesSectionHeaders(sidebar.routes)) { + // API Reference style: section headers define major groups + const sections = extractSectionedRoutes(sidebar.routes, baseUrl); + for (const section of sections) { + if (section.heading) { + lines.push(''); + lines.push(`### ${section.heading}`); + } + + // Output pages directly under section + for (const page of section.pages) { + lines.push(`- [${page.title}](${page.url})`); + } + + // Output sub-groups with #### headings + for (const subGroup of section.subGroups) { + lines.push(''); + lines.push(`#### ${subGroup.heading}`); + for (const page of subGroup.pages) { + lines.push(`- [${page.title}](${page.url})`); + } + } + } + } else { + // Learn style: routes with children define groups + const groups = extractGroupedRoutes(sidebar.routes, baseUrl); + for (const group of groups) { + lines.push(''); + lines.push(`### ${group.heading}`); + for (const page of group.pages) { + lines.push(`- [${page.title}](${page.url})`); + } + } + } + } + + const content = lines.join('\n'); + + res.setHeader('Content-Type', 'text/plain; charset=utf-8'); + res.write(content); + res.end(); + + return {props: {}}; +}; + +export default function LlmsTxt() { + return null; +} diff --git a/src/styles/index.css b/src/styles/index.css index 6b2915be48..7bdf4c7659 100644 --- a/src/styles/index.css +++ b/src/styles/index.css @@ -741,13 +741,6 @@ ol.mdx-illustration-block { } } -.exit { - opacity: 0; - transition: opacity 500ms ease-out; - transition-delay: 1s; - pointer-events: none; -} - .uwu-visible { display: none; } diff --git a/yarn.lock b/yarn.lock index 775106607d..fdf7958f7b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1259,10 +1259,10 @@ unist-util-visit "^4.0.0" vfile "^5.0.0" -"@next/env@15.1.11": - version "15.1.11" - resolved "https://registry.yarnpkg.com/@next/env/-/env-15.1.11.tgz#599a126f7ce56decc39cea46668cb60d96b66bc6" - integrity sha512-yp++FVldfLglEG5LoS2rXhGypPyoSOyY0kxZQJ2vnlYJeP8o318t5DrDu5Tqzr03qAhDWllAID/kOCsXNLcwKw== +"@next/env@15.1.12": + version "15.1.12" + resolved "https://registry.yarnpkg.com/@next/env/-/env-15.1.12.tgz#0821510fa71871f1970cd216963d25a5970fe58a" + integrity sha512-EWKXZKsd9ZNn+gLqOtfwH2PQyWuWFmpLldjStw7mZgWgKu56vaqNkAGQrys2g5ER4CNXEDz3Khj2tD1pnsT9Uw== "@next/eslint-plugin-next@12.0.3": version "12.0.3" @@ -5787,12 +5787,12 @@ next-tick@^1.1.0: resolved "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz" integrity sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ== -next@15.1.11: - version "15.1.11" - resolved "https://registry.yarnpkg.com/next/-/next-15.1.11.tgz#8a70a236e02d8dd62fb0569bedfd5e4290e7af55" - integrity sha512-UiVJaOGhKST58AadwbFUZThlNBmYhKqaCs8bVtm4plTxsgKq0mJ0zTsp7t7j/rzsbAEj9WcAMdZCztjByi4EoQ== +next@15.1.12: + version "15.1.12" + resolved "https://registry.yarnpkg.com/next/-/next-15.1.12.tgz#6d308fe6cb295faed724481b57f77b8abf4f5468" + integrity sha512-fClyhVCGTATGYBnETgKAi7YU5+bSwzM5rqNsY3Dg5wBoBMwE0NSvWA3fzwYj0ijl+LMeiV8P2QAnUFpeqDfTgw== dependencies: - "@next/env" "15.1.11" + "@next/env" "15.1.12" "@swc/counter" "0.1.3" "@swc/helpers" "0.5.15" caniuse-lite "^1.0.30001579"