From 99a56d2c3940ef4f2317e51f887877167a3dcc02 Mon Sep 17 00:00:00 2001 From: Hyko Date: Sun, 12 Apr 2026 13:39:56 -0400 Subject: [PATCH] feat(modules): add external module registration and defineModule support - Add `./modules/define` export path pointing to `defineModule.js` - Implement `registerExternalModules()` to handle modules passed via `zen.config.js`, with env var gating (`ZEN_MODULE_=true`) - Extract `buildAdminConfig()` helper to consolidate admin navigation/page config building - Refactor `loadModuleConfig()` to use `buildAdminConfig()` and simplify public routes check - Improve `initializeModuleTables()` to gracefully skip modules without `db.js` instead of erroring - Update module discovery JSDoc to reflect external module registration support --- docs/modules/EXTERNAL_MODULE.md | 255 +++++++++++++++++++++++++++ docs/modules/INTERNAL_MODULE.md | 221 +++++++++++++++++++++++ package.json | 3 + src/core/modules/defineModule.js | 56 ++++++ src/core/modules/discovery.js | 162 ++++++++++++++--- src/core/modules/index.js | 1 + src/features/provider/ZenProvider.js | 34 +++- src/modules/index.js | 4 + src/modules/init.js | 64 +++---- src/modules/modules.actions.js | 37 ++-- src/modules/modules.metadata.js | 60 ++++--- src/modules/modules.pages.js | 70 ++++++-- src/shared/lib/init.js | 50 ++++-- 13 files changed, 871 insertions(+), 146 deletions(-) create mode 100644 docs/modules/EXTERNAL_MODULE.md create mode 100644 docs/modules/INTERNAL_MODULE.md create mode 100644 src/core/modules/defineModule.js 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} { registered, skipped, errors } + */ +export async function registerExternalModules(modules = []) { + const registered = []; + const skipped = []; + const errors = []; + + for (const moduleConfig of modules) { + const moduleName = moduleConfig?.name; + + if (!moduleName) { + errors.push({ module: '(unknown)', error: 'Missing "name" field.' }); + continue; + } + + try { + if (!isModuleEnabledInEnv(moduleName)) { + skipped.push(moduleName); + console.log(`[External Modules] Skipped ${moduleName} (not enabled)`); + continue; + } + + // Build registry entry from the external module config + const adminConfig = buildAdminConfig(moduleConfig); + + const moduleData = { + name: moduleName, + displayName: moduleConfig.displayName || moduleName, + version: moduleConfig.version || '1.0.0', + description: moduleConfig.description || '', + dependencies: moduleConfig.dependencies || [], + envVars: moduleConfig.envVars || [], + admin: adminConfig, + public: moduleConfig.publicRoutes?.length + ? { routes: moduleConfig.publicRoutes } + : undefined, + actions: moduleConfig.actions || {}, + metadata: moduleConfig.metadata || {}, + db: moduleConfig.db + ? { init: moduleConfig.db.createTables, drop: moduleConfig.db.dropTables } + : undefined, + cron: moduleConfig.cron || undefined, + api: moduleConfig.api || undefined, + enabled: true, + external: true, + }; + + registerModule(moduleName, moduleData); + + // Call setup(ctx) if provided + if (typeof moduleConfig.setup === 'function') { + const ctx = await buildModuleContext(); + await moduleConfig.setup(ctx); + } + + registered.push(moduleName); + console.log(`[External Modules] Registered ${moduleName}`); + } catch (error) { + errors.push({ module: moduleName, error: error.message }); + console.error(`[External Modules] Error registering ${moduleName}:`, error); + } + } + + if (registered.length > 0 || skipped.length > 0) { + console.log( + `[External Modules] Done. Registered: ${registered.length}, Skipped: ${skipped.length}, Errors: ${errors.length}` + ); + } + + return { registered, skipped, errors }; +} + +/** + * Build admin config object from a module config (shared with loadModuleConfig). + * @param {Object} moduleConfig + * @returns {Object|undefined} + */ +function buildAdminConfig(moduleConfig) { + if (!moduleConfig.navigation && !moduleConfig.adminPages) return undefined; + + const adminConfig = {}; + + if (moduleConfig.navigation) { + adminConfig.navigation = moduleConfig.navigation; + } + + if (moduleConfig.adminPages) { + adminConfig.pages = {}; + for (const path of Object.keys(moduleConfig.adminPages)) { + adminConfig.pages[path] = true; + } + } + + return adminConfig; +} + +/** + * Build the context object injected into module setup() callbacks. + * All services are lazy-initialized internally — importing them is safe. + * @returns {Promise} ctx + */ +async function buildModuleContext() { + const [db, email, storage, payments] = await Promise.all([ + import('../../core/database/index.js'), + import('../../core/email/index.js'), + import('../../core/storage/index.js'), + import('../../core/payments/index.js'), + ]); + + return { + db, + email, + storage, + payments, + config: { + get: (key) => process.env[key], + }, + }; +} + /** * Reset module discovery (useful for testing) */ diff --git a/src/core/modules/index.js b/src/core/modules/index.js index 6737e50..95891a8 100644 --- a/src/core/modules/index.js +++ b/src/core/modules/index.js @@ -6,6 +6,7 @@ // Discovery export { discoverModules, + registerExternalModules, isModuleEnabledInEnv, resetDiscovery } from './discovery.js'; diff --git a/src/features/provider/ZenProvider.js b/src/features/provider/ZenProvider.js index f2a8481..1e15294 100644 --- a/src/features/provider/ZenProvider.js +++ b/src/features/provider/ZenProvider.js @@ -1,12 +1,32 @@ 'use client'; +import { useState } from 'react'; import { ToastProvider, ToastContainer } from '@hykocx/zen/toast'; +import { registerExternalModulePages } from '../../modules/modules.pages.js'; -export function ZenProvider({ children }) { - return ( - - {children} - - - ); +/** + * ZenProvider — root client provider for the ZEN CMS. + * + * Pass external module configs via the `modules` prop so their + * admin pages and public pages are available to the client router. + * + * @param {Object} props + * @param {Array} props.modules - External module configs from zen.config.js + * @param {ReactNode} props.children + */ +export function ZenProvider({ modules = [], children }) { + // Register external module pages once, synchronously, before first render. + // useState initializer runs exactly once and does not cause a re-render. + useState(() => { + if (modules.length > 0) { + registerExternalModulePages(modules); + } + }); + + return ( + + {children} + + + ); } diff --git a/src/modules/index.js b/src/modules/index.js index 7eb03db..e280353 100644 --- a/src/modules/index.js +++ b/src/modules/index.js @@ -18,6 +18,7 @@ export { export { // Discovery & initialization discoverModules, + registerExternalModules, initializeModules, initializeModuleDatabases, startModuleCronJobs, @@ -45,5 +46,8 @@ export { isModuleRegistered, } from '../core/modules/index.js'; +// Client-side module pages registry +export { registerExternalModulePages } from './modules.pages.js'; + // Public pages system export * from './pages.js'; diff --git a/src/modules/init.js b/src/modules/init.js index a37f9f3..9e8b7db 100644 --- a/src/modules/init.js +++ b/src/modules/init.js @@ -1,32 +1,20 @@ /** - * Module Database Initialization - * Initializes enabled module database tables - * - * IMPORTANT: When creating a new module, add its createTables import below - * and add it to MODULE_DB_INITIALIZERS. - */ - -// Import createTables functions from each module -// These are bundled together so they're available at runtime -import { createTables as createPostsTables } from './posts/db.js'; - -/** - * Module database initializers - * Maps module names to their createTables functions + * Module Database Initialization (CLI) * - * Add new modules here: + * Initializes DB tables for each enabled module. + * Modules are auto-discovered from AVAILABLE_MODULES — + * no manual registration needed when adding a new module. */ -const MODULE_DB_INITIALIZERS = { - posts: createPostsTables, -}; + +import { AVAILABLE_MODULES } from './modules.registry.js'; /** - * Check if a module is enabled in the environment + * Check if a module is enabled via environment variable * @param {string} moduleName - Module name * @returns {boolean} */ function isModuleEnabled(moduleName) { - const envKey = `ZEN_MODULE_${moduleName.toUpperCase()}`; + const envKey = `ZEN_MODULE_${moduleName.toUpperCase().replace(/-/g, '_')}`; return process.env[envKey] === 'true'; } @@ -37,36 +25,38 @@ function isModuleEnabled(moduleName) { export async function initModules() { const created = []; const skipped = []; - + console.log('\nInitializing module databases...'); - - for (const [moduleName, createTables] of Object.entries(MODULE_DB_INITIALIZERS)) { + + for (const moduleName of AVAILABLE_MODULES) { if (!isModuleEnabled(moduleName)) { console.log(`- Skipped ${moduleName} (not enabled)`); continue; } - + try { - if (typeof createTables === 'function') { - console.log(`\nInitializing ${moduleName} module tables...`); - const result = await createTables(); - - if (result?.created) { - created.push(...result.created); - } - if (result?.skipped) { - skipped.push(...result.skipped); - } - + console.log(`\nInitializing ${moduleName} module tables...`); + const db = await import(`./${moduleName}/db.js`); + + if (typeof db.createTables === 'function') { + const result = await db.createTables(); + + if (result?.created) created.push(...result.created); + if (result?.skipped) skipped.push(...result.skipped); + console.log(`✓ ${moduleName} module initialized`); } else { console.log(`- ${moduleName} has no createTables function`); } } catch (error) { - console.error(`Error initializing ${moduleName}:`, error.message); + if (error.code === 'ERR_MODULE_NOT_FOUND' || error.message?.includes('Cannot find module')) { + console.log(`- ${moduleName} has no db.js (skipped)`); + } else { + console.error(`Error initializing ${moduleName}:`, error.message); + } } } - + return { created, skipped }; } diff --git a/src/modules/modules.actions.js b/src/modules/modules.actions.js index 7ebc095..78ed963 100644 --- a/src/modules/modules.actions.js +++ b/src/modules/modules.actions.js @@ -1,37 +1,36 @@ /** * Module Actions Registry (Server-Side) - * - * Import server actions for public pages (/zen/*) and dashboard. - * Admin pages import actions directly from modules. - * See modules.registry.js for full module creation guide. - * - * Usage in consuming Next.js app: - * ``` - * import { MODULE_ACTIONS, MODULE_DASHBOARD_ACTIONS } from '@hykocx/zen/modules/actions'; - * - * // Access module actions - * const { getInvoiceByTokenAction } = MODULE_ACTIONS.invoice; - * - * // Get dashboard stats - * const stats = await MODULE_DASHBOARD_ACTIONS.invoice(); - * ``` + * + * Static registry for internal module server actions. + * External modules registered via zen.config.js are resolved through the runtime registry. + * + * Usage: + * import { getModuleActions } from '@hykocx/zen/modules/actions'; + * const { getInvoiceByToken } = getModuleActions('invoice'); */ -// Register module actions (for public pages) +import { getModule } from '../core/modules/registry.js'; + +// Static actions for internal modules (add entries here for new internal modules) export const MODULE_ACTIONS = { posts: {}, }; -// Register dashboard stats actions (for admin dashboard) +// Static dashboard stats actions for internal modules export const MODULE_DASHBOARD_ACTIONS = {}; /** - * Get actions for a specific module + * Get actions for a specific module. + * Checks the static registry first, then the runtime registry for external modules. + * * @param {string} moduleName - Module name * @returns {Object} Module actions object or empty object */ export function getModuleActions(moduleName) { - return MODULE_ACTIONS[moduleName] || {}; + if (MODULE_ACTIONS[moduleName]) return MODULE_ACTIONS[moduleName]; + + // External modules declare their actions in their defineModule() config + return getModule(moduleName)?.actions ?? {}; } /** diff --git a/src/modules/modules.metadata.js b/src/modules/modules.metadata.js index ca4dfca..0877705 100644 --- a/src/modules/modules.metadata.js +++ b/src/modules/modules.metadata.js @@ -1,51 +1,57 @@ /** * Module Metadata Registry (Server-Side) - * - * Import metadata generators from modules for SEO/dynamic metadata. - * See modules.registry.js for full module creation guide. - * - * Usage in Next.js page.js: - * ``` - * import { MODULE_METADATA } from '@hykocx/zen/modules/metadata'; - * - * export async function generateMetadata({ params }) { - * return await MODULE_METADATA.invoice.generateInvoicePaymentMetadata(params.token); - * } - * ``` + * + * Static registry for internal module SEO metadata generators. + * External modules registered via zen.config.js are resolved through the runtime registry. + * + * Usage: + * import { getMetadataGenerator } from '@hykocx/zen/modules/metadata'; + * const fn = getMetadataGenerator('invoice', 'payment'); + * const meta = await fn(params.token); */ -// Register module metadata +import { getModule } from '../core/modules/registry.js'; + +// Static metadata for internal modules (add entries here for new internal modules) export const MODULE_METADATA = {}; /** - * Get metadata generators for a specific module + * Get metadata generators for a specific module. + * Checks the static registry first, then the runtime registry for external modules. + * * @param {string} moduleName - Module name * @returns {Object|null} Module metadata object or null */ export function getModuleMetadata(moduleName) { - return MODULE_METADATA[moduleName] || null; + return MODULE_METADATA[moduleName] || getModule(moduleName)?.metadata || null; } /** - * Get a specific metadata generator function + * Get a specific metadata generator function. + * Checks the static registry first, then the runtime registry for external modules. + * * @param {string} moduleName - Module name - * @param {string} generatorName - Name of the metadata generator function (e.g., 'payment', 'pdf', 'receipt') + * @param {string} generatorName - Metadata generator key (e.g., 'payment', 'pdf') * @returns {Function|null} Metadata generator function or null */ export function getMetadataGenerator(moduleName, generatorName) { const metadata = MODULE_METADATA[moduleName]; - if (!metadata) return null; - - // Check the default export first (where the route type mapping is) - if (metadata.default && typeof metadata.default[generatorName] === 'function') { - return metadata.default[generatorName]; + + if (metadata) { + if (metadata.default && typeof metadata.default[generatorName] === 'function') { + return metadata.default[generatorName]; + } + if (typeof metadata[generatorName] === 'function') { + return metadata[generatorName]; + } } - - // Fallback to direct named export - if (typeof metadata[generatorName] === 'function') { - return metadata[generatorName]; + + // External modules declare metadata in their defineModule() config + const externalMetadata = getModule(moduleName)?.metadata; + if (externalMetadata && typeof externalMetadata[generatorName] === 'function') { + return externalMetadata[generatorName]; } - + return null; } diff --git a/src/modules/modules.pages.js b/src/modules/modules.pages.js index 034c634..62033b5 100644 --- a/src/modules/modules.pages.js +++ b/src/modules/modules.pages.js @@ -2,19 +2,41 @@ /** * Module Pages Registry (Client-Side) - * - * Import module configs and register them here. - * See modules.registry.js for full module creation guide. + * + * Static registry for internal modules (imported explicitly for proper code splitting). + * External modules are registered at runtime via registerExternalModulePages(). + * + * To add an internal module: + * 1. Import its config below + * 2. Add it to MODULE_CONFIGS */ -// Import module configs +// Import module configs — add new internal modules here import postsConfig from './posts/module.config.js'; -// Register module configs +// Internal module configs — add new modules here const MODULE_CONFIGS = { posts: postsConfig, }; +// Runtime registry for external modules (populated by ZenProvider via registerExternalModulePages) +const EXTERNAL_MODULE_CONFIGS = new Map(); + +/** + * Register external module configs at runtime. + * Called by ZenProvider when the app starts. + * Idempotent — safe to call multiple times with the same modules. + * + * @param {Array} modules - Array of module configs created with defineModule() + */ +export function registerExternalModulePages(modules = []) { + for (const mod of modules) { + if (mod?.name) { + EXTERNAL_MODULE_CONFIGS.set(mod.name, mod); + } + } +} + /** * Build admin pages from all module configs */ @@ -46,13 +68,27 @@ export const MODULE_DASHBOARD_WIDGETS = Object.entries(MODULE_CONFIGS).reduce((a }, {}); /** - * Get admin page loader for a specific module and path + * Get admin page loader for a specific module and path. + * Checks external modules first, then internal modules. + * * @param {string} moduleName - Module name * @param {string} path - Admin path * @returns {React.LazyExoticComponent|null} Lazy component or null */ export function getModulePageLoader(moduleName, path) { - // Use custom resolver first (for dynamic paths not known at build time) + // Check external modules first + const externalConfig = EXTERNAL_MODULE_CONFIGS.get(moduleName); + if (externalConfig) { + if (typeof externalConfig.pageResolver === 'function') { + const resolved = externalConfig.pageResolver(path); + if (resolved) return resolved; + } + if (externalConfig.adminPages?.[path]) { + return externalConfig.adminPages[path]; + } + } + + // Fall back to internal modules const config = MODULE_CONFIGS[moduleName]; if (config?.pageResolver) { const resolved = config.pageResolver(path); @@ -60,31 +96,45 @@ export function getModulePageLoader(moduleName, path) { } const modulePages = MODULE_ADMIN_PAGES[moduleName]; - if (modulePages && modulePages[path]) { + if (modulePages?.[path]) { return modulePages[path]; } + return null; } /** - * Get public page loader for a specific module + * Get public page loader for a specific module. + * Checks external modules first, then internal modules. + * * @param {string} moduleName - Module name * @returns {React.LazyExoticComponent|null} Lazy component or null */ export function getModulePublicPageLoader(moduleName) { + const externalConfig = EXTERNAL_MODULE_CONFIGS.get(moduleName); + if (externalConfig?.publicPages?.default) { + return externalConfig.publicPages.default; + } + const modulePages = MODULE_PUBLIC_PAGES[moduleName]; if (modulePages?.default) { return modulePages.default; } + return null; } /** - * Get module navigation config + * Get module navigation config. + * Checks external modules first, then internal modules. + * * @param {string} moduleName - Module name * @returns {Object|null} Navigation config or null */ export function getModuleNavigation(moduleName) { + const externalConfig = EXTERNAL_MODULE_CONFIGS.get(moduleName); + if (externalConfig?.navigation) return externalConfig.navigation; + const config = MODULE_CONFIGS[moduleName]; return config?.navigation || null; } diff --git a/src/shared/lib/init.js b/src/shared/lib/init.js index 7339ae7..4a42c49 100644 --- a/src/shared/lib/init.js +++ b/src/shared/lib/init.js @@ -3,7 +3,7 @@ * Initialize all ZEN services and modules using dynamic module discovery */ -import { discoverModules, startModuleCronJobs, stopModuleCronJobs } from '../../core/modules/index.js'; +import { discoverModules, registerExternalModules, startModuleCronJobs, stopModuleCronJobs } from '../../core/modules/index.js'; // Use globalThis to persist initialization flag across module reloads const ZEN_INIT_KEY = Symbol.for('__ZEN_INITIALIZED__'); @@ -16,26 +16,32 @@ const ZEN_INIT_KEY = Symbol.for('__ZEN_INITIALIZED__'); * Alternative: Call this function manually in your root layout * * @example - * // instrumentation.js (Recommended) + * // instrumentation.js (Recommended) — internal modules only * export async function register() { * if (process.env.NEXT_RUNTIME === 'nodejs') { * const { initializeZen } = await import('@hykocx/zen'); * await initializeZen(); * } * } - * + * * @example - * // app/layout.js (Alternative) - * import { initializeZen } from '@hykocx/zen'; - * initializeZen(); - * - * @param {Object} options - Initialization options - * @param {boolean} options.skipCron - Skip cron job initialization - * @param {boolean} options.skipDb - Skip database initialization + * // instrumentation.js — with external modules from zen.config.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); + * } + * } + * + * @param {Object} config - Configuration object + * @param {Array} config.modules - External module configs (from zen.config.js) + * @param {boolean} config.skipCron - Skip cron job initialization + * @param {boolean} config.skipDb - Skip database initialization * @returns {Promise} Initialization result */ -export async function initializeZen(options = {}) { - const { skipCron = false, skipDb = true } = options; +export async function initializeZen(config = {}) { + const { modules: externalModules = [], skipCron = false, skipDb = true } = config; // Only run on server-side if (typeof window !== 'undefined') { @@ -57,21 +63,29 @@ export async function initializeZen(options = {}) { }; try { - // Step 1: Discover and register all enabled modules - // This reads from modules.registry.js and loads each module's config files + // Step 1: Discover and register internal modules (from modules.registry.js) result.discovery = await discoverModules(); - + const enabledCount = result.discovery.enabled?.length || 0; const skippedCount = result.discovery.skipped?.length || 0; - + if (enabledCount > 0) { - console.log(`✓ ZEN: Discovered ${enabledCount} enabled module(s): ${result.discovery.enabled.join(', ')}`); + console.log(`✓ ZEN: Discovered ${enabledCount} internal module(s): ${result.discovery.enabled.join(', ')}`); } if (skippedCount > 0) { console.log(`⚠ ZEN: Skipped ${skippedCount} disabled module(s): ${result.discovery.skipped.join(', ')}`); } + + // Step 2: Register external modules from zen.config.js (if any) + if (externalModules.length > 0) { + result.external = await registerExternalModules(externalModules); + + if (result.external.registered.length > 0) { + console.log(`✓ ZEN: Registered ${result.external.registered.length} external module(s): ${result.external.registered.join(', ')}`); + } + } - // Step 2: Start cron jobs for all enabled modules + // Step 3: Start cron jobs for all enabled modules (internal + external) if (!skipCron) { result.cron = await startModuleCronJobs();