• #設計
  • #URL
  • #SEO
  • #改善
開発完了

URL構造の改善計画

決定事項サマリー

項目決定内容
URL設計ルート直下 /{slug}
カテゴリ分け不要(全コンテンツ同一体系)
重複slug統合(同一トピックは1記事に)
リダイレクト実装_redirectsファイル(Cloudflare Pages標準)
ファイル構造現状維持(日付ディレクトリのまま、URLのみ変更)
移行方式一括移行

URL設計の根拠

  • ドメイン名が log.eurekapu.com のため、/blog//log/ プレフィックスは冗長
  • 「Blog」の語源は「Web Log」であり、log = blog
  • カテゴリ分け(prompts, tutorials等)は現状不要(該当ディレクトリなし)

移行対象(対応後の数値)

項目対応前対応後備考
公開対象ファイル320318sample-article 2件削除
path 明示済み126126path変換のみ
path 未設定194192path追加が必要
重複slug(公開)4種類10件0リネーム・削除で解消
予約語衝突1件0slug変更で解消
内部リンク更新56件028ファイルを更新

現状分析(2026-01-01時点)

コンテンツ統計

項目数値備考
総ファイル数338.md ファイル
frontmatter + title あり320公開対象
frontmatter or title なし18非公開メモ(drafts/ へ移動)
path 明示済み126明示的にpathを指定
path 未設定194ファイルパスから自動生成

対応方針(確定): 非公開メモ(18件)は content/ 外の drafts/ ディレクトリへ移動する。

  • 移動後は Nuxt Content の対象外となり、公開されない
  • 衝突判定の対象からも除外される
  • 最もシンプルで実装不要

path未設定ファイルの扱い

Nuxt Content v3の動作: path が未設定でもファイルパスから自動生成され、公開される

content/2025-10-18/article.md  →  /2025-10-18/article(公開中)

したがって、移行対象は path 明示済みの126件ではなく、公開対象の318件(320件から sample-article 2件削除後)。

重複slugの現状(対応前)

日付を外した場合に衝突するslugが存在:

slug重複ファイル対応方針備考
claude-code-best-practices2025-11-01, 2025-12-3011月版をリネーム公開
development-todo2025-12-16, 12-18, 12-21, 12-24日付サフィックス追加公開
handover-note2025-12-07, 2025-12-08日付サフィックス追加公開
sample-article2025-11-09, 2025-11-16削除(テスト用)公開
README2025-11-28, 2025-12-03drafts/ へ移動非公開(titleなし)

集計:

  • 公開ファイル内の重複: 4種類10ファイル
  • 非公開含む全体: 5種類12ファイル

対応後: 重複ゼロ(リネーム3種類8件 + 削除1種類2件)

算出方法: 公開対象ファイル(frontmatter+titleあり)のファイル名を小文字正規化して集計。

予約語との衝突

予約語チェックで 1件の衝突 を検出:

ファイル衝突する予約語
content/2025-11-21/index.mdindex(Vueページ)

→ slug を index 以外に変更が必要。

予約語(衝突禁止slug)

以下のslugはシステムで使用済みのため、コンテンツのslugとして使用不可:

カテゴリ予約語(正規化後)
Vueページabout, animation-demo, animation-demo-1, animation-demo-2, blog, coding-standards, excel-viewer, financial-data, financial-quiz, flowchart, index, japanese-writing-quiz, search, share
静的アセットapi, audio, data, excel, images, js, favicon
システム_nuxt, sitemap, robots, rss

slug正規化ルール(予約語・コンテンツ両方に適用):

  • 大文字小文字: 区別しないREADMEreadme は衝突とみなす)
  • 比較時: 小文字に変換して判定
  • 拡張子: 除去して比較sitemap.xmlsitemapfavicon.icofavicon
  • 記号: ファイル名に使用可能な文字のみ(-, _ は許可)

対策: 移行スクリプトで衝突検知を実装し、該当slugがあればエラーを出力する。


現状の問題

1. 日付ベースURLの課題

