本页面面向参与 EmDash 开发的人员,而非使用 EmDash 构建网站的用户。它记录了内部机制——表布局、Astro 集成、请求路径、代码生成。使用 EmDash 不需要了解这些内容。如果你正在构建网站,请改为阅读架构和内容模型。
Astro 集成
EmDash 作为 emdash 包的 Astro 集成运行。在构建时:
-
使用 Astro 的
injectRouteAPI 注入管理 SPA 和 REST API 路由。不会向用户项目复制任何内容。注入的路径有:路径模式 用途 /_emdash/admin/[...path]管理面板 SPA /_emdash/api/manifest管理清单(集合、插件) /_emdash/api/content/[collection]内容条目 CRUD /_emdash/api/media/*媒体库操作 /_emdash/api/schema/*Schema 管理 /_emdash/api/settings站点设置 /_emdash/api/menus/*导航菜单 /_emdash/api/taxonomies/*分类、标签、自定义分类法 -
生成虚拟模块,使打包器可以解析和树摇配置代码和插件代码:
模块 用途 virtual:emdash/config数据库和存储配置 virtual:emdash/dialect数据库方言工厂 virtual:emdash/plugin-admins插件管理 UI 的静态导入 -
提供 Live Collections 加载器,管理迁移,并打开存储连接。
数据库优先 Schema
Schema 定义存在于数据库中,而非代码中。两个系统表追踪结构。
_emdash_collections 每个集合保存一行:
CREATE TABLE _emdash_collections (
id TEXT PRIMARY KEY,
slug TEXT UNIQUE NOT NULL, -- "posts", "products"
label TEXT NOT NULL, -- "Blog Posts"
label_singular TEXT, -- "Post"
description TEXT,
icon TEXT,
supports JSON, -- ["drafts", "revisions", "preview"]
source TEXT, -- 创建方式
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT
);
source 列记录来源:manual(管理 UI)、template:<name>(种子文件)、import:wordpress(导入器)或 discovered(从现有表自动检测)。
_emdash_fields 每个字段保存一行,链接到其集合:
CREATE TABLE _emdash_fields (
id TEXT PRIMARY KEY,
collection_id TEXT REFERENCES _emdash_collections(id),
slug TEXT NOT NULL, -- 列名
label TEXT NOT NULL,
type TEXT NOT NULL, -- 字段类型
column_type TEXT NOT NULL, -- TEXT, REAL, INTEGER, JSON
required INTEGER DEFAULT 0,
unique_field INTEGER DEFAULT 0,
default_value TEXT,
validation JSON,
widget TEXT,
options JSON,
sort_order INTEGER,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
UNIQUE(collection_id, slug)
);
每个集合的内容表
每个集合获得自己的表,前缀为 ec_。具有 title 和 price 字段的 products 集合产生:
CREATE TABLE ec_products (
-- 系统列,始终存在
id TEXT PRIMARY KEY,
slug TEXT UNIQUE,
status TEXT DEFAULT 'draft',
author_id TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
published_at TEXT,
deleted_at TEXT, -- 软删除
version INTEGER DEFAULT 1, -- 乐观锁
-- 内容列,来自字段定义
title TEXT NOT NULL,
price REAL
);
真实列(而非一张带有 JSON blob 的表)提供了正确的索引、可用的外键、数据库工具可以检查的 schema,以及无需逐字段 JSON 解析。
关注点保持分离:
| 关注点 | 位置 | 表 |
|---|---|---|
| Schema | 系统表 | _emdash_collections, _emdash_fields |
| 内容 | 每个集合的表 | ec_posts, ec_products, … |
| 媒体 | 独立表 + 存储 | media 表 + R2/S3 |
| 设置 | 选项表 | 带 site: 前缀的 options |
运行时 Schema 变更
通过管理 UI 添加字段会执行三个步骤:
- 在
_emdash_fields中插入记录。 - 执行
ALTER TABLE ec_<collection> ADD COLUMN <name> <TYPE>。 - 重新生成用于验证的 Zod schema。
SQLite 在运行时支持添加、重命名和删除列(删除需要 SQLite 3.35+)。不支持直接更改列的类型,因此 EmDash 透明地重建表:创建新表、复制行、删除旧表、重命名新表。
运行时验证
EmDash 在启动时从字段定义构建 Zod schema,并对每次创建和更新进行验证:
function buildSchema(fields: Field[]): ZodSchema {
const shape: Record<string, ZodType> = {};
for (const field of fields) {
let zodType = fieldTypeToZod(field.type);
if (field.required) zodType = zodType.required();
if (field.validation?.min !== undefined) zodType = zodType.min(field.validation.min);
shape[field.slug] = zodType;
}
return z.object(shape);
}
数据层
EmDash 使用 Kysely 在所有支持的数据库(SQLite、libSQL、Cloudflare D1 和 PostgreSQL)上提供类型安全的 SQL。方言由 virtual:emdash/dialect 从站点传递给集成的配置中选择。
Live Collections 加载器
内容在运行时通过 Astro 的 Live Collections 提供。emdashLoader() 实现 Astro 的 LiveLoader 接口,并注册为单个 _emdash 集合:
import { defineLiveCollection } from "astro:content";
import { emdashLoader } from "emdash/runtime";
export const collections = {
_emdash: defineLiveCollection({ loader: emdashLoader() }),
};
单个 _emdash 集合包装每种内容类型;当调用 getEmDashCollection("posts") 时,加载器按类型过滤。
请求路径
来自页面的内容请求:
- Astro 接收请求并运行页面组件。
getEmDashCollection()调用 Astro 的getLiveCollection()。emdashLoader通过 Kysely 查询相关的ec_*表。- 行被映射为 Astro 的条目格式(
id、slug、data)。 - 组件渲染。
管理请求:
- 中间件验证会话令牌。
- API 路由通过仓库执行 CRUD。
- 生命周期钩子触发(例如
content:beforeSave)。 - Kysely 执行 SQL。
- 路由向管理 SPA 返回 JSON。
管理面板内部
管理面板是一个 React 岛屿。Astro 提供外壳并在中间件中强制执行身份验证;内部一切都是客户端的,基于 TanStack Router、TanStack Query、TanStack Table、React Hook Form + Zod、TipTap 和 Kumo(Cloudflare 的 Base UI + Tailwind 设计系统)构建。
外壳路由在中间件中控制访问:
export async function onRequest({ request, locals }, next) {
const session = await getSession(request);
if (request.url.includes("/_emdash/admin")) {
if (!session?.user) return redirect("/_emdash/admin/login");
locals.user = session.user;
}
return next();
}
清单驱动的 UI
管理面板不会硬编码任何关于集合或插件的内容。它获取 GET /_emdash/api/manifest,返回请求用户可以访问的集合、插件和分类法,按角色过滤:
{
"collections": [
{
"slug": "posts",
"label": "Blog Posts",
"icon": "file-text",
"supports": ["drafts", "revisions", "preview"],
"fields": [{ "slug": "title", "type": "string", "required": true }]
}
],
"plugins": [{ "id": "audit-log", "label": "Audit Log" }],
"taxonomies": [{ "name": "category", "label": "Categories", "hierarchical": true }],
"version": "abc123"
}
导航、表单和字段编辑器从这个清单生成,因此 schema 和插件更改无需管理面板重建即可显示,Zod schema 保留在服务器端。
插件管理 UI
插件管理入口点被收集到一个生成的静态导入虚拟模块中,使打包器可以解析和树摇它们:
import * as pluginAdmin0 from "@emdash-cms/plugin-seo/admin";
export const pluginAdmins = { seo: pluginAdmin0 };
富文本转换
Portable Text 字段在 TipTap(ProseMirror)中编辑。内容在加载和保存边界由 portableTextToProsemirror() 和 prosemirrorToPortableText() 转换。来自插件或导入的未知块作为只读占位符保留。
签名上传
媒体上传在适配器支持时使用直接到存储的签名 URL,否则使用同源流式端点:
- 客户端请求上传 URL(
POST /api/media/upload-url)。 - 客户端上传到返回的目标。兼容 S3 的适配器可以返回绕过 Worker 主体大小限制的签名 URL;原生 R2 绑定和本地存储返回 EmDash 流式端点。
- 客户端确认(
POST /api/media/:id/confirm)。 - 服务器提取元数据(尺寸、MIME 类型)。
扩展内容导入器
WordPress 导入器建立在可插拔的 ImportSource 接口上。自定义源实现 probe、analyze 和 fetch:
interface ImportSource {
probe(input: ImportInput): Promise<ProbeResult>;
analyze(input: ImportInput): Promise<AnalysisResult>;
fetchContent(input: ImportInput): AsyncIterable<NormalizedEntry>;
}
probe 验证输入并报告发现的内容,analyze 将源文章类型映射到 EmDash 集合并标记 schema 差距,fetchContent 流式传输导入管道通过管理面板使用的相同仓库写入的规范化条目。内置源涵盖 WordPress WXR、WordPress.com 和 WordPress REST API;注册自定义源以从其他系统导入。