feat(v2): auto-generated sidebars, frontmatter-less sites (#4582)

* POC of autogenerated sidebars

* use combine-promises utility lib

* autogenerated sidebar poc working

* Revert "autogenerated sidebar poc working"

This reverts commit c81da980

* POC of auto-generated sidebars for community docs

* update tests

* add initial test suite for autogenerated sidebars + fix some edge cases

* Improve autogen sidebars: strip more number prefixes in folder breadcrumb + slugs

* fix typo!

* Add tests for partially generated sidebars + fix edge cases + extract sidebar generation code

* Ability to read category metadatas file from a file in the category

* fix tests

* change position of API

* ability to extract number prefix

* stable system to enable position frontmatter

* fix tests for autogen sidebar position

* renamings

* restore community sidebars

* rename frontmatter position -> sidebar_position

* make sidebarItemsGenerator fn configurable

* minor changes

* rename dirPath => dirName

* Make the init template use autogenerated sidebars

* fix options

* fix docusaurus site: remove test docs

* add _category_ file to docs pathsToWatch

* add _category_ file to docs pathsToWatch

* tutorial: use sidebar_position instead of file number prefixes

* Adapt Docusaurus tutorial for autogenerated sidebars

* remove slug: /

* polish the homepage template

* rename _category_ sidebar_position to just "position"

* test for custom sidebarItemsGenerator fn

* fix category metadata + add link to report tutorial issues

* fix absolute path breaking tests

* fix absolute path breaking tests

* Add test for floating number sidebar_position

* add sidebarItemsGenerator unit tests

* add processSidebars unit tests

* Fix init template broken links

* windows test

* increase code translations test timeout

* cleanup mockCategoryMetadataFiles after windows test fixed

* update init template positions

* fix windows tests

* fix comment

* Add autogenerated sidebar items documentation + rewrite the full sidebars page doc

* add useful comment

* fix code block title
This commit is contained in:
Sébastien Lorber 2021-04-15 16:20:11 +02:00 committed by GitHub
parent 836f92708a
commit db79d462ab
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
67 changed files with 2887 additions and 306 deletions

View file

@ -20,6 +20,7 @@
"devDependencies": {
"@docusaurus/module-type-aliases": "2.0.0-alpha.72",
"@types/picomatch": "^2.2.1",
"@types/js-yaml": "^4.0.0",
"commander": "^5.1.0",
"picomatch": "^2.1.1"
},
@ -30,10 +31,12 @@
"@docusaurus/utils": "2.0.0-alpha.72",
"@docusaurus/utils-validation": "2.0.0-alpha.72",
"chalk": "^4.1.0",
"combine-promises": "^1.1.0",
"execa": "^5.0.0",
"fs-extra": "^9.1.0",
"globby": "^11.0.2",
"import-fresh": "^3.2.2",
"js-yaml": "^4.0.0",
"loader-utils": "^1.2.3",
"lodash": "^4.17.20",
"remark-admonitions": "^1.2.1",

View file

@ -0,0 +1,3 @@
{
"label": "API (label from _category_.json)"
}

View file

@ -0,0 +1,8 @@
---
id: guide2.5
sidebar_position: 2.5
---
# Guide 2.5
Guide 2.5 text

View file

@ -0,0 +1,7 @@
---
id: guide2
---
# Guide 2
Guide 2 text

View file

@ -0,0 +1,7 @@
---
id: guide4
---
# Guide 4
Guide 4 text

View file

@ -0,0 +1,7 @@
---
id: guide5
---
# Guide 5
Guide 5 text

View file

@ -0,0 +1,8 @@
---
id: guide3
sidebar_position: 3
---
# Guide 3
Guide 3 text

View file

@ -0,0 +1,8 @@
---
id: guide1
sidebar_position: 1
---
# Guide 1
Guide 1 text

View file

@ -0,0 +1,14 @@
/**
* 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.
*/
module.exports = {
title: 'My Site',
tagline: 'The tagline of my site',
url: 'https://your-docusaurus-test-site.com',
baseUrl: '/',
favicon: 'img/favicon.ico',
};

View file

@ -0,0 +1,23 @@
/**
* 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.
*/
module.exports = {
someSidebar: [
{type: 'doc', id: 'API/api-end'},
{
type: 'category',
label: 'Some category',
items: [
{type: 'doc', id: 'API/api-overview'},
{
type: 'autogenerated',
dirName: '3-API/02_Extension APIs',
},
],
},
],
};

View file

@ -149,6 +149,7 @@ Object {
\\"title\\": \\"Bar\\",
\\"description\\": \\"This is custom description\\",
\\"source\\": \\"@site/docs/foo/bar.md\\",
\\"sourceDirName\\": \\"foo\\",
\\"slug\\": \\"/foo/bar\\",
\\"permalink\\": \\"/docs/foo/bar\\",
\\"version\\": \\"current\\",
@ -170,6 +171,7 @@ Object {
\\"title\\": \\"baz\\",
\\"description\\": \\"Images\\",
\\"source\\": \\"@site/docs/foo/baz.md\\",
\\"sourceDirName\\": \\"foo\\",
\\"slug\\": \\"/foo/bazSlug.html\\",
\\"permalink\\": \\"/docs/foo/bazSlug.html\\",
\\"version\\": \\"current\\",
@ -195,6 +197,7 @@ Object {
\\"title\\": \\"My heading as title\\",
\\"description\\": \\"\\",
\\"source\\": \\"@site/docs/headingAsTitle.md\\",
\\"sourceDirName\\": \\".\\",
\\"slug\\": \\"/headingAsTitle\\",
\\"permalink\\": \\"/docs/headingAsTitle\\",
\\"version\\": \\"current\\",
@ -207,6 +210,7 @@ Object {
\\"title\\": \\"Hello, World !\\",
\\"description\\": \\"Hi, Endilie here :)\\",
\\"source\\": \\"@site/docs/hello.md\\",
\\"sourceDirName\\": \\".\\",
\\"slug\\": \\"/\\",
\\"permalink\\": \\"/docs/\\",
\\"version\\": \\"current\\",
@ -227,6 +231,7 @@ Object {
\\"title\\": \\"ipsum\\",
\\"description\\": \\"Lorem ipsum.\\",
\\"source\\": \\"@site/docs/ipsum.md\\",
\\"sourceDirName\\": \\".\\",
\\"slug\\": \\"/ipsum\\",
\\"permalink\\": \\"/docs/ipsum\\",
\\"editUrl\\": null,
@ -242,6 +247,7 @@ Object {
\\"title\\": \\"lorem\\",
\\"description\\": \\"Lorem ipsum.\\",
\\"source\\": \\"@site/docs/lorem.md\\",
\\"sourceDirName\\": \\".\\",
\\"slug\\": \\"/lorem\\",
\\"permalink\\": \\"/docs/lorem\\",
\\"editUrl\\": \\"https://github.com/customUrl/docs/lorem.md\\",
@ -258,6 +264,7 @@ Object {
\\"title\\": \\"rootAbsoluteSlug\\",
\\"description\\": \\"Lorem\\",
\\"source\\": \\"@site/docs/rootAbsoluteSlug.md\\",
\\"sourceDirName\\": \\".\\",
\\"slug\\": \\"/rootAbsoluteSlug\\",
\\"permalink\\": \\"/docs/rootAbsoluteSlug\\",
\\"version\\": \\"current\\",
@ -272,6 +279,7 @@ Object {
\\"title\\": \\"rootRelativeSlug\\",
\\"description\\": \\"Lorem\\",
\\"source\\": \\"@site/docs/rootRelativeSlug.md\\",
\\"sourceDirName\\": \\".\\",
\\"slug\\": \\"/rootRelativeSlug\\",
\\"permalink\\": \\"/docs/rootRelativeSlug\\",
\\"version\\": \\"current\\",
@ -286,6 +294,7 @@ Object {
\\"title\\": \\"rootResolvedSlug\\",
\\"description\\": \\"Lorem\\",
\\"source\\": \\"@site/docs/rootResolvedSlug.md\\",
\\"sourceDirName\\": \\".\\",
\\"slug\\": \\"/hey/rootResolvedSlug\\",
\\"permalink\\": \\"/docs/hey/rootResolvedSlug\\",
\\"version\\": \\"current\\",
@ -300,6 +309,7 @@ Object {
\\"title\\": \\"rootTryToEscapeSlug\\",
\\"description\\": \\"Lorem\\",
\\"source\\": \\"@site/docs/rootTryToEscapeSlug.md\\",
\\"sourceDirName\\": \\".\\",
\\"slug\\": \\"/rootTryToEscapeSlug\\",
\\"permalink\\": \\"/docs/rootTryToEscapeSlug\\",
\\"version\\": \\"current\\",
@ -314,6 +324,7 @@ Object {
\\"title\\": \\"absoluteSlug\\",
\\"description\\": \\"Lorem\\",
\\"source\\": \\"@site/docs/slugs/absoluteSlug.md\\",
\\"sourceDirName\\": \\"slugs\\",
\\"slug\\": \\"/absoluteSlug\\",
\\"permalink\\": \\"/docs/absoluteSlug\\",
\\"version\\": \\"current\\",
@ -328,6 +339,7 @@ Object {
\\"title\\": \\"relativeSlug\\",
\\"description\\": \\"Lorem\\",
\\"source\\": \\"@site/docs/slugs/relativeSlug.md\\",
\\"sourceDirName\\": \\"slugs\\",
\\"slug\\": \\"/slugs/relativeSlug\\",
\\"permalink\\": \\"/docs/slugs/relativeSlug\\",
\\"version\\": \\"current\\",
@ -342,6 +354,7 @@ Object {
\\"title\\": \\"resolvedSlug\\",
\\"description\\": \\"Lorem\\",
\\"source\\": \\"@site/docs/slugs/resolvedSlug.md\\",
\\"sourceDirName\\": \\"slugs\\",
\\"slug\\": \\"/slugs/hey/resolvedSlug\\",
\\"permalink\\": \\"/docs/slugs/hey/resolvedSlug\\",
\\"version\\": \\"current\\",
@ -356,6 +369,7 @@ Object {
\\"title\\": \\"tryToEscapeSlug\\",
\\"description\\": \\"Lorem\\",
\\"source\\": \\"@site/docs/slugs/tryToEscapeSlug.md\\",
\\"sourceDirName\\": \\"slugs\\",
\\"slug\\": \\"/tryToEscapeSlug\\",
\\"permalink\\": \\"/docs/tryToEscapeSlug\\",
\\"version\\": \\"current\\",
@ -646,6 +660,134 @@ Array [
]
`;
exports[`site with custom sidebar items generator sidebarItemsGenerator is called with appropriate data 1`] = `
Object {
"docs": Array [
Object {
"frontMatter": Object {},
"id": "API/Core APIs/Client API",
"sidebarPosition": 0,
"source": "@site/docs/3-API/01_Core APIs/0 --- Client API.md",
"sourceDirName": "3-API/01_Core APIs",
},
Object {
"frontMatter": Object {},
"id": "API/Core APIs/Server API",
"sidebarPosition": 1,
"source": "@site/docs/3-API/01_Core APIs/1 --- Server API.md",
"sourceDirName": "3-API/01_Core APIs",
},
Object {
"frontMatter": Object {},
"id": "API/Extension APIs/Plugin API",
"sidebarPosition": 0,
"source": "@site/docs/3-API/02_Extension APIs/0. Plugin API.md",
"sourceDirName": "3-API/02_Extension APIs",
},
Object {
"frontMatter": Object {},
"id": "API/Extension APIs/Theme API",
"sidebarPosition": 1,
"source": "@site/docs/3-API/02_Extension APIs/1. Theme API.md",
"sourceDirName": "3-API/02_Extension APIs",
},
Object {
"frontMatter": Object {},
"id": "API/api-end",
"sidebarPosition": 3,
"source": "@site/docs/3-API/03_api-end.md",
"sourceDirName": "3-API",
},
Object {
"frontMatter": Object {},
"id": "API/api-overview",
"sidebarPosition": 0,
"source": "@site/docs/3-API/00_api-overview.md",
"sourceDirName": "3-API",
},
Object {
"frontMatter": Object {
"id": "guide1",
"sidebar_position": 1,
},
"id": "Guides/guide1",
"sidebarPosition": 1,
"source": "@site/docs/Guides/z-guide1.md",
"sourceDirName": "Guides",
},
Object {
"frontMatter": Object {
"id": "guide2",
},
"id": "Guides/guide2",
"sidebarPosition": 2,
"source": "@site/docs/Guides/02-guide2.md",
"sourceDirName": "Guides",
},
Object {
"frontMatter": Object {
"id": "guide2.5",
"sidebar_position": 2.5,
},
"id": "Guides/guide2.5",
"sidebarPosition": 2.5,
"source": "@site/docs/Guides/0-guide2.5.md",
"sourceDirName": "Guides",
},
Object {
"frontMatter": Object {
"id": "guide3",
"sidebar_position": 3,
},
"id": "Guides/guide3",
"sidebarPosition": 3,
"source": "@site/docs/Guides/guide3.md",
"sourceDirName": "Guides",
},
Object {
"frontMatter": Object {
"id": "guide4",
},
"id": "Guides/guide4",
"sidebarPosition": undefined,
"source": "@site/docs/Guides/a-guide4.md",
"sourceDirName": "Guides",
},
Object {
"frontMatter": Object {
"id": "guide5",
},
"id": "Guides/guide5",
"sidebarPosition": undefined,
"source": "@site/docs/Guides/b-guide5.md",
"sourceDirName": "Guides",
},
Object {
"frontMatter": Object {},
"id": "getting-started",
"sidebarPosition": 0,
"source": "@site/docs/0-getting-started.md",
"sourceDirName": ".",
},
Object {
"frontMatter": Object {},
"id": "installation",
"sidebarPosition": 1,
"source": "@site/docs/1-installation.md",
"sourceDirName": ".",
},
],
"item": Object {
"dirName": ".",
"type": "autogenerated",
},
"version": Object {
"contentPath": "docs",
"versionName": "current",
},
}
`;
exports[`site with wrong sidebar file 1`] = `
"Bad sidebars file.
These sidebar document ids do not exist:
@ -699,6 +841,7 @@ Object {
\\"title\\": \\"team\\",
\\"description\\": \\"Team 1.0.0\\",
\\"source\\": \\"@site/community_versioned_docs/version-1.0.0/team.md\\",
\\"sourceDirName\\": \\".\\",
\\"slug\\": \\"/team\\",
\\"permalink\\": \\"/community/team\\",
\\"version\\": \\"1.0.0\\",
@ -712,6 +855,7 @@ Object {
\\"title\\": \\"Team title translated\\",
\\"description\\": \\"Team current version (translated)\\",
\\"source\\": \\"@site/i18n/en/docusaurus-plugin-content-docs-community/current/team.md\\",
\\"sourceDirName\\": \\".\\",
\\"slug\\": \\"/team\\",
\\"permalink\\": \\"/community/next/team\\",
\\"version\\": \\"current\\",
@ -942,6 +1086,7 @@ Object {
\\"title\\": \\"bar\\",
\\"description\\": \\"This is next version of bar.\\",
\\"source\\": \\"@site/docs/foo/bar.md\\",
\\"sourceDirName\\": \\"foo\\",
\\"slug\\": \\"/foo/barSlug\\",
\\"permalink\\": \\"/docs/next/foo/barSlug\\",
\\"version\\": \\"current\\",
@ -961,6 +1106,7 @@ Object {
\\"title\\": \\"hello\\",
\\"description\\": \\"Hello next !\\",
\\"source\\": \\"@site/docs/hello.md\\",
\\"sourceDirName\\": \\".\\",
\\"slug\\": \\"/\\",
\\"permalink\\": \\"/docs/next/\\",
\\"version\\": \\"current\\",
@ -978,6 +1124,7 @@ Object {
\\"title\\": \\"absoluteSlug\\",
\\"description\\": \\"Lorem\\",
\\"source\\": \\"@site/docs/slugs/absoluteSlug.md\\",
\\"sourceDirName\\": \\"slugs\\",
\\"slug\\": \\"/absoluteSlug\\",
\\"permalink\\": \\"/docs/next/absoluteSlug\\",
\\"version\\": \\"current\\",
@ -992,6 +1139,7 @@ Object {
\\"title\\": \\"relativeSlug\\",
\\"description\\": \\"Lorem\\",
\\"source\\": \\"@site/docs/slugs/relativeSlug.md\\",
\\"sourceDirName\\": \\"slugs\\",
\\"slug\\": \\"/slugs/relativeSlug\\",
\\"permalink\\": \\"/docs/next/slugs/relativeSlug\\",
\\"version\\": \\"current\\",
@ -1006,6 +1154,7 @@ Object {
\\"title\\": \\"resolvedSlug\\",
\\"description\\": \\"Lorem\\",
\\"source\\": \\"@site/docs/slugs/resolvedSlug.md\\",
\\"sourceDirName\\": \\"slugs\\",
\\"slug\\": \\"/slugs/hey/resolvedSlug\\",
\\"permalink\\": \\"/docs/next/slugs/hey/resolvedSlug\\",
\\"version\\": \\"current\\",
@ -1020,6 +1169,7 @@ Object {
\\"title\\": \\"tryToEscapeSlug\\",
\\"description\\": \\"Lorem\\",
\\"source\\": \\"@site/docs/slugs/tryToEscapeSlug.md\\",
\\"sourceDirName\\": \\"slugs\\",
\\"slug\\": \\"/tryToEscapeSlug\\",
\\"permalink\\": \\"/docs/next/tryToEscapeSlug\\",
\\"version\\": \\"current\\",
@ -1034,6 +1184,7 @@ Object {
\\"title\\": \\"hello\\",
\\"description\\": \\"Hello 1.0.0 ! (translated en)\\",
\\"source\\": \\"@site/i18n/en/docusaurus-plugin-content-docs/version-1.0.0/hello.md\\",
\\"sourceDirName\\": \\".\\",
\\"slug\\": \\"/\\",
\\"permalink\\": \\"/docs/1.0.0/\\",
\\"version\\": \\"1.0.0\\",
@ -1051,6 +1202,7 @@ Object {
\\"title\\": \\"bar\\",
\\"description\\": \\"Bar 1.0.0 !\\",
\\"source\\": \\"@site/versioned_docs/version-1.0.0/foo/bar.md\\",
\\"sourceDirName\\": \\"foo\\",
\\"slug\\": \\"/foo/barSlug\\",
\\"permalink\\": \\"/docs/1.0.0/foo/barSlug\\",
\\"version\\": \\"1.0.0\\",
@ -1070,6 +1222,7 @@ Object {
\\"title\\": \\"baz\\",
\\"description\\": \\"Baz 1.0.0 ! This will be deleted in next subsequent versions.\\",
\\"source\\": \\"@site/versioned_docs/version-1.0.0/foo/baz.md\\",
\\"sourceDirName\\": \\"foo\\",
\\"slug\\": \\"/foo/baz\\",
\\"permalink\\": \\"/docs/1.0.0/foo/baz\\",
\\"version\\": \\"1.0.0\\",
@ -1091,6 +1244,7 @@ Object {
\\"title\\": \\"bar\\",
\\"description\\": \\"Bar 1.0.1 !\\",
\\"source\\": \\"@site/versioned_docs/version-1.0.1/foo/bar.md\\",
\\"sourceDirName\\": \\"foo\\",
\\"slug\\": \\"/foo/bar\\",
\\"permalink\\": \\"/docs/foo/bar\\",
\\"version\\": \\"1.0.1\\",
@ -1108,6 +1262,7 @@ Object {
\\"title\\": \\"hello\\",
\\"description\\": \\"Hello 1.0.1 !\\",
\\"source\\": \\"@site/versioned_docs/version-1.0.1/hello.md\\",
\\"sourceDirName\\": \\".\\",
\\"slug\\": \\"/\\",
\\"permalink\\": \\"/docs/\\",
\\"version\\": \\"1.0.1\\",
@ -1125,6 +1280,7 @@ Object {
\\"title\\": \\"rootAbsoluteSlug\\",
\\"description\\": \\"Lorem\\",
\\"source\\": \\"@site/versioned_docs/version-withSlugs/rootAbsoluteSlug.md\\",
\\"sourceDirName\\": \\".\\",
\\"slug\\": \\"/rootAbsoluteSlug\\",
\\"permalink\\": \\"/docs/withSlugs/rootAbsoluteSlug\\",
\\"version\\": \\"withSlugs\\",
@ -1140,6 +1296,7 @@ Object {
\\"title\\": \\"rootRelativeSlug\\",
\\"description\\": \\"Lorem\\",
\\"source\\": \\"@site/versioned_docs/version-withSlugs/rootRelativeSlug.md\\",
\\"sourceDirName\\": \\".\\",
\\"slug\\": \\"/rootRelativeSlug\\",
\\"permalink\\": \\"/docs/withSlugs/rootRelativeSlug\\",
\\"version\\": \\"withSlugs\\",
@ -1154,6 +1311,7 @@ Object {
\\"title\\": \\"rootResolvedSlug\\",
\\"description\\": \\"Lorem\\",
\\"source\\": \\"@site/versioned_docs/version-withSlugs/rootResolvedSlug.md\\",
\\"sourceDirName\\": \\".\\",
\\"slug\\": \\"/hey/rootResolvedSlug\\",
\\"permalink\\": \\"/docs/withSlugs/hey/rootResolvedSlug\\",
\\"version\\": \\"withSlugs\\",
@ -1168,6 +1326,7 @@ Object {
\\"title\\": \\"rootTryToEscapeSlug\\",
\\"description\\": \\"Lorem\\",
\\"source\\": \\"@site/versioned_docs/version-withSlugs/rootTryToEscapeSlug.md\\",
\\"sourceDirName\\": \\".\\",
\\"slug\\": \\"/rootTryToEscapeSlug\\",
\\"permalink\\": \\"/docs/withSlugs/rootTryToEscapeSlug\\",
\\"version\\": \\"withSlugs\\",
@ -1182,6 +1341,7 @@ Object {
\\"title\\": \\"absoluteSlug\\",
\\"description\\": \\"Lorem\\",
\\"source\\": \\"@site/versioned_docs/version-withSlugs/slugs/absoluteSlug.md\\",
\\"sourceDirName\\": \\"slugs\\",
\\"slug\\": \\"/absoluteSlug\\",
\\"permalink\\": \\"/docs/withSlugs/absoluteSlug\\",
\\"version\\": \\"withSlugs\\",
@ -1196,6 +1356,7 @@ Object {
\\"title\\": \\"relativeSlug\\",
\\"description\\": \\"Lorem\\",
\\"source\\": \\"@site/versioned_docs/version-withSlugs/slugs/relativeSlug.md\\",
\\"sourceDirName\\": \\"slugs\\",
\\"slug\\": \\"/slugs/relativeSlug\\",
\\"permalink\\": \\"/docs/withSlugs/slugs/relativeSlug\\",
\\"version\\": \\"withSlugs\\",
@ -1210,6 +1371,7 @@ Object {
\\"title\\": \\"resolvedSlug\\",
\\"description\\": \\"Lorem\\",
\\"source\\": \\"@site/versioned_docs/version-withSlugs/slugs/resolvedSlug.md\\",
\\"sourceDirName\\": \\"slugs\\",
\\"slug\\": \\"/slugs/hey/resolvedSlug\\",
\\"permalink\\": \\"/docs/withSlugs/slugs/hey/resolvedSlug\\",
\\"version\\": \\"withSlugs\\",
@ -1224,6 +1386,7 @@ Object {
\\"title\\": \\"tryToEscapeSlug\\",
\\"description\\": \\"Lorem\\",
\\"source\\": \\"@site/versioned_docs/version-withSlugs/slugs/tryToEscapeSlug.md\\",
\\"sourceDirName\\": \\"slugs\\",
\\"slug\\": \\"/tryToEscapeSlug\\",
\\"permalink\\": \\"/docs/withSlugs/tryToEscapeSlug\\",
\\"version\\": \\"withSlugs\\",

View file

@ -177,6 +177,7 @@ describe('simple site', () => {
version: 'current',
id: 'foo/bar',
unversionedId: 'foo/bar',
sourceDirName: 'foo',
isDocsHomePage: false,
permalink: '/docs/foo/bar',
slug: '/foo/bar',
@ -192,6 +193,7 @@ describe('simple site', () => {
version: 'current',
id: 'hello',
unversionedId: 'hello',
sourceDirName: '.',
isDocsHomePage: false,
permalink: '/docs/hello',
slug: '/hello',
@ -220,6 +222,7 @@ describe('simple site', () => {
version: 'current',
id: 'hello',
unversionedId: 'hello',
sourceDirName: '.',
isDocsHomePage: true,
permalink: '/docs/',
slug: '/',
@ -248,6 +251,7 @@ describe('simple site', () => {
version: 'current',
id: 'foo/bar',
unversionedId: 'foo/bar',
sourceDirName: 'foo',
isDocsHomePage: true,
permalink: '/docs/',
slug: '/',
@ -279,6 +283,7 @@ describe('simple site', () => {
version: 'current',
id: 'foo/baz',
unversionedId: 'foo/baz',
sourceDirName: 'foo',
isDocsHomePage: false,
permalink: '/docs/foo/bazSlug.html',
slug: '/foo/bazSlug.html',
@ -301,6 +306,7 @@ describe('simple site', () => {
version: 'current',
id: 'lorem',
unversionedId: 'lorem',
sourceDirName: '.',
isDocsHomePage: false,
permalink: '/docs/lorem',
slug: '/lorem',
@ -336,6 +342,7 @@ describe('simple site', () => {
version: 'current',
id: 'foo/baz',
unversionedId: 'foo/baz',
sourceDirName: 'foo',
isDocsHomePage: false,
permalink: '/docs/foo/bazSlug.html',
slug: '/foo/bazSlug.html',
@ -378,6 +385,7 @@ describe('simple site', () => {
version: 'current',
id: 'lorem',
unversionedId: 'lorem',
sourceDirName: '.',
isDocsHomePage: false,
permalink: '/docs/lorem',
slug: '/lorem',
@ -549,6 +557,7 @@ describe('versioned site', () => {
await currentVersionTestUtils.testMeta(path.join('foo', 'bar.md'), {
id: 'foo/bar',
unversionedId: 'foo/bar',
sourceDirName: 'foo',
isDocsHomePage: false,
permalink: '/docs/next/foo/barSlug',
slug: '/foo/barSlug',
@ -560,6 +569,7 @@ describe('versioned site', () => {
await currentVersionTestUtils.testMeta(path.join('hello.md'), {
id: 'hello',
unversionedId: 'hello',
sourceDirName: '.',
isDocsHomePage: false,
permalink: '/docs/next/hello',
slug: '/hello',
@ -576,6 +586,7 @@ describe('versioned site', () => {
await version100TestUtils.testMeta(path.join('foo', 'bar.md'), {
id: 'version-1.0.0/foo/bar',
unversionedId: 'foo/bar',
sourceDirName: 'foo',
isDocsHomePage: false,
permalink: '/docs/1.0.0/foo/barSlug',
slug: '/foo/barSlug',
@ -587,6 +598,7 @@ describe('versioned site', () => {
await version100TestUtils.testMeta(path.join('hello.md'), {
id: 'version-1.0.0/hello',
unversionedId: 'hello',
sourceDirName: '.',
isDocsHomePage: false,
permalink: '/docs/1.0.0/hello',
slug: '/hello',
@ -600,6 +612,7 @@ describe('versioned site', () => {
await version101TestUtils.testMeta(path.join('foo', 'bar.md'), {
id: 'version-1.0.1/foo/bar',
unversionedId: 'foo/bar',
sourceDirName: 'foo',
isDocsHomePage: false,
permalink: '/docs/foo/bar',
slug: '/foo/bar',
@ -611,6 +624,7 @@ describe('versioned site', () => {
await version101TestUtils.testMeta(path.join('hello.md'), {
id: 'version-1.0.1/hello',
unversionedId: 'hello',
sourceDirName: '.',
isDocsHomePage: false,
permalink: '/docs/hello',
slug: '/hello',
@ -701,6 +715,7 @@ describe('versioned site', () => {
await testUtilsLocal.testMeta(path.join('hello.md'), {
id: 'version-1.0.0/hello',
unversionedId: 'hello',
sourceDirName: '.',
isDocsHomePage: false,
permalink: '/docs/1.0.0/hello',
slug: '/hello',
@ -741,6 +756,7 @@ describe('versioned site', () => {
await testUtilsLocal.testMeta(path.join('hello.md'), {
id: 'version-1.0.0/hello',
unversionedId: 'hello',
sourceDirName: '.',
isDocsHomePage: false,
permalink: '/docs/1.0.0/hello',
slug: '/hello',
@ -773,6 +789,7 @@ describe('versioned site', () => {
await testUtilsLocal.testMeta(path.join('hello.md'), {
id: 'version-1.0.0/hello',
unversionedId: 'hello',
sourceDirName: '.',
isDocsHomePage: false,
permalink: '/docs/1.0.0/hello',
slug: '/hello',
@ -806,6 +823,7 @@ describe('versioned site', () => {
await testUtilsLocal.testMeta(path.join('hello.md'), {
id: 'version-1.0.0/hello',
unversionedId: 'hello',
sourceDirName: '.',
isDocsHomePage: false,
permalink: '/fr/docs/1.0.0/hello',
slug: '/hello',
@ -840,6 +858,7 @@ describe('versioned site', () => {
await testUtilsLocal.testMeta(path.join('hello.md'), {
id: 'version-1.0.0/hello',
unversionedId: 'hello',
sourceDirName: '.',
isDocsHomePage: false,
permalink: '/fr/docs/1.0.0/hello',
slug: '/hello',

View file

@ -10,7 +10,7 @@
import path from 'path';
import {isMatch} from 'picomatch';
import commander from 'commander';
import {kebabCase} from 'lodash';
import {kebabCase, orderBy} from 'lodash';
import fs from 'fs-extra';
import pluginContentDocs from '../index';
@ -24,7 +24,7 @@ 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} from '../types';
import {DocMetadata, LoadedVersion, SidebarItemsGenerator} from '../types';
import {toSidebarsProp} from '../props';
// @ts-expect-error: TODO typedefs missing?
@ -33,6 +33,17 @@ import {validate} from 'webpack';
function findDocById(version: LoadedVersion, unversionedId: string) {
return version.docs.find((item) => item.unversionedId === unversionedId);
}
function getDocById(version: LoadedVersion, unversionedId: string) {
const doc = findDocById(version, unversionedId);
if (!doc) {
throw new Error(
`No doc found with id=${unversionedId} in version ${version.versionName}.
Available ids=\n- ${version.docs.map((d) => d.unversionedId).join('\n- ')}`,
);
}
return doc;
}
const defaultDocMetadata: Partial<DocMetadata> = {
next: undefined,
previous: undefined,
@ -40,6 +51,7 @@ const defaultDocMetadata: Partial<DocMetadata> = {
lastUpdatedAt: undefined,
lastUpdatedBy: undefined,
sidebar_label: undefined,
formattedLastUpdatedAt: undefined,
};
const createFakeActions = (contentDir: string) => {
@ -203,6 +215,7 @@ describe('simple website', () => {
"sidebars.json",
"i18n/en/docusaurus-plugin-content-docs/current/**/*.{md,mdx}",
"docs/**/*.{md,mdx}",
"docs/**/_category_.{json,yml,yaml}",
]
`);
expect(isMatch('docs/hello.md', matchPattern)).toEqual(true);
@ -247,6 +260,7 @@ describe('simple website', () => {
version: 'current',
id: 'hello',
unversionedId: 'hello',
sourceDirName: '.',
isDocsHomePage: true,
permalink: '/docs/',
slug: '/',
@ -268,11 +282,12 @@ describe('simple website', () => {
},
});
expect(findDocById(currentVersion, 'foo/bar')).toEqual({
expect(getDocById(currentVersion, 'foo/bar')).toEqual({
...defaultDocMetadata,
version: 'current',
id: 'foo/bar',
unversionedId: 'foo/bar',
sourceDirName: 'foo',
isDocsHomePage: false,
next: {
title: 'baz',
@ -368,15 +383,19 @@ describe('versioned website', () => {
"sidebars.json",
"i18n/en/docusaurus-plugin-content-docs/current/**/*.{md,mdx}",
"docs/**/*.{md,mdx}",
"docs/**/_category_.{json,yml,yaml}",
"versioned_sidebars/version-1.0.1-sidebars.json",
"i18n/en/docusaurus-plugin-content-docs/version-1.0.1/**/*.{md,mdx}",
"versioned_docs/version-1.0.1/**/*.{md,mdx}",
"versioned_docs/version-1.0.1/**/_category_.{json,yml,yaml}",
"versioned_sidebars/version-1.0.0-sidebars.json",
"i18n/en/docusaurus-plugin-content-docs/version-1.0.0/**/*.{md,mdx}",
"versioned_docs/version-1.0.0/**/*.{md,mdx}",
"versioned_docs/version-1.0.0/**/_category_.{json,yml,yaml}",
"versioned_sidebars/version-withSlugs-sidebars.json",
"i18n/en/docusaurus-plugin-content-docs/version-withSlugs/**/*.{md,mdx}",
"versioned_docs/version-withSlugs/**/*.{md,mdx}",
"versioned_docs/version-withSlugs/**/_category_.{json,yml,yaml}",
]
`);
expect(isMatch('docs/hello.md', matchPattern)).toEqual(true);
@ -427,10 +446,11 @@ describe('versioned website', () => {
expect(findDocById(version101, 'foo/baz')).toBeUndefined();
expect(findDocById(versionWithSlugs, 'foo/baz')).toBeUndefined();
expect(findDocById(currentVersion, 'foo/bar')).toEqual({
expect(getDocById(currentVersion, 'foo/bar')).toEqual({
...defaultDocMetadata,
id: 'foo/bar',
unversionedId: 'foo/bar',
sourceDirName: 'foo',
isDocsHomePage: false,
permalink: '/docs/next/foo/barSlug',
slug: '/foo/barSlug',
@ -452,10 +472,11 @@ describe('versioned website', () => {
permalink: '/docs/next/',
},
});
expect(findDocById(currentVersion, 'hello')).toEqual({
expect(getDocById(currentVersion, 'hello')).toEqual({
...defaultDocMetadata,
id: 'hello',
unversionedId: 'hello',
sourceDirName: '.',
isDocsHomePage: true,
permalink: '/docs/next/',
slug: '/',
@ -474,10 +495,11 @@ describe('versioned website', () => {
permalink: '/docs/next/foo/barSlug',
},
});
expect(findDocById(version101, 'hello')).toEqual({
expect(getDocById(version101, 'hello')).toEqual({
...defaultDocMetadata,
id: 'version-1.0.1/hello',
unversionedId: 'hello',
sourceDirName: '.',
isDocsHomePage: true,
permalink: '/docs/',
slug: '/',
@ -496,10 +518,11 @@ describe('versioned website', () => {
permalink: '/docs/foo/bar',
},
});
expect(findDocById(version100, 'foo/baz')).toEqual({
expect(getDocById(version100, 'foo/baz')).toEqual({
...defaultDocMetadata,
id: 'version-1.0.0/foo/baz',
unversionedId: 'foo/baz',
sourceDirName: 'foo',
isDocsHomePage: false,
permalink: '/docs/1.0.0/foo/baz',
slug: '/foo/baz',
@ -611,9 +634,11 @@ describe('versioned website (community)', () => {
"community_sidebars.json",
"i18n/en/docusaurus-plugin-content-docs-community/current/**/*.{md,mdx}",
"community/**/*.{md,mdx}",
"community/**/_category_.{json,yml,yaml}",
"community_versioned_sidebars/version-1.0.0-sidebars.json",
"i18n/en/docusaurus-plugin-content-docs-community/version-1.0.0/**/*.{md,mdx}",
"community_versioned_docs/version-1.0.0/**/*.{md,mdx}",
"community_versioned_docs/version-1.0.0/**/_category_.{json,yml,yaml}",
]
`);
expect(isMatch('community/team.md', matchPattern)).toEqual(true);
@ -644,10 +669,11 @@ describe('versioned website (community)', () => {
expect(content.loadedVersions.length).toEqual(2);
const [currentVersion, version100] = content.loadedVersions;
expect(findDocById(currentVersion, 'team')).toEqual({
expect(getDocById(currentVersion, 'team')).toEqual({
...defaultDocMetadata,
id: 'team',
unversionedId: 'team',
sourceDirName: '.',
isDocsHomePage: false,
permalink: '/community/next/team',
slug: '/team',
@ -659,10 +685,11 @@ describe('versioned website (community)', () => {
sidebar: 'community',
frontMatter: {title: 'Team title translated'},
});
expect(findDocById(version100, 'team')).toEqual({
expect(getDocById(version100, 'team')).toEqual({
...defaultDocMetadata,
id: 'version-1.0.0/team',
unversionedId: 'team',
sourceDirName: '.',
isDocsHomePage: false,
permalink: '/community/team',
slug: '/team',
@ -709,7 +736,7 @@ describe('site with doc label', () => {
}),
);
const content = await plugin.loadContent();
const content = (await plugin.loadContent?.())!;
return {content};
}
@ -730,3 +757,807 @@ describe('site with doc label', () => {
expect(sidebarProps.docs[1].label).toBe('Hello 2 From Doc');
});
});
describe('site with full autogenerated sidebar', () => {
async function loadSite() {
const siteDir = path.join(
__dirname,
'__fixtures__',
'site-with-autogenerated-sidebar',
);
const context = await loadContext(siteDir);
const plugin = pluginContentDocs(
context,
normalizePluginOptions(OptionsSchema, {
path: 'docs',
}),
);
const content = (await plugin.loadContent?.())!;
return {content, siteDir};
}
test('sidebar is fully autogenerated', async () => {
const {content} = await loadSite();
const version = content.loadedVersions[0];
expect(version.sidebars).toEqual({
defaultSidebar: [
{
type: 'doc',
id: 'getting-started',
},
{
type: 'doc',
id: 'installation',
},
{
type: 'category',
label: 'Guides',
collapsed: true,
items: [
{
type: 'doc',
id: 'Guides/guide1',
},
{
type: 'doc',
id: 'Guides/guide2',
},
{
type: 'doc',
id: 'Guides/guide2.5',
},
{
type: 'doc',
id: 'Guides/guide3',
},
{
type: 'doc',
id: 'Guides/guide4',
},
{
type: 'doc',
id: 'Guides/guide5',
},
],
},
{
type: 'category',
label: 'API (label from _category_.json)',
collapsed: true,
items: [
{
type: 'doc',
id: 'API/api-overview',
},
{
type: 'category',
label: 'Core APIs',
collapsed: true,
items: [
{
type: 'doc',
id: 'API/Core APIs/Client API',
},
{
type: 'doc',
id: 'API/Core APIs/Server API',
},
],
},
{
type: 'category',
label: 'Extension APIs (label from _category_.yml)',
collapsed: true,
items: [
{
type: 'doc',
id: 'API/Extension APIs/Plugin API',
},
{
type: 'doc',
id: 'API/Extension APIs/Theme API',
},
],
},
{
type: 'doc',
id: 'API/api-end',
},
],
},
],
});
});
test('docs in fully generated sidebar have correct metadatas', async () => {
const {content, siteDir} = await loadSite();
const version = content.loadedVersions[0];
expect(getDocById(version, 'getting-started')).toEqual({
...defaultDocMetadata,
id: 'getting-started',
unversionedId: 'getting-started',
sourceDirName: '.',
isDocsHomePage: false,
permalink: '/docs/getting-started',
slug: '/getting-started',
source: path.posix.join(
'@site',
posixPath(path.relative(siteDir, version.contentPath)),
'0-getting-started.md',
),
title: 'Getting Started',
description: 'Getting started text',
version: 'current',
sidebar: 'defaultSidebar',
frontMatter: {},
sidebarPosition: 0,
previous: undefined,
next: {
permalink: '/docs/installation',
title: 'Installation',
},
});
expect(getDocById(version, 'installation')).toEqual({
...defaultDocMetadata,
id: 'installation',
unversionedId: 'installation',
sourceDirName: '.',
isDocsHomePage: false,
permalink: '/docs/installation',
slug: '/installation',
source: path.posix.join(
'@site',
posixPath(path.relative(siteDir, version.contentPath)),
'1-installation.md',
),
title: 'Installation',
description: 'Installation text',
version: 'current',
sidebar: 'defaultSidebar',
frontMatter: {},
sidebarPosition: 1,
previous: {
permalink: '/docs/getting-started',
title: 'Getting Started',
},
next: {
permalink: '/docs/Guides/guide1',
title: 'Guide 1',
},
});
expect(getDocById(version, 'Guides/guide1')).toEqual({
...defaultDocMetadata,
id: 'Guides/guide1',
unversionedId: 'Guides/guide1',
sourceDirName: 'Guides',
isDocsHomePage: false,
permalink: '/docs/Guides/guide1',
slug: '/Guides/guide1',
source: path.posix.join(
'@site',
posixPath(path.relative(siteDir, version.contentPath)),
'Guides',
'z-guide1.md',
),
title: 'Guide 1',
description: 'Guide 1 text',
version: 'current',
sidebar: 'defaultSidebar',
frontMatter: {
id: 'guide1',
sidebar_position: 1,
},
sidebarPosition: 1,
previous: {
permalink: '/docs/installation',
title: 'Installation',
},
next: {
permalink: '/docs/Guides/guide2',
title: 'Guide 2',
},
});
expect(getDocById(version, 'Guides/guide2')).toEqual({
...defaultDocMetadata,
id: 'Guides/guide2',
unversionedId: 'Guides/guide2',
sourceDirName: 'Guides',
isDocsHomePage: false,
permalink: '/docs/Guides/guide2',
slug: '/Guides/guide2',
source: path.posix.join(
'@site',
posixPath(path.relative(siteDir, version.contentPath)),
'Guides',
'02-guide2.md',
),
title: 'Guide 2',
description: 'Guide 2 text',
version: 'current',
sidebar: 'defaultSidebar',
frontMatter: {
id: 'guide2',
},
sidebarPosition: 2,
previous: {
permalink: '/docs/Guides/guide1',
title: 'Guide 1',
},
next: {
permalink: '/docs/Guides/guide2.5',
title: 'Guide 2.5',
},
});
expect(getDocById(version, 'Guides/guide2.5')).toEqual({
...defaultDocMetadata,
id: 'Guides/guide2.5',
unversionedId: 'Guides/guide2.5',
sourceDirName: 'Guides',
isDocsHomePage: false,
permalink: '/docs/Guides/guide2.5',
slug: '/Guides/guide2.5',
source: path.posix.join(
'@site',
posixPath(path.relative(siteDir, version.contentPath)),
'Guides',
'0-guide2.5.md',
),
title: 'Guide 2.5',
description: 'Guide 2.5 text',
version: 'current',
sidebar: 'defaultSidebar',
frontMatter: {
id: 'guide2.5',
sidebar_position: 2.5,
},
sidebarPosition: 2.5,
previous: {
permalink: '/docs/Guides/guide2',
title: 'Guide 2',
},
next: {
permalink: '/docs/Guides/guide3',
title: 'Guide 3',
},
});
expect(getDocById(version, 'Guides/guide3')).toEqual({
...defaultDocMetadata,
id: 'Guides/guide3',
unversionedId: 'Guides/guide3',
sourceDirName: 'Guides',
isDocsHomePage: false,
permalink: '/docs/Guides/guide3',
slug: '/Guides/guide3',
source: path.posix.join(
'@site',
posixPath(path.relative(siteDir, version.contentPath)),
'Guides',
'guide3.md',
),
title: 'Guide 3',
description: 'Guide 3 text',
version: 'current',
sidebar: 'defaultSidebar',
frontMatter: {
id: 'guide3',
sidebar_position: 3,
},
sidebarPosition: 3,
previous: {
permalink: '/docs/Guides/guide2.5',
title: 'Guide 2.5',
},
next: {
permalink: '/docs/Guides/guide4',
title: 'Guide 4',
},
});
expect(getDocById(version, 'Guides/guide4')).toEqual({
...defaultDocMetadata,
id: 'Guides/guide4',
unversionedId: 'Guides/guide4',
sourceDirName: 'Guides',
isDocsHomePage: false,
permalink: '/docs/Guides/guide4',
slug: '/Guides/guide4',
source: path.posix.join(
'@site',
posixPath(path.relative(siteDir, version.contentPath)),
'Guides',
'a-guide4.md',
),
title: 'Guide 4',
description: 'Guide 4 text',
version: 'current',
sidebar: 'defaultSidebar',
frontMatter: {
id: 'guide4',
},
sidebarPosition: undefined,
previous: {
permalink: '/docs/Guides/guide3',
title: 'Guide 3',
},
next: {
permalink: '/docs/Guides/guide5',
title: 'Guide 5',
},
});
expect(getDocById(version, 'Guides/guide5')).toEqual({
...defaultDocMetadata,
id: 'Guides/guide5',
unversionedId: 'Guides/guide5',
sourceDirName: 'Guides',
isDocsHomePage: false,
permalink: '/docs/Guides/guide5',
slug: '/Guides/guide5',
source: path.posix.join(
'@site',
posixPath(path.relative(siteDir, version.contentPath)),
'Guides',
'b-guide5.md',
),
title: 'Guide 5',
description: 'Guide 5 text',
version: 'current',
sidebar: 'defaultSidebar',
frontMatter: {
id: 'guide5',
},
sidebarPosition: undefined,
previous: {
permalink: '/docs/Guides/guide4',
title: 'Guide 4',
},
next: {
permalink: '/docs/API/api-overview',
title: 'API Overview',
},
});
expect(getDocById(version, 'API/api-overview')).toEqual({
...defaultDocMetadata,
id: 'API/api-overview',
unversionedId: 'API/api-overview',
sourceDirName: '3-API',
isDocsHomePage: false,
permalink: '/docs/API/api-overview',
slug: '/API/api-overview',
source: path.posix.join(
'@site',
posixPath(path.relative(siteDir, version.contentPath)),
'3-API',
'00_api-overview.md',
),
title: 'API Overview',
description: 'API Overview text',
version: 'current',
sidebar: 'defaultSidebar',
frontMatter: {},
sidebarPosition: 0,
previous: {
permalink: '/docs/Guides/guide5',
title: 'Guide 5',
},
next: {
permalink: '/docs/API/Core APIs/Client API',
title: 'Client API',
},
});
expect(getDocById(version, 'API/Core APIs/Client API')).toEqual({
...defaultDocMetadata,
id: 'API/Core APIs/Client API',
unversionedId: 'API/Core APIs/Client API',
sourceDirName: '3-API/01_Core APIs',
isDocsHomePage: false,
permalink: '/docs/API/Core APIs/Client API',
slug: '/API/Core APIs/Client API',
source: path.posix.join(
'@site',
posixPath(path.relative(siteDir, version.contentPath)),
'3-API',
'01_Core APIs',
'0 --- Client API.md',
),
title: 'Client API',
description: 'Client API text',
version: 'current',
sidebar: 'defaultSidebar',
frontMatter: {},
sidebarPosition: 0,
previous: {
permalink: '/docs/API/api-overview',
title: 'API Overview',
},
next: {
permalink: '/docs/API/Core APIs/Server API',
title: 'Server API',
},
});
expect(getDocById(version, 'API/Core APIs/Server API')).toEqual({
...defaultDocMetadata,
id: 'API/Core APIs/Server API',
unversionedId: 'API/Core APIs/Server API',
sourceDirName: '3-API/01_Core APIs',
isDocsHomePage: false,
permalink: '/docs/API/Core APIs/Server API',
slug: '/API/Core APIs/Server API',
source: path.posix.join(
'@site',
posixPath(path.relative(siteDir, version.contentPath)),
'3-API',
'01_Core APIs',
'1 --- Server API.md',
),
title: 'Server API',
description: 'Server API text',
version: 'current',
sidebar: 'defaultSidebar',
frontMatter: {},
sidebarPosition: 1,
previous: {
permalink: '/docs/API/Core APIs/Client API',
title: 'Client API',
},
next: {
permalink: '/docs/API/Extension APIs/Plugin API',
title: 'Plugin API',
},
});
expect(getDocById(version, 'API/Extension APIs/Plugin API')).toEqual({
...defaultDocMetadata,
id: 'API/Extension APIs/Plugin API',
unversionedId: 'API/Extension APIs/Plugin API',
sourceDirName: '3-API/02_Extension APIs',
isDocsHomePage: false,
permalink: '/docs/API/Extension APIs/Plugin API',
slug: '/API/Extension APIs/Plugin API',
source: path.posix.join(
'@site',
posixPath(path.relative(siteDir, version.contentPath)),
'3-API',
'02_Extension APIs',
'0. Plugin API.md',
),
title: 'Plugin API',
description: 'Plugin API text',
version: 'current',
sidebar: 'defaultSidebar',
frontMatter: {},
sidebarPosition: 0,
previous: {
permalink: '/docs/API/Core APIs/Server API',
title: 'Server API',
},
next: {
permalink: '/docs/API/Extension APIs/Theme API',
title: 'Theme API',
},
});
expect(getDocById(version, 'API/Extension APIs/Theme API')).toEqual({
...defaultDocMetadata,
id: 'API/Extension APIs/Theme API',
unversionedId: 'API/Extension APIs/Theme API',
sourceDirName: '3-API/02_Extension APIs',
isDocsHomePage: false,
permalink: '/docs/API/Extension APIs/Theme API',
slug: '/API/Extension APIs/Theme API',
source: path.posix.join(
'@site',
posixPath(path.relative(siteDir, version.contentPath)),
'3-API',
'02_Extension APIs',
'1. Theme API.md',
),
title: 'Theme API',
description: 'Theme API text',
version: 'current',
sidebar: 'defaultSidebar',
frontMatter: {},
sidebarPosition: 1,
previous: {
permalink: '/docs/API/Extension APIs/Plugin API',
title: 'Plugin API',
},
next: {
permalink: '/docs/API/api-end',
title: 'API End',
},
});
expect(getDocById(version, 'API/api-end')).toEqual({
...defaultDocMetadata,
id: 'API/api-end',
unversionedId: 'API/api-end',
sourceDirName: '3-API',
isDocsHomePage: false,
permalink: '/docs/API/api-end',
slug: '/API/api-end',
source: path.posix.join(
'@site',
posixPath(path.relative(siteDir, version.contentPath)),
'3-API',
'03_api-end.md',
),
title: 'API End',
description: 'API End text',
version: 'current',
sidebar: 'defaultSidebar',
frontMatter: {},
sidebarPosition: 3,
previous: {
permalink: '/docs/API/Extension APIs/Theme API',
title: 'Theme API',
},
next: undefined,
});
});
});
describe('site with partial autogenerated sidebars', () => {
async function loadSite() {
const siteDir = path.join(
__dirname,
'__fixtures__',
'site-with-autogenerated-sidebar',
);
const context = await loadContext(siteDir, {});
const plugin = pluginContentDocs(
context,
normalizePluginOptions(OptionsSchema, {
path: 'docs',
sidebarPath: path.join(
__dirname,
'__fixtures__',
'site-with-autogenerated-sidebar',
'partialAutogeneratedSidebars.js',
),
}),
);
const content = (await plugin.loadContent?.())!;
return {content, siteDir};
}
test('sidebar is partially autogenerated', async () => {
const {content} = await loadSite();
const version = content.loadedVersions[0];
expect(version.sidebars).toEqual({
someSidebar: [
{
type: 'doc',
id: 'API/api-end',
},
{
type: 'category',
label: 'Some category',
collapsed: true,
items: [
{
type: 'doc',
id: 'API/api-overview',
},
{
type: 'doc',
id: 'API/Extension APIs/Plugin API',
},
{
type: 'doc',
id: 'API/Extension APIs/Theme API',
},
],
},
],
});
});
test('docs in partially generated sidebar have correct metadatas', async () => {
const {content, siteDir} = await loadSite();
const version = content.loadedVersions[0];
// Only looking at the docs of the autogen sidebar, others metadatas should not be affected
expect(getDocById(version, 'API/api-end')).toEqual({
...defaultDocMetadata,
id: 'API/api-end',
unversionedId: 'API/api-end',
sourceDirName: '3-API',
isDocsHomePage: false,
permalink: '/docs/API/api-end',
slug: '/API/api-end',
source: path.posix.join(
'@site',
posixPath(path.relative(siteDir, version.contentPath)),
'3-API',
'03_api-end.md',
),
title: 'API End',
description: 'API End text',
version: 'current',
sidebar: 'someSidebar',
frontMatter: {},
sidebarPosition: 3, // ignored (not part of the autogenerated sidebar slice)
previous: undefined,
next: {
permalink: '/docs/API/api-overview',
title: 'API Overview',
},
});
expect(getDocById(version, 'API/api-overview')).toEqual({
...defaultDocMetadata,
id: 'API/api-overview',
unversionedId: 'API/api-overview',
sourceDirName: '3-API',
isDocsHomePage: false,
permalink: '/docs/API/api-overview',
slug: '/API/api-overview',
source: path.posix.join(
'@site',
posixPath(path.relative(siteDir, version.contentPath)),
'3-API',
'00_api-overview.md',
),
title: 'API Overview',
description: 'API Overview text',
version: 'current',
sidebar: 'someSidebar',
frontMatter: {},
sidebarPosition: 0, // ignored (not part of the autogenerated sidebar slice)
previous: {
permalink: '/docs/API/api-end',
title: 'API End',
},
next: {
permalink: '/docs/API/Extension APIs/Plugin API',
title: 'Plugin API',
},
});
expect(getDocById(version, 'API/Extension APIs/Plugin API')).toEqual({
...defaultDocMetadata,
id: 'API/Extension APIs/Plugin API',
unversionedId: 'API/Extension APIs/Plugin API',
sourceDirName: '3-API/02_Extension APIs',
isDocsHomePage: false,
permalink: '/docs/API/Extension APIs/Plugin API',
slug: '/API/Extension APIs/Plugin API',
source: path.posix.join(
'@site',
posixPath(path.relative(siteDir, version.contentPath)),
'3-API',
'02_Extension APIs',
'0. Plugin API.md',
),
title: 'Plugin API',
description: 'Plugin API text',
version: 'current',
sidebar: 'someSidebar',
frontMatter: {},
sidebarPosition: 0,
previous: {
permalink: '/docs/API/api-overview',
title: 'API Overview',
},
next: {
permalink: '/docs/API/Extension APIs/Theme API',
title: 'Theme API',
},
});
expect(getDocById(version, 'API/Extension APIs/Theme API')).toEqual({
...defaultDocMetadata,
id: 'API/Extension APIs/Theme API',
unversionedId: 'API/Extension APIs/Theme API',
sourceDirName: '3-API/02_Extension APIs',
isDocsHomePage: false,
permalink: '/docs/API/Extension APIs/Theme API',
slug: '/API/Extension APIs/Theme API',
source: path.posix.join(
'@site',
posixPath(path.relative(siteDir, version.contentPath)),
'3-API',
'02_Extension APIs',
'1. Theme API.md',
),
title: 'Theme API',
description: 'Theme API text',
version: 'current',
sidebar: 'someSidebar',
frontMatter: {},
sidebarPosition: 1,
previous: {
permalink: '/docs/API/Extension APIs/Plugin API',
title: 'Plugin API',
},
next: undefined,
});
});
});
describe('site with custom sidebar items generator', () => {
async function loadSite(sidebarItemsGenerator: SidebarItemsGenerator) {
const siteDir = path.join(
__dirname,
'__fixtures__',
'site-with-autogenerated-sidebar',
);
const context = await loadContext(siteDir);
const plugin = pluginContentDocs(
context,
normalizePluginOptions(OptionsSchema, {
path: 'docs',
sidebarItemsGenerator,
}),
);
const content = (await plugin.loadContent?.())!;
return {content, siteDir};
}
test('sidebar is autogenerated according to custom sidebarItemsGenerator', async () => {
const customSidebarItemsGenerator: SidebarItemsGenerator = async () => {
return [
{type: 'doc', id: 'API/api-overview'},
{type: 'doc', id: 'API/api-end'},
];
};
const customSidebarItemsGeneratorMock: SidebarItemsGenerator = jest.fn(
customSidebarItemsGenerator,
);
const {content} = await loadSite(customSidebarItemsGeneratorMock);
const version = content.loadedVersions[0];
expect(version.sidebars).toEqual({
defaultSidebar: [
{type: 'doc', id: 'API/api-overview'},
{type: 'doc', id: 'API/api-end'},
],
});
});
test('sidebarItemsGenerator is called with appropriate data', async () => {
type GeneratorArg = Parameters<SidebarItemsGenerator>[0];
const customSidebarItemsGeneratorMock = jest.fn(
async (_arg: GeneratorArg) => [],
);
const {siteDir} = await loadSite(customSidebarItemsGeneratorMock);
const generatorArg: GeneratorArg =
customSidebarItemsGeneratorMock.mock.calls[0][0];
// Make test pass even if docs are in different order and paths are absolutes
function makeDeterministic(arg: GeneratorArg): GeneratorArg {
return {
...arg,
docs: orderBy(arg.docs, 'id'),
version: {
...arg.version,
contentPath: path.relative(siteDir, arg.version.contentPath),
},
};
}
expect(makeDeterministic(generatorArg)).toMatchSnapshot();
});
});

View file

@ -0,0 +1,115 @@
/**
* 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 {
extractNumberPrefix,
stripNumberPrefix,
stripPathNumberPrefixes,
} from '../numberPrefix';
const BadNumberPrefixPatterns = [
'a1-My Doc',
'My Doc-000',
'00abc01-My Doc',
'My 001- Doc',
'My -001 Doc',
];
describe('stripNumberPrefix', () => {
test('should strip number prefix if present', () => {
expect(stripNumberPrefix('1-My Doc')).toEqual('My Doc');
expect(stripNumberPrefix('01-My Doc')).toEqual('My Doc');
expect(stripNumberPrefix('001-My Doc')).toEqual('My Doc');
expect(stripNumberPrefix('001 - My Doc')).toEqual('My Doc');
expect(stripNumberPrefix('001 - My Doc')).toEqual('My Doc');
expect(stripNumberPrefix('999 - My Doc')).toEqual('My Doc');
//
expect(stripNumberPrefix('1---My Doc')).toEqual('My Doc');
expect(stripNumberPrefix('01---My Doc')).toEqual('My Doc');
expect(stripNumberPrefix('001---My Doc')).toEqual('My Doc');
expect(stripNumberPrefix('001 --- My Doc')).toEqual('My Doc');
expect(stripNumberPrefix('001 --- My Doc')).toEqual('My Doc');
expect(stripNumberPrefix('999 --- My Doc')).toEqual('My Doc');
//
expect(stripNumberPrefix('1___My Doc')).toEqual('My Doc');
expect(stripNumberPrefix('01___My Doc')).toEqual('My Doc');
expect(stripNumberPrefix('001___My Doc')).toEqual('My Doc');
expect(stripNumberPrefix('001 ___ My Doc')).toEqual('My Doc');
expect(stripNumberPrefix('001 ___ My Doc')).toEqual('My Doc');
expect(stripNumberPrefix('999 ___ My Doc')).toEqual('My Doc');
//
expect(stripNumberPrefix('1.My Doc')).toEqual('My Doc');
expect(stripNumberPrefix('01.My Doc')).toEqual('My Doc');
expect(stripNumberPrefix('001.My Doc')).toEqual('My Doc');
expect(stripNumberPrefix('001 . My Doc')).toEqual('My Doc');
expect(stripNumberPrefix('001 . My Doc')).toEqual('My Doc');
expect(stripNumberPrefix('999 . My Doc')).toEqual('My Doc');
});
test('should not strip number prefix if pattern does not match', () => {
BadNumberPrefixPatterns.forEach((badPattern) => {
expect(stripNumberPrefix(badPattern)).toEqual(badPattern);
});
});
});
describe('stripPathNumberPrefix', () => {
test('should strip number prefixes in paths', () => {
expect(
stripPathNumberPrefixes(
'0-MyRootFolder0/1 - MySubFolder1/2. MyDeepFolder2/3 _MyDoc3',
),
).toEqual('MyRootFolder0/MySubFolder1/MyDeepFolder2/MyDoc3');
});
});
describe('extractNumberPrefix', () => {
test('should extract number prefix if present', () => {
expect(extractNumberPrefix('0-My Doc')).toEqual({
filename: 'My Doc',
numberPrefix: 0,
});
expect(extractNumberPrefix('1-My Doc')).toEqual({
filename: 'My Doc',
numberPrefix: 1,
});
expect(extractNumberPrefix('01-My Doc')).toEqual({
filename: 'My Doc',
numberPrefix: 1,
});
expect(extractNumberPrefix('001-My Doc')).toEqual({
filename: 'My Doc',
numberPrefix: 1,
});
expect(extractNumberPrefix('001 - My Doc')).toEqual({
filename: 'My Doc',
numberPrefix: 1,
});
expect(extractNumberPrefix('001 - My Doc')).toEqual({
filename: 'My Doc',
numberPrefix: 1,
});
expect(extractNumberPrefix('999 - My Doc')).toEqual({
filename: 'My Doc',
numberPrefix: 999,
});
expect(extractNumberPrefix('0046036 - My Doc')).toEqual({
filename: 'My Doc',
numberPrefix: 46036,
});
});
test('should not extract number prefix if pattern does not match', () => {
BadNumberPrefixPatterns.forEach((badPattern) => {
expect(extractNumberPrefix(badPattern)).toEqual({
filename: badPattern,
numberPrefix: undefined,
});
});
});
});

View file

@ -7,6 +7,7 @@
import {OptionsSchema, DEFAULT_OPTIONS} from '../options';
import {normalizePluginOptions} from '@docusaurus/utils-validation';
import {DefaultSidebarItemsGenerator} from '../sidebarItemsGenerator';
// the type of remark/rehype plugins is function
const markdownPluginsFunctionStub = () => {};
@ -26,6 +27,7 @@ describe('normalizeDocsPluginOptions', () => {
homePageId: 'home', // Document id for docs home page.
include: ['**/*.{md,mdx}'], // Extensions to include.
sidebarPath: 'my-sidebar', // Path to sidebar configuration for showing a list of markdown pages.
sidebarItemsGenerator: DefaultSidebarItemsGenerator,
docLayoutComponent: '@theme/DocPage',
docItemComponent: '@theme/DocItem',
remarkPlugins: [markdownPluginsObjectStub],

View file

@ -0,0 +1,268 @@
/**
* 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 {
CategoryMetadatasFile,
DefaultSidebarItemsGenerator,
} from '../sidebarItemsGenerator';
import {DefaultCategoryCollapsedValue} from '../sidebars';
import {Sidebar, SidebarItemsGenerator} from '../types';
import fs from 'fs-extra';
describe('DefaultSidebarItemsGenerator', () => {
function testDefaultSidebarItemsGenerator(
options: Partial<Parameters<SidebarItemsGenerator>[0]>,
) {
return DefaultSidebarItemsGenerator({
item: {
type: 'autogenerated',
dirName: '.',
},
version: {
versionName: 'current',
contentPath: 'docs',
},
docs: [],
...options,
});
}
function mockCategoryMetadataFiles(
categoryMetadataFiles: Record<string, Partial<CategoryMetadatasFile>>,
) {
jest.spyOn(fs, 'pathExists').mockImplementation((metadataFilePath) => {
return typeof categoryMetadataFiles[metadataFilePath] !== 'undefined';
});
jest.spyOn(fs, 'readFile').mockImplementation(
// @ts-expect-error: annoying TS error due to overrides
async (metadataFilePath: string) => {
return JSON.stringify(categoryMetadataFiles[metadataFilePath]);
},
);
}
test('generates empty sidebar slice when no docs and emit a warning', async () => {
const consoleWarn = jest.spyOn(console, 'warn');
const sidebarSlice = await testDefaultSidebarItemsGenerator({
docs: [],
});
expect(sidebarSlice).toEqual([]);
expect(consoleWarn).toHaveBeenCalledWith(
expect.stringMatching(
/No docs found in dir .: can't auto-generate a sidebar/,
),
);
});
test('generates simple flat sidebar', async () => {
const sidebarSlice = await DefaultSidebarItemsGenerator({
item: {
type: 'autogenerated',
dirName: '.',
},
version: {
versionName: 'current',
contentPath: '',
},
docs: [
{
id: 'doc1',
source: 'doc1.md',
sourceDirName: '.',
sidebarPosition: 2,
frontMatter: {
sidebar_label: 'doc1 sidebar label',
},
},
{
id: 'doc2',
source: 'doc2.md',
sourceDirName: '.',
sidebarPosition: 3,
frontMatter: {},
},
{
id: 'doc3',
source: 'doc3.md',
sourceDirName: '.',
sidebarPosition: 1,
frontMatter: {},
},
{
id: 'doc4',
source: 'doc4.md',
sourceDirName: '.',
sidebarPosition: 1.5,
frontMatter: {},
},
{
id: 'doc5',
source: 'doc5.md',
sourceDirName: '.',
sidebarPosition: undefined,
frontMatter: {},
},
],
});
expect(sidebarSlice).toEqual([
{type: 'doc', id: 'doc3'},
{type: 'doc', id: 'doc4'},
{type: 'doc', id: 'doc1', label: 'doc1 sidebar label'},
{type: 'doc', id: 'doc2'},
{type: 'doc', id: 'doc5'},
] as Sidebar);
});
test('generates complex nested sidebar', async () => {
mockCategoryMetadataFiles({
'02-Guides/_category_.json': {collapsed: false},
'02-Guides/01-SubGuides/_category_.yml': {
label: 'SubGuides (metadata file label)',
},
});
const sidebarSlice = await DefaultSidebarItemsGenerator({
item: {
type: 'autogenerated',
dirName: '.',
},
version: {
versionName: 'current',
contentPath: '',
},
docs: [
{
id: 'intro',
source: 'intro.md',
sourceDirName: '.',
sidebarPosition: 1,
frontMatter: {},
},
{
id: 'tutorial2',
source: 'tutorial2.md',
sourceDirName: '01-Tutorials',
sidebarPosition: 2,
frontMatter: {},
},
{
id: 'tutorial1',
source: 'tutorial1.md',
sourceDirName: '01-Tutorials',
sidebarPosition: 1,
frontMatter: {},
},
{
id: 'guide2',
source: 'guide2.md',
sourceDirName: '02-Guides',
sidebarPosition: 2,
frontMatter: {},
},
{
id: 'guide1',
source: 'guide1.md',
sourceDirName: '02-Guides',
sidebarPosition: 1,
frontMatter: {},
},
{
id: 'nested-guide',
source: 'nested-guide.md',
sourceDirName: '02-Guides/01-SubGuides',
sidebarPosition: undefined,
frontMatter: {},
},
{
id: 'end',
source: 'end.md',
sourceDirName: '.',
sidebarPosition: 3,
frontMatter: {},
},
],
});
expect(sidebarSlice).toEqual([
{type: 'doc', id: 'intro'},
{
type: 'category',
label: 'Tutorials',
collapsed: DefaultCategoryCollapsedValue,
items: [
{type: 'doc', id: 'tutorial1'},
{type: 'doc', id: 'tutorial2'},
],
},
{
type: 'category',
label: 'Guides',
collapsed: false,
items: [
{type: 'doc', id: 'guide1'},
{
type: 'category',
label: 'SubGuides (metadata file label)',
collapsed: DefaultCategoryCollapsedValue,
items: [{type: 'doc', id: 'nested-guide'}],
},
{type: 'doc', id: 'guide2'},
],
},
{type: 'doc', id: 'end'},
] as Sidebar);
});
test('generates subfolder sidebar', async () => {
const sidebarSlice = await DefaultSidebarItemsGenerator({
item: {
type: 'autogenerated',
dirName: 'subfolder/subsubfolder',
},
version: {
versionName: 'current',
contentPath: '',
},
docs: [
{
id: 'doc1',
source: 'doc1.md',
sourceDirName: 'subfolder/subsubfolder',
sidebarPosition: undefined,
frontMatter: {},
},
{
id: 'doc2',
source: 'doc2.md',
sourceDirName: 'subfolder',
sidebarPosition: undefined,
frontMatter: {},
},
{
id: 'doc3',
source: 'doc3.md',
sourceDirName: '.',
sidebarPosition: undefined,
frontMatter: {},
},
{
id: 'doc4',
source: 'doc4.md',
sourceDirName: 'subfolder/subsubfolder',
sidebarPosition: undefined,
frontMatter: {},
},
],
});
expect(sidebarSlice).toEqual([
{type: 'doc', id: 'doc1'},
{type: 'doc', id: 'doc4'},
] as Sidebar);
});
});

View file

@ -14,8 +14,16 @@ import {
collectSidebarCategories,
collectSidebarLinks,
transformSidebarItems,
DefaultSidebars,
processSidebars,
} from '../sidebars';
import {Sidebar, Sidebars} from '../types';
import {
Sidebar,
SidebarItem,
SidebarItemsGenerator,
Sidebars,
UnprocessedSidebars,
} from '../types';
/* eslint-disable global-require, import/no-dynamic-require */
@ -124,7 +132,7 @@ describe('loadSidebars', () => {
);
*/
// See https://github.com/facebook/docusaurus/issues/3366
expect(loadSidebars('badpath')).toEqual({});
expect(loadSidebars('badpath')).toEqual(DefaultSidebars);
});
test('undefined path', () => {
@ -443,6 +451,131 @@ describe('transformSidebarItems', () => {
});
});
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,
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,
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,
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,
items: [{type: 'doc', id: 'doc4'}],
label: 'Category',
},
],
};
const processedSidebar = await testProcessSidebars(unprocessedSidebars);
expect(StaticSidebarItemsGenerator).toHaveBeenCalledTimes(3);
expect(StaticSidebarItemsGenerator).toHaveBeenCalledWith({
item: {type: 'autogenerated', dirName: 'dir1'},
docs: [],
version: {},
});
expect(StaticSidebarItemsGenerator).toHaveBeenCalledWith({
item: {type: 'autogenerated', dirName: 'dir2'},
docs: [],
version: {},
});
expect(StaticSidebarItemsGenerator).toHaveBeenCalledWith({
item: {type: 'autogenerated', dirName: 'dir3'},
docs: [],
version: {},
});
expect(processedSidebar).toEqual({
someSidebar: [
{type: 'doc', id: 'doc1'},
{
type: 'category',
collapsed: false,
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,
items: [{type: 'doc', id: 'doc4'}],
label: 'Category',
},
],
} as Sidebars);
});
});
describe('createSidebarsUtils', () => {
const sidebar1: Sidebar = [
{

View file

@ -15,6 +15,23 @@ describe('getSlug', () => {
);
});
test('can strip dir number prefixes', () => {
expect(
getSlug({
baseID: 'doc',
dirName: '/001-dir1/002-dir2',
stripDirNumberPrefixes: true,
}),
).toEqual('/dir1/dir2/doc');
expect(
getSlug({
baseID: 'doc',
dirName: '/001-dir1/002-dir2',
stripDirNumberPrefixes: false,
}),
).toEqual('/001-dir1/002-dir2/doc');
});
// See https://github.com/facebook/docusaurus/issues/3223
test('should handle special chars in doc path', () => {
expect(

View file

@ -12,7 +12,11 @@ import {
} from './versions';
import fs from 'fs-extra';
import path from 'path';
import {Sidebars, PathOptions, SidebarItem} from './types';
import {
PathOptions,
UnprocessedSidebarItem,
UnprocessedSidebars,
} from './types';
import {loadSidebars} from './sidebars';
import {DEFAULT_PLUGIN_ID} from '@docusaurus/core/lib/constants';
@ -90,10 +94,14 @@ export function cliDocsVersionCommand(
// Load current sidebar and create a new versioned sidebars file.
if (fs.existsSync(sidebarPath)) {
const loadedSidebars: Sidebars = loadSidebars(sidebarPath);
const loadedSidebars = loadSidebars(sidebarPath);
// 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: SidebarItem): SidebarItem => {
const normalizeItem = (
item: UnprocessedSidebarItem,
): UnprocessedSidebarItem => {
switch (item.type) {
case 'category':
return {...item, items: item.items.map(normalizeItem)};
@ -108,14 +116,13 @@ export function cliDocsVersionCommand(
}
};
const versionedSidebar: Sidebars = Object.entries(loadedSidebars).reduce(
(acc: Sidebars, [sidebarId, sidebarItems]) => {
const newVersionedSidebarId = `version-${version}/${sidebarId}`;
acc[newVersionedSidebarId] = sidebarItems.map(normalizeItem);
return acc;
},
{},
);
const versionedSidebar: UnprocessedSidebars = Object.entries(
loadedSidebars,
).reduce((acc: UnprocessedSidebars, [sidebarId, sidebarItems]) => {
const newVersionedSidebarId = `version-${version}/${sidebarId}`;
acc[newVersionedSidebarId] = sidebarItems.map(normalizeItem);
return acc;
}, {});
const versionedSidebarsDir = getVersionedSidebarsDirPath(siteDir, pluginId);
const newSidebarFile = path.join(

View file

@ -14,7 +14,9 @@ type DocFrontMatter = {
description?: string;
slug?: string;
sidebar_label?: string;
sidebar_position?: number;
custom_edit_url?: string;
strip_number_prefixes?: boolean;
};
const DocFrontMatterSchema = Joi.object<DocFrontMatter>({
@ -23,7 +25,9 @@ const DocFrontMatterSchema = Joi.object<DocFrontMatter>({
description: Joi.string(),
slug: Joi.string(),
sidebar_label: Joi.string(),
sidebar_position: Joi.number(),
custom_edit_url: Joi.string().allow(null),
strip_number_prefixes: Joi.boolean(),
}).unknown();
export function assertDocFrontMatter(

View file

@ -30,6 +30,7 @@ import getSlug from './slug';
import {CURRENT_VERSION_NAME} from './constants';
import globby from 'globby';
import {getDocsDirPaths} from './versions';
import {extractNumberPrefix, stripPathNumberPrefixes} from './numberPrefix';
import {assertDocFrontMatter} from './docFrontMatter';
type LastUpdateOptions = Pick<
@ -121,37 +122,66 @@ export function processDocMetadata({
});
assertDocFrontMatter(frontMatter);
// ex: api/myDoc -> api
// ex: myDoc -> .
const docsFileDirName = path.dirname(source);
const {
sidebar_label: sidebarLabel,
custom_edit_url: customEditURL,
// Strip number prefixes by default (01-MyFolder/01-MyDoc.md => MyFolder/MyDoc) by default,
// but ability to disable this behavior with frontmatterr
strip_number_prefixes: stripNumberPrefixes = true,
} = frontMatter;
const baseID: string =
frontMatter.id || path.basename(source, path.extname(source));
// ex: api/plugins/myDoc -> myDoc
// ex: myDoc -> myDoc
const sourceFileNameWithoutExtension = path.basename(
source,
path.extname(source),
);
// ex: api/plugins/myDoc -> api/plugins
// ex: myDoc -> .
const sourceDirName = path.dirname(source);
const {filename: unprefixedFileName, numberPrefix} = stripNumberPrefixes
? extractNumberPrefix(sourceFileNameWithoutExtension)
: {filename: sourceFileNameWithoutExtension, numberPrefix: undefined};
const baseID: string = frontMatter.id ?? unprefixedFileName;
if (baseID.includes('/')) {
throw new Error(`Document id [${baseID}] cannot include "/".`);
}
// For autogenerated sidebars, sidebar position can come from filename number prefix or frontmatter
const sidebarPosition: number | undefined =
frontMatter.sidebar_position ?? numberPrefix;
// TODO legacy retrocompatibility
// The same doc in 2 distinct version could keep the same id,
// we just need to namespace the data by version
const versionIdPart =
const versionIdPrefix =
versionMetadata.versionName === CURRENT_VERSION_NAME
? ''
: `version-${versionMetadata.versionName}/`;
? undefined
: `version-${versionMetadata.versionName}`;
// TODO legacy retrocompatibility
// I think it's bad to affect the frontmatter id with the dirname
const dirNameIdPart = docsFileDirName === '.' ? '' : `${docsFileDirName}/`;
// I think it's bad to affect the frontmatter id with the dirname?
function computeDirNameIdPrefix() {
if (sourceDirName === '.') {
return undefined;
}
// Eventually remove the number prefixes from intermediate directories
return stripNumberPrefixes
? stripPathNumberPrefixes(sourceDirName)
: sourceDirName;
}
// TODO legacy composite id, requires a breaking change to modify this
const id = `${versionIdPart}${dirNameIdPart}${baseID}`;
const unversionedId = [computeDirNameIdPrefix(), baseID]
.filter(Boolean)
.join('/');
const unversionedId = `${dirNameIdPart}${baseID}`;
// TODO is versioning the id very useful in practice?
// legacy versioned id, requires a breaking change to modify this
const id = [versionIdPrefix, unversionedId].filter(Boolean).join('/');
// TODO remove soon, deprecated homePageId
const isDocsHomePage = unversionedId === (homePageId ?? '_index');
@ -165,8 +195,9 @@ export function processDocMetadata({
? '/'
: getSlug({
baseID,
dirName: docsFileDirName,
dirName: sourceDirName,
frontmatterSlug: frontMatter.slug,
stripDirNumberPrefixes: stripNumberPrefixes,
});
// Default title is the id.
@ -212,6 +243,7 @@ export function processDocMetadata({
title,
description,
source: aliasedSitePath(filePath, siteDir),
sourceDirName,
slug: docSlug,
permalink,
editUrl: customEditURL !== undefined ? customEditURL : getDocEditUrl(),
@ -224,6 +256,7 @@ export function processDocMetadata({
)
: undefined,
sidebar_label: sidebarLabel,
sidebarPosition,
frontMatter,
};
}

View file

@ -20,8 +20,7 @@ import {
addTrailingPathSeparator,
} from '@docusaurus/utils';
import {LoadContext, Plugin, RouteConfig} from '@docusaurus/types';
import {loadSidebars, createSidebarsUtils} from './sidebars';
import {loadSidebars, createSidebarsUtils, processSidebars} from './sidebars';
import {readVersionDocs, processDocMetadata} from './docs';
import {getDocsDirPaths, readVersionsMetadata} from './versions';
@ -49,6 +48,7 @@ import {
translateLoadedContent,
getLoadedContentTranslationFiles,
} from './translations';
import {CategoryMetadataFilenamePattern} from './sidebarItemsGenerator';
export default function pluginContentDocs(
context: LoadContext,
@ -127,6 +127,7 @@ export default function pluginContentDocs(
),
),
),
`${version.contentPath}/**/${CategoryMetadataFilenamePattern}`,
];
}
@ -162,8 +163,9 @@ export default function pluginContentDocs(
async function loadVersion(
versionMetadata: VersionMetadata,
): Promise<LoadedVersion> {
const sidebars = loadSidebars(versionMetadata.sidebarFilePath);
const sidebarsUtils = createSidebarsUtils(sidebars);
const unprocessedSidebars = loadSidebars(
versionMetadata.sidebarFilePath,
);
const docsBase: DocMetadataBase[] = await loadVersionDocsBase(
versionMetadata,
@ -173,6 +175,15 @@ export default function pluginContentDocs(
(doc) => doc.id,
);
const sidebars = await processSidebars({
sidebarItemsGenerator: options.sidebarItemsGenerator,
unprocessedSidebars,
docs: docsBase,
version: versionMetadata,
});
const sidebarsUtils = createSidebarsUtils(sidebars);
const validDocIds = Object.keys(docsBaseById);
sidebarsUtils.checkSidebarsDocIds(validDocIds);

View file

@ -0,0 +1,35 @@
/**
* 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.
*/
const NumberPrefixRegex = /^(?<numberPrefix>\d+)(?<separator>\s*[-_.]+\s*)(?<suffix>.*)$/;
// 0-myDoc => myDoc
export function stripNumberPrefix(str: string) {
return NumberPrefixRegex.exec(str)?.groups?.suffix ?? str;
}
// 0-myFolder/0-mySubfolder/0-myDoc => myFolder/mySubfolder/myDoc
export function stripPathNumberPrefixes(path: string) {
return path.split('/').map(stripNumberPrefix).join('/');
}
// 0-myDoc => {filename: myDoc, numberPrefix: 0}
// 003 - myDoc => {filename: myDoc, numberPrefix: 3}
export function extractNumberPrefix(
filename: string,
): {filename: string; numberPrefix?: number} {
const match = NumberPrefixRegex.exec(filename);
const cleanFileName = match?.groups?.suffix ?? filename;
const numberPrefixString = match?.groups?.numberPrefix;
const numberPrefix = numberPrefixString
? parseInt(numberPrefixString, 10)
: undefined;
return {
filename: cleanFileName,
numberPrefix,
};
}

View file

@ -15,13 +15,15 @@ import {
import {OptionValidationContext, ValidationResult} from '@docusaurus/types';
import chalk from 'chalk';
import admonitions from 'remark-admonitions';
import {DefaultSidebarItemsGenerator} from './sidebarItemsGenerator';
export const DEFAULT_OPTIONS: Omit<PluginOptions, 'id'> = {
path: 'docs', // Path to data on filesystem, relative to site dir.
routeBasePath: 'docs', // URL Route.
homePageId: undefined, // TODO remove soon, deprecated
include: ['**/*.{md,mdx}'], // Extensions to include.
sidebarPath: 'sidebars.json', // Path to sidebar configuration for showing a list of markdown pages.
sidebarPath: 'sidebars.json', // Path to the sidebars configuration file
sidebarItemsGenerator: DefaultSidebarItemsGenerator,
docLayoutComponent: '@theme/DocPage',
docItemComponent: '@theme/DocItem',
remarkPlugins: [],
@ -61,6 +63,9 @@ export const OptionsSchema = Joi.object({
homePageId: Joi.string().optional(),
include: Joi.array().items(Joi.string()).default(DEFAULT_OPTIONS.include),
sidebarPath: Joi.string().allow('').default(DEFAULT_OPTIONS.sidebarPath),
sidebarItemsGenerator: Joi.function().default(
() => DEFAULT_OPTIONS.sidebarItemsGenerator,
),
docLayoutComponent: Joi.string().default(DEFAULT_OPTIONS.docLayoutComponent),
docItemComponent: Joi.string().default(DEFAULT_OPTIONS.docItemComponent),
remarkPlugins: RemarkPluginsSchema.default(DEFAULT_OPTIONS.remarkPlugins),

View file

@ -0,0 +1,305 @@
/**
* 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 {extractNumberPrefix} from './numberPrefix';
import chalk from 'chalk';
import path from 'path';
import fs from 'fs-extra';
import Yaml from 'js-yaml';
import {DefaultCategoryCollapsedValue} from './sidebars';
const BreadcrumbSeparator = '/';
export const CategoryMetadataFilenameBase = '_category_';
export const CategoryMetadataFilenamePattern = '_category_.{json,yml,yaml}';
export type CategoryMetadatasFile = {
label?: string;
position?: number;
collapsed?: boolean;
// TODO should we allow "items" here? how would this work? would an "autogenerated" type be allowed?
// This mkdocs plugin do something like that: https://github.com/lukasgeiter/mkdocs-awesome-pages-plugin/
// cf comment: https://github.com/facebook/docusaurus/issues/3464#issuecomment-784765199
};
type WithPosition = {position?: number};
type SidebarItemWithPosition = SidebarItem & WithPosition;
const CategoryMetadatasFileSchema = Joi.object<CategoryMetadatasFile>({
label: Joi.string().optional(),
position: Joi.number().optional(),
collapsed: Joi.boolean().optional(),
});
// TODO later if there is `CategoryFolder/index.md`, we may want to read the metadata as yaml on it
// see https://github.com/facebook/docusaurus/issues/3464#issuecomment-818670449
async function readCategoryMetadatasFile(
categoryDirPath: string,
): Promise<CategoryMetadatasFile | null> {
function assertCategoryMetadataFile(
content: unknown,
): asserts content is CategoryMetadatasFile {
Joi.attempt(content, CategoryMetadatasFileSchema);
}
async function tryReadFile(
fileNameWithExtension: string,
parse: (content: string) => unknown,
): Promise<CategoryMetadatasFile | null> {
// Simpler to use only posix paths for mocking file metadatas in tests
const filePath = posixPath(
path.join(categoryDirPath, fileNameWithExtension),
);
if (await fs.pathExists(filePath)) {
const contentString = await fs.readFile(filePath, {encoding: 'utf8'});
const unsafeContent: unknown = parse(contentString);
try {
assertCategoryMetadataFile(unsafeContent);
return 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 function defaultSidebarItemsGenerator({
item,
docs: allDocs,
version,
}): Promise<SidebarItem[]> {
// 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,
}),
...(typeof doc.sidebarPosition !== 'undefined' && {
position: doc.sidebarPosition,
}),
};
}
async function createCategorySidebarItem({
breadcrumb,
}: {
breadcrumb: string[];
}): Promise<SidebarItemCategory & WithPosition> {
const categoryDirPath = path.join(
version.contentPath,
breadcrumb.join(BreadcrumbSeparator),
);
const categoryMetadatas = await readCategoryMetadatasFile(categoryDirPath);
const {tail} = parseBreadcrumb(breadcrumb);
const {filename, numberPrefix} = extractNumberPrefix(tail);
const position = categoryMetadatas?.position ?? numberPrefix;
return {
type: 'category',
label: categoryMetadatas?.label ?? filename,
items: [],
collapsed: categoryMetadatas?.collapsed ?? DefaultCategoryCollapsedValue,
...(typeof position !== 'undefined' && {position}),
};
}
// Not sure how to simplify this algorithm :/
async function autogenerateSidebarItems(): Promise<
SidebarItemWithPosition[]
> {
const sidebarItems: SidebarItem[] = []; // mutable result
const categoriesByBreadcrumb: Record<string, SidebarItemCategory> = {}; // mutable cache of categories already created
async function getOrCreateCategoriesForBreadcrumb(
breadcrumb: string[],
): Promise<SidebarItemCategory | null> {
if (breadcrumb.length === 0) {
return null;
}
const {parents} = parseBreadcrumb(breadcrumb);
const parentCategory = await getOrCreateCategoriesForBreadcrumb(parents);
const existingCategory =
categoriesByBreadcrumb[breadcrumb.join(BreadcrumbSeparator)];
if (existingCategory) {
return existingCategory;
} else {
const newCategory = await createCategorySidebarItem({
breadcrumb,
});
if (parentCategory) {
parentCategory.items.push(newCategory);
} else {
sidebarItems.push(newCategory);
}
categoriesByBreadcrumb[
breadcrumb.join(BreadcrumbSeparator)
] = newCategory;
return newCategory;
}
}
// Get the category breadcrumb of a doc (relative to the dir of the autogenerated sidebar item)
function getRelativeBreadcrumb(doc: SidebarItemsGeneratorDoc): string[] {
const relativeDirPath = getDocDirRelativeToAutogenDir(doc);
if (relativeDirPath === '.') {
return [];
} else {
return relativeDirPath.split(BreadcrumbSeparator);
}
}
async function handleDocItem(doc: SidebarItemsGeneratorDoc): Promise<void> {
const breadcrumb = getRelativeBreadcrumb(doc);
const category = await getOrCreateCategoriesForBreadcrumb(breadcrumb);
const docSidebarItem = createDocSidebarItem(doc);
if (category) {
category.items.push(docSidebarItem);
} else {
sidebarItems.push(docSidebarItem);
}
}
// async process made sequential on purpose! order matters
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: _removed, ...item}) => item);
}

View file

@ -16,9 +16,18 @@ import {
Sidebar,
SidebarItemCategory,
SidebarItemType,
UnprocessedSidebarItem,
UnprocessedSidebars,
UnprocessedSidebar,
DocMetadataBase,
VersionMetadata,
SidebarItemsGenerator,
SidebarItemsGeneratorDoc,
SidebarItemsGeneratorVersion,
} from './types';
import {mapValues, flatten, flatMap, difference} from 'lodash';
import {mapValues, flatten, flatMap, difference, pick, memoize} from 'lodash';
import {getElementsAround} from '@docusaurus/utils';
import combinePromises from 'combine-promises';
type SidebarItemCategoryJSON = SidebarItemBase & {
type: 'category';
@ -27,12 +36,18 @@ type SidebarItemCategoryJSON = SidebarItemBase & {
collapsed?: boolean;
};
type SidebarItemAutogeneratedJSON = SidebarItemBase & {
type: 'autogenerated';
dirName: string;
};
type SidebarItemJSON =
| string
| SidebarCategoryShorthandJSON
| SidebarItemDoc
| SidebarItemLink
| SidebarItemCategoryJSON
| SidebarItemAutogeneratedJSON
| {
type: string;
[key: string]: unknown;
@ -56,7 +71,7 @@ function isCategoryShorthand(
}
// categories are collapsed by default, unless user set collapsed = false
const defaultCategoryCollapsedValue = true;
export const DefaultCategoryCollapsedValue = true;
/**
* Convert {category1: [item1,item2]} shorthand syntax to long-form syntax
@ -66,7 +81,7 @@ function normalizeCategoryShorthand(
): SidebarItemCategoryJSON[] {
return Object.entries(sidebar).map(([label, items]) => ({
type: 'category',
collapsed: defaultCategoryCollapsedValue,
collapsed: DefaultCategoryCollapsedValue,
label,
items,
}));
@ -78,7 +93,7 @@ function normalizeCategoryShorthand(
function assertItem<K extends string>(
item: Record<string, unknown>,
keys: K[],
): asserts item is Record<K, never> {
): asserts item is Record<K, unknown> {
const unknownKeys = Object.keys(item).filter(
// @ts-expect-error: key is always string
(key) => !keys.includes(key as string) && key !== 'type',
@ -115,6 +130,24 @@ function assertIsCategory(
}
}
function assertIsAutogenerated(
item: Record<string, unknown>,
): asserts item is SidebarItemAutogeneratedJSON {
assertItem(item, ['dirName', 'customProps']);
if (typeof item.dirName !== 'string') {
throw new Error(
`Error loading ${JSON.stringify(item)}. "dirName" must be a string.`,
);
}
if (item.dirName.startsWith('/') || item.dirName.endsWith('/')) {
throw new Error(
`Error loading ${JSON.stringify(
item,
)}. "dirName" must be a dir path relative to the docs folder root, and should not start or end with /`,
);
}
}
function assertIsDoc(
item: Record<string, unknown>,
): asserts item is SidebarItemDoc {
@ -152,7 +185,7 @@ function assertIsLink(
* 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): SidebarItem[] {
function normalizeItem(item: SidebarItemJSON): UnprocessedSidebarItem[] {
if (typeof item === 'string') {
return [
{
@ -169,11 +202,14 @@ function normalizeItem(item: SidebarItemJSON): SidebarItem[] {
assertIsCategory(item);
return [
{
collapsed: defaultCategoryCollapsedValue,
collapsed: DefaultCategoryCollapsedValue,
...item,
items: flatMap(item.items, normalizeItem),
},
];
case 'autogenerated':
assertIsAutogenerated(item);
return [item];
case 'link':
assertIsLink(item);
return [item];
@ -195,7 +231,7 @@ function normalizeItem(item: SidebarItemJSON): SidebarItem[] {
}
}
function normalizeSidebar(sidebar: SidebarJSON) {
function normalizeSidebar(sidebar: SidebarJSON): UnprocessedSidebar {
const normalizedSidebar: SidebarItemJSON[] = Array.isArray(sidebar)
? sidebar
: normalizeCategoryShorthand(sidebar);
@ -203,21 +239,29 @@ function normalizeSidebar(sidebar: SidebarJSON) {
return flatMap(normalizedSidebar, normalizeItem);
}
function normalizeSidebars(sidebars: SidebarsJSON): Sidebars {
function normalizeSidebars(sidebars: SidebarsJSON): UnprocessedSidebars {
return mapValues(sidebars, normalizeSidebar);
}
export const DefaultSidebars: UnprocessedSidebars = {
defaultSidebar: [
{
type: 'autogenerated',
dirName: '.',
},
],
};
// TODO refactor: make async
export function loadSidebars(sidebarFilePath: string): Sidebars {
export function loadSidebars(sidebarFilePath: string): UnprocessedSidebars {
if (!sidebarFilePath) {
throw new Error(`sidebarFilePath not provided: ${sidebarFilePath}`);
}
// sidebars file is optional, some users use docs without sidebars!
// See https://github.com/facebook/docusaurus/issues/3366
// No sidebars file: by default we use the file-system structure to generate the sidebar
// See https://github.com/facebook/docusaurus/pull/4582
if (!fs.existsSync(sidebarFilePath)) {
// throw new Error(`No sidebar file exist at path: ${sidebarFilePath}`);
return {};
return DefaultSidebars;
}
// We don't want sidebars to be cached because of hot reloading.
@ -225,6 +269,87 @@ export function loadSidebars(sidebarFilePath: string): Sidebars {
return normalizeSidebars(sidebarJson);
}
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']);
}
// Handle the generation of autogenerated sidebar items
export async function processSidebar({
sidebarItemsGenerator,
unprocessedSidebar,
docs,
version,
}: {
sidebarItemsGenerator: SidebarItemsGenerator;
unprocessedSidebar: UnprocessedSidebar;
docs: DocMetadataBase[];
version: VersionMetadata;
}): Promise<Sidebar> {
// Just a minor lazy transformation optimization
const getSidebarItemsGeneratorDocsAndVersion = memoize(() => ({
docs: docs.map(toSidebarItemsGeneratorDoc),
version: toSidebarItemsGeneratorVersion(version),
}));
async function processRecursive(
item: UnprocessedSidebarItem,
): Promise<SidebarItem[]> {
if (item.type === 'category') {
return [
{
...item,
items: (await Promise.all(item.items.map(processRecursive))).flat(),
},
];
}
if (item.type === 'autogenerated') {
return sidebarItemsGenerator({
item,
...getSidebarItemsGeneratorDocsAndVersion(),
});
}
return [item];
}
return (await Promise.all(unprocessedSidebar.map(processRecursive))).flat();
}
export async function processSidebars({
sidebarItemsGenerator,
unprocessedSidebars,
docs,
version,
}: {
sidebarItemsGenerator: SidebarItemsGenerator;
unprocessedSidebars: UnprocessedSidebars;
docs: DocMetadataBase[];
version: VersionMetadata;
}): Promise<Sidebars> {
return combinePromises(
mapValues(unprocessedSidebars, (unprocessedSidebar) =>
processSidebar({
sidebarItemsGenerator,
unprocessedSidebar,
docs,
version,
}),
),
);
}
function collectSidebarItemsOfType<
Type extends SidebarItemType,
Item extends SidebarItem & {type: SidebarItemType}

View file

@ -11,23 +11,31 @@ import {
isValidPathname,
resolvePathname,
} from '@docusaurus/utils';
import {stripPathNumberPrefixes} from './numberPrefix';
export default function getSlug({
baseID,
frontmatterSlug,
dirName,
stripDirNumberPrefixes = true,
}: {
baseID: string;
frontmatterSlug?: string;
dirName: string;
stripDirNumberPrefixes?: boolean;
}): string {
const baseSlug = frontmatterSlug || baseID;
let slug: string;
if (baseSlug.startsWith('/')) {
slug = baseSlug;
} else {
const dirNameStripped = stripDirNumberPrefixes
? stripPathNumberPrefixes(dirName)
: dirName;
const resolveDirname =
dirName === '.' ? '/' : addLeadingSlash(addTrailingSlash(dirName));
dirName === '.'
? '/'
: addLeadingSlash(addTrailingSlash(dirNameStripped));
slug = resolvePathname(baseSlug, resolveDirname);
}

View file

@ -83,6 +83,7 @@ export type PluginOptions = MetadataOptions &
disableVersioning: boolean;
excludeNextVersionDocs?: boolean;
includeCurrentVersion: boolean;
sidebarItemsGenerator: SidebarItemsGenerator;
};
export type SidebarItemBase = {
@ -108,6 +109,27 @@ export type SidebarItemCategory = SidebarItemBase & {
collapsed: boolean;
};
export type UnprocessedSidebarItemAutogenerated = {
type: 'autogenerated';
dirName: string;
};
export type UnprocessedSidebarItemCategory = SidebarItemBase & {
type: 'category';
label: string;
items: UnprocessedSidebarItem[];
collapsed: boolean;
};
export type UnprocessedSidebarItem =
| SidebarItemDoc
| SidebarItemLink
| UnprocessedSidebarItemCategory
| UnprocessedSidebarItemAutogenerated;
export type UnprocessedSidebar = UnprocessedSidebarItem[];
export type UnprocessedSidebars = Record<string, UnprocessedSidebar>;
export type SidebarItem =
| SidebarItemDoc
| SidebarItemLink
@ -115,9 +137,25 @@ export type SidebarItem =
export type Sidebar = SidebarItem[];
export type SidebarItemType = SidebarItem['type'];
export type Sidebars = Record<string, Sidebar>;
// Reduce API surface for options.sidebarItemsGenerator
// The user-provided generator fn should receive only a subset of metadatas
// A change to any of these metadatas can be considered as a breaking change
export type SidebarItemsGeneratorDoc = Pick<
DocMetadataBase,
'id' | 'frontMatter' | 'source' | 'sourceDirName' | 'sidebarPosition'
>;
export type SidebarItemsGeneratorVersion = Pick<
VersionMetadata,
'versionName' | 'contentPath'
>;
export type SidebarItemsGenerator = (generatorArgs: {
item: UnprocessedSidebarItemAutogenerated;
version: SidebarItemsGeneratorVersion;
docs: SidebarItemsGeneratorDoc[];
}) => Promise<SidebarItem[]>;
export type OrderMetadata = {
previous?: string;
next?: string;
@ -143,10 +181,12 @@ export type DocMetadataBase = LastUpdateData & {
title: string;
description: string;
source: string;
sourceDirName: string; // relative to the docs folder (can be ".")
slug: string;
permalink: string;
// eslint-disable-next-line camelcase
sidebar_label?: string;
sidebarPosition?: number;
editUrl?: string | null;
frontMatter: FrontMatter;
};

View file

@ -434,6 +434,7 @@ function filterVersions(
}
}
// TODO make this async (requires plugin init to be async)
export function readVersionsMetadata({
context,
options,