mirror of
https://github.com/facebook/docusaurus.git
synced 2025-05-12 08:37:25 +02:00
feat(content-docs): expose isCategoryIndex matcher to customize conventions (#6451)
Co-authored-by: sebastienlorber <lorber.sebastien@gmail.com>
This commit is contained in:
parent
76a8d5f38a
commit
24a895fbc5
16 changed files with 408 additions and 93 deletions
|
@ -1491,6 +1491,7 @@ Object {
|
||||||
"unversionedId": "installation",
|
"unversionedId": "installation",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
"isCategoryIndex": [Function],
|
||||||
"item": Object {
|
"item": Object {
|
||||||
"dirName": ".",
|
"dirName": ".",
|
||||||
"type": "autogenerated",
|
"type": "autogenerated",
|
||||||
|
|
|
@ -12,7 +12,7 @@ import {
|
||||||
readVersionDocs,
|
readVersionDocs,
|
||||||
readDocFile,
|
readDocFile,
|
||||||
addDocNavigation,
|
addDocNavigation,
|
||||||
isConventionalDocIndex,
|
isCategoryIndex,
|
||||||
} from '../docs';
|
} from '../docs';
|
||||||
import {loadSidebars} from '../sidebars';
|
import {loadSidebars} from '../sidebars';
|
||||||
import {readVersionsMetadata} from '../versions';
|
import {readVersionsMetadata} from '../versions';
|
||||||
|
@ -938,108 +938,124 @@ describe('versioned site', () => {
|
||||||
describe('isConventionalDocIndex', () => {
|
describe('isConventionalDocIndex', () => {
|
||||||
test('supports readme', () => {
|
test('supports readme', () => {
|
||||||
expect(
|
expect(
|
||||||
isConventionalDocIndex({
|
isCategoryIndex({
|
||||||
sourceDirName: 'doesNotMatter',
|
fileName: 'readme',
|
||||||
source: 'readme.md',
|
directories: ['doesNotMatter'],
|
||||||
|
extension: '.md',
|
||||||
}),
|
}),
|
||||||
).toEqual(true);
|
).toEqual(true);
|
||||||
expect(
|
expect(
|
||||||
isConventionalDocIndex({
|
isCategoryIndex({
|
||||||
sourceDirName: 'doesNotMatter',
|
fileName: 'readme',
|
||||||
source: 'readme.mdx',
|
directories: ['doesNotMatter'],
|
||||||
|
extension: '.mdx',
|
||||||
}),
|
}),
|
||||||
).toEqual(true);
|
).toEqual(true);
|
||||||
expect(
|
expect(
|
||||||
isConventionalDocIndex({
|
isCategoryIndex({
|
||||||
sourceDirName: 'doesNotMatter',
|
fileName: 'README',
|
||||||
source: 'README.md',
|
directories: ['doesNotMatter'],
|
||||||
|
extension: '.md',
|
||||||
}),
|
}),
|
||||||
).toEqual(true);
|
).toEqual(true);
|
||||||
expect(
|
expect(
|
||||||
isConventionalDocIndex({
|
isCategoryIndex({
|
||||||
sourceDirName: 'doesNotMatter',
|
fileName: 'ReAdMe',
|
||||||
source: 'parent/ReAdMe',
|
directories: ['doesNotMatter'],
|
||||||
|
extension: '',
|
||||||
}),
|
}),
|
||||||
).toEqual(true);
|
).toEqual(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('supports index', () => {
|
test('supports index', () => {
|
||||||
expect(
|
expect(
|
||||||
isConventionalDocIndex({
|
isCategoryIndex({
|
||||||
sourceDirName: 'doesNotMatter',
|
fileName: 'index',
|
||||||
source: 'index.md',
|
directories: ['doesNotMatter'],
|
||||||
|
extension: '.md',
|
||||||
}),
|
}),
|
||||||
).toEqual(true);
|
).toEqual(true);
|
||||||
expect(
|
expect(
|
||||||
isConventionalDocIndex({
|
isCategoryIndex({
|
||||||
sourceDirName: 'doesNotMatter',
|
fileName: 'index',
|
||||||
source: 'index.mdx',
|
directories: ['doesNotMatter'],
|
||||||
|
extension: '.mdx',
|
||||||
}),
|
}),
|
||||||
).toEqual(true);
|
).toEqual(true);
|
||||||
expect(
|
expect(
|
||||||
isConventionalDocIndex({
|
isCategoryIndex({
|
||||||
sourceDirName: 'doesNotMatter',
|
fileName: 'INDEX',
|
||||||
source: 'INDEX.md',
|
directories: ['doesNotMatter'],
|
||||||
|
extension: '.md',
|
||||||
}),
|
}),
|
||||||
).toEqual(true);
|
).toEqual(true);
|
||||||
expect(
|
expect(
|
||||||
isConventionalDocIndex({
|
isCategoryIndex({
|
||||||
sourceDirName: 'doesNotMatter',
|
fileName: 'InDeX',
|
||||||
source: 'parent/InDeX',
|
directories: ['doesNotMatter'],
|
||||||
|
extension: '',
|
||||||
}),
|
}),
|
||||||
).toEqual(true);
|
).toEqual(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('supports <categoryName>/<categoryName>.md', () => {
|
test('supports <categoryName>/<categoryName>.md', () => {
|
||||||
expect(
|
expect(
|
||||||
isConventionalDocIndex({
|
isCategoryIndex({
|
||||||
sourceDirName: 'someCategory',
|
fileName: 'someCategory',
|
||||||
source: 'someCategory',
|
directories: ['someCategory', 'doesNotMatter'],
|
||||||
|
extension: '',
|
||||||
}),
|
}),
|
||||||
).toEqual(true);
|
).toEqual(true);
|
||||||
expect(
|
expect(
|
||||||
isConventionalDocIndex({
|
isCategoryIndex({
|
||||||
sourceDirName: 'someCategory',
|
fileName: 'someCategory',
|
||||||
source: 'someCategory.md',
|
directories: ['someCategory'],
|
||||||
|
extension: '.md',
|
||||||
}),
|
}),
|
||||||
).toEqual(true);
|
).toEqual(true);
|
||||||
expect(
|
expect(
|
||||||
isConventionalDocIndex({
|
isCategoryIndex({
|
||||||
sourceDirName: 'someCategory',
|
fileName: 'someCategory',
|
||||||
source: 'someCategory.mdx',
|
directories: ['someCategory'],
|
||||||
|
extension: '.mdx',
|
||||||
}),
|
}),
|
||||||
).toEqual(true);
|
).toEqual(true);
|
||||||
expect(
|
expect(
|
||||||
isConventionalDocIndex({
|
isCategoryIndex({
|
||||||
sourceDirName: 'some_category',
|
fileName: 'SOME_CATEGORY',
|
||||||
source: 'SOME_CATEGORY.md',
|
directories: ['some_category'],
|
||||||
|
extension: '.md',
|
||||||
}),
|
}),
|
||||||
).toEqual(true);
|
).toEqual(true);
|
||||||
expect(
|
expect(
|
||||||
isConventionalDocIndex({
|
isCategoryIndex({
|
||||||
sourceDirName: 'some_category',
|
fileName: 'some_category',
|
||||||
source: 'parent/some_category',
|
directories: ['some_category'],
|
||||||
|
extension: '',
|
||||||
}),
|
}),
|
||||||
).toEqual(true);
|
).toEqual(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('reject other cases', () => {
|
test('reject other cases', () => {
|
||||||
expect(
|
expect(
|
||||||
isConventionalDocIndex({
|
isCategoryIndex({
|
||||||
sourceDirName: 'someCategory',
|
fileName: 'some_Category',
|
||||||
source: 'some_Category',
|
directories: ['someCategory'],
|
||||||
|
extension: '',
|
||||||
}),
|
}),
|
||||||
).toEqual(false);
|
).toEqual(false);
|
||||||
expect(
|
expect(
|
||||||
isConventionalDocIndex({
|
isCategoryIndex({
|
||||||
sourceDirName: 'doesNotMatter',
|
fileName: 'read_me',
|
||||||
source: 'read_me',
|
directories: ['doesNotMatter'],
|
||||||
|
extension: '',
|
||||||
}),
|
}),
|
||||||
).toEqual(false);
|
).toEqual(false);
|
||||||
expect(
|
expect(
|
||||||
isConventionalDocIndex({
|
isCategoryIndex({
|
||||||
sourceDirName: 'doesNotMatter',
|
fileName: 'the index',
|
||||||
source: 'the index',
|
directories: ['doesNotMatter'],
|
||||||
|
extension: '',
|
||||||
}),
|
}),
|
||||||
).toEqual(false);
|
).toEqual(false);
|
||||||
});
|
});
|
||||||
|
|
|
@ -8,7 +8,7 @@
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import fs from 'fs-extra';
|
import fs from 'fs-extra';
|
||||||
import logger from '@docusaurus/logger';
|
import logger from '@docusaurus/logger';
|
||||||
import {keyBy, last} from 'lodash';
|
import {keyBy} from 'lodash';
|
||||||
import {
|
import {
|
||||||
aliasedSitePath,
|
aliasedSitePath,
|
||||||
getEditUrl,
|
getEditUrl,
|
||||||
|
@ -41,6 +41,8 @@ import {toDocNavigationLink, toNavigationLink} from './sidebars/utils';
|
||||||
import type {
|
import type {
|
||||||
MetadataOptions,
|
MetadataOptions,
|
||||||
PluginOptions,
|
PluginOptions,
|
||||||
|
CategoryIndexMatcher,
|
||||||
|
CategoryIndexMatcherParam,
|
||||||
} from '@docusaurus/plugin-content-docs';
|
} from '@docusaurus/plugin-content-docs';
|
||||||
|
|
||||||
type LastUpdateOptions = Pick<
|
type LastUpdateOptions = Pick<
|
||||||
|
@ -367,31 +369,62 @@ export function getMainDocId({
|
||||||
return getMainDoc().unversionedId;
|
return getMainDoc().unversionedId;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLastPathSegment(str: string): string {
|
|
||||||
return last(str.split('/'))!;
|
|
||||||
}
|
|
||||||
|
|
||||||
// By convention, Docusaurus considers some docs are "indexes":
|
// By convention, Docusaurus considers some docs are "indexes":
|
||||||
// - index.md
|
// - index.md
|
||||||
// - readme.md
|
// - readme.md
|
||||||
// - <folder>/<folder>.md
|
// - <folder>/<folder>.md
|
||||||
//
|
//
|
||||||
|
// This function is the default implementation of this convention
|
||||||
|
//
|
||||||
// Those index docs produce a different behavior
|
// Those index docs produce a different behavior
|
||||||
// - Slugs do not end with a weird "/index" suffix
|
// - Slugs do not end with a weird "/index" suffix
|
||||||
// - Auto-generated sidebar categories link to them as intro
|
// - Auto-generated sidebar categories link to them as intro
|
||||||
export function isConventionalDocIndex(doc: {
|
export const isCategoryIndex: CategoryIndexMatcher = ({
|
||||||
source: DocMetadataBase['slug'];
|
fileName,
|
||||||
sourceDirName: DocMetadataBase['sourceDirName'];
|
directories,
|
||||||
}): boolean {
|
}): boolean => {
|
||||||
// "@site/docs/folder/subFolder/subSubFolder/myDoc.md" => "myDoc"
|
const eligibleDocIndexNames = [
|
||||||
const docName = path.parse(doc.source).name;
|
'index',
|
||||||
|
'readme',
|
||||||
|
directories[0]?.toLowerCase(),
|
||||||
|
];
|
||||||
|
return eligibleDocIndexNames.includes(fileName.toLowerCase());
|
||||||
|
};
|
||||||
|
|
||||||
// "folder/subFolder/subSubFolder" => "subSubFolder"
|
export function toCategoryIndexMatcherParam({
|
||||||
const lastDirName = getLastPathSegment(doc.sourceDirName);
|
source,
|
||||||
|
sourceDirName,
|
||||||
|
}: Pick<
|
||||||
|
DocMetadataBase,
|
||||||
|
'source' | 'sourceDirName'
|
||||||
|
>): CategoryIndexMatcherParam {
|
||||||
|
// source + sourceDirName are always posix-style
|
||||||
|
return {
|
||||||
|
fileName: path.posix.parse(source).name,
|
||||||
|
extension: path.posix.parse(source).ext,
|
||||||
|
directories: sourceDirName.split(path.posix.sep).reverse(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const eligibleDocIndexNames = ['index', 'readme', lastDirName.toLowerCase()];
|
/**
|
||||||
|
* guides/sidebar/autogenerated.md -> 'autogenerated', '.md', ['sidebar', 'guides']
|
||||||
return eligibleDocIndexNames.includes(docName.toLowerCase());
|
*/
|
||||||
|
export function splitPath(str: string): {
|
||||||
|
/**
|
||||||
|
* The list of directories, from lowest level to highest.
|
||||||
|
* If there's no dir name, directories is ['.']
|
||||||
|
*/
|
||||||
|
directories: string[];
|
||||||
|
/** The file name, without extension */
|
||||||
|
fileName: string;
|
||||||
|
/** The extension, with a leading dot */
|
||||||
|
extension: string;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
fileName: path.parse(str).name,
|
||||||
|
extension: path.parse(str).ext,
|
||||||
|
directories: path.dirname(str).split(path.sep).reverse(),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return both doc ids
|
// Return both doc ids
|
||||||
|
|
|
@ -13,6 +13,15 @@ declare module '@docusaurus/plugin-content-docs' {
|
||||||
numberPrefix?: number;
|
numberPrefix?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CategoryIndexMatcherParam = {
|
||||||
|
fileName: string;
|
||||||
|
directories: string[];
|
||||||
|
extension: string;
|
||||||
|
};
|
||||||
|
export type CategoryIndexMatcher = (
|
||||||
|
param: CategoryIndexMatcherParam,
|
||||||
|
) => boolean;
|
||||||
|
|
||||||
export type EditUrlFunction = (editUrlParams: {
|
export type EditUrlFunction = (editUrlParams: {
|
||||||
version: string;
|
version: string;
|
||||||
versionDocsDirPath: string;
|
versionDocsDirPath: string;
|
||||||
|
|
|
@ -12,6 +12,7 @@ import {
|
||||||
import type {Sidebar, SidebarItemsGenerator} from '../types';
|
import type {Sidebar, SidebarItemsGenerator} from '../types';
|
||||||
import fs from 'fs-extra';
|
import fs from 'fs-extra';
|
||||||
import {DefaultNumberPrefixParser} from '../../numberPrefix';
|
import {DefaultNumberPrefixParser} from '../../numberPrefix';
|
||||||
|
import {isCategoryIndex} from '../../docs';
|
||||||
|
|
||||||
describe('DefaultSidebarItemsGenerator', () => {
|
describe('DefaultSidebarItemsGenerator', () => {
|
||||||
function testDefaultSidebarItemsGenerator(
|
function testDefaultSidebarItemsGenerator(
|
||||||
|
@ -19,6 +20,7 @@ describe('DefaultSidebarItemsGenerator', () => {
|
||||||
) {
|
) {
|
||||||
return DefaultSidebarItemsGenerator({
|
return DefaultSidebarItemsGenerator({
|
||||||
numberPrefixParser: DefaultNumberPrefixParser,
|
numberPrefixParser: DefaultNumberPrefixParser,
|
||||||
|
isCategoryIndex,
|
||||||
item: {
|
item: {
|
||||||
type: 'autogenerated',
|
type: 'autogenerated',
|
||||||
dirName: '.',
|
dirName: '.',
|
||||||
|
@ -146,6 +148,7 @@ describe('DefaultSidebarItemsGenerator', () => {
|
||||||
|
|
||||||
const sidebarSlice = await DefaultSidebarItemsGenerator({
|
const sidebarSlice = await DefaultSidebarItemsGenerator({
|
||||||
numberPrefixParser: DefaultNumberPrefixParser,
|
numberPrefixParser: DefaultNumberPrefixParser,
|
||||||
|
isCategoryIndex,
|
||||||
item: {
|
item: {
|
||||||
type: 'autogenerated',
|
type: 'autogenerated',
|
||||||
dirName: '.',
|
dirName: '.',
|
||||||
|
@ -157,48 +160,48 @@ describe('DefaultSidebarItemsGenerator', () => {
|
||||||
docs: [
|
docs: [
|
||||||
{
|
{
|
||||||
id: 'intro',
|
id: 'intro',
|
||||||
source: 'intro.md',
|
source: '@site/docs/intro.md',
|
||||||
sourceDirName: '.',
|
sourceDirName: '.',
|
||||||
sidebarPosition: 1,
|
sidebarPosition: 1,
|
||||||
frontMatter: {},
|
frontMatter: {},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'tutorials-index',
|
id: 'tutorials-index',
|
||||||
source: 'index.md',
|
source: '@site/docs/01-Tutorials/index.md',
|
||||||
sourceDirName: '01-Tutorials',
|
sourceDirName: '01-Tutorials',
|
||||||
sidebarPosition: 2,
|
sidebarPosition: 2,
|
||||||
frontMatter: {},
|
frontMatter: {},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'tutorial2',
|
id: 'tutorial2',
|
||||||
source: 'tutorial2.md',
|
source: '@site/docs/01-Tutorials/tutorial2.md',
|
||||||
sourceDirName: '01-Tutorials',
|
sourceDirName: '01-Tutorials',
|
||||||
sidebarPosition: 2,
|
sidebarPosition: 2,
|
||||||
frontMatter: {},
|
frontMatter: {},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'tutorial1',
|
id: 'tutorial1',
|
||||||
source: 'tutorial1.md',
|
source: '@site/docs/01-Tutorials/tutorial1.md',
|
||||||
sourceDirName: '01-Tutorials',
|
sourceDirName: '01-Tutorials',
|
||||||
sidebarPosition: 1,
|
sidebarPosition: 1,
|
||||||
frontMatter: {},
|
frontMatter: {},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'guides-index',
|
id: 'guides-index',
|
||||||
source: '02-Guides.md', // TODO should we allow to just use "Guides.md" to have an index?
|
source: '@site/docs/02-Guides/02-Guides.md', // TODO should we allow to just use "Guides.md" to have an index?
|
||||||
sourceDirName: '02-Guides',
|
sourceDirName: '02-Guides',
|
||||||
frontMatter: {},
|
frontMatter: {},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'guide2',
|
id: 'guide2',
|
||||||
source: 'guide2.md',
|
source: '@site/docs/02-Guides/guide2.md',
|
||||||
sourceDirName: '02-Guides',
|
sourceDirName: '02-Guides',
|
||||||
sidebarPosition: 2,
|
sidebarPosition: 2,
|
||||||
frontMatter: {},
|
frontMatter: {},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'guide1',
|
id: 'guide1',
|
||||||
source: 'guide1.md',
|
source: '@site/docs/02-Guides/guide1.md',
|
||||||
sourceDirName: '02-Guides',
|
sourceDirName: '02-Guides',
|
||||||
sidebarPosition: 1,
|
sidebarPosition: 1,
|
||||||
frontMatter: {
|
frontMatter: {
|
||||||
|
@ -207,14 +210,14 @@ describe('DefaultSidebarItemsGenerator', () => {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'nested-guide',
|
id: 'nested-guide',
|
||||||
source: 'nested-guide.md',
|
source: '@site/docs/02-Guides/01-SubGuides/nested-guide.md',
|
||||||
sourceDirName: '02-Guides/01-SubGuides',
|
sourceDirName: '02-Guides/01-SubGuides',
|
||||||
sidebarPosition: undefined,
|
sidebarPosition: undefined,
|
||||||
frontMatter: {},
|
frontMatter: {},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'end',
|
id: 'end',
|
||||||
source: 'end.md',
|
source: '@site/docs/end.md',
|
||||||
sourceDirName: '.',
|
sourceDirName: '.',
|
||||||
sidebarPosition: 3,
|
sidebarPosition: 3,
|
||||||
frontMatter: {},
|
frontMatter: {},
|
||||||
|
@ -296,6 +299,7 @@ describe('DefaultSidebarItemsGenerator', () => {
|
||||||
|
|
||||||
const sidebarSlice = await DefaultSidebarItemsGenerator({
|
const sidebarSlice = await DefaultSidebarItemsGenerator({
|
||||||
numberPrefixParser: DefaultNumberPrefixParser,
|
numberPrefixParser: DefaultNumberPrefixParser,
|
||||||
|
isCategoryIndex,
|
||||||
item: {
|
item: {
|
||||||
type: 'autogenerated',
|
type: 'autogenerated',
|
||||||
dirName: 'subfolder/subsubfolder',
|
dirName: 'subfolder/subsubfolder',
|
||||||
|
@ -427,19 +431,19 @@ describe('DefaultSidebarItemsGenerator', () => {
|
||||||
docs: [
|
docs: [
|
||||||
{
|
{
|
||||||
id: 'parent/doc1',
|
id: 'parent/doc1',
|
||||||
source: 'index.md',
|
source: '@site/docs/Category/index.md',
|
||||||
sourceDirName: 'Category',
|
sourceDirName: 'Category',
|
||||||
frontMatter: {},
|
frontMatter: {},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'parent/doc2',
|
id: 'parent/doc2',
|
||||||
source: 'index.md',
|
source: '@site/docs/Category/index.md',
|
||||||
sourceDirName: 'Category',
|
sourceDirName: 'Category',
|
||||||
frontMatter: {},
|
frontMatter: {},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'parent/doc3',
|
id: 'parent/doc3',
|
||||||
source: 'doc3.md',
|
source: '@site/docs/Category/doc3.md',
|
||||||
sourceDirName: 'Category',
|
sourceDirName: 'Category',
|
||||||
frontMatter: {},
|
frontMatter: {},
|
||||||
},
|
},
|
||||||
|
@ -473,4 +477,116 @@ describe('DefaultSidebarItemsGenerator', () => {
|
||||||
},
|
},
|
||||||
] as Sidebar);
|
] as Sidebar);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('respects custom isCategoryIndex', async () => {
|
||||||
|
const sidebarSlice = await DefaultSidebarItemsGenerator({
|
||||||
|
numberPrefixParser: DefaultNumberPrefixParser,
|
||||||
|
isCategoryIndex({fileName, directories}) {
|
||||||
|
return (
|
||||||
|
fileName.replace(
|
||||||
|
`${DefaultNumberPrefixParser(
|
||||||
|
directories[0],
|
||||||
|
).filename.toLowerCase()}-`,
|
||||||
|
'',
|
||||||
|
) === 'index'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
item: {
|
||||||
|
type: 'autogenerated',
|
||||||
|
dirName: '.',
|
||||||
|
},
|
||||||
|
version: {
|
||||||
|
versionName: 'current',
|
||||||
|
contentPath: '',
|
||||||
|
},
|
||||||
|
docs: [
|
||||||
|
{
|
||||||
|
id: 'intro',
|
||||||
|
source: '@site/docs/intro.md',
|
||||||
|
sourceDirName: '.',
|
||||||
|
sidebarPosition: 1,
|
||||||
|
frontMatter: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tutorials-index',
|
||||||
|
source: '@site/docs/01-Tutorials/tutorials-index.md',
|
||||||
|
sourceDirName: '01-Tutorials',
|
||||||
|
sidebarPosition: 2,
|
||||||
|
frontMatter: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tutorial2',
|
||||||
|
source: '@site/docs/01-Tutorials/tutorial2.md',
|
||||||
|
sourceDirName: '01-Tutorials',
|
||||||
|
sidebarPosition: 2,
|
||||||
|
frontMatter: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tutorial1',
|
||||||
|
source: '@site/docs/01-Tutorials/tutorial1.md',
|
||||||
|
sourceDirName: '01-Tutorials',
|
||||||
|
sidebarPosition: 1,
|
||||||
|
frontMatter: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'not-guides-index',
|
||||||
|
source: '@site/docs/02-Guides/README.md',
|
||||||
|
sourceDirName: '02-Guides',
|
||||||
|
frontMatter: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'guide2',
|
||||||
|
source: '@site/docs/02-Guides/guide2.md',
|
||||||
|
sourceDirName: '02-Guides',
|
||||||
|
sidebarPosition: 2,
|
||||||
|
frontMatter: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'guide1',
|
||||||
|
source: '@site/docs/02-Guides/guide1.md',
|
||||||
|
sourceDirName: '02-Guides',
|
||||||
|
sidebarPosition: 1,
|
||||||
|
frontMatter: {
|
||||||
|
sidebar_class_name: 'foo',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
options: {
|
||||||
|
sidebarCollapsed: true,
|
||||||
|
sidebarCollapsible: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sidebarSlice).toEqual([
|
||||||
|
{type: 'doc', id: 'intro'},
|
||||||
|
{
|
||||||
|
type: 'category',
|
||||||
|
label: 'Tutorials',
|
||||||
|
collapsed: true,
|
||||||
|
collapsible: true,
|
||||||
|
link: {
|
||||||
|
type: 'doc',
|
||||||
|
id: 'tutorials-index',
|
||||||
|
},
|
||||||
|
items: [
|
||||||
|
{type: 'doc', id: 'tutorial1'},
|
||||||
|
{type: 'doc', id: 'tutorial2'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'category',
|
||||||
|
label: 'Guides',
|
||||||
|
collapsed: true,
|
||||||
|
collapsible: true,
|
||||||
|
items: [
|
||||||
|
{type: 'doc', id: 'guide1', className: 'foo'},
|
||||||
|
{type: 'doc', id: 'guide2'},
|
||||||
|
{
|
||||||
|
type: 'doc',
|
||||||
|
id: 'not-guides-index',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
] as Sidebar);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -16,6 +16,7 @@ import {DefaultSidebarItemsGenerator} from '../generator';
|
||||||
import {createSlugger} from '@docusaurus/utils';
|
import {createSlugger} from '@docusaurus/utils';
|
||||||
import type {VersionMetadata} from '../../types';
|
import type {VersionMetadata} from '../../types';
|
||||||
import {DefaultNumberPrefixParser} from '../../numberPrefix';
|
import {DefaultNumberPrefixParser} from '../../numberPrefix';
|
||||||
|
import {isCategoryIndex} from '../../docs';
|
||||||
|
|
||||||
describe('processSidebars', () => {
|
describe('processSidebars', () => {
|
||||||
function createStaticSidebarItemGenerator(
|
function createStaticSidebarItemGenerator(
|
||||||
|
@ -137,6 +138,7 @@ describe('processSidebars', () => {
|
||||||
versionName: version.versionName,
|
versionName: version.versionName,
|
||||||
},
|
},
|
||||||
numberPrefixParser: DefaultNumberPrefixParser,
|
numberPrefixParser: DefaultNumberPrefixParser,
|
||||||
|
isCategoryIndex,
|
||||||
options: params.sidebarOptions,
|
options: params.sidebarOptions,
|
||||||
});
|
});
|
||||||
expect(StaticSidebarItemsGenerator).toHaveBeenCalledWith({
|
expect(StaticSidebarItemsGenerator).toHaveBeenCalledWith({
|
||||||
|
@ -147,6 +149,7 @@ describe('processSidebars', () => {
|
||||||
versionName: version.versionName,
|
versionName: version.versionName,
|
||||||
},
|
},
|
||||||
numberPrefixParser: DefaultNumberPrefixParser,
|
numberPrefixParser: DefaultNumberPrefixParser,
|
||||||
|
isCategoryIndex,
|
||||||
options: params.sidebarOptions,
|
options: params.sidebarOptions,
|
||||||
});
|
});
|
||||||
expect(StaticSidebarItemsGenerator).toHaveBeenCalledWith({
|
expect(StaticSidebarItemsGenerator).toHaveBeenCalledWith({
|
||||||
|
@ -157,6 +160,7 @@ describe('processSidebars', () => {
|
||||||
versionName: version.versionName,
|
versionName: version.versionName,
|
||||||
},
|
},
|
||||||
numberPrefixParser: DefaultNumberPrefixParser,
|
numberPrefixParser: DefaultNumberPrefixParser,
|
||||||
|
isCategoryIndex,
|
||||||
options: params.sidebarOptions,
|
options: params.sidebarOptions,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -25,7 +25,7 @@ import path from 'path';
|
||||||
import fs from 'fs-extra';
|
import fs from 'fs-extra';
|
||||||
import Yaml from 'js-yaml';
|
import Yaml from 'js-yaml';
|
||||||
import {validateCategoryMetadataFile} from './validation';
|
import {validateCategoryMetadataFile} from './validation';
|
||||||
import {createDocsByIdIndex, isConventionalDocIndex} from '../docs';
|
import {createDocsByIdIndex, toCategoryIndexMatcherParam} from '../docs';
|
||||||
|
|
||||||
const BreadcrumbSeparator = '/';
|
const BreadcrumbSeparator = '/';
|
||||||
// To avoid possible name clashes with a folder of the same name as the ID
|
// To avoid possible name clashes with a folder of the same name as the ID
|
||||||
|
@ -94,6 +94,7 @@ async function readCategoryMetadataFile(
|
||||||
// Comment for this feature: https://github.com/facebook/docusaurus/issues/3464#issuecomment-818670449
|
// Comment for this feature: https://github.com/facebook/docusaurus/issues/3464#issuecomment-818670449
|
||||||
export const DefaultSidebarItemsGenerator: SidebarItemsGenerator = async ({
|
export const DefaultSidebarItemsGenerator: SidebarItemsGenerator = async ({
|
||||||
numberPrefixParser,
|
numberPrefixParser,
|
||||||
|
isCategoryIndex,
|
||||||
docs: allDocs,
|
docs: allDocs,
|
||||||
options,
|
options,
|
||||||
item: {dirName: autogenDir},
|
item: {dirName: autogenDir},
|
||||||
|
@ -210,10 +211,13 @@ export const DefaultSidebarItemsGenerator: SidebarItemsGenerator = async ({
|
||||||
}
|
}
|
||||||
|
|
||||||
function findConventionalCategoryDocLink(): SidebarItemDoc | undefined {
|
function findConventionalCategoryDocLink(): SidebarItemDoc | undefined {
|
||||||
return allItems.find(
|
return allItems.find((item) => {
|
||||||
(item) =>
|
if (item.type !== 'doc') {
|
||||||
item.type === 'doc' && isConventionalDocIndex(getDoc(item.id)),
|
return false;
|
||||||
) as SidebarItemDoc | undefined;
|
}
|
||||||
|
const doc = getDoc(item.id);
|
||||||
|
return isCategoryIndex(toCategoryIndexMatcherParam(doc));
|
||||||
|
}) as SidebarItemDoc | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCategoryLinkedDocId(): string | undefined {
|
function getCategoryLinkedDocId(): string | undefined {
|
||||||
|
|
|
@ -25,6 +25,7 @@ import {DefaultSidebarItemsGenerator} from './generator';
|
||||||
import {mapValues, memoize, pick} from 'lodash';
|
import {mapValues, memoize, pick} from 'lodash';
|
||||||
import combinePromises from 'combine-promises';
|
import combinePromises from 'combine-promises';
|
||||||
import {normalizeItem} from './normalization';
|
import {normalizeItem} from './normalization';
|
||||||
|
import {isCategoryIndex} from '../docs';
|
||||||
import type {Slugger} from '@docusaurus/utils';
|
import type {Slugger} from '@docusaurus/utils';
|
||||||
import type {
|
import type {
|
||||||
NumberPrefixParser,
|
NumberPrefixParser,
|
||||||
|
@ -95,6 +96,7 @@ async function processSidebar(
|
||||||
item,
|
item,
|
||||||
numberPrefixParser,
|
numberPrefixParser,
|
||||||
defaultSidebarItemsGenerator: DefaultSidebarItemsGenerator,
|
defaultSidebarItemsGenerator: DefaultSidebarItemsGenerator,
|
||||||
|
isCategoryIndex,
|
||||||
...getSidebarItemsGeneratorDocsAndVersion(),
|
...getSidebarItemsGeneratorDocsAndVersion(),
|
||||||
options: sidebarOptions,
|
options: sidebarOptions,
|
||||||
});
|
});
|
||||||
|
|
|
@ -10,6 +10,7 @@ import type {DocMetadataBase, VersionMetadata} from '../types';
|
||||||
import type {
|
import type {
|
||||||
NumberPrefixParser,
|
NumberPrefixParser,
|
||||||
SidebarOptions,
|
SidebarOptions,
|
||||||
|
CategoryIndexMatcher,
|
||||||
} from '@docusaurus/plugin-content-docs';
|
} from '@docusaurus/plugin-content-docs';
|
||||||
|
|
||||||
// Makes all properties visible when hovering over the type
|
// Makes all properties visible when hovering over the type
|
||||||
|
@ -195,6 +196,7 @@ export type SidebarItemsGeneratorArgs = {
|
||||||
version: SidebarItemsGeneratorVersion;
|
version: SidebarItemsGeneratorVersion;
|
||||||
docs: SidebarItemsGeneratorDoc[];
|
docs: SidebarItemsGeneratorDoc[];
|
||||||
numberPrefixParser: NumberPrefixParser;
|
numberPrefixParser: NumberPrefixParser;
|
||||||
|
isCategoryIndex: CategoryIndexMatcher;
|
||||||
options: SidebarOptions;
|
options: SidebarOptions;
|
||||||
};
|
};
|
||||||
export type SidebarItemsGenerator = (
|
export type SidebarItemsGenerator = (
|
||||||
|
|
|
@ -16,7 +16,7 @@ import {
|
||||||
stripPathNumberPrefixes,
|
stripPathNumberPrefixes,
|
||||||
} from './numberPrefix';
|
} from './numberPrefix';
|
||||||
import type {DocMetadataBase} from './types';
|
import type {DocMetadataBase} from './types';
|
||||||
import {isConventionalDocIndex} from './docs';
|
import {isCategoryIndex, toCategoryIndexMatcherParam} from './docs';
|
||||||
import type {NumberPrefixParser} from '@docusaurus/plugin-content-docs';
|
import type {NumberPrefixParser} from '@docusaurus/plugin-content-docs';
|
||||||
|
|
||||||
export default function getSlug({
|
export default function getSlug({
|
||||||
|
@ -29,7 +29,7 @@ export default function getSlug({
|
||||||
}: {
|
}: {
|
||||||
baseID: string;
|
baseID: string;
|
||||||
frontMatterSlug?: string;
|
frontMatterSlug?: string;
|
||||||
source: DocMetadataBase['slug'];
|
source: DocMetadataBase['source'];
|
||||||
sourceDirName: DocMetadataBase['sourceDirName'];
|
sourceDirName: DocMetadataBase['sourceDirName'];
|
||||||
stripDirNumberPrefixes?: boolean;
|
stripDirNumberPrefixes?: boolean;
|
||||||
numberPrefixParser?: NumberPrefixParser;
|
numberPrefixParser?: NumberPrefixParser;
|
||||||
|
@ -50,7 +50,10 @@ export default function getSlug({
|
||||||
return frontMatterSlug;
|
return frontMatterSlug;
|
||||||
} else {
|
} else {
|
||||||
const dirNameSlug = getDirNameSlug();
|
const dirNameSlug = getDirNameSlug();
|
||||||
if (!frontMatterSlug && isConventionalDocIndex({source, sourceDirName})) {
|
if (
|
||||||
|
!frontMatterSlug &&
|
||||||
|
isCategoryIndex(toCategoryIndexMatcherParam({source, sourceDirName}))
|
||||||
|
) {
|
||||||
return dirNameSlug;
|
return dirNameSlug;
|
||||||
}
|
}
|
||||||
const baseSlug = frontMatterSlug || baseID;
|
const baseSlug = frontMatterSlug || baseID;
|
||||||
|
|
|
@ -82,8 +82,8 @@ export type DocMetadataBase = LastUpdateData & {
|
||||||
version: string;
|
version: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
source: string; // @site aliased source => "@site/docs/folder/subFolder/subSubFolder/myDoc.md"
|
source: string; // @site aliased posix source => "@site/docs/folder/subFolder/subSubFolder/myDoc.md"
|
||||||
sourceDirName: string; // relative to the versioned docs folder (can be ".") => "folder/subFolder/subSubFolder"
|
sourceDirName: string; // posix path relative to the versioned docs folder (can be ".") => "folder/subFolder/subSubFolder"
|
||||||
slug: string;
|
slug: string;
|
||||||
permalink: string;
|
permalink: string;
|
||||||
sidebarPosition?: number;
|
sidebarPosition?: number;
|
||||||
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
# Introduction
|
||||||
|
|
||||||
|
This file is called `intro.md`. Typically, it won't be selected by the convention; however, it is in this case, because we have used a custom one.
|
|
@ -0,0 +1,3 @@
|
||||||
|
# Sample doc
|
||||||
|
|
||||||
|
Lorem Ipsum
|
|
@ -21,6 +21,20 @@ const dogfoodingPluginInstances = [
|
||||||
// The target folder uses a _ prefix to test against an edge case regarding MDX partials: https://github.com/facebook/docusaurus/discussions/5181#discussioncomment-1018079
|
// The target folder uses a _ prefix to test against an edge case regarding MDX partials: https://github.com/facebook/docusaurus/discussions/5181#discussioncomment-1018079
|
||||||
path: fs.realpathSync('_dogfooding/docs-tests-symlink'),
|
path: fs.realpathSync('_dogfooding/docs-tests-symlink'),
|
||||||
showLastUpdateTime: true,
|
showLastUpdateTime: true,
|
||||||
|
sidebarItemsGenerator(args) {
|
||||||
|
return args.defaultSidebarItemsGenerator({
|
||||||
|
...args,
|
||||||
|
isCategoryIndex({fileName, directories}) {
|
||||||
|
const eligibleDocIndexNames = [
|
||||||
|
'index',
|
||||||
|
'readme',
|
||||||
|
directories[0].toLowerCase(),
|
||||||
|
'intro',
|
||||||
|
];
|
||||||
|
return eligibleDocIndexNames.includes(fileName.toLowerCase());
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|
|
@ -77,6 +77,12 @@ type PrefixParser = (filename: string) => {
|
||||||
numberPrefix?: number;
|
numberPrefix?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type CategoryIndexMatcher = (doc: {
|
||||||
|
fileName: string;
|
||||||
|
directories: string[];
|
||||||
|
extension: string;
|
||||||
|
}) => boolean;
|
||||||
|
|
||||||
type SidebarGenerator = (generatorArgs: {
|
type SidebarGenerator = (generatorArgs: {
|
||||||
item: {type: 'autogenerated'; dirName: string}; // the sidebar item with type "autogenerated"
|
item: {type: 'autogenerated'; dirName: string}; // the sidebar item with type "autogenerated"
|
||||||
version: {contentPath: string; versionName: string}; // the current version
|
version: {contentPath: string; versionName: string}; // the current version
|
||||||
|
@ -88,6 +94,7 @@ type SidebarGenerator = (generatorArgs: {
|
||||||
sidebarPosition?: number | undefined;
|
sidebarPosition?: number | undefined;
|
||||||
}>; // all the docs of that version (unfiltered)
|
}>; // all the docs of that version (unfiltered)
|
||||||
numberPrefixParser: PrefixParser; // numberPrefixParser configured for this plugin
|
numberPrefixParser: PrefixParser; // numberPrefixParser configured for this plugin
|
||||||
|
isCategoryIndex: CategoryIndexMatcher; // the default category index matcher, that you can override
|
||||||
defaultSidebarItemsGenerator: SidebarGenerator; // useful to re-use/enhance default sidebar generation logic from Docusaurus
|
defaultSidebarItemsGenerator: SidebarGenerator; // useful to re-use/enhance default sidebar generation logic from Docusaurus
|
||||||
}) => Promise<SidebarItem[]>;
|
}) => Promise<SidebarItem[]>;
|
||||||
|
|
||||||
|
@ -141,6 +148,7 @@ const config = {
|
||||||
item,
|
item,
|
||||||
version,
|
version,
|
||||||
docs,
|
docs,
|
||||||
|
isCategoryIndex,
|
||||||
}) {
|
}) {
|
||||||
// Use the provided data to generate a custom sidebar slice
|
// Use the provided data to generate a custom sidebar slice
|
||||||
return [
|
return [
|
||||||
|
@ -274,15 +282,15 @@ website/i18n/[locale]/docusaurus-plugin-content-docs
|
||||||
│
|
│
|
||||||
│ # translations for website/docs
|
│ # translations for website/docs
|
||||||
├── current
|
├── current
|
||||||
│ ├── api
|
│ ├── api
|
||||||
│ │ └── config.md
|
│ │ └── config.md
|
||||||
│ └── getting-started.md
|
│ └── getting-started.md
|
||||||
├── current.json
|
├── current.json
|
||||||
│
|
│
|
||||||
│ # translations for website/versioned_docs/version-1.0.0
|
│ # translations for website/versioned_docs/version-1.0.0
|
||||||
├── version-1.0.0
|
├── version-1.0.0
|
||||||
│ ├── api
|
│ ├── api
|
||||||
│ │ └── config.md
|
│ │ └── config.md
|
||||||
│ └── getting-started.md
|
│ └── getting-started.md
|
||||||
└── version-1.0.0.json
|
└── version-1.0.0.json
|
||||||
```
|
```
|
||||||
|
|
|
@ -194,6 +194,102 @@ Naming your introductory document `README.md` makes it show up when browsing the
|
||||||
|
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
<details>
|
||||||
|
|
||||||
|
<summary>Customizing category index matching</summary>
|
||||||
|
|
||||||
|
It is possible to opt out any of the category index conventions, or define even more conventions. You can inject your own `isCategoryIndex` matcher through the [`sidebarItemsGenerator`](#customize-the-sidebar-items-generator) callback. For example, you can also pick `intro` as another file name eligible for automatically becoming the category index.
|
||||||
|
|
||||||
|
```js title="docusaurus.config.js"
|
||||||
|
module.exports = {
|
||||||
|
plugins: [
|
||||||
|
[
|
||||||
|
'@docusaurus/plugin-content-docs',
|
||||||
|
{
|
||||||
|
async sidebarItemsGenerator({
|
||||||
|
...args,
|
||||||
|
isCategoryIndex: defaultCategoryIndexMatcher, // The default matcher implementation, given below
|
||||||
|
defaultSidebarItemsGenerator,
|
||||||
|
}) {
|
||||||
|
return defaultSidebarItemsGenerator({
|
||||||
|
...args,
|
||||||
|
// highlight-start
|
||||||
|
isCategoryIndex(doc) {
|
||||||
|
return (
|
||||||
|
// Also pick intro.md in addition to the default ones
|
||||||
|
doc.fileName.toLowerCase() === 'intro' ||
|
||||||
|
defaultCategoryIndexMatcher(doc)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
// highlight-end
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Or choose to not have any category index convention.
|
||||||
|
|
||||||
|
```js title="docusaurus.config.js"
|
||||||
|
module.exports = {
|
||||||
|
plugins: [
|
||||||
|
[
|
||||||
|
'@docusaurus/plugin-content-docs',
|
||||||
|
{
|
||||||
|
async sidebarItemsGenerator({
|
||||||
|
...args,
|
||||||
|
isCategoryIndex: defaultCategoryIndexMatcher, // The default matcher implementation, given below
|
||||||
|
defaultSidebarItemsGenerator,
|
||||||
|
}) {
|
||||||
|
return defaultSidebarItemsGenerator({
|
||||||
|
...args,
|
||||||
|
// highlight-start
|
||||||
|
isCategoryIndex() {
|
||||||
|
// No doc will be automatically picked as category index
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
// highlight-end
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
The `isCategoryIndex` matcher will be provided with three fields:
|
||||||
|
|
||||||
|
- `fileName`, the file's name without extension and with casing preserved
|
||||||
|
- `directories`, the list of directory names _from the lowest level to the highest level_, relative to the docs root directory
|
||||||
|
- `extension`, the file's extension, with a leading dot.
|
||||||
|
|
||||||
|
For example, for a doc file at `guides/sidebar/autogenerated.md`, the props the matcher receives are
|
||||||
|
|
||||||
|
```js
|
||||||
|
const props = {
|
||||||
|
fileName: 'autogenerated',
|
||||||
|
directories: ['sidebar', 'guides'],
|
||||||
|
extension: '.md',
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
The default implementation is:
|
||||||
|
|
||||||
|
```js
|
||||||
|
function isCategoryIndex({fileName, directories}) {
|
||||||
|
const eligibleDocIndexNames = [
|
||||||
|
'index',
|
||||||
|
'readme',
|
||||||
|
directories[0].toLowerCase(),
|
||||||
|
];
|
||||||
|
return eligibleDocIndexNames.includes(fileName.toLowerCase());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
## Autogenerated sidebar metadata {#autogenerated-sidebar-metadata}
|
## Autogenerated sidebar metadata {#autogenerated-sidebar-metadata}
|
||||||
|
|
||||||
For hand-written sidebar definitions, you would provide metadata to sidebar items through `sidebars.js`; for autogenerated, Docusaurus would read them from the item's respective file. In addition, you may want to adjust the relative position of each item, because, by default, items within a sidebar slice will be generated in **alphabetical order** (using files and folders names).
|
For hand-written sidebar definitions, you would provide metadata to sidebar items through `sidebars.js`; for autogenerated, Docusaurus would read them from the item's respective file. In addition, you may want to adjust the relative position of each item, because, by default, items within a sidebar slice will be generated in **alphabetical order** (using files and folders names).
|
||||||
|
@ -317,6 +413,7 @@ module.exports = {
|
||||||
item,
|
item,
|
||||||
version,
|
version,
|
||||||
docs,
|
docs,
|
||||||
|
isCategoryIndex,
|
||||||
}) {
|
}) {
|
||||||
// Example: return an hardcoded list of static sidebar items
|
// Example: return an hardcoded list of static sidebar items
|
||||||
return [
|
return [
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue