アーキテクチャ(内部構造)

このページ

このページはEmDashで作業する人向けであり、EmDashでサイトを構築する人向けではありません。内部メカニズム — テーブルレイアウト、Astro統合、リクエストパス、コード生成を文書化しています。EmDashを使用するためにこれらの知識は不要です。サイトを構築する場合は、代わりにアーキテクチャコンテンツモデルをお読みください。

Astro統合

EmDashはemdashパッケージからAstro統合として実行されます。ビルド時に:

  • AstroのAPI injectRouteでAdmin SPAとREST APIルートを注入します。ユーザーのプロジェクトには何もコピーされません。注入されるパスは:

    パスパターン目的
    /_emdash/admin/[...path]管理パネルSPA
    /_emdash/api/manifest管理マニフェスト(コレクション、プラグイン)
    /_emdash/api/content/[collection]コンテンツエントリCRUD
    /_emdash/api/media/*メディアライブラリ操作
    /_emdash/api/schema/*スキーマ管理
    /_emdash/api/settingsサイト設定
    /_emdash/api/menus/*ナビゲーションメニュー
    /_emdash/api/taxonomies/*カテゴリー、タグ、カスタムタクソノミー
  • バンドラーが設定コードとプラグインコードを解決してツリーシェイクできるように仮想モジュールを生成します:

    モジュール目的
    virtual:emdash/configデータベースとストレージの設定
    virtual:emdash/dialectデータベースダイアレクトファクトリー
    virtual:emdash/plugin-adminsプラグイン管理UIの静的インポート
  • Live Collectionsローダーを提供し、マイグレーションを管理し、ストレージ接続を開きます。

データベースファーストスキーマ

スキーマ定義はコードではなくデータベースに存在します。2つのシステムテーブルが構造を追跡します。

_emdash_collectionsはコレクションごとに1行を保持します:

CREATE TABLE _emdash_collections (
  id TEXT PRIMARY KEY,
  slug TEXT UNIQUE NOT NULL,        -- "posts", "products"
  label TEXT NOT NULL,              -- "Blog Posts"
  label_singular TEXT,              -- "Post"
  description TEXT,
  icon TEXT,
  supports JSON,                    -- ["drafts", "revisions", "preview"]
  source TEXT,                      -- 作成方法
  created_at TEXT DEFAULT CURRENT_TIMESTAMP,
  updated_at TEXT
);

sourceカラムは出所を記録します:manual(管理UI)、template:<name>(シードファイル)、import:wordpress(インポーター)、またはdiscovered(既存テーブルから自動検出)。

_emdash_fieldsはフィールドごとに1行を保持し、コレクションにリンクされています:

CREATE TABLE _emdash_fields (
  id TEXT PRIMARY KEY,
  collection_id TEXT REFERENCES _emdash_collections(id),
  slug TEXT NOT NULL,               -- カラム名
  label TEXT NOT NULL,
  type TEXT NOT NULL,               -- フィールドタイプ
  column_type TEXT NOT NULL,        -- TEXT, REAL, INTEGER, JSON
  required INTEGER DEFAULT 0,
  unique_field INTEGER DEFAULT 0,
  default_value TEXT,
  validation JSON,
  widget TEXT,
  options JSON,
  sort_order INTEGER,
  created_at TEXT DEFAULT CURRENT_TIMESTAMP,
  UNIQUE(collection_id, slug)
);

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

各コレクションはec_プレフィックスで独自のテーブルを取得します。titlepriceフィールドを持つproductsコレクションは以下を生成します:

CREATE TABLE ec_products (
  -- システムカラム、常に存在
  id TEXT PRIMARY KEY,
  slug TEXT UNIQUE,
  status TEXT DEFAULT 'draft',
  author_id TEXT,
  created_at TEXT DEFAULT (datetime('now')),
  updated_at TEXT DEFAULT (datetime('now')),
  published_at TEXT,
  deleted_at TEXT,                  -- ソフトデリート
  version INTEGER DEFAULT 1,        -- 楽観的ロック

  -- コンテンツカラム、フィールド定義から
  title TEXT NOT NULL,
  price REAL
);

実カラム(JSONブロブを持つ1つのテーブルではなく)は、適切なインデックス作成、動作する外部キー、データベースツールが検査できるスキーマ、フィールドごとのJSON解析不要を提供します。

責務は分離されたままです:

責務場所テーブル
スキーマシステムテーブル_emdash_collections, _emdash_fields
コンテンツコレクションごとのテーブルec_posts, ec_products, …
メディア別テーブル + ストレージmediaテーブル + R2/S3
設定オプションテーブルsite:プレフィックス付きoptions

ランタイムスキーマ変更

管理UIでフィールドを追加すると3つのステップが実行されます:

  1. _emdash_fieldsにレコードを挿入する。
  2. ALTER TABLE ec_<collection> ADD COLUMN <name> <TYPE>を実行する。
  3. バリデーションに使用するZodスキーマを再生成する。

SQLiteはランタイムでカラムの追加、リネーム、ドロップ(ドロップにはSQLite 3.35+が必要)をサポートしています。カラムの型変更は直接サポートされていないため、EmDashはテーブルを透過的に再構築します:新しいテーブルを作成し、行をコピーし、古いテーブルを削除し、新しいテーブルをリネームします。

ランタイムバリデーション

EmDashは起動時にフィールド定義からZodスキーマを構築し、すべての作成と更新をそれに対して検証します:

function buildSchema(fields: Field[]): ZodSchema {
	const shape: Record<string, ZodType> = {};
	for (const field of fields) {
		let zodType = fieldTypeToZod(field.type);
		if (field.required) zodType = zodType.required();
		if (field.validation?.min !== undefined) zodType = zodType.min(field.validation.min);
		shape[field.slug] = zodType;
	}
	return z.object(shape);
}

データレイヤー

EmDashはサポートされるすべてのデータベース(SQLite、libSQL、Cloudflare D1、PostgreSQL)にわたって型安全なSQLのためにKyselyを使用します。ダイアレクトはサイトが統合に渡す設定からvirtual:emdash/dialectによって選択されます。

Live Collectionsローダー

コンテンツはAstroのLive Collectionsを通じてランタイムで提供されます。emdashLoader()はAstroのLiveLoaderインターフェースを実装し、単一の_emdashコレクションとして登録されます:

import { defineLiveCollection } from "astro:content";
import { emdashLoader } from "emdash/runtime";

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

単一の_emdashコレクションがすべてのコンテンツタイプをラップし、getEmDashCollection("posts")が呼び出されるとローダーがタイプでフィルタリングします。

リクエストパス

ページからのコンテンツリクエスト:

  1. Astroがリクエストを受信し、ページコンポーネントを実行する。
  2. getEmDashCollection()がAstroのgetLiveCollection()を呼び出す。
  3. emdashLoaderがKyselyを通じて関連するec_*テーブルをクエリする。
  4. 行がAstroのエントリフォーマット(idslugdata)にマッピングされる。
  5. コンポーネントがレンダリングする。

管理リクエスト:

  1. ミドルウェアがセッショントークンを検証する。
  2. APIルートがリポジトリを通じてCRUDを実行する。
  3. ライフサイクルフックが発火する(例:content:beforeSave)。
  4. KyselyがSQLを実行する。
  5. ルートがAdmin SPAにJSONを返す。

管理パネルの内部

管理パネルは1つのReactアイランドです。Astroがシェルを提供しミドルウェアで認証を強制します。内部はすべてクライアントサイドで、TanStack Router、TanStack Query、TanStack Table、React Hook Form + Zod、TipTap、Kumo(CloudflareのBase UI + Tailwindデザインシステム)で構築されています。

シェルルートがミドルウェアでアクセスを制御します:

export async function onRequest({ request, locals }, next) {
	const session = await getSession(request);
	if (request.url.includes("/_emdash/admin")) {
		if (!session?.user) return redirect("/_emdash/admin/login");
		locals.user = session.user;
	}
	return next();
}

マニフェスト駆動UI

管理パネルはコレクションやプラグインについて何もハードコードしていません。GET /_emdash/api/manifestを取得し、リクエストユーザーがアクセスできるコレクション、プラグイン、タクソノミーをロールでフィルタリングして返します:

{
	"collections": [
		{
			"slug": "posts",
			"label": "Blog Posts",
			"icon": "file-text",
			"supports": ["drafts", "revisions", "preview"],
			"fields": [{ "slug": "title", "type": "string", "required": true }]
		}
	],
	"plugins": [{ "id": "audit-log", "label": "Audit Log" }],
	"taxonomies": [{ "name": "category", "label": "Categories", "hierarchical": true }],
	"version": "abc123"
}

ナビゲーション、フォーム、フィールドエディターはこのマニフェストから生成されるため、スキーマとプラグインの変更は管理パネルのリビルドなしに表示され、Zodスキーマはサーバーサイドに留まります。

プラグイン管理UI

プラグインの管理エントリポイントは、バンドラーが解決してツリーシェイクできるように、生成された仮想モジュールの静的インポートに収集されます:

import * as pluginAdmin0 from "@emdash-cms/plugin-seo/admin";

export const pluginAdmins = { seo: pluginAdmin0 };

リッチテキスト変換

Portable TextフィールドはTipTap(ProseMirror)で編集されます。コンテンツはロードとセーブの境界でportableTextToProsemirror()prosemirrorToPortableText()によって変換されます。プラグインやインポートからの不明なブロックは読み取り専用プレースホルダーとして保持されます。

署名付きアップロード

メディアアップロードはアダプターがサポートする場合はストレージ直接署名付きURLを使用し、それ以外の場合はセイムオリジンストリーミングエンドポイントを使用します:

  1. クライアントがアップロードURLを要求する(POST /api/media/upload-url)。
  2. クライアントが返されたターゲットにアップロードする。S3互換アダプターはWorkerのボディサイズ制限を回避する署名付きURLを返すことができます。ネイティブR2バインディングとローカルストレージはEmDashストリーミングエンドポイントを返します。
  3. クライアントが確認する(POST /api/media/:id/confirm)。
  4. サーバーがメタデータ(寸法、MIMEタイプ)を抽出する。

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

WordPressインポーターはプラグ可能なImportSourceインターフェースで構築されています。カスタムソースはprobe、analyze、fetchを実装します:

interface ImportSource {
	probe(input: ImportInput): Promise<ProbeResult>;
	analyze(input: ImportInput): Promise<AnalysisResult>;
	fetchContent(input: ImportInput): AsyncIterable<NormalizedEntry>;
}

probeは入力を検証し見つかったものを報告し、analyzeはソースのポストタイプをEmDashコレクションにマッピングしスキーマのギャップをフラグし、fetchContentは管理パネルが使用するのと同じリポジトリを通じてインポートパイプラインが書き込む正規化されたエントリをストリームします。組み込みソースはWordPress WXR、WordPress.com、WordPress REST APIをカバーしています。別のシステムからインポートするにはカスタムソースを登録してください。