• #完了
  • #Vue
  • #Nuxt
  • #機能追加
  • #Cloudflare Pages

ブログ記事のマークダウンコピー機能を実装

概要

ブログページにPC表示用の「共有」ボタンと「MD(マークダウンコピー)」ボタンを追加した。

アーキテクチャ

┌─────────────────────────────────────────────────────────────┐
│                    ビルド時                                  │
│  content/*.md → nuxt.config.ts → dist/_raw/*.md            │
│                  (frontmatter除去)                          │
└─────────────────────────────────────────────────────────────┘
                              ↓
┌─────────────────────────────────────────────────────────────┐
│                    実行時                                    │
│  本番環境: fetch('/_raw/...md') → 静的ファイル              │
│  開発環境: $fetch('/api/markdown/...') → Node.js API        │
└─────────────────────────────────────────────────────────────┘

実装ファイル構成

ファイル役割
lib/markdown.tsfrontmatter除去ユーティリティ(共通)
nuxt.config.tsビルド時にマークダウンを静的ファイルとしてコピー
server/api/markdown/[...path].get.ts開発環境用API(本番では使用不可)
app/components/DocPage.vueコピーボタンUI・ロジック
tests/markdown.test.tsユニットテスト(14テスト)

詳細実装

1. frontmatter除去ユーティリティ(lib/markdown.ts)

export function removeFrontmatter(content: string): string {
  // frontmatterは --- で囲まれた部分(YAMLフォーマット)
  // - 開始タグ: コンテンツの先頭で "---" + 改行
  // - 終了タグ: 改行の後に "---"(行頭に来る)
  const frontmatterRegex = /^---\r?\n[\s\S]*?\r?\n---(?:\r?\n)?/;
  return content.replace(frontmatterRegex, '').trim();
}

テスト済みエッジケース:

  • CRLF/LF両対応
  • frontmatter内に---を含む値
  • 閉じタグ後に改行なしで本文開始
  • frontmatterがないコンテンツ
  • 閉じタグが行頭にない場合(マッチしない)

2. ビルド時の静的ファイル生成(nuxt.config.ts)

hooks: {
  'nitro:build:public-assets': async (nitro) => {
    const contentDir = resolve(__dirname, 'content');
    const publicDir = resolve(nitro.options.output.publicDir);
    const rawContentDir = join(publicDir, '_raw');

    async function copyMarkdownFiles(dir: string, relativePath = '') {
      const entries = await readdir(dir, { withFileTypes: true });

      for (const entry of entries) {
        if (entry.isDirectory()) {
          await copyMarkdownFiles(sourcePath, currentRelativePath);
        } else if (/\.md$/i.test(entry.name)) {
          const content = await readFile(sourcePath, 'utf-8');
          const cleanedContent = removeFrontmatter(content);
          await writeFile(targetPath, cleanedContent, 'utf-8');
        }
      }
    }

    await copyMarkdownFiles(contentDir);
  }
}

エラーハンドリング方針:

  • ディレクトリ読み取り失敗: ビルド中断(致命的エラー)
  • 個別ファイル処理失敗: ログして続行
  • マークダウンコピー全体失敗: ビルド中断(コピー機能が動作しなくなるため)
  • 画像コピー失敗: ログして続行(画像がなくても記事は読める)

3. マークダウン取得ヘルパー(DocPage.vue)

const fetchMarkdownContent = async (path: string): Promise<string | null> => {
  // 試行するパスのリスト(優先順)
  const pathsToTry = [
    `/_raw${path}.md`,      // 静的ファイル(本番環境)
    `/_raw${path}/index.md` // index.md形式
  ];

  // 静的ファイルを順番に試す
  for (const mdPath of pathsToTry) {
    try {
      const response = await fetch(mdPath);
      if (response.ok) {
        return await response.text();
      }
    } catch {
      // 次のパスを試す
    }
  }

  // 開発環境用: APIにフォールバック
  try {
    const apiResponse = await $fetch<{ content?: string }>(`/api/markdown${path}`);
    if (apiResponse?.content) {
      return apiResponse.content;
    }
  } catch (error) {
    console.error('API fallback failed:', error);
  }

  return null;
};

4. クリップボードコピー(DocPage.vue)

const copyToClipboard = async (text: string): Promise<boolean> => {
  // まずClipboard APIを試す(HTTPS環境で動作)
  if (navigator.clipboard?.writeText) {
    try {
      await navigator.clipboard.writeText(text);
      return true;
    } catch {
      // フォールバックに進む
    }
  }

  // フォールバック: textarea要素を使用(HTTP環境でも動作)
  const textarea = document.createElement('textarea');
  textarea.value = text;
  textarea.style.position = 'fixed';
  textarea.style.left = '-9999px';
  document.body.appendChild(textarea);
  textarea.focus();
  textarea.select();

  try {
    const success = document.execCommand('copy');
    document.body.removeChild(textarea);
    return success;
  } catch {
    document.body.removeChild(textarea);
    return false;
  }
};

ビルド後の構造

dist/
├── _raw/                    ← マークダウン(frontmatter除去済み)
│   ├── 2025-12-31/
│   │   ├── markdown-copy-feature.md
│   │   └── ...
│   └── ...
├── 2025-12-31/              ← プリレンダリングされたHTML
│   ├── markdown-copy-feature.html
│   └── ...
└── ...

動作確認

  • PC表示: ヘッダー右上に「共有」「MD」ボタンが表示される
  • MDボタンクリック: frontmatterを除いた本文がクリップボードにコピーされる
  • トースト通知: 「マークダウンをコピーしました」と表示
  • モバイル: ボタンは非表示(MobileFloatingButtonsを使用)

バグ修正履歴

本番環境(Cloudflare Pages)で動作しない問題

問題: ローカル開発環境では正常に動作するが本番環境では「コピーに失敗しました」と表示

原因: server/api/markdown/[...path].get.ts でNode.jsの fs/promises モジュールを使用。Cloudflare WorkersはNode.jsの fs モジュールをサポートしていない

解決策: ビルド時に静的ファイルとしてコピーする方式に変更

PRレビュー対応

  1. コード重複解消: removeFrontmatterlib/markdown.tsに抽出
  2. エラーハンドリング強化: ビルドフックに適切なtry-catch追加
  3. テスト追加: 14のユニットテストを追加
  4. 型安全性向上: APIレスポンスのnull/undefinedチェック
  5. 可読性向上: ネストしたtry-catchをヘルパー関数に分離

URL構造変更後の404エラー(2026-01-01)

問題: frontmatterのpathフィールドでURL短縮後、MDボタンが404エラー

原因: _rawへのコピー処理がpathフィールドに追従していなかった

解決策: nuxt.config.tscopyMarkdownFiles関数を修正し、frontmatterのpathを読み取ってそのパスでコピーするように変更