feat(v2): docs versioning ❄️🔥 (#1983)

* wip: versioning

* wip again

* nits lint

* refactor metadata code so that we can have inobject properties optimization, fix typing

* remove buggy permalink code

* modify versioned docs fixture such that foo/baz only exists in v1.0.0

* refactor metadata.ts so that there is less transformon object

* more refactoring

* reduce test fixtures, refactoring

* refactoring readability

* finish metadata part

* refactor with readdir

* first pass of implementation

* fix mdx laoder

* split generated routes by version for performance & smaller bundle

* test data for demo

* refactor with set

* more tests

* typo

* fix typo

* better temporary ui

* stronger typing & docsVersion command

* add 100% test coverage for docsVersion command

* more test and delete manual docs cut

* cut 2.0.0-alpha.35 docs

* cut alpha.36 instead

* copyright

* delete versioned docs

* stronger test on metadata

* update typo
This commit is contained in:
Endi 2019-11-22 16:17:40 +07:00 committed by GitHub
parent c413cff212
commit 9829f56b1e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
45 changed files with 1852 additions and 395 deletions

View file

@ -5,20 +5,17 @@
* LICENSE file in the root directory of this source tree.
*/
import _ from 'lodash';
import globby from 'globby';
import fs from 'fs-extra';
import path from 'path';
import {
idx,
normalizeUrl,
docuHash,
objectWithKeySorted,
} from '@docusaurus/utils';
import {LoadContext, Plugin} from '@docusaurus/types';
import {normalizeUrl, docuHash, objectWithKeySorted} from '@docusaurus/utils';
import {LoadContext, Plugin, RouteConfig} from '@docusaurus/types';
import createOrder from './order';
import loadSidebars from './sidebars';
import processMetadata from './metadata';
import loadEnv from './env';
import {
PluginOptions,
@ -35,8 +32,12 @@ import {
DocsSidebar,
DocsBaseMetadata,
MetadataRaw,
DocsMetadataRaw,
Metadata,
VersionToSidebars,
} from './types';
import {Configuration} from 'webpack';
import {docsVersion} from './version';
const DEFAULT_OPTIONS: PluginOptions = {
path: 'docs', // Path to data on filesystem, relative to site dir.
@ -56,101 +57,166 @@ export default function pluginContentDocs(
opts: Partial<PluginOptions>,
): Plugin<LoadedContent | null> {
const options = {...DEFAULT_OPTIONS, ...opts};
const contentPath = path.resolve(context.siteDir, options.path);
let sourceToPermalink: SourceToPermalink = {};
const {siteDir, generatedFilesDir, baseUrl} = context;
const docsDir = path.resolve(siteDir, options.path);
const sourceToPermalink: SourceToPermalink = {};
const dataDir = path.join(
context.generatedFilesDir,
generatedFilesDir,
'docusaurus-plugin-content-docs',
);
// Versioning
const env = loadEnv(siteDir);
const {versioning} = env;
const {
versions,
docsDir: versionedDir,
sidebarsDir: versionedSidebarsDir,
} = versioning;
const versionsNames = versions.map(version => `version-${version}`);
return {
name: 'docusaurus-plugin-content-docs',
extendCli(cli) {
cli
.command('docs:version')
.arguments('<version>')
.description('Tag a new version for docs')
.action(version => {
docsVersion(version, siteDir, {
path: options.path,
sidebarPath: options.sidebarPath,
});
});
},
getPathsToWatch() {
const {include = []} = options;
const globPattern = include.map(pattern => `${contentPath}/${pattern}`);
const {include} = options;
let globPattern = include.map(pattern => `${docsDir}/${pattern}`);
if (versioning.enabled) {
const docsGlob = _.flatten(
include.map(pattern =>
versionsNames.map(
versionName => `${versionedDir}/${versionName}/${pattern}`,
),
),
);
const sidebarsGlob = versionsNames.map(
versionName => `${versionedSidebarsDir}/${versionName}-sidebars.json`,
);
globPattern = [...globPattern, ...sidebarsGlob, ...docsGlob];
}
return [...globPattern, options.sidebarPath];
},
// Fetches blog contents and returns metadata for the contents.
async loadContent() {
const {
include,
routeBasePath,
sidebarPath,
editUrl,
showLastUpdateAuthor,
showLastUpdateTime,
} = options;
const {siteConfig, siteDir} = context;
const docsDir = contentPath;
const {include, sidebarPath} = options;
if (!fs.existsSync(docsDir)) {
return null;
}
const loadedSidebars: Sidebar = loadSidebars(sidebarPath);
// Build the docs ordering such as next, previous, category and sidebar.
const order: Order = createOrder(loadedSidebars);
// Prepare metadata container.
const docsMetadataRaw: {
[id: string]: MetadataRaw;
} = {};
const docsMetadataRaw: DocsMetadataRaw = {};
const docsPromises = [];
// Metadata for default docs files.
// Metadata for default/ master docs files.
const docsFiles = await globby(include, {
cwd: docsDir,
});
await Promise.all(
docsFiles.map(async source => {
const metadata: MetadataRaw = await processMetadata({
source,
docsDir,
order,
siteConfig,
docsBasePath: routeBasePath,
siteDir,
editUrl,
showLastUpdateAuthor,
showLastUpdateTime,
});
docsMetadataRaw[metadata.id] = metadata;
}),
docsPromises.push(
Promise.all(
docsFiles.map(async source => {
const metadata: MetadataRaw = await processMetadata({
source,
refDir: docsDir,
context,
options,
env,
});
docsMetadataRaw[metadata.id] = metadata;
}),
),
);
// Construct docsMetadata
// Metadata for versioned docs
if (versioning.enabled) {
const versionedGlob = _.flatten(
include.map(pattern =>
versionsNames.map(versionName => `${versionName}/${pattern}`),
),
);
const versionedFiles = await globby(versionedGlob, {
cwd: versionedDir,
});
docsPromises.push(
Promise.all(
versionedFiles.map(async source => {
const metadata = await processMetadata({
source,
refDir: versionedDir,
context,
options,
env,
});
docsMetadataRaw[metadata.id] = metadata;
}),
),
);
}
// Load the sidebars & create docs ordering
const sidebarPaths = [
sidebarPath,
...versionsNames.map(
versionName => `${versionedSidebarsDir}/${versionName}-sidebars.json`,
),
];
const loadedSidebars: Sidebar = loadSidebars(sidebarPaths);
const order: Order = createOrder(loadedSidebars);
await Promise.all(docsPromises);
// Construct inter-metadata relationship in docsMetadata
const docsMetadata: DocsMetadata = {};
const permalinkToSidebar: PermalinkToSidebar = {};
const versionToSidebars: VersionToSidebars = {};
Object.keys(docsMetadataRaw).forEach(currentID => {
let previous;
let next;
const previousID = idx(docsMetadataRaw, [currentID, 'previous']);
if (previousID) {
previous = {
title: idx(docsMetadataRaw, [previousID, 'title']) || 'Previous',
permalink: idx(docsMetadataRaw, [previousID, 'permalink']),
};
}
const nextID = idx(docsMetadataRaw, [currentID, 'next']);
if (nextID) {
next = {
title: idx(docsMetadataRaw, [nextID, 'title']) || 'Next',
permalink: idx(docsMetadataRaw, [nextID, 'permalink']),
};
}
const {next: nextID, previous: previousID, sidebar} =
order[currentID] || {};
const previous = previousID
? {
title: docsMetadataRaw[previousID]?.title ?? 'Previous',
permalink: docsMetadataRaw[previousID]?.permalink,
}
: undefined;
const next = nextID
? {
title: docsMetadataRaw[nextID]?.title ?? 'Next',
permalink: docsMetadataRaw[nextID]?.permalink,
}
: undefined;
docsMetadata[currentID] = {
...docsMetadataRaw[currentID],
sidebar,
previous,
next,
};
// sourceToPermalink and permalinkToSidebar mapping
const {source, permalink, sidebar} = docsMetadataRaw[currentID];
const {source, permalink, version} = docsMetadataRaw[currentID];
sourceToPermalink[source] = permalink;
if (sidebar) {
permalinkToSidebar[permalink] = sidebar;
if (versioning.enabled && version) {
if (!versionToSidebars[version]) {
versionToSidebars[version] = new Set();
}
versionToSidebars[version].add(sidebar);
}
}
});
@ -206,8 +272,8 @@ export default function pluginContentDocs(
docsMetadata,
docsDir,
docsSidebars,
sourceToPermalink,
permalinkToSidebar: objectWithKeySorted(permalinkToSidebar),
versionToSidebars,
};
},
@ -221,49 +287,107 @@ export default function pluginContentDocs(
const aliasedSource = (source: string) =>
`@docusaurus-plugin-content-docs/${path.relative(dataDir, source)}`;
const routes = await Promise.all(
Object.values(content.docsMetadata).map(async metadataItem => {
const metadataPath = await createData(
`${docuHash(metadataItem.permalink)}.json`,
JSON.stringify(metadataItem, null, 2),
);
return {
path: metadataItem.permalink,
component: docItemComponent,
exact: true,
modules: {
content: metadataItem.source,
metadata: aliasedSource(metadataPath),
},
};
}),
);
const docsBaseMetadata: DocsBaseMetadata = {
docsSidebars: content.docsSidebars,
permalinkToSidebar: content.permalinkToSidebar,
const genRoutes = async (
metadataItems: Metadata[],
): Promise<RouteConfig[]> => {
const routes = await Promise.all(
metadataItems.map(async metadataItem => {
const metadataPath = await createData(
`${docuHash(metadataItem.permalink)}.json`,
JSON.stringify(metadataItem, null, 2),
);
return {
path: metadataItem.permalink,
component: docItemComponent,
exact: true,
modules: {
content: metadataItem.source,
metadata: aliasedSource(metadataPath),
},
};
}),
);
return routes.sort((a, b) =>
a.path > b.path ? 1 : b.path > a.path ? -1 : 0,
);
};
const docsBaseRoute = normalizeUrl([
context.baseUrl,
routeBasePath,
':route',
]);
const docsBaseMetadataPath = await createData(
`${docuHash(docsBaseRoute)}.json`,
JSON.stringify(docsBaseMetadata, null, 2),
);
const addBaseRoute = async (
docsBaseRoute: string,
docsBaseMetadata: DocsBaseMetadata,
routes: RouteConfig[],
priority?: number,
) => {
const docsBaseMetadataPath = await createData(
`${docuHash(docsBaseRoute)}.json`,
JSON.stringify(docsBaseMetadata, null, 2),
);
addRoute({
path: docsBaseRoute,
component: docLayoutComponent,
routes: routes.sort((a, b) =>
a.path > b.path ? 1 : b.path > a.path ? -1 : 0,
),
modules: {
docsMetadata: aliasedSource(docsBaseMetadataPath),
},
});
addRoute({
path: docsBaseRoute,
component: docLayoutComponent,
routes,
modules: {
docsMetadata: aliasedSource(docsBaseMetadataPath),
},
priority,
});
};
// If versioning is enabled, we cleverly chunk the generated routes to be by version
// and pick only needed base metadata
if (versioning.enabled) {
const docsMetadataByVersion = _.groupBy(
Object.values(content.docsMetadata),
'version',
);
await Promise.all(
Object.keys(docsMetadataByVersion).map(async version => {
const routes: RouteConfig[] = await genRoutes(
docsMetadataByVersion[version],
);
const isLatestVersion = version === versioning.latestVersion;
const docsBasePermalink = normalizeUrl([
baseUrl,
routeBasePath,
isLatestVersion ? '' : version,
]);
const docsBaseRoute = normalizeUrl([docsBasePermalink, ':route']);
const neededSidebars: Set<string> =
content.versionToSidebars[version] || new Set();
const docsBaseMetadata: DocsBaseMetadata = {
docsSidebars: _.pick(
content.docsSidebars,
Array.from(neededSidebars),
),
permalinkToSidebar: _.pickBy(
content.permalinkToSidebar,
sidebar => neededSidebars.has(sidebar),
),
version,
};
// We want latest version route config to be placed last in the generated routeconfig.
// Otherwise, `/docs/next/foo` will match `/docs/:route` instead of `/docs/next/:route`
return addBaseRoute(
docsBaseRoute,
docsBaseMetadata,
routes,
isLatestVersion ? -1 : undefined,
);
}),
);
} else {
const routes = await genRoutes(Object.values(content.docsMetadata));
const docsBaseMetadata: DocsBaseMetadata = {
docsSidebars: content.docsSidebars,
permalinkToSidebar: content.permalinkToSidebar,
};
const docsBaseRoute = normalizeUrl([baseUrl, routeBasePath, ':route']);
return addBaseRoute(docsBaseRoute, docsBaseMetadata, routes);
}
},
configureWebpack(_config, isServer, utils) {
@ -279,7 +403,7 @@ export default function pluginContentDocs(
rules: [
{
test: /(\.mdx?)$/,
include: [contentPath],
include: [docsDir, versionedDir].filter(Boolean),
use: [
getCacheLoader(isServer),
getBabelLoader(isServer),
@ -293,9 +417,10 @@ export default function pluginContentDocs(
{
loader: path.resolve(__dirname, './markdown/index.js'),
options: {
siteDir: context.siteDir,
docsDir: contentPath,
siteDir,
docsDir,
sourceToPermalink: sourceToPermalink,
versionedDir,
},
},
].filter(Boolean),