Something went wrong. Try again.
replace big-tech and host basic infrastructure yourself! ordner.fyi
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401#!/usr/bin/env node/** * The developer guide's examples: each request and answer is a JSON file * in the guide's examples folder (`EXAMPLES` below), sent by a test to the real server and * compared with what it answers, and quoted in the guide's chapters between * markers this script keeps up to date. * * node scripts/developer-examples.ts [--check] * * Without `--check` it rewrites every quoted block from its file; with it, * it exits 1 when a block differs (the tests run the same check). * * An example file is one of: * * { "title", "credential", "request": <JMAP Request>, "response": <JMAP Response> } * { "title", "credential"?, "http": { "method", "path", "headers"?, "body"? }, * "answer": { "status", "headers"?, "body"? } } * * and may say `"match": "subset"`, under which an answer's objects may hold * more members than the example shows (arrays still match item by item), * and `"unordered": [<JSON pointer>]`, lists of the answer matched in any * order. * * Placeholders. A string `${name}` stands for a value the server made. In a * request every `${name}` is replaced by the value bound to that name; in an * answer a name not bound yet binds to what the server answered there, and a * bound one must equal it. `${*}` in an answer is any value. A placeholder * may sit inside a longer string (`${photo}/${faceId}`) and in a member name. * * In the guide, a block is quoted after a marker line * `<!-- example: <file> request -->` or `<!-- example: <file> response -->` * (the file relative to the examples folder). */
import { readdirSync, readFileSync, writeFileSync } from "node:fs";import { dirname, join, relative } from "node:path";import { fileURLToPath } from "node:url";
export type Json = | null | boolean | number | string | Json[] | { [key: string]: Json };
export interface JmapExample { readonly title: string; readonly credential: string; readonly match?: "subset"; /** JSON pointers into the answer of lists whose order the server does not promise. */ readonly unordered?: readonly string[]; readonly request: Json; readonly response: Json;}
export interface HttpExample { readonly title: string; readonly credential?: string; readonly match?: "subset"; /** JSON pointers into the answer of lists whose order the server does not promise. */ readonly unordered?: readonly string[]; readonly http: { readonly method: string; readonly path: string; readonly headers?: Readonly<Record<string, string>>; readonly body?: Json; }; readonly answer: { readonly status: number; readonly headers?: Readonly<Record<string, string>>; readonly body?: Json; };}
export type Example = JmapExample | HttpExample;
export const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");export const GUIDE = join(ROOT, "docs", "developers");export const EXAMPLES = join(GUIDE, "examples");
export const isHttp = (e: Example): e is HttpExample => "http" in e;
/** Every example file under `dir` (relative to the examples folder), sorted. */export function exampleFiles(dir = ""): string[] { const out: string[] = []; const walk = (at: string) => { for (const entry of readdirSync(join(EXAMPLES, at), { withFileTypes: true, })) { const rel = at === "" ? entry.name : `${at}/${entry.name}`; if (entry.isDirectory()) walk(rel); else if (entry.name.endsWith(".json")) out.push(rel); } }; walk(dir); return out.sort();}
export function readExample(file: string): Example { return JSON.parse(readFileSync(join(EXAMPLES, file), "utf8")) as Example;}
const PLACEHOLDER = /\$\{([A-Za-z0-9_*.-]+)\}/g;
/** The request with every bound placeholder filled in; an unbound one throws. */export function fill(value: Json, bound: ReadonlyMap<string, Json>): Json { if (typeof value === "string") { const whole = /^\$\{([A-Za-z0-9_.-]+)\}$/.exec(value); if (whole !== null) { const name = whole[1] as string; if (!bound.has(name)) throw new Error(`\${${name}} is not bound yet`); return bound.get(name) as Json; } return value.replace(PLACEHOLDER, (_, name: string) => { if (!bound.has(name)) throw new Error(`\${${name}} is not bound yet`); const v = bound.get(name); return typeof v === "string" ? v : JSON.stringify(v); }); } if (Array.isArray(value)) return value.map((v) => fill(v, bound)); if (value !== null && typeof value === "object") return Object.fromEntries( Object.entries(value).map(([k, v]) => [ fill(k, bound) as string, fill(v, bound), ]), ); return value;}
/** * Whether `actual` is what `expected` says, binding the names it meets for * the first time (into `bound`, only when the whole value matches). Answers * the first difference as a JSON pointer and a reason, or null. */export function match( expected: Json, actual: Json, bound: Map<string, Json>, subset = false, unordered: readonly string[] = [],): string | null { const fresh = new Map<string, Json>(); const at = (path: string, why: string) => `${path || "/"}: ${why}`; const known = (name: string) => fresh.has(name) ? fresh.get(name) : bound.get(name); const isKnown = (name: string) => fresh.has(name) || bound.has(name);
const text = (pattern: string, value: string): boolean => { const whole = /^\$\{([A-Za-z0-9_*.-]+)\}$/.exec(pattern); if (whole === null && !pattern.includes("${")) return pattern === value; const names: string[] = []; const source = pattern .split(PLACEHOLDER) .map((part, i) => { if (i % 2 === 0) return RegExp.escape(part); if (part === "*") return "[\\s\\S]*?"; if (isKnown(part)) { const v = known(part); return RegExp.escape(typeof v === "string" ? v : JSON.stringify(v)); } names.push(part); return "([\\s\\S]+?)"; }) .join(""); const m = new RegExp(`^${source}$`).exec(value); if (m === null) return false; names.forEach((name, i) => { fresh.set(name, m[i + 1] as string); }); return true; };
const walk = (e: Json, a: Json, path: string): string | null => { if (typeof e === "string") { const whole = /^\$\{([A-Za-z0-9_*.-]+)\}$/.exec(e); if (whole !== null) { const name = whole[1] as string; if (name === "*") return null; if (!isKnown(name)) { fresh.set(name, a); return null; } return JSON.stringify(known(name)) === JSON.stringify(a) ? null : at( path, `is ${JSON.stringify(a)}, \${${name}} is ${JSON.stringify(known(name))}`, ); } if (typeof a !== "string") return at(path, `is ${JSON.stringify(a)}, not a string`); return text(e, a) ? null : at(path, `is ${JSON.stringify(a)}, not ${JSON.stringify(e)}`); } if (Array.isArray(e)) { if (!Array.isArray(a)) return at(path, `is ${JSON.stringify(a)}, not a list`); if (a.length !== e.length) return at( path, `holds ${a.length} items, not ${e.length}: ${JSON.stringify(a)}`, ); if (unordered.includes(path)) { // Each item matches one of the answer's, in any order. const left = [...a]; for (const [i, v] of e.entries()) { const saved = new Map(fresh); const k = left.findIndex((x) => { const why = walk(v, x, `${path}/${i}`); if (why !== null) { fresh.clear(); for (const [n, y] of saved) fresh.set(n, y); } return why === null; }); if (k < 0) return at( `${path}/${i}`, `matches no item of ${JSON.stringify(a)}`, ); left.splice(k, 1); } return null; } for (const [i, v] of e.entries()) { const why = walk(v, a[i] as Json, `${path}/${i}`); if (why !== null) return why; } return null; } if (e !== null && typeof e === "object") { if (a === null || typeof a !== "object" || Array.isArray(a)) return at(path, `is ${JSON.stringify(a)}, not an object`); const left = new Set(Object.keys(a)); for (const [k, v] of Object.entries(e)) { // A member name holds bound placeholders only, or binds one of the rest. let key: string | undefined; if (!k.includes("${")) key = k; else key = [...left].find((cand) => { const saved = new Map(fresh); const ok = text(k, cand) && walk( v, (a as Record<string, Json>)[cand] as Json, `${path}/${cand}`, ) === null; if (!ok) { fresh.clear(); for (const [n, x] of saved) fresh.set(n, x); } return ok; }); if (key === undefined || !Object.hasOwn(a, key)) return at(`${path}/${k}`, `is missing from ${JSON.stringify(a)}`); left.delete(key); if (k.includes("${")) continue; const why = walk( v, (a as Record<string, Json>)[key] as Json, `${path}/${key}`, ); if (why !== null) return why; } if (!subset && left.size > 0) return at( path, `holds ${[...left].join(", ")} too: ${JSON.stringify(a)}`, ); return null; } return JSON.stringify(e) === JSON.stringify(a) ? null : at(path, `is ${JSON.stringify(a)}, not ${JSON.stringify(e)}`); };
const why = walk(expected, actual, ""); if (why === null) for (const [k, v] of fresh) bound.set(k, v); return why;}
// --- quoting in the guide ---------------------------------------------------
const REASONS: Readonly<Record<number, string>> = { 200: "OK", 201: "Created", 204: "No Content", 301: "Moved Permanently", 302: "Found", 303: "See Other", 400: "Bad Request", 401: "Unauthorized", 403: "Forbidden", 404: "Not Found", 429: "Too Many Requests",};
const pretty = (v: Json) => JSON.stringify(v, null, 2);
function bodyText(body: Json | undefined): string { if (body === undefined) return ""; return typeof body === "string" ? body : pretty(body);}
/** What the guide shows of one side of an example. */export function quoted(e: Example, side: "request" | "response"): string { if (!isHttp(e)) return pretty(side === "request" ? e.request : e.response); if (side === "request") { const lines = [`${e.http.method} ${e.http.path} HTTP/1.1`]; for (const [k, v] of Object.entries(e.http.headers ?? {})) lines.push(`${k}: ${v}`); const body = bodyText(e.http.body); return body === "" ? lines.join("\n") : `${lines.join("\n")}\n\n${body}`; } const lines = [ `HTTP/1.1 ${e.answer.status} ${REASONS[e.answer.status] ?? ""}`.trimEnd(), ]; for (const [k, v] of Object.entries(e.answer.headers ?? {})) lines.push(`${k}: ${v}`); const body = bodyText(e.answer.body); return body === "" ? lines.join("\n") : `${lines.join("\n")}\n\n${body}`;}
const MARKER = /^<!-- example: (\S+) (request|response) -->$/;
/** The guide's chapter with every quoted block made its file's; and the files it quotes. */export function synced(markdown: string): { text: string; quotes: string[] } { const lines = markdown.split("\n"); const out: string[] = []; const quotes: string[] = []; for (let i = 0; i < lines.length; i++) { const line = lines[i] as string; out.push(line); const m = MARKER.exec(line); if (m === null) continue; const file = m[1] as string; const side = m[2] as "request" | "response"; quotes.push(file); const e = readExample(file); const lang = isHttp(e) ? "http" : "json"; // The block after the marker is replaced whole, or added. let j = i + 1; if ((lines[j] ?? "").startsWith("```")) { j += 1; while (j < lines.length && !(lines[j] as string).startsWith("```")) j++; j += 1; } out.push(`\`\`\`${lang}`, quoted(e, side), "```"); i = j - 1; } return { text: out.join("\n"), quotes };}
/** Every chapter of the guide, relative to it. */export function chapters(): string[] { return readdirSync(GUIDE) .filter((f) => f.endsWith(".md")) .sort();}
/** Chapters whose quoted blocks differ from their files; with `write`, rewritten. */export function syncGuide(write: boolean): { stale: string[]; quoted: Set<string>;} { const stale: string[] = []; const all = new Set<string>(); for (const chapter of chapters()) { const path = join(GUIDE, chapter); const before = readFileSync(path, "utf8"); const { text, quotes } = synced(before); for (const q of quotes) all.add(q); if (text !== before) { stale.push(relative(ROOT, path)); if (write) writeFileSync(path, text); } } return { stale, quoted: all };}
if (process.argv[1] === fileURLToPath(import.meta.url)) { const check = process.argv.includes("--check"); const { stale } = syncGuide(!check); if (check && stale.length > 0) { process.stderr.write( `quoted examples differ from their files in: ${stale.join(", ")}\n(run node scripts/developer-examples.ts)\n`, ); process.exit(1); } if (!check) process.stdout.write( stale.length === 0 ? "every quoted example is current\n" : `rewrote ${stale.join(", ")}\n`, );}