ブログ記事のマークダウンコピー機能を実装
概要
ブログページにPC表示用の「共有」ボタンと「MD(マークダウンコピー)」ボタンを追加した。
アーキテクチャ
┌─────────────────────────────────────────────────────────────┐
│ ビルド時 │
│ content/*.md → nuxt.config.ts → dist/_raw/*.md │
│ (frontmatter除去) │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 実行時 │
│ 本番環境: fetch('/_raw/...md') → 静的ファイル │
│ 開発環境: $fetch('/api/markdown/...') → Node.js API │
└─────────────────────────────────────────────────────────────┘
実装ファイル構成
| ファイル | 役割 |
|---|---|
lib/markdown.ts | frontmatter除去ユーティリティ(共通) |
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レビュー対応
- コード重複解消:
removeFrontmatterをlib/markdown.tsに抽出 - エラーハンドリング強化: ビルドフックに適切なtry-catch追加
- テスト追加: 14のユニットテストを追加
- 型安全性向上: APIレスポンスのnull/undefinedチェック
- 可読性向上: ネストしたtry-catchをヘルパー関数に分離
URL構造変更後の404エラー(2026-01-01)
問題: frontmatterのpathフィールドでURL短縮後、MDボタンが404エラー
原因: _rawへのコピー処理がpathフィールドに追従していなかった
解決策: nuxt.config.tsのcopyMarkdownFiles関数を修正し、frontmatterのpathを読み取ってそのパスでコピーするように変更