Compare commits
250 Commits
chore/depl
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8605b7198f | |||
| d41aed040f | |||
| bf01a83268 | |||
| 5b427d5602 | |||
| fa46971e76 | |||
| c313587b26 | |||
| 4976ddcc16 | |||
| 17e11f0322 | |||
| 7df18adfa8 | |||
| 535c5108e2 | |||
| 20705f6c90 | |||
| eabd2f5f29 | |||
| 482151c31c | |||
| 6d9d5a460e | |||
| 89d06ade16 | |||
| 69b35df10c | |||
| b5475e9be1 | |||
| fdd3aace5a | |||
| f86704d67e | |||
| ec8e29a313 | |||
| 1dc9a60580 | |||
| ee18b26d03 | |||
| 5f4a0f74db | |||
| 6b17df7320 | |||
| 0c33495342 | |||
| d8a09b1def | |||
| 39af501ea0 | |||
| 4c821d9e83 | |||
| deb4a91348 | |||
| 5b36b8e54e | |||
| eda5a877ef | |||
| 5b615a6999 | |||
| 99cf0947da | |||
| dbd1e14e4e | |||
| 7033354011 | |||
| 7345730e3c | |||
| cea94c56db | |||
| c1e24736e3 | |||
| 24bbfbc17f | |||
| a11e2f1d18 | |||
| 630b146d02 | |||
| 551360db83 | |||
| a96c282780 | |||
| d2e0f147c2 | |||
| ad95364021 | |||
| e48e322363 | |||
| a9f2a5bbd4 | |||
| 0cba8f868e | |||
| f8e3ee4cca | |||
| 92e0ad01c6 | |||
| e6c18d6b16 | |||
| a3092f5d5b | |||
| 7187b69935 | |||
| 4ba4453661 | |||
| ee42e391e3 | |||
| f7756be59e | |||
| 4abe70e10e | |||
| b2b4ec35c0 | |||
| b255436958 | |||
| ad06b34a85 | |||
| a38f585f3d | |||
| bf0dc125d1 | |||
| f61dc15b47 | |||
| 1ac3d91a19 | |||
| 100ba10409 | |||
| 5f14785abb | |||
| c7043ae9d5 | |||
| bd1235f8c3 | |||
| 7ae7b4dca3 | |||
| f088db57d4 | |||
| 311e94ed91 | |||
| e078f3f9ab | |||
| 7c4bb5a2cf | |||
| 214e174635 | |||
| f1b2f4605f | |||
| 87955adf5d | |||
| e39a0c547d | |||
| c89ddf7237 | |||
| 615effe8bf | |||
| e952b344a0 | |||
| bb0572cc1a | |||
| f6a2347278 | |||
| d902c2bf98 | |||
| baa2082f51 | |||
| 2f249b420f | |||
| d6b4eb82f4 | |||
| bfa44fecda | |||
| e329235aa9 | |||
| 8cbaa5117c | |||
| 3086f333ed | |||
| c1c3c19d13 | |||
| ddcd0acd4d | |||
| 9bc4eeb3e7 | |||
| 7a9a5319ac | |||
| 7d75de5c9f | |||
| 69c91cb661 | |||
| a1bfbf4405 | |||
|
|
be26b425d8 | ||
|
|
abf88e7037 | ||
| 04fee32fdb | |||
|
|
fbddf5c3fc | ||
|
|
bad748df3a | ||
| 0066cf8601 | |||
| 5d3473d66c | |||
| f9d34110e4 | |||
|
|
84e9c3ead4 | ||
|
|
72e59175fc | ||
| 81b69ac6d5 | |||
| 513ae72e85 | |||
| 4c4dd1c515 | |||
| 46b15ed6ab | |||
| a015e72f7c | |||
|
|
d5df7d77a0 | ||
| ec3ceee862 | |||
| 867475c3ff | |||
| b024c5557c | |||
| 93306f360d | |||
| 52847d999d | |||
| b9629b43ff | |||
| 06482a9f8d | |||
| f2e4ae5ac2 | |||
| 71baf0f6da | |||
| d02b6fc347 | |||
| 431f1e347b | |||
| ab6f45ed5c | |||
| 02c060d239 | |||
| be2029de82 | |||
| e148eaeaf3 | |||
| c63a1e6ce4 | |||
| b3a73a7781 | |||
| 1968585d0f | |||
| b33c758411 | |||
| afa543125c | |||
|
|
0d0252cac0 | ||
|
|
a8bd9aeed5 | ||
| d277c61afd | |||
|
|
86479952d1 | ||
| c94024a8ae | |||
| 70611d16f8 | |||
| 354db231b8 | |||
| 9d8d60d00f | |||
| f5d25abbec | |||
| 8e9f7314f5 | |||
| 03b88b944d | |||
| 385c36f660 | |||
| cfa092cd38 | |||
| 1a762f8f54 | |||
| fbdaf72775 | |||
| 67128a9ca1 | |||
| 898759db3d | |||
| 4f009a9557 | |||
| 378d0981f0 | |||
| 8f3702f803 | |||
| cfd303fc85 | |||
| 0fc0976f8a | |||
| e247ea8ead | |||
| 0c57c4bc6d | |||
| 9b7998e1c3 | |||
| 8997a00413 | |||
| a2e6f6185f | |||
| c2715b01d7 | |||
| 6f09dfcd12 | |||
| bb9f55a3b5 | |||
| 298ee7492c | |||
| 37cb836246 | |||
| 19e3318125 | |||
| 683b02f4a0 | |||
| 20aecc30b1 | |||
| daf57aa152 | |||
| bfd693de92 | |||
| ea2ad0017b | |||
| 82e7473cac | |||
| 315523e80f | |||
| 569b6de154 | |||
| 95f8159554 | |||
|
|
5698c494f1 | ||
| e640e66b8d | |||
|
|
9be7d170c6 | ||
| c2c98c516b | |||
| 32554f5998 | |||
| 308f4f92bc | |||
| 4dfd3b026b | |||
| c93f274f41 | |||
| dfa311fb5b | |||
| 3ef1dc2801 | |||
| 435e41ed4d | |||
| 8825932159 | |||
| b55decbea4 | |||
| 414a848db3 | |||
| cbd4f3a57f | |||
| 49f8d6a95e | |||
| 5afa5cbfda | |||
| a1b1df38ba | |||
| d3f7d74da7 | |||
| 6919190107 | |||
| 021ef5abd7 | |||
| 76cdc85f9c | |||
| f2f6df2127 | |||
| dfe13757f7 | |||
| 148ac997df | |||
| 84492d2741 | |||
| 81baadd57f | |||
| 63d0ffc5fc | |||
| 24143fc2c4 | |||
| 55370b704f | |||
| b8a3ce1337 | |||
| 7f9baedf41 | |||
| 1adbe1c7a3 | |||
| 107a26352f | |||
| 1c2b9ddbb6 | |||
| be99460d4d | |||
| 9e1b704aa6 | |||
| 941fbf5845 | |||
| 0b4a31a432 | |||
| c617dc3979 | |||
| 1b95161de0 | |||
| ebd976bd46 | |||
| f8d57d8fe0 | |||
| 09a4d2c472 | |||
| 1f6dc874d0 | |||
| 4dd63945ee | |||
| ba64b0694b | |||
| 58e5ceda59 | |||
| 08f826ee01 | |||
| 3358d280ec | |||
| 9cb0d5e416 | |||
| a46f058917 | |||
| 604b2199da | |||
| d153f71be6 | |||
| 8f64e4b098 | |||
| 459bf195d8 | |||
| 0c46b0d19b | |||
| bfbdc7b688 | |||
| 8fd53dd4d2 | |||
|
|
c8bbee4f74 | ||
| 04cdf54eb7 | |||
|
|
d6bf645ae9 | ||
| 1ca6bcbd81 | |||
| dec5f8eba7 | |||
| 716c887045 | |||
| 9b1789a164 | |||
| 17fa39c7ff | |||
| 8132e315f4 | |||
| 8d993915d7 | |||
| 497bddd05d | |||
| 7c8e49c1a9 | |||
| 901d28b89b | |||
| 43e2862c89 | |||
| 73fb38c4d1 | |||
| 9801ea3cea |
@@ -17,7 +17,7 @@ defaults:
|
|||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: anno-apply-${{ github.event.issue.number || inputs.issue || 'manual' }}
|
group: anno-apply-${{ github.event.issue.number || github.event.issue.index || inputs.issue || 'manual' }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -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 }}
|
FORGE_API: ${{ vars.FORGE_API || vars.FORGE_BASE || vars.FORGE_BASE_URL }}
|
||||||
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,7 +66,10 @@ jobs:
|
|||||||
|
|
||||||
if (!owner || !repo) {
|
if (!owner || !repo) {
|
||||||
const m = cloneUrl.match(/[:/](?<o>[^/]+)\/(?<r>[^/]+?)(?:\.git)?$/);
|
const m = cloneUrl.match(/[:/](?<o>[^/]+)\/(?<r>[^/]+?)(?:\.git)?$/);
|
||||||
if (m?.groups) { owner = owner || m.groups.o; repo = repo || m.groups.r; }
|
if (m?.groups) {
|
||||||
|
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");
|
||||||
|
|
||||||
@@ -81,10 +84,11 @@ 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");
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
|
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;
|
||||||
@@ -93,7 +97,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)}`,
|
||||||
@@ -106,41 +110,52 @@ 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: Gate on label state/approved
|
- name: Early gate (label event fast-skip, but tolerant)
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
source /tmp/anno.env
|
source /tmp/anno.env
|
||||||
|
|
||||||
if [[ "$LABEL_NAME" != "state/approved" && "$LABEL_NAME" != "workflow_dispatch" ]]; then
|
echo "event label = $LABEL_NAME"
|
||||||
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)"
|
|
||||||
|
|
||||||
- name: Fetch issue + gate on Type (skip Proposer)
|
echo "continue to API gating (issue=$ISSUE_NUMBER)"
|
||||||
|
|
||||||
|
- 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; }
|
||||||
|
|
||||||
ISSUE_JSON="$(curl -fsS \
|
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' "$ISSUE_JSON" >> /tmp/anno.env
|
node --input-type=module - <<'NODE' >> /tmp/anno.env
|
||||||
const issue = JSON.parse(process.argv[1] || "{}");
|
import fs from "node:fs";
|
||||||
const title = String(issue.title || "");
|
|
||||||
|
const issue = JSON.parse(fs.readFileSync("/tmp/issue.json", "utf8"));
|
||||||
const body = String(issue.body || "").replace(/\r\n/g, "\n");
|
const body = String(issue.body || "").replace(/\r\n/g, "\n");
|
||||||
|
|
||||||
|
const labels = Array.isArray(issue.labels)
|
||||||
|
? issue.labels.map(l => String(l.name || "")).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
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");
|
||||||
const m = body.match(re);
|
const m = body.match(re);
|
||||||
@@ -150,33 +165,39 @@ jobs:
|
|||||||
const typeRaw = pickLine("Type");
|
const typeRaw = pickLine("Type");
|
||||||
const type = String(typeRaw || "").trim().toLowerCase();
|
const type = String(typeRaw || "").trim().toLowerCase();
|
||||||
|
|
||||||
const allowed = new Set(["type/media","type/reference","type/comment"]);
|
const allowedAnno = new Set(["type/media", "type/reference", "type/comment"]);
|
||||||
const proposer = new Set(["type/correction","type/fact-check"]);
|
const proposerTypes = 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 (allowed.has(type)) {
|
} else if (allowedAnno.has(type)) {
|
||||||
// proceed
|
// proceed
|
||||||
} else if (proposer.has(type)) {
|
} else if (proposerTypes.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 "✅ issue type gating:"
|
echo "gating result:"
|
||||||
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 (Proposer / unsupported / missing Type)
|
- name: Comment issue if skipped (unsupported / missing Type only)
|
||||||
if: ${{ always() }}
|
if: ${{ always() }}
|
||||||
env:
|
env:
|
||||||
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
|
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
|
||||||
@@ -185,22 +206,29 @@ 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
|
|
||||||
|
|
||||||
# message différent si Proposer
|
if [[ "${SKIP_REASON:-}" == "not_approved_label_present" || "${SKIP_REASON:-}" == "label_not_approved_event" ]]; then
|
||||||
REASON="${SKIP_REASON:-}"
|
echo "skip reason=${SKIP_REASON} -> no comment"
|
||||||
TYPE="${ISSUE_TYPE:-}"
|
exit 0
|
||||||
TITLE="${ISSUE_TITLE:-}"
|
|
||||||
|
|
||||||
if [[ "$REASON" == proposer_type:* ]]; then
|
|
||||||
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
|
|
||||||
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")"
|
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:-}"
|
||||||
|
TYPE="${ISSUE_TYPE:-}"
|
||||||
|
|
||||||
|
if [[ "$REASON" == unsupported_type:* ]]; then
|
||||||
|
MSG="Ticket #${ISSUE_NUMBER} ignored: unsupported Type (${TYPE}). Supported types: type/media, type/reference, type/comment."
|
||||||
|
else
|
||||||
|
MSG="Ticket #${ISSUE_NUMBER} ignored: missing or unreadable 'Type:'. Expected: type/media|type/reference|type/comment"
|
||||||
|
fi
|
||||||
|
|
||||||
|
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" \
|
||||||
@@ -212,7 +240,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
|
||||||
@@ -225,16 +253,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
|
||||||
}
|
}
|
||||||
@@ -243,16 +271,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
|
||||||
|
|
||||||
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
|
||||||
@@ -263,9 +291,10 @@ 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}"
|
||||||
@@ -312,43 +341,24 @@ 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
|
||||||
|
|
||||||
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$//')"
|
||||||
else
|
else
|
||||||
BODY="(no apply log found)"
|
BODY="(no apply log found)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
MSG="❌ apply-annotation-ticket a échoué (rc=${RC}).\n\n\`\`\`\n${BODY}\n\`\`\`\n"
|
MSG="apply-annotation-ticket failed (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
|
|
||||||
|
|
||||||
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" \
|
||||||
@@ -365,8 +375,9 @@ 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; }
|
||||||
|
|
||||||
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);
|
||||||
@@ -388,8 +399,8 @@ 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; }
|
||||||
|
|
||||||
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."
|
||||||
@@ -410,10 +421,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 créée pour ticket #${ISSUE_NUMBER} : ${PR_URL}"
|
MSG="PR created for 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" \
|
||||||
@@ -421,7 +432,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() }}
|
||||||
@@ -429,11 +440,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"
|
||||||
@@ -17,7 +17,7 @@ defaults:
|
|||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: anno-reject-${{ github.event.issue.number || inputs.issue || 'manual' }}
|
group: anno-reject-${{ github.event.issue.number || github.event.issue.index || inputs.issue || 'manual' }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -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 }}
|
FORGE_API: ${{ vars.FORGE_API || vars.FORGE_BASE || vars.FORGE_BASE_URL }}
|
||||||
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,10 +75,11 @@ 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");
|
||||||
}
|
}
|
||||||
|
|
||||||
const labelName =
|
// label name: best-effort (non-bloquant)
|
||||||
ev?.label?.name ||
|
let labelName = "workflow_dispatch";
|
||||||
ev?.label ||
|
const lab = ev?.label;
|
||||||
"workflow_dispatch";
|
if (typeof lab === "string") labelName = lab;
|
||||||
|
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()) {
|
||||||
@@ -103,19 +104,20 @@ jobs:
|
|||||||
echo "✅ context:"
|
echo "✅ context:"
|
||||||
sed -n '1,120p' /tmp/reject.env
|
sed -n '1,120p' /tmp/reject.env
|
||||||
|
|
||||||
- name: Gate on label state/rejected only
|
- name: Early gate (fast-skip, tolerant)
|
||||||
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" ]]; then
|
if [[ "$LABEL_NAME" != "state/rejected" && "$LABEL_NAME" != "workflow_dispatch" && "$LABEL_NAME" != "" && "$LABEL_NAME" != "[object Object]" ]]; then
|
||||||
echo "ℹ️ label=$LABEL_NAME => skip"
|
echo "ℹ️ label=$LABEL_NAME => skip early"
|
||||||
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 not conflicting with state/approved)
|
- name: Comment + close (only if label state/rejected is PRESENT now, and no conflict)
|
||||||
env:
|
env:
|
||||||
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
|
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
@@ -126,14 +128,15 @@ 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; }
|
||||||
|
|
||||||
ISSUE_JSON="$(curl -fsS \
|
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
|
||||||
|
|
||||||
# conflict guard: approved + rejected => do nothing, comment warning
|
node --input-type=module - <<'NODE' > /tmp/reject.flags
|
||||||
node --input-type=module - <<'NODE' "$ISSUE_JSON" > /tmp/reject.flags
|
import fs from "node:fs";
|
||||||
const issue = JSON.parse(process.argv[1] || "{}");
|
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 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");
|
||||||
@@ -142,6 +145,12 @@ jobs:
|
|||||||
|
|
||||||
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")"
|
||||||
@@ -154,7 +163,6 @@ jobs:
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# comment reject
|
|
||||||
MSG="❌ Ticket #${ISSUE_NUMBER} refusé (label state/rejected)."
|
MSG="❌ Ticket #${ISSUE_NUMBER} refusé (label state/rejected)."
|
||||||
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")"
|
||||||
|
|
||||||
@@ -164,7 +172,6 @@ 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 "$PAYLOAD"
|
--data-binary "$PAYLOAD"
|
||||||
|
|
||||||
# close issue
|
|
||||||
curl -fsS -X PATCH \
|
curl -fsS -X PATCH \
|
||||||
-H "Authorization: token $FORGE_TOKEN" \
|
-H "Authorization: token $FORGE_TOKEN" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
|
|||||||
@@ -4,22 +4,37 @@ 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: mac-ci
|
||||||
|
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:
|
||||||
FORGE_BASE: ${{ vars.FORGE_API || vars.FORGE_BASE }}
|
# IMPORTANT: préfère FORGE_BASE (LAN) si défini, sinon FORGE_API
|
||||||
|
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, urllib.request, urllib.error
|
import json, os, re, time, urllib.request, urllib.error, socket
|
||||||
|
|
||||||
|
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"]
|
||||||
|
|
||||||
@@ -46,12 +61,9 @@ 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:
|
if t: desired.add(t)
|
||||||
desired.add(t)
|
if s: desired.add(s)
|
||||||
if s:
|
if c: desired.add(c)
|
||||||
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:
|
||||||
@@ -76,42 +88,56 @@ 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.0",
|
"User-Agent": "archicratie-auto-label/1.1",
|
||||||
}
|
}
|
||||||
|
|
||||||
def jreq(method, url, payload=None):
|
def jreq(method, url, payload=None, timeout=60, retries=4, backoff=2.0):
|
||||||
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")
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
last_err = None
|
||||||
try:
|
for i in range(retries):
|
||||||
with urllib.request.urlopen(req, timeout=20) as r:
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
b = r.read()
|
try:
|
||||||
return json.loads(b.decode("utf-8")) if b else None
|
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||||
except urllib.error.HTTPError as e:
|
b = r.read()
|
||||||
b = e.read().decode("utf-8", errors="replace")
|
return json.loads(b.decode("utf-8")) if b else None
|
||||||
raise RuntimeError(f"HTTP {e.code} {method} {url}\n{b}") from e
|
except urllib.error.HTTPError as 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") or []
|
labels = jreq("GET", f"{api}/repos/{owner}/{repo}/labels?limit=1000", timeout=60) 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 = [name_to_id[x] for x in desired]
|
wanted_ids = sorted({int(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") or []
|
current = jreq("GET", f"{api}/repos/{owner}/{repo}/issues/{number}/labels", timeout=60) or []
|
||||||
current_ids = {x.get("id") for x in current if x.get("id") is not None}
|
current_ids = {int(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))
|
||||||
|
|
||||||
# set labels = union (n'enlève rien)
|
# Replace 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:
|
|
||||||
jreq("PUT", url, {"labels": final_ids})
|
# IMPORTANT: on n'envoie JAMAIS une liste brute ici (ça a causé le 422)
|
||||||
except Exception:
|
jreq("PUT", url, {"labels": final_ids}, timeout=90, retries=4)
|
||||||
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
|
||||||
@@ -28,7 +28,7 @@ jobs:
|
|||||||
deploy:
|
deploy:
|
||||||
runs-on: nas-deploy
|
runs-on: nas-deploy
|
||||||
container:
|
container:
|
||||||
image: localhost:5000/archicratie/nas-deploy-node22:2026-02-28-1
|
image: localhost:5000/archicratie/nas-deploy-node22@sha256:fefa8bb307005cebec07796661ab25528dc319c33a8f1e480e1d66f90cd5cff6
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Tools sanity
|
- name: Tools sanity
|
||||||
@@ -47,86 +47,168 @@ 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()) ||
|
||||||
ev?.after ||
|
String(ev?.after || ev?.sha || ev?.head_commit?.id || ev?.pull_request?.head?.sha || "").trim();
|
||||||
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)}`,
|
||||||
`SHA=${shq(sha)}`
|
`BEFORE=${shq(before)}`,
|
||||||
|
`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 "SHA: ${SHA:-<empty>}"
|
echo "BEFORE: ${BEFORE:-<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"
|
||||||
|
|
||||||
if [[ -n "${SHA:-}" ]]; then
|
# Checkout AFTER (or default branch if missing)
|
||||||
git fetch --depth 1 origin "$SHA"
|
if [[ -n "${AFTER:-}" ]]; then
|
||||||
|
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 1 origin "$DEFAULT_BRANCH"
|
git fetch --depth 50 origin "$DEFAULT_BRANCH"
|
||||||
git -c advice.detachedHead=false checkout -q "origin/$DEFAULT_BRANCH"
|
git -c advice.detachedHead=false checkout -q "origin/$DEFAULT_BRANCH"
|
||||||
SHA="$(git rev-parse HEAD)"
|
AFTER="$(git rev-parse HEAD)"
|
||||||
echo "SHA='$SHA'" >> /tmp/deploy.env
|
echo "AFTER='$AFTER'" >> /tmp/deploy.env
|
||||||
echo "Resolved SHA: $SHA"
|
echo "Resolved AFTER: $AFTER"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
git log -1 --oneline
|
git log -1 --oneline
|
||||||
|
|
||||||
- name: Gate — decide HOTPATCH vs FULL rebuild
|
- name: Gate — decide SKIP vs 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}"
|
||||||
|
|
||||||
# liste fichiers touchés (utile pour copier les médias)
|
# Lire before/after du push depuis event.json (merge-proof)
|
||||||
CHANGED="$(git show --name-only --pretty="" "$SHA" | sed '/^$/d' || true)"
|
node --input-type=module <<'NODE'
|
||||||
printf "%s\n" "$CHANGED" > /tmp/changed.txt
|
import fs from "node:fs";
|
||||||
|
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
|
||||||
|
|
||||||
echo "== changed files =="
|
source /tmp/gate.env
|
||||||
echo "$CHANGED" | sed -n '1,260p'
|
|
||||||
|
|
||||||
if [[ "$FORCE" == "1" ]]; then
|
BEFORE="${EV_BEFORE:-}"
|
||||||
echo "GO=1" >> /tmp/deploy.env
|
AFTER="${EV_AFTER:-}"
|
||||||
echo "MODE='full'" >> /tmp/deploy.env
|
if [[ -z "${AFTER:-}" ]]; then
|
||||||
echo "✅ force=1 -> MODE=full (rebuild+restart)"
|
AFTER="${SHA:-}"
|
||||||
exit 0
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Auto mode: uniquement annotations/media => hotpatch only
|
echo "Gate ctx: BEFORE=${BEFORE:-<empty>} AFTER=${AFTER:-<empty>} FORCE=${FORCE}"
|
||||||
if echo "$CHANGED" | grep -qE '^(src/annotations/|public/media/)'; then
|
|
||||||
echo "GO=1" >> /tmp/deploy.env
|
# Produire une liste CHANGED fiable :
|
||||||
echo "MODE='hotpatch'" >> /tmp/deploy.env
|
# - 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 / l’image
|
||||||
|
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)"
|
||||||
|
elif [[ "${HAS_FULL}" == "1" ]]; then
|
||||||
|
GO=1
|
||||||
|
MODE="full"
|
||||||
|
echo "✅ build-impacting change -> MODE=full (rebuild+restart)"
|
||||||
|
elif [[ "${HAS_HOTPATCH}" == "1" ]]; then
|
||||||
|
GO=1
|
||||||
|
MODE="hotpatch"
|
||||||
echo "✅ annotations/media change -> MODE=hotpatch"
|
echo "✅ annotations/media change -> MODE=hotpatch"
|
||||||
else
|
else
|
||||||
echo "GO=0" >> /tmp/deploy.env
|
GO=0
|
||||||
echo "MODE='skip'" >> /tmp/deploy.env
|
MODE="skip"
|
||||||
echo "ℹ️ no annotations/media change -> skip deploy"
|
echo "ℹ️ no relevant change -> skip deploy"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
echo "GO=${GO}" >> /tmp/deploy.env
|
||||||
|
echo "MODE='${MODE}'" >> /tmp/deploy.env
|
||||||
|
|
||||||
- name: Toolchain sanity + resolve COMPOSE_PROJECT_NAME
|
- name: Toolchain sanity + resolve COMPOSE_PROJECT_NAME
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -215,6 +297,19 @@ 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
|
||||||
@@ -224,6 +319,11 @@ 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/' || {
|
||||||
@@ -271,6 +371,19 @@ 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
|
||||||
@@ -284,6 +397,11 @@ 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/' || {
|
||||||
|
|||||||
788
.gitea/workflows/proposer-apply-pr.yml
Normal file
788
.gitea/workflows/proposer-apply-pr.yml
Normal file
@@ -0,0 +1,788 @@
|
|||||||
|
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"
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -28,3 +28,7 @@ public/favicon_io.zip
|
|||||||
|
|
||||||
# macOS
|
# macOS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
# local temp workspace
|
||||||
|
.tmp/
|
||||||
|
public/__ops/health.json
|
||||||
|
|||||||
@@ -86,6 +86,10 @@ 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",
|
||||||
|
|||||||
11
config/anchor-churn-allowlist.json
Normal file
11
config/anchor-churn-allowlist.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"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."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,19 @@ 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 qu’un 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.
|
||||||
|
|||||||
1393
docs/OPS-LOCALHOST-AUTO-SYNC.md
Normal file
1393
docs/OPS-LOCALHOST-AUTO-SYNC.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -203,3 +203,32 @@ 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 qu’un 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 qu’un 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
|
||||||
|
|
||||||
|
|||||||
@@ -1,51 +1,147 @@
|
|||||||
# START-HERE — Archicratie / Édition Web (v2)
|
# START-HERE — Archicratie / Édition Web (v3)
|
||||||
> Onboarding + exploitation “nickel chrome” (DEV → Gitea → CI → Release → Blue/Green → Edge/SSO)
|
> Onboarding + exploitation “nickel chrome” (DEV → Gitea → CI → Release → Blue/Green → Edge/SSO → localhost auto-sync)
|
||||||
|
|
||||||
## 0) TL;DR (la règle d’or)
|
## 0) TL;DR (la règle d’or)
|
||||||
|
|
||||||
- **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 n’est pas la source** : si un hotfix est fait sur NAS, on **backporte** via PR immédiatement.
|
- **Le NAS n’est 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), l’accès est contrôlé au niveau reverse-proxy (Traefik + Authelia).
|
- **Le site est statique Astro** : la prod sert du HTML via nginx ; l’accès est contrôlé au niveau reverse-proxy (Traefik + Authelia).
|
||||||
|
- **Le localhost automatique n’est 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
|
|
||||||
- **Gitea** : dépôt canon + PR + CI (CI.yaml)
|
- **DEV canonique (Mac Studio)** : édition, dev, tests, commits, pushes
|
||||||
- **NAS (DS220+)** : déploiement “blue/green”
|
- **Gitea** : dépôt canonique, PR, CI, workflows éditoriaux
|
||||||
- `web_blue` (staging upstream) → `127.0.0.1:8081`
|
- **NAS (DS220+)** : déploiement blue/green
|
||||||
- `web_green` (live upstream) → `127.0.0.1:8082`
|
- `web_blue` → staging upstream → `127.0.0.1:8081`
|
||||||
- **Edge (Traefik)** : route les hosts
|
- `web_green` → live upstream → `127.0.0.1:8082`
|
||||||
|
- **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/pages/**` : routes Astro (index, [...slug], etc.)
|
- `src/content/**` : contenu MD / MDX canon
|
||||||
- `src/components/**` : composants UI (SiteNav, TOC, SidePanel, etc.)
|
- `src/pages/**` : routes Astro
|
||||||
- `src/layouts/**` : layouts (EditionLayout, SiteLayout)
|
- `src/components/**` : composants UI
|
||||||
|
- `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 : `src/annotations/archicrat-ia/prologue.yml`
|
- Exemple :
|
||||||
- Objectif : stocker “Références / Médias / Commentaires” par page et par paragraphe (`p-...`).
|
`src/annotations/archicrat-ia/prologue.yml`
|
||||||
|
|
||||||
|
Objectif :
|
||||||
|
stocker “Références / Médias / Commentaires” par page et par paragraphe (`p-...`).
|
||||||
|
|
||||||
### 2.3 Scripts (tooling / build)
|
### 2.3 Scripts (tooling / build)
|
||||||
- `scripts/inject-anchor-aliases.mjs` : injection aliases dans dist
|
|
||||||
- `scripts/dedupe-ids-dist.mjs` : retire IDs dupliqués dans dist
|
- `scripts/inject-anchor-aliases.mjs` : injection aliases dans `dist`
|
||||||
- `scripts/build-para-index.mjs` : index paragraphes (postbuild / predev)
|
- `scripts/dedupe-ids-dist.mjs` : retrait IDs dupliqués
|
||||||
- `scripts/build-annotations-index.mjs` : index annotations (postbuild / predev)
|
- `scripts/build-para-index.mjs` : index paragraphes
|
||||||
- `scripts/check-anchors.mjs` : contrat stabilité d’ancres (CI)
|
- `scripts/build-annotations-index.mjs` : index annotations
|
||||||
|
- `scripts/check-anchors.mjs` : contrat stabilité d’ancres
|
||||||
- `scripts/check-annotations*.mjs` : sanity YAML + médias
|
- `scripts/check-annotations*.mjs` : sanity YAML + médias
|
||||||
|
|
||||||
> Important : les scripts sont **partie intégrante** de la stabilité (IDs/ancres/indexation).
|
> Important : ces scripts ne sont pas accessoires.
|
||||||
> On évite “la magie” : tout est scripté + vérifié.
|
> Ils font partie du contrat de stabilité éditoriale.
|
||||||
|
|
||||||
## 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 d’espace de développement
|
||||||
|
|
||||||
|
### 3.3 Ops local hors repo
|
||||||
|
|
||||||
|
```text
|
||||||
|
/Users/s-funia/ops-local/archicratie
|
||||||
|
```
|
||||||
|
|
||||||
|
Usage :
|
||||||
|
|
||||||
|
- scripts d’exploitation
|
||||||
|
- é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
|
||||||
|
|
||||||
|
C’est 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
|
||||||
|
|
||||||
@@ -60,37 +156,48 @@ 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"
|
||||||
|
```
|
||||||
|
|
||||||
### 3.2 PR vers main
|
### 5.2 PR vers `main`
|
||||||
|
|
||||||
Ouvrir PR dans Gitea
|
- ouvrir une PR dans Gitea
|
||||||
|
- attendre une CI verte
|
||||||
|
- merger
|
||||||
|
- laisser les workflows faire le reste
|
||||||
|
|
||||||
CI doit être verte
|
### 5.3 Cas spécial : hotfix prod (NAS)
|
||||||
|
|
||||||
Merge PR → main
|
On peut faire un hotfix d’urgence côté NAS si nécessaire.
|
||||||
|
|
||||||
### 3.3 Cas spécial : hotfix prod (NAS)
|
Mais l’état final doit toujours revenir dans Gitea :
|
||||||
|
|
||||||
On peut faire un hotfix “urgence” en prod/staging si nécessaire…
|
- branche
|
||||||
|
- PR
|
||||||
|
- CI
|
||||||
|
- merge
|
||||||
|
|
||||||
MAIS : l’état final doit revenir dans Gitea : branche → PR → CI → merge.
|
---
|
||||||
|
|
||||||
## 4) Déploiement (NAS) — principe
|
## 6) Déploiement (NAS) — principe
|
||||||
### 4.1 Release pack
|
|
||||||
|
|
||||||
On génère un pack “reproductible” (source + config + scripts) puis on déploie.
|
### 6.1 Release pack
|
||||||
|
|
||||||
### 4.2 Blue/Green
|
On génère un pack reproductible, puis on déploie.
|
||||||
|
|
||||||
web_blue = staging upstream (8081)
|
### 6.2 Blue/Green
|
||||||
|
|
||||||
web_green = live upstream (8082)
|
- `web_blue` = staging (`8081`)
|
||||||
|
- `web_green` = live (`8082`)
|
||||||
|
|
||||||
Edge Traefik sélectionne quel host pointe vers quel upstream.
|
Le reverse-proxy choisit l’upstream selon le host demandé.
|
||||||
|
|
||||||
## 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)
|
||||||
|
|
||||||
@@ -99,55 +206,258 @@ 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
|
||||||
|
```
|
||||||
|
|
||||||
### 5.2 Push + PR
|
### 7.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
|
```
|
||||||
|
|
||||||
### 5.3 Déploiement NAS (résumé)
|
Puis ouvrir la PR dans Gitea.
|
||||||
|
|
||||||
Voir docs/runbooks/DEPLOY-BLUE-GREEN.md.
|
### 7.3 Déploiement NAS
|
||||||
|
|
||||||
## 6) Problèmes “classiques” + diagnostic rapide
|
Voir :
|
||||||
### 6.1 “Le staging ne ressemble pas au local”
|
|
||||||
|
|
||||||
# Comparer upstream direct 8081 vs 8082 :
|
```text
|
||||||
|
docs/runbooks/DEPLOY-BLUE-GREEN.md
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8) Localhost auto-sync — ce qu’il 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 l’agent 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 d’exploitation du localhost automatique, lire :
|
||||||
|
|
||||||
|
```text
|
||||||
|
docs/OPS-LOCALHOST-AUTO-SYNC.md
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9) Règle d’or : 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 qu’il 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 d’expé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 d’ops 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 l’architecture actuelle.
|
||||||
|
Le supprimer reviendrait à réintroduire le flou entre sync Git et exécution d’Astro.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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 quel routeur edge répond (header diag) :
|
Vérifier le routeur edge :
|
||||||
|
|
||||||
|
```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'
|
||||||
|
```
|
||||||
|
|
||||||
# Lire docs/runbooks/EDGE-TRAEFIK.md.
|
Voir :
|
||||||
|
|
||||||
### 6.2 Canonical incorrect (localhost en prod)
|
```text
|
||||||
|
docs/runbooks/EDGE-TRAEFIK.md
|
||||||
|
```
|
||||||
|
|
||||||
Cause racine : site dans Astro = PUBLIC_SITE non injecté au build.
|
### 12.2 Canonical incorrect
|
||||||
|
|
||||||
Fix canonique : voir docs/runbooks/ENV-PUBLIC_SITE.md.
|
Cause probable : `PUBLIC_SITE` mal injecté au build.
|
||||||
|
|
||||||
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
|
||||||
|
```
|
||||||
|
|
||||||
### 6.3 Contrat “anchors” en échec après migration d’URL
|
Voir :
|
||||||
|
|
||||||
Quand on déplace des routes (ex: /archicratie/archicrat-ia/* → /archicrat-ia/*), le test d’ancres peut échouer même si les IDs n’ont pas changé, car les pages ont changé de chemin.
|
```text
|
||||||
|
docs/runbooks/ENV-PUBLIC_SITE.md
|
||||||
|
```
|
||||||
|
|
||||||
# Procédure safe :
|
### 12.3 Contrat anchors en échec après migration d’URL
|
||||||
|
|
||||||
Backup baseline :
|
Procédure safe :
|
||||||
|
|
||||||
|
```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';
|
||||||
@@ -161,16 +471,213 @@ 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
|
||||||
|
```
|
||||||
|
|
||||||
## 7) Ce que l’étape 9 doit faire (orientation)
|
### 12.4 “Le localhost auto-sync ne montre pas les dernières modifs”
|
||||||
|
|
||||||
Stabiliser le pipeline “tickets → YAML annotations”
|
Commande réflexe :
|
||||||
|
|
||||||
Formaliser la spec YAML + merge + anti-doublon (voir docs/EDITORIAL-ANNOTATIONS-SPEC.md)
|
```bash
|
||||||
|
~/ops-local/archicratie/doctor-localhost.sh
|
||||||
|
```
|
||||||
|
|
||||||
Durcir l’onboarding (ce START-HERE + runbooks)
|
Puis :
|
||||||
|
|
||||||
Éviter les régressions par tests (anchors / annotations / smoke)
|
```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
|
||||||
|
```
|
||||||
|
|
||||||
|
Si les SHA diffèrent :
|
||||||
|
- le sync n’a pas tourné
|
||||||
|
- ou l’agent 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 d’ops 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 d’exploitation 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 n’est 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`
|
||||||
|
|
||||||
|
C’est l’état de référence à préserver.
|
||||||
@@ -200,3 +200,347 @@ 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 d’un `annotations-index.json` consolidé depuis `src/annotations/**`
|
||||||
|
- patch direct dans les conteneurs en cours d’exé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 d’ancres)
|
||||||
|
- `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`
|
||||||
|
|
||||||
|
C’est 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) s’exé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 s’exé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 n’a 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 d’un `annotations-index.json` consolidé depuis `src/annotations/**`
|
||||||
|
- patch direct dans les conteneurs en cours d’exé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 d’ancres)
|
||||||
|
- `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`
|
||||||
|
|
||||||
|
C’est 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) s’exé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 s’exé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 n’a 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 : lorsqu’un paragraphe change (ex: ticket “Proposer” appliqué),
|
||||||
|
l’ID 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 : lorsqu’un paragraphe change (ex: ticket “Proposer” appliqué),
|
||||||
|
l’ID 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 d’un 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 l’impression d’un 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 d’un opérateur non supporté dans la shell effective
|
||||||
|
|
||||||
|
Fix :
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
toujours quoter : [[ "${VAR:-}" == "..." ]]
|
||||||
|
|
||||||
|
logguer BEFORE/AFTER/FORCE et s’assurer qu’ils ne sont pas vides
|
||||||
|
|
||||||
|
Symptom 3 : le gate liste “trop de fichiers” alors qu’on 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=...
|
||||||
|
|
||||||
|
|||||||
1783
package-lock.json
generated
1783
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,8 @@
|
|||||||
"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",
|
||||||
"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",
|
"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 && 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",
|
||||||
@@ -25,11 +26,11 @@
|
|||||||
"ci": "CI=1 npm test"
|
"ci": "CI=1 npm test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/mdx": "^4.3.13",
|
"@astrojs/mdx": "^5.0.0",
|
||||||
"astro": "^5.17.3"
|
"astro": "^6.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@astrojs/sitemap": "^3.7.0",
|
"@astrojs/sitemap": "^3.7.1",
|
||||||
"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",
|
||||||
|
|||||||
0
public/media/.gitkeep
Normal file
0
public/media/.gitkeep
Normal file
Binary file not shown.
|
Before Width: | Height: | Size: 61 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 61 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 816 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 822 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 822 KiB |
@@ -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"}
|
{"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"}
|
||||||
@@ -9,8 +9,9 @@ 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
|
* - retrouver le bon paragraphe dans le .mdx/.md
|
||||||
* - remplacer proprement
|
* - remplacer proprement
|
||||||
|
* - ne JAMAIS toucher au frontmatter
|
||||||
* - optionnel: écrire un alias d’ancre old->new (build-time) dans src/anchors/anchor-aliases.json
|
* - optionnel: écrire un alias d’ancre 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)
|
||||||
@@ -39,7 +40,7 @@ Env (recommandé):
|
|||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- Si dist/<chemin>/index.html est absent, le script lance "npm run build" sauf si --no-build.
|
- Si dist/<chemin>/index.html est absent, le script lance "npm run build" sauf si --no-build.
|
||||||
- Sauvegarde automatique: <fichier>.bak.issue-<N> (uniquement si on écrit)
|
- Sauvegarde automatique: .tmp/apply-ticket/<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.
|
||||||
`);
|
`);
|
||||||
@@ -89,6 +90,7 @@ 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 -------------------------- */
|
||||||
|
|
||||||
@@ -136,31 +138,26 @@ 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 bestBlockMatchIndex(blocks, targetText) {
|
function rankedBlockMatches(blocks, targetText, limit = 5) {
|
||||||
let best = { i: -1, score: -1 };
|
return blocks
|
||||||
for (let i = 0; i < blocks.length; i++) {
|
.map((b, i) => ({
|
||||||
const sc = scoreText(blocks[i], targetText);
|
i,
|
||||||
if (sc > best.score) best = { i, score: sc };
|
score: scoreText(b, targetText),
|
||||||
}
|
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) {
|
||||||
@@ -172,6 +169,89 @@ 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 = {}) {
|
||||||
@@ -251,7 +331,9 @@ 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);
|
||||||
|
|
||||||
@@ -266,11 +348,13 @@ 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();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,8 +382,6 @@ 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] : "";
|
||||||
}
|
}
|
||||||
@@ -400,7 +482,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.0",
|
"User-Agent": "archicratie-apply-ticket/2.1",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -416,7 +498,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.0",
|
"User-Agent": "archicratie-apply-ticket/2.1",
|
||||||
};
|
};
|
||||||
|
|
||||||
if (comment) {
|
if (comment) {
|
||||||
@@ -425,7 +507,11 @@ 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, { method: "PATCH", headers, body: JSON.stringify({ state: "closed" }) });
|
const res = await fetch(url, {
|
||||||
|
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(() => "");
|
||||||
@@ -529,10 +615,9 @@ 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 d’ajout), pas un ticket éditorial.`);
|
console.error(`❌ #${issueNum} est une Pull Request (demande d’ajout), pas un ticket éditorial.`);
|
||||||
console.error(`➡️ Ouvre un ticket [Correction]/[Fact-check] depuis le site (Proposer), puis relance apply-ticket sur ce numéro.`);
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -553,7 +638,6 @@ 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);
|
||||||
@@ -592,7 +676,6 @@ 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 = "";
|
||||||
|
|
||||||
@@ -609,21 +692,24 @@ 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 original = await fs.readFile(contentFile, "utf-8");
|
const originalRaw = await fs.readFile(contentFile, "utf-8");
|
||||||
const blocks = splitParagraphBlocks(original);
|
const { hasFrontmatter, frontmatter, body: originalBody } = splitMdxFrontmatter(originalRaw);
|
||||||
|
|
||||||
const best = bestBlockMatchIndex(blocks, targetText);
|
const split = splitParagraphBlocksPreserve(originalBody);
|
||||||
|
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 ? "…" : ""}`);
|
||||||
@@ -631,12 +717,34 @@ 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}`);
|
||||||
|
|
||||||
@@ -650,13 +758,15 @@ async function main() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// backup uniquement si on écrit
|
const relContentFile = path.relative(CWD, contentFile);
|
||||||
const bakPath = `${contentFile}.bak.issue-${issueNum}`;
|
const bakPath = path.join(BACKUP_ROOT, `${relContentFile}.bak.issue-${issueNum}`);
|
||||||
|
await fs.mkdir(path.dirname(bakPath), { recursive: true });
|
||||||
|
|
||||||
if (!(await fileExists(bakPath))) {
|
if (!(await fileExists(bakPath))) {
|
||||||
await fs.writeFile(bakPath, original, "utf-8");
|
await fs.writeFile(bakPath, originalRaw, "utf-8");
|
||||||
}
|
}
|
||||||
|
|
||||||
await fs.writeFile(contentFile, updated, "utf-8");
|
await fs.writeFile(contentFile, updatedRaw, "utf-8");
|
||||||
console.log("✅ Applied.");
|
console.log("✅ Applied.");
|
||||||
|
|
||||||
let aliasChanged = false;
|
let aliasChanged = false;
|
||||||
@@ -677,13 +787,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}).`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// garde-fous rapides
|
run("node", ["scripts/check-anchor-aliases.mjs"], { cwd: CWD });
|
||||||
|
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 });
|
||||||
}
|
}
|
||||||
@@ -713,7 +823,6 @@ 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(
|
||||||
|
|||||||
72
scripts/audit-docx-source.py
Executable file
72
scripts/audit-docx-source.py
Executable file
@@ -0,0 +1,72 @@
|
|||||||
|
#!/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 d’archicration",
|
||||||
|
"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 d’un 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 d’ouvrir 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())
|
||||||
@@ -74,7 +74,24 @@ 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);
|
||||||
@@ -139,6 +156,7 @@ 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;
|
||||||
@@ -172,6 +190,7 @@ 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}` +
|
||||||
@@ -182,11 +201,23 @@ 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 ? " …" : ""}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (prevIds.length >= MIN_PREV && churn > THRESHOLD) failed = true;
|
const exceeds =
|
||||||
if (prevIds.length >= MIN_PREV && removedRatio > THRESHOLD) failed = true;
|
(prevIds.length >= MIN_PREV && churn > THRESHOLD) ||
|
||||||
|
(prevIds.length >= MIN_PREV && removedRatio > THRESHOLD);
|
||||||
|
|
||||||
|
if (exceeds && acceptedReason) {
|
||||||
|
acceptedPages += 1;
|
||||||
|
console.log(` ✅ accepted reset: ${acceptedReason}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exceeds) failed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`\nSummary: pages compared=${pages.length}, pages changed=${changedPages}`);
|
console.log(
|
||||||
|
`\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);
|
||||||
|
|||||||
241
scripts/convert_docx_to_mdx.py
Executable file
241
scripts/convert_docx_to_mdx.py
Executable file
@@ -0,0 +1,241 @@
|
|||||||
|
#!/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()
|
||||||
304
scripts/convert_mdx_to_docx.py
Normal file
304
scripts/convert_mdx_to_docx.py
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
#!/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()
|
||||||
132
scripts/fix-docx-source.py
Executable file
132
scripts/fix-docx-source.py
Executable file
@@ -0,0 +1,132 @@
|
|||||||
|
#!/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 d’archicration": "opérateur d’archicration",
|
||||||
|
"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())
|
||||||
@@ -114,7 +114,6 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -182,17 +181,52 @@ 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 ? items : items.filter(it => args.only.includes(it.slug));
|
const selected = args.all
|
||||||
|
? 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 && selected.length !== args.only.length) {
|
if (!args.all) {
|
||||||
const found = new Set(selected.map(s => s.slug));
|
const found = new Set(
|
||||||
const missing = args.only.filter(s => !found.has(s));
|
selected.flatMap((s) => {
|
||||||
throw new Error(`Some --only slugs not found in manifest: ${missing.join(", ")}`);
|
const rawSlug = String(s.slug || "").trim();
|
||||||
|
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();
|
||||||
@@ -203,11 +237,14 @@ 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", it.collection, it.slug);
|
const assetsPublicDir = path.posix.join("/imported", outCollection, outSlug);
|
||||||
const assetsDiskDir = path.resolve("public", "imported", it.collection, it.slug);
|
const assetsDiskDir = path.resolve("public", "imported", outCollection, outSlug);
|
||||||
|
|
||||||
if (!(await exists(docxPath))) {
|
if (!(await exists(docxPath))) {
|
||||||
throw new Error(`Missing source docx: ${docxPath}`);
|
throw new Error(`Missing source docx: ${docxPath}`);
|
||||||
@@ -244,15 +281,32 @@ async function main() {
|
|||||||
|
|
||||||
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 },
|
||||||
ia: { edition: "ia", status: "cas_pratique", level: 1 },
|
"archicrat-ia": { edition: "archicrat-ia", status: "essai_these", level: 1 },
|
||||||
traite: { edition: "traite", status: "ontodynamique", level: 1 },
|
"cas-ia": { edition: "cas-ia", status: "application", level: 1 },
|
||||||
glossaire: { edition: "glossaire", status: "lexique", level: 1 },
|
traite: { edition: "traite", status: "ontodynamique", level: 1 },
|
||||||
atlas: { edition: "atlas", status: "atlas", level: 1 },
|
glossaire: { edition: "glossaire", status: "lexique", level: 1 },
|
||||||
|
atlas: { edition: "atlas", status: "atlas", level: 1 },
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaults = schemaDefaultsByCollection[it.collection] || { edition: it.collection, status: "draft", level: 1 };
|
// Compat legacy :
|
||||||
|
// 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 = [
|
||||||
"---",
|
"---",
|
||||||
|
|||||||
241
scripts/pick-proposer-issue.mjs
Normal file
241
scripts/pick-proposer-issue.mjs
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
#!/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);
|
||||||
|
});
|
||||||
29
scripts/refresh-chapter2.sh
Executable file
29
scripts/refresh-chapter2.sh
Executable file
@@ -0,0 +1,29 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
DOCX="sources/docx/archicrat-ia/Chapitre_2–Archeogenese_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
|
||||||
20
scripts/write-ops-health.mjs
Normal file
20
scripts/write-ops-health.mjs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
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);
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
sources/docx/commencer/document-de-presentation.docx
Normal file
BIN
sources/docx/commencer/document-de-presentation.docx
Normal file
Binary file not shown.
@@ -1,161 +1,123 @@
|
|||||||
version: 1
|
version: 1
|
||||||
|
|
||||||
docs:
|
docs:
|
||||||
|
# =========================
|
||||||
|
# Document d’entré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: archicratie
|
collection: archicrat-ia
|
||||||
slug: archicrat-ia/prologue
|
slug: prologue
|
||||||
title: "Prologue — Fondation et finalité sociopolitique et historique"
|
title: "Prologue — Fondation, 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: archicratie
|
collection: archicrat-ia
|
||||||
slug: archicrat-ia/chapitre-1
|
slug: 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_2–Archeogenese_des_regimes_de_co-viabilite-version_officielle.docx
|
- source: sources/docx/archicrat-ia/Chapitre_2–Archeogenese_des_regimes_de_co-viabilite-version_officielle.docx
|
||||||
collection: archicratie
|
collection: archicrat-ia
|
||||||
slug: archicrat-ia/chapitre-2
|
slug: 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: archicratie
|
collection: archicrat-ia
|
||||||
slug: archicrat-ia/chapitre-3
|
slug: 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: archicratie
|
collection: archicrat-ia
|
||||||
slug: archicrat-ia/chapitre-4
|
slug: 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: archicratie
|
collection: archicrat-ia
|
||||||
slug: archicrat-ia/chapitre-5
|
slug: 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: archicratie
|
collection: archicrat-ia
|
||||||
slug: archicrat-ia/conclusion
|
slug: conclusion
|
||||||
title: "Conclusion — ArchiCraT-IA"
|
title: "Conclusion — ArchiCraT-IA"
|
||||||
order: 70
|
order: 70
|
||||||
|
|
||||||
# =========================
|
# =========================
|
||||||
# IA — Cas pratique (1 page = 1 chapitre)
|
# Cas pratique — Gouvernance des systèmes IA
|
||||||
# NOTE: on n'inclut PAS le monolithe "Cas_IA-... .docx" dans le manifeste.
|
|
||||||
# =========================
|
# =========================
|
||||||
- source: sources/docx/cas-ia/Cas_IA-Archicratie_et_gouvernance_des_systemes_IA-Introduction_generale—Mettre_en_scene_un_systeme_IA.docx
|
- source: sources/docx/cas-ia/Cas_Pratique-Archicratie_et_gouvernance_des_systemes_IA-Introduction.docx
|
||||||
collection: ia
|
collection: cas-ia
|
||||||
slug: cas-pratique/introduction
|
slug: introduction
|
||||||
title: "Cas pratique — Introduction générale : Mettre en scène un système IA"
|
title: "Introduction générale — Mettre un système d’IA en scène"
|
||||||
order: 110
|
order: 110
|
||||||
|
|
||||||
- source: sources/docx/cas-ia/Cas_IA-Archicratie_et_gouvernance_des_systemes_IA-Chapitre_I—Epreuve_de_detectabilite.docx
|
- source: sources/docx/cas-ia/Cas_Pratique-Archicratie_et_gouvernance_des_systemes_IA-Chapitre_1_Epreuve_de_detectabilite.docx
|
||||||
collection: ia
|
collection: cas-ia
|
||||||
slug: cas-pratique/chapitre-1
|
slug: chapitre-1
|
||||||
title: "Cas pratique — Chapitre I : Épreuve de détectabilité"
|
title: "Chapitre I — Épreuve de détectabilité"
|
||||||
order: 120
|
order: 120
|
||||||
|
|
||||||
- source: sources/docx/cas-ia/Cas_IA-Archicratie_et_gouvernance_des_systemes_IA-Chapitre_II—Epreuve_topologique.docx
|
- source: sources/docx/cas-ia/Cas_Pratique-Archicratie_et_gouvernance_des_systemes_IA-Chapitre_2_Epreuve_Topologique.docx
|
||||||
collection: ia
|
collection: cas-ia
|
||||||
slug: cas-pratique/chapitre-2
|
slug: chapitre-2
|
||||||
title: "Cas pratique — Chapitre II : Épreuve topologique"
|
title: "Chapitre II — Épreuve topologique"
|
||||||
order: 130
|
order: 130
|
||||||
|
|
||||||
- source: sources/docx/cas-ia/Cas_IA-Archicratie_et_gouvernance_des_systemes_IA-Chapitre_III—Epreuve_archeogenetique.docx
|
- source: sources/docx/cas-ia/Cas_Pratique-Archicratie_et_gouvernance_des_systemes_IA-Chapitre_3_Epreuve_archeogenetique.docx
|
||||||
collection: ia
|
collection: cas-ia
|
||||||
slug: cas-pratique/chapitre-3
|
slug: chapitre-3
|
||||||
title: "Cas pratique — Chapitre III : Épreuve archéogénétique"
|
title: "Chapitre III — Épreuve archéogénétique"
|
||||||
order: 140
|
order: 140
|
||||||
|
|
||||||
- source: sources/docx/cas-ia/Cas_IA-Archicratie_et_gouvernance_des_systemes_IA-Chapitre_IV—Epreuve_morphologique.docx
|
- source: sources/docx/cas-ia/Cas_Pratique-Archicratie_et_gouvernance_des_systemes_IA-Chapitre_4_Epreuve_Morphologique.docx
|
||||||
collection: ia
|
collection: cas-ia
|
||||||
slug: cas-pratique/chapitre-4
|
slug: chapitre-4
|
||||||
title: "Cas pratique — Chapitre IV : Épreuve morphologique"
|
title: "Chapitre IV — Épreuve morphologique"
|
||||||
order: 150
|
order: 150
|
||||||
|
|
||||||
- source: sources/docx/cas-ia/Cas_IA-Archicratie_et_gouvernance_des_systemes_IA-Chapitre_V—Epreuve_historique.docx
|
- source: sources/docx/cas-ia/Cas_Pratique-Archicratie_et_gouvernance_des_systemes_IA-Chapitre_5_Epreuve_Historique.docx
|
||||||
collection: ia
|
collection: cas-ia
|
||||||
slug: cas-pratique/chapitre-5
|
slug: chapitre-5
|
||||||
title: "Cas pratique — Chapitre V : Épreuve historique"
|
title: "Chapitre V — Épreuve historique"
|
||||||
order: 160
|
order: 160
|
||||||
|
|
||||||
- source: sources/docx/cas-ia/Cas_IA-Archicratie_et_gouvernance_des_systemes_IA-Chapitre_VI—Epreuve_de_co-viabilite.docx
|
- source: sources/docx/cas-ia/Cas_Pratique-Archicratie_et_gouvernance_des_systemes_IA-Chapitre_6_Epreuve_de_Co-viabilite.docx
|
||||||
collection: ia
|
collection: cas-ia
|
||||||
slug: cas-pratique/chapitre-6
|
slug: chapitre-6
|
||||||
title: "Cas pratique — Chapitre VI : Épreuve de co-viabilité"
|
title: "Chapitre VI — Épreuve de co-viabilité"
|
||||||
order: 170
|
order: 170
|
||||||
|
|
||||||
- source: sources/docx/cas-ia/Cas_IA-Archicratie_et_gouvernance_des_systemes_IA-Chapitre_VII—Gestes_archicratiques_concrets_pour_un_systeme_IA.docx
|
- source: sources/docx/cas-ia/Cas_Pratique-Archicratie_et_gouvernance_des_systemes_IA-Chapitre_7_Gestes_archicratiques_concrets_pour_un_systeme_IA.docx
|
||||||
collection: ia
|
collection: cas-ia
|
||||||
slug: cas-pratique/chapitre-7
|
slug: chapitre-7
|
||||||
title: "Cas pratique — Chapitre VII : Gestes archicratiques concrets"
|
title: "Chapitre VII — Gestes archicratiques concrets pour un système d’IA"
|
||||||
order: 180
|
order: 180
|
||||||
|
|
||||||
- source: sources/docx/cas-ia/Cas_IA-Archicratie_et_gouvernance_des_systemes_IA-Conclusion.docx
|
- source: sources/docx/cas-ia/Cas_Pratique-Archicratie_et_gouvernance_des_systemes_IA-Conclusion.docx
|
||||||
collection: ia
|
collection: cas-ia
|
||||||
slug: cas-pratique/conclusion
|
slug: conclusion
|
||||||
title: "Cas pratique — Conclusion"
|
title: "Conclusion"
|
||||||
order: 190
|
order: 190
|
||||||
|
|
||||||
- source: sources/docx/cas-ia/Cas_IA-Archicratie_et_gouvernance_des_systemes_IA-Annexe—Glossaire_archicratique_pour_audit_des_systemes_IA.docx
|
- source: sources/docx/cas-ia/Cas_Pratique-Archicratie_et_gouvernance_des_systemes_IA-Annexe_Glossaire_Archicratique_Cas_IA.docx
|
||||||
collection: ia
|
collection: cas-ia
|
||||||
slug: cas-pratique/annexe-glossaire-audit
|
slug: annexe-glossaire-audit
|
||||||
title: "Cas pratique — Annexe : Glossaire archicratique pour audit des systèmes IA"
|
title: "Annexe — Glossaire archicratique pour l’audit des systèmes d’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
|
||||||
# =========================
|
# =========================
|
||||||
|
|||||||
@@ -1,2 +1 @@
|
|||||||
{}
|
{}
|
||||||
|
|
||||||
0
src/annotations/.gitkeep
Normal file
0
src/annotations/.gitkeep
Normal file
@@ -1,10 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
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 l’on voulait chercher quelque chose comme une vision du monde chez Kafka..."
|
|
||||||
source: "Bernard Lahire, Franz Kafka, p.475+"
|
|
||||||
|
|
||||||
comments_editorial: []
|
|
||||||
@@ -1,51 +1,79 @@
|
|||||||
---
|
---
|
||||||
import { getCollection } from "astro:content";
|
import { getCollection } from "astro:content";
|
||||||
|
|
||||||
const { currentSlug } = Astro.props;
|
const {
|
||||||
|
currentSlug,
|
||||||
|
collection = "archicrat-ia",
|
||||||
|
basePath = "/archicrat-ia",
|
||||||
|
label = "Table des matières"
|
||||||
|
} = Astro.props;
|
||||||
|
|
||||||
const entries = (await getCollection("archicratie"))
|
const slugOf = (entry) => String(entry.id).replace(/\.(md|mdx)$/i, "");
|
||||||
.filter((e) => e.slug.startsWith("archicrat-ia/"))
|
const hrefOf = (entry) => `${basePath}/${slugOf(entry)}/`;
|
||||||
.sort((a, b) => (a.data.order ?? 0) - (b.data.order ?? 0));
|
|
||||||
|
|
||||||
// ✅ On route l’Essai-thèse sur /archicrat-ia/<slug-sans-prefix>/
|
const collator = new Intl.Collator("fr", { sensitivity: "base", numeric: true });
|
||||||
// (Astro trailingSlash = always → on garde le "/" final)
|
|
||||||
const strip = (s) => String(s || "").replace(/^archicrat-ia\//, "");
|
const entries = [...await getCollection(collection)].sort((a, b) => {
|
||||||
const href = (slug) => `/archicrat-ia/${strip(slug)}/`;
|
const ao = Number(a.data.order ?? 9999);
|
||||||
|
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 class="toc-global" aria-label="Table des matières — ArchiCraT-IA">
|
<nav
|
||||||
<div class="toc-global__head">
|
class="toc-global"
|
||||||
<div class="toc-global__title">Table des matières</div>
|
aria-label={label}
|
||||||
</div>
|
data-toc-global
|
||||||
|
data-toc-key={`global:${collection}:${basePath}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="toc-global__head toc-global__toggle"
|
||||||
|
type="button"
|
||||||
|
aria-expanded="true"
|
||||||
|
aria-controls={tocId}
|
||||||
|
>
|
||||||
|
<span class="toc-global__title">{label}</span>
|
||||||
|
<span class="toc-global__chevron" aria-hidden="true">▾</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<ol class="toc-global__list">
|
<div class="toc-global__body-clip" id={tocId}>
|
||||||
{entries.map((e) => {
|
<div class="toc-global__body">
|
||||||
const active = e.slug === currentSlug;
|
<ol class="toc-global__list">
|
||||||
return (
|
{entries.map((e) => {
|
||||||
<li class={`toc-item ${active ? "is-active" : ""}`}>
|
const slug = slugOf(e);
|
||||||
<a class="toc-link" href={href(e.slug)} aria-current={active ? "page" : undefined}>
|
const active = slug === currentSlug;
|
||||||
<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>
|
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>
|
||||||
|
|
||||||
{active && (
|
<span class="toc-link__title">{e.data.title}</span>
|
||||||
<span class="toc-badge" aria-label="Chapitre en cours">
|
|
||||||
En cours
|
{active && (
|
||||||
|
<span class="toc-badge" aria-label="Chapitre en cours">
|
||||||
|
En cours
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{active && <span class="toc-underline" aria-hidden="true"></span>}
|
{active && <span class="toc-underline" aria-hidden="true"></span>}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ol>
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -56,7 +84,22 @@ const href = (slug) => `/archicrat-ia/${strip(slug)}/`;
|
|||||||
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);
|
||||||
@@ -69,11 +112,36 @@ const href = (slug) => `/archicrat-ia/${strip(slug)}/`;
|
|||||||
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; }
|
||||||
@@ -99,13 +167,33 @@ const href = (slug) => `/archicrat-ia/${strip(slug)}/`;
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toc-active-indicator{
|
.toc-active-mark{
|
||||||
font-size: 14px;
|
width: 14px;
|
||||||
line-height: 1;
|
height: 14px;
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
opacity: .55;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toc-active-spacer{
|
.toc-active-mark__dot{
|
||||||
width: 14px;
|
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{
|
||||||
@@ -143,11 +231,66 @@ const href = (slug) => `/archicrat-ia/${strip(slug)}/`;
|
|||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toc-global__list{
|
@media (max-width: 980px){
|
||||||
max-height: 44vh;
|
.toc-global{
|
||||||
overflow: auto;
|
padding: 10px 12px;
|
||||||
padding-right: 8px;
|
border-radius: 14px;
|
||||||
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark){
|
@media (prefers-color-scheme: dark){
|
||||||
@@ -155,12 +298,88 @@ const href = (slug) => `/archicrat-ia/${strip(slug)}/`;
|
|||||||
.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>
|
||||||
(() => {
|
(() => {
|
||||||
const active = document.querySelector(".toc-global .toc-item.is-active");
|
function init() {
|
||||||
if (active) active.scrollIntoView({ block: "nearest" });
|
document.querySelectorAll("[data-toc-global]").forEach((nav) => {
|
||||||
|
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;
|
||||||
|
nav.classList.toggle("is-collapsed", isMobile && !open);
|
||||||
|
toggle.setAttribute("aria-expanded", open ? "true" : "false");
|
||||||
|
if (persist && isMobile) write(open);
|
||||||
|
};
|
||||||
|
|
||||||
|
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>
|
||||||
505
src/components/GlossaryAside.astro
Normal file
505
src/components/GlossaryAside.astro
Normal file
@@ -0,0 +1,505 @@
|
|||||||
|
---
|
||||||
|
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" open>
|
||||||
|
<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" open>
|
||||||
|
<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" open>
|
||||||
|
<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" open>
|
||||||
|
<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" open>
|
||||||
|
<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 (min-width: 861px){
|
||||||
|
.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)").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 = 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>
|
||||||
110
src/components/GlossaryCardGrid.astro
Normal file
110
src/components/GlossaryCardGrid.astro
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
---
|
||||||
|
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>
|
||||||
16
src/components/GlossaryEntryBody.astro
Normal file
16
src/components/GlossaryEntryBody.astro
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<div class="glossary-entry-body">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.glossary-entry-body{
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
: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>
|
||||||
260
src/components/GlossaryEntryHero.astro
Normal file
260
src/components/GlossaryEntryHero.astro
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
---
|
||||||
|
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: calc(var(--sticky-header-h, 0px) + var(--page-gap, 12px));
|
||||||
|
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: 8px;
|
||||||
|
padding: 9px 10px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glossary-entry-dek{
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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>
|
||||||
31
src/components/GlossaryEntryLegacyNote.astro
Normal file
31
src/components/GlossaryEntryLegacyNote.astro
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
---
|
||||||
|
interface Props {
|
||||||
|
canonicalHref: string;
|
||||||
|
term: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { canonicalHref, term } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<p class="glossary-legacy-note">
|
||||||
|
Cette entrée a été renommée. L’intitulé 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>
|
||||||
289
src/components/GlossaryEntryStickySync.astro
Normal file
289
src/components/GlossaryEntryStickySync.astro
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
<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 = () =>
|
||||||
|
Math.max(0, Math.round(hero.getBoundingClientRect().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 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 syncFollowState = () => {
|
||||||
|
const on = computeFollowOn();
|
||||||
|
if (on === lastFollowOn) return;
|
||||||
|
lastFollowOn = on;
|
||||||
|
body.classList.toggle(FOLLOW_ON_CLASS, on);
|
||||||
|
};
|
||||||
|
|
||||||
|
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){
|
||||||
|
margin-bottom: 0;
|
||||||
|
border-bottom-left-radius: 0;
|
||||||
|
border-bottom-right-radius: 0;
|
||||||
|
box-shadow: 0 8px 20px rgba(0,0,0,0.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: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
: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: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(body.is-glossary-entry-page.glossary-entry-follow-on #reading-follow .reading-follow__inner){
|
||||||
|
margin-top: -1px;
|
||||||
|
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: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
: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>
|
||||||
382
src/components/GlossaryHomeAside.astro
Normal file
382
src/components/GlossaryHomeAside.astro
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
---
|
||||||
|
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>
|
||||||
364
src/components/GlossaryHomeHero.astro
Normal file
364
src/components/GlossaryHomeHero.astro
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
---
|
||||||
|
export interface Props {
|
||||||
|
kicker?: string;
|
||||||
|
title?: string;
|
||||||
|
intro?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
kicker = "Référentiel terminologique",
|
||||||
|
title = "Glossaire archicratique",
|
||||||
|
intro = "Ce glossaire n’est pas seulement un index de définitions. Il constitue une porte d’entré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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
133
src/components/GlossaryHomeSection.astro
Normal file
133
src/components/GlossaryHomeSection.astro
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
---
|
||||||
|
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>
|
||||||
219
src/components/GlossaryPortalAside.astro
Normal file
219
src/components/GlossaryPortalAside.astro
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
---
|
||||||
|
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 && (
|
||||||
|
<div class="glossary-portal-aside__block">
|
||||||
|
<h2 class="glossary-portal-aside__heading">Dans cette page</h2>
|
||||||
|
<ul class="glossary-portal-aside__list">
|
||||||
|
{pageItems.map((item) => (
|
||||||
|
<li><a href={item.href}>{item.label}</a></li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{usefulLinks.length > 0 && (
|
||||||
|
<div class="glossary-portal-aside__block">
|
||||||
|
<h2 class="glossary-portal-aside__heading">Renvois utiles</h2>
|
||||||
|
<ul class="glossary-portal-aside__list">
|
||||||
|
{usefulLinks.map((item) => (
|
||||||
|
<li><a href={item.href}>{item.label}</a></li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
67
src/components/GlossaryPortalCta.astro
Normal file
67
src/components/GlossaryPortalCta.astro
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
---
|
||||||
|
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>
|
||||||
118
src/components/GlossaryPortalGrid.astro
Normal file
118
src/components/GlossaryPortalGrid.astro
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
---
|
||||||
|
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>
|
||||||
260
src/components/GlossaryPortalHero.astro
Normal file
260
src/components/GlossaryPortalHero.astro
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
---
|
||||||
|
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>
|
||||||
127
src/components/GlossaryPortalPanel.astro
Normal file
127
src/components/GlossaryPortalPanel.astro
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
---
|
||||||
|
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>
|
||||||
143
src/components/GlossaryPortalSection.astro
Normal file
143
src/components/GlossaryPortalSection.astro
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
---
|
||||||
|
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>
|
||||||
487
src/components/GlossaryPortalStickySync.astro
Normal file
487
src/components/GlossaryPortalStickySync.astro
Normal file
@@ -0,0 +1,487 @@
|
|||||||
|
---
|
||||||
|
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 qu’il 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* L’accolage hero + follow n’arrive 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>
|
||||||
125
src/components/GlossaryRelationCards.astro
Normal file
125
src/components/GlossaryRelationCards.astro
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
---
|
||||||
|
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: 22px;
|
||||||
|
padding-top: 14px;
|
||||||
|
border-top: 1px solid rgba(127,127,127,0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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>
|
||||||
@@ -68,7 +68,6 @@ 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);
|
||||||
@@ -77,13 +76,11 @@ 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);
|
||||||
});
|
});
|
||||||
@@ -95,6 +92,8 @@ 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{
|
||||||
@@ -106,6 +105,7 @@ 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,4 +125,21 @@ const { initialLevel = 1 } = Astro.props;
|
|||||||
.level-btn:active{
|
.level-btn:active{
|
||||||
transform: translateY(1px);
|
transform: translateY(1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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>
|
</style>
|
||||||
@@ -3,49 +3,122 @@ 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">
|
<nav class="toc-local" aria-label="Dans ce chapitre" data-toc-local>
|
||||||
<div class="toc-local__title">Dans ce chapitre</div>
|
<button
|
||||||
|
class="toc-local__head toc-local__toggle"
|
||||||
|
type="button"
|
||||||
|
aria-expanded="true"
|
||||||
|
aria-controls={tocId}
|
||||||
|
>
|
||||||
|
<span class="toc-local__title">Dans ce chapitre</span>
|
||||||
|
<span class="toc-local__chevron" aria-hidden="true">▾</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<ol class="toc-local__list">
|
<div class="toc-local__body-clip" id={tocId}>
|
||||||
{items.map((h) => (
|
<div class="toc-local__body">
|
||||||
<li
|
<ol class="toc-local__list">
|
||||||
class={`toc-local__item d${h.depth}`}
|
{items.map((h) => (
|
||||||
data-toc-item
|
<li
|
||||||
data-depth={h.depth}
|
class={`toc-local__item d${h.depth}`}
|
||||||
data-id={h.slug}
|
data-toc-item
|
||||||
>
|
data-depth={h.depth}
|
||||||
<a href={`#${h.slug}`} data-toc-link data-slug={h.slug}>
|
data-id={h.slug}
|
||||||
{h.text}
|
>
|
||||||
</a>
|
<a href={`#${h.slug}`} data-toc-link data-slug={h.slug}>
|
||||||
</li>
|
<span class="toc-local__mark" aria-hidden="true"></span>
|
||||||
))}
|
<span class="toc-local__text">{h.text}</span>
|
||||||
</ol>
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<script is:inline>
|
<script is:inline>
|
||||||
(() => {
|
(() => {
|
||||||
function init() {
|
function init() {
|
||||||
const toc = document.querySelector(".toc-local");
|
const toc = document.querySelector(".toc-local[data-toc-local]");
|
||||||
if (!toc) return;
|
if (!toc || toc.dataset.tocReady === "1") 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;
|
||||||
|
toc.classList.toggle("is-collapsed", isMobile && !open);
|
||||||
|
toggle.setAttribute("aria-expanded", open ? "true" : "false");
|
||||||
|
|
||||||
|
if (persist && isMobile) writeState(open);
|
||||||
|
|
||||||
|
if (emit && open && 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 ? true : 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) return;
|
if (!itemEls.length) {
|
||||||
|
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; // span.details-anchor OU h3[id]
|
const el = id ? document.getElementById(id) : null;
|
||||||
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) return;
|
if (!ordered.length) {
|
||||||
|
initAccordion();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const clear = () => {
|
const clear = () => {
|
||||||
for (const t of ordered) {
|
for (const t of ordered) {
|
||||||
@@ -55,14 +128,29 @@ const items = (headings || []).filter((h) => h.depth >= 2 && h.depth <= 3);
|
|||||||
};
|
};
|
||||||
|
|
||||||
const openDetailsIfNeeded = (el) => {
|
const openDetailsIfNeeded = (el) => {
|
||||||
const d = el?.closest?.("details");
|
try {
|
||||||
if (d && !d.open) d.open = true;
|
if (!el) return;
|
||||||
|
|
||||||
|
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) => {
|
const setCurrent = (id, { autoOpen = true } = {}) => {
|
||||||
if (!id || id === current) return;
|
if (!id) return;
|
||||||
const t = ordered.find((x) => x.id === id);
|
const t = ordered.find((x) => x.id === id);
|
||||||
if (!t) return;
|
if (!t) return;
|
||||||
|
|
||||||
@@ -74,17 +162,21 @@ const items = (headings || []).filter((h) => h.depth >= 2 && h.depth <= 3);
|
|||||||
t.a.setAttribute("aria-current", "true");
|
t.a.setAttribute("aria-current", "true");
|
||||||
t.li.classList.add("is-current");
|
t.li.classList.add("is-current");
|
||||||
|
|
||||||
// ✅ 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) return false;
|
if (!inSummary && !t.el.classList?.contains("details-anchor")) return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
@@ -102,7 +194,7 @@ const items = (headings || []).filter((h) => h.depth >= 2 && h.depth <= 3);
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!best) best = visible[0];
|
if (!best) best = visible[0];
|
||||||
setCurrent(best.id);
|
if (best && best.id !== current) setCurrent(best.id, { autoOpen: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
let ticking = false;
|
let ticking = false;
|
||||||
@@ -117,11 +209,14 @@ const items = (headings || []).filter((h) => h.depth >= 2 && h.depth <= 3);
|
|||||||
|
|
||||||
const syncFromHash = () => {
|
const syncFromHash = () => {
|
||||||
const id = (location.hash || "").slice(1);
|
const id = (location.hash || "").slice(1);
|
||||||
if (!id) { computeActive(); return; }
|
if (!id) {
|
||||||
|
computeActive();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
if (el) openDetailsIfNeeded(el);
|
if (el) openDetailsIfNeeded(el);
|
||||||
setCurrent(id);
|
setCurrent(id, { autoOpen: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
toc.addEventListener("click", (ev) => {
|
toc.addEventListener("click", (ev) => {
|
||||||
@@ -133,13 +228,14 @@ const items = (headings || []).filter((h) => h.depth >= 2 && h.depth <= 3);
|
|||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
if (el) openDetailsIfNeeded(el);
|
if (el) openDetailsIfNeeded(el);
|
||||||
|
|
||||||
setCurrent(id);
|
setCurrent(id, { autoOpen: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
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();
|
||||||
}
|
}
|
||||||
@@ -153,30 +249,183 @@ const items = (headings || []).filter((h) => h.depth >= 2 && h.depth <= 3);
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.toc-local{margin-top:12px;border:1px solid rgba(127,127,127,.25);border-radius:16px;padding:12px}
|
.toc-local{
|
||||||
.toc-local__title{font-size:13px;opacity:.85;margin-bottom:8px}
|
margin-top: 12px;
|
||||||
|
border: 1px solid rgba(127,127,127,.25);
|
||||||
.toc-local__list{list-style:none;margin:0;padding:0}
|
border-radius: 16px;
|
||||||
.toc-local__item::marker{content:""}
|
padding: 12px;
|
||||||
.toc-local__item{margin:6px 0}
|
background: rgba(127,127,127,0.03);
|
||||||
.toc-local__item.d3{margin-left:12px;opacity:.9}
|
|
||||||
|
|
||||||
.toc-local__item.is-current > a{
|
|
||||||
font-weight: 750;
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.toc-local a{
|
.toc-local__toggle{
|
||||||
display:inline-block;
|
width: 100%;
|
||||||
max-width:100%;
|
appearance: none;
|
||||||
text-decoration:none;
|
border: 0;
|
||||||
|
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:""; }
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -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 (pop-up au-dessus du panel) */}
|
{/* ✅ Lightbox media (plein écran) */}
|
||||||
<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,6 +93,9 @@
|
|||||||
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");
|
||||||
@@ -101,9 +104,6 @@
|
|||||||
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,6 +114,16 @@
|
|||||||
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: "" };
|
||||||
@@ -121,9 +131,6 @@
|
|||||||
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 };
|
||||||
|
|
||||||
@@ -137,8 +144,7 @@
|
|||||||
return Array.isArray(groups) && groups.some((x) => String(x).toLowerCase() === gg);
|
return Array.isArray(groups) && groups.some((x) => String(x).toLowerCase() === gg);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ règle mission : readers + editors peuvent soumettre médias + commentaires
|
// ✅ readers + editors peuvent soumettre médias + commentaires + refs
|
||||||
// ✅ dev fallback : si /_auth/whoami n’existe 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");
|
||||||
@@ -152,7 +158,6 @@
|
|||||||
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;
|
||||||
@@ -161,7 +166,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}).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;
|
||||||
@@ -213,7 +217,6 @@
|
|||||||
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;
|
||||||
})();
|
})();
|
||||||
@@ -255,24 +258,22 @@
|
|||||||
return issue.toString();
|
return issue.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ouvre un nouvel onglet UNE SEULE FOIS (évite le double-open Safari/Firefox + noopener).
|
function openNewTab(url) {
|
||||||
function openNewTab(url) {
|
try {
|
||||||
try {
|
const a = document.createElement("a");
|
||||||
const a = document.createElement("a");
|
a.href = url;
|
||||||
a.href = url;
|
a.target = "_blank";
|
||||||
a.target = "_blank";
|
a.rel = "noopener noreferrer";
|
||||||
a.rel = "noopener noreferrer";
|
a.style.display = "none";
|
||||||
a.style.display = "none";
|
document.body.appendChild(a);
|
||||||
document.body.appendChild(a);
|
a.click();
|
||||||
a.click();
|
a.remove();
|
||||||
a.remove();
|
return true;
|
||||||
return true; // on ne peut pas détecter proprement un blocage sans retomber dans le double-open
|
} catch {
|
||||||
} catch {
|
return false;
|
||||||
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();
|
||||||
@@ -301,13 +302,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ===== 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);
|
||||||
@@ -346,6 +355,7 @@
|
|||||||
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");
|
||||||
}
|
}
|
||||||
@@ -363,7 +373,6 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Renders =====
|
|
||||||
function renderLevel2(data) {
|
function renderLevel2(data) {
|
||||||
clear(elL2);
|
clear(elL2);
|
||||||
if (!elL2) return;
|
if (!elL2) return;
|
||||||
@@ -373,7 +382,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(data.authors) && data.authors.length) {
|
if (Array.isArray(data.mobilizedAuthors) && data.mobilizedAuthors.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";
|
||||||
@@ -381,7 +390,7 @@
|
|||||||
|
|
||||||
const ul = document.createElement("ul");
|
const ul = document.createElement("ul");
|
||||||
ul.className = "panel-list";
|
ul.className = "panel-list";
|
||||||
for (const a of data.authors) {
|
for (const a of data.mobilizedAuthors) {
|
||||||
const li = document.createElement("li");
|
const li = document.createElement("li");
|
||||||
li.textContent = esc(a);
|
li.textContent = esc(a);
|
||||||
ul.appendChild(li);
|
ul.appendChild(li);
|
||||||
@@ -563,13 +572,16 @@
|
|||||||
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 l’index est indisponible (sans écraser le message d’auth)
|
|
||||||
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).";
|
||||||
@@ -583,7 +595,6 @@
|
|||||||
renderLevel4(data);
|
renderLevel4(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== media "voir tous" =====
|
|
||||||
if (btnMediaAll) {
|
if (btnMediaAll) {
|
||||||
bindClickOnce(btnMediaAll, (ev) => {
|
bindClickOnce(btnMediaAll, (ev) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
@@ -595,7 +606,6 @@
|
|||||||
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();
|
||||||
@@ -638,27 +648,26 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== référence submit (readers + editors) =====
|
if (btnRefSubmit) {
|
||||||
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 d’abord un paragraphe (scroll / survol).", "warn");
|
if (!currentParaId) return showMsg(msgRef, "Choisis d’abord 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}`,
|
||||||
@@ -676,18 +685,16 @@
|
|||||||
``,
|
``,
|
||||||
`---`,
|
`---`,
|
||||||
`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 l’issue.", "error");
|
if (!url) return showMsg(msgRef, "Impossible de générer l’issue.", "error");
|
||||||
|
|
||||||
const ok = openOnce(`ref:${currentParaId}`, () => openNewTab(url));
|
const ok = openOnce(`ref:${currentParaId}`, () => openNewTab(url));
|
||||||
if (!ok) showMsg(msgRef, "Si rien ne s’ouvre : autorise les popups pour ce site.", "error");
|
if (!ok) showMsg(msgRef, "Si rien ne s’ouvre : autorise les popups pour ce site.", "error");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ===== commentaire (readers + editors) =====
|
|
||||||
if (btnSend) {
|
if (btnSend) {
|
||||||
bindClickOnce(btnSend, (ev) => {
|
bindClickOnce(btnSend, (ev) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
@@ -739,60 +746,31 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== wiring: para courant (aligné sur le paragraphe sous le reading-follow) =====
|
// ===== wiring: para courant (SOURCE OF TRUTH = EditionLayout) =====
|
||||||
function isPara(el) {
|
function onCurrentPara(ev) {
|
||||||
return Boolean(el && el.nodeType === 1 && el.matches && el.matches('.reading p[id^="p-"]'));
|
try {
|
||||||
|
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);
|
||||||
|
|
||||||
function pickParaAtY(y) {
|
const initial = String(location.hash || "").replace(/^#/, "").trim();
|
||||||
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);
|
|
||||||
|
|
||||||
for (const c of candidates) {
|
if (/^p-\d+-/i.test(initial)) {
|
||||||
if (isPara(c)) return c;
|
updatePanel(initial);
|
||||||
const p = c.closest ? c.closest('.reading p[id^="p-"]') : null;
|
} else if (window.__archiCurrentParaId && /^p-\d+-/i.test(String(window.__archiCurrentParaId))) {
|
||||||
if (isPara(p)) return p;
|
updatePanel(String(window.__archiCurrentParaId));
|
||||||
}
|
} else {
|
||||||
return null;
|
setTimeout(() => {
|
||||||
|
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>
|
||||||
|
|
||||||
@@ -805,6 +783,8 @@
|
|||||||
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 (80–110 selon goût) */
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(body[data-reading-level="3"]) .page-panel{
|
:global(body[data-reading-level="3"]) .page-panel{
|
||||||
@@ -922,28 +902,33 @@
|
|||||||
/* actions médias en haut */
|
/* actions médias en haut */
|
||||||
.panel-top-actions{ margin-top: 8px; }
|
.panel-top-actions{ margin-top: 8px; }
|
||||||
|
|
||||||
/* ===== media thumbnails (150x150) ===== */
|
/* ===== media thumbnails (plus petits + plus denses) ===== */
|
||||||
.panel-media-grid{
|
.panel-media-grid{
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(var(--thumb), 1fr));
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-media-tile{
|
.panel-media-tile{
|
||||||
width: 150px;
|
width: 100%;
|
||||||
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: 150px;
|
width: 100%;
|
||||||
height: 150px;
|
height: var(--thumb);
|
||||||
max-width: 100%;
|
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
display: block;
|
display: block;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
@@ -951,8 +936,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.panel-media-ph{
|
.panel-media-ph{
|
||||||
width: 150px;
|
width: 100%;
|
||||||
height: 150px;
|
height: var(--thumb);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
@@ -995,7 +980,11 @@
|
|||||||
resize: vertical;
|
resize: vertical;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Lightbox ===== */
|
/* ===== Lightbox (plein écran “cinéma”) ===== */
|
||||||
|
: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;
|
||||||
@@ -1005,58 +994,66 @@
|
|||||||
.panel-lightbox__overlay{
|
.panel-lightbox__overlay{
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: rgba(0,0,0,0.80);
|
background: rgba(0,0,0,0.84);
|
||||||
backdrop-filter: blur(6px);
|
backdrop-filter: blur(10px);
|
||||||
-webkit-backdrop-filter: blur(6px);
|
-webkit-backdrop-filter: blur(10px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-lightbox__dialog{
|
.panel-lightbox__dialog{
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 24px;
|
left: 50%;
|
||||||
top: calc(var(--sticky-header-h) + 16px);
|
top: 50%;
|
||||||
width: min(520px, calc(100vw - 48px));
|
transform: translate(-50%, -50%);
|
||||||
max-height: calc(100vh - (var(--sticky-header-h) + 32px));
|
|
||||||
|
width: min(1100px, 92vw);
|
||||||
|
max-height: 92vh;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
|
||||||
border: 1px solid rgba(127,127,127,0.22);
|
border: 1px solid rgba(255,255,255,0.14);
|
||||||
border-radius: 16px;
|
border-radius: 18px;
|
||||||
background: rgba(255,255,255,0.10);
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
-webkit-backdrop-filter: blur(10px);
|
|
||||||
padding: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark){
|
background: rgba(20,20,20,0.55);
|
||||||
.panel-lightbox__dialog{
|
backdrop-filter: blur(14px);
|
||||||
background: rgba(0,0,0,0.28);
|
-webkit-backdrop-filter: blur(14px);
|
||||||
}
|
|
||||||
|
padding: 16px;
|
||||||
|
box-shadow: 0 24px 70px rgba(0,0,0,0.55);
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-lightbox__close{
|
.panel-lightbox__close{
|
||||||
position: sticky;
|
position: absolute;
|
||||||
top: 0;
|
top: 12px;
|
||||||
margin-left: auto;
|
right: 12px;
|
||||||
|
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 34px;
|
|
||||||
height: 30px;
|
width: 44px;
|
||||||
border-radius: 10px;
|
height: 40px;
|
||||||
border: 1px solid rgba(127,127,127,0.35);
|
|
||||||
background: rgba(127,127,127,0.10);
|
border-radius: 14px;
|
||||||
|
border: 1px solid rgba(255,255,255,0.22);
|
||||||
|
background: rgba(255,255,255,0.10);
|
||||||
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 18px;
|
font-size: 22px;
|
||||||
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%;
|
||||||
height: auto;
|
max-height: calc(92vh - 160px);
|
||||||
max-width: 1400px;
|
object-fit: contain;
|
||||||
margin: 0 auto;
|
|
||||||
border-radius: 12px;
|
background: rgba(0,0,0,0.22);
|
||||||
|
border-radius: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-lightbox__content audio{
|
.panel-lightbox__content audio{
|
||||||
@@ -1064,10 +1061,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.panel-lightbox__caption{
|
.panel-lightbox__caption{
|
||||||
margin-top: 10px;
|
margin-top: 12px;
|
||||||
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){
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
<nav class="site-nav" aria-label="Navigation principale">
|
<nav class="site-nav" aria-label="Navigation principale">
|
||||||
<a href="/">Accueil</a><span aria-hidden="true"> · </span>
|
|
||||||
<a href="/editions/">Carte des œuvres</a><span aria-hidden="true"> · </span>
|
<a href="/">Accueil</a>
|
||||||
<a href="/methode/">Méthode</a><span aria-hidden="true"> · </span>
|
<span aria-hidden="true"> · </span>
|
||||||
<a href="/recherche/">Recherche</a><span aria-hidden="true"> · </span>
|
|
||||||
<a href="/archicrat-ia/">Essai-thèse</a><span aria-hidden="true"> · </span>
|
<a href="/archicrat-ia/">Essai-thèse</a>
|
||||||
<a href="/traite/">Traité</a><span aria-hidden="true"> · </span>
|
<span aria-hidden="true"> · </span>
|
||||||
<a href="/ia/">Cas IA</a><span aria-hidden="true"> · </span>
|
|
||||||
<a href="/glossaire/">Glossaire</a><span aria-hidden="true"> · </span>
|
<a href="/cas-ia/">Cas IA</a>
|
||||||
<a href="/atlas/">Atlas</a>
|
<span aria-hidden="true"> · </span>
|
||||||
|
|
||||||
|
<a href="/glossaire/">Glossaire</a>
|
||||||
|
<span aria-hidden="true"> · </span>
|
||||||
|
|
||||||
|
<a href="/recherche/">Recherche</a>
|
||||||
|
|
||||||
</nav>
|
</nav>
|
||||||
112
src/content.config.ts
Normal file
112
src/content.config.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
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 n’ont 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")])
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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([])
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
export const collections = {
|
||||||
|
commencer,
|
||||||
|
"archicrat-ia": archicratIa,
|
||||||
|
"cas-ia": casIa,
|
||||||
|
glossaire,
|
||||||
|
};
|
||||||
6381
src/content/archicrat-ia/chapitre-1.mdx
Normal file
6381
src/content/archicrat-ia/chapitre-1.mdx
Normal file
File diff suppressed because it is too large
Load Diff
6354
src/content/archicrat-ia/chapitre-2.mdx
Normal file
6354
src/content/archicrat-ia/chapitre-2.mdx
Normal file
File diff suppressed because it is too large
Load Diff
4947
src/content/archicrat-ia/chapitre-3.mdx
Normal file
4947
src/content/archicrat-ia/chapitre-3.mdx
Normal file
File diff suppressed because it is too large
Load Diff
7083
src/content/archicrat-ia/chapitre-4.mdx
Normal file
7083
src/content/archicrat-ia/chapitre-4.mdx
Normal file
File diff suppressed because it is too large
Load Diff
5720
src/content/archicrat-ia/chapitre-5.mdx
Normal file
5720
src/content/archicrat-ia/chapitre-5.mdx
Normal file
File diff suppressed because it is too large
Load Diff
417
src/content/archicrat-ia/conclusion.mdx
Normal file
417
src/content/archicrat-ia/conclusion.mdx
Normal file
@@ -0,0 +1,417 @@
|
|||||||
|
---
|
||||||
|
title: Conclusion — ArchiCraT-IA
|
||||||
|
edition: archicrat-ia
|
||||||
|
status: essai_these
|
||||||
|
level: 1
|
||||||
|
version: 0.1.0
|
||||||
|
concepts: []
|
||||||
|
links: []
|
||||||
|
order: 70
|
||||||
|
summary: ''
|
||||||
|
source:
|
||||||
|
kind: docx
|
||||||
|
path: sources/docx/archicrat-ia/Conclusion-Archicrat-IA-version_officielle.docx
|
||||||
|
---
|
||||||
|
|
||||||
|
Nous n'assistons ni à un retour primordial du désordre ni à une
|
||||||
|
raréfaction du pouvoir. Ce qui se transforme, plus discrètement mais de
|
||||||
|
manière décisive, ce sont les conditions mêmes sous lesquelles la
|
||||||
|
régulation peut apparaître. Ce qui se reconfigure sous nos yeux n'est
|
||||||
|
pas d'abord la quantité de pouvoir à l'œuvre dans nos sociétés, mais la
|
||||||
|
manière dont ce pouvoir se rend — ou ne se rend plus — visible,
|
||||||
|
adressable, contestable. La conflictualité ne disparaît pas ; elle
|
||||||
|
change de régime d'existence. Là où elle trouvait autrefois à se
|
||||||
|
formuler dans des lieux identifiables, à se différer dans des
|
||||||
|
temporalités instituées, à se confronter dans des espaces d'énonciation
|
||||||
|
reconnus, elle se trouve de plus en plus distribuée dans des chaînes
|
||||||
|
opératoires qui se présentent comme de simples enchaînements techniques.
|
||||||
|
Flux, scores, interfaces, barèmes, protocoles : autant de formes qui
|
||||||
|
n'abolissent pas les décisions, mais en modifient profondément les
|
||||||
|
conditions d'apparition. Ce qui relevait d'une épreuve devient
|
||||||
|
traitement. Ce qui relevait d'une adresse devient calcul. Ce qui
|
||||||
|
relevait d'une justification devient paramétrage.
|
||||||
|
|
||||||
|
Ce déplacement est d'autant plus difficile à saisir qu'il ne se donne
|
||||||
|
pas comme rupture. Il s'installe dans la continuité apparente des
|
||||||
|
dispositifs, dans l'amélioration de leur efficacité, dans la promesse
|
||||||
|
d'une gestion plus rapide, plus fluide, plus objective des situations.
|
||||||
|
Le conflit y est désamorcé au nom de la performance. Le différé y est
|
||||||
|
perçu comme ralentissement. L'exposition y est remplacée par une
|
||||||
|
visibilité sans interlocution, où tout semble disponible sans que rien
|
||||||
|
ne soit véritablement tenu. Il ne s'agit pas d'un vide. Il ne s'agit pas
|
||||||
|
d'un monde sans normes ni d'un effondrement du pouvoir. Il s'agit d'un
|
||||||
|
recouvrement. Ce que cet essai-thèse a nommé oblitération archicratique
|
||||||
|
désigne cette dynamique précise : la substitution progressive de la
|
||||||
|
scène par l'exécution, du différé par l'automaticité, de l'énonciation
|
||||||
|
par la trace, de l'épreuve par la donnée.
|
||||||
|
|
||||||
|
Le pouvoir ne cesse pas d'opérer ; il cesse de comparaître. Les
|
||||||
|
décisions continuent d'être prises ; elles cessent d'être tenues. Elles
|
||||||
|
ne disparaissent pas ; elles se soustraient aux formes où elles
|
||||||
|
pourraient être rapportées à leurs fondements, confrontées à leurs
|
||||||
|
effets, reprises à partir de ce qu'elles affectent.
|
||||||
|
|
||||||
|
Ce qui caractérise ainsi notre situation n'est pas une crise de la
|
||||||
|
conflictualité, mais une crise de sa tenue. Non l'absence de dissensus,
|
||||||
|
mais l'altération des formes capables de le porter. Non la disparition
|
||||||
|
du politique, mais sa désarticulation progressive hors des scènes où il
|
||||||
|
pouvait encore apparaître comme tel. À ce niveau, ce que nous appelons
|
||||||
|
crise ne relève plus d'un dysfonctionnement partiel, ni d'une dérive
|
||||||
|
simplement sectorielle. Elle engage les conditions même dans lesquelles
|
||||||
|
un monde peut encore faire apparaître, soutenir et transformer ce qui le
|
||||||
|
traverse.
|
||||||
|
|
||||||
|
Ce déplacement du regard a commandé tout le parcours accompli. Il a
|
||||||
|
d'abord fallu apprendre à voir. Distinguer ce qui fonde de ce qui opère,
|
||||||
|
ce qui opère de ce qui met à l'épreuve. Rompre avec les confusions qui
|
||||||
|
font passer l'exécution pour la justification, la visibilité pour
|
||||||
|
l'opposabilité, la procédure pour la scène. Sans cette discipline de
|
||||||
|
détectabilité, le présent demeure illisible et la critique se dissout
|
||||||
|
dans des généralités. L'une des ambitions premières de ce travail aura
|
||||||
|
donc été de donner des prises : non pas inventer un vocabulaire pour le
|
||||||
|
plaisir de l'invention, mais rendre discernable ce que les descriptions
|
||||||
|
ordinaires du pouvoir, de la gouvernance et de l'administration laissent
|
||||||
|
trop souvent se confondre.
|
||||||
|
|
||||||
|
Encore fallait-il que ce vocabulaire n'usurpe pas sa propre nécessité.
|
||||||
|
L'archicratie ne vaut pas parce qu'elle pourrait tout redire dans sa
|
||||||
|
langue ; elle vaut seulement là où elle permet de discerner quelque
|
||||||
|
chose qui, sans elle, resterait confondu, euphémisé ou inaperçu. Elle ne
|
||||||
|
constitue donc ni une théorie totale du politique, ni une clef
|
||||||
|
universelle des mondes historiques, mais un instrument critique situé,
|
||||||
|
tenu à une obligation de retenue : se taire là où il n'apporte aucun
|
||||||
|
gain de lisibilité, et répondre de ses distinctions là où il prétend en
|
||||||
|
produire. C'est à cette condition seulement qu'un paradigme cesse d'être
|
||||||
|
un idiome de surplomb pour devenir une épreuve réelle de connaissance.
|
||||||
|
|
||||||
|
Il a fallu ensuite remonter plus loin. Reconnaître que les formes
|
||||||
|
d'épreuve ne sont ni des raffinements tardifs des modernités
|
||||||
|
représentatives, ni des suppléments ajoutés à des ordres déjà
|
||||||
|
constitués, mais des conditions plus profondes de la tenue des mondes
|
||||||
|
humains. Avant les États, avant les bureaucraties, avant les
|
||||||
|
codifications juridiques stabilisées, des collectifs ont dû inventer des
|
||||||
|
manières de différer, de ritualiser, d'exposer ce qui les traversait.
|
||||||
|
L'histoire du politique ne commence pas avec la souveraineté constituée
|
||||||
|
; elle commence avec la nécessité, pour un monde traversé de forces
|
||||||
|
hétérogènes, de ne pas s'abandonner à leur pure immédiateté. Elle
|
||||||
|
commence là où quelque chose comme une reprise devient possible, là où
|
||||||
|
ce qui affecte peut être reconduit à une forme d'exposition, si
|
||||||
|
rudimentaire, violente ou dissymétrique soit-elle. La scène n'est donc
|
||||||
|
pas un luxe moderne. Elle appartient à la structure même des mondes qui
|
||||||
|
tiennent.
|
||||||
|
|
||||||
|
Il a fallu également traverser les grandes pensées du pouvoir, non pour
|
||||||
|
les annuler, ni pour les annexer de force à un nouveau système, mais
|
||||||
|
pour en mesurer les prises et les limites. Certaines ont privilégié le
|
||||||
|
fondement, d'autres l'opération, d'autres encore la conflictualité, la
|
||||||
|
dispersion des dispositifs, l'individuation, la justification ou le
|
||||||
|
dissensus. Toutes ont saisi quelque chose de réel ; aucune n'a tenu
|
||||||
|
entièrement ensemble les conditions d'une régulation habitable. Ce que
|
||||||
|
cette traversée a rendu possible, ce n'est pas une synthèse des
|
||||||
|
doctrines, mais une méta-grammaire du politique, capable de les relire à
|
||||||
|
partir de ce qu'elles permettent — ou non — de penser : comment un
|
||||||
|
ordre se fonde, comment il opère, comment il accepte d'être mis à
|
||||||
|
l'épreuve.
|
||||||
|
|
||||||
|
Il a fallu enfin éprouver cette grammaire dans l'histoire effective des
|
||||||
|
transformations modernes, là où les capacités de régulation ont atteint
|
||||||
|
une intensité inédite. Ce qui apparaît alors n'est pas seulement une
|
||||||
|
succession d'innovations techniques, mais une série de reconfigurations
|
||||||
|
du rapport entre fondement, opération et épreuve. Chaque révolution
|
||||||
|
industrielle a redessiné ce triangle ; chacune a accru certaines
|
||||||
|
puissances tout en décalant, fragmentant ou fragilisant les formes
|
||||||
|
capables de les soutenir. L'histoire moderne n'apparaît plus comme celle
|
||||||
|
d'un progrès simplement technique ; elle devient lisible comme celle des
|
||||||
|
déplacements successifs du lieu où le pouvoir se rend — ou cesse de se
|
||||||
|
rendre — comparable, contestable, révisable.
|
||||||
|
|
||||||
|
C'est au point le plus brûlant du présent que cette exigence se révèle
|
||||||
|
avec la plus grande netteté. Les tensions contemporaines ne se laissent
|
||||||
|
pas comprendre comme des crises séparées, ni comme des anomalies
|
||||||
|
sectorielles. Elles manifestent, chacune à leur manière, la difficulté
|
||||||
|
croissante à instituer des formes dans lesquelles ce qui est affecté par
|
||||||
|
les décisions peut être reconduit à une épreuve. Ce qui manque n'est pas
|
||||||
|
la capacité à produire des normes, des infrastructures, des critères,
|
||||||
|
des instruments. Ce qui manque, de plus en plus, c'est l'habileté à les
|
||||||
|
porter. De là la nécessité d'un déplacement conceptuel décisif :
|
||||||
|
substituer au lexique lisse de la durabilité la notion de co-viabilité.
|
||||||
|
Non pas un équilibre supposé entre intérêts déjà constitués, ni la
|
||||||
|
correction technocratique d'externalités, mais l'institution toujours
|
||||||
|
fragile, toujours révisable, toujours conflictuelle, des conditions sous
|
||||||
|
lesquelles des formes de vie hétérogènes peuvent encore tenir ensemble
|
||||||
|
sans destruction irréversible.
|
||||||
|
|
||||||
|
Ce qui se dégage ainsi de l'ensemble n'est pas une doctrine
|
||||||
|
supplémentaire, encore moins un système clos. C'est une condition — qui ne garantit ni harmonie ni salut, mais sans laquelle aucune
|
||||||
|
régulation ne peut être tenue comme monde. Cette condition peut
|
||||||
|
désormais être formulée simplement : une régulation ne devient habitable
|
||||||
|
qu'à la mesure où ce qui la fonde, ce qui l'opère et ce qui la met à
|
||||||
|
l'épreuve demeurent distinguables, articulés et exposables. Toute la
|
||||||
|
difficulté tient alors à ceci : maintenir cette distinction sans les
|
||||||
|
dissocier, et cette articulation sans les confondre. Là où ces
|
||||||
|
dimensions se confondent, se disjoignent ou se dérobent à l'exposition,
|
||||||
|
la régulation peut continuer à fonctionner ; elle cesse de se tenir.
|
||||||
|
|
||||||
|
C'est cette condition minimale que nous avons nommée archicratie. Ni
|
||||||
|
régime parmi d'autres, ni forme institutionnelle déterminée, ni idéal
|
||||||
|
moral à incarner : un seuil. Le seuil au-dessous duquel la régulation se
|
||||||
|
réduit à sa propre opérativité, et au-dessus duquel elle devient, au
|
||||||
|
moins en droit, habitable, parce qu'elle laisse ouverte la possibilité
|
||||||
|
de sa reprise. L'archicratie ne désigne ni la justice, ni la bonté, ni
|
||||||
|
la douceur des décisions ; elle désigne la condition sans laquelle ces
|
||||||
|
questions elles-mêmes cessent de pouvoir être posées politiquement.
|
||||||
|
|
||||||
|
Ce qui fonde une régulation ne se confond ni avec une autorité
|
||||||
|
abstraite, ni avec un texte, ni avec une tradition invoquée une fois
|
||||||
|
pour toutes ; cela renvoie à la capacité d'un ordre à exposer ses
|
||||||
|
raisons comme telles. Ce qui opère désigne les instruments, procédures
|
||||||
|
et dispositifs par lesquels le monde est effectivement découpé,
|
||||||
|
distribué, transformé. Ce qui met à l'épreuve, enfin, ne relève ni d'une
|
||||||
|
consultation formelle ni d'un recours marginal, mais de formes
|
||||||
|
instituées où fondements, opérations et effets peuvent être suspendus,
|
||||||
|
confrontés, repris.
|
||||||
|
|
||||||
|
Ces prises ne sont jamais données à l'état pur. Elles se recouvrent, se
|
||||||
|
déplacent, se distribuent inégalement selon les configurations. Mais
|
||||||
|
leur coprésence différenciée constitue la condition minimale d'une
|
||||||
|
régulation vivable. Là où l'opération se déploie sans être reconduite à
|
||||||
|
ses raisons, là où les décisions s'appliquent sans passer par des
|
||||||
|
épreuves effectives, la régulation bascule vers une forme de fermeture
|
||||||
|
qui ne relève ni du chaos ni du retrait du pouvoir, mais de son
|
||||||
|
auto-suffisance. Dans une telle configuration, le pouvoir ne se retire
|
||||||
|
pas ; il s'accomplit sans comparution. Il produit des effets, parfois
|
||||||
|
avec une grande précision, mais sans se laisser reprendre dans des
|
||||||
|
formes où ces effets pourraient être rapportés à des raisons
|
||||||
|
discutables. Il opère, mais ne s'expose plus. Il décide, mais ne se
|
||||||
|
laisse plus adresser. Tout fonctionne ; mais plus rien ne s'expose ni ne
|
||||||
|
s'explique.
|
||||||
|
|
||||||
|
La différence décisive se situe là. Entre une régulation capable
|
||||||
|
d'exécuter des procédures, de reproduire des normes, de gérer des flux,
|
||||||
|
et une régulation capable de se rapporter à elle-même à partir de ce
|
||||||
|
qu'elle affecte, la différence ne tient pas à l'intensité du pouvoir,
|
||||||
|
mais à la possibilité de sa mise à l'épreuve. Ce qui rend un monde
|
||||||
|
habitable n'est ni l'absence de tensions, ni la stabilité de ses
|
||||||
|
équilibres, ni la pure efficacité de ses dispositifs. C'est la forme
|
||||||
|
dans laquelle ce qui le traverse peut être porté sans être nié, différé
|
||||||
|
sans être dissous, exposé sans être annihilé.
|
||||||
|
|
||||||
|
À partir de là, la question n'est plus d'abord celle d'un bon régime,
|
||||||
|
mais celle d'un monde qui tient. Non d'un monde pacifié, homogène ou
|
||||||
|
réconcilié, mais d'un monde capable de porter ce qui le traverse sans
|
||||||
|
s'abolir dans sa propre exécution. Un monde qui ne tient ni par inertie,
|
||||||
|
ni par répétition, ni par l'évidence supposée de ses fondements. Il
|
||||||
|
tient parce qu'il est capable de porter ce qui le traverse sans le nier,
|
||||||
|
de différer ce qui l'affecte sans le dissoudre, d'exposer ce qui le
|
||||||
|
gouverne sans s'effondrer sous sa propre mise en question. Habiter un
|
||||||
|
monde ne signifie pas simplement y vivre. Cela signifie pouvoir y
|
||||||
|
comparaître. Pouvoir y demander d'où parle ce qui décide. Pouvoir y
|
||||||
|
identifier ce qui opère. Pouvoir y rouvrir le temps lorsque l'exécution
|
||||||
|
tend à se refermer sur elle-même. Pouvoir y faire apparaître ce qui,
|
||||||
|
sans cela, demeurerait converti en variable, en score, en flux.
|
||||||
|
|
||||||
|
La scène prend ici son sens le plus fort. Elle n'est ni un supplément
|
||||||
|
institutionnel, ni un décor ajouté au pouvoir pour en améliorer
|
||||||
|
l'acceptabilité, ni une métaphore commode pour désigner des espaces de
|
||||||
|
parole. Elle est l'une des formes à travers lesquelles un ordre cesse
|
||||||
|
d'être purement opératoire pour devenir politiquement tenable. Là où il
|
||||||
|
y a scène au sens fort — c'est-à-dire espace différé, documenté,
|
||||||
|
institué, capable de suspendre et de requalifier — la régulation ne se
|
||||||
|
contente pas d'agir : elle accepte de comparaître. C'est dans cette
|
||||||
|
comparution que se joue la possibilité, pour un monde, de ne pas se
|
||||||
|
réduire à ce qu'il exécute.
|
||||||
|
|
||||||
|
Il faut ici maintenir une distinction que tout ce travail a jugée
|
||||||
|
décisive. Dire que la scène est condition de viabilité ne signifie
|
||||||
|
nullement que toute scène serait en elle-même juste, démocratique ou
|
||||||
|
émancipatrice. L'histoire des formes politiques, juridiques,
|
||||||
|
religieuses, administratives, guerrières, marchandes ou sacrificielles
|
||||||
|
montre au contraire que des scènes peuvent être violentes,
|
||||||
|
dissymétriques, inquisitoriales, spectaculaires, capturées. La scène
|
||||||
|
n'est pas bonne parce qu'elle apparaît ; elle devient politiquement
|
||||||
|
décisive lorsqu'elle institue réellement l'épreuve de ce qu'elle expose.
|
||||||
|
Ce qui compte n'est pas l'existence abstraite d'un lieu d'apparition,
|
||||||
|
mais la possibilité effective qu'il ouvre : peut-on y demander les
|
||||||
|
fondements ? Les instruments peuvent-ils y être rendus visibles ? Les
|
||||||
|
effets peuvent-ils y être rapportés à ceux qu'ils affectent ? Le différé
|
||||||
|
est-il réel ou purement fictif ? La suspension a-t-elle une force
|
||||||
|
transformatrice ou n'est-elle qu'un rite sans prise ?
|
||||||
|
|
||||||
|
Il n'en demeure pas moins que, sans scène, la régulation se dégrade
|
||||||
|
qualitativement. Elle peut continuer à fonctionner ; elle peut même
|
||||||
|
gagner en efficacité apparente. Mais elle perd sa mémoire, sa
|
||||||
|
réversibilité, sa capacité à se rapporter à elle-même autrement que par
|
||||||
|
recalibrage interne. Elle applique, classe, répartit, déclenche, module
|
||||||
|
; mais elle ne se reprend plus. Elle produit des normes sans en exposer
|
||||||
|
les raisons, des décisions sans en instituer l'épreuve, des effets sans
|
||||||
|
en organiser le retour. Elle tient encore ; mais elle ne sait plus
|
||||||
|
répondre de la manière dont elle tient. C'est en ce point qu'un monde
|
||||||
|
sans scène devient injustifiable. Non pas nécessairement injuste dans
|
||||||
|
chacun de ses effets immédiats ; non pas chaotique ; non pas dépourvu de
|
||||||
|
cohérence locale. Il peut très bien fonctionner, produire des résultats,
|
||||||
|
stabiliser provisoirement des situations, maintenir des chaînes
|
||||||
|
d'obéissance ou d'adaptation. Mais il devient injustifiable parce qu'il
|
||||||
|
ne dispose plus des formes dans lesquelles ses propres décisions peuvent
|
||||||
|
être rejouées, exposées, interrogées, reformulées.
|
||||||
|
|
||||||
|
On comprend alors ce que la co-viabilité signifie exactement. Elle ne
|
||||||
|
désigne ni la simple coexistence de formes de vie différentes, ni leur
|
||||||
|
compatibilité gestionnaire, ni un optimum de répartition des ressources
|
||||||
|
ou des charges. Elle désigne la capacité, toujours fragile, toujours
|
||||||
|
située, toujours révisable, d'un monde à instituer des formes dans
|
||||||
|
lesquelles les hétérogènes qui le traversent peuvent être mis en tension
|
||||||
|
sans être soit mutuellement détruits, soit administrativement
|
||||||
|
neutralisés. Elle est moins un état qu'un régime d'épreuves. Elle ne se
|
||||||
|
mesure pas seulement à l'efficacité des ajustements ; elle se mesure à
|
||||||
|
la possibilité qu'un ordre laisse ouvert sa propre reprise à partir de
|
||||||
|
ce qu'il affecte.
|
||||||
|
|
||||||
|
C'est à ce niveau que le diagnostic du présent trouve sa formulation la
|
||||||
|
plus nette. Non dans l'idée d'un monde privé de régulation, mais dans
|
||||||
|
celle d'un monde où la régulation tend à se déployer hors des formes qui
|
||||||
|
permettaient de la tenir. Qu'il s'agisse des droits sociaux, de
|
||||||
|
l'habitabilité écologique des milieux ou des architectures numériques de
|
||||||
|
décision, la même logique se renforce : les dispositifs deviennent plus
|
||||||
|
puissants au moment même où les formes capables d'en soutenir l'épreuve
|
||||||
|
deviennent plus fragiles, plus tardives, plus périphériques. Ce ne sont
|
||||||
|
pas les décisions qui disparaissent ; ce sont les manières dont elles
|
||||||
|
pourraient être tenues.
|
||||||
|
|
||||||
|
Les droits, dans de nombreuses configurations sociales, se trouvent
|
||||||
|
intermédiés par des procédures dont la logique demeure difficilement
|
||||||
|
accessible à ceux qu'elles affectent ; les décisions qui concernent
|
||||||
|
l'habitabilité écologique des milieux se trouvent portées par des
|
||||||
|
instruments puissants, mais rarement rapportées à des espaces où leurs
|
||||||
|
fondements pourraient être disputés ; les architectures numériques et
|
||||||
|
algorithmiques rendent possible une distribution fine des traitements,
|
||||||
|
des classements, des accès, sans rendre aisément localisable le lieu de
|
||||||
|
leur mise à l'épreuve. Ces dimensions ne doivent pas être comprises
|
||||||
|
comme des sphères séparées. Elles constituent les expressions
|
||||||
|
différenciées d'un même processus : celui par lequel la régulation tend
|
||||||
|
à se déployer hors des formes d'épreuve qui permettaient de la tenir
|
||||||
|
comme monde.
|
||||||
|
|
||||||
|
C'est en ce sens que l'autarchicratie peut être nommée comme la
|
||||||
|
contre-figure terminale de l'archicratie. Non un régime au sens
|
||||||
|
classique, ni une idéologie, ni un type d'État, mais une configuration
|
||||||
|
dans laquelle la régulation tend à se refermer sur sa propre
|
||||||
|
opérativité, à produire ses propres critères de validité, à
|
||||||
|
s'auto-justifier sans passer par des épreuves effectives. Dans une telle
|
||||||
|
configuration, les instruments, les modèles, les indicateurs, les
|
||||||
|
procédures deviennent à la fois ce qui opère et ce qui justifie. Les
|
||||||
|
boucles se ferment. Les ajustements se font à partir de leurs propres
|
||||||
|
résultats. Les audits vérifient la conformité à des critères produits
|
||||||
|
par les systèmes eux-mêmes. La régulation devient auto-référentielle.
|
||||||
|
|
||||||
|
Cette bascule ne doit pas être dramatisée comme si elle était totale,
|
||||||
|
homogène, déjà accomplie. Des scènes subsistent, parfois robustes,
|
||||||
|
parfois fragiles. Des espaces de contestation, de délibération, de
|
||||||
|
reprise continuent d'exister. Mais ils apparaissent souvent comme
|
||||||
|
disjoints des lieux où les décisions se prennent effectivement. La
|
||||||
|
tension se joue moins entre présence et absence de scène qu'entre leur
|
||||||
|
centralité et leur marginalisation. Le problème décisif n'est pas de
|
||||||
|
savoir si toute scène a disparu ; il est de comprendre que la dynamique
|
||||||
|
dominante tend à rendre optionnelle l'épreuve dont dépend pourtant la
|
||||||
|
viabilité de la régulation.
|
||||||
|
|
||||||
|
C'est ici que la distinction entre durabilité et co-viabilité prend
|
||||||
|
toute sa force. La durabilité, telle qu'elle s'est imposée dans les
|
||||||
|
discours contemporains, ne doit pas être critiquée d'abord pour ses
|
||||||
|
intentions, mais pour sa forme. Elle tend à fonctionner comme un
|
||||||
|
opérateur de neutralisation de la conflictualité : en posant comme
|
||||||
|
objectif la préservation ou l'ajustement de certains équilibres, elle
|
||||||
|
déplace l'attention vers la gestion des variables, l'optimisation des
|
||||||
|
paramètres, la correction des trajectoires. Ce déplacement n'est pas
|
||||||
|
illégitime en soi ; il le devient lorsqu'il s'accompagne d'une
|
||||||
|
évacuation des formes dans lesquelles les choix qui structurent ces
|
||||||
|
trajectoires pourraient être discutés. La durabilité peut alors
|
||||||
|
s'accommoder d'une régulation sans scène. La co-viabilité, elle, en fait
|
||||||
|
une impossibilité.
|
||||||
|
|
||||||
|
La différence est décisive. La première tend à organiser la continuité
|
||||||
|
des systèmes ; la seconde à instituer les conditions de leur reprise. La
|
||||||
|
première privilégie l'ajustement des variables ; la seconde la mise à
|
||||||
|
l'épreuve des fondements. La première peut se satisfaire d'une
|
||||||
|
gouvernance qui corrige des déséquilibres ; la seconde exige des formes
|
||||||
|
dans lesquelles les conditions mêmes de ces corrections peuvent être
|
||||||
|
adressées, contestées, transformées. Ainsi comprise, la co-viabilité ne
|
||||||
|
constitue pas un idéal abstrait. Elle désigne le régime minimal dans
|
||||||
|
lequel un monde peut continuer à se transformer sans se soustraire à sa
|
||||||
|
propre interrogation. Elle n'abolit pas les tensions ; elle en organise
|
||||||
|
la tenue. Elle n'élimine pas les conflits ; elle en rend l'épreuve
|
||||||
|
possible. Elle ne garantit pas la justice ; elle rend au moins pensable
|
||||||
|
sa recherche.
|
||||||
|
|
||||||
|
Il reste alors à comprendre ce qui, en dernière instance, est affecté
|
||||||
|
par cette transformation. Non pas seulement des institutions, des
|
||||||
|
règles, des procédures, mais des formes d'existence. Des vies. Des
|
||||||
|
milieux. Des devenirs. Si l'archicratie prend finalement une telle
|
||||||
|
importance, ce n'est pas parce qu'elle offrirait une théorie plus
|
||||||
|
satisfaisante du pouvoir ; c'est parce qu'elle reconduit l'analyse à ce
|
||||||
|
qui, sans scène, devient politiquement illisible. Là où la régulation se
|
||||||
|
déploie sous forme de flux, de calculs, de traitements, le vivant tend à
|
||||||
|
être reconduit à des variables. Les milieux deviennent des stocks ou des
|
||||||
|
contraintes. Les corps deviennent des profils, des trajectoires, des
|
||||||
|
cas. Les expériences deviennent des données d'ajustement. Ce processus
|
||||||
|
n'est pas nécessairement intentionnel. Il résulte de la logique même des
|
||||||
|
dispositifs qui, pour fonctionner, doivent simplifier, catégoriser,
|
||||||
|
standardiser. Mais cette simplification a un effet décisif : elle
|
||||||
|
désinscrit le vivant de la scène. Elle le rend opérable sans qu'il ait à
|
||||||
|
apparaître.
|
||||||
|
|
||||||
|
Le vivant ne disparaît pas ; il devient politiquement illisible. Il est
|
||||||
|
là, partout affecté, mobilisé, transformé — mais de moins en moins
|
||||||
|
capable de faire retour sur ce qui l'affecte. Un monde sans archicration
|
||||||
|
est un monde dans lequel le vivant est présent sans être représentable,
|
||||||
|
affecté sans être adressable, engagé sans être entendu. Il est pris dans
|
||||||
|
des opérations, mais il ne peut plus apparaître comme ce à partir de
|
||||||
|
quoi celles-ci devraient être interrogées.
|
||||||
|
|
||||||
|
C'est en ce sens que l'oblitération archicratique produit une crise de
|
||||||
|
reconnaissance. Non pas au sens restreint d'une reconnaissance morale ou
|
||||||
|
symbolique, mais au sens plus fondamental d'une reconnaissance comme
|
||||||
|
condition d'apparition dans un espace où l'on peut être pris en compte.
|
||||||
|
Reconnaître ne signifie pas simplement identifier ou décrire. Cela
|
||||||
|
signifie instituer des formes dans lesquelles ce qui est affecté peut
|
||||||
|
être reconduit à une scène, où il peut être exposé, où il peut entrer en
|
||||||
|
relation avec ce qui décide. Sans cette reconnaissance, le vivant peut
|
||||||
|
être protégé, géré, optimisé ; il ne peut pas être politiquement tenu.
|
||||||
|
|
||||||
|
Il faut alors comprendre que la question de la scène n'est pas
|
||||||
|
extérieure à celle de la liberté. Elle en constitue l'une des conditions
|
||||||
|
minimales. Non la liberté comme autonomie absolue, mais comme
|
||||||
|
possibilité d'intervenir sur les conditions qui nous affectent. Une
|
||||||
|
société qui ne dispose plus de formes dans lesquelles ses propres
|
||||||
|
régulations puissent être interrogées tend à se percevoir comme soumise
|
||||||
|
à des nécessités. Elle perd la capacité de distinguer ce qui relève de
|
||||||
|
contraintes inévitables et ce qui relève de choix. À l'inverse, une
|
||||||
|
société qui institue des épreuves se dote de la possibilité de se
|
||||||
|
rapporter à elle-même comme à un ensemble de décisions révisables. Elle
|
||||||
|
ne supprime pas les contraintes, mais elle les inscrit dans des formes
|
||||||
|
où elles peuvent être discutées.
|
||||||
|
|
||||||
|
À ce point, aucune réponse définitive ne peut être apportée. Aucun
|
||||||
|
modèle achevé ne peut être proposé. Mais une exigence demeure, désormais
|
||||||
|
visible et irréductible. Un monde ne devient inhabitable ni parce qu'il
|
||||||
|
est traversé de tensions, ni parce qu'il doit décider dans
|
||||||
|
l'incertitude, ni parce qu'il affronte des contraintes puissantes. Il le
|
||||||
|
devient lorsqu'il ne dispose plus des formes capables de porter ce qui
|
||||||
|
le traverse autrement que par la pure exécution. Ce qui est en jeu n'est
|
||||||
|
ni la suppression du conflit, ni l'optimisation des dispositifs, ni la
|
||||||
|
stabilisation d'un équilibre. Ce qui est en jeu, c'est la possibilité de
|
||||||
|
maintenir ouvertes les formes dans lesquelles un monde peut se rapporter
|
||||||
|
à lui-même à partir de ce qu'il affecte. La possibilité, toujours
|
||||||
|
fragile, toujours menacée, de ne pas confondre ce qui fonctionne avec ce
|
||||||
|
qui se tient.
|
||||||
|
|
||||||
|
Rendre à la régulation les formes dans lesquelles elle peut encore être
|
||||||
|
tenue comme monde : telle est l'exigence à laquelle reconduit l'ensemble
|
||||||
|
de ce parcours. Non comme un programme, ni comme une promesse, mais
|
||||||
|
comme ce sans quoi aucune transformation ne peut être habitée. Car ce
|
||||||
|
n'est jamais l'ordre seul qui fait tenir un monde. C'est la possibilité,
|
||||||
|
pour cet ordre, d'être interrompu, exposé, repris. Là où cette
|
||||||
|
possibilité se ferme, le monde peut continuer à marcher ; il cesse peu à
|
||||||
|
peu d'être habitable. Là où elle demeure ouverte, fût-ce dans le
|
||||||
|
conflit, sous contrainte, précairement, quelque chose du politique
|
||||||
|
subsiste encore : non la paix, ni l'innocence, ni l'harmonie, mais la
|
||||||
|
capacité d'un monde à ne pas se confondre avec sa propre exécution.
|
||||||
1440
src/content/archicrat-ia/prologue.mdx
Normal file
1440
src/content/archicrat-ia/prologue.mdx
Normal file
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
Reference in New Issue
Block a user