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
4 changes: 2 additions & 2 deletions _extensions/quarto-openapi/_extension.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
title: quarto-openapi
author: Posit Software, PBC
version: 0.1.2
quarto-required: ">=1.5.0"
version: 0.2.0
quarto-required: ">=1.6.0"
contributes:
metadata:
project:
Expand Down
4 changes: 2 additions & 2 deletions _extensions/quarto-openapi/openapi-to-markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@
* output: "api/index.qmd"
*/

import { parse as parseYaml, stringify as stringifyYaml } from "https://deno.land/std@0.224.0/yaml/mod.ts";
import { join, dirname, extname } from "https://deno.land/std@0.224.0/path/mod.ts";
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";

Expand Down
8 changes: 6 additions & 2 deletions example/_extensions/quarto-openapi/_extension.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
title: quarto-openapi
author: Posit Software, PBC
version: 0.1.0
quarto-required: ">=1.5.0"
version: 0.2.0
quarto-required: ">=1.6.0"
contributes:
metadata:
project:
pre-render:
- _extensions/quarto-openapi/openapi-to-markdown.ts
format:
html:
css:
- quarto-openapi-styles.css
3 changes: 3 additions & 0 deletions example/_extensions/quarto-openapi/lib/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export function gridTable(headers: string[], rows: TableRow[]): string[] {
}
};

lines.push("::: {.quarto-openapi-table}");
lines.push(separator("-"));
emitRow(headerLines);
lines.push(separator("="));
Expand All @@ -64,6 +65,8 @@ export function gridTable(headers: string[], rows: TableRow[]): string[] {
lines.push(separator("-"));
}

lines.push(":::");

return lines;
}

