# Modules externes `@zen/module-*`

Un **module** est un package npm distinct qui ajoute des fonctionnalités à un projet construit avec `@zen/core` — sans aucune modification de code dans le projet consommateur.

```bash
npm install @zen/module-billing
# ajouter les variables d'env documentées dans le README du module
npx zen-db init   # crée les tables du module et seed ses permissions
npm run dev       # tout est câblé : pages admin, sidebar, widgets, API, /zen/<module>/...
```

Aucun fichier de configuration manuelle. La plateforme découvre les modules par scan des dépendances `package.json`.

---

## Découverte

Les modules sont activés via deux **manifestes statiques** générés par la CLI `zen-modules sync` dans le projet consommateur :

- `app/.zen/modules.generated.js` — manifeste serveur (importé par `instrumentation.js`).
- `app/.zen/modules.client.js` — manifeste client (`'use client'`, exporte un Component à rendre dans `app/layout.js`).

Le manifeste serveur peuple le registre côté Node via le top-level await à l'import. Le manifeste client exporte `ZenModulesClient` — un Component React — que `app/layout.js` doit **rendre** dans son tree (pas seulement importer). Sous Next.js 15+/Turbopack, un `import` purement side-effect d'un fichier `'use client'` orphelin (sans Component utilisé) inclut bien le fichier dans le bundle browser mais n'en exécute pas le code top-level — `registerPage()` / `registerWidget()` ne tourneraient jamais côté client. Rendre le Component force l'exécution.

```js
// app/.zen/modules.generated.js — AUTO-GÉNÉRÉ (serveur)
import * as m0_zen_module_posts from '@zen/module-posts';
export const modules = [
  { name: '@zen/module-posts', exports: m0_zen_module_posts },
];
await Promise.all(modules.map(m => m.exports.register?.()));
```

```js
// app/.zen/modules.client.js — AUTO-GÉNÉRÉ (client)
'use client';
import '@zen/module-posts/client';
export default function ZenModulesClient() {
  return null;
}
```

```js
// app/layout.js — projet consommateur
import ZenModulesClient from './.zen/modules.client.js';

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <ZenModulesClient />
        {children}
      </body>
    </html>
  );
}
```

Le manifeste client importe la **sous-entrée `./client`** de chaque module (et non son main entry). C'est essentiel : le main entry tire `createTables` / `registerApiRoutes` / la chaîne `register-server.js` qui dépend de `pg`, `fs`, `next/headers`, etc. — code serveur incompatible avec le bundle browser. Seuls les modules qui exposent `./client` dans leur `package.json#exports` sont inclus dans le manifeste client ; les modules purement back-end (API/DB) sont absents du bundle browser.

L'importation statique (le `import * as ...`) permet à Turbopack/Webpack d'analyser le graphe complet du module — JSX, `next/headers`, `next/navigation`, frontières `'use client'` — exactement comme pour le code de l'application elle-même.

### Critères de détection

`zen-modules sync` scanne `dependencies` + `devDependencies` du `package.json` du projet et inclut tout package matchant :

- **Préfixe officiel** : `@zen/module-*`
- **Préfixe non-scopé** : `zen-module-*`
- **Tiers** : tout package dont le `package.json` contient `"keywords": ["zen-module"]`

### Quand resync ?

Le template `@zen/start` câble la CLI dans :

```json
{
  "scripts": {
    "postinstall": "zen-modules sync",
    "dev": "zen-modules sync && next dev",
    "build": "zen-modules sync && next build"
  }
}
```

`postinstall` couvre `npm install @zen/module-X`. Les hooks `dev`/`build` couvrent les retraits / changements de version qui ne déclenchent pas de re-install. La commande est idempotente — pas d'écriture si le contenu est identique.

`app/.zen/modules.generated.js` est gitignoré : régénéré localement, jamais commit.

---

## Forme d'un module

Le point d'entrée du package (`main` ou `exports["."]`) doit exporter :

