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

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

View file

@ -9,7 +9,10 @@
Create as many sidebars as you want.
*/
module.exports = {
// @ts-check
/** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */
const sidebars = {
// By default, Docusaurus generates a sidebar from the docs folder structure
tutorialSidebar: [{type: 'autogenerated', dirName: '.'}],
@ -24,3 +27,5 @@ module.exports = {
],
*/
};
module.exports = sidebars;

View file

@ -22,7 +22,8 @@
"@types/js-yaml": "^4.0.0",
"@types/picomatch": "^2.2.1",
"commander": "^5.1.0",
"picomatch": "^2.1.1"
"picomatch": "^2.1.1",
"utility-types": "^3.10.0"
},
"dependencies": {
"@docusaurus/core": "2.0.0-beta.6",

View file

@ -22,17 +22,16 @@ import {DEFAULT_PLUGIN_ID} from '@docusaurus/core/lib/constants';
import * as cliDocs from '../cli';
import {OptionsSchema} from '../options';
import {normalizePluginOptions} from '@docusaurus/utils-validation';
import {
DocMetadata,
LoadedVersion,
import type {DocMetadata, LoadedVersion} from '../types';
import type {
SidebarItem,
SidebarItemsGeneratorOption,
SidebarItemsGeneratorOptionArgs,
} from '../types';
} from '../sidebars/types';
import {toSidebarsProp} from '../props';
import {validate} from 'webpack';
import {DefaultSidebarItemsGenerator} from '../sidebarItemsGenerator';
import {DefaultSidebarItemsGenerator} from '../sidebars/generator';
import {DisabledSidebars} from '../sidebars';
function findDocById(version: LoadedVersion, unversionedId: string) {

View file

@ -7,7 +7,7 @@
import {OptionsSchema, DEFAULT_OPTIONS, validateOptions} from '../options';
import {normalizePluginOptions} from '@docusaurus/utils-validation';
import {DefaultSidebarItemsGenerator} from '../sidebarItemsGenerator';
import {DefaultSidebarItemsGenerator} from '../sidebars/generator';
import {
DefaultNumberPrefixParser,
DisabledNumberPrefixParser,

View file

@ -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,
},
],
});
});
});

View file

