Skip to content

Commit 507f7d7

Browse files
sserrataclaude
andcommitted
fix(theme): support multiple x-codeSamples per language (#1204)
Allow multiple x-codeSamples entries that share the same `lang` to render as distinct inner tabs disambiguated by their `label`. Previously this crashed with `Duplicate values "Python, Python" found in <Tabs>` because `mergeCodeSampleLanguage` used `lang` as the per-sample identity, and the inner CodeTab's `value` and React `key` collided. - Build a unique id per sample (`${lang}-${label}` or `${lang}-${index}`) with a defensive collision suffix so duplicate-label specs render two visually identical tabs instead of crashing. - Fix two stale-key bugs in CodeSnippets/index.tsx (`lang.sample` and `lang.variant` used singular defaults inside `.map` iterations). - Reset `selectedSample` when the active language changes so the inner tab strip falls back to the new language's first sample. - Add `themeConfig.api.hideGeneratedSnippets` (default `false`, opt-in) to suppress Postman-generated snippets per-operation, per-language whenever `x-codeSamples` are provided for that language on that operation. Closes #1036 for users who opt in. - Add unit tests for `mergeCodeSampleLanguage` covering single-sample, multi-sample with/without labels, and duplicate-label collision. - Expand the demo `petstore.yaml` x-codeSamples fixture to exercise all five paths. - Document multi-sample usage and the new flag in vendor-extensions.mdx. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent b4ec363 commit 507f7d7

6 files changed

Lines changed: 295 additions & 35 deletions

File tree

demo/docs/vendor-extensions.mdx

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,46 @@ The OpenAPI plugin and theme recognize several [vendor extensions](https://swagg
2222
| `x-enumDescription` / `x-enumDescriptions` | Document individual enum values. |
2323

2424
Other ReDoc extensions such as `x-circular-ref`, `x-code-samples` (deprecated), `x-examples`, `x-ignoredHeaderParameters`, `x-nullable`, `x-servers`, `x-traitTag`, `x-additionalPropertiesName`, and `x-explicitMappingOnly` are detected but ignored when extracting custom extensions.
25+
26+
## `x-codeSamples`
27+
28+
`x-codeSamples` attaches one or more author-written code snippets to an operation. Each entry has a `lang`, an optional `label`, and a `source`. The plugin renders one outer tab per language and one inner tab per sample within that language.
29+
30+
### Multiple samples per language
31+
32+
Authors often want to show several variants of the same language — for example, three Python snippets covering different authentication flows. Provide a distinct `label` on each entry to disambiguate the inner tabs:
33+
34+
```yaml
35+
x-codeSamples:
36+
- lang: Python
37+
label: KeyPair Auth
38+
source: |
39+
# ...
40+
- lang: Python
41+
label: Basic Auth
42+
source: |
43+
# ...
44+
- lang: Python
45+
label: OAuth
46+
source: |
47+
# ...
48+
```
49+
50+
If `label` is omitted on entries that share a `lang`, the inner tabs fall back to indexed identifiers (`Python-0`, `Python-1`, …) and will display the bare language name on each tab. Adding a `label` is recommended whenever a language appears more than once.
51+
52+
If two entries share both the same `lang` and the same `label`, the page still renders — a numeric suffix is appended internally to keep tab identifiers unique — but the visible labels will be identical. Use unique labels to avoid reader confusion.
53+
54+
### Hiding generated snippets when custom samples exist
55+
56+
By default, custom `x-codeSamples` and the Postman-generated snippets (HTTP, cURL, language variants, etc.) render side by side inside each language tab. Set `themeConfig.api.hideGeneratedSnippets` to `true` to suppress the generated block on a per-operation, per-language basis whenever `x-codeSamples` are provided for that language:
57+
58+
```ts
59+
// docusaurus.config.ts
60+
themeConfig: {
61+
api: {
62+
hideGeneratedSnippets: true,
63+
},
64+
}
65+
```
66+
67+
Languages without any custom samples on a given operation continue to show the generated snippets normally. The default is `false`, which preserves existing behavior.

demo/examples/petstore.yaml

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,7 @@ paths:
197197
- OpenID: []
198198

199199
x-codeSamples:
200+
# Single sample, no label (existing case — fallback id is "C#-0")
200201
- lang: "C#"
201202
source: |
202203
PetStore.v1.Pet pet = new PetStore.v1.Pet();
@@ -214,6 +215,7 @@ paths:
214215
// Something wrong -- check response for errors
215216
Console.WriteLine(response.getRawResponse());
216217
}
218+
# Single sample, with label (existing case — id is "PHP-Custom")
217219
- lang: PHP
218220
label: Custom
219221
source: |
@@ -226,6 +228,75 @@ paths:
226228
} catch (UnprocessableEntityException $e) {
227229
var_dump($e->getErrors());
228230
}
231+
# Multiple samples for the same language, each with a distinct label
232+
# (issue #1204). Inner tabs render as KeyPair Auth / Basic Auth / OAuth.
233+
- lang: Python
234+
label: KeyPair Auth
235+
source: |
236+
import requests
237+
from cryptography.hazmat.primitives.serialization import load_pem_private_key
238+
239+
with open("private_key.pem", "rb") as f:
240+
key = load_pem_private_key(f.read(), password=None)
241+
token = sign_jwt(key) # implementation-specific
242+
requests.post(
243+
"https://example.com/v1/pet",
244+
headers={"Authorization": f"Bearer {token}"},
245+
json={"name": "Rex", "photoUrls": []},
246+
)
247+
- lang: Python
248+
label: Basic Auth
249+
source: |
250+
import requests
251+
from requests.auth import HTTPBasicAuth
252+
253+
requests.post(
254+
"https://example.com/v1/pet",
255+
auth=HTTPBasicAuth("user", "password"),
256+
json={"name": "Rex", "photoUrls": []},
257+
)
258+
- lang: Python
259+
label: OAuth
260+
source: |
261+
import requests
262+
from requests_oauthlib import OAuth2Session
263+
264+
oauth = OAuth2Session(client_id, token=token)
265+
oauth.post(
266+
"https://example.com/v1/pet",
267+
json={"name": "Rex", "photoUrls": []},
268+
)
269+
# Multiple samples for the same language with NO labels
270+
# (defensive indexed-id fallback — ids are PowerShell-7 and PowerShell-8).
271+
- lang: PowerShell
272+
source: |
273+
$pet = @{ name = "Rex"; photoUrls = @() } | ConvertTo-Json
274+
Invoke-RestMethod -Uri https://example.com/v1/pet -Method Post -Body $pet -ContentType application/json
275+
- lang: PowerShell
276+
source: |
277+
$headers = @{ Authorization = "Bearer $token" }
278+
$pet = @{ name = "Rex"; photoUrls = @() } | ConvertTo-Json
279+
Invoke-RestMethod -Uri https://example.com/v1/pet -Method Post -Headers $headers -Body $pet -ContentType application/json
280+
# Two samples sharing the same lang AND label (author bug — defensive
281+
# collision suffix keeps the page rendering instead of crashing).
282+
- lang: Java
283+
label: Auth
284+
source: |
285+
// OkHttp + bearer token
286+
Request request = new Request.Builder()
287+
.url("https://example.com/v1/pet")
288+
.header("Authorization", "Bearer " + token)
289+
.post(RequestBody.create(json, JSON))
290+
.build();
291+
- lang: Java
292+
label: Auth
293+
source: |
294+
// HttpClient (JDK 11+) with basic auth
295+
HttpRequest request = HttpRequest.newBuilder()
296+
.uri(URI.create("https://example.com/v1/pet"))
297+
.header("Authorization", "Basic " + encoded)
298+
.POST(BodyPublishers.ofString(json))
299+
.build();
229300
requestBody:
230301
$ref: "#/components/requestBodies/Pet"
231302
put:

