Skip to content

Commit bc72f1f

Browse files
heiskrCopilot
andauthored
feat: add TOC transformer for Article API (#59069)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent 208324a commit bc72f1f

File tree

17 files changed

+697
-27
lines changed

17 files changed

+697
-27
lines changed

content/copilot/index.md

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -10,24 +10,6 @@ changelog:
1010
introLinks:
1111
overview: /copilot/get-started/what-is-github-copilot
1212
quickstart: /copilot/get-started/quickstart
13-
featuredLinks:
14-
startHere:
15-
- /copilot/get-started/what-is-github-copilot
16-
- '{% ifversion fpt %}/copilot/get-started/quickstart{% endif %}'
17-
- '{% ifversion fpt %}/copilot/tutorials/try-extensions{% endif %}'
18-
- '{% ifversion fpt %}/copilot/concepts/agents/coding-agent{% endif %}'
19-
- '{% ifversion ghec %}/copilot/get-started/choose-enterprise-plan{% endif %}'
20-
- '{% ifversion ghec %}/copilot/how-tos/set-up/set-up-for-enterprise{% endif %}'
21-
- '{% ifversion ghec %}/copilot/tutorials/coding-agent/pilot-coding-agent{% endif %}'
22-
popular:
23-
- /copilot/get-started/features
24-
- '{% ifversion fpt %}/copilot/tutorials/copilot-chat-cookbook{% endif %}'
25-
- '{% ifversion fpt %}/copilot/how-tos/get-code-suggestions/get-ide-code-suggestions{% endif %}'
26-
- '{% ifversion fpt %}/copilot/how-tos/chat-with-copilot/chat-in-ide{% endif %}'
27-
- '{% ifversion fpt %}/copilot/how-tos/use-copilot-for-common-tasks/use-copilot-in-the-cli{% endif %}'
28-
- '{% ifversion ghec %}/copilot/how-tos/manage-and-track-spending/manage-request-allowances{% endif %}'
29-
- '{% ifversion ghec %}/copilot/tutorials/roll-out-at-scale/enable-developers/drive-adoption{% endif %}'
30-
- '{% ifversion ghec %}/copilot/tutorials/roll-out-at-scale/enable-developers/integrate-ai-agents{% endif %}'
3113
layout: discovery-landing
3214
heroImage: /assets/images/banner-images/hero-6
3315
versions:

content/enterprise-onboarding/index.md

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,6 @@
11
---
22
title: Enterprise onboarding
33
intro: 'Onboard your company to {% data variables.product.prodname_ghe_cloud %} by following our recommended plan. You will set up teams with the access they need, create a policy framework to ensure compliance, and automate processes securely throughout your enterprise.'
4-
featuredLinks:
5-
startHere:
6-
- '/enterprise-onboarding/getting-started-with-your-enterprise'
7-
- '/enterprise-onboarding/adding-users-to-your-enterprise'
8-
- '/enterprise-onboarding/setting-up-organizations-and-teams'
9-
- '/enterprise-onboarding/support-for-your-enterprise'
10-
popular:
11-
- '/enterprise-onboarding/govern-people-and-repositories'
12-
- '/enterprise-onboarding/github-actions-for-your-enterprise'
134
layout: journey-landing
145
journeyTracks:
156
- id: 'getting_started'
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import type { Context, Page } from '@/types'
2+
import type { LinkData } from '@/article-api/transformers/types'
3+
4+
/**
5+
* Resolves link data (title, href, intro) for a given href and page
6+
*
7+
* This helper is used by landing page transformers to build link lists.
8+
* It resolves the page from an href, renders its title and intro, and
9+
* returns the canonical permalink.
10+
*
11+
* @param href - The href to resolve (can be relative or absolute)
12+
* @param languageCode - The language code for the current page
13+
* @param pathname - The current page's pathname (for relative resolution)
14+
* @param context - The rendering context
15+
* @param resolvePath - Function to resolve an href to a Page object
16+
* @returns LinkData with resolved title, href, and optional intro
17+
*/
18+
export async function getLinkData(
19+
href: string,
20+
languageCode: string,
21+
pathname: string,
22+
context: Context,
23+
resolvePath: (
24+
href: string,
25+
languageCode: string,
26+
pathname: string,
27+
context: Context,
28+
) => Page | undefined,
29+
): Promise<LinkData> {
30+
const linkedPage = resolvePath(href, languageCode, pathname, context)
31+
if (!linkedPage) return { href, title: href }
32+
33+
const title = await linkedPage.renderTitle(context, { unwrap: true })
34+
const intro = linkedPage.intro
35+
? await linkedPage.renderProp('intro', context, { textOnly: true })
36+
: ''
37+
38+
const permalink = linkedPage.permalinks.find(
39+
(p) => p.languageCode === languageCode && p.pageVersion === context.currentVersion,
40+
)
41+
const resolvedHref = permalink ? permalink.href : href
42+
43+
return {
44+
href: resolvedHref,
45+
title,
46+
intro,
47+
}
48+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { readFileSync } from 'fs'
2+
import { join, dirname } from 'path'
3+
import { fileURLToPath } from 'url'
4+
5+
// Get the directory path for the transformers directory
6+
// This will be used to resolve template paths relative to transformers
7+
const __filename = fileURLToPath(import.meta.url)
8+
const __dirname = dirname(__filename)
9+
10+
/**
11+
* Load a template file from the templates directory
12+
*
13+
* This helper loads Liquid template files used by transformers.
14+
* Templates are located in src/article-api/templates/
15+
*
16+
* @param templateName - The name of the template file (e.g., 'landing-page.template.md')
17+
* @returns The template content as a string
18+
*
19+
* @example
20+
* ```typescript
21+
* const template = loadTemplate('landing-page.template.md')
22+
* const rendered = await renderContent(template, context)
23+
* ```
24+
*/
25+
export function loadTemplate(templateName: string): string {
26+
// Templates are in ../templates relative to the lib directory
27+
// lib is at src/article-api/lib
28+
// templates is at src/article-api/templates
29+
const templatePath = join(__dirname, '../templates', templateName)
30+
return readFileSync(templatePath, 'utf8')
31+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import type { Context, Page } from '@/types'
2+
3+
/**
4+
* Resolves an href to a Page object from the context
5+
*
6+
* This function handles various href formats:
7+
* - External URLs (http/https) - returns undefined
8+
* - Language-prefixed absolute paths (/en/copilot/...) - direct lookup
9+
* - Absolute paths without language (/copilot/...) - adds language prefix
10+
* - Relative paths (get-started) - resolved relative to pathname
11+
*
12+
* The function searches through context.pages using multiple strategies:
13+
* 1. Direct key lookup with language prefix
14+
* 2. Relative path joining with current pathname
15+
* 3. endsWith matching for versioned keys (e.g., /en/enterprise-cloud@latest/...)
16+
*
17+
* @param href - The href to resolve
18+
* @param languageCode - The language code (e.g., 'en')
19+
* @param pathname - The current page's pathname (e.g., '/en/copilot')
20+
* @param context - The rendering context containing all pages
21+
* @returns The resolved Page object, or undefined if not found
22+
*
23+
* @example
24+
* ```typescript
25+
* // Absolute path with language
26+
* resolvePath('/en/copilot/quickstart', 'en', '/en/copilot', context)
27+
*
28+
* // Absolute path without language (adds /en/)
29+
* resolvePath('/copilot/quickstart', 'en', '/en/copilot', context)
30+
*
31+
* // Relative path (resolves to /en/copilot/quickstart)
32+
* resolvePath('quickstart', 'en', '/en/copilot', context)
33+
*
34+
* // Relative path with leading slash (resolves relative to pathname)
35+
* resolvePath('/quickstart', 'en', '/en/copilot', context) // -> /en/copilot/quickstart
36+
* ```
37+
*/
38+
export function resolvePath(
39+
href: string,
40+
languageCode: string,
41+
pathname: string,
42+
context: Context,
43+
): Page | undefined {
44+
// External URLs cannot be resolved
45+
if (href.startsWith('http://') || href.startsWith('https://')) {
46+
return undefined
47+
}
48+
49+
if (!context.pages) {
50+
return undefined
51+
}
52+
53+
// Normalize href to start with /
54+
const normalizedHref = href.startsWith('/') ? href : `/${href}`
55+
56+
// Build full path with language prefix if needed
57+
let fullPath: string
58+
if (normalizedHref.startsWith(`/${languageCode}/`)) {
59+
// Already has language prefix
60+
fullPath = normalizedHref
61+
} else if (href.startsWith('/') && !href.startsWith(`/${languageCode}/`)) {
62+
// Path with leading slash but no language prefix - treat as relative to pathname
63+
// e.g., pathname='/en/copilot', href='/get-started' -> '/en/copilot/get-started'
64+
fullPath = pathname + href
65+
} else {
66+
// Relative path - add language prefix
67+
// e.g., href='quickstart' -> '/en/quickstart'
68+
fullPath = `/${languageCode}${normalizedHref}`
69+
}
70+
71+
// Clean up trailing slashes
72+
const cleanPath = fullPath.replace(/\/$/, '')
73+
74+
// Strategy 1: Direct lookup
75+
if (context.pages[cleanPath]) {
76+
return context.pages[cleanPath]
77+
}
78+
79+
// Strategy 2: Try relative to current pathname
80+
const currentPath = pathname.replace(/\/$/, '')
81+
const relativeHref = href.startsWith('/') ? href.slice(1) : href
82+
const joinedPath = `${currentPath}/${relativeHref}`
83+
84+
if (context.pages[joinedPath]) {
85+
return context.pages[joinedPath]
86+
}
87+
88+
// Strategy 3: Search for keys that end with the path (handles versioned keys)
89+
// e.g., key='/en/enterprise-cloud@latest/copilot' should match path='/en/copilot'
90+
for (const [key, page] of Object.entries(context.pages)) {
91+
if (key.endsWith(cleanPath) || key.endsWith(`${cleanPath}/`)) {
92+
return page
93+
}
94+
}
95+
96+
// Strategy 4: If href started with /, try endsWith matching on that too
97+
if (href.startsWith('/')) {
98+
const hrefClean = href.replace(/\/$/, '')
99+
for (const [key, page] of Object.entries(context.pages)) {
100+
if (key.endsWith(hrefClean) || key.endsWith(`${hrefClean}/`)) {
101+
return page
102+
}
103+
}
104+
}
105+
106+
return undefined
107+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# {{ title }}
2+
3+
{% if intro %}
4+
{{ intro }}
5+
{% endif %}
6+
7+
{% for section in sections %}
8+
{% if section.title %}
9+
## {{ section.title }}
10+
{% endif %}
11+
12+
{% for group in section.groups %}
13+
{% if group.title %}
14+
### {{ group.title }}
15+
{% endif %}
16+
17+
{% for link in group.links %}
18+
* [{{ link.title }}]({{ link.href }})
19+
{% if link.intro %}
20+
{{ link.intro }}
21+
{% endif %}
22+
{% endfor %}
23+
24+
{% endfor %}
25+
{% endfor %}
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { describe, expect, test, vi } from 'vitest'
2+
import { getLinkData } from '@/article-api/lib/get-link-data'
3+
import type { Context, Page, Permalink } from '@/types'
4+
5+
// Helper to create a minimal mock page
6+
function createMockPage(options: {
7+
title?: string
8+
intro?: string
9+
permalinks?: Partial<Permalink>[]
10+
}): Page {
11+
const page = {
12+
title: options.title || 'Test Title',
13+
intro: options.intro,
14+
permalinks: (options.permalinks || []) as Permalink[],
15+
renderTitle: vi.fn().mockResolvedValue(options.title || 'Test Title'),
16+
renderProp: vi.fn().mockResolvedValue(options.intro || ''),
17+
}
18+
return page as unknown as Page
19+
}
20+
21+
// Helper to create a minimal context
22+
function createContext(currentVersion = 'free-pro-team@latest'): Context {
23+
return { currentVersion } as unknown as Context
24+
}
25+
26+
describe('getLinkData', () => {
27+
describe('when page is not found', () => {
28+
test('returns href as both href and title when page not resolved', async () => {
29+
const resolvePath = vi.fn().mockReturnValue(undefined)
30+
const context = createContext()
31+
32+
const result = await getLinkData(
33+
'/en/missing-page',
34+
'en',
35+
'/en/current',
36+
context,
37+
resolvePath,
38+
)
39+
40+
expect(result).toEqual({
41+
href: '/en/missing-page',
42+
title: '/en/missing-page',
43+
})
44+
})
45+
})
46+
47+
describe('when page is found', () => {
48+
test('returns rendered title from page', async () => {
49+
const page = createMockPage({ title: 'My Page Title' })
50+
const resolvePath = vi.fn().mockReturnValue(page)
51+
const context = createContext()
52+
53+
const result = await getLinkData('/en/some-page', 'en', '/en/current', context, resolvePath)
54+
55+
expect(result.title).toBe('My Page Title')
56+
expect(page.renderTitle).toHaveBeenCalledWith(context, { unwrap: true })
57+
})
58+
59+
test('returns rendered intro when page has intro', async () => {
60+
const page = createMockPage({
61+
title: 'Page',
62+
intro: 'This is the intro text',
63+
})
64+
const resolvePath = vi.fn().mockReturnValue(page)
65+
const context = createContext()
66+
67+
const result = await getLinkData('/en/some-page', 'en', '/en/current', context, resolvePath)
68+
69+
expect(result.intro).toBe('This is the intro text')
70+
expect(page.renderProp).toHaveBeenCalledWith('intro', context, { textOnly: true })
71+
})
72+
73+
test('returns empty intro when page has no intro', async () => {
74+
const page = createMockPage({ title: 'Page', intro: undefined })
75+
const resolvePath = vi.fn().mockReturnValue(page)
76+
const context = createContext()
77+
78+
const result = await getLinkData('/en/some-page', 'en', '/en/current', context, resolvePath)
79+
80+
expect(result.intro).toBe('')
81+
expect(page.renderProp).not.toHaveBeenCalled()
82+
})
83+
84+
test('uses permalink href when matching permalink found', async () => {
85+
const page = createMockPage({
86+
title: 'Page',
87+
permalinks: [
88+
{ languageCode: 'en', pageVersion: 'free-pro-team@latest', href: '/en/resolved-path' },
89+
],
90+
})
91+
const resolvePath = vi.fn().mockReturnValue(page)
92+
const context = createContext('free-pro-team@latest')
93+
94+
const result = await getLinkData(
95+
'/en/original-href',
96+
'en',
97+
'/en/current',
98+
context,
99+
resolvePath,
100+
)
101+
102+
expect(result.href).toBe('/en/resolved-path')
103+
})
104+
105+
test('falls back to original href when no matching permalink', async () => {
106+
const page = createMockPage({
107+
title: 'Page',
108+
permalinks: [{ languageCode: 'ja', pageVersion: 'free-pro-team@latest', href: '/ja/page' }],
109+
})
110+
const resolvePath = vi.fn().mockReturnValue(page)
111+
const context = createContext('free-pro-team@latest')
112+
113+
const result = await getLinkData(
114+
'/en/original-href',
115+
'en',
116+
'/en/current',
117+
context,
118+
resolvePath,
119+
)
120+
121+
expect(result.href).toBe('/en/original-href')
122+
})
123+
})
124+
125+
describe('resolvePath function usage', () => {
126+
test('passes correct arguments to resolvePath', async () => {
127+
const page = createMockPage({ title: 'Page' })
128+
const resolvePath = vi.fn().mockReturnValue(page)
129+
const context = createContext()
130+
131+
await getLinkData('/en/target', 'en', '/en/current', context, resolvePath)
132+
133+
expect(resolvePath).toHaveBeenCalledWith('/en/target', 'en', '/en/current', context)
134+
})
135+
})
136+
})

0 commit comments

Comments
 (0)