/** * Generates the runtime-config portion of the environment-variable reference * doc directly from the live config schemas, so the doc can never drift from * `packages/core/src/config/schema/*`. * * Output is written between the marker comments in * `packages/docs/content/docs/configuration/environment-variables.mdx`. Content * above/below the markers (the hand-written intro, the bootstrap table, etc.) is * preserved verbatim. * * Wired into the docs build via the root `gen:env-docs` script. Run manually * with: `pnpm run gen:env-docs`. */ import { readFileSync, writeFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import path from 'node:path'; // The config index pulls in `bootstrap` -> `env.ts`, whose envalid schema // requires BASE_URL and SECRET_KEY. We only need the static schema objects, so // satisfy the validator with throwaway values before importing. process.env.BASE_URL ??= 'http://localhost:3000'; process.env.SECRET_KEY ??= '0000000000000000000000000000000000000000000000000000000000000000'; const { runtimeSchemas, describeSettings } = await import('../packages/core/src/config/index.js'); const { isRuntimeConfigField, resolveDescription } = await import('../packages/core/src/config/types.js'); const __dirname = path.dirname(fileURLToPath(import.meta.url)); const DOC_PATH = path.resolve( __dirname, '../packages/docs/content/docs/configuration/environment-variables.mdx' ); const BEGIN = '{/* BEGIN GENERATED ENV REFERENCE */}'; const END = '{/* END GENERATED ENV REFERENCE */}'; /** * Mirrors `packages/frontend/src/app/dashboard/settings/tabs.config.ts` so the * generated doc is grouped/ordered/labelled the same way as the dashboard * Settings UI. Keep in sync if the tab manifest changes. */ const TAB_MANIFEST: Record< string, { label: string; group: string; order: number } > = { api: { label: 'General', group: 'Core', order: 1 }, branding: { label: 'Branding', group: 'Core', order: 2 }, templates: { label: 'Templates', group: 'Core', order: 3 }, metadata: { label: 'Metadata', group: 'Core', order: 4 }, logging: { label: 'Logging', group: 'Core', order: 5 }, http: { label: 'HTTP', group: 'Network', order: 6 }, proxy: { label: 'Proxy', group: 'Network', order: 7 }, nzbProxy: { label: 'NZB Proxy', group: 'Network', order: 8 }, rateLimits: { label: 'Rate Limits', group: 'Network', order: 9 }, services: { label: 'Services', group: 'Content', order: 10 }, presets: { label: 'Presets', group: 'Content', order: 11 }, builtins: { label: 'Built-ins', group: 'Content', order: 12 }, poster: { label: 'Posters', group: 'Content', order: 13 }, resources: { label: 'Resources', group: 'Content', order: 14 }, userLimits: { label: 'User Limits', group: 'Limits', order: 15 }, recursion: { label: 'Recursion', group: 'Limits', order: 16 }, tasks: { label: 'Tasks', group: 'Limits', order: 17 }, }; const GROUP_ORDER = ['Core', 'Network', 'Content', 'Limits', 'Other']; const ACRONYMS: Record = { api: 'API', url: 'URL', uri: 'URI', id: 'ID', ip: 'IP', ui: 'UI', sel: 'SEL', http: 'HTTP', https: 'HTTPS', nzb: 'NZB', tmdb: 'TMDB', tvdb: 'TVDB', rpdb: 'RPDB', oauth: 'OAuth', gdrive: 'GDrive', ttl: 'TTL', nab: 'NAB', db: 'DB', }; function humanise(s: string): string { if (!s) return ''; const tokens = s .replace(/([a-z0-9])([A-Z])/g, '$1 $2') .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') .split(/[\s\-_]+/) .filter(Boolean); return tokens .map((t) => { const lower = t.toLowerCase(); if (ACRONYMS[lower]) return ACRONYMS[lower]; return lower.charAt(0).toUpperCase() + lower.slice(1); }) .join(' '); } function tabFor(section: string) { return ( TAB_MANIFEST[section] ?? { label: humanise(section), group: 'Other', order: 999, } ); } interface Leaf { /** Path segments within the section (excludes the section name). */ sub: string[]; field: any; } function collect(node: Record, sub: string[], out: Leaf[]) { for (const [name, child] of Object.entries(node)) { const childSub = [...sub, name]; if (isRuntimeConfigField(child)) { out.push({ sub: childSub, field: child }); } else { collect(child as Record, childSub, out); } } } // MDX parses `{` as an expression and `<` as JSX inside table cells, and `|` // terminates a cell. Plain markdown backslash-escaping does not work in MDX, so // neutralise the significant characters with HTML entities — but ONLY outside // inline-code spans. MDX keeps inline code literal (no JSX/expression parsing // and no entity decoding), so escaping inside backticks would render the raw // entity text. Within code we still neutralise `|` since it breaks the table. function mdEscape(s: string): string { const oneLine = s.replace(/\r?\n/g, ' ').trim(); return oneLine .split(/(`[^`]*`)/) .map((part) => { if (part.startsWith('`') && part.endsWith('`')) { return part.replace(/\|/g, '\\|'); } return part .replace(/\|/g, '\\|') .replace(/\{/g, '{') .replace(/\}/g, '}') .replace(//g, '>'); }) .join(''); } function fmtDefault(field: any): string { if (field.secret) return '*(unset)*'; const d = field.default; if (d === null || d === undefined || d === '') return '—'; if (typeof d === 'string') return '`' + d + '`'; if (typeof d === 'boolean' || typeof d === 'number') return '`' + d + '`'; const json = JSON.stringify(d); if (json === '{}' || json === '[]') return '—'; return '`' + json + '`'; } function main() { const hints = describeSettings(); // Build: group -> section -> rows const sections = Object.entries(runtimeSchemas as Record) .map(([section, tree]) => { const leaves: Leaf[] = []; collect(tree, [], leaves); return { section, tree, leaves, tab: tabFor(section) }; }) .sort((a, b) => { const ga = GROUP_ORDER.indexOf(a.tab.group); const gb = GROUP_ORDER.indexOf(b.tab.group); if (ga !== gb) return ga - gb; if (a.tab.order !== b.tab.order) return a.tab.order - b.tab.order; return a.section.localeCompare(b.section); }); const lines: string[] = []; lines.push( '{/* This section is auto-generated by scripts/gen-env-docs.ts from the', ' config schemas. Do not edit by hand — run `pnpm run gen:env-docs`. */}', '' ); let currentGroup = ''; for (const { section, leaves, tab } of sections) { if (leaves.length === 0) continue; if (tab.group !== currentGroup) { currentGroup = tab.group; lines.push(`## ${currentGroup}`, ''); } lines.push(`### ${tab.label}`, ''); // Group leaves by their immediate subsection (first sub segment when the // field is nested; "" for top-level fields). const bySub = new Map(); for (const leaf of leaves) { const key = leaf.sub.length > 1 ? leaf.sub[0] : ''; if (!bySub.has(key)) bySub.set(key, []); bySub.get(key)!.push(leaf); } const renderTable = (rows: Leaf[]) => { lines.push( '| Environment Variable | UI Setting | Type | Default | Description |', '| --- | --- | --- | --- | --- |' ); for (const { sub, field } of rows) { const key = `${section}.${sub.join('.')}`; const hint = hints[key]; const kind = hint?.kind ?? 'string'; const flags: string[] = []; if (field.secret) flags.push('secret'); if (field.requiresRestart) flags.push('restart required'); let desc = mdEscape(resolveDescription(field.description, 'env')); if (flags.length) desc += ` _(${flags.join(', ')})_`; const envVar = field.env ? '`' + field.env + '`' : '—'; const uiLabel = sub.map(humanise).join(' › '); lines.push( `| ${envVar} | ${uiLabel} | ${kind} | ${fmtDefault(field)} | ${desc} |` ); } lines.push(''); }; const top = bySub.get('') ?? []; if (top.length) renderTable(top); for (const [subKey, rows] of bySub) { if (subKey === '') continue; lines.push(`#### ${humanise(subKey)}`, ''); renderTable(rows); } } const generated = lines.join('\n').trimEnd(); const doc = readFileSync(DOC_PATH, 'utf8'); const bi = doc.indexOf(BEGIN); const ei = doc.indexOf(END); if (bi === -1 || ei === -1 || ei < bi) { throw new Error( `Could not find generation markers in ${DOC_PATH}. Expected:\n${BEGIN}\n...\n${END}` ); } const before = doc.slice(0, bi + BEGIN.length); const after = doc.slice(ei); const next = `${before}\n\n${generated}\n\n${after}`; if (next !== doc) { writeFileSync(DOC_PATH, next); console.log(`Updated ${path.relative(process.cwd(), DOC_PATH)}`); } else { console.log('Env reference already up to date.'); } } main();