#!/usr/bin/env node import fs from "node:fs/promises"; import path from "node:path"; import process from "node:process"; import { spawnSync } from "node:child_process"; function usage(exitCode = 0) { console.log(` apply-ticket — applique une proposition de correction depuis un ticket Gitea (robuste) Usage: node scripts/apply-ticket.mjs [--dry-run] [--no-build] [--alias] [--commit] Flags: --dry-run : ne modifie rien, affiche BEFORE/AFTER --no-build : n'exécute pas "npm run build" (INCOMPATIBLE avec --alias) --alias : après application, ajoute l'alias d'ancre (old -> new) dans src/anchors/anchor-aliases.json --commit : git add + git commit automatiquement (inclut alias si --alias) Env (recommandé): FORGE_API = base API (LAN) ex: http://192.168.1.20:3000 FORGE_BASE = base web ex: https://gitea.xxx.tld FORGE_TOKEN = PAT (accès repo + issues) GITEA_OWNER = owner (optionnel si auto-détecté depuis git remote) GITEA_REPO = repo (optionnel si auto-détecté depuis git remote) Notes: - Si dist//index.html est absent, le script lance "npm run build" sauf si --no-build. - Sauvegarde automatique: .bak.issue- (uniquement si on écrit) - Avec --alias : le script build pour obtenir le NOUVEL id, puis écrit l'alias old->new. `); process.exit(exitCode); } const argv = process.argv.slice(2); if (argv.length === 0 || argv.includes("--help") || argv.includes("-h")) usage(0); const issueNum = Number(argv[0]); if (!Number.isFinite(issueNum) || issueNum <= 0) { console.error("❌ Numéro de ticket invalide."); usage(1); } const DRY_RUN = argv.includes("--dry-run"); const NO_BUILD = argv.includes("--no-build"); const DO_ALIAS = argv.includes("--alias"); const DO_COMMIT = argv.includes("--commit"); if (DO_ALIAS && NO_BUILD) { console.error("❌ --alias est incompatible avec --no-build (risque d'alias faux)."); console.error("➡️ Relance sans --no-build."); process.exit(1); } if (DRY_RUN && (DO_ALIAS || DO_COMMIT)) { console.warn("ℹ️ --dry-run : --alias/--commit sont ignorés (aucune écriture)."); } if (typeof fetch !== "function") { console.error("❌ fetch() indisponible dans ce Node. Utilise Node 18+ (ou plus)."); process.exit(1); } const CWD = process.cwd(); const CONTENT_ROOT = path.join(CWD, "src", "content"); const DIST_ROOT = path.join(CWD, "dist"); const ALIASES_FILE = path.join(CWD, "src", "anchors", "anchor-aliases.json"); function normalizeText(s) { return String(s ?? "") .normalize("NFKD") .replace(/\p{Diacritic}/gu, "") .replace(/[’‘]/g, "'") .replace(/[“”]/g, '"') .replace(/[–—]/g, "-") .replace(/…/g, "...") .replace(/\s+/g, " ") .trim() .toLowerCase(); } // stripping très pragmatique function stripMd(mdx) { let s = String(mdx ?? ""); s = s.replace(/`[^`]*`/g, " "); // inline code s = s.replace(/!\[[^\]]*\]\([^)]+\)/g, " "); // images s = s.replace(/\[[^\]]*\]\([^)]+\)/g, " "); // links s = s.replace(/[*_~]/g, " "); // emphasis-ish s = s.replace(/<[^>]+>/g, " "); // html tags s = s.replace(/\s+/g, " ").trim(); return s; } function tokenize(s) { const n = normalizeText(stripMd(s)); return n .replace(/[^a-z0-9'\- ]+/g, " ") .split(" ") .filter((w) => w.length >= 4); } function run(cmd, args, opts = {}) { const r = spawnSync(cmd, args, { stdio: "inherit", ...opts }); if (r.error) throw r.error; if (r.status !== 0) throw new Error(`Command failed: ${cmd} ${args.join(" ")}`); } function runQuiet(cmd, args, opts = {}) { const r = spawnSync(cmd, args, { encoding: "utf8", stdio: "pipe", ...opts }); if (r.error) throw r.error; if (r.status !== 0) { const out = (r.stdout || "") + (r.stderr || ""); throw new Error(`Command failed: ${cmd} ${args.join(" ")}\n${out}`); } return r.stdout || ""; } async function fileExists(p) { try { await fs.access(p); return true; } catch { return false; } } function getEnv(name, fallback = "") { return (process.env[name] ?? fallback).trim(); } function inferOwnerRepoFromGit() { const r = spawnSync("git", ["remote", "get-url", "origin"], { encoding: "utf-8" }); if (r.status !== 0) return null; const u = (r.stdout || "").trim(); const m = u.match(/[:/](?[^/]+)\/(?[^/]+?)(?:\.git)?$/); if (!m?.groups) return null; return { owner: m.groups.owner, repo: m.groups.repo }; } function escapeRegExp(s) { return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } function pickLine(body, key) { const re = new RegExp(`^\\s*${escapeRegExp(key)}\\s*:\\s*([^\\n\\r]+)`, "mi"); const m = body.match(re); return m ? m[1].trim() : ""; } function pickHeadingValue(body, headingKey) { const re = new RegExp(`^##\\s*${escapeRegExp(headingKey)}[^\\n]*\\n([\\s\\S]*?)(?=\\n##\\s|\\n\\s*$)`, "mi"); const m = body.match(re); if (!m) return ""; const lines = m[1].split(/\r?\n/).map(l => l.trim()); for (const l of lines) { if (!l) continue; if (l.startsWith("