Skip to content

Commit 947adfe

Browse files
kylemclarenclaude
andcommitted
Add API versioning with sidebar version selector and persistent preferences
- Add v0.0.1-rc30 as stable version alongside dev-latest - Move version selector from page title to sidebar footer for better UX - Add localStorage persistence for version preference with early redirect - Fix sidebar navigation to work across versions (version-agnostic path comparison) - Refactor manual pages to use shared _manual/ directory - Add versionToSlug() to handle Astro's slug transformation (v0.0.1-rc30 → v001-rc30) The sidebar now correctly auto-expands API groups regardless of which version the user is viewing, by stripping the version segment before comparing paths. Co-Authored-By: Claude Opus 4.5 <[email protected]>
1 parent 72b36a2 commit 947adfe

File tree

10 files changed

+607
-91
lines changed

10 files changed

+607
-91
lines changed

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,4 +59,6 @@ styles/write-good/
5959
cli-test-report.json
6060

6161
# Generated API documentation (rebuilt on deploy)
62-
src/content/docs/api/
62+
# Manual pages in _manual/ are tracked, versioned output is generated
63+
src/content/docs/api/*
64+
!src/content/docs/api/_manual/

astro.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ export default defineConfig({
116116
Head: './src/components/Head.astro',
117117
Header: './src/components/Header.astro',
118118
Search: './src/components/Search.astro',
119+
Sidebar: './src/components/Sidebar.astro',
119120
ThemeSelect: './src/components/ThemeSelect.astro',
120121
ThemeProvider: './src/components/ThemeProvider.astro',
121122
PageTitle: './src/components/PageTitle.astro',

scripts/generate-api-docs.ts

Lines changed: 26 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
*/
99

