Skip to content

Commit 705d3b6

Browse files
fengmk2claude
andauthored
feat: add raw markdown access via .md suffix for AI-friendly content (#414)
Enable accessing raw markdown content by adding .md suffix to any page URL. This is useful for AI systems and tools that need to read documentation in its original markdown format. - Add /pages/api/raw/[...slug].js API route for serving raw markdown - Update middleware.js to intercept .md URLs and rewrite to API - Support all locales (en, cn, pt-BR) with proper i18n handling - Include security measures against path traversal attacks - Add caching headers for performance Example usage: - /docs/introduction/simple-package.md → returns English markdown - /cn/docs/concepts/class.md → returns Chinese markdown 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent df029f9 commit 705d3b6

File tree

2 files changed

+115
-0
lines changed

2 files changed

+115
-0
lines changed

middleware.js

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,36 @@
11
import { NextResponse } from 'next/server'
22
import { locales } from 'nextra/locales'
33

4+
const DEFAULT_LOCALE = 'en'
5+
46
export const middleware = (req) => {
7+
const { pathname } = req.nextUrl
8+
9+
// Handle .md suffix requests for raw markdown access (AI-friendly)
10+
// Must be checked BEFORE Nextra's locale handling
11+
if (pathname.endsWith('.md')) {
12+
// Remove .md extension
13+
const cleanPath = pathname.slice(0, -3)
14+
15+
// Get locale from Next.js i18n (it strips locale from pathname)
16+
const locale = req.nextUrl.locale || DEFAULT_LOCALE
17+
18+
// Rewrite to API route - pass locale via header
19+
const apiUrl = new URL(`/api/raw${cleanPath}`, req.url)
20+
const requestHeaders = new Headers(req.headers)
21+
requestHeaders.set('x-raw-md-locale', locale)
22+
23+
return NextResponse.rewrite(apiUrl, {
24+
request: {
25+
headers: requestHeaders,
26+
},
27+
headers: {
28+
'Cross-Origin-Embedder-Policy': 'require-corp',
29+
'Cross-Origin-Opener-Policy': 'same-origin',
30+
},
31+
})
32+
}
33+
534
const res = locales(req)
635
if (res) {
736
res.headers.set('Cross-Origin-Embedder-Policy', 'require-corp')

pages/api/raw/[...slug].js

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import fs from 'fs/promises'
2+
import path from 'path'
3+
4+
const LOCALES = ['en', 'cn', 'pt-BR']
5+
const DEFAULT_LOCALE = 'en'
6+
const PAGES_DIR = path.join(process.cwd(), 'pages')
7+
8+
export default async function handler(req, res) {
9+
if (req.method !== 'GET' && req.method !== 'HEAD') {
10+
return res.status(405).json({ error: 'Method not allowed' })
11+
}
12+
13+
let { slug } = req.query
14+
15+
// If slug is undefined, try to parse from the URL (handles middleware rewrites)
16+
// The x-middleware-rewrite header contains the original path
17+
if (!slug || slug.length === 0) {
18+
// Parse from the URL directly - handle both direct API calls and middleware rewrites
19+
const url = req.url || ''
20+
const apiRawMatch = url.match(/^\/api\/raw\/(.+)$/)
21+
const mdMatch = url.match(/^\/(.+)\.md$/)
22+
23+
if (apiRawMatch) {
24+
slug = apiRawMatch[1].split('/')
25+
} else if (mdMatch) {
26+
// This is a rewritten request from middleware
27+
slug = mdMatch[1].split('/')
28+
}
29+
}
30+
31+
if (!slug || slug.length === 0) {
32+
return res.status(404).json({ error: 'Not found' })
33+
}
34+
35+
// Parse locale - prefer header (from middleware), then from slug
36+
let locale = req.headers['x-raw-md-locale'] || DEFAULT_LOCALE
37+
const pathParts = Array.isArray(slug) ? [...slug] : slug.split('/')
38+
39+
// Only extract locale from slug if not provided via header (direct API access)
40+
if (!req.headers['x-raw-md-locale'] && LOCALES.includes(pathParts[0])) {
41+
locale = pathParts.shift()
42+
}
43+
44+
const docPath = pathParts.join('/')
45+
46+
// Validate path to prevent directory traversal
47+
const normalizedPath = path.normalize(docPath)
48+
if (normalizedPath.includes('..') || path.isAbsolute(normalizedPath)) {
49+
return res.status(400).json({ error: 'Invalid path' })
50+
}
51+
52+
// Try to find the file (mdx first, then md)
53+
const extensions = ['.mdx', '.md']
54+
let content = null
55+
56+
for (const ext of extensions) {
57+
const filePath = path.join(PAGES_DIR, `${docPath}.${locale}${ext}`)
58+
59+
// Double-check the resolved path is within PAGES_DIR
60+
const resolvedPath = path.resolve(filePath)
61+
if (!resolvedPath.startsWith(PAGES_DIR)) {
62+
return res.status(400).json({ error: 'Invalid path' })
63+
}
64+
65+
try {
66+
content = await fs.readFile(filePath, 'utf-8')
67+
break
68+
} catch {
69+
continue
70+
}
71+
}
72+
73+
if (!content) {
74+
return res.status(404).json({ error: 'Document not found' })
75+
}
76+
77+
// Set appropriate headers
78+
res.setHeader('Content-Type', 'text/markdown; charset=utf-8')
79+
res.setHeader(
80+
'Content-Disposition',
81+
`inline; filename="${pathParts[pathParts.length - 1]}.md"`,
82+
)
83+
res.setHeader('Cache-Control', 'public, max-age=3600, s-maxage=3600')
84+
85+
return res.status(200).send(content)
86+
}

0 commit comments

Comments
 (0)