packages/docusaurus-theme-openapi-docs/src/theme/ApiExplorer/CodeSnippets/index.tsx

Lines changed: 50 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import cloneDeep from "lodash/cloneDeep";
1717
import codegen from "postman-code-generators";
1818
import * as sdk from "postman-collection";
1919

20+
import type { ThemeConfig } from "docusaurus-theme-openapi-docs/src/types";
21+
2022
import { CodeSample, Language } from "./code-snippets-types";
2123
import {
2224
getCodeSampleSourceFromLanguage,
@@ -114,6 +116,10 @@ function CodeSnippets({
114116
encoding,
115117
});
116118

119+
const themeConfig = siteConfig.themeConfig as ThemeConfig;
120+
const hideGeneratedSnippets =
121+
themeConfig?.api?.hideGeneratedSnippets ?? false;
122+
117123
// User-defined languages array
118124
// Can override languageSet, change order of langs, override options and variants
119125
const userDefinedLanguageSet =
@@ -153,6 +159,14 @@ function CodeSnippets({
153159
getCodeSampleSourceFromLanguage(language)
154160
);
155161

162+
// Reset selectedSample whenever the active language changes so the inner
163+
// tab strip falls back to its first sample instead of trying to match an
164+
// id that belongs to a different language's samples.
165+
useEffect(() => {
166+
setSelectedSample(language?.samples?.[0]);
167+
// eslint-disable-next-line react-hooks/exhaustive-deps
168+
}, [language?.language]);
169+
156170
useEffect(() => {
157171
if (language && !!language.sample) {
158172
setCodeSampleCodeText(getCodeSampleSourceFromLanguage(language));
@@ -295,7 +309,7 @@ function CodeSnippets({
295309
? lang.samplesLabels[index]
296310
: sample
297311
}
298-
key={`${lang.language}-${lang.sample}`}
312+
key={`${lang.language}-${sample}`}
299313
attributes={{
300314
className: `openapi-tabs__code-item--sample`,
301315
}}
@@ -315,40 +329,42 @@ function CodeSnippets({
315329
)}
316330

317331
{/* Inner generated code snippets */}
318-
<CodeTabs
319-
className="openapi-tabs__code-container-inner"
320-
action={{
321-
setLanguage: setLanguage,
322-
setSelectedVariant: setSelectedVariant,
323-
}}
324-
includeVariant={true}
325-
currentLanguage={lang}
326-
defaultValue={selectedVariant}
327-
languageSet={mergedLangs}
328-
lazy
329-
>
330-
{lang.variants.map((variant, index) => {
331-
return (
332-
<CodeTab
333-
value={variant.toLowerCase()}
334-
label={variant.toUpperCase()}
335-
key={`${lang.language}-${lang.variant}`}
336-
attributes={{
337-
className: `openapi-tabs__code-item--variant`,
338-
}}
339-
>
340-
{/* @ts-ignore */}
341-
<ApiCodeBlock
342-
language={lang.highlight}
343-
className="openapi-explorer__code-block"
344-
showLineNumbers={true}
332+
{!(hideGeneratedSnippets && lang.samples?.length) && (
333+
<CodeTabs
334+
className="openapi-tabs__code-container-inner"
335+
action={{
336+
setLanguage: setLanguage,
337+
setSelectedVariant: setSelectedVariant,
338+
}}
339+
includeVariant={true}
340+
currentLanguage={lang}
341+
defaultValue={selectedVariant}
342+
languageSet={mergedLangs}
343+
lazy
344+
>
345+
{lang.variants.map((variant, index) => {
346+
return (
347+
<CodeTab
348+
value={variant.toLowerCase()}
349+
label={variant.toUpperCase()}
350+
key={`${lang.language}-${variant}`}
351+
attributes={{
352+
className: `openapi-tabs__code-item--variant`,
353+
}}
345354
>
346-
{codeText}
347-
</ApiCodeBlock>
348-
</CodeTab>
349-
);
350-
})}
351-
</CodeTabs>
355+
{/* @ts-ignore */}
356+
<ApiCodeBlock
357+
language={lang.highlight}
358+
className="openapi-explorer__code-block"
359+
showLineNumbers={true}
360+
>
361+
{codeText}
362+
</ApiCodeBlock>
363+
</CodeTab>
364+
);
365+
})}
366+
</CodeTabs>
367+
)}
352368
</CodeTab>
353369
);
354370
})}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
/* ============================================================================
2+
* Copyright (c) Palo Alto Networks
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
* ========================================================================== */
7+
8+
import { CodeSample, Language } from "./code-snippets-types";
9+
import { mergeCodeSampleLanguage } from "./languages";
10+
11+
const baseLang = (
12+
language: string,
13+
codeSampleLanguage: Language["codeSampleLanguage"]
14+
): Language => ({
15+
highlight: language,
16+
language,
17+
codeSampleLanguage,
18+
logoClass: language,
19+
variant: "http",
20+
variants: ["http"],
21+
});
22+
23+
describe("mergeCodeSampleLanguage", () => {
24+
it("returns the language unchanged when no codeSamples target it", () => {
25+
const langs = [baseLang("python", "Python")];
26+
const result = mergeCodeSampleLanguage(langs, []);
27+
expect(result[0]).toEqual(langs[0]);
28+
expect(result[0].samples).toBeUndefined();
29+
});
30+
31+
it("attaches a single sample with no label using an indexed id", () => {
32+
const samples: CodeSample[] = [{ lang: "Python", source: "print('hi')" }];
33+
const [py] = mergeCodeSampleLanguage(
34+
[baseLang("python", "Python")],
35+
samples
36+
);
37+
expect(py.samples).toEqual(["Python-0"]);
38+
expect(py.samplesLabels).toEqual(["Python"]);
39+
expect(py.samplesSources).toEqual(["print('hi')"]);
40+
expect(py.sample).toBe("Python-0");
41+
});
42+
43+
it("attaches a single sample with a label using lang-label id", () => {
44+
const samples: CodeSample[] = [
45+
{ lang: "PHP", label: "Custom", source: "<?php" },
46+
];
47+
const [php] = mergeCodeSampleLanguage([baseLang("php", "PHP")], samples);
48+
expect(php.samples).toEqual(["PHP-Custom"]);
49+
expect(php.samplesLabels).toEqual(["Custom"]);
50+
});
51+
52+
it("produces unique ids for multiple samples sharing a lang with distinct labels (#1204)", () => {
53+
const samples: CodeSample[] = [
54+
{ lang: "Python", label: "KeyPair Auth", source: "a" },
55+
{ lang: "Python", label: "Basic Auth", source: "b" },
56+
{ lang: "Python", label: "OAuth", source: "c" },
57+
];
58+
const [py] = mergeCodeSampleLanguage(
59+
[baseLang("python", "Python")],
60+
samples
61+
);
62+
expect(py.samples).toEqual([
63+
"Python-KeyPair Auth",
64+
"Python-Basic Auth",
65+
"Python-OAuth",
66+
]);
67+
expect(new Set(py.samples).size).toBe(3);
68+
expect(py.samplesLabels).toEqual(["KeyPair Auth", "Basic Auth", "OAuth"]);
69+
expect(py.samplesSources).toEqual(["a", "b", "c"]);
70+
});
71+
72+
it("produces unique indexed ids for multiple samples sharing a lang without labels", () => {
73+
const samples: CodeSample[] = [
74+
{ lang: "PowerShell", source: "x" },
75+
{ lang: "PowerShell", source: "y" },
76+
];
77+
const [ps] = mergeCodeSampleLanguage(
78+
[baseLang("powershell", "PowerShell")],
79+
samples
80+
);
81+
expect(ps.samples).toEqual(["PowerShell-0", "PowerShell-1"]);
82+
expect(new Set(ps.samples).size).toBe(2);
83+
expect(ps.samplesLabels).toEqual(["PowerShell", "PowerShell"]);
84+
});
85+
86+
it("defensively suffixes ids when lang+label collides", () => {
87+
const samples: CodeSample[] = [
88+
{ lang: "Java", label: "Auth", source: "a" },
89+
{ lang: "Java", label: "Auth", source: "b" },
90+
];
91+
const [java] = mergeCodeSampleLanguage([baseLang("java", "Java")], samples);
92+
expect(java.samples).toEqual(["Java-Auth", "Java-Auth-1"]);
93+
expect(new Set(java.samples).size).toBe(2);
94+
expect(java.samplesLabels).toEqual(["Auth", "Auth"]);
95+
});
96+
97+
it("filters codeSamples by codeSampleLanguage and leaves other languages alone", () => {
98+
const samples: CodeSample[] = [
99+
{ lang: "Python", label: "A", source: "a" },
100+
{ lang: "Java", label: "B", source: "b" },
101+
];
102+
const result = mergeCodeSampleLanguage(
103+
[baseLang("python", "Python"), baseLang("php", "PHP")],
104+
samples
105+
);
106+
expect(result[0].samples).toEqual(["Python-A"]);
107+
expect(result[1].samples).toBeUndefined();
108+
});
109+
});

packages/docusaurus-theme-openapi-docs/src/theme/ApiExplorer/CodeSnippets/languages.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,24 @@ export function mergeCodeSampleLanguage(
2222
);
2323

2424
if (languageCodeSamples.length) {
25-
const samples = languageCodeSamples.map(({ lang }) => lang);
2625
const samplesLabels = languageCodeSamples.map(
2726
({ label, lang }) => label || lang
2827
);
2928
const samplesSources = languageCodeSamples.map(({ source }) => source);
3029

30+
// Build a unique id per sample for use as the inner Tab's `value`.
31+
// Prefer `${lang}-${label}`; fall back to `${lang}-${index}` when no
32+
// label is provided. Defensively suffix with `-${index}` on collision
33+
// so duplicate-label specs render two visually identical tabs instead
34+
// of crashing Docusaurus's unique-value check.
35+
const seen = new Set<string>();
36+
const samples = languageCodeSamples.map((cs, i) => {
37+
const base = cs.label ? `${cs.lang}-${cs.label}` : `${cs.lang}-${i}`;
38+
const id = seen.has(base) ? `${base}-${i}` : base;
39+
seen.add(id);
40+
return id;
41+
});
42+
3143
return {
3244
...language,
3345
sample: samples[0],

0 commit comments

Comments
 (0)