Premier module

Écrire, construire, installer et vérifier un module @lrvo/module-* de bout en bout.

Un module est un package npm auto-découvert. C'est la voie à prendre dès qu'une fonctionnalité doit servir dans un deuxième projet.

Ce parcours construit un module @lrvo/module-blog minimal mais complet : table, permissions, page d'admin, entrée de navigation, route API.

La référence complète est dans Créer un module.

1. Créer le package

mkdir lrvo-module-blog
cd lrvo-module-blog
npm init -y
npm install --save-dev tsup
npm install @lrvo/core --save-peer
{
  "name": "@lrvo/module-blog",
  "version": "1.0.0",
  "type": "module",
  "main": "./dist/index.js",
  "exports": {
    ".":        { "import": "./dist/index.js" },
    "./client": { "import": "./dist/client.js" }
  },
  "files": ["dist", "README.md", "LICENSE"],
  "scripts": {
    "build": "tsup",
    "prepublishOnly": "npm run build"
  }
}

Les deux entrées ne sont pas optionnelles. L'entrée principale tire du code serveur, incompatible avec le bundle navigateur ; l'entrée ./client ne contient que les composants.

Le module de ce tutoriel n'a qu'une partie admin. S'il servait aussi une page publique sous /lrvo/blog/…, il lui faudrait une troisième entrée, ./public-page : voir Créer un module.

// tsup.config.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',
});

2. Le point d'entrée

// src/index.js
export const manifest = {
  name: '@lrvo/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: 'Créer, modifier, supprimer', group_name: 'Blog' },
  ],
  envVars: [],
};

export async function register() {
  await import('./register-server.js');
}

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

3. Les tables

// src/db.server.js
import { query, tableExists, create, find } from '@lrvo/core/database';

const TABLES = [
  {
    name: 'lrvo_blog_posts',
    sql: `CREATE TABLE lrvo_blog_posts (
      id          TEXT PRIMARY KEY,
      title       TEXT NOT NULL,
      slug        TEXT NOT NULL UNIQUE,
      content     JSONB DEFAULT '[]'::jsonb,
      published   BOOLEAN NOT NULL DEFAULT false,
      created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
    )`,
  },
];

export async function createTables() {
  const created = [], 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`);
  }
}

export const listPosts   = () => find('lrvo_blog_posts', {}, { orderBy: 'created_at DESC', limit: 50 });
export const createPost  = (data) => create('lrvo_blog_posts', data, { allowedColumns: ['id', 'title', 'slug', 'content'] });
export const countPosts  = async () => (await listPosts()).length;

Deux règles : préfixer les tables par lrvo_<module>_, et rendre createTables idempotent en testant tableExists.

4. La route API

// src/api.server.js
import { defineApiRoutes, apiSuccess, apiError } from '@lrvo/core/api';
import { listPosts, createPost } from './db.server.js';
import { generateId } from '@lrvo/core/users';

async function handleList() {
  return apiSuccess({ posts: await listPosts() });
}

async function handleCreate(request) {
  const body = await request.json();
  if (!body.title?.trim()) return apiError('Bad Request', 'Le titre est requis.');

  const post = await createPost({ id: generateId(), ...body });
  return apiSuccess({ success: true, post, message: 'Billet créé.' });
}

export const routes = defineApiRoutes([
  { path: '/blog/posts', method: 'GET',  handler: handleList,   auth: 'admin', permission: 'blog.view' },
  { path: '/blog/posts', method: 'POST', handler: handleCreate, auth: 'admin', permission: 'blog.manage' },
]);

L'authentification se déclare dans la route, jamais dans le handler.

5. Les enregistrements serveur

// src/register-server.js
import { registerNavSection, registerNavItem, registerPageMeta } from '@lrvo/core/features/admin';
import { registerApiRoutes } from '@lrvo/core/api';
import { registerWidgetFetcher } from '@lrvo/core/features/admin';
import { routes } from './api.server.js';
import { countPosts } from './db.server.js';

registerNavSection({ id: 'blog', title: 'Blog', icon: 'BookBookmark02Icon', order: 40 });
registerNavItem({
  id: 'blog-posts',
  label: 'Billets',
  icon: 'BookBookmark02Icon',
  href: '/admin/blog',
  sectionId: 'blog',
  permission: 'blog.view',
});
registerPageMeta({ slug: 'blog', title: 'Blog', description: 'Gestion des billets' });

registerApiRoutes(routes);
registerWidgetFetcher('blog-posts', async () => ({ count: await countPosts() }));

Aucun composant .client.js n'est importé ici. Ils vivent dans l'entrée client.

6. L'entrée client

// src/client.js
'use client';
import { registerPage, registerWidget } from '@lrvo/core/features/admin';
import { StatCard } from '@lrvo/core/shared/components';
import BlogAdminPage from './admin/BlogAdminPage.client.js';

function BlogWidget({ data, loading }) {
  return <StatCard title="Billets" value={loading ? '-' : String(data?.count ?? 0)} loading={loading} />;
}

registerPage({ slug: 'blog', Component: BlogAdminPage, title: 'Blog' });
registerWidget({ id: 'blog-posts', Component: BlogWidget, order: 40, permission: 'blog.view' });
// src/admin/BlogAdminPage.client.js
'use client';
import { useEffect, useState } from 'react';
import { AdminHeader } from '@lrvo/core/features/admin/components';
import { Table } from '@lrvo/core/shared/components';

export default function BlogAdminPage() {
  const [posts, setPosts] = useState([]);

  useEffect(() => {
    fetch('/lrvo/api/blog/posts')
      .then((r) => r.json())
      .then((d) => setPosts(d.posts ?? []));
  }, []);

  return (
    <div className="flex flex-col gap-6">
      <AdminHeader title="Billets" description="Billets du blog." />
      <Table
        columns={[
          { key: 'title', label: 'Titre', render: (row) => <strong>{row.title}</strong> },
          { key: 'slug',  label: 'Slug' },
          { key: 'created_at', label: 'Créé le', type: 'date' },
        ]}
        rows={posts}
      />
    </div>
  );
}

Ici le fetch est légitime : il part du navigateur vers l'API. L'interdiction de self-fetch ne vise que le code serveur.

7. Construire et vérifier

npm run build

Ouvrir dist/ et contrôler deux choses :

  • Aucun fichier ne contient de JSX brut. Chercher un < suivi d'une majuscule : tout doit être en appels jsx(...).
  • Les fichiers issus de .client.js commencent bien par 'use client'.

8. Installer dans un projet

En local, avant publication :

cd ../mon-projet
npm install ../lrvo-module-blog
npx lrvo-db init
npm run dev

postinstall déclenche lrvo-modules sync, qui régénère les manifestes. lrvo-db init crée la table et attribue les permissions au rôle admin.

À la relance, la section « Blog » apparaît dans la sidebar, la page répond sous /admin/blog, le widget est sur le tableau de bord, et l'API sous /lrvo/api/blog/posts.

Si rien n'apparaît

  1. Le module est-il dans le manifeste ? Ouvrir app/.lrvo/modules.generated.js.
  2. Le composant client est-il rendu ? Vérifier que <LrvoModulesClient /> est dans le JSX de app/layout.js, pas seulement importé.
  3. La sous-entrée ./client est-elle déclarée ? Sans elle, registerPage et registerWidget ne tournent jamais.
  4. Les tables existent-elles ? Relancer npx lrvo-db init.
  5. La permission est-elle attribuée ? Vérifier dans /admin/roles.

La liste complète est dans Cycle de vie d'un module.

9. Publier

npm publish

Voir Publication.

Étape suivante

Déploiement met le projet en production.

Sur cette page