From 6d08499b20c4d4a124b062a76961c085c69434fe Mon Sep 17 00:00:00 2001 From: edavidaja Date: Mon, 13 Apr 2026 17:12:12 -0400 Subject: [PATCH 1/2] support rapidoc-style paths --- README.md | 14 +++- _extensions/quarto-openapi/lib/sections.ts | 16 +++-- .../quarto-openapi/openapi-to-markdown.ts | 15 ++++- .../quarto-openapi/lib/sections.ts | 16 +++-- .../quarto-openapi/openapi-to-markdown.ts | 15 ++++- tests/render_test.ts | 64 +++++++++++++++++++ 6 files changed, 127 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 3e6a3ca..d7420a9 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/_extensions/quarto-openapi/lib/sections.ts b/_extensions/quarto-openapi/lib/sections.ts index 01e010a..62fdcc0 100644 --- a/_extensions/quarto-openapi/lib/sections.ts +++ b/_extensions/quarto-openapi/lib/sections.ts @@ -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" @@ -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(""); } @@ -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[] = []; diff --git a/_extensions/quarto-openapi/openapi-to-markdown.ts b/_extensions/quarto-openapi/openapi-to-markdown.ts index 89a4ff7..674620e 100644 --- a/_extensions/quarto-openapi/openapi-to-markdown.ts +++ b/_extensions/quarto-openapi/openapi-to-markdown.ts @@ -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 { @@ -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; @@ -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 diff --git a/example/_extensions/quarto-openapi/lib/sections.ts b/example/_extensions/quarto-openapi/lib/sections.ts index 01e010a..62fdcc0 100644 --- a/example/_extensions/quarto-openapi/lib/sections.ts +++ b/example/_extensions/quarto-openapi/lib/sections.ts @@ -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" @@ -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(""); } @@ -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[] = []; diff --git a/example/_extensions/quarto-openapi/openapi-to-markdown.ts b/example/_extensions/quarto-openapi/openapi-to-markdown.ts index 89a4ff7..674620e 100644 --- a/example/_extensions/quarto-openapi/openapi-to-markdown.ts +++ b/example/_extensions/quarto-openapi/openapi-to-markdown.ts @@ -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 { @@ -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; @@ -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 diff --git a/tests/render_test.ts b/tests/render_test.ts index 196e336..bde6a56 100644 --- a/tests/render_test.ts +++ b/tests/render_test.ts @@ -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"; @@ -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", From 1c8b6ed6beee0ffc0b7a1ce2fc5de04c62076db2 Mon Sep 17 00:00:00 2001 From: edavidaja Date: Mon, 13 Apr 2026 17:13:04 -0400 Subject: [PATCH 2/2] bump version --- _extensions/quarto-openapi/_extension.yml | 2 +- example/_extensions/quarto-openapi/_extension.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/_extensions/quarto-openapi/_extension.yml b/_extensions/quarto-openapi/_extension.yml index 51b08b2..3070bea 100644 --- a/_extensions/quarto-openapi/_extension.yml +++ b/_extensions/quarto-openapi/_extension.yml @@ -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: diff --git a/example/_extensions/quarto-openapi/_extension.yml b/example/_extensions/quarto-openapi/_extension.yml index 6d2e269..5fc80e2 100644 --- a/example/_extensions/quarto-openapi/_extension.yml +++ b/example/_extensions/quarto-openapi/_extension.yml @@ -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: