このページで分かること

  • Astroのインテグレーションが注入するルートと仮想モジュール
  • データベースに置かれるスキーマ(_emdash_collections_emdash_fields)とコレクションごとのテーブル(ec_)、実行時のスキーマ変更と検証、データ層(Kysely)
  • ライブコンテンツコレクションのローダー、リクエストの流れ、管理画面の内部構成、署名付きURLによるアップロード、インポーターの拡張
難易度
上級
読む時間
7分
このページの目次

このページは、EmDashでサイトを作る人ではなく、EmDashそのものの開発に取り組む人向けです。データベースの構成、Astroのインテグレーション、リクエストの流れ、管理画面のアプリケーション、メディアの流れ、インポートの仕組みを説明します。サイトを作る場合は、代わりにアーキテクチャコンテンツモデルを読みます。

Astroのインテグレーション

EmDashは、emdash パッケージが提供するAstroのインテグレーションとして動きます。ビルド時には、次のことをします。

  • Astroの injectRoute APIを使い、管理画面のアプリケーションとREST APIのルートを注入します。ユーザーのプロジェクトには何もコピーしません。主なルートの系統は次のとおりです。

    パスのパターン 用途
    /_emdash/admin/[...path] 管理画面のSPA
    /_emdash/api/manifest 管理画面のマニフェスト(コレクション、プラグイン)
    /_emdash/api/content/[collection]/... コンテンツのエントリーの操作
    /_emdash/api/media/... メディアライブラリの操作
    /_emdash/api/schema/... スキーマの管理
    /_emdash/api/settings/... サイトの設定
    /_emdash/api/menus/... ナビゲーションメニュー
    /_emdash/api/taxonomies/... カテゴリー、タグ、独自のタクソノミー
    /_emdash/api/plugins/[pluginId]/[...path] プラグインが定義するAPIのルート

    認証、コメント、検索、インポート、ウィジェットなどのルートの系統も含む完全な一覧は、ルートを注入するコードにあります。

  • バンドラーが設定と拡張のコードを解決できるように、仮想モジュールを生成します。

    モジュール 用途
    virtual:emdash/config データベース、ストレージ、サイトの設定
    virtual:emdash/dialect データベースのダイアレクトのファクトリー
    virtual:emdash/admin-registry プラグインの管理画面のための静的なインポート
    virtual:emdash/plugins 設定したプラグインの実装
    virtual:emdash/media-providers 設定した外部のメディアプロバイダー

    残りの実行時のヘルパーと、生成されるモジュールの中身は、virtual-modules.ts で定義しています。

  • ライブコンテンツコレクション(Live Content Collections)のローダーを提供し、実行時のミドルウェアを登録します。リクエスト時には、ルートが使う前に、ミドルウェアが設定済みのデータベースとストレージへの接続を開き、未適用のマイグレーションを適用します。

データベースを起点にしたスキーマ

スキーマの定義は、静的な設定ファイルではなく、データベースにあります。_emdash_collections には、コレクションごとに1行を保存します。主なカラムは、コレクションと、実行時の処理と管理画面が提供する機能を表します。

カラム 用途
idslug コレクションを識別する変わらない値
labellabel_singulardescriptionicon 編集者に表示する名前と説明
supportshas_seocomments_enablededit_locking コレクションの任意の機能
title_fielddate_fieldadmin_confighiddensort_order 管理画面の一覧とナビゲーションの動作
url_patternroutable 公開URLとスラッグの動作
source コレクションの作成方法

source の値には、manualseedtemplate:<name>import:<name>discovered のような作成元が記録されます。追加の設定は登録済みのマイグレーションで追加されるため、現在のカラムの一覧は database/types.ts とマイグレーションで確認します。

_emdash_fields には、各コレクションに結び付くフィールドを保存します。

カラム 用途
idcollection_idslug フィールドの識別と、所属するコレクション
labeltypecolumn_type 編集画面のラベル、EmDashのフィールド型、SQLの保存形式
requireduniquedefault_valuevalidation コンテンツの制約と初期値
widgetoptionssort_order 編集画面の入力部品と表示順
searchableindexedtranslatable 検索、クエリ、ローカライズの動作

