diff --git a/_extensions/quarto-openapi/_extension.yml b/_extensions/quarto-openapi/_extension.yml index 3070bea..8f3273c 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.3.0 +version: 0.3.1 quarto-required: ">=1.6.0" contributes: metadata: diff --git a/_extensions/quarto-openapi/lib/sections.ts b/_extensions/quarto-openapi/lib/sections.ts index 62fdcc0..e4c3114 100644 --- a/_extensions/quarto-openapi/lib/sections.ts +++ b/_extensions/quarto-openapi/lib/sections.ts @@ -43,6 +43,37 @@ export interface RenderOptions { const DEFAULT_OPTIONS: RenderOptions = { anchorStyle: "operation-id" }; +/** + * Build a mapping from operationId to path-style anchor for all endpoints. + */ +export function buildOperationIdToPathMap(spec: OpenAPISpec): Map { + const map = new Map(); + for (const [path, pathItem] of Object.entries(spec.paths)) { + const resolved = isReference(pathItem) + ? resolve(spec, pathItem) + : pathItem; + for (const method of HTTP_METHODS) { + const operation = resolved[method]; + if (!operation || !operation.operationId) continue; + map.set(operation.operationId, pathToAnchor(method, path)); + } + } + return map; +} + +/** + * Rewrite operationId fragment links in markdown text to path-style anchors. + * Matches patterns like (#operationId) and [text](#operationId). + */ +export function rewriteOperationIdRefs(text: string, idToPath: Map): string { + // Match markdown link fragments: (#fragment) where fragment is any non-whitespace, non-paren sequence. + // Covers operationIds with hyphens, dots, digits, etc. + return text.replace(/\(#([^\s)]+)\)/g, (_match, id) => { + const pathAnchor = idToPath.get(id); + return pathAnchor ? `(#${pathAnchor})` : _match; + }); +} + /** * Extract the resource name from a path. * /v1/content/{guid}/bundles -> "content" diff --git a/_extensions/quarto-openapi/openapi-to-markdown.ts b/_extensions/quarto-openapi/openapi-to-markdown.ts index 674620e..43acce2 100644 --- a/_extensions/quarto-openapi/openapi-to-markdown.ts +++ b/_extensions/quarto-openapi/openapi-to-markdown.ts @@ -15,7 +15,7 @@ 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, type RenderOptions } from "./lib/sections.ts"; +import { groupByResource, renderSection, buildOperationIdToPathMap, rewriteOperationIdRefs, type RenderOptions } from "./lib/sections.ts"; type AnchorStyle = "operation-id" | "path"; @@ -136,10 +136,18 @@ async function main() { lines.push(...renderSection(spec, section, renderOptions)); } + // When using path-style anchors, rewrite any operationId cross-references + // (e.g. (#getTask)) in descriptions and schema docs to path-style anchors. + let output = lines.join("\n") + "\n"; + if (anchorStyle === "path") { + const idToPath = buildOperationIdToPathMap(spec); + output = rewriteOperationIdRefs(output, idToPath); + } + // Write output const outputPath = join(projectDir, config.output); await Deno.mkdir(dirname(outputPath), { recursive: true }); - await Deno.writeTextFile(outputPath, lines.join("\n") + "\n"); + await Deno.writeTextFile(outputPath, output); const totalEndpoints = sections.reduce( (sum, s) => sum + s.endpoints.length, diff --git a/example/_extensions/quarto-openapi/_extension.yml b/example/_extensions/quarto-openapi/_extension.yml index 5fc80e2..c13f6af 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.3.0 +version: 0.3.1 quarto-required: ">=1.6.0" contributes: metadata: diff --git a/example/_extensions/quarto-openapi/lib/sections.ts b/example/_extensions/quarto-openapi/lib/sections.ts index 62fdcc0..e4c3114 100644 --- a/example/_extensions/quarto-openapi/lib/sections.ts +++ b/example/_extensions/quarto-openapi/lib/sections.ts @@ -43,6 +43,37 @@ export interface RenderOptions { const DEFAULT_OPTIONS: RenderOptions = { anchorStyle: "operation-id" }; +/** + * Build a mapping from operationId to path-style anchor for all endpoints. + */ +export function buildOperationIdToPathMap(spec: OpenAPISpec): Map { + const map = new Map(); + for (const [path, pathItem] of Object.entries(spec.paths)) { + const resolved = isReference(pathItem) + ? resolve(spec, pathItem) + : pathItem; + for (const method of HTTP_METHODS) { + const operation = resolved[method]; + if (!operation || !operation.operationId) continue; + map.set(operation.operationId, pathToAnchor(method, path)); + } + } + return map; +} + +/** + * Rewrite operationId fragment links in markdown text to path-style anchors. + * Matches patterns like (#operationId) and [text](#operationId). + */ +export function rewriteOperationIdRefs(text: string, idToPath: Map): string { + // Match markdown link fragments: (#fragment) where fragment is any non-whitespace, non-paren sequence. + // Covers operationIds with hyphens, dots, digits, etc. + return text.replace(/\(#([^\s)]+)\)/g, (_match, id) => { + const pathAnchor = idToPath.get(id); + return pathAnchor ? `(#${pathAnchor})` : _match; + }); +} + /** * Extract the resource name from a path. * /v1/content/{guid}/bundles -> "content" diff --git a/example/_extensions/quarto-openapi/openapi-to-markdown.ts b/example/_extensions/quarto-openapi/openapi-to-markdown.ts index 674620e..43acce2 100644 --- a/example/_extensions/quarto-openapi/openapi-to-markdown.ts +++ b/example/_extensions/quarto-openapi/openapi-to-markdown.ts @@ -15,7 +15,7 @@ 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, type RenderOptions } from "./lib/sections.ts"; +import { groupByResource, renderSection, buildOperationIdToPathMap, rewriteOperationIdRefs, type RenderOptions } from "./lib/sections.ts"; type AnchorStyle = "operation-id" | "path"; @@ -136,10 +136,18 @@ async function main() { lines.push(...renderSection(spec, section, renderOptions)); } + // When using path-style anchors, rewrite any operationId cross-references + // (e.g. (#getTask)) in descriptions and schema docs to path-style anchors. + let output = lines.join("\n") + "\n"; + if (anchorStyle === "path") { + const idToPath = buildOperationIdToPathMap(spec); + output = rewriteOperationIdRefs(output, idToPath); + } + // Write output const outputPath = join(projectDir, config.output); await Deno.mkdir(dirname(outputPath), { recursive: true }); - await Deno.writeTextFile(outputPath, lines.join("\n") + "\n"); + await Deno.writeTextFile(outputPath, output); const totalEndpoints = sections.reduce( (sum, s) => sum + s.endpoints.length, diff --git a/tests/rewrite_refs_test.ts b/tests/rewrite_refs_test.ts new file mode 100644 index 0000000..10b61ff --- /dev/null +++ b/tests/rewrite_refs_test.ts @@ -0,0 +1,51 @@ +import { + assertEquals, +} from "https://deno.land/std@0.224.0/assert/mod.ts"; +import { + rewriteOperationIdRefs, +} from "../_extensions/quarto-openapi/lib/sections.ts"; + +Deno.test("rewriteOperationIdRefs: rewrites matching operationId fragment", () => { + const idToPath = new Map([["listPets", "get-/v1/pets"]]); + const input = "See [List pets](#listPets) for details."; + const result = rewriteOperationIdRefs(input, idToPath); + assertEquals(result, "See [List pets](#get-/v1/pets) for details."); +}); + +Deno.test("rewriteOperationIdRefs: leaves non-matching fragments unchanged", () => { + const idToPath = new Map([["listPets", "get-/v1/pets"]]); + const input = "See [other](#someOtherSection) for details."; + const result = rewriteOperationIdRefs(input, idToPath); + assertEquals(result, input); +}); + +Deno.test("rewriteOperationIdRefs: rewrites multiple fragments in one string", () => { + const idToPath = new Map([ + ["listPets", "get-/v1/pets"], + ["createPet", "post-/v1/pets"], + ]); + const input = "See (#listPets) and (#createPet)."; + const result = rewriteOperationIdRefs(input, idToPath); + assertEquals(result, "See (#get-/v1/pets) and (#post-/v1/pets)."); +}); + +Deno.test("rewriteOperationIdRefs: handles operationIds with hyphens", () => { + const idToPath = new Map([["list-pets", "get-/v1/pets"]]); + const input = "See [List pets](#list-pets)."; + const result = rewriteOperationIdRefs(input, idToPath); + assertEquals(result, "See [List pets](#get-/v1/pets)."); +}); + +Deno.test("rewriteOperationIdRefs: handles operationIds with dots", () => { + const idToPath = new Map([["pets.list", "get-/v1/pets"]]); + const input = "See (#pets.list)."; + const result = rewriteOperationIdRefs(input, idToPath); + assertEquals(result, "See (#get-/v1/pets)."); +}); + +Deno.test("rewriteOperationIdRefs: no-op on empty map", () => { + const idToPath = new Map(); + const input = "See (#listPets)."; + const result = rewriteOperationIdRefs(input, idToPath); + assertEquals(result, input); +}); diff --git a/tests/sections_test.ts b/tests/sections_test.ts index 68791fc..8a20f04 100644 --- a/tests/sections_test.ts +++ b/tests/sections_test.ts @@ -1,5 +1,5 @@ import { assertEquals } from "https://deno.land/std@0.224.0/assert/mod.ts"; -import { groupByResource } from "../_extensions/quarto-openapi/lib/sections.ts"; +import { groupByResource, buildOperationIdToPathMap, rewriteOperationIdRefs } from "../_extensions/quarto-openapi/lib/sections.ts"; import type { OpenAPISpec } from "../_extensions/quarto-openapi/lib/types.ts"; function minimalSpec(paths: OpenAPISpec["paths"]): OpenAPISpec { @@ -270,3 +270,75 @@ Deno.test("tictactoe spec groups by tag Gameplay, not path board", () => { assertEquals(sections[0].name, "Gameplay"); assertEquals(sections[0].endpoints.length, 3); }); + +Deno.test("buildOperationIdToPathMap maps operationIds to path-style anchors", () => { + const spec = minimalSpec({ + "/v1/content": { + get: { + operationId: "getContents", + summary: "List content", + responses: { "200": { description: "OK" } }, + }, + post: { + operationId: "createContent", + summary: "Create content", + responses: { "201": { description: "Created" } }, + }, + }, + "/v1/content/{guid}": { + get: { + operationId: "getContent", + summary: "Get content", + responses: { "200": { description: "OK" } }, + }, + }, + "/v1/tasks/{id}": { + get: { + operationId: "getTask", + summary: "Get task", + responses: { "200": { description: "OK" } }, + }, + }, + }); + + const map = buildOperationIdToPathMap(spec); + + assertEquals(map.get("getContents"), "get-/v1/content"); + assertEquals(map.get("createContent"), "post-/v1/content"); + assertEquals(map.get("getContent"), "get-/v1/content/-guid-"); + assertEquals(map.get("getTask"), "get-/v1/tasks/-id-"); + assertEquals(map.has("nonExistent"), false); +}); + +Deno.test("rewriteOperationIdRefs rewrites operationId fragments to path-style", () => { + const idToPath = new Map([ + ["getTask", "get-/v1/tasks/-id-"], + ["createGroup", "post-/v1/groups"], + ["getUsers", "get-/v1/users"], + ]); + + const input = "See [Get Task](#getTask) and [Create Group](#createGroup) for details."; + const expected = "See [Get Task](#get-/v1/tasks/-id-) and [Create Group](#post-/v1/groups) for details."; + + assertEquals(rewriteOperationIdRefs(input, idToPath), expected); +}); + +Deno.test("rewriteOperationIdRefs does not rewrite unknown IDs", () => { + const idToPath = new Map([ + ["getTask", "get-/v1/tasks/-id-"], + ]); + + const input = "See [Authorization](#Authorization) section."; + assertEquals(rewriteOperationIdRefs(input, idToPath), input); +}); + +Deno.test("rewriteOperationIdRefs handles multiple occurrences", () => { + const idToPath = new Map([ + ["getTask", "get-/v1/tasks/-id-"], + ]); + + const input = "Call (#getTask) first, then (#getTask) again."; + const expected = "Call (#get-/v1/tasks/-id-) first, then (#get-/v1/tasks/-id-) again."; + + assertEquals(rewriteOperationIdRefs(input, idToPath), expected); +});