Architettura (interni)

In questa pagina

Questa pagina è per le persone che lavorano su EmDash, non per chi costruisce un sito con esso. Spiega il layout del database, l’integrazione Astro, i percorsi delle richieste, l’applicazione di amministrazione, il flusso dei media e il sistema di importazione. Se stai costruendo un sito, leggi invece Architettura e il Modello di contenuto.

L’integrazione Astro

EmDash viene eseguito come un’integrazione Astro dal pacchetto emdash. Al momento della build:

  • Inietta l’applicazione di amministrazione e le route API REST con l’API injectRoute di Astro. Nulla viene copiato nel progetto dell’utente. Le principali famiglie di route sono:

    Pattern del percorsoScopo
    /_emdash/admin/[...path]SPA del pannello di amministrazione
    /_emdash/api/manifestManifesto admin (collezioni, plugin)
    /_emdash/api/content/[collection]/...Operazioni sulle voci di contenuto
    /_emdash/api/media/...Operazioni della libreria media
    /_emdash/api/schema/...Gestione dello schema
    /_emdash/api/settings/...Impostazioni del sito
    /_emdash/api/menus/...Menu di navigazione
    /_emdash/api/taxonomies/...Categorie, tag, tassonomie personalizzate
    /_emdash/api/plugins/[pluginId]/[...path]Route API definite dai plugin

    L’iniettore di route è l’inventario completo, incluse autenticazione, commenti, ricerca, importazioni, widget e altre famiglie di route.

  • Genera moduli virtuali affinché il bundler possa risolvere il codice di configurazione ed estensione:

    ModuloScopo
    virtual:emdash/configConfigurazione database, storage e sito
    virtual:emdash/dialectFactory del dialetto database
    virtual:emdash/admin-registryImport statici per le interfacce admin dei plugin
    virtual:emdash/pluginsImplementazioni dei plugin configurati
    virtual:emdash/media-providersProvider di media esterni configurati

    virtual-modules.ts definisce gli helper runtime rimanenti e i contenuti dei moduli generati.

  • Fornisce il loader Live Content Collections e registra il middleware runtime. Al momento della richiesta, il middleware apre le connessioni al database e allo storage configurate e applica eventuali migrazioni in sospeso prima che le route le utilizzino.

Schema database-first

Le definizioni dello schema risiedono nel database, non in un file di configurazione statico. _emdash_collections memorizza una riga per collezione. Le sue colonne principali descrivono la collezione e le funzionalità che il runtime e l’admin espongono:

ColonneScopo
id, slugIdentità stabile della collezione
label, label_singular, description, iconNomi e indicazioni mostrati ai redattori
supports, has_seo, comments_enabled, edit_lockingCapacità opzionali della collezione
title_field, date_field, admin_config, hidden, sort_orderComportamento della lista admin e della navigazione
url_pattern, routableURL pubblica e comportamento dello slug
sourceCome è stata creata la collezione

Il valore source registra la provenienza come manual, seed, template:<name>, import:<name> o discovered. Le impostazioni aggiuntive provengono dalle migrazioni registrate, quindi database/types.ts e le migrazioni sono l’inventario attuale delle colonne.

_emdash_fields memorizza i campi collegati a ciascuna collezione:

ColonneScopo
id, collection_id, slugIdentità del campo e collezione proprietaria
label, type, column_typeEtichetta dell’editor, tipo di campo EmDash e tipo di storage SQL
required, unique, default_value, validationVincoli di contenuto e valori predefiniti
widget, options, sort_orderControllo dell’editor e ordine di visualizzazione
searchable, indexed, translatableComportamento di ricerca, query e localizzazione

collection_id fa riferimento a _emdash_collections.id, e ogni slug di campo è unico all’interno della sua collezione.

Tabelle di contenuto per collezione

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

CREATE TABLE ec_products (
  -- Colonne di sistema, presenti in ogni tabella di contenuto
  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,

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

  UNIQUE (slug, locale)
);

Le colonne reali danno a ciascun campo un tipo di database, consentono indici e chiavi esterne, e permettono agli strumenti di database di ispezionare lo schema senza decodificare un blob JSON di contenuto. Il vincolo unique consente alle traduzioni di condividere uno slug mantenendo ogni slug unico all’interno di una lingua. Tutte le varianti linguistiche della stessa voce condividono un valore translation_group, che permette a EmDash di trovare le righe che sono traduzioni l’una dell’altra.

Le principali preoccupazioni sui dati rimangono separate:

AspettoPosizioneTabelle
SchemaTabelle di sistema_emdash_collections, _emdash_fields
ContenutoTabelle per collezioneec_posts, ec_products, …
MediaTabella separata + storageTabella media + storage configurato
ImpostazioniTabella opzionioptions con prefisso site:

Modifiche dello schema a runtime

L’aggiunta di un campo tramite l’UI di amministrazione esegue questi passaggi:

  1. Inserire la definizione del campo in _emdash_fields.
  2. Aggiungere la colonna corrispondente alla tabella ec_* della collezione e creare un indice quando il campo è configurato come indicizzato.
  3. Aggiornare i tipi di sviluppo generati affinché il nuovo campo appaia negli strumenti dell’editor.

La validazione del contenuto legge le definizioni dei campi attuali e costruisce uno schema Zod quando il contenuto viene creato o aggiornato. La modifica del tipo SQL sottostante, del vincolo required o unique, o del comportamento di localizzazione di un campo può richiedere una migrazione manuale del contenuto; SchemaRegistry rifiuta le modifiche sul posto non supportate invece di ricostruire la tabella implicitamente.

Validazione a runtime

