From 2cd9b4b1ba7c4f1da80fcb4044759e23ec29b853 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 5 Oct 2026 18:43:41 -0700 Subject: [PATCH] Help: "Learn more" opens the manual at docs.inbuxa.org MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every tooltip and help panel already carried a stable id; the manual behind them now exists (inbuxa.org #53, docs.inbuxa.org/reference/). - manual.ts: the public manual is the default. still names a deployment's own, and an empty tag turns the links off. manualPath() and manualPage() are split out so the generator names its pages with the same rules the links use. 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. - tools/build-manual.mjs (`npm run manual`): writes the reference from the server's schema and this console's own help texts, loaded through Vite: a page per settings object or kind, an anchor per option, the default worded as the tooltip words it, and an index by menu. --- package.json | 3 +- src/help/help.test.ts | 13 +- src/help/manual.ts | 55 +++++-- tools/build-manual.mjs | 326 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 379 insertions(+), 18 deletions(-) create mode 100644 tools/build-manual.mjs 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}`); -- 2.54.0