From 8d92e9bcf5cf533719b07b17db73facea788fac1 Mon Sep 17 00:00:00 2001 From: Joshua Chen Date: Thu, 14 Oct 2021 20:38:26 +0800 Subject: [PATCH] refactor(content-docs): refactor sidebars, Joi validation, generator rework, expose config types (#5678) --- .../templates/shared/sidebars.js | 7 +- .../package.json | 3 +- .../src/__tests__/index.test.ts | 9 +- .../src/__tests__/options.test.ts | 2 +- .../src/__tests__/sidebars.test.ts | 741 ------------------ .../docusaurus-plugin-content-docs/src/cli.ts | 52 +- .../src/index.ts | 46 +- .../src/options.ts | 2 +- .../src/plugin-content-docs.d.ts | 30 +- .../src/props.ts | 8 +- .../src/sidebarItemsGenerator.ts | 322 -------- .../src/sidebars.ts | 609 -------------- .../sidebars/sidebars-category-shorthand.js | 0 .../sidebars-category-wrong-items.json | 0 .../sidebars-category-wrong-label.json | 0 .../sidebars/sidebars-category.js | 0 .../sidebars-collapsed-first-level.json | 0 .../sidebars/sidebars-collapsed.json | 0 .../sidebars/sidebars-doc-id-not-string.json | 0 .../sidebars-first-level-not-category.js | 0 .../sidebars/sidebars-link-wrong-href.json | 0 .../sidebars/sidebars-link-wrong-label.json | 0 .../__fixtures__/sidebars/sidebars-link.json | 0 .../sidebars/sidebars-unknown-type.json | 0 .../sidebars/sidebars-wrong-field.json | 0 .../__fixtures__/sidebars/sidebars.json | 0 .../__snapshots__/index.test.ts.snap} | 12 +- .../__tests__/generator.test.ts} | 4 +- .../src/sidebars/__tests__/index.test.ts | 202 +++++ .../src/sidebars/__tests__/processor.test.ts | 148 ++++ .../src/sidebars/__tests__/utils.test.ts | 395 ++++++++++ .../src/sidebars/generator.ts | 253 ++++++ .../src/sidebars/index.ts | 84 ++ .../src/sidebars/normalization.ts | 88 +++ .../src/sidebars/processor.ts | 124 +++ .../src/sidebars/types.ts | 156 ++++ .../src/sidebars/utils.ts | 146 ++++ .../src/sidebars/validation.ts | 124 +++ .../src/translations.ts | 17 +- .../src/types.ts | 95 +-- website/sidebars.js | 7 +- 41 files changed, 1806 insertions(+), 1880 deletions(-) delete mode 100644 packages/docusaurus-plugin-content-docs/src/__tests__/sidebars.test.ts delete mode 100644 packages/docusaurus-plugin-content-docs/src/sidebarItemsGenerator.ts delete mode 100644 packages/docusaurus-plugin-content-docs/src/sidebars.ts rename packages/docusaurus-plugin-content-docs/src/{ => sidebars}/__tests__/__fixtures__/sidebars/sidebars-category-shorthand.js (100%) rename packages/docusaurus-plugin-content-docs/src/{ => sidebars}/__tests__/__fixtures__/sidebars/sidebars-category-wrong-items.json (100%) rename packages/docusaurus-plugin-content-docs/src/{ => sidebars}/__tests__/__fixtures__/sidebars/sidebars-category-wrong-label.json (100%) rename packages/docusaurus-plugin-content-docs/src/{ => sidebars}/__tests__/__fixtures__/sidebars/sidebars-category.js (100%) rename packages/docusaurus-plugin-content-docs/src/{ => sidebars}/__tests__/__fixtures__/sidebars/sidebars-collapsed-first-level.json (100%) rename packages/docusaurus-plugin-content-docs/src/{ => sidebars}/__tests__/__fixtures__/sidebars/sidebars-collapsed.json (100%) rename packages/docusaurus-plugin-content-docs/src/{ => sidebars}/__tests__/__fixtures__/sidebars/sidebars-doc-id-not-string.json (100%) rename packages/docusaurus-plugin-content-docs/src/{ => sidebars}/__tests__/__fixtures__/sidebars/sidebars-first-level-not-category.js (100%) rename packages/docusaurus-plugin-content-docs/src/{ => sidebars}/__tests__/__fixtures__/sidebars/sidebars-link-wrong-href.json (100%) rename packages/docusaurus-plugin-content-docs/src/{ => sidebars}/__tests__/__fixtures__/sidebars/sidebars-link-wrong-label.json (100%) rename packages/docusaurus-plugin-content-docs/src/{ => sidebars}/__tests__/__fixtures__/sidebars/sidebars-link.json (100%) rename packages/docusaurus-plugin-content-docs/src/{ => sidebars}/__tests__/__fixtures__/sidebars/sidebars-unknown-type.json (100%) rename packages/docusaurus-plugin-content-docs/src/{ => sidebars}/__tests__/__fixtures__/sidebars/sidebars-wrong-field.json (100%) rename packages/docusaurus-plugin-content-docs/src/{ => sidebars}/__tests__/__fixtures__/sidebars/sidebars.json (100%) rename packages/docusaurus-plugin-content-docs/src/{__tests__/__snapshots__/sidebars.test.ts.snap => sidebars/__tests__/__snapshots__/index.test.ts.snap} (90%) rename packages/docusaurus-plugin-content-docs/src/{__tests__/sidebarItemsGenerator.test.ts => sidebars/__tests__/generator.test.ts} (99%) create mode 100644 packages/docusaurus-plugin-content-docs/src/sidebars/__tests__/index.test.ts create mode 100644 packages/docusaurus-plugin-content-docs/src/sidebars/__tests__/processor.test.ts create mode 100644 packages/docusaurus-plugin-content-docs/src/sidebars/__tests__/utils.test.ts create mode 100644 packages/docusaurus-plugin-content-docs/src/sidebars/generator.ts create mode 100644 packages/docusaurus-plugin-content-docs/src/sidebars/index.ts create mode 100644 packages/docusaurus-plugin-content-docs/src/sidebars/normalization.ts create mode 100644 packages/docusaurus-plugin-content-docs/src/sidebars/processor.ts create mode 100644 packages/docusaurus-plugin-content-docs/src/sidebars/types.ts create mode 100644 packages/docusaurus-plugin-content-docs/src/sidebars/utils.ts create mode 100644 packages/docusaurus-plugin-content-docs/src/sidebars/validation.ts diff --git a/packages/create-docusaurus/templates/shared/sidebars.js b/packages/create-docusaurus/templates/shared/sidebars.js index 981a73cd7a..fd342f2cdb 100644 --- a/packages/create-docusaurus/templates/shared/sidebars.js +++ b/packages/create-docusaurus/templates/shared/sidebars.js @@ -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; diff --git a/packages/docusaurus-plugin-content-docs/package.json b/packages/docusaurus-plugin-content-docs/package.json index d2faf1f2f2..642b409d81 100644 --- a/packages/docusaurus-plugin-content-docs/package.json +++ b/packages/docusaurus-plugin-content-docs/package.json @@ -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", diff --git a/packages/docusaurus-plugin-content-docs/src/__tests__/index.test.ts b/packages/docusaurus-plugin-content-docs/src/__tests__/index.test.ts index 38e6e59cba..98016bd9ae 100644 --- a/packages/docusaurus-plugin-content-docs/src/__tests__/index.test.ts +++ b/packages/docusaurus-plugin-content-docs/src/__tests__/index.test.ts @@ -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) { diff --git a/packages/docusaurus-plugin-content-docs/src/__tests__/options.test.ts b/packages/docusaurus-plugin-content-docs/src/__tests__/options.test.ts index 3d72772e34..929702f141 100644 --- a/packages/docusaurus-plugin-content-docs/src/__tests__/options.test.ts +++ b/packages/docusaurus-plugin-content-docs/src/__tests__/options.test.ts @@ -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, diff --git a/packages/docusaurus-plugin-content-docs/src/__tests__/sidebars.test.ts b/packages/docusaurus-plugin-content-docs/src/__tests__/sidebars.test.ts deleted file mode 100644 index eba3c3aba4..0000000000 --- a/packages/docusaurus-plugin-content-docs/src/__tests__/sidebars.test.ts +++ /dev/null @@ -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, - }, - ], - }); - }); -}); diff --git a/packages/docusaurus-plugin-content-docs/src/cli.ts b/packages/docusaurus-plugin-content-docs/src/cli.ts index cb6e01efb8..1e5c87257f 100644 --- a/packages/docusaurus-plugin-content-docs/src/cli.ts +++ b/packages/docusaurus-plugin-content-docs/src/cli.ts @@ -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( diff --git a/packages/docusaurus-plugin-content-docs/src/index.ts b/packages/docusaurus-plugin-content-docs/src/index.ts index 625f168942..340ce40018 100644 --- a/packages/docusaurus-plugin-content-docs/src/index.ts +++ b/packages/docusaurus-plugin-content-docs/src/index.ts @@ -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 { - 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 diff --git a/packages/docusaurus-plugin-content-docs/src/options.ts b/packages/docusaurus-plugin-content-docs/src/options.ts index c260fd5969..c7671b0ebe 100644 --- a/packages/docusaurus-plugin-content-docs/src/options.ts +++ b/packages/docusaurus-plugin-content-docs/src/options.ts @@ -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, diff --git a/packages/docusaurus-plugin-content-docs/src/plugin-content-docs.d.ts b/packages/docusaurus-plugin-content-docs/src/plugin-content-docs.d.ts index 5b64fefc12..d856c18da0 100644 --- a/packages/docusaurus-plugin-content-docs/src/plugin-content-docs.d.ts +++ b/packages/docusaurus-plugin-content-docs/src/plugin-content-docs.d.ts @@ -7,6 +7,7 @@ declare module '@docusaurus/plugin-content-docs' { export type Options = Partial; + 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; - }; - - 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; diff --git a/packages/docusaurus-plugin-content-docs/src/props.ts b/packages/docusaurus-plugin-content-docs/src/props.ts index b8c1dfe0d3..d02342d44f 100644 --- a/packages/docusaurus-plugin-content-docs/src/props.ts +++ b/packages/docusaurus-plugin-content-docs/src/props.ts @@ -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, diff --git a/packages/docusaurus-plugin-content-docs/src/sidebarItemsGenerator.ts b/packages/docusaurus-plugin-content-docs/src/sidebarItemsGenerator.ts deleted file mode 100644 index 7b759209d9..0000000000 --- a/packages/docusaurus-plugin-content-docs/src/sidebarItemsGenerator.ts +++ /dev/null @@ -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({ - 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 { - function validateCategoryMetadataFile( - content: unknown, - ): CategoryMetadatasFile { - return Joi.attempt(content, CategoryMetadatasFileSchema); - } - - async function tryReadFile( - fileNameWithExtension: string, - parse: (content: string) => unknown, - ): Promise { - // 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 { - 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 = {}; // mutable cache of categories already created - - async function getOrCreateCategoriesForBreadcrumb( - breadcrumb: string[], - ): Promise { - 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 { - 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); -} diff --git a/packages/docusaurus-plugin-content-docs/src/sidebars.ts b/packages/docusaurus-plugin-content-docs/src/sidebars.ts deleted file mode 100644 index a14bc8a726..0000000000 --- a/packages/docusaurus-plugin-content-docs/src/sidebars.ts +++ /dev/null @@ -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( - item: Record, - keys: K[], -): asserts item is Record { - 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, -): 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, -): 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', '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, -): 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 { - // 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, - }; -} diff --git a/packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/sidebars/sidebars-category-shorthand.js b/packages/docusaurus-plugin-content-docs/src/sidebars/__tests__/__fixtures__/sidebars/sidebars-category-shorthand.js similarity index 100% rename from packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/sidebars/sidebars-category-shorthand.js rename to packages/docusaurus-plugin-content-docs/src/sidebars/__tests__/__fixtures__/sidebars/sidebars-category-shorthand.js diff --git a/packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/sidebars/sidebars-category-wrong-items.json b/packages/docusaurus-plugin-content-docs/src/sidebars/__tests__/__fixtures__/sidebars/sidebars-category-wrong-items.json similarity index 100% rename from packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/sidebars/sidebars-category-wrong-items.json rename to packages/docusaurus-plugin-content-docs/src/sidebars/__tests__/__fixtures__/sidebars/sidebars-category-wrong-items.json diff --git a/packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/sidebars/sidebars-category-wrong-label.json b/packages/docusaurus-plugin-content-docs/src/sidebars/__tests__/__fixtures__/sidebars/sidebars-category-wrong-label.json similarity index 100% rename from packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/sidebars/sidebars-category-wrong-label.json rename to packages/docusaurus-plugin-content-docs/src/sidebars/__tests__/__fixtures__/sidebars/sidebars-category-wrong-label.json diff --git a/packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/sidebars/sidebars-category.js b/packages/docusaurus-plugin-content-docs/src/sidebars/__tests__/__fixtures__/sidebars/sidebars-category.js similarity index 100% rename from packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/sidebars/sidebars-category.js rename to packages/docusaurus-plugin-content-docs/src/sidebars/__tests__/__fixtures__/sidebars/sidebars-category.js diff --git a/packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/sidebars/sidebars-collapsed-first-level.json b/packages/docusaurus-plugin-content-docs/src/sidebars/__tests__/__fixtures__/sidebars/sidebars-collapsed-first-level.json similarity index 100% rename from packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/sidebars/sidebars-collapsed-first-level.json rename to packages/docusaurus-plugin-content-docs/src/sidebars/__tests__/__fixtures__/sidebars/sidebars-collapsed-first-level.json diff --git a/packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/sidebars/sidebars-collapsed.json b/packages/docusaurus-plugin-content-docs/src/sidebars/__tests__/__fixtures__/sidebars/sidebars-collapsed.json similarity index 100% rename from packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/sidebars/sidebars-collapsed.json rename to packages/docusaurus-plugin-content-docs/src/sidebars/__tests__/__fixtures__/sidebars/sidebars-collapsed.json diff --git a/packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/sidebars/sidebars-doc-id-not-string.json b/packages/docusaurus-plugin-content-docs/src/sidebars/__tests__/__fixtures__/sidebars/sidebars-doc-id-not-string.json similarity index 100% rename from packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/sidebars/sidebars-doc-id-not-string.json rename to packages/docusaurus-plugin-content-docs/src/sidebars/__tests__/__fixtures__/sidebars/sidebars-doc-id-not-string.json diff --git a/packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/sidebars/sidebars-first-level-not-category.js b/packages/docusaurus-plugin-content-docs/src/sidebars/__tests__/__fixtures__/sidebars/sidebars-first-level-not-category.js similarity index 100% rename from packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/sidebars/sidebars-first-level-not-category.js rename to packages/docusaurus-plugin-content-docs/src/sidebars/__tests__/__fixtures__/sidebars/sidebars-first-level-not-category.js diff --git a/packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/sidebars/sidebars-link-wrong-href.json b/packages/docusaurus-plugin-content-docs/src/sidebars/__tests__/__fixtures__/sidebars/sidebars-link-wrong-href.json similarity index 100% rename from packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/sidebars/sidebars-link-wrong-href.json rename to packages/docusaurus-plugin-content-docs/src/sidebars/__tests__/__fixtures__/sidebars/sidebars-link-wrong-href.json diff --git a/packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/sidebars/sidebars-link-wrong-label.json b/packages/docusaurus-plugin-content-docs/src/sidebars/__tests__/__fixtures__/sidebars/sidebars-link-wrong-label.json similarity index 100% rename from packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/sidebars/sidebars-link-wrong-label.json rename to packages/docusaurus-plugin-content-docs/src/sidebars/__tests__/__fixtures__/sidebars/sidebars-link-wrong-label.json diff --git a/packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/sidebars/sidebars-link.json b/packages/docusaurus-plugin-content-docs/src/sidebars/__tests__/__fixtures__/sidebars/sidebars-link.json similarity index 100% rename from packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/sidebars/sidebars-link.json rename to packages/docusaurus-plugin-content-docs/src/sidebars/__tests__/__fixtures__/sidebars/sidebars-link.json diff --git a/packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/sidebars/sidebars-unknown-type.json b/packages/docusaurus-plugin-content-docs/src/sidebars/__tests__/__fixtures__/sidebars/sidebars-unknown-type.json similarity index 100% rename from packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/sidebars/sidebars-unknown-type.json rename to packages/docusaurus-plugin-content-docs/src/sidebars/__tests__/__fixtures__/sidebars/sidebars-unknown-type.json diff --git a/packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/sidebars/sidebars-wrong-field.json b/packages/docusaurus-plugin-content-docs/src/sidebars/__tests__/__fixtures__/sidebars/sidebars-wrong-field.json similarity index 100% rename from packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/sidebars/sidebars-wrong-field.json rename to packages/docusaurus-plugin-content-docs/src/sidebars/__tests__/__fixtures__/sidebars/sidebars-wrong-field.json diff --git a/packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/sidebars/sidebars.json b/packages/docusaurus-plugin-content-docs/src/sidebars/__tests__/__fixtures__/sidebars/sidebars.json similarity index 100% rename from packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/sidebars/sidebars.json rename to packages/docusaurus-plugin-content-docs/src/sidebars/__tests__/__fixtures__/sidebars/sidebars.json diff --git a/packages/docusaurus-plugin-content-docs/src/__tests__/__snapshots__/sidebars.test.ts.snap b/packages/docusaurus-plugin-content-docs/src/sidebars/__tests__/__snapshots__/index.test.ts.snap similarity index 90% rename from packages/docusaurus-plugin-content-docs/src/__tests__/__snapshots__/sidebars.test.ts.snap rename to packages/docusaurus-plugin-content-docs/src/sidebars/__tests__/__snapshots__/index.test.ts.snap index 24147bc07c..a4457c5bc7 100644 --- a/packages/docusaurus-plugin-content-docs/src/__tests__/__snapshots__/sidebars.test.ts.snap +++ b/packages/docusaurus-plugin-content-docs/src/sidebars/__tests__/__snapshots__/index.test.ts.snap @@ -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 { diff --git a/packages/docusaurus-plugin-content-docs/src/__tests__/sidebarItemsGenerator.test.ts b/packages/docusaurus-plugin-content-docs/src/sidebars/__tests__/generator.test.ts similarity index 99% rename from packages/docusaurus-plugin-content-docs/src/__tests__/sidebarItemsGenerator.test.ts rename to packages/docusaurus-plugin-content-docs/src/sidebars/__tests__/generator.test.ts index 2794285022..5e75012a92 100644 --- a/packages/docusaurus-plugin-content-docs/src/__tests__/sidebarItemsGenerator.test.ts +++ b/packages/docusaurus-plugin-content-docs/src/sidebars/__tests__/generator.test.ts @@ -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( diff --git a/packages/docusaurus-plugin-content-docs/src/sidebars/__tests__/index.test.ts b/packages/docusaurus-plugin-content-docs/src/sidebars/__tests__/index.test.ts new file mode 100644 index 0000000000..8d6f389202 --- /dev/null +++ b/packages/docusaurus-plugin-content-docs/src/sidebars/__tests__/index.test.ts @@ -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(); + }); +}); diff --git a/packages/docusaurus-plugin-content-docs/src/sidebars/__tests__/processor.test.ts b/packages/docusaurus-plugin-content-docs/src/sidebars/__tests__/processor.test.ts new file mode 100644 index 0000000000..2faee76313 --- /dev/null +++ b/packages/docusaurus-plugin-content-docs/src/sidebars/__tests__/processor.test.ts @@ -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); + }); +}); diff --git a/packages/docusaurus-plugin-content-docs/src/sidebars/__tests__/utils.test.ts b/packages/docusaurus-plugin-content-docs/src/sidebars/__tests__/utils.test.ts new file mode 100644 index 0000000000..e5e8bdc25e --- /dev/null +++ b/packages/docusaurus-plugin-content-docs/src/sidebars/__tests__/utils.test.ts @@ -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'}, + ], + }, + ]); + }); +}); diff --git a/packages/docusaurus-plugin-content-docs/src/sidebars/generator.ts b/packages/docusaurus-plugin-content-docs/src/sidebars/generator.ts new file mode 100644 index 0000000000..49e551e9c6 --- /dev/null +++ b/packages/docusaurus-plugin-content-docs/src/sidebars/generator.ts @@ -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 & {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({ + 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 { + async function tryReadFile( + fileNameWithExtension: string, + parse: (content: string) => unknown, + ): Promise { + // 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[]> { + const docsById = keyBy(allDocs, (doc) => doc.id); + function createDocItem(id: string): WithPosition { + 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> { + 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> { + 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[] { + 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; +}; diff --git a/packages/docusaurus-plugin-content-docs/src/sidebars/index.ts b/packages/docusaurus-plugin-content-docs/src/sidebars/index.ts new file mode 100644 index 0000000000..9a86b664ec --- /dev/null +++ b/packages/docusaurus-plugin-content-docs/src/sidebars/index.ts @@ -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 { + const unprocessedSidebars = loadUnprocessedSidebars( + sidebarFilePath, + options.options, + ); + return processSidebars(unprocessedSidebars, options); +} diff --git a/packages/docusaurus-plugin-content-docs/src/sidebars/normalization.ts b/packages/docusaurus-plugin-content-docs/src/sidebars/normalization.ts new file mode 100644 index 0000000000..7e333d57d9 --- /dev/null +++ b/packages/docusaurus-plugin-content-docs/src/sidebars/normalization.ts @@ -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)); +} diff --git a/packages/docusaurus-plugin-content-docs/src/sidebars/processor.ts b/packages/docusaurus-plugin-content-docs/src/sidebars/processor.ts new file mode 100644 index 0000000000..3f229f1772 --- /dev/null +++ b/packages/docusaurus-plugin-content-docs/src/sidebars/processor.ts @@ -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 { + // Just a minor lazy transformation optimization + const getSidebarItemsGeneratorDocsAndVersion = memoize(() => ({ + docs: docs.map(toSidebarItemsGeneratorDoc), + version: toSidebarItemsGeneratorVersion(version), + })); + + async function handleAutoGeneratedItems( + item: NormalizedSidebarItem, + ): 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(); + + 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 { + return combinePromises( + mapValues(unprocessedSidebars, (unprocessedSidebar) => + processSidebar(unprocessedSidebar, props), + ), + ); +} diff --git a/packages/docusaurus-plugin-content-docs/src/sidebars/types.ts b/packages/docusaurus-plugin-content-docs/src/sidebars/types.ts new file mode 100644 index 0000000000..59e4c73b3a --- /dev/null +++ b/packages/docusaurus-plugin-content-docs/src/sidebars/types.ts @@ -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> = {[P in keyof T]: T[P]}; + +export type SidebarItemBase = { + className?: string; + customProps?: Record; +}; + +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 & { + 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; + +// 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; diff --git a/packages/docusaurus-plugin-content-docs/src/sidebars/utils.ts b/packages/docusaurus-plugin-content-docs/src/sidebars/utils.ts new file mode 100644 index 0000000000..465af0f9aa --- /dev/null +++ b/packages/docusaurus-plugin-content-docs/src/sidebars/utils.ts @@ -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 { + 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, + }; +} diff --git a/packages/docusaurus-plugin-content-docs/src/sidebars/validation.ts b/packages/docusaurus-plugin-content-docs/src/sidebars/validation.ts new file mode 100644 index 0000000000..db2e93fbe1 --- /dev/null +++ b/packages/docusaurus-plugin-content-docs/src/sidebars/validation.ts @@ -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({ + className: Joi.string(), + customProps: Joi.object().unknown(), +}); + +const sidebarItemAutogeneratedSchema = + sidebarItemBaseSchema.append({ + 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({ + type: Joi.string().valid('doc', 'ref').required(), + id: Joi.string().required(), + label: Joi.string(), +}); + +const sidebarItemLinkSchema = sidebarItemBaseSchema.append({ + type: 'link', + href: URISchema.required(), + label: Joi.string() + .required() + .messages({'any.unknown': '"label" must be a string'}), +}); + +const sidebarItemCategorySchema = + sidebarItemBaseSchema.append({ + 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 = 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); + } + }); +} diff --git a/packages/docusaurus-plugin-content-docs/src/translations.ts b/packages/docusaurus-plugin-content-docs/src/translations.ts index faa284b330..138deca2c2 100644 --- a/packages/docusaurus-plugin-content-docs/src/translations.ts +++ b/packages/docusaurus-plugin-content-docs/src/translations.ts @@ -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[], diff --git a/packages/docusaurus-plugin-content-docs/src/types.ts b/packages/docusaurus-plugin-content-docs/src/types.ts index e62bf2b3c2..0b75fe4700 100644 --- a/packages/docusaurus-plugin-content-docs/src/types.ts +++ b/packages/docusaurus-plugin-content-docs/src/types.ts @@ -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; -}; - -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; - -export type SidebarItem = - | SidebarItemDoc - | SidebarItemLink - | SidebarItemCategory; - -export type Sidebar = SidebarItem[]; -export type SidebarItemType = SidebarItem['type']; -export type Sidebars = Record; - -// 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; - -// 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; - -export type OrderMetadata = { - previous?: string; - next?: string; - sidebar?: string; -}; - export type LastUpdateData = { lastUpdatedAt?: number; formattedLastUpdatedAt?: string; diff --git a/website/sidebars.js b/website/sidebars.js index bdf2b4570c..b93f6f94cc 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -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;