mirror of
https://github.com/facebook/docusaurus.git
synced 2025-05-13 09:07:29 +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.
|
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
|
// By default, Docusaurus generates a sidebar from the docs folder structure
|
||||||
tutorialSidebar: [{type: 'autogenerated', dirName: '.'}],
|
tutorialSidebar: [{type: 'autogenerated', dirName: '.'}],
|
||||||
|
|
||||||
|
@ -24,3 +27,5 @@ module.exports = {
|
||||||
],
|
],
|
||||||
*/
|
*/
|
||||||
};
|
};
|
||||||
|
|
||||||
|
module.exports = sidebars;
|
||||||
|
|
|
@ -22,7 +22,8 @@
|
||||||
"@types/js-yaml": "^4.0.0",
|
"@types/js-yaml": "^4.0.0",
|
||||||
"@types/picomatch": "^2.2.1",
|
"@types/picomatch": "^2.2.1",
|
||||||
"commander": "^5.1.0",
|
"commander": "^5.1.0",
|
||||||
"picomatch": "^2.1.1"
|
"picomatch": "^2.1.1",
|
||||||
|
"utility-types": "^3.10.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@docusaurus/core": "2.0.0-beta.6",
|
"@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 * as cliDocs from '../cli';
|
||||||
import {OptionsSchema} from '../options';
|
import {OptionsSchema} from '../options';
|
||||||
import {normalizePluginOptions} from '@docusaurus/utils-validation';
|
import {normalizePluginOptions} from '@docusaurus/utils-validation';
|
||||||
import {
|
import type {DocMetadata, LoadedVersion} from '../types';
|
||||||
DocMetadata,
|
import type {
|
||||||
LoadedVersion,
|
|
||||||
SidebarItem,
|
SidebarItem,
|
||||||
SidebarItemsGeneratorOption,
|
SidebarItemsGeneratorOption,
|
||||||
SidebarItemsGeneratorOptionArgs,
|
SidebarItemsGeneratorOptionArgs,
|
||||||
} from '../types';
|
} from '../sidebars/types';
|
||||||
import {toSidebarsProp} from '../props';
|
import {toSidebarsProp} from '../props';
|
||||||
|
|
||||||
import {validate} from 'webpack';
|
import {validate} from 'webpack';
|
||||||
import {DefaultSidebarItemsGenerator} from '../sidebarItemsGenerator';
|
import {DefaultSidebarItemsGenerator} from '../sidebars/generator';
|
||||||
import {DisabledSidebars} from '../sidebars';
|
import {DisabledSidebars} from '../sidebars';
|
||||||
|
|
||||||
function findDocById(version: LoadedVersion, unversionedId: string) {
|
function findDocById(version: LoadedVersion, unversionedId: string) {
|
||||||
|
|
|
@ -7,7 +7,7 @@
|
||||||
|
|
||||||
import {OptionsSchema, DEFAULT_OPTIONS, validateOptions} from '../options';
|
import {OptionsSchema, DEFAULT_OPTIONS, validateOptions} from '../options';
|
||||||
import {normalizePluginOptions} from '@docusaurus/utils-validation';
|
import {normalizePluginOptions} from '@docusaurus/utils-validation';
|
||||||
import {DefaultSidebarItemsGenerator} from '../sidebarItemsGenerator';
|
import {DefaultSidebarItemsGenerator} from '../sidebars/generator';
|
||||||
import {
|
import {
|
||||||
DefaultNumberPrefixParser,
|
DefaultNumberPrefixParser,
|
||||||
DisabledNumberPrefixParser,
|
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';
|
} from './versions';
|
||||||
import fs from 'fs-extra';
|
import fs from 'fs-extra';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import {
|
import type {PathOptions, SidebarOptions} from './types';
|
||||||
PathOptions,
|
import {transformSidebarItems} from './sidebars/utils';
|
||||||
UnprocessedSidebarItem,
|
import type {SidebarItem, NormalizedSidebars, Sidebar} from './sidebars/types';
|
||||||
UnprocessedSidebars,
|
import {loadUnprocessedSidebars, resolveSidebarPathOption} from './sidebars';
|
||||||
SidebarOptions,
|
|
||||||
} from './types';
|
|
||||||
import {loadSidebars, resolveSidebarPathOption} from './sidebars';
|
|
||||||
import {DEFAULT_PLUGIN_ID} from '@docusaurus/core/lib/constants';
|
import {DEFAULT_PLUGIN_ID} from '@docusaurus/core/lib/constants';
|
||||||
|
|
||||||
function createVersionedSidebarFile({
|
function createVersionedSidebarFile({
|
||||||
|
@ -35,7 +32,7 @@ function createVersionedSidebarFile({
|
||||||
options: SidebarOptions;
|
options: SidebarOptions;
|
||||||
}) {
|
}) {
|
||||||
// Load current sidebar and create a new versioned sidebars file (if needed).
|
// 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)
|
// Do not create a useless versioned sidebars file if sidebars file is empty or sidebars are disabled/false)
|
||||||
const shouldCreateVersionedSidebarFile =
|
const shouldCreateVersionedSidebarFile =
|
||||||
|
@ -45,30 +42,27 @@ function createVersionedSidebarFile({
|
||||||
// TODO @slorber: this "version prefix" in versioned sidebars looks like a bad idea to me
|
// TODO @slorber: this "version prefix" in versioned sidebars looks like a bad idea to me
|
||||||
// TODO try to get rid of it
|
// TODO try to get rid of it
|
||||||
// Transform id in original sidebar to versioned id.
|
// Transform id in original sidebar to versioned id.
|
||||||
const normalizeItem = (
|
const prependVersion = (item: SidebarItem): SidebarItem => {
|
||||||
item: UnprocessedSidebarItem,
|
if (item.type === 'ref' || item.type === 'doc') {
|
||||||
): UnprocessedSidebarItem => {
|
|
||||||
switch (item.type) {
|
|
||||||
case 'category':
|
|
||||||
return {...item, items: item.items.map(normalizeItem)};
|
|
||||||
case 'ref':
|
|
||||||
case 'doc':
|
|
||||||
return {
|
return {
|
||||||
type: item.type,
|
type: item.type,
|
||||||
id: `version-${version}/${item.id}`,
|
id: `version-${version}/${item.id}`,
|
||||||
};
|
};
|
||||||
default:
|
|
||||||
return item;
|
|
||||||
}
|
}
|
||||||
|
return item;
|
||||||
};
|
};
|
||||||
|
|
||||||
const versionedSidebar: UnprocessedSidebars = Object.entries(
|
const versionedSidebar = Object.entries(loadedSidebars).reduce(
|
||||||
loadedSidebars,
|
(acc: NormalizedSidebars, [sidebarId, sidebar]) => {
|
||||||
).reduce((acc: UnprocessedSidebars, [sidebarId, sidebarItems]) => {
|
const versionedId = `version-${version}/${sidebarId}`;
|
||||||
const newVersionedSidebarId = `version-${version}/${sidebarId}`;
|
acc[versionedId] = transformSidebarItems(
|
||||||
acc[newVersionedSidebarId] = sidebarItems.map(normalizeItem);
|
sidebar as Sidebar,
|
||||||
|
prependVersion,
|
||||||
|
);
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
},
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
const versionedSidebarsDir = getVersionedSidebarsDirPath(siteDir, pluginId);
|
const versionedSidebarsDir = getVersionedSidebarsDirPath(siteDir, pluginId);
|
||||||
const newSidebarFile = path.join(
|
const newSidebarFile = path.join(
|
||||||
|
|
|
@ -21,7 +21,9 @@ import {
|
||||||
createAbsoluteFilePathMatcher,
|
createAbsoluteFilePathMatcher,
|
||||||
} from '@docusaurus/utils';
|
} from '@docusaurus/utils';
|
||||||
import {LoadContext, Plugin, RouteConfig} from '@docusaurus/types';
|
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 {readVersionDocs, processDocMetadata} from './docs';
|
||||||
import {getDocsDirPaths, readVersionsMetadata} from './versions';
|
import {getDocsDirPaths, readVersionsMetadata} from './versions';
|
||||||
|
|
||||||
|
@ -42,14 +44,13 @@ import {
|
||||||
import {RuleSetRule} from 'webpack';
|
import {RuleSetRule} from 'webpack';
|
||||||
import {cliDocsVersionCommand} from './cli';
|
import {cliDocsVersionCommand} from './cli';
|
||||||
import {VERSIONS_JSON_FILE} from './constants';
|
import {VERSIONS_JSON_FILE} from './constants';
|
||||||
import {flatten, keyBy, compact, mapValues} from 'lodash';
|
import {keyBy, compact, mapValues} from 'lodash';
|
||||||
import {toGlobalDataVersion} from './globalData';
|
import {toGlobalDataVersion} from './globalData';
|
||||||
import {toTagDocListProp, toVersionMetadataProp} from './props';
|
import {toTagDocListProp, toVersionMetadataProp} from './props';
|
||||||
import {
|
import {
|
||||||
translateLoadedContent,
|
translateLoadedContent,
|
||||||
getLoadedContentTranslationFiles,
|
getLoadedContentTranslationFiles,
|
||||||
} from './translations';
|
} from './translations';
|
||||||
import {CategoryMetadataFilenamePattern} from './sidebarItemsGenerator';
|
|
||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
import {getVersionTags} from './tags';
|
import {getVersionTags} from './tags';
|
||||||
import {PropTagsListPage} from '@docusaurus/plugin-content-docs-types';
|
import {PropTagsListPage} from '@docusaurus/plugin-content-docs-types';
|
||||||
|
@ -116,13 +117,11 @@ export default function pluginContentDocs(
|
||||||
getPathsToWatch() {
|
getPathsToWatch() {
|
||||||
function getVersionPathsToWatch(version: VersionMetadata): string[] {
|
function getVersionPathsToWatch(version: VersionMetadata): string[] {
|
||||||
const result = [
|
const result = [
|
||||||
...flatten(
|
...options.include.flatMap((pattern) =>
|
||||||
options.include.map((pattern) =>
|
|
||||||
getDocsDirPaths(version).map(
|
getDocsDirPaths(version).map(
|
||||||
(docsDirPath) => `${docsDirPath}/${pattern}`,
|
(docsDirPath) => `${docsDirPath}/${pattern}`,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
`${version.contentPath}/**/${CategoryMetadataFilenamePattern}`,
|
`${version.contentPath}/**/${CategoryMetadataFilenamePattern}`,
|
||||||
];
|
];
|
||||||
if (typeof version.sidebarFilePath === 'string') {
|
if (typeof version.sidebarFilePath === 'string') {
|
||||||
|
@ -131,7 +130,7 @@ export default function pluginContentDocs(
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
return flatten(versionsMetadata.map(getVersionPathsToWatch));
|
return versionsMetadata.flatMap(getVersionPathsToWatch);
|
||||||
},
|
},
|
||||||
|
|
||||||
async loadContent() {
|
async loadContent() {
|
||||||
|
@ -163,14 +162,6 @@ export default function pluginContentDocs(
|
||||||
async function doLoadVersion(
|
async function doLoadVersion(
|
||||||
versionMetadata: VersionMetadata,
|
versionMetadata: VersionMetadata,
|
||||||
): Promise<LoadedVersion> {
|
): Promise<LoadedVersion> {
|
||||||
const unprocessedSidebars = loadSidebars(
|
|
||||||
versionMetadata.sidebarFilePath,
|
|
||||||
{
|
|
||||||
sidebarCollapsed: options.sidebarCollapsed,
|
|
||||||
sidebarCollapsible: options.sidebarCollapsible,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const docsBase: DocMetadataBase[] = await loadVersionDocsBase(
|
const docsBase: DocMetadataBase[] = await loadVersionDocsBase(
|
||||||
versionMetadata,
|
versionMetadata,
|
||||||
);
|
);
|
||||||
|
@ -179,10 +170,9 @@ export default function pluginContentDocs(
|
||||||
(doc) => doc.id,
|
(doc) => doc.id,
|
||||||
);
|
);
|
||||||
|
|
||||||
const sidebars = await processSidebars({
|
const sidebars = await loadSidebars(versionMetadata.sidebarFilePath, {
|
||||||
sidebarItemsGenerator: options.sidebarItemsGenerator,
|
sidebarItemsGenerator: options.sidebarItemsGenerator,
|
||||||
numberPrefixParser: options.numberPrefixParser,
|
numberPrefixParser: options.numberPrefixParser,
|
||||||
unprocessedSidebars,
|
|
||||||
docs: docsBase,
|
docs: docsBase,
|
||||||
version: versionMetadata,
|
version: versionMetadata,
|
||||||
options: {
|
options: {
|
||||||
|
@ -191,18 +181,21 @@ export default function pluginContentDocs(
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const sidebarsUtils = createSidebarsUtils(sidebars);
|
const {
|
||||||
|
checkSidebarsDocIds,
|
||||||
|
getDocNavigation,
|
||||||
|
getFirstDocIdOfFirstSidebar,
|
||||||
|
} = createSidebarsUtils(sidebars);
|
||||||
|
|
||||||
const validDocIds = Object.keys(docsBaseById);
|
const validDocIds = Object.keys(docsBaseById);
|
||||||
sidebarsUtils.checkSidebarsDocIds(
|
checkSidebarsDocIds(
|
||||||
validDocIds,
|
validDocIds,
|
||||||
versionMetadata.sidebarFilePath as string,
|
versionMetadata.sidebarFilePath as string,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add sidebar/next/previous to the docs
|
// Add sidebar/next/previous to the docs
|
||||||
function addNavData(doc: DocMetadataBase): DocMetadata {
|
function addNavData(doc: DocMetadataBase): DocMetadata {
|
||||||
const {sidebarName, previousId, nextId} =
|
const {sidebarName, previousId, nextId} = getDocNavigation(doc.id);
|
||||||
sidebarsUtils.getDocNavigation(doc.id);
|
|
||||||
const toDocNavLink = (navDocId: string): DocNavLink => {
|
const toDocNavLink = (navDocId: string): DocNavLink => {
|
||||||
const {title, permalink, frontMatter} = docsBaseById[navDocId];
|
const {title, permalink, frontMatter} = docsBaseById[navDocId];
|
||||||
return {
|
return {
|
||||||
|
@ -236,8 +229,7 @@ export default function pluginContentDocs(
|
||||||
(doc) =>
|
(doc) =>
|
||||||
doc.unversionedId === options.homePageId || doc.slug === '/',
|
doc.unversionedId === options.homePageId || doc.slug === '/',
|
||||||
);
|
);
|
||||||
const firstDocIdOfFirstSidebar =
|
const firstDocIdOfFirstSidebar = getFirstDocIdOfFirstSidebar();
|
||||||
sidebarsUtils.getFirstDocIdOfFirstSidebar();
|
|
||||||
if (versionHomeDoc) {
|
if (versionHomeDoc) {
|
||||||
return versionHomeDoc;
|
return versionHomeDoc;
|
||||||
} else if (firstDocIdOfFirstSidebar) {
|
} else if (firstDocIdOfFirstSidebar) {
|
||||||
|
@ -429,7 +421,7 @@ export default function pluginContentDocs(
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
function getSourceToPermalink(): SourceToPermalink {
|
function getSourceToPermalink(): SourceToPermalink {
|
||||||
const allDocs = flatten(content.loadedVersions.map((v) => v.docs));
|
const allDocs = content.loadedVersions.flatMap((v) => v.docs);
|
||||||
return mapValues(
|
return mapValues(
|
||||||
keyBy(allDocs, (d) => d.source),
|
keyBy(allDocs, (d) => d.source),
|
||||||
(d) => d.permalink,
|
(d) => d.permalink,
|
||||||
|
@ -452,7 +444,7 @@ export default function pluginContentDocs(
|
||||||
};
|
};
|
||||||
|
|
||||||
function createMDXLoaderRule(): RuleSetRule {
|
function createMDXLoaderRule(): RuleSetRule {
|
||||||
const contentDirs = flatten(versionsMetadata.map(getDocsDirPaths));
|
const contentDirs = versionsMetadata.flatMap(getDocsDirPaths);
|
||||||
return {
|
return {
|
||||||
test: /(\.mdx?)$/,
|
test: /(\.mdx?)$/,
|
||||||
include: contentDirs
|
include: contentDirs
|
||||||
|
|
|
@ -17,7 +17,7 @@ import {GlobExcludeDefault} from '@docusaurus/utils';
|
||||||
import {OptionValidationContext, ValidationResult} from '@docusaurus/types';
|
import {OptionValidationContext, ValidationResult} from '@docusaurus/types';
|
||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
import admonitions from 'remark-admonitions';
|
import admonitions from 'remark-admonitions';
|
||||||
import {DefaultSidebarItemsGenerator} from './sidebarItemsGenerator';
|
import {DefaultSidebarItemsGenerator} from './sidebars/generator';
|
||||||
import {
|
import {
|
||||||
DefaultNumberPrefixParser,
|
DefaultNumberPrefixParser,
|
||||||
DisabledNumberPrefixParser,
|
DisabledNumberPrefixParser,
|
||||||
|
|
|
@ -7,6 +7,7 @@
|
||||||
|
|
||||||
declare module '@docusaurus/plugin-content-docs' {
|
declare module '@docusaurus/plugin-content-docs' {
|
||||||
export type Options = Partial<import('./types').PluginOptions>;
|
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"
|
// 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;
|
docsSidebars: PropSidebars;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PropsSidebarItemBase = {
|
export type PropSidebarItemLink = import('./sidebars/types').SidebarItemLink;
|
||||||
className?: string;
|
export type PropSidebarItemCategory =
|
||||||
customProps?: Record<string, unknown>;
|
import('./sidebars/types').PropSidebarItemCategory;
|
||||||
};
|
export type PropSidebarItem = import('./sidebars/types').PropSidebarItem;
|
||||||
|
export type PropSidebars = import('./sidebars/types').PropSidebars;
|
||||||
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 PropTagDocListDoc = {
|
export type PropTagDocListDoc = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
|
@ -5,14 +5,12 @@
|
||||||
* LICENSE file in the root directory of this source tree.
|
* LICENSE file in the root directory of this source tree.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {LoadedVersion, VersionTag, DocMetadata} from './types';
|
||||||
LoadedVersion,
|
import type {
|
||||||
SidebarItemDoc,
|
SidebarItemDoc,
|
||||||
SidebarItemLink,
|
SidebarItemLink,
|
||||||
SidebarItem,
|
SidebarItem,
|
||||||
VersionTag,
|
} from './sidebars/types';
|
||||||
DocMetadata,
|
|
||||||
} from './types';
|
|
||||||
import type {
|
import type {
|
||||||
PropSidebars,
|
PropSidebars,
|
||||||
PropVersionMetadata,
|
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
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
exports[`loadSidebars sidebars link 1`] = `
|
exports[`loadUnprocessedSidebars sidebars link 1`] = `
|
||||||
Object {
|
Object {
|
||||||
"docs": Array [
|
"docs": Array [
|
||||||
Object {
|
Object {
|
||||||
|
@ -20,7 +20,7 @@ Object {
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`loadSidebars sidebars with category.collapsed property 1`] = `
|
exports[`loadUnprocessedSidebars sidebars with category.collapsed property 1`] = `
|
||||||
Object {
|
Object {
|
||||||
"docs": Array [
|
"docs": Array [
|
||||||
Object {
|
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 {
|
Object {
|
||||||
"docs": Array [
|
"docs": Array [
|
||||||
Object {
|
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 {
|
Object {
|
||||||
"docs": Array [
|
"docs": Array [
|
||||||
Object {
|
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 {
|
Object {
|
||||||
"docs": Array [
|
"docs": Array [
|
||||||
Object {
|
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 {
|
Object {
|
||||||
"docs": Array [
|
"docs": Array [
|
||||||
Object {
|
Object {
|
|
@ -8,10 +8,10 @@
|
||||||
import {
|
import {
|
||||||
CategoryMetadatasFile,
|
CategoryMetadatasFile,
|
||||||
DefaultSidebarItemsGenerator,
|
DefaultSidebarItemsGenerator,
|
||||||
} from '../sidebarItemsGenerator';
|
} from '../generator';
|
||||||
import {Sidebar, SidebarItemsGenerator} from '../types';
|
import {Sidebar, SidebarItemsGenerator} from '../types';
|
||||||
import fs from 'fs-extra';
|
import fs from 'fs-extra';
|
||||||
import {DefaultNumberPrefixParser} from '../numberPrefix';
|
import {DefaultNumberPrefixParser} from '../../numberPrefix';
|
||||||
|
|
||||||
describe('DefaultSidebarItemsGenerator', () => {
|
describe('DefaultSidebarItemsGenerator', () => {
|
||||||
function testDefaultSidebarItemsGenerator(
|
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.
|
* LICENSE file in the root directory of this source tree.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import type {LoadedVersion, LoadedContent} from './types';
|
||||||
LoadedVersion,
|
import type {Sidebar, Sidebars} from './sidebars/types';
|
||||||
Sidebar,
|
|
||||||
LoadedContent,
|
|
||||||
Sidebars,
|
|
||||||
SidebarItem,
|
|
||||||
} from './types';
|
|
||||||
|
|
||||||
import {chain, mapValues, flatten, keyBy} from 'lodash';
|
import {chain, mapValues, keyBy} from 'lodash';
|
||||||
import {
|
import {
|
||||||
collectSidebarCategories,
|
collectSidebarCategories,
|
||||||
transformSidebarItems,
|
transformSidebarItems,
|
||||||
collectSidebarLinks,
|
collectSidebarLinks,
|
||||||
} from './sidebars';
|
} from './sidebars/utils';
|
||||||
import {
|
import {
|
||||||
TranslationFileContent,
|
TranslationFileContent,
|
||||||
TranslationFile,
|
TranslationFile,
|
||||||
|
@ -131,7 +126,7 @@ function translateSidebar({
|
||||||
sidebarName: string;
|
sidebarName: string;
|
||||||
sidebarsTranslations: TranslationFileContent;
|
sidebarsTranslations: TranslationFileContent;
|
||||||
}): Sidebar {
|
}): Sidebar {
|
||||||
return transformSidebarItems(sidebar, (item: SidebarItem): SidebarItem => {
|
return transformSidebarItems(sidebar, (item) => {
|
||||||
if (item.type === 'category') {
|
if (item.type === 'category') {
|
||||||
return {
|
return {
|
||||||
...item,
|
...item,
|
||||||
|
@ -222,7 +217,7 @@ function translateVersion(
|
||||||
function getVersionsTranslationFiles(
|
function getVersionsTranslationFiles(
|
||||||
versions: LoadedVersion[],
|
versions: LoadedVersion[],
|
||||||
): TranslationFiles {
|
): TranslationFiles {
|
||||||
return flatten(versions.map(getVersionTranslationFiles));
|
return versions.flatMap(getVersionTranslationFiles);
|
||||||
}
|
}
|
||||||
function translateVersions(
|
function translateVersions(
|
||||||
versions: LoadedVersion[],
|
versions: LoadedVersion[],
|
||||||
|
|
|
@ -13,6 +13,7 @@ import type {
|
||||||
BrokenMarkdownLink as IBrokenMarkdownLink,
|
BrokenMarkdownLink as IBrokenMarkdownLink,
|
||||||
ContentPaths,
|
ContentPaths,
|
||||||
} from '@docusaurus/utils/lib/markdownLinks';
|
} from '@docusaurus/utils/lib/markdownLinks';
|
||||||
|
import type {SidebarItemsGeneratorOption, Sidebars} from './sidebars/types';
|
||||||
|
|
||||||
export type DocFile = {
|
export type DocFile = {
|
||||||
contentPath: string; // /!\ may be localized
|
contentPath: string; // /!\ may be localized
|
||||||
|
@ -104,100 +105,6 @@ export type PluginOptions = MetadataOptions &
|
||||||
tagsBasePath: string;
|
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 = {
|
export type LastUpdateData = {
|
||||||
lastUpdatedAt?: number;
|
lastUpdatedAt?: number;
|
||||||
formattedLastUpdatedAt?: string;
|
formattedLastUpdatedAt?: string;
|
||||||
|
|
|
@ -5,7 +5,10 @@
|
||||||
* LICENSE file in the root directory of this source tree.
|
* LICENSE file in the root directory of this source tree.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
module.exports = {
|
// @ts-check
|
||||||
|
|
||||||
|
/** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */
|
||||||
|
const sidebars = {
|
||||||
docs: [
|
docs: [
|
||||||
'introduction',
|
'introduction',
|
||||||
{
|
{
|
||||||
|
@ -137,3 +140,5 @@ module.exports = {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
module.exports = sidebars;
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue