mirror of
https://github.com/facebook/docusaurus.git
synced 2025-06-19 11:12:36 +02:00
refactor(content-docs): refactor sidebars, Joi validation, generator rework, expose config types (#5678)
This commit is contained in:
parent
543011c9d2
commit
8d92e9bcf5
41 changed files with 1806 additions and 1880 deletions
|
@ -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',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"docs": {
|
||||
"Test": [
|
||||
{
|
||||
"type": "category",
|
||||
"label": "Category Label",
|
||||
"items": "doc1"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"docs": {
|
||||
"Test": [
|
||||
{
|
||||
"type": "category",
|
||||
"label": true,
|
||||
"items": ["doc1"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
|
@ -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',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"docs": [
|
||||
{
|
||||
"type": "category",
|
||||
"label": "Introduction",
|
||||
"items": [
|
||||
"doc1"
|
||||
],
|
||||
"collapsed": false
|
||||
},
|
||||
{
|
||||
"type": "category",
|
||||
"label": "Powering MDX",
|
||||
"items": [
|
||||
"doc2"
|
||||
],
|
||||
"collapsed": false
|
||||
}
|
||||
]
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"docs": {
|
||||
"Test": [
|
||||
{
|
||||
"type": "category",
|
||||
"label": "Introduction",
|
||||
"items": ["doc1"],
|
||||
"collapsed": false
|
||||
}
|
||||
],
|
||||
"Reference": [
|
||||
{
|
||||
"type": "category",
|
||||
"label": "Powering MDX",
|
||||
"items": ["doc2"],
|
||||
"collapsed": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"docs": {
|
||||
"Test": [
|
||||
{
|
||||
"type": "doc",
|
||||
"id": ["doc1"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
|
@ -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',
|
||||
},
|
||||
],
|
||||
};
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"docs": {
|
||||
"Test": [
|
||||
{
|
||||
"type": "link",
|
||||
"label": "GitHub",
|
||||
"href": ["example.com"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"docs": {
|
||||
"Test": [
|
||||
{
|
||||
"type": "link",
|
||||
"label": false,
|
||||
"href": "https://github.com"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"docs": {
|
||||
"Test": [
|
||||
{
|
||||
"type": "link",
|
||||
"label": "category",
|
||||
"href": "https://github.com"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"docs": {
|
||||
"Test": [
|
||||
"foo/bar",
|
||||
"foo/baz",
|
||||
{
|
||||
"type": "superman"
|
||||
}
|
||||
],
|
||||
"Guides": [
|
||||
"hello"
|
||||
]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"docs": {
|
||||
"Test": [
|
||||
"foo/bar",
|
||||
"foo/baz",
|
||||
{
|
||||
"type": "category",
|
||||
"label": "category",
|
||||
"href": "https://github.com"
|
||||
},
|
||||
{
|
||||
"type": "ref",
|
||||
"id": "hello"
|
||||
}
|
||||
],
|
||||
"Guides": [
|
||||
"hello"
|
||||
]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"docs": {
|
||||
"Test": [
|
||||
"foo/bar",
|
||||
"foo/baz",
|
||||
{
|
||||
"type": "link",
|
||||
"label": "Github",
|
||||
"href": "https://github.com"
|
||||
},
|
||||
{
|
||||
"type": "ref",
|
||||
"id": "hello"
|
||||
}
|
||||
],
|
||||
"Guides": [
|
||||
"hello"
|
||||
]
|
||||
}
|
||||
}
|
|
@ -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",
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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\\" [31m[1][0m: \\"doc1\\"
|
||||
}
|
||||
[31m
|
||||
[1] \\"items\\" must be an array[0m"
|
||||
`);
|
||||
});
|
||||
|
||||
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\\" [31m[1][0m: true
|
||||
}
|
||||
[31m
|
||||
[1] \\"label\\" must be a string[0m"
|
||||
`);
|
||||
});
|
||||
|
||||
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\\" [31m[1][0m: [
|
||||
\\"doc1\\"
|
||||
]
|
||||
}
|
||||
[31m
|
||||
[1] \\"id\\" must be a string[0m"
|
||||
`);
|
||||
});
|
||||
|
||||
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\\" [31m[1][0m: false
|
||||
}
|
||||
[31m
|
||||
[1] \\"label\\" must be a string[0m"
|
||||
`);
|
||||
});
|
||||
|
||||
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\\" [31m[1][0m: [
|
||||
\\"example.com\\"
|
||||
]
|
||||
}
|
||||
[31m
|
||||
[1] \\"href\\" contains an invalid value[0m"
|
||||
`);
|
||||
});
|
||||
|
||||
test('sidebars with unknown sidebar item type', async () => {
|
||||
const sidebarPath = path.join(fixtureDir, 'sidebars-unknown-type.json');
|
||||
expect(() => loadUnprocessedSidebars(sidebarPath, options))
|
||||
.toThrowErrorMatchingInlineSnapshot(`
|
||||
"{
|
||||
\\"type\\": \\"superman\\",
|
||||
[41m\\"undefined\\"[0m[31m [1]: -- missing --[0m
|
||||
}
|
||||
[31m
|
||||
[1] Unknown sidebar item type \\"superman\\".[0m"
|
||||
`);
|
||||
});
|
||||
|
||||
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\\",
|
||||
[41m\\"items\\"[0m[31m [1]: -- missing --[0m
|
||||
}
|
||||
[31m
|
||||
[1] \\"items\\" is required[0m"
|
||||
`);
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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'},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
};
|
|
@ -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);
|
||||
}
|
|
@ -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));
|
||||
}
|
|
@ -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),
|
||||
),
|
||||
);
|
||||
}
|
156
packages/docusaurus-plugin-content-docs/src/sidebars/types.ts
Normal file
156
packages/docusaurus-plugin-content-docs/src/sidebars/types.ts
Normal 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[]>;
|
146
packages/docusaurus-plugin-content-docs/src/sidebars/utils.ts
Normal file
146
packages/docusaurus-plugin-content-docs/src/sidebars/utils.ts
Normal 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,
|
||||
};
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue