#!/usr/bin/env node const fs = require("fs/promises"); const path = require("path"); const { loadEnv } = require("./lib/env"); loadEnv(); const DEFAULT_CONFIG_PATH = "tools/stats.json"; const DEFAULT_DATA_OUTPUT = "content/stats/data/stats.json"; const DEFAULT_IMAGE_DIR = "content/stats/images"; function parseArgs(argv) { const args = {}; for (let index = 0; index < argv.length; index += 1) { const current = argv[index]; const next = argv[index + 1]; switch (current) { case "--config": case "-c": args.config = next; index += 1; break; case "--data": case "-d": args.data = next; index += 1; break; case "--only": case "-o": args.only = next; index += 1; break; default: break; } } return args; } async function loadDefinition(configPath) { const raw = await fs.readFile(configPath, "utf8"); try { return JSON.parse(raw); } catch (error) { throw new Error(`Impossible de parser ${configPath}: ${error.message}`); } } async function loadModule(scriptPath) { const resolved = path.resolve(scriptPath); await fs.access(resolved); // allow re-run without cache delete require.cache[resolved]; const mod = require(resolved); if (!mod || typeof mod.run !== "function") { throw new Error(`Le script ${scriptPath} doit exporter une fonction run(context)`); } return mod; } function resolvePythonInterpreter() { const envPython = process.env.VIRTUAL_ENV ? path.join(process.env.VIRTUAL_ENV, "bin", "python") : null; const candidates = [ envPython, path.join(process.cwd(), ".venv", "bin", "python"), path.join(process.cwd(), ".venv", "bin", "python3"), "python3", ].filter(Boolean); return candidates.find((candidate) => { try { const stat = require("fs").statSync(candidate); return stat.isFile() || stat.isSymbolicLink(); } catch (_error) { return false; } }) || "python3"; } async function runPython(scriptPath, payload) { const resolved = path.resolve(scriptPath); await fs.access(resolved); const interpreter = resolvePythonInterpreter(); return new Promise((resolve, reject) => { const child = require("child_process").spawn(interpreter, [resolved], { stdio: ["pipe", "pipe", "pipe"], }); let stdout = ""; let stderr = ""; child.stdout.on("data", (data) => { stdout += data.toString(); }); child.stderr.on("data", (data) => { stderr += data.toString(); }); child.on("error", (error) => { reject(error); }); child.on("close", (code) => { if (code !== 0) { const err = new Error(stderr || `Python exited with code ${code}`); err.code = code; return reject(err); } const trimmed = stdout.trim(); if (!trimmed) return resolve({}); try { resolve(JSON.parse(trimmed)); } catch (error) { reject(new Error(`Invalid JSON from ${scriptPath}: ${error.message}`)); } }); child.stdin.write(JSON.stringify(payload)); child.stdin.end(); }); } function toPublicPath(target, { rootDir = process.cwd(), staticDir = path.resolve("static"), absOutput } = {}) { if (!target) return ""; const normalized = target.replace(/\\/g, "/"); if (/^https?:\/\//i.test(normalized)) return normalized; if (normalized.startsWith("/")) { return normalized.replace(/\/{2,}/g, "/"); } const absolute = absOutput || path.resolve(rootDir, target); if (absolute.startsWith(staticDir)) { const rel = path.relative(staticDir, absolute).replace(/\\/g, "/"); return `/${rel}`; } if (!path.isAbsolute(target) && !target.startsWith("/")) { return normalized; } const relRoot = path.relative(rootDir, absolute).replace(/\\/g, "/"); return `/${relRoot}`; } function resolveGraphicPaths(stat, defaultImageDir, { rootDir, staticDir }) { const target = stat.image || (defaultImageDir ? path.join(defaultImageDir, `${stat.key}.png`) : null); if (!target) { throw new Error("Chemin d'image manquant (image ou defaultImageDir)"); } const absOutput = path.isAbsolute(target) ? target : path.resolve(rootDir, target); const publicPath = toPublicPath(target, { rootDir, staticDir, absOutput }); return { publicPath, outputPath: absOutput }; } function mergeResult(base, result, { publicPath } = {}) { const entry = { ...base }; if (publicPath && base.type === "graphic") { entry.image = publicPath; } if (result === undefined || result === null) { return entry; } if (typeof result === "object" && !Array.isArray(result)) { if (Object.prototype.hasOwnProperty.call(result, "value")) { entry.value = result.value; } if (result.image) { entry.image = toPublicPath(result.image); } if (result.meta) { entry.meta = result.meta; } if (result.data) { entry.data = result.data; } return entry; } entry.value = result; return entry; } async function runStat(stat, context) { const base = { key: stat.key, title: stat.title, type: stat.type, }; if (!stat.key) { throw new Error("Cle manquante pour cette statistique"); } if (!stat.script) { throw new Error(`Script manquant pour ${stat.key}`); } if (!stat.type) { throw new Error(`Type manquant pour ${stat.key}`); } const isPython = stat.script.endsWith(".py"); if (isPython) { if (stat.type === "graphic") { const { publicPath, outputPath } = resolveGraphicPaths(stat, context.defaultImageDir, context); await fs.mkdir(path.dirname(outputPath), { recursive: true }); const result = await runPython(stat.script, { ...context, stat, outputPath, publicPath, }); return mergeResult(base, result, { publicPath }); } if (stat.type === "variable") { const result = await runPython(stat.script, { ...context, stat, }); return mergeResult(base, result); } throw new Error(`Type inconnu pour ${stat.key}: ${stat.type}`); } else { const mod = await loadModule(stat.script); if (stat.type === "graphic") { const { publicPath, outputPath } = resolveGraphicPaths(stat, context.defaultImageDir, context); await fs.mkdir(path.dirname(outputPath), { recursive: true }); const result = await mod.run({ ...context, stat, outputPath, publicPath, }); return mergeResult(base, result, { publicPath }); } if (stat.type === "variable") { const result = await mod.run({ ...context, stat, }); return mergeResult(base, result); } throw new Error(`Type inconnu pour ${stat.key}: ${stat.type}`); } } function buildOnlyFilter(onlyRaw) { if (!onlyRaw) return null; const parts = onlyRaw .split(",") .map((part) => part.trim()) .filter(Boolean); return new Set(parts); } async function main() { const cliArgs = parseArgs(process.argv.slice(2)); const definitionPath = path.resolve(cliArgs.config || DEFAULT_CONFIG_PATH); const definition = await loadDefinition(definitionPath); const statsConfig = definition.config || {}; const dataOutput = path.resolve(cliArgs.data || statsConfig.dataOutput || DEFAULT_DATA_OUTPUT); const defaultImageDir = statsConfig.defaultImageDir || DEFAULT_IMAGE_DIR; const onlyFilter = buildOnlyFilter(cliArgs.only); const context = { rootDir: process.cwd(), contentDir: path.resolve("content"), staticDir: path.resolve("static"), definitionPath, defaultImageDir, config: statsConfig, }; const output = { generated_at: new Date().toISOString(), sections: [], }; const errors = []; for (const section of definition.sections || []) { const results = []; for (const stat of section.statistics || []) { if (onlyFilter && !onlyFilter.has(stat.key)) { continue; } try { const entry = await runStat(stat, context); results.push(entry); console.log(`[ok] ${stat.key}`); } catch (error) { errors.push({ key: stat.key, message: error.message }); console.error(`[err] ${stat.key}: ${error.message}`); results.push({ key: stat.key, title: stat.title, type: stat.type, error: error.message, image: stat.image ? toPublicPath(stat.image) : undefined, }); } } if (results.length > 0) { output.sections.push({ title: section.title, statistics: results, }); } } if (errors.length > 0) { output.errors = errors; } await fs.mkdir(path.dirname(dataOutput), { recursive: true }); await fs.writeFile(dataOutput, `${JSON.stringify(output, null, 2)}\n`, "utf8"); const relativeOutput = path.relative(process.cwd(), dataOutput); console.log(`\nFichier de donnees genere: ${relativeOutput}`); if (errors.length > 0) { console.log(`Statistiques en erreur: ${errors.length}. Les entrees concernees contiennent le message d'erreur.`); } } main().catch((error) => { console.error(error.message); process.exit(1); });