架构(内部结构)

本页内容

本页面面向参与 EmDash 自身开发的人员,而非使用 EmDash 构建站点的人员。它解释了数据库布局、Astro 集成、请求路径、管理应用程序、媒体流程和导入系统。如果您正在构建站点,请改为阅读架构内容模型

Astro 集成

EmDash 作为来自 emdash 包的 Astro 集成运行。在构建时:

  • 使用 Astro 的 injectRoute API 注入管理应用程序和 REST API 路由。不会向用户项目复制任何内容。主要路由族包括:

    路径模式用途
    /_emdash/admin/[...path]管理面板 SPA
    /_emdash/api/manifest管理清单(集合、插件)
    /_emdash/api/content/[collection]/...内容条目操作
    /_emdash/api/media/...媒体库操作
    /_emdash/api/schema/...模式管理
    /_emdash/api/settings/...站点设置
    /_emdash/api/menus/...导航菜单
    /_emdash/api/taxonomies/...分类、标签、自定义分类法
    /_emdash/api/plugins/[pluginId]/[...path]插件定义的 API 路由

    路由注入器是完整的清单,包括认证、评论、搜索、导入、小部件和其他路由族。

  • 生成虚拟模块,以便打包器可以解析配置和扩展代码:

    模块用途
    virtual:emdash/config数据库、存储和站点配置
    virtual:emdash/dialect数据库方言工厂
    virtual:emdash/admin-registry插件管理界面的静态导入
    virtual:emdash/plugins已配置的插件实现
    virtual:emdash/media-providers已配置的外部媒体提供者

    virtual-modules.ts 定义了其余的运行时帮助程序和生成的模块内容。

  • 提供 Live Content Collections 加载器并注册运行时中间件。在请求时,中间件打开已配置的数据库和存储连接,并在路由使用之前应用任何待处理的迁移。

数据库优先模式

模式定义存在于数据库中,而不是静态配置文件中。_emdash_collections 每个集合存储一行。其核心列描述了集合以及运行时和管理公开的功能:

用途
id, slug稳定的集合标识
label, label_singular, description, icon向编辑者显示的名称和指导
supports, has_seo, comments_enabled, edit_locking可选的集合功能
title_field, date_field, admin_config, hidden, sort_order管理列表和导航行为
url_pattern, routable公开 URL 和 slug 行为
source集合的创建方式

source 值记录来源,如 manualseedtemplate:<name>import:<name>discovered。额外设置来自注册的迁移,因此 database/types.ts 和迁移是当前的列清单。

_emdash_fields 存储与每个集合关联的字段:

用途
id, collection_id, slug字段标识和所属集合
label, type, column_type编辑器标签、EmDash 字段类型和 SQL 存储类型
required, unique, default_value, validation内容约束和默认值
widget, options, sort_order编辑器控件和显示顺序
searchable, indexed, translatable搜索、查询和本地化行为

collection_id 引用 _emdash_collections.id,每个字段 slug 在其集合内唯一。

每集合内容表

每个集合获得自己的表,前缀为 ec_。具有 titleprice 字段的 products 集合会生成如下形式的表:

CREATE TABLE ec_products (
  -- 系统列,存在于每个内容表中
  id TEXT PRIMARY KEY,
  slug TEXT,
  status TEXT DEFAULT 'draft',
  author_id TEXT,
  primary_byline_id TEXT,
  created_at TEXT DEFAULT CURRENT_TIMESTAMP,
  updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
  published_at TEXT,
  scheduled_at TEXT,
  deleted_at TEXT,
  version INTEGER DEFAULT 1,
  live_revision_id TEXT,
  draft_revision_id TEXT,
  locale TEXT NOT NULL DEFAULT 'en',
  translation_group TEXT,

  -- 内容列,从字段定义创建
  title TEXT NOT NULL,
  price REAL,

  UNIQUE (slug, locale)
);

