A capability is four surfaces, and none of them linked to the others

`ai` is an API reference, a `hanzo ai` command tree, an MCP tool and an SDK
method. Each is generated by a different script into a different section, and
measured before this: all three linked to the API reference and NONE of them
linked to each other. A reader on the CLI page had no route to the tool that does
the same thing; a reader who arrived from a model had no route to the reference.

One row on all three, naming the four with the current one in bold, so the set is
the same everywhere and only your position in it changes. 273 pages, 126
capabilities.

IT IS A POST-PASS, and that is the whole design. Each generator names its pages
with its OWN spelling of the capability and the spellings differ in both
directions — the MCP page for the `affiliates` tool is filed under the product
`affiliate`, and the CLI tree for `prompt` is at `prompts`. So no generator can
be handed its peers' sets: whichever runs first reads a directory that is not
written yet, and DERIVING a peer's spelling is a second copy of a decision that
generator owns. Both failures are measured rather than argued — 48 broken links
deriving MCP names from the vendored tool list, then 26 more in the other
direction once the order was flipped to fix it.

Reading the finished directories has neither problem: the pages that exist are
the addresses that exist. A capability whose spellings genuinely differ loses the
link rather than getting a guessed one — a missing link costs a reader a search,
a wrong one costs a 404 and the credibility of the rest of the row. Now 0 broken
of 273, and the pass is idempotent: a second run rewrites nothing.
This commit is contained in:
2026-08-22 18:05:01 -07:00
parent c8d7a31fbb
commit cf8cb09cb4
2 changed files with 114 additions and 0 deletions
+107
View File
@@ -0,0 +1,107 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const APP = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const DOCS = path.join(APP, 'content/docs');
/**
* The FOUR ways to use one capability, cross-linked, as a pass that runs AFTER
* every generator.
*
* A capability is not one page. `ai` is an API reference, a `hanzo ai` command
* tree, an MCP tool and an SDK method, each generated by a different script into
* a different section. Measured before this existed: all three linked to the API
* reference and NONE of them linked to each other, so a reader who arrived at
* the CLI page could not reach the tool that does the same thing.
*
* IT HAS TO BE A POST-PASS, and that is the whole design note. Each generator
* names its pages with its OWN spelling of the capability, and the spellings
* differ in both directions — the MCP page for the `affiliates` tool is filed
* under the product `affiliate`, and the CLI tree for `prompt` is at `prompts`.
* So no generator can be given the others' sets: whichever runs first would be
* reading a directory that is not written yet, and any attempt to DERIVE a
* peer's spelling is a second copy of a decision that generator owns. Both
* failures were measured — 48 broken links deriving MCP names, then 26 more
* deriving CLI names once the order was flipped.
*
* Reading the finished directories has neither problem: the pages that exist are
* the addresses that exist, which is the only thing a link needs to be true.
*
* Matching is by SLUG, so a capability appears in the row only where a page is
* actually on disk under that name. Where a spelling genuinely differs the
* surface is left out rather than guessed at — a missing link costs a reader one
* search, a wrong one costs them a 404 and their trust in the rest of the row.
*/
type Surface = { key: string; label: string; dir: string; href: (n: string) => string };
const SURFACES: Surface[] = [
{ key: 'api', label: 'API', dir: 'openapi', href: (n) => `/docs/openapi/${n}` },
{ key: 'cli', label: 'CLI', dir: 'cli', href: (n) => `/docs/cli/${n}` },
{ key: 'mcp', label: 'MCP', dir: 'mcp-tools', href: (n) => `/docs/mcp-tools/${n}` },
];
const MARK = 'Also for this capability:';
/** The capability slugs a section actually published, as files or folders. */
function published(dir: string): Set<string> {
const out = new Set<string>();
try {
for (const e of fs.readdirSync(path.join(DOCS, dir), { withFileTypes: true })) {
const n = e.isDirectory() ? e.name : e.name.replace(/\.mdx?$/, '');
if (n && n !== 'index' && n !== 'all-tools') out.add(n);
}
} catch {
/* not generated in this run — contribute nothing rather than a guess */
}
return out;
}
/** The page file for one capability in one section, or null. */
function pageOf(dir: string, name: string): string | null {
for (const p of [path.join(DOCS, dir, `${name}.mdx`), path.join(DOCS, dir, name, 'index.mdx')]) {
if (fs.existsSync(p)) return p;
}
return null;
}
/**
* Insert the row after the frontmatter, replacing one already there so the pass
* is idempotent — it runs on every build over pages the generators just rewrote,
* and a pass that appends would stack a row per build.
*/
function write(file: string, row: string): boolean {
const src = fs.readFileSync(file, 'utf8');
const body = src.replace(new RegExp(`^${MARK}.*$\n\n?`, 'm'), '');
const m = body.match(/^---\n[\s\S]*?\n---\n/);
if (!m) return false;
const next =
body.slice(0, m[0].length) + '\n' + row + '\n\n' + body.slice(m[0].length).replace(/^\n+/, '');
if (next === src) return false;
fs.writeFileSync(file, next, 'utf8');
return true;
}
export function crossLink(): void {
const have = new Map(SURFACES.map((s) => [s.key, published(s.dir)]));
const names = new Set<string>([...have.values()].flatMap((s) => [...s]));
let rows = 0;
for (const name of names) {
const on = SURFACES.filter((s) => have.get(s.key)!.has(name));
// One surface is not a cross-link; it is the page you are already reading.
if (on.length < 2) continue;
for (const here of on) {
const file = pageOf(here.dir, name);
if (!file) continue;
const row =
`${MARK} ` +
on.map((s) => (s.key === here.key ? `**${s.label}**` : `[${s.label}](${s.href(name)})`)).join(' · ') +
' · [SDKs](/docs/sdks)';
if (write(file, row)) rows++;
}
}
console.log(`[cross-link] ${rows} pages, ${names.size} capabilities across ${SURFACES.length} sections`);
}
if (import.meta.main) crossLink();
+7
View File
@@ -3,6 +3,7 @@ import { genOpenapiPages } from './gen-openapi-pages';
import { genFlowPages } from './gen-flow-pages';
import { genMcpPages } from './gen-mcp-pages';
import { genCliPages } from './gen-cli-pages';
import { crossLink } from './cross-link';
import { genPricingPage } from './gen-pricing-page';
import { genKeyTypes } from './gen-key-types';
import { syncCliCommands } from './sync-cli-commands';
@@ -45,6 +46,12 @@ async function main() {
// The CLI's own command table, which has been synced on every build since it
// was added and rendered on no page.
await genCliPages();
// Cross-link the four surfaces, AFTER every generator: each names its pages
// with its own spelling of the capability and the spellings differ in both
// directions, so no generator can be handed its peers' sets. See
// scripts/cross-link.ts, which reads the finished directories instead.
crossLink();
// The key types the authentication page teaches. Also from the document, and
// BEFORE the checks below, because the check reads what it writes.
genKeyTypes();