Something went wrong. Try again.
Monorepo for Tangled tangled.org
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221// 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 upfunction 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`);}