import fs from "node:fs/promises"; import path from "node:path"; import type { Plugin, ResolvedConfig } from "vite"; import type { RecipesIndex } from "@ben-to/recipes/catalog"; import { buildRecipeOgHeadModel, forEachRecipeSharePair, injectRecipeSeoIntoIndexHtml } from "@ben-to/recipes/recipe-og-meta"; const DEFAULT_SITE_ORIGIN = "https://ben-to.fr"; /** * Pré-rendu des pages recette pour les métadonnées Open Graph / Twitter (HTML initial). * - **Dev** : middleware qui réinjecte le `` pour les URLs `/r/:base/:variant`. * - **Build** : écrit `dist/r///index.html` pour chaque couple catalogue. */ export function recipeSeoPlugin(): Plugin { let config: ResolvedConfig; return { name: "ben-to-recipe-seo", configResolved(resolved) { config = resolved; }, configureServer(server) { const root = server.config.root; const port = server.config.server.port ?? 3000; server.middlewares.use(async (req, res, next) => { if (req.method !== "GET" || !req.url) { next(); return; } const pathname = new URL(req.url, "http://localhost").pathname.replace(/\/$/, ""); const match = /^\/r\/([^/]+)\/([^/]+)$/.exec(pathname); if (!match) { next(); return; } try { const baseId = decodeURIComponent(match[1]); const variantId = decodeURIComponent(match[2]); const catalogPath = path.join(root, "public/recipes/catalog.json"); const indexPath = path.join(root, "index.html"); const [catalogRaw, rawIndexHtml] = await Promise.all([ fs.readFile(catalogPath, "utf8"), fs.readFile(indexPath, "utf8") ]); // On passe par le pipeline de transformation HTML de Vite (injecte la CSP // et tout autre plugin qui utilise `transformIndexHtml`) avant d'injecter // les balises OG/Twitter recette. const indexHtml = await server.transformIndexHtml(req.url ?? pathname, rawIndexHtml); const catalog = JSON.parse(catalogRaw) as RecipesIndex; const base = catalog.bases.find((b) => b.id === baseId); const variant = catalog.variants.find((v) => v.id === variantId); if (!base || !variant || !variant.baseIds.includes(baseId)) { next(); return; } const devOrigin = process.env.BEN_TO_SITE_ORIGIN ?? `http://localhost:${port}`; const model = buildRecipeOgHeadModel({ siteOrigin: devOrigin, base, variant, baseId, variantId }); const html = injectRecipeSeoIntoIndexHtml(indexHtml, model); res.statusCode = 200; res.setHeader("Content-Type", "text/html; charset=utf-8"); res.end(html); } catch { next(); } }); }, async closeBundle() { const outDir = path.resolve(config.root, config.build.outDir); const indexPath = path.join(outDir, "index.html"); const catalogPath = path.join(outDir, "recipes/catalog.json"); const siteOrigin = process.env.BEN_TO_SITE_ORIGIN ?? DEFAULT_SITE_ORIGIN; const [indexHtml, catalogRaw] = await Promise.all([ fs.readFile(indexPath, "utf8"), fs.readFile(catalogPath, "utf8") ]); const catalog = JSON.parse(catalogRaw) as RecipesIndex; const tasks: Promise[] = []; forEachRecipeSharePair(catalog, ({ base, variant, baseId, variantId }) => { tasks.push( (async () => { const model = buildRecipeOgHeadModel({ siteOrigin, base, variant, baseId, variantId }); const html = injectRecipeSeoIntoIndexHtml(indexHtml, model); const dir = path.join(outDir, "r", encodeURIComponent(baseId), encodeURIComponent(variantId)); await fs.mkdir(dir, { recursive: true }); await fs.writeFile(path.join(dir, "index.html"), html, "utf8"); })() ); }); await Promise.all(tasks); config.logger.info(`recipe-seo: ${tasks.length} page(s) OG générée(s) sous dist/r/`); } }; }