Expand Down
14 changes: 8 additions & 6 deletions example/_extensions/quarto-openapi/lib/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,9 @@ function flattenProperties(
for (const [name, prop] of Object.entries(properties)) {
const resolved = isReference(prop) ? resolve<Schema>(spec, prop) : prop;
const displayName = prefix ? `${prefix}.${name}` : name;
const indent = " ".repeat(depth);
const nameCell = depth > 0
? `[\`${displayName}\`]{.schema-nest-${depth}}`
: `\`${displayName}\``;

if (
(resolved.type === "object" || resolved.properties) &&
Expand All @@ -167,7 +169,7 @@ function flattenProperties(
) {
// Nested object: emit a row for the object itself, then recurse
rows.push({
name: `${indent}\`${displayName}\``,
name: nameCell,
type: "`object`",
description: resolved.description || "",
});
Expand All @@ -183,7 +185,7 @@ function flattenProperties(
} else if (resolved.allOf) {
const merged = mergeAllOf(spec, resolved.allOf);
rows.push({
name: `${indent}\`${displayName}\``,
name: nameCell,
type: "`object`",
description: resolved.description || merged.description || "",
});
Expand All @@ -205,7 +207,7 @@ function flattenProperties(

if (items.type === "object" || items.properties || items.allOf) {
rows.push({
name: `${indent}\`${displayName}\``,
name: nameCell,
type: "`[object]`",
description: buildDescription(resolved, requiredSet.has(name)),
});
Expand All @@ -224,14 +226,14 @@ function flattenProperties(
} else {
const itemType = items.type || "unknown";
rows.push({
name: `${indent}\`${displayName}\``,
name: nameCell,
type: `\`[${itemType}]\``,
description: buildDescription(resolved, requiredSet.has(name)),
});
}
} else {
rows.push({
name: `${indent}\`${displayName}\``,
name: nameCell,
type: `\`${formatTypeString(resolved)}\``,
description: buildDescription(resolved, requiredSet.has(name)),
});
Expand Down
70 changes: 66 additions & 4 deletions example/_extensions/quarto-openapi/lib/sections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,10 +123,28 @@ export function groupByResource(spec: OpenAPISpec): Section[] {
}
}

// Preserve insertion order (first-seen tag order).
// Determine section order: use spec.tags when present, otherwise first-seen.
const orderedNames: string[] = [];
if (spec.tags && spec.tags.length > 0) {
for (const tag of spec.tags) {
if (sectionMap.has(tag.name)) {
orderedNames.push(tag.name);
}
}
// Append any tags not listed in spec.tags (first-seen order).
for (const name of sectionMap.keys()) {
if (!orderedNames.includes(name)) {
orderedNames.push(name);
}
}
} else {
orderedNames.push(...sectionMap.keys());
}

// Sort endpoints within each section by path then method.
const sections: Section[] = [];
for (const [name, endpoints] of sectionMap) {
for (const name of orderedNames) {
const endpoints = sectionMap.get(name)!;
endpoints.sort((a, b) => {
const pathCmp = a.path.localeCompare(b.path);
if (pathCmp !== 0) return pathCmp;
Expand Down Expand Up @@ -155,6 +173,50 @@ export function renderSection(spec: OpenAPISpec, section: Section): string[] {
return lines;
}

/**
* Shift markdown headings in a description so the highest heading
* becomes h4 (one level below the endpoint h3).
* Headings inside fenced code blocks are left untouched.
*/
function shiftHeadings(description: string): string {
const lines = description.split("\n");
const headingRe = /^(#{1,6})\s/;

// First pass: find minimum heading level outside code fences.
let minLevel = 7;
let inFence = false;
for (const line of lines) {
if (/^```/.test(line)) {
inFence = !inFence;
continue;
}
if (inFence) continue;
const m = headingRe.exec(line);
if (m) minLevel = Math.min(minLevel, m[1].length);
}

if (minLevel >= 7) return description; // no headings found

const shift = 4 - minLevel;
if (shift <= 0) return description;

// Second pass: shift headings outside code fences.
inFence = false;
const result = lines.map((line) => {
if (/^```/.test(line)) {
inFence = !inFence;
return line;
}
if (inFence) return line;
return line.replace(headingRe, (_full, hashes: string) => {
const newLevel = Math.min(hashes.length + shift, 6);
return "#".repeat(newLevel) + " ";
});
});

return result.join("\n");
}

function renderEndpoint(spec: OpenAPISpec, endpoint: Endpoint): string[] {
const { method, path, operation } = endpoint;
const title = operation.summary || `${methodBadge(method)} ${path}`;
Expand All @@ -178,9 +240,9 @@ function renderEndpoint(spec: OpenAPISpec, endpoint: Endpoint): string[] {
lines.push("");
}

// Description
// Description — shift headings so they nest below the endpoint h3
if (operation.description) {
lines.push(operation.description);
lines.push(shiftHeadings(operation.description));
lines.push("");
}

Expand Down
6 changes: 4 additions & 2 deletions example/_extensions/quarto-openapi/openapi-to-markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@
* output: "api/index.qmd"
*/

import { parse as parseYaml, stringify as stringifyYaml } from "https://deno.land/std@0.224.0/yaml/mod.ts";
import { join, dirname, extname } from "https://deno.land/std@0.224.0/path/mod.ts";
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";

Expand Down Expand Up @@ -103,6 +103,8 @@ async function main() {
// YAML frontmatter — use a proper serializer to avoid injection via title
const frontmatter = stringifyYaml({
title: spec.info.title,
"page-layout": "full",
"toc-location": "left",
toc: true,
"toc-depth": 3,
"toc-expand": 1,
Expand Down
28 changes: 28 additions & 0 deletions example/_extensions/quarto-openapi/quarto-openapi-styles.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/* Schema table layout */
.quarto-openapi-table {
overflow-x: auto;
}

.quarto-openapi-table colgroup {
display: none;
}

.quarto-openapi-table table {
table-layout: auto;
}

.quarto-openapi-table td:first-child,
.quarto-openapi-table th:first-child {
white-space: nowrap;
}

.quarto-openapi-table td:nth-child(2),
.quarto-openapi-table th:nth-child(2) {
white-space: nowrap;
}

/* Nesting indentation for schema property tables */
.schema-nest-1 { padding-left: 1em; }
.schema-nest-2 { padding-left: 2em; }
.schema-nest-3 { padding-left: 3em; }
.schema-nest-4 { padding-left: 4em; }