真实列为每个字段提供数据库类型,允许索引和外键,并让数据库工具在不解码内容 JSON blob 的情况下检查模式。唯一约束允许翻译共享 slug,同时保持每个 slug 在语言环境内唯一。同一条目的所有语言环境变体共享 translation_group 值,使 EmDash 能够找到彼此互为翻译的行。

主要数据关注点保持分离:

关注点位置
模式系统表_emdash_collections, _emdash_fields
内容每集合表ec_posts, ec_products, …
媒体独立表 + 存储media 表 + 已配置存储
设置选项表site: 前缀的 options

运行时模式更改

通过管理 UI 添加字段会执行以下步骤:

  1. 将字段定义插入 _emdash_fields
  2. 向集合的 ec_* 表添加相应列,当字段配置为已索引时创建索引。
  3. 刷新生成的开发类型,使新字段出现在编辑器工具中。

内容验证读取当前字段定义,并在创建或更新内容时构建 Zod 模式。更改字段的底层 SQL 类型、requiredunique 约束或本地化行为可能需要手动内容迁移;SchemaRegistry 拒绝不支持的原地更改,而不是隐式重建表。

运行时验证

EmDash 从集合的当前字段派生 Zod 模式。生成器将类型和约束细节委托给 generateFieldSchema()

export function generateZodSchema(
	collection: CollectionWithFields,
): z.ZodObject<Record<string, ZodType>> {
	const shape: Record<string, ZodType> = {};

	for (const field of collection.fields) {
		shape[field.slug] = generateFieldSchema(field);
	}

	return z.object(shape);
}

内容处理器还会拒绝未知字段、检查必填字符串值并验证对其他集合的引用。

数据层

EmDash 使用 Kysely 在 SQLite、libSQL、Cloudflare D1 和 PostgreSQL 之间提供类型化 SQL。站点配置选择数据库适配器;集成通过 virtual:emdash/dialect 公开其方言工厂。

Live Content Collections 加载器

内容通过 Astro 的 Live Content Collections 在运行时提供。emdashLoader() 实现 Astro 的 LiveLoader 接口,并注册为单个 _emdash 集合:

import { defineLiveCollection } from "astro:content";
import { emdashLoader } from "emdash/runtime";

export const collections = {
	_emdash: defineLiveCollection({ loader: emdashLoader() }),
};

单个 _emdash 集合包装每个 EmDash 集合。getEmDashCollection("posts") 提供 posts 类型过滤器,加载器将其映射到 ec_posts 表。

请求路径

来自 Astro 页面的内容请求遵循此路径:

  1. 页面调用 getEmDashCollection()getEmDashEntry()
  2. 查询包装器使用内部 _emdash 集合和请求的 EmDash 集合类型调用 Astro 的 getLiveCollection()getLiveEntry()
  3. emdashLoader() 通过 Kysely 查询相关的 ec_* 表,应用发布、语言环境、过滤、排序和分页规则。
  4. 查询包装器将行映射到 Astro 条目并加载其署名和分类法术语。
  5. Astro 组件渲染返回的条目。

预览和编辑模式状态通过请求上下文传播,因此在中间件验证请求后,相同的查询函数可以返回草稿内容。

管理 API 请求遵循单独的路径:

  1. 中间件认证请求并将已解析的用户存储在 Astro.locals 中。
  2. API 路由解析请求并检查该操作所需的权限。
  3. 路由将业务逻辑委托给处理器或仓库。
  4. 处理器在数据库操作周围运行插件生命周期钩子(当该操作公开钩子时)。
  5. 路由向管理应用程序返回标准 JSON 成功或错误响应。

管理面板内部结构

管理面板是一个 React 单页应用程序。Astro 提供其外壳,认证中间件保护管理路由。在应用程序内部,TanStack Router 处理导航,TanStack Query 加载服务器状态,TanStack Table 渲染数据网格,React Hook Form 和 Zod 管理表单,TipTap 编辑 Portable Text,Kumo 提供设计系统。

