diff --git a/content/README.md b/content/README.md index c0806e63ae..e2740dfeef 100644 --- a/content/README.md +++ b/content/README.md @@ -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` diff --git a/content/contributing/writing-for-github-docs/using-yaml-frontmatter.md b/content/contributing/writing-for-github-docs/using-yaml-frontmatter.md index d66fafb3d1..764cb95acb 100644 --- a/content/contributing/writing-for-github-docs/using-yaml-frontmatter.md +++ b/content/contributing/writing-for-github-docs/using-yaml-frontmatter.md @@ -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` diff --git a/content/enterprise-onboarding/index.md b/content/enterprise-onboarding/index.md index a3dffca05b..c79a08f5d1 100644 --- a/content/enterprise-onboarding/index.md +++ b/content/enterprise-onboarding/index.md @@ -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: diff --git a/content/get-started/start-your-journey/index.md b/content/get-started/start-your-journey/index.md index ec7ad779f1..8fad16bcef 100644 --- a/content/get-started/start-your-journey/index.md +++ b/content/get-started/start-your-journey/index.md @@ -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' --- diff --git a/src/content-linter/lib/linting-rules/journey-tracks-guide-path-exists.ts b/src/content-linter/lib/linting-rules/journey-tracks-guide-path-exists.ts index 17bfbe04c3..5ded02cac2 100644 --- a/src/content-linter/lib/linting-rules/journey-tracks-guide-path-exists.ts +++ b/src/content-linter/lib/linting-rules/journey-tracks-guide-path-exists.ts @@ -71,14 +71,19 @@ export const journeyTracksGuidePathExists = { const trackObj = track as Record 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, ) } } diff --git a/src/content-linter/lib/linting-rules/journey-tracks-liquid.ts b/src/content-linter/lib/linting-rules/journey-tracks-liquid.ts index b125c043c1..bb602fc1c1 100644 --- a/src/content-linter/lib/linting-rules/journey-tracks-liquid.ts +++ b/src/content-linter/lib/linting-rules/journey-tracks-liquid.ts @@ -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, ) } } diff --git a/src/content-linter/tests/fixtures/journey-tracks/duplicate-ids.md b/src/content-linter/tests/fixtures/journey-tracks/duplicate-ids.md index bcc808a813..a54559bc8d 100644 --- a/src/content-linter/tests/fixtures/journey-tracks/duplicate-ids.md +++ b/src/content-linter/tests/fixtures/journey-tracks/duplicate-ids.md @@ -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 diff --git a/src/content-linter/tests/fixtures/journey-tracks/invalid-paths.md b/src/content-linter/tests/fixtures/journey-tracks/invalid-paths.md index ef4f7d558b..c863f9c0f0 100644 --- a/src/content-linter/tests/fixtures/journey-tracks/invalid-paths.md +++ b/src/content-linter/tests/fixtures/journey-tracks/invalid-paths.md @@ -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 diff --git a/src/content-linter/tests/fixtures/journey-tracks/non-journey-layout.md b/src/content-linter/tests/fixtures/journey-tracks/non-journey-layout.md index a7dcec80eb..53b5599487 100644 --- a/src/content-linter/tests/fixtures/journey-tracks/non-journey-layout.md +++ b/src/content-linter/tests/fixtures/journey-tracks/non-journey-layout.md @@ -11,7 +11,7 @@ journeyTracks: - id: track-1 title: "Should be ignored" guides: - - /nonexistent/path + - href: /nonexistent/path --- # Non-Journey Page diff --git a/src/content-linter/tests/fixtures/journey-tracks/valid-journey.md b/src/content-linter/tests/fixtures/journey-tracks/valid-journey.md index 30b98033b4..bf64df1389 100644 --- a/src/content-linter/tests/fixtures/journey-tracks/valid-journey.md +++ b/src/content-linter/tests/fixtures/journey-tracks/valid-journey.md @@ -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 diff --git a/src/content-linter/tests/unit/journey-tracks.ts b/src/content-linter/tests/unit/journey-tracks.ts index f097eb3f16..08615adadb 100644 --- a/src/content-linter/tests/unit/journey-tracks.ts +++ b/src/content-linter/tests/unit/journey-tracks.ts @@ -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', () => { diff --git a/src/fixtures/fixtures/content/get-started/foo/index.md b/src/fixtures/fixtures/content/get-started/foo/index.md index e49f677283..774984fc47 100644 --- a/src/fixtures/fixtures/content/get-started/foo/index.md +++ b/src/fixtures/fixtures/content/get-started/foo/index.md @@ -18,4 +18,5 @@ children: - /page-with-permissions-and-product-callout - /table-with-ifversions - /code-snippet-with-hashbang + - /journey-test-article --- diff --git a/src/fixtures/fixtures/content/get-started/foo/journey-test-article.md b/src/fixtures/fixtures/content/get-started/foo/journey-test-article.md new file mode 100644 index 0000000000..e6541e2287 --- /dev/null +++ b/src/fixtures/fixtures/content/get-started/foo/journey-test-article.md @@ -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. diff --git a/src/fixtures/fixtures/content/get-started/index.md b/src/fixtures/fixtures/content/get-started/index.md index f49326331f..45693f0c70 100644 --- a/src/fixtures/fixtures/content/get-started/index.md +++ b/src/fixtures/fixtures/content/get-started/index.md @@ -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 diff --git a/src/fixtures/fixtures/content/get-started/test-journey/index.md b/src/fixtures/fixtures/content/get-started/test-journey/index.md index 1b30f95696..c6cc9365b5 100644 --- a/src/fixtures/fixtures/content/get-started/test-journey/index.md +++ b/src/fixtures/fixtures/content/get-started/test-journey/index.md @@ -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. diff --git a/src/fixtures/tests/playwright-rendering.spec.ts b/src/fixtures/tests/playwright-rendering.spec.ts index fd7d8b895e..385439074e 100644 --- a/src/fixtures/tests/playwright-rendering.spec.ts +++ b/src/fixtures/tests/playwright-rendering.spec.ts @@ -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', () => { diff --git a/src/frame/components/article/ArticlePage.tsx b/src/frame/components/article/ArticlePage.tsx index 89ef49ed88..629655fc9f 100644 --- a/src/frame/components/article/ArticlePage.tsx +++ b/src/frame/components/article/ArticlePage.tsx @@ -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 && } + {isJourneyTrack && } {miniTocItems.length > 1 && } ) @@ -122,6 +126,11 @@ export const ArticlePage = () => { ) : null} + {isJourneyTrack ? ( +
+ +
+ ) : null} ) : (
@@ -148,6 +157,11 @@ export const ArticlePage = () => {
) : null} + {isJourneyTrack ? ( +
+ +
+ ) : null} )} diff --git a/src/frame/lib/frontmatter.ts b/src/frame/lib/frontmatter.ts index 3beca783ef..a923d1b06b 100644 --- a/src/frame/lib/frontmatter.ts +++ b/src/frame/lib/frontmatter.ts @@ -247,7 +247,20 @@ export const schema: Schema = { guides: { type: 'array', 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', }, diff --git a/src/journeys/components/JourneyTrackCard.tsx b/src/journeys/components/JourneyTrackCard.tsx index 26a84fcf40..7eda7b69e6 100644 --- a/src/journeys/components/JourneyTrackCard.tsx +++ b/src/journeys/components/JourneyTrackCard.tsx @@ -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 (

- {journeyTitle} + {trackTitle}

- {trackTitle} {t('current_progress') .replace('{n}', `${numberOfGuides}`) @@ -49,6 +47,12 @@ export function JourneyTrackCard({ journey }: Props) { )} + {journey.alternativeNextStep && ( +
+ )}
) diff --git a/src/journeys/components/JourneyTrackNav.tsx b/src/journeys/components/JourneyTrackNav.tsx index 91f71bc383..4dba06ba3e 100644 --- a/src/journeys/components/JourneyTrackNav.tsx +++ b/src/journeys/components/JourneyTrackNav.tsx @@ -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 (
- - {trackTitle} - - {t('current_progress') - .replace('{n}', `${numberOfGuides}`) - .replace('{i}', `${currentGuideIndex + 1}`)} - - - - {nextGuide && ( + {nextGuide ? ( <> {t('next_article')} {nextGuide.title} - )} + ) : nextTrackFirstGuide ? ( + <> + {t('next_article')} + + {nextTrackFirstGuide.trackTitle} + + + ) : null}
) diff --git a/src/journeys/lib/journey-path-resolver.ts b/src/journeys/lib/journey-path-resolver.ts index 1bc9b22088..b1136fbdec 100644 --- a/src/journeys/lib/journey-path-resolver.ts +++ b/src/journeys/lib/journey-path-resolver.ts @@ -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 { + 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', diff --git a/src/journeys/middleware/journey-track.ts b/src/journeys/middleware/journey-track.ts index e6e4127857..481ba58ca6 100644 --- a/src/journeys/middleware/journey-track.ts +++ b/src/journeys/middleware/journey-track.ts @@ -9,29 +9,21 @@ 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 - const resolvedTracks = await journeyResolver.resolveJourneyTracks( - (req.context.page as any).journeyTracks, - req.context, - ) + // 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, + ) - // Store resolved tracks on the page context for later use in getServerSideProps - ;(req.context.page as any).resolvedJourneyTracks = resolvedTracks + // 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 || {}, diff --git a/src/journeys/tests/journey-path-resolver.ts b/src/journeys/tests/journey-path-resolver.ts index 510b14a99a..891bd940cd 100644 --- a/src/journeys/tests/journey-path-resolver.ts +++ b/src/journeys/tests/journey-path-resolver.ts @@ -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' }], }, ]