```js
// @zen/module-blog/src/index.js  (compilé vers dist/index.js par tsup)

export const manifest = {
  name: '@zen/module-blog',
  version: '1.0.0',
  permissions: [
    { key: 'blog.view',   name: 'Voir les billets',   description: 'Consultation', group_name: 'Blog' },
    { key: 'blog.manage', name: 'Gérer les billets',  description: 'CRUD',         group_name: 'Blog' },
  ],
  envVars: [
    { key: 'BLOG_UPLOAD_DIR', required: false, description: 'Répertoire des médias' },
  ],
};

export async function register() {
  // Tous les enregistrements runtime se font ici (voir API ci-dessous).
  await import('./register-server.js');
}

export { createTables, dropTables } from './db.js';
```

| Export | Type | Obligatoire |
|--------|------|-------------|
| `manifest` | objet (voir ci-dessous) | oui |
| `register` | `() => void \| Promise<void>` | oui |
| `createTables` | `async () => { created?: string[], skipped?: string[] }` | si le module a des tables |
| `dropTables` | `async () => void` | si le module a des tables |

### Manifest

| Champ | Type | Description |
|-------|------|-------------|
| `name` | `string` | Nom du package (utilisé comme identifiant unique). |
| `version` | `string` | Version du module (logguée au boot). |
| `permissions` | `Array` | Permissions ajoutées au catalogue. Auto-attribuées au rôle `admin` au prochain `zen-db init`. |
| `envVars` | `Array` | Variables d'env du module ; les `required: true` absentes émettent un warning au boot. |

---

## API d'enregistrement

Toutes ces fonctions s'utilisent depuis le hook `register()` du module.

### Permissions

Déclarées dans `manifest.permissions`. Le core les enregistre automatiquement avant le seed BD et les attribue au rôle `admin`. À la connexion, l'admin peut les distribuer à d'autres rôles via `/admin/roles`.

### Sidebar admin

```js
import { registerNavSection, registerNavItem } from '@zen/core/features/admin';

registerNavSection({ id: 'blog', title: 'Blog', icon: 'Notebook01Icon', order: 40 });
registerNavItem({
  id: 'blog-posts',
  label: 'Billets',
  icon: 'Notebook01Icon',
  href: '/admin/blog',
  sectionId: 'blog',
  permission: 'blog.view',
});
```

### Pages admin

```js
import { registerPage } from '@zen/core/features/admin';
import BlogAdminPage from './admin/BlogAdminPage.client.js';

registerPage({ slug: 'blog', Component: BlogAdminPage, title: 'Blog' });
```

Rendue sous `/admin/blog`.

Pour l'en-tête des pages admin (titre, description, bouton retour, action), utiliser `AdminHeader` importé depuis `@zen/core/features/admin/components` :

```js
'use client';
import { AdminHeader } from '@zen/core/features/admin/components';

// Page liste avec bouton d'action
export default function BlogListPage() {
  return (
    <div className="flex flex-col gap-6">
      <AdminHeader
        title="Billets"
        description="Billets disponibles."
        action={<Button onClick={...}>Créer un billet</Button>}
      />
      {/* ... */}
    </div>
  );
}

// Page formulaire avec bouton retour
export default function BlogCreatePage() {
  return (
    <div className="flex flex-col gap-6">
      <AdminHeader
        title="Créer un billet"
        description="Remplir les champs et enregistrer."
        backHref="/admin/blog"
      />
      {/* ... */}
    </div>
  );
}
```

| Prop | Type | Description |
|------|------|-------------|
| `title` | `string` | Titre de la page. |
| `description` | `string` | Sous-titre optionnel. |
| `backHref` | `string` | Si fourni, affiche un bouton « ← Retour » qui navigue vers cette URL. |
| `backLabel` | `string` | Label du bouton retour (défaut : `← Retour`). |
| `action` | `ReactNode` | Élément affiché à droite (bouton créer, etc.). |

### Médias attachés au contenu

Pour qu'un module puisse attacher des médias (image à la une, galerie, PDF) à ses propres ressources, utiliser `@zen/core/features/media` côté serveur et `@zen/core/features/media/picker` côté client.

