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 도구와의 통합을 지원합니다.