refactor(content-docs): split version handling into several files (#7140)

* refactor(content-docs): split version handling into several files

* fix test

* increase timeout
This commit is contained in:
Joshua Chen 2022-04-09 17:08:57 +08:00 committed by GitHub
parent 7d44961d8b
commit 96fbcb3f51
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 618 additions and 698 deletions

View file

@ -33,6 +33,8 @@ import type {Optional} from 'utility-types';
import {createSlugger, posixPath, DEFAULT_PLUGIN_ID} from '@docusaurus/utils'; import {createSlugger, posixPath, DEFAULT_PLUGIN_ID} from '@docusaurus/utils';
import {createSidebarsUtils} from '../sidebars/utils'; import {createSidebarsUtils} from '../sidebars/utils';
jest.setTimeout(15000);
const fixtureDir = path.join(__dirname, '__fixtures__'); const fixtureDir = path.join(__dirname, '__fixtures__');
const createFakeDocFile = ({ const createFakeDocFile = ({

View file

@ -7,14 +7,15 @@
import { import {
getVersionsFilePath, getVersionsFilePath,
getVersionedDocsDirPath, getVersionDocsDirPath,
getVersionedSidebarsDirPath, getVersionSidebarsPath,
getDocsDirPathLocalized, getDocsDirPathLocalized,
} from './versions'; } from './versions/files';
import {validateVersionName} from './versions/validation';
import fs from 'fs-extra'; import fs from 'fs-extra';
import path from 'path'; import path from 'path';
import type {PluginOptions} from '@docusaurus/plugin-content-docs'; import type {PluginOptions} from '@docusaurus/plugin-content-docs';
import {loadSidebarsFileUnsafe, resolveSidebarPathOption} from './sidebars'; import {loadSidebarsFileUnsafe} from './sidebars';
import {CURRENT_VERSION_NAME} from './constants'; import {CURRENT_VERSION_NAME} from './constants';
import {DEFAULT_PLUGIN_ID} from '@docusaurus/utils'; import {DEFAULT_PLUGIN_ID} from '@docusaurus/utils';
import logger from '@docusaurus/logger'; import logger from '@docusaurus/logger';
@ -42,13 +43,8 @@ async function createVersionedSidebarFile({
const shouldCreateVersionedSidebarFile = Object.keys(sidebars).length > 0; const shouldCreateVersionedSidebarFile = Object.keys(sidebars).length > 0;
if (shouldCreateVersionedSidebarFile) { if (shouldCreateVersionedSidebarFile) {
const versionedSidebarsDir = getVersionedSidebarsDirPath(siteDir, pluginId);
const newSidebarFile = path.join(
versionedSidebarsDir,
`version-${version}-sidebars.json`,
);
await fs.outputFile( await fs.outputFile(
newSidebarFile, getVersionSidebarsPath(siteDir, pluginId, version),
`${JSON.stringify(sidebars, null, 2)}\n`, `${JSON.stringify(sidebars, null, 2)}\n`,
'utf8', 'utf8',
); );
@ -57,7 +53,7 @@ async function createVersionedSidebarFile({
// Tests depend on non-default export for mocking. // Tests depend on non-default export for mocking.
export async function cliDocsVersionCommand( export async function cliDocsVersionCommand(
version: string | null | undefined, version: string,
{id: pluginId, path: docsPath, sidebarPath}: PluginOptions, {id: pluginId, path: docsPath, sidebarPath}: PluginOptions,
{siteDir, i18n}: LoadContext, {siteDir, i18n}: LoadContext,
): Promise<void> { ): Promise<void> {
@ -66,44 +62,18 @@ export async function cliDocsVersionCommand(
const pluginIdLogPrefix = const pluginIdLogPrefix =
pluginId === DEFAULT_PLUGIN_ID ? '[docs]' : `[${pluginId}]`; pluginId === DEFAULT_PLUGIN_ID ? '[docs]' : `[${pluginId}]`;
if (!version) { try {
throw new Error( validateVersionName(version);
`${pluginIdLogPrefix}: no version tag specified! Pass the version you wish to create as an argument, for example: 1.0.0.`, } catch (e) {
); logger.info`${pluginIdLogPrefix}: Invalid version name provided. Try something like: 1.0.0`;
} throw e;
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.`,
);
} }
// Load existing versions. // Load existing versions.
let versions = []; let versions = [];
const versionsJSONFile = getVersionsFilePath(siteDir, pluginId); const versionsJSONFile = getVersionsFilePath(siteDir, pluginId);
if (await fs.pathExists(versionsJSONFile)) { if (await fs.pathExists(versionsJSONFile)) {
versions = JSON.parse(await fs.readFile(versionsJSONFile, 'utf8')); versions = await fs.readJSON(versionsJSONFile);
} }
// Check if version already exists. // Check if version already exists.
@ -146,10 +116,7 @@ export async function cliDocsVersionCommand(
const newVersionDir = const newVersionDir =
locale === i18n.defaultLocale locale === i18n.defaultLocale
? path.join( ? getVersionDocsDirPath(siteDir, pluginId, version)
getVersionedDocsDirPath(siteDir, pluginId),
`version-${version}`,
)
: getDocsDirPathLocalized({ : getDocsDirPathLocalized({
siteDir, siteDir,
locale, locale,
@ -164,7 +131,7 @@ export async function cliDocsVersionCommand(
siteDir, siteDir,
pluginId, pluginId,
version, version,
sidebarPath: resolveSidebarPathOption(siteDir, sidebarPath), sidebarPath,
}); });
// Update versions.json file. // Update versions.json file.

View file

@ -5,9 +5,11 @@
* LICENSE file in the root directory of this source tree. * 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'; export const CURRENT_VERSION_NAME = 'current';
/** All doc versions are stored here by version names */
export const VERSIONED_DOCS_DIR = 'versioned_docs'; export const VERSIONED_DOCS_DIR = 'versioned_docs';
/** All doc versioned sidebars are stored here by version names */
export const VERSIONED_SIDEBARS_DIR = 'versioned_sidebars'; 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'; export const VERSIONS_JSON_FILE = 'versions.json';

View file

@ -20,7 +20,7 @@ import {
DEFAULT_PLUGIN_ID, DEFAULT_PLUGIN_ID,
} from '@docusaurus/utils'; } from '@docusaurus/utils';
import type {LoadContext, Plugin} from '@docusaurus/types'; import type {LoadContext, Plugin} from '@docusaurus/types';
import {loadSidebars} from './sidebars'; import {loadSidebars, resolveSidebarPathOption} from './sidebars';
import {CategoryMetadataFilenamePattern} from './sidebars/generator'; import {CategoryMetadataFilenamePattern} from './sidebars/generator';
import { import {
readVersionDocs, readVersionDocs,
@ -64,6 +64,8 @@ export default async function pluginContentDocs(
options: PluginOptions, options: PluginOptions,
): Promise<Plugin<LoadedContent>> { ): Promise<Plugin<LoadedContent>> {
const {siteDir, generatedFilesDir, baseUrl, siteConfig} = context; 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}); const versionsMetadata = await readVersionsMetadata({context, options});

View file

@ -18,7 +18,5 @@ export {
getDefaultVersionBanner, getDefaultVersionBanner,
getVersionBadge, getVersionBadge,
getVersionBanner, getVersionBanner,
getVersionsFilePath,
readVersionsFile,
readVersionNames,
} from './versions'; } from './versions';
export {readVersionNames} from './versions/files';

View file

@ -32,7 +32,6 @@ export const DefaultSidebars: SidebarsConfig = {
export const DisabledSidebars: SidebarsConfig = {}; export const DisabledSidebars: SidebarsConfig = {};
// If a path is provided, make it absolute // If a path is provided, make it absolute
// use this before loadSidebars()
export function resolveSidebarPathOption( export function resolveSidebarPathOption(
siteDir: string, siteDir: string,
sidebarPathOption: PluginOptions['sidebarPath'], sidebarPathOption: PluginOptions['sidebarPath'],
@ -93,7 +92,6 @@ export async function loadSidebarsFileUnsafe(
return importFresh(sidebarFilePath); return importFresh(sidebarFilePath);
} }
// Note: sidebarFilePath must be absolute, use resolveSidebarPathOption
export async function loadSidebars( export async function loadSidebars(
sidebarFilePath: string | false | undefined, sidebarFilePath: string | false | undefined,
options: SidebarProcessorParams, options: SidebarProcessorParams,

View file

@ -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<string[] | null> {
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<string[]> {
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<LoadContext, 'siteDir' | 'i18n'>;
options: Pick<PluginOptions, 'id' | 'path' | 'sidebarPath'>;
}): 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<LoadContext, 'siteDir' | 'i18n'>;
options: Pick<
PluginOptions,
'id' | 'path' | 'editUrl' | 'editCurrentVersion'
>;
}): Pick<VersionMetadata, 'editUrl' | 'editUrlLocalized'> {
// 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<PluginOptions, 'versions'>;
}): 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<PluginOptions, 'versions'>;
}): 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<PluginOptions, 'versions'>;
}): 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<LoadContext, 'siteDir' | 'baseUrl' | 'i18n'>;
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<LoadContext, 'siteDir'>;
}) {
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<PluginOptions, 'onlyIncludeVersions'>,
): string[] {
if (options.onlyIncludeVersions) {
return versionNamesUnfiltered.filter((name) =>
options.onlyIncludeVersions!.includes(name),
);
}
return versionNamesUnfiltered;
}
export async function readVersionsMetadata({
context,
options,
}: {
context: Pick<LoadContext, 'siteDir' | 'baseUrl' | 'i18n'>;
options: Pick<
PluginOptions,
| 'id'
| 'path'
| 'sidebarPath'
| 'routeBasePath'
| 'tagsBasePath'
| 'includeCurrentVersion'
| 'disableVersioning'
| 'lastVersion'
| 'versions'
| 'onlyIncludeVersions'
| 'editUrl'
| 'editCurrentVersion'
>;
}): Promise<VersionMetadata[]> {
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;
}

View file

@ -7,13 +7,8 @@
import {jest} from '@jest/globals'; import {jest} from '@jest/globals';
import path from 'path'; import path from 'path';
import { import {readVersionsMetadata} from '../index';
getVersionsFilePath, import {DEFAULT_OPTIONS} from '../../options';
getVersionedDocsDirPath,
getVersionedSidebarsDirPath,
readVersionsMetadata,
} from '../versions';
import {DEFAULT_OPTIONS} from '../options';
import {DEFAULT_PLUGIN_ID} from '@docusaurus/utils'; import {DEFAULT_PLUGIN_ID} from '@docusaurus/utils';
import type {I18n} from '@docusaurus/types'; import type {I18n} from '@docusaurus/types';
import type { import type {
@ -28,44 +23,11 @@ const DefaultI18N: I18n = {
localeConfigs: {}, 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('readVersionsMetadata', () => {
describe('simple site', () => { describe('simple site', () => {
async function loadSite() { async function loadSite() {
const simpleSiteDir = path.resolve( const simpleSiteDir = path.resolve(
path.join(__dirname, '__fixtures__', 'simple-site'), path.join(__dirname, '../../__tests__/__fixtures__', 'simple-site'),
); );
const defaultOptions: PluginOptions = { const defaultOptions: PluginOptions = {
id: DEFAULT_PLUGIN_ID, id: DEFAULT_PLUGIN_ID,
@ -217,7 +179,7 @@ describe('readVersionsMetadata', () => {
context: defaultContext, context: defaultContext,
}), }),
).rejects.toThrowErrorMatchingInlineSnapshot( ).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 <PROJECT_ROOT>/docs through "includeCurrentVersion: false", while the versions file is empty/non-existent."`,
); );
}); });
}); });
@ -225,12 +187,12 @@ describe('readVersionsMetadata', () => {
describe('versioned site, pluginId=default', () => { describe('versioned site, pluginId=default', () => {
async function loadSite() { async function loadSite() {
const versionedSiteDir = path.resolve( const versionedSiteDir = path.resolve(
path.join(__dirname, '__fixtures__', 'versioned-site'), path.join(__dirname, '../../__tests__/__fixtures__', 'versioned-site'),
); );
const defaultOptions: PluginOptions = { const defaultOptions: PluginOptions = {
id: DEFAULT_PLUGIN_ID, id: DEFAULT_PLUGIN_ID,
...DEFAULT_OPTIONS, ...DEFAULT_OPTIONS,
sidebarPath: 'sidebars.json', sidebarPath: path.join(versionedSiteDir, 'sidebars.json'),
}; };
const defaultContext = { const defaultContext = {
siteDir: versionedSiteDir, siteDir: versionedSiteDir,
@ -562,7 +524,7 @@ describe('readVersionsMetadata', () => {
context: defaultContext, context: defaultContext,
}), }),
).rejects.toThrowErrorMatchingInlineSnapshot( ).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 <PROJECT_ROOT>/docs through "includeCurrentVersion: false", while versioning is disabled with "disableVersioning: true"."`,
); );
}); });
@ -651,7 +613,9 @@ describe('readVersionsMetadata', () => {
options: defaultOptions, options: defaultOptions,
context: defaultContext, context: defaultContext,
}), }),
).rejects.toThrowErrorMatchingInlineSnapshot(`"Invalid version " "."`); ).rejects.toThrowErrorMatchingInlineSnapshot(
`"Invalid version name " ": version name must contain at least one non-whitespace character."`,
);
jsonMock.mockRestore(); jsonMock.mockRestore();
}); });
}); });
@ -659,14 +623,14 @@ describe('readVersionsMetadata', () => {
describe('versioned site, pluginId=community', () => { describe('versioned site, pluginId=community', () => {
async function loadSite() { async function loadSite() {
const versionedSiteDir = path.resolve( const versionedSiteDir = path.resolve(
path.join(__dirname, '__fixtures__', 'versioned-site'), path.join(__dirname, '../../__tests__/__fixtures__', 'versioned-site'),
); );
const defaultOptions: PluginOptions = { const defaultOptions: PluginOptions = {
...DEFAULT_OPTIONS, ...DEFAULT_OPTIONS,
id: 'community', id: 'community',
path: 'community', path: 'community',
routeBasePath: 'communityBasePath', routeBasePath: 'communityBasePath',
sidebarPath: 'sidebars.json', sidebarPath: path.join(versionedSiteDir, 'sidebars.json'),
}; };
const defaultContext = { const defaultContext = {
siteDir: versionedSiteDir, siteDir: versionedSiteDir,
@ -779,7 +743,7 @@ describe('readVersionsMetadata', () => {
context: defaultContext, context: defaultContext,
}), }),
).rejects.toThrowErrorMatchingInlineSnapshot( ).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 <PROJECT_ROOT>/community through "includeCurrentVersion: false", while versioning is disabled with "disableVersioning: true"."`,
); );
}); });
}); });

View file

@ -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<string[] | null> {
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<string[]> {
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};
}

View file

@ -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<VersionMetadata, 'contentPath' | 'contentPathLocalized'> & {
context: LoadContext;
options: PluginOptions;
}): Pick<VersionMetadata, 'editUrl' | 'editUrlLocalized'> {
// 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<VersionMetadata> {
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<VersionContext, 'versionNames' | 'options'>) {
return (
options.lastVersion ??
versionNames.find((name) => name !== CURRENT_VERSION_NAME) ??
CURRENT_VERSION_NAME
);
}
export async function readVersionsMetadata({
context,
options,
}: {
context: LoadContext;
options: PluginOptions;
}): Promise<VersionMetadata[]> {
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;
}

View file

@ -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.`,
);
}
}
}