mirror of
https://github.com/facebook/docusaurus.git
synced 2025-05-10 07:37:19 +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
|
@ -9,7 +9,10 @@
|
|||
Create as many sidebars as you want.
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
// @ts-check
|
||||
|
||||
/** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */
|
||||
const sidebars = {
|
||||
// By default, Docusaurus generates a sidebar from the docs folder structure
|
||||
tutorialSidebar: [{type: 'autogenerated', dirName: '.'}],
|
||||
|
||||
|
@ -24,3 +27,5 @@ module.exports = {
|
|||
],
|
||||
*/
|
||||
};
|
||||
|
||||
module.exports = sidebars;
|
||||
|
|
|
@ -22,7 +22,8 @@
|
|||
"@types/js-yaml": "^4.0.0",
|
||||
"@types/picomatch": "^2.2.1",
|
||||
"commander": "^5.1.0",
|
||||
"picomatch": "^2.1.1"
|
||||
"picomatch": "^2.1.1",
|
||||
"utility-types": "^3.10.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@docusaurus/core": "2.0.0-beta.6",
|
||||
|
|
|
@ -22,17 +22,16 @@ import {DEFAULT_PLUGIN_ID} from '@docusaurus/core/lib/constants';
|
|||
import * as cliDocs from '../cli';
|
||||
import {OptionsSchema} from '../options';
|
||||
import {normalizePluginOptions} from '@docusaurus/utils-validation';
|
||||
import {
|
||||
DocMetadata,
|
||||
LoadedVersion,
|
||||
import type {DocMetadata, LoadedVersion} from '../types';
|
||||
import type {
|
||||
SidebarItem,
|
||||
SidebarItemsGeneratorOption,
|
||||
SidebarItemsGeneratorOptionArgs,
|
||||
} from '../types';
|
||||
} from '../sidebars/types';
|
||||
import {toSidebarsProp} from '../props';
|
||||
|
||||
import {validate} from 'webpack';
|
||||
import {DefaultSidebarItemsGenerator} from '../sidebarItemsGenerator';
|
||||
import {DefaultSidebarItemsGenerator} from '../sidebars/generator';
|
||||
import {DisabledSidebars} from '../sidebars';
|
||||
|
||||
function findDocById(version: LoadedVersion, unversionedId: string) {
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
import {OptionsSchema, DEFAULT_OPTIONS, validateOptions} from '../options';
|
||||
import {normalizePluginOptions} from '@docusaurus/utils-validation';
|
||||
import {DefaultSidebarItemsGenerator} from '../sidebarItemsGenerator';
|
||||
import {DefaultSidebarItemsGenerator} from '../sidebars/generator';
|
||||
import {
|
||||
DefaultNumberPrefixParser,
|
||||
DisabledNumberPrefixParser,
|
||||
|
|
|
@ -1,741 +0,0 @@
|
|||
/**
|
||||
* 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 {
|
||||
loadSidebars,
|
||||
collectSidebarDocItems,
|
||||
collectSidebarsDocIds,
|
||||
createSidebarsUtils,
|
||||
collectSidebarCategories,
|
||||
collectSidebarLinks,
|
||||
transformSidebarItems,
|
||||
processSidebars,
|
||||
DefaultSidebars,
|
||||
DisabledSidebars,
|
||||
fixSidebarItemInconsistencies,
|
||||
} from '../sidebars';
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarItem,
|
||||
SidebarItemsGenerator,
|
||||
Sidebars,
|
||||
UnprocessedSidebars,
|
||||
SidebarOptions,
|
||||
SidebarItemCategory,
|
||||
} from '../types';
|
||||
import {DefaultSidebarItemsGenerator} from '../sidebarItemsGenerator';
|
||||
|
||||
describe('loadSidebars', () => {
|
||||
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 = loadSidebars(sidebarPath, options);
|
||||
expect(result).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('sidebars with deep level of category', async () => {
|
||||
const sidebarPath = path.join(fixtureDir, 'sidebars-category.js');
|
||||
const result = loadSidebars(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 = loadSidebars(sidebarPath1, options);
|
||||
const sidebar2 = loadSidebars(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(() =>
|
||||
loadSidebars(sidebarPath, options),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Error loading {\\"type\\":\\"category\\",\\"label\\":\\"Category Label\\",\\"items\\":\\"doc1\\"}: \\"items\\" must be an array."`,
|
||||
);
|
||||
});
|
||||
|
||||
test('sidebars with category but category label is not a string', async () => {
|
||||
const sidebarPath = path.join(
|
||||
fixtureDir,
|
||||
'sidebars-category-wrong-label.json',
|
||||
);
|
||||
expect(() =>
|
||||
loadSidebars(sidebarPath, options),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Error loading {\\"type\\":\\"category\\",\\"label\\":true,\\"items\\":[\\"doc1\\"]}: \\"label\\" must be a string."`,
|
||||
);
|
||||
});
|
||||
|
||||
test('sidebars item doc but id is not a string', async () => {
|
||||
const sidebarPath = path.join(
|
||||
fixtureDir,
|
||||
'sidebars-doc-id-not-string.json',
|
||||
);
|
||||
expect(() =>
|
||||
loadSidebars(sidebarPath, options),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Error loading {\\"type\\":\\"doc\\",\\"id\\":[\\"doc1\\"]}: \\"id\\" must be a string."`,
|
||||
);
|
||||
});
|
||||
|
||||
test('sidebars with first level not a category', async () => {
|
||||
const sidebarPath = path.join(
|
||||
fixtureDir,
|
||||
'sidebars-first-level-not-category.js',
|
||||
);
|
||||
const result = loadSidebars(sidebarPath, options);
|
||||
expect(result).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('sidebars link', async () => {
|
||||
const sidebarPath = path.join(fixtureDir, 'sidebars-link.json');
|
||||
const result = loadSidebars(sidebarPath, options);
|
||||
expect(result).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('sidebars link wrong label', async () => {
|
||||
const sidebarPath = path.join(fixtureDir, 'sidebars-link-wrong-label.json');
|
||||
expect(() =>
|
||||
loadSidebars(sidebarPath, options),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Error loading {\\"type\\":\\"link\\",\\"label\\":false,\\"href\\":\\"https://github.com\\"}: \\"label\\" must be a string."`,
|
||||
);
|
||||
});
|
||||
|
||||
test('sidebars link wrong href', async () => {
|
||||
const sidebarPath = path.join(fixtureDir, 'sidebars-link-wrong-href.json');
|
||||
expect(() =>
|
||||
loadSidebars(sidebarPath, options),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Error loading {\\"type\\":\\"link\\",\\"label\\":\\"GitHub\\",\\"href\\":[\\"example.com\\"]}: \\"href\\" must be a string."`,
|
||||
);
|
||||
});
|
||||
|
||||
test('sidebars with unknown sidebar item type', async () => {
|
||||
const sidebarPath = path.join(fixtureDir, 'sidebars-unknown-type.json');
|
||||
expect(() => loadSidebars(sidebarPath, options))
|
||||
.toThrowErrorMatchingInlineSnapshot(`
|
||||
"Unknown sidebar item type \\"superman\\". Sidebar item is {\\"type\\":\\"superman\\"}.
|
||||
"
|
||||
`);
|
||||
});
|
||||
|
||||
test('sidebars with known sidebar item type but wrong field', async () => {
|
||||
const sidebarPath = path.join(fixtureDir, 'sidebars-wrong-field.json');
|
||||
expect(() =>
|
||||
loadSidebars(sidebarPath, options),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Unknown sidebar item keys: href. Item: {\\"type\\":\\"category\\",\\"label\\":\\"category\\",\\"href\\":\\"https://github.com\\"}"`,
|
||||
);
|
||||
});
|
||||
|
||||
test('unexisting path', () => {
|
||||
expect(loadSidebars('badpath', options)).toEqual(DisabledSidebars);
|
||||
});
|
||||
|
||||
test('undefined path', () => {
|
||||
expect(loadSidebars(undefined, options)).toEqual(DefaultSidebars);
|
||||
});
|
||||
|
||||
test('literal false path', () => {
|
||||
expect(loadSidebars(false, options)).toEqual(DisabledSidebars);
|
||||
});
|
||||
|
||||
test('sidebars with category.collapsed property', async () => {
|
||||
const sidebarPath = path.join(fixtureDir, 'sidebars-collapsed.json');
|
||||
const result = loadSidebars(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 = loadSidebars(sidebarPath, options);
|
||||
expect(result).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
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'},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
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: UnprocessedSidebars) {
|
||||
return processSidebars({
|
||||
sidebarItemsGenerator: StaticSidebarItemsGenerator,
|
||||
unprocessedSidebars,
|
||||
docs: [],
|
||||
// @ts-expect-error: useless for this test
|
||||
version: {},
|
||||
});
|
||||
}
|
||||
|
||||
test('let sidebars without autogenerated items untouched', async () => {
|
||||
const unprocessedSidebars: UnprocessedSidebars = {
|
||||
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: UnprocessedSidebars = {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
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('fixSidebarItemInconsistencies', () => {
|
||||
test('should not fix good category', () => {
|
||||
const category: SidebarItemCategory = {
|
||||
type: 'category',
|
||||
label: 'Cat',
|
||||
items: [],
|
||||
collapsible: true,
|
||||
collapsed: true,
|
||||
};
|
||||
expect(fixSidebarItemInconsistencies(category)).toEqual(category);
|
||||
});
|
||||
|
||||
test('should fix bad category', () => {
|
||||
const category: SidebarItemCategory = {
|
||||
type: 'category',
|
||||
label: 'Cat',
|
||||
items: [],
|
||||
collapsible: false,
|
||||
collapsed: true, // Bad because collapsible=false
|
||||
};
|
||||
expect(fixSidebarItemInconsistencies(category)).toEqual({
|
||||
...category,
|
||||
collapsed: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('should fix bad subcategory', () => {
|
||||
const subCategory: SidebarItemCategory = {
|
||||
type: 'category',
|
||||
label: 'SubCat',
|
||||
items: [],
|
||||
collapsible: false,
|
||||
collapsed: true, // Bad because collapsible=false
|
||||
};
|
||||
const category: SidebarItemCategory = {
|
||||
type: 'category',
|
||||
label: 'Cat',
|
||||
items: [subCategory],
|
||||
collapsible: true,
|
||||
collapsed: true,
|
||||
};
|
||||
expect(fixSidebarItemInconsistencies(category)).toEqual({
|
||||
...category,
|
||||
items: [
|
||||
{
|
||||
...subCategory,
|
||||
collapsed: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
|
@ -12,13 +12,10 @@ import {
|
|||
} from './versions';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import {
|
||||
PathOptions,
|
||||
UnprocessedSidebarItem,
|
||||
UnprocessedSidebars,
|
||||
SidebarOptions,
|
||||
} from './types';
|
||||
import {loadSidebars, resolveSidebarPathOption} from './sidebars';
|
||||
import type {PathOptions, SidebarOptions} from './types';
|
||||
import {transformSidebarItems} from './sidebars/utils';
|
||||
import type {SidebarItem, NormalizedSidebars, Sidebar} from './sidebars/types';
|
||||
import {loadUnprocessedSidebars, resolveSidebarPathOption} from './sidebars';
|
||||
import {DEFAULT_PLUGIN_ID} from '@docusaurus/core/lib/constants';
|
||||
|
||||
function createVersionedSidebarFile({
|
||||
|
@ -35,7 +32,7 @@ function createVersionedSidebarFile({
|
|||
options: SidebarOptions;
|
||||
}) {
|
||||
// Load current sidebar and create a new versioned sidebars file (if needed).
|
||||
const loadedSidebars = loadSidebars(sidebarPath, options);
|
||||
const loadedSidebars = loadUnprocessedSidebars(sidebarPath, options);
|
||||
|
||||
// Do not create a useless versioned sidebars file if sidebars file is empty or sidebars are disabled/false)
|
||||
const shouldCreateVersionedSidebarFile =
|
||||
|
@ -45,30 +42,27 @@ function createVersionedSidebarFile({
|
|||
// TODO @slorber: this "version prefix" in versioned sidebars looks like a bad idea to me
|
||||
// TODO try to get rid of it
|
||||
// Transform id in original sidebar to versioned id.
|
||||
const normalizeItem = (
|
||||
item: UnprocessedSidebarItem,
|
||||
): UnprocessedSidebarItem => {
|
||||
switch (item.type) {
|
||||
case 'category':
|
||||
return {...item, items: item.items.map(normalizeItem)};
|
||||
case 'ref':
|
||||
case 'doc':
|
||||
const prependVersion = (item: SidebarItem): SidebarItem => {
|
||||
if (item.type === 'ref' || item.type === 'doc') {
|
||||
return {
|
||||
type: item.type,
|
||||
id: `version-${version}/${item.id}`,
|
||||
};
|
||||
default:
|
||||
return item;
|
||||
}
|
||||
return item;
|
||||
};
|
||||
|
||||
const versionedSidebar: UnprocessedSidebars = Object.entries(
|
||||
loadedSidebars,
|
||||
).reduce((acc: UnprocessedSidebars, [sidebarId, sidebarItems]) => {
|
||||
const newVersionedSidebarId = `version-${version}/${sidebarId}`;
|
||||
acc[newVersionedSidebarId] = sidebarItems.map(normalizeItem);
|
||||
const versionedSidebar = Object.entries(loadedSidebars).reduce(
|
||||
(acc: NormalizedSidebars, [sidebarId, sidebar]) => {
|
||||
const versionedId = `version-${version}/${sidebarId}`;
|
||||
acc[versionedId] = transformSidebarItems(
|
||||
sidebar as Sidebar,
|
||||
prependVersion,
|
||||
);
|
||||
return acc;
|
||||
}, {});
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
const versionedSidebarsDir = getVersionedSidebarsDirPath(siteDir, pluginId);
|
||||
const newSidebarFile = path.join(
|
||||
|
|
|
@ -21,7 +21,9 @@ import {
|
|||
createAbsoluteFilePathMatcher,
|
||||
} from '@docusaurus/utils';
|
||||
import {LoadContext, Plugin, RouteConfig} from '@docusaurus/types';
|
||||
import {loadSidebars, createSidebarsUtils, processSidebars} from './sidebars';
|
||||
import {loadSidebars} from './sidebars';
|
||||
import {createSidebarsUtils} from './sidebars/utils';
|
||||
import {CategoryMetadataFilenamePattern} from './sidebars/generator';
|
||||
import {readVersionDocs, processDocMetadata} from './docs';
|
||||
import {getDocsDirPaths, readVersionsMetadata} from './versions';
|
||||
|
||||
|
@ -42,14 +44,13 @@ import {
|
|||
import {RuleSetRule} from 'webpack';
|
||||
import {cliDocsVersionCommand} from './cli';
|
||||
import {VERSIONS_JSON_FILE} from './constants';
|
||||
import {flatten, keyBy, compact, mapValues} from 'lodash';
|
||||
import {keyBy, compact, mapValues} from 'lodash';
|
||||
import {toGlobalDataVersion} from './globalData';
|
||||
import {toTagDocListProp, toVersionMetadataProp} from './props';
|
||||
import {
|
||||
translateLoadedContent,
|
||||
getLoadedContentTranslationFiles,
|
||||
} from './translations';
|
||||
import {CategoryMetadataFilenamePattern} from './sidebarItemsGenerator';
|
||||
import chalk from 'chalk';
|
||||
import {getVersionTags} from './tags';
|
||||
import {PropTagsListPage} from '@docusaurus/plugin-content-docs-types';
|
||||
|
@ -116,13 +117,11 @@ export default function pluginContentDocs(
|
|||
getPathsToWatch() {
|
||||
function getVersionPathsToWatch(version: VersionMetadata): string[] {
|
||||
const result = [
|
||||
...flatten(
|
||||
options.include.map((pattern) =>
|
||||
...options.include.flatMap((pattern) =>
|
||||
getDocsDirPaths(version).map(
|
||||
(docsDirPath) => `${docsDirPath}/${pattern}`,
|
||||
),
|
||||
),
|
||||
),
|
||||
`${version.contentPath}/**/${CategoryMetadataFilenamePattern}`,
|
||||
];
|
||||
if (typeof version.sidebarFilePath === 'string') {
|
||||
|
@ -131,7 +130,7 @@ export default function pluginContentDocs(
|
|||
return result;
|
||||
}
|
||||
|
||||
return flatten(versionsMetadata.map(getVersionPathsToWatch));
|
||||
return versionsMetadata.flatMap(getVersionPathsToWatch);
|
||||
},
|
||||
|
||||
async loadContent() {
|
||||
|
@ -163,14 +162,6 @@ export default function pluginContentDocs(
|
|||
async function doLoadVersion(
|
||||
versionMetadata: VersionMetadata,
|
||||
): Promise<LoadedVersion> {
|
||||
const unprocessedSidebars = loadSidebars(
|
||||
versionMetadata.sidebarFilePath,
|
||||
{
|
||||
sidebarCollapsed: options.sidebarCollapsed,
|
||||
sidebarCollapsible: options.sidebarCollapsible,
|
||||
},
|
||||
);
|
||||
|
||||
const docsBase: DocMetadataBase[] = await loadVersionDocsBase(
|
||||
versionMetadata,
|
||||
);
|
||||
|
@ -179,10 +170,9 @@ export default function pluginContentDocs(
|
|||
(doc) => doc.id,
|
||||
);
|
||||
|
||||
const sidebars = await processSidebars({
|
||||
const sidebars = await loadSidebars(versionMetadata.sidebarFilePath, {
|
||||
sidebarItemsGenerator: options.sidebarItemsGenerator,
|
||||
numberPrefixParser: options.numberPrefixParser,
|
||||
unprocessedSidebars,
|
||||
docs: docsBase,
|
||||
version: versionMetadata,
|
||||
options: {
|
||||
|
@ -191,18 +181,21 @@ export default function pluginContentDocs(
|
|||
},
|
||||
});
|
||||
|
||||
const sidebarsUtils = createSidebarsUtils(sidebars);
|
||||
const {
|
||||
checkSidebarsDocIds,
|
||||
getDocNavigation,
|
||||
getFirstDocIdOfFirstSidebar,
|
||||
} = createSidebarsUtils(sidebars);
|
||||
|
||||
const validDocIds = Object.keys(docsBaseById);
|
||||
sidebarsUtils.checkSidebarsDocIds(
|
||||
checkSidebarsDocIds(
|
||||
validDocIds,
|
||||
versionMetadata.sidebarFilePath as string,
|
||||
);
|
||||
|
||||
// Add sidebar/next/previous to the docs
|
||||
function addNavData(doc: DocMetadataBase): DocMetadata {
|
||||
const {sidebarName, previousId, nextId} =
|
||||
sidebarsUtils.getDocNavigation(doc.id);
|
||||
const {sidebarName, previousId, nextId} = getDocNavigation(doc.id);
|
||||
const toDocNavLink = (navDocId: string): DocNavLink => {
|
||||
const {title, permalink, frontMatter} = docsBaseById[navDocId];
|
||||
return {
|
||||
|
@ -236,8 +229,7 @@ export default function pluginContentDocs(
|
|||
(doc) =>
|
||||
doc.unversionedId === options.homePageId || doc.slug === '/',
|
||||
);
|
||||
const firstDocIdOfFirstSidebar =
|
||||
sidebarsUtils.getFirstDocIdOfFirstSidebar();
|
||||
const firstDocIdOfFirstSidebar = getFirstDocIdOfFirstSidebar();
|
||||
if (versionHomeDoc) {
|
||||
return versionHomeDoc;
|
||||
} else if (firstDocIdOfFirstSidebar) {
|
||||
|
@ -429,7 +421,7 @@ export default function pluginContentDocs(
|
|||
} = options;
|
||||
|
||||
function getSourceToPermalink(): SourceToPermalink {
|
||||
const allDocs = flatten(content.loadedVersions.map((v) => v.docs));
|
||||
const allDocs = content.loadedVersions.flatMap((v) => v.docs);
|
||||
return mapValues(
|
||||
keyBy(allDocs, (d) => d.source),
|
||||
(d) => d.permalink,
|
||||
|
@ -452,7 +444,7 @@ export default function pluginContentDocs(
|
|||
};
|
||||
|
||||
function createMDXLoaderRule(): RuleSetRule {
|
||||
const contentDirs = flatten(versionsMetadata.map(getDocsDirPaths));
|
||||
const contentDirs = versionsMetadata.flatMap(getDocsDirPaths);
|
||||
return {
|
||||
test: /(\.mdx?)$/,
|
||||
include: contentDirs
|
||||
|
|
|
@ -17,7 +17,7 @@ import {GlobExcludeDefault} from '@docusaurus/utils';
|
|||
import {OptionValidationContext, ValidationResult} from '@docusaurus/types';
|
||||
import chalk from 'chalk';
|
||||
import admonitions from 'remark-admonitions';
|
||||
import {DefaultSidebarItemsGenerator} from './sidebarItemsGenerator';
|
||||
import {DefaultSidebarItemsGenerator} from './sidebars/generator';
|
||||
import {
|
||||
DefaultNumberPrefixParser,
|
||||
DisabledNumberPrefixParser,
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
declare module '@docusaurus/plugin-content-docs' {
|
||||
export type Options = Partial<import('./types').PluginOptions>;
|
||||
export type SidebarsConfig = import('./sidebars/types').SidebarsConfig;
|
||||
}
|
||||
|
||||
// TODO public api surface types should rather be exposed as "@docusaurus/plugin-content-docs"
|
||||
|
@ -29,30 +30,11 @@ declare module '@docusaurus/plugin-content-docs-types' {
|
|||
docsSidebars: PropSidebars;
|
||||
};
|
||||
|
||||
type PropsSidebarItemBase = {
|
||||
className?: string;
|
||||
customProps?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type PropSidebarItemLink = PropsSidebarItemBase & {
|
||||
type: 'link';
|
||||
href: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type PropSidebarItemCategory = PropsSidebarItemBase & {
|
||||
type: 'category';
|
||||
label: string;
|
||||
items: PropSidebarItem[];
|
||||
collapsed: boolean;
|
||||
collapsible: boolean;
|
||||
};
|
||||
|
||||
export type PropSidebarItem = PropSidebarItemLink | PropSidebarItemCategory;
|
||||
|
||||
export type PropSidebars = {
|
||||
[sidebarId: string]: PropSidebarItem[];
|
||||
};
|
||||
export type PropSidebarItemLink = import('./sidebars/types').SidebarItemLink;
|
||||
export type PropSidebarItemCategory =
|
||||
import('./sidebars/types').PropSidebarItemCategory;
|
||||
export type PropSidebarItem = import('./sidebars/types').PropSidebarItem;
|
||||
export type PropSidebars = import('./sidebars/types').PropSidebars;
|
||||
|
||||
export type PropTagDocListDoc = {
|
||||
id: string;
|
||||
|
|
|
@ -5,14 +5,12 @@
|
|||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
import {
|
||||
LoadedVersion,
|
||||
import {LoadedVersion, VersionTag, DocMetadata} from './types';
|
||||
import type {
|
||||
SidebarItemDoc,
|
||||
SidebarItemLink,
|
||||
SidebarItem,
|
||||
VersionTag,
|
||||
DocMetadata,
|
||||
} from './types';
|
||||
} from './sidebars/types';
|
||||
import type {
|
||||
PropSidebars,
|
||||
PropVersionMetadata,
|
||||
|
|
|
@ -1,322 +0,0 @@
|
|||
/**
|
||||
* 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 {
|
||||
SidebarItem,
|
||||
SidebarItemDoc,
|
||||
SidebarItemCategory,
|
||||
SidebarItemsGenerator,
|
||||
SidebarItemsGeneratorDoc,
|
||||
} from './types';
|
||||
import {sortBy, take, last, orderBy} 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 = '/';
|
||||
|
||||
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 = {position?: number};
|
||||
type SidebarItemWithPosition = SidebarItem & WithPosition;
|
||||
|
||||
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> {
|
||||
function validateCategoryMetadataFile(
|
||||
content: unknown,
|
||||
): CategoryMetadatasFile {
|
||||
return Joi.attempt(content, CategoryMetadatasFileSchema);
|
||||
}
|
||||
|
||||
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: unknown = parse(contentString);
|
||||
try {
|
||||
return validateCategoryMetadataFile(unsafeContent);
|
||||
} 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))
|
||||
);
|
||||
}
|
||||
|
||||
// [...parents, tail]
|
||||
function parseBreadcrumb(breadcrumb: string[]): {
|
||||
parents: string[];
|
||||
tail: string;
|
||||
} {
|
||||
return {
|
||||
parents: take(breadcrumb, breadcrumb.length - 1),
|
||||
tail: last(breadcrumb)!,
|
||||
};
|
||||
}
|
||||
|
||||
// Comment for this feature: https://github.com/facebook/docusaurus/issues/3464#issuecomment-818670449
|
||||
export const DefaultSidebarItemsGenerator: SidebarItemsGenerator = async ({
|
||||
item,
|
||||
docs: allDocs,
|
||||
version,
|
||||
numberPrefixParser,
|
||||
options,
|
||||
}) => {
|
||||
// Doc at the root of the autogenerated sidebar dir
|
||||
function isRootDoc(doc: SidebarItemsGeneratorDoc) {
|
||||
return doc.sourceDirName === item.dirName;
|
||||
}
|
||||
|
||||
// Doc inside a subfolder of the autogenerated sidebar dir
|
||||
function isCategoryDoc(doc: SidebarItemsGeneratorDoc) {
|
||||
if (isRootDoc(doc)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
// autogen dir is . and doc is in subfolder
|
||||
item.dirName === '.' ||
|
||||
// autogen dir is not . and doc is in subfolder
|
||||
// "api/myDoc" startsWith "api/" (note "api2/myDoc" is not included)
|
||||
doc.sourceDirName.startsWith(addTrailingSlash(item.dirName))
|
||||
);
|
||||
}
|
||||
|
||||
function isInAutogeneratedDir(doc: SidebarItemsGeneratorDoc) {
|
||||
return isRootDoc(doc) || isCategoryDoc(doc);
|
||||
}
|
||||
|
||||
// autogenDir=a/b and docDir=a/b/c/d => returns c/d
|
||||
// autogenDir=a/b and docDir=a/b => returns .
|
||||
function getDocDirRelativeToAutogenDir(
|
||||
doc: SidebarItemsGeneratorDoc,
|
||||
): string {
|
||||
if (!isInAutogeneratedDir(doc)) {
|
||||
throw new Error(
|
||||
'getDocDirRelativeToAutogenDir() can only be called for subdocs of the sidebar autogen dir.',
|
||||
);
|
||||
}
|
||||
// Is there a node API to compare 2 relative paths more easily?
|
||||
// path.relative() does not give good results
|
||||
if (item.dirName === '.') {
|
||||
return doc.sourceDirName;
|
||||
} else if (item.dirName === doc.sourceDirName) {
|
||||
return '.';
|
||||
} else {
|
||||
return doc.sourceDirName.replace(addTrailingSlash(item.dirName), '');
|
||||
}
|
||||
}
|
||||
|
||||
// Get only docs in the autogen dir
|
||||
// Sort by folder+filename at once
|
||||
const docs = sortBy(allDocs.filter(isInAutogeneratedDir), (d) => d.source);
|
||||
|
||||
if (docs.length === 0) {
|
||||
console.warn(
|
||||
chalk.yellow(
|
||||
`No docs found in dir ${item.dirName}: can't auto-generate a sidebar.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function createDocSidebarItem(
|
||||
doc: SidebarItemsGeneratorDoc,
|
||||
): SidebarItemDoc & WithPosition {
|
||||
return {
|
||||
type: 'doc',
|
||||
id: doc.id,
|
||||
...(doc.frontMatter.sidebar_label && {
|
||||
label: doc.frontMatter.sidebar_label,
|
||||
}),
|
||||
...(doc.frontMatter.sidebar_class_name && {
|
||||
className: doc.frontMatter.sidebar_class_name,
|
||||
}),
|
||||
...(typeof doc.sidebarPosition !== 'undefined' && {
|
||||
position: doc.sidebarPosition,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async function createCategorySidebarItem({
|
||||
breadcrumb,
|
||||
}: {
|
||||
breadcrumb: string[];
|
||||
}): Promise<SidebarItemCategory & WithPosition> {
|
||||
const categoryDirPath = path.join(
|
||||
version.contentPath,
|
||||
item.dirName, // fix https://github.com/facebook/docusaurus/issues/4638
|
||||
breadcrumb.join(BreadcrumbSeparator),
|
||||
);
|
||||
|
||||
const categoryMetadatas = await readCategoryMetadatasFile(categoryDirPath);
|
||||
|
||||
const {tail} = parseBreadcrumb(breadcrumb);
|
||||
|
||||
const {filename, numberPrefix} = numberPrefixParser(tail);
|
||||
|
||||
const position = categoryMetadatas?.position ?? numberPrefix;
|
||||
|
||||
const collapsible =
|
||||
categoryMetadatas?.collapsible ?? options.sidebarCollapsible;
|
||||
const collapsed = categoryMetadatas?.collapsed ?? options.sidebarCollapsed;
|
||||
const className = categoryMetadatas?.className;
|
||||
|
||||
return {
|
||||
type: 'category',
|
||||
label: categoryMetadatas?.label ?? filename,
|
||||
items: [],
|
||||
collapsed,
|
||||
collapsible,
|
||||
...(typeof position !== 'undefined' && {position}),
|
||||
...(typeof className !== 'undefined' && {className}),
|
||||
};
|
||||
}
|
||||
|
||||
// Not sure how to simplify this algorithm :/
|
||||
async function autogenerateSidebarItems(): Promise<
|
||||
SidebarItemWithPosition[]
|
||||
> {
|
||||
const sidebarItems: SidebarItem[] = []; // mutable result
|
||||
|
||||
const categoriesByBreadcrumb: Record<string, SidebarItemCategory> = {}; // mutable cache of categories already created
|
||||
|
||||
async function getOrCreateCategoriesForBreadcrumb(
|
||||
breadcrumb: string[],
|
||||
): Promise<SidebarItemCategory | null> {
|
||||
if (breadcrumb.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const {parents} = parseBreadcrumb(breadcrumb);
|
||||
const parentCategory = await getOrCreateCategoriesForBreadcrumb(parents);
|
||||
const existingCategory =
|
||||
categoriesByBreadcrumb[breadcrumb.join(BreadcrumbSeparator)];
|
||||
|
||||
if (existingCategory) {
|
||||
return existingCategory;
|
||||
} else {
|
||||
const newCategory = await createCategorySidebarItem({
|
||||
breadcrumb,
|
||||
});
|
||||
if (parentCategory) {
|
||||
parentCategory.items.push(newCategory);
|
||||
} else {
|
||||
sidebarItems.push(newCategory);
|
||||
}
|
||||
categoriesByBreadcrumb[breadcrumb.join(BreadcrumbSeparator)] =
|
||||
newCategory;
|
||||
return newCategory;
|
||||
}
|
||||
}
|
||||
|
||||
// Get the category breadcrumb of a doc (relative to the dir of the autogenerated sidebar item)
|
||||
function getRelativeBreadcrumb(doc: SidebarItemsGeneratorDoc): string[] {
|
||||
const relativeDirPath = getDocDirRelativeToAutogenDir(doc);
|
||||
if (relativeDirPath === '.') {
|
||||
return [];
|
||||
} else {
|
||||
return relativeDirPath.split(BreadcrumbSeparator);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDocItem(doc: SidebarItemsGeneratorDoc): Promise<void> {
|
||||
const breadcrumb = getRelativeBreadcrumb(doc);
|
||||
const category = await getOrCreateCategoriesForBreadcrumb(breadcrumb);
|
||||
|
||||
const docSidebarItem = createDocSidebarItem(doc);
|
||||
if (category) {
|
||||
category.items.push(docSidebarItem);
|
||||
} else {
|
||||
sidebarItems.push(docSidebarItem);
|
||||
}
|
||||
}
|
||||
|
||||
// async process made sequential on purpose! order matters
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
for (const doc of docs) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await handleDocItem(doc);
|
||||
}
|
||||
|
||||
return sidebarItems;
|
||||
}
|
||||
|
||||
const sidebarItems = await autogenerateSidebarItems();
|
||||
|
||||
return sortSidebarItems(sidebarItems);
|
||||
};
|
||||
|
||||
// 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 sortSidebarItems(
|
||||
sidebarItems: SidebarItemWithPosition[],
|
||||
): SidebarItem[] {
|
||||
const processedSidebarItems = sidebarItems.map((item) => {
|
||||
if (item.type === 'category') {
|
||||
return {
|
||||
...item,
|
||||
items: sortSidebarItems(item.items),
|
||||
};
|
||||
}
|
||||
return item;
|
||||
});
|
||||
|
||||
const sortedSidebarItems = orderBy(
|
||||
processedSidebarItems,
|
||||
(item) => item.position,
|
||||
['asc'],
|
||||
);
|
||||
|
||||
return sortedSidebarItems.map(({position, ...item}) => item);
|
||||
}
|
|
@ -1,609 +0,0 @@
|
|||
/**
|
||||
* 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 {
|
||||
Sidebars,
|
||||
SidebarItem,
|
||||
SidebarItemBase,
|
||||
SidebarItemLink,
|
||||
SidebarItemDoc,
|
||||
Sidebar,
|
||||
SidebarItemCategory,
|
||||
SidebarItemType,
|
||||
UnprocessedSidebarItem,
|
||||
UnprocessedSidebars,
|
||||
UnprocessedSidebar,
|
||||
DocMetadataBase,
|
||||
VersionMetadata,
|
||||
SidebarItemsGeneratorDoc,
|
||||
SidebarItemsGeneratorVersion,
|
||||
NumberPrefixParser,
|
||||
SidebarItemsGeneratorOption,
|
||||
SidebarOptions,
|
||||
PluginOptions,
|
||||
} from './types';
|
||||
import {mapValues, flatten, flatMap, difference, pick, memoize} from 'lodash';
|
||||
import {getElementsAround, toMessageRelativeFilePath} from '@docusaurus/utils';
|
||||
import combinePromises from 'combine-promises';
|
||||
import {DefaultSidebarItemsGenerator} from './sidebarItemsGenerator';
|
||||
import path from 'path';
|
||||
|
||||
type SidebarItemCategoryJSON = SidebarItemBase & {
|
||||
type: 'category';
|
||||
label: string;
|
||||
items: SidebarItemJSON[];
|
||||
collapsed?: boolean;
|
||||
collapsible?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
type SidebarItemAutogeneratedJSON = SidebarItemBase & {
|
||||
type: 'autogenerated';
|
||||
dirName: string;
|
||||
};
|
||||
|
||||
type SidebarItemJSON =
|
||||
| string
|
||||
| SidebarCategoryShorthandJSON
|
||||
| SidebarItemDoc
|
||||
| SidebarItemLink
|
||||
| SidebarItemCategoryJSON
|
||||
| SidebarItemAutogeneratedJSON
|
||||
| {
|
||||
type: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type SidebarCategoryShorthandJSON = {
|
||||
[sidebarCategory: string]: SidebarItemJSON[];
|
||||
};
|
||||
|
||||
type SidebarJSON = SidebarCategoryShorthandJSON | SidebarItemJSON[];
|
||||
|
||||
// Sidebar given by user that is not normalized yet. e.g: sidebars.json
|
||||
type SidebarsJSON = {
|
||||
[sidebarId: string]: SidebarJSON;
|
||||
};
|
||||
|
||||
function isCategoryShorthand(
|
||||
item: SidebarItemJSON,
|
||||
): item is SidebarCategoryShorthandJSON {
|
||||
return typeof item !== 'string' && !item.type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert {category1: [item1,item2]} shorthand syntax to long-form syntax
|
||||
*/
|
||||
function normalizeCategoryShorthand(
|
||||
sidebar: SidebarCategoryShorthandJSON,
|
||||
options: SidebarOptions,
|
||||
): SidebarItemCategoryJSON[] {
|
||||
return Object.entries(sidebar).map(([label, items]) => ({
|
||||
type: 'category',
|
||||
collapsed: options.sidebarCollapsed,
|
||||
collapsible: options.sidebarCollapsible,
|
||||
label,
|
||||
items,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check that item contains only allowed keys.
|
||||
*/
|
||||
function assertItem<K extends string>(
|
||||
item: Record<string, unknown>,
|
||||
keys: K[],
|
||||
): asserts item is Record<K, unknown> {
|
||||
const unknownKeys = Object.keys(item).filter(
|
||||
(key) => !keys.includes(key as K) && key !== 'type',
|
||||
);
|
||||
|
||||
if (unknownKeys.length) {
|
||||
throw new Error(
|
||||
`Unknown sidebar item keys: ${unknownKeys}. Item: ${JSON.stringify(
|
||||
item,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertIsCategory(
|
||||
item: Record<string, unknown>,
|
||||
): asserts item is SidebarItemCategoryJSON {
|
||||
assertItem(item, [
|
||||
'items',
|
||||
'label',
|
||||
'collapsed',
|
||||
'collapsible',
|
||||
'className',
|
||||
'customProps',
|
||||
]);
|
||||
if (typeof item.label !== 'string') {
|
||||
throw new Error(
|
||||
`Error loading ${JSON.stringify(item)}: "label" must be a string.`,
|
||||
);
|
||||
}
|
||||
if (!Array.isArray(item.items)) {
|
||||
throw new Error(
|
||||
`Error loading ${JSON.stringify(item)}: "items" must be an array.`,
|
||||
);
|
||||
}
|
||||
// "collapsed" is an optional property
|
||||
if (
|
||||
typeof item.collapsed !== 'undefined' &&
|
||||
typeof item.collapsed !== 'boolean'
|
||||
) {
|
||||
throw new Error(
|
||||
`Error loading ${JSON.stringify(item)}: "collapsed" must be a boolean.`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
typeof item.collapsible !== 'undefined' &&
|
||||
typeof item.collapsible !== 'boolean'
|
||||
) {
|
||||
throw new Error(
|
||||
`Error loading ${JSON.stringify(item)}: "collapsible" must be a boolean.`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
typeof item.className !== 'undefined' &&
|
||||
typeof item.className !== 'string'
|
||||
) {
|
||||
throw new Error(
|
||||
`Error loading ${JSON.stringify(item)}: "className" must be a string.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertIsAutogenerated(
|
||||
item: Record<string, unknown>,
|
||||
): asserts item is SidebarItemAutogeneratedJSON {
|
||||
assertItem(item, ['dirName', 'customProps']);
|
||||
if (typeof item.dirName !== 'string') {
|
||||
throw new Error(
|
||||
`Error loading ${JSON.stringify(item)}: "dirName" must be a string.`,
|
||||
);
|
||||
}
|
||||
if (item.dirName.startsWith('/') || item.dirName.endsWith('/')) {
|
||||
throw new Error(
|
||||
`Error loading ${JSON.stringify(
|
||||
item,
|
||||
)}: "dirName" must be a dir path relative to the docs folder root, and should not start or end with slash`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertIsDoc(
|
||||
item: Record<string, unknown>,
|
||||
): asserts item is SidebarItemDoc {
|
||||
assertItem(item, ['id', 'label', 'className', 'customProps']);
|
||||
if (typeof item.id !== 'string') {
|
||||
throw new Error(
|
||||
`Error loading ${JSON.stringify(item)}: "id" must be a string.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof item.label !== 'undefined' && typeof item.label !== 'string') {
|
||||
throw new Error(
|
||||
`Error loading ${JSON.stringify(item)}: "label" must be a string.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
typeof item.className !== 'undefined' &&
|
||||
typeof item.className !== 'string'
|
||||
) {
|
||||
throw new Error(
|
||||
`Error loading ${JSON.stringify(item)}: "className" must be a string.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertIsLink(
|
||||
item: Record<string, unknown>,
|
||||
): asserts item is SidebarItemLink {
|
||||
assertItem(item, ['href', 'label', 'className', 'customProps']);
|
||||
if (typeof item.href !== 'string') {
|
||||
throw new Error(
|
||||
`Error loading ${JSON.stringify(item)}: "href" must be a string.`,
|
||||
);
|
||||
}
|
||||
if (typeof item.label !== 'string') {
|
||||
throw new Error(
|
||||
`Error loading ${JSON.stringify(item)}: "label" must be a string.`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
typeof item.className !== 'undefined' &&
|
||||
typeof item.className !== 'string'
|
||||
) {
|
||||
throw new Error(
|
||||
`Error loading ${JSON.stringify(item)}: "className" must be a string.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: SidebarItemJSON,
|
||||
options: SidebarOptions,
|
||||
): UnprocessedSidebarItem[] {
|
||||
if (typeof item === 'string') {
|
||||
return [
|
||||
{
|
||||
type: 'doc',
|
||||
id: item,
|
||||
},
|
||||
];
|
||||
}
|
||||
if (isCategoryShorthand(item)) {
|
||||
return flatMap(normalizeCategoryShorthand(item, options), (subitem) =>
|
||||
normalizeItem(subitem, options),
|
||||
);
|
||||
}
|
||||
switch (item.type) {
|
||||
case 'category':
|
||||
assertIsCategory(item);
|
||||
return [
|
||||
{
|
||||
...item,
|
||||
items: flatMap(item.items, (subItem) =>
|
||||
normalizeItem(subItem, options),
|
||||
),
|
||||
collapsible: item.collapsible ?? options.sidebarCollapsible,
|
||||
collapsed: item.collapsed ?? options.sidebarCollapsed,
|
||||
},
|
||||
];
|
||||
case 'autogenerated':
|
||||
assertIsAutogenerated(item);
|
||||
return [item];
|
||||
case 'link':
|
||||
assertIsLink(item);
|
||||
return [item];
|
||||
case 'ref':
|
||||
case 'doc':
|
||||
assertIsDoc(item);
|
||||
return [item];
|
||||
default: {
|
||||
const extraMigrationError =
|
||||
item.type === 'subcategory'
|
||||
? 'Docusaurus v2: "subcategory" has been renamed as "category".'
|
||||
: '';
|
||||
throw new Error(
|
||||
`Unknown sidebar item type "${
|
||||
item.type
|
||||
}". Sidebar item is ${JSON.stringify(item)}.\n${extraMigrationError}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSidebar(
|
||||
sidebar: SidebarJSON,
|
||||
options: SidebarOptions,
|
||||
): UnprocessedSidebar {
|
||||
const normalizedSidebar: SidebarItemJSON[] = Array.isArray(sidebar)
|
||||
? sidebar
|
||||
: normalizeCategoryShorthand(sidebar, options);
|
||||
|
||||
return flatMap(normalizedSidebar, (subitem) =>
|
||||
normalizeItem(subitem, options),
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeSidebars(
|
||||
sidebars: SidebarsJSON,
|
||||
options: SidebarOptions,
|
||||
): UnprocessedSidebars {
|
||||
return mapValues(sidebars, (subitem) => normalizeSidebar(subitem, options));
|
||||
}
|
||||
|
||||
export const DefaultSidebars: UnprocessedSidebars = {
|
||||
defaultSidebar: [
|
||||
{
|
||||
type: 'autogenerated',
|
||||
dirName: '.',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const DisabledSidebars: UnprocessedSidebars = {};
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// TODO refactor: make async
|
||||
// Note: sidebarFilePath must be absolute, use resolveSidebarPathOption
|
||||
export function loadSidebars(
|
||||
sidebarFilePath: string | false | undefined,
|
||||
options: SidebarOptions,
|
||||
): UnprocessedSidebars {
|
||||
// false => no sidebars
|
||||
if (sidebarFilePath === false) {
|
||||
return DisabledSidebars;
|
||||
}
|
||||
|
||||
// undefined => defaults to autogenerated sidebars
|
||||
if (typeof sidebarFilePath === 'undefined') {
|
||||
return DefaultSidebars;
|
||||
}
|
||||
|
||||
// unexisting 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.
|
||||
const sidebarJson = importFresh(sidebarFilePath) as SidebarsJSON;
|
||||
|
||||
return normalizeSidebars(sidebarJson, options);
|
||||
}
|
||||
|
||||
export function toSidebarItemsGeneratorDoc(
|
||||
doc: DocMetadataBase,
|
||||
): SidebarItemsGeneratorDoc {
|
||||
return pick(doc, [
|
||||
'id',
|
||||
'frontMatter',
|
||||
'source',
|
||||
'sourceDirName',
|
||||
'sidebarPosition',
|
||||
]);
|
||||
}
|
||||
export function toSidebarItemsGeneratorVersion(
|
||||
version: VersionMetadata,
|
||||
): SidebarItemsGeneratorVersion {
|
||||
return pick(version, ['versionName', 'contentPath']);
|
||||
}
|
||||
|
||||
export function fixSidebarItemInconsistencies(item: SidebarItem): SidebarItem {
|
||||
function fixCategoryInconsistencies(
|
||||
category: SidebarItemCategory,
|
||||
): SidebarItemCategory {
|
||||
// A non-collapsible category can't be collapsed!
|
||||
if (!category.collapsible && category.collapsed) {
|
||||
return {
|
||||
...category,
|
||||
collapsed: false,
|
||||
};
|
||||
}
|
||||
return category;
|
||||
}
|
||||
|
||||
if (item.type === 'category') {
|
||||
return {
|
||||
...fixCategoryInconsistencies(item),
|
||||
items: item.items.map(fixSidebarItemInconsistencies),
|
||||
};
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
// Handle the generation of autogenerated sidebar items and other post-processing checks
|
||||
export async function processSidebar({
|
||||
sidebarItemsGenerator,
|
||||
numberPrefixParser,
|
||||
unprocessedSidebar,
|
||||
docs,
|
||||
version,
|
||||
options,
|
||||
}: {
|
||||
sidebarItemsGenerator: SidebarItemsGeneratorOption;
|
||||
numberPrefixParser: NumberPrefixParser;
|
||||
unprocessedSidebar: UnprocessedSidebar;
|
||||
docs: DocMetadataBase[];
|
||||
version: VersionMetadata;
|
||||
options: SidebarOptions;
|
||||
}): Promise<Sidebar> {
|
||||
// Just a minor lazy transformation optimization
|
||||
const getSidebarItemsGeneratorDocsAndVersion = memoize(() => ({
|
||||
docs: docs.map(toSidebarItemsGeneratorDoc),
|
||||
version: toSidebarItemsGeneratorVersion(version),
|
||||
}));
|
||||
|
||||
async function handleAutoGeneratedItems(
|
||||
item: UnprocessedSidebarItem,
|
||||
): 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();
|
||||
|
||||
return processedSidebar.map(fixSidebarItemInconsistencies);
|
||||
}
|
||||
|
||||
export async function processSidebars({
|
||||
sidebarItemsGenerator,
|
||||
numberPrefixParser,
|
||||
unprocessedSidebars,
|
||||
docs,
|
||||
version,
|
||||
options,
|
||||
}: {
|
||||
sidebarItemsGenerator: SidebarItemsGeneratorOption;
|
||||
numberPrefixParser: NumberPrefixParser;
|
||||
unprocessedSidebars: UnprocessedSidebars;
|
||||
docs: DocMetadataBase[];
|
||||
version: VersionMetadata;
|
||||
options: SidebarOptions;
|
||||
}): Promise<Sidebars> {
|
||||
return combinePromises(
|
||||
mapValues(unprocessedSidebars, (unprocessedSidebar) =>
|
||||
processSidebar({
|
||||
sidebarItemsGenerator,
|
||||
numberPrefixParser,
|
||||
unprocessedSidebar,
|
||||
docs,
|
||||
version,
|
||||
options,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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' ? flatten(item.items.map(collectRecursive)) : [];
|
||||
|
||||
return [...currentItemsCollected, ...childItemsCollected];
|
||||
}
|
||||
|
||||
return flatten(sidebar.map(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 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);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
function getFirstDocIdOfFirstSidebar(): string | undefined {
|
||||
return Object.values(sidebarNameToDocIds)[0]?.[0];
|
||||
}
|
||||
|
||||
function getSidebarNameByDocId(docId: string): string | undefined {
|
||||
// TODO lookup speed can be optimized
|
||||
const entry = Object.entries(sidebarNameToDocIds).find(
|
||||
([_sidebarName, docIds]) => docIds.includes(docId),
|
||||
);
|
||||
|
||||
return entry?.[0];
|
||||
}
|
||||
|
||||
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 = flatten(Object.values(sidebarNameToDocIds));
|
||||
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,
|
||||
};
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`loadSidebars sidebars link 1`] = `
|
||||
exports[`loadUnprocessedSidebars sidebars link 1`] = `
|
||||
Object {
|
||||
"docs": Array [
|
||||
Object {
|
||||
|
@ -20,7 +20,7 @@ Object {
|
|||
}
|
||||
`;
|
||||
|
||||
exports[`loadSidebars sidebars with category.collapsed property 1`] = `
|
||||
exports[`loadUnprocessedSidebars sidebars with category.collapsed property 1`] = `
|
||||
Object {
|
||||
"docs": Array [
|
||||
Object {
|
||||
|
@ -67,7 +67,7 @@ Object {
|
|||
}
|
||||
`;
|
||||
|
||||
exports[`loadSidebars sidebars with category.collapsed property at first level 1`] = `
|
||||
exports[`loadUnprocessedSidebars sidebars with category.collapsed property at first level 1`] = `
|
||||
Object {
|
||||
"docs": Array [
|
||||
Object {
|
||||
|
@ -98,7 +98,7 @@ Object {
|
|||
}
|
||||
`;
|
||||
|
||||
exports[`loadSidebars sidebars with deep level of category 1`] = `
|
||||
exports[`loadUnprocessedSidebars sidebars with deep level of category 1`] = `
|
||||
Object {
|
||||
"docs": Array [
|
||||
Object {
|
||||
|
@ -165,7 +165,7 @@ Object {
|
|||
}
|
||||
`;
|
||||
|
||||
exports[`loadSidebars sidebars with first level not a category 1`] = `
|
||||
exports[`loadUnprocessedSidebars sidebars with first level not a category 1`] = `
|
||||
Object {
|
||||
"docs": Array [
|
||||
Object {
|
||||
|
@ -188,7 +188,7 @@ Object {
|
|||
}
|
||||
`;
|
||||
|
||||
exports[`loadSidebars sidebars with known sidebar item type 1`] = `
|
||||
exports[`loadUnprocessedSidebars sidebars with known sidebar item type 1`] = `
|
||||
Object {
|
||||
"docs": Array [
|
||||
Object {
|
|
@ -8,10 +8,10 @@
|
|||
import {
|
||||
CategoryMetadatasFile,
|
||||
DefaultSidebarItemsGenerator,
|
||||
} from '../sidebarItemsGenerator';
|
||||
} from '../generator';
|
||||
import {Sidebar, SidebarItemsGenerator} from '../types';
|
||||
import fs from 'fs-extra';
|
||||
import {DefaultNumberPrefixParser} from '../numberPrefix';
|
||||
import {DefaultNumberPrefixParser} from '../../numberPrefix';
|
||||
|
||||
describe('DefaultSidebarItemsGenerator', () => {
|
||||
function testDefaultSidebarItemsGenerator(
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
|
@ -5,20 +5,15 @@
|
|||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
import {
|
||||
LoadedVersion,
|
||||
Sidebar,
|
||||
LoadedContent,
|
||||
Sidebars,
|
||||
SidebarItem,
|
||||
} from './types';
|
||||
import type {LoadedVersion, LoadedContent} from './types';
|
||||
import type {Sidebar, Sidebars} from './sidebars/types';
|
||||
|
||||
import {chain, mapValues, flatten, keyBy} from 'lodash';
|
||||
import {chain, mapValues, keyBy} from 'lodash';
|
||||
import {
|
||||
collectSidebarCategories,
|
||||
transformSidebarItems,
|
||||
collectSidebarLinks,
|
||||
} from './sidebars';
|
||||
} from './sidebars/utils';
|
||||
import {
|
||||
TranslationFileContent,
|
||||
TranslationFile,
|
||||
|
@ -131,7 +126,7 @@ function translateSidebar({
|
|||
sidebarName: string;
|
||||
sidebarsTranslations: TranslationFileContent;
|
||||
}): Sidebar {
|
||||
return transformSidebarItems(sidebar, (item: SidebarItem): SidebarItem => {
|
||||
return transformSidebarItems(sidebar, (item) => {
|
||||
if (item.type === 'category') {
|
||||
return {
|
||||
...item,
|
||||
|
@ -222,7 +217,7 @@ function translateVersion(
|
|||
function getVersionsTranslationFiles(
|
||||
versions: LoadedVersion[],
|
||||
): TranslationFiles {
|
||||
return flatten(versions.map(getVersionTranslationFiles));
|
||||
return versions.flatMap(getVersionTranslationFiles);
|
||||
}
|
||||
function translateVersions(
|
||||
versions: LoadedVersion[],
|
||||
|
|
|
@ -13,6 +13,7 @@ import type {
|
|||
BrokenMarkdownLink as IBrokenMarkdownLink,
|
||||
ContentPaths,
|
||||
} from '@docusaurus/utils/lib/markdownLinks';
|
||||
import type {SidebarItemsGeneratorOption, Sidebars} from './sidebars/types';
|
||||
|
||||
export type DocFile = {
|
||||
contentPath: string; // /!\ may be localized
|
||||
|
@ -104,100 +105,6 @@ export type PluginOptions = MetadataOptions &
|
|||
tagsBasePath: string;
|
||||
};
|
||||
|
||||
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 SidebarItemCategory = SidebarItemBase & {
|
||||
type: 'category';
|
||||
label: string;
|
||||
items: SidebarItem[];
|
||||
collapsed: boolean;
|
||||
collapsible: boolean;
|
||||
};
|
||||
|
||||
export type UnprocessedSidebarItemAutogenerated = {
|
||||
type: 'autogenerated';
|
||||
dirName: string;
|
||||
};
|
||||
|
||||
export type UnprocessedSidebarItemCategory = SidebarItemBase & {
|
||||
type: 'category';
|
||||
label: string;
|
||||
items: UnprocessedSidebarItem[];
|
||||
collapsed: boolean;
|
||||
collapsible: boolean;
|
||||
};
|
||||
|
||||
export type UnprocessedSidebarItem =
|
||||
| SidebarItemDoc
|
||||
| SidebarItemLink
|
||||
| UnprocessedSidebarItemCategory
|
||||
| UnprocessedSidebarItemAutogenerated;
|
||||
|
||||
export type UnprocessedSidebar = UnprocessedSidebarItem[];
|
||||
export type UnprocessedSidebars = Record<string, UnprocessedSidebar>;
|
||||
|
||||
export type SidebarItem =
|
||||
| SidebarItemDoc
|
||||
| SidebarItemLink
|
||||
| SidebarItemCategory;
|
||||
|
||||
export type Sidebar = SidebarItem[];
|
||||
export type SidebarItemType = SidebarItem['type'];
|
||||
export type Sidebars = Record<string, Sidebar>;
|
||||
|
||||
// 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: UnprocessedSidebarItemAutogenerated;
|
||||
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[]>;
|
||||
|
||||
export type OrderMetadata = {
|
||||
previous?: string;
|
||||
next?: string;
|
||||
sidebar?: string;
|
||||
};
|
||||
|
||||
export type LastUpdateData = {
|
||||
lastUpdatedAt?: number;
|
||||
formattedLastUpdatedAt?: string;
|
||||
|
|
|
@ -5,7 +5,10 @@
|
|||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
// @ts-check
|
||||
|
||||
/** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */
|
||||
const sidebars = {
|
||||
docs: [
|
||||
'introduction',
|
||||
{
|
||||
|
@ -137,3 +140,5 @@ module.exports = {
|
|||
},
|
||||
],
|
||||
};
|
||||
|
||||
module.exports = sidebars;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue