refactor(content-docs): refactor sidebars, Joi validation, generator rework, expose config types (#5678)

This commit is contained in:
Joshua Chen 2021-10-14 20:38:26 +08:00 committed by GitHub
parent 543011c9d2
commit 8d92e9bcf5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
41 changed files with 1806 additions and 1880 deletions

View file

@ -0,0 +1,34 @@
/**
* 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.
*/
module.exports = {
docs: [
{
'level 1': [
'a',
{
'level 2': [
{
'level 3': [
'c',
{
'level 4': [
'd',
{
'deeper more more': ['e'],
},
],
},
],
},
'f',
],
},
],
},
],
};

View file

@ -0,0 +1,11 @@
{
"docs": {
"Test": [
{
"type": "category",
"label": "Category Label",
"items": "doc1"
}
]
}
}

View file

@ -0,0 +1,11 @@
{
"docs": {
"Test": [
{
"type": "category",
"label": true,
"items": ["doc1"]
}
]
}
}

View file

@ -0,0 +1,44 @@
/*
* 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.
*/
module.exports = {
docs: [
{
type: 'category',
label: 'level 1',
items: [
'a',
{
type: 'category',
label: 'level 2',
items: [
{
type: 'category',
label: 'level 3',
items: [
'c',
{
type: 'category',
label: 'level 4',
items: [
'd',
{
type: 'category',
label: 'deeper more more',
items: ['e'],
},
],
},
],
},
'f',
],
},
],
},
],
};

View file

@ -0,0 +1,20 @@
{
"docs": [
{
"type": "category",
"label": "Introduction",
"items": [
"doc1"
],
"collapsed": false
},
{
"type": "category",
"label": "Powering MDX",
"items": [
"doc2"
],
"collapsed": false
}
]
}

View file

@ -0,0 +1,21 @@
{
"docs": {
"Test": [
{
"type": "category",
"label": "Introduction",
"items": ["doc1"],
"collapsed": false
}
],
"Reference": [
{
"type": "category",
"label": "Powering MDX",
"items": ["doc2"],
"collapsed": false
}
]
}
}

View file

@ -0,0 +1,10 @@
{
"docs": {
"Test": [
{
"type": "doc",
"id": ["doc1"]
}
]
}
}

View file

@ -0,0 +1,20 @@
/**
* 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.
*/
module.exports = {
docs: [
{
type: 'category',
label: 'Getting Started',
items: ['greeting'],
},
{
type: 'doc',
id: 'api',
},
],
};

View file

@ -0,0 +1,11 @@
{
"docs": {
"Test": [
{
"type": "link",
"label": "GitHub",
"href": ["example.com"]
}
]
}
}

View file

@ -0,0 +1,11 @@
{
"docs": {
"Test": [
{
"type": "link",
"label": false,
"href": "https://github.com"
}
]
}
}

View file

@ -0,0 +1,11 @@
{
"docs": {
"Test": [
{
"type": "link",
"label": "category",
"href": "https://github.com"
}
]
}
}

View file

@ -0,0 +1,14 @@
{
"docs": {
"Test": [
"foo/bar",
"foo/baz",
{
"type": "superman"
}
],
"Guides": [
"hello"
]
}
}

View file

@ -0,0 +1,20 @@
{
"docs": {
"Test": [
"foo/bar",
"foo/baz",
{
"type": "category",
"label": "category",
"href": "https://github.com"
},
{
"type": "ref",
"id": "hello"
}
],
"Guides": [
"hello"
]
}
}

View file

@ -0,0 +1,20 @@
{
"docs": {
"Test": [
"foo/bar",
"foo/baz",
{
"type": "link",
"label": "Github",
"href": "https://github.com"
},
{
"type": "ref",
"id": "hello"
}
],
"Guides": [
"hello"
]
}
}

View file

@ -0,0 +1,233 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`loadUnprocessedSidebars sidebars link 1`] = `
Object {
"docs": Array [
Object {
"collapsed": true,
"collapsible": true,
"items": Array [
Object {
"href": "https://github.com",
"label": "category",
"type": "link",
},
],
"label": "Test",
"type": "category",
},
],
}
`;
exports[`loadUnprocessedSidebars sidebars with category.collapsed property 1`] = `
Object {
"docs": Array [
Object {
"collapsed": true,
"collapsible": true,
"items": Array [
Object {
"collapsed": false,
"collapsible": true,
"items": Array [
Object {
"id": "doc1",
"type": "doc",
},
],
"label": "Introduction",
"type": "category",
},
],
"label": "Test",
"type": "category",
},
Object {
"collapsed": true,
"collapsible": true,
"items": Array [
Object {
"collapsed": false,
"collapsible": true,
"items": Array [
Object {
"id": "doc2",
"type": "doc",
},
],
"label": "Powering MDX",
"type": "category",
},
],
"label": "Reference",
"type": "category",
},
],
}
`;
exports[`loadUnprocessedSidebars sidebars with category.collapsed property at first level 1`] = `
Object {
"docs": Array [
Object {
"collapsed": false,
"collapsible": true,
"items": Array [
Object {
"id": "doc1",
"type": "doc",
},
],
"label": "Introduction",
"type": "category",
},
Object {
"collapsed": false,
"collapsible": true,
"items": Array [
Object {
"id": "doc2",
"type": "doc",
},
],
"label": "Powering MDX",
"type": "category",
},
],
}
`;
exports[`loadUnprocessedSidebars sidebars with deep level of category 1`] = `
Object {
"docs": Array [
Object {
"collapsed": true,
"collapsible": true,
"items": Array [
Object {
"id": "a",
"type": "doc",
},
Object {
"collapsed": true,
"collapsible": true,
"items": Array [
Object {
"collapsed": true,
"collapsible": true,
"items": Array [
Object {
"id": "c",
"type": "doc",
},
Object {
"collapsed": true,
"collapsible": true,
"items": Array [
Object {
"id": "d",
"type": "doc",
},
Object {
"collapsed": true,
"collapsible": true,
"items": Array [
Object {
"id": "e",
"type": "doc",
},
],
"label": "deeper more more",
"type": "category",
},
],
"label": "level 4",
"type": "category",
},
],
"label": "level 3",
"type": "category",
},
Object {
"id": "f",
"type": "doc",
},
],
"label": "level 2",
"type": "category",
},
],
"label": "level 1",
"type": "category",
},
],
}
`;
exports[`loadUnprocessedSidebars sidebars with first level not a category 1`] = `
Object {
"docs": Array [
Object {
"collapsed": true,
"collapsible": true,
"items": Array [
Object {
"id": "greeting",
"type": "doc",
},
],
"label": "Getting Started",
"type": "category",
},
Object {
"id": "api",
"type": "doc",
},
],
}
`;
exports[`loadUnprocessedSidebars sidebars with known sidebar item type 1`] = `
Object {
"docs": Array [
Object {
"collapsed": true,
"collapsible": true,
"items": Array [
Object {
"id": "foo/bar",
"type": "doc",
},
Object {
"id": "foo/baz",
"type": "doc",
},
Object {
"href": "https://github.com",
"label": "Github",
"type": "link",
},
Object {
"id": "hello",
"type": "ref",
},
],
"label": "Test",
"type": "category",
},
Object {
"collapsed": true,
"collapsible": true,
"items": Array [
Object {
"id": "hello",
"type": "doc",
},
],
"label": "Guides",
"type": "category",
},
],
}
`;

View file

