Architecture (fonctionnement interne)

Sur cette page

Cette page est destinée aux personnes travaillant sur EmDash, pas à celles qui construisent un site avec. Elle explique la disposition de la base de données, l’intégration Astro, les chemins de requête, l’application d’administration, le flux de médias et le système d’importation. Si vous construisez un site, lisez plutôt Architecture et le Modèle de contenu.

L’intégration Astro

EmDash s’exécute comme une intégration Astro depuis le paquet emdash. Au moment de la compilation :

  • Il injecte l’application d’administration et les routes API REST avec l’API injectRoute d’Astro. Rien n’est copié dans le projet de l’utilisateur. Les principales familles de routes sont :

    Modèle de cheminObjectif
    /_emdash/admin/[...path]SPA du panneau d’administration
    /_emdash/api/manifestManifeste admin (collections, plugins)
    /_emdash/api/content/[collection]/...Opérations sur les entrées de contenu
    /_emdash/api/media/...Opérations de la bibliothèque média
    /_emdash/api/schema/...Gestion du schéma
    /_emdash/api/settings/...Paramètres du site
    /_emdash/api/menus/...Menus de navigation
    /_emdash/api/taxonomies/...Catégories, tags, taxonomies personnalisées
    /_emdash/api/plugins/[pluginId]/[...path]Routes API définies par les plugins

    L’injecteur de routes est l’inventaire complet, incluant l’authentification, les commentaires, la recherche, les importations, les widgets et les autres familles de routes.

  • Génère des modules virtuels pour que le bundler puisse résoudre le code de configuration et d’extension :

    ModuleObjectif
    virtual:emdash/configConfiguration de la base de données, du stockage et du site
    virtual:emdash/dialectFactory du dialecte de base de données
    virtual:emdash/admin-registryImportations statiques pour les interfaces admin des plugins
    virtual:emdash/pluginsImplémentations de plugins configurés
    virtual:emdash/media-providersFournisseurs de médias externes configurés

    virtual-modules.ts définit les helpers runtime restants et les contenus des modules générés.

  • Fournit le loader Live Content Collections et enregistre le middleware runtime. Au moment de la requête, le middleware ouvre les connexions de base de données et de stockage configurées et applique les migrations en attente avant que les routes les utilisent.

Schéma database-first

Les définitions de schéma résident dans la base de données, pas dans un fichier de configuration statique. _emdash_collections stocke une ligne par collection. Ses colonnes principales décrivent la collection et les fonctionnalités que le runtime et l’admin exposent :

ColonnesObjectif
id, slugIdentité stable de la collection
label, label_singular, description, iconNoms et orientations montrés aux rédacteurs
supports, has_seo, comments_enabled, edit_lockingCapacités optionnelles de la collection
title_field, date_field, admin_config, hidden, sort_orderComportement de la liste admin et de la navigation
url_pattern, routableURL publique et comportement du slug
sourceComment la collection a été créée

La valeur source enregistre la provenance comme manual, seed, template:<name>, import:<name> ou discovered. Les paramètres supplémentaires proviennent des migrations enregistrées, donc database/types.ts et les migrations sont l’inventaire actuel des colonnes.

_emdash_fields stocke les champs liés à chaque collection :

ColonnesObjectif
id, collection_id, slugIdentité du champ et collection propriétaire
label, type, column_typeLibellé de l’éditeur, type de champ EmDash et type de stockage SQL
required, unique, default_value, validationContraintes de contenu et valeurs par défaut
widget, options, sort_orderContrôle de l’éditeur et ordre d’affichage
searchable, indexed, translatableComportement de recherche, requête et localisation

collection_id référence _emdash_collections.id, et chaque slug de champ est unique au sein de sa collection.

Tables de contenu par collection

Chaque collection obtient sa propre table, préfixée ec_. Une collection products avec les champs title et price produit une table de cette forme :

CREATE TABLE ec_products (
  -- Colonnes système, présentes dans chaque table de contenu
  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,

  -- Colonnes de contenu, créées à partir des définitions de champs
  title TEXT NOT NULL,
  price REAL,

  UNIQUE (slug, locale)
);

Les vraies colonnes donnent à chaque champ un type de base de données, permettent les index et les clés étrangères, et laissent les outils de base de données inspecter le schéma sans décoder un blob JSON de contenu. La contrainte unique permet aux traductions de partager un slug tout en gardant chaque slug unique au sein d’une langue. Toutes les variantes linguistiques de la même entrée partagent une valeur translation_group, qui permet à EmDash de trouver les lignes qui sont des traductions les unes des autres.

Les principaux aspects des données restent séparés :

AspectEmplacementTables
SchémaTables système_emdash_collections, _emdash_fields
ContenuTables par collectionec_posts, ec_products, …
MédiasTable séparée + stockageTable media + stockage configuré
ParamètresTable d’optionsoptions avec préfixe site:

Changements de schéma au runtime

L’ajout d’un champ via l’UI d’administration exécute ces étapes :

  1. Insérer la définition du champ dans _emdash_fields.
  2. Ajouter la colonne correspondante à la table ec_* de la collection et créer un index lorsque le champ est configuré comme indexé.
  3. Rafraîchir les types de développement générés pour que le nouveau champ apparaisse dans les outils de l’éditeur.

La validation de contenu lit les définitions de champs actuelles et construit un schéma Zod lorsque du contenu est créé ou mis à jour. Changer le type SQL sous-jacent, la contrainte required ou unique, ou le comportement de localisation d’un champ peut nécessiter une migration de contenu manuelle ; SchemaRegistry rejette les changements sur place non supportés au lieu de reconstruire la table implicitement.

Validation au runtime

