---
title: ページスキーマ
date: 2026-02-24T23:33:16.422Z
updated: 2026-08-28T03:00:00.000Z
tags:
  - garden
url: https://baku89.com/ja/garden/page-schema
---

# ページスキーマ

ページのデータモデルについて。型定義は `types/page.ts`、RxDB スキーマは `utils/rxdb/pageSchema.ts` にある。

## Page インターフェース

`Page` は frontmatter の情報に加えて、パース済みの本文やアウトリンクを含む完全なページデータ。

主なフィールド:

- `path` — ファイルパス（拡張子なし）
- `slug` — 正規化済みの識別子（→ [information-architecture.md](./information-architecture)）
- `content_id` — ULID。リネーム追跡用の不変 ID
- `body` — パース済みの本文 AST（`MDCRoot`、en/ja の多言語対応）
- `source` — Markdown のソースコード（非 admin には非公開）
- `source_hash` — `source` の `ohash` フィンガープリント。content equality 判定や redundant write skip に使う
- `outlinks` — このページから出ている内部リンクの配列

赤リンク（バックリンクのみで実体が無いページ）は `Page` のフィールドではなく、解決時の `resolution: 'redlink'` という別経路で表現する。

## Frontmatter

YAML frontmatter のスキーマは Zod で定義している（`FrontmatterSchema`）。Obsidian 互換を意識したフォーマット。

```
content_id: 01HQ3K5P0G3Z1VXYZ2ABC3DEF
title: Hello World
title_locale:
  ja: こんにちは、世界！
date: 2025-08-20
updated: 2025-08-21
visibility: public
draft: false
layout: grid
tags:
  - video
  - diary
description: A sample page
thumbnail: https://example.com/image.jpg
```

主なフィールド:

- `content_id` — ULID。省略時は自動生成
- `title` — 文字列（`title_locale` で `{en, ja}` の locale 別タイトルを上書き可能）
- `date` — 代表日時（イベント開始日や公開日）。ISO 8601 の日付または日時
- `until` — 期間ものイベントの終了日時。未設定なら index 投入時に `date` をコピー
- `updated` — 訪問者向けの更新日時（ソートや RSS で使う）。明示的に著者がスタンプする運用（保存時に自動で進めない）
- `visibility` — `'public'` / `'unlisted'` / `'protected'` / `'private'` / `'contextual'`（→ [information-architecture.md](./information-architecture)）
- `draft` — `true` で noindex/nofollow + `<html>` に `draft` クラス付与。デフォルト `false` は「公開」
- `layout` — `'page'`（デフォルト）/ `'grid'` / `'table'` 等
- `tags` — tag link の配列。slug に正規化されて `outlinks` に変換される
- `aliases` — ページの別名（リダイレクト用）
- `redirect` — 別ページへの転送

## 継承とカスケード

frontmatter の一部はディレクトリ階層に沿って下のページへ継承される。歩く順序は `getCascadeSlugs(slug)`（`utils/pages/getAncestorSlugs.ts`）が決める。`/aaa/bbb` なら:

```
index → aaa → aaa/index → aaa/bbb
```

ルートの `index.md` から始まり、各ディレクトリ階層では兄弟ファイル（`aaa.md`）の次にそのディレクトリの `aaa/index.md`、最後にページ自身。両方が存在すればどちらも効き、深い側が後勝ち。存在しない階層は何も寄与しない。クライアントでは `useCascade(slug)`（`composables/useCascade.ts`）が vault の購読でこれを reactive に解決し、SSR には同じ歩きの seed が敷かれる。

この歩きに乗るキーは二種類ある。

### 継承キー（top-level に書く）

`INHERITED_PROFILE_KEYS`（`types/page.ts`）: `site_title` / `short_title` / `site_logo` / `site_title_home_href` / `menu` / `footer_menu` / `footer_text` / `title_separator` / `date_format` / `lang` / `theme` / `custom_css`（本文の `<style>` ブロック）/ `site_head`。

どのページでも **top-level** に書けば、そのページと配下すべてに効く。セクションの index が出すヘッダーやテーマは配下と同じで当然なので、名前空間は要らない。サイト全体の値はルートの `index.md` に書く——それは単に歩きの起点というだけで、特別扱いではない。

キーごとのマージ規則（`utils/pages/resolveEffectiveProfile.ts`）:

