• #nuxt
  • #content
  • #pages
  • #architecture
  • #requirements
  • #sqlite
未分類

Nuxt Content + Pages ハイブリッドアーキテクチャ:プロジェクト要件定義書

目次

  1. プロジェクト概要
  2. アーキテクチャの核心概念
  3. Nuxt Content + SQLite の仕様
  4. Nuxt Pages の自動ルーティング仕様
  5. ハイブリッド戦略:使い分けのベストプラクティス
  6. 新規プロジェクト要件定義
  7. 環境構築手順
  8. デプロイメント戦略

プロジェクト概要

このアーキテクチャが解決する課題

従来のWebサイト構築では以下の選択を迫られていました:

  • 静的サイトジェネレーター(Hugo, Jekyll等)
    • ✅ シンプルなMarkdown記事管理
    • ❌ インタラクティブなコンテンツが困難
    • ❌ 複雑なUIコンポーネントの実装に限界
  • フルスタックフレームワーク(Next.js, Nuxt等)
    • ✅ リッチなインタラクティブコンテンツ
    • ❌ 全てのページをVueコンポーネント化する必要がある
    • ❌ シンプルな記事でもコード量が増える

本プロジェクトのアプローチ

Nuxt Content(Markdown + SQLite)とNuxt Pages(Vue)の長所を組み合わせる

📁 プロジェクト構成
├── apps/web/content/        ← Markdown記事(自動的にSQLiteへ)
│   ├── 2025-11-29/
│   │   └── article.md
│   └── features/
│       └── guide.md
│
└── apps/web/app/pages/      ← Vueコンポーネント(インタラクティブ)
    ├── blog/
    │   └── interactive-demo.vue
    └── excel-viewer.vue

使い分けの原則:

  • シンプルな記事content/ にMarkdownで書く
  • インタラクティブなコンテンツpages/ にVueで書く

アーキテクチャの核心概念

1. Nuxt Content + SQLite による自動コンテンツ管理

Nuxt Content v3は、Markdownファイルを自動的にSQLiteデータベースに変換します。

データフロー

┌────────────────────────────────────────────────────────────────┐
│ 1. コンテンツ作成                                                │
│    apps/web/content/2025-11-29/article.md                      │
│    ---                                                         │
│    title: 記事タイトル                                           │
│    publishedAt: 2025-11-29                                     │
│    tags: [nuxt, vue]                                           │
│    ---                                                         │
│    # 記事内容                                                    │
└──────────────┬─────────────────────────────────────────────────┘
               │
               ↓ ① ファイル監視(開発時: Vite/Chokidar)
               │
┌──────────────┴─────────────────────────────────────────────────┐
│ 2. @nuxt/content ビルドプラグイン                                │
│    - フロントマター解析(YAML → オブジェクト)                     │
│    - Markdown → AST変換(remark/rehype)                        │
│    - content.config.ts のスキーマで検証                          │
└──────────────┬─────────────────────────────────────────────────┘
               │
               ↓ ② AST を JSON に変換
               │
┌──────────────┴─────────────────────────────────────────────────┐
│ 3. SQLite データベースへ自動保存                                  │
│    .data/content/contents.sqlite                               │
│                                                                │
│    CREATE TABLE _content_pages (                               │
│      id TEXT PRIMARY KEY,                                      │
│      title VARCHAR,                                            │
│      body TEXT,           -- AST (minimark 形式)               │
│      publishedAt DATE,                                         │
│      tags TEXT,           -- JSON 配列                         │
│      path VARCHAR,                                             │
│      __hash__ TEXT        -- 整合性チェック用                    │
│    );                                                          │
│                                                                │
│    INSERT INTO _content_pages VALUES (...);                    │
└──────────────┬─────────────────────────────────────────────────┘
               │
               ↓ ③ クエリAPI経由で取得
               │
┌──────────────┴─────────────────────────────────────────────────┐
│ 4. ページでのコンテンツ取得                                        │
│    const doc = await queryCollection("pages")                 │
│                       .path("/2025-11-29/article")             │
│                       .first()                                 │
│                                                                │
│    → SELECT * FROM _content_pages WHERE path = '/...' LIMIT 1 │
└────────────────────────────────────────────────────────────────┘

重要なポイント

  1. 手動でSQLを書く必要は一切ない
    • Markdownファイルを保存するだけでSQLiteへ自動変換
    • スキーマは content.config.ts で定義
  2. 開発時はリアルタイム更新
    • ファイル保存 → SQLite更新 → ブラウザHMR
    • .data/content/contents.sqlite が常に最新状態
  3. 本番環境では静的ダンプとして配信
    • ビルド時: database.compressed.mjs を生成
    • ランタイム: SQLiteファイルではなくJavaScriptとして配信
    • エッジ環境(Cloudflare Workers等)でも動作

2. Nuxt Pages の自動ルーティング

Nuxtは pages/ ディレクトリ内のファイル構造を自動的にルートに変換します。

ファイル → ルートのマッピング

apps/web/app/pages/
├── index.vue                    → /
├── about.vue                    → /about
├── blog/
│   ├── index.vue                → /blog
│   └── interactive-demo.vue     → /blog/interactive-demo
├── excel-viewer.vue             → /excel-viewer
└── [...slug].vue                → /任意のパス(catch-all)

Catch-allルートの役割

[...slug].vueNuxt Contentと連携するための特別なルートです:

<!-- apps/web/app/pages/[...slug].vue -->
<script setup lang="ts">
// URLパスに基づいてコンテンツを取得
const route = useRoute()
const docPath = computed(() => `/${route.params.slug?.join('/') || ''}`)

const { data: doc } = await useAsyncData(
  `content-${docPath.value}`,
  () => queryCollection("pages")
       .path(docPath.value)
       .first()
)
</script>

<template>
  <ContentRenderer v-if="doc" :value="doc" />
</template>

動作例:

  • /2025-11-29/article にアクセス ↓
  • [...slug].vue がキャッチ ↓
  • SQLiteから path = '/2025-11-29/article' のコンテンツを取得 ↓
  • <ContentRenderer> でレンダリング