@ -12,13 +12,10 @@ import {
} from './versions';
import fs from 'fs-extra';
import path from 'path';
import {
PathOptions,
UnprocessedSidebarItem,
UnprocessedSidebars,
SidebarOptions,
} from './types';
import {loadSidebars, resolveSidebarPathOption} from './sidebars';
import type {PathOptions, SidebarOptions} from './types';
import {transformSidebarItems} from './sidebars/utils';
import type {SidebarItem, NormalizedSidebars, Sidebar} from './sidebars/types';
import {loadUnprocessedSidebars, resolveSidebarPathOption} from './sidebars';
import {DEFAULT_PLUGIN_ID} from '@docusaurus/core/lib/constants';
function createVersionedSidebarFile({
@ -35,7 +32,7 @@ function createVersionedSidebarFile({
options: SidebarOptions;
}) {
// Load current sidebar and create a new versioned sidebars file (if needed).
const loadedSidebars = loadSidebars(sidebarPath, options);
const loadedSidebars = loadUnprocessedSidebars(sidebarPath, options);
// Do not create a useless versioned sidebars file if sidebars file is empty or sidebars are disabled/false)
const shouldCreateVersionedSidebarFile =
@ -45,30 +42,27 @@ function createVersionedSidebarFile({
// TODO @slorber: this "version prefix" in versioned sidebars looks like a bad idea to me
// TODO try to get rid of it
// Transform id in original sidebar to versioned id.
const normalizeItem = (
item: UnprocessedSidebarItem,
): UnprocessedSidebarItem => {
switch (item.type) {
case 'category':
return {...item, items: item.items.map(normalizeItem)};
case 'ref':
case 'doc':
return {
type: item.type,
id: `version-${version}/${item.id}`,
};
default:
return item;
const prependVersion = (item: SidebarItem): SidebarItem => {
if (item.type === 'ref' || item.type === 'doc') {
return {
type: item.type,
id: `version-${version}/${item.id}`,
};
}
return item;
};
const versionedSidebar: UnprocessedSidebars = Object.entries(
loadedSidebars,
).reduce((acc: UnprocessedSidebars, [sidebarId, sidebarItems]) => {
const newVersionedSidebarId = `version-${version}/${sidebarId}`;
acc[newVersionedSidebarId] = sidebarItems.map(normalizeItem);
return acc;
}, {});
const versionedSidebar = Object.entries(loadedSidebars).reduce(
(acc: NormalizedSidebars, [sidebarId, sidebar]) => {
const versionedId = `version-${version}/${sidebarId}`;
acc[versionedId] = transformSidebarItems(
sidebar as Sidebar,
prependVersion,
);
return acc;
},
{},
);
const versionedSidebarsDir = getVersionedSidebarsDirPath(siteDir, pluginId);
const newSidebarFile = path.join(

View file

@ -21,7 +21,9 @@ import {
createAbsoluteFilePathMatcher,
} from '@docusaurus/utils';
import {LoadContext, Plugin, RouteConfig} from '@docusaurus/types';
import {loadSidebars, createSidebarsUtils, processSidebars} from './sidebars';
import {loadSidebars} from './sidebars';
import {createSidebarsUtils} from './sidebars/utils';
import {CategoryMetadataFilenamePattern} from './sidebars/generator';
import {readVersionDocs, processDocMetadata} from './docs';
import {getDocsDirPaths, readVersionsMetadata} from './versions';
@ -42,14 +44,13 @@ import {
import {RuleSetRule} from 'webpack';
import {cliDocsVersionCommand} from './cli';
import {VERSIONS_JSON_FILE} from './constants';
import {flatten, keyBy, compact, mapValues} from 'lodash';
import {keyBy, compact, mapValues} from 'lodash';
import {toGlobalDataVersion} from './globalData';
import {toTagDocListProp, toVersionMetadataProp} from './props';
import {
translateLoadedContent,
getLoadedContentTranslationFiles,
} from './translations';
import {CategoryMetadataFilenamePattern} from './sidebarItemsGenerator';
import chalk from 'chalk';
import {getVersionTags} from './tags';
import {PropTagsListPage} from '@docusaurus/plugin-content-docs-types';
@ -116,11 +117,9 @@ export default function pluginContentDocs(
getPathsToWatch() {
function getVersionPathsToWatch(version: VersionMetadata): string[] {
const result = [
...flatten(
options.include.map((pattern) =>
getDocsDirPaths(version).map(
(docsDirPath) => `${docsDirPath}/${pattern}`,
),
...options.include.flatMap((pattern) =>
getDocsDirPaths(version).map(
(docsDirPath) => `${docsDirPath}/${pattern}`,
),
),
`${version.contentPath}/**/${CategoryMetadataFilenamePattern}`,
@ -131,7 +130,7 @@ export default function pluginContentDocs(
return result;
}
return flatten(versionsMetadata.map(getVersionPathsToWatch));
return versionsMetadata.flatMap(getVersionPathsToWatch);
},
async loadContent() {
@ -163,14 +162,6 @@ export default function pluginContentDocs(
async function doLoadVersion(
versionMetadata: VersionMetadata,
): Promise<LoadedVersion> {
const unprocessedSidebars = loadSidebars(
versionMetadata.sidebarFilePath,
{
sidebarCollapsed: options.sidebarCollapsed,
sidebarCollapsible: options.sidebarCollapsible,
},
);
const docsBase: DocMetadataBase[] = await loadVersionDocsBase(
versionMetadata,
);
@ -179,10 +170,9 @@ export default function pluginContentDocs(
(doc) => doc.id,
);
const sidebars = await processSidebars({
const sidebars = await loadSidebars(versionMetadata.sidebarFilePath, {
sidebarItemsGenerator: options.sidebarItemsGenerator,
numberPrefixParser: options.numberPrefixParser,
unprocessedSidebars,
docs: docsBase,
version: versionMetadata,
options: {
@ -191,18 +181,21 @@ export default function pluginContentDocs(
},
});
const sidebarsUtils = createSidebarsUtils(sidebars);
const {
checkSidebarsDocIds,
getDocNavigation,
getFirstDocIdOfFirstSidebar,
} = createSidebarsUtils(sidebars);
const validDocIds = Object.keys(docsBaseById);
sidebarsUtils.checkSidebarsDocIds(
checkSidebarsDocIds(
validDocIds,
versionMetadata.sidebarFilePath as string,
);
// Add sidebar/next/previous to the docs
function addNavData(doc: DocMetadataBase): DocMetadata {
const {sidebarName, previousId, nextId} =
sidebarsUtils.getDocNavigation(doc.id);
const {sidebarName, previousId, nextId} = getDocNavigation(doc.id);
const toDocNavLink = (navDocId: string): DocNavLink => {
const {title, permalink, frontMatter} = docsBaseById[navDocId];
return {
@ -236,8 +229,7 @@ export default function pluginContentDocs(
(doc) =>
doc.unversionedId === options.homePageId || doc.slug === '/',
);
const firstDocIdOfFirstSidebar =
sidebarsUtils.getFirstDocIdOfFirstSidebar();
const firstDocIdOfFirstSidebar = getFirstDocIdOfFirstSidebar();
if (versionHomeDoc) {
return versionHomeDoc;
} else if (firstDocIdOfFirstSidebar) {
@ -429,7 +421,7 @@ export default function pluginContentDocs(
} = options;
function getSourceToPermalink(): SourceToPermalink {
const allDocs = flatten(content.loadedVersions.map((v) => v.docs));
const allDocs = content.loadedVersions.flatMap((v) => v.docs);
return mapValues(
keyBy(allDocs, (d) => d.source),
(d) => d.permalink,
@ -452,7 +444,7 @@ export default function pluginContentDocs(
};
function createMDXLoaderRule(): RuleSetRule {
const contentDirs = flatten(versionsMetadata.map(getDocsDirPaths));
const contentDirs = versionsMetadata.flatMap(getDocsDirPaths);
return {
test: /(\.mdx?)$/,
include: contentDirs

View file

@ -17,7 +17,7 @@ import {GlobExcludeDefault} from '@docusaurus/utils';
import {OptionValidationContext, ValidationResult} from '@docusaurus/types';
import chalk from 'chalk';
import admonitions from 'remark-admonitions';
import {DefaultSidebarItemsGenerator} from './sidebarItemsGenerator';
import {DefaultSidebarItemsGenerator} from './sidebars/generator';
import {
DefaultNumberPrefixParser,
DisabledNumberPrefixParser,

View file

@ -7,6 +7,7 @@
declare module '@docusaurus/plugin-content-docs' {
export type Options = Partial<import('./types').PluginOptions>;
export type SidebarsConfig = import('./sidebars/types').SidebarsConfig;
}
// TODO public api surface types should rather be exposed as "@docusaurus/plugin-content-docs"
@ -29,30 +30,11 @@ declare module '@docusaurus/plugin-content-docs-types' {
docsSidebars: PropSidebars;
};
type PropsSidebarItemBase = {
className?: string;
customProps?: Record<string, unknown>;
};
export type PropSidebarItemLink = PropsSidebarItemBase & {
type: 'link';
href: string;
label: string;
};
export type PropSidebarItemCategory = PropsSidebarItemBase & {
type: 'category';
label: string;
items: PropSidebarItem[];
collapsed: boolean;
collapsible: boolean;
};
export type PropSidebarItem = PropSidebarItemLink | PropSidebarItemCategory;
export type PropSidebars = {
[sidebarId: string]: PropSidebarItem[];
};
export type PropSidebarItemLink = import('./sidebars/types').SidebarItemLink;
export type PropSidebarItemCategory =
import('./sidebars/types').PropSidebarItemCategory;
export type PropSidebarItem = import('./sidebars/types').PropSidebarItem;
export type PropSidebars = import('./sidebars/types').PropSidebars;
export type PropTagDocListDoc = {
id: string;

View file

@ -5,14 +5,12 @@
* LICENSE file in the root directory of this source tree.
*/
import {
LoadedVersion,
import {LoadedVersion, VersionTag, DocMetadata} from './types';
import type {
SidebarItemDoc,
SidebarItemLink,
SidebarItem,
VersionTag,
DocMetadata,
} from './types';
} from './sidebars/types';
import type {
PropSidebars,
PropVersionMetadata,

View file

@ -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);
}

View file

@ -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,
};
}

View file

@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`loadSidebars sidebars link 1`] = `
exports[`loadUnprocessedSidebars sidebars link 1`] = `
Object {
"docs": Array [
Object {
@ -20,7 +20,7 @@ Object {
}
`;
exports[`loadSidebars sidebars with category.collapsed property 1`] = `
exports[`loadUnprocessedSidebars sidebars with category.collapsed property 1`] = `
Object {
"docs": Array [
Object {
@ -67,7 +67,7 @@ Object {
}
`;
exports[`loadSidebars sidebars with category.collapsed property at first level 1`] = `
exports[`loadUnprocessedSidebars sidebars with category.collapsed property at first level 1`] = `
Object {
"docs": Array [
Object {
@ -98,7 +98,7 @@ Object {
}
`;
exports[`loadSidebars sidebars with deep level of category 1`] = `
exports[`loadUnprocessedSidebars sidebars with deep level of category 1`] = `
Object {
"docs": Array [
Object {
@ -165,7 +165,7 @@ Object {
}
`;
exports[`loadSidebars sidebars with first level not a category 1`] = `
exports[`loadUnprocessedSidebars sidebars with first level not a category 1`] = `
Object {
"docs": Array [
Object {
@ -188,7 +188,7 @@ Object {
}
`;
exports[`loadSidebars sidebars with known sidebar item type 1`] = `
exports[`loadUnprocessedSidebars sidebars with known sidebar item type 1`] = `
Object {
"docs": Array [
Object {

View file

@ -8,10 +8,10 @@
import {
CategoryMetadatasFile,
DefaultSidebarItemsGenerator,
} from '../sidebarItemsGenerator';
} from '../generator';
import {Sidebar, SidebarItemsGenerator} from '../types';
import fs from 'fs-extra';
import {DefaultNumberPrefixParser} from '../numberPrefix';
import {DefaultNumberPrefixParser} from '../../numberPrefix';
describe('DefaultSidebarItemsGenerator', () => {
function testDefaultSidebarItemsGenerator(

View file

@ -0,0 +1,202 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import path from 'path';
import {
loadUnprocessedSidebars,
DefaultSidebars,
DisabledSidebars,
} from '../index';
import type {SidebarOptions} from '../../types';
describe('loadUnprocessedSidebars', () => {
const fixtureDir = path.join(__dirname, '__fixtures__', 'sidebars');
const options: SidebarOptions = {
sidebarCollapsed: true,
sidebarCollapsible: true,
};
test('sidebars with known sidebar item type', async () => {
const sidebarPath = path.join(fixtureDir, 'sidebars.json');
const result = loadUnprocessedSidebars(sidebarPath, options);
expect(result).toMatchSnapshot();
});
test('sidebars with deep level of category', async () => {
const sidebarPath = path.join(fixtureDir, 'sidebars-category.js');
const result = loadUnprocessedSidebars(sidebarPath, options);
expect(result).toMatchSnapshot();
});
test('sidebars shorthand and longform lead to exact same sidebar', async () => {
const sidebarPath1 = path.join(fixtureDir, 'sidebars-category.js');
const sidebarPath2 = path.join(
fixtureDir,
'sidebars-category-shorthand.js',
);
const sidebar1 = loadUnprocessedSidebars(sidebarPath1, options);
const sidebar2 = loadUnprocessedSidebars(sidebarPath2, options);
expect(sidebar1).toEqual(sidebar2);
});
test('sidebars with category but category.items is not an array', async () => {
const sidebarPath = path.join(
fixtureDir,
'sidebars-category-wrong-items.json',
);
expect(() => loadUnprocessedSidebars(sidebarPath, options))
.toThrowErrorMatchingInlineSnapshot(`
"{
\\"type\\": \\"category\\",
\\"label\\": \\"Category Label\\",
\\"items\\" [1]: \\"doc1\\"
}

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

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

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

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

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

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

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

View file

@ -0,0 +1,148 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import {processSidebars} from '../processor';
import type {
SidebarItem,
SidebarItemsGenerator,
Sidebars,
NormalizedSidebars,
} from '../types';
import {DefaultSidebarItemsGenerator} from '../generator';
describe('processSidebars', () => {
const StaticGeneratedSidebarSlice: SidebarItem[] = [
{type: 'doc', id: 'doc-generated-id-1'},
{type: 'doc', id: 'doc-generated-id-2'},
];
const StaticSidebarItemsGenerator: SidebarItemsGenerator = jest.fn(
async () => {
return StaticGeneratedSidebarSlice;
},
);
async function testProcessSidebars(unprocessedSidebars: NormalizedSidebars) {
return processSidebars(unprocessedSidebars, {
sidebarItemsGenerator: StaticSidebarItemsGenerator,
docs: [],
// @ts-expect-error: useless for this test
version: {},
});
}
test('let sidebars without autogenerated items untouched', async () => {
const unprocessedSidebars: NormalizedSidebars = {
someSidebar: [
{type: 'doc', id: 'doc1'},
{
type: 'category',
collapsed: false,
collapsible: true,
items: [{type: 'doc', id: 'doc2'}],
label: 'Category',
},
{type: 'link', href: 'https://facebook.com', label: 'FB'},
],
secondSidebar: [
{type: 'doc', id: 'doc3'},
{type: 'link', href: 'https://instagram.com', label: 'IG'},
{
type: 'category',
collapsed: false,
collapsible: true,
items: [{type: 'doc', id: 'doc4'}],
label: 'Category',
},
],
};
const processedSidebar = await testProcessSidebars(unprocessedSidebars);
expect(processedSidebar).toEqual(unprocessedSidebars);
});
test('replace autogenerated items by generated sidebars slices', async () => {
const unprocessedSidebars: NormalizedSidebars = {
someSidebar: [
{type: 'doc', id: 'doc1'},
{
type: 'category',
collapsed: false,
collapsible: true,
items: [
{type: 'doc', id: 'doc2'},
{type: 'autogenerated', dirName: 'dir1'},
],
label: 'Category',
},
{type: 'link', href: 'https://facebook.com', label: 'FB'},
],
secondSidebar: [
{type: 'doc', id: 'doc3'},
{type: 'autogenerated', dirName: 'dir2'},
{type: 'link', href: 'https://instagram.com', label: 'IG'},
{type: 'autogenerated', dirName: 'dir3'},
{
type: 'category',
collapsed: false,
collapsible: true,
items: [{type: 'doc', id: 'doc4'}],
label: 'Category',
},
],
};
const processedSidebar = await testProcessSidebars(unprocessedSidebars);
expect(StaticSidebarItemsGenerator).toHaveBeenCalledTimes(3);
expect(StaticSidebarItemsGenerator).toHaveBeenCalledWith({
defaultSidebarItemsGenerator: DefaultSidebarItemsGenerator,
item: {type: 'autogenerated', dirName: 'dir1'},
docs: [],
version: {},
});
expect(StaticSidebarItemsGenerator).toHaveBeenCalledWith({
defaultSidebarItemsGenerator: DefaultSidebarItemsGenerator,
item: {type: 'autogenerated', dirName: 'dir2'},
docs: [],
version: {},
});
expect(StaticSidebarItemsGenerator).toHaveBeenCalledWith({
defaultSidebarItemsGenerator: DefaultSidebarItemsGenerator,
item: {type: 'autogenerated', dirName: 'dir3'},
docs: [],
version: {},
});
expect(processedSidebar).toEqual({
someSidebar: [
{type: 'doc', id: 'doc1'},
{
type: 'category',
collapsed: false,
collapsible: true,
items: [{type: 'doc', id: 'doc2'}, ...StaticGeneratedSidebarSlice],
label: 'Category',
},
{type: 'link', href: 'https://facebook.com', label: 'FB'},
],
secondSidebar: [
{type: 'doc', id: 'doc3'},
...StaticGeneratedSidebarSlice,
{type: 'link', href: 'https://instagram.com', label: 'IG'},
...StaticGeneratedSidebarSlice,
{
type: 'category',
collapsed: false,
collapsible: true,
items: [{type: 'doc', id: 'doc4'}],
label: 'Category',
},
],
} as Sidebars);
});
});

View file

@ -0,0 +1,395 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import {
createSidebarsUtils,
collectSidebarDocItems,
collectSidebarCategories,
collectSidebarLinks,
transformSidebarItems,
collectSidebarsDocIds,
} from '../utils';
import type {Sidebar, Sidebars} from '../types';
describe('createSidebarsUtils', () => {
const sidebar1: Sidebar = [
{
type: 'category',
collapsed: false,
collapsible: true,
label: 'Category1',
items: [
{
type: 'category',
collapsed: false,
collapsible: true,
label: 'Subcategory 1',
items: [{type: 'doc', id: 'doc1'}],
},
{type: 'doc', id: 'doc2'},
],
},
];
const sidebar2: Sidebar = [
{
type: 'category',
collapsed: false,
collapsible: true,
label: 'Category2',
items: [
{type: 'doc', id: 'doc3'},
{type: 'doc', id: 'doc4'},
],
},
];
const sidebars: Sidebars = {sidebar1, sidebar2};
const {getFirstDocIdOfFirstSidebar, getSidebarNameByDocId, getDocNavigation} =
createSidebarsUtils(sidebars);
test('getSidebarNameByDocId', async () => {
expect(getFirstDocIdOfFirstSidebar()).toEqual('doc1');
});
test('getSidebarNameByDocId', async () => {
expect(getSidebarNameByDocId('doc1')).toEqual('sidebar1');
expect(getSidebarNameByDocId('doc2')).toEqual('sidebar1');
expect(getSidebarNameByDocId('doc3')).toEqual('sidebar2');
expect(getSidebarNameByDocId('doc4')).toEqual('sidebar2');
expect(getSidebarNameByDocId('doc5')).toEqual(undefined);
expect(getSidebarNameByDocId('doc6')).toEqual(undefined);
});
test('getDocNavigation', async () => {
expect(getDocNavigation('doc1')).toEqual({
sidebarName: 'sidebar1',
previousId: undefined,
nextId: 'doc2',
});
expect(getDocNavigation('doc2')).toEqual({
sidebarName: 'sidebar1',
previousId: 'doc1',
nextId: undefined,
});
expect(getDocNavigation('doc3')).toEqual({
sidebarName: 'sidebar2',
previousId: undefined,
nextId: 'doc4',
});
expect(getDocNavigation('doc4')).toEqual({
sidebarName: 'sidebar2',
previousId: 'doc3',
nextId: undefined,
});
});
});
describe('collectSidebarDocItems', () => {
test('can collect docs', async () => {
const sidebar: Sidebar = [
{
type: 'category',
collapsed: false,
collapsible: true,
label: 'Category1',
items: [
{
type: 'category',
collapsed: false,
collapsible: true,
label: 'Subcategory 1',
items: [{type: 'doc', id: 'doc1'}],
},
{
type: 'category',
collapsed: false,
collapsible: true,
label: 'Subcategory 2',
items: [
{type: 'doc', id: 'doc2'},
{
type: 'category',
collapsed: false,
collapsible: true,
label: 'Sub sub category 1',
items: [{type: 'doc', id: 'doc3'}],
},
],
},
],
},
{
type: 'category',
collapsed: false,
collapsible: true,
label: 'Category2',
items: [
{type: 'doc', id: 'doc4'},
{type: 'doc', id: 'doc5'},
],
},
];
expect(collectSidebarDocItems(sidebar).map((doc) => doc.id)).toEqual([
'doc1',
'doc2',
'doc3',
'doc4',
'doc5',
]);
});
});
describe('collectSidebarCategories', () => {
test('can collect categories', async () => {
const sidebar: Sidebar = [
{
type: 'category',
collapsed: false,
collapsible: true,
label: 'Category1',
items: [
{
type: 'category',
collapsed: false,
collapsible: true,
label: 'Subcategory 1',
items: [{type: 'doc', id: 'doc1'}],
},
{
type: 'category',
collapsed: false,
collapsible: true,
label: 'Subcategory 2',
items: [
{type: 'doc', id: 'doc2'},
{
type: 'category',
collapsed: false,
collapsible: true,
label: 'Sub sub category 1',
items: [{type: 'doc', id: 'doc3'}],
},
],
},
],
},
{
type: 'category',
collapsed: false,
collapsible: true,
label: 'Category2',
items: [
{type: 'doc', id: 'doc4'},
{type: 'doc', id: 'doc5'},
],
},
];
expect(
collectSidebarCategories(sidebar).map((category) => category.label),
).toEqual([
'Category1',
'Subcategory 1',
'Subcategory 2',
'Sub sub category 1',
'Category2',
]);
});
});
describe('collectSidebarLinks', () => {
test('can collect links', async () => {
const sidebar: Sidebar = [
{
type: 'category',
collapsed: false,
collapsible: true,
label: 'Category1',
items: [
{
type: 'link',
href: 'https://google.com',
label: 'Google',
},
{
type: 'category',
collapsed: false,
collapsible: true,
label: 'Subcategory 2',
items: [
{
type: 'link',
href: 'https://facebook.com',
label: 'Facebook',
},
],
},
],
},
];
expect(collectSidebarLinks(sidebar).map((link) => link.href)).toEqual([
'https://google.com',
'https://facebook.com',
]);
});
});
describe('collectSidebarsDocIds', () => {
test('can collect sidebars doc items', async () => {
const sidebar1: Sidebar = [
{
type: 'category',
collapsed: false,
collapsible: true,
label: 'Category1',
items: [
{
type: 'category',
collapsed: false,
collapsible: true,
label: 'Subcategory 1',
items: [{type: 'doc', id: 'doc1'}],
},
{type: 'doc', id: 'doc2'},
],
},
];
const sidebar2: Sidebar = [
{
type: 'category',
collapsed: false,
collapsible: true,
label: 'Category2',
items: [
{type: 'doc', id: 'doc3'},
{type: 'doc', id: 'doc4'},
],
},
];
const sidebar3: Sidebar = [
{type: 'doc', id: 'doc5'},
{type: 'doc', id: 'doc6'},
];
expect(collectSidebarsDocIds({sidebar1, sidebar2, sidebar3})).toEqual({
sidebar1: ['doc1', 'doc2'],
sidebar2: ['doc3', 'doc4'],
sidebar3: ['doc5', 'doc6'],
});
});
});
describe('transformSidebarItems', () => {
test('can transform sidebar items', async () => {
const sidebar: Sidebar = [
{
type: 'category',
collapsed: false,
collapsible: true,
label: 'Category1',
items: [
{
type: 'category',
collapsed: false,
collapsible: true,
label: 'Subcategory 1',
items: [{type: 'doc', id: 'doc1'}],
customProps: {fakeProp: false},
},
{
type: 'category',
collapsed: false,
collapsible: true,
label: 'Subcategory 2',
items: [
{type: 'doc', id: 'doc2'},
{
type: 'category',
collapsed: false,
collapsible: true,
label: 'Sub sub category 1',
items: [
{type: 'doc', id: 'doc3', customProps: {lorem: 'ipsum'}},
],
},
],
},
],
},
{
type: 'category',
collapsed: false,
collapsible: true,
label: 'Category2',
items: [
{type: 'doc', id: 'doc4'},
{type: 'doc', id: 'doc5'},
],
},
];
expect(
transformSidebarItems(sidebar, (item) => {
if (item.type === 'category') {
return {...item, label: `MODIFIED LABEL: ${item.label}`};
}
return item;
}),
).toEqual([
{
type: 'category',
collapsed: false,
collapsible: true,
label: 'MODIFIED LABEL: Category1',
items: [
{
type: 'category',
collapsed: false,
collapsible: true,
label: 'MODIFIED LABEL: Subcategory 1',
items: [{type: 'doc', id: 'doc1'}],
customProps: {fakeProp: false},
},
{
type: 'category',
collapsed: false,
collapsible: true,
label: 'MODIFIED LABEL: Subcategory 2',
items: [
{type: 'doc', id: 'doc2'},
{
type: 'category',
collapsed: false,
collapsible: true,
label: 'MODIFIED LABEL: Sub sub category 1',
items: [
{type: 'doc', id: 'doc3', customProps: {lorem: 'ipsum'}},
],
},
],
},
],
},
{
type: 'category',
collapsed: false,
collapsible: true,
label: 'MODIFIED LABEL: Category2',
items: [
{type: 'doc', id: 'doc4'},
{type: 'doc', id: 'doc5'},
],
},
]);
});
});

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,156 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import {Optional} from 'utility-types';
import type {
DocMetadataBase,
VersionMetadata,
NumberPrefixParser,
SidebarOptions,
} from '../types';
// Makes all properties visible when hovering over the type
type Expand<T extends Record<string, unknown>> = {[P in keyof T]: T[P]};
export type SidebarItemBase = {
className?: string;
customProps?: Record<string, unknown>;
};
export type SidebarItemDoc = SidebarItemBase & {
type: 'doc' | 'ref';
label?: string;
id: string;
};
export type SidebarItemLink = SidebarItemBase & {
type: 'link';
href: string;
label: string;
};
export type SidebarItemAutogenerated = SidebarItemBase & {
type: 'autogenerated';
dirName: string;
};
type SidebarItemCategoryBase = SidebarItemBase & {
type: 'category';
label: string;
collapsed: boolean;
collapsible: boolean;
};
// The user-given configuration in sidebars.js, before normalization
export type SidebarItemCategoryConfig = Expand<
Optional<SidebarItemCategoryBase, 'collapsed' | 'collapsible'> & {
items: SidebarItemConfig[];
}
>;
export type SidebarCategoriesShorthand = {
[sidebarCategory: string]: SidebarItemConfig[];
};
export function isCategoriesShorthand(
item: SidebarItemConfig,
): item is SidebarCategoriesShorthand {
return typeof item !== 'string' && !item.type;
}
export type SidebarItemConfig =
| SidebarItemDoc
| SidebarItemLink
| SidebarItemAutogenerated
| SidebarItemCategoryConfig
| string
| SidebarCategoriesShorthand;
export type SidebarConfig = SidebarCategoriesShorthand | SidebarItemConfig[];
export type SidebarsConfig = {
[sidebarId: string]: SidebarConfig;
};
// Normalized but still has 'autogenerated', which will be handled in processing
export type NormalizedSidebarItemCategory = Expand<
SidebarItemCategoryBase & {
items: NormalizedSidebarItem[];
}
>;
export type NormalizedSidebarItem =
| SidebarItemDoc
| SidebarItemLink
| NormalizedSidebarItemCategory
| SidebarItemAutogenerated;
export type NormalizedSidebar = NormalizedSidebarItem[];
export type NormalizedSidebars = {
[sidebarId: string]: NormalizedSidebar;
};
export type SidebarItemCategory = Expand<
SidebarItemCategoryBase & {
items: SidebarItem[];
}
>;
export type SidebarItem =
| SidebarItemDoc
| SidebarItemLink
| SidebarItemCategory;
export type Sidebar = SidebarItem[];
export type SidebarItemType = SidebarItem['type'];
export type Sidebars = {
[sidebarId: string]: Sidebar;
};
// Doc links have been resolved to URLs, ready to be passed to the theme
export type PropSidebarItemCategory = Expand<
SidebarItemCategoryBase & {
items: PropSidebarItem[];
}
>;
export type PropSidebarItem = SidebarItemLink | PropSidebarItemCategory;
export type PropSidebar = PropSidebarItem[];
export type PropSidebars = {
[sidebarId: string]: PropSidebar;
};
// Reduce API surface for options.sidebarItemsGenerator
// The user-provided generator fn should receive only a subset of metadatas
// A change to any of these metadatas can be considered as a breaking change
export type SidebarItemsGeneratorDoc = Pick<
DocMetadataBase,
'id' | 'frontMatter' | 'source' | 'sourceDirName' | 'sidebarPosition'
>;
export type SidebarItemsGeneratorVersion = Pick<
VersionMetadata,
'versionName' | 'contentPath'
>;
export type SidebarItemsGeneratorArgs = {
item: SidebarItemAutogenerated;
version: SidebarItemsGeneratorVersion;
docs: SidebarItemsGeneratorDoc[];
numberPrefixParser: NumberPrefixParser;
options: SidebarOptions;
};
export type SidebarItemsGenerator = (
generatorArgs: SidebarItemsGeneratorArgs,
) => Promise<SidebarItem[]>;
// Also inject the default generator to conveniently wrap/enhance/sort the default sidebar gen logic
// see https://github.com/facebook/docusaurus/issues/4640#issuecomment-822292320
export type SidebarItemsGeneratorOptionArgs = {
defaultSidebarItemsGenerator: SidebarItemsGenerator;
} & SidebarItemsGeneratorArgs;
export type SidebarItemsGeneratorOption = (
generatorArgs: SidebarItemsGeneratorOptionArgs,
) => Promise<SidebarItem[]>;

View file

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

View file

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

View file

@ -5,20 +5,15 @@
* LICENSE file in the root directory of this source tree.
*/
import {
LoadedVersion,
Sidebar,
LoadedContent,
Sidebars,
SidebarItem,
} from './types';
import type {LoadedVersion, LoadedContent} from './types';
import type {Sidebar, Sidebars} from './sidebars/types';
import {chain, mapValues, flatten, keyBy} from 'lodash';
import {chain, mapValues, keyBy} from 'lodash';
import {
collectSidebarCategories,
transformSidebarItems,
collectSidebarLinks,
} from './sidebars';
} from './sidebars/utils';
import {
TranslationFileContent,
TranslationFile,
@ -131,7 +126,7 @@ function translateSidebar({
sidebarName: string;
sidebarsTranslations: TranslationFileContent;
}): Sidebar {
return transformSidebarItems(sidebar, (item: SidebarItem): SidebarItem => {
return transformSidebarItems(sidebar, (item) => {
if (item.type === 'category') {
return {
...item,
@ -222,7 +217,7 @@ function translateVersion(
function getVersionsTranslationFiles(
versions: LoadedVersion[],
): TranslationFiles {
return flatten(versions.map(getVersionTranslationFiles));
return versions.flatMap(getVersionTranslationFiles);
}
function translateVersions(
versions: LoadedVersion[],

View file

@ -13,6 +13,7 @@ import type {
BrokenMarkdownLink as IBrokenMarkdownLink,
ContentPaths,
} from '@docusaurus/utils/lib/markdownLinks';
import type {SidebarItemsGeneratorOption, Sidebars} from './sidebars/types';
export type DocFile = {
contentPath: string; // /!\ may be localized
@ -104,100 +105,6 @@ export type PluginOptions = MetadataOptions &
tagsBasePath: string;
};
export type SidebarItemBase = {
className?: string;
customProps?: Record<string, unknown>;
};
export type SidebarItemDoc = SidebarItemBase & {
type: 'doc' | 'ref';
label?: string;
id: string;
};
export type SidebarItemLink = SidebarItemBase & {
type: 'link';
href: string;
label: string;
};
export type SidebarItemCategory = SidebarItemBase & {
type: 'category';
label: string;
items: SidebarItem[];
collapsed: boolean;
collapsible: boolean;
};
export type UnprocessedSidebarItemAutogenerated = {
type: 'autogenerated';
dirName: string;
};
export type UnprocessedSidebarItemCategory = SidebarItemBase & {
type: 'category';
label: string;
items: UnprocessedSidebarItem[];
collapsed: boolean;
collapsible: boolean;
};
export type UnprocessedSidebarItem =
| SidebarItemDoc
| SidebarItemLink
| UnprocessedSidebarItemCategory
| UnprocessedSidebarItemAutogenerated;
export type UnprocessedSidebar = UnprocessedSidebarItem[];
export type UnprocessedSidebars = Record<string, UnprocessedSidebar>;
export type SidebarItem =
| SidebarItemDoc
| SidebarItemLink
| SidebarItemCategory;
export type Sidebar = SidebarItem[];
export type SidebarItemType = SidebarItem['type'];
export type Sidebars = Record<string, Sidebar>;
// Reduce API surface for options.sidebarItemsGenerator
// The user-provided generator fn should receive only a subset of metadatas
// A change to any of these metadatas can be considered as a breaking change
export type SidebarItemsGeneratorDoc = Pick<
DocMetadataBase,
'id' | 'frontMatter' | 'source' | 'sourceDirName' | 'sidebarPosition'
>;
export type SidebarItemsGeneratorVersion = Pick<
VersionMetadata,
'versionName' | 'contentPath'
>;
export type SidebarItemsGeneratorArgs = {
item: UnprocessedSidebarItemAutogenerated;
version: SidebarItemsGeneratorVersion;
docs: SidebarItemsGeneratorDoc[];
numberPrefixParser: NumberPrefixParser;
options: SidebarOptions;
};
export type SidebarItemsGenerator = (
generatorArgs: SidebarItemsGeneratorArgs,
) => Promise<SidebarItem[]>;
// Also inject the default generator to conveniently wrap/enhance/sort the default sidebar gen logic
// see https://github.com/facebook/docusaurus/issues/4640#issuecomment-822292320
export type SidebarItemsGeneratorOptionArgs = {
defaultSidebarItemsGenerator: SidebarItemsGenerator;
} & SidebarItemsGeneratorArgs;
export type SidebarItemsGeneratorOption = (
generatorArgs: SidebarItemsGeneratorOptionArgs,
) => Promise<SidebarItem[]>;
export type OrderMetadata = {
previous?: string;
next?: string;
sidebar?: string;
};
export type LastUpdateData = {
lastUpdatedAt?: number;
formattedLastUpdatedAt?: string;

View file

@ -5,7 +5,10 @@
* LICENSE file in the root directory of this source tree.
*/
module.exports = {
// @ts-check
/** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */
const sidebars = {
docs: [
'introduction',
{
@ -137,3 +140,5 @@ module.exports = {
},
],
};
module.exports = sidebars;