Skip to content

Commit 7206b3a

Browse files
authored
feat: add discovery landing transformer for Article API (#59074)
1 parent fc888b8 commit 7206b3a

File tree

4 files changed

+394
-0
lines changed

4 files changed

+394
-0
lines changed
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import type { Context, Page } from '@/types'
2+
import type { LinkData } from '@/article-api/transformers/types'
3+
import { resolvePath } from './resolve-path'
4+
5+
interface PageWithChildren extends Page {
6+
children?: string[]
7+
category?: string[]
8+
}
9+
10+
interface TocItem extends LinkData {
11+
category?: string[]
12+
childTocItems?: TocItem[]
13+
}
14+
15+
/**
16+
* Recursively gathers all TOC items from a page and its descendants.
17+
* This mirrors the behavior of getTocItems() in the generic-toc middleware
18+
* but works with the page.children frontmatter property.
19+
*
20+
* @param page - The page to gather TOC items from
21+
* @param context - The rendering context
22+
* @param options - Configuration options
23+
* @returns Array of TocItems with nested childTocItems
24+
*/
25+
export async function getAllTocItems(
26+
page: Page,
27+
context: Context,
28+
options: {
29+
recurse?: boolean
30+
renderIntros?: boolean
31+
} = {},
32+
): Promise<TocItem[]> {
33+
const { recurse = true, renderIntros = true } = options
34+
const pageWithChildren = page as PageWithChildren
35+
const languageCode = page.languageCode || 'en'
36+
37+
if (!pageWithChildren.children || pageWithChildren.children.length === 0) {
38+
return []
39+
}
40+
41+
// Get the page's pathname for resolving children
42+
const pagePermalink = page.permalinks.find(
43+
(p) => p.languageCode === languageCode && p.pageVersion === context.currentVersion,
44+
)
45+
const pathname = pagePermalink ? pagePermalink.href : `/${languageCode}`
46+
47+
const items: TocItem[] = []
48+
49+
for (const childHref of pageWithChildren.children) {
50+
const childPage = resolvePath(childHref, languageCode, pathname, context) as
51+
| PageWithChildren
52+
| undefined
53+
54+
if (!childPage) continue
55+
56+
const title = await childPage.renderTitle(context, { unwrap: true })
57+
const intro =
58+
renderIntros && childPage.intro
59+
? await childPage.renderProp('intro', context, { textOnly: true })
60+
: ''
61+
62+
const childPermalink = childPage.permalinks.find(
63+
(p) => p.languageCode === languageCode && p.pageVersion === context.currentVersion,
64+
)
65+
const href = childPermalink ? childPermalink.href : childHref
66+
67+
const category = childPage.category || []
68+
69+
const item: TocItem = {
70+
href,
71+
title,
72+
intro,
73+
category,
74+
childTocItems: [],
75+
}
76+
77+
// Recursively get children if enabled
78+
if (recurse && childPage.children && childPage.children.length > 0) {
79+
item.childTocItems = await getAllTocItems(childPage, context, options)
80+
}
81+
82+
items.push(item)
83+
}
84+
85+
return items
86+
}
87+
88+
/**
89+
* Flattens nested TOC items into a single array.
90+
* Only includes leaf nodes (items without children) or all items based on options.
91+
*
92+
* @param tocItems - The nested TOC items to flatten
93+
* @param options - Configuration options
94+
* @returns Flat array of LinkData items
95+
*/
96+
export function flattenTocItems(
97+
tocItems: TocItem[],
98+
options: {
99+
excludeParents?: boolean // If true, only include items without children
100+
} = {},
101+
): LinkData[] {
102+
const { excludeParents = true } = options
103+
const result: LinkData[] = []
104+
105+
function recurse(items: TocItem[]) {
106+
for (const item of items) {
107+
const hasChildren = item.childTocItems && item.childTocItems.length > 0
108+
109+
// Include this item if it's a leaf or if we're including parents
110+
if (!hasChildren || !excludeParents) {
111+
result.push({
112+
href: item.href,
113+
title: item.title,
114+
intro: item.intro,
115+
})
116+
}
117+
118+
// Recurse into children
119+
if (hasChildren) {
120+
recurse(item.childTocItems!)
121+
}
122+
}
123+
}
124+
125+
recurse(tocItems)
126+
return result
127+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
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('discovery landing transformer', () => {
9+
test('renders a discovery landing page in markdown', async () => {
10+
// /en/get-started/carousel is a discovery landing page with recommended carousel
11+
const res = await get(makeURL('/en/get-started/carousel'))
12+
expect(res.statusCode).toBe(200)
13+
expect(res.headers['content-type']).toContain('text/markdown')
14+
15+
// Check for title and intro
16+
expect(res.body).toContain('# Landing Page Carousel')
17+
expect(res.body).toContain('A test category page for testing the LandingCarousel component')
18+
19+
// Should have Articles section with all descendant articles
20+
expect(res.body).toContain('## Articles')
21+
expect(res.body).toContain('[Carousel Article One]')
22+
expect(res.body).toContain('[Carousel Article Two]')
23+
expect(res.body).toContain('[Carousel Article Three]')
24+
})
25+
26+
test('renders a discovery landing page with children', async () => {
27+
// /en/get-started/article-grid-discovery has discovery landing with children
28+
const res = await get(makeURL('/en/get-started/article-grid-discovery'))
29+
expect(res.statusCode).toBe(200)
30+
expect(res.headers['content-type']).toContain('text/markdown')
31+
32+
// Check for title
33+
expect(res.body).toContain('# Article Grid Discovery')
34+
35+
// Should have Articles section with all descendant articles (recursive)
36+
expect(res.body).toContain('## Articles')
37+
expect(res.body).toContain('[Grid Article One]')
38+
expect(res.body).toContain('[Grid Article Two]')
39+
expect(res.body).toContain('[Grid Article Three]')
40+
expect(res.body).toContain('[Grid Article Four]')
41+
})
42+
43+
test('handles discovery landing structure consistently', async () => {
44+
// Discovery pages should have a consistent structure
45+
const res = await get(makeURL('/en/get-started/carousel'))
46+
expect(res.statusCode).toBe(200)
47+
48+
// Should have intro
49+
expect(res.body).toMatch(/^# .+\n\n.+\n\n/)
50+
51+
// Should have at least one section
52+
expect(res.body).toContain('##')
53+
})
54+
})
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
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 DiscoveryPage extends Page {
14+
rawIntroLinks?: Record<string, string>
15+
introLinks?: Record<string, string>
16+
recommended?: RecommendedItem[]
17+
rawRecommended?: string[]
18+
includedCategories?: string[]
19+
children?: string[]
20+
}
21+
22+
/**
23+
* Transforms discovery-landing pages into markdown format.
24+
* Handles recommended carousel, intro links, article grids with
25+
* category filtering, and children listings.
26+
*/
27+
export class DiscoveryLandingTransformer implements PageTransformer {
28+
templateName = 'landing-page.template.md'
29+
30+
canTransform(page: Page): boolean {
31+
return page.layout === 'discovery-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 discoveryPage = page as DiscoveryPage
54+
const sections: Section[] = []
55+
56+
// Recommended carousel
57+
const recommended = discoveryPage.recommended ?? discoveryPage.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+
// Intro links (getting started)
92+
const rawIntroLinks = discoveryPage.introLinks ?? discoveryPage.rawIntroLinks
93+
if (rawIntroLinks) {
94+
const { default: getLearningTrackLinkData } = await import(
95+
'@/learning-track/lib/get-link-data'
96+
)
97+
const links = await Promise.all(
98+
Object.values(rawIntroLinks).map(async (href): Promise<LinkData> => {
99+
if (typeof href === 'string') {
100+
const linkData = await getLearningTrackLinkData(href, context)
101+
if (Array.isArray(linkData) && linkData.length > 0) {
102+
const item = linkData[0]
103+
return { href: item.href || '', title: item.title || '', intro: item.intro || '' }
104+
} else if (
105+
linkData &&
106+
typeof linkData === 'object' &&
107+
!Array.isArray(linkData) &&
108+
'href' in linkData
109+
) {
110+
const item = linkData as { href?: string; title?: string; intro?: string }
111+
return {
112+
href: item.href || '',
113+
title: item.title || '',
114+
intro: item.intro || '',
115+
}
116+
}
117+
}
118+
return { href: '', title: '' }
119+
}),
120+
)
121+
const validLinks = links.filter((l) => l.href)
122+
if (validLinks.length > 0) {
123+
sections.push({
124+
title: 'Links',
125+
groups: [{ title: 'Getting started', links: validLinks }],
126+
})
127+
}
128+
}
129+
130+
// Articles section: recursively gather ALL descendant articles
131+
// This matches the behavior of the site which uses genericTocFlat/genericTocNested
132+
if (discoveryPage.children && discoveryPage.children.length > 0) {
133+
const tocItems = await getAllTocItems(page, context, {
134+
recurse: true,
135+
renderIntros: true,
136+
})
137+
138+
// Flatten to get all leaf articles (excludeParents: true means only get articles, not category pages)
139+
let allArticles = flattenTocItems(tocItems, { excludeParents: true })
140+
141+
// Apply includedCategories filter if specified
142+
if (discoveryPage.includedCategories && discoveryPage.includedCategories.length > 0) {
143+
const includedCategories = discoveryPage.includedCategories.map((c) => c.toLowerCase())
144+
// Filter tocItems before flattening to preserve category info
145+
const filterByCategory = (items: typeof tocItems): typeof tocItems => {
146+
return items.filter((item) => {
147+
const itemCategories = (item.category || []).map((c: string) => c.toLowerCase())
148+
return itemCategories.some((cat) => includedCategories.includes(cat))
149+
})
150+
}
151+
152+
// Re-flatten with category filtering
153+
const filteredTocItems = filterByCategory(flattenTocItemsWithCategory(tocItems))
154+
allArticles = filteredTocItems.map((item) => ({
155+
href: item.href,
156+
title: item.title,
157+
intro: item.intro,
158+
}))
159+
}
160+
161+
if (allArticles.length > 0) {
162+
sections.push({
163+
title: 'Articles',
164+
groups: [{ title: null, links: allArticles }],
165+
})
166+
}
167+
}
168+
169+
const intro = page.intro ? await page.renderProp('intro', context, { textOnly: true }) : ''
170+
const title = await page.renderTitle(context, { unwrap: true })
171+
172+
return {
173+
title,
174+
intro,
175+
sections,
176+
}
177+
}
178+
}
179+
180+
/**
181+
* Helper to flatten TOC items while preserving category info for filtering
182+
*/
183+
interface TocItemWithCategory {
184+
href: string
185+
title: string
186+
intro?: string
187+
category?: string[]
188+
childTocItems?: TocItemWithCategory[]
189+
}
190+
191+
function flattenTocItemsWithCategory(tocItems: TocItemWithCategory[]): TocItemWithCategory[] {
192+
const result: TocItemWithCategory[] = []
193+
194+
function recurse(items: TocItemWithCategory[]) {
195+
for (const item of items) {
196+
const hasChildren = item.childTocItems && item.childTocItems.length > 0
197+
198+
// Only include leaf nodes (articles, not category pages)
199+
if (!hasChildren) {
200+
result.push(item)
201+
}
202+
203+
if (hasChildren) {
204+
recurse(item.childTocItems!)
205+
}
206+
}
207+
}
208+
209+
recurse(tocItems)
210+
return result
211+
}

0 commit comments

Comments
 (0)