diff --git a/_extensions/quarto-openapi/_extension.yml b/_extensions/quarto-openapi/_extension.yml index f5c7f0e..51b08b2 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.1 +version: 0.2.2 quarto-required: ">=1.6.0" contributes: metadata: diff --git a/_extensions/quarto-openapi/lib/sections.ts b/_extensions/quarto-openapi/lib/sections.ts index 143bd15..01e010a 100644 --- a/_extensions/quarto-openapi/lib/sections.ts +++ b/_extensions/quarto-openapi/lib/sections.ts @@ -249,7 +249,7 @@ function renderEndpoint(spec: OpenAPISpec, endpoint: Endpoint): string[] { // Parameters const paramLines = renderParameters(spec, operation, path); if (paramLines.length > 0) { - lines.push(heading(4, "Parameters")); + lines.push(heading(4, "Parameters", `${anchor}-parameters`)); lines.push(""); lines.push(...paramLines); lines.push(""); @@ -258,16 +258,16 @@ function renderEndpoint(spec: OpenAPISpec, endpoint: Endpoint): string[] { // Request body const bodyLines = renderRequestBody(spec, operation); if (bodyLines.length > 0) { - lines.push(heading(4, "Request body")); + lines.push(heading(4, "Request body", `${anchor}-request-body`)); lines.push(""); lines.push(...bodyLines); lines.push(""); } // Responses - const responseLines = renderResponses(spec, operation); + const responseLines = renderResponses(spec, operation, anchor); if (responseLines.length > 0) { - lines.push(heading(4, "Responses")); + lines.push(heading(4, "Responses", `${anchor}-responses`)); lines.push(""); lines.push(...responseLines); lines.push(""); @@ -371,6 +371,7 @@ function renderRequestBody( function renderResponses( spec: OpenAPISpec, operation: Operation, + anchor: string, ): string[] { const entries = Object.entries(operation.responses).sort( ([a], [b]) => a.localeCompare(b), @@ -402,7 +403,7 @@ function renderResponses( "::: {.panel-tabset}", "", ...tabs.flatMap((tab) => [ - `##### ${tab.label}`, + heading(5, tab.label, `${anchor}-${tab.label}`), "", ...tab.content, "", diff --git a/example/_extensions/quarto-openapi/_extension.yml b/example/_extensions/quarto-openapi/_extension.yml index 0dd8310..6d2e269 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.1 +version: 0.2.2 quarto-required: ">=1.6.0" contributes: metadata: diff --git a/tests/render_test.ts b/tests/render_test.ts index cd5630f..196e336 100644 --- a/tests/render_test.ts +++ b/tests/render_test.ts @@ -99,6 +99,29 @@ Deno.test("renderSection: parameters rendered as grid table", () => { assertStringIncludes(output, "Max items"); }); +Deno.test("renderSection: parameters heading has scoped anchor ID", () => { + const spec = minimalSpec({ + "/v1/pets": { + get: { + operationId: "listPets", + summary: "List pets", + parameters: [ + { + name: "limit", + in: "query", + schema: { type: "integer" }, + }, + ], + responses: { "200": { description: "OK" } }, + }, + }, + }); + + const output = renderedSection(spec); + + assertStringIncludes(output, '#### Parameters {id="listPets-parameters"}'); +}); + Deno.test("renderSection: request body schema rendered", () => { const spec = minimalSpec({ "/v1/pets": { @@ -129,6 +152,50 @@ Deno.test("renderSection: request body schema rendered", () => { assertStringIncludes(output, "Pet name"); }); +Deno.test("renderSection: request body heading has scoped anchor ID", () => { + const spec = minimalSpec({ + "/v1/pets": { + post: { + operationId: "createPet", + summary: "Create a pet", + requestBody: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + name: { type: "string" }, + }, + }, + }, + }, + }, + responses: { "201": { description: "Created" } }, + }, + }, + }); + + const output = renderedSection(spec); + + assertStringIncludes(output, '#### Request body {id="createPet-request-body"}'); +}); + +Deno.test("renderSection: responses heading has scoped anchor ID", () => { + const spec = minimalSpec({ + "/v1/pets": { + get: { + operationId: "listPets", + summary: "List pets", + responses: { "200": { description: "OK" } }, + }, + }, + }); + + const output = renderedSection(spec); + + assertStringIncludes(output, '#### Responses {id="listPets-responses"}'); +}); + Deno.test("renderSection: multiple responses render as tabset", () => { const spec = minimalSpec({ "/v1/pets/{id}": { @@ -150,6 +217,57 @@ Deno.test("renderSection: multiple responses render as tabset", () => { assertStringIncludes(output, "**404**: Not found"); }); +Deno.test("renderSection: response code tab headings have scoped anchor IDs", () => { + const spec = minimalSpec({ + "/v1/pets/{id}": { + get: { + operationId: "getPet", + summary: "Get a pet", + responses: { + "200": { description: "OK" }, + "404": { description: "Not found" }, + }, + }, + }, + }); + + const output = renderedSection(spec); + + assertStringIncludes(output, '##### 200 {id="getPet-200"}'); + assertStringIncludes(output, '##### 404 {id="getPet-404"}'); +}); + +Deno.test("renderSection: sub-heading anchors fall back to method+path slug without operationId", () => { + const spec = minimalSpec({ + "/v1/pets": { + get: { + summary: "List pets", + parameters: [ + { + name: "limit", + in: "query", + schema: { type: "integer" }, + }, + ], + responses: { + "200": { description: "OK" }, + "400": { description: "Bad request" }, + }, + }, + }, + }); + + const output = renderedSection(spec); + + // Endpoint heading uses path-based anchor + assertStringIncludes(output, '{id="get-/v1/pets"}'); + // Sub-headings use the same slug as prefix + 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: path-level parameters merged into operations", () => { const spec: OpenAPISpec = { openapi: "3.0.3",