|
| 1 | +import type { Context, Page } from '@/types' |
| 2 | +import type { PageTransformer, TemplateData, Section, LinkGroup, 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 RecommendedItem { |
| 9 | + href: string |
| 10 | + title?: string |
| 11 | + intro?: string |
| 12 | +} |
| 13 | + |
| 14 | +interface ProductPage extends Omit<Page, 'featuredLinks'> { |
| 15 | + featuredLinks?: Record<string, Array<string | { href: string; title: string; intro?: string }>> |
| 16 | + children?: string[] |
| 17 | + recommended?: RecommendedItem[] |
| 18 | + rawRecommended?: string[] |
| 19 | + includedCategories?: string[] |
| 20 | +} |
| 21 | + |
| 22 | +interface PageWithChildren extends Page { |
| 23 | + children?: string[] |
| 24 | + category?: string[] |
| 25 | +} |
| 26 | + |
| 27 | +/** |
| 28 | + * Transforms product-landing pages into markdown format. |
| 29 | + * Handles featured links (startHere, popular, videos), guide cards, |
| 30 | + * article grids with category filtering, and children listings. |
| 31 | + */ |
| 32 | +export class ProductLandingTransformer implements PageTransformer { |
| 33 | + templateName = 'landing-page.template.md' |
| 34 | + |
| 35 | + canTransform(page: Page): boolean { |
| 36 | + return page.layout === 'product-landing' |
| 37 | + } |
| 38 | + |
| 39 | + async transform(page: Page, pathname: string, context: Context): Promise<string> { |
| 40 | + const templateData = await this.prepareTemplateData(page, pathname, context) |
| 41 | + |
| 42 | + const templateContent = loadTemplate(this.templateName) |
| 43 | + |
| 44 | + const rendered = await renderContent(templateContent, { |
| 45 | + ...context, |
| 46 | + ...templateData, |
| 47 | + markdownRequested: true, |
| 48 | + }) |
| 49 | + |
| 50 | + return rendered |
| 51 | + } |
| 52 | + |
| 53 | + private async prepareTemplateData( |
| 54 | + page: Page, |
| 55 | + pathname: string, |
| 56 | + context: Context, |
| 57 | + ): Promise<TemplateData> { |
| 58 | + const productPage = page as ProductPage |
| 59 | + const languageCode = page.languageCode || 'en' |
| 60 | + const sections: Section[] = [] |
| 61 | + |
| 62 | + // Recommended carousel |
| 63 | + const recommended = productPage.recommended ?? productPage.rawRecommended |
| 64 | + if (recommended && recommended.length > 0) { |
| 65 | + const { default: getLearningTrackLinkData } = await import( |
| 66 | + '@/learning-track/lib/get-link-data' |
| 67 | + ) |
| 68 | + |
| 69 | + let links: LinkData[] |
| 70 | + if (typeof recommended[0] === 'object' && 'title' in recommended[0]) { |
| 71 | + links = recommended.map((item) => ({ |
| 72 | + href: typeof item === 'string' ? item : item.href, |
| 73 | + title: (typeof item === 'object' && item.title) || '', |
| 74 | + intro: (typeof item === 'object' && item.intro) || '', |
| 75 | + })) |
| 76 | + } else { |
| 77 | + const linkData = await getLearningTrackLinkData(recommended as string[], context, { |
| 78 | + title: true, |
| 79 | + intro: true, |
| 80 | + }) |
| 81 | + links = (linkData || []).map((item: { href: string; title?: string; intro?: string }) => ({ |
| 82 | + href: item.href, |
| 83 | + title: item.title || '', |
| 84 | + intro: item.intro || '', |
| 85 | + })) |
| 86 | + } |
| 87 | + |
| 88 | + const validLinks = links.filter((l) => l.href && l.title) |
| 89 | + if (validLinks.length > 0) { |
| 90 | + sections.push({ |
| 91 | + title: 'Recommended', |
| 92 | + groups: [{ title: null, links: validLinks }], |
| 93 | + }) |
| 94 | + } |
| 95 | + } |
| 96 | + |
| 97 | + // Featured links (startHere, popular, videos, etc.) |
| 98 | + const rawFeaturedLinks = productPage.featuredLinks |
| 99 | + if (rawFeaturedLinks) { |
| 100 | + const { default: getLearningTrackLinkData } = await import( |
| 101 | + '@/learning-track/lib/get-link-data' |
| 102 | + ) |
| 103 | + |
| 104 | + const featuredKeys = ['startHere', 'popular', 'videos'] |
| 105 | + const featuredGroups: LinkGroup[] = [] |
| 106 | + |
| 107 | + for (const key of featuredKeys) { |
| 108 | + const links = rawFeaturedLinks[key] |
| 109 | + if (!Array.isArray(links) || links.length === 0) continue |
| 110 | + |
| 111 | + const sectionTitle = this.getSectionTitle(key) |
| 112 | + |
| 113 | + let resolvedLinks: LinkData[] |
| 114 | + |
| 115 | + if (key === 'videos') { |
| 116 | + // Videos are external URLs with title and href properties |
| 117 | + const videoLinks = await Promise.all( |
| 118 | + links.map(async (link) => { |
| 119 | + if (typeof link === 'object' && link.href) { |
| 120 | + const title = await renderContent(link.title, context, { textOnly: true }) |
| 121 | + return title ? { href: link.href, title, intro: link.intro || '' } : null |
| 122 | + } |
| 123 | + return null |
| 124 | + }), |
| 125 | + ) |
| 126 | + resolvedLinks = videoLinks.filter((l) => l !== null) as LinkData[] |
| 127 | + } else { |
| 128 | + // Other featuredLinks are page hrefs that need Liquid evaluation |
| 129 | + const stringLinks = links.map((item) => (typeof item === 'string' ? item : item.href)) |
| 130 | + const linkData = await getLearningTrackLinkData(stringLinks, context, { |
| 131 | + title: true, |
| 132 | + intro: true, |
| 133 | + }) |
| 134 | + resolvedLinks = (linkData || []).map((item) => ({ |
| 135 | + href: item.href, |
| 136 | + title: item.title || '', |
| 137 | + intro: item.intro || '', |
| 138 | + })) |
| 139 | + } |
| 140 | + |
| 141 | + const validLinks = resolvedLinks.filter((l) => l.href) |
| 142 | + if (validLinks.length > 0) { |
| 143 | + featuredGroups.push({ |
| 144 | + title: sectionTitle, |
| 145 | + links: validLinks, |
| 146 | + }) |
| 147 | + } |
| 148 | + } |
| 149 | + |
| 150 | + if (featuredGroups.length > 0) { |
| 151 | + sections.push({ |
| 152 | + title: 'Featured', |
| 153 | + groups: featuredGroups, |
| 154 | + }) |
| 155 | + } |
| 156 | + } |
| 157 | + |
| 158 | + // Guide cards |
| 159 | + if (rawFeaturedLinks?.guideCards) { |
| 160 | + const links = rawFeaturedLinks.guideCards |
| 161 | + if (Array.isArray(links)) { |
| 162 | + const resolvedLinks = await Promise.all( |
| 163 | + links.map(async (link) => { |
| 164 | + if (typeof link === 'string') { |
| 165 | + return await getLinkData(link, languageCode, pathname, context, resolvePath) |
| 166 | + } else if (link.href) { |
| 167 | + return { |
| 168 | + href: link.href, |
| 169 | + title: link.title, |
| 170 | + intro: link.intro || '', |
| 171 | + } |
| 172 | + } |
| 173 | + return null |
| 174 | + }), |
| 175 | + ) |
| 176 | + |
| 177 | + const validLinks = resolvedLinks.filter((l): l is LinkData => l !== null && !!l.href) |
| 178 | + if (validLinks.length > 0) { |
| 179 | + sections.push({ |
| 180 | + title: 'Guides', |
| 181 | + groups: [{ title: null, links: validLinks }], |
| 182 | + }) |
| 183 | + } |
| 184 | + } |
| 185 | + } |
| 186 | + |
| 187 | + // Article grid with includedCategories filtering |
| 188 | + if (productPage.children && productPage.includedCategories) { |
| 189 | + const gridGroups: LinkGroup[] = [] |
| 190 | + const includedCategories = productPage.includedCategories |
| 191 | + |
| 192 | + for (const childHref of productPage.children) { |
| 193 | + const childPage = resolvePath(childHref, languageCode, pathname, context) as |
| 194 | + | PageWithChildren |
| 195 | + | undefined |
| 196 | + if (!childPage?.children) continue |
| 197 | + |
| 198 | + const childChildren = childPage.children |
| 199 | + if (childChildren.length === 0) continue |
| 200 | + |
| 201 | + // Get the child page's pathname to use for resolving grandchildren |
| 202 | + const childPermalink = childPage.permalinks.find( |
| 203 | + (p) => p.languageCode === languageCode && p.pageVersion === context.currentVersion, |
| 204 | + ) |
| 205 | + const childPathname = childPermalink ? childPermalink.href : pathname + childHref |
| 206 | + |
| 207 | + const articles = await Promise.all( |
| 208 | + childChildren.map(async (grandchildHref: string) => { |
| 209 | + const linkData = await getLinkData( |
| 210 | + grandchildHref, |
| 211 | + languageCode, |
| 212 | + childPathname, |
| 213 | + context, |
| 214 | + resolvePath, |
| 215 | + ) |
| 216 | + |
| 217 | + if (includedCategories.length > 0) { |
| 218 | + const linkedPage = resolvePath( |
| 219 | + grandchildHref, |
| 220 | + languageCode, |
| 221 | + childPathname, |
| 222 | + context, |
| 223 | + ) as PageWithChildren | undefined |
| 224 | + if (linkedPage) { |
| 225 | + const pageCategories = linkedPage.category || [] |
| 226 | + const hasMatchingCategory = |
| 227 | + Array.isArray(pageCategories) && |
| 228 | + pageCategories.some((cat: string) => |
| 229 | + includedCategories.some( |
| 230 | + (included) => included.toLowerCase() === cat.toLowerCase(), |
| 231 | + ), |
| 232 | + ) |
| 233 | + if (!hasMatchingCategory) { |
| 234 | + return null |
| 235 | + } |
| 236 | + } |
| 237 | + } |
| 238 | + |
| 239 | + return linkData |
| 240 | + }), |
| 241 | + ) |
| 242 | + |
| 243 | + const validArticles = articles.filter((a): a is LinkData => a !== null && !!a.href) |
| 244 | + if (validArticles.length > 0) { |
| 245 | + const childTitle = await childPage.renderTitle(context, { unwrap: true }) |
| 246 | + gridGroups.push({ |
| 247 | + title: childTitle, |
| 248 | + links: validArticles, |
| 249 | + }) |
| 250 | + } |
| 251 | + } |
| 252 | + |
| 253 | + if (gridGroups.length > 0) { |
| 254 | + sections.push({ |
| 255 | + title: 'Articles', |
| 256 | + groups: gridGroups, |
| 257 | + }) |
| 258 | + } |
| 259 | + } |
| 260 | + |
| 261 | + // All children (full listing) |
| 262 | + if (productPage.children) { |
| 263 | + const links = await Promise.all( |
| 264 | + productPage.children.map(async (childHref) => { |
| 265 | + return await getLinkData(childHref, languageCode, pathname, context, resolvePath) |
| 266 | + }), |
| 267 | + ) |
| 268 | + const validLinks = links.filter((l) => l.href) |
| 269 | + if (validLinks.length > 0) { |
| 270 | + sections.push({ |
| 271 | + title: 'Links', |
| 272 | + groups: [{ title: null, links: validLinks }], |
| 273 | + }) |
| 274 | + } |
| 275 | + } |
| 276 | + |
| 277 | + const intro = page.intro ? await page.renderProp('intro', context, { textOnly: true }) : '' |
| 278 | + const title = await page.renderTitle(context, { unwrap: true }) |
| 279 | + |
| 280 | + return { |
| 281 | + title, |
| 282 | + intro, |
| 283 | + sections, |
| 284 | + } |
| 285 | + } |
| 286 | + |
| 287 | + private getSectionTitle(key: string): string { |
| 288 | + const map: Record<string, string> = { |
| 289 | + gettingStarted: 'Getting started', |
| 290 | + startHere: 'Start here', |
| 291 | + guideCards: 'Guides', |
| 292 | + popular: 'Popular', |
| 293 | + videos: 'Videos', |
| 294 | + } |
| 295 | + return map[key] || key |
| 296 | + } |
| 297 | +} |
0 commit comments