feat(v2): docs version configuration: lastVersion, version.{path,label} (#3357)

* add new docs versioning options

* Add some tests for new versioning options

* Add some docs for version configurations

* try to fix broken link detection after /docs/ root paths have been removed on deploy previews

* improve dev/deploypreview versioning configurations

* disable custom current version path, as it produces broken links

* readVersionDocs should not be order sensitive

* fix versions page according to versioning config

* fix versions page according to versioning config
This commit is contained in:
Sébastien Lorber 2020-08-28 18:37:49 +02:00 committed by GitHub
parent 4bfc3bbbe7
commit ae877f2990
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 387 additions and 79 deletions

View file

@ -290,6 +290,7 @@ Object {
}",
"version-current-metadata-prop-751.json": "{
\\"version\\": \\"current\\",
\\"label\\": \\"Next\\",
\\"docsSidebars\\": {
\\"docs\\": [
{
@ -613,6 +614,7 @@ Object {
}",
"version-1-0-0-metadata-prop-608.json": "{
\\"version\\": \\"1.0.0\\",
\\"label\\": \\"1.0.0\\",
\\"docsSidebars\\": {
\\"version-1.0.0/community\\": [
{
@ -628,6 +630,7 @@ Object {
}",
"version-current-metadata-prop-751.json": "{
\\"version\\": \\"current\\",
\\"label\\": \\"Next\\",
\\"docsSidebars\\": {
\\"community\\": [
{
@ -1069,6 +1072,7 @@ Object {
}",
"version-1-0-0-metadata-prop-608.json": "{
\\"version\\": \\"1.0.0\\",
\\"label\\": \\"1.0.0\\",
\\"docsSidebars\\": {
\\"version-1.0.0/docs\\": [
{
@ -1110,6 +1114,7 @@ Object {
}",
"version-1-0-1-metadata-prop-e87.json": "{
\\"version\\": \\"1.0.1\\",
\\"label\\": \\"1.0.1\\",
\\"docsSidebars\\": {
\\"version-1.0.1/docs\\": [
{
@ -1145,6 +1150,7 @@ Object {
}",
"version-current-metadata-prop-751.json": "{
\\"version\\": \\"current\\",
\\"label\\": \\"Next\\",
\\"docsSidebars\\": {
\\"docs\\": [
{
@ -1180,6 +1186,7 @@ Object {
}",
"version-with-slugs-metadata-prop-2bf.json": "{
\\"version\\": \\"withSlugs\\",
\\"label\\": \\"withSlugs\\",
\\"docsSidebars\\": {
\\"version-1.0.1/docs\\": [
{

View file

@ -135,7 +135,8 @@ describe('simple site', () => {
test('readVersionDocs', async () => {
const docs = await readVersionDocs(currentVersion, options);
expect(docs.map((doc) => doc.source)).toMatchObject([
expect(docs.map((doc) => doc.source).sort()).toEqual(
[
'hello.md',
'ipsum.md',
'lorem.md',
@ -149,7 +150,8 @@ describe('simple site', () => {
'slugs/relativeSlug.md',
'slugs/resolvedSlug.md',
'slugs/tryToEscapeSlug.md',
]);
].sort(),
);
});
test('normal docs', async () => {

View file

@ -36,6 +36,16 @@ describe('normalizeDocsPluginOptions', () => {
excludeNextVersionDocs: true,
includeCurrentVersion: false,
disableVersioning: true,
versions: {
current: {
path: 'next',
label: 'next',
},
version1: {
path: 'hello',
label: 'world',
},
},
};
const {value, error} = await OptionsSchema.validate(userOptions);
expect(value).toEqual(userOptions);
@ -117,4 +127,32 @@ describe('normalizeDocsPluginOptions', () => {
`"\\"remarkPlugins\\" must be an array"`,
);
});
test('should reject bad lastVersion', () => {
expect(() => {
normalizePluginOptions(OptionsSchema, {
lastVersion: false,
});
}).toThrowErrorMatchingInlineSnapshot(
`"\\"lastVersion\\" must be a string"`,
);
});
test('should reject bad versions', () => {
expect(() => {
normalizePluginOptions(OptionsSchema, {
versions: {
current: {
hey: 3,
},
version1: {
path: 'hello',
label: 'world',
},
},
});
}).toThrowErrorMatchingInlineSnapshot(
`"\\"versions.current.hey\\" is not allowed"`,
);
});
});

View file

@ -94,7 +94,63 @@ describe('simple site', () => {
]);
});
test('readVersionsMetadata simple site with base url', () => {
test('readVersionsMetadata simple site with current version config', () => {
const versionsMetadata = readVersionsMetadata({
options: {
...defaultOptions,
versions: {
current: {
label: 'current-label',
path: 'current-path',
},
},
},
context: {
...defaultContext,
baseUrl: '/myBaseUrl',
},
});
expect(versionsMetadata).toEqual([
{
...vCurrent,
versionPath: '/myBaseUrl/docs/current-path',
versionLabel: 'current-label',
routePriority: undefined,
},
]);
});
test('readVersionsMetadata simple site with unknown lastVersion should throw', () => {
expect(() =>
readVersionsMetadata({
options: {...defaultOptions, lastVersion: 'unknownVersionName'},
context: defaultContext,
}),
).toThrowErrorMatchingInlineSnapshot(
`"Docs option lastVersion=unknownVersionName is invalid. Available version names are: current"`,
);
});
test('readVersionsMetadata simple site with unknown version configurations should throw', () => {
expect(() =>
readVersionsMetadata({
options: {
...defaultOptions,
versions: {
current: {label: 'current'},
unknownVersionName1: {label: 'unknownVersionName1'},
unknownVersionName2: {label: 'unknownVersionName2'},
},
},
context: defaultContext,
}),
).toThrowErrorMatchingInlineSnapshot(
`"Docs versions option provided configuration for unknown versions: unknownVersionName1,unknownVersionName2. Available version names are: current"`,
);
});
test('readVersionsMetadata simple site with disableVersioning while single version should throw', () => {
expect(() =>
readVersionsMetadata({
options: {...defaultOptions, disableVersioning: true},
@ -105,7 +161,7 @@ describe('simple site', () => {
);
});
test('readVersionsMetadata simple site with base url', () => {
test('readVersionsMetadata simple site without including current version should throw', () => {
expect(() =>
readVersionsMetadata({
options: {...defaultOptions, includeCurrentVersion: false},
@ -205,6 +261,42 @@ describe('versioned site, pluginId=default', () => {
]);
});
test('readVersionsMetadata versioned site with version options', () => {
const versionsMetadata = readVersionsMetadata({
options: {
...defaultOptions,
lastVersion: '1.0.0',
versions: {
current: {
path: 'current-path',
},
'1.0.0': {
label: '1.0.0-label',
},
},
},
context: defaultContext,
});
expect(versionsMetadata).toEqual([
{...vCurrent, versionPath: '/docs/current-path'},
{
...v101,
isLast: false,
routePriority: undefined,
versionPath: '/docs/1.0.1',
},
{
...v100,
isLast: true,
routePriority: -1,
versionLabel: '1.0.0-label',
versionPath: '/docs',
},
vwithSlugs,
]);
});
test('readVersionsMetadata versioned site with disableVersioning', () => {
const versionsMetadata = readVersionsMetadata({
options: {...defaultOptions, disableVersioning: true},

View file

@ -33,8 +33,19 @@ export const DEFAULT_OPTIONS: Omit<PluginOptions, 'id'> = {
excludeNextVersionDocs: false,
includeCurrentVersion: true,
disableVersioning: false,
lastVersion: undefined,
versions: {},
};
const VersionOptionsSchema = Joi.object({
path: Joi.string().allow('').optional(),
label: Joi.string().optional(),
});
const VersionsOptionsSchema = Joi.object()
.pattern(Joi.string().required(), VersionOptionsSchema)
.default(DEFAULT_OPTIONS.versions);
export const OptionsSchema = Joi.object({
path: Joi.string().default(DEFAULT_OPTIONS.path),
editUrl: URISchema,
@ -58,6 +69,8 @@ export const OptionsSchema = Joi.object({
DEFAULT_OPTIONS.includeCurrentVersion,
),
disableVersioning: Joi.bool().default(DEFAULT_OPTIONS.disableVersioning),
lastVersion: Joi.string().optional(),
versions: VersionsOptionsSchema,
});
// TODO bad validation function types

View file

@ -8,14 +8,13 @@
/* eslint-disable camelcase */
declare module '@docusaurus/plugin-content-docs-types' {
export type VersionName = string;
export type PermalinkToSidebar = {
[permalink: string]: string;
};
export type PropVersionMetadata = {
version: VersionName;
version: string;
label: string;
docsSidebars: PropSidebars;
permalinkToSidebar: PermalinkToSidebar;
};

View file

@ -66,6 +66,7 @@ export function toVersionMetadataProp(
): PropVersionMetadata {
return {
version: loadedVersion.versionName,
label: loadedVersion.versionLabel,
docsSidebars: toSidebarsProp(loadedVersion),
permalinkToSidebar: loadedVersion.permalinkToSidebar,
};

View file

@ -39,8 +39,19 @@ export type PathOptions = {
sidebarPath: string;
};
export type VersionOptions = {
path?: string;
label?: string;
};
export type VersionsOptions = {
lastVersion?: string;
versions: Record<string, VersionOptions>;
};
export type PluginOptions = MetadataOptions &
PathOptions & {
PathOptions &
VersionsOptions & {
id: string;
include: string[];
docLayoutComponent: string;

View file

@ -7,7 +7,12 @@
import path from 'path';
import fs from 'fs-extra';
import {PluginOptions, VersionMetadata} from './types';
import {
PluginOptions,
VersionMetadata,
VersionOptions,
VersionsOptions,
} from './types';
import {
VERSIONS_JSON_FILE,
VERSIONED_DOCS_DIR,
@ -18,6 +23,7 @@ import {
import {DEFAULT_PLUGIN_ID} from '@docusaurus/core/lib/constants';
import {LoadContext} from '@docusaurus/types';
import {normalizeUrl} from '@docusaurus/utils';
import {difference} from 'lodash';
// retro-compatibility: no prefix for the default plugin id
function addPluginIdPrefix(fileOrDir: string, pluginId: string): string {
@ -161,7 +167,10 @@ function createVersionMetadata({
versionName: string;
isLast: boolean;
context: Pick<LoadContext, 'siteDir' | 'baseUrl'>;
options: Pick<PluginOptions, 'id' | 'path' | 'sidebarPath' | 'routeBasePath'>;
options: Pick<
PluginOptions,
'id' | 'path' | 'sidebarPath' | 'routeBasePath' | 'versions'
>;
}): VersionMetadata {
const {sidebarFilePath, docsDirPath} = getVersionMetadataPaths({
versionName,
@ -169,16 +178,20 @@ function createVersionMetadata({
options,
});
// TODO hardcoded for retro-compatibility
// TODO Need to make this configurable
const versionLabel =
// retro-compatible values
const defaultVersionLabel =
versionName === CURRENT_VERSION_NAME ? 'Next' : versionName;
const versionPathPart = isLast
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,
@ -219,7 +232,7 @@ function checkVersionMetadataPaths({
// TODO for retrocompatibility with existing behavior
// We should make this configurable
// "last version" is not a very good concept nor api surface
function getLastVersionName(versionNames: string[]) {
function getDefaultLastVersionName(versionNames: string[]) {
if (versionNames.length === 1) {
return versionNames[0];
} else {
@ -229,6 +242,34 @@ function getLastVersionName(versionNames: string[]) {
}
}
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 unknownVersionNames = difference(
Object.keys(options.versions),
availableVersionNames,
);
if (unknownVersionNames.length > 0) {
throw new Error(
`Docs versions option provided configuration for unknown versions: ${unknownVersionNames.join(
',',
)}. ${availableVersionNamesMsg}`,
);
}
}
export function readVersionsMetadata({
context,
options,
@ -242,10 +283,17 @@ export function readVersionsMetadata({
| 'routeBasePath'
| 'includeCurrentVersion'
| 'disableVersioning'
| 'lastVersion'
| 'versions'
>;
}): VersionMetadata[] {
const versionNames = readVersionNames(context.siteDir, options);
const lastVersionName = getLastVersionName(versionNames);
checkVersionsOptions(versionNames, options);
const lastVersionName =
options.lastVersion ?? getDefaultLastVersionName(versionNames);
const versionsMetadata = versionNames.map((versionName) =>
createVersionMetadata({
versionName,

View file

@ -17,6 +17,16 @@ import TOC from '@theme/TOC';
import clsx from 'clsx';
import styles from './styles.module.css';
import {useActivePlugin, useActiveVersion} from '@theme/hooks/useDocs';
// TODO can't we receive the version as props instead?
const useDocVersion = () => {
const version = useActiveVersion(useActivePlugin().pluginId);
if (!version) {
throw new Error("unexpected, can't get version data of doc"); // should not happen
}
return version;
};
function DocItem(props: Props): JSX.Element {
const {siteConfig = {}} = useDocusaurusContext();
@ -30,7 +40,6 @@ function DocItem(props: Props): JSX.Element {
editUrl,
lastUpdatedAt,
lastUpdatedBy,
version,
} = metadata;
const {
frontMatter: {
@ -40,6 +49,7 @@ function DocItem(props: Props): JSX.Element {
hide_table_of_contents: hideTableOfContents,
},
} = DocContent;
const version = useDocVersion();
const metaTitle = title ? `${title} | ${siteTitle}` : siteTitle;
const metaImageUrl = useBaseUrl(metaImage, {absolute: true});
@ -76,7 +86,7 @@ function DocItem(props: Props): JSX.Element {
{version && (
<div>
<span className="badge badge--secondary">
Version: {version}
Version: {version.label}
</span>
</div>
)}

View file

@ -43,8 +43,6 @@ function DocVersionSuggestions(): JSX.Element {
return <></>;
}
const activeVersionName = activeVersion.name;
// try to link to same doc in latest version (not always possible)
// fallback to main doc of latest version
const suggestedDoc =
@ -54,15 +52,15 @@ function DocVersionSuggestions(): JSX.Element {
<div className="alert alert--warning margin-bottom--md" role="alert">
{
// TODO need refactoring
activeVersionName === 'current' ? (
activeVersion.name === 'current' ? (
<div>
This is unreleased documentation for {siteTitle}{' '}
<strong>{activeVersionName}</strong> version.
<strong>{activeVersion.label}</strong> version.
</div>
) : (
<div>
This is documentation for {siteTitle}{' '}
<strong>v{activeVersionName}</strong>, which is no longer actively
<strong>{activeVersion.label}</strong>, which is no longer actively
maintained.
</div>
)
@ -72,7 +70,7 @@ function DocVersionSuggestions(): JSX.Element {
<strong>
<Link to={suggestedDoc.path}>latest version</Link>
</strong>{' '}
({latestVersionSuggestion.name}).
({latestVersionSuggestion.label}).
</div>
</div>
);

View file

@ -335,6 +335,33 @@ module.exports = {
* in `/docs/next` directory, only versioned docs.
*/
excludeNextVersionDocs: false,
/**
* The last version is the one we navigate to in priority on versioned sites
* It is the one displayed by default in docs navbar items
* By default, the last version is the first one to appear in versions.json
* By default, the last version is at the "root" (docs have path=/docs/myDoc)
* Note: it is possible to configure the path and label of the last version
* Tip: using lastVersion: 'current' make sense in many cases
*/
lastVersion: undefined,
/**
* The docusaurus versioning defaults don't make sense for all projects
* This gives the ability customize the label and path of each version
* You may not like that default versin
*/
versions: {
/*
Example configuration:
current: {
label: 'Android SDK v2.0.0 (WIP)',
path: 'android-2.0.0',
},
'1.0.0': {
label: 'Android SDK v1.0.0',
path: 'android-1.0.0',
},
*/
},
},
],
],

View file

@ -64,9 +64,9 @@ When tagging a new version, the document versioning mechanism will:
- Create a versioned sidebars file based from your current [sidebar](docs.md#sidebar) configuration (if it exists) - saved as `versioned_sidebars/version-<version>-sidebars.json`.
- Append the new version number to `versions.json`.
## Files
## Docs
### Creating new files
### Creating new docs
1. Place the new file into the corresponding version folder.
1. Include the reference for the new file into the corresponding sidebar file, according to version number.
@ -91,7 +91,7 @@ versioned_docs/version-1.0.0/new.md
versioned_sidebars/version-1.0.0-sidebars.json
```
### Linking files
### Linking docs
- Remember to include the `.md` extension.
- Files will be linked to correct corresponding version.
@ -138,6 +138,35 @@ Example:
## Recommended practices
### Figure out the behavior for the "current" version
The "current" version is the version name for the `./docs` folder.
There are different ways to manage versioning, but two very common patterns are:
- You release v1, and start immediately working on v2 (including its docs)
- You release v1, and will maintain it for some time before thinking about v2.
Docusaurus defaults work great for the first usecase.
**For the 2nd usecase**: if you release v1 and don't plan to work on v2 anytime soon, instead of versioning v1 and having to maintain the docs in 2 folders (`./docs` + `./versioned_docs/version-1.0.0`), you may consider using the following configuration instead:
```json
{
"lastVersion": "current",
"versions": {
"current": {
"label": "1.0.0",
"path": "1.0.0"
}
}
}
```
The docs in `./docs` will be served at `/docs/1.0.0` instead of `/docs/next`, and `1.0.0` will become the default version we link to in the navbar dropdown, and you will only need to maintain a single `./docs` folder.
See [docs plugin configuration](using-plugins#docusaurusplugin-content-docs) for more details.
### Version your documentation only when needed
For example, you are building a documentation for your npm package `foo` and you are currently in version 1.0.0. You then release a patch version for a minor bug fix and it's now 1.0.1.
@ -156,3 +185,23 @@ Don't use relative paths import within the docs. Because when we cut a version t
- import Foo from '../src/components/Foo';
+ import Foo from '@site/src/components/Foo';
```
### Global or versioned colocated assets
You should decide if assets like images and files are per version or shared between versions
If your assets should be versioned, put them in the docs version, and use relative paths:
```md
![img alt](./myImage.png)
[dowload this file](./file.pdf)
```
If your assets are global, put them in `/static` and use absolute paths:
```md
![img alt](/myImage.png)
[dowload this file](/file.pdf)
```

View file

@ -14,13 +14,15 @@ const allDocHomesPaths = [
...versions.slice(1).map((version) => `/docs/${version}/`),
];
const isDev = process.env.NODE_ENV === 'development';
const isDeployPreview =
process.env.NETLIFY && process.env.CONTEXT === 'deploy-preview';
const baseUrl = process.env.BASE_URL || '/';
const isBootstrapPreset = process.env.DOCUSAURUS_PRESET === 'bootstrap';
const isVersioningDisabled = !!process.env.DISABLE_VERSIONING;
if (isBootstrapPreset) {
console.log('Will use bootstrap preset!');
}
const isVersioningDisabled = !!process.env.DISABLE_VERSIONING;
module.exports = {
title: 'Docusaurus',
@ -175,6 +177,16 @@ module.exports = {
showLastUpdateTime: true,
remarkPlugins: [require('./src/plugins/remark-npm2yarn')],
disableVersioning: isVersioningDisabled,
lastVersion: isDev || isDeployPreview ? 'current' : undefined,
versions: {
current: {
// path: isDev || isDeployPreview ? '' : 'next',
label:
isDev || isDeployPreview
? `Next (${isDeployPreview ? 'deploy preview' : 'dev'})`
: 'Next',
},
},
},
blog: {
// routeBasePath: '/',

View file

@ -6,20 +6,21 @@
*/
import React from 'react';
import Layout from '@theme/Layout';
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
import Link from '@docusaurus/Link';
import useBaseUrl from '@docusaurus/useBaseUrl';
import Layout from '@theme/Layout';
import versions from '../../versions.json';
import {useVersions, useLatestVersion} from '@theme/hooks/useDocs';
function Version() {
const context = useDocusaurusContext();
const {siteConfig = {}} = context;
const latestVersion = versions[0];
const pastVersions = versions.filter((version) => version !== latestVersion);
const {siteConfig} = useDocusaurusContext();
const versions = useVersions();
const latestVersion = useLatestVersion();
const currentVersion = versions.find((version) => version.name === 'current');
const pastVersions = versions.filter(
(version) => version !== latestVersion && version.name !== 'current',
);
const repoUrl = `https://github.com/${siteConfig.organizationName}/${siteConfig.projectName}`;
return (
<Layout
@ -34,12 +35,12 @@ function Version() {
<table>
<tbody>
<tr>
<th>{latestVersion}</th>
<th>{latestVersion.label}</th>
<td>
<Link to={useBaseUrl('/docs')}>Documentation</Link>
<Link to={latestVersion.path}>Documentation</Link>
</td>
<td>
<a href={`${repoUrl}/releases/tag/v${latestVersion}`}>
<a href={`${repoUrl}/releases/tag/v${latestVersion.name}`}>
Release Notes
</a>
</td>
@ -47,6 +48,7 @@ function Version() {
</tbody>
</table>
</div>
{currentVersion !== latestVersion && (
<div className="margin-bottom--lg">
<h3 id="next">Next version (Unreleased)</h3>
<p>Here you can find the documentation for unreleased version.</p>
@ -55,7 +57,7 @@ function Version() {
<tr>
<th>master</th>
<td>
<Link to={useBaseUrl('/docs/next')}>Documentation</Link>
<Link to={currentVersion.path}>Documentation</Link>
</td>
<td>
<a href={repoUrl}>Source Code</a>
@ -64,6 +66,7 @@ function Version() {
</tbody>
</table>
</div>
)}
{pastVersions.length > 0 && (
<div className="margin-bottom--lg">
<h3 id="archive">Past Versions</h3>
@ -74,15 +77,13 @@ function Version() {
<table>
<tbody>
{pastVersions.map((version) => (
<tr key={version}>
<th>{version}</th>
<tr key={version.name}>
<th>{version.label}</th>
<td>
<Link to={useBaseUrl(`/docs/${version}`)}>
Documentation
</Link>
<Link to={version.path}>Documentation</Link>
</td>
<td>
<a href={`${repoUrl}/releases/tag/v${version}`}>
<a href={`${repoUrl}/releases/tag/v${version.name}`}>
Release Notes
</a>
</td>