現在のURL構造: /2025-12-26/article-slug

問題点詳細
SEO古い日付のURLは「古いコンテンツ」と認識されやすい
更新時の矛盾内容を更新しても /2025-09-01/... のまま
長期運用10年運用で約3,650個の日付ディレクトリが生まれる
意味の重複publishedAt / updatedAt があるのにURLにも日付

2. ファイル管理の煩雑さ

  • 古いコンテンツが日付ディレクトリで散らばっている
  • 見返すことが少ない古い記事がディレクトリ一覧を圧迫

調査結果

Nuxt Contentのpath生成の仕組み

重要: Nuxt Content v3は2つの方法でpathを決定する。

ケース動作
frontmatterにpathありそのpathを使用
frontmatterにpathなしファイルパスから自動生成
# pathなしの場合(現状の多くのファイル)
content/2025-11-27/article.md → /2025-11-27/article

# pathありの場合
content/2025-11-27/article.md
---
path: "/blog/article"  ← これが優先される
---

この挙動が有効になる条件

前提条件: コレクションのスキーマで path フィールドを明示的に定義していること。

// content.config.ts
export default defineContentConfig({
  collections: {
    pages: defineCollection({
      type: "page",
      source: "**/*.{md,mdx,mdc}",
      schema: z.object({
        // ...
        path: z.string().optional(),  // ← これが必須
        // ...
      })
    })
  }
});

Nuxt Content v3のドキュメントより:

For page-type collections, path is automatically generated. You can override any of these fields by defining them in the collection's schema.

つまり、このプロジェクトでは content.config.tspath をスキーマに定義しているため、frontmatterの path が自動生成パスをオーバーライドする。これはNuxt Content v3の標準機能であり、カスタム実装ではない。

参考: Nuxt Content - Collection Types

移行時の注意

URL構造を変更する場合、以下の対応が必要:

  1. 新規コンテンツ: frontmatterに明示的にpathを指定
  2. 既存コンテンツ:
    • pathフィールドを追加(新URL)
    • または、ファイル移動+旧URLリダイレクト

主要テックブログのURL設計

サービスURL形式日付
Zenn/articles/{slug}なし
Qiita/items/{id}なし
Medium/{slug}-{hash}なし
dev.to/{user}/{slug}なし

日付なしが主流。日付はメタデータで管理。

現在の実装仕様

// [...slug].vue
queryCollection("pages").path(docPath.value).first()

重要な発見: ルーティングは frontmatter の path フィールド を参照している。

つまり:

  • ファイル構造とURLは独立
  • path フィールドを変更するだけでURLを変更可能
  • ファイル移動なしでURL体系を刷新できる

移行後のイメージ

frontmatter例

# ファイル: content/2025-12-31/ai-deep-funnel.md(ファイル位置は変更なし)
---
title: "AI Deep Funnel"
path: "/ai-deep-funnel"  # ← 日付なしのルート直下
publishedAt: "2025-12-31"
updatedAt: "2026-01-01"
---

ファイル構造

content/
├── 2025-10-17/     # 日付ディレクトリは維持(変更なし)
│   └── article.md
├── 2025-12-31/
│   └── ai-deep-funnel.md
└── 2026-01-01/
    └── url-structure-improvement.md

→ ファイル構造は現状維持。URLのみ path フィールドで制御。

URL設計(確定)

変更前変更後
/2025-12-31/ai-deep-funnel/ai-deep-funnel
/2025-10-17/kindle-publishing-howto/kindle-publishing-howto
/about/about(変更なし)

全コンテンツをルート直下 /{slug} で統一。カテゴリプレフィックスは使用しない。

移行時の注意点

  1. 既存の外部リンク: 旧URLへのリンクが切れないよう _redirects で301リダイレクト必須
  2. Google Search Console: 移行完了後にサイトマップ再送信
  3. 内部リンク: コンテンツ内の内部リンクも新URLに更新が必要
  4. OGP/canonical: 新URLで正しく動作することを確認
  5. 画像の相対パス: 絶対パスへの変更が必要(下記参照)

