feat(core): simplify plugin API, support route.props (#10042)

This commit is contained in:
Sébastien Lorber 2024-04-16 13:57:11 +02:00 committed by GitHub
parent d1590e37ac
commit 5c1d6464d8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 2858 additions and 2240 deletions

View file

@ -48,7 +48,7 @@ jobs:
repo-token: ${{ secrets.GITHUB_TOKEN }} repo-token: ${{ secrets.GITHUB_TOKEN }}
build-script: build:website:en build-script: build:website:en
clean-script: clear:website # see https://github.com/facebook/docusaurus/pull/6838 clean-script: clear:website # see https://github.com/facebook/docusaurus/pull/6838
pattern: '{website/build/assets/js/main*js,website/build/assets/css/styles*css,website/.docusaurus/globalData.json,website/build/index.html,website/build/blog/index.html,website/build/blog/**/introducing-docusaurus/*,website/build/docs/index.html,website/build/docs/installation/index.html,website/build/tests/docs/index.html,website/build/tests/docs/standalone/index.html}' pattern: '{website/build/assets/js/main*js,website/build/assets/css/styles*css,website/.docusaurus/globalData.json,website/.docusaurus/registry.js,website/.docusaurus/routes.js,website/.docusaurus/routesChunkNames.json,website/.docusaurus/site-metadata.json,website/.docusaurus/codeTranslations.json,website/.docusaurus/i18n.json,website/.docusaurus/docusaurus.config.mjs,website/build/index.html,website/build/blog/index.html,website/build/blog/**/introducing-docusaurus/*,website/build/docs/index.html,website/build/docs/installation/index.html,website/build/tests/docs/index.html,website/build/tests/docs/standalone/index.html}'
strip-hash: '\.([^;]\w{7})\.' strip-hash: '\.([^;]\w{7})\.'
minimum-change-threshold: 30 minimum-change-threshold: 30
compression: none compression: none

View file

@ -56,6 +56,7 @@ exports[`translateContent falls back when translation is incomplete 1`] = `
"source": "/blog/2021/06/19/hello", "source": "/blog/2021/06/19/hello",
"tags": [], "tags": [],
"title": "Hello", "title": "Hello",
"unlisted": false,
}, },
}, },
], ],
@ -99,6 +100,7 @@ exports[`translateContent returns translated loaded 1`] = `
"source": "/blog/2021/06/19/hello", "source": "/blog/2021/06/19/hello",
"tags": [], "tags": [],
"title": "Hello", "title": "Hello",
"unlisted": false,
}, },
}, },
], ],

View file

@ -34,6 +34,7 @@ const sampleBlogPosts: BlogPost[] = [
hasTruncateMarker: true, hasTruncateMarker: true,
authors: [], authors: [],
frontMatter: {}, frontMatter: {},
unlisted: false,
}, },
content: '', content: '',
}, },

View file

