Compare commits

...

35 Commits

Author SHA1 Message Date
5e95dc9898 ci: fix deploy (install docker compose plugin)
All checks were successful
CI / build-and-anchors (push) Successful in 1m42s
SMOKE / smoke (push) Successful in 23s
2026-02-26 11:15:53 +01:00
2b612214bb ci: auto deploy staging+live (annotations-only)
All checks were successful
CI / build-and-anchors (push) Successful in 1m55s
SMOKE / smoke (push) Successful in 18s
2026-02-26 10:29:17 +01:00
29a6c349aa Merge pull request 'anno: apply ticket #121' (#122) from bot/anno-121-20260225-191130 into main
All checks were successful
CI / build-and-anchors (push) Successful in 2m18s
SMOKE / smoke (push) Successful in 19s
Reviewed-on: #122
2026-02-26 09:01:52 +01:00
archicratie-bot
33a227c401 anno: apply ticket #121 (archicrat-ia/chapitre-4#p-7-1da4a458 type/media)
All checks were successful
CI / build-and-anchors (push) Successful in 1m46s
SMOKE / smoke (push) Successful in 21s
2026-02-25 19:11:36 +00:00
396ad4df7c Merge pull request 'anno: apply ticket #115' (#120) from bot/anno-115-20260225-185830 into main
All checks were successful
CI / build-and-anchors (push) Successful in 1m45s
SMOKE / smoke (push) Successful in 15s
Reviewed-on: #120
2026-02-25 20:00:04 +01:00
archicratie-bot
0b39427090 anno: apply ticket #115 (archicrat-ia/chapitre-4#p-2-31b12529 type/media)
All checks were successful
CI / build-and-anchors (push) Successful in 1m42s
SMOKE / smoke (push) Successful in 15s
2026-02-25 18:58:34 +00:00
8fcb18cb46 Merge pull request 'ci: anno apply workflow builds dist for strict verify' (#119) from chore/fix-anno-verify-build2 into main
All checks were successful
CI / build-and-anchors (push) Successful in 1m49s
SMOKE / smoke (push) Successful in 20s
Reviewed-on: #119
2026-02-25 19:51:24 +01:00
d03fc519de ci: anno apply workflow builds dist for strict verify
All checks were successful
CI / build-and-anchors (push) Successful in 1m50s
SMOKE / smoke (push) Successful in 19s
2026-02-25 19:50:47 +01:00
97dd3797d6 Merge pull request 'ci: anno apply workflow builds dist for strict verify' (#118) from chore/fix-anno-verify-build into main
All checks were successful
CI / build-and-anchors (push) Successful in 1m35s
SMOKE / smoke (push) Successful in 20s
Reviewed-on: #118
2026-02-25 19:31:45 +01:00
6c7b7ab6a0 ci: anno apply workflow builds dist for strict verify
All checks were successful
CI / build-and-anchors (push) Successful in 1m59s
SMOKE / smoke (push) Successful in 23s
2026-02-25 19:29:36 +01:00
105dfe1b5b Merge pull request 'ci: add apply-annotation-ticket script for anno bot' (#117) from chore/add-apply-annotation-ticket into main
All checks were successful
CI / build-and-anchors (push) Successful in 1m47s
SMOKE / smoke (push) Successful in 20s
Reviewed-on: #117
2026-02-25 19:02:33 +01:00
82f6453538 ci: add apply-annotation-ticket script for anno bot
All checks were successful
CI / build-and-anchors (push) Successful in 1m43s
SMOKE / smoke (push) Successful in 17s
2026-02-25 19:02:03 +01:00
fe862102d3 Merge pull request 'ci: fix anno apply/reject workflows (yaml valid)' (#116) from chore/fix-anno-workflows into main
All checks were successful
CI / build-and-anchors (push) Successful in 1m49s
SMOKE / smoke (push) Successful in 17s
Reviewed-on: #116
2026-02-25 18:28:36 +01:00
6ef538a0c4 ci: fix anno apply/reject workflows (yaml valid)
All checks were successful
CI / build-and-anchors (push) Successful in 1m36s
SMOKE / smoke (push) Successful in 20s
2026-02-25 18:26:03 +01:00
689612ff7f Merge pull request 'anno-workflows' (#114) from chore/anno-workflows into main
All checks were successful
CI / build-and-anchors (push) Successful in 1m52s
SMOKE / smoke (push) Successful in 18s
Reviewed-on: #114
2026-02-25 17:51:16 +01:00
7b135a4707 ci: add anno apply bot workflow
All checks were successful
CI / build-and-anchors (push) Successful in 2m0s
SMOKE / smoke (push) Successful in 16s
2026-02-25 17:50:45 +01:00
0cb8a54195 Merge pull request 'fix: remove specimen media from prologue annotations' (#108) from chore/Fix_whoami into main
Some checks failed
CI / build-and-anchors (push) Has started running
SMOKE / smoke (push) Has been cancelled
Reviewed-on: #108
2026-02-23 12:37:39 +01:00
a7a333397d fix: remove specimen media from prologue annotations
All checks were successful
CI / build-and-anchors (push) Successful in 1m36s
SMOKE / smoke (push) Successful in 15s
2026-02-23 12:33:55 +01:00
eb1d444776 Merge pull request 'fix: …' (#107) from chore/Fix_whoami into main
Some checks failed
CI / build-and-anchors (push) Failing after 1m45s
SMOKE / smoke (push) Successful in 15s
Reviewed-on: #107
2026-02-23 12:17:21 +01:00
68c3416594 fix: …
Some checks failed
CI / build-and-anchors (push) Failing after 2m6s
SMOKE / smoke (push) Successful in 13s
2026-02-23 12:07:01 +01:00
ae809e0152 Merge pull request 'docs: add pro runbooks (deploy/edge/public_site) + annotations spec + start-here v2' (#106) from fix/canonical-public-site into main
All checks were successful
CI / build-and-anchors (push) Successful in 1m25s
SMOKE / smoke (push) Successful in 13s
Reviewed-on: #106
2026-02-21 15:36:07 +01:00
7444eeb532 docs: add pro runbooks (deploy/edge/public_site) + annotations spec + start-here v2
All checks were successful
CI / build-and-anchors (push) Successful in 1m45s
SMOKE / smoke (push) Successful in 11s
2026-02-21 15:34:47 +01:00
9bbebf5886 Merge pull request 'fix(seo): enforce PUBLIC_SITE at docker build (canonical/sitemap) + set per blue/green' (#105) from fix/canonical-public-site into main
All checks were successful
CI / build-and-anchors (push) Successful in 1m55s
SMOKE / smoke (push) Successful in 23s
Reviewed-on: #105
2026-02-21 12:32:08 +01:00
fe7810671d fix(seo): enforce PUBLIC_SITE at docker build (canonical/sitemap) + set per blue/green
All checks were successful
CI / build-and-anchors (push) Successful in 2m26s
SMOKE / smoke (push) Successful in 20s
2026-02-21 12:31:23 +01:00
53562025ac Merge pull request 'fix/anchors-baseline-archicrat-ia-20260220' (#104) from fix/anchors-baseline-archicrat-ia-20260220 into main
All checks were successful
CI / build-and-anchors (push) Successful in 1m32s
SMOKE / smoke (push) Successful in 15s
Reviewed-on: #104
2026-02-20 22:50:22 +01:00
2b35315466 Merge branch 'main' into fix/anchors-baseline-archicrat-ia-20260220
All checks were successful
CI / build-and-anchors (push) Successful in 1m51s
SMOKE / smoke (push) Successful in 15s
2026-02-20 22:50:06 +01:00
1b7f23d0a6 fix(home): Essai-thèse -> /archicrat-ia/ + rename ArchiCraT-IA
All checks were successful
CI / build-and-anchors (push) Successful in 1m49s
SMOKE / smoke (push) Successful in 17s
2026-02-20 22:42:49 +01:00
3d1d4d7952 fix(annotations): update archicrat-ia prologue specimen paths 2026-02-20 22:29:55 +01:00
3320563e1b chore: add ops scripts + diagrams PNG renders 2026-02-20 22:29:47 +01:00
798b2ddd0b chore: ignore .DS_Store 2026-02-20 22:22:35 +01:00
31d4896f5d Merge pull request 'test(anchors): update baseline after URL migration to /archicrat-ia' (#103) from fix/anchors-baseline-archicrat-ia-20260220 into main
All checks were successful
CI / build-and-anchors (push) Successful in 1m24s
SMOKE / smoke (push) Successful in 13s
Reviewed-on: #103
2026-02-20 21:29:19 +01:00
3fda37491d test(anchors): update baseline after URL migration to /archicrat-ia
All checks were successful
CI / build-and-anchors (push) Successful in 1m45s
SMOKE / smoke (push) Successful in 13s
2026-02-20 21:28:45 +01:00
488c02b8b5 Merge pull request 'chore/url-migration-archicrat-ia-20260220' (#102) from chore/url-migration-archicrat-ia-20260220 into main
Some checks failed
CI / build-and-anchors (push) Failing after 1m31s
SMOKE / smoke (push) Successful in 13s
Reviewed-on: #102
2026-02-20 21:15:55 +01:00
f9ea3760e2 Merge pull request 'chore: add diagrams + scripts + archicrat-ia route' (#101) from chore/url-migration-archicrat-ia-20260220 into main
All checks were successful
CI / build-and-anchors (push) Successful in 1m51s
SMOKE / smoke (push) Successful in 16s
Reviewed-on: #101
2026-02-20 18:31:40 +01:00
00e1a1d4b0 Merge pull request 'chore: add missing diagrams/scripts + archicrat-ia routes' (#100) from chore/url-migration-archicrat-ia-20260220 into main
All checks were successful
CI / build-and-anchors (push) Successful in 1m31s
SMOKE / smoke (push) Successful in 19s
Reviewed-on: #100
2026-02-20 18:17:31 +01:00
43 changed files with 3658 additions and 261 deletions

View File

@@ -3,7 +3,7 @@ name: "Correction paragraphe"
about: "Proposer une correction ciblée (un paragraphe) avec justification."
---
## Chemin (ex: /archicratie/prologue/)
## Chemin (ex: /archicrat-ia/prologue/)
<!-- obligatoire -->
/...

View File

@@ -3,7 +3,7 @@ name: "Vérification factuelle / sources"
about: "Signaler une assertion à sourcer ou à corriger (preuves, références)."
---
## Chemin (ex: /archicratie/prologue/)
## Chemin (ex: /archicrat-ia/prologue/)
<!-- obligatoire -->
/...

View File

@@ -0,0 +1,343 @@
name: Anno Apply (PR)
on:
issues:
types: [labeled]
workflow_dispatch:
inputs:
issue:
description: "Issue number to apply"
required: true
env:
NODE_OPTIONS: --dns-result-order=ipv4first
defaults:
run:
shell: bash
jobs:
apply-approved:
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/devcontainers/javascript-node:22-bookworm
steps:
- name: Tools sanity
run: |
set -euo pipefail
git --version
node --version
npm --version
npm ping --registry=https://registry.npmjs.org
- name: Derive context (event.json / workflow_dispatch)
env:
INPUT_ISSUE: ${{ inputs.issue }}
FORGE_API: ${{ vars.FORGE_API || vars.FORGE_BASE }}
run: |
set -euo pipefail
export EVENT_JSON="/var/run/act/workflow/event.json"
test -f "$EVENT_JSON" || { echo "❌ Missing $EVENT_JSON"; exit 1; }
node --input-type=module - <<'NODE' > /tmp/anno.env
import fs from "node:fs";
const ev = JSON.parse(fs.readFileSync(process.env.EVENT_JSON, "utf8"));
const repoObj = ev?.repository || {};
const cloneUrl =
repoObj?.clone_url ||
(repoObj?.html_url ? (repoObj.html_url.replace(/\/$/,"") + ".git") : "");
if (!cloneUrl) throw new Error("No repository clone_url/html_url in event.json");
let owner =
repoObj?.owner?.login ||
repoObj?.owner?.username ||
(repoObj?.full_name ? repoObj.full_name.split("/")[0] : "");
let repo =
repoObj?.name ||
(repoObj?.full_name ? repoObj.full_name.split("/")[1] : "");
if (!owner || !repo) {
const m = cloneUrl.match(/[:/](?<o>[^/]+)\/(?<r>[^/]+?)(?:\.git)?$/);
if (m?.groups) { owner = owner || m.groups.o; repo = repo || m.groups.r; }
}
if (!owner || !repo) throw new Error("Cannot infer owner/repo");
const defaultBranch = repoObj?.default_branch || "main";
const issueNumber =
ev?.issue?.number ||
ev?.issue?.index ||
(process.env.INPUT_ISSUE ? Number(process.env.INPUT_ISSUE) : 0);
if (!issueNumber || !Number.isFinite(Number(issueNumber))) {
throw new Error("No issue number in event.json or workflow_dispatch input");
}
const labelName =
ev?.label?.name ||
ev?.label ||
"workflow_dispatch";
const u = new URL(cloneUrl);
const origin = u.origin;
const apiBase = (process.env.FORGE_API && String(process.env.FORGE_API).trim())
? String(process.env.FORGE_API).trim().replace(/\/+$/,"")
: origin;
function sh(s){ return JSON.stringify(String(s)); }
process.stdout.write([
`CLONE_URL=${sh(cloneUrl)}`,
`OWNER=${sh(owner)}`,
`REPO=${sh(repo)}`,
`DEFAULT_BRANCH=${sh(defaultBranch)}`,
`ISSUE_NUMBER=${sh(issueNumber)}`,
`LABEL_NAME=${sh(labelName)}`,
`API_BASE=${sh(apiBase)}`
].join("\n") + "\n");
NODE
echo "✅ context:"
sed -n '1,120p' /tmp/anno.env
- name: Gate on label state/approved
run: |
set -euo pipefail
source /tmp/anno.env
if [[ "$LABEL_NAME" != "state/approved" && "$LABEL_NAME" != "workflow_dispatch" ]]; then
echo " label=$LABEL_NAME => skip"
echo "SKIP=1" >> /tmp/anno.env
exit 0
fi
echo "✅ proceed (issue=$ISSUE_NUMBER)"
- name: Checkout default branch
run: |
set -euo pipefail
source /tmp/anno.env
[[ "${SKIP:-0}" != "1" ]] || { echo " skipped"; exit 0; }
rm -rf .git
git init -q
git remote add origin "$CLONE_URL"
git fetch --depth 1 origin "$DEFAULT_BRANCH"
git -c advice.detachedHead=false checkout -q FETCH_HEAD
git log -1 --oneline
- name: Install deps
run: |
set -euo pipefail
source /tmp/anno.env
[[ "${SKIP:-0}" != "1" ]] || { echo " skipped"; exit 0; }
npm ci
- name: Check apply script exists
run: |
set -euo pipefail
source /tmp/anno.env
[[ "${SKIP:-0}" != "1" ]] || { echo " skipped"; exit 0; }
test -f scripts/apply-annotation-ticket.mjs || {
echo "❌ missing scripts/apply-annotation-ticket.mjs on $DEFAULT_BRANCH"
ls -la scripts | sed -n '1,200p' || true
exit 1
}
- name: Build dist (needed for --verify)
run: |
set -euo pipefail
source /tmp/anno.env
[[ "${SKIP:-0}" != "1" ]] || { echo " skipped"; exit 0; }
npm run build:clean
test -f dist/para-index.json || {
echo "❌ missing dist/para-index.json after build"
ls -la dist | sed -n '1,200p' || true
exit 1
}
echo "✅ dist/para-index.json present"
- name: Apply ticket on bot branch (strict+verify, commit)
continue-on-error: true
env:
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
BOT_GIT_NAME: ${{ secrets.BOT_GIT_NAME }}
BOT_GIT_EMAIL: ${{ secrets.BOT_GIT_EMAIL }}
run: |
set -euo pipefail
source /tmp/anno.env
[[ "${SKIP:-0}" != "1" ]] || { echo " skipped"; exit 0; }
test -n "${FORGE_TOKEN:-}" || { echo "❌ Missing secret FORGE_TOKEN"; exit 1; }
git config user.name "${BOT_GIT_NAME:-archicratie-bot}"
git config user.email "${BOT_GIT_EMAIL:-bot@archicratie.local}"
START_SHA="$(git rev-parse HEAD)"
TS="$(date -u +%Y%m%d-%H%M%S)"
BR="bot/anno-${ISSUE_NUMBER}-${TS}"
echo "BRANCH=$BR" >> /tmp/anno.env
git checkout -b "$BR"
export FORGE_API="$API_BASE"
export GITEA_OWNER="$OWNER"
export GITEA_REPO="$REPO"
LOG="/tmp/apply.log"
set +e
node scripts/apply-annotation-ticket.mjs "$ISSUE_NUMBER" --strict --verify --commit >"$LOG" 2>&1
RC=$?
set -e
echo "APPLY_RC=$RC" >> /tmp/anno.env
echo "== apply log (tail) =="
tail -n 180 "$LOG" || true
END_SHA="$(git rev-parse HEAD)"
if [[ "$RC" -ne 0 ]]; then
echo "NOOP=0" >> /tmp/anno.env
exit 0
fi
if [[ "$START_SHA" == "$END_SHA" ]]; then
echo "NOOP=1" >> /tmp/anno.env
else
echo "NOOP=0" >> /tmp/anno.env
echo "END_SHA=$END_SHA" >> /tmp/anno.env
fi
- name: Comment issue on failure (strict/verify/etc)
if: ${{ always() }}
env:
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
run: |
set -euo pipefail
source /tmp/anno.env
[[ "${SKIP:-0}" != "1" ]] || { echo " skipped"; exit 0; }
RC="${APPLY_RC:-0}"
if [[ "$RC" == "0" ]]; then
echo " no failure detected"
exit 0
fi
BODY="$(tail -n 160 /tmp/apply.log | sed 's/\r$//')"
MSG="❌ apply-annotation-ticket a échoué (rc=${RC}).\n\n\`\`\`\n${BODY}\n\`\`\`\n"
PAYLOAD="$(node --input-type=module -e 'console.log(JSON.stringify({body: process.argv[1]||""}))' "$MSG")"
curl -fsS -X POST \
-H "Authorization: token $FORGE_TOKEN" \
-H "Content-Type: application/json" \
"$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER/comments" \
--data-binary "$PAYLOAD"
- name: Comment issue if no-op (already applied)
if: ${{ always() }}
env:
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
run: |
set -euo pipefail
source /tmp/anno.env
[[ "${SKIP:-0}" != "1" ]] || exit 0
[[ "${APPLY_RC:-0}" == "0" ]] || exit 0
[[ "${NOOP:-0}" == "1" ]] || exit 0
MSG=" Ticket #${ISSUE_NUMBER} : rien à appliquer (déjà présent / dédupliqué)."
PAYLOAD="$(node --input-type=module -e 'console.log(JSON.stringify({body: process.argv[1]||""}))' "$MSG")"
curl -fsS -X POST \
-H "Authorization: token $FORGE_TOKEN" \
-H "Content-Type: application/json" \
"$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER/comments" \
--data-binary "$PAYLOAD"
- name: Push bot branch
if: ${{ always() }}
env:
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
run: |
set -euo pipefail
source /tmp/anno.env
[[ "${SKIP:-0}" != "1" ]] || exit 0
[[ "${APPLY_RC:-0}" == "0" ]] || { echo " apply failed -> skip push"; exit 0; }
[[ "${NOOP:-0}" == "0" ]] || { echo " no-op -> skip push"; exit 0; }
AUTH_URL="$(node --input-type=module -e '
const [clone, tok] = process.argv.slice(1);
const u = new URL(clone);
u.username = "oauth2";
u.password = tok;
console.log(u.toString());
' "$CLONE_URL" "$FORGE_TOKEN")"
git remote set-url origin "$AUTH_URL"
git push -u origin "$BRANCH"
- name: Create PR + comment issue
if: ${{ always() }}
env:
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
run: |
set -euo pipefail
source /tmp/anno.env
[[ "${SKIP:-0}" != "1" ]] || exit 0
[[ "${APPLY_RC:-0}" == "0" ]] || { echo " apply failed -> skip PR"; exit 0; }
[[ "${NOOP:-0}" == "0" ]] || { echo " no-op -> skip PR"; exit 0; }
PR_TITLE="anno: apply ticket #${ISSUE_NUMBER}"
PR_BODY="PR auto depuis ticket #${ISSUE_NUMBER} (state/approved).\n\n- Branche: ${BRANCH}\n- Commit: ${END_SHA}\n\nMerge si CI OK."
PR_PAYLOAD="$(node --input-type=module -e '
const [title, body, base, head] = process.argv.slice(1);
console.log(JSON.stringify({ title, body, base, head, allow_maintainer_edit: true }));
' "$PR_TITLE" "$PR_BODY" "$DEFAULT_BRANCH" "${OWNER}:${BRANCH}")"
PR_JSON="$(curl -fsS -X POST \
-H "Authorization: token $FORGE_TOKEN" \
-H "Content-Type: application/json" \
"$API_BASE/api/v1/repos/$OWNER/$REPO/pulls" \
--data-binary "$PR_PAYLOAD")"
PR_URL="$(node --input-type=module -e '
const pr = JSON.parse(process.argv[1] || "{}");
console.log(pr.html_url || pr.url || "");
' "$PR_JSON")"
test -n "$PR_URL" || { echo "❌ PR URL missing. Raw: $PR_JSON"; exit 1; }
MSG="✅ PR créée pour ticket #${ISSUE_NUMBER} : ${PR_URL}"
C_PAYLOAD="$(node --input-type=module -e 'console.log(JSON.stringify({body: process.argv[1]||""}))' "$MSG")"
curl -fsS -X POST \
-H "Authorization: token $FORGE_TOKEN" \
-H "Content-Type: application/json" \
"$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER/comments" \
--data-binary "$C_PAYLOAD"
echo "✅ PR: $PR_URL"
- name: Finalize (fail job if apply failed)
if: ${{ always() }}
run: |
set -euo pipefail
source /tmp/anno.env || true
[[ "${SKIP:-0}" != "1" ]] || { echo " skipped"; exit 0; }
RC="${APPLY_RC:-0}"
if [[ "$RC" != "0" ]]; then
echo "❌ apply failed (rc=$RC)"
exit "$RC"
fi
echo "✅ apply ok"

View File

@@ -0,0 +1,98 @@
name: Anno Reject
on:
issues:
types: [labeled]
env:
NODE_OPTIONS: --dns-result-order=ipv4first
defaults:
run:
shell: bash
jobs:
reject:
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/devcontainers/javascript-node:22-bookworm
steps:
- name: Derive context
run: |
set -euo pipefail
export EVENT_JSON="/var/run/act/workflow/event.json"
test -f "$EVENT_JSON" || { echo "❌ Missing $EVENT_JSON"; exit 1; }
node --input-type=module - <<'NODE' > /tmp/reject.env
import fs from "node:fs";
const ev = JSON.parse(fs.readFileSync(process.env.EVENT_JSON, "utf8"));
const repoObj = ev?.repository || {};
const cloneUrl =
repoObj?.clone_url ||
(repoObj?.html_url ? (repoObj.html_url.replace(/\/$/,"") + ".git") : "");
if (!cloneUrl) throw new Error("No repository url");
let owner =
repoObj?.owner?.login ||
repoObj?.owner?.username ||
(repoObj?.full_name ? repoObj.full_name.split("/")[0] : "");
let repo =
repoObj?.name ||
(repoObj?.full_name ? repoObj.full_name.split("/")[1] : "");
if (!owner || !repo) {
const m = cloneUrl.match(/[:/](?<o>[^/]+)\/(?<r>[^/]+?)(?:\.git)?$/);
if (m?.groups) { owner = owner || m.groups.o; repo = repo || m.groups.r; }
}
if (!owner || !repo) throw new Error("Cannot infer owner/repo");
const issueNumber = ev?.issue?.number || ev?.issue?.index;
if (!issueNumber) throw new Error("No issue number");
const labelName = ev?.label?.name || ev?.label || "";
const u = new URL(cloneUrl);
function sh(s){ return JSON.stringify(String(s)); }
process.stdout.write([
`OWNER=${sh(owner)}`,
`REPO=${sh(repo)}`,
`ISSUE_NUMBER=${sh(issueNumber)}`,
`LABEL_NAME=${sh(labelName)}`,
`API_BASE=${sh(u.origin)}`
].join("\n") + "\n");
NODE
- name: Gate on label state/rejected
run: |
set -euo pipefail
source /tmp/reject.env
if [[ "$LABEL_NAME" != "state/rejected" ]]; then
echo " label=$LABEL_NAME => skip"
exit 0
fi
echo "✅ reject issue=$ISSUE_NUMBER"
- name: Comment + close issue
env:
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
run: |
set -euo pipefail
source /tmp/reject.env
test -n "${FORGE_TOKEN:-}" || { echo "❌ Missing secret FORGE_TOKEN"; exit 1; }
MSG="❌ Ticket #${ISSUE_NUMBER} refusé (label state/rejected)."
PAYLOAD="$(node --input-type=module -e 'console.log(JSON.stringify({body: process.argv[1]||""}))' "$MSG")"
curl -fsS -X POST \
-H "Authorization: token $FORGE_TOKEN" \
-H "Content-Type: application/json" \
"$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER/comments" \
--data-binary "$PAYLOAD"
curl -fsS -X PATCH \
-H "Authorization: token $FORGE_TOKEN" \
-H "Content-Type: application/json" \
"$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER" \
--data-binary '{"state":"closed"}'

View File

@@ -79,22 +79,7 @@ jobs:
set -euo pipefail
npm ci
- name: Inline scripts syntax check
- name: Full test suite (CI=1)
run: |
set -euo pipefail
node scripts/check-inline-js.mjs
- name: Build (includes postbuild injection + pagefind)
run: |
set -euo pipefail
npm run build
- name: Anchors contract
run: |
set -euo pipefail
npm run test:anchors
- name: Verify anchor aliases injected in dist
run: |
set -euo pipefail
node scripts/verify-anchor-aliases-in-dist.mjs
npm run ci

View File

@@ -1,103 +0,0 @@
name: CI
on:
push: {}
pull_request:
branches: ["master"]
workflow_dispatch: {}
env:
NODE_OPTIONS: --dns-result-order=ipv4first
defaults:
run:
shell: bash
jobs:
build-and-anchors:
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/devcontainers/javascript-node:22-bookworm
steps:
- name: Tools sanity
run: |
set -euo pipefail
git --version
node --version
npm --version
npm ping --registry=https://registry.npmjs.org
- name: Checkout (from event.json, no external actions)
run: |
set -euo pipefail
EVENT_JSON="/var/run/act/workflow/event.json"
test -f "$EVENT_JSON" || (echo "❌ Missing $EVENT_JSON" && exit 1)
eval "$(node - <<'NODE'
import fs from "node:fs";
const ev = JSON.parse(fs.readFileSync("/var/run/act/workflow/event.json","utf8"));
const repo =
ev?.repository?.clone_url ||
(ev?.repository?.html_url ? (ev.repository.html_url.replace(/\/$/,'') + ".git") : "");
const sha =
ev?.after ||
ev?.pull_request?.head?.sha ||
ev?.head_commit?.id ||
ev?.sha ||
"";
if (!repo) { console.error("No repository.clone_url/html_url in event.json"); process.exit(1); }
if (!sha) { console.error("No sha/after/pull_request.head.sha in event.json"); process.exit(1); }
console.log(`REPO_URL=${JSON.stringify(repo)}`);
console.log(`SHA=${JSON.stringify(sha)}`);
NODE
)"
echo "Repo URL: $REPO_URL"
echo "SHA: $SHA"
rm -rf .git
git init
git remote add origin "$REPO_URL"
git fetch --depth 1 origin "$SHA"
git checkout -q FETCH_HEAD
git log -1 --oneline
- name: Anchor aliases schema
run: |
set -euo pipefail
node scripts/check-anchor-aliases.mjs
- name: NPM harden
run: |
set -euo pipefail
npm config set fetch-retries 5
npm config set fetch-retry-mintimeout 20000
npm config set fetch-retry-maxtimeout 120000
npm config set registry https://registry.npmjs.org
npm config get registry
- name: Install deps
run: |
set -euo pipefail
npm ci
- name: Inline scripts syntax check
run: |
set -euo pipefail
node scripts/check-inline-js.mjs
- name: Build (includes postbuild injection + pagefind)
run: |
set -euo pipefail
npm run build
- name: Anchors contract
run: |
set -euo pipefail
npm run test:anchors
- name: Verify anchor aliases injected in dist
run: |
set -euo pipefail
node scripts/verify-anchor-aliases-in-dist.mjs

View File

@@ -0,0 +1,191 @@
name: Deploy staging+live (annotations)
on:
push:
branches: [main]
workflow_dispatch:
env:
NODE_OPTIONS: --dns-result-order=ipv4first
DOCKER_API_VERSION: "1.43"
COMPOSE_VERSION: "2.29.7"
defaults:
run:
shell: bash
concurrency:
group: deploy-staging-live-main
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/devcontainers/javascript-node:22-bookworm
steps:
- name: Tools sanity
run: |
set -euo pipefail
git --version
node --version
npm --version
- name: Checkout (from event.json, no external actions)
run: |
set -euo pipefail
export EVENT_JSON="/var/run/act/workflow/event.json"
test -f "$EVENT_JSON" || { echo "❌ Missing $EVENT_JSON"; exit 1; }
eval "$(node --input-type=module -e 'import fs from "node:fs";
const ev = JSON.parse(fs.readFileSync(process.env.EVENT_JSON,"utf8"));
const repo =
ev?.repository?.clone_url ||
(ev?.repository?.html_url ? (ev.repository.html_url.replace(/\/$/,"") + ".git") : "");
const sha =
ev?.after ||
ev?.pull_request?.head?.sha ||
ev?.head_commit?.id ||
ev?.sha ||
"";
if (!repo) throw new Error("No repository url in event.json");
if (!sha) throw new Error("No sha in event.json");
process.stdout.write(`REPO_URL=${JSON.stringify(repo)}\nSHA=${JSON.stringify(sha)}\n`);
')"
echo "Repo URL: $REPO_URL"
echo "SHA: $SHA"
rm -rf .git
git init -q
git remote add origin "$REPO_URL"
git fetch --depth 1 origin "$SHA"
git -c advice.detachedHead=false checkout -q FETCH_HEAD
git log -1 --oneline
echo "SHA=$SHA" >> /tmp/deploy.env
echo "REPO_URL=$REPO_URL" >> /tmp/deploy.env
- name: Gate — auto deploy only on annotations/media changes
run: |
set -euo pipefail
source /tmp/deploy.env
# fichiers touchés par CE commit (merge commit inclus)
CHANGED="$(git diff-tree --no-commit-id --name-only -r "$SHA" || true)"
echo "== changed files =="
echo "$CHANGED" | sed -n '1,200p'
# Gate strict : uniquement annotations + media + le workflow lui-même (si tu veux autoriser)
if echo "$CHANGED" | grep -qE '^(src/annotations/|public/media/)'; then
echo "GO=1" >> /tmp/deploy.env
echo "✅ deploy allowed (annotations/media change detected)"
exit 0
fi
echo "GO=0" >> /tmp/deploy.env
echo " no annotations/media change -> skip deploy"
exit 0
- name: Install docker client + docker compose plugin (v2)
run: |
set -euo pipefail
source /tmp/deploy.env
[[ "${GO:-0}" == "1" ]] || { echo " skipped"; exit 0; }
apt-get -o Acquire::Retries=5 -o Acquire::ForceIPv4=true update
apt-get install -y --no-install-recommends ca-certificates curl docker.io
rm -rf /var/lib/apt/lists/*
mkdir -p /usr/local/lib/docker/cli-plugins
curl -fsSL \
"https://github.com/docker/compose/releases/download/v${COMPOSE_VERSION}/docker-compose-linux-x86_64" \
-o /usr/local/lib/docker/cli-plugins/docker-compose
chmod +x /usr/local/lib/docker/cli-plugins/docker-compose
docker version
docker compose version
- name: Assert required vars (PUBLIC_GITEA_*)
env:
PUBLIC_GITEA_BASE: ${{ vars.PUBLIC_GITEA_BASE }}
PUBLIC_GITEA_OWNER: ${{ vars.PUBLIC_GITEA_OWNER }}
PUBLIC_GITEA_REPO: ${{ vars.PUBLIC_GITEA_REPO }}
run: |
set -euo pipefail
source /tmp/deploy.env
[[ "${GO:-0}" == "1" ]] || { echo " skipped"; exit 0; }
test -n "${PUBLIC_GITEA_BASE:-}" || { echo "❌ missing repo var PUBLIC_GITEA_BASE"; exit 2; }
test -n "${PUBLIC_GITEA_OWNER:-}" || { echo "❌ missing repo var PUBLIC_GITEA_OWNER"; exit 2; }
test -n "${PUBLIC_GITEA_REPO:-}" || { echo "❌ missing repo var PUBLIC_GITEA_REPO"; exit 2; }
echo "✅ vars OK"
- name: Build + deploy staging (blue) then smoke
env:
PUBLIC_GITEA_BASE: ${{ vars.PUBLIC_GITEA_BASE }}
PUBLIC_GITEA_OWNER: ${{ vars.PUBLIC_GITEA_OWNER }}
PUBLIC_GITEA_REPO: ${{ vars.PUBLIC_GITEA_REPO }}
run: |
set -euo pipefail
source /tmp/deploy.env
[[ "${GO:-0}" == "1" ]] || { echo " skipped"; exit 0; }
# backup tags (best effort)
TS="$(date -u +%Y%m%d-%H%M%S)"
echo "TS=$TS" >> /tmp/deploy.env
docker image tag archicratie-web:blue "archicratie-web:blue.BAK.${TS}" || true
docker image tag archicratie-web:green "archicratie-web:green.BAK.${TS}" || true
# build + restart staging (blue=8081)
docker compose build --no-cache web_blue
docker compose up -d --force-recreate web_blue
# smoke staging (local port)
curl -fsS "http://127.0.0.1:8081/para-index.json" >/dev/null
curl -fsS "http://127.0.0.1:8081/annotations-index.json" >/dev/null
curl -fsS "http://127.0.0.1:8081/pagefind/pagefind.js" >/dev/null
CANON="$(curl -fsS "http://127.0.0.1:8081/archicrat-ia/chapitre-1/" | grep -oE 'rel="canonical" href="[^"]+"' | head -n1 || true)"
echo "canonical(blue)=$CANON"
echo "$CANON" | grep -q 'https://staging\.archicratie\.trans-hands\.synology\.me/' || {
echo "❌ staging canonical mismatch"; exit 3;
}
echo "✅ staging OK"
- name: Build + deploy live (green) then smoke + rollback if needed
env:
PUBLIC_GITEA_BASE: ${{ vars.PUBLIC_GITEA_BASE }}
PUBLIC_GITEA_OWNER: ${{ vars.PUBLIC_GITEA_OWNER }}
PUBLIC_GITEA_REPO: ${{ vars.PUBLIC_GITEA_REPO }}
run: |
set -euo pipefail
source /tmp/deploy.env
[[ "${GO:-0}" == "1" ]] || { echo " skipped"; exit 0; }
TS="${TS:-$(date -u +%Y%m%d-%H%M%S)}"
rollback() {
echo "⚠️ rollback green -> previous image tag (best effort)"
docker image tag "archicratie-web:green.BAK.${TS}" archicratie-web:green || true
docker compose up -d --force-recreate web_green || true
}
set +e
docker compose build --no-cache web_green
docker compose up -d --force-recreate web_green
curl -fsS "http://127.0.0.1:8082/para-index.json" >/dev/null
curl -fsS "http://127.0.0.1:8082/annotations-index.json" >/dev/null
curl -fsS "http://127.0.0.1:8082/pagefind/pagefind.js" >/dev/null
CANON="$(curl -fsS "http://127.0.0.1:8082/archicrat-ia/chapitre-1/" | grep -oE 'rel="canonical" href="[^"]+"' | head -n1 || true)"
echo "canonical(green)=$CANON"
echo "$CANON" | grep -q 'https://archicratie\.trans-hands\.synology\.me/' || {
echo "❌ live canonical mismatch"; rollback; exit 4;
}
echo "✅ live OK"
set -e

7
.gitignore vendored
View File

@@ -3,6 +3,10 @@
.env.*
!.env.example
# dev-only
public/_auth/whoami
public/_auth/whoami/*
# --- local backups ---
*.bak
*.bak.*
@@ -21,3 +25,6 @@ dist/
# local backups
Dockerfile.bak.*
public/favicon_io.zip
# macOS
.DS_Store

View File

@@ -12,7 +12,7 @@ ENV npm_config_update_notifier=false \
# (Optionnel mais propre) git + certificats
RUN apt-get -o Acquire::Retries=5 -o Acquire::ForceIPv4=true update \
&& apt-get install -y --no-install-recommends ca-certificates git \
&& rm -rf /var/lib/apt/lists/*
&& rm -rf /var/lib/apt/lists/*
# Déps dabord (cache Docker)
COPY package.json package-lock.json ./
@@ -25,9 +25,21 @@ COPY . .
ARG PUBLIC_GITEA_BASE
ARG PUBLIC_GITEA_OWNER
ARG PUBLIC_GITEA_REPO
# ✅ Canonical + sitemap base (astro.config.mjs lit process.env.PUBLIC_SITE)
ARG PUBLIC_SITE
# ✅ Garde-fou : si 1 → build fail si PUBLIC_SITE absent
ARG REQUIRE_PUBLIC_SITE=0
ENV PUBLIC_GITEA_BASE=$PUBLIC_GITEA_BASE \
PUBLIC_GITEA_OWNER=$PUBLIC_GITEA_OWNER \
PUBLIC_GITEA_REPO=$PUBLIC_GITEA_REPO
PUBLIC_GITEA_REPO=$PUBLIC_GITEA_REPO \
PUBLIC_SITE=$PUBLIC_SITE \
REQUIRE_PUBLIC_SITE=$REQUIRE_PUBLIC_SITE
# ✅ antifragile : refuse de builder sans PUBLIC_SITE quand on lexige
RUN node -e "if (process.env.REQUIRE_PUBLIC_SITE==='1' && !process.env.PUBLIC_SITE) { console.error('FATAL: PUBLIC_SITE is required (canonical/sitemap).'); process.exit(1) }"
# Build Astro (postbuild tourne via npm scripts)
RUN npm run build
@@ -38,4 +50,4 @@ COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist/ /usr/share/nginx/html/
RUN find /usr/share/nginx/html -type d -exec chmod 755 {} \; \
&& find /usr/share/nginx/html -type f -exec chmod 644 {} \;
EXPOSE 80
EXPOSE 80

View File

@@ -5,6 +5,8 @@ services:
dockerfile: Dockerfile
network: host
args:
REQUIRE_PUBLIC_SITE: "1"
PUBLIC_SITE: "https://staging.archicratie.trans-hands.synology.me"
PUBLIC_GITEA_BASE: ${PUBLIC_GITEA_BASE}
PUBLIC_GITEA_OWNER: ${PUBLIC_GITEA_OWNER}
PUBLIC_GITEA_REPO: ${PUBLIC_GITEA_REPO}
@@ -20,6 +22,8 @@ services:
dockerfile: Dockerfile
network: host
args:
REQUIRE_PUBLIC_SITE: "1"
PUBLIC_SITE: "https://archicratie.trans-hands.synology.me"
PUBLIC_GITEA_BASE: ${PUBLIC_GITEA_BASE}
PUBLIC_GITEA_OWNER: ${PUBLIC_GITEA_OWNER}
PUBLIC_GITEA_REPO: ${PUBLIC_GITEA_REPO}
@@ -27,4 +31,4 @@ services:
container_name: archicratie-web-green
ports:
- "127.0.0.1:8082:80"
restart: unless-stopped
restart: unless-stopped

View File

@@ -0,0 +1,327 @@
# SPEC — Annotations éditoriales (YAML v1) + merge + anti-doublon
> Objectif : permettre aux tickets (Gitea) de déposer “Références / Médias / Commentaires” dans `src/annotations/**`,
> de façon univoque, stable, et sans régression.
## 0) Contexte et intention
Le site est statique. Lédition collaborative se fait via :
- un mode “proposition” (UI / modal)
- un ticket Gitea (issue) standardisé
- un script dapplication côté éditeur (`apply-ticket.mjs` ou équivalent)
- génération dun YAML dannotations versionné dans Git
La donnée dannotation doit être :
- **audit-able** (Git)
- **merge-able** (sans tout casser)
- **stable** (IDs paragraphes / liens / médias)
- **scalable** (éviter YAML monstrueux à long terme)
## 1) Arborescence canonique
### 1.1 Un workKey par “ouvrage / section du site”
On veut une univocité entre :
- SiteNav (Méthode, Essai-thèse, Traité, Cas IA, Glossaire, Atlas)
et
- larborescence annotations
Proposition canonique (workKey = route racine) :
- `methode`
- `archicrat-ia` (Essai-thèse ArchiCraT-IA)
- `traite`
- `ia`
- `glossaire`
- `atlas`
### 1.2 Règle de stockage “v1”
**Par page**, un YAML unique :
src/annotations/<workKey>/<slugSansWorkKey>.yml
Exemples :
- Page : `/archicrat-ia/prologue/`
- slug content = `archicrat-ia/prologue`
- fichier : `src/annotations/archicrat-ia/prologue.yml`
- Page : `/traite/00-demarrage/`
- fichier : `src/annotations/traite/00-demarrage.yml`
> Note : “slugSansWorkKey” = la partie après `<workKey>/`.
> Sil y a des sous-dossiers (chapitres), le chemin reflète la structure : `chapitre-1/section-a.yml` si on choisit du sharding.
## 2) Question “gros YAML” : page unique vs sharding par paragraphe
### 2.1 Option A (v1 recommandée) : 1 YAML par page
Avantages :
- simple
- peu de fichiers
- diff lisible si volume modéré
- cohérent avec un modèle “annotations par page”
Inconvénients :
- YAML peut grossir si milliers dannotations
### 2.2 Option B (v2 future) : sharding par paragraphe
src/annotations/<workKey>/<slugSansWorkKey>/<paraId>.yml
Avantages :
- fichiers petits
- merges moins conflictuels
Inconvénients :
- plus de fichiers
- tooling plus complexe (indexation + merge multi-fichiers)
### 2.3 Recommandation de mission (sans casser lexistant)
- On démarre en **Option A**.
- On se garde une migration future (v2) quand le volume réel le justifie.
- On impose dès v1 : **clé unique + merge déterministe + anti-doublon**, ce qui rend la migration future possible.
## 3) Format YAML v1 (schéma complet)
### 3.1 Top-level
en yaml :
schema: 1
# Optionnel mais recommandé (doit matcher la page)
page: "<workKey>/<slugSansWorkKey>"
meta:
title: "Titre de la page (optionnel)"
updatedAt: "2026-02-21T12:34:56Z" # ISO8601
updatedBy: "username" # compte editor
source:
kind: "ticket"
id: 123
url: "https://gitea.../issues/123"
paras:
"<paraId>":
references: []
media: []
comments: []
### 3.2 paras : clé = paraId (ex: p-0-d7974f88)
Chaque paragraphe peut porter 3 types déléments :
references
media
comments
Règle : si une section est vide, elle peut être [] ou absente.
Mais pour simplifier les merges, on recommande de garder la forme canonique avec [].
## 4) Formats des items + clés uniques
### 4.1 References
#### 4.1.1 Format
references:
- id: "ref:doi:10.1234/abcd.efgh" # clé stable (voir 4.1.2)
kind: "doi" # doi | url | isbn | arxiv | hal | other
label: "Titre court"
target: "https://doi.org/10.1234/abcd.efgh"
note: "Pourquoi cest pertinent (optionnel)"
addedAt: "2026-02-21T12:34:56Z"
addedBy: "username"
#### 4.1.2 Règle de clé unique (anti-doublon)
id doit être stable et déterministe :
doi → ref:doi:<doi>
isbn → ref:isbn:<isbn>
url → ref:url:<normalizedUrl>
Normalisation URL (v1) : au minimum
trim
lowercase scheme/host
retirer trailing slash si non significatif
conserver query si importante
#### 4.1.3 Merge / précédence
Quand on merge deux listes references :
union par id (clé unique)
si même id existe des deux côtés :
conserver kind/target de litem le plus “riche” (target non vide gagne)
concat/merge note :
si notes différentes : garder les deux en les séparant (ex: noteA + "\n---\n" + noteB)
addedAt : conserver le plus ancien
addedBy : conserver le premier (ou liste si on veut, mais v1 simple : first)
### 4.2 Media
#### 4.2.1 Format
media:
- id: "media:image:sha256:abcd..." # clé stable (voir 4.2.2)
type: "image" # image | video | audio | file
src: "/public/media/<workKey>/<slugSansWorkKey>/<paraId>/<filename>"
caption: "Légende (optionnel)"
credit: "Auteur/source (optionnel)"
license: "CC-BY (optionnel)"
addedAt: "2026-02-21T12:34:56Z"
addedBy: "username"
#### 4.2.2 Règle de clé unique
id déterministe :
idéal : hash du fichier (sha256)
sinon : hash de type + src
v1 (si on ne calcule pas de hash fichier) :
media:<type>:<src>
#### 4.2.3 Merge / précédence
union par id
si collision :
garder src identique (sinon cest un bug)
fusionner caption/credit/license selon “non vide gagne”
addedAt : plus ancien
### 4.3 Comments
#### 4.3.1 Format
comments:
- id: "cmt:20260221T123456Z:username:0001"
kind: "comment" # comment | question | objection | todo | validation
text: "Texte du commentaire"
status: "open" # open | resolved
addedAt: "2026-02-21T12:34:56Z"
addedBy: "username"
source:
kind: "ticket"
id: 123
#### 4.3.2 Clé unique
Les commentaires sont “append-only” → id peut être générée (timestamp + user + compteur)
Anti-doublon : si on ré-applique un ticket, on refuse de dupliquer un id existant.
#### 4.3.3 Merge / précédence
union par id
collisions rares, mais si elles arrivent :
si textes différents → garder les deux (on renomme lid du second)
## 5) Règles globales de merge (résumé)
Quand on applique un ticket sur un YAML existant :
vérifier schema == 1
vérifier page si présent :
doit matcher <workKey>/<slugSansWorkKey>
paras :
créer paras[paraId] si absent
pour chaque liste (references/media/comments) :
merge par id (anti-doublon)
appliquer règles de précédence (non vide gagne / concat note / append-only comments)
## 6) Table de correspondance “UI ticket → YAML”
Cette table permet à un successeur IA dimplémenter apply-ticket.mjs sans ambiguïté.
### 6.1 Champs UI minimaux
workKey (sélection implicite via page)
pagePath (ex: /archicrat-ia/prologue/)
pageSlug (ex: archicrat-ia/prologue)
paraId (ex: p-0-d7974f88)
kind :
reference
media
comment
### 6.2 Mapping exact
| UI kind | UI champs | YAML cible |
| --------- | ----------------------------------------------------------- | ---------------------------- |
| reference | kind(doi/url/isbn), target, label, note | `paras[paraId].references[]` |
| media | type(image/video/audio/file), src, caption, credit, license | `paras[paraId].media[]` |
| comment | kind(comment/question/objection/todo/validation), text | `paras[paraId].comments[]` |
### 6.3 Règles de génération dID (implémentation)
reference.id :
doi : ref:doi:${doi}
isbn : ref:isbn:${isbn}
url : ref:url:${normalize(url)}
media.id :
media:${type}:${src}
comment.id :
cmt:${timestamp}:${user}:${counter}
## 7) Validation YAML (sanity)
Avant commit (et en CI) :
YAML parse OK
schema OK
page si présent cohérent
paras est un mapping
paraId match pattern : ^p-\d+-[a-f0-9]{8}$ (existant)
src media pointe dans /public/media/... (ou /media/... si on choisit un alias, mais v1 canon : /public/media/...)
## 8) Notes de compatibilité
Les routes “Essai-thèse” ont été migrées vers /archicrat-ia/*.
Les anciennes routes /archicratie/archicrat-ia/* peuvent exister en legacy, mais la donnée canonique dannotation doit suivre le workKey final (archicrat-ia).
## 9) Ce que létape 9 devra implémenter
pipeline : ticket → YAML (apply-ticket)
index : build-annotations-index + check-annotations
tooling : détection médias orphelins / liens cassés
éventuellement : migration vers sharding par paragraphe (v2) si volume réel le justifie

176
docs/START-HERE.md Normal file
View File

@@ -0,0 +1,176 @@
# START-HERE — Archicratie / Édition Web (v2)
> Onboarding + exploitation “nickel chrome” (DEV → Gitea → CI → Release → Blue/Green → Edge/SSO)
## 0) TL;DR (la règle dor)
- **Gitea = source canonique**.
- **main est protégé** : toute modification passe par **branche → PR → CI → merge**.
- **Le NAS nest pas la source** : si un hotfix est fait sur NAS, on **backporte** via PR immédiatement.
- **Le site est statique Astro** : la prod sert du HTML (nginx), laccès est contrôlé au niveau reverse-proxy (Traefik + Authelia).
## 1) Architecture mentale (ultra simple)
- **DEV (Mac Studio)** : édition + tests + commit + push
- **Gitea** : dépôt canon + PR + CI (CI.yaml)
- **NAS (DS220+)** : déploiement “blue/green”
- `web_blue` (staging upstream) → `127.0.0.1:8081`
- `web_green` (live upstream) → `127.0.0.1:8082`
- **Edge (Traefik)** : route les hosts
- `staging.archicratie...` → 8081
- `archicratie...` → 8082
- **Authelia** devant, via middleware `chain-auth@file`
## 2) Répertoires & conventions (repo)
### 2.1 Contenu canon (édition)
- `src/content/**` : contenu MD / MDX canon (Astro content collections)
- `src/pages/**` : routes Astro (index, [...slug], etc.)
- `src/components/**` : composants UI (SiteNav, TOC, SidePanel, etc.)
- `src/layouts/**` : layouts (EditionLayout, SiteLayout)
- `src/styles/**` : CSS global
### 2.2 Annotations (pré-Édition “tickets”)
- `src/annotations/<workKey>/<slug>.yml`
- Exemple : `src/annotations/archicrat-ia/prologue.yml`
- Objectif : stocker “Références / Médias / Commentaires” par page et par paragraphe (`p-...`).
### 2.3 Scripts (tooling / build)
- `scripts/inject-anchor-aliases.mjs` : injection aliases dans dist
- `scripts/dedupe-ids-dist.mjs` : retire IDs dupliqués dans dist
- `scripts/build-para-index.mjs` : index paragraphes (postbuild / predev)
- `scripts/build-annotations-index.mjs` : index annotations (postbuild / predev)
- `scripts/check-anchors.mjs` : contrat stabilité dancres (CI)
- `scripts/check-annotations*.mjs` : sanity YAML + médias
> Important : les scripts sont **partie intégrante** de la stabilité (IDs/ancres/indexation).
> On évite “la magie” : tout est scripté + vérifié.
## 3) Workflow Git “pro” (main protégé)
### 3.1 Cycle standard (toute modif)
en bash :
git checkout main
git pull --ff-only
BR="chore/xxx-$(date +%Y%m%d)"
git checkout -b "$BR"
# dev…
npm i
npm run build
npm run test:anchors
git add -A
git commit -m "xxx: description claire"
git push -u origin "$BR"
### 3.2 PR vers main
Ouvrir PR dans Gitea
CI doit être verte
Merge PR → main
### 3.3 Cas spécial : hotfix prod (NAS)
On peut faire un hotfix “urgence” en prod/staging si nécessaire…
MAIS : létat final doit revenir dans Gitea : branche → PR → CI → merge.
## 4) Déploiement (NAS) — principe
### 4.1 Release pack
On génère un pack “reproductible” (source + config + scripts) puis on déploie.
### 4.2 Blue/Green
web_blue = staging upstream (8081)
web_green = live upstream (8082)
Edge Traefik sélectionne quel host pointe vers quel upstream.
## 5) Check-list “≤ 10 commandes” (happy path complet)
### 5.1 DEV (Mac)
git checkout main && git pull --ff-only
git checkout -b chore/my-change-$(date +%Y%m%d)
npm i
rm -rf .astro node_modules/.vite dist
npm run build
npm run test:anchors
npm run dev
### 5.2 Push + PR
git add -A
git commit -m "chore: my change"
git push -u origin chore/my-change-YYYYMMDD
# ouvrir PR dans Gitea
### 5.3 Déploiement NAS (résumé)
Voir docs/runbooks/DEPLOY-BLUE-GREEN.md.
## 6) Problèmes “classiques” + diagnostic rapide
### 6.1 “Le staging ne ressemble pas au local”
# Comparer upstream direct 8081 vs 8082 :
curl -sS http://127.0.0.1:8081/ | head -n 2
curl -sS http://127.0.0.1:8082/ | head -n 2
# Vérifier quel routeur edge répond (header diag) :
curl -sSI -H 'Host: staging.archicratie.trans-hands.synology.me' http://127.0.0.1:18080/ \
| grep -iE 'HTTP/|location:|x-archi-router'
# Lire docs/runbooks/EDGE-TRAEFIK.md.
### 6.2 Canonical incorrect (localhost en prod)
Cause racine : site dans Astro = PUBLIC_SITE non injecté au build.
Fix canonique : voir docs/runbooks/ENV-PUBLIC_SITE.md.
Test :
curl -sS http://127.0.0.1:8082/ | grep -oE 'rel="canonical" href="[^"]+"' | head -1
### 6.3 Contrat “anchors” en échec après migration dURL
Quand on déplace des routes (ex: /archicratie/archicrat-ia/* → /archicrat-ia/*), le test dancres peut échouer même si les IDs nont pas changé, car les pages ont changé de chemin.
# Procédure safe :
Backup baseline :
cp -a tests/anchors-baseline.json /tmp/anchors-baseline.json.bak.$(date +%F-%H%M%S)
Mettre à jour les clés (chemins) sans toucher aux IDs :
node - <<'NODE'
import fs from 'fs';
const p='tests/anchors-baseline.json';
const j=JSON.parse(fs.readFileSync(p,'utf8'));
const out={};
for (const [k,v] of Object.entries(j)) {
const nk = k.replace(/^archicratie\/archicrat-ia\//, 'archicrat-ia/');
out[nk]=v;
}
fs.writeFileSync(p, JSON.stringify(out,null,2)+'\n');
console.log('updated keys:', Object.keys(j).length, '->', Object.keys(out).length);
NODE
Re-run :
npm run test:anchors
## 7) Ce que létape 9 doit faire (orientation)
Stabiliser le pipeline “tickets → YAML annotations”
Formaliser la spec YAML + merge + anti-doublon (voir docs/EDITORIAL-ANNOTATIONS-SPEC.md)
Durcir lonboarding (ce START-HERE + runbooks)
Éviter les régressions par tests (anchors / annotations / smoke)

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 395 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 360 KiB

View File

@@ -0,0 +1,202 @@
# RUNBOOK — Déploiement Blue/Green (NAS DS220+)
> Objectif : déployer une release **sans casser**, avec rollback immédiat.
## 0) Portée
Ce runbook décrit le déploiement de lédition web Archicratie sur NAS (Synology), en mode blue/green :
- `web_blue` : upstream staging → `127.0.0.1:8081`
- `web_green` : upstream live → `127.0.0.1:8082`
- Edge Traefik publie :
- `staging.archicratie.trans-hands.synology.me` → 8081
- `archicratie.trans-hands.synology.me` → 8082
## 1) Pré-requis
- Accès shell NAS (user `archicratia`) + `sudo`
- Docker Compose Synology nécessite souvent :
- `sudo env DOCKER_API_VERSION=1.43 docker compose ...`
- Les fichiers edge Traefik sont dans :
- `/volume2/docker/edge/config/dynamic/`
## 2) Répertoires canon (NAS)
On considère ces chemins (adapter si besoin, mais rester cohérent) :
- Base : `/volume2/docker/archicratie-web`
- Releases : `/volume2/docker/archicratie-web/releases/YYYYMMDD-HHMMSS/app`
- Symlink actif : `/volume2/docker/archicratie-web/current` → pointe vers le `.../app` actif
## 3) Garde-fous (AVANT toute action)
### 3.1 Snapshot de létat actuel
en bash :
cd /volume2/docker/archicratie-web
ls -la current || true
readlink current || true
### 3.2 Vérifier létat live/staging upstream direct
curl -sSI http://127.0.0.1:8081/ | head -n 12
curl -sSI http://127.0.0.1:8082/ | head -n 12
### 3.3 Vérifier létat edge (host routing)
curl -sSI -H 'Host: staging.archicratie.trans-hands.synology.me' http://127.0.0.1:18080/ \
| grep -iE 'HTTP/|location:|x-archi-router' | head -n 30
curl -sSI -H 'Host: archicratie.trans-hands.synology.me' http://127.0.0.1:18080/ \
| grep -iE 'HTTP/|location:|x-archi-router' | head -n 30
Si tu nes pas authentifié, tu verras un 302 vers auth... : cest normal.
## 4) Procédure de déploiement (release pack → nouvelle release)
### 4.1 Déposer le pack
Hypothèse : tu as un .tgz “release pack” (issu de release-pack.sh) dans incoming/ :
cd /volume2/docker/archicratie-web
ls -la incoming | tail -n 20
### 4.2 Créer un répertoire release
TS="$(date +%Y%m%d-%H%M%S)"
REL="/volume2/docker/archicratie-web/releases/$TS"
APP="$REL/app"
sudo mkdir -p "$APP"
### 4.3 Extraire le pack
PKG="/volume2/docker/archicratie-web/incoming/archicratie-web.tar.gz" # adapter au nom réel
sudo tar -xzf "$PKG" -C "$APP"
### 4.4 Sanity check (fichiers attendus)
sudo test -f "$APP/Dockerfile" && echo "OK Dockerfile"
sudo test -f "$APP/docker-compose.yml" && echo "OK compose"
sudo test -f "$APP/astro.config.mjs" && echo "OK astro config"
sudo test -f "$APP/src/layouts/EditionLayout.astro" && echo "OK layout"
sudo test -f "$APP/src/pages/archicrat-ia/index.astro" && echo "OK archicrat-ia index"
sudo test -f "$APP/docs/diagrams/archicratie-web-edition-global-verbatim-v2.svg" && echo "OK diagrams"
### 4.5 Permissions (crucial sur Synology)
But : archicratia:users doit pouvoir traverser le parent + lire le contenu.
sudo chown -R archicratia:users "$REL"
sudo chmod -R u+rwX,g+rX,o-rwx "$REL"
sudo chmod 750 "$REL" "$APP"
Vérifier :
ls -ld "$REL" "$APP"
ls -la "$APP" | head
## 5) Activation : basculer current vers la nouvelle release
### 5.1 Backup du current existant
cd /volume2/docker/archicratie-web
TS2="$(date +%F-%H%M%S)"
# on backup "current" (symlink ou dossier)
if [ -e current ] || [ -L current ]; then
sudo mv -f current "current.BAK.$TS2"
echo "✅ backup: current.BAK.$TS2"
fi
### 5.2 Recréer current (symlink propre)
sudo ln -s "$APP" current
ls -la current
readlink current
sudo test -f current/docker-compose.yml && echo "✅ OK: current/docker-compose.yml"
Si cd current échoue, cest que current nest pas un symlink correct OU que le parent nest pas traversable (permissions).
## 6) Build & run : (re)construire web_blue/web_green
### 6.1 Vérifier la config compose
cd /volume2/docker/archicratie-web/current
sudo env DOCKER_API_VERSION=1.43 docker compose -f docker-compose.yml config \
| grep -nE 'services:|web_blue:|web_green:|context:|dockerfile:|PUBLIC_SITE|REQUIRE_PUBLIC_SITE' \
| sed -n '1,220p'
### 6.2 Build propre (recommandé si changement de code/config)
sudo env DOCKER_API_VERSION=1.43 docker compose build --no-cache web_blue web_green
### 6.3 Up (force recreate)
sudo env DOCKER_API_VERSION=1.43 docker compose up -d --force-recreate web_blue web_green
### 6.4 Vérifier upstream direct (8081/8082)
curl -sSI http://127.0.0.1:8081/ | head -n 12
curl -sSI http://127.0.0.1:8082/ | head -n 12
## 7) Tests de non-régression (MINIMAL CHECKLIST)
À exécuter systématiquement après up.
### 7.1 Upstreams directs
curl -sSI http://127.0.0.1:8081/ | head -n 12
curl -sSI http://127.0.0.1:8082/ | head -n 12
### 7.2 Canonical (anti “localhost en prod”)
curl -sS http://127.0.0.1:8081/ | grep -oE 'rel="canonical" href="[^"]+"' | head -n 1
curl -sS http://127.0.0.1:8082/ | grep -oE 'rel="canonical" href="[^"]+"' | head -n 1
Attendu :
blue (8081) → https://staging.archicratie.../
green (8082) → https://archicratie.../
### 7.3 Edge routing (Host header + diag)
curl -sSI -H 'Host: staging.archicratie.trans-hands.synology.me' http://127.0.0.1:18080/ \
| grep -iE 'HTTP/|location:|x-archi-router' | head -n 30
curl -sSI -H 'Host: staging.archicratie.trans-hands.synology.me' http://127.0.0.1:18080/_auth/whoami \
| grep -iE 'HTTP/|location:|x-archi-router' | head -n 30
### 7.4 Smoke UI (manuel)
Home : lien “Essai-thèse — ArchiCraT-IA” → /archicrat-ia/
TOC global : liens /archicrat-ia/* (pas de préfixe /archicratie/archicrat-ia/*)
Reading-follow/TOC local : scroll ok
## 8) Rollback (si un seul test est mauvais)
Objectif : revenir immédiatement à létat précédent.
### 8.1 Repointer current sur lancien backup
cd /volume2/docker/archicratie-web
ls -la current.BAK.* | tail -n 5
# choisir le plus récent
OLD="current.BAK.YYYY-MM-DD-HHMMSS"
sudo rm -f current
sudo ln -s "$(readlink -f "$OLD")" current 2>/dev/null || sudo ln -s "$(readlink "$OLD")" current
ls -la current
readlink current
### 8.2 Rebuild + recreate
cd /volume2/docker/archicratie-web/current
sudo env DOCKER_API_VERSION=1.43 docker compose build --no-cache web_blue web_green
sudo env DOCKER_API_VERSION=1.43 docker compose up -d --force-recreate web_blue web_green
### 8.3 Re-tester la checklist (section 7)
Si rollback OK : investiguer en environnement isolé (staging upstream uniquement, ou release dans un autre current).
## 9) Notes opérationnelles
Ne jamais modifier dist/ “à la main” sur NAS.
Si un hotfix prod est indispensable : documenter et backporter via PR Gitea.
Le canonical dépend du build : PUBLIC_SITE doit être injecté (voir runbook ENV-PUBLIC_SITE).

View File

@@ -0,0 +1,147 @@
# RUNBOOK — Edge Traefik (routing + SSO Authelia)
> Objectif : comprendre et diagnostiquer rapidement qui route quoi, et pourquoi staging/live peuvent diverger.
## 0) Portée
Edge Traefik route plusieurs hosts vers des backends locaux (127.0.0.1:*), avec Auth via Authelia.
Répertoire :
- `/volume2/docker/edge/config/dynamic/`
Port dentrée edge :
- `http://127.0.0.1:18080/` (entryPoint `web`)
- Les hosts publics pointent vers cet edge.
## 1) Fichiers dynamiques (canon)
### 00-smoke.yml
- route `/__smoke` vers le service `smoke_svc``127.0.0.1:18081`
### 10-core.yml
- définit les middlewares :
- `sanitize-remote`
- `authelia` (forwardAuth vers 9091)
- `chain-auth` (chain sanitize-remote + authelia)
### 20-archicratie-backend.yml
- définit service `archicratie_web``127.0.0.1:8082` (live upstream)
### 21-archicratie-staging.yml
- route staging host vers `127.0.0.1:8081` (staging upstream)
- applique middlewares `diag-staging@file` et `chain-auth@file`
- IMPORTANT : `diag-staging@file` doit exister
### 22-archicratie-authinfo-staging.yml
- route `/ _auth /` sur staging vers `whoami@file`
- applique `diag-staging-authinfo@file` + `chain-auth@file`
- IMPORTANT : `diag-staging-authinfo@file` doit exister
### 90-overlay-staging-fix.yml (overlay de diagnostic + fallback)
Rôle :
- **fournir** les middlewares manquants (`diag-staging`, `diag-staging-authinfo`)
- optionnel : fallback route si 21/22 sont cassés
- injecter un header `X-Archi-Router` pour identifier le routeur utilisé
### 92-overlay-live-fix.yml
- route live host `archicratie.trans-hands.synology.me``archicratie_web@file` (8082)
- route `/ _auth/whoami``whoami@file` (18081)
## 2) Diagnostiquer rapidement : quel routeur répond ?
### 2.1 Test “host header” (sans UI)
# en bash :
curl -sSI -H 'Host: staging.archicratie.trans-hands.synology.me' http://127.0.0.1:18080/ \
| grep -iE 'HTTP/|location:|x-archi-router' | head -n 30
curl -sSI -H 'Host: staging.archicratie.trans-hands.synology.me' http://127.0.0.1:18080/_auth/whoami \
| grep -iE 'HTTP/|location:|x-archi-router' | head -n 30
# Interprétation :
X-Archi-Router: staging@21 → routeur 21-archicratie-staging.yml OK
X-Archi-Router: staging-authinfo@22 → routeur authinfo OK
Si tu vois staging-fallback@90 → tu es tombé sur le fallback 90 (donc 21/22 potentiellement invalides)
### 2.2 Vérifier lupstream direct derrière edge
curl -sSI http://127.0.0.1:8081/ | head -n 12
curl -sSI http://127.0.0.1:8082/ | head -n 12
Si 8081 et 8082 servent des versions différentes : cest “normal” en blue/green, mais il faut savoir laquelle est censée être staging/live.
## 3) Diagnostiquer les erreurs Traefik (fichier invalide / middleware manquant)
### 3.1 Grep “level=error”
sudo docker logs edge-traefik --since 5m | grep -Ei 'level=error|middleware|router|service|yaml' | tail -n 80
# Cas typique :
middleware "diag-staging@file" does not exist
→ 21-archicratie-staging.yml référence un middleware absent. Solution : le définir (souvent dans 90-overlay-staging-fix.yml).
## 4) Procédure safe de modification (jamais en aveugle)
### 4.1 Backup
cd /volume2/docker/edge/config/dynamic
TS="$(date +%F-%H%M%S)"
sudo cp -a 90-overlay-staging-fix.yml "90-overlay-staging-fix.yml.bak.$TS"
### 4.2 Édition (ex : ajouter middlewares diag)
Faire une modif minimale
Ne pas casser les règles existantes (Host + PathPrefix)
Respecter les priorités (voir section 5)
### 4.3 Reload Traefik
sudo docker restart edge-traefik
### 4.4 Tests immédiats
curl -sSI -H 'Host: staging.archicratie.trans-hands.synology.me' http://127.0.0.1:18080/ \
| grep -iE 'HTTP/|location:|x-archi-router'
curl -sSI -H 'Host: staging.archicratie.trans-hands.synology.me' http://127.0.0.1:18080/_auth/whoami \
| grep -iE 'HTTP/|location:|x-archi-router'
## 5) Priorités Traefik (le point subtil)
Traefik choisit le routeur selon :
la correspondance de règle
la priority (plus grand gagne)
en cas dégalité, lordre interne (à éviter)
### 5.1 Canon pour staging
21-archicratie-staging.yml : priority 10
22-archicratie-authinfo-staging.yml : priority 10000
90-overlay-staging-fix.yml :
fallback host : priority faible (ex: 5) pour ne PAS écraser 21
fallback whoami : priority < 10000 (ex: 9000) pour ne PAS écraser 22
=> On garde 90 comme filet de sécurité / diag, pas comme “source”.
## 6) Rollback (si un changement edge casse staging/live)
cd /volume2/docker/edge/config/dynamic
# choisir le bon backup
sudo mv -f 90-overlay-staging-fix.yml "90-overlay-staging-fix.yml.BAD.$(date +%F-%H%M%S)"
sudo cp -a 90-overlay-staging-fix.yml.bak.YYYY-MM-DD-HHMMSS 90-overlay-staging-fix.yml
sudo docker restart edge-traefik
Puis re-tests section 2.
## 7) Remarques
Les 302 Authelia sont normaux si non authentifié.
Un 404 “Not Found” depuis edge alors que 8081 répond : souvent routeur manquant / invalidé / middleware absent.

View File

@@ -0,0 +1,114 @@
# RUNBOOK — PUBLIC_SITE (canonical + sitemap) “anti localhost en prod”
> Objectif : ne plus jamais voir `rel="canonical" href="http://localhost:4321/"` en staging/live.
## 0) Pourquoi cest critique
Astro génère :
- `<link rel="canonical" href="...">`
- `sitemap-index.xml`
Ces valeurs dépendent de `site` dans `astro.config.mjs`.
Si `site` vaut `http://localhost:4321` au moment du build Docker, **la prod sortira des canonical faux** :
- SEO / partage / cohérence de navigation impactés
- confusion staging/live
## 1) Règle canonique
- `astro.config.mjs` :
# en js :
site: process.env.PUBLIC_SITE ?? "http://localhost:4321"
# Donc :
En DEV local : pas besoin de PUBLIC_SITE (fallback ok)
En build “déploiement” : on DOIT fournir PUBLIC_SITE
## 2) Exigence “antifragile”
### 2.1 Dockerfile (build stage)
On injecte PUBLIC_SITE au build et on peut le rendre obligatoire :
ARG PUBLIC_SITE
ARG REQUIRE_PUBLIC_SITE=0
ENV PUBLIC_SITE=$PUBLIC_SITE
# garde-fou :
RUN if [ "$REQUIRE_PUBLIC_SITE" = "1" ] && [ -z "$PUBLIC_SITE" ]; then \
echo "ERROR: PUBLIC_SITE is required (REQUIRE_PUBLIC_SITE=1)"; exit 1; \
fi
=> Si quelquun oublie lURL en prod, le build casse au lieu de produire une release mauvaise.
## 3) docker-compose : blue/staging vs green/live
Objectif : injecter deux valeurs différentes, sans bricolage.
### 3.1 .env (NAS)
Exemple canonique :
PUBLIC_SITE_BLUE=https://staging.archicratie.trans-hands.synology.me
PUBLIC_SITE_GREEN=https://archicratie.trans-hands.synology.me
### 3.2 docker-compose.yml
web_blue :
REQUIRE_PUBLIC_SITE: "1"
PUBLIC_SITE: ${PUBLIC_SITE_BLUE}
web_green :
REQUIRE_PUBLIC_SITE: "1"
PUBLIC_SITE: ${PUBLIC_SITE_GREEN}
## 4) Tests (obligatoires après build)
### 4.1 Vérifier linjection dans compose
sudo env DOCKER_API_VERSION=1.43 docker compose config \
| grep -nE 'PUBLIC_SITE|REQUIRE_PUBLIC_SITE|web_blue:|web_green:' | sed -n '1,200p'
### 4.2 Vérifier canonical (upstream direct)
curl -sS http://127.0.0.1:8081/ | grep -oE 'rel="canonical" href="[^"]+"' | head -n 1
curl -sS http://127.0.0.1:8082/ | grep -oE 'rel="canonical" href="[^"]+"' | head -n 1
# Attendu :
blue : https://staging.../
green : https://archicratie.../
## 5) Procédure de correction (si canonical est faux)
### 5.1 Vérifier astro.config.mjs dans la release courante
cd /volume2/docker/archicratie-web/current
grep -nE 'site:\s*process\.env\.PUBLIC_SITE' astro.config.mjs
### 5.2 Vérifier que Dockerfile exporte PUBLIC_SITE
grep -nE 'ARG PUBLIC_SITE|ENV PUBLIC_SITE|REQUIRE_PUBLIC_SITE' Dockerfile
### 5.3 Vérifier .env et compose
grep -nE 'PUBLIC_SITE_BLUE|PUBLIC_SITE_GREEN' .env
grep -nE 'PUBLIC_SITE|REQUIRE_PUBLIC_SITE' docker-compose.yml
### 5.4 Rebuild + recreate
sudo env DOCKER_API_VERSION=1.43 docker compose build --no-cache web_blue web_green
sudo env DOCKER_API_VERSION=1.43 docker compose up -d --force-recreate web_blue web_green
Puis tests section 4.
## 6) Notes
Cette mécanique doit être backportée dans Gitea (source canonique), sinon ça re-cassera au prochain pack.
En DEV local, conserver le fallback http://localhost:4321 est utile et normal.

473
package-lock.json generated
View File

@@ -9,7 +9,7 @@
"version": "0.0.1",
"dependencies": {
"@astrojs/mdx": "^4.3.13",
"astro": "^5.16.11"
"astro": "^5.17.3"
},
"devDependencies": {
"@astrojs/sitemap": "^3.7.0",
@@ -1905,9 +1905,9 @@
}
},
"node_modules/astro": {
"version": "5.16.11",
"resolved": "https://registry.npmjs.org/astro/-/astro-5.16.11.tgz",
"integrity": "sha512-Z7kvkTTT5n6Hn5lCm6T3WU6pkxx84Hn25dtQ6dR7ATrBGq9eVa8EuB/h1S8xvaoVyCMZnIESu99Z9RJfdLRLDA==",
"version": "5.17.3",
"resolved": "https://registry.npmjs.org/astro/-/astro-5.17.3.tgz",
"integrity": "sha512-69dcfPe8LsHzklwj+hl+vunWUbpMB6pmg35mACjetxbJeUNNys90JaBM8ZiwsPK689SAj/4Zqb1ayaANls9/MA==",
"license": "MIT",
"dependencies": {
"@astrojs/compiler": "^2.13.0",
@@ -1933,7 +1933,7 @@
"dlv": "^1.1.3",
"dset": "^3.1.4",
"es-module-lexer": "^1.7.0",
"esbuild": "^0.25.0",
"esbuild": "^0.27.3",
"estree-walker": "^3.0.3",
"flattie": "^1.1.1",
"fontace": "~0.4.0",
@@ -1954,16 +1954,16 @@
"prompts": "^2.4.2",
"rehype": "^13.0.2",
"semver": "^7.7.3",
"shiki": "^3.20.0",
"shiki": "^3.21.0",
"smol-toml": "^1.6.0",
"svgo": "^4.0.0",
"tinyexec": "^1.0.2",
"tinyglobby": "^0.2.15",
"tsconfck": "^3.1.6",
"ultrahtml": "^1.6.0",
"unifont": "~0.7.1",
"unifont": "~0.7.3",
"unist-util-visit": "^5.0.0",
"unstorage": "^1.17.3",
"unstorage": "^1.17.4",
"vfile": "^6.0.3",
"vite": "^6.4.1",
"vitefu": "^1.1.1",
@@ -1990,6 +1990,463 @@
"sharp": "^0.34.0"
}
},
"node_modules/astro/node_modules/@esbuild/aix-ppc64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
"integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==",
"cpu": [
"ppc64"
],
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/astro/node_modules/@esbuild/android-arm": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz",
"integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/astro/node_modules/@esbuild/android-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz",
"integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/astro/node_modules/@esbuild/android-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz",
"integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/astro/node_modules/@esbuild/darwin-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz",
"integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/astro/node_modules/@esbuild/darwin-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz",
"integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/astro/node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz",
"integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/astro/node_modules/@esbuild/freebsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz",
"integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/astro/node_modules/@esbuild/linux-arm": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz",
"integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/astro/node_modules/@esbuild/linux-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz",
"integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/astro/node_modules/@esbuild/linux-ia32": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz",
"integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/astro/node_modules/@esbuild/linux-loong64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz",
"integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==",
"cpu": [
"loong64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/astro/node_modules/@esbuild/linux-mips64el": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz",
"integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==",
"cpu": [
"mips64el"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/astro/node_modules/@esbuild/linux-ppc64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz",
"integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==",
"cpu": [
"ppc64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/astro/node_modules/@esbuild/linux-riscv64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz",
"integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/astro/node_modules/@esbuild/linux-s390x": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz",
"integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==",
"cpu": [
"s390x"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/astro/node_modules/@esbuild/linux-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz",
"integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/astro/node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz",
"integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/astro/node_modules/@esbuild/netbsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz",
"integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/astro/node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz",
"integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/astro/node_modules/@esbuild/openbsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz",
"integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/astro/node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz",
"integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/astro/node_modules/@esbuild/sunos-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz",
"integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/astro/node_modules/@esbuild/win32-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz",
"integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/astro/node_modules/@esbuild/win32-ia32": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz",
"integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/astro/node_modules/@esbuild/win32-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz",
"integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/astro/node_modules/esbuild": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
"integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==",
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.3",
"@esbuild/android-arm": "0.27.3",
"@esbuild/android-arm64": "0.27.3",
"@esbuild/android-x64": "0.27.3",
"@esbuild/darwin-arm64": "0.27.3",
"@esbuild/darwin-x64": "0.27.3",
"@esbuild/freebsd-arm64": "0.27.3",
"@esbuild/freebsd-x64": "0.27.3",
"@esbuild/linux-arm": "0.27.3",
"@esbuild/linux-arm64": "0.27.3",
"@esbuild/linux-ia32": "0.27.3",
"@esbuild/linux-loong64": "0.27.3",
"@esbuild/linux-mips64el": "0.27.3",
"@esbuild/linux-ppc64": "0.27.3",
"@esbuild/linux-riscv64": "0.27.3",
"@esbuild/linux-s390x": "0.27.3",
"@esbuild/linux-x64": "0.27.3",
"@esbuild/netbsd-arm64": "0.27.3",
"@esbuild/netbsd-x64": "0.27.3",
"@esbuild/openbsd-arm64": "0.27.3",
"@esbuild/openbsd-x64": "0.27.3",
"@esbuild/openharmony-arm64": "0.27.3",
"@esbuild/sunos-x64": "0.27.3",
"@esbuild/win32-arm64": "0.27.3",
"@esbuild/win32-ia32": "0.27.3",
"@esbuild/win32-x64": "0.27.3"
}
},
"node_modules/axobject-query": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",

View File

@@ -4,32 +4,29 @@
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "astro dev",
"dev": "node scripts/write-dev-whoami.mjs && astro dev",
"preview": "astro preview",
"astro": "astro",
"clean": "rm -rf dist",
"build": "astro build",
"build:clean": "npm run clean && npm run build",
"postbuild": "node scripts/inject-anchor-aliases.mjs && node scripts/dedupe-ids-dist.mjs && npx pagefind --site dist",
"postbuild": "node scripts/inject-anchor-aliases.mjs && node scripts/dedupe-ids-dist.mjs && node scripts/build-para-index.mjs && node scripts/build-annotations-index.mjs && node scripts/purge-dist-dev-whoami.mjs && npx pagefind --site dist",
"import": "node scripts/import-docx.mjs",
"apply:ticket": "node scripts/apply-ticket.mjs",
"audit:dist": "node scripts/audit-dist.mjs",
"build:para-index": "node scripts/build-para-index.mjs",
"build:annotations-index": "node scripts/build-annotations-index.mjs",
"test:aliases": "node scripts/check-anchor-aliases.mjs",
"test:anchors": "node scripts/check-anchors.mjs",
"test:anchors:update": "node scripts/check-anchors.mjs --update",
"test": "npm run test:aliases && npm run build:clean && npm run audit:dist && node scripts/verify-anchor-aliases-in-dist.mjs && npm run test:anchors && node scripts/check-inline-js.mjs",
"test:annotations": "node scripts/check-annotations.mjs",
"test:annotations:media": "node scripts/check-annotations-media.mjs",
"test": "npm run test:aliases && npm run build:clean && npm run audit:dist && node scripts/verify-anchor-aliases-in-dist.mjs && npm run test:anchors && npm run test:annotations && npm run test:annotations:media && node scripts/check-inline-js.mjs",
"ci": "CI=1 npm test"
},
"dependencies": {
"@astrojs/mdx": "^4.3.13",
"astro": "^5.16.11"
"astro": "^5.17.3"
},
"devDependencies": {
"@astrojs/sitemap": "^3.7.0",

Binary file not shown.

After

Width:  |  Height:  |  Size: 822 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 822 KiB

View File

@@ -0,0 +1,688 @@
#!/usr/bin/env node
// scripts/apply-annotation-ticket.mjs
// Applique un ticket Gitea "type/media | type/reference | type/comment" vers src/annotations + public/media
// Robuste, idempotent, non destructif
//
// DRY RUN par défaut si --dry-run
// Options: --dry-run --no-download --verify --strict --commit --close
//
// Env requis:
// FORGE_API = base API Gitea (LAN) ex: http://192.168.1.20:3000
// FORGE_TOKEN = PAT Gitea (repo + issues)
//
// Env optionnel:
// GITEA_OWNER / GITEA_REPO (sinon auto-détecté via git remote)
// ANNO_DIR (défaut: src/annotations)
// PUBLIC_DIR (défaut: public)
// MEDIA_ROOT (défaut URL: /media)
//
// Ticket attendu (body):
// Chemin: /archicrat-ia/chapitre-4/
// Ancre: #p-0-xxxxxxxx
// Type: type/media | type/reference | type/comment
//
// Exit codes:
// 0 ok
// 1 erreur fatale
// 2 refus (strict/verify/usage)
import fs from "node:fs/promises";
import path from "node:path";
import process from "node:process";
import { spawnSync } from "node:child_process";
import YAML from "yaml";
/* ---------------------------------- usage --------------------------------- */
function usage(exitCode = 0) {
console.log(`
apply-annotation-ticket — applique un ticket SidePanel (media/ref/comment) vers src/annotations/
Usage:
node scripts/apply-annotation-ticket.mjs <issue_number> [--dry-run] [--no-download] [--verify] [--strict] [--commit] [--close]
Flags:
--dry-run : n'écrit rien (affiche un aperçu)
--no-download : n'essaie pas de télécharger les pièces jointes (media)
--verify : tente de vérifier que (page, ancre) existent (baseline/dist si dispo)
--strict : refuse si URL ref invalide (http/https) OU caption media vide
--commit : git add + git commit (le script commit dans la branche courante)
--close : ferme le ticket (nécessite --commit)
Env requis:
FORGE_API = base API Gitea (LAN) ex: http://192.168.1.20:3000
FORGE_TOKEN = PAT Gitea (repo + issues)
Env optionnel:
GITEA_OWNER / GITEA_REPO (sinon auto-détecté via git remote)
ANNO_DIR (défaut: src/annotations)
PUBLIC_DIR (défaut: public)
MEDIA_ROOT (défaut URL: /media) -> écrit dans public/media/...
Exit codes:
0 ok
1 erreur fatale
2 refus (strict/verify/close sans commit / incohérence)
`);
process.exit(exitCode);
}
/* ---------------------------------- args ---------------------------------- */
const argv = process.argv.slice(2);
if (argv.length === 0 || argv.includes("--help") || argv.includes("-h")) usage(0);
const issueNum = Number(argv[0]);
if (!Number.isFinite(issueNum) || issueNum <= 0) {
console.error("❌ Numéro de ticket invalide.");
usage(2);
}
const DRY_RUN = argv.includes("--dry-run");
const NO_DOWNLOAD = argv.includes("--no-download");
const DO_VERIFY = argv.includes("--verify");
const STRICT = argv.includes("--strict");
const DO_COMMIT = argv.includes("--commit");
const DO_CLOSE = argv.includes("--close");
if (DO_CLOSE && !DO_COMMIT) {
console.error("❌ --close nécessite --commit.");
process.exit(2);
}
if (typeof fetch !== "function") {
console.error("❌ fetch() indisponible. Utilise Node 18+.");
process.exit(1);
}
/* --------------------------------- config --------------------------------- */
const CWD = process.cwd();
const ANNO_DIR = path.join(CWD, process.env.ANNO_DIR || "src", "annotations");
const PUBLIC_DIR = path.join(CWD, process.env.PUBLIC_DIR || "public");
const MEDIA_URL_ROOT = String(process.env.MEDIA_ROOT || "/media").replace(/\/+$/, "");
function getEnv(name, fallback = "") {
return (process.env[name] ?? fallback).trim();
}
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 exists(p) {
try { await fs.access(p); return true; } catch { return false; }
}
function inferOwnerRepoFromGit() {
const r = spawnSync("git", ["remote", "get-url", "origin"], { encoding: "utf-8" });
if (r.status !== 0) return null;
const u = (r.stdout || "").trim();
const m = u.match(/[:/](?<owner>[^/]+)\/(?<repo>[^/]+?)(?:\.git)?$/);
if (!m?.groups) return null;
return { owner: m.groups.owner, repo: m.groups.repo };
}
function gitHasStagedChanges() {
const r = spawnSync("git", ["diff", "--cached", "--quiet"]);
return r.status === 1;
}
/* ------------------------------ gitea helpers ------------------------------ */
function apiBaseNorm(forgeApiBase) {
return forgeApiBase.replace(/\/+$/, "");
}
async function giteaGET(url, token) {
const res = await fetch(url, {
headers: {
Authorization: `token ${token}`,
Accept: "application/json",
"User-Agent": "archicratie-apply-annotation/1.0",
},
});
if (!res.ok) {
const t = await res.text().catch(() => "");
throw new Error(`HTTP ${res.status} GET ${url}\n${t}`);
}
return await res.json();
}
async function fetchIssue({ forgeApiBase, owner, repo, token, issueNum }) {
const url = `${apiBaseNorm(forgeApiBase)}/api/v1/repos/${owner}/${repo}/issues/${issueNum}`;
return await giteaGET(url, token);
}
async function fetchIssueAssets({ forgeApiBase, owner, repo, token, issueNum }) {
// ✅ Gitea: /issues/{index}/assets
const url = `${apiBaseNorm(forgeApiBase)}/api/v1/repos/${owner}/${repo}/issues/${issueNum}/assets`;
try {
const json = await giteaGET(url, token);
return Array.isArray(json) ? json : [];
} catch {
return [];
}
}
async function postIssueComment({ forgeApiBase, owner, repo, token, issueNum, comment }) {
const url = `${apiBaseNorm(forgeApiBase)}/api/v1/repos/${owner}/${repo}/issues/${issueNum}/comments`;
const res = await fetch(url, {
method: "POST",
headers: {
Authorization: `token ${token}`,
Accept: "application/json",
"Content-Type": "application/json",
"User-Agent": "archicratie-apply-annotation/1.0",
},
body: JSON.stringify({ body: comment }),
});
if (!res.ok) {
const t = await res.text().catch(() => "");
throw new Error(`HTTP ${res.status} POST comment ${url}\n${t}`);
}
}
async function closeIssue({ forgeApiBase, owner, repo, token, issueNum, comment }) {
if (comment) await postIssueComment({ forgeApiBase, owner, repo, token, issueNum, comment });
const url = `${apiBaseNorm(forgeApiBase)}/api/v1/repos/${owner}/${repo}/issues/${issueNum}`;
const res = await fetch(url, {
method: "PATCH",
headers: {
Authorization: `token ${token}`,
Accept: "application/json",
"Content-Type": "application/json",
"User-Agent": "archicratie-apply-annotation/1.0",
},
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}`);
}
}
/* ------------------------------ parsing helpers ---------------------------- */
function escapeRegExp(s) {
return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function pickLine(body, key) {
const re = new RegExp(`^\\s*${escapeRegExp(key)}\\s*:\\s*([^\\n\\r]+)`, "mi");
const m = String(body || "").match(re);
return m ? m[1].trim() : "";
}
function pickSection(body, markers) {
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)
.sort((a, b) => a.i - b.i)[0];
if (!idx) return "";
const start = idx.i + idx.m.length;
const tail = text.slice(start);
const stops = [
"\n## ",
"\n---",
"\nJustification",
"\nProposition",
"\nSources",
];
let end = tail.length;
for (const s of stops) {
const j = tail.toLowerCase().indexOf(s.toLowerCase());
if (j >= 0 && j < end) end = j;
}
return tail.slice(0, end).trim();
}
function normalizeChemin(chemin) {
let c = String(chemin || "").trim();
if (!c) return "";
if (!c.startsWith("/")) c = "/" + c;
if (!c.endsWith("/")) c = c + "/";
c = c.replace(/\/{2,}/g, "/");
return c;
}
function normalizePageKeyFromChemin(chemin) {
return normalizeChemin(chemin).replace(/^\/+|\/+$/g, "");
}
function normalizeAnchorId(s) {
let a = String(s || "").trim();
if (a.startsWith("#")) a = a.slice(1);
return a;
}
function assert(cond, msg, code = 1) {
if (!cond) {
const e = new Error(msg);
e.__exitCode = code;
throw e;
}
}
function isPlainObject(x) {
return !!x && typeof x === "object" && !Array.isArray(x);
}
/* ----------------------------- verify helpers ------------------------------ */
function paraIndexFromId(id) {
const m = String(id).match(/^p-(\d+)-/i);
return m ? Number(m[1]) : Number.NaN;
}
async function tryVerifyAnchor(pageKey, anchorId) {
// 1) dist/para-index.json (si build déjà faite)
const distIdx = path.join(CWD, "dist", "para-index.json");
if (await exists(distIdx)) {
const raw = await fs.readFile(distIdx, "utf8");
const idx = JSON.parse(raw);
const byId = idx?.byId;
if (byId && typeof byId === "object" && byId[anchorId] != null) return true;
}
// 2) tests/anchors-baseline.json (si dispo)
const base = path.join(CWD, "tests", "anchors-baseline.json");
if (await exists(base)) {
const raw = await fs.readFile(base, "utf8");
const j = JSON.parse(raw);
// tolérant: cherche un array d'ids associé à la page
const candidates = [];
// cas 1: j.pages[...]
if (j?.pages && typeof j.pages === "object") {
for (const [k, v] of Object.entries(j.pages)) {
if (!Array.isArray(v)) continue;
// on matche large: pageKey inclus dans le path
if (String(k).includes(pageKey)) candidates.push(...v);
}
}
// cas 2: j.entries = [{page, ids}]
if (Array.isArray(j?.entries)) {
for (const it of j.entries) {
const p = String(it?.page || "");
const ids = it?.ids;
if (Array.isArray(ids) && p.includes(pageKey)) candidates.push(...ids);
}
}
if (candidates.length) {
return candidates.some((x) => String(x) === anchorId);
}
}
// impossible à vérifier
return null;
}
/* ----------------------------- annotations I/O ----------------------------- */
async function loadAnnoDoc(fileAbs, pageKey) {
if (!(await exists(fileAbs))) {
return { schema: 1, page: pageKey, paras: {} };
}
const raw = await fs.readFile(fileAbs, "utf8");
let doc;
try {
doc = YAML.parse(raw);
} catch (e) {
throw new Error(`${path.relative(CWD, fileAbs)}: parse failed: ${String(e?.message ?? e)}`);
}
assert(isPlainObject(doc), `${path.relative(CWD, fileAbs)}: doc must be an object`);
assert(doc.schema === 1, `${path.relative(CWD, fileAbs)}: schema must be 1`);
assert(isPlainObject(doc.paras), `${path.relative(CWD, fileAbs)}: missing object key "paras"`);
if (doc.page != null) {
const got = String(doc.page).replace(/^\/+/, "").replace(/\/+$/, "");
assert(got === pageKey, `${path.relative(CWD, fileAbs)}: page mismatch (page="${doc.page}" vs path="${pageKey}")`);
} else {
doc.page = pageKey;
}
return doc;
}
function sortParasObject(paras) {
const keys = Object.keys(paras || {});
keys.sort((a, b) => {
const ia = paraIndexFromId(a);
const ib = paraIndexFromId(b);
if (Number.isFinite(ia) && Number.isFinite(ib) && ia !== ib) return ia - ib;
return String(a).localeCompare(String(b));
});
const out = {};
for (const k of keys) out[k] = paras[k];
return out;
}
async function saveAnnoDocYaml(fileAbs, doc) {
await fs.mkdir(path.dirname(fileAbs), { recursive: true });
doc.paras = sortParasObject(doc.paras);
const out = YAML.stringify(doc);
await fs.writeFile(fileAbs, out, "utf8");
}
/* ------------------------------ apply per type ----------------------------- */
function ensureEntry(doc, paraId) {
if (!doc.paras[paraId] || !isPlainObject(doc.paras[paraId])) doc.paras[paraId] = {};
return doc.paras[paraId];
}
function uniqPush(arr, item, keyFn) {
const k = keyFn(item);
const exists = arr.some((x) => keyFn(x) === k);
if (!exists) arr.push(item);
return !exists;
}
function stableSortByTs(arr) {
if (!Array.isArray(arr)) return;
arr.sort((a, b) => {
const ta = Date.parse(a?.ts || "") || 0;
const tb = Date.parse(b?.ts || "") || 0;
if (ta !== tb) return ta - tb;
return JSON.stringify(a).localeCompare(JSON.stringify(b));
});
}
function parseReferenceBlock(body) {
const block =
pickSection(body, ["Référence (à compléter):", "Reference (à compléter):"]) ||
pickSection(body, ["Référence:", "Reference:"]);
const lines = String(block || "").split(/\r?\n/).map((l) => l.trim());
const get = (k) => {
const re = new RegExp(`^[-*]\\s*${escapeRegExp(k)}\\s*:\\s*(.*)$`, "i");
const m = lines.map((l) => l.match(re)).find(Boolean);
return (m?.[1] ?? "").trim();
};
return {
url: get("URL") || "",
label: get("Label") || "",
kind: get("Kind") || "",
citation: get("Citation") || get("Passage") || get("Extrait") || "",
rawBlock: block || "",
};
}
function inferMediaTypeFromFilename(name) {
const n = String(name || "").toLowerCase();
if (/\.(png|jpe?g|webp|gif|svg)$/.test(n)) return "image";
if (/\.(mp4|webm|mov|m4v)$/.test(n)) return "video";
if (/\.(mp3|wav|ogg|m4a)$/.test(n)) return "audio";
return "link";
}
function sanitizeFilename(name) {
return String(name || "file")
.replace(/[\/\\]/g, "_")
.replace(/[^\w.\-]+/g, "_")
.replace(/_+/g, "_")
.slice(0, 180);
}
function isHttpUrl(u) {
try {
const x = new URL(String(u));
return x.protocol === "http:" || x.protocol === "https:";
} catch {
return false;
}
}
async function downloadToFile(url, token, destAbs) {
const res = await fetch(url, {
headers: {
// la plupart des /attachments sont publics, mais on garde le token “au cas où”
Authorization: `token ${token}`,
"User-Agent": "archicratie-apply-annotation/1.0",
},
redirect: "follow",
});
if (!res.ok) {
const t = await res.text().catch(() => "");
throw new Error(`download failed HTTP ${res.status}: ${url}\n${t}`);
}
const buf = Buffer.from(await res.arrayBuffer());
await fs.mkdir(path.dirname(destAbs), { recursive: true });
await fs.writeFile(destAbs, buf);
return buf.length;
}
/* ----------------------------------- main ---------------------------------- */
async function main() {
const token = getEnv("FORGE_TOKEN");
assert(token, "❌ FORGE_TOKEN manquant.", 2);
const forgeApiBase = getEnv("FORGE_API") || getEnv("FORGE_BASE");
assert(forgeApiBase, "❌ FORGE_API (ou FORGE_BASE) manquant.", 2);
const inferred = inferOwnerRepoFromGit() || {};
const owner = getEnv("GITEA_OWNER", inferred.owner || "");
const repo = getEnv("GITEA_REPO", inferred.repo || "");
assert(owner && repo, "❌ Impossible de déterminer owner/repo. Fix: export GITEA_OWNER=... GITEA_REPO=...", 2);
console.log(`🔎 Fetch ticket #${issueNum} from ${owner}/${repo}`);
const issue = await fetchIssue({ forgeApiBase, owner, repo, token, issueNum });
if (issue?.pull_request) {
console.error(`❌ #${issueNum} est une Pull Request, pas un ticket annotations.`);
process.exit(2);
}
const body = String(issue.body || "").replace(/\r\n/g, "\n");
const title = String(issue.title || "");
const type = pickLine(body, "Type").toLowerCase();
const chemin = normalizeChemin(pickLine(body, "Chemin"));
const ancre = normalizeAnchorId(pickLine(body, "Ancre"));
assert(chemin, "Ticket: Chemin manquant.", 2);
assert(ancre && /^p-\d+-/i.test(ancre), `Ticket: Ancre invalide ("${ancre}")`, 2);
assert(type, "Ticket: Type manquant.", 2);
const pageKey = normalizePageKeyFromChemin(chemin);
assert(pageKey, "Ticket: impossible de dériver pageKey.", 2);
if (DO_VERIFY) {
const ok = await tryVerifyAnchor(pageKey, ancre);
if (ok === false) {
throw Object.assign(new Error(`Ticket verify: ancre introuvable pour page "${pageKey}" => ${ancre}`), { __exitCode: 2 });
}
if (ok === null) {
// pas de source de vérité dispo
if (STRICT) throw Object.assign(new Error(`Ticket verify (strict): impossible de vérifier (pas de baseline/dist)`), { __exitCode: 2 });
console.warn("⚠️ verify: impossible de vérifier (pas de baseline/dist) — on continue.");
}
}
const annoFileAbs = path.join(ANNO_DIR, `${pageKey}.yml`);
const annoFileRel = path.relative(CWD, annoFileAbs).replace(/\\/g, "/");
console.log("✅ Parsed:", { type, chemin, ancre: `#${ancre}`, pageKey, annoFile: annoFileRel });
const doc = await loadAnnoDoc(annoFileAbs, pageKey);
const entry = ensureEntry(doc, ancre);
const touchedFiles = [];
const notes = [];
let changed = false;
const nowIso = new Date().toISOString();
if (type === "type/comment") {
const comment = pickSection(body, ["Commentaire:", "Comment:", "Commentaires:"]) || "";
const text = comment.trim();
assert(text.length >= 3, "Ticket comment: bloc 'Commentaire:' introuvable ou trop court.", 2);
if (!Array.isArray(entry.comments_editorial)) entry.comments_editorial = [];
const item = { text, status: "new", ts: nowIso, fromIssue: issueNum };
const added = uniqPush(entry.comments_editorial, item, (x) => `${(x?.text || "").trim()}`);
if (added) { changed = true; notes.push(`+ comment added (len=${text.length})`); }
else notes.push(`~ comment already present (dedup)`);
stableSortByTs(entry.comments_editorial);
}
else if (type === "type/reference") {
const ref = parseReferenceBlock(body);
assert(ref.url || ref.label, "Ticket reference: renseigne au moins - URL: ou - Label: dans le ticket.", 2);
if (STRICT && ref.url && !isHttpUrl(ref.url)) {
throw Object.assign(new Error(`Ticket reference (strict): URL invalide (http/https requis): "${ref.url}"`), { __exitCode: 2 });
}
if (!Array.isArray(entry.refs)) entry.refs = [];
const item = {
url: ref.url || "",
label: ref.label || (ref.url ? ref.url : "Référence"),
kind: ref.kind || "",
ts: nowIso,
fromIssue: issueNum,
};
if (ref.citation) item.citation = ref.citation;
const added = uniqPush(entry.refs, item, (x) => `${x?.url || ""}||${x?.label || ""}||${x?.kind || ""}||${x?.citation || ""}`);
if (added) { changed = true; notes.push(`+ reference added (${item.url ? "url" : "label"})`); }
else notes.push(`~ reference already present (dedup)`);
stableSortByTs(entry.refs);
}
else if (type === "type/media") {
if (!Array.isArray(entry.media)) entry.media = [];
const atts = NO_DOWNLOAD ? [] : await fetchIssueAssets({ forgeApiBase, owner, repo, token, issueNum });
if (!atts.length) {
notes.push("! no assets found (nothing to download).");
}
for (const a of atts) {
const name = sanitizeFilename(a?.name || `asset-${a?.id || "x"}`);
const dl = a?.browser_download_url || a?.download_url || "";
if (!dl) { notes.push(`! asset missing download url: ${name}`); continue; }
// caption = title du ticket (fallback ".")
const caption = (title || "").trim() || ".";
if (STRICT && !caption.trim()) {
throw Object.assign(new Error("Ticket media (strict): caption vide."), { __exitCode: 2 });
}
const mediaDirAbs = path.join(PUBLIC_DIR, "media", pageKey, ancre);
const destAbs = path.join(mediaDirAbs, name);
const urlPath = `${MEDIA_URL_ROOT}/${pageKey}/${ancre}/${name}`.replace(/\/{2,}/g, "/");
if (await exists(destAbs)) {
notes.push(`~ media already exists: ${urlPath}`);
} else if (!DRY_RUN) {
const bytes = await downloadToFile(dl, token, destAbs);
notes.push(`+ downloaded ${name} (${bytes} bytes) -> ${urlPath}`);
touchedFiles.push(path.relative(CWD, destAbs).replace(/\\/g, "/"));
} else {
notes.push(`(dry) would download ${name} -> ${urlPath}`);
}
const item = {
type: inferMediaTypeFromFilename(name),
src: urlPath,
caption,
credit: "",
ts: nowIso,
fromIssue: issueNum,
};
const added = uniqPush(entry.media, item, (x) => String(x?.src || ""));
if (added) changed = true;
}
stableSortByTs(entry.media);
}
else {
throw Object.assign(new Error(`Type non supporté: "${type}"`), { __exitCode: 2 });
}
if (!changed) {
console.log(" No changes to apply.");
for (const n of notes) console.log(" ", n);
return;
}
if (DRY_RUN) {
console.log("\n--- DRY RUN (no write) ---");
console.log(`Would update: ${annoFileRel}`);
for (const n of notes) console.log(" ", n);
console.log("\nExcerpt (resulting entry):");
console.log(YAML.stringify({ [ancre]: doc.paras[ancre] }).trimEnd());
console.log("\n✅ Dry-run terminé.");
return;
}
await saveAnnoDocYaml(annoFileAbs, doc);
touchedFiles.unshift(annoFileRel);
console.log(`✅ Updated: ${annoFileRel}`);
for (const n of notes) console.log(" ", n);
if (DO_COMMIT) {
run("git", ["add", ...touchedFiles], { cwd: CWD });
if (!gitHasStagedChanges()) {
console.log(" Nothing to commit (aucun changement staged).");
return;
}
const msg = `anno: apply ticket #${issueNum} (${pageKey}#${ancre} ${type})`;
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-annotation-ticket.\nCommit: ${sha}`;
await closeIssue({ forgeApiBase, owner, repo, token, issueNum, comment });
console.log(`✅ Ticket #${issueNum} fermé.`);
}
} else {
console.log("\nNext (manuel) :");
console.log(` git diff -- ${touchedFiles[0]}`);
console.log(` git add ${touchedFiles.join(" ")}`);
console.log(` git commit -m "anno: apply ticket #${issueNum} (${pageKey}#${ancre} ${type})"`);
}
}
main().catch((e) => {
const code = e?.__exitCode || 1;
console.error("💥", e?.message || e);
process.exit(code);
});

View File

@@ -0,0 +1,159 @@
// scripts/build-annotations-index.mjs
import fs from "node:fs/promises";
import path from "node:path";
import YAML from "yaml";
function parseArgs(argv) {
const out = {
inDir: "src/annotations",
outFile: "dist/annotations-index.json",
};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === "--in" && argv[i + 1]) out.inDir = argv[++i];
else if (a.startsWith("--in=")) out.inDir = a.slice("--in=".length);
if (a === "--out" && argv[i + 1]) out.outFile = argv[++i];
else if (a.startsWith("--out=")) out.outFile = a.slice("--out=".length);
}
return out;
}
async function exists(p) {
try { await fs.access(p); return true; } catch { return false; }
}
async function walk(dir) {
const out = [];
const ents = await fs.readdir(dir, { withFileTypes: true });
for (const e of ents) {
const p = path.join(dir, e.name);
if (e.isDirectory()) out.push(...(await walk(p)));
else out.push(p);
}
return out;
}
function inferPageKeyFromFile(inDirAbs, fileAbs) {
// src/annotations/<page>.yml -> "<page>"
const rel = path.relative(inDirAbs, fileAbs).replace(/\\/g, "/");
return rel.replace(/\.(ya?ml|json)$/i, "");
}
function assert(cond, msg) {
if (!cond) throw new Error(msg);
}
function isPlainObject(x) {
return !!x && typeof x === "object" && !Array.isArray(x);
}
function normalizePageKey(s) {
// pas de / en tête/fin
return String(s || "").replace(/^\/+/, "").replace(/\/+$/, "");
}
function validateAndNormalizeDoc(doc, pageKey, fileRel) {
assert(isPlainObject(doc), `${fileRel}: document must be an object`);
assert(doc.schema === 1, `${fileRel}: schema must be 1`);
if (doc.page != null) {
assert(
normalizePageKey(doc.page) === pageKey,
`${fileRel}: page mismatch (page="${doc.page}" vs path="${pageKey}")`
);
}
assert(isPlainObject(doc.paras), `${fileRel}: missing object key "paras"`);
const parasOut = Object.create(null);
for (const [paraId, entry] of Object.entries(doc.paras)) {
assert(/^p-\d+-/i.test(paraId), `${fileRel}: invalid para id "${paraId}"`);
// entry peut être vide, mais doit être un objet si présent
assert(entry == null || isPlainObject(entry), `${fileRel}: paras.${paraId} must be an object`);
const e = entry ? { ...entry } : {};
// Sanity checks (non destructifs : on nécrase pas, on vérifie juste les types)
if (e.refs != null) assert(Array.isArray(e.refs), `${fileRel}: paras.${paraId}.refs must be an array`);
if (e.authors != null) assert(Array.isArray(e.authors), `${fileRel}: paras.${paraId}.authors must be an array`);
if (e.quotes != null) assert(Array.isArray(e.quotes), `${fileRel}: paras.${paraId}.quotes must be an array`);
if (e.media != null) assert(Array.isArray(e.media), `${fileRel}: paras.${paraId}.media must be an array`);
if (e.comments_editorial != null) assert(Array.isArray(e.comments_editorial), `${fileRel}: paras.${paraId}.comments_editorial must be an array`);
parasOut[paraId] = e;
}
return parasOut;
}
async function readDoc(fileAbs) {
const raw = await fs.readFile(fileAbs, "utf8");
if (/\.json$/i.test(fileAbs)) return JSON.parse(raw);
return YAML.parse(raw);
}
async function main() {
const { inDir, outFile } = parseArgs(process.argv.slice(2));
const CWD = process.cwd();
const inDirAbs = path.isAbsolute(inDir) ? inDir : path.join(CWD, inDir);
const outAbs = path.isAbsolute(outFile) ? outFile : path.join(CWD, outFile);
// antifragile
if (!(await exists(inDirAbs))) {
console.log(` annotations-index: skip (input missing): ${inDir}`);
process.exit(0);
}
const files = (await walk(inDirAbs)).filter((p) => /\.(ya?ml|json)$/i.test(p));
if (!files.length) {
console.log(` annotations-index: skip (no .yml/.yaml/.json found in): ${inDir}`);
process.exit(0);
}
const pages = Object.create(null);
let paraCount = 0;
for (const f of files) {
const fileRel = path.relative(CWD, f).replace(/\\/g, "/");
const pageKey = normalizePageKey(inferPageKeyFromFile(inDirAbs, f));
assert(pageKey, `${fileRel}: cannot infer page key`);
let doc;
try {
doc = await readDoc(f);
} catch (e) {
throw new Error(`${fileRel}: parse failed: ${String(e?.message ?? e)}`);
}
const paras = validateAndNormalizeDoc(doc, pageKey, fileRel);
// 1 fichier = 1 page (canon)
assert(!pages[pageKey], `${fileRel}: duplicate page "${pageKey}" (only one file per page)`);
pages[pageKey] = { paras };
paraCount += Object.keys(paras).length;
}
const out = {
schema: 1,
generatedAt: new Date().toISOString(),
pages,
stats: {
pages: Object.keys(pages).length,
paras: paraCount,
},
};
await fs.mkdir(path.dirname(outAbs), { recursive: true });
await fs.writeFile(outAbs, JSON.stringify(out), "utf8");
console.log(`✅ annotations-index: pages=${out.stats.pages} paras=${out.stats.paras} -> ${path.relative(CWD, outAbs)}`);
}
main().catch((e) => {
console.error("FAIL: build-annotations-index crashed:", e);
process.exit(1);
});

View File

@@ -0,0 +1,97 @@
import fs from "node:fs/promises";
import path from "node:path";
import YAML from "yaml";
const CWD = process.cwd();
const ANNO_DIR = path.join(CWD, "src", "annotations");
const PUBLIC_DIR = path.join(CWD, "public");
async function exists(p) {
try { await fs.access(p); return true; } catch { return false; }
}
async function walk(dir) {
const out = [];
const ents = await fs.readdir(dir, { withFileTypes: true });
for (const e of ents) {
const p = path.join(dir, e.name);
if (e.isDirectory()) out.push(...(await walk(p)));
else out.push(p);
}
return out;
}
function parseDoc(raw, fileAbs) {
if (/\.json$/i.test(fileAbs)) return JSON.parse(raw);
return YAML.parse(raw);
}
function isPlainObject(x) {
return !!x && typeof x === "object" && !Array.isArray(x);
}
function toPublicPathFromUrl(urlPath) {
// "/media/..." -> "public/media/..."
const clean = String(urlPath || "").split("?")[0].split("#")[0];
if (!clean.startsWith("/media/")) return null;
return path.join(PUBLIC_DIR, clean.replace(/^\/+/, ""));
}
async function main() {
if (!(await exists(ANNO_DIR))) {
console.log("✅ annotations-media: aucun src/annotations — rien à vérifier.");
process.exit(0);
}
const files = (await walk(ANNO_DIR)).filter((p) => /\.(ya?ml|json)$/i.test(p));
let checked = 0;
let missing = 0;
const notes = [];
for (const f of files) {
const rel = path.relative(CWD, f).replace(/\\/g, "/");
const raw = await fs.readFile(f, "utf8");
let doc;
try { doc = parseDoc(raw, f); }
catch (e) {
missing++;
notes.push(`- PARSE FAIL: ${rel} (${String(e?.message ?? e)})`);
continue;
}
if (!isPlainObject(doc) || doc.schema !== 1 || !isPlainObject(doc.paras)) continue;
for (const [paraId, entry] of Object.entries(doc.paras)) {
const media = entry?.media;
if (!Array.isArray(media)) continue;
for (const m of media) {
const src = String(m?.src || "");
if (!src.startsWith("/media/")) continue; // externes ok, ou autres conventions futures
checked++;
const p = toPublicPathFromUrl(src);
if (!p) continue;
if (!(await exists(p))) {
missing++;
notes.push(`- MISSING MEDIA: ${src} (from ${rel} para ${paraId})`);
}
}
}
}
if (missing > 0) {
console.error(`FAIL: annotations media missing (checked=${checked} missing=${missing})`);
for (const n of notes) console.error(n);
process.exit(1);
}
console.log(`✅ annotations-media OK: checked=${checked}`);
}
main().catch((e) => {
console.error("FAIL: check-annotations-media crashed:", e);
process.exit(1);
});

View File

@@ -60,10 +60,12 @@ function getAlias(aliases, pageKey, oldId) {
// supporte:
// 1) { "<pageKey>": { "<old>": "<new>" } }
// 2) { "<old>": "<new>" }
const a1 = aliases?.[pageKey]?.[oldId];
if (a1) return a1;
const k1 = String(pageKey || "");
const k2 = k1 ? ("/" + k1.replace(/^\/+|\/+$/g, "") + "/") : "";
const a1 = (aliases?.[k1]?.[oldId]) || (k2 ? aliases?.[k2]?.[oldId] : "");
if (a1) return String(a1);
const a2 = aliases?.[oldId];
if (a2) return a2;
if (a2) return String(a2);
return "";
}

View File

@@ -14,6 +14,24 @@ const STRICT = argv.includes("--strict") || process.env.CI === "1" || process.en
function escRe(s) {
return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
async function exists(p) {
try {
await fs.access(p);
return true;
} catch {
return false;
}
}
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;
}
function countIdAttr(html, id) {
const re = new RegExp(`\\bid=(["'])${escRe(id)}\\1`, "gi");
let c = 0;
@@ -22,7 +40,6 @@ function countIdAttr(html, id) {
}
function findStartTagWithId(html, id) {
// 1er élément qui porte id="..."
const re = new RegExp(
`<([a-zA-Z0-9:-]+)\\b[^>]*\\bid=(["'])${escRe(id)}\\2[^>]*>`,
"i"
@@ -36,34 +53,10 @@ function isInjectedAliasSpan(html, id) {
const found = findStartTagWithId(html, id);
if (!found) return false;
if (found.tagName !== "span") return false;
// class="... para-alias ..."
return /\bclass=(["'])(?:(?!\1).)*\bpara-alias\b(?:(?!\1).)*\1/i.test(found.tag);
}
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"
@@ -82,6 +75,7 @@ async function main() {
}
const raw = await fs.readFile(ALIASES_PATH, "utf-8");
/** @type {Record<string, Record<string,string>>} */
let aliases;
try {
@@ -89,6 +83,7 @@ async function main() {
} catch (e) {
throw new Error(`JSON invalide: ${ALIASES_PATH} (${e?.message || e})`);
}
if (!aliases || typeof aliases !== "object" || Array.isArray(aliases)) {
throw new Error(`Format invalide: attendu { route: { oldId: newId } } dans ${ALIASES_PATH}`);
}
@@ -114,10 +109,10 @@ async function main() {
console.log(msg);
warnCount++;
}
if (entries.length === 0) continue;
const rel = route.replace(/^\/+|\/+$/g, ""); // sans slash
const rel = route.replace(/^\/+|\/+$/g, "");
const htmlPath = path.join(DIST_ROOT, rel, "index.html");
if (!(await exists(htmlPath))) {
@@ -135,24 +130,8 @@ async function main() {
if (!oldId || !newId) continue;
const oldCount = countIdAttr(html, oldId);
if (oldCount > 0) {
// ✅ déjà injecté (idempotent)
if (isInjectedAliasSpan(html, oldId)) continue;
// ⛔️ oldId existe déjà "en vrai" (ex: <p id="oldId">)
// => alias inutile / inversé / obsolète
const found = findStartTagWithId(html, oldId);
const where = found ? `<${found.tagName} … id="${oldId}" …>` : `id="${oldId}"`;
const msg =
`⚠️ alias inutile/inversé: oldId déjà présent dans la page (${where}). ` +
`Supprime l'alias ${oldId} -> ${newId} (ou corrige le sens) pour route=${route}`;
if (STRICT) throw new Error(msg);
console.log(msg);
warnCount++;
continue;
}
// juste après avoir calculé oldCount
// ✅ déjà injecté => idempotent
if (oldCount > 0 && isInjectedAliasSpan(html, oldId)) {
if (STRICT && oldCount !== 1) {
throw new Error(`oldId dupliqué (${oldCount}) alors qu'il est censé être unique: ${route} id=${oldId}`);
@@ -160,18 +139,23 @@ async function main() {
continue;
}
// avant l'injection, après hasId(newId)
const newCount = countIdAttr(html, newId);
if (newCount !== 1) {
const msg = `⚠️ newId non-unique (${newCount}) : ${route} new=${newId} (injection ambiguë)`;
// ⛔️ oldId existe déjà "en vrai" => alias inutile/inversé
if (oldCount > 0) {
const found = findStartTagWithId(html, oldId);
const where = found ? `<${found.tagName} … id="${oldId}" …>` : `id="${oldId}"`;
const msg =
`⚠️ alias inutile/inversé: oldId déjà présent (${where}). ` +
`Supprime ${oldId} -> ${newId} (ou corrige le sens) pour route=${route}`;
if (STRICT) throw new Error(msg);
console.log(msg);
warnCount++;
continue;
}
if (!hasId(html, newId)) {
const msg = `⚠️ newId introuvable: ${route} old=${oldId} -> new=${newId}`;
// newId doit exister UNE fois (sinon injection ambiguë)
const newCount = countIdAttr(html, newId);
if (newCount !== 1) {
const msg = `⚠️ newId non-unique (${newCount}) : ${route} new=${newId} (injection ambiguë)`;
if (STRICT) throw new Error(msg);
console.log(msg);
warnCount++;

View File

@@ -0,0 +1,31 @@
// scripts/purge-dist-dev-whoami.mjs
import fs from "node:fs/promises";
import path from "node:path";
const CWD = process.cwd();
const targetDir = path.join(CWD, "dist", "_auth", "whoami");
const targetIndex = path.join(CWD, "dist", "_auth", "whoami", "index.html");
// Purge idempotente (force=true => pas d'erreur si absent)
async function rmSafe(p) {
try {
await fs.rm(p, { recursive: true, force: true });
return true;
} catch {
return false;
}
}
async function main() {
const removedIndex = await rmSafe(targetIndex);
const removedDir = await rmSafe(targetDir);
// Optionnel: si dist/_auth devient vide, on laisse tel quel (pas besoin de toucher)
const any = removedIndex || removedDir;
console.log(`✅ purge-dist-dev-whoami: ${any ? "purged" : "nothing to purge"}`);
}
main().catch((e) => {
console.error("❌ purge-dist-dev-whoami failed:", e);
process.exit(1);
});

131
scripts/switch-archicratie.sh Executable file
View File

@@ -0,0 +1,131 @@
#!/usr/bin/env bash
set -euo pipefail
# switch-archicratie.sh — SAFE switch LIVE + STAGING (avec backups horodatés)
#
# Usage (NAS recommandé) :
# sudo bash -c 'LIVE_PORT=8081 /volume2/docker/archicratie-web/current/scripts/switch-archicratie.sh'
# sudo bash -c 'LIVE_PORT=8082 /volume2/docker/archicratie-web/current/scripts/switch-archicratie.sh'
#
# Usage (test local R&D, sans NAS) :
# D=/tmp/dynamic-test LIVE_PORT=8081 bash scripts/switch-archicratie.sh --dry-run
# D=/tmp/dynamic-test LIVE_PORT=8081 bash scripts/switch-archicratie.sh
usage() {
cat <<'EOF'
SAFE switch LIVE + STAGING (avec backups horodatés).
Variables / options :
LIVE_PORT=8081|8082 (obligatoire) port LIVE cible
D=/volume2/docker/edge/config/dynamic (optionnel) dossier des yml Traefik dynamiques
--dry-run n'écrit rien, affiche seulement ce qui serait fait
-h, --help aide
Exemples :
sudo bash -c 'LIVE_PORT=8082 /volume2/docker/archicratie-web/current/scripts/switch-archicratie.sh'
D=/tmp/dynamic-test LIVE_PORT=8081 bash scripts/switch-archicratie.sh --dry-run
EOF
}
DRY_RUN=0
for arg in "${@:-}"; do
case "$arg" in
--dry-run) DRY_RUN=1 ;;
-h|--help) usage; exit 0 ;;
*) ;;
esac
done
D="${D:-/volume2/docker/edge/config/dynamic}"
F_LIVE="$D/20-archicratie-backend.yml"
F_STAG="$D/21-archicratie-staging.yml"
LIVE_PORT="${LIVE_PORT:-}"
if [[ "$LIVE_PORT" != "8081" && "$LIVE_PORT" != "8082" ]]; then
echo "❌ LIVE_PORT doit valoir 8081 ou 8082."
usage
exit 1
fi
if [[ ! -f "$F_LIVE" || ! -f "$F_STAG" ]]; then
echo "❌ Fichiers manquants :"
echo " $F_LIVE"
echo " $F_STAG"
echo " (Astuce R&D locale : mets D=/tmp/dynamic-test et crée 20/21 dedans.)"
exit 1
fi
OTHER_PORT="8081"
[[ "$LIVE_PORT" == "8081" ]] && OTHER_PORT="8082"
show_urls() {
local f="$1"
echo "$f"
grep -nE '^\s*-\s*url:\s*".*"' "$f" || true
}
# Garde-fou : on attend au moins un "url:" dans chaque fichier
grep -qE '^\s*-\s*url:\s*"' "$F_LIVE" || { echo "❌ Format inattendu dans $F_LIVE (pas de - url: \")"; exit 1; }
grep -qE '^\s*-\s*url:\s*"' "$F_STAG" || { echo "❌ Format inattendu dans $F_STAG (pas de - url: \")"; exit 1; }
echo "Avant :"
show_urls "$F_LIVE"
show_urls "$F_STAG"
echo
echo "Plan : LIVE -> $LIVE_PORT ; STAGING -> $OTHER_PORT"
echo
if [[ "$DRY_RUN" == "1" ]]; then
echo "DRY-RUN : aucune écriture."
exit 0
fi
TS="$(date +%F-%H%M%S)"
cp -a "$F_LIVE" "$F_LIVE.bak.$TS"
cp -a "$F_STAG" "$F_STAG.bak.$TS"
# sed inplace portable (macOS vs Linux/DSM)
sed_inplace() {
local expr="$1" file="$2"
if [[ "$(uname -s)" == "Darwin" ]]; then
sed -i '' -e "$expr" "$file"
else
sed -i -e "$expr" "$file"
fi
}
# Remplacement ciblé UNIQUEMENT sur la ligne - url: "http://127.0.0.1:808X"
sed_inplace \
"s#^\([[:space:]]*-[[:space:]]*url:[[:space:]]*\"http://127\\.0\\.0\\.1:\\)808[12]\\(\"[[:space:]]*\)#\\1${LIVE_PORT}\\2#g" \
"$F_LIVE"
sed_inplace \
"s#^\([[:space:]]*-[[:space:]]*url:[[:space:]]*\"http://127\\.0\\.0\\.1:\\)808[12]\\(\"[[:space:]]*\)#\\1${OTHER_PORT}\\2#g" \
"$F_STAG"
# Post-check : on confirme que les fichiers contiennent bien les ports attendus
grep -qE "http://127\.0\.0\.1:${LIVE_PORT}\"" "$F_LIVE" || {
echo "❌ Post-check FAIL : $F_LIVE ne contient pas http://127.0.0.1:${LIVE_PORT}"
echo "➡️ rollback backups : $F_LIVE.bak.$TS / $F_STAG.bak.$TS"
exit 1
}
grep -qE "http://127\.0\.0\.1:${OTHER_PORT}\"" "$F_STAG" || {
echo "❌ Post-check FAIL : $F_STAG ne contient pas http://127.0.0.1:${OTHER_PORT}"
echo "➡️ rollback backups : $F_LIVE.bak.$TS / $F_STAG.bak.$TS"
exit 1
}
echo "✅ OK. Backups :"
echo " - $F_LIVE.bak.$TS"
echo " - $F_STAG.bak.$TS"
echo
echo "Après :"
show_urls "$F_LIVE"
show_urls "$F_STAG"
echo
echo "Smoke tests :"
echo " curl -sS -I http://127.0.0.1:${LIVE_PORT}/ | head -n 12"
echo " curl -sS -I http://127.0.0.1:${OTHER_PORT}/ | head -n 12"
echo " curl -sS -I -H 'Host: archicratie.trans-hands.synology.me' http://127.0.0.1:18080/ | head -n 20"
echo " curl -sS -I -H 'Host: staging.archicratie.trans-hands.synology.me' http://127.0.0.1:18080/ | head -n 20"

View File

@@ -205,7 +205,7 @@ for (const [route, mapping] of Object.entries(data)) {
newId,
htmlPath,
msg:
`oldId present but is NOT an injected alias span (<span class="para-alias">).</n` +
`oldId present but is NOT an injected alias span (<span class="para-alias">).\n` +
`Saw: ${seen}`,
});
continue;

View File

@@ -0,0 +1,26 @@
import fs from "node:fs/promises";
import path from "node:path";
const OUT = path.join(process.cwd(), "public", "_auth", "whoami");
const groupsRaw = process.env.PUBLIC_WHOAMI_GROUPS ?? "editors";
const user = process.env.PUBLIC_WHOAMI_USER ?? "dev";
const name = process.env.PUBLIC_WHOAMI_NAME ?? "Dev Local";
const email = process.env.PUBLIC_WHOAMI_EMAIL ?? "area.technik@proton.me";
const groups = groupsRaw
.split(/[;,]/)
.map((s) => s.trim())
.filter(Boolean)
.join(",");
const body =
`Remote-User: ${user}\n` +
`Remote-Name: ${name}\n` +
`Remote-Email: ${email}\n` +
`Remote-Groups: ${groups}\n`;
await fs.mkdir(path.dirname(OUT), { recursive: true });
await fs.writeFile(OUT, body, "utf8");
console.log(`✅ dev whoami written: ${path.relative(process.cwd(), OUT)} (${groups})`);

View File

@@ -0,0 +1,21 @@
schema: 1
page: archicrat-ia/chapitre-4
paras:
p-2-31b12529:
media:
- type: image
src: /media/archicrat-ia/chapitre-4/p-2-31b12529/Capture_d_e_cran_2026-02-16_a_13.05.58.png
caption: "[Media] p-2-31b12529 — Chapitre 4 — Histoire archicratique des
révolutions industrielles"
credit: ""
ts: 2026-02-25T18:58:32.359Z
fromIssue: 115
p-7-1da4a458:
media:
- type: image
src: /media/archicrat-ia/chapitre-4/p-7-1da4a458/Capture_d_e_cran_2026-02-16_a_13.05.58.png
caption: "[Media] p-7-1da4a458 — Chapitre 4 — Histoire archicratique des
révolutions industrielles"
credit: ""
ts: 2026-02-25T19:11:32.634Z
fromIssue: 121

View File

@@ -1,8 +1,5 @@
schema: 1
# optionnel (si présent, doit matcher le chemin du fichier)
page: archicrat-ia/prologue
paras:
p-0-d7974f88:
refs:
@@ -25,11 +22,11 @@ paras:
media:
- type: "image"
src: "/public/media/archicrat-ia/prologue/p-0-d7974f88/schema-1.svg"
src: "/public/media/archicratie/archicrat-ia/prologue/p-0-d7974f88/schema-1.svg"
caption: "Tableau explicatif"
credit: "ChatGPT"
- type: "image"
src: "/public/media/archicrat-ia/prologue/p-0-d7974f88/schema-2.svg"
src: "/public/media/archicratie/archicrat-ia/prologue/p-0-d7974f88/schema-2.svg"
caption: "Diagramme dévolution"
credit: "Yanis Varoufakis"
@@ -50,10 +47,4 @@ paras:
- text: "Si lon voulait chercher quelque chose comme une vision du monde chez Kafka..."
source: "Bernard Lahire, Franz Kafka, p.475+"
media:
- type: "video"
src: "/public/media/archicrat-ia/prologue/p-1-2ef25f29/bien_commun.mp4"
caption: "Entretien avec Bernard Lahire"
credit: "Cairn.info"
comments_editorial: []

View File

@@ -144,15 +144,14 @@
const canReaders = inGroup(groups, "readers");
const canEditors = inGroup(groups, "editors");
access.canUsers = Boolean((info?.ok && (canReaders || canEditors)) || (isDev() && !info?.ok));
const whoamiSkipped = Boolean(window.__archiFlags && window.__archiFlags.whoamiSkipped);
access.canUsers = Boolean((info?.ok && (canReaders || canEditors)) || whoamiSkipped);
access.ready = true;
if (btnMediaSubmit) btnMediaSubmit.disabled = !access.canUsers;
if (btnSend) btnSend.disabled = !access.canUsers;
if (btnRefSubmit) btnRefSubmit.disabled = !access.canUsers;
// si pas d'accès, on informe (soft)
if (!access.canUsers) {
if (msgHead) {
@@ -162,12 +161,13 @@
}
}
}).catch(() => {
// fallback dev
// fallback dev (cohérent: media + ref + comment)
access.ready = true;
if (isDev()) {
if (Boolean(window.__archiFlags && window.__archiFlags.whoamiSkipped)) {
access.canUsers = true;
if (btnMediaSubmit) btnMediaSubmit.disabled = false;
if (btnSend) btnSend.disabled = false;
if (btnRefSubmit) btnRefSubmit.disabled = false;
}
});
@@ -209,8 +209,12 @@
async function loadIndex() {
if (_idxP) return _idxP;
_idxP = (async () => {
const res = await fetch("/annotations-index.json?_=" + Date.now(), { cache: "no-store" }).catch(() => null);
if (res && res.ok) return await res.json();
try {
const res = await fetch("/annotations-index.json?_=" + Date.now(), { cache: "no-store" });
if (res && res.ok) return await res.json();
} catch {}
// ✅ antifragile: ne pas “cacher” un échec pour toujours (dev/HMR/boot race)
_idxP = null;
return null;
})();
return _idxP;
@@ -564,6 +568,14 @@
hideMsg(msgComment);
const idx = await loadIndex();
// ✅ message soft si lindex est indisponible (sans écraser le message dauth)
if (!idx && msgHead && msgHead.hidden) {
msgHead.hidden = false;
msgHead.textContent = "Index annotations indisponible (annotations-index.json).";
msgHead.dataset.kind = "info";
}
const data = idx?.pages?.[pageKey]?.paras?.[currentParaId] || null;
renderLevel2(data);

View File

@@ -30,6 +30,13 @@ const GITEA_REPO = import.meta.env.PUBLIC_GITEA_REPO ?? "";
// ✅ OPTIONNEL : bridge serveur (proxy same-origin)
const ISSUE_BRIDGE_PATH = import.meta.env.PUBLIC_ISSUE_BRIDGE_PATH ?? "";
// ✅ Auth whoami (same-origin) — configurable, antifragile en dev
const WHOAMI_PATH = import.meta.env.PUBLIC_WHOAMI_PATH ?? "/_auth/whoami";
// Par défaut: en DEV local on SKIP pour éviter le spam 404.
// Pour tester lauth en dev: export PUBLIC_WHOAMI_IN_DEV=1
const WHOAMI_IN_DEV = (import.meta.env.PUBLIC_WHOAMI_IN_DEV ?? "") === "1";
const WHOAMI_FORCE_LOCALHOST = (import.meta.env.PUBLIC_WHOAMI_FORCE_LOCALHOST ?? "") === "1";
---
<!doctype html>
@@ -52,54 +59,104 @@ const ISSUE_BRIDGE_PATH = import.meta.env.PUBLIC_ISSUE_BRIDGE_PATH ?? "";
<meta data-pagefind-meta={`version:${String(version ?? "")}`} />
{/* ✅ BOOT EARLY : SidePanel dépend de ces globals. */}
<script is:inline define:vars={{ IS_DEV, GITEA_BASE, GITEA_OWNER, GITEA_REPO, ISSUE_BRIDGE_PATH }}>
<script
is:inline
define:vars={{
IS_DEV,
GITEA_BASE,
GITEA_OWNER,
GITEA_REPO,
ISSUE_BRIDGE_PATH,
WHOAMI_PATH,
WHOAMI_IN_DEV,
WHOAMI_FORCE_LOCALHOST,
}}
>
(() => {
const __DEV__ = Boolean(IS_DEV);
window.__archiFlags = Object.assign({}, window.__archiFlags, { dev: __DEV__ });
// ✅ anti double-init (HMR / inclusion accidentelle)
if (window.__archiBootOnce === 1) return;
window.__archiBootOnce = 1;
const base = String(GITEA_BASE || "").replace(/\/+$/, "");
const owner = String(GITEA_OWNER || "");
const repo = String(GITEA_REPO || "");
const giteaReady = Boolean(base && owner && repo);
window.__archiGitea = { ready: giteaReady, base, owner, repo };
var __DEV__ = Boolean(IS_DEV);
const rawBridge = String(ISSUE_BRIDGE_PATH || "").trim();
const normBridge = rawBridge
// ===== Gitea globals =====
var base = String(GITEA_BASE || "").replace(/\/+$/, "");
var owner = String(GITEA_OWNER || "");
var repo = String(GITEA_REPO || "");
window.__archiGitea = {
ready: Boolean(base && owner && repo),
base, owner, repo
};
// ===== optional issue bridge (same-origin proxy) =====
var rawBridge = String(ISSUE_BRIDGE_PATH || "").trim();
var normBridge = rawBridge
? (rawBridge.startsWith("/") ? rawBridge : ("/" + rawBridge.replace(/^\/+/, ""))).replace(/\/+$/, "")
: "";
window.__archiIssueBridge = { ready: Boolean(normBridge), path: normBridge };
const WHOAMI_PATH = "/_auth/whoami";
const REQUIRED_GROUP = "editors";
const READ_GROUP = "readers";
// ===== whoami config =====
var __WHOAMI_PATH__ = String(WHOAMI_PATH || "/_auth/whoami");
var __WHOAMI_IN_DEV__ = Boolean(WHOAMI_IN_DEV);
// En dev: par défaut on SKIP (=> pas de spam 404). Override via PUBLIC_WHOAMI_IN_DEV=1.
var SHOULD_FETCH_WHOAMI = (!__DEV__) || __WHOAMI_IN_DEV__;
window.__archiFlags = Object.assign({}, window.__archiFlags, {
dev: __DEV__,
whoamiPath: __WHOAMI_PATH__,
whoamiInDev: __WHOAMI_IN_DEV__,
whoamiFetch: SHOULD_FETCH_WHOAMI,
});
var REQUIRED_GROUP = "editors";
var READ_GROUP = "readers";
function parseWhoamiLine(text, key) {
const re = new RegExp(`^${key}:\\s*(.*)$`, "mi");
const m = String(text || "").match(re);
return (m?.[1] ?? "").trim();
var re = new RegExp("^" + key + ":\\s*(.*)$", "mi");
var m = String(text || "").match(re);
return (m && m[1] ? m[1] : "").trim();
}
function inGroup(groups, g) {
const gg = String(g || "").toLowerCase();
var gg = String(g || "").toLowerCase();
return Array.isArray(groups) && groups.some((x) => String(x).toLowerCase() === gg);
}
// ===== Auth info promise (single source of truth) =====
if (!window.__archiAuthInfoP) {
window.__archiAuthInfoP = (async () => {
const res = await fetch(`${WHOAMI_PATH}?_=${Date.now()}`, {
credentials: "include",
cache: "no-store",
redirect: "manual",
headers: { Accept: "text/plain" },
}).catch(() => null);
// ✅ dev default: skip
if (!SHOULD_FETCH_WHOAMI) {
return { ok: false, user: "", name: "", email: "", groups: [], raw: "" };
}
var res = null;
try {
res = await fetch(__WHOAMI_PATH__ + "?_=" + Date.now(), {
credentials: "include",
cache: "no-store",
redirect: "manual",
headers: { Accept: "text/plain" },
});
} catch {
res = null;
}
if (!res) return { ok: false, user: "", name: "", email: "", groups: [], raw: "" };
if (res.type === "opaqueredirect") return { ok: false, user: "", name: "", email: "", groups: [], raw: "" };
if (res.status >= 300 && res.status < 400) return { ok: false, user: "", name: "", email: "", groups: [], raw: "" };
if (res.status === 404) return { ok: false, user: "", name: "", email: "", groups: [], raw: "" };
const text = await res.text().catch(() => "");
const looksLikeWhoami = /Remote-(User|Groups|Email|Name)\s*:/i.test(text);
if (!res.ok || !looksLikeWhoami) return { ok: false, user: "", name: "", email: "", groups: [], raw: text };
var text = "";
try { text = await res.text(); } catch { text = ""; }
const groups = parseWhoamiLine(text, "Remote-Groups")
var looksLikeWhoami = /Remote-(User|Groups|Email|Name)\s*:/i.test(text);
if (!res.ok || !looksLikeWhoami) {
return { ok: false, user: "", name: "", email: "", groups: [], raw: text };
}
var groups = parseWhoamiLine(text, "Remote-Groups")
.split(/[;,]/)
.map((s) => s.trim())
.filter(Boolean)
@@ -116,18 +173,22 @@ const ISSUE_BRIDGE_PATH = import.meta.env.PUBLIC_ISSUE_BRIDGE_PATH ?? "";
})().catch(() => ({ ok: false, user: "", name: "", email: "", groups: [], raw: "" }));
}
// readers + editors (strict)
if (!window.__archiCanReadP) {
window.__archiCanReadP = window.__archiAuthInfoP.then((info) =>
Boolean(info.ok && (inGroup(info.groups, READ_GROUP) || inGroup(info.groups, REQUIRED_GROUP)))
Boolean(info && info.ok && (inGroup(info.groups, READ_GROUP) || inGroup(info.groups, REQUIRED_GROUP)))
);
}
// editors gate for "Proposer"
if (!window.__archiIsEditorP) {
window.__archiIsEditorP = window.__archiAuthInfoP
.then((info) => Boolean(inGroup(info.groups, REQUIRED_GROUP) || (__DEV__ && !info.ok)))
.catch(() => false);
// ✅ DEV fallback: si whoami absent/KO => Proposer autorisé (comme ton intention initiale)
.then((info) => Boolean(inGroup(info.groups, REQUIRED_GROUP) || (__DEV__ && !(info && info.ok))))
.catch(() => Boolean(__DEV__));
}
})();
</script>
</head>
@@ -950,11 +1011,13 @@ const ISSUE_BRIDGE_PATH = import.meta.env.PUBLIC_ISSUE_BRIDGE_PATH ?? "";
safe("propose-gate", () => {
if (!giteaReady) return;
const p = window.__archiIsEditorP || Promise.resolve(false);
p.then((ok) => {
document.querySelectorAll(".para-propose").forEach((el) => {
if (ok) showEl(el);
else el.remove();
else hideEl(el); // ✅ jamais remove => antifragile
});
}).catch((err) => {
console.warn("[proposer] gate failed; keeping Proposer hidden", err);

View File

@@ -0,0 +1,197 @@
import type { APIRoute } from "astro";
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { parse as parseYAML } from "yaml";
const CWD = process.cwd();
const ANNO_DIR = path.join(CWD, "src", "annotations");
// Strict en CI (ou override explicite)
const STRICT =
process.env.ANNOTATIONS_STRICT === "1" ||
process.env.CI === "1" ||
process.env.CI === "true";
async function exists(p: string): Promise<boolean> {
try {
await fs.access(p);
return true;
} catch {
return false;
}
}
async function walk(dir: string): Promise<string[]> {
const out: string[] = [];
const ents = await fs.readdir(dir, { withFileTypes: true });
for (const e of ents) {
const p = path.join(dir, e.name);
if (e.isDirectory()) out.push(...(await walk(p)));
else out.push(p);
}
return out;
}
function isPlainObject(x: unknown): x is Record<string, unknown> {
return !!x && typeof x === "object" && !Array.isArray(x);
}
function normalizePageKey(s: unknown): string {
return String(s ?? "")
.replace(/^\/+/, "")
.replace(/\/+$/, "")
.trim();
}
function inferPageKeyFromFile(inDirAbs: string, fileAbs: string): string {
const rel = path.relative(inDirAbs, fileAbs).replace(/\\/g, "/");
return rel.replace(/\.(ya?ml|json)$/i, "");
}
function parseDoc(raw: string, fileAbs: string): unknown {
if (/\.json$/i.test(fileAbs)) return JSON.parse(raw);
return parseYAML(raw);
}
function hardFailOrCollect(errors: string[], msg: string): void {
if (STRICT) throw new Error(msg);
errors.push(msg);
}
function sanitizeEntry(
fileRel: string,
paraId: string,
entry: unknown,
errors: string[]
): Record<string, unknown> {
if (entry == null) return {};
if (!isPlainObject(entry)) {
hardFailOrCollect(errors, `${fileRel}: paras.${paraId} must be an object`);
return {};
}
const e: Record<string, unknown> = { ...entry };
const arrayFields = [
"refs",
"authors",
"quotes",
"media",
"comments_editorial",
] as const;
for (const k of arrayFields) {
if (e[k] == null) continue;
if (!Array.isArray(e[k])) {
errors.push(`${fileRel}: paras.${paraId}.${k} must be an array (coerced to [])`);
e[k] = [];
}
}
return e;
}
export const GET: APIRoute = async () => {
if (!(await exists(ANNO_DIR))) {
const out = {
schema: 1,
generatedAt: new Date().toISOString(),
pages: {},
stats: { pages: 0, paras: 0, errors: 0 },
errors: [] as string[],
};
return new Response(JSON.stringify(out), {
headers: {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "no-store",
},
});
}
const files = (await walk(ANNO_DIR)).filter((p) => /\.(ya?ml|json)$/i.test(p));
const pages: Record<string, { paras: Record<string, Record<string, unknown>> }> =
Object.create(null);
const errors: string[] = [];
let paraCount = 0;
for (const f of files) {
const fileRel = path.relative(CWD, f).replace(/\\/g, "/");
const pageKey = normalizePageKey(inferPageKeyFromFile(ANNO_DIR, f));
if (!pageKey) {
hardFailOrCollect(errors, `${fileRel}: cannot infer page key`);
continue;
}
let doc: unknown;
try {
const raw = await fs.readFile(f, "utf8");
doc = parseDoc(raw, f);
} catch (e) {
hardFailOrCollect(errors, `${fileRel}: parse failed: ${String((e as any)?.message ?? e)}`);
continue;
}
if (!isPlainObject(doc) || (doc as any).schema !== 1) {
hardFailOrCollect(errors, `${fileRel}: schema must be 1`);
continue;
}
if ((doc as any).page != null) {
const declared = normalizePageKey((doc as any).page);
if (declared !== pageKey) {
hardFailOrCollect(
errors,
`${fileRel}: page mismatch (page="${declared}" vs path="${pageKey}")`
);
}
}
const parasAny = (doc as any).paras;
if (!isPlainObject(parasAny)) {
hardFailOrCollect(errors, `${fileRel}: missing object key "paras"`);
continue;
}
if (pages[pageKey]) {
hardFailOrCollect(errors, `${fileRel}: duplicate page "${pageKey}" (only one file per page)`);
continue;
}
const parasOut: Record<string, Record<string, unknown>> = Object.create(null);
for (const [paraId, entry] of Object.entries(parasAny)) {
if (!/^p-\d+-/i.test(paraId)) {
hardFailOrCollect(errors, `${fileRel}: invalid para id "${paraId}"`);
continue;
}
parasOut[paraId] = sanitizeEntry(fileRel, paraId, entry, errors);
}
pages[pageKey] = { paras: parasOut };
paraCount += Object.keys(parasOut).length;
}
const out = {
schema: 1,
generatedAt: new Date().toISOString(),
pages,
stats: {
pages: Object.keys(pages).length,
paras: paraCount,
errors: errors.length,
},
errors,
};
return new Response(JSON.stringify(out), {
headers: {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "no-store",
},
});
};

View File

@@ -4,13 +4,13 @@ import SiteLayout from "../layouts/SiteLayout.astro";
<SiteLayout title="Accueil">
<h1>Archicratie — Édition web</h1>
<p>
Portail daccès aux éditions : Traité (Ontodynamique générative), Essai-thèse (Archicratie),
Portail daccès aux éditions : Traité (Ontodynamique générative), Essai-thèse (ArchiCraT-IA),
Cas pratique (IA), Glossaire, Atlas.
</p>
<ul>
<li><a href="/editions/">Carte des œuvres</a></li>
<li><a href="/archicratie/">Essai-thèse — Archicratie</a></li>
<li><a href="/archicrat-ia/">Essai-thèse — ArchiCraT-IA</a></li>
<li><a href="/traite/">Traité — Ontodynamique générative</a></li>
<li><a href="/ia/">Cas pratique — Gouvernance des systèmes IA</a></li>
<li><a href="/glossaire/">Glossaire archicratique</a></li>

View File

@@ -0,0 +1,42 @@
import type { APIRoute } from "astro";
import * as fs from "node:fs/promises";
import * as path from "node:path";
export const prerender = true;
async function exists(p: string) {
try { await fs.access(p); return true; } catch { return false; }
}
export const GET: APIRoute = async () => {
const distFile = path.join(process.cwd(), "dist", "para-index.json");
// Si dist existe (ex: après un build), on renvoie le vrai fichier.
if (await exists(distFile)) {
const raw = await fs.readFile(distFile, "utf8");
return new Response(raw, {
status: 200,
headers: {
"content-type": "application/json; charset=utf-8",
"cache-control": "no-store",
},
});
}
// Sinon stub (dev sans build) : pas derreur, pas de crash, pas de 404.
const stub = {
schema: 1,
generatedAt: new Date().toISOString(),
items: [],
byId: {},
note: "para-index not built yet (run: npm run build to generate dist/para-index.json)",
};
return new Response(JSON.stringify(stub), {
status: 200,
headers: {
"content-type": "application/json; charset=utf-8",
"cache-control": "no-store",
},
});
};

View File

@@ -1,9 +1,5 @@
{
"archicratie/00-demarrage/index.html": [
"p-0-d64c1c39",
"p-1-3f750540"
],
"archicratie/archicrat-ia/chapitre-1/index.html": [
"archicrat-ia/chapitre-1/index.html": [
"p-0-8d27a7f5",
"p-1-8a6c18bf",
"p-2-39c6e4f4",
@@ -664,7 +660,7 @@
"p-657-7c465f0f",
"p-658-3fc26620"
],
"archicratie/archicrat-ia/chapitre-2/index.html": [
"archicrat-ia/chapitre-2/index.html": [
"p-0-32820f76",
"p-1-63506bae",
"p-2-206c653a",
@@ -1981,7 +1977,7 @@
"p-1313-c83e97d4",
"p-1314-4c2ed9ba"
],
"archicratie/archicrat-ia/chapitre-3/index.html": [
"archicrat-ia/chapitre-3/index.html": [
"p-0-ace27175",
"p-1-60c7ea48",
"p-2-1167ed0e",
@@ -2973,7 +2969,7 @@
"p-988-d8a0cce7",
"p-989-fdbb8595"
],
"archicratie/archicrat-ia/chapitre-4/index.html": [
"archicrat-ia/chapitre-4/index.html": [
"p-0-ba984c9d",
"p-1-ea84724d",
"p-2-31b12529",
@@ -3730,7 +3726,7 @@
"p-753-f778aef4",
"p-754-d99a24c1"
],
"archicratie/archicrat-ia/chapitre-5/index.html": [
"archicrat-ia/chapitre-5/index.html": [
"p-0-96edff22",
"p-1-a51a4ee1",
"p-2-dff32cbc",
@@ -4771,7 +4767,7 @@
"p-1037-4825033b",
"p-1038-54aa72be"
],
"archicratie/archicrat-ia/conclusion/index.html": [
"archicrat-ia/conclusion/index.html": [
"p-0-5ec4522a",
"p-1-e481f7e6",
"p-2-7a56c59b",
@@ -4892,7 +4888,7 @@
"p-117-3a086369",
"p-118-67afae83"
],
"archicratie/archicrat-ia/prologue/index.html": [
"archicrat-ia/prologue/index.html": [
"p-0-d7974f88",
"p-1-2ef25f29",
"p-2-edb49e0a",