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:
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
// Discovery
|
// Discovery
|
||||||
export {
|
export {
|
||||||
discoverModules,
|
discoverModules,
|
||||||
|
registerExternalModules,
|
||||||
isModuleEnabledInEnv,
|
isModuleEnabledInEnv,
|
||||||
resetDiscovery
|
resetDiscovery
|
||||||
} from './discovery.js';
|
} from './discovery.js';
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 ?? {};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user