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
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,21 @@ Add an `openapi` key to your project's `_quarto.yml`:
openapi:
spec: "openapi.json" # path to your OpenAPI 3.x spec (JSON or YAML)
output: "api/index.qmd" # output file path
anchor-style: "operation-id" # anchor ID strategy (default: "operation-id")
```

Both fields are required. Add the output file to `.gitignore` since the extension regenerates it on each render.
`spec` and `output` are required. Add the output file to `.gitignore` since the extension regenerates it on each render.

### `anchor-style`

Controls how anchor IDs are generated for endpoint headings. Optional, defaults to `"operation-id"`.

| Value | Anchor source | Example |
|-------|---------------|---------|
| `"operation-id"` | `operationId` field, falling back to method + path | `#listPets` |
| `"path"` | Always method + path (rapidoc-style) | `#get-/v1/pets` |

Use `"path"` when you need stable anchors that don't change when `operationId` values are renamed, or to preserve compatibility with existing links from a RapiDoc-based site.

## Usage

Expand Down
2 changes: 1 addition & 1 deletion _extensions/quarto-openapi/_extension.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
title: quarto-openapi
author: Posit Software, PBC
version: 0.2.2
version: 0.3.0
quarto-required: ">=1.6.0"
contributes:
metadata:
Expand Down
16 changes: 12 additions & 4 deletions _extensions/quarto-openapi/lib/sections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ export interface Endpoint {
operation: Operation;
}

export interface RenderOptions {
anchorStyle: "operation-id" | "path";
}

const DEFAULT_OPTIONS: RenderOptions = { anchorStyle: "operation-id" };

