Compare commits

...

6 Commits

Author SHA1 Message Date
dc2826df08 Merge pull request 'build: fix astro mdx/rehype config + dedupe duplicate ids in dist' (#82) from chore/fix-astro-ts-mdx-types into main
All checks were successful
CI / build-and-anchors (push) Successful in 2m6s
SMOKE / smoke (push) Successful in 23s
Reviewed-on: #82
2026-02-15 15:19:57 +01:00
3e4df18b88 build: fix astro mdx/rehype config + dedupe duplicate ids in dist
All checks were successful
CI / build-and-anchors (push) Successful in 2m25s
SMOKE / smoke (push) Successful in 23s
2026-02-15 15:19:05 +01:00
a2d1df427d Merge pull request 'docs: RUNBOOK-PR-AUTO-BITEA' (#81) from docs/RUNBOOK-PR-AUTO-GITEA into main
All checks were successful
CI / build-and-anchors (push) Successful in 1m30s
SMOKE / smoke (push) Successful in 12s
Reviewed-on: #81
2026-02-13 20:11:46 +01:00
ab63511d81 docs: RUNBOOK-PR-AUTO-BITEA
All checks were successful
CI / build-and-anchors (push) Successful in 1m39s
SMOKE / smoke (push) Successful in 16s
2026-02-13 20:10:05 +01:00
e5d831cb61 Merge pull request 'docs: add auth stack + main protected PR workflow' (#80) from docs/runbooks-sync-2026-02-13-FIX into main
All checks were successful
CI / build-and-anchors (push) Successful in 1m37s
SMOKE / smoke (push) Successful in 19s
Reviewed-on: #80
2026-02-13 19:31:36 +01:00
c7704ada8a Merge pull request 'docs: add runbooks (proposer/whoami gate, blue-green deploy, gitea PR workflow)' (#79) from docs/runbooks-sync-2026-02-13-FIX into main
All checks were successful
CI / build-and-anchors (push) Successful in 1m28s
SMOKE / smoke (push) Successful in 15s
Reviewed-on: #79
2026-02-13 19:06:14 +01:00
4 changed files with 338 additions and 22 deletions

View File

@@ -10,41 +10,101 @@ import rehypeAutolinkHeadings from "rehype-autolink-headings";
import rehypeDetailsSections from "./scripts/rehype-details-sections.mjs";
import rehypeParagraphIds from "./src/plugins/rehype-paragraph-ids.js";
const must = (name, fn) => {
if (typeof fn !== "function") {
throw new Error(`[astro.config] rehype plugin "${name}" is not a function (export default vs named?)`);
}
return fn;
};
/**
* Cast minimal pour satisfaire @ts-check sans dépendre de types internes Astro/Unified.
* @param {unknown} x
* @returns {any}
*/
const asAny = (x) => /** @type {any} */ (x);
/**
* @param {any} node
* @param {string} cls
* @returns {boolean}
*/
function hasClass(node, cls) {
const cn = node?.properties?.className;
if (Array.isArray(cn)) return cn.includes(cls);
if (typeof cn === "string") return cn.split(/\s+/).includes(cls);
return false;
}
/**
* Rehype plugin: retire les ids dupliqués en gardant en priorité:
* 1) span.details-anchor
* 2) h1..h6
* 3) sinon: premier rencontré
* @returns {(tree: any) => void}
*/
function rehypeDedupeIds() {
/** @param {any} tree */
return (tree) => {
/** @type {Map<string, Array<{node:any, pref:number, idx:number}>>} */
const occ = new Map();
let idx = 0;
/** @param {any} node */
const walk = (node) => {
if (!node || typeof node !== "object") return;
if (node.type === "element") {
const id = node.properties?.id;
if (typeof id === "string" && id) {
let pref = 2;
if (node.tagName === "span" && hasClass(node, "details-anchor")) pref = 0;
else if (/^h[1-6]$/.test(String(node.tagName || ""))) pref = 1;
const arr = occ.get(id) || [];
arr.push({ node, pref, idx: idx++ });
occ.set(id, arr);
}
const children = node.children;
if (Array.isArray(children)) for (const c of children) walk(c);
} else if (Array.isArray(node.children)) {
for (const c of node.children) walk(c);
}
};
walk(tree);
for (const [id, items] of occ.entries()) {
if (items.length <= 1) continue;
items.sort((a, b) => (a.pref - b.pref) || (a.idx - b.idx));
const keep = items[0];
for (let i = 1; i < items.length; i++) {
const n = items[i].node;
if (n?.properties?.id === id) delete n.properties.id;
}
// safety: on s'assure qu'un seul garde bien l'id
if (keep?.node?.properties) keep.node.properties.id = id;
}
};
}
export default defineConfig({
output: "static",
trailingSlash: "always",
site: process.env.PUBLIC_SITE ?? "http://localhost:4321",
integrations: [
mdx(),
// Important: MDX hérite du pipeline markdown (ids p-… + autres plugins)
mdx({ extendMarkdownConfig: true }),
sitemap({
filter: (page) => !page.includes("/api/") && !page.endsWith("/robots.txt"),
}),
],
// ✅ Plugins appliqués AU MDX
mdx: {
// ✅ MDX hérite déjà de markdown.rehypePlugins
// donc ici on ne met QUE le spécifique MDX
rehypePlugins: [
must("rehype-details-sections", rehypeDetailsSections),
],
},
// ✅ Plugins appliqués au Markdown non-MDX
markdown: {
rehypePlugins: [
must("rehype-slug", rehypeSlug),
[must("rehype-autolink-headings", rehypeAutolinkHeadings), { behavior: "append" }],
must("rehype-paragraph-ids", rehypeParagraphIds),
asAny(rehypeSlug),
[asAny(rehypeAutolinkHeadings), { behavior: "append" }],
asAny(rehypeDetailsSections),
asAny(rehypeParagraphIds),
asAny(rehypeDedupeIds),
],
},
});

View File

@@ -0,0 +1,122 @@
# RUNBOOK — Créer une Demande dajout (PR) “automatique” depuis un push (Gitea)
## Objectif
Pousser une branche depuis le Mac vers Gitea et obtenir le workflow standard :
1) branche dédiée
2) push
3) suggestion “Nouvelle demande dajout” (bandeau vert) OU lien terminal
4) création PR via UI
5) merge (main protégé)
> Important : Gitea ne crée pas une PR automatiquement.
> Il affiche une *suggestion* (bandeau vert) ou imprime un lien “Create a new pull request” lors du push.
---
## Pré-check (obligatoire, 10 secondes)
en bash :
git status -sb
git fetch origin --prune
git branch --show-current
Procédure standard (zéro surprise)
### 1) Se remettre propre sur main
git checkout main
git pull --ff-only
### 2) Créer une branche AVANT de modifier / ajouter des fichiers
git switch -c docs/<YYYY-MM-DD>-<sujet-court>
### 3) Ajouter/modifier tes fichiers dans docs/
Exemple :
docs/auth-stack.md
docs/runbook-....
### 4) Vérifier ce qui va partir
git status -sb
git diff
### 5) Commit
git add docs/
git commit -m "docs: <résumé clair>"
### 6) Vérifier que ta branche a bien des commits “devant” main (SINON pas de PR possible)
git fetch origin
git log --oneline origin/main..HEAD
Si ça naffiche rien : tu nas rien à proposer (branche identique à main).
### 7) Push (méthode la plus robuste)
git push -u origin HEAD
### 8) Créer la PR (2 chemins fiables)
# Chemin A — le plus simple : utiliser le lien imprimé dans le terminal
Après le push, Gitea affiche généralement :
“Create a new pull request for '<ta-branche>': <URL>”
➡️ Ouvre cette URL, clique “Créer la demande dajout”.
# Chemin B — via lUI Gitea (si tu veux le bandeau vert)
Va sur le dépôt
Onglet “Demandes dajout”
Clique “Nouvelle demande dajout”
Source branch = ta branche, Target = main
Créer
## Pourquoi le bandeau vert peut ne PAS apparaître (et ce que ça signifie)
Ta branche est identique à main
# Diagnostic :
git fetch origin
git diff --name-status origin/main..HEAD
Si vide => normal, pas de suggestion.
Tu nes pas sur la bonne branche
# Diagnostic :
git branch --show-current
Tu regardes lUI au mauvais endroit
Solution : utilise le bouton “Nouvelle demande dajout” ou le lien du terminal (chemin A).
Anti-bêtise (optionnel mais recommandé)
Empêcher de commit sur main par erreur (hook local)
# Créer .git/hooks/pre-commit :
#!/bin/sh
b="$(git branch --show-current)"
if [ "$b" = "main" ]; then
echo "❌ Refus: commit interdit sur main. Crée une branche."
exit 1
fi
Puis :
chmod +x .git/hooks/pre-commit
## Rappel : main protégé
Si main est protégé, tu ne merges PAS par git push origin main.
Tu merges via la PR (UI), après CI verte.

