Merge pull request #15 from Cloud-Neutral-Toolkit/codex/refactor-blog-to-use-next-mdx-remote

This commit is contained in:
cloudneutral 2025-12-22 01:08:44 +08:00 committed by GitHub
commit 4b25efd5a5
6 changed files with 2803 additions and 211 deletions

View File

@ -20,6 +20,7 @@
"test:e2e": "playwright test --config tests/e2e/playwright.config.ts"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.956.0",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.2",
@ -48,6 +49,7 @@
"marked": "^16.1.2",
"mermaid": "^11.12.2",
"next": "^16.0.9",
"next-mdx-remote": "^5.0.0",
"next-themes": "^0.4.6",
"pdfjs-dist": "^4.2.67",
"prismjs": "^1.30.0",

View File

@ -1,6 +1,7 @@
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { readMarkdownFile } from '@lib/markdown'
import { compileMDX } from 'next-mdx-remote/rsc'
import { readMdxFile } from '@lib/mdx'
import { resolveBlogContentRoot } from '@lib/marketingContent'
import type { Metadata } from 'next'
@ -31,7 +32,7 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
const slugPath = Array.isArray(slug) ? slug.join('/') : slug
try {
const blogContentRoot = resolveBlogContentRoot()
const file = await readMarkdownFile(`${slugPath}.md`, { baseDir: blogContentRoot })
const file = await readMdxFile(slugPath, { baseDir: blogContentRoot })
const title = file.metadata.title as string
const excerpt = (file.metadata.excerpt as string) || ''
@ -52,7 +53,10 @@ export default async function BlogPostPage({ params }: PageProps) {
const slugPath = Array.isArray(slug) ? slug.join('/') : slug
try {
const blogContentRoot = resolveBlogContentRoot()
const file = await readMarkdownFile(`${slugPath}.md`, { baseDir: blogContentRoot })
const file = await readMdxFile(slugPath, { baseDir: blogContentRoot })
const mdx = await compileMDX({
source: file.content,
})
const title = (file.metadata.title as string) || slugPath
const author = file.metadata.author as string | undefined
@ -105,8 +109,9 @@ export default async function BlogPostPage({ params }: PageProps) {
{/* Article content */}
<article
className="prose prose-slate max-w-none prose-headings:scroll-mt-24 prose-a:text-brand prose-a:no-underline hover:prose-a:underline"
dangerouslySetInnerHTML={{ __html: file.html }}
/>
>
{mdx.content}
</article>
{/* Footer */}
<footer className="mt-16 border-t border-slate-200 pt-8">

View File

@ -1,118 +0,0 @@
import { promises as fs } from 'fs'
import path from 'path'
import { marked } from 'marked'
export type FrontMatterValue = string | string[]
export interface MarkdownFile {
metadata: Record<string, FrontMatterValue>
content: string
html: string
slug: string
}
const CONTENT_ROOT = path.join(process.cwd(), 'src', 'content')
function normalizeQuotes(value: string): string {
const trimmed = value.trim()
if (
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
(trimmed.startsWith("'") && trimmed.endsWith("'"))
) {
return trimmed.slice(1, -1).trim()
}
return trimmed
}
function parseFrontMatter(raw: string): {
metadata: Record<string, FrontMatterValue>
content: string
} {
const frontMatterMatch = raw.match(/^---\s*\n([\s\S]*?)\n---\s*\n?([\s\S]*)$/)
if (!frontMatterMatch) {
return { metadata: {}, content: raw.trim() }
}
const [, frontMatter, body] = frontMatterMatch
const metadata: Record<string, FrontMatterValue> = {}
let currentKey: string | null = null
const lines = frontMatter.split(/\r?\n/)
for (const line of lines) {
if (!line.trim()) {
continue
}
const keyValueMatch = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/)
if (keyValueMatch) {
const [, key, value] = keyValueMatch
currentKey = key
if (!value || value.trim() === '') {
metadata[key] = []
continue
}
metadata[key] = normalizeQuotes(value)
continue
}
if (currentKey && line.trim().startsWith('- ')) {
const normalizedValue = normalizeQuotes(line.trim().slice(2))
const currentValue = metadata[currentKey]
if (Array.isArray(currentValue)) {
currentValue.push(normalizedValue)
} else if (typeof currentValue === 'string') {
metadata[currentKey] = [currentValue, normalizedValue]
} else {
metadata[currentKey] = [normalizedValue]
}
}
}
return {
metadata,
content: body.trim(),
}
}
export async function readMarkdownFile(
relativePath: string,
options?: { baseDir?: string }
): Promise<MarkdownFile> {
const baseDir = options?.baseDir ?? CONTENT_ROOT
const filePath = path.join(baseDir, relativePath)
const raw = await fs.readFile(filePath, 'utf-8')
const { metadata, content } = parseFrontMatter(raw)
const htmlResult = await marked.parse(content)
const html = typeof htmlResult === 'string' ? htmlResult : await htmlResult
const withoutExtension = relativePath.replace(new RegExp(`${path.extname(relativePath)}$`), '')
const slug = withoutExtension.split(path.sep).join('/')
return { metadata, content, html, slug }
}
export async function readMarkdownDirectory(
relativeDir: string,
options?: { baseDir?: string; recursive?: boolean }
): Promise<MarkdownFile[]> {
const baseDir = options?.baseDir ?? CONTENT_ROOT
const dirPath = path.join(baseDir, relativeDir)
const entries = await fs.readdir(dirPath, { withFileTypes: true })
const files = entries.filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
const results = await Promise.all(
files.map((file) => readMarkdownFile(path.join(relativeDir, file.name), { baseDir }))
)
if (!options?.recursive) {
return results
}
const nestedDirectories = entries.filter((entry) => entry.isDirectory())
const nestedResults = await Promise.all(
nestedDirectories.map((dir) => readMarkdownDirectory(path.join(relativeDir, dir.name), { baseDir, recursive: true }))
)
return results.concat(...nestedResults)
}

View File

@ -1,7 +1,7 @@
import fs from 'fs'
import path from 'path'
import { readMarkdownDirectory } from './markdown'
import { readMdxDirectory } from './mdx'
export interface HeroContent {
eyebrow?: string
@ -38,7 +38,7 @@ export interface HomepagePost {
readingTime?: string
tags: string[]
excerpt: string
contentHtml: string
content: string
category?: {
key: string
label: string
@ -225,7 +225,12 @@ function resolveCategory(slug: string): { key: string; label: string } | undefin
}
function extractExcerpt(markdown: string): string {
const blocks = markdown.split(/\r?\n\s*\r?\n/)
const cleaned = markdown
.replace(/^\s*import\s+.*$/gm, '')
.replace(/^\s*export\s+const\s+.*$/gm, '')
.trim()
const blocks = cleaned.split(/\r?\n\s*\r?\n/)
for (const block of blocks) {
const trimmed = block.trim()
if (!trimmed) continue
@ -252,7 +257,7 @@ export async function getHomepagePosts(): Promise<HomepagePost[]> {
let posts: HomepagePost[] = []
try {
const contentRoot = resolveBlogContentRoot()
const files = await readMarkdownDirectory('', { baseDir: contentRoot, recursive: true })
const files = await readMdxDirectory('', { baseDir: contentRoot, recursive: true })
posts = files.map((file) => {
const title = typeof file.metadata.title === 'string' ? file.metadata.title : file.slug
@ -275,7 +280,7 @@ export async function getHomepagePosts(): Promise<HomepagePost[]> {
readingTime,
tags,
excerpt,
contentHtml: file.html,
content: file.content,
category,
}
})

184
src/lib/mdx.ts Normal file
View File

@ -0,0 +1,184 @@
import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3'
import { promises as fs } from 'fs'
import matter from 'gray-matter'
import path from 'path'
import { Readable } from 'stream'
export type FrontMatterValue = string | string[]
export interface MdxFile {
metadata: Record<string, FrontMatterValue>
content: string
raw: string
slug: string
}
export type MdxSource =
| { type: 'filesystem'; filePath: string }
| { type: 'volume'; filePath: string }
| { type: 'http'; url: string; headers?: Record<string, string> }
| { type: 's3'; bucket: string; key: string; region?: string; client?: S3Client }
const DEFAULT_CONTENT_ROOT = path.join(process.cwd(), 'src', 'content')
const DEFAULT_EXTENSIONS = ['.mdx', '.md']
async function streamToString(stream: Readable): Promise<string> {
const chunks: Buffer[] = []
for await (const chunk of stream) {
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : Buffer.from(chunk))
}
return Buffer.concat(chunks).toString('utf-8')
}
async function readableStreamToString(stream: ReadableStream<Uint8Array>): Promise<string> {
const reader = stream.getReader()
const chunks: Uint8Array[] = []
while (true) {
const { value, done } = await reader.read()
if (done) break
if (value) {
chunks.push(value)
}
}
return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))).toString('utf-8')
}
async function objectBodyToString(body: unknown): Promise<string> {
if (!body) {
throw new Error('S3 object body is empty')
}
if (typeof body === 'string') {
return body
}
if (body instanceof Uint8Array) {
return Buffer.from(body).toString('utf-8')
}
if (body instanceof Readable) {
return streamToString(body)
}
if (body instanceof Blob) {
return body.text()
}
if (typeof (body as ReadableStream<Uint8Array>).getReader === 'function') {
return readableStreamToString(body as ReadableStream<Uint8Array>)
}
throw new Error('Unsupported S3 body type')
}
function normalizeMetadata(data: Record<string, unknown>): Record<string, FrontMatterValue> {
return Object.entries(data).reduce<Record<string, FrontMatterValue>>((acc, [key, value]) => {
if (typeof value === 'string') {
acc[key] = value
} else if (Array.isArray(value)) {
const items = value.filter((item): item is string => typeof item === 'string')
if (items.length) {
acc[key] = items
}
} else if (value != null) {
acc[key] = String(value)
}
return acc
}, {})
}
export async function loadMdxSource(source: MdxSource): Promise<string> {
switch (source.type) {
case 'filesystem':
case 'volume': {
return fs.readFile(source.filePath, 'utf-8')
}
case 'http': {
const response = await fetch(source.url, { headers: source.headers })
if (!response.ok) {
throw new Error(`Failed to fetch MDX from ${source.url}: ${response.status} ${response.statusText}`)
}
return response.text()
}
case 's3': {
const client = source.client ?? new S3Client({ region: source.region })
const result = await client.send(new GetObjectCommand({ Bucket: source.bucket, Key: source.key }))
return objectBodyToString(result.Body)
}
default:
throw new Error('Unsupported MDX source')
}
}
async function resolveFilePath(relativePath: string, baseDir: string, extensions: string[]): Promise<string> {
const targetPath = path.isAbsolute(relativePath) ? relativePath : path.join(baseDir, relativePath)
const hasExtension = Boolean(path.extname(targetPath))
const candidatePaths = hasExtension ? [targetPath] : extensions.map((ext) => `${targetPath}${ext}`)
for (const candidate of candidatePaths) {
try {
await fs.access(candidate)
return candidate
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error
}
}
}
throw new Error(`MDX file not found at ${relativePath}`)
}
export async function readMdxFile(
relativePath: string,
options?: { baseDir?: string; extensions?: string[] }
): Promise<MdxFile> {
const baseDir = options?.baseDir ?? DEFAULT_CONTENT_ROOT
const extensions = options?.extensions ?? DEFAULT_EXTENSIONS
const absolutePath = await resolveFilePath(relativePath, baseDir, extensions)
const raw = await loadMdxSource({ type: 'filesystem', filePath: absolutePath })
const { data, content } = matter(raw)
const normalizedPath = path.relative(baseDir, absolutePath)
const withoutExtension = normalizedPath.replace(new RegExp(`${path.extname(normalizedPath)}$`), '')
const slug = withoutExtension.split(path.sep).join('/')
return {
metadata: normalizeMetadata(data as Record<string, unknown>),
content: content.trim(),
raw,
slug,
}
}
export async function readMdxDirectory(
relativeDir: string,
options?: { baseDir?: string; recursive?: boolean; extensions?: string[] }
): Promise<MdxFile[]> {
const baseDir = options?.baseDir ?? DEFAULT_CONTENT_ROOT
const extensions = options?.extensions ?? DEFAULT_EXTENSIONS
const dirPath = path.isAbsolute(relativeDir) ? relativeDir : path.join(baseDir, relativeDir)
const entries = await fs.readdir(dirPath, { withFileTypes: true })
const files = entries.filter(
(entry) => entry.isFile() && extensions.includes(path.extname(entry.name).toLowerCase())
)
const results = await Promise.all(
files.map((file) => readMdxFile(path.join(relativeDir, file.name), { baseDir, extensions }))
)
if (!options?.recursive) {
return results
}
const nestedDirectories = entries.filter((entry) => entry.isDirectory())
const nestedResults = await Promise.all(
nestedDirectories.map((dir) =>
readMdxDirectory(path.join(relativeDir, dir.name), { baseDir, recursive: true, extensions })
)
)
return results.concat(...nestedResults)
}

2680
yarn.lock

File diff suppressed because it is too large Load Diff