画像パスの問題(2026-01-01 追記)

問題: path フィールドでURLを変更すると、マークダウン内の相対画像パスが壊れる。

# 変更前(path未設定)
ファイル: content/2025-12-31/article.md
URL: /2025-12-31/article
画像: ![img](image.png) → /2025-12-31/image.png ✅

# 変更後(pathでURL変更)
ファイル: content/2025-12-31/article.md
URL: /article(pathで上書き)
画像: ![img](image.png) → /image.png ❌(404)

原因: ブラウザは現在のURLを基準に相対パスを解決する。URLが /article の場合、image.png/image.png に解決されるが、実際の画像は /2025-12-31/image.png に配置されている。

解決策: 画像パスを絶対パスに変更する。

# 修正前(相対パス)
![alt text](image.png)

# 修正後(絶対パス)
![alt text](/2025-12-31/image.png)

対象ファイル: path フィールドを持ち、かつ相対画像パスを使用しているファイル(24件)。

検出コマンド:

cd apps/web/content
for f in $(grep -r "^path:" --include="*.md" -l); do
  if grep -q '!\[.*\]([^/h][^)]*\.\(png\|jpg\|jpeg\|gif\|webp\))' "$f" 2>/dev/null; then
    echo "$f"
  fi
done

代替案: nuxt-content-assets モジュールを導入すれば、相対パスを自動で絶対パスに変換できる。ただし追加の依存関係が増える。

参考: nuxt-content-assets · Nuxt Modules


実装手順

Step 1: 重複slugの解消

以下の5種類12ファイルを個別対応する:

slug対象ファイル対応方針
claude-code-best-practices2025-11-01, 2025-12-3011月版を claude-code-best-practices-anthropic にリネーム(Anthropicブログ翻訳)、12月版はそのまま
development-todo2025-12-16, 12-18, 12-21, 12-24各ファイルに日付サフィックス追加: development-todo-2025-12-16
handover-note2025-12-07, 2025-12-08各ファイルに日付サフィックス追加: handover-note-2025-12-07
README2025-11-28, 2025-12-03非公開(プロジェクト固有README)→ drafts/ へ移動
sample-article2025-11-09, 2025-11-16削除(テスト用ファイル)

Step 2: frontmatter一括更新

318ファイルの path フィールドを更新するスクリプトを作成・実行:

// スクリプトの処理内容
// 1. path明示済み(126件): 日付部分を除去
//    旧: path: "/2025-12-31/ai-deep-funnel"
//    新: path: "/ai-deep-funnel"
//
// 2. path未設定(192件): ファイルパスからpathを生成して追加
//    ファイル: content/2025-10-18/article.md
//    追加: path: "/article"
//
// 3. 予約語チェック: 衝突があればエラー出力
// 4. 重複チェック: 同一slugがあればエラー出力

Step 3: _redirectsファイル生成

public/_redirects に旧URL→新URLのマッピングを出力:

/2025-12-31/ai-deep-funnel /ai-deep-funnel 301
/2025-10-17/kindle-publishing-howto /kindle-publishing-howto 301
# ... 全318件

リダイレクト削除基準:

  • 最短: Google Search Consoleで旧URLのインプレッションがゼロになってから1ヶ月後
  • 推奨: 移行後6ヶ月経過、かつCloudflare Analyticsで旧URLへのアクセスがゼロ
  • 安全策: テキストファイルでコストゼロのため永続的に残す

Step 4: 画像の相対パス修正

path フィールドを持つファイル内の相対画像パスを絶対パスに変換:

// 変換パターン:
//   ![alt](image.png) → ![alt](/2025-12-31/image.png)
//   ![alt](./image.png) → ![alt](/2025-12-31/image.png)
//
// ファイルのディレクトリパスから絶対パスを生成

対象: 24ファイル(pathあり + 相対画像パス使用)

Step 5: 内部リンク更新

コンテンツ内の内部リンクを新URLに更新:

