Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { SignupLink } from "../../analytics";
import TabbedCodeBlock from "../../tabbed-code-block";
import TableOfContents from "../../table-of-contents";
import ToolFooter from "../../tool-footer";
import { sortDocumentationChunks } from "../lib/documentation-chunks";
import type {
DocumentationChunk,
DocumentationChunkLocation,
Expand Down Expand Up @@ -239,64 +240,13 @@ function ChunkContent({ chunk }: { chunk: DocumentationChunk }) {
);
}

/**
* Default priority for chunks without an explicit priority
*/
const DEFAULT_CHUNK_PRIORITY = 100;

/**
* Compare two chunks by header alphabetically.
* Returns a negative/positive number if both have headers,
* -1 if only a has a header, 1 if only b has a header,
* or null if neither has a header (fall through to next criterion).
*/
function compareByHeader(
a: DocumentationChunk,
b: DocumentationChunk
): number | null {
const headerA = (a.header ?? "").replace(HEADER_PREFIX_REGEX, "").trim();
const headerB = (b.header ?? "").replace(HEADER_PREFIX_REGEX, "").trim();

if (headerA && headerB) {
return headerA.localeCompare(headerB);
}
if (headerA) {
return -1;
}
if (headerB) {
return 1;
}
return null;
}

/**
* Sorts documentation chunks deterministically by:
* 1. Priority (lower = earlier)
* 2. Header alphabetically (for chunks with same priority)
* 3. Content string (for chunks with same priority and no header)
*/
export function sortChunksDeterministically(
chunks: readonly DocumentationChunk[]
): DocumentationChunk[] {
return [...chunks].sort((a, b) => {
const priorityDiff =
(a.priority ?? DEFAULT_CHUNK_PRIORITY) -
(b.priority ?? DEFAULT_CHUNK_PRIORITY);

if (priorityDiff !== 0) {
return priorityDiff;
}

const headerResult = compareByHeader(a, b);
if (headerResult !== null) {
return headerResult;
}

// Finally, sort by content for stability.
// Some call sites pass lightweight chunk objects where content is optional.
return (a.content ?? "").localeCompare(b.content ?? "");
});
}
export const sortChunksDeterministically = sortDocumentationChunks;

/**
* DocumentationChunkRenderer
Expand Down
53 changes: 53 additions & 0 deletions app/_components/toolkit-docs/lib/documentation-chunks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import type { DocumentationChunk } from "../types";

const DEFAULT_CHUNK_PRIORITY = 100;
const HEADER_PREFIX_REGEX = /^#+\s*/;

type SortableDocumentationChunk = Pick<
DocumentationChunk,
"header" | "priority"
> &
Partial<Pick<DocumentationChunk, "content">>;

function compareByHeader<T extends SortableDocumentationChunk>(
left: T,
right: T
): number | null {
const leftHeader = (left.header ?? "")
.replace(HEADER_PREFIX_REGEX, "")
.trim();
const rightHeader = (right.header ?? "")
.replace(HEADER_PREFIX_REGEX, "")
.trim();

if (leftHeader && rightHeader) {
return leftHeader.localeCompare(rightHeader);
}
if (leftHeader) {
return -1;
}
if (rightHeader) {
return 1;
}
return null;
}