Nuxt Content + SQLite の仕様

設定ファイル

1. nuxt.config.ts

export default defineNuxtConfig({
  modules: ["@nuxt/content"],

  content: {
    documentDriven: true,  // 自動的にドキュメントベースのルーティング

    database: {
      type: "sqlite"       // SQLiteバックエンドを使用
    },

    experimental: {
      sqliteConnector: "native"  // Node.js 22.5+ のネイティブSQLite
    },

    markdown: {
      mdc: true,           // Markdown Components を有効化
      remarkPlugins: {
        "remark-gfm": {},  // GitHub Flavored Markdown
        "remark-math": {}  // 数式サポート
      }
    },

    sources: {
      content: {
        driver: "fs",
        base: resolve(__dirname, "content")  // コンテンツのベースディレクトリ
      }
    }
  }
})

2. content.config.ts(スキーマ定義)

import { defineCollection, defineContentConfig } from "@nuxt/content"
import { z } from "zod"

export default defineContentConfig({
  collections: {
    pages: defineCollection({
      type: "page",
      source: "**/*.{md,mdx,mdc}",  // 対象ファイル
      schema: z.object({
        title: z.string().optional(),
        description: z.string().optional(),
        path: z.string().optional(),
        tags: z.array(z.string()).optional(),
        publishedAt: z.coerce.date().optional(),  // 文字列→日付に自動変換
        updatedAt: z.coerce.date().optional()
      })
    })
  }
})

このスキーマから自動生成されるSQLテーブル:

CREATE TABLE _content_pages (
  id TEXT PRIMARY KEY,
  title VARCHAR,
  description VARCHAR,
  path VARCHAR,
  tags TEXT,              -- JSON配列として保存
  publishedAt DATE,
  updatedAt DATE,
  body TEXT,              -- Markdown AST (minimark形式)
  __hash__ TEXT UNIQUE
);

クエリAPI

// 1. 単一ドキュメント取得
const doc = await queryCollection("pages")
  .path("/2025-11-29/article")
  .first()

// 2. タグでフィルタリング
const nuxtArticles = await queryCollection("pages")
  .where({ tags: { $contains: "nuxt" } })
  .all()

// 3. 日付でソート
const latestArticles = await queryCollection("pages")
  .sort({ publishedAt: -1 })
  .limit(10)
  .all()

// 4. ディレクトリ内の全コンテンツ
const todayArticles = await queryCollection("pages")
  .where({ path: { $startsWith: "/2025-11-29" } })
  .all()

内部で実行されるSQL(例):

-- .first()
SELECT * FROM _content_pages WHERE path = '/2025-11-29/article' LIMIT 1;

-- タグフィルタ
SELECT * FROM _content_pages WHERE tags LIKE '%nuxt%';

-- ソート
SELECT * FROM _content_pages ORDER BY publishedAt DESC LIMIT 10;

画像の取り扱い

Markdownと同じディレクトリに画像を配置できます:

content/
└── 2025-11-29/
    ├── article.md
    ├── image1.png
    └── diagram.svg

Markdown内での参照:

![説明](./image1.png)
![図表](./diagram.svg)

自動コピーの仕組み(nuxt.config.ts):

export default defineNuxtConfig({
  hooks: {
    'nitro:build:public-assets': async (nitro) => {
      const contentDir = resolve(__dirname, 'content')
      const publicDir = resolve(nitro.options.output.publicDir)

      // content/ 内の画像を public/ にコピー
      async function copyImages(dir: string, relativePath = '') {
        // PNG, JPG, SVG, WEBP, DRAWIO 等を再帰的にコピー
      }

      await copyImages(contentDir)
    }
  }
})

ランタイムでの配信(server/middleware/content-images.ts):

export default defineEventHandler((event) => {
  const url = event.node.req.url || ''

  // /2025-11-29/image.png のようなパターンをマッチ
  const match = url.match(/^\/(\d{4}-\d{2}-\d{2})\/([^/]+\.(png|jpg|jpeg|gif|webp|svg))$/i)

  if (match) {
    const [, dateDir, filename] = match
    const contentPath = join(process.cwd(), 'content', dateDir, filename)

    // content/ から直接配信
    return sendStream(event, createReadStream(contentPath))
  }
})

Nuxt Pages の自動ルーティング仕様

ファイルベースルーティングのルール

基本パターン

ファイルパス生成されるルート説明
pages/index.vue/ホームページ
pages/about.vue/about固定ページ
pages/blog/index.vue/blogディレクトリのindex
pages/blog/article.vue/blog/articleネストされたページ

動的ルート

ファイルパス生成されるルートマッチ例
pages/[id].vue/:id/123, /abc
pages/blog/[slug].vue/blog/:slug/blog/my-article
pages/[...slug].vue/:slug(.*)/any/path/here

Catch-allルートの優先順位

Nuxtのルーティングは具体的なルートを優先します:

優先順位(高 → 低):
1. 固定パス:     /blog/index.vue          → /blog
2. 固定パス:     /excel-viewer.vue        → /excel-viewer
3. 動的パス:     /blog/[slug].vue         → /blog/:slug
4. Catch-all:    /[...slug].vue           → /:slug(.*)

実例:

/blog にアクセスした場合:

  • pages/blog/index.vue が存在 → これを使用 ✅
  • 存在しない場合 → pages/[...slug].vue にフォールバック

/2025-11-29/article にアクセスした場合:

  • 固定ルートに一致しない
  • pages/[...slug].vue が処理 ✅
  • このルート内でNuxt Contentからコンテンツを取得

Vueページの基本構造

シンプルなページ

<!-- pages/about.vue -->
<template>
  <div>
    <h1>About Us</h1>
    <p>会社情報</p>
  </div>
</template>

インタラクティブなページ

<!-- pages/excel-viewer.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import HyperFormula from 'hyperformula'

const data = ref([
  ['商品', '単価', '数量', '合計'],
  ['リンゴ', 100, 5, '=B2*C2'],
  ['バナナ', 80, 3, '=B3*C3']
])

const hf = HyperFormula.buildFromArray(data.value)

