EmDash CMS
Habilidad de ara.so — Colección Daily 2026 Skills.
EmDash es un CMS TypeScript full-stack construido sobre Astro y Cloudflare. Es el sucesor espiritual de WordPress: extensible, amigable para desarrolladores y potenciado por un sistema de plugins que ejecuta plugins en entornos aislados de Workers sin acceso completo al sistema de archivos/base de datos. EmDash almacena texto enriquecido como Portable Text (JSON estructurado) en lugar de HTML, soporta autenticación basada en passkeys y se ejecuta en Cloudflare (D1 + R2 + Workers) o en cualquier servidor Node.js con SQLite.
Instalación
Scaffold de un nuevo proyecto
npm create emdash@latest
Sigue las indicaciones para elegir una plantilla (blog, marketing, portafolio, inicio, en blanco) y una plataforma (Cloudflare o Node.js/SQLite).
Despliegue directo en Cloudflare
Usa el botón de despliegue con un solo clic del README, o:
npm create emdash@latest -- --template blog-cloudflare
cd my-site
npm run deploy
Añadir EmDash a un proyecto Astro existente
npm install emdash
// astro.config.mjs
import { defineConfig } from "astro/config";
import emdash from "emdash/astro";
import { d1 } from "emdash/db";
export default defineConfig({
integrations: [
emdash({
database: d1(), // Cloudflare D1
}),
],
});
Para Node.js + SQLite (no se necesita cuenta de Cloudflare):
// astro.config.mjs
import { defineConfig } from "astro/config";
import emdash from "emdash/astro";
import { sqlite } from "emdash/db";
export default defineConfig({
integrations: [
emdash({
database: sqlite({ path: "./content.db" }),
}),
],
});
Comandos CLI principales
# Scaffold de un nuevo proyecto EmDash
npm create emdash@latest
# Generar tipos TypeScript a partir del esquema activo
npx emdash types
# Poblar el sitio demo con contenido de ejemplo
npx emdash seed
# Ejecutar migraciones de base de datos
npx emdash migrate
# Iniciar el servidor de desarrollo (comando estándar de Astro)
npx astro dev
# Construir para producción
npx astro build
# Abrir el panel de administración (después de iniciar el servidor de desarrollo)
abre http://localhost:4321/_emdash/admin
Comandos para monorepos / colaboradores
pnpm install
pnpm build
pnpm test # ejecutar todos los tests
pnpm typecheck # comprobación TypeScript
pnpm lint:quick # lint rápido (< 1s)
pnpm format # formatear con oxfmt
# Ejecutar la demo (Node.js + SQLite, sin Cloudflare)
pnpm --filter emdash-demo seed
pnpm --filter emdash-demo dev
Configuración
Cloudflare (D1 + R2 + KV + Worker Loaders)
// wrangler.jsonc
{
"name": "my-emdash-site",
"compatibility_date": "2025-01-01",
"d1_databases": [
{
"binding": "DB",
"database_name": "emdash-content",
"database_id": "$DATABASE_ID"
}
],
"r2_buckets": [
{
"binding": "MEDIA",
"bucket_name": "emdash-media"
}
],
"kv_namespaces": [
{
"binding": "SESSIONS",
"id": "$KV_NAMESPACE_ID"
}
],
// Eliminar este bloque para deshabilitar plugins aislados (cuentas gratuitas)
"worker_loaders": [
{
"binding": "PLUGIN_LOADER"
}
]
}
// astro.config.mjs
import emdash from "emdash/astro";
import { d1 } from "emdash/db";
import { r2 } from "emdash/storage";
import { kv } from "emdash/sessions";
export default defineConfig({
integrations: [
emdash({
database: d1({ binding: "DB" }),
storage: r2({ binding: "MEDIA" }),
sessions: kv({ binding: "SESSIONS" }),
}),
],
});
Node.js + SQLite
// astro.config.mjs
import emdash from "emdash/astro";
import { sqlite } from "emdash/db";
import { localFiles } from "emdash/storage";
export default defineConfig({
integrations: [
emdash({
database: sqlite({ path: "./content.db" }),
storage: localFiles({ dir: "./public/uploads" }),
}),
],
});
PostgreSQL / Turso
import { postgres } from "emdash/db";
import { turso } from "emdash/db";
// PostgreSQL
database: postgres({ url: process.env.DATABASE_URL })
// Turso/libSQL
database: turso({
url: process.env.TURSO_DATABASE_URL,
authToken: process.env.TURSO_AUTH_TOKEN,
})
Consulta de Contenido
Los tipos de contenido se definen en la interfaz de administración (sin necesidad de código). Después de crear una colección, genera los tipos:
npx emdash types
Esto escribe las definiciones de tipo en src/emdash.d.ts.
Obtener una colección en una página Astro
---
// src/pages/blog/index.astro
import { getEmDashCollection } from "emdash";
const { entries: posts } = await getEmDashCollection("posts", {
filter: { status: "published" },
sort: { field: "publishedAt", direction: "desc" },
limit: 10,
});
---
<ul>
{posts.map((post) => (
<li>
<a href={`/blog/${post.data.slug}`}>{post.data.title}</a>
<time datetime={post.data.publishedAt}>{post.data.publishedAt}</time>
</li>
))}
</ul>
Obtener una sola entrada
---
// src/pages/blog/[slug].astro
import { getEmDashEntry, renderPortableText } from "emdash";
const { slug } = Astro.params;
const post = await getEmDashEntry("posts", { slug });
if (!post) return Astro.redirect("/404");
const { Content } = await renderPortableText(post.data.body);
---
<article>
<h1>{post.data.title}</h1>
<Content />
</article>
Paginación
---
import { getEmDashCollection } from "emdash";
const page = Number(Astro.params.page ?? 1);
const { entries: posts, total } = await getEmDashCollection("posts", {
filter: { status: "published" },
sort: { field: "publishedAt", direction: "desc" },
limit: 10,
offset: (page - 1) * 10,
});
---
Filtrado por taxonomía
---
import { getEmDashCollection } from "emdash";
const { entries: posts } = await getEmDashCollection("posts", {
filter: {
status: "published",
tags: { contains: "typescript" },
},
});
---
Renderizado de Portable Text
EmDash almacena texto enriquecido como Portable Text (JSON estructurado), no HTML.
---
import { renderPortableText } from "emdash";
const post = await getEmDashEntry("posts", { slug: Astro.params.slug });
const { Content } = await renderPortableText(post.data.body);
---
<Content />
Renderizadores de bloques personalizados
// src/portable-text.ts
import { definePortableTextComponents } from "emdash/blocks";
export const components = definePortableTextComponents({
types: {
callout: ({ value }) => `
<div class="callout callout--${value.type}">
${value.text}
</div>
`,
image: ({ value }) => `
<figure>
<img src="${value.url}" alt="${value.alt ?? ""}" />
${value.caption ? `<figcaption>${value.caption}</figcaption>` : ""}
</figure>
`,
},
marks: {
highlight: ({ children }) => `<mark>${children}</mark>`,
},
});
---
import { renderPortableText } from "emdash";
import { components } from "../portable-text";
const { Content } = await renderPortableText(post.data.body, { components });
---
<Content />
Desarrollo de Plugins
Los plugins son el mecanismo de extensión principal. En Cloudflare, se ejecutan en entornos aislados de Workers con un manifiesto de capacidades declarado. En Node.js, se ejecutan en el mismo proceso (modo seguro).
Plugin mínimo
// plugins/my-plugin/index.ts
import { definePlugin } from "emdash/plugin";
export default () =>
definePlugin({
id: "my-plugin",
name: "My Plugin",
version: "1.0.0",
capabilities: [],
hooks: {},
});
Plugin con hooks de contenido y correo electrónico
import { definePlugin } from "emdash/plugin";
export default () =>
definePlugin({
id: "notify-on-publish",
name: "Notify on Publish",
version: "1.0.0",
capabilities: ["read:content", "email:send"],
hooks: {
"content:afterSave": async (event, ctx) => {
if (event.content.status !== "published") return;
await ctx.email.send({
to: "[email protected]",
subject: `New post published: ${event.content.title}`,
text: `"${event.content.title}" is now live.`,
});
},
},
});
Plugin con almacenamiento KV y configuración de administración
import { definePlugin } from "emdash/plugin";
export default () =>
definePlugin({
id: "view-counter",
name: "View Counter",
version: "1.0.0",
capabilities: ["read:content", "kv:read", "kv:write"],
settings: {
schema: {
resetDaily: { type: "boolean", default: false, label: "Reset counts daily" },
},
},
hooks: {
"content:beforeRender": async (event, ctx) => {
const key = `views:${event.content.id}`;
const current = Number(await ctx.kv.get(key) ?? 0);
await ctx.kv.set(key, String(current + 1));
event.content.meta.views = current + 1;
},
},
adminPages: [
{
path: "/analytics",
title: "View Analytics",
component: "ViewAnalytics",
},
],
});
Plugin con tipo de bloque personalizado
import { definePlugin } from "emdash/plugin";
import { defineBlock } from "emdash/blocks";
export default () =>
definePlugin({
id: "callout-block",
name: "Callout Block",
version: "1.0.0",
capabilities: [],
blocks: [
defineBlock({
name: "callout",
title: "Callout",
fields: [
{ name: "type", type: "select", options: ["info", "warning", "danger"], default: "info" },
{ name: "text", type: "text", label: "Message" },
],
}),
],
hooks: {},
});
Plugin con ruta API personalizada
import { definePlugin } from "emdash/plugin";
export default () =>
definePlugin({
id: "newsletter",
name: "Newsletter",
version: "1.0.0",
capabilities: ["kv:write"],
apiRoutes: [
{
method: "POST",
path: "/subscribe",
handler: async (request, ctx) => {
const { email } = await request.json();
await ctx.kv.set(`subscriber:${email}`, "true");
return new Response(JSON.stringify({ ok: true }), {
headers: { "Content-Type": "application/json" },
});
},
},
],
hooks: {},
});
Capacidades disponibles
| Capacidad | Qué permite |
|---|---|
read:content | Leer contenido publicado |
write:content | Crear y actualizar contenido |
read:users | Leer perfiles de usuario |
email:send | Enviar correo electrónico a través del proveedor configurado |
kv:read | Leer del espacio de nombres KV del plugin |
kv:write | Escribir en el espacio de nombres KV del plugin |
http:fetch | Realizar solicitudes HTTP salientes |
storage:read | Leer del almacenamiento de medios |
storage:write | Escribir en el almacenamiento de medios |
Hooks disponibles
// Ciclo de vida del contenido
"content:beforeSave"
"content:afterSave"
"content:beforeDelete"
"content:afterDelete"
"content:beforeRender"
// Ciclo de vida de autenticación
"auth:afterLogin"
"auth:afterLogout"
"auth:afterRegister"
// Ciclo de vida de medios
"media:beforeUpload"
"media:afterUpload"
"media:beforeDelete"
// Ciclo de vida de administración
"admin:init"
Autenticación
EmDash utiliza autenticación basada en passkeys (WebAuthn) con respaldo de OAuth y enlaces mágicos.
Configurar proveedores OAuth
// astro.config.mjs
import emdash from "emdash/astro";
import { github, google } from "emdash/auth";
export default defineConfig({
integrations: [
emdash({
database: d1(),
auth: {
providers: [
github({
clientId: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
}),
google({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
}),
],
},
}),
],
});
Configurar enlace mágico por correo electrónico
auth: {
providers: [
magicLink({
from: "[email protected]",
// usa el adaptador de correo configurado
}),
],
}
Proteger una página
---
// src/pages/dashboard.astro
import { requireAuth } from "emdash/auth";
const user = await requireAuth(Astro);
// Redirige a /login si no está autenticado
---
<p>Bienvenido, {user.name}!</p>
Obtener el usuario actual (opcional)
---
import { getUser } from "emdash/auth";
const user = await getUser(Astro); // null si no ha iniciado sesión
---
{user ? <p>Hola {user.name}</p> : <a href="/login">Iniciar sesión</a>}
Roles
import { requireRole } from "emdash/auth";
// En una ruta API o página
const user = await requireRole(Astro, "editor");
// Roles: "administrator" | "editor" | "author" | "contributor"
Migración desde WordPress
Importar desde un archivo de exportación WXR
npx emdash import:wordpress ./export.xml
Importar desde la API REST de WordPress
npx emdash import:wordpress --url https://yoursite.wordpress.com --api-key $WP_API_KEY
Importar desde WordPress.com
npx emdash import:wordpress --wpcom --site yoursite.wordpress.com
El importador migra entradas, páginas, archivos multimedia, categorías, etiquetas, autores y comentarios. Los bloques de Gutenberg se convierten a Portable Text mediante el paquete gutenberg-to-portable-text.
Esquema de Contenido (Interfaz de Administración)
Los tipos de contenido se crean en el panel de administración en /_emdash/admin, no en el código. Después de crear o modificar una colección, regenera los tipos TypeScript:
npx emdash types
# Escribe en src/emdash.d.ts
Tipos de campo disponibles en el constructor de esquemas
text— cadena cortarichText— Portable Text (editor TipTap)number— entero o flotanteboolean— alternar verdadero/falsodate/datetime— selectores de fechaselect— menú desplegable de selección únicamultiSelect— selección múltipleimage— selector de biblioteca multimediafile— archivo adjuntorelation— enlace a otra entrada de la colecciónslug— cadena segura para URL, autogenerada desde un campo de origenjson— JSON sin procesar
Servidor MCP (Integración con Herramientas de IA)
EmDash incluye un servidor MCP incorporado