Skip to content

Commit e71fbde

Browse files
kylemclarenclaude
andcommitted
Generate API sidebar dynamically from schema with version-aware links
- Add sidebar transformation utilities to api-versions.ts: - rewriteApiUrl() rewrites API URLs to target version - transformSidebarForVersion() recursively transforms sidebar entries - Type definitions for SidebarEntry, SidebarLink, SidebarGroup - Update Sidebar.astro to transform sidebar at render time: - Detects current API version from URL path - Rewrites all /api/ links to match current version - Sidebar links now match the version being viewed - Modify generate-api-docs.ts to output src/lib/api-sidebar.ts: - New generateUnifiedSidebarFile() function - Uses versionToSlug() for Astro-compatible paths - Auto-generated from API schema at build time - Update sidebar.ts to import generated config: - Remove 150+ lines of hardcoded API Reference section - Import apiSidebarConfig from generated file Benefits: - Sidebar auto-updates when API schema changes - New endpoints appear automatically - Version switching shows correct links for each version Co-Authored-By: Claude Opus 4.5 <[email protected]>
1 parent c01d475 commit e71fbde

File tree

5 files changed

+259
-163
lines changed

5 files changed

+259
-163
lines changed

scripts/generate-api-docs.ts

Lines changed: 78 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
API_VERSIONS,
2222
type APIVersion,
2323
DEFAULT_VERSION,
24+
versionToSlug,
2425
} from '../src/lib/api-versions';
2526

