// The reference pages read as reference, not as Markdown tables: a table // whose rows are a name and what it does becomes a list of them, each name // on a line of its own; a route or XRPC method shows its HTTP method as a // badge; and an XRPC method of knotless's own has its lexicon under it, // parameters, input and output, read from lexicons/ so it cannot drift. // docs/ stays plain Markdown: all of this happens as the site is built. import { existsSync, readdirSync, readFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' const here = dirname(fileURLToPath(import.meta.url)) const LEXICONS = join(here, '../lexicons/dev/knotless') const REPO = 'https://knotless.dev/chadtmiller.com/knotless' /** Every dev.knotless lexicon, by NSID. */ const lexicons = new Map( existsSync(LEXICONS) ? readdirSync(LEXICONS) .filter((f) => f.endsWith('.json')) .map((f) => JSON.parse(readFileSync(join(LEXICONS, f), 'utf8'))) .map((l) => [l.id, l]) : [], ) const METHOD = { query: 'GET', procedure: 'POST', subscription: 'WS' } const text = (node) => node.type === 'text' || node.type === 'inlineCode' ? node.value : (node.children ?? []).map(text).join('') const escape = (s) => String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') /** `GET /refs`, `POST dev.knotless.x`, `dev.knotless.x?q=`: its method, and the NSID it names, if any. */ function route(code) { const m = code.match(/^(GET|POST|PUT|DELETE|PATCH|HEAD)\s+(\S+)/) const path = m ? m[2] : code const nsid = path.match(/^(?:\/xrpc\/)?((?:[a-z][a-z0-9-]*\.){2,}[a-zA-Z][a-zA-Z0-9]*)/)?.[1] const lexicon = nsid && lexicons.get(nsid) const method = m?.[1] ?? (lexicon && METHOD[lexicon.defs.main?.type]) return { method, nsid: lexicon ? nsid : undefined } } /** A field's type, as one reads it: `string (did)`, `integer, 1 to 500, default 50`, `ref repoView`. */ function type(field) { if (!field) return '' const bits = [] switch (field.type) { case 'string': bits.push(field.format ? `string (${field.format})` : 'string') if (field.maxLength) bits.push(`at most ${field.maxLength} bytes`) if (field.knownValues) bits.push(field.knownValues.map((v) => `\`${v}\``).join(', ')) break case 'integer': bits.push('integer') if (field.minimum !== undefined && field.maximum !== undefined) bits.push(`${field.minimum} to ${field.maximum}`) break case 'array': bits.push(`array of ${type(field.items)}`) if (field.maxLength) bits.push(`at most ${field.maxLength}`) break case 'ref': bits.push(field.ref.replace(/^#/, '').replace(/^dev\.knotless\./, '')) break case 'union': bits.push(`one of ${field.refs.map((r) => r.replace(/^#/, '').replace(/^dev\.knotless\./, '')).join(', ')}`) break default: bits.push(field.type) } if (field.default !== undefined) bits.push(`default ${JSON.stringify(field.default)}`) return bits.join(', ') } /** Markdown's inline code, and nothing else, made HTML: enough for a lexicon's descriptions. */ const inline = (s) => escape(s ?? '').replace(/`([^`]+)`/g, '$1') function fields(title, object) { if (!object) return '' if (object.type === 'ref' || object.type === 'union') return `

${title}

${escape(type(object))}

` const props = Object.entries(object.properties ?? {}) if (!props.length) return '' const required = new Set(object.required ?? []) const rows = props .map( ([name, f]) => `${escape(name)}${inline(type(f))}${required.has(name) ? '' : ', optional'}` + `${inline(f.description)}`, ) .join('') return `

${title}

${rows}
` } /** The lexicon of `nsid`, folded away under its name. */ function lexicon(nsid) { const lex = lexicons.get(nsid) const main = lex.defs.main const body = fields('Parameters', main.parameters) + fields('Input', main.input?.schema) + fields('Output', main.output?.schema) + (main.errors?.length ? `

Errors

${main.errors.map((e) => `${escape(e.name)}`).join(', ')}

