Skip to content

Commit 6bf35f9

Browse files
sunbryeCopilotCopilotsarahs
authored
Add per-language code tabs for SDK documentation (#61324)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Sarah Schneider <sarahs@users.noreply.github.com>
1 parent b217a9d commit 6bf35f9

13 files changed

Lines changed: 526 additions & 21 deletions

File tree

data/ui.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ picker:
2525
product_picker_default_text: All products
2626
version_picker_default_text: Choose a version
2727

28+
code_tabs:
29+
aria_label: Code languages
30+
2831
release_notes:
2932
banner_text: GitHub began rolling these changes out to enterprises on
3033

src/code-tabs/lib/languages.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export const codeLanguages: Record<string, string> = {
2+
typescript: 'TypeScript',
3+
python: 'Python',
4+
go: 'Go',
5+
csharp: 'C#',
6+
java: 'Java',
7+
ruby: 'Ruby',
8+
shell: 'Shell',
9+
javascript: 'JavaScript',
10+
dotnet: '.NET',
11+
}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { encode } from 'html-entities'
2+
import { TokenizationError, type TagToken, type TopLevelToken } from 'liquidjs'
3+
4+
import { codeLanguages } from '@/code-tabs/lib/languages'
5+
6+
interface LiquidTemplate {
7+
[key: string]: unknown
8+
}
9+
10+
interface LiquidStream {
11+
on(event: string, callback: (template?: LiquidTemplate) => void): LiquidStream
12+
stop(): void
13+
start(): void
14+
}
15+
16+
interface LiquidEngine {
17+
parser: {
18+
parseStream(tokens: TopLevelToken[]): LiquidStream
19+
}
20+
renderer: {
21+
renderTemplates(templates: LiquidTemplate[], scope: Record<string, unknown>): string
22+
}
23+
parseAndRender(template: string, context: Record<string, string>): string
24+
}
25+
26+
interface LiquidBlockTag {
27+
type: 'block'
28+
templates: LiquidTemplate[]
29+
liquid: LiquidEngine | null
30+
parse(tagToken: TagToken, remainTokens: TopLevelToken[]): void
31+
render(scope: Record<string, unknown>): Generator<unknown, unknown, unknown>
32+
}
33+
34+
const codeTabsTemplate = '<div class="ghd-codetabs">{{ output }}</div>\n'
35+
const codeTabTemplate =
36+
'<div class="ghd-codetab" data-lang="{{ languageKey }}" data-label="{{ label }}"><div class="ghd-codetab-fallback-label" role="heading" aria-level="3">{{ label }}</div>{{ output }}</div>\n'
37+
const codeTabSyntax = /^(?<key>[a-z0-9-]+)(?:\s+"(?<label>[^"]+)")?$/
38+
const codeTabSyntaxHelp =
39+
'Syntax Error in tag \'codetab\' - Valid syntax: codetab <language-key> ["Label"]'
40+
41+
export const tags = ['codetabs', 'codetab']
42+
43+
function parseBlockTemplates(
44+
context: LiquidBlockTag,
45+
tagToken: TagToken,
46+
remainTokens: TopLevelToken[],
47+
endTagName: string,
48+
): void {
49+
context.templates = []
50+
51+
const stream = context.liquid!.parser.parseStream(remainTokens)
52+
stream
53+
.on(`tag:${endTagName}`, () => stream.stop())
54+
.on('template', (template?: LiquidTemplate) => {
55+
if (template) context.templates.push(template)
56+
})
57+
.on('end', () => {
58+
throw new Error(`tag ${tagToken.getText()} not closed`)
59+
})
60+
stream.start()
61+
}
62+
63+
export const CodeTabs: LiquidBlockTag = {
64+
type: 'block',
65+
templates: [],
66+
liquid: null,
67+
68+
parse(tagToken: TagToken, remainTokens: TopLevelToken[]): void {
69+
if (tagToken.args.trim()) {
70+
throw new TokenizationError(
71+
"Syntax Error in tag 'codetabs' - This tag does not accept arguments",
72+
tagToken,
73+
)
74+
}
75+
76+
parseBlockTemplates(this, tagToken, remainTokens, 'endcodetabs')
77+
},
78+
79+
*render(scope: Record<string, unknown>): Generator<unknown, unknown, unknown> {
80+
const output = yield this.liquid!.renderer.renderTemplates(this.templates, scope)
81+
return yield this.liquid!.parseAndRender(codeTabsTemplate, { output })
82+
},
83+
}
84+
85+
interface CodeTabTag extends LiquidBlockTag {
86+
languageKey: string
87+
label: string
88+
}
89+
90+
export const CodeTab: CodeTabTag = {
91+
type: 'block',
92+
templates: [],
93+
liquid: null,
94+
languageKey: '',
95+
label: '',
96+
97+
parse(tagToken: TagToken, remainTokens: TopLevelToken[]): void {
98+
const args = tagToken.args.trim()
99+
const match = args.match(codeTabSyntax)
100+
101+
if (!match?.groups) {
102+
throw new TokenizationError(codeTabSyntaxHelp, tagToken)
103+
}
104+
105+
const { key, label } = match.groups
106+
if (!Object.prototype.hasOwnProperty.call(codeLanguages, key)) {
107+
throw new TokenizationError(
108+
`Unknown codetab language '${key}'. Valid values: ${Object.keys(codeLanguages).join(', ')}`,
109+
tagToken,
110+
)
111+
}
112+
113+
this.languageKey = encode(key)
114+
this.label = encode(label || codeLanguages[key])
115+
116+
parseBlockTemplates(this, tagToken, remainTokens, 'endcodetab')
117+
},
118+
119+
*render(scope: Record<string, unknown>): Generator<unknown, unknown, unknown> {
120+
const output = yield this.liquid!.renderer.renderTemplates(this.templates, scope)
121+
return yield this.liquid!.parseAndRender(codeTabTemplate, {
122+
languageKey: this.languageKey,
123+
label: this.label,
124+
output,
125+
})
126+
},
127+
}

