Skip to content
Merged
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
12 changes: 10 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: denoland/setup-deno@v2
- run: deno test tests/
- uses: actions/setup-node@v4
with:
node-version: "24"
- run: npm ci
# The pre-render script imports `yaml`; typecheck needs it resolvable.
- run: npm ci --prefix _extensions/quarto-openapi
- run: npm run typecheck
- run: npm test
- name: Check the example copy of the extension is in sync
run: npm run sync-example -- --check
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
node_modules/
3 changes: 0 additions & 3 deletions .vscode/settings.json

This file was deleted.

43 changes: 42 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,25 @@ A Quarto extension that generates API reference documentation from an OpenAPI 3.

The extension runs as a pre-render script: it reads your OpenAPI spec and generates a single `.qmd` page with the full API reference. The page includes sections grouped by resource, endpoint details, parameter tables, request/response schemas, and anchor IDs.

## Requirements

The pre-render script runs on Node.js 22.6 or later, which Quarto must be able
to find on `PATH` (or via `QUARTO_NODE`), and npm to install its one
dependency. Quarto 2 runs `.ts` render scripts with Node directly; Quarto 1
runs them with its own bundled runtime, which supports the same imports.

## Installation

```bash
quarto add posit-dev/quarto-openapi
npm ci --prefix _extensions/posit-dev/quarto-openapi
```

The second command installs the `yaml` package the pre-render script imports.
Node resolves bare imports from the `node_modules` nearest the script, so it
goes beside the script rather than in your project root. Repeat it after
`quarto update`.

## Configuration

Add an `openapi` key to your project's `_quarto.yml`:
Expand Down Expand Up @@ -119,18 +132,46 @@ quarto-openapi/
_extensions/
quarto-openapi/
_extension.yml # metadata extension manifest
openapi-to-markdown.ts # pre-render entry point (Deno/TypeScript)
openapi-to-markdown.ts # pre-render entry point (Node/TypeScript)
package.json # the script's `yaml` dependency
package-lock.json # pinned; `npm ci` installs from it
lib/
types.ts # OpenAPI 3.0.x type definitions
refs.ts # $ref resolution
sections.ts # path grouping and endpoint rendering
schema.ts # schema-to-table conversion
markdown.ts # grid table and markdown utilities
tests/ # node:test suites for lib/
scripts/
sync-example.ts # copies the extension into example/
example/ # working example (Tic Tac Toe API)
_quarto.yml
openapi.json
```

## Development

```bash
npm install # dev dependencies: TypeScript and its Node types
npm run setup # extension dependencies, and the example's copy of them
npm test # node --test over tests/
npm run typecheck # tsc --noEmit; Node strips types but never checks them
npm run sync-example # refresh example/_extensions from _extensions
```

`npm run setup` is needed once before `npm run typecheck` or rendering the
example, since both need `yaml` resolvable.

`example/_extensions/posit-dev/quarto-openapi` is a copy of the extension
source at the path `quarto add` installs to, so the example runs the same code
a user gets. Run `npm run sync-example` after changing the extension; CI fails
if the copy is stale. Dependencies are excluded from the copy — the example
installs its own.

Only TypeScript syntax that Node can erase is allowed, since Node strips types
rather than compiling them — no `enum`, `namespace`, or parameter properties.
`tsconfig.json` sets `erasableSyntaxOnly` to catch this at typecheck time.

## Limitations

- OpenAPI 3.x only. Swagger 2.0 specs are not supported. Use a tool like [swagger2openapi](https://github.com/Mermade/oas-kit/tree/main/packages/swagger2openapi) to convert.
39 changes: 23 additions & 16 deletions _extensions/quarto-openapi/openapi-to-markdown.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,23 @@
#!/usr/bin/env -S quarto run
#!/usr/bin/env node

/**
* Pre-render script for the quarto-openapi extension.
*
* Reads an OpenAPI 3.x spec and generates a single .qmd file
* with the full API reference.
*
* Runs under node (>= 22.6, for TypeScript type stripping). The `yaml`
* dependency is installed in this extension's directory; see package.json.
*
* Configuration is read from _quarto.yml under the "openapi" key:
* openapi:
* spec: "api/openapi.json"
* output: "api/index.qmd"
*/

import { parse as parseYaml, stringify as stringifyYaml } from "stdlib/yaml";
import { join, dirname, extname } from "stdlib/path";
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
import { join, dirname, extname } from "node:path";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import type { OpenAPISpec } from "./lib/types.ts";
import { groupByResource, renderApiReferenceBody } from "./lib/sections.ts";

Expand All @@ -30,23 +34,26 @@ interface QuartoProject {
}

async function main() {
const projectDir = Deno.env.get("QUARTO_PROJECT_DIR");
const projectDir = process.env.QUARTO_PROJECT_DIR;
if (!projectDir) {
console.error(
"QUARTO_PROJECT_DIR not set. This script must run as a Quarto pre-render script.",
);
Deno.exit(1);
process.exit(1);
}

// Read _quarto.yml
const quartoYmlPath = join(projectDir, "_quarto.yml");
let quartoYml: QuartoProject;
try {
const content = await Deno.readTextFile(quartoYmlPath);
quartoYml = parseYaml(content) as QuartoProject;
const content = await readFile(quartoYmlPath, "utf8");
// Quarto 2 config files may tag strings with `!path`; resolve the tag to
// the plain string instead of warning about it once per occurrence.
const pathTag = { tag: "!path", resolve: (str: string) => str };
quartoYml = parseYaml(content, { customTags: [pathTag] }) as QuartoProject;
} catch (e) {
console.error(`Failed to read ${quartoYmlPath}: ${e}`);
Deno.exit(1);
process.exit(1);
}

const config = quartoYml.openapi;
Expand All @@ -57,25 +64,25 @@ async function main() {

if (!config.spec) {
console.error("openapi.spec is required in _quarto.yml");
Deno.exit(1);
process.exit(1);
}
if (!config.output) {
console.error("openapi.output is required in _quarto.yml");
Deno.exit(1);
process.exit(1);
}

const validAnchorStyles: AnchorStyle[] = ["operation-id", "path"];
if (config["anchor-style"] && !validAnchorStyles.includes(config["anchor-style"])) {
console.error(`openapi.anchor-style must be one of: ${validAnchorStyles.join(", ")}`);
Deno.exit(1);
process.exit(1);
}
const anchorStyle: AnchorStyle = config["anchor-style"] ?? "operation-id";

// Load the OpenAPI spec
const specPath = join(projectDir, config.spec);
let spec: OpenAPISpec;
try {
const content = await Deno.readTextFile(specPath);
const content = await readFile(specPath, "utf8");
const ext = extname(specPath).toLowerCase();
if (ext === ".json") {
spec = JSON.parse(content);
Expand All @@ -84,15 +91,15 @@ async function main() {
}
} catch (e) {
console.error(`Failed to read spec at ${specPath}: ${e}`);
Deno.exit(1);
process.exit(1);
}

// Validate it looks like OpenAPI 3.x
if (!spec.openapi || !spec.openapi.startsWith("3.")) {
console.error(
`Expected OpenAPI 3.x spec, got version: ${spec.openapi || "unknown"}`,
);
Deno.exit(1);
process.exit(1);
}

console.log(`Loaded OpenAPI ${spec.openapi} spec: ${spec.info.title}`);
Expand Down Expand Up @@ -140,8 +147,8 @@ async function main() {

// Write output
const outputPath = join(projectDir, config.output);
await Deno.mkdir(dirname(outputPath), { recursive: true });
await Deno.writeTextFile(outputPath, output);
await mkdir(dirname(outputPath), { recursive: true });
await writeFile(outputPath, output);

const totalEndpoints = sections.reduce(
(sum, s) => sum + s.endpoints.length,
Expand Down
28 changes: 28 additions & 0 deletions _extensions/quarto-openapi/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions _extensions/quarto-openapi/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"name": "quarto-openapi-extension",
"private": true,
"type": "module",
"description": "Dependencies for the quarto-openapi pre-render script. node resolves bare specifiers from node_modules upward from the script's directory, so they must be installed here, not at the repo root.",
"dependencies": {
"yaml": "^2.9.0"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ contributes:
metadata:
project:
pre-render:
- _extensions/quarto-openapi/openapi-to-markdown.ts
- _extensions/posit-dev/quarto-openapi/openapi-to-markdown.ts
format:
html:
css:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,19 @@ export interface TableRow {
cells: string[];
}

/** Fenced div markers wrapping every gridTable() block. */
export const TABLE_DIV_OPEN = "::: {.quarto-openapi-table}";
export const TABLE_DIV_CLOSE = ":::";

/**
* Generate a Pandoc grid table.
*
* Grid tables support multi-line cells and are the most flexible
* table format in Pandoc/Quarto.
*
* Grid tables are fixed-width: cells must be in their final form, because
* changing a cell's length afterward breaks the alignment Pandoc needs to
* read the table.
*/
export function gridTable(headers: string[], rows: TableRow[]): string[] {
if (rows.length === 0) return [];
Expand Down Expand Up @@ -55,7 +63,7 @@ export function gridTable(headers: string[], rows: TableRow[]): string[] {
}
};

lines.push("::: {.quarto-openapi-table}");
lines.push(TABLE_DIV_OPEN);
lines.push(separator("-"));
emitRow(headerLines);
lines.push(separator("="));
Expand All @@ -65,7 +73,7 @@ export function gridTable(headers: string[], rows: TableRow[]): string[] {
lines.push(separator("-"));
}

lines.push(":::");
lines.push(TABLE_DIV_CLOSE);

return lines;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,48 @@ export function rewriteOperationIdRefs(text: string, idToPath: Map<string, strin
return lines.join("\n");
}

/**
* Rewrite operationId refs in every description and summary field of the
* spec, in place. Runs before any rendering so tables are laid out with
* final text (see gridTable).
*/
export function rewriteSpecRefs(spec: OpenAPISpec, idToPath: Map<string, string>): void {
const walk = (node: unknown): void => {
if (Array.isArray(node)) {
for (const item of node) walk(item);
return;
}
if (node === null || typeof node !== "object") return;
for (const [key, value] of Object.entries(node as Record<string, unknown>)) {
if ((key === "description" || key === "summary") && typeof value === "string") {
(node as Record<string, unknown>)[key] = rewriteOperationIdRefs(value, idToPath);
} else {
walk(value);
}
}
};
walk(spec);
}

/**
* Render the full API reference body: rewrite spec refs for the requested
* anchor style (mutating the spec), then render every resource section.
* Both openapi-to-markdown.ts and the tests go through this entry point.
*/
export function renderApiReferenceBody(
spec: OpenAPISpec,
anchorStyle: RenderOptions["anchorStyle"],
): string[] {
if (anchorStyle === "path") {
rewriteSpecRefs(spec, buildOperationIdToPathMap(spec));
}
const lines: string[] = [];
for (const section of groupByResource(spec)) {
lines.push(...renderSection(spec, section, { anchorStyle }));
}
return lines;
}

/**
* Extract the resource name from a path.
* /v1/content/{guid}/bundles -> "content"
Expand Down Expand Up @@ -297,14 +339,24 @@ function renderEndpoint(spec: OpenAPISpec, endpoint: Endpoint, options: RenderOp
lines.push(`\`${methodBadge(method)} ${path}\``);
lines.push("");

// Deprecated badge
// Deprecated callout
if (operation.deprecated) {
lines.push("::: {.callout-warning}");
lines.push('::: {.callout-warning title="Deprecated"}');
lines.push("This endpoint is deprecated.");
lines.push(":::");
lines.push("");
}

// Experimental callout
if (operation["x-experimental"]) {
lines.push('::: {.callout-note title="Experimental"}');
lines.push(
"This endpoint is experimental and may change or be removed in a future release without notice.",
);
lines.push(":::");
lines.push("");
}

// Description — shift headings so they nest below the endpoint h3
if (operation.description) {
lines.push(shiftHeadings(operation.description));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ export interface Operation {
responses: Record<string, Response | Reference>;
security?: SecurityRequirement[];
deprecated?: boolean;
// OpenAPI vendor extension. The renderer relies only on truthiness; do not
// compare with === true.
"x-experimental"?: boolean;
}

export interface Parameter {
Expand Down
Loading
Loading