Architektur (Interna)

Auf dieser Seite

Diese Seite richtet sich an Personen, die an EmDash arbeiten, nicht an solche, die eine Website damit erstellen. Sie dokumentiert interne Mechanismen – Tabellenlayouts, die Astro-Integration, den Request-Pfad, Code-Generierung. Nichts davon wird zur Nutzung von EmDash benötigt. Wenn Sie eine Website erstellen, lesen Sie stattdessen Architecture und das Content Model.

Die Astro-Integration

EmDash läuft als Astro-Integration aus dem emdash-Paket. Zur Build-Zeit:

  • Injiziert die Admin-SPA und REST-API-Routen mit Astros injectRoute-API. Es wird nichts in das Projekt des Benutzers kopiert. Die injizierten Pfade sind:

    PfadmusterZweck
    /_emdash/admin/[...path]Admin-Panel-SPA
    /_emdash/api/manifestAdmin-Manifest (Collections, Plugins)
    /_emdash/api/content/[collection]Content-Entry-CRUD
    /_emdash/api/media/*Media-Library-Operationen
    /_emdash/api/schema/*Schema-Management
    /_emdash/api/settingsWebsite-Einstellungen
    /_emdash/api/menus/*Navigationsmenüs
    /_emdash/api/taxonomies/*Kategorien, Tags, benutzerdefinierte Taxonomien
  • Generiert virtuelle Module, damit der Bundler Konfigurations- und Plugin-Code auflösen und tree-shaken kann:

    ModulZweck
    virtual:emdash/configDatenbank- und Speicherkonfiguration
    virtual:emdash/dialectDatenbank-Dialekt-Factory
    virtual:emdash/plugin-adminsStatische Imports für Plugin-Admin-UIs
  • Stellt den Live Collections Loader bereit, verwaltet Migrationen und öffnet die Speicherverbindung.

Database-first Schema

Schema-Definitionen leben in der Datenbank, nicht im Code. Zwei Systemtabellen verfolgen die Struktur.

_emdash_collections enthält eine Zeile pro Collection:

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

Die source-Spalte zeichnet die Herkunft auf: manual (Admin-UI), template:<name> (Seed-Datei), import:wordpress (Importer) oder discovered (automatisch erkannt aus vorhandenen Tabellen).

_emdash_fields enthält eine Zeile pro Feld, verknüpft mit seiner Collection:

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

Pro-Collection-Content-Tabellen

Jede Collection erhält ihre eigene Tabelle mit dem Präfix ec_. Eine products-Collection mit title- und price-Feldern erzeugt:

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

Echte Spalten (statt einer Tabelle mit einem JSON-Blob) ermöglichen ordnungsgemäße Indizierung, funktionierende Foreign Keys, ein Schema, das Datenbanktools inspizieren können, und keine Feld-für-Feld-JSON-Analyse.

Die Zuständigkeiten bleiben getrennt:

ZuständigkeitOrtTabellen
SchemaSystemtabellen_emdash_collections, _emdash_fields
ContentPro-Collection-Tabellenec_posts, ec_products, …
MediaSeparate Tabelle + Storagemedia-Tabelle + R2/S3
SettingsOptions-Tabelleoptions mit site:-Präfix

Laufzeit-Schema-Änderungen

Das Hinzufügen eines Felds über die Admin-UI führt drei Schritte aus:

  1. Fügt einen Datensatz in _emdash_fields ein.
  2. Führt ALTER TABLE ec_<collection> ADD COLUMN <name> <TYPE> aus.
  3. Regeneriert das für die Validierung verwendete Zod-Schema.

SQLite unterstützt das Hinzufügen, Umbenennen und Löschen von Spalten (Löschen erfordert SQLite 3.35+) zur Laufzeit. Das Ändern des Spaltentyps wird nicht direkt unterstützt, daher baut EmDash die Tabelle transparent neu auf: neue Tabelle erstellen, Zeilen kopieren, alte Tabelle löschen, neue umbenennen.

Laufzeit-Validierung

EmDash erstellt Zod-Schemas aus den Felddefinitionen beim Start und validiert jede Erstellung und Aktualisierung gegen sie:

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

Datenschicht

EmDash verwendet Kysely für typsicheres SQL über alle unterstützten Datenbanken hinweg (SQLite, libSQL, Cloudflare D1 und PostgreSQL). Der Dialekt wird von virtual:emdash/dialect aus der Konfiguration ausgewählt, die die Website an die Integration übergibt.

Live Collections Loader

Content wird zur Laufzeit über Astros Live Collections bereitgestellt. emdashLoader() implementiert Astros LiveLoader-Interface und wird als einzelne _emdash-Collection registriert:

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

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

Die einzelne _emdash-Collection umschließt jeden Content-Typ; der Loader filtert nach Typ, wenn getEmDashCollection("posts") aufgerufen wird.

Request-Pfade

Ein Content-Request von einer Seite:

  1. Astro empfängt die Anfrage und führt die Seitenkomponente aus.
  2. getEmDashCollection() ruft Astros getLiveCollection() auf.
  3. emdashLoader fragt die relevante ec_*-Tabelle über Kysely ab.
  4. Zeilen werden auf Astros Entry-Format (id, slug, data) gemappt.
  5. Die Komponente rendert.

Ein Admin-Request:

  1. Middleware validiert das Session-Token.
  2. Die API-Route führt CRUD über ein Repository aus.
  3. Lifecycle-Hooks werden ausgelöst (z. B. content:beforeSave).
  4. Kysely führt das SQL aus.
  5. Die Route gibt JSON an die Admin-SPA zurück.

Admin-Panel-Interna

Das Admin ist eine React-Insel. Astro liefert die Shell und erzwingt die Authentifizierung in Middleware; alles im Inneren ist clientseitig, gebaut auf TanStack Router, TanStack Query, TanStack Table, React Hook Form + Zod, TipTap und Kumo (Cloudflares Base UI + Tailwind-Designsystem).

Die Shell-Route steuert den Zugriff in Middleware:

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

Manifest-gesteuerte UI

Das Admin hardcodiert nichts über Collections oder Plugins. Es holt GET /_emdash/api/manifest, das die Collections, Plugins und Taxonomien zurückgibt, auf die der anfragende Benutzer zugreifen darf, gefiltert nach Rolle:

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

Navigation, Formulare und Feld-Editoren werden aus diesem Manifest generiert, sodass Schema- und Plugin-Änderungen ohne Admin-Rebuild erscheinen und Zod-Schemas serverseitig bleiben.

Plugin-Admin-UIs

Plugin-Admin-Einstiegspunkte werden in ein generiertes virtuelles Modul statischer Imports gesammelt, damit der Bundler sie auflösen und tree-shaken kann:

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

export const pluginAdmins = { seo: pluginAdmin0 };

Rich-Text-Konvertierung

Portable Text-Felder werden in TipTap (ProseMirror) bearbeitet. Content wird an den Lade- und Speichergrenzen durch portableTextToProsemirror() und prosemirrorToPortableText() konvertiert. Unbekannte Blöcke von Plugins oder Imports werden als schreibgeschützte Platzhalter erhalten.

Signierte Uploads

Media-Uploads umgehen Worker Body-Size-Limits mit direkten signierten URLs zum Storage:

  1. Der Client fordert eine Upload-URL an (POST /api/media/upload-url).
  2. Der Client lädt direkt zur signierten URL hoch (R2 oder S3).
  3. Der Client bestätigt (POST /api/media/:id/confirm).
  4. Der Server extrahiert Metadaten (Dimensionen, MIME-Typ).

Erweitern des Content-Importers

Der WordPress-Importer basiert auf einem pluggable ImportSource-Interface. Eine benutzerdefinierte Quelle implementiert probe, analyze und fetch:

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

probe validiert die Eingabe und meldet, was gefunden wurde, analyze ordnet Quell-Post-Typen EmDash-Collections zu und kennzeichnet Schema-Lücken, und fetchContent streamt normalisierte Einträge, die die Import-Pipeline über dieselben Repositories schreibt, die auch das Admin verwendet. Eingebaute Quellen decken WordPress WXR, WordPress.com und die WordPress REST API ab; registrieren Sie eine benutzerdefinierte Quelle, um aus einem anderen System zu importieren.