mirror of
https://github.com/facebook/docusaurus.git
synced 2025-07-31 23:40:39 +02:00
wip
This commit is contained in:
parent
45af11fd13
commit
a9d6bcf968
9 changed files with 107 additions and 192 deletions
|
@ -11,11 +11,4 @@ module.exports = {
|
||||||
url: 'https://your-docusaurus-site.example.com',
|
url: 'https://your-docusaurus-site.example.com',
|
||||||
baseUrl: '/',
|
baseUrl: '/',
|
||||||
favicon: 'img/favicon.ico',
|
favicon: 'img/favicon.ico',
|
||||||
markdown: {
|
|
||||||
parseFrontMatter: async (params) => {
|
|
||||||
const result = await params.defaultParseFrontMatter(params);
|
|
||||||
result.frontMatter.custom_frontMatter = 'added by parseFrontMatter';
|
|
||||||
return result;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
|
@ -5,65 +5,12 @@
|
||||||
* LICENSE file in the root directory of this source tree.
|
* LICENSE file in the root directory of this source tree.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {escapeRegexp} from '@docusaurus/utils';
|
import {validateShowcaseItem} from '../validation';
|
||||||
import {validateShowcaseFrontMatter} from '../frontMatter';
|
import type {ShowcaseItem} from '@docusaurus/plugin-content-showcase';
|
||||||
import type {ShowcaseFrontMatter} from '@docusaurus/plugin-content-showcase';
|
|
||||||
|
|
||||||
function testField(params: {
|
|
||||||
prefix: string;
|
|
||||||
validFrontMatters: ShowcaseFrontMatter[];
|
|
||||||
convertibleFrontMatter?: [
|
|
||||||
ConvertibleFrontMatter: {[key: string]: unknown},
|
|
||||||
ConvertedFrontMatter: ShowcaseFrontMatter,
|
|
||||||
][];
|
|
||||||
invalidFrontMatters?: [
|
|
||||||
InvalidFrontMatter: {[key: string]: unknown},
|
|
||||||
ErrorMessage: string,
|
|
||||||
][];
|
|
||||||
}) {
|
|
||||||
// eslint-disable-next-line jest/require-top-level-describe
|
|
||||||
test(`[${params.prefix}] accept valid values`, () => {
|
|
||||||
params.validFrontMatters.forEach((frontMatter) => {
|
|
||||||
expect(validateShowcaseFrontMatter(frontMatter)).toEqual(frontMatter);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// eslint-disable-next-line jest/require-top-level-describe
|
|
||||||
test(`[${params.prefix}] convert valid values`, () => {
|
|
||||||
params.convertibleFrontMatter?.forEach(
|
|
||||||
([convertibleFrontMatter, convertedFrontMatter]) => {
|
|
||||||
expect(validateShowcaseFrontMatter(convertibleFrontMatter)).toEqual(
|
|
||||||
convertedFrontMatter,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// eslint-disable-next-line jest/require-top-level-describe
|
|
||||||
test(`[${params.prefix}] throw error for values`, () => {
|
|
||||||
params.invalidFrontMatters?.forEach(([frontMatter, message]) => {
|
|
||||||
try {
|
|
||||||
validateShowcaseFrontMatter(frontMatter);
|
|
||||||
throw new Error(
|
|
||||||
`Showcase front matter is expected to be rejected, but was accepted successfully:\n ${JSON.stringify(
|
|
||||||
frontMatter,
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
)}`,
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
// eslint-disable-next-line jest/no-conditional-expect
|
|
||||||
expect((err as Error).message).toMatch(
|
|
||||||
new RegExp(escapeRegexp(message)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('showcase front matter schema', () => {
|
describe('showcase front matter schema', () => {
|
||||||
it('accepts valid frontmatter', () => {
|
it('accepts valid frontmatter', () => {
|
||||||
const frontMatter: ShowcaseFrontMatter = {
|
const frontMatter: ShowcaseItem = {
|
||||||
title: 'title',
|
title: 'title',
|
||||||
description: 'description',
|
description: 'description',
|
||||||
preview: 'preview',
|
preview: 'preview',
|
||||||
|
@ -71,37 +18,24 @@ describe('showcase front matter schema', () => {
|
||||||
tags: [],
|
tags: [],
|
||||||
website: 'website',
|
website: 'website',
|
||||||
};
|
};
|
||||||
expect(validateShowcaseFrontMatter(frontMatter)).toEqual(frontMatter);
|
expect(validateShowcaseItem(frontMatter)).toEqual(frontMatter);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reject invalid frontmatter', () => {
|
it('reject invalid frontmatter', () => {
|
||||||
const frontMatter = {};
|
const frontMatter = {};
|
||||||
expect(() =>
|
expect(() =>
|
||||||
validateShowcaseFrontMatter(frontMatter),
|
validateShowcaseItem(frontMatter),
|
||||||
).toThrowErrorMatchingInlineSnapshot(
|
).toThrowErrorMatchingInlineSnapshot(
|
||||||
`""title" is required. "description" is required. "preview" is required. "website" is required. "source" is required. "tags" is required"`,
|
`""title" is required. "description" is required. "preview" is required. "website" is required. "source" is required. "tags" is required"`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
describe('validateShowcaseFrontMatter full', () => {
|
it('reject invalid frontmatter value', () => {
|
||||||
testField({
|
const frontMatter = {title: 42};
|
||||||
prefix: 'valid full frontmatter',
|
expect(() =>
|
||||||
validFrontMatters: [
|
validateShowcaseItem(frontMatter),
|
||||||
{
|
).toThrowErrorMatchingInlineSnapshot(
|
||||||
title: 'title',
|
`""title" must be a string. "description" is required. "preview" is required. "website" is required. "source" is required. "tags" is required"`,
|
||||||
description: 'description',
|
);
|
||||||
preview: 'preview',
|
|
||||||
source: 'source',
|
|
||||||
tags: [],
|
|
||||||
website: 'website',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
invalidFrontMatters: [
|
|
||||||
[
|
|
||||||
{},
|
|
||||||
'"title" is required. "description" is required. "preview" is required. "website" is required. "source" is required. "tags" is required',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -13,18 +13,13 @@ import {
|
||||||
Globby,
|
Globby,
|
||||||
} from '@docusaurus/utils';
|
} from '@docusaurus/utils';
|
||||||
import Yaml from 'js-yaml';
|
import Yaml from 'js-yaml';
|
||||||
|
|
||||||
import {Joi} from '@docusaurus/utils-validation';
|
import {Joi} from '@docusaurus/utils-validation';
|
||||||
import {
|
import {validateFrontMatterTags, validateShowcaseItem} from './validation';
|
||||||
validateFrontMatterTags,
|
import {getTagsList} from './tags';
|
||||||
validateShowcaseFrontMatter,
|
|
||||||
} from './frontMatter';
|
|
||||||
import {tagSchema} from './options';
|
|
||||||
import type {LoadContext, Plugin} from '@docusaurus/types';
|
import type {LoadContext, Plugin} from '@docusaurus/types';
|
||||||
import type {
|
import type {
|
||||||
PluginOptions,
|
PluginOptions,
|
||||||
ShowcaseItem,
|
ShowcaseItems,
|
||||||
TagOption,
|
|
||||||
} from '@docusaurus/plugin-content-showcase';
|
} from '@docusaurus/plugin-content-showcase';
|
||||||
import type {ShowcaseContentPaths} from './types';
|
import type {ShowcaseContentPaths} from './types';
|
||||||
|
|
||||||
|
@ -35,55 +30,24 @@ export function getContentPathList(
|
||||||
}
|
}
|
||||||
|
|
||||||
function createTagSchema(tags: string[]): Joi.Schema {
|
function createTagSchema(tags: string[]): Joi.Schema {
|
||||||
return Joi.alternatives().try(
|
return Joi.array().items(Joi.string().valid(...tags)); // Schema for array of strings
|
||||||
Joi.string().valid(...tags), // Schema for single string
|
|
||||||
Joi.array().items(Joi.string().valid(...tags)), // Schema for array of strings
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function pluginContentShowcase(
|
export default function pluginContentShowcase(
|
||||||
context: LoadContext,
|
context: LoadContext,
|
||||||
options: PluginOptions,
|
options: PluginOptions,
|
||||||
): Plugin<ShowcaseItem | null> {
|
): Plugin<ShowcaseItems | null> {
|
||||||
const {siteDir, localizationDir} = context;
|
const {siteDir, localizationDir} = context;
|
||||||
|
|
||||||
const contentPaths: ShowcaseContentPaths = {
|
const contentPaths: ShowcaseContentPaths = {
|
||||||
contentPath: path.resolve(siteDir, options.path),
|
contentPath: path.resolve(siteDir, options.path),
|
||||||
contentPathLocalized: getPluginI18nPath({
|
contentPathLocalized: getPluginI18nPath({
|
||||||
localizationDir,
|
localizationDir,
|
||||||
pluginName: 'docusaurus-plugin-content-pages',
|
pluginName: 'docusaurus-plugin-content-showcase',
|
||||||
pluginId: options.id,
|
pluginId: options.id,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
async function getTagsList(
|
|
||||||
configTags: string | TagOption[],
|
|
||||||
): Promise<string[]> {
|
|
||||||
if (typeof configTags === 'object') {
|
|
||||||
return Object.keys(configTags);
|
|
||||||
}
|
|
||||||
|
|
||||||
const tagsPath = path.resolve(contentPaths.contentPath, configTags);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const rawYaml = await fs.readFile(tagsPath, 'utf-8');
|
|
||||||
const unsafeYaml = Yaml.load(rawYaml);
|
|
||||||
const safeYaml = tagSchema.validate(unsafeYaml);
|
|
||||||
|
|
||||||
if (safeYaml.error) {
|
|
||||||
throw new Error(
|
|
||||||
`There was an error extracting tags: ${safeYaml.error.message}`,
|
|
||||||
{cause: safeYaml.error},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const tagLabels = Object.keys(safeYaml.value);
|
|
||||||
return tagLabels;
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error(`Failed to read tags file for showcase`, {cause: error});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: 'docusaurus-plugin-content-showcase',
|
name: 'docusaurus-plugin-content-showcase',
|
||||||
|
|
||||||
|
@ -95,19 +59,24 @@ export default function pluginContentShowcase(
|
||||||
// );
|
// );
|
||||||
// },
|
// },
|
||||||
|
|
||||||
async loadContent(): Promise<ShowcaseItem | null> {
|
async loadContent(): Promise<ShowcaseItems | null> {
|
||||||
if (!(await fs.pathExists(contentPaths.contentPath))) {
|
if (!(await fs.pathExists(contentPaths.contentPath))) {
|
||||||
return null;
|
throw new Error(
|
||||||
|
`The showcase content path does not exist: ${contentPaths.contentPath}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const {include} = options;
|
const {include} = options;
|
||||||
|
|
||||||
const showcaseFiles = await Globby(include, {
|
const showcaseFiles = await Globby(include, {
|
||||||
cwd: contentPaths.contentPath,
|
cwd: contentPaths.contentPath,
|
||||||
ignore: options.exclude,
|
ignore: [...options.exclude],
|
||||||
});
|
});
|
||||||
|
|
||||||
const tagList = await getTagsList(options.tags);
|
const tagList = await getTagsList({
|
||||||
|
configTags: options.tags,
|
||||||
|
configPath: contentPaths.contentPath,
|
||||||
|
});
|
||||||
const createdTagSchema = createTagSchema(tagList);
|
const createdTagSchema = createTagSchema(tagList);
|
||||||
|
|
||||||
async function processShowcaseSourceFile(relativeSource: string) {
|
async function processShowcaseSourceFile(relativeSource: string) {
|
||||||
|
@ -119,14 +88,14 @@ export default function pluginContentShowcase(
|
||||||
|
|
||||||
const sourcePath = path.join(contentPath, relativeSource);
|
const sourcePath = path.join(contentPath, relativeSource);
|
||||||
|
|
||||||
const rawYaml = await fs.readFile(sourcePath, 'utf-8');
|
const data = await fs.readFile(sourcePath, 'utf-8');
|
||||||
// todo remove as ... because bad practice ?
|
// todo remove as ... because bad practice ?
|
||||||
const unsafeYaml = Yaml.load(rawYaml) as {[key: string]: unknown};
|
const unsafeData = Yaml.load(data) as {[key: string]: unknown};
|
||||||
const yaml = validateShowcaseFrontMatter(unsafeYaml);
|
const showcaseItem = validateShowcaseItem(unsafeData);
|
||||||
|
|
||||||
validateFrontMatterTags(yaml.tags, createdTagSchema);
|
validateFrontMatterTags(showcaseItem.tags, createdTagSchema);
|
||||||
|
|
||||||
return yaml;
|
return showcaseItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doProcessShowcaseSourceFile(relativeSource: string) {
|
async function doProcessShowcaseSourceFile(relativeSource: string) {
|
||||||
|
@ -160,7 +129,7 @@ export default function pluginContentShowcase(
|
||||||
);
|
);
|
||||||
|
|
||||||
addRoute({
|
addRoute({
|
||||||
path: '/showcaseAll',
|
path: options.routeBasePath,
|
||||||
component: '@theme/Showcase',
|
component: '@theme/Showcase',
|
||||||
modules: {
|
modules: {
|
||||||
content: showcaseAllData,
|
content: showcaseAllData,
|
||||||
|
|
|
@ -1,22 +0,0 @@
|
||||||
/**
|
|
||||||
* 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 type {LoaderContext} from 'webpack';
|
|
||||||
|
|
||||||
export default function markdownLoader(
|
|
||||||
this: LoaderContext<undefined>,
|
|
||||||
fileString: string,
|
|
||||||
): void {
|
|
||||||
const callback = this.async();
|
|
||||||
|
|
||||||
// const options = this.getOptions();
|
|
||||||
|
|
||||||
// TODO provide additional md processing here? like interlinking pages?
|
|
||||||
// fileString = linkify(fileString)
|
|
||||||
|
|
||||||
return callback(null, fileString);
|
|
||||||
}
|
|
|
@ -13,8 +13,9 @@ import type {PluginOptions, Options} from '@docusaurus/plugin-content-showcase';
|
||||||
export const DEFAULT_OPTIONS: PluginOptions = {
|
export const DEFAULT_OPTIONS: PluginOptions = {
|
||||||
id: 'showcase',
|
id: 'showcase',
|
||||||
path: 'showcase', // Path to data on filesystem, relative to site dir.
|
path: 'showcase', // Path to data on filesystem, relative to site dir.
|
||||||
routeBasePath: '/', // URL Route.
|
routeBasePath: '/showcase', // URL Route.
|
||||||
include: ['**/*.{yml,yaml}'],
|
include: ['**/*.{yml,yaml}'],
|
||||||
|
// todo exclude won't work if user pass a custom file name
|
||||||
exclude: [...GlobExcludeDefault, 'tags.*'],
|
exclude: [...GlobExcludeDefault, 'tags.*'],
|
||||||
tags: 'tags.yaml',
|
tags: 'tags.yaml',
|
||||||
};
|
};
|
||||||
|
|
|
@ -8,15 +8,17 @@
|
||||||
declare module '@docusaurus/plugin-content-showcase' {
|
declare module '@docusaurus/plugin-content-showcase' {
|
||||||
import type {LoadContext, Plugin} from '@docusaurus/types';
|
import type {LoadContext, Plugin} from '@docusaurus/types';
|
||||||
|
|
||||||
export type TagOption = {
|
type Tag = {
|
||||||
[key: string]: {
|
label: string;
|
||||||
label: string;
|
description: {
|
||||||
description: {
|
message: string;
|
||||||
message: string;
|
id: string;
|
||||||
id: string;
|
|
||||||
};
|
|
||||||
color: string;
|
|
||||||
};
|
};
|
||||||
|
color: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TagsOption = {
|
||||||
|
[tagName: string]: Tag;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PluginOptions = {
|
export type PluginOptions = {
|
||||||
|
@ -25,32 +27,20 @@ declare module '@docusaurus/plugin-content-showcase' {
|
||||||
routeBasePath: string;
|
routeBasePath: string;
|
||||||
include: string[];
|
include: string[];
|
||||||
exclude: string[];
|
exclude: string[];
|
||||||
tags: string | TagOption[];
|
tags: string | TagsOption;
|
||||||
};
|
};
|
||||||
|
|
||||||
type TagType =
|
export type ShowcaseItem = {
|
||||||
| 'favorite'
|
|
||||||
| 'opensource'
|
|
||||||
| 'product'
|
|
||||||
| 'design'
|
|
||||||
| 'i18n'
|
|
||||||
| 'versioning'
|
|
||||||
| 'large'
|
|
||||||
| 'meta'
|
|
||||||
| 'personal'
|
|
||||||
| 'rtl';
|
|
||||||
|
|
||||||
export type ShowcaseFrontMatter = {
|
|
||||||
readonly title: string;
|
readonly title: string;
|
||||||
readonly description: string;
|
readonly description: string;
|
||||||
readonly preview: string | null; // null = use our serverless screenshot service
|
readonly preview: string | null; // null = use our serverless screenshot service
|
||||||
readonly website: string;
|
readonly website: string;
|
||||||
readonly source: string | null;
|
readonly source: string | null;
|
||||||
readonly tags: TagType[];
|
readonly tags: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ShowcaseItem = {
|
export type ShowcaseItems = {
|
||||||
items: ShowcaseFrontMatter[];
|
items: ShowcaseItem[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Options = Partial<PluginOptions>;
|
export type Options = Partial<PluginOptions>;
|
||||||
|
@ -58,5 +48,5 @@ declare module '@docusaurus/plugin-content-showcase' {
|
||||||
export default function pluginContentShowcase(
|
export default function pluginContentShowcase(
|
||||||
context: LoadContext,
|
context: LoadContext,
|
||||||
options: PluginOptions,
|
options: PluginOptions,
|
||||||
): Promise<Plugin<ShowcaseItem | null>>;
|
): Promise<Plugin<ShowcaseItems | null>>;
|
||||||
}
|
}
|
||||||
|
|
45
packages/docusaurus-plugin-content-showcase/src/tags.ts
Normal file
45
packages/docusaurus-plugin-content-showcase/src/tags.ts
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
/**
|
||||||
|
* 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 Yaml from 'js-yaml';
|
||||||
|
import {tagSchema} from './options';
|
||||||
|
import type {TagsOption} from '@docusaurus/plugin-content-showcase';
|
||||||
|
|
||||||
|
// todo extract in another file
|
||||||
|
export async function getTagsList({
|
||||||
|
configTags,
|
||||||
|
configPath,
|
||||||
|
}: {
|
||||||
|
configTags: string | TagsOption;
|
||||||
|
configPath: string;
|
||||||
|
}): Promise<string[]> {
|
||||||
|
if (typeof configTags === 'object') {
|
||||||
|
return Object.keys(configTags);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tagsPath = path.resolve(configPath, configTags);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await fs.readFile(tagsPath, 'utf-8');
|
||||||
|
const unsafeData = Yaml.load(data);
|
||||||
|
const tags = tagSchema.validate(unsafeData);
|
||||||
|
|
||||||
|
if (tags.error) {
|
||||||
|
throw new Error(
|
||||||
|
`There was an error extracting tags: ${tags.error.message}`,
|
||||||
|
{cause: tags.error},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tagLabels = Object.keys(tags.value);
|
||||||
|
return tagLabels;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Failed to read tags file for showcase`, {cause: error});
|
||||||
|
}
|
||||||
|
}
|
|
@ -6,9 +6,9 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {Joi, validateFrontMatter} from '@docusaurus/utils-validation';
|
import {Joi, validateFrontMatter} from '@docusaurus/utils-validation';
|
||||||
import type {ShowcaseFrontMatter} from '@docusaurus/plugin-content-showcase';
|
import type {ShowcaseItem} from '@docusaurus/plugin-content-showcase';
|
||||||
|
|
||||||
const showcaseFrontMatterSchema = Joi.object({
|
const showcaseItemSchema = Joi.object({
|
||||||
title: Joi.string().required(),
|
title: Joi.string().required(),
|
||||||
description: Joi.string().required(),
|
description: Joi.string().required(),
|
||||||
preview: Joi.string().required(),
|
preview: Joi.string().required(),
|
||||||
|
@ -17,10 +17,10 @@ const showcaseFrontMatterSchema = Joi.object({
|
||||||
tags: Joi.array().items(Joi.string()).required(),
|
tags: Joi.array().items(Joi.string()).required(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export function validateShowcaseFrontMatter(frontMatter: {
|
export function validateShowcaseItem(frontMatter: {
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}): ShowcaseFrontMatter {
|
}): ShowcaseItem {
|
||||||
return validateFrontMatter(frontMatter, showcaseFrontMatterSchema);
|
return validateFrontMatter(frontMatter, showcaseItemSchema);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validateFrontMatterTags(
|
export function validateFrontMatterTags(
|
|
@ -239,7 +239,12 @@ export default async function createConfigAsync() {
|
||||||
],
|
],
|
||||||
themes: ['live-codeblock', ...dogfoodingThemeInstances],
|
themes: ['live-codeblock', ...dogfoodingThemeInstances],
|
||||||
plugins: [
|
plugins: [
|
||||||
['content-showcase', {}],
|
[
|
||||||
|
'content-showcase',
|
||||||
|
{
|
||||||
|
routeBasePath: '/showcaseAll',
|
||||||
|
},
|
||||||
|
],
|
||||||
[
|
[
|
||||||
'./src/plugins/changelog/index.js',
|
'./src/plugins/changelog/index.js',
|
||||||
{
|
{
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue