10 Commits

Author SHA1 Message Date
5aec056e0d p0: anchors test includes alias spans
Some checks failed
CI / build-and-anchors (push) Failing after 4m34s
CI / build-and-anchors (pull_request) Failing after 33s
2026-01-20 16:11:03 +01:00
fd9612d333 p0: build-time anchor aliases (web-native)
Some checks failed
CI / build-and-anchors (push) Failing after 34s
2026-01-20 16:00:54 +01:00
927d8b6f85 anchors: update baseline
Some checks failed
CI / build-and-anchors (push) Failing after 32s
2026-01-20 15:01:18 +01:00
5149bdec89 docs: add quickstart + reference manual + ticket contract
Some checks failed
CI / build-and-anchors (push) Failing after 32s
2026-01-20 14:57:42 +01:00
5f34a1d393 ui: propose modal opens ticket in new tab only 2026-01-20 14:57:26 +01:00
84c295d4ce ci: build + anchors + inline-js guard
Some checks failed
CI / build-and-anchors (push) Failing after 5m4s
2026-01-20 14:19:11 +01:00
04d6db10af Merge pull request 'propose: exact paragraph + apply-ticket guardrails' (#33) from feat/proposer-exact-paragraph into master
Reviewed-on: #33
2026-01-20 12:59:18 +01:00
3b8376d6a9 propose: exact paragraph + apply-ticket guardrails 2026-01-20 12:42:30 +01:00
ec42c4b2f4 edit: apply ticket #14 (/archicratie/prologue/#p-8-e7075fe3) 2026-01-20 12:39:08 +01:00
c5d767fad1 Merge pull request 'feat/proposer-antifragile' (#31) from feat/proposer-antifragile into master
Reviewed-on: #31
2026-01-19 21:30:25 +01:00
17 changed files with 963 additions and 198 deletions

35
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,35 @@
name: CI
on:
push:
branches: ["**"]
pull_request:
branches: ["master"]
jobs:
build-and-anchors:
runs-on: ubuntu-latest
container:
image: node:20-bookworm-slim
steps:
- name: Install git (needed by checkout)
run: |
apt-get update
apt-get install -y --no-install-recommends git ca-certificates
git --version
- name: Checkout
uses: actions/checkout@v4
- name: Install deps
run: npm ci
- name: Inline scripts syntax check
run: node scripts/check-inline-js.mjs
- name: Build
run: npm run build
- name: Anchors contract
run: npm run test:anchors

11
.gitignore vendored
View File

@@ -11,18 +11,13 @@ dist/
# Dossiers de travail local (à garder hors repo)
sources/
scripts/
# Backups / fichiers cassés (à garder hors repo)
# Astro generated
.astro/
# --- allow the apply-ticket tool script to be versioned ---
!scripts/
!scripts/apply-ticket.mjs
# Local backups / crash copies
# Backups / crash copies
src/**/*.bak
src/**/*.bak.*
src/**/*.BROKEN.*
src/**/*.step*-fix.bak
src/**/*.bak.issue-*

View File

@@ -41,3 +41,27 @@ All commands are run from the root of the project, from a terminal:
## 👀 Want to learn more?
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).
# Archicratie — Web Edition (Atelier éditorial)
Ce repo contient le site Astro “Archicratie Web Edition” et sa machine éditoriale :
- Proposer (tickets Gitea pré-remplis depuis les paragraphes)
- Apply-ticket (application sûre des corrections)
- Contrat dancres (citabilité) + churn test
- CI Gitea Actions + runner DS220+
## Démarrage rapide
- `npm install`
- `npm run dev`
## Documentation (référence)
- **Quickstart (10 min)** : `docs/QUICKSTART.md`
- **Manuel de référence** : `docs/MANUEL_REFERENCE.md`
- **Contrat de tickets** : `docs/CONTRAT_TICKETS.md`
## Commandes clés
- Tests complets : `npm test`
- Test ancres : `npm run test:anchors`
- Appliquer un ticket :
- `node scripts/apply-ticket.mjs <N> --dry-run`
- `node scripts/apply-ticket.mjs <N>`

29
docs/CONTRAT_TICKETS.md Normal file
View File

@@ -0,0 +1,29 @@
# Contrat de ticket — Proposer / Apply-ticket / Auto-label
Ce document fixe le format minimal et les invariants des tickets dédition.
Objectif : parsing fiable par scripts + workflows.
## Invariants (non négociables)
Doivent toujours exister dans le body du ticket :
- `Chemin: /.../`
- `URL locale: ...#...` (utile pour audit humain)
- `Ancre: #p-...`
- `Type: type/...`
- `State: state/...`
- `Proposition (remplacer par):`
## Texte actuel : best effort
Priorité :
1) `Texte actuel (copie exacte du paragraphe):`
2) sinon `Texte actuel (extrait):` + note de troncature
> Même si le texte actuel est un extrait, lancre + chemin rendent le ticket opposable.
## Catégorie (optionnelle)
- `Category: cat/...` (ou vide)
## Pourquoi ce contrat ?
- `apply-ticket.mjs` dépend de repères textuels stables
- `auto-label-issues` dépend de `Type/State/Category`
- on veut éviter des tickets “illisibles machine” qui cassent lindustrialisation

206
docs/MANUEL_REFERENCE.md Normal file
View File

@@ -0,0 +1,206 @@
# Manuel de référence — Archicratie Web Edition (Site + Gitea + Runner)
Ce manuel explique comment utiliser et maintenir loutil dédition :
- Site Astro “Archicratie Web Edition”
- Proposer (tickets Gitea pré-remplis)
- Apply-ticket (application semi-automatique dans le contenu)
- Contrat dancres (citabilité) + tests
- CI Gitea Actions + ds220-runner (DS220+)
> Objectif : une boucle éditoriale reproductible, opposable, sûre, et testée.
---
## 0) Glossaire minimal (anti-jargon)
- **PR** (Pull Request) : demande de fusion dune branche vers `master`.
- **PAT** (Personal Access Token) : jeton daccès Gitea utilisé comme “mot de passe” pour API/Git.
- **Anchor / Ancre** : identifiant stable dun paragraphe (ex: `p-8-0e65838d`) utilisable dans une URL `#...`.
- **Churn** : variation des ancres dune page entre deux builds (mesure de stabilité).
- **dist/** : sortie buildée (artefact), jamais la “source de vérité”.
- **CI** : automatisation (build + tests) à chaque push/PR.
---
## 1) Pré-requis (poste local)
### Outils
- Node.js + npm
- Git
- (Optionnel) Python3 pour scripts de debug ponctuels
### Accès
- Compte Gitea avec droits sur le repo
- Un **PAT** Gitea
- Accès au site local (dev) et/ou build (dist)
---
## 2) Vue densemble : la boucle éditoriale (rituel)
### Boucle standard (la “cadence courte”)
1) Sur le site, cliquer **Proposer** sur un paragraphe.
2) Choisir Type (Correction / Fact-check), puis Category.
3) Gitea ouvre un ticket pré-rempli (Chemin/URL/Ancre + texte actuel).
4) Rédiger la **Proposition (remplacer par)** + Justification.
5) En local :
- `node scripts/apply-ticket.mjs <num>` (dabord `--dry-run`)
- `npm run test:anchors`
- `npm run build`
6) Commit + push + PR si nécessaire.
---
## 3) Le site “Proposer / Citer / ¶” (para-tools)
### Où ça vit ?
- `src/layouts/EditionLayout.astro` injecte un script qui :
- ajoute “¶” (lien dancre), “Citer”, “Proposer”
- construit lURL de création dissue Gitea
- embarque si possible le **paragraphe exact** (sinon un extrait)
- `src/components/ProposeModal.astro` gère la modal 2 étapes :
- Type (correction/fact-check)
- Category (cat/style, cat/lexique, etc.)
- ouvre ensuite lissue en **nouvel onglet** sans quitter le site
### Invariants (non négociables)
- **Ancre + URL + Chemin** doivent toujours être présents.
- Le mode “texte exact” est *best effort* :
- si trop long → fallback extrait + note
- priorité absolue : rester robuste et cliquable.
### Limites / règles de taille
- **FULL_TEXT_SOFT_LIMIT** : taille max pour tenter dembarquer “copie exacte”
- **URL_HARD_LIMIT** : si lURL devient trop longue → repasse en extrait
> But : éviter des issues amputées + éviter des URLs trop longues / fragiles.
---
## 4) Format des tickets (machine-readable)
Le parsing des tickets est fait par `scripts/apply-ticket.mjs` + workflow auto-label.
Donc le contenu doit rester “parsable”.
Sections attendues (au minimum) :
- `Chemin: /.../`
- `Ancre: #p-...`
- `Texte actuel (...)` (idéalement “copie exacte”, sinon “extrait”)
- `Proposition (remplacer par): ...`
- `Justification: ...` (peut être vide, mais le champ doit exister si possible)
> Voir aussi : docs/CONTRAT_TICKETS.md
---
## 5) Appliquer un ticket en local : `apply-ticket`
### Commandes
- Dry run (recommandé) :
- `node scripts/apply-ticket.mjs <N> --dry-run`
- Application réelle :
- `node scripts/apply-ticket.mjs <N>`
### Comportements de sûreté (guardrails)
- `--dry-run` :
- **aucun fichier écrit**
- **aucun backup créé**
- affiche BEFORE/AFTER (extrait)
- Mode écriture :
- crée un backup `.bak.issue-<N>` uniquement si écriture
- match “best effort” mais refuse si score insuffisant
### Après application
- `git diff -- <fichier>`
- `git add <fichier>`
- `git commit -m "edit: apply ticket #N (...)"`
---
## 6) Contrat de citabilité : ancres + churn test
### Pourquoi ?
Les ancres sont le “socle de citabilité”. On veut prévenir les liens morts.
### Scripts
- `npm run test:anchors`
- compare les ancres de `dist/` avec une baseline
- calcule le churn et signale les pages modifiées
- `npm run test:anchors:update`
- met à jour la baseline (à faire seulement quand cest volontaire)
> Important : `dist/` est un artefact ; la baseline sert à mesurer, pas à “éditer dist”.
---
## 7) Garde-fou JS inline : `check-inline-js`
Pourquoi : éviter quun JS inline cassé supprime les boutons et fasse disparaître “Proposer/Citer”.
- `node scripts/check-inline-js.mjs`
- intégré dans `npm test`
---
## 8) CI (Gitea Actions)
### Objectif
Sur chaque push/PR : vérifier automatiquement que :
- build OK
- ancres OK
- JS inline OK
### Workflow
- `.gitea/workflows/ci.yml`
### Commande “contrat”
- `npm test`
- exécute : build + anchors + inline-js
---
## 9) ds220-runner (DS220+ / Gitea act-runner)
### Comportement normal
Le runner lance des jobs dans des conteneurs éphémères qui démarrent/stop.
DSM peut notifier “conteneur arrêté de manière inattendue” : cest souvent un faux positif “bruyant”.
### À surveiller vraiment
- Jobs en échec côté Gitea Actions
- logs mentionnant :
- auth API (401)
- labels manquants
- npm ci/build en échec
---
## 10) Dépannage (symptômes → causes fréquentes)
### “Proposer” ouvre deux onglets / remplace longlet courant
Cause : double handler click / manque de `stopPropagation` / mauvais fallback.
Fix : le flux doit ouvrir **nouvel onglet uniquement**, et sinon prompt.
### “Proposer/Citer/¶” disparaissent
Cause : JS inline cassé → exception ou erreur syntaxe.
Fix : `node scripts/check-inline-js.mjs` + vérifier `dist/.../index.html` console.
### apply-ticket : “Proposition introuvable”
Cause : ticket sans section “Proposition (remplacer par):”.
Fix : respecter le contrat de ticket.
### apply-ticket : “Match trop faible”
Cause : texte actuel absent / trop tronqué / mismatch.
Fix :
- privilégier “Texte actuel (copie exacte du paragraphe)”
- sinon le script récupère via `dist` (si possible).
---
## 11) Règles de contribution (discipline de repo)
- Toute modif éditoriale passe par ticket → apply-ticket → tests.
- Toute modif structurelle (layouts, rendu, MDX) doit être suivie de :
- `npm test`
- éventuellement `npm run test:anchors:update` si changement volontaire dancres.
- Ne jamais éditer `dist/` à la main.
---
## 12) Commandes utiles (raccourcis)
- Dev : `npm run dev`
- Build : `npm run build`
- Tests : `npm test`
- Anchors : `npm run test:anchors`
- Apply ticket : `node scripts/apply-ticket.mjs <N> --dry-run`

80
docs/QUICKSTART.md Normal file
View File

@@ -0,0 +1,80 @@
# Quickstart — 10 minutes (Proposer → Ticket → Apply → Tests)
## 1) Pré-requis
- Node.js + npm
- Accès Gitea (compte) + PAT si usage API (apply-ticket)
## 2) Lancer le site
en bash :
npm install
npm run dev
Dans le navigateur, url : http://localhost:4321
## 3) Proposer une correction
Ouvre une page (ex: Prologue).
Sur un paragraphe : clique Proposer.
Choisis :
Type : Correction / Fact-check
Category (optionnel)
Un nouvel onglet Gitea souvre sur une issue pré-remplie.
Rédige :
Proposition (remplacer par):
Justification:
## 4) Appliquer le ticket en local (<NUMERO> = numéro du ticket non pas id-paragraphe)
En bash :
# dry-run (recommandé)
node scripts/apply-ticket.mjs <NUMERO> --dry-run
# appliquer
node scripts/apply-ticket.mjs <NUMERO>
git diff
git add <fichier>
git commit -m "edit: apply ticket #<NUMERO> (...)"
## 5) Vérifier avant push
npm test
## 6) Règle dor
Ne jamais éditer dist/ à la main.
Toujours garder Chemin + Ancre + Proposition dans le ticket.
Déplacer/ajouter les deux docs que je tai donnés
- `docs/MANUEL_REFERENCE.md`
- `docs/CONTRAT_TICKETS.md`
*(Tu peux reprendre mes versions telles quelles.)*
---
## Commandes terminal P0 (copier-coller)
En bash
mkdir -p docs
# crée/édite les fichiers avec ton éditeur habituel
# README.md
# docs/QUICKSTART.md
# docs/MANUEL_REFERENCE.md
# docs/CONTRAT_TICKETS.md
git status -sb
npm test
git add docs/QUICKSTART.md docs/MANUEL_REFERENCE.md docs/CONTRAT_TICKETS.md
git commit -m "docs: add quickstart + reference manual + ticket contract"
git push

View File

@@ -7,9 +7,10 @@
"build": "astro build",
"preview": "astro preview",
"astro": "astro",
"postbuild": "npx pagefind --site dist",
"postbuild": "node scripts/inject-anchor-aliases.mjs && npx pagefind --site dist",
"import": "node scripts/import-docx.mjs",
"apply:ticket": "node scripts/apply-ticket.mjs",
"test": "npm run build && npm run test:anchors && node scripts/check-inline-js.mjs",
"test:anchors": "node scripts/check-anchors.mjs",
"test:anchors:update": "node scripts/check-anchors.mjs --update"
},

View File

@@ -20,7 +20,7 @@ Env (recommandé):
Notes:
- Si dist/<chemin>/index.html est absent, le script lance "npm run build" sauf si --no-build.
- Sauvegarde automatique: <fichier>.bak.issue-<N>
- Sauvegarde automatique: <fichier>.bak.issue-<N> (uniquement si on écrit)
`);
process.exit(exitCode);
}
@@ -45,12 +45,16 @@ 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 (anti-fragile > parfait)
// stripping très pragmatique
function stripMd(mdx) {
let s = String(mdx ?? "");
s = s.replace(/`[^`]*`/g, " "); // inline code
@@ -62,6 +66,14 @@ function stripMd(mdx) {
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.status !== 0) throw new Error(`Command failed: ${cmd} ${args.join(" ")}`);
@@ -79,25 +91,25 @@ function inferOwnerRepoFromGit() {
const r = spawnSync("git", ["remote", "get-url", "origin"], { encoding: "utf-8" });
if (r.status !== 0) return null;
const u = (r.stdout || "").trim();
// supports: https://host/owner/repo.git or ssh
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) {
// tolère espaces/indent
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) {
// ex: "## Chemin ..." ligne suivante contenant /...
const re = new RegExp(`^##\\s*${escapeRegExp(headingKey)}[^\\n]*\\n([\\s\\S]*?)(?=\\n##\\s|\\n\\s*$)`, "mi");
const m = body.match(re);
if (!m) return "";
// première ligne non vide et non commentée
const lines = m[1].split(/\r?\n/).map(l => l.trim());
for (const l of lines) {
if (!l) continue;
@@ -108,7 +120,6 @@ function pickHeadingValue(body, headingKey) {
}
function pickSection(body, markers) {
// capture bloc après le 1er marker trouvé, jusqu'à un séparateur connu
const text = body.replace(/\r\n/g, "\n");
const idx = markers
.map(m => ({ m, i: text.toLowerCase().indexOf(m.toLowerCase()) }))
@@ -118,7 +129,6 @@ function pickSection(body, markers) {
const start = idx.i + idx.m.length;
const tail = text.slice(start);
// stop markers (robuste)
const stops = [
"\n## ", "\nJustification", "\n---", "\n## Justification", "\n## Sources",
"\nProblème identifié", "\nSources proposées", "\n## Proposition", "\n## Problème"
@@ -132,7 +142,6 @@ function pickSection(body, markers) {
}
function unquoteBlock(s) {
// enlève ">" de citation markdown
return String(s ?? "")
.split(/\r?\n/)
.map(l => l.replace(/^\s*>\s?/, ""))
@@ -140,64 +149,66 @@ function unquoteBlock(s) {
.trim();
}
function escapeRegExp(s) {
return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
async function readHtmlParagraphText(htmlPath, anchorId) {
const html = await fs.readFile(htmlPath, "utf-8");
// cherche <p id="anchorId" ...> ... </p>
const re = new RegExp(`<p[^>]*\\bid=["']${escapeRegExp(anchorId)}["'][^>]*>([\\s\\S]*?)<\\/p>`, "i");
const m = html.match(re);
if (!m) return "";
let inner = m[1];
// supprime les outils "para-tools" si présents
inner = inner.replace(/<span[^>]*class=["'][^"']*para-tools[^"']*["'][^>]*>[\s\S]*?<\/span>/gi, " ");
// strip tags
inner = inner.replace(/<[^>]+>/g, " ");
inner = inner.replace(/\s+/g, " ").trim();
// enlève artefacts éventuels
inner = inner.replace(/\b(¶|Citer|Proposer|Copié)\b/gi, "").replace(/\s+/g, " ").trim();
return inner;
}
function splitParagraphBlocks(mdxText) {
// bloc = séparé par 2 sauts de ligne (pragmatique)
const raw = mdxText.replace(/\r\n/g, "\n");
const parts = raw.split(/\n{2,}/);
return parts;
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; // tronqué/tronquee etc (sans diacritiques)
return false;
}
function scoreBlock(block, targetText) {
const tgt = tokenize(targetText);
const blk = tokenize(block);
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++;
// Bonus si un long préfixe ressemble (moins strict qu'un includes brut)
const tgtNorm = normalizeText(stripMd(targetText));
const blkNorm = normalizeText(stripMd(block));
const prefix = tgtNorm.slice(0, Math.min(180, tgtNorm.length));
const prefixBonus = prefix && blkNorm.includes(prefix) ? 1000 : 0;
// Ratio bonus (0..100)
const ratio = hit / Math.max(1, tgtSet.size);
const ratioBonus = Math.round(ratio * 100);
return prefixBonus + hit + ratioBonus;
}
function bestBlockMatchIndex(blocks, targetText) {
const tgt = normalizeText(stripMd(targetText));
if (!tgt) return -1;
// on compare par inclusion de snippet + score "overlap"
const snippet = tgt.slice(0, Math.min(160, tgt.length));
let best = { i: -1, score: -1 };
for (let i = 0; i < blocks.length; i++) {
const b = normalizeText(stripMd(blocks[i]));
if (!b) continue;
let score = 0;
if (b.includes(snippet)) score += 1000; // jackpot
// overlap par mots (cheap mais robuste)
const words = new Set(tgt.split(" ").filter(w => w.length >= 4));
let hit = 0;
for (const w of words) if (b.includes(w)) hit++;
score += hit;
if (score > best.score) best = { i, score };
const b = blocks[i];
const sc = scoreBlock(b, targetText);
if (sc > best.score) best = { i, score: sc };
}
// seuil minimal : évite remplacement sauvage
if (best.score < 20) return -1;
return best.i;
return best;
}
async function findContentFileFromChemin(chemin) {
@@ -205,11 +216,10 @@ async function findContentFileFromChemin(chemin) {
const parts = clean.split("/").filter(Boolean);
if (parts.length < 2) return null;
const collection = parts[0];
const slugPath = parts.slice(1).join("/"); // support nested
const slugPath = parts.slice(1).join("/");
const root = path.join(CONTENT_ROOT, collection);
if (!(await fileExists(root))) return null;
// cherche fichier dont le path relatif (sans ext) == slugPath
const exts = [".mdx", ".md"];
async function walk(dir) {
const entries = await fs.readdir(dir, { withFileTypes: true });
@@ -250,7 +260,7 @@ async function fetchIssue({ forgeApiBase, owner, repo, token, issueNum }) {
headers: {
"Authorization": `token ${token}`,
"Accept": "application/json",
"User-Agent": "archicratie-apply-ticket/1.0",
"User-Agent": "archicratie-apply-ticket/1.1",
}
});
if (!res.ok) {
@@ -275,7 +285,6 @@ async function main() {
process.exit(1);
}
// API base: priorise LAN (FORGE_API), sinon FORGE_BASE
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'");
@@ -285,22 +294,17 @@ async function main() {
console.log(`🔎 Fetch ticket #${issueNum} from ${owner}/${repo}`);
const issue = await fetchIssue({ forgeApiBase, owner, repo, token, issueNum });
const title = issue.title || "";
const bodyRaw = issue.body || "";
const body = bodyRaw.replace(/\r\n/g, "\n");
const body = String(issue.body || "").replace(/\r\n/g, "\n");
// Chemin / Ancre: support format "Chemin:" OU "## Chemin"
let chemin = pickLine(body, "Chemin") || pickHeadingValue(body, "Chemin");
let ancre = pickLine(body, "Ancre") || pickHeadingValue(body, "Ancre paragraphe") || pickHeadingValue(body, "Ancre");
ancre = ancre.trim();
ancre = (ancre || "").trim();
if (ancre.startsWith("#")) ancre = ancre.slice(1);
// Texte actuel: support "Texte actuel (copie exacte...)" OU "Texte actuel (extrait)"
const current1 = pickSection(body, ["Texte actuel (copie exacte du paragraphe)", "## Texte actuel (copie exacte du paragraphe)"]);
const current2 = pickSection(body, ["Texte actuel (extrait)", "## Assertion / passage à vérifier", "Assertion / passage à vérifier"]);
const texteActuel = unquoteBlock(current1 || current2);
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);
// Proposition: support 2 modèles
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();
@@ -313,56 +317,62 @@ async function main() {
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)}`);
// dist html path
const distHtmlPath = path.join(DIST_ROOT, chemin.replace(/^\/+|\/+$/g,""), "index.html");
await ensureBuildIfNeeded(distHtmlPath);
// texte cible: priorité au texte actuel du ticket, sinon récup HTML du paragraphe via ancre
// targetText: préférence au texte complet (ticket), sinon dist si extrait probable
let targetText = texteActuel;
if (!targetText) {
let distText = "";
if (await fileExists(distHtmlPath)) {
const htmlText = await readHtmlParagraphText(distHtmlPath, ancre);
if (htmlText) targetText = htmlText;
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).");
}
// lecture + split blocs
const original = await fs.readFile(contentFile, "utf-8");
const blocks = splitParagraphBlocks(original);
const idx = bestBlockMatchIndex(blocks, targetText);
if (idx < 0) {
const best = bestBlockMatchIndex(blocks, targetText);
// seuil de sécurité : on veut au moins un overlap raisonnable.
// Avec le bonus prefix+ratio, un match correct dépasse très vite ~6080.
if (best.i < 0 || best.score < 40) {
console.error("❌ Match trop faible: je refuse de remplacer automatiquement.");
console.error("➡️ Action: mets 'Texte actuel (copie exacte du paragraphe)' dans le ticket (recommandé).");
console.error(`➡️ Score=${best.score}. Recommandation: ticket avec 'Texte actuel (copie exacte du paragraphe)'.`);
// debug: top 5
const ranked = blocks
.map((b, i) => ({ i, score: scoreBlock(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[idx];
const beforeBlock = blocks[best.i];
const afterBlock = proposition.trim();
// garde le style: 1 bloc -> 1 bloc
const nextBlocks = blocks.slice();
nextBlocks[idx] = afterBlock;
nextBlocks[best.i] = afterBlock;
const updated = nextBlocks.join("\n\n");
// backup
const bakPath = `${contentFile}.bak.issue-${issueNum}`;
if (!(await fileExists(bakPath))) {
await fs.writeFile(bakPath, original, "utf-8");
}
// preview stats
console.log(`🧩 Matched block #${idx+1}/${blocks.length} (backup: ${path.relative(CWD, bakPath)})`);
console.log(`🧩 Matched block #${best.i + 1}/${blocks.length} score=${best.score}`);
if (DRY_RUN) {
console.log("\n--- DRY RUN (no write) ---\n");
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) ===");
@@ -371,6 +381,12 @@ async function main() {
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. Next:");
console.log(` git diff -- ${path.relative(CWD, contentFile)}`);

View File

@@ -29,14 +29,29 @@ async function walk(dir) {
return out;
}
// Contrat : .reading p[id^="p-"]
// Contrat :
// - paragraphes citables : .reading p[id^="p-"]
// - alias web-natifs : .reading span.para-alias[id^="p-"]
function extractIds(html) {
if (!html.includes('class="reading"')) return [];
const ids = [];
const re = /<p\b[^>]*\sid="(p-[^"]+)"/g;
let m;
while ((m = re.exec(html))) ids.push(m[1]);
const ids = [];
let m;
// 1) IDs principaux (paragraphes)
const reP = /<p\b[^>]*\sid="(p-[^"]+)"/g;
while ((m = reP.exec(html))) ids.push(m[1]);
// 2) IDs alias (spans injectés)
// cas A : id="..." avant class="...para-alias..."
const reA1 = /<span\b[^>]*\bid="(p-[^"]+)"[^>]*\bclass="[^"]*\bpara-alias\b[^"]*"/g;
while ((m = reA1.exec(html))) ids.push(m[1]);
// cas B : class="...para-alias..." avant id="..."
const reA2 = /<span\b[^>]*\bclass="[^"]*\bpara-alias\b[^"]*"[^>]*\bid="(p-[^"]+)"/g;
while ((m = reA2.exec(html))) ids.push(m[1]);
// Dé-doublonnage (on garde un ordre stable)
const seen = new Set();
const uniq = [];
for (const id of ids) {

View File

@@ -0,0 +1,70 @@
#!/usr/bin/env node
import fs from "node:fs/promises";
import path from "node:path";
import os from "node:os";
import { spawnSync } from "node:child_process";
const ROOT = process.cwd();
const SRC = path.join(ROOT, "src");
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()) yield* walk(full);
else yield full;
}
}
function extractInlineScripts(astroText) {
// capture <script ... is:inline ...> ... </script>
const re = /<script\b[^>]*\bis:inline\b[^>]*>([\s\S]*?)<\/script>/gi;
const out = [];
let m;
while ((m = re.exec(astroText))) out.push(m[1] ?? "");
return out;
}
function checkSyntax(js, label) {
const tmp = path.join(os.tmpdir(), `inline-js-check-${Date.now()}-${Math.random().toString(16).slice(2)}.mjs`);
const payload = `// ${label}\n${js}\n`;
return fs.writeFile(tmp, payload, "utf-8").then(() => {
const r = spawnSync(process.execPath, ["--check", tmp], { encoding: "utf-8" });
fs.unlink(tmp).catch(() => {});
if (r.status !== 0) {
const msg = (r.stderr || r.stdout || "").trim();
throw new Error(`${label}\n${msg}`);
}
});
}
async function main() {
const targets = [];
for await (const f of walk(SRC)) {
if (f.endsWith(".astro")) targets.push(f);
}
let checked = 0;
for (const file of targets) {
const txt = await fs.readFile(file, "utf-8");
const scripts = extractInlineScripts(txt);
if (!scripts.length) continue;
for (let i = 0; i < scripts.length; i++) {
const js = (scripts[i] || "").trim();
if (!js) continue;
const label = `${path.relative(ROOT, file)} :: <script is:inline> #${i + 1}`;
await checkSyntax(js, label);
checked++;
}
}
console.log(`OK inline-js: scripts checked=${checked}`);
}
main().catch((e) => {
console.error("❌ inline-js syntax check failed");
console.error(e?.message || e);
process.exit(1);
});

View File

@@ -0,0 +1,143 @@
#!/usr/bin/env node
import fs from "node:fs/promises";
import path from "node:path";
import process from "node:process";
const CWD = process.cwd();
const DIST_ROOT = path.join(CWD, "dist");
const ALIASES_PATH = path.join(CWD, "src", "anchors", "anchor-aliases.json");
const argv = process.argv.slice(2);
const DRY_RUN = argv.includes("--dry-run");
const STRICT = argv.includes("--strict");
function escRe(s) {
return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function normalizeRoute(route) {
let r = String(route || "").trim();
if (!r.startsWith("/")) r = "/" + r;
if (!r.endsWith("/")) r = r + "/";
r = r.replace(/\/{2,}/g, "/");
return r;
}
async function exists(p) {
try {
await fs.access(p);
return true;
} catch {
return false;
}
}
function hasId(html, id) {
const re = new RegExp(`\\bid=(["'])${escRe(id)}\\1`, "i");
return re.test(html);
}
function injectBeforeId(html, newId, injectHtml) {
// insère juste avant la balise qui porte id="newId"
const re = new RegExp(
`(<[^>]+\\bid=(["'])${escRe(newId)}\\2[^>]*>)`,
"i"
);
const m = html.match(re);
if (!m || m.index == null) return { html, injected: false };
const i = m.index;
const out = html.slice(0, i) + injectHtml + "\n" + html.slice(i);
return { html: out, injected: true };
}
async function main() {
if (!(await exists(ALIASES_PATH))) {
console.log(" Aucun fichier d'aliases (src/anchors/anchor-aliases.json). Skip.");
return;
}
const raw = await fs.readFile(ALIASES_PATH, "utf-8");
/** @type {Record<string, Record<string,string>>} */
const aliases = JSON.parse(raw);
const routes = Object.keys(aliases || {});
if (routes.length === 0) {
console.log(" Aliases vides. Rien à injecter.");
return;
}
let changedFiles = 0;
let injectedCount = 0;
let warnCount = 0;
for (const routeKey of routes) {
const route = normalizeRoute(routeKey);
const map = aliases[routeKey] || {};
const entries = Object.entries(map);
if (entries.length === 0) continue;
const rel = route.replace(/^\/+|\/+$/g, ""); // sans slash
const htmlPath = path.join(DIST_ROOT, rel, "index.html");
if (!(await exists(htmlPath))) {
const msg = `⚠️ dist introuvable pour route=${route} (${htmlPath})`;
if (STRICT) throw new Error(msg);
console.log(msg);
warnCount++;
continue;
}
let html = await fs.readFile(htmlPath, "utf-8");
let fileChanged = false;
for (const [oldId, newId] of entries) {
if (!oldId || !newId) continue;
if (hasId(html, oldId)) {
// alias déjà présent → idempotent
continue;
}
if (!hasId(html, newId)) {
const msg = `⚠️ newId introuvable: ${route} old=${oldId} -> new=${newId}`;
if (STRICT) throw new Error(msg);
console.log(msg);
warnCount++;
continue;
}
const aliasSpan = `<span id="${oldId}" class="para-alias" aria-hidden="true"></span>`;
const r = injectBeforeId(html, newId, aliasSpan);
if (!r.injected) {
const msg = `⚠️ injection impossible (pattern non trouvé) : ${route} new=${newId}`;
if (STRICT) throw new Error(msg);
console.log(msg);
warnCount++;
continue;
}
html = r.html;
fileChanged = true;
injectedCount++;
}
if (fileChanged) {
changedFiles++;
if (!DRY_RUN) await fs.writeFile(htmlPath, html, "utf-8");
}
}
console.log(
`✅ inject-anchor-aliases: files_changed=${changedFiles} aliases_injected=${injectedCount} warnings=${warnCount}` +
(DRY_RUN ? " (dry-run)" : "")
);
if (STRICT && warnCount > 0) process.exit(2);
}
main().catch((e) => {
console.error("💥 inject-anchor-aliases:", e?.message || e);
process.exit(1);
});

View File

@@ -0,0 +1,6 @@
{
"/archicratie/prologue/": {
"p-8-e7075fe3": "p-8-0e65838d"
}
}

View File

@@ -95,6 +95,9 @@
/** @type {"correction"|"fact"|""} */
let kind = "";
// Doit rester cohérent avec EditionLayout
const URL_HARD_LIMIT = 6500;
const esc = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const upsertLine = (text, key, value) => {
@@ -125,29 +128,98 @@
u.searchParams.set("title", `[${prefix}] ${cleaned}`.trim());
};
const tryUpgradeBodyWithFull = (u, full) => {
if (!full) return;
let body = u.searchParams.get("body") || "";
if (!body) return;
// Si déjà en "copie exacte", rien à faire
if (body.includes("Texte actuel (copie exacte du paragraphe)")) return;
// On ne tente que si le body est en mode extrait
if (!body.includes("Texte actuel (extrait):")) return;
const quoted = full.split(/\r?\n/).map(l => `> ${l}`.trimEnd()).join("\n");
// Remplace le bloc "Texte actuel (extrait)" jusqu'à "Proposition (remplacer par):"
const re = /Texte actuel \(extrait\):[\s\S]*?\n\nProposition \(remplacer par\):/m;
const next = body.replace(
re,
`Texte actuel (copie exacte du paragraphe):\n${quoted}\n\nProposition (remplacer par):`
);
if (next === body) return;
u.searchParams.set("body", next);
// garde-fou URL
if (u.toString().length > URL_HARD_LIMIT) {
// revert
u.searchParams.set("body", body);
}
};
// ✅ Ouvre EN NOUVEL ONGLET sans jamais remplacer longlet courant
const openInNewTab = (url) => {
// 1) tente window.open
try {
const w = window.open(url, "_blank", "noopener,noreferrer");
if (w) return true;
} catch {}
// 2) fallback "anchor click" (souvent mieux toléré)
try {
const a = document.createElement("a");
a.href = url;
a.target = "_blank";
a.rel = "noopener noreferrer";
a.style.display = "none";
document.body.appendChild(a);
a.click();
a.remove();
return true;
} catch {}
// 3) dernier recours: on ne quitte PAS la page
window.prompt("Popup bloquée. Copiez ce lien pour ouvrir le ticket :", url);
return false;
};
const openWith = (url) => {
pending = url;
kind = "";
dlg.dataset.step = "1";
if (typeof dlg.showModal === "function") dlg.showModal();
else window.open(url.toString(), "_blank", "noopener,noreferrer");
else openInNewTab(url.toString());
};
// Intercepte UNIQUEMENT les liens marqués data-propose
document.addEventListener("click", (e) => {
document.addEventListener("click", async (e) => {
const a = e.target?.closest?.("a[data-propose]");
if (!a) return;
e.preventDefault();
e.stopPropagation();
// L'URL réelle est dans data-url (préparée côté EditionLayout)
const rawUrl = a.dataset.url || a.getAttribute("href") || "";
if (!rawUrl || rawUrl === "#") return;
const full = (a.dataset.full || "").trim();
try {
openWith(new URL(rawUrl));
const u = new URL(rawUrl);
// Option B.1 : copie presse-papier du texte complet (si dispo)
if (full) {
try { await navigator.clipboard.writeText(full); } catch {}
// Option B.2 : upgrade du body (si l'URL reste raisonnable)
tryUpgradeBodyWithFull(u, full);
}
openWith(u);
} catch {
window.open(rawUrl, "_blank", "noopener,noreferrer");
openInNewTab(rawUrl);
}
});
@@ -157,7 +229,6 @@
if (!btn || !pending) return;
kind = btn.getAttribute("data-kind") || "";
let body = pending.searchParams.get("body") || "";
if (kind === "fact") {
@@ -189,15 +260,14 @@
const cat = btn.getAttribute("data-category") || "";
let body = pending.searchParams.get("body") || "";
body = upsertLine(body, "Category", cat);
pending.searchParams.set("body", body);
const u = pending.toString();
dlg.close();
const w = window.open(u, "_blank", "noopener,noreferrer");
if (!w) window.location.href = u;
// ✅ ouvre en nouvel onglet, sans jamais remplacer longlet courant
openInNewTab(u);
});
})();
</script>

