Compare commits
10 Commits
feat/propo
...
feat/ancho
| Author | SHA1 | Date | |
|---|---|---|---|
| 5aec056e0d | |||
| fd9612d333 | |||
| 927d8b6f85 | |||
| 5149bdec89 | |||
| 5f34a1d393 | |||
| 84c295d4ce | |||
| 04d6db10af | |||
| 3b8376d6a9 | |||
| ec42c4b2f4 | |||
| c5d767fad1 |
35
.gitea/workflows/ci.yml
Normal file
35
.gitea/workflows/ci.yml
Normal 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
11
.gitignore
vendored
@@ -11,18 +11,13 @@ dist/
|
|||||||
|
|
||||||
# Dossiers de travail local (à garder hors repo)
|
# Dossiers de travail local (à garder hors repo)
|
||||||
sources/
|
sources/
|
||||||
scripts/
|
|
||||||
|
|
||||||
# Backups / fichiers cassés (à garder hors repo)
|
|
||||||
|
|
||||||
# Astro generated
|
# Astro generated
|
||||||
.astro/
|
.astro/
|
||||||
|
|
||||||
# --- allow the apply-ticket tool script to be versioned ---
|
# Backups / crash copies
|
||||||
!scripts/
|
|
||||||
!scripts/apply-ticket.mjs
|
|
||||||
|
|
||||||
# Local backups / crash copies
|
|
||||||
src/**/*.bak
|
src/**/*.bak
|
||||||
|
src/**/*.bak.*
|
||||||
src/**/*.BROKEN.*
|
src/**/*.BROKEN.*
|
||||||
src/**/*.step*-fix.bak
|
src/**/*.step*-fix.bak
|
||||||
|
src/**/*.bak.issue-*
|
||||||
|
|||||||
24
README.md
24
README.md
@@ -41,3 +41,27 @@ All commands are run from the root of the project, from a terminal:
|
|||||||
## 👀 Want to learn more?
|
## 👀 Want to learn more?
|
||||||
|
|
||||||
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).
|
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 d’ancres (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
29
docs/CONTRAT_TICKETS.md
Normal 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, l’ancre + 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 l’industrialisation
|
||||||
206
docs/MANUEL_REFERENCE.md
Normal file
206
docs/MANUEL_REFERENCE.md
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
# Manuel de référence — Archicratie Web Edition (Site + Gitea + Runner)
|
||||||
|
|
||||||
|
Ce manuel explique comment utiliser et maintenir l’outil d’édition :
|
||||||
|
- Site Astro “Archicratie – Web Edition”
|
||||||
|
- Proposer (tickets Gitea pré-remplis)
|
||||||
|
- Apply-ticket (application semi-automatique dans le contenu)
|
||||||
|
- Contrat d’ancres (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 d’une branche vers `master`.
|
||||||
|
- **PAT** (Personal Access Token) : jeton d’accès Gitea utilisé comme “mot de passe” pour API/Git.
|
||||||
|
- **Anchor / Ancre** : identifiant stable d’un paragraphe (ex: `p-8-0e65838d`) utilisable dans une URL `#...`.
|
||||||
|
- **Churn** : variation des ancres d’une 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 d’ensemble : 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>` (d’abord `--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 d’ancre), “Citer”, “Proposer”
|
||||||
|
- construit l’URL de création d’issue 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 l’issue 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 d’embarquer “copie exacte”
|
||||||
|
- **URL_HARD_LIMIT** : si l’URL 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 c’est volontaire)
|
||||||
|
|
||||||
|
> Important : `dist/` est un artefact ; la baseline sert à mesurer, pas à “éditer dist”.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7) Garde-fou JS inline : `check-inline-js`
|
||||||
|
Pourquoi : éviter qu’un 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” : c’est 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 l’onglet 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 d’ancres.
|
||||||
|
- 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
80
docs/QUICKSTART.md
Normal 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 s’ouvre 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 d’or
|
||||||
|
|
||||||
|
Ne jamais éditer dist/ à la main.
|
||||||
|
|
||||||
|
Toujours garder Chemin + Ancre + Proposition dans le ticket.
|
||||||
|
|
||||||
|
Déplacer/ajouter les deux docs que je t’ai 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
|
||||||
|
|
||||||
@@ -7,9 +7,10 @@
|
|||||||
"build": "astro build",
|
"build": "astro build",
|
||||||
"preview": "astro preview",
|
"preview": "astro preview",
|
||||||
"astro": "astro",
|
"astro": "astro",
|
||||||
"postbuild": "npx pagefind --site dist",
|
"postbuild": "node scripts/inject-anchor-aliases.mjs && npx pagefind --site dist",
|
||||||
"import": "node scripts/import-docx.mjs",
|
"import": "node scripts/import-docx.mjs",
|
||||||
"apply:ticket": "node scripts/apply-ticket.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": "node scripts/check-anchors.mjs",
|
||||||
"test:anchors:update": "node scripts/check-anchors.mjs --update"
|
"test:anchors:update": "node scripts/check-anchors.mjs --update"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ Env (recommandé):
|
|||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- Si dist/<chemin>/index.html est absent, le script lance "npm run build" sauf si --no-build.
|
- 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);
|
process.exit(exitCode);
|
||||||
}
|
}
|
||||||
@@ -45,12 +45,16 @@ function normalizeText(s) {
|
|||||||
return String(s ?? "")
|
return String(s ?? "")
|
||||||
.normalize("NFKD")
|
.normalize("NFKD")
|
||||||
.replace(/\p{Diacritic}/gu, "")
|
.replace(/\p{Diacritic}/gu, "")
|
||||||
|
.replace(/[’‘]/g, "'")
|
||||||
|
.replace(/[“”]/g, '"')
|
||||||
|
.replace(/[–—]/g, "-")
|
||||||
|
.replace(/…/g, "...")
|
||||||
.replace(/\s+/g, " ")
|
.replace(/\s+/g, " ")
|
||||||
.trim()
|
.trim()
|
||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
// stripping très pragmatique (anti-fragile > parfait)
|
// stripping très pragmatique
|
||||||
function stripMd(mdx) {
|
function stripMd(mdx) {
|
||||||
let s = String(mdx ?? "");
|
let s = String(mdx ?? "");
|
||||||
s = s.replace(/`[^`]*`/g, " "); // inline code
|
s = s.replace(/`[^`]*`/g, " "); // inline code
|
||||||
@@ -62,6 +66,14 @@ function stripMd(mdx) {
|
|||||||
return s;
|
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 = {}) {
|
function run(cmd, args, opts = {}) {
|
||||||
const r = spawnSync(cmd, args, { stdio: "inherit", ...opts });
|
const r = spawnSync(cmd, args, { stdio: "inherit", ...opts });
|
||||||
if (r.status !== 0) throw new Error(`Command failed: ${cmd} ${args.join(" ")}`);
|
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" });
|
const r = spawnSync("git", ["remote", "get-url", "origin"], { encoding: "utf-8" });
|
||||||
if (r.status !== 0) return null;
|
if (r.status !== 0) return null;
|
||||||
const u = (r.stdout || "").trim();
|
const u = (r.stdout || "").trim();
|
||||||
// supports: https://host/owner/repo.git or ssh
|
|
||||||
const m = u.match(/[:/](?<owner>[^/]+)\/(?<repo>[^/]+?)(?:\.git)?$/);
|
const m = u.match(/[:/](?<owner>[^/]+)\/(?<repo>[^/]+?)(?:\.git)?$/);
|
||||||
if (!m?.groups) return null;
|
if (!m?.groups) return null;
|
||||||
return { owner: m.groups.owner, repo: m.groups.repo };
|
return { owner: m.groups.owner, repo: m.groups.repo };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeRegExp(s) {
|
||||||
|
return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
}
|
||||||
|
|
||||||
function pickLine(body, key) {
|
function pickLine(body, key) {
|
||||||
// tolère espaces/indent
|
|
||||||
const re = new RegExp(`^\\s*${escapeRegExp(key)}\\s*:\\s*([^\\n\\r]+)`, "mi");
|
const re = new RegExp(`^\\s*${escapeRegExp(key)}\\s*:\\s*([^\\n\\r]+)`, "mi");
|
||||||
const m = body.match(re);
|
const m = body.match(re);
|
||||||
return m ? m[1].trim() : "";
|
return m ? m[1].trim() : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function pickHeadingValue(body, headingKey) {
|
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 re = new RegExp(`^##\\s*${escapeRegExp(headingKey)}[^\\n]*\\n([\\s\\S]*?)(?=\\n##\\s|\\n\\s*$)`, "mi");
|
||||||
const m = body.match(re);
|
const m = body.match(re);
|
||||||
if (!m) return "";
|
if (!m) return "";
|
||||||
// première ligne non vide et non commentée
|
|
||||||
const lines = m[1].split(/\r?\n/).map(l => l.trim());
|
const lines = m[1].split(/\r?\n/).map(l => l.trim());
|
||||||
for (const l of lines) {
|
for (const l of lines) {
|
||||||
if (!l) continue;
|
if (!l) continue;
|
||||||
@@ -108,7 +120,6 @@ function pickHeadingValue(body, headingKey) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function pickSection(body, markers) {
|
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 text = body.replace(/\r\n/g, "\n");
|
||||||
const idx = markers
|
const idx = markers
|
||||||
.map(m => ({ m, i: text.toLowerCase().indexOf(m.toLowerCase()) }))
|
.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 start = idx.i + idx.m.length;
|
||||||
const tail = text.slice(start);
|
const tail = text.slice(start);
|
||||||
|
|
||||||
// stop markers (robuste)
|
|
||||||
const stops = [
|
const stops = [
|
||||||
"\n## ", "\nJustification", "\n---", "\n## Justification", "\n## Sources",
|
"\n## ", "\nJustification", "\n---", "\n## Justification", "\n## Sources",
|
||||||
"\nProblème identifié", "\nSources proposées", "\n## Proposition", "\n## Problème"
|
"\nProblème identifié", "\nSources proposées", "\n## Proposition", "\n## Problème"
|
||||||
@@ -132,7 +142,6 @@ function pickSection(body, markers) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function unquoteBlock(s) {
|
function unquoteBlock(s) {
|
||||||
// enlève ">" de citation markdown
|
|
||||||
return String(s ?? "")
|
return String(s ?? "")
|
||||||
.split(/\r?\n/)
|
.split(/\r?\n/)
|
||||||
.map(l => l.replace(/^\s*>\s?/, ""))
|
.map(l => l.replace(/^\s*>\s?/, ""))
|
||||||
@@ -140,64 +149,66 @@ function unquoteBlock(s) {
|
|||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeRegExp(s) {
|
|
||||||
return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readHtmlParagraphText(htmlPath, anchorId) {
|
async function readHtmlParagraphText(htmlPath, anchorId) {
|
||||||
const html = await fs.readFile(htmlPath, "utf-8");
|
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 re = new RegExp(`<p[^>]*\\bid=["']${escapeRegExp(anchorId)}["'][^>]*>([\\s\\S]*?)<\\/p>`, "i");
|
||||||
const m = html.match(re);
|
const m = html.match(re);
|
||||||
if (!m) return "";
|
if (!m) return "";
|
||||||
let inner = m[1];
|
let inner = m[1];
|
||||||
|
|
||||||
// supprime les outils "para-tools" si présents
|
|
||||||
inner = inner.replace(/<span[^>]*class=["'][^"']*para-tools[^"']*["'][^>]*>[\s\S]*?<\/span>/gi, " ");
|
inner = inner.replace(/<span[^>]*class=["'][^"']*para-tools[^"']*["'][^>]*>[\s\S]*?<\/span>/gi, " ");
|
||||||
|
|
||||||
// strip tags
|
|
||||||
inner = inner.replace(/<[^>]+>/g, " ");
|
inner = inner.replace(/<[^>]+>/g, " ");
|
||||||
inner = inner.replace(/\s+/g, " ").trim();
|
inner = inner.replace(/\s+/g, " ").trim();
|
||||||
|
|
||||||
// enlève artefacts éventuels
|
|
||||||
inner = inner.replace(/\b(¶|Citer|Proposer|Copié)\b/gi, "").replace(/\s+/g, " ").trim();
|
inner = inner.replace(/\b(¶|Citer|Proposer|Copié)\b/gi, "").replace(/\s+/g, " ").trim();
|
||||||
return inner;
|
return inner;
|
||||||
}
|
}
|
||||||
|
|
||||||
function splitParagraphBlocks(mdxText) {
|
function splitParagraphBlocks(mdxText) {
|
||||||
// bloc = séparé par 2 sauts de ligne (pragmatique)
|
|
||||||
const raw = mdxText.replace(/\r\n/g, "\n");
|
const raw = mdxText.replace(/\r\n/g, "\n");
|
||||||
const parts = raw.split(/\n{2,}/);
|
return raw.split(/\n{2,}/);
|
||||||
return parts;
|
}
|
||||||
|
|
||||||
|
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) {
|
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 };
|
let best = { i: -1, score: -1 };
|
||||||
|
|
||||||
for (let i = 0; i < blocks.length; i++) {
|
for (let i = 0; i < blocks.length; i++) {
|
||||||
const b = normalizeText(stripMd(blocks[i]));
|
const b = blocks[i];
|
||||||
if (!b) continue;
|
const sc = scoreBlock(b, targetText);
|
||||||
|
if (sc > best.score) best = { i, score: sc };
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
|
return best;
|
||||||
// seuil minimal : évite remplacement sauvage
|
|
||||||
if (best.score < 20) return -1;
|
|
||||||
return best.i;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function findContentFileFromChemin(chemin) {
|
async function findContentFileFromChemin(chemin) {
|
||||||
@@ -205,11 +216,10 @@ async function findContentFileFromChemin(chemin) {
|
|||||||
const parts = clean.split("/").filter(Boolean);
|
const parts = clean.split("/").filter(Boolean);
|
||||||
if (parts.length < 2) return null;
|
if (parts.length < 2) return null;
|
||||||
const collection = parts[0];
|
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);
|
const root = path.join(CONTENT_ROOT, collection);
|
||||||
if (!(await fileExists(root))) return null;
|
if (!(await fileExists(root))) return null;
|
||||||
|
|
||||||
// cherche fichier dont le path relatif (sans ext) == slugPath
|
|
||||||
const exts = [".mdx", ".md"];
|
const exts = [".mdx", ".md"];
|
||||||
async function walk(dir) {
|
async function walk(dir) {
|
||||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||||
@@ -250,7 +260,7 @@ async function fetchIssue({ forgeApiBase, owner, repo, token, issueNum }) {
|
|||||||
headers: {
|
headers: {
|
||||||
"Authorization": `token ${token}`,
|
"Authorization": `token ${token}`,
|
||||||
"Accept": "application/json",
|
"Accept": "application/json",
|
||||||
"User-Agent": "archicratie-apply-ticket/1.0",
|
"User-Agent": "archicratie-apply-ticket/1.1",
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -275,7 +285,6 @@ async function main() {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// API base: priorise LAN (FORGE_API), sinon FORGE_BASE
|
|
||||||
const forgeApiBase = getEnv("FORGE_API") || getEnv("FORGE_BASE");
|
const forgeApiBase = getEnv("FORGE_API") || getEnv("FORGE_BASE");
|
||||||
if (!forgeApiBase) {
|
if (!forgeApiBase) {
|
||||||
console.error("❌ FORGE_API ou FORGE_BASE manquant. Ex: export FORGE_API='http://192.168.1.20:3000'");
|
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} …`);
|
console.log(`🔎 Fetch ticket #${issueNum} from ${owner}/${repo} …`);
|
||||||
const issue = await fetchIssue({ forgeApiBase, owner, repo, token, issueNum });
|
const issue = await fetchIssue({ forgeApiBase, owner, repo, token, issueNum });
|
||||||
|
|
||||||
const title = issue.title || "";
|
const body = String(issue.body || "").replace(/\r\n/g, "\n");
|
||||||
const bodyRaw = issue.body || "";
|
|
||||||
const body = bodyRaw.replace(/\r\n/g, "\n");
|
|
||||||
|
|
||||||
// Chemin / Ancre: support format "Chemin:" OU "## Chemin"
|
|
||||||
let chemin = pickLine(body, "Chemin") || pickHeadingValue(body, "Chemin");
|
let chemin = pickLine(body, "Chemin") || pickHeadingValue(body, "Chemin");
|
||||||
let ancre = pickLine(body, "Ancre") || pickHeadingValue(body, "Ancre paragraphe") || pickHeadingValue(body, "Ancre");
|
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);
|
if (ancre.startsWith("#")) ancre = ancre.slice(1);
|
||||||
|
|
||||||
// Texte actuel: support "Texte actuel (copie exacte...)" OU "Texte actuel (extrait)"
|
const currentFull = pickSection(body, ["Texte actuel (copie exacte du paragraphe)", "## Texte actuel (copie exacte du paragraphe)"]);
|
||||||
const current1 = 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 current2 = pickSection(body, ["Texte actuel (extrait)", "## Assertion / passage à vérifier", "Assertion / passage à vérifier"]);
|
const texteActuel = unquoteBlock(currentFull || currentEx);
|
||||||
const texteActuel = unquoteBlock(current1 || current2);
|
|
||||||
|
|
||||||
// Proposition: support 2 modèles
|
|
||||||
const prop1 = pickSection(body, ["Proposition (texte corrigé complet)", "## Proposition (texte corrigé complet)"]);
|
const prop1 = pickSection(body, ["Proposition (texte corrigé complet)", "## Proposition (texte corrigé complet)"]);
|
||||||
const prop2 = pickSection(body, ["Proposition (remplacer par):", "## Proposition (remplacer par)"]);
|
const prop2 = pickSection(body, ["Proposition (remplacer par):", "## Proposition (remplacer par)"]);
|
||||||
const proposition = (prop1 || prop2).trim();
|
const proposition = (prop1 || prop2).trim();
|
||||||
@@ -313,56 +317,62 @@ async function main() {
|
|||||||
|
|
||||||
const contentFile = await findContentFileFromChemin(chemin);
|
const contentFile = await findContentFileFromChemin(chemin);
|
||||||
if (!contentFile) throw new Error(`Fichier contenu introuvable pour Chemin=${chemin}`);
|
if (!contentFile) throw new Error(`Fichier contenu introuvable pour Chemin=${chemin}`);
|
||||||
|
|
||||||
console.log(`📄 Target content file: ${path.relative(CWD, contentFile)}`);
|
console.log(`📄 Target content file: ${path.relative(CWD, contentFile)}`);
|
||||||
|
|
||||||
// dist html path
|
|
||||||
const distHtmlPath = path.join(DIST_ROOT, chemin.replace(/^\/+|\/+$/g,""), "index.html");
|
const distHtmlPath = path.join(DIST_ROOT, chemin.replace(/^\/+|\/+$/g,""), "index.html");
|
||||||
await ensureBuildIfNeeded(distHtmlPath);
|
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;
|
let targetText = texteActuel;
|
||||||
if (!targetText) {
|
|
||||||
|
let distText = "";
|
||||||
if (await fileExists(distHtmlPath)) {
|
if (await fileExists(distHtmlPath)) {
|
||||||
const htmlText = await readHtmlParagraphText(distHtmlPath, ancre);
|
distText = await readHtmlParagraphText(distHtmlPath, ancre);
|
||||||
if (htmlText) targetText = htmlText;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!targetText && distText) targetText = distText;
|
||||||
|
if (targetText && distText && isLikelyExcerpt(targetText) && distText.length > targetText.length) {
|
||||||
|
targetText = distText;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!targetText) {
|
if (!targetText) {
|
||||||
throw new Error("Impossible de reconstruire le texte du paragraphe (ni texte actuel, ni dist html).");
|
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 original = await fs.readFile(contentFile, "utf-8");
|
||||||
const blocks = splitParagraphBlocks(original);
|
const blocks = splitParagraphBlocks(original);
|
||||||
|
|
||||||
const idx = bestBlockMatchIndex(blocks, targetText);
|
const best = bestBlockMatchIndex(blocks, targetText);
|
||||||
if (idx < 0) {
|
|
||||||
|
// seuil de sécurité : on veut au moins un overlap raisonnable.
|
||||||
|
// Avec le bonus prefix+ratio, un match correct dépasse très vite ~60–80.
|
||||||
|
if (best.i < 0 || best.score < 40) {
|
||||||
console.error("❌ Match trop faible: je refuse de remplacer automatiquement.");
|
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);
|
process.exit(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
const beforeBlock = blocks[idx];
|
const beforeBlock = blocks[best.i];
|
||||||
const afterBlock = proposition.trim();
|
const afterBlock = proposition.trim();
|
||||||
|
|
||||||
// garde le style: 1 bloc -> 1 bloc
|
|
||||||
const nextBlocks = blocks.slice();
|
const nextBlocks = blocks.slice();
|
||||||
nextBlocks[idx] = afterBlock;
|
nextBlocks[best.i] = afterBlock;
|
||||||
|
|
||||||
const updated = nextBlocks.join("\n\n");
|
const updated = nextBlocks.join("\n\n");
|
||||||
|
|
||||||
// backup
|
console.log(`🧩 Matched block #${best.i + 1}/${blocks.length} score=${best.score}`);
|
||||||
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)})`);
|
|
||||||
|
|
||||||
if (DRY_RUN) {
|
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("=== BEFORE (excerpt) ===");
|
||||||
console.log(beforeBlock.slice(0, 400) + (beforeBlock.length > 400 ? "…" : ""));
|
console.log(beforeBlock.slice(0, 400) + (beforeBlock.length > 400 ? "…" : ""));
|
||||||
console.log("\n=== AFTER (excerpt) ===");
|
console.log("\n=== AFTER (excerpt) ===");
|
||||||
@@ -371,6 +381,12 @@ async function main() {
|
|||||||
return;
|
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");
|
await fs.writeFile(contentFile, updated, "utf-8");
|
||||||
console.log("✅ Applied. Next:");
|
console.log("✅ Applied. Next:");
|
||||||
console.log(` git diff -- ${path.relative(CWD, contentFile)}`);
|
console.log(` git diff -- ${path.relative(CWD, contentFile)}`);
|
||||||
|
|||||||
@@ -29,14 +29,29 @@ async function walk(dir) {
|
|||||||
return out;
|
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) {
|
function extractIds(html) {
|
||||||
if (!html.includes('class="reading"')) return [];
|
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 seen = new Set();
|
||||||
const uniq = [];
|
const uniq = [];
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
|
|||||||
70
scripts/check-inline-js.mjs
Normal file
70
scripts/check-inline-js.mjs
Normal 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);
|
||||||
|
});
|
||||||
143
scripts/inject-anchor-aliases.mjs
Normal file
143
scripts/inject-anchor-aliases.mjs
Normal 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);
|
||||||
|
});
|
||||||
6
src/anchors/anchor-aliases.json
Normal file
6
src/anchors/anchor-aliases.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"/archicratie/prologue/": {
|
||||||
|
"p-8-e7075fe3": "p-8-0e65838d"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -95,6 +95,9 @@
|
|||||||
/** @type {"correction"|"fact"|""} */
|
/** @type {"correction"|"fact"|""} */
|
||||||
let kind = "";
|
let kind = "";
|
||||||
|
|
||||||
|
// Doit rester cohérent avec EditionLayout
|
||||||
|
const URL_HARD_LIMIT = 6500;
|
||||||
|
|
||||||
const esc = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
const esc = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
|
||||||
const upsertLine = (text, key, value) => {
|
const upsertLine = (text, key, value) => {
|
||||||
@@ -125,29 +128,98 @@
|
|||||||
u.searchParams.set("title", `[${prefix}] ${cleaned}`.trim());
|
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 l’onglet 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) => {
|
const openWith = (url) => {
|
||||||
pending = url;
|
pending = url;
|
||||||
kind = "";
|
kind = "";
|
||||||
dlg.dataset.step = "1";
|
dlg.dataset.step = "1";
|
||||||
if (typeof dlg.showModal === "function") dlg.showModal();
|
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
|
// 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]");
|
const a = e.target?.closest?.("a[data-propose]");
|
||||||
if (!a) return;
|
if (!a) return;
|
||||||
|
|
||||||
e.preventDefault();
|
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") || "";
|
const rawUrl = a.dataset.url || a.getAttribute("href") || "";
|
||||||
if (!rawUrl || rawUrl === "#") return;
|
if (!rawUrl || rawUrl === "#") return;
|
||||||
|
|
||||||
|
const full = (a.dataset.full || "").trim();
|
||||||
|
|
||||||
try {
|
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 {
|
} catch {
|
||||||
window.open(rawUrl, "_blank", "noopener,noreferrer");
|
openInNewTab(rawUrl);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -157,7 +229,6 @@
|
|||||||
if (!btn || !pending) return;
|
if (!btn || !pending) return;
|
||||||
|
|
||||||
kind = btn.getAttribute("data-kind") || "";
|
kind = btn.getAttribute("data-kind") || "";
|
||||||
|
|
||||||
let body = pending.searchParams.get("body") || "";
|
let body = pending.searchParams.get("body") || "";
|
||||||
|
|
||||||
if (kind === "fact") {
|
if (kind === "fact") {
|
||||||
@@ -189,15 +260,14 @@
|
|||||||
const cat = btn.getAttribute("data-category") || "";
|
const cat = btn.getAttribute("data-category") || "";
|
||||||
let body = pending.searchParams.get("body") || "";
|
let body = pending.searchParams.get("body") || "";
|
||||||
body = upsertLine(body, "Category", cat);
|
body = upsertLine(body, "Category", cat);
|
||||||
|
|
||||||
|
|
||||||
pending.searchParams.set("body", body);
|
pending.searchParams.set("body", body);
|
||||||
|
|
||||||
const u = pending.toString();
|
const u = pending.toString();
|
||||||
dlg.close();
|
dlg.close();
|
||||||
|
|
||||||
const w = window.open(u, "_blank", "noopener,noreferrer");
|
// ✅ ouvre en nouvel onglet, sans jamais remplacer l’onglet courant
|
||||||
if (!w) window.location.href = u;
|
openInNewTab(u);
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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, 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é 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, s’opposent, s’ajustent des forces hétérogènes dont l’accord 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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -65,59 +65,120 @@ const GITEA_REPO = import.meta.env.PUBLIC_GITEA_REPO ?? "";
|
|||||||
</article>
|
</article>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|
||||||
<ProposeModal />
|
<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 }}>
|
<script is:inline define:vars={{ GITEA_BASE, GITEA_OWNER, GITEA_REPO }}>
|
||||||
(() => {
|
(() => {
|
||||||
|
try {
|
||||||
// Nettoyage si un ancien bug a injecté ?body=... dans l'URL
|
// Nettoyage si un ancien bug a injecté ?body=... dans l'URL
|
||||||
if (window.location.search.includes("body=")) {
|
if (window.location.search.includes("body=")) {
|
||||||
history.replaceState(null, "", window.location.pathname + window.location.hash);
|
history.replaceState(null, "", window.location.pathname + window.location.hash);
|
||||||
}
|
}
|
||||||
|
|
||||||
const title = document.body.dataset.docTitle || document.title;
|
// ✅ Hotfix compat citabilité :
|
||||||
const version = document.body.dataset.docVersion || "";
|
// 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);
|
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 base = String(GITEA_BASE).replace(/\/+$/, "");
|
||||||
const issue = new URL(`${base}/${GITEA_OWNER}/${GITEA_REPO}/issues/new`);
|
const issue = new URL(`${base}/${GITEA_OWNER}/${GITEA_REPO}/issues/new`);
|
||||||
|
|
||||||
// URL locale "propre" : on ignore totalement query-string (?body=...)
|
// URL locale "propre" : on ignore totalement query-string
|
||||||
const local = new URL(window.location.origin + window.location.pathname);
|
const local = new URL(window.location.href);
|
||||||
// évite d’embarquer des paramètres parasites (ex: ?body=... issus de tests)
|
local.search = "";
|
||||||
local.searchParams.delete("body");
|
|
||||||
local.searchParams.delete("title");
|
|
||||||
local.hash = anchorId;
|
local.hash = anchorId;
|
||||||
const path = local.pathname;
|
|
||||||
|
|
||||||
const issueTitle = `[Correction] ${anchorId} — ${title}`;
|
const path = local.pathname;
|
||||||
const body = [
|
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}`,
|
`Chemin: ${path}`,
|
||||||
`URL locale: ${local.toString()}`,
|
`URL locale: ${local.toString()}`,
|
||||||
`Ancre: #${anchorId}`,
|
`Ancre: #${anchorId}`,
|
||||||
`Version: ${version || "(non renseignée)"}`,
|
`Version: ${docVersion || "(non renseignée)"}`,
|
||||||
`Type: type/correction`,
|
`Type: type/correction`,
|
||||||
`State: state/recevable`,
|
`State: state/recevable`,
|
||||||
``,
|
``,
|
||||||
|
];
|
||||||
|
|
||||||
|
const texteActuel = embedFull
|
||||||
|
? [
|
||||||
|
`Texte actuel (copie exacte du paragraphe):`,
|
||||||
|
quoteBlock(fullText),
|
||||||
|
]
|
||||||
|
: [
|
||||||
`Texte actuel (extrait):`,
|
`Texte actuel (extrait):`,
|
||||||
`> ${excerpt}`,
|
quoteBlock(excerpt || ""),
|
||||||
|
``,
|
||||||
|
`Note: paragraphe long → extrait (pour éviter une URL trop longue).`,
|
||||||
|
];
|
||||||
|
|
||||||
|
const footer = [
|
||||||
``,
|
``,
|
||||||
`Proposition (remplacer par):`,
|
`Proposition (remplacer par):`,
|
||||||
``,
|
``,
|
||||||
`Justification:`,
|
`Justification:`,
|
||||||
``,
|
``,
|
||||||
`---`,
|
`---`,
|
||||||
`Note: issue générée depuis le site (pré-remplissage).`
|
`Note: issue générée depuis le site (pré-remplissage).`,
|
||||||
].join("\n");
|
];
|
||||||
|
|
||||||
|
return header.concat(texteActuel, footer).join("\n");
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1) On tente "texte complet"
|
||||||
issue.searchParams.set("title", issueTitle);
|
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();
|
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) {
|
for (const p of paras) {
|
||||||
if (p.querySelector(".para-tools")) continue;
|
if (p.querySelector(".para-tools")) continue;
|
||||||
|
|
||||||
@@ -136,9 +197,11 @@ const GITEA_REPO = import.meta.env.PUBLIC_GITEA_REPO ?? "";
|
|||||||
citeBtn.textContent = "Citer";
|
citeBtn.textContent = "Citer";
|
||||||
|
|
||||||
citeBtn.addEventListener("click", async () => {
|
citeBtn.addEventListener("click", async () => {
|
||||||
const url = new URL(window.location.origin + window.location.pathname);
|
const pageUrl = new URL(window.location.href);
|
||||||
url.hash = p.id;
|
pageUrl.search = "";
|
||||||
const cite = `${title}${version ? ` (v${version})` : ""} — ${url.toString()}`;
|
pageUrl.hash = p.id;
|
||||||
|
|
||||||
|
const cite = `${docTitle}${docVersion ? ` (v${docVersion})` : ""} — ${pageUrl.toString()}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(cite);
|
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 raw = (p.textContent || "").trim().replace(/\s+/g, " ");
|
||||||
const excerpt = raw.length > 420 ? (raw.slice(0, 420) + "…") : raw;
|
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.
|
// progressive enhancement : sans JS/modal, href fonctionne.
|
||||||
propose.href = url;
|
propose.href = issueUrl;
|
||||||
|
|
||||||
// compat : la modal lit data-url en priorité (garde aussi href).
|
// la modal lit data-url en priorité (garde aussi href).
|
||||||
propose.dataset.url = url;
|
propose.dataset.url = issueUrl;
|
||||||
|
|
||||||
tools.appendChild(propose);
|
tools.appendChild(propose);
|
||||||
}
|
}
|
||||||
|
|
||||||
p.appendChild(tools);
|
p.appendChild(tools);
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[EditionLayout] para-tools init failed:", err);
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -121,3 +121,10 @@ body[data-reading-level="2"] .level-3 { display: none; }
|
|||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.para-alias {
|
||||||
|
display: block;
|
||||||
|
height: 0;
|
||||||
|
/* ajuste si header sticky : */
|
||||||
|
scroll-margin-top: var(--scroll-margin-top, 96px);
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"p-5-85126fa5",
|
"p-5-85126fa5",
|
||||||
"p-6-3515039d",
|
"p-6-3515039d",
|
||||||
"p-7-64a0ca9c",
|
"p-7-64a0ca9c",
|
||||||
"p-8-e7075fe3",
|
"p-8-0e65838d",
|
||||||
"p-9-5ff70fb7",
|
"p-9-5ff70fb7",
|
||||||
"p-10-e250e810",
|
"p-10-e250e810",
|
||||||
"p-11-594bf307",
|
"p-11-594bf307",
|
||||||
@@ -141,7 +141,8 @@
|
|||||||
"p-134-358f5875",
|
"p-134-358f5875",
|
||||||
"p-135-c19330ce",
|
"p-135-c19330ce",
|
||||||
"p-136-17f1cf51",
|
"p-136-17f1cf51",
|
||||||
"p-137-d8f1539e"
|
"p-137-d8f1539e",
|
||||||
|
"p-8-e7075fe3"
|
||||||
],
|
],
|
||||||
"atlas/00-demarrage/index.html": [
|
"atlas/00-demarrage/index.html": [
|
||||||
"p-0-97681330"
|
"p-0-97681330"
|
||||||
|
|||||||
Reference in New Issue
Block a user