Compare commits
21 Commits
feat/propo
...
feat/m2-ap
| Author | SHA1 | Date | |
|---|---|---|---|
| 30d5a20572 | |||
| 0abf98aa1f | |||
| d87d8c0a8f | |||
| 5c00593e67 | |||
| b2b3d5621b | |||
| 4c7b6a772c | |||
| 11e45eb9d0 | |||
| 266b364195 | |||
| cb4cd01409 | |||
| a09dbca800 | |||
| 874c630a2a | |||
| 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)
|
||||
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-*
|
||||
|
||||
58
README.md
58
README.md
@@ -1,43 +1,25 @@
|
||||
# Astro Starter Kit: Minimal
|
||||
# Archicratie — Web Edition (Atelier éditorial)
|
||||
|
||||
```sh
|
||||
npm create astro@latest -- --template minimal
|
||||
```
|
||||
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+
|
||||
|
||||
> 🧑🚀 **Seasoned astronaut?** Delete this file. Have fun!
|
||||
## Démarrage rapide
|
||||
- `npm install`
|
||||
- `npm run dev`
|
||||
|
||||
## 🚀 Project Structure
|
||||
## 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`
|
||||
- **Topo logique métier** : `docs/TOPO_LOGIQUE_METIER.md`
|
||||
|
||||
Inside of your Astro project, you'll see the following folders and files:
|
||||
## 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>`
|
||||
|
||||
```text
|
||||
/
|
||||
├── public/
|
||||
├── src/
|
||||
│ └── pages/
|
||||
│ └── index.astro
|
||||
└── package.json
|
||||
```
|
||||
|
||||
Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name.
|
||||
|
||||
There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components.
|
||||
|
||||
Any static assets, like images, can be placed in the `public/` directory.
|
||||
|
||||
## 🧞 Commands
|
||||
|
||||
All commands are run from the root of the project, from a terminal:
|
||||
|
||||
| Command | Action |
|
||||
| :------------------------ | :----------------------------------------------- |
|
||||
| `npm install` | Installs dependencies |
|
||||
| `npm run dev` | Starts local dev server at `localhost:4321` |
|
||||
| `npm run build` | Build your production site to `./dist/` |
|
||||
| `npm run preview` | Preview your build locally, before deploying |
|
||||
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
|
||||
| `npm run astro -- --help` | Get help using the Astro CLI |
|
||||
|
||||
## 👀 Want to learn more?
|
||||
|
||||
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).
|
||||
|
||||
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
|
||||
|
||||
250
docs/TOPO_LOGIQUE_METIER.md
Normal file
250
docs/TOPO_LOGIQUE_METIER.md
Normal file
@@ -0,0 +1,250 @@
|
||||
# Topo – Logique métier de l’outil éditorial (Web Edition ↔ Gitea ↔ apply-ticket ↔ CI)
|
||||
|
||||
Ce document explique **ce que fait réellement l’outil**, pourquoi il existe, et comment le maintenir **sans casser** la promesse centrale : **citabilité durable + édition traçable + intégration sûre**.
|
||||
|
||||
> Public visé : “commun des mortels” (nouveau contributeur, mainteneur occasionnel, moi dans 6 mois).
|
||||
|
||||
---
|
||||
|
||||
## 0) La promesse (le “pourquoi”)
|
||||
|
||||
On veut un site (Astro) où chaque paragraphe est :
|
||||
- **citable** (URL + ancre stable),
|
||||
- **corrigeable** (ouvrir un ticket pré-rempli depuis le paragraphe),
|
||||
- **réparable de manière sûre** (appliquer la proposition dans les sources sans magie),
|
||||
- **vérifié automatiquement** (tests + build + garde-fous en CI).
|
||||
|
||||
En bref :
|
||||
**Lecture → proposition → ticket → application → tests → build → publication**
|
||||
…sans perdre la traçabilité, ni casser les citations historiques.
|
||||
|
||||
---
|
||||
|
||||
## 1) Glossaire (pas de jargon sans définition)
|
||||
|
||||
- **Ancre** : partie `#...` d’une URL (ex : `#p-8-0e65838d`). Le navigateur saute à l’élément HTML portant cet `id`.
|
||||
- **Paragraphe citable** : un `<p id="p-...">...</p>` dans l’article.
|
||||
- **Ticket / Issue** : demande de correction sur Gitea (ex : #14).
|
||||
- **PR (Pull Request)** : “demande d’ajout / fusion” : une proposition de merge d’une branche vers `master`.
|
||||
- **CI** : tests automatiques exécutés sur le serveur à chaque push/PR.
|
||||
- **Runner** : machine/conteneur qui exécute la CI (chez nous : DS220+ / ds220-runner).
|
||||
- **DEV** : `npm run dev` (serveur Astro à la volée, pas de `dist/`).
|
||||
- **BUILD / PROD-like** : `npm run build` puis servir `dist/` (ce qui ressemble à la prod).
|
||||
|
||||
---
|
||||
|
||||
## 2) Vue d’ensemble : le pipeline (la “machine éditoriale”)
|
||||
|
||||
### 2.1 Le flux humain (ce que fait l’éditeur)
|
||||
1) Lire une page sur “Archicratie – Web Edition”.
|
||||
2) Sur un paragraphe : cliquer **Proposer**.
|
||||
3) Choisir **Type** (Correction / Fact-check) + **Category** (lexique, style, etc.).
|
||||
4) Gitea s’ouvre en **nouvel onglet** avec un ticket pré-rempli.
|
||||
5) L’éditeur écrit la proposition, justifie, et valide le ticket.
|
||||
|
||||
### 2.2 Le flux technique (ce que fait la machine)
|
||||
6) `apply-ticket` récupère le ticket via l’API Gitea.
|
||||
7) Il retrouve le bon fichier source (MDX) + le bon paragraphe (matching sûr).
|
||||
8) Il applique la modification (ou refuse si trop incertain).
|
||||
9) On lance `npm test` → build + test ancres + check JS inline.
|
||||
10) On commit / push.
|
||||
11) La CI revalide automatiquement sur le serveur.
|
||||
|
||||
---
|
||||
|
||||
## 3) Composant A — Le site (Astro) et les outils de paragraphe
|
||||
|
||||
Dans le rendu final, chaque paragraphe important reçoit un `id` de forme :
|
||||
|
||||
- `p-<index>-<hash8>`
|
||||
ex : `p-8-0e65838d`
|
||||
|
||||
Ensuite, un script ajoute des **outils de paragraphe** (“para-tools”) :
|
||||
- **¶** : lien direct sur le paragraphe (ancre)
|
||||
- **Citer** : copie une citation (titre + version + URL sans query-string)
|
||||
- **Proposer** : ouvre la modale (2 étapes), puis ouvre le ticket Gitea
|
||||
|
||||
### Point crucial : progressive enhancement
|
||||
- Si JS marche : UX optimale.
|
||||
- Si JS casse : le site reste lisible, et le lien de proposition reste utilisable au minimum via `href`.
|
||||
|
||||
---
|
||||
|
||||
## 4) Composant B — Contrat des tickets (machine-readable)
|
||||
|
||||
Pour que `apply-ticket` puisse travailler, le ticket doit rester **structuré**.
|
||||
|
||||
Le ticket contient des lignes “clé: valeur” (une par ligne), par ex :
|
||||
- `Chemin: /archicratie/prologue/`
|
||||
- `URL locale: http://localhost:4321/archicratie/prologue/#p-...`
|
||||
- `Ancre: #p-8-...`
|
||||
- `Version: ...`
|
||||
- `Type: type/correction` (ou type/fact-check)
|
||||
- `State: state/recevable` (ou state/a-sourcer)
|
||||
- `Category: cat/lexique` (optionnel)
|
||||
|
||||
**Règle d’or :** ces clés doivent rester simples, stables, sur une ligne chacune.
|
||||
Sinon, scripts + CI + auto-labeling deviennent fragiles.
|
||||
|
||||
---
|
||||
|
||||
## 5) Composant C — apply-ticket (appliquer sans magie)
|
||||
|
||||
`apply-ticket` fait 4 choses :
|
||||
|
||||
1) **Fetch** : récupère le ticket via API (FORGE_TOKEN requis).
|
||||
2) **Parse** : extrait Chemin/Ancre/Proposition/(Texte actuel si présent).
|
||||
3) **Match** : retrouve le paragraphe dans le fichier source (MDX) :
|
||||
- idéal : “Texte actuel (copie exacte…)” → match fort
|
||||
- sinon : match par score → si score trop faible, **refus** (sécurité)
|
||||
4) **Apply** : modifie le fichier source, sans casser le reste.
|
||||
|
||||
### Mode sûr
|
||||
- `--dry-run` : montre BEFORE/AFTER, **n’écrit rien**
|
||||
- mode normal : écrit + propose ensuite `git diff`, `git add`, `git commit`
|
||||
|
||||
---
|
||||
|
||||
## 6) Composant D — Citabilité P0 : ancres, churn test, aliases
|
||||
|
||||
### 6.1 Le problème
|
||||
Si l’ID dépend du contenu, modifier un paragraphe peut changer l’ID.
|
||||
Donc une citation historique `...#ancien` casse.
|
||||
|
||||
### 6.2 La solution robuste (“web native”)
|
||||
On ne “résout” pas l’ancre : **on la fait exister**.
|
||||
|
||||
On maintient un fichier :
|
||||
- `src/anchors/anchor-aliases.json`
|
||||
|
||||
Exemple (par page/chemin) :
|
||||
en json
|
||||
{
|
||||
"/archicratie/prologue/": {
|
||||
"p-8-e7075fe3": "p-8-0e65838d"
|
||||
}
|
||||
}
|
||||
|
||||
Au build, un script injecte dans dist/.../index.html :
|
||||
<span class="para-alias" id="p-8-e7075fe3"></span>
|
||||
<p id="p-8-0e65838d">...</p>
|
||||
|
||||
Résultat :
|
||||
|
||||
le navigateur résout #p-8-e7075fe3 sans JS.
|
||||
|
||||
### 6.3 Pourquoi un fallback JS existe encore ?
|
||||
|
||||
En mode npm run dev, on ne passe pas par dist/, donc pas d’injection build-time.
|
||||
Le fallback JS est un filet :
|
||||
|
||||
utile en DEV,
|
||||
|
||||
utile si un alias manque,
|
||||
|
||||
mais pas la solution principale.
|
||||
|
||||
## 7) Tests & garde-fous (qualité automatique)
|
||||
### 7.1 npm test doit rester “simple et vrai”
|
||||
|
||||
Il exécute :
|
||||
|
||||
npm run build (génère dist)
|
||||
|
||||
npm run test:anchors (stabilité des ancres)
|
||||
|
||||
node scripts/check-inline-js.mjs (évite les scripts inline invalides)
|
||||
|
||||
### 7.2 test:anchors (baseline + churn)
|
||||
|
||||
tests/anchors-baseline.json = snapshot de référence.
|
||||
|
||||
check-anchors compare dist à la baseline.
|
||||
|
||||
Si trop de churn → échec (donc on voit la casse).
|
||||
|
||||
On met à jour la baseline uniquement quand on accepte consciemment la nouvelle réalité :
|
||||
npm run test:anchors:update
|
||||
|
||||
## 8) DEV vs BUILD : comprendre sans se tromper
|
||||
|
||||
npm run dev → serveur Astro “live”, pas de dist/
|
||||
⇒ les alias build-time n’existent pas ⇒ fallback JS peut s’activer.
|
||||
|
||||
npm run build && npx serve dist → rendu final “prod-like”
|
||||
⇒ alias injectés ⇒ citations historiques fonctionnent sans JS.
|
||||
|
||||
Repère immédiat :
|
||||
|
||||
port 4321 = DEV
|
||||
|
||||
port 3000 (serve dist) = PROD-like
|
||||
|
||||
## 9) Rituels opérationnels (les 3 recettes)
|
||||
### Recette 1 — Créer un ticket propre
|
||||
|
||||
Cliquer “Proposer”
|
||||
|
||||
Choisir Type + Category
|
||||
|
||||
Dans Gitea : compléter “Proposition (remplacer par)” + “Justification”
|
||||
|
||||
Ne pas détruire les lignes Chemin / Ancre / Type / State / Category
|
||||
|
||||
### Recette 2 — Appliquer un ticket
|
||||
en bash :
|
||||
node scripts/apply-ticket.mjs <ID> --dry-run
|
||||
node scripts/apply-ticket.mjs <ID>
|
||||
git diff
|
||||
npm test
|
||||
git add ...
|
||||
git commit -m "edit: apply ticket #<ID> (<chemin>#<ancre>)"
|
||||
git push
|
||||
|
||||
### Recette 3 — Quand un ID change (citabilité)
|
||||
|
||||
trouver ancien id + nouveau id
|
||||
|
||||
ajouter alias dans src/anchors/anchor-aliases.json
|
||||
|
||||
npm run build
|
||||
|
||||
vérifier ...#ancien → scroll sur le bon paragraphe
|
||||
|
||||
npm test
|
||||
|
||||
## 10) Règles d’or (invariants non négociables)
|
||||
|
||||
Ne jamais éditer dist/ à la main (artefact).
|
||||
|
||||
Ne jamais sacrifier l’ancre : Chemin + Ancre doivent exister.
|
||||
|
||||
Tickets toujours “parsables” (clés stables ligne-par-ligne).
|
||||
|
||||
apply-ticket doit pouvoir refuser quand c’est ambigu.
|
||||
|
||||
npm test doit rester “le bouton rouge” fiable.
|
||||
|
||||
## 11) Dépannage rapide
|
||||
|
||||
401 invalid token : FORGE_TOKEN absent ou mal exporté.
|
||||
|
||||
Header invalid value : token collé avec des caractères parasites (espaces, retours, texte autour).
|
||||
|
||||
Proposer ouvre 2 onglets / remplace la page : bug d’interception click → vérifier preventDefault + stopPropagation et l’“openInNewTab”.
|
||||
|
||||
Ancien #id ne marche plus :
|
||||
|
||||
en PROD-like : vérifier alias injecté + JSON
|
||||
|
||||
en DEV : normal que fallback JS soit requis
|
||||
|
||||
## 12) Où lire quoi (docs)
|
||||
|
||||
docs/QUICKSTART.md : démarrer vite
|
||||
|
||||
docs/MANUEL_REFERENCE.md : usage complet + procédures
|
||||
|
||||
docs/CONTRAT_TICKETS.md : format strict des issues
|
||||
|
||||
(ce doc) docs/TOPO_LOGIQUE_METIER.md : la logique qui relie tout
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -4,23 +4,44 @@ import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
/**
|
||||
* apply-ticket — applique une proposition de correction depuis un ticket Gitea
|
||||
*
|
||||
* Conçu pour:
|
||||
* - prendre un ticket [Correction]/[Fact-check] (issue) avec Chemin + Ancre + Proposition
|
||||
* - retrouver le bon paragraphe dans le .mdx
|
||||
* - remplacer proprement
|
||||
* - optionnel: écrire un alias d’ancre old->new (build-time) dans src/anchors/anchor-aliases.json
|
||||
* - optionnel: committer automatiquement
|
||||
* - optionnel: fermer le ticket (après commit)
|
||||
*/
|
||||
|
||||
function usage(exitCode = 0) {
|
||||
console.log(`
|
||||
apply-ticket — applique une proposition de correction depuis un ticket Gitea (robuste)
|
||||
|
||||
Usage:
|
||||
node scripts/apply-ticket.mjs <issue_number> [--dry-run] [--no-build]
|
||||
node scripts/apply-ticket.mjs <issue_number> [--dry-run] [--no-build] [--alias] [--commit] [--close]
|
||||
|
||||
Flags:
|
||||
--dry-run : ne modifie rien, affiche BEFORE/AFTER
|
||||
--no-build : n'exécute pas "npm run build" (INCOMPATIBLE avec --alias)
|
||||
--alias : après application, ajoute l'alias d'ancre (old -> new) dans src/anchors/anchor-aliases.json
|
||||
--commit : git add + git commit automatiquement (inclut alias si --alias)
|
||||
--close : ferme automatiquement le ticket après commit (+ commentaire avec SHA)
|
||||
|
||||
Env (recommandé):
|
||||
FORGE_API = base API (LAN) ex: http://192.168.1.20:3000 (évite DNS)
|
||||
FORGE_BASE = base web ex: https://gitea.xxx.tld
|
||||
FORGE_TOKEN = PAT (avec accès au repo + issues)
|
||||
FORGE_API = base API (LAN) ex: http://192.168.1.20:3000
|
||||
FORGE_BASE = base web ex: https://gitea.xxx.tld (fallback si FORGE_API absent)
|
||||
FORGE_TOKEN = PAT (accès repo + issues)
|
||||
GITEA_OWNER = owner (optionnel si auto-détecté depuis git remote)
|
||||
GITEA_REPO = repo (optionnel si auto-détecté depuis git remote)
|
||||
|
||||
Notes:
|
||||
- Si dist/<chemin>/index.html est absent, le script lance "npm run build" sauf si --no-build.
|
||||
- Sauvegarde automatique: <fichier>.bak.issue-<N>
|
||||
- Sauvegarde automatique: <fichier>.bak.issue-<N> (uniquement si on écrit)
|
||||
- Avec --alias : le script rebuild pour identifier le NOUVEL id, puis écrit l'alias old->new.
|
||||
- Refuse automatiquement les Pull Requests (PR) : ce ne sont pas des tickets éditoriaux.
|
||||
`);
|
||||
process.exit(exitCode);
|
||||
}
|
||||
@@ -36,39 +57,146 @@ if (!Number.isFinite(issueNum) || issueNum <= 0) {
|
||||
|
||||
const DRY_RUN = argv.includes("--dry-run");
|
||||
const NO_BUILD = argv.includes("--no-build");
|
||||
const DO_ALIAS = argv.includes("--alias");
|
||||
const DO_COMMIT = argv.includes("--commit");
|
||||
const DO_CLOSE = argv.includes("--close");
|
||||
|
||||
if (DO_ALIAS && NO_BUILD) {
|
||||
console.error("❌ --alias est incompatible avec --no-build (risque d'alias faux).");
|
||||
console.error("➡️ Relance sans --no-build.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (DRY_RUN && (DO_ALIAS || DO_COMMIT || DO_CLOSE)) {
|
||||
console.warn("ℹ️ --dry-run : --alias/--commit/--close sont ignorés (aucune écriture).");
|
||||
}
|
||||
|
||||
if (DO_CLOSE && DRY_RUN) {
|
||||
console.error("❌ --close est incompatible avec --dry-run.");
|
||||
process.exit(1);
|
||||
}
|
||||
if (DO_CLOSE && !DO_COMMIT) {
|
||||
console.error("❌ --close nécessite --commit (on ne ferme jamais un ticket sans commit).");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (typeof fetch !== "function") {
|
||||
console.error("❌ fetch() indisponible dans ce Node. Utilise Node 18+ (ou plus).");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const CWD = process.cwd();
|
||||
const CONTENT_ROOT = path.join(CWD, "src", "content");
|
||||
const DIST_ROOT = path.join(CWD, "dist");
|
||||
const ALIASES_FILE = path.join(CWD, "src", "anchors", "anchor-aliases.json");
|
||||
|
||||
/* -------------------------- utils texte / matching -------------------------- */
|
||||
|
||||
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
|
||||
s = s.replace(/`[^`]*`/g, " "); // inline code
|
||||
s = s.replace(/!\[[^\]]*\]\([^)]+\)/g, " "); // images
|
||||
s = s.replace(/\[[^\]]*\]\([^)]+\)/g, " "); // links
|
||||
s = s.replace(/[*_~]/g, " "); // emphasis-ish
|
||||
s = s.replace(/<[^>]+>/g, " "); // html tags
|
||||
s = s.replace(/\[[^\]]*\]\([^)]+\)/g, " "); // links
|
||||
s = s.replace(/[*_~]/g, " "); // emphasis-ish
|
||||
s = s.replace(/<[^>]+>/g, " "); // html tags
|
||||
s = s.replace(/\s+/g, " ").trim();
|
||||
return s;
|
||||
}
|
||||
|
||||
function tokenize(s) {
|
||||
const n = normalizeText(stripMd(s));
|
||||
return n
|
||||
.replace(/[^a-z0-9'\- ]+/g, " ")
|
||||
.split(" ")
|
||||
.filter((w) => w.length >= 4);
|
||||
}
|
||||
|
||||
function scoreText(candidate, targetText) {
|
||||
const tgt = tokenize(targetText);
|
||||
const blk = tokenize(candidate);
|
||||
if (!tgt.length || !blk.length) return 0;
|
||||
|
||||
const tgtSet = new Set(tgt);
|
||||
const blkSet = new Set(blk);
|
||||
|
||||
let hit = 0;
|
||||
for (const w of tgtSet) if (blkSet.has(w)) hit++;
|
||||
|
||||
// Bonus si un long préfixe ressemble
|
||||
const tgtNorm = normalizeText(stripMd(targetText));
|
||||
const blkNorm = normalizeText(stripMd(candidate));
|
||||
const prefix = tgtNorm.slice(0, Math.min(180, tgtNorm.length));
|
||||
const prefixBonus = prefix && blkNorm.includes(prefix) ? 1000 : 0;
|
||||
|
||||
// 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) {
|
||||
let best = { i: -1, score: -1 };
|
||||
for (let i = 0; i < blocks.length; i++) {
|
||||
const sc = scoreText(blocks[i], targetText);
|
||||
if (sc > best.score) best = { i, score: sc };
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function splitParagraphBlocks(mdxText) {
|
||||
const raw = String(mdxText ?? "").replace(/\r\n/g, "\n");
|
||||
return raw.split(/\n{2,}/);
|
||||
}
|
||||
|
||||
function isLikelyExcerpt(s) {
|
||||
const t = String(s || "").trim();
|
||||
if (!t) return true;
|
||||
if (t.length < 120) return true;
|
||||
if (/[.…]$/.test(t)) return true;
|
||||
if (normalizeText(t).includes("tronqu")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/* ------------------------------ utils système ------------------------------ */
|
||||
|
||||
function run(cmd, args, opts = {}) {
|
||||
const r = spawnSync(cmd, args, { stdio: "inherit", ...opts });
|
||||
if (r.error) throw r.error;
|
||||
if (r.status !== 0) throw new Error(`Command failed: ${cmd} ${args.join(" ")}`);
|
||||
}
|
||||
|
||||
function runQuiet(cmd, args, opts = {}) {
|
||||
const r = spawnSync(cmd, args, { encoding: "utf8", stdio: "pipe", ...opts });
|
||||
if (r.error) throw r.error;
|
||||
if (r.status !== 0) {
|
||||
const out = (r.stdout || "") + (r.stderr || "");
|
||||
throw new Error(`Command failed: ${cmd} ${args.join(" ")}\n${out}`);
|
||||
}
|
||||
return r.stdout || "";
|
||||
}
|
||||
|
||||
async function fileExists(p) {
|
||||
try { await fs.access(p); return true; } catch { return false; }
|
||||
try {
|
||||
await fs.access(p);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getEnv(name, fallback = "") {
|
||||
@@ -79,26 +207,36 @@ 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 gitHasStagedChanges() {
|
||||
const r = spawnSync("git", ["diff", "--cached", "--quiet"]);
|
||||
return r.status === 1;
|
||||
}
|
||||
|
||||
/* ------------------------------ parsing ticket ----------------------------- */
|
||||
|
||||
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);
|
||||
const m = String(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);
|
||||
const re = new RegExp(
|
||||
`^##\\s*${escapeRegExp(headingKey)}[^\\n]*\\n([\\s\\S]*?)(?=\\n##\\s|\\n\\s*$)`,
|
||||
"mi"
|
||||
);
|
||||
const m = String(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());
|
||||
const lines = m[1].split(/\r?\n/).map((l) => l.trim());
|
||||
for (const l of lines) {
|
||||
if (!l) continue;
|
||||
if (l.startsWith("<!--")) continue;
|
||||
@@ -108,20 +246,25 @@ 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 text = String(body || "").replace(/\r\n/g, "\n");
|
||||
const idx = markers
|
||||
.map(m => ({ m, i: text.toLowerCase().indexOf(m.toLowerCase()) }))
|
||||
.filter(x => x.i >= 0)
|
||||
.map((m) => ({ m, i: text.toLowerCase().indexOf(m.toLowerCase()) }))
|
||||
.filter((x) => x.i >= 0)
|
||||
.sort((a, b) => a.i - b.i)[0];
|
||||
if (!idx) return "";
|
||||
const start = idx.i + idx.m.length;
|
||||
const tail = text.slice(start);
|
||||
|
||||
// stop markers (robuste)
|
||||
const stops = [
|
||||
"\n## ", "\nJustification", "\n---", "\n## Justification", "\n## Sources",
|
||||
"\nProblème identifié", "\nSources proposées", "\n## Proposition", "\n## Problème"
|
||||
"\n## ",
|
||||
"\nJustification",
|
||||
"\n---",
|
||||
"\n## Justification",
|
||||
"\n## Sources",
|
||||
"\nProblème identifié",
|
||||
"\nSources proposées",
|
||||
"\n## Proposition",
|
||||
"\n## Problème",
|
||||
];
|
||||
let end = tail.length;
|
||||
for (const s of stops) {
|
||||
@@ -132,85 +275,86 @@ function pickSection(body, markers) {
|
||||
}
|
||||
|
||||
function unquoteBlock(s) {
|
||||
// enlève ">" de citation markdown
|
||||
return String(s ?? "")
|
||||
.split(/\r?\n/)
|
||||
.map(l => l.replace(/^\s*>\s?/, ""))
|
||||
.map((l) => l.replace(/^\s*>\s?/, ""))
|
||||
.join("\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function escapeRegExp(s) {
|
||||
return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
function normalizeChemin(chemin) {
|
||||
let c = String(chemin || "").trim();
|
||||
if (!c) return "";
|
||||
if (!c.startsWith("/")) c = "/" + c;
|
||||
if (!c.endsWith("/")) c = c + "/";
|
||||
return c;
|
||||
}
|
||||
|
||||
function extractAnchorIdAnywhere(text) {
|
||||
const s = String(text || "");
|
||||
const m = s.match(/#?(p-\d+-[0-9a-f]{8})/i);
|
||||
return m ? m[1] : "";
|
||||
}
|
||||
|
||||
function extractCheminFromAnyUrl(text) {
|
||||
const s = String(text || "");
|
||||
// Exemple: http://localhost:4321/archicratie/prologue/#p-3-xxxx
|
||||
// ou: /archicratie/prologue/#p-3-xxxx
|
||||
const m = s.match(/(\/[a-z0-9\-]+\/[a-z0-9\-\/]+\/)#p-\d+-[0-9a-f]{8}/i);
|
||||
return m ? m[1] : "";
|
||||
}
|
||||
|
||||
/* --------------------------- lecture HTML paragraphe ------------------------ */
|
||||
|
||||
function cleanHtmlInner(inner) {
|
||||
let s = String(inner ?? "");
|
||||
s = s.replace(
|
||||
/<span[^>]*class=["'][^"']*para-tools[^"']*["'][^>]*>[\s\S]*?<\/span>/gi,
|
||||
" "
|
||||
);
|
||||
s = s.replace(/<[^>]+>/g, " ");
|
||||
s = s.replace(/\s+/g, " ").trim();
|
||||
s = s.replace(/\b(¶|Citer|Proposer|Copié)\b/gi, "").replace(/\s+/g, " ").trim();
|
||||
return s;
|
||||
}
|
||||
|
||||
async function 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 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;
|
||||
return cleanHtmlInner(m[1]);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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 };
|
||||
async function readAllHtmlParagraphs(htmlPath) {
|
||||
const html = await fs.readFile(htmlPath, "utf-8");
|
||||
const out = [];
|
||||
const re = /<p\b[^>]*\sid=["'](p-\d+-[0-9a-f]{8})["'][^>]*>([\s\S]*?)<\/p>/gi;
|
||||
let m;
|
||||
while ((m = re.exec(html))) {
|
||||
out.push({ id: m[1], text: cleanHtmlInner(m[2]) });
|
||||
}
|
||||
|
||||
// seuil minimal : évite remplacement sauvage
|
||||
if (best.score < 20) return -1;
|
||||
return best.i;
|
||||
return out;
|
||||
}
|
||||
|
||||
/* --------------------------- localisation fichier contenu ------------------- */
|
||||
|
||||
async function findContentFileFromChemin(chemin) {
|
||||
const clean = chemin.replace(/^\/+|\/+$/g, "");
|
||||
const clean = normalizeChemin(chemin).replace(/^\/+|\/+$/g, "");
|
||||
const parts = clean.split("/").filter(Boolean);
|
||||
if (parts.length < 2) return null;
|
||||
|
||||
const collection = parts[0];
|
||||
const slugPath = parts.slice(1).join("/"); // 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 });
|
||||
for (const e of entries) {
|
||||
@@ -230,36 +374,137 @@ async function findContentFileFromChemin(chemin) {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return await walk(root);
|
||||
}
|
||||
|
||||
/* -------------------------------- build helper ----------------------------- */
|
||||
|
||||
async function ensureBuildIfNeeded(distHtmlPath) {
|
||||
if (NO_BUILD) return;
|
||||
if (await fileExists(distHtmlPath)) return;
|
||||
|
||||
console.log("ℹ️ dist manquant pour cette page → build (npm run build) …");
|
||||
run("npm", ["run", "build"], { cwd: CWD });
|
||||
|
||||
if (!(await fileExists(distHtmlPath))) {
|
||||
throw new Error(`dist toujours introuvable après build: ${distHtmlPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
/* ----------------------------- API Gitea helpers --------------------------- */
|
||||
|
||||
async function fetchIssue({ forgeApiBase, owner, repo, token, issueNum }) {
|
||||
const url = `${forgeApiBase.replace(/\/+$/,"")}/api/v1/repos/${owner}/${repo}/issues/${issueNum}`;
|
||||
const url = `${forgeApiBase.replace(/\/+$/, "")}/api/v1/repos/${owner}/${repo}/issues/${issueNum}`;
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
"Authorization": `token ${token}`,
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "archicratie-apply-ticket/1.0",
|
||||
}
|
||||
Authorization: `token ${token}`,
|
||||
Accept: "application/json",
|
||||
"User-Agent": "archicratie-apply-ticket/2.0",
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
const t = await res.text().catch(()=> "");
|
||||
const t = await res.text().catch(() => "");
|
||||
throw new Error(`HTTP ${res.status} fetching issue: ${url}\n${t}`);
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
async function closeIssue({ forgeApiBase, owner, repo, token, issueNum, comment }) {
|
||||
const base = forgeApiBase.replace(/\/+$/, "");
|
||||
const headers = {
|
||||
Authorization: `token ${token}`,
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "archicratie-apply-ticket/2.0",
|
||||
};
|
||||
|
||||
if (comment) {
|
||||
const urlC = `${base}/api/v1/repos/${owner}/${repo}/issues/${issueNum}/comments`;
|
||||
await fetch(urlC, { method: "POST", headers, body: JSON.stringify({ body: comment }) });
|
||||
}
|
||||
|
||||
const url = `${base}/api/v1/repos/${owner}/${repo}/issues/${issueNum}`;
|
||||
const res = await fetch(url, { method: "PATCH", headers, body: JSON.stringify({ state: "closed" }) });
|
||||
|
||||
if (!res.ok) {
|
||||
const t = await res.text().catch(() => "");
|
||||
throw new Error(`HTTP ${res.status} closing issue: ${url}\n${t}`);
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------ Aliases helpers ---------------------------- */
|
||||
|
||||
async function loadAliases() {
|
||||
try {
|
||||
const s = await fs.readFile(ALIASES_FILE, "utf8");
|
||||
const obj = JSON.parse(s);
|
||||
return obj && typeof obj === "object" ? obj : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function sortObjectKeys(obj) {
|
||||
return Object.fromEntries(Object.keys(obj).sort().map((k) => [k, obj[k]]));
|
||||
}
|
||||
|
||||
async function saveAliases(obj) {
|
||||
let out = obj || {};
|
||||
for (const k of Object.keys(out)) {
|
||||
if (out[k] && typeof out[k] === "object") out[k] = sortObjectKeys(out[k]);
|
||||
}
|
||||
out = sortObjectKeys(out);
|
||||
|
||||
await fs.mkdir(path.dirname(ALIASES_FILE), { recursive: true });
|
||||
await fs.writeFile(ALIASES_FILE, JSON.stringify(out, null, 2) + "\n", "utf8");
|
||||
}
|
||||
|
||||
async function upsertAlias({ chemin, oldId, newId }) {
|
||||
const route = normalizeChemin(chemin);
|
||||
if (!oldId || !newId) throw new Error("Alias: oldId/newId requis");
|
||||
if (oldId === newId) return { changed: false, reason: "same" };
|
||||
|
||||
const data = await loadAliases();
|
||||
if (!data[route]) data[route] = {};
|
||||
|
||||
const prev = data[route][oldId];
|
||||
if (prev && prev !== newId) {
|
||||
throw new Error(
|
||||
`Alias conflict: ${route}${oldId} already mapped to ${prev} (new=${newId})`
|
||||
);
|
||||
}
|
||||
if (prev === newId) return { changed: false, reason: "already" };
|
||||
|
||||
data[route][oldId] = newId;
|
||||
await saveAliases(data);
|
||||
return { changed: true, reason: "written" };
|
||||
}
|
||||
|
||||
async function computeNewIdFromDistByContent(distHtmlPath, afterBlock) {
|
||||
const paras = await readAllHtmlParagraphs(distHtmlPath);
|
||||
if (!paras.length) throw new Error(`Aucun <p id="p-..."> trouvé dans ${distHtmlPath}`);
|
||||
|
||||
let best = { id: null, score: -1 };
|
||||
const target = stripMd(afterBlock).slice(0, 1200);
|
||||
|
||||
for (const p of paras) {
|
||||
const sc = scoreText(p.text, target);
|
||||
if (sc > best.score) best = { id: p.id, score: sc };
|
||||
}
|
||||
|
||||
if (!best.id || best.score < 60) {
|
||||
throw new Error(
|
||||
`Impossible d'identifier le nouvel id dans dist (score trop faible: ${best.score}).\n` +
|
||||
`➡️ Vérifie que la proposition correspond bien à UN paragraphe.`
|
||||
);
|
||||
}
|
||||
|
||||
return best.id;
|
||||
}
|
||||
|
||||
/* ----------------------------------- MAIN ---------------------------------- */
|
||||
|
||||
async function main() {
|
||||
const token = getEnv("FORGE_TOKEN");
|
||||
if (!token) {
|
||||
@@ -269,13 +514,12 @@ async function main() {
|
||||
|
||||
const inferred = inferOwnerRepoFromGit() || {};
|
||||
const owner = getEnv("GITEA_OWNER", inferred.owner || "");
|
||||
const repo = getEnv("GITEA_REPO", inferred.repo || "");
|
||||
const repo = getEnv("GITEA_REPO", inferred.repo || "");
|
||||
if (!owner || !repo) {
|
||||
console.error("❌ Impossible de déterminer owner/repo. Fix: export GITEA_OWNER=... GITEA_REPO=...");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 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,24 +529,54 @@ 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");
|
||||
// Guard PR (Pull Request = "Demande d'ajout" = pas un ticket éditorial)
|
||||
if (issue?.pull_request) {
|
||||
console.error(`❌ #${issueNum} est une Pull Request (demande d’ajout), pas un ticket éditorial.`);
|
||||
console.error(`➡️ Ouvre un ticket [Correction]/[Fact-check] depuis le site (Proposer), puis relance apply-ticket sur ce numéro.`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// 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();
|
||||
const body = String(issue.body || "").replace(/\r\n/g, "\n");
|
||||
const title = String(issue.title || "");
|
||||
|
||||
let chemin =
|
||||
pickLine(body, "Chemin") ||
|
||||
pickHeadingValue(body, "Chemin") ||
|
||||
extractCheminFromAnyUrl(body) ||
|
||||
extractCheminFromAnyUrl(title);
|
||||
|
||||
let ancre =
|
||||
pickLine(body, "Ancre") ||
|
||||
pickHeadingValue(body, "Ancre paragraphe") ||
|
||||
pickHeadingValue(body, "Ancre");
|
||||
|
||||
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);
|
||||
// fallback si ticket mal formé
|
||||
if (!ancre) ancre = extractAnchorIdAnywhere(title) || extractAnchorIdAnywhere(body);
|
||||
|
||||
// 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)"]);
|
||||
chemin = normalizeChemin(chemin);
|
||||
|
||||
const currentFull = pickSection(body, [
|
||||
"Texte actuel (copie exacte du paragraphe)",
|
||||
"## Texte actuel (copie exacte du paragraphe)",
|
||||
]);
|
||||
const currentEx = pickSection(body, [
|
||||
"Texte actuel (extrait)",
|
||||
"## Assertion / passage à vérifier",
|
||||
"Assertion / passage à vérifier",
|
||||
]);
|
||||
const texteActuel = unquoteBlock(currentFull || currentEx);
|
||||
|
||||
const prop1 = pickSection(body, [
|
||||
"Proposition (texte corrigé complet)",
|
||||
"## Proposition (texte corrigé complet)",
|
||||
]);
|
||||
const prop2 = pickSection(body, [
|
||||
"Proposition (remplacer par):",
|
||||
"## Proposition (remplacer par)",
|
||||
]);
|
||||
const proposition = (prop1 || prop2).trim();
|
||||
|
||||
if (!chemin) throw new Error("Ticket: Chemin introuvable dans le body.");
|
||||
@@ -313,56 +587,61 @@ 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");
|
||||
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
|
||||
// Texte cible: préférence au texte complet (ticket), sinon dist si extrait probable
|
||||
let targetText = texteActuel;
|
||||
if (!targetText) {
|
||||
if (await fileExists(distHtmlPath)) {
|
||||
const htmlText = await readHtmlParagraphText(distHtmlPath, ancre);
|
||||
if (htmlText) targetText = htmlText;
|
||||
}
|
||||
let distText = "";
|
||||
|
||||
if (await fileExists(distHtmlPath)) {
|
||||
distText = await readHtmlParagraphText(distHtmlPath, ancre);
|
||||
}
|
||||
|
||||
if (!targetText && distText) targetText = distText;
|
||||
if (targetText && distText && isLikelyExcerpt(targetText) && distText.length > targetText.length) {
|
||||
targetText = distText;
|
||||
}
|
||||
|
||||
if (!targetText) {
|
||||
throw new Error("Impossible de reconstruire le texte du paragraphe (ni texte actuel, ni dist html).");
|
||||
}
|
||||
|
||||
// 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é
|
||||
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)'.`);
|
||||
|
||||
const ranked = blocks
|
||||
.map((b, i) => ({ i, score: scoreText(b, targetText), excerpt: stripMd(b).slice(0, 140) }))
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, 5);
|
||||
|
||||
console.error("Top candidates:");
|
||||
for (const r of ranked) {
|
||||
console.error(` #${r.i + 1} score=${r.score} ${r.excerpt}${r.excerpt.length >= 140 ? "…" : ""}`);
|
||||
}
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const beforeBlock = blocks[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,11 +650,81 @@ 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("✅ Applied.");
|
||||
|
||||
let aliasChanged = false;
|
||||
let newId = null;
|
||||
|
||||
if (DO_ALIAS) {
|
||||
console.log("🔁 Rebuild to compute new anchor ids (npm run build) …");
|
||||
run("npm", ["run", "build"], { cwd: CWD });
|
||||
|
||||
if (!(await fileExists(distHtmlPath))) {
|
||||
throw new Error(`dist introuvable après build: ${distHtmlPath}`);
|
||||
}
|
||||
|
||||
newId = await computeNewIdFromDistByContent(distHtmlPath, afterBlock);
|
||||
|
||||
const res = await upsertAlias({ chemin, oldId: ancre, newId });
|
||||
aliasChanged = res.changed;
|
||||
|
||||
if (aliasChanged) {
|
||||
console.log(`✅ Alias ajouté: ${chemin} ${ancre} -> ${newId}`);
|
||||
// MàJ dist sans rebuild complet (inject seulement)
|
||||
run("node", ["scripts/inject-anchor-aliases.mjs"], { cwd: CWD });
|
||||
} else {
|
||||
console.log(`ℹ️ Alias déjà présent ou inutile (${ancre} -> ${newId}).`);
|
||||
}
|
||||
|
||||
// garde-fous rapides
|
||||
run("npm", ["run", "test:anchors"], { cwd: CWD });
|
||||
run("node", ["scripts/check-inline-js.mjs"], { cwd: CWD });
|
||||
}
|
||||
|
||||
if (DO_COMMIT) {
|
||||
const files = [path.relative(CWD, contentFile)];
|
||||
if (DO_ALIAS && aliasChanged) files.push(path.relative(CWD, ALIASES_FILE));
|
||||
|
||||
run("git", ["add", ...files], { cwd: CWD });
|
||||
|
||||
if (!gitHasStagedChanges()) {
|
||||
console.log("ℹ️ Nothing to commit (aucun changement staged).");
|
||||
return;
|
||||
}
|
||||
|
||||
const msg = `edit: apply ticket #${issueNum} (${chemin}#${ancre})`;
|
||||
run("git", ["commit", "-m", msg], { cwd: CWD });
|
||||
|
||||
const sha = runQuiet("git", ["rev-parse", "--short", "HEAD"], { cwd: CWD }).trim();
|
||||
console.log(`✅ Committed: ${msg} (${sha})`);
|
||||
|
||||
if (DO_CLOSE) {
|
||||
const comment = `✅ Appliqué par apply-ticket.\nCommit: ${sha}`;
|
||||
await closeIssue({ forgeApiBase, owner, repo, token, issueNum, comment });
|
||||
console.log(`✅ Ticket #${issueNum} fermé.`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// mode manuel
|
||||
console.log("Next (manuel) :");
|
||||
console.log(` git diff -- ${path.relative(CWD, contentFile)}`);
|
||||
console.log(` git add ${path.relative(CWD, contentFile)}`);
|
||||
console.log(
|
||||
` git add ${path.relative(CWD, contentFile)}${
|
||||
DO_ALIAS ? " src/anchors/anchor-aliases.json" : ""
|
||||
}`
|
||||
);
|
||||
console.log(` git commit -m "edit: apply ticket #${issueNum} (${chemin}#${ancre})"`);
|
||||
if (DO_CLOSE) {
|
||||
console.log(" (puis relance avec --commit --close pour fermer automatiquement)");
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
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);
|
||||
});
|
||||
8
src/anchors/anchor-aliases.json
Normal file
8
src/anchors/anchor-aliases.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"/archicratie/prologue/": {
|
||||
"p-8-e7075fe3": "p-8-0e65838d",
|
||||
"p-3-76df8102": "p-3-539ac0fd",
|
||||
"p-5-85126fa5": "p-5-285d27a7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 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) => {
|
||||
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 l’onglet courant
|
||||
openInNewTab(u);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
|
||||
@@ -17,18 +17,17 @@ C'est cette perte de prise sur le réel que ce livre souhaite prendre au sérieu
|
||||
|
||||
Cette tenue du monde n'équivaut ni à la paix civile, ni à la stabilité des institutions, ni à l'ordre établi. C'est une difficulté conceptuelle que d'envisager *la possibilité pour un ordre de durer sans s'effondrer*, alors même qu'il est traversé en permanence par des forces et des légitimités qui le travaillent, l'éprouvent, le modifient, l'usent, le contestent, le prolongent ou le sapent. Cette possibilité de tenir le monde commun, nous la nommons *co-viabilité*.
|
||||
|
||||
Le terme n'est pas trivial. Il ne s'agit pas simplement d'une viabilité partagée, ni d'une coexistence pacifique, ni même d'une durabilité écologique élargie. Il s'agit d'un état dynamique, instable, fragile, dans lequel un ensemble — une société, d'un système biologique, d'une formation historique, d'un milieu technique ou d'un monde institué — parvient à maintenir une *existence viable*, *malgré et grâce à ses tensions constitutives*.
|
||||
Le terme n’est pas trivial. Il ne s’agit pas simplement d’une viabilité partagée, ni d’une coexistence pacifique, ni même d’une durabilité écologique élargie. Il s’agit d’un état dynamique, instable, fragile, dans lequel un ensemble — une société, un système biologique, une formation historique, un milieu technique ou un monde institué — parvient à maintenir une existence viable, malgré et grâce à ses tensions constitutives.
|
||||
|
||||
La *co-viabilité* ne désigne ni un état d'équilibre, ni une finalité normative. Elle nomme un état dynamique et instable, dans lequel un monde — société, milieu technique, formation historique — tient non pas par homogénéité ou harmonie, mais parce qu'il parvient à réguler ce qui le menace sans se détruire lui-même. Il compose entre des éléments hétérogènes — forces d'inertie et d'innovation, attachements profonds et ruptures nécessaires — sans chercher à les unifier. C'est cette disposition active, faite de compromis fragiles et d'ajustements toujours révisables, que nous tenons pour première, et non dérivée.
|
||||
|
||||
Ce qui revient à dire que la question politique — au sens fort — n'a peut-être jamais été qui commande ? Mais bien plus : *Comment un ordre tient-il malgré ce qui le défait ?* *Quels sont les dispositifs qui permettent à une société de ne pas se désagréger sous l'effet de ses propres contradictions ?* *Comment sont régulées les tensions qui traversent le tissu du monde commun sans le déchirer ?*\
|
||||
Cette bascule de perspective prolonge des intuitions anciennes. Max Weber (*Économie et société*, 1922) rappelait que ce qui fait tenir un ordre, ce n'est pas seulement la force ou la loi, mais les « chances de validité » socialement reconnues. Norbert Elias (*La dynamique de l'Occident*, 1939/1975) montrait, quant à lui, que les sociétés se maintiennent par des équilibres toujours précaires entre interdépendances, rivalités et pacifications. Notre démarche s'inscrit dans ce sillage : travailler cette interrogation sur les *conditions de viabilité d'un monde commun*.
|
||||
Ce qui revient à dire que la question politique — au sens fort — n’a peut-être jamais été qui commande ? Mais bien plus : Comment un ordre tient-il malgré ce qui le défait ? Quels sont les dispositifs qui permettent à une société de ne pas se désagréger sous l’effet de ses propres contradictions ? Comment sont régulées les tensions qui traversent le tissu du monde commun sans le déchirer ? Cette bascule de perspective prolonge des intuitions anciennes. Max Weber (Économie et société, 1922) rappelait que ce qui fait tenir un ordre, ce n’est pas seulement la force ou la loi, mais les « chances de validité » socialement reconnues. Norbert Elias (La dynamique de l’Occident, 1939/1974) montrait, quant à lui, que les sociétés se maintiennent par des équilibres toujours précaires entre interdépendances, rivalités et pacifications. Notre démarche s’inscrit dans ce sillage : travailler cette interrogation sur les conditions de viabilité d’un monde commun.
|
||||
|
||||
Ce changement de perspective implique une rupture profonde dans la manière même de poser la question politique. Pendant des siècles, les sociétés ont pensé le politique à partir de principes transcendants — Dieu, Nature, Volonté générale, Pacte social. Ces principes, supposés extérieurs aux conflits du présent, garantissaient l'ordre en surplomb. Comme le rappelle Michel Foucault, il n'y a pas de principe extérieur au jeu des forces : seulement des rapports de pouvoir situés, modulés, réversibles. C'est précisément cette exigence — trouver dans les relations elles-mêmes les ressources nécessaires pour maintenir des mondes vivables — qui définit notre époque.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -65,115 +65,183 @@ const GITEA_REPO = import.meta.env.PUBLIC_GITEA_REPO ?? "";
|
||||
</article>
|
||||
</main>
|
||||
|
||||
|
||||
<ProposeModal />
|
||||
<!-- IMPORTANT: define:vars injecte les constantes dans le JS sans templating fragile -->
|
||||
<ProposeModal />
|
||||
|
||||
<!-- IMPORTANT: define:vars injecte les constantes dans le JS sans templating fragile -->
|
||||
<script is:inline define:vars={{ GITEA_BASE, GITEA_OWNER, GITEA_REPO }}>
|
||||
(() => {
|
||||
// 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);
|
||||
}
|
||||
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 giteaReady = Boolean(GITEA_BASE && GITEA_OWNER && GITEA_REPO);
|
||||
const oldId = h.slice(1);
|
||||
if (document.getElementById(oldId)) return; // l'ancre existe, rien à faire
|
||||
|
||||
function buildIssueURL(anchorId, excerpt) {
|
||||
const base = String(GITEA_BASE).replace(/\/+$/, "");
|
||||
const issue = new URL(`${base}/${GITEA_OWNER}/${GITEA_REPO}/issues/new`);
|
||||
const idx = m[1];
|
||||
const prefix = `p-${idx}-`;
|
||||
const replacement = document.querySelector(`[id^="${prefix}"]`);
|
||||
if (!replacement) return;
|
||||
|
||||
// URL locale "propre" : on ignore totalement query-string (?body=...)
|
||||
const local = new URL(window.location.origin + window.location.pathname);
|
||||
// évite d’embarquer des paramètres parasites (ex: ?body=... issus de tests)
|
||||
local.searchParams.delete("body");
|
||||
local.searchParams.delete("title");
|
||||
local.hash = anchorId;
|
||||
const path = local.pathname;
|
||||
console.warn("[anchors] legacy hash fallback used:", `#${oldId}`, "→", `#${replacement.id}`);
|
||||
// On ne réécrit PAS le hash : on garde la citabilité historique visible.
|
||||
replacement.scrollIntoView({ block: "start" });
|
||||
})();
|
||||
|
||||
const issueTitle = `[Correction] ${anchorId} — ${title}`;
|
||||
const body = [
|
||||
`Chemin: ${path}`,
|
||||
`URL locale: ${local.toString()}`,
|
||||
`Ancre: #${anchorId}`,
|
||||
`Version: ${version || "(non renseignée)"}`,
|
||||
`Type: type/correction`,
|
||||
`State: state/recevable`,
|
||||
``,
|
||||
`Texte actuel (extrait):`,
|
||||
`> ${excerpt}`,
|
||||
``,
|
||||
`Proposition (remplacer par):`,
|
||||
``,
|
||||
`Justification:`,
|
||||
``,
|
||||
`---`,
|
||||
`Note: issue générée depuis le site (pré-remplissage).`
|
||||
].join("\n");
|
||||
const docTitle = document.body.dataset.docTitle || document.title;
|
||||
const docVersion = document.body.dataset.docVersion || "";
|
||||
|
||||
issue.searchParams.set("title", issueTitle);
|
||||
issue.searchParams.set("body", body);
|
||||
return issue.toString();
|
||||
}
|
||||
const giteaReady = Boolean(GITEA_BASE && GITEA_OWNER && GITEA_REPO);
|
||||
|
||||
const paras = Array.from(document.querySelectorAll(".reading p[id]"));
|
||||
for (const p of paras) {
|
||||
if (p.querySelector(".para-tools")) continue;
|
||||
// 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 tools = document.createElement("span");
|
||||
tools.className = "para-tools";
|
||||
const quoteBlock = (s) =>
|
||||
String(s || "")
|
||||
.split(/\r?\n/)
|
||||
.map((l) => (`> ${l}`).trimEnd())
|
||||
.join("\n");
|
||||
|
||||
const a = document.createElement("a");
|
||||
a.className = "para-anchor";
|
||||
a.href = `#${p.id}`;
|
||||
a.setAttribute("aria-label", "Lien vers ce paragraphe");
|
||||
a.textContent = "¶";
|
||||
function buildIssueURL(anchorId, fullText, excerpt) {
|
||||
const base = String(GITEA_BASE).replace(/\/+$/, "");
|
||||
const issue = new URL(`${base}/${GITEA_OWNER}/${GITEA_REPO}/issues/new`);
|
||||
|
||||
const citeBtn = document.createElement("button");
|
||||
citeBtn.type = "button";
|
||||
citeBtn.className = "para-cite";
|
||||
citeBtn.textContent = "Citer";
|
||||
// URL locale "propre" : on ignore totalement query-string
|
||||
const local = new URL(window.location.href);
|
||||
local.search = "";
|
||||
local.hash = anchorId;
|
||||
|
||||
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 path = local.pathname;
|
||||
const issueTitle = `[Correction] ${anchorId} — ${docTitle}`;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(cite);
|
||||
const prev = citeBtn.textContent;
|
||||
citeBtn.textContent = "Copié";
|
||||
setTimeout(() => (citeBtn.textContent = prev), 900);
|
||||
} catch {
|
||||
window.prompt("Copiez la citation :", cite);
|
||||
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: ${docVersion || "(non renseignée)"}`,
|
||||
`Type: type/correction`,
|
||||
`State: state/recevable`,
|
||||
``,
|
||||
];
|
||||
|
||||
const texteActuel = embedFull
|
||||
? [
|
||||
`Texte actuel (copie exacte du paragraphe):`,
|
||||
quoteBlock(fullText),
|
||||
]
|
||||
: [
|
||||
`Texte actuel (extrait):`,
|
||||
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).`,
|
||||
];
|
||||
|
||||
return header.concat(texteActuel, footer).join("\n");
|
||||
};
|
||||
|
||||
// 1) On tente "texte complet"
|
||||
issue.searchParams.set("title", issueTitle);
|
||||
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));
|
||||
}
|
||||
});
|
||||
|
||||
tools.appendChild(a);
|
||||
tools.appendChild(citeBtn);
|
||||
return issue.toString();
|
||||
}
|
||||
|
||||
if (giteaReady) {
|
||||
const propose = document.createElement("a");
|
||||
propose.className = "para-propose";
|
||||
propose.textContent = "Proposer";
|
||||
propose.setAttribute("aria-label", "Proposer une correction sur Gitea");
|
||||
propose.setAttribute("data-propose", "1");
|
||||
// Contrat : uniquement les paragraphes citables
|
||||
const paras = Array.from(document.querySelectorAll('.reading p[id^="p-"]'));
|
||||
|
||||
const raw = (p.textContent || "").trim().replace(/\s+/g, " ");
|
||||
const excerpt = raw.length > 420 ? (raw.slice(0, 420) + "…") : raw;
|
||||
const url = buildIssueURL(p.id, excerpt);
|
||||
for (const p of paras) {
|
||||
if (p.querySelector(".para-tools")) continue;
|
||||
|
||||
// progressive enhancement : sans JS/modal, href fonctionne.
|
||||
propose.href = url;
|
||||
const tools = document.createElement("span");
|
||||
tools.className = "para-tools";
|
||||
|
||||
// compat : la modal lit data-url en priorité (garde aussi href).
|
||||
propose.dataset.url = url;
|
||||
const a = document.createElement("a");
|
||||
a.className = "para-anchor";
|
||||
a.href = `#${p.id}`;
|
||||
a.setAttribute("aria-label", "Lien vers ce paragraphe");
|
||||
a.textContent = "¶";
|
||||
|
||||
tools.appendChild(propose);
|
||||
}
|
||||
const citeBtn = document.createElement("button");
|
||||
citeBtn.type = "button";
|
||||
citeBtn.className = "para-cite";
|
||||
citeBtn.textContent = "Citer";
|
||||
|
||||
p.appendChild(tools);
|
||||
citeBtn.addEventListener("click", async () => {
|
||||
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);
|
||||
const prev = citeBtn.textContent;
|
||||
citeBtn.textContent = "Copié";
|
||||
setTimeout(() => (citeBtn.textContent = prev), 900);
|
||||
} catch {
|
||||
window.prompt("Copiez la citation :", cite);
|
||||
}
|
||||
});
|
||||
|
||||
tools.appendChild(a);
|
||||
tools.appendChild(citeBtn);
|
||||
|
||||
if (giteaReady) {
|
||||
const propose = document.createElement("a");
|
||||
propose.className = "para-propose";
|
||||
propose.textContent = "Proposer";
|
||||
propose.setAttribute("aria-label", "Proposer une correction sur Gitea");
|
||||
propose.setAttribute("data-propose", "1");
|
||||
|
||||
const raw = (p.textContent || "").trim().replace(/\s+/g, " ");
|
||||
const excerpt = raw.length > 420 ? (raw.slice(0, 420) + "…") : raw;
|
||||
|
||||
const issueUrl = buildIssueURL(p.id, raw, excerpt);
|
||||
|
||||
// progressive enhancement : sans JS/modal, href fonctionne.
|
||||
propose.href = issueUrl;
|
||||
|
||||
// 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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -7,12 +7,12 @@
|
||||
"p-0-d7974f88",
|
||||
"p-1-2ef25f29",
|
||||
"p-2-edb49e0a",
|
||||
"p-3-76df8102",
|
||||
"p-3-539ac0fd",
|
||||
"p-4-8ed4f807",
|
||||
"p-5-85126fa5",
|
||||
"p-5-285d27a7",
|
||||
"p-6-3515039d",
|
||||
"p-7-64a0ca9c",
|
||||
"p-8-e7075fe3",
|
||||
"p-8-0e65838d",
|
||||
"p-9-5ff70fb7",
|
||||
"p-10-e250e810",
|
||||
"p-11-594bf307",
|
||||
@@ -141,7 +141,10 @@
|
||||
"p-134-358f5875",
|
||||
"p-135-c19330ce",
|
||||
"p-136-17f1cf51",
|
||||
"p-137-d8f1539e"
|
||||
"p-137-d8f1539e",
|
||||
"p-3-76df8102",
|
||||
"p-5-85126fa5",
|
||||
"p-8-e7075fe3"
|
||||
],
|
||||
"atlas/00-demarrage/index.html": [
|
||||
"p-0-97681330"
|
||||
|
||||
Reference in New Issue
Block a user