Skip to content

Commit e48db01

Browse files
authored
feat: add version-agnostic "latest" SDK reference redirects (#253)
Generate a wildcard redirect per SDK that points a stable /docs/sdk-reference/{sdk}/latest/... path at the newest (default) version, e.g. /docs/sdk-reference/js-sdk/latest/sandbox redirects to the current versioned page. These are regenerated on every docs.json rebuild so they always track the latest release, and are non-permanent since the destination moves. Existing manual redirects are preserved; only the generated latest ones are refreshed.
1 parent c49d6a1 commit e48db01

4 files changed

Lines changed: 240 additions & 1 deletion

File tree

docs.json

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5015,6 +5015,41 @@
50155015
"source": "/docs/git",
50165016
"destination": "/docs/sandbox/git-integration",
50175017
"permanent": true
5018+
},
5019+
{
5020+
"source": "/docs/sdk-reference/js-sdk/latest/:slug*",
5021+
"destination": "/docs/sdk-reference/js-sdk/v2.29.1/:slug*",
5022+
"permanent": false
5023+
},
5024+
{
5025+
"source": "/docs/sdk-reference/python-sdk/latest/:slug*",
5026+
"destination": "/docs/sdk-reference/python-sdk/v2.28.2/:slug*",
5027+
"permanent": false
5028+
},
5029+
{
5030+
"source": "/docs/sdk-reference/code-interpreter-js-sdk/latest/:slug*",
5031+
"destination": "/docs/sdk-reference/code-interpreter-js-sdk/v2.6.0/:slug*",
5032+
"permanent": false
5033+
},
5034+
{
5035+
"source": "/docs/sdk-reference/code-interpreter-python-sdk/latest/:slug*",
5036+
"destination": "/docs/sdk-reference/code-interpreter-python-sdk/v2.8.0/:slug*",
5037+
"permanent": false
5038+
},
5039+
{
5040+
"source": "/docs/sdk-reference/desktop-js-sdk/latest/:slug*",
5041+
"destination": "/docs/sdk-reference/desktop-js-sdk/v2.3.1/:slug*",
5042+
"permanent": false
5043+
},
5044+
{
5045+
"source": "/docs/sdk-reference/desktop-python-sdk/latest/:slug*",
5046+
"destination": "/docs/sdk-reference/desktop-python-sdk/v2.4.1/:slug*",
5047+
"permanent": false
5048+
},
5049+
{
5050+
"source": "/docs/sdk-reference/cli/latest/:slug*",
5051+
"destination": "/docs/sdk-reference/cli/v2.11.1/:slug*",
5052+
"permanent": false
50185053
}
50195054
]
50205055
}

sdk-reference-generator/src/__tests__/navigation.test.ts

Lines changed: 139 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,9 @@ vi.mock("../../sdks.config.js", () => ({
3131
}));
3232

3333
// import after mocking
34-
const { buildNavigation, mergeNavigation } = await import("../navigation.js");
34+
const { buildNavigation, mergeNavigation, buildLatestRedirects } = await import(
35+
"../navigation.js"
36+
);
3537

3638
describe("buildNavigation", () => {
3739
let tempDir: string;
@@ -206,6 +208,100 @@ describe("buildNavigation", () => {
206208
});
207209
});
208210

