Communitygithub.com

EmDash CMS (tendencia/ara.so)

Habilidad para agentes de codificación de IA para construir con EmDash, el CMS TypeScript full-stack construido sobre Astro y Cloudflare.

¿Qué es ara.so)?

EmDash es un CMS TypeScript full-stack construido sobre Astro y Cloudflare, considerado el sucesor espiritual de WordPress. Es extensible, amigable para desarrolladores y cuenta con un sistema de plugins que se ejecutan en entornos aislados de Workers (sandboxing) en lugar de tener acceso completo al sistema de archivos o base de datos. Almacena texto enriquecido como Portable Text (JSON estructurado) en vez de HTML, soporta autenticación basada en passkeys (WebAuthn) y se ejecuta en Cloudflare (D1 + R2 + Workers) o en cualquier servidor Node.js con SQLite. Esta habilidad está diseñada para ser utilizada con agentes de IA como Claude Code, Cursor o Codex, y documenta la instalación, configuración, consulta de contenido, renderizado de Portable Text, desarrollo de plugins, migración desde WordPress y el servidor MCP integrado para integración con herramientas de IA. La habilidad abarca desde el andamiaje de proyectos hasta la implementación de plugins personalizados con capacidades y ganchos específicos, facilitando la creación de sitios web modernos, seguros y escalables con un enfoque centrado en el desarrollador.

Compatible con✓Claude Code~Codex CLI~Cursor
npx skills add https://github.com/reason-machines/trending-skills/tree/HEAD/skills/emdash-cms

Preguntar en tu IA favorita

Abre un nuevo chat con esta habilidad de agente ya precargada.

Vista previa

Del README del skill

ara.so) preview

Documentación

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

CapacidadQué permite
read:contentLeer contenido publicado
write:contentCrear y actualizar contenido
read:usersLeer perfiles de usuario
email:sendEnviar correo electrónico a través del proveedor configurado
kv:readLeer del espacio de nombres KV del plugin
kv:writeEscribir en el espacio de nombres KV del plugin
http:fetchRealizar solicitudes HTTP salientes
storage:readLeer del almacenamiento de medios
storage:writeEscribir 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 corta
  • richText — Portable Text (editor TipTap)
  • number — entero o flotante
  • boolean — alternar verdadero/falso
  • date / datetime — selectores de fecha
  • select — menú desplegable de selección única
  • multiSelect — selección múltiple
  • image — selector de biblioteca multimedia
  • file — archivo adjunto
  • relation — enlace a otra entrada de la colección
  • slug — cadena segura para URL, autogenerada desde un campo de origen
  • json — JSON sin procesar

Servidor MCP (Integración con Herramientas de IA)

EmDash incluye un servidor MCP incorporado

Skills relacionados