From fd678214a4b03b626c8c6751d85968e4eb1e12d9 Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Mon, 12 Jan 2026 17:05:03 -0800 Subject: [PATCH] feat: add product guides transformer for Article API (#59077) --- .../tests/product-guides-transformer.ts | 37 +++++ src/article-api/transformers/index.ts | 2 + .../product-guides-transformer.ts | 138 ++++++++++++++++++ 3 files changed, 177 insertions(+) create mode 100644 src/article-api/tests/product-guides-transformer.ts create mode 100644 src/article-api/transformers/product-guides-transformer.ts diff --git a/src/article-api/tests/product-guides-transformer.ts b/src/article-api/tests/product-guides-transformer.ts new file mode 100644 index 000000000000..d469e9547942 --- /dev/null +++ b/src/article-api/tests/product-guides-transformer.ts @@ -0,0 +1,37 @@ +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 guides transformer', () => { + test('renders a product guides page with learning tracks', async () => { + // Product guides pages use layout: product-guides + // /en/codespaces/guides is a product guides page + const res = await get(makeURL('/en/codespaces/guides')) + expect(res.statusCode).toBe(200) + expect(res.headers['content-type']).toContain('text/markdown') + + // Should have Links section + expect(res.body).toContain('## Links') + }) + + test('includes guide cards if present', async () => { + const res = await get(makeURL('/en/codespaces/guides')) + expect(res.statusCode).toBe(200) + + // If includeGuides are present, they should appear under Guides + // The rendering depends on what's in the frontmatter + expect(res.body).toMatch(/##|###/) + }) + + test('includes learning tracks if present', async () => { + const res = await get(makeURL('/en/codespaces/guides')) + expect(res.statusCode).toBe(200) + + // Learning tracks should be rendered as sections with their titles + // The actual content depends on frontmatter configuration + expect(res.body).toContain('## Links') + }) +}) diff --git a/src/article-api/transformers/index.ts b/src/article-api/transformers/index.ts index a3aa2c8ea5b5..0d8e5f783506 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 { ProductGuidesTransformer } from './product-guides-transformer' import { ProductLandingTransformer } from './product-landing-transformer' /** @@ -23,6 +24,7 @@ transformerRegistry.register(new GraphQLTransformer()) transformerRegistry.register(new GithubAppsTransformer()) transformerRegistry.register(new WebhooksTransformer()) transformerRegistry.register(new TocTransformer()) +transformerRegistry.register(new ProductGuidesTransformer()) transformerRegistry.register(new ProductLandingTransformer()) export { TransformerRegistry } from './types' diff --git a/src/article-api/transformers/product-guides-transformer.ts b/src/article-api/transformers/product-guides-transformer.ts new file mode 100644 index 000000000000..54d54b00b4aa --- /dev/null +++ b/src/article-api/transformers/product-guides-transformer.ts @@ -0,0 +1,138 @@ +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' + +interface ProcessedLink { + href: string + title?: string + intro?: string +} + +interface LearningTrack { + title: string + guides: ProcessedLink[] +} + +/** + * ProductGuidesPage extends Page with optional guide and learning track fields. + * - includeGuides/rawIncludeGuides: Curated list of guide articles (processed objects vs raw paths) + * - learningTracks/rawLearningTracks: Grouped tutorials (processed objects vs raw track IDs) + */ +interface ProductGuidesPage extends Page { + includeGuides?: ProcessedLink[] + rawIncludeGuides?: string[] + learningTracks?: LearningTrack[] + rawLearningTracks?: string[] +} + +/** + * Transforms product-guides pages into markdown format. + * Handles includeGuides (curated articles) and learningTracks (grouped tutorials). + */ +export class ProductGuidesTransformer implements PageTransformer { + templateName = 'landing-page.template.md' + + canTransform(page: Page): boolean { + return page.layout === 'product-guides' + } + + async transform(page: Page, pathname: string, context: Context): Promise { + const templateData = await this.prepareTemplateData(page, pathname, context) + const templateContent = loadTemplate(this.templateName) + + return await renderContent(templateContent, { + ...context, + ...templateData, + markdownRequested: true, + }) + } + + private async prepareTemplateData( + page: Page, + pathname: string, + context: Context, + ): Promise { + const guidesPage = page as ProductGuidesPage + const sections: Section[] = [] + const groups: LinkGroup[] = [] + + // Include guides + const includeGuidesData = guidesPage.includeGuides ?? guidesPage.rawIncludeGuides + if (includeGuidesData && includeGuidesData.length > 0) { + const { default: getLinkData } = await import('@/learning-track/lib/get-link-data') + + const isProcessed = typeof includeGuidesData[0] === 'object' + + let processedLinks: ProcessedLink[] + if (isProcessed) { + processedLinks = includeGuidesData as ProcessedLink[] + } else { + processedLinks = + (await getLinkData(includeGuidesData as string[], context, { + title: true, + intro: true, + })) || [] + } + + const links: LinkData[] = (processedLinks || []).map((item) => ({ + href: item.href, + title: item.title || '', + intro: item.intro || '', + })) + + const validLinks = links.filter((l) => l.href) + if (validLinks.length > 0) { + groups.push({ title: 'Guides', links: validLinks }) + } + } + + // Learning tracks + const learningTracksData = guidesPage.learningTracks ?? guidesPage.rawLearningTracks + if (learningTracksData && learningTracksData.length > 0) { + let processedTracks: LearningTrack[] + if (Array.isArray(guidesPage.learningTracks) && guidesPage.learningTracks.length > 0) { + processedTracks = guidesPage.learningTracks + } else { + const { default: processLearningTracks } = await import( + '@/learning-track/lib/process-learning-tracks' + ) + const { learningTracks } = await processLearningTracks( + learningTracksData as string[], + context, + ) + processedTracks = learningTracks + } + + for (const track of processedTracks) { + if (!track.guides || !Array.isArray(track.guides)) continue + + const links: LinkData[] = track.guides.map((guide) => ({ + href: guide.href, + title: guide.title || '', + intro: guide.intro || '', + })) + + if (links.length > 0) { + groups.push({ title: track.title, links }) + } + } + } + + if (groups.length > 0) { + sections.push({ + title: 'Links', + groups, + }) + } + + const intro = page.intro ? await page.renderProp('intro', context, { textOnly: true }) : '' + const title = await page.renderTitle(context, { unwrap: true }) + + return { + title, + intro, + sections, + } + } +}