Merge pull request #15 from Cloud-Neutral-Toolkit/codex/refactor-blog-to-use-next-mdx-remote
This commit is contained in:
commit
4b25efd5a5
@ -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",
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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)
|
||||
}
|
||||
@ -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
184
src/lib/mdx.ts
Normal 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)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user