diff --git a/package.json b/package.json
index f02eded..6e22001 100644
--- a/package.json
+++ b/package.json
@@ -17,7 +17,8 @@
"i18n": "node tools/i18n-extract.mjs",
"i18n:check": "node tools/i18n-extract.mjs --check",
"notice:check": "node tools/notice-check.mjs",
- "notice:fix": "node tools/notice-check.mjs --fix"
+ "notice:fix": "node tools/notice-check.mjs --fix",
+ "manual": "node tools/build-manual.mjs --schema ../inbuxa-server/resources/schema/schema.json.gz --out ../inbuxa.org/docs-site/docs/reference"
},
"dependencies": {
"@daypicker/react": "^10.0.1",
diff --git a/src/help/help.test.ts b/src/help/help.test.ts
index b89c76b..14d67b1 100644
--- a/src/help/help.test.ts
+++ b/src/help/help.test.ts
@@ -11,7 +11,15 @@ import { fieldHelp, FIELD_HELP } from './texts';
describe('manualUrl', () => {
afterEach(() => document.querySelector('meta[name="manual-url"]')?.remove());
- it('shows no link until a manual is configured', () => {
+ it('links to the public manual unless a deployment names its own', () => {
+ expect(manualUrl('x:Domain.dnsManagement')).toBe('https://docs.inbuxa.org/reference/domain/#dnsmanagement');
+ });
+
+ it('shows no link when a deployment turns them off with an empty tag', () => {
+ const meta = document.createElement('meta');
+ meta.name = 'manual-url';
+ meta.content = '';
+ document.head.appendChild(meta);
expect(manualUrl('x:Domain.dnsManagement')).toBeNull();
});
@@ -26,6 +34,9 @@ describe('manualUrl', () => {
'https://docs.example.org/admin/reference/dns-server-cloudflare/#secret',
);
expect(manualUrl('x:Account/User')).toBe('https://docs.example.org/admin/reference/account-user/');
+ // The JMAP object and the settings of the same name are two pages
+ expect(manualUrl('x:AddressBook')).toBe('https://docs.example.org/admin/reference/address-book/');
+ expect(manualUrl('AddressBook.name')).toBe('https://docs.example.org/admin/reference/jmap-address-book/#name');
});
});
diff --git a/src/help/manual.ts b/src/help/manual.ts
index 43586ad..aa7a6a2 100644
--- a/src/help/manual.ts
+++ b/src/help/manual.ts
@@ -8,32 +8,55 @@
* Links from in-app help to the INBUXA admin manual (MkDocs). Every tooltip
* and help panel carries a stable id, `x:Domain.dnsManagement` for a field
* or `x:Domain` for a page, and this is the one place that turns an id into
- * an address. Until a manual is published there is no base URL and no link
- * is shown.
+ * an address.
*
- * The base URL comes from VITE_MANUAL_URL at build time, or from
- * at deploy time.
+ * The base URL is when a
+ * deployment sets one (empty for no links), else VITE_MANUAL_URL from the
+ * build, else the public manual at docs.inbuxa.org.
*/
+
+/** The public manual, for the same product: used unless a deployment names its own. */
+export const DEFAULT_MANUAL_URL = 'https://docs.inbuxa.org';
+
function manualBase(): string | null {
- const fromMeta =
- typeof document !== 'undefined' ? document.querySelector('meta[name="manual-url"]')?.getAttribute('content') : null;
- const base = (fromMeta || (import.meta.env.VITE_MANUAL_URL as string | undefined) || '').trim();
- return base ? base.replace(/\/+$/, '') : null;
+ // A deployment's own manual, or none: an empty tag turns the links off
+ const meta = typeof document !== 'undefined' ? document.querySelector('meta[name="manual-url"]') : null;
+ if (meta) {
+ const own = (meta.getAttribute('content') ?? '').trim();
+ return own ? own.replace(/\/+$/, '') : null;
+ }
+ const built = ((import.meta.env.VITE_MANUAL_URL as string | undefined) ?? '').trim();
+ return (built || DEFAULT_MANUAL_URL).replace(/\/+$/, '');
}
/**
- * The manual page for a help id: `x:Domain.dnsManagement` becomes
- * `/reference/domain/#dnsmanagement`, `x:Domain` becomes
- * `/reference/domain/`. The manual's page names must follow this.
+ * Where a help id's entry is in the manual, relative to its root:
+ * `x:Domain.dnsManagement` is `reference/domain/#dnsmanagement`, `x:Domain`
+ * is `reference/domain/`. The manual's generator (tools/build-manual.mjs)
+ * names its pages and anchors with this same function.
*/
-export function manualUrl(id: string): string | null {
- const base = manualBase();
- if (!base) return null;
+export function manualPath(id: string): string {
const [object, field] = id.split('.', 2);
- const page = object
+ return `reference/${manualPage(object)}/${field ? `#${field.toLowerCase()}` : ''}`;
+}
+
+/**
+ * The page name for an object, schema or view: `x:DnsServerCloudflare` is
+ * `dns-server-cloudflare`. The JMAP objects that aren't the registry's
+ * (`AddressBook`, `Calendar`…) get `jmap-` first, or they'd share a page with
+ * the settings of the same name (`x:AddressBook`).
+ */
+export function manualPage(object: string): string {
+ const own = object.startsWith('x:') || object.startsWith('CustomComponent/');
+ return (own ? object : `jmap-${object}`)
.replace(/^x:/, '')
.replace(/\//g, '-')
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
.toLowerCase();
- return `${base}/reference/${page}/${field ? `#${field.toLowerCase()}` : ''}`;
+}
+
+/** The manual's address for a help id, or null when there's no manual. */
+export function manualUrl(id: string): string | null {
+ const base = manualBase();
+ return base ? `${base}/${manualPath(id)}` : null;
}
diff --git a/tools/build-manual.mjs b/tools/build-manual.mjs
new file mode 100644
index 0000000..a96f6de
--- /dev/null
+++ b/tools/build-manual.mjs
@@ -0,0 +1,326 @@
+#!/usr/bin/env node
+// SPDX-FileCopyrightText: 2026 Coffey Labs
+// 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/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.
+
+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 --out `);
+ 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');
+await vite.close();
+
+const WORDS = { on: 'On', off: 'Off', none: 'None' };
+
+/** Text for markdown: angle brackets would read as HTML. */
+function md(text) {
+ return String(text ?? '')
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .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]);
+ }
+ }
+}
+for (const layout of schema.layouts) walk(layout.items, [layout.name].filter(Boolean));
+
+// --- 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 = [];
+ lines.push(`# ${md(title(name))}`, '');
+ const where = whereInConsole.get(name);
+ if (where) lines.push(`*In the console: ${where.map(md).join(' › ')}*`, '');
+ 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 = [`# ${md(title(name))}`, ''];
+ const where = whereInConsole.get(name);
+ if (where) lines.push(`*In the console: ${where.map(md).join(' › ')}*`, '');
+ 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 = [
+ '# 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}`);