const getCellValue = (row: number, col: number) => {
  return hf.getCellValue({ sheet: 0, row, col })
}
</script>

<template>
  <div>
    <h1>Excel Viewer</h1>
    <table>
      <tr v-for="(row, i) in data" :key="i">
        <td v-for="(cell, j) in row" :key="j">
          {{ getCellValue(i, j) }}
        </td>
      </tr>
    </table>
  </div>
</template>

ハイブリッド戦略:使い分けのベストプラクティス

判断基準マトリクス

コンテンツの特性推奨方法理由
テキスト中心の記事content/ Markdown執筆が簡単、フロントマターで管理
技術ドキュメントcontent/ Markdownコードブロック、リスト、表が書きやすい
ブログポストcontent/ Markdown日付ベースのディレクトリ管理が自然
データビジュアライゼーションpages/ VueChart.js, D3.js等のライブラリが必要
フォーム・入力UIpages/ Vueリアクティブなバリデーション
Excelビューアーpages/ VueHyperFormula等の複雑なライブラリ
SPAライクな画面遷移pages/ Vueクライアントサイドルーティング
動的なコンポーネントpages/ Vueユーザー操作による状態変化

具体例:ブログシステム

シンプルな記事(Markdown)

content/2025-11-29/simple-tutorial.md
---
title: Nuxt 入門ガイド
description: Nuxtの基本的な使い方を解説
tags: [nuxt, vue, tutorial]
publishedAt: 2025-11-29
---

# Nuxt 入門ガイド

Nuxtは Vue.js のメタフレームワークです。

## インストール

