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. - `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) - `title` (required): Display title for the journey (supports Liquid variables)
- `description` (optional): Description of 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`. - Only applicable when used with `layout: journey-landing`.
- Optional. - Optional.
@@ -271,15 +273,16 @@ journeyTracks:
title: 'Getting started with {% data variables.product.prodname_actions %}' title: 'Getting started with {% data variables.product.prodname_actions %}'
description: 'Learn the basics of GitHub Actions.' description: 'Learn the basics of GitHub Actions.'
guides: guides:
- '/actions/quickstart' - href: '/actions/quickstart'
- '/actions/learn-github-actions' - href: '/actions/learn-github-actions'
- '/actions/using-workflows' alternativeNextStep: 'Want to skip ahead? See [AUTOTITLE](/actions/using-workflows).'
- href: '/actions/using-workflows'
- id: 'advanced' - id: 'advanced'
title: 'Advanced {% data variables.product.prodname_actions %}' title: 'Advanced {% data variables.product.prodname_actions %}'
description: 'Dive deeper into advanced features.' description: 'Dive deeper into advanced features.'
guides: guides:
- '/actions/using-workflows/workflow-syntax-for-github-actions' - href: '/actions/using-workflows/workflow-syntax-for-github-actions'
- '/actions/deployment/deploying-with-github-actions' - href: '/actions/deployment/deploying-with-github-actions'
``` ```
### `type` ### `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. * `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) * `title` (required): Display title for the journey (supports Liquid variables)
* `description` (optional): Description of 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`. * Only applicable when used with `layout: journey-landing`.
* Optional. * Optional.
@@ -269,15 +271,16 @@ journeyTracks:
title: 'Getting started with {% data variables.product.prodname_actions %}' title: 'Getting started with {% data variables.product.prodname_actions %}'
description: 'Learn the basics of GitHub Actions.' description: 'Learn the basics of GitHub Actions.'
guides: guides:
- '/actions/quickstart' - href: '/actions/quickstart'
- '/actions/learn-github-actions' - href: '/actions/learn-github-actions'
- '/actions/using-workflows' alternativeNextStep: 'Want to skip ahead? See [AUTOTITLE](/actions/using-workflows).'
- href: '/actions/using-workflows'
- id: 'advanced' - id: 'advanced'
title: 'Advanced {% data variables.product.prodname_actions %}' title: 'Advanced {% data variables.product.prodname_actions %}'
description: 'Dive deeper into advanced features.' description: 'Dive deeper into advanced features.'
guides: guides:
- '/actions/using-workflows/workflow-syntax-for-github-actions' - href: '/actions/using-workflows/workflow-syntax-for-github-actions'
- '/actions/deployment/deploying-with-github-actions' - href: '/actions/deployment/deploying-with-github-actions'
``` ```
### `type` ### `type`

View File

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

View File

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

View File

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

View File

@@ -75,16 +75,38 @@ export const journeyTracksLiquid = {
if (track.guides && Array.isArray(track.guides)) { if (track.guides && Array.isArray(track.guides)) {
for (let guideIndex = 0; guideIndex < track.guides.length; guideIndex++) { for (let guideIndex = 0; guideIndex < track.guides.length; guideIndex++) {
const guide: string = track.guides[guideIndex] const guideObj = track.guides[guideIndex]
if (typeof guide === 'string') {
// 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 { try {
liquid.parse(guide) liquid.parse(guideObj.href)
} catch (error: any) { } catch (error: any) {
addError( addError(
onError, onError,
trackLineNumber, trackLineNumber,
`Invalid Liquid syntax in journey track guide (track ${trackIndex + 1}, guide ${guideIndex + 1}): ${error.message}`, `Invalid Liquid syntax in journey track guide href (track ${trackIndex + 1}, guide ${guideIndex + 1}): ${error.message}`,
guide, 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 - id: duplicate-id
title: "First Track" title: "First Track"
guides: guides:
- /article-one - href: /article-one
- id: unique-id - id: unique-id
title: "Unique Track" title: "Unique Track"
guides: guides:
- /article-two - href: /article-two
- id: duplicate-id - id: duplicate-id
title: "Second Track with Same ID" title: "Second Track with Same ID"
guides: guides:
- /subdir/article-three - href: /subdir/article-three
--- ---
# Journey with Duplicate IDs # Journey with Duplicate IDs

View File

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

View File

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

View File

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

View File

@@ -40,7 +40,7 @@ journeyTracks:
title: "Track with {% invalid liquid" title: "Track with {% invalid liquid"
description: "Description with {{ unclosed liquid" description: "Description with {{ unclosed liquid"
guides: guides:
- /article-one - href: /article-one
--- ---
# Journey with Liquid Issues # 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'][0].ruleDescription).toMatch(/liquid syntax/i)
expect(result['test-invalid-liquid.md'][1].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', () => { describe('journey-tracks-guide-path-exists', () => {

View File

@@ -18,4 +18,5 @@ children:
- /page-with-permissions-and-product-callout - /page-with-permissions-and-product-callout
- /table-with-ifversions - /table-with-ifversions
- /code-snippet-with-hashbang - /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' title: 'Getting started'
description: 'Learn the basics of our platform.' description: 'Learn the basics of our platform.'
guides: guides:
- '/get-started/start-your-journey/hello-world' - href: '/get-started/start-your-journey/hello-world'
- '/get-started/foo/bar' - href: '/get-started/foo/bar'
- id: 'advanced' - id: 'advanced'
title: 'Advanced topics' title: 'Advanced topics'
description: 'Dive deeper into advanced features.' description: 'Dive deeper into advanced features.'
guides: guides:
- '/get-started/foo/autotitling' - href: '/get-started/foo/autotitling'
- '/get-started/start-your-journey/hello-world' - href: '/get-started/start-your-journey/hello-world'
children: children:
- /start-your-journey - /start-your-journey
- /foo - /foo

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,6 +13,11 @@ export interface JourneyContext {
journeyPath: string journeyPath: string
currentGuideIndex: number currentGuideIndex: number
numberOfGuides: number numberOfGuides: number
nextTrackFirstGuide?: {
href: string
title: string
trackTitle: string
}
nextGuide?: { nextGuide?: {
href: string href: string
title: string title: string
@@ -21,6 +26,7 @@ export interface JourneyContext {
href: string href: string
title: string title: string
} }
alternativeNextStep?: string
} }
export interface JourneyTrack { export interface JourneyTrack {
@@ -43,7 +49,10 @@ type JourneyPage = {
id: string id: string
title: string title: string
description?: string description?: string
guides: string[] guides: Array<{
href: string
alternativeNextStep?: string
}>
}> }>
} }
@@ -83,6 +92,32 @@ function normalizeGuidePath(path: string): string {
: `/${withoutLanguage || path}` : `/${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. * 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) { for (const track of journeyPage.journeyTracks) {
if (!track.guides || !Array.isArray(track.guides)) continue if (!track.guides || !Array.isArray(track.guides)) continue
@@ -124,7 +161,7 @@ export async function resolveJourneyContext(
let guideIndex = -1 let guideIndex = -1
for (let i = 0; i < track.guides.length; i++) { for (let i = 0; i < track.guides.length; i++) {
const guidePath = track.guides[i] const guidePath = track.guides[i].href
let renderedGuidePath = guidePath let renderedGuidePath = guidePath
// Handle Liquid conditionals in guide paths // Handle Liquid conditionals in guide paths
@@ -148,6 +185,21 @@ export async function resolveJourneyContext(
} }
if (guideIndex >= 0) { 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 = { result = {
trackId: track.id, trackId: track.id,
trackName: track.id, trackName: track.id,
@@ -157,52 +209,51 @@ export async function resolveJourneyContext(
journeyPage.permalink || Permalink.relativePathToSuffix(journeyPage.relativePath || ''), journeyPage.permalink || Permalink.relativePathToSuffix(journeyPage.relativePath || ''),
currentGuideIndex: guideIndex, currentGuideIndex: guideIndex,
numberOfGuides: track.guides.length, numberOfGuides: track.guides.length,
alternativeNextStep: renderedAlternativeNextStep,
} }
// Set up previous guide // Set up previous guide
if (guideIndex > 0) { if (guideIndex > 0) {
const prevGuidePath = track.guides[guideIndex - 1] const prevGuidePath = track.guides[guideIndex - 1].href
try { const guideData = await fetchGuideData(prevGuidePath, context)
const resultData = await getLinkData(prevGuidePath, context, { if (guideData) {
title: true, result.prevGuide = guideData
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)
} }
} }
// Set up next guide // Set up next guide
if (guideIndex < track.guides.length - 1) { if (guideIndex < track.guides.length - 1) {
const nextGuidePath = track.guides[guideIndex + 1] const nextGuidePath = track.guides[guideIndex + 1].href
try { const guideData = await fetchGuideData(nextGuidePath, context)
const resultData = await getLinkData(nextGuidePath, context, { if (guideData) {
title: true, result.nextGuide = guideData
intro: false, }
fullTitle: false, }
})
if (resultData && resultData.length > 0) { // Only populate nextTrackFirstGuide when on the last guide of the track
const linkResult = resultData[0] if (guideIndex === track.guides.length - 1) {
result.nextGuide = { foundTrackIndex = trackIndex
href: linkResult.href,
title: linkResult.title || '', 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 break // Found the track, stop searching
} }
trackIndex++
} }
if (result) break // Found the journey, stop searching 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. * Returns an array of JourneyTrack objects with titles, descriptions, and guide links.
*/ */
export async function resolveJourneyTracks( export async function resolveJourneyTracks(
journeyTracks: any[], journeyTracks: JourneyPage['journeyTracks'],
context: ContentContext, context: ContentContext,
): Promise<JourneyTrack[]> { ): Promise<JourneyTrack[]> {
if (!journeyTracks || journeyTracks.length === 0) {
return []
}
const result = await Promise.all( const result = await Promise.all(
journeyTracks.map(async (track: any) => { journeyTracks.map(async (track) => {
// Render Liquid templates in title and description // Render Liquid templates in title and description
const renderedTitle = await renderContent(track.title, context, { textOnly: true }) const renderedTitle = await renderContent(track.title, context, { textOnly: true })
const renderedDescription = track.description const renderedDescription = track.description
@@ -229,9 +284,9 @@ export async function resolveJourneyTracks(
: undefined : undefined
const guides = await Promise.all( const guides = await Promise.all(
track.guides.map(async (guidePath: string) => { track.guides.map(async (guide: { href: string; alternativeNextStep?: string }) => {
const linkData = await getLinkData(guidePath, context, { title: true }) const linkData = await getLinkData(guide.href, context, { title: true })
const baseHref = linkData?.[0]?.href || guidePath const baseHref = linkData?.[0]?.href || guide.href
return { return {
href: baseHref, href: baseHref,
title: linkData?.[0]?.title || 'Untitled Guide', title: linkData?.[0]?.title || 'Untitled Guide',

View File

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

View File

@@ -46,11 +46,21 @@ describe('journey-path-resolver', () => {
title: 'Getting started', title: 'Getting started',
description: 'Learn the basics', description: 'Learn the basics',
guides: [ guides: [
'/enterprise-onboarding/setup', { href: '/enterprise-onboarding/setup' },
'/enterprise-onboarding/config', {
'/enterprise-onboarding/deploy', 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 () => { test('handles first article in track (no previous)', async () => {
const result = await resolveJourneyContext( const result = await resolveJourneyContext(
'/enterprise-onboarding/setup', '/enterprise-onboarding/setup',
@@ -122,6 +154,20 @@ describe('journey-path-resolver', () => {
expect(result?.currentGuideIndex).toBe(2) 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 () => { test('normalizes article paths without leading slash', async () => {
// The resolver should handle paths without leading slashes // The resolver should handle paths without leading slashes
// by normalizing them to match the guide paths in the data // by normalizing them to match the guide paths in the data
@@ -149,13 +195,16 @@ describe('journey-path-resolver', () => {
id: 'getting_started', id: 'getting_started',
title: 'Getting started with {% data variables.product.company_short %}', title: 'Getting started with {% data variables.product.company_short %}',
description: 'Learn the {% data variables.product.company_short %} basics', 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', id: 'advanced',
title: 'Advanced configuration', title: 'Advanced configuration',
description: 'Advanced topics for experts', 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', id: 'no_desc',
title: 'Track without description', title: 'Track without description',
guides: ['/some-guide'], guides: [{ href: '/some-guide' }],
}, },
] ]