Communitygithub.com

reason-machines/trending-skills

AI 編碼代理技能,用於開發 EmDash,這是一個基於 Astro 和 Cloudflare 建構的全端 TypeScript CMS。

trending-skills 是什麼?

EmDash 是一個全端 TypeScript CMS,建構在 Astro 和 Cloudflare 之上,旨在成為 WordPress 的精神繼承者。它提供可擴展、對開發者友好的架構,並採用獨特的沙箱插件系統,不僅限於傳統的檔案系統或資料庫存取,而是在 Cloudflare Workers 隔離環境中執行插件。內容儲存採用 Portable Text(結構化 JSON)而非 HTML,支援以 Passkey 為主的無密碼驗證,並能靈活部署於 Cloudflare(D1 + R2 + Workers)或任何使用 SQLite 的 Node.js 伺服器。對於使用 AI 編碼代理的開發者,此技能能協助快速建立內容網站,並透過 MCP 伺服器整合 AI 工具,提升開發效率。

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

在你喜歡的 AI 中提問

開啟一個已預先載入此 Agent Skill 的新對話。

預覽

來自技能 README

trending-skills preview

說明文件

EmDash CMS

由 ara.so 開發的技能 — Daily 2026 Skills 系列。

EmDash 是一個全端 TypeScript CMS,建構在 Astro 和 Cloudflare 之上。它是 WordPress 的精神繼承者:可擴展、對開發者友善,並採用插件系統,在沙箱化的 Worker 隔離環境中執行插件,而非直接存取完整的檔案系統或資料庫。EmDash 將富文本儲存為 Portable Text(結構化 JSON)而非 HTML,支援以 Passkey 為主的驗證,並可在 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

# 從即時 schema 產生 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    # 快速 lint(< 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 將富文本儲存為 Portable Text(結構化 JSON),而非 HTML。

---
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: `新文章已發布:${event.content.title}`,
          text: `「${event.content.title}」已上線。`,
        });
      },
    },
  });

包含 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: "每日重置計數" },
      },
    },

    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: "瀏覽分析",
        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: "提示區塊",
        fields: [
          { name: "type", type: "select", options: ["info", "warning", "danger"], default: "info" },
          { name: "text", type: "text", label: "訊息" },
        ],
      }),
    ],

    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。


內容 Schema(管理後台 UI)

內容類型在管理後台的 /_emdash/admin 中建立——而非透過程式碼。建立或修改集合後,重新產生 TypeScript 類型:

npx emdash types
# 寫入至 src/emdash.d.ts

Schema 建構工具中可用的欄位類型

  • text — 簡短字串
  • richText — Portable Text(TipTap 編輯器)
  • number — 整數或浮點數
  • boolean — 是/否切換
  • date / datetime — 日期選擇器
  • select — 單選下拉選單
  • multiSelect — 多選
  • image — 媒體庫選擇器
  • file — 檔案附件
  • relation — 連結至另一個集合項目
  • slug — URL 安全字串,從來源欄位自動產生
  • json — 原始 JSON

MCP 伺服器(AI 工具整合)

EmDash 包含一個內建的 MCP 伺服器,用於 AI 工具整合。此伺服器允許 AI 代理(如 Claude Code 或 Cursor)直接與 CMS 互動,執行諸如內容查詢、管理操作或插件設定等任務,從而簡化開發工作流程。

相關技能