collection_id_emdash_collections.id を参照し、フィールドのスラッグはコレクションの中で一意です。

コレクションごとのコンテンツのテーブル

コレクションごとに、ec_ で始まる専用のテーブルが作られます。titleprice のフィールドを持つ products コレクションからは、次の形のテーブルが作られます。

CREATE TABLE ec_products (
  -- System columns, present on every content table
  id TEXT PRIMARY KEY,
  slug TEXT,
  status TEXT DEFAULT 'draft',
  author_id TEXT,
  primary_byline_id TEXT,
  created_at TEXT DEFAULT CURRENT_TIMESTAMP,
  updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
  published_at TEXT,
  scheduled_at TEXT,
  deleted_at TEXT,
  version INTEGER DEFAULT 1,
  live_revision_id TEXT,
  draft_revision_id TEXT,
  locale TEXT NOT NULL DEFAULT 'en',
  translation_group TEXT,

  -- Content columns, created from field definitions
  title TEXT NOT NULL,
  price REAL,

  UNIQUE (slug, locale)
);

実際のカラムを使うことで、各フィールドがデータベースの型を持ち、インデックスや外部キーを使えるようになります。また、データベースのツールが、コンテンツのJSONの塊を解読しなくてもスキーマを調べられます。一意性の制約により、翻訳同士は同じスラッグを共有でき、それぞれのスラッグはロケールの中で一意に保たれます。同じエントリーのすべてのロケールの版は同じ translation_group の値を共有し、EmDashはこの値を使って、互いに翻訳の関係にある行を見つけます。

主なデータの関心事は、それぞれ分かれています。

関心事 置き場所 テーブル
スキーマ システムテーブル _emdash_collections_emdash_fields
コンテンツ コレクションごとのテーブル ec_postsec_products、…
メディア 別のテーブル+ストレージ media テーブル+設定したストレージ
設定 optionsテーブル site: の接頭辞を付けた options

実行時のスキーマ変更

管理画面からフィールドを追加すると、次の手順が実行されます。

  1. フィールドの定義を _emdash_fields に挿入します。
  2. コレクションの ec_* テーブルに対応するカラムを追加し、フィールドがインデックス付きに設定されている場合はインデックスを作成します。
  3. 新しいフィールドが編集用のツールに表示されるように、生成された開発用の型を更新します。

コンテンツの検証は、コンテンツの作成時と更新時に現在のフィールドの定義を読み込み、Zodのスキーマを組み立てます。フィールドの基になるSQLの型、requiredunique の制約、ローカライズの動作を変更すると、コンテンツの手動のマイグレーションが必要になる場合があります。SchemaRegistry は、対応していないその場での変更を、暗黙にテーブルを作り直すのではなく、拒否します。

実行時の検証

EmDashは、コレクションの現在のフィールドからZodのスキーマを導き出します。型と制約の詳細は、生成処理が generateFieldSchema() に任せます。

packages/core/src/schema/zod-generator.ts
export function generateZodSchema(
	collection: CollectionWithFields,
): z.ZodObject<Record<string, ZodType>> {
	const shape: Record<string, ZodType> = {};

	for (const field of collection.fields) {
		shape[field.slug] = generateFieldSchema(field);
	}

	return z.object(shape);
}

コンテンツのハンドラーは、このほかに、未知のフィールドを拒否し、必須の文字列の値を確認し、他のコレクションへの参照を検証します。

本サイトの補足 やさしい解説

WordPressでいう投稿タイプはEmDashのコレクションに、投稿メタはコレクションのフィールドにあたります。EmDashは、コレクションの定義を _emdash_collections_emdash_fields のテーブルに保存し、コレクションごとに ec_ で始まる専用のテーブルを作ります。フィールドは、そのテーブルの実際のカラムになります。そのため、管理画面でフィールドを追加すると、定義の行が増えるだけでなく、テーブルにカラムも追加されます。

