Next.js 15 + MDX 3.0 部落格開發完整指南:分散式 ESM Metadata 與現代化內容管理 (2025)

Next.js 15 + MDX 3.0 部落格開發完整指南:分散式 ESM Metadata 與現代化內容管理 (2025)
Ian Chou
Ian Chou

深入剖析 Next.js 15 結合 MDX 3.0 的現代部落格開發流程,從分散式 ESM metadata 管理、local components 到最終頁面渲染的完整技術解析。探索如何在每個 MDX 檔案中使用 ESM export 管理 metadata,提升開發效率與維護性。涵蓋組件架構設計、靜態路由產生、MDX 渲染機制等核心概念,並提供進階優化策略與最佳實踐。

Next.jsMDXReactTypeScriptBlog DevelopmentApp RouterSSGComponent ArchitectureESM Metadata技術教學

在現代前端開發中,Next.js 結合 MDX 已成為建立技術部落格的主流選擇。本文將深入剖析從分散式 ESM metadata、local components 到最終 blog 頁面的完整轉換流程,以實際專案架構為例,逐步拆解每個環節的技術細節。我們將重點探討如何使用 MDX 檔案頂部的 ESM export 來管理 metadata,這種方式相比集中式管理提供了更好的內容內聚性與開發體驗。

目錄

  1. 專案架構概覽
  2. ESM Metadata 管理策略
  3. 檔案與資料來源
  4. 內容讀取與解析機制
  5. 靜態路由產生流程
  6. MDX 渲染與組件整合
  7. 頁面組合與最終呈現
  8. 完整數據流示例
  9. 進階優化與最佳實踐

專案架構概覽

本專案採用 Next.js 15+ App Router 架構,結合 MDX 3.1.0 進行內容管理,並使用分散式 ESM metadata 管理策略。核心技術棧包括:

  • Next.js 15.3.2 with App Router
  • React 19.0.0
  • MDX 3.1.0 for content with ESM metadata
  • TypeScript for type safety
  • Tailwind CSS v4 for styling
  • next-mdx-remote for dynamic MDX rendering

核心檔案結構

├── app/
│   └── blog/
│       └── [slug]/
│           ├── page.tsx              # 主要文章頁面邏輯
│           └── MDXRenderer.tsx       # 客戶端 MDX 渲染組件
├── content/
│   └── posts/
│       └── [slug]/
│           ├── content.mdx           # 文章內容 + ESM metadata
│           └── components/           # 文章專用組件
│               ├── index.ts          # 組件匯出檔
│               ├── CustomChart.tsx
│               └── ...
├── lib/
│   ├── mdx.ts                        # MDX 處理核心函數
│   ├── mdx-loader.ts                 # 組件載入邏輯
│   └── metadata-extractor.ts        # ESM metadata 提取工具
└── components/
    ├── BlogPostContent.static.tsx    # 靜態頁面布局
    └── mdx/
        └── global-components/        # 全域 MDX 組件

ESM Metadata 管理策略

1. 分散式 ESM Metadata 的優勢

相較於集中式管理,ESM metadata 提供以下優勢:

  • 內容內聚性:metadata 與內容位於同一檔案,便於維護
  • 型別安全:每個檔案的 metadata 都有完整的 TypeScript 支援
  • 版本控制友善:metadata 修改與內容修改在同一個 commit 中
  • 獨立性:每篇文章可以獨立管理,不會互相影響
  • 擴展性:容易新增文章專用的 metadata 欄位

2. MDX 檔案的 ESM Metadata 結構

每個 MDX 檔案的頂部使用 ESM export 定義 metadata:

// content/posts/nextjs-mdx-setup/content.mdx

# Next.js + MDX 部落格建置指南

建立現代化的技術部落格需要考慮多個面向...

## 效能分析

以下是不同框架的效能比較:

<PerformanceChart 
  data={performanceData} 
  title="Framework Performance Comparison" 
/>

3. Metadata 型別定義

