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
injectRoutede Astro. Nada se copia en el proyecto del usuario. Las rutas inyectadas son:Patrón de ruta Propó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ódulo Propó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:
| Responsabilidad | Ubicación | Tablas |
|---|---|---|
| Esquema | Tablas del sistema | _emdash_collections, _emdash_fields |
| Contenido | Tablas por colección | ec_posts, ec_products, … |
| Medios | Tabla separada + almacenamiento | Tabla media + R2/S3 |
| Configuración | Tabla de opciones | options con prefijo site: |
Cambios de esquema en tiempo de ejecución
Agregar un campo a través de la UI admin ejecuta tres pasos:
- Insertar un registro en
_emdash_fields. - Ejecutar
ALTER TABLE ec_<collection> ADD COLUMN <name> <TYPE>. - 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:
- Astro recibe la solicitud y ejecuta el componente de página.
getEmDashCollection()llama agetLiveCollection()de Astro.emdashLoaderconsulta la tablaec_*relevante a través de Kysely.- Las filas se mapean al formato de entrada de Astro (
id,slug,data). - El componente renderiza.
Una solicitud admin:
- El middleware valida el token de sesión.
- La ruta API ejecuta CRUD a través de un repositorio.
- Los hooks de ciclo de vida se disparan (por ejemplo
content:beforeSave). - Kysely ejecuta el SQL.
- 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:
- El cliente solicita una URL de subida (
POST /api/media/upload-url). - 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.
- El cliente confirma (
POST /api/media/:id/confirm). - 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.