110 lines
4.2 KiB
TypeScript
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/`);
|
|
}
|
|
};
|
|
}
|