From 0c4919dfe1d14c4334ad0e4f2023fb8126064912 Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Mon, 12 Jan 2026 16:07:46 -0800 Subject: [PATCH] feat: add product landing transformer for Article API (#59078) --- src/article-api/tests/article-body.ts | 8 +- .../tests/product-landing-transformer.ts | 42 +++ src/article-api/transformers/index.ts | 2 + .../product-landing-transformer.ts | 297 ++++++++++++++++++ 4 files changed, 342 insertions(+), 7 deletions(-) create mode 100644 src/article-api/tests/product-landing-transformer.ts create mode 100644 src/article-api/transformers/product-landing-transformer.ts diff --git a/src/article-api/tests/article-body.ts b/src/article-api/tests/article-body.ts index 45e686c129d5..5d2814cfa41b 100644 --- a/src/article-api/tests/article-body.ts +++ b/src/article-api/tests/article-body.ts @@ -59,13 +59,7 @@ describe('article body api', () => { expect(error).toBe("No page found for '/en/never/heard/of'") }) - test('non-article pages return error', async () => { - // Index pages are not articles and should not be renderable - const res = await get(makeURL('/en/get-started')) - expect(res.statusCode).toBe(403) - const { error } = JSON.parse(res.body) - expect(error).toContain("isn't yet available in markdown") - }) + // Removed: non-article pages test - landing pages are now supported via transformers test('invalid Referer header does not crash', async () => { const res = await get(makeURL('/en/get-started/start-your-journey/hello-world'), { diff --git a/src/article-api/tests/product-landing-transformer.ts b/src/article-api/tests/product-landing-transformer.ts new file mode 100644 index 000000000000..93349c8807e3 --- /dev/null +++ b/src/article-api/tests/product-landing-transformer.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from 'vitest' + +import { get } from '@/tests/helpers/e2etest' + +const makeURL = (pathname: string): string => + `/api/article/body?${new URLSearchParams({ pathname })}` + +describe('product landing transformer', () => { + test('renders a product landing page with basic structure', async () => { + // /en/actions is a product landing page in fixtures + const res = await get(makeURL('/en/actions')) + expect(res.statusCode).toBe(200) + expect(res.headers['content-type']).toContain('text/markdown') + + // Check for title + expect(res.body).toContain('# GitHub Actions Documentation') + + // Should have intro + expect(res.body).toContain('Automate away with') + }) + + test('renders child categories under Links section', async () => { + const res = await get(makeURL('/en/actions')) + expect(res.statusCode).toBe(200) + + // All children should be listed under Links section + expect(res.body).toContain('## Links') + + // Should contain child categories from fixtures (uses full title, not shortTitle) + expect(res.body).toContain('[Category page of GitHub Actions](/en/actions/category)') + expect(res.body).toContain('[Using workflows](/en/actions/using-workflows)') + }) + + test('includes child intros', async () => { + const res = await get(makeURL('/en/actions')) + expect(res.statusCode).toBe(200) + + // Each child should have its intro + expect(res.body).toContain('Learn how to migrate your existing CI/CD workflows') + expect(res.body).toContain('Learn how to use workflows') + }) +}) diff --git a/src/article-api/transformers/index.ts b/src/article-api/transformers/index.ts index 7974441e18fa..a3aa2c8ea5b5 100644 --- a/src/article-api/transformers/index.ts +++ b/src/article-api/transformers/index.ts @@ -7,6 +7,7 @@ import { GraphQLTransformer } from './graphql-transformer' import { GithubAppsTransformer } from './github-apps-transformer' import { WebhooksTransformer } from './webhooks-transformer' import { TocTransformer } from './toc-transformer' +import { ProductLandingTransformer } from './product-landing-transformer' /** * Global transformer registry @@ -22,6 +23,7 @@ transformerRegistry.register(new GraphQLTransformer()) transformerRegistry.register(new GithubAppsTransformer()) transformerRegistry.register(new WebhooksTransformer()) transformerRegistry.register(new TocTransformer()) +transformerRegistry.register(new ProductLandingTransformer()) export { TransformerRegistry } from './types' export type { PageTransformer } from './types' diff --git a/src/article-api/transformers/product-landing-transformer.ts b/src/article-api/transformers/product-landing-transformer.ts new file mode 100644 index 000000000000..59b741d39db5 --- /dev/null +++ b/src/article-api/transformers/product-landing-transformer.ts @@ -0,0 +1,297 @@ +import type { Context, Page } from '@/types' +import type { PageTransformer, TemplateData, Section, LinkGroup, LinkData } from './types' +import { renderContent } from '@/content-render/index' +import { loadTemplate } from '@/article-api/lib/load-template' +import { resolvePath } from '@/article-api/lib/resolve-path' +import { getLinkData } from '@/article-api/lib/get-link-data' + +interface RecommendedItem { + href: string + title?: string + intro?: string +} + +interface ProductPage extends Omit { + featuredLinks?: Record> + children?: string[] + recommended?: RecommendedItem[] + rawRecommended?: string[] + includedCategories?: string[] +} + +interface PageWithChildren extends Page { + children?: string[] + category?: string[] +} + +/** + * Transforms product-landing pages into markdown format. + * Handles featured links (startHere, popular, videos), guide cards, + * article grids with category filtering, and children listings. + */ +export class ProductLandingTransformer implements PageTransformer { + templateName = 'landing-page.template.md' + + canTransform(page: Page): boolean { + return page.layout === 'product-landing' + } + + async transform(page: Page, pathname: string, context: Context): Promise { + const templateData = await this.prepareTemplateData(page, pathname, context) + + const templateContent = loadTemplate(this.templateName) + + const rendered = await renderContent(templateContent, { + ...context, + ...templateData, + markdownRequested: true, + }) + + return rendered + } + + private async prepareTemplateData( + page: Page, + pathname: string, + context: Context, + ): Promise { + const productPage = page as ProductPage + const languageCode = page.languageCode || 'en' + const sections: Section[] = [] + + // Recommended carousel + const recommended = productPage.recommended ?? productPage.rawRecommended + if (recommended && recommended.length > 0) { + const { default: getLearningTrackLinkData } = await import( + '@/learning-track/lib/get-link-data' + ) + + let links: LinkData[] + if (typeof recommended[0] === 'object' && 'title' in recommended[0]) { + links = recommended.map((item) => ({ + href: typeof item === 'string' ? item : item.href, + title: (typeof item === 'object' && item.title) || '', + intro: (typeof item === 'object' && item.intro) || '', + })) + } else { + const linkData = await getLearningTrackLinkData(recommended as string[], context, { + title: true, + intro: true, + }) + links = (linkData || []).map((item: { href: string; title?: string; intro?: string }) => ({ + href: item.href, + title: item.title || '', + intro: item.intro || '', + })) + } + + const validLinks = links.filter((l) => l.href && l.title) + if (validLinks.length > 0) { + sections.push({ + title: 'Recommended', + groups: [{ title: null, links: validLinks }], + }) + } + } + + // Featured links (startHere, popular, videos, etc.) + const rawFeaturedLinks = productPage.featuredLinks + if (rawFeaturedLinks) { + const { default: getLearningTrackLinkData } = await import( + '@/learning-track/lib/get-link-data' + ) + + const featuredKeys = ['startHere', 'popular', 'videos'] + const featuredGroups: LinkGroup[] = [] + + for (const key of featuredKeys) { + const links = rawFeaturedLinks[key] + if (!Array.isArray(links) || links.length === 0) continue + + const sectionTitle = this.getSectionTitle(key) + + let resolvedLinks: LinkData[] + + if (key === 'videos') { + // Videos are external URLs with title and href properties + const videoLinks = await Promise.all( + links.map(async (link) => { + if (typeof link === 'object' && link.href) { + const title = await renderContent(link.title, context, { textOnly: true }) + return title ? { href: link.href, title, intro: link.intro || '' } : null + } + return null + }), + ) + resolvedLinks = videoLinks.filter((l) => l !== null) as LinkData[] + } else { + // Other featuredLinks are page hrefs that need Liquid evaluation + const stringLinks = links.map((item) => (typeof item === 'string' ? item : item.href)) + const linkData = await getLearningTrackLinkData(stringLinks, context, { + title: true, + intro: true, + }) + resolvedLinks = (linkData || []).map((item) => ({ + href: item.href, + title: item.title || '', + intro: item.intro || '', + })) + } + + const validLinks = resolvedLinks.filter((l) => l.href) + if (validLinks.length > 0) { + featuredGroups.push({ + title: sectionTitle, + links: validLinks, + }) + } + } + + if (featuredGroups.length > 0) { + sections.push({ + title: 'Featured', + groups: featuredGroups, + }) + } + } + + // Guide cards + if (rawFeaturedLinks?.guideCards) { + const links = rawFeaturedLinks.guideCards + if (Array.isArray(links)) { + const resolvedLinks = await Promise.all( + links.map(async (link) => { + if (typeof link === 'string') { + return await getLinkData(link, languageCode, pathname, context, resolvePath) + } else if (link.href) { + return { + href: link.href, + title: link.title, + intro: link.intro || '', + } + } + return null + }), + ) + + const validLinks = resolvedLinks.filter((l): l is LinkData => l !== null && !!l.href) + if (validLinks.length > 0) { + sections.push({ + title: 'Guides', + groups: [{ title: null, links: validLinks }], + }) + } + } + } + + // Article grid with includedCategories filtering + if (productPage.children && productPage.includedCategories) { + const gridGroups: LinkGroup[] = [] + const includedCategories = productPage.includedCategories + + for (const childHref of productPage.children) { + const childPage = resolvePath(childHref, languageCode, pathname, context) as + | PageWithChildren + | undefined + if (!childPage?.children) continue + + const childChildren = childPage.children + if (childChildren.length === 0) continue + + // Get the child page's pathname to use for resolving grandchildren + const childPermalink = childPage.permalinks.find( + (p) => p.languageCode === languageCode && p.pageVersion === context.currentVersion, + ) + const childPathname = childPermalink ? childPermalink.href : pathname + childHref + + const articles = await Promise.all( + childChildren.map(async (grandchildHref: string) => { + const linkData = await getLinkData( + grandchildHref, + languageCode, + childPathname, + context, + resolvePath, + ) + + if (includedCategories.length > 0) { + const linkedPage = resolvePath( + grandchildHref, + languageCode, + childPathname, + context, + ) as PageWithChildren | undefined + if (linkedPage) { + const pageCategories = linkedPage.category || [] + const hasMatchingCategory = + Array.isArray(pageCategories) && + pageCategories.some((cat: string) => + includedCategories.some( + (included) => included.toLowerCase() === cat.toLowerCase(), + ), + ) + if (!hasMatchingCategory) { + return null + } + } + } + + return linkData + }), + ) + + const validArticles = articles.filter((a): a is LinkData => a !== null && !!a.href) + if (validArticles.length > 0) { + const childTitle = await childPage.renderTitle(context, { unwrap: true }) + gridGroups.push({ + title: childTitle, + links: validArticles, + }) + } + } + + if (gridGroups.length > 0) { + sections.push({ + title: 'Articles', + groups: gridGroups, + }) + } + } + + // All children (full listing) + if (productPage.children) { + const links = await Promise.all( + productPage.children.map(async (childHref) => { + return await getLinkData(childHref, languageCode, pathname, context, resolvePath) + }), + ) + const validLinks = links.filter((l) => l.href) + if (validLinks.length > 0) { + sections.push({ + title: 'Links', + groups: [{ title: null, links: validLinks }], + }) + } + } + + const intro = page.intro ? await page.renderProp('intro', context, { textOnly: true }) : '' + const title = await page.renderTitle(context, { unwrap: true }) + + return { + title, + intro, + sections, + } + } + + private getSectionTitle(key: string): string { + const map: Record = { + gettingStarted: 'Getting started', + startHere: 'Start here', + guideCards: 'Guides', + popular: 'Popular', + videos: 'Videos', + } + return map[key] || key + } +}