// The docs pages, into site/dist/docs: the guides in docs.mjs, and an API reference for each // service from api.mjs, laid out as docs.rs lays out a crate (#223): a root page listing its // modules and types, a page a module listing its endpoints, a page an endpoint and a page a type. // No script: the site's Content-Security-Policy allows none of its own. // // node site/gen.mjs (site/build.sh runs it) import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { ipx, directory } from './api.mjs'; import { guides } from './docs.mjs'; const here = path.dirname(fileURLToPath(import.meta.url)); const dist = path.join(here, 'dist'); const SRC = 'https://git.sdf1.net/rays/ipx/src/branch/main'; const esc = s => String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); /// The version a reference documents: its service's last release, from its changelog, not the /// -dev version between releases. function released(changelog) { const m = fs.readFileSync(path.join(here, '..', changelog), 'utf8').match(/^## \[(\d+\.\d+\.\d+)\]/m); return m ? m[1] : ''; } function write(rel, html) { const file = path.join(dist, rel); fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, html); } function shell({ title, description, sidebar, body, current }) { return ` ${esc(title)}
iPX
${body}
`; } /* ---------------- the guides ---------------- */ const REFS = [[ipx, 'ipx'], [directory, 'ipx-directory']]; function guideSidebar(cur) { const li = (href, name, on) => `
  • ${name}
  • `; return ``; } for (const g of guides) { write(g.file, shell({ title: g.title, description: g.description, sidebar: guideSidebar(g.href), body: g.body, current: 'docs' })); } /* ---------------- the references ---------------- */ const METHOD_CLASS = { GET: 'm-get', POST: 'm-post', PATCH: 'm-patch', PUT: 'm-patch', DELETE: 'm-delete' }; function reference(api) { const label = api.label || api.name; const version = released(api.changelog); const typeNames = new Set(api.types.map(t => t.name)); const typeHref = n => `${api.root}/types/${n}`; const itemHref = (g, it) => `${api.root}/${g.id}/${it.slug}`; const fnName = it => it.slug.replace(/-/g, '_'); // A Rust type, its names that are types of this API linked. const ty = s => esc(s).replace(/\b[A-Z][A-Za-z]+\b/g, n => typeNames.has(n) ? `${n}` : n); const isType = s => /^[A-Z]/.test(s) && /[A-Za-z>]$/.test(s) && !/\s/.test(s.replace(/, /g, ',')); const access = it => it.access || 'signed in'; // Which endpoints mention each type, for its "Used by", as docs.rs lists where a type turns up. const usedBy = new Map(); for (const g of api.groups) for (const it of g.items) { for (const s of [it.body, it.query, it.returns].filter(Boolean)) { for (const n of s.match(/\b[A-Z][A-Za-z]+\b/g) || []) if (typeNames.has(n)) { if (!usedBy.has(n)) usedBy.set(n, []); if (!usedBy.get(n).some(([, x]) => x === it)) usedBy.get(n).push([g, it]); } } } // Types inside types: a type used only as a field is reached from the type that holds it. for (const t of api.types) for (const [, ft] of t.fields) { for (const n of ft.match(/\b[A-Z][A-Za-z]+\b/g) || []) if (typeNames.has(n) && n !== t.name) { if (!usedBy.has(n)) usedBy.set(n, []); if (!usedBy.get(n).some(([, x]) => x === t)) usedBy.get(n).push([null, t]); } } const crumbs = (...parts) => `

    ${esc(api.name)}${parts.map(([h, n]) => `::${n}`).join('')}

    `; const top = `${esc(label)}${version ? `${version}` : ''}`; const back = `

    All docs

    `; function sidebar({ group, item, type } = {}) { let s = top; if (group) { s += `

    Module ${group.name}

    `; } s += `

    Modules

    `; s += `

    Types

    `; return `${back}`; } const page = (rel, title, description, side, body) => write(rel, shell({ title, description, sidebar: side, body, current: 'docs' })); // The root: what the crate page shows on docs.rs, its modules and types. page(`${api.root.slice(1)}/index.html`, `${label} API`, `The HTTP API of ${label}, endpoint by endpoint.`, sidebar(), `

    API ${esc(label)}${version ? ` ${version}` : ''}

    ${api.intro}

    Modules

    ${api.groups.map(g => `
    ${g.name}
    ${g.summary}
    `).join('')}

    Types

    ${api.types.map(t => `
    ${t.name}
    ${t.doc || ''}
    `).join('')}
    `); for (const g of api.groups) { page(`${api.root.slice(1)}/${g.id}/index.html`, `${api.name}::${g.name}`, g.title, sidebar({ group: g }), `${crumbs()}

    Module ${esc(api.name)}::${g.name}

    ${g.summary}

    Endpoints

    ${g.items.map(it => `
    ${it.method} ${esc(it.path)}
    ${it.summary}
    `).join('')}
    `); for (const it of g.items) { const decl = [ `${it.method} ${esc(it.path)}`, it.query && ` query ${ty(it.query)}`, it.body && ` body ${ty(it.body)}`, ` -> ${isType(it.returns) ? ty(it.returns) : esc(it.returns)}`, ].filter(Boolean).join('\n'); const fieldsOf = n => api.types.find(t => t.name === n)?.fields || []; const rows = list => `
    ${list.map(([n, t, d]) => `
    ${esc(n)}: ${ty(t)}
    ${d || ''}
    `).join('')}
    `; let body = `${crumbs([`${api.root}/${g.id}/`, g.name])}

    Endpoint ${esc(api.name)}::${g.name}::${fnName(it)}Source

    ${decl}

    ${it.summary}

    ${it.doc || ''}

    Access: ${esc(access(it))}

    `; if (it.pathParams) body += `

    Path parameters

    ${rows(it.pathParams)}`; if (it.query) body += `

    Query

    ${ty(it.query)}, as query parameters:

    ${rows(fieldsOf(it.query))}`; if (it.body) body += `

    Body

    ${ty(it.body)}, as JSON.

    `; body += `

    Returns

    ${isType(it.returns) ? `${ty(it.returns)}, as JSON.` : esc(it.returns)}

    `; if (it.errors?.length) body += `

    Errors

    ${it.errors.map(([c, d]) => `
    ${esc(c)}
    ${d}
    `).join('')}
    `; if (it.example) body += `

    Example

    ${esc(it.example)}
    `; page(`${api.root.slice(1)}/${g.id}/${it.slug}.html`, `${it.method} ${it.path} - ${label}`, it.summary.replace(/<[^>]+>/g, ''), sidebar({ group: g, item: it }), body); } } page(`${api.root.slice(1)}/types/index.html`, `${api.name}::types`, `The types ${label}'s API takes and gives.`, sidebar(), `${crumbs()}

    Module ${esc(api.name)}::types

    ${api.types.map(t => `
    ${t.name}
    ${t.doc || ''}
    `).join('')}
    `); for (const t of api.types) { const decl = t.name === 'Event' ? `enum Event {\n${t.fields.map(([n, f]) => ` ${n} ${ty(f)},`).join('\n')}\n}` : t.fields.length ? `struct ${t.name} {\n${t.fields.map(([n, f]) => ` ${n}: ${ty(f)},`).join('\n')}\n}` : `enum ${t.name}`; const uses = usedBy.get(t.name) || []; let body = `${crumbs([`${api.root}/types/`, 'types'])}

    ${t.name === 'Event' || !t.fields.length ? 'Enum' : 'Struct'} ${esc(api.name)}::types::${t.name}

    ${decl}
    ${t.doc ? `

    ${t.doc}

    ` : ''}`; if (t.fields.length) { body += `

    ${t.name === 'Event' ? 'Variants' : 'Fields'}

    ${t.fields.map(([n, f, d]) => `
    ยง${esc(n)}${t.name === 'Event' ? ' ' : ': '}${ty(f)}
    ${d || ''}
    `).join('')}
    `; } if (uses.length) { body += `

    Used by

    `; } page(`${api.root.slice(1)}/types/${t.name}.html`, `${t.name} - ${label}`, (t.doc || `${t.name} in ${label}'s API`).replace(/<[^>]+>/g, ''), sidebar({ type: t }), body); } // Every link to a type must name one there is: a misspelt type in api.mjs is otherwise a // name in plain text, which looks right and leads nowhere. for (const g of api.groups) for (const it of g.items) for (const s of [it.body, it.query].filter(Boolean)) { for (const n of s.match(/\b[A-Z][A-Za-z]+\b/g)) if (!typeNames.has(n) && !['Vec', 'Option'].includes(n)) throw new Error(`${it.path}: no type ${n}`); } } reference(ipx); reference(directory);