// lib/types.ts
export interface BlogMetadata {
  title: string;
  date: string;
  author: string;
  excerpt: string;
  tags: string[];
  coverImage?: string;
  readingTime: number;
  featured?: boolean;
  category?: string;
  updatedDate?: string;
  seoKeywords?: string[];
  canonicalUrl?: string;
}

export interface BlogPost extends BlogMetadata {
  slug: string;
}

export interface PostWithContent {
  metadata: BlogMetadata;
  content: string;
  rawContent: string; // 包含 metadata export 的原始內容
}

檔案與資料來源

1. MDX 內容結構

每篇文章的結構保持一致,metadata 直接在檔案頂部定義:

content/posts/nextjs-mdx-setup/
├── content.mdx                 # 主要文章內容 + ESM metadata
└── components/                 # 文章專用組件
    ├── index.ts               # 組件匯出檔
    ├── PerformanceChart.tsx   # 效能圖表組件
    └── CodeComparison.tsx     # 程式碼比較組件

2. ESM Metadata 提取機制

我們需要建立專門的工具來提取 MDX 檔案中的 ESM metadata:

// lib/metadata-extractor.ts
import { compile } from '@mdx-js/mdx';
import { VFile } from 'vfile';

/**
 * 從 MDX 內容中提取 ESM metadata
 */
export async function extractMetadataFromMDX(content: string): Promise<{
  metadata: any;
  cleanContent: string;
}> {
  try {
    // 使用正則表達式提取 export const metadata
    const metadataRegex = /^export\s+const\s+metadata\s*=\s*({[\s\S]*?});/m;
    const match = content.match(metadataRegex);
    
    if (!match) {
      throw new Error('No metadata export found in MDX file');
    }

    const metadataString = match[1];
    const cleanContent = content.replace(metadataRegex, '').trim();

    // 安全地評估 metadata 對象
    const metadata = evaluateMetadataObject(metadataString);

    return {
      metadata,
      cleanContent
    };
  } catch (error) {
    console.error('Error extracting metadata:', error);
    throw error;
  }
}

/**
 * 安全地評估 metadata 對象
 */
function evaluateMetadataObject(metadataString: string): any {
  try {
    // 建立一個安全的評估環境
    const evalFunction = new Function(`return ${metadataString}`);
    return evalFunction();
  } catch (error) {
    console.error('Error evaluating metadata object:', error);
    throw new Error('Invalid metadata object syntax');
  }
}

/**
 * 驗證 metadata 格式
 */
export function validateMetadata(metadata: any): BlogMetadata {
  const required = ['title', 'date', 'author', 'excerpt'];
  
  for (const field of required) {
    if (!metadata[field]) {
      throw new Error(`Missing required metadata field: ${field}`);
    }
  }

  // 確保 tags 是陣列
  if (metadata.tags && !Array.isArray(metadata.tags)) {
    throw new Error('Tags must be an array');
  }

  // 確保 date 格式正確
  if (isNaN(Date.parse(metadata.date))) {
    throw new Error('Invalid date format');
  }

  return metadata as BlogMetadata;
}

3. Local Components 架構

組件架構保持不變,每篇文章可以擁有專屬的 React 組件:

// content/posts/nextjs-mdx-setup/components/index.ts
"use client";

import PerformanceChart from './PerformanceChart';
import CodeComparison from './CodeComparison';
import InteractiveDemo from './InteractiveDemo';

export {
  PerformanceChart,
  CodeComparison,
  InteractiveDemo
};

內容讀取與解析機制

1. 核心讀取函數

getAllPostSlugs - 掃描所有文章目錄

// lib/mdx.ts
import fs from 'fs';
import path from 'path';

const postsDirectory = path.join(process.cwd(), 'content', 'posts');

export function getAllPostSlugs(): string[] {
  try {
    const entries = fs.readdirSync(postsDirectory, { withFileTypes: true });
    
    return entries
      .filter(entry => entry.isDirectory())
      .map(entry => entry.name)
      .filter(slug => {
        // 確認每個目錄都有 content.mdx 檔案
        const mdxPath = path.join(postsDirectory, slug, 'content.mdx');
        return fs.existsSync(mdxPath);
      });
  } catch (error) {
    console.error('Error reading posts directory:', error);
    return [];
  }
}

