Compare commits
106 Commits
chore/url-
...
testA-full
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| a81d206aba | |||
| 9801ea3cea | |||
| c11189fe11 | |||
| b47edb24cf | |||
| be191b09a0 | |||
| e06587478d | |||
| 402ffb04cd | |||
| 1cbfc02670 | |||
| 28d2fbbd2f | |||
| 225368a952 | |||
| 3574695041 | |||
| ea68025a1d | |||
| 3a08698003 | |||
| 3d583608c2 | |||
|
|
01ae95ab43 | ||
|
|
0d5821c640 | ||
|
|
2bcea39558 | ||
| af85970d4a | |||
| 210f621487 | |||
| 8ad960dc69 | |||
| d45a8b285f | |||
| b6e04a9138 | |||
| dcf1fc2d0b | |||
| 41b0517c6c | |||
| 6b43eb199d | |||
| d40f24e92d | |||
| 480a61b071 | |||
| a5d68d6a7e | |||
| 390f2c33e5 | |||
| 16485dc4a9 | |||
| a43ce5f188 | |||
| 0519ae2dd0 | |||
| 0d5b790e52 | |||
| 342e21b9ea | |||
| 4dec9e182b | |||
| c7ae883c6a | |||
| 9b4584f70a | |||
| 7b64fb7401 | |||
|
|
57cb23ce8b | ||
| 708b87ff35 | |||
| 577cfd08e8 | |||
| de9edbe532 | |||
| 5e95dc9898 | |||
| 006fec7efd | |||
| 2b612214bb | |||
| 29a6c349aa | |||
|
|
33a227c401 | ||
| 396ad4df7c | |||
|
|
0b39427090 | ||
| 8fcb18cb46 | |||
| d03fc519de | |||
| 97dd3797d6 | |||
| 6c7b7ab6a0 | |||
| 105dfe1b5b | |||
| 82f6453538 | |||
| fe862102d3 | |||
| 6ef538a0c4 | |||
| 689612ff7f | |||
| 7b135a4707 | |||
| 0cb8a54195 | |||
| a7a333397d | |||
| eb1d444776 | |||
| 68c3416594 | |||
| ae809e0152 | |||
| 7444eeb532 | |||
| 9bbebf5886 | |||
| fe7810671d | |||
| 53562025ac | |||
| 2b35315466 | |||
| 1b7f23d0a6 | |||
| 3d1d4d7952 | |||
| 3320563e1b | |||
| 798b2ddd0b | |||
| 31d4896f5d | |||
| 3fda37491d | |||
| 488c02b8b5 | |||
| f9ea3760e2 | |||
| 00e1a1d4b0 |
@@ -3,7 +3,7 @@ name: "Correction paragraphe"
|
||||
about: "Proposer une correction ciblée (un paragraphe) avec justification."
|
||||
---
|
||||
|
||||
## Chemin (ex: /archicratie/prologue/)
|
||||
## Chemin (ex: /archicrat-ia/prologue/)
|
||||
<!-- obligatoire -->
|
||||
/...
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ name: "Vérification factuelle / sources"
|
||||
about: "Signaler une assertion à sourcer ou à corriger (preuves, références)."
|
||||
---
|
||||
|
||||
## Chemin (ex: /archicratie/prologue/)
|
||||
## Chemin (ex: /archicrat-ia/prologue/)
|
||||
<!-- obligatoire -->
|
||||
/...
|
||||
|
||||
|
||||
449
.gitea/workflows/anno-apply-pr.yml
Normal file
@@ -0,0 +1,449 @@
|
||||
name: Anno Apply (PR)
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [labeled]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
issue:
|
||||
description: "Issue number to apply"
|
||||
required: true
|
||||
|
||||
env:
|
||||
NODE_OPTIONS: --dns-result-order=ipv4first
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
concurrency:
|
||||
group: anno-apply-${{ github.event.issue.number || github.event.issue.index || inputs.issue || 'manual' }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
apply-approved:
|
||||
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)
|
||||
env:
|
||||
INPUT_ISSUE: ${{ inputs.issue }}
|
||||
FORGE_API: ${{ vars.FORGE_API || vars.FORGE_BASE || vars.FORGE_BASE_URL }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export EVENT_JSON="/var/run/act/workflow/event.json"
|
||||
test -f "$EVENT_JSON" || { echo "❌ Missing $EVENT_JSON"; exit 1; }
|
||||
|
||||
node --input-type=module - <<'NODE' > /tmp/anno.env
|
||||
import fs from "node:fs";
|
||||
|
||||
const ev = JSON.parse(fs.readFileSync(process.env.EVENT_JSON, "utf8"));
|
||||
const repoObj = ev?.repository || {};
|
||||
|
||||
const cloneUrl =
|
||||
repoObj?.clone_url ||
|
||||
(repoObj?.html_url ? (repoObj.html_url.replace(/\/$/,"") + ".git") : "");
|
||||
|
||||
if (!cloneUrl) throw new Error("No repository clone_url/html_url in event.json");
|
||||
|
||||
let owner =
|
||||
repoObj?.owner?.login ||
|
||||
repoObj?.owner?.username ||
|
||||
(repoObj?.full_name ? repoObj.full_name.split("/")[0] : "");
|
||||
|
||||
let repo =
|
||||
repoObj?.name ||
|
||||
(repoObj?.full_name ? repoObj.full_name.split("/")[1] : "");
|
||||
|
||||
if (!owner || !repo) {
|
||||
const m = cloneUrl.match(/[:/](?<o>[^/]+)\/(?<r>[^/]+?)(?:\.git)?$/);
|
||||
if (m?.groups) { owner = owner || m.groups.o; repo = repo || m.groups.r; }
|
||||
}
|
||||
if (!owner || !repo) throw new Error("Cannot infer owner/repo");
|
||||
|
||||
const defaultBranch = repoObj?.default_branch || "main";
|
||||
|
||||
const issueNumber =
|
||||
ev?.issue?.number ||
|
||||
ev?.issue?.index ||
|
||||
(process.env.INPUT_ISSUE ? Number(process.env.INPUT_ISSUE) : 0);
|
||||
|
||||
if (!issueNumber || !Number.isFinite(Number(issueNumber))) {
|
||||
throw new Error("No issue number in event.json or workflow_dispatch input");
|
||||
}
|
||||
|
||||
// label name: best-effort (non-bloquant)
|
||||
let labelName = "workflow_dispatch";
|
||||
const lab = ev?.label;
|
||||
if (typeof lab === "string") labelName = lab;
|
||||
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 origin = u.origin;
|
||||
|
||||
const apiBase = (process.env.FORGE_API && String(process.env.FORGE_API).trim())
|
||||
? String(process.env.FORGE_API).trim().replace(/\/+$/,"")
|
||||
: origin;
|
||||
|
||||
function sh(s){ return JSON.stringify(String(s)); }
|
||||
|
||||
process.stdout.write([
|
||||
`CLONE_URL=${sh(cloneUrl)}`,
|
||||
`OWNER=${sh(owner)}`,
|
||||
`REPO=${sh(repo)}`,
|
||||
`DEFAULT_BRANCH=${sh(defaultBranch)}`,
|
||||
`ISSUE_NUMBER=${sh(issueNumber)}`,
|
||||
`LABEL_NAME=${sh(labelName)}`,
|
||||
`API_BASE=${sh(apiBase)}`
|
||||
].join("\n") + "\n");
|
||||
NODE
|
||||
|
||||
echo "✅ context:"
|
||||
sed -n '1,120p' /tmp/anno.env
|
||||
|
||||
- name: Early gate (label event fast-skip, but tolerant)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source /tmp/anno.env
|
||||
|
||||
echo "ℹ️ event label = $LABEL_NAME"
|
||||
|
||||
# Fast skip on obvious non-approved label events (avoid noise),
|
||||
# BUT do NOT skip if label payload is weird/unknown.
|
||||
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_REASON=\"label_not_approved_event\"" >> /tmp/anno.env
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "✅ continue to API gating (issue=$ISSUE_NUMBER)"
|
||||
|
||||
- name: Fetch issue + hard gate on labels + Type
|
||||
env:
|
||||
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source /tmp/anno.env
|
||||
[[ "${SKIP:-0}" != "1" ]] || { echo "ℹ️ skipped"; exit 0; }
|
||||
|
||||
test -n "${FORGE_TOKEN:-}" || { echo "❌ Missing secret FORGE_TOKEN"; exit 1; }
|
||||
|
||||
curl -fsS \
|
||||
-H "Authorization: token $FORGE_TOKEN" \
|
||||
-H "Accept: application/json" \
|
||||
"$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER" \
|
||||
-o /tmp/issue.json
|
||||
|
||||
node --input-type=module - <<'NODE' >> /tmp/anno.env
|
||||
import fs from "node:fs";
|
||||
|
||||
const issue = JSON.parse(fs.readFileSync("/tmp/issue.json","utf8"));
|
||||
const title = String(issue.title || "");
|
||||
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) {
|
||||
const re = new RegExp(`^\\s*${key}\\s*:\\s*([^\\n\\r]+)`, "mi");
|
||||
const m = body.match(re);
|
||||
return m ? m[1].trim() : "";
|
||||
}
|
||||
|
||||
const typeRaw = pickLine("Type");
|
||||
const type = String(typeRaw || "").trim().toLowerCase();
|
||||
|
||||
const allowed = new Set(["type/media","type/reference","type/comment"]);
|
||||
const proposer = new Set(["type/correction","type/fact-check"]);
|
||||
|
||||
const out = [];
|
||||
out.push(`ISSUE_TITLE=${JSON.stringify(title)}`);
|
||||
out.push(`ISSUE_TYPE=${JSON.stringify(type)}`);
|
||||
|
||||
// HARD gate: must currently have state/approved (avoids depending on event payload)
|
||||
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) {
|
||||
out.push(`SKIP=1`);
|
||||
out.push(`SKIP_REASON=${JSON.stringify("missing_type")}`);
|
||||
} else if (allowed.has(type)) {
|
||||
// proceed
|
||||
} else if (proposer.has(type)) {
|
||||
out.push(`SKIP=1`);
|
||||
out.push(`SKIP_REASON=${JSON.stringify("proposer_type:"+type)}`);
|
||||
} else {
|
||||
out.push(`SKIP=1`);
|
||||
out.push(`SKIP_REASON=${JSON.stringify("unsupported_type:"+type)}`);
|
||||
}
|
||||
|
||||
process.stdout.write(out.join("\n") + "\n");
|
||||
NODE
|
||||
|
||||
echo "✅ gating result:"
|
||||
grep -E '^(ISSUE_TYPE|SKIP|SKIP_REASON)=' /tmp/anno.env || true
|
||||
|
||||
- name: Comment issue if skipped (Proposer / unsupported / missing Type)
|
||||
if: ${{ always() }}
|
||||
env:
|
||||
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source /tmp/anno.env || true
|
||||
|
||||
[[ "${SKIP:-0}" == "1" ]] || exit 0
|
||||
|
||||
# IMPORTANT: do NOT comment for "not_approved_label_present" (avoid spam on other label events)
|
||||
if [[ "${SKIP_REASON:-}" == "not_approved_label_present" || "${SKIP_REASON:-}" == "label_not_approved_event" ]]; then
|
||||
echo "ℹ️ skip reason=${SKIP_REASON} -> no comment"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
test -n "${FORGE_TOKEN:-}" || exit 0
|
||||
|
||||
REASON="${SKIP_REASON:-}"
|
||||
TYPE="${ISSUE_TYPE:-}"
|
||||
|
||||
if [[ "$REASON" == proposer_type:* ]]; then
|
||||
MSG="ℹ️ Ticket #${ISSUE_NUMBER} détecté comme **Proposer** (${TYPE}).\n\n- Ce type est **traité manuellement par les editors**.\n✅ Aucun traitement automatique."
|
||||
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."
|
||||
else
|
||||
MSG="ℹ️ Ticket #${ISSUE_NUMBER} ignoré : champ 'Type:' manquant ou illisible.\n\nAjoute : Type: 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 \
|
||||
-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: Checkout default branch
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source /tmp/anno.env
|
||||
[[ "${SKIP:-0}" != "1" ]] || { echo "ℹ️ skipped"; exit 0; }
|
||||
|
||||
rm -rf .git
|
||||
git init -q
|
||||
git remote add origin "$CLONE_URL"
|
||||
git fetch --depth 1 origin "$DEFAULT_BRANCH"
|
||||
git -c advice.detachedHead=false checkout -q FETCH_HEAD
|
||||
git log -1 --oneline
|
||||
|
||||
- name: Install deps
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source /tmp/anno.env
|
||||
[[ "${SKIP:-0}" != "1" ]] || { echo "ℹ️ skipped"; exit 0; }
|
||||
npm ci --no-audit --no-fund
|
||||
|
||||
- name: Check apply script exists
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source /tmp/anno.env
|
||||
[[ "${SKIP:-0}" != "1" ]] || { echo "ℹ️ skipped"; exit 0; }
|
||||
test -f scripts/apply-annotation-ticket.mjs || {
|
||||
echo "❌ missing scripts/apply-annotation-ticket.mjs on $DEFAULT_BRANCH"
|
||||
ls -la scripts | sed -n '1,200p' || true
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Build dist (needed for --verify)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source /tmp/anno.env
|
||||
[[ "${SKIP:-0}" != "1" ]] || { echo "ℹ️ skipped"; exit 0; }
|
||||
|
||||
npm run build
|
||||
|
||||
test -f dist/para-index.json || {
|
||||
echo "❌ missing dist/para-index.json after build"
|
||||
ls -la dist | sed -n '1,200p' || true
|
||||
exit 1
|
||||
}
|
||||
echo "✅ dist/para-index.json present"
|
||||
|
||||
- name: Apply ticket on bot branch (strict+verify, commit)
|
||||
continue-on-error: true
|
||||
env:
|
||||
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
|
||||
BOT_GIT_NAME: ${{ secrets.BOT_GIT_NAME }}
|
||||
BOT_GIT_EMAIL: ${{ secrets.BOT_GIT_EMAIL }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source /tmp/anno.env
|
||||
[[ "${SKIP:-0}" != "1" ]] || { echo "ℹ️ skipped"; exit 0; }
|
||||
test -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; }
|
||||
|
||||
git config user.name "${BOT_GIT_NAME:-archicratie-bot}"
|
||||
git config user.email "${BOT_GIT_EMAIL:-bot@archicratie.local}"
|
||||
|
||||
START_SHA="$(git rev-parse HEAD)"
|
||||
TS="$(date -u +%Y%m%d-%H%M%S)"
|
||||
BR="bot/anno-${ISSUE_NUMBER}-${TS}"
|
||||
echo "BRANCH=$BR" >> /tmp/anno.env
|
||||
git checkout -b "$BR"
|
||||
|
||||
export FORGE_API="$API_BASE"
|
||||
export GITEA_OWNER="$OWNER"
|
||||
export GITEA_REPO="$REPO"
|
||||
|
||||
LOG="/tmp/apply.log"
|
||||
set +e
|
||||
node scripts/apply-annotation-ticket.mjs "$ISSUE_NUMBER" --strict --verify --commit >"$LOG" 2>&1
|
||||
RC=$?
|
||||
set -e
|
||||
|
||||
echo "APPLY_RC=$RC" >> /tmp/anno.env
|
||||
|
||||
echo "== apply log (tail) =="
|
||||
tail -n 180 "$LOG" || true
|
||||
|
||||
END_SHA="$(git rev-parse HEAD)"
|
||||
|
||||
if [[ "$RC" -ne 0 ]]; then
|
||||
echo "NOOP=0" >> /tmp/anno.env
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$START_SHA" == "$END_SHA" ]]; then
|
||||
echo "NOOP=1" >> /tmp/anno.env
|
||||
else
|
||||
echo "NOOP=0" >> /tmp/anno.env
|
||||
echo "END_SHA=$END_SHA" >> /tmp/anno.env
|
||||
fi
|
||||
|
||||
- name: Comment issue on failure (strict/verify/etc)
|
||||
if: ${{ always() }}
|
||||
env:
|
||||
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source /tmp/anno.env || true
|
||||
[[ "${SKIP:-0}" != "1" ]] || { echo "ℹ️ skipped"; exit 0; }
|
||||
|
||||
RC="${APPLY_RC:-0}"
|
||||
if [[ "$RC" == "0" ]]; then
|
||||
echo "ℹ️ no failure detected"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
test -n "${FORGE_TOKEN:-}" || exit 0
|
||||
|
||||
if [[ -f /tmp/apply.log ]]; then
|
||||
BODY="$(tail -n 160 /tmp/apply.log | sed 's/\r$//')"
|
||||
else
|
||||
BODY="(no apply log found)"
|
||||
fi
|
||||
|
||||
MSG="❌ apply-annotation-ticket a échoué (rc=${RC}).\n\n\`\`\`\n${BODY}\n\`\`\`\n"
|
||||
PAYLOAD="$(node --input-type=module -e 'console.log(JSON.stringify({body: process.argv[1]||""}))' "$MSG")"
|
||||
|
||||
curl -fsS -X POST \
|
||||
-H "Authorization: token $FORGE_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
"$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER/comments" \
|
||||
--data-binary "$PAYLOAD"
|
||||
|
||||
- name: Push bot branch
|
||||
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" ]] || { echo "ℹ️ apply failed -> 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 '
|
||||
const [clone, tok] = process.argv.slice(1);
|
||||
const u = new URL(clone);
|
||||
u.username = "oauth2";
|
||||
u.password = tok;
|
||||
console.log(u.toString());
|
||||
' "$CLONE_URL" "$FORGE_TOKEN")"
|
||||
|
||||
git remote set-url origin "$AUTH_URL"
|
||||
git push -u origin "$BRANCH"
|
||||
|
||||
- name: Create PR + comment issue
|
||||
if: ${{ always() }}
|
||||
env:
|
||||
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source /tmp/anno.env || true
|
||||
[[ "${SKIP:-0}" != "1" ]] || exit 0
|
||||
|
||||
[[ "${APPLY_RC:-0}" == "0" ]] || { echo "ℹ️ apply failed -> skip PR"; exit 0; }
|
||||
[[ "${NOOP:-0}" == "0" ]] || { echo "ℹ️ no-op -> skip PR"; exit 0; }
|
||||
|
||||
PR_TITLE="anno: apply ticket #${ISSUE_NUMBER}"
|
||||
PR_BODY="PR auto depuis ticket #${ISSUE_NUMBER} (state/approved).\n\n- Branche: ${BRANCH}\n- Commit: ${END_SHA}\n\nMerge si CI OK."
|
||||
|
||||
PR_PAYLOAD="$(node --input-type=module -e '
|
||||
const [title, body, base, head] = process.argv.slice(1);
|
||||
console.log(JSON.stringify({ title, body, base, head, allow_maintainer_edit: true }));
|
||||
' "$PR_TITLE" "$PR_BODY" "$DEFAULT_BRANCH" "${OWNER}:${BRANCH}")"
|
||||
|
||||
PR_JSON="$(curl -fsS -X POST \
|
||||
-H "Authorization: token $FORGE_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
"$API_BASE/api/v1/repos/$OWNER/$REPO/pulls" \
|
||||
--data-binary "$PR_PAYLOAD")"
|
||||
|
||||
PR_URL="$(node --input-type=module -e '
|
||||
const pr = JSON.parse(process.argv[1] || "{}");
|
||||
console.log(pr.html_url || pr.url || "");
|
||||
' "$PR_JSON")"
|
||||
|
||||
test -n "$PR_URL" || { echo "❌ PR URL missing. Raw: $PR_JSON"; exit 1; }
|
||||
|
||||
MSG="✅ PR créée pour ticket #${ISSUE_NUMBER} : ${PR_URL}"
|
||||
C_PAYLOAD="$(node --input-type=module -e 'console.log(JSON.stringify({body: process.argv[1]||""}))' "$MSG")"
|
||||
|
||||
curl -fsS -X POST \
|
||||
-H "Authorization: token $FORGE_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
"$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER/comments" \
|
||||
--data-binary "$C_PAYLOAD"
|
||||
|
||||
echo "✅ PR: $PR_URL"
|
||||
|
||||
- name: Finalize (fail job if apply failed)
|
||||
if: ${{ always() }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source /tmp/anno.env || true
|
||||
|
||||
[[ "${SKIP:-0}" != "1" ]] || { echo "ℹ️ skipped"; exit 0; }
|
||||
|
||||
RC="${APPLY_RC:-0}"
|
||||
if [[ "$RC" != "0" ]]; then
|
||||
echo "❌ apply failed (rc=$RC)"
|
||||
exit "$RC"
|
||||
fi
|
||||
echo "✅ apply ok"
|
||||
181
.gitea/workflows/anno-reject.yml
Normal file
@@ -0,0 +1,181 @@
|
||||
name: Anno Reject (close issue)
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [labeled]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
issue:
|
||||
description: "Issue number to reject/close"
|
||||
required: true
|
||||
|
||||
env:
|
||||
NODE_OPTIONS: --dns-result-order=ipv4first
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
concurrency:
|
||||
group: anno-reject-${{ github.event.issue.number || github.event.issue.index || inputs.issue || 'manual' }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
reject:
|
||||
runs-on: mac-ci
|
||||
container:
|
||||
image: mcr.microsoft.com/devcontainers/javascript-node:22-bookworm
|
||||
|
||||
steps:
|
||||
- name: Tools sanity
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node --version
|
||||
|
||||
- name: Derive context (event.json / workflow_dispatch)
|
||||
env:
|
||||
INPUT_ISSUE: ${{ inputs.issue }}
|
||||
FORGE_API: ${{ vars.FORGE_API || vars.FORGE_BASE || vars.FORGE_BASE_URL }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export EVENT_JSON="/var/run/act/workflow/event.json"
|
||||
test -f "$EVENT_JSON" || { echo "❌ Missing $EVENT_JSON"; exit 1; }
|
||||
|
||||
node --input-type=module - <<'NODE' > /tmp/reject.env
|
||||
import fs from "node:fs";
|
||||
|
||||
const ev = JSON.parse(fs.readFileSync(process.env.EVENT_JSON, "utf8"));
|
||||
const repoObj = ev?.repository || {};
|
||||
|
||||
const cloneUrl =
|
||||
repoObj?.clone_url ||
|
||||
(repoObj?.html_url ? (repoObj.html_url.replace(/\/$/,"") + ".git") : "");
|
||||
|
||||
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) && cloneUrl) {
|
||||
const m = cloneUrl.match(/[:/](?<o>[^/]+)\/(?<r>[^/]+?)(?:\.git)?$/);
|
||||
if (m?.groups) { owner = owner || m.groups.o; repo = repo || m.groups.r; }
|
||||
}
|
||||
if (!owner || !repo) throw new Error("Cannot infer owner/repo");
|
||||
|
||||
const issueNumber =
|
||||
ev?.issue?.number ||
|
||||
ev?.issue?.index ||
|
||||
(process.env.INPUT_ISSUE ? Number(process.env.INPUT_ISSUE) : 0);
|
||||
|
||||
if (!issueNumber || !Number.isFinite(Number(issueNumber))) {
|
||||
throw new Error("No issue number in event.json or workflow_dispatch input");
|
||||
}
|
||||
|
||||
// label name: best-effort (non-bloquant)
|
||||
let labelName = "workflow_dispatch";
|
||||
const lab = ev?.label;
|
||||
if (typeof lab === "string") labelName = lab;
|
||||
else if (lab && typeof lab === "object" && typeof lab.name === "string") labelName = lab.name;
|
||||
|
||||
let apiBase = "";
|
||||
if (process.env.FORGE_API && String(process.env.FORGE_API).trim()) {
|
||||
apiBase = String(process.env.FORGE_API).trim().replace(/\/+$/,"");
|
||||
} else if (cloneUrl) {
|
||||
apiBase = new URL(cloneUrl).origin;
|
||||
} else {
|
||||
apiBase = "";
|
||||
}
|
||||
|
||||
function sh(s){ return JSON.stringify(String(s)); }
|
||||
|
||||
process.stdout.write([
|
||||
`OWNER=${sh(owner)}`,
|
||||
`REPO=${sh(repo)}`,
|
||||
`ISSUE_NUMBER=${sh(issueNumber)}`,
|
||||
`LABEL_NAME=${sh(labelName)}`,
|
||||
`API_BASE=${sh(apiBase)}`
|
||||
].join("\n") + "\n");
|
||||
NODE
|
||||
|
||||
echo "✅ context:"
|
||||
sed -n '1,120p' /tmp/reject.env
|
||||
|
||||
- name: Early gate (fast-skip, tolerant)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source /tmp/reject.env
|
||||
echo "ℹ️ event label = $LABEL_NAME"
|
||||
|
||||
if [[ "$LABEL_NAME" != "state/rejected" && "$LABEL_NAME" != "workflow_dispatch" && "$LABEL_NAME" != "" && "$LABEL_NAME" != "[object Object]" ]]; then
|
||||
echo "ℹ️ label=$LABEL_NAME => skip early"
|
||||
echo "SKIP=1" >> /tmp/reject.env
|
||||
echo "SKIP_REASON=\"label_not_rejected_event\"" >> /tmp/reject.env
|
||||
exit 0
|
||||
fi
|
||||
|
||||
- name: Comment + close (only if label state/rejected is PRESENT now, and no conflict)
|
||||
env:
|
||||
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source /tmp/reject.env
|
||||
[[ "${SKIP:-0}" != "1" ]] || { echo "ℹ️ skipped"; exit 0; }
|
||||
|
||||
test -n "${FORGE_TOKEN:-}" || { echo "❌ Missing secret FORGE_TOKEN"; exit 1; }
|
||||
test -n "${API_BASE:-}" || { echo "❌ Missing API_BASE"; exit 1; }
|
||||
|
||||
curl -fsS \
|
||||
-H "Authorization: token $FORGE_TOKEN" \
|
||||
-H "Accept: application/json" \
|
||||
"$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER" \
|
||||
-o /tmp/reject.issue.json
|
||||
|
||||
node --input-type=module - <<'NODE' > /tmp/reject.flags
|
||||
import fs from "node:fs";
|
||||
const issue = JSON.parse(fs.readFileSync("/tmp/reject.issue.json","utf8"));
|
||||
const labels = Array.isArray(issue.labels) ? issue.labels.map(l => String(l.name || "")).filter(Boolean) : [];
|
||||
const hasApproved = labels.includes("state/approved");
|
||||
const hasRejected = labels.includes("state/rejected");
|
||||
process.stdout.write(`HAS_APPROVED=${hasApproved ? "1":"0"}\nHAS_REJECTED=${hasRejected ? "1":"0"}\n`);
|
||||
NODE
|
||||
|
||||
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
|
||||
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")"
|
||||
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"
|
||||
echo "ℹ️ conflict => stop"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
MSG="❌ Ticket #${ISSUE_NUMBER} refusé (label state/rejected)."
|
||||
PAYLOAD="$(node --input-type=module -e 'console.log(JSON.stringify({body: process.argv[1]||""}))' "$MSG")"
|
||||
|
||||
curl -fsS -X POST \
|
||||
-H "Authorization: token $FORGE_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
"$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER/comments" \
|
||||
--data-binary "$PAYLOAD"
|
||||
|
||||
curl -fsS -X PATCH \
|
||||
-H "Authorization: token $FORGE_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
"$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER" \
|
||||
--data-binary '{"state":"closed"}'
|
||||
|
||||
echo "✅ rejected+closed"
|
||||
@@ -4,22 +4,37 @@ on:
|
||||
issues:
|
||||
types: [opened, edited]
|
||||
|
||||
concurrency:
|
||||
group: auto-label-${{ github.event.issue.number || github.event.issue.index || 'manual' }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
label:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: mac-ci
|
||||
container:
|
||||
image: mcr.microsoft.com/devcontainers/javascript-node:22-bookworm
|
||||
|
||||
steps:
|
||||
- name: Apply labels from Type/State/Category
|
||||
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 }}
|
||||
REPO_FULL: ${{ gitea.repository }}
|
||||
EVENT_PATH: ${{ github.event_path }}
|
||||
NODE_OPTIONS: --dns-result-order=ipv4first
|
||||
run: |
|
||||
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)
|
||||
event_path = os.environ["EVENT_PATH"]
|
||||
|
||||
@@ -46,12 +61,9 @@ jobs:
|
||||
print("PARSED:", {"Type": t, "State": s, "Category": c})
|
||||
|
||||
# 1) explicite depuis le body
|
||||
if t:
|
||||
desired.add(t)
|
||||
if s:
|
||||
desired.add(s)
|
||||
if c:
|
||||
desired.add(c)
|
||||
if t: desired.add(t)
|
||||
if s: desired.add(s)
|
||||
if c: desired.add(c)
|
||||
|
||||
# 2) fallback depuis le titre si Type absent
|
||||
if not t:
|
||||
@@ -76,42 +88,56 @@ jobs:
|
||||
"Authorization": f"token {token}",
|
||||
"Accept": "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")
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=20) as r:
|
||||
b = r.read()
|
||||
return json.loads(b.decode("utf-8")) if b else None
|
||||
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
|
||||
last_err = None
|
||||
for i in range(retries):
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||
b = r.read()
|
||||
return json.loads(b.decode("utf-8")) if b else None
|
||||
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 = 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}
|
||||
|
||||
missing = [x for x in desired if x not in name_to_id]
|
||||
if 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
|
||||
current = jreq("GET", f"{api}/repos/{owner}/{repo}/issues/{number}/labels") or []
|
||||
current_ids = {x.get("id") for x in current if x.get("id") is not None}
|
||||
current = jreq("GET", f"{api}/repos/{owner}/{repo}/issues/{number}/labels", timeout=60) or []
|
||||
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))
|
||||
|
||||
# set labels = union (n'enlève rien)
|
||||
# Replace labels = union (n'enlève rien)
|
||||
url = f"{api}/repos/{owner}/{repo}/issues/{number}/labels"
|
||||
try:
|
||||
jreq("PUT", url, {"labels": final_ids})
|
||||
except Exception:
|
||||
jreq("PUT", url, final_ids)
|
||||
|
||||
# IMPORTANT: on n'envoie JAMAIS une liste brute ici (ça a causé le 422)
|
||||
jreq("PUT", url, {"labels": final_ids}, timeout=90, retries=4)
|
||||
|
||||
# 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)}")
|
||||
PY
|
||||
@@ -3,7 +3,7 @@ name: CI
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
branches: [master]
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
@@ -15,7 +15,7 @@ defaults:
|
||||
|
||||
jobs:
|
||||
build-and-anchors:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: mac-ci
|
||||
container:
|
||||
image: mcr.microsoft.com/devcontainers/javascript-node:22-bookworm
|
||||
|
||||
@@ -79,22 +79,7 @@ jobs:
|
||||
set -euo pipefail
|
||||
npm ci
|
||||
|
||||
- name: Inline scripts syntax check
|
||||
- name: Full test suite (CI=1)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node scripts/check-inline-js.mjs
|
||||
|
||||
- name: Build (includes postbuild injection + pagefind)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
npm run build
|
||||
|
||||
- name: Anchors contract
|
||||
run: |
|
||||
set -euo pipefail
|
||||
npm run test:anchors
|
||||
|
||||
- name: Verify anchor aliases injected in dist
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node scripts/verify-anchor-aliases-in-dist.mjs
|
||||
npm run ci
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push: {}
|
||||
pull_request:
|
||||
branches: ["master"]
|
||||
workflow_dispatch: {}
|
||||
|
||||
env:
|
||||
NODE_OPTIONS: --dns-result-order=ipv4first
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
jobs:
|
||||
build-and-anchors:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/devcontainers/javascript-node:22-bookworm
|
||||
|
||||
steps:
|
||||
- name: Tools sanity
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git --version
|
||||
node --version
|
||||
npm --version
|
||||
npm ping --registry=https://registry.npmjs.org
|
||||
|
||||
- name: Checkout (from event.json, no external actions)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
EVENT_JSON="/var/run/act/workflow/event.json"
|
||||
test -f "$EVENT_JSON" || (echo "❌ Missing $EVENT_JSON" && exit 1)
|
||||
|
||||
eval "$(node - <<'NODE'
|
||||
import fs from "node:fs";
|
||||
const ev = JSON.parse(fs.readFileSync("/var/run/act/workflow/event.json","utf8"));
|
||||
const repo =
|
||||
ev?.repository?.clone_url ||
|
||||
(ev?.repository?.html_url ? (ev.repository.html_url.replace(/\/$/,'') + ".git") : "");
|
||||
const sha =
|
||||
ev?.after ||
|
||||
ev?.pull_request?.head?.sha ||
|
||||
ev?.head_commit?.id ||
|
||||
ev?.sha ||
|
||||
"";
|
||||
if (!repo) { console.error("No repository.clone_url/html_url in event.json"); process.exit(1); }
|
||||
if (!sha) { console.error("No sha/after/pull_request.head.sha in event.json"); process.exit(1); }
|
||||
console.log(`REPO_URL=${JSON.stringify(repo)}`);
|
||||
console.log(`SHA=${JSON.stringify(sha)}`);
|
||||
NODE
|
||||
)"
|
||||
|
||||
echo "Repo URL: $REPO_URL"
|
||||
echo "SHA: $SHA"
|
||||
|
||||
rm -rf .git
|
||||
git init
|
||||
git remote add origin "$REPO_URL"
|
||||
git fetch --depth 1 origin "$SHA"
|
||||
git checkout -q FETCH_HEAD
|
||||
git log -1 --oneline
|
||||
|
||||
- name: Anchor aliases schema
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node scripts/check-anchor-aliases.mjs
|
||||
|
||||
- name: NPM harden
|
||||
run: |
|
||||
set -euo pipefail
|
||||
npm config set fetch-retries 5
|
||||
npm config set fetch-retry-mintimeout 20000
|
||||
npm config set fetch-retry-maxtimeout 120000
|
||||
npm config set registry https://registry.npmjs.org
|
||||
npm config get registry
|
||||
|
||||
- name: Install deps
|
||||
run: |
|
||||
set -euo pipefail
|
||||
npm ci
|
||||
|
||||
- name: Inline scripts syntax check
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node scripts/check-inline-js.mjs
|
||||
|
||||
- name: Build (includes postbuild injection + pagefind)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
npm run build
|
||||
|
||||
- name: Anchors contract
|
||||
run: |
|
||||
set -euo pipefail
|
||||
npm run test:anchors
|
||||
|
||||
- name: Verify anchor aliases injected in dist
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node scripts/verify-anchor-aliases-in-dist.mjs
|
||||
520
.gitea/workflows/deploy-staging-live.yml
Normal file
@@ -0,0 +1,520 @@
|
||||
name: Deploy staging+live (annotations)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
force:
|
||||
description: "Force FULL deploy (rebuild+restart) even if gate would hotpatch-only (1=yes, 0=no)"
|
||||
required: false
|
||||
default: "0"
|
||||
|
||||
env:
|
||||
NODE_OPTIONS: --dns-result-order=ipv4first
|
||||
DOCKER_API_VERSION: "1.43"
|
||||
COMPOSE_VERSION: "2.29.7"
|
||||
ASTRO_TELEMETRY_DISABLED: "1"
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
concurrency:
|
||||
group: deploy-staging-live-main
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: nas-deploy
|
||||
container:
|
||||
image: localhost:5000/archicratie/nas-deploy-node22@sha256:fefa8bb307005cebec07796661ab25528dc319c33a8f1e480e1d66f90cd5cff6
|
||||
|
||||
steps:
|
||||
- name: Tools sanity
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git --version
|
||||
node --version
|
||||
npm --version
|
||||
|
||||
- name: Checkout (push or workflow_dispatch, no external actions)
|
||||
env:
|
||||
EVENT_JSON: /var/run/act/workflow/event.json
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -f "$EVENT_JSON" || { echo "❌ Missing $EVENT_JSON"; exit 1; }
|
||||
|
||||
node --input-type=module <<'NODE'
|
||||
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");
|
||||
|
||||
const defaultBranch = repoObj?.default_branch || "main";
|
||||
const sha =
|
||||
(process.env.GITHUB_SHA && String(process.env.GITHUB_SHA).trim()) ||
|
||||
ev?.after ||
|
||||
ev?.sha ||
|
||||
ev?.head_commit?.id ||
|
||||
ev?.pull_request?.head?.sha ||
|
||||
"";
|
||||
|
||||
const shq = (s) => "'" + String(s).replace(/'/g, "'\\''") + "'";
|
||||
fs.writeFileSync("/tmp/deploy.env", [
|
||||
`REPO_URL=${shq(cloneUrl)}`,
|
||||
`DEFAULT_BRANCH=${shq(defaultBranch)}`,
|
||||
`SHA=${shq(sha)}`
|
||||
].join("\n") + "\n");
|
||||
NODE
|
||||
|
||||
source /tmp/deploy.env
|
||||
echo "Repo URL: $REPO_URL"
|
||||
echo "Default branch: $DEFAULT_BRANCH"
|
||||
echo "SHA: ${SHA:-<empty>}"
|
||||
|
||||
rm -rf .git
|
||||
git init -q
|
||||
git remote add origin "$REPO_URL"
|
||||
|
||||
if [[ -n "${SHA:-}" ]]; then
|
||||
git fetch --depth 1 origin "$SHA"
|
||||
git -c advice.detachedHead=false checkout -q FETCH_HEAD
|
||||
else
|
||||
git fetch --depth 1 origin "$DEFAULT_BRANCH"
|
||||
git -c advice.detachedHead=false checkout -q "origin/$DEFAULT_BRANCH"
|
||||
SHA="$(git rev-parse HEAD)"
|
||||
echo "SHA='$SHA'" >> /tmp/deploy.env
|
||||
echo "Resolved SHA: $SHA"
|
||||
fi
|
||||
|
||||
git log -1 --oneline
|
||||
|
||||
- name: Gate — decide SKIP vs HOTPATCH vs FULL rebuild
|
||||
env:
|
||||
INPUT_FORCE: ${{ inputs.force }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source /tmp/deploy.env
|
||||
|
||||
FORCE="${INPUT_FORCE:-0}"
|
||||
|
||||
# liste fichiers touchés (utile pour copier les médias)
|
||||
CHANGED="$(git show --name-only --pretty="" "$SHA" | sed '/^$/d' || true)"
|
||||
printf "%s\n" "$CHANGED" > /tmp/changed.txt
|
||||
|
||||
echo "== changed files =="
|
||||
echo "$CHANGED" | sed -n '1,260p'
|
||||
|
||||
# 0) Forçage manuel
|
||||
if [[ "$FORCE" == "1" ]]; then
|
||||
echo "GO=1" >> /tmp/deploy.env
|
||||
echo "MODE='full'" >> /tmp/deploy.env
|
||||
echo "✅ force=1 -> MODE=full (rebuild+restart)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 1) Détection des classes de changements
|
||||
HAS_FULL=0
|
||||
HAS_HOTPATCH=0
|
||||
|
||||
# FULL si build-impacting (zéro surprise)
|
||||
if echo "$CHANGED" | grep -qE '^(src/content/|src/anchors/|src/pages/|scripts/)'; then
|
||||
HAS_FULL=1
|
||||
fi
|
||||
|
||||
# HOTPATCH si annotations/media
|
||||
if echo "$CHANGED" | grep -qE '^(src/annotations/|public/media/)'; then
|
||||
HAS_HOTPATCH=1
|
||||
fi
|
||||
|
||||
echo "Gate flags: HAS_FULL=$HAS_FULL HAS_HOTPATCH=$HAS_HOTPATCH"
|
||||
|
||||
# 2) Décision (priorité au FULL)
|
||||
if [[ "$HAS_FULL" == "1" ]]; then
|
||||
echo "GO=1" >> /tmp/deploy.env
|
||||
echo "MODE='full'" >> /tmp/deploy.env
|
||||
echo "✅ build-impacting change -> MODE=full (rebuild+restart)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$HAS_HOTPATCH" == "1" ]]; then
|
||||
echo "GO=1" >> /tmp/deploy.env
|
||||
echo "MODE='hotpatch'" >> /tmp/deploy.env
|
||||
echo "✅ annotations/media change -> MODE=hotpatch"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "GO=0" >> /tmp/deploy.env
|
||||
echo "MODE='skip'" >> /tmp/deploy.env
|
||||
echo "ℹ️ no deploy-relevant change -> skip deploy"
|
||||
|
||||
- name: Toolchain sanity + resolve COMPOSE_PROJECT_NAME
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source /tmp/deploy.env
|
||||
[[ "${GO:-0}" == "1" ]] || { echo "ℹ️ skipped"; exit 0; }
|
||||
|
||||
# tools are prebaked in the image
|
||||
git --version
|
||||
docker version
|
||||
docker compose version
|
||||
python3 -c 'import yaml; print("PyYAML OK")'
|
||||
|
||||
# Reuse existing compose project name if containers already exist
|
||||
PROJ="$(docker inspect archicratie-web-blue --format '{{ index .Config.Labels "com.docker.compose.project" }}' 2>/dev/null || true)"
|
||||
if [[ -z "${PROJ:-}" ]]; then
|
||||
PROJ="$(docker inspect archicratie-web-green --format '{{ index .Config.Labels "com.docker.compose.project" }}' 2>/dev/null || true)"
|
||||
fi
|
||||
if [[ -z "${PROJ:-}" ]]; then PROJ="archicratie-web"; fi
|
||||
echo "COMPOSE_PROJECT_NAME='$PROJ'" >> /tmp/deploy.env
|
||||
echo "✅ Using COMPOSE_PROJECT_NAME=$PROJ"
|
||||
|
||||
# Assert target containers exist (hotpatch needs them)
|
||||
for c in archicratie-web-blue archicratie-web-green; do
|
||||
docker inspect "$c" >/dev/null 2>&1 || { echo "❌ missing container $c"; exit 5; }
|
||||
done
|
||||
|
||||
- name: Assert required vars (PUBLIC_GITEA_*) — only needed for MODE=full
|
||||
env:
|
||||
PUBLIC_GITEA_BASE: ${{ vars.PUBLIC_GITEA_BASE }}
|
||||
PUBLIC_GITEA_OWNER: ${{ vars.PUBLIC_GITEA_OWNER }}
|
||||
PUBLIC_GITEA_REPO: ${{ vars.PUBLIC_GITEA_REPO }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source /tmp/deploy.env
|
||||
[[ "${GO:-0}" == "1" ]] || { echo "ℹ️ skipped"; exit 0; }
|
||||
[[ "${MODE:-hotpatch}" == "full" ]] || { echo "ℹ️ hotpatch mode -> vars not required"; exit 0; }
|
||||
|
||||
test -n "${PUBLIC_GITEA_BASE:-}" || { echo "❌ missing repo var PUBLIC_GITEA_BASE"; exit 2; }
|
||||
test -n "${PUBLIC_GITEA_OWNER:-}" || { echo "❌ missing repo var PUBLIC_GITEA_OWNER"; exit 2; }
|
||||
test -n "${PUBLIC_GITEA_REPO:-}" || { echo "❌ missing repo var PUBLIC_GITEA_REPO"; exit 2; }
|
||||
echo "✅ vars OK"
|
||||
|
||||
- name: Assert deploy files exist — only needed for MODE=full
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source /tmp/deploy.env
|
||||
[[ "${GO:-0}" == "1" ]] || { echo "ℹ️ skipped"; exit 0; }
|
||||
[[ "${MODE:-hotpatch}" == "full" ]] || { echo "ℹ️ hotpatch mode -> files not required"; exit 0; }
|
||||
|
||||
test -f docker-compose.yml
|
||||
test -f Dockerfile
|
||||
test -f nginx.conf
|
||||
echo "✅ deploy files OK"
|
||||
|
||||
- name: FULL — Build + deploy staging (blue) then warmup+smoke
|
||||
env:
|
||||
PUBLIC_GITEA_BASE: ${{ vars.PUBLIC_GITEA_BASE }}
|
||||
PUBLIC_GITEA_OWNER: ${{ vars.PUBLIC_GITEA_OWNER }}
|
||||
PUBLIC_GITEA_REPO: ${{ vars.PUBLIC_GITEA_REPO }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source /tmp/deploy.env
|
||||
[[ "${GO:-0}" == "1" ]] || { echo "ℹ️ skipped"; exit 0; }
|
||||
[[ "${MODE:-hotpatch}" == "full" ]] || { echo "ℹ️ MODE=$MODE -> skip full rebuild"; exit 0; }
|
||||
|
||||
PROJ="${COMPOSE_PROJECT_NAME:-archicratie-web}"
|
||||
|
||||
wait_url() {
|
||||
local url="$1"
|
||||
local label="$2"
|
||||
local tries="${3:-60}"
|
||||
for i in $(seq 1 "$tries"); do
|
||||
if curl -fsS --max-time 4 "$url" >/dev/null; then
|
||||
echo "✅ $label OK ($url)"
|
||||
return 0
|
||||
fi
|
||||
echo "… warmup $label ($i/$tries)"
|
||||
sleep 1
|
||||
done
|
||||
echo "❌ timeout $label ($url)"
|
||||
return 1
|
||||
}
|
||||
|
||||
TS="$(date -u +%Y%m%d-%H%M%S)"
|
||||
echo "TS='$TS'" >> /tmp/deploy.env
|
||||
docker image tag archicratie-web:blue "archicratie-web:blue.BAK.${TS}" || true
|
||||
docker image tag archicratie-web:green "archicratie-web:green.BAK.${TS}" || true
|
||||
|
||||
docker compose -p "$PROJ" -f docker-compose.yml build web_blue
|
||||
docker rm -f archicratie-web-blue || true
|
||||
docker compose -p "$PROJ" -f docker-compose.yml up -d --force-recreate --remove-orphans web_blue
|
||||
|
||||
# warmup endpoints
|
||||
wait_url "http://127.0.0.1:8081/para-index.json" "blue para-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"
|
||||
|
||||
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 "$CANON" | grep -q 'https://staging\.archicratie\.trans-hands\.synology\.me/' || {
|
||||
echo "❌ staging canonical mismatch"
|
||||
docker logs --tail 120 archicratie-web-blue || true
|
||||
exit 3
|
||||
}
|
||||
|
||||
echo "✅ staging OK"
|
||||
|
||||
- name: FULL — Build + deploy live (green) then warmup+smoke + rollback if needed
|
||||
env:
|
||||
PUBLIC_GITEA_BASE: ${{ vars.PUBLIC_GITEA_BASE }}
|
||||
PUBLIC_GITEA_OWNER: ${{ vars.PUBLIC_GITEA_OWNER }}
|
||||
PUBLIC_GITEA_REPO: ${{ vars.PUBLIC_GITEA_REPO }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source /tmp/deploy.env
|
||||
[[ "${GO:-0}" == "1" ]] || { echo "ℹ️ skipped"; exit 0; }
|
||||
[[ "${MODE:-hotpatch}" == "full" ]] || { echo "ℹ️ MODE=$MODE -> skip full rebuild"; exit 0; }
|
||||
|
||||
PROJ="${COMPOSE_PROJECT_NAME:-archicratie-web}"
|
||||
TS="${TS:-$(date -u +%Y%m%d-%H%M%S)}"
|
||||
|
||||
wait_url() {
|
||||
local url="$1"
|
||||
local label="$2"
|
||||
local tries="${3:-60}"
|
||||
for i in $(seq 1 "$tries"); do
|
||||
if curl -fsS --max-time 4 "$url" >/dev/null; then
|
||||
echo "✅ $label OK ($url)"
|
||||
return 0
|
||||
fi
|
||||
echo "… warmup $label ($i/$tries)"
|
||||
sleep 1
|
||||
done
|
||||
echo "❌ timeout $label ($url)"
|
||||
return 1
|
||||
}
|
||||
|
||||
rollback() {
|
||||
echo "⚠️ rollback green -> previous image tag (best effort)"
|
||||
docker image tag "archicratie-web:green.BAK.${TS}" archicratie-web:green || true
|
||||
docker rm -f archicratie-web-green || true
|
||||
docker compose -p "$PROJ" -f docker-compose.yml up -d --force-recreate --remove-orphans web_green || true
|
||||
}
|
||||
|
||||
# build/restart green
|
||||
if ! docker compose -p "$PROJ" -f docker-compose.yml build web_green; then
|
||||
echo "❌ build green failed"; rollback; exit 4
|
||||
fi
|
||||
|
||||
docker rm -f archicratie-web-green || true
|
||||
docker compose -p "$PROJ" -f docker-compose.yml up -d --force-recreate --remove-orphans web_green
|
||||
|
||||
# warmup endpoints
|
||||
if ! wait_url "http://127.0.0.1:8082/para-index.json" "green para-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
|
||||
|
||||
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 "$CANON" | grep -q 'https://archicratie\.trans-hands\.synology\.me/' || {
|
||||
echo "❌ live canonical mismatch"
|
||||
docker logs --tail 120 archicratie-web-green || true
|
||||
rollback
|
||||
exit 4
|
||||
}
|
||||
|
||||
echo "✅ live OK"
|
||||
|
||||
- name: HOTPATCH — deep merge shards -> annotations-index + copy changed media into blue+green
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source /tmp/deploy.env
|
||||
[[ "${GO:-0}" == "1" ]] || { echo "ℹ️ skipped"; exit 0; }
|
||||
|
||||
python3 - <<'PY'
|
||||
import os, re, json, glob
|
||||
import yaml
|
||||
import datetime as dt
|
||||
|
||||
ROOT = os.getcwd()
|
||||
ANNO_ROOT = os.path.join(ROOT, "src", "annotations")
|
||||
|
||||
def is_obj(x): return isinstance(x, dict)
|
||||
def is_arr(x): return isinstance(x, list)
|
||||
|
||||
def iso_dt(x):
|
||||
if isinstance(x, dt.datetime):
|
||||
if x.tzinfo is None:
|
||||
return x.isoformat()
|
||||
return x.astimezone(dt.timezone.utc).isoformat().replace("+00:00","Z")
|
||||
if isinstance(x, dt.date):
|
||||
return x.isoformat()
|
||||
return None
|
||||
|
||||
def normalize(x):
|
||||
s = iso_dt(x)
|
||||
if s is not None: return s
|
||||
if isinstance(x, dict):
|
||||
return {str(k): normalize(v) for k, v in x.items()}
|
||||
if isinstance(x, list):
|
||||
return [normalize(v) for v in x]
|
||||
return x
|
||||
|
||||
def key_media(it): return str((it or {}).get("src",""))
|
||||
def key_ref(it):
|
||||
it = it or {}
|
||||
return "||".join([str(it.get("url","")), str(it.get("label","")), str(it.get("kind","")), str(it.get("citation",""))])
|
||||
def key_comment(it): return str((it or {}).get("text","")).strip()
|
||||
|
||||
def dedup_extend(dst_list, src_list, key_fn):
|
||||
seen = set(); out = []
|
||||
for x in (dst_list or []):
|
||||
x = normalize(x); k = key_fn(x)
|
||||
if k and k not in seen: seen.add(k); out.append(x)
|
||||
for x in (src_list or []):
|
||||
x = normalize(x); k = key_fn(x)
|
||||
if k and k not in seen: seen.add(k); out.append(x)
|
||||
return out
|
||||
|
||||
def deep_merge(dst, src):
|
||||
src = normalize(src)
|
||||
for k, v in (src or {}).items():
|
||||
if k in ("media","refs","comments_editorial") and is_arr(v):
|
||||
if k == "media": dst[k] = dedup_extend(dst.get(k, []), v, key_media)
|
||||
elif k == "refs": dst[k] = dedup_extend(dst.get(k, []), v, key_ref)
|
||||
else: dst[k] = dedup_extend(dst.get(k, []), v, key_comment)
|
||||
continue
|
||||
|
||||
if is_obj(v):
|
||||
if not is_obj(dst.get(k)): dst[k] = {}
|
||||
deep_merge(dst[k], v)
|
||||
continue
|
||||
|
||||
if is_arr(v):
|
||||
cur = dst.get(k, [])
|
||||
if not is_arr(cur): cur = []
|
||||
seen = set(); out = []
|
||||
for x in cur:
|
||||
x = normalize(x)
|
||||
s = json.dumps(x, sort_keys=True, ensure_ascii=False)
|
||||
if s not in seen: seen.add(s); out.append(x)
|
||||
for x in v:
|
||||
x = normalize(x)
|
||||
s = json.dumps(x, sort_keys=True, ensure_ascii=False)
|
||||
if s not in seen: seen.add(s); out.append(x)
|
||||
dst[k] = out
|
||||
continue
|
||||
|
||||
v = normalize(v)
|
||||
if k not in dst or dst.get(k) in (None, ""):
|
||||
dst[k] = v
|
||||
|
||||
def para_num(pid):
|
||||
m = re.match(r"^p-(\d+)-", str(pid))
|
||||
return int(m.group(1)) if m else 10**9
|
||||
|
||||
def sort_lists(entry):
|
||||
for k in ("media","refs","comments_editorial"):
|
||||
arr = entry.get(k)
|
||||
if not is_arr(arr): continue
|
||||
def ts(x):
|
||||
x = normalize(x)
|
||||
try:
|
||||
s = str((x or {}).get("ts",""))
|
||||
return dt.datetime.fromisoformat(s.replace("Z","+00:00")).timestamp() if s else 0
|
||||
except Exception:
|
||||
return 0
|
||||
arr = [normalize(x) for x in arr]
|
||||
arr.sort(key=lambda x: (ts(x), json.dumps(x, sort_keys=True, ensure_ascii=False)))
|
||||
entry[k] = arr
|
||||
|
||||
if not os.path.isdir(ANNO_ROOT):
|
||||
raise SystemExit(f"Missing annotations root: {ANNO_ROOT}")
|
||||
|
||||
pages = {}
|
||||
errors = []
|
||||
|
||||
files = sorted(glob.glob(os.path.join(ANNO_ROOT, "**", "*.yml"), recursive=True))
|
||||
for fp in files:
|
||||
try:
|
||||
with open(fp, "r", encoding="utf-8") as f:
|
||||
doc = yaml.safe_load(f) or {}
|
||||
doc = normalize(doc)
|
||||
if not isinstance(doc, dict) or doc.get("schema") != 1:
|
||||
continue
|
||||
|
||||
page = str(doc.get("page","")).strip().strip("/")
|
||||
paras = doc.get("paras") or {}
|
||||
if not page or not isinstance(paras, dict):
|
||||
continue
|
||||
|
||||
pg = pages.setdefault(page, {"paras": {}})
|
||||
for pid, entry in paras.items():
|
||||
pid = str(pid)
|
||||
if pid not in pg["paras"] or not isinstance(pg["paras"].get(pid), dict):
|
||||
pg["paras"][pid] = {}
|
||||
if isinstance(entry, dict):
|
||||
deep_merge(pg["paras"][pid], entry)
|
||||
sort_lists(pg["paras"][pid])
|
||||
|
||||
except Exception as e:
|
||||
errors.append({"file": os.path.relpath(fp, ROOT), "error": str(e)})
|
||||
|
||||
for page, obj in pages.items():
|
||||
keys = list((obj.get("paras") or {}).keys())
|
||||
keys.sort(key=lambda k: (para_num(k), k))
|
||||
obj["paras"] = {k: obj["paras"][k] for k in keys}
|
||||
|
||||
out = {
|
||||
"schema": 1,
|
||||
"generatedAt": dt.datetime.utcnow().replace(tzinfo=dt.timezone.utc).isoformat().replace("+00:00","Z"),
|
||||
"pages": pages,
|
||||
"stats": {
|
||||
"pages": len(pages),
|
||||
"paras": sum(len(v.get("paras") or {}) for v in pages.values()),
|
||||
"errors": len(errors),
|
||||
},
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
with open("/tmp/annotations-index.json", "w", encoding="utf-8") as f:
|
||||
json.dump(out, f, ensure_ascii=False)
|
||||
|
||||
print("OK: wrote /tmp/annotations-index.json pages=", out["stats"]["pages"], "paras=", out["stats"]["paras"], "errors=", out["stats"]["errors"])
|
||||
PY
|
||||
|
||||
# patch JSON into running containers
|
||||
for c in archicratie-web-blue archicratie-web-green; do
|
||||
echo "== patch annotations-index.json into $c =="
|
||||
docker cp /tmp/annotations-index.json "${c}:/usr/share/nginx/html/annotations-index.json"
|
||||
done
|
||||
|
||||
# copy changed media files into containers (so new media appears without rebuild)
|
||||
if [[ -s /tmp/changed.txt ]]; then
|
||||
while IFS= read -r f; do
|
||||
[[ -n "$f" ]] || continue
|
||||
if [[ "$f" == public/media/* ]]; then
|
||||
dest="/usr/share/nginx/html/${f#public/}" # => /usr/share/nginx/html/media/...
|
||||
for c in archicratie-web-blue archicratie-web-green; do
|
||||
echo "== copy media into $c: $f -> $dest =="
|
||||
docker exec "$c" sh -lc "mkdir -p \"$(dirname "$dest")\""
|
||||
docker cp "$f" "$c:$dest"
|
||||
done
|
||||
fi
|
||||
done < /tmp/changed.txt
|
||||
fi
|
||||
|
||||
# smoke after patch
|
||||
for p in 8081 8082; do
|
||||
echo "== smoke annotations-index on $p =="
|
||||
curl -fsS --max-time 6 "http://127.0.0.1:${p}/annotations-index.json" \
|
||||
| python3 -c 'import sys,json; j=json.load(sys.stdin); print("generatedAt:", j.get("generatedAt")); print("pages:", len(j.get("pages") or {})); print("paras:", j.get("stats",{}).get("paras"))'
|
||||
done
|
||||
|
||||
echo "✅ hotpatch done"
|
||||
|
||||
- name: Debug on failure (containers status/logs)
|
||||
if: ${{ failure() }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "== docker ps =="
|
||||
docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Image}}' | sed -n '1,80p' || true
|
||||
for c in archicratie-web-blue archicratie-web-green; do
|
||||
echo "== logs $c (tail 200) =="
|
||||
docker logs --tail 200 "$c" || true
|
||||
done
|
||||
395
.gitea/workflows/proposer-apply-pr.yml
Normal file
@@ -0,0 +1,395 @@
|
||||
name: Proposer Apply (PR)
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [labeled]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
issue:
|
||||
description: "Issue number to apply (Proposer: correction/fact-check)"
|
||||
required: true
|
||||
|
||||
env:
|
||||
NODE_OPTIONS: --dns-result-order=ipv4first
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
concurrency:
|
||||
group: proposer-apply-${{ github.event.issue.number || inputs.issue || 'manual' }}
|
||||
cancel-in-progress: true
|
||||
|
||||
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)
|
||||
env:
|
||||
INPUT_ISSUE: ${{ inputs.issue }}
|
||||
FORGE_API: ${{ vars.FORGE_API || vars.FORGE_BASE }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export EVENT_JSON="/var/run/act/workflow/event.json"
|
||||
test -f "$EVENT_JSON" || { echo "❌ Missing $EVENT_JSON"; exit 1; }
|
||||
|
||||
node --input-type=module - <<'NODE' > /tmp/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);
|
||||
|
||||
if (!issueNumber || !Number.isFinite(Number(issueNumber))) {
|
||||
throw new Error("No issue number in event.json or workflow_dispatch input");
|
||||
}
|
||||
|
||||
const labelName =
|
||||
ev?.label?.name ||
|
||||
ev?.label ||
|
||||
"workflow_dispatch";
|
||||
|
||||
const u = new URL(cloneUrl);
|
||||
const origin = u.origin;
|
||||
|
||||
const apiBase = (process.env.FORGE_API && String(process.env.FORGE_API).trim())
|
||||
? String(process.env.FORGE_API).trim().replace(/\/+$/,"")
|
||||
: origin;
|
||||
|
||||
function sh(s){ return JSON.stringify(String(s)); }
|
||||
process.stdout.write([
|
||||
`CLONE_URL=${sh(cloneUrl)}`,
|
||||
`OWNER=${sh(owner)}`,
|
||||
`REPO=${sh(repo)}`,
|
||||
`DEFAULT_BRANCH=${sh(defaultBranch)}`,
|
||||
`ISSUE_NUMBER=${sh(issueNumber)}`,
|
||||
`LABEL_NAME=${sh(labelName)}`,
|
||||
`API_BASE=${sh(apiBase)}`
|
||||
].join("\n") + "\n");
|
||||
NODE
|
||||
|
||||
echo "✅ context:"
|
||||
sed -n '1,120p' /tmp/proposer.env
|
||||
|
||||
- name: Gate on label state/approved
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source /tmp/proposer.env
|
||||
|
||||
if [[ "$LABEL_NAME" != "state/approved" && "$LABEL_NAME" != "workflow_dispatch" ]]; then
|
||||
echo "ℹ️ label=$LABEL_NAME => skip"
|
||||
echo "SKIP=1" >> /tmp/proposer.env
|
||||
exit 0
|
||||
fi
|
||||
echo "✅ proceed (issue=$ISSUE_NUMBER)"
|
||||
|
||||
- name: Fetch issue + API-hard gate on (state/approved present + proposer type)
|
||||
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; }
|
||||
|
||||
curl -fsS \
|
||||
-H "Authorization: token $FORGE_TOKEN" \
|
||||
-H "Accept: application/json" \
|
||||
"$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER" \
|
||||
-o /tmp/issue.json
|
||||
|
||||
node --input-type=module - <<'NODE' >> /tmp/proposer.env
|
||||
import fs from "node:fs";
|
||||
const issue = JSON.parse(fs.readFileSync("/tmp/issue.json","utf8"));
|
||||
const title = String(issue.title || "");
|
||||
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) : [];
|
||||
|
||||
function pickLine(key) {
|
||||
const re = new RegExp(`^\\s*${key}\\s*:\\s*([^\\n\\r]+)`, "mi");
|
||||
const m = body.match(re);
|
||||
return m ? m[1].trim() : "";
|
||||
}
|
||||
|
||||
const typeRaw = pickLine("Type");
|
||||
const type = String(typeRaw || "").trim().toLowerCase();
|
||||
|
||||
const hasApproved = labels.includes("state/approved");
|
||||
const proposer = new Set(["type/correction","type/fact-check"]);
|
||||
|
||||
const out = [];
|
||||
out.push(`ISSUE_TITLE=${JSON.stringify(title)}`);
|
||||
out.push(`ISSUE_TYPE=${JSON.stringify(type)}`);
|
||||
out.push(`HAS_APPROVED=${hasApproved ? "1":"0"}`);
|
||||
|
||||
if (!hasApproved) {
|
||||
out.push(`SKIP=1`);
|
||||
out.push(`SKIP_REASON=${JSON.stringify("approved_not_present")}`);
|
||||
} else if (!type) {
|
||||
out.push(`SKIP=1`);
|
||||
out.push(`SKIP_REASON=${JSON.stringify("missing_type")}`);
|
||||
} else if (!proposer.has(type)) {
|
||||
out.push(`SKIP=1`);
|
||||
out.push(`SKIP_REASON=${JSON.stringify("not_proposer:"+type)}`);
|
||||
}
|
||||
process.stdout.write(out.join("\n") + "\n");
|
||||
NODE
|
||||
|
||||
echo "✅ proposer gating:"
|
||||
grep -E '^(ISSUE_TYPE|HAS_APPROVED|SKIP|SKIP_REASON)=' /tmp/proposer.env || true
|
||||
|
||||
- name: Comment issue if skipped
|
||||
if: ${{ always() }}
|
||||
env:
|
||||
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source /tmp/proposer.env || true
|
||||
|
||||
[[ "${SKIP:-0}" == "1" ]] || exit 0
|
||||
[[ "$LABEL_NAME" == "state/approved" || "$LABEL_NAME" == "workflow_dispatch" ]] || exit 0
|
||||
|
||||
REASON="${SKIP_REASON:-}"
|
||||
TYPE="${ISSUE_TYPE:-}"
|
||||
|
||||
if [[ "$REASON" == "approved_not_present" ]]; then
|
||||
MSG="ℹ️ Proposer Apply: skip — le label **state/approved** n'est pas présent sur le ticket au moment du run (gate API-hard)."
|
||||
elif [[ "$REASON" == "missing_type" ]]; then
|
||||
MSG="ℹ️ Proposer Apply: skip — champ **Type:** manquant/illisible. Attendu: type/correction ou type/fact-check."
|
||||
else
|
||||
MSG="ℹ️ Proposer Apply: skip — Type non-Proposer (${TYPE}). (Ce workflow ne traite que correction/fact-check.)"
|
||||
fi
|
||||
|
||||
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" || true
|
||||
|
||||
- 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
|
||||
echo "✅ workspace:"
|
||||
ls -la | sed -n '1,120p'
|
||||
|
||||
- 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"
|
||||
ls -la "$APP_DIR" | sed -n '1,120p'
|
||||
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: NPM harden (reduce flakiness)
|
||||
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 (APP_DIR)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source /tmp/proposer.env
|
||||
[[ "${SKIP:-0}" != "1" ]] || { echo "ℹ️ skipped"; exit 0; }
|
||||
cd "$APP_DIR"
|
||||
npm ci --no-audit --no-fund
|
||||
|
||||
- name: Build dist baseline (APP_DIR)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source /tmp/proposer.env
|
||||
[[ "${SKIP:-0}" != "1" ]] || { echo "ℹ️ skipped"; exit 0; }
|
||||
cd "$APP_DIR"
|
||||
npm run build
|
||||
|
||||
- name: Apply ticket (alias + commit) 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 }}
|
||||
FORGE_API: ${{ vars.FORGE_API || vars.FORGE_BASE }}
|
||||
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)"
|
||||
TS="$(date -u +%Y%m%d-%H%M%S)"
|
||||
BR="bot/proposer-${ISSUE_NUMBER}-${TS}"
|
||||
echo "BRANCH=$BR" >> /tmp/proposer.env
|
||||
git checkout -b "$BR"
|
||||
|
||||
export GITEA_OWNER="$OWNER"
|
||||
export GITEA_REPO="$REPO"
|
||||
export FORGE_BASE="$API_BASE"
|
||||
|
||||
LOG="/tmp/proposer-apply.log"
|
||||
set +e
|
||||
(cd "$APP_DIR" && node scripts/apply-ticket.mjs "$ISSUE_NUMBER" --alias --commit) >"$LOG" 2>&1
|
||||
RC=$?
|
||||
set -e
|
||||
|
||||
echo "APPLY_RC=$RC" >> /tmp/proposer.env
|
||||
|
||||
echo "== apply log (tail) =="
|
||||
tail -n 200 "$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: 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; }
|
||||
[[ "${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 issue
|
||||
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
|
||||
[[ "${NOOP:-0}" == "0" ]] || exit 0
|
||||
[[ -n "${BRANCH:-}" ]] || { echo "ℹ️ BRANCH unset -> skip PR"; exit 0; }
|
||||
|
||||
PR_TITLE="proposer: apply ticket #${ISSUE_NUMBER}"
|
||||
PR_BODY="PR auto depuis ticket #${ISSUE_NUMBER} (state/approved).\n\n- Branche: ${BRANCH}\n- Commit: ${END_SHA:-unknown}\n\nMerge si CI OK."
|
||||
|
||||
PR_PAYLOAD="$(node --input-type=module -e '
|
||||
const [title, body, base, head] = process.argv.slice(1);
|
||||
console.log(JSON.stringify({ title, body, base, head, allow_maintainer_edit: true }));
|
||||
' "$PR_TITLE" "$PR_BODY" "$DEFAULT_BRANCH" "${OWNER}:${BRANCH}")"
|
||||
|
||||
PR_JSON="$(curl -fsS -X POST \
|
||||
-H "Authorization: token $FORGE_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
"$API_BASE/api/v1/repos/$OWNER/$REPO/pulls" \
|
||||
--data-binary "$PR_PAYLOAD")"
|
||||
|
||||
PR_URL="$(node --input-type=module -e '
|
||||
const pr = JSON.parse(process.argv[1] || "{}");
|
||||
console.log(pr.html_url || pr.url || "");
|
||||
' "$PR_JSON")"
|
||||
|
||||
test -n "$PR_URL" || { echo "❌ PR URL missing. Raw: $PR_JSON"; exit 1; }
|
||||
|
||||
MSG="✅ PR Proposer créée pour ticket #${ISSUE_NUMBER} : ${PR_URL}"
|
||||
C_PAYLOAD="$(node --input-type=module -e 'console.log(JSON.stringify({body: process.argv[1]||""}))' "$MSG")"
|
||||
|
||||
curl -fsS -X POST \
|
||||
-H "Authorization: token $FORGE_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
"$API_BASE/api/v1/repos/$OWNER/$REPO/issues/$ISSUE_NUMBER/comments" \
|
||||
--data-binary "$C_PAYLOAD"
|
||||
|
||||
- name: Finalize (fail job if apply failed)
|
||||
if: ${{ always() }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source /tmp/proposer.env || true
|
||||
[[ "${SKIP:-0}" != "1" ]] || exit 0
|
||||
|
||||
RC="${APPLY_RC:-0}"
|
||||
if [[ "$RC" != "0" ]]; then
|
||||
echo "❌ apply failed (rc=$RC)"
|
||||
exit "$RC"
|
||||
fi
|
||||
echo "✅ apply ok"
|
||||
@@ -3,7 +3,7 @@ on: [push, workflow_dispatch]
|
||||
|
||||
jobs:
|
||||
smoke:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: mac-ci
|
||||
steps:
|
||||
- run: node -v && npm -v
|
||||
- run: echo "runner OK"
|
||||
|
||||
7
.gitignore
vendored
@@ -3,6 +3,10 @@
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# dev-only
|
||||
public/_auth/whoami
|
||||
public/_auth/whoami/*
|
||||
|
||||
# --- local backups ---
|
||||
*.bak
|
||||
*.bak.*
|
||||
@@ -21,3 +25,6 @@ dist/
|
||||
# local backups
|
||||
Dockerfile.bak.*
|
||||
public/favicon_io.zip
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
18
Dockerfile
@@ -12,7 +12,7 @@ ENV npm_config_update_notifier=false \
|
||||
# (Optionnel mais propre) git + certificats
|
||||
RUN apt-get -o Acquire::Retries=5 -o Acquire::ForceIPv4=true update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Déps d’abord (cache Docker)
|
||||
COPY package.json package-lock.json ./
|
||||
@@ -25,9 +25,21 @@ COPY . .
|
||||
ARG PUBLIC_GITEA_BASE
|
||||
ARG PUBLIC_GITEA_OWNER
|
||||
ARG PUBLIC_GITEA_REPO
|
||||
|
||||
# ✅ Canonical + sitemap base (astro.config.mjs lit process.env.PUBLIC_SITE)
|
||||
ARG PUBLIC_SITE
|
||||
|
||||
# ✅ Garde-fou : si 1 → build fail si PUBLIC_SITE absent
|
||||
ARG REQUIRE_PUBLIC_SITE=0
|
||||
|
||||
ENV PUBLIC_GITEA_BASE=$PUBLIC_GITEA_BASE \
|
||||
PUBLIC_GITEA_OWNER=$PUBLIC_GITEA_OWNER \
|
||||
PUBLIC_GITEA_REPO=$PUBLIC_GITEA_REPO
|
||||
PUBLIC_GITEA_REPO=$PUBLIC_GITEA_REPO \
|
||||
PUBLIC_SITE=$PUBLIC_SITE \
|
||||
REQUIRE_PUBLIC_SITE=$REQUIRE_PUBLIC_SITE
|
||||
|
||||
# ✅ antifragile : refuse de builder sans PUBLIC_SITE quand on l’exige
|
||||
RUN node -e "if (process.env.REQUIRE_PUBLIC_SITE==='1' && !process.env.PUBLIC_SITE) { console.error('FATAL: PUBLIC_SITE is required (canonical/sitemap).'); process.exit(1) }"
|
||||
|
||||
# Build Astro (postbuild tourne via npm scripts)
|
||||
RUN npm run build
|
||||
@@ -38,4 +50,4 @@ COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist/ /usr/share/nginx/html/
|
||||
RUN find /usr/share/nginx/html -type d -exec chmod 755 {} \; \
|
||||
&& find /usr/share/nginx/html -type f -exec chmod 644 {} \;
|
||||
EXPOSE 80
|
||||
EXPOSE 80
|
||||
@@ -5,6 +5,8 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
network: host
|
||||
args:
|
||||
REQUIRE_PUBLIC_SITE: "1"
|
||||
PUBLIC_SITE: "https://staging.archicratie.trans-hands.synology.me"
|
||||
PUBLIC_GITEA_BASE: ${PUBLIC_GITEA_BASE}
|
||||
PUBLIC_GITEA_OWNER: ${PUBLIC_GITEA_OWNER}
|
||||
PUBLIC_GITEA_REPO: ${PUBLIC_GITEA_REPO}
|
||||
@@ -20,6 +22,8 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
network: host
|
||||
args:
|
||||
REQUIRE_PUBLIC_SITE: "1"
|
||||
PUBLIC_SITE: "https://archicratie.trans-hands.synology.me"
|
||||
PUBLIC_GITEA_BASE: ${PUBLIC_GITEA_BASE}
|
||||
PUBLIC_GITEA_OWNER: ${PUBLIC_GITEA_OWNER}
|
||||
PUBLIC_GITEA_REPO: ${PUBLIC_GITEA_REPO}
|
||||
@@ -27,4 +31,4 @@ services:
|
||||
container_name: archicratie-web-green
|
||||
ports:
|
||||
- "127.0.0.1:8082:80"
|
||||
restart: unless-stopped
|
||||
restart: unless-stopped
|
||||
327
docs/EDITORIAL-ANNOTATIONS-SPEC.md
Normal file
@@ -0,0 +1,327 @@
|
||||
# SPEC — Annotations éditoriales (YAML v1) + merge + anti-doublon
|
||||
> Objectif : permettre aux tickets (Gitea) de déposer “Références / Médias / Commentaires” dans `src/annotations/**`,
|
||||
> de façon univoque, stable, et sans régression.
|
||||
|
||||
## 0) Contexte et intention
|
||||
Le site est statique. L’édition collaborative se fait via :
|
||||
- un mode “proposition” (UI / modal)
|
||||
- un ticket Gitea (issue) standardisé
|
||||
- un script d’application côté éditeur (`apply-ticket.mjs` ou équivalent)
|
||||
- génération d’un YAML d’annotations versionné dans Git
|
||||
|
||||
La donnée d’annotation doit être :
|
||||
- **audit-able** (Git)
|
||||
- **merge-able** (sans tout casser)
|
||||
- **stable** (IDs paragraphes / liens / médias)
|
||||
- **scalable** (éviter YAML monstrueux à long terme)
|
||||
|
||||
## 1) Arborescence canonique
|
||||
### 1.1 Un workKey par “ouvrage / section du site”
|
||||
On veut une univocité entre :
|
||||
- SiteNav (Méthode, Essai-thèse, Traité, Cas IA, Glossaire, Atlas)
|
||||
et
|
||||
- l’arborescence annotations
|
||||
|
||||
Proposition canonique (workKey = route racine) :
|
||||
- `methode`
|
||||
- `archicrat-ia` (Essai-thèse ArchiCraT-IA)
|
||||
- `traite`
|
||||
- `ia`
|
||||
- `glossaire`
|
||||
- `atlas`
|
||||
|
||||
### 1.2 Règle de stockage “v1”
|
||||
**Par page**, un YAML unique :
|
||||
|
||||
src/annotations/<workKey>/<slugSansWorkKey>.yml
|
||||
|
||||
Exemples :
|
||||
- Page : `/archicrat-ia/prologue/`
|
||||
- slug content = `archicrat-ia/prologue`
|
||||
- fichier : `src/annotations/archicrat-ia/prologue.yml`
|
||||
|
||||
- Page : `/traite/00-demarrage/`
|
||||
- fichier : `src/annotations/traite/00-demarrage.yml`
|
||||
|
||||
> Note : “slugSansWorkKey” = la partie après `<workKey>/`.
|
||||
> S’il y a des sous-dossiers (chapitres), le chemin reflète la structure : `chapitre-1/section-a.yml` si on choisit du sharding.
|
||||
|
||||
## 2) Question “gros YAML” : page unique vs sharding par paragraphe
|
||||
### 2.1 Option A (v1 recommandée) : 1 YAML par page
|
||||
Avantages :
|
||||
- simple
|
||||
- peu de fichiers
|
||||
- diff lisible si volume modéré
|
||||
- cohérent avec un modèle “annotations par page”
|
||||
|
||||
Inconvénients :
|
||||
- YAML peut grossir si milliers d’annotations
|
||||
|
||||
### 2.2 Option B (v2 future) : sharding par paragraphe
|
||||
|
||||
src/annotations/<workKey>/<slugSansWorkKey>/<paraId>.yml
|
||||
|
||||
Avantages :
|
||||
- fichiers petits
|
||||
- merges moins conflictuels
|
||||
Inconvénients :
|
||||
- plus de fichiers
|
||||
- tooling plus complexe (indexation + merge multi-fichiers)
|
||||
|
||||
### 2.3 Recommandation de mission (sans casser l’existant)
|
||||
- On démarre en **Option A**.
|
||||
- On se garde une migration future (v2) quand le volume réel le justifie.
|
||||
- On impose dès v1 : **clé unique + merge déterministe + anti-doublon**, ce qui rend la migration future possible.
|
||||
|
||||
## 3) Format YAML v1 (schéma complet)
|
||||
### 3.1 Top-level
|
||||
en yaml :
|
||||
|
||||
schema: 1
|
||||
|
||||
# Optionnel mais recommandé (doit matcher la page)
|
||||
page: "<workKey>/<slugSansWorkKey>"
|
||||
|
||||
meta:
|
||||
title: "Titre de la page (optionnel)"
|
||||
updatedAt: "2026-02-21T12:34:56Z" # ISO8601
|
||||
updatedBy: "username" # compte editor
|
||||
source:
|
||||
kind: "ticket"
|
||||
id: 123
|
||||
url: "https://gitea.../issues/123"
|
||||
|
||||
paras:
|
||||
"<paraId>":
|
||||
references: []
|
||||
media: []
|
||||
comments: []
|
||||
|
||||
### 3.2 paras : clé = paraId (ex: p-0-d7974f88)
|
||||
|
||||
Chaque paragraphe peut porter 3 types d’éléments :
|
||||
|
||||
references
|
||||
|
||||
media
|
||||
|
||||
comments
|
||||
|
||||
Règle : si une section est vide, elle peut être [] ou absente.
|
||||
Mais pour simplifier les merges, on recommande de garder la forme canonique avec [].
|
||||
|
||||
## 4) Formats des items + clés uniques
|
||||
### 4.1 References
|
||||
#### 4.1.1 Format
|
||||
|
||||
references:
|
||||
- id: "ref:doi:10.1234/abcd.efgh" # clé stable (voir 4.1.2)
|
||||
kind: "doi" # doi | url | isbn | arxiv | hal | other
|
||||
label: "Titre court"
|
||||
target: "https://doi.org/10.1234/abcd.efgh"
|
||||
note: "Pourquoi c’est pertinent (optionnel)"
|
||||
addedAt: "2026-02-21T12:34:56Z"
|
||||
addedBy: "username"
|
||||
|
||||
#### 4.1.2 Règle de clé unique (anti-doublon)
|
||||
|
||||
id doit être stable et déterministe :
|
||||
|
||||
doi → ref:doi:<doi>
|
||||
|
||||
isbn → ref:isbn:<isbn>
|
||||
|
||||
url → ref:url:<normalizedUrl>
|
||||
|
||||
Normalisation URL (v1) : au minimum
|
||||
|
||||
trim
|
||||
|
||||
lowercase scheme/host
|
||||
|
||||
retirer trailing slash si non significatif
|
||||
|
||||
conserver query si importante
|
||||
|
||||
#### 4.1.3 Merge / précédence
|
||||
|
||||
Quand on merge deux listes references :
|
||||
|
||||
union par id (clé unique)
|
||||
|
||||
si même id existe des deux côtés :
|
||||
|
||||
conserver kind/target de l’item le plus “riche” (target non vide gagne)
|
||||
|
||||
concat/merge note :
|
||||
|
||||
si notes différentes : garder les deux en les séparant (ex: noteA + "\n---\n" + noteB)
|
||||
|
||||
addedAt : conserver le plus ancien
|
||||
|
||||
addedBy : conserver le premier (ou liste si on veut, mais v1 simple : first)
|
||||
|
||||
### 4.2 Media
|
||||
#### 4.2.1 Format
|
||||
|
||||
media:
|
||||
- id: "media:image:sha256:abcd..." # clé stable (voir 4.2.2)
|
||||
type: "image" # image | video | audio | file
|
||||
src: "/public/media/<workKey>/<slugSansWorkKey>/<paraId>/<filename>"
|
||||
caption: "Légende (optionnel)"
|
||||
credit: "Auteur/source (optionnel)"
|
||||
license: "CC-BY (optionnel)"
|
||||
addedAt: "2026-02-21T12:34:56Z"
|
||||
addedBy: "username"
|
||||
|
||||
#### 4.2.2 Règle de clé unique
|
||||
|
||||
id déterministe :
|
||||
|
||||
idéal : hash du fichier (sha256)
|
||||
|
||||
sinon : hash de type + src
|
||||
|
||||
v1 (si on ne calcule pas de hash fichier) :
|
||||
|
||||
media:<type>:<src>
|
||||
|
||||
#### 4.2.3 Merge / précédence
|
||||
|
||||
union par id
|
||||
|
||||
si collision :
|
||||
|
||||
garder src identique (sinon c’est un bug)
|
||||
|
||||
fusionner caption/credit/license selon “non vide gagne”
|
||||
|
||||
addedAt : plus ancien
|
||||
|
||||
### 4.3 Comments
|
||||
#### 4.3.1 Format
|
||||
|
||||
comments:
|
||||
- id: "cmt:20260221T123456Z:username:0001"
|
||||
kind: "comment" # comment | question | objection | todo | validation
|
||||
text: "Texte du commentaire"
|
||||
status: "open" # open | resolved
|
||||
addedAt: "2026-02-21T12:34:56Z"
|
||||
addedBy: "username"
|
||||
source:
|
||||
kind: "ticket"
|
||||
id: 123
|
||||
|
||||
#### 4.3.2 Clé unique
|
||||
|
||||
Les commentaires sont “append-only” → id peut être générée (timestamp + user + compteur)
|
||||
|
||||
Anti-doublon : si on ré-applique un ticket, on refuse de dupliquer un id existant.
|
||||
|
||||
#### 4.3.3 Merge / précédence
|
||||
|
||||
union par id
|
||||
|
||||
collisions rares, mais si elles arrivent :
|
||||
|
||||
si textes différents → garder les deux (on renomme l’id du second)
|
||||
|
||||
## 5) Règles globales de merge (résumé)
|
||||
|
||||
Quand on applique un ticket sur un YAML existant :
|
||||
|
||||
vérifier schema == 1
|
||||
|
||||
vérifier page si présent :
|
||||
|
||||
doit matcher <workKey>/<slugSansWorkKey>
|
||||
|
||||
paras :
|
||||
|
||||
créer paras[paraId] si absent
|
||||
|
||||
pour chaque liste (references/media/comments) :
|
||||
|
||||
merge par id (anti-doublon)
|
||||
|
||||
appliquer règles de précédence (non vide gagne / concat note / append-only comments)
|
||||
|
||||
## 6) Table de correspondance “UI ticket → YAML”
|
||||
|
||||
Cette table permet à un successeur IA d’implémenter apply-ticket.mjs sans ambiguïté.
|
||||
|
||||
### 6.1 Champs UI minimaux
|
||||
|
||||
workKey (sélection implicite via page)
|
||||
|
||||
pagePath (ex: /archicrat-ia/prologue/)
|
||||
|
||||
pageSlug (ex: archicrat-ia/prologue)
|
||||
|
||||
paraId (ex: p-0-d7974f88)
|
||||
|
||||
kind :
|
||||
|
||||
reference
|
||||
|
||||
media
|
||||
|
||||
comment
|
||||
|
||||
### 6.2 Mapping exact
|
||||
|
||||
| UI kind | UI champs | YAML cible |
|
||||
| --------- | ----------------------------------------------------------- | ---------------------------- |
|
||||
| reference | kind(doi/url/isbn), target, label, note | `paras[paraId].references[]` |
|
||||
| media | type(image/video/audio/file), src, caption, credit, license | `paras[paraId].media[]` |
|
||||
| comment | kind(comment/question/objection/todo/validation), text | `paras[paraId].comments[]` |
|
||||
|
||||
### 6.3 Règles de génération d’ID (implémentation)
|
||||
|
||||
reference.id :
|
||||
|
||||
doi : ref:doi:${doi}
|
||||
|
||||
isbn : ref:isbn:${isbn}
|
||||
|
||||
url : ref:url:${normalize(url)}
|
||||
|
||||
media.id :
|
||||
|
||||
media:${type}:${src}
|
||||
|
||||
comment.id :
|
||||
|
||||
cmt:${timestamp}:${user}:${counter}
|
||||
|
||||
## 7) Validation YAML (sanity)
|
||||
|
||||
Avant commit (et en CI) :
|
||||
|
||||
YAML parse OK
|
||||
|
||||
schema OK
|
||||
|
||||
page si présent cohérent
|
||||
|
||||
paras est un mapping
|
||||
|
||||
paraId match pattern : ^p-\d+-[a-f0-9]{8}$ (existant)
|
||||
|
||||
src media pointe dans /public/media/... (ou /media/... si on choisit un alias, mais v1 canon : /public/media/...)
|
||||
|
||||
## 8) Notes de compatibilité
|
||||
|
||||
Les routes “Essai-thèse” ont été migrées vers /archicrat-ia/*.
|
||||
|
||||
Les anciennes routes /archicratie/archicrat-ia/* peuvent exister en legacy, mais la donnée canonique d’annotation doit suivre le workKey final (archicrat-ia).
|
||||
|
||||
## 9) Ce que l’étape 9 devra implémenter
|
||||
|
||||
pipeline : ticket → YAML (apply-ticket)
|
||||
|
||||
index : build-annotations-index + check-annotations
|
||||
|
||||
tooling : détection médias orphelins / liens cassés
|
||||
|
||||
éventuellement : migration vers sharding par paragraphe (v2) si volume réel le justifie
|
||||
176
docs/START-HERE.md
Normal file
@@ -0,0 +1,176 @@
|
||||
# START-HERE — Archicratie / Édition Web (v2)
|
||||
> Onboarding + exploitation “nickel chrome” (DEV → Gitea → CI → Release → Blue/Green → Edge/SSO)
|
||||
|
||||
## 0) TL;DR (la règle d’or)
|
||||
- **Gitea = source canonique**.
|
||||
- **main est protégé** : 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 site est statique Astro** : la prod sert du HTML (nginx), l’accès est contrôlé au niveau reverse-proxy (Traefik + Authelia).
|
||||
|
||||
## 1) Architecture mentale (ultra simple)
|
||||
- **DEV (Mac Studio)** : édition + tests + commit + push
|
||||
- **Gitea** : dépôt canon + PR + CI (CI.yaml)
|
||||
- **NAS (DS220+)** : déploiement “blue/green”
|
||||
- `web_blue` (staging upstream) → `127.0.0.1:8081`
|
||||
- `web_green` (live upstream) → `127.0.0.1:8082`
|
||||
- **Edge (Traefik)** : route les hosts
|
||||
- `staging.archicratie...` → 8081
|
||||
- `archicratie...` → 8082
|
||||
- **Authelia** devant, via middleware `chain-auth@file`
|
||||
|
||||
## 2) Répertoires & conventions (repo)
|
||||
### 2.1 Contenu canon (édition)
|
||||
- `src/content/**` : contenu MD / MDX canon (Astro content collections)
|
||||
- `src/pages/**` : routes Astro (index, [...slug], etc.)
|
||||
- `src/components/**` : composants UI (SiteNav, TOC, SidePanel, etc.)
|
||||
- `src/layouts/**` : layouts (EditionLayout, SiteLayout)
|
||||
- `src/styles/**` : CSS global
|
||||
|
||||
### 2.2 Annotations (pré-Édition “tickets”)
|
||||
- `src/annotations/<workKey>/<slug>.yml`
|
||||
- Exemple : `src/annotations/archicrat-ia/prologue.yml`
|
||||
- Objectif : stocker “Références / Médias / Commentaires” par page et par paragraphe (`p-...`).
|
||||
|
||||
### 2.3 Scripts (tooling / build)
|
||||
- `scripts/inject-anchor-aliases.mjs` : injection aliases dans dist
|
||||
- `scripts/dedupe-ids-dist.mjs` : retire IDs dupliqués dans dist
|
||||
- `scripts/build-para-index.mjs` : index paragraphes (postbuild / predev)
|
||||
- `scripts/build-annotations-index.mjs` : index annotations (postbuild / predev)
|
||||
- `scripts/check-anchors.mjs` : contrat stabilité d’ancres (CI)
|
||||
- `scripts/check-annotations*.mjs` : sanity YAML + médias
|
||||
|
||||
> Important : les scripts sont **partie intégrante** de la stabilité (IDs/ancres/indexation).
|
||||
> On évite “la magie” : tout est scripté + vérifié.
|
||||
|
||||
## 3) Workflow Git “pro” (main protégé)
|
||||
### 3.1 Cycle standard (toute modif)
|
||||
en bash :
|
||||
|
||||
git checkout main
|
||||
git pull --ff-only
|
||||
|
||||
BR="chore/xxx-$(date +%Y%m%d)"
|
||||
git checkout -b "$BR"
|
||||
|
||||
# dev…
|
||||
npm i
|
||||
npm run build
|
||||
npm run test:anchors
|
||||
|
||||
git add -A
|
||||
git commit -m "xxx: description claire"
|
||||
git push -u origin "$BR"
|
||||
|
||||
### 3.2 PR vers main
|
||||
|
||||
Ouvrir PR dans Gitea
|
||||
|
||||
CI doit être verte
|
||||
|
||||
Merge PR → main
|
||||
|
||||
### 3.3 Cas spécial : hotfix prod (NAS)
|
||||
|
||||
On peut faire un hotfix “urgence” en prod/staging si nécessaire…
|
||||
|
||||
MAIS : l’état final doit revenir dans Gitea : branche → PR → CI → merge.
|
||||
|
||||
## 4) Déploiement (NAS) — principe
|
||||
### 4.1 Release pack
|
||||
|
||||
On génère un pack “reproductible” (source + config + scripts) puis on déploie.
|
||||
|
||||
### 4.2 Blue/Green
|
||||
|
||||
web_blue = staging upstream (8081)
|
||||
|
||||
web_green = live upstream (8082)
|
||||
|
||||
Edge Traefik sélectionne quel host pointe vers quel upstream.
|
||||
|
||||
## 5) Check-list “≤ 10 commandes” (happy path complet)
|
||||
### 5.1 DEV (Mac)
|
||||
|
||||
git checkout main && git pull --ff-only
|
||||
git checkout -b chore/my-change-$(date +%Y%m%d)
|
||||
|
||||
npm i
|
||||
rm -rf .astro node_modules/.vite dist
|
||||
npm run build
|
||||
npm run test:anchors
|
||||
npm run dev
|
||||
|
||||
### 5.2 Push + PR
|
||||
|
||||
git add -A
|
||||
git commit -m "chore: my change"
|
||||
git push -u origin chore/my-change-YYYYMMDD
|
||||
# ouvrir PR dans Gitea
|
||||
|
||||
### 5.3 Déploiement NAS (résumé)
|
||||
|
||||
Voir docs/runbooks/DEPLOY-BLUE-GREEN.md.
|
||||
|
||||
## 6) Problèmes “classiques” + diagnostic rapide
|
||||
### 6.1 “Le staging ne ressemble pas au local”
|
||||
|
||||
# Comparer upstream direct 8081 vs 8082 :
|
||||
|
||||
curl -sS http://127.0.0.1:8081/ | head -n 2
|
||||
curl -sS http://127.0.0.1:8082/ | head -n 2
|
||||
|
||||
# Vérifier quel routeur edge répond (header diag) :
|
||||
|
||||
curl -sSI -H 'Host: staging.archicratie.trans-hands.synology.me' http://127.0.0.1:18080/ \
|
||||
| grep -iE 'HTTP/|location:|x-archi-router'
|
||||
|
||||
# Lire docs/runbooks/EDGE-TRAEFIK.md.
|
||||
|
||||
### 6.2 Canonical incorrect (localhost en prod)
|
||||
|
||||
Cause racine : site dans Astro = PUBLIC_SITE non injecté au build.
|
||||
|
||||
Fix canonique : voir docs/runbooks/ENV-PUBLIC_SITE.md.
|
||||
|
||||
Test :
|
||||
|
||||
curl -sS http://127.0.0.1:8082/ | grep -oE 'rel="canonical" href="[^"]+"' | head -1
|
||||
|
||||
### 6.3 Contrat “anchors” en échec après migration d’URL
|
||||
|
||||
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.
|
||||
|
||||
# Procédure safe :
|
||||
|
||||
Backup baseline :
|
||||
|
||||
cp -a tests/anchors-baseline.json /tmp/anchors-baseline.json.bak.$(date +%F-%H%M%S)
|
||||
|
||||
Mettre à jour les clés (chemins) sans toucher aux IDs :
|
||||
|
||||
node - <<'NODE'
|
||||
import fs from 'fs';
|
||||
const p='tests/anchors-baseline.json';
|
||||
const j=JSON.parse(fs.readFileSync(p,'utf8'));
|
||||
const out={};
|
||||
for (const [k,v] of Object.entries(j)) {
|
||||
const nk = k.replace(/^archicratie\/archicrat-ia\//, 'archicrat-ia/');
|
||||
out[nk]=v;
|
||||
}
|
||||
fs.writeFileSync(p, JSON.stringify(out,null,2)+'\n');
|
||||
console.log('updated keys:', Object.keys(j).length, '->', Object.keys(out).length);
|
||||
NODE
|
||||
|
||||
Re-run :
|
||||
|
||||
npm run test:anchors
|
||||
|
||||
## 7) Ce que l’étape 9 doit faire (orientation)
|
||||
|
||||
Stabiliser le pipeline “tickets → YAML annotations”
|
||||
|
||||
Formaliser la spec YAML + merge + anti-doublon (voir docs/EDITORIAL-ANNOTATIONS-SPEC.md)
|
||||
|
||||
Durcir l’onboarding (ce START-HERE + runbooks)
|
||||
|
||||
Éviter les régressions par tests (anchors / annotations / smoke)
|
||||
|
After Width: | Height: | Size: 221 KiB |
|
After Width: | Height: | Size: 187 KiB |
BIN
docs/diagrams/out/archicratie-web-edition-git-ci-workflow-v1.png
Normal file
|
After Width: | Height: | Size: 395 KiB |
BIN
docs/diagrams/out/archicratie-web-edition-global-verbatim-v2.png
Normal file
|
After Width: | Height: | Size: 284 KiB |
|
After Width: | Height: | Size: 304 KiB |
|
After Width: | Height: | Size: 360 KiB |
202
docs/runbooks/DEPLOY-BLUE-GREEN.md
Normal file
@@ -0,0 +1,202 @@
|
||||
# RUNBOOK — Déploiement Blue/Green (NAS DS220+)
|
||||
> Objectif : déployer une release **sans casser**, avec rollback immédiat.
|
||||
|
||||
## 0) Portée
|
||||
Ce runbook décrit le déploiement de l’édition web Archicratie sur NAS (Synology), en mode blue/green :
|
||||
- `web_blue` : upstream staging → `127.0.0.1:8081`
|
||||
- `web_green` : upstream live → `127.0.0.1:8082`
|
||||
- Edge Traefik publie :
|
||||
- `staging.archicratie.trans-hands.synology.me` → 8081
|
||||
- `archicratie.trans-hands.synology.me` → 8082
|
||||
|
||||
## 1) Pré-requis
|
||||
- Accès shell NAS (user `archicratia`) + `sudo`
|
||||
- Docker Compose Synology nécessite souvent :
|
||||
- `sudo env DOCKER_API_VERSION=1.43 docker compose ...`
|
||||
- Les fichiers edge Traefik sont dans :
|
||||
- `/volume2/docker/edge/config/dynamic/`
|
||||
|
||||
## 2) Répertoires canon (NAS)
|
||||
On considère ces chemins (adapter si besoin, mais rester cohérent) :
|
||||
- Base : `/volume2/docker/archicratie-web`
|
||||
- Releases : `/volume2/docker/archicratie-web/releases/YYYYMMDD-HHMMSS/app`
|
||||
- Symlink actif : `/volume2/docker/archicratie-web/current` → pointe vers le `.../app` actif
|
||||
|
||||
## 3) Garde-fous (AVANT toute action)
|
||||
### 3.1 Snapshot de l’état actuel
|
||||
en bash :
|
||||
|
||||
cd /volume2/docker/archicratie-web
|
||||
ls -la current || true
|
||||
readlink current || true
|
||||
|
||||
### 3.2 Vérifier l’état live/staging upstream direct
|
||||
|
||||
curl -sSI http://127.0.0.1:8081/ | head -n 12
|
||||
curl -sSI http://127.0.0.1:8082/ | head -n 12
|
||||
|
||||
### 3.3 Vérifier l’état edge (host routing)
|
||||
|
||||
curl -sSI -H 'Host: staging.archicratie.trans-hands.synology.me' http://127.0.0.1:18080/ \
|
||||
| grep -iE 'HTTP/|location:|x-archi-router' | head -n 30
|
||||
|
||||
curl -sSI -H 'Host: archicratie.trans-hands.synology.me' http://127.0.0.1:18080/ \
|
||||
| grep -iE 'HTTP/|location:|x-archi-router' | head -n 30
|
||||
|
||||
Si tu n’es pas authentifié, tu verras un 302 vers auth... : c’est normal.
|
||||
|
||||
## 4) Procédure de déploiement (release pack → nouvelle release)
|
||||
### 4.1 Déposer le pack
|
||||
|
||||
Hypothèse : tu as un .tgz “release pack” (issu de release-pack.sh) dans incoming/ :
|
||||
|
||||
cd /volume2/docker/archicratie-web
|
||||
ls -la incoming | tail -n 20
|
||||
|
||||
### 4.2 Créer un répertoire release
|
||||
|
||||
TS="$(date +%Y%m%d-%H%M%S)"
|
||||
REL="/volume2/docker/archicratie-web/releases/$TS"
|
||||
APP="$REL/app"
|
||||
sudo mkdir -p "$APP"
|
||||
|
||||
### 4.3 Extraire le pack
|
||||
|
||||
PKG="/volume2/docker/archicratie-web/incoming/archicratie-web.tar.gz" # adapter au nom réel
|
||||
sudo tar -xzf "$PKG" -C "$APP"
|
||||
|
||||
### 4.4 Sanity check (fichiers attendus)
|
||||
|
||||
sudo test -f "$APP/Dockerfile" && echo "OK Dockerfile"
|
||||
sudo test -f "$APP/docker-compose.yml" && echo "OK compose"
|
||||
sudo test -f "$APP/astro.config.mjs" && echo "OK astro config"
|
||||
sudo test -f "$APP/src/layouts/EditionLayout.astro" && echo "OK layout"
|
||||
sudo test -f "$APP/src/pages/archicrat-ia/index.astro" && echo "OK archicrat-ia index"
|
||||
sudo test -f "$APP/docs/diagrams/archicratie-web-edition-global-verbatim-v2.svg" && echo "OK diagrams"
|
||||
|
||||
### 4.5 Permissions (crucial sur Synology)
|
||||
|
||||
But : archicratia:users doit pouvoir traverser le parent + lire le contenu.
|
||||
|
||||
sudo chown -R archicratia:users "$REL"
|
||||
sudo chmod -R u+rwX,g+rX,o-rwx "$REL"
|
||||
sudo chmod 750 "$REL" "$APP"
|
||||
|
||||
Vérifier :
|
||||
|
||||
ls -ld "$REL" "$APP"
|
||||
ls -la "$APP" | head
|
||||
|
||||
## 5) Activation : basculer current vers la nouvelle release
|
||||
### 5.1 Backup du current existant
|
||||
|
||||
cd /volume2/docker/archicratie-web
|
||||
TS2="$(date +%F-%H%M%S)"
|
||||
|
||||
# on backup "current" (symlink ou dossier)
|
||||
if [ -e current ] || [ -L current ]; then
|
||||
sudo mv -f current "current.BAK.$TS2"
|
||||
echo "✅ backup: current.BAK.$TS2"
|
||||
fi
|
||||
|
||||
### 5.2 Recréer current (symlink propre)
|
||||
|
||||
sudo ln -s "$APP" current
|
||||
|
||||
ls -la current
|
||||
readlink current
|
||||
sudo test -f current/docker-compose.yml && echo "✅ OK: current/docker-compose.yml"
|
||||
|
||||
Si cd current échoue, c’est que current n’est pas un symlink correct OU que le parent n’est pas traversable (permissions).
|
||||
|
||||
## 6) Build & run : (re)construire web_blue/web_green
|
||||
### 6.1 Vérifier la config compose
|
||||
|
||||
cd /volume2/docker/archicratie-web/current
|
||||
sudo env DOCKER_API_VERSION=1.43 docker compose -f docker-compose.yml config \
|
||||
| grep -nE 'services:|web_blue:|web_green:|context:|dockerfile:|PUBLIC_SITE|REQUIRE_PUBLIC_SITE' \
|
||||
| sed -n '1,220p'
|
||||
|
||||
### 6.2 Build propre (recommandé si changement de code/config)
|
||||
|
||||
sudo env DOCKER_API_VERSION=1.43 docker compose build --no-cache web_blue web_green
|
||||
|
||||
### 6.3 Up (force recreate)
|
||||
|
||||
sudo env DOCKER_API_VERSION=1.43 docker compose up -d --force-recreate web_blue web_green
|
||||
|
||||
### 6.4 Vérifier upstream direct (8081/8082)
|
||||
|
||||
curl -sSI http://127.0.0.1:8081/ | head -n 12
|
||||
curl -sSI http://127.0.0.1:8082/ | head -n 12
|
||||
|
||||
## 7) Tests de non-régression (MINIMAL CHECKLIST)
|
||||
|
||||
À exécuter systématiquement après up.
|
||||
|
||||
### 7.1 Upstreams directs
|
||||
|
||||
curl -sSI http://127.0.0.1:8081/ | head -n 12
|
||||
curl -sSI http://127.0.0.1:8082/ | head -n 12
|
||||
|
||||
### 7.2 Canonical (anti “localhost en prod”)
|
||||
|
||||
curl -sS http://127.0.0.1:8081/ | grep -oE 'rel="canonical" href="[^"]+"' | head -n 1
|
||||
curl -sS http://127.0.0.1:8082/ | grep -oE 'rel="canonical" href="[^"]+"' | head -n 1
|
||||
|
||||
Attendu :
|
||||
|
||||
blue (8081) → https://staging.archicratie.../
|
||||
|
||||
green (8082) → https://archicratie.../
|
||||
|
||||
### 7.3 Edge routing (Host header + diag)
|
||||
|
||||
curl -sSI -H 'Host: staging.archicratie.trans-hands.synology.me' http://127.0.0.1:18080/ \
|
||||
| grep -iE 'HTTP/|location:|x-archi-router' | head -n 30
|
||||
|
||||
curl -sSI -H 'Host: staging.archicratie.trans-hands.synology.me' http://127.0.0.1:18080/_auth/whoami \
|
||||
| grep -iE 'HTTP/|location:|x-archi-router' | head -n 30
|
||||
|
||||
### 7.4 Smoke UI (manuel)
|
||||
|
||||
Home : lien “Essai-thèse — ArchiCraT-IA” → /archicrat-ia/
|
||||
|
||||
TOC global : liens /archicrat-ia/* (pas de préfixe /archicratie/archicrat-ia/*)
|
||||
|
||||
Reading-follow/TOC local : scroll ok
|
||||
|
||||
## 8) Rollback (si un seul test est mauvais)
|
||||
|
||||
Objectif : revenir immédiatement à l’état précédent.
|
||||
|
||||
### 8.1 Repointer current sur l’ancien backup
|
||||
|
||||
cd /volume2/docker/archicratie-web
|
||||
ls -la current.BAK.* | tail -n 5
|
||||
|
||||
# choisir le plus récent
|
||||
OLD="current.BAK.YYYY-MM-DD-HHMMSS"
|
||||
sudo rm -f current
|
||||
sudo ln -s "$(readlink -f "$OLD")" current 2>/dev/null || sudo ln -s "$(readlink "$OLD")" current
|
||||
|
||||
ls -la current
|
||||
readlink current
|
||||
|
||||
### 8.2 Rebuild + recreate
|
||||
|
||||
cd /volume2/docker/archicratie-web/current
|
||||
sudo env DOCKER_API_VERSION=1.43 docker compose build --no-cache web_blue web_green
|
||||
sudo env DOCKER_API_VERSION=1.43 docker compose up -d --force-recreate web_blue web_green
|
||||
|
||||
### 8.3 Re-tester la checklist (section 7)
|
||||
|
||||
Si rollback OK : investiguer en environnement isolé (staging upstream uniquement, ou release dans un autre current).
|
||||
|
||||
## 9) Notes opérationnelles
|
||||
|
||||
Ne jamais modifier dist/ “à la main” sur NAS.
|
||||
|
||||
Si un hotfix prod est indispensable : documenter et backporter via PR Gitea.
|
||||
|
||||
Le canonical dépend du build : PUBLIC_SITE doit être injecté (voir runbook ENV-PUBLIC_SITE).
|
||||
147
docs/runbooks/EDGE-TRAEFIK.md
Normal file
@@ -0,0 +1,147 @@
|
||||
# RUNBOOK — Edge Traefik (routing + SSO Authelia)
|
||||
> Objectif : comprendre et diagnostiquer rapidement qui route quoi, et pourquoi staging/live peuvent diverger.
|
||||
|
||||
## 0) Portée
|
||||
Edge Traefik route plusieurs hosts vers des backends locaux (127.0.0.1:*), avec Auth via Authelia.
|
||||
|
||||
Répertoire :
|
||||
- `/volume2/docker/edge/config/dynamic/`
|
||||
|
||||
Port d’entrée edge :
|
||||
- `http://127.0.0.1:18080/` (entryPoint `web`)
|
||||
- Les hosts publics pointent vers cet edge.
|
||||
|
||||
## 1) Fichiers dynamiques (canon)
|
||||
### 00-smoke.yml
|
||||
- route `/__smoke` vers le service `smoke_svc` → `127.0.0.1:18081`
|
||||
|
||||
### 10-core.yml
|
||||
- définit les middlewares :
|
||||
- `sanitize-remote`
|
||||
- `authelia` (forwardAuth vers 9091)
|
||||
- `chain-auth` (chain sanitize-remote + authelia)
|
||||
|
||||
### 20-archicratie-backend.yml
|
||||
- définit service `archicratie_web` → `127.0.0.1:8082` (live upstream)
|
||||
|
||||
### 21-archicratie-staging.yml
|
||||
- route staging host vers `127.0.0.1:8081` (staging upstream)
|
||||
- applique middlewares `diag-staging@file` et `chain-auth@file`
|
||||
- IMPORTANT : `diag-staging@file` doit exister
|
||||
|
||||
### 22-archicratie-authinfo-staging.yml
|
||||
- route `/ _auth /` sur staging vers `whoami@file`
|
||||
- applique `diag-staging-authinfo@file` + `chain-auth@file`
|
||||
- IMPORTANT : `diag-staging-authinfo@file` doit exister
|
||||
|
||||
### 90-overlay-staging-fix.yml (overlay de diagnostic + fallback)
|
||||
Rôle :
|
||||
- **fournir** les middlewares manquants (`diag-staging`, `diag-staging-authinfo`)
|
||||
- optionnel : fallback route si 21/22 sont cassés
|
||||
- injecter un header `X-Archi-Router` pour identifier le routeur utilisé
|
||||
|
||||
### 92-overlay-live-fix.yml
|
||||
- route live host `archicratie.trans-hands.synology.me` → `archicratie_web@file` (8082)
|
||||
- route `/ _auth/whoami` → `whoami@file` (18081)
|
||||
|
||||
## 2) Diagnostiquer rapidement : quel routeur répond ?
|
||||
### 2.1 Test “host header” (sans UI)
|
||||
# en bash :
|
||||
|
||||
curl -sSI -H 'Host: staging.archicratie.trans-hands.synology.me' http://127.0.0.1:18080/ \
|
||||
| grep -iE 'HTTP/|location:|x-archi-router' | head -n 30
|
||||
|
||||
curl -sSI -H 'Host: staging.archicratie.trans-hands.synology.me' http://127.0.0.1:18080/_auth/whoami \
|
||||
| grep -iE 'HTTP/|location:|x-archi-router' | head -n 30
|
||||
|
||||
# Interprétation :
|
||||
|
||||
X-Archi-Router: staging@21 → routeur 21-archicratie-staging.yml OK
|
||||
|
||||
X-Archi-Router: staging-authinfo@22 → routeur authinfo OK
|
||||
|
||||
Si tu vois staging-fallback@90 → tu es tombé sur le fallback 90 (donc 21/22 potentiellement invalides)
|
||||
|
||||
### 2.2 Vérifier l’upstream direct derrière edge
|
||||
|
||||
curl -sSI http://127.0.0.1:8081/ | head -n 12
|
||||
curl -sSI http://127.0.0.1:8082/ | head -n 12
|
||||
|
||||
Si 8081 et 8082 servent des versions différentes : c’est “normal” en blue/green, mais il faut savoir laquelle est censée être staging/live.
|
||||
|
||||
## 3) Diagnostiquer les erreurs Traefik (fichier invalide / middleware manquant)
|
||||
### 3.1 Grep “level=error”
|
||||
|
||||
sudo docker logs edge-traefik --since 5m | grep -Ei 'level=error|middleware|router|service|yaml' | tail -n 80
|
||||
|
||||
# Cas typique :
|
||||
|
||||
middleware "diag-staging@file" does not exist
|
||||
→ 21-archicratie-staging.yml référence un middleware absent. Solution : le définir (souvent dans 90-overlay-staging-fix.yml).
|
||||
|
||||
## 4) Procédure safe de modification (jamais en aveugle)
|
||||
### 4.1 Backup
|
||||
|
||||
cd /volume2/docker/edge/config/dynamic
|
||||
TS="$(date +%F-%H%M%S)"
|
||||
sudo cp -a 90-overlay-staging-fix.yml "90-overlay-staging-fix.yml.bak.$TS"
|
||||
|
||||
### 4.2 Édition (ex : ajouter middlewares diag)
|
||||
|
||||
Faire une modif minimale
|
||||
|
||||
Ne pas casser les règles existantes (Host + PathPrefix)
|
||||
|
||||
Respecter les priorités (voir section 5)
|
||||
|
||||
### 4.3 Reload Traefik
|
||||
|
||||
sudo docker restart edge-traefik
|
||||
|
||||
### 4.4 Tests immédiats
|
||||
|
||||
curl -sSI -H 'Host: staging.archicratie.trans-hands.synology.me' http://127.0.0.1:18080/ \
|
||||
| grep -iE 'HTTP/|location:|x-archi-router'
|
||||
|
||||
curl -sSI -H 'Host: staging.archicratie.trans-hands.synology.me' http://127.0.0.1:18080/_auth/whoami \
|
||||
| grep -iE 'HTTP/|location:|x-archi-router'
|
||||
|
||||
## 5) Priorités Traefik (le point subtil)
|
||||
|
||||
Traefik choisit le routeur selon :
|
||||
|
||||
la correspondance de règle
|
||||
|
||||
la priority (plus grand gagne)
|
||||
|
||||
en cas d’égalité, l’ordre interne (à éviter)
|
||||
|
||||
### 5.1 Canon pour staging
|
||||
|
||||
21-archicratie-staging.yml : priority 10
|
||||
|
||||
22-archicratie-authinfo-staging.yml : priority 10000
|
||||
|
||||
90-overlay-staging-fix.yml :
|
||||
|
||||
fallback host : priority faible (ex: 5) pour ne PAS écraser 21
|
||||
|
||||
fallback whoami : priority < 10000 (ex: 9000) pour ne PAS écraser 22
|
||||
|
||||
=> On garde 90 comme filet de sécurité / diag, pas comme “source”.
|
||||
|
||||
## 6) Rollback (si un changement edge casse staging/live)
|
||||
|
||||
cd /volume2/docker/edge/config/dynamic
|
||||
# choisir le bon backup
|
||||
sudo mv -f 90-overlay-staging-fix.yml "90-overlay-staging-fix.yml.BAD.$(date +%F-%H%M%S)"
|
||||
sudo cp -a 90-overlay-staging-fix.yml.bak.YYYY-MM-DD-HHMMSS 90-overlay-staging-fix.yml
|
||||
sudo docker restart edge-traefik
|
||||
|
||||
Puis re-tests section 2.
|
||||
|
||||
## 7) Remarques
|
||||
|
||||
Les 302 Authelia sont normaux si non authentifié.
|
||||
|
||||
Un 404 “Not Found” depuis edge alors que 8081 répond : souvent routeur manquant / invalidé / middleware absent.
|
||||
114
docs/runbooks/ENV-PUBLIC_SITE.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# RUNBOOK — PUBLIC_SITE (canonical + sitemap) “anti localhost en prod”
|
||||
> Objectif : ne plus jamais voir `rel="canonical" href="http://localhost:4321/"` en staging/live.
|
||||
|
||||
## 0) Pourquoi c’est critique
|
||||
Astro génère :
|
||||
- `<link rel="canonical" href="...">`
|
||||
- `sitemap-index.xml`
|
||||
|
||||
Ces valeurs dépendent de `site` dans `astro.config.mjs`.
|
||||
|
||||
Si `site` vaut `http://localhost:4321` au moment du build Docker, **la prod sortira des canonical faux** :
|
||||
- SEO / partage / cohérence de navigation impactés
|
||||
- confusion staging/live
|
||||
|
||||
## 1) Règle canonique
|
||||
- `astro.config.mjs` :
|
||||
# en js :
|
||||
|
||||
site: process.env.PUBLIC_SITE ?? "http://localhost:4321"
|
||||
|
||||
# Donc :
|
||||
|
||||
En DEV local : pas besoin de PUBLIC_SITE (fallback ok)
|
||||
|
||||
En build “déploiement” : on DOIT fournir PUBLIC_SITE
|
||||
|
||||
## 2) Exigence “antifragile”
|
||||
### 2.1 Dockerfile (build stage)
|
||||
|
||||
On injecte PUBLIC_SITE au build et on peut le rendre obligatoire :
|
||||
|
||||
ARG PUBLIC_SITE
|
||||
|
||||
ARG REQUIRE_PUBLIC_SITE=0
|
||||
|
||||
ENV PUBLIC_SITE=$PUBLIC_SITE
|
||||
|
||||
# garde-fou :
|
||||
|
||||
RUN if [ "$REQUIRE_PUBLIC_SITE" = "1" ] && [ -z "$PUBLIC_SITE" ]; then \
|
||||
echo "ERROR: PUBLIC_SITE is required (REQUIRE_PUBLIC_SITE=1)"; exit 1; \
|
||||
fi
|
||||
|
||||
=> Si quelqu’un oublie l’URL en prod, le build casse au lieu de produire une release mauvaise.
|
||||
|
||||
## 3) docker-compose : blue/staging vs green/live
|
||||
|
||||
Objectif : injecter deux valeurs différentes, sans bricolage.
|
||||
|
||||
### 3.1 .env (NAS)
|
||||
|
||||
Exemple canonique :
|
||||
|
||||
PUBLIC_SITE_BLUE=https://staging.archicratie.trans-hands.synology.me
|
||||
PUBLIC_SITE_GREEN=https://archicratie.trans-hands.synology.me
|
||||
|
||||
### 3.2 docker-compose.yml
|
||||
|
||||
web_blue :
|
||||
|
||||
REQUIRE_PUBLIC_SITE: "1"
|
||||
|
||||
PUBLIC_SITE: ${PUBLIC_SITE_BLUE}
|
||||
|
||||
web_green :
|
||||
|
||||
REQUIRE_PUBLIC_SITE: "1"
|
||||
|
||||
PUBLIC_SITE: ${PUBLIC_SITE_GREEN}
|
||||
|
||||
## 4) Tests (obligatoires après build)
|
||||
### 4.1 Vérifier l’injection dans compose
|
||||
|
||||
sudo env DOCKER_API_VERSION=1.43 docker compose config \
|
||||
| grep -nE 'PUBLIC_SITE|REQUIRE_PUBLIC_SITE|web_blue:|web_green:' | sed -n '1,200p'
|
||||
|
||||
### 4.2 Vérifier canonical (upstream direct)
|
||||
|
||||
curl -sS http://127.0.0.1:8081/ | grep -oE 'rel="canonical" href="[^"]+"' | head -n 1
|
||||
curl -sS http://127.0.0.1:8082/ | grep -oE 'rel="canonical" href="[^"]+"' | head -n 1
|
||||
|
||||
# Attendu :
|
||||
|
||||
blue : https://staging.../
|
||||
|
||||
green : https://archicratie.../
|
||||
|
||||
## 5) Procédure de correction (si canonical est faux)
|
||||
### 5.1 Vérifier astro.config.mjs dans la release courante
|
||||
|
||||
cd /volume2/docker/archicratie-web/current
|
||||
grep -nE 'site:\s*process\.env\.PUBLIC_SITE' astro.config.mjs
|
||||
|
||||
### 5.2 Vérifier que Dockerfile exporte PUBLIC_SITE
|
||||
|
||||
grep -nE 'ARG PUBLIC_SITE|ENV PUBLIC_SITE|REQUIRE_PUBLIC_SITE' Dockerfile
|
||||
|
||||
### 5.3 Vérifier .env et compose
|
||||
|
||||
grep -nE 'PUBLIC_SITE_BLUE|PUBLIC_SITE_GREEN' .env
|
||||
grep -nE 'PUBLIC_SITE|REQUIRE_PUBLIC_SITE' docker-compose.yml
|
||||
|
||||
### 5.4 Rebuild + recreate
|
||||
|
||||
sudo env DOCKER_API_VERSION=1.43 docker compose build --no-cache web_blue web_green
|
||||
sudo env DOCKER_API_VERSION=1.43 docker compose up -d --force-recreate web_blue web_green
|
||||
|
||||
Puis tests section 4.
|
||||
|
||||
## 6) Notes
|
||||
|
||||
Cette mécanique doit être backportée dans Gitea (source canonique), sinon ça re-cassera au prochain pack.
|
||||
|
||||
En DEV local, conserver le fallback http://localhost:4321 est utile et normal.
|
||||
473
package-lock.json
generated
@@ -9,7 +9,7 @@
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"@astrojs/mdx": "^4.3.13",
|
||||
"astro": "^5.16.11"
|
||||
"astro": "^5.17.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/sitemap": "^3.7.0",
|
||||
@@ -1905,9 +1905,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/astro": {
|
||||
"version": "5.16.11",
|
||||
"resolved": "https://registry.npmjs.org/astro/-/astro-5.16.11.tgz",
|
||||
"integrity": "sha512-Z7kvkTTT5n6Hn5lCm6T3WU6pkxx84Hn25dtQ6dR7ATrBGq9eVa8EuB/h1S8xvaoVyCMZnIESu99Z9RJfdLRLDA==",
|
||||
"version": "5.17.3",
|
||||
"resolved": "https://registry.npmjs.org/astro/-/astro-5.17.3.tgz",
|
||||
"integrity": "sha512-69dcfPe8LsHzklwj+hl+vunWUbpMB6pmg35mACjetxbJeUNNys90JaBM8ZiwsPK689SAj/4Zqb1ayaANls9/MA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@astrojs/compiler": "^2.13.0",
|
||||
@@ -1933,7 +1933,7 @@
|
||||
"dlv": "^1.1.3",
|
||||
"dset": "^3.1.4",
|
||||
"es-module-lexer": "^1.7.0",
|
||||
"esbuild": "^0.25.0",
|
||||
"esbuild": "^0.27.3",
|
||||
"estree-walker": "^3.0.3",
|
||||
"flattie": "^1.1.1",
|
||||
"fontace": "~0.4.0",
|
||||
@@ -1954,16 +1954,16 @@
|
||||
"prompts": "^2.4.2",
|
||||
"rehype": "^13.0.2",
|
||||
"semver": "^7.7.3",
|
||||
"shiki": "^3.20.0",
|
||||
"shiki": "^3.21.0",
|
||||
"smol-toml": "^1.6.0",
|
||||
"svgo": "^4.0.0",
|
||||
"tinyexec": "^1.0.2",
|
||||
"tinyglobby": "^0.2.15",
|
||||
"tsconfck": "^3.1.6",
|
||||
"ultrahtml": "^1.6.0",
|
||||
"unifont": "~0.7.1",
|
||||
"unifont": "~0.7.3",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"unstorage": "^1.17.3",
|
||||
"unstorage": "^1.17.4",
|
||||
"vfile": "^6.0.3",
|
||||
"vite": "^6.4.1",
|
||||
"vitefu": "^1.1.1",
|
||||
@@ -1990,6 +1990,463 @@
|
||||
"sharp": "^0.34.0"
|
||||
}
|
||||
},
|
||||
"node_modules/astro/node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
|
||||
"integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/astro/node_modules/@esbuild/android-arm": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz",
|
||||
"integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/astro/node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/astro/node_modules/@esbuild/android-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/astro/node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/astro/node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/astro/node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/astro/node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/astro/node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz",
|
||||
"integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/astro/node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/astro/node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz",
|
||||
"integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/astro/node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz",
|
||||
"integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/astro/node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz",
|
||||
"integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/astro/node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz",
|
||||
"integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/astro/node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz",
|
||||
"integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/astro/node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz",
|
||||
"integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/astro/node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/astro/node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/astro/node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/astro/node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/astro/node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/astro/node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/astro/node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/astro/node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/astro/node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz",
|
||||
"integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/astro/node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/astro/node_modules/esbuild": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
|
||||
"integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.27.3",
|
||||
"@esbuild/android-arm": "0.27.3",
|
||||
"@esbuild/android-arm64": "0.27.3",
|
||||
"@esbuild/android-x64": "0.27.3",
|
||||
"@esbuild/darwin-arm64": "0.27.3",
|
||||
"@esbuild/darwin-x64": "0.27.3",
|
||||
"@esbuild/freebsd-arm64": "0.27.3",
|
||||
"@esbuild/freebsd-x64": "0.27.3",
|
||||
"@esbuild/linux-arm": "0.27.3",
|
||||
"@esbuild/linux-arm64": "0.27.3",
|
||||
"@esbuild/linux-ia32": "0.27.3",
|
||||
"@esbuild/linux-loong64": "0.27.3",
|
||||
"@esbuild/linux-mips64el": "0.27.3",
|
||||
"@esbuild/linux-ppc64": "0.27.3",
|
||||
"@esbuild/linux-riscv64": "0.27.3",
|
||||
"@esbuild/linux-s390x": "0.27.3",
|
||||
"@esbuild/linux-x64": "0.27.3",
|
||||
"@esbuild/netbsd-arm64": "0.27.3",
|
||||
"@esbuild/netbsd-x64": "0.27.3",
|
||||
"@esbuild/openbsd-arm64": "0.27.3",
|
||||
"@esbuild/openbsd-x64": "0.27.3",
|
||||
"@esbuild/openharmony-arm64": "0.27.3",
|
||||
"@esbuild/sunos-x64": "0.27.3",
|
||||
"@esbuild/win32-arm64": "0.27.3",
|
||||
"@esbuild/win32-ia32": "0.27.3",
|
||||
"@esbuild/win32-x64": "0.27.3"
|
||||
}
|
||||
},
|
||||
"node_modules/axobject-query": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||
|
||||
19
package.json
@@ -4,32 +4,29 @@
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"dev": "node scripts/write-dev-whoami.mjs && astro dev",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro",
|
||||
|
||||
"clean": "rm -rf dist",
|
||||
"build": "astro build",
|
||||
"build:clean": "npm run clean && npm run build",
|
||||
|
||||
"postbuild": "node scripts/inject-anchor-aliases.mjs && node scripts/dedupe-ids-dist.mjs && npx pagefind --site dist",
|
||||
|
||||
"postbuild": "node scripts/inject-anchor-aliases.mjs && node scripts/dedupe-ids-dist.mjs && node scripts/build-para-index.mjs && node scripts/build-annotations-index.mjs && node scripts/purge-dist-dev-whoami.mjs && npx pagefind --site dist",
|
||||
"import": "node scripts/import-docx.mjs",
|
||||
"apply:ticket": "node scripts/apply-ticket.mjs",
|
||||
|
||||
"audit:dist": "node scripts/audit-dist.mjs",
|
||||
|
||||
"build:para-index": "node scripts/build-para-index.mjs",
|
||||
"build:annotations-index": "node scripts/build-annotations-index.mjs",
|
||||
"test:aliases": "node scripts/check-anchor-aliases.mjs",
|
||||
"test:anchors": "node scripts/check-anchors.mjs",
|
||||
"test:anchors:update": "node scripts/check-anchors.mjs --update",
|
||||
|
||||
"test": "npm run test:aliases && npm run build:clean && npm run audit:dist && node scripts/verify-anchor-aliases-in-dist.mjs && npm run test:anchors && node scripts/check-inline-js.mjs",
|
||||
|
||||
"test:annotations": "node scripts/check-annotations.mjs",
|
||||
"test:annotations:media": "node scripts/check-annotations-media.mjs",
|
||||
"test": "npm run test:aliases && npm run build:clean && npm run audit:dist && node scripts/verify-anchor-aliases-in-dist.mjs && npm run test:anchors && npm run test:annotations && npm run test:annotations:media && node scripts/check-inline-js.mjs",
|
||||
"ci": "CI=1 npm test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/mdx": "^4.3.13",
|
||||
"astro": "^5.16.11"
|
||||
"astro": "^5.17.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/sitemap": "^3.7.0",
|
||||
|
||||
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 816 KiB |
|
After Width: | Height: | Size: 822 KiB |
|
After Width: | Height: | Size: 822 KiB |
899
scripts/apply-annotation-ticket.mjs
Normal file
@@ -0,0 +1,899 @@
|
||||
#!/usr/bin/env node
|
||||
// scripts/apply-annotation-ticket.mjs
|
||||
//
|
||||
// Applique un ticket Gitea "type/media | type/reference | type/comment" vers:
|
||||
//
|
||||
// ✅ src/annotations/<oeuvre>/<chapitre>/<paraId>.yml (sharding par paragraphe)
|
||||
// ✅ public/media/<oeuvre>/<chapitre>/<paraId>/<file>
|
||||
//
|
||||
// Compat rétro : lit (si présent) l'ancien monolithe:
|
||||
// src/annotations/<oeuvre>/<chapitre>.yml
|
||||
// et deep-merge NON destructif dans le shard lors d'une nouvelle application,
|
||||
// pour permettre une migration progressive sans perte.
|
||||
//
|
||||
// Robuste, idempotent, non destructif.
|
||||
// DRY RUN si --dry-run
|
||||
// Options: --dry-run --no-download --verify --strict --commit --close
|
||||
//
|
||||
// Env requis:
|
||||
// FORGE_API = base API Gitea (LAN) ex: http://192.168.1.20:3000
|
||||
// FORGE_TOKEN = PAT Gitea (repo + issues)
|
||||
//
|
||||
// Env optionnel:
|
||||
// GITEA_OWNER / GITEA_REPO (sinon auto-détecté via git remote)
|
||||
// ANNO_DIR (défaut: src/annotations)
|
||||
// PUBLIC_DIR (défaut: public)
|
||||
// MEDIA_ROOT (défaut URL: /media)
|
||||
//
|
||||
// Ticket attendu (body):
|
||||
// Chemin: /archicrat-ia/chapitre-4/
|
||||
// Ancre: #p-0-xxxxxxxx
|
||||
// Type: type/media | type/reference | type/comment
|
||||
//
|
||||
// Exit codes:
|
||||
// 0 ok
|
||||
// 1 erreur fatale
|
||||
// 2 refus (strict/verify/usage)
|
||||
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import YAML from "yaml";
|
||||
|
||||
/* ---------------------------------- usage --------------------------------- */
|
||||
|
||||
function usage(exitCode = 0) {
|
||||
console.log(`
|
||||
apply-annotation-ticket — applique un ticket SidePanel (media/ref/comment) vers src/annotations/ (shard par paragraphe)
|
||||
|
||||
Usage:
|
||||
node scripts/apply-annotation-ticket.mjs <issue_number> [--dry-run] [--no-download] [--verify] [--strict] [--commit] [--close]
|
||||
|
||||
Flags:
|
||||
--dry-run : n'écrit rien (affiche un aperçu)
|
||||
--no-download : n'essaie pas de télécharger les pièces jointes (media)
|
||||
--verify : vérifie que (page, ancre) existent (dist/para-index.json si dispo, sinon baseline)
|
||||
--strict : refuse si URL ref invalide (http/https) OU caption media vide OU verify impossible
|
||||
--commit : git add + git commit (commit dans la branche courante)
|
||||
--close : ferme le ticket (nécessite --commit)
|
||||
|
||||
Env requis:
|
||||
FORGE_API = base API Gitea (LAN) ex: http://192.168.1.20:3000
|
||||
FORGE_TOKEN = PAT Gitea (repo + issues)
|
||||
|
||||
Env optionnel:
|
||||
GITEA_OWNER / GITEA_REPO (sinon auto-détecté via git remote)
|
||||
ANNO_DIR (défaut: src/annotations)
|
||||
PUBLIC_DIR (défaut: public)
|
||||
MEDIA_ROOT (défaut URL: /media)
|
||||
|
||||
Exit codes:
|
||||
0 ok
|
||||
1 erreur fatale
|
||||
2 refus (strict/verify/close sans commit / incohérence)
|
||||
`);
|
||||
process.exit(exitCode);
|
||||
}
|
||||
|
||||
/* ---------------------------------- args ---------------------------------- */
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
if (argv.length === 0 || argv.includes("--help") || argv.includes("-h")) usage(0);
|
||||
|
||||
const issueNum = Number(argv[0]);
|
||||
if (!Number.isFinite(issueNum) || issueNum <= 0) {
|
||||
console.error("❌ Numéro de ticket invalide.");
|
||||
usage(2);
|
||||
}
|
||||
|
||||
const DRY_RUN = argv.includes("--dry-run");
|
||||
const NO_DOWNLOAD = argv.includes("--no-download");
|
||||
const DO_VERIFY = argv.includes("--verify");
|
||||
const STRICT = argv.includes("--strict");
|
||||
const DO_COMMIT = argv.includes("--commit");
|
||||
const DO_CLOSE = argv.includes("--close");
|
||||
|
||||
if (DO_CLOSE && !DO_COMMIT) {
|
||||
console.error("❌ --close nécessite --commit.");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
if (typeof fetch !== "function") {
|
||||
console.error("❌ fetch() indisponible. Utilise Node 18+.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/* --------------------------------- config --------------------------------- */
|
||||
|
||||
const CWD = process.cwd();
|
||||
const ANNO_DIR = path.join(CWD, process.env.ANNO_DIR || "src", "annotations");
|
||||
const PUBLIC_DIR = path.join(CWD, process.env.PUBLIC_DIR || "public");
|
||||
const MEDIA_URL_ROOT = String(process.env.MEDIA_ROOT || "/media").replace(/\/+$/, "");
|
||||
|
||||
/* --------------------------------- helpers -------------------------------- */
|
||||
|
||||
function getEnv(name, fallback = "") {
|
||||
return (process.env[name] ?? fallback).trim();
|
||||
}
|
||||
|
||||
function run(cmd, args, opts = {}) {
|
||||
const r = spawnSync(cmd, args, { stdio: "inherit", ...opts });
|
||||
if (r.error) throw r.error;
|
||||
if (r.status !== 0) throw new Error(`Command failed: ${cmd} ${args.join(" ")}`);
|
||||
}
|
||||
|
||||
function runQuiet(cmd, args, opts = {}) {
|
||||
const r = spawnSync(cmd, args, { encoding: "utf8", stdio: "pipe", ...opts });
|
||||
if (r.error) throw r.error;
|
||||
if (r.status !== 0) {
|
||||
const out = (r.stdout || "") + (r.stderr || "");
|
||||
throw new Error(`Command failed: ${cmd} ${args.join(" ")}\n${out}`);
|
||||
}
|
||||
return r.stdout || "";
|
||||
}
|
||||
|
||||
async function exists(p) {
|
||||
try {
|
||||
await fs.access(p);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function inferOwnerRepoFromGit() {
|
||||
const r = spawnSync("git", ["remote", "get-url", "origin"], { encoding: "utf-8" });
|
||||
if (r.status !== 0) return null;
|
||||
const u = (r.stdout || "").trim();
|
||||
const m = u.match(/[:/](?<owner>[^/]+)\/(?<repo>[^/]+?)(?:\.git)?$/);
|
||||
if (!m?.groups) return null;
|
||||
return { owner: m.groups.owner, repo: m.groups.repo };
|
||||
}
|
||||
|
||||
function gitHasStagedChanges() {
|
||||
const r = spawnSync("git", ["diff", "--cached", "--quiet"]);
|
||||
return r.status === 1;
|
||||
}
|
||||
|
||||
function escapeRegExp(s) {
|
||||
return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function pickLine(body, key) {
|
||||
const re = new RegExp(`^\\s*${escapeRegExp(key)}\\s*:\\s*([^\\n\\r]+)`, "mi");
|
||||
const m = String(body || "").match(re);
|
||||
return m ? m[1].trim() : "";
|
||||
}
|
||||
|
||||
function pickSection(body, markers) {
|
||||
const text = String(body || "").replace(/\r\n/g, "\n");
|
||||
const idx = markers
|
||||
.map((m) => ({ m, i: text.toLowerCase().indexOf(m.toLowerCase()) }))
|
||||
.filter((x) => x.i >= 0)
|
||||
.sort((a, b) => a.i - b.i)[0];
|
||||
if (!idx) return "";
|
||||
|
||||
const start = idx.i + idx.m.length;
|
||||
const tail = text.slice(start);
|
||||
|
||||
const stops = ["\n## ", "\n---", "\nJustification", "\nProposition", "\nSources"];
|
||||
let end = tail.length;
|
||||
for (const s of stops) {
|
||||
const j = tail.toLowerCase().indexOf(s.toLowerCase());
|
||||
if (j >= 0 && j < end) end = j;
|
||||
}
|
||||
return tail.slice(0, end).trim();
|
||||
}
|
||||
|
||||
function normalizeChemin(chemin) {
|
||||
let c = String(chemin || "").trim();
|
||||
if (!c) return "";
|
||||
if (!c.startsWith("/")) c = "/" + c;
|
||||
if (!c.endsWith("/")) c = c + "/";
|
||||
c = c.replace(/\/{2,}/g, "/");
|
||||
return c;
|
||||
}
|
||||
|
||||
function normalizePageKeyFromChemin(chemin) {
|
||||
// ex: /archicrat-ia/chapitre-4/ => archicrat-ia/chapitre-4
|
||||
return normalizeChemin(chemin).replace(/^\/+|\/+$/g, "");
|
||||
}
|
||||
|
||||
function normalizeAnchorId(s) {
|
||||
let a = String(s || "").trim();
|
||||
if (a.startsWith("#")) a = a.slice(1);
|
||||
return a;
|
||||
}
|
||||
|
||||
function assert(cond, msg, code = 1) {
|
||||
if (!cond) {
|
||||
const e = new Error(msg);
|
||||
e.__exitCode = code;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
function isPlainObject(x) {
|
||||
return !!x && typeof x === "object" && !Array.isArray(x);
|
||||
}
|
||||
|
||||
function paraIndexFromId(id) {
|
||||
const m = String(id).match(/^p-(\d+)-/i);
|
||||
return m ? Number(m[1]) : Number.NaN;
|
||||
}
|
||||
|
||||
function isHttpUrl(u) {
|
||||
try {
|
||||
const x = new URL(String(u));
|
||||
return x.protocol === "http:" || x.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function stableSortByTs(arr) {
|
||||
if (!Array.isArray(arr)) return;
|
||||
arr.sort((a, b) => {
|
||||
const ta = Date.parse(a?.ts || "") || 0;
|
||||
const tb = Date.parse(b?.ts || "") || 0;
|
||||
if (ta !== tb) return ta - tb;
|
||||
return JSON.stringify(a).localeCompare(JSON.stringify(b));
|
||||
});
|
||||
}
|
||||
|
||||
function normPage(s) {
|
||||
let x = String(s || "").trim();
|
||||
if (!x) return "";
|
||||
// retire origin si on a une URL complète
|
||||
x = x.replace(/^https?:\/\/[^/]+/i, "");
|
||||
// enlève query/hash
|
||||
x = x.split("#")[0].split("?")[0];
|
||||
// enlève index.html
|
||||
x = x.replace(/index\.html$/i, "");
|
||||
// enlève slashs de bord
|
||||
x = x.replace(/^\/+/, "").replace(/\/+$/, "");
|
||||
return x;
|
||||
}
|
||||
|
||||
/* ------------------------------ para-index (verify + order) ------------------------------ */
|
||||
|
||||
async function loadParaOrderFromDist(pageKey) {
|
||||
const distIdx = path.join(CWD, "dist", "para-index.json");
|
||||
if (!(await exists(distIdx))) return null;
|
||||
|
||||
let j;
|
||||
try {
|
||||
j = JSON.parse(await fs.readFile(distIdx, "utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const want = normPage(pageKey);
|
||||
|
||||
// Support A) { items:[{id,page,...}, ...] } (ou variantes)
|
||||
const items = Array.isArray(j?.items)
|
||||
? j.items
|
||||
: Array.isArray(j?.index?.items)
|
||||
? j.index.items
|
||||
: null;
|
||||
|
||||
if (items) {
|
||||
const ids = [];
|
||||
for (const it of items) {
|
||||
// page peut être dans plein de clés différentes
|
||||
const pageCand = normPage(
|
||||
it?.page ??
|
||||
it?.pageKey ??
|
||||
it?.path ??
|
||||
it?.route ??
|
||||
it?.href ??
|
||||
it?.url ??
|
||||
""
|
||||
);
|
||||
|
||||
// id peut être dans plein de clés différentes
|
||||
let id = String(it?.id ?? it?.paraId ?? it?.anchorId ?? it?.anchor ?? "");
|
||||
if (id.startsWith("#")) id = id.slice(1);
|
||||
|
||||
if (pageCand === want && id) ids.push(id);
|
||||
}
|
||||
if (ids.length) return ids;
|
||||
}
|
||||
|
||||
// Support B) { byId: { "p-...": { page:"...", ... }, ... } }
|
||||
if (j?.byId && typeof j.byId === "object") {
|
||||
const ids = Object.keys(j.byId)
|
||||
.filter((id) => {
|
||||
const meta = j.byId[id] || {};
|
||||
const pageCand = normPage(meta.page ?? meta.pageKey ?? meta.path ?? meta.route ?? meta.url ?? "");
|
||||
return pageCand === want;
|
||||
});
|
||||
|
||||
if (ids.length) {
|
||||
ids.sort((a, b) => {
|
||||
const ia = paraIndexFromId(a);
|
||||
const ib = paraIndexFromId(b);
|
||||
if (Number.isFinite(ia) && Number.isFinite(ib) && ia !== ib) return ia - ib;
|
||||
return String(a).localeCompare(String(b));
|
||||
});
|
||||
return ids;
|
||||
}
|
||||
}
|
||||
|
||||
// Support C) { pages: { "archicrat-ia/chapitre-4": { ids:[...] } } } (ou variantes)
|
||||
if (j?.pages && typeof j.pages === "object") {
|
||||
// essaie de trouver la bonne clé même si elle est /.../ ou .../index.html
|
||||
const keys = Object.keys(j.pages);
|
||||
const hit = keys.find((k) => normPage(k) === want);
|
||||
if (hit) {
|
||||
const pg = j.pages[hit];
|
||||
if (Array.isArray(pg?.ids)) return pg.ids.map(String);
|
||||
if (Array.isArray(pg?.paras)) return pg.paras.map(String);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function tryVerifyAnchor(pageKey, anchorId) {
|
||||
// 1) dist/para-index.json : order complet si possible
|
||||
const order = await loadParaOrderFromDist(pageKey);
|
||||
if (order) return order.includes(anchorId);
|
||||
|
||||
// 1bis) dist/para-index.json : fallback “best effort” => recherche brute (IDs quasi uniques)
|
||||
const distIdx = path.join(CWD, "dist", "para-index.json");
|
||||
if (await exists(distIdx)) {
|
||||
try {
|
||||
const raw = await fs.readFile(distIdx, "utf8");
|
||||
if (raw.includes(`"${anchorId}"`) || raw.includes(`"#${anchorId}"`)) {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
// 2) tests/anchors-baseline.json (fallback)
|
||||
const base = path.join(CWD, "tests", "anchors-baseline.json");
|
||||
if (await exists(base)) {
|
||||
try {
|
||||
const j = JSON.parse(await fs.readFile(base, "utf8"));
|
||||
const candidates = [];
|
||||
if (j?.pages && typeof j.pages === "object") {
|
||||
for (const [k, v] of Object.entries(j.pages)) {
|
||||
if (!Array.isArray(v)) continue;
|
||||
if (normPage(k).includes(normPage(pageKey))) candidates.push(...v);
|
||||
}
|
||||
}
|
||||
if (Array.isArray(j?.entries)) {
|
||||
for (const it of j.entries) {
|
||||
const p = String(it?.page || "");
|
||||
const ids = it?.ids;
|
||||
if (Array.isArray(ids) && normPage(p).includes(normPage(pageKey))) candidates.push(...ids);
|
||||
}
|
||||
}
|
||||
if (candidates.length) return candidates.some((x) => String(x) === anchorId);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
return null; // cannot verify
|
||||
}
|
||||
|
||||
/* ----------------------------- deep merge helpers (non destructive) ----------------------------- */
|
||||
|
||||
function keyMedia(x) {
|
||||
return String(x?.src || "");
|
||||
}
|
||||
function keyRef(x) {
|
||||
return `${x?.url || ""}||${x?.label || ""}||${x?.kind || ""}||${x?.citation || ""}`;
|
||||
}
|
||||
function keyComment(x) {
|
||||
return String(x?.text || "").trim();
|
||||
}
|
||||
|
||||
function uniqUnion(dstArr, srcArr, keyFn) {
|
||||
const out = Array.isArray(dstArr) ? [...dstArr] : [];
|
||||
const seen = new Set(out.map((x) => keyFn(x)));
|
||||
for (const it of (Array.isArray(srcArr) ? srcArr : [])) {
|
||||
const k = keyFn(it);
|
||||
if (!k) continue;
|
||||
if (!seen.has(k)) {
|
||||
seen.add(k);
|
||||
out.push(it);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function deepMergeEntry(dst, src) {
|
||||
if (!isPlainObject(dst) || !isPlainObject(src)) return;
|
||||
|
||||
for (const [k, v] of Object.entries(src)) {
|
||||
if (k === "media" && Array.isArray(v)) {
|
||||
dst.media = uniqUnion(dst.media, v, keyMedia);
|
||||
continue;
|
||||
}
|
||||
if (k === "refs" && Array.isArray(v)) {
|
||||
dst.refs = uniqUnion(dst.refs, v, keyRef);
|
||||
continue;
|
||||
}
|
||||
if (k === "comments_editorial" && Array.isArray(v)) {
|
||||
dst.comments_editorial = uniqUnion(dst.comments_editorial, v, keyComment);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isPlainObject(v)) {
|
||||
if (!isPlainObject(dst[k])) dst[k] = {};
|
||||
deepMergeEntry(dst[k], v);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Array.isArray(v)) {
|
||||
const cur = Array.isArray(dst[k]) ? dst[k] : [];
|
||||
const seen = new Set(cur.map((x) => JSON.stringify(x)));
|
||||
const out = [...cur];
|
||||
for (const it of v) {
|
||||
const s = JSON.stringify(it);
|
||||
if (!seen.has(s)) {
|
||||
seen.add(s);
|
||||
out.push(it);
|
||||
}
|
||||
}
|
||||
dst[k] = out;
|
||||
continue;
|
||||
}
|
||||
|
||||
// scalar: set only if missing/empty
|
||||
if (!(k in dst) || dst[k] == null || dst[k] === "") {
|
||||
dst[k] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ----------------------------- annotations I/O ----------------------------- */
|
||||
|
||||
async function loadAnnoDocYaml(fileAbs, pageKey) {
|
||||
if (!(await exists(fileAbs))) {
|
||||
return { schema: 1, page: pageKey, paras: {} };
|
||||
}
|
||||
|
||||
const raw = await fs.readFile(fileAbs, "utf8");
|
||||
let doc;
|
||||
try {
|
||||
doc = YAML.parse(raw);
|
||||
} catch (e) {
|
||||
throw new Error(`${path.relative(CWD, fileAbs)}: parse failed: ${String(e?.message ?? e)}`);
|
||||
}
|
||||
|
||||
assert(isPlainObject(doc), `${path.relative(CWD, fileAbs)}: doc must be an object`, 2);
|
||||
assert(doc.schema === 1, `${path.relative(CWD, fileAbs)}: schema must be 1`, 2);
|
||||
assert(isPlainObject(doc.paras), `${path.relative(CWD, fileAbs)}: missing object key "paras"`, 2);
|
||||
|
||||
if (doc.page != null) {
|
||||
const got = String(doc.page).replace(/^\/+/, "").replace(/\/+$/, "");
|
||||
assert(got === pageKey, `${path.relative(CWD, fileAbs)}: page mismatch (page="${doc.page}" vs path="${pageKey}")`, 2);
|
||||
} else {
|
||||
doc.page = pageKey;
|
||||
}
|
||||
|
||||
return doc;
|
||||
}
|
||||
|
||||
function sortParasObject(paras, order) {
|
||||
const keys = Object.keys(paras || {});
|
||||
const idx = new Map();
|
||||
if (Array.isArray(order)) order.forEach((id, i) => idx.set(String(id), i));
|
||||
|
||||
keys.sort((a, b) => {
|
||||
const ha = idx.has(a);
|
||||
const hb = idx.has(b);
|
||||
if (ha && hb) return idx.get(a) - idx.get(b);
|
||||
if (ha && !hb) return -1;
|
||||
if (!ha && hb) return 1;
|
||||
|
||||
const ia = paraIndexFromId(a);
|
||||
const ib = paraIndexFromId(b);
|
||||
if (Number.isFinite(ia) && Number.isFinite(ib) && ia !== ib) return ia - ib;
|
||||
return String(a).localeCompare(String(b));
|
||||
});
|
||||
|
||||
const out = {};
|
||||
for (const k of keys) out[k] = paras[k];
|
||||
return out;
|
||||
}
|
||||
|
||||
async function saveAnnoDocYaml(fileAbs, doc, order = null) {
|
||||
await fs.mkdir(path.dirname(fileAbs), { recursive: true });
|
||||
|
||||
doc.paras = sortParasObject(doc.paras, order);
|
||||
|
||||
for (const e of Object.values(doc.paras || {})) {
|
||||
if (!isPlainObject(e)) continue;
|
||||
stableSortByTs(e.media);
|
||||
stableSortByTs(e.refs);
|
||||
stableSortByTs(e.comments_editorial);
|
||||
}
|
||||
|
||||
const out = YAML.stringify(doc);
|
||||
await fs.writeFile(fileAbs, out, "utf8");
|
||||
}
|
||||
|
||||
/* ------------------------------ gitea helpers ------------------------------ */
|
||||
|
||||
function apiBaseNorm(forgeApiBase) {
|
||||
return forgeApiBase.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
async function giteaGET(url, token) {
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `token ${token}`,
|
||||
Accept: "application/json",
|
||||
"User-Agent": "archicratie-apply-annotation/1.0",
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
const t = await res.text().catch(() => "");
|
||||
throw new Error(`HTTP ${res.status} GET ${url}\n${t}`);
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
async function fetchIssue({ forgeApiBase, owner, repo, token, issueNum }) {
|
||||
const url = `${apiBaseNorm(forgeApiBase)}/api/v1/repos/${owner}/${repo}/issues/${issueNum}`;
|
||||
return await giteaGET(url, token);
|
||||
}
|
||||
|
||||
async function fetchIssueAssets({ forgeApiBase, owner, repo, token, issueNum }) {
|
||||
// Gitea: /issues/{index}/assets
|
||||
const url = `${apiBaseNorm(forgeApiBase)}/api/v1/repos/${owner}/${repo}/issues/${issueNum}/assets`;
|
||||
try {
|
||||
const json = await giteaGET(url, token);
|
||||
return Array.isArray(json) ? json : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function postIssueComment({ forgeApiBase, owner, repo, token, issueNum, comment }) {
|
||||
const url = `${apiBaseNorm(forgeApiBase)}/api/v1/repos/${owner}/${repo}/issues/${issueNum}/comments`;
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `token ${token}`,
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "archicratie-apply-annotation/1.0",
|
||||
},
|
||||
body: JSON.stringify({ body: comment }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const t = await res.text().catch(() => "");
|
||||
throw new Error(`HTTP ${res.status} POST comment ${url}\n${t}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function closeIssue({ forgeApiBase, owner, repo, token, issueNum, comment }) {
|
||||
if (comment) await postIssueComment({ forgeApiBase, owner, repo, token, issueNum, comment });
|
||||
|
||||
const url = `${apiBaseNorm(forgeApiBase)}/api/v1/repos/${owner}/${repo}/issues/${issueNum}`;
|
||||
const res = await fetch(url, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
Authorization: `token ${token}`,
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "archicratie-apply-annotation/1.0",
|
||||
},
|
||||
body: JSON.stringify({ state: "closed" }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const t = await res.text().catch(() => "");
|
||||
throw new Error(`HTTP ${res.status} closing issue: ${url}\n${t}`);
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------ media helpers ------------------------------ */
|
||||
|
||||
function inferMediaTypeFromFilename(name) {
|
||||
const n = String(name || "").toLowerCase();
|
||||
if (/\.(png|jpe?g|webp|gif|svg)$/.test(n)) return "image";
|
||||
if (/\.(mp4|webm|mov|m4v)$/.test(n)) return "video";
|
||||
if (/\.(mp3|wav|ogg|m4a)$/.test(n)) return "audio";
|
||||
return "link";
|
||||
}
|
||||
|
||||
function sanitizeFilename(name) {
|
||||
return String(name || "file")
|
||||
.replace(/[\/\\]/g, "_")
|
||||
.replace(/[^\w.\-]+/g, "_")
|
||||
.replace(/_+/g, "_")
|
||||
.slice(0, 180);
|
||||
}
|
||||
|
||||
async function downloadToFile(url, token, destAbs) {
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `token ${token}`,
|
||||
"User-Agent": "archicratie-apply-annotation/1.0",
|
||||
},
|
||||
redirect: "follow",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const t = await res.text().catch(() => "");
|
||||
throw new Error(`download failed HTTP ${res.status}: ${url}\n${t}`);
|
||||
}
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
await fs.mkdir(path.dirname(destAbs), { recursive: true });
|
||||
await fs.writeFile(destAbs, buf);
|
||||
return buf.length;
|
||||
}
|
||||
|
||||
/* ------------------------------ type parsers ------------------------------ */
|
||||
|
||||
function parseReferenceBlock(body) {
|
||||
const block =
|
||||
pickSection(body, ["Référence (à compléter):", "Reference (à compléter):"]) ||
|
||||
pickSection(body, ["Référence:", "Reference:"]);
|
||||
|
||||
const lines = String(block || "").split(/\r?\n/).map((l) => l.trim());
|
||||
const get = (k) => {
|
||||
const re = new RegExp(`^[-*]\\s*${escapeRegExp(k)}\\s*:\\s*(.*)$`, "i");
|
||||
const m = lines.map((l) => l.match(re)).find(Boolean);
|
||||
return (m?.[1] ?? "").trim();
|
||||
};
|
||||
|
||||
return {
|
||||
url: get("URL") || "",
|
||||
label: get("Label") || "",
|
||||
kind: get("Kind") || "",
|
||||
citation: get("Citation") || get("Passage") || get("Extrait") || "",
|
||||
rawBlock: block || "",
|
||||
};
|
||||
}
|
||||
|
||||
/* ----------------------------------- main ---------------------------------- */
|
||||
|
||||
async function main() {
|
||||
const token = getEnv("FORGE_TOKEN");
|
||||
assert(token, "❌ FORGE_TOKEN manquant.", 2);
|
||||
|
||||
const forgeApiBase = getEnv("FORGE_API") || getEnv("FORGE_BASE");
|
||||
assert(forgeApiBase, "❌ FORGE_API (ou FORGE_BASE) manquant.", 2);
|
||||
|
||||
const inferred = inferOwnerRepoFromGit() || {};
|
||||
const owner = getEnv("GITEA_OWNER", inferred.owner || "");
|
||||
const repo = getEnv("GITEA_REPO", inferred.repo || "");
|
||||
assert(owner && repo, "❌ Impossible de déterminer owner/repo. Fix: export GITEA_OWNER=... GITEA_REPO=...", 2);
|
||||
|
||||
console.log(`🔎 Fetch ticket #${issueNum} from ${owner}/${repo} …`);
|
||||
const issue = await fetchIssue({ forgeApiBase, owner, repo, token, issueNum });
|
||||
|
||||
if (issue?.pull_request) {
|
||||
console.error(`❌ #${issueNum} est une Pull Request, pas un ticket annotations.`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const body = String(issue.body || "").replace(/\r\n/g, "\n");
|
||||
const title = String(issue.title || "");
|
||||
|
||||
const type = pickLine(body, "Type").toLowerCase();
|
||||
const chemin = normalizeChemin(pickLine(body, "Chemin"));
|
||||
const ancre = normalizeAnchorId(pickLine(body, "Ancre"));
|
||||
|
||||
assert(chemin, "Ticket: Chemin manquant.", 2);
|
||||
assert(ancre && /^p-\d+-/i.test(ancre), `Ticket: Ancre invalide ("${ancre}")`, 2);
|
||||
assert(type, "Ticket: Type manquant.", 2);
|
||||
|
||||
const pageKey = normalizePageKeyFromChemin(chemin);
|
||||
assert(pageKey, "Ticket: impossible de dériver pageKey.", 2);
|
||||
|
||||
const paraOrder = DO_VERIFY ? await loadParaOrderFromDist(pageKey) : null;
|
||||
|
||||
if (DO_VERIFY) {
|
||||
const ok = await tryVerifyAnchor(pageKey, ancre);
|
||||
if (ok === false) {
|
||||
throw Object.assign(new Error(`Ticket verify: ancre introuvable pour page "${pageKey}" => ${ancre}`), { __exitCode: 2 });
|
||||
}
|
||||
if (ok === null) {
|
||||
if (STRICT) {
|
||||
throw Object.assign(
|
||||
new Error(`Ticket verify (strict): impossible de vérifier (pas de dist/para-index.json ou baseline)`),
|
||||
{ __exitCode: 2 }
|
||||
);
|
||||
}
|
||||
console.warn("⚠️ verify: impossible de vérifier (pas de dist/para-index.json ou baseline) — on continue.");
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ shard path: src/annotations/<pageKey>/<paraId>.yml
|
||||
const shardAbs = path.join(ANNO_DIR, ...pageKey.split("/"), `${ancre}.yml`);
|
||||
const shardRel = path.relative(CWD, shardAbs).replace(/\\/g, "/");
|
||||
|
||||
// legacy monolith: src/annotations/<pageKey>.yml (read-only, for migration)
|
||||
const legacyAbs = path.join(ANNO_DIR, `${pageKey}.yml`);
|
||||
|
||||
console.log("✅ Parsed:", { type, chemin, ancre: `#${ancre}`, pageKey, annoFile: shardRel });
|
||||
|
||||
// load shard doc
|
||||
const doc = await loadAnnoDocYaml(shardAbs, pageKey);
|
||||
if (!isPlainObject(doc.paras[ancre])) doc.paras[ancre] = {};
|
||||
const entry = doc.paras[ancre];
|
||||
|
||||
// merge legacy entry into shard in-memory (non destructive) to keep compat + enable progressive migration
|
||||
if (await exists(legacyAbs)) {
|
||||
try {
|
||||
const legacy = await loadAnnoDocYaml(legacyAbs, pageKey);
|
||||
const legacyEntry = legacy?.paras?.[ancre];
|
||||
if (isPlainObject(legacyEntry)) {
|
||||
deepMergeEntry(entry, legacyEntry);
|
||||
}
|
||||
} catch {
|
||||
// ignore legacy parse issues; shard still applies new data
|
||||
}
|
||||
}
|
||||
|
||||
const touchedFiles = [];
|
||||
const notes = [];
|
||||
let changed = false;
|
||||
const nowIso = new Date().toISOString();
|
||||
|
||||
if (type === "type/comment") {
|
||||
const comment = pickSection(body, ["Commentaire:", "Comment:", "Commentaires:"]) || "";
|
||||
const text = comment.trim();
|
||||
assert(text.length >= 3, "Ticket comment: bloc 'Commentaire:' introuvable ou trop court.", 2);
|
||||
|
||||
if (!Array.isArray(entry.comments_editorial)) entry.comments_editorial = [];
|
||||
const item = { text, status: "new", ts: nowIso, fromIssue: issueNum };
|
||||
|
||||
const before = entry.comments_editorial.length;
|
||||
entry.comments_editorial = uniqUnion(entry.comments_editorial, [item], keyComment);
|
||||
if (entry.comments_editorial.length !== before) {
|
||||
changed = true;
|
||||
notes.push(`+ comment added (len=${text.length})`);
|
||||
} else {
|
||||
notes.push(`~ comment already present (dedup)`);
|
||||
}
|
||||
stableSortByTs(entry.comments_editorial);
|
||||
}
|
||||
|
||||
else if (type === "type/reference") {
|
||||
const ref = parseReferenceBlock(body);
|
||||
assert(ref.url || ref.label, "Ticket reference: renseigne au moins - URL: ou - Label: dans le ticket.", 2);
|
||||
|
||||
if (STRICT && ref.url && !isHttpUrl(ref.url)) {
|
||||
throw Object.assign(new Error(`Ticket reference (strict): URL invalide (http/https requis): "${ref.url}"`), { __exitCode: 2 });
|
||||
}
|
||||
|
||||
if (!Array.isArray(entry.refs)) entry.refs = [];
|
||||
const item = {
|
||||
url: ref.url || "",
|
||||
label: ref.label || (ref.url ? ref.url : "Référence"),
|
||||
kind: ref.kind || "",
|
||||
ts: nowIso,
|
||||
fromIssue: issueNum,
|
||||
};
|
||||
if (ref.citation) item.citation = ref.citation;
|
||||
|
||||
const before = entry.refs.length;
|
||||
entry.refs = uniqUnion(entry.refs, [item], keyRef);
|
||||
if (entry.refs.length !== before) {
|
||||
changed = true;
|
||||
notes.push(`+ reference added (${item.url ? "url" : "label"})`);
|
||||
} else {
|
||||
notes.push(`~ reference already present (dedup)`);
|
||||
}
|
||||
stableSortByTs(entry.refs);
|
||||
}
|
||||
|
||||
else if (type === "type/media") {
|
||||
if (!Array.isArray(entry.media)) entry.media = [];
|
||||
|
||||
const caption = (title || "").trim();
|
||||
if (STRICT && !caption) {
|
||||
throw Object.assign(new Error("Ticket media (strict): caption vide (titre de ticket requis)."), { __exitCode: 2 });
|
||||
}
|
||||
const captionFinal = caption || ".";
|
||||
|
||||
const atts = NO_DOWNLOAD ? [] : await fetchIssueAssets({ forgeApiBase, owner, repo, token, issueNum });
|
||||
if (!atts.length) notes.push("! no assets found (nothing to download).");
|
||||
|
||||
for (const a of atts) {
|
||||
const name = sanitizeFilename(a?.name || `asset-${a?.id || "x"}`);
|
||||
const dl = a?.browser_download_url || a?.download_url || "";
|
||||
if (!dl) { notes.push(`! asset missing download url: ${name}`); continue; }
|
||||
|
||||
const mediaDirAbs = path.join(PUBLIC_DIR, "media", ...pageKey.split("/"), ancre);
|
||||
const destAbs = path.join(mediaDirAbs, name);
|
||||
const urlPath = `${MEDIA_URL_ROOT}/${pageKey}/${ancre}/${name}`.replace(/\/{2,}/g, "/");
|
||||
|
||||
if (await exists(destAbs)) {
|
||||
notes.push(`~ media already exists: ${urlPath}`);
|
||||
} else if (!DRY_RUN) {
|
||||
const bytes = await downloadToFile(dl, token, destAbs);
|
||||
notes.push(`+ downloaded ${name} (${bytes} bytes) -> ${urlPath}`);
|
||||
touchedFiles.push(path.relative(CWD, destAbs).replace(/\\/g, "/"));
|
||||
changed = true;
|
||||
} else {
|
||||
notes.push(`(dry) would download ${name} -> ${urlPath}`);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
const item = {
|
||||
type: inferMediaTypeFromFilename(name),
|
||||
src: urlPath,
|
||||
caption: captionFinal,
|
||||
credit: "",
|
||||
ts: nowIso,
|
||||
fromIssue: issueNum,
|
||||
};
|
||||
|
||||
const before = entry.media.length;
|
||||
entry.media = uniqUnion(entry.media, [item], keyMedia);
|
||||
if (entry.media.length !== before) changed = true;
|
||||
}
|
||||
|
||||
stableSortByTs(entry.media);
|
||||
}
|
||||
|
||||
else {
|
||||
throw Object.assign(new Error(`Type non supporté: "${type}"`), { __exitCode: 2 });
|
||||
}
|
||||
|
||||
if (!changed) {
|
||||
console.log("ℹ️ No changes to apply.");
|
||||
for (const n of notes) console.log(" ", n);
|
||||
return;
|
||||
}
|
||||
|
||||
if (DRY_RUN) {
|
||||
console.log("\n--- DRY RUN (no write) ---");
|
||||
console.log(`Would update: ${shardRel}`);
|
||||
for (const n of notes) console.log(" ", n);
|
||||
console.log("\nExcerpt (resulting entry):");
|
||||
console.log(YAML.stringify({ [ancre]: doc.paras[ancre] }).trimEnd());
|
||||
console.log("\n✅ Dry-run terminé.");
|
||||
return;
|
||||
}
|
||||
|
||||
await saveAnnoDocYaml(shardAbs, doc, paraOrder);
|
||||
touchedFiles.unshift(shardRel);
|
||||
|
||||
console.log(`✅ Updated: ${shardRel}`);
|
||||
for (const n of notes) console.log(" ", n);
|
||||
|
||||
if (DO_COMMIT) {
|
||||
run("git", ["add", ...touchedFiles], { cwd: CWD });
|
||||
|
||||
if (!gitHasStagedChanges()) {
|
||||
console.log("ℹ️ Nothing to commit (aucun changement staged).");
|
||||
return;
|
||||
}
|
||||
|
||||
const msg = `anno: apply ticket #${issueNum} (${pageKey}#${ancre} ${type})`;
|
||||
run("git", ["commit", "-m", msg], { cwd: CWD });
|
||||
|
||||
const sha = runQuiet("git", ["rev-parse", "--short", "HEAD"], { cwd: CWD }).trim();
|
||||
console.log(`✅ Committed: ${msg} (${sha})`);
|
||||
|
||||
if (DO_CLOSE) {
|
||||
const comment = `✅ Appliqué par apply-annotation-ticket.\nCommit: ${sha}`;
|
||||
await closeIssue({ forgeApiBase, owner, repo, token, issueNum, comment });
|
||||
console.log(`✅ Ticket #${issueNum} fermé.`);
|
||||
}
|
||||
} else {
|
||||
console.log("\nNext (manuel) :");
|
||||
console.log(` git diff -- ${touchedFiles[0]}`);
|
||||
console.log(` git add ${touchedFiles.join(" ")}`);
|
||||
console.log(` git commit -m "anno: apply ticket #${issueNum} (${pageKey}#${ancre} ${type})"`);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
const code = e?.__exitCode || 1;
|
||||
console.error("💥", e?.message || e);
|
||||
process.exit(code);
|
||||
});
|
||||
246
scripts/build-annotations-index.mjs
Normal file
@@ -0,0 +1,246 @@
|
||||
#!/usr/bin/env node
|
||||
// scripts/build-annotations-index.mjs
|
||||
// Construit dist/annotations-index.json à partir de src/annotations/**/*.yml
|
||||
// Supporte:
|
||||
// - monolith : src/annotations/<pageKey>.yml
|
||||
// - shard : src/annotations/<pageKey>/<paraId>.yml (paraId = p-<n>-...)
|
||||
// Invariants:
|
||||
// - doc.schema === 1
|
||||
// - doc.page (si présent) == pageKey déduit du chemin
|
||||
// - shard: doc.paras doit contenir EXACTEMENT la clé paraId (sinon fail)
|
||||
//
|
||||
// Deep-merge non destructif (media/refs/comments dédupliqués), tri stable.
|
||||
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import YAML from "yaml";
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const ANNO_ROOT = path.join(ROOT, "src", "annotations");
|
||||
const DIST_DIR = path.join(ROOT, "dist");
|
||||
const OUT = path.join(DIST_DIR, "annotations-index.json");
|
||||
|
||||
function assert(cond, msg) {
|
||||
if (!cond) throw new Error(msg);
|
||||
}
|
||||
|
||||
function isObj(x) {
|
||||
return !!x && typeof x === "object" && !Array.isArray(x);
|
||||
}
|
||||
function isArr(x) {
|
||||
return Array.isArray(x);
|
||||
}
|
||||
|
||||
function normPath(s) {
|
||||
return String(s || "")
|
||||
.replace(/\\/g, "/")
|
||||
.replace(/^\/+|\/+$/g, "");
|
||||
}
|
||||
|
||||
function paraNum(pid) {
|
||||
const m = String(pid).match(/^p-(\d+)-/i);
|
||||
return m ? Number(m[1]) : Number.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
function stableSortByTs(arr) {
|
||||
if (!Array.isArray(arr)) return;
|
||||
arr.sort((a, b) => {
|
||||
const ta = Date.parse(a?.ts || "") || 0;
|
||||
const tb = Date.parse(b?.ts || "") || 0;
|
||||
if (ta !== tb) return ta - tb;
|
||||
return JSON.stringify(a).localeCompare(JSON.stringify(b));
|
||||
});
|
||||
}
|
||||
|
||||
function keyMedia(x) { return String(x?.src || ""); }
|
||||
function keyRef(x) {
|
||||
return `${x?.url || ""}||${x?.label || ""}||${x?.kind || ""}||${x?.citation || ""}`;
|
||||
}
|
||||
function keyComment(x) { return String(x?.text || "").trim(); }
|
||||
|
||||
function uniqUnion(dst, src, keyFn) {
|
||||
const out = isArr(dst) ? [...dst] : [];
|
||||
const seen = new Set(out.map((x) => keyFn(x)));
|
||||
for (const it of (isArr(src) ? src : [])) {
|
||||
const k = keyFn(it);
|
||||
if (!k) continue;
|
||||
if (!seen.has(k)) {
|
||||
seen.add(k);
|
||||
out.push(it);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function deepMergeEntry(dst, src) {
|
||||
if (!isObj(dst) || !isObj(src)) return;
|
||||
|
||||
for (const [k, v] of Object.entries(src)) {
|
||||
if (k === "media" && isArr(v)) { dst.media = uniqUnion(dst.media, v, keyMedia); continue; }
|
||||
if (k === "refs" && isArr(v)) { dst.refs = uniqUnion(dst.refs, v, keyRef); continue; }
|
||||
if (k === "comments_editorial" && isArr(v)) { dst.comments_editorial = uniqUnion(dst.comments_editorial, v, keyComment); continue; }
|
||||
|
||||
if (isObj(v)) {
|
||||
if (!isObj(dst[k])) dst[k] = {};
|
||||
deepMergeEntry(dst[k], v);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isArr(v)) {
|
||||
const cur = isArr(dst[k]) ? dst[k] : [];
|
||||
const seen = new Set(cur.map((x) => JSON.stringify(x)));
|
||||
const out = [...cur];
|
||||
for (const it of v) {
|
||||
const s = JSON.stringify(it);
|
||||
if (!seen.has(s)) { seen.add(s); out.push(it); }
|
||||
}
|
||||
dst[k] = out;
|
||||
continue;
|
||||
}
|
||||
|
||||
// scalar: set only if missing/empty
|
||||
if (!(k in dst) || dst[k] == null || dst[k] === "") dst[k] = v;
|
||||
}
|
||||
}
|
||||
|
||||
async function walk(dir) {
|
||||
const out = [];
|
||||
const ents = await fs.readdir(dir, { withFileTypes: true });
|
||||
for (const e of ents) {
|
||||
const p = path.join(dir, e.name);
|
||||
if (e.isDirectory()) out.push(...await walk(p));
|
||||
else if (e.isFile() && /\.ya?ml$/i.test(e.name)) out.push(p);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function inferExpectedFromRel(relNoExt) {
|
||||
const parts = relNoExt.split("/").filter(Boolean);
|
||||
const last = parts.at(-1) || "";
|
||||
const isShard = parts.length > 1 && /^p-\d+-/i.test(last); // ✅ durcissement
|
||||
const pageKey = isShard ? parts.slice(0, -1).join("/") : relNoExt;
|
||||
const paraId = isShard ? last : null;
|
||||
return { isShard, pageKey, paraId };
|
||||
}
|
||||
|
||||
function validateAndNormalizeDoc(doc, relFile, expectedPageKey, expectedParaId) {
|
||||
assert(isObj(doc), `${relFile}: doc must be an object`);
|
||||
assert(doc.schema === 1, `${relFile}: schema must be 1`);
|
||||
assert(isObj(doc.paras), `${relFile}: missing object key "paras"`);
|
||||
|
||||
const gotPage = doc.page != null ? normPath(doc.page) : "";
|
||||
const expPage = normPath(expectedPageKey);
|
||||
|
||||
if (gotPage) {
|
||||
assert(
|
||||
gotPage === expPage,
|
||||
`${relFile}: page mismatch (page="${doc.page}" vs path="${expectedPageKey}")`
|
||||
);
|
||||
} else {
|
||||
doc.page = expPage;
|
||||
}
|
||||
|
||||
if (expectedParaId) {
|
||||
const keys = Object.keys(doc.paras || {}).map(String);
|
||||
assert(
|
||||
keys.includes(expectedParaId),
|
||||
`${relFile}: shard mismatch: must contain paras["${expectedParaId}"]`
|
||||
);
|
||||
assert(
|
||||
keys.length === 1 && keys[0] === expectedParaId,
|
||||
`${relFile}: shard invariant violated: shard file must contain ONLY paras["${expectedParaId}"] (got: ${keys.join(", ")})`
|
||||
);
|
||||
}
|
||||
|
||||
return doc;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const pages = {};
|
||||
const errors = [];
|
||||
|
||||
await fs.mkdir(DIST_DIR, { recursive: true });
|
||||
|
||||
const files = await walk(ANNO_ROOT);
|
||||
|
||||
for (const fp of files) {
|
||||
const rel = normPath(path.relative(ANNO_ROOT, fp));
|
||||
const relNoExt = rel.replace(/\.ya?ml$/i, "");
|
||||
const { isShard, pageKey, paraId } = inferExpectedFromRel(relNoExt);
|
||||
|
||||
try {
|
||||
const raw = await fs.readFile(fp, "utf8");
|
||||
const doc = YAML.parse(raw) || {};
|
||||
|
||||
if (!isObj(doc) || doc.schema !== 1) continue;
|
||||
|
||||
validateAndNormalizeDoc(
|
||||
doc,
|
||||
`src/annotations/${rel}`,
|
||||
pageKey,
|
||||
isShard ? paraId : null
|
||||
);
|
||||
|
||||
const pg = (pages[pageKey] ??= { paras: {} });
|
||||
|
||||
if (isShard) {
|
||||
const entry = doc.paras[paraId];
|
||||
if (!isObj(pg.paras[paraId])) pg.paras[paraId] = {};
|
||||
if (isObj(entry)) deepMergeEntry(pg.paras[paraId], entry);
|
||||
|
||||
stableSortByTs(pg.paras[paraId].media);
|
||||
stableSortByTs(pg.paras[paraId].refs);
|
||||
stableSortByTs(pg.paras[paraId].comments_editorial);
|
||||
} else {
|
||||
for (const [pid, entry] of Object.entries(doc.paras || {})) {
|
||||
const p = String(pid);
|
||||
if (!isObj(pg.paras[p])) pg.paras[p] = {};
|
||||
if (isObj(entry)) deepMergeEntry(pg.paras[p], entry);
|
||||
|
||||
stableSortByTs(pg.paras[p].media);
|
||||
stableSortByTs(pg.paras[p].refs);
|
||||
stableSortByTs(pg.paras[p].comments_editorial);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
errors.push({ file: `src/annotations/${rel}`, error: String(e?.message || e) });
|
||||
}
|
||||
}
|
||||
|
||||
for (const [pageKey, pg] of Object.entries(pages)) {
|
||||
const keys = Object.keys(pg.paras || {});
|
||||
keys.sort((a, b) => {
|
||||
const ia = paraNum(a);
|
||||
const ib = paraNum(b);
|
||||
if (Number.isFinite(ia) && Number.isFinite(ib) && ia !== ib) return ia - ib;
|
||||
return String(a).localeCompare(String(b));
|
||||
});
|
||||
const next = {};
|
||||
for (const k of keys) next[k] = pg.paras[k];
|
||||
pg.paras = next;
|
||||
}
|
||||
|
||||
const out = {
|
||||
schema: 1,
|
||||
generatedAt: new Date().toISOString(),
|
||||
pages,
|
||||
stats: {
|
||||
pages: Object.keys(pages).length,
|
||||
paras: Object.values(pages).reduce((n, p) => n + Object.keys(p.paras || {}).length, 0),
|
||||
errors: errors.length,
|
||||
},
|
||||
errors,
|
||||
};
|
||||
|
||||
if (errors.length) {
|
||||
throw new Error(`${errors[0].file}: ${errors[0].error}`);
|
||||
}
|
||||
|
||||
await fs.writeFile(OUT, JSON.stringify(out), "utf8");
|
||||
console.log(`✅ annotations-index: pages=${out.stats.pages} paras=${out.stats.paras} -> dist/annotations-index.json`);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(`FAIL: build-annotations-index crashed: ${e?.stack || e?.message || e}`);
|
||||
process.exit(1);
|
||||
});
|
||||
104
scripts/check-annotations-media.mjs
Normal file
@@ -0,0 +1,104 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import YAML from "yaml";
|
||||
|
||||
const CWD = process.cwd();
|
||||
const ANNO_DIR = path.join(CWD, "src", "annotations");
|
||||
const PUBLIC_DIR = path.join(CWD, "public");
|
||||
|
||||
async function exists(p) {
|
||||
try { await fs.access(p); return true; } catch { return false; }
|
||||
}
|
||||
|
||||
async function walk(dir) {
|
||||
const out = [];
|
||||
const ents = await fs.readdir(dir, { withFileTypes: true });
|
||||
for (const e of ents) {
|
||||
const p = path.join(dir, e.name);
|
||||
if (e.isDirectory()) out.push(...(await walk(p)));
|
||||
else out.push(p);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseDoc(raw, fileAbs) {
|
||||
if (/\.json$/i.test(fileAbs)) return JSON.parse(raw);
|
||||
return YAML.parse(raw);
|
||||
}
|
||||
|
||||
function isPlainObject(x) {
|
||||
return !!x && typeof x === "object" && !Array.isArray(x);
|
||||
}
|
||||
|
||||
function toPublicPathFromUrl(urlPath) {
|
||||
// "/media/..." -> "public/media/..."
|
||||
const clean = String(urlPath || "").split("?")[0].split("#")[0];
|
||||
if (!clean.startsWith("/media/")) return null;
|
||||
return path.join(PUBLIC_DIR, clean.replace(/^\/+/, ""));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!(await exists(ANNO_DIR))) {
|
||||
console.log("✅ annotations-media: aucun src/annotations — rien à vérifier.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const files = (await walk(ANNO_DIR)).filter((p) => /\.(ya?ml|json)$/i.test(p));
|
||||
let checked = 0;
|
||||
let missing = 0;
|
||||
const notes = [];
|
||||
|
||||
// Optim: éviter de vérifier 100 fois le même fichier media
|
||||
const seenMedia = new Set(); // src string
|
||||
|
||||
for (const f of files) {
|
||||
const rel = path.relative(CWD, f).replace(/\\/g, "/");
|
||||
const raw = await fs.readFile(f, "utf8");
|
||||
|
||||
let doc;
|
||||
try { doc = parseDoc(raw, f); }
|
||||
catch (e) {
|
||||
missing++;
|
||||
notes.push(`- PARSE FAIL: ${rel} (${String(e?.message ?? e)})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isPlainObject(doc) || doc.schema !== 1 || !isPlainObject(doc.paras)) continue;
|
||||
|
||||
for (const [paraId, entry] of Object.entries(doc.paras)) {
|
||||
const media = entry?.media;
|
||||
if (!Array.isArray(media)) continue;
|
||||
|
||||
for (const m of media) {
|
||||
const src = String(m?.src || "");
|
||||
if (!src.startsWith("/media/")) continue; // externes ok, ou autres conventions futures
|
||||
|
||||
// dédupe
|
||||
if (seenMedia.has(src)) continue;
|
||||
seenMedia.add(src);
|
||||
|
||||
checked++;
|
||||
const p = toPublicPathFromUrl(src);
|
||||
if (!p) continue;
|
||||
|
||||
if (!(await exists(p))) {
|
||||
missing++;
|
||||
notes.push(`- MISSING MEDIA: ${src} (from ${rel} para ${paraId})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (missing > 0) {
|
||||
console.error(`FAIL: annotations media missing (checked=${checked} missing=${missing})`);
|
||||
for (const n of notes) console.error(n);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`✅ annotations-media OK: checked=${checked}`);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error("FAIL: check-annotations-media crashed:", e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -27,11 +27,6 @@ function escRe(s) {
|
||||
return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function inferPageKeyFromFile(fileAbs) {
|
||||
const rel = path.relative(ANNO_DIR, fileAbs).replace(/\\/g, "/");
|
||||
return rel.replace(/\.(ya?ml|json)$/i, "");
|
||||
}
|
||||
|
||||
function normalizePageKey(s) {
|
||||
return String(s || "").replace(/^\/+/, "").replace(/\/+$/, "");
|
||||
}
|
||||
@@ -40,6 +35,31 @@ function isPlainObject(x) {
|
||||
return !!x && typeof x === "object" && !Array.isArray(x);
|
||||
}
|
||||
|
||||
function isParaId(s) {
|
||||
return /^p-\d+-/i.test(String(s || ""));
|
||||
}
|
||||
|
||||
/**
|
||||
* Supporte:
|
||||
* - monolith: src/annotations/<pageKey>.yml -> pageKey = rel sans ext
|
||||
* - shard : src/annotations/<pageKey>/<paraId>.yml -> pageKey = dirname(rel), paraId = basename
|
||||
*
|
||||
* shard seulement si le fichier est dans un sous-dossier (anti cas pathologique).
|
||||
*/
|
||||
function inferFromFile(fileAbs) {
|
||||
const rel = path.relative(ANNO_DIR, fileAbs).replace(/\\/g, "/");
|
||||
const relNoExt = rel.replace(/\.(ya?ml|json)$/i, "");
|
||||
const parts = relNoExt.split("/").filter(Boolean);
|
||||
const base = parts[parts.length - 1] || "";
|
||||
const dirParts = parts.slice(0, -1);
|
||||
|
||||
const isShard = dirParts.length > 0 && isParaId(base);
|
||||
const pageKey = isShard ? dirParts.join("/") : relNoExt;
|
||||
const paraId = isShard ? base : "";
|
||||
|
||||
return { pageKey: normalizePageKey(pageKey), paraId };
|
||||
}
|
||||
|
||||
async function loadAliases() {
|
||||
if (!(await exists(ALIASES_PATH))) return {};
|
||||
try {
|
||||
@@ -60,10 +80,12 @@ function getAlias(aliases, pageKey, oldId) {
|
||||
// supporte:
|
||||
// 1) { "<pageKey>": { "<old>": "<new>" } }
|
||||
// 2) { "<old>": "<new>" }
|
||||
const a1 = aliases?.[pageKey]?.[oldId];
|
||||
if (a1) return a1;
|
||||
const k1 = String(pageKey || "");
|
||||
const k2 = k1 ? ("/" + k1.replace(/^\/+|\/+$/g, "") + "/") : "";
|
||||
const a1 = (aliases?.[k1]?.[oldId]) || (k2 ? aliases?.[k2]?.[oldId] : "");
|
||||
if (a1) return String(a1);
|
||||
const a2 = aliases?.[oldId];
|
||||
if (a2) return a2;
|
||||
if (a2) return String(a2);
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -81,7 +103,11 @@ async function main() {
|
||||
const aliases = await loadAliases();
|
||||
const files = (await walk(ANNO_DIR)).filter((p) => /\.(ya?ml|json)$/i.test(p));
|
||||
|
||||
let pages = 0;
|
||||
// perf: cache HTML par page (shards = beaucoup de fichiers pour 1 page)
|
||||
const htmlCache = new Map(); // pageKey -> html
|
||||
const missingDistPage = new Set(); // pageKey
|
||||
|
||||
let pagesSeen = new Set();
|
||||
let checked = 0;
|
||||
let failures = 0;
|
||||
const notes = [];
|
||||
@@ -105,7 +131,7 @@ async function main() {
|
||||
continue;
|
||||
}
|
||||
|
||||
const pageKey = normalizePageKey(inferPageKeyFromFile(f));
|
||||
const { pageKey, paraId: shardParaId } = inferFromFile(f);
|
||||
|
||||
if (doc.page != null && normalizePageKey(doc.page) !== pageKey) {
|
||||
failures++;
|
||||
@@ -119,20 +145,44 @@ async function main() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// shard invariant (fort) : doit contenir paras[paraId]
|
||||
if (shardParaId) {
|
||||
if (!Object.prototype.hasOwnProperty.call(doc.paras, shardParaId)) {
|
||||
failures++;
|
||||
notes.push(`- SHARD MISMATCH: ${rel} (expected paras["${shardParaId}"] present)`);
|
||||
continue;
|
||||
}
|
||||
// si extras -> warning (non destructif)
|
||||
const keys = Object.keys(doc.paras);
|
||||
if (!(keys.length === 1 && keys[0] === shardParaId)) {
|
||||
notes.push(`- WARN shard has extra paras: ${rel} (expected only "${shardParaId}", got ${keys.join(", ")})`);
|
||||
}
|
||||
}
|
||||
|
||||
pagesSeen.add(pageKey);
|
||||
|
||||
const distFile = path.join(DIST_DIR, pageKey, "index.html");
|
||||
if (!(await exists(distFile))) {
|
||||
failures++;
|
||||
notes.push(`- MISSING PAGE: dist/${pageKey}/index.html (from ${rel})`);
|
||||
if (!missingDistPage.has(pageKey)) {
|
||||
missingDistPage.add(pageKey);
|
||||
failures++;
|
||||
notes.push(`- MISSING PAGE: dist/${pageKey}/index.html (from ${rel})`);
|
||||
} else {
|
||||
notes.push(`- WARN missing page already reported: dist/${pageKey}/index.html (from ${rel})`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
pages++;
|
||||
const html = await fs.readFile(distFile, "utf8");
|
||||
let html = htmlCache.get(pageKey);
|
||||
if (!html) {
|
||||
html = await fs.readFile(distFile, "utf8");
|
||||
htmlCache.set(pageKey, html);
|
||||
}
|
||||
|
||||
for (const paraId of Object.keys(doc.paras)) {
|
||||
checked++;
|
||||
|
||||
if (!/^p-\d+-/i.test(paraId)) {
|
||||
if (!isParaId(paraId)) {
|
||||
failures++;
|
||||
notes.push(`- INVALID ID: ${rel} (${paraId})`);
|
||||
continue;
|
||||
@@ -156,6 +206,7 @@ async function main() {
|
||||
}
|
||||
|
||||
const warns = notes.filter((x) => x.startsWith("- WARN"));
|
||||
const pages = pagesSeen.size;
|
||||
|
||||
if (failures > 0) {
|
||||
console.error(`FAIL: annotations invalid (pages=${pages} checked=${checked} failures=${failures})`);
|
||||
@@ -170,4 +221,4 @@ async function main() {
|
||||
main().catch((e) => {
|
||||
console.error("FAIL: annotations check crashed:", e);
|
||||
process.exit(1);
|
||||
});
|
||||
});
|
||||
@@ -114,7 +114,6 @@ async function runMammoth(docxPath, assetsOutDirWebRoot) {
|
||||
);
|
||||
|
||||
let html = result.value || "";
|
||||
|
||||
// Mammoth gives relative src="image-xx.png" ; we will prefix later
|
||||
return html;
|
||||
}
|
||||
@@ -182,6 +181,25 @@ async function exists(p) {
|
||||
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() {
|
||||
const args = parseArgs(process.argv);
|
||||
const manifestPath = path.resolve(args.manifest);
|
||||
@@ -203,11 +221,14 @@ async function main() {
|
||||
|
||||
for (const it of selected) {
|
||||
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 assetsPublicDir = path.posix.join("/imported", it.collection, it.slug);
|
||||
const assetsDiskDir = path.resolve("public", "imported", it.collection, it.slug);
|
||||
const assetsPublicDir = path.posix.join("/imported", outCollection, outSlug);
|
||||
const assetsDiskDir = path.resolve("public", "imported", outCollection, outSlug);
|
||||
|
||||
if (!(await exists(docxPath))) {
|
||||
throw new Error(`Missing source docx: ${docxPath}`);
|
||||
@@ -241,18 +262,20 @@ async function main() {
|
||||
html = rewriteLocalImageLinks(html, assetsPublicDir);
|
||||
body = html.trim() ? html : "<p>(Import vide)</p>";
|
||||
}
|
||||
|
||||
|
||||
const defaultVersion = process.env.PUBLIC_RELEASE || "0.1.0";
|
||||
|
||||
// ✅ IMPORTANT: archicrat-ia partage edition/status avec archicratie (pas de migration frontmatter)
|
||||
const schemaDefaultsByCollection = {
|
||||
archicratie: { edition: "archicratie", status: "modele_sociopolitique", level: 1 },
|
||||
ia: { edition: "ia", status: "cas_pratique", level: 1 },
|
||||
traite: { edition: "traite", status: "ontodynamique", level: 1 },
|
||||
glossaire: { edition: "glossaire", status: "lexique", level: 1 },
|
||||
atlas: { edition: "atlas", status: "atlas", level: 1 },
|
||||
archicratie: { edition: "archicratie", status: "modele_sociopolitique", level: 1 },
|
||||
"archicrat-ia": { edition: "archicrat-ia", status: "essai_these", level: 1 },
|
||||
ia: { edition: "ia", status: "cas_pratique", level: 1 },
|
||||
traite: { edition: "traite", status: "ontodynamique", 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 };
|
||||
const defaults = schemaDefaultsByCollection[outCollection] || { edition: outCollection, status: "draft", level: 1 };
|
||||
|
||||
const fm = [
|
||||
"---",
|
||||
@@ -282,4 +305,4 @@ async function main() {
|
||||
main().catch((e) => {
|
||||
console.error("\nERROR:", e?.message || e);
|
||||
process.exit(1);
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,24 @@ const STRICT = argv.includes("--strict") || process.env.CI === "1" || process.en
|
||||
function escRe(s) {
|
||||
return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
async function exists(p) {
|
||||
try {
|
||||
await fs.access(p);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRoute(route) {
|
||||
let r = String(route || "").trim();
|
||||
if (!r.startsWith("/")) r = "/" + r;
|
||||
if (!r.endsWith("/")) r = r + "/";
|
||||
r = r.replace(/\/{2,}/g, "/");
|
||||
return r;
|
||||
}
|
||||
|
||||
function countIdAttr(html, id) {
|
||||
const re = new RegExp(`\\bid=(["'])${escRe(id)}\\1`, "gi");
|
||||
let c = 0;
|
||||
@@ -22,7 +40,6 @@ function countIdAttr(html, id) {
|
||||
}
|
||||
|
||||
function findStartTagWithId(html, id) {
|
||||
// 1er élément qui porte id="..."
|
||||
const re = new RegExp(
|
||||
`<([a-zA-Z0-9:-]+)\\b[^>]*\\bid=(["'])${escRe(id)}\\2[^>]*>`,
|
||||
"i"
|
||||
@@ -36,34 +53,10 @@ function isInjectedAliasSpan(html, id) {
|
||||
const found = findStartTagWithId(html, id);
|
||||
if (!found) return false;
|
||||
if (found.tagName !== "span") return false;
|
||||
// class="... para-alias ..."
|
||||
return /\bclass=(["'])(?:(?!\1).)*\bpara-alias\b(?:(?!\1).)*\1/i.test(found.tag);
|
||||
}
|
||||
|
||||
function normalizeRoute(route) {
|
||||
let r = String(route || "").trim();
|
||||
if (!r.startsWith("/")) r = "/" + r;
|
||||
if (!r.endsWith("/")) r = r + "/";
|
||||
r = r.replace(/\/{2,}/g, "/");
|
||||
return r;
|
||||
}
|
||||
|
||||
async function exists(p) {
|
||||
try {
|
||||
await fs.access(p);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function hasId(html, id) {
|
||||
const re = new RegExp(`\\bid=(["'])${escRe(id)}\\1`, "i");
|
||||
return re.test(html);
|
||||
}
|
||||
|
||||
function injectBeforeId(html, newId, injectHtml) {
|
||||
// insère juste avant la balise qui porte id="newId"
|
||||
const re = new RegExp(
|
||||
`(<[^>]+\\bid=(["'])${escRe(newId)}\\2[^>]*>)`,
|
||||
"i"
|
||||
@@ -82,6 +75,7 @@ async function main() {
|
||||
}
|
||||
|
||||
const raw = await fs.readFile(ALIASES_PATH, "utf-8");
|
||||
|
||||
/** @type {Record<string, Record<string,string>>} */
|
||||
let aliases;
|
||||
try {
|
||||
@@ -89,6 +83,7 @@ async function main() {
|
||||
} catch (e) {
|
||||
throw new Error(`JSON invalide: ${ALIASES_PATH} (${e?.message || e})`);
|
||||
}
|
||||
|
||||
if (!aliases || typeof aliases !== "object" || Array.isArray(aliases)) {
|
||||
throw new Error(`Format invalide: attendu { route: { oldId: newId } } dans ${ALIASES_PATH}`);
|
||||
}
|
||||
@@ -114,10 +109,10 @@ async function main() {
|
||||
console.log(msg);
|
||||
warnCount++;
|
||||
}
|
||||
|
||||
|
||||
if (entries.length === 0) continue;
|
||||
|
||||
const rel = route.replace(/^\/+|\/+$/g, ""); // sans slash
|
||||
const rel = route.replace(/^\/+|\/+$/g, "");
|
||||
const htmlPath = path.join(DIST_ROOT, rel, "index.html");
|
||||
|
||||
if (!(await exists(htmlPath))) {
|
||||
@@ -135,24 +130,8 @@ async function main() {
|
||||
if (!oldId || !newId) continue;
|
||||
|
||||
const oldCount = countIdAttr(html, oldId);
|
||||
if (oldCount > 0) {
|
||||
// ✅ déjà injecté (idempotent)
|
||||
if (isInjectedAliasSpan(html, oldId)) continue;
|
||||
|
||||
// ⛔️ oldId existe déjà "en vrai" (ex: <p id="oldId">)
|
||||
// => alias inutile / inversé / obsolète
|
||||
const found = findStartTagWithId(html, oldId);
|
||||
const where = found ? `<${found.tagName} … id="${oldId}" …>` : `id="${oldId}"`;
|
||||
const msg =
|
||||
`⚠️ alias inutile/inversé: oldId déjà présent dans la page (${where}). ` +
|
||||
`Supprime l'alias ${oldId} -> ${newId} (ou corrige le sens) pour route=${route}`;
|
||||
if (STRICT) throw new Error(msg);
|
||||
console.log(msg);
|
||||
warnCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// juste après avoir calculé oldCount
|
||||
// ✅ déjà injecté => idempotent
|
||||
if (oldCount > 0 && isInjectedAliasSpan(html, oldId)) {
|
||||
if (STRICT && oldCount !== 1) {
|
||||
throw new Error(`oldId dupliqué (${oldCount}) alors qu'il est censé être unique: ${route} id=${oldId}`);
|
||||
@@ -160,18 +139,23 @@ async function main() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// avant l'injection, après hasId(newId)
|
||||
const newCount = countIdAttr(html, newId);
|
||||
if (newCount !== 1) {
|
||||
const msg = `⚠️ newId non-unique (${newCount}) : ${route} new=${newId} (injection ambiguë)`;
|
||||
// ⛔️ oldId existe déjà "en vrai" => alias inutile/inversé
|
||||
if (oldCount > 0) {
|
||||
const found = findStartTagWithId(html, oldId);
|
||||
const where = found ? `<${found.tagName} … id="${oldId}" …>` : `id="${oldId}"`;
|
||||
const msg =
|
||||
`⚠️ alias inutile/inversé: oldId déjà présent (${where}). ` +
|
||||
`Supprime ${oldId} -> ${newId} (ou corrige le sens) pour route=${route}`;
|
||||
if (STRICT) throw new Error(msg);
|
||||
console.log(msg);
|
||||
warnCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!hasId(html, newId)) {
|
||||
const msg = `⚠️ newId introuvable: ${route} old=${oldId} -> new=${newId}`;
|
||||
// newId doit exister UNE fois (sinon injection ambiguë)
|
||||
const newCount = countIdAttr(html, newId);
|
||||
if (newCount !== 1) {
|
||||
const msg = `⚠️ newId non-unique (${newCount}) : ${route} new=${newId} (injection ambiguë)`;
|
||||
if (STRICT) throw new Error(msg);
|
||||
console.log(msg);
|
||||
warnCount++;
|
||||
|
||||
31
scripts/purge-dist-dev-whoami.mjs
Normal file
@@ -0,0 +1,31 @@
|
||||
// scripts/purge-dist-dev-whoami.mjs
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const CWD = process.cwd();
|
||||
const targetDir = path.join(CWD, "dist", "_auth", "whoami");
|
||||
const targetIndex = path.join(CWD, "dist", "_auth", "whoami", "index.html");
|
||||
|
||||
// Purge idempotente (force=true => pas d'erreur si absent)
|
||||
async function rmSafe(p) {
|
||||
try {
|
||||
await fs.rm(p, { recursive: true, force: true });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const removedIndex = await rmSafe(targetIndex);
|
||||
const removedDir = await rmSafe(targetDir);
|
||||
|
||||
// Optionnel: si dist/_auth devient vide, on laisse tel quel (pas besoin de toucher)
|
||||
const any = removedIndex || removedDir;
|
||||
console.log(`✅ purge-dist-dev-whoami: ${any ? "purged" : "nothing to purge"}`);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error("❌ purge-dist-dev-whoami failed:", e);
|
||||
process.exit(1);
|
||||
});
|
||||
131
scripts/switch-archicratie.sh
Executable file
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# switch-archicratie.sh — SAFE switch LIVE + STAGING (avec backups horodatés)
|
||||
#
|
||||
# Usage (NAS recommandé) :
|
||||
# sudo bash -c 'LIVE_PORT=8081 /volume2/docker/archicratie-web/current/scripts/switch-archicratie.sh'
|
||||
# sudo bash -c 'LIVE_PORT=8082 /volume2/docker/archicratie-web/current/scripts/switch-archicratie.sh'
|
||||
#
|
||||
# Usage (test local R&D, sans NAS) :
|
||||
# D=/tmp/dynamic-test LIVE_PORT=8081 bash scripts/switch-archicratie.sh --dry-run
|
||||
# D=/tmp/dynamic-test LIVE_PORT=8081 bash scripts/switch-archicratie.sh
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
SAFE switch LIVE + STAGING (avec backups horodatés).
|
||||
|
||||
Variables / options :
|
||||
LIVE_PORT=8081|8082 (obligatoire) port LIVE cible
|
||||
D=/volume2/docker/edge/config/dynamic (optionnel) dossier des yml Traefik dynamiques
|
||||
--dry-run n'écrit rien, affiche seulement ce qui serait fait
|
||||
-h, --help aide
|
||||
|
||||
Exemples :
|
||||
sudo bash -c 'LIVE_PORT=8082 /volume2/docker/archicratie-web/current/scripts/switch-archicratie.sh'
|
||||
D=/tmp/dynamic-test LIVE_PORT=8081 bash scripts/switch-archicratie.sh --dry-run
|
||||
EOF
|
||||
}
|
||||
|
||||
DRY_RUN=0
|
||||
for arg in "${@:-}"; do
|
||||
case "$arg" in
|
||||
--dry-run) DRY_RUN=1 ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) ;;
|
||||
esac
|
||||
done
|
||||
|
||||
D="${D:-/volume2/docker/edge/config/dynamic}"
|
||||
F_LIVE="$D/20-archicratie-backend.yml"
|
||||
F_STAG="$D/21-archicratie-staging.yml"
|
||||
|
||||
LIVE_PORT="${LIVE_PORT:-}"
|
||||
if [[ "$LIVE_PORT" != "8081" && "$LIVE_PORT" != "8082" ]]; then
|
||||
echo "❌ LIVE_PORT doit valoir 8081 ou 8082."
|
||||
usage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$F_LIVE" || ! -f "$F_STAG" ]]; then
|
||||
echo "❌ Fichiers manquants :"
|
||||
echo " $F_LIVE"
|
||||
echo " $F_STAG"
|
||||
echo " (Astuce R&D locale : mets D=/tmp/dynamic-test et crée 20/21 dedans.)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
OTHER_PORT="8081"
|
||||
[[ "$LIVE_PORT" == "8081" ]] && OTHER_PORT="8082"
|
||||
|
||||
show_urls() {
|
||||
local f="$1"
|
||||
echo "— $f"
|
||||
grep -nE '^\s*-\s*url:\s*".*"' "$f" || true
|
||||
}
|
||||
|
||||
# Garde-fou : on attend au moins un "url:" dans chaque fichier
|
||||
grep -qE '^\s*-\s*url:\s*"' "$F_LIVE" || { echo "❌ Format inattendu dans $F_LIVE (pas de - url: \")"; exit 1; }
|
||||
grep -qE '^\s*-\s*url:\s*"' "$F_STAG" || { echo "❌ Format inattendu dans $F_STAG (pas de - url: \")"; exit 1; }
|
||||
|
||||
echo "Avant :"
|
||||
show_urls "$F_LIVE"
|
||||
show_urls "$F_STAG"
|
||||
echo
|
||||
|
||||
echo "Plan : LIVE -> $LIVE_PORT ; STAGING -> $OTHER_PORT"
|
||||
echo
|
||||
|
||||
if [[ "$DRY_RUN" == "1" ]]; then
|
||||
echo "DRY-RUN : aucune écriture."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
TS="$(date +%F-%H%M%S)"
|
||||
cp -a "$F_LIVE" "$F_LIVE.bak.$TS"
|
||||
cp -a "$F_STAG" "$F_STAG.bak.$TS"
|
||||
|
||||
# sed inplace portable (macOS vs Linux/DSM)
|
||||
sed_inplace() {
|
||||
local expr="$1" file="$2"
|
||||
if [[ "$(uname -s)" == "Darwin" ]]; then
|
||||
sed -i '' -e "$expr" "$file"
|
||||
else
|
||||
sed -i -e "$expr" "$file"
|
||||
fi
|
||||
}
|
||||
|
||||
# Remplacement ciblé UNIQUEMENT sur la ligne - url: "http://127.0.0.1:808X"
|
||||
sed_inplace \
|
||||
"s#^\([[:space:]]*-[[:space:]]*url:[[:space:]]*\"http://127\\.0\\.0\\.1:\\)808[12]\\(\"[[:space:]]*\)#\\1${LIVE_PORT}\\2#g" \
|
||||
"$F_LIVE"
|
||||
|
||||
sed_inplace \
|
||||
"s#^\([[:space:]]*-[[:space:]]*url:[[:space:]]*\"http://127\\.0\\.0\\.1:\\)808[12]\\(\"[[:space:]]*\)#\\1${OTHER_PORT}\\2#g" \
|
||||
"$F_STAG"
|
||||
|
||||
# Post-check : on confirme que les fichiers contiennent bien les ports attendus
|
||||
grep -qE "http://127\.0\.0\.1:${LIVE_PORT}\"" "$F_LIVE" || {
|
||||
echo "❌ Post-check FAIL : $F_LIVE ne contient pas http://127.0.0.1:${LIVE_PORT}"
|
||||
echo "➡️ rollback backups : $F_LIVE.bak.$TS / $F_STAG.bak.$TS"
|
||||
exit 1
|
||||
}
|
||||
grep -qE "http://127\.0\.0\.1:${OTHER_PORT}\"" "$F_STAG" || {
|
||||
echo "❌ Post-check FAIL : $F_STAG ne contient pas http://127.0.0.1:${OTHER_PORT}"
|
||||
echo "➡️ rollback backups : $F_LIVE.bak.$TS / $F_STAG.bak.$TS"
|
||||
exit 1
|
||||
}
|
||||
|
||||
echo "✅ OK. Backups :"
|
||||
echo " - $F_LIVE.bak.$TS"
|
||||
echo " - $F_STAG.bak.$TS"
|
||||
echo
|
||||
echo "Après :"
|
||||
show_urls "$F_LIVE"
|
||||
show_urls "$F_STAG"
|
||||
echo
|
||||
echo "Smoke tests :"
|
||||
echo " curl -sS -I http://127.0.0.1:${LIVE_PORT}/ | head -n 12"
|
||||
echo " curl -sS -I http://127.0.0.1:${OTHER_PORT}/ | head -n 12"
|
||||
echo " curl -sS -I -H 'Host: archicratie.trans-hands.synology.me' http://127.0.0.1:18080/ | head -n 20"
|
||||
echo " curl -sS -I -H 'Host: staging.archicratie.trans-hands.synology.me' http://127.0.0.1:18080/ | head -n 20"
|
||||
@@ -205,7 +205,7 @@ for (const [route, mapping] of Object.entries(data)) {
|
||||
newId,
|
||||
htmlPath,
|
||||
msg:
|
||||
`oldId present but is NOT an injected alias span (<span class="para-alias">).</n` +
|
||||
`oldId present but is NOT an injected alias span (<span class="para-alias">).\n` +
|
||||
`Saw: ${seen}`,
|
||||
});
|
||||
continue;
|
||||
|
||||
26
scripts/write-dev-whoami.mjs
Normal file
@@ -0,0 +1,26 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const OUT = path.join(process.cwd(), "public", "_auth", "whoami");
|
||||
|
||||
const groupsRaw = process.env.PUBLIC_WHOAMI_GROUPS ?? "editors";
|
||||
const user = process.env.PUBLIC_WHOAMI_USER ?? "dev";
|
||||
const name = process.env.PUBLIC_WHOAMI_NAME ?? "Dev Local";
|
||||
const email = process.env.PUBLIC_WHOAMI_EMAIL ?? "area.technik@proton.me";
|
||||
|
||||
const groups = groupsRaw
|
||||
.split(/[;,]/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.join(",");
|
||||
|
||||
const body =
|
||||
`Remote-User: ${user}\n` +
|
||||
`Remote-Name: ${name}\n` +
|
||||
`Remote-Email: ${email}\n` +
|
||||
`Remote-Groups: ${groups}\n`;
|
||||
|
||||
await fs.mkdir(path.dirname(OUT), { recursive: true });
|
||||
await fs.writeFile(OUT, body, "utf8");
|
||||
|
||||
console.log(`✅ dev whoami written: ${path.relative(process.cwd(), OUT)} (${groups})`);
|
||||
@@ -1,2 +1,5 @@
|
||||
{}
|
||||
|
||||
{
|
||||
"/archicrat-ia/chapitre-3/": {
|
||||
"p-1-60c7ea48": "p-1-a21087b0"
|
||||
}
|
||||
}
|
||||
|
||||
10
src/annotations/archicrat-ia/chapitre-1/p-0-8d27a7f5.yml
Normal file
@@ -0,0 +1,10 @@
|
||||
schema: 1
|
||||
page: archicrat-ia/chapitre-1
|
||||
paras:
|
||||
p-0-8d27a7f5:
|
||||
refs:
|
||||
- url: https://auth.archicratie.trans-hands.synology.me/authenticated
|
||||
label: Lien web
|
||||
kind: (livre / article / vidéo / site / autre) Site
|
||||
ts: 2026-02-27T12:34:31.704Z
|
||||
fromIssue: 142
|
||||
9
src/annotations/archicrat-ia/chapitre-1/p-1-8a6c18bf.yml
Normal file
@@ -0,0 +1,9 @@
|
||||
schema: 1
|
||||
page: archicrat-ia/chapitre-1
|
||||
paras:
|
||||
p-1-8a6c18bf:
|
||||
comments_editorial:
|
||||
- text: Yeaha
|
||||
status: new
|
||||
ts: 2026-02-27T12:40:39.462Z
|
||||
fromIssue: 143
|
||||
18
src/annotations/archicrat-ia/chapitre-3/p-0-ace27175.yml
Normal file
@@ -0,0 +1,18 @@
|
||||
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
|
||||
refs:
|
||||
- url: https://gitea.archicratie.trans-hands.synology.me
|
||||
label: Gitea
|
||||
kind: (livre / article / vidéo / site / autre) Site
|
||||
ts: 2026-03-02T19:53:21.252Z
|
||||
fromIssue: 169
|
||||
10
src/annotations/archicrat-ia/chapitre-3/p-1-60c7ea48.yml
Normal file
@@ -0,0 +1,10 @@
|
||||
schema: 1
|
||||
page: archicrat-ia/chapitre-3
|
||||
paras:
|
||||
p-1-60c7ea48:
|
||||
refs:
|
||||
- url: https://gitea.archicratie.trans-hands.synology.me
|
||||
label: Gitea
|
||||
kind: (livre / article / vidéo / site / autre) Site
|
||||
ts: 2026-03-02T20:01:55.858Z
|
||||
fromIssue: 172
|
||||
30
src/annotations/archicrat-ia/chapitre-4.yml
Normal file
@@ -0,0 +1,30 @@
|
||||
schema: 1
|
||||
page: archicrat-ia/chapitre-4
|
||||
paras:
|
||||
p-2-31b12529:
|
||||
media:
|
||||
- type: image
|
||||
src: /media/archicrat-ia/chapitre-4/p-2-31b12529/Capture_d_e_cran_2026-02-16_a_13.05.58.png
|
||||
caption: "[Media] p-2-31b12529 — Chapitre 4 — Histoire archicratique des
|
||||
révolutions industrielles"
|
||||
credit: ""
|
||||
ts: 2026-02-25T18:58:32.359Z
|
||||
fromIssue: 115
|
||||
p-7-1da4a458:
|
||||
media:
|
||||
- type: image
|
||||
src: /media/archicrat-ia/chapitre-4/p-7-1da4a458/Capture_d_e_cran_2026-02-16_a_13.05.58.png
|
||||
caption: "[Media] p-7-1da4a458 — Chapitre 4 — Histoire archicratique des
|
||||
révolutions industrielles"
|
||||
credit: ""
|
||||
ts: 2026-02-25T19:11:32.634Z
|
||||
fromIssue: 121
|
||||
p-11-67c14c09:
|
||||
media:
|
||||
- type: image
|
||||
src: /media/archicrat-ia/chapitre-4/p-11-67c14c09/Capture_d_e_cran_2026-02-16_a_13.07.35.png
|
||||
caption: "[Media] p-11-67c14c09 — Chapitre 4 — Histoire archicratique des
|
||||
révolutions industrielles"
|
||||
credit: ""
|
||||
ts: 2026-02-26T13:17:41.286Z
|
||||
fromIssue: 129
|
||||
19
src/annotations/archicrat-ia/chapitre-4/p-11-67c14c09.yml
Normal file
@@ -0,0 +1,19 @@
|
||||
schema: 1
|
||||
page: archicrat-ia/chapitre-4
|
||||
paras:
|
||||
p-11-67c14c09:
|
||||
media:
|
||||
- type: image
|
||||
src: /media/archicrat-ia/chapitre-4/p-11-67c14c09/Capture_d_e_cran_2026-02-16_a_13.07.35.png
|
||||
caption: "[Media] p-11-67c14c09 — Chapitre 4 — Histoire archicratique des
|
||||
révolutions industrielles"
|
||||
credit: ""
|
||||
ts: 2026-02-26T13:17:41.286Z
|
||||
fromIssue: 129
|
||||
- type: image
|
||||
src: /media/archicrat-ia/chapitre-4/p-11-67c14c09/Capture_d_e_cran_2025-05-05_a_19.20.40.png
|
||||
caption: "[Media] p-11-67c14c09 — Chapitre 4 — Histoire archicratique des
|
||||
révolutions industrielles"
|
||||
credit: ""
|
||||
ts: 2026-02-27T09:17:04.386Z
|
||||
fromIssue: 127
|
||||
@@ -1,8 +1,5 @@
|
||||
schema: 1
|
||||
|
||||
# optionnel (si présent, doit matcher le chemin du fichier)
|
||||
page: archicrat-ia/prologue
|
||||
|
||||
paras:
|
||||
p-0-d7974f88:
|
||||
refs:
|
||||
@@ -25,11 +22,11 @@ paras:
|
||||
|
||||
media:
|
||||
- type: "image"
|
||||
src: "/public/media/archicrat-ia/prologue/p-0-d7974f88/schema-1.svg"
|
||||
src: "/public/media/archicratie/archicrat-ia/prologue/p-0-d7974f88/schema-1.svg"
|
||||
caption: "Tableau explicatif"
|
||||
credit: "ChatGPT"
|
||||
- type: "image"
|
||||
src: "/public/media/archicrat-ia/prologue/p-0-d7974f88/schema-2.svg"
|
||||
src: "/public/media/archicratie/archicrat-ia/prologue/p-0-d7974f88/schema-2.svg"
|
||||
caption: "Diagramme d’évolution"
|
||||
credit: "Yanis Varoufakis"
|
||||
|
||||
@@ -50,10 +47,4 @@ paras:
|
||||
- text: "Si l’on voulait chercher quelque chose comme une vision du monde chez Kafka..."
|
||||
source: "Bernard Lahire, Franz Kafka, p.475+"
|
||||
|
||||
media:
|
||||
- type: "video"
|
||||
src: "/public/media/archicrat-ia/prologue/p-1-2ef25f29/bien_commun.mp4"
|
||||
caption: "Entretien avec Bernard Lahire"
|
||||
credit: "Cairn.info"
|
||||
|
||||
comments_editorial: []
|
||||
|
||||
@@ -3,14 +3,11 @@ import { getCollection } from "astro:content";
|
||||
|
||||
const { currentSlug } = Astro.props;
|
||||
|
||||
const entries = (await getCollection("archicratie"))
|
||||
.filter((e) => e.slug.startsWith("archicrat-ia/"))
|
||||
// ✅ Après migration : TOC = collection "archicrat-ia"
|
||||
const entries = (await getCollection("archicrat-ia"))
|
||||
.sort((a, b) => (a.data.order ?? 0) - (b.data.order ?? 0));
|
||||
|
||||
// ✅ On route l’Essai-thèse sur /archicrat-ia/<slug-sans-prefix>/
|
||||
// (Astro trailingSlash = always → on garde le "/" final)
|
||||
const strip = (s) => String(s || "").replace(/^archicrat-ia\//, "");
|
||||
const href = (slug) => `/archicrat-ia/${strip(slug)}/`;
|
||||
const href = (slug) => `/archicrat-ia/${slug}/`;
|
||||
---
|
||||
|
||||
<nav class="toc-global" aria-label="Table des matières — ArchiCraT-IA">
|
||||
@@ -163,4 +160,4 @@ const href = (slug) => `/archicrat-ia/${strip(slug)}/`;
|
||||
const active = document.querySelector(".toc-global .toc-item.is-active");
|
||||
if (active) active.scrollIntoView({ block: "nearest" });
|
||||
})();
|
||||
</script>
|
||||
</script>
|
||||
@@ -144,15 +144,14 @@
|
||||
const canReaders = inGroup(groups, "readers");
|
||||
const canEditors = inGroup(groups, "editors");
|
||||
|
||||
access.canUsers = Boolean((info?.ok && (canReaders || canEditors)) || (isDev() && !info?.ok));
|
||||
const whoamiSkipped = Boolean(window.__archiFlags && window.__archiFlags.whoamiSkipped);
|
||||
access.canUsers = Boolean((info?.ok && (canReaders || canEditors)) || whoamiSkipped);
|
||||
access.ready = true;
|
||||
|
||||
if (btnMediaSubmit) btnMediaSubmit.disabled = !access.canUsers;
|
||||
if (btnSend) btnSend.disabled = !access.canUsers;
|
||||
|
||||
if (btnRefSubmit) btnRefSubmit.disabled = !access.canUsers;
|
||||
|
||||
|
||||
// si pas d'accès, on informe (soft)
|
||||
if (!access.canUsers) {
|
||||
if (msgHead) {
|
||||
@@ -162,12 +161,13 @@
|
||||
}
|
||||
}
|
||||
}).catch(() => {
|
||||
// fallback dev
|
||||
// fallback dev (cohérent: media + ref + comment)
|
||||
access.ready = true;
|
||||
if (isDev()) {
|
||||
if (Boolean(window.__archiFlags && window.__archiFlags.whoamiSkipped)) {
|
||||
access.canUsers = true;
|
||||
if (btnMediaSubmit) btnMediaSubmit.disabled = false;
|
||||
if (btnSend) btnSend.disabled = false;
|
||||
if (btnRefSubmit) btnRefSubmit.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -209,8 +209,12 @@
|
||||
async function loadIndex() {
|
||||
if (_idxP) return _idxP;
|
||||
_idxP = (async () => {
|
||||
const res = await fetch("/annotations-index.json?_=" + Date.now(), { cache: "no-store" }).catch(() => null);
|
||||
if (res && res.ok) return await res.json();
|
||||
try {
|
||||
const res = await fetch("/annotations-index.json?_=" + Date.now(), { cache: "no-store" });
|
||||
if (res && res.ok) return await res.json();
|
||||
} catch {}
|
||||
// ✅ antifragile: ne pas “cacher” un échec pour toujours (dev/HMR/boot race)
|
||||
_idxP = null;
|
||||
return null;
|
||||
})();
|
||||
return _idxP;
|
||||
@@ -564,6 +568,14 @@
|
||||
hideMsg(msgComment);
|
||||
|
||||
const idx = await loadIndex();
|
||||
|
||||
// ✅ message soft si l’index est indisponible (sans écraser le message d’auth)
|
||||
if (!idx && msgHead && msgHead.hidden) {
|
||||
msgHead.hidden = false;
|
||||
msgHead.textContent = "Index annotations indisponible (annotations-index.json).";
|
||||
msgHead.dataset.kind = "info";
|
||||
}
|
||||
|
||||
const data = idx?.pages?.[pageKey]?.paras?.[currentParaId] || null;
|
||||
|
||||
renderLevel2(data);
|
||||
|
||||
@@ -11,10 +11,12 @@ summary: ""
|
||||
source:
|
||||
kind: docx
|
||||
path: "sources/docx/archicrat-ia/Chapitre_3—Philosophies_du_pouvoir_et_Archicration-pour_une_topologie_differenciee_des_regimes_regulateurs-version_officielle.docx"
|
||||
|
||||
<!-- testA: full-auto gate proof -->
|
||||
---
|
||||
Ce chapitre se tient à un point nodal de notre essai-thèse : il ouvre un espace d’exploration systématique des formes conceptuelles et philosophiques à travers lesquelles le pouvoir se configure comme régime de régulation. Il ne s’agit pas ici de revenir une nouvelle fois sur les fondements de l’autorité, ni d’interroger la légitimité politique au sens classique du terme, ni même d’enquêter sur la genèse des institutions. L’ambition est autre, structurelle, transversale, morphologique, elle tentera d’arpenter, à même les dispositifs, les pensées, les théorisations et les expériences, les modalités différentiées par lesquelles s’instaurent, s’éprouvent et se disputent les formes de régulation du vivre-ensemble.
|
||||
|
||||
Dès lors, ce chapitre ne postule aucun fondement, ne cherche aucun point d’origine, ne prétend restituer aucune ontologie stable du politique. Ce qu’il donne à lire, c’est une cartographie dynamique des régimes de régulation, traversée par des formes irréductibles, non homogènes, souvent conflictuelles, parfois incompatibles, mais toutes pensées comme des configurations singulières.
|
||||
Dès lors, ce chapitre ne postule aucun fondement, ne cherche aucun point d’origine, ne prétend restituer aucune ontologie stable du politique. Ce qu’il donne à lire, c’est une cartographie dynamique des régimes de régulation, traversée par des formes irréductibles, non homogènes, souvent conflictuelles, parfois incompatibles, mais toutes pensées comme des configurations singulières, et souvent complémentaires.
|
||||
|
||||
Ainsi, loin d’être une galerie illustrative de théories politiques juxtaposées, le chapitre s’agence comme une topologie critique, une plongée stratigraphique dans les scènes où s’articule la régulation — entendue ici non comme stabilisation externe ou ajustement technico-fonctionnel, mais comme dispositif instituant, tension structurante, scène traversée de conflictualité et d’exigence normative. Car à nos yeux, la régulation n’est pas ce qui vient après le pouvoir, elle en est la forme même constitutive — son architecture, son rythme, son épaisseur. Elle est ce par quoi le pouvoir ne se contente pas d’être exercé, mais s’institue, se justifie, se dispute, se recompose.
|
||||
|
||||
@@ -2,7 +2,7 @@ import { defineCollection, z } from "astro:content";
|
||||
|
||||
const linkSchema = z.object({
|
||||
type: z.enum(["definition", "appui", "transposition"]),
|
||||
target: z.string().min(1), // URL interne (ex: /glossaire/archicratie/) ou slug
|
||||
target: z.string().min(1),
|
||||
note: z.string().optional()
|
||||
});
|
||||
|
||||
@@ -12,7 +12,6 @@ const baseTextSchema = z.object({
|
||||
version: z.string().min(1),
|
||||
concepts: z.array(z.string().min(1)).default([]),
|
||||
links: z.array(linkSchema).default([]),
|
||||
// optionnels mais utiles dès maintenant
|
||||
order: z.number().int().nonnegative().optional(),
|
||||
summary: z.string().optional()
|
||||
});
|
||||
@@ -50,20 +49,31 @@ const atlas = defineCollection({
|
||||
})
|
||||
});
|
||||
|
||||
// ✅ 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), // Titre public (souvent identique au terme)
|
||||
term: z.string().min(1), // Terme canonique
|
||||
title: z.string().min(1),
|
||||
term: z.string().min(1),
|
||||
aliases: z.array(z.string().min(1)).default([]),
|
||||
edition: z.literal("glossaire"),
|
||||
status: z.literal("referentiel"),
|
||||
version: z.string().min(1),
|
||||
// Micro-définition affichable en popover (courte, stable)
|
||||
definitionShort: z.string().min(1),
|
||||
concepts: z.array(z.string().min(1)).default([]),
|
||||
// Liens typés (vers ouvrages ou autres termes)
|
||||
links: z.array(linkSchema).default([])
|
||||
})
|
||||
});
|
||||
@@ -73,5 +83,8 @@ export const collections = {
|
||||
archicratie,
|
||||
ia,
|
||||
glossaire,
|
||||
atlas
|
||||
};
|
||||
atlas,
|
||||
|
||||
// ⚠️ clé avec tiret => doit être quotée
|
||||
"archicrat-ia": archicratIa
|
||||
};
|
||||
@@ -30,6 +30,13 @@ const GITEA_REPO = import.meta.env.PUBLIC_GITEA_REPO ?? "";
|
||||
|
||||
// ✅ OPTIONNEL : bridge serveur (proxy same-origin)
|
||||
const ISSUE_BRIDGE_PATH = import.meta.env.PUBLIC_ISSUE_BRIDGE_PATH ?? "";
|
||||
|
||||
// ✅ Auth whoami (same-origin) — configurable, antifragile en dev
|
||||
const WHOAMI_PATH = import.meta.env.PUBLIC_WHOAMI_PATH ?? "/_auth/whoami";
|
||||
// Par défaut: en DEV local on SKIP pour éviter le spam 404.
|
||||
// Pour tester l’auth en dev: export PUBLIC_WHOAMI_IN_DEV=1
|
||||
const WHOAMI_IN_DEV = (import.meta.env.PUBLIC_WHOAMI_IN_DEV ?? "") === "1";
|
||||
const WHOAMI_FORCE_LOCALHOST = (import.meta.env.PUBLIC_WHOAMI_FORCE_LOCALHOST ?? "") === "1";
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
@@ -52,54 +59,104 @@ const ISSUE_BRIDGE_PATH = import.meta.env.PUBLIC_ISSUE_BRIDGE_PATH ?? "";
|
||||
<meta data-pagefind-meta={`version:${String(version ?? "")}`} />
|
||||
|
||||
{/* ✅ BOOT EARLY : SidePanel dépend de ces globals. */}
|
||||
<script is:inline define:vars={{ IS_DEV, GITEA_BASE, GITEA_OWNER, GITEA_REPO, ISSUE_BRIDGE_PATH }}>
|
||||
<script
|
||||
is:inline
|
||||
define:vars={{
|
||||
IS_DEV,
|
||||
GITEA_BASE,
|
||||
GITEA_OWNER,
|
||||
GITEA_REPO,
|
||||
ISSUE_BRIDGE_PATH,
|
||||
WHOAMI_PATH,
|
||||
WHOAMI_IN_DEV,
|
||||
WHOAMI_FORCE_LOCALHOST,
|
||||
}}
|
||||
>
|
||||
(() => {
|
||||
const __DEV__ = Boolean(IS_DEV);
|
||||
window.__archiFlags = Object.assign({}, window.__archiFlags, { dev: __DEV__ });
|
||||
// ✅ anti double-init (HMR / inclusion accidentelle)
|
||||
if (window.__archiBootOnce === 1) return;
|
||||
window.__archiBootOnce = 1;
|
||||
|
||||
const base = String(GITEA_BASE || "").replace(/\/+$/, "");
|
||||
const owner = String(GITEA_OWNER || "");
|
||||
const repo = String(GITEA_REPO || "");
|
||||
const giteaReady = Boolean(base && owner && repo);
|
||||
window.__archiGitea = { ready: giteaReady, base, owner, repo };
|
||||
var __DEV__ = Boolean(IS_DEV);
|
||||
|
||||
const rawBridge = String(ISSUE_BRIDGE_PATH || "").trim();
|
||||
const normBridge = rawBridge
|
||||
// ===== Gitea globals =====
|
||||
var base = String(GITEA_BASE || "").replace(/\/+$/, "");
|
||||
var owner = String(GITEA_OWNER || "");
|
||||
var repo = String(GITEA_REPO || "");
|
||||
window.__archiGitea = {
|
||||
ready: Boolean(base && owner && repo),
|
||||
base, owner, repo
|
||||
};
|
||||
|
||||
// ===== optional issue bridge (same-origin proxy) =====
|
||||
var rawBridge = String(ISSUE_BRIDGE_PATH || "").trim();
|
||||
var normBridge = rawBridge
|
||||
? (rawBridge.startsWith("/") ? rawBridge : ("/" + rawBridge.replace(/^\/+/, ""))).replace(/\/+$/, "")
|
||||
: "";
|
||||
window.__archiIssueBridge = { ready: Boolean(normBridge), path: normBridge };
|
||||
|
||||
const WHOAMI_PATH = "/_auth/whoami";
|
||||
const REQUIRED_GROUP = "editors";
|
||||
const READ_GROUP = "readers";
|
||||
// ===== whoami config =====
|
||||
var __WHOAMI_PATH__ = String(WHOAMI_PATH || "/_auth/whoami");
|
||||
var __WHOAMI_IN_DEV__ = Boolean(WHOAMI_IN_DEV);
|
||||
|
||||
// En dev: par défaut on SKIP (=> pas de spam 404). Override via PUBLIC_WHOAMI_IN_DEV=1.
|
||||
var SHOULD_FETCH_WHOAMI = (!__DEV__) || __WHOAMI_IN_DEV__;
|
||||
|
||||
window.__archiFlags = Object.assign({}, window.__archiFlags, {
|
||||
dev: __DEV__,
|
||||
whoamiPath: __WHOAMI_PATH__,
|
||||
whoamiInDev: __WHOAMI_IN_DEV__,
|
||||
whoamiFetch: SHOULD_FETCH_WHOAMI,
|
||||
});
|
||||
|
||||
var REQUIRED_GROUP = "editors";
|
||||
var READ_GROUP = "readers";
|
||||
|
||||
function parseWhoamiLine(text, key) {
|
||||
const re = new RegExp(`^${key}:\\s*(.*)$`, "mi");
|
||||
const m = String(text || "").match(re);
|
||||
return (m?.[1] ?? "").trim();
|
||||
var re = new RegExp("^" + key + ":\\s*(.*)$", "mi");
|
||||
var m = String(text || "").match(re);
|
||||
return (m && m[1] ? m[1] : "").trim();
|
||||
}
|
||||
|
||||
function inGroup(groups, g) {
|
||||
const gg = String(g || "").toLowerCase();
|
||||
var gg = String(g || "").toLowerCase();
|
||||
return Array.isArray(groups) && groups.some((x) => String(x).toLowerCase() === gg);
|
||||
}
|
||||
|
||||
// ===== Auth info promise (single source of truth) =====
|
||||
if (!window.__archiAuthInfoP) {
|
||||
window.__archiAuthInfoP = (async () => {
|
||||
const res = await fetch(`${WHOAMI_PATH}?_=${Date.now()}`, {
|
||||
credentials: "include",
|
||||
cache: "no-store",
|
||||
redirect: "manual",
|
||||
headers: { Accept: "text/plain" },
|
||||
}).catch(() => null);
|
||||
// ✅ dev default: skip
|
||||
if (!SHOULD_FETCH_WHOAMI) {
|
||||
return { ok: false, user: "", name: "", email: "", groups: [], raw: "" };
|
||||
}
|
||||
|
||||
var res = null;
|
||||
try {
|
||||
res = await fetch(__WHOAMI_PATH__ + "?_=" + Date.now(), {
|
||||
credentials: "include",
|
||||
cache: "no-store",
|
||||
redirect: "manual",
|
||||
headers: { Accept: "text/plain" },
|
||||
});
|
||||
} catch {
|
||||
res = null;
|
||||
}
|
||||
|
||||
if (!res) return { ok: false, user: "", name: "", email: "", groups: [], raw: "" };
|
||||
if (res.type === "opaqueredirect") return { ok: false, user: "", name: "", email: "", groups: [], raw: "" };
|
||||
if (res.status >= 300 && res.status < 400) return { ok: false, user: "", name: "", email: "", groups: [], raw: "" };
|
||||
if (res.status === 404) return { ok: false, user: "", name: "", email: "", groups: [], raw: "" };
|
||||
|
||||
const text = await res.text().catch(() => "");
|
||||
const looksLikeWhoami = /Remote-(User|Groups|Email|Name)\s*:/i.test(text);
|
||||
if (!res.ok || !looksLikeWhoami) return { ok: false, user: "", name: "", email: "", groups: [], raw: text };
|
||||
var text = "";
|
||||
try { text = await res.text(); } catch { text = ""; }
|
||||
|
||||
const groups = parseWhoamiLine(text, "Remote-Groups")
|
||||
var looksLikeWhoami = /Remote-(User|Groups|Email|Name)\s*:/i.test(text);
|
||||
if (!res.ok || !looksLikeWhoami) {
|
||||
return { ok: false, user: "", name: "", email: "", groups: [], raw: text };
|
||||
}
|
||||
|
||||
var groups = parseWhoamiLine(text, "Remote-Groups")
|
||||
.split(/[;,]/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
@@ -116,18 +173,22 @@ const ISSUE_BRIDGE_PATH = import.meta.env.PUBLIC_ISSUE_BRIDGE_PATH ?? "";
|
||||
})().catch(() => ({ ok: false, user: "", name: "", email: "", groups: [], raw: "" }));
|
||||
}
|
||||
|
||||
// readers + editors (strict)
|
||||
if (!window.__archiCanReadP) {
|
||||
window.__archiCanReadP = window.__archiAuthInfoP.then((info) =>
|
||||
Boolean(info.ok && (inGroup(info.groups, READ_GROUP) || inGroup(info.groups, REQUIRED_GROUP)))
|
||||
Boolean(info && info.ok && (inGroup(info.groups, READ_GROUP) || inGroup(info.groups, REQUIRED_GROUP)))
|
||||
);
|
||||
}
|
||||
|
||||
// editors gate for "Proposer"
|
||||
if (!window.__archiIsEditorP) {
|
||||
window.__archiIsEditorP = window.__archiAuthInfoP
|
||||
.then((info) => Boolean(inGroup(info.groups, REQUIRED_GROUP) || (__DEV__ && !info.ok)))
|
||||
.catch(() => false);
|
||||
// ✅ DEV fallback: si whoami absent/KO => Proposer autorisé (comme ton intention initiale)
|
||||
.then((info) => Boolean(inGroup(info.groups, REQUIRED_GROUP) || (__DEV__ && !(info && info.ok))))
|
||||
.catch(() => Boolean(__DEV__));
|
||||
}
|
||||
})();
|
||||
|
||||
</script>
|
||||
</head>
|
||||
|
||||
@@ -950,11 +1011,13 @@ const ISSUE_BRIDGE_PATH = import.meta.env.PUBLIC_ISSUE_BRIDGE_PATH ?? "";
|
||||
|
||||
safe("propose-gate", () => {
|
||||
if (!giteaReady) return;
|
||||
|
||||
const p = window.__archiIsEditorP || Promise.resolve(false);
|
||||
|
||||
p.then((ok) => {
|
||||
document.querySelectorAll(".para-propose").forEach((el) => {
|
||||
if (ok) showEl(el);
|
||||
else el.remove();
|
||||
else hideEl(el); // ✅ jamais remove => antifragile
|
||||
});
|
||||
}).catch((err) => {
|
||||
console.warn("[proposer] gate failed; keeping Proposer hidden", err);
|
||||
|
||||
199
src/pages/annotations-index.json.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
// src/pages/annotations-index.json.ts
|
||||
import type { APIRoute } from "astro";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import YAML from "yaml";
|
||||
|
||||
const CWD = process.cwd();
|
||||
const ANNO_ROOT = path.join(CWD, "src", "annotations");
|
||||
|
||||
const isObj = (x: any) => !!x && typeof x === "object" && !Array.isArray(x);
|
||||
const isArr = (x: any) => Array.isArray(x);
|
||||
|
||||
function normPath(s: string) {
|
||||
return String(s || "").replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
|
||||
}
|
||||
function paraNum(pid: string) {
|
||||
const m = String(pid).match(/^p-(\d+)-/i);
|
||||
return m ? Number(m[1]) : Number.POSITIVE_INFINITY;
|
||||
}
|
||||
function toIso(v: any) {
|
||||
if (v instanceof Date) return v.toISOString();
|
||||
return typeof v === "string" ? v : "";
|
||||
}
|
||||
function stableSortByTs(arr: any[]) {
|
||||
if (!Array.isArray(arr)) return;
|
||||
arr.sort((a, b) => {
|
||||
const ta = Date.parse(toIso(a?.ts)) || 0;
|
||||
const tb = Date.parse(toIso(b?.ts)) || 0;
|
||||
if (ta !== tb) return ta - tb;
|
||||
return JSON.stringify(a).localeCompare(JSON.stringify(b));
|
||||
});
|
||||
}
|
||||
|
||||
function keyMedia(x: any) { return String(x?.src || ""); }
|
||||
function keyRef(x: any) {
|
||||
return `${x?.url || ""}||${x?.label || ""}||${x?.kind || ""}||${x?.citation || ""}`;
|
||||
}
|
||||
function keyComment(x: any) { return String(x?.text || "").trim(); }
|
||||
|
||||
function uniqUnion(dst: any[], src: any[], keyFn: (x:any)=>string) {
|
||||
const out = isArr(dst) ? [...dst] : [];
|
||||
const seen = new Set(out.map((x) => keyFn(x)));
|
||||
for (const it of (isArr(src) ? src : [])) {
|
||||
const k = keyFn(it);
|
||||
if (!k) continue;
|
||||
if (!seen.has(k)) { seen.add(k); out.push(it); }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function deepMergeEntry(dst: any, src: any) {
|
||||
if (!isObj(dst) || !isObj(src)) return;
|
||||
|
||||
for (const [k, v] of Object.entries(src)) {
|
||||
if (k === "media" && isArr(v)) { dst.media = uniqUnion(dst.media, v, keyMedia); continue; }
|
||||
if (k === "refs" && isArr(v)) { dst.refs = uniqUnion(dst.refs, v, keyRef); continue; }
|
||||
if (k === "comments_editorial" && isArr(v)) { dst.comments_editorial = uniqUnion(dst.comments_editorial, v, keyComment); continue; }
|
||||
|
||||
if (isObj(v)) {
|
||||
if (!isObj((dst as any)[k])) (dst as any)[k] = {};
|
||||
deepMergeEntry((dst as any)[k], v);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isArr(v)) {
|
||||
const cur = isArr((dst as any)[k]) ? (dst as any)[k] : [];
|
||||
const seen = new Set(cur.map((x:any) => JSON.stringify(x)));
|
||||
const out = [...cur];
|
||||
for (const it of v) {
|
||||
const s = JSON.stringify(it);
|
||||
if (!seen.has(s)) { seen.add(s); out.push(it); }
|
||||
}
|
||||
(dst as any)[k] = out;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!(k in (dst as any)) || (dst as any)[k] == null || (dst as any)[k] === "") (dst as any)[k] = v;
|
||||
}
|
||||
}
|
||||
|
||||
async function walk(dir: string): Promise<string[]> {
|
||||
const out: string[] = [];
|
||||
const ents = await fs.readdir(dir, { withFileTypes: true });
|
||||
for (const e of ents) {
|
||||
const p = path.join(dir, e.name);
|
||||
if (e.isDirectory()) out.push(...await walk(p));
|
||||
else if (e.isFile() && /\.ya?ml$/i.test(e.name)) out.push(p);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function inferExpected(relNoExt: string) {
|
||||
const parts = relNoExt.split("/").filter(Boolean);
|
||||
const last = parts.at(-1) || "";
|
||||
const isShard = parts.length > 1 && /^p-\d+-/i.test(last); // ✅ durcissement
|
||||
const pageKey = isShard ? parts.slice(0, -1).join("/") : relNoExt;
|
||||
const paraId = isShard ? last : null;
|
||||
return { isShard, pageKey, paraId };
|
||||
}
|
||||
|
||||
export const GET: APIRoute = async () => {
|
||||
const pages: Record<string, { paras: Record<string, any> }> = {};
|
||||
const errors: Array<{ file: string; error: string }> = [];
|
||||
|
||||
let files: string[] = [];
|
||||
try {
|
||||
files = await walk(ANNO_ROOT);
|
||||
} catch (e: any) {
|
||||
throw new Error(`Missing annotations root: ${ANNO_ROOT} (${e?.message || e})`);
|
||||
}
|
||||
|
||||
for (const fp of files) {
|
||||
const rel = normPath(path.relative(ANNO_ROOT, fp));
|
||||
const relNoExt = rel.replace(/\.ya?ml$/i, "");
|
||||
const { isShard, pageKey, paraId } = inferExpected(relNoExt);
|
||||
|
||||
try {
|
||||
const raw = await fs.readFile(fp, "utf8");
|
||||
const doc = YAML.parse(raw) || {};
|
||||
|
||||
if (!isObj(doc) || doc.schema !== 1) continue;
|
||||
|
||||
const docPage = normPath(doc.page || "");
|
||||
if (docPage && docPage !== pageKey) {
|
||||
throw new Error(`page mismatch (page="${doc.page}" vs path="${pageKey}")`);
|
||||
}
|
||||
if (!doc.page) doc.page = pageKey;
|
||||
|
||||
if (!isObj(doc.paras)) throw new Error(`missing object key "paras"`);
|
||||
|
||||
const pg = pages[pageKey] ??= { paras: {} };
|
||||
|
||||
if (isShard) {
|
||||
if (!paraId) throw new Error("internal: missing paraId");
|
||||
if (!(paraId in doc.paras)) {
|
||||
throw new Error(`shard mismatch: file must contain paras["${paraId}"]`);
|
||||
}
|
||||
// ✅ invariant aligné avec build-annotations-index
|
||||
const keys = Object.keys(doc.paras).map(String);
|
||||
if (!(keys.length === 1 && keys[0] === paraId)) {
|
||||
throw new Error(`shard invariant violated: shard must contain ONLY paras["${paraId}"] (got: ${keys.join(", ")})`);
|
||||
}
|
||||
|
||||
const entry = doc.paras[paraId];
|
||||
if (!isObj(pg.paras[paraId])) pg.paras[paraId] = {};
|
||||
if (isObj(entry)) deepMergeEntry(pg.paras[paraId], entry);
|
||||
|
||||
stableSortByTs(pg.paras[paraId].media);
|
||||
stableSortByTs(pg.paras[paraId].refs);
|
||||
stableSortByTs(pg.paras[paraId].comments_editorial);
|
||||
} else {
|
||||
for (const [pid, entry] of Object.entries(doc.paras)) {
|
||||
const p = String(pid);
|
||||
if (!isObj(pg.paras[p])) pg.paras[p] = {};
|
||||
if (isObj(entry)) deepMergeEntry(pg.paras[p], entry);
|
||||
|
||||
stableSortByTs(pg.paras[p].media);
|
||||
stableSortByTs(pg.paras[p].refs);
|
||||
stableSortByTs(pg.paras[p].comments_editorial);
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
errors.push({ file: `src/annotations/${rel}`, error: String(e?.message || e) });
|
||||
}
|
||||
}
|
||||
|
||||
for (const [pk, pg] of Object.entries(pages)) {
|
||||
const keys = Object.keys(pg.paras || {});
|
||||
keys.sort((a, b) => {
|
||||
const ia = paraNum(a);
|
||||
const ib = paraNum(b);
|
||||
if (Number.isFinite(ia) && Number.isFinite(ib) && ia !== ib) return ia - ib;
|
||||
return String(a).localeCompare(String(b));
|
||||
});
|
||||
const next: Record<string, any> = {};
|
||||
for (const k of keys) next[k] = pg.paras[k];
|
||||
pg.paras = next;
|
||||
}
|
||||
|
||||
const out = {
|
||||
schema: 1,
|
||||
generatedAt: new Date().toISOString(),
|
||||
pages,
|
||||
stats: {
|
||||
pages: Object.keys(pages).length,
|
||||
paras: Object.values(pages).reduce((n, p) => n + Object.keys(p.paras || {}).length, 0),
|
||||
errors: errors.length,
|
||||
},
|
||||
errors,
|
||||
};
|
||||
|
||||
if (errors.length) {
|
||||
throw new Error(`${errors[0].file}: ${errors[0].error}`);
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify(out), {
|
||||
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||
});
|
||||
};
|
||||
@@ -5,12 +5,11 @@ import EditionToc from "../../components/EditionToc.astro";
|
||||
import LocalToc from "../../components/LocalToc.astro";
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const entries = (await getCollection("archicratie"))
|
||||
.filter((e) => e.slug.startsWith("archicrat-ia/"));
|
||||
// ✅ Après migration : plus de filtre par prefix, on prend toute la collection
|
||||
const entries = await getCollection("archicrat-ia");
|
||||
|
||||
return entries.map((entry) => ({
|
||||
// ✅ inline : jamais de helper externe (évite "stripPrefix is not defined")
|
||||
params: { slug: entry.slug.replace(/^archicrat-ia\//, "") },
|
||||
params: { slug: entry.slug },
|
||||
props: { entry },
|
||||
}));
|
||||
}
|
||||
@@ -35,4 +34,4 @@ const { Content, headings } = await entry.render();
|
||||
|
||||
<h1>{entry.data.title}</h1>
|
||||
<Content />
|
||||
</EditionLayout>
|
||||
</EditionLayout>
|
||||
@@ -2,13 +2,12 @@
|
||||
import SiteLayout from "../../layouts/SiteLayout.astro";
|
||||
import { getCollection } from "astro:content";
|
||||
|
||||
const entries = (await getCollection("archicratie"))
|
||||
.filter((e) => e.slug.startsWith("archicrat-ia/"));
|
||||
// ✅ Après migration physique : collection = "archicrat-ia", slug = "chapitre-3" (sans prefix)
|
||||
const entries = await getCollection("archicrat-ia");
|
||||
|
||||
entries.sort((a, b) => (a.data.order ?? 9999) - (b.data.order ?? 9999));
|
||||
|
||||
const strip = (slug) => slug.replace(/^archicrat-ia\//, "");
|
||||
const href = (slug) => `/archicrat-ia/${strip(slug)}/`;
|
||||
const href = (slug) => `/archicrat-ia/${slug}/`;
|
||||
---
|
||||
|
||||
<SiteLayout title="Essai-thèse — ArchiCraT-IA">
|
||||
@@ -19,4 +18,4 @@ const href = (slug) => `/archicrat-ia/${strip(slug)}/`;
|
||||
<li><a href={href(e.slug)}>{e.data.title}</a></li>
|
||||
))}
|
||||
</ul>
|
||||
</SiteLayout>
|
||||
</SiteLayout>
|
||||
@@ -4,13 +4,13 @@ import SiteLayout from "../layouts/SiteLayout.astro";
|
||||
<SiteLayout title="Accueil">
|
||||
<h1>Archicratie — Édition web</h1>
|
||||
<p>
|
||||
Portail d’accès aux éditions : Traité (Ontodynamique générative), Essai-thèse (Archicratie),
|
||||
Portail d’accès aux éditions : Traité (Ontodynamique générative), Essai-thèse (ArchiCraT-IA),
|
||||
Cas pratique (IA), Glossaire, Atlas.
|
||||
</p>
|
||||
|
||||
<ul>
|
||||
<li><a href="/editions/">Carte des œuvres</a></li>
|
||||
<li><a href="/archicratie/">Essai-thèse — Archicratie</a></li>
|
||||
<li><a href="/archicrat-ia/">Essai-thèse — ArchiCraT-IA</a></li>
|
||||
<li><a href="/traite/">Traité — Ontodynamique générative</a></li>
|
||||
<li><a href="/ia/">Cas pratique — Gouvernance des systèmes IA</a></li>
|
||||
<li><a href="/glossaire/">Glossaire archicratique</a></li>
|
||||
|
||||
42
src/pages/para-index.json.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
|
||||
export const prerender = true;
|
||||
|
||||
async function exists(p: string) {
|
||||
try { await fs.access(p); return true; } catch { return false; }
|
||||
}
|
||||
|
||||
export const GET: APIRoute = async () => {
|
||||
const distFile = path.join(process.cwd(), "dist", "para-index.json");
|
||||
|
||||
// Si dist existe (ex: après un build), on renvoie le vrai fichier.
|
||||
if (await exists(distFile)) {
|
||||
const raw = await fs.readFile(distFile, "utf8");
|
||||
return new Response(raw, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"cache-control": "no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Sinon stub (dev sans build) : pas d’erreur, pas de crash, pas de 404.
|
||||
const stub = {
|
||||
schema: 1,
|
||||
generatedAt: new Date().toISOString(),
|
||||
items: [],
|
||||
byId: {},
|
||||
note: "para-index not built yet (run: npm run build to generate dist/para-index.json)",
|
||||
};
|
||||
|
||||
return new Response(JSON.stringify(stub), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"cache-control": "no-store",
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,9 +1,5 @@
|
||||
{
|
||||
"archicratie/00-demarrage/index.html": [
|
||||
"p-0-d64c1c39",
|
||||
"p-1-3f750540"
|
||||
],
|
||||
"archicratie/archicrat-ia/chapitre-1/index.html": [
|
||||
"archicrat-ia/chapitre-1/index.html": [
|
||||
"p-0-8d27a7f5",
|
||||
"p-1-8a6c18bf",
|
||||
"p-2-39c6e4f4",
|
||||
@@ -664,7 +660,7 @@
|
||||
"p-657-7c465f0f",
|
||||
"p-658-3fc26620"
|
||||
],
|
||||
"archicratie/archicrat-ia/chapitre-2/index.html": [
|
||||
"archicrat-ia/chapitre-2/index.html": [
|
||||
"p-0-32820f76",
|
||||
"p-1-63506bae",
|
||||
"p-2-206c653a",
|
||||
@@ -1981,7 +1977,7 @@
|
||||
"p-1313-c83e97d4",
|
||||
"p-1314-4c2ed9ba"
|
||||
],
|
||||
"archicratie/archicrat-ia/chapitre-3/index.html": [
|
||||
"archicrat-ia/chapitre-3/index.html": [
|
||||
"p-0-ace27175",
|
||||
"p-1-60c7ea48",
|
||||
"p-2-1167ed0e",
|
||||
@@ -2973,7 +2969,7 @@
|
||||
"p-988-d8a0cce7",
|
||||
"p-989-fdbb8595"
|
||||
],
|
||||
"archicratie/archicrat-ia/chapitre-4/index.html": [
|
||||
"archicrat-ia/chapitre-4/index.html": [
|
||||
"p-0-ba984c9d",
|
||||
"p-1-ea84724d",
|
||||
"p-2-31b12529",
|
||||
@@ -3730,7 +3726,7 @@
|
||||
"p-753-f778aef4",
|
||||
"p-754-d99a24c1"
|
||||
],
|
||||
"archicratie/archicrat-ia/chapitre-5/index.html": [
|
||||
"archicrat-ia/chapitre-5/index.html": [
|
||||
"p-0-96edff22",
|
||||
"p-1-a51a4ee1",
|
||||
"p-2-dff32cbc",
|
||||
@@ -4771,7 +4767,7 @@
|
||||
"p-1037-4825033b",
|
||||
"p-1038-54aa72be"
|
||||
],
|
||||
"archicratie/archicrat-ia/conclusion/index.html": [
|
||||
"archicrat-ia/conclusion/index.html": [
|
||||
"p-0-5ec4522a",
|
||||
"p-1-e481f7e6",
|
||||
"p-2-7a56c59b",
|
||||
@@ -4892,7 +4888,7 @@
|
||||
"p-117-3a086369",
|
||||
"p-118-67afae83"
|
||||
],
|
||||
"archicratie/archicrat-ia/prologue/index.html": [
|
||||
"archicrat-ia/prologue/index.html": [
|
||||
"p-0-d7974f88",
|
||||
"p-1-2ef25f29",
|
||||
"p-2-edb49e0a",
|
||||
|
||||