mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-24 20:09:02 +00:00
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:
@@ -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();
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user