\`\`\`bash
npx nuxi init my-app
\`\`\`

## 特徴

- ファイルベースルーティング
- SSR/SSGのサポート
- 豊富なモジュールエコシステム

URL: https://example.com/2025-11-29/simple-tutorial

インタラクティブなデモ(Vue)

pages/blog/chart-demo.vue
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { Chart, registerables } from 'chart.js'

Chart.register(...registerables)

const chartRef = ref<HTMLCanvasElement | null>(null)

onMounted(() => {
  if (chartRef.value) {
    new Chart(chartRef.value, {
      type: 'bar',
      data: {
        labels: ['Jan', 'Feb', 'Mar'],
        datasets: [{
          label: 'Sales',
          data: [12, 19, 3]
        }]
      }
    })
  }
})
</script>

<template>
  <div>
    <h1>インタラクティブグラフデモ</h1>
    <canvas ref="chartRef"></canvas>
  </div>
</template>

URL: https://example.com/blog/chart-demo

ハイブリッド統合パターン

パターン1: ブログIndexページ(Vue)+ 記事(Markdown)

<!-- pages/blog/index.vue -->
<script setup lang="ts">
// Markdown記事をクエリAPIで取得
const articles = await queryCollection("pages")
  .where({ tags: { $contains: "blog" } })
  .sort({ publishedAt: -1 })
  .all()
</script>

<template>
  <div>
    <h1>Blog</h1>
    <div v-for="article in articles" :key="article.id">
      <NuxtLink :to="article.path">
        <h2>{{ article.title }}</h2>
        <p>{{ article.description }}</p>
      </NuxtLink>
    </div>
  </div>
</template>

パターン2: Markdownに埋め込むVueコンポーネント

<!-- content/2025-11-29/article.md -->
---
title: データビジュアライゼーション入門
---

# データビジュアライゼーション入門

この記事では Chart.js を使ったグラフの作り方を学びます。

::ChartComponent
---
type: bar
data:
  labels: [Jan, Feb, Mar]
  values: [10, 20, 30]
---
::

上記のグラフは Chart.js で描画されています。
<!-- components/content/ChartComponent.vue -->
<script setup lang="ts">
// MDCコンポーネントとして使用
</script>

新規プロジェクト要件定義

この要件定義は、別のセッションで生成AIにこのドキュメントを渡し、ゼロから同じ環境を構築するためのものです。

プロジェクト名

sample-project(または任意の名前)

技術スタック

カテゴリ技術バージョン用途
フレームワークNuxt4.1.2+Vue.jsベースのメタフレームワーク
ランタイムNode.js22.6.0+SQLiteネイティブサポート
パッケージマネージャーpnpm10.10.0モノレポ管理
コンテンツ管理@nuxt/content3.7.1+Markdown → SQLite
データベースSQLite-ローカル・エッジ環境両対応
デプロイ先Cloudflare Pages/Workers-エッジネットワーク配信(Pages経由でWorkers実行)
スキーマバリデーションZod3.25.8+フロントマタースキーマ
Markdownパーサーremark-gfm, remark-math-GFM・数式サポート

ディレクトリ構造

sample-project/
├── apps/
│   └── web/                        # Nuxtアプリケーション
│       ├── app/
│       │   ├── components/         # Vueコンポーネント
│       │   │   ├── ArticleTable.vue
│       │   │   ├── Breadcrumb.vue
│       │   │   └── TableOfContents.vue
│       │   └── pages/              # ルーティング(Vue)
│       │       ├── index.vue       # トップページ
│       │       ├── blog/           # ブログページ
│       │       │   └── index.vue
│       │       ├── excel-viewer.vue
│       │       └── [...slug].vue   # Catch-all(Nuxt Content連携)
│       ├── content/                # コンテンツ(Markdown)
│       │   ├── 2025-11-29/
│       │   │   └── article.md
│       │   └── features/
│       │       └── guide.md
│       ├── server/
│       │   └── middleware/
│       │       └── content-images.ts  # 画像配信ミドルウェア
│       ├── nuxt.config.ts          # Nuxt設定
│       ├── content.config.ts       # コンテンツスキーマ
│       ├── wrangler.toml           # Cloudflare設定
│       └── package.json
├── .data/
│   └── content/
│       └── contents.sqlite         # 開発時のSQLiteファイル(自動生成)
├── .nuxt/
│   └── content/
│       ├── database.compressed.mjs # ビルド時の圧縮SQLiteダンプ
│       └── sql_dump.txt            # SQLダンプ(デバッグ用)
└── package.json                    # ルートpackage.json

機能要件

1. コンテンツ管理システム

FR-1: Markdownファイルの自動データベース化

  • content/ ディレクトリに .md ファイルを配置すると自動的にSQLiteに保存
  • フロントマターのパース(YAML → オブジェクト)
  • 日付、タグ、タイトル、説明等のメタデータ管理
  • BOMの自動除去(UTF-8 without BOM強制)

FR-2: リアルタイム更新(開発環境)

  • Markdownファイル保存時に即座にSQLite更新
  • HMR(Hot Module Replacement)による自動リロード
  • .data/content/contents.sqlite の継続的監視

FR-3: クエリAPI

  • パス指定でのコンテンツ取得
  • タグ、日付によるフィルタリング
  • ソート、ページネーション
  • 全文検索(オプション)

2. ルーティングシステム

FR-4: ファイルベース自動ルーティング

  • pages/ ディレクトリの構造を自動的にルートに変換
  • 動的ルート([id].vue, [...slug].vue)のサポート
  • 固定ルート優先、Catch-allルートへのフォールバック

FR-5: Nuxt Content統合

  • [...slug].vue でMarkdownコンテンツを自動レンダリング
  • パンくずリストの自動生成
  • 目次(TOC)の自動生成

3. 画像管理

FR-6: コンテンツと同じディレクトリでの画像管理

  • content/2025-11-29/image.png のような配置
  • ビルド時に public/ へ自動コピー
  • ランタイムでの動的配信(開発時)

FR-7: 画像の最適化とキャッシング

  • 本番環境: 長期キャッシュ(max-age=31536000)
  • 開発環境: キャッシュ無効化(.drawioファイル等)

4. デプロイメント

FR-8: 静的サイト生成(SSG)

  • nuxt generate による完全静的化
  • SQLiteダンプのJavaScript化(database.compressed.mjs
  • Cloudflare Pages/Workersへのデプロイ
  • Git連携による自動デプロイ(GitHub push → 自動ビルド → 自動デプロイ)

FR-9: エッジ環境対応

  • Cloudflare Workers上で実行
  • WASM SQLiteによるブラウザ内クエリ実行
  • プレビューデプロイ(Pull Request単位)

非機能要件

NFR-1: パフォーマンス

  • 初回ページロード: 2秒以内(LCP)
  • ページ遷移: 500ms以内
  • SQLiteクエリ: 100ms以内

NFR-2: SEO

  • 全ページでSSR/SSG対応
  • meta タグの自動生成(title, description, OGP)
  • sitemap.xml の自動生成

NFR-3: 開発体験

  • ファイル保存から反映まで1秒以内(HMR)
  • TypeScript完全サポート
  • ESLint/Prettierによるコード品質管理

NFR-4: 保守性

  • モジュラーなコンポーネント設計
  • 設定ファイルの一元管理
  • ドキュメントの充実

制約事項

CON-1: 環境

  • Node.js 22.6.0以上必須(ネイティブSQLiteサポート)
  • pnpm 10.10.0以上推奨

CON-2: ファイルフォーマット

  • コンテンツファイル: UTF-8 without BOM
  • フロントマター: YAML形式(YAMLスペック準拠)
  • 画像: PNG, JPG, SVG, WEBP, DRAWIO

CON-3: デプロイ環境

  • Cloudflare Pages/Workers(Nitro preset: cloudflare-pages
  • Git連携による自動デプロイ、またはGitHub Actions + Direct Upload
  • D1データベース非使用(SQLiteは静的ダンプとして配信)

環境構築手順

この手順は、まったく新規のプロジェクトとしてゼロから構築するためのステップバイステップガイドです。

前提条件

# Node.jsバージョン確認
node -v  # v22.6.0以上

# pnpmインストール
npm install -g [email protected]

# pnpmバージョン確認
pnpm -v  # 10.10.0

ステップ1: プロジェクト初期化

# プロジェクトディレクトリ作成
mkdir sample-project
cd sample-project

# pnpm workspace初期化
pnpm init

# package.json編集
cat > package.json << 'EOF'
{
  "name": "sample-project",
  "version": "1.0.0",
  "private": true,
  "packageManager": "[email protected]",
  "scripts": {
    "dev": "pnpm --filter nuxt-app dev",
    "build": "pnpm --filter nuxt-app build",
    "preview": "pnpm --filter nuxt-app preview"
  }
}
EOF

# pnpm workspace設定
cat > pnpm-workspace.yaml << 'EOF'
packages:
  - apps/*
EOF

ステップ2: Nuxtアプリケーション作成

# appsディレクトリ作成
mkdir -p apps

# Nuxt初期化
cd apps
npx nuxi@latest init web
cd web

# パッケージマネージャーをpnpmに設定
echo "[email protected]" >> .npmrc

ステップ3: 必要なパッケージインストール

# Nuxt Content + 依存関係
pnpm add @nuxt/content@^3.7.1

# Markdown拡張
pnpm add remark-gfm@^4.0.1 remark-math@^6.0.0

# スキーマバリデーション
pnpm add zod@^3.25.8

# オプション: インタラクティブコンポーネント用
pnpm add chart.js@^4.5.1 hyperformula@^3.1.0 mermaid@^11.12.1

# 開発依存
pnpm add -D wrangler@4 @libsql/client@^0.15.15

ステップ4: ディレクトリ構造作成

# アプリケーションディレクトリ
mkdir -p app/components
mkdir -p app/pages/blog
mkdir -p app/composables

# コンテンツディレクトリ
mkdir -p content/2025-11-29
mkdir -p content/features

# サーバーディレクトリ
mkdir -p server/middleware

ステップ5: 設定ファイル作成

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"

export default defineNuxtConfig({
  compatibilityDate: "2025-10-02",

  app: {
    head: {
      htmlAttrs: {
        lang: 'ja'
      },
      charset: 'utf-8',
      viewport: 'width=device-width, initial-scale=1',
      title: 'log.eurekapu.com',
      meta: [
        { name: 'description', content: '日々の学びや発見を記録していく個人ログサイト。技術メモ、学習記録、気づきなどを綴っています。' },
        { property: 'og:site_name', content: 'log.eurekapu.com' },
        { property: 'og:image', content: 'https://log.eurekapu.com/favicon.svg' }
      ],
      link: [
        { rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' }
      ]
    }
  },

  modules: ["@nuxt/content"],

  nitro: {
    preset: "cloudflare-pages"
  },

  content: {
    documentDriven: true,
    database: {
      type: "sqlite"
    },
    experimental: {
      sqliteConnector: "native"
    },
    build: {
      markdown: {
        highlight: {
          theme: {
            default: "vitesse-light",
            dark: "vitesse-light"
          },
          langs: [
            "javascript", "typescript", "vue", "css", "scss", "html",
            "json", "yaml", "markdown", "bash", "shell", "sql",
            "python", "go", "rust"
          ]
        }
      }
    },
    markdown: {
      mdc: true,
      remarkPlugins: {
        "remark-gfm": {},
        "remark-math": {}
      },
      anchorLinks: false
    },
    sources: {
      content: {
        driver: "fs",
        base: resolve(__dirname, "content")
      }
    }
  },

  runtimeConfig: {
    // Server-side only (private)
    resendApiKey: process.env.RESEND_API_KEY || '',
    stockSummaryEmailFrom: process.env.STOCK_SUMMARY_EMAIL_FROM || '[email protected]',
    stockSummaryEmailTo: process.env.STOCK_SUMMARY_EMAIL_TO || '',
    // Public (client + server)
    public: {
      apiBase: process.env.NUXT_PUBLIC_API_BASE ?? "http://localhost:4000/api"
    }
  },

  hooks: {
    'nitro:build:public-assets': async (nitro) => {
      const contentDir = resolve(__dirname, 'content')
      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|drawio)$/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 file: ${currentRelativePath}`)
          }
        }
      }

      await copyImages(contentDir)
      console.log('✓ Content images copied to public directory')
    }
  }
})