データ層

EmDashは、SQLite、libSQL、Cloudflare D1、PostgreSQLで型付きのSQLを扱うために Kysely を使います。データベースのアダプターはサイトの設定で選びます。インテグレーションは、そのアダプターのダイアレクトのファクトリーを virtual:emdash/dialect で提供します。

ライブコンテンツコレクションのローダー

コンテンツは、Astroのライブコンテンツコレクションを通じて、実行時に提供されます。emdashLoader() はAstroの LiveLoader インターフェースを実装しており、1つの _emdash コレクションとして登録します。

src/live.config.ts
import { defineLiveCollection } from "astro:content";
import { emdashLoader } from "emdash/runtime";

export const collections = {
	_emdash: defineLiveCollection({ loader: emdashLoader() }),
};

この1つの _emdash コレクションが、EmDashのすべてのコレクションを包んでいます。getEmDashCollection("posts")posts という種類で絞り込む条件を渡し、ローダーはそれを ec_posts テーブルに対応付けます。

リクエストの流れ

Astroのページからのコンテンツのリクエストは、次の流れで処理されます。

  1. ページが getEmDashCollection() または getEmDashEntry() を呼び出します。
  2. クエリのラッパーが、内部の _emdash コレクションと、要求されたEmDashのコレクションの種類を指定して、Astroの getLiveCollection() または getLiveEntry() を呼び出します。
  3. emdashLoader() が、公開状態、ロケール、絞り込み、並び順、ページネーションの規則を適用しながら、Kyselyを通じて該当する ec_* テーブルをクエリします。
  4. クエリのラッパーが、行をAstroのエントリーに対応付け、そのバイラインとタクソノミーのタームを読み込みます。
  5. Astroのコンポーネントが、返されたエントリーを描画します。

プレビューと編集モードの状態はリクエストのコンテキストを通じて渡されるため、ミドルウェアがリクエストを検証したあとは、同じクエリ関数が下書きのコンテンツを返せます。

管理画面のAPIのリクエストは、別の流れで処理されます。

  1. ミドルウェアがリクエストを認証し、特定したユーザーを Astro.locals に保存します。
  2. APIのルートがリクエストを解析し、その操作に必要な権限を確認します。
  3. ルートが、業務ロジックをハンドラーまたはリポジトリに任せます。
  4. その操作がフックを公開している場合、ハンドラーはデータベースの操作の前後でプラグインのライフサイクルのフックを実行します。
  5. ルートが、標準的なJSONの成功またはエラーのレスポンスを管理画面のアプリケーションに返します。

管理画面の内部構成

管理画面は、Reactのシングルページアプリケーションです。Astroがその外枠を配信し、認証のミドルウェアが管理画面のルートを保護します。アプリケーションの中では、TanStack Routerが画面の移動を、TanStack Queryがサーバーの状態の読み込みを、TanStack Tableがデータのグリッドの描画を、React Hook FormとZodがフォームの管理を、TipTapがPortable Textの編集を担い、Kumoがデザインシステムを提供します。

セッションによる認証では、ミドルウェアは、認証されていないブラウザーのリクエストをログインページにリダイレクトし、認証されていないAPIのリクエストにはJSONのエラーを返します。有効なユーザーを読み込んだあとは、そのユーザーをルートのために Astro.locals に置きます。

packages/core/src/astro/middleware/auth.ts
const sessionUser = await resolveSessionUser(session);

if (!sessionUser?.id) {
	if (isApiRoute) {
		return apiError("NOT_AUTHENTICATED", "Not authenticated", 401);
	}

	const loginUrl = new URL("/_emdash/admin/login", getPublicOrigin(url, emdash?.config));
	loginUrl.searchParams.set("redirect", url.pathname);
	return context.redirect(loginUrl.toString());
}

この分岐のあと、ミドルウェアはユーザーを読み込み、存在しないアカウントや無効にされたアカウントを拒否し、有効なユーザーを Astro.locals に置いて、ルートの処理に進みます。

マニフェストに基づくUI