// 対象: 全マークダウンファイル内のリンク
// 変換パターン:
//   絶対パス:   [記事](/ai-deep-funnel) → [記事](/ai-deep-funnel)
//   相対パス:   [記事](/ai-deep-funnel) → [記事](/ai-deep-funnel)
//   完全URL:    [記事](/ai-deep-funnel) → [記事](/ai-deep-funnel)

検出コマンド(全パターン対応):

# 絶対パス・相対パス
grep -rE "\(/?\d{4}-\d{2}-\d{2}/" content/

# 完全URL
grep -rE "https?://log\.eurekapu\.com/\d{4}-\d{2}-\d{2}/" content/

Step 6: 動作確認

  • 全新URLでコンテンツが表示されること
  • 全旧URLが新URLにリダイレクトされること
  • 内部リンクが正しく動作すること
  • OGP/canonicalが新URLを指していること

テストコード(TDD形式)

テストランナー: Vitest(pnpm test tests/url-migration.test.ts

パス解決: __dirname 基準(実行場所に依存しない)

テストは2つのスイートで構成:

1. 現状確認テスト(移行前にPASS)

移行作業の前提条件を検証。これらが失敗した場合、ファイル構成が想定と異なる。

describe('現状確認(移行前)', () => {
  it('総ファイル数が338件')
  it('公開対象が320件')
  it('公開ファイル内の重複slugが4種類存在する')  // READMEは非公開
  it('index.mdが予約語と衝突している')
  it('日付パターンを含む内部リンクが56件存在する')
})

2. 移行完了条件テスト(TDD: 今FAIL → 移行後PASS)

it.fails を使用: アサーションが失敗することを期待。

describe('移行完了条件(現在FAIL → 移行後PASS)', () => {
  it.fails('全slugがユニーク')
  it.fails('予約語と衝突するslugがない')
  it.fails('全ファイルにtitleがある')
  it.fails('日付パターンを含む内部リンクがない')
  it.fails('全公開ファイルにpathフィールドがある')
  it.fails('全pathが /{slug} 形式(階層なし)')  // ^/[^/]+$ で検証
})

it.fails の挙動:

  • 現在: アサーションが失敗する → it.fails として正常(テストスイートはPASS)
  • 移行後: アサーションが成功する → it.fails のままだとテストスイートがFAIL

移行完了時: it.failsit に変更してテスト実行。全PASSで完了確認。


移行完了条件

移行完了とみなす条件:

  • 全対象記事(318件)のpathが新URL体系に更新済み
  • 旧URL→新URLの301リダイレクトが全件動作確認済み
  • 内部リンクが全て新URLに更新済み(日付パターンの残存ゼロ)
  • 画像の相対パスが全て絶対パスに変換済み(24ファイル)
  • 404エラーがゼロ(内部リンク、外部リンク、画像全て)
  • Google Search Consoleでエラーゼロ
  • サイトマップが新URLで生成されている
  • canonical URLが新URLを指している
  • OGP画像URLが新URLで動作

次のアクション

ステータス: 設計確定済み、実装待ち

  • Step 0: テストコード実装(tests/url-migration.test.ts
  • Step 0.5: 非公開メモ(18件)を drafts/ へ移動
  • Step 1: 重複slug統合(5種類12ファイル)+ 予約語衝突解消(1件: index.md)
  • Step 2: path一括更新スクリプト作成・実行(318件)
  • Step 3: _redirectsファイル生成(318件)
  • Step 4: 画像の相対パス修正(24ファイル)
  • Step 5: 内部リンク更新
  • Step 6: 動作確認・404チェック
  • Google Search Console でサイトマップ再送信

備考: この文書自身(url-structure-improvement.md)も移行対象。移行後は /url-structure-improvement でアクセス可能になる。

参考

  • Nuxt Content v3 では path フィールドでURLを完全制御可能
  • ファイル構造とURLは独立しているため、ファイル移動なしでURL体系を変更可能
  • _redirects ファイルはCloudflare Pages標準機能(ドキュメント