content.config.ts

import { defineCollection, defineContentConfig } from "@nuxt/content"
import { z } from "zod"

export default defineContentConfig({
  collections: {
    pages: defineCollection({
      type: "page",
      source: "**/*.{md,mdx,mdc}",
      schema: z.object({
        title: z.string().optional(),
        description: z.string().optional(),
        path: z.string().optional(),
        tags: z.array(z.string()).optional(),
        publishedAt: z.coerce.date().optional(),
        updatedAt: z.coerce.date().optional()
      })
    })
  }
})

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) => {
  const url = event.node.req.url || ''
  const pathname = url.split('?')[0]

  const match = pathname.match(/^\/(\d{4}-\d{2}-\d{2})\/([^/]+\.(png|jpg|jpeg|gif|webp|svg|drawio))$/i)
  if (!match) return

  const [, dateDir, filename, ext] = match
  const contentPath = join(process.cwd(), 'content', dateDir, filename)

  if (!existsSync(contentPath)) return
  if (!statSync(contentPath).isFile()) return

  const mimeTypes: Record<string, string> = {
    png: 'image/png',
    jpg: 'image/jpeg',
    jpeg: 'image/jpeg',
    gif: 'image/gif',
    webp: 'image/webp',
    svg: 'image/svg+xml',
    drawio: 'application/xml'
  }

  const isDev = process.env.NODE_ENV === 'development'
  const cacheControl = (ext === 'drawio' && isDev)
    ? 'no-cache, no-store, must-revalidate'
    : 'public, max-age=31536000'

  setResponseHeaders(event, {
    'Content-Type': mimeTypes[ext.toLowerCase()] || 'application/octet-stream',
    'Cache-Control': cacheControl,
    'X-Content-Type-Options': 'nosniff'
  })

  return sendStream(event, createReadStream(contentPath))
})

ステップ6: コンポーネント作成

app/components/Breadcrumb.vue

<script setup lang="ts">
import { computed } from "vue";

const route = useRoute();

interface BreadcrumbItem {
  label: string;
  path: string;
}

const breadcrumbs = computed<BreadcrumbItem[]>(() => {
  const path = route.path;
  const segments = path.split('/').filter(Boolean);

  const items: BreadcrumbItem[] = [
    { label: 'Home', path: '/' }
  ];

  let currentPath = '';
  segments.forEach((segment) => {
    currentPath += `/${segment}`;
    items.push({
      label: decodeURIComponent(segment),
      path: currentPath
    });
  });

  return items;
});
</script>

<template>
  <nav class="breadcrumb" aria-label="パンくずリスト">
    <ol class="breadcrumb-list">
      <li v-for="(item, index) in breadcrumbs" :key="item.path" class="breadcrumb-item">
        <NuxtLink
          v-if="index < breadcrumbs.length - 1"
          :to="item.path"
          class="breadcrumb-link"
        >
          {{ item.label }}
        </NuxtLink>
        <span v-else class="breadcrumb-current" aria-current="page">
          {{ item.label }}
        </span>
        <span v-if="index < breadcrumbs.length - 1" class="breadcrumb-separator" aria-hidden="true">/</span>
      </li>
    </ol>
  </nav>
</template>

<style scoped>
.breadcrumb {
  margin-bottom: 1.5rem;
}

.breadcrumb-list {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.5rem;
  list-style: none;
  margin: 0;
  padding: 0;
  font-size: 0.9rem;
}

.breadcrumb-item {
  display: flex;
  align-items: center;
  gap: 0.5rem;
}

.breadcrumb-link {
  color: #2563eb;
  text-decoration: none;
  transition: color 0.2s;
}

.breadcrumb-link:hover {
  color: #1d4ed8;
  text-decoration: underline;
}

.breadcrumb-current {
  color: #6b7280;
  font-weight: 500;
}

.breadcrumb-separator {
  color: #9ca3af;
  user-select: none;
}
</style>

app/components/ArticleTable.vue

