Cette page est destinée aux personnes travaillant sur EmDash, et non à celles qui construisent un site avec. Elle documente les mécanismes internes : dispositions de tables, l’intégration Astro, le chemin de requête, la génération de code. Rien de tout cela n’est nécessaire pour utiliser EmDash. Si vous construisez un site, lisez plutôt Architecture et le Content Model.
L’intégration Astro
EmDash s’exécute en tant qu’intégration Astro depuis le package emdash. Au moment de la compilation :
-
Injecte le SPA admin et les routes API REST avec l’API
injectRouted’Astro. Rien n’est copié dans le projet de l’utilisateur. Les chemins injectés sont :Modèle de chemin Objectif /_emdash/admin/[...path]SPA du panneau d’administration /_emdash/api/manifestManifeste admin (collections, plugins) /_emdash/api/content/[collection]CRUD d’entrées de contenu /_emdash/api/media/*Opérations de bibliothèque de médias /_emdash/api/schema/*Gestion de schéma /_emdash/api/settingsParamètres du site /_emdash/api/menus/*Menus de navigation /_emdash/api/taxonomies/*Catégories, tags, taxonomies personnalisées -
Génère des modules virtuels afin que le bundler puisse résoudre et tree-shake le code de configuration et de plugins :
Module Objectif virtual:emdash/configConfiguration de base de données et stockage virtual:emdash/dialectFactory de dialecte de base de données virtual:emdash/plugin-adminsImportations statiques pour les UIs admin de plugins -
Fournit le chargeur Live Collections, gère les migrations et ouvre la connexion de stockage.
Schéma database-first
Les définitions de schéma vivent dans la base de données, pas dans le code. Deux tables système suivent la structure.
_emdash_collections contient une ligne par collection :
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, -- how it was created
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT
);
La colonne source enregistre la provenance : manual (UI admin), template:<name> (fichier seed), import:wordpress (importateur) ou discovered (auto-détecté à partir de tables existantes).
_emdash_fields contient une ligne par champ, liée à sa collection :
CREATE TABLE _emdash_fields (
id TEXT PRIMARY KEY,
collection_id TEXT REFERENCES _emdash_collections(id),
slug TEXT NOT NULL, -- column name
label TEXT NOT NULL,
type TEXT NOT NULL, -- field type
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)
);
Tables de contenu par collection
Chaque collection obtient sa propre table, préfixée ec_. Une collection products avec des champs title et price produit :
CREATE TABLE ec_products (
-- System columns, always present
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, -- soft delete
version INTEGER DEFAULT 1, -- optimistic locking
-- Content columns, from field definitions
title TEXT NOT NULL,
price REAL
);
Les vraies colonnes (plutôt qu’une table avec un blob JSON) offrent une indexation appropriée, des clés étrangères fonctionnelles, un schéma que les outils de base de données peuvent inspecter et aucune analyse JSON champ par champ.
Les préoccupations restent séparées :
| Préoccupation | Emplacement | Tables |
|---|---|---|
| Schéma | Tables système | _emdash_collections, _emdash_fields |
| Contenu | Tables par collection | ec_posts, ec_products, … |
| Médias | Table séparée + stockage | Table media + R2/S3 |
| Paramètres | Table d’options | options avec préfixe site: |
Modifications de schéma à l’exécution
L’ajout d’un champ via l’UI admin exécute trois étapes :
- Insère un enregistrement dans
_emdash_fields. - Exécute
ALTER TABLE ec_<collection> ADD COLUMN <name> <TYPE>. - Régénère le schéma Zod utilisé pour la validation.
SQLite prend en charge l’ajout, le renommage et la suppression de colonnes (la suppression nécessite SQLite 3.35+) à l’exécution. La modification du type d’une colonne n’est pas prise en charge sur place, donc EmDash reconstruit la table de manière transparente : créer une nouvelle table, copier les lignes, supprimer l’ancienne table, renommer la nouvelle.
Validation à l’exécution
EmDash construit des schémas Zod à partir des définitions de champs au démarrage et valide chaque création et mise à jour contre eux :
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);
}
Couche de données
EmDash utilise Kysely pour du SQL sûr au niveau des types sur toutes les bases de données prises en charge (SQLite, libSQL, Cloudflare D1 et PostgreSQL). Le dialecte est sélectionné par virtual:emdash/dialect depuis la configuration que le site transmet à l’intégration.
Chargeur Live Collections
Le contenu est servi à l’exécution via les Live 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 encapsule chaque type de contenu ; le chargeur filtre par type lorsque getEmDashCollection("posts") est appelé.
Chemins de requête
Une requête de contenu depuis une page :
- Astro reçoit la requête et exécute le composant de page.
getEmDashCollection()appellegetLiveCollection()d’Astro.emdashLoaderinterroge la tableec_*pertinente via Kysely.- Les lignes sont mappées au format d’entrée d’Astro (
id,slug,data). - Le composant se rend.
Une requête admin :
- Le middleware valide le jeton de session.
- La route API exécute CRUD via un dépôt.
- Les hooks de cycle de vie se déclenchent (par exemple
content:beforeSave). - Kysely exécute le SQL.
- La route renvoie du JSON au SPA admin.
Internals du panneau d’administration
L’admin est une île React. Astro sert le shell et applique l’authentification dans le middleware ; tout à l’intérieur est côté client, construit sur TanStack Router, TanStack Query, TanStack Table, React Hook Form + Zod, TipTap et Kumo (Base UI de Cloudflare + système de design Tailwind).
La route shell contrôle l’accès dans le 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 pilotée par manifeste
L’admin ne code rien en dur sur les collections ou plugins. Il récupère GET /_emdash/api/manifest, qui renvoie les collections, plugins et taxonomies auxquels l’utilisateur demandeur peut accéder, filtrés par rôle :
{
"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 navigation, les formulaires et les éditeurs de champs sont générés à partir de ce manifeste, donc les modifications de schéma et de plugins apparaissent sans reconstruction de l’admin, et les schémas Zod restent côté serveur.
UIs admin de plugins
Les points d’entrée admin des plugins sont collectés dans un module virtuel généré d’importations statiques afin que le bundler puisse les résoudre et tree-shake :
import * as pluginAdmin0 from "@emdash-cms/plugin-seo/admin";
export const pluginAdmins = { seo: pluginAdmin0 };
Conversion de texte riche
Les champs Portable Text s’éditent dans TipTap (ProseMirror). Le contenu est converti aux frontières de chargement et de sauvegarde par portableTextToProsemirror() et prosemirrorToPortableText(). Les blocs inconnus provenant de plugins ou d’importations sont préservés comme espaces réservés en lecture seule.
Téléchargements signés
Les téléchargements de médias contournent les limites de taille de corps Worker avec des URLs signées directes vers le stockage :
- Le client demande une URL de téléchargement (
POST /api/media/upload-url). - Le client télécharge directement vers l’URL signée (R2 ou S3).
- Le client confirme (
POST /api/media/:id/confirm). - Le serveur extrait les métadonnées (dimensions, type MIME).
Extension de l’importateur de contenu
L’importateur WordPress est construit sur une interface pluggable ImportSource. Une source personnalisée implémente probe, analyze et fetch :
interface ImportSource {
probe(input: ImportInput): Promise<ProbeResult>;
analyze(input: ImportInput): Promise<AnalysisResult>;
fetchContent(input: ImportInput): AsyncIterable<NormalizedEntry>;
}
probe valide l’entrée et rapporte ce qu’elle a trouvé, analyze mappe les types de publication source aux collections EmDash et signale les lacunes de schéma, et fetchContent diffuse des entrées normalisées que le pipeline d’importation écrit via les mêmes dépôts que l’admin utilise. Les sources intégrées couvrent WordPress WXR, WordPress.com et l’API REST WordPress ; enregistrez une source personnalisée pour importer depuis un autre système.