/** * 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( item: Record, keys: K[], ): asserts item is Record { 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, ): 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, ): 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, ): 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, ): 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 { // Just a minor lazy transformation optimization const getSidebarItemsGeneratorDocsAndVersion = memoize(() => ({ docs: docs.map(toSidebarItemsGeneratorDoc), version: toSidebarItemsGeneratorVersion(version), })); async function handleAutoGeneratedItems( item: UnprocessedSidebarItem, ): Promise { 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 { 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 { 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, }; }