src/content-render/liquid/engine.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import Ifversion from './ifversion'
66
import { Tool, tags as toolTags } from './tool'
77
import { Spotlight, tags as spotlightTags } from './spotlight'
88
import { Prompt } from './prompt'
9+
import { CodeTab, CodeTabs, tags as codeTabTags } from './codetabs'
910
import IndentedDataReference from './indented-data-reference'
1011

1112
type LiquidTagDef = Parameters<Liquid['registerTag']>[1]
@@ -15,6 +16,8 @@ const ifversionTag = Ifversion as unknown as LiquidTagDef
1516
const toolTag = Tool as unknown as LiquidTagDef
1617
const spotlightTag = Spotlight as unknown as LiquidTagDef
1718
const promptTag = Prompt as unknown as LiquidTagDef
19+
const codeTabsTag = CodeTabs as unknown as LiquidTagDef
20+
const codeTabTag = CodeTab as unknown as LiquidTagDef
1821
const indentedDataReferenceTag = IndentedDataReference as unknown as LiquidTagDef
1922

2023
export const engine = new Liquid({
@@ -35,6 +38,10 @@ for (const tag in spotlightTags) {
3538
engine.registerTag(tag, spotlightTag)
3639
}
3740

41+
for (const tag of codeTabTags) {
42+
engine.registerTag(tag, tag === 'codetabs' ? codeTabsTag : codeTabTag)
43+
}
44+
3845
engine.registerTag('prompt', promptTag)
3946

4047
/**

src/events/lib/schema.ts

Lines changed: 24 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { languageKeys } from '@/languages/lib/languages-server'
22
import { allVersionKeys } from '@/versions/lib/all-versions'
33
import { productIds } from '@/products/lib/all-products'
44
import { allTools } from '@/tools/lib/all-tools'
5+
import { codeLanguages } from '@/code-tabs/lib/languages'
56
import { contentTypesEnum } from '@/frame/lib/frontmatter'
67

78
const versionPattern = '^\\d+(\\.\\d+)?(\\.\\d+)?$'
@@ -613,29 +614,34 @@ const preference = {
613614
},
614615
preference_name: {
615616
type: 'string',
616-
enum: ['application', 'color_mode', 'os', 'code_display'],
617-
description: 'The preference name, such as os, application, or color_mode',
617+
enum: ['application', 'color_mode', 'os', 'code_display', 'code_language'],
618+
description: 'The preference name, such as os, application, color_mode, or code_language',
618619
},
619620
preference_value: {
620621
type: 'string',
621622
enum: [
622-
// application
623-
...Object.keys(allTools),
624-
// color_mode
625-
'dark',
626-
'light',
627-
'auto',
628-
'auto:dark',
629-
'auto:light',
630-
// os
631-
'linux',
632-
'mac',
633-
'windows',
634-
// code_display
635-
'beside',
636-
'inline',
623+
...new Set([
624+
// application
625+
...Object.keys(allTools),
626+
// color_mode
627+
'dark',
628+
'light',
629+
'auto',
630+
'auto:dark',
631+
'auto:light',
632+
// os
633+
'linux',
634+
'mac',
635+
'windows',
636+
// code_display
637+
'beside',
638+
'inline',
639+
// code_language (may overlap with allTools, e.g. 'javascript')
640+
...Object.keys(codeLanguages),
641+
]),
637642
],
638-
description: 'The application, color_mode, os, or code_display selected by the user.',
643+
description:
644+
'The application, color_mode, os, code_display, or code_language selected by the user.',
639645
},
640646
},
641647
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
---
2+
title: Code tabs test
3+
versions:
4+
fpt: "*"
5+
---
6+
7+
## Basic code tabs
8+
9+
{% codetabs %}
10+
{% codetab typescript %}
11+
12+
```typescript
13+
import { CopilotClient } from "@github/copilot-sdk";
14+
15+
const client = new CopilotClient();
16+
const session = await client.createSession({
17+
model: "gpt-4.1",
18+
});
19+
```
20+
21+
{% endcodetab %}
22+
{% codetab python %}
23+
24+
```python
25+
from copilot import CopilotClient
26+
27+
client = CopilotClient()
28+
await client.start()
29+
session = await client.create_session(model='gpt-4.1')
30+
```
31+
32+
{% endcodetab %}
33+
{% codetab go %}
34+
35+
```golang
36+
package main
37+
38+
import copilot "github.com/github/copilot-sdk/go"
39+
40+
func main() {
41+
client := copilot.NewClient(nil)
42+
client.Start(ctx)
43+
}
44+
```
45+
46+
{% endcodetab %}
47+
{% endcodetabs %}
48+
49+
## Synced language groups
50+
51+
{% codetabs %}
52+
{% codetab python "Python" %}
53+
54+
```python
55+
client = CopilotClient()
56+
await client.start()
57+
```
58+
59+
{% endcodetab %}
60+
{% codetab typescript "TypeScript" %}
61+
62+
```typescript
63+
const client = new CopilotClient();
64+
await client.start();
65+
```
66+
67+
{% endcodetab %}
68+
{% endcodetabs %}

src/fixtures/fixtures/content/get-started/liquid/index.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22
title: Liquid tags
33
intro: Testing various fancy Liquid rendering functionality
44
versions:
5-
fpt: '*'
6-
ghes: '*'
7-
ghec: '*'
5+
fpt: "*"
6+
ghes: "*"
7+
ghec: "*"
88
children:
99
- /warnings
1010
- /danger
@@ -20,4 +20,5 @@ children:
2020
- /tool-platform-switcher
2121
- /tool-picker-issue
2222
- /data
23+
- /code-tabs-test
2324
---

src/fixtures/fixtures/data/ui.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ picker:
2525
product_picker_default_text: All products
2626
version_picker_default_text: Choose a version
2727

28+
code_tabs:
29+
aria_label: Code languages
30+
2831
release_notes:
2932
banner_text: GitHub began rolling these changes out to enterprises on
3033

src/fixtures/tests/liquid.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,17 @@ describe('tool', () => {
6666
})
6767
})
6868

69+
describe('codetabs', () => {
70+
test('renders code tabs with language metadata', async () => {
71+
const $: CheerioAPI = await getDOM('/get-started/liquid/code-tabs-test')
72+
73+
expect($('.ghd-codetabs').length).toBe(2)
74+
expect($('.ghd-codetab[data-lang="typescript"][data-label="TypeScript"]').length).toBe(2)
75+
expect($('.ghd-codetab[data-lang="python"][data-label="Python"]').length).toBe(2)
76+
expect($('.ghd-codetab-fallback-label').first().text()).toBe('TypeScript')
77+
})
78+
})
79+
6980
describe('post', () => {
7081
test('whitespace control', async () => {
7182
const $: CheerioAPI = await getDOM('/get-started/liquid/whitespace')

src/fixtures/tests/playwright-rendering.spec.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,44 @@ test.describe('tool picker', () => {
310310
})
311311
})
312312

313+
test.describe('code tabs', () => {
314+
test('switch languages across groups', async ({ page }) => {
315+
await page.goto('/get-started/liquid/code-tabs-test')
316+
await turnOffExperimentsInPage(page)
317+
318+
const firstGroup = page.locator('.ghd-codetabs').nth(0)
319+
const secondGroup = page.locator('.ghd-codetabs').nth(1)
320+
321+
await expect(firstGroup.getByRole('link', { name: 'TypeScript' })).toHaveAttribute(
322+
'aria-current',
323+
'page',
324+
)
325+
await firstGroup.getByRole('link', { name: 'Python' }).click()
326+
327+
await expect(firstGroup.getByRole('link', { name: 'Python' })).toHaveAttribute(
328+
'aria-current',
329+
'page',
330+
)
331+
await expect(secondGroup.getByRole('link', { name: 'Python' })).toHaveAttribute(
332+
'aria-current',
333+
'page',
334+
)
335+
await expect(firstGroup.getByText('from copilot import CopilotClient')).toBeVisible()
336+
await expect(firstGroup.getByText('@github/copilot-sdk')).not.toBeVisible()
337+
})
338+
339+
test('remembers the last selected language', async ({ page }) => {
340+
await page.goto('/get-started/liquid/code-tabs-test')
341+
await turnOffExperimentsInPage(page)
342+
await page.locator('.ghd-codetabs').nth(0).getByRole('link', { name: 'Python' }).click()
343+
344+
await page.goto('/get-started/liquid/code-tabs-test')
345+
await expect(
346+
page.locator('.ghd-codetabs').nth(0).getByRole('link', { name: 'Python' }),
347+
).toHaveAttribute('aria-current', 'page')
348+
})
349+
})
350+
313351
test('navigate with side bar into article inside a subcategory inside a category', async ({
314352
page,
315353
}) => {

0 commit comments

Comments
 (0)