View File

@@ -28,7 +28,7 @@ Ce changement de perspective implique une rupture profonde dans la manière mêm
Ce qui émerge n'est pas de nouveaux principes, ni une nouvelle idéologie, mais une exigence beaucoup plus modeste, mais aussi beaucoup plus difficile à satisfaire : celle de trouver dans les relations elles-mêmes — entre groupes, entre institutions, entre individus, entre temporalités — les ressources nécessaires pour maintenir leurs mondes viables. Autrement dit : c'est *dans* les tensions, *à même* les conflits, *au sein* des alliances, *au cœur* des désaccords et des polémiques, que semble se construire la régulation. Non plus *au-dessus*, par un décret transcendant, mais *au-dedans*, par un agencement toujours révisable. C'est cela que nous voulons dire — sans technicité inutile — quand nous parlons d'un déplacement vers une *instance de régulation située de co-viabilité* : un espace commun où les forces hétérogènes, souvent antagonistes, peuvent coexister, se contredire, se confronter, s'éprouver, sans se détruire mutuellement.
Penser le politique depuis cette approche, c'est renoncer à l'idée même qu'un ordre puisse se fonder définitivement, une fois pour toutes. C'est reconnaître que ce qui fait tenir une société n'est jamais un principe unique, un commandement souverain, une légitimité première, mais *un espace d'épreuve toujours rejoué* où se négocient, se recadrent, s'opposent, s'ajustent des forces hétérogènes dont l'accord est constamment partiel, toujours temporaire, perpétuellement instable.
Penser le politique depuis cette approche, cest renoncer à lidée même quun ordre puisse se fonder définitivement, une fois pour toutes. Cest reconnaître que ce qui fait tenir une société ne se joue jamais sur un principe unique, un commandement souverain, une légitimité première, mais sur un espace dépreuve toujours rejoué où se négocient, se recadrent, sopposent, sajustent des forces hétérogènes dont laccord est constamment partiel, toujours temporaire, perpétuellement instable.
Par conséquent, un ordre durerait moins par ses fondements proclamés que par ses *capacités régulatrices effectives*. Autant dire que ce sont les dispositifs, les formats, les médiations — parfois massifs, parfois imperceptibles — par lesquels un ordre parvient à faire coexister ce qui, en droit, pourrait s'exclure : des intérêts antagonistes, des affects discordants, des récits historiques incompatibles, des régimes de valeur irréconciliables, des temporalités sociales déphasées, des exigences contradictoires en matière de justice, d\'efficacité, de mémoire ou d\'avenir.

View File

@@ -65,59 +65,120 @@ const GITEA_REPO = import.meta.env.PUBLIC_GITEA_REPO ?? "";
</article>
</main>
<ProposeModal />
<!-- IMPORTANT: define:vars injecte les constantes dans le JS sans templating fragile -->
<!-- IMPORTANT: define:vars injecte les constantes dans le JS sans templating fragile -->
<script is:inline define:vars={{ GITEA_BASE, GITEA_OWNER, GITEA_REPO }}>
(() => {
try {
// Nettoyage si un ancien bug a injecté ?body=... dans l'URL
if (window.location.search.includes("body=")) {
history.replaceState(null, "", window.location.pathname + window.location.hash);
}
const title = document.body.dataset.docTitle || document.title;
const version = document.body.dataset.docVersion || "";
// ✅ Hotfix compat citabilité :
// si l'URL pointe vers un ancien hash (#p-8-xxxxxxxx) qui n'existe plus,
// on retombe sur le paragraphe actuel du même index (#p-8-YYYYYYYY).
(function resolveLegacyParagraphHash() {
const h = window.location.hash || "";
const m = h.match(/^#p-(\d+)-[0-9a-f]{8}$/i);
if (!m) return;
const oldId = h.slice(1);
if (document.getElementById(oldId)) return; // l'ancre existe, rien à faire
const idx = m[1];
const prefix = `p-${idx}-`;
const replacement = document.querySelector(`[id^="${prefix}"]`);
if (!replacement) return;
history.replaceState(null, "", `${window.location.pathname}#${replacement.id}`);
replacement.scrollIntoView({ block: "start" });
})();
const docTitle = document.body.dataset.docTitle || document.title;
const docVersion = document.body.dataset.docVersion || "";
const giteaReady = Boolean(GITEA_BASE && GITEA_OWNER && GITEA_REPO);
function buildIssueURL(anchorId, excerpt) {
// Limites pragmatiques :
// - FULL_TEXT_SOFT_LIMIT : taille max du paragraphe qu'on essaie d'embarquer tel quel
// - URL_HARD_LIMIT : taille max de l'URL finale issues/new?... (au-delà, on repasse en extrait)
const FULL_TEXT_SOFT_LIMIT = 1600;
const URL_HARD_LIMIT = 6500;
const quoteBlock = (s) =>
String(s || "")
.split(/\r?\n/)
.map((l) => (`> ${l}`).trimEnd())
.join("\n");
function buildIssueURL(anchorId, fullText, excerpt) {
const base = String(GITEA_BASE).replace(/\/+$/, "");
const issue = new URL(`${base}/${GITEA_OWNER}/${GITEA_REPO}/issues/new`);
// URL locale "propre" : on ignore totalement query-string (?body=...)
const local = new URL(window.location.origin + window.location.pathname);
// évite dembarquer des paramètres parasites (ex: ?body=... issus de tests)
local.searchParams.delete("body");
local.searchParams.delete("title");
// URL locale "propre" : on ignore totalement query-string
const local = new URL(window.location.href);
local.search = "";
local.hash = anchorId;
const path = local.pathname;
const issueTitle = `[Correction] ${anchorId} — ${title}`;
const body = [
const path = local.pathname;
const issueTitle = `[Correction] ${anchorId} — ${docTitle}`;
const hasFull = Boolean(fullText && fullText.length);
const canTryFull = hasFull && fullText.length <= FULL_TEXT_SOFT_LIMIT;
const makeBody = (embedFull) => {
const header = [
`Chemin: ${path}`,
`URL locale: ${local.toString()}`,
`Ancre: #${anchorId}`,
`Version: ${version || "(non renseignée)"}`,
`Version: ${docVersion || "(non renseignée)"}`,
`Type: type/correction`,
`State: state/recevable`,
``,
];
const texteActuel = embedFull
? [
`Texte actuel (copie exacte du paragraphe):`,
quoteBlock(fullText),
]
: [
`Texte actuel (extrait):`,
`> ${excerpt}`,
quoteBlock(excerpt || ""),
``,
`Note: paragraphe long → extrait (pour éviter une URL trop longue).`,
];
const footer = [
``,
`Proposition (remplacer par):`,
``,
`Justification:`,
``,
`---`,
`Note: issue générée depuis le site (pré-remplissage).`
].join("\n");
`Note: issue générée depuis le site (pré-remplissage).`,
];
return header.concat(texteActuel, footer).join("\n");
};
// 1) On tente "texte complet"
issue.searchParams.set("title", issueTitle);
issue.searchParams.set("body", body);
issue.searchParams.set("body", makeBody(Boolean(canTryFull)));
// 2) Si l'URL devient trop longue, on repasse en extrait
if (issue.toString().length > URL_HARD_LIMIT) {
issue.searchParams.set("body", makeBody(false));
}
return issue.toString();
}
const paras = Array.from(document.querySelectorAll(".reading p[id]"));
// Contrat : uniquement les paragraphes citables
const paras = Array.from(document.querySelectorAll('.reading p[id^="p-"]'));
for (const p of paras) {
if (p.querySelector(".para-tools")) continue;
@@ -136,9 +197,11 @@ const GITEA_REPO = import.meta.env.PUBLIC_GITEA_REPO ?? "";
citeBtn.textContent = "Citer";
citeBtn.addEventListener("click", async () => {
const url = new URL(window.location.origin + window.location.pathname);
url.hash = p.id;
const cite = `${title}${version ? ` (v${version})` : ""} — ${url.toString()}`;
const pageUrl = new URL(window.location.href);
pageUrl.search = "";
pageUrl.hash = p.id;
const cite = `${docTitle}${docVersion ? ` (v${docVersion})` : ""} — ${pageUrl.toString()}`;
try {
await navigator.clipboard.writeText(cite);
@@ -162,19 +225,23 @@ const GITEA_REPO = import.meta.env.PUBLIC_GITEA_REPO ?? "";
const raw = (p.textContent || "").trim().replace(/\s+/g, " ");
const excerpt = raw.length > 420 ? (raw.slice(0, 420) + "…") : raw;
const url = buildIssueURL(p.id, excerpt);
const issueUrl = buildIssueURL(p.id, raw, excerpt);
// progressive enhancement : sans JS/modal, href fonctionne.
propose.href = url;
propose.href = issueUrl;
// compat : la modal lit data-url en priorité (garde aussi href).
propose.dataset.url = url;
// la modal lit data-url en priorité (garde aussi href).
propose.dataset.url = issueUrl;
tools.appendChild(propose);
}
}
p.appendChild(tools);
}
} catch (err) {
console.error("[EditionLayout] para-tools init failed:", err);
}
})();
</script>
</body>

View File

@@ -121,3 +121,10 @@ body[data-reading-level="2"] .level-3 { display: none; }
border-radius: 999px;
padding: 2px 8px;
}
.para-alias {
display: block;
height: 0;
/* ajuste si header sticky : */
scroll-margin-top: var(--scroll-margin-top, 96px);
}

View File

@@ -12,7 +12,7 @@
"p-5-85126fa5",
"p-6-3515039d",
"p-7-64a0ca9c",
"p-8-e7075fe3",
"p-8-0e65838d",
"p-9-5ff70fb7",
"p-10-e250e810",
"p-11-594bf307",
@@ -141,7 +141,8 @@
"p-134-358f5875",
"p-135-c19330ce",
"p-136-17f1cf51",
"p-137-d8f1539e"
"p-137-d8f1539e",
"p-8-e7075fe3"
],
"atlas/00-demarrage/index.html": [
"p-0-97681330"