getPostBySlug - 取得完整文章資料與 metadata

export async function getPostBySlug(slug: string): Promise<PostWithContent | null> {
  try {
    const fullPath = path.join(postsDirectory, slug, 'content.mdx');
    
    if (!fs.existsSync(fullPath)) {
      console.warn(`MDX file not found: ${fullPath}`);
      return null;
    }

    // 讀取 MDX 檔案內容
    const rawContent = fs.readFileSync(fullPath, 'utf8');

    // 提取 metadata 和清理後的內容
    const { metadata, cleanContent } = await extractMetadataFromMDX(rawContent);
    
    // 驗證 metadata 格式
    const validatedMetadata = validateMetadata(metadata);

    return {
      metadata: validatedMetadata,
      content: cleanContent,
      rawContent
    };
  } catch (error) {
    console.error(`Error reading post ${slug}:`, error);
    return null;
  }
}

getAllPosts - 取得所有文章列表

export async function getAllPosts(): Promise<BlogPost[]> {
  const slugs = getAllPostSlugs();
  
  const posts = await Promise.all(
    slugs.map(async (slug) => {
      const post = await getPostBySlug(slug);
      if (!post) return null;
      
      return { slug, ...post.metadata };
    })
  );

  const validPosts = posts.filter((post): post is BlogPost => post !== null);

  // 按日期排序,最新的在前
  return validPosts.sort((a, b) => {
    return new Date(b.date).getTime() - new Date(a.date).getTime();
  });
}

2. 快取機制

為了提升效能,我們可以加入記憶體快取:

// lib/mdx-cache.ts
class MDXCache {
  private cache = new Map<string, PostWithContent>();
  private metadataCache = new Map<string, BlogMetadata>();

  async getPost(slug: string): Promise<PostWithContent | null> {
    if (this.cache.has(slug)) {
      return this.cache.get(slug)!;
    }

    const post = await getPostBySlug(slug);
    if (post) {
      this.cache.set(slug, post);
      this.metadataCache.set(slug, post.metadata);
    }

    return post;
  }

  async getMetadata(slug: string): Promise<BlogMetadata | null> {
    if (this.metadataCache.has(slug)) {
      return this.metadataCache.get(slug)!;
    }

    const post = await this.getPost(slug);
    return post ? post.metadata : null;
  }

  clear(): void {
    this.cache.clear();
    this.metadataCache.clear();
  }

  // 開發環境下可以清除快取
  clearInDevelopment(): void {
    if (process.env.NODE_ENV === 'development') {
      this.clear();
    }
  }
}

export const mdxCache = new MDXCache();

3. Local Components 載入機制

組件載入邏輯保持不變:

// lib/mdx-loader.ts
export async function getPostComponents(slug: string): Promise<Record<string, any>> {
  try {
    const componentsPath = path.join(postsDirectory, slug, 'components');
    
    if (!fs.existsSync(componentsPath)) {
      return {};
    }

    const indexPath = path.join(componentsPath, 'index.ts');
    
    if (!fs.existsSync(indexPath)) {
      return {};
    }

    // 動態 import 組件
    const components = await import(indexPath);
    return components;
  } catch (error) {
    console.warn(`Failed to load components for ${slug}:`, error);
    return {};
  }
}

靜態路由產生流程

1. generateStaticParams - 產生所有文章路徑

// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
  const posts = await getAllPosts();
  
  return posts.map((post) => ({
    slug: post.slug,
  }));
}

2. generateMetadata - 產生 SEO metadata

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const resolvedParams = await params;
  const post = await getPostBySlug(resolvedParams.slug);

  if (!post) {
    return {
      title: 'Post Not Found',
    };
  }

  const { metadata } = post;

  return {
    title: metadata.title,
    description: metadata.excerpt,
    keywords: metadata.seoKeywords ? metadata.seoKeywords.join(', ') : metadata.tags.join(', '),
    authors: [{ name: metadata.author }],
    canonical: metadata.canonicalUrl,
    openGraph: {
      title: metadata.title,
      description: metadata.excerpt,
      type: 'article',
      publishedTime: metadata.date,
      modifiedTime: metadata.updatedDate,
      authors: [metadata.author],
      tags: metadata.tags,
      images: metadata.coverImage ? [
        {
          url: metadata.coverImage,
          width: 1200,
          height: 630,
          alt: metadata.title,
        }
      ] : [],
    },
    twitter: {
      card: 'summary_large_image',
      title: metadata.title,
      description: metadata.excerpt,
      images: metadata.coverImage ? [metadata.coverImage] : [],
    },
  };
}

