Architecture (internals)

On this page

This page is for people working on EmDash, not building a site with it. It explains the database layout, Astro integration, request paths, admin application, media flow, and import system. If you are building a site, read Architecture and the Content Model instead.

The Astro integration

EmDash runs as an Astro integration from the emdash package. At build time it:

  • Injects the admin application and REST API routes with Astro’s injectRoute API. Nothing is copied into the user’s project. The main route families are:

    Path patternPurpose
    /_emdash/admin/[...path]Admin panel SPA
    /_emdash/api/manifestAdmin manifest (collections, plugins)
    /_emdash/api/content/[collection]/...Content entry operations
    /_emdash/api/media/...Media library operations
    /_emdash/api/schema/...Schema management
    /_emdash/api/settings/...Site settings
    /_emdash/api/menus/...Navigation menus
    /_emdash/api/taxonomies/...Categories, tags, custom taxonomies
    /_emdash/api/plugins/[pluginId]/[...path]Plugin-defined API routes

    The route injector is the complete inventory, including authentication, comments, search, imports, widgets, and other route families.

  • Generates virtual modules so the bundler can resolve configuration and extension code:

    ModulePurpose
    virtual:emdash/configDatabase, storage, and site configuration
    virtual:emdash/dialectDatabase dialect factory
    virtual:emdash/admin-registryStatic imports for plugin admin interfaces
    virtual:emdash/pluginsConfigured plugin implementations
    virtual:emdash/media-providersConfigured external media providers

    virtual-modules.ts defines the remaining runtime helpers and generated module contents.

  • Provides the Live Content Collections loader and registers the runtime middleware. At request time, the middleware opens the configured database and storage connections and applies any pending migrations before routes use them.

Database-first schema

Schema definitions live in the database, not in a static configuration file. _emdash_collections stores one row per collection. Its core columns describe the collection and the features that the runtime and admin expose:

ColumnsPurpose
id, slugStable collection identity
label, label_singular, description, iconNames and guidance shown to editors
supports, has_seo, comments_enabled, edit_lockingOptional collection capabilities
title_field, date_field, admin_config, hidden, sort_orderAdmin list and navigation behavior
url_pattern, routablePublic URL and slug behavior
sourceHow the collection was created

The source value records provenance such as manual, seed, template:<name>, import:<name>, or discovered. Additional settings come from registered migrations, so database/types.ts and the migrations are the current column inventory.

_emdash_fields stores the fields linked to each collection:

ColumnsPurpose
id, collection_id, slugField identity and owning collection
label, type, column_typeEditor label, EmDash field type, and SQL storage type
required, unique, default_value, validationContent constraints and defaults
widget, options, sort_orderEditor control and display order
searchable, indexed, translatableSearch, query, and localization behavior

collection_id references _emdash_collections.id, and each field slug is unique within its collection.

Per-collection content tables

Each collection gets its own table, prefixed ec_. A products collection with title and price fields produces a table with this shape:

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

Real columns give each field a database type, allow indexes and foreign keys, and let database tools inspect the schema without decoding a content JSON blob. The unique constraint allows translations to share a slug while keeping each slug unique within a locale. All locale variants of the same entry share a translation_group value, which lets EmDash find the rows that are translations of one another.

The main data concerns remain separate:

ConcernLocationTables
SchemaSystem tables_emdash_collections, _emdash_fields
ContentPer-collection tablesec_posts, ec_products, …
MediaSeparate table + storagemedia table + configured storage
SettingsOptions tableoptions with a site: prefix

Runtime schema changes

Adding a field through the admin UI runs these steps:

  1. Insert the field definition into _emdash_fields.
  2. Add the corresponding column to the collection’s ec_* table and create an index when the field is configured as indexed.
  3. Refresh generated development types so the new field appears in editor tooling.

Content validation reads the current field definitions and builds a Zod schema when content is created or updated. Changing a field’s underlying SQL type, required or unique constraint, or localization behavior can require a manual content migration; SchemaRegistry rejects unsupported in-place changes instead of rebuilding the table implicitly.

Runtime validation

EmDash derives a Zod schema from the collection’s current fields. The generator delegates the type and constraint details to 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);
}

