Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 112 additions & 7 deletions scripts/rewrite_docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@ const OperationMetadata = z.looseObject({
}),
});

/**
* Extensions this script reads off an operation. Declaring them keeps a typo in an
* extension key a compile error rather than a guard that silently never fires.
*/
interface OperationExtensions {
'x-speakeasy-ignore'?: unknown;
'x-vf'?: unknown;
}

enum Method {
GET = 'get',
PUT = 'put',
Expand All @@ -20,28 +29,102 @@ enum Method {
DELETE = 'delete',
}

/**
* Injected descriptions are delimited so a re-run replaces the previous block instead of
* appending a second copy. This matters because `docs/` is NOT regenerated between runs:
* it is produced by cmd/gendocs, which nothing in the repo invokes -- `yarn codegen` is
* `speakeasy run && tsx ./scripts/rewrite_docs.ts`. This script must therefore be
* idempotent against its own previous output.
*/
const DESCRIPTION_BLOCK = /<!-- vf:description -->[\s\S]*?<!-- \/vf:description -->\n?/;
const descriptionBlock = (body: string) => `<!-- vf:description -->\n${body}\n<!-- /vf:description -->\n`;

/** Injected content sits inside a section, so a heading of its own would break the anchors. */
const HEADING_LINE = /^### /m;

const document: OpenAPIV3.Document = JSON.parse(await fs.readFile('openapi.stable.json', 'utf-8'));

for (const operations of Object.values(document.paths).slice(0, 3)) {
/** Commands that carry enrichment but whose docs page is absent — reported once at the end. */
const missingDocsPages: string[] = [];
let candidateCount = 0;
let rewrittenCount = 0;

for (const [path, operations] of Object.entries(document.paths)) {
if (!operations) continue;

for (const method of Object.values(Method)) {
const operation = operations[method];
const operation = operations[method] as (OpenAPIV3.OperationObject & OperationExtensions) | undefined;
if (!operation) continue;

const { cli } = OperationMetadata.parse((operation as any)['x-vf']);
// Speakeasy generates no command for ignored operations, so they have no docs page.
if (operation['x-speakeasy-ignore'] === true) continue;

const metadata = operation['x-vf'];

// Operations without CLI metadata are not exposed as commands and have nothing to
// enrich. Anything else malformed falls through to the parse below and fails loudly.
if (metadata === undefined || metadata === null) continue;
if (typeof metadata === 'object' && !('cli' in metadata)) continue;

let parsed: z.infer<typeof OperationMetadata>;
try {
parsed = OperationMetadata.parse(metadata);
} catch (cause) {
throw new Error(`Invalid x-vf.cli metadata on ${method.toUpperCase()} ${path}`, { cause });
}
const { cli } = parsed;

if (!cli.example && !cli.description) continue;

for (const field of ['example', 'description'] as const) {
const value = cli[field];
if (value && HEADING_LINE.test(value)) {
throw new Error(
`x-vf.cli.${field} on ${method.toUpperCase()} ${path} contains a "### " heading, ` +
'which would break the structure of the docs page it is injected into'
);
}
}

candidateCount += 1;

// Mirrors the filename cmd/gendocs builds: strings.ReplaceAll(cmd.CommandPath(), " ", "_").
// `replace` would substitute only the first space and break every subgrouped command.
const docsPath = `docs/vf_${cli.command.replaceAll(' ', '_')}.md`;

const docsPath = `docs/vf_${cli.command.replace(' ', '_')}.md`;
let markdown = await fs.readFile(docsPath, 'utf-8');
let markdown: string;
try {
markdown = await fs.readFile(docsPath, 'utf-8');
} catch (error) {
// Only a genuinely absent page is tolerable; a permissions or I/O fault is not.
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
missingDocsPages.push(`${cli.command} -> ${docsPath}`);
continue;
}

const original = markdown;

// markdown = markdown.replaceAll(/\[([^\]]+)\]\(([^)]+)\.md\)/, '[$1](./$2)');

// Both injections use replacer functions rather than replacement strings: `$1`, `$&`,
// `$'` and `$$` are substitution directives inside a replacement string, and authored
// shell examples contain them routinely (`awk '{print $1}'`, `$$` for a PID).
if (cli.example) {
markdown = markdown.replace(/(### Examples).+(### Options\n)/s, `$1\n\n${cli.example}\n$2`);
const { example } = cli;
markdown = markdown.replace(
/(### Examples).+?(### Options\n)/s,
(_match, heading: string, options: string) => `${heading}\n\n${example}\n${options}`
);
}

if (cli.description) {
markdown = markdown.replace(/(### Synopsis.+)(### Examples\n)/s, `$1\n${cli.description}\n$2`);
const block = descriptionBlock(cli.description);
markdown = DESCRIPTION_BLOCK.test(markdown)
? markdown.replace(DESCRIPTION_BLOCK, () => block)
: markdown.replace(
/(### Synopsis.+?)(### Examples\n)/s,
(_match, synopsis: string, examples: string) => `${synopsis}${block}${examples}`
);
}

// const lastCommand = cli.command.split(' ').at(-1);
Expand All @@ -53,6 +136,28 @@ for (const operations of Object.values(document.paths).slice(0, 3)) {
// ${markdown}`;
// }

if (markdown === original) continue;

await fs.writeFile(docsPath, markdown, 'utf-8');
rewrittenCount += 1;
}
}

// Every page missing means docs/ was never generated, not that a few commands are new.
// Warning and exiting 0 there would turn a broken pipeline into a green build.
if (candidateCount > 0 && missingDocsPages.length === candidateCount) {
throw new Error(
`No docs page exists for any of the ${candidateCount} command(s) carrying x-vf enrichment. ` +
'docs/ is generated by cmd/gendocs, which `yarn codegen` does not run — regenerate it with `go run ./cmd/gendocs`.'
);
}

if (missingDocsPages.length > 0) {
console.warn(
`[rewrite_docs] ${missingDocsPages.length} command(s) carry x-vf enrichment but have no docs page. ` +
'docs/ is generated by cmd/gendocs — regenerate it with `go run ./cmd/gendocs`:'
);
for (const entry of missingDocsPages) console.warn(` - ${entry}`);
}

console.log(`[rewrite_docs] rewrote ${rewrittenCount} docs page(s)`);
174 changes: 174 additions & 0 deletions test/rewrite-docs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

import { execa } from 'execa';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';

const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const TSX = path.join(ROOT, 'node_modules/.bin/tsx');
const SCRIPT = path.join(ROOT, 'scripts/rewrite_docs.ts');

/** Mirrors the shape cmd/gendocs emits: one each of Synopsis, Examples and Options. */
const DOCS_PAGE = `## vf project get

Get project

### Synopsis

Get a project by ID.

\`\`\`
vf project get [flags]
\`\`\`

### Examples

\`\`\`
generated example
\`\`\`

### Options

\`\`\`
-h, --help help for get
\`\`\`

### Options inherited from parent commands

\`\`\`
--token string Voiceflow bearer token
\`\`\`

### SEE ALSO

* [vf project](vf_project.md)
`;

const spec = (cli: Record<string, unknown>, extras: Record<string, unknown> = {}) => ({
openapi: '3.0.0',
info: { title: 'test', version: '1.0.0' },
paths: { '/v1/stable/project/{projectID}': { get: { 'x-vf': { cli }, ...extras, responses: {} } } },
});

describe('scripts/rewrite_docs.ts', () => {
let cwd: string;

beforeEach(async () => {
cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'rewrite-docs-'));
await fs.mkdir(path.join(cwd, 'docs'));
await fs.writeFile(path.join(cwd, 'docs/vf_project_get.md'), DOCS_PAGE);
});

afterEach(() => fs.rm(cwd, { recursive: true, force: true }));

const write = (document: unknown) => fs.writeFile(path.join(cwd, 'openapi.stable.json'), JSON.stringify(document));

const run = () => execa(TSX, [SCRIPT], { cwd, reject: false });
const page = () => fs.readFile(path.join(cwd, 'docs/vf_project_get.md'), 'utf-8');

it('injects example and description into the generated page', async () => {
await write(
spec({ command: 'project get', example: 'vf project get --id abc', description: 'Fetches one project.' })
);

const result = await run();

expect(result.exitCode).toBe(0);
await expect(page()).resolves.toContain('vf project get --id abc');
await expect(page()).resolves.toContain('Fetches one project.');
await expect(page()).resolves.not.toContain('generated example');
});

// docs/ is produced by cmd/gendocs, which nothing in the repo runs, so this script is
// re-applied to its own previous output. Appending instead of replacing compounds forever.
it('is idempotent across repeated runs', async () => {
await write(spec({ command: 'project get', example: 'vf project get', description: 'Fetches one project.' }));

await run();
const first = await page();
const second = await run();

expect(second.exitCode).toBe(0);
await expect(page()).resolves.toBe(first);
expect(first.match(/Fetches one project\./g)).toHaveLength(1);
});

it('replaces a previously injected description rather than accumulating', async () => {
await write(spec({ command: 'project get', description: 'First.' }));
await run();
await write(spec({ command: 'project get', description: 'Second.' }));
await run();

const content = await page();
expect(content).toContain('Second.');
expect(content).not.toContain('First.');
});

// `$1`, `$&`, `$'` and `$$` are substitution directives in a String.replace replacement
// string, and shell examples contain them routinely.
it('preserves $ sequences in authored examples verbatim', async () => {
const example = `vf project get | awk '{print $1}' && echo "run-$$" && printf $'\\n'`;
await write(spec({ command: 'project get', example }));

await run();

await expect(page()).resolves.toContain(example);
});

it('skips operations Speakeasy is configured to ignore', async () => {
await write(spec({ command: 'project get', example: 'should not appear' }, { 'x-speakeasy-ignore': true }));

const result = await run();

expect(result.exitCode).toBe(0);
await expect(page()).resolves.toBe(DOCS_PAGE);
});

it('warns but succeeds when only some commands lack a docs page', async () => {
await write({
openapi: '3.0.0',
info: { title: 'test', version: '1.0.0' },
paths: {
'/documented': { get: { 'x-vf': { cli: { command: 'project get', example: 'x' } }, responses: {} } },
'/undocumented': { get: { 'x-vf': { cli: { command: 'project ghost', example: 'x' } }, responses: {} } },
},
});

const result = await run();

expect(result.exitCode).toBe(0);
expect(result.stderr).toContain('docs/vf_project_ghost.md');
await expect(page()).resolves.toContain('x');
});

// Failing soft here would turn a completely ungenerated docs/ into a green build.
it('fails when no enriched command has a docs page at all', async () => {
await fs.rm(path.join(cwd, 'docs/vf_project_get.md'));
await write(spec({ command: 'project get', example: 'x' }));

const result = await run();

expect(result.exitCode).toBe(1);
expect(result.stderr).toContain('go run ./cmd/gendocs');
});

it('names the offending operation when cli metadata is malformed', async () => {
await write(spec({ example: 'missing the command field' }));

const result = await run();

expect(result.exitCode).toBe(1);
expect(result.stderr).toContain('GET /v1/stable/project/{projectID}');
});

it('rejects injected content carrying its own heading', async () => {
await write(spec({ command: 'project get', description: 'Intro.\n### Examples\nbroken' }));

const result = await run();

expect(result.exitCode).toBe(1);
expect(result.stderr).toContain('### ');
});
});