Arquitetura (internos)

Nesta página

Esta página é para pessoas que trabalham no EmDash, não para quem constrói 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 o EmDash. Se você está construindo um site, leia Arquitetura e o Modelo de conteúdo em vez disso.

A integração Astro

O EmDash roda como uma integração Astro do pacote emdash. No momento do build:

  • Injeta as rotas da SPA admin e da API REST 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 da biblioteca de mídia
    /_emdash/api/schema/*Gerenciamento de schema
    /_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 fazer tree-shake do código de configuração e plugins:

    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-adminsImports estáticos para UIs admin de plugins
  • Fornece o loader de Live Collections, gerencia migrações e abre a conexão de armazenamento.

Schema database-first

Definições de schema 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,                      -- como foi criada
  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-detectada 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,               -- nome da coluna
  label TEXT NOT NULL,
  type TEXT NOT NULL,               -- tipo de campo
  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, com prefixo ec_. Uma coleção products com campos title e price produz:

CREATE TABLE ec_products (
  -- Colunas do sistema, sempre presentes
  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,                  -- exclusão suave
  version INTEGER DEFAULT 1,        -- bloqueio otimista

  -- Colunas de conteúdo, das definições de campo
  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 schema que ferramentas de banco de dados podem inspecionar e sem parsing JSON por campo.

As responsabilidades permanecem separadas:

ResponsabilidadeLocalizaçãoTabelas
SchemaTabelas 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 schema em runtime

Adicionar um campo pela UI admin executa três passos:

  1. Inserir um registro em _emdash_fields.
  2. Executar ALTER TABLE ec_<collection> ADD COLUMN <name> <TYPE>.
  3. Regenerar o schema Zod usado para validação.

O SQLite suporta adicionar, renomear e remover colunas (remover requer SQLite 3.35+) em runtime. Alterar o tipo de uma coluna não é suportado diretamente, então o EmDash reconstrói a tabela de forma transparente: cria uma nova tabela, copia as linhas, remove a tabela antiga, renomeia a nova.

Validação em runtime

O EmDash constrói schemas Zod 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

O 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 da configuração que o site passa para a integração.

Loader de Live Collections

O conteúdo é servido em runtime 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 única coleção _emdash envolve cada tipo de conteúdo; o loader filtra por tipo quando getEmDashCollection("posts") é chamado.

Caminhos de requisição

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

  1. O 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 da API executa CRUD através de um repositório.
  3. Hooks de ciclo de vida disparam (por exemplo content:beforeSave).
  4. O Kysely executa o SQL.
  5. A rota retorna JSON para a SPA admin.

Internos do painel admin

O admin é uma ilha React. O Astro serve a 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 (o sistema de design Base UI + Tailwind da Cloudflare).

A rota da 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 tem nada hardcoded 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, filtradas 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 schema e plugins aparecem sem uma reconstrução do admin, e schemas Zod permanecem do lado do servidor.

UIs admin de plugins

Os pontos de entrada admin de plugins são coletados em um módulo virtual gerado de imports estáticos para que o bundler possa resolver e fazer tree-shake deles:

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

export const pluginAdmins = { seo: pluginAdmin0 };

Conversão de rich text

Campos Portable Text são editados no TipTap (ProseMirror). O conteúdo é convertido nas fronteiras 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 usam URLs assinadas diretas para o armazenamento quando o adaptador as suporta e um endpoint de streaming same-origin caso contrário:

  1. O cliente solicita uma URL de upload (POST /api/media/upload-url).
  2. O cliente faz upload para o destino retornado. Adaptadores compatíveis com S3 podem retornar uma URL assinada que contorna os limites de tamanho de body do Worker; bindings R2 nativos e armazenamento local retornam um endpoint de streaming EmDash.
  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 ImportSource plugável. 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 reporta o que encontrou, analyze mapeia os tipos de post fonte para coleções EmDash e sinaliza lacunas no schema, 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.