ページスキーマ

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

Page インターフェース

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

主なフィールド:

  • path — ファイルパス(拡張子なし)
  • slug — 正規化済みの識別子(→ information-architecture.md
  • content_id — ULID。リネーム追跡用の不変 ID
  • body — パース済みの本文 AST(MDCRoot、en/ja の多言語対応)
  • source — Markdown のソースコード(非 admin には非公開)
  • source_hashsourceohash フィンガープリント。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
  • drafttrue で 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_KEYStypes/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.tssiteProfile が最下層のデフォルト。デフォルトと同じ値は上書きとみなさない(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 に置く。

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

interface Link {
  type: 'tag' | 'folder' | 'link'
  slug: string
  hash?: string
}
  • tag — frontmatter の tags から生成
  • folder — パスの親ディレクトリから暗黙的に生成
  • link — 本文中のハイパーリンクから生成

RxDB スキーマ

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

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

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

主なインデックス: 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.tsfindLiveSlugHolder / 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 秒、保存のたびに走っていた。softDeletedcontent_id の除外は JS 側でフィルタする。