Construire un bundle thème externe

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

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

C'est la façon de distribuer des thèmes quand vous ne maintenez pas votre propre fork.

Exemple de référence

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

Structure

my-theme/
├── manifest.json          ← métadonnées d'installation
├── package.json
├── tsconfig.json
├── vite.config.ts          ← même config que les plugins externes
├── scripts/pack.mjs        ← zippe aussi theme.css
├── src/
│   ├── manifest.tsx
│   ├── theme.css           ← CSS compilée importée via ?raw
│   ├── templates/
│   │   ├── BaseLayout.tsx       ← CRITIQUE : contient les sentinels
│   │   ├── HomeTemplate.tsx
│   │   ├── SingleTemplate.tsx
│   │   ├── CategoryTemplate.tsx
│   │   ├── AuthorTemplate.tsx
│   │   └── NotFoundTemplate.tsx
│   └── types/
│       └── cms-runtime.d.ts
└── README.md

manifest.json

Identique aux plugins externes :

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

L'admin lit ce fichier à l'install + à chaque boot.

src/manifest.tsx

TSX
import cssText from "./theme.css?raw";
import { BaseLayout } from "./templates/BaseLayout";
import { HomeTemplate } from "./templates/HomeTemplate";
import { SingleTemplate } from "./templates/SingleTemplate";
import { CategoryTemplate } from "./templates/CategoryTemplate";
import { AuthorTemplate } from "./templates/AuthorTemplate";
import { NotFoundTemplate } from "./templates/NotFoundTemplate";

const manifest = {
  id: "my-theme",
  name: "My Theme",
  version: "1.0.0",
  description: "Pour quoi ce thème est conçu en une phrase.",
  cssText,
  templates: {
    base: BaseLayout,
    home: HomeTemplate,
    single: SingleTemplate,
    category: CategoryTemplate,
    author: AuthorTemplate,
    notFound: NotFoundTemplate,
  },
};

export default manifest;

vite.config.ts

Identique aux plugins externes. Voir Construire un bundle plugin externe. Les trois pièces critiques sont les mêmes : define, external, inlineDynamicImports.

src/templates/BaseLayout.tsx — la partie CRITIQUE

TSX
import type { BaseLayoutProps } from "../types/cms-runtime";

export function BaseLayout({
  site,
  pageTitle,
  pageDescription,
  ogImage,
  currentPath,
  children,
}: BaseLayoutProps) {
  const lang = site.settings.language ?? "en";
  return (
    <html lang={lang}>
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <title>{pageTitle}</title>
        {pageDescription && <meta name="description" content={pageDescription} />}
        {ogImage && <meta property="og:image" content={ogImage} />}
        <link rel="stylesheet" href={`/theme-assets/my-theme.css`} />
        {/* SENTINEL OBLIGATOIRE — les plugins injectent des head tags ici */}
        <meta name="x-cms-head-extra" />
      </head>
      <body>
        {children}
        {/* SENTINEL OBLIGATOIRE — les plugins injectent des scripts body-end ici */}
        <script type="application/x-cms-body-end" />
      </body>
    </html>
  );
}

Sans ces deux sentinels, les plugins comme core-seo, flexweg-favicon, flexweg-custom-code silencieusement no-op sur votre thème.

Les templates reçoivent des props serializables

Les composants de thème doivent être purs / consumers de props serializables — pas de hooks Firestore, pas de context admin. Le publisher résout les URLs, MediaView shapes, ResolvedMenuItems, etc. avant le rendu. Copiez les types canoniques du in-tree src/themes/types.ts dans votre src/types/cms-runtime.d.ts pour que le bundle soit self-contained.

Pipeline CSS — votre choix

L'admin upload manifest.cssText verbatim à /theme-assets/<id>.css. Votre pipeline CSS doit juste produire une string au build :

  • CSS pure : écrivez theme.css, import cssText from "./theme.css?raw". Plus simple.
  • Tailwind : pré-buildez avec tailwindcss -i src/theme.css -o dist/theme.css avant vite build, puis importez le fichier dist.
  • SCSS : import cssText from "./theme.scss?raw" — Vite supporte Sass inline.

Scopez votre CSS avec un préfixe de classe unique (ex. .mt- pour my-theme) pour éviter les collisions quand les utilisateurs basculent de thème.

Customisation live avec compileCss

Si les réglages de votre thème exposent des couleurs / polices / spacing éditables, intégrez-les dans la CSS uploadée via compileCss :

TS
manifest.compileCss = (config: MyThemeConfig) => {
  const overrides = `:root {\n  --my-primary: ${config.primaryColor};\n}\n`;
  return cssText + "\n\n" + overrides;
};

L'admin appelle ceci chaque fois que theme-assets/<id>.css est uploadé (bouton Sync theme assets + save de la page de réglages du thème). Sans compileCss, chaque sync écrase les overrides utilisateur.

Build + install

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

Puis dans l'admin : /admin/themesInstall theme → drag le ZIP. L'admin valide, upload, dynamic-imports, reload.

scripts/pack.mjs (avec CSS)

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")));
zip.file("theme.css", readFileSync(resolve(root, "src/theme.css")));
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`));

Le thème externe ship theme.css séparément pour que l'admin puisse l'uploader à theme-assets/<id>.css dès l'install (avant le premier sync).

Tutoriel pas-à-pas

Voir Créer un thème externe — tutoriel pour un exemple complet bout-à-bout.