diff --git a/src/lib/docs/__tests__/is-release-notes-page.test.ts b/src/lib/docs/__tests__/is-release-notes-page.test.ts new file mode 100644 index 0000000000..70eda3b82a --- /dev/null +++ b/src/lib/docs/__tests__/is-release-notes-page.test.ts @@ -0,0 +1,61 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import { isReleaseNotesPage } from 'lib/docs/is-release-notes-page' + +describe('isReleaseNotesPage', () => { + it('returns true for valid release notes page paths', () => { + const validPaths = [ + 'v202409-2/releases/2024/v202407-1', + 'releases/2022/v220601-1', + 'releases/2021/v210601-2', + 'release-notes/1.2.3', + 'release-notes/2.0.x', + '/release-notes/v2.0.x', + '/boundary/docs/release-notes/v0_15_0', + '/vault/docs/release-notes/1.13.0', + ] + + validPaths.forEach((path) => { + expect(isReleaseNotesPage(path)).toBe(true) + }) + }) + + it('returns false for invalid release notes page paths', () => { + const invalidPaths = [ + 'releases/2022/v220601', + 'releases/2021/v210601', + 'release-notes/1.2', + 'release-notes/2.0', + 'release-notes/2.x', + 'releases/2022/v220601-', + 'releases/2021/v210601-', + '/release-notes/1.2.', + '/release-notes/2.0.', + '/release-notes/2.x.', + '/releases/2022/v220601-1234-5678', + '/releases/2021/v210601-5678-1234', + '/release-notes/1.2.3.4', + '/release-notes/2.0.x.y', + ] + invalidPaths.forEach((path) => { + expect(isReleaseNotesPage(path)).toBe(false) + }) + }) + + it('returns false for non-release notes page paths', () => { + const nonReleaseNotesPaths = [ + '/releases', + '/getting-started', + '/enterprise/v202401-1/migrate', + '/enterprise/v202401-1/releases', + '/waypoint/reference/config', + '/vault/install', + ] + nonReleaseNotesPaths.forEach((path) => { + expect(isReleaseNotesPage(path)).toBe(false) + }) + }) +}) diff --git a/src/lib/docs/is-release-notes-page.ts b/src/lib/docs/is-release-notes-page.ts new file mode 100644 index 0000000000..8c04ba9717 --- /dev/null +++ b/src/lib/docs/is-release-notes-page.ts @@ -0,0 +1,25 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +/** + * Determines if a given path corresponds to a release notes page. + * + * This function uses a regular expression to check if the provided path matches + * the expected patterns for release notes pages. The patterns include: + * - `vYYYYMM-NN/releases/YYYY/vYYYYMM-NN` + * - `releases/YYYY/vYYYYMM-NN` + * - `/releases/YYYY/vYYYYMM-NN` + * - `release-notes/vX.X.X` or `/release-notes/X.X.X` + * + * @param path - The path to be checked. + * @returns `true` if the path matches the release notes pattern, otherwise `false`. + */ +export const isReleaseNotesPage = (path: string): boolean => { + const regexPatterns = [ + /(\/?releases\/\d{4}\/(v\d{6}-\d{1}))$/i, + /\/?release-notes\/(v\d+[.|_]|(\d+[.|_]))\d+[.|_]([0-9]|x)$/i, + ] + return regexPatterns.some((pattern) => pattern.test(path)) +} diff --git a/src/views/docs-view/server.ts b/src/views/docs-view/server.ts index 68b78ea41b..db77ecfff7 100644 --- a/src/views/docs-view/server.ts +++ b/src/views/docs-view/server.ts @@ -5,7 +5,7 @@ // Third-party imports import { GetStaticPaths, GetStaticProps, GetStaticPropsResult } from 'next' -import path from 'path' +import path from 'node:path' import { Pluggable } from 'unified' import slugify from 'slugify' @@ -36,13 +36,79 @@ import { import tutorialMap from 'data/_tutorial-map.generated.json' // Local imports -import { getValidVersions } from './utils/get-valid-versions' import { getProductUrlAdjuster } from './utils/product-url-adjusters' import { getBackToLink } from './utils/get-back-to-link' import { getDeployPreviewLoader } from './utils/get-deploy-preview-loader' import { getCustomLayout } from './utils/get-custom-layout' import type { DocsViewPropOptions } from './utils/get-root-docs-path-generation-functions' import { DocsViewProps } from './types' +import { isReleaseNotesPage } from 'lib/docs/is-release-notes-page' +import { getValidVersions } from './utils/get-valid-versions' +import { VersionSelectItem } from './loaders/remote-content' + +/** + * Fetches valid versions of a document based on the provided path parts and version information. + * + * @param pathParts - An array of strings representing parts of the document path. + * @param versionPathPart - A string representing the version part of the path. + * @param basePathForLoader - The base path used for loading the document. + * @param versions - An array of `VersionSelectItem` objects representing available versions. + * @param productSlugForLoader - A string representing the product slug used for loading the document. + * @returns A promise that resolves to an array of `VersionSelectItem` objects representing valid versions. + * + * This function filters the provided versions to include only those where the document exists. + * It handles special cases for release notes pages, ensuring the correct version is used in the path. + * For other pages, it constructs a document path that the content API will recognize and fetches valid versions. + */ +export async function fetchValidVersions( + pathParts: string[], + versionPathPart: string, + basePathForLoader: string, + versions: VersionSelectItem[], + productSlugForLoader: string +): Promise { + /** + * Filter versions to include only those where this document exists + */ + let pathToFetchValidVersions = pathParts.join('/') + + if (isReleaseNotesPage(pathToFetchValidVersions)) { + /** + * Check specific to PTFE releases notes page, which may have a version in the path twice + * e.g. v202409-2/releases/2024/v202407-1 + * Remove the first version instance, which is the docs version + * e.g. releases/2024/v202407-1 + * the mdx file this page pulls from has a version in the title (e.g. 202407-1mdx) + * the second version is the path should not be removed for this reason. + * This block is here because the default (else statement below) + * removes all versions from the path, which is not desired for release notes. + */ + if (/(v\d{6}-\d{1})\/releases/i.test(pathToFetchValidVersions)) { + pathToFetchValidVersions = pathToFetchValidVersions.replace( + versionPathPart, + '' + ) + } + } else { + // Construct a document path that the content API will recognize + pathToFetchValidVersions = pathParts + .filter((part) => part !== versionPathPart) + .join('/') + } + const fullPath = `doc#${path.join( + basePathForLoader, + pathToFetchValidVersions + )}` + + // Filter for valid versions, fetching from the content API under the hood + const validVersions = await getValidVersions( + versions, + fullPath, + productSlugForLoader + ) + + return validVersions +} /** * Returns static generation functions which can be exported from a page to fetch docs data @@ -387,15 +453,11 @@ export function getStaticGenerationFunctions< /** * Filter versions to include only those where this document exists */ - // Construct a document path that the content API will recognize - const pathWithoutVersion = pathParts - .filter((part) => part !== versionPathPart) - .join('/') - const fullPath = `doc#${path.join(basePathForLoader, pathWithoutVersion)}` - // Filter for valid versions, fetching from the content API under the hood - const validVersions = await getValidVersions( + const validVersions = await fetchValidVersions( + pathParts, + versionPathPart, + basePathForLoader, versions, - fullPath, productSlugForLoader ) diff --git a/src/views/docs-view/utils/__tests__/fetch-valid-versions.test.ts b/src/views/docs-view/utils/__tests__/fetch-valid-versions.test.ts new file mode 100644 index 0000000000..7f20358707 --- /dev/null +++ b/src/views/docs-view/utils/__tests__/fetch-valid-versions.test.ts @@ -0,0 +1,170 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import { describe, it, expect, vi } from 'vitest' +import { isReleaseNotesPage } from 'lib/docs/is-release-notes-page' +import { getValidVersions } from '../get-valid-versions' +import { VersionSelectItem } from '../../loaders/remote-content' +import { fetchValidVersions } from 'views/docs-view/server' + +vi.mock('lib/docs/is-release-notes-page') +vi.mock('../get-valid-versions') + +describe('fetchValidVersions', () => { + const versions: VersionSelectItem[] = [ + { + version: 'v1.0.0', + name: 'v1.0.0', + label: 'v1.0.0', + isLatest: false, + releaseStage: 'stable', + }, + { + version: 'v2.0.0', + name: '', + label: '', + isLatest: false, + releaseStage: 'stable', + }, + ] + + const mockIsReleaseNotesPage = (returnValue: boolean) => { + vi.mocked(isReleaseNotesPage).mockReturnValue(returnValue) + } + + const mockGetValidVersions = (returnValue: VersionSelectItem[]) => { + vi.mocked(getValidVersions).mockResolvedValue(returnValue) + } + + const runTest = async ( + pathParts: string[], + versionPathPart: string, + basePathForLoader: string, + productSlugForLoader: string, + expectedPath: string, + expectedVersions: VersionSelectItem[] + ) => { + const result = await fetchValidVersions( + pathParts, + versionPathPart, + basePathForLoader, + versions, + productSlugForLoader + ) + + expect(isReleaseNotesPage).toHaveBeenCalledWith(pathParts.join('/')) + expect(getValidVersions).toHaveBeenCalledWith( + versions, + expectedPath, + productSlugForLoader + ) + expect(result).toEqual(expectedVersions) + } + + it('should filter versions correctly for non-release notes pages', async () => { + const pathParts = ['docs', 'v1.0.0', 'guide'] + const versionPathPart = 'v1.0.0' + const basePathForLoader = '/base/path' + const productSlugForLoader = 'product-slug' + + mockIsReleaseNotesPage(false) + mockGetValidVersions([ + { + version: 'v1.0.0', + name: 'v1.0.0', + label: 'v1.0.0', + isLatest: false, + releaseStage: 'stable', + }, + ]) + + await runTest( + pathParts, + versionPathPart, + basePathForLoader, + productSlugForLoader, + 'doc#/base/path/docs/guide', + [ + { + version: 'v1.0.0', + name: 'v1.0.0', + label: 'v1.0.0', + isLatest: false, + releaseStage: 'stable', + }, + ] + ) + }) + + it('should filter versions correctly for release notes pages', async () => { + const pathParts = ['v202409-2', 'releases', '2024', 'v202409-1'] + const versionPathPart = 'v202409-2' + const basePathForLoader = 'enterprise' + const productSlugForLoader = 'ptfe-releases' + const releaseNotesVersions: VersionSelectItem[] = [ + { + name: 'latest', + label: 'v202409-2 (latest)', + isLatest: true, + releaseStage: 'stable', + version: 'v202409-2', + }, + { + name: 'v202409-1', + label: 'v202409-1', + isLatest: false, + releaseStage: 'stable', + version: 'v202409-1', + }, + ] + + mockIsReleaseNotesPage(true) + mockGetValidVersions(releaseNotesVersions) + + await runTest( + pathParts, + versionPathPart, + basePathForLoader, + productSlugForLoader, + 'doc#enterprise/releases/2024/v202409-1', + releaseNotesVersions + ) + }) + + it('should handle paths without versions correctly', async () => { + const pathParts = ['docs', 'guide'] + const versionPathPart = 'v1.0.0' + const basePathForLoader = '/base/path' + const productSlugForLoader = 'product-slug' + + mockIsReleaseNotesPage(false) + mockGetValidVersions([ + { + version: 'v1.0.0', + name: 'v1.0.0', + label: 'v1.0.0', + isLatest: false, + releaseStage: 'stable', + }, + ]) + + await runTest( + pathParts, + versionPathPart, + basePathForLoader, + productSlugForLoader, + 'doc#/base/path/docs/guide', + [ + { + version: 'v1.0.0', + name: 'v1.0.0', + label: 'v1.0.0', + isLatest: false, + releaseStage: 'stable', + }, + ] + ) + }) +}) diff --git a/src/views/docs-view/utils/__tests__/get-valid-versions.test.ts b/src/views/docs-view/utils/__tests__/get-valid-versions.test.ts new file mode 100644 index 0000000000..d6279e6fb3 --- /dev/null +++ b/src/views/docs-view/utils/__tests__/get-valid-versions.test.ts @@ -0,0 +1,98 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import { describe, it, expect, vi } from 'vitest' +import { getValidVersions } from '../get-valid-versions' +import type { VersionSelectItem } from '../../loaders/remote-content' + +// Mock fetch +global.fetch = vi.fn() as typeof fetch + +describe('getValidVersions', () => { + const versions: VersionSelectItem[] = [ + { + version: '1.0.0', + label: 'v1.0.0', + name: 'v1.0.0', + isLatest: false, + releaseStage: 'stable', + }, + { + version: '2.0.0', + label: 'v2.0.0', + name: 'v2.0.0', + isLatest: true, + releaseStage: 'stable', + }, + ] + const fullPath = 'doc#/path/to/document' + const productSlugForLoader = 'product-slug' + + it('should return an empty array if versions are falsy or empty', async () => { + expect(await getValidVersions([], fullPath, productSlugForLoader)).toEqual( + [] + ) + expect( + await getValidVersions(undefined as any, fullPath, productSlugForLoader) + ).toEqual([]) + }) + + it('should return filtered versions based on known versions from API', async () => { + const knownVersions = ['1.0.0'] + ;(fetch as any).mockResolvedValueOnce({ + json: async () => ({ versions: knownVersions }), + }) + + const result = await getValidVersions( + versions, + fullPath, + productSlugForLoader + ) + expect(result).toEqual([ + { + isLatest: false, + label: 'v1.0.0', + name: 'v1.0.0', + releaseStage: 'stable', + version: '1.0.0', + }, + ]) + }) + + it('should return all versions if API call fails', async () => { + // Temporarily mock console.error + const originalConsoleError = console.error + console.error = vi.fn() + + try { + ;(fetch as any).mockRejectedValueOnce(new Error('API error')) + + const result = await getValidVersions( + versions, + fullPath, + productSlugForLoader + ) + expect(result).toEqual(versions) + } finally { + // Restore console.error after the test + console.error = originalConsoleError + } + }) + + it('should log an error if API call fails', async () => { + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}) + ;(fetch as any).mockRejectedValueOnce(new Error('API error')) + + await getValidVersions(versions, fullPath, productSlugForLoader) + expect(consoleErrorSpy).toHaveBeenCalledWith( + `[docs-view/server] error fetching known versions for "${productSlugForLoader}" document "${fullPath}". Falling back to showing all versions.`, + expect.any(Error) + ) + + consoleErrorSpy.mockRestore() + }) +})