EmDash CMS
ara.so によるスキル — Daily 2026 Skills コレクション。
EmDash は、Astro と Cloudflare 上に構築されたフルスタック TypeScript CMS です。WordPress の精神的な後継者として、拡張性が高く、開発者にとって使いやすく、ファイルシステムやデータベースへのフルアクセスではなく、サンドボックス化された Worker 分離環境でプラグインを実行するプラグインシステムを採用しています。EmDash はリッチテキストを HTML ではなく Portable Text (構造化 JSON) として保存し、passkey ファーストの認証をサポートし、Cloudflare (D1 + R2 + Workers) または任意の Node.js サーバー + SQLite 上で動作します。
インストール
新しいプロジェクトをスキャフォールディングする
npm create emdash@latest
プロンプトに従って、テンプレート (blog、marketing、portfolio、starter、blank) とプラットフォーム (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 ローダー)
// 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 は passkey ファースト (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 には、AI エージェントが EmDash プロジェクトと対話できるようにする組み込みの MCP サーバーが含まれています。npx emdash mcp を実行して起動します。詳細については、EmDash MCP ドキュメント を参照してください。