Files
inbuxa-admin/tools/build-manual.mjs
T
jcoffey-dev 515074445f
ci / build (pull_request) Skipped
ci / publish (pull_request) Skipped
ci / notices (pull_request) Successful in 1m5s
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 2m30s
Manual: name console pages by the menu people see
build-manual.mjs read the server's own layout, but the console regroups
Settings on the client, so the reference named menus that no longer exist,
such as Settings › TLS › Certificates for what the console shows as
Settings › Security › Certificates.

It now applies withRegroupedSettings before walking the menus. A page reads
as Settings › category › page, the way most guides write it; the group stays
as a heading in the reference index.

Page titles come from the same menu labels, so they follow the console's
wording too. Pages the old layout didn't list, such as Branding, gain a
location line. File names and anchors are unchanged, so "Learn more" links
keep working.
2026-11-02 14:07:18 -07:00

344 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
// SPDX-FileCopyrightText: 2026 Coffey Labs LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
// Writes the admin manual's reference section: one page per settings object
// in the server's schema, an anchor per option, so every "Learn more" link a
// tooltip or help panel makes (src/help/manual.ts) lands on its own entry.
//
// node tools/build-manual.mjs --schema ../inbuxa-server/resources/schema/schema.json.gz \
// --out ../inbuxa.org/docs-site/content/docs/reference
//
// The words are the console's own: the help texts in src/help/texts.ts first,
// the schema's descriptions where we haven't written any, and defaults
// described the way the console's tooltips describe them. Page names and
// anchors come from manualPath(), the same function the console links with,
// so the two can't drift apart. Run it again after a schema or help change;
// it rewrites the whole directory.
//
// The docs site is HotDog CMS (MkDocs until October 2026): each page opens
// with front matter naming its title, which the docs' layout shows, and the
// index is the folder's own page, _index.md.
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { gunzipSync } from 'node:zlib';
import { createServer } from 'vite';
function arg(name) {
const i = process.argv.indexOf(`--${name}`);
if (i < 0 || !process.argv[i + 1]) {
console.error(`usage: build-manual.mjs --schema <schema.json.gz> --out <content/docs/reference>`);
process.exit(2);
}
return process.argv[i + 1];
}
const schemaPath = resolve(arg('schema'));
const outDir = resolve(arg('out'));
const raw = readFileSync(schemaPath);
const schema = JSON.parse((schemaPath.endsWith('.gz') ? gunzipSync(raw) : raw).toString('utf8'));
// The console's modules, through Vite so the @/ paths resolve
const vite = await createServer({ server: { middlewareMode: true }, appType: 'custom', logLevel: 'error' });
const { FIELD_HELP, PAGE_HELP } = await vite.ssrLoadModule('/src/help/texts.ts');
const { describeDefault } = await vite.ssrLoadModule('/src/help/defaults.ts');
const { humanize } = await vite.ssrLoadModule('/src/lib/humanize.ts');
const { manualPage } = await vite.ssrLoadModule('/src/help/manual.ts');
const { withRegroupedSettings } = await vite.ssrLoadModule('/src/lib/settingsLayout.ts');
await vite.close();
const WORDS = { on: 'On', off: 'Off', none: 'None' };
/** Text for markdown: angle brackets would read as HTML. */
/** A page's front matter: its title (JSON's quoting is YAML's). */
function front(pageTitle) {
return ['---', `title: ${JSON.stringify(String(pageTitle))}`, '---', ''];
}
function md(text) {
return String(text ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.trim();
}
/** The schema's descriptions often stop without a full stop. */
function sentence(text) {
return /[.!?:)]$/.test(text) ? text : `${text}.`;
}
/** Pages link to each other by file, so MkDocs checks every link. */
const link = (name) => `${manualPage(name)}.md`;
const hasPage = (name) => Boolean(schema.fields[name] || schema.schemas[name]);
// --- Where each object is in the console, from the menus ---------------------
const whereInConsole = new Map();
function walk(items, path) {
for (const item of items ?? []) {
const container = item.container ?? (item.type === 'container' ? item : null);
if (container) {
walk(container.items, [...path, container.name]);
} else if (item.type === 'link' && item.viewName) {
const target = schema.objects[item.viewName]?.objectName ?? item.viewName;
if (!whereInConsole.has(target)) whereInConsole.set(target, [...path, item.name]);
if (!whereInConsole.has(item.viewName)) whereInConsole.set(item.viewName, [...path, item.name]);
}
}
}
// The menus as the console shows them: Settings is regrouped client-side, so the
// server's own layout names groups nobody sees.
for (const layout of withRegroupedSettings(schema).layouts) walk(layout.items, [layout.name].filter(Boolean));
// Settings › category › group › page reads as the category and the page, the way
// the guides write it; the group is only a heading in the category's menu.
function whereWords(where) {
const path = where[0] === 'Settings' && where.length === 4 ? [where[0], where[1], where[3]] : where;
return path.map(md).join(' › ');
}
// --- What a field holds, in words --------------------------------------------
function typeWords(field) {
const t = field.type ?? {};
const enumLabels = (name) =>
(schema.enums[name] ?? []).map((e) => `${md(e.label ?? e.name)} (\`${e.name}\`)`).join(', ');
switch (t.type) {
case 'boolean':
return 'On or off.';
case 'number':
if (t.format === 'duration') return 'A length of time.';
if (t.format === 'size') return 'A size in bytes (KB, MB and GB are accepted).';
return 'A number.';
case 'utcDateTime':
return 'A date and time.';
case 'enum':
return t.enumName ? `One of: ${enumLabels(t.enumName)}.` : 'One of a fixed set of values.';
case 'object':
return t.objectName && hasPage(t.objectName)
? `Settings of their own: see [${md(title(t.objectName))}](${link(t.objectName)}).`
: 'Settings of their own.';
case 'objectList':
return t.objectName && hasPage(t.objectName)
? `A list, each with the settings in [${md(title(t.objectName))}](${link(t.objectName)}).`
: 'A list.';
case 'set':
case 'list': {
const item = t.class ? typeWords({ type: t.class }) : null;
return item ? `A list. Each one: ${item.charAt(0).toLowerCase()}${item.slice(1)}` : 'A list of values.';
}
case 'map':
return 'A set of entries, each a key and its value.';
case 'objectId':
return t.objectName
? hasPage(t.objectName)
? `One of your [${md(title(t.objectName))}](${link(t.objectName)}), chosen from a list.`
: `One of your ${md(humanize(t.objectName.replace(/^x:/, '')).toLowerCase())}s, chosen from a list.`
: 'Another object, chosen from a list.';
case 'blobId':
return 'An uploaded file.';
case 'string':
switch (t.format) {
case 'secret':
case 'secretText':
return 'A secret: stored, and never shown again once saved.';
case 'emailAddress':
return 'An email address.';
case 'ipAddress':
return 'An IP address.';
case 'ipMask':
case 'ipNetwork':
return 'An IP address or network, like 192.0.2.0/24.';
case 'html':
return 'HTML.';
case 'color':
return 'A color, like #1a7f74.';
case 'text':
return 'Text, over several lines if needed.';
case 'uri':
case 'url':
return 'An address (URL).';
default:
return 'Text.';
}
default:
if (field.type?.expression || t.type === 'expression') return 'An expression: a value, or rules that pick one.';
return null;
}
}
// --- Page titles and what a page is about ----------------------------------
function title(name) {
const list = schema.lists[name];
if (list?.title) return list.title;
const where = whereInConsole.get(name);
if (where) return where[where.length - 1];
return humanize(name.replace(/^x:/, '').replace(/\//g, ' '));
}
function about(name) {
return PAGE_HELP[name]?.about ?? schema.lists[name]?.subtitle ?? schema.objects[name]?.description ?? null;
}
// --- One page -------------------------------------------------------------
/** The fields of a schema, in the console's form order, then any the form doesn't show. */
function orderedFields(scope) {
const fields = schema.fields[scope];
if (!fields) return [];
const form = schema.forms[scope];
const out = [];
const seen = new Set();
for (const section of form?.sections ?? []) {
for (const f of section.fields ?? []) {
if (!fields.properties[f.name] || seen.has(f.name)) continue;
seen.add(f.name);
out.push({ name: f.name, label: f.label, section: section.title });
}
}
// Fields kept only so old data still opens say so ("Not used: …"); a reader has nothing to set
const unused = (name) => /^Not used\b/.test(fields.properties[name].description ?? '');
const rest = Object.keys(fields.properties).filter((name) => !seen.has(name) && !unused(name));
for (const name of rest) {
if (fields.properties[name].update !== 'serverSet') out.push({ name, label: null, section: 'Other settings' });
}
for (const name of rest) {
if (fields.properties[name].update === 'serverSet') out.push({ name, label: null, section: 'Set by the server' });
}
return out;
}
function fieldEntry(scope, f) {
const fields = schema.fields[scope];
const prop = fields.properties[f.name];
const label = f.label || humanize(f.name);
const lines = [`### ${md(label)} {#${f.name.toLowerCase()}}`, ''];
const text = FIELD_HELP[`${scope}.${f.name}`] ?? prop.description;
if (text) lines.push(sentence(md(text)), '');
const facts = [];
const words = typeWords(prop);
if (words) facts.push(words);
const def = describeDefault(prop, fields.defaults?.[f.name], schema, WORDS);
if (def) facts.push(`Default: ${md(def)}.`);
if (prop.update === 'immutable') facts.push('Set when it’s created; can’t be changed afterwards.');
if (prop.update === 'serverSet') facts.push('Read only.');
facts.push(`Name in the API: \`${f.name}\`.`);
lines.push(facts.join(' '), '');
return lines.join('\n');
}
function schemaPage(name) {
const lines = front(title(name));
const where = whereInConsole.get(name);
if (where) lines.push(`*In the console: ${whereWords(where)}*`, '');
const text = about(name);
if (text) lines.push(sentence(md(text)), '');
const sch = schema.schemas[name];
if (sch?.type === 'multiple') {
lines.push('## Kinds', '');
lines.push('Choose one kind; each has its own settings.', '');
for (const v of sch.variants) {
if (v.name.startsWith('Deprecated')) continue;
lines.push(
v.schemaName ? `- [${md(v.label)}](${link(v.schemaName)})` : `- ${md(v.label)}: no settings of its own.`,
);
}
lines.push('');
return lines.join('\n');
}
const scope = sch?.type === 'single' ? sch.schemaName : name;
const fields = orderedFields(scope);
let section = undefined;
for (const f of fields) {
const heading = f.section;
if (heading && heading !== section) {
lines.push(`## ${md(heading)}`, '');
section = heading;
}
lines.push(fieldEntry(scope, f));
}
if (fields.length === 0) lines.push('This has no settings of its own.', '');
return lines.join('\n');
}
/** A page of the console's own, with what it's for (its options live in the code, not the schema). */
function ownPage(name) {
const help = PAGE_HELP[name];
const lines = front(title(name));
const where = whereInConsole.get(name);
if (where) lines.push(`*In the console: ${whereWords(where)}*`, '');
if (help?.about) lines.push(md(help.about), '');
for (const option of help?.options ?? []) {
lines.push(`### ${md(option.label)}`, '', md(option.text), '');
}
return lines.join('\n');
}
// --- Every page ----------------------------------------------------------
const pages = new Map();
for (const name of Object.keys(schema.schemas)) pages.set(name, () => schemaPage(name));
for (const name of Object.keys(schema.fields)) if (!pages.has(name)) pages.set(name, () => schemaPage(name));
for (const name of Object.keys(PAGE_HELP)) {
if (pages.has(name) || schema.schemas[name]) continue;
const obj = schema.objects[name];
if (obj?.type === 'view') continue;
pages.set(name, () => ownPage(name));
}
rmSync(outDir, { recursive: true, force: true });
mkdirSync(outDir, { recursive: true });
const written = [];
for (const [name, render] of pages) {
const file = `${manualPage(name)}.md`;
writeFileSync(join(outDir, file), `${render()}\n`);
written.push({ name, file });
}
// The index: the console's menus first, then everything else
const index = [
...front('Reference'),
'Every setting in the console, page by page: what it does, what it’s set to unless you change it, and the name the API uses. The **Learn more** link in a tooltip or a page’s help panel opens its entry here.',
'',
'These pages are generated from the server’s own description of its settings, with the console’s help text. To set things up step by step, start with [Run it](../run/topology.md) instead.',
'',
];
const listed = new Set();
const groups = new Map();
for (const { name, file } of written) {
const where = whereInConsole.get(name);
if (!where) continue;
const group = where.slice(0, -1).join(' › ') || 'Console';
if (!groups.has(group)) groups.set(group, []);
groups.get(group).push({ name, file });
listed.add(name);
}
// In the order the menus run, and each menu's items in theirs
const menuOrder = [...whereInConsole.keys()];
const rank = (name) => {
const i = menuOrder.indexOf(name);
return i < 0 ? Number.MAX_SAFE_INTEGER : i;
};
for (const entries of groups.values()) entries.sort((a, b) => rank(a.name) - rank(b.name));
const ordered = [...groups.entries()].sort((a, b) => rank(a[1][0].name) - rank(b[1][0].name));
for (const [group, entries] of ordered) {
index.push(`## ${md(group)}`, '');
for (const e of entries) index.push(`- [${md(title(e.name))}](${e.file})`);
index.push('');
}
const rest = written.filter((w) => !listed.has(w.name)).sort((a, b) => title(a.name).localeCompare(title(b.name)));
if (rest.length) {
index.push('## Settings inside other settings', '');
index.push('Kinds and parts that appear inside the pages above.', '');
for (const e of rest) index.push(`- [${md(title(e.name))}](${e.file})`);
index.push('');
}
writeFileSync(join(outDir, '_index.md'), `${index.join('\n')}\n`);
console.log(`wrote ${written.length} pages and an index to ${outDir}`);