2627
const OUTPUT_BASE_DIR = './src/content/docs/api';
@@ -1217,8 +1218,11 @@ function generateSidebarItems(
12171218
endpointsByCategory: Record<string, APIEndpoint[]>,
12181219
versionId: string,
12191220
): SidebarItem[] {
1221+
// Use slugified version for Astro routing (dots removed)
1222+
const versionSlug = versionToSlug(versionId);
1223+
12201224
const items: SidebarItem[] = [
1221-
{ label: 'Overview', slug: `api/${versionId}` },
1225+
{ label: 'Overview', slug: `api/${versionSlug}` },
12221226
];
12231227

12241228
// Add manual pages (Sprites) with nested endpoint items
@@ -1227,7 +1231,7 @@ function generateSidebarItems(
12271231
// Create a group with nested endpoint items
12281232
const endpointItems: SidebarLink[] = page.endpoints.map((ep) => ({
12291233
label: ep.title,
1230-
link: `/api/${versionId}/${page.category}#${slugifyEndpoint(ep.title)}`,
1234+
link: `/api/${versionSlug}/${page.category}#${slugifyEndpoint(ep.title)}`,
12311235
attrs: getMethodAttrs(ep.method),
12321236
}));
12331237
items.push({
@@ -1238,7 +1242,7 @@ function generateSidebarItems(
12381242
} else {
12391243
items.push({
12401244
label: page.title,
1241-
slug: `api/${versionId}/${page.category}`,
1245+
slug: `api/${versionSlug}/${page.category}`,
12421246
});
12431247
}
12441248
}
@@ -1249,7 +1253,7 @@ function generateSidebarItems(
12491253
if (endpoints.length > 0) {
12501254
const endpointItems: SidebarLink[] = endpoints.map((ep) => ({
12511255
label: ep.name,
1252-
link: `/api/${versionId}/${category}#${slugifyEndpoint(ep.name)}`,
1256+
link: `/api/${versionSlug}/${category}#${slugifyEndpoint(ep.name)}`,
12531257
attrs: getMethodAttrs(ep.method),
12541258
}));
12551259
items.push({
@@ -1260,12 +1264,12 @@ function generateSidebarItems(
12601264
} else {
12611265
items.push({
12621266
label: getCategoryTitle(category),
1263-
slug: `api/${versionId}/${category}`,
1267+
slug: `api/${versionSlug}/${category}`,
12641268
});
12651269
}
12661270
}
12671271

1268-
items.push({ label: 'Type Definitions', slug: `api/${versionId}/types` });
1272+
items.push({ label: 'Type Definitions', slug: `api/${versionSlug}/types` });
12691273

12701274
return items;
12711275
}
@@ -1334,6 +1338,48 @@ ${itemsStr}
13341338
`;
13351339
}
13361340

1341+
/**
1342+
* Generate the unified API sidebar config file for use in sidebar.ts.
1343+
* This uses the DEFAULT_VERSION and is imported by the main sidebar config.
1344+
*/
1345+
function generateUnifiedSidebarFile(
1346+
categories: string[],
1347+
endpointsByCategory: Record<string, APIEndpoint[]>,
1348+
versionId: string,
1349+
): string {
1350+
const items = generateSidebarItems(
1351+
categories,
1352+
endpointsByCategory,
1353+
versionId,
1354+
);
1355+
1356+
// Generate TypeScript with proper typing
1357+
const itemsStr = items
1358+
.map((item) => serializeSidebarItem(item, 1))
1359+
.join(',\n');
1360+
1361+
const versionSlug = versionToSlug(versionId);
1362+
1363+
return `/**
1364+
* Auto-generated API sidebar configuration.
1365+
* Generated by scripts/generate-api-docs.ts
1366+
* DO NOT EDIT MANUALLY - changes will be overwritten.
1367+
*
1368+
* This file uses the default API version (${versionId}${versionSlug}) for sidebar links.
1369+
* The Sidebar.astro component dynamically rewrites these links based on
1370+
* the current URL's version, so users see version-appropriate links.
1371+
*/
1372+
1373+
import type { StarlightUserConfig } from '@astrojs/starlight/types';
1374+
1375+
type SidebarItem = NonNullable<StarlightUserConfig['sidebar']>[number];
1376+
1377+
export const apiSidebarConfig: SidebarItem[] = [
1378+
${itemsStr}
1379+
];
1380+
`;
1381+
}
1382+
13371383
// ============================================================================
13381384
// Manual page handling - shared across all versions
13391385
// ============================================================================
@@ -1482,6 +1528,8 @@ async function generateVersionDocs(version: APIVersion): Promise<{
14821528
// Main
14831529
// ============================================================================
14841530

1531+
const API_SIDEBAR_OUTPUT_PATH = './src/lib/api-sidebar.ts';
1532+
14851533
async function main() {
14861534
console.log(
14871535
'🚀 Generating API documentation (versioned, double-pane layout)...',
@@ -1507,9 +1555,20 @@ async function main() {
15071555
await mkdir(OUTPUT_BASE_DIR, { recursive: true });
15081556
}
15091557

1558+
// Store default version data for unified sidebar generation
1559+
let defaultVersionData: {
1560+
categories: string[];
1561+
endpointsByCategory: Record<string, APIEndpoint[]>;
1562+
} | null = null;
1563+
15101564
// Generate docs for each version
15111565
for (const version of API_VERSIONS) {
1512-
await generateVersionDocs(version);
1566+
const versionData = await generateVersionDocs(version);
1567+
1568+
// Save default version data for sidebar generation
1569+
if (version.id === DEFAULT_VERSION.id) {
1570+
defaultVersionData = versionData;
1571+
}
15131572

15141573
// Copy shared manual pages to this version
15151574
await copyManualPagesToVersion(version.id);
@@ -1520,6 +1579,18 @@ async function main() {
15201579
const redirectContent = await generateRootRedirectPage(DEFAULT_VERSION);
15211580
await writeFile(join(OUTPUT_BASE_DIR, 'index.mdx'), redirectContent);
15221581

1582+
// Generate unified API sidebar config
1583+
if (defaultVersionData) {
1584+
console.log('\n📝 Generating unified API sidebar config...');
1585+
const sidebarContent = generateUnifiedSidebarFile(
1586+
defaultVersionData.categories,
1587+
defaultVersionData.endpointsByCategory,
1588+
DEFAULT_VERSION.id,
1589+
);
1590+
await writeFile(API_SIDEBAR_OUTPUT_PATH, sidebarContent);
1591+
console.log(` ✅ Generated ${API_SIDEBAR_OUTPUT_PATH}`);
1592+
}
1593+
15231594
console.log('\n🎉 All versions generated successfully!');
15241595
console.log(` Default version: ${DEFAULT_VERSION.id}`);
15251596
}

src/components/Sidebar.astro

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,28 @@
11
---
22
import StarlightSidebar from '@astrojs/starlight/components/Sidebar.astro';
33
import { VersionSelector } from './react/api/VersionSelector';
4-
import { getVersionFromPath, API_VERSIONS } from '@/lib/api-versions';
4+
import {
5+
getVersionFromPath,
6+
API_VERSIONS,
7+
transformSidebarForVersion,
8+
type SidebarEntry,
9+
} from '@/lib/api-versions';
510
611
const currentPath = Astro.url.pathname;
712
813
// Check if this is a versioned API page (or any page where we want to show the selector)
914
const versionId = getVersionFromPath(currentPath);
1015
const showVersionSelector = versionId !== null && API_VERSIONS.length > 1;
16+
17+
// Transform sidebar links to use the current API version
18+
// This ensures sidebar links match the version the user is viewing
19+
if (versionId && Astro.locals.starlightRoute?.sidebar) {
20+
const originalSidebar = Astro.locals.starlightRoute.sidebar as SidebarEntry[];
21+
Astro.locals.starlightRoute.sidebar = transformSidebarForVersion(
22+
originalSidebar,
23+
versionId,
24+
);
25+
}
1126
---
1227

1328
<StarlightSidebar {...Astro.props}>

src/lib/api-sidebar.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/**
2+
* Auto-generated API sidebar configuration.
3+
* Generated by scripts/generate-api-docs.ts
4+
* DO NOT EDIT MANUALLY - changes will be overwritten.
5+
*
6+
* This file uses the default API version (v0.0.1-rc30 → v001-rc30) for sidebar links.
7+
* The Sidebar.astro component dynamically rewrites these links based on
8+
* the current URL's version, so users see version-appropriate links.
9+
*/
10+
11+
import type { StarlightUserConfig } from '@astrojs/starlight/types';
12+
13+
type SidebarItem = NonNullable<StarlightUserConfig['sidebar']>[number];
14+
15+
export const apiSidebarConfig: SidebarItem[] = [
16+
{ label: 'Overview', slug: 'api/v001-rc30' },
17+
{
18+
label: 'Sprites',
19+
collapsed: true,
20+
items: [
21+
{ label: 'Create Sprite', link: '/api/v001-rc30/sprites#create-sprite', attrs: { 'data-method': 'post' } },
22+
{ label: 'List Sprites', link: '/api/v001-rc30/sprites#list-sprites', attrs: { 'data-method': 'get' } },
23+
{ label: 'Get Sprite', link: '/api/v001-rc30/sprites#get-sprite', attrs: { 'data-method': 'get' } },
24+
{ label: 'Update Sprite', link: '/api/v001-rc30/sprites#update-sprite', attrs: { 'data-method': 'put' } },
25+
{ label: 'Delete Sprite', link: '/api/v001-rc30/sprites#delete-sprite', attrs: { 'data-method': 'delete' } }
26+
]
27+
},
28+
{
29+
label: 'Checkpoints',
30+
collapsed: true,
31+
items: [
32+
{ label: 'Create Checkpoint', link: '/api/v001-rc30/checkpoints#create-checkpoint', attrs: { 'data-method': 'post' } },
33+
{ label: 'List Checkpoints', link: '/api/v001-rc30/checkpoints#list-checkpoints', attrs: { 'data-method': 'get' } },
34+
{ label: 'Get Checkpoint', link: '/api/v001-rc30/checkpoints#get-checkpoint', attrs: { 'data-method': 'get' } },
35+
{ label: 'Restore Checkpoint', link: '/api/v001-rc30/checkpoints#restore-checkpoint', attrs: { 'data-method': 'post' } }
36+
]
37+
},
38+
{
39+
label: 'Exec',
40+
collapsed: true,
41+
items: [
42+
{ label: 'Execute Command', link: '/api/v001-rc30/exec#execute-command', attrs: { 'data-method': 'wss' } },
43+
{ label: 'List Exec Sessions', link: '/api/v001-rc30/exec#list-exec-sessions', attrs: { 'data-method': 'get' } },
44+
{ label: 'Execute Command', link: '/api/v001-rc30/exec#execute-command', attrs: { 'data-method': 'post' } },
45+
{ label: 'Attach to Exec Session', link: '/api/v001-rc30/exec#attach-to-exec-session', attrs: { 'data-method': 'wss' } },
46+
{ label: 'Kill Exec Session', link: '/api/v001-rc30/exec#kill-exec-session', attrs: { 'data-method': 'post' } }
47+
]
48+
},
49+
{
50+
label: 'Filesystem',
51+
collapsed: true,
52+
items: [
53+
{ label: 'Read File', link: '/api/v001-rc30/filesystem#read-file', attrs: { 'data-method': 'get' } },
54+
{ label: 'Write File', link: '/api/v001-rc30/filesystem#write-file', attrs: { 'data-method': 'put' } },
55+
{ label: 'List Directory', link: '/api/v001-rc30/filesystem#list-directory', attrs: { 'data-method': 'get' } },
56+
{ label: 'Delete File or Directory', link: '/api/v001-rc30/filesystem#delete-file-or-directory', attrs: { 'data-method': 'delete' } },
57+
{ label: 'Rename File or Directory', link: '/api/v001-rc30/filesystem#rename-file-or-directory', attrs: { 'data-method': 'post' } },
58+
{ label: 'Copy File or Directory', link: '/api/v001-rc30/filesystem#copy-file-or-directory', attrs: { 'data-method': 'post' } },
59+
{ label: 'Change File Mode', link: '/api/v001-rc30/filesystem#change-file-mode', attrs: { 'data-method': 'post' } },
60+
{ label: 'Change File Owner', link: '/api/v001-rc30/filesystem#change-file-owner', attrs: { 'data-method': 'post' } },
61+
{ label: 'Watch Filesystem', link: '/api/v001-rc30/filesystem#watch-filesystem', attrs: { 'data-method': 'wss' } }
62+
]
63+
},
64+
{
65+
label: 'Policy',
66+
collapsed: true,
67+
items: [
68+
{ label: 'Get Network Policy', link: '/api/v001-rc30/policy#get-network-policy', attrs: { 'data-method': 'get' } },
69+
{ label: 'Set Network Policy', link: '/api/v001-rc30/policy#set-network-policy', attrs: { 'data-method': 'post' } }
70+
]
71+
},
72+
{
73+
label: 'HTTP Proxy',
74+
collapsed: true,
75+
items: [
76+
{ label: 'TCP Proxy', link: '/api/v001-rc30/proxy#tcp-proxy', attrs: { 'data-method': 'wss' } }
77+
]
78+
},
79+
{
80+
label: 'Services',
81+
collapsed: true,
82+
items: [
83+
{ label: 'List Services', link: '/api/v001-rc30/services#list-services', attrs: { 'data-method': 'get' } },
84+
{ label: 'Get Service', link: '/api/v001-rc30/services#get-service', attrs: { 'data-method': 'get' } },
85+
{ label: 'Create Service', link: '/api/v001-rc30/services#create-service', attrs: { 'data-method': 'put' } },
86+
{ label: 'Start Service', link: '/api/v001-rc30/services#start-service', attrs: { 'data-method': 'post' } },
87+
{ label: 'Stop Service', link: '/api/v001-rc30/services#stop-service', attrs: { 'data-method': 'post' } },
88+
{ label: 'Get Service Logs', link: '/api/v001-rc30/services#get-service-logs', attrs: { 'data-method': 'get' } }
89+
]
90+
},
91+
{ label: 'Type Definitions', slug: 'api/v001-rc30/types' }
92+
];

src/lib/api-versions.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,3 +94,70 @@ export function buildVersionedPath(versionId: string, page: string): string {
9494
const basePath = `/api/${slug}`;
9595
return page ? `${basePath}/${page}` : basePath;
9696
}
97+
98+
/**
99+
* Rewrite an API URL to use a different version.
100+
* Preserves the page path and hash fragment.
101+
* e.g., ("/api/v001-rc30/exec#execute", "dev-latest") → "/api/dev-latest/exec#execute"
102+
*/
103+
export function rewriteApiUrl(url: string, targetVersionId: string): string {
104+
// Match /api/{version}/{page}#{hash} or /api/{version}/{page} or /api/{version}/
105+
const match = url.match(/^\/api\/[^/]+\/?(.*)$/);
106+
if (!match) return url;
107+
108+
const pageAndHash = match[1]; // e.g., "exec#execute-command" or "exec" or ""
109+
const targetSlug = versionToSlug(targetVersionId);
110+
111+
if (!pageAndHash) {
112+
return `/api/${targetSlug}/`;
113+
}
114+
return `/api/${targetSlug}/${pageAndHash}`;
115+
}
116+
117+
// Re-export types for sidebar transformation (used by Sidebar.astro)
118+
export interface SidebarLink {
119+
type: 'link';
120+
label: string;
121+
href: string;
122+
isCurrent: boolean;
123+
badge?: { text: string; variant: string };
124+
attrs?: Record<string, string>;
125+
}
126+
127+
export interface SidebarGroup {
128+
type: 'group';
129+
label: string;
130+
entries: SidebarEntry[];
131+
collapsed: boolean;
132+
badge?: { text: string; variant: string };
133+
}
134+
135+
export type SidebarEntry = SidebarLink | SidebarGroup;
136+
137+
/**
138+
* Recursively transform sidebar entries to use a specific API version.
139+
* Only affects links that start with /api/.
140+
*/
141+
export function transformSidebarForVersion(
142+
entries: SidebarEntry[],
143+
targetVersionId: string,
144+
): SidebarEntry[] {
145+
return entries.map((entry): SidebarEntry => {
146+
if (entry.type === 'link') {
147+
// Only transform API links
148+
if (entry.href.startsWith('/api/')) {
149+
return {
150+
...entry,
151+
href: rewriteApiUrl(entry.href, targetVersionId),
152+
};
153+
}
154+
return entry;
155+
}
156+
157+
// It's a group - recurse into entries
158+
return {
159+
...entry,
160+
entries: transformSidebarForVersion(entry.entries, targetVersionId),
161+
};
162+
});
163+
}

0 commit comments

Comments
 (0)