Arquitetura (internos)

Nesta página

Esta página é para pessoas trabalhando no EmDash, não construindo um site com ele. Ela explica o layout do banco de dados, a integração Astro, os caminhos de requisição, a aplicação de administração, o fluxo de mídia e o sistema de importação. 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 tempo de build:

  • Injeta a aplicação de administração e as rotas da API REST com a API injectRoute do Astro. Nada é copiado para o projeto do usuário. As principais famílias de rotas são:

    Padrão de caminhoPropósito
    /_emdash/admin/[...path]SPA do painel de administração
    /_emdash/api/manifestManifesto do admin (coleções, plugins)
    /_emdash/api/content/[collection]/...Operações de entradas de conteúdo
    /_emdash/api/media/...Operações da biblioteca de mídia
    /_emdash/api/schema/...Gerenciamento de esquema
    /_emdash/api/settings/...Configurações do site
    /_emdash/api/menus/...Menus de navegação
    /_emdash/api/taxonomies/...Categorias, tags, taxonomias customizadas
    /_emdash/api/plugins/[pluginId]/[...path]Rotas de API definidas por plugins

    O injetor de rotas é o inventário completo, incluindo autenticação, comentários, busca, importações, widgets e outras famílias de rotas.

  • Gera módulos virtuais para que o bundler possa resolver código de configuração e extensão:

    MóduloPropósito
    virtual:emdash/configConfiguração de banco de dados, armazenamento e site
    virtual:emdash/dialectFactory do dialeto de banco de dados
    virtual:emdash/admin-registryImportações estáticas para interfaces admin de plugins
    virtual:emdash/pluginsImplementações de plugins configurados
    virtual:emdash/media-providersProvedores de mídia externos configurados

    virtual-modules.ts define os helpers de runtime restantes e os conteúdos de módulos gerados.

  • Fornece o loader de Live Content Collections e registra o middleware de runtime. No tempo de requisição, o middleware abre as conexões de banco de dados e armazenamento configuradas e aplica quaisquer migrações pendentes antes que as rotas as usem.

Esquema database-first

As definições de esquema residem no banco de dados, não em um arquivo de configuração estático. _emdash_collections armazena uma linha por coleção. Suas colunas principais descrevem a coleção e os recursos que o runtime e o admin expõem:

ColunasPropósito
id, slugIdentidade estável da coleção
label, label_singular, description, iconNomes e orientações mostrados aos editores
supports, has_seo, comments_enabled, edit_lockingCapacidades opcionais da coleção
title_field, date_field, admin_config, hidden, sort_orderComportamento da lista admin e navegação
url_pattern, routableURL pública e comportamento do slug
sourceComo a coleção foi criada

O valor source registra a proveniência como manual, seed, template:<name>, import:<name> ou discovered. Configurações adicionais vêm de migrações registradas, então database/types.ts e as migrações são o inventário atual de colunas.

_emdash_fields armazena os campos vinculados a cada coleção:

ColunasPropósito
id, collection_id, slugIdentidade do campo e coleção proprietária
label, type, column_typeRótulo do editor, tipo de campo EmDash e tipo de armazenamento SQL
required, unique, default_value, validationRestrições de conteúdo e padrões
widget, options, sort_orderControle do editor e ordem de exibição
searchable, indexed, translatableComportamento de busca, consulta e localização

collection_id referencia _emdash_collections.id, e cada slug de campo é único dentro de sua coleção.

Tabelas de conteúdo por coleção

Cada coleção recebe sua própria tabela, com prefixo ec_. Uma coleção products com campos title e price produz uma tabela com esta forma:

CREATE TABLE ec_products (
  -- Colunas do sistema, presentes em toda tabela de conteúdo
  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,

  -- Colunas de conteúdo, criadas a partir de definições de campos
  title TEXT NOT NULL,
  price REAL,

  UNIQUE (slug, locale)
);

Colunas reais dão a cada campo um tipo de banco de dados, permitem índices e chaves estrangeiras, e deixam ferramentas de banco de dados inspecionar o esquema sem decodificar um blob JSON de conteúdo. A restrição unique permite que traduções compartilhem um slug enquanto mantêm cada slug único dentro de uma localidade. Todas as variantes de localidade da mesma entrada compartilham um valor translation_group, que permite ao EmDash encontrar as linhas que são traduções umas das outras.

As principais preocupações de dados 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 + armazenamento configurado
ConfiguraçõesTabela de opçõesoptions com prefixo site:

Mudanças de esquema em runtime

Adicionar um campo pela UI de administração executa estes passos:

  1. Inserir a definição do campo em _emdash_fields.
  2. Adicionar a coluna correspondente à tabela ec_* da coleção e criar um índice quando o campo é configurado como indexado.
  3. Atualizar os tipos de desenvolvimento gerados para que o novo campo apareça nas ferramentas do editor.

A validação de conteúdo lê as definições de campos atuais e constrói um esquema Zod quando conteúdo é criado ou atualizado. Alterar o tipo SQL subjacente, a restrição required ou unique, ou o comportamento de localização de um campo pode requerer uma migração de conteúdo manual; SchemaRegistry rejeita alterações in loco não suportadas em vez de reconstruir a tabela implicitamente.

Validação em runtime

O EmDash deriva um esquema Zod dos campos atuais da coleção. O gerador delega os detalhes de tipo e restrição para 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);
}