/**
* Extract the resource name from a path.
* /v1/content/{guid}/bundles -> "content"
Expand Down Expand Up @@ -159,14 +165,14 @@ export function groupByResource(spec: OpenAPISpec): Section[] {
/**
* Render a section with a ## heading and ### per endpoint.
*/
export function renderSection(spec: OpenAPISpec, section: Section): string[] {
export function renderSection(spec: OpenAPISpec, section: Section, options: RenderOptions = DEFAULT_OPTIONS): string[] {
const lines: string[] = [];

lines.push(heading(2, section.name));
lines.push("");

for (const endpoint of section.endpoints) {
lines.push(...renderEndpoint(spec, endpoint));
lines.push(...renderEndpoint(spec, endpoint, options));
lines.push("");
}

Expand Down Expand Up @@ -217,10 +223,12 @@ function shiftHeadings(description: string): string {
return result.join("\n");
}

function renderEndpoint(spec: OpenAPISpec, endpoint: Endpoint): string[] {
function renderEndpoint(spec: OpenAPISpec, endpoint: Endpoint, options: RenderOptions = DEFAULT_OPTIONS): string[] {
const { method, path, operation } = endpoint;
const title = operation.summary || `${methodBadge(method)} ${path}`;
const anchor = operation.operationId || pathToAnchor(method, path);
const anchor = options.anchorStyle === "path"
? pathToAnchor(method, path)
: operation.operationId || pathToAnchor(method, path);

const lines: string[] = [];

Expand Down
15 changes: 13 additions & 2 deletions _extensions/quarto-openapi/openapi-to-markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,14 @@
import { parse as parseYaml, stringify as stringifyYaml } from "stdlib/yaml";
import { join, dirname, extname } from "stdlib/path";
import type { OpenAPISpec } from "./lib/types.ts";
import { groupByResource, renderSection } from "./lib/sections.ts";
import { groupByResource, renderSection, type RenderOptions } from "./lib/sections.ts";

type AnchorStyle = "operation-id" | "path";

interface OpenAPIConfig {
spec: string;
output: string;
"anchor-style"?: AnchorStyle;
}

interface QuartoProject {
Expand Down Expand Up @@ -61,6 +64,14 @@ async function main() {
Deno.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);
}
const anchorStyle: AnchorStyle = config["anchor-style"] ?? "operation-id";
const renderOptions: RenderOptions = { anchorStyle };

// Load the OpenAPI spec
const specPath = join(projectDir, config.spec);
let spec: OpenAPISpec;
Expand Down Expand Up @@ -122,7 +133,7 @@ async function main() {

// Sections
for (const section of sections) {
lines.push(...renderSection(spec, section));
lines.push(...renderSection(spec, section, renderOptions));
}

// Write output
Expand Down
2 changes: 1 addition & 1 deletion example/_extensions/quarto-openapi/_extension.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
title: quarto-openapi
author: Posit Software, PBC
version: 0.2.2
version: 0.3.0
quarto-required: ">=1.6.0"
contributes:
metadata:
Expand Down
16 changes: 12 additions & 4 deletions example/_extensions/quarto-openapi/lib/sections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ export interface Endpoint {
operation: Operation;
}

export interface RenderOptions {
anchorStyle: "operation-id" | "path";
}

const DEFAULT_OPTIONS: RenderOptions = { anchorStyle: "operation-id" };

/**
* Extract the resource name from a path.
* /v1/content/{guid}/bundles -> "content"
Expand Down Expand Up @@ -159,14 +165,14 @@ export function groupByResource(spec: OpenAPISpec): Section[] {
/**
* Render a section with a ## heading and ### per endpoint.
*/
export function renderSection(spec: OpenAPISpec, section: Section): string[] {
export function renderSection(spec: OpenAPISpec, section: Section, options: RenderOptions = DEFAULT_OPTIONS): string[] {
const lines: string[] = [];

lines.push(heading(2, section.name));
lines.push("");

for (const endpoint of section.endpoints) {
lines.push(...renderEndpoint(spec, endpoint));
lines.push(...renderEndpoint(spec, endpoint, options));
lines.push("");
}

Expand Down Expand Up @@ -217,10 +223,12 @@ function shiftHeadings(description: string): string {
return result.join("\n");
}

function renderEndpoint(spec: OpenAPISpec, endpoint: Endpoint): string[] {
function renderEndpoint(spec: OpenAPISpec, endpoint: Endpoint, options: RenderOptions = DEFAULT_OPTIONS): string[] {
const { method, path, operation } = endpoint;
const title = operation.summary || `${methodBadge(method)} ${path}`;
const anchor = operation.operationId || pathToAnchor(method, path);
const anchor = options.anchorStyle === "path"
? pathToAnchor(method, path)
: operation.operationId || pathToAnchor(method, path);

const lines: string[] = [];

Expand Down
15 changes: 13 additions & 2 deletions example/_extensions/quarto-openapi/openapi-to-markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,14 @@
import { parse as parseYaml, stringify as stringifyYaml } from "stdlib/yaml";
import { join, dirname, extname } from "stdlib/path";
import type { OpenAPISpec } from "./lib/types.ts";
import { groupByResource, renderSection } from "./lib/sections.ts";
import { groupByResource, renderSection, type RenderOptions } from "./lib/sections.ts";

type AnchorStyle = "operation-id" | "path";

interface OpenAPIConfig {
spec: string;
output: string;
"anchor-style"?: AnchorStyle;
}

interface QuartoProject {
Expand Down Expand Up @@ -61,6 +64,14 @@ async function main() {
Deno.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);
}
const anchorStyle: AnchorStyle = config["anchor-style"] ?? "operation-id";
const renderOptions: RenderOptions = { anchorStyle };

// Load the OpenAPI spec
const specPath = join(projectDir, config.spec);
let spec: OpenAPISpec;
Expand Down Expand Up @@ -122,7 +133,7 @@ async function main() {

// Sections
for (const section of sections) {
lines.push(...renderSection(spec, section));
lines.push(...renderSection(spec, section, renderOptions));
}

// Write output
Expand Down
64 changes: 64 additions & 0 deletions tests/render_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
import {
groupByResource,
renderSection,
type RenderOptions,
} from "../_extensions/quarto-openapi/lib/sections.ts";
import type { OpenAPISpec } from "../_extensions/quarto-openapi/lib/types.ts";

Expand Down Expand Up @@ -268,6 +269,69 @@ Deno.test("renderSection: sub-heading anchors fall back to method+path slug with
assertStringIncludes(output, '##### 400 {id="get-/v1/pets-400"}');
});

Deno.test("renderSection: anchor-style path forces path anchors even with operationId", () => {
const spec = minimalSpec({
"/v1/pets": {
get: {
operationId: "listPets",
summary: "List all pets",
tags: ["Pets"],
responses: { "200": { description: "OK" } },
},
},
});

const sections = groupByResource(spec);
const output = renderSection(spec, sections[0], { anchorStyle: "path" }).join("\n");

assertStringIncludes(output, '{id="get-/v1/pets"}');
});

Deno.test("renderSection: anchor-style path propagates to sub-anchors", () => {
const spec = minimalSpec({
"/v1/pets": {
get: {
operationId: "listPets",
summary: "List all pets",
tags: ["Pets"],
parameters: [
{ name: "limit", in: "query", schema: { type: "integer" } },
],
responses: {
"200": { description: "OK" },
"400": { description: "Bad request" },
},
},
},
});

const sections = groupByResource(spec);
const output = renderSection(spec, sections[0], { anchorStyle: "path" }).join("\n");

assertStringIncludes(output, '#### Parameters {id="get-/v1/pets-parameters"}');
assertStringIncludes(output, '#### Responses {id="get-/v1/pets-responses"}');
assertStringIncludes(output, '##### 200 {id="get-/v1/pets-200"}');
assertStringIncludes(output, '##### 400 {id="get-/v1/pets-400"}');
});

Deno.test("renderSection: explicit anchor-style operation-id uses operationId", () => {
const spec = minimalSpec({
"/v1/pets": {
get: {
operationId: "listPets",
summary: "List all pets",
tags: ["Pets"],
responses: { "200": { description: "OK" } },
},
},
});

const sections = groupByResource(spec);
const output = renderSection(spec, sections[0], { anchorStyle: "operation-id" }).join("\n");

assertStringIncludes(output, '{id="listPets"}');
});

Deno.test("renderSection: path-level parameters merged into operations", () => {
const spec: OpenAPISpec = {
openapi: "3.0.3",
Expand Down