Communitygithub.com

reason-machines/trending-skills - EmDash CMS

EmDash CMS를 사용한 전체 스택 TypeScript CMS 구축을 위한 AI 코딩 에이전트 스킬입니다. EmDash는 Astro와 Cloudflare를 기반으로 구축되었습니다.

trending-skills - EmDash CMS란 무엇인가요?

이 스킬은 AI 코딩 에이전트(예: Claude Code, Cursor, Codex)가 EmDash CMS를 사용하여 웹사이트와 애플리케이션을 구축할 수 있도록 설계되었습니다. EmDash는 WordPress의 정신적 후계자로, 확장 가능하고 개발자 친화적이며, 플러그인이 파일 시스템/데이터베이스에 완전히 접근하는 대신 샌드박스 처리된 Worker 격리 환경에서 실행되는 플러그인 시스템을 갖추고 있습니다. 이 스킬은 새로운 프로젝트 스캐폴딩, 기존 Astro 프로젝트에 EmDash 통합, CLI 명령 실행, 다양한 데이터베이스(D1, SQLite, PostgreSQL, Turso) 및 스토리지 백엔드(R2, 로컬 파일) 구성, Portable Text 렌더링, 플러그인 개발, WordPress 마이그레이션 등을 위한 지침을 제공합니다. Cloudflare와 Node.js/SQLite를 모두 지원하므로 개발자가 무료 티어에서 시작하거나 로컬에서 개발할 수 있습니다. 또한 AI 도구가 검색 및 탐색을 위해 EmDash의 MCP 서버와 상호 작용할 수 있도록 지원하여 에이전트가 CMS 데이터를 쿼리하고 표시할 수 있도록 합니다.

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

즐겨 사용하는 AI에게 물어보기

이 에이전트 스킬이 미리 로드된 새 채팅을 엽니다.

미리보기

스킬 README에서

trending-skills - EmDash CMS preview

문서

EmDash CMS

ara.so의 스킬 — Daily 2026 Skills 컬렉션.

EmDash는 Astro와 Cloudflare를 기반으로 구축된 전체 스택 TypeScript CMS입니다. 이는 WordPress의 정신적 후계자로, 확장 가능하고 개발자 친화적이며, 플러그인이 파일 시스템/데이터베이스에 완전히 접근하는 대신 샌드박스 처리된 Worker 격리 환경에서 실행되는 플러그인 시스템을 갖추고 있습니다. EmDash는 리치 텍스트를 HTML 대신 Portable Text(구조화된 JSON)로 저장하고, 패스키 우선 인증을 지원하며, Cloudflare(D1 + R2 + Workers) 또는 SQLite를 사용한 모든 Node.js 서버에서 실행됩니다.


설치

새 프로젝트 스캐폴딩

npm create emdash@latest

프롬프트에 따라 템플릿(블로그, 마케팅, 포트폴리오, 스타터, 빈 프로젝트)과 플랫폼(Cloudflare 또는 Node.js/SQLite)을 선택합니다.

Cloudflare에 직접 배포

README의 원클릭 배포 버튼을 사용하거나 다음 명령어를 실행합니다:

npm create emdash@latest -- --template blog-cloudflare
cd my-site
npm run deploy

기존 Astro 프로젝트에 EmDash 추가

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

Node.js + SQLite (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" }),
    }),
  ],
});

주요 CLI 명령어

# 새 EmDash 프로젝트 스캐폴딩
npm create emdash@latest

# 라이브 스키마에서 TypeScript 타입 생성
npx emdash types

# 데모 사이트에 샘플 콘텐츠 시드
npx emdash seed

# 데이터베이스 마이그레이션 실행
npx emdash migrate

# 개발 서버 시작 (표준 Astro 명령어)
npx astro dev

# 프로덕션 빌드
npx astro build

# 관리자 패널 열기 (개발 서버 시작 후)
open http://localhost:4321/_emdash/admin

모노레포 / 기여자 명령어

pnpm install
pnpm build
pnpm test          # 모든 테스트 실행
pnpm typecheck     # TypeScript 검사
pnpm lint:quick    # 빠른 린트 (< 1초)
pnpm format        # oxfmt으로 포맷팅

# 데모 실행 (Node.js + SQLite, Cloudflare 불필요)
pnpm --filter emdash-demo seed
pnpm --filter emdash-demo dev

구성

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"
    }
  ],
  // 샌드박스 플러그인을 비활성화하려면 이 블록을 제거하세요 (무료 계정)
  "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,
})

콘텐츠 쿼리

콘텐츠 유형은 관리자 UI에서 정의됩니다 (코드 불필요). 컬렉션을 만든 후 타입을 생성합니다:

npx emdash types

이 명령어는 src/emdash.d.ts에 타입 정의를 작성합니다.

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>

단일 항목 가져오기

---
// 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>

페이지네이션

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

분류별 필터링

---
import { getEmDashCollection } from "emdash";