<template>
  <div>
    <table class="article-table">
      <thead>
        <tr>
          <th>タイトル</th>
          <th>作成日</th>
          <th>タグ</th>
        </tr>
      </thead>
      <tbody>
        <tr v-for="article in paginatedArticles" :key="article.path">
          <td>
            <NuxtLink :to="article.path" class="article-link">
              {{ article.title || article.path }}
            </NuxtLink>
          </td>
          <td class="date">
            {{ formatDate(article.publishedAt || article.date) }}
          </td>
          <td>
            <div class="tags">
              <span v-for="tag in article.tags" :key="tag" class="tag">
                {{ tag }}
              </span>
            </div>
          </td>
        </tr>
      </tbody>
    </table>

    <!-- ページネーション -->
    <div v-if="totalPages > 1" class="pagination">
      <button
        @click="currentPage--"
        :disabled="currentPage === 1"
        class="pagination-btn"
      >
        前へ
      </button>

      <span class="page-info">
        {{ currentPage }} / {{ totalPages }}
      </span>

      <button
        @click="currentPage++"
        :disabled="currentPage === totalPages"
        class="pagination-btn"
      >
        次へ
      </button>
    </div>
  </div>
</template>

<script setup lang="ts">
import { computed, ref } from 'vue';

const props = defineProps({
  articles: {
    type: Array,
    required: true,
    default: () => []
  },
  itemsPerPage: {
    type: Number,
    default: 20
  }
});

const currentPage = ref(1);

const totalPages = computed(() => {
  if (!props.articles) return 1;
  return Math.ceil(props.articles.length / props.itemsPerPage);
});

const paginatedArticles = computed(() => {
  const allArticles = props.articles;
  if (!allArticles || allArticles.length === 0) return [];

  // 日付順にソート(新しい順)
  const sorted = [...allArticles].sort((a, b) => {
    const dateA = a.publishedAt || a.date || '';
    const dateB = b.publishedAt || b.date || '';
    return dateB.localeCompare(dateA);
  });

  const start = (currentPage.value - 1) * props.itemsPerPage;
  const end = start + props.itemsPerPage;
  return sorted.slice(start, end);
});

const formatDate = (date: string | Date | undefined): string => {
  if (!date) return '-';
  const d = new Date(date);
  if (isNaN(d.getTime())) return '-';
  return d.toLocaleDateString('ja-JP', {
    year: 'numeric',
    month: '2-digit',
    day: '2-digit'
  });
};
</script>

<style scoped>
.article-table {
  width: 100%;
  border-collapse: collapse;
  margin-top: 1rem;
}

.article-table th {
  background: #f3f4f6;
  padding: 0.75rem;
  text-align: left;
  font-weight: 600;
  color: #374151;
  border-bottom: 2px solid #e5e7eb;
}

.article-table td {
  padding: 0.75rem;
  border-bottom: 1px solid #e5e7eb;
}

.article-link {
  color: #3b82f6;
  text-decoration: none;
  font-weight: 500;
}

.article-link:hover {
  text-decoration: underline;
}

.date {
  color: #6b7280;
  white-space: nowrap;
  width: 120px;
}

.tags {
  display: flex;
  gap: 0.25rem;
  flex-wrap: wrap;
}

.tag {
  padding: 0.125rem 0.5rem;
  background: #eff6ff;
  color: #3b82f6;
  border-radius: 0.25rem;
  font-size: 0.75rem;
}

.pagination {
  display: flex;
  justify-content: center;
  align-items: center;
  gap: 1rem;
  margin-top: 2rem;
  padding: 1rem;
}

.pagination-btn {
  padding: 0.5rem 1rem;
  background: #3b82f6;
  color: white;
  border: none;
  border-radius: 0.25rem;
  cursor: pointer;
  font-weight: 500;
}

.pagination-btn:hover:not(:disabled) {
  background: #2563eb;
}

.pagination-btn:disabled {
  background: #d1d5db;
  cursor: not-allowed;
}

.page-info {
  color: #6b7280;
  font-weight: 500;
}
</style>

ステップ7: ページ作成

app/pages/[...slug].vue(Catch-all)

<script setup lang="ts">
import { computed } from "vue";
import { queryCollection } from "#imports";

const route = useRoute();
const docPath = computed(() => {
  const normalized = route.path.replace(/\/$/, "");
  return normalized === "" ? "/" : normalized;
});

// 単一ドキュメントを取得
const { data: doc } = await useAsyncData(
  `content-${docPath.value}`,
  () => queryCollection("pages").path(docPath.value).first(),
  { watch: [docPath] }
);

// ディレクトリ内の記事一覧を取得
const { data: allPages } = await useAsyncData(
  'all-pages-for-dir',
  () => queryCollection("pages").all()
);

const articles = computed(() => {
  if (!allPages.value) return [];
  return allPages.value.filter(page => {
    const pagePath = page.path || page._path;
    if (!pagePath) return false;
    return pagePath.startsWith(docPath.value + "/") && pagePath !== docPath.value;
  });
});

const isDirectoryListing = computed(() => !doc.value && articles.value.length > 0);

// SEO設定
useSeoMeta({
  title: () => {
    if (doc.value?.title) return doc.value.title;
    if (isDirectoryListing.value) return `${docPath.value} の記事一覧`;
    return 'ページが見つかりません';
  },
  description: () => {
    if (doc.value?.description) return doc.value.description;
    if (isDirectoryListing.value) return `${docPath.value} 配下の記事一覧を表示しています。`;
    return '指定されたページが存在しません。';
  },
});
</script>

<template>
  <main>
    <Breadcrumb />
    <template v-if="isDirectoryListing">
      <section class="directory-listing">
        <h1>{{ docPath }} の記事一覧</h1>
        <ArticleTable :articles="articles" :items-per-page="20" />
      </section>
    </template>
    <template v-else-if="doc">
      <!-- DocPageコンポーネントがある場合はそれを使用、なければContentRenderer -->
      <ContentRenderer :value="doc" />
    </template>
    <template v-else>
      <section class="state">
        <h1>Not Found</h1>
        <p>指定されたドキュメントが存在しません。</p>
        <NuxtLink to="/">トップページに戻る</NuxtLink>
      </section>
    </template>
  </main>
</template>

<style scoped>
main {
  max-width: 1300px;
  margin: 0 auto;
  padding: 2rem 0.75rem;
  line-height: 1.7;
}

