feat(content-docs): sidebar category linking to document or auto-generated index page (#5830)

Co-authored-by: Joshua Chen <sidachen2003@gmail.com>
Co-authored-by: Armano <armano2@users.noreply.github.com>
Co-authored-by: Alexey Pyltsyn <lex61rus@gmail.com>
This commit is contained in:
Sébastien Lorber 2021-12-03 14:44:59 +01:00 committed by GitHub
parent 95f911efef
commit cfae5d0933
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
105 changed files with 3904 additions and 816 deletions

View file

@ -1,7 +1,7 @@
{
"version-1.0.1/docs": {
"VersionedSideBarNameDoesNotMatter/docs": {
"Test": [
"version-1.0.1/foo/bar"
"foo/bar"
],
"Guides": [
"version-1.0.1/hello"

View file

@ -2,122 +2,64 @@
exports[`docsVersion first time versioning 1`] = `
Object {
"version-1.0.0/docs": Array [
Object {
"collapsed": true,
"collapsible": true,
"items": Array [
Object {
"collapsed": true,
"collapsible": true,
"items": Array [
Object {
"id": "version-1.0.0/foo/bar",
"type": "doc",
},
Object {
"id": "version-1.0.0/foo/baz",
"type": "doc",
},
],
"label": "foo",
"type": "category",
},
Object {
"collapsed": true,
"collapsible": true,
"items": Array [
Object {
"id": "version-1.0.0/rootAbsoluteSlug",
"type": "doc",
},
Object {
"id": "version-1.0.0/rootRelativeSlug",
"type": "doc",
},
Object {
"id": "version-1.0.0/rootResolvedSlug",
"type": "doc",
},
Object {
"id": "version-1.0.0/rootTryToEscapeSlug",
"type": "doc",
},
],
"label": "Slugs",
"type": "category",
},
Object {
"id": "version-1.0.0/headingAsTitle",
"type": "doc",
},
Object {
"href": "https://github.com",
"label": "Github",
"type": "link",
},
Object {
"id": "version-1.0.0/hello",
"type": "ref",
},
],
"label": "Test",
"type": "category",
},
Object {
"collapsed": true,
"collapsible": true,
"items": Array [
Object {
"id": "version-1.0.0/hello",
"type": "doc",
},
],
"label": "Guides",
"type": "category",
},
],
"docs": Object {
"Guides": Array [
"hello",
],
"Test": Array [
Object {
"items": Array [
"foo/bar",
"foo/baz",
],
"label": "foo",
"type": "category",
},
Object {
"items": Array [
"rootAbsoluteSlug",
"rootRelativeSlug",
"rootResolvedSlug",
"rootTryToEscapeSlug",
],
"label": "Slugs",
"type": "category",
},
Object {
"id": "headingAsTitle",
"type": "doc",
},
Object {
"href": "https://github.com",
"label": "Github",
"type": "link",
},
Object {
"id": "hello",
"type": "ref",
},
],
},
}
`;
exports[`docsVersion not the first time versioning 1`] = `
Object {
"version-2.0.0/docs": Array [
Object {
"collapsed": true,
"collapsible": true,
"items": Array [
Object {
"id": "version-2.0.0/foo/bar",
"type": "doc",
},
],
"label": "Test",
"type": "category",
},
Object {
"collapsed": true,
"collapsible": true,
"items": Array [
Object {
"id": "version-2.0.0/hello",
"type": "doc",
},
],
"label": "Guides",
"type": "category",
},
],
"docs": Object {
"Guides": Array [
"hello",
],
"Test": Array [
"foo/bar",
],
},
}
`;
exports[`docsVersion second docs instance versioning 1`] = `
Object {
"version-2.0.0/community": Array [
Object {
"id": "version-2.0.0/team",
"type": "doc",
},
"community": Array [
"team",
],
}
`;

View file

@ -43,6 +43,7 @@ Object {
},
],
"label": "foo",
"link": undefined,
"type": "category",
},
Object {
@ -67,6 +68,7 @@ Object {
},
],
"label": "Slugs",
"link": undefined,
"type": "category",
},
Object {
@ -84,6 +86,7 @@ Object {
},
],
"label": "Test",
"link": undefined,
"type": "category",
},
Object {
@ -96,6 +99,7 @@ Object {
},
],
"label": "Guides",
"link": undefined,
"type": "category",
},
],
@ -614,12 +618,14 @@ Object {
{
\\"type\\": \\"link\\",
\\"label\\": \\"Bar\\",
\\"href\\": \\"/docs/foo/bar\\"
\\"href\\": \\"/docs/foo/bar\\",
\\"docId\\": \\"foo/bar\\"
},
{
\\"type\\": \\"link\\",
\\"label\\": \\"baz\\",
\\"href\\": \\"/docs/foo/bazSlug.html\\"
\\"href\\": \\"/docs/foo/bazSlug.html\\",
\\"docId\\": \\"foo/baz\\"
}
],
\\"collapsible\\": true,
@ -632,22 +638,26 @@ Object {
{
\\"type\\": \\"link\\",
\\"label\\": \\"rootAbsoluteSlug\\",
\\"href\\": \\"/docs/rootAbsoluteSlug\\"
\\"href\\": \\"/docs/rootAbsoluteSlug\\",
\\"docId\\": \\"rootAbsoluteSlug\\"
},
{
\\"type\\": \\"link\\",
\\"label\\": \\"rootRelativeSlug\\",
\\"href\\": \\"/docs/rootRelativeSlug\\"
\\"href\\": \\"/docs/rootRelativeSlug\\",
\\"docId\\": \\"rootRelativeSlug\\"
},
{
\\"type\\": \\"link\\",
\\"label\\": \\"rootResolvedSlug\\",
\\"href\\": \\"/docs/hey/rootResolvedSlug\\"
\\"href\\": \\"/docs/hey/rootResolvedSlug\\",
\\"docId\\": \\"rootResolvedSlug\\"
},
{
\\"type\\": \\"link\\",
\\"label\\": \\"rootTryToEscapeSlug\\",
\\"href\\": \\"/docs/rootTryToEscapeSlug\\"
\\"href\\": \\"/docs/rootTryToEscapeSlug\\",
\\"docId\\": \\"rootTryToEscapeSlug\\"
}
],
\\"collapsible\\": true,
@ -656,7 +666,8 @@ Object {
{
\\"type\\": \\"link\\",
\\"label\\": \\"My heading as title\\",
\\"href\\": \\"/docs/headingAsTitle\\"
\\"href\\": \\"/docs/headingAsTitle\\",
\\"docId\\": \\"headingAsTitle\\"
},
{
\\"type\\": \\"link\\",
@ -666,7 +677,8 @@ Object {
{
\\"type\\": \\"link\\",
\\"label\\": \\"Hello sidebar_label\\",
\\"href\\": \\"/docs/\\"
\\"href\\": \\"/docs/\\",
\\"docId\\": \\"hello\\"
}
]
},
@ -679,11 +691,92 @@ Object {
{
\\"type\\": \\"link\\",
\\"label\\": \\"Hello sidebar_label\\",
\\"href\\": \\"/docs/\\"
\\"href\\": \\"/docs/\\",
\\"docId\\": \\"hello\\"
}
]
}
]
},
\\"docs\\": {
\\"foo/bar\\": {
\\"id\\": \\"foo/bar\\",
\\"title\\": \\"Bar\\",
\\"description\\": \\"This is custom description\\",
\\"sidebar\\": \\"docs\\"
},
\\"foo/baz\\": {
\\"id\\": \\"foo/baz\\",
\\"title\\": \\"baz\\",
\\"description\\": \\"Images\\",
\\"sidebar\\": \\"docs\\"
},
\\"headingAsTitle\\": {
\\"id\\": \\"headingAsTitle\\",
\\"title\\": \\"My heading as title\\",
\\"description\\": \\"\\",
\\"sidebar\\": \\"docs\\"
},
\\"hello\\": {
\\"id\\": \\"hello\\",
\\"title\\": \\"Hello, World !\\",
\\"description\\": \\"Hi, Endilie here :)\\",
\\"sidebar\\": \\"docs\\"
},
\\"ipsum\\": {
\\"id\\": \\"ipsum\\",
\\"title\\": \\"ipsum\\",
\\"description\\": \\"Lorem ipsum.\\"
},
\\"lorem\\": {
\\"id\\": \\"lorem\\",
\\"title\\": \\"lorem\\",
\\"description\\": \\"Lorem ipsum.\\"
},
\\"rootAbsoluteSlug\\": {
\\"id\\": \\"rootAbsoluteSlug\\",
\\"title\\": \\"rootAbsoluteSlug\\",
\\"description\\": \\"Lorem\\",
\\"sidebar\\": \\"docs\\"
},
\\"rootRelativeSlug\\": {
\\"id\\": \\"rootRelativeSlug\\",
\\"title\\": \\"rootRelativeSlug\\",
\\"description\\": \\"Lorem\\",
\\"sidebar\\": \\"docs\\"
},
\\"rootResolvedSlug\\": {
\\"id\\": \\"rootResolvedSlug\\",
\\"title\\": \\"rootResolvedSlug\\",
\\"description\\": \\"Lorem\\",
\\"sidebar\\": \\"docs\\"
},
\\"rootTryToEscapeSlug\\": {
\\"id\\": \\"rootTryToEscapeSlug\\",
\\"title\\": \\"rootTryToEscapeSlug\\",
\\"description\\": \\"Lorem\\",
\\"sidebar\\": \\"docs\\"
},
\\"slugs/absoluteSlug\\": {
\\"id\\": \\"slugs/absoluteSlug\\",
\\"title\\": \\"absoluteSlug\\",
\\"description\\": \\"Lorem\\"
},
\\"slugs/relativeSlug\\": {
\\"id\\": \\"slugs/relativeSlug\\",
\\"title\\": \\"relativeSlug\\",
\\"description\\": \\"Lorem\\"
},
\\"slugs/resolvedSlug\\": {
\\"id\\": \\"slugs/resolvedSlug\\",
\\"title\\": \\"resolvedSlug\\",
\\"description\\": \\"Lorem\\"
},
\\"slugs/tryToEscapeSlug\\": {
\\"id\\": \\"slugs/tryToEscapeSlug\\",
\\"title\\": \\"tryToEscapeSlug\\",
\\"description\\": \\"Lorem\\"
}
}
}",
}
@ -958,6 +1051,7 @@ Object {
"sidebarPosition": 0,
"source": "@site/docs/3-API/01_Core APIs/0 --- Client API.md",
"sourceDirName": "3-API/01_Core APIs",
"unversionedId": "API/Core APIs/Client API",
},
Object {
"frontMatter": Object {},
@ -965,6 +1059,7 @@ Object {
"sidebarPosition": 1,
"source": "@site/docs/3-API/01_Core APIs/1 --- Server API.md",
"sourceDirName": "3-API/01_Core APIs",
"unversionedId": "API/Core APIs/Server API",
},
Object {
"frontMatter": Object {},
@ -972,6 +1067,7 @@ Object {
"sidebarPosition": 0,
"source": "@site/docs/3-API/02_Extension APIs/0. Plugin API.md",
"sourceDirName": "3-API/02_Extension APIs",
"unversionedId": "API/Extension APIs/Plugin API",
},
Object {
"frontMatter": Object {},
@ -979,6 +1075,7 @@ Object {
"sidebarPosition": 1,
"source": "@site/docs/3-API/02_Extension APIs/1. Theme API.md",
"sourceDirName": "3-API/02_Extension APIs",
"unversionedId": "API/Extension APIs/Theme API",
},
Object {
"frontMatter": Object {},
@ -986,6 +1083,7 @@ Object {
"sidebarPosition": 3,
"source": "@site/docs/3-API/03_api-end.md",
"sourceDirName": "3-API",
"unversionedId": "API/api-end",
},
Object {
"frontMatter": Object {},
@ -993,6 +1091,7 @@ Object {
"sidebarPosition": 0,
"source": "@site/docs/3-API/00_api-overview.md",
"sourceDirName": "3-API",
"unversionedId": "API/api-overview",
},
Object {
"frontMatter": Object {
@ -1003,6 +1102,7 @@ Object {
"sidebarPosition": 1,
"source": "@site/docs/Guides/z-guide1.md",
"sourceDirName": "Guides",
"unversionedId": "Guides/guide1",
},
Object {
"frontMatter": Object {
@ -1012,6 +1112,7 @@ Object {
"sidebarPosition": 2,
"source": "@site/docs/Guides/02-guide2.md",
"sourceDirName": "Guides",
"unversionedId": "Guides/guide2",
},
Object {
"frontMatter": Object {
@ -1022,6 +1123,7 @@ Object {
"sidebarPosition": 2.5,
"source": "@site/docs/Guides/0-guide2.5.md",
"sourceDirName": "Guides",
"unversionedId": "Guides/guide2.5",
},
Object {
"frontMatter": Object {
@ -1032,6 +1134,7 @@ Object {
"sidebarPosition": 3,
"source": "@site/docs/Guides/guide3.md",
"sourceDirName": "Guides",
"unversionedId": "Guides/guide3",
},
Object {
"frontMatter": Object {
@ -1041,6 +1144,7 @@ Object {
"sidebarPosition": undefined,
"source": "@site/docs/Guides/a-guide4.md",
"sourceDirName": "Guides",
"unversionedId": "Guides/guide4",
},
Object {
"frontMatter": Object {
@ -1050,6 +1154,7 @@ Object {
"sidebarPosition": undefined,
"source": "@site/docs/Guides/b-guide5.md",
"sourceDirName": "Guides",
"unversionedId": "Guides/guide5",
},
Object {
"frontMatter": Object {},
@ -1057,6 +1162,7 @@ Object {
"sidebarPosition": 0,
"source": "@site/docs/0-getting-started.md",
"sourceDirName": ".",
"unversionedId": "getting-started",
},
Object {
"frontMatter": Object {},
@ -1064,6 +1170,7 @@ Object {
"sidebarPosition": 1,
"source": "@site/docs/1-installation.md",
"sourceDirName": ".",
"unversionedId": "installation",
},
],
"item": Object {
@ -1151,9 +1258,18 @@ Object {
{
\\"type\\": \\"link\\",
\\"label\\": \\"team\\",
\\"href\\": \\"/community/team\\"
\\"href\\": \\"/community/team\\",
\\"docId\\": \\"team\\"
}
]
},
\\"docs\\": {
\\"team\\": {
\\"id\\": \\"team\\",
\\"title\\": \\"team\\",
\\"description\\": \\"Team 1.0.0\\",
\\"sidebar\\": \\"version-1.0.0/community\\"
}
}
}",
"version-current-metadata-prop-751.json": "{
@ -1169,9 +1285,18 @@ Object {
{
\\"type\\": \\"link\\",
\\"label\\": \\"Team title translated\\",
\\"href\\": \\"/community/next/team\\"
\\"href\\": \\"/community/next/team\\",
\\"docId\\": \\"team\\"
}
]
},
\\"docs\\": {
\\"team\\": {
\\"id\\": \\"team\\",
\\"title\\": \\"Team title translated\\",
\\"description\\": \\"Team current version (translated)\\",
\\"sidebar\\": \\"community\\"
}
}
}",
}
@ -1279,6 +1404,7 @@ Object {
},
],
"label": "Test",
"link": undefined,
"type": "category",
},
Object {
@ -1291,6 +1417,7 @@ Object {
},
],
"label": "Guides",
"link": undefined,
"type": "category",
},
],
@ -1299,17 +1426,18 @@ Object {
exports[`versioned website content: 101 version sidebars 1`] = `
Object {
"version-1.0.1/docs": Array [
"VersionedSideBarNameDoesNotMatter/docs": Array [
Object {
"collapsed": true,
"collapsible": true,
"items": Array [
Object {
"id": "version-1.0.1/foo/bar",
"id": "foo/bar",
"type": "doc",
},
],
"label": "Test",
"link": undefined,
"type": "category",
},
Object {
@ -1322,6 +1450,7 @@ Object {
},
],
"label": "Guides",
"link": undefined,
"type": "category",
},
],
@ -1341,6 +1470,7 @@ Object {
},
],
"label": "Test",
"link": undefined,
"type": "category",
},
Object {
@ -1353,6 +1483,7 @@ Object {
},
],
"label": "Guides",
"link": undefined,
"type": "category",
},
],
@ -1562,7 +1693,7 @@ Object {
\\"tags\\": [],
\\"version\\": \\"1.0.1\\",
\\"frontMatter\\": {},
\\"sidebar\\": \\"version-1.0.1/docs\\",
\\"sidebar\\": \\"VersionedSideBarNameDoesNotMatter/docs\\",
\\"next\\": {
\\"title\\": \\"hello\\",
\\"permalink\\": \\"/docs/\\"
@ -1581,7 +1712,7 @@ Object {
\\"tags\\": [],
\\"version\\": \\"1.0.1\\",
\\"frontMatter\\": {},
\\"sidebar\\": \\"version-1.0.1/docs\\",
\\"sidebar\\": \\"VersionedSideBarNameDoesNotMatter/docs\\",
\\"previous\\": {
\\"title\\": \\"bar\\",
\\"permalink\\": \\"/docs/foo/bar\\"
@ -1791,12 +1922,14 @@ Object {
{
\\"type\\": \\"link\\",
\\"label\\": \\"bar\\",
\\"href\\": \\"/docs/1.0.0/foo/barSlug\\"
\\"href\\": \\"/docs/1.0.0/foo/barSlug\\",
\\"docId\\": \\"foo/bar\\"
},
{
\\"type\\": \\"link\\",
\\"label\\": \\"baz\\",
\\"href\\": \\"/docs/1.0.0/foo/baz\\"
\\"href\\": \\"/docs/1.0.0/foo/baz\\",
\\"docId\\": \\"foo/baz\\"
}
]
},
@ -1809,11 +1942,32 @@ Object {
{
\\"type\\": \\"link\\",
\\"label\\": \\"hello\\",
\\"href\\": \\"/docs/1.0.0/\\"
\\"href\\": \\"/docs/1.0.0/\\",
\\"docId\\": \\"hello\\"
}
]
}
]
},
\\"docs\\": {
\\"foo/bar\\": {
\\"id\\": \\"foo/bar\\",
\\"title\\": \\"bar\\",
\\"description\\": \\"Bar 1.0.0 !\\",
\\"sidebar\\": \\"version-1.0.0/docs\\"
},
\\"foo/baz\\": {
\\"id\\": \\"foo/baz\\",
\\"title\\": \\"baz\\",
\\"description\\": \\"Baz 1.0.0 ! This will be deleted in next subsequent versions.\\",
\\"sidebar\\": \\"version-1.0.0/docs\\"
},
\\"hello\\": {
\\"id\\": \\"hello\\",
\\"title\\": \\"hello\\",
\\"description\\": \\"Hello 1.0.0 ! (translated en)\\",
\\"sidebar\\": \\"version-1.0.0/docs\\"
}
}
}",
"version-1-0-1-metadata-prop-e87.json": "{
@ -1825,7 +1979,7 @@ Object {
\\"className\\": \\"docs-version-1.0.1\\",
\\"isLast\\": true,
\\"docsSidebars\\": {
\\"version-1.0.1/docs\\": [
\\"VersionedSideBarNameDoesNotMatter/docs\\": [
{
\\"type\\": \\"category\\",
\\"collapsed\\": true,
@ -1835,7 +1989,8 @@ Object {
{
\\"type\\": \\"link\\",
\\"label\\": \\"bar\\",
\\"href\\": \\"/docs/foo/bar\\"
\\"href\\": \\"/docs/foo/bar\\",
\\"docId\\": \\"foo/bar\\"
}
]
},
@ -1848,11 +2003,26 @@ Object {
{
\\"type\\": \\"link\\",
\\"label\\": \\"hello\\",
\\"href\\": \\"/docs/\\"
\\"href\\": \\"/docs/\\",
\\"docId\\": \\"hello\\"
}
]
}
]
},
\\"docs\\": {
\\"foo/bar\\": {
\\"id\\": \\"foo/bar\\",
\\"title\\": \\"bar\\",
\\"description\\": \\"Bar 1.0.1 !\\",
\\"sidebar\\": \\"VersionedSideBarNameDoesNotMatter/docs\\"
},
\\"hello\\": {
\\"id\\": \\"hello\\",
\\"title\\": \\"hello\\",
\\"description\\": \\"Hello 1.0.1 !\\",
\\"sidebar\\": \\"VersionedSideBarNameDoesNotMatter/docs\\"
}
}
}",
"version-current-metadata-prop-751.json": "{
@ -1874,7 +2044,8 @@ Object {
{
\\"type\\": \\"link\\",
\\"label\\": \\"bar\\",
\\"href\\": \\"/docs/next/foo/barSlug\\"
\\"href\\": \\"/docs/next/foo/barSlug\\",
\\"docId\\": \\"foo/bar\\"
}
]
},
@ -1887,11 +2058,46 @@ Object {
{
\\"type\\": \\"link\\",
\\"label\\": \\"hello\\",
\\"href\\": \\"/docs/next/\\"
\\"href\\": \\"/docs/next/\\",
\\"docId\\": \\"hello\\"
}
]
}
]
},
\\"docs\\": {
\\"foo/bar\\": {
\\"id\\": \\"foo/bar\\",
\\"title\\": \\"bar\\",
\\"description\\": \\"This is next version of bar.\\",
\\"sidebar\\": \\"docs\\"
},
\\"hello\\": {
\\"id\\": \\"hello\\",
\\"title\\": \\"hello\\",
\\"description\\": \\"Hello next !\\",
\\"sidebar\\": \\"docs\\"
},
\\"slugs/absoluteSlug\\": {
\\"id\\": \\"slugs/absoluteSlug\\",
\\"title\\": \\"absoluteSlug\\",
\\"description\\": \\"Lorem\\"
},
\\"slugs/relativeSlug\\": {
\\"id\\": \\"slugs/relativeSlug\\",
\\"title\\": \\"relativeSlug\\",
\\"description\\": \\"Lorem\\"
},
\\"slugs/resolvedSlug\\": {
\\"id\\": \\"slugs/resolvedSlug\\",
\\"title\\": \\"resolvedSlug\\",
\\"description\\": \\"Lorem\\"
},
\\"slugs/tryToEscapeSlug\\": {
\\"id\\": \\"slugs/tryToEscapeSlug\\",
\\"title\\": \\"tryToEscapeSlug\\",
\\"description\\": \\"Lorem\\"
}
}
}",
"version-with-slugs-metadata-prop-2bf.json": "{
@ -1913,11 +2119,55 @@ Object {
{
\\"type\\": \\"link\\",
\\"label\\": \\"rootAbsoluteSlug\\",
\\"href\\": \\"/docs/withSlugs/rootAbsoluteSlug\\"
\\"href\\": \\"/docs/withSlugs/rootAbsoluteSlug\\",
\\"docId\\": \\"rootAbsoluteSlug\\"
}
]
}
]
},
\\"docs\\": {
\\"rootAbsoluteSlug\\": {
\\"id\\": \\"rootAbsoluteSlug\\",
\\"title\\": \\"rootAbsoluteSlug\\",
\\"description\\": \\"Lorem\\",
\\"sidebar\\": \\"version-1.0.1/docs\\"
},
\\"rootRelativeSlug\\": {
\\"id\\": \\"rootRelativeSlug\\",
\\"title\\": \\"rootRelativeSlug\\",
\\"description\\": \\"Lorem\\"
},
\\"rootResolvedSlug\\": {
\\"id\\": \\"rootResolvedSlug\\",
\\"title\\": \\"rootResolvedSlug\\",
\\"description\\": \\"Lorem\\"
},
\\"rootTryToEscapeSlug\\": {
\\"id\\": \\"rootTryToEscapeSlug\\",
\\"title\\": \\"rootTryToEscapeSlug\\",
\\"description\\": \\"Lorem\\"
},
\\"slugs/absoluteSlug\\": {
\\"id\\": \\"slugs/absoluteSlug\\",
\\"title\\": \\"absoluteSlug\\",
\\"description\\": \\"Lorem\\"
},
\\"slugs/relativeSlug\\": {
\\"id\\": \\"slugs/relativeSlug\\",
\\"title\\": \\"relativeSlug\\",
\\"description\\": \\"Lorem\\"
},
\\"slugs/resolvedSlug\\": {
\\"id\\": \\"slugs/resolvedSlug\\",
\\"title\\": \\"resolvedSlug\\",
\\"description\\": \\"Lorem\\"
},
\\"slugs/tryToEscapeSlug\\": {
\\"id\\": \\"slugs/tryToEscapeSlug\\",
\\"title\\": \\"tryToEscapeSlug\\",
\\"description\\": \\"Lorem\\"
}
}
}",
}
@ -1973,12 +2223,12 @@ Object {
Object {
"id": "foo/bar",
"path": "/docs/foo/bar",
"sidebar": "version-1.0.1/docs",
"sidebar": "VersionedSideBarNameDoesNotMatter/docs",
},
Object {
"id": "hello",
"path": "/docs/",
"sidebar": "version-1.0.1/docs",
"sidebar": "VersionedSideBarNameDoesNotMatter/docs",
},
],
"isLast": true,
@ -2291,7 +2541,7 @@ Array [
"content": "@site/versioned_docs/version-1.0.1/hello.md",
},
"path": "/docs/",
"sidebar": "version-1.0.1/docs",
"sidebar": "VersionedSideBarNameDoesNotMatter/docs",
},
Object {
"component": "@theme/DocItem",
@ -2300,7 +2550,7 @@ Array [
"content": "@site/versioned_docs/version-1.0.1/foo/bar.md",
},
"path": "/docs/foo/bar",
"sidebar": "version-1.0.1/docs",
"sidebar": "VersionedSideBarNameDoesNotMatter/docs",
},
],
},
@ -2320,6 +2570,7 @@ Object {
},
],
"label": "Test",
"link": undefined,
"type": "category",
},
],

View file

@ -8,6 +8,14 @@ Array [
"description": "The label for category Getting started in sidebar docs",
"message": "Getting started",
},
"sidebar.docs.category.Getting started.link.generated-index.description": Object {
"description": "The generated-index page description for category Getting started in sidebar docs",
"message": "Getting started index description",
},
"sidebar.docs.category.Getting started.link.generated-index.title": Object {
"description": "The generated-index page title for category Getting started in sidebar docs",
"message": "Getting started index title",
},
"sidebar.docs.link.Link label": Object {
"description": "The label for link Link label in sidebar docs, linking to https://facebook.com",
"message": "Link label",
@ -25,6 +33,14 @@ Array [
"description": "The label for category Getting started in sidebar docs",
"message": "Getting started",
},
"sidebar.docs.category.Getting started.link.generated-index.description": Object {
"description": "The generated-index page description for category Getting started in sidebar docs",
"message": "Getting started index description",
},
"sidebar.docs.category.Getting started.link.generated-index.title": Object {
"description": "The generated-index page title for category Getting started in sidebar docs",
"message": "Getting started index title",
},
"sidebar.docs.link.Link label": Object {
"description": "The label for link Link label in sidebar docs, linking to https://facebook.com",
"message": "Link label",
@ -42,6 +58,14 @@ Array [
"description": "The label for category Getting started in sidebar docs",
"message": "Getting started",
},
"sidebar.docs.category.Getting started.link.generated-index.description": Object {
"description": "The generated-index page description for category Getting started in sidebar docs",
"message": "Getting started index description",
},
"sidebar.docs.category.Getting started.link.generated-index.title": Object {
"description": "The generated-index page title for category Getting started in sidebar docs",
"message": "Getting started index title",
},
"sidebar.docs.link.Link label": Object {
"description": "The label for link Link label in sidebar docs, linking to https://facebook.com",
"message": "Link label",
@ -177,6 +201,13 @@ Object {
},
],
"label": "Getting started (translated)",
"link": Object {
"description": "Getting started index description (translated)",
"permalink": "/docs/category/getting-started-index-slug",
"slug": "/category/getting-started-index-slug",
"title": "Getting started index title (translated)",
"type": "generated-index",
},
"type": "category",
},
Object {
@ -317,6 +348,13 @@ Object {
},
],
"label": "Getting started (translated)",
"link": Object {
"description": "Getting started index description (translated)",
"permalink": "/docs/category/getting-started-index-slug",
"slug": "/category/getting-started-index-slug",
"title": "Getting started index title (translated)",
"type": "generated-index",
},
"type": "category",
},
Object {
@ -457,6 +495,13 @@ Object {
},
],
"label": "Getting started (translated)",
"link": Object {
"description": "Getting started index description (translated)",
"permalink": "/docs/category/getting-started-index-slug",
"slug": "/category/getting-started-index-slug",
"title": "Getting started index title (translated)",
"type": "generated-index",
},
"type": "category",
},
Object {

View file

@ -11,7 +11,8 @@ import {
processDocMetadata,
readVersionDocs,
readDocFile,
handleNavigation,
addDocNavigation,
isConventionalDocIndex,
} from '../docs';
import {loadSidebars} from '../sidebars';
import {readVersionsMetadata} from '../versions';
@ -28,7 +29,8 @@ import type {LoadContext} from '@docusaurus/types';
import {DEFAULT_PLUGIN_ID} from '@docusaurus/core/lib/constants';
import {DEFAULT_OPTIONS} from '../options';
import {Optional} from 'utility-types';
import {posixPath} from '@docusaurus/utils';
import {createSlugger, posixPath} from '@docusaurus/utils';
import {createSidebarsUtils} from '../sidebars/utils';
const fixtureDir = path.join(__dirname, '__fixtures__');
@ -119,7 +121,7 @@ function createTestUtils({
async function generateNavigation(
docFiles: DocFile[],
): Promise<[DocNavLink, DocNavLink][]> {
): Promise<[DocNavLink | undefined, DocNavLink | undefined][]> {
const rawDocs = await Promise.all(
docFiles.map((docFile) =>
processDocMetadata({
@ -136,16 +138,19 @@ function createTestUtils({
numberPrefixParser: options.numberPrefixParser,
docs: rawDocs,
version: versionMetadata,
options: {
sidebarOptions: {
sidebarCollapsed: false,
sidebarCollapsible: true,
},
categoryLabelSlugger: createSlugger(),
});
return handleNavigation(
const sidebarsUtils = createSidebarsUtils(sidebars);
return addDocNavigation(
rawDocs,
sidebars,
sidebarsUtils,
versionMetadata.sidebarFilePath as string,
).docs.map((doc) => [doc.previous, doc.next]);
).map((doc) => [doc.previous, doc.next]);
}
return {processDocFile, testMeta, testSlug, generateNavigation};
@ -1022,3 +1027,113 @@ describe('versioned site', () => {
});
});
});
describe('isConventionalDocIndex', () => {
test('supports readme', () => {
expect(
isConventionalDocIndex({
sourceDirName: 'doesNotMatter',
source: 'readme.md',
}),
).toEqual(true);
expect(
isConventionalDocIndex({
sourceDirName: 'doesNotMatter',
source: 'readme.mdx',
}),
).toEqual(true);
expect(
isConventionalDocIndex({
sourceDirName: 'doesNotMatter',
source: 'README.md',
}),
).toEqual(true);
expect(
isConventionalDocIndex({
sourceDirName: 'doesNotMatter',
source: 'parent/ReAdMe',
}),
).toEqual(true);
});
test('supports index', () => {
expect(
isConventionalDocIndex({
sourceDirName: 'doesNotMatter',
source: 'index.md',
}),
).toEqual(true);
expect(
isConventionalDocIndex({
sourceDirName: 'doesNotMatter',
source: 'index.mdx',
}),
).toEqual(true);
expect(
isConventionalDocIndex({
sourceDirName: 'doesNotMatter',
source: 'INDEX.md',
}),
).toEqual(true);
expect(
isConventionalDocIndex({
sourceDirName: 'doesNotMatter',
source: 'parent/InDeX',
}),
).toEqual(true);
});
test('supports <categoryName>/<categoryName>.md', () => {
expect(
isConventionalDocIndex({
sourceDirName: 'someCategory',
source: 'someCategory',
}),
).toEqual(true);
expect(
isConventionalDocIndex({
sourceDirName: 'someCategory',
source: 'someCategory.md',
}),
).toEqual(true);
expect(
isConventionalDocIndex({
sourceDirName: 'someCategory',
source: 'someCategory.mdx',
}),
).toEqual(true);
expect(
isConventionalDocIndex({
sourceDirName: 'some_category',
source: 'SOME_CATEGORY.md',
}),
).toEqual(true);
expect(
isConventionalDocIndex({
sourceDirName: 'some_category',
source: 'parent/some_category',
}),
).toEqual(true);
});
test('reject other cases', () => {
expect(
isConventionalDocIndex({
sourceDirName: 'someCategory',
source: 'some_Category',
}),
).toEqual(false);
expect(
isConventionalDocIndex({
sourceDirName: 'doesNotMatter',
source: 'read_me',
}),
).toEqual(false);
expect(
isConventionalDocIndex({
sourceDirName: 'doesNotMatter',
source: 'the index',
}),
).toEqual(false);
});
});

View file

@ -619,6 +619,32 @@ describe('versioned website', () => {
{label: 'barTag 3', permalink: '/docs/next/tags/barTag-3-permalink'},
],
});
expect(getDocById(version101, 'foo/bar')).toEqual({
...defaultDocMetadata,
id: 'version-1.0.1/foo/bar',
unversionedId: 'foo/bar',
sourceDirName: 'foo',
isDocsHomePage: false,
permalink: '/docs/foo/bar',
slug: '/foo/bar',
source: path.posix.join(
'@site',
posixPath(path.relative(siteDir, version101.contentPath)),
'foo',
'bar.md',
),
title: 'bar',
description: 'Bar 1.0.1 !',
frontMatter: {},
version: '1.0.1',
sidebar: 'VersionedSideBarNameDoesNotMatter/docs',
next: {
title: 'hello',
permalink: '/docs/',
},
tags: [],
});
expect(getDocById(currentVersion, 'hello')).toEqual({
...defaultDocMetadata,
id: 'hello',
@ -659,7 +685,7 @@ describe('versioned website', () => {
description: 'Hello 1.0.1 !',
frontMatter: {},
version: '1.0.1',
sidebar: 'version-1.0.1/docs',
sidebar: 'VersionedSideBarNameDoesNotMatter/docs',
previous: {
title: 'bar',
permalink: '/docs/foo/bar',

View file

@ -51,6 +51,8 @@ describe('normalizeDocsPluginOptions', () => {
docItemComponent: '@theme/DocItem',
docTagDocListComponent: '@theme/DocTagDocListPage',
docTagsListComponent: '@theme/DocTagsListPage',
docCategoryGeneratedIndexComponent:
'@theme/DocCategoryGeneratedIndexPage',
remarkPlugins: [markdownPluginsObjectStub],
rehypePlugins: [markdownPluginsFunctionStub],
beforeDefaultRehypePlugins: [],

View file

@ -9,24 +9,92 @@ import getSlug from '../slug';
describe('getSlug', () => {
test('should default to dirname/id', () => {
expect(getSlug({baseID: 'doc', dirName: '/dir'})).toEqual('/dir/doc');
expect(getSlug({baseID: 'doc', dirName: '/dir/subdir'})).toEqual(
'/dir/subdir/doc',
);
expect(
getSlug({
baseID: 'doc',
source: '@site/docs/dir/doc.md',
sourceDirName: '/dir',
}),
).toEqual('/dir/doc');
expect(
getSlug({
baseID: 'doc',
source: '@site/docs/dir/subdir/doc.md',
sourceDirName: '/dir/subdir',
}),
).toEqual('/dir/subdir/doc');
});
test('should handle conventional doc indexes', () => {
expect(
getSlug({
baseID: 'doc',
source: '@site/docs/dir/subdir/index.md',
sourceDirName: '/dir/subdir',
}),
).toEqual('/dir/subdir/');
expect(
getSlug({
baseID: 'doc',
source: '@site/docs/dir/subdir/inDEx.mdx',
sourceDirName: '/dir/subdir',
}),
).toEqual('/dir/subdir/');
expect(
getSlug({
baseID: 'doc',
source: '@site/docs/dir/subdir/readme.md',
sourceDirName: '/dir/subdir',
}),
).toEqual('/dir/subdir/');
expect(
getSlug({
baseID: 'doc',
source: '@site/docs/dir/subdir/reADMe.mdx',
sourceDirName: '/dir/subdir',
}),
).toEqual('/dir/subdir/');
expect(
getSlug({
baseID: 'doc',
source: '@site/docs/dir/subdir/subdir.md',
sourceDirName: '/dir/subdir',
}),
).toEqual('/dir/subdir/');
expect(
getSlug({
baseID: 'doc',
source: '@site/docs/dir/subdir/suBDir.mdx',
sourceDirName: '/dir/subdir',
}),
).toEqual('/dir/subdir/');
});
test('should ignore conventional doc index when explicit slug frontmatter is provided', () => {
expect(
getSlug({
baseID: 'doc',
source: '@site/docs/dir/subdir/index.md',
sourceDirName: '/dir/subdir',
frontmatterSlug: '/my/frontMatterSlug',
}),
).toEqual('/my/frontMatterSlug');
});
test('can strip dir number prefixes', () => {
expect(
getSlug({
baseID: 'doc',
dirName: '/001-dir1/002-dir2',
source: '@site/docs/001-dir1/002-dir2/doc.md',
sourceDirName: '/001-dir1/002-dir2',
stripDirNumberPrefixes: true,
}),
).toEqual('/dir1/dir2/doc');
expect(
getSlug({
baseID: 'doc',
dirName: '/001-dir1/002-dir2',
source: '@site/docs/001-dir1/002-dir2/doc.md',
sourceDirName: '/001-dir1/002-dir2',
stripDirNumberPrefixes: false,
}),
).toEqual('/001-dir1/002-dir2/doc');
@ -35,26 +103,45 @@ describe('getSlug', () => {
// See https://github.com/facebook/docusaurus/issues/3223
test('should handle special chars in doc path', () => {
expect(
getSlug({baseID: 'my dôc', dirName: '/dir with spâce/hey $hello'}),
getSlug({
baseID: 'my dôc',
source: '@site/docs/dir with spâce/hey $hello/doc.md',
sourceDirName: '/dir with spâce/hey $hello',
}),
).toEqual('/dir with spâce/hey $hello/my dôc');
});
test('should handle current dir', () => {
expect(getSlug({baseID: 'doc', dirName: '.'})).toEqual('/doc');
expect(getSlug({baseID: 'doc', dirName: '/'})).toEqual('/doc');
expect(
getSlug({baseID: 'doc', source: '@site/docs/doc.md', sourceDirName: '.'}),
).toEqual('/doc');
expect(
getSlug({baseID: 'doc', source: '@site/docs/doc.md', sourceDirName: '/'}),
).toEqual('/doc');
});
test('should resolve absolute slug frontmatter', () => {
expect(
getSlug({baseID: 'any', dirName: '.', frontmatterSlug: '/abc/def'}),
).toEqual('/abc/def');
expect(
getSlug({baseID: 'any', dirName: './any', frontmatterSlug: '/abc/def'}),
getSlug({
baseID: 'any',
source: '@site/docs/doc.md',
sourceDirName: '.',
frontmatterSlug: '/abc/def',
}),
).toEqual('/abc/def');
expect(
getSlug({
baseID: 'any',
dirName: './any/any',
source: '@site/docs/any/doc.md',
sourceDirName: './any',
frontmatterSlug: '/abc/def',
}),
).toEqual('/abc/def');
expect(
getSlug({
baseID: 'any',
source: '@site/docs/any/any/doc.md',
sourceDirName: './any/any',
frontmatterSlug: '/abc/def',
}),
).toEqual('/abc/def');
@ -62,46 +149,66 @@ describe('getSlug', () => {
test('should resolve relative slug frontmatter', () => {
expect(
getSlug({baseID: 'any', dirName: '.', frontmatterSlug: 'abc/def'}),
getSlug({
baseID: 'any',
source: '@site/docs/doc.md',
sourceDirName: '.',
frontmatterSlug: 'abc/def',
}),
).toEqual('/abc/def');
expect(
getSlug({baseID: 'any', dirName: '/dir', frontmatterSlug: 'abc/def'}),
getSlug({
baseID: 'any',
source: '@site/docs/dir/doc.md',
sourceDirName: '/dir',
frontmatterSlug: 'abc/def',
}),
).toEqual('/dir/abc/def');
expect(
getSlug({
baseID: 'any',
dirName: 'unslashedDir',
source: '@site/docs/unslashedDir/doc.md',
sourceDirName: 'unslashedDir',
frontmatterSlug: 'abc/def',
}),
).toEqual('/unslashedDir/abc/def');
expect(
getSlug({
baseID: 'any',
dirName: 'dir/subdir',
source: '@site/docs/dir/subdir/doc.md',
sourceDirName: 'dir/subdir',
frontmatterSlug: 'abc/def',
}),
).toEqual('/dir/subdir/abc/def');
expect(
getSlug({baseID: 'any', dirName: '/dir', frontmatterSlug: './abc/def'}),
getSlug({
baseID: 'any',
source: '@site/docs/dir/doc.md',
sourceDirName: '/dir',
frontmatterSlug: './abc/def',
}),
).toEqual('/dir/abc/def');
expect(
getSlug({
baseID: 'any',
dirName: '/dir',
source: '@site/docs/dir/doc.md',
sourceDirName: '/dir',
frontmatterSlug: './abc/../def',
}),
).toEqual('/dir/def');
expect(
getSlug({
baseID: 'any',
dirName: '/dir/subdir',
source: '@site/docs/dir/subdir/doc.md',
sourceDirName: '/dir/subdir',
frontmatterSlug: '../abc/def',
}),
).toEqual('/dir/abc/def');
expect(
getSlug({
baseID: 'any',
dirName: '/dir/subdir',
source: '@site/docs/dir/subdirdoc.md',
sourceDirName: '/dir/subdir',
frontmatterSlug: '../../../../../abc/../def',
}),
).toEqual('/def');

View file

@ -68,6 +68,13 @@ function createSampleVersion(
type: 'category',
label: 'Getting started',
collapsed: false,
link: {
type: 'generated-index',
slug: '/category/getting-started-index-slug',
permalink: '/docs/category/getting-started-index-slug',
title: 'Getting started index title',
description: 'Getting started index description',
},
items: [
{
type: 'doc',

View file

@ -0,0 +1,57 @@
/**
* 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 {CategoryGeneratedIndexMetadata, DocMetadataBase} from './types';
import {SidebarItemCategoryWithGeneratedIndex} from './sidebars/types';
import {SidebarsUtils, toNavigationLink} from './sidebars/utils';
import {createDocsByIdIndex} from './docs';
function getCategoryGeneratedIndexMetadata({
category,
sidebarsUtils,
docsById,
}: {
category: SidebarItemCategoryWithGeneratedIndex;
sidebarsUtils: SidebarsUtils;
docsById: Record<string, DocMetadataBase>;
}): CategoryGeneratedIndexMetadata {
const {sidebarName, previous, next} =
sidebarsUtils.getCategoryGeneratedIndexNavigation(category.link.permalink);
if (!sidebarName) {
throw new Error('unexpected');
}
return {
title: category.link.title ?? category.label,
description: category.link.description,
slug: category.link.slug,
permalink: category.link.permalink,
sidebar: sidebarName,
previous: toNavigationLink(previous, docsById),
next: toNavigationLink(next, docsById),
};
}
export function getCategoryGeneratedIndexMetadataList({
docs,
sidebarsUtils,
}: {
sidebarsUtils: SidebarsUtils;
docs: DocMetadataBase[];
}): CategoryGeneratedIndexMetadata[] {
const docsById = createDocsByIdIndex(docs);
const categoryGeneratedIndexItems =
sidebarsUtils.getCategoryGeneratedIndexList();
return categoryGeneratedIndexItems.map((category) =>
getCategoryGeneratedIndexMetadata({
category,
sidebarsUtils,
docsById,
}),
);
}

View file

@ -13,9 +13,7 @@ import {
import fs from 'fs-extra';
import path from 'path';
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 {loadSidebarsFile, resolveSidebarPathOption} from './sidebars';
import {DEFAULT_PLUGIN_ID} from '@docusaurus/core/lib/constants';
function createVersionedSidebarFile({
@ -23,47 +21,20 @@ function createVersionedSidebarFile({
pluginId,
sidebarPath,
version,
options,
}: {
siteDir: string;
pluginId: string;
sidebarPath: string | false | undefined;
version: string;
options: SidebarOptions;
}) {
// Load current sidebar and create a new versioned sidebars file (if needed).
const loadedSidebars = loadUnprocessedSidebars(sidebarPath, options);
// Note: we don't need the sidebars file to be normalized: it's ok to let plugin option changes to impact older, versioned sidebars
const sidebars = loadSidebarsFile(sidebarPath);
// Do not create a useless versioned sidebars file if sidebars file is empty or sidebars are disabled/false)
const shouldCreateVersionedSidebarFile =
Object.keys(loadedSidebars).length > 0;
const shouldCreateVersionedSidebarFile = Object.keys(sidebars).length > 0;
if (shouldCreateVersionedSidebarFile) {
// 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 prependVersion = (item: SidebarItem): SidebarItem => {
if (item.type === 'ref' || item.type === 'doc') {
return {
type: item.type,
id: `version-${version}/${item.id}`,
};
}
return item;
};
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(
versionedSidebarsDir,
@ -72,7 +43,7 @@ function createVersionedSidebarFile({
fs.ensureDirSync(path.dirname(newSidebarFile));
fs.writeFileSync(
newSidebarFile,
`${JSON.stringify(versionedSidebar, null, 2)}\n`,
`${JSON.stringify(sidebars, null, 2)}\n`,
'utf8',
);
}
@ -155,7 +126,6 @@ export function cliDocsVersionCommand(
pluginId,
version,
sidebarPath: resolveSidebarPathOption(siteDir, sidebarPath),
options,
});
// Update versions.json file.

View file

@ -8,7 +8,7 @@
import path from 'path';
import fs from 'fs-extra';
import chalk from 'chalk';
import {keyBy} from 'lodash';
import {keyBy, last} from 'lodash';
import {
aliasedSitePath,
getEditUrl,
@ -38,8 +38,11 @@ import {CURRENT_VERSION_NAME} from './constants';
import {getDocsDirPaths} from './versions';
import {stripPathNumberPrefixes} from './numberPrefix';
import {validateDocFrontMatter} from './docFrontMatter';
import type {Sidebars} from './sidebars/types';
import {createSidebarsUtils} from './sidebars/utils';
import {
SidebarsUtils,
toDocNavigationLink,
toNavigationLink,
} from './sidebars/utils';
type LastUpdateOptions = Pick<
PluginOptions,
@ -205,7 +208,8 @@ function doProcessDocMetadata({
? '/'
: getSlug({
baseID,
dirName: sourceDirName,
source,
sourceDirName,
frontmatterSlug: frontMatter.slug,
stripDirNumberPrefixes: parseNumberPrefixes,
numberPrefixParser: options.numberPrefixParser,
@ -291,68 +295,76 @@ export function processDocMetadata(args: {
}
}
export function handleNavigation(
export function addDocNavigation(
docsBase: DocMetadataBase[],
sidebars: Sidebars,
sidebarsUtils: SidebarsUtils,
sidebarFilePath: string,
): Pick<LoadedVersion, 'mainDocId' | 'docs'> {
const docsBaseById = keyBy(docsBase, (doc) => doc.id);
const {checkSidebarsDocIds, getDocNavigation, getFirstDocIdOfFirstSidebar} =
createSidebarsUtils(sidebars);
): LoadedVersion['docs'] {
const docsById = createDocsByIdIndex(docsBase);
const validDocIds = Object.keys(docsBaseById);
checkSidebarsDocIds(validDocIds, sidebarFilePath);
sidebarsUtils.checkSidebarsDocIds(
docsBase.flatMap(getDocIds),
sidebarFilePath,
);
// Add sidebar/next/previous to the docs
function addNavData(doc: DocMetadataBase): DocMetadata {
const {sidebarName, previousId, nextId} = getDocNavigation(doc.id);
const toDocNavLink = (
const navigation = sidebarsUtils.getDocNavigation(
doc.unversionedId,
doc.id,
);
const toNavigationLinkByDocId = (
docId: string | null | undefined,
type: 'prev' | 'next',
): DocNavLink | undefined => {
if (!docId) {
return undefined;
}
if (!docsBaseById[docId]) {
const navDoc = docsById[docId];
if (!navDoc) {
// This could only happen if user provided the ID through front matter
throw new Error(
`Error when loading ${doc.id} in ${doc.sourceDirName}: the pagination_${type} front matter points to a non-existent ID ${docId}.`,
);
}
const {
title,
permalink,
frontMatter: {
pagination_label: paginationLabel,
sidebar_label: sidebarLabel,
},
} = docsBaseById[docId];
return {title: paginationLabel ?? sidebarLabel ?? title, permalink};
return toDocNavigationLink(navDoc);
};
const {
frontMatter: {
pagination_next: paginationNext = nextId,
pagination_prev: paginationPrev = previousId,
},
} = doc;
const previous = toDocNavLink(paginationPrev, 'prev');
const next = toDocNavLink(paginationNext, 'next');
return {...doc, sidebar: sidebarName, previous, next};
}
const docs = docsBase.map(addNavData);
// sort to ensure consistent output for tests
docs.sort((a, b) => a.id.localeCompare(b.id));
/**
* The "main doc" is the "version entry point"
* We browse this doc by clicking on a version:
* - the "home" doc (at '/docs/')
* - the first doc of the first sidebar
* - a random doc (if no docs are in any sidebar... edge case)
*/
const previous: DocNavLink | undefined = doc.frontMatter.pagination_prev
? toNavigationLinkByDocId(doc.frontMatter.pagination_prev, 'prev')
: toNavigationLink(navigation.previous, docsById);
const next: DocNavLink | undefined = doc.frontMatter.pagination_next
? toNavigationLinkByDocId(doc.frontMatter.pagination_next, 'next')
: toNavigationLink(navigation.next, docsById);
return {...doc, sidebar: navigation.sidebarName, previous, next};
}
const docsWithNavigation = docsBase.map(addNavData);
// sort to ensure consistent output for tests
docsWithNavigation.sort((a, b) => a.id.localeCompare(b.id));
return docsWithNavigation;
}
/**
* The "main doc" is the "version entry point"
* We browse this doc by clicking on a version:
* - the "home" doc (at '/docs/')
* - the first doc of the first sidebar
* - a random doc (if no docs are in any sidebar... edge case)
*/
export function getMainDocId({
docs,
sidebarsUtils,
}: {
docs: DocMetadataBase[];
sidebarsUtils: SidebarsUtils;
}): string {
function getMainDoc(): DocMetadata {
const versionHomeDoc = docs.find((doc) => doc.slug === '/');
const firstDocIdOfFirstSidebar = getFirstDocIdOfFirstSidebar();
const firstDocIdOfFirstSidebar =
sidebarsUtils.getFirstDocIdOfFirstSidebar();
if (versionHomeDoc) {
return versionHomeDoc;
} else if (firstDocIdOfFirstSidebar) {
@ -362,5 +374,51 @@ export function handleNavigation(
}
}
return {mainDocId: getMainDoc().unversionedId, docs};
return getMainDoc().unversionedId;
}
function getLastPathSegment(str: string): string {
return last(str.split('/'))!;
}
// By convention, Docusaurus considers some docs are "indexes":
// - index.md
// - readme.md
// - <folder>/<folder>.md
//
// Those index docs produce a different behavior
// - Slugs do not end with a weird "/index" suffix
// - Auto-generated sidebar categories link to them as intro
export function isConventionalDocIndex(doc: {
source: DocMetadataBase['slug'];
sourceDirName: DocMetadataBase['sourceDirName'];
}): boolean {
// "@site/docs/folder/subFolder/subSubFolder/myDoc.md" => "myDoc"
const docName = path.parse(doc.source).name;
// "folder/subFolder/subSubFolder" => "subSubFolder"
const lastDirName = getLastPathSegment(doc.sourceDirName);
const eligibleDocIndexNames = ['index', 'readme', lastDirName.toLowerCase()];
return eligibleDocIndexNames.includes(docName.toLowerCase());
}
// Return both doc ids
// TODO legacy retro-compatibility due to old versioned sidebars using versioned doc ids
// ("id" should be removed & "versionedId" should be renamed to "id")
export function getDocIds(doc: DocMetadataBase): [string, string] {
return [doc.unversionedId, doc.id];
}
// docs are indexed by both versioned and unversioned ids at the same time
// TODO legacy retro-compatibility due to old versioned sidebars using versioned doc ids
// ("id" should be removed & "versionedId" should be renamed to "id")
export function createDocsByIdIndex<
Doc extends {id: string; unversionedId: string},
>(docs: Doc[]): Record<string, Doc> {
return {
...keyBy(docs, (doc) => doc.unversionedId),
...keyBy(docs, (doc) => doc.id),
};
}

View file

@ -16,11 +16,17 @@ import {
posixPath,
addTrailingPathSeparator,
createAbsoluteFilePathMatcher,
createSlugger,
} from '@docusaurus/utils';
import type {LoadContext, Plugin, RouteConfig} from '@docusaurus/types';
import type {LoadContext, Plugin} from '@docusaurus/types';
import {loadSidebars} from './sidebars';
import {CategoryMetadataFilenamePattern} from './sidebars/generator';
import {readVersionDocs, processDocMetadata, handleNavigation} from './docs';
import {
readVersionDocs,
processDocMetadata,
addDocNavigation,
getMainDocId,
} from './docs';
import {getDocsDirPaths, readVersionsMetadata} from './versions';
import {
@ -28,7 +34,6 @@ import {
LoadedContent,
SourceToPermalink,
DocMetadataBase,
DocMetadata,
GlobalPluginData,
VersionMetadata,
LoadedVersion,
@ -41,14 +46,17 @@ import {cliDocsVersionCommand} from './cli';
import {VERSIONS_JSON_FILE} from './constants';
import {keyBy, mapValues} from 'lodash';
import {toGlobalDataVersion} from './globalData';
import {toTagDocListProp, toVersionMetadataProp} from './props';
import {toTagDocListProp} from './props';
import {
translateLoadedContent,
getLoadedContentTranslationFiles,
} from './translations';
import chalk from 'chalk';
import {getVersionTags} from './tags';
import {createVersionRoutes} from './routes';
import type {PropTagsListPage} from '@docusaurus/plugin-content-docs';
import {createSidebarsUtils} from './sidebars/utils';
import {getCategoryGeneratedIndexMetadataList} from './categoryGeneratedIndex';
export default function pluginContentDocs(
context: LoadContext,
@ -157,28 +165,37 @@ export default function pluginContentDocs(
async function doLoadVersion(
versionMetadata: VersionMetadata,
): Promise<LoadedVersion> {
const docsBase: DocMetadataBase[] = await loadVersionDocsBase(
const docs: DocMetadataBase[] = await loadVersionDocsBase(
versionMetadata,
);
const sidebars = await loadSidebars(versionMetadata.sidebarFilePath, {
sidebarItemsGenerator: options.sidebarItemsGenerator,
numberPrefixParser: options.numberPrefixParser,
docs: docsBase,
docs,
version: versionMetadata,
options: {
sidebarOptions: {
sidebarCollapsed: options.sidebarCollapsed,
sidebarCollapsible: options.sidebarCollapsible,
},
categoryLabelSlugger: createSlugger(),
});
const sidebarsUtils = createSidebarsUtils(sidebars);
return {
...versionMetadata,
...handleNavigation(
docsBase,
sidebars,
docs: addDocNavigation(
docs,
sidebarsUtils,
versionMetadata.sidebarFilePath as string,
),
sidebars,
mainDocId: getMainDocId({docs, sidebarsUtils}),
categoryGeneratedIndices: getCategoryGeneratedIndexMetadataList({
docs,
sidebarsUtils,
}),
};
}
@ -206,45 +223,17 @@ export default function pluginContentDocs(
async contentLoaded({content, actions}) {
const {loadedVersions} = content;
const {docLayoutComponent, docItemComponent} = options;
const {
docLayoutComponent,
docItemComponent,
docCategoryGeneratedIndexComponent,
} = options;
const {addRoute, createData, setGlobalData} = actions;
const createDocRoutes = async (
docs: DocMetadata[],
): Promise<RouteConfig[]> => {
const routes = await Promise.all(
docs.map(async (metadataItem) => {
await createData(
// Note that this created data path must be in sync with
// metadataPath provided to mdx-loader.
`${docuHash(metadataItem.source)}.json`,
JSON.stringify(metadataItem, null, 2),
);
const docRoute: RouteConfig = {
path: metadataItem.permalink,
component: docItemComponent,
exact: true,
modules: {
content: metadataItem.source,
},
// Because the parent (DocPage) comp need to access it easily
// This permits to render the sidebar once without unmount/remount when navigating (and preserve sidebar state)
...(metadataItem.sidebar && {
sidebar: metadataItem.sidebar,
}),
};
return docRoute;
}),
);
return routes.sort((a, b) => a.path.localeCompare(b.path));
};
async function createVersionTagsRoutes(loadedVersion: LoadedVersion) {
const versionTags = getVersionTags(loadedVersion.docs);
async function createVersionTagsRoutes(version: LoadedVersion) {
const versionTags = getVersionTags(version.docs);
// TODO tags should be a sub route of the version route
async function createTagsListPage() {
const tagsProp: PropTagsListPage['tags'] = Object.values(
versionTags,
@ -257,11 +246,11 @@ export default function pluginContentDocs(
// Only create /tags page if there are tags.
if (Object.keys(tagsProp).length > 0) {
const tagsPropPath = await createData(
`${docuHash(`tags-list-${loadedVersion.versionName}-prop`)}.json`,
`${docuHash(`tags-list-${version.versionName}-prop`)}.json`,
JSON.stringify(tagsProp, null, 2),
);
addRoute({
path: loadedVersion.tagsPath,
path: version.tagsPath,
exact: true,
component: options.docTagsListComponent,
modules: {
@ -271,11 +260,12 @@ export default function pluginContentDocs(
}
}
// TODO tags should be a sub route of the version route
async function createTagDocListPage(tag: VersionTag) {
const tagProps = toTagDocListProp({
allTagsPath: loadedVersion.tagsPath,
allTagsPath: version.tagsPath,
tag,
docs: loadedVersion.docs,
docs: version.docs,
});
const tagPropPath = await createData(
`${docuHash(`tag-${tag.permalink}`)}.json`,
@ -295,50 +285,22 @@ export default function pluginContentDocs(
await Promise.all(Object.values(versionTags).map(createTagDocListPage));
}
async function doCreateVersionRoutes(
loadedVersion: LoadedVersion,
): Promise<void> {
await createVersionTagsRoutes(loadedVersion);
await Promise.all(
loadedVersions.map((loadedVersion) =>
createVersionRoutes({
loadedVersion,
docItemComponent,
docLayoutComponent,
docCategoryGeneratedIndexComponent,
pluginId,
aliasedSource,
actions,
}),
),
);
const versionMetadata = toVersionMetadataProp(pluginId, loadedVersion);
const versionMetadataPropPath = await createData(
`${docuHash(
`version-${loadedVersion.versionName}-metadata-prop`,
)}.json`,
JSON.stringify(versionMetadata, null, 2),
);
addRoute({
path: loadedVersion.versionPath,
// allow matching /docs/* as well
exact: false,
// main docs component (DocPage)
component: docLayoutComponent,
// sub-routes for each doc
routes: await createDocRoutes(loadedVersion.docs),
modules: {
versionMetadata: aliasedSource(versionMetadataPropPath),
},
priority: loadedVersion.routePriority,
});
}
async function createVersionRoutes(
loadedVersion: LoadedVersion,
): Promise<void> {
try {
return await doCreateVersionRoutes(loadedVersion);
} catch (e) {
console.error(
chalk.red(
`Can't create version routes for version "${loadedVersion.versionName}"`,
),
);
throw e;
}
}
await Promise.all(loadedVersions.map(createVersionRoutes));
// TODO tags should be a sub route of the version route
await Promise.all(loadedVersions.map(createVersionTagsRoutes));
setGlobalData<GlobalPluginData>({
path: normalizeUrl([baseUrl, options.routeBasePath]),

View file

@ -39,6 +39,7 @@ export const DEFAULT_OPTIONS: Omit<PluginOptions, 'id' | 'sidebarPath'> = {
docItemComponent: '@theme/DocItem',
docTagDocListComponent: '@theme/DocTagDocListPage',
docTagsListComponent: '@theme/DocTagsListPage',
docCategoryGeneratedIndexComponent: '@theme/DocCategoryGeneratedIndexPage',
remarkPlugins: [],
rehypePlugins: [],
beforeDefaultRemarkPlugins: [],
@ -109,6 +110,9 @@ export const OptionsSchema = Joi.object({
docTagDocListComponent: Joi.string().default(
DEFAULT_OPTIONS.docTagDocListComponent,
),
docCategoryGeneratedIndexComponent: Joi.string().default(
DEFAULT_OPTIONS.docCategoryGeneratedIndexComponent,
),
remarkPlugins: RemarkPluginsSchema.default(DEFAULT_OPTIONS.remarkPlugins),
rehypePlugins: RehypePluginsSchema.default(DEFAULT_OPTIONS.rehypePlugins),
beforeDefaultRemarkPlugins: RemarkPluginsSchema.default(

View file

@ -15,6 +15,18 @@ declare module '@docusaurus/plugin-content-docs' {
export type {GlobalDataVersion, GlobalDataDoc};
export type PropNavigationLink = {
readonly title: string;
readonly permalink: string;
};
export type PropNavigation = {
readonly previous?: PropNavigationLink;
readonly next?: PropNavigationLink;
};
export type PropVersionDoc = import('./sidebars/types').PropVersionDoc;
export type PropVersionDocs = import('./sidebars/types').PropVersionDocs;
export type PropVersionMetadata = {
pluginId: string;
version: string;
@ -24,12 +36,23 @@ declare module '@docusaurus/plugin-content-docs' {
className: string;
isLast: boolean;
docsSidebars: PropSidebars;
docs: PropVersionDocs;
};
export type PropSidebarItemLink = import('./sidebars/types').SidebarItemLink;
export type PropCategoryGeneratedIndex = {
title: string;
description?: string;
slug: string;
permalink: string;
navigation: PropNavigation;
};
export type PropSidebarItemLink =
import('./sidebars/types').PropSidebarItemLink;
export type PropSidebarItemCategory =
import('./sidebars/types').PropSidebarItemCategory;
export type PropSidebarItem = import('./sidebars/types').PropSidebarItem;
export type PropSidebar = import('./sidebars/types').PropSidebar;
export type PropSidebars = import('./sidebars/types').PropSidebars;
export type PropTagDocListDoc = {
@ -56,7 +79,10 @@ declare module '@docusaurus/plugin-content-docs' {
declare module '@theme/DocItem' {
import type {TOCItem} from '@docusaurus/types';
import type {PropVersionMetadata} from '@docusaurus/plugin-content-docs';
import type {
PropNavigationLink,
PropVersionMetadata,
} from '@docusaurus/plugin-content-docs';
export type DocumentRoute = {
readonly component: () => JSX.Element;
@ -85,8 +111,8 @@ declare module '@theme/DocItem' {
readonly formattedLastUpdatedAt?: string;
readonly lastUpdatedBy?: string;
readonly version?: string;
readonly previous?: {readonly permalink: string; readonly title: string};
readonly next?: {readonly permalink: string; readonly title: string};
readonly previous?: PropNavigationLink;
readonly next?: PropNavigationLink;
readonly tags: readonly {
readonly label: string;
readonly permalink: string;
@ -109,6 +135,38 @@ declare module '@theme/DocItem' {
export default DocItem;
}
declare module '@theme/DocCard' {
import type {PropSidebarItem} from '@docusaurus/plugin-content-docs';
export interface Props {
readonly item: PropSidebarItem;
}
export default function DocCard(props: Props): JSX.Element;
}
declare module '@theme/DocCardList' {
import type {PropSidebarItem} from '@docusaurus/plugin-content-docs';
export interface Props {
readonly items: PropSidebarItem[];
}
export default function DocCardList(props: Props): JSX.Element;
}
declare module '@theme/DocCategoryGeneratedIndexPage' {
import type {PropCategoryGeneratedIndex} from '@docusaurus/plugin-content-docs';
export interface Props {
readonly categoryGeneratedIndex: PropCategoryGeneratedIndex;
}
export default function DocCategoryGeneratedIndexPage(
props: Props,
): JSX.Element;
}
declare module '@theme/DocItemFooter' {
import type {Props} from '@theme/DocItem';
@ -132,14 +190,19 @@ declare module '@theme/DocTagDocListPage' {
}
declare module '@theme/DocVersionBanner' {
import type {PropVersionMetadata} from '@docusaurus/plugin-content-docs';
export interface Props {
readonly versionMetadata: PropVersionMetadata;
readonly className?: string;
}
const DocVersionBanner: (props: Props) => JSX.Element;
export default DocVersionBanner;
export default function DocVersionBanner(props: Props): JSX.Element;
}
declare module '@theme/DocVersionBadge' {
export interface Props {
readonly className?: string;
}
export default function DocVersionBadge(props: Props): JSX.Element;
}
declare module '@theme/DocPage' {

View file

@ -8,25 +8,28 @@
import type {LoadedVersion, VersionTag, DocMetadata} from './types';
import type {
SidebarItemDoc,
SidebarItemLink,
SidebarItem,
SidebarItemCategory,
SidebarItemCategoryLink,
PropVersionDocs,
} from './sidebars/types';
import type {
PropSidebars,
PropVersionMetadata,
PropSidebarItem,
PropSidebarItemCategory,
PropTagDocList,
PropTagDocListDoc,
PropSidebarItemLink,
} from '@docusaurus/plugin-content-docs';
import {compact, keyBy, mapValues} from 'lodash';
import {createDocsByIdIndex} from './docs';
export function toSidebarsProp(loadedVersion: LoadedVersion): PropSidebars {
const docsById = keyBy(loadedVersion.docs, (doc) => doc.id);
const docsById = createDocsByIdIndex(loadedVersion.docs);
const convertDocLink = (item: SidebarItemDoc): SidebarItemLink => {
const docId = item.id;
function getDocById(docId: string): DocMetadata {
const docMetadata = docsById[docId];
if (!docMetadata) {
throw new Error(
`Invalid sidebars file. The document with id "${docId}" was used in the sidebar, but no document with this id could be found.
@ -34,26 +37,49 @@ Available document ids are:
- ${Object.keys(docsById).sort().join('\n- ')}`,
);
}
return docMetadata;
}
const convertDocLink = (item: SidebarItemDoc): PropSidebarItemLink => {
const docMetadata = getDocById(item.id);
const {
title,
permalink,
frontMatter: {sidebar_label: sidebarLabel},
} = docMetadata;
return {
type: 'link',
label: sidebarLabel || item.label || title,
href: permalink,
className: item.className,
customProps: item.customProps,
docId: docMetadata.unversionedId,
};
};
const normalizeItem = (item: SidebarItem): PropSidebarItem => {
function getCategoryLinkHref(
link: SidebarItemCategoryLink | undefined,
): string | undefined {
switch (link?.type) {
case 'doc':
return getDocById(link.id).permalink;
case 'generated-index':
return link.permalink;
default:
return undefined;
}
}
function convertCategory(item: SidebarItemCategory): PropSidebarItemCategory {
const {link, ...rest} = item;
const href = getCategoryLinkHref(link);
return {...rest, items: item.items.map(normalizeItem), ...(href && {href})};
}
function normalizeItem(item: SidebarItem): PropSidebarItem {
switch (item.type) {
case 'category':
return {...item, items: item.items.map(normalizeItem)};
return convertCategory(item);
case 'ref':
case 'doc':
return convertDocLink(item);
@ -61,7 +87,7 @@ Available document ids are:
default:
return item;
}
};
}
// Transform the sidebar so that all sidebar item will be in the
// form of 'link' or 'category' only.
@ -69,6 +95,18 @@ Available document ids are:
return mapValues(loadedVersion.sidebars, (items) => items.map(normalizeItem));
}
function toVersionDocsProp(loadedVersion: LoadedVersion): PropVersionDocs {
return mapValues(
keyBy(loadedVersion.docs, (doc) => doc.unversionedId),
(doc) => ({
id: doc.unversionedId,
title: doc.title,
description: doc.description,
sidebar: doc.sidebar,
}),
);
}
export function toVersionMetadataProp(
pluginId: string,
loadedVersion: LoadedVersion,
@ -82,6 +120,7 @@ export function toVersionMetadataProp(
className: loadedVersion.versionClassName,
isLast: loadedVersion.isLast,
docsSidebars: toSidebarsProp(loadedVersion),
docs: toVersionDocsProp(loadedVersion),
};
}

View file

@ -0,0 +1,173 @@
/**
* 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 {PluginContentLoadedActions, RouteConfig} from '@docusaurus/types';
import {docuHash, createSlugger} from '@docusaurus/utils';
import {
CategoryGeneratedIndexMetadata,
DocMetadata,
LoadedVersion,
} from './types';
import type {PropCategoryGeneratedIndex} from '@docusaurus/plugin-content-docs';
import {toVersionMetadataProp} from './props';
import chalk from 'chalk';
export async function createCategoryGeneratedIndexRoutes({
version,
actions,
docCategoryGeneratedIndexComponent,
}: {
version: LoadedVersion;
actions: PluginContentLoadedActions;
docCategoryGeneratedIndexComponent: string;
}): Promise<RouteConfig[]> {
const slugs = createSlugger();
async function createCategoryGeneratedIndexRoute(
categoryGeneratedIndex: CategoryGeneratedIndexMetadata,
): Promise<RouteConfig> {
const {sidebar, title, description, slug, permalink, previous, next} =
categoryGeneratedIndex;
const propFileName = slugs.slug(
`${version.versionPath}-${categoryGeneratedIndex.sidebar}-category-${categoryGeneratedIndex.title}`,
);
const prop: PropCategoryGeneratedIndex = {
title,
description,
slug,
permalink,
navigation: {
previous,
next,
},
};
const propData = await actions.createData(
`${docuHash(`category/${propFileName}`)}.json`,
JSON.stringify(prop, null, 2),
);
return {
path: permalink,
component: docCategoryGeneratedIndexComponent,
exact: true,
modules: {
categoryGeneratedIndex: propData,
},
// Same as doc, this sidebar route attribute permits to associate this subpage to the given sidebar
...(sidebar && {sidebar}),
};
}
return Promise.all(
version.categoryGeneratedIndices.map(createCategoryGeneratedIndexRoute),
);
}
export async function createDocRoutes({
docs,
actions,
docItemComponent,
}: {
docs: DocMetadata[];
actions: PluginContentLoadedActions;
docItemComponent: string;
}): Promise<RouteConfig[]> {
return Promise.all(
docs.map(async (metadataItem) => {
await actions.createData(
// Note that this created data path must be in sync with
// metadataPath provided to mdx-loader.
`${docuHash(metadataItem.source)}.json`,
JSON.stringify(metadataItem, null, 2),
);
const docRoute: RouteConfig = {
path: metadataItem.permalink,
component: docItemComponent,
exact: true,
modules: {
content: metadataItem.source,
},
// Because the parent (DocPage) comp need to access it easily
// This permits to render the sidebar once without unmount/remount when navigating (and preserve sidebar state)
...(metadataItem.sidebar && {
sidebar: metadataItem.sidebar,
}),
};
return docRoute;
}),
);
}
export async function createVersionRoutes({
loadedVersion,
actions,
docItemComponent,
docLayoutComponent,
docCategoryGeneratedIndexComponent,
pluginId,
aliasedSource,
}: {
loadedVersion: LoadedVersion;
actions: PluginContentLoadedActions;
docLayoutComponent: string;
docItemComponent: string;
docCategoryGeneratedIndexComponent: string;
pluginId: string;
aliasedSource: (str: string) => string;
}): Promise<void> {
async function doCreateVersionRoutes(version: LoadedVersion): Promise<void> {
const versionMetadata = toVersionMetadataProp(pluginId, version);
const versionMetadataPropPath = await actions.createData(
`${docuHash(`version-${version.versionName}-metadata-prop`)}.json`,
JSON.stringify(versionMetadata, null, 2),
);
async function createVersionSubRoutes() {
const [docRoutes, sidebarsRoutes] = await Promise.all([
createDocRoutes({docs: version.docs, actions, docItemComponent}),
createCategoryGeneratedIndexRoutes({
version,
actions,
docCategoryGeneratedIndexComponent,
}),
]);
const routes = [...docRoutes, ...sidebarsRoutes];
return routes.sort((a, b) => a.path.localeCompare(b.path));
}
actions.addRoute({
path: version.versionPath,
// allow matching /docs/* as well
exact: false,
// main docs component (DocPage)
component: docLayoutComponent,
// sub-routes for each doc
routes: await createVersionSubRoutes(),
modules: {
versionMetadata: aliasedSource(versionMetadataPropPath),
},
priority: version.routePriority,
});
}
try {
return await doCreateVersionRoutes(loadedVersion);
} catch (e) {
console.error(
chalk.red(
`Can't create version routes for version "${loadedVersion.versionName}"`,
),
);
throw e;
}
}

View file

@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`loadUnprocessedSidebars sidebars link 1`] = `
exports[`loadNormalizedSidebars sidebars link 1`] = `
Object {
"docs": Array [
Object {
@ -14,13 +14,14 @@ Object {
},
],
"label": "Test",
"link": undefined,
"type": "category",
},
],
}
`;
exports[`loadUnprocessedSidebars sidebars with category.collapsed property 1`] = `
exports[`loadNormalizedSidebars sidebars with category.collapsed property 1`] = `
Object {
"docs": Array [
Object {
@ -37,10 +38,12 @@ Object {
},
],
"label": "Introduction",
"link": undefined,
"type": "category",
},
],
"label": "Test",
"link": undefined,
"type": "category",
},
Object {
@ -57,17 +60,19 @@ Object {
},
],
"label": "Powering MDX",
"link": undefined,
"type": "category",
},
],
"label": "Reference",
"link": undefined,
"type": "category",
},
],
}
`;
exports[`loadUnprocessedSidebars sidebars with category.collapsed property at first level 1`] = `
exports[`loadNormalizedSidebars sidebars with category.collapsed property at first level 1`] = `
Object {
"docs": Array [
Object {
@ -80,6 +85,7 @@ Object {
},
],
"label": "Introduction",
"link": undefined,
"type": "category",
},
Object {
@ -92,13 +98,14 @@ Object {
},
],
"label": "Powering MDX",
"link": undefined,
"type": "category",
},
],
}
`;
exports[`loadUnprocessedSidebars sidebars with deep level of category 1`] = `
exports[`loadNormalizedSidebars sidebars with deep level of category 1`] = `
Object {
"docs": Array [
Object {
@ -139,14 +146,17 @@ Object {
},
],
"label": "deeper more more",
"link": undefined,
"type": "category",
},
],
"label": "level 4",
"link": undefined,
"type": "category",
},
],
"label": "level 3",
"link": undefined,
"type": "category",
},
Object {
@ -155,17 +165,19 @@ Object {
},
],
"label": "level 2",
"link": undefined,
"type": "category",
},
],
"label": "level 1",
"link": undefined,
"type": "category",
},
],
}
`;
exports[`loadUnprocessedSidebars sidebars with first level not a category 1`] = `
exports[`loadNormalizedSidebars sidebars with first level not a category 1`] = `
Object {
"docs": Array [
Object {
@ -178,6 +190,7 @@ Object {
},
],
"label": "Getting Started",
"link": undefined,
"type": "category",
},
Object {
@ -188,7 +201,7 @@ Object {
}
`;
exports[`loadUnprocessedSidebars sidebars with known sidebar item type 1`] = `
exports[`loadNormalizedSidebars sidebars with known sidebar item type 1`] = `
Object {
"docs": Array [
Object {
@ -214,6 +227,7 @@ Object {
},
],
"label": "Test",
"link": undefined,
"type": "category",
},
Object {
@ -226,6 +240,7 @@ Object {
},
],
"label": "Guides",
"link": undefined,
"type": "category",
},
],

View file

@ -129,9 +129,15 @@ describe('DefaultSidebarItemsGenerator', () => {
test('generates complex nested sidebar', async () => {
mockCategoryMetadataFiles({
'02-Guides/_category_.json': {collapsed: false},
'02-Guides/_category_.json': {collapsed: false} as CategoryMetadataFile,
'02-Guides/01-SubGuides/_category_.yml': {
label: 'SubGuides (metadata file label)',
link: {
type: 'generated-index',
slug: 'subguides-generated-index-slug',
title: 'subguides-title',
description: 'subguides-description',
},
},
});
@ -153,6 +159,13 @@ describe('DefaultSidebarItemsGenerator', () => {
sidebarPosition: 1,
frontMatter: {},
},
{
id: 'tutorials-index',
source: 'index.md',
sourceDirName: '01-Tutorials',
sidebarPosition: 2,
frontMatter: {},
},
{
id: 'tutorial2',
source: 'tutorial2.md',
@ -167,6 +180,12 @@ describe('DefaultSidebarItemsGenerator', () => {
sidebarPosition: 1,
frontMatter: {},
},
{
id: 'guides-index',
source: '02-Guides.md', // TODO should we allow to just use "Guides.md" to have an index?
sourceDirName: '02-Guides',
frontMatter: {},
},
{
id: 'guide2',
source: 'guide2.md',
@ -209,6 +228,10 @@ describe('DefaultSidebarItemsGenerator', () => {
label: 'Tutorials',
collapsed: true,
collapsible: true,
link: {
type: 'doc',
id: 'tutorials-index',
},
items: [
{type: 'doc', id: 'tutorial1'},
{type: 'doc', id: 'tutorial2'},
@ -219,6 +242,10 @@ describe('DefaultSidebarItemsGenerator', () => {
label: 'Guides',
collapsed: false,
collapsible: true,
link: {
type: 'doc',
id: 'guides-index',
},
items: [
{type: 'doc', id: 'guide1'},
{
@ -227,6 +254,12 @@ describe('DefaultSidebarItemsGenerator', () => {
collapsed: true,
collapsible: true,
items: [{type: 'doc', id: 'nested-guide'}],
link: {
type: 'generated-index',
slug: 'subguides-generated-index-slug',
title: 'subguides-title',
description: 'subguides-description',
},
},
{type: 'doc', id: 'guide2'},
],
@ -354,4 +387,75 @@ describe('DefaultSidebarItemsGenerator', () => {
},
] as Sidebar);
});
test('uses explicit link over the index/readme.{md,mdx} naming convention', async () => {
mockCategoryMetadataFiles({
'Category/_category_.yml': {
label: 'Category label',
link: {
type: 'doc',
id: 'doc3', // Using a "local doc id" ("doc1" instead of "parent/doc1") on purpose
},
},
});
const sidebarSlice = await DefaultSidebarItemsGenerator({
numberPrefixParser: DefaultNumberPrefixParser,
item: {
type: 'autogenerated',
dirName: '.',
},
version: {
versionName: 'current',
contentPath: '',
},
docs: [
{
id: 'parent/doc1',
source: 'index.md',
sourceDirName: 'Category',
frontMatter: {},
},
{
id: 'parent/doc2',
source: 'index.md',
sourceDirName: 'Category',
frontMatter: {},
},
{
id: 'parent/doc3',
source: 'doc3.md',
sourceDirName: 'Category',
frontMatter: {},
},
],
options: {
sidebarCollapsed: true,
sidebarCollapsible: true,
},
});
expect(sidebarSlice).toEqual([
{
type: 'category',
label: 'Category label',
collapsed: true,
collapsible: true,
link: {
id: 'parent/doc3',
type: 'doc',
},
items: [
{
id: 'parent/doc1',
type: 'doc',
},
{
id: 'parent/doc2',
type: 'doc',
},
],
},
] as Sidebar);
});
});

View file

@ -7,27 +7,31 @@
import path from 'path';
import {
loadUnprocessedSidebars,
loadNormalizedSidebars,
DefaultSidebars,
DisabledSidebars,
} from '../index';
import type {SidebarOptions} from '../../types';
import type {NormalizeSidebarsParams, VersionMetadata} from '../../types';
describe('loadUnprocessedSidebars', () => {
describe('loadNormalizedSidebars', () => {
const fixtureDir = path.join(__dirname, '__fixtures__', 'sidebars');
const options: SidebarOptions = {
const options: NormalizeSidebarsParams = {
sidebarCollapsed: true,
sidebarCollapsible: true,
version: {
versionName: 'version',
versionPath: 'versionPath',
} as VersionMetadata,
};
test('sidebars with known sidebar item type', async () => {
const sidebarPath = path.join(fixtureDir, 'sidebars.json');
const result = loadUnprocessedSidebars(sidebarPath, options);
const result = loadNormalizedSidebars(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);
const result = loadNormalizedSidebars(sidebarPath, options);
expect(result).toMatchSnapshot();
});
@ -37,8 +41,8 @@ describe('loadUnprocessedSidebars', () => {
fixtureDir,
'sidebars-category-shorthand.js',
);
const sidebar1 = loadUnprocessedSidebars(sidebarPath1, options);
const sidebar2 = loadUnprocessedSidebars(sidebarPath2, options);
const sidebar1 = loadNormalizedSidebars(sidebarPath1, options);
const sidebar2 = loadNormalizedSidebars(sidebarPath2, options);
expect(sidebar1).toEqual(sidebar2);
});
@ -47,7 +51,7 @@ describe('loadUnprocessedSidebars', () => {
fixtureDir,
'sidebars-category-wrong-items.json',
);
expect(() => loadUnprocessedSidebars(sidebarPath, options))
expect(() => loadNormalizedSidebars(sidebarPath, options))
.toThrowErrorMatchingInlineSnapshot(`
"{
\\"type\\": \\"category\\",
@ -64,7 +68,7 @@ describe('loadUnprocessedSidebars', () => {
fixtureDir,
'sidebars-category-wrong-label.json',
);
expect(() => loadUnprocessedSidebars(sidebarPath, options))
expect(() => loadNormalizedSidebars(sidebarPath, options))
.toThrowErrorMatchingInlineSnapshot(`
"{
\\"type\\": \\"category\\",
@ -83,7 +87,7 @@ describe('loadUnprocessedSidebars', () => {
fixtureDir,
'sidebars-doc-id-not-string.json',
);
expect(() => loadUnprocessedSidebars(sidebarPath, options))
expect(() => loadNormalizedSidebars(sidebarPath, options))
.toThrowErrorMatchingInlineSnapshot(`
"{
\\"type\\": \\"doc\\",
@ -101,19 +105,19 @@ describe('loadUnprocessedSidebars', () => {
fixtureDir,
'sidebars-first-level-not-category.js',
);
const result = loadUnprocessedSidebars(sidebarPath, options);
const result = loadNormalizedSidebars(sidebarPath, options);
expect(result).toMatchSnapshot();
});
test('sidebars link', async () => {
const sidebarPath = path.join(fixtureDir, 'sidebars-link.json');
const result = loadUnprocessedSidebars(sidebarPath, options);
const result = loadNormalizedSidebars(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))
expect(() => loadNormalizedSidebars(sidebarPath, options))
.toThrowErrorMatchingInlineSnapshot(`
"{
\\"type\\": \\"link\\",
@ -127,7 +131,7 @@ describe('loadUnprocessedSidebars', () => {
test('sidebars link wrong href', async () => {
const sidebarPath = path.join(fixtureDir, 'sidebars-link-wrong-href.json');
expect(() => loadUnprocessedSidebars(sidebarPath, options))
expect(() => loadNormalizedSidebars(sidebarPath, options))
.toThrowErrorMatchingInlineSnapshot(`
"{
\\"type\\": \\"link\\",
@ -143,7 +147,7 @@ describe('loadUnprocessedSidebars', () => {
test('sidebars with unknown sidebar item type', async () => {
const sidebarPath = path.join(fixtureDir, 'sidebars-unknown-type.json');
expect(() => loadUnprocessedSidebars(sidebarPath, options))
expect(() => loadNormalizedSidebars(sidebarPath, options))
.toThrowErrorMatchingInlineSnapshot(`
"{
\\"type\\": \\"superman\\",
@ -156,7 +160,7 @@ describe('loadUnprocessedSidebars', () => {
test('sidebars with known sidebar item type but wrong field', async () => {
const sidebarPath = path.join(fixtureDir, 'sidebars-wrong-field.json');
expect(() => loadUnprocessedSidebars(sidebarPath, options))
expect(() => loadNormalizedSidebars(sidebarPath, options))
.toThrowErrorMatchingInlineSnapshot(`
"{
\\"type\\": \\"category\\",
@ -170,24 +174,22 @@ describe('loadUnprocessedSidebars', () => {
});
test('unexisting path', () => {
expect(loadUnprocessedSidebars('badpath', options)).toEqual(
expect(loadNormalizedSidebars('badpath', options)).toEqual(
DisabledSidebars,
);
});
test('undefined path', () => {
expect(loadUnprocessedSidebars(undefined, options)).toEqual(
DefaultSidebars,
);
expect(loadNormalizedSidebars(undefined, options)).toEqual(DefaultSidebars);
});
test('literal false path', () => {
expect(loadUnprocessedSidebars(false, options)).toEqual(DisabledSidebars);
expect(loadNormalizedSidebars(false, options)).toEqual(DisabledSidebars);
});
test('sidebars with category.collapsed property', async () => {
const sidebarPath = path.join(fixtureDir, 'sidebars-collapsed.json');
const result = loadUnprocessedSidebars(sidebarPath, options);
const result = loadNormalizedSidebars(sidebarPath, options);
expect(result).toMatchSnapshot();
});
@ -196,7 +198,7 @@ describe('loadUnprocessedSidebars', () => {
fixtureDir,
'sidebars-collapsed-first-level.json',
);
const result = loadUnprocessedSidebars(sidebarPath, options);
const result = loadNormalizedSidebars(sidebarPath, options);
expect(result).toMatchSnapshot();
});
});

View file

@ -5,7 +5,7 @@
* LICENSE file in the root directory of this source tree.
*/
import {processSidebars} from '../processor';
import {processSidebars, SidebarProcessorParams} from '../processor';
import type {
SidebarItem,
SidebarItemsGenerator,
@ -13,23 +13,50 @@ import type {
NormalizedSidebars,
} from '../types';
import {DefaultSidebarItemsGenerator} from '../generator';
import {createSlugger} from '@docusaurus/utils';
import {VersionMetadata} from '../../types';
import {DefaultNumberPrefixParser} from '../../numberPrefix';
describe('processSidebars', () => {
function createStaticSidebarItemGenerator(
sidebarSlice: SidebarItem[],
): SidebarItemsGenerator {
return jest.fn(async () => sidebarSlice);
}
const StaticGeneratedSidebarSlice: SidebarItem[] = [
{type: 'doc', id: 'doc-generated-id-1'},
{type: 'doc', id: 'doc-generated-id-2'},
];
const StaticSidebarItemsGenerator: SidebarItemsGenerator = jest.fn(
async () => StaticGeneratedSidebarSlice,
);
const StaticSidebarItemsGenerator: SidebarItemsGenerator =
createStaticSidebarItemGenerator(StaticGeneratedSidebarSlice);
async function testProcessSidebars(unprocessedSidebars: NormalizedSidebars) {
// @ts-expect-error: good enough for this test
const version: VersionMetadata = {
versionName: '1.0.0',
versionPath: '/docs/1.0.0',
};
const params: SidebarProcessorParams = {
sidebarItemsGenerator: StaticSidebarItemsGenerator,
docs: [],
version,
numberPrefixParser: DefaultNumberPrefixParser,
categoryLabelSlugger: createSlugger(),
sidebarOptions: {
sidebarCollapsed: true,
sidebarCollapsible: true,
},
};
async function testProcessSidebars(
unprocessedSidebars: NormalizedSidebars,
paramsOverrides: Partial<SidebarProcessorParams> = {},
) {
return processSidebars(unprocessedSidebars, {
sidebarItemsGenerator: StaticSidebarItemsGenerator,
docs: [],
// @ts-expect-error: useless for this test
version: {},
...params,
...paramsOverrides,
});
}
@ -69,13 +96,18 @@ describe('processSidebars', () => {
{type: 'doc', id: 'doc1'},
{
type: 'category',
label: 'Category',
link: {
type: 'generated-index',
slug: 'category-generated-index-slug',
permalink: 'category-generated-index-permalink',
},
collapsed: false,
collapsible: true,
items: [
{type: 'doc', id: 'doc2'},
{type: 'autogenerated', dirName: 'dir1'},
],
label: 'Category',
},
{type: 'link', href: 'https://facebook.com', label: 'FB'},
],
@ -86,10 +118,10 @@ describe('processSidebars', () => {
{type: 'autogenerated', dirName: 'dir3'},
{
type: 'category',
label: 'Category',
collapsed: false,
collapsible: true,
items: [{type: 'doc', id: 'doc4'}],
label: 'Category',
},
],
};
@ -100,20 +132,32 @@ describe('processSidebars', () => {
expect(StaticSidebarItemsGenerator).toHaveBeenCalledWith({
defaultSidebarItemsGenerator: DefaultSidebarItemsGenerator,
item: {type: 'autogenerated', dirName: 'dir1'},
docs: [],
version: {},
docs: params.docs,
version: {
versionName: version.versionName,
},
numberPrefixParser: DefaultNumberPrefixParser,
options: params.sidebarOptions,
});
expect(StaticSidebarItemsGenerator).toHaveBeenCalledWith({
defaultSidebarItemsGenerator: DefaultSidebarItemsGenerator,
item: {type: 'autogenerated', dirName: 'dir2'},
docs: [],
version: {},
docs: params.docs,
version: {
versionName: version.versionName,
},
numberPrefixParser: DefaultNumberPrefixParser,
options: params.sidebarOptions,
});
expect(StaticSidebarItemsGenerator).toHaveBeenCalledWith({
defaultSidebarItemsGenerator: DefaultSidebarItemsGenerator,
item: {type: 'autogenerated', dirName: 'dir3'},
docs: [],
version: {},
docs: params.docs,
version: {
versionName: version.versionName,
},
numberPrefixParser: DefaultNumberPrefixParser,
options: params.sidebarOptions,
});
expect(processedSidebar).toEqual({
@ -121,10 +165,15 @@ describe('processSidebars', () => {
{type: 'doc', id: 'doc1'},
{
type: 'category',
label: 'Category',
link: {
type: 'generated-index',
slug: 'category-generated-index-slug',
permalink: 'category-generated-index-permalink',
},
collapsed: false,
collapsible: true,
items: [{type: 'doc', id: 'doc2'}, ...StaticGeneratedSidebarSlice],
label: 'Category',
},
{type: 'link', href: 'https://facebook.com', label: 'FB'},
],
@ -135,10 +184,52 @@ describe('processSidebars', () => {
...StaticGeneratedSidebarSlice,
{
type: 'category',
label: 'Category',
collapsed: false,
collapsible: true,
items: [{type: 'doc', id: 'doc4'}],
label: 'Category',
},
],
} as Sidebars);
});
test('ensure generated items are normalized', async () => {
const sidebarSliceContainingCategoryGeneratedIndex: SidebarItem[] = [
{
type: 'category',
label: 'Generated category',
link: {
type: 'generated-index',
slug: 'generated-cat-index-slug',
// @ts-expect-error: TODO undefined should be allowed here, typing error needing refactor
permalink: undefined,
},
},
];
const unprocessedSidebars: NormalizedSidebars = {
someSidebar: [{type: 'autogenerated', dirName: 'dir2'}],
};
const processedSidebar = await testProcessSidebars(unprocessedSidebars, {
sidebarItemsGenerator: createStaticSidebarItemGenerator(
sidebarSliceContainingCategoryGeneratedIndex,
),
});
expect(processedSidebar).toEqual({
someSidebar: [
{
type: 'category',
label: 'Generated category',
link: {
type: 'generated-index',
slug: 'generated-cat-index-slug',
permalink: '/docs/1.0.0/generated-cat-index-slug',
},
items: [],
collapsible: true,
collapsed: true,
},
],
} as Sidebars);

View file

@ -12,8 +12,12 @@ import {
collectSidebarLinks,
transformSidebarItems,
collectSidebarsDocIds,
SidebarNavigation,
toDocNavigationLink,
toNavigationLink,
} from '../utils';
import type {Sidebar, Sidebars} from '../types';
import {DocMetadataBase, DocNavLink} from '../../types';
describe('createSidebarsUtils', () => {
const sidebar1: Sidebar = [
@ -21,13 +25,13 @@ describe('createSidebarsUtils', () => {
type: 'category',
collapsed: false,
collapsible: true,
label: 'Category1',
label: 'S1 Category',
items: [
{
type: 'category',
collapsed: false,
collapsible: true,
label: 'Subcategory 1',
label: 'S1 Subcategory',
items: [{type: 'doc', id: 'doc1'}],
},
{type: 'doc', id: 'doc2'},
@ -40,7 +44,7 @@ describe('createSidebarsUtils', () => {
type: 'category',
collapsed: false,
collapsible: true,
label: 'Category2',
label: 'S2 Category',
items: [
{type: 'doc', id: 'doc3'},
{type: 'doc', id: 'doc4'},
@ -48,10 +52,58 @@ describe('createSidebarsUtils', () => {
},
];
const sidebars: Sidebars = {sidebar1, sidebar2};
const sidebar3: Sidebar = [
{
type: 'category',
collapsed: false,
collapsible: true,
label: 'S3 Category',
link: {
type: 'doc',
id: 'doc5',
},
items: [
{
type: 'category',
collapsed: false,
collapsible: true,
label: 'S3 SubCategory',
link: {
type: 'generated-index',
slug: '/s3-subcategory-index-slug',
permalink: '/s3-subcategory-index-permalink',
},
items: [
{
type: 'category',
collapsed: false,
collapsible: true,
label: 'S3 SubSubCategory',
link: {
type: 'generated-index',
slug: '/s3-subsubcategory-slug',
permalink: '/s3-subsubcategory-index-permalink',
},
items: [
{type: 'doc', id: 'doc6'},
{type: 'doc', id: 'doc7'},
],
},
],
},
],
},
];
const {getFirstDocIdOfFirstSidebar, getSidebarNameByDocId, getDocNavigation} =
createSidebarsUtils(sidebars);
const sidebars: Sidebars = {sidebar1, sidebar2, sidebar3};
const {
getFirstDocIdOfFirstSidebar,
getSidebarNameByDocId,
getDocNavigation,
getCategoryGeneratedIndexNavigation,
getCategoryGeneratedIndexList,
} = createSidebarsUtils(sidebars);
test('getSidebarNameByDocId', async () => {
expect(getFirstDocIdOfFirstSidebar()).toEqual('doc1');
@ -62,32 +114,117 @@ describe('createSidebarsUtils', () => {
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);
expect(getSidebarNameByDocId('doc5')).toEqual('sidebar3');
expect(getSidebarNameByDocId('doc6')).toEqual('sidebar3');
expect(getSidebarNameByDocId('doc7')).toEqual('sidebar3');
expect(getSidebarNameByDocId('unknown_id')).toEqual(undefined);
});
test('getDocNavigation', async () => {
expect(getDocNavigation('doc1')).toEqual({
sidebarName: 'sidebar1',
previousId: undefined,
nextId: 'doc2',
});
previous: undefined,
next: {
type: 'doc',
id: 'doc2',
},
} as SidebarNavigation);
expect(getDocNavigation('doc2')).toEqual({
sidebarName: 'sidebar1',
previousId: 'doc1',
nextId: undefined,
});
previous: {
type: 'doc',
id: 'doc1',
},
next: undefined,
} as SidebarNavigation);
expect(getDocNavigation('doc3')).toEqual({
sidebarName: 'sidebar2',
previousId: undefined,
nextId: 'doc4',
});
previous: undefined,
next: {
type: 'doc',
id: 'doc4',
},
} as SidebarNavigation);
expect(getDocNavigation('doc4')).toEqual({
sidebarName: 'sidebar2',
previousId: 'doc3',
nextId: undefined,
});
previous: {
type: 'doc',
id: 'doc3',
},
next: undefined,
} as SidebarNavigation);
expect(getDocNavigation('doc5')).toMatchObject({
sidebarName: 'sidebar3',
previous: undefined,
next: {
type: 'category',
label: 'S3 SubCategory',
},
} as SidebarNavigation);
expect(getDocNavigation('doc6')).toMatchObject({
sidebarName: 'sidebar3',
previous: {
type: 'category',
label: 'S3 SubSubCategory',
},
next: {
type: 'doc',
id: 'doc7',
},
} as SidebarNavigation);
expect(getDocNavigation('doc7')).toMatchObject({
sidebarName: 'sidebar3',
previous: {
type: 'doc',
id: 'doc6',
},
next: undefined,
} as SidebarNavigation);
});
test('getCategoryGeneratedIndexNavigation', async () => {
expect(
getCategoryGeneratedIndexNavigation('/s3-subcategory-index-permalink'),
).toMatchObject({
sidebarName: 'sidebar3',
previous: {
type: 'category',
label: 'S3 Category',
},
next: {
type: 'category',
label: 'S3 SubSubCategory',
},
} as SidebarNavigation);
expect(
getCategoryGeneratedIndexNavigation('/s3-subsubcategory-index-permalink'),
).toMatchObject({
sidebarName: 'sidebar3',
previous: {
type: 'category',
label: 'S3 SubCategory',
},
next: {
type: 'doc',
id: 'doc6',
},
} as SidebarNavigation);
});
test('getCategoryGeneratedIndexList', async () => {
expect(getCategoryGeneratedIndexList()).toMatchObject([
{
type: 'category',
label: 'S3 SubCategory',
},
{
type: 'category',
label: 'S3 SubSubCategory',
},
]);
});
});
@ -393,3 +530,166 @@ describe('transformSidebarItems', () => {
]);
});
});
describe('toDocNavigationLink', () => {
type TestDoc = Pick<DocMetadataBase, 'permalink' | 'title' | 'frontMatter'>;
function testDoc(data: TestDoc) {
return data as DocMetadataBase;
}
test('with no frontmatter', () => {
expect(
toDocNavigationLink(
testDoc({
title: 'Doc Title',
permalink: '/docPermalink',
frontMatter: {},
}),
),
).toEqual({
title: 'Doc Title',
permalink: '/docPermalink',
} as DocNavLink);
});
test('with pagination_label frontmatter', () => {
expect(
toDocNavigationLink(
testDoc({
title: 'Doc Title',
permalink: '/docPermalink',
frontMatter: {
pagination_label: 'pagination_label',
},
}),
),
).toEqual({
title: 'pagination_label',
permalink: '/docPermalink',
} as DocNavLink);
});
test('with sidebar_label frontmatter', () => {
expect(
toDocNavigationLink(
testDoc({
title: 'Doc Title',
permalink: '/docPermalink',
frontMatter: {
sidebar_label: 'sidebar_label',
},
}),
),
).toEqual({
title: 'sidebar_label',
permalink: '/docPermalink',
} as DocNavLink);
});
test('with pagination_label + sidebar_label frontmatter', () => {
expect(
toDocNavigationLink(
testDoc({
title: 'Doc Title',
permalink: '/docPermalink',
frontMatter: {
pagination_label: 'pagination_label',
sidebar_label: 'sidebar_label',
},
}),
),
).toEqual({
title: 'pagination_label',
permalink: '/docPermalink',
} as DocNavLink);
});
});
describe('toNavigationLink', () => {
type TestDoc = Pick<DocMetadataBase, 'permalink' | 'title'>;
function testDoc(data: TestDoc) {
return {...data, frontMatter: {}} as DocMetadataBase;
}
const docsById: Record<string, DocMetadataBase> = {
doc1: testDoc({
title: 'Doc 1',
permalink: '/doc1',
}),
doc2: testDoc({
title: 'Doc 1',
permalink: '/doc1',
}),
};
test('with doc items', () => {
expect(toNavigationLink({type: 'doc', id: 'doc1'}, docsById)).toEqual(
toDocNavigationLink(docsById.doc1),
);
expect(toNavigationLink({type: 'doc', id: 'doc2'}, docsById)).toEqual(
toDocNavigationLink(docsById.doc2),
);
expect(() =>
toNavigationLink({type: 'doc', id: 'doc3'}, docsById),
).toThrowErrorMatchingInlineSnapshot(
`"Can't create navigation link: no doc found with id=doc3"`,
);
});
test('with category item and doc link', () => {
expect(
toNavigationLink(
{
type: 'category',
label: 'Category',
items: [],
link: {
type: 'doc',
id: 'doc1',
},
collapsed: true,
collapsible: true,
},
docsById,
),
).toEqual(toDocNavigationLink(docsById.doc1));
expect(() =>
toNavigationLink(
{
type: 'category',
label: 'Category',
items: [],
link: {
type: 'doc',
id: 'doc3',
},
collapsed: true,
collapsible: true,
},
docsById,
),
).toThrowErrorMatchingInlineSnapshot(
`"Can't create navigation link: no doc found with id=doc3"`,
);
});
test('with category item and generated-index link', () => {
expect(
toNavigationLink(
{
type: 'category',
label: 'Category',
items: [],
link: {
type: 'generated-index',
slug: 'slug',
permalink: 'generated-index-permalink',
},
collapsed: true,
collapsible: true,
},
docsById,
),
).toEqual({title: 'Category', permalink: 'generated-index-permalink'});
});
});

View file

@ -0,0 +1,105 @@
/**
* 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 {validateSidebars, validateCategoryMetadataFile} from '../validation';
import {CategoryMetadataFile} from '../generator';
import {SidebarsConfig} from '../types';
describe('validateSidebars', () => {
// TODO add more tests
// TODO it seems many error cases are not validated properly
// and error messages are quite bad
test('throw for bad value', async () => {
expect(() => validateSidebars({sidebar: [{type: 42}]}))
.toThrowErrorMatchingInlineSnapshot(`
"{
\\"type\\": 42,
\\"undefined\\" [1]: -- missing --
}

[1] Unknown sidebar item type \\"42\\"."
`);
});
test('accept empty object', async () => {
const sidebars: SidebarsConfig = {};
validateSidebars(sidebars);
});
test('accept valid values', async () => {
const sidebars: SidebarsConfig = {
sidebar1: [
{type: 'doc', id: 'doc1'},
{type: 'doc', id: 'doc2'},
{
type: 'category',
label: 'Category',
items: [{type: 'doc', id: 'doc3'}],
},
],
};
validateSidebars(sidebars);
});
});
describe('validateCategoryMetadataFile', () => {
// TODO add more tests
test('throw for bad value', async () => {
expect(() =>
validateCategoryMetadataFile(42),
).toThrowErrorMatchingInlineSnapshot(
`"\\"value\\" must be of type object"`,
);
});
test('accept empty object', async () => {
const content: CategoryMetadataFile = {};
expect(validateCategoryMetadataFile(content)).toEqual(content);
});
test('accept valid values', async () => {
const content: CategoryMetadataFile = {
className: 'className',
label: 'Category Label',
link: {
type: 'generated-index',
slug: 'slug',
title: 'title',
description: 'description',
},
collapsible: true,
collapsed: true,
position: 3,
};
expect(validateCategoryMetadataFile(content)).toEqual(content);
});
test('rejects permalink', async () => {
const content: CategoryMetadataFile = {
className: 'className',
label: 'Category Label',
link: {
type: 'generated-index',
slug: 'slug',
// @ts-expect-error: rejected on purpose
permalink: 'somePermalink',
title: 'title',
description: 'description',
},
collapsible: true,
collapsed: true,
position: 3,
};
expect(() =>
validateCategoryMetadataFile(content),
).toThrowErrorMatchingInlineSnapshot(
`"\\"link.permalink\\" is not allowed"`,
);
});
});

View file

@ -11,19 +11,27 @@ import type {
SidebarItemCategory,
SidebarItemsGenerator,
SidebarItemsGeneratorDoc,
SidebarItemCategoryLink,
SidebarItemCategoryLinkConfig,
} from './types';
import {keyBy, sortBy} from 'lodash';
import {sortBy, last} 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';
import {validateCategoryMetadataFile} from './validation';
import {createDocsByIdIndex, isConventionalDocIndex} from '../docs';
const BreadcrumbSeparator = '/';
// To avoid possible name clashes with a folder of the same name as the ID
const docIdPrefix = '$doc$/';
// Just an alias to the make code more explicit
function getLocalDocId(docId: string): string {
return last(docId.split('/'))!;
}
export const CategoryMetadataFilenameBase = '_category_';
export const CategoryMetadataFilenamePattern = '_category_.{json,yml,yaml}';
@ -33,6 +41,7 @@ export type CategoryMetadataFile = {
collapsed?: boolean;
collapsible?: boolean;
className?: string;
link?: SidebarItemCategoryLinkConfig;
// 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/
@ -50,17 +59,9 @@ type Dir = {
[item: string]: Dir | null;
};
const CategoryMetadataFileSchema = Joi.object<CategoryMetadataFile>({
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
// TODO later if there is `CategoryFolder/with-category-name-doc.md`, we may want to read the metadata as yaml on it
// see https://github.com/facebook/docusaurus/issues/3464#issuecomment-818670449
async function readCategoryMetadataFile(
categoryDirPath: string,
@ -69,7 +70,7 @@ async function readCategoryMetadataFile(
const contentString = await fs.readFile(filePath, {encoding: 'utf8'});
const unsafeContent = Yaml.load(contentString);
try {
return Joi.attempt(unsafeContent, CategoryMetadataFileSchema);
return validateCategoryMetadataFile(unsafeContent);
} catch (e) {
console.error(
chalk.red(
@ -100,6 +101,21 @@ export const DefaultSidebarItemsGenerator: SidebarItemsGenerator = async ({
item: {dirName: autogenDir},
version,
}) => {
const docsById = createDocsByIdIndex(allDocs);
const findDoc = (docId: string): SidebarItemsGeneratorDoc | undefined =>
docsById[docId];
const getDoc = (docId: string): SidebarItemsGeneratorDoc => {
const doc = findDoc(docId);
if (!doc) {
throw new Error(
`Can't find any doc with id=${docId}.\nAvailable doc ids:\n- ${Object.keys(
docsById,
).join('\n- ')}`,
);
}
return doc;
};
/**
* Step 1. Extract the docs that are in the autogen dir.
*/
@ -163,12 +179,11 @@ export const DefaultSidebarItemsGenerator: SidebarItemsGenerator = async ({
* (From a record to an array of items, akin to normalizing shorthand)
*/
function generateSidebar(fsModel: Dir): Promise<WithPosition<SidebarItem>[]> {
const docsById = keyBy(allDocs, (doc) => doc.id);
function createDocItem(id: string): WithPosition<SidebarItemDoc> {
const {
sidebarPosition: position,
frontMatter: {sidebar_label: label, sidebar_class_name: className},
} = docsById[id];
} = getDoc(id);
return {
type: 'doc',
id,
@ -187,6 +202,57 @@ export const DefaultSidebarItemsGenerator: SidebarItemsGenerator = async ({
const categoryMetadata = await readCategoryMetadataFile(categoryPath);
const className = categoryMetadata?.className;
const {filename, numberPrefix} = numberPrefixParser(folderName);
const allItems = await Promise.all(
Object.entries(dir).map(([key, content]) =>
dirToItem(content, key, `${fullPath}/${key}`),
),
);
// Try to match a doc inside the category folder,
// using the "local id" (myDoc) or "qualified id" (dirName/myDoc)
function findDocByLocalId(localId: string): SidebarItemDoc | undefined {
return allItems.find(
(item) => item.type === 'doc' && getLocalDocId(item.id) === localId,
) as SidebarItemDoc | undefined;
}
function findConventionalCategoryDocLink(): SidebarItemDoc | undefined {
return allItems.find(
(item) =>
item.type === 'doc' && isConventionalDocIndex(getDoc(item.id)),
) as SidebarItemDoc | undefined;
}
function getCategoryLinkedDocId(): string | undefined {
const link = categoryMetadata?.link;
if (link) {
if (link.type === 'doc') {
return findDocByLocalId(link.id)?.id || getDoc(link.id).id;
} else {
// We don't continue for other link types on purpose!
// IE if user decide to use type "generated-index", we should not pick a README.md file as the linked doc
return undefined;
}
}
// Apply default convention to pick index.md, README.md or <categoryName>.md as the category doc
return findConventionalCategoryDocLink()?.id;
}
const categoryLinkedDocId = getCategoryLinkedDocId();
const link: SidebarItemCategoryLink | undefined = categoryLinkedDocId
? {
type: 'doc',
id: categoryLinkedDocId, // We "remap" a potentially "local id" to a "qualified id"
}
: // TODO typing issue
(categoryMetadata?.link as SidebarItemCategoryLink | undefined);
// If a doc is linked, remove it from the category subItems
const items = allItems.filter(
(item) => !(item.type === 'doc' && item.id === categoryLinkedDocId),
);
return {
type: 'category',
label: categoryMetadata?.label ?? filename,
@ -195,11 +261,8 @@ export const DefaultSidebarItemsGenerator: SidebarItemsGenerator = async ({
collapsed: categoryMetadata?.collapsed ?? options.sidebarCollapsed,
position: categoryMetadata?.position ?? numberPrefix,
...(className !== undefined && {className}),
items: await Promise.all(
Object.entries(dir).map(([key, content]) =>
dirToItem(content, key, `${fullPath}/${key}`),
),
),
items,
...(link && {link}),
};
}
async function dirToItem(

View file

@ -8,11 +8,12 @@
import fs from 'fs-extra';
import importFresh from 'import-fresh';
import type {SidebarsConfig, Sidebars, NormalizedSidebars} from './types';
import type {PluginOptions} from '../types';
import type {NormalizeSidebarsParams, PluginOptions} from '../types';
import {validateSidebars} from './validation';
import {normalizeSidebars} from './normalization';
import {processSidebars, SidebarProcessorProps} from './processor';
import {processSidebars, SidebarProcessorParams} from './processor';
import path from 'path';
import {createSlugger} from '@docusaurus/utils';
export const DefaultSidebars: SidebarsConfig = {
defaultSidebar: [
@ -36,7 +37,7 @@ export function resolveSidebarPathOption(
: sidebarPathOption;
}
function loadSidebarFile(
function loadSidebarsFileUnsafe(
sidebarFilePath: string | false | undefined,
): SidebarsConfig {
// false => no sidebars
@ -60,25 +61,34 @@ function loadSidebarFile(
return importFresh(sidebarFilePath);
}
export function loadUnprocessedSidebars(
export function loadSidebarsFile(
sidebarFilePath: string | false | undefined,
options: SidebarProcessorProps['options'],
): NormalizedSidebars {
const sidebarsConfig = loadSidebarFile(sidebarFilePath);
): SidebarsConfig {
const sidebarsConfig = loadSidebarsFileUnsafe(sidebarFilePath);
validateSidebars(sidebarsConfig);
return sidebarsConfig;
}
const normalizedSidebars = normalizeSidebars(sidebarsConfig, options);
return normalizedSidebars;
export function loadNormalizedSidebars(
sidebarFilePath: string | false | undefined,
params: NormalizeSidebarsParams,
): NormalizedSidebars {
return normalizeSidebars(loadSidebarsFile(sidebarFilePath), params);
}
// Note: sidebarFilePath must be absolute, use resolveSidebarPathOption
export async function loadSidebars(
sidebarFilePath: string | false | undefined,
options: SidebarProcessorProps,
options: SidebarProcessorParams,
): Promise<Sidebars> {
const unprocessedSidebars = loadUnprocessedSidebars(
const normalizeSidebarsParams: NormalizeSidebarsParams = {
...options.sidebarOptions,
version: options.version,
categoryLabelSlugger: createSlugger(),
};
const normalizedSidebars = loadNormalizedSidebars(
sidebarFilePath,
options.options,
normalizeSidebarsParams,
);
return processSidebars(unprocessedSidebars, options);
return processSidebars(normalizedSidebars, options);
}

View file

@ -5,7 +5,7 @@
* LICENSE file in the root directory of this source tree.
*/
import type {SidebarOptions} from '../types';
import type {NormalizeSidebarsParams, SidebarOptions} from '../types';
import type {
NormalizedSidebarItem,
NormalizedSidebar,
@ -15,9 +15,31 @@ import type {
SidebarItemConfig,
SidebarConfig,
SidebarsConfig,
SidebarItemCategoryLink,
NormalizedSidebarItemCategory,
} from './types';
import {mapValues} from 'lodash';
import {isCategoriesShorthand} from './utils';
import {mapValues} from 'lodash';
import {normalizeUrl} from '@docusaurus/utils';
function normalizeCategoryLink(
category: SidebarItemCategoryConfig,
params: NormalizeSidebarsParams,
): SidebarItemCategoryLink | undefined {
if (category.link?.type === 'generated-index') {
// default slug logic can be improved
const getDefaultSlug = () =>
`/category/${params.categoryLabelSlugger.slug(category.label)}`;
const slug = category.link.slug ?? getDefaultSlug();
const permalink = normalizeUrl([params.version.versionPath, slug]);
return {
...category.link,
slug,
permalink,
};
}
return category.link;
}
function normalizeCategoriesShorthand(
sidebar: SidebarCategoriesShorthand,
@ -36,9 +58,9 @@ function normalizeCategoriesShorthand(
* Normalizes recursively item and all its children. Ensures that at the end
* each item will be an object with the corresponding type.
*/
function normalizeItem(
export function normalizeItem(
item: SidebarItemConfig,
options: SidebarOptions,
options: NormalizeSidebarsParams,
): NormalizedSidebarItem[] {
if (typeof item === 'string') {
return [
@ -49,40 +71,42 @@ function normalizeItem(
];
}
if (isCategoriesShorthand(item)) {
return normalizeCategoriesShorthand(item, options).flatMap((subitem) =>
normalizeItem(subitem, options),
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];
if (item.type === 'category') {
const link = normalizeCategoryLink(item, options);
const normalizedCategory: NormalizedSidebarItemCategory = {
...item,
link,
items: (item.items ?? []).flatMap((subItem) =>
normalizeItem(subItem, options),
),
collapsible: item.collapsible ?? options.sidebarCollapsible,
collapsed: item.collapsed ?? options.sidebarCollapsed,
};
return [normalizedCategory];
}
return [item];
}
function normalizeSidebar(
sidebar: SidebarConfig,
options: SidebarOptions,
options: NormalizeSidebarsParams,
): NormalizedSidebar {
const normalizedSidebar = Array.isArray(sidebar)
? sidebar
: normalizeCategoriesShorthand(sidebar, options);
return normalizedSidebar.flatMap((subitem) =>
normalizeItem(subitem, options),
return normalizedSidebar.flatMap((subItem) =>
normalizeItem(subItem, options),
);
}
export function normalizeSidebars(
sidebars: SidebarsConfig,
options: SidebarOptions,
params: NormalizeSidebarsParams,
): NormalizedSidebars {
return mapValues(sidebars, (subitem) => normalizeSidebar(subitem, options));
return mapValues(sidebars, (items) => normalizeSidebar(items, params));
}

View file

@ -21,18 +21,24 @@ import type {
SidebarItemsGeneratorOption,
SidebarItemsGeneratorDoc,
SidebarItemsGeneratorVersion,
NormalizedSidebarItemCategory,
SidebarItemCategory,
SidebarItemAutogenerated,
} from './types';
import {transformSidebarItems} from './utils';
import {DefaultSidebarItemsGenerator} from './generator';
import {mapValues, memoize, pick} from 'lodash';
import combinePromises from 'combine-promises';
import {normalizeItem} from './normalization';
import {Slugger} from '@docusaurus/utils';
export type SidebarProcessorProps = {
export type SidebarProcessorParams = {
sidebarItemsGenerator: SidebarItemsGeneratorOption;
numberPrefixParser: NumberPrefixParser;
docs: DocMetadataBase[];
version: VersionMetadata;
options: SidebarOptions;
categoryLabelSlugger: Slugger;
sidebarOptions: SidebarOptions;
};
function toSidebarItemsGeneratorDoc(
@ -40,6 +46,7 @@ function toSidebarItemsGeneratorDoc(
): SidebarItemsGeneratorDoc {
return pick(doc, [
'id',
'unversionedId',
'frontMatter',
'source',
'sourceDirName',
@ -56,48 +63,71 @@ function toSidebarItemsGeneratorVersion(
// Handle the generation of autogenerated sidebar items and other post-processing checks
async function processSidebar(
unprocessedSidebar: NormalizedSidebar,
{
params: SidebarProcessorParams,
): Promise<Sidebar> {
const {
sidebarItemsGenerator,
numberPrefixParser,
docs,
version,
options,
}: SidebarProcessorProps,
): Promise<Sidebar> {
sidebarOptions,
} = params;
// Just a minor lazy transformation optimization
const getSidebarItemsGeneratorDocsAndVersion = memoize(() => ({
docs: docs.map(toSidebarItemsGeneratorDoc),
version: toSidebarItemsGeneratorVersion(version),
}));
async function handleAutoGeneratedItems(
async function processCategoryItem(
item: NormalizedSidebarItemCategory,
): Promise<SidebarItemCategory> {
return {
...item,
items: (await Promise.all(item.items.map(processItem))).flat(),
};
}
async function processAutoGeneratedItem(
item: SidebarItemAutogenerated,
): Promise<SidebarItem[]> {
// TODO the returned type can't be trusted in practice (generator can be user-provided)
const generatedItems = await sidebarItemsGenerator({
item,
numberPrefixParser,
defaultSidebarItemsGenerator: DefaultSidebarItemsGenerator,
...getSidebarItemsGeneratorDocsAndVersion(),
options: sidebarOptions,
});
// TODO validate generated items: user can generate bad items
const generatedItemsNormalized = generatedItems.flatMap((generatedItem) =>
normalizeItem(generatedItem, {...params, ...sidebarOptions}),
);
// Process again... weird but sidebar item generated might generate some auto-generated items?
return processItems(generatedItemsNormalized);
}
async function processItem(
item: NormalizedSidebarItem,
): Promise<SidebarItem[]> {
if (item.type === 'category') {
return [
{
...item,
items: (
await Promise.all(item.items.map(handleAutoGeneratedItems))
).flat(),
},
];
return [await processCategoryItem(item)];
}
if (item.type === 'autogenerated') {
return sidebarItemsGenerator({
item,
numberPrefixParser,
defaultSidebarItemsGenerator: DefaultSidebarItemsGenerator,
...getSidebarItemsGeneratorDocsAndVersion(),
options,
});
return processAutoGeneratedItem(item);
}
return [item];
}
const processedSidebar = (
await Promise.all(unprocessedSidebar.map(handleAutoGeneratedItems))
).flat();
async function processItems(
items: NormalizedSidebarItem[],
): Promise<SidebarItem[]> {
return (await Promise.all(items.map(processItem))).flat();
}
const processedSidebar = await processItems(unprocessedSidebar);
const fixSidebarItemInconsistencies = (item: SidebarItem): SidebarItem => {
// A non-collapsible category can't be collapsed!
@ -114,11 +144,11 @@ async function processSidebar(
export async function processSidebars(
unprocessedSidebars: NormalizedSidebars,
props: SidebarProcessorProps,
params: SidebarProcessorParams,
): Promise<Sidebars> {
return combinePromises(
mapValues(unprocessedSidebars, (unprocessedSidebar) =>
processSidebar(unprocessedSidebar, props),
processSidebar(unprocessedSidebar, params),
),
);
}

View file

@ -12,6 +12,7 @@ import type {
NumberPrefixParser,
SidebarOptions,
} from '../types';
import {Required} from 'utility-types';
// Makes all properties visible when hovering over the type
type Expand<T extends Record<string, unknown>> = {[P in keyof T]: T[P]};
@ -45,10 +46,35 @@ type SidebarItemCategoryBase = SidebarItemBase & {
collapsible: boolean;
};
export type SidebarItemCategoryLinkDoc = {type: 'doc'; id: string};
export type SidebarItemCategoryLinkGeneratedIndexConfig = {
type: 'generated-index';
slug?: string;
title?: string;
description?: string;
};
export type SidebarItemCategoryLinkGeneratedIndex = {
type: 'generated-index';
slug: string;
permalink: string;
title?: string;
description?: string;
};
export type SidebarItemCategoryLinkConfig =
| SidebarItemCategoryLinkDoc
| SidebarItemCategoryLinkGeneratedIndexConfig;
export type SidebarItemCategoryLink =
| SidebarItemCategoryLinkDoc
| SidebarItemCategoryLinkGeneratedIndex;
// The user-given configuration in sidebars.js, before normalization
export type SidebarItemCategoryConfig = Expand<
Optional<SidebarItemCategoryBase, 'collapsed' | 'collapsible'> & {
items: SidebarItemConfig[];
link?: SidebarItemCategoryLinkConfig;
}
>;
@ -73,6 +99,7 @@ export type SidebarsConfig = {
export type NormalizedSidebarItemCategory = Expand<
SidebarItemCategoryBase & {
items: NormalizedSidebarItem[];
link?: SidebarItemCategoryLink;
}
>;
@ -90,14 +117,25 @@ export type NormalizedSidebars = {
export type SidebarItemCategory = Expand<
SidebarItemCategoryBase & {
items: SidebarItem[];
link?: SidebarItemCategoryLink;
}
>;
export type SidebarItemCategoryWithLink = Required<SidebarItemCategory, 'link'>;
export type SidebarItemCategoryWithGeneratedIndex =
SidebarItemCategoryWithLink & {link: SidebarItemCategoryLinkGeneratedIndex};
export type SidebarItem =
| SidebarItemDoc
| SidebarItemLink
| SidebarItemCategory;
// A sidebar item that is part of the previous/next ordered navigation
export type SidebarNavigationItem =
| SidebarItemDoc
| SidebarItemCategoryWithLink;
export type Sidebar = SidebarItem[];
export type SidebarItemType = SidebarItem['type'];
export type Sidebars = {
@ -108,21 +146,42 @@ export type Sidebars = {
export type PropSidebarItemCategory = Expand<
SidebarItemCategoryBase & {
items: PropSidebarItem[];
href?: string;
}
>;
export type PropSidebarItem = SidebarItemLink | PropSidebarItemCategory;
// we may want to use a union type in props instead of this generic link?
export type PropSidebarItemLink = SidebarItemLink & {
docId?: string;
};
export type PropSidebarItem = PropSidebarItemLink | PropSidebarItemCategory;
export type PropSidebar = PropSidebarItem[];
export type PropSidebars = {
[sidebarId: string]: PropSidebar;
};
export type PropVersionDoc = {
id: string;
title: string;
description?: string;
sidebar?: string;
};
export type PropVersionDocs = {
[docId: string]: PropVersionDoc;
};
// Reduce API surface for options.sidebarItemsGenerator
// The user-provided generator fn should receive only a subset of metadata
// A change to any of these metadata can be considered as a breaking change
export type SidebarItemsGeneratorDoc = Pick<
DocMetadataBase,
'id' | 'frontMatter' | 'source' | 'sourceDirName' | 'sidebarPosition'
| 'id'
| 'unversionedId'
| 'frontMatter'
| 'source'
| 'sourceDirName'
| 'sidebarPosition'
>;
export type SidebarItemsGeneratorVersion = Pick<
VersionMetadata,
@ -138,7 +197,9 @@ export type SidebarItemsGeneratorArgs = {
};
export type SidebarItemsGenerator = (
generatorArgs: SidebarItemsGeneratorArgs,
) => Promise<SidebarItem[]>;
) => // TODO TS issue: the generator can generate un-normalized items!
Promise<SidebarItem[]>;
// Promise<SidebarItemConfig[]>;
// 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

View file

@ -16,8 +16,15 @@ import type {
SidebarCategoriesShorthand,
SidebarItemConfig,
} from './types';
import {mapValues, difference} from 'lodash';
import {mapValues, difference, uniq} from 'lodash';
import {getElementsAround, toMessageRelativeFilePath} from '@docusaurus/utils';
import {DocMetadataBase, DocNavLink} from '../types';
import {
SidebarItemCategoryWithGeneratedIndex,
SidebarItemCategoryWithLink,
SidebarNavigationItem,
} from './types';
export function isCategoriesShorthand(
item: SidebarItemConfig,
@ -41,21 +48,24 @@ export function transformSidebarItems(
return sidebar.map(transformRecursive);
}
// Flatten sidebar items into a single flat array (containing categories/docs on the same level)
// /!\ order matters (useful for next/prev nav), top categories appear before their child elements
function flattenSidebarItems(items: SidebarItem[]): SidebarItem[] {
function flattenRecursive(item: SidebarItem): SidebarItem[] {
return item.type === 'category'
? [item, ...item.items.flatMap(flattenRecursive)]
: [item];
}
return items.flatMap(flattenRecursive);
}
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);
return flattenSidebarItems(sidebar).filter(
(item) => item.type === type,
) as Item[];
}
export function collectSidebarDocItems(sidebar: Sidebar): SidebarItemDoc[] {
@ -70,25 +80,72 @@ export function collectSidebarLinks(sidebar: Sidebar): SidebarItemLink[] {
return collectSidebarItemsOfType('link', sidebar);
}
// /!\ docId order matters for navigation!
export function collectSidebarDocIds(sidebar: Sidebar): string[] {
return flattenSidebarItems(sidebar).flatMap((item) => {
if (item.type === 'category') {
return item.link?.type === 'doc' ? [item.link.id] : [];
}
if (item.type === 'doc') {
return [item.id];
}
return [];
});
}
export function collectSidebarNavigation(
sidebar: Sidebar,
): SidebarNavigationItem[] {
return flattenSidebarItems(sidebar).flatMap((item) => {
if (item.type === 'category' && item.link) {
return [item as SidebarNavigationItem];
}
if (item.type === 'doc') {
return [item];
}
return [];
});
}
export function collectSidebarsDocIds(
sidebars: Sidebars,
): Record<string, string[]> {
return mapValues(sidebars, (sidebar) =>
collectSidebarDocItems(sidebar).map((docItem) => docItem.id),
);
return mapValues(sidebars, collectSidebarDocIds);
}
export function createSidebarsUtils(sidebars: Sidebars): {
export function collectSidebarsNavigations(
sidebars: Sidebars,
): Record<string, SidebarNavigationItem[]> {
return mapValues(sidebars, collectSidebarNavigation);
}
export type SidebarNavigation = {
sidebarName: string | undefined;
previous: SidebarNavigationItem | undefined;
next: SidebarNavigationItem | undefined;
};
// A convenient and performant way to query the sidebars content
export type SidebarsUtils = {
sidebars: Sidebars;
getFirstDocIdOfFirstSidebar: () => string | undefined;
getSidebarNameByDocId: (docId: string) => string | undefined;
getDocNavigation: (docId: string) => {
sidebarName: string | undefined;
previousId: string | undefined;
nextId: string | undefined;
};
getDocNavigation: (
unversionedId: string,
versionedId: string,
) => SidebarNavigation;
getCategoryGeneratedIndexList: () => SidebarItemCategoryWithGeneratedIndex[];
getCategoryGeneratedIndexNavigation: (
categoryGeneratedIndexPermalink: string,
) => SidebarNavigation;
checkSidebarsDocIds: (validDocIds: string[], sidebarFilePath: string) => void;
} {
};
export function createSidebarsUtils(sidebars: Sidebars): SidebarsUtils {
const sidebarNameToDocIds = collectSidebarsDocIds(sidebars);
const sidebarNameToNavigationItems = collectSidebarsNavigations(sidebars);
// Reverse mapping
const docIdToSidebarName = Object.fromEntries(
Object.entries(sidebarNameToDocIds).flatMap(([sidebarName, docIds]) =>
@ -104,27 +161,91 @@ export function createSidebarsUtils(sidebars: Sidebars): {
return docIdToSidebarName[docId];
}
function getDocNavigation(docId: string): {
sidebarName: string | undefined;
previousId: string | undefined;
nextId: string | undefined;
} {
const sidebarName = getSidebarNameByDocId(docId);
function emptySidebarNavigation(): SidebarNavigation {
return {
sidebarName: undefined,
previous: undefined,
next: undefined,
};
}
function getDocNavigation(
unversionedId: string,
versionedId: string,
): SidebarNavigation {
// TODO legacy id retro-compatibility!
let docId = unversionedId;
let sidebarName = getSidebarNameByDocId(docId);
if (!sidebarName) {
docId = versionedId;
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,
};
const navigationItems = sidebarNameToNavigationItems[sidebarName];
const currentItemIndex = navigationItems.findIndex((item) => {
if (item.type === 'doc') {
return item.id === docId;
}
if (item.type === 'category' && item.link.type === 'doc') {
return item.link.id === docId;
}
return false;
});
const {previous, next} = getElementsAround(
navigationItems,
currentItemIndex,
);
return {sidebarName, previous, next};
} else {
return {
sidebarName: undefined,
previousId: undefined,
nextId: undefined,
};
return emptySidebarNavigation();
}
}
function getCategoryGeneratedIndexList(): SidebarItemCategoryWithGeneratedIndex[] {
return Object.values(sidebarNameToNavigationItems)
.flat()
.flatMap((item) => {
if (item.type === 'category' && item.link.type === 'generated-index') {
return [item as SidebarItemCategoryWithGeneratedIndex];
}
return [];
});
}
// We identity the category generated index by its permalink (should be unique)
// More reliable than using object identity
function getCategoryGeneratedIndexNavigation(
categoryGeneratedIndexPermalink: string,
): SidebarNavigation {
function isCurrentCategoryGeneratedIndexItem(
item: SidebarNavigationItem,
): boolean {
return (
item.type === 'category' &&
item.link?.type === 'generated-index' &&
item.link.permalink === categoryGeneratedIndexPermalink
);
}
const sidebarName = Object.entries(sidebarNameToNavigationItems).find(
([, navigationItems]) =>
navigationItems.find(isCurrentCategoryGeneratedIndexItem),
)?.[0];
if (sidebarName) {
const navigationItems = sidebarNameToNavigationItems[sidebarName];
const currentItemIndex = navigationItems.findIndex(
isCurrentCategoryGeneratedIndexItem,
);
const {previous, next} = getElementsAround(
navigationItems,
currentItemIndex,
);
return {sidebarName, previous, next};
} else {
return emptySidebarNavigation();
}
}
@ -140,15 +261,69 @@ These sidebar document ids do not exist:
- ${invalidSidebarDocIds.sort().join('\n- ')}
Available document ids are:
- ${validDocIds.sort().join('\n- ')}`,
- ${uniq(validDocIds).sort().join('\n- ')}`,
);
}
}
return {
sidebars,
getFirstDocIdOfFirstSidebar,
getSidebarNameByDocId,
getDocNavigation,
getCategoryGeneratedIndexList,
getCategoryGeneratedIndexNavigation,
checkSidebarsDocIds,
};
}
export function toDocNavigationLink(doc: DocMetadataBase): DocNavLink {
const {
title,
permalink,
frontMatter: {
pagination_label: paginationLabel,
sidebar_label: sidebarLabel,
},
} = doc;
return {title: paginationLabel ?? sidebarLabel ?? title, permalink};
}
export function toNavigationLink(
navigationItem: SidebarNavigationItem | undefined,
docsById: Record<string, DocMetadataBase>,
): DocNavLink | undefined {
function getDocById(docId: string) {
const doc = docsById[docId];
if (!doc) {
throw new Error(
`Can't create navigation link: no doc found with id=${docId}`,
);
}
return doc;
}
function handleCategory(category: SidebarItemCategoryWithLink): DocNavLink {
if (category.link.type === 'doc') {
return toDocNavigationLink(getDocById(category.link.id));
} else if (category.link.type === 'generated-index') {
return {
title: category.label,
permalink: category.link.permalink,
};
} else {
throw new Error('unexpected category link type');
}
}
if (!navigationItem) {
return undefined;
}
if (navigationItem.type === 'doc') {
return toDocNavigationLink(getDocById(navigationItem.id));
} else if (navigationItem.type === 'category') {
return handleCategory(navigationItem);
} else {
throw new Error('unexpected navigation item');
}
}

View file

@ -14,9 +14,13 @@ import type {
SidebarItemDoc,
SidebarItemLink,
SidebarItemCategoryConfig,
SidebarItemCategoryLink,
SidebarsConfig,
SidebarItemCategoryLinkDoc,
SidebarItemCategoryLinkGeneratedIndex,
} from './types';
import {isCategoriesShorthand} from './utils';
import {CategoryMetadataFile} from './generator';
const sidebarItemBaseSchema = Joi.object<SidebarItemBase>({
className: Joi.string(),
@ -48,6 +52,36 @@ const sidebarItemLinkSchema = sidebarItemBaseSchema.append<SidebarItemLink>({
.messages({'any.unknown': '"label" must be a string'}),
});
const sidebarItemCategoryLinkSchema = Joi.object<SidebarItemCategoryLink>()
.when('.type', {
switch: [
{
is: 'doc',
then: Joi.object<SidebarItemCategoryLinkDoc>({
type: 'doc',
id: Joi.string().required(),
}),
},
{
is: 'generated-index',
then: Joi.object<SidebarItemCategoryLinkGeneratedIndex>({
type: 'generated-index',
slug: Joi.string().optional(),
// permalink: Joi.string().optional(), // No, this one is not in the user config, only in the normalized version
title: Joi.string().optional(),
description: Joi.string().optional(),
}),
},
{
is: Joi.string().required(),
then: Joi.forbidden().messages({
'any.unknown': 'Unknown sidebar category link type "{.type}".',
}),
},
],
})
.id('sidebarCategoryLinkSchema');
const sidebarItemCategorySchema =
sidebarItemBaseSchema.append<SidebarItemCategoryConfig>({
type: 'category',
@ -58,6 +92,7 @@ const sidebarItemCategorySchema =
items: Joi.array()
.required()
.messages({'any.unknown': '"items" must be an array'}), // .items(Joi.link('#sidebarItemSchema')),
link: sidebarItemCategoryLinkSchema,
collapsed: Joi.boolean().messages({
'any.unknown': '"collapsed" must be a boolean',
}),
@ -77,14 +112,7 @@ const sidebarItemSchema: Joi.Schema<SidebarItemConfig> = Joi.object()
{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(),
is: Joi.any().required(),
then: Joi.forbidden().messages({
'any.unknown': 'Unknown sidebar item type "{.type}".',
}),
@ -105,6 +133,7 @@ function validateSidebarItem(item: unknown): asserts item is SidebarItemConfig {
);
} else {
Joi.assert(item, sidebarItemSchema);
if ((item as SidebarItemCategoryConfig).type === 'category') {
(item as SidebarItemCategoryConfig).items.forEach(validateSidebarItem);
}
@ -122,3 +151,18 @@ export function validateSidebars(
}
});
}
const categoryMetadataFileSchema = Joi.object<CategoryMetadataFile>({
label: Joi.string(),
position: Joi.number(),
collapsed: Joi.boolean(),
collapsible: Joi.boolean(),
className: Joi.string(),
link: sidebarItemCategoryLinkSchema,
});
export function validateCategoryMetadataFile(
unsafeContent: unknown,
): CategoryMetadataFile {
return Joi.attempt(unsafeContent, categoryMetadataFileSchema);
}

View file

@ -15,39 +15,52 @@ import {
DefaultNumberPrefixParser,
stripPathNumberPrefixes,
} from './numberPrefix';
import type {NumberPrefixParser} from './types';
import type {DocMetadataBase, NumberPrefixParser} from './types';
import {isConventionalDocIndex} from './docs';
export default function getSlug({
baseID,
frontmatterSlug,
dirName,
source,
sourceDirName,
stripDirNumberPrefixes = true,
numberPrefixParser = DefaultNumberPrefixParser,
}: {
baseID: string;
frontmatterSlug?: string;
dirName: string;
source: DocMetadataBase['slug'];
sourceDirName: DocMetadataBase['sourceDirName'];
stripDirNumberPrefixes?: boolean;
numberPrefixParser?: NumberPrefixParser;
}): string {
const baseSlug = frontmatterSlug || baseID;
let slug: string;
if (baseSlug.startsWith('/')) {
slug = baseSlug;
} else {
function getDirNameSlug(): string {
const dirNameStripped = stripDirNumberPrefixes
? stripPathNumberPrefixes(dirName, numberPrefixParser)
: dirName;
? stripPathNumberPrefixes(sourceDirName, numberPrefixParser)
: sourceDirName;
const resolveDirname =
dirName === '.'
sourceDirName === '.'
? '/'
: addLeadingSlash(addTrailingSlash(dirNameStripped));
slug = resolvePathname(baseSlug, resolveDirname);
return resolveDirname;
}
if (!isValidPathname(slug)) {
throw new Error(
`We couldn't compute a valid slug for document with id "${baseID}" in "${dirName}" directory.
function computeSlug(): string {
if (frontmatterSlug?.startsWith('/')) {
return frontmatterSlug;
} else {
const dirNameSlug = getDirNameSlug();
if (!frontmatterSlug && isConventionalDocIndex({source, sourceDirName})) {
return dirNameSlug;
}
const baseSlug = frontmatterSlug || baseID;
return resolvePathname(baseSlug, getDirNameSlug());
}
}
function ensureValidSlug(slug: string): string {
if (!isValidPathname(slug)) {
throw new Error(
`We couldn't compute a valid slug for document with id "${baseID}" in "${sourceDirName}" directory.
The slug we computed looks invalid: ${slug}.
Maybe your slug frontmatter is incorrect or you use weird chars in the file path?
By using the slug frontmatter, you should be able to fix this error, by using the slug of your choice:
@ -57,8 +70,10 @@ Example =>
slug: /my/customDocPath
---
`,
);
);
}
return slug;
}
return slug;
return ensureValidSlug(computeSlug());
}

View file

@ -6,7 +6,12 @@
*/
import type {LoadedVersion, LoadedContent} from './types';
import type {Sidebar, Sidebars} from './sidebars/types';
import type {
Sidebar,
SidebarItemCategory,
SidebarItemCategoryLink,
Sidebars,
} from './sidebars/types';
import {chain, mapValues, keyBy} from 'lodash';
import {
@ -21,6 +26,7 @@ import type {
} from '@docusaurus/types';
import {mergeTranslations} from '@docusaurus/utils';
import {CURRENT_VERSION_NAME} from './constants';
import {TranslationMessage} from '@docusaurus/types';
function getVersionFileName(versionName: string): string {
if (versionName === CURRENT_VERSION_NAME) {
@ -96,14 +102,48 @@ function getSidebarTranslationFileContent(
sidebar: Sidebar,
sidebarName: string,
): TranslationFileContent {
type TranslationMessageEntry = [string, TranslationMessage];
const categories = collectSidebarCategories(sidebar);
const categoryContent: TranslationFileContent = chain(categories)
.keyBy((category) => `sidebar.${sidebarName}.category.${category.label}`)
.mapValues((category) => ({
message: category.label,
description: `The label for category ${category.label} in sidebar ${sidebarName}`,
}))
.value();
const categoryContent: TranslationFileContent = Object.fromEntries(
categories.flatMap((category) => {
const entries: TranslationMessageEntry[] = [];
entries.push([
`sidebar.${sidebarName}.category.${category.label}`,
{
message: category.label,
description: `The label for category ${category.label} in sidebar ${sidebarName}`,
},
]);
if (category.link) {
if (category.link.type === 'generated-index') {
if (category.link.title) {
entries.push([
`sidebar.${sidebarName}.category.${category.label}.link.generated-index.title`,
{
message: category.link.title,
description: `The generated-index page title for category ${category.label} in sidebar ${sidebarName}`,
},
]);
}
if (category.link.description) {
entries.push([
`sidebar.${sidebarName}.category.${category.label}.link.generated-index.description`,
{
message: category.link.description,
description: `The generated-index page description for category ${category.label} in sidebar ${sidebarName}`,
},
]);
}
}
}
return entries;
}),
);
const links = collectSidebarLinks(sidebar);
const linksContent: TranslationFileContent = chain(links)
@ -126,13 +166,39 @@ function translateSidebar({
sidebarName: string;
sidebarsTranslations: TranslationFileContent;
}): Sidebar {
function transformSidebarCategoryLink(
category: SidebarItemCategory,
): SidebarItemCategoryLink | undefined {
if (!category.link) {
return undefined;
}
if (category.link.type === 'generated-index') {
const title =
sidebarsTranslations[
`sidebar.${sidebarName}.category.${category.label}.link.generated-index.title`
]?.message ?? category.link.title;
const description =
sidebarsTranslations[
`sidebar.${sidebarName}.category.${category.label}.link.generated-index.description`
]?.message ?? category.link.description;
return {
...category.link,
title,
description,
};
}
return category.link;
}
return transformSidebarItems(sidebar, (item) => {
if (item.type === 'category') {
const link = transformSidebarCategoryLink(item);
return {
...item,
label:
sidebarsTranslations[`sidebar.${sidebarName}.category.${item.label}`]
?.message ?? item.label,
...(link && {link}),
};
}
if (item.type === 'link') {

View file

@ -8,7 +8,7 @@
/// <reference types="@docusaurus/module-type-aliases" />
import type {RemarkAndRehypePluginOptions} from '@docusaurus/mdx-loader';
import type {Tag, FrontMatterTag} from '@docusaurus/utils';
import type {Tag, FrontMatterTag, Slugger} from '@docusaurus/utils';
import type {
BrokenMarkdownLink as IBrokenMarkdownLink,
ContentPaths,
@ -86,6 +86,11 @@ export type SidebarOptions = {
sidebarCollapsed: boolean;
};
export type NormalizeSidebarsParams = SidebarOptions & {
version: VersionMetadata;
categoryLabelSlugger: Slugger;
};
export type PluginOptions = MetadataOptions &
PathOptions &
VersionsOptions &
@ -98,6 +103,7 @@ export type PluginOptions = MetadataOptions &
docItemComponent: string;
docTagDocListComponent: string;
docTagsListComponent: string;
docCategoryGeneratedIndexComponent: string;
admonitions: Record<string, unknown>;
disableVersioning: boolean;
includeCurrentVersion: boolean;
@ -135,14 +141,14 @@ export type DocFrontMatter = {
};
export type DocMetadataBase = LastUpdateData & {
id: string; // TODO legacy versioned id => try to remove
unversionedId: string; // TODO new unversioned id => try to rename to "id"
version: VersionName;
unversionedId: string;
id: string;
isDocsHomePage: boolean;
title: string;
description: string;
source: string;
sourceDirName: string; // relative to the docs folder (can be ".")
source: string; // @site aliased source => "@site/docs/folder/subFolder/subSubFolder/myDoc.md"
sourceDirName: string; // relative to the versioned docs folder (can be ".") => "folder/subFolder/subSubFolder"
slug: string;
permalink: string;
sidebarPosition?: number;
@ -162,6 +168,16 @@ export type DocMetadata = DocMetadataBase & {
next?: DocNavLink;
};
export type CategoryGeneratedIndexMetadata = {
title: string;
description?: string;
slug: string;
permalink: string;
sidebar: string;
previous?: DocNavLink;
next?: DocNavLink;
};
export type SourceToPermalink = {
[source: string]: string;
};
@ -180,6 +196,7 @@ export type LoadedVersion = VersionMetadata & {
mainDocId: string;
docs: DocMetadata[];
sidebars: Sidebars;
categoryGeneratedIndices: CategoryGeneratedIndexMetadata[];
};
export type LoadedContent = {