Skip to content

Commit 813bb97

Browse files
committed
Per-version tag routes, smart version switching, and nav animation fix
1 parent c7af48c commit 813bb97

File tree

10 files changed

+579
-66
lines changed

10 files changed

+579
-66
lines changed

examples/cosmo-cargo/schema/fleet-ops.json

Lines changed: 409 additions & 0 deletions
Large diffs are not rendered by default.

examples/cosmo-cargo/zudoku.build.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,20 @@ const buildConfig: ZudokuBuildConfig = {
88

99
return schema;
1010
},
11+
({ schema, params }) => {
12+
const prefix = params.prefix;
13+
if (!prefix) return schema;
14+
15+
return {
16+
...schema,
17+
info: { ...schema.info, version: prefix },
18+
paths: Object.fromEntries(
19+
Object.entries(schema.paths ?? {}).filter(([path]) =>
20+
path.startsWith(prefix),
21+
),
22+
),
23+
};
24+
},
1125
],
1226
prerender: {
1327
workers: Math.floor(os.cpus().length * 0.75),

examples/cosmo-cargo/zudoku.config.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,28 @@ const config: ZudokuConfig = {
372372
path: "/catalog/api-cargo-containers",
373373
categories: [{ label: "General", tags: ["Containers", "Booking"] }],
374374
},
375+
{
376+
type: "file",
377+
input: [
378+
{
379+
input: "./schema/fleet-ops.json?prefix=/v3",
380+
path: "v3",
381+
label: "v3 (Quantum)",
382+
},
383+
{
384+
input: "./schema/fleet-ops.json?prefix=/v2",
385+
path: "v2",
386+
label: "v2 (Warp)",
387+
},
388+
{
389+
input: "./schema/fleet-ops.json?prefix=/v1",
390+
path: "v1",
391+
label: "v1 (Sublight)",
392+
},
393+
],
394+
path: "/catalog/api-fleet-ops",
395+
categories: [{ label: "General", tags: ["Fleet Command"] }],
396+
},
375397
],
376398
theme: {
377399
light: {

packages/zudoku/src/lib/components/navigation/NavigationCategory.tsx

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -100,9 +100,9 @@ const NavigationCategoryInner = ({
100100
}}
101101
className={styles}
102102
onClick={() => {
103-
setHasInteracted(true);
104103
// if it is the current path and closed then open it because there's no path change to trigger the open
105104
if (isActive && !open) {
105+
setHasInteracted(true);
106106
setOpen(true);
107107
}
108108
}}
@@ -114,14 +114,7 @@ const NavigationCategoryInner = ({
114114
</div>
115115
</NavLink>
116116
) : (
117-
// biome-ignore lint/a11y/noStaticElementInteractions: This is only to track if the user has interacted
118-
<div
119-
onClick={() => setHasInteracted(true)}
120-
onKeyUp={(e) => {
121-
if (e.key === "Enter" || e.key === " ") setHasInteracted(true);
122-
}}
123-
className={styles}
124-
>
117+
<div className={styles}>
125118
{icon}
126119
<div className="flex items-center justify-between w-full">
127120
<div className="flex gap-2 truncate w-full">{category.label}</div>
@@ -137,6 +130,7 @@ const NavigationCategoryInner = ({
137130
category.items.length === 0 && "hidden",
138131
"ms-6 my-1",
139132
)}
133+
onAnimationEnd={() => setHasInteracted(false)}
140134
>
141135
<ul className="relative after:absolute after:-inset-s-(--padding-nav-item) after:translate-x-[1.5px] after:top-0 after:bottom-0 after:w-px after:bg-border">
142136
{category.items.map((item) => (

packages/zudoku/src/lib/plugins/openapi/ApiHeader.tsx

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,15 @@ import { Heading } from "../../components/Heading.js";
1818
import { Markdown } from "../../components/Markdown.js";
1919
import { useOasConfig } from "./context.js";
2020
import { DownloadSchemaButton } from "./DownloadSchemaButton.js";
21+
import { buildVersionSwitchUrl } from "./util/getRoutes.js";
2122

2223
type ApiHeaderProps = {
2324
title: string;
2425
heading: ReactNode;
2526
headingId: string;
2627
description?: string;
2728
children?: ReactNode;
29+
tag?: string;
2830
};
2931

3032
export const ApiHeader = ({
@@ -33,11 +35,12 @@ export const ApiHeader = ({
3335
headingId,
3436
description,
3537
children,
38+
tag,
3639
}: ApiHeaderProps) => {
3740
const { input, type, versions, version, options } = useOasConfig();
3841
const navigate = useNavigate();
3942

40-
const hasMultipleVersions = Object.entries(versions).length > 1;
43+
const hasMultipleVersions = Object.keys(versions).length > 1;
4144
const showVersions =
4245
options?.showVersionSelect === "always" ||
4346
(hasMultipleVersions && options?.showVersionSelect !== "hide");
@@ -50,6 +53,13 @@ export const ApiHeader = ({
5053
: currentVersion?.downloadUrl
5154
: undefined;
5255

56+
const handleVersionChange = (newVersion: string) => {
57+
const target = versions[newVersion];
58+
if (!target) return;
59+
60+
navigate(buildVersionSwitchUrl(target, tag));
61+
};
62+
5363
return (
5464
<Collapsible className="w-full" defaultOpen={options?.expandApiInformation}>
5565
<div className="flex flex-col gap-4 sm:flex-row justify-around items-start sm:items-end">
@@ -70,11 +80,8 @@ export const ApiHeader = ({
7080
<div className="flex gap-2 items-center">
7181
{showVersions && (
7282
<Select
73-
onValueChange={(v) =>
74-
// biome-ignore lint/style/noNonNullAssertion: is guaranteed to be defined
75-
navigate(versions[v]!.path)
76-
}
77-
defaultValue={version}
83+
onValueChange={handleVersionChange}
84+
value={version}
7885
disabled={!hasMultipleVersions}
7986
>
8087
<SelectTrigger className="w-[180px]" size="sm">

packages/zudoku/src/lib/plugins/openapi/OasProvider.tsx

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -19,22 +19,23 @@ export const OasProvider = ({
1919
client: GraphQLClient;
2020
}) => {
2121
const value = useMemo(() => {
22-
const {
23-
versions: availableVersions,
24-
labels,
25-
downloadUrls,
26-
} = getVersionMetadata(config);
22+
const { versions: availableVersions, versionMap } =
23+
getVersionMetadata(config);
2724
const currentVersion = version ?? availableVersions.at(0);
2825

29-
const versionLinks = Object.fromEntries(
30-
availableVersions.map((id) => [
31-
id,
32-
{
33-
path: joinUrl(basePath, id),
34-
label: labels[id] ?? id,
35-
downloadUrl: downloadUrls[id],
36-
},
37-
]),
26+
const versions = Object.fromEntries(
27+
availableVersions.map((id) => {
28+
const meta = versionMap[id];
29+
return [
30+
id,
31+
{
32+
path: joinUrl(basePath, id),
33+
label: meta?.label ?? id,
34+
downloadUrl: meta?.downloadUrl,
35+
tagPages: meta?.tagPages,
36+
},
37+
];
38+
}),
3839
);
3940

4041
const resolveInput = (): string | (() => Promise<unknown>) => {
@@ -57,7 +58,7 @@ export const OasProvider = ({
5758
config: {
5859
...config,
5960
version: currentVersion,
60-
versions: versionLinks,
61+
versions,
6162
input: resolveInput(),
6263
},
6364
};

packages/zudoku/src/lib/plugins/openapi/OperationList.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,7 @@ export const OperationList = ({
258258
heading={tagTitle}
259259
headingId="description"
260260
description={description ?? undefined}
261+
tag={tag ?? tagFromParams}
261262
>
262263
<Endpoint />
263264
</ApiHeader>

packages/zudoku/src/lib/plugins/openapi/interfaces.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export type VersionedInput<T = string> = {
1212
label?: string;
1313
input: T;
1414
hasUntaggedOperations?: boolean;
15+
tagPages?: string[];
1516
};
1617

1718
type OasSource =
@@ -85,11 +86,15 @@ type BaseOasConfig = {
8586

8687
export type OasPluginConfig = BaseOasConfig & OasSource;
8788

89+
export type VersionEntry = {
90+
path: string;
91+
label: string;
92+
downloadUrl?: string;
93+
tagPages?: string[];
94+
};
95+
8896
export type OasPluginContext = BaseOasConfig &
8997
ContextOasSource & {
9098
version?: string;
91-
versions: Record<
92-
string,
93-
{ path: string; label: string; downloadUrl?: string }
94-
>;
99+
versions: Record<string, VersionEntry>;
95100
};

packages/zudoku/src/lib/plugins/openapi/util/getRoutes.test.tsx

Lines changed: 54 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import { describe, expect, it } from "vitest";
22
import type { GraphQLClient } from "../client/GraphQLClient.js";
33
import type { OpenApiPluginOptions } from "../index.js";
4-
import { getRoutes, getVersionMetadata } from "./getRoutes.js";
4+
import {
5+
buildVersionSwitchUrl,
6+
getRoutes,
7+
getVersionMetadata,
8+
} from "./getRoutes.js";
59

610
const mockClient = {} as GraphQLClient;
711

@@ -13,23 +17,15 @@ const baseConfig: OpenApiPluginOptions = {
1317
describe("getVersionMetadata", () => {
1418
it("returns empty metadata for raw type", () => {
1519
const result = getVersionMetadata({ type: "raw", input: "{}" });
16-
expect(result).toEqual({
17-
versions: [],
18-
labels: {},
19-
downloadUrls: {},
20-
});
20+
expect(result).toEqual({ versions: [], versionMap: {} });
2121
});
2222

2323
it("returns empty metadata for non-array input", () => {
2424
const result = getVersionMetadata({
2525
type: "url",
2626
input: "https://example.com/openapi.json",
2727
});
28-
expect(result).toEqual({
29-
versions: [],
30-
labels: {},
31-
downloadUrls: {},
32-
});
28+
expect(result).toEqual({ versions: [], versionMap: {} });
3329
});
3430

3531
it("extracts versions, labels and download URLs from versioned input", () => {
@@ -50,12 +46,17 @@ describe("getVersionMetadata", () => {
5046
],
5147
});
5248

53-
expect(result).toEqual({
54-
versions: ["v1", "v2"],
55-
labels: { v1: "Version 1", v2: "v2" },
56-
downloadUrls: {
57-
v1: "https://example.com/v1.json",
58-
v2: "https://example.com/v2.json",
49+
expect(result.versions).toEqual(["v1", "v2"]);
50+
expect(result.versionMap).toEqual({
51+
v1: {
52+
label: "Version 1",
53+
downloadUrl: "https://example.com/v1.json",
54+
tagPages: undefined,
55+
},
56+
v2: {
57+
label: "v2",
58+
downloadUrl: "https://example.com/v2.json",
59+
tagPages: undefined,
5960
},
6061
});
6162
});
@@ -66,8 +67,11 @@ describe("getVersionMetadata", () => {
6667
input: [{ path: "v3", input: "https://example.com/v3.json" }],
6768
});
6869

69-
expect(result.labels).toEqual({ v3: "v3" });
70-
expect(result.downloadUrls).toEqual({ v3: undefined });
70+
expect(result.versionMap.v3).toEqual({
71+
label: "v3",
72+
downloadUrl: undefined,
73+
tagPages: undefined,
74+
});
7175
});
7276

7377
it("extracts metadata from file type with versioned input", () => {
@@ -83,10 +87,11 @@ describe("getVersionMetadata", () => {
8387
],
8488
});
8589

86-
expect(result).toEqual({
87-
versions: ["v1"],
88-
labels: { v1: "File V1" },
89-
downloadUrls: { v1: "https://example.com/v1.json" },
90+
expect(result.versions).toEqual(["v1"]);
91+
expect(result.versionMap.v1).toEqual({
92+
label: "File V1",
93+
downloadUrl: "https://example.com/v1.json",
94+
tagPages: undefined,
9095
});
9196
});
9297
});
@@ -466,3 +471,29 @@ describe("getRoutes", () => {
466471
).toBeDefined();
467472
});
468473
});
474+
475+
describe("buildVersionSwitchUrl", () => {
476+
it("preserves tag when it exists in target version", () => {
477+
const target = {
478+
path: "/api/v2",
479+
label: "V2",
480+
tagPages: ["users", "posts"],
481+
};
482+
expect(buildVersionSwitchUrl(target, "users")).toBe("/api/v2/users");
483+
});
484+
485+
it("falls back to version root when tag missing in target", () => {
486+
const target = { path: "/api/v2", label: "V2", tagPages: ["users"] };
487+
expect(buildVersionSwitchUrl(target, "analytics")).toBe("/api/v2");
488+
});
489+
490+
it("preserves tag when target has no tagPages (URL schemas)", () => {
491+
const target = { path: "/api/v2", label: "V2" };
492+
expect(buildVersionSwitchUrl(target, "users")).toBe("/api/v2/users");
493+
});
494+
495+
it("returns version root when no current tag", () => {
496+
const target = { path: "/api/v2", label: "V2", tagPages: ["users"] };
497+
expect(buildVersionSwitchUrl(target, undefined)).toBe("/api/v2");
498+
});
499+
});

0 commit comments

Comments
 (0)