fix(plugin-docs,theme): refactor docs plugin routes and component tree (#7966)

This commit is contained in:
Sébastien Lorber 2022-08-18 17:55:05 +02:00 committed by GitHub
parent c29218ea1d
commit 3b9b497d13
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
35 changed files with 1189 additions and 857 deletions

View file

@ -49,7 +49,9 @@ describe('normalizeDocsPluginOptions', () => {
sidebarPath: 'my-sidebar', // Path to sidebar configuration for showing a list of markdown pages.
sidebarItemsGenerator: DefaultSidebarItemsGenerator,
numberPrefixParser: DefaultNumberPrefixParser,
docLayoutComponent: '@theme/DocPage',
docsRootComponent: '@theme/DocsRoot',
docVersionRootComponent: '@theme/DocVersionRoot',
docRootComponent: '@theme/DocRoot',
docItemComponent: '@theme/DocItem',
docTagDocListComponent: '@theme/DocTagDocListPage',
docTagsListComponent: '@theme/DocTagsListPage',

View file

@ -27,22 +27,18 @@ import {
addDocNavigation,
type DocEnv,
} from './docs';
import {readVersionsMetadata} from './versions';
import {readVersionsMetadata, toFullVersion} from './versions';
import {cliDocsVersionCommand} from './cli';
import {VERSIONS_JSON_FILE} from './constants';
import {toGlobalDataVersion} from './globalData';
import {toTagDocListProp} from './props';
import {getCategoryGeneratedIndexMetadataList} from './categoryGeneratedIndex';
import {
translateLoadedContent,
getLoadedContentTranslationFiles,
} from './translations';
import {getVersionTags} from './tags';
import {createVersionRoutes} from './routes';
import {createAllRoutes} from './routes';
import {createSidebarsUtils} from './sidebars/utils';
import type {
PropTagsListPage,
PluginOptions,
DocMetadataBase,
VersionMetadata,
@ -55,7 +51,6 @@ import type {
SourceToPermalink,
DocFile,
DocsMarkdownOption,
VersionTag,
FullVersion,
} from './types';
import type {RuleSetRule} from 'webpack';
@ -209,102 +204,20 @@ export default async function pluginContentDocs(
},
async contentLoaded({content, actions}) {
const {loadedVersions} = content;
const {
docLayoutComponent,
docItemComponent,
docCategoryGeneratedIndexComponent,
breadcrumbs,
} = options;
const {addRoute, createData, setGlobalData} = actions;
const versions: FullVersion[] = loadedVersions.map((version) => {
const sidebarsUtils = createSidebarsUtils(version.sidebars);
return {
...version,
sidebarsUtils,
categoryGeneratedIndices: getCategoryGeneratedIndexMetadataList({
docs: version.docs,
sidebarsUtils,
}),
};
const versions: FullVersion[] = content.loadedVersions.map(toFullVersion);
await createAllRoutes({
baseUrl,
versions,
options,
actions,
aliasedSource,
});
async function createVersionTagsRoutes(version: FullVersion) {
const versionTags = getVersionTags(version.docs);
// TODO tags should be a sub route of the version route
async function createTagsListPage() {
const tagsProp: PropTagsListPage['tags'] = Object.values(
versionTags,
).map((tagValue) => ({
label: tagValue.label,
permalink: tagValue.permalink,
count: tagValue.docIds.length,
}));
// Only create /tags page if there are tags.
if (tagsProp.length > 0) {
const tagsPropPath = await createData(
`${docuHash(`tags-list-${version.versionName}-prop`)}.json`,
JSON.stringify(tagsProp, null, 2),
);
addRoute({
path: version.tagsPath,
exact: true,
component: options.docTagsListComponent,
modules: {
tags: aliasedSource(tagsPropPath),
},
});
}
}
// TODO tags should be a sub route of the version route
async function createTagDocListPage(tag: VersionTag) {
const tagProps = toTagDocListProp({
allTagsPath: version.tagsPath,
tag,
docs: version.docs,
});
const tagPropPath = await createData(
`${docuHash(`tag-${tag.permalink}`)}.json`,
JSON.stringify(tagProps, null, 2),
);
addRoute({
path: tag.permalink,
component: options.docTagDocListComponent,
exact: true,
modules: {
tag: aliasedSource(tagPropPath),
},
});
}
await createTagsListPage();
await Promise.all(Object.values(versionTags).map(createTagDocListPage));
}
await Promise.all(
versions.map((version) =>
createVersionRoutes({
version,
docItemComponent,
docLayoutComponent,
docCategoryGeneratedIndexComponent,
pluginId,
aliasedSource,
actions,
}),
),
);
// TODO tags should be a sub route of the version route
await Promise.all(versions.map(createVersionTagsRoutes));
setGlobalData({
actions.setGlobalData({
path: normalizeUrl([baseUrl, options.routeBasePath]),
versions: versions.map(toGlobalDataVersion),
breadcrumbs,
breadcrumbs: options.breadcrumbs,
});
},

View file

@ -30,7 +30,9 @@ export const DEFAULT_OPTIONS: Omit<PluginOptions, 'id' | 'sidebarPath'> = {
exclude: GlobExcludeDefault,
sidebarItemsGenerator: DefaultSidebarItemsGenerator,
numberPrefixParser: DefaultNumberPrefixParser,
docLayoutComponent: '@theme/DocPage',
docsRootComponent: '@theme/DocsRoot',
docVersionRootComponent: '@theme/DocVersionRoot',
docRootComponent: '@theme/DocRoot',
docItemComponent: '@theme/DocItem',
docTagDocListComponent: '@theme/DocTagDocListPage',
docTagsListComponent: '@theme/DocTagsListPage',
@ -104,7 +106,11 @@ const OptionsSchema = Joi.object<PluginOptions>({
}),
)
.default(() => DEFAULT_OPTIONS.numberPrefixParser),
docLayoutComponent: Joi.string().default(DEFAULT_OPTIONS.docLayoutComponent),
docsRootComponent: Joi.string().default(DEFAULT_OPTIONS.docsRootComponent),
docVersionRootComponent: Joi.string().default(
DEFAULT_OPTIONS.docVersionRootComponent,
),
docRootComponent: Joi.string().default(DEFAULT_OPTIONS.docRootComponent),
docItemComponent: Joi.string().default(DEFAULT_OPTIONS.docItemComponent),
docTagsListComponent: Joi.string().default(
DEFAULT_OPTIONS.docTagsListComponent,

View file

@ -198,10 +198,24 @@ declare module '@docusaurus/plugin-content-docs' {
*/
exclude: string[];
/**
* Root layout component of each doc page. Provides the version data
* context, and is not unmounted when switching docs.
* Parent component of all the docs plugin pages (including all versions).
* Stays mounted when navigation between docs pages and versions.
*/
docLayoutComponent: string;
docsRootComponent: string;
/**
* Parent component of all docs pages of an individual version:
* - docs pages with sidebars
* - tags pages
* Stays mounted when navigation between pages of that specific version.
*/
docVersionRootComponent: string;
/**
* Parent component of all docs pages with sidebars:
* - regular docs pages
* - category generated index pages
* Stays mounted when navigation between such pages.
*/
docRootComponent: string;
/** Main doc container, with TOC, pagination, etc. */
docItemComponent: string;
/** Root component of the "docs containing tag X" page. */
@ -610,14 +624,32 @@ declare module '@theme/DocBreadcrumbs' {
export default function DocBreadcrumbs(): JSX.Element;
}
declare module '@theme/DocPage' {
declare module '@theme/DocsRoot' {
import type {RouteConfigComponentProps} from 'react-router-config';
import type {Required} from 'utility-types';
export interface Props extends Required<RouteConfigComponentProps, 'route'> {}
export default function DocsRoot(props: Props): JSX.Element;
}
declare module '@theme/DocVersionRoot' {
import type {PropVersionMetadata} from '@docusaurus/plugin-content-docs';
import type {RouteConfigComponentProps} from 'react-router-config';
import type {Required} from 'utility-types';
export interface Props extends Required<RouteConfigComponentProps, 'route'> {
readonly versionMetadata: PropVersionMetadata;
readonly version: PropVersionMetadata;
}
export default function DocPage(props: Props): JSX.Element;
export default function DocVersionRoot(props: Props): JSX.Element;
}
declare module '@theme/DocRoot' {
import type {RouteConfigComponentProps} from 'react-router-config';
import type {Required} from 'utility-types';
export interface Props extends Required<RouteConfigComponentProps, 'route'> {}
export default function DocRoot(props: Props): JSX.Element;
}

View file

@ -7,7 +7,7 @@
import _ from 'lodash';
import {createDocsByIdIndex} from './docs';
import type {VersionTag} from './types';
import type {VersionTag, VersionTags} from './types';
import type {
SidebarItemDoc,
SidebarItem,
@ -21,6 +21,7 @@ import type {
PropSidebarItemCategory,
PropTagDocList,
PropTagDocListDoc,
PropTagsListPage,
PropSidebarItemLink,
PropVersionDocs,
DocMetadata,
@ -181,3 +182,13 @@ export function toTagDocListProp({
items: toDocListProp(),
};
}
export function toTagsListTagsProp(
versionTags: VersionTags,
): PropTagsListPage['tags'] {
return Object.values(versionTags).map((tagValue) => ({
label: tagValue.label,
permalink: tagValue.permalink,
count: tagValue.docIds.length,
}));
}

View file

@ -5,30 +5,32 @@
* LICENSE file in the root directory of this source tree.
*/
import _ from 'lodash';
import logger from '@docusaurus/logger';
import {docuHash, createSlugger} from '@docusaurus/utils';
import {toVersionMetadataProp} from './props';
import {docuHash, createSlugger, normalizeUrl} from '@docusaurus/utils';
import {
toTagDocListProp,
toTagsListTagsProp,
toVersionMetadataProp,
} from './props';
import {getVersionTags} from './tags';
import type {PluginContentLoadedActions, RouteConfig} from '@docusaurus/types';
import type {FullVersion} from './types';
import type {FullVersion, VersionTag} from './types';
import type {
CategoryGeneratedIndexMetadata,
DocMetadata,
PluginOptions,
PropTagsListPage,
} from '@docusaurus/plugin-content-docs';
export async function createCategoryGeneratedIndexRoutes({
async function buildVersionCategoryGeneratedIndexRoutes({
version,
actions,
docCategoryGeneratedIndexComponent,
options,
aliasedSource,
}: {
version: FullVersion;
actions: PluginContentLoadedActions;
docCategoryGeneratedIndexComponent: string;
aliasedSource: (str: string) => string;
}): Promise<RouteConfig[]> {
}: BuildVersionRoutesParam): Promise<RouteConfig[]> {
const slugs = createSlugger();
async function createCategoryGeneratedIndexRoute(
async function buildCategoryGeneratedIndexRoute(
categoryGeneratedIndex: CategoryGeneratedIndexMetadata,
): Promise<RouteConfig> {
const {sidebar, ...prop} = categoryGeneratedIndex;
@ -44,7 +46,7 @@ export async function createCategoryGeneratedIndexRoutes({
return {
path: categoryGeneratedIndex.permalink,
component: docCategoryGeneratedIndexComponent,
component: options.docCategoryGeneratedIndexComponent,
exact: true,
modules: {
categoryGeneratedIndex: aliasedSource(propData),
@ -56,21 +58,17 @@ export async function createCategoryGeneratedIndexRoutes({
}
return Promise.all(
version.categoryGeneratedIndices.map(createCategoryGeneratedIndexRoute),
version.categoryGeneratedIndices.map(buildCategoryGeneratedIndexRoute),
);
}
export async function createDocRoutes({
docs,
async function buildVersionDocRoutes({
version,
actions,
docItemComponent,
}: {
docs: DocMetadata[];
actions: PluginContentLoadedActions;
docItemComponent: string;
}): Promise<RouteConfig[]> {
options,
}: BuildVersionRoutesParam): Promise<RouteConfig[]> {
return Promise.all(
docs.map(async (metadataItem) => {
version.docs.map(async (metadataItem) => {
await actions.createData(
// Note that this created data path must be in sync with
// metadataPath provided to mdx-loader.
@ -80,12 +78,12 @@ export async function createDocRoutes({
const docRoute: RouteConfig = {
path: metadataItem.permalink,
component: docItemComponent,
component: options.docItemComponent,
exact: true,
modules: {
content: metadataItem.source,
},
// Because the parent (DocPage) comp need to access it easily
// Because the parent (DocRoot) comp need to access it easily
// This permits to render the sidebar once without unmount/remount when
// navigating (and preserve sidebar state)
...(metadataItem.sidebar && {
@ -98,62 +96,160 @@ export async function createDocRoutes({
);
}
export async function createVersionRoutes({
version,
actions,
docItemComponent,
docLayoutComponent,
docCategoryGeneratedIndexComponent,
pluginId,
aliasedSource,
}: {
version: FullVersion;
actions: PluginContentLoadedActions;
docLayoutComponent: string;
docItemComponent: string;
docCategoryGeneratedIndexComponent: string;
pluginId: string;
aliasedSource: (str: string) => string;
}): Promise<void> {
async function doCreateVersionRoutes(): Promise<void> {
const versionMetadata = toVersionMetadataProp(pluginId, version);
const versionMetadataPropPath = await actions.createData(
`${docuHash(`version-${version.versionName}-metadata-prop`)}.json`,
JSON.stringify(versionMetadata, null, 2),
);
async function buildVersionSidebarRoute(param: BuildVersionRoutesParam) {
const [docRoutes, categoryGeneratedIndexRoutes] = await Promise.all([
buildVersionDocRoutes(param),
buildVersionCategoryGeneratedIndexRoutes(param),
]);
const subRoutes = [...docRoutes, ...categoryGeneratedIndexRoutes];
return {
path: param.version.path,
exact: false,
component: param.options.docRootComponent,
routes: subRoutes,
};
}
async function createVersionSubRoutes() {
const [docRoutes, sidebarsRoutes] = await Promise.all([
createDocRoutes({docs: version.docs, actions, docItemComponent}),
createCategoryGeneratedIndexRoutes({
version,
actions,
docCategoryGeneratedIndexComponent,
aliasedSource,
}),
]);
async function buildVersionTagsRoutes(
param: BuildVersionRoutesParam,
): Promise<RouteConfig[]> {
const {version, options, actions, aliasedSource} = param;
const versionTags = getVersionTags(version.docs);
const routes = [...docRoutes, ...sidebarsRoutes];
return routes.sort((a, b) => a.path.localeCompare(b.path));
async function buildTagsListRoute(): Promise<RouteConfig | null> {
// Don't create a tags list page if there's no tag
if (Object.keys(versionTags).length === 0) {
return null;
}
actions.addRoute({
path: version.path,
// Allow matching /docs/* since this is the wrapping route
exact: false,
component: docLayoutComponent,
routes: await createVersionSubRoutes(),
const tagsProp: PropTagsListPage['tags'] = toTagsListTagsProp(versionTags);
const tagsPropPath = await actions.createData(
`${docuHash(`tags-list-${version.versionName}-prop`)}.json`,
JSON.stringify(tagsProp, null, 2),
);
return {
path: version.tagsPath,
exact: true,
component: options.docTagsListComponent,
modules: {
versionMetadata: aliasedSource(versionMetadataPropPath),
tags: aliasedSource(tagsPropPath),
},
};
}
async function buildTagDocListRoute(tag: VersionTag): Promise<RouteConfig> {
const tagProps = toTagDocListProp({
allTagsPath: version.tagsPath,
tag,
docs: version.docs,
});
const tagPropPath = await actions.createData(
`${docuHash(`tag-${tag.permalink}`)}.json`,
JSON.stringify(tagProps, null, 2),
);
return {
path: tag.permalink,
component: options.docTagDocListComponent,
exact: true,
modules: {
tag: aliasedSource(tagPropPath),
},
};
}
const [tagsListRoute, allTagsDocListRoutes] = await Promise.all([
buildTagsListRoute(),
Promise.all(Object.values(versionTags).map(buildTagDocListRoute)),
]);
return _.compact([tagsListRoute, ...allTagsDocListRoutes]);
}
type BuildVersionRoutesParam = Omit<BuildAllRoutesParam, 'versions'> & {
version: FullVersion;
};
async function buildVersionRoutes(
param: BuildVersionRoutesParam,
): Promise<RouteConfig> {
const {version, actions, options, aliasedSource} = param;
async function buildVersionSubRoutes() {
const [sidebarRoute, tagsRoutes] = await Promise.all([
buildVersionSidebarRoute(param),
buildVersionTagsRoutes(param),
]);
return [sidebarRoute, ...tagsRoutes];
}
async function doBuildVersionRoutes(): Promise<RouteConfig> {
const versionProp = toVersionMetadataProp(options.id, version);
const versionPropPath = await actions.createData(
`${docuHash(`version-${version.versionName}-metadata-prop`)}.json`,
JSON.stringify(versionProp, null, 2),
);
const subRoutes = await buildVersionSubRoutes();
return {
path: version.path,
exact: false,
component: options.docVersionRootComponent,
routes: subRoutes,
modules: {
version: aliasedSource(versionPropPath),
},
priority: version.routePriority,
});
};
}
try {
return await doCreateVersionRoutes();
return await doBuildVersionRoutes();
} catch (err) {
logger.error`Can't create version routes for version name=${version.versionName}`;
throw err;
}
}
type BuildAllRoutesParam = Omit<CreateAllRoutesParam, 'actions'> & {
actions: Omit<PluginContentLoadedActions, 'addRoute' | 'setGlobalData'>;
};
// TODO we want this buildAllRoutes function to be easily testable
// Ideally, we should avoid side effects here (ie not injecting actions)
export async function buildAllRoutes(
param: BuildAllRoutesParam,
): Promise<RouteConfig[]> {
const subRoutes = await Promise.all(
param.versions.map((version) =>
buildVersionRoutes({
...param,
version,
}),
),
);
// all docs routes are wrapped under a single parent route, this ensures
// the theme layout never unmounts/remounts when navigating between versions
return [
{
path: normalizeUrl([param.baseUrl, param.options.routeBasePath]),
exact: false,
component: param.options.docsRootComponent,
routes: subRoutes,
},
];
}
type CreateAllRoutesParam = {
baseUrl: string;
versions: FullVersion[];
options: PluginOptions;
actions: PluginContentLoadedActions;
aliasedSource: (str: string) => string;
};
export async function createAllRoutes(
param: CreateAllRoutesParam,
): Promise<void> {
const routes = await buildAllRoutes(param);
routes.forEach(param.actions.addRoute);
}

View file

@ -14,12 +14,16 @@ import {
getVersionMetadataPaths,
readVersionNames,
} from './files';
import {createSidebarsUtils} from '../sidebars/utils';
import {getCategoryGeneratedIndexMetadataList} from '../categoryGeneratedIndex';
import type {FullVersion} from '../types';
import type {LoadContext} from '@docusaurus/types';
import type {
LoadedVersion,
PluginOptions,
VersionBanner,
VersionMetadata,
} from '@docusaurus/plugin-content-docs';
import type {LoadContext} from '@docusaurus/types';
export type VersionContext = {
/** The version name to get banner of. */
@ -252,3 +256,15 @@ export async function readVersionsMetadata({
);
return versionsMetadata;
}
export function toFullVersion(version: LoadedVersion): FullVersion {
const sidebarsUtils = createSidebarsUtils(version.sidebars);
return {
...version,
sidebarsUtils,
categoryGeneratedIndices: getCategoryGeneratedIndexMetadataList({
docs: version.docs,
sidebarsUtils,
}),
};
}