@ -11,7 +11,6 @@ import {
normalizeUrl, normalizeUrl,
docuHash, docuHash,
aliasedSitePath, aliasedSitePath,
aliasedSitePathToRelativePath,
getPluginI18nPath, getPluginI18nPath,
posixPath, posixPath,
addTrailingPathSeparator, addTrailingPathSeparator,
@ -32,24 +31,17 @@ import footnoteIDFixer from './remark/footnoteIDFixer';
import {translateContent, getTranslationFiles} from './translations'; import {translateContent, getTranslationFiles} from './translations';
import {createBlogFeedFiles} from './feed'; import {createBlogFeedFiles} from './feed';
import {toTagProp, toTagsProp} from './props'; import {createAllRoutes} from './routes';
import type {BlogContentPaths, BlogMarkdownLoaderOptions} from './types'; import type {BlogContentPaths, BlogMarkdownLoaderOptions} from './types';
import type { import type {LoadContext, Plugin, HtmlTags} from '@docusaurus/types';
LoadContext,
Plugin,
HtmlTags,
RouteMetadata,
} from '@docusaurus/types';
import type { import type {
PluginOptions, PluginOptions,
BlogPostFrontMatter, BlogPostFrontMatter,
BlogPostMetadata, BlogPostMetadata,
Assets, Assets,
BlogTag,
BlogTags, BlogTags,
BlogContent, BlogContent,
BlogPaginated, BlogPaginated,
BlogMetadata,
} from '@docusaurus/plugin-content-blog'; } from '@docusaurus/plugin-content-blog';
export default async function pluginContentBlog( export default async function pluginContentBlog(
@ -80,6 +72,9 @@ export default async function pluginContentBlog(
'docusaurus-plugin-content-blog', 'docusaurus-plugin-content-blog',
); );
const dataDir = path.join(pluginDataDirRoot, pluginId); const dataDir = path.join(pluginDataDirRoot, pluginId);
// TODO Docusaurus v4 breaking change
// module aliasing should be automatic
// we should never find local absolute FS paths in the codegen registry
const aliasedSource = (source: string) => const aliasedSource = (source: string) =>
`~blog/${posixPath(path.relative(pluginDataDirRoot, source))}`; `~blog/${posixPath(path.relative(pluginDataDirRoot, source))}`;
@ -185,213 +180,14 @@ export default async function pluginContentBlog(
}; };
}, },
async contentLoaded({content: blogContents, actions}) { async contentLoaded({content, actions}) {
const { await createAllRoutes({
blogListComponent, baseUrl,
blogPostComponent, content,
blogTagsListComponent, actions,
blogTagsPostsComponent, options,
blogArchiveComponent, aliasedSource,
routeBasePath, });
archiveBasePath,
blogTitle,
} = options;
const {addRoute, createData} = actions;
const {
blogSidebarTitle,
blogPosts,
blogListPaginated,
blogTags,
blogTagsListPath,
} = blogContents;
const listedBlogPosts = blogPosts.filter(shouldBeListed);
const blogItemsToMetadata: {[postId: string]: BlogPostMetadata} = {};
const sidebarBlogPosts =
options.blogSidebarCount === 'ALL'
? blogPosts
: blogPosts.slice(0, options.blogSidebarCount);
function blogPostItemsModule(items: string[]) {
return items.map((postId) => {
const blogPostMetadata = blogItemsToMetadata[postId]!;
return {
content: {
__import: true,
path: blogPostMetadata.source,
query: {
truncated: true,
},
},
};
});
}
if (archiveBasePath && listedBlogPosts.length) {
const archiveUrl = normalizeUrl([
baseUrl,
routeBasePath,
archiveBasePath,
]);
// Create a blog archive route
const archiveProp = await createData(
`${docuHash(archiveUrl)}.json`,
JSON.stringify({blogPosts: listedBlogPosts}, null, 2),
);
addRoute({
path: archiveUrl,
component: blogArchiveComponent,
exact: true,
modules: {
archive: aliasedSource(archiveProp),
},
});
}
// This prop is useful to provide the blog list sidebar
const sidebarProp = await createData(
// Note that this created data path must be in sync with
// metadataPath provided to mdx-loader.
`blog-post-list-prop-${pluginId}.json`,
JSON.stringify(
{
title: blogSidebarTitle,
items: sidebarBlogPosts.map((blogPost) => ({
title: blogPost.metadata.title,
permalink: blogPost.metadata.permalink,
unlisted: blogPost.metadata.unlisted,
})),
},
null,
2,
),
);
const blogMetadata: BlogMetadata = {
blogBasePath: normalizeUrl([baseUrl, routeBasePath]),
blogTitle,
};
const blogMetadataPath = await createData(
`blogMetadata-${pluginId}.json`,
JSON.stringify(blogMetadata, null, 2),
);
function createBlogPostRouteMetadata(
blogPostMeta: BlogPostMetadata,
): RouteMetadata {
return {
sourceFilePath: aliasedSitePathToRelativePath(blogPostMeta.source),
lastUpdatedAt: blogPostMeta.lastUpdatedAt,
};
}
// Create routes for blog entries.
await Promise.all(
blogPosts.map(async (blogPost) => {
const {id, metadata} = blogPost;
await createData(
// Note that this created data path must be in sync with
// metadataPath provided to mdx-loader.
`${docuHash(metadata.source)}.json`,
JSON.stringify(metadata, null, 2),
);
addRoute({
path: metadata.permalink,
component: blogPostComponent,
exact: true,
modules: {
sidebar: aliasedSource(sidebarProp),
content: metadata.source,
},
metadata: createBlogPostRouteMetadata(metadata),
context: {
blogMetadata: aliasedSource(blogMetadataPath),
},
});
blogItemsToMetadata[id] = metadata;
}),
);
// Create routes for blog's paginated list entries.
await Promise.all(
blogListPaginated.map(async (listPage) => {
const {metadata, items} = listPage;
const {permalink} = metadata;
const pageMetadataPath = await createData(
`${docuHash(permalink)}.json`,
JSON.stringify(metadata, null, 2),
);
addRoute({
path: permalink,
component: blogListComponent,
exact: true,
modules: {
sidebar: aliasedSource(sidebarProp),
items: blogPostItemsModule(items),
metadata: aliasedSource(pageMetadataPath),
},
});
}),
);
// Tags. This is the last part so we early-return if there are no tags.
if (Object.keys(blogTags).length === 0) {
return;
}
async function createTagsListPage() {
const tagsPropPath = await createData(
`${docuHash(`${blogTagsListPath}-tags`)}.json`,
JSON.stringify(toTagsProp({blogTags}), null, 2),
);
addRoute({
path: blogTagsListPath,
component: blogTagsListComponent,
exact: true,
modules: {
sidebar: aliasedSource(sidebarProp),
tags: aliasedSource(tagsPropPath),
},
});
}
async function createTagPostsListPage(tag: BlogTag): Promise<void> {
await Promise.all(
tag.pages.map(async (blogPaginated) => {
const {metadata, items} = blogPaginated;
const tagPropPath = await createData(
`${docuHash(metadata.permalink)}.json`,
JSON.stringify(toTagProp({tag, blogTagsListPath}), null, 2),
);
const listMetadataPath = await createData(
`${docuHash(metadata.permalink)}-list.json`,
JSON.stringify(metadata, null, 2),
);
addRoute({
path: metadata.permalink,
component: blogTagsPostsComponent,
exact: true,
modules: {
sidebar: aliasedSource(sidebarProp),
items: blogPostItemsModule(items),
tag: aliasedSource(tagPropPath),
listMetadata: aliasedSource(listMetadataPath),
},
});
}),
);
}
await createTagsListPage();
await Promise.all(Object.values(blogTags).map(createTagPostsListPage));
}, },
translateContent({content, translationFiles}) { translateContent({content, translationFiles}) {

View file

@ -0,0 +1,263 @@
/**
* 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 {
normalizeUrl,
docuHash,
aliasedSitePathToRelativePath,
} from '@docusaurus/utils';
import {shouldBeListed} from './blogUtils';
import {toTagProp, toTagsProp} from './props';
import type {
PluginContentLoadedActions,
RouteConfig,
RouteMetadata,
} from '@docusaurus/types';
import type {
BlogPostMetadata,
BlogTag,
BlogMetadata,
BlogContent,
PluginOptions,
BlogPost,
BlogSidebar,
} from '@docusaurus/plugin-content-blog';
type CreateAllRoutesParam = {
baseUrl: string;
content: BlogContent;
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);
}
export async function buildAllRoutes({
baseUrl,
content,
actions,
options,
aliasedSource,
}: CreateAllRoutesParam): Promise<RouteConfig[]> {
const {
blogListComponent,
blogPostComponent,
blogTagsListComponent,
blogTagsPostsComponent,
blogArchiveComponent,
routeBasePath,
archiveBasePath,
blogTitle,
} = options;
const pluginId = options.id!;
const {createData} = actions;
const {
blogSidebarTitle,
blogPosts,
blogListPaginated,
blogTags,
blogTagsListPath,
} = content;
const listedBlogPosts = blogPosts.filter(shouldBeListed);
const blogPostsById = _.keyBy(blogPosts, (post) => post.id);
function getBlogPostById(id: string): BlogPost {
const blogPost = blogPostsById[id];
if (!blogPost) {
throw new Error(`unexpected, can't find blog post id=${id}`);
}
return blogPost;
}
const sidebarBlogPosts =
options.blogSidebarCount === 'ALL'
? blogPosts
: blogPosts.slice(0, options.blogSidebarCount);
async function createSidebarModule() {
const sidebar: BlogSidebar = {
title: blogSidebarTitle,
items: sidebarBlogPosts.map((blogPost) => ({
title: blogPost.metadata.title,
permalink: blogPost.metadata.permalink,
unlisted: blogPost.metadata.unlisted,
})),
};
const modulePath = await createData(
`blog-post-list-prop-${pluginId}.json`,
sidebar,
);
return aliasedSource(modulePath);
}
async function createBlogMetadataModule() {
const blogMetadata: BlogMetadata = {
blogBasePath: normalizeUrl([baseUrl, routeBasePath]),
blogTitle,
};
const modulePath = await createData(
`blogMetadata-${pluginId}.json`,
blogMetadata,
);
return aliasedSource(modulePath);
}
// TODO we should have a parent blog route,
// and inject blog metadata + sidebar as a parent context
// unfortunately we can't have a parent route for blog yet
// because if both blog/docs are using routeBasePath /,
// React router config rendering doesn't support that well
const sidebarModulePath = await createSidebarModule();
const blogMetadataModulePath = await createBlogMetadataModule();
function blogPostItemsModule(ids: string[]) {
return ids.map((id) => {
return {
content: {
__import: true,
path: getBlogPostById(id).metadata.source,
query: {
truncated: true,
},
},
};
});
}
function createArchiveRoute(): RouteConfig[] {
if (archiveBasePath && listedBlogPosts.length) {
return [
{
path: normalizeUrl([baseUrl, routeBasePath, archiveBasePath]),
component: blogArchiveComponent,
exact: true,
props: {
archive: {blogPosts: listedBlogPosts},
},
},
];
}
return [];
}
function createBlogPostRouteMetadata(
blogPostMeta: BlogPostMetadata,
): RouteMetadata {
return {
sourceFilePath: aliasedSitePathToRelativePath(blogPostMeta.source),
lastUpdatedAt: blogPostMeta.lastUpdatedAt,
};
}
await Promise.all(
blogPosts.map(async (blogPost) => {
const {metadata} = blogPost;
await createData(
// Note that this created data path must be in sync with
// metadataPath provided to mdx-loader.
`${docuHash(metadata.source)}.json`,
metadata,
);
}),
);
function createBlogPostRoute(blogPost: BlogPost): RouteConfig {
return {
path: blogPost.metadata.permalink,
component: blogPostComponent,
exact: true,
modules: {
sidebar: sidebarModulePath,
content: blogPost.metadata.source,
},
metadata: createBlogPostRouteMetadata(blogPost.metadata),
context: {
blogMetadata: blogMetadataModulePath,
},
};
}
function createBlogPostRoutes(): RouteConfig[] {
return blogPosts.map(createBlogPostRoute);
}
function createBlogPostsPaginatedRoutes(): RouteConfig[] {
return blogListPaginated.map((paginated) => {
return {
path: paginated.metadata.permalink,
component: blogListComponent,
exact: true,
modules: {
sidebar: sidebarModulePath,
items: blogPostItemsModule(paginated.items),
},
props: {
metadata: paginated.metadata,
},
};
});
}
function createTagsRoutes(): RouteConfig[] {
// Tags. This is the last part so we early-return if there are no tags.
if (Object.keys(blogTags).length === 0) {
return [];
}
const tagsListRoute: RouteConfig = {
path: blogTagsListPath,
component: blogTagsListComponent,
exact: true,
modules: {
sidebar: sidebarModulePath,
},
props: {
tags: toTagsProp({blogTags}),
},
};
function createTagPaginatedRoutes(tag: BlogTag): RouteConfig[] {
return tag.pages.map((paginated) => {
return {
path: paginated.metadata.permalink,
component: blogTagsPostsComponent,
exact: true,
modules: {
sidebar: sidebarModulePath,
items: blogPostItemsModule(paginated.items),
},
props: {
tag: toTagProp({tag, blogTagsListPath}),
listMetadata: paginated.metadata,
},
};
});
}
const tagsPaginatedRoutes: RouteConfig[] = Object.values(blogTags).flatMap(
createTagPaginatedRoutes,
);
return [tagsListRoute, ...tagsPaginatedRoutes];
}
return [
...createBlogPostRoutes(),
...createBlogPostsPaginatedRoutes(),
...createTagsRoutes(),
...createArchiveRoute(),
];
}

View file

@ -31,7 +31,6 @@ import type {
Options, Options,
PluginOptions, PluginOptions,
PropSidebarItemLink, PropSidebarItemLink,
PropSidebars,
} from '@docusaurus/plugin-content-docs'; } from '@docusaurus/plugin-content-docs';
import type { import type {
SidebarItemsGeneratorOption, SidebarItemsGeneratorOption,
@ -76,36 +75,11 @@ const createFakeActions = (contentDir: string) => {
}, },
}; };
// Query by prefix, because files have a hash at the end so it's not
// convenient to query by full filename
function getCreatedDataByPrefix(prefix: string) {
const entry = Object.entries(dataContainer).find(([key]) =>
key.startsWith(prefix),
);
if (!entry) {
throw new Error(`No created entry found for prefix "${prefix}".
Entries created:
- ${Object.keys(dataContainer).join('\n- ')}
`);
}
return JSON.parse(entry[1] as string) as PropSidebars;
}
// Extra fns useful for tests! // Extra fns useful for tests!
const utils = { const utils = {
getGlobalData: () => globalDataContainer, getGlobalData: () => globalDataContainer,
getRouteConfigs: () => routeConfigs, getRouteConfigs: () => routeConfigs,
checkVersionMetadataPropCreated: (version: LoadedVersion | undefined) => {
if (!version) {
throw new Error('Version not found');
}
const versionMetadataProp = getCreatedDataByPrefix(
`version-${_.kebabCase(version.versionName)}-metadata-prop`,
);
expect(versionMetadataProp.docsSidebars).toEqual(toSidebarsProp(version));
},
expectSnapshot: () => { expectSnapshot: () => {
// Sort the route config like in src/server/plugins/index.ts for // Sort the route config like in src/server/plugins/index.ts for
// consistent snapshot ordering // consistent snapshot ordering
@ -335,11 +309,8 @@ describe('simple website', () => {
await plugin.contentLoaded!({ await plugin.contentLoaded!({
content, content,
actions, actions,
allContent: {},
}); });
utils.checkVersionMetadataPropCreated(currentVersion);
utils.expectSnapshot(); utils.expectSnapshot();
expect(utils.getGlobalData()).toMatchSnapshot(); expect(utils.getGlobalData()).toMatchSnapshot();
@ -464,14 +435,8 @@ describe('versioned website', () => {
await plugin.contentLoaded!({ await plugin.contentLoaded!({
content, content,
actions, actions,
allContent: {},
}); });
utils.checkVersionMetadataPropCreated(currentVersion);
utils.checkVersionMetadataPropCreated(version101);
utils.checkVersionMetadataPropCreated(version100);
utils.checkVersionMetadataPropCreated(versionWithSlugs);
utils.expectSnapshot(); utils.expectSnapshot();
}); });
}); });
@ -569,12 +534,8 @@ describe('versioned website (community)', () => {
await plugin.contentLoaded!({ await plugin.contentLoaded!({
content, content,
actions, actions,
allContent: {},
}); });
utils.checkVersionMetadataPropCreated(currentVersion);
utils.checkVersionMetadataPropCreated(version100);
utils.expectSnapshot(); utils.expectSnapshot();
}); });
}); });

View file

@ -73,6 +73,9 @@ export default async function pluginContentDocs(
'docusaurus-plugin-content-docs', 'docusaurus-plugin-content-docs',
); );
const dataDir = path.join(pluginDataDirRoot, pluginId); const dataDir = path.join(pluginDataDirRoot, pluginId);
// TODO Docusaurus v4 breaking change
// module aliasing should be automatic
// we should never find local absolute FS paths in the codegen registry
const aliasedSource = (source: string) => const aliasedSource = (source: string) =>
`~docs/${posixPath(path.relative(pluginDataDirRoot, source))}`; `~docs/${posixPath(path.relative(pluginDataDirRoot, source))}`;

View file

@ -9,7 +9,6 @@ import _ from 'lodash';
import logger from '@docusaurus/logger'; import logger from '@docusaurus/logger';
import { import {
docuHash, docuHash,
createSlugger,
normalizeUrl, normalizeUrl,
aliasedSitePathToRelativePath, aliasedSitePathToRelativePath,
} from '@docusaurus/utils'; } from '@docusaurus/utils';
@ -29,7 +28,6 @@ import type {
CategoryGeneratedIndexMetadata, CategoryGeneratedIndexMetadata,
DocMetadata, DocMetadata,
PluginOptions, PluginOptions,
PropTagsListPage,
} from '@docusaurus/plugin-content-docs'; } from '@docusaurus/plugin-content-docs';
function createDocRouteMetadata(docMeta: DocMetadata): RouteMetadata { function createDocRouteMetadata(docMeta: DocMetadata): RouteMetadata {
@ -41,36 +39,23 @@ function createDocRouteMetadata(docMeta: DocMetadata): RouteMetadata {
async function buildVersionCategoryGeneratedIndexRoutes({ async function buildVersionCategoryGeneratedIndexRoutes({
version, version,
actions,
options, options,
aliasedSource,
}: BuildVersionRoutesParam): Promise<RouteConfig[]> { }: BuildVersionRoutesParam): Promise<RouteConfig[]> {
const slugs = createSlugger();
async function buildCategoryGeneratedIndexRoute( async function buildCategoryGeneratedIndexRoute(
categoryGeneratedIndex: CategoryGeneratedIndexMetadata, categoryGeneratedIndex: CategoryGeneratedIndexMetadata,
): Promise<RouteConfig> { ): Promise<RouteConfig> {
const {sidebar, ...prop} = categoryGeneratedIndex;
const propFileName = slugs.slug(
`${version.path}-${categoryGeneratedIndex.sidebar}-category-${categoryGeneratedIndex.title}`,
);
const propData = await actions.createData(
`${docuHash(`category/${propFileName}`)}.json`,
JSON.stringify(prop, null, 2),
);
return { return {
path: categoryGeneratedIndex.permalink, path: categoryGeneratedIndex.permalink,
component: options.docCategoryGeneratedIndexComponent, component: options.docCategoryGeneratedIndexComponent,
exact: true, exact: true,
modules: { props: {
categoryGeneratedIndex: aliasedSource(propData), categoryGeneratedIndex,
}, },
// Same as doc, this sidebar route attribute permits to associate this // Same as doc, this sidebar route attribute permits to associate this
// subpage to the given sidebar // subpage to the given sidebar
...(sidebar && {sidebar}), ...(categoryGeneratedIndex.sidebar && {
sidebar: categoryGeneratedIndex.sidebar,
}),
}; };
} }
@ -90,7 +75,7 @@ async function buildVersionDocRoutes({
// Note that this created data path must be in sync with // Note that this created data path must be in sync with
// metadataPath provided to mdx-loader. // metadataPath provided to mdx-loader.
`${docuHash(doc.source)}.json`, `${docuHash(doc.source)}.json`,
JSON.stringify(doc, null, 2), doc,
); );
const docRoute: RouteConfig = { const docRoute: RouteConfig = {
@ -131,7 +116,7 @@ async function buildVersionSidebarRoute(param: BuildVersionRoutesParam) {
async function buildVersionTagsRoutes( async function buildVersionTagsRoutes(
param: BuildVersionRoutesParam, param: BuildVersionRoutesParam,
): Promise<RouteConfig[]> { ): Promise<RouteConfig[]> {
const {version, options, actions, aliasedSource} = param; const {version, options} = param;
const versionTags = getVersionTags(version.docs); const versionTags = getVersionTags(version.docs);
async function buildTagsListRoute(): Promise<RouteConfig | null> { async function buildTagsListRoute(): Promise<RouteConfig | null> {
@ -139,37 +124,27 @@ async function buildVersionTagsRoutes(
if (Object.keys(versionTags).length === 0) { if (Object.keys(versionTags).length === 0) {
return null; return null;
} }
const tagsProp: PropTagsListPage['tags'] = toTagsListTagsProp(versionTags);
const tagsPropPath = await actions.createData(
`${docuHash(`tags-list-${version.versionName}-prop`)}.json`,
JSON.stringify(tagsProp, null, 2),
);
return { return {
path: version.tagsPath, path: version.tagsPath,
exact: true, exact: true,
component: options.docTagsListComponent, component: options.docTagsListComponent,
modules: { props: {
tags: aliasedSource(tagsPropPath), tags: toTagsListTagsProp(versionTags),
}, },
}; };
} }
async function buildTagDocListRoute(tag: VersionTag): Promise<RouteConfig> { 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 { return {
path: tag.permalink, path: tag.permalink,
component: options.docTagDocListComponent, component: options.docTagDocListComponent,
exact: true, exact: true,
modules: { props: {
tag: aliasedSource(tagPropPath), tag: toTagDocListProp({
allTagsPath: version.tagsPath,
tag,
docs: version.docs,
}),
}, },
}; };
} }
@ -189,7 +164,7 @@ type BuildVersionRoutesParam = Omit<BuildAllRoutesParam, 'versions'> & {
async function buildVersionRoutes( async function buildVersionRoutes(
param: BuildVersionRoutesParam, param: BuildVersionRoutesParam,
): Promise<RouteConfig> { ): Promise<RouteConfig> {
const {version, actions, options, aliasedSource} = param; const {version, options} = param;
async function buildVersionSubRoutes() { async function buildVersionSubRoutes() {
const [sidebarRoute, tagsRoutes] = await Promise.all([ const [sidebarRoute, tagsRoutes] = await Promise.all([
@ -201,19 +176,15 @@ async function buildVersionRoutes(
} }
async function doBuildVersionRoutes(): Promise<RouteConfig> { 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 { return {
path: version.path, path: version.path,
exact: false, exact: false,
component: options.docVersionRootComponent, component: options.docVersionRootComponent,
routes: subRoutes, routes: await buildVersionSubRoutes(),
modules: { props: {
version: aliasedSource(versionPropPath), // TODO Docusaurus v4 breaking change?
// expose version metadata as route context instead of props
version: toVersionMetadataProp(options.id, version),
}, },
priority: version.routePriority, priority: version.routePriority,
}; };

View file

@ -0,0 +1,194 @@
/**
* 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 fs from 'fs-extra';
import path from 'path';
import {
encodePath,
fileToPath,
aliasedSitePath,
getFolderContainingFile,
Globby,
normalizeUrl,
parseMarkdownFile,
isUnlisted,
isDraft,
readLastUpdateData,
getEditUrl,
posixPath,
getPluginI18nPath,
} from '@docusaurus/utils';
import {validatePageFrontMatter} from './frontMatter';
import type {LoadContext} from '@docusaurus/types';
import type {PagesContentPaths} from './types';
import type {
PluginOptions,
Metadata,
LoadedContent,
} from '@docusaurus/plugin-content-pages';
export function createPagesContentPaths({
context,
options,
}: {
context: LoadContext;
options: PluginOptions;
}): PagesContentPaths {
const {siteDir, localizationDir} = context;
return {
contentPath: path.resolve(siteDir, options.path),
contentPathLocalized: getPluginI18nPath({
localizationDir,
pluginName: 'docusaurus-plugin-content-pages',
pluginId: options.id,
}),
};
}
export function getContentPathList(contentPaths: PagesContentPaths): string[] {
return [contentPaths.contentPathLocalized, contentPaths.contentPath];
}
const isMarkdownSource = (source: string) =>
source.endsWith('.md') || source.endsWith('.mdx');
type LoadContentParams = {
context: LoadContext;
options: PluginOptions;
contentPaths: PagesContentPaths;
};
export async function loadPagesContent(
params: LoadContentParams,
): Promise<LoadedContent> {
const {options} = params;
const pagesFiles = await Globby(params.options.include, {
cwd: params.contentPaths.contentPath,
ignore: options.exclude,
});
async function doProcessPageSourceFile(relativeSource: string) {
try {
return await processPageSourceFile(relativeSource, params);
} catch (err) {
throw new Error(
`Processing of page source file path=${relativeSource} failed.`,
{cause: err as Error},
);
}
}
return (await Promise.all(pagesFiles.map(doProcessPageSourceFile))).filter(
(res): res is Metadata => {
return res !== undefined;
},
);
}
async function processPageSourceFile(
relativeSource: string,
params: LoadContentParams,
): Promise<Metadata | undefined> {
const {context, options, contentPaths} = params;
const {siteConfig, baseUrl, siteDir, i18n} = context;
const {editUrl} = options;
// Lookup in localized folder in priority
const contentPath = await getFolderContainingFile(
getContentPathList(contentPaths),
relativeSource,
);
const source = path.join(contentPath, relativeSource);
const aliasedSourcePath = aliasedSitePath(source, siteDir);
const permalink = normalizeUrl([
baseUrl,
options.routeBasePath,
encodePath(fileToPath(relativeSource)),
]);
if (!isMarkdownSource(relativeSource)) {
return {
type: 'jsx',
permalink,
source: aliasedSourcePath,
};
}
const content = await fs.readFile(source, 'utf-8');
const {
frontMatter: unsafeFrontMatter,
contentTitle,
excerpt,
} = await parseMarkdownFile({
filePath: source,
fileContent: content,
parseFrontMatter: siteConfig.markdown.parseFrontMatter,
});
const frontMatter = validatePageFrontMatter(unsafeFrontMatter);
const pagesDirPath = await getFolderContainingFile(
getContentPathList(contentPaths),
relativeSource,
);
const pagesSourceAbsolute = path.join(pagesDirPath, relativeSource);
function getPagesEditUrl() {
const pagesPathRelative = path.relative(
pagesDirPath,
path.resolve(pagesSourceAbsolute),
);
if (typeof editUrl === 'function') {
return editUrl({
pagesDirPath: posixPath(path.relative(siteDir, pagesDirPath)),
pagesPath: posixPath(pagesPathRelative),
permalink,
locale: i18n.currentLocale,
});
} else if (typeof editUrl === 'string') {
const isLocalized = pagesDirPath === contentPaths.contentPathLocalized;
const fileContentPath =
isLocalized && options.editLocalizedFiles
? contentPaths.contentPathLocalized
: contentPaths.contentPath;
const contentPathEditUrl = normalizeUrl([
editUrl,
posixPath(path.relative(siteDir, fileContentPath)),
]);
return getEditUrl(pagesPathRelative, contentPathEditUrl);
}
return undefined;
}
const lastUpdatedData = await readLastUpdateData(
source,
options,
frontMatter.last_update,
);
if (isDraft({frontMatter})) {
return undefined;
}
const unlisted = isUnlisted({frontMatter});
return {
type: 'mdx',
permalink,
source: aliasedSourcePath,
title: frontMatter.title ?? contentTitle,
description: frontMatter.description ?? excerpt,
frontMatter,
lastUpdatedBy: lastUpdatedData.lastUpdatedBy,
lastUpdatedAt: lastUpdatedData.lastUpdatedAt,
editUrl: getPagesEditUrl(),
unlisted,
};
}

View file

@ -8,57 +8,32 @@
import fs from 'fs-extra'; import fs from 'fs-extra';
import path from 'path'; import path from 'path';
import { import {
encodePath,
fileToPath,
aliasedSitePath, aliasedSitePath,
aliasedSitePathToRelativePath,
docuHash, docuHash,
getPluginI18nPath,
getFolderContainingFile,
addTrailingPathSeparator, addTrailingPathSeparator,
Globby,
createAbsoluteFilePathMatcher, createAbsoluteFilePathMatcher,
normalizeUrl,
DEFAULT_PLUGIN_ID, DEFAULT_PLUGIN_ID,
parseMarkdownFile,
isUnlisted,
isDraft,
readLastUpdateData,
getEditUrl,
posixPath,
} from '@docusaurus/utils'; } from '@docusaurus/utils';
import {validatePageFrontMatter} from './frontMatter'; import {createAllRoutes} from './routes';
import type {LoadContext, Plugin, RouteMetadata} from '@docusaurus/types'; import {
import type {PagesContentPaths} from './types'; createPagesContentPaths,
getContentPathList,
loadPagesContent,
} from './content';
import type {LoadContext, Plugin} from '@docusaurus/types';
import type { import type {
PluginOptions, PluginOptions,
Metadata,
LoadedContent, LoadedContent,
PageFrontMatter, PageFrontMatter,
} from '@docusaurus/plugin-content-pages'; } from '@docusaurus/plugin-content-pages';
export function getContentPathList(contentPaths: PagesContentPaths): string[] {
return [contentPaths.contentPathLocalized, contentPaths.contentPath];
}
const isMarkdownSource = (source: string) =>
source.endsWith('.md') || source.endsWith('.mdx');
export default function pluginContentPages( export default function pluginContentPages(
context: LoadContext, context: LoadContext,
options: PluginOptions, options: PluginOptions,
): Plugin<LoadedContent | null> { ): Plugin<LoadedContent | null> {
const {siteConfig, siteDir, generatedFilesDir, localizationDir, i18n} = const {siteConfig, siteDir, generatedFilesDir} = context;
context;
const contentPaths: PagesContentPaths = { const contentPaths = createPagesContentPaths({context, options});
contentPath: path.resolve(siteDir, options.path),
contentPathLocalized: getPluginI18nPath({
localizationDir,
pluginName: 'docusaurus-plugin-content-pages',
pluginId: options.id,
}),
};
const pluginDataDirRoot = path.join( const pluginDataDirRoot = path.join(
generatedFilesDir, generatedFilesDir,
@ -77,182 +52,17 @@ export default function pluginContentPages(
}, },
async loadContent() { async loadContent() {
const {include, editUrl} = options;
if (!(await fs.pathExists(contentPaths.contentPath))) { if (!(await fs.pathExists(contentPaths.contentPath))) {
return null; return null;
} }
return loadPagesContent({context, options, contentPaths});
const {baseUrl} = siteConfig;
const pagesFiles = await Globby(include, {
cwd: contentPaths.contentPath,
ignore: options.exclude,
});
async function processPageSourceFile(
relativeSource: string,
): Promise<Metadata | undefined> {
// Lookup in localized folder in priority
const contentPath = await getFolderContainingFile(
getContentPathList(contentPaths),
relativeSource,
);
const source = path.join(contentPath, relativeSource);
const aliasedSourcePath = aliasedSitePath(source, siteDir);
const permalink = normalizeUrl([
baseUrl,
options.routeBasePath,
encodePath(fileToPath(relativeSource)),
]);
if (!isMarkdownSource(relativeSource)) {
return {
type: 'jsx',
permalink,
source: aliasedSourcePath,
};
}
const content = await fs.readFile(source, 'utf-8');
const {
frontMatter: unsafeFrontMatter,
contentTitle,
excerpt,
} = await parseMarkdownFile({
filePath: source,
fileContent: content,
parseFrontMatter: siteConfig.markdown.parseFrontMatter,
});
const frontMatter = validatePageFrontMatter(unsafeFrontMatter);
const pagesDirPath = await getFolderContainingFile(
getContentPathList(contentPaths),
relativeSource,
);
const pagesSourceAbsolute = path.join(pagesDirPath, relativeSource);
function getPagesEditUrl() {
const pagesPathRelative = path.relative(
pagesDirPath,
path.resolve(pagesSourceAbsolute),
);
if (typeof editUrl === 'function') {
return editUrl({
pagesDirPath: posixPath(path.relative(siteDir, pagesDirPath)),
pagesPath: posixPath(pagesPathRelative),
permalink,
locale: i18n.currentLocale,
});
} else if (typeof editUrl === 'string') {
const isLocalized =
pagesDirPath === contentPaths.contentPathLocalized;
const fileContentPath =
isLocalized && options.editLocalizedFiles
? contentPaths.contentPathLocalized
: contentPaths.contentPath;
const contentPathEditUrl = normalizeUrl([
editUrl,
posixPath(path.relative(siteDir, fileContentPath)),
]);
return getEditUrl(pagesPathRelative, contentPathEditUrl);
}
return undefined;
}
const lastUpdatedData = await readLastUpdateData(
source,
options,
frontMatter.last_update,
);
if (isDraft({frontMatter})) {
return undefined;
}
const unlisted = isUnlisted({frontMatter});
return {
type: 'mdx',
permalink,
source: aliasedSourcePath,
title: frontMatter.title ?? contentTitle,
description: frontMatter.description ?? excerpt,
frontMatter,
lastUpdatedBy: lastUpdatedData.lastUpdatedBy,
lastUpdatedAt: lastUpdatedData.lastUpdatedAt,
editUrl: getPagesEditUrl(),
unlisted,
};
}
async function doProcessPageSourceFile(relativeSource: string) {
try {
return await processPageSourceFile(relativeSource);
} catch (err) {
throw new Error(
`Processing of page source file path=${relativeSource} failed.`,
{cause: err as Error},
);
}
}
return (
await Promise.all(pagesFiles.map(doProcessPageSourceFile))
).filter(Boolean) as Metadata[];
}, },
async contentLoaded({content, actions}) { async contentLoaded({content, actions}) {
if (!content) { if (!content) {
return; return;
} }
await createAllRoutes({content, options, actions});
const {addRoute, createData} = actions;
function createPageRouteMetadata(metadata: Metadata): RouteMetadata {
const lastUpdatedAt =
metadata.type === 'mdx' ? metadata.lastUpdatedAt : undefined;
return {
sourceFilePath: aliasedSitePathToRelativePath(metadata.source),
lastUpdatedAt,
};
}
await Promise.all(
content.map(async (metadata) => {
const {permalink, source} = metadata;
const routeMetadata = createPageRouteMetadata(metadata);
if (metadata.type === 'mdx') {
await createData(
// Note that this created data path must be in sync with
// metadataPath provided to mdx-loader.
`${docuHash(metadata.source)}.json`,
JSON.stringify(metadata, null, 2),
);
addRoute({
path: permalink,
component: options.mdxPageComponent,
exact: true,
metadata: routeMetadata,
modules: {
content: source,
},
});
} else {
addRoute({
path: permalink,
component: source,
exact: true,
metadata: routeMetadata,
modules: {
config: `@generated/docusaurus.config`,
},
});
}
}),
);
}, },
configureWebpack() { configureWebpack() {
@ -265,11 +75,6 @@ export default function pluginContentPages(
} = options; } = options;
const contentDirs = getContentPathList(contentPaths); const contentDirs = getContentPathList(contentPaths);
return { return {
resolve: {
alias: {
'~pages': pluginDataDirRoot,
},
},
module: { module: {
rules: [ rules: [
{ {

View file

@ -0,0 +1,89 @@
/**
* 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 {aliasedSitePathToRelativePath, docuHash} from '@docusaurus/utils';
import type {
PluginContentLoadedActions,
RouteConfig,
RouteMetadata,
} from '@docusaurus/types';
import type {
PluginOptions,
Metadata,
LoadedContent,
MDXPageMetadata,
} from '@docusaurus/plugin-content-pages';
type CreateAllRoutesParam = {
content: LoadedContent;
options: PluginOptions;
actions: PluginContentLoadedActions;
};
function createPageRouteMetadata(metadata: Metadata): RouteMetadata {
const lastUpdatedAt =
metadata.type === 'mdx' ? metadata.lastUpdatedAt : undefined;
return {
sourceFilePath: aliasedSitePathToRelativePath(metadata.source),
lastUpdatedAt,
};
}
export async function createAllRoutes(
param: CreateAllRoutesParam,
): Promise<void> {
const routes = await buildAllRoutes(param);
routes.forEach(param.actions.addRoute);
}
export async function buildAllRoutes({
content,
actions,
options,
}: CreateAllRoutesParam): Promise<RouteConfig[]> {
const {createData} = actions;
async function buildMDXPageRoute(
metadata: MDXPageMetadata,
): Promise<RouteConfig> {
await createData(
// Note that this created data path must be in sync with
// metadataPath provided to mdx-loader.
`${docuHash(metadata.source)}.json`,
metadata,
);
return {
path: metadata.permalink,
component: options.mdxPageComponent,
exact: true,
metadata: createPageRouteMetadata(metadata),
modules: {
content: metadata.source,
},
};
}
async function buildJSXRoute(metadata: Metadata): Promise<RouteConfig> {
return {
path: metadata.permalink,
component: metadata.source,
exact: true,
metadata: createPageRouteMetadata(metadata),
modules: {
config: `@generated/docusaurus.config`,
},
};
}
async function buildPageRoute(metadata: Metadata): Promise<RouteConfig> {
return metadata.type === 'mdx'
? buildMDXPageRoute(metadata)
: buildJSXRoute(metadata);
}
return Promise.all(content.map(buildPageRoute));
}

View file

@ -5,21 +5,12 @@
* LICENSE file in the root directory of this source tree. * LICENSE file in the root directory of this source tree.
*/ */
import path from 'path'; import {normalizeUrl} from '@docusaurus/utils';
import {docuHash, normalizeUrl, posixPath} from '@docusaurus/utils';
import type {LoadContext, Plugin} from '@docusaurus/types'; import type {LoadContext, Plugin} from '@docusaurus/types';
export default function pluginDebug({ export default function pluginDebug({
siteConfig: {baseUrl}, siteConfig: {baseUrl},
generatedFilesDir,
}: LoadContext): Plugin<undefined> { }: LoadContext): Plugin<undefined> {
const pluginDataDirRoot = path.join(
generatedFilesDir,
'docusaurus-plugin-debug',
);
const aliasedSource = (source: string) =>
`~debug/${posixPath(path.relative(pluginDataDirRoot, source))}`;
return { return {
name: 'docusaurus-plugin-debug', name: 'docusaurus-plugin-debug',
@ -30,14 +21,7 @@ export default function pluginDebug({
return '../src/theme'; return '../src/theme';
}, },
async allContentLoaded({actions: {createData, addRoute}, allContent}) { async allContentLoaded({actions: {addRoute}, allContent}) {
const allContentPath = await createData(
// Note that this created data path must be in sync with
// metadataPath provided to mdx-loader.
`${docuHash('docusaurus-debug-allContent')}.json`,
JSON.stringify(allContent, null, 2),
);
// Home is config (duplicate for now) // Home is config (duplicate for now)
addRoute({ addRoute({
path: normalizeUrl([baseUrl, '__docusaurus/debug']), path: normalizeUrl([baseUrl, '__docusaurus/debug']),
@ -73,8 +57,8 @@ export default function pluginDebug({
path: normalizeUrl([baseUrl, '__docusaurus/debug/content']), path: normalizeUrl([baseUrl, '__docusaurus/debug/content']),
component: '@theme/DebugContent', component: '@theme/DebugContent',
exact: true, exact: true,
modules: { props: {
allContent: aliasedSource(allContentPath), allContent,
}, },
}); });
@ -84,15 +68,5 @@ export default function pluginDebug({
exact: true, exact: true,
}); });
}, },
configureWebpack() {
return {
resolve: {
alias: {
'~debug': pluginDataDirRoot,
},
},
};
},
}; };
} }

View file

@ -7,7 +7,7 @@
import type {DocusaurusConfig} from './config'; import type {DocusaurusConfig} from './config';
import type {CodeTranslations, I18n} from './i18n'; import type {CodeTranslations, I18n} from './i18n';
import type {LoadedPlugin, PluginVersionInformation} from './plugin'; import type {LoadedPlugin, PluginVersionInformation} from './plugin';
import type {RouteConfig} from './routing'; import type {PluginRouteConfig} from './routing';
export type DocusaurusContext = { export type DocusaurusContext = {
siteConfig: DocusaurusConfig; siteConfig: DocusaurusConfig;
@ -57,7 +57,7 @@ export type Props = LoadContext & {
preBodyTags: string; preBodyTags: string;
postBodyTags: string; postBodyTags: string;
siteMetadata: SiteMetadata; siteMetadata: SiteMetadata;
routes: RouteConfig[]; routes: PluginRouteConfig[];
routesPaths: string[]; routesPaths: string[];
plugins: LoadedPlugin[]; plugins: LoadedPlugin[];
}; };

View file

@ -70,6 +70,7 @@ export {
export { export {
RouteConfig, RouteConfig,
PluginRouteConfig,
RouteMetadata, RouteMetadata,
RouteContext, RouteContext,
PluginRouteContext, PluginRouteContext,

View file

@ -49,7 +49,7 @@ export type PluginVersionInformation =
export type PluginContentLoadedActions = { export type PluginContentLoadedActions = {
addRoute: (config: RouteConfig) => void; addRoute: (config: RouteConfig) => void;
createData: (name: string, data: string) => Promise<string>; createData: (name: string, data: string | object) => Promise<string>;
setGlobalData: (data: unknown) => void; setGlobalData: (data: unknown) => void;
}; };

View file

@ -6,6 +6,7 @@
*/ */
import type {ParsedUrlQueryInput} from 'querystring'; import type {ParsedUrlQueryInput} from 'querystring';
import type {PluginIdentifier} from './plugin';
/** /**
* A "module" represents a unit of serialized data emitted from the plugin. It * A "module" represents a unit of serialized data emitted from the plugin. It
@ -110,9 +111,23 @@ export type RouteConfig = {
*/ */
metadata?: RouteMetadata; metadata?: RouteMetadata;
/** /**
* Extra props; will be available on the client side. * Optional props object; will be converted to a module and injected as props
* into the route component.
*/ */
[propName: string]: unknown; props?: {[propName: string]: unknown};
/**
* Extra route attribute; will be available on the client side route object.
*/
[attributeName: string]: unknown;
};
export type PluginRouteConfig = RouteConfig & {
/**
* Routes are always created by Docusaurus plugins
* A plugin identifier is available at the top of a routing tree
* (child routes are implicitly created by the same plugin as their parent)
*/
plugin: PluginIdentifier;
}; };
export type RouteContext = { export type RouteContext = {

View file

@ -85,7 +85,8 @@ export default function ComponentCreator(
const loadedModules = JSON.parse(JSON.stringify(chunkNames)) as { const loadedModules = JSON.parse(JSON.stringify(chunkNames)) as {
__comp?: React.ComponentType<object>; __comp?: React.ComponentType<object>;
__context?: RouteContext; __context?: RouteContext;
[propName: string]: unknown; __props?: {[propName: string]: unknown};
[attributeName: string]: unknown;
}; };
Object.entries(loaded).forEach(([keyPath, loadedModule]) => { Object.entries(loaded).forEach(([keyPath, loadedModule]) => {
// JSON modules are also loaded as `{ default: ... }` (`import()` // JSON modules are also loaded as `{ default: ... }` (`import()`
@ -127,12 +128,18 @@ export default function ComponentCreator(
delete loadedModules.__comp; delete loadedModules.__comp;
const routeContext = loadedModules.__context!; const routeContext = loadedModules.__context!;
delete loadedModules.__context; delete loadedModules.__context;
const routeProps = loadedModules.__props;
delete loadedModules.__props;
/* eslint-enable no-underscore-dangle */ /* eslint-enable no-underscore-dangle */
// Is there any way to put this RouteContextProvider upper in the tree? // Is there any way to put this RouteContextProvider upper in the tree?
return ( return (
<RouteContextProvider value={routeContext}> <RouteContextProvider value={routeContext}>
<Component {...loadedModules} {...(props as object)} /> <Component
{...loadedModules}
{...routeProps}
{...(props as object)}
/>
</RouteContextProvider> </RouteContextProvider>
); );
}, },

View file

@ -16,7 +16,7 @@ import type {
DocusaurusConfig, DocusaurusConfig,
GlobalData, GlobalData,
I18n, I18n,
RouteConfig, PluginRouteConfig,
SiteMetadata, SiteMetadata,
} from '@docusaurus/types'; } from '@docusaurus/types';
@ -140,7 +140,7 @@ type CodegenParams = {
i18n: I18n; i18n: I18n;
codeTranslations: CodeTranslations; codeTranslations: CodeTranslations;
siteMetadata: SiteMetadata; siteMetadata: SiteMetadata;
routes: RouteConfig[]; routes: PluginRouteConfig[];
}; };
export async function generateSiteFiles(params: CodegenParams): Promise<void> { export async function generateSiteFiles(params: CodegenParams): Promise<void> {

View file

@ -6,6 +6,7 @@
*/ */
import query from 'querystring'; import query from 'querystring';
import path from 'path';
import _ from 'lodash'; import _ from 'lodash';
import {docuHash, simpleHash, escapePath, generate} from '@docusaurus/utils'; import {docuHash, simpleHash, escapePath, generate} from '@docusaurus/utils';
import type { import type {
@ -14,6 +15,8 @@ import type {
RouteModules, RouteModules,
ChunkNames, ChunkNames,
RouteChunkNames, RouteChunkNames,
PluginRouteConfig,
PluginIdentifier,
} from '@docusaurus/types'; } from '@docusaurus/types';
type RoutesCode = { type RoutesCode = {
@ -88,13 +91,13 @@ function serializeRouteConfig({
routeHash, routeHash,
exact, exact,
subroutesCodeStrings, subroutesCodeStrings,
props, attributes,
}: { }: {
routePath: string; routePath: string;
routeHash: string; routeHash: string;
exact?: boolean; exact?: boolean;
subroutesCodeStrings?: string[]; subroutesCodeStrings?: string[];
props: {[propName: string]: unknown}; attributes: {[attributeName: string]: unknown};
}) { }) {
const parts = [ const parts = [
`path: '${routePath}'`, `path: '${routePath}'`,
@ -113,11 +116,11 @@ ${indent(subroutesCodeStrings.join(',\n'))}
); );
} }
Object.entries(props).forEach(([propName, propValue]) => { Object.entries(attributes).forEach(([attrName, attrValue]) => {
const isIdentifier = const isIdentifier =
/^[$_\p{ID_Start}][$\u200c\u200d\p{ID_Continue}]*$/u.test(propName); /^[$_\p{ID_Start}][$\u200c\u200d\p{ID_Continue}]*$/u.test(attrName);
const key = isIdentifier ? propName : JSON.stringify(propName); const key = isIdentifier ? attrName : JSON.stringify(attrName);
parts.push(`${key}: ${JSON.stringify(propValue)}`); parts.push(`${key}: ${JSON.stringify(attrValue)}`);
}); });
return `{ return `{
@ -201,7 +204,9 @@ function genRouteCode(routeConfig: RouteConfig, res: RoutesCode): string {
priority, priority,
exact, exact,
metadata, metadata,
...props props,
plugin,
...attributes
} = routeConfig; } = routeConfig;
if (typeof routePath !== 'string' || !component) { if (typeof routePath !== 'string' || !component) {
@ -225,7 +230,7 @@ ${JSON.stringify(routeConfig)}`,
routeHash, routeHash,
subroutesCodeStrings: subroutes?.map((r) => genRouteCode(r, res)), subroutesCodeStrings: subroutes?.map((r) => genRouteCode(r, res)),
exact, exact,
props, attributes,
}); });
} }
@ -311,14 +316,134 @@ const genRoutes = ({
type GenerateRouteFilesParams = { type GenerateRouteFilesParams = {
generatedFilesDir: string; generatedFilesDir: string;
routes: RouteConfig[]; routes: PluginRouteConfig[];
baseUrl: string; baseUrl: string;
}; };
export async function generateRouteFiles({ async function generateRoutePropModule({
generatedFilesDir,
route,
plugin,
}: {
generatedFilesDir: string;
route: RouteConfig;
plugin: PluginIdentifier;
}) {
ensureNoPropsConflict(route);
const moduleContent = JSON.stringify(route.props);
// TODO we should aim to reduce this path length
// This adds bytes to the global module registry
const relativePath = path.posix.join(
plugin.name,
plugin.id,
'p',
`${docuHash(route.path)}.json`,
);
const modulePath = path.posix.join(generatedFilesDir, relativePath);
const aliasedPath = path.posix.join('@generated', relativePath);
await generate(generatedFilesDir, modulePath, moduleContent);
return aliasedPath;
}
function ensureNoPropsConflict(route: RouteConfig) {
if (!route.props && !route.modules) {
return;
}
const conflictingPropNames = _.intersection(
Object.keys(route.props ?? {}),
Object.keys(route.modules ?? {}),
);
if (conflictingPropNames.length > 0) {
throw new Error(
`Route ${
route.path
} has conflicting props declared using both route.modules and route.props APIs for keys: ${conflictingPropNames.join(
', ',
)}\nThis is not permitted, otherwise one prop would override the over.`,
);
}
}
async function preprocessRouteProps({
generatedFilesDir,
route,
plugin,
}: {
generatedFilesDir: string;
route: RouteConfig;
plugin: PluginIdentifier;
}): Promise<RouteConfig> {
const propsModulePathPromise = route.props
? generateRoutePropModule({
generatedFilesDir,
route,
plugin,
})
: undefined;
const subRoutesPromise = route.routes
? Promise.all(
route.routes.map((subRoute: RouteConfig) => {
return preprocessRouteProps({
generatedFilesDir,
route: subRoute,
plugin,
});
}),
)
: undefined;
const [propsModulePath, subRoutes] = await Promise.all([
propsModulePathPromise,
subRoutesPromise,
]);
const newRoute: RouteConfig = {
...route,
modules: {
...route.modules,
...(propsModulePath && {__props: propsModulePath}),
},
routes: subRoutes,
props: undefined,
};
return newRoute;
}
// For convenience, it's possible to pass a "route.props" object
// This method converts the props object to a regular module
// and assigns it to route.modules.__props attribute
async function preprocessAllPluginsRoutesProps({
generatedFilesDir, generatedFilesDir,
routes, routes,
}: {
generatedFilesDir: string;
routes: PluginRouteConfig[];
}) {
return Promise.all(
routes.map((route) => {
return preprocessRouteProps({
generatedFilesDir,
route,
plugin: route.plugin,
});
}),
);
}
export async function generateRouteFiles({
generatedFilesDir,
routes: initialRoutes,
}: GenerateRouteFilesParams): Promise<void> { }: GenerateRouteFilesParams): Promise<void> {
const routes = await preprocessAllPluginsRoutesProps({
generatedFilesDir,
routes: initialRoutes,
});
const {registry, routesChunkNames, routesConfig} = generateRoutesCode(routes); const {registry, routesChunkNames, routesConfig} = generateRoutesCode(routes);
await Promise.all([ await Promise.all([
genRegistry({generatedFilesDir, registry}), genRegistry({generatedFilesDir, registry}),

View file

@ -119,12 +119,16 @@ describe('loadPlugins', () => {
"data": { "data": {
"someContext": "someContextPath", "someContext": "someContextPath",
}, },
"plugin": "<PROJECT_ROOT>/packages/docusaurus/src/server/plugins/__tests__/__fixtures__/site-with-plugin/.docusaurus/plugin-name/default/plugin-route-context-module-100.json", "plugin": "@generated/plugin-name/default/__plugin.json",
}, },
"modules": { "modules": {
"someModule": "someModulePath", "someModule": "someModulePath",
}, },
"path": "/foo/", "path": "/foo/",
"plugin": {
"id": "default",
"name": "plugin-name",
},
}, },
] ]
`); `);
@ -180,9 +184,13 @@ describe('loadPlugins', () => {
{ {
"component": "Comp", "component": "Comp",
"context": { "context": {
"plugin": "<PROJECT_ROOT>/packages/docusaurus/src/server/plugins/__tests__/__fixtures__/site-with-plugin/.docusaurus/plugin-name/plugin-id/plugin-route-context-module-100.json", "plugin": "@generated/plugin-name/plugin-id/__plugin.json",
}, },
"path": "/foo/", "path": "/foo/",
"plugin": {
"id": "plugin-id",
"name": "plugin-name",
},
}, },
] ]
`); `);
@ -275,23 +283,35 @@ describe('loadPlugins', () => {
{ {
"component": "Comp", "component": "Comp",
"context": { "context": {
"plugin": "<PROJECT_ROOT>/packages/docusaurus/src/server/plugins/__tests__/__fixtures__/site-with-plugin/.docusaurus/plugin-name/default/plugin-route-context-module-100.json", "plugin": "@generated/plugin-name/default/__plugin.json",
}, },
"path": "/allContentLoadedRouteSingle/", "path": "/allContentLoadedRouteSingle/",
"plugin": {
"id": "default",
"name": "plugin-name",
},
}, },
{ {
"component": "Comp", "component": "Comp",
"context": { "context": {
"plugin": "<PROJECT_ROOT>/packages/docusaurus/src/server/plugins/__tests__/__fixtures__/site-with-plugin/.docusaurus/plugin-name/default/plugin-route-context-module-100.json", "plugin": "@generated/plugin-name/default/__plugin.json",
}, },
"path": "/contentLoadedRouteSingle/", "path": "/contentLoadedRouteSingle/",
"plugin": {
"id": "default",
"name": "plugin-name",
},
}, },
{ {
"component": "Comp", "component": "Comp",
"context": { "context": {
"plugin": "<PROJECT_ROOT>/packages/docusaurus/src/server/plugins/__tests__/__fixtures__/site-with-plugin/.docusaurus/plugin-name/default/plugin-route-context-module-100.json", "plugin": "@generated/plugin-name/default/__plugin.json",
}, },
"path": "/allContentLoadedRouteParent/", "path": "/allContentLoadedRouteParent/",
"plugin": {
"id": "default",
"name": "plugin-name",
},
"routes": [ "routes": [
{ {
"component": "Comp", "component": "Comp",
@ -302,9 +322,13 @@ describe('loadPlugins', () => {
{ {
"component": "Comp", "component": "Comp",
"context": { "context": {
"plugin": "<PROJECT_ROOT>/packages/docusaurus/src/server/plugins/__tests__/__fixtures__/site-with-plugin/.docusaurus/plugin-name/default/plugin-route-context-module-100.json", "plugin": "@generated/plugin-name/default/__plugin.json",
}, },
"path": "/contentLoadedRouteParent/", "path": "/contentLoadedRouteParent/",
"plugin": {
"id": "default",
"name": "plugin-name",
},
"routes": [ "routes": [
{ {
"component": "Comp", "component": "Comp",
@ -386,23 +410,35 @@ describe('reloadPlugin', () => {
{ {
"component": "Comp", "component": "Comp",
"context": { "context": {
"plugin": "<PROJECT_ROOT>/packages/docusaurus/src/server/plugins/__tests__/__fixtures__/site-with-plugin/.docusaurus/plugin-name/default/plugin-route-context-module-100.json", "plugin": "@generated/plugin-name/default/__plugin.json",
}, },
"path": "/allContentLoadedRouteSingle/", "path": "/allContentLoadedRouteSingle/",
"plugin": {
"id": "default",
"name": "plugin-name",
},
}, },
{ {
"component": "Comp", "component": "Comp",
"context": { "context": {
"plugin": "<PROJECT_ROOT>/packages/docusaurus/src/server/plugins/__tests__/__fixtures__/site-with-plugin/.docusaurus/plugin-name/default/plugin-route-context-module-100.json", "plugin": "@generated/plugin-name/default/__plugin.json",
}, },
"path": "/contentLoadedRouteSingle/", "path": "/contentLoadedRouteSingle/",
"plugin": {
"id": "default",
"name": "plugin-name",
},
}, },
{ {
"component": "Comp", "component": "Comp",
"context": { "context": {
"plugin": "<PROJECT_ROOT>/packages/docusaurus/src/server/plugins/__tests__/__fixtures__/site-with-plugin/.docusaurus/plugin-name/default/plugin-route-context-module-100.json", "plugin": "@generated/plugin-name/default/__plugin.json",
}, },
"path": "/allContentLoadedRouteParent/", "path": "/allContentLoadedRouteParent/",
"plugin": {
"id": "default",
"name": "plugin-name",
},
"routes": [ "routes": [
{ {
"component": "Comp", "component": "Comp",
@ -413,9 +449,13 @@ describe('reloadPlugin', () => {
{ {
"component": "Comp", "component": "Comp",
"context": { "context": {
"plugin": "<PROJECT_ROOT>/packages/docusaurus/src/server/plugins/__tests__/__fixtures__/site-with-plugin/.docusaurus/plugin-name/default/plugin-route-context-module-100.json", "plugin": "@generated/plugin-name/default/__plugin.json",
}, },
"path": "/contentLoadedRouteParent/", "path": "/contentLoadedRouteParent/",
"plugin": {
"id": "default",
"name": "plugin-name",
},
"routes": [ "routes": [
{ {
"component": "Comp", "component": "Comp",
@ -502,23 +542,35 @@ describe('reloadPlugin', () => {
{ {
"component": "Comp", "component": "Comp",
"context": { "context": {
"plugin": "<PROJECT_ROOT>/packages/docusaurus/src/server/plugins/__tests__/__fixtures__/site-with-plugin/.docusaurus/plugin-name-1/default/plugin-route-context-module-100.json", "plugin": "@generated/plugin-name-1/default/__plugin.json",
}, },
"path": "/allContentLoaded-route-initial/", "path": "/allContentLoaded-route-initial/",
"plugin": {
"id": "default",
"name": "plugin-name-1",
},
}, },
{ {
"component": "Comp", "component": "Comp",
"context": { "context": {
"plugin": "<PROJECT_ROOT>/packages/docusaurus/src/server/plugins/__tests__/__fixtures__/site-with-plugin/.docusaurus/plugin-name-1/default/plugin-route-context-module-100.json", "plugin": "@generated/plugin-name-1/default/__plugin.json",
}, },
"path": "/contentLoaded-route-initial/", "path": "/contentLoaded-route-initial/",
"plugin": {
"id": "default",
"name": "plugin-name-1",
},
}, },
{ {
"component": "Comp", "component": "Comp",
"context": { "context": {
"plugin": "<PROJECT_ROOT>/packages/docusaurus/src/server/plugins/__tests__/__fixtures__/site-with-plugin/.docusaurus/plugin-name-2/default/plugin-route-context-module-100.json", "plugin": "@generated/plugin-name-2/default/__plugin.json",
}, },
"path": "/plugin-2-route/", "path": "/plugin-2-route/",
"plugin": {
"id": "default",
"name": "plugin-name-2",
},
}, },
] ]
`); `);
@ -542,23 +594,35 @@ describe('reloadPlugin', () => {
{ {
"component": "Comp", "component": "Comp",
"context": { "context": {
"plugin": "<PROJECT_ROOT>/packages/docusaurus/src/server/plugins/__tests__/__fixtures__/site-with-plugin/.docusaurus/plugin-name-1/default/plugin-route-context-module-100.json", "plugin": "@generated/plugin-name-1/default/__plugin.json",
}, },
"path": "/allContentLoaded-route-reload/", "path": "/allContentLoaded-route-reload/",
"plugin": {
"id": "default",
"name": "plugin-name-1",
},
}, },
{ {
"component": "Comp", "component": "Comp",
"context": { "context": {
"plugin": "<PROJECT_ROOT>/packages/docusaurus/src/server/plugins/__tests__/__fixtures__/site-with-plugin/.docusaurus/plugin-name-1/default/plugin-route-context-module-100.json", "plugin": "@generated/plugin-name-1/default/__plugin.json",
}, },
"path": "/contentLoaded-route-reload/", "path": "/contentLoaded-route-reload/",
"plugin": {
"id": "default",
"name": "plugin-name-1",
},
}, },
{ {
"component": "Comp", "component": "Comp",
"context": { "context": {
"plugin": "<PROJECT_ROOT>/packages/docusaurus/src/server/plugins/__tests__/__fixtures__/site-with-plugin/.docusaurus/plugin-name-2/default/plugin-route-context-module-100.json", "plugin": "@generated/plugin-name-2/default/__plugin.json",
}, },
"path": "/plugin-2-route/", "path": "/plugin-2-route/",
"plugin": {
"id": "default",
"name": "plugin-name-2",
},
}, },
] ]
`); `);

View file

@ -6,7 +6,7 @@
*/ */
import path from 'path'; import path from 'path';
import {docuHash, generate} from '@docusaurus/utils'; import {generate, posixPath} from '@docusaurus/utils';
import {applyRouteTrailingSlash} from './routeConfig'; import {applyRouteTrailingSlash} from './routeConfig';
import type { import type {
InitializedPlugin, InitializedPlugin,
@ -38,17 +38,26 @@ export async function createPluginActionsUtils({
}): Promise<PluginActionUtils> { }): Promise<PluginActionUtils> {
const pluginId = plugin.options.id; const pluginId = plugin.options.id;
// Plugins data files are namespaced by pluginName/pluginId // Plugins data files are namespaced by pluginName/pluginId
// TODO Docusaurus v4 breaking change
// module aliasing should be automatic
// we should never find local absolute FS paths in the codegen registry
const aliasedSource = (source: string) =>
`@generated/${posixPath(path.relative(generatedFilesDir, source))}`;
// TODO use @generated data dir here!
// The module registry should not contain absolute paths
const dataDir = path.join(generatedFilesDir, plugin.name, pluginId); const dataDir = path.join(generatedFilesDir, plugin.name, pluginId);
const pluginRouteContext: PluginRouteContext['plugin'] = { const pluginRouteContext: PluginRouteContext['plugin'] = {
name: plugin.name, name: plugin.name,
id: pluginId, id: pluginId,
}; };
const pluginRouteContextModulePath = path.join(
dataDir, const pluginRouteContextModulePath = path.join(dataDir, `__plugin.json`);
`${docuHash('pluginRouteContextModule')}.json`,
);
// TODO not ideal place to generate that file // TODO not ideal place to generate that file
// move to codegen step instead!
await generate( await generate(
'/', '/',
pluginRouteContextModulePath, pluginRouteContextModulePath,
@ -69,13 +78,15 @@ export async function createPluginActionsUtils({
...finalRouteConfig, ...finalRouteConfig,
context: { context: {
...(finalRouteConfig.context && {data: finalRouteConfig.context}), ...(finalRouteConfig.context && {data: finalRouteConfig.context}),
plugin: pluginRouteContextModulePath, plugin: aliasedSource(pluginRouteContextModulePath),
}, },
}); });
}, },
async createData(name, data) { async createData(name, data) {
const modulePath = path.join(dataDir, name); const modulePath = path.join(dataDir, name);
await generate(dataDir, name, data); const dataString =
typeof data === 'string' ? data : JSON.stringify(data, null, 2);
await generate(dataDir, name, dataString);
return modulePath; return modulePath;
}, },
setGlobalData(data) { setGlobalData(data) {

View file

@ -18,6 +18,7 @@ import {
formatPluginName, formatPluginName,
getPluginByIdentifier, getPluginByIdentifier,
mergeGlobalData, mergeGlobalData,
toPluginRoute,
} from './pluginsUtils'; } from './pluginsUtils';
import type { import type {
LoadContext, LoadContext,
@ -27,6 +28,7 @@ import type {
PluginIdentifier, PluginIdentifier,
LoadedPlugin, LoadedPlugin,
InitializedPlugin, InitializedPlugin,
PluginRouteConfig,
} from '@docusaurus/types'; } from '@docusaurus/types';
async function translatePluginContent({ async function translatePluginContent({
@ -174,7 +176,10 @@ async function executePluginAllContentLoaded({
); );
} }
type AllContentLoadedResult = {routes: RouteConfig[]; globalData: GlobalData}; type AllContentLoadedResult = {
routes: PluginRouteConfig[];
globalData: GlobalData;
};
async function executeAllPluginsAllContentLoaded({ async function executeAllPluginsAllContentLoaded({
plugins, plugins,
@ -186,28 +191,32 @@ async function executeAllPluginsAllContentLoaded({
return PerfLogger.async(`allContentLoaded()`, async () => { return PerfLogger.async(`allContentLoaded()`, async () => {
const allContent = aggregateAllContent(plugins); const allContent = aggregateAllContent(plugins);
const routes: RouteConfig[] = []; const allRoutes: PluginRouteConfig[] = [];
const globalData: GlobalData = {}; const allGlobalData: GlobalData = {};
await Promise.all( await Promise.all(
plugins.map(async (plugin) => { plugins.map(async (plugin) => {
const {routes: pluginRoutes, globalData: pluginGlobalData} = const {routes, globalData: pluginGlobalData} =
await executePluginAllContentLoaded({ await executePluginAllContentLoaded({
plugin, plugin,
context, context,
allContent, allContent,
}); });
routes.push(...pluginRoutes); const pluginRoutes = routes.map((route) =>
toPluginRoute({plugin, route}),
);
allRoutes.push(...pluginRoutes);
if (pluginGlobalData !== undefined) { if (pluginGlobalData !== undefined) {
globalData[plugin.name] ??= {}; allGlobalData[plugin.name] ??= {};
globalData[plugin.name]![plugin.options.id] = pluginGlobalData; allGlobalData[plugin.name]![plugin.options.id] = pluginGlobalData;
} }
}), }),
); );
return {routes, globalData}; return {routes: allRoutes, globalData: allGlobalData};
}); });
} }
@ -221,7 +230,7 @@ function mergeResults({
plugins: LoadedPlugin[]; plugins: LoadedPlugin[];
allContentLoadedResult: AllContentLoadedResult; allContentLoadedResult: AllContentLoadedResult;
}) { }) {
const routes: RouteConfig[] = [ const routes: PluginRouteConfig[] = [
...aggregateRoutes(plugins), ...aggregateRoutes(plugins),
...allContentLoadedResult.routes, ...allContentLoadedResult.routes,
]; ];
@ -237,7 +246,7 @@ function mergeResults({
export type LoadPluginsResult = { export type LoadPluginsResult = {
plugins: LoadedPlugin[]; plugins: LoadedPlugin[];
routes: RouteConfig[]; routes: PluginRouteConfig[];
globalData: GlobalData; globalData: GlobalData;
}; };

View file

@ -13,6 +13,7 @@ import type {
InitializedPlugin, InitializedPlugin,
LoadedPlugin, LoadedPlugin,
PluginIdentifier, PluginIdentifier,
PluginRouteConfig,
RouteConfig, RouteConfig,
} from '@docusaurus/types'; } from '@docusaurus/types';
@ -49,8 +50,22 @@ export function aggregateAllContent(loadedPlugins: LoadedPlugin[]): AllContent {
.value(); .value();
} }
export function aggregateRoutes(loadedPlugins: LoadedPlugin[]): RouteConfig[] { export function toPluginRoute({
return loadedPlugins.flatMap((p) => p.routes); plugin,
route,
}: {
plugin: LoadedPlugin;
route: RouteConfig;
}): PluginRouteConfig {
return {plugin: {name: plugin.name, id: plugin.options.id}, ...route};
}
export function aggregateRoutes(
loadedPlugins: LoadedPlugin[],
): PluginRouteConfig[] {
return loadedPlugins.flatMap((plugin) =>
plugin.routes.map((route) => toPluginRoute({plugin, route})),
);
} }
export function aggregateGlobalData(loadedPlugins: LoadedPlugin[]): GlobalData { export function aggregateGlobalData(loadedPlugins: LoadedPlugin[]): GlobalData {

View file

@ -12,10 +12,10 @@ import {
import type {RouteConfig} from '@docusaurus/types'; import type {RouteConfig} from '@docusaurus/types';
/** Recursively applies trailing slash config to all nested routes. */ /** Recursively applies trailing slash config to all nested routes. */
export function applyRouteTrailingSlash( export function applyRouteTrailingSlash<Route extends RouteConfig>(
route: RouteConfig, route: Route,
params: ApplyTrailingSlashParams, params: ApplyTrailingSlashParams,
): RouteConfig { ): Route {
return { return {
...route, ...route,
path: applyTrailingSlash(route.path, params), path: applyTrailingSlash(route.path, params),

View file

@ -5,7 +5,8 @@
* LICENSE file in the root directory of this source tree. * LICENSE file in the root directory of this source tree.
*/ */
import {normalizeUrl} from '@docusaurus/utils'; import path from 'path';
import {normalizeUrl, posixPath} from '@docusaurus/utils';
/** /**
* @param {import('@docusaurus/types').LoadContext} context * @param {import('@docusaurus/types').LoadContext} context
@ -20,12 +21,21 @@ export default function FeatureRequestsPlugin(context) {
'paths.json', 'paths.json',
JSON.stringify(basePath), JSON.stringify(basePath),
); );
// TODO Docusaurus v4 breaking change
// module aliasing should be automatic
// we should never find local absolute FS paths in the codegen registry
const aliasedSource = (source) =>
`@generated/${posixPath(
path.relative(context.generatedFilesDir, source),
)}`;
actions.addRoute({ actions.addRoute({
path: basePath, path: basePath,
exact: false, exact: false,
component: '@site/src/plugins/featureRequests/FeatureRequestsPage', component: '@site/src/plugins/featureRequests/FeatureRequestsPage',
modules: { modules: {
basePath: paths, basePath: aliasedSource(paths),
}, },
}); });
}, },