");
+ for (const header of ["Name", "Type", "Description"]) {
+ assertStringIncludes(table, `| ${header} | `);
+ }
+
+ // Body cells keep their inline markup.
+ assertStringIncludes(table, "widget_id");
+ assertStringIncludes(table, "integer");
+ assertStringIncludes(table, "Page size.");
+
+ // A cell holding two blocks stays two paragraphs.
+ assertStringIncludes(
+ table,
+ "First paragraph.
\nSecond paragraph.
",
+ );
+
+ // One column per header, and `widths` reaches the rendered proportions.
+ const widths = [...table.matchAll(//g)]
+ .map((match) => Number(match[1]));
+ assertEquals(widths.length, 3);
+ assert(
+ widths[2] > widths[0] && widths[2] > widths[1],
+ `expected the description column to be widest, got ${widths.join(",")}`,
+ );
+});
+
+test("Quarto renders a fenced cell and escaped braces verbatim", { skip }, async () => {
+ // Cell content is indented to the item's content level, so a fence inside a
+ // cell is an indented fence. Quarto has to read it as code, and the escaped
+ // braces in the prose cell beside it have to reach the reader as literal
+ // braces rather than as backslashes or as an attribute block.
+ const markdown = escapePathBracesOutsideCode([
+ "---",
+ "title: Path parameters",
+ "---",
+ "",
+ ...listTable(
+ ["Name", "Description"],
+ [
+ {
+ cells: [
+ "`widget-id`",
+ "Example:\n\n```bash\ncurl /v1/widgets/{widget-id}\n```",
+ ],
+ },
+ { cells: ["`key2`", "Passed to GET /v1/widgets/{widget-id}/keys/{key2}."] },
+ ],
+ ),
+ "",
+ ].join("\n"));
+
+ const table = tableOf(await render(markdown));
+
+ assertStringIncludes(table, ' {
+test("renderApiReferenceBody: refs are rewritten before anything is rendered", () => {
const spec = specWithRequestBodyDescription(
"The ID of the pet to adopt. Obtain it from the " +
"[GET /v1/pets](#listPets) endpoint before creating an adoption.",
@@ -171,9 +171,6 @@ test("renderApiReferenceBody: rewritten link in a request body table stays well-
const block = findTableBlocks(output).find((b) => b.some((l) => l.includes("pet_id")));
assert(block, "expected to find the pet_id property table in the rendered output");
- for (const line of block) {
- assertEquals(line.length, block[0].length, `off-width line: ${JSON.stringify(line)}`);
- }
assert(
block.some((l) => l.includes("#get-/v1/pets")),
"expected the operationId anchor to be rewritten to its path-style form",
diff --git a/tests/schema_test.ts b/tests/schema_test.ts
index d248bfb..e43d3b3 100644
--- a/tests/schema_test.ts
+++ b/tests/schema_test.ts
@@ -22,7 +22,7 @@ function rendered(spec: OpenAPISpec, schema: Schema): string {
return renderSchema(spec, schema).join("\n");
}
-test("renderSchema: simple object produces a grid table with properties", () => {
+test("renderSchema: simple object produces a table with properties", () => {
const spec = specWithSchemas();
const schema: Schema = {
type: "object",
@@ -40,8 +40,8 @@ test("renderSchema: simple object produces a grid table with properties", () =>
assertStringIncludes(output, "`age`");
assertStringIncludes(output, "`integer`");
assertStringIncludes(output, "Age in years");
- // Grid table markers
- assertStringIncludes(output, "+===");
+ // List table markers
+ assertStringIncludes(output, '::: {.list-table header-rows="1"');
});
test("renderSchema: nested object flattens with dotted names", () => {
@@ -204,7 +204,7 @@ test("renderSchema: top-level fields have no span wrapper", () => {
const output = rendered(spec, schema);
// Top-level field should be plain backtick-quoted, not wrapped in a span
- assertStringIncludes(output, "| `name`");
+ assertStringIncludes(output, "* * `name`");
assert(!output.includes("{.schema-nest"), "top-level fields should not have nesting class");
});
@@ -404,3 +404,28 @@ test("renderSchema: top-level any map renders 'Map of string to any'", () => {
assertStringIncludes(output, "Map of string to any");
});
+
+test("renderSchema: an empty-string default renders as a visible empty string", () => {
+ const spec = specWithSchemas();
+ const schema: Schema = {
+ type: "object",
+ properties: { prefix: { type: "string", default: "" } },
+ };
+
+ const output = rendered(spec, schema);
+
+ // An empty code span would render as nothing at all
+ assertStringIncludes(output, 'Default: `""`');
+});
+
+test("renderSchema: enum values go through the same formatter as defaults", () => {
+ const spec = specWithSchemas();
+ const schema: Schema = {
+ type: "object",
+ properties: { mode: { type: "string", enum: ["", "fast", null] } },
+ };
+
+ const output = rendered(spec, schema);
+
+ assertStringIncludes(output, 'Enum: `""`, `fast`, `null`');
+});
diff --git a/tests/sections_test.ts b/tests/sections_test.ts
index f33e4b8..193c856 100644
--- a/tests/sections_test.ts
+++ b/tests/sections_test.ts
@@ -1,6 +1,6 @@
import { test } from "node:test";
-import { assertEquals } from "./assert.ts";
-import { groupByResource, buildOperationIdToPathMap, rewriteOperationIdRefs } from "../_extensions/quarto-openapi/lib/sections.ts";
+import { assert, assertEquals } from "./assert.ts";
+import { groupByResource, buildOperationIdToPathMap, renderApiReferenceBody, rewriteOperationIdRefs } from "../_extensions/quarto-openapi/lib/sections.ts";
import type { OpenAPISpec } from "../_extensions/quarto-openapi/lib/types.ts";
function minimalSpec(paths: OpenAPISpec["paths"]): OpenAPISpec {
@@ -343,3 +343,168 @@ test("rewriteOperationIdRefs handles multiple occurrences", () => {
assertEquals(rewriteOperationIdRefs(input, idToPath), expected);
});
+
+test("renderApiReferenceBody: disambiguates a group heading against a prose anchor", () => {
+ const spec = minimalSpec({
+ "/v1/keys": {
+ get: {
+ operationId: "listKeys",
+ summary: "List keys",
+ tags: ["API Keys"],
+ responses: { "200": { description: "OK" } },
+ },
+ },
+ });
+ spec.info.description = "## API Keys {#api-keys}\n\nHow to authenticate.";
+
+ const body = renderApiReferenceBody(spec, "operation-id").join("\n");
+
+ assert(
+ body.includes('## API Keys {id="api-keys-1"}'),
+ `expected a disambiguated heading, got:\n${body.slice(0, 300)}`,
+ );
+});
+
+test("renderApiReferenceBody: leaves an uncontested group heading bare", () => {
+ const spec = minimalSpec({
+ "/v1/users": {
+ get: {
+ operationId: "listUsers",
+ summary: "List users",
+ tags: ["Users"],
+ responses: { "200": { description: "OK" } },
+ },
+ },
+ });
+
+ const body = renderApiReferenceBody(spec, "operation-id").join("\n");
+
+ assert(body.includes("## Users\n"), `expected a bare heading, got:\n${body.slice(0, 200)}`);
+});
+
+test("renderApiReferenceBody: disambiguates a group heading against an endpoint anchor", () => {
+ const spec = minimalSpec({
+ "/v1/bootstrap": {
+ post: {
+ operationId: "bootstrap",
+ summary: "Create the first administrator",
+ tags: ["Bootstrap"],
+ responses: { "200": { description: "OK" } },
+ },
+ },
+ });
+
+ const body = renderApiReferenceBody(spec, "operation-id").join("\n");
+
+ assert(
+ body.includes('## Bootstrap {id="bootstrap-1"}'),
+ `expected a disambiguated heading, got:\n${body.slice(0, 300)}`,
+ );
+ assert(
+ body.includes('### Create the first administrator {id="bootstrap"}'),
+ "expected the endpoint to keep its anchor",
+ );
+});
+
+test("renderApiReferenceBody: disambiguates against an endpoint sub-anchor", () => {
+ const spec = minimalSpec({
+ "/v1/bootstrap": {
+ post: {
+ operationId: "bootstrap",
+ summary: "Boot",
+ tags: ["Bootstrap Responses"],
+ responses: { "200": { description: "OK" } },
+ },
+ },
+ });
+
+ const body = renderApiReferenceBody(spec, "operation-id").join("\n");
+
+ assert(
+ body.includes('## Bootstrap Responses {id="bootstrap-responses-1"}'),
+ `expected a disambiguated heading, got:\n${body.slice(0, 300)}`,
+ );
+});
+
+test("renderApiReferenceBody: disambiguates against a sanitized endpoint anchor", () => {
+ const spec = minimalSpec({
+ "/v1/keys": {
+ get: {
+ operationId: "api keys",
+ summary: "List keys",
+ tags: ["API Keys"],
+ responses: { "200": { description: "OK" } },
+ },
+ },
+ });
+
+ const body = renderApiReferenceBody(spec, "operation-id").join("\n");
+
+ assert(
+ body.includes('## API Keys {id="api-keys-1"}'),
+ `expected a disambiguated heading, got:\n${body.slice(0, 300)}`,
+ );
+});
+
+test("renderApiReferenceBody: ignores an anchor written inside a code span", () => {
+ const spec = minimalSpec({
+ "/v1/keys": {
+ get: {
+ operationId: "listKeys",
+ summary: "List keys",
+ tags: ["API Keys"],
+ responses: { "200": { description: "OK" } },
+ },
+ },
+ });
+ spec.info.description = "Write `{#api-keys}` as literal syntax.";
+
+ const body = renderApiReferenceBody(spec, "operation-id").join("\n");
+
+ assert(
+ body.includes("## API Keys\n"),
+ `expected a bare heading, got:\n${body.slice(0, 300)}`,
+ );
+});
+
+test("renderApiReferenceBody: ignores an anchor in an endpoint description code span", () => {
+ const spec = minimalSpec({
+ "/v1/keys": {
+ get: {
+ operationId: "listKeys",
+ summary: "List keys",
+ tags: ["API Keys"],
+ description: "Write `{#api-keys}` as literal syntax.",
+ responses: { "200": { description: "OK" } },
+ },
+ },
+ });
+
+ const body = renderApiReferenceBody(spec, "operation-id").join("\n");
+
+ assert(
+ body.includes("## API Keys\n"),
+ `expected a bare heading, got:\n${body.slice(0, 300)}`,
+ );
+});
+
+test("renderApiReferenceBody: ignores an anchor in a fenced code block", () => {
+ const spec = minimalSpec({
+ "/v1/keys": {
+ get: {
+ operationId: "listKeys",
+ summary: "List keys",
+ tags: ["API Keys"],
+ description: "Example:\n\n```markdown\n## Heading {#api-keys}\n```",
+ responses: { "200": { description: "OK" } },
+ },
+ },
+ });
+
+ const body = renderApiReferenceBody(spec, "operation-id").join("\n");
+
+ assert(
+ body.includes("## API Keys\n"),
+ `expected a bare heading, got:\n${body.slice(0, 300)}`,
+ );
+});