diff --git a/docs/modules/EXTERNAL_MODULE.md b/docs/modules/EXTERNAL_MODULE.md
new file mode 100644
index 0000000..a6a6b35
--- /dev/null
+++ b/docs/modules/EXTERNAL_MODULE.md
@@ -0,0 +1,255 @@
+# Créer un module externe
+
+Un module externe est un package npm indépendant qui s'intègre dans une app qui utilise `@hykocx/zen`. Il n'a pas besoin de modifier le code source du CMS.
+
+---
+
+## Convention de nommage
+
+```
+@scope/zen-nom-du-module
+```
+
+Exemples : `@hykocx/zen-invoice`, `@hykocx/zen-nuage`.
+
+---
+
+## Structure du package
+
+```
+zen-invoice/
+├── index.js # Point d'entrée — exporte defineModule()
+├── admin/ # Composants React pour l'admin
+│ ├── InvoiceListPage.js
+│ ├── InvoiceCreatePage.js
+│ └── InvoiceEditPage.js
+├── db.js # createTables() et dropTables()
+├── actions.js # Server actions pour pages publiques
+├── metadata.js # Générateurs de métadonnées SEO
+├── package.json
+└── .env.example
+```
+
+---
+
+## index.js
+
+On utilise `defineModule()` importé depuis `@hykocx/zen/modules/define`.
+
+```js
+import { lazy } from 'react';
+import { defineModule } from '@hykocx/zen/modules/define';
+
+import { createTables, dropTables } from './db.js';
+import { getInvoiceByToken } from './actions.js';
+import { generatePaymentMetadata } from './metadata.js';
+
+const InvoiceListPage = lazy(() => import('./admin/InvoiceListPage.js'));
+const InvoiceCreatePage = lazy(() => import('./admin/InvoiceCreatePage.js'));
+const InvoiceEditPage = lazy(() => import('./admin/InvoiceEditPage.js'));
+
+export default defineModule({
+ name: 'invoice',
+ displayName: 'Facturation',
+ version: '1.0.0',
+ description: 'Gestion des factures et paiements.',
+
+ dependencies: [],
+ envVars: ['STRIPE_SECRET_KEY', 'ZEN_INVOICE_TAX_RATE'],
+
+ // Navigation dans le panneau admin
+ navigation: [
+ {
+ id: 'invoice',
+ title: 'Facturation',
+ icon: 'Invoice03Icon',
+ items: [
+ { name: 'Factures', href: '/admin/invoice/list', icon: 'Invoice03Icon' },
+ { name: 'Nouvelle', href: '/admin/invoice/new', icon: 'Add01Icon' },
+ ],
+ },
+ ],
+
+ // Pages admin avec leurs composants lazy
+ adminPages: {
+ '/admin/invoice/list': InvoiceListPage,
+ '/admin/invoice/new': InvoiceCreatePage,
+ '/admin/invoice/edit': InvoiceEditPage,
+ },
+
+ // Résolveur pour les routes dynamiques (ex: /admin/invoice/edit/123)
+ pageResolver(path) {
+ const parts = path.split('/').filter(Boolean);
+ if (parts[0] !== 'admin' || parts[1] !== 'invoice') return null;
+ if (parts[2] === 'list') return InvoiceListPage;
+ if (parts[2] === 'new') return InvoiceCreatePage;
+ if (parts[2] === 'edit') return InvoiceEditPage;
+ return null;
+ },
+
+ publicPages: {},
+ publicRoutes: [
+ { pattern: ':token', description: 'Page de paiement' },
+ { pattern: ':token/pdf', description: 'Télécharger la facture PDF' },
+ ],
+
+ dashboardWidgets: [],
+
+ // Base de données
+ db: { createTables, dropTables },
+
+ // Server actions pour les pages publiques (/zen/invoice/...)
+ actions: { getInvoiceByToken },
+
+ // Générateurs de métadonnées SEO
+ metadata: {
+ payment: generatePaymentMetadata,
+ },
+
+ // Appelé une fois au démarrage du serveur
+ // ctx donne accès aux services du CMS
+ async setup(ctx) {
+ const stripe = await ctx.payments.then(p => p.stripe);
+ // Initialiser des webhooks, vérifier la config, etc.
+ console.log('[invoice] Stripe prêt :', !!stripe);
+ },
+});
+```
+
+---
+
+## L'objet ctx dans setup()
+
+`setup(ctx)` reçoit un objet qui donne accès aux services du CMS. Chaque propriété retourne une promesse vers le module correspondant.
+
+```js
+async setup(ctx) {
+ // Base de données PostgreSQL
+ const { query, queryOne, queryAll } = await ctx.db;
+ const rows = await query('SELECT * FROM zen_auth_users LIMIT 1');
+
+ // Envoi de courriels (Resend)
+ const { sendEmail } = await ctx.email;
+ await sendEmail({ to: 'test@example.com', subject: 'Test', html: '
ok
' });
+
+ // Stockage de fichiers (Cloudflare R2)
+ const { uploadFile, deleteFile } = await ctx.storage;
+
+ // Stripe
+ const { stripe } = await ctx.payments;
+
+ // Variables d'environnement
+ const apiKey = ctx.config.get('STRIPE_SECRET_KEY');
+}
+```
+
+---
+
+## package.json du module externe
+
+```json
+{
+ "name": "@hykocx/zen-invoice",
+ "version": "1.0.0",
+ "type": "module",
+ "main": "./index.js",
+ "exports": {
+ ".": "./index.js"
+ },
+ "peerDependencies": {
+ "@hykocx/zen": ">=1.0.0",
+ "react": ">=19.0.0"
+ }
+}
+```
+
+---
+
+## Intégration dans l'app consommatrice
+
+### 1. Installer le package
+
+```bash
+npm install @hykocx/zen-invoice
+```
+
+### 2. Créer zen.config.js à la racine de l'app
+
+```js
+// zen.config.js
+import invoiceModule from '@hykocx/zen-invoice';
+
+export default {
+ modules: [invoiceModule],
+};
+```
+
+### 3. Passer la config à initializeZen()
+
+```js
+// instrumentation.js
+import zenConfig from './zen.config.js';
+
+export async function register() {
+ if (process.env.NEXT_RUNTIME === 'nodejs') {
+ const { initializeZen } = await import('@hykocx/zen');
+ await initializeZen(zenConfig);
+ }
+}
+```
+
+### 4. Passer les modules à ZenProvider
+
+```jsx
+// app/layout.js
+import zenConfig from './zen.config.js';
+import { ZenProvider } from '@hykocx/zen/provider';
+
+export default function RootLayout({ children }) {
+ return (
+
+
+
+ {children}
+
+
+
+ );
+}
+```
+
+### 5. Activer le module via variable d'environnement
+
+```bash
+# .env.local
+ZEN_MODULE_INVOICE=true
+```
+
+La convention est la même que pour les modules internes : tirets en underscores, tout en majuscules.
+
+---
+
+## Initialiser la base de données
+
+Le module déclare ses tables dans `db.createTables`. Deux façons de les créer.
+
+**Au démarrage du serveur** (si `skipDb: false`) :
+
+```js
+await initializeZen({ modules: zenConfig.modules, skipDb: false });
+```
+
+**Via la CLI** :
+
+```bash
+npx zen-db init
+```
+
+---
+
+## Vérification rapide
+
+1. Démarrer avec `ZEN_MODULE_INVOICE=true`.
+2. Ouvrir `/admin`. La section "Facturation" doit apparaître dans la navigation.
+3. Naviguer vers `/admin/invoice/list`. La page du module doit se charger.
+4. Appeler `getModuleActions('invoice')` côté serveur. Les actions du module doivent être retournées.
diff --git a/docs/modules/INTERNAL_MODULE.md b/docs/modules/INTERNAL_MODULE.md
new file mode 100644
index 0000000..6fca0e5
--- /dev/null
+++ b/docs/modules/INTERNAL_MODULE.md
@@ -0,0 +1,221 @@
+# Créer un module interne
+
+Un module interne vit dans `src/modules/` et fait partie du package `@hykocx/zen`. Il a accès direct aux services du CMS sans passer par un contexte injecté.
+
+---
+
+## Structure d'un module
+
+```
+src/modules/mon-module/
+├── module.config.js # Obligatoire — navigation, pages, routes, cron, etc.
+├── db.js # Optionnel — createTables() et dropTables()
+├── api.js # Optionnel — routes REST
+├── cron.config.js # Optionnel — tâches planifiées
+├── crud.js # Optionnel — accès aux données
+├── admin/ # Composants React pour l'admin
+└── .env.example # Variables d'environnement requises
+```
+
+---
+
+## module.config.js
+
+C'est la source de vérité du module. On utilise `defineModule()` pour déclarer la configuration.
+
+```js
+import { lazy } from 'react';
+import { defineModule } from '../../core/modules/defineModule.js';
+
+const ListPage = lazy(() => import('./admin/ListPage.js'));
+const CreatePage = lazy(() => import('./admin/CreatePage.js'));
+const EditPage = lazy(() => import('./admin/EditPage.js'));
+
+export default defineModule({
+ name: 'mon-module',
+ displayName: 'Mon module',
+ version: '1.0.0',
+ description: 'Description courte.',
+
+ // Modules dont celui-ci dépend (vérification au démarrage)
+ dependencies: [],
+
+ // Variables d'environnement que ce module lit
+ envVars: ['ZEN_MON_MODULE_OPTION'],
+
+ // Navigation admin — un ou plusieurs objets de section
+ navigation: [
+ {
+ id: 'mon-module',
+ title: 'Mon module',
+ icon: 'SomeIcon',
+ items: [
+ { name: 'Liste', href: '/admin/mon-module/list', icon: 'SomeIcon' },
+ { name: 'Nouveau', href: '/admin/mon-module/new', icon: 'AddIcon' },
+ ],
+ },
+ ],
+
+ // Pages admin — chemin exact vers composant lazy
+ adminPages: {
+ '/admin/mon-module/list': ListPage,
+ '/admin/mon-module/new': CreatePage,
+ '/admin/mon-module/edit': EditPage,
+ },
+
+ // Résolveur pour les routes dynamiques (non connues à la compilation)
+ // Retourner null si le chemin ne correspond pas
+ pageResolver(path) {
+ const parts = path.split('/').filter(Boolean);
+ if (parts[0] !== 'admin' || parts[1] !== 'mon-module') return null;
+ if (parts[2] === 'list') return ListPage;
+ if (parts[2] === 'new') return CreatePage;
+ if (parts[2] === 'edit') return EditPage;
+ return null;
+ },
+
+ // Pages publiques accessibles sans authentification (/zen/mon-module/...)
+ publicPages: {},
+ publicRoutes: [],
+
+ // Widgets affichés sur le dashboard admin
+ dashboardWidgets: [],
+});
+```
+
+---
+
+## db.js
+
+Si le module crée des tables, on exporte `createTables` et `dropTables`.
+
+```js
+import { query } from '../../core/database/index.js';
+import { tableExists } from '../../core/database/helpers.js';
+
+export async function createTables() {
+ const created = [];
+ const skipped = [];
+
+ if (!(await tableExists('zen_mon_module'))) {
+ await query(`
+ CREATE TABLE zen_mon_module (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ titre TEXT NOT NULL,
+ created_at TIMESTAMPTZ DEFAULT NOW()
+ )
+ `);
+ created.push('zen_mon_module');
+ } else {
+ skipped.push('zen_mon_module');
+ }
+
+ return { created, skipped };
+}
+
+export async function dropTables() {
+ await query('DROP TABLE IF EXISTS zen_mon_module CASCADE');
+}
+```
+
+---
+
+## api.js
+
+Les routes API sont montées automatiquement sous `/api/zen/mon-module/...`.
+
+```js
+async function handleList(request) {
+ // ...
+ return Response.json({ items });
+}
+
+export default {
+ routes: [
+ {
+ path: 'mon-module/list',
+ method: 'GET',
+ handler: handleList,
+ requireAuth: true,
+ requireAdmin: false,
+ },
+ ],
+};
+```
+
+---
+
+## cron.config.js
+
+```js
+import { maFonction } from './crud.js';
+
+export default {
+ jobs: [
+ {
+ name: 'mon-module:nettoyage',
+ schedule: '0 3 * * *', // Tous les jours à 3h
+ handler: maFonction,
+ timezone: 'America/Toronto',
+ },
+ ],
+};
+```
+
+---
+
+## Enregistrement — 2 étapes
+
+Après avoir créé les fichiers, on enregistre le module dans deux endroits.
+
+### 1. `src/modules/modules.registry.js`
+
+Ajouter le nom du module à `AVAILABLE_MODULES` :
+
+```js
+export const AVAILABLE_MODULES = [
+ 'posts',
+ 'mon-module', // ajout
+];
+```
+
+### 2. `src/modules/modules.pages.js`
+
+Importer la config et l'ajouter à `MODULE_CONFIGS` :
+
+```js
+import postsConfig from './posts/module.config.js';
+import monModuleConfig from './mon-module/module.config.js'; // ajout
+
+const MODULE_CONFIGS = {
+ posts: postsConfig,
+ 'mon-module': monModuleConfig, // ajout
+};
+```
+
+---
+
+## Activation
+
+Le module est ignoré au démarrage tant que sa variable d'environnement n'est pas définie :
+
+```bash
+# .env.local
+ZEN_MODULE_MON_MODULE=true
+```
+
+La règle de conversion : tirets et espaces deviennent des underscores, tout en majuscules.
+
+```
+mon-module → ZEN_MODULE_MON_MODULE
+post-types → ZEN_MODULE_POST_TYPES
+```
+
+---
+
+## Vérification rapide
+
+1. Démarrer le serveur avec `ZEN_MODULE_MON_MODULE=true`.
+2. Ouvrir `/admin`. La section de navigation du module doit apparaître.
+3. Naviguer vers `/admin/mon-module/list`. La page doit se charger.
+4. Lancer `npx zen-db init`. La table `zen_mon_module` doit être créée.
diff --git a/package.json b/package.json
index 0d8b3fe..b0e46c0 100644
--- a/package.json
+++ b/package.json
@@ -135,6 +135,9 @@
"./modules": {
"import": "./dist/modules/index.js"
},
+ "./modules/define": {
+ "import": "./dist/core/modules/defineModule.js"
+ },
"./modules/pages": {
"import": "./dist/modules/pages.js"
},
diff --git a/src/core/modules/defineModule.js b/src/core/modules/defineModule.js
new file mode 100644
index 0000000..66359f1
--- /dev/null
+++ b/src/core/modules/defineModule.js
@@ -0,0 +1,56 @@
+/**
+ * defineModule — helper to declare a ZEN module.
+ *
+ * Used for both internal modules (src/modules/) and external npm packages.
+ *
+ * @param {Object} config - Module configuration
+ * @returns {Object} Normalized module configuration
+ */
+export function defineModule(config) {
+ if (!config || typeof config !== 'object') {
+ throw new Error('[defineModule] Config must be an object.');
+ }
+
+ if (!config.name || typeof config.name !== 'string') {
+ throw new Error('[defineModule] Field "name" is required (e.g. "invoice").');
+ }
+
+ return {
+ // Identity
+ version: '1.0.0',
+ displayName: config.name.charAt(0).toUpperCase() + config.name.slice(1),
+ description: '',
+
+ // Dependencies and environment variables
+ dependencies: [],
+ envVars: [],
+
+ // Admin UI
+ navigation: null,
+ adminPages: {},
+ pageResolver: null,
+
+ // Public pages
+ publicPages: {},
+ publicRoutes: [],
+ dashboardWidgets: [],
+
+ // Server actions for public pages
+ actions: {},
+
+ // SEO metadata generators
+ metadata: {},
+
+ // Database (optional) — { createTables, dropTables }
+ db: null,
+
+ // Initialization callback (optional) — setup(ctx)
+ setup: null,
+
+ // Spread last so all fields above can be overridden
+ ...config,
+
+ // Internal marker — do not override
+ __isZenModule: true,
+ };
+}
diff --git a/src/core/modules/discovery.js b/src/core/modules/discovery.js
index fb8da86..0751254 100644
--- a/src/core/modules/discovery.js
+++ b/src/core/modules/discovery.js
@@ -1,6 +1,7 @@
/**
* Module Discovery System
- * Auto-discovers and registers modules from the modules directory
+ * Auto-discovers and registers modules from the modules directory.
+ * Also handles registration of external modules passed via zen.config.js.
*/
import { registerModule, clearRegistry } from './registry.js';
@@ -95,27 +96,7 @@ async function loadModuleConfig(moduleName) {
try {
const config = await import(`../../modules/${moduleName}/module.config.js`);
const moduleConfig = config.default || config;
-
- // Build admin config with navigation and pages
- let adminConfig = undefined;
- if (moduleConfig.navigation || moduleConfig.adminPages) {
- adminConfig = {};
- if (moduleConfig.navigation) {
- adminConfig.navigation = moduleConfig.navigation;
- }
- // Extract admin page paths (keys only, not the lazy components)
- // This allows getAdminPage() to know which paths belong to this module
- if (moduleConfig.adminPages) {
- adminConfig.pages = {};
- for (const path of Object.keys(moduleConfig.adminPages)) {
- // Store true as a marker that this path exists
- // The actual component is loaded client-side via modules.pages.js
- adminConfig.pages[path] = true;
- }
- }
- }
-
- // Extract server-side relevant data
+
return {
name: moduleConfig.name || moduleName,
displayName: moduleConfig.displayName || moduleName.charAt(0).toUpperCase() + moduleName.slice(1),
@@ -123,12 +104,12 @@ async function loadModuleConfig(moduleName) {
description: moduleConfig.description || `${moduleName} module`,
dependencies: moduleConfig.dependencies || [],
envVars: moduleConfig.envVars || [],
- // Admin configuration (navigation + page paths)
- admin: adminConfig,
- // Public routes metadata (not components)
- public: moduleConfig.publicRoutes ? {
- routes: moduleConfig.publicRoutes
- } : undefined,
+ // Admin config: navigation + page path markers (components loaded client-side)
+ admin: buildAdminConfig(moduleConfig),
+ // Public routes metadata (components loaded client-side)
+ public: moduleConfig.publicRoutes?.length
+ ? { routes: moduleConfig.publicRoutes }
+ : undefined,
};
} catch (error) {
console.log(`[Module Discovery] No module.config.js for ${moduleName}, using defaults`);
@@ -182,6 +163,131 @@ async function loadModuleComponents(moduleName) {
return components;
}
+/**
+ * Register external modules provided via zen.config.js.
+ * Skips any module whose ZEN_MODULE_=true env var is not set.
+ *
+ * @param {Array} modules - Array of module configs created with defineModule()
+ * @returns {Promise