MDX 渲染與組件整合

1. MDXRenderer - 客戶端 MDX 渲染器

// app/blog/[slug]/MDXRenderer.tsx
'use client';

import React, { useState, useEffect } from 'react';
import { MDXRemote, type MDXRemoteSerializeResult } from 'next-mdx-remote';
import { serialize } from 'next-mdx-remote/serialize';
import globalComponents from '@/components/mdx/MDXComponents';

interface MDXRendererProps {
  source: string;
  components?: Record<string, any>;
}

export default function MDXRenderer({ source, components = {} }: MDXRendererProps) {
  const [mdxSource, setMdxSource] = useState<MDXRemoteSerializeResult | null>(null);
  const [error, setError] = useState<string | null>(null);

  // 合併全域組件與本地組件(本地組件優先)
  const mergedComponents = {
    ...globalComponents,
    ...components,
  };

  useEffect(() => {
    const processMDX = async () => {
      try {
        const serialized = await serialize(source, {
          parseFrontmatter: false, // 我們已經在伺服器端處理過 metadata
          mdxOptions: {
            development: process.env.NODE_ENV === 'development',
          },
        });
        
        setMdxSource(serialized);
        setError(null);
      } catch (err) {
        console.error('MDX processing error:', err);
        setError('MDX 內容處理錯誤,請檢查語法是否正確。');
      }
    };

    if (source) {
      processMDX();
    }
  }, [source]);

  if (error) {
    return (
      <div className="bg-red-50 border border-red-200 rounded-lg p-4">
        <p className="text-red-800">{error}</p>
      </div>
    );
  }

  if (!mdxSource) {
    return (
      <div className="flex items-center justify-center py-8">
        <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
        <span className="ml-2 text-gray-600">載入中...</span>
      </div>
    );
  }

  return <MDXRemote {...mdxSource} components={mergedComponents} />;
}

2. 全域與區域組件合併機制

組件優先級順序保持不變:

  1. Local Components (最高優先級) - 文章專用組件
  2. Global Components - 全站共用組件
// components/mdx/MDXComponents.tsx
import CodeBlock from './global-components/CodeBlock';
import Alert from './global-components/Alert';
import Mermaid from './global-components/Mermaid';

const globalComponents = {
  // 覆蓋預設 HTML 元素
  h1: (props: any) => <h1 className="text-3xl font-bold mt-8 mb-4" {...props} />,
  h2: (props: any) => <h2 className="text-2xl font-semibold mt-6 mb-3" {...props} />,
  p: (props: any) => <p className="mb-4 leading-relaxed" {...props} />,
  
  // 自訂組件
  CodeBlock,
  Alert,
  Mermaid,
};

export default globalComponents;

頁面組合與最終呈現

1. 主要頁面組件

// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation';
import { getPostBySlug, getPostComponents } from '@/lib/mdx';
import BlogPostContentStatic from '@/components/BlogPostContent.static';
import MDXRenderer from './MDXRenderer';

interface Props {
  params: Promise<{ slug: string }>;
}

export default async function BlogPostPage({ params }: Props) {
  const resolvedParams = await params;
  const post = await getPostBySlug(resolvedParams.slug);

  if (!post) {
    notFound();
  }

  // 載入文章專用組件
  const components = await getPostComponents(resolvedParams.slug);

  return (
    <BlogPostContentStatic metadata={post.metadata}>
      <MDXRenderer 
        source={post.content} 
        components={components} 
      />
    </BlogPostContentStatic>
  );
}

2. 頁面布局組件

