Construire un bundle plugin externe

Les plugins externes sont livrés en contenant un bundle ESM pré-compilé, uploadés dans Flexweg via l'UI Install plugin, et chargés au runtime via dynamic — pas besoin de rebuild de l'admin.

Les plugins externes sont livrés en .zip contenant un bundle ESM pré-compilé, uploadés dans Flexweg via l'UI Install plugin, et chargés au runtime via dynamic import() — pas besoin de rebuild de l'admin.

C'est la façon de distribuer des plugins quand vous ne maintenez pas votre propre fork de l'admin.

Exemple de référence

examples/external-plugin/ est un plugin minimaliste complet — clonez-le, renommez-le, customisez-le. La config de build + le packaging ZIP sont déjà mis en place.

Structure du projet

my-external-plugin/
├── package.json
├── tsconfig.json
├── vite.config.ts
├── manifest.json           ← métadonnées d'installation
├── scripts/pack.mjs        ← zippe dist/ + manifest.json en <id>.zip
├── src/
│   ├── manifest.tsx        ← export default le PluginManifest
│   └── types/cms-runtime.d.ts ← stubs minimaux pour TS
└── README.md

manifest.json (métadonnées d'installation)

Lu par l'admin AVANT d'importer le bundle. C'est ce qui apparaît dans le modal d'install.

JSON
{
  "id": "my-plugin",
  "name": "My Plugin",
  "version": "1.0.0",
  "apiVersion": "1.3.0",
  "entry": "bundle.js"
}

Champs :

  • id — kebab-case, immuable après install
  • name — affichage UI
  • version — semver du plugin
  • apiVersion — version de l'API runtime contre laquelle vous compilez. L'admin refuse de charger si hors-range.
  • entry — défaut bundle.js, rarement override

vite.config.ts (critique)

TS
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  // Vite lib mode ne remplace PAS process.env.NODE_ENV comme en app mode.
  // Le shim prod de React, react-i18next, plusieurs deps Tiptap référencent.
  // Sans ça, le bundle crashe au premier import.
  define: {
    "process.env.NODE_ENV": JSON.stringify("production"),
  },
  build: {
    lib: {
      entry: "src/manifest.tsx",
      formats: ["es"],
      fileName: () => "bundle.js",
    },
    outDir: "dist",
    rollupOptions: {
      // CHAQUE specifier bare que l'import-map de l'admin couvre DOIT être external.
      // Sinon Rollup embarque un deuxième React → "Invalid hook call".
      external: [
        "react",
        "react/jsx-runtime",
        "react-dom",
        "react-dom/client",
        "react-i18next",
        "@flexweg/cms-runtime",
      ],
      output: {
        // L'admin charge UN seul bundle.js. Désactiver le chunk splitting
        // pour qu'aucun chunk séparé ne 404.
        inlineDynamicImports: true,
      },
    },
  },
});

Sans ces trois pièces (define process.env.NODE_ENV, external, inlineDynamicImports), le bundle crashe au boot.

package.json

JSON
{
  "name": "my-plugin",
  "private": true,
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "build": "vite build && node scripts/pack.mjs"
  },
  "devDependencies": {
    "@types/react": "^18.3.28",
    "@vitejs/plugin-react": "^4.3.3",
    "jszip": "^3.10.1",
    "react": "^18.3.1",
    "react-dom": "^18.3.1",
    "react-i18next": "^15.2.0",
    "typescript": "^6.0.3",
    "vite": "^5.4.10"
  }
}

Installer avec npm install --legacy-peer-deps (le projet pin TypeScript 6 alors que react-i18next a un peer optionnel sur TS 5).

scripts/pack.mjs

JS
import { createWriteStream, readFileSync, existsSync } from "node:fs";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import JSZip from "jszip";

const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const manifest = JSON.parse(readFileSync(resolve(root, "manifest.json"), "utf-8"));

const zip = new JSZip();
zip.file("manifest.json", readFileSync(resolve(root, "manifest.json")));
zip.file("bundle.js", readFileSync(resolve(root, "dist/bundle.js")));
const readme = resolve(root, "README.md");
if (existsSync(readme)) zip.file("README.md", readFileSync(readme));

zip
  .generateNodeStream({ type: "nodebuffer", streamFiles: true })
  .pipe(createWriteStream(resolve(root, `${manifest.id}.zip`)))
  .on("finish", () => console.log(`Packed: ${manifest.id}.zip`));

src/types/cms-runtime.d.ts (stubs minimaux)

Le runtime n'est PAS un package npm publié — c'est le pont dynamique du runtime de l'admin. Les types sont fournis localement comme stubs :

TS
declare module "@flexweg/cms-runtime" {
  export const FLEXWEG_API_VERSION: string;
  export const FLEXWEG_API_MIN_VERSION: string;
  export const pluginApi: {
    addFilter<T>(hook: string, fn: (value: T, ...args: unknown[]) => T | Promise<T>, priority?: number): void;
    addAction(hook: string, fn: (...args: unknown[]) => void | Promise<void>, priority?: number): void;
    registerBlock(manifest: unknown): void;
    registerDashboardCard(manifest: { id: string; priority?: number; component: React.ComponentType }): void;
  };
  export interface PluginManifest<TConfig = unknown> {
    id: string;
    name: string;
    version: string;
    description?: string;
    author?: string;
    readme?: string;
    register: (api: typeof pluginApi) => void;
    settings?: {
      navLabelKey: string;
      defaultConfig: TConfig;
      component: React.ComponentType<PluginSettingsPageProps<TConfig>>;
    };
    i18n?: Record<string, Record<string, unknown>>;
  }
  export interface PluginSettingsPageProps<T> {
    config: T;
    save: (next: T) => void | Promise<void>;
  }
  // Ajoutez Post, PublishContext, etc. selon vos besoins.
}

Ajoutez les types dont vous avez besoin. La référence complète : Types.

Build + install

BASH
npm install --legacy-peer-deps
npm run build       # → my-plugin.zip

Puis dans l'admin : /admin/plugins → bouton Install plugin → drag le ZIP. L'admin :

  1. Extrait le ZIP en mémoire (JSZip)
  2. Lit manifest.json → valide id, version, apiVersion
  3. Upload chaque fichier sous /admin/plugins/<id>/ sur Flexweg
  4. Met à jour le registry externe en base
  5. Dynamic-import bundle.js → enregistre le manifest
  6. Reload de la page

Si une version précédente du même id existe, l'admin fait un upgrade in-place : pré-clean l'ancien dossier, upload le nouveau, replace l'entrée registry. Affiche v1.0.0 → v1.1.0 dans le toast.

Uninstall

Bouton Uninstall dans le même modal. Supprime l'entrée registry + le dossier /admin/plugins/<id>/ sur Flexweg. Le config en base (pluginConfigs[<id>]) reste — si vous réinstallez plus tard, vos réglages sont restaurés.

Pour aller plus loin