mirror of
https://github.com/facebook/docusaurus.git
synced 2025-06-09 06:12:28 +02:00
refactor(v2): plugins lifecycle (#1299)
* refactor(v2): plugins lifecycle * dont allow duplicate plugin & fix typo
This commit is contained in:
parent
3a7a253db7
commit
73b89658cc
10 changed files with 127 additions and 136 deletions
|
@ -41,7 +41,7 @@ class DocusaurusPluginContentBlog {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetches blog contents and returns metadata for the contents.
|
// Fetches blog contents and returns metadata for the contents.
|
||||||
async loadContents() {
|
async loadContent() {
|
||||||
const {pageCount, include, routeBasePath} = this.options;
|
const {pageCount, include, routeBasePath} = this.options;
|
||||||
const {env, siteConfig} = this.context;
|
const {env, siteConfig} = this.context;
|
||||||
const blogDir = this.contentPath;
|
const blogDir = this.contentPath;
|
||||||
|
@ -109,10 +109,10 @@ class DocusaurusPluginContentBlog {
|
||||||
return blogMetadata;
|
return blogMetadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
async generateRoutes({metadata, actions}) {
|
async contentLoaded({content, actions}) {
|
||||||
const {blogPageComponent, blogPostComponent} = this.options;
|
const {blogPageComponent, blogPostComponent} = this.options;
|
||||||
const {addRoute} = actions;
|
const {addRoute} = actions;
|
||||||
metadata.forEach(metadataItem => {
|
content.forEach(metadataItem => {
|
||||||
const {isBlogPage, permalink} = metadataItem;
|
const {isBlogPage, permalink} = metadataItem;
|
||||||
if (isBlogPage) {
|
if (isBlogPage) {
|
||||||
addRoute({
|
addRoute({
|
||||||
|
|
|
@ -11,7 +11,7 @@ import loadSetup from '../../docusaurus/test/loadSetup';
|
||||||
import DocusaurusPluginContentPages from '../index';
|
import DocusaurusPluginContentPages from '../index';
|
||||||
|
|
||||||
describe('docusaurus-plugin-content-pages', () => {
|
describe('docusaurus-plugin-content-pages', () => {
|
||||||
describe('loadContents', () => {
|
describe('loadContent', () => {
|
||||||
test.each([
|
test.each([
|
||||||
[
|
[
|
||||||
'simple',
|
'simple',
|
||||||
|
@ -116,7 +116,7 @@ describe('docusaurus-plugin-content-pages', () => {
|
||||||
siteDir,
|
siteDir,
|
||||||
siteConfig,
|
siteConfig,
|
||||||
});
|
});
|
||||||
const pagesMetadatas = await plugin.loadContents();
|
const pagesMetadatas = await plugin.loadContent();
|
||||||
const pagesDir = plugin.contentPath;
|
const pagesDir = plugin.contentPath;
|
||||||
|
|
||||||
expect(pagesMetadatas).toEqual(expected(pagesDir));
|
expect(pagesMetadatas).toEqual(expected(pagesDir));
|
||||||
|
|
|
@ -31,7 +31,7 @@ class DocusaurusPluginContentPages {
|
||||||
return 'docusaurus-plugin-content-pages';
|
return 'docusaurus-plugin-content-pages';
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadContents() {
|
async loadContent() {
|
||||||
const {include} = this.options;
|
const {include} = this.options;
|
||||||
const {env, siteConfig} = this.context;
|
const {env, siteConfig} = this.context;
|
||||||
const pagesDir = this.contentPath;
|
const pagesDir = this.contentPath;
|
||||||
|
@ -88,11 +88,11 @@ class DocusaurusPluginContentPages {
|
||||||
return pagesMetadatas;
|
return pagesMetadatas;
|
||||||
}
|
}
|
||||||
|
|
||||||
async generateRoutes({metadata, actions}) {
|
async contentLoaded({content, actions}) {
|
||||||
const {component} = this.options;
|
const {component} = this.options;
|
||||||
const {addRoute} = actions;
|
const {addRoute} = actions;
|
||||||
|
|
||||||
metadata.forEach(metadataItem => {
|
content.forEach(metadataItem => {
|
||||||
const {permalink, source} = metadataItem;
|
const {permalink, source} = metadataItem;
|
||||||
addRoute({
|
addRoute({
|
||||||
path: permalink,
|
path: permalink,
|
||||||
|
|
|
@ -46,27 +46,24 @@ module.exports = async function build(siteDir) {
|
||||||
const props = await load(siteDir);
|
const props = await load(siteDir);
|
||||||
|
|
||||||
// Apply user webpack config.
|
// Apply user webpack config.
|
||||||
const {
|
const {outDir, plugins} = props;
|
||||||
outDir,
|
|
||||||
siteConfig: {configureWebpack},
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
const clientConfigObj = createClientConfig(props);
|
const clientConfigObj = createClientConfig(props);
|
||||||
// Remove/clean build folders before building bundles.
|
// Remove/clean build folders before building bundles.
|
||||||
clientConfigObj
|
clientConfigObj
|
||||||
.plugin('clean')
|
.plugin('clean')
|
||||||
.use(CleanWebpackPlugin, [outDir, {verbose: false, allowExternal: true}]);
|
.use(CleanWebpackPlugin, [outDir, {verbose: false, allowExternal: true}]);
|
||||||
const serverConfigObj = createServerConfig(props);
|
let serverConfig = createServerConfig(props).toConfig();
|
||||||
const clientConfig = applyConfigureWebpack(
|
let clientConfig = clientConfigObj.toConfig();
|
||||||
configureWebpack,
|
|
||||||
clientConfigObj.toConfig(),
|
// Plugin lifecycle - configureWebpack
|
||||||
false,
|
plugins.forEach(({configureWebpack}) => {
|
||||||
);
|
if (!configureWebpack) {
|
||||||
const serverConfig = applyConfigureWebpack(
|
return;
|
||||||
configureWebpack,
|
}
|
||||||
serverConfigObj.toConfig(),
|
clientConfig = applyConfigureWebpack(configureWebpack, clientConfig, false);
|
||||||
true,
|
serverConfig = applyConfigureWebpack(configureWebpack, serverConfig, true);
|
||||||
);
|
});
|
||||||
|
|
||||||
// Build the client bundles first.
|
// Build the client bundles first.
|
||||||
// We cannot run them in parallel because the server needs to know
|
// We cannot run them in parallel because the server needs to know
|
||||||
|
|
|
@ -77,7 +77,7 @@ module.exports = async function start(siteDir, cliOptions = {}) {
|
||||||
// Create compiler from generated webpack config.
|
// Create compiler from generated webpack config.
|
||||||
let config = createClientConfig(props);
|
let config = createClientConfig(props);
|
||||||
|
|
||||||
const {siteConfig} = props;
|
const {siteConfig, plugins = []} = props;
|
||||||
config.plugin('html-webpack-plugin').use(HtmlWebpackPlugin, [
|
config.plugin('html-webpack-plugin').use(HtmlWebpackPlugin, [
|
||||||
{
|
{
|
||||||
inject: false,
|
inject: false,
|
||||||
|
@ -89,11 +89,13 @@ module.exports = async function start(siteDir, cliOptions = {}) {
|
||||||
]);
|
]);
|
||||||
config = config.toConfig();
|
config = config.toConfig();
|
||||||
|
|
||||||
// Apply user webpack config.
|
// Plugin lifecycle - configureWebpack
|
||||||
const {
|
plugins.forEach(({configureWebpack}) => {
|
||||||
siteConfig: {configureWebpack},
|
if (!configureWebpack) {
|
||||||
} = props;
|
return;
|
||||||
|
}
|
||||||
config = applyConfigureWebpack(configureWebpack, config, false);
|
config = applyConfigureWebpack(configureWebpack, config, false);
|
||||||
|
});
|
||||||
|
|
||||||
const compiler = webpack(config);
|
const compiler = webpack(config);
|
||||||
|
|
||||||
|
|
|
@ -14,6 +14,7 @@ const loadDocs = require('./docs');
|
||||||
const loadEnv = require('./env');
|
const loadEnv = require('./env');
|
||||||
const loadTheme = require('./theme');
|
const loadTheme = require('./theme');
|
||||||
const loadRoutes = require('./routes');
|
const loadRoutes = require('./routes');
|
||||||
|
const loadPlugins = require('./plugins');
|
||||||
const constants = require('../constants');
|
const constants = require('../constants');
|
||||||
|
|
||||||
module.exports = async function load(siteDir) {
|
module.exports = async function load(siteDir) {
|
||||||
|
@ -23,7 +24,7 @@ module.exports = async function load(siteDir) {
|
||||||
);
|
);
|
||||||
fs.ensureDirSync(generatedFilesDir);
|
fs.ensureDirSync(generatedFilesDir);
|
||||||
|
|
||||||
// Site Config - @tested
|
// Site Config
|
||||||
const siteConfig = loadConfig.loadConfig(siteDir);
|
const siteConfig = loadConfig.loadConfig(siteDir);
|
||||||
await generate(
|
await generate(
|
||||||
generatedFilesDir,
|
generatedFilesDir,
|
||||||
|
@ -31,7 +32,7 @@ module.exports = async function load(siteDir) {
|
||||||
`export default ${JSON.stringify(siteConfig, null, 2)};`,
|
`export default ${JSON.stringify(siteConfig, null, 2)};`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Env - @tested
|
// Env
|
||||||
const env = loadEnv({siteDir, siteConfig});
|
const env = loadEnv({siteDir, siteConfig});
|
||||||
await generate(
|
await generate(
|
||||||
generatedFilesDir,
|
generatedFilesDir,
|
||||||
|
@ -73,76 +74,11 @@ module.exports = async function load(siteDir) {
|
||||||
// Process plugins.
|
// Process plugins.
|
||||||
const pluginConfigs = siteConfig.plugins || [];
|
const pluginConfigs = siteConfig.plugins || [];
|
||||||
const context = {env, siteDir, siteConfig};
|
const context = {env, siteDir, siteConfig};
|
||||||
|
const {plugins, pluginRouteConfigs} = await loadPlugins({
|
||||||
// Initialize plugins.
|
pluginConfigs,
|
||||||
const plugins = pluginConfigs.map(({name, path: pluginPath, options}) => {
|
context,
|
||||||
let Plugin;
|
|
||||||
// If path itself is provided
|
|
||||||
if (pluginPath && fs.existsSync(pluginPath)) {
|
|
||||||
// eslint-disable-next-line
|
|
||||||
Plugin = require(pluginPath);
|
|
||||||
} else {
|
|
||||||
// Resolve using node_modules as well.
|
|
||||||
try {
|
|
||||||
// eslint-disable-next-line
|
|
||||||
Plugin = require(name);
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(`'${name}' plugin cannot be found.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return new Plugin(options, context);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Plugin lifecycle - loadContents().
|
|
||||||
// Currently plugins run lifecycle 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.
|
|
||||||
const pluginsLoadedMetadata = await Promise.all(
|
|
||||||
plugins.map(async plugin => {
|
|
||||||
if (!plugin.loadContents) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const name = plugin.getName();
|
|
||||||
const {options} = plugin;
|
|
||||||
const {metadataKey, metadataFileName} = options;
|
|
||||||
const metadata = await plugin.loadContents();
|
|
||||||
const pluginContentPath = path.join(name, metadataFileName);
|
|
||||||
const pluginContentDir = path.join(generatedFilesDir, name);
|
|
||||||
fs.ensureDirSync(pluginContentDir);
|
|
||||||
await generate(
|
|
||||||
pluginContentDir,
|
|
||||||
metadataFileName,
|
|
||||||
JSON.stringify(metadata, null, 2),
|
|
||||||
);
|
|
||||||
const contentPath = path.join('@generated', pluginContentPath);
|
|
||||||
|
|
||||||
return {
|
|
||||||
metadataKey,
|
|
||||||
contentPath,
|
|
||||||
metadata,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Plugin lifecycle - generateRoutes().
|
|
||||||
const pluginRouteConfigs = [];
|
|
||||||
const actions = {
|
|
||||||
addRoute: config => pluginRouteConfigs.push(config),
|
|
||||||
};
|
|
||||||
await Promise.all(
|
|
||||||
plugins.map(async (plugin, index) => {
|
|
||||||
if (!plugin.generateRoutes) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const loadedMetadata = pluginsLoadedMetadata[index];
|
|
||||||
await plugin.generateRoutes({
|
|
||||||
metadata: loadedMetadata.metadata,
|
|
||||||
actions,
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Resolve outDir.
|
// Resolve outDir.
|
||||||
const outDir = path.resolve(siteDir, 'build');
|
const outDir = path.resolve(siteDir, 'build');
|
||||||
|
|
||||||
|
@ -167,16 +103,9 @@ module.exports = async function load(siteDir) {
|
||||||
'../core/metadata.template.ejs',
|
'../core/metadata.template.ejs',
|
||||||
);
|
);
|
||||||
const metadataTemplate = fs.readFileSync(metadataTemplateFile).toString();
|
const metadataTemplate = fs.readFileSync(metadataTemplateFile).toString();
|
||||||
const pluginMetadataImports = pluginsLoadedMetadata.map(
|
|
||||||
({metadataKey, contentPath}) => ({
|
|
||||||
name: metadataKey,
|
|
||||||
path: contentPath,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const metadataFile = ejs.render(metadataTemplate, {
|
const metadataFile = ejs.render(metadataTemplate, {
|
||||||
imports: [
|
imports: [
|
||||||
...pluginMetadataImports,
|
|
||||||
{
|
{
|
||||||
name: 'docsMetadatas',
|
name: 'docsMetadatas',
|
||||||
path: '@generated/docsMetadatas',
|
path: '@generated/docsMetadatas',
|
||||||
|
|
70
packages/docusaurus/lib/load/plugins.js
Normal file
70
packages/docusaurus/lib/load/plugins.js
Normal file
|
@ -0,0 +1,70 @@
|
||||||
|
/**
|
||||||
|
* Copyright (c) 2017-present, Facebook, Inc.
|
||||||
|
*
|
||||||
|
* This source code is licensed under the MIT license found in the
|
||||||
|
* LICENSE file in the root directory of this source tree.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs-extra');
|
||||||
|
|
||||||
|
module.exports = async function loadPlugins({pluginConfigs = [], context}) {
|
||||||
|
/* 1. Plugin Lifecycle - Initializiation/Constructor */
|
||||||
|
const plugins = pluginConfigs.map(({name, path: pluginPath, options}) => {
|
||||||
|
let Plugin;
|
||||||
|
if (pluginPath && fs.existsSync(pluginPath)) {
|
||||||
|
// eslint-disable-next-line
|
||||||
|
Plugin = require(pluginPath);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line
|
||||||
|
Plugin = require(name);
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`'${name}' plugin cannot be found.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new Plugin(options, context);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Do not allow plugin with duplicate name
|
||||||
|
const pluginNames = new Set();
|
||||||
|
plugins.forEach(plugin => {
|
||||||
|
const name = plugin.getName();
|
||||||
|
if (pluginNames.has(name)) {
|
||||||
|
throw new Error(`Duplicate plugin with name '${name}' found`);
|
||||||
|
}
|
||||||
|
pluginNames.add(name);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* 2. Plugin lifecycle - LoadContent */
|
||||||
|
const pluginsLoadedContent = {};
|
||||||
|
await Promise.all(
|
||||||
|
plugins.map(async plugin => {
|
||||||
|
if (!plugin.loadContent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const name = plugin.getName();
|
||||||
|
pluginsLoadedContent[name] = await plugin.loadContent();
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
/* 3. Plugin lifecycle - contentLoaded */
|
||||||
|
const pluginRouteConfigs = [];
|
||||||
|
const actions = {
|
||||||
|
addRoute: config => pluginRouteConfigs.push(config),
|
||||||
|
};
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
plugins.map(async plugin => {
|
||||||
|
if (!plugin.contentLoaded) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const name = plugin.getName();
|
||||||
|
const content = pluginsLoadedContent[name];
|
||||||
|
await plugin.contentLoaded({content, actions});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
plugins,
|
||||||
|
pluginRouteConfigs,
|
||||||
|
};
|
||||||
|
};
|
|
@ -14,9 +14,12 @@ import DocusaurusContext from '@docusaurus/context';
|
||||||
|
|
||||||
function BlogPage(props) {
|
function BlogPage(props) {
|
||||||
const context = useContext(DocusaurusContext);
|
const context = useContext(DocusaurusContext);
|
||||||
const {blogMetadata, language, siteConfig = {}} = context;
|
const {language, siteConfig = {}} = context;
|
||||||
const {baseUrl, favicon} = siteConfig;
|
const {baseUrl, favicon} = siteConfig;
|
||||||
const {modules: BlogPosts} = props;
|
const {
|
||||||
|
metadata: {posts = []},
|
||||||
|
modules: BlogPosts,
|
||||||
|
} = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
|
@ -28,7 +31,7 @@ function BlogPage(props) {
|
||||||
</Head>
|
</Head>
|
||||||
<div>
|
<div>
|
||||||
<ul>
|
<ul>
|
||||||
{blogMetadata.map(metadata => (
|
{posts.map(metadata => (
|
||||||
<li key={metadata.permalink}>
|
<li key={metadata.permalink}>
|
||||||
<Link to={metadata.permalink}>{metadata.permalink}</Link>
|
<Link to={metadata.permalink}>{metadata.permalink}</Link>
|
||||||
</li>
|
</li>
|
||||||
|
|
|
@ -5,15 +5,11 @@
|
||||||
* LICENSE file in the root directory of this source tree.
|
* LICENSE file in the root directory of this source tree.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, {useContext} from 'react';
|
import React from 'react';
|
||||||
import Link from '@docusaurus/Link';
|
|
||||||
|
|
||||||
import DocusaurusContext from '@docusaurus/context';
|
|
||||||
|
|
||||||
import styles from './styles.module.css';
|
import styles from './styles.module.css';
|
||||||
|
|
||||||
function Footer() {
|
function Footer() {
|
||||||
const context = useContext(DocusaurusContext);
|
|
||||||
return (
|
return (
|
||||||
<footer className={styles.footer}>
|
<footer className={styles.footer}>
|
||||||
<section className={styles.footerRow}>
|
<section className={styles.footerRow}>
|
||||||
|
@ -77,19 +73,6 @@ function Footer() {
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
{/* This is for v2 development only to know the available pages. */}
|
|
||||||
<div className={styles.footerColumn}>
|
|
||||||
<h3 className={styles.footerColumnTitle}>Pages</h3>
|
|
||||||
<ul className={styles.footerList}>
|
|
||||||
{context.pagesMetadata.map(metadata => (
|
|
||||||
<li key={metadata.permalink} className={styles.footerListItem}>
|
|
||||||
<Link className={styles.footerLink} to={metadata.permalink}>
|
|
||||||
{metadata.permalink}
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
<section className={styles.copyright}>
|
<section className={styles.copyright}>
|
||||||
<span>Copyright © {new Date().getFullYear()} Facebook Inc.</span>
|
<span>Copyright © {new Date().getFullYear()} Facebook Inc.</span>
|
||||||
|
|
|
@ -4,7 +4,7 @@ Plugins are one of the best ways to add functionality to our Docusaurus. Plugins
|
||||||
|
|
||||||
## Installing a Plugin
|
## Installing a Plugin
|
||||||
|
|
||||||
A plugin is usually a dependency, so you install them like other packages in node using NPM. However, you don't need to install official plugin provided by Docusaurus team because it comes by default.
|
A plugin is usually a dependency, so you install them like other packages in node using NPM.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
yarn add docusaurus-plugin-name
|
yarn add docusaurus-plugin-name
|
||||||
|
@ -16,14 +16,14 @@ Then you add it in your site's `docusaurus.config.js` plugin arrays:
|
||||||
module.exports = {
|
module.exports = {
|
||||||
plugins: [
|
plugins: [
|
||||||
{
|
{
|
||||||
name: 'docusaurus-plugin-content-pages',
|
name: '@docusaurus/plugin-content-pages',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// Plugin with options
|
// Plugin with options
|
||||||
name: 'docusaurus-plugin-content-blog',
|
name: '@docusaurus/plugin-content-blog',
|
||||||
options: {
|
options: {
|
||||||
include: ['*.md', '*.mdx'],
|
include: ['*.md', '*.mdx'],
|
||||||
path: '../v1/website/blog',
|
path: 'blog',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
@ -50,6 +50,7 @@ For examples, please refer to several official plugins created.
|
||||||
// A JavaScript class
|
// A JavaScript class
|
||||||
class DocusaurusPlugin {
|
class DocusaurusPlugin {
|
||||||
constructor(options, context) {
|
constructor(options, context) {
|
||||||
|
// Initialization hook
|
||||||
|
|
||||||
// options are the plugin options set on config file
|
// options are the plugin options set on config file
|
||||||
this.options = {...options};
|
this.options = {...options};
|
||||||
|
@ -62,13 +63,19 @@ class DocusaurusPlugin {
|
||||||
// plugin name identifier
|
// plugin name identifier
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadContents() {
|
|
||||||
// Content loading hook that runs the first time plugin is loaded
|
async loadContent()) {
|
||||||
// expect a content data structure to be returned
|
// The loadContent hook is executed after siteConfig and env has been loaded
|
||||||
|
// You can return a JavaScript object that will be passed to contentLoaded hook
|
||||||
}
|
}
|
||||||
|
|
||||||
async generateRoutes({metadata, actions}) {
|
async contentLoaded({content, actions}) {
|
||||||
// This is routes generation hook
|
// loaded hook is done after load hook is done
|
||||||
|
// actions are set of functional API provided by Docusaurus. e.g: addRoute
|
||||||
|
}
|
||||||
|
|
||||||
|
configureWebpack(config, isServer) {
|
||||||
|
// Modify internal webpack config. If returned value is an Object, it will be merged into the final config using webpack-merge; If returned value is a function, it will receive the config as the 1st argument and an isServer flag as the 2nd argument.
|
||||||
}
|
}
|
||||||
|
|
||||||
getPathsToWatch() {
|
getPathsToWatch() {
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue