Compare commits

..

1 Commits

Author SHA1 Message Date
6f81f572cd ci: fix anno workflows JSON parsing (node stdin argv) + harden guards
All checks were successful
SMOKE / smoke (push) Successful in 40s
CI / build-and-anchors (push) Successful in 2m13s
2026-02-28 10:33:43 +01:00
263 changed files with 14627 additions and 69798 deletions

View File

@@ -17,12 +17,12 @@ defaults:
shell: bash shell: bash
concurrency: concurrency:
group: anno-apply-${{ github.event.issue.number || github.event.issue.index || inputs.issue || 'manual' }} group: anno-apply-${{ github.event.issue.number || inputs.issue || 'manual' }}
cancel-in-progress: true cancel-in-progress: true
jobs: jobs:
apply-approved: apply-approved:
runs-on: mac-ci runs-on: ubuntu-latest
container: container:
image: mcr.microsoft.com/devcontainers/javascript-node:22-bookworm image: mcr.microsoft.com/devcontainers/javascript-node:22-bookworm
@@ -37,11 +37,11 @@ jobs:
- name: Derive context (event.json / workflow_dispatch) - name: Derive context (event.json / workflow_dispatch)
env: env:
INPUT_ISSUE: ${{ inputs.issue }} INPUT_ISSUE: ${{ inputs.issue }}
FORGE_API: ${{ vars.FORGE_API || vars.FORGE_BASE || vars.FORGE_BASE_URL }} FORGE_API: ${{ vars.FORGE_API || vars.FORGE_BASE }}
run: | run: |
set -euo pipefail set -euo pipefail
export EVENT_JSON="/var/run/act/workflow/event.json" export EVENT_JSON="/var/run/act/workflow/event.json"
test -f "$EVENT_JSON" || { echo "Missing $EVENT_JSON"; exit 1; } test -f "$EVENT_JSON" || { echo "Missing $EVENT_JSON"; exit 1; }
node --input-type=module - <<'NODE' > /tmp/anno.env node --input-type=module - <<'NODE' > /tmp/anno.env
import fs from "node:fs"; import fs from "node:fs";
@@ -66,10 +66,7 @@ jobs:
if (!owner || !repo) { if (!owner || !repo) {
const m = cloneUrl.match(/[:/](?<o>[^/]+)\/(?<r>[^/]+?)(?:\.git)?$/); const m = cloneUrl.match(/[:/](?<o>[^/]+)\/(?<r>[^/]+?)(?:\.git)?$/);
if (m?.groups) { if (m?.groups) { owner = owner || m.groups.o; repo = repo || m.groups.r; }
owner = owner || m.groups.o;
repo = repo || m.groups.r;
}
} }
if (!owner || !repo) throw new Error("Cannot infer owner/repo"); if (!owner || !repo) throw new Error("Cannot infer owner/repo");
@@ -84,11 +81,10 @@ jobs:
throw new Error("No issue number in event.json or workflow_dispatch input"); throw new Error("No issue number in event.json or workflow_dispatch input");
} }
let labelName = "workflow_dispatch"; const labelName =
const lab = ev?.label; ev?.label?.name ||
if (typeof lab === "string") labelName = lab; ev?.label ||
else if (lab && typeof lab === "object" && typeof lab.name === "string") labelName = lab.name; "workflow_dispatch";
else if (ev?.label?.name) labelName = ev.label.name;
const u = new URL(cloneUrl); const u = new URL(cloneUrl);
const origin = u.origin; const origin = u.origin;
@@ -97,7 +93,7 @@ jobs:
? String(process.env.FORGE_API).trim().replace(/\/+$/,"") ? String(process.env.FORGE_API).trim().replace(/\/+$/,"")
: origin; : origin;
function sh(s) { return JSON.stringify(String(s)); } function sh(s){ return JSON.stringify(String(s)); }
process.stdout.write([ process.stdout.write([
`CLONE_URL=${sh(cloneUrl)}`, `CLONE_URL=${sh(cloneUrl)}`,
@@ -110,51 +106,48 @@ jobs:
].join("\n") + "\n"); ].join("\n") + "\n");
NODE NODE
echo "context:" echo "context:"
sed -n '1,120p' /tmp/anno.env sed -n '1,120p' /tmp/anno.env
- name: Early gate (label event fast-skip, but tolerant) - name: Gate on label state/approved
run: | run: |
set -euo pipefail set -euo pipefail
source /tmp/anno.env source /tmp/anno.env
echo "event label = $LABEL_NAME" if [[ "$LABEL_NAME" != "state/approved" && "$LABEL_NAME" != "workflow_dispatch" ]]; then
echo " label=$LABEL_NAME => skip"
if [[ "$LABEL_NAME" != "state/approved" && "$LABEL_NAME" != "workflow_dispatch" && "$LABEL_NAME" != "" && "$LABEL_NAME" != "[object Object]" ]]; then
echo "label=$LABEL_NAME => skip early"
echo "SKIP=1" >> /tmp/anno.env echo "SKIP=1" >> /tmp/anno.env
echo "SKIP_REASON=\"label_not_approved_event\"" >> /tmp/anno.env
exit 0 exit 0
fi fi
echo "✅ proceed (issue=$ISSUE_NUMBER)"
echo "continue to API gating (issue=$ISSUE_NUMBER)" - name: Fetch issue + gate on Type (skip Proposer)
- name: Fetch issue + hard gate on labels + Type
env: env:
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }} FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
run: | run: |
set -euo pipefail set -euo pipefail
source /tmp/anno.env source /tmp/anno.env
[[ "${SKIP:-0}" != "1" ]] || { echo "skipped"; exit 0; } [[ "${SKIP:-0}" != "1" ]] || { echo " skipped"; exit 0; }
test -n "${FORGE_TOKEN:-}" || { echo "Missing secret FORGE_TOKEN"; exit 1; } test -n "${FORGE_TOKEN:-}" || { echo "Missing secret FORGE_TOKEN"; exit 1; }
curl -fsS \ ISSUE_JSON="$(curl -fsS \
-H "Authorization: token $FORGE_TOKEN" \ -H "Authorization: token $FORGE_TOKEN" \
-H "Accept: application/json" \ -H "Accept: application/json" \
"$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER" \ "$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER")"
-o /tmp/issue.json
node --input-type=module - <<'NODE' >> /tmp/anno.env # ✅ Robust: write JSON to file (avoid argv/stdi n "-" issue)
printf '%s' "$ISSUE_JSON" > /tmp/issue.json
node --input-type=module - /tmp/issue.json >> /tmp/anno.env <<'NODE'
import fs from "node:fs"; import fs from "node:fs";
const issue = JSON.parse(fs.readFileSync("/tmp/issue.json", "utf8")); const fp = process.argv[2] || "";
const body = String(issue.body || "").replace(/\r\n/g, "\n"); const raw = fp ? fs.readFileSync(fp, "utf8") : "{}";
const issue = JSON.parse(raw || "{}");
const labels = Array.isArray(issue.labels) const title = String(issue.title || "");
? issue.labels.map(l => String(l.name || "")).filter(Boolean) const body = String(issue.body || "").replace(/\r\n/g, "\n");
: [];
const hasApproved = labels.includes("state/approved");
function pickLine(key) { function pickLine(key) {
const re = new RegExp(`^\\s*${key}\\s*:\\s*([^\\n\\r]+)`, "mi"); const re = new RegExp(`^\\s*${key}\\s*:\\s*([^\\n\\r]+)`, "mi");
@@ -165,39 +158,33 @@ jobs:
const typeRaw = pickLine("Type"); const typeRaw = pickLine("Type");
const type = String(typeRaw || "").trim().toLowerCase(); const type = String(typeRaw || "").trim().toLowerCase();
const allowedAnno = new Set(["type/media", "type/reference", "type/comment"]); const allowed = new Set(["type/media","type/reference","type/comment"]);
const proposerTypes = new Set(["type/correction", "type/fact-check"]); const proposer = new Set(["type/correction","type/fact-check"]);
const out = []; const out = [];
out.push(`ISSUE_TITLE=${JSON.stringify(title)}`);
out.push(`ISSUE_TYPE=${JSON.stringify(type)}`); out.push(`ISSUE_TYPE=${JSON.stringify(type)}`);
if (!hasApproved) {
out.push(`SKIP=1`);
out.push(`SKIP_REASON=${JSON.stringify("not_approved_label_present")}`);
process.stdout.write(out.join("\n") + "\n");
process.exit(0);
}
if (!type) { if (!type) {
out.push(`SKIP=1`); out.push(`SKIP=1`);
out.push(`SKIP_REASON=${JSON.stringify("missing_type")}`); out.push(`SKIP_REASON=${JSON.stringify("missing_type")}`);
} else if (allowedAnno.has(type)) { } else if (allowed.has(type)) {
// proceed // proceed
} else if (proposerTypes.has(type)) { } else if (proposer.has(type)) {
out.push(`SKIP=1`); out.push(`SKIP=1`);
out.push(`SKIP_REASON=${JSON.stringify("proposer_type:" + type)}`); out.push(`SKIP_REASON=${JSON.stringify("proposer_type:"+type)}`);
} else { } else {
out.push(`SKIP=1`); out.push(`SKIP=1`);
out.push(`SKIP_REASON=${JSON.stringify("unsupported_type:" + type)}`); out.push(`SKIP_REASON=${JSON.stringify("unsupported_type:"+type)}`);
} }
process.stdout.write(out.join("\n") + "\n"); process.stdout.write(out.join("\n") + "\n");
NODE NODE
echo "gating result:" echo "✅ issue type gating:"
grep -E '^(ISSUE_TYPE|SKIP|SKIP_REASON)=' /tmp/anno.env || true grep -E '^(ISSUE_TYPE|SKIP|SKIP_REASON)=' /tmp/anno.env || true
- name: Comment issue if skipped (unsupported / missing Type only) - name: Comment issue if skipped (Proposer / unsupported / missing Type)
if: ${{ always() }} if: ${{ always() }}
env: env:
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }} FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
@@ -206,29 +193,21 @@ jobs:
source /tmp/anno.env || true source /tmp/anno.env || true
[[ "${SKIP:-0}" == "1" ]] || exit 0 [[ "${SKIP:-0}" == "1" ]] || exit 0
[[ "${LABEL_NAME:-}" == "state/approved" || "${LABEL_NAME:-}" == "workflow_dispatch" ]] || exit 0
if [[ "${SKIP_REASON:-}" == "not_approved_label_present" || "${SKIP_REASON:-}" == "label_not_approved_event" ]]; then test -n "${FORGE_TOKEN:-}" || { echo " missing FORGE_TOKEN -> skip comment"; exit 0; }
echo "skip reason=${SKIP_REASON} -> no comment"
exit 0
fi
if [[ "${SKIP_REASON:-}" == proposer_type:* ]]; then
echo "proposer ticket detected -> anno stays silent"
exit 0
fi
test -n "${FORGE_TOKEN:-}" || exit 0
REASON="${SKIP_REASON:-}" REASON="${SKIP_REASON:-}"
TYPE="${ISSUE_TYPE:-}" TYPE="${ISSUE_TYPE:-}"
if [[ "$REASON" == unsupported_type:* ]]; then if [[ "$REASON" == proposer_type:* ]]; then
MSG="Ticket #${ISSUE_NUMBER} ignored: unsupported Type (${TYPE}). Supported types: type/media, type/reference, type/comment." MSG=" Ticket #${ISSUE_NUMBER} détecté comme **Proposer** (${TYPE}).\n\n- Ce type est **traité manuellement par les editors** (correction/fact-check + cat/*).\n- Le bot n'applique **jamais** Proposer et n'ajoute **jamais** state/approved automatiquement.\n\n✅ Action : traitement éditorial manuel."
elif [[ "$REASON" == unsupported_type:* ]]; then
MSG=" Ticket #${ISSUE_NUMBER} ignoré : Type non supporté par le bot (${TYPE}).\n\nTypes supportés : type/media, type/reference, type/comment.\n✅ Action : traitement manuel si nécessaire."
else else
MSG="Ticket #${ISSUE_NUMBER} ignored: missing or unreadable 'Type:'. Expected: type/media|type/reference|type/comment" MSG=" Ticket #${ISSUE_NUMBER} ignoré : champ 'Type:' manquant ou illisible.\n\n✅ Action : corriger le ticket (ajouter 'Type: type/media|type/reference|type/comment') ou traiter manuellement."
fi fi
PAYLOAD="$(node --input-type=module -e 'console.log(JSON.stringify({body: process.argv[1] || ""}))' "$MSG")" PAYLOAD="$(node --input-type=module -e 'console.log(JSON.stringify({body: process.argv[1]||""}))' "$MSG")"
curl -fsS -X POST \ curl -fsS -X POST \
-H "Authorization: token $FORGE_TOKEN" \ -H "Authorization: token $FORGE_TOKEN" \
@@ -240,7 +219,7 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
source /tmp/anno.env source /tmp/anno.env
[[ "${SKIP:-0}" != "1" ]] || { echo "skipped"; exit 0; } [[ "${SKIP:-0}" != "1" ]] || { echo " skipped"; exit 0; }
rm -rf .git rm -rf .git
git init -q git init -q
@@ -253,16 +232,16 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
source /tmp/anno.env source /tmp/anno.env
[[ "${SKIP:-0}" != "1" ]] || { echo "skipped"; exit 0; } [[ "${SKIP:-0}" != "1" ]] || { echo " skipped"; exit 0; }
npm ci --no-audit --no-fund npm ci --no-audit --no-fund
- name: Check apply script exists - name: Check apply script exists
run: | run: |
set -euo pipefail set -euo pipefail
source /tmp/anno.env source /tmp/anno.env
[[ "${SKIP:-0}" != "1" ]] || { echo "skipped"; exit 0; } [[ "${SKIP:-0}" != "1" ]] || { echo " skipped"; exit 0; }
test -f scripts/apply-annotation-ticket.mjs || { test -f scripts/apply-annotation-ticket.mjs || {
echo "missing scripts/apply-annotation-ticket.mjs on $DEFAULT_BRANCH" echo "missing scripts/apply-annotation-ticket.mjs on $DEFAULT_BRANCH"
ls -la scripts | sed -n '1,200p' || true ls -la scripts | sed -n '1,200p' || true
exit 1 exit 1
} }
@@ -271,16 +250,16 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
source /tmp/anno.env source /tmp/anno.env
[[ "${SKIP:-0}" != "1" ]] || { echo "skipped"; exit 0; } [[ "${SKIP:-0}" != "1" ]] || { echo " skipped"; exit 0; }
npm run build npm run build:clean
test -f dist/para-index.json || { test -f dist/para-index.json || {
echo "missing dist/para-index.json after build" echo "missing dist/para-index.json after build"
ls -la dist | sed -n '1,200p' || true ls -la dist | sed -n '1,200p' || true
exit 1 exit 1
} }
echo "dist/para-index.json present" echo "dist/para-index.json present"
- name: Apply ticket on bot branch (strict+verify, commit) - name: Apply ticket on bot branch (strict+verify, commit)
continue-on-error: true continue-on-error: true
@@ -291,10 +270,9 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
source /tmp/anno.env source /tmp/anno.env
[[ "${SKIP:-0}" != "1" ]] || { echo "skipped"; exit 0; } [[ "${SKIP:-0}" != "1" ]] || { echo " skipped"; exit 0; }
test -d .git || { echo "not a git repo (checkout failed)"; echo "APPLY_RC=90" >> /tmp/anno.env; exit 0; }
test -n "${FORGE_TOKEN:-}" || { echo "Missing secret FORGE_TOKEN"; exit 1; } test -n "${FORGE_TOKEN:-}" || { echo "Missing secret FORGE_TOKEN"; exit 1; }
git config user.name "${BOT_GIT_NAME:-archicratie-bot}" git config user.name "${BOT_GIT_NAME:-archicratie-bot}"
git config user.email "${BOT_GIT_EMAIL:-bot@archicratie.local}" git config user.email "${BOT_GIT_EMAIL:-bot@archicratie.local}"
@@ -341,15 +319,15 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
source /tmp/anno.env || true source /tmp/anno.env || true
[[ "${SKIP:-0}" != "1" ]] || { echo "skipped"; exit 0; } [[ "${SKIP:-0}" != "1" ]] || { echo " skipped"; exit 0; }
RC="${APPLY_RC:-0}" RC="${APPLY_RC:-0}"
if [[ "$RC" == "0" ]]; then if [[ "$RC" == "0" ]]; then
echo "no failure detected" echo " no failure detected"
exit 0 exit 0
fi fi
test -n "${FORGE_TOKEN:-}" || exit 0 test -n "${FORGE_TOKEN:-}" || { echo " missing FORGE_TOKEN -> skip comment"; exit 0; }
if [[ -f /tmp/apply.log ]]; then if [[ -f /tmp/apply.log ]]; then
BODY="$(tail -n 160 /tmp/apply.log | sed 's/\r$//')" BODY="$(tail -n 160 /tmp/apply.log | sed 's/\r$//')"
@@ -357,8 +335,31 @@ jobs:
BODY="(no apply log found)" BODY="(no apply log found)"
fi fi
MSG="apply-annotation-ticket failed (rc=${RC}).\n\n\`\`\`\n${BODY}\n\`\`\`\n" 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")" 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 || true
[[ "${SKIP:-0}" != "1" ]] || exit 0
[[ "${APPLY_RC:-0}" == "0" ]] || exit 0
[[ "${NOOP:-0}" == "1" ]] || exit 0
test -n "${FORGE_TOKEN:-}" || { echo " missing FORGE_TOKEN -> skip comment"; 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 \ curl -fsS -X POST \
-H "Authorization: token $FORGE_TOKEN" \ -H "Authorization: token $FORGE_TOKEN" \
@@ -375,9 +376,12 @@ jobs:
source /tmp/anno.env || true source /tmp/anno.env || true
[[ "${SKIP:-0}" != "1" ]] || exit 0 [[ "${SKIP:-0}" != "1" ]] || exit 0
[[ "${APPLY_RC:-0}" == "0" ]] || { echo "apply failed -> skip push"; exit 0; } [[ "${APPLY_RC:-0}" == "0" ]] || { echo " apply failed -> skip push"; exit 0; }
[[ "${NOOP:-0}" == "0" ]] || { echo "no-op -> skip push"; exit 0; } [[ "${NOOP:-0}" == "0" ]] || { echo " no-op -> skip push"; exit 0; }
test -d .git || { echo "no git repo -> skip push"; exit 0; }
test -d .git || { echo " no git repo -> skip push"; exit 0; }
test -n "${BRANCH:-}" || { echo " missing BRANCH -> skip push"; exit 0; }
test -n "${FORGE_TOKEN:-}" || { echo " missing FORGE_TOKEN -> skip push"; exit 0; }
AUTH_URL="$(node --input-type=module -e ' AUTH_URL="$(node --input-type=module -e '
const [clone, tok] = process.argv.slice(1); const [clone, tok] = process.argv.slice(1);
@@ -399,8 +403,12 @@ jobs:
source /tmp/anno.env || true source /tmp/anno.env || true
[[ "${SKIP:-0}" != "1" ]] || exit 0 [[ "${SKIP:-0}" != "1" ]] || exit 0
[[ "${APPLY_RC:-0}" == "0" ]] || { echo "apply failed -> skip PR"; exit 0; } [[ "${APPLY_RC:-0}" == "0" ]] || { echo " apply failed -> skip PR"; exit 0; }
[[ "${NOOP:-0}" == "0" ]] || { echo "no-op -> skip PR"; exit 0; } [[ "${NOOP:-0}" == "0" ]] || { echo " no-op -> skip PR"; exit 0; }
test -n "${BRANCH:-}" || { echo " missing BRANCH -> skip PR"; exit 0; }
test -n "${END_SHA:-}" || { echo " missing END_SHA -> skip PR"; exit 0; }
test -n "${FORGE_TOKEN:-}" || { echo " missing FORGE_TOKEN -> skip PR"; exit 0; }
PR_TITLE="anno: apply ticket #${ISSUE_NUMBER}" 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_BODY="PR auto depuis ticket #${ISSUE_NUMBER} (state/approved).\n\n- Branche: ${BRANCH}\n- Commit: ${END_SHA}\n\nMerge si CI OK."
@@ -421,10 +429,10 @@ jobs:
console.log(pr.html_url || pr.url || ""); console.log(pr.html_url || pr.url || "");
' "$PR_JSON")" ' "$PR_JSON")"
test -n "$PR_URL" || { echo "PR URL missing. Raw: $PR_JSON"; exit 1; } test -n "$PR_URL" || { echo "PR URL missing. Raw: $PR_JSON"; exit 1; }
MSG="PR created for ticket #${ISSUE_NUMBER}: ${PR_URL}" 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")" C_PAYLOAD="$(node --input-type=module -e 'console.log(JSON.stringify({body: process.argv[1]||""}))' "$MSG")"
curl -fsS -X POST \ curl -fsS -X POST \
-H "Authorization: token $FORGE_TOKEN" \ -H "Authorization: token $FORGE_TOKEN" \
@@ -432,7 +440,7 @@ jobs:
"$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER/comments" \ "$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER/comments" \
--data-binary "$C_PAYLOAD" --data-binary "$C_PAYLOAD"
echo "PR: $PR_URL" echo "PR: $PR_URL"
- name: Finalize (fail job if apply failed) - name: Finalize (fail job if apply failed)
if: ${{ always() }} if: ${{ always() }}
@@ -440,11 +448,11 @@ jobs:
set -euo pipefail set -euo pipefail
source /tmp/anno.env || true source /tmp/anno.env || true
[[ "${SKIP:-0}" != "1" ]] || { echo "skipped"; exit 0; } [[ "${SKIP:-0}" != "1" ]] || { echo " skipped"; exit 0; }
RC="${APPLY_RC:-0}" RC="${APPLY_RC:-0}"
if [[ "$RC" != "0" ]]; then if [[ "$RC" != "0" ]]; then
echo "apply failed (rc=$RC)" echo "apply failed (rc=$RC)"
exit "$RC" exit "$RC"
fi fi
echo "apply ok" echo "apply ok"

View File

@@ -17,12 +17,12 @@ defaults:
shell: bash shell: bash
concurrency: concurrency:
group: anno-reject-${{ github.event.issue.number || github.event.issue.index || inputs.issue || 'manual' }} group: anno-reject-${{ github.event.issue.number || inputs.issue || 'manual' }}
cancel-in-progress: true cancel-in-progress: true
jobs: jobs:
reject: reject:
runs-on: mac-ci runs-on: ubuntu-latest
container: container:
image: mcr.microsoft.com/devcontainers/javascript-node:22-bookworm image: mcr.microsoft.com/devcontainers/javascript-node:22-bookworm
@@ -35,7 +35,7 @@ jobs:
- name: Derive context (event.json / workflow_dispatch) - name: Derive context (event.json / workflow_dispatch)
env: env:
INPUT_ISSUE: ${{ inputs.issue }} INPUT_ISSUE: ${{ inputs.issue }}
FORGE_API: ${{ vars.FORGE_API || vars.FORGE_BASE || vars.FORGE_BASE_URL }} FORGE_API: ${{ vars.FORGE_API || vars.FORGE_BASE }}
run: | run: |
set -euo pipefail set -euo pipefail
export EVENT_JSON="/var/run/act/workflow/event.json" export EVENT_JSON="/var/run/act/workflow/event.json"
@@ -75,11 +75,10 @@ jobs:
throw new Error("No issue number in event.json or workflow_dispatch input"); throw new Error("No issue number in event.json or workflow_dispatch input");
} }
// label name: best-effort (non-bloquant) const labelName =
let labelName = "workflow_dispatch"; ev?.label?.name ||
const lab = ev?.label; ev?.label ||
if (typeof lab === "string") labelName = lab; "workflow_dispatch";
else if (lab && typeof lab === "object" && typeof lab.name === "string") labelName = lab.name;
let apiBase = ""; let apiBase = "";
if (process.env.FORGE_API && String(process.env.FORGE_API).trim()) { if (process.env.FORGE_API && String(process.env.FORGE_API).trim()) {
@@ -104,20 +103,19 @@ jobs:
echo "✅ context:" echo "✅ context:"
sed -n '1,120p' /tmp/reject.env sed -n '1,120p' /tmp/reject.env
- name: Early gate (fast-skip, tolerant) - name: Gate on label state/rejected only
run: | run: |
set -euo pipefail set -euo pipefail
source /tmp/reject.env source /tmp/reject.env
echo " event label = $LABEL_NAME"
if [[ "$LABEL_NAME" != "state/rejected" && "$LABEL_NAME" != "workflow_dispatch" && "$LABEL_NAME" != "" && "$LABEL_NAME" != "[object Object]" ]]; then if [[ "$LABEL_NAME" != "state/rejected" && "$LABEL_NAME" != "workflow_dispatch" ]]; then
echo " label=$LABEL_NAME => skip early" echo " label=$LABEL_NAME => skip"
echo "SKIP=1" >> /tmp/reject.env echo "SKIP=1" >> /tmp/reject.env
echo "SKIP_REASON=\"label_not_rejected_event\"" >> /tmp/reject.env
exit 0 exit 0
fi fi
echo "✅ proceed (issue=$ISSUE_NUMBER)"
- name: Comment + close (only if label state/rejected is PRESENT now, and no conflict) - name: Comment + close (only if not conflicting with state/approved)
env: env:
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }} FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
run: | run: |
@@ -128,29 +126,33 @@ jobs:
test -n "${FORGE_TOKEN:-}" || { echo "❌ Missing secret FORGE_TOKEN"; exit 1; } test -n "${FORGE_TOKEN:-}" || { echo "❌ Missing secret FORGE_TOKEN"; exit 1; }
test -n "${API_BASE:-}" || { echo "❌ Missing API_BASE"; exit 1; } test -n "${API_BASE:-}" || { echo "❌ Missing API_BASE"; exit 1; }
curl -fsS \ ISSUE_JSON="$(curl -fsS \
-H "Authorization: token $FORGE_TOKEN" \ -H "Authorization: token $FORGE_TOKEN" \
-H "Accept: application/json" \ -H "Accept: application/json" \
"$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER" \ "$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER")"
-o /tmp/reject.issue.json
node --input-type=module - <<'NODE' > /tmp/reject.flags # ✅ Robust: write JSON to file (avoid argv/stdi n "-" issue)
printf '%s' "$ISSUE_JSON" > /tmp/issue.json
node --input-type=module - /tmp/issue.json > /tmp/reject.flags <<'NODE'
import fs from "node:fs"; import fs from "node:fs";
const issue = JSON.parse(fs.readFileSync("/tmp/reject.issue.json","utf8"));
const labels = Array.isArray(issue.labels) ? issue.labels.map(l => String(l.name || "")).filter(Boolean) : []; const fp = process.argv[2] || "";
const raw = fp ? fs.readFileSync(fp, "utf8") : "{}";
const issue = JSON.parse(raw || "{}");
const labels = Array.isArray(issue.labels)
? issue.labels.map(l => String(l.name || "")).filter(Boolean)
: [];
const hasApproved = labels.includes("state/approved"); const hasApproved = labels.includes("state/approved");
const hasRejected = labels.includes("state/rejected"); const hasRejected = labels.includes("state/rejected");
process.stdout.write(`HAS_APPROVED=${hasApproved ? "1":"0"}\nHAS_REJECTED=${hasRejected ? "1":"0"}\n`); process.stdout.write(`HAS_APPROVED=${hasApproved ? "1":"0"}\nHAS_REJECTED=${hasRejected ? "1":"0"}\n`);
NODE NODE
source /tmp/reject.flags source /tmp/reject.flags
# Do nothing unless state/rejected is truly present now (anti payload weird)
if [[ "${HAS_REJECTED:-0}" != "1" ]]; then
echo " state/rejected not present -> skip"
exit 0
fi
if [[ "${HAS_APPROVED:-0}" == "1" && "${HAS_REJECTED:-0}" == "1" ]]; then if [[ "${HAS_APPROVED:-0}" == "1" && "${HAS_REJECTED:-0}" == "1" ]]; then
MSG="⚠️ Conflit d'état sur le ticket #${ISSUE_NUMBER} : labels **state/approved** et **state/rejected** présents.\n\n➡ Action manuelle requise : retirer l'un des deux labels avant relance." MSG="⚠️ Conflit d'état sur le ticket #${ISSUE_NUMBER} : labels **state/approved** et **state/rejected** présents.\n\n➡ Action manuelle requise : retirer l'un des deux labels avant relance."
PAYLOAD="$(node --input-type=module -e 'console.log(JSON.stringify({body: process.argv[1]||""}))' "$MSG")" PAYLOAD="$(node --input-type=module -e 'console.log(JSON.stringify({body: process.argv[1]||""}))' "$MSG")"

View File

@@ -4,37 +4,22 @@ on:
issues: issues:
types: [opened, edited] types: [opened, edited]
concurrency:
group: auto-label-${{ github.event.issue.number || github.event.issue.index || 'manual' }}
cancel-in-progress: true
jobs: jobs:
label: label:
runs-on: mac-ci runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/devcontainers/javascript-node:22-bookworm
steps: steps:
- name: Apply labels from Type/State/Category - name: Apply labels from Type/State/Category
env: env:
# IMPORTANT: préfère FORGE_BASE (LAN) si défini, sinon FORGE_API FORGE_BASE: ${{ vars.FORGE_API || vars.FORGE_BASE }}
FORGE_BASE: ${{ vars.FORGE_BASE || vars.FORGE_API || vars.FORGE_API_BASE }}
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }} FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
REPO_FULL: ${{ gitea.repository }} REPO_FULL: ${{ gitea.repository }}
EVENT_PATH: ${{ github.event_path }} EVENT_PATH: ${{ github.event_path }}
NODE_OPTIONS: --dns-result-order=ipv4first
run: | run: |
python3 - <<'PY' python3 - <<'PY'
import json, os, re, time, urllib.request, urllib.error, socket import json, os, re, urllib.request, urllib.error
forge = (os.environ.get("FORGE_BASE") or "").rstrip("/")
if not forge:
raise SystemExit("Missing FORGE_BASE/FORGE_API repo variable (e.g. http://192.168.1.20:3000)")
token = os.environ.get("FORGE_TOKEN") or ""
if not token:
raise SystemExit("Missing secret FORGE_TOKEN")
forge = os.environ["FORGE_BASE"].rstrip("/")
token = os.environ["FORGE_TOKEN"]
owner, repo = os.environ["REPO_FULL"].split("/", 1) owner, repo = os.environ["REPO_FULL"].split("/", 1)
event_path = os.environ["EVENT_PATH"] event_path = os.environ["EVENT_PATH"]
@@ -61,9 +46,12 @@ jobs:
print("PARSED:", {"Type": t, "State": s, "Category": c}) print("PARSED:", {"Type": t, "State": s, "Category": c})
# 1) explicite depuis le body # 1) explicite depuis le body
if t: desired.add(t) if t:
if s: desired.add(s) desired.add(t)
if c: desired.add(c) if s:
desired.add(s)
if c:
desired.add(c)
# 2) fallback depuis le titre si Type absent # 2) fallback depuis le titre si Type absent
if not t: if not t:
@@ -88,56 +76,42 @@ jobs:
"Authorization": f"token {token}", "Authorization": f"token {token}",
"Accept": "application/json", "Accept": "application/json",
"Content-Type": "application/json", "Content-Type": "application/json",
"User-Agent": "archicratie-auto-label/1.1", "User-Agent": "archicratie-auto-label/1.0",
} }
def jreq(method, url, payload=None, timeout=60, retries=4, backoff=2.0): def jreq(method, url, payload=None):
data = None if payload is None else json.dumps(payload).encode("utf-8") data = None if payload is None else json.dumps(payload).encode("utf-8")
last_err = None req = urllib.request.Request(url, data=data, headers=headers, method=method)
for i in range(retries): try:
req = urllib.request.Request(url, data=data, headers=headers, method=method) with urllib.request.urlopen(req, timeout=20) as r:
try: b = r.read()
with urllib.request.urlopen(req, timeout=timeout) as r: return json.loads(b.decode("utf-8")) if b else None
b = r.read() except urllib.error.HTTPError as e:
return json.loads(b.decode("utf-8")) if b else None b = e.read().decode("utf-8", errors="replace")
except urllib.error.HTTPError as e: raise RuntimeError(f"HTTP {e.code} {method} {url}\n{b}") from e
b = e.read().decode("utf-8", errors="replace")
raise RuntimeError(f"HTTP {e.code} {method} {url}\n{b}") from e
except (TimeoutError, socket.timeout, urllib.error.URLError) as e:
last_err = e
# retry only on network/timeout
time.sleep(backoff * (i + 1))
raise RuntimeError(f"Network/timeout after retries: {method} {url}\n{last_err}")
# labels repo # labels repo
labels = jreq("GET", f"{api}/repos/{owner}/{repo}/labels?limit=1000", timeout=60) or [] labels = jreq("GET", f"{api}/repos/{owner}/{repo}/labels?limit=1000") or []
name_to_id = {x.get("name"): x.get("id") for x in labels} name_to_id = {x.get("name"): x.get("id") for x in labels}
missing = [x for x in desired if x not in name_to_id] missing = [x for x in desired if x not in name_to_id]
if missing: if missing:
raise SystemExit("Missing labels in repo: " + ", ".join(sorted(missing))) raise SystemExit("Missing labels in repo: " + ", ".join(sorted(missing)))
wanted_ids = sorted({int(name_to_id[x]) for x in desired}) wanted_ids = [name_to_id[x] for x in desired]
# labels actuels de l'issue # labels actuels de l'issue
current = jreq("GET", f"{api}/repos/{owner}/{repo}/issues/{number}/labels", timeout=60) or [] current = jreq("GET", f"{api}/repos/{owner}/{repo}/issues/{number}/labels") or []
current_ids = {int(x.get("id")) for x in current if x.get("id") is not None} current_ids = {x.get("id") for x in current if x.get("id") is not None}
final_ids = sorted(current_ids.union(wanted_ids)) final_ids = sorted(current_ids.union(wanted_ids))
# Replace labels = union (n'enlève rien) # set labels = union (n'enlève rien)
url = f"{api}/repos/{owner}/{repo}/issues/{number}/labels" url = f"{api}/repos/{owner}/{repo}/issues/{number}/labels"
try:
# IMPORTANT: on n'envoie JAMAIS une liste brute ici (ça a causé le 422) jreq("PUT", url, {"labels": final_ids})
jreq("PUT", url, {"labels": final_ids}, timeout=90, retries=4) except Exception:
jreq("PUT", url, final_ids)
# vérif post-apply (anti "timeout mais appliqué")
post = jreq("GET", f"{api}/repos/{owner}/{repo}/issues/{number}/labels", timeout=60) or []
post_ids = {int(x.get("id")) for x in post if x.get("id") is not None}
missing_ids = [i for i in wanted_ids if i not in post_ids]
if missing_ids:
raise RuntimeError(f"Labels not applied after PUT (missing ids): {missing_ids}")
print(f"OK labels #{number}: {sorted(desired)}") print(f"OK labels #{number}: {sorted(desired)}")
PY PY

View File

@@ -3,7 +3,7 @@ name: CI
on: on:
push: push:
pull_request: pull_request:
branches: [main] branches: [master]
workflow_dispatch: workflow_dispatch:
env: env:
@@ -15,7 +15,7 @@ defaults:
jobs: jobs:
build-and-anchors: build-and-anchors:
runs-on: mac-ci runs-on: ubuntu-latest
container: container:
image: mcr.microsoft.com/devcontainers/javascript-node:22-bookworm image: mcr.microsoft.com/devcontainers/javascript-node:22-bookworm

View File

@@ -26,9 +26,9 @@ concurrency:
jobs: jobs:
deploy: deploy:
runs-on: nas-deploy runs-on: ubuntu-latest
container: container:
image: localhost:5000/archicratie/nas-deploy-node22@sha256:fefa8bb307005cebec07796661ab25528dc319c33a8f1e480e1d66f90cd5cff6 image: mcr.microsoft.com/devcontainers/javascript-node:22-bookworm
steps: steps:
- name: Tools sanity - name: Tools sanity
@@ -47,179 +47,105 @@ jobs:
node --input-type=module <<'NODE' node --input-type=module <<'NODE'
import fs from "node:fs"; import fs from "node:fs";
const ev = JSON.parse(fs.readFileSync(process.env.EVENT_JSON, "utf8")); const ev = JSON.parse(fs.readFileSync(process.env.EVENT_JSON, "utf8"));
const repoObj = ev?.repository || {}; const repoObj = ev?.repository || {};
const cloneUrl = const cloneUrl =
repoObj?.clone_url || repoObj?.clone_url ||
(repoObj?.html_url ? (repoObj.html_url.replace(/\/$/,"") + ".git") : ""); (repoObj?.html_url ? (repoObj.html_url.replace(/\/$/,"") + ".git") : "");
if (!cloneUrl) throw new Error("No repository clone_url/html_url in event.json"); if (!cloneUrl) throw new Error("No repository clone_url/html_url in event.json");
const defaultBranch = repoObj?.default_branch || "main"; const defaultBranch = repoObj?.default_branch || "main";
const sha =
// Push-range (most reliable for change detection)
const before = String(ev?.before || "").trim();
const after =
(process.env.GITHUB_SHA && String(process.env.GITHUB_SHA).trim()) || (process.env.GITHUB_SHA && String(process.env.GITHUB_SHA).trim()) ||
String(ev?.after || ev?.sha || ev?.head_commit?.id || ev?.pull_request?.head?.sha || "").trim(); ev?.after ||
ev?.sha ||
ev?.head_commit?.id ||
ev?.pull_request?.head?.sha ||
"";
const shq = (s) => "'" + String(s).replace(/'/g, "'\\''") + "'"; const shq = (s) => "'" + String(s).replace(/'/g, "'\\''") + "'";
fs.writeFileSync("/tmp/deploy.env", [ fs.writeFileSync("/tmp/deploy.env", [
`REPO_URL=${shq(cloneUrl)}`, `REPO_URL=${shq(cloneUrl)}`,
`DEFAULT_BRANCH=${shq(defaultBranch)}`, `DEFAULT_BRANCH=${shq(defaultBranch)}`,
`BEFORE=${shq(before)}`, `SHA=${shq(sha)}`
`AFTER=${shq(after)}`
].join("\n") + "\n"); ].join("\n") + "\n");
NODE NODE
source /tmp/deploy.env source /tmp/deploy.env
echo "Repo URL: $REPO_URL" echo "Repo URL: $REPO_URL"
echo "Default branch: $DEFAULT_BRANCH" echo "Default branch: $DEFAULT_BRANCH"
echo "BEFORE: ${BEFORE:-<empty>}" echo "SHA: ${SHA:-<empty>}"
echo "AFTER: ${AFTER:-<empty>}"
rm -rf .git rm -rf .git
git init -q git init -q
git remote add origin "$REPO_URL" git remote add origin "$REPO_URL"
# Checkout AFTER (or default branch if missing) if [[ -n "${SHA:-}" ]]; then
if [[ -n "${AFTER:-}" ]]; then git fetch --depth 1 origin "$SHA"
git fetch --depth 50 origin "$AFTER"
git -c advice.detachedHead=false checkout -q FETCH_HEAD git -c advice.detachedHead=false checkout -q FETCH_HEAD
else else
git fetch --depth 50 origin "$DEFAULT_BRANCH" git fetch --depth 1 origin "$DEFAULT_BRANCH"
git -c advice.detachedHead=false checkout -q "origin/$DEFAULT_BRANCH" git -c advice.detachedHead=false checkout -q "origin/$DEFAULT_BRANCH"
AFTER="$(git rev-parse HEAD)" SHA="$(git rev-parse HEAD)"
echo "AFTER='$AFTER'" >> /tmp/deploy.env echo "SHA='$SHA'" >> /tmp/deploy.env
echo "Resolved AFTER: $AFTER" echo "Resolved SHA: $SHA"
fi fi
git log -1 --oneline git log -1 --oneline
- name: Gate — decide SKIP vs HOTPATCH vs FULL rebuild - name: Gate — decide HOTPATCH vs FULL rebuild
env: env:
INPUT_FORCE: ${{ inputs.force }} INPUT_FORCE: ${{ inputs.force }}
EVENT_JSON: /var/run/act/workflow/event.json
run: | run: |
set -euo pipefail set -euo pipefail
source /tmp/deploy.env source /tmp/deploy.env
FORCE="${INPUT_FORCE:-0}" FORCE="${INPUT_FORCE:-0}"
# Lire before/after du push depuis event.json (merge-proof) # liste fichiers touchés (utile pour copier les médias)
node --input-type=module <<'NODE' CHANGED="$(git show --name-only --pretty="" "$SHA" | sed '/^$/d' || true)"
import fs from "node:fs"; printf "%s\n" "$CHANGED" > /tmp/changed.txt
const ev = JSON.parse(fs.readFileSync(process.env.EVENT_JSON, "utf8"));
const before = ev?.before || "";
const after = ev?.after || ev?.sha || "";
const shq = (s) => "'" + String(s).replace(/'/g, "'\\''") + "'";
fs.writeFileSync("/tmp/gate.env", [
`EV_BEFORE=${shq(before)}`,
`EV_AFTER=${shq(after)}`
].join("\n") + "\n");
NODE
source /tmp/gate.env echo "== changed files =="
echo "$CHANGED" | sed -n '1,260p'
BEFORE="${EV_BEFORE:-}" if [[ "$FORCE" == "1" ]]; then
AFTER="${EV_AFTER:-}" echo "GO=1" >> /tmp/deploy.env
if [[ -z "${AFTER:-}" ]]; then echo "MODE='full'" >> /tmp/deploy.env
AFTER="${SHA:-}"
fi
echo "Gate ctx: BEFORE=${BEFORE:-<empty>} AFTER=${AFTER:-<empty>} FORCE=${FORCE}"
# Produire une liste CHANGED fiable :
# - si BEFORE/AFTER valides -> git diff before..after
# - sinon fallback -> diff parent1..after ou show after
CHANGED=""
Z40="0000000000000000000000000000000000000000"
if [[ -n "${BEFORE:-}" && "${BEFORE}" != "${Z40}" ]] \
&& git cat-file -e "${BEFORE}^{commit}" 2>/dev/null \
&& git cat-file -e "${AFTER}^{commit}" 2>/dev/null; then
CHANGED="$(git diff --name-only "${BEFORE}" "${AFTER}" || true)"
else
P1="$(git rev-parse "${AFTER}^" 2>/dev/null || true)"
if [[ -n "${P1:-}" ]] && git cat-file -e "${P1}^{commit}" 2>/dev/null; then
CHANGED="$(git diff --name-only "${P1}" "${AFTER}" || true)"
else
CHANGED="$(git show --name-only --pretty="" "${AFTER}" | sed '/^$/d' || true)"
fi
fi
printf "%s\n" "${CHANGED}" > /tmp/changed.txt
echo "== changed files (first 200) =="
sed -n '1,200p' /tmp/changed.txt || true
# Flags
HAS_FULL=0
HAS_HOTPATCH=0
# HOTPATCH si annotations/media touchés
if grep -qE '^(src/annotations/|public/media/)' /tmp/changed.txt; then
HAS_HOTPATCH=1
fi
# FULL si build-impacting (robuste)
# 1) Tout src/ SAUF src/annotations/
if grep -qE '^src/' /tmp/changed.txt && grep -qEv '^src/annotations/' /tmp/changed.txt; then
HAS_FULL=1
fi
# 2) scripts/
if grep -qE '^scripts/' /tmp/changed.txt; then
HAS_FULL=1
fi
# 3) Tout public/ SAUF public/media/
if grep -qE '^public/' /tmp/changed.txt && grep -qEv '^public/media/' /tmp/changed.txt; then
HAS_FULL=1
fi
# 4) fichiers racine qui changent le build / limage
if grep -qE '^(package\.json|package-lock\.json|astro\.config\.mjs|tsconfig\.json|\.npmrc|\.nvmrc|Dockerfile|docker-compose\.yml|nginx\.conf)$' /tmp/changed.txt; then
HAS_FULL=1
fi
echo "Gate flags: HAS_FULL=${HAS_FULL} HAS_HOTPATCH=${HAS_HOTPATCH}"
# Décision
if [[ "${FORCE}" == "1" ]]; then
GO=1
MODE="full"
echo "✅ force=1 -> MODE=full (rebuild+restart)" echo "✅ force=1 -> MODE=full (rebuild+restart)"
elif [[ "${HAS_FULL}" == "1" ]]; then exit 0
GO=1 fi
MODE="full"
echo "✅ build-impacting change -> MODE=full (rebuild+restart)" # Auto mode: uniquement annotations/media => hotpatch only
elif [[ "${HAS_HOTPATCH}" == "1" ]]; then if echo "$CHANGED" | grep -qE '^(src/annotations/|public/media/)'; then
GO=1 echo "GO=1" >> /tmp/deploy.env
MODE="hotpatch" echo "MODE='hotpatch'" >> /tmp/deploy.env
echo "✅ annotations/media change -> MODE=hotpatch" echo "✅ annotations/media change -> MODE=hotpatch"
else else
GO=0 echo "GO=0" >> /tmp/deploy.env
MODE="skip" echo "MODE='skip'" >> /tmp/deploy.env
echo " no relevant change -> skip deploy" echo " no annotations/media change -> skip deploy"
fi fi
echo "GO=${GO}" >> /tmp/deploy.env - name: Install docker client + docker compose plugin (v2) + python yaml
echo "MODE='${MODE}'" >> /tmp/deploy.env
- name: Toolchain sanity + resolve COMPOSE_PROJECT_NAME
run: | run: |
set -euo pipefail set -euo pipefail
source /tmp/deploy.env source /tmp/deploy.env
[[ "${GO:-0}" == "1" ]] || { echo " skipped"; exit 0; } [[ "${GO:-0}" == "1" ]] || { echo " skipped"; exit 0; }
# tools are prebaked in the image apt-get -o Acquire::Retries=5 -o Acquire::ForceIPv4=true update
git --version apt-get install -y --no-install-recommends ca-certificates curl docker.io python3 python3-yaml
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 version
docker compose version docker compose version
python3 -c 'import yaml; print("PyYAML OK")' python3 --version
# Reuse existing compose project name if containers already exist # Reuse existing compose project name if containers already exist
PROJ="$(docker inspect archicratie-web-blue --format '{{ index .Config.Labels "com.docker.compose.project" }}' 2>/dev/null || true)" PROJ="$(docker inspect archicratie-web-blue --format '{{ index .Config.Labels "com.docker.compose.project" }}' 2>/dev/null || true)"
@@ -297,19 +223,6 @@ jobs:
docker image tag archicratie-web:blue "archicratie-web:blue.BAK.${TS}" || true docker image tag archicratie-web:blue "archicratie-web:blue.BAK.${TS}" || true
docker image tag archicratie-web:green "archicratie-web:green.BAK.${TS}" || true docker image tag archicratie-web:green "archicratie-web:green.BAK.${TS}" || true
BUILD_TIME_RAW="$(TZ=Europe/Paris date '+%Y-%m-%dT%H:%M:%S%z')"
BUILD_TIME="${BUILD_TIME_RAW:0:${#BUILD_TIME_RAW}-2}:${BUILD_TIME_RAW:${#BUILD_TIME_RAW}-2}"
PUBLIC_OPS_ENV=staging \
PUBLIC_OPS_UPSTREAM=web_blue \
PUBLIC_BUILD_SHA="${AFTER}" \
PUBLIC_BUILD_TIME="${BUILD_TIME}" \
node scripts/write-ops-health.mjs
test -f public/__ops/health.json
echo "=== public/__ops/health.json (blue/staging) ==="
cat public/__ops/health.json
docker compose -p "$PROJ" -f docker-compose.yml build web_blue docker compose -p "$PROJ" -f docker-compose.yml build web_blue
docker rm -f archicratie-web-blue || true docker rm -f archicratie-web-blue || true
docker compose -p "$PROJ" -f docker-compose.yml up -d --force-recreate --remove-orphans web_blue docker compose -p "$PROJ" -f docker-compose.yml up -d --force-recreate --remove-orphans web_blue
@@ -319,11 +232,6 @@ jobs:
wait_url "http://127.0.0.1:8081/annotations-index.json" "blue annotations-index" wait_url "http://127.0.0.1:8081/annotations-index.json" "blue annotations-index"
wait_url "http://127.0.0.1:8081/pagefind/pagefind.js" "blue pagefind.js" wait_url "http://127.0.0.1:8081/pagefind/pagefind.js" "blue pagefind.js"
wait_url "http://127.0.0.1:8081/__ops/health.json" "blue ops health"
curl -fsS --max-time 6 "http://127.0.0.1:8081/__ops/health.json" \
| python3 -c 'import sys, json; j=json.load(sys.stdin); print("env=", j.get("env")); print("upstream=", j.get("upstream")); print("buildSha=", j.get("buildSha")); print("builtAt=", j.get("builtAt"))'
CANON="$(curl -fsS --max-time 6 "http://127.0.0.1:8081/archicrat-ia/chapitre-1/" | grep -oE 'rel="canonical" href="[^"]+"' | head -n1 || true)" CANON="$(curl -fsS --max-time 6 "http://127.0.0.1:8081/archicrat-ia/chapitre-1/" | grep -oE 'rel="canonical" href="[^"]+"' | head -n1 || true)"
echo "canonical(blue)=$CANON" echo "canonical(blue)=$CANON"
echo "$CANON" | grep -q 'https://staging\.archicratie\.trans-hands\.synology\.me/' || { echo "$CANON" | grep -q 'https://staging\.archicratie\.trans-hands\.synology\.me/' || {
@@ -371,19 +279,6 @@ jobs:
docker compose -p "$PROJ" -f docker-compose.yml up -d --force-recreate --remove-orphans web_green || true docker compose -p "$PROJ" -f docker-compose.yml up -d --force-recreate --remove-orphans web_green || true
} }
BUILD_TIME_RAW="$(TZ=Europe/Paris date '+%Y-%m-%dT%H:%M:%S%z')"
BUILD_TIME="${BUILD_TIME_RAW:0:${#BUILD_TIME_RAW}-2}:${BUILD_TIME_RAW:${#BUILD_TIME_RAW}-2}"
PUBLIC_OPS_ENV=prod \
PUBLIC_OPS_UPSTREAM=web_green \
PUBLIC_BUILD_SHA="${AFTER}" \
PUBLIC_BUILD_TIME="${BUILD_TIME}" \
node scripts/write-ops-health.mjs
test -f public/__ops/health.json
echo "=== public/__ops/health.json (green/prod) ==="
cat public/__ops/health.json
# build/restart green # build/restart green
if ! docker compose -p "$PROJ" -f docker-compose.yml build web_green; then if ! docker compose -p "$PROJ" -f docker-compose.yml build web_green; then
echo "❌ build green failed"; rollback; exit 4 echo "❌ build green failed"; rollback; exit 4
@@ -397,11 +292,6 @@ jobs:
if ! wait_url "http://127.0.0.1:8082/annotations-index.json" "green annotations-index"; then rollback; exit 4; fi if ! wait_url "http://127.0.0.1:8082/annotations-index.json" "green annotations-index"; then rollback; exit 4; fi
if ! wait_url "http://127.0.0.1:8082/pagefind/pagefind.js" "green pagefind.js"; then rollback; exit 4; fi if ! wait_url "http://127.0.0.1:8082/pagefind/pagefind.js" "green pagefind.js"; then rollback; exit 4; fi
if ! wait_url "http://127.0.0.1:8082/__ops/health.json" "green ops health"; then rollback; exit 4; fi
curl -fsS --max-time 6 "http://127.0.0.1:8082/__ops/health.json" \
| python3 -c 'import sys, json; j=json.load(sys.stdin); print("env=", j.get("env")); print("upstream=", j.get("upstream")); print("buildSha=", j.get("buildSha")); print("builtAt=", j.get("builtAt"))'
CANON="$(curl -fsS --max-time 6 "http://127.0.0.1:8082/archicrat-ia/chapitre-1/" | grep -oE 'rel="canonical" href="[^"]+"' | head -n1 || true)" CANON="$(curl -fsS --max-time 6 "http://127.0.0.1:8082/archicrat-ia/chapitre-1/" | grep -oE 'rel="canonical" href="[^"]+"' | head -n1 || true)"
echo "canonical(green)=$CANON" echo "canonical(green)=$CANON"
echo "$CANON" | grep -q 'https://archicratie\.trans-hands\.synology\.me/' || { echo "$CANON" | grep -q 'https://archicratie\.trans-hands\.synology\.me/' || {

View File

@@ -1,788 +0,0 @@
name: Proposer Apply (Queue)
on:
issues:
types: [labeled]
push:
branches: [main]
workflow_dispatch:
inputs:
issue:
description: "Issue number to prioritize (optional)"
required: false
default: ""
env:
NODE_OPTIONS: --dns-result-order=ipv4first
defaults:
run:
shell: bash
concurrency:
group: proposer-queue-main
cancel-in-progress: false
jobs:
apply-proposer:
runs-on: mac-ci
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: Derive context (event.json / workflow_dispatch / push)
env:
INPUT_ISSUE: ${{ inputs.issue }}
EVENT_NAME_IN: ${{ github.event_name }}
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/proposer.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) ||
0;
const labelName =
ev?.label?.name ||
(typeof ev?.label === "string" ? ev.label : "") ||
"";
const eventName =
String(process.env.EVENT_NAME_IN || "").trim() ||
(ev?.issue ? "issues" : (ev?.before || ev?.after ? "push" : "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)}`,
`EVENT_NAME=${sh(eventName)}`,
`API_BASE=${sh(apiBase)}`
].join("\n") + "\n");
NODE
echo "Context:"
sed -n '1,200p' /tmp/proposer.env
- name: Early gate (tolerant on empty issue label payload)
run: |
set -euo pipefail
source /tmp/proposer.env
echo "event=$EVENT_NAME label=${LABEL_NAME:-<empty>}"
if [[ "$EVENT_NAME" == "issues" ]]; then
if [[ -n "${LABEL_NAME:-}" && "$LABEL_NAME" != "state/approved" ]]; then
echo "issues/labeled with explicit non-approved label=$LABEL_NAME -> skip"
echo 'SKIP=1' >> /tmp/proposer.env
echo 'SKIP_REASON="label_not_state_approved_event"' >> /tmp/proposer.env
exit 0
fi
fi
echo "Proceed to API-based selection/gating"
- name: Checkout default branch
run: |
set -euo pipefail
source /tmp/proposer.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: Detect app dir (repo-root vs ./site)
run: |
set -euo pipefail
source /tmp/proposer.env
[[ "${SKIP:-0}" != "1" ]] || { echo "Skipped"; exit 0; }
APP_DIR="."
if [[ -d "site" && -f "site/package.json" ]]; then
APP_DIR="site"
fi
echo "APP_DIR=$APP_DIR" >> /tmp/proposer.env
echo "APP_DIR=$APP_DIR"
test -f "$APP_DIR/package.json" || {
echo "package.json missing in APP_DIR=$APP_DIR"
exit 1
}
test -d "$APP_DIR/scripts" || {
echo "scripts/ missing in APP_DIR=$APP_DIR"
exit 1
}
- name: Select next proposer batch (by path)
env:
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
run: |
set -euo pipefail
source /tmp/proposer.env
[[ "${SKIP:-0}" != "1" ]] || { echo "Skipped"; exit 0; }
test -n "${FORGE_TOKEN:-}" || {
echo "Missing secret FORGE_TOKEN"
exit 1
}
export GITEA_OWNER="$OWNER"
export GITEA_REPO="$REPO"
export FORGE_API="$API_BASE"
cd "$APP_DIR"
test -f scripts/pick-proposer-issue.mjs || {
echo "missing scripts/pick-proposer-issue.mjs in APP_DIR=$APP_DIR"
ls -la scripts | sed -n '1,200p' || true
exit 1
}
node scripts/pick-proposer-issue.mjs "${ISSUE_NUMBER:-0}" > /tmp/proposer.pick.env
cat /tmp/proposer.pick.env >> /tmp/proposer.env
source /tmp/proposer.pick.env
if [[ "${TARGET_FOUND:-0}" != "1" ]]; then
echo 'SKIP=1' >> /tmp/proposer.env
echo "SKIP_REASON=${TARGET_REASON:-no_target}" >> /tmp/proposer.env
echo "No target batch"
exit 0
fi
echo "Target batch:"
grep -E '^(TARGET_PRIMARY_ISSUE|TARGET_ISSUES|TARGET_COUNT|TARGET_CHEMIN)=' /tmp/proposer.env
- name: Derive deterministic batch identity
run: |
set -euo pipefail
source /tmp/proposer.env
[[ "${SKIP:-0}" != "1" ]] || { echo "Skipped"; exit 0; }
export TARGET_ISSUES TARGET_CHEMIN
node --input-type=module - <<'NODE'
import fs from "node:fs";
import crypto from "node:crypto";
const issues = String(process.env.TARGET_ISSUES || "")
.trim()
.split(/\s+/)
.filter(Boolean)
.sort((a, b) => Number(a) - Number(b));
const chemin = String(process.env.TARGET_CHEMIN || "").trim();
const keySource = `${chemin}::${issues.join(",")}`;
const hash = crypto.createHash("sha1").update(keySource).digest("hex").slice(0, 12);
const primary = issues[0] || "0";
const batchBranch = `bot/proposer-${primary}-${hash}`;
fs.appendFileSync(
"/tmp/proposer.env",
[
`BATCH_KEY=${JSON.stringify(keySource)}`,
`BATCH_HASH=${JSON.stringify(hash)}`,
`BATCH_BRANCH=${JSON.stringify(batchBranch)}`
].join("\n") + "\n"
);
NODE
echo "Batch identity:"
grep -E '^(BATCH_KEY|BATCH_HASH|BATCH_BRANCH)=' /tmp/proposer.env
- name: Inspect open proposer PRs
env:
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
run: |
set -euo pipefail
source /tmp/proposer.env
[[ "${SKIP:-0}" != "1" ]] || { echo "Skipped"; exit 0; }
curl -fsS \
-H "Authorization: token $FORGE_TOKEN" \
-H "Accept: application/json" \
"$API_BASE/api/v1/repos/$OWNER/$REPO/pulls?state=open&limit=100" \
-o /tmp/open_pulls.json
export TARGET_ISSUES="${TARGET_ISSUES:-}"
export BATCH_BRANCH="${BATCH_BRANCH:-}"
export BATCH_KEY="${BATCH_KEY:-}"
node --input-type=module - <<'NODE' >> /tmp/proposer.env
import fs from "node:fs";
const pulls = JSON.parse(fs.readFileSync("/tmp/open_pulls.json", "utf8"));
const issues = String(process.env.TARGET_ISSUES || "")
.trim()
.split(/\s+/)
.filter(Boolean);
const batchBranch = String(process.env.BATCH_BRANCH || "");
const batchKey = String(process.env.BATCH_KEY || "");
const proposerOpen = Array.isArray(pulls)
? pulls.filter((pr) => String(pr?.head?.ref || "").startsWith("bot/proposer-"))
: [];
const sameBatch = proposerOpen.find((pr) => {
const ref = String(pr?.head?.ref || "");
const title = String(pr?.title || "");
const body = String(pr?.body || "");
if (batchBranch && ref === batchBranch) return true;
if (batchKey && body.includes(`Batch-Key: ${batchKey}`)) return true;
return issues.some((n) =>
ref.startsWith(`bot/proposer-${n}-`) ||
title.includes(`#${n}`) ||
body.includes(`#${n}`) ||
body.includes(`ticket #${n}`)
);
});
const out = [];
if (sameBatch) {
out.push("SKIP=1");
out.push(`SKIP_REASON=${JSON.stringify("issue_already_has_open_pr")}`);
out.push(`OPEN_PR_URL=${JSON.stringify(String(sameBatch.html_url || sameBatch.url || ""))}`);
out.push(`OPEN_PR_BRANCH=${JSON.stringify(String(sameBatch?.head?.ref || ""))}`);
} else if (proposerOpen.length > 0) {
const first = proposerOpen[0];
out.push("SKIP=1");
out.push(`SKIP_REASON=${JSON.stringify("queue_busy_open_proposer_pr")}`);
out.push(`OPEN_PR_URL=${JSON.stringify(String(first.html_url || first.url || ""))}`);
out.push(`OPEN_PR_BRANCH=${JSON.stringify(String(first?.head?.ref || ""))}`);
}
process.stdout.write(out.join("\n") + (out.length ? "\n" : ""));
NODE
- name: Guard on remote batch branch before heavy work
run: |
set -euo pipefail
source /tmp/proposer.env
[[ "${SKIP:-0}" != "1" ]] || { echo "Skipped"; exit 0; }
if git ls-remote --exit-code --heads origin "$BATCH_BRANCH" >/dev/null 2>&1; then
echo 'SKIP=1' >> /tmp/proposer.env
echo 'SKIP_REASON="batch_branch_exists_without_pr"' >> /tmp/proposer.env
echo "OPEN_PR_BRANCH=${BATCH_BRANCH}" >> /tmp/proposer.env
echo "Remote batch branch already exists -> skip duplicate materialization"
exit 0
fi
echo "Remote batch branch is free"
- name: Comment issue if queued / skipped
if: ${{ always() }}
env:
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
run: |
set -euo pipefail
source /tmp/proposer.env || true
[[ "${SKIP:-0}" == "1" ]] || exit 0
[[ "${EVENT_NAME:-}" != "push" ]] || exit 0
if [[ "${SKIP_REASON:-}" == "label_not_state_approved_event" || "${SKIP_REASON:-}" == "label_not_state_approved" ]]; then
echo "Skip reason=${SKIP_REASON} -> no comment"
exit 0
fi
test -n "${FORGE_TOKEN:-}" || exit 0
ISSUE_TO_COMMENT="${ISSUE_NUMBER:-0}"
if [[ "$ISSUE_TO_COMMENT" == "0" || -z "$ISSUE_TO_COMMENT" ]]; then
ISSUE_TO_COMMENT="${TARGET_PRIMARY_ISSUE:-0}"
fi
[[ "$ISSUE_TO_COMMENT" != "0" ]] || exit 0
case "${SKIP_REASON:-}" in
queue_busy_open_proposer_pr)
MSG="Ticket queued in proposer queue. An open proposer PR already exists: ${OPEN_PR_URL:-"(URL unavailable)"}. The workflow will resume after merge on main."
;;
issue_already_has_open_pr)
MSG="This batch already has an open proposer PR: ${OPEN_PR_URL:-"(URL unavailable)"}"
;;
batch_branch_exists_without_pr)
MSG="This batch already has a remote batch branch (${OPEN_PR_BRANCH:-"(unknown branch)"}). Manual inspection is required before any new proposer PR is created."
;;
batch_branch_already_materialized)
MSG="This batch was already materialized by another run on branch ${OPEN_PR_BRANCH:-"(unknown branch)"}. No duplicate PR was created."
;;
explicit_issue_missing_chemin)
MSG="Proposer Apply: cannot process this ticket automatically because field Chemin is missing or unreadable."
;;
explicit_issue_missing_type)
MSG="Proposer Apply: cannot process this ticket automatically because field Type is missing or unreadable."
;;
explicit_issue_not_approved)
MSG="Proposer Apply: this ticket is not currently labeled state/approved."
;;
explicit_issue_rejected)
MSG="Proposer Apply: this ticket has state/rejected and is not eligible for the proposer queue."
;;
no_open_approved_proposer_issue)
MSG="No approved proposer ticket is currently waiting."
;;
*)
MSG="Proposer Apply: skip - ${SKIP_REASON:-unspecified reason}."
;;
esac
export MSG
node --input-type=module - <<'NODE' > /tmp/proposer.skip.comment.json
const msg = process.env.MSG || "";
process.stdout.write(JSON.stringify({ body: msg }));
NODE
curl -fsS -X POST \
-H "Authorization: token $FORGE_TOKEN" \
-H "Content-Type: application/json" \
"$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_TO_COMMENT/comments" \
--data-binary @/tmp/proposer.skip.comment.json || true
- name: NPM harden
run: |
set -euo pipefail
source /tmp/proposer.env
[[ "${SKIP:-0}" != "1" ]] || exit 0
cd "$APP_DIR"
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
- name: Install deps
run: |
set -euo pipefail
source /tmp/proposer.env
[[ "${SKIP:-0}" != "1" ]] || exit 0
cd "$APP_DIR"
npm ci --no-audit --no-fund
- name: Build dist baseline
run: |
set -euo pipefail
source /tmp/proposer.env
[[ "${SKIP:-0}" != "1" ]] || exit 0
cd "$APP_DIR"
npm run build
- name: Apply proposer batch on bot branch
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/proposer.env
[[ "${SKIP:-0}" != "1" ]] || { echo "Skipped"; exit 0; }
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)"
BR="$BATCH_BRANCH"
echo "BRANCH=$BR" >> /tmp/proposer.env
git checkout -b "$BR"
export GITEA_OWNER="$OWNER"
export GITEA_REPO="$REPO"
export FORGE_API="$API_BASE"
LOG="/tmp/proposer-apply.log"
: > "$LOG"
RC=0
FAILED_ISSUE=""
for ISSUE in $TARGET_ISSUES; do
echo "" >> "$LOG"
echo "== ticket #$ISSUE ==" >> "$LOG"
set +e
(cd "$APP_DIR" && node scripts/apply-ticket.mjs "$ISSUE" --alias --commit) >> "$LOG" 2>&1
STEP_RC=$?
set -e
if [[ "$STEP_RC" -ne 0 ]]; then
RC="$STEP_RC"
FAILED_ISSUE="$ISSUE"
break
fi
done
echo "APPLY_RC=$RC" >> /tmp/proposer.env
echo "FAILED_ISSUE=${FAILED_ISSUE}" >> /tmp/proposer.env
echo "Apply log (tail):"
tail -n 220 "$LOG" || true
END_SHA="$(git rev-parse HEAD)"
if [[ "$RC" -ne 0 ]]; then
echo "NOOP=0" >> /tmp/proposer.env
exit 0
fi
if [[ "$START_SHA" == "$END_SHA" ]]; then
echo "NOOP=1" >> /tmp/proposer.env
else
echo "NOOP=0" >> /tmp/proposer.env
echo "END_SHA=$END_SHA" >> /tmp/proposer.env
fi
- name: Rebase bot branch on latest main
continue-on-error: true
run: |
set -euo pipefail
source /tmp/proposer.env || true
[[ "${SKIP:-0}" != "1" ]] || exit 0
[[ "${APPLY_RC:-0}" == "0" ]] || exit 0
[[ "${NOOP:-0}" == "0" ]] || exit 0
LOG="/tmp/proposer-apply.log"
git fetch origin "$DEFAULT_BRANCH"
set +e
git rebase "origin/$DEFAULT_BRANCH" >> "$LOG" 2>&1
RC=$?
set -e
if [[ "$RC" -ne 0 ]]; then
git rebase --abort || true
fi
echo "REBASE_RC=$RC" >> /tmp/proposer.env
echo "Rebase log (tail):"
tail -n 220 "$LOG" || true
- name: Comment issues on failure
if: ${{ always() }}
env:
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
run: |
set -euo pipefail
source /tmp/proposer.env || true
[[ "${SKIP:-0}" != "1" ]] || exit 0
APPLY_RC="${APPLY_RC:-0}"
REBASE_RC="${REBASE_RC:-0}"
if [[ "$APPLY_RC" == "0" && "$REBASE_RC" == "0" ]]; then
echo "No failure detected"
exit 0
fi
test -n "${FORGE_TOKEN:-}" || exit 0
if [[ -f /tmp/proposer-apply.log ]]; then
BODY="$(tail -n 160 /tmp/proposer-apply.log | sed 's/\r$//')"
else
BODY="(no proposer log found)"
fi
export BODY APPLY_RC REBASE_RC FAILED_ISSUE
if [[ "$APPLY_RC" != "0" ]]; then
export FAILURE_KIND="apply"
else
export FAILURE_KIND="rebase"
fi
node --input-type=module - <<'NODE' > /tmp/proposer.failure.comment.json
const body = process.env.BODY || "";
const applyRc = process.env.APPLY_RC || "0";
const rebaseRc = process.env.REBASE_RC || "0";
const failedIssue = process.env.FAILED_ISSUE || "unknown";
const kind = process.env.FAILURE_KIND || "apply";
const msg =
kind === "apply"
? `Batch proposer failed on ticket #${failedIssue} (rc=${applyRc}).\n\n\`\`\`\n${body}\n\`\`\`\n`
: `Rebase proposer failed on main (rc=${rebaseRc}).\n\n\`\`\`\n${body}\n\`\`\`\n`;
process.stdout.write(JSON.stringify({ body: msg }));
NODE
for ISSUE in ${TARGET_ISSUES:-}; do
curl -fsS -X POST \
-H "Authorization: token $FORGE_TOKEN" \
-H "Content-Type: application/json" \
"$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE/comments" \
--data-binary @/tmp/proposer.failure.comment.json || true
done
- name: Late guard against duplicate batch materialization
run: |
set -euo pipefail
source /tmp/proposer.env || true
[[ "${SKIP:-0}" != "1" ]] || exit 0
[[ "${APPLY_RC:-0}" == "0" ]] || exit 0
[[ "${REBASE_RC:-0}" == "0" ]] || exit 0
[[ "${NOOP:-0}" == "0" ]] || exit 0
REMOTE_SHA="$(git ls-remote --heads origin "$BATCH_BRANCH" | awk 'NR==1 {print $1}')"
if [[ -n "${REMOTE_SHA:-}" && "${REMOTE_SHA}" != "${END_SHA:-}" ]]; then
echo 'SKIP=1' >> /tmp/proposer.env
echo 'SKIP_REASON="batch_branch_already_materialized"' >> /tmp/proposer.env
echo "OPEN_PR_BRANCH=${BATCH_BRANCH}" >> /tmp/proposer.env
echo "Remote batch branch already exists at $REMOTE_SHA -> skip duplicate push/PR"
exit 0
fi
echo "Late guard OK"
- name: Push bot branch
if: ${{ always() }}
env:
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
run: |
set -euo pipefail
source /tmp/proposer.env || true
[[ "${SKIP:-0}" != "1" ]] || exit 0
[[ "${APPLY_RC:-0}" == "0" ]] || { echo "Apply failed -> skip push"; exit 0; }
[[ "${REBASE_RC:-0}" == "0" ]] || { echo "Rebase failed -> skip push"; exit 0; }
[[ "${NOOP:-0}" == "0" ]] || { echo "No-op -> skip push"; exit 0; }
[[ -n "${BRANCH:-}" ]] || { echo "BRANCH unset -> 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 issues + close issues
if: ${{ always() }}
env:
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
run: |
set -euo pipefail
source /tmp/proposer.env || true
[[ "${SKIP:-0}" != "1" ]] || exit 0
[[ "${APPLY_RC:-0}" == "0" ]] || exit 0
[[ "${REBASE_RC:-0}" == "0" ]] || exit 0
[[ "${NOOP:-0}" == "0" ]] || exit 0
[[ -n "${BRANCH:-}" ]] || { echo "BRANCH unset -> skip PR"; exit 0; }
test -n "${FORGE_TOKEN:-}" || { echo "Missing FORGE_TOKEN"; exit 1; }
OPEN_PRS_JSON="$(curl -fsS \
-H "Authorization: token $FORGE_TOKEN" \
-H "Accept: application/json" \
"$API_BASE/api/v1/repos/$OWNER/$REPO/pulls?state=open&limit=100")"
export OPEN_PRS_JSON BATCH_BRANCH BATCH_KEY
EXISTING_PR_URL="$(node --input-type=module -e '
const pulls = JSON.parse(process.env.OPEN_PRS_JSON || "[]");
const branch = String(process.env.BATCH_BRANCH || "");
const key = String(process.env.BATCH_KEY || "");
const current = Array.isArray(pulls)
? pulls.find((pr) => {
const ref = String(pr?.head?.ref || "");
const body = String(pr?.body || "");
return (branch && ref === branch) || (key && body.includes(`Batch-Key: ${key}`));
})
: null;
process.stdout.write(current ? String(current.html_url || current.url || "") : "");
')"
if [[ -n "${EXISTING_PR_URL:-}" ]]; then
echo "PR already exists for this batch: $EXISTING_PR_URL"
exit 0
fi
if [[ "${TARGET_COUNT:-0}" == "1" ]]; then
PR_TITLE="proposer: apply ticket #${TARGET_PRIMARY_ISSUE}"
else
PR_TITLE="proposer: apply ${TARGET_COUNT} tickets on ${TARGET_CHEMIN}"
fi
export PR_TITLE TARGET_CHEMIN TARGET_ISSUES BRANCH END_SHA DEFAULT_BRANCH OWNER BATCH_KEY
node --input-type=module -e '
import fs from "node:fs";
const issues = String(process.env.TARGET_ISSUES || "")
.trim()
.split(/\s+/)
.filter(Boolean);
const body = [
`PR auto depuis ticket${issues.length > 1 ? "s" : ""} ${issues.map((n) => `#${n}`).join(", ")} (state/approved).`,
"",
`- Chemin: ${process.env.TARGET_CHEMIN || "(inconnu)"}`,
"- Tickets:",
...issues.map((n) => ` - #${n}`),
`- Branche: ${process.env.BRANCH || ""}`,
`- Commit: ${process.env.END_SHA || "unknown"}`,
`- Batch-Key: ${process.env.BATCH_KEY || ""}`,
"",
"Merge si CI OK."
].join("\n");
fs.writeFileSync(
"/tmp/proposer.pr.json",
JSON.stringify({
title: process.env.PR_TITLE || "proposer: apply tickets",
body,
base: process.env.DEFAULT_BRANCH || "main",
head: `${process.env.OWNER}:${process.env.BRANCH}`,
allow_maintainer_edit: true
})
);
'
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 @/tmp/proposer.pr.json)"
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
}
for ISSUE in $TARGET_ISSUES; do
export ISSUE PR_URL
node --input-type=module -e '
import fs from "node:fs";
const issue = process.env.ISSUE || "";
const url = process.env.PR_URL || "";
const msg =
`PR proposer creee pour le ticket #${issue} : ${url}\n\n` +
`Le ticket est cloture automatiquement ; la discussion peut se poursuivre dans la PR.`;
fs.writeFileSync(
"/tmp/proposer.issue.close.comment.json",
JSON.stringify({ body: msg })
);
'
curl -fsS -X POST \
-H "Authorization: token $FORGE_TOKEN" \
-H "Content-Type: application/json" \
"$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE/comments" \
--data-binary @/tmp/proposer.issue.close.comment.json
curl -fsS -X PATCH \
-H "Authorization: token $FORGE_TOKEN" \
-H "Content-Type: application/json" \
"$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE" \
--data-binary '{"state":"closed"}'
ISSUE_STATE="$(curl -fsS \
-H "Authorization: token $FORGE_TOKEN" \
-H "Accept: application/json" \
"$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE" | \
node --input-type=module -e 'let s=""; process.stdin.on("data", d => s += d); process.stdin.on("end", () => { const j = JSON.parse(s || "{}"); process.stdout.write(String(j.state || "")); });')"
[[ "$ISSUE_STATE" == "closed" ]] || {
echo "Issue #$ISSUE is still not closed after PATCH"
exit 1
}
done
echo "PR: $PR_URL"
- name: Finalize
if: ${{ always() }}
run: |
set -euo pipefail
source /tmp/proposer.env || true
[[ "${SKIP:-0}" != "1" ]] || exit 0
if [[ "${APPLY_RC:-0}" != "0" ]]; then
echo "Apply failed (rc=${APPLY_RC})"
exit "${APPLY_RC}"
fi
if [[ "${REBASE_RC:-0}" != "0" ]]; then
echo "Rebase failed (rc=${REBASE_RC})"
exit "${REBASE_RC}"
fi
echo "Proposer queue OK"

View File

@@ -3,7 +3,7 @@ on: [push, workflow_dispatch]
jobs: jobs:
smoke: smoke:
runs-on: mac-ci runs-on: ubuntu-latest
steps: steps:
- run: node -v && npm -v - run: node -v && npm -v
- run: echo "runner OK" - run: echo "runner OK"

4
.gitignore vendored
View File

@@ -28,7 +28,3 @@ public/favicon_io.zip
# macOS # macOS
.DS_Store .DS_Store
# local temp workspace
.tmp/
public/__ops/health.json

View File

@@ -86,10 +86,6 @@ function rehypeDedupeIds() {
} }
export default defineConfig({ export default defineConfig({
legacy: {
collectionsBackwardsCompat: true,
},
output: "static", output: "static",
trailingSlash: "always", trailingSlash: "always",
site: process.env.PUBLIC_SITE ?? "http://localhost:4321", site: process.env.PUBLIC_SITE ?? "http://localhost:4321",

View File

@@ -1,11 +0,0 @@
{
"accepted_resets": {
"archicrat-ia/prologue/index.html": "Reset intentionnel des ancres après réimport DOCX et révision substantielle du prologue depuis la source officielle. Site neuf, sans annotations ni compatibilité descendante à préserver.",
"archicrat-ia/chapitre-1/index.html": "Reset intentionnel des ancres après révision doctrinale substantielle du chapitre 1. Site neuf, sans annotations ni compatibilité descendante à préserver.",
"archicrat-ia/chapitre-2/index.html": "Reset intentionnel des ancres après restauration doctrinale substantielle du chapitre 2 depuis la bonne source officielle. Site neuf, sans annotations ni compatibilité descendante à préserver.",
"archicrat-ia/chapitre-3/index.html": "Reset intentionnel des ancres après réimport DOCX et perfectionnement doctrinal substantiel du chapitre 3 depuis la source officielle. Site neuf, sans annotations ni compatibilité descendante à préserver.",
"archicrat-ia/chapitre-4/index.html": "Reset intentionnel des ancres après réimport DOCX et stabilisation doctrinale substantielle du chapitre 4 depuis la source officielle. Site neuf, sans annotations ni compatibilité descendante à préserver.",
"archicrat-ia/chapitre-5/index.html": "Reset intentionnel des ancres après réimport DOCX et stabilisation doctrinale substantielle du chapitre 5 depuis la source officielle. Site neuf, sans annotations ni compatibilité descendante à préserver.",
"archicrat-ia/conclusion/index.html": "Reset intentionnel des ancres après réimport DOCX et révision substantielle de la conclusion depuis la source officielle. Site neuf, sans annotations ni compatibilité descendante à préserver."
}
}

View File

@@ -25,19 +25,6 @@ Objectif : déployer une nouvelle version du site sur le NAS (DS220+) sans jamai
➡️ Déploiement = `docs/DEPLOY_PROD_SYNOLOGY_DS220.md` (procédure détaillée, à jour). ➡️ Déploiement = `docs/DEPLOY_PROD_SYNOLOGY_DS220.md` (procédure détaillée, à jour).
## Mise à jour (2026-03-03) — Gate CI de déploiement (SKIP / HOTPATCH / FULL) + preuves A/B
La procédure de déploiement “vivante” est désormais pilotée par **Gitea Actions** via le workflow :
- `.gitea/workflows/deploy-staging-live.yml`
Ce workflow décide automatiquement :
- **FULL** (rebuild + restart blue + green) dès quun changement impacte le build (ex: `src/content/`, `src/pages/`, `scripts/`, `src/anchors/`, etc.)
- **HOTPATCH** (patch JSON + copie media) quand le changement ne concerne que `src/annotations/` et/ou `public/media/`
- **SKIP** sinon
Les preuves et la procédure de test reproductible A/B sont documentées dans :
➡️ `docs/runbooks/DEPLOY-BLUE-GREEN.md` → section “CI Deploy gate (merge-proof) + Tests A/B + preuve alias injection”.
## Schéma (résumé, sans commandes) ## Schéma (résumé, sans commandes)
- Ne jamais toucher au slot live. - Ne jamais toucher au slot live.

File diff suppressed because it is too large Load Diff

View File

@@ -202,33 +202,4 @@ docker compose logs --tail=200 web_blue
docker compose logs --tail=200 web_green docker compose logs --tail=200 web_green
# Si tu veux suivre en live : # Si tu veux suivre en live :
docker compose logs -f web_green docker compose logs -f web_green
## Historique synthétique (2026-03-03) — Stabilisation CI/CD “zéro surprise”
### Problème initial observé
- Déploiement parfois lancé en “hotpatch” alors quun rebuild était nécessaire.
- Sur merge commits, la détection de fichiers modifiés pouvait être ambiguë.
- Résultat : besoin de `force=1` manuel pour éviter des incohérences.
### Correctif appliqué
- Gate CI rendu **merge-proof** :
- lecture de `BEFORE` et `AFTER` depuis `event.json`
- calcul des fichiers modifiés via `git diff --name-only BEFORE AFTER`
- Politique de décision stabilisée :
- FULL auto dès quun changement impacte build/runtime (content/pages/scripts/anchors/etc.)
- HOTPATCH auto uniquement pour annotations/media
### Preuves
- Test A (touch src/content) :
- Gate flags: HAS_FULL=1 HAS_HOTPATCH=0 → MODE=full
- Test B (touch src/annotations) :
- Gate flags: HAS_FULL=0 HAS_HOTPATCH=1 → MODE=hotpatch
### Audit post-déploiement (preuves côté NAS)
- 8081 + 8082 répondent HTTP 200
- `/para-index.json` + `/annotations-index.json` OK
- Aliases injectés visibles dans HTML via `.para-alias` quand alias présent

View File

@@ -1,147 +1,51 @@
# START-HERE — Archicratie / Édition Web (v3) # START-HERE — Archicratie / Édition Web (v2)
> Onboarding + exploitation “nickel chrome” (DEV → Gitea → CI → Release → Blue/Green → Edge/SSO → localhost auto-sync) > Onboarding + exploitation “nickel chrome” (DEV → Gitea → CI → Release → Blue/Green → Edge/SSO)
## 0) TL;DR (la règle dor) ## 0) TL;DR (la règle dor)
- **Gitea = source canonique**.
- **Gitea = source canonique**. - **main est protégé** : toute modification passe par **branche → PR → CI → merge**.
- **`main` est protégée** : 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 NAS nest pas la source** : si un hotfix est fait sur NAS, il doit être **backporté immédiatement** via PR. - **Le site est statique Astro** : la prod sert du HTML (nginx), laccès est contrôlé au niveau reverse-proxy (Traefik + Authelia).
- **Le site est statique Astro** : la prod sert du HTML via nginx ; laccès est contrôlé au niveau reverse-proxy (Traefik + Authelia).
- **Le localhost automatique nest pas le repo de dev** : il tourne depuis un **worktree dédié**, synchronisé sur `origin/main`.
---
## 1) Architecture mentale (ultra simple) ## 1) Architecture mentale (ultra simple)
- **DEV (Mac Studio)** : édition + tests + commit + push
- **DEV canonique (Mac Studio)** : édition, dev, tests, commits, pushes - **Gitea** : dépôt canon + PR + CI (CI.yaml)
- **Gitea** : dépôt canonique, PR, CI, workflows éditoriaux - **NAS (DS220+)** : déploiement “blue/green”
- **NAS (DS220+)** : déploiement blue/green - `web_blue` (staging upstream) → `127.0.0.1:8081`
- `web_blue` → staging upstream → `127.0.0.1:8081` - `web_green` (live upstream)`127.0.0.1:8082`
- `web_green` → live upstream → `127.0.0.1:8082` - **Edge (Traefik)** : route les hosts
- **Edge (Traefik)** : routage des hosts
- `staging.archicratie...` → 8081 - `staging.archicratie...` → 8081
- `archicratie...` → 8082 - `archicratie...` → 8082
- **Authelia** devant, via middleware `chain-auth@file` - **Authelia** devant, via middleware `chain-auth@file`
- **Localhost auto-sync**
- un **repo canonique de développement**
- un **worktree localhost miroir de `origin/main`**
- un **agent de sync**
- un **agent Astro**
---
## 2) Répertoires & conventions (repo) ## 2) Répertoires & conventions (repo)
### 2.1 Contenu canon (édition) ### 2.1 Contenu canon (édition)
- `src/content/**` : contenu MD / MDX canon (Astro content collections)
- `src/content/**` : contenu MD / MDX canon - `src/pages/**` : routes Astro (index, [...slug], etc.)
- `src/pages/**` : routes Astro - `src/components/**` : composants UI (SiteNav, TOC, SidePanel, etc.)
- `src/components/**` : composants UI - `src/layouts/**` : layouts (EditionLayout, SiteLayout)
- `src/layouts/**` : layouts
- `src/styles/**` : CSS global - `src/styles/**` : CSS global
### 2.2 Annotations (pré-Édition “tickets”) ### 2.2 Annotations (pré-Édition “tickets”)
- `src/annotations/<workKey>/<slug>.yml` - `src/annotations/<workKey>/<slug>.yml`
- Exemple : - Exemple : `src/annotations/archicrat-ia/prologue.yml`
`src/annotations/archicrat-ia/prologue.yml` - Objectif : stocker “Références / Médias / Commentaires” par page et par paragraphe (`p-...`).
Objectif :
stocker “Références / Médias / Commentaires” par page et par paragraphe (`p-...`).
### 2.3 Scripts (tooling / build) ### 2.3 Scripts (tooling / build)
- `scripts/inject-anchor-aliases.mjs` : injection aliases dans dist
- `scripts/inject-anchor-aliases.mjs` : injection aliases dans `dist` - `scripts/dedupe-ids-dist.mjs` : retire IDs dupliqués dans dist
- `scripts/dedupe-ids-dist.mjs` : retrait IDs dupliqués - `scripts/build-para-index.mjs` : index paragraphes (postbuild / predev)
- `scripts/build-para-index.mjs` : index paragraphes - `scripts/build-annotations-index.mjs` : index annotations (postbuild / predev)
- `scripts/build-annotations-index.mjs` : index annotations - `scripts/check-anchors.mjs` : contrat stabilité dancres (CI)
- `scripts/check-anchors.mjs` : contrat stabilité dancres
- `scripts/check-annotations*.mjs` : sanity YAML + médias - `scripts/check-annotations*.mjs` : sanity YAML + médias
> Important : ces scripts ne sont pas accessoires. > Important : les scripts sont **partie intégrante** de la stabilité (IDs/ancres/indexation).
> Ils font partie du contrat de stabilité éditoriale. > On évite “la magie” : tout est scripté + vérifié.
--- ## 3) Workflow Git “pro” (main protégé)
### 3.1 Cycle standard (toute modif)
en bash :
## 3) Les trois espaces à ne jamais confondre
### 3.1 Repo canonique de développement
```text
/Volumes/FunIA/dev/archicratie-edition/site
```
Usage :
- développement normal
- branches de travail
- nouvelles fonctionnalités
- corrections manuelles
- commits
- pushes
- PR
### 3.2 Worktree localhost miroir de `main`
```text
/Users/s-funia/ops-local/archicratie/localhost-worktree
```
Branche attendue :
```text
localhost-sync
```
Usage :
- exécuter le localhost automatique
- refléter `origin/main`
- ne jamais servir despace de développement
### 3.3 Ops local hors repo
```text
/Users/s-funia/ops-local/archicratie
```
Usage :
- scripts dexploitation
- état
- logs
- automatisation `launchd`
---
## 4) Pourquoi cette séparation existe
Il ne faut pas utiliser le repo canonique de développement comme serveur localhost permanent.
Sinon on mélange :
- travail en cours
- commits non poussés
- essais temporaires
- état réellement publié sur `main`
Le résultat devient ambigu.
La séparation retenue est donc :
- **repo canonique** = espace de développement
- **worktree localhost** = miroir exécutable de `origin/main`
- **ops local** = scripts et automatisation
Cest cette séparation qui rend le système lisible, robuste et opérable.
---
## 5) Workflow Git “pro” (main protégée)
### 5.1 Cycle standard (toute modif)
```bash
git checkout main git checkout main
git pull --ff-only git pull --ff-only
@@ -156,48 +60,37 @@ npm run test:anchors
git add -A git add -A
git commit -m "xxx: description claire" git commit -m "xxx: description claire"
git push -u origin "$BR" git push -u origin "$BR"
```
### 5.2 PR vers `main` ### 3.2 PR vers main
- ouvrir une PR dans Gitea Ouvrir PR dans Gitea
- attendre une CI verte
- merger
- laisser les workflows faire le reste
### 5.3 Cas spécial : hotfix prod (NAS) CI doit être verte
On peut faire un hotfix durgence côté NAS si nécessaire. Merge PR → main
Mais létat final doit toujours revenir dans Gitea : ### 3.3 Cas spécial : hotfix prod (NAS)
- branche On peut faire un hotfix “urgence” en prod/staging si nécessaire…
- PR
- CI
- merge
--- MAIS : létat final doit revenir dans Gitea : branche → PR → CI → merge.
## 6) Déploiement (NAS) — principe ## 4) Déploiement (NAS) — principe
### 4.1 Release pack
### 6.1 Release pack On génère un pack “reproductible” (source + config + scripts) puis on déploie.
On génère un pack reproductible, puis on déploie. ### 4.2 Blue/Green
### 6.2 Blue/Green web_blue = staging upstream (8081)
- `web_blue` = staging (`8081`) web_green = live upstream (8082)
- `web_green` = live (`8082`)
Le reverse-proxy choisit lupstream selon le host demandé. Edge Traefik sélectionne quel host pointe vers quel upstream.
--- ## 5) Check-list “≤ 10 commandes” (happy path complet)
### 5.1 DEV (Mac)
## 7) Happy path complet
### 7.1 DEV (Mac)
```bash
git checkout main && git pull --ff-only git checkout main && git pull --ff-only
git checkout -b chore/my-change-$(date +%Y%m%d) git checkout -b chore/my-change-$(date +%Y%m%d)
@@ -206,258 +99,55 @@ rm -rf .astro node_modules/.vite dist
npm run build npm run build
npm run test:anchors npm run test:anchors
npm run dev npm run dev
```
### 7.2 Push + PR ### 5.2 Push + PR
```bash
git add -A git add -A
git commit -m "chore: my change" git commit -m "chore: my change"
git push -u origin chore/my-change-YYYYMMDD git push -u origin chore/my-change-YYYYMMDD
``` # ouvrir PR dans Gitea
Puis ouvrir la PR dans Gitea. ### 5.3 Déploiement NAS (résumé)
### 7.3 Déploiement NAS Voir docs/runbooks/DEPLOY-BLUE-GREEN.md.
Voir : ## 6) Problèmes “classiques” + diagnostic rapide
### 6.1 “Le staging ne ressemble pas au local”
```text # Comparer upstream direct 8081 vs 8082 :
docs/runbooks/DEPLOY-BLUE-GREEN.md
```
---
## 8) Localhost auto-sync — ce quil faut retenir
Le localhost automatique sert à voir **la vérité de `main`**, pas à développer du neuf.
### 8.1 Scripts principaux
#### Script de sync
```text
~/ops-local/archicratie/auto-sync-localhost.sh
```
Rôle :
- fetch `origin/main`
- réaligner le worktree localhost
- lancer `npm ci` si besoin
- redéclencher lagent Astro si nécessaire
#### Script Astro
```text
~/ops-local/archicratie/run-astro-localhost.sh
```
Rôle :
- lancer `astro dev`
- depuis le bon worktree
- avec le bon runtime Node
- sur `127.0.0.1:4321`
> Oui : ce script est nécessaire.
> Il isole proprement le lancement du serveur Astro dans un contexte `launchd` stable.
### 8.2 LaunchAgents
#### Agent sync
```text
~/Library/LaunchAgents/me.archicratie.localhost-sync.plist
```
#### Agent Astro
```text
~/Library/LaunchAgents/me.archicratie.localhost-astro.plist
```
### 8.3 Document de référence
Pour tout le détail dexploitation du localhost automatique, lire :
```text
docs/OPS-LOCALHOST-AUTO-SYNC.md
```
---
## 9) Règle dor : il y a deux usages locaux distincts
### 9.1 Voir ce qui est réellement sur `main`
Utiliser :
```text
http://127.0.0.1:4321
```
Ce localhost doit être considéré comme :
**un miroir local exécutable de `origin/main`**
### 9.2 Développer / tester une nouvelle fonctionnalité
Utiliser le repo canonique :
```bash
cd /Volumes/FunIA/dev/archicratie-edition/site
npm run dev
```
Donc :
- **localhost auto-sync** = vérité de `main`
- **localhost de dev manuel** = expérimentation en cours
Il ne faut pas les confondre.
---
## 10) Ce quil ne faut pas faire
### 10.1 Ne pas développer dans le worktree localhost
Le worktree localhost est piloté automatiquement.
Il peut être :
- réaligné
- nettoyé
- redémarré
Donc :
- pas de commits dedans
- pas de dev feature dedans
- pas dexpérimentation de fond dedans
### 10.2 Ne pas utiliser le repo canonique comme miroir auto-sync
Sinon on mélange :
- espace de dev
- état publié
- serveur local permanent
### 10.3 Ne pas remettre les scripts ops sur un volume externe
Les scripts dops doivent rester sous `HOME`.
Le fait de les mettre sous `/Volumes/...` a déjà provoqué des erreurs du type :
```text
Operation not permitted
```
### 10.4 Ne pas supprimer `run-astro-localhost.sh`
Ce script fait partie de larchitecture actuelle.
Le supprimer reviendrait à réintroduire le flou entre sync Git et exécution dAstro.
---
## 11) Commandes de contrôle essentielles
### 11.1 État global
```bash
~/ops-local/archicratie/doctor-localhost.sh
```
### 11.2 État Git
```bash
git -C ~/ops-local/archicratie/localhost-worktree rev-parse HEAD
git -C /Volumes/FunIA/dev/archicratie-edition/site ls-remote origin refs/heads/main
git -C ~/ops-local/archicratie/localhost-worktree branch --show-current
```
### 11.3 État LaunchAgents
```bash
launchctl print "gui/$(id -u)/me.archicratie.localhost-sync" | sed -n '1,160p'
launchctl print "gui/$(id -u)/me.archicratie.localhost-astro" | sed -n '1,160p'
```
### 11.4 État logs
```bash
tail -n 120 ~/ops-local/archicratie/logs/auto-sync-localhost.log
tail -n 120 ~/ops-local/archicratie/logs/astro-localhost.log
tail -n 80 ~/Library/Logs/archicratie-localhost-sync.err.log
tail -n 80 ~/Library/Logs/archicratie-localhost-astro.err.log
```
### 11.5 État serveur
```bash
lsof -nP -iTCP:4321 -sTCP:LISTEN
PID="$(lsof -tiTCP:4321 -sTCP:LISTEN | head -n 1)"
ps -p "$PID" -o pid=,command=
lsof -a -p "$PID" -d cwd
```
### 11.6 Vérification contenu
```bash
curl -s http://127.0.0.1:4321/archicrat-ia/prologue/ | grep -n "taxe Zucman"
```
---
## 12) Problèmes classiques + diagnostic
### 12.1 “Le staging ne ressemble pas au local”
Comparer les upstream directs :
```bash
curl -sS http://127.0.0.1:8081/ | head -n 2 curl -sS http://127.0.0.1:8081/ | head -n 2
curl -sS http://127.0.0.1:8082/ | head -n 2 curl -sS http://127.0.0.1:8082/ | head -n 2
```
Vérifier le routeur edge : # Vérifier quel routeur edge répond (header diag) :
```bash
curl -sSI -H 'Host: staging.archicratie.trans-hands.synology.me' http://127.0.0.1:18080/ \ curl -sSI -H 'Host: staging.archicratie.trans-hands.synology.me' http://127.0.0.1:18080/ \
| grep -iE 'HTTP/|location:|x-archi-router' | grep -iE 'HTTP/|location:|x-archi-router'
```
Voir : # Lire docs/runbooks/EDGE-TRAEFIK.md.
```text ### 6.2 Canonical incorrect (localhost en prod)
docs/runbooks/EDGE-TRAEFIK.md
```
### 12.2 Canonical incorrect Cause racine : site dans Astro = PUBLIC_SITE non injecté au build.
Cause probable : `PUBLIC_SITE` mal injecté au build. Fix canonique : voir docs/runbooks/ENV-PUBLIC_SITE.md.
Test : Test :
```bash
curl -sS http://127.0.0.1:8082/ | grep -oE 'rel="canonical" href="[^"]+"' | head -1 curl -sS http://127.0.0.1:8082/ | grep -oE 'rel="canonical" href="[^"]+"' | head -1
```
Voir : ### 6.3 Contrat “anchors” en échec après migration dURL
```text 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.
docs/runbooks/ENV-PUBLIC_SITE.md
```
### 12.3 Contrat anchors en échec après migration dURL # Procédure safe :
Procédure safe : Backup baseline :
```bash
cp -a tests/anchors-baseline.json /tmp/anchors-baseline.json.bak.$(date +%F-%H%M%S) 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' node - <<'NODE'
import fs from 'fs'; import fs from 'fs';
const p='tests/anchors-baseline.json'; const p='tests/anchors-baseline.json';
@@ -471,213 +161,16 @@ fs.writeFileSync(p, JSON.stringify(out,null,2)+'\n');
console.log('updated keys:', Object.keys(j).length, '->', Object.keys(out).length); console.log('updated keys:', Object.keys(j).length, '->', Object.keys(out).length);
NODE NODE
Re-run :
npm run test:anchors npm run test:anchors
```
### 12.4 “Le localhost auto-sync ne montre pas les dernières modifs” ## 7) Ce que létape 9 doit faire (orientation)
Commande réflexe : Stabiliser le pipeline “tickets → YAML annotations”
```bash Formaliser la spec YAML + merge + anti-doublon (voir docs/EDITORIAL-ANNOTATIONS-SPEC.md)
~/ops-local/archicratie/doctor-localhost.sh
```
Puis : Durcir lonboarding (ce START-HERE + runbooks)
```bash Éviter les régressions par tests (anchors / annotations / smoke)
git -C ~/ops-local/archicratie/localhost-worktree rev-parse HEAD
git -C /Volumes/FunIA/dev/archicratie-edition/site ls-remote origin refs/heads/main
```
Si les SHA diffèrent :
- le sync na pas tourné
- ou lagent sync a un problème
### 12.5 “Le SHA est bon mais le contenu web est faux”
Vérifier quel Astro écoute réellement :
```bash
lsof -nP -iTCP:4321 -sTCP:LISTEN
PID="$(lsof -tiTCP:4321 -sTCP:LISTEN | head -n 1)"
ps -p "$PID" -o pid=,command=
lsof -a -p "$PID" -d cwd
```
Attendu :
- commande contenant `astro dev`
- cwd = `~/ops-local/archicratie/localhost-worktree`
### 12.6 Erreur `EBADENGINE`
Cause probable :
- Node 23 utilisé au lieu de Node 22
Résolution :
- forcer `node@22` dans les scripts et les LaunchAgents
### 12.7 Erreur `Operation not permitted`
Cause probable :
- scripts dops placés sous `/Volumes/...`
Résolution :
- garder les scripts sous :
```text
~/ops-local/archicratie
```
### 12.8 Erreur `EPERM` sur `astro.mjs`
Cause probable :
- ancien worktree sur volume externe
- ancien chemin résiduel
- Astro lancé depuis un mauvais emplacement
Résolution :
- worktree localhost sous :
```text
~/ops-local/archicratie/localhost-worktree
```
- scripts cohérents avec ce chemin
- réinstallation propre via :
```bash
~/ops-local/archicratie/install-localhost-sync.sh
```
---
## 13) Redémarrage machine
Après reboot, le comportement attendu est :
1. le LaunchAgent sync se recharge
2. le LaunchAgent Astro se recharge
3. le worktree localhost est réaligné
4. Astro redémarre sur `127.0.0.1:4321`
### Vérification rapide après reboot
```bash
~/ops-local/archicratie/doctor-localhost.sh
```
Si nécessaire :
```bash
~/ops-local/archicratie/install-localhost-sync.sh
```
---
## 14) Procédure de secours manuelle
### Forcer un sync
```bash
~/ops-local/archicratie/auto-sync-localhost.sh
```
### Réinstaller proprement le dispositif local
```bash
~/ops-local/archicratie/install-localhost-sync.sh
```
### Diagnostic complet
```bash
~/ops-local/archicratie/doctor-localhost.sh
```
---
## 15) Décision dexploitation finale
La politique retenue est la suivante :
- **repo canonique** = espace de développement
- **worktree localhost** = miroir automatique de `main`
- **ops sous HOME** = scripts, logs, automation
- **LaunchAgent sync** = réalignement Git
- **LaunchAgent Astro** = exécution stable du serveur local
- **Astro local** = lancé uniquement depuis le worktree localhost
Cette séparation rend le dispositif plus :
- lisible
- robuste
- opérable
- antifragile
---
## 16) Résumé opératoire
### Pour voir la vérité de `main`
Ouvrir :
```text
http://127.0.0.1:4321
```
Le serveur doit provenir de :
```text
/Users/s-funia/ops-local/archicratie/localhost-worktree
```
### Pour développer
Travailler dans :
```text
/Volumes/FunIA/dev/archicratie-edition/site
```
avec les commandes habituelles.
### Pour réparer vite
```bash
~/ops-local/archicratie/doctor-localhost.sh
~/ops-local/archicratie/auto-sync-localhost.sh
```
---
## 17) Mémoire courte
Si un jour plus rien nest clair, repartir de ces commandes :
```bash
~/ops-local/archicratie/doctor-localhost.sh
git -C ~/ops-local/archicratie/localhost-worktree rev-parse HEAD
git -C /Volumes/FunIA/dev/archicratie-edition/site ls-remote origin refs/heads/main
lsof -nP -iTCP:4321 -sTCP:LISTEN
```
Puis lire :
```bash
tail -n 120 ~/ops-local/archicratie/logs/auto-sync-localhost.log
tail -n 120 ~/ops-local/archicratie/logs/astro-localhost.log
```
---
## 18) Statut actuel visé
Quand tout fonctionne correctement :
- le worktree localhost pointe sur le même SHA que `origin/main`
- `astro dev` écoute sur `127.0.0.1:4321`
- son cwd est `~/ops-local/archicratie/localhost-worktree`
- le contenu servi correspond au contenu mergé sur `main`
Cest létat de référence à préserver.

View File

@@ -199,348 +199,4 @@ Ne jamais modifier dist/ “à la main” sur NAS.
Si un hotfix prod est indispensable : documenter et backporter via PR Gitea. 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). Le canonical dépend du build : PUBLIC_SITE doit être injecté (voir runbook ENV-PUBLIC_SITE).
## 10) CI Deploy (Gitea Actions) — Gate SKIP / HOTPATCH / FULL (merge-proof) + preuves
Cette section documente le comportement **canonique** du workflow :
- `.gitea/workflows/deploy-staging-live.yml`
Objectif : **zéro surprise**.
On ne veut plus “penser à force=1”.
Le gate doit décider automatiquement, y compris sur des **merge commits**.
### 10.1 — Principe (ce que fait réellement le gate)
Le job `deploy` calcule les fichiers modifiés entre :
- `BEFORE` = commit précédent (avant le push sur main)
- `AFTER` = commit actuel (après le push / merge sur main)
Puis il classe le déploiement dans un mode :
- **MODE=full**
- rebuild image + restart `archicratie-web-blue` (8081) + `archicratie-web-green` (8082)
- warmup endpoints (para-index, annotations-index, pagefind.js)
- vérification canonical staging + live
- **MODE=hotpatch**
- rebuild dun `annotations-index.json` consolidé depuis `src/annotations/**`
- patch direct dans les conteneurs en cours dexécution (blue+green)
- copie des médias modifiés `public/media/**` vers `/usr/share/nginx/html/media/**`
- smoke sur `/annotations-index.json` des deux ports
- **MODE=skip**
- pas de déploiement (on évite le bruit)
⚠️ Important : le mode “hotpatch” **ne rebuild pas** Astro.
Donc toute modification de contenu, routes, scripts, anchors, etc. doit déclencher **full**.
### 10.2 — Matrice de décision (règles officielles)
Le gate définit deux flags :
- `HAS_FULL=1` si changement “build-impacting”
- `HAS_HOTPATCH=1` si changement “annotations/media only”
Règle de priorité :
1) Si `HAS_FULL=1`**MODE=full**
2) Sinon si `HAS_HOTPATCH=1`**MODE=hotpatch**
3) Sinon → **MODE=skip**
#### 10.2.1 — Changements qui déclenchent FULL (build-impacting)
Exemples typiques (non exhaustif, mais on couvre le cœur) :
- `src/content/**` (contenu MD/MDX)
- `src/pages/**` (routes Astro)
- `src/anchors/**` (aliases dancres)
- `scripts/**` (tooling postbuild : injection, index, tests)
- `src/layouts/**`, `src/components/**`, `src/styles/**` (rendu et scripts inline)
- `astro.config.mjs`, `package.json`, `package-lock.json`
- `Dockerfile`, `docker-compose.yml`, `nginx.conf`
- `.gitea/workflows/**` (changement infra CI/CD)
=> On veut **full** pour garantir cohérence et éviter “site partiellement mis à jour”.
#### 10.2.2 — Changements qui déclenchent HOTPATCH (sans rebuild)
Uniquement :
- `src/annotations/**` (shards YAML)
- `public/media/**` (assets média)
=> On veut hotpatch pour vitesse et éviter rebuild NAS.
### 10.3 — “Merge-proof” : pourquoi on ne lit PAS seulement `git show $SHA`
Sur un merge commit, `git show --name-only $SHA` peut être trompeur selon le contexte.
La méthode robuste est :
- utiliser `event.json` (Gitea Actions) pour récupérer `before` et `after`
- calculer `git diff --name-only BEFORE AFTER`
Cest ce qui rend le gate **merge-proof**.
### 10.4 — Tests de preuve A/B (reproductibles)
Ces tests valident le gate sans ambiguïté.
But : vérifier que le mode choisi est EXACTEMENT celui attendu.
#### Test A — toucher `src/content/...` (FULL auto)
1) Créer une branche test
2) Modifier 1 fichier dans `src/content/` (ex : ajouter une ligne de commentaire non destructive)
3) PR → merge dans `main`
4) Vérifier dans `deploy-staging-live.yml` :
Attendus :
- `Gate flags: HAS_FULL=1 HAS_HOTPATCH=0`
- `✅ build-impacting change -> MODE=full (rebuild+restart)`
- Les étapes FULL (blue puis green) sexécutent réellement
#### Test B — toucher `src/annotations/...` uniquement (HOTPATCH auto)
1) Créer une branche test
2) Modifier 1 fichier sous `src/annotations/**` (ex: un champ comment, ts, etc.)
3) PR → merge dans `main`
4) Vérifier dans `deploy-staging-live.yml` :
Attendus :
- `Gate flags: HAS_FULL=0 HAS_HOTPATCH=1`
- `✅ annotations/media change -> MODE=hotpatch`
- Les étapes FULL sont “skip” (durée 0s)
- Létape HOTPATCH sexécute réellement
### 10.5 — Preuve opérationnelle côté NAS (2 URLs + 2 commandes)
But : prouver que staging+live servent bien les endpoints essentiels (et que le déploiement na pas “fait semblant”).
#### 10.5.1 — Deux URLs à vérifier (staging et live)
- Staging (blue) : `http://127.0.0.1:8081/`
- Live (green) : `http://127.0.0.1:8082/`
#### 10.5.2 — Deux commandes minimales (zéro débat)
```bash
curl -fsSI http://127.0.0.1:8081/ | head -n 1
curl -fsSI http://127.0.0.1:8082/ | head -n 1
---
## 10) CI Deploy (Gitea Actions) — Gate SKIP / HOTPATCH / FULL (merge-proof) + preuves
Cette section documente le comportement **canonique** du workflow :
- `.gitea/workflows/deploy-staging-live.yml`
Objectif : **zéro surprise**.
On ne veut plus “penser à force=1”.
Le gate doit décider automatiquement, y compris sur des **merge commits**.
### 10.1 — Principe (ce que fait réellement le gate)
Le job `deploy` calcule les fichiers modifiés entre :
- `BEFORE` = commit précédent (avant le push sur main)
- `AFTER` = commit actuel (après le push / merge sur main)
Puis il classe le déploiement dans un mode :
- **MODE=full**
- rebuild image + restart `archicratie-web-blue` (8081) + `archicratie-web-green` (8082)
- warmup endpoints (para-index, annotations-index, pagefind.js)
- vérification canonical staging + live
- **MODE=hotpatch**
- rebuild dun `annotations-index.json` consolidé depuis `src/annotations/**`
- patch direct dans les conteneurs en cours dexécution (blue+green)
- copie des médias modifiés `public/media/**` vers `/usr/share/nginx/html/media/**`
- smoke sur `/annotations-index.json` des deux ports
- **MODE=skip**
- pas de déploiement (on évite le bruit)
⚠️ Important : le mode “hotpatch” **ne rebuild pas** Astro.
Donc toute modification de contenu, routes, scripts, anchors, etc. doit déclencher **full**.
### 10.2 — Matrice de décision (règles officielles)
Le gate définit deux flags :
- `HAS_FULL=1` si changement “build-impacting”
- `HAS_HOTPATCH=1` si changement “annotations/media only”
Règle de priorité :
1) Si `HAS_FULL=1` → **MODE=full**
2) Sinon si `HAS_HOTPATCH=1` → **MODE=hotpatch**
3) Sinon → **MODE=skip**
#### 10.2.1 — Changements qui déclenchent FULL (build-impacting)
Exemples typiques (non exhaustif, mais on couvre le cœur) :
- `src/content/**` (contenu MD/MDX)
- `src/pages/**` (routes Astro)
- `src/anchors/**` (aliases dancres)
- `scripts/**` (tooling postbuild : injection, index, tests)
- `src/layouts/**`, `src/components/**`, `src/styles/**` (rendu et scripts inline)
- `astro.config.mjs`, `package.json`, `package-lock.json`
- `Dockerfile`, `docker-compose.yml`, `nginx.conf`
- `.gitea/workflows/**` (changement infra CI/CD)
=> On veut **full** pour garantir cohérence et éviter “site partiellement mis à jour”.
#### 10.2.2 — Changements qui déclenchent HOTPATCH (sans rebuild)
Uniquement :
- `src/annotations/**` (shards YAML)
- `public/media/**` (assets média)
=> On veut hotpatch pour vitesse et éviter rebuild NAS.
### 10.3 — “Merge-proof” : pourquoi on ne lit PAS seulement `git show $SHA`
Sur un merge commit, `git show --name-only $SHA` peut être trompeur selon le contexte.
La méthode robuste est :
- utiliser `event.json` (Gitea Actions) pour récupérer `before` et `after`
- calculer `git diff --name-only BEFORE AFTER`
Cest ce qui rend le gate **merge-proof**.
### 10.4 — Tests de preuve A/B (reproductibles)
Ces tests valident le gate sans ambiguïté.
But : vérifier que le mode choisi est EXACTEMENT celui attendu.
#### Test A — toucher `src/content/...` (FULL auto)
1) Créer une branche test
2) Modifier 1 fichier dans `src/content/` (ex : ajouter une ligne de commentaire non destructive)
3) PR → merge dans `main`
4) Vérifier dans `deploy-staging-live.yml` :
Attendus :
- `Gate flags: HAS_FULL=1 HAS_HOTPATCH=0`
- `✅ build-impacting change -> MODE=full (rebuild+restart)`
- Les étapes FULL (blue puis green) sexécutent réellement
#### Test B — toucher `src/annotations/...` uniquement (HOTPATCH auto)
1) Créer une branche test
2) Modifier 1 fichier sous `src/annotations/**` (ex: un champ comment, ts, etc.)
3) PR → merge dans `main`
4) Vérifier dans `deploy-staging-live.yml` :
Attendus :
- `Gate flags: HAS_FULL=0 HAS_HOTPATCH=1`
- `✅ annotations/media change -> MODE=hotpatch`
- Les étapes FULL sont “skip” (durée 0s)
- Létape HOTPATCH sexécute réellement
### 10.5 — Preuve opérationnelle côté NAS (2 URLs + 2 commandes)
But : prouver que staging+live servent bien les endpoints essentiels (et que le déploiement na pas “fait semblant”).
#### 10.5.1 — Deux URLs à vérifier (staging et live)
- Staging (blue) : `http://127.0.0.1:8081/`
- Live (green) : `http://127.0.0.1:8082/`
#### 10.5.2 — Deux commandes minimales (zéro débat)
en bash :
curl -fsSI http://127.0.0.1:8081/ | head -n 1
curl -fsSI http://127.0.0.1:8082/ | head -n 1
Attendu : HTTP/1.1 200 OK des deux côtés.
10.6 — Preuve “alias injection” (ancre ancienne → nouvelle) sur une page
Contexte : lorsquun paragraphe change (ex: ticket “Proposer” appliqué),
lID de paragraphe peut changer, mais on doit préserver les liens anciens via :
src/anchors/anchor-aliases.json
injection build-time dans dist (span .para-alias)
10.6.1 — Check rapide (staging + live)
Remplacer OLD/NEW par tes ids réels :
Attendu : HTTP/1.1 200 OK des deux côtés.
10.6 — Preuve “alias injection” (ancre ancienne → nouvelle) sur une page
Contexte : lorsquun paragraphe change (ex: ticket “Proposer” appliqué),
lID de paragraphe peut changer, mais on doit préserver les liens anciens via :
src/anchors/anchor-aliases.json
injection build-time dans dist (span .para-alias)
10.6.1 — Check rapide (staging + live)
Remplacer OLD/NEW par tes ids réels :
OLD="p-1-60c7ea48"
NEW="p-1-a21087b0"
for P in 8081 8082; do
echo "=== $P ==="
HTML="$(curl -fsS "http://127.0.0.1:${P}/archicrat-ia/chapitre-3/" | tr -d '\r')"
echo "OLD count: $(printf '%s' "$HTML" | grep -o "$OLD" | wc -l | tr -d ' ')"
echo "NEW count: $(printf '%s' "$HTML" | grep -o "$NEW" | wc -l | tr -d ' ')"
printf '%s\n' "$HTML" | grep -nE "$OLD|$NEW|class=\"para-alias\"" | head -n 40 || true
done
Attendu :
présence dun alias : <span id="$OLD" class="para-alias"...>
présence du nouveau paragraphe : <p id="$NEW">...
10.6.2 — Check “lien ancien ne casse pas” (HTTP 200)
for P in 8081 8082; do
curl -fsSI "http://127.0.0.1:${P}/archicrat-ia/chapitre-3/#${OLD}" | head -n 1
done
Attendu : HTTP/1.1 200 OK et navigation fonctionnelle côté navigateur.
10.7 — Troubleshooting gate (symptômes typiques)
Symptom 1 : job bloqué “Set up job” très longtemps
Causes fréquentes :
runner indisponible / capacity saturée
runner ne récupère pas les tâches (fetch_timeout trop court + réseau instable)
erreur dans “Gate — decide …” qui casse bash (et donne limpression dun hang)
Commandes NAS (diagnostic rapide) :
docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Image}}' | grep -E 'gitea-act-runner|registry|archicratie-web'
docker logs --since 30m --tail 400 gitea-act-runner | tail -n 200
Symptom 2 : conditional binary operator expected
Cause :
test bash du type [[ "$X" == "1" && "$Y" == "2" ]] mal formé
variable vide non quotée
usage dun opérateur non supporté dans la shell effective
Fix :
set -euo pipefail
toujours quoter : [[ "${VAR:-}" == "..." ]]
logguer BEFORE/AFTER/FORCE et sassurer quils ne sont pas vides
Symptom 3 : le gate liste “trop de fichiers” alors quon a changé 1 seul fichier
Cause :
comparaison faite sur le mauvais range (ex: git show sur merge, ou mauvais parent)
Fix :
toujours utiliser git diff --name-only "$BEFORE" "$AFTER" (merge-proof)
confirmer dans le log : Gate ctx: BEFORE=... AFTER=...

1779
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,8 +10,7 @@
"clean": "rm -rf dist", "clean": "rm -rf dist",
"build": "astro build", "build": "astro build",
"build:clean": "npm run clean && npm run build", "build:clean": "npm run clean && npm run build",
"build:search": "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",
"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 && npm run build:search",
"import": "node scripts/import-docx.mjs", "import": "node scripts/import-docx.mjs",
"apply:ticket": "node scripts/apply-ticket.mjs", "apply:ticket": "node scripts/apply-ticket.mjs",
"audit:dist": "node scripts/audit-dist.mjs", "audit:dist": "node scripts/audit-dist.mjs",
@@ -26,11 +25,11 @@
"ci": "CI=1 npm test" "ci": "CI=1 npm test"
}, },
"dependencies": { "dependencies": {
"@astrojs/mdx": "^5.0.0", "@astrojs/mdx": "^4.3.13",
"astro": "^6.0.2" "astro": "^5.17.3"
}, },
"devDependencies": { "devDependencies": {
"@astrojs/sitemap": "^3.7.1", "@astrojs/sitemap": "^3.7.0",
"mammoth": "^1.11.0", "mammoth": "^1.11.0",
"pagefind": "^1.4.0", "pagefind": "^1.4.0",
"rehype-autolink-headings": "^7.1.0", "rehype-autolink-headings": "^7.1.0",

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 816 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 822 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 822 KiB

View File

@@ -1 +1 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone","orientation":"any"} {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}

View File

@@ -9,9 +9,8 @@ import { spawnSync } from "node:child_process";
* *
* Conçu pour: * Conçu pour:
* - prendre un ticket [Correction]/[Fact-check] (issue) avec Chemin + Ancre + Proposition * - prendre un ticket [Correction]/[Fact-check] (issue) avec Chemin + Ancre + Proposition
* - retrouver le bon paragraphe dans le .mdx/.md * - retrouver le bon paragraphe dans le .mdx
* - remplacer proprement * - remplacer proprement
* - ne JAMAIS toucher au frontmatter
* - optionnel: écrire un alias dancre old->new (build-time) dans src/anchors/anchor-aliases.json * - optionnel: écrire un alias dancre old->new (build-time) dans src/anchors/anchor-aliases.json
* - optionnel: committer automatiquement * - optionnel: committer automatiquement
* - optionnel: fermer le ticket (après commit) * - optionnel: fermer le ticket (après commit)
@@ -40,7 +39,7 @@ Env (recommandé):
Notes: Notes:
- Si dist/<chemin>/index.html est absent, le script lance "npm run build" sauf si --no-build. - Si dist/<chemin>/index.html est absent, le script lance "npm run build" sauf si --no-build.
- Sauvegarde automatique: .tmp/apply-ticket/<fichier>.bak.issue-<N> (uniquement si on écrit) - Sauvegarde automatique: <fichier>.bak.issue-<N> (uniquement si on écrit)
- Avec --alias : le script rebuild pour identifier le NOUVEL id, puis écrit l'alias old->new. - Avec --alias : le script rebuild pour identifier le NOUVEL id, puis écrit l'alias old->new.
- Refuse automatiquement les Pull Requests (PR) : ce ne sont pas des tickets éditoriaux. - Refuse automatiquement les Pull Requests (PR) : ce ne sont pas des tickets éditoriaux.
`); `);
@@ -90,7 +89,6 @@ const CWD = process.cwd();
const CONTENT_ROOT = path.join(CWD, "src", "content"); const CONTENT_ROOT = path.join(CWD, "src", "content");
const DIST_ROOT = path.join(CWD, "dist"); const DIST_ROOT = path.join(CWD, "dist");
const ALIASES_FILE = path.join(CWD, "src", "anchors", "anchor-aliases.json"); const ALIASES_FILE = path.join(CWD, "src", "anchors", "anchor-aliases.json");
const BACKUP_ROOT = path.join(CWD, ".tmp", "apply-ticket");
/* -------------------------- utils texte / matching -------------------------- */ /* -------------------------- utils texte / matching -------------------------- */
@@ -138,26 +136,31 @@ function scoreText(candidate, targetText) {
let hit = 0; let hit = 0;
for (const w of tgtSet) if (blkSet.has(w)) hit++; for (const w of tgtSet) if (blkSet.has(w)) hit++;
// Bonus si un long préfixe ressemble
const tgtNorm = normalizeText(stripMd(targetText)); const tgtNorm = normalizeText(stripMd(targetText));
const blkNorm = normalizeText(stripMd(candidate)); const blkNorm = normalizeText(stripMd(candidate));
const prefix = tgtNorm.slice(0, Math.min(180, tgtNorm.length)); const prefix = tgtNorm.slice(0, Math.min(180, tgtNorm.length));
const prefixBonus = prefix && blkNorm.includes(prefix) ? 1000 : 0; const prefixBonus = prefix && blkNorm.includes(prefix) ? 1000 : 0;
// Ratio bonus (0..100)
const ratio = hit / Math.max(1, tgtSet.size); const ratio = hit / Math.max(1, tgtSet.size);
const ratioBonus = Math.round(ratio * 100); const ratioBonus = Math.round(ratio * 100);
return prefixBonus + hit + ratioBonus; return prefixBonus + hit + ratioBonus;
} }
function rankedBlockMatches(blocks, targetText, limit = 5) { function bestBlockMatchIndex(blocks, targetText) {
return blocks let best = { i: -1, score: -1 };
.map((b, i) => ({ for (let i = 0; i < blocks.length; i++) {
i, const sc = scoreText(blocks[i], targetText);
score: scoreText(b, targetText), if (sc > best.score) best = { i, score: sc };
excerpt: stripMd(b).slice(0, 140), }
})) return best;
.sort((a, b) => b.score - a.score) }
.slice(0, limit);
function splitParagraphBlocks(mdxText) {
const raw = String(mdxText ?? "").replace(/\r\n/g, "\n");
return raw.split(/\n{2,}/);
} }
function isLikelyExcerpt(s) { function isLikelyExcerpt(s) {
@@ -169,89 +172,6 @@ function isLikelyExcerpt(s) {
return false; return false;
} }
/* --------------------------- frontmatter / structure ------------------------ */
function normalizeNewlines(s) {
return String(s ?? "").replace(/^\uFEFF/, "").replace(/\r\n/g, "\n");
}
function splitMdxFrontmatter(src) {
const text = normalizeNewlines(src);
const m = text.match(/^---\n[\s\S]*?\n---\n?/);
if (!m) {
return {
hasFrontmatter: false,
frontmatter: "",
body: text,
};
}
const frontmatter = m[0];
const body = text.slice(frontmatter.length);
return {
hasFrontmatter: true,
frontmatter,
body,
};
}
function joinMdxFrontmatter(frontmatter, body) {
if (!frontmatter) return String(body ?? "");
return String(frontmatter) + String(body ?? "");
}
function assertFrontmatterIntegrity({ hadFrontmatter, originalFrontmatter, finalText, filePath }) {
if (!hadFrontmatter) return;
const text = normalizeNewlines(finalText);
if (!text.startsWith("---\n")) {
throw new Error(`Frontmatter perdu pendant la mise à jour de ${filePath}`);
}
if (!text.startsWith(originalFrontmatter)) {
throw new Error(`Frontmatter altéré pendant la mise à jour de ${filePath}`);
}
}
function splitParagraphBlocksPreserve(bodyText) {
const text = normalizeNewlines(bodyText);
if (!text) {
return { blocks: [], separators: [] };
}
const blocks = [];
const separators = [];
const re = /(\n{2,})/g;
let last = 0;
let m;
while ((m = re.exec(text))) {
blocks.push(text.slice(last, m.index));
separators.push(m[1]);
last = m.index + m[1].length;
}
blocks.push(text.slice(last));
return { blocks, separators };
}
function joinParagraphBlocksPreserve(blocks, separators) {
if (!Array.isArray(blocks) || blocks.length === 0) return "";
let out = "";
for (let i = 0; i < blocks.length; i++) {
out += blocks[i];
if (i < separators.length) out += separators[i];
}
return out;
}
/* ------------------------------ utils système ------------------------------ */ /* ------------------------------ utils système ------------------------------ */
function run(cmd, args, opts = {}) { function run(cmd, args, opts = {}) {
@@ -331,9 +251,7 @@ function pickSection(body, markers) {
.map((m) => ({ m, i: text.toLowerCase().indexOf(m.toLowerCase()) })) .map((m) => ({ m, i: text.toLowerCase().indexOf(m.toLowerCase()) }))
.filter((x) => x.i >= 0) .filter((x) => x.i >= 0)
.sort((a, b) => a.i - b.i)[0]; .sort((a, b) => a.i - b.i)[0];
if (!idx) return ""; if (!idx) return "";
const start = idx.i + idx.m.length; const start = idx.i + idx.m.length;
const tail = text.slice(start); const tail = text.slice(start);
@@ -348,13 +266,11 @@ function pickSection(body, markers) {
"\n## Proposition", "\n## Proposition",
"\n## Problème", "\n## Problème",
]; ];
let end = tail.length; let end = tail.length;
for (const s of stops) { for (const s of stops) {
const j = tail.toLowerCase().indexOf(s.toLowerCase()); const j = tail.toLowerCase().indexOf(s.toLowerCase());
if (j >= 0 && j < end) end = j; if (j >= 0 && j < end) end = j;
} }
return tail.slice(0, end).trim(); return tail.slice(0, end).trim();
} }
@@ -382,6 +298,8 @@ function extractAnchorIdAnywhere(text) {
function extractCheminFromAnyUrl(text) { function extractCheminFromAnyUrl(text) {
const s = String(text || ""); const s = String(text || "");
// Exemple: http://localhost:4321/archicratie/prologue/#p-3-xxxx
// ou: /archicratie/prologue/#p-3-xxxx
const m = s.match(/(\/[a-z0-9\-]+\/[a-z0-9\-\/]+\/)#p-\d+-[0-9a-f]{8}/i); const m = s.match(/(\/[a-z0-9\-]+\/[a-z0-9\-\/]+\/)#p-\d+-[0-9a-f]{8}/i);
return m ? m[1] : ""; return m ? m[1] : "";
} }
@@ -482,7 +400,7 @@ async function fetchIssue({ forgeApiBase, owner, repo, token, issueNum }) {
headers: { headers: {
Authorization: `token ${token}`, Authorization: `token ${token}`,
Accept: "application/json", Accept: "application/json",
"User-Agent": "archicratie-apply-ticket/2.1", "User-Agent": "archicratie-apply-ticket/2.0",
}, },
}); });
if (!res.ok) { if (!res.ok) {
@@ -498,7 +416,7 @@ async function closeIssue({ forgeApiBase, owner, repo, token, issueNum, comment
Authorization: `token ${token}`, Authorization: `token ${token}`,
Accept: "application/json", Accept: "application/json",
"Content-Type": "application/json", "Content-Type": "application/json",
"User-Agent": "archicratie-apply-ticket/2.1", "User-Agent": "archicratie-apply-ticket/2.0",
}; };
if (comment) { if (comment) {
@@ -507,11 +425,7 @@ async function closeIssue({ forgeApiBase, owner, repo, token, issueNum, comment
} }
const url = `${base}/api/v1/repos/${owner}/${repo}/issues/${issueNum}`; const url = `${base}/api/v1/repos/${owner}/${repo}/issues/${issueNum}`;
const res = await fetch(url, { const res = await fetch(url, { method: "PATCH", headers, body: JSON.stringify({ state: "closed" }) });
method: "PATCH",
headers,
body: JSON.stringify({ state: "closed" }),
});
if (!res.ok) { if (!res.ok) {
const t = await res.text().catch(() => ""); const t = await res.text().catch(() => "");
@@ -615,9 +529,10 @@ async function main() {
console.log(`🔎 Fetch ticket #${issueNum} from ${owner}/${repo}`); console.log(`🔎 Fetch ticket #${issueNum} from ${owner}/${repo}`);
const issue = await fetchIssue({ forgeApiBase, owner, repo, token, issueNum }); const issue = await fetchIssue({ forgeApiBase, owner, repo, token, issueNum });
// Guard PR (Pull Request = "Demande d'ajout" = pas un ticket éditorial)
if (issue?.pull_request) { if (issue?.pull_request) {
console.error(`❌ #${issueNum} est une Pull Request (demande dajout), pas un ticket éditorial.`); console.error(`❌ #${issueNum} est une Pull Request (demande dajout), pas un ticket éditorial.`);
console.error("➡️ Ouvre un ticket [Correction]/[Fact-check] depuis le site (Proposer), puis relance apply-ticket sur ce numéro."); console.error(`➡️ Ouvre un ticket [Correction]/[Fact-check] depuis le site (Proposer), puis relance apply-ticket sur ce numéro.`);
process.exit(2); process.exit(2);
} }
@@ -638,6 +553,7 @@ async function main() {
ancre = (ancre || "").trim(); ancre = (ancre || "").trim();
if (ancre.startsWith("#")) ancre = ancre.slice(1); if (ancre.startsWith("#")) ancre = ancre.slice(1);
// fallback si ticket mal formé
if (!ancre) ancre = extractAnchorIdAnywhere(title) || extractAnchorIdAnywhere(body); if (!ancre) ancre = extractAnchorIdAnywhere(title) || extractAnchorIdAnywhere(body);
chemin = normalizeChemin(chemin); chemin = normalizeChemin(chemin);
@@ -676,6 +592,7 @@ async function main() {
const distHtmlPath = path.join(DIST_ROOT, chemin.replace(/^\/+|\/+$/g, ""), "index.html"); const distHtmlPath = path.join(DIST_ROOT, chemin.replace(/^\/+|\/+$/g, ""), "index.html");
await ensureBuildIfNeeded(distHtmlPath); await ensureBuildIfNeeded(distHtmlPath);
// Texte cible: préférence au texte complet (ticket), sinon dist si extrait probable
let targetText = texteActuel; let targetText = texteActuel;
let distText = ""; let distText = "";
@@ -692,24 +609,21 @@ async function main() {
throw new Error("Impossible de reconstruire le texte du paragraphe (ni texte actuel, ni dist html)."); throw new Error("Impossible de reconstruire le texte du paragraphe (ni texte actuel, ni dist html).");
} }
const originalRaw = await fs.readFile(contentFile, "utf-8"); const original = await fs.readFile(contentFile, "utf-8");
const { hasFrontmatter, frontmatter, body: originalBody } = splitMdxFrontmatter(originalRaw); const blocks = splitParagraphBlocks(original);
const split = splitParagraphBlocksPreserve(originalBody); const best = bestBlockMatchIndex(blocks, targetText);
const blocks = split.blocks;
const separators = split.separators;
if (!blocks.length) {
throw new Error(`Aucun bloc éditorial exploitable dans ${path.relative(CWD, contentFile)}`);
}
const ranked = rankedBlockMatches(blocks, targetText, 5);
const best = ranked[0] || { i: -1, score: -1, excerpt: "" };
const runnerUp = ranked[1] || null;
// seuil de sécurité
if (best.i < 0 || best.score < 40) { if (best.i < 0 || best.score < 40) {
console.error("❌ Match trop faible: je refuse de remplacer automatiquement."); console.error("❌ Match trop faible: je refuse de remplacer automatiquement.");
console.error(`➡️ Score=${best.score}. Recommandation: ticket avec 'Texte actuel (copie exacte du paragraphe)'.`); console.error(`➡️ Score=${best.score}. Recommandation: ticket avec 'Texte actuel (copie exacte du paragraphe)'.`);
const ranked = blocks
.map((b, i) => ({ i, score: scoreText(b, targetText), excerpt: stripMd(b).slice(0, 140) }))
.sort((a, b) => b.score - a.score)
.slice(0, 5);
console.error("Top candidates:"); console.error("Top candidates:");
for (const r of ranked) { for (const r of ranked) {
console.error(` #${r.i + 1} score=${r.score} ${r.excerpt}${r.excerpt.length >= 140 ? "…" : ""}`); console.error(` #${r.i + 1} score=${r.score} ${r.excerpt}${r.excerpt.length >= 140 ? "…" : ""}`);
@@ -717,34 +631,12 @@ async function main() {
process.exit(2); process.exit(2);
} }
if (runnerUp) {
const ambiguityGap = best.score - runnerUp.score;
if (ambiguityGap < 15) {
console.error("❌ Match ambigu: le meilleur candidat est trop proche du second.");
console.error(`➡️ best=${best.score} / second=${runnerUp.score} / gap=${ambiguityGap}`);
console.error("Top candidates:");
for (const r of ranked) {
console.error(` #${r.i + 1} score=${r.score} ${r.excerpt}${r.excerpt.length >= 140 ? "…" : ""}`);
}
process.exit(2);
}
}
const beforeBlock = blocks[best.i]; const beforeBlock = blocks[best.i];
const afterBlock = proposition.trim(); const afterBlock = proposition.trim();
const nextBlocks = blocks.slice(); const nextBlocks = blocks.slice();
nextBlocks[best.i] = afterBlock; nextBlocks[best.i] = afterBlock;
const updated = nextBlocks.join("\n\n");
const updatedBody = joinParagraphBlocksPreserve(nextBlocks, separators);
const updatedRaw = joinMdxFrontmatter(frontmatter, updatedBody);
assertFrontmatterIntegrity({
hadFrontmatter: hasFrontmatter,
originalFrontmatter: frontmatter,
finalText: updatedRaw,
filePath: path.relative(CWD, contentFile),
});
console.log(`🧩 Matched block #${best.i + 1}/${blocks.length} score=${best.score}`); console.log(`🧩 Matched block #${best.i + 1}/${blocks.length} score=${best.score}`);
@@ -758,15 +650,13 @@ async function main() {
return; return;
} }
const relContentFile = path.relative(CWD, contentFile); // backup uniquement si on écrit
const bakPath = path.join(BACKUP_ROOT, `${relContentFile}.bak.issue-${issueNum}`); const bakPath = `${contentFile}.bak.issue-${issueNum}`;
await fs.mkdir(path.dirname(bakPath), { recursive: true });
if (!(await fileExists(bakPath))) { if (!(await fileExists(bakPath))) {
await fs.writeFile(bakPath, originalRaw, "utf-8"); await fs.writeFile(bakPath, original, "utf-8");
} }
await fs.writeFile(contentFile, updatedRaw, "utf-8"); await fs.writeFile(contentFile, updated, "utf-8");
console.log("✅ Applied."); console.log("✅ Applied.");
let aliasChanged = false; let aliasChanged = false;
@@ -787,13 +677,13 @@ async function main() {
if (aliasChanged) { if (aliasChanged) {
console.log(`✅ Alias ajouté: ${chemin} ${ancre} -> ${newId}`); console.log(`✅ Alias ajouté: ${chemin} ${ancre} -> ${newId}`);
// MàJ dist sans rebuild complet (inject seulement)
run("node", ["scripts/inject-anchor-aliases.mjs"], { cwd: CWD }); run("node", ["scripts/inject-anchor-aliases.mjs"], { cwd: CWD });
} else { } else {
console.log(` Alias déjà présent ou inutile (${ancre} -> ${newId}).`); console.log(` Alias déjà présent ou inutile (${ancre} -> ${newId}).`);
} }
run("node", ["scripts/check-anchor-aliases.mjs"], { cwd: CWD }); // garde-fous rapides
run("node", ["scripts/verify-anchor-aliases-in-dist.mjs"], { cwd: CWD });
run("npm", ["run", "test:anchors"], { cwd: CWD }); run("npm", ["run", "test:anchors"], { cwd: CWD });
run("node", ["scripts/check-inline-js.mjs"], { cwd: CWD }); run("node", ["scripts/check-inline-js.mjs"], { cwd: CWD });
} }
@@ -823,6 +713,7 @@ async function main() {
return; return;
} }
// mode manuel
console.log("Next (manuel) :"); console.log("Next (manuel) :");
console.log(` git diff -- ${path.relative(CWD, contentFile)}`); console.log(` git diff -- ${path.relative(CWD, contentFile)}`);
console.log( console.log(
@@ -839,4 +730,4 @@ async function main() {
main().catch((e) => { main().catch((e) => {
console.error("💥", e?.message || e); console.error("💥", e?.message || e);
process.exit(1); process.exit(1);
}); });

View File

@@ -1,72 +0,0 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import sys
import unicodedata
import xml.etree.ElementTree as ET
from zipfile import ZipFile
NS = {"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main"}
FORBIDDEN = [
"coviabilité",
"sacroinstitutionnelle",
"technologistique",
"scripturonormative",
"textesrepères",
"ellemême",
"opérateur de darchicration",
"systèmes plusieurs statuts",
"celle-ci se donne à voir",
"Pour autant il serait",
"Telles peuvent être le cas de",
"la co-viabilité devient ,",
]
def norm(s: str) -> str:
return unicodedata.normalize("NFC", s or "")
def main() -> int:
parser = argparse.ArgumentParser(description="Audit simple dun DOCX source officiel.")
parser.add_argument("docx", help="Chemin du fichier .docx")
args = parser.parse_args()
try:
with ZipFile(args.docx) as zf:
data = zf.read("word/document.xml")
except FileNotFoundError:
print(f"ECHEC: fichier introuvable: {args.docx}", file=sys.stderr)
return 2
except KeyError:
print("ECHEC: word/document.xml introuvable dans le DOCX.", file=sys.stderr)
return 2
except Exception as e:
print(f"ECHEC: impossible douvrir le DOCX: {e}", file=sys.stderr)
return 2
root = ET.fromstring(data)
found = False
for i, p in enumerate(root.findall(".//w:p", NS), start=1):
txt = "".join(t.text or "" for t in p.findall(".//w:t", NS))
txt_n = norm(txt)
hits = [needle for needle in FORBIDDEN if needle in txt_n]
if hits:
found = True
print(f"\n[paragraphe {i}]")
print("Hits :", ", ".join(hits))
print(txt_n)
if found:
print("\nECHEC: formes interdites encore présentes dans le DOCX.")
return 1
print("OK: aucune forme interdite trouvée dans le DOCX.")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -74,24 +74,7 @@ function loadAllowMissing() {
return new Set(arr.map(String)); return new Set(arr.map(String));
} }
function loadAcceptedResets() {
const p = path.resolve("config/anchor-churn-allowlist.json");
if (!fssync.existsSync(p)) return {};
const raw = fssync.readFileSync(p, "utf8").trim();
if (!raw) return {};
const data = JSON.parse(raw);
if (!data || typeof data !== "object" || Array.isArray(data)) {
throw new Error("anchor-churn-allowlist.json must be an object");
}
const accepted = data.accepted_resets || {};
if (!accepted || typeof accepted !== "object" || Array.isArray(accepted)) {
throw new Error("anchor-churn-allowlist.json: accepted_resets must be an object");
}
return accepted;
}
const ALLOW_MISSING = loadAllowMissing(); const ALLOW_MISSING = loadAllowMissing();
const ACCEPTED_RESETS = loadAcceptedResets();
async function buildSnapshot() { async function buildSnapshot() {
const absDist = path.resolve(DIST_DIR); const absDist = path.resolve(DIST_DIR);
@@ -156,7 +139,6 @@ function diffPage(prevIds, curIds) {
let failed = false; let failed = false;
let changedPages = 0; let changedPages = 0;
let acceptedPages = 0;
for (const p of pages) { for (const p of pages) {
const prevIds = base[p] || null; const prevIds = base[p] || null;
@@ -190,7 +172,6 @@ function diffPage(prevIds, curIds) {
const prevN = prevIds.length || 1; const prevN = prevIds.length || 1;
const churn = (added.length + removed.length) / prevN; const churn = (added.length + removed.length) / prevN;
const removedRatio = removed.length / prevN; const removedRatio = removed.length / prevN;
const acceptedReason = ACCEPTED_RESETS[p] || null;
console.log( console.log(
`~ ${p} prev=${prevIds.length} now=${curIds.length}` + `~ ${p} prev=${prevIds.length} now=${curIds.length}` +
@@ -201,23 +182,11 @@ function diffPage(prevIds, curIds) {
console.log(` removed: ${removed.slice(0, 20).join(", ")}${removed.length > 20 ? " …" : ""}`); console.log(` removed: ${removed.slice(0, 20).join(", ")}${removed.length > 20 ? " …" : ""}`);
} }
const exceeds = if (prevIds.length >= MIN_PREV && churn > THRESHOLD) failed = true;
(prevIds.length >= MIN_PREV && churn > THRESHOLD) || if (prevIds.length >= MIN_PREV && removedRatio > THRESHOLD) failed = true;
(prevIds.length >= MIN_PREV && removedRatio > THRESHOLD);
if (exceeds && acceptedReason) {
acceptedPages += 1;
console.log(` ✅ accepted reset: ${acceptedReason}`);
continue;
}
if (exceeds) failed = true;
} }
console.log( console.log(`\nSummary: pages compared=${pages.length}, pages changed=${changedPages}`);
`\nSummary: pages compared=${pages.length}, pages changed=${changedPages}, accepted resets=${acceptedPages}`
);
if (failed) { if (failed) {
console.error(`FAIL: anchor churn above threshold (threshold=${pct(THRESHOLD)} minPrev=${MIN_PREV})`); console.error(`FAIL: anchor churn above threshold (threshold=${pct(THRESHOLD)} minPrev=${MIN_PREV})`);
process.exit(1); process.exit(1);

View File

@@ -1,241 +0,0 @@
#!/usr/bin/env python3
import argparse
import os
import re
import shutil
import subprocess
import sys
from pathlib import Path
try:
import yaml
except ImportError:
print("Erreur : PyYAML n'est pas installé. Lance : pip3 install pyyaml")
sys.exit(1)
EDITION = "archicrat-ia"
STATUS = "essai_these"
VERSION = "0.1.0"
ORDER_MAP = {
"prologue": 10,
"chapitre-1": 20,
"chapitre-2": 30,
"chapitre-3": 40,
"chapitre-4": 50,
"chapitre-5": 60,
"conclusion": 70,
}
TITLE_MAP = {
"prologue": "Prologue — Fondation, finalité sociopolitique et historique",
"chapitre-1": "Chapitre 1 — Fondements épistémologiques et modélisation",
"chapitre-2": "Chapitre 2 — Archéogenèse des régimes de co-viabilité",
"chapitre-3": "Chapitre 3 — Philosophies du pouvoir et archicration",
"chapitre-4": "Chapitre 4 — Histoire archicratique des révolutions industrielles",
"chapitre-5": "Chapitre 5 — Tensions, co-viabilités et régulations",
"conclusion": "Conclusion — ArchiCraT-IA",
}
def slugify_name(path: Path) -> str:
stem = path.stem.lower().strip()
replacements = {
" ": "-",
"_": "-",
"": "-",
"": "-",
"é": "e",
"è": "e",
"ê": "e",
"ë": "e",
"à": "a",
"â": "a",
"ä": "a",
"î": "i",
"ï": "i",
"ô": "o",
"ö": "o",
"ù": "u",
"û": "u",
"ü": "u",
"ç": "c",
"'": "",
"": "",
}
for old, new in replacements.items():
stem = stem.replace(old, new)
stem = re.sub(r"-+", "-", stem).strip("-")
# normalisations spécifiques
stem = stem.replace("chapitre-1-fondements-epistemologiques-et-modelisation-archicratie-version-officielle-revise", "chapitre-1")
stem = stem.replace("chapitre-2", "chapitre-2")
stem = stem.replace("chapitre-3", "chapitre-3")
stem = stem.replace("chapitre-4", "chapitre-4")
stem = stem.replace("chapitre-5", "chapitre-5")
if "prologue" in stem:
return "prologue"
if "chapitre-1" in stem:
return "chapitre-1"
if "chapitre-2" in stem:
return "chapitre-2"
if "chapitre-3" in stem:
return "chapitre-3"
if "chapitre-4" in stem:
return "chapitre-4"
if "chapitre-5" in stem:
return "chapitre-5"
if "conclusion" in stem:
return "conclusion"
return stem
def extract_title_from_markdown(md_text: str) -> str | None:
for line in md_text.splitlines():
line = line.strip()
if not line:
continue
if line.startswith("# "):
return line[2:].strip()
return None
def remove_first_h1(md_text: str) -> str:
lines = md_text.splitlines()
out = []
removed = False
for line in lines:
if not removed and line.strip().startswith("# "):
removed = True
continue
out.append(line)
text = "\n".join(out).lstrip()
return text
def clean_markdown(md_text: str) -> str:
text = md_text.replace("\r\n", "\n").replace("\r", "\n")
# nettoyer espaces multiples
text = re.sub(r"\n{3,}", "\n\n", text)
# supprimer éventuels signets/artefacts de liens internes Pandoc
text = re.sub(r"\[\]\(#.*?\)", "", text)
# convertir astérismes parasites
text = re.sub(r"[ \t]+$", "", text, flags=re.MULTILINE)
return text.strip() + "\n"
def compute_level(slug: str) -> int:
if slug == "prologue":
return 1
if slug.startswith("chapitre-"):
return 1
if slug == "conclusion":
return 1
return 1
def convert_one_file(input_docx: Path, output_dir: Path, source_root: Path):
slug = slugify_name(input_docx)
output_mdx = output_dir / f"{slug}.mdx"
cmd = [
"pandoc",
str(input_docx),
"-f",
"docx",
"-t",
"gfm+smart",
]
result = subprocess.run(cmd, check=True, capture_output=True, text=True)
md_text = result.stdout
detected_title = extract_title_from_markdown(md_text)
md_body = remove_first_h1(md_text)
md_body = clean_markdown(md_body)
title = TITLE_MAP.get(slug) or detected_title or input_docx.stem
order = ORDER_MAP.get(slug, 999)
level = compute_level(slug)
relative_source = input_docx
try:
relative_source = input_docx.relative_to(source_root)
except ValueError:
relative_source = input_docx.name
frontmatter = {
"title": title,
"edition": EDITION,
"status": STATUS,
"level": level,
"version": VERSION,
"concepts": [],
"links": [],
"order": order,
"summary": "",
"source": {
"kind": "docx",
"path": str(relative_source),
},
}
yaml_block = yaml.safe_dump(
frontmatter,
allow_unicode=True,
sort_keys=False,
default_flow_style=False,
).strip()
final_text = f"---\n{yaml_block}\n---\n{md_body if md_body.startswith(chr(10)) else chr(10) + md_body}"
output_mdx.write_text(final_text, encoding="utf-8")
print(f"{input_docx.name} -> {output_mdx.name}")
def main():
parser = argparse.ArgumentParser(description="Convertit un dossier DOCX en MDX avec frontmatter.")
parser.add_argument("input_dir", help="Dossier source contenant les DOCX")
parser.add_argument("output_dir", help="Dossier de sortie pour les MDX")
args = parser.parse_args()
input_dir = Path(args.input_dir).expanduser().resolve()
output_dir = Path(args.output_dir).expanduser().resolve()
if not shutil.which("pandoc"):
print("Erreur : pandoc n'est pas installé. Lance : brew install pandoc")
sys.exit(1)
if not input_dir.exists() or not input_dir.is_dir():
print(f"Erreur : dossier source introuvable : {input_dir}")
sys.exit(1)
output_dir.mkdir(parents=True, exist_ok=True)
docx_files = sorted(input_dir.glob("*.docx"))
if not docx_files:
print(f"Aucun DOCX trouvé dans : {input_dir}")
sys.exit(1)
for docx_file in docx_files:
convert_one_file(docx_file, output_dir, input_dir)
print()
print("Conversion DOCX -> MDX terminée.")
if __name__ == "__main__":
main()

View File

@@ -1,304 +0,0 @@
#!/usr/bin/env python3
import argparse
import re
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
import zipfile
try:
import yaml
except ImportError:
print("Erreur : PyYAML n'est pas installé. Lance : pip3 install pyyaml")
sys.exit(1)
try:
from docx import Document
except ImportError:
print("Erreur : python-docx n'est pas installé. Lance : pip3 install python-docx")
sys.exit(1)
def split_frontmatter(text: str):
if not text.startswith("---\n"):
return {}, text
match = re.match(r"^---\n(.*?)\n---\n(.*)$", text, flags=re.DOTALL)
if not match:
return {}, text
yaml_block = match.group(1)
body = match.group(2)
try:
metadata = yaml.safe_load(yaml_block) or {}
except Exception as e:
print(f"Avertissement : frontmatter YAML illisible : {e}")
metadata = {}
return metadata, body
def strip_mdx_artifacts(text: str):
# imports / exports MDX
text = re.sub(r"^\s*(import|export)\s+.+?$", "", text, flags=re.MULTILINE)
# composants autofermants : <Component />
text = re.sub(r"<[A-Z][A-Za-z0-9._-]*\b[^>]*\/>", "", text)
# composants bloc : <Component ...>...</Component>
text = re.sub(
r"<([A-Z][A-Za-z0-9._-]*)\b[^>]*>.*?</\1>",
"",
text,
flags=re.DOTALL,
)
# accolades seules résiduelles sur ligne
text = re.sub(r"^\s*{\s*}\s*$", "", text, flags=re.MULTILINE)
# lignes vides multiples
text = re.sub(r"\n{3,}", "\n\n", text)
return text.strip() + "\n"
def inject_h1_from_title(metadata: dict, body: str):
title = metadata.get("title", "")
if not title:
return body
if re.match(r"^\s*#\s+", body):
return body
return f"# {title}\n\n{body.lstrip()}"
def find_style_by_candidates(doc, candidates):
# Cherche d'abord par nom visible
for style in doc.styles:
for candidate in candidates:
if style.name == candidate:
return style
# Puis par style_id Word interne
for style in doc.styles:
style_id = getattr(style, "style_id", "")
if style_id in {"BodyText", "Heading1", "Heading2", "Heading3", "Heading4"}:
for candidate in candidates:
if candidate in {"Body Text", "Corps de texte"} and style_id == "BodyText":
return style
if candidate in {"Heading 1", "Titre 1"} and style_id == "Heading1":
return style
if candidate in {"Heading 2", "Titre 2"} and style_id == "Heading2":
return style
if candidate in {"Heading 3", "Titre 3"} and style_id == "Heading3":
return style
if candidate in {"Heading 4", "Titre 4"} and style_id == "Heading4":
return style
return None
def strip_leading_paragraph_numbers(text: str):
"""
Supprime les numéros de paragraphe du type :
2. Texte...
11. Texte...
101. Texte...
sans toucher aux titres Markdown (#, ##, ###).
"""
fixed_lines = []
for line in text.splitlines():
stripped = line.lstrip()
# Ne jamais toucher aux titres Markdown
if stripped.startswith("#"):
fixed_lines.append(line)
continue
# Supprime un numéro de paragraphe en début de ligne
line = re.sub(r"^\s*\d+\.\s+", "", line)
fixed_lines.append(line)
return "\n".join(fixed_lines) + "\n"
def normalize_non_heading_paragraphs(docx_path: Path):
"""
Force tous les paragraphes non-titres en Body Text / Corps de texte.
On laisse intacts les Heading 1-4.
"""
doc = Document(str(docx_path))
body_style = find_style_by_candidates(doc, ["Body Text", "Corps de texte"])
if body_style is None:
print(f"Avertissement : style 'Body Text / Corps de texte' introuvable dans {docx_path.name}")
return
heading_names = {
"Heading 1", "Heading 2", "Heading 3", "Heading 4",
"Titre 1", "Titre 2", "Titre 3", "Titre 4",
}
heading_ids = {"Heading1", "Heading2", "Heading3", "Heading4"}
changed = 0
for para in doc.paragraphs:
text = para.text.strip()
if not text:
continue
current_style = para.style
current_name = current_style.name if current_style else ""
current_id = getattr(current_style, "style_id", "") if current_style else ""
if current_name in heading_names or current_id in heading_ids:
continue
# Tout le reste passe en Body Text
para.style = body_style
changed += 1
doc.save(str(docx_path))
print(f" ↳ normalisation styles : {changed} paragraphe(s) mis en 'Body Text / Corps de texte'")
def remove_word_bookmarks(docx_path: Path):
"""
Supprime les bookmarks Word (signets) du DOCX.
Ce sont eux qui apparaissent comme crochets gris dans LibreOffice/Word
quand l'affichage des signets est activé.
"""
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir = Path(tmpdir)
# Dézipper le docx
with zipfile.ZipFile(docx_path, "r") as zin:
zin.extractall(tmpdir)
xml_targets = [
tmpdir / "word" / "document.xml",
tmpdir / "word" / "footnotes.xml",
tmpdir / "word" / "endnotes.xml",
tmpdir / "word" / "comments.xml",
]
removed = 0
for xml_file in xml_targets:
if not xml_file.exists():
continue
text = xml_file.read_text(encoding="utf-8")
# enlever <w:bookmarkStart .../> et <w:bookmarkEnd .../>
text, c1 = re.subn(r"<w:bookmarkStart\b[^>]*/>", "", text)
text, c2 = re.subn(r"<w:bookmarkEnd\b[^>]*/>", "", text)
removed += c1 + c2
xml_file.write_text(text, encoding="utf-8")
# Rezipper
tmp_output = docx_path.with_suffix(".cleaned.docx")
with zipfile.ZipFile(tmp_output, "w", zipfile.ZIP_DEFLATED) as zout:
for file in tmpdir.rglob("*"):
if file.is_file():
zout.write(file, file.relative_to(tmpdir))
tmp_output.replace(docx_path)
print(f" ↳ suppression signets : {removed} balise(s) supprimée(s)")
def convert_one_file(input_path: Path, output_path: Path, reference_doc: Path | None):
raw = input_path.read_text(encoding="utf-8")
metadata, body = split_frontmatter(raw)
body = strip_mdx_artifacts(body)
body = strip_leading_paragraph_numbers(body)
body = inject_h1_from_title(metadata, body)
with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False, encoding="utf-8") as tmp:
tmp.write(body)
tmp_md = Path(tmp.name)
cmd = [
"pandoc",
str(tmp_md),
"-f",
"markdown",
"-o",
str(output_path),
]
if reference_doc:
cmd.extend(["--reference-doc", str(reference_doc)])
try:
subprocess.run(cmd, check=True)
finally:
try:
tmp_md.unlink()
except FileNotFoundError:
pass
normalize_non_heading_paragraphs(output_path)
remove_word_bookmarks(output_path)
def main():
parser = argparse.ArgumentParser(
description="Convertit des fichiers MDX en DOCX en conservant H1/H2/H3/H4 et en forçant le corps en Body Text."
)
parser.add_argument("input_dir", help="Dossier contenant les .mdx")
parser.add_argument(
"--output-dir",
default=str(Path.home() / "Desktop" / "archicrat-ia-docx"),
help="Dossier de sortie DOCX"
)
parser.add_argument(
"--reference-doc",
default=None,
help="DOCX modèle Word à utiliser comme reference-doc"
)
args = parser.parse_args()
input_dir = Path(args.input_dir)
output_dir = Path(args.output_dir)
reference_doc = Path(args.reference_doc) if args.reference_doc else None
if not shutil.which("pandoc"):
print("Erreur : pandoc n'est pas installé. Installe-le avec : brew install pandoc")
sys.exit(1)
if not input_dir.exists() or not input_dir.is_dir():
print(f"Erreur : dossier introuvable : {input_dir}")
sys.exit(1)
if reference_doc and not reference_doc.exists():
print(f"Erreur : reference-doc introuvable : {reference_doc}")
sys.exit(1)
output_dir.mkdir(parents=True, exist_ok=True)
mdx_files = sorted(input_dir.glob("*.mdx"))
if not mdx_files:
print(f"Aucun fichier .mdx trouvé dans : {input_dir}")
sys.exit(1)
print(f"Conversion de {len(mdx_files)} fichier(s)...")
print(f"Entrée : {input_dir}")
print(f"Sortie : {output_dir}")
if reference_doc:
print(f"Modèle : {reference_doc}")
print()
for mdx_file in mdx_files:
docx_name = mdx_file.with_suffix(".docx").name
out_file = output_dir / docx_name
print(f"{mdx_file.name} -> {docx_name}")
convert_one_file(mdx_file, out_file, reference_doc)
print()
print("✅ Conversion terminée.")
if __name__ == "__main__":
main()

View File

@@ -1,132 +0,0 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import shutil
import tempfile
import unicodedata
import xml.etree.ElementTree as ET
from pathlib import Path
from zipfile import ZIP_DEFLATED, ZipFile
W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
XML_NS = "http://www.w3.org/XML/1998/namespace"
NS = {"w": W_NS}
ET.register_namespace("w", W_NS)
REPLACEMENTS = {
"coviabilité": "co-viabilité",
"sacroinstitutionnelle": "sacro-institutionnelle",
"technologistique": "techno-logistique",
"scripturonormative": "scripturo-normative",
"textesrepères": "textes-repères",
"ellemême": "elle-même",
"opérateur de darchicration": "opérateur darchicration",
"systèmes plusieurs statuts": "systèmes à plusieurs statuts",
"celle-ci se donne à voir": "Celle-ci se donne à voir",
"Pour autant il serait": "Pour autant, il serait",
"Telles peuvent être le cas de": "Tels peuvent être les cas de",
}
# volontairement NON auto-corrigé : "la co-viabilité devient ,"
# ce cas demande une décision éditoriale humaine.
def qn(tag: str) -> str:
prefix, local = tag.split(":")
if prefix != "w":
raise ValueError(tag)
return f"{{{W_NS}}}{local}"
def norm(s: str) -> str:
return unicodedata.normalize("NFC", s or "")
def paragraph_text(p: ET.Element) -> str:
return "".join(t.text or "" for t in p.findall(".//w:t", NS))
def replaced_text(s: str) -> str:
out = norm(s)
for bad, good in REPLACEMENTS.items():
out = out.replace(bad, good)
return out
def rewrite_paragraph_text(p: ET.Element, new_text: str) -> None:
ppr = p.find("w:pPr", NS)
for child in list(p):
if ppr is not None and child is ppr:
continue
p.remove(child)
r = ET.Element(qn("w:r"))
t = ET.SubElement(r, qn("w:t"))
t.set(f"{{{XML_NS}}}space", "preserve")
t.text = new_text
p.append(r)
def process_document_xml(xml_path: Path) -> int:
tree = ET.parse(xml_path)
root = tree.getroot()
changed = 0
for p in root.findall(".//w:p", NS):
old = paragraph_text(p)
new = replaced_text(old)
if new != old:
rewrite_paragraph_text(p, new)
changed += 1
tree.write(xml_path, encoding="utf-8", xml_declaration=True)
return changed
def repack_docx(tmpdir: Path, out_docx: Path) -> None:
tmp_out = out_docx.with_suffix(out_docx.suffix + ".tmp")
with ZipFile(tmp_out, "w", ZIP_DEFLATED) as zf:
for p in sorted(tmpdir.rglob("*")):
if p.is_file():
zf.write(p, p.relative_to(tmpdir))
shutil.move(tmp_out, out_docx)
def main() -> int:
parser = argparse.ArgumentParser(description="Répare mécaniquement certaines scories DOCX.")
parser.add_argument("docx", help="Chemin du DOCX")
parser.add_argument("--in-place", action="store_true", help="Réécrit le DOCX en place")
args = parser.parse_args()
src = Path(args.docx)
if not src.exists():
print(f"ECHEC: fichier introuvable: {src}", file=sys.stderr)
return 2
out = src if args.in_place else src.with_name(src.stem + ".fixed.docx")
with tempfile.TemporaryDirectory(prefix="docx-fix-") as td:
td_path = Path(td)
with ZipFile(src) as zf:
zf.extractall(td_path)
document_xml = td_path / "word" / "document.xml"
if not document_xml.exists():
print("ECHEC: word/document.xml absent.", file=sys.stderr)
return 2
changed = process_document_xml(document_xml)
repack_docx(td_path, out)
print(f"OK: DOCX réparé par réécriture paragraphe/XML. Paragraphes modifiés: {changed}")
return 0
if __name__ == "__main__":
import sys
raise SystemExit(main())

View File

@@ -114,6 +114,7 @@ async function runMammoth(docxPath, assetsOutDirWebRoot) {
); );
let html = result.value || ""; let html = result.value || "";
// Mammoth gives relative src="image-xx.png" ; we will prefix later // Mammoth gives relative src="image-xx.png" ; we will prefix later
return html; return html;
} }
@@ -181,52 +182,17 @@ async function exists(p) {
try { await fs.access(p); return true; } catch { return false; } try { await fs.access(p); return true; } catch { return false; }
} }
/**
* ✅ compat:
* - ancien : collection="archicratie" + slug="archicrat-ia/chapitre-3"
* - nouveau : collection="archicrat-ia" + slug="chapitre-3"
*
* But : toujours écrire dans src/content/archicrat-ia/<slugSansPrefix>.mdx
*/
function normalizeDest(collection, slug) {
let outCollection = String(collection || "").trim();
let outSlug = String(slug || "").trim().replace(/^\/+|\/+$/g, "");
if (outCollection === "archicratie" && outSlug.startsWith("archicrat-ia/")) {
outCollection = "archicrat-ia";
outSlug = outSlug.replace(/^archicrat-ia\//, "");
}
return { outCollection, outSlug };
}
async function main() { async function main() {
const args = parseArgs(process.argv); const args = parseArgs(process.argv);
const manifestPath = path.resolve(args.manifest); const manifestPath = path.resolve(args.manifest);
const items = await readManifest(manifestPath); const items = await readManifest(manifestPath);
const selected = args.all const selected = args.all ? items : items.filter(it => args.only.includes(it.slug));
? items
: items.filter((it) => {
const rawSlug = String(it.slug || "").trim();
const rawCollection = String(it.collection || "").trim();
const qualified = `${rawCollection}/${rawSlug}`;
return args.only.includes(rawSlug) || args.only.includes(qualified);
});
if (!args.all) { if (!args.all && selected.length !== args.only.length) {
const found = new Set( const found = new Set(selected.map(s => s.slug));
selected.flatMap((s) => { const missing = args.only.filter(s => !found.has(s));
const rawSlug = String(s.slug || "").trim(); throw new Error(`Some --only slugs not found in manifest: ${missing.join(", ")}`);
const rawCollection = String(s.collection || "").trim();
return [rawSlug, `${rawCollection}/${rawSlug}`];
})
);
const missing = args.only.filter((s) => !found.has(s));
if (missing.length > 0) {
throw new Error(`Some --only slugs not found in manifest: ${missing.join(", ")}`);
}
} }
const pandocOk = havePandoc(); const pandocOk = havePandoc();
@@ -237,14 +203,11 @@ async function main() {
for (const it of selected) { for (const it of selected) {
const docxPath = path.resolve(it.source); const docxPath = path.resolve(it.source);
const outFile = path.resolve("src/content", it.collection, `${it.slug}.mdx`);
const { outCollection, outSlug } = normalizeDest(it.collection, it.slug);
const outFile = path.resolve("src/content", outCollection, `${outSlug}.mdx`);
const outDir = path.dirname(outFile); const outDir = path.dirname(outFile);
const assetsPublicDir = path.posix.join("/imported", outCollection, outSlug); const assetsPublicDir = path.posix.join("/imported", it.collection, it.slug);
const assetsDiskDir = path.resolve("public", "imported", outCollection, outSlug); const assetsDiskDir = path.resolve("public", "imported", it.collection, it.slug);
if (!(await exists(docxPath))) { if (!(await exists(docxPath))) {
throw new Error(`Missing source docx: ${docxPath}`); throw new Error(`Missing source docx: ${docxPath}`);
@@ -278,35 +241,18 @@ async function main() {
html = rewriteLocalImageLinks(html, assetsPublicDir); html = rewriteLocalImageLinks(html, assetsPublicDir);
body = html.trim() ? html : "<p>(Import vide)</p>"; body = html.trim() ? html : "<p>(Import vide)</p>";
} }
const defaultVersion = process.env.PUBLIC_RELEASE || "0.1.0"; const defaultVersion = process.env.PUBLIC_RELEASE || "0.1.0";
// ✅ IMPORTANT: archicrat-ia partage edition/status avec archicratie (pas de migration frontmatter)
const schemaDefaultsByCollection = { const schemaDefaultsByCollection = {
archicratie: { edition: "archicratie", status: "modele_sociopolitique", level: 1 }, archicratie: { edition: "archicratie", status: "modele_sociopolitique", level: 1 },
"archicrat-ia": { edition: "archicrat-ia", status: "essai_these", level: 1 }, ia: { edition: "ia", status: "cas_pratique", level: 1 },
"cas-ia": { edition: "cas-ia", status: "application", level: 1 }, traite: { edition: "traite", status: "ontodynamique", level: 1 },
traite: { edition: "traite", status: "ontodynamique", level: 1 }, glossaire: { edition: "glossaire", status: "lexique", level: 1 },
glossaire: { edition: "glossaire", status: "lexique", level: 1 }, atlas: { edition: "atlas", status: "atlas", level: 1 },
atlas: { edition: "atlas", status: "atlas", level: 1 },
}; };
// Compat legacy : const defaults = schemaDefaultsByCollection[it.collection] || { edition: it.collection, status: "draft", level: 1 };
// manifest collection="archicratie" + slug="archicrat-ia/..."
// => on écrit bien dans src/content/archicrat-ia/...
// => mais on conserve edition/status historiques de type archicratie/modele_sociopolitique
const defaultsKey =
String(it.collection || "").trim() === "archicratie" &&
String(it.slug || "").trim().startsWith("archicrat-ia/")
? "archicratie"
: outCollection;
const defaults =
schemaDefaultsByCollection[defaultsKey] || {
edition: defaultsKey,
status: "draft",
level: 1,
};
const fm = [ const fm = [
"---", "---",
@@ -336,4 +282,4 @@ async function main() {
main().catch((e) => { main().catch((e) => {
console.error("\nERROR:", e?.message || e); console.error("\nERROR:", e?.message || e);
process.exit(1); process.exit(1);
}); });

View File

@@ -1,241 +0,0 @@
#!/usr/bin/env node
import process from "node:process";
function getEnv(name, fallback = "") {
return String(process.env[name] ?? fallback).trim();
}
function sh(value) {
return JSON.stringify(String(value ?? ""));
}
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 pickHeadingValue(body, headingKey) {
const re = new RegExp(
`^##\\s*${escapeRegExp(headingKey)}[^\\n]*\\n([\\s\\S]*?)(?=\\n##\\s|\\n\\s*$)`,
"mi"
);
const m = String(body || "").match(re);
if (!m) return "";
const lines = m[1].split(/\r?\n/).map((l) => l.trim());
for (const l of lines) {
if (!l) continue;
if (l.startsWith("<!--")) continue;
return l.replace(/^\/?/, "/").trim();
}
return "";
}
function normalizeChemin(chemin) {
let c = String(chemin || "").trim();
if (!c) return "";
if (!c.startsWith("/")) c = "/" + c;
if (!c.endsWith("/")) c += "/";
return c;
}
function extractCheminFromAnyUrl(text) {
const s = String(text || "");
const m = s.match(/(\/[a-z0-9\-]+\/[a-z0-9\-\/]+\/)#p-\d+-[0-9a-f]{8}/i);
return m ? m[1] : "";
}
function inferType(issue) {
const title = String(issue?.title || "");
const body = String(issue?.body || "").replace(/\r\n/g, "\n");
const fromBody = String(pickLine(body, "Type") || "").trim().toLowerCase();
if (fromBody) return fromBody;
if (title.startsWith("[Correction]")) return "type/correction";
if (title.startsWith("[Fact-check]") || title.startsWith("[Vérification]")) return "type/fact-check";
return "";
}
function inferChemin(issue) {
const title = String(issue?.title || "");
const body = String(issue?.body || "").replace(/\r\n/g, "\n");
return normalizeChemin(
pickLine(body, "Chemin") ||
pickHeadingValue(body, "Chemin") ||
extractCheminFromAnyUrl(body) ||
extractCheminFromAnyUrl(title)
);
}
function labelsOf(issue) {
return Array.isArray(issue?.labels)
? issue.labels.map((l) => String(l?.name || "")).filter(Boolean)
: [];
}
function issueNumber(issue) {
return Number(issue?.number || issue?.index || 0);
}
function parseMeta(issue) {
const labels = labelsOf(issue);
const type = inferType(issue);
const chemin = inferChemin(issue);
const number = issueNumber(issue);
const hasApproved = labels.includes("state/approved");
const hasRejected = labels.includes("state/rejected");
const isProposer = type === "type/correction" || type === "type/fact-check";
const isOpen = String(issue?.state || "open") === "open";
const isPR = Boolean(issue?.pull_request);
const eligible =
number > 0 &&
isOpen &&
!isPR &&
hasApproved &&
!hasRejected &&
isProposer &&
Boolean(chemin);
return {
issue,
number,
type,
chemin,
labels,
hasApproved,
hasRejected,
eligible,
};
}
async function fetchJson(url, token) {
const res = await fetch(url, {
headers: {
Authorization: `token ${token}`,
Accept: "application/json",
"User-Agent": "archicratie-pick-proposer-issue/1.0",
},
});
if (!res.ok) {
const t = await res.text().catch(() => "");
throw new Error(`HTTP ${res.status} ${url}\n${t}`);
}
return await res.json();
}
async function fetchIssue(apiBase, owner, repo, token, n) {
const url = `${apiBase}/api/v1/repos/${owner}/${repo}/issues/${n}`;
return await fetchJson(url, token);
}
async function listOpenIssues(apiBase, owner, repo, token) {
const out = [];
let page = 1;
const limit = 100;
while (true) {
const url = `${apiBase}/api/v1/repos/${owner}/${repo}/issues?state=open&page=${page}&limit=${limit}`;
const batch = await fetchJson(url, token);
if (!Array.isArray(batch) || batch.length === 0) break;
out.push(...batch);
if (batch.length < limit) break;
page += 1;
}
return out;
}
function emitNone(reason) {
process.stdout.write(
[
`TARGET_FOUND="0"`,
`TARGET_REASON=${sh(reason)}`,
`TARGET_PRIMARY_ISSUE=""`,
`TARGET_ISSUES=""`,
`TARGET_COUNT="0"`,
`TARGET_CHEMIN=""`,
].join("\n") + "\n"
);
}
async function main() {
const token = getEnv("FORGE_TOKEN");
const owner = getEnv("GITEA_OWNER");
const repo = getEnv("GITEA_REPO");
const apiBase = (getEnv("FORGE_API") || getEnv("FORGE_BASE")).replace(/\/+$/, "");
const explicit = Number(process.argv[2] || 0);
if (!token) throw new Error("Missing FORGE_TOKEN");
if (!owner || !repo) throw new Error("Missing GITEA_OWNER / GITEA_REPO");
if (!apiBase) throw new Error("Missing FORGE_API / FORGE_BASE");
let metas = [];
if (explicit > 0) {
const issue = await fetchIssue(apiBase, owner, repo, token, explicit);
const meta = parseMeta(issue);
if (!meta.eligible) {
emitNone(
!meta.hasApproved
? "explicit_issue_not_approved"
: meta.hasRejected
? "explicit_issue_rejected"
: !meta.type
? "explicit_issue_missing_type"
: !meta.chemin
? "explicit_issue_missing_chemin"
: "explicit_issue_not_eligible"
);
return;
}
const openIssues = await listOpenIssues(apiBase, owner, repo, token);
metas = openIssues.map(parseMeta).filter((m) => m.eligible && m.chemin === meta.chemin);
} else {
const openIssues = await listOpenIssues(apiBase, owner, repo, token);
metas = openIssues.map(parseMeta).filter((m) => m.eligible);
if (metas.length === 0) {
emitNone("no_open_approved_proposer_issue");
return;
}
metas.sort((a, b) => a.number - b.number);
const first = metas[0];
metas = metas.filter((m) => m.chemin === first.chemin);
}
metas.sort((a, b) => a.number - b.number);
if (metas.length === 0) {
emitNone("no_batch_for_path");
return;
}
const primary = metas[0];
const issues = metas.map((m) => String(m.number));
process.stdout.write(
[
`TARGET_FOUND="1"`,
`TARGET_REASON="ok"`,
`TARGET_PRIMARY_ISSUE=${sh(primary.number)}`,
`TARGET_ISSUES=${sh(issues.join(" "))}`,
`TARGET_COUNT=${sh(issues.length)}`,
`TARGET_CHEMIN=${sh(primary.chemin)}`,
].join("\n") + "\n"
);
}
main().catch((e) => {
console.error("💥 pick-proposer-issue:", e?.message || e);
process.exit(1);
});

View File

@@ -1,29 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
DOCX="sources/docx/archicrat-ia/Chapitre_2Archeogenese_des_regimes_de_co-viabilite-version_officielle.docx"
MANIFEST="sources/manifest.yml"
ONLY="archicrat-ia/chapitre-2"
echo "== Audit source avant fix =="
if ! python3 scripts/audit-docx-source.py "$DOCX"; then
echo
echo "== Fix source =="
python3 scripts/fix-docx-source.py --in-place "$DOCX"
echo
echo "== Audit source après fix =="
python3 scripts/audit-docx-source.py "$DOCX"
fi
echo
echo "== Réimport =="
node scripts/import-docx.mjs --manifest "$MANIFEST" --only "$ONLY" --force
echo
echo "== Build =="
npm run build
echo
echo "== Tests =="
npm test

View File

@@ -1,20 +0,0 @@
import fs from "node:fs";
import path from "node:path";
const root = process.cwd();
const outDir = path.join(root, "public", "__ops");
const outFile = path.join(outDir, "health.json");
const payload = {
service: "archicratie-site",
env: process.env.PUBLIC_OPS_ENV || "unknown",
upstream: process.env.PUBLIC_OPS_UPSTREAM || "unknown",
buildSha: process.env.PUBLIC_BUILD_SHA || "unknown",
builtAt: process.env.PUBLIC_BUILD_TIME || new Date().toISOString(),
};
fs.mkdirSync(outDir, { recursive: true });
fs.writeFileSync(outFile, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
console.log(`✅ ops health written: ${outFile}`);
console.log(payload);

View File

@@ -1,123 +1,161 @@
version: 1 version: 1
docs: docs:
# =========================
# Document dentrée
# =========================
- source: sources/docx/commencer/document-de-presentation.docx
collection: commencer
slug: document-de-presentation
title: "Document de présentation"
order: 0
# ========================= # =========================
# Archicratie — Essai-thèse "ArchiCraT-IA" # Archicratie — Essai-thèse "ArchiCraT-IA"
# ========================= # =========================
- source: sources/docx/archicrat-ia/Prologue—Archicratie-fondation_et_finalite_sociopolitique_et_historique-version_officielle.docx - source: sources/docx/archicrat-ia/Prologue—Archicratie-fondation_et_finalite_sociopolitique_et_historique-version_officielle.docx
collection: archicrat-ia collection: archicratie
slug: prologue slug: archicrat-ia/prologue
title: "Prologue — Fondation, finalité sociopolitique et historique" title: "Prologue — Fondation et finalité sociopolitique et historique"
order: 10 order: 10
- source: sources/docx/archicrat-ia/Chapitre_1—Fondements_epistemologiques_et_modelisation_Archicratie-version_officielle.docx - source: sources/docx/archicrat-ia/Chapitre_1—Fondements_epistemologiques_et_modelisation_Archicratie-version_officielle.docx
collection: archicrat-ia collection: archicratie
slug: chapitre-1 slug: archicrat-ia/chapitre-1
title: "Chapitre 1 — Fondements épistémologiques et modélisation" title: "Chapitre 1 — Fondements épistémologiques et modélisation"
order: 20 order: 20
- source: sources/docx/archicrat-ia/Chapitre_2Archeogenese_des_regimes_de_co-viabilite-version_officielle.docx - source: sources/docx/archicrat-ia/Chapitre_2Archeogenese_des_regimes_de_co-viabilite-version_officielle.docx
collection: archicrat-ia collection: archicratie
slug: chapitre-2 slug: archicrat-ia/chapitre-2
title: "Chapitre 2 — Archéogenèse des régimes de co-viabilité" title: "Chapitre 2 — Archéogenèse des régimes de co-viabilité"
order: 30 order: 30
- source: sources/docx/archicrat-ia/Chapitre_3—Philosophies_du_pouvoir_et_Archicration-pour_une_topologie_differenciee_des_regimes_regulateurs-version_officielle.docx - source: sources/docx/archicrat-ia/Chapitre_3—Philosophies_du_pouvoir_et_Archicration-pour_une_topologie_differenciee_des_regimes_regulateurs-version_officielle.docx
collection: archicrat-ia collection: archicratie
slug: chapitre-3 slug: archicrat-ia/chapitre-3
title: "Chapitre 3 — Philosophies du pouvoir et archicration" title: "Chapitre 3 — Philosophies du pouvoir et archicration"
order: 40 order: 40
- source: sources/docx/archicrat-ia/Chapitre_4—Vers_une_histoire_archicratique_des_revolutions_industrielles-version_officielle.docx - source: sources/docx/archicrat-ia/Chapitre_4—Vers_une_histoire_archicratique_des_revolutions_industrielles-version_officielle.docx
collection: archicrat-ia collection: archicratie
slug: chapitre-4 slug: archicrat-ia/chapitre-4
title: "Chapitre 4 — Histoire archicratique des révolutions industrielles" title: "Chapitre 4 — Histoire archicratique des révolutions industrielles"
order: 50 order: 50
- source: sources/docx/archicrat-ia/Chapitre_5—Problematiques_des_tensions_des_co-viabilites_et_des_regulations_archicratiques-version_officielle.docx - source: sources/docx/archicrat-ia/Chapitre_5—Problematiques_des_tensions_des_co-viabilites_et_des_regulations_archicratiques-version_officielle.docx
collection: archicrat-ia collection: archicratie
slug: chapitre-5 slug: archicrat-ia/chapitre-5
title: "Chapitre 5 — Tensions, co-viabilités et régulations" title: "Chapitre 5 — Tensions, co-viabilités et régulations"
order: 60 order: 60
- source: sources/docx/archicrat-ia/Conclusion-Archicrat-IA-version_officielle.docx - source: sources/docx/archicrat-ia/Conclusion-Archicrat-IA-version_officielle.docx
collection: archicrat-ia collection: archicratie
slug: conclusion slug: archicrat-ia/conclusion
title: "Conclusion — ArchiCraT-IA" title: "Conclusion — ArchiCraT-IA"
order: 70 order: 70
# ========================= # =========================
# Cas pratique — Gouvernance des systèmes IA # IA — Cas pratique (1 page = 1 chapitre)
# NOTE: on n'inclut PAS le monolithe "Cas_IA-... .docx" dans le manifeste.
# ========================= # =========================
- source: sources/docx/cas-ia/Cas_Pratique-Archicratie_et_gouvernance_des_systemes_IA-Introduction.docx - source: sources/docx/cas-ia/Cas_IA-Archicratie_et_gouvernance_des_systemes_IA-Introduction_generale—Mettre_en_scene_un_systeme_IA.docx
collection: cas-ia collection: ia
slug: introduction slug: cas-pratique/introduction
title: "Introduction générale Mettre un système dIA en scène" title: "Cas pratique — Introduction générale : Mettre en scène un système IA"
order: 110 order: 110
- source: sources/docx/cas-ia/Cas_Pratique-Archicratie_et_gouvernance_des_systemes_IA-Chapitre_1_Epreuve_de_detectabilite.docx - source: sources/docx/cas-ia/Cas_IA-Archicratie_et_gouvernance_des_systemes_IA-Chapitre_I—Epreuve_de_detectabilite.docx
collection: cas-ia collection: ia
slug: chapitre-1 slug: cas-pratique/chapitre-1
title: "Chapitre I Épreuve de détectabilité" title: "Cas pratique — Chapitre I : Épreuve de détectabilité"
order: 120 order: 120
- source: sources/docx/cas-ia/Cas_Pratique-Archicratie_et_gouvernance_des_systemes_IA-Chapitre_2_Epreuve_Topologique.docx - source: sources/docx/cas-ia/Cas_IA-Archicratie_et_gouvernance_des_systemes_IA-Chapitre_II—Epreuve_topologique.docx
collection: cas-ia collection: ia
slug: chapitre-2 slug: cas-pratique/chapitre-2
title: "Chapitre II Épreuve topologique" title: "Cas pratique — Chapitre II : Épreuve topologique"
order: 130 order: 130
- source: sources/docx/cas-ia/Cas_Pratique-Archicratie_et_gouvernance_des_systemes_IA-Chapitre_3_Epreuve_archeogenetique.docx - source: sources/docx/cas-ia/Cas_IA-Archicratie_et_gouvernance_des_systemes_IA-Chapitre_III—Epreuve_archeogenetique.docx
collection: cas-ia collection: ia
slug: chapitre-3 slug: cas-pratique/chapitre-3
title: "Chapitre III Épreuve archéogénétique" title: "Cas pratique — Chapitre III : Épreuve archéogénétique"
order: 140 order: 140
- source: sources/docx/cas-ia/Cas_Pratique-Archicratie_et_gouvernance_des_systemes_IA-Chapitre_4_Epreuve_Morphologique.docx - source: sources/docx/cas-ia/Cas_IA-Archicratie_et_gouvernance_des_systemes_IA-Chapitre_IV—Epreuve_morphologique.docx
collection: cas-ia collection: ia
slug: chapitre-4 slug: cas-pratique/chapitre-4
title: "Chapitre IV Épreuve morphologique" title: "Cas pratique — Chapitre IV : Épreuve morphologique"
order: 150 order: 150
- source: sources/docx/cas-ia/Cas_Pratique-Archicratie_et_gouvernance_des_systemes_IA-Chapitre_5_Epreuve_Historique.docx - source: sources/docx/cas-ia/Cas_IA-Archicratie_et_gouvernance_des_systemes_IA-Chapitre_V—Epreuve_historique.docx
collection: cas-ia collection: ia
slug: chapitre-5 slug: cas-pratique/chapitre-5
title: "Chapitre V Épreuve historique" title: "Cas pratique — Chapitre V : Épreuve historique"
order: 160 order: 160
- source: sources/docx/cas-ia/Cas_Pratique-Archicratie_et_gouvernance_des_systemes_IA-Chapitre_6_Epreuve_de_Co-viabilite.docx - source: sources/docx/cas-ia/Cas_IA-Archicratie_et_gouvernance_des_systemes_IA-Chapitre_VI—Epreuve_de_co-viabilite.docx
collection: cas-ia collection: ia
slug: chapitre-6 slug: cas-pratique/chapitre-6
title: "Chapitre VI Épreuve de co-viabilité" title: "Cas pratique — Chapitre VI : Épreuve de co-viabilité"
order: 170 order: 170
- source: sources/docx/cas-ia/Cas_Pratique-Archicratie_et_gouvernance_des_systemes_IA-Chapitre_7_Gestes_archicratiques_concrets_pour_un_systeme_IA.docx - source: sources/docx/cas-ia/Cas_IA-Archicratie_et_gouvernance_des_systemes_IA-Chapitre_VII—Gestes_archicratiques_concrets_pour_un_systeme_IA.docx
collection: cas-ia collection: ia
slug: chapitre-7 slug: cas-pratique/chapitre-7
title: "Chapitre VII Gestes archicratiques concrets pour un système dIA" title: "Cas pratique — Chapitre VII : Gestes archicratiques concrets"
order: 180 order: 180
- source: sources/docx/cas-ia/Cas_Pratique-Archicratie_et_gouvernance_des_systemes_IA-Conclusion.docx - source: sources/docx/cas-ia/Cas_IA-Archicratie_et_gouvernance_des_systemes_IA-Conclusion.docx
collection: cas-ia collection: ia
slug: conclusion slug: cas-pratique/conclusion
title: "Conclusion" title: "Cas pratique — Conclusion"
order: 190 order: 190
- source: sources/docx/cas-ia/Cas_Pratique-Archicratie_et_gouvernance_des_systemes_IA-Annexe_Glossaire_Archicratique_Cas_IA.docx - source: sources/docx/cas-ia/Cas_IA-Archicratie_et_gouvernance_des_systemes_IA-AnnexeGlossaire_archicratique_pour_audit_des_systemes_IA.docx
collection: cas-ia collection: ia
slug: annexe-glossaire-audit slug: cas-pratique/annexe-glossaire-audit
title: "Annexe Glossaire archicratique pour laudit des systèmes dIA" title: "Cas pratique — Annexe : Glossaire archicratique pour audit des systèmes IA"
order: 195 order: 195
# =========================
# Traité — Ontodynamique générative (1 page = 1 chapitre)
# NOTE: on n'inclut PAS le monolithe "Traite-...-version_officielle.docx" dans le manifeste.
# =========================
- source: sources/docx/traite/Traite-Ontodynamique_Generative-Fondements_Archicratie-Introduction-version_officielle.docx
collection: traite
slug: ontodynamique/introduction
title: "Traité — Introduction"
order: 210
- source: sources/docx/traite/Traite-Ontodynamique_Generative-Fondements_Archicratie-Chapitre_1—Le_flux_ontogenetique-version_officielle.docx
collection: traite
slug: ontodynamique/chapitre-1
title: "Traité — Chapitre 1 : Le flux ontogénétique"
order: 220
- source: sources/docx/traite/Traite-Ontodynamique_Generative-Fondements_Archicratie-Chapitre_2—economie_du_reel-version_officielle.docx
collection: traite
slug: ontodynamique/chapitre-2
title: "Traité — Chapitre 2 : Économie du réel"
order: 230
- source: sources/docx/traite/Traite-Ontodynamique_Generative-Fondements_Archicratie-Chapitre_3—Le_reel_comme_systeme_regulateur-version_officielle.docx
collection: traite
slug: ontodynamique/chapitre-3
title: "Traité — Chapitre 3 : Le réel comme système régulateur"
order: 240
- source: sources/docx/traite/Traite-Ontodynamique_Generative-Fondements_Archicratie-Chapitre_4—Arcalite-structures_formes_invariants-version_officielle.docx
collection: traite
slug: ontodynamique/chapitre-4
title: "Traité — Chapitre 4 : Arcalité — structures, formes, invariants"
order: 250
- source: sources/docx/traite/Traite-Ontodynamique_Generative-Fondements_Archicratie-Chapitre_5-Cratialite-forces_flux_gradients-version_officielle.docx
collection: traite
slug: ontodynamique/chapitre-5
title: "Traité — Chapitre 5 : Cratialité — forces, flux, gradients"
order: 260
- source: sources/docx/traite/Traite-Ontodynamique_Generative-Fondements_Archicratie-Chapitre_6—Archicration-version_officielle.docx
collection: traite
slug: ontodynamique/chapitre-6
title: "Traité — Chapitre 6 : Archicration"
order: 270
# ========================= # =========================
# Glossaire / Lexique # Glossaire / Lexique
# ========================= # =========================
@@ -131,4 +169,4 @@ docs:
collection: glossaire collection: glossaire
slug: mini-glossaire-verbes slug: mini-glossaire-verbes
title: "Mini-glossaire des verbes de la scène archicratique" title: "Mini-glossaire des verbes de la scène archicratique"
order: 910 order: 910

View File

@@ -1 +1,2 @@
{} {}

View File

@@ -0,0 +1,10 @@
schema: 1
page: archicrat-ia/chapitre-1
paras:
p-0-8d27a7f5:
refs:
- url: https://auth.archicratie.trans-hands.synology.me/authenticated
label: Lien web
kind: (livre / article / vidéo / site / autre) Site
ts: 2026-02-27T12:34:31.704Z
fromIssue: 142

View File

@@ -0,0 +1,9 @@
schema: 1
page: archicrat-ia/chapitre-1
paras:
p-1-8a6c18bf:
comments_editorial:
- text: Yeaha
status: new
ts: 2026-02-27T12:40:39.462Z
fromIssue: 143

View File

@@ -0,0 +1,12 @@
schema: 1
page: archicrat-ia/chapitre-3
paras:
p-0-ace27175:
media:
- type: image
src: /media/archicrat-ia/chapitre-3/p-0-ace27175/Capture_d_e_cran_2025-05-05_a_19.20.40.png
caption: "[Media] p-0-ace27175 — Chapitre 3 — Philosophies du pouvoir et
archicration"
credit: ""
ts: 2026-02-27T12:43:14.259Z
fromIssue: 144

View File

@@ -0,0 +1,30 @@
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
p-11-67c14c09:
media:
- type: image
src: /media/archicrat-ia/chapitre-4/p-11-67c14c09/Capture_d_e_cran_2026-02-16_a_13.07.35.png
caption: "[Media] p-11-67c14c09 — Chapitre 4 — Histoire archicratique des
révolutions industrielles"
credit: ""
ts: 2026-02-26T13:17:41.286Z
fromIssue: 129

View File

@@ -0,0 +1,19 @@
schema: 1
page: archicrat-ia/chapitre-4
paras:
p-11-67c14c09:
media:
- type: image
src: /media/archicrat-ia/chapitre-4/p-11-67c14c09/Capture_d_e_cran_2026-02-16_a_13.07.35.png
caption: "[Media] p-11-67c14c09 — Chapitre 4 — Histoire archicratique des
révolutions industrielles"
credit: ""
ts: 2026-02-26T13:17:41.286Z
fromIssue: 129
- type: image
src: /media/archicrat-ia/chapitre-4/p-11-67c14c09/Capture_d_e_cran_2025-05-05_a_19.20.40.png
caption: "[Media] p-11-67c14c09 — Chapitre 4 — Histoire archicratique des
révolutions industrielles"
credit: ""
ts: 2026-02-27T09:17:04.386Z
fromIssue: 127

View File

@@ -0,0 +1,50 @@
schema: 1
paras:
p-0-d7974f88:
refs:
- label: "Happycratie — (Cabanas & Illouz) via Cairn"
url: "https://shs.cairn.info/revue-ethnologie-francaise-2019-4-page-813?lang=fr"
kind: "article"
- label: "Techno-féodalisme — Variations (OpenEdition)"
url: "https://journals.openedition.org/variations/2290"
kind: "article"
authors:
- "Eva Illouz"
- "Yanis Varoufakis"
quotes:
- text: "Dans Happycratie, Edgar Cabanas et Eva Illouz..."
source: "Happycratie, p.1"
- text: "En eux-mêmes, les actifs ne sont ni féodaux ni capitalistes..."
source: "Entretien Morozov/Varoufakis — techno-féodalisme"
media:
- type: "image"
src: "/public/media/archicratie/archicrat-ia/prologue/p-0-d7974f88/schema-1.svg"
caption: "Tableau explicatif"
credit: "ChatGPT"
- type: "image"
src: "/public/media/archicratie/archicrat-ia/prologue/p-0-d7974f88/schema-2.svg"
caption: "Diagramme dévolution"
credit: "Yanis Varoufakis"
comments_editorial:
- text: "TODO: nuancer / préciser — commentaire éditorial versionné (pas public)."
status: "draft"
p-1-2ef25f29:
refs:
- label: "Kafka et le pouvoir — Bernard Lahire (Cairn)"
url: "https://shs.cairn.info/franz-kafka--9782707159410-page-475?lang=fr"
kind: "book"
authors:
- "Bernard Lahire"
quotes:
- text: "Si lon voulait chercher quelque chose comme une vision du monde chez Kafka..."
source: "Bernard Lahire, Franz Kafka, p.475+"
comments_editorial: []

View File

@@ -1,80 +1,51 @@
--- ---
import { getCollection } from "astro:content"; import { getCollection } from "astro:content";
const { const { currentSlug } = Astro.props;
currentSlug,
collection = "archicrat-ia",
basePath = "/archicrat-ia",
label = "Table des matières"
} = Astro.props;
const slugOf = (entry) => String(entry.id).replace(/\.(md|mdx)$/i, ""); const entries = (await getCollection("archicratie"))
const hrefOf = (entry) => `${basePath}/${slugOf(entry)}/`; .filter((e) => e.slug.startsWith("archicrat-ia/"))
.sort((a, b) => (a.data.order ?? 0) - (b.data.order ?? 0));
const collator = new Intl.Collator("fr", { sensitivity: "base", numeric: true }); // ✅ On route lEssai-thèse sur /archicrat-ia/<slug-sans-prefix>/
// (Astro trailingSlash = always → on garde le "/" final)
const entries = [...await getCollection(collection)].sort((a, b) => { const strip = (s) => String(s || "").replace(/^archicrat-ia\//, "");
const ao = Number(a.data.order ?? 9999); const href = (slug) => `/archicrat-ia/${strip(slug)}/`;
const bo = Number(b.data.order ?? 9999);
if (ao !== bo) return ao - bo;
const at = String(a.data.title ?? a.data.term ?? slugOf(a));
const bt = String(b.data.title ?? b.data.term ?? slugOf(b));
return collator.compare(at, bt);
});
const tocId = `toc-global-${collection}-${String(basePath).replace(/[^\w-]+/g, "-")}`;
--- ---
<nav <nav class="toc-global" aria-label="Table des matières — ArchiCraT-IA">
class="toc-global" <div class="toc-global__head">
data-mobile-default="closed" <div class="toc-global__title">Table des matières</div>
aria-label={label}
data-toc-global
data-toc-key={`global:${collection}:${basePath}`}
>
<button
class="toc-global__head toc-global__toggle"
type="button"
aria-expanded="false"
aria-controls={tocId}
>
<span class="toc-global__title">{label}</span>
<span class="toc-global__chevron" aria-hidden="true">▾</span>
</button>
<div class="toc-global__body-clip" id={tocId} hidden>
<div class="toc-global__body">
<ol class="toc-global__list">
{entries.map((e) => {
const slug = slugOf(e);
const active = slug === currentSlug;
return (
<li class={`toc-item ${active ? "is-active" : ""}`}>
<a class="toc-link" href={hrefOf(e)} aria-current={active ? "page" : undefined}>
<span class="toc-link__row">
<span class={`toc-active-mark ${active ? "is-on" : ""}`} aria-hidden="true">
<span class="toc-active-mark__dot"></span>
</span>
<span class="toc-link__title">{e.data.title}</span>
{active && (
<span class="toc-badge" aria-label="Chapitre en cours">
En cours
</span>
)}
</span>
{active && <span class="toc-underline" aria-hidden="true"></span>}
</a>
</li>
);
})}
</ol>
</div>
</div> </div>
<ol class="toc-global__list">
{entries.map((e) => {
const active = e.slug === currentSlug;
return (
<li class={`toc-item ${active ? "is-active" : ""}`}>
<a class="toc-link" href={href(e.slug)} aria-current={active ? "page" : undefined}>
<span class="toc-link__row">
{active ? (
<span class="toc-active-indicator" aria-hidden="true">👉</span>
) : (
<span class="toc-active-spacer" aria-hidden="true"></span>
)}
<span class="toc-link__title">{e.data.title}</span>
{active && (
<span class="toc-badge" aria-label="Chapitre en cours">
En cours
</span>
)}
</span>
{active && <span class="toc-underline" aria-hidden="true"></span>}
</a>
</li>
);
})}
</ol>
</nav> </nav>
<style> <style>
@@ -85,22 +56,7 @@ const tocId = `toc-global-${collection}-${String(basePath).replace(/[^\w-]+/g, "
background: rgba(127,127,127,0.06); background: rgba(127,127,127,0.06);
} }
.toc-global__toggle{
width: 100%;
appearance: none;
border: 0;
background: transparent;
color: inherit;
text-align: left;
padding: 0;
cursor: pointer;
}
.toc-global__head{ .toc-global__head{
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 10px; margin-bottom: 10px;
padding-bottom: 10px; padding-bottom: 10px;
border-bottom: 1px dashed rgba(127,127,127,0.25); border-bottom: 1px dashed rgba(127,127,127,0.25);
@@ -113,36 +69,11 @@ const tocId = `toc-global-${collection}-${String(basePath).replace(/[^\w-]+/g, "
opacity: .88; opacity: .88;
} }
.toc-global__chevron{
font-size: 12px;
opacity: .7;
transition: transform 180ms ease;
}
.toc-global__body-clip{
display: grid;
grid-template-rows: 1fr;
transition:
grid-template-rows 220ms ease,
opacity 160ms ease,
margin-top 220ms ease;
}
.toc-global__body{
min-height: 0;
overflow: hidden;
}
.toc-global__list{ .toc-global__list{
list-style: none; list-style: none;
margin: 0; margin: 0;
padding: 0; padding: 0;
max-height: 44vh;
overflow: auto;
padding-right: 8px;
scrollbar-gutter: stable;
} }
.toc-global__list li::marker{ content: ""; } .toc-global__list li::marker{ content: ""; }
.toc-item{ margin: 6px 0; } .toc-item{ margin: 6px 0; }
@@ -168,33 +99,13 @@ const tocId = `toc-global-${collection}-${String(basePath).replace(/[^\w-]+/g, "
align-items: center; align-items: center;
} }
.toc-active-mark{ .toc-active-indicator{
font-size: 14px;
line-height: 1;
}
.toc-active-spacer{
width: 14px; width: 14px;
height: 14px;
display: inline-grid;
place-items: center;
border-radius: 999px;
border: 1px solid transparent;
opacity: .55;
}
.toc-active-mark__dot{
width: 5px;
height: 5px;
border-radius: 999px;
background: currentColor;
opacity: .65;
}
.toc-active-mark.is-on{
border-color: rgba(127,127,127,0.34);
opacity: 1;
}
.toc-active-mark.is-on .toc-active-mark__dot{
width: 6px;
height: 6px;
opacity: 1;
} }
.toc-link__title{ .toc-link__title{
@@ -232,70 +143,11 @@ const tocId = `toc-global-${collection}-${String(basePath).replace(/[^\w-]+/g, "
border-radius: 999px; border-radius: 999px;
} }
@media (max-width: 980px){ .toc-global__list{
.toc-global{ max-height: 44vh;
padding: 10px 12px; overflow: auto;
border-radius: 14px; padding-right: 8px;
} scrollbar-gutter: stable;
.toc-global__head{
margin-bottom: 0;
padding-bottom: 0;
border-bottom: 0;
min-height: 28px;
}
.toc-global__title{
font-size: 13px;
}
.toc-global__body-clip{
margin-top: 10px;
}
.toc-global.is-collapsed .toc-global__body-clip{
grid-template-rows: 0fr;
opacity: 0;
margin-top: 0;
}
.toc-global__body{
min-height: 0;
overflow: hidden;
transition: opacity 180ms ease;
}
.toc-global.is-collapsed .toc-global__body{
opacity: 0;
}
.toc-global.is-collapsed .toc-global__chevron{
transform: rotate(-90deg);
}
.toc-link{
padding: 7px 9px;
border-radius: 12px;
}
.toc-link__title{
font-size: 12.5px;
line-height: 1.22;
}
.toc-badge{
font-size: 10px;
padding: 2px 7px;
}
.toc-global__list{
max-height: min(42vh, 360px);
padding-right: 4px;
}
.toc-global__body-clip[hidden]{
display: none !important;
}
} }
@media (prefers-color-scheme: dark){ @media (prefers-color-scheme: dark){
@@ -303,95 +155,12 @@ const tocId = `toc-global-${collection}-${String(basePath).replace(/[^\w-]+/g, "
.toc-link:hover{ background: rgba(255,255,255,0.06); } .toc-link:hover{ background: rgba(255,255,255,0.06); }
.toc-item.is-active .toc-link{ background: rgba(255,255,255,0.06); } .toc-item.is-active .toc-link{ background: rgba(255,255,255,0.06); }
.toc-badge{ background: rgba(255,255,255,0.06); } .toc-badge{ background: rgba(255,255,255,0.06); }
.toc-active-mark.is-on{ border-color: rgba(255,255,255,0.22); }
} }
</style> </style>
<script is:inline> <script is:inline>
(() => { (() => {
function init() { const active = document.querySelector(".toc-global .toc-item.is-active");
document.querySelectorAll("[data-toc-global]").forEach((nav) => { if (active) active.scrollIntoView({ block: "nearest" });
if (nav.dataset.tocReady === "1") return;
nav.dataset.tocReady = "1";
const toggle = nav.querySelector(".toc-global__toggle");
const bodyClip = nav.querySelector(".toc-global__body-clip");
const active = nav.querySelector(".toc-item.is-active");
const mq = window.matchMedia("(max-width: 980px)");
const key = `archicratie:${nav.dataset.tocKey || "toc-global"}`;
if (!toggle || !bodyClip) return;
const read = () => {
try {
const v = localStorage.getItem(key);
if (v === "open") return true;
if (v === "closed") return false;
} catch {}
return null;
};
const write = (open) => {
try { localStorage.setItem(key, open ? "open" : "closed"); } catch {}
};
const setOpen = (open, { persist = true } = {}) => {
const isMobile = mq.matches;
const effectiveOpen = isMobile ? open : true;
nav.classList.toggle("is-collapsed", isMobile && !effectiveOpen);
toggle.setAttribute("aria-expanded", effectiveOpen ? "true" : "false");
if (bodyClip) {
bodyClip.hidden = isMobile && !effectiveOpen;
}
if (persist && isMobile) write(effectiveOpen);
};
const initState = () => {
if (!mq.matches) {
setOpen(true, { persist: false });
if (active) active.scrollIntoView({ block: "nearest" });
return;
}
const stored = read();
const open = stored == null ? false : stored;
setOpen(open, { persist: false });
if (open && active) active.scrollIntoView({ block: "nearest" });
};
toggle.addEventListener("click", () => {
const open = toggle.getAttribute("aria-expanded") !== "true";
setOpen(open);
if (open && active) active.scrollIntoView({ block: "nearest" });
if (open) {
window.dispatchEvent(new CustomEvent("archicratie:tocGlobalOpen"));
}
});
window.addEventListener("archicratie:tocLocalOpen", () => {
if (!mq.matches) return;
setOpen(false);
});
if (mq.addEventListener) {
mq.addEventListener("change", initState);
} else if (mq.addListener) {
mq.addListener(initState);
}
initState();
});
}
if (document.readyState === "loading") {
window.addEventListener("DOMContentLoaded", init, { once: true });
} else {
init();
}
})(); })();
</script> </script>

View File

@@ -1,529 +0,0 @@
---
import {
getGlossaryEntryAsideData,
getGlossaryPortalLinks,
hrefOfGlossaryEntry,
slugOfGlossaryEntry,
} from "../lib/glossary";
const {
currentEntry,
allEntries = [],
} = Astro.props;
const currentSlug = slugOfGlossaryEntry(currentEntry);
const {
displayFamily,
displayDomain,
displayLevel,
showNoyau,
showSameFamily,
fondamentaux,
sameFamilyTitle,
sameFamilyEntries,
relationSections,
contextualTheory,
} = getGlossaryEntryAsideData(currentEntry, allEntries);
const portalLinks = getGlossaryPortalLinks();
---
<nav class="glossary-aside" aria-label="Navigation du glossaire">
<div class="glossary-aside__block glossary-aside__block--intro">
<a class="glossary-aside__back" href="/glossaire/">← Retour au glossaire</a>
<div class="glossary-aside__title">Glossaire archicratique</div>
<div class="glossary-aside__pills" aria-label="Repères de lecture">
<span class="glossary-aside__pill glossary-aside__pill--family">
{displayFamily}
</span>
{displayDomain && (
<span class="glossary-aside__pill">{displayDomain}</span>
)}
{displayLevel && (
<span class="glossary-aside__pill">{displayLevel}</span>
)}
</div>
</div>
<details class="glossary-aside__block glossary-aside__disclosure">
<summary class="glossary-aside__summary">
<span class="glossary-aside__heading">Portails</span>
<span class="glossary-aside__chevron" aria-hidden="true">▾</span>
</summary>
<div class="glossary-aside__panel">
<ul class="glossary-aside__list">
{portalLinks.map((item) => (
<li><a href={item.href}>{item.label}</a></li>
))}
</ul>
</div>
</details>
{showNoyau && (
<details class="glossary-aside__block glossary-aside__disclosure">
<summary class="glossary-aside__summary">
<span class="glossary-aside__heading">Noyau archicratique</span>
<span class="glossary-aside__chevron" aria-hidden="true">▾</span>
</summary>
<div class="glossary-aside__panel">
<ul class="glossary-aside__list">
{fondamentaux.map((entry) => {
const active = slugOfGlossaryEntry(entry) === currentSlug;
return (
<li>
<a
href={hrefOfGlossaryEntry(entry)}
aria-current={active ? "page" : undefined}
class={active ? "is-active" : undefined}
>
{entry.data.term}
</a>
</li>
);
})}
</ul>
</div>
</details>
)}
{showSameFamily && (
<details class="glossary-aside__block glossary-aside__disclosure">
<summary class="glossary-aside__summary">
<span class="glossary-aside__heading">{sameFamilyTitle}</span>
<span class="glossary-aside__chevron" aria-hidden="true">▾</span>
</summary>
<div class="glossary-aside__panel">
<ul class="glossary-aside__list">
{sameFamilyEntries.map((entry) => {
const active = slugOfGlossaryEntry(entry) === currentSlug;
return (
<li>
<a
href={hrefOfGlossaryEntry(entry)}
aria-current={active ? "page" : undefined}
class={active ? "is-active" : undefined}
>
{entry.data.term}
</a>
</li>
);
})}
</ul>
</div>
</details>
)}
{relationSections.length > 0 && (
<details class="glossary-aside__block glossary-aside__disclosure">
<summary class="glossary-aside__summary">
<span class="glossary-aside__heading">Autour de cette fiche</span>
<span class="glossary-aside__chevron" aria-hidden="true">▾</span>
</summary>
<div class="glossary-aside__panel">
{relationSections.map((section) => (
<>
<h3 class="glossary-aside__subheading">{section.title}</h3>
<ul class="glossary-aside__list">
{section.items.map((entry) => (
<li><a href={hrefOfGlossaryEntry(entry)}>{entry.data.term}</a></li>
))}
</ul>
</>
))}
</div>
</details>
)}
{contextualTheory.length > 0 && (
<details class="glossary-aside__block glossary-aside__disclosure">
<summary class="glossary-aside__summary">
<span class="glossary-aside__heading">Paysage théorique</span>
<span class="glossary-aside__chevron" aria-hidden="true">▾</span>
</summary>
<div class="glossary-aside__panel">
<ul class="glossary-aside__list">
{contextualTheory.map((entry) => (
<li><a href={hrefOfGlossaryEntry(entry)}>{entry.data.term}</a></li>
))}
</ul>
</div>
</details>
)}
</nav>
<style>
.glossary-aside{
display: flex;
flex-direction: column;
gap: 14px;
min-width: 0;
}
.glossary-aside__block{
border: 1px solid rgba(127,127,127,0.22);
border-radius: 16px;
padding: 14px;
background: rgba(127,127,127,0.05);
min-width: 0;
}
.glossary-aside__block--intro{
padding-top: 13px;
padding-bottom: 13px;
}
.glossary-aside__back{
display: inline-block;
margin-bottom: 10px;
font-size: 14px;
font-weight: 700;
line-height: 1.35;
text-decoration: none;
}
.glossary-aside__title{
font-size: 18px;
font-weight: 850;
letter-spacing: .1px;
line-height: 1.22;
}
.glossary-aside__pills{
display: flex;
flex-wrap: wrap;
gap: 7px;
margin-top: 10px;
}
.glossary-aside__pill{
display: inline-flex;
align-items: center;
padding: 5px 10px;
border: 1px solid rgba(127,127,127,0.24);
border-radius: 999px;
background: rgba(127,127,127,0.04);
font-size: 13px;
line-height: 1.35;
opacity: .92;
min-width: 0;
}
.glossary-aside__pill--family{
border-color: rgba(127,127,127,0.38);
font-weight: 800;
}
.glossary-aside__disclosure{
padding: 0;
overflow: hidden;
}
.glossary-aside__summary{
list-style: none;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px;
cursor: pointer;
user-select: none;
}
.glossary-aside__summary::-webkit-details-marker{
display: none;
}
.glossary-aside__summary:hover{
background: rgba(127,127,127,0.035);
}
.glossary-aside__heading{
margin: 0;
font-size: 16px;
font-weight: 850;
line-height: 1.28;
opacity: .97;
}
.glossary-aside__chevron{
flex: 0 0 auto;
font-size: 14px;
line-height: 1;
opacity: .72;
transform: rotate(0deg);
transition: transform 160ms ease, opacity 160ms ease;
}
.glossary-aside__disclosure[open] .glossary-aside__chevron{
transform: rotate(180deg);
opacity: .96;
}
.glossary-aside__panel{
padding: 0 14px 14px;
}
.glossary-aside__subheading{
margin: 13px 0 8px;
font-size: 12.5px;
font-weight: 800;
line-height: 1.35;
opacity: .82;
text-transform: uppercase;
letter-spacing: .04em;
}
.glossary-aside__list{
list-style: none;
margin: 0;
padding: 0;
}
.glossary-aside__list li{
margin: 7px 0;
}
.glossary-aside__list a{
text-decoration: none;
font-size: 14px;
line-height: 1.4;
word-break: break-word;
}
.glossary-aside__list a.is-active{
font-weight: 800;
}
@media (max-width: 860px){
.glossary-aside{
gap: 10px;
}
.glossary-aside__block{
border-radius: 14px;
}
.glossary-aside__block--intro{
padding: 12px;
}
.glossary-aside__back{
margin-bottom: 8px;
font-size: 13px;
line-height: 1.28;
}
.glossary-aside__title{
font-size: 19px;
line-height: 1.18;
}
.glossary-aside__pills{
gap: 6px;
margin-top: 8px;
}
.glossary-aside__pill{
padding: 4px 9px;
font-size: 12px;
line-height: 1.26;
}
.glossary-aside__summary{
padding: 12px;
}
.glossary-aside__heading{
font-size: 17px;
line-height: 1.2;
}
.glossary-aside__panel{
padding: 0 12px 12px;
}
.glossary-aside__subheading{
margin: 10px 0 6px;
font-size: 11.5px;
line-height: 1.26;
}
.glossary-aside__list li{
margin: 5px 0;
}
.glossary-aside__list a{
font-size: 14px;
line-height: 1.34;
}
.glossary-aside__disclosure:not([open]) .glossary-aside__panel{
display: none;
}
}
@media (max-width: 860px){
.glossary-aside__disclosure{
background: rgba(127,127,127,0.045);
}
.glossary-aside__disclosure[open] .glossary-aside__summary{
border-bottom: 1px solid rgba(127,127,127,0.12);
}
}
@media (orientation: landscape) and (max-width: 920px) and (max-height: 520px){
.glossary-aside{
gap: 8px;
}
.glossary-aside__block{
border-radius: 12px;
}
.glossary-aside__block--intro{
padding: 10px 11px;
}
.glossary-aside__back{
margin-bottom: 6px;
font-size: 12px;
line-height: 1.2;
}
.glossary-aside__title{
font-size: 16px;
line-height: 1.14;
}
.glossary-aside__pills{
gap: 5px;
margin-top: 7px;
}
.glossary-aside__pill{
padding: 3px 8px;
font-size: 11px;
line-height: 1.2;
}
.glossary-aside__summary{
padding: 10px 11px;
}
.glossary-aside__heading{
font-size: 15px;
line-height: 1.16;
}
.glossary-aside__panel{
padding: 0 11px 10px;
}
.glossary-aside__subheading{
margin: 8px 0 5px;
font-size: 11px;
line-height: 1.18;
}
.glossary-aside__list li{
margin: 4px 0;
}
.glossary-aside__list a{
font-size: 13px;
line-height: 1.28;
}
}
@media (orientation: portrait) and (max-width: 1024px) and (pointer: coarse){
.glossary-aside{
gap: 10px;
}
.glossary-aside__disclosure{
background: rgba(127,127,127,0.045);
}
.glossary-aside__disclosure:not([open]) .glossary-aside__panel{
display: none;
}
.glossary-aside__summary{
cursor: pointer;
}
.glossary-aside__chevron{
display: inline;
}
}
@media (min-width: 861px) and (hover: hover) and (pointer: fine){
.glossary-aside__summary{
cursor: default;
}
.glossary-aside__chevron{
display: none;
}
}
@media (prefers-color-scheme: dark){
.glossary-aside__block,
.glossary-aside__pill{
background: rgba(255,255,255,0.04);
}
.glossary-aside__summary:hover{
background: rgba(255,255,255,0.03);
}
}
</style>
<script is:inline>
(() => {
const syncMobileDisclosure = () => {
const mobile = window.matchMedia(
"(max-width: 860px), ((orientation: portrait) and (max-width: 1024px) and (pointer: coarse))"
).matches;
const smallLandscape = window.matchMedia(
"(orientation: landscape) and (max-width: 920px) and (max-height: 520px)"
).matches;
const compact = mobile || smallLandscape;
document
.querySelectorAll(".glossary-aside__disclosure")
.forEach((el, index) => {
if (!(el instanceof HTMLDetailsElement)) return;
if (compact) {
if (!el.dataset.mobileInit) {
el.open = false;
el.dataset.mobileInit = "true";
}
} else {
el.open = true;
}
});
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", syncMobileDisclosure, { once: true });
} else {
syncMobileDisclosure();
}
window.addEventListener("resize", syncMobileDisclosure);
window.addEventListener("pageshow", syncMobileDisclosure);
})();
</script>

View File

@@ -1,110 +0,0 @@
---
import { hrefOfGlossaryEntry, type GlossaryEntry } from "../lib/glossary";
export interface Props {
entries?: GlossaryEntry[];
wide?: boolean;
}
const {
entries = [],
wide = false,
} = Astro.props;
---
<div class="glossary-cards">
{entries.map((entry) => (
<a
class:list={[
"glossary-card",
wide && "glossary-card--wide",
]}
href={hrefOfGlossaryEntry(entry)}
>
<strong>{entry.data.term}</strong>
<span>{entry.data.definitionShort}</span>
</a>
))}
</div>
<style>
.glossary-cards{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
margin-top: 12px;
}
.glossary-card{
display: flex;
flex-direction: column;
gap: 7px;
padding: 13px 14px;
border: 1px solid var(--glossary-border);
border-radius: 16px;
background: var(--glossary-bg-soft);
text-decoration: none;
transition: transform 120ms ease, background 120ms ease, border-color 120ms ease;
}
.glossary-card:hover{
transform: translateY(-1px);
background: var(--glossary-bg-soft-strong);
border-color: rgba(0,217,255,0.16);
text-decoration: none;
}
.glossary-card--wide{
grid-column: 1 / -1;
}
.glossary-card strong{
color: var(--glossary-accent);
font-size: 1.02rem;
line-height: 1.24;
}
.glossary-card span{
color: inherit;
font-size: .98rem;
line-height: 1.46;
opacity: .94;
}
@media (max-width: 760px){
.glossary-cards{
grid-template-columns: 1fr;
gap: 10px;
margin-top: 10px;
}
.glossary-card{
gap: 6px;
padding: 12px 12px;
border-radius: 14px;
}
.glossary-card strong{
font-size: .98rem;
}
.glossary-card span{
font-size: .94rem;
line-height: 1.42;
}
.glossary-card--wide{
grid-column: auto;
}
}
@media (prefers-color-scheme: dark){
.glossary-card{
background: rgba(255,255,255,0.04);
}
.glossary-card:hover{
background: rgba(255,255,255,0.07);
}
}
</style>

View File

@@ -1,26 +0,0 @@
<div class="glossary-entry-body">
<slot />
</div>
<style>
.glossary-entry-body{
margin-bottom: 16px;
}
.glossary-entry-body > :last-child{
margin-bottom: 0;
}
@media (max-width: 760px){
.glossary-entry-body{
margin-bottom: 12px;
}
}
:global(.glossary-entry-body h2),
:global(.glossary-entry-body h3),
:global(.glossary-relations h2),
:global(.glossary-relations h3){
scroll-margin-top: calc(var(--sticky-offset-px, 96px) + 18px);
}
</style>

View File

@@ -1,264 +0,0 @@
---
interface Props {
term: string;
definitionShort: string;
displayFamily: string;
displayDomain?: string;
displayLevel?: string;
mobilizedAuthors?: string[];
comparisonTraditions?: string[];
}
const {
term,
definitionShort,
displayFamily,
displayDomain = "",
displayLevel = "",
mobilizedAuthors = [],
comparisonTraditions = [],
} = Astro.props;
const hasScholarlyMeta =
mobilizedAuthors.length > 0 ||
comparisonTraditions.length > 0;
---
<header class="glossary-entry-head" data-ge-hero>
<div class="glossary-entry-head__title">
<h1>{term}</h1>
</div>
<div class="glossary-entry-summary">
<p class="glossary-entry-dek">
<em>{definitionShort}</em>
</p>
<div class="glossary-entry-signals" aria-label="Repères de lecture">
<span class="glossary-pill glossary-pill--family">
<strong>Famille :</strong> {displayFamily}
</span>
{displayDomain && (
<span class="glossary-pill">
<strong>Domaine :</strong> {displayDomain}
</span>
)}
{displayLevel && (
<span class="glossary-pill">
<strong>Niveau :</strong> {displayLevel}
</span>
)}
</div>
{hasScholarlyMeta && (
<div class="glossary-entry-meta">
{mobilizedAuthors.length > 0 && (
<p>
<strong>Auteurs mobilisés :</strong> {mobilizedAuthors.join(" / ")}
</p>
)}
{comparisonTraditions.length > 0 && (
<p>
<strong>Traditions de comparaison :</strong> {comparisonTraditions.join(" / ")}
</p>
)}
</div>
)}
</div>
</header>
<style>
.glossary-entry-head{
position: sticky;
top: var(--sticky-header-h, 0px);
z-index: 11;
margin: 0 0 22px;
border: 1px solid rgba(127,127,127,0.18);
border-radius: 24px;
background:
linear-gradient(180deg, rgba(0,0,0,0.60), rgba(0,0,0,0.92)),
radial-gradient(900px 240px at 20% 0%, rgba(0,217,255,0.08), transparent 60%);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
overflow: hidden;
transition:
border-radius 180ms ease,
box-shadow 180ms ease,
border-color 180ms ease;
}
.glossary-entry-head__title{
padding:
var(--entry-hero-pad-top, 18px)
var(--entry-hero-pad-x, 18px)
calc(var(--entry-hero-pad-top, 18px) - 2px);
transition: padding 180ms ease;
}
.glossary-entry-head h1{
margin: 0;
font-size: var(--entry-hero-h1-size, clamp(2.2rem, 4vw, 3.15rem));
line-height: 1.02;
letter-spacing: -.04em;
font-weight: 850;
transition: font-size 180ms ease;
}
.glossary-entry-summary{
display: grid;
gap: var(--entry-hero-gap, 14px);
padding:
calc(var(--entry-hero-pad-bottom, 18px) - 2px)
var(--entry-hero-pad-x, 18px)
var(--entry-hero-pad-bottom, 18px);
border-top: 1px solid rgba(127,127,127,0.14);
background: rgba(255,255,255,0.02);
transition: gap 180ms ease, padding 180ms ease;
}
.glossary-entry-dek{
margin: 0;
max-width: var(--entry-hero-dek-maxw, 76ch);
font-size: var(--entry-hero-dek-size, 1.04rem);
line-height: var(--entry-hero-dek-lh, 1.55);
opacity: .94;
transition:
max-width 180ms ease,
font-size 180ms ease,
line-height 180ms ease;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 4;
overflow: hidden;
}
.glossary-entry-signals{
display: flex;
flex-wrap: wrap;
gap: 7px;
margin: 0;
transition: gap 180ms ease;
}
.glossary-pill{
display: inline-flex;
align-items: center;
gap: 5px;
padding: 5px 9px;
border: 1px solid rgba(127,127,127,0.24);
border-radius: 999px;
background: rgba(127,127,127,0.05);
font-size: 12.5px;
line-height: 1.28;
transition:
padding 180ms ease,
font-size 180ms ease,
background 120ms ease,
border-color 120ms ease;
}
.glossary-pill--family{
border-color: rgba(127,127,127,0.36);
font-weight: 700;
}
.glossary-entry-meta{
margin: 0;
padding: 10px 12px;
border: 1px solid rgba(127,127,127,0.18);
border-radius: 12px;
background: rgba(127,127,127,0.04);
max-height: var(--entry-hero-meta-max-h, 12rem);
opacity: var(--entry-hero-meta-opacity, 1);
overflow: hidden;
transition:
max-height 180ms ease,
opacity 140ms ease,
padding 180ms ease,
border-color 180ms ease;
}
.glossary-entry-meta p{
margin: 0;
font-size: 13.5px;
line-height: 1.45;
}
.glossary-entry-meta p + p{
margin-top: 6px;
}
@media (max-width: 860px){
.glossary-entry-head{
position: static;
border-radius: 18px;
margin-bottom: 16px;
}
.glossary-entry-head__title{
padding: 12px 12px 10px;
}
.glossary-entry-summary{
gap: 9px;
padding: 10px 12px 12px;
}
.glossary-entry-dek{
max-width: none;
-webkit-line-clamp: 3;
}
.glossary-entry-signals{
gap: 6px;
}
.glossary-pill{
font-size: 12px;
padding: 4px 8px;
}
}
@media (max-width: 520px){
.glossary-entry-head{
border-radius: 16px;
margin-bottom: 14px;
}
.glossary-entry-head__title{
padding: 10px 10px 9px;
}
.glossary-entry-summary{
gap: 9px;
padding: 9px 10px 11px;
}
.glossary-entry-dek{
display: block;
max-width: none;
overflow: visible;
-webkit-line-clamp: unset;
-webkit-box-orient: unset;
}
.glossary-pill{
font-size: 11.5px;
padding: 3px 7px;
}
}
@media (prefers-color-scheme: dark){
.glossary-entry-meta{
background: rgba(255,255,255,0.03);
}
.glossary-pill{
background: rgba(255,255,255,0.04);
}
}
</style>

View File

@@ -1,31 +0,0 @@
---
interface Props {
canonicalHref: string;
term: string;
}
const { canonicalHref, term } = Astro.props;
---
<p class="glossary-legacy-note">
Cette entrée a été renommée. Lintitulé canonique est :
<a href={canonicalHref}>{term}</a>.
</p>
<style>
.glossary-legacy-note{
padding: 10px 12px;
border: 1px solid rgba(127,127,127,0.22);
border-radius: 12px;
background: rgba(127,127,127,0.05);
font-size: 14px;
line-height: 1.45;
margin-bottom: 18px;
}
@media (prefers-color-scheme: dark){
.glossary-legacy-note{
background: rgba(255,255,255,0.04);
}
}
</style>

View File

@@ -1,291 +0,0 @@
<script is:inline>
(() => {
const boot = () => {
const body = document.body;
const root = document.documentElement;
const hero = document.querySelector("[data-ge-hero]");
const follow = document.getElementById("reading-follow");
const mqMobile = window.matchMedia("(max-width: 860px)");
const mqSmallLandscape = window.matchMedia(
"(orientation: landscape) and (max-width: 920px) and (max-height: 520px)"
);
if (!body || !root || !hero || !follow) return;
const BODY_CLASS = "is-glossary-entry-page";
const FOLLOW_ON_CLASS = "glossary-entry-follow-on";
let lastHeight = -1;
let lastFollowOn = null;
let raf = 0;
body.classList.add(BODY_CLASS);
const isCompactViewport = () =>
mqMobile.matches || mqSmallLandscape.matches;
const heroHeight = () => {
const rect = hero.getBoundingClientRect();
return Math.max(0, Math.round(rect.height || 0));
};
const neutralizeGlobalFollowIfCompact = () => {
if (!isCompactViewport()) {
follow.style.display = "";
return;
}
follow.classList.remove("is-on");
follow.setAttribute("aria-hidden", "true");
follow.style.display = "none";
root.style.setProperty("--followbar-h", "0px");
};
const computeFollowOn = () =>
!isCompactViewport() &&
follow.classList.contains("is-on") &&
follow.style.display !== "none" &&
follow.getAttribute("aria-hidden") !== "true";
const syncFollowState = () => {
const on = computeFollowOn();
if (on) {
if (lastFollowOn === true) return;
lastFollowOn = true;
body.classList.add(FOLLOW_ON_CLASS);
return;
}
if (lastFollowOn === false) return;
lastFollowOn = false;
body.classList.remove(FOLLOW_ON_CLASS);
};
const stripLocalSticky = () => {
document
.querySelectorAll(
".glossary-entry-body h2, .glossary-entry-body h3, .glossary-relations h2, .glossary-relations h3"
)
.forEach((el) => {
el.classList.remove("is-sticky");
el.removeAttribute("data-sticky-active");
});
};
const applyLocalStickyHeight = () => {
const h = isCompactViewport() ? 0 : heroHeight();
if (h === lastHeight) return;
lastHeight = h;
if (typeof window.__archiSetLocalStickyHeight === "function") {
window.__archiSetLocalStickyHeight(h);
} else {
root.style.setProperty("--glossary-local-sticky-h", `${h}px`);
}
};
const syncAll = () => {
neutralizeGlobalFollowIfCompact();
stripLocalSticky();
syncFollowState();
applyLocalStickyHeight();
};
const schedule = () => {
if (raf) return;
raf = requestAnimationFrame(() => {
raf = 0;
syncAll();
});
};
const followObserver = new MutationObserver(schedule);
followObserver.observe(follow, {
attributes: true,
attributeFilter: ["class", "style", "aria-hidden"],
subtree: false,
});
const heroResizeObserver =
typeof ResizeObserver !== "undefined"
? new ResizeObserver(schedule)
: null;
heroResizeObserver?.observe(hero);
window.addEventListener("resize", schedule);
window.addEventListener("pageshow", schedule);
if (document.fonts?.ready) {
document.fonts.ready.then(schedule).catch(() => {});
}
if (mqMobile.addEventListener) {
mqMobile.addEventListener("change", schedule);
} else if (mqMobile.addListener) {
mqMobile.addListener(schedule);
}
if (mqSmallLandscape.addEventListener) {
mqSmallLandscape.addEventListener("change", schedule);
} else if (mqSmallLandscape.addListener) {
mqSmallLandscape.addListener(schedule);
}
schedule();
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot, { once: true });
} else {
boot();
}
})();
</script>
<style>
:global(body.is-glossary-entry-page #reading-follow){
z-index: 10;
}
:global(body.is-glossary-entry-page.glossary-entry-follow-on .glossary-entry-head h1){
letter-spacing: -.03em;
}
:global(body.is-glossary-entry-page.glossary-entry-follow-on .glossary-entry-summary){
gap: 8px;
padding-top: 10px;
padding-bottom: 8px;
border-top-color: rgba(127,127,127,0.10);
}
:global(body.is-glossary-entry-page.glossary-entry-follow-on .glossary-entry-dek){
display: block;
-webkit-line-clamp: unset;
overflow: visible;
}
:global(body.is-glossary-entry-page.glossary-entry-follow-on .glossary-entry-signals){
gap: 5px;
}
:global(body.is-glossary-entry-page.glossary-entry-follow-on .glossary-pill){
gap: 4px;
padding: 3px 7px;
font-size: 11px;
}
:global(body.is-glossary-entry-page.glossary-entry-follow-on .glossary-entry-meta){
padding: 0;
border-color: transparent;
max-height: 0;
opacity: 0;
overflow: hidden;
}
:global(body.is-glossary-entry-page.glossary-entry-follow-on #reading-follow){
transform: none;
}
:global(body.is-glossary-entry-page.glossary-entry-follow-on #reading-follow .reading-follow__inner){
margin-top: 0;
border-top-left-radius: 0;
border-top-right-radius: 0;
}
:global(body.is-glossary-entry-page .glossary-entry-body h2.is-sticky),
:global(body.is-glossary-entry-page .glossary-entry-body h2[data-sticky-active="true"]),
:global(body.is-glossary-entry-page .glossary-entry-body h3.is-sticky),
:global(body.is-glossary-entry-page .glossary-entry-body h3[data-sticky-active="true"]),
:global(body.is-glossary-entry-page .glossary-relations h2.is-sticky),
:global(body.is-glossary-entry-page .glossary-relations h2[data-sticky-active="true"]),
:global(body.is-glossary-entry-page .glossary-relations h3.is-sticky),
:global(body.is-glossary-entry-page .glossary-relations h3[data-sticky-active="true"]){
position: static !important;
top: auto !important;
z-index: auto !important;
padding: 0 !important;
border: 0 !important;
background: transparent !important;
box-shadow: none !important;
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
}
@media (max-width: 860px){
:global(body.is-glossary-entry-page #reading-follow),
:global(body.is-glossary-entry-page #reading-follow .reading-follow__inner){
display: none !important;
opacity: 0 !important;
pointer-events: none !important;
visibility: hidden !important;
}
:global(body.is-glossary-entry-page){
--followbar-h: 0px !important;
--sticky-offset-px: calc(var(--sticky-header-h, 0px) + var(--page-gap, 12px)) !important;
}
:global(body.is-glossary-entry-page.glossary-entry-follow-on .glossary-entry-head){
margin-bottom: 18px;
border-radius: 20px;
box-shadow: none;
}
:global(body.is-glossary-entry-page.glossary-entry-follow-on .glossary-entry-summary){
gap: 6px;
padding-top: 8px;
padding-bottom: 8px;
}
:global(body.is-glossary-entry-page.glossary-entry-follow-on .glossary-entry-dek){
display: block;
}
:global(body.is-glossary-entry-page.glossary-entry-follow-on .glossary-entry-signals){
gap: 5px;
}
:global(body.is-glossary-entry-page.glossary-entry-follow-on .glossary-pill){
padding: 3px 6px;
font-size: 10.5px;
}
}
@media (orientation: landscape) and (max-width: 920px) and (max-height: 520px){
:global(body.is-glossary-entry-page #reading-follow),
:global(body.is-glossary-entry-page #reading-follow .reading-follow__inner){
display: none !important;
opacity: 0 !important;
pointer-events: none !important;
visibility: hidden !important;
}
:global(body.is-glossary-entry-page){
--followbar-h: 0px !important;
--sticky-offset-px: calc(var(--sticky-header-h, 0px) + var(--page-gap, 12px)) !important;
}
:global(body.is-glossary-entry-page.glossary-entry-follow-on .glossary-entry-head){
margin-bottom: 14px;
border-radius: 16px;
box-shadow: none;
}
:global(body.is-glossary-entry-page.glossary-entry-follow-on .glossary-entry-summary){
gap: 5px;
padding-top: 6px;
padding-bottom: 6px;
}
:global(body.is-glossary-entry-page.glossary-entry-follow-on .glossary-entry-dek){
display: none;
}
:global(body.is-glossary-entry-page.glossary-entry-follow-on .glossary-pill){
padding: 2px 6px;
font-size: 10px;
}
}
</style>

View File

@@ -1,382 +0,0 @@
---
import {
getFondamentaux,
getGlossaryHomeStats,
getGlossaryPortalLinks,
hrefOfGlossaryEntry,
} from "../lib/glossary";
const {
allEntries = [],
} = Astro.props;
const fondamentaux = getFondamentaux(allEntries);
const portalLinks = getGlossaryPortalLinks();
const {
totalEntries,
paradigmesCount,
doctrinesCount,
metaRegimesCount,
} = getGlossaryHomeStats(allEntries);
---
<nav class="glossary-home-aside" aria-label="Navigation du portail du glossaire">
<div class="glossary-home-aside__block glossary-home-aside__block--intro">
<div class="glossary-home-aside__title">Glossaire archicratique</div>
<div class="glossary-home-aside__meta">
portail de lecture · cartographie conceptuelle
</div>
<div class="glossary-home-aside__pills" aria-label="Repères de navigation">
<span class="glossary-home-aside__pill">{totalEntries} entrées</span>
<span class="glossary-home-aside__pill">{metaRegimesCount} méta-régimes</span>
<span class="glossary-home-aside__pill">
{doctrinesCount} doctrine{doctrinesCount > 1 ? "s" : ""} · {paradigmesCount} paradigme{paradigmesCount > 1 ? "s" : ""}
</span>
</div>
</div>
<details class="glossary-home-aside__block glossary-home-aside__disclosure" open>
<summary class="glossary-home-aside__summary">
<span class="glossary-home-aside__heading">Parcours du glossaire</span>
<span class="glossary-home-aside__chevron" aria-hidden="true">▾</span>
</summary>
<div class="glossary-home-aside__panel">
<ul class="glossary-home-aside__list">
{portalLinks.map((item) => (
<li><a href={item.href}>{item.label}</a></li>
))}
</ul>
</div>
</details>
{fondamentaux.length > 0 && (
<details class="glossary-home-aside__block glossary-home-aside__disclosure" open>
<summary class="glossary-home-aside__summary">
<span class="glossary-home-aside__heading">Noyau archicratique</span>
<span class="glossary-home-aside__chevron" aria-hidden="true">▾</span>
</summary>
<div class="glossary-home-aside__panel">
<ul class="glossary-home-aside__list">
{fondamentaux.map((entry) => (
<li><a href={hrefOfGlossaryEntry(entry)}>{entry.data.term}</a></li>
))}
</ul>
</div>
</details>
)}
</nav>
<style>
.glossary-home-aside{
display: flex;
flex-direction: column;
gap: 14px;
min-width: 0;
}
.glossary-home-aside__block{
border: 1px solid rgba(127,127,127,0.22);
border-radius: 16px;
padding: 14px;
background: rgba(127,127,127,0.05);
min-width: 0;
}
.glossary-home-aside__block--intro{
padding-top: 13px;
padding-bottom: 13px;
}
.glossary-home-aside__title{
font-size: 18px;
font-weight: 850;
letter-spacing: .1px;
line-height: 1.22;
}
.glossary-home-aside__meta{
margin-top: 8px;
font-size: 13px;
line-height: 1.4;
opacity: .8;
}
.glossary-home-aside__pills{
display: flex;
flex-wrap: wrap;
gap: 7px;
margin-top: 11px;
}
.glossary-home-aside__pill{
display: inline-flex;
align-items: center;
padding: 5px 10px;
border: 1px solid rgba(127,127,127,0.24);
border-radius: 999px;
background: rgba(127,127,127,0.04);
font-size: 13px;
line-height: 1.35;
opacity: .92;
min-width: 0;
}
.glossary-home-aside__disclosure{
padding: 0;
overflow: hidden;
}
.glossary-home-aside__summary{
list-style: none;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px;
cursor: pointer;
user-select: none;
}
.glossary-home-aside__summary::-webkit-details-marker{
display: none;
}
.glossary-home-aside__summary:hover{
background: rgba(127,127,127,0.035);
}
.glossary-home-aside__heading{
margin: 0;
font-size: 16px;
font-weight: 850;
line-height: 1.28;
opacity: .97;
}
.glossary-home-aside__chevron{
flex: 0 0 auto;
font-size: 14px;
line-height: 1;
opacity: .72;
transform: rotate(0deg);
transition: transform 160ms ease, opacity 160ms ease;
}
.glossary-home-aside__disclosure[open] .glossary-home-aside__chevron{
transform: rotate(180deg);
opacity: .96;
}
.glossary-home-aside__panel{
padding: 0 14px 14px;
}
.glossary-home-aside__list{
list-style: none;
margin: 0;
padding: 0;
}
.glossary-home-aside__list li{
margin: 7px 0;
}
.glossary-home-aside__list a{
text-decoration: none;
font-size: 14px;
line-height: 1.42;
word-break: break-word;
}
@media (max-width: 860px){
.glossary-home-aside{
gap: 10px;
}
.glossary-home-aside__block{
border-radius: 14px;
}
.glossary-home-aside__block--intro{
padding: 12px;
}
.glossary-home-aside__title{
font-size: 19px;
line-height: 1.18;
}
.glossary-home-aside__meta{
margin-top: 6px;
font-size: 12px;
line-height: 1.32;
}
.glossary-home-aside__pills{
gap: 6px;
margin-top: 9px;
}
.glossary-home-aside__pill{
padding: 4px 9px;
font-size: 12px;
line-height: 1.28;
}
.glossary-home-aside__summary{
padding: 12px;
}
.glossary-home-aside__heading{
font-size: 17px;
line-height: 1.2;
}
.glossary-home-aside__panel{
padding: 0 12px 12px;
}
.glossary-home-aside__list li{
margin: 5px 0;
}
.glossary-home-aside__list a{
font-size: 14px;
line-height: 1.34;
}
.glossary-home-aside__disclosure:not([open]) .glossary-home-aside__panel{
display: none;
}
}
@media (max-width: 860px){
.glossary-home-aside__disclosure{
background: rgba(127,127,127,0.045);
}
.glossary-home-aside__disclosure[open] .glossary-home-aside__summary{
border-bottom: 1px solid rgba(127,127,127,0.12);
}
}
@media (orientation: landscape) and (max-width: 920px) and (max-height: 520px){
.glossary-home-aside{
gap: 8px;
}
.glossary-home-aside__block{
border-radius: 12px;
}
.glossary-home-aside__block--intro{
padding: 10px 11px;
}
.glossary-home-aside__title{
font-size: 16px;
line-height: 1.14;
}
.glossary-home-aside__meta{
font-size: 11px;
line-height: 1.26;
margin-top: 5px;
}
.glossary-home-aside__pills{
gap: 5px;
margin-top: 8px;
}
.glossary-home-aside__pill{
padding: 3px 8px;
font-size: 11px;
line-height: 1.2;
}
.glossary-home-aside__summary{
padding: 10px 11px;
}
.glossary-home-aside__heading{
font-size: 15px;
line-height: 1.16;
}
.glossary-home-aside__panel{
padding: 0 11px 10px;
}
.glossary-home-aside__list li{
margin: 4px 0;
}
.glossary-home-aside__list a{
font-size: 13px;
line-height: 1.28;
}
}
@media (min-width: 861px){
.glossary-home-aside__summary{
cursor: default;
}
.glossary-home-aside__chevron{
display: none;
}
}
@media (prefers-color-scheme: dark){
.glossary-home-aside__block,
.glossary-home-aside__pill{
background: rgba(255,255,255,0.04);
}
.glossary-home-aside__summary:hover{
background: rgba(255,255,255,0.03);
}
}
</style>
<script is:inline>
(() => {
const syncMobileDisclosure = () => {
const mobile = window.matchMedia("(max-width: 860px)").matches;
const smallLandscape = window.matchMedia(
"(orientation: landscape) and (max-width: 920px) and (max-height: 520px)"
).matches;
const compact = mobile || smallLandscape;
document
.querySelectorAll(".glossary-home-aside__disclosure")
.forEach((el, index) => {
if (!(el instanceof HTMLDetailsElement)) return;
if (compact) {
if (!el.dataset.mobileInit) {
el.open = index === 0;
el.dataset.mobileInit = "true";
}
} else {
el.open = true;
}
});
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", syncMobileDisclosure, { once: true });
} else {
syncMobileDisclosure();
}
window.addEventListener("resize", syncMobileDisclosure);
window.addEventListener("pageshow", syncMobileDisclosure);
})();
</script>

View File

@@ -1,460 +0,0 @@
---
export interface Props {
kicker?: string;
title?: string;
intro?: string;
}
const {
kicker = "Référentiel terminologique",
title = "Glossaire archicratique",
intro = "Ce glossaire nest pas seulement un index de définitions. Il constitue une porte dentrée dans la pensée archicratique : une cartographie raisonnée des concepts fondamentaux, des scènes, des dynamiques et des méta-régimes à partir desquels une société peut être décrite comme organisation de tensions et recherche de co-viabilité.",
} = Astro.props;
---
<header class="glossary-hero" id="glossary-hero">
<p class="glossary-kicker">{kicker}</p>
<h1>{title}</h1>
<div class="glossary-hero__collapsible">
<p
class="glossary-intro"
id="glossary-hero-intro"
aria-hidden="false"
>
{intro}
</p>
<button
class="glossary-hero__toggle"
id="glossary-hero-toggle"
type="button"
aria-controls="glossary-hero-intro"
aria-expanded="false"
hidden
>
lire la suite
</button>
</div>
<h2
class="glossary-hero-follow"
id="glossary-hero-follow"
aria-hidden="true"
></h2>
</header>
<style>
.glossary-hero{
position: sticky;
top: var(--glossary-sticky-top);
z-index: 12;
margin-bottom: 28px;
padding: 14px 16px 18px;
border: 1px solid rgba(127,127,127,0.18);
border-radius: 28px;
background:
linear-gradient(180deg, rgba(0,0,0,0.60), rgba(0,0,0,0.90)),
radial-gradient(900px 240px at 20% 0%, rgba(0,217,255,0.08), transparent 60%);
transition:
padding 220ms cubic-bezier(.22,.8,.22,1),
border-radius 220ms cubic-bezier(.22,.8,.22,1),
background 300ms cubic-bezier(.22,.8,.22,1),
border-color 300ms cubic-bezier(.22,.8,.22,1),
box-shadow 300ms cubic-bezier(.22,.8,.22,1);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
display: grid;
row-gap: 12px;
min-width: 0;
overflow: clip;
}
.glossary-kicker{
margin: 0;
font-size: 12px;
letter-spacing: .12em;
text-transform: uppercase;
opacity: .72;
}
.glossary-hero h1{
margin: 0;
font-size: clamp(2.2rem, 4vw, 3.15rem);
line-height: 1.02;
letter-spacing: -.04em;
font-weight: 850;
transition:
font-size 220ms cubic-bezier(.22,.8,.22,1),
line-height 220ms cubic-bezier(.22,.8,.22,1);
min-width: 0;
}
.glossary-hero__collapsible{
display: grid;
row-gap: 6px;
min-width: 0;
}
.glossary-intro{
margin: 0;
max-width: 72ch;
font-size: 1.05rem;
line-height: 1.55;
opacity: .94;
min-width: 0;
transition:
font-size 220ms cubic-bezier(.22,.8,.22,1),
line-height 220ms cubic-bezier(.22,.8,.22,1),
max-height 220ms cubic-bezier(.22,.8,.22,1),
opacity 180ms ease;
}
:global(body[data-edition-key="glossaire"] .glossary-hero p#glossary-hero-intro){
padding-right: 0;
scroll-margin-top: 0;
}
.glossary-hero__toggle{
display: inline-flex;
align-items: center;
justify-content: center;
width: fit-content;
min-height: 30px;
padding: 3px 0;
border: 0;
border-radius: 0;
background: transparent;
color: inherit;
font-size: 12px;
line-height: 1.2;
letter-spacing: .01em;
opacity: .72;
cursor: pointer;
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 2px;
transition:
opacity 120ms ease,
transform 120ms ease;
}
.glossary-hero__toggle:hover{
opacity: .92;
transform: translateY(-1px);
}
.glossary-hero__toggle:focus-visible{
outline: 2px solid rgba(0,217,255,0.24);
outline-offset: 4px;
border-radius: 4px;
}
.glossary-hero__toggle[hidden]{
display: none !important;
}
.glossary-hero-follow{
margin: 2px 0 0;
min-height: var(--glossary-follow-height);
display: block;
max-width: min(100%, 22ch);
opacity: 0;
transform: translateY(10px) scale(.985);
filter: blur(6px);
transition:
opacity 220ms cubic-bezier(.22,1,.36,1),
transform 320ms cubic-bezier(.22,1,.36,1),
filter 320ms cubic-bezier(.22,1,.36,1);
pointer-events: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
will-change: opacity, transform, filter;
min-width: 0;
}
.glossary-hero-follow.is-visible{
opacity: 1;
transform: translateY(0) scale(1);
filter: blur(0);
}
:global(body.glossary-home-follow-on) .glossary-hero{
padding: 12px 14px 14px;
border-bottom-left-radius: 18px;
border-bottom-right-radius: 18px;
}
:global(body.glossary-home-follow-on) .glossary-hero h1{
font-size: clamp(1.7rem, 3.2vw, 2.2rem);
line-height: 1.02;
}
:global(body.glossary-home-follow-on:not(.glossary-home-hero-expanded)) .glossary-intro{
font-size: .94rem;
line-height: 1.34;
max-height: 2.7em;
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
:global(body.glossary-home-follow-on:not(.glossary-home-hero-expanded)) .glossary-hero__toggle{
display: inline-flex;
}
@media (max-width: 760px){
.glossary-hero{
top: calc(var(--glossary-sticky-top) - 2px);
padding: 12px 14px 16px;
border-radius: 22px;
row-gap: 10px;
}
.glossary-hero h1{
font-size: clamp(1.9rem, 8vw, 2.45rem);
line-height: 1.02;
letter-spacing: -.03em;
}
.glossary-hero__collapsible{
row-gap: 7px;
}
.glossary-intro{
max-width: 100%;
width: 100%;
font-size: .98rem;
line-height: 1.44;
}
:global(body.glossary-home-follow-on) .glossary-hero{
padding: 10px 13px 12px;
border-radius: 18px;
}
:global(body.glossary-home-follow-on) .glossary-hero h1{
font-size: clamp(1.45rem, 6vw, 1.8rem);
}
:global(body.glossary-home-follow-on:not(.glossary-home-hero-expanded)) .glossary-intro{
max-width: 100%;
width: 100%;
font-size: .86rem;
line-height: 1.24;
max-height: 2.48em;
-webkit-line-clamp: 2;
opacity: .9;
}
.glossary-hero__toggle{
min-height: 28px;
font-size: 11.5px;
}
.glossary-hero-follow{
max-width: min(100%, 24ch);
}
}
@media (max-width: 520px){
.glossary-hero{
padding: 11px 12px 14px;
border-radius: 20px;
}
.glossary-intro{
max-width: 100%;
width: 100%;
font-size: .94rem;
line-height: 1.4;
}
:global(body.glossary-home-follow-on) .glossary-hero{
padding: 9px 11px 11px;
}
:global(body.glossary-home-follow-on:not(.glossary-home-hero-expanded)) .glossary-intro{
max-width: 100%;
width: 100%;
font-size: .84rem;
line-height: 1.22;
}
}
@media (orientation: landscape) and (max-width: 920px) and (max-height: 520px){
.glossary-hero{
padding: 10px 12px 12px;
border-radius: 16px;
row-gap: 8px;
}
.glossary-kicker{
font-size: 10px;
letter-spacing: .1em;
}
.glossary-hero h1{
font-size: clamp(1.35rem, 4vw, 1.8rem);
line-height: 1;
}
.glossary-intro{
font-size: .84rem;
line-height: 1.24;
}
:global(body.glossary-home-follow-on) .glossary-hero{
padding: 9px 11px 10px;
border-radius: 16px;
}
:global(body.glossary-home-follow-on) .glossary-hero h1{
font-size: clamp(1.1rem, 3vw, 1.35rem);
}
:global(body.glossary-home-follow-on:not(.glossary-home-hero-expanded)) .glossary-intro{
font-size: .8rem;
line-height: 1.18;
max-height: 2.36em;
-webkit-line-clamp: 2;
opacity: .88;
}
.glossary-hero__toggle{
min-height: 24px;
font-size: 11px;
}
.glossary-hero-follow{
max-width: min(100%, 26ch);
}
}
@media (max-width: 860px){
.glossary-hero{
position: static !important;
top: auto !important;
z-index: auto !important;
margin-bottom: 18px !important;
}
.glossary-hero-follow{
display: none !important;
min-height: 0 !important;
opacity: 0 !important;
transform: none !important;
filter: none !important;
}
}
@media (orientation: landscape) and (max-width: 920px) and (max-height: 520px){
.glossary-hero{
position: static !important;
top: auto !important;
z-index: auto !important;
margin-bottom: 14px !important;
}
.glossary-hero-follow{
display: none !important;
min-height: 0 !important;
opacity: 0 !important;
transform: none !important;
filter: none !important;
}
}
/* Neutralisation mobile/tablette : le hero n'est plus sticky, donc aucun état condensé. */
@media (max-width: 860px){
.glossary-hero{
position: static !important;
top: auto !important;
z-index: auto !important;
margin-bottom: 18px !important;
padding: 12px 14px 16px !important;
border-radius: 22px !important;
row-gap: 8px !important;
}
.glossary-hero h1,
:global(body.glossary-home-follow-on) .glossary-hero h1{
font-size: clamp(2rem, 6.2vw, 2.75rem) !important;
line-height: 1.04 !important;
letter-spacing: -.035em !important;
max-width: 100%;
overflow-wrap: normal;
word-break: normal;
hyphens: none;
text-wrap: balance;
}
.glossary-intro,
:global(body.glossary-home-follow-on:not(.glossary-home-hero-expanded)) .glossary-intro{
max-width: 100% !important;
width: 100% !important;
max-height: none !important;
overflow: visible !important;
display: block !important;
-webkit-line-clamp: unset !important;
-webkit-box-orient: unset !important;
font-size: .94rem !important;
line-height: 1.4 !important;
opacity: .94 !important;
padding-right: 0 !important;
scroll-margin-top: 0 !important;
}
.glossary-hero__toggle,
.glossary-hero-follow{
display: none !important;
}
}
/* Mobile paysage compact : même logique, mais plus dense. */
@media (orientation: landscape) and (max-width: 920px) and (max-height: 520px){
.glossary-hero{
padding: 8px 10px 9px !important;
border-radius: 14px !important;
row-gap: 5px !important;
margin-bottom: 10px !important;
}
.glossary-kicker{
font-size: 9px !important;
letter-spacing: .11em !important;
}
.glossary-hero h1,
:global(body.glossary-home-follow-on) .glossary-hero h1{
font-size: clamp(1.55rem, 4.2vw, 1.9rem) !important;
line-height: 1.03 !important;
letter-spacing: -.025em !important;
}
.glossary-intro,
:global(body.glossary-home-follow-on:not(.glossary-home-hero-expanded)) .glossary-intro{
font-size: .72rem !important;
line-height: 1.18 !important;
}
}
/* Tablette large / iPad landscape : le follow reste lisible, jamais tronqué brutalement. */
@media (min-width: 861px) and (max-width: 1240px){
.glossary-hero h1{
font-size: clamp(2.35rem, 4.2vw, 3.05rem) !important;
line-height: 1.03 !important;
}
.glossary-hero-follow{
max-width: 100% !important;
white-space: normal !important;
overflow: visible !important;
text-overflow: clip !important;
font-size: clamp(1.55rem, 3.1vw, 2.05rem) !important;
line-height: 1.08 !important;
}
}
</style>

View File

@@ -1,133 +0,0 @@
---
export interface Props {
id?: string;
title: string;
intro?: string;
followSection?: string;
ctaHref?: string;
ctaLabel?: string;
}
const {
id,
title,
intro,
followSection,
ctaHref,
ctaLabel,
} = Astro.props;
const resolvedFollowSection = (followSection || title || "").trim();
const showCta = Boolean(ctaHref && ctaLabel);
---
<section id={id} class="glossary-section">
<div class="glossary-section__head">
<div>
<h2 data-follow-section={resolvedFollowSection}>{title}</h2>
{intro && (
<p class="glossary-intro">{intro}</p>
)}
</div>
{showCta && (
<a class="glossary-cta" href={ctaHref}>
{ctaLabel}
</a>
)}
</div>
<slot />
</section>
<style>
.glossary-section{
margin-top: 34px;
scroll-margin-top: calc(var(--glossary-sticky-top) + 150px);
}
.glossary-section__head{
display: flex;
justify-content: space-between;
align-items: start;
gap: 14px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.glossary-section h2{
margin: 0;
font-size: clamp(1.8rem, 3vw, 2.55rem);
line-height: 1.06;
letter-spacing: -.03em;
font-weight: 800;
}
.glossary-intro{
margin: 0;
max-width: 72ch;
font-size: 1rem;
line-height: 1.52;
opacity: .94;
}
.glossary-section__head .glossary-intro{
margin-top: 8px;
}
.glossary-cta{
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 38px;
border: 1px solid var(--glossary-border-strong);
border-radius: 999px;
padding: 6px 13px;
color: var(--glossary-accent);
text-decoration: none;
white-space: nowrap;
transition: transform 120ms ease, background 120ms ease;
}
.glossary-cta:hover{
background: var(--glossary-bg-soft-strong);
text-decoration: none;
transform: translateY(-1px);
}
@media (max-width: 760px){
.glossary-section{
margin-top: 24px;
scroll-margin-top: calc(var(--glossary-sticky-top) + 110px);
}
.glossary-section__head{
flex-direction: column;
align-items: stretch;
gap: 10px;
margin-bottom: 10px;
}
.glossary-section h2{
font-size: clamp(1.45rem, 6vw, 1.95rem);
line-height: 1.05;
}
.glossary-intro{
font-size: .95rem;
line-height: 1.42;
}
.glossary-section__head .glossary-intro{
margin-top: 6px;
}
.glossary-cta{
width: fit-content;
min-height: 35px;
padding: 5px 12px;
font-size: .95rem;
}
}
</style>

View File

@@ -1,286 +0,0 @@
---
interface LinkItem {
href: string;
label: string;
}
interface Props {
ariaLabel: string;
title: string;
meta?: string;
backHref?: string;
backLabel?: string;
pageItems?: LinkItem[];
usefulLinks?: LinkItem[];
}
const {
ariaLabel,
title,
meta,
backHref = "/glossaire/",
backLabel = "← Retour au glossaire",
pageItems = [],
usefulLinks = [],
} = Astro.props;
---
<nav class="glossary-portal-aside" aria-label={ariaLabel}>
<div class="glossary-portal-aside__block">
<a class="glossary-portal-aside__back" href={backHref}>{backLabel}</a>
<div class="glossary-portal-aside__title">{title}</div>
{meta && <div class="glossary-portal-aside__meta">{meta}</div>}
</div>
{pageItems.length > 0 && (
<details class="glossary-portal-aside__block glossary-portal-aside__disclosure">
<summary class="glossary-portal-aside__summary">
<span class="glossary-portal-aside__heading">Dans cette page</span>
<span class="glossary-portal-aside__chevron" aria-hidden="true">▾</span>
</summary>
<div class="glossary-portal-aside__panel">
<ul class="glossary-portal-aside__list">
{pageItems.map((item) => (
<li><a href={item.href}>{item.label}</a></li>
))}
</ul>
</div>
</details>
)}
{usefulLinks.length > 0 && (
<details class="glossary-portal-aside__block glossary-portal-aside__disclosure">
<summary class="glossary-portal-aside__summary">
<span class="glossary-portal-aside__heading">Renvois utiles</span>
<span class="glossary-portal-aside__chevron" aria-hidden="true">▾</span>
</summary>
<div class="glossary-portal-aside__panel">
<ul class="glossary-portal-aside__list">
{usefulLinks.map((item) => (
<li><a href={item.href}>{item.label}</a></li>
))}
</ul>
</div>
</details>
)}
</nav>
<style>
.glossary-portal-aside{
display: flex;
flex-direction: column;
gap: 14px;
}
.glossary-portal-aside__block{
border: 1px solid rgba(127,127,127,0.22);
border-radius: 16px;
padding: 14px;
background: rgba(127,127,127,0.05);
}
.glossary-portal-aside__back{
display: inline-block;
margin-bottom: 10px;
font-size: 14px;
font-weight: 700;
line-height: 1.35;
text-decoration: none;
}
.glossary-portal-aside__title{
font-size: 16px;
font-weight: 800;
letter-spacing: .2px;
line-height: 1.3;
}
.glossary-portal-aside__meta{
margin-top: 8px;
font-size: 13px;
line-height: 1.4;
opacity: .8;
}
.glossary-portal-aside__heading{
margin: 0 0 11px;
font-size: 14px;
font-weight: 800;
line-height: 1.35;
opacity: .94;
}
.glossary-portal-aside__list{
list-style: none;
margin: 0;
padding: 0;
}
.glossary-portal-aside__list li{
margin: 7px 0;
}
.glossary-portal-aside__list a{
text-decoration: none;
font-size: 14px;
line-height: 1.4;
}
@media (max-width: 980px){
.glossary-portal-aside{
gap: 12px;
}
.glossary-portal-aside__block{
padding: 12px;
border-radius: 14px;
}
}
@media (max-width: 760px){
.glossary-portal-aside{
gap: 10px;
}
.glossary-portal-aside__block{
padding: 11px 12px;
border-radius: 14px;
}
.glossary-portal-aside__back{
margin-bottom: 8px;
font-size: 13px;
}
.glossary-portal-aside__title{
font-size: 15px;
line-height: 1.22;
}
.glossary-portal-aside__meta{
margin-top: 6px;
font-size: 12px;
line-height: 1.32;
}
.glossary-portal-aside__heading{
margin-bottom: 8px;
font-size: 13px;
line-height: 1.22;
}
.glossary-portal-aside__list li{
margin: 5px 0;
}
.glossary-portal-aside__list a{
font-size: 12.5px;
line-height: 1.3;
}
}
@media (orientation: landscape) and (max-width: 920px) and (max-height: 520px){
.glossary-portal-aside{
gap: 8px;
}
.glossary-portal-aside__block{
padding: 9px 10px;
border-radius: 12px;
}
.glossary-portal-aside__back{
margin-bottom: 6px;
font-size: 12px;
}
.glossary-portal-aside__title{
font-size: 14px;
line-height: 1.18;
}
.glossary-portal-aside__meta{
margin-top: 4px;
font-size: 11px;
line-height: 1.24;
}
.glossary-portal-aside__heading{
margin-bottom: 6px;
font-size: 12px;
line-height: 1.18;
}
.glossary-portal-aside__list li{
margin: 4px 0;
}
.glossary-portal-aside__list a{
font-size: 11.5px;
line-height: 1.22;
}
}
@media (prefers-color-scheme: dark){
.glossary-portal-aside__block{
background: rgba(255,255,255,0.04);
}
}
.glossary-portal-aside__disclosure{
padding: 0;
overflow: hidden;
}
.glossary-portal-aside__summary{
list-style: none;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px;
cursor: pointer;
user-select: none;
}
.glossary-portal-aside__summary::-webkit-details-marker{
display: none;
}
.glossary-portal-aside__summary .glossary-portal-aside__heading{
margin: 0;
}
.glossary-portal-aside__disclosure:not([open]) .glossary-portal-aside__panel{
display: none;
}
.glossary-portal-aside__chevron{
flex: 0 0 auto;
font-size: 14px;
line-height: 1;
opacity: .72;
transition: transform 160ms ease, opacity 160ms ease;
}
.glossary-portal-aside__disclosure[open] .glossary-portal-aside__chevron{
transform: rotate(180deg);
opacity: .96;
}
.glossary-portal-aside__panel{
padding: 0 14px 14px;
}
@media (max-width: 980px){
.glossary-portal-aside__summary{
padding: 12px;
}
.glossary-portal-aside__panel{
padding: 0 12px 12px;
}
}
</style>

View File

@@ -1,67 +0,0 @@
---
export interface Props {
href: string;
label: string;
icon?: string;
className?: string;
}
const {
href,
label,
icon = "↗",
className,
} = Astro.props;
---
<a class:list={["glossary-portal-cta", className]} href={href}>
<span>{label}</span>
<span aria-hidden="true">{icon}</span>
</a>
<style>
.glossary-portal-cta{
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 40px;
padding: 7px 14px;
border: 1px solid rgba(127,127,127,0.24);
border-radius: 999px;
background: rgba(127,127,127,0.05);
text-decoration: none;
line-height: 1.2;
transition:
transform 120ms ease,
background 120ms ease,
border-color 120ms ease;
}
.glossary-portal-cta:hover{
transform: translateY(-1px);
background: rgba(127,127,127,0.08);
border-color: rgba(0,217,255,0.18);
text-decoration: none;
}
.glossary-portal-cta:focus-visible{
outline: 2px solid rgba(0,217,255,0.28);
outline-offset: 3px;
}
@media (max-width: 760px){
.glossary-portal-cta{
min-height: 36px;
padding: 6px 12px;
font-size: 12px;
}
}
@media (orientation: landscape) and (max-width: 920px) and (max-height: 520px){
.glossary-portal-cta{
min-height: 32px;
padding: 5px 10px;
font-size: 11px;
}
}
</style>

View File

@@ -1,118 +0,0 @@
---
export type GlossaryPortalGridItem = {
href: string;
title: string;
description: string;
meta: string;
};
export interface Props {
items?: GlossaryPortalGridItem[];
secondary?: boolean;
}
const {
items = [],
secondary = false,
} = Astro.props;
---
<div
class:list={[
"glossary-portals",
secondary && "glossary-portals--secondary",
]}
>
{items.map((item) => (
<a class="glossary-portal-card" href={item.href}>
<strong>{item.title}</strong>
<span>{item.description}</span>
<small>{item.meta}</small>
</a>
))}
</div>
<style>
.glossary-portals{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 12px;
margin-top: 12px;
}
.glossary-portal-card{
display: flex;
flex-direction: column;
gap: 7px;
padding: 14px 15px;
border: 1px solid var(--glossary-border);
border-radius: 16px;
background: var(--glossary-bg-soft);
text-decoration: none;
transition: transform 120ms ease, background 120ms ease, border-color 120ms ease;
}
.glossary-portal-card:hover{
transform: translateY(-1px);
background: var(--glossary-bg-soft-strong);
border-color: rgba(0,217,255,0.16);
text-decoration: none;
}
.glossary-portal-card strong{
color: var(--glossary-accent);
font-size: 1.04rem;
line-height: 1.24;
}
.glossary-portal-card span{
color: inherit;
font-size: .98rem;
line-height: 1.46;
opacity: .94;
}
.glossary-portal-card small{
color: var(--glossary-accent);
font-size: .9rem;
line-height: 1.28;
opacity: .9;
}
@media (max-width: 760px){
.glossary-portals{
grid-template-columns: 1fr;
gap: 10px;
margin-top: 10px;
}
.glossary-portal-card{
padding: 12px 12px;
border-radius: 14px;
gap: 6px;
}
.glossary-portal-card strong{
font-size: .98rem;
}
.glossary-portal-card span{
font-size: .94rem;
line-height: 1.42;
}
.glossary-portal-card small{
font-size: .85rem;
}
}
@media (prefers-color-scheme: dark){
.glossary-portal-card{
background: rgba(255,255,255,0.04);
}
.glossary-portal-card:hover{
background: rgba(255,255,255,0.07);
}
}
</style>

View File

@@ -1,260 +0,0 @@
---
interface Props {
prefix: string;
kicker: string;
title: string;
intro: string;
moreParagraphs?: string[];
introMaxWidth?: string;
followIntroMaxWidth?: string;
moreMaxHeight?: string;
}
const {
prefix,
kicker,
title,
intro,
moreParagraphs = [],
introMaxWidth = "70ch",
followIntroMaxWidth = "62ch",
moreMaxHeight = "18rem",
} = Astro.props;
---
<div
class="glossary-portal-hero glossary-page-hero"
data-glossary-portal-hero
style={`--portal-hero-intro-max-w:${introMaxWidth}; --portal-hero-follow-intro-max-w:${followIntroMaxWidth}; --portal-hero-secondary-max-h:${moreMaxHeight};`}
>
<p class="glossary-portal-hero__kicker">{kicker}</p>
<h1>{title}</h1>
<p class="glossary-portal-hero__intro glossary-portal-hero__intro--lead">
{intro}
</p>
{moreParagraphs.length > 0 && (
<div class="glossary-portal-hero__collapsible">
<div
class="glossary-portal-hero__more"
id={`${prefix}-hero-more`}
data-glossary-portal-more
aria-hidden="false"
>
{moreParagraphs.map((paragraph) => (
<p class="glossary-portal-hero__intro glossary-portal-hero__intro--more">
{paragraph}
</p>
))}
</div>
<button
class="glossary-portal-hero__toggle"
id={`${prefix}-hero-toggle`}
data-glossary-portal-toggle
type="button"
aria-controls={`${prefix}-hero-more`}
aria-expanded="false"
hidden
>
lire la suite
</button>
</div>
)}
</div>
<style>
.glossary-portal-hero{
position: sticky;
top: var(--glossary-sticky-top);
z-index: 12;
margin-bottom: var(--portal-hero-margin-bottom, 28px);
padding:
var(--portal-hero-pad-top, 20px)
var(--portal-hero-pad-x, 18px)
var(--portal-hero-pad-bottom, 22px);
border: 1px solid rgba(127,127,127,0.18);
border-radius: 28px;
background:
linear-gradient(180deg, rgba(0,0,0,0.60), rgba(0,0,0,0.92)),
radial-gradient(980px 260px at 18% 0%, rgba(0,217,255,0.08), transparent 60%);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
display: grid;
row-gap: var(--portal-hero-gap, 16px);
min-width: 0;
overflow: hidden;
transition:
background 280ms cubic-bezier(.22,.8,.22,1),
border-color 220ms cubic-bezier(.22,.8,.22,1),
box-shadow 220ms cubic-bezier(.22,.8,.22,1),
border-radius 220ms ease,
padding 220ms ease,
row-gap 220ms ease,
margin-bottom 220ms ease;
box-shadow:
inset 0 1px 0 rgba(255,255,255,0.02),
0 10px 26px rgba(0,0,0,0.08);
}
.glossary-portal-hero__kicker{
margin: 0;
font-size: var(--portal-kicker-size, 12px);
line-height: var(--portal-kicker-lh, 1.2);
letter-spacing: var(--portal-kicker-spacing, .14em);
text-transform: uppercase;
font-weight: 650;
opacity: .74;
}
.glossary-portal-hero h1{
margin: 0;
font-size: var(--portal-hero-h1-size, clamp(3rem, 4.8vw, 4.15rem));
line-height: var(--portal-hero-h1-lh, .98);
letter-spacing: var(--portal-hero-h1-spacing, -.045em);
font-weight: 850;
text-wrap: balance;
transition:
font-size 180ms ease,
line-height 180ms ease,
letter-spacing 180ms ease;
}
.glossary-portal-hero__intro{
margin: 0;
max-width: var(--portal-hero-intro-max-w, 70ch);
font-size: var(--portal-hero-intro-size, 1.06rem);
line-height: var(--portal-hero-intro-lh, 1.6);
text-wrap: pretty;
transition:
font-size 180ms ease,
line-height 180ms ease,
max-width 180ms ease,
opacity 180ms ease;
}
.glossary-portal-hero__intro--lead{ opacity: .95; }
.glossary-portal-hero__intro--more{ opacity: .89; }
.glossary-portal-hero__collapsible{
display: grid;
row-gap: 8px;
min-width: 0;
}
.glossary-portal-hero__more{
display: grid;
gap: 12px;
max-height: var(--portal-hero-secondary-max-h, 20em);
overflow: hidden;
opacity: var(--portal-hero-secondary-opacity, .92);
min-width: 0;
transition:
max-height 220ms ease,
opacity 180ms ease;
}
.glossary-portal-hero__toggle{
display: inline-flex;
align-items: center;
justify-content: center;
width: fit-content;
min-height: 34px;
padding: 5px 0;
border: 0;
border-radius: 0;
background: transparent;
color: inherit;
font-size: 12.5px;
line-height: 1.2;
letter-spacing: .01em;
opacity: .72;
cursor: pointer;
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 2px;
transition:
opacity 120ms ease,
transform 120ms ease;
}
.glossary-portal-hero__toggle:hover{
opacity: .92;
transform: translateY(-1px);
}
.glossary-portal-hero__toggle:focus-visible{
outline: 2px solid rgba(0,217,255,0.24);
outline-offset: 4px;
border-radius: 4px;
}
.glossary-portal-hero__toggle[hidden]{
display: none !important;
}
@media (max-width: 980px){
.glossary-portal-hero{
border-radius: 24px;
}
.glossary-portal-hero h1{
text-wrap: pretty;
}
.glossary-portal-hero__more{
gap: 10px;
}
}
@media (max-width: 860px){
.glossary-portal-hero{
position: static !important;
top: auto !important;
z-index: auto !important;
margin-bottom: 18px !important;
width: 100% !important;
max-width: 100% !important;
min-width: 0 !important;
}
.glossary-portal-hero h1,
.glossary-portal-hero__intro,
.glossary-portal-hero__more,
.glossary-portal-hero__collapsible{
min-width: 0 !important;
max-width: 100% !important;
}
}
@media (orientation: landscape) and (max-width: 920px) and (max-height: 520px){
.glossary-portal-hero{
position: static !important;
top: auto !important;
z-index: auto !important;
margin-bottom: 14px !important;
width: 100% !important;
max-width: 100% !important;
min-width: 0 !important;
border-radius: 16px !important;
}
.glossary-portal-hero h1,
.glossary-portal-hero__intro,
.glossary-portal-hero__more,
.glossary-portal-hero__collapsible{
min-width: 0 !important;
max-width: 100% !important;
}
}
@media (prefers-color-scheme: dark){
.glossary-portal-hero{
box-shadow:
inset 0 1px 0 rgba(255,255,255,0.02),
0 14px 34px rgba(0,0,0,0.16);
}
}
</style>

View File

@@ -1,127 +0,0 @@
---
export interface Props {
id?: string;
title: string;
count?: string;
intro?: string;
surface?: boolean;
className?: string;
}
const {
id,
title,
count,
intro,
surface = false,
className,
} = Astro.props;
---
<div
class:list={[
"glossary-portal-panel",
surface && "glossary-portal-panel--surface",
className,
]}
>
<div class="glossary-portal-panel__head">
<h3 id={id}>{title}</h3>
{count && <span class="glossary-portal-panel__count">{count}</span>}
</div>
{intro && <p class="glossary-portal-panel__intro">{intro}</p>}
<slot />
</div>
<style>
.glossary-portal-panel{
display: grid;
gap: 10px;
}
.glossary-portal-panel--surface{
padding:
var(--portal-panel-pad-y, 16px)
var(--portal-panel-pad-x, 16px);
border: 1px solid var(--glossary-border, rgba(127,127,127,0.18));
border-radius: var(--portal-panel-radius, 18px);
background:
var(--glossary-bg-soft, rgba(127,127,127,0.035));
}
.glossary-portal-panel__head{
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
}
.glossary-portal-panel__head h3{
margin: 0;
font-size: var(--portal-local-h3-size, clamp(1.35rem, 2vw, 1.7rem));
line-height: var(--portal-local-h3-lh, 1.15);
letter-spacing: -.02em;
}
.glossary-portal-panel__count{
display: inline-flex;
align-items: center;
min-height: 26px;
padding: 0 9px;
border: 1px solid rgba(127,127,127,0.20);
border-radius: 999px;
background: rgba(127,127,127,0.04);
font-size: 11.5px;
line-height: 1.2;
opacity: .8;
white-space: nowrap;
}
.glossary-portal-panel__intro{
margin: 0;
font-size: var(--portal-card-text-size, 14px);
line-height: var(--portal-card-text-lh, 1.45);
opacity: .92;
}
@media (max-width: 760px){
.glossary-portal-panel{
gap: 8px;
}
.glossary-portal-panel__head{
gap: 8px;
}
.glossary-portal-panel__count{
min-height: 23px;
padding: 0 8px;
font-size: 10.5px;
}
}
@media (orientation: landscape) and (max-width: 920px) and (max-height: 520px){
.glossary-portal-panel{
gap: 6px;
}
.glossary-portal-panel__head{
gap: 6px;
}
.glossary-portal-panel__count{
min-height: 21px;
padding: 0 7px;
font-size: 10px;
}
}
@media (prefers-color-scheme: dark){
.glossary-portal-panel--surface{
background: rgba(255,255,255,0.04);
}
}
</style>

View File

@@ -1,143 +0,0 @@
---
interface Props {
id: string;
title: string;
count?: string;
intro?: string;
final?: boolean;
className?: string;
}
const {
id,
title,
count,
intro,
final = false,
className,
} = Astro.props;
---
<section class:list={["glossary-portal-section", final && "glossary-portal-section--final", className]}>
<div class="glossary-portal-section__head">
<h2 id={id}>{title}</h2>
{count && <span class="glossary-portal-section__count">{count}</span>}
</div>
{intro && <p class="glossary-portal-section__intro">{intro}</p>}
<slot />
</section>
<style>
.glossary-portal-section{
margin-top: 30px;
}
.glossary-portal-section h2{
margin: 0;
font-size: clamp(1.8rem, 3vw, 2.35rem);
line-height: 1.05;
letter-spacing: -.03em;
font-weight: 800;
}
.glossary-portal-section__head{
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 10px;
}
.glossary-portal-section__count{
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 0 10px;
border: 1px solid rgba(127,127,127,0.20);
border-radius: 999px;
background: rgba(127,127,127,0.04);
font-size: 12px;
line-height: 1.2;
opacity: .8;
white-space: nowrap;
}
.glossary-portal-section__intro{
margin: 0;
max-width: 76ch;
font-size: var(--portal-body-size, 1rem);
line-height: var(--portal-body-lh, 1.55);
opacity: .94;
}
.glossary-portal-section--final{
margin-top: 34px;
}
@media (max-width: 980px){
.glossary-portal-section{
margin-top: 24px;
}
.glossary-portal-section h2{
font-size: clamp(1.6rem, 4.4vw, 2rem);
line-height: 1.04;
}
}
@media (max-width: 760px){
.glossary-portal-section{
margin-top: 20px;
}
.glossary-portal-section__head{
gap: 8px;
margin-bottom: 8px;
}
.glossary-portal-section h2{
font-size: clamp(1.34rem, 6.5vw, 1.72rem);
line-height: 1.04;
letter-spacing: -.022em;
}
.glossary-portal-section__count{
min-height: 24px;
padding: 0 8px;
font-size: 11px;
}
.glossary-portal-section--final{
margin-top: 24px;
}
}
@media (orientation: landscape) and (max-width: 920px) and (max-height: 520px){
.glossary-portal-section{
margin-top: 16px;
}
.glossary-portal-section__head{
gap: 6px;
margin-bottom: 6px;
}
.glossary-portal-section h2{
font-size: clamp(1.12rem, 4.2vw, 1.34rem);
line-height: 1.02;
}
.glossary-portal-section__count{
min-height: 22px;
padding: 0 7px;
font-size: 10px;
}
.glossary-portal-section--final{
margin-top: 18px;
}
}
</style>

View File

@@ -1,487 +0,0 @@
---
interface Props {
heroMoreId: string;
heroToggleId: string;
sectionHeadSelector?: string;
mobileBreakpoint?: number;
autoCollapseDelta?: number;
}
const {
heroMoreId,
heroToggleId,
sectionHeadSelector = ".glossary-portal-section__head",
mobileBreakpoint = 860,
autoCollapseDelta = 160,
} = Astro.props;
---
<script
is:inline
define:vars={{ heroMoreId, heroToggleId, sectionHeadSelector, mobileBreakpoint, autoCollapseDelta }}
>
(() => {
const boot = () => {
const body = document.body;
const root = document.documentElement;
const hero = document.querySelector("[data-glossary-portal-hero]");
const follow = document.getElementById("reading-follow");
const heroMore = document.getElementById(heroMoreId);
const heroToggle = document.getElementById(heroToggleId);
if (!body || !root || !hero || !follow) return;
const BODY_CLASS = "is-glossary-portal-page";
const FOLLOW_ON_CLASS = "glossary-portal-follow-on";
const EXPANDED_CLASS = "glossary-portal-hero-expanded";
const CONDENSED_CLASS = "glossary-portal-hero-condensed";
const mqMobile = window.matchMedia(`(max-width: ${mobileBreakpoint}px)`);
const mqSmallLandscape = window.matchMedia(
"(orientation: landscape) and (max-width: 920px) and (max-height: 520px)"
);
let expandedAtY = null;
let lastScrollY = window.scrollY || 0;
let raf = 0;
let lastFollowOn = null;
let lastCondensed = null;
let lastHeroHeight = -1;
body.classList.add(BODY_CLASS);
const isCompactViewport = () =>
mqMobile.matches || mqSmallLandscape.matches;
const stripLocalSticky = () => {
document.querySelectorAll(sectionHeadSelector).forEach((el) => {
el.classList.remove("is-sticky");
el.removeAttribute("data-sticky-active");
});
};
const readStickyTop = () => {
const raw = getComputedStyle(document.documentElement)
.getPropertyValue("--glossary-sticky-top")
.trim();
const n = Number.parseFloat(raw);
return Number.isFinite(n) ? n : 64;
};
const computeFollowOn = () =>
!isCompactViewport() &&
follow.classList.contains("is-on") &&
follow.style.display !== "none" &&
follow.getAttribute("aria-hidden") !== "true";
const computeCondensed = () => {
if (isCompactViewport()) return false;
const heroRect = hero.getBoundingClientRect();
const stickyTop = readStickyTop();
return heroRect.top <= stickyTop + 2;
};
const measureHeroHeight = () =>
Math.max(0, Math.round(hero.getBoundingClientRect().height || 0));
const PIN_EPS = 3;
const isHeroPinned = () => {
if (isCompactViewport()) return false;
const rect = hero.getBoundingClientRect();
const stickyTop = readStickyTop();
const cs = getComputedStyle(hero);
if (cs.position !== "sticky") return false;
const pinnedOnRail = Math.abs(rect.top - stickyTop) <= PIN_EPS;
const stillVisible = rect.bottom > stickyTop + 24;
return pinnedOnRail && stillVisible;
};
const applyLocalStickyHeight = () => {
const h = isHeroPinned() ? measureHeroHeight() : 0;
if (h === lastHeroHeight) return;
lastHeroHeight = h;
if (typeof window.__archiSetLocalStickyHeight === "function") {
window.__archiSetLocalStickyHeight(h);
} else {
root.style.setProperty("--glossary-local-sticky-h", `${h}px`);
}
};
const syncFollowState = () => {
const on = computeFollowOn();
if (on !== lastFollowOn) {
lastFollowOn = on;
body.classList.toggle(FOLLOW_ON_CLASS, on);
}
return on;
};
const syncCondensedState = () => {
const condensed = computeCondensed();
if (condensed !== lastCondensed) {
lastCondensed = condensed;
body.classList.toggle(CONDENSED_CLASS, condensed);
}
return condensed;
};
const collapseHero = () => {
if (!body.classList.contains(EXPANDED_CLASS)) return;
body.classList.remove(EXPANDED_CLASS);
expandedAtY = null;
if (heroMore) {
heroMore.setAttribute("aria-hidden", "true");
}
if (heroToggle) {
heroToggle.hidden = false;
heroToggle.setAttribute("aria-expanded", "false");
}
try {
window.__archiUpdateFollow?.();
} catch {}
schedule();
};
const expandHero = () => {
body.classList.add(EXPANDED_CLASS);
expandedAtY = window.scrollY || 0;
if (heroMore) {
heroMore.setAttribute("aria-hidden", "false");
}
if (heroToggle) {
heroToggle.hidden = true;
heroToggle.setAttribute("aria-expanded", "true");
}
try {
window.__archiUpdateFollow?.();
} catch {}
schedule();
};
const syncHeroState = (condensed) => {
const expanded = body.classList.contains(EXPANDED_CLASS);
const collapsed = condensed && !expanded;
if (isCompactViewport() || !condensed) {
body.classList.remove(EXPANDED_CLASS);
expandedAtY = null;
if (heroMore) {
heroMore.setAttribute("aria-hidden", "false");
}
if (heroToggle) {
heroToggle.hidden = true;
heroToggle.setAttribute("aria-expanded", "false");
}
return;
}
if (heroMore) {
heroMore.setAttribute("aria-hidden", collapsed ? "true" : "false");
}
if (heroToggle) {
heroToggle.hidden = !collapsed;
heroToggle.setAttribute("aria-expanded", expanded ? "true" : "false");
}
};
const maybeAutoCollapseOnScroll = () => {
if (isCompactViewport()) {
lastScrollY = window.scrollY || 0;
return;
}
if (!body.classList.contains(EXPANDED_CLASS)) {
lastScrollY = window.scrollY || 0;
return;
}
if (expandedAtY == null) {
lastScrollY = window.scrollY || 0;
return;
}
const currentY = window.scrollY || 0;
const scrollingDown = currentY > lastScrollY;
const delta = currentY - expandedAtY;
if (scrollingDown && delta >= autoCollapseDelta) {
collapseHero();
}
lastScrollY = currentY;
};
const syncAll = () => {
stripLocalSticky();
if (isCompactViewport()) {
body.classList.remove(FOLLOW_ON_CLASS);
body.classList.remove(CONDENSED_CLASS);
body.classList.remove(EXPANDED_CLASS);
lastFollowOn = false;
lastCondensed = false;
expandedAtY = null;
if (heroMore) {
heroMore.setAttribute("aria-hidden", "false");
}
if (heroToggle) {
heroToggle.hidden = true;
heroToggle.setAttribute("aria-expanded", "false");
}
requestAnimationFrame(() => {
applyLocalStickyHeight();
try {
window.__archiUpdateFollow?.();
} catch {}
});
return;
}
const condensed = syncCondensedState();
syncHeroState(condensed);
requestAnimationFrame(() => {
applyLocalStickyHeight();
syncFollowState();
try {
window.__archiUpdateFollow?.();
} catch {}
});
requestAnimationFrame(() => {
applyLocalStickyHeight();
try {
window.__archiUpdateFollow?.();
} catch {}
});
};
const schedule = () => {
if (raf) return;
raf = requestAnimationFrame(() => {
raf = 0;
syncAll();
});
};
heroToggle?.addEventListener("click", expandHero);
const onScroll = () => {
maybeAutoCollapseOnScroll();
schedule();
};
const followObserver = new MutationObserver(schedule);
followObserver.observe(follow, {
attributes: true,
attributeFilter: ["class", "style", "aria-hidden"],
subtree: false,
});
const heroResizeObserver =
typeof ResizeObserver !== "undefined"
? new ResizeObserver(schedule)
: null;
heroResizeObserver?.observe(hero);
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", schedule);
window.addEventListener("pageshow", schedule);
if (document.fonts?.ready) {
document.fonts.ready.then(schedule).catch(() => {});
}
if (mqMobile.addEventListener) {
mqMobile.addEventListener("change", schedule);
} else if (mqMobile.addListener) {
mqMobile.addListener(schedule);
}
if (mqSmallLandscape.addEventListener) {
mqSmallLandscape.addEventListener("change", schedule);
} else if (mqSmallLandscape.addListener) {
mqSmallLandscape.addListener(schedule);
}
schedule();
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot, { once: true });
} else {
boot();
}
})();
</script>
<style>
:global(body.is-glossary-portal-page #reading-follow){
z-index: 10;
}
/* Le hero se condense dès quil devient sticky */
:global(body.is-glossary-portal-page.glossary-portal-hero-condensed .glossary-portal-hero){
padding:
var(--portal-hero-pad-top-condensed, 14px)
var(--portal-hero-pad-x-condensed, 16px)
var(--portal-hero-pad-bottom-condensed, 16px);
row-gap: var(--portal-hero-gap-condensed, 10px);
box-shadow:
inset 0 1px 0 rgba(255,255,255,0.02),
0 8px 20px rgba(0,0,0,0.12);
}
:global(body.is-glossary-portal-page.glossary-portal-hero-condensed .glossary-portal-hero h1){
font-size: var(--portal-hero-h1-size-condensed, clamp(2.05rem, 3.15vw, 2.7rem));
line-height: var(--portal-hero-h1-lh-condensed, 1);
letter-spacing: var(--portal-hero-h1-spacing-condensed, -.04em);
}
:global(body.is-glossary-portal-page.glossary-portal-hero-condensed .glossary-portal-hero__intro){
max-width: var(--portal-hero-follow-intro-max-w, 62ch);
font-size: var(--portal-hero-intro-size-condensed, .98rem);
line-height: var(--portal-hero-intro-lh-condensed, 1.46);
}
:global(body.is-glossary-portal-page.glossary-portal-hero-condensed .glossary-portal-hero__kicker){
opacity: .68;
}
/* Le more se replie dès létat condensé */
:global(body.is-glossary-portal-page.glossary-portal-hero-condensed:not(.glossary-portal-hero-expanded) .glossary-portal-hero__more){
max-height: 0;
opacity: 0;
overflow: hidden;
pointer-events: none;
}
:global(body.is-glossary-portal-page.glossary-portal-hero-condensed:not(.glossary-portal-hero-expanded) .glossary-portal-hero__toggle){
display: inline-flex;
}
/* Laccolage hero + follow narrive que quand le follow est actif */
:global(body.is-glossary-portal-page.glossary-portal-hero-condensed.glossary-portal-follow-on .glossary-portal-hero){
margin-bottom: 0;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
:global(body.is-glossary-portal-page.glossary-portal-follow-on #reading-follow .reading-follow__inner){
margin-top: -1px;
border-top-left-radius: 0;
border-top-right-radius: 0;
}
:global(body.is-glossary-portal-page.glossary-portal-follow-on #reading-follow .rf-h2){
letter-spacing: -.02em;
}
:global(body.is-glossary-portal-page .glossary-portal-section__head.is-sticky),
:global(body.is-glossary-portal-page .glossary-portal-section__head[data-sticky-active="true"]){
position: static !important;
top: auto !important;
z-index: auto !important;
padding: 0 !important;
border: 0 !important;
background: transparent !important;
box-shadow: none !important;
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
}
@media (max-width: 860px){
:global(body.is-glossary-portal-page #reading-follow),
:global(body.is-glossary-portal-page #reading-follow .reading-follow__inner){
display: none !important;
opacity: 0 !important;
visibility: hidden !important;
pointer-events: none !important;
}
:global(body.is-glossary-portal-page){
--followbar-h: 0px !important;
--sticky-offset-px: calc(var(--sticky-header-h, 0px) + var(--page-gap, 12px)) !important;
}
:global(body.is-glossary-portal-page .glossary-portal-hero){
margin-bottom: var(--portal-hero-margin-bottom, 18px);
border-radius: 20px !important;
box-shadow: none !important;
}
:global(body.is-glossary-portal-page .glossary-portal-hero__more){
max-height: none !important;
opacity: 1 !important;
overflow: visible !important;
pointer-events: auto !important;
}
:global(body.is-glossary-portal-page .glossary-portal-hero__toggle){
display: none !important;
}
}
@media (orientation: landscape) and (max-width: 920px) and (max-height: 520px){
:global(body.is-glossary-portal-page #reading-follow),
:global(body.is-glossary-portal-page #reading-follow .reading-follow__inner){
display: none !important;
opacity: 0 !important;
visibility: hidden !important;
pointer-events: none !important;
}
:global(body.is-glossary-portal-page){
--followbar-h: 0px !important;
--sticky-offset-px: calc(var(--sticky-header-h, 0px) + var(--page-gap, 12px)) !important;
}
:global(body.is-glossary-portal-page .glossary-portal-hero){
margin-bottom: var(--portal-hero-margin-bottom, 12px);
border-radius: 16px !important;
box-shadow: none !important;
}
:global(body.is-glossary-portal-page .glossary-portal-hero__more){
max-height: none !important;
opacity: 1 !important;
overflow: visible !important;
pointer-events: auto !important;
}
:global(body.is-glossary-portal-page .glossary-portal-hero__toggle){
display: none !important;
}
}
</style>

View File

@@ -1,132 +0,0 @@
---
import type { GlossaryRelationBlock } from "../lib/glossary";
import { hrefOfGlossaryEntry } from "../lib/glossary";
interface Props {
relationBlocks: GlossaryRelationBlock[];
}
const { relationBlocks = [] } = Astro.props;
const relationsHeadingId = "relations-conceptuelles";
---
{relationBlocks.length > 0 && (
<section
class="glossary-relations"
aria-labelledby={relationsHeadingId}
>
<h2 id={relationsHeadingId}>Relations conceptuelles</h2>
<div class="glossary-relations-grid">
{relationBlocks.map((block) => (
<section class={`glossary-relations-card ${block.className}`}>
<h3>{block.title}</h3>
<ul>
{block.items.map((item) => (
<li>
<a href={hrefOfGlossaryEntry(item)}>{item.data.term}</a>
<span> — {item.data.definitionShort}</span>
</li>
))}
</ul>
</section>
))}
</div>
</section>
)}
<style>
.glossary-relations{
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid rgba(127,127,127,0.18);
}
@media (max-width: 760px){
.glossary-relations{
margin-top: 12px;
padding-top: 12px;
}
}
.glossary-relations h2{
margin: 0 0 12px;
font-size: clamp(1.35rem, 3vw, 1.8rem);
line-height: 1.08;
}
.glossary-relations-grid{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 10px;
}
.glossary-relations-card{
border: 1px solid rgba(127,127,127,0.20);
border-radius: 14px;
padding: 12px 13px;
background: rgba(127,127,127,0.05);
}
.glossary-relations-card h3{
margin: 0 0 8px;
font-size: 14px;
line-height: 1.3;
}
.glossary-relations-card ul{
margin: 0;
padding-left: 16px;
}
.glossary-relations-card li{
margin-bottom: 7px;
font-size: 13.5px;
line-height: 1.42;
}
.glossary-relations-card li:last-child{
margin-bottom: 0;
}
.glossary-relations-card span{
opacity: .88;
}
@media (max-width: 760px){
.glossary-relations{
margin-top: 18px;
padding-top: 12px;
}
.glossary-relations h2{
margin-bottom: 10px;
font-size: 1.3rem;
}
.glossary-relations-grid{
grid-template-columns: 1fr;
gap: 8px;
}
.glossary-relations-card{
padding: 11px 11px;
border-radius: 13px;
}
.glossary-relations-card h3{
font-size: 13px;
margin-bottom: 7px;
}
.glossary-relations-card li{
font-size: 13px;
line-height: 1.38;
}
}
@media (prefers-color-scheme: dark){
.glossary-relations-card{
background: rgba(255,255,255,0.04);
}
}
</style>

View File

@@ -1,307 +0,0 @@
---
import type { GlossarySmartNavigation } from "../lib/glossary";
import { hrefOfGlossaryEntry } from "../lib/glossary";
interface Props {
smartNavigation?: GlossarySmartNavigation;
}
const { smartNavigation } = Astro.props;
const hasPrimary = Boolean(smartNavigation?.primaryNext);
const paths = smartNavigation?.paths ?? [];
const flows = smartNavigation?.flows ?? [];
const hasPaths = paths.length > 0;
const hasFlows = flows.length > 0;
---
{(hasPrimary || hasPaths || hasFlows) && (
<section class="glossary-smart-nav" aria-label="Navigation guidée du glossaire">
<div class="glossary-smart-nav__eyebrow">Explorer les prolongements</div>
{smartNavigation?.primaryNext && (
<div class="glossary-smart-nav__primary">
<span class="glossary-smart-nav__label">Étape suivante</span>
<a href={hrefOfGlossaryEntry(smartNavigation.primaryNext)}>
{smartNavigation.primaryNext.data.term}
</a>
{smartNavigation.primaryReason && (
<p>{smartNavigation.primaryReason}</p>
)}
</div>
)}
{hasFlows && (
<div class="glossary-smart-nav__flows" aria-label="Parcours contextuels">
<span class="glossary-smart-nav__label">Parcours contextuels</span>
<div class="glossary-smart-nav__flow-list">
{flows.map((flow) => (
flow.primaryNext && (
<a class="glossary-smart-nav__flow" href={hrefOfGlossaryEntry(flow.primaryNext)}>
<span class="glossary-smart-nav__flow-label">{flow.label}</span>
<strong>{flow.primaryNext.data.term}</strong>
{flow.primaryReason && <span>{flow.primaryReason}</span>}
</a>
)
))}
</div>
</div>
)}
{hasPaths && (
<div class="glossary-smart-nav__paths" aria-label="Parcours de lecture">
{paths.map((path) => {
const panelId = `smart-nav-${path.key}`;
return (
<div class="glossary-smart-nav__path">
<button
class="glossary-smart-nav__path-button"
type="button"
aria-expanded="false"
aria-controls={panelId}
>
<span>{path.label}</span>
<span class="glossary-smart-nav__chevron" aria-hidden="true">▾</span>
</button>
<ul id={panelId} class="glossary-smart-nav__path-panel" hidden>
{path.entries.map((entry) => (
<li>
<a href={hrefOfGlossaryEntry(entry)}>{entry.data.term}</a>
</li>
))}
</ul>
</div>
);
})}
</div>
)}
</section>
)}
<style>
.glossary-smart-nav{
margin: 14px 0 18px;
padding: 14px;
border: 1px solid rgba(127,127,127,0.20);
border-radius: 18px;
background: rgba(127,127,127,0.045);
}
.glossary-smart-nav__primary{
display: grid;
gap: 5px;
margin-bottom: 10px;
}
.glossary-smart-nav__label{
font-size: 13px;
font-weight: 800;
opacity: .76;
}
.glossary-smart-nav__primary a{
width: fit-content;
font-size: clamp(1.05rem, 2vw, 1.22rem);
font-weight: 900;
line-height: 1.18;
text-decoration: none;
}
.glossary-smart-nav__primary p{
max-width: 72ch;
margin: 0;
font-size: 14px;
line-height: 1.45;
opacity: .88;
}
.glossary-smart-nav__paths{
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 8px;
margin-top: 12px;
}
.glossary-smart-nav__path{
align-self: flex-start;
min-width: min(180px, 100%);
border: 1px solid rgba(127,127,127,0.18);
border-radius: 14px;
background: rgba(127,127,127,0.035);
overflow: hidden;
}
.glossary-smart-nav__path-button{
width: 100%;
border: 1px solid rgba(127,127,127,0.18);
background: rgba(127,127,127,0.06);
color: inherit;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 8px 12px;
cursor: pointer;
font: inherit;
font-size: 13px;
font-weight: 850;
line-height: 1.25;
text-align: left;
user-select: none;
border-radius: 999px;
}
.glossary-smart-nav__path-button:hover{
background: rgba(127,127,127,0.10);
}
.glossary-smart-nav__path-button:active{
transform: scale(0.98);
}
.glossary-smart-nav__chevron{
flex: 0 0 auto;
font-size: 13px;
line-height: 1;
opacity: .72;
transform: rotate(0deg);
transition: transform 160ms ease, opacity 160ms ease;
}
.glossary-smart-nav__path-button[aria-expanded="true"] .glossary-smart-nav__chevron{
transform: rotate(180deg);
opacity: .96;
}
.glossary-smart-nav__path-panel{
margin: 0;
padding: 0 11px 10px 24px;
}
.glossary-smart-nav__path-panel[hidden]{
display: none;
}
.glossary-smart-nav__path-panel li{
margin: 5px 0;
font-size: 13px;
line-height: 1.32;
}
.glossary-smart-nav__path-panel a{
text-decoration: none;
}
.glossary-smart-nav__flows{
display: grid;
gap: 8px;
margin-top: 12px;
margin-bottom: 6px;
}
.glossary-smart-nav__flow-list{
display: grid;
gap: 8px;
}
.glossary-smart-nav__flow{
display: grid;
gap: 3px;
padding: 10px 11px;
border: 1px solid rgba(127,127,127,0.18);
border-radius: 14px;
background: rgba(127,127,127,0.035);
text-decoration: none;
}
.glossary-smart-nav__flow-label{
font-size: 12px;
font-weight: 850;
letter-spacing: .04em;
text-transform: uppercase;
opacity: .72;
}
.glossary-smart-nav__flow strong{
font-size: 14px;
line-height: 1.25;
}
.glossary-smart-nav__flow span:last-child{
font-size: 13px;
line-height: 1.35;
opacity: .84;
}
.glossary-smart-nav__eyebrow{
margin-bottom: 8px;
font-size: 12px;
font-weight: 850;
letter-spacing: .08em; /* légèrement augmenté */
text-transform: uppercase;
opacity: .78; /* un poil plus visible */
}
@media (max-width: 760px){
.glossary-smart-nav{
margin: 12px 0 16px;
padding: 12px;
border-radius: 16px;
}
.glossary-smart-nav__paths{
display: grid;
grid-template-columns: 1fr;
gap: 7px;
}
.glossary-smart-nav__path{
width: 100%;
}
}
@media (prefers-color-scheme: dark){
.glossary-smart-nav{
background: rgba(255,255,255,0.04);
}
.glossary-smart-nav__path{
background: rgba(255,255,255,0.035);
}
.glossary-smart-nav__flow{
background: rgba(255,255,255,0.035);
}
}
</style>
<script is:inline>
(() => {
document.querySelectorAll(".glossary-smart-nav").forEach((nav) => {
nav
.querySelectorAll(".glossary-smart-nav__path-button")
.forEach((button) => {
button.addEventListener("click", () => {
const panelId = button.getAttribute("aria-controls");
const panel = panelId ? document.getElementById(panelId) : null;
if (!panel || !nav.contains(panel)) return;
const expanded = button.getAttribute("aria-expanded") === "true";
const nextExpanded = !expanded;
button.setAttribute("aria-expanded", nextExpanded ? "true" : "false");
panel.hidden = !nextExpanded;
button
.closest(".glossary-smart-nav__path")
?.classList.toggle("is-open", nextExpanded);
});
});
});
})();
</script>

View File

@@ -68,6 +68,7 @@ const { initialLevel = 1 } = Astro.props;
} catch {} } catch {}
} }
// init : storage > initialLevel
let start = clampLevel(initialLevel); let start = clampLevel(initialLevel);
try { try {
const stored = localStorage.getItem(KEY); const stored = localStorage.getItem(KEY);
@@ -76,11 +77,13 @@ const { initialLevel = 1 } = Astro.props;
applyLevel(start, { persist: false }); applyLevel(start, { persist: false });
// clicks
wrap.addEventListener("click", (ev) => { wrap.addEventListener("click", (ev) => {
const btn = ev.target?.closest?.("button[data-level]"); const btn = ev.target?.closest?.("button[data-level]");
if (!btn) return; if (!btn) return;
ev.preventDefault(); ev.preventDefault();
// ✅ crucial : on capture la position AVANT le reflow lié au changement de niveau
captureBeforeLevelSwitch(); captureBeforeLevelSwitch();
applyLevel(btn.dataset.level); applyLevel(btn.dataset.level);
}); });
@@ -92,8 +95,6 @@ const { initialLevel = 1 } = Astro.props;
display: inline-flex; display: inline-flex;
gap: 8px; gap: 8px;
align-items: center; align-items: center;
flex-wrap: wrap;
max-width: 100%;
} }
.level-btn{ .level-btn{
@@ -105,7 +106,6 @@ const { initialLevel = 1 } = Astro.props;
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
transition: filter .12s ease, transform .12s ease, background .12s ease, border-color .12s ease; transition: filter .12s ease, transform .12s ease, background .12s ease, border-color .12s ease;
white-space: nowrap;
} }
.level-btn:hover{ .level-btn:hover{
@@ -125,21 +125,4 @@ const { initialLevel = 1 } = Astro.props;
.level-btn:active{ .level-btn:active{
transform: translateY(1px); transform: translateY(1px);
} }
</style>
@media (max-width: 980px){
.level-toggle{
gap: 6px;
}
.level-btn{
padding: 5px 9px;
font-size: 12px;
}
}
@media (max-width: 760px){
.level-toggle{
display: none;
}
}
</style>

View File

@@ -3,128 +3,49 @@ const { headings } = Astro.props;
// H2/H3 seulement // H2/H3 seulement
const items = (headings || []).filter((h) => h.depth >= 2 && h.depth <= 3); const items = (headings || []).filter((h) => h.depth >= 2 && h.depth <= 3);
const tocId = `toc-local-${Math.random().toString(36).slice(2, 9)}`;
--- ---
{items.length > 0 && ( {items.length > 0 && (
<nav class="toc-local" aria-label="Dans ce chapitre" data-toc-local data-mobile-default="closed"> <nav class="toc-local" aria-label="Dans ce chapitre">
<button <div class="toc-local__title">Dans ce chapitre</div>
class="toc-local__head toc-local__toggle"
type="button"
aria-expanded="false"
aria-controls={tocId}
>
<span class="toc-local__title">Dans ce chapitre</span>
<span class="toc-local__chevron" aria-hidden="true">▾</span>
</button>
<div class="toc-local__body-clip" id={tocId} hidden> <ol class="toc-local__list">
<div class="toc-local__body"> {items.map((h) => (
<ol class="toc-local__list"> <li
{items.map((h) => ( class={`toc-local__item d${h.depth}`}
<li data-toc-item
class={`toc-local__item d${h.depth}`} data-depth={h.depth}
data-toc-item data-id={h.slug}
data-depth={h.depth} >
data-id={h.slug} <a href={`#${h.slug}`} data-toc-link data-slug={h.slug}>
> {h.text}
<a href={`#${h.slug}`} data-toc-link data-slug={h.slug}> </a>
<span class="toc-local__mark" aria-hidden="true"></span> </li>
<span class="toc-local__text">{h.text}</span> ))}
</a> </ol>
</li>
))}
</ol>
</div>
</div>
</nav> </nav>
)} )}
<script is:inline> <script is:inline>
(() => { (() => {
function init() { function init() {
const toc = document.querySelector(".toc-local[data-toc-local]"); const toc = document.querySelector(".toc-local");
if (!toc || toc.dataset.tocReady === "1") return; if (!toc) return;
toc.dataset.tocReady = "1";
const toggle = toc.querySelector(".toc-local__toggle");
const bodyClip = toc.querySelector(".toc-local__body-clip");
const mq = window.matchMedia("(max-width: 980px)");
const KEY = `archicratie:toc-local:${window.location.pathname}`;
if (!toggle || !bodyClip) return;
const readState = () => {
try {
const v = localStorage.getItem(KEY);
if (v === "open") return true;
if (v === "closed") return false;
} catch {}
return null;
};
const writeState = (open) => {
try { localStorage.setItem(KEY, open ? "open" : "closed"); } catch {}
};
const setOpen = (open, { persist = true, emit = true } = {}) => {
const isMobile = mq.matches;
const effectiveOpen = isMobile ? open : true;
toc.classList.toggle("is-collapsed", isMobile && !effectiveOpen);
toggle.setAttribute("aria-expanded", effectiveOpen ? "true" : "false");
if (bodyClip) {
bodyClip.hidden = isMobile && !effectiveOpen;
}
if (persist && isMobile) writeState(effectiveOpen);
if (emit && effectiveOpen && isMobile) {
window.dispatchEvent(new CustomEvent("archicratie:tocLocalOpen"));
}
};
const initAccordion = () => {
if (!mq.matches) {
setOpen(true, { persist: false, emit: false });
return;
}
const stored = readState();
setOpen(stored == null ? false : stored, { persist: false, emit: false });
};
toggle.addEventListener("click", () => {
const next = toggle.getAttribute("aria-expanded") !== "true";
setOpen(next);
});
if (mq.addEventListener) {
mq.addEventListener("change", initAccordion);
} else if (mq.addListener) {
mq.addListener(initAccordion);
}
const itemEls = Array.from(toc.querySelectorAll("[data-toc-item]")); const itemEls = Array.from(toc.querySelectorAll("[data-toc-item]"));
if (!itemEls.length) { if (!itemEls.length) return;
initAccordion();
return;
}
const ordered = itemEls const ordered = itemEls
.map((li) => { .map((li) => {
const a = li.querySelector("a[data-toc-link]"); const a = li.querySelector("a[data-toc-link]");
const id = li.getAttribute("data-id") || a?.dataset.slug || ""; const id = li.getAttribute("data-id") || a?.dataset.slug || "";
const depth = Number(li.getAttribute("data-depth") || "0"); const depth = Number(li.getAttribute("data-depth") || "0");
const el = id ? document.getElementById(id) : null; const el = id ? document.getElementById(id) : null; // span.details-anchor OU h3[id]
return (a && id && el) ? { id, depth, li, a, el } : null; return (a && id && el) ? { id, depth, li, a, el } : null;
}) })
.filter(Boolean); .filter(Boolean);
if (!ordered.length) { if (!ordered.length) return;
initAccordion();
return;
}
const clear = () => { const clear = () => {
for (const t of ordered) { for (const t of ordered) {
@@ -134,29 +55,14 @@ const tocId = `toc-local-${Math.random().toString(36).slice(2, 9)}`;
}; };
const openDetailsIfNeeded = (el) => { const openDetailsIfNeeded = (el) => {
try { const d = el?.closest?.("details");
if (!el) return; if (d && !d.open) d.open = true;
let d = el.closest?.("details") || null;
if (!d && el.classList?.contains("details-anchor")) {
const n = el.nextElementSibling;
if (n && n.tagName === "DETAILS") d = n;
}
if (!d) {
const s = el.closest?.("summary");
if (s && s.parentElement && s.parentElement.tagName === "DETAILS") d = s.parentElement;
}
if (d && d.tagName === "DETAILS" && !d.open) d.open = true;
} catch {}
}; };
let current = ""; let current = "";
const setCurrent = (id, { autoOpen = true } = {}) => { const setCurrent = (id) => {
if (!id) return; if (!id || id === current) return;
const t = ordered.find((x) => x.id === id); const t = ordered.find((x) => x.id === id);
if (!t) return; if (!t) return;
@@ -168,22 +74,17 @@ const tocId = `toc-local-${Math.random().toString(36).slice(2, 9)}`;
t.a.setAttribute("aria-current", "true"); t.a.setAttribute("aria-current", "true");
t.li.classList.add("is-current"); t.li.classList.add("is-current");
// Sur mobile/tablette, le suivi actif ne doit pas rouvrir automatiquement la TOC. // ✅ IMPORTANT: plus de scrollIntoView ici
if (!mq.matches && autoOpen && toc.classList.contains("is-collapsed")) { // sinon ça scroll l'aside pendant le scroll du reading => TOC global “disparaît”.
setOpen(true);
}
window.dispatchEvent(
new CustomEvent("archicratie:tocLocalActive", { detail: { id } })
);
}; };
const computeActive = () => { const computeActive = () => {
const visible = ordered.filter((t) => { const visible = ordered.filter((t) => {
const d = t.el.closest?.("details"); const d = t.el.closest?.("details");
if (d && !d.open) { if (d && !d.open) {
// Si l'élément est dans <summary>, il reste visible même details fermé
const inSummary = !!t.el.closest?.("summary"); const inSummary = !!t.el.closest?.("summary");
if (!inSummary && !t.el.classList?.contains("details-anchor")) return false; if (!inSummary) return false;
} }
return true; return true;
}); });
@@ -201,7 +102,7 @@ const tocId = `toc-local-${Math.random().toString(36).slice(2, 9)}`;
} }
if (!best) best = visible[0]; if (!best) best = visible[0];
if (best && best.id !== current) setCurrent(best.id, { autoOpen: true }); setCurrent(best.id);
}; };
let ticking = false; let ticking = false;
@@ -216,14 +117,11 @@ const tocId = `toc-local-${Math.random().toString(36).slice(2, 9)}`;
const syncFromHash = () => { const syncFromHash = () => {
const id = (location.hash || "").slice(1); const id = (location.hash || "").slice(1);
if (!id) { if (!id) { computeActive(); return; }
computeActive();
return;
}
const el = document.getElementById(id); const el = document.getElementById(id);
if (el) openDetailsIfNeeded(el); if (el) openDetailsIfNeeded(el);
setCurrent(id, { autoOpen: false }); setCurrent(id);
}; };
toc.addEventListener("click", (ev) => { toc.addEventListener("click", (ev) => {
@@ -235,14 +133,13 @@ const tocId = `toc-local-${Math.random().toString(36).slice(2, 9)}`;
const el = document.getElementById(id); const el = document.getElementById(id);
if (el) openDetailsIfNeeded(el); if (el) openDetailsIfNeeded(el);
setCurrent(id, { autoOpen: true }); setCurrent(id);
}); });
window.addEventListener("scroll", onScroll, { passive: true }); window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onScroll); window.addEventListener("resize", onScroll);
window.addEventListener("hashchange", syncFromHash); window.addEventListener("hashchange", syncFromHash);
initAccordion();
syncFromHash(); syncFromHash();
onScroll(); onScroll();
} }
@@ -256,187 +153,30 @@ const tocId = `toc-local-${Math.random().toString(36).slice(2, 9)}`;
</script> </script>
<style> <style>
.toc-local{ .toc-local{margin-top:12px;border:1px solid rgba(127,127,127,.25);border-radius:16px;padding:12px}
margin-top: 12px; .toc-local__title{font-size:13px;opacity:.85;margin-bottom:8px}
border: 1px solid rgba(127,127,127,.25);
border-radius: 16px; .toc-local__list{list-style:none;margin:0;padding:0}
padding: 12px; .toc-local__item::marker{content:""}
background: rgba(127,127,127,0.03); .toc-local__item{margin:6px 0}
.toc-local__item.d3{margin-left:12px;opacity:.9}
.toc-local__item.is-current > a{
font-weight: 750;
text-decoration: underline;
} }
.toc-local__toggle{ .toc-local a{
width: 100%; display:inline-block;
appearance: none; max-width:100%;
border: 0; text-decoration:none;
background: transparent;
color: inherit;
text-align: left;
padding: 0;
cursor: pointer;
} }
.toc-local a:hover{ text-decoration: underline; }
.toc-local__head{
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 8px;
}
.toc-local__title{
font-size: 13px;
opacity: .85;
}
.toc-local__chevron{
font-size: 12px;
opacity: .72;
transition: transform 180ms ease;
}
.toc-local__body-clip{
display: grid;
grid-template-rows: 1fr;
transition:
grid-template-rows 220ms ease,
opacity 160ms ease,
margin-top 220ms ease;
}
.toc-local__body{
min-height: 0;
overflow: hidden;
}
.toc-local__list{ .toc-local__list{
list-style: none;
margin: 0;
padding: 0;
max-height: 44vh; max-height: 44vh;
overflow: auto; overflow: auto;
padding-right: 8px; padding-right: 8px;
scrollbar-gutter: stable; scrollbar-gutter: stable;
} }
.toc-local__item::marker{ content:""; } </style>
.toc-local__item{ margin: 6px 0; }
.toc-local__item.d3{
margin-left: 14px;
opacity: .94;
}
.toc-local a{
display: grid;
grid-template-columns: auto 1fr;
gap: 8px;
align-items: start;
max-width: 100%;
text-decoration: none;
}
.toc-local a:hover{
text-decoration: none;
}
.toc-local__mark{
width: 10px;
height: 10px;
margin-top: .36em;
border-radius: 999px;
border: 1px solid rgba(127,127,127,.34);
background: transparent;
opacity: .68;
}
.toc-local__text{
line-height: 1.28;
}
.toc-local__item.is-current > a{
font-weight: 760;
}
.toc-local__item.is-current > a .toc-local__mark{
background: currentColor;
border-color: currentColor;
box-shadow: 0 0 0 3px rgba(127,127,127,.10);
opacity: 1;
}
@media (max-width: 980px){
.toc-local{
padding: 10px 12px;
border-radius: 14px;
}
.toc-local__head{
margin-bottom: 0;
min-height: 28px;
}
.toc-local__body-clip{
margin-top: 10px;
}
.toc-local.is-collapsed .toc-local__body-clip{
grid-template-rows: 0fr;
opacity: 0;
margin-top: 0;
}
.toc-local__body{
min-height: 0;
overflow: hidden;
transition: opacity 180ms ease;
}
.toc-local.is-collapsed .toc-local__body{
opacity: 0;
}
.toc-local.is-collapsed .toc-local__chevron{
transform: rotate(-90deg);
}
.toc-local__title{
font-size: 13px;
}
.toc-local__list{
max-height: min(42vh, 360px);
padding-right: 4px;
}
.toc-local__item{
margin: 5px 0;
}
.toc-local__item.d2 > a .toc-local__text{
font-size: 12.9px;
line-height: 1.24;
font-weight: 680;
}
.toc-local__item.d3{
margin-left: 12px;
}
.toc-local__item.d3 > a .toc-local__text{
font-size: 12.1px;
line-height: 1.22;
opacity: .95;
}
.toc-local__item.d3 > a .toc-local__mark{
width: 8px;
height: 8px;
margin-top: .42em;
opacity: .55;
}
.toc-local__body-clip[hidden]{
display: none !important;
}
}
</style>

View File

@@ -25,12 +25,12 @@
{/* ✅ actions références en haut (niveau 2 uniquement) */} {/* ✅ actions références en haut (niveau 2 uniquement) */}
<div class="panel-top-actions level-2" aria-label="Actions références"> <div class="panel-top-actions level-2" aria-label="Actions références">
<div class="panel-actions"> <div class="panel-actions">
<button class="panel-btn panel-btn--primary" id="panel-ref-submit" type="button"> <button class="panel-btn panel-btn--primary" id="panel-ref-submit" type="button">
Soumettre une référence (Gitea) Soumettre une référence (Gitea)
</button> </button>
</div> </div>
<div class="panel-msg" id="panel-ref-msg" hidden></div> <div class="panel-msg" id="panel-ref-msg" hidden></div>
</div> </div>
<section class="panel-block level-2" aria-label="Références et auteurs"> <section class="panel-block level-2" aria-label="Références et auteurs">
@@ -60,7 +60,7 @@
</section> </section>
</div> </div>
{/* ✅ Lightbox media (plein écran) */} {/* ✅ Lightbox media (pop-up au-dessus du panel) */}
<div class="panel-lightbox" id="panel-lightbox" hidden aria-hidden="true"> <div class="panel-lightbox" id="panel-lightbox" hidden aria-hidden="true">
<div class="panel-lightbox__overlay" data-close="1"></div> <div class="panel-lightbox__overlay" data-close="1"></div>
<div class="panel-lightbox__dialog" role="dialog" aria-modal="true" aria-label="Aperçu du média"> <div class="panel-lightbox__dialog" role="dialog" aria-modal="true" aria-label="Aperçu du média">
@@ -93,9 +93,6 @@
const btnMediaSubmit = root.querySelector("#panel-media-submit"); const btnMediaSubmit = root.querySelector("#panel-media-submit");
const msgMedia = root.querySelector("#panel-media-msg"); const msgMedia = root.querySelector("#panel-media-msg");
const btnRefSubmit = root.querySelector("#panel-ref-submit");
const msgRef = root.querySelector("#panel-ref-msg");
const taComment = root.querySelector("#panel-comment-text"); const taComment = root.querySelector("#panel-comment-text");
const btnSend = root.querySelector("#panel-comment-send"); const btnSend = root.querySelector("#panel-comment-send");
const msgComment = root.querySelector("#panel-comment-msg"); const msgComment = root.querySelector("#panel-comment-msg");
@@ -104,6 +101,9 @@
const lbContent = root.querySelector("#panel-lightbox-content"); const lbContent = root.querySelector("#panel-lightbox-content");
const lbCaption = root.querySelector("#panel-lightbox-caption"); const lbCaption = root.querySelector("#panel-lightbox-caption");
const btnRefSubmit = root.querySelector("#panel-ref-submit");
const msgRef = root.querySelector("#panel-ref-msg");
const docTitle = document.body?.dataset?.docTitle || document.title || "Archicratie"; const docTitle = document.body?.dataset?.docTitle || document.title || "Archicratie";
const docVersion = document.body?.dataset?.docVersion || ""; const docVersion = document.body?.dataset?.docVersion || "";
@@ -114,16 +114,6 @@
let currentParaId = ""; let currentParaId = "";
let mediaShowAll = (localStorage.getItem("archicratie:panel:mediaAll") === "1"); let mediaShowAll = (localStorage.getItem("archicratie:panel:mediaAll") === "1");
// ===== cosmetics: micro flash “update” =====
let _flashT = 0;
function flashUpdate(){
try {
root.classList.add("is-updating");
if (_flashT) clearTimeout(_flashT);
_flashT = setTimeout(() => root.classList.remove("is-updating"), 180);
} catch {}
}
// ===== globals ===== // ===== globals =====
function getG() { function getG() {
return window.__archiGitea || { ready: false, base: "", owner: "", repo: "" }; return window.__archiGitea || { ready: false, base: "", owner: "", repo: "" };
@@ -131,6 +121,9 @@
function getAuthInfoP() { function getAuthInfoP() {
return window.__archiAuthInfoP || Promise.resolve({ ok: false, groups: [] }); return window.__archiAuthInfoP || Promise.resolve({ ok: false, groups: [] });
} }
function isDev() {
return Boolean((window.__archiFlags && window.__archiFlags.dev) || /^(localhost|127\.0\.0\.1|\[::1\])$/i.test(location.hostname));
}
const access = { ready: false, canUsers: false }; const access = { ready: false, canUsers: false };
@@ -144,7 +137,8 @@
return Array.isArray(groups) && groups.some((x) => String(x).toLowerCase() === gg); return Array.isArray(groups) && groups.some((x) => String(x).toLowerCase() === gg);
} }
// ✅ readers + editors peuvent soumettre médias + commentaires + refs // ✅ règle mission : readers + editors peuvent soumettre médias + commentaires
// ✅ dev fallback : si /_auth/whoami nexiste pas, on autorise pour tester
getAuthInfoP().then((info) => { getAuthInfoP().then((info) => {
const groups = Array.isArray(info?.groups) ? info.groups : []; const groups = Array.isArray(info?.groups) ? info.groups : [];
const canReaders = inGroup(groups, "readers"); const canReaders = inGroup(groups, "readers");
@@ -158,6 +152,7 @@
if (btnSend) btnSend.disabled = !access.canUsers; if (btnSend) btnSend.disabled = !access.canUsers;
if (btnRefSubmit) btnRefSubmit.disabled = !access.canUsers; if (btnRefSubmit) btnRefSubmit.disabled = !access.canUsers;
// si pas d'accès, on informe (soft)
if (!access.canUsers) { if (!access.canUsers) {
if (msgHead) { if (msgHead) {
msgHead.hidden = false; msgHead.hidden = false;
@@ -166,6 +161,7 @@
} }
} }
}).catch(() => { }).catch(() => {
// fallback dev (cohérent: media + ref + comment)
access.ready = true; access.ready = true;
if (Boolean(window.__archiFlags && window.__archiFlags.whoamiSkipped)) { if (Boolean(window.__archiFlags && window.__archiFlags.whoamiSkipped)) {
access.canUsers = true; access.canUsers = true;
@@ -217,6 +213,7 @@
const res = await fetch("/annotations-index.json?_=" + Date.now(), { cache: "no-store" }); const res = await fetch("/annotations-index.json?_=" + Date.now(), { cache: "no-store" });
if (res && res.ok) return await res.json(); if (res && res.ok) return await res.json();
} catch {} } catch {}
// ✅ antifragile: ne pas “cacher” un échec pour toujours (dev/HMR/boot race)
_idxP = null; _idxP = null;
return null; return null;
})(); })();
@@ -258,22 +255,24 @@
return issue.toString(); return issue.toString();
} }
function openNewTab(url) { // Ouvre un nouvel onglet UNE SEULE FOIS (évite le double-open Safari/Firefox + noopener).
try { function openNewTab(url) {
const a = document.createElement("a"); try {
a.href = url; const a = document.createElement("a");
a.target = "_blank"; a.href = url;
a.rel = "noopener noreferrer"; a.target = "_blank";
a.style.display = "none"; a.rel = "noopener noreferrer";
document.body.appendChild(a); a.style.display = "none";
a.click(); document.body.appendChild(a);
a.remove(); a.click();
return true; a.remove();
} catch { return true; // on ne peut pas détecter proprement un blocage sans retomber dans le double-open
return false; } catch {
return false;
}
} }
}
// ====== GARDES ANTI-DOUBLONS ======
const _openStamp = new Map(); const _openStamp = new Map();
function openOnce(key, fn) { function openOnce(key, fn) {
const now = Date.now(); const now = Date.now();
@@ -302,21 +301,13 @@
} }
// ===== Lightbox ===== // ===== Lightbox =====
function lockScroll(on) {
try {
document.documentElement.classList.toggle("archi-lb-open", !!on);
} catch {}
}
function closeLightbox() { function closeLightbox() {
if (!lb) return; if (!lb) return;
lb.hidden = true; lb.hidden = true;
lb.setAttribute("aria-hidden", "true"); lb.setAttribute("aria-hidden", "true");
if (lbContent) clear(lbContent); if (lbContent) clear(lbContent);
if (lbCaption) { lbCaption.hidden = true; lbCaption.textContent = ""; } if (lbCaption) { lbCaption.hidden = true; lbCaption.textContent = ""; }
lockScroll(false);
} }
function openLightbox({ type, src, caption }) { function openLightbox({ type, src, caption }) {
if (!lb || !lbContent) return; if (!lb || !lbContent) return;
clear(lbContent); clear(lbContent);
@@ -355,7 +346,6 @@
else { lbCaption.hidden = true; lbCaption.textContent = ""; } else { lbCaption.hidden = true; lbCaption.textContent = ""; }
} }
lockScroll(true);
lb.hidden = false; lb.hidden = false;
lb.setAttribute("aria-hidden", "false"); lb.setAttribute("aria-hidden", "false");
} }
@@ -373,6 +363,7 @@
}); });
} }
// ===== Renders =====
function renderLevel2(data) { function renderLevel2(data) {
clear(elL2); clear(elL2);
if (!elL2) return; if (!elL2) return;
@@ -382,7 +373,7 @@
return; return;
} }
if (Array.isArray(data.mobilizedAuthors) && data.mobilizedAuthors.length) { if (Array.isArray(data.authors) && data.authors.length) {
const h = document.createElement("h3"); const h = document.createElement("h3");
h.className = "panel-subtitle"; h.className = "panel-subtitle";
h.textContent = "Auteurs"; h.textContent = "Auteurs";
@@ -390,7 +381,7 @@
const ul = document.createElement("ul"); const ul = document.createElement("ul");
ul.className = "panel-list"; ul.className = "panel-list";
for (const a of data.mobilizedAuthors) { for (const a of data.authors) {
const li = document.createElement("li"); const li = document.createElement("li");
li.textContent = esc(a); li.textContent = esc(a);
ul.appendChild(li); ul.appendChild(li);
@@ -572,16 +563,13 @@
async function updatePanel(paraId) { async function updatePanel(paraId) {
currentParaId = paraId || currentParaId || ""; currentParaId = paraId || currentParaId || "";
if (elId) elId.textContent = currentParaId || "—"; if (elId) elId.textContent = currentParaId || "—";
flashUpdate();
hideMsg(msgHead); hideMsg(msgHead);
hideMsg(msgMedia); hideMsg(msgMedia);
hideMsg(msgComment); hideMsg(msgComment);
hideMsg(msgRef);
const idx = await loadIndex(); const idx = await loadIndex();
// ✅ message soft si lindex est indisponible (sans écraser le message dauth)
if (!idx && msgHead && msgHead.hidden) { if (!idx && msgHead && msgHead.hidden) {
msgHead.hidden = false; msgHead.hidden = false;
msgHead.textContent = "Index annotations indisponible (annotations-index.json)."; msgHead.textContent = "Index annotations indisponible (annotations-index.json).";
@@ -595,6 +583,7 @@
renderLevel4(data); renderLevel4(data);
} }
// ===== media "voir tous" =====
if (btnMediaAll) { if (btnMediaAll) {
bindClickOnce(btnMediaAll, (ev) => { bindClickOnce(btnMediaAll, (ev) => {
ev.preventDefault(); ev.preventDefault();
@@ -606,6 +595,7 @@
btnMediaAll.textContent = mediaShowAll ? "Réduire la liste" : "Voir tous les éléments"; btnMediaAll.textContent = mediaShowAll ? "Réduire la liste" : "Voir tous les éléments";
} }
// ===== media submit (readers + editors) =====
if (btnMediaSubmit) { if (btnMediaSubmit) {
bindClickOnce(btnMediaSubmit, (ev) => { bindClickOnce(btnMediaSubmit, (ev) => {
ev.preventDefault(); ev.preventDefault();
@@ -648,26 +638,27 @@
}); });
} }
if (btnRefSubmit) { // ===== référence submit (readers + editors) =====
if (btnRefSubmit) {
bindClickOnce(btnRefSubmit, (ev) => { bindClickOnce(btnRefSubmit, (ev) => {
ev.preventDefault(); ev.preventDefault();
hideMsg(msgRef); hideMsg(msgRef);
if (guardEventOnce(ev, "gitea_open_ref")) return; if (guardEventOnce(ev, "gitea_open_ref")) return;
if (!currentParaId) return showMsg(msgRef, "Choisis dabord un paragraphe (scroll / survol).", "warn"); if (!currentParaId) return showMsg(msgRef, "Choisis dabord un paragraphe (scroll / survol).", "warn");
if (!getG().ready) return showMsg(msgRef, "Gitea non configuré (PUBLIC_GITEA_*).", "error"); if (!getG().ready) return showMsg(msgRef, "Gitea non configuré (PUBLIC_GITEA_*).", "error");
if (btnRefSubmit.disabled) return showMsg(msgRef, "Connexion requise (readers/editors).", "error"); if (btnRefSubmit.disabled) return showMsg(msgRef, "Connexion requise (readers/editors).", "error");
const pageUrl = new URL(location.href); const pageUrl = new URL(location.href);
pageUrl.search = ""; pageUrl.search = "";
pageUrl.hash = currentParaId; pageUrl.hash = currentParaId;
const paraTxt = getParaText(currentParaId); const paraTxt = getParaText(currentParaId);
const excerpt = paraTxt.length > FULL_TEXT_SOFT_LIMIT ? (paraTxt.slice(0, FULL_TEXT_SOFT_LIMIT) + "…") : paraTxt; const excerpt = paraTxt.length > FULL_TEXT_SOFT_LIMIT ? (paraTxt.slice(0, FULL_TEXT_SOFT_LIMIT) + "…") : paraTxt;
const title = `[Reference] ${currentParaId} — ${docTitle}`; const title = `[Reference] ${currentParaId} — ${docTitle}`;
const body = [ const body = [
`Chemin: ${location.pathname}`, `Chemin: ${location.pathname}`,
`URL: ${pageUrl.toString()}`, `URL: ${pageUrl.toString()}`,
`Ancre: #${currentParaId}`, `Ancre: #${currentParaId}`,
@@ -685,16 +676,18 @@
``, ``,
`---`, `---`,
`Note: issue générée depuis le site (pré-remplissage).`, `Note: issue générée depuis le site (pré-remplissage).`,
].join("\n"); ].join("\n");
const url = buildIssueURL({ title, body }); const url = buildIssueURL({ title, body });
if (!url) return showMsg(msgRef, "Impossible de générer lissue.", "error"); if (!url) return showMsg(msgRef, "Impossible de générer lissue.", "error");
const ok = openOnce(`ref:${currentParaId}`, () => openNewTab(url)); const ok = openOnce(`ref:${currentParaId}`, () => openNewTab(url));
if (!ok) showMsg(msgRef, "Si rien ne souvre : autorise les popups pour ce site.", "error"); if (!ok) showMsg(msgRef, "Si rien ne souvre : autorise les popups pour ce site.", "error");
}); });
} }
// ===== commentaire (readers + editors) =====
if (btnSend) { if (btnSend) {
bindClickOnce(btnSend, (ev) => { bindClickOnce(btnSend, (ev) => {
ev.preventDefault(); ev.preventDefault();
@@ -746,31 +739,60 @@
}); });
} }
// ===== wiring: para courant (SOURCE OF TRUTH = EditionLayout) ===== // ===== wiring: para courant (aligné sur le paragraphe sous le reading-follow) =====
function onCurrentPara(ev) { function isPara(el) {
try { return Boolean(el && el.nodeType === 1 && el.matches && el.matches('.reading p[id^="p-"]'));
const id = ev?.detail?.id ? String(ev.detail.id) : "";
if (!id || !/^p-\d+-/i.test(id)) return;
if (id === currentParaId) return;
updatePanel(id);
} catch {}
} }
window.addEventListener("archicratie:currentPara", onCurrentPara);
const initial = String(location.hash || "").replace(/^#/, "").trim(); function pickParaAtY(y) {
const x = Math.max(0, Math.round(window.innerWidth * 0.5));
const candidates = [
document.elementFromPoint(x, y),
document.elementFromPoint(Math.min(window.innerWidth - 1, x + 60), y),
document.elementFromPoint(Math.max(0, x - 60), y),
].filter(Boolean);
if (/^p-\d+-/i.test(initial)) { for (const c of candidates) {
updatePanel(initial); if (isPara(c)) return c;
} else if (window.__archiCurrentParaId && /^p-\d+-/i.test(String(window.__archiCurrentParaId))) { const p = c.closest ? c.closest('.reading p[id^="p-"]') : null;
updatePanel(String(window.__archiCurrentParaId)); if (isPara(p)) return p;
} else { }
setTimeout(() => { return null;
try {
const id = String(window.__archiCurrentParaId || "").trim();
if (/^p-\d+-/i.test(id)) updatePanel(id);
} catch {}
}, 0);
} }
let _lastPicked = "";
function syncFromFollowLine() {
const off = Number(document.documentElement.style.getPropertyValue("--sticky-offset-px")) || 0;
const y = Math.round(off + 8);
const p = pickParaAtY(y);
if (!p || !p.id) return;
if (p.id === _lastPicked) return;
_lastPicked = p.id;
// met à jour l'app global (EditionLayout écoute déjà currentPara)
try { window.dispatchEvent(new CustomEvent("archicratie:currentPara", { detail: { id: p.id } })); } catch {}
// et met à jour le panel immédiatement (sans attendre)
updatePanel(p.id);
}
let ticking = false;
function onScroll() {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => {
ticking = false;
syncFromFollowLine();
});
}
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onScroll);
// Initial: hash > sinon calc
const initial = String(location.hash || "").replace(/^#/, "");
if (/^p-\d+-/i.test(initial)) updatePanel(initial);
else setTimeout(() => { try { syncFromFollowLine(); } catch {} }, 0);
})(); })();
</script> </script>
@@ -783,8 +805,6 @@
position: sticky; position: sticky;
top: calc(var(--sticky-header-h) + var(--page-gap)); top: calc(var(--sticky-header-h) + var(--page-gap));
align-self: start; align-self: start;
--thumb: 92px; /* ✅ taille des vignettes (80110 selon goût) */
} }
:global(body[data-reading-level="3"]) .page-panel{ :global(body[data-reading-level="3"]) .page-panel{
@@ -902,33 +922,28 @@
/* actions médias en haut */ /* actions médias en haut */
.panel-top-actions{ margin-top: 8px; } .panel-top-actions{ margin-top: 8px; }
/* ===== media thumbnails (plus petits + plus denses) ===== */ /* ===== media thumbnails (150x150) ===== */
.panel-media-grid{ .panel-media-grid{
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(var(--thumb), 1fr)); grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 10px; gap: 10px;
} }
.panel-media-tile{ .panel-media-tile{
width: 100%; width: 150px;
max-width: 100%;
border: 1px solid rgba(127,127,127,.20); border: 1px solid rgba(127,127,127,.20);
border-radius: 14px; border-radius: 14px;
padding: 8px; padding: 8px;
background: rgba(127,127,127,0.04); background: rgba(127,127,127,0.04);
cursor: pointer; cursor: pointer;
text-align: left; text-align: left;
transition: transform 120ms ease, background 120ms ease, border-color 120ms ease;
}
.panel-media-tile:hover{
transform: translateY(-1px);
background: rgba(127,127,127,0.07);
border-color: rgba(127,127,127,.32);
} }
.panel-media-tile img{ .panel-media-tile img{
width: 100%; width: 150px;
height: var(--thumb); height: 150px;
max-width: 100%;
object-fit: cover; object-fit: cover;
display: block; display: block;
border-radius: 10px; border-radius: 10px;
@@ -936,8 +951,8 @@
} }
.panel-media-ph{ .panel-media-ph{
width: 100%; width: 150px;
height: var(--thumb); height: 150px;
border-radius: 10px; border-radius: 10px;
display: grid; display: grid;
place-items: center; place-items: center;
@@ -980,11 +995,7 @@
resize: vertical; resize: vertical;
} }
/* ===== Lightbox (plein écran “cinéma”) ===== */ /* ===== Lightbox ===== */
:global(html.archi-lb-open){
overflow: hidden; /* ✅ empêche le scroll derrière */
}
.panel-lightbox{ .panel-lightbox{
position: fixed; position: fixed;
inset: 0; inset: 0;
@@ -994,66 +1005,58 @@
.panel-lightbox__overlay{ .panel-lightbox__overlay{
position: absolute; position: absolute;
inset: 0; inset: 0;
background: rgba(0,0,0,0.84); background: rgba(0,0,0,0.80);
backdrop-filter: blur(10px); backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(6px);
} }
.panel-lightbox__dialog{ .panel-lightbox__dialog{
position: absolute; position: absolute;
left: 50%; right: 24px;
top: 50%; top: calc(var(--sticky-header-h) + 16px);
transform: translate(-50%, -50%); width: min(520px, calc(100vw - 48px));
max-height: calc(100vh - (var(--sticky-header-h) + 32px));
width: min(1100px, 92vw);
max-height: 92vh;
overflow: auto; overflow: auto;
border: 1px solid rgba(255,255,255,0.14); border: 1px solid rgba(127,127,127,0.22);
border-radius: 18px; border-radius: 16px;
background: rgba(255,255,255,0.10);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
padding: 12px;
}
background: rgba(20,20,20,0.55); @media (prefers-color-scheme: dark){
backdrop-filter: blur(14px); .panel-lightbox__dialog{
-webkit-backdrop-filter: blur(14px); background: rgba(0,0,0,0.28);
}
padding: 16px;
box-shadow: 0 24px 70px rgba(0,0,0,0.55);
} }
.panel-lightbox__close{ .panel-lightbox__close{
position: absolute; position: sticky;
top: 12px; top: 0;
right: 12px; margin-left: auto;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 34px;
width: 44px; height: 30px;
height: 40px; border-radius: 10px;
border: 1px solid rgba(127,127,127,0.35);
border-radius: 14px; background: rgba(127,127,127,0.10);
border: 1px solid rgba(255,255,255,0.22);
background: rgba(255,255,255,0.10);
cursor: pointer; cursor: pointer;
font-size: 22px; font-size: 18px;
font-weight: 900; font-weight: 900;
} }
.panel-lightbox__content{
margin-top: 36px;
}
.panel-lightbox__content img, .panel-lightbox__content img,
.panel-lightbox__content video{ .panel-lightbox__content video{
display: block; display: block;
width: 100%; width: 100%;
max-height: calc(92vh - 160px); height: auto;
object-fit: contain; max-width: 1400px;
margin: 0 auto;
background: rgba(0,0,0,0.22); border-radius: 12px;
border-radius: 14px;
} }
.panel-lightbox__content audio{ .panel-lightbox__content audio{
@@ -1061,14 +1064,13 @@
} }
.panel-lightbox__caption{ .panel-lightbox__caption{
margin-top: 12px; margin-top: 10px;
font-size: 12px; font-size: 12px;
font-weight: 800; font-weight: 800;
opacity: .92; opacity: .92;
color: rgba(255,255,255,0.92);
} }
@media (max-width: 1100px){ @media (max-width: 1100px){
.page-panel{ display: none; } .page-panel{ display: none; }
} }
</style> </style>

View File

@@ -1,24 +1,11 @@
---
const path = Astro.url.pathname;
const isActive = (href: string) => {
if (href === "/") return path === "/";
return path === href || path.startsWith(href);
};
---
<nav class="site-nav" aria-label="Navigation principale"> <nav class="site-nav" aria-label="Navigation principale">
<a href="/" aria-current={isActive("/") ? "page" : undefined}>Accueil</a> <a href="/">Accueil</a><span aria-hidden="true"> · </span>
<span aria-hidden="true"> · </span> <a href="/editions/">Carte des œuvres</a><span aria-hidden="true"> · </span>
<a href="/methode/">Méthode</a><span aria-hidden="true"> · </span>
<a href="/archicrat-ia/" aria-current={isActive("/archicrat-ia/") ? "page" : undefined}>Essai-thèse — ArchiCraT-IA</a> <a href="/recherche/">Recherche</a><span aria-hidden="true"> · </span>
<span aria-hidden="true"> · </span> <a href="/archicrat-ia/">Essai-thèse</a><span aria-hidden="true"> · </span>
<a href="/traite/">Traité</a><span aria-hidden="true"> · </span>
<a href="/cas-ia/" aria-current={isActive("/cas-ia/") ? "page" : undefined}>Cas pratique — Gouvernance IA</a> <a href="/ia/">Cas IA</a><span aria-hidden="true"> · </span>
<span aria-hidden="true"> · </span> <a href="/glossaire/">Glossaire</a><span aria-hidden="true"> · </span>
<a href="/atlas/">Atlas</a>
<a href="/glossaire/" aria-current={isActive("/glossaire/") ? "page" : undefined}>Glossaire</a> </nav>
<span aria-hidden="true"> · </span>
<a href="/recherche/" aria-current={isActive("/recherche/") ? "page" : undefined}>Recherche</a>
</nav>

View File

@@ -1,143 +0,0 @@
import { defineCollection, z } from "astro:content";
const linkSchema = z.object({
type: z.enum(["definition", "appui", "transposition"]),
target: z.string().min(1),
note: z.string().optional()
});
const baseTextSchema = z.object({
title: z.string().min(1),
level: z.union([z.literal(1), z.literal(2), z.literal(3)]).default(1),
version: z.string().min(1),
concepts: z.array(z.string().min(1)).default([]),
links: z.array(linkSchema).default([]),
order: z.number().int().nonnegative().optional(),
summary: z.string().optional()
});
// Éditions (séparation stricte : edition + status verrouillés par collection)
const casIa = defineCollection({
type: "content",
schema: baseTextSchema.extend({
edition: z.literal("cas-ia"),
status: z.literal("application")
})
});
const commencer = defineCollection({
type: "content",
schema: baseTextSchema.extend({
edition: z.literal("commencer"),
status: z.union([z.literal("presentation"), z.literal("draft")])
})
});
// ✅ NOUVELLE collection : archicrat-ia (Essai-thèse)
// NOTE : on accepte temporairement edition/status "archicratie/modele_sociopolitique"
// si tes MDX nont pas encore été normalisés.
// Quand tu voudras "strict", on passera à edition="archicrat-ia" status="essai_these"
// + update frontmatter des 7 fichiers.
const archicratIa = defineCollection({
type: "content",
schema: baseTextSchema.extend({
edition: z.union([z.literal("archicrat-ia"), z.literal("archicratie")]),
status: z.union([z.literal("essai_these"), z.literal("modele_sociopolitique")])
})
});
const glossaryNavigationFlowSchema = z.object({
label: z.string().min(1),
primaryNext: z.string().min(1).optional(),
primaryReason: z.string().min(1).optional(),
});
const glossaryNavigationSchema = z.object({
primaryNext: z.string().min(1).optional(),
primaryReason: z.string().min(1).optional(),
paths: z
.object({
understand: z.array(z.string().min(1)).default([]),
deepen: z.array(z.string().min(1)).default([]),
compare: z.array(z.string().min(1)).default([]),
apply: z.array(z.string().min(1)).default([]),
})
.default({
understand: [],
deepen: [],
compare: [],
apply: [],
}),
flows: z
.record(z.string(), glossaryNavigationFlowSchema)
.default({}),
relationWeights: z
.record(z.string(), z.number().int().nonnegative())
.default({}),
});
// Glossaire (référentiel terminologique)
const glossaire = defineCollection({
type: "content",
schema: z.object({
title: z.string().min(1),
term: z.string().min(1),
aliases: z.array(z.string().min(1)).default([]),
urlAliases: z
.array(z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/))
.default([]),
mobilizedAuthors: z.array(z.string().min(1)).default([]),
comparisonTraditions: z.array(z.string().min(1)).default([]),
edition: z.literal("glossaire"),
status: z.literal("referentiel"),
version: z.string().min(1),
definitionShort: z.string().min(1),
concepts: z.array(z.string().min(1)).default([]),
links: z.array(linkSchema).default([]),
kind: z.enum([
"concept",
"topologie",
"diagnostic",
"verbe",
"paradigme",
"doctrine",
"dispositif",
"figure",
"qualification",
"epistemologie",
]),
family: z.enum([
"concept-fondamental",
"scene",
"dynamique",
"pathologie",
"topologie",
"meta-regime",
"paradigme",
"doctrine",
"verbe",
"dispositif-ia",
"tension-irreductible",
"figure",
"qualification",
"epistemologie",
]
)
.optional(),
domain: z.enum(["transversal", "theorie", "cas-ia"]),
level: z.enum(["fondamental", "intermediaire", "avance"]),
related: z.array(z.string().min(1)).default([]),
opposedTo: z.array(z.string().min(1)).default([]),
seeAlso: z.array(z.string().min(1)).default([]),
navigation: glossaryNavigationSchema.optional()
})
});
export const collections = {
commencer,
"archicrat-ia": archicratIa,
"cas-ia": casIa,
glossaire,
};

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More