export function sortDocumentationChunks<T extends SortableDocumentationChunk>(
chunks: readonly T[]
): T[] {
return [...chunks].sort((left, right) => {
const priorityDifference =
(left.priority ?? DEFAULT_CHUNK_PRIORITY) -
(right.priority ?? DEFAULT_CHUNK_PRIORITY);
if (priorityDifference !== 0) {
return priorityDifference;
}

const headerDifference = compareByHeader(left, right);
if (headerDifference !== null) {
return headerDifference;
}

return (left.content ?? "").localeCompare(right.content ?? "");
});
}
134 changes: 103 additions & 31 deletions app/_lib/toolkit-markdown.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { sortDocumentationChunks } from "@/app/_components/toolkit-docs/lib/documentation-chunks";
import type {
DocumentationChunk,
ToolDefinition,
ToolkitData,
ToolParameter,
Expand All @@ -13,6 +15,52 @@ import type {
*/
const JSON_INDENT = 2;

function documentationBlocks(
chunks: readonly DocumentationChunk[] | null | undefined,
location: DocumentationChunk["location"],
position: DocumentationChunk["position"]
): string[] {
const blocks: string[] = [];
const sorted = sortDocumentationChunks(
(chunks ?? []).filter(
(chunk) => chunk.location === location && chunk.position === position
)
);

for (const chunk of sorted) {
const content =
typeof chunk.content === "string" ? chunk.content.trim() : "";
if (!content) {
continue;
}
blocks.push(
chunk.type === "code" ? `\`\`\`text\n${content}\n\`\`\`` : content
);
}

return blocks;
}

function sectionBlocks(
chunks: readonly DocumentationChunk[] | null | undefined,
location: DocumentationChunk["location"],
defaultBlock: string | null
): string[] {
const before = documentationBlocks(chunks, location, "before");
const replacement = documentationBlocks(chunks, location, "replace");
const after = documentationBlocks(chunks, location, "after");
const hasReplacement = (chunks ?? []).some(
(chunk) => chunk.location === location && chunk.position === "replace"
);
let content: string[] = [];
if (hasReplacement) {
content = replacement;
} else if (defaultBlock) {
content = [defaultBlock];
}
return [...before, ...content, ...after];
}

/** Collapse newlines and escape pipes so a value is safe inside a table cell. */
function cell(value: string | null | undefined): string {
return (value ?? "")
Expand Down Expand Up @@ -48,37 +96,52 @@ function exampleBlock(tool: ToolDefinition): string | null {
function toolBlock(tool: ToolDefinition): string {
const blocks: string[] = [`### ${tool.qualifiedName}`];

if (tool.description) {
blocks.push(tool.description.trim());
}

const scopes = tool.auth?.scopes ?? [];
if (scopes.length > 0) {
blocks.push(
`**Required OAuth scopes:** ${scopes.map((s) => `\`${s}\``).join(", ")}`
);
}
blocks.push(
...sectionBlocks(
tool.documentationChunks,
"description",
tool.description?.trim() ?? null
)
);

const parameterBlock =
tool.parameters && tool.parameters.length > 0
? `**Parameters**\n\n${[
"| Name | Type | Required | Description |",
"| --- | --- | --- | --- |",
...tool.parameters.map(parameterRow),
].join("\n")}`
: "_No parameters._";
blocks.push(
...sectionBlocks(tool.documentationChunks, "parameters", parameterBlock)
);
Comment thread
jottakka marked this conversation as resolved.

const secrets = tool.secrets ?? [];
if (secrets.length > 0) {
blocks.push(`**Secrets:** ${secrets.map((s) => `\`${s}\``).join(", ")}`);
}
const secretsBlock =
secrets.length > 0
? `**Secrets:** ${secrets.map((secret) => `\`${secret}\``).join(", ")}`
: null;
blocks.push(
...sectionBlocks(tool.documentationChunks, "secrets", secretsBlock)
);

if (tool.parameters && tool.parameters.length > 0) {
const rows = [
"| Name | Type | Required | Description |",
"| --- | --- | --- | --- |",
...tool.parameters.map(parameterRow),
];
blocks.push(`**Parameters**\n\n${rows.join("\n")}`);
} else {
blocks.push("_No parameters._");
}

if (tool.output) {
const desc = tool.output.description ? ` — ${tool.output.description}` : "";
blocks.push(`**Output:** \`${tool.output.type}\`${desc}`);
}
const scopes = tool.auth?.scopes ?? [];
const scopesBlock =
scopes.length > 0
? `**Required OAuth scopes:** ${scopes
.map((scope) => `\`${scope}\``)
.join(", ")}`
: null;
blocks.push(...sectionBlocks(tool.documentationChunks, "auth", scopesBlock));

const outputBlock = tool.output
? `**Output:** \`${tool.output.type}\`${
tool.output.description ? ` — ${tool.output.description}` : ""
}`
: null;
blocks.push(
...sectionBlocks(tool.documentationChunks, "output", outputBlock)
);

const example = exampleBlock(tool);
if (example) {
Expand All @@ -90,19 +153,28 @@ function toolBlock(tool: ToolDefinition): string {

export function toToolkitMarkdown(data: ToolkitData): string {
const blocks: string[] = [`# ${data.label || data.id}`];
const chunks = data.documentationChunks;

if (data.description) {
blocks.push(data.description.trim());
}
blocks.push(...sectionBlocks(chunks, "header", null));
blocks.push(
...sectionBlocks(chunks, "description", data.description?.trim() ?? null)
);
if (data.summary) {
blocks.push(data.summary.trim());
}
blocks.push(...sectionBlocks(chunks, "auth", null));
blocks.push(...sectionBlocks(chunks, "before_available_tools", null));
blocks.push(...sectionBlocks(chunks, "custom_section", null));

const tools = data.tools ?? [];
blocks.push(`## Tools (${tools.length})`);
for (const tool of tools) {
blocks.push(toolBlock(tool));
}
blocks.push(...sectionBlocks(chunks, "after_available_tools", null));
blocks.push(...documentationBlocks(chunks, "footer", "before"));
blocks.push(...documentationBlocks(chunks, "footer", "replace"));
blocks.push(...documentationBlocks(chunks, "footer", "after"));

return `${blocks.join("\n\n")}\n`;
}
19 changes: 19 additions & 0 deletions tests/documentation-chunks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { describe, expect, test } from "vitest";
import { sortDocumentationChunks } from "../app/_components/toolkit-docs/lib/documentation-chunks";
import type { DocumentationChunk } from "../app/_components/toolkit-docs/types";

describe("sortDocumentationChunks", () => {
test("supports lightweight chunks with optional content", () => {
const chunks = [
{ header: undefined },
{ header: undefined },
{ header: "## B" },
{ header: "## A" },
] as unknown as DocumentationChunk[];

expect(() => sortDocumentationChunks(chunks)).not.toThrow();
expect(
sortDocumentationChunks(chunks).map((chunk) => chunk.header ?? null)
).toEqual(["## A", "## B", null, null]);
});
});
Loading
Loading