Skip to content

Commit f047aa2

Browse files
gaearonclaude
andcommitted
Load Markdown content through the bundler
Reading src/content with `fs` inside 'use cache' meant that in development an edited .md file kept serving stale content on a normal reload (only a hard refresh bypassed the cache), and nothing triggered a refresh on save now that next-remote-watch is gone. Import the files via import.meta.glob with Turbopack's built-in raw-loader instead. The content is part of the module graph, so saving a .md file Fast Refreshes the open page in place, and production bundles the files rather than depending on outputFileTracingIncludes. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 474b1e9 commit f047aa2

6 files changed

Lines changed: 87 additions & 86 deletions

File tree

next.config.js

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,6 @@ const nextConfig = {
1717
reactStrictMode: true,
1818
reactCompiler: true,
1919
cacheComponents: true,
20-
outputFileTracingIncludes: {
21-
'/*': ['./src/content/**/*.md'],
22-
},
2320
serverExternalPackages: [
2421
'@babel/core',
2522
'@babel/plugin-transform-modules-commonjs',
@@ -31,6 +28,15 @@ const nextConfig = {
3128
'remark-frontmatter',
3229
],
3330
turbopack: {
31+
// Lets src/contentFiles.ts import the Markdown sources as strings via
32+
// import.meta.glob, so content is bundled and hot-reloads in development.
33+
// raw-loader is built into Turbopack; it doesn't need to be installed.
34+
rules: {
35+
'*.md': {
36+
loaders: ['raw-loader'],
37+
as: '*.js',
38+
},
39+
},
3440
resolveAlias: {
3541
'use-sync-external-store/shim': 'react',
3642
esquery: 'esquery/dist/esquery.min.js',

src/app/api/md/[...path]/route.ts

Lines changed: 3 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,10 @@
55
* LICENSE file in the root directory of this source tree.
66
*/
77

8-
import fs from 'fs';
9-
import path from 'path';
108
import {NextResponse} from 'next/server';
119
import {cacheLife} from 'next/cache';
1210
import {collectAllContentPaths, isContentPageAvailable} from 'lib/collectPaths';
11+
import {readContentPage} from 'contentFiles';
1312

1413
const FOOTER = `
1514
---
@@ -29,8 +28,7 @@ export async function generateStaticParams() {
2928
}
3029

3130
/**
32-
* Read a markdown file for the given URL segments. Cached so prerendered
33-
* `.md` endpoints don't re-read from disk per request. Returns null when no
31+
* Read a markdown file for the given URL segments. Returns null when no
3432
* matching file exists.
3533
*/
3634
async function readContentMarkdown(
@@ -45,18 +43,7 @@ async function readContentMarkdown(
4543
// Block /index.md URLs - use /foo.md instead of /foo/index.md
4644
if (filePath.endsWith('/index') || filePath === 'index') return null;
4745

48-
const candidates = [
49-
path.join(process.cwd(), 'src/content', filePath + '.md'),
50-
path.join(process.cwd(), 'src/content', filePath, 'index.md'),
51-
];
52-
for (const fullPath of candidates) {
53-
try {
54-
return fs.readFileSync(/* turbopackIgnore: true */ fullPath, 'utf8');
55-
} catch {
56-
// Try next candidate
57-
}
58-
}
59-
return null;
46+
return readContentPage(filePath);
6047
}
6148

6249
export async function GET(

src/contentFiles.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
import 'server-only';
9+
10+
// Every Markdown file under src/content, keyed by its path relative to that
11+
// folder (e.g. "learn/index.md"). Loading the content through the bundler
12+
// instead of `fs` makes it part of the module graph: edits trigger Fast
13+
// Refresh in development, and the files are bundled for production instead
14+
// of relying on output file tracing. The `*.md` -> raw-loader rule lives in
15+
// next.config.js.
16+
//
17+
// This file has to sit next to `content/`: Turbopack's import.meta.glob does
18+
// not match patterns that start with `../`.
19+
const contentModules = import.meta.glob('./content/**/*.md', {
20+
import: 'default',
21+
}) as Record<string, () => Promise<string>>;
22+
23+
const PREFIX = './content/';
24+
25+
const contentFiles = new Map<string, () => Promise<string>>();
26+
for (const [key, load] of Object.entries(contentModules)) {
27+
contentFiles.set(key.slice(PREFIX.length), load);
28+
}
29+
30+
/** Relative paths of all Markdown files, e.g. ["learn/index.md", ...]. */
31+
export function listContentFiles(): string[] {
32+
return Array.from(contentFiles.keys());
33+
}
34+
35+
/** Read "learn/index.md" etc. Returns null when the file doesn't exist. */
36+
export async function readContentFile(
37+
relativePath: string
38+
): Promise<string | null> {
39+
const load = contentFiles.get(relativePath);
40+
return load ? load() : null;
41+
}
42+
43+
/**
44+
* Resolve a route path ("learn/state") to `<path>.md` or `<path>/index.md`,
45+
* mirroring the old Pages Router lookup.
46+
*/
47+
export async function readContentPage(
48+
routePath: string
49+
): Promise<string | null> {
50+
return (
51+
(await readContentFile(routePath + '.md')) ??
52+
(await readContentFile(routePath + '/index.md'))
53+
);
54+
}

src/lib/collectPaths.ts

Lines changed: 12 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,8 @@
66
*/
77

88
import 'server-only';
9-
import fs from 'fs';
10-
import path from 'path';
11-
import {promisify} from 'util';
12-
import {cacheLife} from 'next/cache';
9+
import {listContentFiles} from 'contentFiles';
1310

14-
const readdir = promisify(fs.readdir);
15-
const stat = promisify(fs.stat);
16-
17-
const ROOT = path.join(process.cwd(), 'src/content');
1811
const DEV_ONLY_PAGES = new Set(['learn/rsc-sandbox-test']);
1912

2013
export function isContentPageAvailable(segments: string[]): boolean {
@@ -24,21 +17,10 @@ export function isContentPageAvailable(segments: string[]): boolean {
2417
);
2518
}
2619

27-
async function getFiles(dir: string, base: string): Promise<string[]> {
28-
const subdirs = await readdir(dir);
29-
const files = await Promise.all(
30-
subdirs.map(async (subdir) => {
31-
const res = path.resolve(dir, subdir);
32-
return (await stat(res)).isDirectory()
33-
? getFiles(res, base)
34-
: res.slice(base.length + 1);
35-
})
36-
);
37-
return files.flat().filter((file) => file.endsWith('.md'));
38-
}
39-
20+
// 'foo/bar/baz.md' -> ['foo', 'bar', 'baz']
21+
// 'foo/bar/qux/index.md' -> ['foo', 'bar', 'qux']
4022
function getSegments(file: string): string[] {
41-
const segments = file.slice(0, -3).replace(/\\/g, '/').split('/');
23+
const segments = file.slice(0, -3).split('/');
4224
if (segments[segments.length - 1] === 'index') {
4325
segments.pop();
4426
}
@@ -54,13 +36,9 @@ function getSegments(file: string): string[] {
5436
export async function collectSectionPaths(
5537
section: string
5638
): Promise<string[][]> {
57-
'use cache';
58-
cacheLife('max');
59-
const dir = path.join(ROOT, section);
60-
if (!fs.existsSync(dir)) return [];
61-
const files = await getFiles(dir, dir);
62-
return files
63-
.map((file) => getSegments(file))
39+
return listContentFiles()
40+
.filter((file) => file.startsWith(section + '/'))
41+
.map((file) => getSegments(file).slice(1))
6442
.filter((segments) => isContentPageAvailable([section, ...segments]));
6543
}
6644

@@ -71,12 +49,9 @@ export async function collectSectionPaths(
7149
* statically prerender the markdown route handler.
7250
*/
7351
export async function collectAllContentPaths(): Promise<string[][]> {
74-
'use cache';
75-
cacheLife('max');
76-
const files = await getFiles(ROOT, ROOT);
7752
return (
78-
files
79-
.map((file) => getSegments(file))
53+
listContentFiles()
54+
.map(getSegments)
8055
// Drop the root `index.md` (-> []); `/index.md` isn't a served URL and an
8156
// empty catch-all param can't be prerendered.
8257
.filter((segments) => segments.length > 0)
@@ -91,12 +66,7 @@ export async function collectAllContentPaths(): Promise<string[][]> {
9166
export async function collectFlatSectionSlugs(
9267
section: string
9368
): Promise<string[]> {
94-
'use cache';
95-
cacheLife('max');
96-
const dir = path.join(ROOT, section);
97-
if (!fs.existsSync(dir)) return [];
98-
const entries = await readdir(dir);
99-
return entries
100-
.filter((name) => name.endsWith('.md') && name !== 'index.md')
101-
.map((name) => name.slice(0, -3));
69+
return (await collectSectionPaths(section))
70+
.filter((segments) => segments.length === 1)
71+
.map(([slug]) => slug);
10272
}

src/lib/loadErrorDecoderData.ts

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,11 @@
66
*/
77

88
import 'server-only';
9-
import fs from 'fs';
10-
import path from 'path';
119
import {notFound} from 'next/navigation';
1210
import {cacheLife} from 'next/cache';
1311
import compileMDX from 'utils/compileMDX';
1412
import type {CompiledMDX} from 'utils/compileMDX';
13+
import {readContentFile} from 'contentFiles';
1514

1615
export interface ErrorDecoderData extends CompiledMDX {
1716
errorCode: string | null;
@@ -38,13 +37,12 @@ async function compileErrorDecoderData(
3837
): Promise<ErrorDecoderData> {
3938
'use cache';
4039
cacheLife('max');
41-
const rootDir = path.join(process.cwd(), 'src/content/errors');
42-
const targetPath = code || 'index';
43-
let mdx: string;
44-
try {
45-
mdx = fs.readFileSync(path.join(rootDir, targetPath + '.md'), 'utf8');
46-
} catch {
47-
mdx = fs.readFileSync(path.join(rootDir, 'generic.md'), 'utf8');
40+
// Use errors/<code>.md when it exists, otherwise fall back to generic.md.
41+
const mdx =
42+
(await readContentFile(`errors/${code || 'index'}.md`)) ??
43+
(await readContentFile('errors/generic.md'));
44+
if (mdx == null) {
45+
throw new Error('Missing src/content/errors/generic.md');
4846
}
4947

5048
const compiled = await compileMDX(mdx);

src/lib/readMarkdownPage.ts

Lines changed: 2 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,14 @@
66
*/
77

88
import 'server-only';
9-
import fs from 'fs/promises';
10-
import path from 'path';
119
import {cacheLife} from 'next/cache';
1210
import {isContentPageAvailable} from './collectPaths';
11+
import {readContentPage} from 'contentFiles';
1312
import compileMDX from 'utils/compileMDX';
1413
import type {CompiledMDX} from 'utils/compileMDX';
1514

1615
export type PageData = CompiledMDX;
1716

18-
const ROOT = path.join(process.cwd(), 'src/content');
19-
2017
/**
2118
* Read and compile an MDX page from src/content. Resolves either
2219
* `<segments>.md` or `<segments>/index.md`. Returns null when neither exists.
@@ -36,18 +33,7 @@ export async function readMarkdownPage(
3633
cacheLife('max');
3734
if (!isContentPageAvailable(segments)) return null;
3835
const routePath = segments.join('/') || 'index';
39-
let mdx: string | null = null;
40-
for (const candidate of [
41-
path.join(ROOT, routePath + '.md'),
42-
path.join(ROOT, routePath, 'index.md'),
43-
]) {
44-
try {
45-
mdx = await fs.readFile(/* turbopackIgnore: true */ candidate, 'utf8');
46-
break;
47-
} catch {
48-
// Try next candidate.
49-
}
50-
}
36+
const mdx = await readContentPage(routePath);
5137
if (mdx == null) return null;
5238
const compiled = await compileMDX(mdx);
5339
if (routePath === 'index') {

0 commit comments

Comments
 (0)