EmDash dérive un schéma Zod des champs actuels de la collection. Le générateur délègue les détails de type et de contrainte à 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);
}

Le gestionnaire de contenu rejette également les champs inconnus, vérifie les valeurs de chaîne requises et vérifie les références à d’autres collections.

Couche de données

EmDash utilise Kysely pour du SQL typé à travers SQLite, libSQL, Cloudflare D1 et PostgreSQL. La configuration du site sélectionne l’adaptateur de base de données ; l’intégration expose sa factory de dialecte via virtual:emdash/dialect.

Loader Live Content Collections

Le contenu est servi au runtime via les Live Content Collections d’Astro. emdashLoader() implémente l’interface LiveLoader d’Astro et est enregistré comme une seule collection _emdash :

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

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

La collection unique _emdash englobe chaque collection EmDash. getEmDashCollection("posts") fournit le filtre de type posts, et le loader le mappe à la table ec_posts.

Chemins de requête

Une requête de contenu depuis une page Astro suit ce chemin :

  1. La page appelle getEmDashCollection() ou getEmDashEntry().
  2. Le wrapper de requête appelle getLiveCollection() ou getLiveEntry() d’Astro avec la collection interne _emdash et le type de collection EmDash demandé.
  3. emdashLoader() interroge la table ec_* pertinente via Kysely, appliquant les règles de publication, de langue, de filtre, de tri et de pagination.
  4. Le wrapper de requête mappe les lignes aux entrées Astro et charge leurs signatures et termes de taxonomie.
  5. Le composant Astro rend les entrées retournées.

L’état de prévisualisation et du mode édition voyage à travers le contexte de requête, de sorte que les mêmes fonctions de requête peuvent retourner du contenu brouillon après que le middleware a vérifié la requête.

Une requête API d’administration suit un chemin séparé :

  1. Le middleware authentifie la requête et stocke l’utilisateur résolu dans Astro.locals.
  2. La route API analyse la requête et vérifie la permission nécessaire pour cette opération.
  3. La route délègue la logique métier à un gestionnaire ou un dépôt.
  4. Le gestionnaire exécute les hooks de cycle de vie des plugins autour de l’opération de base de données lorsque cette opération expose des hooks.
  5. La route retourne une réponse JSON standard de succès ou d’erreur à l’application d’administration.

Fonctionnement interne du panneau d’administration

L’admin est une application monopage React. Astro sert son shell et le middleware d’authentification protège les routes d’administration. À l’intérieur de l’application, TanStack Router gère la navigation, TanStack Query charge l’état du serveur, TanStack Table rend les grilles de données, React Hook Form et Zod gèrent les formulaires, TipTap édite le Portable Text et Kumo fournit le système de design.

Pour l’authentification de session, le middleware redirige une requête de navigateur non authentifiée vers la page de connexion et retourne une erreur JSON pour une requête API non authentifiée. Après avoir chargé un utilisateur actif, il le place sur Astro.locals pour 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());
}

Après cette branche, le middleware charge l’utilisateur, rejette les comptes manquants ou désactivés, place l’utilisateur actif sur Astro.locals et continue vers la route.

UI pilotée par le manifeste

L’admin ne code pas en dur les schémas de collection ou les contributions des plugins. Il récupère GET /_emdash/api/manifest, qui décrit les collections actuelles, champs, plugins, taxonomies, mode d’authentification et autres capacités configurées. Un manifeste abrégé ressemble à ceci :

{
	"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 utilise le manifeste pour construire la navigation des collections et les éditeurs de champs. Comme le endpoint lit le schéma en direct, les changements de collections et de champs apparaissent sans reconstruire l’application d’administration.

UIs admin des plugins

Les points d’entrée admin des plugins configurés sont rassemblés dans virtual:emdash/admin-registry. Le module généré utilise des importations statiques pour que le bundler puisse inclure les composants React :

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

export const pluginAdmins = { seo: pluginAdmin0 };

Conversion de texte riche

Les champs Portable Text utilisent TipTap, qui est basé sur ProseMirror. EmDash convertit le Portable Text en ProseMirror lorsque l’éditeur charge et le reconvertit en Portable Text lorsque l’entrée est enregistrée. Les blocs inconnus provenant de plugins ou d’importations sont préservés comme des espaces réservés en lecture seule au lieu d’être supprimés.

Uploads signés

Les uploads de médias utilisent des URLs signées directes vers le stockage lorsque l’adaptateur de stockage les supporte et un endpoint de streaming same-origin sinon :

  1. Le client demande une cible d’upload depuis POST /_emdash/api/media/upload-url. EmDash crée un élément média en attente.
  2. Le client upload vers la cible retournée. Les adaptateurs compatibles S3 peuvent retourner une URL signée qui contourne les limites de taille du corps de l’application ; les bindings natifs R2 et le stockage local retournent un endpoint de streaming EmDash.
  3. Le client confirme l’upload avec POST /_emdash/api/media/:id/confirm.
  4. EmDash valide le fichier stocké et marque l’élément média comme prêt.

Extension de l’importateur de contenu

L’importateur WordPress utilise une interface ImportSource extensible. Une source peut sonder une URL, analyser le contenu disponible par rapport au schéma actuel et diffuser des éléments de contenu normalisés :

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 source WXR importe les fichiers d’exportation WordPress. La source connecteur importe directement depuis des sites avec le plugin EmDash WordPress. Une source REST séparée détecte les sites WordPress publics, mais dirige l’utilisateur vers un export WXR car l’import REST direct n’est pas implémenté. Enregistrez une autre source lorsqu’un importateur peut produire les mêmes formes d’analyse normalisée et d’éléments de contenu.