Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 1 addition & 7 deletions src/article-api/tests/article-body.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'), {
Expand Down
42 changes: 42 additions & 0 deletions src/article-api/tests/product-landing-transformer.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
2 changes: 2 additions & 0 deletions src/article-api/transformers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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'
297 changes: 297 additions & 0 deletions src/article-api/transformers/product-landing-transformer.ts
Original file line number Diff line number Diff line change
@@ -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<Page, 'featuredLinks'> {
featuredLinks?: Record<string, Array<string | { href: string; title: string; intro?: string }>>
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<string> {
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<TemplateData> {
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<string, string> = {
gettingStarted: 'Getting started',
startHere: 'Start here',
guideCards: 'Guides',
popular: 'Popular',
videos: 'Videos',
}
return map[key] || key
}
}
Loading