/** * 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 { PluginOptions, VersionMetadata, VersionOptions, VersionsOptions, } from './types'; import { VERSIONS_JSON_FILE, VERSIONED_DOCS_DIR, VERSIONED_SIDEBARS_DIR, CURRENT_VERSION_NAME, } from './constants'; import {DEFAULT_PLUGIN_ID} from '@docusaurus/core/lib/constants'; import {LoadContext} from '@docusaurus/types'; import {getPluginI18nPath, normalizeUrl, posixPath} from '@docusaurus/utils'; import {difference} from 'lodash'; import chalk from 'chalk'; // retro-compatibility: no prefix for the default plugin id function addPluginIdPrefix(fileOrDir: string, pluginId: string): string { if (pluginId === DEFAULT_PLUGIN_ID) { return fileOrDir; } else { return `${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 (!(versionArray instanceof Array)) { throw new Error( `The versions file should contain an array of versions! Found content=${JSON.stringify( versionArray, )}`, ); } versionArray.forEach(ensureValidVersionString); } // TODO not easy to make async due to many deps function readVersionsFile(siteDir: string, pluginId: string): string[] | null { const versionsFilePath = getVersionsFilePath(siteDir, pluginId); if (fs.existsSync(versionsFilePath)) { const content = JSON.parse(fs.readFileSync(versionsFilePath, 'utf8')); ensureValidVersionArray(content); return content; } else { return null; } } // TODO not easy to make async due to many deps function readVersionNames( siteDir: string, options: Pick< PluginOptions, 'id' | 'disableVersioning' | 'includeCurrentVersion' >, ): string[] { const versionFileContent = 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 // - it's 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; } function getDocsDirPathLocalized({ siteDir, locale, pluginId, versionName, }: { siteDir: string; locale: string; pluginId: string; versionName: 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 contentPath = isCurrentVersion ? path.resolve(context.siteDir, options.path) : path.join( getVersionedDocsDirPath(context.siteDir, options.id), `version-${versionName}`, ); const contentPathLocalized = getDocsDirPathLocalized({ siteDir: context.siteDir, locale: context.i18n.currentLocale, pluginId: options.id, versionName, }); const sidebarFilePath = isCurrentVersion ? path.resolve(context.siteDir, options.sidebarPath) : path.join( getVersionedSidebarsDirPath(context.siteDir, options.id), `version-${versionName}-sidebars.json`, ); return {contentPath, contentPathLocalized, sidebarFilePath}; } function getVersionEditUrls({ contentPath, contentPathLocalized, context: {siteDir, i18n}, options: {id, path: currentVersionPath, editUrl, editCurrentVersion}, }: { contentPath: string; contentPathLocalized: string; context: Pick; options: Pick< PluginOptions, 'id' | 'path' | 'editUrl' | 'editCurrentVersion' >; }): {versionEditUrl: string; versionEditUrlLocalized: string} | undefined { if (!editUrl) { return undefined; } // if the user is using the functional form of editUrl, // he has total freedom and we can't compute a "version edit url" if (typeof editUrl === 'function') { return 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 versionEditUrl = normalizeUrl([editUrl, versionPathSegment]); const versionEditUrlLocalized = normalizeUrl([ editUrl, versionPathSegmentLocalized, ]); return { versionEditUrl, versionEditUrlLocalized, }; } function createVersionMetadata({ versionName, isLast, context, options, }: { versionName: string; isLast: boolean; context: Pick; options: Pick< PluginOptions, | 'id' | 'path' | 'sidebarPath' | 'routeBasePath' | 'versions' | 'editUrl' | 'editCurrentVersion' >; }): VersionMetadata { const { sidebarFilePath, contentPath, contentPathLocalized, } = getVersionMetadataPaths({ versionName, context, options, }); // retro-compatible values const defaultVersionLabel = versionName === CURRENT_VERSION_NAME ? 'Next' : versionName; const defaultVersionPathPart = isLast ? '' : versionName === CURRENT_VERSION_NAME ? 'next' : versionName; const versionOptions: VersionOptions = options.versions[versionName] ?? {}; const versionLabel = versionOptions.label ?? defaultVersionLabel; const versionPathPart = versionOptions.path ?? defaultVersionPathPart; const versionPath = normalizeUrl([ context.baseUrl, options.routeBasePath, versionPathPart, ]); const versionEditUrls = getVersionEditUrls({ contentPath, contentPathLocalized, context, options, }); // Because /docs/:route` should always be after `/docs/versionName/:route`. const routePriority = versionPathPart === '' ? -1 : undefined; return { versionName, versionLabel, versionPath, versionEditUrl: versionEditUrls?.versionEditUrl, versionEditUrlLocalized: versionEditUrls?.versionEditUrlLocalized, isLast, routePriority, sidebarFilePath, contentPath, contentPathLocalized, }; } function checkVersionMetadataPaths({ versionMetadata, context, }: { versionMetadata: VersionMetadata; context: Pick; }) { const {versionName, contentPath, sidebarFilePath} = versionMetadata; const {siteDir} = context; if (!fs.existsSync(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, )}`, ); } // See https://github.com/facebook/docusaurus/issues/3366 if (!fs.existsSync(sidebarFilePath)) { console.log( chalk.yellow( `The sidebar file of docs version [${versionName}] does not exist. It is optional, but should rather be provided at ${sidebarFilePath}`, ), ); } } // 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]; } else { 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( `Bad docs options.versions: unknown versions found: ${unknownVersionConfigNames.join( ',', )}. ${availableVersionNamesMsg}`, ); } if (options.onlyIncludeVersions) { if (options.onlyIncludeVersions.length === 0) { throw new Error( `Bad docs options.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( `Bad docs options.onlyIncludeVersions: unknown versions found: ${unknownOnlyIncludeVersionNames.join( ',', )}. ${availableVersionNamesMsg}`, ); } if ( options.lastVersion && !options.onlyIncludeVersions.includes(options.lastVersion) ) { throw new Error( `Bad docs options.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 function filterVersions( versionNamesUnfiltered: string[], options: Pick, ) { if (options.onlyIncludeVersions) { return versionNamesUnfiltered.filter((name) => (options.onlyIncludeVersions || []).includes(name), ); } else { return versionNamesUnfiltered; } } // TODO make this async (requires plugin init to be async) export function readVersionsMetadata({ context, options, }: { context: Pick; options: Pick< PluginOptions, | 'id' | 'path' | 'sidebarPath' | 'routeBasePath' | 'includeCurrentVersion' | 'disableVersioning' | 'lastVersion' | 'versions' | 'onlyIncludeVersions' | 'editUrl' | 'editCurrentVersion' >; }): VersionMetadata[] { const versionNamesUnfiltered = 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, isLast: versionName === lastVersionName, context, options, }), ); versionsMetadata.forEach((versionMetadata) => checkVersionMetadataPaths({versionMetadata, context}), ); return versionsMetadata; } // order matter! // Read in priority the localized path, then the unlocalized one // We want the localized doc to "override" the unlocalized one export function getDocsDirPaths( versionMetadata: Pick< VersionMetadata, 'contentPath' | 'contentPathLocalized' >, ): [string, string] { return [versionMetadata.contentPathLocalized, versionMetadata.contentPath]; }