The content handler also rejects unknown fields, checks required string values, and verifies references to other collections.

Data layer

EmDash uses Kysely for typed SQL across SQLite, libSQL, Cloudflare D1, and PostgreSQL. The site configuration selects the database adapter; the integration exposes its dialect factory through virtual:emdash/dialect.

Live Content Collections loader

Content is served at runtime through Astro’s Live Content Collections. emdashLoader() implements Astro’s LiveLoader interface and is registered as a single _emdash collection:

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

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

The single _emdash collection wraps every EmDash collection. getEmDashCollection("posts") supplies the posts type filter, and the loader maps it to the ec_posts table.

Request paths

A content request from an Astro page follows this path:

  1. The page calls getEmDashCollection() or getEmDashEntry().
  2. The query wrapper calls Astro’s getLiveCollection() or getLiveEntry() with the internal _emdash collection and the requested EmDash collection type.
  3. emdashLoader() queries the relevant ec_* table through Kysely, applying publication, locale, filter, ordering, and pagination rules.
  4. The query wrapper maps rows to Astro entries and loads their bylines and taxonomy terms.
  5. The Astro component renders the returned entries.

Preview and edit-mode state travels through the request context, so the same query functions can return draft content after middleware verifies the request.

An admin API request follows a separate path:

  1. Middleware authenticates the request and stores the resolved user in Astro.locals.
  2. The API route parses the request and checks the permission needed for that operation.
  3. The route delegates business logic to a handler or repository.
  4. The handler runs plugin lifecycle hooks around the database operation when that operation exposes hooks.
  5. The route returns a standard JSON success or error response to the admin application.

Admin panel internals

The admin is a React single-page application. Astro serves its shell and the authentication middleware protects admin routes. Inside the application, TanStack Router handles navigation, TanStack Query loads server state, TanStack Table renders data grids, React Hook Form and Zod manage forms, TipTap edits Portable Text, and Kumo supplies the design system.

For session authentication, the middleware redirects an unauthenticated browser request to the login page and returns a JSON error for an unauthenticated API request. After it loads an active user, it places that user on Astro.locals for the route:

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

After this branch, the middleware loads the user, rejects missing or disabled accounts, places the active user on Astro.locals, and continues to the route.

Manifest-driven UI

The admin does not hardcode collection schemas or plugin contributions. It fetches GET /_emdash/api/manifest, which describes the current collections, fields, plugins, taxonomies, authentication mode, and other configured capabilities. An abridged manifest looks like this:

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

The admin uses the manifest to build collection navigation and field editors. Because the endpoint reads the live schema, collection and field changes appear without rebuilding the admin application.

Plugin admin UIs

Configured plugin admin entry points are collected into virtual:emdash/admin-registry. The generated module uses static imports so the bundler can include the React components:

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

export const pluginAdmins = { seo: pluginAdmin0 };

Rich text conversion

Portable Text fields use TipTap, which is based on ProseMirror. EmDash converts Portable Text to ProseMirror when the editor loads and converts it back to Portable Text when the entry is saved. Unknown blocks from plugins or imports are preserved as read-only placeholders instead of being discarded.

Signed uploads

Media uploads use direct-to-storage signed URLs when the storage adapter supports them and a same-origin streaming endpoint otherwise:

  1. The client requests an upload target from POST /_emdash/api/media/upload-url. EmDash creates a pending media item.
  2. The client uploads to the returned target. S3-compatible adapters can return a signed URL that bypasses application body-size limits; native R2 bindings and local storage return an EmDash streaming endpoint.
  3. The client confirms the upload with POST /_emdash/api/media/:id/confirm.
  4. EmDash validates the stored file and marks the media item as ready.

Extending the content importer

The WordPress importer uses a pluggable ImportSource interface. A source can probe a URL, analyze the available content against the current schema, and stream normalized content items:

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

The WXR source imports WordPress export files. The connector source imports directly from sites with the EmDash WordPress plugin. A separate REST source detects public WordPress sites, but directs the user to a WXR export because direct REST import is not implemented. Register another source when an importer can produce the same normalized analysis and content-item shapes.