Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 0 additions & 4 deletions src/components/Layout/SidebarNav/SidebarNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -63,9 +62,6 @@ export default function SidebarNav({
</Suspense>
<div className="h-20" />
</nav>
<div className="fixed bottom-0 hidden lg:block">
<Feedback />
</div>
</aside>
</div>
</div>
Expand Down
4 changes: 0 additions & 4 deletions src/components/Layout/TopNav/TopNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -448,9 +447,6 @@ export default function TopNav({
</Suspense>
<div className="h-16" />
</nav>
<div className="fixed bottom-0 hidden lg:block">
<Feedback />
</div>
</aside>
</div>
)}
Expand Down
53 changes: 53 additions & 0 deletions src/pages/api/md/[...path].ts
Original file line number Diff line number Diff line change
@@ -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');
}
269 changes: 269 additions & 0 deletions src/pages/llms.txt.tsx
Original file line number Diff line number Diff line change
@@ -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;
}
Loading