```js
// côté serveur — au moment de sauvegarder un billet :
import { attachMedia, detachAllForSource } from '@zen/core/features/media';

await attachMedia({
  mediaId: payload.featuredImageId,
  sourceType: '@zen/module-blog:post',
  sourceId: post.id,
  field: 'featured_image',
});

// au moment de supprimer le billet : libérer toutes les références.
await detachAllForSource({ sourceType: '@zen/module-blog:post', sourceId: post.id });
```

```jsx
// côté client — sélecteur dans un formulaire :
'use client';
import MediaPicker from '@zen/core/features/media/picker';

<MediaPicker
  isOpen={open}
  onClose={() => setOpen(false)}
  accept="image/*"
  visibility="public"
  onSelect={(media) => setFeaturedImage(media)}
/>
```

Le module Médias doit être activé (`ZEN_MEDIA=true`) dans le projet consommateur — sinon les permissions ne sont pas seedées et les API renvoient 403. Vérifier avec `isMediaEnabled()` côté serveur si on veut adapter l'UI.

### Widgets dashboard

```js
// côté serveur
import { registerWidgetFetcher } from '@zen/core/features/admin';
registerWidgetFetcher('blog-posts', async () => ({ count: await countPosts() }));

// côté client
'use client';
import { registerWidget } from '@zen/core/features/admin';
registerWidget({ id: 'blog-posts', Component: BlogWidget, order: 40, permission: 'blog.view' });
```

### Routes API

```js
import { registerApiRoutes } from '@zen/core/api';
import { defineApiRoutes, apiSuccess } from '@zen/core/api';

const routes = defineApiRoutes([
  { path: '/blog/posts',     method: 'GET',  handler: handleListPosts,  auth: 'admin', permission: 'blog.view' },
  { path: '/blog/posts',     method: 'POST', handler: handleCreatePost, auth: 'admin', permission: 'blog.manage' },
  { path: '/blog/posts/:id', method: 'GET',  handler: handleGetPost,    auth: 'public' },
]);

registerApiRoutes(routes);
```

Le router applique automatiquement la session, le rate-limit et la vérification de permission. Les routes sont accessibles sous `/zen/api/*`.

| Champ `auth` | Comportement |
|--------------|--------------|
| `'public'` | Aucune session requise. |
| `'user'` | Session valide. |
| `'admin'` | Session avec permission `admin.access`. La permission granulaire `permission` est aussi vérifiée si fournie. |

### Pages publiques `/zen/<module>/...`

```js
import { registerPublicModulePage } from '@zen/core/public-pages';
import BlogPublicPage from './public/BlogPublicPage.js';

registerPublicModulePage({ moduleName: 'blog', Component: BlogPublicPage });
```

URL : `/zen/blog/<...>`. Le composant reçoit `{ params, segments }` :

```js
function BlogPublicPage({ params, segments }) {
  // /zen/blog/post/abc-123 → segments = ['post', 'abc-123']
  if (segments[0] === 'post') return <PostView id={segments[1]} />;
  return <BlogIndex />;
}
```

Le namespace `api` est réservé aux routes API et ne peut être utilisé comme `moduleName`.

### Migrations BD

```js
// db.js
import { query, tableExists } from '@zen/core/database';

const TABLES = [
  { name: 'zen_blog_posts', sql: `CREATE TABLE zen_blog_posts (...)` },
];

export async function createTables() {
  const created = [];
  const skipped = [];
  for (const t of TABLES) {
    if (await tableExists(t.name)) { skipped.push(t.name); continue; }
    await query(t.sql);
    created.push(t.name);
  }
  return { created, skipped };
}

export async function dropTables() {
  for (const t of [...TABLES].reverse()) {
    await query(`DROP TABLE IF EXISTS "${t.name}" CASCADE`);
  }
}
```

Convention : préfixer toutes les tables par `zen_<module>_` pour éviter les collisions.

---

## Frontières serveur/client

Un module avec partie admin expose **deux entrées** dans son `package.json#exports` :

```json
{
  "exports": {
    ".":        { "import": "./dist/index.js" },
    "./client": { "import": "./dist/client.js" }
  }
}
```

