Arquitectura (internos)

En esta página

Esta página es para personas que trabajan en EmDash, no para quienes construyen un sitio con él. Documenta mecánicas internas — diseños de tablas, la integración Astro, la ruta de solicitud, generación de código. Nada de esto es necesario para usar EmDash. Si estás construyendo un sitio, lee Arquitectura y el Modelo de contenido en su lugar.

La integración Astro

EmDash se ejecuta como una integración Astro desde el paquete emdash. En tiempo de build:

  • Inyecta las rutas de la SPA admin y la API REST con la API injectRoute de Astro. Nada se copia en el proyecto del usuario. Las rutas inyectadas son:

    Patrón de rutaPropósito
    /_emdash/admin/[...path]SPA del panel admin
    /_emdash/api/manifestManifiesto admin (colecciones, plugins)
    /_emdash/api/content/[collection]CRUD de entradas de contenido
    /_emdash/api/media/*Operaciones de biblioteca de medios
    /_emdash/api/schema/*Gestión de esquema
    /_emdash/api/settingsConfiguración del sitio
    /_emdash/api/menus/*Menús de navegación
    /_emdash/api/taxonomies/*Categorías, etiquetas, taxonomías personalizadas
  • Genera módulos virtuales para que el bundler pueda resolver y hacer tree-shake del código de configuración y plugins:

    MóduloPropósito
    virtual:emdash/configConfiguración de base de datos y almacenamiento
    virtual:emdash/dialectFábrica de dialecto de base de datos
    virtual:emdash/plugin-adminsImports estáticos para UIs admin de plugins
  • Proporciona el cargador de Live Collections, gestiona migraciones y abre la conexión de almacenamiento.

Esquema database-first

Las definiciones de esquema viven en la base de datos, no en el código. Dos tablas del sistema rastrean la estructura.

_emdash_collections contiene una fila por colección:

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,                      -- cómo fue creada
  created_at TEXT DEFAULT CURRENT_TIMESTAMP,
  updated_at TEXT
);

La columna source registra la procedencia: manual (UI admin), template:<name> (archivo seed), import:wordpress (importador) o discovered (auto-detectada desde tablas existentes).

_emdash_fields contiene una fila por campo, vinculada a su colección:

CREATE TABLE _emdash_fields (
  id TEXT PRIMARY KEY,
  collection_id TEXT REFERENCES _emdash_collections(id),
  slug TEXT NOT NULL,               -- nombre de columna
  label TEXT NOT NULL,
  type TEXT NOT NULL,               -- tipo de campo
  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)
);

Tablas de contenido por colección

Cada colección obtiene su propia tabla, con prefijo ec_. Una colección products con campos title y price produce:

CREATE TABLE ec_products (
  -- Columnas del sistema, siempre presentes
  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,                  -- eliminación suave
  version INTEGER DEFAULT 1,        -- bloqueo optimista

  -- Columnas de contenido, desde definiciones de campo
  title TEXT NOT NULL,
  price REAL
);

Columnas reales (en lugar de una tabla con un blob JSON) proporcionan indexación adecuada, claves foráneas funcionales, un esquema que las herramientas de base de datos pueden inspeccionar y sin análisis JSON por campo.

Las responsabilidades se mantienen separadas:

ResponsabilidadUbicaciónTablas
EsquemaTablas del sistema_emdash_collections, _emdash_fields
ContenidoTablas por colecciónec_posts, ec_products, …
MediosTabla separada + almacenamientoTabla media + R2/S3
ConfiguraciónTabla de opcionesoptions con prefijo site:

Cambios de esquema en tiempo de ejecución

Agregar un campo a través de la UI admin ejecuta tres pasos:

  1. Insertar un registro en _emdash_fields.
  2. Ejecutar ALTER TABLE ec_<collection> ADD COLUMN <name> <TYPE>.
  3. Regenerar el esquema Zod usado para validación.

SQLite soporta agregar, renombrar y eliminar columnas (eliminar requiere SQLite 3.35+) en tiempo de ejecución. Cambiar el tipo de una columna no se soporta directamente, así que EmDash reconstruye la tabla de forma transparente: crear una nueva tabla, copiar filas, eliminar la tabla antigua, renombrar la nueva.

Validación en tiempo de ejecución

EmDash construye esquemas Zod a partir de las definiciones de campo al inicio y valida cada creación y actualización contra ellos:

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

Capa de datos

EmDash usa Kysely para SQL con seguridad de tipos en todas las bases de datos soportadas (SQLite, libSQL, Cloudflare D1 y PostgreSQL). El dialecto se selecciona por virtual:emdash/dialect desde la configuración que el sitio pasa a la integración.

Cargador de Live Collections

El contenido se sirve en tiempo de ejecución a través de las Live Collections de Astro. emdashLoader() implementa la interfaz LiveLoader de Astro y se registra como una única colección _emdash:

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

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

La única colección _emdash envuelve cada tipo de contenido; el cargador filtra por tipo cuando se llama getEmDashCollection("posts").

Rutas de solicitud

Una solicitud de contenido desde una página:

  1. Astro recibe la solicitud y ejecuta el componente de página.
  2. getEmDashCollection() llama a getLiveCollection() de Astro.
  3. emdashLoader consulta la tabla ec_* relevante a través de Kysely.
  4. Las filas se mapean al formato de entrada de Astro (id, slug, data).
  5. El componente renderiza.

Una solicitud admin:

  1. El middleware valida el token de sesión.
  2. La ruta API ejecuta CRUD a través de un repositorio.
  3. Los hooks de ciclo de vida se disparan (por ejemplo content:beforeSave).
  4. Kysely ejecuta el SQL.
  5. La ruta devuelve JSON a la SPA admin.

Internos del panel admin

El admin es una isla React. Astro sirve la shell y aplica autenticación en el middleware; todo dentro es del lado del cliente, construido sobre TanStack Router, TanStack Query, TanStack Table, React Hook Form + Zod, TipTap y Kumo (el sistema de diseño Base UI + Tailwind de Cloudflare).

La ruta shell controla el acceso en el 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();
}

UI dirigida por manifiesto

El admin no hardcodea nada sobre colecciones o plugins. Obtiene GET /_emdash/api/manifest, que devuelve las colecciones, plugins y taxonomías a las que el usuario solicitante puede acceder, filtradas por rol:

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

La navegación, formularios y editores de campo se generan desde este manifiesto, por lo que los cambios de esquema y plugins aparecen sin una reconstrucción del admin, y los esquemas Zod permanecen del lado del servidor.

UIs admin de plugins

Los puntos de entrada admin de plugins se recopilan en un módulo virtual generado de imports estáticos para que el bundler pueda resolver y hacer tree-shake de ellos:

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

export const pluginAdmins = { seo: pluginAdmin0 };

Conversión de texto enriquecido

Los campos Portable Text se editan en TipTap (ProseMirror). El contenido se convierte en los límites de carga y guardado por portableTextToProsemirror() y prosemirrorToPortableText(). Los bloques desconocidos de plugins o importaciones se preservan como placeholders de solo lectura.

Subidas firmadas

Las subidas de medios usan URLs firmadas directas al almacenamiento cuando el adaptador las soporta y un endpoint de streaming same-origin en caso contrario:

  1. El cliente solicita una URL de subida (POST /api/media/upload-url).
  2. El cliente sube al destino devuelto. Los adaptadores compatibles con S3 pueden devolver una URL firmada que omite los límites de tamaño de body del Worker; las vinculaciones nativas R2 y el almacenamiento local devuelven un endpoint de streaming EmDash.
  3. El cliente confirma (POST /api/media/:id/confirm).
  4. El servidor extrae metadatos (dimensiones, tipo MIME).

Extender el importador de contenido

El importador de WordPress está construido sobre una interfaz ImportSource conectable. Una fuente personalizada implementa probe, analyze y fetch:

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

probe valida la entrada y reporta lo que encontró, analyze mapea los tipos de post fuente a colecciones EmDash y señala brechas de esquema, y fetchContent transmite entradas normalizadas que la pipeline de importación escribe a través de los mismos repositorios que usa el admin. Las fuentes integradas cubren WordPress WXR, WordPress.com y la API REST de WordPress; registra una fuente personalizada para importar desde otro sistema.