1010
import { constants } from 'node:fs';
11-
import { access, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
11+
import { access, mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
1212
import { join } from 'node:path';
1313
import {
1414
API_VERSIONS,
@@ -1328,9 +1328,11 @@ ${itemsStr}
13281328
}
13291329

13301330
// ============================================================================
1331-
// Manual page preservation
1331+
// Manual page handling - shared across all versions
13321332
// ============================================================================
13331333

1334+
const MANUAL_PAGES_DIR = join(OUTPUT_BASE_DIR, '_manual');
1335+
13341336
async function fileExists(path: string): Promise<boolean> {
13351337
try {
13361338
await access(path, constants.F_OK);
@@ -1340,31 +1342,20 @@ async function fileExists(path: string): Promise<boolean> {
13401342
}
13411343
}
13421344

1343-
async function preserveManualPages(
1344-
versionId: string,
1345-
): Promise<Map<string, string>> {
1346-
const preserved = new Map<string, string>();
1347-
1345+
/**
1346+
* Copy manual pages from _manual/ directory to a specific version directory.
1347+
* Manual pages are shared across all versions.
1348+
*/
1349+
async function copyManualPagesToVersion(versionId: string): Promise<void> {
13481350
for (const page of MANUAL_PAGES) {
1349-
const filePath = join(OUTPUT_BASE_DIR, versionId, `${page.category}.mdx`);
1350-
if (await fileExists(filePath)) {
1351-
const content = await readFile(filePath, 'utf-8');
1352-
preserved.set(page.category, content);
1353-
console.log(` 📌 Preserving manual page: ${page.category}.mdx`);
1354-
}
1355-
}
1356-
1357-
return preserved;
1358-
}
1351+
const sourcePath = join(MANUAL_PAGES_DIR, `${page.category}.mdx`);
1352+
const destPath = join(OUTPUT_BASE_DIR, versionId, `${page.category}.mdx`);
13591353

1360-
async function restoreManualPages(
1361-
versionId: string,
1362-
preserved: Map<string, string>,
1363-
): Promise<void> {
1364-
for (const [category, content] of preserved) {
1365-
const filePath = join(OUTPUT_BASE_DIR, versionId, `${category}.mdx`);
1366-
await writeFile(filePath, content);
1367-
console.log(` 📌 Restored manual page: ${category}.mdx`);
1354+
if (await fileExists(sourcePath)) {
1355+
const content = await readFile(sourcePath, 'utf-8');
1356+
await writeFile(destPath, content);
1357+
console.log(` 📄 Copied manual page: ${page.category}.mdx`);
1358+
}
13681359
}
13691360
}
13701361

@@ -1492,34 +1483,26 @@ async function main() {
14921483
`📋 Versions to generate: ${API_VERSIONS.map((v) => v.id).join(', ')}`,
14931484
);
14941485

1495-
// Preserve manual pages before cleaning
1496-
console.log('\n📌 Preserving manual pages...');
1497-
const preservedPages = new Map<string, Map<string, string>>();
1498-
for (const version of API_VERSIONS) {
1499-
const preserved = await preserveManualPages(version.id);
1500-
if (preserved.size > 0) {
1501-
preservedPages.set(version.id, preserved);
1502-
}
1503-
}
1504-
1505-
// Clean output directory
1486+
// Clean output directory (but preserve _manual/)
15061487
console.log('\n🧹 Cleaning output directory...');
15071488
try {
1508-
await rm(OUTPUT_BASE_DIR, { recursive: true, force: true });
1489+
const entries = await readdir(OUTPUT_BASE_DIR);
1490+
for (const entry of entries) {
1491+
if (entry !== '_manual') {
1492+
await rm(join(OUTPUT_BASE_DIR, entry), { recursive: true, force: true });
1493+
}
1494+
}
15091495
} catch {
15101496
// Directory may not exist
1497+
await mkdir(OUTPUT_BASE_DIR, { recursive: true });
15111498
}
1512-
await mkdir(OUTPUT_BASE_DIR, { recursive: true });
15131499

15141500
// Generate docs for each version
15151501
for (const version of API_VERSIONS) {
15161502
await generateVersionDocs(version);
15171503

1518-
// Restore manual pages for this version
1519-
const preserved = preservedPages.get(version.id);
1520-
if (preserved) {
1521-
await restoreManualPages(version.id, preserved);
1522-
}
1504+
// Copy shared manual pages to this version
1505+
await copyManualPagesToVersion(version.id);
15231506
}
15241507

15251508
// Generate root redirect page

src/components/Head.astro

Lines changed: 47 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,38 @@ const breadcrumbSchema = {
4848
item: item.url,
4949
})),
5050
};
51+
52+
// API version redirect - check if we need to redirect to stored preference
53+
const currentPath = Astro.url.pathname;
54+
const apiMatch = currentPath.match(/^\/api\/([^/]+)/);
55+
const isApiPage = apiMatch !== null;
56+
57+
// Build version map for the inline script
58+
import { API_VERSIONS, versionToSlug, getVersionFromPath } from '@/lib/api-versions';
59+
const versionMap = Object.fromEntries(
60+
API_VERSIONS.map(v => [v.id, versionToSlug(v.id)])
61+
);
62+
const currentVersionId = isApiPage ? getVersionFromPath(currentPath) : null;
5163
---
5264

65+
{/* Version redirect script - must run immediately before any content */}
66+
{isApiPage && (
67+
<script is:inline define:vars={{ versionMap, currentVersionId }}>
68+
(function() {
69+
var STORAGE_KEY = 'sprites-api-version';
70+
var storedVersion = localStorage.getItem(STORAGE_KEY);
71+
if (!storedVersion || !currentVersionId || storedVersion === currentVersionId) return;
72+
var storedSlug = versionMap[storedVersion];
73+
if (!storedSlug) return;
74+
var path = window.location.pathname;
75+
var match = path.match(/^\/api\/[^/]+\/?(.*)$/);
76+
var page = match ? match[1] : '';
77+
var newPath = '/api/' + storedSlug + (page ? '/' + page : '/');
78+
window.location.replace(newPath);
79+
})();
80+
</script>
81+
)}
82+
5383
<Default {...Astro.props}><slot /></Default>
5484
<Background />
5585

@@ -219,9 +249,13 @@ const breadcrumbSchema = {
219249
function initSidebarNavigation() {
220250
// Normalize path by removing trailing slash for comparison
221251
const normalizePath = (path) => path.replace(/\/$/, '') || '/';
252+
253+
// Strip API version from path for comparison (e.g., /api/v001-rc30/sprites -> /api/sprites)
254+
const stripApiVersion = (path) => path.replace(/^\/api\/[^/]+/, '/api');
255+
222256
const currentPath = normalizePath(window.location.pathname);
257+
const currentPathNoVersion = stripApiVersion(currentPath);
223258
const currentHash = window.location.hash;
224-
const currentUrl = currentPath + currentHash;
225259

226260
// Find all nested sidebar groups within API Reference
227261
const sidebarGroups = document.querySelectorAll('nav[aria-label="Main"] details details');
@@ -237,10 +271,11 @@ const breadcrumbSchema = {
237271
// Extract the page URL without the anchor and normalize
238272
const href = firstLink.getAttribute('href') || '';
239273
const pageUrl = normalizePath(href.split('#')[0]);
274+
const pageUrlNoVersion = stripApiVersion(pageUrl);
240275
if (!pageUrl) return;
241276

242-
// Auto-expand if current page matches this group's page
243-
if (currentPath === pageUrl) {
277+
// Auto-expand if current page matches this group's page (comparing without version)
278+
if (currentPathNoVersion === pageUrlNoVersion) {
244279
details.setAttribute('open', '');
245280
}
246281

@@ -249,15 +284,15 @@ const breadcrumbSchema = {
249284
allLinks.forEach((link) => {
250285
const linkHref = link.getAttribute('href') || '';
251286
const linkPath = normalizePath(linkHref.split('#')[0]);
287+
const linkPathNoVersion = stripApiVersion(linkPath);
252288
const linkHash = linkHref.includes('#') ? '#' + linkHref.split('#')[1] : '';
253-
const fullLinkUrl = linkPath + linkHash;
254289

255290
// Only mark as current if:
256-
// 1. Link has a hash and it matches the current URL exactly (path + hash)
257-
// 2. Link has no hash and we're on that exact page with no hash
291+
// 1. Link has a hash and path matches (ignoring version) + hash matches exactly
292+
// 2. Link has no hash and we're on that exact page (ignoring version) with no hash
258293
const isCurrentLink = linkHash
259-
? fullLinkUrl === currentUrl
260-
: (linkPath === currentPath && !currentHash);
294+
? (linkPathNoVersion === currentPathNoVersion && linkHash === currentHash)
295+
: (linkPathNoVersion === currentPathNoVersion && !currentHash);
261296

262297
if (isCurrentLink) {
263298
link.setAttribute('data-current', 'true');
@@ -275,14 +310,15 @@ const breadcrumbSchema = {
275310
// Allow normal toggle behavior with modifier keys
276311
if (e.shiftKey || e.ctrlKey || e.metaKey) return;
277312

278-
// If currently closed, expand and navigate to page
279-
if (!details.open) {
313+
// If currently closed and we're NOT already on this page, expand and navigate
314+
// (Compare without version to handle being on different version of same page)
315+
if (!details.open && currentPathNoVersion !== pageUrlNoVersion) {
280316
e.preventDefault();
281317
e.stopPropagation();
282318
// Navigate to the page (without anchor hash)
283319
window.location.href = pageUrl;
284320
}
285-
// If already open, just toggle closed (default behavior)
321+
// If already on this page or already open, let default toggle behavior happen
286322
});
287323

288324
// Make summary look clickable

src/components/PageTitle.astro

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,14 @@
22
import StarlightPageTitle from '@astrojs/starlight/components/PageTitle.astro';
33
import CopyPageButton from './CopyPageButton.astro';
44
import { ContentBreadcrumbs } from './react/Breadcrumbs';
5-
import { VersionSelector } from './react/api/VersionSelector';
6-
import { getVersionFromPath, API_VERSIONS } from '@/lib/api-versions';
75
86
const currentPath = Astro.url.pathname;
97
const sidebar = Astro.locals.starlightRoute?.sidebar ?? [];
10-
11-
// Check if this is a versioned API page
12-
const versionId = getVersionFromPath(currentPath);
13-
const isVersionedApiPage = versionId !== null && API_VERSIONS.length > 1;
148
---
159

1610
<div class="page-nav-container">
1711
<ContentBreadcrumbs client:load currentPath={currentPath} sidebar={sidebar} />
1812
<div class="page-nav-actions">
19-
{isVersionedApiPage && (
20-
<VersionSelector client:load currentPath={currentPath} />
21-
)}
2213
<CopyPageButton />
2314
</div>
2415
</div>

src/components/Sidebar.astro

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
import StarlightSidebar from '@astrojs/starlight/components/Sidebar.astro';
3+
import { VersionSelector } from './react/api/VersionSelector';
4+
import { getVersionFromPath, API_VERSIONS } from '@/lib/api-versions';
5+
6+
const currentPath = Astro.url.pathname;
7+
8+
// Check if this is a versioned API page (or any page where we want to show the selector)
9+
const versionId = getVersionFromPath(currentPath);
10+
const showVersionSelector = versionId !== null && API_VERSIONS.length > 1;
11+
---
12+
13+
<StarlightSidebar {...Astro.props}>
14+
<slot />
15+
</StarlightSidebar>
16+
17+
{showVersionSelector && (
18+
<div class="sidebar-version-selector">
19+
<VersionSelector client:load currentPath={currentPath} />
20+
</div>
21+
)}
22+
23+
{/* Version redirect is handled in Head.astro for earliest execution */}
24+
25+
<style>
26+
.sidebar-version-selector {
27+
position: sticky;
28+
bottom: 0;
29+
padding: 1rem;
30+
background: var(--sl-color-bg-sidebar);
31+
margin-top: auto;
32+
}
33+
</style>

src/components/react/api/VersionSelector.tsx

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import {
1515
getVersionFromPath,
1616
} from '@/lib/api-versions';
1717

18+
const STORAGE_KEY = 'sprites-api-version';
19+
1820
interface VersionSelectorProps {
1921
currentPath: string;
2022
}
@@ -36,7 +38,7 @@ function BadgeIcon({ badge }: { badge?: APIVersion['badge'] }) {
3638

3739
return (
3840
<span
39-
className={`ml-2 px-1.5 py-0.5 text-[10px] font-medium rounded ${colors[badge]}`}
41+
className={`px-1.5 py-0.5 text-[10px] font-medium rounded ${colors[badge]}`}
4042
>
4143
{labels[badge]}
4244
</span>
@@ -54,24 +56,26 @@ export function VersionSelector({ currentPath }: VersionSelectorProps) {
5456
}
5557

5658
const handleVersionChange = (newVersionId: string) => {
59+
// Save preference and navigate
60+
localStorage.setItem(STORAGE_KEY, newVersionId);
5761
const newPath = buildVersionedPath(newVersionId, currentPage);
5862
window.location.href = newPath;
5963
};
6064

6165
return (
6266
<Select value={currentVersionId || ''} onValueChange={handleVersionChange}>
63-
<SelectTrigger className="w-[160px] h-8 text-sm bg-[var(--sl-color-bg)] border-[var(--sl-color-gray-5)]">
67+
<SelectTrigger className="w-full h-9 text-sm bg-[var(--sl-color-bg)] border-[var(--sl-color-gray-5)]">
6468
<SelectValue>
65-
<div className="flex items-center">
69+
<div className="flex items-center gap-2">
6670
<span>{currentVersion.label}</span>
6771
<BadgeIcon badge={currentVersion.badge} />
6872
</div>
6973
</SelectValue>
7074
</SelectTrigger>
71-
<SelectContent>
75+
<SelectContent className="min-w-[200px]">
7276
{API_VERSIONS.map((version) => (
73-
<SelectItem key={version.id} value={version.id}>
74-
<div className="flex items-center">
77+
<SelectItem key={version.id} value={version.id} className="py-2">
78+
<div className="flex items-center gap-2">
7579
<span>{version.label}</span>
7680
<BadgeIcon badge={version.badge} />
7781
</div>

0 commit comments

Comments
 (0)