// components/BlogPostContent.static.tsx
import React from 'react';
import Image from 'next/image';
import { getAuthorAvatar } from '@/lib/authors';
import type { BlogMetadata } from '@/lib/types';

interface BlogPostContentProps {
  metadata: BlogMetadata;
  children: React.ReactNode;
}

export default function BlogPostContentStatic({ 
  metadata, 
  children 
}: BlogPostContentProps) {
  return (
    <article className="max-w-4xl mx-auto px-4 py-8">
      <header className="mb-8">
        {/* 分類標籤 */}
        {metadata.category && (
          <div className="mb-4">
            <span className="px-3 py-1 bg-blue-500 text-white text-sm rounded-full">
              {metadata.category}
            </span>
          </div>
        )}

        {/* 文章標題 */}
        <h1 className="text-4xl md:text-5xl font-bold mb-4 leading-tight">
          {metadata.title}
        </h1>

        {/* 封面圖片 */}
        {metadata.coverImage && (
          <div className="relative w-full h-72 mb-6 rounded-lg overflow-hidden transition-transform duration-300 hover:scale-102">
            <Image
              src={metadata.coverImage}
              alt={metadata.title}
              fill
              className="object-contain bg-white"
              priority
            />
          </div>
        )}

        {/* 作者與日期資訊 */}
        <div className="flex items-center gap-4 text-gray-600 mb-4">
          <div className="flex items-center gap-2">
            <div className="w-8 h-8 relative rounded-full overflow-hidden">
              <Image
                src={getAuthorAvatar(metadata.author)}
                alt={metadata.author}
                fill
                className="object-cover"
              />
            </div>
            <span className="font-medium">{metadata.author}</span>
          </div>
          <span></span>
          <time dateTime={metadata.date} className="text-sm">
            {new Date(metadata.date).toLocaleDateString('zh-TW', {
              year: 'numeric',
              month: 'long',
              day: 'numeric'
            })}
          </time>
          {metadata.updatedDate && (
            <>
              <span></span>
              <time dateTime={metadata.updatedDate} className="text-sm">
                更新於 {new Date(metadata.updatedDate).toLocaleDateString('zh-TW')}
              </time>
            </>
          )}
          <span></span>
          <span className="text-sm">{metadata.readingTime} 分鐘閱讀</span>
        </div>

        {/* 標籤 */}
        {metadata.tags && metadata.tags.length > 0 && (
          <div className="flex flex-wrap gap-2 mb-6">
            {metadata.tags.map((tag) => (
              <span
                key={tag}
                className="px-3 py-1 bg-blue-100 text-blue-800 text-sm rounded-full"
              >
                {tag}
              </span>
            ))}
          </div>
        )}

        {/* 精選文章標示 */}
        {metadata.featured && (
          <div className="mb-4">
            <span className="px-3 py-1 bg-yellow-100 text-yellow-800 text-sm rounded-full">
              ⭐ 精選文章
            </span>
          </div>
        )}
      </header>

      {/* 文章內容 */}
      <section className="prose prose-lg max-w-none">
        {children}
      </section>
    </article>
  );
}

完整數據流示例

步驟 1: 使用者訪問 /blog/nextjs-mdx-setup

步驟 2: Next.js 路由解析

URL: /blog/nextjs-mdx-setup
↓
匹配路由: app/blog/[slug]/page.tsx
↓  
params: { slug: 'nextjs-mdx-setup' }

步驟 3: 讀取文章資料

// 執行 getPostBySlug('nextjs-mdx-setup')
const post = await getPostBySlug('nextjs-mdx-setup');

// 步驟 3.1: 讀取 MDX 檔案
const fullPath = 'content/posts/nextjs-mdx-setup/content.mdx';
const rawContent = fs.readFileSync(fullPath, 'utf8');

// 步驟 3.2: 提取 ESM metadata
const { metadata, cleanContent } = await extractMetadataFromMDX(rawContent);
// 提取結果:
// metadata = {
//   title: 'Next.js + MDX 部落格建置指南',
//   date: '2024-05-21',
//   author: 'Ian Chou',
//   excerpt: 'Complete guide to setting up Next.js with MDX',
//   tags: ['Next.js', 'MDX', 'React'],
//   coverImage: '/images/covers/nextjs-mdx.jpg',
//   readingTime: 8,
//   featured: true
// }
// cleanContent = MDX 內容(已移除 metadata export)