View File

@@ -12,7 +12,7 @@
"build": "astro build",
"build:clean": "npm run clean && npm run build",
"postbuild": "node scripts/inject-anchor-aliases.mjs && npx pagefind --site dist",
"postbuild": "node scripts/inject-anchor-aliases.mjs && node scripts/dedupe-ids-dist.mjs && npx pagefind --site dist",
"import": "node scripts/import-docx.mjs",
"apply:ticket": "node scripts/apply-ticket.mjs",

134
scripts/dedupe-ids-dist.mjs Normal file
View File

@@ -0,0 +1,134 @@
import { promises as fs } from "node:fs";
import path from "node:path";
const DIST_DIR = path.resolve("dist");
/** @param {string} dir */
async function walkHtml(dir) {
/** @type {string[]} */
const out = [];
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const e of entries) {
const p = path.join(dir, e.name);
if (e.isDirectory()) out.push(...(await walkHtml(p)));
else if (e.isFile() && p.endsWith(".html")) out.push(p);
}
return out;
}
/** @param {string} attrs */
function getClass(attrs) {
const m = attrs.match(/\bclass="([^"]*)"/i);
return m ? m[1] : "";
}
/** @param {{tag:string,id:string,cls:string}} occ */
function score(occ) {
// plus petit = mieux (on garde)
if (occ.tag === "span" && /\bdetails-anchor\b/.test(occ.cls)) return 0;
if (/^h[1-6]$/.test(occ.tag)) return 1;
if (occ.tag === "p" && occ.id.startsWith("p-")) return 2;
return 10; // tout le reste (toc, nav, etc.)
}
async function main() {
let changedFiles = 0;
let removed = 0;
const files = await walkHtml(DIST_DIR);
for (const file of files) {
let html = await fs.readFile(file, "utf8");
// capture: <tag ... id="X" ...>
const re = /<([A-Za-z][\w:-]*)([^>]*?)\s+id="([^"]+)"([^>]*?)>/g;
/** @type {Array<{id:string,tag:string,pre:string,post:string,start:number,end:number,cls:string,idx:number}>} */
const occs = [];
let m;
let idx = 0;
while ((m = re.exec(html)) !== null) {
const tag = m[1].toLowerCase();
const pre = m[2] || "";
const id = m[3] || "";
const post = m[4] || "";
const fullAttrs = `${pre}${post}`;
const cls = getClass(fullAttrs);
occs.push({
id,
tag,
pre,
post,
start: m.index,
end: m.index + m[0].length,
cls,
idx: idx++,
});
}
if (occs.length === 0) continue;
/** @type {Map<string, Array<typeof occs[number]>>} */
const byId = new Map();
for (const o of occs) {
if (!o.id) continue;
const arr = byId.get(o.id) || [];
arr.push(o);
byId.set(o.id, arr);
}
/** @type {Array<{start:number,end:number,repl:string}>} */
const edits = [];
for (const [id, arr] of byId.entries()) {
if (arr.length <= 1) continue;
// choisir le “meilleur” porteur did : details-anchor > h2/h3... > p-... > reste
const sorted = [...arr].sort((a, b) => {
const sa = score(a);
const sb = score(b);
if (sa !== sb) return sa - sb;
return a.idx - b.idx; // stable: premier
});
const keep = sorted[0];
for (const o of sorted.slice(1)) {
// remplacer louverture de tag en supprimant lattribut id
// <tag{pre} id="X"{post}> ==> <tag{pre}{post}>
const repl = `<${o.tag}${o.pre}${o.post}>`;
edits.push({ start: o.start, end: o.end, repl });
removed++;
}
// sécurité: on “force” l'id sur le keep (au cas où il aurait été modifié plus haut)
// (on ne touche pas au keep ici, juste on ne le retire pas)
void keep;
void id;
}
if (edits.length === 0) continue;
// appliquer de la fin vers le début
edits.sort((a, b) => b.start - a.start);
for (const e of edits) {
html = html.slice(0, e.start) + e.repl + html.slice(e.end);
}
await fs.writeFile(file, html, "utf8");
changedFiles++;
}
if (changedFiles > 0) {
console.log(`✅ dedupe-ids-dist: files_changed=${changedFiles} ids_removed=${removed}`);
} else {
console.log(" dedupe-ids-dist: no duplicates found");
}
}
main().catch((err) => {
console.error("❌ dedupe-ids-dist failed:", err);
process.exit(1);
});