Files
docs/apps/bot-docs/scripts/convert-docs.ts
T
zeekayandHanzo Dev f0f8b79f48 The bot docs converter copies the bot's own words, in MDX that parses
The bot repo's docs are branded at the source, so the rebrand map only still
matched names written on purpose, and rewrote the clawdbot compatibility shim
and would rewrite an OpenClaw migration guide's commands and paths into ones
that do not exist. It is gone. Source and destination are the argument and
this app's content/, not two absolute paths into other checkouts, and a page
a fixed nav list leaves out is reported. install/ lists migrate-from-openclaw.

Converting today's docs left five pages MDX refuses to parse (an HTML
comment, an autolink, a bare `<->`), and a page that does not parse is not
rendered. mdxSafe turns each into its MDX form outside code; all 317 pages
compile. The converter runs only as a script, so its test can import it.


Co-authored-by: Hanzo Dev <[email protected]>
2026-09-25 23:58:51 -07:00

374 lines
11 KiB
TypeScript

/**
* Convert Hanzo Bot's docs (hanzoai/bot, docs/*.md) into this app's .mdx.
*
* - Copies every .md file in the categories below, preserving directory structure
* - Converts frontmatter (summary → description, drops read_when)
* - Creates meta.json for each directory
* - Renames index.md → index.mdx
*
* bun scripts/convert-docs.ts [path/to/bot/docs]
*
* The text is copied as written. It used to be run through a rebrand map
* (OpenClaw → Hanzo Bot, openclaw → hanzo-bot, …) from when the fork's docs
* still said OpenClaw. They no longer do: the bot repo is the source of truth and
* is branded at the source, so every name the map still matched was one written
* on purpose — the migration guide's `hanzo bot migrate openclaw` and
* `~/.openclaw`, the `clawdbot` compatibility shim in install/updating — and the
* map turned each into a command, a path or a fact that does not exist.
*/
import { readdir, readFile, writeFile, mkdir } from 'fs/promises';
import { join, relative, dirname, resolve } from 'path';
import { existsSync } from 'fs';
import { homedir } from 'os';
const SRC = resolve(process.argv[2] ?? join(homedir(), 'work/hanzobot/bot/docs'));
const DEST = resolve(import.meta.dirname, '../content/docs');
// Directories to include (top-level categories)
const CATEGORIES = [
'install', 'start', 'concepts', 'cli', 'channels', 'providers',
'tools', 'gateway', 'automation', 'platforms', 'nodes', 'web',
'plugins', 'help', 'reference',
];
// Files to skip
const SKIP_FILES = new Set([
'_config.yml', 'docs.json', 'CNAME', '.gitignore',
'Gemfile', 'Gemfile.lock', '_layouts', '_includes',
]);
// Category display names and ordering
const CATEGORY_META: Record<string, { title: string; pages?: string[] }> = {
install: {
title: 'Installation',
pages: ['index', 'installer', 'node', 'docker', 'nix', 'ansible', 'bun', 'updating', 'migrate-from-openclaw', 'development-channels', 'uninstall'],
},
start: {
title: 'Getting Started',
pages: ['getting-started', 'wizard', 'setup', 'bot', 'pairing', 'showcase', 'onboarding', 'hubs', 'lore'],
},
concepts: {
title: 'Core Concepts',
},
cli: {
title: 'CLI Reference',
},
channels: {
title: 'Channels',
pages: ['index', 'whatsapp', 'telegram', 'grammy', 'discord', 'slack', 'signal', 'bluebubbles', 'imessage', 'matrix', 'mattermost', 'googlechat', 'msteams', 'nostr', 'tlon', 'nextcloud-talk', 'zalo', 'zalouser', 'location', 'troubleshooting'],
},
providers: {
title: 'LLM Providers',
pages: ['index', 'openai', 'anthropic', 'ollama', 'qwen', 'openrouter', 'moonshot', 'minimax', 'venice', 'synthetic', 'vercel-ai-gateway', 'opencode', 'zai', 'glm', 'github-copilot', 'deepgram'],
},
tools: {
title: 'Skills & Tools',
},
gateway: {
title: 'Gateway',
},
automation: {
title: 'Automation',
},
platforms: {
title: 'Platforms',
},
nodes: {
title: 'Nodes & Media',
},
web: {
title: 'Web Interfaces',
},
plugins: {
title: 'Plugins',
},
help: {
title: 'Help',
},
reference: {
title: 'Reference',
},
};
function convertFrontmatter(content: string): string {
// Extract existing frontmatter
const fmMatch = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
if (!fmMatch) {
return content;
}
const fm = fmMatch[1];
let body = fmMatch[2];
// Parse frontmatter fields
const lines = fm.split('\n');
let title = '';
let description = '';
let isIndex = false;
for (const line of lines) {
const summaryMatch = line.match(/^summary:\s*"(.+)"$/);
if (summaryMatch) {
description = summaryMatch[1];
}
const titleMatch = line.match(/^title:\s*"?(.+?)"?\s*$/);
if (titleMatch) {
title = titleMatch[1];
}
}
// Extract title from first heading if not in frontmatter
if (!title) {
const headingMatch = body.match(/^#\s+(.+)$/m);
if (headingMatch) {
title = headingMatch[1];
// Remove the heading from body since Hanzo Docs uses frontmatter title
body = body.replace(/^#\s+.+\n+/, '');
}
}
if (!title) {
title = 'Untitled';
}
if (!description) {
description = title;
}
// Build new frontmatter
const newFm = [
'---',
`title: "${title.replace(/"/g, '\\"')}"`,
`description: "${description.replace(/"/g, '\\"')}"`,
];
if (isIndex) {
newFm.push('index: true');
}
newFm.push('---');
return newFm.join('\n') + '\n\n' + body.trim() + '\n';
}
/**
* Markdown that MDX cannot parse, made into markdown it can.
*
* The bot's docs are CommonMark; this site compiles MDX, where `<` always opens
* JSX. Three constructs the source uses on purpose stop the parser, and a page
* that does not parse is not rendered at all:
*
* - an HTML comment (`<!-- markdownlint-disable MD037 -->`) becomes an MDX
* comment, `{/* … *\/}`;
* - an autolink (`<https://www.perplexity.ai/settings/api>`) becomes the bare
* URL, which GFM links on its own;
* - a `<` that cannot open a tag (`GPT-4 <-> Claude`, `a <= b`) becomes `&lt;`.
*
* Fenced blocks and inline code are left exactly as written.
*/
export function mdxSafe(doc: string): string {
const front = doc.match(/^---\n[\s\S]*?\n---\n/)?.[0] ?? '';
const out: string[] = [];
let fence = '';
let comment = false;
for (const line of doc.slice(front.length).split('\n')) {
const f = line.match(/^\s*(```+|~~~+)/);
if (fence) {
if (f && f[1][0] === fence[0] && f[1].length >= fence.length) fence = '';
out.push(line);
continue;
}
if (f) {
fence = f[1];
out.push(line);
continue;
}
const parts = line.split('`');
for (let i = 0; i < parts.length; i += 2) {
let t = parts[i];
if (comment) {
const end = t.indexOf('-->');
if (end < 0) {
parts[i] = t.replace(/\*\//g, '* /');
continue;
}
t = t.slice(0, end).replace(/\*\//g, '* /') + '*/}' + t.slice(end + 3);
comment = false;
}
t = t.replace(/<!--([\s\S]*?)-->/g, (_m, c: string) => `{/*${c.replace(/\*\//g, '* /')}*/}`);
const open = t.indexOf('<!--');
if (open >= 0) {
t = t.slice(0, open) + '{/*' + t.slice(open + 4).replace(/\*\//g, '* /');
comment = true;
}
t = t.replace(/<((?:https?|ftp|mailto):[^\s<>]+)>/g, '$1');
t = t.replace(/<(?![A-Za-z/!{])/g, '&lt;');
parts[i] = t;
}
out.push(parts.join('`'));
}
return front + out.join('\n');
}
async function* walkDir(dir: string): AsyncGenerator<string> {
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
if (!SKIP_FILES.has(entry.name) && !entry.name.startsWith('_') && !entry.name.startsWith('.')) {
yield* walkDir(fullPath);
}
} else if (entry.name.endsWith('.md') && !SKIP_FILES.has(entry.name)) {
yield fullPath;
}
}
}
async function convertFile(srcPath: string) {
const relPath = relative(SRC, srcPath);
const parts = relPath.split('/');
// Only process files in our target categories or root-level
const category = parts[0];
if (parts.length > 1 && !CATEGORIES.includes(category)) {
return;
}
// Skip root-level files that aren't in categories
if (parts.length === 1 && parts[0] !== 'index.md') {
// Root files that should go into specific categories
const rootFileMap: Record<string, string> = {
'tui.md': 'web/tui.mdx',
'tts.md': 'nodes/tts.mdx',
'environment.md': 'reference/environment.mdx',
'scripts.md': 'reference/scripts.mdx',
'testing.md': 'reference/testing.mdx',
'debug.md': 'help/debug.mdx',
};
const mapped = rootFileMap[parts[0]];
if (!mapped) return;
const destPath = join(DEST, mapped);
await mkdir(dirname(destPath), { recursive: true });
let content = await readFile(srcPath, 'utf-8');
content = mdxSafe(convertFrontmatter(content));
await writeFile(destPath, content, 'utf-8');
console.log(` ${relPath} → ${mapped}`);
return;
}
// Build dest path (.md → .mdx)
let destRel = relPath.replace(/\.md$/, '.mdx');
const destPath = join(DEST, destRel);
await mkdir(dirname(destPath), { recursive: true });
let content = await readFile(srcPath, 'utf-8');
content = mdxSafe(convertFrontmatter(content));
await writeFile(destPath, content, 'utf-8');
console.log(` ${relPath} → ${destRel}`);
}
async function createMetaFiles() {
// Create meta.json for each category directory
for (const [category, meta] of Object.entries(CATEGORY_META)) {
const catDir = join(DEST, category);
if (!existsSync(catDir)) continue;
const metaContent: any = {};
if (meta.title) {
metaContent.title = meta.title;
}
if (meta.pages) {
metaContent.pages = meta.pages;
// A page a fixed list does not name is converted and then never shown:
// the sidebar lists what meta.json lists. Say so, so a new page is added
// to the list on purpose rather than lost from the nav by default.
for (const f of await readdir(catDir)) {
const page = f.replace(/\.mdx$/, '');
if (f.endsWith('.mdx') && !meta.pages.includes(page)) {
console.warn(` [nav] ${category}/${f} is not in ${category}'s pages; it has no sidebar entry`);
}
}
} else {
// Auto-discover pages from directory
try {
const entries = await readdir(catDir);
const pages = entries
.filter(f => f.endsWith('.mdx') && f !== 'index.mdx')
.map(f => f.replace('.mdx', ''))
.sort();
if (pages.length > 0) {
// Put index first if it exists
const hasIndex = entries.includes('index.mdx');
if (hasIndex) {
metaContent.pages = ['index', ...pages];
} else {
metaContent.pages = pages;
}
}
} catch {
// Directory might not exist yet
}
}
await writeFile(
join(catDir, 'meta.json'),
JSON.stringify(metaContent, null, 2) + '\n',
'utf-8'
);
console.log(` Created ${category}/meta.json`);
}
// Handle subdirectories (platforms/mac, reference/templates)
const subdirs = ['platforms/mac', 'reference/templates'];
for (const subdir of subdirs) {
const subdirPath = join(DEST, subdir);
if (!existsSync(subdirPath)) continue;
const entries = await readdir(subdirPath);
const pages = entries
.filter(f => f.endsWith('.mdx'))
.map(f => f.replace('.mdx', ''))
.sort();
if (pages.length > 0) {
await writeFile(
join(subdirPath, 'meta.json'),
JSON.stringify({ pages }, null, 2) + '\n',
'utf-8'
);
console.log(` Created ${subdir}/meta.json`);
}
}
}
async function main() {
console.log('Converting Hanzo Bot docs → MDX\n');
console.log(`Source: ${SRC}`);
console.log(`Dest: ${DEST}\n`);
let count = 0;
// Convert all markdown files
for await (const srcPath of walkDir(SRC)) {
await convertFile(srcPath);
count++;
}
console.log(`\nConverted ${count} files`);
// Create meta.json files
console.log('\nCreating meta.json files...');
await createMetaFiles();
console.log('\nDone!');
}
if (import.meta.main) main().catch(console.error);