// 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))}
${escape(name)}${inline(type(f))}${required.has(name) ? '' : ', optional'}${title}
Errors
${main.errors.map((e) => `${escape(e.name)}`).join(', ')}
${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)
}
}