- **上書き**: `site_title`, `menu`, `footer_menu`, `footer_text`, `title_separator`, `lang` など。いちばん深いページの値が勝つ。空の `menu: []` は「メニュー無し」ではなく「何も言っていない」扱い
- **プロパティ単位でマージ**: `theme`, `date_format`。セクションで色だけ変えてフォントはサイトのまま、ができる
- **蓄積**: `custom_css`, `site_head`。ルートから順に全部注入され、深い側は通常の CSS カスケードで勝つ

`app.config.ts` の `siteProfile` が最下層のデフォルト。デフォルトと同じ値は上書きとみなさない（zod のデフォルトが焼き込まれた古いドキュメントが継承を壊さないための規則）。

ヘッダー・フッター・`<title>`・テーマ・カスタム CSS はこれを `useEffectiveProfile()` 経由で読む（現在ルートの分を `app.vue` / `error.vue` が一度だけ解決して provide する）。`useSiteProfile()` はサイトに一つしかないもの（`favicon`, `app_icon`, `quick_post`, `person`, `og_twitter`, `admin_home` …）だけのために残っていて、ルートの `index` だけを読む。どちらも hydration 後は vault に追従するので、`index.md` を編集すればリロード無しでクロームに反映される。

### `defaults.*`（配下のためだけの値）

index ページ自身の値と、配下に配りたい値が**食い違う**キーのための名前空間。連載トップの `::lead` は連載の紹介文だが、配下の各記事に付けたいのは「この記事は連載の一部です」という別物——top-level に書くと連載トップ自身にも出てしまう。だから `defaults.lead` は「自分の下にあるものの lead」を意味する。

該当キー: `lead`, `tail`, `related`, `show_login_button`。top-level の `related:` はそのページ自身の override、`defaults.related` が配下へのカスケード。リッチコンテンツ（`lead` / `tail`）は本文の `::defaults` ブロック内 `:::lead` / `:::tail` でも書ける（frontmatter が優先）。解決は `useEffectiveDefaults(slug)`。

この食い違いが無いキーを `defaults.*` に入れてはいけない——継承キーとして top-level に置く。

## Link 型

ページから出ていくリンクの型。

```
interface Link {
  type: 'tag' | 'folder' | 'link'
  slug: string
  hash?: string
}
```

- `tag` — frontmatter の `tags` から生成
- `folder` — パスの親ディレクトリから暗黙的に生成
- `link` — 本文中のハイパーリンクから生成

## RxDB スキーマ

DB 上では `PageDoc = {id, content, softDeleted}` の形で保存される。

- `id` — `content.content_id` 由来の primary key
- `content` — `Page` 全体（`additionalProperties: true`、MDC AST のような複雑な JSON を JSON Schema で完全モデル化するのは現実的でないので開けてある）
- `softDeleted` — soft delete フラグ。RxDB native の `_deleted` ではなく通常フィールドなので、replication が他のフィールド変更と同じ経路で削除を伝播する

サーバ側のみ、`PageDoc` に `seq: number`（required + indexed）が追加される。`pageSchema.ts` で `import.meta.server` 分岐により schema を fork し、レプリケーション pull のレスポンス組み立て時にサーバ側で `seq` を destructure で除去してからクライアントへ送る。詳細は [replication.md](./replication)。

主なインデックス: `content.content_id`, `content.slug`, `softDeleted`, `content.date`, `content.until`, `content.updated`, `content.visibility.type`、および `[visibility.type, date/until/updated]` の compound index。サーバ側のみ `seq`。

slug の重複チェックは `pageSchema.ts` の `findLiveSlugHolder` / `dedupePagePath` に集約されていて、ローカルの `upsertPage` とサーバーのレプリケーション push ハンドラ（`push.post.ts`）の両方が使う。同じ `slug` を持つ別 `content_id` の live なドキュメントが既にあれば、パスに `_2`, `_3`, … のサフィックスを付けて自動リネームする（ローカル書き込みでは toast で通知、サーバー側はリネームした doc をその行の conflict として返してクライアントに採用させる）。

検索は `content.slug` **単独**の selector で行う。同じ selector に `softDeleted: false` を足すと、RxDB の query planner は boolean の `$eq` を slug の等値と同じ点数に評価して後ろにある `softDeleted` 索引を選び、live な全ドキュメントを走査してから slug を照合する——3k ページの vault で 1 回 0.6〜4 秒、保存のたびに走っていた。`softDeleted` と `content_id` の除外は JS 側でフィルタする。
