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