198 lines
5.0 KiB
TypeScript
198 lines
5.0 KiB
TypeScript
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",
|
|
},
|
|
});
|
|
};
|