Skip to content

Commit ad5adfa

Browse files
authored
Generate API sidebar dynamically from schema with version-aware links (#89)
1 parent c01d475 commit ad5adfa

File tree

5 files changed

+169
-163
lines changed

5 files changed

+169
-163
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,5 @@ cli-test-report.json
6363
src/content/docs/api/*
6464
!src/content/docs/api/_manual/
6565
.playwright-mcp/
66+
# Auto-generated
67+
src/lib/api-sidebar.ts

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-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)