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

このページ

このページは 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 行を格納します。そのコア列はコレクションとランタイムおよび管理が公開する機能を記述します:

目的
id, slug安定したコレクション ID
label, label_singular, description, iconエディターに表示される名前とガイダンス
supports, has_seo, comments_enabled, edit_lockingオプションのコレクション機能
title_field, date_field, admin_config, hidden, sort_order管理リストとナビゲーション動作
url_pattern, routable公開 URL とスラッグ動作
sourceコレクションの作成方法

source 値は manualseedtemplate:<name>import:<name>discovered などの出所を記録します。追加設定は登録されたマイグレーションから来るため、database/types.ts とマイグレーションが現在の列インベントリです。

_emdash_fields は各コレクションにリンクされたフィールドを格納します:

目的
id, collection_id, slugフィールド ID と所有コレクション
label, type, column_typeエディターラベル、EmDash フィールドタイプ、SQL ストレージタイプ
required, unique, default_value, validationコンテンツ制約とデフォルト
widget, options, sort_orderエディターコントロールと表示順序
searchable, indexed, translatable検索、クエリ、ローカリゼーション動作

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

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

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

CREATE TABLE ec_products (
  -- システム列、すべてのコンテンツテーブルに存在
  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,

  -- コンテンツ列、フィールド定義から作成
  title TEXT NOT NULL,
  price REAL,

  UNIQUE (slug, locale)
);

実際の列は各フィールドにデータベース型を与え、インデックスと外部キーを許可し、コンテンツ JSON ブロブをデコードせずにデータベースツールがスキーマを検査できるようにします。ユニーク制約により、翻訳はスラッグを共有しながら各ロケール内でスラッグを一意に保てます。同じエントリのすべてのロケールバリアントは translation_group 値を共有し、EmDash が互いの翻訳である行を見つけられるようにします。

主要なデータ関心事は分離されたままです:

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

ランタイムスキーマ変更

管理 UI を通じてフィールドを追加すると、次のステップが実行されます:

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

コンテンツバリデーションは現在のフィールド定義を読み取り、コンテンツが作成または更新されるときに Zod スキーマを構築します。フィールドの基盤となる SQL タイプ、required または unique 制約、またはローカリゼーション動作を変更するには、手動のコンテンツマイグレーションが必要な場合があります。SchemaRegistry はテーブルを暗黙的に再構築する代わりに、サポートされていないインプレース変更を拒否します。

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

EmDash はコレクションの現在のフィールドから Zod スキーマを導出します。ジェネレーターは型と制約の詳細を generateFieldSchema() に委譲します:

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);
}

コンテンツハンドラーは不明なフィールドも拒否し、必須の文字列値をチェックし、他のコレクションへの参照を検証します。

データレイヤー

EmDash は SQLite、libSQL、Cloudflare D1、PostgreSQL にまたがる型付き SQL のために Kysely を使用します。サイト設定がデータベースアダプターを選択し、統合は virtual:emdash/dialect を通じてダイアレクトファクトリーを公開します。

Live Content Collections ローダー

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

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

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

単一の _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 にそのユーザーを配置します:

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"
}

管理パネルはマニフェストを使用してコレクションナビゲーションとフィールドエディターを構築します。エンドポイントがライブスキーマを読み取るため、コレクションとフィールドの変更は管理アプリケーションを再構築せずに表示されます。

プラグイン管理 UI

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

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 インターフェースを使用します。ソースは URL をプローブし、現在のスキーマに対して利用可能なコンテンツを分析し、正規化されたコンテンツアイテムをストリームできます:

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 エクスポートに誘導します。インポーターが同じ正規化された分析とコンテンツアイテム形状を生成できる場合は、別のソースを登録してください。