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
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.3.0
version: 0.3.1
quarto-required: ">=1.6.0"
contributes:
metadata:
Expand Down
31 changes: 31 additions & 0 deletions _extensions/quarto-openapi/lib/sections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> {
const map = new Map<string, string>();
for (const [path, pathItem] of Object.entries(spec.paths)) {
const resolved = isReference(pathItem)
? resolve<PathItem>(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, string>): 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"
Expand Down
12 changes: 10 additions & 2 deletions _extensions/quarto-openapi/openapi-to-markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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,
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.3.0
version: 0.3.1
quarto-required: ">=1.6.0"
contributes:
metadata:
Expand Down
31 changes: 31 additions & 0 deletions example/_extensions/quarto-openapi/lib/sections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> {
const map = new Map<string, string>();
for (const [path, pathItem] of Object.entries(spec.paths)) {
const resolved = isReference(pathItem)
? resolve<PathItem>(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, string>): 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"
Expand Down
12 changes: 10 additions & 2 deletions example/_extensions/quarto-openapi/openapi-to-markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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,
Expand Down
51 changes: 51 additions & 0 deletions tests/rewrite_refs_test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>();
const input = "See (#listPets).";
const result = rewriteOperationIdRefs(input, idToPath);
assertEquals(result, input);
});
74 changes: 73 additions & 1 deletion tests/sections_test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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);
});