const { entries: posts } = await getEmDashCollection("posts", {
  filter: {
    status: "published",
    tags: { contains: "typescript" },
  },
});
---

Portable Text 렌더링

EmDash는 리치 텍스트를 HTML이 아닌 Portable Text(구조화된 JSON)로 저장합니다.

---
import { renderPortableText } from "emdash";

const post = await getEmDashEntry("posts", { slug: Astro.params.slug });
const { Content } = await renderPortableText(post.data.body);
---

<Content />

사용자 정의 블록 렌더러

// 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 />

플러그인 개발

플러그인은 기본 확장 메커니즘입니다. Cloudflare에서는 선언된 기능 매니페스트와 함께 샌드박스 처리된 Worker 격리 환경에서 실행됩니다. Node.js에서는 인프로세스(안전 모드)로 실행됩니다.

최소 플러그인

// 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: {},
  });

콘텐츠 훅 및 이메일이 포함된 플러그인

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.`,
        });
      },
    },
  });

KV 스토리지 및 관리자 설정이 포함된 플러그인

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",
      },
    ],
  });

사용자 정의 블록 유형이 포함된 플러그인

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: {},
  });

사용자 정의 API 라우트가 포함된 플러그인

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: {},
  });

사용 가능한 기능

기능허용되는 작업
read:content게시된 콘텐츠 읽기
write:content콘텐츠 생성 및 업데이트
read:users사용자 프로필 읽기
email:send구성된 제공자를 통해 이메일 보내기
kv:read플러그인의 KV 네임스페이스에서 읽기
kv:write플러그인의 KV 네임스페이스에 쓰기
http:fetch아웃바운드 HTTP 요청
storage:read미디어 스토리지에서 읽기
storage:write미디어 스토리지에 쓰기

사용 가능한 훅

// 콘텐츠 생명주기
"content:beforeSave"
"content:afterSave"
"content:beforeDelete"
"content:afterDelete"
"content:beforeRender"

// 인증 생명주기
"auth:afterLogin"
"auth:afterLogout"
"auth:afterRegister"

// 미디어 생명주기
"media:beforeUpload"
"media:afterUpload"
"media:beforeDelete"

// 관리자 생명주기
"admin:init"

인증

EmDash는 패스키 우선(WebAuthn) 인증을 사용하며 OAuth 및 매직 링크 폴백을 지원합니다.

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

매직 링크 이메일 구성

auth: {
  providers: [
    magicLink({
      from: "[email protected]",
      // 구성된 이메일 어댑터 사용
    }),
  ],
}

페이지 보호

---
// src/pages/dashboard.astro
import { requireAuth } from "emdash/auth";

const user = await requireAuth(Astro);
// 인증되지 않은 경우 /login으로 리디렉션
---

<p>환영합니다, {user.name}님!</p>

현재 사용자 가져오기 (선택 사항)

---
import { getUser } from "emdash/auth";

const user = await getUser(Astro); // 로그인하지 않은 경우 null
---

{user ? <p>안녕하세요 {user.name}님</p> : <a href="/login">로그인</a>}

역할

import { requireRole } from "emdash/auth";

// API 라우트 또는 페이지에서
const user = await requireRole(Astro, "editor");
// 역할: "administrator" | "editor" | "author" | "contributor"

WordPress 마이그레이션

WXR 내보내기 파일에서 가져오기

npx emdash import:wordpress ./export.xml

WordPress REST API에서 가져오기

npx emdash import:wordpress --url https://yoursite.wordpress.com --api-key $WP_API_KEY

WordPress.com에서 가져오기

npx emdash import:wordpress --wpcom --site yoursite.wordpress.com

임포터는 게시글, 페이지, 미디어 첨부 파일, 카테고리, 태그, 작성자, 댓글을 마이그레이션합니다. Gutenberg 블록은 gutenberg-to-portable-text 패키지를 통해 Portable Text로 변환됩니다.


콘텐츠 스키마 (관리자 UI)

콘텐츠 유형은 코드가 아닌 /_emdash/admin의 관리자 패널에서 생성됩니다. 컬렉션을 생성하거나 수정한 후 TypeScript 타입을 다시 생성합니다:

npx emdash types
# src/emdash.d.ts에 작성

스키마 빌더에서 사용 가능한 필드 유형

  • text — 짧은 문자열
  • richText — Portable Text (TipTap 편집기)
  • number — 정수 또는 부동 소수점
  • boolean — true/false 토글
  • date / datetime — 날짜 선택기
  • select — 단일 선택 드롭다운
  • multiSelect — 다중 선택
  • image — 미디어 라이브러리 선택기
  • file — 파일 첨부
  • relation — 다른 컬렉션 항목에 대한 링크
  • slug — URL에 안전한 문자열, 소스 필드에서 자동 생성
  • json — 원시 JSON

MCP 서버 (AI 도구 통합)

EmDash에는 MCP 서버가 내장되어 있어 AI 도구와의 통합을 지원합니다.

관련 스킬