Files
ben-to/apps/web/vite-plugin-recipe-seo.ts
2026-07-21 16:50:58 +02:00

110 lines
4.2 KiB
TypeScript

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 `<head>` pour les URLs `/r/:base/:variant`.
* - **Build** : écrit `dist/r/<base>/<variant>/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<void>[] = [];
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/`);
}
};
}