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
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) => (
- -
-
- {guide.title}
-
+
+ {(track.guides || []).map((article: { href: string; title: string }) => (
+ -
+
+ {article.title}
+
))}
@@ -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)