1
0
mirror of synced 2025-12-19 09:57:42 -05:00

show journey nav and support journey article branching text frontmatter (#58848)

This commit is contained in:
Robert Sese
2025-12-17 11:40:15 -06:00
committed by GitHub
parent efe839b16d
commit 549c97ce53
23 changed files with 413 additions and 164 deletions

View File

@@ -259,7 +259,9 @@ includeGuides:
- `id` (required): Unique identifier for the journey. The id only needs to be unique for journeys within a single journey landing page.
- `title` (required): Display title for the journey (supports Liquid variables)
- `description` (optional): Description of the journey (supports Liquid variables)
- `guides` (required): Array of article paths that make up this journey
- `guides` (required): Array of guide objects that make up this journey. Each guide object has:
- `href` (required): Path to the article
- `alternativeNextStep` (optional): Custom text to guide users to alternative paths in the journey. Supports Liquid variables and `[AUTOTITLE]`.
- Only applicable when used with `layout: journey-landing`.
- Optional.
@@ -271,15 +273,16 @@ journeyTracks:
title: 'Getting started with {% data variables.product.prodname_actions %}'
description: 'Learn the basics of GitHub Actions.'
guides:
- '/actions/quickstart'
- '/actions/learn-github-actions'
- '/actions/using-workflows'
- href: '/actions/quickstart'
- href: '/actions/learn-github-actions'
alternativeNextStep: 'Want to skip ahead? See [AUTOTITLE](/actions/using-workflows).'
- href: '/actions/using-workflows'
- id: 'advanced'
title: 'Advanced {% data variables.product.prodname_actions %}'
description: 'Dive deeper into advanced features.'
guides:
- '/actions/using-workflows/workflow-syntax-for-github-actions'
- '/actions/deployment/deploying-with-github-actions'
- href: '/actions/using-workflows/workflow-syntax-for-github-actions'
- href: '/actions/deployment/deploying-with-github-actions'
```
### `type`

View File

@@ -257,7 +257,9 @@ includeGuides:
* `id` (required): Unique identifier for the journey. The id only needs to be unique for journeys within a single journey landing page.
* `title` (required): Display title for the journey (supports Liquid variables)
* `description` (optional): Description of the journey (supports Liquid variables)
* `guides` (required): Array of article paths that make up this journey
* `guides` (required): Array of guide objects that make up this journey. Each guide object has:
* `href` (required): Path to the article
* `alternativeNextStep` (optional): Custom text to guide users to alternative paths in the journey. Supports Liquid variables and `[AUTOTITLE]`.
* Only applicable when used with `layout: journey-landing`.
* Optional.
@@ -269,15 +271,16 @@ journeyTracks:
title: 'Getting started with {% data variables.product.prodname_actions %}'
description: 'Learn the basics of GitHub Actions.'
guides:
- '/actions/quickstart'
- '/actions/learn-github-actions'
- '/actions/using-workflows'
- href: '/actions/quickstart'
- href: '/actions/learn-github-actions'
alternativeNextStep: 'Want to skip ahead? See [AUTOTITLE](/actions/using-workflows).'
- href: '/actions/using-workflows'
- id: 'advanced'
title: 'Advanced {% data variables.product.prodname_actions %}'
description: 'Dive deeper into advanced features.'
guides:
- '/actions/using-workflows/workflow-syntax-for-github-actions'
- '/actions/deployment/deploying-with-github-actions'
- href: '/actions/using-workflows/workflow-syntax-for-github-actions'
- href: '/actions/deployment/deploying-with-github-actions'
```
### `type`

View File

@@ -16,56 +16,56 @@ journeyTracks:
title: 'Getting started with your enterprise'
description: 'Master the fundamentals of {% data variables.product.prodname_ghe_cloud %} and get started with a trial.'
guides:
- '/enterprise-onboarding/getting-started-with-your-enterprise/choose-an-enterprise-type'
- '/enterprise-onboarding/getting-started-with-your-enterprise/setting-up-a-trial-of-github-enterprise'
- '/enterprise-onboarding/getting-started-with-your-enterprise/adding-users-to-your-enterprise'
- '/enterprise-onboarding/getting-started-with-your-enterprise/about-enterprise-billing'
- '/enterprise-onboarding/getting-started-with-your-enterprise/about-migrating-to-github-enterprise-cloud'
- href: '/enterprise-onboarding/getting-started-with-your-enterprise/choose-an-enterprise-type'
- href: '/enterprise-onboarding/getting-started-with-your-enterprise/setting-up-a-trial-of-github-enterprise'
- href: '/enterprise-onboarding/getting-started-with-your-enterprise/adding-users-to-your-enterprise'
- href: '/enterprise-onboarding/getting-started-with-your-enterprise/about-enterprise-billing'
- href: '/enterprise-onboarding/getting-started-with-your-enterprise/about-migrating-to-github-enterprise-cloud'
- id: 'setting_up_organizations_and_teams'
title: 'Setting up organizations and teams in your enterprise'
description: 'Organize work effectively and ensure people have the access they need to resources and administrative settings.'
guides:
- '/enterprise-onboarding/setting-up-organizations-and-teams/best-practices'
- '/enterprise-onboarding/setting-up-organizations-and-teams/setting-up-an-organization'
- '/enterprise-onboarding/setting-up-organizations-and-teams/about-roles-in-an-enterprise'
- '/enterprise-onboarding/setting-up-organizations-and-teams/identify-role-requirements'
- '/enterprise-onboarding/setting-up-organizations-and-teams/creating-custom-roles'
- '/enterprise-onboarding/setting-up-organizations-and-teams/about-teams-in-an-enterprise'
- '/enterprise-onboarding/setting-up-organizations-and-teams/creating-teams'
- '/enterprise-onboarding/setting-up-organizations-and-teams/assigning-roles-to-teams-and-users'
- '/enterprise-onboarding/setting-up-organizations-and-teams/use-innersource'
- href: '/enterprise-onboarding/setting-up-organizations-and-teams/best-practices'
- href: '/enterprise-onboarding/setting-up-organizations-and-teams/setting-up-an-organization'
- href: '/enterprise-onboarding/setting-up-organizations-and-teams/about-roles-in-an-enterprise'
- href: '/enterprise-onboarding/setting-up-organizations-and-teams/identify-role-requirements'
- href: '/enterprise-onboarding/setting-up-organizations-and-teams/creating-custom-roles'
- href: '/enterprise-onboarding/setting-up-organizations-and-teams/about-teams-in-an-enterprise'
- href: '/enterprise-onboarding/setting-up-organizations-and-teams/creating-teams'
- href: '/enterprise-onboarding/setting-up-organizations-and-teams/assigning-roles-to-teams-and-users'
- href: '/enterprise-onboarding/setting-up-organizations-and-teams/use-innersource'
- id: 'support_for_your_enterprise'
title: 'Creating a support model for your enterprise'
description: 'Find out how to get help and choose who will be able to contact Support.'
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'
- href: '/enterprise-onboarding/support-for-your-enterprise/understanding-support'
- href: '/enterprise-onboarding/support-for-your-enterprise/using-the-support-portal'
- href: '/enterprise-onboarding/support-for-your-enterprise/managing-support-entitlements'
- id: 'govern_people_and_repositories'
title: 'Governing people and repositories'
description: 'Implement policies, custom properties, and rulesets to govern users and repositories across your enterprise.'
guides:
- '/enterprise-onboarding/govern-people-and-repositories/about-enterprise-policies'
- '/enterprise-onboarding/govern-people-and-repositories/create-custom-properties'
- '/enterprise-onboarding/govern-people-and-repositories/create-repository-policies'
- '/enterprise-onboarding/govern-people-and-repositories/protect-branches'
- '/enterprise-onboarding/govern-people-and-repositories/using-the-audit-log-for-your-enterprise'
- '/enterprise-onboarding/govern-people-and-repositories/about-enterprise-security'
- href: '/enterprise-onboarding/govern-people-and-repositories/about-enterprise-policies'
- href: '/enterprise-onboarding/govern-people-and-repositories/create-custom-properties'
- href: '/enterprise-onboarding/govern-people-and-repositories/create-repository-policies'
- href: '/enterprise-onboarding/govern-people-and-repositories/protect-branches'
- href: '/enterprise-onboarding/govern-people-and-repositories/using-the-audit-log-for-your-enterprise'
- href: '/enterprise-onboarding/govern-people-and-repositories/about-enterprise-security'
- id: 'github_apps'
title: 'Automating processes with GitHub Apps'
description: 'Create and install apps to automate processes securely in your enterprise and organizations.'
guides:
- '/enterprise-onboarding/github-apps/create-enterprise-apps'
- '/enterprise-onboarding/github-apps/install-enterprise-apps'
- href: '/enterprise-onboarding/github-apps/create-enterprise-apps'
- href: '/enterprise-onboarding/github-apps/install-enterprise-apps'
- id: 'github_actions_for_your_enterprise'
title: 'Setting up CI/CD with GitHub Actions'
description: 'Explore {% data variables.product.prodname_actions %}, plan your rollout, and get started.'
guides:
- '/enterprise-onboarding/github-actions-for-your-enterprise/about-github-actions-for-enterprises'
- '/enterprise-onboarding/github-actions-for-your-enterprise/actions-components'
- '/enterprise-onboarding/github-actions-for-your-enterprise/planning-a-rollout-of-github-actions'
- '/enterprise-onboarding/github-actions-for-your-enterprise/migrating-your-enterprise-to-github-actions'
- '/enterprise-onboarding/github-actions-for-your-enterprise/getting-started-with-github-actions-for-github-enterprise-cloud'
- href: '/enterprise-onboarding/github-actions-for-your-enterprise/about-github-actions-for-enterprises'
- href: '/enterprise-onboarding/github-actions-for-your-enterprise/actions-components'
- href: '/enterprise-onboarding/github-actions-for-your-enterprise/planning-a-rollout-of-github-actions'
- href: '/enterprise-onboarding/github-actions-for-your-enterprise/migrating-your-enterprise-to-github-actions'
- href: '/enterprise-onboarding/github-actions-for-your-enterprise/getting-started-with-github-actions-for-github-enterprise-cloud'
versions:
ghec: '*'
topics:

View File

@@ -28,12 +28,12 @@ journeyTracks:
title: 'Get started'
description: 'Master the fundamentals of {% data variables.product.github %} and Git.'
guides:
- '/get-started/start-your-journey/about-github-and-git'
- '/get-started/start-your-journey/creating-an-account-on-github'
- '/get-started/start-your-journey/hello-world'
- '/get-started/start-your-journey/setting-up-your-profile'
- '/get-started/start-your-journey/finding-inspiration-on-github'
- '/get-started/start-your-journey/downloading-files-from-github'
- '/get-started/start-your-journey/uploading-a-project-to-github'
- '/get-started/start-your-journey/git-and-github-learning-resources'
- href: '/get-started/start-your-journey/about-github-and-git'
- href: '/get-started/start-your-journey/creating-an-account-on-github'
- href: '/get-started/start-your-journey/hello-world'
- href: '/get-started/start-your-journey/setting-up-your-profile'
- href: '/get-started/start-your-journey/finding-inspiration-on-github'
- href: '/get-started/start-your-journey/downloading-files-from-github'
- href: '/get-started/start-your-journey/uploading-a-project-to-github'
- href: '/get-started/start-your-journey/git-and-github-learning-resources'
---

View File

@@ -71,14 +71,19 @@ export const journeyTracksGuidePathExists = {
const trackObj = track as Record<string, unknown>
if (trackObj.guides && Array.isArray(trackObj.guides)) {
for (let guideIndex = 0; guideIndex < trackObj.guides.length; guideIndex++) {
const guide: string = trackObj.guides[guideIndex]
if (typeof guide === 'string') {
if (!isValidGuidePath(guide, params.name)) {
const guideObj = trackObj.guides[guideIndex]
// Validate guide is an object with expected properties
if (!guideObj || typeof guideObj !== 'object') continue
// Validate href property
if ('href' in guideObj && typeof guideObj.href === 'string') {
if (!isValidGuidePath(guideObj.href, params.name)) {
addError(
onError,
journeyTracksLineNumber,
`Journey track guide path does not exist: ${guide} (track ${trackIndex + 1}, guide ${guideIndex + 1})`,
guide,
`Journey track guide path does not exist: ${guideObj.href} (track ${trackIndex + 1}, guide ${guideIndex + 1})`,
guideObj.href,
)
}
}

View File

@@ -75,16 +75,38 @@ export const journeyTracksLiquid = {
if (track.guides && Array.isArray(track.guides)) {
for (let guideIndex = 0; guideIndex < track.guides.length; guideIndex++) {
const guide: string = track.guides[guideIndex]
if (typeof guide === 'string') {
const guideObj = track.guides[guideIndex]
// Validate guide is an object with expected properties
if (!guideObj || typeof guideObj !== 'object') continue
// Validate href property
if ('href' in guideObj && typeof guideObj.href === 'string') {
try {
liquid.parse(guide)
liquid.parse(guideObj.href)
} catch (error: any) {
addError(
onError,
trackLineNumber,
`Invalid Liquid syntax in journey track guide (track ${trackIndex + 1}, guide ${guideIndex + 1}): ${error.message}`,
guide,
`Invalid Liquid syntax in journey track guide href (track ${trackIndex + 1}, guide ${guideIndex + 1}): ${error.message}`,
guideObj.href,
)
}
}
// Validate alternativeNextStep property if present
if (
'alternativeNextStep' in guideObj &&
typeof guideObj.alternativeNextStep === 'string'
) {
try {
liquid.parse(guideObj.alternativeNextStep)
} catch (error: any) {
addError(
onError,
trackLineNumber,
`Invalid Liquid syntax in journey track guide alternativeNextStep (track ${trackIndex + 1}, guide ${guideIndex + 1}): ${error.message}`,
guideObj.alternativeNextStep,
)
}
}

View File

@@ -11,15 +11,15 @@ journeyTracks:
- id: duplicate-id
title: "First Track"
guides:
- /article-one
- href: /article-one
- id: unique-id
title: "Unique Track"
guides:
- /article-two
- href: /article-two
- id: duplicate-id
title: "Second Track with Same ID"
guides:
- /subdir/article-three
- href: /subdir/article-three
---
# Journey with Duplicate IDs

View File

@@ -11,9 +11,9 @@ journeyTracks:
- id: track-1
title: "Track with Invalid Guides"
guides:
- /article-one
- /nonexistent/guide
- /another/invalid/path
- href: /article-one
- href: /nonexistent/guide
- href: /another/invalid/path
---
# Journey with Invalid Paths

View File

@@ -11,7 +11,7 @@ journeyTracks:
- id: track-1
title: "Should be ignored"
guides:
- /nonexistent/path
- href: /nonexistent/path
---
# Non-Journey Page

View File

@@ -11,12 +11,12 @@ journeyTracks:
- id: track-1
title: "Getting Started Track"
guides:
- /article-one
- /article-two
- href: /article-one
- href: /article-two
- id: track-2
title: "Advanced Track"
guides:
- /subdir/article-three
- href: /subdir/article-three
---
# Valid Journey Landing

View File

@@ -40,7 +40,7 @@ journeyTracks:
title: "Track with {% invalid liquid"
description: "Description with {{ unclosed liquid"
guides:
- /article-one
- href: /article-one
---
# Journey with Liquid Issues
@@ -55,6 +55,36 @@ This journey landing page has invalid liquid syntax in journeyTracks.
expect(result['test-invalid-liquid.md'][0].ruleDescription).toMatch(/liquid syntax/i)
expect(result['test-invalid-liquid.md'][1].ruleDescription).toMatch(/liquid syntax/i)
})
test('invalid liquid syntax in alternativeNextStep fails', async () => {
const invalidAlternativeNextStepContent = `---
title: Journey with Invalid Alternative Next Step
layout: journey-landing
versions:
fpt: '*'
ghec: '*'
ghes: '*'
journeyTracks:
- id: track-1
title: "Test Track"
guides:
- href: /article-one
alternativeNextStep: "Want to skip? See {% invalid liquid syntax"
---
# Journey with Invalid Alternative Next Step
`
const result = await runRule(journeyTracksLiquid, {
strings: { 'test-invalid-alternative-next-step.md': invalidAlternativeNextStepContent },
...fmOptions,
})
expect(result['test-invalid-alternative-next-step.md']).toHaveLength(1)
expect(result['test-invalid-alternative-next-step.md'][0].errorDetail).toMatch(
/alternativeNextStep/,
)
expect(result['test-invalid-alternative-next-step.md'][0].errorDetail).toMatch(/liquid syntax/i)
})
})
describe('journey-tracks-guide-path-exists', () => {

View File

@@ -18,4 +18,5 @@ children:
- /page-with-permissions-and-product-callout
- /table-with-ifversions
- /code-snippet-with-hashbang
- /journey-test-article
---

View File

@@ -0,0 +1,12 @@
---
title: Journey Test Article
intro: This article is used for testing journey branching text.
versions:
fpt: '*'
ghes: '*'
ghec: '*'
---
## Test Article
This article exists solely for testing journey navigation features.

View File

@@ -21,14 +21,14 @@ journeyTracks:
title: 'Getting started'
description: 'Learn the basics of our platform.'
guides:
- '/get-started/start-your-journey/hello-world'
- '/get-started/foo/bar'
- href: '/get-started/start-your-journey/hello-world'
- href: '/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'
- href: '/get-started/foo/autotitling'
- href: '/get-started/start-your-journey/hello-world'
children:
- /start-your-journey
- /foo

View File

@@ -7,18 +7,19 @@ versions:
ghes: '*'
ghec: '*'
journeyTracks:
- id: 'getting_started'
title: 'Getting started'
description: 'Learn the basics of our platform.'
- id: 'first_track'
title: 'First Track'
description: 'The first track in the journey.'
guides:
- '/get-started/start-your-journey/hello-world'
- '/get-started/foo/bar'
- id: 'advanced'
title: 'Advanced topics'
description: 'Dive deeper into advanced features.'
- href: '/get-started/start-your-journey/hello-world'
- href: '/get-started/foo/journey-test-article'
alternativeNextStep: 'Want to skip ahead? See [AUTOTITLE](/get-started/start-your-journey/hello-world)'
- id: 'second_track'
title: 'Next Track'
description: 'The second track in the journey.'
guides:
- '/get-started/foo/autotitling'
- '/get-started/start-your-journey/hello-world'
- href: '/get-started/foo/autotitling'
- href: '/get-started/start-your-journey/hello-world'
---
This is a test page for journey tracks.

View File

@@ -1139,6 +1139,53 @@ test.describe('Journey Tracks', () => {
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')
// journey card should be visible in sidebar
const journeyCard = page.locator('[data-testid="journey-track-card"]')
await expect(journeyCard).toBeVisible()
// journey footer nav should be visible
const journeyNav = page.locator('[data-testid="journey-track-nav"]')
await expect(journeyNav).toBeVisible()
})
test('journey footer nav component links to first article in next track from last article in previous track', async ({
page,
}) => {
await page.goto('/get-started/foo/bar')
const journeyNav = page.locator('[data-testid="journey-track-nav"]')
await expect(journeyNav).toBeVisible()
// Link should display the next track's title and go to its first article
const nextTrackLink = journeyNav.locator('a').filter({ hasText: 'Advanced topics' })
await expect(nextTrackLink).toBeVisible()
const href = await nextTrackLink.getAttribute('href')
expect(href).toContain('/get-started/foo/autotitling')
})
test('journey card displays branching text when present', async ({ page }) => {
await page.goto('/get-started/foo/journey-test-article')
const journeyCard = page.locator('[data-testid="journey-track-card"]')
await expect(journeyCard).toBeVisible()
// Branching text should be rendered with markdown links
await expect(journeyCard).toContainText('Want to skip ahead?')
// AUTOTITLE should be resolved to actual article title
const branchingLink = journeyCard.locator('a').filter({ hasText: 'Hello World' })
await expect(branchingLink).toBeVisible()
await expect(journeyCard).not.toContainText('AUTOTITLE')
const href = await branchingLink.getAttribute('href')
expect(href).toContain('/get-started/start-your-journey/hello-world')
})
})
test.describe('LandingArticleGridWithFilter component', () => {

View File

@@ -22,6 +22,7 @@ import { Link } from '@/frame/components/Link'
import { useTranslation } from '@/languages/components/useTranslation'
import { LinkPreviewPopover } from '@/links/components/LinkPreviewPopover'
import { UtmPreserver } from '@/frame/components/UtmPreserver'
import { JourneyTrackCard, JourneyTrackNav } from '@/journeys/components'
const ClientSideRefresh = dynamic(() => import('@/frame/components/ClientSideRefresh'), {
ssr: false,
@@ -42,10 +43,12 @@ export const ArticlePage = () => {
productVideoUrl,
miniTocItems,
currentLearningTrack,
currentJourneyTrack,
supportPortalVaIframeProps,
currentLayout,
} = useArticleContext()
const isLearningPath = !!currentLearningTrack?.trackName
const isJourneyTrack = !!currentJourneyTrack?.trackId
const { t } = useTranslation(['pages'])
const introProp = (
@@ -72,6 +75,7 @@ export const ArticlePage = () => {
const toc = (
<>
{isLearningPath && <LearningTrackCard track={currentLearningTrack} />}
{isJourneyTrack && <JourneyTrackCard journey={currentJourneyTrack} />}
{miniTocItems.length > 1 && <MiniTocs miniTocItems={miniTocItems} />}
</>
)
@@ -122,6 +126,11 @@ export const ArticlePage = () => {
<LearningTrackNav track={currentLearningTrack} />
</div>
) : null}
{isJourneyTrack ? (
<div className="container-lg mt-4 px-3">
<JourneyTrackNav context={currentJourneyTrack} />
</div>
) : null}
</>
) : (
<div className="container-xl px-3 px-md-6 my-4">
@@ -148,6 +157,11 @@ export const ArticlePage = () => {
<LearningTrackNav track={currentLearningTrack} />
</div>
) : null}
{isJourneyTrack ? (
<div className="container-lg mt-4 px-3">
<JourneyTrackNav context={currentJourneyTrack} />
</div>
) : null}
</div>
)}
</DefaultLayout>

View File

@@ -247,7 +247,20 @@ export const schema: Schema = {
guides: {
type: 'array',
items: {
type: 'object',
required: ['href'],
properties: {
href: {
type: 'string',
description: 'Path to the article in the journey track',
},
alternativeNextStep: {
type: 'string',
description:
'Optional branching text for the article when guiding users through the journey',
},
},
additionalProperties: false,
},
description: 'Array of article paths that make up this journey track',
},

View File

@@ -13,9 +13,8 @@ export function JourneyTrackCard({ journey }: Props) {
const { locale } = useRouter()
const { currentVersion } = useVersion()
const { t } = useTranslation('journey_track_nav')
const { trackTitle, journeyTitle, journeyPath, nextGuide, numberOfGuides, currentGuideIndex } =
journey
const fullPath = `/${locale}/${currentVersion}${journeyPath}?feature=journey-landing`
const { trackTitle, journeyPath, nextGuide, numberOfGuides, currentGuideIndex } = journey
const fullPath = `/${locale}/${currentVersion}${journeyPath}`
return (
<div
@@ -25,10 +24,9 @@ export function JourneyTrackCard({ journey }: Props) {
<div className="d-flex flex-column width-full">
<h2 className="h4">
<Link href={fullPath} className="mb-1 text-underline">
{journeyTitle}
{trackTitle}
</Link>
</h2>
<span className="f6 color-fg-muted mb-2">{trackTitle}</span>
<span className="f5 color-fg-muted">
{t('current_progress')
.replace('{n}', `${numberOfGuides}`)
@@ -49,6 +47,12 @@ export function JourneyTrackCard({ journey }: Props) {
</Link>
)}
</span>
{journey.alternativeNextStep && (
<div
className="mt-4"
dangerouslySetInnerHTML={{ __html: journey.alternativeNextStep }}
></div>
)}
</div>
</div>
)

View File

@@ -8,7 +8,7 @@ type Props = {
export function JourneyTrackNav({ context }: Props) {
const { t } = useTranslation('journey_track_nav')
const { prevGuide, nextGuide, trackTitle, currentGuideIndex, numberOfGuides } = context
const { prevGuide, nextGuide, nextTrackFirstGuide } = context
return (
<div
@@ -26,24 +26,22 @@ export function JourneyTrackNav({ context }: Props) {
)}
</span>
<span className="f5 d-flex flex-column flex-items-center">
<span className="color-fg-muted">{trackTitle}</span>
<span className="color-fg-muted">
{t('current_progress')
.replace('{n}', `${numberOfGuides}`)
.replace('{i}', `${currentGuideIndex + 1}`)}
</span>
</span>
<span className="f5 d-flex flex-column flex-items-end">
{nextGuide && (
{nextGuide ? (
<>
<span className="color-fg-default">{t('next_article')}</span>
<Link href={nextGuide.href} className="text-bold color-fg text-right">
{nextGuide.title}
</Link>
</>
)}
) : nextTrackFirstGuide ? (
<>
<span className="color-fg-default">{t('next_article')}</span>
<Link href={nextTrackFirstGuide.href} className="text-bold color-fg text-right">
{nextTrackFirstGuide.trackTitle}
</Link>
</>
) : null}
</span>
</div>
)

View File

@@ -13,6 +13,11 @@ export interface JourneyContext {
journeyPath: string
currentGuideIndex: number
numberOfGuides: number
nextTrackFirstGuide?: {
href: string
title: string
trackTitle: string
}
nextGuide?: {
href: string
title: string
@@ -21,6 +26,7 @@ export interface JourneyContext {
href: string
title: string
}
alternativeNextStep?: string
}
export interface JourneyTrack {
@@ -43,7 +49,10 @@ type JourneyPage = {
id: string
title: string
description?: string
guides: string[]
guides: Array<{
href: string
alternativeNextStep?: string
}>
}>
}
@@ -83,6 +92,32 @@ function normalizeGuidePath(path: string): string {
: `/${withoutLanguage || path}`
}
/**
* Helper function to fetch guide data (href and title) for a given path
*/
async function fetchGuideData(
guidePath: string,
context: ContentContext,
): Promise<{ href: string; title: string } | null> {
try {
const resultData = await getLinkData(guidePath, context, {
title: true,
intro: false,
fullTitle: false,
})
if (resultData && resultData.length > 0) {
const linkResult = resultData[0]
return {
href: linkResult.href,
title: linkResult.title || '',
}
}
} catch (error) {
console.warn('Could not get link data for guide:', guidePath, error)
}
return null
}
/**
* Resolves the journey context for a given article path.
*
@@ -117,6 +152,8 @@ export async function resolveJourneyContext(
}
}
let trackIndex = 0
let foundTrackIndex = 0
for (const track of journeyPage.journeyTracks) {
if (!track.guides || !Array.isArray(track.guides)) continue
@@ -124,7 +161,7 @@ export async function resolveJourneyContext(
let guideIndex = -1
for (let i = 0; i < track.guides.length; i++) {
const guidePath = track.guides[i]
const guidePath = track.guides[i].href
let renderedGuidePath = guidePath
// Handle Liquid conditionals in guide paths
@@ -148,6 +185,21 @@ export async function resolveJourneyContext(
}
if (guideIndex >= 0) {
const alternativeNextStep = track.guides[guideIndex].alternativeNextStep || ''
let renderedAlternativeNextStep = alternativeNextStep
// Handle Liquid conditionals in branching text which likely has links
try {
renderedAlternativeNextStep = await executeWithFallback(
context,
() => renderContent(alternativeNextStep, context),
() => alternativeNextStep,
)
} catch {
// If rendering fails, use the original branching text rather than erroring
renderedAlternativeNextStep = alternativeNextStep
}
result = {
trackId: track.id,
trackName: track.id,
@@ -157,52 +209,51 @@ export async function resolveJourneyContext(
journeyPage.permalink || Permalink.relativePathToSuffix(journeyPage.relativePath || ''),
currentGuideIndex: guideIndex,
numberOfGuides: track.guides.length,
alternativeNextStep: renderedAlternativeNextStep,
}
// 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: linkResult.href,
title: linkResult.title || '',
}
}
} catch (error) {
console.warn('Could not get link data for previous guide:', prevGuidePath, error)
const prevGuidePath = track.guides[guideIndex - 1].href
const guideData = await fetchGuideData(prevGuidePath, context)
if (guideData) {
result.prevGuide = guideData
}
}
// 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: linkResult.href,
title: linkResult.title || '',
const nextGuidePath = track.guides[guideIndex + 1].href
const guideData = await fetchGuideData(nextGuidePath, context)
if (guideData) {
result.nextGuide = guideData
}
}
// Only populate nextTrackFirstGuide when on the last guide of the track
if (guideIndex === track.guides.length - 1) {
foundTrackIndex = trackIndex
if (
journeyPage.journeyTracks[foundTrackIndex + 1] &&
journeyPage.journeyTracks[foundTrackIndex + 1].guides.length > 0
) {
const nextTrack = journeyPage.journeyTracks[foundTrackIndex + 1]
const nextTrackFirstGuidePath = nextTrack.guides[0].href
const guideData = await fetchGuideData(nextTrackFirstGuidePath, context)
if (guideData) {
result.nextTrackFirstGuide = {
...guideData,
trackTitle: nextTrack.title,
}
}
} catch (error) {
console.warn('Could not get link data for next guide:', nextGuidePath, error)
}
}
break // Found the track, stop searching
}
trackIndex++
}
if (result) break // Found the journey, stop searching
@@ -217,11 +268,15 @@ export async function resolveJourneyContext(
* Returns an array of JourneyTrack objects with titles, descriptions, and guide links.
*/
export async function resolveJourneyTracks(
journeyTracks: any[],
journeyTracks: JourneyPage['journeyTracks'],
context: ContentContext,
): Promise<JourneyTrack[]> {
if (!journeyTracks || journeyTracks.length === 0) {
return []
}
const result = await Promise.all(
journeyTracks.map(async (track: any) => {
journeyTracks.map(async (track) => {
// Render Liquid templates in title and description
const renderedTitle = await renderContent(track.title, context, { textOnly: true })
const renderedDescription = track.description
@@ -229,9 +284,9 @@ export async function resolveJourneyTracks(
: 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
track.guides.map(async (guide: { href: string; alternativeNextStep?: string }) => {
const linkData = await getLinkData(guide.href, context, { title: true })
const baseHref = linkData?.[0]?.href || guide.href
return {
href: baseHref,
title: linkData?.[0]?.title || 'Untitled Guide',

View File

@@ -9,19 +9,11 @@ export default async function journeyTrack(
if (!req.context) throw new Error('request is not contextualized')
if (!req.context.page) return next()
// Only run journey resolution if the page has journey tracks defined
if (!(req.context.page as any).journeyTracks) {
req.context.currentJourneyTrack = null
return next()
}
try {
// Import and use the journey resolver which uses renderContent, need the
// async import since it uses fs Node apis
const journeyResolver = await import('../lib/journey-path-resolver')
// resolve the journey tracks which renders the journey content like the
// description to handle liquid rendering
// If this page has journey tracks defined, resolve them for the landing page
if ((req.context.page as any).journeyTracks) {
const resolvedTracks = await journeyResolver.resolveJourneyTracks(
(req.context.page as any).journeyTracks,
req.context,
@@ -29,9 +21,9 @@ export default async function journeyTrack(
// Store resolved tracks on the page context for later use in getServerSideProps
;(req.context.page as any).resolvedJourneyTracks = resolvedTracks
}
// resolve the current journey context since we're on a journey track page
// i.e. next/prev articles in the track, this article's position in the track
// Always try to resolve journey context (for navigation on guide articles)
const journeyContext = await journeyResolver.resolveJourneyContext(
req.pagePath || '',
req.context.pages || {},

View File

@@ -46,11 +46,21 @@ describe('journey-path-resolver', () => {
title: 'Getting started',
description: 'Learn the basics',
guides: [
'/enterprise-onboarding/setup',
'/enterprise-onboarding/config',
'/enterprise-onboarding/deploy',
{ href: '/enterprise-onboarding/setup' },
{
href: '/enterprise-onboarding/config',
alternativeNextStep:
'Ready for more? Visit [AUTOTITLE](/enterprise-onboarding/advanced-setup)',
},
{ href: '/enterprise-onboarding/deploy' },
],
},
{
id: 'advanced',
title: 'Advanced configuration',
description: 'Configure advanced options',
guides: [{ href: '/enterprise-onboarding/advanced-setup' }],
},
],
},
}
@@ -100,6 +110,28 @@ describe('journey-path-resolver', () => {
})
})
test('includes alternative next step when provided', async () => {
const result = await resolveJourneyContext(
'/enterprise-onboarding/config',
mockPages,
mockContext,
)
expect(result?.alternativeNextStep).toBe(
'Ready for more? Visit [AUTOTITLE](/enterprise-onboarding/advanced-setup)',
)
})
test('does not populate next track guide when not on last guide', async () => {
const result = await resolveJourneyContext(
'/enterprise-onboarding/config',
mockPages,
mockContext,
)
expect(result?.nextTrackFirstGuide).toBeUndefined()
})
test('handles first article in track (no previous)', async () => {
const result = await resolveJourneyContext(
'/enterprise-onboarding/setup',
@@ -122,6 +154,20 @@ describe('journey-path-resolver', () => {
expect(result?.currentGuideIndex).toBe(2)
})
test('populates next track guide when on last guide', async () => {
const result = await resolveJourneyContext(
'/enterprise-onboarding/deploy',
mockPages,
mockContext,
)
expect(result?.nextTrackFirstGuide).toEqual({
href: '/en/enterprise-cloud@latest/enterprise-onboarding/advanced-setup',
title: 'Mock Title for /enterprise-onboarding/advanced-setup',
trackTitle: 'Advanced configuration',
})
})
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
@@ -149,13 +195,16 @@ describe('journey-path-resolver', () => {
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'],
guides: [
{ href: '/enterprise-onboarding/setup' },
{ href: '/enterprise-onboarding/config' },
],
},
{
id: 'advanced',
title: 'Advanced configuration',
description: 'Advanced topics for experts',
guides: ['/enterprise-onboarding/advanced-setup'],
guides: [{ href: '/enterprise-onboarding/advanced-setup' }],
},
]
@@ -210,7 +259,7 @@ describe('journey-path-resolver', () => {
{
id: 'no_desc',
title: 'Track without description',
guides: ['/some-guide'],
guides: [{ href: '/some-guide' }],
},
]