mirror of
https://github.com/facebook/docusaurus.git
synced 2025-06-20 03:32:29 +02:00
feat(content-docs): sidebar category linking to document or auto-generated index page (#5830)
Co-authored-by: Joshua Chen <sidachen2003@gmail.com> Co-authored-by: Armano <armano2@users.noreply.github.com> Co-authored-by: Alexey Pyltsyn <lex61rus@gmail.com>
This commit is contained in:
parent
95f911efef
commit
cfae5d0933
105 changed files with 3904 additions and 816 deletions
|
@ -1,6 +1,6 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`loadUnprocessedSidebars sidebars link 1`] = `
|
||||
exports[`loadNormalizedSidebars sidebars link 1`] = `
|
||||
Object {
|
||||
"docs": Array [
|
||||
Object {
|
||||
|
@ -14,13 +14,14 @@ Object {
|
|||
},
|
||||
],
|
||||
"label": "Test",
|
||||
"link": undefined,
|
||||
"type": "category",
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`loadUnprocessedSidebars sidebars with category.collapsed property 1`] = `
|
||||
exports[`loadNormalizedSidebars sidebars with category.collapsed property 1`] = `
|
||||
Object {
|
||||
"docs": Array [
|
||||
Object {
|
||||
|
@ -37,10 +38,12 @@ Object {
|
|||
},
|
||||
],
|
||||
"label": "Introduction",
|
||||
"link": undefined,
|
||||
"type": "category",
|
||||
},
|
||||
],
|
||||
"label": "Test",
|
||||
"link": undefined,
|
||||
"type": "category",
|
||||
},
|
||||
Object {
|
||||
|
@ -57,17 +60,19 @@ Object {
|
|||
},
|
||||
],
|
||||
"label": "Powering MDX",
|
||||
"link": undefined,
|
||||
"type": "category",
|
||||
},
|
||||
],
|
||||
"label": "Reference",
|
||||
"link": undefined,
|
||||
"type": "category",
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`loadUnprocessedSidebars sidebars with category.collapsed property at first level 1`] = `
|
||||
exports[`loadNormalizedSidebars sidebars with category.collapsed property at first level 1`] = `
|
||||
Object {
|
||||
"docs": Array [
|
||||
Object {
|
||||
|
@ -80,6 +85,7 @@ Object {
|
|||
},
|
||||
],
|
||||
"label": "Introduction",
|
||||
"link": undefined,
|
||||
"type": "category",
|
||||
},
|
||||
Object {
|
||||
|
@ -92,13 +98,14 @@ Object {
|
|||
},
|
||||
],
|
||||
"label": "Powering MDX",
|
||||
"link": undefined,
|
||||
"type": "category",
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`loadUnprocessedSidebars sidebars with deep level of category 1`] = `
|
||||
exports[`loadNormalizedSidebars sidebars with deep level of category 1`] = `
|
||||
Object {
|
||||
"docs": Array [
|
||||
Object {
|
||||
|
@ -139,14 +146,17 @@ Object {
|
|||
},
|
||||
],
|
||||
"label": "deeper more more",
|
||||
"link": undefined,
|
||||
"type": "category",
|
||||
},
|
||||
],
|
||||
"label": "level 4",
|
||||
"link": undefined,
|
||||
"type": "category",
|
||||
},
|
||||
],
|
||||
"label": "level 3",
|
||||
"link": undefined,
|
||||
"type": "category",
|
||||
},
|
||||
Object {
|
||||
|
@ -155,17 +165,19 @@ Object {
|
|||
},
|
||||
],
|
||||
"label": "level 2",
|
||||
"link": undefined,
|
||||
"type": "category",
|
||||
},
|
||||
],
|
||||
"label": "level 1",
|
||||
"link": undefined,
|
||||
"type": "category",
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`loadUnprocessedSidebars sidebars with first level not a category 1`] = `
|
||||
exports[`loadNormalizedSidebars sidebars with first level not a category 1`] = `
|
||||
Object {
|
||||
"docs": Array [
|
||||
Object {
|
||||
|
@ -178,6 +190,7 @@ Object {
|
|||
},
|
||||
],
|
||||
"label": "Getting Started",
|
||||
"link": undefined,
|
||||
"type": "category",
|
||||
},
|
||||
Object {
|
||||
|
@ -188,7 +201,7 @@ Object {
|
|||
}
|
||||
`;
|
||||
|
||||
exports[`loadUnprocessedSidebars sidebars with known sidebar item type 1`] = `
|
||||
exports[`loadNormalizedSidebars sidebars with known sidebar item type 1`] = `
|
||||
Object {
|
||||
"docs": Array [
|
||||
Object {
|
||||
|
@ -214,6 +227,7 @@ Object {
|
|||
},
|
||||
],
|
||||
"label": "Test",
|
||||
"link": undefined,
|
||||
"type": "category",
|
||||
},
|
||||
Object {
|
||||
|
@ -226,6 +240,7 @@ Object {
|
|||
},
|
||||
],
|
||||
"label": "Guides",
|
||||
"link": undefined,
|
||||
"type": "category",
|
||||
},
|
||||
],
|
||||
|
|
|
@ -129,9 +129,15 @@ describe('DefaultSidebarItemsGenerator', () => {
|
|||
|
||||
test('generates complex nested sidebar', async () => {
|
||||
mockCategoryMetadataFiles({
|
||||
'02-Guides/_category_.json': {collapsed: false},
|
||||
'02-Guides/_category_.json': {collapsed: false} as CategoryMetadataFile,
|
||||
'02-Guides/01-SubGuides/_category_.yml': {
|
||||
label: 'SubGuides (metadata file label)',
|
||||
link: {
|
||||
type: 'generated-index',
|
||||
slug: 'subguides-generated-index-slug',
|
||||
title: 'subguides-title',
|
||||
description: 'subguides-description',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -153,6 +159,13 @@ describe('DefaultSidebarItemsGenerator', () => {
|
|||
sidebarPosition: 1,
|
||||
frontMatter: {},
|
||||
},
|
||||
{
|
||||
id: 'tutorials-index',
|
||||
source: 'index.md',
|
||||
sourceDirName: '01-Tutorials',
|
||||
sidebarPosition: 2,
|
||||
frontMatter: {},
|
||||
},
|
||||
{
|
||||
id: 'tutorial2',
|
||||
source: 'tutorial2.md',
|
||||
|
@ -167,6 +180,12 @@ describe('DefaultSidebarItemsGenerator', () => {
|
|||
sidebarPosition: 1,
|
||||
frontMatter: {},
|
||||
},
|
||||
{
|
||||
id: 'guides-index',
|
||||
source: '02-Guides.md', // TODO should we allow to just use "Guides.md" to have an index?
|
||||
sourceDirName: '02-Guides',
|
||||
frontMatter: {},
|
||||
},
|
||||
{
|
||||
id: 'guide2',
|
||||
source: 'guide2.md',
|
||||
|
@ -209,6 +228,10 @@ describe('DefaultSidebarItemsGenerator', () => {
|
|||
label: 'Tutorials',
|
||||
collapsed: true,
|
||||
collapsible: true,
|
||||
link: {
|
||||
type: 'doc',
|
||||
id: 'tutorials-index',
|
||||
},
|
||||
items: [
|
||||
{type: 'doc', id: 'tutorial1'},
|
||||
{type: 'doc', id: 'tutorial2'},
|
||||
|
@ -219,6 +242,10 @@ describe('DefaultSidebarItemsGenerator', () => {
|
|||
label: 'Guides',
|
||||
collapsed: false,
|
||||
collapsible: true,
|
||||
link: {
|
||||
type: 'doc',
|
||||
id: 'guides-index',
|
||||
},
|
||||
items: [
|
||||
{type: 'doc', id: 'guide1'},
|
||||
{
|
||||
|
@ -227,6 +254,12 @@ describe('DefaultSidebarItemsGenerator', () => {
|
|||
collapsed: true,
|
||||
collapsible: true,
|
||||
items: [{type: 'doc', id: 'nested-guide'}],
|
||||
link: {
|
||||
type: 'generated-index',
|
||||
slug: 'subguides-generated-index-slug',
|
||||
title: 'subguides-title',
|
||||
description: 'subguides-description',
|
||||
},
|
||||
},
|
||||
{type: 'doc', id: 'guide2'},
|
||||
],
|
||||
|
@ -354,4 +387,75 @@ describe('DefaultSidebarItemsGenerator', () => {
|
|||
},
|
||||
] as Sidebar);
|
||||
});
|
||||
|
||||
test('uses explicit link over the index/readme.{md,mdx} naming convention', async () => {
|
||||
mockCategoryMetadataFiles({
|
||||
'Category/_category_.yml': {
|
||||
label: 'Category label',
|
||||
link: {
|
||||
type: 'doc',
|
||||
id: 'doc3', // Using a "local doc id" ("doc1" instead of "parent/doc1") on purpose
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const sidebarSlice = await DefaultSidebarItemsGenerator({
|
||||
numberPrefixParser: DefaultNumberPrefixParser,
|
||||
item: {
|
||||
type: 'autogenerated',
|
||||
dirName: '.',
|
||||
},
|
||||
version: {
|
||||
versionName: 'current',
|
||||
contentPath: '',
|
||||
},
|
||||
docs: [
|
||||
{
|
||||
id: 'parent/doc1',
|
||||
source: 'index.md',
|
||||
sourceDirName: 'Category',
|
||||
frontMatter: {},
|
||||
},
|
||||
{
|
||||
id: 'parent/doc2',
|
||||
source: 'index.md',
|
||||
sourceDirName: 'Category',
|
||||
frontMatter: {},
|
||||
},
|
||||
{
|
||||
id: 'parent/doc3',
|
||||
source: 'doc3.md',
|
||||
sourceDirName: 'Category',
|
||||
frontMatter: {},
|
||||
},
|
||||
],
|
||||
options: {
|
||||
sidebarCollapsed: true,
|
||||
sidebarCollapsible: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(sidebarSlice).toEqual([
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Category label',
|
||||
collapsed: true,
|
||||
collapsible: true,
|
||||
link: {
|
||||
id: 'parent/doc3',
|
||||
type: 'doc',
|
||||
},
|
||||
items: [
|
||||
{
|
||||
id: 'parent/doc1',
|
||||
type: 'doc',
|
||||
},
|
||||
{
|
||||
id: 'parent/doc2',
|
||||
type: 'doc',
|
||||
},
|
||||
],
|
||||
},
|
||||
] as Sidebar);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -7,27 +7,31 @@
|
|||
|
||||
import path from 'path';
|
||||
import {
|
||||
loadUnprocessedSidebars,
|
||||
loadNormalizedSidebars,
|
||||
DefaultSidebars,
|
||||
DisabledSidebars,
|
||||
} from '../index';
|
||||
import type {SidebarOptions} from '../../types';
|
||||
import type {NormalizeSidebarsParams, VersionMetadata} from '../../types';
|
||||
|
||||
describe('loadUnprocessedSidebars', () => {
|
||||
describe('loadNormalizedSidebars', () => {
|
||||
const fixtureDir = path.join(__dirname, '__fixtures__', 'sidebars');
|
||||
const options: SidebarOptions = {
|
||||
const options: NormalizeSidebarsParams = {
|
||||
sidebarCollapsed: true,
|
||||
sidebarCollapsible: true,
|
||||
version: {
|
||||
versionName: 'version',
|
||||
versionPath: 'versionPath',
|
||||
} as VersionMetadata,
|
||||
};
|
||||
test('sidebars with known sidebar item type', async () => {
|
||||
const sidebarPath = path.join(fixtureDir, 'sidebars.json');
|
||||
const result = loadUnprocessedSidebars(sidebarPath, options);
|
||||
const result = loadNormalizedSidebars(sidebarPath, options);
|
||||
expect(result).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('sidebars with deep level of category', async () => {
|
||||
const sidebarPath = path.join(fixtureDir, 'sidebars-category.js');
|
||||
const result = loadUnprocessedSidebars(sidebarPath, options);
|
||||
const result = loadNormalizedSidebars(sidebarPath, options);
|
||||
expect(result).toMatchSnapshot();
|
||||
});
|
||||
|
||||
|
@ -37,8 +41,8 @@ describe('loadUnprocessedSidebars', () => {
|
|||
fixtureDir,
|
||||
'sidebars-category-shorthand.js',
|
||||
);
|
||||
const sidebar1 = loadUnprocessedSidebars(sidebarPath1, options);
|
||||
const sidebar2 = loadUnprocessedSidebars(sidebarPath2, options);
|
||||
const sidebar1 = loadNormalizedSidebars(sidebarPath1, options);
|
||||
const sidebar2 = loadNormalizedSidebars(sidebarPath2, options);
|
||||
expect(sidebar1).toEqual(sidebar2);
|
||||
});
|
||||
|
||||
|
@ -47,7 +51,7 @@ describe('loadUnprocessedSidebars', () => {
|
|||
fixtureDir,
|
||||
'sidebars-category-wrong-items.json',
|
||||
);
|
||||
expect(() => loadUnprocessedSidebars(sidebarPath, options))
|
||||
expect(() => loadNormalizedSidebars(sidebarPath, options))
|
||||
.toThrowErrorMatchingInlineSnapshot(`
|
||||
"{
|
||||
\\"type\\": \\"category\\",
|
||||
|
@ -64,7 +68,7 @@ describe('loadUnprocessedSidebars', () => {
|
|||
fixtureDir,
|
||||
'sidebars-category-wrong-label.json',
|
||||
);
|
||||
expect(() => loadUnprocessedSidebars(sidebarPath, options))
|
||||
expect(() => loadNormalizedSidebars(sidebarPath, options))
|
||||
.toThrowErrorMatchingInlineSnapshot(`
|
||||
"{
|
||||
\\"type\\": \\"category\\",
|
||||
|
@ -83,7 +87,7 @@ describe('loadUnprocessedSidebars', () => {
|
|||
fixtureDir,
|
||||
'sidebars-doc-id-not-string.json',
|
||||
);
|
||||
expect(() => loadUnprocessedSidebars(sidebarPath, options))
|
||||
expect(() => loadNormalizedSidebars(sidebarPath, options))
|
||||
.toThrowErrorMatchingInlineSnapshot(`
|
||||
"{
|
||||
\\"type\\": \\"doc\\",
|
||||
|
@ -101,19 +105,19 @@ describe('loadUnprocessedSidebars', () => {
|
|||
fixtureDir,
|
||||
'sidebars-first-level-not-category.js',
|
||||
);
|
||||
const result = loadUnprocessedSidebars(sidebarPath, options);
|
||||
const result = loadNormalizedSidebars(sidebarPath, options);
|
||||
expect(result).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('sidebars link', async () => {
|
||||
const sidebarPath = path.join(fixtureDir, 'sidebars-link.json');
|
||||
const result = loadUnprocessedSidebars(sidebarPath, options);
|
||||
const result = loadNormalizedSidebars(sidebarPath, options);
|
||||
expect(result).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('sidebars link wrong label', async () => {
|
||||
const sidebarPath = path.join(fixtureDir, 'sidebars-link-wrong-label.json');
|
||||
expect(() => loadUnprocessedSidebars(sidebarPath, options))
|
||||
expect(() => loadNormalizedSidebars(sidebarPath, options))
|
||||
.toThrowErrorMatchingInlineSnapshot(`
|
||||
"{
|
||||
\\"type\\": \\"link\\",
|
||||
|
@ -127,7 +131,7 @@ describe('loadUnprocessedSidebars', () => {
|
|||
|
||||
test('sidebars link wrong href', async () => {
|
||||
const sidebarPath = path.join(fixtureDir, 'sidebars-link-wrong-href.json');
|
||||
expect(() => loadUnprocessedSidebars(sidebarPath, options))
|
||||
expect(() => loadNormalizedSidebars(sidebarPath, options))
|
||||
.toThrowErrorMatchingInlineSnapshot(`
|
||||
"{
|
||||
\\"type\\": \\"link\\",
|
||||
|
@ -143,7 +147,7 @@ describe('loadUnprocessedSidebars', () => {
|
|||
|
||||
test('sidebars with unknown sidebar item type', async () => {
|
||||
const sidebarPath = path.join(fixtureDir, 'sidebars-unknown-type.json');
|
||||
expect(() => loadUnprocessedSidebars(sidebarPath, options))
|
||||
expect(() => loadNormalizedSidebars(sidebarPath, options))
|
||||
.toThrowErrorMatchingInlineSnapshot(`
|
||||
"{
|
||||
\\"type\\": \\"superman\\",
|
||||
|
@ -156,7 +160,7 @@ describe('loadUnprocessedSidebars', () => {
|
|||
|
||||
test('sidebars with known sidebar item type but wrong field', async () => {
|
||||
const sidebarPath = path.join(fixtureDir, 'sidebars-wrong-field.json');
|
||||
expect(() => loadUnprocessedSidebars(sidebarPath, options))
|
||||
expect(() => loadNormalizedSidebars(sidebarPath, options))
|
||||
.toThrowErrorMatchingInlineSnapshot(`
|
||||
"{
|
||||
\\"type\\": \\"category\\",
|
||||
|
@ -170,24 +174,22 @@ describe('loadUnprocessedSidebars', () => {
|
|||
});
|
||||
|
||||
test('unexisting path', () => {
|
||||
expect(loadUnprocessedSidebars('badpath', options)).toEqual(
|
||||
expect(loadNormalizedSidebars('badpath', options)).toEqual(
|
||||
DisabledSidebars,
|
||||
);
|
||||
});
|
||||
|
||||
test('undefined path', () => {
|
||||
expect(loadUnprocessedSidebars(undefined, options)).toEqual(
|
||||
DefaultSidebars,
|
||||
);
|
||||
expect(loadNormalizedSidebars(undefined, options)).toEqual(DefaultSidebars);
|
||||
});
|
||||
|
||||
test('literal false path', () => {
|
||||
expect(loadUnprocessedSidebars(false, options)).toEqual(DisabledSidebars);
|
||||
expect(loadNormalizedSidebars(false, options)).toEqual(DisabledSidebars);
|
||||
});
|
||||
|
||||
test('sidebars with category.collapsed property', async () => {
|
||||
const sidebarPath = path.join(fixtureDir, 'sidebars-collapsed.json');
|
||||
const result = loadUnprocessedSidebars(sidebarPath, options);
|
||||
const result = loadNormalizedSidebars(sidebarPath, options);
|
||||
expect(result).toMatchSnapshot();
|
||||
});
|
||||
|
||||
|
@ -196,7 +198,7 @@ describe('loadUnprocessedSidebars', () => {
|
|||
fixtureDir,
|
||||
'sidebars-collapsed-first-level.json',
|
||||
);
|
||||
const result = loadUnprocessedSidebars(sidebarPath, options);
|
||||
const result = loadNormalizedSidebars(sidebarPath, options);
|
||||
expect(result).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
import {processSidebars} from '../processor';
|
||||
import {processSidebars, SidebarProcessorParams} from '../processor';
|
||||
import type {
|
||||
SidebarItem,
|
||||
SidebarItemsGenerator,
|
||||
|
@ -13,23 +13,50 @@ import type {
|
|||
NormalizedSidebars,
|
||||
} from '../types';
|
||||
import {DefaultSidebarItemsGenerator} from '../generator';
|
||||
import {createSlugger} from '@docusaurus/utils';
|
||||
import {VersionMetadata} from '../../types';
|
||||
import {DefaultNumberPrefixParser} from '../../numberPrefix';
|
||||
|
||||
describe('processSidebars', () => {
|
||||
function createStaticSidebarItemGenerator(
|
||||
sidebarSlice: SidebarItem[],
|
||||
): SidebarItemsGenerator {
|
||||
return jest.fn(async () => sidebarSlice);
|
||||
}
|
||||
|
||||
const StaticGeneratedSidebarSlice: SidebarItem[] = [
|
||||
{type: 'doc', id: 'doc-generated-id-1'},
|
||||
{type: 'doc', id: 'doc-generated-id-2'},
|
||||
];
|
||||
|
||||
const StaticSidebarItemsGenerator: SidebarItemsGenerator = jest.fn(
|
||||
async () => StaticGeneratedSidebarSlice,
|
||||
);
|
||||
const StaticSidebarItemsGenerator: SidebarItemsGenerator =
|
||||
createStaticSidebarItemGenerator(StaticGeneratedSidebarSlice);
|
||||
|
||||
async function testProcessSidebars(unprocessedSidebars: NormalizedSidebars) {
|
||||
// @ts-expect-error: good enough for this test
|
||||
const version: VersionMetadata = {
|
||||
versionName: '1.0.0',
|
||||
versionPath: '/docs/1.0.0',
|
||||
};
|
||||
|
||||
const params: SidebarProcessorParams = {
|
||||
sidebarItemsGenerator: StaticSidebarItemsGenerator,
|
||||
docs: [],
|
||||
version,
|
||||
numberPrefixParser: DefaultNumberPrefixParser,
|
||||
categoryLabelSlugger: createSlugger(),
|
||||
sidebarOptions: {
|
||||
sidebarCollapsed: true,
|
||||
sidebarCollapsible: true,
|
||||
},
|
||||
};
|
||||
|
||||
async function testProcessSidebars(
|
||||
unprocessedSidebars: NormalizedSidebars,
|
||||
paramsOverrides: Partial<SidebarProcessorParams> = {},
|
||||
) {
|
||||
return processSidebars(unprocessedSidebars, {
|
||||
sidebarItemsGenerator: StaticSidebarItemsGenerator,
|
||||
docs: [],
|
||||
// @ts-expect-error: useless for this test
|
||||
version: {},
|
||||
...params,
|
||||
...paramsOverrides,
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -69,13 +96,18 @@ describe('processSidebars', () => {
|
|||
{type: 'doc', id: 'doc1'},
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Category',
|
||||
link: {
|
||||
type: 'generated-index',
|
||||
slug: 'category-generated-index-slug',
|
||||
permalink: 'category-generated-index-permalink',
|
||||
},
|
||||
collapsed: false,
|
||||
collapsible: true,
|
||||
items: [
|
||||
{type: 'doc', id: 'doc2'},
|
||||
{type: 'autogenerated', dirName: 'dir1'},
|
||||
],
|
||||
label: 'Category',
|
||||
},
|
||||
{type: 'link', href: 'https://facebook.com', label: 'FB'},
|
||||
],
|
||||
|
@ -86,10 +118,10 @@ describe('processSidebars', () => {
|
|||
{type: 'autogenerated', dirName: 'dir3'},
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Category',
|
||||
collapsed: false,
|
||||
collapsible: true,
|
||||
items: [{type: 'doc', id: 'doc4'}],
|
||||
label: 'Category',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
@ -100,20 +132,32 @@ describe('processSidebars', () => {
|
|||
expect(StaticSidebarItemsGenerator).toHaveBeenCalledWith({
|
||||
defaultSidebarItemsGenerator: DefaultSidebarItemsGenerator,
|
||||
item: {type: 'autogenerated', dirName: 'dir1'},
|
||||
docs: [],
|
||||
version: {},
|
||||
docs: params.docs,
|
||||
version: {
|
||||
versionName: version.versionName,
|
||||
},
|
||||
numberPrefixParser: DefaultNumberPrefixParser,
|
||||
options: params.sidebarOptions,
|
||||
});
|
||||
expect(StaticSidebarItemsGenerator).toHaveBeenCalledWith({
|
||||
defaultSidebarItemsGenerator: DefaultSidebarItemsGenerator,
|
||||
item: {type: 'autogenerated', dirName: 'dir2'},
|
||||
docs: [],
|
||||
version: {},
|
||||
docs: params.docs,
|
||||
version: {
|
||||
versionName: version.versionName,
|
||||
},
|
||||
numberPrefixParser: DefaultNumberPrefixParser,
|
||||
options: params.sidebarOptions,
|
||||
});
|
||||
expect(StaticSidebarItemsGenerator).toHaveBeenCalledWith({
|
||||
defaultSidebarItemsGenerator: DefaultSidebarItemsGenerator,
|
||||
item: {type: 'autogenerated', dirName: 'dir3'},
|
||||
docs: [],
|
||||
version: {},
|
||||
docs: params.docs,
|
||||
version: {
|
||||
versionName: version.versionName,
|
||||
},
|
||||
numberPrefixParser: DefaultNumberPrefixParser,
|
||||
options: params.sidebarOptions,
|
||||
});
|
||||
|
||||
expect(processedSidebar).toEqual({
|
||||
|
@ -121,10 +165,15 @@ describe('processSidebars', () => {
|
|||
{type: 'doc', id: 'doc1'},
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Category',
|
||||
link: {
|
||||
type: 'generated-index',
|
||||
slug: 'category-generated-index-slug',
|
||||
permalink: 'category-generated-index-permalink',
|
||||
},
|
||||
collapsed: false,
|
||||
collapsible: true,
|
||||
items: [{type: 'doc', id: 'doc2'}, ...StaticGeneratedSidebarSlice],
|
||||
label: 'Category',
|
||||
},
|
||||
{type: 'link', href: 'https://facebook.com', label: 'FB'},
|
||||
],
|
||||
|
@ -135,10 +184,52 @@ describe('processSidebars', () => {
|
|||
...StaticGeneratedSidebarSlice,
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Category',
|
||||
collapsed: false,
|
||||
collapsible: true,
|
||||
items: [{type: 'doc', id: 'doc4'}],
|
||||
label: 'Category',
|
||||
},
|
||||
],
|
||||
} as Sidebars);
|
||||
});
|
||||
|
||||
test('ensure generated items are normalized', async () => {
|
||||
const sidebarSliceContainingCategoryGeneratedIndex: SidebarItem[] = [
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Generated category',
|
||||
link: {
|
||||
type: 'generated-index',
|
||||
slug: 'generated-cat-index-slug',
|
||||
// @ts-expect-error: TODO undefined should be allowed here, typing error needing refactor
|
||||
permalink: undefined,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const unprocessedSidebars: NormalizedSidebars = {
|
||||
someSidebar: [{type: 'autogenerated', dirName: 'dir2'}],
|
||||
};
|
||||
|
||||
const processedSidebar = await testProcessSidebars(unprocessedSidebars, {
|
||||
sidebarItemsGenerator: createStaticSidebarItemGenerator(
|
||||
sidebarSliceContainingCategoryGeneratedIndex,
|
||||
),
|
||||
});
|
||||
|
||||
expect(processedSidebar).toEqual({
|
||||
someSidebar: [
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Generated category',
|
||||
link: {
|
||||
type: 'generated-index',
|
||||
slug: 'generated-cat-index-slug',
|
||||
permalink: '/docs/1.0.0/generated-cat-index-slug',
|
||||
},
|
||||
items: [],
|
||||
collapsible: true,
|
||||
collapsed: true,
|
||||
},
|
||||
],
|
||||
} as Sidebars);
|
||||
|
|
|
@ -12,8 +12,12 @@ import {
|
|||
collectSidebarLinks,
|
||||
transformSidebarItems,
|
||||
collectSidebarsDocIds,
|
||||
SidebarNavigation,
|
||||
toDocNavigationLink,
|
||||
toNavigationLink,
|
||||
} from '../utils';
|
||||
import type {Sidebar, Sidebars} from '../types';
|
||||
import {DocMetadataBase, DocNavLink} from '../../types';
|
||||
|
||||
describe('createSidebarsUtils', () => {
|
||||
const sidebar1: Sidebar = [
|
||||
|
@ -21,13 +25,13 @@ describe('createSidebarsUtils', () => {
|
|||
type: 'category',
|
||||
collapsed: false,
|
||||
collapsible: true,
|
||||
label: 'Category1',
|
||||
label: 'S1 Category',
|
||||
items: [
|
||||
{
|
||||
type: 'category',
|
||||
collapsed: false,
|
||||
collapsible: true,
|
||||
label: 'Subcategory 1',
|
||||
label: 'S1 Subcategory',
|
||||
items: [{type: 'doc', id: 'doc1'}],
|
||||
},
|
||||
{type: 'doc', id: 'doc2'},
|
||||
|
@ -40,7 +44,7 @@ describe('createSidebarsUtils', () => {
|
|||
type: 'category',
|
||||
collapsed: false,
|
||||
collapsible: true,
|
||||
label: 'Category2',
|
||||
label: 'S2 Category',
|
||||
items: [
|
||||
{type: 'doc', id: 'doc3'},
|
||||
{type: 'doc', id: 'doc4'},
|
||||
|
@ -48,10 +52,58 @@ describe('createSidebarsUtils', () => {
|
|||
},
|
||||
];
|
||||
|
||||
const sidebars: Sidebars = {sidebar1, sidebar2};
|
||||
const sidebar3: Sidebar = [
|
||||
{
|
||||
type: 'category',
|
||||
collapsed: false,
|
||||
collapsible: true,
|
||||
label: 'S3 Category',
|
||||
link: {
|
||||
type: 'doc',
|
||||
id: 'doc5',
|
||||
},
|
||||
items: [
|
||||
{
|
||||
type: 'category',
|
||||
collapsed: false,
|
||||
collapsible: true,
|
||||
label: 'S3 SubCategory',
|
||||
link: {
|
||||
type: 'generated-index',
|
||||
slug: '/s3-subcategory-index-slug',
|
||||
permalink: '/s3-subcategory-index-permalink',
|
||||
},
|
||||
items: [
|
||||
{
|
||||
type: 'category',
|
||||
collapsed: false,
|
||||
collapsible: true,
|
||||
label: 'S3 SubSubCategory',
|
||||
link: {
|
||||
type: 'generated-index',
|
||||
slug: '/s3-subsubcategory-slug',
|
||||
permalink: '/s3-subsubcategory-index-permalink',
|
||||
},
|
||||
items: [
|
||||
{type: 'doc', id: 'doc6'},
|
||||
{type: 'doc', id: 'doc7'},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const {getFirstDocIdOfFirstSidebar, getSidebarNameByDocId, getDocNavigation} =
|
||||
createSidebarsUtils(sidebars);
|
||||
const sidebars: Sidebars = {sidebar1, sidebar2, sidebar3};
|
||||
|
||||
const {
|
||||
getFirstDocIdOfFirstSidebar,
|
||||
getSidebarNameByDocId,
|
||||
getDocNavigation,
|
||||
getCategoryGeneratedIndexNavigation,
|
||||
getCategoryGeneratedIndexList,
|
||||
} = createSidebarsUtils(sidebars);
|
||||
|
||||
test('getSidebarNameByDocId', async () => {
|
||||
expect(getFirstDocIdOfFirstSidebar()).toEqual('doc1');
|
||||
|
@ -62,32 +114,117 @@ describe('createSidebarsUtils', () => {
|
|||
expect(getSidebarNameByDocId('doc2')).toEqual('sidebar1');
|
||||
expect(getSidebarNameByDocId('doc3')).toEqual('sidebar2');
|
||||
expect(getSidebarNameByDocId('doc4')).toEqual('sidebar2');
|
||||
expect(getSidebarNameByDocId('doc5')).toEqual(undefined);
|
||||
expect(getSidebarNameByDocId('doc6')).toEqual(undefined);
|
||||
expect(getSidebarNameByDocId('doc5')).toEqual('sidebar3');
|
||||
expect(getSidebarNameByDocId('doc6')).toEqual('sidebar3');
|
||||
expect(getSidebarNameByDocId('doc7')).toEqual('sidebar3');
|
||||
expect(getSidebarNameByDocId('unknown_id')).toEqual(undefined);
|
||||
});
|
||||
|
||||
test('getDocNavigation', async () => {
|
||||
expect(getDocNavigation('doc1')).toEqual({
|
||||
sidebarName: 'sidebar1',
|
||||
previousId: undefined,
|
||||
nextId: 'doc2',
|
||||
});
|
||||
previous: undefined,
|
||||
next: {
|
||||
type: 'doc',
|
||||
id: 'doc2',
|
||||
},
|
||||
} as SidebarNavigation);
|
||||
expect(getDocNavigation('doc2')).toEqual({
|
||||
sidebarName: 'sidebar1',
|
||||
previousId: 'doc1',
|
||||
nextId: undefined,
|
||||
});
|
||||
previous: {
|
||||
type: 'doc',
|
||||
id: 'doc1',
|
||||
},
|
||||
next: undefined,
|
||||
} as SidebarNavigation);
|
||||
|
||||
expect(getDocNavigation('doc3')).toEqual({
|
||||
sidebarName: 'sidebar2',
|
||||
previousId: undefined,
|
||||
nextId: 'doc4',
|
||||
});
|
||||
previous: undefined,
|
||||
next: {
|
||||
type: 'doc',
|
||||
id: 'doc4',
|
||||
},
|
||||
} as SidebarNavigation);
|
||||
expect(getDocNavigation('doc4')).toEqual({
|
||||
sidebarName: 'sidebar2',
|
||||
previousId: 'doc3',
|
||||
nextId: undefined,
|
||||
});
|
||||
previous: {
|
||||
type: 'doc',
|
||||
id: 'doc3',
|
||||
},
|
||||
next: undefined,
|
||||
} as SidebarNavigation);
|
||||
|
||||
expect(getDocNavigation('doc5')).toMatchObject({
|
||||
sidebarName: 'sidebar3',
|
||||
previous: undefined,
|
||||
next: {
|
||||
type: 'category',
|
||||
label: 'S3 SubCategory',
|
||||
},
|
||||
} as SidebarNavigation);
|
||||
expect(getDocNavigation('doc6')).toMatchObject({
|
||||
sidebarName: 'sidebar3',
|
||||
previous: {
|
||||
type: 'category',
|
||||
label: 'S3 SubSubCategory',
|
||||
},
|
||||
next: {
|
||||
type: 'doc',
|
||||
id: 'doc7',
|
||||
},
|
||||
} as SidebarNavigation);
|
||||
expect(getDocNavigation('doc7')).toMatchObject({
|
||||
sidebarName: 'sidebar3',
|
||||
previous: {
|
||||
type: 'doc',
|
||||
id: 'doc6',
|
||||
},
|
||||
next: undefined,
|
||||
} as SidebarNavigation);
|
||||
});
|
||||
|
||||
test('getCategoryGeneratedIndexNavigation', async () => {
|
||||
expect(
|
||||
getCategoryGeneratedIndexNavigation('/s3-subcategory-index-permalink'),
|
||||
).toMatchObject({
|
||||
sidebarName: 'sidebar3',
|
||||
previous: {
|
||||
type: 'category',
|
||||
label: 'S3 Category',
|
||||
},
|
||||
next: {
|
||||
type: 'category',
|
||||
label: 'S3 SubSubCategory',
|
||||
},
|
||||
} as SidebarNavigation);
|
||||
|
||||
expect(
|
||||
getCategoryGeneratedIndexNavigation('/s3-subsubcategory-index-permalink'),
|
||||
).toMatchObject({
|
||||
sidebarName: 'sidebar3',
|
||||
previous: {
|
||||
type: 'category',
|
||||
label: 'S3 SubCategory',
|
||||
},
|
||||
next: {
|
||||
type: 'doc',
|
||||
id: 'doc6',
|
||||
},
|
||||
} as SidebarNavigation);
|
||||
});
|
||||
|
||||
test('getCategoryGeneratedIndexList', async () => {
|
||||
expect(getCategoryGeneratedIndexList()).toMatchObject([
|
||||
{
|
||||
type: 'category',
|
||||
label: 'S3 SubCategory',
|
||||
},
|
||||
{
|
||||
type: 'category',
|
||||
label: 'S3 SubSubCategory',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -393,3 +530,166 @@ describe('transformSidebarItems', () => {
|
|||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toDocNavigationLink', () => {
|
||||
type TestDoc = Pick<DocMetadataBase, 'permalink' | 'title' | 'frontMatter'>;
|
||||
function testDoc(data: TestDoc) {
|
||||
return data as DocMetadataBase;
|
||||
}
|
||||
|
||||
test('with no frontmatter', () => {
|
||||
expect(
|
||||
toDocNavigationLink(
|
||||
testDoc({
|
||||
title: 'Doc Title',
|
||||
permalink: '/docPermalink',
|
||||
frontMatter: {},
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
title: 'Doc Title',
|
||||
permalink: '/docPermalink',
|
||||
} as DocNavLink);
|
||||
});
|
||||
|
||||
test('with pagination_label frontmatter', () => {
|
||||
expect(
|
||||
toDocNavigationLink(
|
||||
testDoc({
|
||||
title: 'Doc Title',
|
||||
permalink: '/docPermalink',
|
||||
frontMatter: {
|
||||
pagination_label: 'pagination_label',
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
title: 'pagination_label',
|
||||
permalink: '/docPermalink',
|
||||
} as DocNavLink);
|
||||
});
|
||||
|
||||
test('with sidebar_label frontmatter', () => {
|
||||
expect(
|
||||
toDocNavigationLink(
|
||||
testDoc({
|
||||
title: 'Doc Title',
|
||||
permalink: '/docPermalink',
|
||||
frontMatter: {
|
||||
sidebar_label: 'sidebar_label',
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
title: 'sidebar_label',
|
||||
permalink: '/docPermalink',
|
||||
} as DocNavLink);
|
||||
});
|
||||
|
||||
test('with pagination_label + sidebar_label frontmatter', () => {
|
||||
expect(
|
||||
toDocNavigationLink(
|
||||
testDoc({
|
||||
title: 'Doc Title',
|
||||
permalink: '/docPermalink',
|
||||
frontMatter: {
|
||||
pagination_label: 'pagination_label',
|
||||
sidebar_label: 'sidebar_label',
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
title: 'pagination_label',
|
||||
permalink: '/docPermalink',
|
||||
} as DocNavLink);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toNavigationLink', () => {
|
||||
type TestDoc = Pick<DocMetadataBase, 'permalink' | 'title'>;
|
||||
function testDoc(data: TestDoc) {
|
||||
return {...data, frontMatter: {}} as DocMetadataBase;
|
||||
}
|
||||
|
||||
const docsById: Record<string, DocMetadataBase> = {
|
||||
doc1: testDoc({
|
||||
title: 'Doc 1',
|
||||
permalink: '/doc1',
|
||||
}),
|
||||
doc2: testDoc({
|
||||
title: 'Doc 1',
|
||||
permalink: '/doc1',
|
||||
}),
|
||||
};
|
||||
|
||||
test('with doc items', () => {
|
||||
expect(toNavigationLink({type: 'doc', id: 'doc1'}, docsById)).toEqual(
|
||||
toDocNavigationLink(docsById.doc1),
|
||||
);
|
||||
expect(toNavigationLink({type: 'doc', id: 'doc2'}, docsById)).toEqual(
|
||||
toDocNavigationLink(docsById.doc2),
|
||||
);
|
||||
expect(() =>
|
||||
toNavigationLink({type: 'doc', id: 'doc3'}, docsById),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Can't create navigation link: no doc found with id=doc3"`,
|
||||
);
|
||||
});
|
||||
|
||||
test('with category item and doc link', () => {
|
||||
expect(
|
||||
toNavigationLink(
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Category',
|
||||
items: [],
|
||||
link: {
|
||||
type: 'doc',
|
||||
id: 'doc1',
|
||||
},
|
||||
collapsed: true,
|
||||
collapsible: true,
|
||||
},
|
||||
docsById,
|
||||
),
|
||||
).toEqual(toDocNavigationLink(docsById.doc1));
|
||||
expect(() =>
|
||||
toNavigationLink(
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Category',
|
||||
items: [],
|
||||
link: {
|
||||
type: 'doc',
|
||||
id: 'doc3',
|
||||
},
|
||||
collapsed: true,
|
||||
collapsible: true,
|
||||
},
|
||||
docsById,
|
||||
),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Can't create navigation link: no doc found with id=doc3"`,
|
||||
);
|
||||
});
|
||||
|
||||
test('with category item and generated-index link', () => {
|
||||
expect(
|
||||
toNavigationLink(
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Category',
|
||||
items: [],
|
||||
link: {
|
||||
type: 'generated-index',
|
||||
slug: 'slug',
|
||||
permalink: 'generated-index-permalink',
|
||||
},
|
||||
collapsed: true,
|
||||
collapsible: true,
|
||||
},
|
||||
docsById,
|
||||
),
|
||||
).toEqual({title: 'Category', permalink: 'generated-index-permalink'});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -0,0 +1,105 @@
|
|||
/**
|
||||
* 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 {validateSidebars, validateCategoryMetadataFile} from '../validation';
|
||||
import {CategoryMetadataFile} from '../generator';
|
||||
import {SidebarsConfig} from '../types';
|
||||
|
||||
describe('validateSidebars', () => {
|
||||
// TODO add more tests
|
||||
|
||||
// TODO it seems many error cases are not validated properly
|
||||
// and error messages are quite bad
|
||||
test('throw for bad value', async () => {
|
||||
expect(() => validateSidebars({sidebar: [{type: 42}]}))
|
||||
.toThrowErrorMatchingInlineSnapshot(`
|
||||
"{
|
||||
\\"type\\": 42,
|
||||
[41m\\"undefined\\"[0m[31m [1]: -- missing --[0m
|
||||
}
|
||||
[31m
|
||||
[1] Unknown sidebar item type \\"42\\".[0m"
|
||||
`);
|
||||
});
|
||||
|
||||
test('accept empty object', async () => {
|
||||
const sidebars: SidebarsConfig = {};
|
||||
validateSidebars(sidebars);
|
||||
});
|
||||
|
||||
test('accept valid values', async () => {
|
||||
const sidebars: SidebarsConfig = {
|
||||
sidebar1: [
|
||||
{type: 'doc', id: 'doc1'},
|
||||
{type: 'doc', id: 'doc2'},
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Category',
|
||||
items: [{type: 'doc', id: 'doc3'}],
|
||||
},
|
||||
],
|
||||
};
|
||||
validateSidebars(sidebars);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateCategoryMetadataFile', () => {
|
||||
// TODO add more tests
|
||||
|
||||
test('throw for bad value', async () => {
|
||||
expect(() =>
|
||||
validateCategoryMetadataFile(42),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"\\"value\\" must be of type object"`,
|
||||
);
|
||||
});
|
||||
|
||||
test('accept empty object', async () => {
|
||||
const content: CategoryMetadataFile = {};
|
||||
expect(validateCategoryMetadataFile(content)).toEqual(content);
|
||||
});
|
||||
|
||||
test('accept valid values', async () => {
|
||||
const content: CategoryMetadataFile = {
|
||||
className: 'className',
|
||||
label: 'Category Label',
|
||||
link: {
|
||||
type: 'generated-index',
|
||||
slug: 'slug',
|
||||
title: 'title',
|
||||
description: 'description',
|
||||
},
|
||||
collapsible: true,
|
||||
collapsed: true,
|
||||
position: 3,
|
||||
};
|
||||
expect(validateCategoryMetadataFile(content)).toEqual(content);
|
||||
});
|
||||
|
||||
test('rejects permalink', async () => {
|
||||
const content: CategoryMetadataFile = {
|
||||
className: 'className',
|
||||
label: 'Category Label',
|
||||
link: {
|
||||
type: 'generated-index',
|
||||
slug: 'slug',
|
||||
// @ts-expect-error: rejected on purpose
|
||||
permalink: 'somePermalink',
|
||||
title: 'title',
|
||||
description: 'description',
|
||||
},
|
||||
collapsible: true,
|
||||
collapsed: true,
|
||||
position: 3,
|
||||
};
|
||||
expect(() =>
|
||||
validateCategoryMetadataFile(content),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"\\"link.permalink\\" is not allowed"`,
|
||||
);
|
||||
});
|
||||
});
|
|
@ -11,19 +11,27 @@ import type {
|
|||
SidebarItemCategory,
|
||||
SidebarItemsGenerator,
|
||||
SidebarItemsGeneratorDoc,
|
||||
SidebarItemCategoryLink,
|
||||
SidebarItemCategoryLinkConfig,
|
||||
} from './types';
|
||||
import {keyBy, sortBy} from 'lodash';
|
||||
import {sortBy, last} from 'lodash';
|
||||
import {addTrailingSlash, posixPath} from '@docusaurus/utils';
|
||||
import {Joi} from '@docusaurus/utils-validation';
|
||||
import chalk from 'chalk';
|
||||
import path from 'path';
|
||||
import fs from 'fs-extra';
|
||||
import Yaml from 'js-yaml';
|
||||
import {validateCategoryMetadataFile} from './validation';
|
||||
import {createDocsByIdIndex, isConventionalDocIndex} from '../docs';
|
||||
|
||||
const BreadcrumbSeparator = '/';
|
||||
// To avoid possible name clashes with a folder of the same name as the ID
|
||||
const docIdPrefix = '$doc$/';
|
||||
|
||||
// Just an alias to the make code more explicit
|
||||
function getLocalDocId(docId: string): string {
|
||||
return last(docId.split('/'))!;
|
||||
}
|
||||
|
||||
export const CategoryMetadataFilenameBase = '_category_';
|
||||
export const CategoryMetadataFilenamePattern = '_category_.{json,yml,yaml}';
|
||||
|
||||
|
@ -33,6 +41,7 @@ export type CategoryMetadataFile = {
|
|||
collapsed?: boolean;
|
||||
collapsible?: boolean;
|
||||
className?: string;
|
||||
link?: SidebarItemCategoryLinkConfig;
|
||||
|
||||
// TODO should we allow "items" here? how would this work? would an "autogenerated" type be allowed?
|
||||
// This mkdocs plugin do something like that: https://github.com/lukasgeiter/mkdocs-awesome-pages-plugin/
|
||||
|
@ -50,17 +59,9 @@ type Dir = {
|
|||
[item: string]: Dir | null;
|
||||
};
|
||||
|
||||
const CategoryMetadataFileSchema = Joi.object<CategoryMetadataFile>({
|
||||
label: Joi.string(),
|
||||
position: Joi.number(),
|
||||
collapsed: Joi.boolean(),
|
||||
collapsible: Joi.boolean(),
|
||||
className: Joi.string(),
|
||||
});
|
||||
|
||||
// TODO I now believe we should read all the category metadata files ahead of time: we may need this metadata to customize docs metadata
|
||||
// Example use-case being able to disable number prefix parsing at the folder level, or customize the default route path segment for an intermediate directory...
|
||||
// TODO later if there is `CategoryFolder/index.md`, we may want to read the metadata as yaml on it
|
||||
// TODO later if there is `CategoryFolder/with-category-name-doc.md`, we may want to read the metadata as yaml on it
|
||||
// see https://github.com/facebook/docusaurus/issues/3464#issuecomment-818670449
|
||||
async function readCategoryMetadataFile(
|
||||
categoryDirPath: string,
|
||||
|
@ -69,7 +70,7 @@ async function readCategoryMetadataFile(
|
|||
const contentString = await fs.readFile(filePath, {encoding: 'utf8'});
|
||||
const unsafeContent = Yaml.load(contentString);
|
||||
try {
|
||||
return Joi.attempt(unsafeContent, CategoryMetadataFileSchema);
|
||||
return validateCategoryMetadataFile(unsafeContent);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
chalk.red(
|
||||
|
@ -100,6 +101,21 @@ export const DefaultSidebarItemsGenerator: SidebarItemsGenerator = async ({
|
|||
item: {dirName: autogenDir},
|
||||
version,
|
||||
}) => {
|
||||
const docsById = createDocsByIdIndex(allDocs);
|
||||
const findDoc = (docId: string): SidebarItemsGeneratorDoc | undefined =>
|
||||
docsById[docId];
|
||||
const getDoc = (docId: string): SidebarItemsGeneratorDoc => {
|
||||
const doc = findDoc(docId);
|
||||
if (!doc) {
|
||||
throw new Error(
|
||||
`Can't find any doc with id=${docId}.\nAvailable doc ids:\n- ${Object.keys(
|
||||
docsById,
|
||||
).join('\n- ')}`,
|
||||
);
|
||||
}
|
||||
return doc;
|
||||
};
|
||||
|
||||
/**
|
||||
* Step 1. Extract the docs that are in the autogen dir.
|
||||
*/
|
||||
|
@ -163,12 +179,11 @@ export const DefaultSidebarItemsGenerator: SidebarItemsGenerator = async ({
|
|||
* (From a record to an array of items, akin to normalizing shorthand)
|
||||
*/
|
||||
function generateSidebar(fsModel: Dir): Promise<WithPosition<SidebarItem>[]> {
|
||||
const docsById = keyBy(allDocs, (doc) => doc.id);
|
||||
function createDocItem(id: string): WithPosition<SidebarItemDoc> {
|
||||
const {
|
||||
sidebarPosition: position,
|
||||
frontMatter: {sidebar_label: label, sidebar_class_name: className},
|
||||
} = docsById[id];
|
||||
} = getDoc(id);
|
||||
return {
|
||||
type: 'doc',
|
||||
id,
|
||||
|
@ -187,6 +202,57 @@ export const DefaultSidebarItemsGenerator: SidebarItemsGenerator = async ({
|
|||
const categoryMetadata = await readCategoryMetadataFile(categoryPath);
|
||||
const className = categoryMetadata?.className;
|
||||
const {filename, numberPrefix} = numberPrefixParser(folderName);
|
||||
const allItems = await Promise.all(
|
||||
Object.entries(dir).map(([key, content]) =>
|
||||
dirToItem(content, key, `${fullPath}/${key}`),
|
||||
),
|
||||
);
|
||||
|
||||
// Try to match a doc inside the category folder,
|
||||
// using the "local id" (myDoc) or "qualified id" (dirName/myDoc)
|
||||
function findDocByLocalId(localId: string): SidebarItemDoc | undefined {
|
||||
return allItems.find(
|
||||
(item) => item.type === 'doc' && getLocalDocId(item.id) === localId,
|
||||
) as SidebarItemDoc | undefined;
|
||||
}
|
||||
|
||||
function findConventionalCategoryDocLink(): SidebarItemDoc | undefined {
|
||||
return allItems.find(
|
||||
(item) =>
|
||||
item.type === 'doc' && isConventionalDocIndex(getDoc(item.id)),
|
||||
) as SidebarItemDoc | undefined;
|
||||
}
|
||||
|
||||
function getCategoryLinkedDocId(): string | undefined {
|
||||
const link = categoryMetadata?.link;
|
||||
if (link) {
|
||||
if (link.type === 'doc') {
|
||||
return findDocByLocalId(link.id)?.id || getDoc(link.id).id;
|
||||
} else {
|
||||
// We don't continue for other link types on purpose!
|
||||
// IE if user decide to use type "generated-index", we should not pick a README.md file as the linked doc
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
// Apply default convention to pick index.md, README.md or <categoryName>.md as the category doc
|
||||
return findConventionalCategoryDocLink()?.id;
|
||||
}
|
||||
|
||||
const categoryLinkedDocId = getCategoryLinkedDocId();
|
||||
|
||||
const link: SidebarItemCategoryLink | undefined = categoryLinkedDocId
|
||||
? {
|
||||
type: 'doc',
|
||||
id: categoryLinkedDocId, // We "remap" a potentially "local id" to a "qualified id"
|
||||
}
|
||||
: // TODO typing issue
|
||||
(categoryMetadata?.link as SidebarItemCategoryLink | undefined);
|
||||
|
||||
// If a doc is linked, remove it from the category subItems
|
||||
const items = allItems.filter(
|
||||
(item) => !(item.type === 'doc' && item.id === categoryLinkedDocId),
|
||||
);
|
||||
|
||||
return {
|
||||
type: 'category',
|
||||
label: categoryMetadata?.label ?? filename,
|
||||
|
@ -195,11 +261,8 @@ export const DefaultSidebarItemsGenerator: SidebarItemsGenerator = async ({
|
|||
collapsed: categoryMetadata?.collapsed ?? options.sidebarCollapsed,
|
||||
position: categoryMetadata?.position ?? numberPrefix,
|
||||
...(className !== undefined && {className}),
|
||||
items: await Promise.all(
|
||||
Object.entries(dir).map(([key, content]) =>
|
||||
dirToItem(content, key, `${fullPath}/${key}`),
|
||||
),
|
||||
),
|
||||
items,
|
||||
...(link && {link}),
|
||||
};
|
||||
}
|
||||
async function dirToItem(
|
||||
|
|
|
@ -8,11 +8,12 @@
|
|||
import fs from 'fs-extra';
|
||||
import importFresh from 'import-fresh';
|
||||
import type {SidebarsConfig, Sidebars, NormalizedSidebars} from './types';
|
||||
import type {PluginOptions} from '../types';
|
||||
import type {NormalizeSidebarsParams, PluginOptions} from '../types';
|
||||
import {validateSidebars} from './validation';
|
||||
import {normalizeSidebars} from './normalization';
|
||||
import {processSidebars, SidebarProcessorProps} from './processor';
|
||||
import {processSidebars, SidebarProcessorParams} from './processor';
|
||||
import path from 'path';
|
||||
import {createSlugger} from '@docusaurus/utils';
|
||||
|
||||
export const DefaultSidebars: SidebarsConfig = {
|
||||
defaultSidebar: [
|
||||
|
@ -36,7 +37,7 @@ export function resolveSidebarPathOption(
|
|||
: sidebarPathOption;
|
||||
}
|
||||
|
||||
function loadSidebarFile(
|
||||
function loadSidebarsFileUnsafe(
|
||||
sidebarFilePath: string | false | undefined,
|
||||
): SidebarsConfig {
|
||||
// false => no sidebars
|
||||
|
@ -60,25 +61,34 @@ function loadSidebarFile(
|
|||
return importFresh(sidebarFilePath);
|
||||
}
|
||||
|
||||
export function loadUnprocessedSidebars(
|
||||
export function loadSidebarsFile(
|
||||
sidebarFilePath: string | false | undefined,
|
||||
options: SidebarProcessorProps['options'],
|
||||
): NormalizedSidebars {
|
||||
const sidebarsConfig = loadSidebarFile(sidebarFilePath);
|
||||
): SidebarsConfig {
|
||||
const sidebarsConfig = loadSidebarsFileUnsafe(sidebarFilePath);
|
||||
validateSidebars(sidebarsConfig);
|
||||
return sidebarsConfig;
|
||||
}
|
||||
|
||||
const normalizedSidebars = normalizeSidebars(sidebarsConfig, options);
|
||||
return normalizedSidebars;
|
||||
export function loadNormalizedSidebars(
|
||||
sidebarFilePath: string | false | undefined,
|
||||
params: NormalizeSidebarsParams,
|
||||
): NormalizedSidebars {
|
||||
return normalizeSidebars(loadSidebarsFile(sidebarFilePath), params);
|
||||
}
|
||||
|
||||
// Note: sidebarFilePath must be absolute, use resolveSidebarPathOption
|
||||
export async function loadSidebars(
|
||||
sidebarFilePath: string | false | undefined,
|
||||
options: SidebarProcessorProps,
|
||||
options: SidebarProcessorParams,
|
||||
): Promise<Sidebars> {
|
||||
const unprocessedSidebars = loadUnprocessedSidebars(
|
||||
const normalizeSidebarsParams: NormalizeSidebarsParams = {
|
||||
...options.sidebarOptions,
|
||||
version: options.version,
|
||||
categoryLabelSlugger: createSlugger(),
|
||||
};
|
||||
const normalizedSidebars = loadNormalizedSidebars(
|
||||
sidebarFilePath,
|
||||
options.options,
|
||||
normalizeSidebarsParams,
|
||||
);
|
||||
return processSidebars(unprocessedSidebars, options);
|
||||
return processSidebars(normalizedSidebars, options);
|
||||
}
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
import type {SidebarOptions} from '../types';
|
||||
import type {NormalizeSidebarsParams, SidebarOptions} from '../types';
|
||||
import type {
|
||||
NormalizedSidebarItem,
|
||||
NormalizedSidebar,
|
||||
|
@ -15,9 +15,31 @@ import type {
|
|||
SidebarItemConfig,
|
||||
SidebarConfig,
|
||||
SidebarsConfig,
|
||||
SidebarItemCategoryLink,
|
||||
NormalizedSidebarItemCategory,
|
||||
} from './types';
|
||||
import {mapValues} from 'lodash';
|
||||
import {isCategoriesShorthand} from './utils';
|
||||
import {mapValues} from 'lodash';
|
||||
import {normalizeUrl} from '@docusaurus/utils';
|
||||
|
||||
function normalizeCategoryLink(
|
||||
category: SidebarItemCategoryConfig,
|
||||
params: NormalizeSidebarsParams,
|
||||
): SidebarItemCategoryLink | undefined {
|
||||
if (category.link?.type === 'generated-index') {
|
||||
// default slug logic can be improved
|
||||
const getDefaultSlug = () =>
|
||||
`/category/${params.categoryLabelSlugger.slug(category.label)}`;
|
||||
const slug = category.link.slug ?? getDefaultSlug();
|
||||
const permalink = normalizeUrl([params.version.versionPath, slug]);
|
||||
return {
|
||||
...category.link,
|
||||
slug,
|
||||
permalink,
|
||||
};
|
||||
}
|
||||
return category.link;
|
||||
}
|
||||
|
||||
function normalizeCategoriesShorthand(
|
||||
sidebar: SidebarCategoriesShorthand,
|
||||
|
@ -36,9 +58,9 @@ function normalizeCategoriesShorthand(
|
|||
* Normalizes recursively item and all its children. Ensures that at the end
|
||||
* each item will be an object with the corresponding type.
|
||||
*/
|
||||
function normalizeItem(
|
||||
export function normalizeItem(
|
||||
item: SidebarItemConfig,
|
||||
options: SidebarOptions,
|
||||
options: NormalizeSidebarsParams,
|
||||
): NormalizedSidebarItem[] {
|
||||
if (typeof item === 'string') {
|
||||
return [
|
||||
|
@ -49,40 +71,42 @@ function normalizeItem(
|
|||
];
|
||||
}
|
||||
if (isCategoriesShorthand(item)) {
|
||||
return normalizeCategoriesShorthand(item, options).flatMap((subitem) =>
|
||||
normalizeItem(subitem, options),
|
||||
return normalizeCategoriesShorthand(item, options).flatMap((subItem) =>
|
||||
normalizeItem(subItem, options),
|
||||
);
|
||||
}
|
||||
return item.type === 'category'
|
||||
? [
|
||||
{
|
||||
...item,
|
||||
items: item.items.flatMap((subItem) =>
|
||||
normalizeItem(subItem, options),
|
||||
),
|
||||
collapsible: item.collapsible ?? options.sidebarCollapsible,
|
||||
collapsed: item.collapsed ?? options.sidebarCollapsed,
|
||||
},
|
||||
]
|
||||
: [item];
|
||||
if (item.type === 'category') {
|
||||
const link = normalizeCategoryLink(item, options);
|
||||
const normalizedCategory: NormalizedSidebarItemCategory = {
|
||||
...item,
|
||||
link,
|
||||
items: (item.items ?? []).flatMap((subItem) =>
|
||||
normalizeItem(subItem, options),
|
||||
),
|
||||
collapsible: item.collapsible ?? options.sidebarCollapsible,
|
||||
collapsed: item.collapsed ?? options.sidebarCollapsed,
|
||||
};
|
||||
return [normalizedCategory];
|
||||
}
|
||||
return [item];
|
||||
}
|
||||
|
||||
function normalizeSidebar(
|
||||
sidebar: SidebarConfig,
|
||||
options: SidebarOptions,
|
||||
options: NormalizeSidebarsParams,
|
||||
): NormalizedSidebar {
|
||||
const normalizedSidebar = Array.isArray(sidebar)
|
||||
? sidebar
|
||||
: normalizeCategoriesShorthand(sidebar, options);
|
||||
|
||||
return normalizedSidebar.flatMap((subitem) =>
|
||||
normalizeItem(subitem, options),
|
||||
return normalizedSidebar.flatMap((subItem) =>
|
||||
normalizeItem(subItem, options),
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeSidebars(
|
||||
sidebars: SidebarsConfig,
|
||||
options: SidebarOptions,
|
||||
params: NormalizeSidebarsParams,
|
||||
): NormalizedSidebars {
|
||||
return mapValues(sidebars, (subitem) => normalizeSidebar(subitem, options));
|
||||
return mapValues(sidebars, (items) => normalizeSidebar(items, params));
|
||||
}
|
||||
|
|
|
@ -21,18 +21,24 @@ import type {
|
|||
SidebarItemsGeneratorOption,
|
||||
SidebarItemsGeneratorDoc,
|
||||
SidebarItemsGeneratorVersion,
|
||||
NormalizedSidebarItemCategory,
|
||||
SidebarItemCategory,
|
||||
SidebarItemAutogenerated,
|
||||
} from './types';
|
||||
import {transformSidebarItems} from './utils';
|
||||
import {DefaultSidebarItemsGenerator} from './generator';
|
||||
import {mapValues, memoize, pick} from 'lodash';
|
||||
import combinePromises from 'combine-promises';
|
||||
import {normalizeItem} from './normalization';
|
||||
import {Slugger} from '@docusaurus/utils';
|
||||
|
||||
export type SidebarProcessorProps = {
|
||||
export type SidebarProcessorParams = {
|
||||
sidebarItemsGenerator: SidebarItemsGeneratorOption;
|
||||
numberPrefixParser: NumberPrefixParser;
|
||||
docs: DocMetadataBase[];
|
||||
version: VersionMetadata;
|
||||
options: SidebarOptions;
|
||||
categoryLabelSlugger: Slugger;
|
||||
sidebarOptions: SidebarOptions;
|
||||
};
|
||||
|
||||
function toSidebarItemsGeneratorDoc(
|
||||
|
@ -40,6 +46,7 @@ function toSidebarItemsGeneratorDoc(
|
|||
): SidebarItemsGeneratorDoc {
|
||||
return pick(doc, [
|
||||
'id',
|
||||
'unversionedId',
|
||||
'frontMatter',
|
||||
'source',
|
||||
'sourceDirName',
|
||||
|
@ -56,48 +63,71 @@ function toSidebarItemsGeneratorVersion(
|
|||
// Handle the generation of autogenerated sidebar items and other post-processing checks
|
||||
async function processSidebar(
|
||||
unprocessedSidebar: NormalizedSidebar,
|
||||
{
|
||||
params: SidebarProcessorParams,
|
||||
): Promise<Sidebar> {
|
||||
const {
|
||||
sidebarItemsGenerator,
|
||||
numberPrefixParser,
|
||||
docs,
|
||||
version,
|
||||
options,
|
||||
}: SidebarProcessorProps,
|
||||
): Promise<Sidebar> {
|
||||
sidebarOptions,
|
||||
} = params;
|
||||
|
||||
// Just a minor lazy transformation optimization
|
||||
const getSidebarItemsGeneratorDocsAndVersion = memoize(() => ({
|
||||
docs: docs.map(toSidebarItemsGeneratorDoc),
|
||||
version: toSidebarItemsGeneratorVersion(version),
|
||||
}));
|
||||
|
||||
async function handleAutoGeneratedItems(
|
||||
async function processCategoryItem(
|
||||
item: NormalizedSidebarItemCategory,
|
||||
): Promise<SidebarItemCategory> {
|
||||
return {
|
||||
...item,
|
||||
items: (await Promise.all(item.items.map(processItem))).flat(),
|
||||
};
|
||||
}
|
||||
|
||||
async function processAutoGeneratedItem(
|
||||
item: SidebarItemAutogenerated,
|
||||
): Promise<SidebarItem[]> {
|
||||
// TODO the returned type can't be trusted in practice (generator can be user-provided)
|
||||
const generatedItems = await sidebarItemsGenerator({
|
||||
item,
|
||||
numberPrefixParser,
|
||||
defaultSidebarItemsGenerator: DefaultSidebarItemsGenerator,
|
||||
...getSidebarItemsGeneratorDocsAndVersion(),
|
||||
options: sidebarOptions,
|
||||
});
|
||||
// TODO validate generated items: user can generate bad items
|
||||
|
||||
const generatedItemsNormalized = generatedItems.flatMap((generatedItem) =>
|
||||
normalizeItem(generatedItem, {...params, ...sidebarOptions}),
|
||||
);
|
||||
|
||||
// Process again... weird but sidebar item generated might generate some auto-generated items?
|
||||
return processItems(generatedItemsNormalized);
|
||||
}
|
||||
|
||||
async function processItem(
|
||||
item: NormalizedSidebarItem,
|
||||
): Promise<SidebarItem[]> {
|
||||
if (item.type === 'category') {
|
||||
return [
|
||||
{
|
||||
...item,
|
||||
items: (
|
||||
await Promise.all(item.items.map(handleAutoGeneratedItems))
|
||||
).flat(),
|
||||
},
|
||||
];
|
||||
return [await processCategoryItem(item)];
|
||||
}
|
||||
if (item.type === 'autogenerated') {
|
||||
return sidebarItemsGenerator({
|
||||
item,
|
||||
numberPrefixParser,
|
||||
defaultSidebarItemsGenerator: DefaultSidebarItemsGenerator,
|
||||
...getSidebarItemsGeneratorDocsAndVersion(),
|
||||
options,
|
||||
});
|
||||
return processAutoGeneratedItem(item);
|
||||
}
|
||||
return [item];
|
||||
}
|
||||
|
||||
const processedSidebar = (
|
||||
await Promise.all(unprocessedSidebar.map(handleAutoGeneratedItems))
|
||||
).flat();
|
||||
async function processItems(
|
||||
items: NormalizedSidebarItem[],
|
||||
): Promise<SidebarItem[]> {
|
||||
return (await Promise.all(items.map(processItem))).flat();
|
||||
}
|
||||
|
||||
const processedSidebar = await processItems(unprocessedSidebar);
|
||||
|
||||
const fixSidebarItemInconsistencies = (item: SidebarItem): SidebarItem => {
|
||||
// A non-collapsible category can't be collapsed!
|
||||
|
@ -114,11 +144,11 @@ async function processSidebar(
|
|||
|
||||
export async function processSidebars(
|
||||
unprocessedSidebars: NormalizedSidebars,
|
||||
props: SidebarProcessorProps,
|
||||
params: SidebarProcessorParams,
|
||||
): Promise<Sidebars> {
|
||||
return combinePromises(
|
||||
mapValues(unprocessedSidebars, (unprocessedSidebar) =>
|
||||
processSidebar(unprocessedSidebar, props),
|
||||
processSidebar(unprocessedSidebar, params),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -12,6 +12,7 @@ import type {
|
|||
NumberPrefixParser,
|
||||
SidebarOptions,
|
||||
} from '../types';
|
||||
import {Required} from 'utility-types';
|
||||
|
||||
// Makes all properties visible when hovering over the type
|
||||
type Expand<T extends Record<string, unknown>> = {[P in keyof T]: T[P]};
|
||||
|
@ -45,10 +46,35 @@ type SidebarItemCategoryBase = SidebarItemBase & {
|
|||
collapsible: boolean;
|
||||
};
|
||||
|
||||
export type SidebarItemCategoryLinkDoc = {type: 'doc'; id: string};
|
||||
|
||||
export type SidebarItemCategoryLinkGeneratedIndexConfig = {
|
||||
type: 'generated-index';
|
||||
slug?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
};
|
||||
export type SidebarItemCategoryLinkGeneratedIndex = {
|
||||
type: 'generated-index';
|
||||
slug: string;
|
||||
permalink: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export type SidebarItemCategoryLinkConfig =
|
||||
| SidebarItemCategoryLinkDoc
|
||||
| SidebarItemCategoryLinkGeneratedIndexConfig;
|
||||
|
||||
export type SidebarItemCategoryLink =
|
||||
| SidebarItemCategoryLinkDoc
|
||||
| SidebarItemCategoryLinkGeneratedIndex;
|
||||
|
||||
// The user-given configuration in sidebars.js, before normalization
|
||||
export type SidebarItemCategoryConfig = Expand<
|
||||
Optional<SidebarItemCategoryBase, 'collapsed' | 'collapsible'> & {
|
||||
items: SidebarItemConfig[];
|
||||
link?: SidebarItemCategoryLinkConfig;
|
||||
}
|
||||
>;
|
||||
|
||||
|
@ -73,6 +99,7 @@ export type SidebarsConfig = {
|
|||
export type NormalizedSidebarItemCategory = Expand<
|
||||
SidebarItemCategoryBase & {
|
||||
items: NormalizedSidebarItem[];
|
||||
link?: SidebarItemCategoryLink;
|
||||
}
|
||||
>;
|
||||
|
||||
|
@ -90,14 +117,25 @@ export type NormalizedSidebars = {
|
|||
export type SidebarItemCategory = Expand<
|
||||
SidebarItemCategoryBase & {
|
||||
items: SidebarItem[];
|
||||
link?: SidebarItemCategoryLink;
|
||||
}
|
||||
>;
|
||||
|
||||
export type SidebarItemCategoryWithLink = Required<SidebarItemCategory, 'link'>;
|
||||
|
||||
export type SidebarItemCategoryWithGeneratedIndex =
|
||||
SidebarItemCategoryWithLink & {link: SidebarItemCategoryLinkGeneratedIndex};
|
||||
|
||||
export type SidebarItem =
|
||||
| SidebarItemDoc
|
||||
| SidebarItemLink
|
||||
| SidebarItemCategory;
|
||||
|
||||
// A sidebar item that is part of the previous/next ordered navigation
|
||||
export type SidebarNavigationItem =
|
||||
| SidebarItemDoc
|
||||
| SidebarItemCategoryWithLink;
|
||||
|
||||
export type Sidebar = SidebarItem[];
|
||||
export type SidebarItemType = SidebarItem['type'];
|
||||
export type Sidebars = {
|
||||
|
@ -108,21 +146,42 @@ export type Sidebars = {
|
|||
export type PropSidebarItemCategory = Expand<
|
||||
SidebarItemCategoryBase & {
|
||||
items: PropSidebarItem[];
|
||||
href?: string;
|
||||
}
|
||||
>;
|
||||
|
||||
export type PropSidebarItem = SidebarItemLink | PropSidebarItemCategory;
|
||||
// we may want to use a union type in props instead of this generic link?
|
||||
export type PropSidebarItemLink = SidebarItemLink & {
|
||||
docId?: string;
|
||||
};
|
||||
|
||||
export type PropSidebarItem = PropSidebarItemLink | PropSidebarItemCategory;
|
||||
export type PropSidebar = PropSidebarItem[];
|
||||
export type PropSidebars = {
|
||||
[sidebarId: string]: PropSidebar;
|
||||
};
|
||||
|
||||
export type PropVersionDoc = {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
sidebar?: string;
|
||||
};
|
||||
export type PropVersionDocs = {
|
||||
[docId: string]: PropVersionDoc;
|
||||
};
|
||||
|
||||
// Reduce API surface for options.sidebarItemsGenerator
|
||||
// The user-provided generator fn should receive only a subset of metadata
|
||||
// A change to any of these metadata can be considered as a breaking change
|
||||
export type SidebarItemsGeneratorDoc = Pick<
|
||||
DocMetadataBase,
|
||||
'id' | 'frontMatter' | 'source' | 'sourceDirName' | 'sidebarPosition'
|
||||
| 'id'
|
||||
| 'unversionedId'
|
||||
| 'frontMatter'
|
||||
| 'source'
|
||||
| 'sourceDirName'
|
||||
| 'sidebarPosition'
|
||||
>;
|
||||
export type SidebarItemsGeneratorVersion = Pick<
|
||||
VersionMetadata,
|
||||
|
@ -138,7 +197,9 @@ export type SidebarItemsGeneratorArgs = {
|
|||
};
|
||||
export type SidebarItemsGenerator = (
|
||||
generatorArgs: SidebarItemsGeneratorArgs,
|
||||
) => Promise<SidebarItem[]>;
|
||||
) => // TODO TS issue: the generator can generate un-normalized items!
|
||||
Promise<SidebarItem[]>;
|
||||
// Promise<SidebarItemConfig[]>;
|
||||
|
||||
// Also inject the default generator to conveniently wrap/enhance/sort the default sidebar gen logic
|
||||
// see https://github.com/facebook/docusaurus/issues/4640#issuecomment-822292320
|
||||
|
|
|
@ -16,8 +16,15 @@ import type {
|
|||
SidebarCategoriesShorthand,
|
||||
SidebarItemConfig,
|
||||
} from './types';
|
||||
import {mapValues, difference} from 'lodash';
|
||||
|
||||
import {mapValues, difference, uniq} from 'lodash';
|
||||
import {getElementsAround, toMessageRelativeFilePath} from '@docusaurus/utils';
|
||||
import {DocMetadataBase, DocNavLink} from '../types';
|
||||
import {
|
||||
SidebarItemCategoryWithGeneratedIndex,
|
||||
SidebarItemCategoryWithLink,
|
||||
SidebarNavigationItem,
|
||||
} from './types';
|
||||
|
||||
export function isCategoriesShorthand(
|
||||
item: SidebarItemConfig,
|
||||
|
@ -41,21 +48,24 @@ export function transformSidebarItems(
|
|||
return sidebar.map(transformRecursive);
|
||||
}
|
||||
|
||||
// Flatten sidebar items into a single flat array (containing categories/docs on the same level)
|
||||
// /!\ order matters (useful for next/prev nav), top categories appear before their child elements
|
||||
function flattenSidebarItems(items: SidebarItem[]): SidebarItem[] {
|
||||
function flattenRecursive(item: SidebarItem): SidebarItem[] {
|
||||
return item.type === 'category'
|
||||
? [item, ...item.items.flatMap(flattenRecursive)]
|
||||
: [item];
|
||||
}
|
||||
return items.flatMap(flattenRecursive);
|
||||
}
|
||||
|
||||
function collectSidebarItemsOfType<
|
||||
Type extends SidebarItemType,
|
||||
Item extends SidebarItem & {type: SidebarItemType},
|
||||
>(type: Type, sidebar: Sidebar): Item[] {
|
||||
function collectRecursive(item: SidebarItem): Item[] {
|
||||
const currentItemsCollected: Item[] =
|
||||
item.type === type ? [item as Item] : [];
|
||||
|
||||
const childItemsCollected: Item[] =
|
||||
item.type === 'category' ? item.items.flatMap(collectRecursive) : [];
|
||||
|
||||
return [...currentItemsCollected, ...childItemsCollected];
|
||||
}
|
||||
|
||||
return sidebar.flatMap(collectRecursive);
|
||||
return flattenSidebarItems(sidebar).filter(
|
||||
(item) => item.type === type,
|
||||
) as Item[];
|
||||
}
|
||||
|
||||
export function collectSidebarDocItems(sidebar: Sidebar): SidebarItemDoc[] {
|
||||
|
@ -70,25 +80,72 @@ export function collectSidebarLinks(sidebar: Sidebar): SidebarItemLink[] {
|
|||
return collectSidebarItemsOfType('link', sidebar);
|
||||
}
|
||||
|
||||
// /!\ docId order matters for navigation!
|
||||
export function collectSidebarDocIds(sidebar: Sidebar): string[] {
|
||||
return flattenSidebarItems(sidebar).flatMap((item) => {
|
||||
if (item.type === 'category') {
|
||||
return item.link?.type === 'doc' ? [item.link.id] : [];
|
||||
}
|
||||
if (item.type === 'doc') {
|
||||
return [item.id];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
export function collectSidebarNavigation(
|
||||
sidebar: Sidebar,
|
||||
): SidebarNavigationItem[] {
|
||||
return flattenSidebarItems(sidebar).flatMap((item) => {
|
||||
if (item.type === 'category' && item.link) {
|
||||
return [item as SidebarNavigationItem];
|
||||
}
|
||||
if (item.type === 'doc') {
|
||||
return [item];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
export function collectSidebarsDocIds(
|
||||
sidebars: Sidebars,
|
||||
): Record<string, string[]> {
|
||||
return mapValues(sidebars, (sidebar) =>
|
||||
collectSidebarDocItems(sidebar).map((docItem) => docItem.id),
|
||||
);
|
||||
return mapValues(sidebars, collectSidebarDocIds);
|
||||
}
|
||||
|
||||
export function createSidebarsUtils(sidebars: Sidebars): {
|
||||
export function collectSidebarsNavigations(
|
||||
sidebars: Sidebars,
|
||||
): Record<string, SidebarNavigationItem[]> {
|
||||
return mapValues(sidebars, collectSidebarNavigation);
|
||||
}
|
||||
|
||||
export type SidebarNavigation = {
|
||||
sidebarName: string | undefined;
|
||||
previous: SidebarNavigationItem | undefined;
|
||||
next: SidebarNavigationItem | undefined;
|
||||
};
|
||||
|
||||
// A convenient and performant way to query the sidebars content
|
||||
export type SidebarsUtils = {
|
||||
sidebars: Sidebars;
|
||||
getFirstDocIdOfFirstSidebar: () => string | undefined;
|
||||
getSidebarNameByDocId: (docId: string) => string | undefined;
|
||||
getDocNavigation: (docId: string) => {
|
||||
sidebarName: string | undefined;
|
||||
previousId: string | undefined;
|
||||
nextId: string | undefined;
|
||||
};
|
||||
getDocNavigation: (
|
||||
unversionedId: string,
|
||||
versionedId: string,
|
||||
) => SidebarNavigation;
|
||||
getCategoryGeneratedIndexList: () => SidebarItemCategoryWithGeneratedIndex[];
|
||||
getCategoryGeneratedIndexNavigation: (
|
||||
categoryGeneratedIndexPermalink: string,
|
||||
) => SidebarNavigation;
|
||||
|
||||
checkSidebarsDocIds: (validDocIds: string[], sidebarFilePath: string) => void;
|
||||
} {
|
||||
};
|
||||
|
||||
export function createSidebarsUtils(sidebars: Sidebars): SidebarsUtils {
|
||||
const sidebarNameToDocIds = collectSidebarsDocIds(sidebars);
|
||||
const sidebarNameToNavigationItems = collectSidebarsNavigations(sidebars);
|
||||
|
||||
// Reverse mapping
|
||||
const docIdToSidebarName = Object.fromEntries(
|
||||
Object.entries(sidebarNameToDocIds).flatMap(([sidebarName, docIds]) =>
|
||||
|
@ -104,27 +161,91 @@ export function createSidebarsUtils(sidebars: Sidebars): {
|
|||
return docIdToSidebarName[docId];
|
||||
}
|
||||
|
||||
function getDocNavigation(docId: string): {
|
||||
sidebarName: string | undefined;
|
||||
previousId: string | undefined;
|
||||
nextId: string | undefined;
|
||||
} {
|
||||
const sidebarName = getSidebarNameByDocId(docId);
|
||||
function emptySidebarNavigation(): SidebarNavigation {
|
||||
return {
|
||||
sidebarName: undefined,
|
||||
previous: undefined,
|
||||
next: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function getDocNavigation(
|
||||
unversionedId: string,
|
||||
versionedId: string,
|
||||
): SidebarNavigation {
|
||||
// TODO legacy id retro-compatibility!
|
||||
let docId = unversionedId;
|
||||
let sidebarName = getSidebarNameByDocId(docId);
|
||||
if (!sidebarName) {
|
||||
docId = versionedId;
|
||||
sidebarName = getSidebarNameByDocId(docId);
|
||||
}
|
||||
|
||||
if (sidebarName) {
|
||||
const docIds = sidebarNameToDocIds[sidebarName];
|
||||
const currentIndex = docIds.indexOf(docId);
|
||||
const {previous, next} = getElementsAround(docIds, currentIndex);
|
||||
return {
|
||||
sidebarName,
|
||||
previousId: previous,
|
||||
nextId: next,
|
||||
};
|
||||
const navigationItems = sidebarNameToNavigationItems[sidebarName];
|
||||
const currentItemIndex = navigationItems.findIndex((item) => {
|
||||
if (item.type === 'doc') {
|
||||
return item.id === docId;
|
||||
}
|
||||
if (item.type === 'category' && item.link.type === 'doc') {
|
||||
return item.link.id === docId;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const {previous, next} = getElementsAround(
|
||||
navigationItems,
|
||||
currentItemIndex,
|
||||
);
|
||||
return {sidebarName, previous, next};
|
||||
} else {
|
||||
return {
|
||||
sidebarName: undefined,
|
||||
previousId: undefined,
|
||||
nextId: undefined,
|
||||
};
|
||||
return emptySidebarNavigation();
|
||||
}
|
||||
}
|
||||
|
||||
function getCategoryGeneratedIndexList(): SidebarItemCategoryWithGeneratedIndex[] {
|
||||
return Object.values(sidebarNameToNavigationItems)
|
||||
.flat()
|
||||
.flatMap((item) => {
|
||||
if (item.type === 'category' && item.link.type === 'generated-index') {
|
||||
return [item as SidebarItemCategoryWithGeneratedIndex];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
// We identity the category generated index by its permalink (should be unique)
|
||||
// More reliable than using object identity
|
||||
function getCategoryGeneratedIndexNavigation(
|
||||
categoryGeneratedIndexPermalink: string,
|
||||
): SidebarNavigation {
|
||||
function isCurrentCategoryGeneratedIndexItem(
|
||||
item: SidebarNavigationItem,
|
||||
): boolean {
|
||||
return (
|
||||
item.type === 'category' &&
|
||||
item.link?.type === 'generated-index' &&
|
||||
item.link.permalink === categoryGeneratedIndexPermalink
|
||||
);
|
||||
}
|
||||
|
||||
const sidebarName = Object.entries(sidebarNameToNavigationItems).find(
|
||||
([, navigationItems]) =>
|
||||
navigationItems.find(isCurrentCategoryGeneratedIndexItem),
|
||||
)?.[0];
|
||||
|
||||
if (sidebarName) {
|
||||
const navigationItems = sidebarNameToNavigationItems[sidebarName];
|
||||
const currentItemIndex = navigationItems.findIndex(
|
||||
isCurrentCategoryGeneratedIndexItem,
|
||||
);
|
||||
const {previous, next} = getElementsAround(
|
||||
navigationItems,
|
||||
currentItemIndex,
|
||||
);
|
||||
return {sidebarName, previous, next};
|
||||
} else {
|
||||
return emptySidebarNavigation();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -140,15 +261,69 @@ These sidebar document ids do not exist:
|
|||
- ${invalidSidebarDocIds.sort().join('\n- ')}
|
||||
|
||||
Available document ids are:
|
||||
- ${validDocIds.sort().join('\n- ')}`,
|
||||
- ${uniq(validDocIds).sort().join('\n- ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sidebars,
|
||||
getFirstDocIdOfFirstSidebar,
|
||||
getSidebarNameByDocId,
|
||||
getDocNavigation,
|
||||
getCategoryGeneratedIndexList,
|
||||
getCategoryGeneratedIndexNavigation,
|
||||
checkSidebarsDocIds,
|
||||
};
|
||||
}
|
||||
|
||||
export function toDocNavigationLink(doc: DocMetadataBase): DocNavLink {
|
||||
const {
|
||||
title,
|
||||
permalink,
|
||||
frontMatter: {
|
||||
pagination_label: paginationLabel,
|
||||
sidebar_label: sidebarLabel,
|
||||
},
|
||||
} = doc;
|
||||
return {title: paginationLabel ?? sidebarLabel ?? title, permalink};
|
||||
}
|
||||
|
||||
export function toNavigationLink(
|
||||
navigationItem: SidebarNavigationItem | undefined,
|
||||
docsById: Record<string, DocMetadataBase>,
|
||||
): DocNavLink | undefined {
|
||||
function getDocById(docId: string) {
|
||||
const doc = docsById[docId];
|
||||
if (!doc) {
|
||||
throw new Error(
|
||||
`Can't create navigation link: no doc found with id=${docId}`,
|
||||
);
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
function handleCategory(category: SidebarItemCategoryWithLink): DocNavLink {
|
||||
if (category.link.type === 'doc') {
|
||||
return toDocNavigationLink(getDocById(category.link.id));
|
||||
} else if (category.link.type === 'generated-index') {
|
||||
return {
|
||||
title: category.label,
|
||||
permalink: category.link.permalink,
|
||||
};
|
||||
} else {
|
||||
throw new Error('unexpected category link type');
|
||||
}
|
||||
}
|
||||
if (!navigationItem) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (navigationItem.type === 'doc') {
|
||||
return toDocNavigationLink(getDocById(navigationItem.id));
|
||||
} else if (navigationItem.type === 'category') {
|
||||
return handleCategory(navigationItem);
|
||||
} else {
|
||||
throw new Error('unexpected navigation item');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,9 +14,13 @@ import type {
|
|||
SidebarItemDoc,
|
||||
SidebarItemLink,
|
||||
SidebarItemCategoryConfig,
|
||||
SidebarItemCategoryLink,
|
||||
SidebarsConfig,
|
||||
SidebarItemCategoryLinkDoc,
|
||||
SidebarItemCategoryLinkGeneratedIndex,
|
||||
} from './types';
|
||||
import {isCategoriesShorthand} from './utils';
|
||||
import {CategoryMetadataFile} from './generator';
|
||||
|
||||
const sidebarItemBaseSchema = Joi.object<SidebarItemBase>({
|
||||
className: Joi.string(),
|
||||
|
@ -48,6 +52,36 @@ const sidebarItemLinkSchema = sidebarItemBaseSchema.append<SidebarItemLink>({
|
|||
.messages({'any.unknown': '"label" must be a string'}),
|
||||
});
|
||||
|
||||
const sidebarItemCategoryLinkSchema = Joi.object<SidebarItemCategoryLink>()
|
||||
.when('.type', {
|
||||
switch: [
|
||||
{
|
||||
is: 'doc',
|
||||
then: Joi.object<SidebarItemCategoryLinkDoc>({
|
||||
type: 'doc',
|
||||
id: Joi.string().required(),
|
||||
}),
|
||||
},
|
||||
{
|
||||
is: 'generated-index',
|
||||
then: Joi.object<SidebarItemCategoryLinkGeneratedIndex>({
|
||||
type: 'generated-index',
|
||||
slug: Joi.string().optional(),
|
||||
// permalink: Joi.string().optional(), // No, this one is not in the user config, only in the normalized version
|
||||
title: Joi.string().optional(),
|
||||
description: Joi.string().optional(),
|
||||
}),
|
||||
},
|
||||
{
|
||||
is: Joi.string().required(),
|
||||
then: Joi.forbidden().messages({
|
||||
'any.unknown': 'Unknown sidebar category link type "{.type}".',
|
||||
}),
|
||||
},
|
||||
],
|
||||
})
|
||||
.id('sidebarCategoryLinkSchema');
|
||||
|
||||
const sidebarItemCategorySchema =
|
||||
sidebarItemBaseSchema.append<SidebarItemCategoryConfig>({
|
||||
type: 'category',
|
||||
|
@ -58,6 +92,7 @@ const sidebarItemCategorySchema =
|
|||
items: Joi.array()
|
||||
.required()
|
||||
.messages({'any.unknown': '"items" must be an array'}), // .items(Joi.link('#sidebarItemSchema')),
|
||||
link: sidebarItemCategoryLinkSchema,
|
||||
collapsed: Joi.boolean().messages({
|
||||
'any.unknown': '"collapsed" must be a boolean',
|
||||
}),
|
||||
|
@ -77,14 +112,7 @@ const sidebarItemSchema: Joi.Schema<SidebarItemConfig> = Joi.object()
|
|||
{is: 'autogenerated', then: sidebarItemAutogeneratedSchema},
|
||||
{is: 'category', then: sidebarItemCategorySchema},
|
||||
{
|
||||
is: 'subcategory',
|
||||
then: Joi.forbidden().messages({
|
||||
'any.unknown':
|
||||
'Docusaurus v2: "subcategory" has been renamed as "category".',
|
||||
}),
|
||||
},
|
||||
{
|
||||
is: Joi.string().required(),
|
||||
is: Joi.any().required(),
|
||||
then: Joi.forbidden().messages({
|
||||
'any.unknown': 'Unknown sidebar item type "{.type}".',
|
||||
}),
|
||||
|
@ -105,6 +133,7 @@ function validateSidebarItem(item: unknown): asserts item is SidebarItemConfig {
|
|||
);
|
||||
} else {
|
||||
Joi.assert(item, sidebarItemSchema);
|
||||
|
||||
if ((item as SidebarItemCategoryConfig).type === 'category') {
|
||||
(item as SidebarItemCategoryConfig).items.forEach(validateSidebarItem);
|
||||
}
|
||||
|
@ -122,3 +151,18 @@ export function validateSidebars(
|
|||
}
|
||||
});
|
||||
}
|
||||
|
||||
const categoryMetadataFileSchema = Joi.object<CategoryMetadataFile>({
|
||||
label: Joi.string(),
|
||||
position: Joi.number(),
|
||||
collapsed: Joi.boolean(),
|
||||
collapsible: Joi.boolean(),
|
||||
className: Joi.string(),
|
||||
link: sidebarItemCategoryLinkSchema,
|
||||
});
|
||||
|
||||
export function validateCategoryMetadataFile(
|
||||
unsafeContent: unknown,
|
||||
): CategoryMetadataFile {
|
||||
return Joi.attempt(unsafeContent, categoryMetadataFileSchema);
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue