架构(内部结构)

本页内容

本页面面向开发 EmDash 的人,而非使用它构建网站的人。它记录了内部机制——表布局、Astro 集成、请求路径、代码生成。这些内容对使用 EmDash 不是必需的。如果你正在构建网站,请改为阅读 ArchitectureContent Model

Astro 集成

EmDash 作为 Astro 集成从 emdash 包运行。在构建时它会:

  • 使用 Astro 的 injectRoute API 注入管理 SPA 和 REST API 路由。不会向用户项目复制任何内容。注入的路径包括:

    路径模式用途
    /_emdash/admin/[...path]管理面板 SPA
    /_emdash/api/manifest管理清单(集合、插件)
    /_emdash/api/content/[collection]内容条目 CRUD
    /_emdash/api/media/*媒体库操作
    /_emdash/api/schema/*架构管理
    /_emdash/api/settings网站设置
    /_emdash/api/menus/*导航菜单
    /_emdash/api/taxonomies/*分类、标签、自定义分类法
  • 生成虚拟模块,使打包器能够解析和摇树优化配置和插件代码:

    模块用途
    virtual:emdash/config数据库和存储配置
    virtual:emdash/dialect数据库方言工厂
    virtual:emdash/plugin-admins插件管理 UI 的静态导入
  • 提供 Live Collections 加载器,管理迁移,并打开存储连接。

数据库优先架构

架构定义存在于数据库中,而不是代码中。两个系统表追踪结构。

_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,                      -- how it was created
  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,               -- column name
  label TEXT NOT NULL,
  type TEXT NOT NULL,               -- field type
  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_。带有 titleprice 字段的 products 集合生成:

CREATE TABLE ec_products (
  -- System columns, always present
  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,                  -- soft delete
  version INTEGER DEFAULT 1,        -- optimistic locking

  -- Content columns, from field definitions
  title TEXT NOT NULL,
  price REAL
);

真实列(而不是带有 JSON blob 的单个表)提供适当的索引、可用的外键、数据库工具可以检查的架构,以及无需逐字段 JSON 解析。

关注点保持分离:

关注点位置
架构系统表_emdash_collections_emdash_fields
内容每个集合的表ec_postsec_products、…
媒体独立表 + 存储media 表 + R2/S3
设置选项表site: 前缀的 options

运行时架构更改

通过管理 UI 添加字段运行三个步骤:

  1. _emdash_fields 插入一条记录。
  2. 运行 ALTER TABLE ec_<collection> ADD COLUMN <name> <TYPE>
  3. 重新生成用于验证的 Zod 架构。

SQLite 支持在运行时添加、重命名和删除列(删除需要 SQLite 3.35+)。不支持就地更改列类型,因此 EmDash 透明地重建表:创建新表,复制行,删除旧表,重命名新表。

运行时验证

EmDash 在启动时从字段定义构建 Zod 架构,并针对它们验证每次创建和更新:

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") 时,加载器按类型过滤。

请求路径

来自页面的内容请求:

  1. Astro 接收请求并运行页面组件。
  2. getEmDashCollection() 调用 Astro 的 getLiveCollection()
  3. emdashLoader 通过 Kysely 查询相关的 ec_* 表。
  4. 行映射到 Astro 的条目格式(idslugdata)。
  5. 组件渲染。

管理请求:

  1. 中间件验证会话令牌。
  2. API 路由通过存储库运行 CRUD。
  3. 生命周期钩子触发(例如 content:beforeSave)。
  4. Kysely 执行 SQL。
  5. 路由向管理 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"
}

导航、表单和字段编辑器从此清单生成,因此架构和插件更改无需重建管理面板即可显示,Zod 架构保留在服务器端。

插件管理 UI

插件管理入口点被收集到生成的静态导入虚拟模块中,以便打包器可以解析和摇树优化它们:

import * as pluginAdmin0 from "@emdash-cms/plugin-seo/admin";

export const pluginAdmins = { seo: pluginAdmin0 };

富文本转换

Portable Text 字段在 TipTap(ProseMirror)中编辑。内容在加载和保存边界通过 portableTextToProsemirror()prosemirrorToPortableText() 转换。来自插件或导入的未知块被保留为只读占位符。

签名上传

媒体上传通过直接到存储的签名 URL 绕过 Worker 主体大小限制:

  1. 客户端请求上传 URL(POST /api/media/upload-url)。
  2. 客户端直接上传到签名 URL(R2 或 S3)。
  3. 客户端确认(POST /api/media/:id/confirm)。
  4. 服务器提取元数据(尺寸、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 集合并标记架构差距,fetchContent 流式传输规范化条目,导入管道通过管理面板使用的相同存储库写入。内置源涵盖 WordPress WXR、WordPress.com 和 WordPress REST API;注册自定义源以从其他系统导入。