576 lines
19 KiB
JavaScript
Executable File
576 lines
19 KiB
JavaScript
Executable File
#!/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 <issue_number> [--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/<chemin>/index.html est absent, le script lance "npm run build" sauf si --no-build.
|
||
- Sauvegarde automatique: <fichier>.bak.issue-<N> (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(/[:/](?<owner>[^/]+)\/(?<repo>[^/]+?)(?:\.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("<!--")) continue;
|
||
return l.replace(/^\/?/, "/").trim();
|
||
}
|
||
return "";
|
||
}
|
||
|
||
function pickSection(body, markers) {
|
||
const text = body.replace(/\r\n/g, "\n");
|
||
const idx = markers
|
||
.map(m => ({ m, i: text.toLowerCase().indexOf(m.toLowerCase()) }))
|
||
.filter(x => x.i >= 0)
|
||
.sort((a, b) => a.i - b.i)[0];
|
||
if (!idx) return "";
|
||
const start = idx.i + idx.m.length;
|
||
const tail = text.slice(start);
|
||
|
||
const stops = [
|
||
"\n## ", "\nJustification", "\n---", "\n## Justification", "\n## Sources",
|
||
"\nProblème identifié", "\nSources proposées", "\n## Proposition", "\n## Problème"
|
||
];
|
||
let end = tail.length;
|
||
for (const s of stops) {
|
||
const j = tail.toLowerCase().indexOf(s.toLowerCase());
|
||
if (j >= 0 && j < end) end = j;
|
||
}
|
||
return tail.slice(0, end).trim();
|
||
}
|
||
|
||
function unquoteBlock(s) {
|
||
return String(s ?? "")
|
||
.split(/\r?\n/)
|
||
.map(l => l.replace(/^\s*>\s?/, ""))
|
||
.join("\n")
|
||
.trim();
|
||
}
|
||
|
||
async function readHtmlParagraphText(htmlPath, anchorId) {
|
||
const html = await fs.readFile(htmlPath, "utf-8");
|
||
const re = new RegExp(`<p[^>]*\\bid=["']${escapeRegExp(anchorId)}["'][^>]*>([\\s\\S]*?)<\\/p>`, "i");
|
||
const m = html.match(re);
|
||
if (!m) return "";
|
||
return cleanHtmlInner(m[1]);
|
||
}
|
||
|
||
function cleanHtmlInner(inner) {
|
||
let s = String(inner ?? "");
|
||
s = s.replace(/<span[^>]*class=["'][^"']*para-tools[^"']*["'][^>]*>[\s\S]*?<\/span>/gi, " ");
|
||
s = s.replace(/<[^>]+>/g, " ");
|
||
s = s.replace(/\s+/g, " ").trim();
|
||
s = s.replace(/\b(¶|Citer|Proposer|Copié)\b/gi, "").replace(/\s+/g, " ").trim();
|
||
return s;
|
||
}
|
||
|
||
async function readAllHtmlParagraphs(htmlPath) {
|
||
const html = await fs.readFile(htmlPath, "utf-8");
|
||
const out = [];
|
||
const re = /<p\b[^>]*\sid=["'](p-\d+-[0-9a-f]{8})["'][^>]*>([\s\S]*?)<\/p>/gi;
|
||
let m;
|
||
while ((m = re.exec(html))) {
|
||
out.push({ id: m[1], text: cleanHtmlInner(m[2]) });
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function splitParagraphBlocks(mdxText) {
|
||
const raw = mdxText.replace(/\r\n/g, "\n");
|
||
return raw.split(/\n{2,}/);
|
||
}
|
||
|
||
function isLikelyExcerpt(s) {
|
||
const t = String(s || "").trim();
|
||
if (!t) return true;
|
||
if (t.length < 120) return true;
|
||
if (/[.…]$/.test(t)) return true;
|
||
if (t.includes("tronqu")) return true;
|
||
return false;
|
||
}
|
||
|
||
function scoreText(candidate, targetText) {
|
||
const tgt = tokenize(targetText);
|
||
const blk = tokenize(candidate);
|
||
if (!tgt.length || !blk.length) return 0;
|
||
|
||
const tgtSet = new Set(tgt);
|
||
const blkSet = new Set(blk);
|
||
|
||
let hit = 0;
|
||
for (const w of tgtSet) if (blkSet.has(w)) hit++;
|
||
|
||
const tgtNorm = normalizeText(stripMd(targetText));
|
||
const blkNorm = normalizeText(stripMd(candidate));
|
||
const prefix = tgtNorm.slice(0, Math.min(180, tgtNorm.length));
|
||
const prefixBonus = prefix && blkNorm.includes(prefix) ? 1000 : 0;
|
||
|
||
const ratio = hit / Math.max(1, tgtSet.size);
|
||
const ratioBonus = Math.round(ratio * 100);
|
||
|
||
return prefixBonus + hit + ratioBonus;
|
||
}
|
||
|
||
function bestBlockMatchIndex(blocks, targetText) {
|
||
let best = { i: -1, score: -1 };
|
||
for (let i = 0; i < blocks.length; i++) {
|
||
const sc = scoreText(blocks[i], targetText);
|
||
if (sc > best.score) best = { i, score: sc };
|
||
}
|
||
return best;
|
||
}
|
||
|
||
function normalizeChemin(chemin) {
|
||
let c = String(chemin || "").trim();
|
||
if (!c.startsWith("/")) c = "/" + c;
|
||
if (!c.endsWith("/")) c = c + "/";
|
||
return c;
|
||
}
|
||
|
||
async function findContentFileFromChemin(chemin) {
|
||
const clean = normalizeChemin(chemin).replace(/^\/+|\/+$/g, "");
|
||
const parts = clean.split("/").filter(Boolean);
|
||
if (parts.length < 2) return null;
|
||
const collection = parts[0];
|
||
const slugPath = parts.slice(1).join("/");
|
||
const root = path.join(CONTENT_ROOT, collection);
|
||
if (!(await fileExists(root))) return null;
|
||
|
||
const exts = [".mdx", ".md"];
|
||
async function walk(dir) {
|
||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||
for (const e of entries) {
|
||
const full = path.join(dir, e.name);
|
||
if (e.isDirectory()) {
|
||
const r = await walk(full);
|
||
if (r) return r;
|
||
} else {
|
||
for (const ext of exts) {
|
||
if (e.name.endsWith(ext)) {
|
||
const rel = path.relative(root, full).replace(/\\/g, "/");
|
||
const relNoExt = rel.slice(0, -ext.length);
|
||
if (relNoExt === slugPath) return full;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
return await walk(root);
|
||
}
|
||
|
||
async function ensureBuildIfNeeded(distHtmlPath) {
|
||
if (NO_BUILD) return;
|
||
if (await fileExists(distHtmlPath)) return;
|
||
|
||
console.log("ℹ️ dist manquant pour cette page → build (npm run build) …");
|
||
run("npm", ["run", "build"], { cwd: CWD });
|
||
if (!(await fileExists(distHtmlPath))) {
|
||
throw new Error(`dist toujours introuvable après build: ${distHtmlPath}`);
|
||
}
|
||
}
|
||
|
||
async function fetchIssue({ forgeApiBase, owner, repo, token, issueNum }) {
|
||
const url = `${forgeApiBase.replace(/\/+$/,"")}/api/v1/repos/${owner}/${repo}/issues/${issueNum}`;
|
||
const res = await fetch(url, {
|
||
headers: {
|
||
"Authorization": `token ${token}`,
|
||
"Accept": "application/json",
|
||
"User-Agent": "archicratie-apply-ticket/1.2",
|
||
}
|
||
});
|
||
if (!res.ok) {
|
||
const t = await res.text().catch(()=> "");
|
||
throw new Error(`HTTP ${res.status} fetching issue: ${url}\n${t}`);
|
||
}
|
||
return await res.json();
|
||
}
|
||
|
||
async function loadAliases() {
|
||
try {
|
||
const s = await fs.readFile(ALIASES_FILE, "utf8");
|
||
const obj = JSON.parse(s);
|
||
return obj && typeof obj === "object" ? obj : {};
|
||
} catch {
|
||
return {};
|
||
}
|
||
}
|
||
|
||
function sortObjectKeys(obj) {
|
||
return Object.fromEntries(Object.keys(obj).sort().map((k) => [k, obj[k]]));
|
||
}
|
||
|
||
async function saveAliases(obj) {
|
||
let out = obj || {};
|
||
// tri stable
|
||
for (const k of Object.keys(out)) {
|
||
if (out[k] && typeof out[k] === "object") out[k] = sortObjectKeys(out[k]);
|
||
}
|
||
out = sortObjectKeys(out);
|
||
|
||
await fs.mkdir(path.dirname(ALIASES_FILE), { recursive: true });
|
||
await fs.writeFile(ALIASES_FILE, JSON.stringify(out, null, 2) + "\n", "utf8");
|
||
}
|
||
|
||
async function upsertAlias({ chemin, oldId, newId }) {
|
||
const route = normalizeChemin(chemin);
|
||
if (!oldId || !newId) throw new Error("Alias: oldId/newId requis");
|
||
if (oldId === newId) return { changed: false, reason: "same" };
|
||
|
||
const data = await loadAliases();
|
||
if (!data[route]) data[route] = {};
|
||
|
||
const prev = data[route][oldId];
|
||
if (prev && prev !== newId) {
|
||
throw new Error(`Alias conflict: ${route}${oldId} already mapped to ${prev} (new=${newId})`);
|
||
}
|
||
if (prev === newId) return { changed: false, reason: "already" };
|
||
|
||
data[route][oldId] = newId;
|
||
await saveAliases(data);
|
||
return { changed: true, reason: "written" };
|
||
}
|
||
|
||
function gitHasStagedChanges() {
|
||
const r = spawnSync("git", ["diff", "--cached", "--quiet"]);
|
||
return r.status === 1;
|
||
}
|
||
|
||
async function computeNewIdFromDistByContent(distHtmlPath, afterBlock) {
|
||
const paras = await readAllHtmlParagraphs(distHtmlPath);
|
||
if (!paras.length) throw new Error(`Aucun <p id="p-..."> trouvé dans ${distHtmlPath}`);
|
||
|
||
let best = { id: null, score: -1, text: "" };
|
||
const target = stripMd(afterBlock).slice(0, 1200);
|
||
|
||
for (const p of paras) {
|
||
const sc = scoreText(p.text, target);
|
||
if (sc > best.score) best = { id: p.id, score: sc, text: p.text };
|
||
}
|
||
|
||
// seuil de sécurité (évite alias faux)
|
||
if (!best.id || best.score < 60) {
|
||
throw new Error(
|
||
`Impossible d'identifier le nouvel id dans dist (score trop faible: ${best.score}).\n` +
|
||
`➡️ Vérifie que la proposition correspond bien à UN paragraphe.`
|
||
);
|
||
}
|
||
|
||
return best.id;
|
||
}
|
||
|
||
async function main() {
|
||
const token = getEnv("FORGE_TOKEN");
|
||
if (!token) {
|
||
console.error("❌ FORGE_TOKEN manquant (PAT). Ex: export FORGE_TOKEN='...'");
|
||
process.exit(1);
|
||
}
|
||
|
||
const inferred = inferOwnerRepoFromGit() || {};
|
||
const owner = getEnv("GITEA_OWNER", inferred.owner || "");
|
||
const repo = getEnv("GITEA_REPO", inferred.repo || "");
|
||
if (!owner || !repo) {
|
||
console.error("❌ Impossible de déterminer owner/repo. Fix: export GITEA_OWNER=... GITEA_REPO=...");
|
||
process.exit(1);
|
||
}
|
||
|
||
const forgeApiBase = getEnv("FORGE_API") || getEnv("FORGE_BASE");
|
||
if (!forgeApiBase) {
|
||
console.error("❌ FORGE_API ou FORGE_BASE manquant. Ex: export FORGE_API='http://192.168.1.20:3000'");
|
||
process.exit(1);
|
||
}
|
||
|
||
console.log(`🔎 Fetch ticket #${issueNum} from ${owner}/${repo} …`);
|
||
const issue = await fetchIssue({ forgeApiBase, owner, repo, token, issueNum });
|
||
|
||
const body = String(issue.body || "").replace(/\r\n/g, "\n");
|
||
|
||
let chemin = pickLine(body, "Chemin") || pickHeadingValue(body, "Chemin");
|
||
let ancre = pickLine(body, "Ancre") || pickHeadingValue(body, "Ancre paragraphe") || pickHeadingValue(body, "Ancre");
|
||
chemin = normalizeChemin(chemin);
|
||
ancre = (ancre || "").trim();
|
||
if (ancre.startsWith("#")) ancre = ancre.slice(1);
|
||
|
||
const currentFull = pickSection(body, ["Texte actuel (copie exacte du paragraphe)", "## Texte actuel (copie exacte du paragraphe)"]);
|
||
const currentEx = pickSection(body, ["Texte actuel (extrait)", "## Assertion / passage à vérifier", "Assertion / passage à vérifier"]);
|
||
const texteActuel = unquoteBlock(currentFull || currentEx);
|
||
|
||
const prop1 = pickSection(body, ["Proposition (texte corrigé complet)", "## Proposition (texte corrigé complet)"]);
|
||
const prop2 = pickSection(body, ["Proposition (remplacer par):", "## Proposition (remplacer par)"]);
|
||
const proposition = (prop1 || prop2).trim();
|
||
|
||
if (!chemin) throw new Error("Ticket: Chemin introuvable dans le body.");
|
||
if (!ancre) throw new Error("Ticket: Ancre introuvable dans le body.");
|
||
if (!proposition) throw new Error("Ticket: Proposition introuvable dans le body.");
|
||
|
||
console.log("✅ Parsed:", { chemin, ancre: `#${ancre}`, hasTexteActuel: Boolean(texteActuel) });
|
||
|
||
const contentFile = await findContentFileFromChemin(chemin);
|
||
if (!contentFile) throw new Error(`Fichier contenu introuvable pour Chemin=${chemin}`);
|
||
console.log(`📄 Target content file: ${path.relative(CWD, contentFile)}`);
|
||
|
||
const distHtmlPath = path.join(DIST_ROOT, chemin.replace(/^\/+|\/+$/g,""), "index.html");
|
||
await ensureBuildIfNeeded(distHtmlPath);
|
||
|
||
// targetText: préférence au texte complet (ticket), sinon dist si extrait probable
|
||
let targetText = texteActuel;
|
||
|
||
let distText = "";
|
||
if (await fileExists(distHtmlPath)) {
|
||
distText = await readHtmlParagraphText(distHtmlPath, ancre);
|
||
}
|
||
|
||
if (!targetText && distText) targetText = distText;
|
||
if (targetText && distText && isLikelyExcerpt(targetText) && distText.length > targetText.length) {
|
||
targetText = distText;
|
||
}
|
||
|
||
if (!targetText) {
|
||
throw new Error("Impossible de reconstruire le texte du paragraphe (ni texte actuel, ni dist html).");
|
||
}
|
||
|
||
const original = await fs.readFile(contentFile, "utf-8");
|
||
const blocks = splitParagraphBlocks(original);
|
||
|
||
const best = bestBlockMatchIndex(blocks, targetText);
|
||
|
||
// seuil de sécurité
|
||
if (best.i < 0 || best.score < 40) {
|
||
console.error("❌ Match trop faible: je refuse de remplacer automatiquement.");
|
||
console.error(`➡️ Score=${best.score}. Recommandation: ticket avec 'Texte actuel (copie exacte du paragraphe)'.`);
|
||
const ranked = blocks
|
||
.map((b, i) => ({ i, score: scoreText(b, targetText), excerpt: stripMd(b).slice(0, 140) }))
|
||
.sort((a, b) => b.score - a.score)
|
||
.slice(0, 5);
|
||
|
||
console.error("Top candidates:");
|
||
for (const r of ranked) {
|
||
console.error(` #${r.i + 1} score=${r.score} ${r.excerpt}${r.excerpt.length >= 140 ? "…" : ""}`);
|
||
}
|
||
process.exit(2);
|
||
}
|
||
|
||
const beforeBlock = blocks[best.i];
|
||
const afterBlock = proposition.trim();
|
||
|
||
const nextBlocks = blocks.slice();
|
||
nextBlocks[best.i] = afterBlock;
|
||
const updated = nextBlocks.join("\n\n");
|
||
|
||
console.log(`🧩 Matched block #${best.i + 1}/${blocks.length} score=${best.score}`);
|
||
|
||
if (DRY_RUN) {
|
||
console.log("\n--- DRY RUN (no write, no backup) ---\n");
|
||
console.log("=== BEFORE (excerpt) ===");
|
||
console.log(beforeBlock.slice(0, 400) + (beforeBlock.length > 400 ? "…" : ""));
|
||
console.log("\n=== AFTER (excerpt) ===");
|
||
console.log(afterBlock.slice(0, 400) + (afterBlock.length > 400 ? "…" : ""));
|
||
console.log("\n✅ Dry-run terminé.");
|
||
return;
|
||
}
|
||
|
||
// backup uniquement si on écrit
|
||
const bakPath = `${contentFile}.bak.issue-${issueNum}`;
|
||
if (!(await fileExists(bakPath))) {
|
||
await fs.writeFile(bakPath, original, "utf-8");
|
||
}
|
||
|
||
await fs.writeFile(contentFile, updated, "utf-8");
|
||
console.log("✅ Applied.");
|
||
|
||
let aliasChanged = false;
|
||
let newId = null;
|
||
|
||
if (DO_ALIAS) {
|
||
console.log("🔁 Rebuild to compute new anchor ids (npm run build) …");
|
||
run("npm", ["run", "build"], { cwd: CWD });
|
||
|
||
if (!(await fileExists(distHtmlPath))) {
|
||
throw new Error(`dist introuvable après build: ${distHtmlPath}`);
|
||
}
|
||
|
||
newId = await computeNewIdFromDistByContent(distHtmlPath, afterBlock);
|
||
|
||
const res = await upsertAlias({ chemin, oldId: ancre, newId });
|
||
aliasChanged = res.changed;
|
||
|
||
if (aliasChanged) {
|
||
console.log(`✅ Alias ajouté: ${chemin} ${ancre} -> ${newId}`);
|
||
// met à jour dist immédiatement (sans rebuild complet)
|
||
run("node", ["scripts/inject-anchor-aliases.mjs"], { cwd: CWD });
|
||
} else {
|
||
console.log(`ℹ️ Alias déjà présent ou inutile (${ancre} -> ${newId}).`);
|
||
}
|
||
|
||
// garde-fous rapides
|
||
run("npm", ["run", "test:anchors"], { cwd: CWD });
|
||
run("node", ["scripts/check-inline-js.mjs"], { cwd: CWD });
|
||
}
|
||
|
||
if (DO_COMMIT) {
|
||
const files = [path.relative(CWD, contentFile)];
|
||
if (DO_ALIAS && aliasChanged) files.push(path.relative(CWD, ALIASES_FILE));
|
||
|
||
run("git", ["add", ...files], { cwd: CWD });
|
||
|
||
if (!gitHasStagedChanges()) {
|
||
console.log("ℹ️ Nothing to commit (aucun changement staged).");
|
||
return;
|
||
}
|
||
|
||
const msg = `edit: apply ticket #${issueNum} (${chemin}#${ancre})`;
|
||
run("git", ["commit", "-m", msg], { cwd: CWD });
|
||
console.log(`✅ Committed: ${msg}`);
|
||
return;
|
||
}
|
||
|
||
// mode manuel (historique)
|
||
console.log("Next (manuel) :");
|
||
console.log(` git diff -- ${path.relative(CWD, contentFile)}`);
|
||
console.log(` git add ${path.relative(CWD, contentFile)}${DO_ALIAS ? " src/anchors/anchor-aliases.json" : ""}`);
|
||
console.log(` git commit -m "edit: apply ticket #${issueNum} (${chemin}#${ancre})"`);
|
||
}
|
||
|
||
main().catch((e) => {
|
||
console.error("💥", e?.message || e);
|
||
process.exit(1);
|
||
});
|