• #nuxt
  • #markdown
  • #images
  • #cloudflare
  • #memo
開発nuxt-content-docsメモ

Nuxt Contentでのマークダウン画像管理

概要

このドキュメントでは、Nuxt Contentのマークダウンファイルで画像を扱う方法を説明します。以下の3つの環境で正しく動作します。

  1. VS Codeのマークダウンプレビュー
  2. ローカル開発サーバー(pnpm dev
  3. Cloudflare Pagesへのデプロイ

ディレクトリ構成

apps/web/
├── content/
│   └── 2025-10-17/
│       ├── article.md
│       └── image.png
├── server/
│   └── middleware/
│       └── content-images.ts
└── nuxt.config.ts

マークダウンの記法

マークダウンファイルでは相対パスを使用します:

![alt text](image.png)

画像はマークダウンファイルと同じディレクトリに配置してください。これで3つの環境すべてで動作します。

実装の詳細

1. VS Codeプレビュー

VS Codeのマークダウンプレビューは、同じディレクトリからの相対パスを自動的に解決します。

2. ローカル開発(pnpm dev

ファイル: apps/web/server/middleware/content-images.ts

import { defineEventHandler, setResponseHeaders, sendStream } from 'h3'
import { createReadStream, existsSync, statSync } from 'node:fs'
import { join } from 'node:path'

export default defineEventHandler(async (event) => {
  // リクエストURLを取得
  const url = event.node.req.url || ''

  // 日付ディレクトリ内の画像ファイルにマッチ(例:/2025-10-17/image.png)
  const match = url.match(/^\/(\d{4}-\d{2}-\d{2})\/([^/]+\.(png|jpg|jpeg|gif|webp|svg))$/i)
  if (!match) return

  // マッチした情報を取得(日付ディレクトリ、ファイル名、拡張子)
  const [, dateDir, filename, ext] = match
  // contentディレクトリ内のファイルパスを構築
  const contentPath = join(process.cwd(), 'content', dateDir, filename)

  // ファイルが存在しない場合は処理を中断
  if (!existsSync(contentPath)) return
  // ファイルでない場合(ディレクトリなど)は処理を中断
  if (!statSync(contentPath).isFile()) return

  // 拡張子とMIMEタイプのマッピング
  const mimeTypes: Record<string, string> = {
    png: 'image/png',
    jpg: 'image/jpeg',
    jpeg: 'image/jpeg',
    gif: 'image/gif',
    webp: 'image/webp',
    svg: 'image/svg+xml'
  }

  // レスポンスヘッダーを設定(Content-Typeとキャッシュ制御)
  setResponseHeaders(event, {
    'Content-Type': mimeTypes[ext.toLowerCase()] || 'application/octet-stream',
    'Cache-Control': 'public, max-age=31536000'
  })

  // ファイルをストリームとして返す
  return sendStream(event, createReadStream(contentPath))
})

このミドルウェアは画像リクエストをインターセプトし、contentディレクトリから直接配信します。

3. Cloudflare Pagesへのデプロイ

ファイル: apps/web/nuxt.config.ts

以下のインポートを追加:

import { resolve } from "node:path";
import { copyFile, mkdir, readdir } from "node:fs/promises";
import { existsSync } from "node:fs";
import { join } from "node:path";

Nuxt configに以下のフックを追加:

export default defineNuxtConfig({
  // ... other config
  hooks: {
    // Nitroのビルド時、publicアセットが生成された後に実行されるフック
    'nitro:build:public-assets': async (nitro) => {
      // コピー元:contentディレクトリ
      const contentDir = resolve(__dirname, 'content');
      // コピー先:.output/publicディレクトリ
      const publicDir = resolve(nitro.options.output.publicDir);

      // ディレクトリを再帰的に走査して画像をコピーする関数
      async function copyImages(dir: string, relativePath = '') {
        // ディレクトリ内のファイル・フォルダ一覧を取得
        const entries = await readdir(dir, { withFileTypes: true });

        for (const entry of entries) {
          const sourcePath = join(dir, entry.name);
          const currentRelativePath = join(relativePath, entry.name);

          if (entry.isDirectory()) {
            // ディレクトリの場合は再帰的に処理
            await copyImages(sourcePath, currentRelativePath);
          } else if (/\.(png|jpg|jpeg|gif|webp|svg)$/i.test(entry.name)) {
            // 画像ファイルの場合はコピー
            const targetPath = join(publicDir, currentRelativePath);
            const targetDir = join(targetPath, '..');

            // コピー先ディレクトリが存在しない場合は作成
            if (!existsSync(targetDir)) {
              await mkdir(targetDir, { recursive: true });
            }

            // ファイルをコピー
            await copyFile(sourcePath, targetPath);
            console.log(`Copied image: ${currentRelativePath}`);
          }
        }
      }

      // contentディレクトリから画像のコピーを開始
      await copyImages(contentDir);
      console.log('✓ Content images copied to public directory');
    }
  }
})

このフックはビルドプロセス中に実行され、contentから.output/publicにすべての画像をコピーし、ディレクトリ構造を維持します。

4. 画像のスタイリング

ファイル: apps/web/app/components/DocPage.vue

画像がコンテンツ幅に収まるようCSSを追加:

.doc__body :deep(img) {
  max-width: 100%;
  height: auto;
  display: block;
  margin: 1.5rem 0;
}

ワークフロー

  1. content/YYYY-MM-DD/にマークダウンファイルを作成
  2. VS Codeに画像を貼り付け - 同じディレクトリに自動保存される
  3. 相対パスで画像を参照:![description](image.png)
  4. VS Codeプレビューですぐに画像が表示される
  5. pnpm devを実行 - server middlewareで画像が表示される
  6. pnpm run buildを実行 - 画像がpublicディレクトリにコピーされる
  7. Cloudflare Pagesにデプロイ - publicディレクトリから画像が配信される

メリット

  • 単一の真実の源:画像はcontentディレクトリに配置
  • 手動コピー不要:ビルドプロセスがデプロイを処理
  • どこでも動作:VS Code、ローカル開発、本番環境
  • シンプルなマークダウン:標準的な相対パスを使用
  • パス変更不要:すべての環境で同じ記法が動作

デプロイコマンド

# プロジェクトをビルド
pnpm run build

# Cloudflare Pagesにデプロイ
pnpm exec wrangler pages deploy apps/web/.output/public --project-name=mdx-playground-web