O handler de conteúdo também rejeita campos desconhecidos, verifica valores de string obrigatórios e verifica referências a outras coleções.

Camada de dados

O EmDash usa Kysely para SQL tipado através de SQLite, libSQL, Cloudflare D1 e PostgreSQL. A configuração do site seleciona o adaptador de banco de dados; a integração expõe sua factory de dialeto através de virtual:emdash/dialect.

Loader de Live Content Collections

O conteúdo é servido em runtime através das Live Content 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 cada coleção do EmDash. getEmDashCollection("posts") fornece o filtro de tipo posts, e o loader o mapeia para a tabela ec_posts.

Caminhos de requisição

Uma requisição de conteúdo de uma página Astro segue este caminho:

  1. A página chama getEmDashCollection() ou getEmDashEntry().
  2. O wrapper de consulta chama getLiveCollection() ou getLiveEntry() do Astro com a coleção interna _emdash e o tipo de coleção EmDash solicitado.
  3. emdashLoader() consulta a tabela ec_* relevante através do Kysely, aplicando regras de publicação, localidade, filtro, ordenação e paginação.
  4. O wrapper de consulta mapeia as linhas para entradas Astro e carrega seus bylines e termos de taxonomia.
  5. O componente Astro renderiza as entradas retornadas.

O estado de pré-visualização e modo de edição viaja através do contexto de requisição, então as mesmas funções de consulta podem retornar conteúdo de rascunho depois que o middleware verifica a requisição.

Uma requisição da API de administração segue um caminho separado:

  1. O middleware autentica a requisição e armazena o usuário resolvido em Astro.locals.
  2. A rota da API analisa a requisição e verifica a permissão necessária para essa operação.
  3. A rota delega a lógica de negócio a um handler ou repositório.
  4. O handler executa hooks de ciclo de vida de plugins em torno da operação de banco de dados quando essa operação expõe hooks.
  5. A rota retorna uma resposta JSON padrão de sucesso ou erro para a aplicação de administração.

Internos do painel de administração

O admin é uma aplicação React de página única. O Astro serve seu shell e o middleware de autenticação protege as rotas de administração. Dentro da aplicação, TanStack Router gerencia a navegação, TanStack Query carrega o estado do servidor, TanStack Table renderiza grids de dados, React Hook Form e Zod gerenciam formulários, TipTap edita Portable Text e Kumo fornece o sistema de design.

Para autenticação de sessão, o middleware redireciona uma requisição de navegador não autenticada para a página de login e retorna um erro JSON para uma requisição de API não autenticada. Depois de carregar um usuário ativo, ele o coloca em Astro.locals para a rota:

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

Após este branch, o middleware carrega o usuário, rejeita contas ausentes ou desabilitadas, coloca o usuário ativo em Astro.locals e continua para a rota.

UI orientada por manifesto

O admin não codifica esquemas de coleção ou contribuições de plugins. Ele busca GET /_emdash/api/manifest, que descreve as coleções atuais, campos, plugins, taxonomias, modo de autenticação e outras capacidades configuradas. Um manifesto abreviado parece assim:

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

O admin usa o manifesto para construir a navegação de coleções e os editores de campos. Como o endpoint lê o esquema ao vivo, as mudanças de coleções e campos aparecem sem reconstruir a aplicação de administração.

UIs admin de plugins

Os pontos de entrada admin de plugins configurados são coletados em virtual:emdash/admin-registry. O módulo gerado usa importações estáticas para que o bundler possa incluir os componentes React:

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

export const pluginAdmins = { seo: pluginAdmin0 };

Conversão de texto rico

Campos de Portable Text usam TipTap, que é baseado em ProseMirror. O EmDash converte Portable Text para ProseMirror quando o editor carrega e converte de volta para Portable Text quando a entrada é salva. Blocos desconhecidos de plugins ou importações são preservados como placeholders somente leitura em vez de serem descartados.

Uploads assinados

Uploads de mídia usam URLs assinadas diretas para o armazenamento quando o adaptador de armazenamento as suporta e um endpoint de streaming same-origin caso contrário:

  1. O cliente solicita um alvo de upload de POST /_emdash/api/media/upload-url. O EmDash cria um item de mídia pendente.
  2. O cliente faz upload para o alvo retornado. Adaptadores compatíveis com S3 podem retornar uma URL assinada que contorna os limites de tamanho do corpo da aplicação; bindings nativos R2 e armazenamento local retornam um endpoint de streaming EmDash.
  3. O cliente confirma o upload com POST /_emdash/api/media/:id/confirm.
  4. O EmDash valida o arquivo armazenado e marca o item de mídia como pronto.

Extensão do importador de conteúdo

O importador do WordPress usa uma interface ImportSource plugável. Uma fonte pode sondar uma URL, analisar o conteúdo disponível contra o esquema atual e transmitir itens de conteúdo normalizados:

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

A fonte WXR importa arquivos de exportação do WordPress. A fonte de conector importa diretamente de sites com o plugin EmDash para WordPress. Uma fonte REST separada detecta sites WordPress públicos, mas direciona o usuário para uma exportação WXR porque a importação REST direta não está implementada. Registre outra fonte quando um importador puder produzir as mesmas formas de análise normalizada e itens de conteúdo.