| Entrée | Bundle | Contenu |
|--------|--------|---------|
| `.` (main) | serveur | `manifest`, `register()`, `createTables`, `dropTables`. Le `register()` tire la chaîne `register-server.js` (API routes, navigation, fetchers, storage prefixes, hooks DB). |
| `./client` | client | `'use client'` ; uniquement `registerPage({ Component })` et `registerWidget({ Component })`. Aucun import vers `pg`, `fs`, `.server.js` ou `register-server.js`. |

Le manifeste serveur importe `@zen/module-X` (main) ; le manifeste client importe `@zen/module-X/client`. La séparation est obligatoire : tout ce qui est statiquement importé depuis l'entrée client finit dans le bundle browser, et `pg` / `fs` / `next/headers` y crashent.

```js
// src/index.js (entrée serveur)
export const manifest = { /* ... */ };
export async function register() { await import('./register-server.js'); }
export { createTables, dropTables } from './db.server.js';
```

```js
// src/register-server.js (server-only — appelée par register())
import { registerNavItem, registerNavSection } from '@zen/core/features/admin';
import { registerApiRoutes } from '@zen/core/api';
import { routes } from './api.server.js';
// PAS d'import de Component .client.js ici — ils vivent dans client.js.
registerNavSection({ /* ... */ });
registerApiRoutes(routes);
```

```js
// src/client.js (entrée client — chargée par le manifeste client uniquement)
'use client';
import { registerPage } from '@zen/core/features/admin';
import BlogAdminPage from './admin/BlogAdminPage.client.js';
registerPage({ slug: 'blog', Component: BlogAdminPage, title: 'Blog' });
```

### Sous-entrées `@zen/core/*` safe-pour-client

Le `client.js` d'un module ne doit jamais importer un barrel mixte (un barrel qui ré-exporte du code serveur à côté de constantes client-safe). Si on le fait, Turbopack/Webpack tracent toute la chaîne d'imports statiques et ramènent `pg`/`fs`/`next/headers` dans le bundle browser — qui crashe avec `Module not found: Can't resolve 'dns'` ou équivalent.

Sous-entrées explicitement safe pour un import depuis un fichier `'use client'` :

| Sous-entrée | Contenu |
|-------------|---------|
| `@zen/core/users/constants` | `PERMISSIONS`, `PERMISSION_DEFINITIONS`, `getPermissionGroups` — aucun import serveur. |
| `@zen/core/features/admin` | `registerPage`, `registerWidget`, `registerNavItem`, `registerNavSection`, `buildNavigationSections`. Neutre côté boundary. |
| `@zen/core/features/admin/components` | Composants client : `AdminHeader`, `AdminShell`, `AdminSidebar`, `ThemeToggle`, modals. |
| `@zen/core/features/media/picker` | Composant client `MediaPicker` (modale de sélection de média). Importer **uniquement ce sous-chemin** depuis un client — `@zen/core/features/media` (barrel) tire le code serveur. |
| `@zen/core/themes` | Tokens/utilitaires de thème. |
| `@zen/core/toast` | API toast côté client. |
| `@zen/core/shared/icons` | Composants d'icônes. |
| `@zen/core/shared/components` | Composants partagés. |

Tout ce qui n'est pas dans cette liste — en particulier `@zen/core/users` (barrel complet), `@zen/core/database`, `@zen/core/api`, `@zen/core/storage` — est du code serveur. Ne JAMAIS l'importer depuis un fichier `'use client'` ou un fichier transitivement importé par un `'use client'`.

---

## Variables d'environnement

Toute variable requise par le module doit être déclarée dans `manifest.envVars` et documentée dans le `README.md` du module. Les variables `required: true` absentes génèrent un warning au boot — elles ne crashent pas le serveur, le module gère son propre fallback.

---

## Classes Tailwind

Le projet consommateur importe `@zen/core/styles/zen.css`, qui déclare deux directives `@source` :

