mirror of
https://github.com/facebook/docusaurus.git
synced 2025-05-01 11:18:24 +02:00
* Bump deps * Run prettier * Format docs * Minor refactor * Collapse objects * Fix type * Update lock file
583 lines
15 KiB
TypeScript
583 lines
15 KiB
TypeScript
/**
|
|
* 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;
|
|
};
|
|
|
|
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(
|
|
// @ts-expect-error: key is always string
|
|
(key) => !keys.includes(key as string) && 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',
|
|
'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.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
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', 'customProps']);
|
|
if (typeof item.id !== 'string') {
|
|
throw new Error(
|
|
`Error loading ${JSON.stringify(item)}: "id" must be a string.`,
|
|
);
|
|
}
|
|
|
|
if (item.label && typeof item.label !== 'string') {
|
|
throw new Error(
|
|
`Error loading ${JSON.stringify(item)}: "label" must be a string.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function assertIsLink(
|
|
item: Record<string, unknown>,
|
|
): asserts item is SidebarItemLink {
|
|
assertItem(item, ['href', 'label', '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.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
};
|
|
}
|