Architettura (interni)

In questa pagina

Questa pagina è per le persone che lavorano su EmDash, non per chi costruisce un sito con esso. Documenta i meccanismi interni — layout delle tabelle, l’integrazione Astro, il percorso delle richieste, la generazione del codice. Niente di tutto ciò è necessario per usare EmDash. Se stai costruendo un sito, leggi invece Architettura e il Modello di contenuto.

L’integrazione Astro

EmDash viene eseguito come integrazione Astro dal pacchetto emdash. Al momento del build:

  • Inietta le route della SPA admin e dell’API REST con l’API injectRoute di Astro. Nulla viene copiato nel progetto dell’utente. I percorsi iniettati sono:

    Modello di percorsoScopo
    /_emdash/admin/[...path]SPA del pannello admin
    /_emdash/api/manifestManifesto admin (collezioni, plugin)
    /_emdash/api/content/[collection]CRUD delle voci di contenuto
    /_emdash/api/media/*Operazioni della libreria multimediale
    /_emdash/api/schema/*Gestione dello schema
    /_emdash/api/settingsImpostazioni del sito
    /_emdash/api/menus/*Menu di navigazione
    /_emdash/api/taxonomies/*Categorie, tag, tassonomie personalizzate
  • Genera moduli virtuali in modo che il bundler possa risolvere e fare tree-shake del codice di configurazione e dei plugin:

    ModuloScopo
    virtual:emdash/configConfigurazione database e storage
    virtual:emdash/dialectFactory del dialetto database
    virtual:emdash/plugin-adminsImport statici per le UI admin dei plugin
  • Fornisce il loader Live Collections, gestisce le migrazioni e apre la connessione allo storage.

Schema database-first

Le definizioni dello schema vivono nel database, non nel codice. Due tabelle di sistema tracciano la struttura.

_emdash_collections contiene una riga per collezione:

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,                      -- come è stata creata
  created_at TEXT DEFAULT CURRENT_TIMESTAMP,
  updated_at TEXT
);

La colonna source registra la provenienza: manual (UI admin), template:<name> (file seed), import:wordpress (importatore) o discovered (auto-rilevata dalle tabelle esistenti).

_emdash_fields contiene una riga per campo, collegata alla sua collezione:

CREATE TABLE _emdash_fields (
  id TEXT PRIMARY KEY,
  collection_id TEXT REFERENCES _emdash_collections(id),
  slug TEXT NOT NULL,               -- nome della colonna
  label TEXT NOT NULL,
  type TEXT NOT NULL,               -- tipo di 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)
);

Tabelle di contenuto per collezione

Ogni collezione ottiene la propria tabella, con prefisso ec_. Una collezione products con campi title e price produce:

CREATE TABLE ec_products (
  -- Colonne di sistema, sempre presenti
  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,                  -- eliminazione soft
  version INTEGER DEFAULT 1,        -- blocco ottimistico

  -- Colonne di contenuto, dalle definizioni dei campi
  title TEXT NOT NULL,
  price REAL
);

Colonne reali (anziché una tabella con un blob JSON) forniscono indicizzazione corretta, chiavi esterne funzionanti, uno schema che gli strumenti database possono ispezionare e nessun parsing JSON per campo.

Le responsabilità restano separate:

ResponsabilitàPosizioneTabelle
SchemaTabelle di sistema_emdash_collections, _emdash_fields
ContenutoTabelle per collezioneec_posts, ec_products, …
MediaTabella separata + storageTabella media + R2/S3
ImpostazioniTabella opzionioptions con prefisso site:

Modifiche dello schema a runtime

L’aggiunta di un campo tramite l’UI admin esegue tre passaggi:

  1. Inserire un record in _emdash_fields.
  2. Eseguire ALTER TABLE ec_<collection> ADD COLUMN <name> <TYPE>.
  3. Rigenerare lo schema Zod usato per la validazione.

SQLite supporta l’aggiunta, la rinomina e l’eliminazione di colonne (l’eliminazione richiede SQLite 3.35+) a runtime. Cambiare il tipo di una colonna non è supportato direttamente, quindi EmDash ricostruisce la tabella in modo trasparente: crea una nuova tabella, copia le righe, elimina la vecchia tabella, rinomina la nuova.

Validazione a runtime

EmDash costruisce schemi Zod dalle definizioni dei campi all’avvio e valida ogni creazione e aggiornamento contro di essi:

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

Livello dati

EmDash usa Kysely per SQL type-safe su tutti i database supportati (SQLite, libSQL, Cloudflare D1 e PostgreSQL). Il dialetto viene selezionato da virtual:emdash/dialect dalla configurazione che il sito passa all’integrazione.

Loader Live Collections

Il contenuto viene servito a runtime tramite le Live Collections di Astro. emdashLoader() implementa l’interfaccia LiveLoader di Astro ed è registrato come singola collezione _emdash:

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

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

La singola collezione _emdash avvolge ogni tipo di contenuto; il loader filtra per tipo quando viene chiamato getEmDashCollection("posts").

Percorsi delle richieste

Una richiesta di contenuto da una pagina:

  1. Astro riceve la richiesta ed esegue il componente della pagina.
  2. getEmDashCollection() chiama getLiveCollection() di Astro.
  3. emdashLoader interroga la tabella ec_* rilevante tramite Kysely.
  4. Le righe vengono mappate al formato di voce di Astro (id, slug, data).
  5. Il componente renderizza.

Una richiesta admin:

  1. Il middleware valida il token di sessione.
  2. La route API esegue CRUD tramite un repository.
  3. Gli hook del ciclo di vita si attivano (ad esempio content:beforeSave).
  4. Kysely esegue l’SQL.
  5. La route restituisce JSON alla SPA admin.

Interni del pannello admin

L’admin è un’isola React. Astro serve la shell e applica l’autenticazione nel middleware; tutto all’interno è lato client, costruito su TanStack Router, TanStack Query, TanStack Table, React Hook Form + Zod, TipTap e Kumo (il sistema di design Base UI + Tailwind di Cloudflare).

La route shell controlla l’accesso nel 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 guidata dal manifesto

L’admin non ha nulla hardcoded su collezioni o plugin. Recupera GET /_emdash/api/manifest, che restituisce le collezioni, i plugin e le tassonomie a cui l’utente richiedente può accedere, filtrati per ruolo:

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

La navigazione, i form e gli editor dei campi vengono generati da questo manifesto, così i cambiamenti di schema e plugin appaiono senza una ricostruzione dell’admin, e gli schemi Zod rimangono lato server.

UI admin dei plugin

I punti di ingresso admin dei plugin vengono raccolti in un modulo virtuale generato di import statici in modo che il bundler possa risolverli e fare tree-shake:

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

export const pluginAdmins = { seo: pluginAdmin0 };

Conversione rich text

I campi Portable Text vengono modificati in TipTap (ProseMirror). Il contenuto viene convertito ai confini di caricamento e salvataggio da portableTextToProsemirror() e prosemirrorToPortableText(). I blocchi sconosciuti da plugin o importazioni vengono preservati come segnaposto di sola lettura.

Upload firmati

Gli upload di media usano URL firmate dirette allo storage quando l’adattatore le supporta e un endpoint di streaming same-origin altrimenti:

  1. Il client richiede un URL di upload (POST /api/media/upload-url).
  2. Il client carica verso la destinazione restituita. Gli adattatori compatibili S3 possono restituire un URL firmato che bypassa i limiti di dimensione del body del Worker; i binding R2 nativi e lo storage locale restituiscono un endpoint di streaming EmDash.
  3. Il client conferma (POST /api/media/:id/confirm).
  4. Il server estrae i metadati (dimensioni, tipo MIME).

Estendere l’importatore di contenuti

L’importatore WordPress è costruito su un’interfaccia ImportSource collegabile. Una sorgente personalizzata implementa probe, analyze e fetch:

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

probe valida l’input e riporta cosa ha trovato, analyze mappa i tipi di post sorgente alle collezioni EmDash e segnala le lacune nello schema, e fetchContent trasmette voci normalizzate che la pipeline di importazione scrive attraverso gli stessi repository usati dall’admin. Le sorgenti integrate coprono WordPress WXR, WordPress.com e l’API REST di WordPress; registra una sorgente personalizzata per importare da un altro sistema.