管理画面は、コレクションのスキーマやプラグインが追加する要素をコードに直接書き込んでいません。GET /_emdash/api/manifest を取得し、現在のコレクション、フィールド、プラグイン、タクソノミー、認証の方式、その他の設定済みの機能を知ります。マニフェストを省略して示すと、次のようになります。

{
	"collections": {
		"posts": {
			"label": "Blog Posts",
			"labelSingular": "Post",
			"supports": ["drafts", "revisions", "preview"],
			"fields": {
				"title": { "kind": "string", "label": "Title", "required": true }
			}
		}
	},
	"plugins": {
		"audit-log": { "version": "0.2.1", "enabled": true }
	},
	"taxonomies": [
		{ "name": "category", "label": "Categories", "hierarchical": true }
	],
	"version": "0.37.0"
}

管理画面は、マニフェストを使って、コレクションのナビゲーションとフィールドの編集欄を組み立てます。このエンドポイントは現在のスキーマを読み込むため、コレクションやフィールドの変更は、管理画面のアプリケーションを再ビルドしなくても反映されます。

プラグインの管理画面

設定したプラグインの管理画面のエントリーポイントは、virtual:emdash/admin-registry に集められます。生成されるモジュールは静的なインポートを使うため、バンドラーがReactのコンポーネントを含められます。

virtual:emdash/admin-registry (generated)
import * as pluginAdmin0 from "@emdash-cms/plugin-seo/admin";

export const pluginAdmins = { seo: pluginAdmin0 };

リッチテキストの変換

Portable Textのフィールドは、ProseMirrorを基にしたTipTapを使います。EmDashは、編集画面を読み込むときにPortable TextをProseMirrorの形式に変換し、エントリーを保存するときにPortable Textに戻します。プラグインやインポートから来た未知のブロックは、捨てられずに、読み取り専用のプレースホルダーとして保持されます。

署名付きのアップロード

メディアのアップロードでは、ストレージのアダプターが対応している場合はストレージに直接アップロードする署名付きURLを使い、対応していない場合は同一オリジンのストリーミング用のエンドポイントを使います。

  1. クライアントが POST /_emdash/api/media/upload-url でアップロード先を要求します。EmDashは保留中のメディア項目を作成します。
  2. クライアントが、返されたアップロード先にアップロードします。S3互換のアダプターは、アプリケーションのリクエスト本文のサイズ制限を通らない署名付きURLを返せます。R2のネイティブのバインディングとローカルのストレージは、EmDashのストリーミング用のエンドポイントを返します。
  3. クライアントが POST /_emdash/api/media/:id/confirm でアップロードを確定します。
  4. EmDashが保存されたファイルを検証し、メディア項目を利用可能な状態にします。

コンテンツのインポーターの拡張

WordPressのインポーターは、差し替えできる ImportSource インターフェースを使います。インポート元(source)は、URLを調べ、現在のスキーマに照らして利用できるコンテンツを分析し、正規化したコンテンツの項目をストリームで返せます。

packages/core/src/import/types.ts
interface ImportSource {
	id: string;
	name: string;
	description: string;
	icon: "upload" | "globe" | "wordpress" | "plug";
	requiresFile?: boolean;
	canProbe?: boolean;
	probe?(url: string): Promise<SourceProbeResult | null>;
	analyze(input: SourceInput, context: ImportContext): Promise<ImportAnalysis>;
	fetchContent(input: SourceInput, options: FetchOptions): AsyncGenerator<NormalizedItem>;
	fetchMedia?(url: string, input: SourceInput): Promise<Blob>;
}

WXRのインポート元は、WordPressのエクスポートファイルをインポートします。コネクターのインポート元は、EmDashのWordPressプラグインを入れたサイトから直接インポートします。別のRESTのインポート元は、公開されているWordPressのサイトを検出しますが、RESTからの直接のインポートは実装されていないため、ユーザーにWXRでのエクスポートを案内します。同じ形の正規化した分析結果とコンテンツの項目を作れるインポーターを作る場合は、別のインポート元として登録します。