@ -0,0 +1,358 @@
/**
* 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 {
CategoryMetadatasFile,
DefaultSidebarItemsGenerator,
} from '../generator';
import {Sidebar, SidebarItemsGenerator} from '../types';
import fs from 'fs-extra';
import {DefaultNumberPrefixParser} from '../../numberPrefix';
describe('DefaultSidebarItemsGenerator', () => {
function testDefaultSidebarItemsGenerator(
params: Partial<Parameters<SidebarItemsGenerator>[0]>,
) {
return DefaultSidebarItemsGenerator({
numberPrefixParser: DefaultNumberPrefixParser,
item: {
type: 'autogenerated',
dirName: '.',
},
version: {
versionName: 'current',
contentPath: 'docs',
},
docs: [],
options: {
sidebarCollapsed: true,
sidebarCollapsible: true,
},
...params,
});
}
function mockCategoryMetadataFiles(
categoryMetadataFiles: Record<string, Partial<CategoryMetadatasFile>>,
) {
jest.spyOn(fs, 'pathExists').mockImplementation((metadataFilePath) => {
return typeof categoryMetadataFiles[metadataFilePath] !== 'undefined';
});
jest.spyOn(fs, 'readFile').mockImplementation(
// @ts-expect-error: annoying TS error due to overrides
async (metadataFilePath: string) => {
return JSON.stringify(categoryMetadataFiles[metadataFilePath]);
},
);
}
test('generates empty sidebar slice when no docs and emit a warning', async () => {
const consoleWarn = jest.spyOn(console, 'warn');
const sidebarSlice = await testDefaultSidebarItemsGenerator({
docs: [],
});
expect(sidebarSlice).toEqual([]);
expect(consoleWarn).toHaveBeenCalledWith(
expect.stringMatching(
/No docs found in dir .: can't auto-generate a sidebar/,
),
);
});
test('generates simple flat sidebar', async () => {
const sidebarSlice = await DefaultSidebarItemsGenerator({
numberPrefixParser: DefaultNumberPrefixParser,
item: {
type: 'autogenerated',
dirName: '.',
},
version: {
versionName: 'current',
contentPath: '',
},
docs: [
{
id: 'doc1',
source: 'doc1.md',
sourceDirName: '.',
sidebarPosition: 2,
frontMatter: {
sidebar_label: 'doc1 sidebar label',
},
},
{
id: 'doc2',
source: 'doc2.md',
sourceDirName: '.',
sidebarPosition: 3,
frontMatter: {},
},
{
id: 'doc3',
source: 'doc3.md',
sourceDirName: '.',
sidebarPosition: 1,
frontMatter: {},
},
{
id: 'doc4',
source: 'doc4.md',
sourceDirName: '.',
sidebarPosition: 1.5,
frontMatter: {},
},
{
id: 'doc5',
source: 'doc5.md',
sourceDirName: '.',
sidebarPosition: undefined,
frontMatter: {},
},
],
options: {
sidebarCollapsed: true,
sidebarCollapsible: true,
},
});
expect(sidebarSlice).toEqual([
{type: 'doc', id: 'doc3'},
{type: 'doc', id: 'doc4'},
{type: 'doc', id: 'doc1', label: 'doc1 sidebar label'},
{type: 'doc', id: 'doc2'},
{type: 'doc', id: 'doc5'},
] as Sidebar);
});
test('generates complex nested sidebar', async () => {
mockCategoryMetadataFiles({
'02-Guides/_category_.json': {collapsed: false},
'02-Guides/01-SubGuides/_category_.yml': {
label: 'SubGuides (metadata file label)',
},
});
const sidebarSlice = await DefaultSidebarItemsGenerator({
numberPrefixParser: DefaultNumberPrefixParser,
item: {
type: 'autogenerated',
dirName: '.',
},
version: {
versionName: 'current',
contentPath: '',
},
docs: [
{
id: 'intro',
source: 'intro.md',
sourceDirName: '.',
sidebarPosition: 1,
frontMatter: {},
},
{
id: 'tutorial2',
source: 'tutorial2.md',
sourceDirName: '01-Tutorials',
sidebarPosition: 2,
frontMatter: {},
},
{
id: 'tutorial1',
source: 'tutorial1.md',
sourceDirName: '01-Tutorials',
sidebarPosition: 1,
frontMatter: {},
},
{
id: 'guide2',
source: 'guide2.md',
sourceDirName: '02-Guides',
sidebarPosition: 2,
frontMatter: {},
},
{
id: 'guide1',
source: 'guide1.md',
sourceDirName: '02-Guides',
sidebarPosition: 1,
frontMatter: {},
},
{
id: 'nested-guide',
source: 'nested-guide.md',
sourceDirName: '02-Guides/01-SubGuides',
sidebarPosition: undefined,
frontMatter: {},
},
{
id: 'end',
source: 'end.md',
sourceDirName: '.',
sidebarPosition: 3,
frontMatter: {},
},
],
options: {
sidebarCollapsed: true,
sidebarCollapsible: true,
},
});
expect(sidebarSlice).toEqual([
{type: 'doc', id: 'intro'},
{
type: 'category',
label: 'Tutorials',
collapsed: true,
collapsible: true,
items: [
{type: 'doc', id: 'tutorial1'},
{type: 'doc', id: 'tutorial2'},
],
},
{
type: 'category',
label: 'Guides',
collapsed: false,
collapsible: true,
items: [
{type: 'doc', id: 'guide1'},
{
type: 'category',
label: 'SubGuides (metadata file label)',
collapsed: true,
collapsible: true,
items: [{type: 'doc', id: 'nested-guide'}],
},
{type: 'doc', id: 'guide2'},
],
},
{type: 'doc', id: 'end'},
] as Sidebar);
});
test('generates subfolder sidebar', async () => {
// Ensure that category metadata file is correctly read
// fix edge case found in https://github.com/facebook/docusaurus/issues/4638
mockCategoryMetadataFiles({
'subfolder/subsubfolder/subsubsubfolder2/_category_.yml': {
position: 2,
label: 'subsubsubfolder2 (_category_.yml label)',
},
'subfolder/subsubfolder/subsubsubfolder3/_category_.json': {
position: 1,
label: 'subsubsubfolder3 (_category_.json label)',
collapsible: false,
collapsed: false,
},
});
const sidebarSlice = await DefaultSidebarItemsGenerator({
numberPrefixParser: DefaultNumberPrefixParser,
item: {
type: 'autogenerated',
dirName: 'subfolder/subsubfolder',
},
version: {
versionName: 'current',
contentPath: '',
},
docs: [
{
id: 'doc1',
source: 'doc1.md',
sourceDirName: 'subfolder/subsubfolder',
sidebarPosition: undefined,
frontMatter: {},
},
{
id: 'doc2',
source: 'doc2.md',
sourceDirName: 'subfolder',
sidebarPosition: undefined,
frontMatter: {},
},
{
id: 'doc3',
source: 'doc3.md',
sourceDirName: '.',
sidebarPosition: undefined,
frontMatter: {},
},
{
id: 'doc4',
source: 'doc4.md',
sourceDirName: 'subfolder/subsubfolder',
sidebarPosition: undefined,
frontMatter: {},
},
{
id: 'doc5',
source: 'doc5.md',
sourceDirName: 'subfolder/subsubfolder/subsubsubfolder',
sidebarPosition: undefined,
frontMatter: {},
},
{
id: 'doc6',
source: 'doc6.md',
sourceDirName: 'subfolder/subsubfolder/subsubsubfolder2',
sidebarPosition: undefined,
frontMatter: {},
},
{
id: 'doc7',
source: 'doc7.md',
sourceDirName: 'subfolder/subsubfolder/subsubsubfolder3',
sidebarPosition: 2,
frontMatter: {},
},
{
id: 'doc8',
source: 'doc8.md',
sourceDirName: 'subfolder/subsubfolder/subsubsubfolder3',
sidebarPosition: 1,
frontMatter: {},
},
],
options: {
sidebarCollapsed: true,
sidebarCollapsible: true,
},
});
expect(sidebarSlice).toEqual([
{
type: 'category',
label: 'subsubsubfolder3 (_category_.json label)',
collapsed: false,
collapsible: false,
items: [
{type: 'doc', id: 'doc8'},
{type: 'doc', id: 'doc7'},
],
},
{
type: 'category',
label: 'subsubsubfolder2 (_category_.yml label)',
collapsed: true,
collapsible: true,
items: [{type: 'doc', id: 'doc6'}],
},
{type: 'doc', id: 'doc1'},
{type: 'doc', id: 'doc4'},
{
type: 'category',
label: 'subsubsubfolder',
collapsed: true,
collapsible: true,
items: [{type: 'doc', id: 'doc5'}],
},
] as Sidebar);
});
});

View file

@ -0,0 +1,202 @@
/**
* 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 path from 'path';
import {
loadUnprocessedSidebars,
DefaultSidebars,
DisabledSidebars,
} from '../index';
import type {SidebarOptions} from '../../types';
describe('loadUnprocessedSidebars', () => {
const fixtureDir = path.join(__dirname, '__fixtures__', 'sidebars');
const options: SidebarOptions = {
sidebarCollapsed: true,
sidebarCollapsible: true,
};
test('sidebars with known sidebar item type', async () => {
const sidebarPath = path.join(fixtureDir, 'sidebars.json');
const result = loadUnprocessedSidebars(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);
expect(result).toMatchSnapshot();
});
test('sidebars shorthand and longform lead to exact same sidebar', async () => {
const sidebarPath1 = path.join(fixtureDir, 'sidebars-category.js');
const sidebarPath2 = path.join(
fixtureDir,
'sidebars-category-shorthand.js',
);
const sidebar1 = loadUnprocessedSidebars(sidebarPath1, options);
const sidebar2 = loadUnprocessedSidebars(sidebarPath2, options);
expect(sidebar1).toEqual(sidebar2);
});
test('sidebars with category but category.items is not an array', async () => {
const sidebarPath = path.join(
fixtureDir,
'sidebars-category-wrong-items.json',
);
expect(() => loadUnprocessedSidebars(sidebarPath, options))
.toThrowErrorMatchingInlineSnapshot(`
"{
\\"type\\": \\"category\\",
\\"label\\": \\"Category Label\\",
\\"items\\" [1]: \\"doc1\\"
}

[1] \\"items\\" must be an array"
`);
});
test('sidebars with category but category label is not a string', async () => {
const sidebarPath = path.join(
fixtureDir,
'sidebars-category-wrong-label.json',
);
expect(() => loadUnprocessedSidebars(sidebarPath, options))
.toThrowErrorMatchingInlineSnapshot(`
"{
\\"type\\": \\"category\\",
\\"items\\": [
\\"doc1\\"
],
\\"label\\" [1]: true
}

[1] \\"label\\" must be a string"
`);
});
test('sidebars item doc but id is not a string', async () => {
const sidebarPath = path.join(
fixtureDir,
'sidebars-doc-id-not-string.json',
);
expect(() => loadUnprocessedSidebars(sidebarPath, options))
.toThrowErrorMatchingInlineSnapshot(`
"{
\\"type\\": \\"doc\\",
\\"id\\" [1]: [
\\"doc1\\"
]
}

[1] \\"id\\" must be a string"
`);
});
test('sidebars with first level not a category', async () => {
const sidebarPath = path.join(
fixtureDir,
'sidebars-first-level-not-category.js',
);
const result = loadUnprocessedSidebars(sidebarPath, options);
expect(result).toMatchSnapshot();
});
test('sidebars link', async () => {
const sidebarPath = path.join(fixtureDir, 'sidebars-link.json');
const result = loadUnprocessedSidebars(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))
.toThrowErrorMatchingInlineSnapshot(`
"{
\\"type\\": \\"link\\",
\\"href\\": \\"https://github.com\\",
\\"label\\" [1]: false
}

[1] \\"label\\" must be a string"
`);
});
test('sidebars link wrong href', async () => {
const sidebarPath = path.join(fixtureDir, 'sidebars-link-wrong-href.json');
expect(() => loadUnprocessedSidebars(sidebarPath, options))
.toThrowErrorMatchingInlineSnapshot(`
"{
\\"type\\": \\"link\\",
\\"label\\": \\"GitHub\\",
\\"href\\" [1]: [
\\"example.com\\"
]
}

[1] \\"href\\" contains an invalid value"
`);
});
test('sidebars with unknown sidebar item type', async () => {
const sidebarPath = path.join(fixtureDir, 'sidebars-unknown-type.json');
expect(() => loadUnprocessedSidebars(sidebarPath, options))
.toThrowErrorMatchingInlineSnapshot(`
"{
\\"type\\": \\"superman\\",
\\"undefined\\" [1]: -- missing --
}

[1] Unknown sidebar item type \\"superman\\"."
`);
});
test('sidebars with known sidebar item type but wrong field', async () => {
const sidebarPath = path.join(fixtureDir, 'sidebars-wrong-field.json');
expect(() => loadUnprocessedSidebars(sidebarPath, options))
.toThrowErrorMatchingInlineSnapshot(`
"{
\\"type\\": \\"category\\",
\\"label\\": \\"category\\",
\\"href\\": \\"https://github.com\\",
\\"items\\" [1]: -- missing --
}

[1] \\"items\\" is required"
`);
});
test('unexisting path', () => {
expect(loadUnprocessedSidebars('badpath', options)).toEqual(
DisabledSidebars,
);
});
test('undefined path', () => {
expect(loadUnprocessedSidebars(undefined, options)).toEqual(
DefaultSidebars,
);
});
test('literal false path', () => {
expect(loadUnprocessedSidebars(false, options)).toEqual(DisabledSidebars);
});
test('sidebars with category.collapsed property', async () => {
const sidebarPath = path.join(fixtureDir, 'sidebars-collapsed.json');
const result = loadUnprocessedSidebars(sidebarPath, options);
expect(result).toMatchSnapshot();
});
test('sidebars with category.collapsed property at first level', async () => {
const sidebarPath = path.join(
fixtureDir,
'sidebars-collapsed-first-level.json',
);
const result = loadUnprocessedSidebars(sidebarPath, options);
expect(result).toMatchSnapshot();
});
});

View file

@ -0,0 +1,148 @@
/**
* 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 {processSidebars} from '../processor';
import type {
SidebarItem,
SidebarItemsGenerator,
Sidebars,
NormalizedSidebars,
} from '../types';
import {DefaultSidebarItemsGenerator} from '../generator';
describe('processSidebars', () => {
const StaticGeneratedSidebarSlice: SidebarItem[] = [
{type: 'doc', id: 'doc-generated-id-1'},
{type: 'doc', id: 'doc-generated-id-2'},
];
const StaticSidebarItemsGenerator: SidebarItemsGenerator = jest.fn(
async () => {
return StaticGeneratedSidebarSlice;
},
);
async function testProcessSidebars(unprocessedSidebars: NormalizedSidebars) {
return processSidebars(unprocessedSidebars, {
sidebarItemsGenerator: StaticSidebarItemsGenerator,
docs: [],
// @ts-expect-error: useless for this test
version: {},
});
}
test('let sidebars without autogenerated items untouched', async () => {
const unprocessedSidebars: NormalizedSidebars = {
someSidebar: [
{type: 'doc', id: 'doc1'},
{
type: 'category',
collapsed: false,
collapsible: true,
items: [{type: 'doc', id: 'doc2'}],
label: 'Category',
},
{type: 'link', href: 'https://facebook.com', label: 'FB'},
],
secondSidebar: [
{type: 'doc', id: 'doc3'},
{type: 'link', href: 'https://instagram.com', label: 'IG'},
{
type: 'category',
collapsed: false,
collapsible: true,
items: [{type: 'doc', id: 'doc4'}],
label: 'Category',
},
],
};
const processedSidebar = await testProcessSidebars(unprocessedSidebars);
expect(processedSidebar).toEqual(unprocessedSidebars);
});
test('replace autogenerated items by generated sidebars slices', async () => {
const unprocessedSidebars: NormalizedSidebars = {
someSidebar: [
{type: 'doc', id: 'doc1'},
{
type: 'category',
collapsed: false,
collapsible: true,
items: [
{type: 'doc', id: 'doc2'},
{type: 'autogenerated', dirName: 'dir1'},
],
label: 'Category',
},
{type: 'link', href: 'https://facebook.com', label: 'FB'},
],
secondSidebar: [
{type: 'doc', id: 'doc3'},
{type: 'autogenerated', dirName: 'dir2'},
{type: 'link', href: 'https://instagram.com', label: 'IG'},
{type: 'autogenerated', dirName: 'dir3'},
{
type: 'category',
collapsed: false,
collapsible: true,
items: [{type: 'doc', id: 'doc4'}],
label: 'Category',
},
],
};
const processedSidebar = await testProcessSidebars(unprocessedSidebars);
expect(StaticSidebarItemsGenerator).toHaveBeenCalledTimes(3);
expect(StaticSidebarItemsGenerator).toHaveBeenCalledWith({
defaultSidebarItemsGenerator: DefaultSidebarItemsGenerator,
item: {type: 'autogenerated', dirName: 'dir1'},
docs: [],
version: {},
});
expect(StaticSidebarItemsGenerator).toHaveBeenCalledWith({
defaultSidebarItemsGenerator: DefaultSidebarItemsGenerator,
item: {type: 'autogenerated', dirName: 'dir2'},
docs: [],
version: {},
});
expect(StaticSidebarItemsGenerator).toHaveBeenCalledWith({
defaultSidebarItemsGenerator: DefaultSidebarItemsGenerator,
item: {type: 'autogenerated', dirName: 'dir3'},
docs: [],
version: {},
});
expect(processedSidebar).toEqual({
someSidebar: [
{type: 'doc', id: 'doc1'},
{
type: 'category',
collapsed: false,
collapsible: true,
items: [{type: 'doc', id: 'doc2'}, ...StaticGeneratedSidebarSlice],
label: 'Category',
},
{type: 'link', href: 'https://facebook.com', label: 'FB'},
],
secondSidebar: [
{type: 'doc', id: 'doc3'},
...StaticGeneratedSidebarSlice,
{type: 'link', href: 'https://instagram.com', label: 'IG'},
...StaticGeneratedSidebarSlice,
{
type: 'category',
collapsed: false,
collapsible: true,
items: [{type: 'doc', id: 'doc4'}],
label: 'Category',
},
],
} as Sidebars);
});
});

View file

@ -0,0 +1,395 @@
/**
* 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 {
createSidebarsUtils,
collectSidebarDocItems,
collectSidebarCategories,
collectSidebarLinks,
transformSidebarItems,
collectSidebarsDocIds,
} from '../utils';
import type {Sidebar, Sidebars} from '../types';
describe('createSidebarsUtils', () => {
const sidebar1: Sidebar = [
{
type: 'category',
collapsed: false,
collapsible: true,
label: 'Category1',
items: [
{
type: 'category',
collapsed: false,
collapsible: true,
label: 'Subcategory 1',
items: [{type: 'doc', id: 'doc1'}],
},
{type: 'doc', id: 'doc2'},
],
},
];
const sidebar2: Sidebar = [
{
type: 'category',
collapsed: false,
collapsible: true,
label: 'Category2',
items: [
{type: 'doc', id: 'doc3'},
{type: 'doc', id: 'doc4'},
],
},
];
const sidebars: Sidebars = {sidebar1, sidebar2};
const {getFirstDocIdOfFirstSidebar, getSidebarNameByDocId, getDocNavigation} =
createSidebarsUtils(sidebars);
test('getSidebarNameByDocId', async () => {
expect(getFirstDocIdOfFirstSidebar()).toEqual('doc1');
});
test('getSidebarNameByDocId', async () => {
expect(getSidebarNameByDocId('doc1')).toEqual('sidebar1');
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);
});
test('getDocNavigation', async () => {
expect(getDocNavigation('doc1')).toEqual({
sidebarName: 'sidebar1',
previousId: undefined,
nextId: 'doc2',
});
expect(getDocNavigation('doc2')).toEqual({
sidebarName: 'sidebar1',
previousId: 'doc1',
nextId: undefined,
});
expect(getDocNavigation('doc3')).toEqual({
sidebarName: 'sidebar2',
previousId: undefined,
nextId: 'doc4',
});
expect(getDocNavigation('doc4')).toEqual({
sidebarName: 'sidebar2',
previousId: 'doc3',
nextId: undefined,
});
});
});
describe('collectSidebarDocItems', () => {
test('can collect docs', async () => {
const sidebar: Sidebar = [
{
type: 'category',
collapsed: false,
collapsible: true,
label: 'Category1',
items: [
{
type: 'category',
collapsed: false,
collapsible: true,
label: 'Subcategory 1',
items: [{type: 'doc', id: 'doc1'}],
},
{
type: 'category',
collapsed: false,
collapsible: true,
label: 'Subcategory 2',
items: [
{type: 'doc', id: 'doc2'},
{
type: 'category',
collapsed: false,
collapsible: true,
label: 'Sub sub category 1',
items: [{type: 'doc', id: 'doc3'}],
},
],
},
],
},
{
type: 'category',
collapsed: false,
collapsible: true,
label: 'Category2',
items: [
{type: 'doc', id: 'doc4'},
{type: 'doc', id: 'doc5'},
],
},
];
expect(collectSidebarDocItems(sidebar).map((doc) => doc.id)).toEqual([
'doc1',
'doc2',
'doc3',
'doc4',
'doc5',
]);
});
});
describe('collectSidebarCategories', () => {
test('can collect categories', async () => {
const sidebar: Sidebar = [
{
type: 'category',
collapsed: false,
collapsible: true,
label: 'Category1',
items: [
{
type: 'category',
collapsed: false,
collapsible: true,
label: 'Subcategory 1',
items: [{type: 'doc', id: 'doc1'}],
},
{
type: 'category',
collapsed: false,
collapsible: true,
label: 'Subcategory 2',
items: [
{type: 'doc', id: 'doc2'},
{
type: 'category',
collapsed: false,
collapsible: true,
label: 'Sub sub category 1',
items: [{type: 'doc', id: 'doc3'}],
},
],
},
],
},
{
type: 'category',
collapsed: false,
collapsible: true,
label: 'Category2',
items: [
{type: 'doc', id: 'doc4'},
{type: 'doc', id: 'doc5'},
],
},
];
expect(
collectSidebarCategories(sidebar).map((category) => category.label),
).toEqual([
'Category1',
'Subcategory 1',
'Subcategory 2',
'Sub sub category 1',
'Category2',
]);
});
});
describe('collectSidebarLinks', () => {
test('can collect links', async () => {
const sidebar: Sidebar = [
{
type: 'category',
collapsed: false,
collapsible: true,
label: 'Category1',
items: [
{
type: 'link',
href: 'https://google.com',
label: 'Google',
},
{
type: 'category',
collapsed: false,
collapsible: true,
label: 'Subcategory 2',
items: [
{
type: 'link',
href: 'https://facebook.com',
label: 'Facebook',
},
],
},
],
},
];
expect(collectSidebarLinks(sidebar).map((link) => link.href)).toEqual([
'https://google.com',
'https://facebook.com',
]);
});
});
describe('collectSidebarsDocIds', () => {
test('can collect sidebars doc items', async () => {
const sidebar1: Sidebar = [
{
type: 'category',
collapsed: false,
collapsible: true,
label: 'Category1',
items: [
{
type: 'category',
collapsed: false,
collapsible: true,
label: 'Subcategory 1',
items: [{type: 'doc', id: 'doc1'}],
},
{type: 'doc', id: 'doc2'},
],
},
];
const sidebar2: Sidebar = [
{
type: 'category',
collapsed: false,
collapsible: true,
label: 'Category2',
items: [
{type: 'doc', id: 'doc3'},
{type: 'doc', id: 'doc4'},
],
},
];
const sidebar3: Sidebar = [
{type: 'doc', id: 'doc5'},
{type: 'doc', id: 'doc6'},
];
expect(collectSidebarsDocIds({sidebar1, sidebar2, sidebar3})).toEqual({
sidebar1: ['doc1', 'doc2'],
sidebar2: ['doc3', 'doc4'],
sidebar3: ['doc5', 'doc6'],
});
});
});
describe('transformSidebarItems', () => {
test('can transform sidebar items', async () => {
const sidebar: Sidebar = [
{
type: 'category',
collapsed: false,
collapsible: true,
label: 'Category1',
items: [
{
type: 'category',
collapsed: false,
collapsible: true,
label: 'Subcategory 1',
items: [{type: 'doc', id: 'doc1'}],
customProps: {fakeProp: false},
},
{
type: 'category',
collapsed: false,
collapsible: true,
label: 'Subcategory 2',
items: [
{type: 'doc', id: 'doc2'},
{
type: 'category',
collapsed: false,
collapsible: true,
label: 'Sub sub category 1',
items: [
{type: 'doc', id: 'doc3', customProps: {lorem: 'ipsum'}},
],
},
],
},
],
},
{
type: 'category',
collapsed: false,
collapsible: true,
label: 'Category2',
items: [
{type: 'doc', id: 'doc4'},
{type: 'doc', id: 'doc5'},
],
},
];
expect(
transformSidebarItems(sidebar, (item) => {
if (item.type === 'category') {
return {...item, label: `MODIFIED LABEL: ${item.label}`};
}
return item;
}),
).toEqual([
{
type: 'category',
collapsed: false,
collapsible: true,
label: 'MODIFIED LABEL: Category1',
items: [
{
type: 'category',
collapsed: false,
collapsible: true,
label: 'MODIFIED LABEL: Subcategory 1',
items: [{type: 'doc', id: 'doc1'}],
customProps: {fakeProp: false},
},
{
type: 'category',
collapsed: false,
collapsible: true,
label: 'MODIFIED LABEL: Subcategory 2',
items: [
{type: 'doc', id: 'doc2'},
{
type: 'category',
collapsed: false,
collapsible: true,
label: 'MODIFIED LABEL: Sub sub category 1',
items: [
{type: 'doc', id: 'doc3', customProps: {lorem: 'ipsum'}},
],
},
],
},
],
},
{
type: 'category',
collapsed: false,
collapsible: true,
label: 'MODIFIED LABEL: Category2',
items: [
{type: 'doc', id: 'doc4'},
{type: 'doc', id: 'doc5'},
],
},
]);
});
});

View file

@ -0,0 +1,253 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import type {
SidebarItem,
SidebarItemDoc,
SidebarItemCategory,
SidebarItemsGenerator,
SidebarItemsGeneratorDoc,
} from './types';
import {keyBy, sortBy} 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';
const BreadcrumbSeparator = '/';
// To avoid possible name clashes with a folder of the same name as the ID
const docIdPrefix = '$doc$/';
export const CategoryMetadataFilenameBase = '_category_';
export const CategoryMetadataFilenamePattern = '_category_.{json,yml,yaml}';
export type CategoryMetadatasFile = {
label?: string;
position?: number;
collapsed?: boolean;
collapsible?: boolean;
className?: string;
// 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/
// cf comment: https://github.com/facebook/docusaurus/issues/3464#issuecomment-784765199
};
type WithPosition<T> = T & {position?: number};
/**
* A representation of the fs structure. For each object entry:
* If it's a folder, the key is the directory name, and value is the directory content;
* If it's a doc file, the key is the doc id prefixed with '$doc$/', and value is null
*/
type Dir = {
[item: string]: Dir | null;
};
const CategoryMetadatasFileSchema = Joi.object<CategoryMetadatasFile>({
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
// see https://github.com/facebook/docusaurus/issues/3464#issuecomment-818670449
async function readCategoryMetadatasFile(
categoryDirPath: string,
): Promise<CategoryMetadatasFile | null> {
async function tryReadFile(
fileNameWithExtension: string,
parse: (content: string) => unknown,
): Promise<CategoryMetadatasFile | null> {
// Simpler to use only posix paths for mocking file metadatas in tests
const filePath = posixPath(
path.join(categoryDirPath, fileNameWithExtension),
);
if (await fs.pathExists(filePath)) {
const contentString = await fs.readFile(filePath, {encoding: 'utf8'});
const unsafeContent = parse(contentString);
try {
return Joi.attempt(unsafeContent, CategoryMetadatasFileSchema);
} catch (e) {
console.error(
chalk.red(
`The docs sidebar category metadata file looks invalid!\nPath: ${filePath}`,
),
);
throw e;
}
}
return null;
}
return (
(await tryReadFile(`${CategoryMetadataFilenameBase}.json`, JSON.parse)) ??
(await tryReadFile(`${CategoryMetadataFilenameBase}.yml`, Yaml.load)) ??
// eslint-disable-next-line no-return-await
(await tryReadFile(`${CategoryMetadataFilenameBase}.yaml`, Yaml.load))
);
}
// Comment for this feature: https://github.com/facebook/docusaurus/issues/3464#issuecomment-818670449
export const DefaultSidebarItemsGenerator: SidebarItemsGenerator = async ({
numberPrefixParser,
docs: allDocs,
options,
item: {dirName: autogenDir},
version,
}) => {
/**
* Step 1. Extract the docs that are in the autogen dir.
*/
function getAutogenDocs(): SidebarItemsGeneratorDoc[] {
function isInAutogeneratedDir(doc: SidebarItemsGeneratorDoc) {
return (
// Doc at the root of the autogenerated sidebar dir
doc.sourceDirName === autogenDir ||
// autogen dir is . and doc is in subfolder
autogenDir === '.' ||
// autogen dir is not . and doc is in subfolder
// "api/myDoc" startsWith "api/" (note "api2/myDoc" is not included)
doc.sourceDirName.startsWith(addTrailingSlash(autogenDir))
);
}
const docs = allDocs.filter(isInAutogeneratedDir);
if (docs.length === 0) {
console.warn(
chalk.yellow(
`No docs found in dir ${autogenDir}: can't auto-generate a sidebar.`,
),
);
}
return docs;
}
/**
* Step 2. Turn the linear file list into a tree structure.
*/
function treeify(docs: SidebarItemsGeneratorDoc[]): Dir {
// Get the category breadcrumb of a doc (relative to the dir of the autogenerated sidebar item)
// autogenDir=a/b and docDir=a/b/c/d => returns [c, d]
// autogenDir=a/b and docDir=a/b => returns []
// TODO: try to use path.relative()
function getRelativeBreadcrumb(doc: SidebarItemsGeneratorDoc): string[] {
return autogenDir === doc.sourceDirName
? []
: doc.sourceDirName
.replace(addTrailingSlash(autogenDir), '')
.split(BreadcrumbSeparator);
}
const treeRoot: Dir = {};
docs.forEach((doc) => {
const breadcrumb = getRelativeBreadcrumb(doc);
let currentDir = treeRoot; // We walk down the file's path to generate the fs structure
// eslint-disable-next-line no-restricted-syntax
for (const dir of breadcrumb) {
if (typeof currentDir[dir] === 'undefined') {
currentDir[dir] = {}; // Create new folder.
}
currentDir = currentDir[dir]!; // Go into the subdirectory.
}
currentDir[`${docIdPrefix}${doc.id}`] = null; // We've walked through the file path. Register the file in this directory.
});
return treeRoot;
}
/**
* Step 3. Recursively transform the tree-like file structure to sidebar items.
* (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];
return {
type: 'doc',
id,
position,
// We don't want these fields to magically appear in the generated sidebar
...(label !== undefined && {label}),
...(className !== undefined && {className}),
};
}
async function createCategoryItem(
dir: Dir,
fullPath: string,
folderName: string,
): Promise<WithPosition<SidebarItemCategory>> {
const categoryPath = path.join(version.contentPath, autogenDir, fullPath);
const categoryMetadatas = await readCategoryMetadatasFile(categoryPath);
const className = categoryMetadatas?.className;
const {filename, numberPrefix} = numberPrefixParser(folderName);
return {
type: 'category',
label: categoryMetadatas?.label ?? filename,
collapsible:
categoryMetadatas?.collapsible ?? options.sidebarCollapsible,
collapsed: categoryMetadatas?.collapsed ?? options.sidebarCollapsed,
position: categoryMetadatas?.position ?? numberPrefix,
...(className !== undefined && {className}),
items: await Promise.all(
Object.entries(dir).map(([key, content]) =>
dirToItem(content, key, `${fullPath}/${key}`),
),
),
};
}
async function dirToItem(
dir: Dir | null, // The directory item to be transformed.
itemKey: string, // For docs, it's the doc ID; for categories, it's used to generate the next `relativePath`.
fullPath: string, // `dir`'s full path relative to the autogen dir.
): Promise<WithPosition<SidebarItem>> {
return dir
? createCategoryItem(dir, fullPath, itemKey)
: createDocItem(itemKey.substring(docIdPrefix.length));
}
return Promise.all(
Object.entries(fsModel).map(([key, content]) =>
dirToItem(content, key, key),
),
);
}
/**
* Step 4. Recursively sort the categories/docs + remove the "position" attribute from final output.
* Note: the "position" is only used to sort "inside" a sidebar slice. It is not
* used to sort across multiple consecutive sidebar slices (ie a whole Category
* composed of multiple autogenerated items)
*/
function sortItems(sidebarItems: WithPosition<SidebarItem>[]): SidebarItem[] {
const processedSidebarItems = sidebarItems.map((item) => {
if (item.type === 'category') {
return {...item, items: sortItems(item.items)};
}
return item;
});
const sortedSidebarItems = sortBy(
processedSidebarItems,
(item) => item.position,
);
return sortedSidebarItems.map(({position, ...item}) => item);
}
// TODO: the whole code is designed for pipeline operator
// return getAutogenDocs() |> treeify |> await generateSidebar(^) |> sortItems;
const docs = getAutogenDocs();
const fsModel = treeify(docs);
const sidebarWithPosition = await generateSidebar(fsModel);
const sortedSidebar = sortItems(sidebarWithPosition);
return sortedSidebar;
};

View file

@ -0,0 +1,84 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import fs from 'fs-extra';
import importFresh from 'import-fresh';
import type {SidebarsConfig, Sidebars, NormalizedSidebars} from './types';
import type {PluginOptions} from '../types';
import {validateSidebars} from './validation';
import {normalizeSidebars} from './normalization';
import {processSidebars, SidebarProcessorProps} from './processor';
import path from 'path';
export const DefaultSidebars: SidebarsConfig = {
defaultSidebar: [
{
type: 'autogenerated',
dirName: '.',
},
],
};
export const DisabledSidebars: SidebarsConfig = {};
// If a path is provided, make it absolute
// use this before loadSidebars()
export function resolveSidebarPathOption(
siteDir: string,
sidebarPathOption: PluginOptions['sidebarPath'],
): PluginOptions['sidebarPath'] {
return sidebarPathOption
? path.resolve(siteDir, sidebarPathOption)
: sidebarPathOption;
}
function loadSidebarFile(
sidebarFilePath: string | false | undefined,
): SidebarsConfig {
// false => no sidebars
if (sidebarFilePath === false) {
return DisabledSidebars;
}
// undefined => defaults to autogenerated sidebars
if (typeof sidebarFilePath === 'undefined') {
return DefaultSidebars;
}
// Non-existent sidebars file: no sidebars
// Note: this edge case can happen on versioned docs, not current version
// We avoid creating empty versioned sidebars file with the CLI
if (!fs.existsSync(sidebarFilePath)) {
return DisabledSidebars;
}
// We don't want sidebars to be cached because of hot reloading.
return importFresh(sidebarFilePath);
}
export function loadUnprocessedSidebars(
sidebarFilePath: string | false | undefined,
options: SidebarProcessorProps['options'],
): NormalizedSidebars {
const sidebarsConfig = loadSidebarFile(sidebarFilePath);
validateSidebars(sidebarsConfig);
const normalizedSidebars = normalizeSidebars(sidebarsConfig, options);
return normalizedSidebars;
}
// Note: sidebarFilePath must be absolute, use resolveSidebarPathOption
export async function loadSidebars(
sidebarFilePath: string | false | undefined,
options: SidebarProcessorProps,
): Promise<Sidebars> {
const unprocessedSidebars = loadUnprocessedSidebars(
sidebarFilePath,
options.options,
);
return processSidebars(unprocessedSidebars, options);
}

View file

@ -0,0 +1,88 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import type {SidebarOptions} from '../types';
import {
NormalizedSidebarItem,
NormalizedSidebar,
NormalizedSidebars,
SidebarCategoriesShorthand,
SidebarItemCategoryConfig,
SidebarItemConfig,
SidebarConfig,
SidebarsConfig,
isCategoriesShorthand,
} from './types';
import {mapValues} from 'lodash';
function normalizeCategoriesShorthand(
sidebar: SidebarCategoriesShorthand,
options: SidebarOptions,
): SidebarItemCategoryConfig[] {
return Object.entries(sidebar).map(([label, items]) => ({
type: 'category',
collapsed: options.sidebarCollapsed,
collapsible: options.sidebarCollapsible,
label,
items,
}));
}
/**
* Normalizes recursively item and all its children. Ensures that at the end
* each item will be an object with the corresponding type.
*/
function normalizeItem(
item: SidebarItemConfig,
options: SidebarOptions,
): NormalizedSidebarItem[] {
if (typeof item === 'string') {
return [
{
type: 'doc',
id: item,
},
];
}
if (isCategoriesShorthand(item)) {
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];
}
function normalizeSidebar(
sidebar: SidebarConfig,
options: SidebarOptions,
): NormalizedSidebar {
const normalizedSidebar = Array.isArray(sidebar)
? sidebar
: normalizeCategoriesShorthand(sidebar, options);
return normalizedSidebar.flatMap((subitem) =>
normalizeItem(subitem, options),
);
}
export function normalizeSidebars(
sidebars: SidebarsConfig,
options: SidebarOptions,
): NormalizedSidebars {
return mapValues(sidebars, (subitem) => normalizeSidebar(subitem, options));
}

View file

@ -0,0 +1,124 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import type {
NumberPrefixParser,
DocMetadataBase,
VersionMetadata,
SidebarOptions,
} from '../types';
import type {
Sidebars,
Sidebar,
SidebarItem,
NormalizedSidebarItem,
NormalizedSidebar,
NormalizedSidebars,
SidebarItemsGeneratorOption,
SidebarItemsGeneratorDoc,
SidebarItemsGeneratorVersion,
} from './types';
import {transformSidebarItems} from './utils';
import {DefaultSidebarItemsGenerator} from './generator';
import {mapValues, memoize, pick} from 'lodash';
import combinePromises from 'combine-promises';
export type SidebarProcessorProps = {
sidebarItemsGenerator: SidebarItemsGeneratorOption;
numberPrefixParser: NumberPrefixParser;
docs: DocMetadataBase[];
version: VersionMetadata;
options: SidebarOptions;
};
function toSidebarItemsGeneratorDoc(
doc: DocMetadataBase,
): SidebarItemsGeneratorDoc {
return pick(doc, [
'id',
'frontMatter',
'source',
'sourceDirName',
'sidebarPosition',
]);
}
function toSidebarItemsGeneratorVersion(
version: VersionMetadata,
): SidebarItemsGeneratorVersion {
return pick(version, ['versionName', 'contentPath']);
}
// Handle the generation of autogenerated sidebar items and other post-processing checks
async function processSidebar(
unprocessedSidebar: NormalizedSidebar,
{
sidebarItemsGenerator,
numberPrefixParser,
docs,
version,
options,
}: SidebarProcessorProps,
): Promise<Sidebar> {
// Just a minor lazy transformation optimization
const getSidebarItemsGeneratorDocsAndVersion = memoize(() => ({
docs: docs.map(toSidebarItemsGeneratorDoc),
version: toSidebarItemsGeneratorVersion(version),
}));
async function handleAutoGeneratedItems(
item: NormalizedSidebarItem,
): Promise<SidebarItem[]> {
if (item.type === 'category') {
return [
{
...item,
items: (
await Promise.all(item.items.map(handleAutoGeneratedItems))
).flat(),
},
];
}
if (item.type === 'autogenerated') {
return sidebarItemsGenerator({
item,
numberPrefixParser,
defaultSidebarItemsGenerator: DefaultSidebarItemsGenerator,
...getSidebarItemsGeneratorDocsAndVersion(),
options,
});
}
return [item];
}
const processedSidebar = (
await Promise.all(unprocessedSidebar.map(handleAutoGeneratedItems))
).flat();
const fixSidebarItemInconsistencies = (item: SidebarItem): SidebarItem => {
// A non-collapsible category can't be collapsed!
if (item.type === 'category' && !item.collapsible && item.collapsed) {
return {
...item,
collapsed: false,
};
}
return item;
};
return transformSidebarItems(processedSidebar, fixSidebarItemInconsistencies);
}
export async function processSidebars(
unprocessedSidebars: NormalizedSidebars,
props: SidebarProcessorProps,
): Promise<Sidebars> {
return combinePromises(
mapValues(unprocessedSidebars, (unprocessedSidebar) =>
processSidebar(unprocessedSidebar, props),
),
);
}

View file

@ -0,0 +1,156 @@
/**
* 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 {Optional} from 'utility-types';
import type {
DocMetadataBase,
VersionMetadata,
NumberPrefixParser,
SidebarOptions,
} from '../types';
// Makes all properties visible when hovering over the type
type Expand<T extends Record<string, unknown>> = {[P in keyof T]: T[P]};
export type SidebarItemBase = {
className?: string;
customProps?: Record<string, unknown>;
};
export type SidebarItemDoc = SidebarItemBase & {
type: 'doc' | 'ref';
label?: string;
id: string;
};
export type SidebarItemLink = SidebarItemBase & {
type: 'link';
href: string;
label: string;
};
export type SidebarItemAutogenerated = SidebarItemBase & {
type: 'autogenerated';
dirName: string;
};
type SidebarItemCategoryBase = SidebarItemBase & {
type: 'category';
label: string;
collapsed: boolean;
collapsible: boolean;
};
// The user-given configuration in sidebars.js, before normalization
export type SidebarItemCategoryConfig = Expand<
Optional<SidebarItemCategoryBase, 'collapsed' | 'collapsible'> & {
items: SidebarItemConfig[];
}
>;
export type SidebarCategoriesShorthand = {
[sidebarCategory: string]: SidebarItemConfig[];
};
export function isCategoriesShorthand(
item: SidebarItemConfig,
): item is SidebarCategoriesShorthand {
return typeof item !== 'string' && !item.type;
}
export type SidebarItemConfig =
| SidebarItemDoc
| SidebarItemLink
| SidebarItemAutogenerated
| SidebarItemCategoryConfig
| string
| SidebarCategoriesShorthand;
export type SidebarConfig = SidebarCategoriesShorthand | SidebarItemConfig[];
export type SidebarsConfig = {
[sidebarId: string]: SidebarConfig;
};
// Normalized but still has 'autogenerated', which will be handled in processing
export type NormalizedSidebarItemCategory = Expand<
SidebarItemCategoryBase & {
items: NormalizedSidebarItem[];
}
>;
export type NormalizedSidebarItem =
| SidebarItemDoc
| SidebarItemLink
| NormalizedSidebarItemCategory
| SidebarItemAutogenerated;
export type NormalizedSidebar = NormalizedSidebarItem[];
export type NormalizedSidebars = {
[sidebarId: string]: NormalizedSidebar;
};
export type SidebarItemCategory = Expand<
SidebarItemCategoryBase & {
items: SidebarItem[];
}
>;
export type SidebarItem =
| SidebarItemDoc
| SidebarItemLink
| SidebarItemCategory;
export type Sidebar = SidebarItem[];
export type SidebarItemType = SidebarItem['type'];
export type Sidebars = {
[sidebarId: string]: Sidebar;
};
// Doc links have been resolved to URLs, ready to be passed to the theme
export type PropSidebarItemCategory = Expand<
SidebarItemCategoryBase & {
items: PropSidebarItem[];
}
>;
export type PropSidebarItem = SidebarItemLink | PropSidebarItemCategory;
export type PropSidebar = PropSidebarItem[];
export type PropSidebars = {
[sidebarId: string]: PropSidebar;
};
// Reduce API surface for options.sidebarItemsGenerator
// The user-provided generator fn should receive only a subset of metadatas
// A change to any of these metadatas can be considered as a breaking change
export type SidebarItemsGeneratorDoc = Pick<
DocMetadataBase,
'id' | 'frontMatter' | 'source' | 'sourceDirName' | 'sidebarPosition'
>;
export type SidebarItemsGeneratorVersion = Pick<
VersionMetadata,
'versionName' | 'contentPath'
>;
export type SidebarItemsGeneratorArgs = {
item: SidebarItemAutogenerated;
version: SidebarItemsGeneratorVersion;
docs: SidebarItemsGeneratorDoc[];
numberPrefixParser: NumberPrefixParser;
options: SidebarOptions;
};
export type SidebarItemsGenerator = (
generatorArgs: SidebarItemsGeneratorArgs,
) => Promise<SidebarItem[]>;
// 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
export type SidebarItemsGeneratorOptionArgs = {
defaultSidebarItemsGenerator: SidebarItemsGenerator;
} & SidebarItemsGeneratorArgs;
export type SidebarItemsGeneratorOption = (
generatorArgs: SidebarItemsGeneratorOptionArgs,
) => Promise<SidebarItem[]>;

View file

@ -0,0 +1,146 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import type {
Sidebars,
Sidebar,
SidebarItem,
SidebarItemCategory,
SidebarItemLink,
SidebarItemDoc,
SidebarItemType,
} from './types';
import {mapValues, difference} from 'lodash';
import {getElementsAround, toMessageRelativeFilePath} from '@docusaurus/utils';
export function transformSidebarItems(
sidebar: Sidebar,
updateFn: (item: SidebarItem) => SidebarItem,
): Sidebar {
function transformRecursive(item: SidebarItem): SidebarItem {
if (item.type === 'category') {
return updateFn({
...item,
items: item.items.map(transformRecursive),
});
}
return updateFn(item);
}
return sidebar.map(transformRecursive);
}
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);
}
export function collectSidebarDocItems(sidebar: Sidebar): SidebarItemDoc[] {
return collectSidebarItemsOfType('doc', sidebar);
}
export function collectSidebarCategories(
sidebar: Sidebar,
): SidebarItemCategory[] {
return collectSidebarItemsOfType('category', sidebar);
}
export function collectSidebarLinks(sidebar: Sidebar): SidebarItemLink[] {
return collectSidebarItemsOfType('link', sidebar);
}
export function collectSidebarsDocIds(
sidebars: Sidebars,
): Record<string, string[]> {
return mapValues(sidebars, (sidebar) => {
return collectSidebarDocItems(sidebar).map((docItem) => docItem.id);
});
}
export function createSidebarsUtils(sidebars: Sidebars): {
getFirstDocIdOfFirstSidebar: () => string | undefined;
getSidebarNameByDocId: (docId: string) => string | undefined;
getDocNavigation: (docId: string) => {
sidebarName: string | undefined;
previousId: string | undefined;
nextId: string | undefined;
};
checkSidebarsDocIds: (validDocIds: string[], sidebarFilePath: string) => void;
} {
const sidebarNameToDocIds = collectSidebarsDocIds(sidebars);
// Reverse mapping
const docIdToSidebarName = Object.fromEntries(
Object.entries(sidebarNameToDocIds).flatMap(([sidebarName, docIds]) =>
docIds.map((docId) => [docId, sidebarName]),
),
);
function getFirstDocIdOfFirstSidebar(): string | undefined {
return Object.values(sidebarNameToDocIds)[0]?.[0];
}
function getSidebarNameByDocId(docId: string): string | undefined {
return docIdToSidebarName[docId];
}
function getDocNavigation(docId: string): {
sidebarName: string | undefined;
previousId: string | undefined;
nextId: string | undefined;
} {
const 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,
};
} else {
return {
sidebarName: undefined,
previousId: undefined,
nextId: undefined,
};
}
}
function checkSidebarsDocIds(validDocIds: string[], sidebarFilePath: string) {
const allSidebarDocIds = Object.values(sidebarNameToDocIds).flat();
const invalidSidebarDocIds = difference(allSidebarDocIds, validDocIds);
if (invalidSidebarDocIds.length > 0) {
throw new Error(
`Invalid sidebar file at "${toMessageRelativeFilePath(
sidebarFilePath,
)}".
These sidebar document ids do not exist:
- ${invalidSidebarDocIds.sort().join('\n- ')}
Available document ids are:
- ${validDocIds.sort().join('\n- ')}`,
);
}
}
return {
getFirstDocIdOfFirstSidebar,
getSidebarNameByDocId,
getDocNavigation,
checkSidebarsDocIds,
};
}

View file

@ -0,0 +1,124 @@
/**
* 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 {Joi, URISchema} from '@docusaurus/utils-validation';
import {
SidebarItemConfig,
SidebarCategoriesShorthand,
SidebarItemBase,
SidebarItemAutogenerated,
SidebarItemDoc,
SidebarItemLink,
SidebarItemCategoryConfig,
SidebarsConfig,
isCategoriesShorthand,
} from './types';
const sidebarItemBaseSchema = Joi.object<SidebarItemBase>({
className: Joi.string(),
customProps: Joi.object().unknown(),
});
const sidebarItemAutogeneratedSchema =
sidebarItemBaseSchema.append<SidebarItemAutogenerated>({
type: 'autogenerated',
dirName: Joi.string()
.required()
.pattern(/^[^/](.*[^/])?$/)
.message(
'"dirName" must be a dir path relative to the docs folder root, and should not start or end with slash',
),
});
const sidebarItemDocSchema = sidebarItemBaseSchema.append<SidebarItemDoc>({
type: Joi.string().valid('doc', 'ref').required(),
id: Joi.string().required(),
label: Joi.string(),
});
const sidebarItemLinkSchema = sidebarItemBaseSchema.append<SidebarItemLink>({
type: 'link',
href: URISchema.required(),
label: Joi.string()
.required()
.messages({'any.unknown': '"label" must be a string'}),
});
const sidebarItemCategorySchema =
sidebarItemBaseSchema.append<SidebarItemCategoryConfig>({
type: 'category',
label: Joi.string()
.required()
.messages({'any.unknown': '"label" must be a string'}),
// TODO: Joi doesn't allow mutual recursion. See https://github.com/sideway/joi/issues/2611
items: Joi.array()
.required()
.messages({'any.unknown': '"items" must be an array'}), // .items(Joi.link('#sidebarItemSchema')),
collapsed: Joi.boolean().messages({
'any.unknown': '"collapsed" must be a boolean',
}),
collapsible: Joi.boolean().messages({
'any.unknown': '"collapsible" must be a boolean',
}),
});
const sidebarItemSchema: Joi.Schema<SidebarItemConfig> = Joi.object()
.when('.type', {
switch: [
{is: 'link', then: sidebarItemLinkSchema},
{
is: Joi.string().valid('doc', 'ref').required(),
then: sidebarItemDocSchema,
},
{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(),
then: Joi.forbidden().messages({
'any.unknown': 'Unknown sidebar item type "{.type}".',
}),
},
],
})
.id('sidebarItemSchema');
function validateSidebarItem(item: unknown): asserts item is SidebarItemConfig {
if (typeof item === 'string') {
return;
}
// TODO: remove once with proper Joi support
// Because we can't use Joi to validate nested items (see above), we do it manually
if (isCategoriesShorthand(item as SidebarItemConfig)) {
Object.values(item as SidebarCategoriesShorthand).forEach((category) =>
category.forEach(validateSidebarItem),
);
} else {
Joi.assert(item, sidebarItemSchema);
if ((item as SidebarItemCategoryConfig).type === 'category') {
(item as SidebarItemCategoryConfig).items.forEach(validateSidebarItem);
}
}
}
export function validateSidebars(
sidebars: unknown,
): asserts sidebars is SidebarsConfig {
Object.values(sidebars as SidebarsConfig).forEach((sidebar) => {
if (Array.isArray(sidebar)) {
sidebar.forEach(validateSidebarItem);
} else {
validateSidebarItem(sidebar);
}
});
}