From 5dbd8b54db55231a1d87df4ae4fe8313147cd258 Mon Sep 17 00:00:00 2001 From: Robert Sese <734194+rsese@users.noreply.github.com> Date: Tue, 7 Oct 2025 15:05:44 -0700 Subject: [PATCH 1/2] Journey tracks for journey landing pages (#57722) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- content/enterprise-onboarding/index.md | 27 ++ data/ui.yml | 5 + .../fixtures/content/get-started/index.md | 13 + src/fixtures/fixtures/content/index.md | 1 + .../fixtures/content/test-journey/index.md | 24 ++ src/fixtures/fixtures/data/ui.yml | 5 + .../tests/playwright-rendering.spec.ts | 99 +++++++ src/frame/components/article/ArticlePage.tsx | 18 ++ .../components/context/ArticleContext.tsx | 3 + src/frame/lib/frontmatter.js | 33 +++ src/frame/middleware/index.ts | 2 + src/journeys/components/JourneyTrackCard.tsx | 52 ++++ src/journeys/components/JourneyTrackNav.tsx | 50 ++++ src/journeys/components/index.ts | 2 + src/journeys/lib/get-link-data.ts | 2 + src/journeys/lib/journey-path-resolver.ts | 268 ++++++++++++++++++ src/journeys/middleware/journey-track.ts | 27 ++ src/journeys/tests/journey-path-resolver.ts | 222 +++++++++++++++ .../components/journey/JourneyLanding.tsx | 90 +----- .../journey/JourneyLearningTracks.tsx | 31 +- src/landings/context/LandingContext.tsx | 11 + src/landings/pages/product.tsx | 3 + 22 files changed, 887 insertions(+), 101 deletions(-) create mode 100644 src/fixtures/fixtures/content/test-journey/index.md create mode 100644 src/journeys/components/JourneyTrackCard.tsx create mode 100644 src/journeys/components/JourneyTrackNav.tsx create mode 100644 src/journeys/components/index.ts create mode 100644 src/journeys/lib/get-link-data.ts create mode 100644 src/journeys/lib/journey-path-resolver.ts create mode 100644 src/journeys/middleware/journey-track.ts create mode 100644 src/journeys/tests/journey-path-resolver.ts diff --git a/content/enterprise-onboarding/index.md b/content/enterprise-onboarding/index.md index 38db4f25c6be..bbe7ebe132f5 100644 --- a/content/enterprise-onboarding/index.md +++ b/content/enterprise-onboarding/index.md @@ -13,6 +13,33 @@ featuredLinks: - '/enterprise-onboarding/github-actions-for-your-enterprise' - '/enterprise-onboarding/feature-enhancements' layout: product-landing +journeyTracks: + - id: 'getting_started' + title: 'Getting started with your enterprise' + description: 'Learn how to start a trial of {% data variables.product.prodname_enterprise %}, and about enterprise billing and migrations.' + guides: + - '/enterprise-onboarding/getting-started-with-your-enterprise/setting-up-a-trial-of-github-enterprise' + - '/enterprise-onboarding/getting-started-with-your-enterprise/ending-a-trial-of-github-enterprise' + - '/enterprise-onboarding/getting-started-with-your-enterprise/about-enterprise-billing' + - '/enterprise-onboarding/getting-started-with-your-enterprise/about-migrating-to-github-enterprise-cloud' + - '/enterprise-onboarding/getting-started-with-your-enterprise/securing-your-enterprise-with-managed-users' + - '/enterprise-onboarding/getting-started-with-your-enterprise/securing-enterprise-resources-with-single-sign-on' + - id: 'organizations_and_teams' + title: 'Setting up organizations and teams in your enterprise' + description: 'Learn how to add and manage organizations and teams in your enterprise.' + guides: + - '/enterprise-onboarding/setting-up-organizations-and-teams/setting-up-an-organization' + - '/enterprise-onboarding/setting-up-organizations-and-teams/managing-organization-members' + - '/enterprise-onboarding/setting-up-organizations-and-teams/managing-your-organizations' + - '/enterprise-onboarding/setting-up-organizations-and-teams/creating-teams' + - '/enterprise-onboarding/setting-up-organizations-and-teams/best-practices-for-organizations-in-your-enterprise' + - id: 'support' + title: 'Creating a support model for your enterprise' + description: 'Learn about {% data variables.product.github %} support and how to set up a support model for your enterprise.' + guides: + - '/enterprise-onboarding/support-for-your-enterprise/understanding-support' + - '/enterprise-onboarding/support-for-your-enterprise/using-the-support-portal' + - '/enterprise-onboarding/support-for-your-enterprise/managing-support-entitlements' versions: ghec: '*' topics: diff --git a/data/ui.yml b/data/ui.yml index ed30ddca4583..e7f0dfba8895 100644 --- a/data/ui.yml +++ b/data/ui.yml @@ -326,6 +326,11 @@ learning_track_nav: next_guide: Next more_guides: More guides → current_progress: '{i} of {n} in learning path' +journey_track_nav: + prev_article: Previous + next_article: Next + more_articles: More articles → + current_progress: 'Article {i} of {n}' scroll_button: scroll_to_top: Scroll to top popovers: diff --git a/src/fixtures/fixtures/content/get-started/index.md b/src/fixtures/fixtures/content/get-started/index.md index 7c67d37a583e..f8a3aa3b47fb 100644 --- a/src/fixtures/fixtures/content/get-started/index.md +++ b/src/fixtures/fixtures/content/get-started/index.md @@ -16,6 +16,19 @@ featuredLinks: - /get-started/foo/bar guideCards: - /get-started/foo/autotitling +journeyTracks: + - id: 'getting_started' + title: 'Getting started' + description: 'Learn the basics of our platform.' + guides: + - '/get-started/start-your-journey/hello-world' + - '/get-started/foo/bar' + - id: 'advanced' + title: 'Advanced topics' + description: 'Dive deeper into advanced features.' + guides: + - '/get-started/foo/autotitling' + - '/get-started/start-your-journey/hello-world' children: - /start-your-journey - /foo diff --git a/src/fixtures/fixtures/content/index.md b/src/fixtures/fixtures/content/index.md index e3b95a0713d8..d13e6dd64985 100644 --- a/src/fixtures/fixtures/content/index.md +++ b/src/fixtures/fixtures/content/index.md @@ -26,6 +26,7 @@ children: # as if the URL had been `/en/free-pro-team@latest/get-started/anything`. - search - get-started + - test-journey - early-access - pages - code-security diff --git a/src/fixtures/fixtures/content/test-journey/index.md b/src/fixtures/fixtures/content/test-journey/index.md new file mode 100644 index 000000000000..364e1d2880b7 --- /dev/null +++ b/src/fixtures/fixtures/content/test-journey/index.md @@ -0,0 +1,24 @@ +--- +title: Test Journey Landing +intro: 'Test page for journey tracks functionality' +layout: journey-landing +versions: + fpt: '*' + ghes: '*' + ghec: '*' +journeyTracks: + - id: 'getting_started' + title: 'Getting started' + description: 'Learn the basics of our platform.' + guides: + - '/get-started/start-your-journey/hello-world' + - '/get-started/foo/bar' + - id: 'advanced' + title: 'Advanced topics' + description: 'Dive deeper into advanced features.' + guides: + - '/get-started/foo/autotitling' + - '/get-started/start-your-journey/hello-world' +--- + +This is a test page for journey tracks. diff --git a/src/fixtures/fixtures/data/ui.yml b/src/fixtures/fixtures/data/ui.yml index ed30ddca4583..e7f0dfba8895 100644 --- a/src/fixtures/fixtures/data/ui.yml +++ b/src/fixtures/fixtures/data/ui.yml @@ -326,6 +326,11 @@ learning_track_nav: next_guide: Next more_guides: More guides → current_progress: '{i} of {n} in learning path' +journey_track_nav: + prev_article: Previous + next_article: Next + more_articles: More articles → + current_progress: 'Article {i} of {n}' scroll_button: scroll_to_top: Scroll to top popovers: diff --git a/src/fixtures/tests/playwright-rendering.spec.ts b/src/fixtures/tests/playwright-rendering.spec.ts index 42f24f2012f8..9c65a35ab786 100644 --- a/src/fixtures/tests/playwright-rendering.spec.ts +++ b/src/fixtures/tests/playwright-rendering.spec.ts @@ -1056,3 +1056,102 @@ test.describe('LandingCarousel component', () => { await expect(cards).toHaveCount(1) }) }) + +test.describe('Journey Tracks', () => { + test('displays journey tracks on landing pages', async ({ page }) => { + await page.goto('/get-started?feature=journey-landing') + + const journeyTracks = page.locator('[data-testid="journey-tracks"]') + await expect(journeyTracks).toBeVisible() + + // Check that at least one track is displayed + const tracks = page.locator('[data-testid="journey-track"]') + await expect(tracks.first()).toBeVisible() + + // Verify track has proper structure + const firstTrack = tracks.first() + await expect(firstTrack.locator('h3')).toBeVisible() // Track title + await expect(firstTrack.locator('p')).toBeVisible() // Track description + }) + + test('track expansion and collapse functionality', async ({ page }) => { + await page.goto('/get-started?feature=journey-landing') + + const firstTrack = page.locator('[data-testid="journey-track"]').first() + const expandButton = firstTrack.locator('summary') + + // Initially collapsed + const articlesList = firstTrack.locator('[data-testid="journey-articles"]') + await expect(articlesList).not.toBeVisible() + + await expandButton.click() + await expect(articlesList).toBeVisible() + + const articles = articlesList.locator('li') + await expect(articles.first()).toBeVisible() + + await expandButton.click() + await expect(articlesList).not.toBeVisible() + }) + + test('article navigation within tracks', async ({ page }) => { + await page.goto('/get-started?feature=journey-landing') + + const firstTrack = page.locator('[data-testid="journey-track"]').first() + const expandButton = firstTrack.locator('summary') + + await expandButton.click() + + // Click on first article + const firstArticle = firstTrack.locator('[data-testid="journey-articles"] li a').first() + await expect(firstArticle).toBeVisible() + + const articleTitle = await firstArticle.textContent() + expect(articleTitle).toBeTruthy() + expect(articleTitle!.length).toBeGreaterThan(0) + }) + + test('preserves version in journey track links', async ({ page }) => { + await page.goto('/enterprise-cloud@latest/get-started?feature=journey-landing') + + const firstTrack = page.locator('[data-testid="journey-track"]').first() + const expandButton = firstTrack.locator('summary') + await expandButton.click() + + // article links should preserve the language and version + const firstArticle = firstTrack.locator('[data-testid="journey-articles"] li a').first() + const href = await firstArticle.getAttribute('href') + + expect(href).toContain('/en/') + expect(href).toContain('enterprise-cloud@latest') + }) + + test('handles liquid template rendering in track content', async ({ page }) => { + await page.goto('/get-started?feature=journey-landing') + + const tracks = page.locator('[data-testid="journey-track"]') + + // Check that liquid templates are rendered (no raw template syntax visible) + const trackContent = await tracks.first().textContent() + expect(trackContent).not.toContain('{{') + expect(trackContent).not.toContain('}}') + expect(trackContent).not.toContain('{%') + expect(trackContent).not.toContain('%}') + }) + + test('journey navigation components show on article pages', async ({ page }) => { + // go to an article that's part of a journey track + await page.goto('/get-started/start-your-journey/hello-world?feature=journey-navigation') + + // journey next/prev nav components should rende + const journeyCard = page.locator('[data-testid="journey-track-card"]') + if (await journeyCard.isVisible()) { + await expect(journeyCard).toBeVisible() + } + + const journeyNav = page.locator('[data-testid="journey-track-nav"]') + if (await journeyNav.isVisible()) { + await expect(journeyNav).toBeVisible() + } + }) +}) diff --git a/src/frame/components/article/ArticlePage.tsx b/src/frame/components/article/ArticlePage.tsx index 89ef49ed8842..b6d42702003a 100644 --- a/src/frame/components/article/ArticlePage.tsx +++ b/src/frame/components/article/ArticlePage.tsx @@ -7,6 +7,8 @@ import { DefaultLayout } from '@/frame/components/DefaultLayout' import { ArticleTitle } from '@/frame/components/article/ArticleTitle' import { useArticleContext } from '@/frame/components/context/ArticleContext' import { LearningTrackNav } from '@/learning-track/components/article/LearningTrackNav' +import { JourneyTrackNav } from '@/journeys/components/JourneyTrackNav' +import { JourneyTrackCard } from '@/journeys/components/JourneyTrackCard' import { MarkdownContent } from '@/frame/components/ui/MarkdownContent' import { Lead } from '@/frame/components/ui/Lead' import { PermissionsStatement } from '@/frame/components/ui/PermissionsStatement' @@ -42,10 +44,14 @@ export const ArticlePage = () => { productVideoUrl, miniTocItems, currentLearningTrack, + currentJourneyTrack, supportPortalVaIframeProps, currentLayout, } = useArticleContext() const isLearningPath = !!currentLearningTrack?.trackName + const isJourneyPath = !!currentJourneyTrack?.trackId + // Only show journey track components when feature flag is enabled + const showJourneyTracks = isJourneyPath && router.query?.feature === 'journey-navigation' const { t } = useTranslation(['pages']) const introProp = ( @@ -72,6 +78,7 @@ export const ArticlePage = () => { const toc = ( <> {isLearningPath && } + {showJourneyTracks && } {miniTocItems.length > 1 && } ) @@ -122,6 +129,11 @@ export const ArticlePage = () => { ) : null} + {showJourneyTracks ? ( +
+ +
+ ) : null} ) : (
@@ -148,6 +160,12 @@ export const ArticlePage = () => {
) : null} + + {showJourneyTracks ? ( +
+ +
+ ) : null} )} diff --git a/src/frame/components/context/ArticleContext.tsx b/src/frame/components/context/ArticleContext.tsx index 9ffb55d9d950..a067bc1cb910 100644 --- a/src/frame/components/context/ArticleContext.tsx +++ b/src/frame/components/context/ArticleContext.tsx @@ -1,5 +1,6 @@ import { SupportPortalVaIframeProps } from '@/frame/components/article/SupportPortalVaIframe' import { createContext, useContext } from 'react' +import type { JourneyContext } from '@/journeys/lib/journey-path-resolver' export type LearningTrack = { trackTitle: string @@ -34,6 +35,7 @@ export type ArticleContextT = { product?: string productVideoUrl?: string currentLearningTrack?: LearningTrack + currentJourneyTrack?: JourneyContext detectedPlatforms: Array detectedTools: Array allTools: Record @@ -98,6 +100,7 @@ export const getArticleContextFromRequest = (req: any): ArticleContextT => { product: page.product || '', productVideoUrl: page.product_video || '', currentLearningTrack: req.context.currentLearningTrack, + currentJourneyTrack: req.context.currentJourneyTrack, detectedPlatforms: page.detectedPlatforms || [], detectedTools: page.detectedTools || [], allTools: page.allToolsParsed || [], // this is set at the page level, see lib/page.js diff --git a/src/frame/lib/frontmatter.js b/src/frame/lib/frontmatter.js index 4c13dd76b838..2e27e8e465b9 100644 --- a/src/frame/lib/frontmatter.js +++ b/src/frame/lib/frontmatter.js @@ -197,6 +197,39 @@ export const schema = { learningTracks: { type: 'array', }, + // Journey tracks for journey landing pages + journeyTracks: { + type: 'array', + items: { + type: 'object', + required: ['id', 'title', 'guides'], + properties: { + id: { + type: 'string', + description: 'Unique identifier for the journey track', + }, + title: { + type: 'string', + translatable: true, + description: 'Display title for the journey track', + }, + description: { + type: 'string', + translatable: true, + description: 'Optional description for the journey track', + }, + guides: { + type: 'array', + items: { + type: 'string', + }, + description: 'Array of article paths that make up this journey track', + }, + }, + additionalProperties: false, + }, + description: 'Array of journey tracks for journey landing pages', + }, // Used in `product-landing.html` beta_product: { type: 'boolean', diff --git a/src/frame/middleware/index.ts b/src/frame/middleware/index.ts index 0e64c35a0db7..b52591590ea2 100644 --- a/src/frame/middleware/index.ts +++ b/src/frame/middleware/index.ts @@ -50,6 +50,7 @@ import productExamples from './context/product-examples' import productGroups from './context/product-groups' import featuredLinks from '@/landings/middleware/featured-links' import learningTrack from '@/learning-track/middleware/learning-track' +import journeyTrack from '@/journeys/middleware/journey-track' import next from './next' import renderPage from './render-page' import assetPreprocessing from '@/assets/middleware/asset-preprocessing' @@ -270,6 +271,7 @@ export default function (app: Express) { app.use(asyncMiddleware(featuredLinks)) app.use(asyncMiddleware(resolveRecommended)) app.use(asyncMiddleware(learningTrack)) + app.use(asyncMiddleware(journeyTrack)) if (ENABLE_FASTLY_TESTING) { // The fastlyCacheTest middleware is intended to be used with Fastly to test caching behavior. diff --git a/src/journeys/components/JourneyTrackCard.tsx b/src/journeys/components/JourneyTrackCard.tsx new file mode 100644 index 000000000000..d1830a760c52 --- /dev/null +++ b/src/journeys/components/JourneyTrackCard.tsx @@ -0,0 +1,52 @@ +import { useRouter } from 'next/router' + +import { Link } from '@/frame/components/Link' +import type { JourneyContext } from '@/journeys/lib/journey-path-resolver' +import { useTranslation } from '@/languages/components/useTranslation' + +type Props = { + journey: JourneyContext +} + +export function JourneyTrackCard({ journey }: Props) { + const { locale } = useRouter() + const { t } = useTranslation('journey_track_nav') + const { trackTitle, journeyTitle, journeyPath, nextGuide, numberOfGuides, currentGuideIndex } = + journey + + return ( +
+
+

+ + {journeyTitle} + +

+ {trackTitle} + + {t('current_progress') + .replace('{n}', `${numberOfGuides}`) + .replace('{i}', `${currentGuideIndex + 1}`)} + +
+ + {nextGuide ? ( + <> + {t('next_article')}: + + {nextGuide.title} + + + ) : ( + + {t('more_articles')} + + )} + +
+
+ ) +} diff --git a/src/journeys/components/JourneyTrackNav.tsx b/src/journeys/components/JourneyTrackNav.tsx new file mode 100644 index 000000000000..91f71bc38368 --- /dev/null +++ b/src/journeys/components/JourneyTrackNav.tsx @@ -0,0 +1,50 @@ +import { Link } from '@/frame/components/Link' +import type { JourneyContext } from '@/journeys/lib/journey-path-resolver' +import { useTranslation } from '@/languages/components/useTranslation' + +type Props = { + context: JourneyContext +} + +export function JourneyTrackNav({ context }: Props) { + const { t } = useTranslation('journey_track_nav') + const { prevGuide, nextGuide, trackTitle, currentGuideIndex, numberOfGuides } = context + + return ( +
+ + {prevGuide && ( + <> + {t('prev_article')} + + {prevGuide.title} + + + )} + + + + {trackTitle} + + {t('current_progress') + .replace('{n}', `${numberOfGuides}`) + .replace('{i}', `${currentGuideIndex + 1}`)} + + + + + {nextGuide && ( + <> + {t('next_article')} + + {nextGuide.title} + + + )} + +
+ ) +} diff --git a/src/journeys/components/index.ts b/src/journeys/components/index.ts new file mode 100644 index 000000000000..d0c2a65fd310 --- /dev/null +++ b/src/journeys/components/index.ts @@ -0,0 +1,2 @@ +export { JourneyTrackCard } from './JourneyTrackCard' +export { JourneyTrackNav } from './JourneyTrackNav' diff --git a/src/journeys/lib/get-link-data.ts b/src/journeys/lib/get-link-data.ts new file mode 100644 index 000000000000..b292dd607dc9 --- /dev/null +++ b/src/journeys/lib/get-link-data.ts @@ -0,0 +1,2 @@ +// Re-export the getLinkData function from learning tracks for journey tracks +export { default } from '@/learning-track/lib/get-link-data' diff --git a/src/journeys/lib/journey-path-resolver.ts b/src/journeys/lib/journey-path-resolver.ts new file mode 100644 index 000000000000..c71638b2fe5f --- /dev/null +++ b/src/journeys/lib/journey-path-resolver.ts @@ -0,0 +1,268 @@ +import { getPathWithoutLanguage, getPathWithoutVersion } from '@/frame/lib/path-utils' +import { renderContent } from '@/content-render/index' +import { executeWithFallback } from '@/languages/lib/render-with-fallback' +import getApplicableVersions from '@/versions/lib/get-applicable-versions' +import getLinkData from './get-link-data' + +export interface JourneyContext { + trackId: string + trackName: string + trackTitle: string + journeyTitle: string + journeyPath: string + currentGuideIndex: number + numberOfGuides: number + nextGuide?: { + href: string + title: string + } + prevGuide?: { + href: string + title: string + } +} + +export interface JourneyTrack { + id: string + title: string + description?: string + guides: Array<{ + href: string + title: string + }> +} + +type JourneyPage = { + layout?: string + title?: string + permalink?: string + relativePath?: string + versions?: any + journeyTracks?: Array<{ + id: string + title: string + description?: string + guides: string[] + }> +} + +type Pages = Record +type ContentContext = { + currentProduct?: string + currentLanguage?: string + currentVersion?: string + pages?: Pages + redirects?: any + [key: string]: any +} + +// Cache for journey pages so we only filter all pages once +let cachedJourneyPages: JourneyPage[] | null = null + +function getJourneyPages(pages: Pages): JourneyPage[] { + if (!cachedJourneyPages) { + cachedJourneyPages = Object.values(pages).filter( + (page: any) => page.journeyTracks && page.journeyTracks.length > 0, + ) as JourneyPage[] + } + return cachedJourneyPages +} + +function normalizeGuidePath(path: string): string { + // First ensure we have a leading slash for consistent processing + const pathWithSlash = path.startsWith('/') ? path : `/${path}` + + // Use the same normalization pattern as learning tracks and other middleware + const withoutVersion = getPathWithoutVersion(pathWithSlash) + const withoutLanguage = getPathWithoutLanguage(withoutVersion) + + // Ensure we always return a path with leading slash for consistent comparison + return withoutLanguage && withoutLanguage.startsWith('/') + ? withoutLanguage + : `/${withoutLanguage || path}` +} + +/** + * Helper function to append the journey-navigation feature flag to URLs + */ +function appendJourneyFeatureFlag(href: string): string { + if (!href) return href + + try { + // we have to pass some URL here, we just throw it away though + const url = new URL(href, 'https://docs.github.com') + url.searchParams.set('feature', 'journey-navigation') + return url.pathname + url.search + } catch { + // fallback if URL parsing fails + const separator = href.includes('?') ? '&' : '?' + return `${href}${separator}feature=journey-navigation` + } +} + +/** + * Resolves the journey context for a given article path. + * + * The journey context includes information about the journey track, the current + * guide's position within that track, and links to the previous and next + * guides if they exist. + */ +export async function resolveJourneyContext( + articlePath: string, + pages: Pages, + context: ContentContext, + currentJourneyPage?: JourneyPage, +): Promise { + const normalizedPath = normalizeGuidePath(articlePath) + + // Use the current journey page if provided, otherwise find all journey pages + const journeyPages = currentJourneyPage ? [currentJourneyPage] : getJourneyPages(pages) + + let result: JourneyContext | null = null + + // Search through all journey pages + for (const journeyPage of journeyPages) { + if (!journeyPage.journeyTracks) continue + + // Check version compatibility - only show journey navigation if the current version + // is compatible with the journey landing page's versions (journey track articles + // currently inherit the journey landing page's versions) + if (journeyPage.versions) { + const journeyVersions = getApplicableVersions(journeyPage.versions) + if (!journeyVersions.includes(context.currentVersion || '')) { + continue // Skip this journey if current version is not supported + } + } + + for (const track of journeyPage.journeyTracks) { + if (!track.guides || !Array.isArray(track.guides)) continue + + // Find if current article is in this track + let guideIndex = -1 + + for (let i = 0; i < track.guides.length; i++) { + const guidePath = track.guides[i] + let renderedGuidePath = guidePath + + // Handle Liquid conditionals in guide paths + try { + renderedGuidePath = await executeWithFallback( + context, + () => renderContent(guidePath, context, { textOnly: true }), + () => guidePath, + ) + } catch { + // If rendering fails, use the original path rather than erroring + renderedGuidePath = guidePath + } + + const normalizedGuidePath = normalizeGuidePath(renderedGuidePath) + + if (normalizedGuidePath === normalizedPath) { + guideIndex = i + break + } + } + + if (guideIndex >= 0) { + result = { + trackId: track.id, + trackName: track.id, + trackTitle: track.title, + journeyTitle: journeyPage.title || '', + journeyPath: journeyPage.permalink || `/${journeyPage.relativePath || ''}`, + currentGuideIndex: guideIndex, + numberOfGuides: track.guides.length, + } + + // Set up previous guide + if (guideIndex > 0) { + const prevGuidePath = track.guides[guideIndex - 1] + try { + const resultData = await getLinkData(prevGuidePath, context, { + title: true, + intro: false, + fullTitle: false, + }) + if (resultData && resultData.length > 0) { + const linkResult = resultData[0] + result.prevGuide = { + href: appendJourneyFeatureFlag(linkResult.href), + title: linkResult.title || '', + } + } + } catch (error) { + console.warn('Could not get link data for previous guide:', prevGuidePath, error) + } + } + + // Set up next guide + if (guideIndex < track.guides.length - 1) { + const nextGuidePath = track.guides[guideIndex + 1] + try { + const resultData = await getLinkData(nextGuidePath, context, { + title: true, + intro: false, + fullTitle: false, + }) + if (resultData && resultData.length > 0) { + const linkResult = resultData[0] + result.nextGuide = { + href: appendJourneyFeatureFlag(linkResult.href), + title: linkResult.title || '', + } + } + } catch (error) { + console.warn('Could not get link data for next guide:', nextGuidePath, error) + } + } + + break // Found the track, stop searching + } + } + + if (result) break // Found the journey, stop searching + } + + return result +} + +/** + * Resolves journey tracks data from frontmatter, including rendering any Liquid. + * + * Returns an array of JourneyTrack objects with titles, descriptions, and guide links. + */ +export async function resolveJourneyTracks( + journeyTracks: any[], + context: ContentContext, +): Promise { + const result = await Promise.all( + journeyTracks.map(async (track: any) => { + // Render Liquid templates in title and description + const renderedTitle = await renderContent(track.title, context, { textOnly: true }) + const renderedDescription = track.description + ? await renderContent(track.description, context, { textOnly: true }) + : undefined + + const guides = await Promise.all( + track.guides.map(async (guidePath: string) => { + const linkData = await getLinkData(guidePath, context, { title: true }) + const baseHref = linkData?.[0]?.href || guidePath + return { + href: appendJourneyFeatureFlag(baseHref), + title: linkData?.[0]?.title || 'Untitled Guide', + } + }), + ) + + return { + id: track.id, + title: renderedTitle, + description: renderedDescription, + guides, + } + }), + ) + + return result +} diff --git a/src/journeys/middleware/journey-track.ts b/src/journeys/middleware/journey-track.ts new file mode 100644 index 000000000000..5321058f96fa --- /dev/null +++ b/src/journeys/middleware/journey-track.ts @@ -0,0 +1,27 @@ +import type { Response, NextFunction } from 'express' +import type { ExtendedRequest, Context } from '@/types' +import { resolveJourneyContext } from '../lib/journey-path-resolver' + +export default async function journeyTrack( + req: ExtendedRequest & { context: Context }, + res: Response, + next: NextFunction, +) { + if (!req.context) throw new Error('request is not contextualized') + if (!req.context.page) return next() + + try { + const journeyContext = await resolveJourneyContext( + req.pagePath || '', + req.context.pages || {}, + req.context, + ) + + req.context.currentJourneyTrack = journeyContext + } catch (error) { + console.warn('Failed to resolve journey context:', error) + req.context.currentJourneyTrack = null + } + + return next() +} diff --git a/src/journeys/tests/journey-path-resolver.ts b/src/journeys/tests/journey-path-resolver.ts new file mode 100644 index 000000000000..624d92f06087 --- /dev/null +++ b/src/journeys/tests/journey-path-resolver.ts @@ -0,0 +1,222 @@ +import { describe, expect, test, vi } from 'vitest' + +import { resolveJourneyContext, resolveJourneyTracks } from '../lib/journey-path-resolver' + +// Mock modules since we just want to test journey functions, not their dependencies or +// against real content files +vi.mock('@/journeys/lib/get-link-data', () => ({ + default: async (path: string) => [ + { + href: `/en/enterprise-cloud@latest${path}`, + title: `Mock Title for ${path}`, + }, + ], +})) + +vi.mock('@/content-render/index', () => ({ + renderContent: async (content: string) => content, +})) + +vi.mock('@/languages/lib/render-with-fallback', () => ({ + executeWithFallback: async (fn: () => Promise, fallback: () => string) => { + try { + return await fn() + } catch { + return fallback() + } + }, +})) + +describe('journey-path-resolver', () => { + describe('resolveJourneyContext', () => { + const mockContext = { + currentProduct: 'github', + currentLanguage: 'en', + currentVersion: 'enterprise-cloud@latest', + } + + const mockPages = { + 'enterprise-onboarding/index': { + layout: 'journey-landing', + title: 'Enterprise onboarding', + permalink: '/enterprise-onboarding', + journeyTracks: [ + { + id: 'getting_started', + title: 'Getting started', + description: 'Learn the basics', + guides: [ + '/enterprise-onboarding/setup', + '/enterprise-onboarding/config', + '/enterprise-onboarding/deploy', + ], + }, + ], + }, + } + + test('returns null for article not in any journey track', async () => { + const result = await resolveJourneyContext('/some-other-article', mockPages, mockContext) + expect(result).toBeNull() + }) + + test('finds article in journey track', async () => { + const result = await resolveJourneyContext( + '/enterprise-onboarding/config', + mockPages, + mockContext, + ) + + expect(result).not.toBeNull() + expect(result?.trackId).toBe('getting_started') + expect(result?.trackTitle).toBe('Getting started') + expect(result?.currentGuideIndex).toBe(1) + expect(result?.numberOfGuides).toBe(3) + }) + + test('sets up previous guide navigation', async () => { + const result = await resolveJourneyContext( + '/enterprise-onboarding/config', + mockPages, + mockContext, + ) + + expect(result?.prevGuide).toEqual({ + href: '/en/enterprise-cloud@latest/enterprise-onboarding/setup?feature=journey-navigation', + title: 'Mock Title for /enterprise-onboarding/setup', + }) + }) + + test('sets up next guide navigation', async () => { + const result = await resolveJourneyContext( + '/enterprise-onboarding/config', + mockPages, + mockContext, + ) + + expect(result?.nextGuide).toEqual({ + href: '/en/enterprise-cloud@latest/enterprise-onboarding/deploy?feature=journey-navigation', + title: 'Mock Title for /enterprise-onboarding/deploy', + }) + }) + + test('handles first article in track (no previous)', async () => { + const result = await resolveJourneyContext( + '/enterprise-onboarding/setup', + mockPages, + mockContext, + ) + + expect(result?.prevGuide).toBeUndefined() + expect(result?.currentGuideIndex).toBe(0) + }) + + test('handles last article in track (no next)', async () => { + const result = await resolveJourneyContext( + '/enterprise-onboarding/deploy', + mockPages, + mockContext, + ) + + expect(result?.nextGuide).toBeUndefined() + expect(result?.currentGuideIndex).toBe(2) + }) + + test('normalizes article paths without leading slash', async () => { + // The resolver should handle paths without leading slashes + // by normalizing them to match the guide paths in the data + const result = await resolveJourneyContext( + 'enterprise-onboarding/config', + mockPages, + mockContext, + ) + + // This should find the same track as the version with leading slash + expect(result?.trackId).toBe('getting_started') + expect(result?.currentGuideIndex).toBe(1) + }) + }) + + describe('resolveJourneyTracks', () => { + const mockContext = { + currentProduct: 'github', + currentLanguage: 'en', + currentVersion: 'enterprise-cloud@latest', + } + + const mockJourneyTracks = [ + { + id: 'getting_started', + title: 'Getting started with {% data variables.product.company_short %}', + description: 'Learn the {% data variables.product.company_short %} basics', + guides: ['/enterprise-onboarding/setup', '/enterprise-onboarding/config'], + }, + { + id: 'advanced', + title: 'Advanced configuration', + description: 'Advanced topics for experts', + guides: ['/enterprise-onboarding/advanced-setup'], + }, + ] + + test('resolves all journey tracks', async () => { + const result = await resolveJourneyTracks(mockJourneyTracks, mockContext) + + expect(result).toHaveLength(2) + expect(result[0].id).toBe('getting_started') + expect(result[1].id).toBe('advanced') + }) + + test('renders liquid templates in titles and descriptions', async () => { + const result = await resolveJourneyTracks(mockJourneyTracks, mockContext) + + // Should return the content as-is since our mock renderContent is a passthrough + expect(result[0].title).toBe( + 'Getting started with {% data variables.product.company_short %}', + ) + expect(result[0].description).toBe( + 'Learn the {% data variables.product.company_short %} basics', + ) + }) + + test('resolves guide links with proper versioning', async () => { + const result = await resolveJourneyTracks(mockJourneyTracks, mockContext) + + expect(result[0].guides).toHaveLength(2) + expect(result[0].guides[0]).toEqual({ + href: '/en/enterprise-cloud@latest/enterprise-onboarding/setup?feature=journey-navigation', + title: 'Mock Title for /enterprise-onboarding/setup', + }) + }) + + test('handles tracks with no guides', async () => { + const emptyTrack = [ + { + id: 'empty', + title: 'Empty track', + description: 'No guides here', + guides: [], + }, + ] + + const result = await resolveJourneyTracks(emptyTrack, mockContext) + + expect(result).toHaveLength(1) + expect(result[0].guides).toHaveLength(0) + }) + + test('handles missing optional descriptions', async () => { + const trackWithoutDescription = [ + { + id: 'no_desc', + title: 'Track without description', + guides: ['/some-guide'], + }, + ] + + const result = await resolveJourneyTracks(trackWithoutDescription, mockContext) + + expect(result[0].description).toBeUndefined() + }) + }) +}) diff --git a/src/landings/components/journey/JourneyLanding.tsx b/src/landings/components/journey/JourneyLanding.tsx index 2dc85368e7ac..9b1e47e693e6 100644 --- a/src/landings/components/journey/JourneyLanding.tsx +++ b/src/landings/components/journey/JourneyLanding.tsx @@ -3,94 +3,8 @@ import { useLandingContext } from '@/landings/context/LandingContext' import { LandingHero } from '@/landings/components/shared/LandingHero' import { JourneyLearningTracks } from './JourneyLearningTracks' -export type JourneyLearningTrack = { - id: string - title: string - description: string - trackName: string - trackProduct: string - guides?: Array<{ - href: string - title: string - }> -} - export const JourneyLanding = () => { - const { title, intro, heroImage, introLinks } = useLandingContext() - - // Temp until we hookup real data - const stubLearningTracks: JourneyLearningTrack[] = [ - { - id: 'admin:get_started_with_your_enterprise_account', - title: 'Get started with your enterprise account', - description: - 'Set up your enterprise account and configure initial settings for your organization.', - trackName: 'get_started_with_your_enterprise_account', - trackProduct: 'admin', - guides: [ - { - href: '/admin/overview/about-enterprise-accounts?learn=get_started_with_your_enterprise_account&learnProduct=admin', - title: 'About enterprise accounts', - }, - { - href: '/admin/managing-accounts-and-repositories/managing-users-in-your-enterprise/inviting-people-to-manage-your-enterprise?learn=get_started_with_your_enterprise_account&learnProduct=admin', - title: 'Inviting people to manage your enterprise', - }, - { - href: '/admin/policies/enforcing-policies-for-your-enterprise/about-enterprise-policies?learn=get_started_with_your_enterprise_account&learnProduct=admin', - title: 'About enterprise policies', - }, - ], - }, - { - id: 'admin:adopting_github_actions_for_your_enterprise_ghec', - title: 'Adopt GitHub Actions for your enterprise', - description: - 'Learn how to plan and implement a rollout of GitHub Actions in your enterprise.', - trackName: 'adopting_github_actions_for_your_enterprise_ghec', - trackProduct: 'admin', - guides: [ - { - href: '/admin/managing-github-actions-for-your-enterprise/getting-started-with-github-actions-for-your-enterprise/about-github-actions-for-enterprises?learn=adopting_github_actions_for_your_enterprise_ghec&learnProduct=admin', - title: 'About GitHub Actions for enterprises', - }, - { - href: '/actions/get-started/understand-github-actions?learn=adopting_github_actions_for_your_enterprise_ghec&learnProduct=admin', - title: 'Understanding GitHub Actions', - }, - { - href: '/admin/managing-github-actions-for-your-enterprise/getting-started-with-github-actions-for-your-enterprise/introducing-github-actions-to-your-enterprise?learn=adopting_github_actions_for_your_enterprise_ghec&learnProduct=admin', - title: 'Introducing GitHub Actions to your enterprise', - }, - { - href: '/admin/managing-github-actions-for-your-enterprise/getting-started-with-github-actions-for-your-enterprise/migrating-your-enterprise-to-github-actions?learn=adopting_github_actions_for_your_enterprise_ghec&learnProduct=admin', - title: 'Migrating your enterprise to GitHub Actions', - }, - ], - }, - { - id: 'actions:continuous-integration', - title: 'Continuous integration with GitHub Actions', - description: - 'Set up automated testing and building for your projects using GitHub Actions workflows.', - trackName: 'continuous-integration', - trackProduct: 'actions', - guides: [ - { - href: '/actions/automating-builds-and-tests/about-continuous-integration?learn=continuous-integration&learnProduct=actions', - title: 'About continuous integration', - }, - { - href: '/actions/automating-builds-and-tests/building-and-testing-nodejs?learn=continuous-integration&learnProduct=actions', - title: 'Building and testing Node.js', - }, - { - href: '/actions/automating-builds-and-tests/building-and-testing-python?learn=continuous-integration&learnProduct=actions', - title: 'Building and testing Python', - }, - ], - }, - ] + const { title, intro, heroImage, introLinks, journeyTracks } = useLandingContext() return ( @@ -98,7 +12,7 @@ export const JourneyLanding = () => {
- +
diff --git a/src/landings/components/journey/JourneyLearningTracks.tsx b/src/landings/components/journey/JourneyLearningTracks.tsx index 87397ec49d30..a742e30fd16f 100644 --- a/src/landings/components/journey/JourneyLearningTracks.tsx +++ b/src/landings/components/journey/JourneyLearningTracks.tsx @@ -1,11 +1,12 @@ /* filepath: /workspaces/docs-internal/src/landings/components/journey/JourneyLearningTracks.tsx */ import { ChevronDownIcon, ChevronUpIcon } from '@primer/octicons-react' import { Button, Details, Timeline, Token, useDetails } from '@primer/react' -import type { JourneyLearningTrack } from './JourneyLanding' +import { Link } from '@/frame/components/Link' +import { JourneyTrack } from '@/journeys/lib/journey-path-resolver' import styles from './JourneyLearningTracks.module.css' type JourneyLearningTracksProps = { - tracks: JourneyLearningTrack[] + tracks: JourneyTrack[] } export const JourneyLearningTracks = ({ tracks }: JourneyLearningTracksProps) => { @@ -13,7 +14,7 @@ export const JourneyLearningTracks = ({ tracks }: JourneyLearningTracksProps) => return null } - const renderTrackContent = (track: JourneyLearningTrack, trackIndex: number) => { + const renderTrackContent = (track: JourneyTrack, trackIndex: number) => { const { getDetailsProps, open } = useDetails({}) return ( @@ -36,12 +37,12 @@ export const JourneyLearningTracks = ({ tracks }: JourneyLearningTracksProps) => > {open ? : } -
    - {(track.guides || []).map((guide) => ( -
  1. - - {guide.title} - +
      + {(track.guides || []).map((article: { href: string; title: string }) => ( +
    1. + + {article.title} +
    2. ))}
    @@ -51,7 +52,7 @@ export const JourneyLearningTracks = ({ tracks }: JourneyLearningTracksProps) => } return ( - <> +
    {/* Desktop: Timeline component */}
    @@ -60,7 +61,9 @@ export const JourneyLearningTracks = ({ tracks }: JourneyLearningTracksProps) => {trackIndex + 1} -
    {renderTrackContent(track, trackIndex)}
    +
    + {renderTrackContent(track, trackIndex)} +
    ) @@ -74,12 +77,14 @@ export const JourneyLearningTracks = ({ tracks }: JourneyLearningTracksProps) =>
    {trackIndex + 1}
    -
    {renderTrackContent(track, trackIndex)}
    +
    + {renderTrackContent(track, trackIndex)} +
    {trackIndex < tracks.length - 1 &&
    }
    ))}
    - +
    ) } diff --git a/src/landings/context/LandingContext.tsx b/src/landings/context/LandingContext.tsx index 6e4030555c55..28f26ba20581 100644 --- a/src/landings/context/LandingContext.tsx +++ b/src/landings/context/LandingContext.tsx @@ -3,6 +3,7 @@ import { getFeaturedLinksFromReq } from '@/landings/components/ProductLandingCon import { mapRawTocItemToTocItem } from '@/landings/types' import type { TocItem } from '@/landings/types' import type { LearningTrack } from '@/types' +import type { JourneyTrack } from '@/journeys/lib/journey-path-resolver' import type { FeaturedLink } from '@/landings/components/ProductLandingContext' export type LandingType = 'bespoke' | 'discovery' | 'journey' @@ -23,6 +24,8 @@ export type LandingContextT = { // For discovery landing pages recommended?: Array<{ title: string; intro: string; href: string; category: string[] }> // Resolved article data introLinks?: Record + // For journey landing pages + journeyTracks?: JourneyTrack[] } export const LandingContext = createContext(null) @@ -55,6 +58,13 @@ export const getLandingContextFromRequest = async ( } } + let journeyTracks: JourneyTrack[] = [] + if (landingType === 'journey' && page.journeyTracks) { + // Need a dynamic import because journey-path-resolver uses Node fs apis + const { resolveJourneyTracks } = await import('@/journeys/lib/journey-path-resolver') + journeyTracks = await resolveJourneyTracks(page.journeyTracks, req.context) + } + return { landingType, title: page.title, @@ -72,5 +82,6 @@ export const getLandingContextFromRequest = async ( heroImage: page.heroImage || '/assets/images/banner-images/hero-1.png', introLinks: page.introLinks || null, recommended, + journeyTracks, } } diff --git a/src/landings/pages/product.tsx b/src/landings/pages/product.tsx index c5414d908267..d4cc1594dc00 100644 --- a/src/landings/pages/product.tsx +++ b/src/landings/pages/product.tsx @@ -208,6 +208,9 @@ export const getServerSideProps: GetServerSideProps = async (context) => if (props.articleContext.currentLearningTrack?.trackName) { additionalUINamespaces.push('learning_track_nav') } + if (props.articleContext.currentJourneyTrack?.trackId) { + additionalUINamespaces.push('journey_track_nav') + } } addUINamespaces(req, props.mainContext.data.ui, additionalUINamespaces) From 6f4d2621032cf41a48da31f7c3e64ad51fbab4d5 Mon Sep 17 00:00:00 2001 From: Shantanu Phadke Date: Tue, 7 Oct 2025 16:32:32 -0700 Subject: [PATCH 2/2] Moving Spark billing docs (#57854) Co-authored-by: Joe Clark <31087804+jc-clark@users.noreply.github.com> --- .../concepts/product-billing/github-spark.md} | 4 ++-- content/billing/concepts/product-billing/index.md | 1 + content/copilot/concepts/billing/index.md | 1 - content/copilot/concepts/spark.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) rename content/{copilot/concepts/billing/billing-for-spark.md => billing/concepts/product-billing/github-spark.md} (96%) diff --git a/content/copilot/concepts/billing/billing-for-spark.md b/content/billing/concepts/product-billing/github-spark.md similarity index 96% rename from content/copilot/concepts/billing/billing-for-spark.md rename to content/billing/concepts/product-billing/github-spark.md index 4339b3e9379d..8f5cdf525c49 100644 --- a/content/copilot/concepts/billing/billing-for-spark.md +++ b/content/billing/concepts/product-billing/github-spark.md @@ -1,11 +1,11 @@ --- -title: About billing for GitHub Spark +title: GitHub Spark billing intro: 'Learn how {% data variables.product.prodname_spark %} is billed for users.' versions: feature: spark topics: - Copilot -shortTitle: Billing for Spark +shortTitle: GitHub Spark redirect_from: - /copilot/concepts/copilot-billing/about-billing-for-github-spark - /copilot/concepts/copilot-billing/billing-for-spark diff --git a/content/billing/concepts/product-billing/index.md b/content/billing/concepts/product-billing/index.md index 15d0890ebbff..fbfcdc9c8344 100644 --- a/content/billing/concepts/product-billing/index.md +++ b/content/billing/concepts/product-billing/index.md @@ -16,6 +16,7 @@ children: - /github-models - /github-packages - /git-lfs + - /github-spark contentType: concepts --- diff --git a/content/copilot/concepts/billing/index.md b/content/copilot/concepts/billing/index.md index d47bc7cdc1e3..71da8663b21f 100644 --- a/content/copilot/concepts/billing/index.md +++ b/content/copilot/concepts/billing/index.md @@ -11,7 +11,6 @@ children: - /individual-plans - /billing-for-individuals - /organizations-and-enterprises - - /billing-for-spark redirect_from: - /managing-copilot/managing-copilot-as-an-individual-subscriber/billing-and-payments - /copilot/managing-copilot/understanding-and-managing-copilot-usage diff --git a/content/copilot/concepts/spark.md b/content/copilot/concepts/spark.md index c23ba2d67b45..e70329de7f24 100644 --- a/content/copilot/concepts/spark.md +++ b/content/copilot/concepts/spark.md @@ -87,7 +87,7 @@ Benefits include: ### Billing Each natural-language prompt in {% data variables.product.prodname_spark_short %} consumes four premium requests. -See [AUTOTITLE](/copilot/concepts/billing/billing-for-spark). +See [AUTOTITLE](/billing/concepts/product-billing/github-spark). ### Infrastructure