From 96fbcb3f51626e8310ebbb89145a70315cb16760 Mon Sep 17 00:00:00 2001 From: Joshua Chen Date: Sat, 9 Apr 2022 17:08:57 +0800 Subject: [PATCH] refactor(content-docs): split version handling into several files (#7140) * refactor(content-docs): split version handling into several files * fix test * increase timeout --- .../src/__tests__/cli.test.ts | Bin 11678 -> 11292 bytes .../src/__tests__/docs.test.ts | 2 + .../docusaurus-plugin-content-docs/src/cli.ts | 63 +- .../src/constants.ts | 6 +- .../src/index.ts | 4 +- .../src/server-export.ts | 4 +- .../src/sidebars/index.ts | 2 - .../src/versions.ts | 593 ------------------ .../__tests__/index.test.ts} | 62 +- .../src/versions/files.ts | 220 +++++++ .../src/versions/index.ts | 247 ++++++++ .../src/versions/validation.ts | 113 ++++ 12 files changed, 618 insertions(+), 698 deletions(-) delete mode 100644 packages/docusaurus-plugin-content-docs/src/versions.ts rename packages/docusaurus-plugin-content-docs/src/{__tests__/versions.test.ts => versions/__tests__/index.test.ts} (91%) create mode 100644 packages/docusaurus-plugin-content-docs/src/versions/files.ts create mode 100644 packages/docusaurus-plugin-content-docs/src/versions/index.ts create mode 100644 packages/docusaurus-plugin-content-docs/src/versions/validation.ts diff --git a/packages/docusaurus-plugin-content-docs/src/__tests__/cli.test.ts b/packages/docusaurus-plugin-content-docs/src/__tests__/cli.test.ts index 6b684e82b8a9ca81f4ae3a3460ac68855088a95f..bd0db4e0ad5a940921aaa503181ffdbda5d034d7 100644 GIT binary patch delta 1404 zcmchX&ubGw6vtg5-SkJAMi6ZS<~g*6(YQ^2TrB>m9{iC*FQODI;$$;PR<|=@cT$lG zMe(8_Lf_RN=XeV2)uYgVz?*_+j|C6Xld~oo6Lzs)I>$F}KkvKmec!yj^Ln-Y#=v(* zF?rLe@G|p!{9;TdW~+|NgYU-k!&h!|&v&Ho!LQ4vTY)kMUwMwG`4(K2O;G{$sKJ5A z@_lYA0#)h3M&}zKqUpK>$A>QAbmD1gzbFmmRY&m30Ie6-$kyr&vB6xY@~4_$>skj{ z>JO|peFe4@ia7$90+%x#kb;Ad;_Sn^qqyH-HV3=TJZ3BIVb1)v5Dl+NIjfsASfkS# zoJ&$8kC~wh7$hGo#uC=n{)J#EiZE*8hv83K(HM46XX~H}#ndUq#0N)UGa;l3-RVkE z;~uCw6R_G9TEywmkC&rcYFUHKuH(C0ViC~ZCOQvS#@;W-Q1%bWnFV~2`do?z zo!4G_tc`Lp2B`m(L1zKNWrYS)b-fnLm`Xpp9*xw*L$THiTNe#HH}NDMxb&aEoH-L* z%TRNq##Q${z*ol$!v)CCSw*Xu$6dM-oS9rSa3VX>dzEt8nZBH(c=x0k>`i@5{7q`n z6Zn#3!p!;Kv@r8lKdeoUMW&g!H+4GLBSb`Osg$ow}KMqQvM3;g)L0meMq#?F3L9m(S40nF_oO|zidFR#I>rP;XTG$B` zGSs@kEc%c*vc#M+QMNw&6e^CWL)I|PwWuSR;O>vW^BMdS4?o}n`tLxID#!*kS%pz6 z1!W>7D4PzxZ;2*6V$udBz;q~46o>@EfjHG>opPl^ML0kolX{~@m*KooFbeqY$cO2G zKF?jjNRUkj){g1;Gx%0pvxF({F9YK(Qe)P@t9@RU1bz=|r{Siv1ya*4etiKN|g}fMXloTx%RYRy{kNQRqa^DjmEVQ5-ky}Qgyo* zKwem_B=xAK&qDvnV(a8tT*wu0Q;WDClNST{EtfbkTrw1F7@p5$hpYP`mhXm6UiKr< z*pPzbj+Vh 0; if (shouldCreateVersionedSidebarFile) { - const versionedSidebarsDir = getVersionedSidebarsDirPath(siteDir, pluginId); - const newSidebarFile = path.join( - versionedSidebarsDir, - `version-${version}-sidebars.json`, - ); await fs.outputFile( - newSidebarFile, + getVersionSidebarsPath(siteDir, pluginId, version), `${JSON.stringify(sidebars, null, 2)}\n`, 'utf8', ); @@ -57,7 +53,7 @@ async function createVersionedSidebarFile({ // Tests depend on non-default export for mocking. export async function cliDocsVersionCommand( - version: string | null | undefined, + version: string, {id: pluginId, path: docsPath, sidebarPath}: PluginOptions, {siteDir, i18n}: LoadContext, ): Promise { @@ -66,44 +62,18 @@ export async function cliDocsVersionCommand( const pluginIdLogPrefix = pluginId === DEFAULT_PLUGIN_ID ? '[docs]' : `[${pluginId}]`; - if (!version) { - throw new Error( - `${pluginIdLogPrefix}: no version tag specified! Pass the version you wish to create as an argument, for example: 1.0.0.`, - ); - } - - if (version.includes('/') || version.includes('\\')) { - throw new Error( - `${pluginIdLogPrefix}: invalid version tag specified! Do not include slash (/) or backslash (\\). Try something like: 1.0.0.`, - ); - } - - if (version.length > 32) { - throw new Error( - `${pluginIdLogPrefix}: invalid version tag specified! Length cannot exceed 32 characters. Try something like: 1.0.0.`, - ); - } - - // Since we are going to create `version-${version}` folder, we need to make - // sure it's a valid pathname. - // eslint-disable-next-line no-control-regex - if (/[<>:"|?*\x00-\x1F]/.test(version)) { - throw new Error( - `${pluginIdLogPrefix}: invalid version tag specified! Please ensure its a valid pathname too. Try something like: 1.0.0.`, - ); - } - - if (/^\.\.?$/.test(version)) { - throw new Error( - `${pluginIdLogPrefix}: invalid version tag specified! Do not name your version "." or "..". Try something like: 1.0.0.`, - ); + try { + validateVersionName(version); + } catch (e) { + logger.info`${pluginIdLogPrefix}: Invalid version name provided. Try something like: 1.0.0`; + throw e; } // Load existing versions. let versions = []; const versionsJSONFile = getVersionsFilePath(siteDir, pluginId); if (await fs.pathExists(versionsJSONFile)) { - versions = JSON.parse(await fs.readFile(versionsJSONFile, 'utf8')); + versions = await fs.readJSON(versionsJSONFile); } // Check if version already exists. @@ -146,10 +116,7 @@ export async function cliDocsVersionCommand( const newVersionDir = locale === i18n.defaultLocale - ? path.join( - getVersionedDocsDirPath(siteDir, pluginId), - `version-${version}`, - ) + ? getVersionDocsDirPath(siteDir, pluginId, version) : getDocsDirPathLocalized({ siteDir, locale, @@ -164,7 +131,7 @@ export async function cliDocsVersionCommand( siteDir, pluginId, version, - sidebarPath: resolveSidebarPathOption(siteDir, sidebarPath), + sidebarPath, }); // Update versions.json file. diff --git a/packages/docusaurus-plugin-content-docs/src/constants.ts b/packages/docusaurus-plugin-content-docs/src/constants.ts index 822b8e600e..943bb4ce49 100644 --- a/packages/docusaurus-plugin-content-docs/src/constants.ts +++ b/packages/docusaurus-plugin-content-docs/src/constants.ts @@ -5,9 +5,11 @@ * LICENSE file in the root directory of this source tree. */ -// The name of the version at the root of your site (website/docs) +/** The name of the version that's actively worked on (e.g. `website/docs`) */ export const CURRENT_VERSION_NAME = 'current'; - +/** All doc versions are stored here by version names */ export const VERSIONED_DOCS_DIR = 'versioned_docs'; +/** All doc versioned sidebars are stored here by version names */ export const VERSIONED_SIDEBARS_DIR = 'versioned_sidebars'; +/** The version names. Should 1-1 map to the content of versioned docs dir. */ export const VERSIONS_JSON_FILE = 'versions.json'; diff --git a/packages/docusaurus-plugin-content-docs/src/index.ts b/packages/docusaurus-plugin-content-docs/src/index.ts index 7fbc9595b4..9889276775 100644 --- a/packages/docusaurus-plugin-content-docs/src/index.ts +++ b/packages/docusaurus-plugin-content-docs/src/index.ts @@ -20,7 +20,7 @@ import { DEFAULT_PLUGIN_ID, } from '@docusaurus/utils'; import type {LoadContext, Plugin} from '@docusaurus/types'; -import {loadSidebars} from './sidebars'; +import {loadSidebars, resolveSidebarPathOption} from './sidebars'; import {CategoryMetadataFilenamePattern} from './sidebars/generator'; import { readVersionDocs, @@ -64,6 +64,8 @@ export default async function pluginContentDocs( options: PluginOptions, ): Promise> { const {siteDir, generatedFilesDir, baseUrl, siteConfig} = context; + // Mutate options to resolve sidebar path according to siteDir + options.sidebarPath = resolveSidebarPathOption(siteDir, options.sidebarPath); const versionsMetadata = await readVersionsMetadata({context, options}); diff --git a/packages/docusaurus-plugin-content-docs/src/server-export.ts b/packages/docusaurus-plugin-content-docs/src/server-export.ts index a1942a1e8a..7cde21eb40 100644 --- a/packages/docusaurus-plugin-content-docs/src/server-export.ts +++ b/packages/docusaurus-plugin-content-docs/src/server-export.ts @@ -18,7 +18,5 @@ export { getDefaultVersionBanner, getVersionBadge, getVersionBanner, - getVersionsFilePath, - readVersionsFile, - readVersionNames, } from './versions'; +export {readVersionNames} from './versions/files'; diff --git a/packages/docusaurus-plugin-content-docs/src/sidebars/index.ts b/packages/docusaurus-plugin-content-docs/src/sidebars/index.ts index b28e94ca17..f99b03374a 100644 --- a/packages/docusaurus-plugin-content-docs/src/sidebars/index.ts +++ b/packages/docusaurus-plugin-content-docs/src/sidebars/index.ts @@ -32,7 +32,6 @@ export const DefaultSidebars: SidebarsConfig = { export const DisabledSidebars: SidebarsConfig = {}; // If a path is provided, make it absolute -// use this before loadSidebars() export function resolveSidebarPathOption( siteDir: string, sidebarPathOption: PluginOptions['sidebarPath'], @@ -93,7 +92,6 @@ export async function loadSidebarsFileUnsafe( return importFresh(sidebarFilePath); } -// Note: sidebarFilePath must be absolute, use resolveSidebarPathOption export async function loadSidebars( sidebarFilePath: string | false | undefined, options: SidebarProcessorParams, diff --git a/packages/docusaurus-plugin-content-docs/src/versions.ts b/packages/docusaurus-plugin-content-docs/src/versions.ts deleted file mode 100644 index a7f216f532..0000000000 --- a/packages/docusaurus-plugin-content-docs/src/versions.ts +++ /dev/null @@ -1,593 +0,0 @@ -/** - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -import path from 'path'; -import fs from 'fs-extra'; -import { - VERSIONS_JSON_FILE, - VERSIONED_DOCS_DIR, - VERSIONED_SIDEBARS_DIR, - CURRENT_VERSION_NAME, -} from './constants'; -import type { - PluginOptions, - VersionBanner, - VersionsOptions, - VersionMetadata, -} from '@docusaurus/plugin-content-docs'; - -import type {LoadContext} from '@docusaurus/types'; -import { - getPluginI18nPath, - normalizeUrl, - posixPath, - DEFAULT_PLUGIN_ID, -} from '@docusaurus/utils'; -import _ from 'lodash'; -import {resolveSidebarPathOption} from './sidebars'; - -// retro-compatibility: no prefix for the default plugin id -function addPluginIdPrefix(fileOrDir: string, pluginId: string): string { - return pluginId === DEFAULT_PLUGIN_ID - ? fileOrDir - : `${pluginId}_${fileOrDir}`; -} - -export function getVersionedDocsDirPath( - siteDir: string, - pluginId: string, -): string { - return path.join(siteDir, addPluginIdPrefix(VERSIONED_DOCS_DIR, pluginId)); -} - -export function getVersionedSidebarsDirPath( - siteDir: string, - pluginId: string, -): string { - return path.join( - siteDir, - addPluginIdPrefix(VERSIONED_SIDEBARS_DIR, pluginId), - ); -} - -export function getVersionsFilePath(siteDir: string, pluginId: string): string { - return path.join(siteDir, addPluginIdPrefix(VERSIONS_JSON_FILE, pluginId)); -} - -function ensureValidVersionString(version: unknown): asserts version is string { - if (typeof version !== 'string') { - throw new Error( - `Versions should be strings. Found type "${typeof version}" for version "${version}".`, - ); - } - // Should we forbid versions with special chars like / ? - if (version.trim().length === 0) { - throw new Error(`Invalid version "${version}".`); - } -} - -function ensureValidVersionArray( - versionArray: unknown, -): asserts versionArray is string[] { - if (!Array.isArray(versionArray)) { - throw new Error( - `The versions file should contain an array of version names! Found content: ${JSON.stringify( - versionArray, - )}`, - ); - } - - versionArray.forEach(ensureValidVersionString); -} - -export async function readVersionsFile( - siteDir: string, - pluginId: string, -): Promise { - const versionsFilePath = getVersionsFilePath(siteDir, pluginId); - if (await fs.pathExists(versionsFilePath)) { - const content = JSON.parse(await fs.readFile(versionsFilePath, 'utf8')); - ensureValidVersionArray(content); - return content; - } - return null; -} - -export async function readVersionNames( - siteDir: string, - options: Pick< - PluginOptions, - 'id' | 'disableVersioning' | 'includeCurrentVersion' - >, -): Promise { - const versionFileContent = await readVersionsFile(siteDir, options.id); - - if (!versionFileContent && options.disableVersioning) { - throw new Error( - `Docs: using "disableVersioning: ${options.disableVersioning}" option on a non-versioned site does not make sense.`, - ); - } - - const versions = options.disableVersioning ? [] : versionFileContent ?? []; - - // We add the current version at the beginning, unless: - // - user don't want to; or - // - it's already been explicitly added to versions.json - if ( - options.includeCurrentVersion && - !versions.includes(CURRENT_VERSION_NAME) - ) { - versions.unshift(CURRENT_VERSION_NAME); - } - - if (versions.length === 0) { - throw new Error( - `It is not possible to use docs without any version. Please check the configuration of these options: "includeCurrentVersion: ${options.includeCurrentVersion}", "disableVersioning: ${options.disableVersioning}".`, - ); - } - - return versions; -} - -export function getDocsDirPathLocalized({ - siteDir, - locale, - pluginId, - versionName, -}: { - siteDir: string; - locale: string; - pluginId: string; - versionName: string; -}): string { - return getPluginI18nPath({ - siteDir, - locale, - pluginName: 'docusaurus-plugin-content-docs', - pluginId, - subPaths: [ - versionName === CURRENT_VERSION_NAME - ? CURRENT_VERSION_NAME - : `version-${versionName}`, - ], - }); -} - -function getVersionMetadataPaths({ - versionName, - context, - options, -}: { - versionName: string; - context: Pick; - options: Pick; -}): Pick< - VersionMetadata, - 'contentPath' | 'contentPathLocalized' | 'sidebarFilePath' -> { - const isCurrentVersion = versionName === CURRENT_VERSION_NAME; - - const contentPathLocalized = getDocsDirPathLocalized({ - siteDir: context.siteDir, - locale: context.i18n.currentLocale, - pluginId: options.id, - versionName, - }); - - if (isCurrentVersion) { - return { - contentPath: path.resolve(context.siteDir, options.path), - contentPathLocalized, - sidebarFilePath: resolveSidebarPathOption( - context.siteDir, - options.sidebarPath, - ), - }; - } - - return { - contentPath: path.join( - getVersionedDocsDirPath(context.siteDir, options.id), - `version-${versionName}`, - ), - contentPathLocalized, - sidebarFilePath: path.join( - getVersionedSidebarsDirPath(context.siteDir, options.id), - `version-${versionName}-sidebars.json`, - ), - }; -} - -function getVersionEditUrls({ - contentPath, - contentPathLocalized, - context: {siteDir, i18n}, - options: { - id, - path: currentVersionPath, - editUrl: editUrlOption, - editCurrentVersion, - }, -}: { - contentPath: string; - contentPathLocalized: string; - context: Pick; - options: Pick< - PluginOptions, - 'id' | 'path' | 'editUrl' | 'editCurrentVersion' - >; -}): Pick { - // If the user is using the functional form of editUrl, - // she has total freedom and we can't compute a "version edit url" - if (!editUrlOption || typeof editUrlOption === 'function') { - return {editUrl: undefined, editUrlLocalized: undefined}; - } - - const editDirPath = editCurrentVersion ? currentVersionPath : contentPath; - const editDirPathLocalized = editCurrentVersion - ? getDocsDirPathLocalized({ - siteDir, - locale: i18n.currentLocale, - versionName: CURRENT_VERSION_NAME, - pluginId: id, - }) - : contentPathLocalized; - - const versionPathSegment = posixPath( - path.relative(siteDir, path.resolve(siteDir, editDirPath)), - ); - const versionPathSegmentLocalized = posixPath( - path.relative(siteDir, path.resolve(siteDir, editDirPathLocalized)), - ); - - const editUrl = normalizeUrl([editUrlOption, versionPathSegment]); - - const editUrlLocalized = normalizeUrl([ - editUrlOption, - versionPathSegmentLocalized, - ]); - - return { - editUrl, - editUrlLocalized, - }; -} - -export function getDefaultVersionBanner({ - versionName, - versionNames, - lastVersionName, -}: { - versionName: string; - versionNames: string[]; - lastVersionName: string; -}): VersionBanner | null { - // Current version: good, no banner - if (versionName === lastVersionName) { - return null; - } - // Upcoming versions: unreleased banner - if ( - versionNames.indexOf(versionName) < versionNames.indexOf(lastVersionName) - ) { - return 'unreleased'; - } - // Older versions: display unmaintained banner - return 'unmaintained'; -} - -export function getVersionBanner({ - versionName, - versionNames, - lastVersionName, - options, -}: { - versionName: string; - versionNames: string[]; - lastVersionName: string; - options: Pick; -}): VersionBanner | null { - const versionBannerOption = options.versions[versionName]?.banner; - if (versionBannerOption) { - return versionBannerOption === 'none' ? null : versionBannerOption; - } - return getDefaultVersionBanner({ - versionName, - versionNames, - lastVersionName, - }); -} - -export function getVersionBadge({ - versionName, - versionNames, - options, -}: { - versionName: string; - versionNames: string[]; - options: Pick; -}): boolean { - const versionBadgeOption = options.versions[versionName]?.badge; - // If site is not versioned or only one version is included - // we don't show the version badge by default - // See https://github.com/facebook/docusaurus/issues/3362 - const versionBadgeDefault = versionNames.length !== 1; - return versionBadgeOption ?? versionBadgeDefault; -} - -function getVersionClassName({ - versionName, - options, -}: { - versionName: string; - options: Pick; -}): string { - const versionClassNameOption = options.versions[versionName]?.className; - const versionClassNameDefault = `docs-version-${versionName}`; - return versionClassNameOption ?? versionClassNameDefault; -} - -function createVersionMetadata({ - versionName, - versionNames, - lastVersionName, - context, - options, -}: { - versionName: string; - versionNames: string[]; - lastVersionName: string; - context: Pick; - options: Pick< - PluginOptions, - | 'id' - | 'path' - | 'sidebarPath' - | 'routeBasePath' - | 'tagsBasePath' - | 'versions' - | 'editUrl' - | 'editCurrentVersion' - >; -}): VersionMetadata { - const {sidebarFilePath, contentPath, contentPathLocalized} = - getVersionMetadataPaths({versionName, context, options}); - - const isLast = versionName === lastVersionName; - - // retro-compatible values - const defaultVersionLabel = - versionName === CURRENT_VERSION_NAME ? 'Next' : versionName; - function getDefaultVersionPathPart() { - if (isLast) { - return ''; - } - return versionName === CURRENT_VERSION_NAME ? 'next' : versionName; - } - const defaultVersionPathPart = getDefaultVersionPathPart(); - - const versionOptions = options.versions[versionName] ?? {}; - - const label = versionOptions.label ?? defaultVersionLabel; - const versionPathPart = versionOptions.path ?? defaultVersionPathPart; - - const routePath = normalizeUrl([ - context.baseUrl, - options.routeBasePath, - versionPathPart, - ]); - - const versionEditUrls = getVersionEditUrls({ - contentPath, - contentPathLocalized, - context, - options, - }); - - const routePriority = versionPathPart === '' ? -1 : undefined; - - // the path that will be used to refer the docs tags - // example below will be using /docs/tags - const tagsPath = normalizeUrl([routePath, options.tagsBasePath]); - - return { - versionName, - label, - path: routePath, - tagsPath, - editUrl: versionEditUrls.editUrl, - editUrlLocalized: versionEditUrls.editUrlLocalized, - banner: getVersionBanner({ - versionName, - versionNames, - lastVersionName, - options, - }), - badge: getVersionBadge({versionName, versionNames, options}), - className: getVersionClassName({versionName, options}), - isLast, - routePriority, - sidebarFilePath, - contentPath, - contentPathLocalized, - }; -} - -async function checkVersionMetadataPaths({ - versionMetadata, - context, -}: { - versionMetadata: VersionMetadata; - context: Pick; -}) { - const {versionName, contentPath, sidebarFilePath} = versionMetadata; - const {siteDir} = context; - const isCurrentVersion = versionName === CURRENT_VERSION_NAME; - - if (!(await fs.pathExists(contentPath))) { - throw new Error( - `The docs folder does not exist for version "${versionName}". A docs folder is expected to be found at ${path.relative( - siteDir, - contentPath, - )}.`, - ); - } - - // If the current version defines a path to a sidebar file that does not - // exist, we throw! Note: for versioned sidebars, the file may not exist (as - // we prefer to not create it rather than to create an empty file) - // See https://github.com/facebook/docusaurus/issues/3366 - // See https://github.com/facebook/docusaurus/pull/4775 - if ( - isCurrentVersion && - typeof sidebarFilePath === 'string' && - !(await fs.pathExists(sidebarFilePath)) - ) { - throw new Error(`The path to the sidebar file does not exist at "${path.relative( - siteDir, - sidebarFilePath, - )}". -Please set the docs "sidebarPath" field in your config file to: -- a sidebars path that exists -- false: to disable the sidebar -- undefined: for Docusaurus to generate it automatically`); - } -} - -// TODO for retrocompatibility with existing behavior -// We should make this configurable -// "last version" is not a very good concept nor api surface -function getDefaultLastVersionName(versionNames: string[]) { - if (versionNames.length === 1) { - return versionNames[0]!; - } - return versionNames.filter( - (versionName) => versionName !== CURRENT_VERSION_NAME, - )[0]!; -} - -function checkVersionsOptions( - availableVersionNames: string[], - options: VersionsOptions, -) { - const availableVersionNamesMsg = `Available version names are: ${availableVersionNames.join( - ', ', - )}`; - if ( - options.lastVersion && - !availableVersionNames.includes(options.lastVersion) - ) { - throw new Error( - `Docs option lastVersion: ${options.lastVersion} is invalid. ${availableVersionNamesMsg}`, - ); - } - const unknownVersionConfigNames = _.difference( - Object.keys(options.versions), - availableVersionNames, - ); - if (unknownVersionConfigNames.length > 0) { - throw new Error( - `Invalid docs option "versions": unknown versions (${unknownVersionConfigNames.join( - ',', - )}) found. ${availableVersionNamesMsg}`, - ); - } - - if (options.onlyIncludeVersions) { - if (options.onlyIncludeVersions.length === 0) { - throw new Error( - `Invalid docs option "onlyIncludeVersions": an empty array is not allowed, at least one version is needed.`, - ); - } - const unknownOnlyIncludeVersionNames = _.difference( - options.onlyIncludeVersions, - availableVersionNames, - ); - if (unknownOnlyIncludeVersionNames.length > 0) { - throw new Error( - `Invalid docs option "onlyIncludeVersions": unknown versions (${unknownOnlyIncludeVersionNames.join( - ',', - )}) found. ${availableVersionNamesMsg}`, - ); - } - if ( - options.lastVersion && - !options.onlyIncludeVersions.includes(options.lastVersion) - ) { - throw new Error( - `Invalid docs option "lastVersion": if you use both the "onlyIncludeVersions" and "lastVersion" options, then "lastVersion" must be present in the provided "onlyIncludeVersions" array.`, - ); - } - } -} - -/** - * Filter versions according to provided options. - * Note: we preserve the order in which versions are provided; - * the order of the onlyIncludeVersions array does not matter - */ -export function filterVersions( - versionNamesUnfiltered: string[], - options: Pick, -): string[] { - if (options.onlyIncludeVersions) { - return versionNamesUnfiltered.filter((name) => - options.onlyIncludeVersions!.includes(name), - ); - } - return versionNamesUnfiltered; -} - -export async function readVersionsMetadata({ - context, - options, -}: { - context: Pick; - options: Pick< - PluginOptions, - | 'id' - | 'path' - | 'sidebarPath' - | 'routeBasePath' - | 'tagsBasePath' - | 'includeCurrentVersion' - | 'disableVersioning' - | 'lastVersion' - | 'versions' - | 'onlyIncludeVersions' - | 'editUrl' - | 'editCurrentVersion' - >; -}): Promise { - const versionNamesUnfiltered = await readVersionNames( - context.siteDir, - options, - ); - - checkVersionsOptions(versionNamesUnfiltered, options); - - const versionNames = filterVersions(versionNamesUnfiltered, options); - - const lastVersionName = - options.lastVersion ?? getDefaultLastVersionName(versionNames); - - const versionsMetadata = versionNames.map((versionName) => - createVersionMetadata({ - versionName, - versionNames, - lastVersionName, - context, - options, - }), - ); - await Promise.all( - versionsMetadata.map((versionMetadata) => - checkVersionMetadataPaths({versionMetadata, context}), - ), - ); - return versionsMetadata; -} diff --git a/packages/docusaurus-plugin-content-docs/src/__tests__/versions.test.ts b/packages/docusaurus-plugin-content-docs/src/versions/__tests__/index.test.ts similarity index 91% rename from packages/docusaurus-plugin-content-docs/src/__tests__/versions.test.ts rename to packages/docusaurus-plugin-content-docs/src/versions/__tests__/index.test.ts index 059d5b87c7..c019d8a3ae 100644 --- a/packages/docusaurus-plugin-content-docs/src/__tests__/versions.test.ts +++ b/packages/docusaurus-plugin-content-docs/src/versions/__tests__/index.test.ts @@ -7,13 +7,8 @@ import {jest} from '@jest/globals'; import path from 'path'; -import { - getVersionsFilePath, - getVersionedDocsDirPath, - getVersionedSidebarsDirPath, - readVersionsMetadata, -} from '../versions'; -import {DEFAULT_OPTIONS} from '../options'; +import {readVersionsMetadata} from '../index'; +import {DEFAULT_OPTIONS} from '../../options'; import {DEFAULT_PLUGIN_ID} from '@docusaurus/utils'; import type {I18n} from '@docusaurus/types'; import type { @@ -28,44 +23,11 @@ const DefaultI18N: I18n = { localeConfigs: {}, }; -describe('getVersionsFilePath', () => { - it('works', () => { - expect(getVersionsFilePath('someSiteDir', DEFAULT_PLUGIN_ID)).toBe( - `someSiteDir${path.sep}versions.json`, - ); - expect(getVersionsFilePath('otherSite/dir', 'pluginId')).toBe( - `otherSite${path.sep}dir${path.sep}pluginId_versions.json`, - ); - }); -}); - -describe('getVersionedDocsDirPath', () => { - it('works', () => { - expect(getVersionedDocsDirPath('someSiteDir', DEFAULT_PLUGIN_ID)).toBe( - `someSiteDir${path.sep}versioned_docs`, - ); - expect(getVersionedDocsDirPath('otherSite/dir', 'pluginId')).toBe( - `otherSite${path.sep}dir${path.sep}pluginId_versioned_docs`, - ); - }); -}); - -describe('getVersionedSidebarsDirPath', () => { - it('works', () => { - expect(getVersionedSidebarsDirPath('someSiteDir', DEFAULT_PLUGIN_ID)).toBe( - `someSiteDir${path.sep}versioned_sidebars`, - ); - expect(getVersionedSidebarsDirPath('otherSite/dir', 'pluginId')).toBe( - `otherSite${path.sep}dir${path.sep}pluginId_versioned_sidebars`, - ); - }); -}); - describe('readVersionsMetadata', () => { describe('simple site', () => { async function loadSite() { const simpleSiteDir = path.resolve( - path.join(__dirname, '__fixtures__', 'simple-site'), + path.join(__dirname, '../../__tests__/__fixtures__', 'simple-site'), ); const defaultOptions: PluginOptions = { id: DEFAULT_PLUGIN_ID, @@ -217,7 +179,7 @@ describe('readVersionsMetadata', () => { context: defaultContext, }), ).rejects.toThrowErrorMatchingInlineSnapshot( - `"It is not possible to use docs without any version. Please check the configuration of these options: "includeCurrentVersion: false", "disableVersioning: false"."`, + `"It is not possible to use docs without any version. No version is included because you have requested to not include /docs through "includeCurrentVersion: false", while the versions file is empty/non-existent."`, ); }); }); @@ -225,12 +187,12 @@ describe('readVersionsMetadata', () => { describe('versioned site, pluginId=default', () => { async function loadSite() { const versionedSiteDir = path.resolve( - path.join(__dirname, '__fixtures__', 'versioned-site'), + path.join(__dirname, '../../__tests__/__fixtures__', 'versioned-site'), ); const defaultOptions: PluginOptions = { id: DEFAULT_PLUGIN_ID, ...DEFAULT_OPTIONS, - sidebarPath: 'sidebars.json', + sidebarPath: path.join(versionedSiteDir, 'sidebars.json'), }; const defaultContext = { siteDir: versionedSiteDir, @@ -562,7 +524,7 @@ describe('readVersionsMetadata', () => { context: defaultContext, }), ).rejects.toThrowErrorMatchingInlineSnapshot( - `"It is not possible to use docs without any version. Please check the configuration of these options: "includeCurrentVersion: false", "disableVersioning: true"."`, + `"It is not possible to use docs without any version. No version is included because you have requested to not include /docs through "includeCurrentVersion: false", while versioning is disabled with "disableVersioning: true"."`, ); }); @@ -651,7 +613,9 @@ describe('readVersionsMetadata', () => { options: defaultOptions, context: defaultContext, }), - ).rejects.toThrowErrorMatchingInlineSnapshot(`"Invalid version " "."`); + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Invalid version name " ": version name must contain at least one non-whitespace character."`, + ); jsonMock.mockRestore(); }); }); @@ -659,14 +623,14 @@ describe('readVersionsMetadata', () => { describe('versioned site, pluginId=community', () => { async function loadSite() { const versionedSiteDir = path.resolve( - path.join(__dirname, '__fixtures__', 'versioned-site'), + path.join(__dirname, '../../__tests__/__fixtures__', 'versioned-site'), ); const defaultOptions: PluginOptions = { ...DEFAULT_OPTIONS, id: 'community', path: 'community', routeBasePath: 'communityBasePath', - sidebarPath: 'sidebars.json', + sidebarPath: path.join(versionedSiteDir, 'sidebars.json'), }; const defaultContext = { siteDir: versionedSiteDir, @@ -779,7 +743,7 @@ describe('readVersionsMetadata', () => { context: defaultContext, }), ).rejects.toThrowErrorMatchingInlineSnapshot( - `"It is not possible to use docs without any version. Please check the configuration of these options: "includeCurrentVersion: false", "disableVersioning: true"."`, + `"It is not possible to use docs without any version. No version is included because you have requested to not include /community through "includeCurrentVersion: false", while versioning is disabled with "disableVersioning: true"."`, ); }); }); diff --git a/packages/docusaurus-plugin-content-docs/src/versions/files.ts b/packages/docusaurus-plugin-content-docs/src/versions/files.ts new file mode 100644 index 0000000000..69660f34a9 --- /dev/null +++ b/packages/docusaurus-plugin-content-docs/src/versions/files.ts @@ -0,0 +1,220 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import path from 'path'; +import fs from 'fs-extra'; +import { + VERSIONS_JSON_FILE, + VERSIONED_DOCS_DIR, + VERSIONED_SIDEBARS_DIR, + CURRENT_VERSION_NAME, +} from '../constants'; +import {validateVersionNames} from './validation'; +import {getPluginI18nPath, DEFAULT_PLUGIN_ID} from '@docusaurus/utils'; +import type { + PluginOptions, + VersionMetadata, +} from '@docusaurus/plugin-content-docs'; +import type {VersionContext} from './index'; + +/** Add a prefix like `community_version-1.0.0`. No-op for default instance. */ +function addPluginIdPrefix(fileOrDir: string, pluginId: string): string { + return pluginId === DEFAULT_PLUGIN_ID + ? fileOrDir + : `${pluginId}_${fileOrDir}`; +} + +/** `[siteDir]/community_versioned_docs/version-1.0.0` */ +export function getVersionDocsDirPath( + siteDir: string, + pluginId: string, + versionName: string, +): string { + return path.join( + siteDir, + addPluginIdPrefix(VERSIONED_DOCS_DIR, pluginId), + `version-${versionName}`, + ); +} + +/** `[siteDir]/community_versioned_sidebars/version-1.0.0-sidebars.json` */ +export function getVersionSidebarsPath( + siteDir: string, + pluginId: string, + versionName: string, +): string { + return path.join( + siteDir, + addPluginIdPrefix(VERSIONED_SIDEBARS_DIR, pluginId), + `version-${versionName}-sidebars.json`, + ); +} + +export function getDocsDirPathLocalized({ + siteDir, + locale, + pluginId, + versionName, +}: { + siteDir: string; + locale: string; + pluginId: string; + versionName: string; +}): string { + return getPluginI18nPath({ + siteDir, + locale, + pluginName: 'docusaurus-plugin-content-docs', + pluginId, + subPaths: [ + versionName === CURRENT_VERSION_NAME + ? CURRENT_VERSION_NAME + : `version-${versionName}`, + ], + }); +} + +/** `community` => `[siteDir]/community_versions.json` */ +export function getVersionsFilePath(siteDir: string, pluginId: string): string { + return path.join(siteDir, addPluginIdPrefix(VERSIONS_JSON_FILE, pluginId)); +} + +/** + * Reads the plugin's respective `versions.json` file, and returns its content. + * + * @throws Throws if validation fails, i.e. `versions.json` doesn't contain an + * array of valid version names. + */ +async function readVersionsFile( + siteDir: string, + pluginId: string, +): Promise { + const versionsFilePath = getVersionsFilePath(siteDir, pluginId); + if (await fs.pathExists(versionsFilePath)) { + const content = await fs.readJSON(versionsFilePath); + validateVersionNames(content); + return content; + } + return null; +} + +/** + * Reads the `versions.json` file, and returns an ordered list of version names. + * + * - If `disableVersioning` is turned on, it will return `["current"]` (requires + * `includeCurrentVersion` to be true); + * - If `includeCurrentVersion` is turned on, "current" will be inserted at the + * beginning, if not already there. + * + * You need to use {@link filterVersions} after this. + * + * @throws Throws an error if `disableVersioning: true` but `versions.json` + * doesn't exist (i.e. site is not versioned) + * @throws Throws an error if versions list is empty (empty `versions.json` or + * `disableVersioning` is true, and not including current version) + */ +export async function readVersionNames( + siteDir: string, + options: PluginOptions, +): Promise { + const versionFileContent = await readVersionsFile(siteDir, options.id); + + if (!versionFileContent && options.disableVersioning) { + throw new Error( + `Docs: using "disableVersioning: true" option on a non-versioned site does not make sense.`, + ); + } + + const versions = options.disableVersioning ? [] : versionFileContent ?? []; + + // We add the current version at the beginning, unless: + // - user don't want to; or + // - it's already been explicitly added to versions.json + if ( + options.includeCurrentVersion && + !versions.includes(CURRENT_VERSION_NAME) + ) { + versions.unshift(CURRENT_VERSION_NAME); + } + + if (versions.length === 0) { + throw new Error( + `It is not possible to use docs without any version. No version is included because you have requested to not include ${path.resolve( + options.path, + )} through "includeCurrentVersion: false", while ${ + options.disableVersioning + ? 'versioning is disabled with "disableVersioning: true"' + : `the versions file is empty/non-existent` + }.`, + ); + } + + return versions; +} + +/** + * Gets the path-related version metadata. + * + * @throws Throws if the resolved docs folder or sidebars file doesn't exist. + * Does not throw if a versioned sidebar is missing (since we don't create empty + * files). + */ +export async function getVersionMetadataPaths({ + versionName, + context, + options, +}: VersionContext): Promise< + Pick< + VersionMetadata, + 'contentPath' | 'contentPathLocalized' | 'sidebarFilePath' + > +> { + const isCurrent = versionName === CURRENT_VERSION_NAME; + const contentPathLocalized = getDocsDirPathLocalized({ + siteDir: context.siteDir, + locale: context.i18n.currentLocale, + pluginId: options.id, + versionName, + }); + const contentPath = isCurrent + ? path.resolve(context.siteDir, options.path) + : getVersionDocsDirPath(context.siteDir, options.id, versionName); + const sidebarFilePath = isCurrent + ? options.sidebarPath + : getVersionSidebarsPath(context.siteDir, options.id, versionName); + + if (!(await fs.pathExists(contentPath))) { + throw new Error( + `The docs folder does not exist for version "${versionName}". A docs folder is expected to be found at ${path.relative( + context.siteDir, + contentPath, + )}.`, + ); + } + + // If the current version defines a path to a sidebar file that does not + // exist, we throw! Note: for versioned sidebars, the file may not exist (as + // we prefer to not create it rather than to create an empty file) + // See https://github.com/facebook/docusaurus/issues/3366 + // See https://github.com/facebook/docusaurus/pull/4775 + if ( + versionName === CURRENT_VERSION_NAME && + typeof sidebarFilePath === 'string' && + !(await fs.pathExists(sidebarFilePath)) + ) { + throw new Error(`The path to the sidebar file does not exist at "${path.relative( + context.siteDir, + sidebarFilePath, + )}". +Please set the docs "sidebarPath" field in your config file to: +- a sidebars path that exists +- false: to disable the sidebar +- undefined: for Docusaurus to generate it automatically`); + } + + return {contentPath, contentPathLocalized, sidebarFilePath}; +} diff --git a/packages/docusaurus-plugin-content-docs/src/versions/index.ts b/packages/docusaurus-plugin-content-docs/src/versions/index.ts new file mode 100644 index 0000000000..cd85ddf213 --- /dev/null +++ b/packages/docusaurus-plugin-content-docs/src/versions/index.ts @@ -0,0 +1,247 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import path from 'path'; +import {CURRENT_VERSION_NAME} from '../constants'; +import {normalizeUrl, posixPath} from '@docusaurus/utils'; +import {validateVersionsOptions} from './validation'; +import { + getDocsDirPathLocalized, + getVersionMetadataPaths, + readVersionNames, +} from './files'; +import type { + PluginOptions, + VersionBanner, + VersionMetadata, +} from '@docusaurus/plugin-content-docs'; +import type {LoadContext} from '@docusaurus/types'; + +export type VersionContext = { + /** The version name to get banner of. */ + versionName: string; + /** All versions, ordered from newest to oldest. */ + versionNames: string[]; + lastVersionName: string; + context: LoadContext; + options: PluginOptions; +}; + +function getVersionEditUrls({ + contentPath, + contentPathLocalized, + context, + options, +}: Pick & { + context: LoadContext; + options: PluginOptions; +}): Pick { + // If the user is using the functional form of editUrl, + // she has total freedom and we can't compute a "version edit url" + if (!options.editUrl || typeof options.editUrl === 'function') { + return {editUrl: undefined, editUrlLocalized: undefined}; + } + + const editDirPath = options.editCurrentVersion ? options.path : contentPath; + const editDirPathLocalized = options.editCurrentVersion + ? getDocsDirPathLocalized({ + siteDir: context.siteDir, + locale: context.i18n.currentLocale, + versionName: CURRENT_VERSION_NAME, + pluginId: options.id, + }) + : contentPathLocalized; + + const versionPathSegment = posixPath( + path.relative(context.siteDir, path.resolve(context.siteDir, editDirPath)), + ); + const versionPathSegmentLocalized = posixPath( + path.relative( + context.siteDir, + path.resolve(context.siteDir, editDirPathLocalized), + ), + ); + + const editUrl = normalizeUrl([options.editUrl, versionPathSegment]); + + const editUrlLocalized = normalizeUrl([ + options.editUrl, + versionPathSegmentLocalized, + ]); + + return {editUrl, editUrlLocalized}; +} + +/** + * The default version banner depends on the version's relative position to the + * latest version. More recent ones are "unreleased", and older ones are + * "unmaintained". + */ +export function getDefaultVersionBanner({ + versionName, + versionNames, + lastVersionName, +}: VersionContext): VersionBanner | null { + // Current version: good, no banner + if (versionName === lastVersionName) { + return null; + } + // Upcoming versions: unreleased banner + if ( + versionNames.indexOf(versionName) < versionNames.indexOf(lastVersionName) + ) { + return 'unreleased'; + } + // Older versions: display unmaintained banner + return 'unmaintained'; +} + +export function getVersionBanner( + context: VersionContext, +): VersionMetadata['banner'] { + const {versionName, options} = context; + const versionBannerOption = options.versions[versionName]?.banner; + if (versionBannerOption) { + return versionBannerOption === 'none' ? null : versionBannerOption; + } + return getDefaultVersionBanner(context); +} + +export function getVersionBadge({ + versionName, + versionNames, + options, +}: VersionContext): VersionMetadata['badge'] { + // If site is not versioned or only one version is included + // we don't show the version badge by default + // See https://github.com/facebook/docusaurus/issues/3362 + const defaultVersionBadge = versionNames.length !== 1; + return options.versions[versionName]?.badge ?? defaultVersionBadge; +} + +function getVersionClassName({ + versionName, + options, +}: VersionContext): VersionMetadata['className'] { + const defaultVersionClassName = `docs-version-${versionName}`; + return options.versions[versionName]?.className ?? defaultVersionClassName; +} + +function getVersionLabel({ + versionName, + options, +}: VersionContext): VersionMetadata['label'] { + const defaultVersionLabel = + versionName === CURRENT_VERSION_NAME ? 'Next' : versionName; + return options.versions[versionName]?.label ?? defaultVersionLabel; +} + +function getVersionPathPart({ + versionName, + options, + lastVersionName, +}: VersionContext): string { + function getDefaultVersionPathPart() { + if (versionName === lastVersionName) { + return ''; + } + return versionName === CURRENT_VERSION_NAME ? 'next' : versionName; + } + return options.versions[versionName]?.path ?? getDefaultVersionPathPart(); +} + +async function createVersionMetadata( + context: VersionContext, +): Promise { + const {versionName, lastVersionName, options, context: loadContext} = context; + const {sidebarFilePath, contentPath, contentPathLocalized} = + await getVersionMetadataPaths(context); + const versionPathPart = getVersionPathPart(context); + + const routePath = normalizeUrl([ + loadContext.baseUrl, + options.routeBasePath, + versionPathPart, + ]); + + const versionEditUrls = getVersionEditUrls({ + contentPath, + contentPathLocalized, + context: loadContext, + options, + }); + + return { + versionName, + label: getVersionLabel(context), + banner: getVersionBanner(context), + badge: getVersionBadge(context), + className: getVersionClassName(context), + path: routePath, + tagsPath: normalizeUrl([routePath, options.tagsBasePath]), + ...versionEditUrls, + isLast: versionName === lastVersionName, + routePriority: versionPathPart === '' ? -1 : undefined, + sidebarFilePath, + contentPath, + contentPathLocalized, + }; +} + +/** + * Filter versions according to provided options (i.e. `onlyIncludeVersions`). + * + * Note: we preserve the order in which versions are provided; the order of the + * `onlyIncludeVersions` array does not matter + */ +export function filterVersions( + versionNamesUnfiltered: string[], + options: PluginOptions, +): string[] { + if (options.onlyIncludeVersions) { + return versionNamesUnfiltered.filter((name) => + options.onlyIncludeVersions!.includes(name), + ); + } + return versionNamesUnfiltered; +} + +function getLastVersionName({ + versionNames, + options, +}: Pick) { + return ( + options.lastVersion ?? + versionNames.find((name) => name !== CURRENT_VERSION_NAME) ?? + CURRENT_VERSION_NAME + ); +} + +export async function readVersionsMetadata({ + context, + options, +}: { + context: LoadContext; + options: PluginOptions; +}): Promise { + const allVersionNames = await readVersionNames(context.siteDir, options); + validateVersionsOptions(allVersionNames, options); + const versionNames = filterVersions(allVersionNames, options); + const lastVersionName = getLastVersionName({versionNames, options}); + const versionsMetadata = await Promise.all( + versionNames.map((versionName) => + createVersionMetadata({ + versionName, + versionNames, + lastVersionName, + context, + options, + }), + ), + ); + return versionsMetadata; +} diff --git a/packages/docusaurus-plugin-content-docs/src/versions/validation.ts b/packages/docusaurus-plugin-content-docs/src/versions/validation.ts new file mode 100644 index 0000000000..422d58f713 --- /dev/null +++ b/packages/docusaurus-plugin-content-docs/src/versions/validation.ts @@ -0,0 +1,113 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import _ from 'lodash'; +import type {VersionsOptions} from '@docusaurus/plugin-content-docs'; + +export function validateVersionName(name: unknown): asserts name is string { + if (typeof name !== 'string') { + throw new Error( + `Versions should be strings. Found type "${typeof name}" for version "${name}".`, + ); + } + if (!name.trim()) { + throw new Error( + `Invalid version name "${name}": version name must contain at least one non-whitespace character.`, + ); + } + const errors: [RegExp, string][] = [ + [/[/\\]/, 'should not include slash (/) or backslash (\\)'], + [/.{33,}/, 'cannot be longer than 32 characters'], + // eslint-disable-next-line no-control-regex + [/[<>:"|?*\x00-\x1F]/, 'should be a valid file path'], + [/^\.\.?$/, 'should not be "." or ".."'], + ]; + + errors.forEach(([pattern, message]) => { + if (pattern.test(name)) { + throw new Error( + `Invalid version name "${name}": version name ${message}.`, + ); + } + }); +} + +export function validateVersionNames( + names: unknown, +): asserts names is string[] { + if (!Array.isArray(names)) { + throw new Error( + `The versions file should contain an array of version names! Found content: ${JSON.stringify( + names, + )}`, + ); + } + + names.forEach(validateVersionName); +} + +/** + * @throws Throws for one of the following invalid options: + * - `lastVersion` is non-existent + * - `versions` includes unknown keys + * - `onlyIncludeVersions` is empty, contains unknown names, or doesn't include + * `latestVersion` (if provided) + */ +export function validateVersionsOptions( + availableVersionNames: string[], + options: VersionsOptions, +): void { + const availableVersionNamesMsg = `Available version names are: ${availableVersionNames.join( + ', ', + )}`; + if ( + options.lastVersion && + !availableVersionNames.includes(options.lastVersion) + ) { + throw new Error( + `Docs option lastVersion: ${options.lastVersion} is invalid. ${availableVersionNamesMsg}`, + ); + } + const unknownVersionConfigNames = _.difference( + Object.keys(options.versions), + availableVersionNames, + ); + if (unknownVersionConfigNames.length > 0) { + throw new Error( + `Invalid docs option "versions": unknown versions (${unknownVersionConfigNames.join( + ',', + )}) found. ${availableVersionNamesMsg}`, + ); + } + + if (options.onlyIncludeVersions) { + if (options.onlyIncludeVersions.length === 0) { + throw new Error( + `Invalid docs option "onlyIncludeVersions": an empty array is not allowed, at least one version is needed.`, + ); + } + const unknownOnlyIncludeVersionNames = _.difference( + options.onlyIncludeVersions, + availableVersionNames, + ); + if (unknownOnlyIncludeVersionNames.length > 0) { + throw new Error( + `Invalid docs option "onlyIncludeVersions": unknown versions (${unknownOnlyIncludeVersionNames.join( + ',', + )}) found. ${availableVersionNamesMsg}`, + ); + } + if ( + options.lastVersion && + !options.onlyIncludeVersions.includes(options.lastVersion) + ) { + throw new Error( + `Invalid docs option "lastVersion": if you use both the "onlyIncludeVersions" and "lastVersion" options, then "lastVersion" must be present in the provided "onlyIncludeVersions" array.`, + ); + } + } +}