1. `@source "../../**/*.js"` — scanne `node_modules/@zen/core/dist/**/*.js`
2. `@source "../../../../module-*/dist/**/*.js"` — scanne `node_modules/@zen/module-*/dist/**/*.js`

Conséquence pour un module : les classes Tailwind utilisées dans les composants du module sont générées **automatiquement** par le Tailwind du consommateur, à condition que :

- Le package soit publié sous le nom `@zen/module-<nom>` (la convention est déjà imposée par la découverte runtime des modules).
- Les fichiers compilés se trouvent dans `dist/` (pattern de scan).

Aucune action n'est requise côté module ni côté consommateur — un module peut donc utiliser librement n'importe quelle classe Tailwind, y compris des variantes responsives (`md:`, `lg:`...) ou des valeurs arbitraires (`w-[300px]`).

---

## Squelette minimal d'un module

```
@zen/module-blog/
├── package.json          # name: "@zen/module-blog", main: "./dist/index.js"
├── tsup.config.js        # build avec bundle: false, loader JSX, outbase: 'src'
├── README.md             # documente les env vars et la configuration
├── src/
│   ├── index.js              # exporte manifest, register, createTables, dropTables
│   ├── db.server.js          # createTables/dropTables
│   ├── register-server.js    # imports déclencheurs (chargé par register())
│   ├── api.server.js         # routes API (registerApiRoutes)
│   ├── admin/
│   │   ├── BlogAdminPage.client.js    # registerPage + composant
│   │   └── widgets/...                # registerWidgetFetcher + registerWidget
│   └── public/
│       └── BlogPublicPage.js          # registerPublicModulePage
└── dist/                 # généré par `npm run build` — c'est ce qui est publié
```

Le champ `files` dans `package.json` publie **uniquement** `dist/`, `README.md` et `LICENSE` :

```json
{
  "main": "./dist/index.js",
  "exports": { ".": { "import": "./dist/index.js" } },
  "files": ["dist", "README.md", "LICENSE"],
  "scripts": {
    "build": "tsup",
    "prepublishOnly": "npm run build"
  }
}
```

---

## Build avant publish

**Tout module `@zen/module-*` est pré-compilé avant publication**, comme `@zen/core` lui-même. Le build `tsup` avec `bundle: false` transforme le JSX, préserve les directives `'use client'` en haut des fichiers compilés, et garde un fichier d'entrée par fichier de sortie pour respecter les frontières RSC quand le projet consommateur bundle le module.

C'est ce qui permet au manifeste statique généré par `zen-modules sync` de simplement faire `import * as ... from '@zen/module-X'` et de laisser Turbopack/Webpack composer le reste : aucune transformation runtime n'est requise côté consommateur.

### Exemple de `tsup.config.js` minimal

```js
import { defineConfig } from 'tsup';

export default defineConfig({
  entry: ['src/**/*.js', 'src/**/*.jsx'],
  format: ['esm'],
  outDir: 'dist',
  outbase: 'src',
  bundle: false,
  splitting: false,
  clean: true,
  loader: { '.js': 'jsx' },
  jsx: 'automatic',
});
```

### Vérifier qu'un module est correctement compilé

Avant `npm publish`, ouvrir `dist/` et confirmer qu'aucun fichier ne contient de syntaxe JSX brute (chercher `<` suivi d'une majuscule). Tous les composants doivent apparaître sous forme d'appels `jsx(...)` ou `React.createElement(...)`.

---

## Cycle de vie complet

| Étape | Côté core | Côté module |
|-------|-----------|-------------|
| Install | — | `npm install @zen/module-X` |
| Configuration | — | Ajout des `envVars` au `.env` |
| Migration BD | `zen-db init` scanne, charge le module, registerPermissions(), seed, createTables() | `createTables()` exécuté |
| Boot serveur | `instrumentation.js` → `initializeZen()` scanne, charge, registerPermissions(), `register()` | `register()` exécuté côté serveur |
| Premier render client | bundle client traverse les imports → composants client enregistrés | `registerWidget()` exécuté côté client |
| Runtime | router dispatch les requêtes, admin résout les pages/widgets via le registre | aucun travail supplémentaire |
