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.
{
"id": "my-plugin",
"name": "My Plugin",
"version": "1.0.0",
"apiVersion": "1.3.0",
"entry": "bundle.js"
}
Champs :
id— kebab-case, immuable après installname— affichage UIversion— semver du pluginapiVersion— version de l'API runtime contre laquelle vous compilez. L'admin refuse de charger si hors-range.entry— défautbundle.js, rarement override
vite.config.ts (critique)
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
{
"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
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 :
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
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 :
- Extrait le ZIP en mémoire (JSZip)
- Lit
manifest.json→ valide id, version, apiVersion - Upload chaque fichier sous
/admin/plugins/<id>/sur Flexweg - Met à jour le registry externe en base
- Dynamic-import
bundle.js→ enregistre le manifest - 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
- Tutoriel pas-à-pas — exemple complet bout-à-bout
- Référence du manifest — toutes les options
- Hooks — filtres et actions