@media (min-width: 768px) {
  main {
    padding: 3rem 1.5rem;
  }
}

.state {
  text-align: center;
  color: #555;
}

.directory-listing {
  max-width: 900px;
  margin: 0 auto;
}
</style>

ステップ7: サンプルコンテンツ作成

content/2025-11-29/first-article.md

---
title: はじめての記事
description: Nuxt Content + Pagesハイブリッドシステムのテスト記事
tags:
  - nuxt
  - content
  - markdown
publishedAt: 2025-11-29
updatedAt: 2025-11-29
---

# はじめての記事

これはNuxt Contentで管理されるMarkdown記事です。

## 特徴

- SQLiteに自動保存
- リアルタイム更新
- クエリAPIでの取得

## コードブロック例

\`\`\`typescript
const greeting = "Hello, Nuxt Content!"
console.log(greeting)
\`\`\`

app/pages/blog/chart-demo.vue(インタラクティブページの例)

<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { Chart, registerables } from 'chart.js'

Chart.register(...registerables)

const chartRef = ref<HTMLCanvasElement | null>(null)

onMounted(() => {
  if (chartRef.value) {
    new Chart(chartRef.value, {
      type: 'bar',
      data: {
        labels: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'],
        datasets: [{
          label: 'Sales',
          data: [12, 19, 3, 5, 2, 3],
          backgroundColor: 'rgba(54, 162, 235, 0.2)',
          borderColor: 'rgba(54, 162, 235, 1)',
          borderWidth: 1
        }]
      },
      options: {
        responsive: true,
        scales: {
          y: {
            beginAtZero: true
          }
        }
      }
    })
  }
})

useSeoMeta({
  title: 'インタラクティブチャートデモ',
  description: 'Chart.jsを使用したVueコンポーネントのデモページ'
})
</script>

<template>
  <main>
    <Breadcrumb />
    <div class="chart-container">
      <h1>インタラクティブチャートデモ</h1>
      <p>これは <code>pages/</code> ディレクトリに配置されたVueコンポーネントです。</p>
      <canvas ref="chartRef"></canvas>
    </div>
  </main>
</template>

<style scoped>
main {
  max-width: 800px;
  margin: 0 auto;
  padding: 2rem;
}
.chart-container {
  margin-top: 2rem;
}
</style>

ステップ8: 開発サーバー起動

# apps/web ディレクトリで実行
pnpm dev

# ブラウザで確認
# http://localhost:3000
# http://localhost:3000/2025-11-29/first-article
# http://localhost:3000/blog/chart-demo

ステップ9: Cloudflareデプロイ設定(オプション)

wrangler.toml

name = "my-site"
compatibility_date = "2025-11-29"

[site]
bucket = ".output/public"

デプロイコマンド

# ビルド
pnpm build

# Cloudflare Pagesにデプロイ
pnpm exec wrangler pages deploy .output/public --project-name=my-site

デプロイメント戦略

Cloudflare Pages/Workers の関係(2025年最新情報)

重要な変更点:

2025年現在、CloudflareはWorkersを中心とした統合プラットフォームへの移行を進めています:

  • Cloudflare Pages: 静的サイトのホスティングとGit統合に特化
  • Cloudflare Workers: サーバーレス実行環境(Pages Functionsの基盤)
  • 統合: PagesからデプロイされたアプリケーションはWorkers上で実行される

Nuxt + Nitroの場合:

  • Nitro preset cloudflare-pages を使用
  • ビルド成果物は静的ファイル + Workers
  • デプロイ先は「Cloudflare Pages」だが、実行環境は「Workers」

デプロイ方法の選択

方法1: GitHub連携(推奨)

メリット:

  • GitHubにpushするだけで自動ビルド・デプロイ
  • プレビューデプロイ(PR単位)の自動生成
  • ビルド履歴の管理
  • セットアップが簡単

デメリット:

  • Cloudflareのビルド環境に依存
  • ビルド時間の制限あり(無料プラン: 3,000分/月)

方法2: GitHub Actions + Direct Upload

メリット:

  • ビルドプロセスの完全制御
  • カスタムビルドステップの追加が容易
  • GitHub Actionsの無料枠を利用

デメリット:

  • 初期設定がやや複雑
  • API tokenの管理が必要

方法1: GitHub連携の設定(推奨・最も簡単)

ステップ1: GitHubリポジトリの準備

# プロジェクトをGitで初期化
cd sample-project
git init
git add .
git commit -m "Initial commit"

# GitHubにリポジトリを作成してpush
gh repo create sample-project --private --source=. --push

# または手動でGitHubにリポジトリを作成してからpush
git remote add origin https://github.com/your-username/sample-project.git
git branch -M main
git push -u origin main

ステップ2: Cloudflare Pagesでプロジェクト作成

重要:最初の設定が肝心です。後から変更が難しい項目もあります。

  1. Cloudflare Dashboardにログイン
  2. Workers & Pages > Create application > Pages > Connect to Git
  3. GitHubアカウントを接続
    • 「Connect GitHub」をクリック
    • Cloudflareに必要な権限を許可
    • リポジトリを選択(個人 or Organization)
  4. ビルド設定(重要!)
    設定項目説明
    Project namesample-projectデプロイ後のURL: sample-project.pages.dev
    Production branchmain または master本番デプロイのブランチ
    Build commandpnpm install && pnpm buildビルドコマンド
    Build output directory.output/publicNitroのビルド成果物
    Root directoryapps/webモノレポの場合は必須
    Node version22.6.0環境変数で設定
  5. 環境変数の設定
    Environment variables セクションで以下を追加:
    NODE_VERSION = 22.6.0
    PNPM_VERSION = 10.10.0
    
  6. Save and Deploy

ステップ3: 自動デプロイの確認

# コードを変更してpush
git add .
git commit -m "Update content"
git push

# Cloudflare Dashboardで自動ビルドが開始される
# ビルドログ、デプロイ状況をリアルタイムで確認可能

プレビューデプロイ(PR単位)

GitHubでPull Requestを作成すると、自動的にプレビューURLが生成されます:

# 例: PR #5 のプレビューURL
https://5.sample-project.pages.dev

PR内のコメントにプレビューURLとビルドステータスが自動投稿されます。

ブランチ制御

特定のブランチのみ自動デプロイしたい場合:

  1. Cloudflare Dashboard > プロジェクト > Settings > Builds & deployments
  2. Branch deployments で設定:
    • Production branch: main のみ
    • Preview branches: All branches または Custom branches

方法2: GitHub Actions + Direct Upload

より細かい制御が必要な場合はGitHub Actionsを使用します。

ステップ1: Cloudflare API Tokenの取得

  1. Cloudflare Dashboard > My Profile > API Tokens
  2. Create Token > Edit Cloudflare Workers テンプレートを選択
  3. Permissions:
    • Account > Cloudflare Pages > Edit
  4. Tokenをコピー(再表示不可)

ステップ2: GitHub Secretsに追加

# GitHub CLI使用
gh secret set CLOUDFLARE_API_TOKEN

# または手動で設定
# Settings > Secrets and variables > Actions > New repository secret

追加するシークレット:

  • CLOUDFLARE_API_TOKEN: APIトークン
  • CLOUDFLARE_ACCOUNT_ID: アカウントID(Dashboard右上に表示)

ステップ3: GitHub Actionsワークフロー作成

.github/workflows/deploy.yml:

name: Deploy to Cloudflare Pages

on:
  push:
    branches:
      - main
  pull_request:
    branches:
      - main

jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      deployments: write
    name: Deploy to Cloudflare Pages
    steps:
      - uses: actions/checkout@v4

      # pnpmセットアップ
      - uses: pnpm/action-setup@v2
        with:
          version: 10.10.0

      # Node.jsセットアップ
      - uses: actions/setup-node@v4
        with:
          node-version: '22.6.0'
          cache: 'pnpm'

      # 依存関係インストール
      - name: Install dependencies
        run: pnpm install

      # ビルド
      - name: Build
        run: pnpm build
        working-directory: apps/web

      # Cloudflare Pagesにデプロイ(wrangler-action使用)
      - name: Deploy to Cloudflare Pages
        uses: cloudflare/wrangler-action@v3
        with:
          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
          command: pages deploy apps/web/.output/public --project-name=sample-project

重要: cloudflare/pages-action非推奨です。代わりに cloudflare/wrangler-action@v3 を使用してください。

ステップ4: プッシュしてデプロイ

git add .
git commit -m "Add GitHub Actions workflow"
git push

# GitHub Actionsが自動実行される
# Actions タブで進行状況を確認

トラブルシューティング

問題1: 「途中からGit連携できない」場合

原因: Cloudflare Pagesプロジェクトを「Direct Upload」で作成してしまった

解決策:

  1. 新しいプロジェクトを作成する(推奨)
    • Cloudflare Dashboard > 既存プロジェクトを削除
    • 「Connect to Git」で新規作成
  2. GitHub Actionsに移行する
    • 上記「方法2」の手順に従う
    • 既存プロジェクトをそのまま使用可能

問題2: ビルドが失敗する

よくある原因:

  • Root directory が未設定(モノレポの場合)
  • Node.js バージョンが古い(環境変数で NODE_VERSION=22.6.0 を設定)
  • pnpm が認識されない(PNPM_VERSION=10.10.0 を設定)

確認方法:

Cloudflare Dashboard > プロジェクト > Deployments > ビルドログを確認

問題3: プレビューデプロイが作成されない

確認事項:

  • Settings > Builds & deployments > Branch deployments が有効か
  • GitHubアプリの権限が適切か(Cloudflare Pagesがリポジトリにアクセスできるか)

推奨デプロイフロー(Git連携)

1. ローカル開発
   └─ pnpm dev で動作確認

2. 機能ブランチで開発
   └─ git checkout -b feature/new-article

3. コミット & プッシュ
   └─ git push origin feature/new-article

4. Pull Request作成
   └─ プレビューURLが自動生成(例: https://123.sample-project.pages.dev)

5. レビュー & マージ
   └─ main ブランチにマージ

6. 本番デプロイ
   └─ 自動で https://sample-project.pages.dev にデプロイ

パフォーマンス最適化

1. 画像最適化

// nuxt.config.ts
export default defineNuxtConfig({
  image: {
    provider: 'cloudflare',
    domains: ['your-domain.pages.dev']
  }
})

2. プリレンダリング

// nuxt.config.ts
export default defineNuxtConfig({
  nitro: {
    prerender: {
      crawlLinks: true,
      routes: ['/'],
      ignore: ['/api/']
    }
  }
})

3. SQLiteダンプの圧縮

Nuxt Contentが自動で database.compressed.mjs を生成しますが、さらにBrotli圧縮を適用:

// nuxt.config.ts
export default defineNuxtConfig({
  nitro: {
    compressPublicAssets: {
      brotli: true,
      gzip: true
    }
  }
})

まとめ

このアーキテクチャの利点

  1. コンテンツ管理が簡単
    • MarkdownファイルをGitで管理
    • フロントマターでメタデータ管理
    • SQLiteへの自動変換
  2. 柔軟性が高い
    • シンプルな記事: Markdown
    • 複雑なUI: Vue
    • 両方を自然に共存させる
  3. パフォーマンス
    • SQLiteクエリは高速
    • 静的生成で配信も高速
    • エッジ環境での実行
  4. 開発体験
    • HMRによるリアルタイム更新
    • TypeScript完全サポート
    • モダンなツールチェーン

推奨される運用フロー

1. 新規コンテンツ作成
   ├─ シンプルな記事 → content/YYYY-MM-DD/article.md
   └─ インタラクティブ → pages/blog/demo.vue

2. ローカル開発
   └─ pnpm dev → http://localhost:3000 で確認

3. Git commit & push
   ├─ 機能ブランチにpush → プレビューデプロイ自動作成
   └─ main/masterブランチにマージ → 本番デプロイ自動実行

4. 本番環境
   └─ Cloudflare Pages/Workers で配信
   └─ エッジネットワーク経由でグローバル配信

参考リンク


このドキュメントを別のセッションに渡すことで、まったく同じ環境を再現できます。