EmDash deriva uno schema Zod dai campi attuali della collezione. Il generatore delega i dettagli di tipo e vincolo a 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);
}

Il gestore di contenuto rifiuta anche campi sconosciuti, verifica i valori stringa obbligatori e verifica i riferimenti ad altre collezioni.

Livello dati

EmDash utilizza Kysely per SQL tipizzato attraverso SQLite, libSQL, Cloudflare D1 e PostgreSQL. La configurazione del sito seleziona l’adattatore del database; l’integrazione espone la sua factory di dialetto tramite virtual:emdash/dialect.

Loader Live Content Collections

Il contenuto viene servito a runtime attraverso le Live Content Collections di Astro. emdashLoader() implementa l’interfaccia LiveLoader di Astro ed è registrato come una 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 collezione EmDash. getEmDashCollection("posts") fornisce il filtro di tipo posts, e il loader lo mappa alla tabella ec_posts.

Percorsi delle richieste

Una richiesta di contenuto da una pagina Astro segue questo percorso:

  1. La pagina chiama getEmDashCollection() o getEmDashEntry().
  2. Il wrapper di query chiama getLiveCollection() o getLiveEntry() di Astro con la collezione interna _emdash e il tipo di collezione EmDash richiesto.
  3. emdashLoader() interroga la tabella ec_* rilevante tramite Kysely, applicando regole di pubblicazione, lingua, filtro, ordinamento e paginazione.
  4. Il wrapper di query mappa le righe alle voci Astro e carica le loro firme e i termini di tassonomia.
  5. Il componente Astro renderizza le voci restituite.

Lo stato di anteprima e modalità di modifica viaggia attraverso il contesto della richiesta, così le stesse funzioni di query possono restituire contenuto bozza dopo che il middleware ha verificato la richiesta.

Una richiesta API di amministrazione segue un percorso separato:

  1. Il middleware autentica la richiesta e memorizza l’utente risolto in Astro.locals.
  2. La route API analizza la richiesta e verifica il permesso necessario per quell’operazione.
  3. La route delega la logica di business a un gestore o repository.
  4. Il gestore esegue gli hook del ciclo di vita dei plugin attorno all’operazione di database quando quell’operazione espone hook.
  5. La route restituisce una risposta JSON standard di successo o errore all’applicazione di amministrazione.

Interni del pannello di amministrazione

L’admin è un’applicazione a pagina singola React. Astro serve il suo shell e il middleware di autenticazione protegge le route di amministrazione. All’interno dell’applicazione, TanStack Router gestisce la navigazione, TanStack Query carica lo stato del server, TanStack Table renderizza le griglie di dati, React Hook Form e Zod gestiscono i moduli, TipTap modifica il Portable Text e Kumo fornisce il sistema di design.

Per l’autenticazione di sessione, il middleware reindirizza una richiesta del browser non autenticata alla pagina di login e restituisce un errore JSON per una richiesta API non autenticata. Dopo aver caricato un utente attivo, lo posiziona su Astro.locals per la route:

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

Dopo questo ramo, il middleware carica l’utente, rifiuta gli account mancanti o disabilitati, posiziona l’utente attivo su Astro.locals e prosegue verso la route.

UI guidata dal manifesto

L’admin non codifica in modo rigido gli schemi delle collezioni o i contributi dei plugin. Recupera GET /_emdash/api/manifest, che descrive le collezioni attuali, i campi, i plugin, le tassonomie, la modalità di autenticazione e altre capacità configurate. Un manifesto abbreviato appare così:

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

L’admin usa il manifesto per costruire la navigazione delle collezioni e gli editor dei campi. Poiché l’endpoint legge lo schema live, le modifiche a collezioni e campi appaiono senza ricostruire l’applicazione di amministrazione.

UI admin dei plugin

I punti di ingresso admin dei plugin configurati sono raccolti in virtual:emdash/admin-registry. Il modulo generato usa importazioni statiche affinché il bundler possa includere i componenti React:

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

export const pluginAdmins = { seo: pluginAdmin0 };

Conversione del testo ricco

I campi Portable Text usano TipTap, che è basato su ProseMirror. EmDash converte il Portable Text in ProseMirror quando l’editor carica e lo riconverte in Portable Text quando la voce viene salvata. I blocchi sconosciuti da plugin o importazioni vengono preservati come segnaposto di sola lettura invece di essere scartati.

Upload firmati

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

  1. Il client richiede una destinazione di upload da POST /_emdash/api/media/upload-url. EmDash crea un elemento media in sospeso.
  2. Il client carica verso la destinazione restituita. Gli adattatori compatibili con S3 possono restituire un URL firmato che aggira i limiti di dimensione del corpo dell’applicazione; i binding nativi R2 e lo storage locale restituiscono un endpoint di streaming EmDash.
  3. Il client conferma l’upload con POST /_emdash/api/media/:id/confirm.
  4. EmDash valida il file memorizzato e segna l’elemento media come pronto.

Estensione dell’importatore di contenuto

L’importatore WordPress usa un’interfaccia ImportSource estensibile. Una sorgente può sondare un URL, analizzare il contenuto disponibile rispetto allo schema attuale e trasmettere in streaming elementi di contenuto normalizzati:

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

La sorgente WXR importa file di esportazione WordPress. La sorgente connettore importa direttamente da siti con il plugin EmDash per WordPress. Una sorgente REST separata rileva siti WordPress pubblici, ma indirizza l’utente a un’esportazione WXR perché l’importazione REST diretta non è implementata. Registra un’altra sorgente quando un importatore può produrre le stesse forme di analisi normalizzata e elementi di contenuto.