对于会话认证,中间件将未认证的浏览器请求重定向到登录页面,并为未认证的 API 请求返回 JSON 错误。加载活动用户后,将该用户放置在 Astro.locals 上供路由使用:

const sessionUser = await resolveSessionUser(session);

if (!sessionUser?.id) {
	if (isApiRoute) {
		return apiError("NOT_AUTHENTICATED", "Not authenticated", 401);
	}

	const loginUrl = new URL("/_emdash/admin/login", getPublicOrigin(url, emdash?.config));
	loginUrl.searchParams.set("redirect", url.pathname);
	return context.redirect(loginUrl.toString());
}

在此分支之后,中间件加载用户,拒绝缺失或禁用的账户,将活动用户放置在 Astro.locals 上,并继续到路由。

清单驱动的 UI

管理面板不会硬编码集合模式或插件贡献。它获取 GET /_emdash/api/manifest,该端点描述当前集合、字段、插件、分类法、认证模式和其他已配置的功能。简化的清单如下所示:

{
	"collections": {
		"posts": {
			"label": "Blog Posts",
			"labelSingular": "Post",
			"supports": ["drafts", "revisions", "preview"],
			"fields": {
				"title": { "kind": "string", "label": "Title", "required": true }
			}
		}
	},
	"plugins": {
		"audit-log": { "version": "0.2.1", "enabled": true }
	},
	"taxonomies": [
		{ "name": "category", "label": "Categories", "hierarchical": true }
	],
	"version": "0.37.0"
}

管理面板使用清单构建集合导航和字段编辑器。由于端点读取实时模式,集合和字段更改无需重建管理应用程序即可显示。

插件管理 UI

已配置的插件管理入口点收集到 virtual:emdash/admin-registry 中。生成的模块使用静态导入,以便打包器可以包含 React 组件:

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

export const pluginAdmins = { seo: pluginAdmin0 };

富文本转换

Portable Text 字段使用基于 ProseMirror 的 TipTap。EmDash 在编辑器加载时将 Portable Text 转换为 ProseMirror,并在条目保存时转换回 Portable Text。来自插件或导入的未知块被保留为只读占位符,而不是被丢弃。

签名上传

媒体上传在存储适配器支持时使用直接到存储的签名 URL,否则使用同源流式端点:

  1. 客户端从 POST /_emdash/api/media/upload-url 请求上传目标。EmDash 创建一个待处理的媒体项。
  2. 客户端上传到返回的目标。S3 兼容适配器可以返回绕过应用程序正文大小限制的签名 URL;原生 R2 绑定和本地存储返回 EmDash 流式端点。
  3. 客户端通过 POST /_emdash/api/media/:id/confirm 确认上传。
  4. EmDash 验证存储的文件并将媒体项标记为就绪。

扩展内容导入器

WordPress 导入器使用可插拔的 ImportSource 接口。源可以探测 URL、根据当前模式分析可用内容并流式传输标准化的内容项:

interface ImportSource {
	id: string;
	name: string;
	description: string;
	icon: "upload" | "globe" | "wordpress" | "plug";
	requiresFile?: boolean;
	canProbe?: boolean;
	probe?(url: string): Promise<SourceProbeResult | null>;
	analyze(input: SourceInput, context: ImportContext): Promise<ImportAnalysis>;
	fetchContent(input: SourceInput, options: FetchOptions): AsyncGenerator<NormalizedItem>;
	fetchMedia?(url: string, input: SourceInput): Promise<Blob>;
}

WXR 源导入 WordPress 导出文件。连接器源从安装了 EmDash WordPress 插件的站点直接导入。单独的 REST 源检测公开 WordPress 站点,但由于直接 REST 导入未实现,会引导用户使用 WXR 导出。当导入器可以生成相同的标准化分析和内容项形状时,注册另一个源。