211+
describe("buildLatestRedirects", () => {
212+
it("returns no redirects for empty navigation", () => {
213+
expect(buildLatestRedirects([])).toEqual([]);
214+
});
215+
216+
it("builds a wildcard redirect pointing latest at the default version", () => {
217+
const navigation = [
218+
{
219+
dropdown: "Test SDK",
220+
icon: "square-js",
221+
versions: [
222+
{
223+
version: "v2.0.0",
224+
default: true,
225+
pages: ["docs/sdk-reference/test-js-sdk/v2.0.0/sandbox"],
226+
},
227+
{
228+
version: "v1.0.0",
229+
default: false,
230+
pages: ["docs/sdk-reference/test-js-sdk/v1.0.0/sandbox"],
231+
},
232+
],
233+
},
234+
];
235+
236+
const redirects = buildLatestRedirects(navigation);
237+
238+
expect(redirects).toEqual([
239+
{
240+
source: "/docs/sdk-reference/test-js-sdk/latest/:slug*",
241+
destination: "/docs/sdk-reference/test-js-sdk/v2.0.0/:slug*",
242+
permanent: false,
243+
},
244+
]);
245+
});
246+
247+
it("emits one redirect per SDK dropdown", () => {
248+
const navigation = [
249+
{
250+
dropdown: "JS",
251+
icon: "square-js",
252+
versions: [
253+
{
254+
version: "v2.0.0",
255+
default: true,
256+
pages: ["docs/sdk-reference/js-sdk/v2.0.0/sandbox"],
257+
},
258+
],
259+
},
260+
{
261+
dropdown: "Py",
262+
icon: "python",
263+
versions: [
264+
{
265+
version: "v3.0.0",
266+
default: true,
267+
pages: ["docs/sdk-reference/python-sdk/v3.0.0/sandbox_sync"],
268+
},
269+
],
270+
},
271+
];
272+
273+
const redirects = buildLatestRedirects(navigation);
274+
275+
expect(redirects.map((r) => r.source)).toEqual([
276+
"/docs/sdk-reference/js-sdk/latest/:slug*",
277+
"/docs/sdk-reference/python-sdk/latest/:slug*",
278+
]);
279+
});
280+
281+
it("skips dropdowns without a default version or pages", () => {
282+
const navigation = [
283+
{
284+
dropdown: "No default",
285+
icon: "square-js",
286+
versions: [
287+
{
288+
version: "v1.0.0",
289+
default: false,
290+
pages: ["docs/sdk-reference/test-js-sdk/v1.0.0/sandbox"],
291+
},
292+
],
293+
},
294+
{
295+
dropdown: "No pages",
296+
icon: "python",
297+
versions: [{ version: "v1.0.0", default: true, pages: [] }],
298+
},
299+
];
300+
301+
expect(buildLatestRedirects(navigation)).toEqual([]);
302+
});
303+
});
304+
209305
describe("mergeNavigation", () => {
210306
let tempDir: string;
211307
let docsJsonPath: string;
@@ -391,6 +487,48 @@ describe("mergeNavigation", () => {
391487
expect(content).toContain(' "navigation"');
392488
});
393489

490+
it("refreshes latest redirects while preserving manual ones", async () => {
491+
await fs.writeJSON(docsJsonPath, {
492+
navigation: { anchors: [] },
493+
redirects: [
494+
// a manual redirect that must survive
495+
{ source: "/docs/old", destination: "/docs/new", permanent: true },
496+
// a stale latest redirect pointing at an old version
497+
{
498+
source: "/docs/sdk-reference/test-js-sdk/latest/:slug*",
499+
destination: "/docs/sdk-reference/test-js-sdk/v1.0.0/:slug*",
500+
permanent: false,
501+
},
502+
],
503+
});
504+
505+
const navigation = [
506+
{
507+
dropdown: "Test SDK",
508+
icon: "square-js",
509+
versions: [
510+
{
511+
version: "v2.0.0",
512+
default: true,
513+
pages: ["docs/sdk-reference/test-js-sdk/v2.0.0/sandbox"],
514+
},
515+
],
516+
},
517+
];
518+
519+
await mergeNavigation(navigation, tempDir);
520+
521+
const result = await fs.readJSON(docsJsonPath);
522+
expect(result.redirects).toEqual([
523+
{ source: "/docs/old", destination: "/docs/new", permanent: true },
524+
{
525+
source: "/docs/sdk-reference/test-js-sdk/latest/:slug*",
526+
destination: "/docs/sdk-reference/test-js-sdk/v2.0.0/:slug*",
527+
permanent: false,
528+
},
529+
]);
530+
});
531+
394532
it("ensures newline at end of file", async () => {
395533
await fs.writeJSON(docsJsonPath, {
396534
navigation: { anchors: [] },

sdk-reference-generator/src/navigation.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { log } from "./lib/log.js";
1111
import type {
1212
NavigationDropdown,
1313
NavigationDropdownWithOrder,
14+
RedirectEntry,
1415
} from "./types.js";
1516

1617
async function getVersions(sdkDir: string): Promise<string[]> {
@@ -97,6 +98,52 @@ export async function buildNavigation(
9798
.map(({ _order, ...rest }) => rest);
9899
}
99100

101+
/**
102+
* Build version-agnostic "latest" redirects from the navigation.
103+
*
104+
* For each SDK, emits a single wildcard redirect that points a stable
105+
* `.../latest/...` path at the newest (default) version, e.g.
106+
* /docs/sdk-reference/js-sdk/latest/:slug* -> /docs/sdk-reference/js-sdk/v2.29.1/:slug*
107+
*
108+
* These are regenerated on every sync so they always track the current
109+
* release. They are non-permanent because the destination moves over time.
110+
*/
111+
export function buildLatestRedirects(
112+
navigation: NavigationDropdown[]
113+
): RedirectEntry[] {
114+
const redirects: RedirectEntry[] = [];
115+
116+
for (const dropdown of navigation) {
117+
const latestVersion = dropdown.versions.find((v) => v.default);
118+
const firstPage = latestVersion?.pages[0];
119+
if (!latestVersion || !firstPage) continue;
120+
121+
// pages look like: docs/sdk-reference/{sdkKey}/{version}/{module}
122+
const parts = firstPage.split("/");
123+
const sdkKey = parts[2];
124+
const version = parts[3];
125+
if (!sdkKey || !version) continue;
126+
127+
const base = `/${CONSTANTS.DOCS_SDK_REF_PATH}/${sdkKey}`;
128+
redirects.push({
129+
source: `${base}/latest/:slug*`,
130+
destination: `${base}/${version}/:slug*`,
131+
permanent: false,
132+
});
133+
}
134+
135+
return redirects;
136+
}
137+
138+
function isLatestSdkRedirect(source: unknown): boolean {
139+
return (
140+
typeof source === "string" &&
141+
new RegExp(
142+
`^/${CONSTANTS.DOCS_SDK_REF_PATH}/[^/]+/latest/`
143+
).test(source)
144+
);
145+
}
146+
100147
export async function mergeNavigation(
101148
navigation: NavigationDropdown[],
102149
docsDir: string
@@ -140,6 +187,19 @@ export async function mergeNavigation(
140187
anchors[sdkRefIndex] = sdkRefAnchor;
141188
}
142189

190+
// Refresh the "latest" redirects so they track the newest version. Existing
191+
// manual redirects are preserved; only the generated SDK latest ones are
192+
// replaced.
193+
const latestRedirects = buildLatestRedirects(validDropdowns);
194+
const existingRedirects: RedirectEntry[] = Array.isArray(docsJson.redirects)
195+
? docsJson.redirects
196+
: [];
197+
docsJson.redirects = [
198+
...existingRedirects.filter((r) => !isLatestSdkRedirect(r?.source)),
199+
...latestRedirects,
200+
];
201+
log.info(`Refreshed ${latestRedirects.length} SDK "latest" redirects`, 1);
202+
143203
await fs.writeJSON(docsJsonPath, docsJson, { spaces: 2 });
144204
const content = await fs.readFile(docsJsonPath, "utf-8");
145205
if (!content.endsWith("\n")) {

sdk-reference-generator/src/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,3 +80,9 @@ export interface NavigationDropdown {
8080
export interface NavigationDropdownWithOrder extends NavigationDropdown {
8181
_order: number;
8282
}
83+
84+
export interface RedirectEntry {
85+
source: string;
86+
destination: string;
87+
permanent: boolean;
88+
}

0 commit comments

Comments
 (0)