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 con Astro, la ruta de solicitudes, generación de código. Nada de esto es necesario para usar EmDash. Si estás construyendo un sitio, lee Architecture y el Content Model en su lugar.
La integración con Astro
EmDash se ejecuta como una integración de Astro desde el paquete emdash. En tiempo de compilación:
-
Inyecta el SPA del admin y las rutas de la API REST con la API
injectRoutede Astro. Nada se copia al proyecto del usuario. Las rutas inyectadas son:Patrón de ruta Propósito /_emdash/admin/[...path]SPA del panel de administración /_emdash/api/manifestManifiesto del 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 esquemas /_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 tree-shake el código de configuración y plugins:
Módulo Propósito virtual:emdash/configConfiguración de base de datos y almacenamiento virtual:emdash/dialectFactory de dialecto de base de datos virtual:emdash/plugin-adminsImportaciones estáticas para UIs de 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, -- how it was created
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT
);
La columna source registra la procedencia: manual (UI del admin), template:<name> (archivo seed), import:wordpress (importador) o discovered (auto-detectado de 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, -- 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)
);
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 (
-- 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
);
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 campo por campo.
Las preocupaciones permanecen separadas:
| Preocupación | Ubicación | Tablas |
|---|---|---|
| Esquema | Tablas del sistema | _emdash_collections, _emdash_fields |
| Contenido | Tablas por colección | ec_posts, ec_products, … |
| Medios | Tabla separada + almacenamiento | media tabla + 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 del admin ejecuta tres pasos:
- Inserta un registro en
_emdash_fields. - Ejecuta
ALTER TABLE ec_<collection> ADD COLUMN <name> <TYPE>. - Regenera el esquema Zod usado para validación.
SQLite admite agregar, renombrar y eliminar columnas (eliminar requiere SQLite 3.35+) en tiempo de ejecución. Cambiar el tipo de una columna no es compatible directamente, por lo 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 desde las definiciones de campos 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 tipado seguro en todas las bases de datos compatibles (SQLite, libSQL, Cloudflare D1 y PostgreSQL). El dialecto es seleccionado 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 colección única _emdash envuelve cada tipo de contenido; el cargador filtra por tipo cuando se llama a getEmDashCollection("posts").
Rutas de solicitudes
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 del admin:
- El middleware valida el token de sesión.
- La ruta de la 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 al SPA del admin.
Internos del panel de administración
El admin es una isla de React. Astro sirve el shell y aplica autenticación en middleware; todo lo interno es del lado del cliente, construido sobre TanStack Router, TanStack Query, TanStack Table, React Hook Form + Zod, TipTap y Kumo (Base UI de Cloudflare + sistema de diseño Tailwind).
La ruta del shell controla el acceso en 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 impulsada por manifiesto
El admin no codifica 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 campos se generan desde este manifiesto, por lo que los cambios de esquema y plugins aparecen sin reconstrucción del admin, y los esquemas Zod permanecen del lado del servidor.
UIs de admin de plugins
Los puntos de entrada del admin de plugins se recopilan en un módulo virtual generado de importaciones estáticas para que el bundler pueda resolverlos y tree-shake:
import * as pluginAdmin0 from "@emdash-cms/plugin-seo/admin";
export const pluginAdmins = { seo: pluginAdmin0 };
Conversión de texto enriquecido
Los campos de 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 marcadores de posición de solo lectura.
Subidas firmadas
Las subidas de medios evitan los límites de tamaño de cuerpo de Worker con URLs firmadas directas al almacenamiento:
- El cliente solicita una URL de subida (
POST /api/media/upload-url). - El cliente sube directamente a la URL firmada (R2 o S3).
- 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 pluggable ImportSource. 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 e informa lo que encontró, analyze mapea tipos de publicación de origen a colecciones EmDash y marca brechas de esquema, y fetchContent transmite entradas normalizadas que el 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.