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

このページ

このページは、EmDash 開発する人向けであり、EmDash でサイトを構築する人向けではありません。内部メカニズムを文書化しています — テーブルレイアウト、Astro 統合、リクエストパス、コード生成。EmDash を使用するために必要なものは何もありません。サイトを構築している場合は、代わりに ArchitectureContent Model をお読みください。

Astro 統合

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

  • Astro の injectRoute API を使用して、管理画面 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,                      -- how it was created
  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,               -- column name
  label TEXT NOT NULL,
  type TEXT NOT NULL,               -- field type
  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 (
  -- System columns, always present
  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,                  -- soft delete
  version INTEGER DEFAULT 1,        -- optimistic locking

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

実際の列(JSON blob を含む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. ルートが管理 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 で Worker のボディサイズ制限を回避します:

  1. クライアントがアップロード URL をリクエストします(POST /api/media/upload-url)。
  2. クライアントが署名付き URL に直接アップロードします(R2 または S3)。
  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 をカバーしています。別のシステムからインポートするにはカスタムソースを登録してください。