diff --git a/scripts/rewrite_docs.ts b/scripts/rewrite_docs.ts index ab7a023..0139725 100644 --- a/scripts/rewrite_docs.ts +++ b/scripts/rewrite_docs.ts @@ -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', @@ -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 = /[\s\S]*?\n?/; +const descriptionBlock = (body: string) => `\n${body}\n\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; + 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); @@ -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)`); diff --git a/test/rewrite-docs.test.ts b/test/rewrite-docs.test.ts new file mode 100644 index 0000000..a21d52c --- /dev/null +++ b/test/rewrite-docs.test.ts @@ -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, extras: Record = {}) => ({ + 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('### '); + }); +});