架構(內部結構)

本頁內容

本頁面面向參與 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 匯出。當匯入器可以產生相同的標準化分析和內容項目形狀時,註冊另一個來源。