Arquitetura (internals)

Nesta página

Esta página é para pessoas trabalhando no EmDash, não para quem está construindo um site com ele. Documenta mecânicas internas — layouts de tabelas, a integração Astro, o caminho de requisição, geração de código. Nada disso é necessário para usar EmDash. Se você está construindo um site, leia Architecture e o Content Model em vez disso.

A integração Astro

EmDash executa como uma integração Astro do pacote emdash. No momento da compilação:

  • Injeta o SPA admin e rotas REST API com a API injectRoute do Astro. Nada é copiado para o projeto do usuário. Os caminhos injetados são:

    Padrão de caminhoPropósito
    /_emdash/admin/[...path]SPA do painel admin
    /_emdash/api/manifestManifesto admin (coleções, plugins)
    /_emdash/api/content/[collection]CRUD de entradas de conteúdo
    /_emdash/api/media/*Operações de biblioteca de mídia
    /_emdash/api/schema/*Gerenciamento de esquema
    /_emdash/api/settingsConfigurações do site
    /_emdash/api/menus/*Menus de navegação
    /_emdash/api/taxonomies/*Categorias, tags, taxonomias personalizadas
  • Gera módulos virtuais para que o bundler possa resolver e tree-shake o código de configuração e plugin:

    MóduloPropósito
    virtual:emdash/configConfiguração de banco de dados e armazenamento
    virtual:emdash/dialectFactory de dialeto de banco de dados
    virtual:emdash/plugin-adminsImportações estáticas para UIs admin de plugins
  • Fornece o carregador Live Collections, gerencia migrações e abre a conexão de armazenamento.

Esquema database-first

Definições de esquema vivem no banco de dados, não no código. Duas tabelas do sistema rastreiam a estrutura.

_emdash_collections contém uma linha por coleção:

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
);

A coluna source registra a proveniência: manual (UI admin), template:<name> (arquivo seed), import:wordpress (importador) ou discovered (auto-detectado de tabelas existentes).

_emdash_fields contém uma linha por campo, vinculada à sua coleção:

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)
);

Tabelas de conteúdo por coleção

Cada coleção obtém sua própria tabela, prefixada com ec_. Uma coleção products com campos title e price produz:

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
);

Colunas reais (em vez de uma tabela com um blob JSON) fornecem indexação adequada, chaves estrangeiras funcionais, um esquema que ferramentas de banco de dados podem inspecionar e sem análise JSON campo por campo.

As preocupações permanecem separadas:

PreocupaçãoLocalizaçãoTabelas
EsquemaTabelas do sistema_emdash_collections, _emdash_fields
ConteúdoTabelas por coleçãoec_posts, ec_products, …
MídiaTabela separada + armazenamentoTabela media + R2/S3
ConfiguraçõesTabela de opçõesoptions com prefixo site:

Alterações de esquema em tempo de execução

Adicionar um campo através da UI admin executa três etapas:

  1. Insere um registro em _emdash_fields.
  2. Executa ALTER TABLE ec_<collection> ADD COLUMN <name> <TYPE>.
  3. Regenera o esquema Zod usado para validação.

SQLite suporta adicionar, renomear e remover colunas (remover requer SQLite 3.35+) em tempo de execução. Alterar o tipo de uma coluna não é suportado no local, então EmDash reconstrói a tabela de forma transparente: criar uma nova tabela, copiar linhas, remover a tabela antiga, renomear a nova.

Validação em tempo de execução

EmDash constrói esquemas Zod a partir das definições de campo na inicialização e valida cada criação e atualização contra eles:

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);
}

Camada de dados

EmDash usa Kysely para SQL type-safe em todos os bancos de dados suportados (SQLite, libSQL, Cloudflare D1 e PostgreSQL). O dialeto é selecionado por virtual:emdash/dialect a partir da configuração que o site passa para a integração.

Carregador Live Collections

O conteúdo é servido em tempo de execução através das Live Collections do Astro. emdashLoader() implementa a interface LiveLoader do Astro e é registrado como uma única coleção _emdash:

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

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

A coleção única _emdash envolve todos os tipos de conteúdo; o carregador filtra por tipo quando getEmDashCollection("posts") é chamado.

Caminhos de requisição

Uma requisição de conteúdo de uma página:

  1. Astro recebe a requisição e executa o componente da página.
  2. getEmDashCollection() chama getLiveCollection() do Astro.
  3. emdashLoader consulta a tabela ec_* relevante através do Kysely.
  4. As linhas são mapeadas para o formato de entrada do Astro (id, slug, data).
  5. O componente renderiza.

Uma requisição admin:

  1. O middleware valida o token de sessão.
  2. A rota API executa CRUD através de um repositório.
  3. Hooks de ciclo de vida disparam (por exemplo content:beforeSave).
  4. Kysely executa o SQL.
  5. A rota retorna JSON para o SPA admin.

Internals do painel admin

O admin é uma ilha React. Astro serve o shell e aplica autenticação no middleware; tudo dentro é do lado do cliente, construído sobre TanStack Router, TanStack Query, TanStack Table, React Hook Form + Zod, TipTap e Kumo (Base UI da Cloudflare + sistema de design Tailwind).

A rota shell controla o acesso no middleware:

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 dirigida por manifesto

O admin não codifica nada sobre coleções ou plugins. Ele busca GET /_emdash/api/manifest, que retorna as coleções, plugins e taxonomias que o usuário solicitante pode acessar, filtrados por papel:

{
	"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"
}

Navegação, formulários e editores de campo são gerados a partir deste manifesto, então mudanças de esquema e plugin aparecem sem reconstrução do admin, e esquemas Zod permanecem do lado do servidor.

UIs admin de plugins

Pontos de entrada admin de plugins são coletados em um módulo virtual gerado de importações estáticas para que o bundler possa resolvê-los e tree-shake:

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

export const pluginAdmins = { seo: pluginAdmin0 };

Conversão de rich text

Campos Portable Text editam no TipTap (ProseMirror). O conteúdo é convertido nos limites de carregamento e salvamento por portableTextToProsemirror() e prosemirrorToPortableText(). Blocos desconhecidos de plugins ou importações são preservados como placeholders somente leitura.

Uploads assinados

Uploads de mídia contornam limites de tamanho de corpo do Worker com URLs assinadas diretas para armazenamento:

  1. O cliente solicita uma URL de upload (POST /api/media/upload-url).
  2. O cliente faz upload diretamente para a URL assinada (R2 ou S3).
  3. O cliente confirma (POST /api/media/:id/confirm).
  4. O servidor extrai metadados (dimensões, tipo MIME).

Estendendo o importador de conteúdo

O importador WordPress é construído sobre uma interface plugável ImportSource. Uma fonte personalizada implementa probe, analyze e fetch:

interface ImportSource {
	probe(input: ImportInput): Promise<ProbeResult>;
	analyze(input: ImportInput): Promise<AnalysisResult>;
	fetchContent(input: ImportInput): AsyncIterable<NormalizedEntry>;
}

probe valida a entrada e relata o que encontrou, analyze mapeia tipos de post de origem para coleções EmDash e sinaliza lacunas de esquema, e fetchContent transmite entradas normalizadas que o pipeline de importação escreve através dos mesmos repositórios que o admin usa. Fontes integradas cobrem WordPress WXR, WordPress.com e a API REST do WordPress; registre uma fonte personalizada para importar de outro sistema.