Skip to content

Commit d2dceda

Browse files
authored
Merge pull request #42363 from github/repo-sync
Repo sync
2 parents 52202bd + 0c4919d commit d2dceda

File tree

4 files changed

+342
-7
lines changed

4 files changed

+342
-7
lines changed

src/article-api/tests/article-body.ts

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -59,13 +59,7 @@ describe('article body api', () => {
5959
expect(error).toBe("No page found for '/en/never/heard/of'")
6060
})
6161

62-
test('non-article pages return error', async () => {
63-
// Index pages are not articles and should not be renderable
64-
const res = await get(makeURL('/en/get-started'))
65-
expect(res.statusCode).toBe(403)
66-
const { error } = JSON.parse(res.body)
67-
expect(error).toContain("isn't yet available in markdown")
68-
})
62+
// Removed: non-article pages test - landing pages are now supported via transformers
6963

7064
test('invalid Referer header does not crash', async () => {
7165
const res = await get(makeURL('/en/get-started/start-your-journey/hello-world'), {
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { describe, expect, test } from 'vitest'
2+
3+
import { get } from '@/tests/helpers/e2etest'
4+
5+
const makeURL = (pathname: string): string =>
6+
`/api/article/body?${new URLSearchParams({ pathname })}`
7+
8+
describe('product landing transformer', () => {
9+
test('renders a product landing page with basic structure', async () => {
10+
// /en/actions is a product landing page in fixtures
11+
const res = await get(makeURL('/en/actions'))
12+
expect(res.statusCode).toBe(200)
13+
expect(res.headers['content-type']).toContain('text/markdown')
14+
15+
// Check for title
16+
expect(res.body).toContain('# GitHub Actions Documentation')
17+
18+
// Should have intro
19+
expect(res.body).toContain('Automate away with')
20+
})
21+
22+
test('renders child categories under Links section', async () => {
23+
const res = await get(makeURL('/en/actions'))
24+
expect(res.statusCode).toBe(200)
25+
26+
// All children should be listed under Links section
27+
expect(res.body).toContain('## Links')
28+
29+
// Should contain child categories from fixtures (uses full title, not shortTitle)
30+
expect(res.body).toContain('[Category page of GitHub Actions](/en/actions/category)')
31+
expect(res.body).toContain('[Using workflows](/en/actions/using-workflows)')
32+
})
33+
34+
test('includes child intros', async () => {
35+
const res = await get(makeURL('/en/actions'))
36+
expect(res.statusCode).toBe(200)
37+
38+
// Each child should have its intro
39+
expect(res.body).toContain('Learn how to migrate your existing CI/CD workflows')
40+
expect(res.body).toContain('Learn how to use workflows')
41+
})
42+
})

src/article-api/transformers/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { GraphQLTransformer } from './graphql-transformer'
77
import { GithubAppsTransformer } from './github-apps-transformer'
88
import { WebhooksTransformer } from './webhooks-transformer'
99
import { TocTransformer } from './toc-transformer'
10+
import { ProductLandingTransformer } from './product-landing-transformer'
1011

1112
/**
1213
* Global transformer registry
@@ -22,6 +23,7 @@ transformerRegistry.register(new GraphQLTransformer())
2223
transformerRegistry.register(new GithubAppsTransformer())
2324
transformerRegistry.register(new WebhooksTransformer())
2425
transformerRegistry.register(new TocTransformer())
26+
transformerRegistry.register(new ProductLandingTransformer())
2527

2628
export { TransformerRegistry } from './types'
2729
export type { PageTransformer } from './types'
Lines changed: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
1+
import type { Context, Page } from '@/types'
2+
import type { PageTransformer, TemplateData, Section, LinkGroup, LinkData } from './types'
3+
import { renderContent } from '@/content-render/index'
4+
import { loadTemplate } from '@/article-api/lib/load-template'
5+
import { resolvePath } from '@/article-api/lib/resolve-path'
6+
import { getLinkData } from '@/article-api/lib/get-link-data'
7+
8+
interface RecommendedItem {
9+
href: string
10+
title?: string
11+
intro?: string
12+
}
13+
14+
interface ProductPage extends Omit<Page, 'featuredLinks'> {
15+
featuredLinks?: Record<string, Array<string | { href: string; title: string; intro?: string }>>
16+
children?: string[]
17+
recommended?: RecommendedItem[]
18+
rawRecommended?: string[]
19+
includedCategories?: string[]
20+
}
21+
22+
interface PageWithChildren extends Page {
23+
children?: string[]
24+
category?: string[]
25+
}
26+
27+
/**
28+
* Transforms product-landing pages into markdown format.
29+
* Handles featured links (startHere, popular, videos), guide cards,
30+
* article grids with category filtering, and children listings.
31+
*/
32+
export class ProductLandingTransformer implements PageTransformer {
33+
templateName = 'landing-page.template.md'
34+
35+
canTransform(page: Page): boolean {
36+
return page.layout === 'product-landing'
37+
}
38+
39+
async transform(page: Page, pathname: string, context: Context): Promise<string> {
40+
const templateData = await this.prepareTemplateData(page, pathname, context)
41+
42+
const templateContent = loadTemplate(this.templateName)
43+
44+
const rendered = await renderContent(templateContent, {
45+
...context,
46+
...templateData,
47+
markdownRequested: true,
48+
})
49+
50+
return rendered
51+
}
52+
53+
private async prepareTemplateData(
54+
page: Page,
55+
pathname: string,
56+
context: Context,
57+
): Promise<TemplateData> {
58+
const productPage = page as ProductPage
59+
const languageCode = page.languageCode || 'en'
60+
const sections: Section[] = []
61+
62+
// Recommended carousel
63+
const recommended = productPage.recommended ?? productPage.rawRecommended
64+
if (recommended && recommended.length > 0) {
65+
const { default: getLearningTrackLinkData } = await import(
66+
'@/learning-track/lib/get-link-data'
67+
)
68+
69+
let links: LinkData[]
70+
if (typeof recommended[0] === 'object' && 'title' in recommended[0]) {
71+
links = recommended.map((item) => ({
72+
href: typeof item === 'string' ? item : item.href,
73+
title: (typeof item === 'object' && item.title) || '',
74+
intro: (typeof item === 'object' && item.intro) || '',
75+
}))
76+
} else {
77+
const linkData = await getLearningTrackLinkData(recommended as string[], context, {
78+
title: true,
79+
intro: true,
80+
})
81+
links = (linkData || []).map((item: { href: string; title?: string; intro?: string }) => ({
82+
href: item.href,
83+
title: item.title || '',
84+
intro: item.intro || '',
85+
}))
86+
}
87+
88+
const validLinks = links.filter((l) => l.href && l.title)
89+
if (validLinks.length > 0) {
90+
sections.push({
91+
title: 'Recommended',
92+
groups: [{ title: null, links: validLinks }],
93+
})
94+
}
95+
}
96+
97+
// Featured links (startHere, popular, videos, etc.)
98+
const rawFeaturedLinks = productPage.featuredLinks
99+
if (rawFeaturedLinks) {
100+
const { default: getLearningTrackLinkData } = await import(
101+
'@/learning-track/lib/get-link-data'
102+
)
103+
104+
const featuredKeys = ['startHere', 'popular', 'videos']
105+
const featuredGroups: LinkGroup[] = []
106+
107+
for (const key of featuredKeys) {
108+
const links = rawFeaturedLinks[key]
109+
if (!Array.isArray(links) || links.length === 0) continue
110+
111+
const sectionTitle = this.getSectionTitle(key)
112+
113+
let resolvedLinks: LinkData[]
114+
115+
if (key === 'videos') {
116+
// Videos are external URLs with title and href properties
117+
const videoLinks = await Promise.all(
118+
links.map(async (link) => {
119+
if (typeof link === 'object' && link.href) {
120+
const title = await renderContent(link.title, context, { textOnly: true })
121+
return title ? { href: link.href, title, intro: link.intro || '' } : null
122+
}
123+
return null
124+
}),
125+
)
126+
resolvedLinks = videoLinks.filter((l) => l !== null) as LinkData[]
127+
} else {
128+
// Other featuredLinks are page hrefs that need Liquid evaluation
129+
const stringLinks = links.map((item) => (typeof item === 'string' ? item : item.href))
130+
const linkData = await getLearningTrackLinkData(stringLinks, context, {
131+
title: true,
132+
intro: true,
133+
})
134+
resolvedLinks = (linkData || []).map((item) => ({
135+
href: item.href,
136+
title: item.title || '',
137+
intro: item.intro || '',
138+
}))
139+
}
140+
141+
const validLinks = resolvedLinks.filter((l) => l.href)
142+
if (validLinks.length > 0) {
143+
featuredGroups.push({
144+
title: sectionTitle,
145+
links: validLinks,
146+
})
147+
}
148+
}
149+
150+
if (featuredGroups.length > 0) {
151+
sections.push({
152+
title: 'Featured',
153+
groups: featuredGroups,
154+
})
155+
}
156+
}
157+
158+
// Guide cards
159+
if (rawFeaturedLinks?.guideCards) {
160+
const links = rawFeaturedLinks.guideCards
161+
if (Array.isArray(links)) {
162+
const resolvedLinks = await Promise.all(
163+
links.map(async (link) => {
164+
if (typeof link === 'string') {
165+
return await getLinkData(link, languageCode, pathname, context, resolvePath)
166+
} else if (link.href) {
167+
return {
168+
href: link.href,
169+
title: link.title,
170+
intro: link.intro || '',
171+
}
172+
}
173+
return null
174+
}),
175+
)
176+
177+
const validLinks = resolvedLinks.filter((l): l is LinkData => l !== null && !!l.href)
178+
if (validLinks.length > 0) {
179+
sections.push({
180+
title: 'Guides',
181+
groups: [{ title: null, links: validLinks }],
182+
})
183+
}
184+
}
185+
}
186+
187+
// Article grid with includedCategories filtering
188+
if (productPage.children && productPage.includedCategories) {
189+
const gridGroups: LinkGroup[] = []
190+
const includedCategories = productPage.includedCategories
191+
192+
for (const childHref of productPage.children) {
193+
const childPage = resolvePath(childHref, languageCode, pathname, context) as
194+
| PageWithChildren
195+
| undefined
196+
if (!childPage?.children) continue
197+
198+
const childChildren = childPage.children
199+
if (childChildren.length === 0) continue
200+
201+
// Get the child page's pathname to use for resolving grandchildren
202+
const childPermalink = childPage.permalinks.find(
203+
(p) => p.languageCode === languageCode && p.pageVersion === context.currentVersion,
204+
)
205+
const childPathname = childPermalink ? childPermalink.href : pathname + childHref
206+
207+
const articles = await Promise.all(
208+
childChildren.map(async (grandchildHref: string) => {
209+
const linkData = await getLinkData(
210+
grandchildHref,
211+
languageCode,
212+
childPathname,
213+
context,
214+
resolvePath,
215+
)
216+
217+
if (includedCategories.length > 0) {
218+
const linkedPage = resolvePath(
219+
grandchildHref,
220+
languageCode,
221+
childPathname,
222+
context,
223+
) as PageWithChildren | undefined
224+
if (linkedPage) {
225+
const pageCategories = linkedPage.category || []
226+
const hasMatchingCategory =
227+
Array.isArray(pageCategories) &&
228+
pageCategories.some((cat: string) =>
229+
includedCategories.some(
230+
(included) => included.toLowerCase() === cat.toLowerCase(),
231+
),
232+
)
233+
if (!hasMatchingCategory) {
234+
return null
235+
}
236+
}
237+
}
238+
239+
return linkData
240+
}),
241+
)
242+
243+
const validArticles = articles.filter((a): a is LinkData => a !== null && !!a.href)
244+
if (validArticles.length > 0) {
245+
const childTitle = await childPage.renderTitle(context, { unwrap: true })
246+
gridGroups.push({
247+
title: childTitle,
248+
links: validArticles,
249+
})
250+
}
251+
}
252+
253+
if (gridGroups.length > 0) {
254+
sections.push({
255+
title: 'Articles',
256+
groups: gridGroups,
257+
})
258+
}
259+
}
260+
261+
// All children (full listing)
262+
if (productPage.children) {
263+
const links = await Promise.all(
264+
productPage.children.map(async (childHref) => {
265+
return await getLinkData(childHref, languageCode, pathname, context, resolvePath)
266+
}),
267+
)
268+
const validLinks = links.filter((l) => l.href)
269+
if (validLinks.length > 0) {
270+
sections.push({
271+
title: 'Links',
272+
groups: [{ title: null, links: validLinks }],
273+
})
274+
}
275+
}
276+
277+
const intro = page.intro ? await page.renderProp('intro', context, { textOnly: true }) : ''
278+
const title = await page.renderTitle(context, { unwrap: true })
279+
280+
return {
281+
title,
282+
intro,
283+
sections,
284+
}
285+
}
286+
287+
private getSectionTitle(key: string): string {
288+
const map: Record<string, string> = {
289+
gettingStarted: 'Getting started',
290+
startHere: 'Start here',
291+
guideCards: 'Guides',
292+
popular: 'Popular',
293+
videos: 'Videos',
294+
}
295+
return map[key] || key
296+
}
297+
}

0 commit comments

Comments
 (0)