// generates docs/src/content/docs/api/*.md from the service routers and the lexicons
// run from anywhere: node docs/scripts/xrpc-api.mjs
import { readFileSync, readdirSync, statSync, writeFileSync, mkdirSync } from "node:fs";
import { join, dirname, relative } from "node:path";
import { fileURLToPath } from "node:url";
const root = join(dirname(fileURLToPath(import.meta.url)), "../..");
const out = join(root, "docs/src/content/docs/api");
const services = [
{
slug: "knot",
title: "Knot",
description: "XRPC methods served by a knot.",
intro: "Served by `knot-server` at `https://<knot host>/xrpc/<method>`.",
rust: ["knot2/crates/knot-xrpc/src", "knot2/crates/knot-server/src"],
},
{
slug: "spindle",
title: "Spindle",
description: "XRPC methods served by a spindle.",
intro: "Served by `spindle` at `https://<spindle host>/xrpc/<method>`.",
go: ["spindle/xrpc/xrpc.go"],
},
{
slug: "bobbin",
title: "Bobbin",
description: "XRPC methods served by bobbin, the Tangled API.",
intro: "Served by `bobbin` at `https://<bobbin host>/xrpc/<method>`. Ours is `https://api.tangled.org`.",
rust: ["bobbin/crates/xrpc/src", "bobbin/crates/bobbin/src"],
},
];
function walk(dir, ext) {
const files = [];
for (const name of readdirSync(dir)) {
const path = join(dir, name);
if (statSync(path).isDirectory()) {
if (name !== "tests") files.push(...walk(path, ext));
} else if (name.endsWith(ext) && name !== "tests.rs" && !name.endsWith("_test.go")) {
files.push(path);
}
}
return files;
}
// strip test modules so test-only routes are not picked up
function nonTest(src) {
let out = "";
let at = 0;
for (let i = src.indexOf("#[cfg(test)]"); i !== -1; i = src.indexOf("#[cfg(test)]", at)) {
out += src.slice(at, i);
const semi = src.indexOf(";", i);
let j = src.indexOf("{", i);
if (j === -1 || (semi !== -1 && semi < j)) {
at = semi + 1;
continue;
}
for (let depth = 0; j < src.length; j++) {
if (src[j] === "{") depth++;
else if (src[j] === "}" && --depth === 0) break;
}
at = j + 1;
}
return out + src.slice(at);
}
function rustMethods(dirs) {
const files = dirs.flatMap((d) => walk(join(root, d), ".rs")).map((f) => nonTest(readFileSync(f, "utf8")));
const consts = new Map();
for (const src of files) {
for (const m of src.matchAll(/const\s+(\w+)\s*:\s*&(?:'static\s+)?str\s*=\s*"([^"]+)"/g)) {
consts.set(m[1], m[2]);
}
}
const methods = new Set();
for (const src of files) {
for (const m of src.matchAll(/\.route\(\s*(?:"([^"]+)"|([\w:]+))\s*,/g)) {
const path = m[1] ?? consts.get(m[2].split("::").pop());
if (path?.startsWith("/xrpc/") && path.includes(".")) methods.add(path.slice("/xrpc/".length));
}
for (const m of src.matchAll(/const\s+\w+_NSIDS\s*:\s*&\[&str\]\s*=\s*&\[([^\]]*)\]/g)) {
for (const n of m[1].matchAll(/"([^"]+)"/g)) methods.add(n[1]);
}
}
return methods;
}
function goMethods(files) {
const consts = new Map();
for (const f of walk(join(root, "api"), ".go")) {
for (const m of readFileSync(f, "utf8").matchAll(/(\w+NSID)\s*=\s*"([^"]+)"/g)) consts.set(m[1], m[2]);
}
const methods = new Set();
for (const f of files) {
const src = readFileSync(join(root, f), "utf8");
for (const m of src.matchAll(/r\.(?:Get|Post)\("\/"\s*\+\s*\w+\.(\w+NSID)/g)) {
if (consts.has(m[1])) methods.add(consts.get(m[1]));
}
}
return methods;
}
const lexicons = new Map();
for (const f of walk(join(root, "lexicons"), ".json")) {
const doc = JSON.parse(readFileSync(f, "utf8"));
if (doc.id) lexicons.set(doc.id, doc);
}
function resolve(ref, base) {
const [id, name] = ref.startsWith("#") ? [base, ref.slice(1)] : ref.split("#");
const doc = lexicons.get(id);
return doc && { def: doc.defs[name ?? "main"], id };
}
function refName(ref, base) {
if (ref.startsWith("#")) return ref.slice(1);
const [id, name] = ref.split("#");
return id === base ? name : name ? `${id}#${name}` : id;
}
function typeOf(field, base) {
switch (field.type) {
case "ref":
return `\`${refName(field.ref, base)}\``;
case "union":
return field.refs.map((r) => `\`${refName(r, base)}\``).join(" or ");
case "array":
return `array of ${typeOf(field.items, base)}`;
case "string":
return field.format ? `string, ${field.format}` : "string";
default:
return field.type;
}
}
function fieldLine(name, field, required, base) {
const tags = [typeOf(field, base)];
if (required) tags.push("required");
const values = field.enum ?? field.knownValues;
if (values) tags.push(`${field.enum ? "one of" : "known values"} ${values.map((v) => `\`${v}\``).join(", ")}`);
if (field.default !== undefined) tags.push(`default \`${JSON.stringify(field.default)}\``);
if (field.minimum !== undefined && field.maximum !== undefined) tags.push(`${field.minimum} to ${field.maximum}`);
const description = field.description ? `: ${field.description.trim()}` : "";
return `- \`${name}\` (${tags.join(", ")})${description}`;
}
function objectLines(schema, base) {
if (schema?.type === "ref") {
const target = resolve(schema.ref, base);
if (!target) return [`- \`${refName(schema.ref, base)}\``];
return objectLines(target.def, target.id);
}
if (schema?.type === "union") return [`- ${typeOf(schema, base)}`];
const props = Object.entries(schema?.properties ?? {});
if (props.length === 0) return ["- None."];
const required = new Set(schema.required ?? []);
return props.map(([name, field]) => fieldLine(name, field, required.has(name), base));
}
function bodyLines(label, body, base) {
if (!body) return [];
const lines = [`${label}:`, ""];
if (body.schema) {
if (body.encoding && body.encoding !== "application/json") lines.push(`- Encoding: \`${body.encoding}\``);
lines.push(...objectLines(body.schema, base));
} else {
lines.push(`- Encoding: \`${body.encoding}\``);
}
return [...lines, ""];
}
const verbs = { query: "`GET`", procedure: "`POST`", subscription: "WebSocket" };
function methodSection(nsid) {
const lines = [`## ${nsid}`, ""];
const main = lexicons.get(nsid)?.defs?.main;
if (!main) {
const note = nsid.startsWith("com.atproto.")
? "Standard atproto method, see the [atproto lexicons](https://github.com/bluesky-social/atproto/tree/main/lexicons/com/atproto)."
: "No lexicon in this repository.";
return [...lines, note, ""];
}
lines.push(main.description ? `${verbs[main.type]}. ${main.description.trim()}` : `${verbs[main.type]}.`, "");
if (main.parameters && Object.keys(main.parameters.properties ?? {}).length) {
lines.push("Params:", "", ...objectLines(main.parameters, nsid), "");
}
lines.push(...bodyLines("Input", main.input, nsid));
lines.push(...bodyLines("Output", main.output, nsid));
if (main.type === "subscription" && main.message) lines.push("Messages:", "", ...objectLines(main.message.schema, nsid), "");
if (main.errors?.length) {
lines.push("Errors:", "", ...main.errors.map((e) => `- \`${e.name}\`${e.description ? `: ${e.description.trim()}` : ""}`), "");
}
return lines;
}
mkdirSync(out, { recursive: true });
for (const service of services) {
const methods = [
...(service.rust ? rustMethods(service.rust) : []),
...(service.go ? goMethods(service.go) : []),
]
.filter((nsid) => !nsid.startsWith("sh.tangled.") && !nsid.startsWith("org.tangled.temp.") && !nsid.startsWith("blue.microcosm."))
.sort();
const lines = [
"---",
`title: ${service.title}`,
`description: ${service.description}`,
"---",
"",
`<!-- generated by ${relative(join(root, "docs"), fileURLToPath(import.meta.url))}, do not edit -->`,
"",
service.intro,
"",
...methods.flatMap(methodSection),
];
writeFileSync(join(out, `${service.slug}.md`), lines.join("\n").trimEnd() + "\n");
console.log(`${service.slug}: ${methods.length} methods`);
}