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_<NAME>=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
This commit is contained in:
2026-04-12 13:39:56 -04:00
parent 4983a24325
commit 99a56d2c39
13 changed files with 871 additions and 146 deletions
+255
View File
@@ -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: '<p>ok</p>' });
// 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 (
<html>
<body>
<ZenProvider modules={zenConfig.modules}>
{children}
</ZenProvider>
</body>
</html>
);
}
```
### 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.
+221
View File
@@ -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.
+3
View File
@@ -135,6 +135,9 @@
"./modules": { "./modules": {
"import": "./dist/modules/index.js" "import": "./dist/modules/index.js"
}, },
"./modules/define": {
"import": "./dist/core/modules/defineModule.js"
},
"./modules/pages": { "./modules/pages": {
"import": "./dist/modules/pages.js" "import": "./dist/modules/pages.js"
}, },
+56
View File
@@ -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,
};
}
+134 -28
View File
@@ -1,6 +1,7 @@
/** /**
* Module Discovery System * 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'; import { registerModule, clearRegistry } from './registry.js';
@@ -95,27 +96,7 @@ async function loadModuleConfig(moduleName) {
try { try {
const config = await import(`../../modules/${moduleName}/module.config.js`); const config = await import(`../../modules/${moduleName}/module.config.js`);
const moduleConfig = config.default || config; 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 { return {
name: moduleConfig.name || moduleName, name: moduleConfig.name || moduleName,
displayName: moduleConfig.displayName || moduleName.charAt(0).toUpperCase() + moduleName.slice(1), displayName: moduleConfig.displayName || moduleName.charAt(0).toUpperCase() + moduleName.slice(1),
@@ -123,12 +104,12 @@ async function loadModuleConfig(moduleName) {
description: moduleConfig.description || `${moduleName} module`, description: moduleConfig.description || `${moduleName} module`,
dependencies: moduleConfig.dependencies || [], dependencies: moduleConfig.dependencies || [],
envVars: moduleConfig.envVars || [], envVars: moduleConfig.envVars || [],
// Admin configuration (navigation + page paths) // Admin config: navigation + page path markers (components loaded client-side)
admin: adminConfig, admin: buildAdminConfig(moduleConfig),
// Public routes metadata (not components) // Public routes metadata (components loaded client-side)
public: moduleConfig.publicRoutes ? { public: moduleConfig.publicRoutes?.length
routes: moduleConfig.publicRoutes ? { routes: moduleConfig.publicRoutes }
} : undefined, : undefined,
}; };
} catch (error) { } catch (error) {
console.log(`[Module Discovery] No module.config.js for ${moduleName}, using defaults`); console.log(`[Module Discovery] No module.config.js for ${moduleName}, using defaults`);
@@ -182,6 +163,131 @@ async function loadModuleComponents(moduleName) {
return components; return components;
} }
/**
* Register external modules provided via zen.config.js.
* Skips any module whose ZEN_MODULE_<NAME>=true env var is not set.
*
* @param {Array} modules - Array of module configs created with defineModule()
* @returns {Promise<Object>} { 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<Object>} 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) * Reset module discovery (useful for testing)
*/ */
+1
View File
@@ -6,6 +6,7 @@
// Discovery // Discovery
export { export {
discoverModules, discoverModules,
registerExternalModules,
isModuleEnabledInEnv, isModuleEnabledInEnv,
resetDiscovery resetDiscovery
} from './discovery.js'; } from './discovery.js';
+27 -7
View File
@@ -1,12 +1,32 @@
'use client'; 'use client';
import { useState } from 'react';
import { ToastProvider, ToastContainer } from '@hykocx/zen/toast'; import { ToastProvider, ToastContainer } from '@hykocx/zen/toast';
import { registerExternalModulePages } from '../../modules/modules.pages.js';
export function ZenProvider({ children }) { /**
return ( * ZenProvider — root client provider for the ZEN CMS.
<ToastProvider> *
{children} * Pass external module configs via the `modules` prop so their
<ToastContainer /> * admin pages and public pages are available to the client router.
</ToastProvider> *
); * @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 (
<ToastProvider>
{children}
<ToastContainer />
</ToastProvider>
);
} }
+4
View File
@@ -18,6 +18,7 @@ export {
export { export {
// Discovery & initialization // Discovery & initialization
discoverModules, discoverModules,
registerExternalModules,
initializeModules, initializeModules,
initializeModuleDatabases, initializeModuleDatabases,
startModuleCronJobs, startModuleCronJobs,
@@ -45,5 +46,8 @@ export {
isModuleRegistered, isModuleRegistered,
} from '../core/modules/index.js'; } from '../core/modules/index.js';
// Client-side module pages registry
export { registerExternalModulePages } from './modules.pages.js';
// Public pages system // Public pages system
export * from './pages.js'; export * from './pages.js';
+27 -37
View File
@@ -1,32 +1,20 @@
/** /**
* Module Database Initialization * Module Database Initialization (CLI)
* 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
* *
* 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 * @param {string} moduleName - Module name
* @returns {boolean} * @returns {boolean}
*/ */
function isModuleEnabled(moduleName) { function isModuleEnabled(moduleName) {
const envKey = `ZEN_MODULE_${moduleName.toUpperCase()}`; const envKey = `ZEN_MODULE_${moduleName.toUpperCase().replace(/-/g, '_')}`;
return process.env[envKey] === 'true'; return process.env[envKey] === 'true';
} }
@@ -37,36 +25,38 @@ function isModuleEnabled(moduleName) {
export async function initModules() { export async function initModules() {
const created = []; const created = [];
const skipped = []; const skipped = [];
console.log('\nInitializing module databases...'); console.log('\nInitializing module databases...');
for (const [moduleName, createTables] of Object.entries(MODULE_DB_INITIALIZERS)) { for (const moduleName of AVAILABLE_MODULES) {
if (!isModuleEnabled(moduleName)) { if (!isModuleEnabled(moduleName)) {
console.log(`- Skipped ${moduleName} (not enabled)`); console.log(`- Skipped ${moduleName} (not enabled)`);
continue; continue;
} }
try { try {
if (typeof createTables === 'function') { console.log(`\nInitializing ${moduleName} module tables...`);
console.log(`\nInitializing ${moduleName} module tables...`); const db = await import(`./${moduleName}/db.js`);
const result = await createTables();
if (typeof db.createTables === 'function') {
if (result?.created) { const result = await db.createTables();
created.push(...result.created);
} if (result?.created) created.push(...result.created);
if (result?.skipped) { if (result?.skipped) skipped.push(...result.skipped);
skipped.push(...result.skipped);
}
console.log(`${moduleName} module initialized`); console.log(`${moduleName} module initialized`);
} else { } else {
console.log(`- ${moduleName} has no createTables function`); console.log(`- ${moduleName} has no createTables function`);
} }
} catch (error) { } 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 }; return { created, skipped };
} }
+18 -19
View File
@@ -1,37 +1,36 @@
/** /**
* Module Actions Registry (Server-Side) * Module Actions Registry (Server-Side)
* *
* Import server actions for public pages (/zen/*) and dashboard. * Static registry for internal module server actions.
* Admin pages import actions directly from modules. * External modules registered via zen.config.js are resolved through the runtime registry.
* See modules.registry.js for full module creation guide. *
* * Usage:
* Usage in consuming Next.js app: * import { getModuleActions } from '@hykocx/zen/modules/actions';
* ``` * const { getInvoiceByToken } = getModuleActions('invoice');
* 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();
* ```
*/ */
// 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 = { export const MODULE_ACTIONS = {
posts: {}, posts: {},
}; };
// Register dashboard stats actions (for admin dashboard) // Static dashboard stats actions for internal modules
export const MODULE_DASHBOARD_ACTIONS = {}; 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 * @param {string} moduleName - Module name
* @returns {Object} Module actions object or empty object * @returns {Object} Module actions object or empty object
*/ */
export function getModuleActions(moduleName) { 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 ?? {};
} }
/** /**
+33 -27
View File
@@ -1,51 +1,57 @@
/** /**
* Module Metadata Registry (Server-Side) * Module Metadata Registry (Server-Side)
* *
* Import metadata generators from modules for SEO/dynamic metadata. * Static registry for internal module SEO metadata generators.
* See modules.registry.js for full module creation guide. * External modules registered via zen.config.js are resolved through the runtime registry.
* *
* Usage in Next.js page.js: * Usage:
* ``` * import { getMetadataGenerator } from '@hykocx/zen/modules/metadata';
* import { MODULE_METADATA } from '@hykocx/zen/modules/metadata'; * const fn = getMetadataGenerator('invoice', 'payment');
* * const meta = await fn(params.token);
* export async function generateMetadata({ params }) {
* return await MODULE_METADATA.invoice.generateInvoicePaymentMetadata(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 = {}; 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 * @param {string} moduleName - Module name
* @returns {Object|null} Module metadata object or null * @returns {Object|null} Module metadata object or null
*/ */
export function getModuleMetadata(moduleName) { 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} 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 * @returns {Function|null} Metadata generator function or null
*/ */
export function getMetadataGenerator(moduleName, generatorName) { export function getMetadataGenerator(moduleName, generatorName) {
const metadata = MODULE_METADATA[moduleName]; const metadata = MODULE_METADATA[moduleName];
if (!metadata) return null;
if (metadata) {
// Check the default export first (where the route type mapping is) if (metadata.default && typeof metadata.default[generatorName] === 'function') {
if (metadata.default && typeof metadata.default[generatorName] === 'function') { return metadata.default[generatorName];
return metadata.default[generatorName]; }
if (typeof metadata[generatorName] === 'function') {
return metadata[generatorName];
}
} }
// Fallback to direct named export // External modules declare metadata in their defineModule() config
if (typeof metadata[generatorName] === 'function') { const externalMetadata = getModule(moduleName)?.metadata;
return metadata[generatorName]; if (externalMetadata && typeof externalMetadata[generatorName] === 'function') {
return externalMetadata[generatorName];
} }
return null; return null;
} }
+60 -10
View File
@@ -2,19 +2,41 @@
/** /**
* Module Pages Registry (Client-Side) * Module Pages Registry (Client-Side)
* *
* Import module configs and register them here. * Static registry for internal modules (imported explicitly for proper code splitting).
* See modules.registry.js for full module creation guide. * 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'; import postsConfig from './posts/module.config.js';
// Register module configs // Internal module configs — add new modules here
const MODULE_CONFIGS = { const MODULE_CONFIGS = {
posts: postsConfig, 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 * 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} moduleName - Module name
* @param {string} path - Admin path * @param {string} path - Admin path
* @returns {React.LazyExoticComponent|null} Lazy component or null * @returns {React.LazyExoticComponent|null} Lazy component or null
*/ */
export function getModulePageLoader(moduleName, path) { 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]; const config = MODULE_CONFIGS[moduleName];
if (config?.pageResolver) { if (config?.pageResolver) {
const resolved = config.pageResolver(path); const resolved = config.pageResolver(path);
@@ -60,31 +96,45 @@ export function getModulePageLoader(moduleName, path) {
} }
const modulePages = MODULE_ADMIN_PAGES[moduleName]; const modulePages = MODULE_ADMIN_PAGES[moduleName];
if (modulePages && modulePages[path]) { if (modulePages?.[path]) {
return modulePages[path]; return modulePages[path];
} }
return null; 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 * @param {string} moduleName - Module name
* @returns {React.LazyExoticComponent|null} Lazy component or null * @returns {React.LazyExoticComponent|null} Lazy component or null
*/ */
export function getModulePublicPageLoader(moduleName) { 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]; const modulePages = MODULE_PUBLIC_PAGES[moduleName];
if (modulePages?.default) { if (modulePages?.default) {
return modulePages.default; return modulePages.default;
} }
return null; return null;
} }
/** /**
* Get module navigation config * Get module navigation config.
* Checks external modules first, then internal modules.
*
* @param {string} moduleName - Module name * @param {string} moduleName - Module name
* @returns {Object|null} Navigation config or null * @returns {Object|null} Navigation config or null
*/ */
export function getModuleNavigation(moduleName) { export function getModuleNavigation(moduleName) {
const externalConfig = EXTERNAL_MODULE_CONFIGS.get(moduleName);
if (externalConfig?.navigation) return externalConfig.navigation;
const config = MODULE_CONFIGS[moduleName]; const config = MODULE_CONFIGS[moduleName];
return config?.navigation || null; return config?.navigation || null;
} }
+32 -18
View File
@@ -3,7 +3,7 @@
* Initialize all ZEN services and modules using dynamic module discovery * 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 // Use globalThis to persist initialization flag across module reloads
const ZEN_INIT_KEY = Symbol.for('__ZEN_INITIALIZED__'); 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 * Alternative: Call this function manually in your root layout
* *
* @example * @example
* // instrumentation.js (Recommended) * // instrumentation.js (Recommended) — internal modules only
* export async function register() { * export async function register() {
* if (process.env.NEXT_RUNTIME === 'nodejs') { * if (process.env.NEXT_RUNTIME === 'nodejs') {
* const { initializeZen } = await import('@hykocx/zen'); * const { initializeZen } = await import('@hykocx/zen');
* await initializeZen(); * await initializeZen();
* } * }
* } * }
* *
* @example * @example
* // app/layout.js (Alternative) * // instrumentation.js — with external modules from zen.config.js
* import { initializeZen } from '@hykocx/zen'; * import zenConfig from './zen.config.js';
* initializeZen(); * export async function register() {
* * if (process.env.NEXT_RUNTIME === 'nodejs') {
* @param {Object} options - Initialization options * const { initializeZen } = await import('@hykocx/zen');
* @param {boolean} options.skipCron - Skip cron job initialization * await initializeZen(zenConfig);
* @param {boolean} options.skipDb - Skip database initialization * }
* }
*
* @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<Object>} Initialization result * @returns {Promise<Object>} Initialization result
*/ */
export async function initializeZen(options = {}) { export async function initializeZen(config = {}) {
const { skipCron = false, skipDb = true } = options; const { modules: externalModules = [], skipCron = false, skipDb = true } = config;
// Only run on server-side // Only run on server-side
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
@@ -57,21 +63,29 @@ export async function initializeZen(options = {}) {
}; };
try { try {
// Step 1: Discover and register all enabled modules // Step 1: Discover and register internal modules (from modules.registry.js)
// This reads from modules.registry.js and loads each module's config files
result.discovery = await discoverModules(); result.discovery = await discoverModules();
const enabledCount = result.discovery.enabled?.length || 0; const enabledCount = result.discovery.enabled?.length || 0;
const skippedCount = result.discovery.skipped?.length || 0; const skippedCount = result.discovery.skipped?.length || 0;
if (enabledCount > 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) { if (skippedCount > 0) {
console.log(`⚠ ZEN: Skipped ${skippedCount} disabled module(s): ${result.discovery.skipped.join(', ')}`); 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) { if (!skipCron) {
result.cron = await startModuleCronJobs(); result.cron = await startModuleCronJobs();