feat(v2): plugins injectHtmlTags + configureWebpack should receive content loaded (#5037)

* more lifecycles should receive plugin loaded content

* refactor docs/blog plugins to use newly injected loaded plugin content instead of a mutable variable

* update lifecycle docs

* update lifecycle docs

* fix failing tests
This commit is contained in:
Sébastien Lorber 2021-06-22 17:36:51 +02:00 committed by GitHub
parent 4e88ea0a1a
commit 119c6d143e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 112 additions and 62 deletions

View file

@ -55,7 +55,7 @@ import {
export default function pluginContentBlog(
context: LoadContext,
options: PluginOptions,
): Plugin<BlogContent | null> {
): Plugin<BlogContent> {
if (options.admonitions) {
options.remarkPlugins = options.remarkPlugins.concat([
[admonitions, options.admonitions],
@ -88,8 +88,6 @@ export default function pluginContentBlog(
const aliasedSource = (source: string) =>
`~blog/${posixPath(path.relative(pluginDataDirRoot, source))}`;
let blogPosts: BlogPost[] = [];
return {
name: 'docusaurus-plugin-content-blog',
@ -116,10 +114,19 @@ export default function pluginContentBlog(
async loadContent() {
const {postsPerPage, routeBasePath} = options;
blogPosts = await generateBlogPosts(contentPaths, context, options);
const blogPosts: BlogPost[] = await generateBlogPosts(
contentPaths,
context,
options,
);
if (!blogPosts.length) {
return null;
return {
blogPosts: [],
blogListPaginated: [],
blogTags: {},
blogTagsListPath: null,
};
}
// Colocate next and prev metadata.
@ -242,7 +249,7 @@ export default function pluginContentBlog(
const {addRoute, createData} = actions;
const {
blogPosts: loadedBlogPosts,
blogPosts,
blogListPaginated,
blogTags,
blogTagsListPath,
@ -275,7 +282,7 @@ export default function pluginContentBlog(
// Create routes for blog entries.
await Promise.all(
loadedBlogPosts.map(async (blogPost) => {
blogPosts.map(async (blogPost) => {
const {id, metadata} = blogPost;
await createData(
// Note that this created data path must be in sync with
@ -403,6 +410,7 @@ export default function pluginContentBlog(
_config: Configuration,
isServer: boolean,
{getJSLoader}: ConfigureWebpackUtils,
content,
) {
const {
rehypePlugins,
@ -416,7 +424,7 @@ export default function pluginContentBlog(
siteDir,
contentPaths,
truncateMarker,
sourceToPermalink: getSourceToPermalink(blogPosts),
sourceToPermalink: getSourceToPermalink(content.blogPosts),
onBrokenMarkdownLink: (brokenMarkdownLink) => {
if (onBrokenMarkdownLinks === 'ignore') {
return;
@ -506,8 +514,8 @@ export default function pluginContentBlog(
);
},
injectHtmlTags() {
if (!blogPosts.length) {
injectHtmlTags({content}) {
if (!content.blogPosts.length) {
return {};
}

View file

@ -309,6 +309,8 @@ describe('simple website', () => {
test('configureWebpack', async () => {
const {plugin} = await loadSite();
const content = await plugin.loadContent?.();
const config = applyConfigureWebpack(
plugin.configureWebpack,
{
@ -319,6 +321,8 @@ describe('simple website', () => {
},
},
false,
undefined,
content,
);
const errors = validate(config);
expect(errors).toBeUndefined();

View file

@ -41,7 +41,7 @@ import {PermalinkToSidebar} from '@docusaurus/plugin-content-docs-types';
import {RuleSetRule} from 'webpack';
import {cliDocsVersionCommand} from './cli';
import {VERSIONS_JSON_FILE} from './constants';
import {flatten, keyBy, compact} from 'lodash';
import {flatten, keyBy, compact, mapValues} from 'lodash';
import {toGlobalDataVersion} from './globalData';
import {toVersionMetadataProp} from './props';
import {
@ -59,7 +59,6 @@ export default function pluginContentDocs(
const versionsMetadata = readVersionsMetadata({context, options});
const sourceToPermalink: SourceToPermalink = {};
const pluginId = options.id ?? DEFAULT_PLUGIN_ID;
const pluginDataDirRoot = path.join(
@ -225,12 +224,6 @@ export default function pluginContentDocs(
// sort to ensure consistent output for tests
docs.sort((a, b) => a.id.localeCompare(b.id));
// TODO annoying side effect!
Object.values(docs).forEach((loadedDoc) => {
const {source, permalink} = loadedDoc;
sourceToPermalink[source] = permalink;
});
// TODO really useful? replace with global state logic?
const permalinkToSidebar: PermalinkToSidebar = {};
Object.values(docs).forEach((doc) => {
@ -369,7 +362,7 @@ export default function pluginContentDocs(
});
},
configureWebpack(_config, isServer, utils) {
configureWebpack(_config, isServer, utils, content) {
const {getJSLoader} = utils;
const {
rehypePlugins,
@ -378,9 +371,17 @@ export default function pluginContentDocs(
beforeDefaultRemarkPlugins,
} = options;
function getSourceToPermalink(): SourceToPermalink {
const allDocs = flatten(content.loadedVersions.map((v) => v.docs));
return mapValues(
keyBy(allDocs, (d) => d.source),
(d) => d.permalink,
);
}
const docsMarkdownOptions: DocsMarkdownOption = {
siteDir,
sourceToPermalink,
sourceToPermalink: getSourceToPermalink(),
versionsMetadata,
onBrokenMarkdownLink: (brokenMarkdownLink) => {
if (siteConfig.onBrokenMarkdownLinks === 'ignore') {

View file

@ -198,7 +198,7 @@ export interface Props extends LoadContext, InjectedHtmlTags {
siteMetadata: DocusaurusSiteMetadata;
routes: RouteConfig[];
routesPaths: string[];
plugins: Plugin<unknown>[];
plugins: LoadedPlugin<unknown>[];
}
export interface PluginContentLoadedActions {
@ -233,10 +233,12 @@ export interface Plugin<Content> {
routesLoaded?(routes: RouteConfig[]): void; // TODO remove soon, deprecated (alpha-60)
postBuild?(props: Props): void;
postStart?(props: Props): void;
// TODO refactor the configureWebpack API surface: use an object instead of multiple params (requires breaking change)
configureWebpack?(
config: Configuration,
isServer: boolean,
utils: ConfigureWebpackUtils,
content: Content,
): Configuration & {mergeStrategy?: ConfigureWebpackFnMergeStrategy};
configurePostCss?(options: PostCssOptions): PostCssOptions;
getThemePath?(): string;
@ -244,7 +246,9 @@ export interface Plugin<Content> {
getPathsToWatch?(): string[];
getClientModules?(): string[];
extendCli?(cli: Command): void;
injectHtmlTags?(): {
injectHtmlTags?({
content: Content,
}): {
headTags?: HtmlTags;
preBodyTags?: HtmlTags;
postBodyTags?: HtmlTags;

View file

@ -184,17 +184,19 @@ async function buildLocale({
if (configureWebpack) {
clientConfig = applyConfigureWebpack(
configureWebpack.bind(plugin), // The plugin lifecycle may reference `this`.
configureWebpack.bind(plugin), // The plugin lifecycle may reference `this`. // TODO remove this implicit api: inject in callback instead
clientConfig,
false,
props.siteConfig.webpack?.jsLoader,
plugin.content,
);
serverConfig = applyConfigureWebpack(
configureWebpack.bind(plugin), // The plugin lifecycle may reference `this`.
configureWebpack.bind(plugin), // The plugin lifecycle may reference `this`. // TODO remove this implicit api: inject in callback instead
serverConfig,
true,
props.siteConfig.webpack?.jsLoader,
plugin.content,
);
}
});

View file

@ -156,10 +156,11 @@ export default async function start(
if (configureWebpack) {
config = applyConfigureWebpack(
configureWebpack.bind(plugin), // The plugin lifecycle may reference `this`.
configureWebpack.bind(plugin), // The plugin lifecycle may reference `this`. // TODO remove this implicit api: inject in callback instead
config,
false,
props.siteConfig.webpack?.jsLoader,
plugin.content,
);
}
});

View file

@ -6,12 +6,8 @@
*/
import htmlTagObjectToString from './htmlTags';
import {
Plugin,
InjectedHtmlTags,
HtmlTagObject,
HtmlTags,
} from '@docusaurus/types';
import {InjectedHtmlTags, HtmlTagObject, HtmlTags} from '@docusaurus/types';
import {LoadedPlugin} from '../plugins';
function toString(val: string | HtmlTagObject): string {
return typeof val === 'string' ? val : htmlTagObjectToString(val);
@ -21,14 +17,14 @@ export function createHtmlTagsString(tags: HtmlTags): string {
return Array.isArray(tags) ? tags.map(toString).join('\n') : toString(tags);
}
export function loadHtmlTags(plugins: Plugin<unknown>[]): InjectedHtmlTags {
export function loadHtmlTags(plugins: LoadedPlugin[]): InjectedHtmlTags {
const htmlTags = plugins.reduce(
(acc, plugin) => {
if (!plugin.injectHtmlTags) {
return acc;
}
const {headTags, preBodyTags, postBodyTags} =
plugin.injectHtmlTags() || {};
plugin.injectHtmlTags({content: plugin.content}) || {};
return {
headTags: headTags
? `${acc.headTags}\n${createHtmlTagsString(headTags)}`

View file

@ -196,6 +196,7 @@ export async function load(
} = siteConfig;
plugins.push({
name: 'docusaurus-bootstrap-plugin',
content: null,
options: {},
version: {type: 'synthetic'},
getClientModules() {

View file

@ -53,6 +53,8 @@ export function sortConfig(routeConfigs: RouteConfig[]): void {
});
}
export type LoadedPlugin = InitPlugin & {content: unknown};
export async function loadPlugins({
pluginConfigs,
context,
@ -60,7 +62,7 @@ export async function loadPlugins({
pluginConfigs: PluginConfig[];
context: LoadContext;
}): Promise<{
plugins: InitPlugin[];
plugins: LoadedPlugin[];
pluginsRouteConfigs: RouteConfig[];
globalData: unknown;
themeConfigTranslated: ThemeConfig;
@ -75,21 +77,20 @@ export async function loadPlugins({
// Currently plugins run lifecycle methods in parallel and are not order-dependent.
// We could change this in future if there are plugins which need to
// run in certain order or depend on others for data.
type ContentLoadedPlugin = {plugin: InitPlugin; content: unknown};
const contentLoadedPlugins: ContentLoadedPlugin[] = await Promise.all(
const loadedPlugins: LoadedPlugin[] = await Promise.all(
plugins.map(async (plugin) => {
const content = plugin.loadContent ? await plugin.loadContent() : null;
return {plugin, content};
return {...plugin, content};
}),
);
type ContentLoadedTranslatedPlugin = ContentLoadedPlugin & {
type ContentLoadedTranslatedPlugin = LoadedPlugin & {
translationFiles: TranslationFiles;
};
const contentLoadedTranslatedPlugins: ContentLoadedTranslatedPlugin[] = await Promise.all(
contentLoadedPlugins.map(async (contentLoadedPlugin) => {
loadedPlugins.map(async (contentLoadedPlugin) => {
const translationFiles =
(await contentLoadedPlugin.plugin?.getTranslationFiles?.({
(await contentLoadedPlugin?.getTranslationFiles?.({
content: contentLoadedPlugin.content,
})) ?? [];
const localizedTranslationFiles = await Promise.all(
@ -98,7 +99,7 @@ export async function loadPlugins({
locale: context.i18n.currentLocale,
siteDir: context.siteDir,
translationFile,
plugin: contentLoadedPlugin.plugin,
plugin: contentLoadedPlugin,
}),
),
);
@ -109,11 +110,11 @@ export async function loadPlugins({
}),
);
const allContent: AllContent = chain(contentLoadedPlugins)
.groupBy((item) => item.plugin.name)
const allContent: AllContent = chain(loadedPlugins)
.groupBy((item) => item.name)
.mapValues((nameItems) => {
return chain(nameItems)
.groupBy((item) => item.plugin.options.id ?? DEFAULT_PLUGIN_ID)
.groupBy((item) => item.options.id ?? DEFAULT_PLUGIN_ID)
.mapValues((idItems) => idItems[0].content)
.value();
})
@ -126,7 +127,7 @@ export async function loadPlugins({
await Promise.all(
contentLoadedTranslatedPlugins.map(
async ({plugin, content, translationFiles}) => {
async ({content, translationFiles, ...plugin}) => {
if (!plugin.contentLoaded) {
return;
}
@ -191,7 +192,7 @@ export async function loadPlugins({
// We could change this in future if there are plugins which need to
// run in certain order or depend on others for data.
await Promise.all(
contentLoadedTranslatedPlugins.map(async ({plugin}) => {
contentLoadedTranslatedPlugins.map(async (plugin) => {
if (!plugin.routesLoaded) {
return null;
}
@ -218,10 +219,10 @@ export async function loadPlugins({
untranslatedThemeConfig: ThemeConfig,
): ThemeConfig {
return contentLoadedTranslatedPlugins.reduce(
(currentThemeConfig, {plugin, translationFiles}) => {
(currentThemeConfig, plugin) => {
const translatedThemeConfigSlice = plugin.translateThemeConfig?.({
themeConfig: currentThemeConfig,
translationFiles,
translationFiles: plugin.translationFiles,
});
return {
...currentThemeConfig,
@ -233,7 +234,7 @@ export async function loadPlugins({
}
return {
plugins,
plugins: loadedPlugins,
pluginsRouteConfigs,
globalData,
themeConfigTranslated: translateThemeConfig(context.siteConfig.themeConfig),

View file

@ -77,7 +77,9 @@ describe('extending generated webpack config', () => {
return {};
};
config = applyConfigureWebpack(configureWebpack, config, false);
config = applyConfigureWebpack(configureWebpack, config, false, undefined, {
content: 42,
});
expect(config).toEqual({
entry: 'entry.js',
output: {
@ -105,7 +107,9 @@ describe('extending generated webpack config', () => {
},
});
config = applyConfigureWebpack(configureWebpack, config, false);
config = applyConfigureWebpack(configureWebpack, config, false, undefined, {
content: 42,
});
expect(config).toEqual({
entry: 'entry.js',
output: {
@ -137,6 +141,8 @@ describe('extending generated webpack config', () => {
createConfigureWebpack(),
config,
false,
undefined,
{content: 42},
);
expect(defaultStrategyMergeConfig).toEqual({
module: {
@ -148,6 +154,8 @@ describe('extending generated webpack config', () => {
createConfigureWebpack({'module.rules': 'prepend'}),
config,
false,
undefined,
{content: 42},
);
expect(prependRulesStrategyConfig).toEqual({
module: {
@ -159,6 +167,8 @@ describe('extending generated webpack config', () => {
createConfigureWebpack({uselessAttributeName: 'append'}),
config,
false,
undefined,
{content: 42},
);
expect(uselessMergeStrategyConfig).toEqual({
module: {

View file

@ -198,13 +198,15 @@ function getCacheLoaderDeprecated() {
* @param config initial webpack config
* @param isServer indicates if this is a server webpack configuration
* @param jsLoader custom js loader config
* @param content content loaded by the plugin
* @returns final/ modified webpack config
*/
export function applyConfigureWebpack(
configureWebpack: ConfigureWebpackFn,
config: Configuration,
isServer: boolean,
jsLoader?: 'babel' | ((isServer: boolean) => RuleSetRule),
jsLoader: 'babel' | ((isServer: boolean) => RuleSetRule) | undefined,
content: unknown,
): Configuration {
// Export some utility functions
const utils: ConfigureWebpackUtils = {
@ -214,7 +216,12 @@ export function applyConfigureWebpack(
getCacheLoader: getCacheLoaderDeprecated,
};
if (typeof configureWebpack === 'function') {
const {mergeStrategy, ...res} = configureWebpack(config, isServer, utils);
const {mergeStrategy, ...res} = configureWebpack(
config,
isServer,
utils,
content,
);
if (res && typeof res === 'object') {
// @ts-expect-error: annoying error due to enums: https://github.com/survivejs/webpack-merge/issues/179
const customizeRules: Record<string, CustomizeRule> = mergeStrategy ?? {};

View file

@ -279,10 +279,16 @@ export default function friendsPlugin(context, options) {
}
```
## `configureWebpack(config, isServer, utils)` {#configurewebpackconfig-isserver-utils}
## `configureWebpack(config, isServer, utils, content)` {#configurewebpackconfig-isserver-utils}
Modifies the internal webpack config. If the return value is a JavaScript object, it will be merged into the final config using [`webpack-merge`](https://github.com/survivejs/webpack-merge). If it is a function, it will be called and receive `config` as the first argument and an `isServer` flag as the argument argument.
:::caution
The API of `configureWebpack` will be modified in the future to accept an object (`configureWebpack({config, isServer, utils, content})`)
:::
### `config` {#config}
`configureWebpack` is called with `config` generated according to client/server build. You may treat this as the base config to be merged with.
@ -293,11 +299,10 @@ Modifies the internal webpack config. If the return value is a JavaScript object
### `utils` {#utils}
The initial call to `configureWebpack` also receives a util object consists of three functions:
`configureWebpack` also receives an util object:
- `getStyleLoaders(isServer: boolean, cssOptions: {[key: string]: any}): Loader[]`
- `getCacheLoader(isServer: boolean, cacheOptions?: {}): Loader | null`
- `getBabelLoader(isServer: boolean, babelOptions?: {}): Loader`
- `getJSLoader(isServer: boolean, cacheOptions?: {}): Loader | null`
You may use them to return your webpack configures conditionally.
@ -326,6 +331,10 @@ module.exports = function (context, options) {
};
```
### `content` {#content}
`configureWebpack` will be called both with the content loaded by the plugin.
### Merge strategy {#merge-strategy}
We merge the Webpack configuration parts of plugins into the global Webpack config using [webpack-merge](https://github.com/survivejs/webpack-merge).
@ -439,10 +448,12 @@ module.exports = function (context, options) {
};
```
## `injectHtmlTags()` {#injecthtmltags}
## `injectHtmlTags({content})` {#injecthtmltags}
Inject head and/or body HTML tags to Docusaurus generated HTML.
`injectHtmlTags` will be called both with the content loaded by the plugin.
```typescript
function injectHtmlTags(): {
headTags?: HtmlTags;
@ -477,8 +488,11 @@ Example:
module.exports = function (context, options) {
return {
name: 'docusaurus-plugin',
loadContent: async () => {
return {remoteHeadTags: await fetchHeadTagsFromAPI()};
},
// highlight-start
injectHtmlTags() {
injectHtmlTags({content}) {
return {
headTags: [
{
@ -488,6 +502,7 @@ module.exports = function (context, options) {
href: 'https://www.github.com',
},
},
...content.remoteHeadTags,
],
preBodyTags: [
{
@ -765,7 +780,7 @@ module.exports = function (context, opts) {
// https://webpack.js.org/configuration/dev-server/#devserverafter
},
configureWebpack(config, isServer) {
configureWebpack(config, isServer, utils, content) {
// Modify internal webpack config. If returned value is an Object, it
// will be merged into the final config using webpack-merge;
// If the returned value is a function, it will receive the config as the 1st argument and an isServer flag as the 2nd argument.
@ -790,11 +805,11 @@ module.exports = function (context, opts) {
// Register an extra command to enhance the CLI of Docusaurus
},
injectHtmlTags() {
injectHtmlTags({content}) {
// Inject head and/or body HTML tags.
},
async getTranslationFiles() {
async getTranslationFiles({content}) {
// Return translation files
},