refactor(content-docs): clean up sidebars logic; validate generator returns (#6596)

* refactor(content-docs): clean up sidebars logic; validate generator returns

* remove another TODO

* fix types

* refactors

* refactor...
This commit is contained in:
Joshua Chen 2022-02-04 09:46:25 +08:00 committed by GitHub
parent d6bdf7e804
commit e3fd3e74ce
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 750 additions and 615 deletions

View file

@ -770,8 +770,6 @@ Object {
\\"docs\\": [ \\"docs\\": [
{ {
\\"type\\": \\"category\\", \\"type\\": \\"category\\",
\\"collapsed\\": true,
\\"collapsible\\": true,
\\"label\\": \\"Test\\", \\"label\\": \\"Test\\",
\\"items\\": [ \\"items\\": [
{ {
@ -791,8 +789,8 @@ Object {
\\"docId\\": \\"foo/baz\\" \\"docId\\": \\"foo/baz\\"
} }
], ],
\\"collapsible\\": true, \\"collapsed\\": true,
\\"collapsed\\": true \\"collapsible\\": true
}, },
{ {
\\"type\\": \\"category\\", \\"type\\": \\"category\\",
@ -823,8 +821,8 @@ Object {
\\"docId\\": \\"rootTryToEscapeSlug\\" \\"docId\\": \\"rootTryToEscapeSlug\\"
} }
], ],
\\"collapsible\\": true,
\\"collapsed\\": true, \\"collapsed\\": true,
\\"collapsible\\": true,
\\"href\\": \\"/docs/category/slugs\\" \\"href\\": \\"/docs/category/slugs\\"
}, },
{ {
@ -844,12 +842,12 @@ Object {
\\"href\\": \\"/docs/\\", \\"href\\": \\"/docs/\\",
\\"docId\\": \\"hello\\" \\"docId\\": \\"hello\\"
} }
] ],
\\"collapsed\\": true,
\\"collapsible\\": true
}, },
{ {
\\"type\\": \\"category\\", \\"type\\": \\"category\\",
\\"collapsed\\": true,
\\"collapsible\\": true,
\\"label\\": \\"Guides\\", \\"label\\": \\"Guides\\",
\\"items\\": [ \\"items\\": [
{ {
@ -858,7 +856,9 @@ Object {
\\"href\\": \\"/docs/\\", \\"href\\": \\"/docs/\\",
\\"docId\\": \\"hello\\" \\"docId\\": \\"hello\\"
} }
] ],
\\"collapsed\\": true,
\\"collapsible\\": true
} }
] ]
}, },
@ -3188,8 +3188,6 @@ Object {
\\"version-1.0.0/docs\\": [ \\"version-1.0.0/docs\\": [
{ {
\\"type\\": \\"category\\", \\"type\\": \\"category\\",
\\"collapsed\\": true,
\\"collapsible\\": true,
\\"label\\": \\"Test\\", \\"label\\": \\"Test\\",
\\"items\\": [ \\"items\\": [
{ {
@ -3204,12 +3202,12 @@ Object {
\\"href\\": \\"/docs/1.0.0/foo/baz\\", \\"href\\": \\"/docs/1.0.0/foo/baz\\",
\\"docId\\": \\"foo/baz\\" \\"docId\\": \\"foo/baz\\"
} }
] ],
\\"collapsed\\": true,
\\"collapsible\\": true
}, },
{ {
\\"type\\": \\"category\\", \\"type\\": \\"category\\",
\\"collapsed\\": true,
\\"collapsible\\": true,
\\"label\\": \\"Guides\\", \\"label\\": \\"Guides\\",
\\"items\\": [ \\"items\\": [
{ {
@ -3218,7 +3216,9 @@ Object {
\\"href\\": \\"/docs/1.0.0/\\", \\"href\\": \\"/docs/1.0.0/\\",
\\"docId\\": \\"hello\\" \\"docId\\": \\"hello\\"
} }
] ],
\\"collapsed\\": true,
\\"collapsible\\": true
} }
] ]
}, },
@ -3255,8 +3255,6 @@ Object {
\\"VersionedSideBarNameDoesNotMatter/docs\\": [ \\"VersionedSideBarNameDoesNotMatter/docs\\": [
{ {
\\"type\\": \\"category\\", \\"type\\": \\"category\\",
\\"collapsed\\": true,
\\"collapsible\\": true,
\\"label\\": \\"Test\\", \\"label\\": \\"Test\\",
\\"items\\": [ \\"items\\": [
{ {
@ -3265,12 +3263,12 @@ Object {
\\"href\\": \\"/docs/foo/bar\\", \\"href\\": \\"/docs/foo/bar\\",
\\"docId\\": \\"foo/bar\\" \\"docId\\": \\"foo/bar\\"
} }
] ],
\\"collapsed\\": true,
\\"collapsible\\": true
}, },
{ {
\\"type\\": \\"category\\", \\"type\\": \\"category\\",
\\"collapsed\\": true,
\\"collapsible\\": true,
\\"label\\": \\"Guides\\", \\"label\\": \\"Guides\\",
\\"items\\": [ \\"items\\": [
{ {
@ -3279,7 +3277,9 @@ Object {
\\"href\\": \\"/docs/\\", \\"href\\": \\"/docs/\\",
\\"docId\\": \\"hello\\" \\"docId\\": \\"hello\\"
} }
] ],
\\"collapsed\\": true,
\\"collapsible\\": true
} }
] ]
}, },
@ -3310,8 +3310,6 @@ Object {
\\"docs\\": [ \\"docs\\": [
{ {
\\"type\\": \\"category\\", \\"type\\": \\"category\\",
\\"collapsed\\": true,
\\"collapsible\\": true,
\\"label\\": \\"Test\\", \\"label\\": \\"Test\\",
\\"items\\": [ \\"items\\": [
{ {
@ -3320,12 +3318,12 @@ Object {
\\"href\\": \\"/docs/next/foo/barSlug\\", \\"href\\": \\"/docs/next/foo/barSlug\\",
\\"docId\\": \\"foo/bar\\" \\"docId\\": \\"foo/bar\\"
} }
] ],
\\"collapsed\\": true,
\\"collapsible\\": true
}, },
{ {
\\"type\\": \\"category\\", \\"type\\": \\"category\\",
\\"collapsed\\": true,
\\"collapsible\\": true,
\\"label\\": \\"Guides\\", \\"label\\": \\"Guides\\",
\\"items\\": [ \\"items\\": [
{ {
@ -3334,7 +3332,9 @@ Object {
\\"href\\": \\"/docs/next/\\", \\"href\\": \\"/docs/next/\\",
\\"docId\\": \\"hello\\" \\"docId\\": \\"hello\\"
} }
] ],
\\"collapsed\\": true,
\\"collapsible\\": true
} }
] ]
}, },
@ -3385,8 +3385,6 @@ Object {
\\"version-1.0.1/docs\\": [ \\"version-1.0.1/docs\\": [
{ {
\\"type\\": \\"category\\", \\"type\\": \\"category\\",
\\"collapsed\\": true,
\\"collapsible\\": true,
\\"label\\": \\"Test\\", \\"label\\": \\"Test\\",
\\"items\\": [ \\"items\\": [
{ {
@ -3395,7 +3393,9 @@ Object {
\\"href\\": \\"/docs/withSlugs/rootAbsoluteSlug\\", \\"href\\": \\"/docs/withSlugs/rootAbsoluteSlug\\",
\\"docId\\": \\"rootAbsoluteSlug\\" \\"docId\\": \\"rootAbsoluteSlug\\"
} }
] ],
\\"collapsed\\": true,
\\"collapsible\\": true
} }
] ]
}, },

View file

@ -16,7 +16,7 @@ import type {
PathOptions, PathOptions,
SidebarOptions, SidebarOptions,
} from '@docusaurus/plugin-content-docs'; } from '@docusaurus/plugin-content-docs';
import {loadSidebarsFile, resolveSidebarPathOption} from './sidebars'; import {loadSidebarsFileUnsafe, resolveSidebarPathOption} from './sidebars';
import {DEFAULT_PLUGIN_ID} from '@docusaurus/utils'; import {DEFAULT_PLUGIN_ID} from '@docusaurus/utils';
import logger from '@docusaurus/logger'; import logger from '@docusaurus/logger';
@ -34,7 +34,8 @@ async function createVersionedSidebarFile({
// Load current sidebar and create a new versioned sidebars file (if needed). // Load current sidebar and create a new versioned sidebars file (if needed).
// Note: we don't need the sidebars file to be normalized: it's ok to let // Note: we don't need the sidebars file to be normalized: it's ok to let
// plugin option changes to impact older, versioned sidebars // plugin option changes to impact older, versioned sidebars
const sidebars = await loadSidebarsFile(sidebarPath); // We don't validate here, assuming the user has already built the version
const sidebars = await loadSidebarsFileUnsafe(sidebarPath);
// Do not create a useless versioned sidebars file if sidebars file is empty // Do not create a useless versioned sidebars file if sidebars file is empty
// or sidebars are disabled/false) // or sidebars are disabled/false)

View file

@ -0,0 +1,9 @@
# Sidebars
This part is very complicated and hard to navigate. Sidebars are loaded through the following steps:
1. **Loading**. The sidebars file is read. Returns `SidebarsConfig`.
2. **Normalization**. The shorthands are expanded. This step is very lenient about the sidebars' shapes. Returns `NormalizedSidebars`.
3. **Validation**. The normalized sidebars are validated. This step happens after normalization, because the normalized sidebars are easier to validate, and allows us to repeatedly validate & generate in the future.
4. **Generation**. This step is done through the "processor" (naming is hard). The `autogenerated` items are unwrapped. In the future, steps 3 and 4 may be repeatedly done until all autogenerated items are unwrapped. Returns `ProcessedSidebars`.
5. **Post-processing**. Defaults are applied (collapsed states), category links are resolved, empty categories are flattened. Returns `Sidebars`.

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`loadNormalizedSidebars sidebars link 1`] = ` exports[`loadSidebars sidebars link 1`] = `
Object { Object {
"docs": Array [ "docs": Array [
Object { Object {
@ -21,7 +21,7 @@ Object {
} }
`; `;
exports[`loadNormalizedSidebars sidebars with category.collapsed property 1`] = ` exports[`loadSidebars sidebars with category.collapsed property 1`] = `
Object { Object {
"docs": Array [ "docs": Array [
Object { Object {
@ -72,7 +72,7 @@ Object {
} }
`; `;
exports[`loadNormalizedSidebars sidebars with category.collapsed property at first level 1`] = ` exports[`loadSidebars sidebars with category.collapsed property at first level 1`] = `
Object { Object {
"docs": Array [ "docs": Array [
Object { Object {
@ -105,7 +105,7 @@ Object {
} }
`; `;
exports[`loadNormalizedSidebars sidebars with deep level of category 1`] = ` exports[`loadSidebars sidebars with deep level of category 1`] = `
Object { Object {
"docs": Array [ "docs": Array [
Object { Object {
@ -177,7 +177,7 @@ Object {
} }
`; `;
exports[`loadNormalizedSidebars sidebars with first level not a category 1`] = ` exports[`loadSidebars sidebars with first level not a category 1`] = `
Object { Object {
"docs": Array [ "docs": Array [
Object { Object {
@ -201,7 +201,7 @@ Object {
} }
`; `;
exports[`loadNormalizedSidebars sidebars with known sidebar item type 1`] = ` exports[`loadSidebars sidebars with known sidebar item type 1`] = `
Object { Object {
"docs": Array [ "docs": Array [
Object { Object {
@ -246,3 +246,23 @@ Object {
], ],
} }
`; `;
exports[`loadSidebars undefined path 1`] = `
Object {
"defaultSidebar": Array [
Object {
"collapsed": true,
"collapsible": true,
"items": Array [
Object {
"id": "bar",
"type": "doc",
},
],
"label": "foo",
"link": undefined,
"type": "category",
},
],
}
`;

View file

@ -214,8 +214,6 @@ describe('DefaultSidebarItemsGenerator', () => {
{ {
type: 'category', type: 'category',
label: 'Tutorials', label: 'Tutorials',
collapsed: true,
collapsible: true,
link: { link: {
type: 'doc', type: 'doc',
id: 'tutorials-index', id: 'tutorials-index',
@ -228,19 +226,16 @@ describe('DefaultSidebarItemsGenerator', () => {
{ {
type: 'category', type: 'category',
label: 'Guides', label: 'Guides',
collapsed: false,
collapsible: true,
link: { link: {
type: 'doc', type: 'doc',
id: 'guides-index', id: 'guides-index',
}, },
collapsed: false,
items: [ items: [
{type: 'doc', id: 'guide1', className: 'foo'}, {type: 'doc', id: 'guide1', className: 'foo'},
{ {
type: 'category', type: 'category',
label: 'SubGuides (metadata file label)', label: 'SubGuides (metadata file label)',
collapsed: true,
collapsible: true,
items: [{type: 'doc', id: 'nested-guide'}], items: [{type: 'doc', id: 'nested-guide'}],
link: { link: {
type: 'generated-index', type: 'generated-index',
@ -279,8 +274,6 @@ describe('DefaultSidebarItemsGenerator', () => {
'subfolder/subsubfolder/subsubsubfolder3': { 'subfolder/subsubfolder/subsubsubfolder3': {
position: 1, position: 1,
label: 'subsubsubfolder3 (_category_.json label)', label: 'subsubsubfolder3 (_category_.json label)',
collapsible: false,
collapsed: false,
link: { link: {
type: 'doc', type: 'doc',
id: 'doc1', // This is a "fully-qualified" ID that can't be found locally id: 'doc1', // This is a "fully-qualified" ID that can't be found locally
@ -355,8 +348,6 @@ describe('DefaultSidebarItemsGenerator', () => {
{ {
type: 'category', type: 'category',
label: 'subsubsubfolder3 (_category_.json label)', label: 'subsubsubfolder3 (_category_.json label)',
collapsed: false,
collapsible: false,
link: { link: {
id: 'doc1', id: 'doc1',
type: 'doc', type: 'doc',
@ -369,8 +360,6 @@ describe('DefaultSidebarItemsGenerator', () => {
{ {
type: 'category', type: 'category',
label: 'subsubsubfolder2 (_category_.yml label)', label: 'subsubsubfolder2 (_category_.yml label)',
collapsed: true,
collapsible: true,
className: 'bar', className: 'bar',
items: [{type: 'doc', id: 'doc6'}], items: [{type: 'doc', id: 'doc6'}],
}, },
@ -379,8 +368,6 @@ describe('DefaultSidebarItemsGenerator', () => {
{ {
type: 'category', type: 'category',
label: 'subsubsubfolder', label: 'subsubsubfolder',
collapsed: true,
collapsible: true,
items: [{type: 'doc', id: 'doc5'}], items: [{type: 'doc', id: 'doc5'}],
}, },
] as Sidebar); ] as Sidebar);
@ -458,8 +445,6 @@ describe('DefaultSidebarItemsGenerator', () => {
{ {
type: 'category', type: 'category',
label: 'Category label', label: 'Category label',
collapsed: true,
collapsible: true,
link: { link: {
id: 'parent/doc3', id: 'parent/doc3',
type: 'doc', type: 'doc',
@ -478,8 +463,6 @@ describe('DefaultSidebarItemsGenerator', () => {
{ {
type: 'category', type: 'category',
label: 'Category 2 label', label: 'Category 2 label',
collapsed: true,
collapsible: true,
items: [ items: [
{ {
id: 'parent/doc4', id: 'parent/doc4',
@ -583,8 +566,6 @@ describe('DefaultSidebarItemsGenerator', () => {
{ {
type: 'category', type: 'category',
label: 'Tutorials', label: 'Tutorials',
collapsed: true,
collapsible: true,
link: { link: {
type: 'doc', type: 'doc',
id: 'tutorials-index', id: 'tutorials-index',
@ -597,8 +578,6 @@ describe('DefaultSidebarItemsGenerator', () => {
{ {
type: 'category', type: 'category',
label: 'Guides', label: 'Guides',
collapsed: true,
collapsible: true,
items: [ items: [
{type: 'doc', id: 'guide1', className: 'foo'}, {type: 'doc', id: 'guide1', className: 'foo'},
{type: 'doc', id: 'guide2'}, {type: 'doc', id: 'guide2'},

View file

@ -6,32 +6,36 @@
*/ */
import path from 'path'; import path from 'path';
import { import {loadSidebars, DisabledSidebars} from '../index';
loadNormalizedSidebars, import type {SidebarProcessorParams} from '../types';
DefaultSidebars, import {DefaultSidebarItemsGenerator} from '../generator';
DisabledSidebars,
} from '../index';
import type {NormalizeSidebarsParams, VersionMetadata} from '../../types';
describe('loadNormalizedSidebars', () => { describe('loadSidebars', () => {
const fixtureDir = path.join(__dirname, '__fixtures__', 'sidebars'); const fixtureDir = path.join(__dirname, '__fixtures__', 'sidebars');
const options: NormalizeSidebarsParams = { const params: SidebarProcessorParams = {
sidebarCollapsed: true, sidebarItemsGenerator: DefaultSidebarItemsGenerator,
sidebarCollapsible: true, numberPrefixParser: (filename) => ({filename}),
version: { docs: [
versionName: 'version', {
versionPath: 'versionPath', source: '@site/docs/foo/bar.md',
} as VersionMetadata, sourceDirName: 'foo',
id: 'bar',
frontMatter: {},
},
],
version: {contentPath: 'docs/foo', contentPathLocalized: 'docs/foo'},
categoryLabelSlugger: null,
sidebarOptions: {sidebarCollapsed: true, sidebarCollapsible: true},
}; };
test('sidebars with known sidebar item type', async () => { test('sidebars with known sidebar item type', async () => {
const sidebarPath = path.join(fixtureDir, 'sidebars.json'); const sidebarPath = path.join(fixtureDir, 'sidebars.json');
const result = await loadNormalizedSidebars(sidebarPath, options); const result = await loadSidebars(sidebarPath, params);
expect(result).toMatchSnapshot(); expect(result).toMatchSnapshot();
}); });
test('sidebars with deep level of category', async () => { test('sidebars with deep level of category', async () => {
const sidebarPath = path.join(fixtureDir, 'sidebars-category.js'); const sidebarPath = path.join(fixtureDir, 'sidebars-category.js');
const result = await loadNormalizedSidebars(sidebarPath, options); const result = await loadSidebars(sidebarPath, params);
expect(result).toMatchSnapshot(); expect(result).toMatchSnapshot();
}); });
@ -41,8 +45,8 @@ describe('loadNormalizedSidebars', () => {
fixtureDir, fixtureDir,
'sidebars-category-shorthand.js', 'sidebars-category-shorthand.js',
); );
const sidebar1 = await loadNormalizedSidebars(sidebarPath1, options); const sidebar1 = await loadSidebars(sidebarPath1, params);
const sidebar2 = await loadNormalizedSidebars(sidebarPath2, options); const sidebar2 = await loadSidebars(sidebarPath2, params);
expect(sidebar1).toEqual(sidebar2); expect(sidebar1).toEqual(sidebar2);
}); });
@ -51,53 +55,11 @@ describe('loadNormalizedSidebars', () => {
fixtureDir, fixtureDir,
'sidebars-category-wrong-items.json', 'sidebars-category-wrong-items.json',
); );
await expect(() => loadNormalizedSidebars(sidebarPath, options)).rejects await expect(() =>
.toThrowErrorMatchingInlineSnapshot(` loadSidebars(sidebarPath, params),
"{ ).rejects.toThrowErrorMatchingInlineSnapshot(
\\"type\\": \\"category\\", `"Invalid category {\\"type\\":\\"category\\",\\"label\\":\\"Category Label\\",\\"items\\":\\"doc1\\"}: items must be an array"`,
\\"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',
); );
await expect(() => loadNormalizedSidebars(sidebarPath, options)).rejects
.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',
);
await expect(() => loadNormalizedSidebars(sidebarPath, options)).rejects
.toThrowErrorMatchingInlineSnapshot(`
"{
\\"type\\": \\"doc\\",
\\"id\\" [1]: [
\\"doc1\\"
]
}

[1] \\"id\\" must be a string"
`);
}); });
test('sidebars with first level not a category', async () => { test('sidebars with first level not a category', async () => {
@ -105,95 +67,35 @@ describe('loadNormalizedSidebars', () => {
fixtureDir, fixtureDir,
'sidebars-first-level-not-category.js', 'sidebars-first-level-not-category.js',
); );
const result = await loadNormalizedSidebars(sidebarPath, options); const result = await loadSidebars(sidebarPath, params);
expect(result).toMatchSnapshot(); expect(result).toMatchSnapshot();
}); });
test('sidebars link', async () => { test('sidebars link', async () => {
const sidebarPath = path.join(fixtureDir, 'sidebars-link.json'); const sidebarPath = path.join(fixtureDir, 'sidebars-link.json');
const result = await loadNormalizedSidebars(sidebarPath, options); const result = await loadSidebars(sidebarPath, params);
expect(result).toMatchSnapshot(); expect(result).toMatchSnapshot();
}); });
test('sidebars link wrong label', async () => {
const sidebarPath = path.join(fixtureDir, 'sidebars-link-wrong-label.json');
await expect(() => loadNormalizedSidebars(sidebarPath, options)).rejects
.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');
await expect(() => loadNormalizedSidebars(sidebarPath, options)).rejects
.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');
await expect(() => loadNormalizedSidebars(sidebarPath, options)).rejects
.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');
await expect(() => loadNormalizedSidebars(sidebarPath, options)).rejects
.toThrowErrorMatchingInlineSnapshot(`
"{
\\"type\\": \\"category\\",
\\"label\\": \\"category\\",
\\"href\\": \\"https://github.com\\",
\\"items\\" [1]: -- missing --
}

[1] \\"items\\" is required"
`);
});
test('unexisting path', async () => { test('unexisting path', async () => {
await expect(loadNormalizedSidebars('badpath', options)).resolves.toEqual( await expect(loadSidebars('badpath', params)).resolves.toEqual(
DisabledSidebars, DisabledSidebars,
); );
}); });
test('undefined path', async () => { test('undefined path', async () => {
await expect(loadNormalizedSidebars(undefined, options)).resolves.toEqual( await expect(loadSidebars(undefined, params)).resolves.toMatchSnapshot();
DefaultSidebars,
);
}); });
test('literal false path', async () => { test('literal false path', async () => {
await expect(loadNormalizedSidebars(false, options)).resolves.toEqual( await expect(loadSidebars(false, params)).resolves.toEqual(
DisabledSidebars, DisabledSidebars,
); );
}); });
test('sidebars with category.collapsed property', async () => { test('sidebars with category.collapsed property', async () => {
const sidebarPath = path.join(fixtureDir, 'sidebars-collapsed.json'); const sidebarPath = path.join(fixtureDir, 'sidebars-collapsed.json');
const result = await loadNormalizedSidebars(sidebarPath, options); const result = await loadSidebars(sidebarPath, params);
expect(result).toMatchSnapshot(); expect(result).toMatchSnapshot();
}); });
@ -202,7 +104,7 @@ describe('loadNormalizedSidebars', () => {
fixtureDir, fixtureDir,
'sidebars-collapsed-first-level.json', 'sidebars-collapsed-first-level.json',
); );
const result = await loadNormalizedSidebars(sidebarPath, options); const result = await loadSidebars(sidebarPath, params);
expect(result).toMatchSnapshot(); expect(result).toMatchSnapshot();
}); });
}); });

View file

@ -0,0 +1,194 @@
/**
* 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 {postProcessSidebars} from '../postProcessor';
describe('postProcess', () => {
test('transforms category without subitems', () => {
const processedSidebar = postProcessSidebars(
{
sidebar: [
{
type: 'category',
label: 'Category',
link: {
type: 'generated-index',
slug: 'generated/permalink',
},
items: [],
},
{
type: 'category',
label: 'Category 2',
link: {
type: 'doc',
id: 'doc ID',
},
items: [],
},
],
},
{
sidebarOptions: {sidebarCollapsed: true, sidebarCollapsible: true},
version: {versionPath: 'version'},
},
);
expect(processedSidebar).toMatchInlineSnapshot(`
Object {
"sidebar": Array [
Object {
"href": "version/generated/permalink",
"label": "Category",
"type": "link",
},
Object {
"id": "doc ID",
"label": "Category 2",
"type": "doc",
},
],
}
`);
expect(() => {
postProcessSidebars(
{
sidebar: [
{
type: 'category',
label: 'Bad category',
items: [],
},
],
},
{
sidebarOptions: {sidebarCollapsed: true, sidebarCollapsible: true},
version: {versionPath: 'version'},
},
);
}).toThrowErrorMatchingInlineSnapshot(
`"Sidebar category Bad category has neither any subitem nor a link. This makes this item not able to link to anything."`,
);
});
test('corrects collapsed state inconsistencies', () => {
expect(
postProcessSidebars(
{
sidebar: [
{
type: 'category',
label: 'Category',
collapsed: true,
collapsible: false,
items: [{type: 'doc', id: 'foo'}],
},
],
},
{
sidebarOptions: {sidebarCollapsed: true, sidebarCollapsible: true},
version: {versionPath: 'version'},
},
),
).toMatchInlineSnapshot(`
Object {
"sidebar": Array [
Object {
"collapsed": false,
"collapsible": false,
"items": Array [
Object {
"id": "foo",
"type": "doc",
},
],
"label": "Category",
"link": undefined,
"type": "category",
},
],
}
`);
expect(
postProcessSidebars(
{
sidebar: [
{
type: 'category',
label: 'Category',
collapsed: true,
items: [{type: 'doc', id: 'foo'}],
},
],
},
{
sidebarOptions: {sidebarCollapsed: false, sidebarCollapsible: false},
version: {versionPath: 'version'},
},
),
).toMatchInlineSnapshot(`
Object {
"sidebar": Array [
Object {
"collapsed": false,
"collapsible": false,
"items": Array [
Object {
"id": "foo",
"type": "doc",
},
],
"label": "Category",
"link": undefined,
"type": "category",
},
],
}
`);
expect(
postProcessSidebars(
{
sidebar: [
{
type: 'category',
label: 'Category',
items: [{type: 'doc', id: 'foo'}],
},
],
},
{
sidebarOptions: {sidebarCollapsed: true, sidebarCollapsible: false},
version: {versionPath: 'version'},
},
),
).toMatchInlineSnapshot(`
Object {
"sidebar": Array [
Object {
"collapsed": false,
"collapsible": false,
"items": Array [
Object {
"id": "foo",
"type": "doc",
},
],
"label": "Category",
"link": undefined,
"type": "category",
},
],
}
`);
});
});

View file

@ -5,12 +5,15 @@
* LICENSE file in the root directory of this source tree. * LICENSE file in the root directory of this source tree.
*/ */
import {processSidebars, type SidebarProcessorParams} from '../processor'; import {processSidebars} from '../processor';
import type { import type {
SidebarItem, SidebarItem,
SidebarItemsGenerator, SidebarItemsGenerator,
Sidebars, NormalizedSidebar,
NormalizedSidebars, NormalizedSidebars,
SidebarProcessorParams,
CategoryMetadataFile,
ProcessedSidebars,
} from '../types'; } from '../types';
import {DefaultSidebarItemsGenerator} from '../generator'; import {DefaultSidebarItemsGenerator} from '../generator';
import {createSlugger} from '@docusaurus/utils'; import {createSlugger} from '@docusaurus/utils';
@ -25,7 +28,7 @@ describe('processSidebars', () => {
return jest.fn(async () => sidebarSlice); return jest.fn(async () => sidebarSlice);
} }
const StaticGeneratedSidebarSlice: SidebarItem[] = [ const StaticGeneratedSidebarSlice: NormalizedSidebar = [
{type: 'doc', id: 'doc-generated-id-1'}, {type: 'doc', id: 'doc-generated-id-1'},
{type: 'doc', id: 'doc-generated-id-2'}, {type: 'doc', id: 'doc-generated-id-2'},
]; ];
@ -53,9 +56,10 @@ describe('processSidebars', () => {
async function testProcessSidebars( async function testProcessSidebars(
unprocessedSidebars: NormalizedSidebars, unprocessedSidebars: NormalizedSidebars,
categoriesMetadata: Record<string, CategoryMetadataFile> = {},
paramsOverrides: Partial<SidebarProcessorParams> = {}, paramsOverrides: Partial<SidebarProcessorParams> = {},
) { ) {
return processSidebars(unprocessedSidebars, { return processSidebars(unprocessedSidebars, categoriesMetadata, {
...params, ...params,
...paramsOverrides, ...paramsOverrides,
}); });
@ -101,10 +105,7 @@ describe('processSidebars', () => {
link: { link: {
type: 'generated-index', type: 'generated-index',
slug: 'category-generated-index-slug', slug: 'category-generated-index-slug',
permalink: 'category-generated-index-permalink',
}, },
collapsed: true, // A suspicious bad config that will be normalized
collapsible: false,
items: [ items: [
{type: 'doc', id: 'doc2'}, {type: 'doc', id: 'doc2'},
{type: 'autogenerated', dirName: 'dir1'}, {type: 'autogenerated', dirName: 'dir1'},
@ -131,6 +132,7 @@ describe('processSidebars', () => {
expect(StaticSidebarItemsGenerator).toHaveBeenCalledTimes(3); expect(StaticSidebarItemsGenerator).toHaveBeenCalledTimes(3);
expect(StaticSidebarItemsGenerator).toHaveBeenCalledWith({ expect(StaticSidebarItemsGenerator).toHaveBeenCalledWith({
categoriesMetadata: {},
defaultSidebarItemsGenerator: DefaultSidebarItemsGenerator, defaultSidebarItemsGenerator: DefaultSidebarItemsGenerator,
item: {type: 'autogenerated', dirName: 'dir1'}, item: {type: 'autogenerated', dirName: 'dir1'},
docs: params.docs, docs: params.docs,
@ -143,6 +145,7 @@ describe('processSidebars', () => {
}); });
expect(StaticSidebarItemsGenerator).toHaveBeenCalledWith({ expect(StaticSidebarItemsGenerator).toHaveBeenCalledWith({
defaultSidebarItemsGenerator: DefaultSidebarItemsGenerator, defaultSidebarItemsGenerator: DefaultSidebarItemsGenerator,
categoriesMetadata: {},
item: {type: 'autogenerated', dirName: 'dir2'}, item: {type: 'autogenerated', dirName: 'dir2'},
docs: params.docs, docs: params.docs,
version: { version: {
@ -154,6 +157,7 @@ describe('processSidebars', () => {
}); });
expect(StaticSidebarItemsGenerator).toHaveBeenCalledWith({ expect(StaticSidebarItemsGenerator).toHaveBeenCalledWith({
defaultSidebarItemsGenerator: DefaultSidebarItemsGenerator, defaultSidebarItemsGenerator: DefaultSidebarItemsGenerator,
categoriesMetadata: {},
item: {type: 'autogenerated', dirName: 'dir3'}, item: {type: 'autogenerated', dirName: 'dir3'},
docs: params.docs, docs: params.docs,
version: { version: {
@ -173,10 +177,7 @@ describe('processSidebars', () => {
link: { link: {
type: 'generated-index', type: 'generated-index',
slug: 'category-generated-index-slug', slug: 'category-generated-index-slug',
permalink: 'category-generated-index-permalink',
}, },
collapsed: false,
collapsible: false,
items: [{type: 'doc', id: 'doc2'}, ...StaticGeneratedSidebarSlice], items: [{type: 'doc', id: 'doc2'}, ...StaticGeneratedSidebarSlice],
}, },
{type: 'link', href: 'https://facebook.com', label: 'FB'}, {type: 'link', href: 'https://facebook.com', label: 'FB'},
@ -194,20 +195,17 @@ describe('processSidebars', () => {
items: [{type: 'doc', id: 'doc4'}], items: [{type: 'doc', id: 'doc4'}],
}, },
], ],
} as Sidebars); } as ProcessedSidebars);
}); });
test('ensure generated items are normalized', async () => { test('ensure generated items are normalized', async () => {
const sidebarSliceContainingCategoryGeneratedIndex: SidebarItem[] = [ const sidebarSliceContainingCategoryGeneratedIndex: NormalizedSidebar = [
{ {
type: 'category', type: 'category',
label: 'Generated category', label: 'Generated category',
link: { link: {
type: 'generated-index', type: 'generated-index',
slug: 'generated-cat-index-slug', slug: 'generated-cat-index-slug',
// @ts-expect-error: TODO undefined should be allowed here,
// typing error needing refactor
permalink: undefined,
}, },
items: [ items: [
{ {
@ -218,15 +216,19 @@ describe('processSidebars', () => {
}, },
]; ];
const unprocessedSidebars: NormalizedSidebars = { const unprocessedSidebars = {
someSidebar: [{type: 'autogenerated', dirName: 'dir2'}], someSidebar: [{type: 'autogenerated', dirName: 'dir2'}],
}; };
const processedSidebar = await testProcessSidebars(unprocessedSidebars, { const processedSidebar = await testProcessSidebars(
sidebarItemsGenerator: createStaticSidebarItemGenerator( unprocessedSidebars,
sidebarSliceContainingCategoryGeneratedIndex, {},
), {
}); sidebarItemsGenerator: createStaticSidebarItemGenerator(
sidebarSliceContainingCategoryGeneratedIndex,
),
},
);
expect(processedSidebar).toEqual({ expect(processedSidebar).toEqual({
someSidebar: [ someSidebar: [
@ -236,7 +238,6 @@ describe('processSidebars', () => {
link: { link: {
type: 'generated-index', type: 'generated-index',
slug: 'generated-cat-index-slug', slug: 'generated-cat-index-slug',
permalink: '/docs/1.0.0/generated-cat-index-slug',
}, },
items: [ items: [
{ {
@ -244,67 +245,8 @@ describe('processSidebars', () => {
id: 'foo', id: 'foo',
}, },
], ],
collapsible: true,
collapsed: true,
}, },
], ],
} as Sidebars); } as ProcessedSidebars);
});
test('transforms category without subitems', async () => {
const sidebarSlice: SidebarItem[] = [
{
type: 'category',
label: 'Category',
link: {
type: 'generated-index',
permalink: 'generated/permalink',
},
items: [],
},
{
type: 'category',
label: 'Category 2',
link: {
type: 'doc',
id: 'doc ID',
},
items: [],
},
];
const processedSidebar = await testProcessSidebars(
{sidebar: sidebarSlice},
{},
);
expect(processedSidebar).toEqual({
sidebar: [
{
type: 'link',
label: 'Category',
href: 'generated/permalink',
},
{
type: 'doc',
label: 'Category 2',
id: 'doc ID',
},
],
} as Sidebars);
await expect(async () => {
await testProcessSidebars({
sidebar: [
{
type: 'category',
label: 'Bad category',
items: [],
},
],
});
}).rejects.toThrowErrorMatchingInlineSnapshot(
`"Sidebar category Bad category has neither any subitem nor a link. This makes this item not able to link to anything."`,
);
}); });
}); });

View file

@ -6,14 +6,9 @@
*/ */
import {validateSidebars, validateCategoryMetadataFile} from '../validation'; import {validateSidebars, validateCategoryMetadataFile} from '../validation';
import type {CategoryMetadataFile} from '../generator'; import type {SidebarsConfig, CategoryMetadataFile} from '../types';
import type {SidebarsConfig} from '../types';
describe('validateSidebars', () => { describe('validateSidebars', () => {
// TODO add more tests
// TODO it seems many error cases are not validated properly
// and error messages are quite bad
test('throw for bad value', async () => { test('throw for bad value', async () => {
expect(() => validateSidebars({sidebar: [{type: 42}]})) expect(() => validateSidebars({sidebar: [{type: 42}]}))
.toThrowErrorMatchingInlineSnapshot(` .toThrowErrorMatchingInlineSnapshot(`
@ -45,10 +40,193 @@ describe('validateSidebars', () => {
}; };
validateSidebars(sidebars); validateSidebars(sidebars);
}); });
});
describe('html item type', () => { test('sidebar category wrong label', () => {
test('requires a value', () => { expect(() =>
validateSidebars({
docs: [
{
type: 'category',
label: true,
items: [{type: 'doc', id: 'doc1'}],
},
],
}),
).toThrowErrorMatchingInlineSnapshot(`
"{
\\"type\\": \\"category\\",
\\"items\\": [
{
\\"type\\": \\"doc\\",
\\"id\\": \\"doc1\\"
}
],
\\"label\\" [1]: true
}

[1] \\"label\\" must be a string"
`);
});
test('sidebars link wrong label', () => {
expect(() =>
validateSidebars({
docs: [
{
type: 'link',
label: false,
href: 'https://github.com',
},
],
}),
).toThrowErrorMatchingInlineSnapshot(`
"{
\\"type\\": \\"link\\",
\\"href\\": \\"https://github.com\\",
\\"label\\" [1]: false
}

[1] \\"label\\" must be a string"
`);
});
test('sidebars link wrong href', () => {
expect(() =>
validateSidebars({
docs: [
{
type: 'link',
label: 'GitHub',
href: ['example.com'],
},
],
}),
).toThrowErrorMatchingInlineSnapshot(`
"{
\\"type\\": \\"link\\",
\\"label\\": \\"GitHub\\",
\\"href\\" [1]: [
\\"example.com\\"
]
}

[1] \\"href\\" contains an invalid value"
`);
});
test('sidebars with unknown sidebar item type', () => {
expect(() =>
validateSidebars({
docs: [
{
type: 'superman',
},
],
}),
).toThrowErrorMatchingInlineSnapshot(`
"{
\\"type\\": \\"superman\\",
\\"undefined\\" [1]: -- missing --
}

[1] Unknown sidebar item type \\"superman\\"."
`);
});
test('sidebars category missing items', () => {
expect(() =>
validateSidebars({
docs: [
{
type: 'category',
label: 'category',
},
{
type: 'ref',
id: 'hello',
},
],
}),
).toThrowErrorMatchingInlineSnapshot(`
"{
\\"type\\": \\"category\\",
\\"label\\": \\"category\\",
\\"items\\" [1]: -- missing --
}

[1] \\"items\\" is required"
`);
});
test('sidebars category wrong field', () => {
expect(() =>
validateSidebars({
docs: [
{
type: 'category',
label: 'category',
items: [],
href: 'https://google.com',
},
{
type: 'ref',
id: 'hello',
},
],
}),
).toThrowErrorMatchingInlineSnapshot(`
"{
\\"type\\": \\"category\\",
\\"label\\": \\"category\\",
\\"items\\": [],
\\"href\\" [1]: \\"https://google.com\\"
}

[1] \\"href\\" is not allowed"
`);
});
test('sidebar category wrong items', () => {
expect(() =>
validateSidebars({
docs: {
Test: [
{
type: 'category',
label: 'Category Label',
items: 'doc1',
},
],
},
}),
).toThrowErrorMatchingInlineSnapshot(`"sidebar.forEach is not a function"`);
});
test('sidebars item doc but id is not a string', async () => {
expect(() =>
validateSidebars({
docs: [
{
type: 'doc',
id: ['doc1'],
},
],
}),
).toThrowErrorMatchingInlineSnapshot(`
"{
\\"type\\": \\"doc\\",
\\"id\\" [1]: [
\\"doc1\\"
]
}

[1] \\"id\\" must be a string"
`);
});
test('HTML type requires a value', () => {
const sidebars: SidebarsConfig = { const sidebars: SidebarsConfig = {
sidebar1: [ sidebar1: [
{ {
@ -68,7 +246,7 @@ describe('html item type', () => {
`); `);
}); });
test('accepts valid values', () => { test('HTML type accepts valid values', () => {
const sidebars: SidebarsConfig = { const sidebars: SidebarsConfig = {
sidebar1: [ sidebar1: [
{ {

View file

@ -6,12 +6,12 @@
*/ */
import type { import type {
SidebarItem,
SidebarItemDoc, SidebarItemDoc,
SidebarItemCategory,
SidebarItemsGenerator, SidebarItemsGenerator,
SidebarItemsGeneratorDoc, SidebarItemsGeneratorDoc,
SidebarItemCategoryLink, NormalizedSidebarItemCategory,
NormalizedSidebarItem,
SidebarItemCategoryLinkConfig,
} from './types'; } from './types';
import {sortBy, last} from 'lodash'; import {sortBy, last} from 'lodash';
import {addTrailingSlash, posixPath} from '@docusaurus/utils'; import {addTrailingSlash, posixPath} from '@docusaurus/utils';
@ -48,7 +48,6 @@ export const DefaultSidebarItemsGenerator: SidebarItemsGenerator = async ({
numberPrefixParser, numberPrefixParser,
isCategoryIndex, isCategoryIndex,
docs: allDocs, docs: allDocs,
options,
item: {dirName: autogenDir}, item: {dirName: autogenDir},
categoriesMetadata, categoriesMetadata,
}) => { }) => {
@ -125,7 +124,9 @@ export const DefaultSidebarItemsGenerator: SidebarItemsGenerator = async ({
* Step 3. Recursively transform the tree-like structure to sidebar items. * Step 3. Recursively transform the tree-like structure to sidebar items.
* (From a record to an array of items, akin to normalizing shorthand) * (From a record to an array of items, akin to normalizing shorthand)
*/ */
function generateSidebar(fsModel: Dir): Promise<WithPosition<SidebarItem>[]> { function generateSidebar(
fsModel: Dir,
): Promise<WithPosition<NormalizedSidebarItem>[]> {
function createDocItem(id: string): WithPosition<SidebarItemDoc> { function createDocItem(id: string): WithPosition<SidebarItemDoc> {
const { const {
sidebarPosition: position, sidebarPosition: position,
@ -145,7 +146,7 @@ export const DefaultSidebarItemsGenerator: SidebarItemsGenerator = async ({
dir: Dir, dir: Dir,
fullPath: string, fullPath: string,
folderName: string, folderName: string,
): Promise<WithPosition<SidebarItemCategory>> { ): Promise<WithPosition<NormalizedSidebarItemCategory>> {
const categoryMetadata = const categoryMetadata =
categoriesMetadata[posixPath(path.join(autogenDir, fullPath))]; categoriesMetadata[posixPath(path.join(autogenDir, fullPath))];
const className = categoryMetadata?.className; const className = categoryMetadata?.className;
@ -160,18 +161,19 @@ export const DefaultSidebarItemsGenerator: SidebarItemsGenerator = async ({
// using the "local id" (myDoc) or "qualified id" (dirName/myDoc) // using the "local id" (myDoc) or "qualified id" (dirName/myDoc)
function findDocByLocalId(localId: string): SidebarItemDoc | undefined { function findDocByLocalId(localId: string): SidebarItemDoc | undefined {
return allItems.find( return allItems.find(
(item) => item.type === 'doc' && getLocalDocId(item.id) === localId, (item): item is SidebarItemDoc =>
) as SidebarItemDoc | undefined; item.type === 'doc' && getLocalDocId(item.id) === localId,
);
} }
function findConventionalCategoryDocLink(): SidebarItemDoc | undefined { function findConventionalCategoryDocLink(): SidebarItemDoc | undefined {
return allItems.find((item) => { return allItems.find((item): item is SidebarItemDoc => {
if (item.type !== 'doc') { if (item.type !== 'doc') {
return false; return false;
} }
const doc = getDoc(item.id); const doc = getDoc(item.id);
return isCategoryIndex(toCategoryIndexMatcherParam(doc)); return isCategoryIndex(toCategoryIndexMatcherParam(doc));
}) as SidebarItemDoc | undefined; });
} }
function getCategoryLinkedDocId(): string | undefined { function getCategoryLinkedDocId(): string | undefined {
@ -190,13 +192,13 @@ export const DefaultSidebarItemsGenerator: SidebarItemsGenerator = async ({
const categoryLinkedDocId = getCategoryLinkedDocId(); const categoryLinkedDocId = getCategoryLinkedDocId();
const link: SidebarItemCategoryLink | undefined = categoryLinkedDocId const link: SidebarItemCategoryLinkConfig | null | undefined =
? { categoryLinkedDocId
type: 'doc', ? {
id: categoryLinkedDocId, // We "remap" a potentially "local id" to a "qualified id" type: 'doc',
} id: categoryLinkedDocId, // We "remap" a potentially "local id" to a "qualified id"
: // TODO typing issue }
(categoryMetadata?.link as SidebarItemCategoryLink | undefined); : categoryMetadata?.link;
// If a doc is linked, remove it from the category subItems // If a doc is linked, remove it from the category subItems
const items = allItems.filter( const items = allItems.filter(
@ -206,9 +208,8 @@ export const DefaultSidebarItemsGenerator: SidebarItemsGenerator = async ({
return { return {
type: 'category', type: 'category',
label: categoryMetadata?.label ?? filename, label: categoryMetadata?.label ?? filename,
collapsible: collapsible: categoryMetadata?.collapsible,
categoryMetadata?.collapsible ?? options.sidebarCollapsible, collapsed: categoryMetadata?.collapsed,
collapsed: categoryMetadata?.collapsed ?? options.sidebarCollapsed,
position: categoryMetadata?.position ?? numberPrefix, position: categoryMetadata?.position ?? numberPrefix,
...(className !== undefined && {className}), ...(className !== undefined && {className}),
items, items,
@ -219,7 +220,7 @@ export const DefaultSidebarItemsGenerator: SidebarItemsGenerator = async ({
dir: Dir | null, // The directory item to be transformed. 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`. 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. fullPath: string, // `dir`'s full path relative to the autogen dir.
): Promise<WithPosition<SidebarItem>> { ): Promise<WithPosition<NormalizedSidebarItem>> {
return dir return dir
? createCategoryItem(dir, fullPath, itemKey) ? createCategoryItem(dir, fullPath, itemKey)
: createDocItem(itemKey.substring(docIdPrefix.length)); : createDocItem(itemKey.substring(docIdPrefix.length));
@ -238,7 +239,9 @@ export const DefaultSidebarItemsGenerator: SidebarItemsGenerator = async ({
* consecutive sidebar slices (i.e. a whole category composed of multiple * consecutive sidebar slices (i.e. a whole category composed of multiple
* autogenerated items) * autogenerated items)
*/ */
function sortItems(sidebarItems: WithPosition<SidebarItem>[]): SidebarItem[] { function sortItems(
sidebarItems: WithPosition<NormalizedSidebarItem>[],
): NormalizedSidebarItem[] {
const processedSidebarItems = sidebarItems.map((item) => { const processedSidebarItems = sidebarItems.map((item) => {
if (item.type === 'category') { if (item.type === 'category') {
return {...item, items: sortItems(item.items)}; return {...item, items: sortItems(item.items)};

View file

@ -7,13 +7,13 @@
import fs from 'fs-extra'; import fs from 'fs-extra';
import importFresh from 'import-fresh'; import importFresh from 'import-fresh';
import type {SidebarsConfig, Sidebars, NormalizedSidebars} from './types'; import type {SidebarsConfig, Sidebars, SidebarProcessorParams} from './types';
import type {NormalizeSidebarsParams} from '../types';
import {validateSidebars, validateCategoryMetadataFile} from './validation'; import {validateSidebars, validateCategoryMetadataFile} from './validation';
import {normalizeSidebars} from './normalization'; import {normalizeSidebars} from './normalization';
import {processSidebars, type SidebarProcessorParams} from './processor'; import {processSidebars} from './processor';
import {postProcessSidebars} from './postProcessor';
import path from 'path'; import path from 'path';
import {createSlugger, Globby} from '@docusaurus/utils'; import {Globby} from '@docusaurus/utils';
import logger from '@docusaurus/logger'; import logger from '@docusaurus/logger';
import type {PluginOptions} from '@docusaurus/plugin-content-docs'; import type {PluginOptions} from '@docusaurus/plugin-content-docs';
import Yaml from 'js-yaml'; import Yaml from 'js-yaml';
@ -69,7 +69,7 @@ async function readCategoriesMetadata(contentPath: string) {
); );
} }
async function loadSidebarsFileUnsafe( export async function loadSidebarsFileUnsafe(
sidebarFilePath: string | false | undefined, sidebarFilePath: string | false | undefined,
): Promise<SidebarsConfig> { ): Promise<SidebarsConfig> {
// false => no sidebars // false => no sidebars
@ -93,37 +93,21 @@ async function loadSidebarsFileUnsafe(
return importFresh(sidebarFilePath); return importFresh(sidebarFilePath);
} }
export async function loadSidebarsFile(
sidebarFilePath: string | false | undefined,
): Promise<SidebarsConfig> {
const sidebarsConfig = await loadSidebarsFileUnsafe(sidebarFilePath);
validateSidebars(sidebarsConfig);
return sidebarsConfig;
}
export async function loadNormalizedSidebars(
sidebarFilePath: string | false | undefined,
params: NormalizeSidebarsParams,
): Promise<NormalizedSidebars> {
return normalizeSidebars(await loadSidebarsFile(sidebarFilePath), params);
}
// Note: sidebarFilePath must be absolute, use resolveSidebarPathOption // Note: sidebarFilePath must be absolute, use resolveSidebarPathOption
export async function loadSidebars( export async function loadSidebars(
sidebarFilePath: string | false | undefined, sidebarFilePath: string | false | undefined,
options: Omit<SidebarProcessorParams, 'categoriesMetadata'>, options: SidebarProcessorParams,
): Promise<Sidebars> { ): Promise<Sidebars> {
const normalizeSidebarsParams: NormalizeSidebarsParams = { const sidebarsConfig = await loadSidebarsFileUnsafe(sidebarFilePath);
...options.sidebarOptions, const normalizedSidebars = normalizeSidebars(sidebarsConfig);
version: options.version, validateSidebars(normalizedSidebars);
categoryLabelSlugger: createSlugger(),
};
const normalizedSidebars = await loadNormalizedSidebars(
sidebarFilePath,
normalizeSidebarsParams,
);
const categoriesMetadata = await readCategoriesMetadata( const categoriesMetadata = await readCategoriesMetadata(
options.version.contentPath, options.version.contentPath,
); );
return processSidebars(normalizedSidebars, {...options, categoriesMetadata}); const processedSidebars = await processSidebars(
normalizedSidebars,
categoriesMetadata,
options,
);
return postProcessSidebars(processedSidebars, options);
} }

View file

@ -5,7 +5,6 @@
* LICENSE file in the root directory of this source tree. * LICENSE file in the root directory of this source tree.
*/ */
import type {NormalizeSidebarsParams} from '../types';
import type { import type {
NormalizedSidebarItem, NormalizedSidebarItem,
NormalizedSidebar, NormalizedSidebar,
@ -15,41 +14,16 @@ import type {
SidebarItemConfig, SidebarItemConfig,
SidebarConfig, SidebarConfig,
SidebarsConfig, SidebarsConfig,
SidebarItemCategoryLink,
NormalizedSidebarItemCategory, NormalizedSidebarItemCategory,
} from './types'; } from './types';
import {isCategoriesShorthand} from './utils'; import {isCategoriesShorthand} from './utils';
import {mapValues} from 'lodash'; import {mapValues} from 'lodash';
import {normalizeUrl} from '@docusaurus/utils';
import type {SidebarOptions} from '@docusaurus/plugin-content-docs';
function normalizeCategoryLink(
category: SidebarItemCategoryConfig,
params: NormalizeSidebarsParams,
): SidebarItemCategoryLink | undefined {
if (category.link?.type === 'generated-index') {
// default slug logic can be improved
const getDefaultSlug = () =>
`/category/${params.categoryLabelSlugger.slug(category.label)}`;
const slug = category.link.slug ?? getDefaultSlug();
const permalink = normalizeUrl([params.version.versionPath, slug]);
return {
...category.link,
slug,
permalink,
};
}
return category.link;
}
function normalizeCategoriesShorthand( function normalizeCategoriesShorthand(
sidebar: SidebarCategoriesShorthand, sidebar: SidebarCategoriesShorthand,
options: SidebarOptions,
): SidebarItemCategoryConfig[] { ): SidebarItemCategoryConfig[] {
return Object.entries(sidebar).map(([label, items]) => ({ return Object.entries(sidebar).map(([label, items]) => ({
type: 'category', type: 'category',
collapsed: options.sidebarCollapsed,
collapsible: options.sidebarCollapsible,
label, label,
items, items,
})); }));
@ -61,7 +35,6 @@ function normalizeCategoriesShorthand(
*/ */
export function normalizeItem( export function normalizeItem(
item: SidebarItemConfig, item: SidebarItemConfig,
options: NormalizeSidebarsParams,
): NormalizedSidebarItem[] { ): NormalizedSidebarItem[] {
if (typeof item === 'string') { if (typeof item === 'string') {
return [ return [
@ -72,42 +45,35 @@ export function normalizeItem(
]; ];
} }
if (isCategoriesShorthand(item)) { if (isCategoriesShorthand(item)) {
return normalizeCategoriesShorthand(item, options).flatMap((subItem) => return normalizeCategoriesShorthand(item).flatMap((subItem) =>
normalizeItem(subItem, options), normalizeItem(subItem),
); );
} }
if (item.type === 'category') { if (item.type === 'category') {
const link = normalizeCategoryLink(item, options); if (typeof item.items !== 'undefined' && !Array.isArray(item.items)) {
throw new Error(
`Invalid category ${JSON.stringify(item)}: items must be an array`,
);
}
const normalizedCategory: NormalizedSidebarItemCategory = { const normalizedCategory: NormalizedSidebarItemCategory = {
...item, ...item,
link, items: item.items.flatMap((subItem) => normalizeItem(subItem)),
items: (item.items ?? []).flatMap((subItem) =>
normalizeItem(subItem, options),
),
collapsible: item.collapsible ?? options.sidebarCollapsible,
collapsed: item.collapsed ?? options.sidebarCollapsed,
}; };
return [normalizedCategory]; return [normalizedCategory];
} }
return [item]; return [item];
} }
function normalizeSidebar( function normalizeSidebar(sidebar: SidebarConfig): NormalizedSidebar {
sidebar: SidebarConfig,
options: NormalizeSidebarsParams,
): NormalizedSidebar {
const normalizedSidebar = Array.isArray(sidebar) const normalizedSidebar = Array.isArray(sidebar)
? sidebar ? sidebar
: normalizeCategoriesShorthand(sidebar, options); : normalizeCategoriesShorthand(sidebar);
return normalizedSidebar.flatMap((subItem) => return normalizedSidebar.flatMap((subItem) => normalizeItem(subItem));
normalizeItem(subItem, options),
);
} }
export function normalizeSidebars( export function normalizeSidebars(
sidebars: SidebarsConfig, sidebars: SidebarsConfig,
params: NormalizeSidebarsParams,
): NormalizedSidebars { ): NormalizedSidebars {
return mapValues(sidebars, (items) => normalizeSidebar(items, params)); return mapValues(sidebars, normalizeSidebar);
} }

View file

@ -0,0 +1,94 @@
/**
* 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 {normalizeUrl} from '@docusaurus/utils';
import type {
SidebarItem,
Sidebars,
SidebarProcessorParams,
ProcessedSidebarItemCategory,
ProcessedSidebarItem,
ProcessedSidebars,
SidebarItemCategoryLink,
} from './types';
import {mapValues} from 'lodash';
function normalizeCategoryLink(
category: ProcessedSidebarItemCategory,
params: SidebarProcessorParams,
): SidebarItemCategoryLink | undefined {
if (category.link?.type === 'generated-index') {
// default slug logic can be improved
const getDefaultSlug = () =>
`/category/${params.categoryLabelSlugger.slug(category.label)}`;
const slug = category.link.slug ?? getDefaultSlug();
const permalink = normalizeUrl([params.version.versionPath, slug]);
return {
...category.link,
slug,
permalink,
};
}
return category.link;
}
function postProcessSidebarItem(
item: ProcessedSidebarItem,
params: SidebarProcessorParams,
): SidebarItem {
if (item.type === 'category') {
const category = {
...item,
collapsed: item.collapsed ?? params.sidebarOptions.sidebarCollapsed,
collapsible: item.collapsible ?? params.sidebarOptions.sidebarCollapsible,
link: normalizeCategoryLink(item, params),
items: item.items.map((subItem) =>
postProcessSidebarItem(subItem, params),
),
};
// If the current category doesn't have subitems, we render a normal link
// instead.
if (category.items.length === 0) {
if (!category.link) {
throw new Error(
`Sidebar category ${item.label} has neither any subitem nor a link. This makes this item not able to link to anything.`,
);
}
switch (category.link.type) {
case 'doc':
return {
type: 'doc',
label: category.label,
id: category.link.id,
};
case 'generated-index':
return {
type: 'link',
label: category.label,
href: category.link.permalink,
};
default:
throw new Error('Unexpected sidebar category link type');
}
}
// A non-collapsible category can't be collapsed!
if (category.collapsible === false) {
category.collapsed = false;
}
return category;
}
return item;
}
export function postProcessSidebars(
sidebars: ProcessedSidebars,
params: SidebarProcessorParams,
): Sidebars {
return mapValues(sidebars, (sidebar) =>
sidebar.map((item) => postProcessSidebarItem(item, params)),
);
}

View file

@ -7,41 +7,23 @@
import type {DocMetadataBase, VersionMetadata} from '../types'; import type {DocMetadataBase, VersionMetadata} from '../types';
import type { import type {
Sidebars,
Sidebar,
SidebarItem,
NormalizedSidebarItem, NormalizedSidebarItem,
NormalizedSidebar, NormalizedSidebar,
NormalizedSidebars, NormalizedSidebars,
SidebarItemsGeneratorOption,
SidebarItemsGeneratorDoc, SidebarItemsGeneratorDoc,
SidebarItemsGeneratorVersion, SidebarItemsGeneratorVersion,
NormalizedSidebarItemCategory,
SidebarItemCategory,
SidebarItemAutogenerated, SidebarItemAutogenerated,
ProcessedSidebarItem,
ProcessedSidebar,
ProcessedSidebars,
SidebarProcessorParams,
CategoryMetadataFile, CategoryMetadataFile,
} from './types'; } from './types';
import {transformSidebarItems} from './utils';
import {DefaultSidebarItemsGenerator} from './generator'; import {DefaultSidebarItemsGenerator} from './generator';
import {validateSidebars} from './validation';
import {mapValues, memoize, pick} from 'lodash'; import {mapValues, memoize, pick} from 'lodash';
import combinePromises from 'combine-promises'; import combinePromises from 'combine-promises';
import {normalizeItem} from './normalization';
import {isCategoryIndex} from '../docs'; import {isCategoryIndex} from '../docs';
import type {Slugger} from '@docusaurus/utils';
import type {
NumberPrefixParser,
SidebarOptions,
} from '@docusaurus/plugin-content-docs';
export type SidebarProcessorParams = {
sidebarItemsGenerator: SidebarItemsGeneratorOption;
numberPrefixParser: NumberPrefixParser;
docs: DocMetadataBase[];
version: VersionMetadata;
categoryLabelSlugger: Slugger;
sidebarOptions: SidebarOptions;
categoriesMetadata: Record<string, CategoryMetadataFile>;
};
function toSidebarItemsGeneratorDoc( function toSidebarItemsGeneratorDoc(
doc: DocMetadataBase, doc: DocMetadataBase,
@ -66,15 +48,15 @@ function toSidebarItemsGeneratorVersion(
// post-processing checks // post-processing checks
async function processSidebar( async function processSidebar(
unprocessedSidebar: NormalizedSidebar, unprocessedSidebar: NormalizedSidebar,
categoriesMetadata: Record<string, CategoryMetadataFile>,
params: SidebarProcessorParams, params: SidebarProcessorParams,
): Promise<Sidebar> { ): Promise<ProcessedSidebar> {
const { const {
sidebarItemsGenerator, sidebarItemsGenerator,
numberPrefixParser, numberPrefixParser,
docs, docs,
version, version,
sidebarOptions, sidebarOptions,
categoriesMetadata,
} = params; } = params;
// Just a minor lazy transformation optimization // Just a minor lazy transformation optimization
@ -83,20 +65,9 @@ async function processSidebar(
version: toSidebarItemsGeneratorVersion(version), version: toSidebarItemsGeneratorVersion(version),
})); }));
async function processCategoryItem(
item: NormalizedSidebarItemCategory,
): Promise<SidebarItemCategory> {
return {
...item,
items: (await Promise.all(item.items.map(processItem))).flat(),
};
}
async function processAutoGeneratedItem( async function processAutoGeneratedItem(
item: SidebarItemAutogenerated, item: SidebarItemAutogenerated,
): Promise<SidebarItem[]> { ): Promise<ProcessedSidebarItem[]> {
// TODO the returned type can't be trusted in practice (generator can be
// user-provided)
const generatedItems = await sidebarItemsGenerator({ const generatedItems = await sidebarItemsGenerator({
item, item,
numberPrefixParser, numberPrefixParser,
@ -106,50 +77,23 @@ async function processSidebar(
options: sidebarOptions, options: sidebarOptions,
categoriesMetadata, categoriesMetadata,
}); });
// TODO validate generated items: user can generate bad items
const generatedItemsNormalized = generatedItems.flatMap((generatedItem) =>
normalizeItem(generatedItem, {...params, ...sidebarOptions}),
);
// Process again... weird but sidebar item generated might generate some // Process again... weird but sidebar item generated might generate some
// auto-generated items? // auto-generated items?
return processItems(generatedItemsNormalized); // TODO repeatedly process & unwrap autogenerated items until there are no
// more autogenerated items, or when loop count (e.g. 10) is reached
return processItems(generatedItems);
} }
async function processItem( async function processItem(
item: NormalizedSidebarItem, item: NormalizedSidebarItem,
): Promise<SidebarItem[]> { ): Promise<ProcessedSidebarItem[]> {
if (item.type === 'category') { if (item.type === 'category') {
// If the current category doesn't have subitems, we render a normal doc link instead. return [
if (item.items.length === 0) { {
if (!item.link) { ...item,
throw new Error( items: (await Promise.all(item.items.map(processItem))).flat(),
`Sidebar category ${item.label} has neither any subitem nor a link. This makes this item not able to link to anything.`, },
); ];
}
switch (item.link.type) {
case 'doc':
return [
{
type: 'doc',
label: item.label,
id: item.link.id,
},
];
case 'generated-index':
return [
{
type: 'link',
label: item.label,
href: item.link.permalink,
},
];
default:
throw new Error('Unexpected sidebar category link type');
}
}
return [await processCategoryItem(item)];
} }
if (item.type === 'autogenerated') { if (item.type === 'autogenerated') {
return processAutoGeneratedItem(item); return processAutoGeneratedItem(item);
@ -159,32 +103,24 @@ async function processSidebar(
async function processItems( async function processItems(
items: NormalizedSidebarItem[], items: NormalizedSidebarItem[],
): Promise<SidebarItem[]> { ): Promise<ProcessedSidebarItem[]> {
return (await Promise.all(items.map(processItem))).flat(); return (await Promise.all(items.map(processItem))).flat();
} }
const processedSidebar = await processItems(unprocessedSidebar); const processedSidebar = await processItems(unprocessedSidebar);
return processedSidebar;
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( export async function processSidebars(
unprocessedSidebars: NormalizedSidebars, unprocessedSidebars: NormalizedSidebars,
categoriesMetadata: Record<string, CategoryMetadataFile>,
params: SidebarProcessorParams, params: SidebarProcessorParams,
): Promise<Sidebars> { ): Promise<ProcessedSidebars> {
return combinePromises( const processedSidebars = await combinePromises(
mapValues(unprocessedSidebars, (unprocessedSidebar) => mapValues(unprocessedSidebars, (unprocessedSidebar) =>
processSidebar(unprocessedSidebar, params), processSidebar(unprocessedSidebar, categoriesMetadata, params),
), ),
); );
validateSidebars(processedSidebars);
return processedSidebars;
} }

View file

@ -12,6 +12,7 @@ import type {
SidebarOptions, SidebarOptions,
CategoryIndexMatcher, CategoryIndexMatcher,
} from '@docusaurus/plugin-content-docs'; } from '@docusaurus/plugin-content-docs';
import type {Slugger} from '@docusaurus/utils';
// Makes all properties visible when hovering over the type // Makes all properties visible when hovering over the type
type Expand<T extends Record<string, unknown>> = {[P in keyof T]: T[P]}; type Expand<T extends Record<string, unknown>> = {[P in keyof T]: T[P]};
@ -107,9 +108,9 @@ export type SidebarsConfig = {
// Normalized but still has 'autogenerated', which will be handled in processing // Normalized but still has 'autogenerated', which will be handled in processing
export type NormalizedSidebarItemCategory = Expand< export type NormalizedSidebarItemCategory = Expand<
SidebarItemCategoryBase & { Optional<SidebarItemCategoryBase, 'collapsed' | 'collapsible'> & {
items: NormalizedSidebarItem[]; items: NormalizedSidebarItem[];
link?: SidebarItemCategoryLink; link?: SidebarItemCategoryLinkConfig;
} }
>; >;
@ -125,6 +126,22 @@ export type NormalizedSidebars = {
[sidebarId: string]: NormalizedSidebar; [sidebarId: string]: NormalizedSidebar;
}; };
export type ProcessedSidebarItemCategory = Expand<
Optional<SidebarItemCategoryBase, 'collapsed' | 'collapsible'> & {
items: ProcessedSidebarItem[];
link?: SidebarItemCategoryLinkConfig;
}
>;
export type ProcessedSidebarItem =
| SidebarItemDoc
| SidebarItemHtml
| SidebarItemLink
| ProcessedSidebarItemCategory;
export type ProcessedSidebar = ProcessedSidebarItem[];
export type ProcessedSidebars = {
[sidebarId: string]: ProcessedSidebar;
};
export type SidebarItemCategory = Expand< export type SidebarItemCategory = Expand<
SidebarItemCategoryBase & { SidebarItemCategoryBase & {
items: SidebarItem[]; items: SidebarItem[];
@ -230,9 +247,7 @@ export type SidebarItemsGeneratorArgs = {
}; };
export type SidebarItemsGenerator = ( export type SidebarItemsGenerator = (
generatorArgs: SidebarItemsGeneratorArgs, generatorArgs: SidebarItemsGeneratorArgs,
) => // TODO TS issue: the generator can generate un-normalized items! ) => Promise<NormalizedSidebar>;
Promise<SidebarItem[]>;
// Promise<SidebarItemConfig[]>;
// Also inject the default generator to conveniently wrap/enhance/sort the // Also inject the default generator to conveniently wrap/enhance/sort the
// default sidebar gen logic // default sidebar gen logic
@ -242,4 +257,13 @@ export type SidebarItemsGeneratorOptionArgs = {
} & SidebarItemsGeneratorArgs; } & SidebarItemsGeneratorArgs;
export type SidebarItemsGeneratorOption = ( export type SidebarItemsGeneratorOption = (
generatorArgs: SidebarItemsGeneratorOptionArgs, generatorArgs: SidebarItemsGeneratorOptionArgs,
) => Promise<SidebarItem[]>; ) => Promise<NormalizedSidebarItem[]>;
export type SidebarProcessorParams = {
sidebarItemsGenerator: SidebarItemsGeneratorOption;
numberPrefixParser: NumberPrefixParser;
docs: DocMetadataBase[];
version: VersionMetadata;
categoryLabelSlugger: Slugger;
sidebarOptions: SidebarOptions;
};

View file

@ -8,7 +8,6 @@
import {Joi, URISchema} from '@docusaurus/utils-validation'; import {Joi, URISchema} from '@docusaurus/utils-validation';
import type { import type {
SidebarItemConfig, SidebarItemConfig,
SidebarCategoriesShorthand,
SidebarItemBase, SidebarItemBase,
SidebarItemAutogenerated, SidebarItemAutogenerated,
SidebarItemDoc, SidebarItemDoc,
@ -16,12 +15,13 @@ import type {
SidebarItemLink, SidebarItemLink,
SidebarItemCategoryConfig, SidebarItemCategoryConfig,
SidebarItemCategoryLink, SidebarItemCategoryLink,
SidebarsConfig,
SidebarItemCategoryLinkDoc, SidebarItemCategoryLinkDoc,
SidebarItemCategoryLinkGeneratedIndex, SidebarItemCategoryLinkGeneratedIndex,
NormalizedSidebars,
NormalizedSidebarItem,
NormalizedSidebarItemCategory,
CategoryMetadataFile, CategoryMetadataFile,
} from './types'; } from './types';
import {isCategoriesShorthand} from './utils';
// NOTE: we don't add any default values during validation on purpose! // NOTE: we don't add any default values during validation on purpose!
// Config types are exposed to users for typechecking and we use the same type // Config types are exposed to users for typechecking and we use the same type
@ -52,7 +52,7 @@ const sidebarItemDocSchema = sidebarItemBaseSchema.append<SidebarItemDoc>({
const sidebarItemHtmlSchema = sidebarItemBaseSchema.append<SidebarItemHtml>({ const sidebarItemHtmlSchema = sidebarItemBaseSchema.append<SidebarItemHtml>({
type: 'html', type: 'html',
value: Joi.string().required(), value: Joi.string().required(),
defaultStyle: Joi.boolean().default(false), defaultStyle: Joi.boolean(),
}); });
const sidebarItemLinkSchema = sidebarItemBaseSchema.append<SidebarItemLink>({ const sidebarItemLinkSchema = sidebarItemBaseSchema.append<SidebarItemLink>({
@ -88,14 +88,13 @@ const sidebarItemCategoryLinkSchema = Joi.object<SidebarItemCategoryLink>()
}), }),
}, },
{ {
is: Joi.string().required(), is: Joi.required(),
then: Joi.forbidden().messages({ then: Joi.forbidden().messages({
'any.unknown': 'Unknown sidebar category link type "{.type}".', 'any.unknown': 'Unknown sidebar category link type "{.type}".',
}), }),
}, },
], ],
}) });
.id('sidebarCategoryLinkSchema');
const sidebarItemCategorySchema = const sidebarItemCategorySchema =
sidebarItemBaseSchema.append<SidebarItemCategoryConfig>({ sidebarItemBaseSchema.append<SidebarItemCategoryConfig>({
@ -103,10 +102,11 @@ const sidebarItemCategorySchema =
label: Joi.string() label: Joi.string()
.required() .required()
.messages({'any.unknown': '"label" must be a string'}), .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() items: Joi.array()
.required() .required()
.messages({'any.unknown': '"items" must be an array'}), // .items(Joi.link('#sidebarItemSchema')), .messages({'any.unknown': '"items" must be an array'}),
// TODO: Joi doesn't allow mutual recursion. See https://github.com/sideway/joi/issues/2611
// .items(Joi.link('#sidebarItemSchema')),
link: sidebarItemCategoryLinkSchema, link: sidebarItemCategoryLinkSchema,
collapsed: Joi.boolean().messages({ collapsed: Joi.boolean().messages({
'any.unknown': '"collapsed" must be a boolean', 'any.unknown': '"collapsed" must be a boolean',
@ -116,56 +116,44 @@ const sidebarItemCategorySchema =
}), }),
}); });
const sidebarItemSchema: Joi.Schema<SidebarItemConfig> = Joi.object() const sidebarItemSchema = Joi.object<SidebarItemConfig>().when('.type', {
.when('.type', { switch: [
switch: [ {is: 'link', then: sidebarItemLinkSchema},
{is: 'link', then: sidebarItemLinkSchema}, {
{ is: Joi.string().valid('doc', 'ref').required(),
is: Joi.string().valid('doc', 'ref').required(), then: sidebarItemDocSchema,
then: sidebarItemDocSchema, },
}, {is: 'html', then: sidebarItemHtmlSchema},
{is: 'html', then: sidebarItemHtmlSchema}, {is: 'autogenerated', then: sidebarItemAutogeneratedSchema},
{is: 'autogenerated', then: sidebarItemAutogeneratedSchema}, {is: 'category', then: sidebarItemCategorySchema},
{is: 'category', then: sidebarItemCategorySchema}, {
{ is: Joi.any().required(),
is: Joi.any().required(), then: Joi.forbidden().messages({
then: Joi.forbidden().messages({ 'any.unknown': 'Unknown sidebar item type "{.type}".',
'any.unknown': 'Unknown sidebar item type "{.type}".', }),
}), },
}, ],
], });
}) // .id('sidebarItemSchema');
.id('sidebarItemSchema');
function validateSidebarItem(item: unknown): asserts item is SidebarItemConfig { function validateSidebarItem(
if (typeof item === 'string') { item: unknown,
return; ): asserts item is NormalizedSidebarItem {
}
// TODO: remove once with proper Joi support // TODO: remove once with proper Joi support
// Because we can't use Joi to validate nested items (see above), we do it // Because we can't use Joi to validate nested items (see above), we do it
// manually // manually
if (isCategoriesShorthand(item as SidebarItemConfig)) { Joi.assert(item, sidebarItemSchema);
Object.values(item as SidebarCategoriesShorthand).forEach((category) =>
category.forEach(validateSidebarItem),
);
} else {
Joi.assert(item, sidebarItemSchema);
if ((item as SidebarItemCategoryConfig).type === 'category') { if ((item as NormalizedSidebarItemCategory).type === 'category') {
(item as SidebarItemCategoryConfig).items.forEach(validateSidebarItem); (item as NormalizedSidebarItemCategory).items.forEach(validateSidebarItem);
}
} }
} }
export function validateSidebars( export function validateSidebars(
sidebars: unknown, sidebars: Record<string, unknown>,
): asserts sidebars is SidebarsConfig { ): asserts sidebars is NormalizedSidebars {
Object.values(sidebars as SidebarsConfig).forEach((sidebar) => { Object.values(sidebars as NormalizedSidebars).forEach((sidebar) => {
if (Array.isArray(sidebar)) { sidebar.forEach(validateSidebarItem);
sidebar.forEach(validateSidebarItem);
} else {
validateSidebarItem(sidebar);
}
}); });
} }

View file

@ -8,15 +8,12 @@
/// <reference types="@docusaurus/module-type-aliases" /> /// <reference types="@docusaurus/module-type-aliases" />
import type {Sidebars} from './sidebars/types'; import type {Sidebars} from './sidebars/types';
import type {Tag, FrontMatterTag, Slugger} from '@docusaurus/utils'; import type {Tag, FrontMatterTag} from '@docusaurus/utils';
import type { import type {
BrokenMarkdownLink as IBrokenMarkdownLink, BrokenMarkdownLink as IBrokenMarkdownLink,
ContentPaths, ContentPaths,
} from '@docusaurus/utils/lib/markdownLinks'; } from '@docusaurus/utils/lib/markdownLinks';
import type { import type {VersionBanner} from '@docusaurus/plugin-content-docs';
VersionBanner,
SidebarOptions,
} from '@docusaurus/plugin-content-docs';
export type DocFile = { export type DocFile = {
contentPath: string; // /!\ may be localized contentPath: string; // /!\ may be localized
@ -41,11 +38,6 @@ export type VersionMetadata = ContentPaths & {
routePriority: number | undefined; // -1 for the latest docs routePriority: number | undefined; // -1 for the latest docs
}; };
export type NormalizeSidebarsParams = SidebarOptions & {
version: VersionMetadata;
categoryLabelSlugger: Slugger;
};
export type LastUpdateData = { export type LastUpdateData = {
lastUpdatedAt?: number; lastUpdatedAt?: number;
formattedLastUpdatedAt?: string; formattedLastUpdatedAt?: string;