// 步驟 3.3: 驗證 metadata
const validatedMetadata = validateMetadata(metadata);

步驟 4: 載入文章專用組件

const components = await getPostComponents('nextjs-mdx-setup');
// 從 content/posts/nextjs-mdx-setup/components/index.ts 取得:
// {
//   PerformanceChart: [Function],
//   CodeComparison: [Function],
//   InteractiveDemo: [Function]
// }

步驟 5: 渲染頁面結構

return (
  <BlogPostContentStatic metadata={post.metadata}>
    <MDXRenderer 
      source={post.content} 
      components={components} 
    />
  </BlogPostContentStatic>
);

步驟 6: MDX 處理與組件合併

// MDXRenderer 內部處理
const mergedComponents = {
  ...globalComponents,  // 全域組件 (h1, h2, CodeBlock, Alert, 等)
  ...components,        // 區域組件 (PerformanceChart, CodeComparison, 等)
};

// MDX 序列化(不處理 frontmatter,因為已經在伺服器端處理)
const serialized = await serialize(source, {
  parseFrontmatter: false
});

進階優化與最佳實踐

1. 效能優化策略

並行處理 metadata 提取

export async function getAllPostsMetadata(): Promise<BlogPost[]> {
  const slugs = getAllPostSlugs();
  
  // 並行處理所有文章的 metadata 提取
  const metadataPromises = slugs.map(async (slug) => {
    try {
      const post = await getPostBySlug(slug);
      return post ? { slug, ...post.metadata } : null;
    } catch (error) {
      console.error(`Error processing ${slug}:`, error);
      return null;
    }
  });

  const results = await Promise.allSettled(metadataPromises);
  
  return results
    .map(result => result.status === 'fulfilled' ? result.value : null)
    .filter((post): post is BlogPost => post !== null)
    .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
}

開發環境快取清除

// lib/mdx.ts
if (process.env.NODE_ENV === 'development') {
  // 開發環境下監聽檔案變化,清除快取
  const chokidar = require('chokidar');
  
  chokidar.watch('content/posts/**/*.mdx').on('change', () => {
    mdxCache.clear();
    console.log('MDX cache cleared due to file change');
  });
}

2. ESM Metadata 驗證與錯誤處理

嚴格的 metadata 驗證

// lib/metadata-validator.ts
export function validateBlogMetadata(metadata: any): BlogMetadata {
  const errors: string[] = [];

  // 必填欄位檢查
  const requiredFields = ['title', 'date', 'author', 'excerpt'];
  for (const field of requiredFields) {
    if (!metadata[field] || typeof metadata[field] !== 'string') {
      errors.push(`Missing or invalid required field: ${field}`);
    }
  }

  // 日期格式檢查
  if (metadata.date && isNaN(Date.parse(metadata.date))) {
    errors.push('Invalid date format');
  }

  // tags 格式檢查
  if (metadata.tags && !Array.isArray(metadata.tags)) {
    errors.push('Tags must be an array');
  }

  // readingTime 檢查
  if (metadata.readingTime && (typeof metadata.readingTime !== 'number' || metadata.readingTime <= 0)) {
    errors.push('readingTime must be a positive number');
  }

  // URL 格式檢查
  if (metadata.coverImage && !isValidUrl(metadata.coverImage)) {
    errors.push('Invalid coverImage URL');
  }

  if (metadata.canonicalUrl && !isValidUrl(metadata.canonicalUrl)) {
    errors.push('Invalid canonicalUrl');
  }

  if (errors.length > 0) {
    throw new Error(`Metadata validation failed: ${errors.join(', ')}`);
  }

  return metadata as BlogMetadata;
}

function isValidUrl(urlString: string): boolean {
  try {
    new URL(urlString);
    return true;
  } catch {
    return false;
  }
}

3. 建置時期檢查

文章完整性檢查

// scripts/validate-posts.ts
import { getAllPostSlugs, getPostBySlug } from '@/lib/mdx';

async function validateAllPosts() {
  const slugs = getAllPostSlugs();
  const errors: string[] = [];

  console.log(`🔍 Validating ${slugs.length} posts...`);

  for (const slug of slugs) {
    try {
      const post = await getPostBySlug(slug);
      
      if (!post) {
        errors.push(`❌ ${slug}: Unable to load post`);
        continue;
      }

      // 檢查必要檔案
      const mdxPath = `content/posts/${slug}/content.mdx`;
      if (!fs.existsSync(mdxPath)) {
        errors.push(`❌ ${slug}: Missing content.mdx file`);
      }

      // 檢查 metadata 完整性
      validateBlogMetadata(post.metadata);

      // 檢查封面圖片
      if (post.metadata.coverImage) {
        const imagePath = path.join(process.cwd(), 'public', post.metadata.coverImage);
        if (!fs.existsSync(imagePath)) {
          errors.push(`⚠️  ${slug}: Cover image not found: ${post.metadata.coverImage}`);
        }
      }

      console.log(`✅ ${slug}: Valid`);
      
    } catch (error) {
      errors.push(`❌ ${slug}: ${error instanceof Error ? error.message : 'Unknown error'}`);
    }
  }

  if (errors.length > 0) {
    console.error('\n📝 Validation Errors:');
    errors.forEach(error => console.error(`  ${error}`));
    process.exit(1);
  }

  console.log(`\n🎉 All ${slugs.length} posts validated successfully!`);
}

// 可以在 package.json 中加入: "validate-posts": "tsx scripts/validate-posts.ts"
if (require.main === module) {
  validateAllPosts().catch(console.error);
}

4. 開發工具整合

VS Code 程式碼片段

// .vscode/mdx.code-snippets
{
  "MDX Blog Post Template": {
    "prefix": "mdx-blog",
    "body": [
      "export const metadata = {",
      "  title: '$1',",
      "  date: '$CURRENT_YEAR-$CURRENT_MONTH-$CURRENT_DATE',",
      "  author: 'Ian Chou',",
      "  excerpt: '$2',",
      "  tags: [$3],",
      "  coverImage: '/images/posts/$4.webp',",
      "  readingTime: $5,",
      "  featured: false,",
      "  category: '$6'",
      "};",
      "",
      "# $1",
      "",
      "$7"
    ],
    "description": "Create a new MDX blog post with metadata"
  }
}

ESLint 規則

// .eslintrc.js
module.exports = {
  // ... 其他設定
  rules: {
    // 確保 MDX 檔案中的 metadata export 符合規範
    'import/no-anonymous-default-export': 'off',
  },
  overrides: [
    {
      files: ['content/posts/**/*.mdx'],
      rules: {
        // MDX 特定規則
        'react/jsx-no-undef': 'off',
      },
    },
  ],
};

結論

分散式 ESM metadata 管理相較於集中式管理提供了更好的開發體驗和維護性。主要優勢包括:

✅ 優勢總結

  1. 內容內聚性:metadata 與內容位於同一檔案,減少維護成本
  2. 版本控制友善:內容與 metadata 的修改在同一個 commit 中
  3. 型別安全:每個檔案都有完整的 TypeScript 支援
  4. 獨立性:每篇文章可以獨立管理,互不影響
  5. 擴展性:容易為特定文章新增專用的 metadata 欄位

🔧 最佳實踐

  1. 使用嚴格的 metadata 驗證確保資料品質
  2. 建立完整的錯誤處理機制提升系統穩定性
  3. 實作建置時期檢查及早發現問題
  4. 使用開發工具提升寫作效率
  5. 設計合理的快取策略優化效能

這套架構適合各種規模的技術部落格,從個人部落格到團隊知識庫都能有效應用。透過理解 ESM metadata 管理的核心概念,開發者可以建立出既現代化又易維護的內容管理系統,為讀者提供優質的閱讀體驗。

Thanks for reading!

Found this article helpful? Share it with others or explore more content.

More Articles
Published May 22, 202520 min read10 tags