` : '') const source = `${REPO}/blob/main/lexicons/dev/knotless/${nsid.split('.').pop()}.json` return ( `
Lexicon` + (main.description ? `

${inline(main.description)}

` : '') + body + `

${escape(nsid)}.json

` ) } /** A heading's anchor as GitHub, and harness/docs.mjs, make it. */ const slug = (s) => s.trim().toLowerCase().replace(/[^\p{L}\p{N}_\- ]/gu, '').replaceAll(' ', '-') const badge = (method) => method && { type: 'methodBadge', data: { hName: 'span', hProperties: { className: ['method', `method-${method.toLowerCase()}`] } }, children: [{ type: 'text', value: method }], } const gap = { type: 'text', value: ' ' } /** A command or route as code that breaks only between its words: a browser would break inside `--flag` after its hyphens. */ const signature = (code) => ({ type: 'html', value: `${code.split(/(\s+)/).map((w) => (/\s/.test(w) ? w : `${escape(w)}`)).join('')}`, }) /** A cell's code, if the cell starts with some. */ const lead = (cell) => (cell.children[0]?.type === 'inlineCode' ? cell.children[0].value : null) /** A table of names, each with a sentence or more about it, rather than a grid of values. */ function definitional(table) { const [, ...rows] = table.children if (!rows.length || !rows.every((r) => lead(r.children[0]) !== null)) return false // Four columns or more never fit the page's width; fewer do while the last is short. if (table.children[0].children.length >= 4) return true const last = rows.map((r) => text(r.children.at(-1)).length) return last.reduce((a, b) => a + b, 0) / rows.length > 60 } /** The table as a list: each row's first cell its term, the middle cells labelled by their heads, the last its description. */ function asList(table) { const [head, ...rows] = table.children const heads = head.children.map(text) const items = rows.flatMap((row) => { const [first, ...rest] = row.children const { method, nsid } = route(lead(first)) const [code, ...others] = first.children const shown = [signature(method ? code.value.replace(/^[A-Z]+\s+/, '') : code.value), ...others] const term = { type: 'refTerm', data: { hName: 'dt' }, children: method ? [badge(method), gap, ...shown] : shown } const middle = rest.slice(0, -1).flatMap((cell, i) => text(cell).trim() ? [{ type: 'refMeta', data: { hName: 'p', hProperties: { className: ['ref-meta'] } }, children: [{ type: 'html', value: `${escape(heads[i + 1])} ` }, ...cell.children] }] : [], ) // The last column is the description; labelled too when others are, so each says what it is. const label = rest.length > 1 && heads.at(-1) ? [{ type: 'html', value: `${escape(heads.at(-1))} ` }] : [] const description = { type: 'paragraph', children: [...label, ...(rest.at(-1)?.children ?? [])] } const detail = { type: 'refDetail', data: { hName: 'dd' }, children: [...middle, description, ...(nsid ? [{ type: 'html', value: lexicon(nsid) }] : [])] } return [term, detail] }) return { type: 'refList', data: { hName: 'dl', hProperties: { className: ['ref-list'] } }, children: items } } export function remarkReference() { return (tree) => { const visit = (parent) => { parent.children?.forEach((node, i) => { if (node.type === 'table' && definitional(node)) parent.children[i] = asList(node) else if (node.type === 'heading' && node.children.length === 1 && node.children[0].type === 'inlineCode') { // `### \`GET /healthz\``, `### \`dev.knotless.listRepos\``: the method a badge before it. The // anchor is fixed first, from the heading as written, so links to it still land. const code = node.children[0] const { method, nsid } = route(code.value) if (!method) return node.data = { ...node.data, hProperties: { ...node.data?.hProperties, id: slug(code.value) } } node.children = [badge(method), gap, { ...code, value: code.value.replace(/^[A-Z]+\s+/, '') }] if (nsid) { // At the end of its section, after what is said of it. let end = i + 1 while (end < parent.children.length && !(parent.children[end].type === 'heading' && parent.children[end].depth <= node.depth)) end++ parent.children.splice(end, 0, { type: 'html', value: lexicon(nsid) }) } } else visit(node) }) } visit(tree) } }