Skip to content

Commit c269a9d

Browse files
authored
feat: add category landing transformer for Article API (#59075)
1 parent 40991e6 commit c269a9d

File tree

3 files changed

+175
-0
lines changed

3 files changed

+175
-0
lines changed
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
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('category landing transformer', () => {
9+
test('handles subcategory pages without category-landing layout', async () => {
10+
// /en/get-started/start-your-journey is a subcategory index page without category-landing layout
11+
// It should return 403 (not transformed) or be handled by default behavior
12+
const res = await get(makeURL('/en/get-started/start-your-journey'))
13+
14+
// This page doesn't have category-landing layout, so it won't be transformed
15+
// That's expected behavior - only pages with layout: category-landing are transformed
16+
expect([200, 403]).toContain(res.statusCode)
17+
})
18+
})
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
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 { resolvePath } from '@/article-api/lib/resolve-path'
6+
import { getLinkData } from '@/article-api/lib/get-link-data'
7+
8+
interface CategoryPage extends Page {
9+
spotlight?: Array<{ article: string; image: string }>
10+
children?: string[]
11+
}
12+
13+
/**
14+
* Transforms category-landing pages into markdown format.
15+
* Handles spotlight sections and recursively collects all descendant articles.
16+
*/
17+
export class CategoryLandingTransformer implements PageTransformer {
18+
templateName = 'landing-page.template.md'
19+
20+
canTransform(page: Page): boolean {
21+
return page.layout === 'category-landing'
22+
}
23+
24+
async transform(page: Page, pathname: string, context: Context): Promise<string> {
25+
const templateData = await this.prepareTemplateData(page, pathname, context)
26+
const templateContent = loadTemplate(this.templateName)
27+
28+
return await renderContent(templateContent, {
29+
...context,
30+
...templateData,
31+
markdownRequested: true,
32+
})
33+
}
34+
35+
/**
36+
* Recursively collects all descendant articles from the given parent hrefs.
37+
* Traverses the page tree, adding non-index pages and recursing into children.
38+
* Uses a visited set to prevent infinite loops from circular references.
39+
*/
40+
private async getAllDescendantArticles(
41+
parentHrefs: string[],
42+
languageCode: string,
43+
pathname: string,
44+
context: Context,
45+
visited: Set<string> = new Set(),
46+
): Promise<LinkData[]> {
47+
const allArticles: LinkData[] = []
48+
49+
for (const href of parentHrefs) {
50+
// Prevent infinite loops from circular references
51+
if (visited.has(href)) continue
52+
visited.add(href)
53+
54+
const parentPage = resolvePath(href, languageCode, pathname, context) as
55+
| CategoryPage
56+
| undefined
57+
if (!parentPage) continue
58+
59+
// Add this page if it's an article (not an index)
60+
if (!parentPage.relativePath.endsWith('index.md')) {
61+
const linkData = await getLinkData(href, languageCode, pathname, context, resolvePath)
62+
if (linkData.href) {
63+
allArticles.push(linkData)
64+
}
65+
}
66+
67+
// Recursively get children
68+
const children = parentPage.children
69+
if (children && Array.isArray(children) && children.length > 0) {
70+
// Get the parent's permalink to use as the base path for resolving children
71+
const parentPermalink = parentPage.permalinks.find(
72+
(p) => p.languageCode === languageCode && p.pageVersion === context.currentVersion,
73+
)
74+
const parentPathname = parentPermalink ? parentPermalink.href : pathname
75+
76+
const childArticles = await this.getAllDescendantArticles(
77+
children,
78+
languageCode,
79+
parentPathname,
80+
context,
81+
visited,
82+
)
83+
allArticles.push(...childArticles)
84+
}
85+
}
86+
87+
return allArticles
88+
}
89+
90+
private async prepareTemplateData(
91+
page: Page,
92+
pathname: string,
93+
context: Context,
94+
): Promise<TemplateData> {
95+
const categoryPage = page as CategoryPage
96+
const languageCode = page.languageCode || 'en'
97+
const sections: Section[] = []
98+
99+
// Spotlight section
100+
const spotlight = categoryPage.spotlight
101+
if (spotlight && spotlight.length > 0) {
102+
const links = await Promise.all(
103+
spotlight.map(async (item) => {
104+
const linkData = await getLinkData(
105+
item.article,
106+
languageCode,
107+
pathname,
108+
context,
109+
resolvePath,
110+
)
111+
return {
112+
...linkData,
113+
intro: linkData.intro
114+
? `${linkData.intro} (Image: ${item.image})`
115+
: `Image: ${item.image}`,
116+
}
117+
}),
118+
)
119+
120+
const validLinks = links.filter((l) => l.href)
121+
if (validLinks.length > 0) {
122+
sections.push({
123+
title: 'Spotlight',
124+
groups: [{ title: null, links: validLinks }],
125+
})
126+
}
127+
}
128+
129+
// Children - get all descendant articles recursively
130+
if (categoryPage.children) {
131+
const allArticles = await this.getAllDescendantArticles(
132+
categoryPage.children,
133+
languageCode,
134+
pathname,
135+
context,
136+
)
137+
138+
if (allArticles.length > 0) {
139+
sections.push({
140+
title: 'Links',
141+
groups: [{ title: null, links: allArticles }],
142+
})
143+
}
144+
}
145+
146+
const intro = page.intro ? await page.renderProp('intro', context, { textOnly: true }) : ''
147+
const title = await page.renderTitle(context, { unwrap: true })
148+
149+
return {
150+
title,
151+
intro,
152+
sections,
153+
}
154+
}
155+
}

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 { CategoryLandingTransformer } from './category-landing-transformer'
1011
import { DiscoveryLandingTransformer } from './discovery-landing-transformer'
1112
import { ProductGuidesTransformer } from './product-guides-transformer'
1213
import { ProductLandingTransformer } from './product-landing-transformer'
@@ -25,6 +26,7 @@ transformerRegistry.register(new GraphQLTransformer())
2526
transformerRegistry.register(new GithubAppsTransformer())
2627
transformerRegistry.register(new WebhooksTransformer())
2728
transformerRegistry.register(new TocTransformer())
29+
transformerRegistry.register(new CategoryLandingTransformer())
2830
transformerRegistry.register(new DiscoveryLandingTransformer())
2931
transformerRegistry.register(new ProductGuidesTransformer())
3032
transformerRegistry.register(new ProductLandingTransformer())

0 commit comments

Comments
 (0)