Skip to content

Commit a95d9c3

Browse files
authored
feat: add bespoke landing transformer for Article API (#59079)
1 parent ef7a3ab commit a95d9c3

3 files changed

Lines changed: 156 additions & 0 deletions

File tree

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
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('bespoke landing transformer', () => {
9+
test('renders a bespoke landing page with all sections', async () => {
10+
// /en/get-started/article-grid-bespoke is a bespoke landing page
11+
const res = await get(makeURL('/en/get-started/article-grid-bespoke'))
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('# Article Grid Bespoke Landing')
17+
18+
// Should have intro
19+
expect(res.body).toContain('A test page for testing')
20+
})
21+
22+
test('renders all descendant articles recursively', async () => {
23+
const res = await get(makeURL('/en/get-started/article-grid-bespoke'))
24+
expect(res.statusCode).toBe(200)
25+
26+
// Should have Articles section with all descendant articles (recursive)
27+
expect(res.body).toContain('## Articles')
28+
expect(res.body).toContain('[Grid Article One]')
29+
expect(res.body).toContain('[Grid Article Two]')
30+
expect(res.body).toContain('[Grid Article Three]')
31+
expect(res.body).toContain('[Grid Article Four]')
32+
})
33+
})
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import type { Context, Page } from '@/types'
2+
import type { PageTransformer, TemplateData, Section, LinkData } from './types'
3+
import { renderContent } from '@/content-render/index'
4+
import { loadTemplate } from '@/article-api/lib/load-template'
5+
import { getAllTocItems, flattenTocItems } from '@/article-api/lib/get-all-toc-items'
6+
7+
interface RecommendedItem {
8+
href: string
9+
title?: string
10+
intro?: string
11+
}
12+
13+
interface BespokeLandingPage extends Omit<Page, 'featuredLinks'> {
14+
featuredLinks?: Record<string, Array<string | { href: string; title: string; intro?: string }>>
15+
children?: string[]
16+
recommended?: RecommendedItem[]
17+
rawRecommended?: string[]
18+
includedCategories?: string[]
19+
}
20+
21+
/**
22+
* Transforms bespoke-landing pages into markdown format.
23+
* Handles recommended carousel and full article listings.
24+
* Note: Unlike discovery-landing, bespoke-landing shows ALL articles
25+
* regardless of includedCategories.
26+
*/
27+
export class BespokeLandingTransformer implements PageTransformer {
28+
templateName = 'landing-page.template.md'
29+
30+
canTransform(page: Page): boolean {
31+
return page.layout === 'bespoke-landing'
32+
}
33+
34+
async transform(page: Page, pathname: string, context: Context): Promise<string> {
35+
const templateData = await this.prepareTemplateData(page, pathname, context)
36+
37+
const templateContent = loadTemplate(this.templateName)
38+
39+
const rendered = await renderContent(templateContent, {
40+
...context,
41+
...templateData,
42+
markdownRequested: true,
43+
})
44+
45+
return rendered
46+
}
47+
48+
private async prepareTemplateData(
49+
page: Page,
50+
_pathname: string,
51+
context: Context,
52+
): Promise<TemplateData> {
53+
const bespokePage = page as BespokeLandingPage
54+
const sections: Section[] = []
55+
56+
// Recommended carousel
57+
const recommended = bespokePage.recommended ?? bespokePage.rawRecommended
58+
if (recommended && recommended.length > 0) {
59+
const { default: getLearningTrackLinkData } = await import(
60+
'@/learning-track/lib/get-link-data'
61+
)
62+
63+
let links: LinkData[]
64+
if (typeof recommended[0] === 'object' && 'title' in recommended[0]) {
65+
links = recommended.map((item) => ({
66+
href: typeof item === 'string' ? item : item.href,
67+
title: (typeof item === 'object' && item.title) || '',
68+
intro: (typeof item === 'object' && item.intro) || '',
69+
}))
70+
} else {
71+
const linkData = await getLearningTrackLinkData(recommended as string[], context, {
72+
title: true,
73+
intro: true,
74+
})
75+
links = (linkData || []).map((item: { href: string; title?: string; intro?: string }) => ({
76+
href: item.href,
77+
title: item.title || '',
78+
intro: item.intro || '',
79+
}))
80+
}
81+
82+
const validLinks = links.filter((l) => l.href && l.title)
83+
if (validLinks.length > 0) {
84+
sections.push({
85+
title: 'Recommended',
86+
groups: [{ title: null, links: validLinks }],
87+
})
88+
}
89+
}
90+
91+
// Articles section: recursively gather ALL descendant articles
92+
// This matches the behavior of the site which uses genericTocFlat/genericTocNested
93+
// Note: For bespoke-landing pages, the site shows ALL articles regardless of includedCategories
94+
// (includedCategories only filters for discovery-landing pages)
95+
if (bespokePage.children && bespokePage.children.length > 0) {
96+
const tocItems = await getAllTocItems(page, context, {
97+
recurse: true,
98+
renderIntros: true,
99+
})
100+
101+
// Flatten to get all leaf articles (excludeParents: true means only get articles, not category pages)
102+
const allArticles = flattenTocItems(tocItems, { excludeParents: true })
103+
104+
if (allArticles.length > 0) {
105+
sections.push({
106+
title: 'Articles',
107+
groups: [{ title: null, links: allArticles }],
108+
})
109+
}
110+
}
111+
112+
const intro = page.intro ? await page.renderProp('intro', context, { textOnly: true }) : ''
113+
const title = await page.renderTitle(context, { unwrap: true })
114+
115+
return {
116+
title,
117+
intro,
118+
sections,
119+
}
120+
}
121+
}

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 { BespokeLandingTransformer } from './bespoke-landing-transformer'
1011
import { JourneyLandingTransformer } from './journey-landing-transformer'
1112
import { CategoryLandingTransformer } from './category-landing-transformer'
1213
import { DiscoveryLandingTransformer } from './discovery-landing-transformer'
@@ -27,6 +28,7 @@ transformerRegistry.register(new GraphQLTransformer())
2728
transformerRegistry.register(new GithubAppsTransformer())
2829
transformerRegistry.register(new WebhooksTransformer())
2930
transformerRegistry.register(new TocTransformer())
31+
transformerRegistry.register(new BespokeLandingTransformer())
3032
transformerRegistry.register(new JourneyLandingTransformer())
3133
transformerRegistry.register(new CategoryLandingTransformer())
3234
transformerRegistry.register(new DiscoveryLandingTransformer())

0 commit comments

Comments
 (0)