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

@ -167,7 +167,6 @@ module.exports = {
'head',
'tail',
'initial',
'last',
],
message: 'These APIs have their ES counterparts.',
},

View file

@ -28,7 +28,6 @@
"escape-html": "^1.0.3",
"file-loader": "^6.2.0",
"fs-extra": "^10.0.0",
"github-slugger": "^1.4.0",
"gray-matter": "^4.0.3",
"mdast-util-to-string": "^2.0.0",
"remark-emoji": "^2.1.0",
@ -40,7 +39,6 @@
"devDependencies": {
"@docusaurus/types": "2.0.0-beta.9",
"@types/escape-html": "^1.0.1",
"@types/github-slugger": "^1.3.0",
"@types/mdast": "^3.0.7",
"@types/stringify-object": "^3.3.1",
"@types/unist": "^2.0.6",

View file

@ -7,19 +7,16 @@
/* Based on remark-slug (https://github.com/remarkjs/remark-slug) and gatsby-remark-autolink-headers (https://github.com/gatsbyjs/gatsby/blob/master/packages/gatsby-remark-autolink-headers) */
import {parseMarkdownHeadingId} from '@docusaurus/utils';
import {parseMarkdownHeadingId, createSlugger} from '@docusaurus/utils';
import visit, {Visitor} from 'unist-util-visit';
import toString from 'mdast-util-to-string';
import Slugger from 'github-slugger';
import type {Transformer} from 'unified';
import type {Parent} from 'unist';
import type {Heading, Text} from 'mdast';
const slugs = new Slugger();
function headings(): Transformer {
const transformer: Transformer = (ast) => {
slugs.reset();
const slugs = createSlugger();
const visitor: Visitor<Heading> = (headingNode) => {
const data = headingNode.data || (headingNode.data = {});
@ -29,7 +26,7 @@ function headings(): Transformer {
let {id} = properties;
if (id) {
id = slugs.slug(id, true);
id = slugs.slug(id, {maintainCase: true});
} else {
const headingTextNodes = headingNode.children.filter(
({type}) => !['html', 'jsx'].includes(type),

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 = {

View file

@ -37,7 +37,7 @@
"clsx": "^1.1.1",
"copy-text-to-clipboard": "^3.0.1",
"globby": "^11.0.2",
"infima": "0.2.0-alpha.34",
"infima": "0.2.0-alpha.36",
"lodash": "^4.17.20",
"postcss": "^8.3.7",
"prism-react-renderer": "^1.2.1",

View file

@ -107,11 +107,12 @@ declare module '@theme/CodeBlock' {
}
declare module '@theme/DocPaginator' {
import type {PropNavigation} from '@docusaurus/plugin-content-docs';
type PageInfo = {readonly permalink: string; readonly title: string};
export interface Props {
readonly metadata: {readonly previous?: PageInfo; readonly next?: PageInfo};
}
// May be simpler to provide a {navigation: PropNavigation} prop?
export interface Props extends PropNavigation {}
const DocPaginator: (props: Props) => JSX.Element;
export default DocPaginator;
@ -138,7 +139,7 @@ declare module '@theme/DocSidebarItem' {
type DocSidebarPropsBase = {
readonly activePath: string;
readonly onItemClick?: () => void;
readonly onItemClick?: (item: PropSidebarItem) => void;
readonly level: number;
readonly tabIndex?: number;
};

View file

@ -0,0 +1,100 @@
/**
* 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 React, {ReactNode} from 'react';
import Link from '@docusaurus/Link';
import {
PropSidebarItemCategory,
PropSidebarItemLink,
} from '@docusaurus/plugin-content-docs';
import type {Props} from '@theme/DocCard';
import {findFirstCategoryLink, useDocById} from '@docusaurus/theme-common';
import clsx from 'clsx';
import styles from './styles.module.css';
import isInternalUrl from '@docusaurus/isInternalUrl';
function CardContainer({
href,
children,
}: {
href?: string;
children: ReactNode;
}): JSX.Element {
const className = clsx(
'card margin-bottom--lg padding--lg',
styles.cardContainer,
href && styles.cardContainerLink,
);
return href ? (
<Link href={href} className={className}>
{children}
</Link>
) : (
<div className={className}>{children}</div>
);
}
function CardLayout({
href,
icon,
title,
description,
}: {
href?: string;
icon: ReactNode;
title: string;
description?: string;
}): JSX.Element {
return (
<CardContainer href={href}>
<h2 className={clsx('text--truncate', styles.cardTitle)} title={title}>
{icon} {title}
</h2>
<div
className={clsx('text--truncate', styles.cardDescription)}
title={description}>
{description}
</div>
</CardContainer>
);
}
function CardCategory({item}: {item: PropSidebarItemCategory}): JSX.Element {
const href = findFirstCategoryLink(item);
return (
<CardLayout
href={href}
icon="🗃️"
title={item.label}
description={`${item.items.length} items`}
/>
);
}
function CardLink({item}: {item: PropSidebarItemLink}): JSX.Element {
const icon = isInternalUrl(item.href) ? '📄️' : '🔗';
const doc = useDocById(item.docId ?? undefined);
return (
<CardLayout
href={item.href}
icon={icon}
title={item.label}
description={doc?.description}
/>
);
}
export default function DocCard({item}: Props): JSX.Element {
switch (item.type) {
case 'link':
return <CardLink item={item} />;
case 'category':
return <CardCategory item={item} />;
default:
throw new Error(`unknown item type ${JSON.stringify(item)}`);
}
}

View file

@ -0,0 +1,43 @@
/**
* 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.
*/
.cardContainer {
height: 8rem;
color: var(--ifm-color-emphasis-800);
--ifm-link-color: var(--ifm-color-emphasis-800);
--ifm-link-hover-color: var(--ifm-color-emphasis-800);
--ifm-link-hover-decoration: none;
/* box-shadow: var(--ifm-global-shadow-lw); */
box-shadow: 0 1.5px 3px 0 rgba(0, 0, 0, 0.15);
border: 1px solid var(--ifm-color-emphasis-200);
transition: box-shadow var(--ifm-transition-fast) ease,
background-color var(--ifm-transition-fast) ease;
}
.cardContainer.cardContainerLink:hover {
/* box-shadow: var(--ifm-global-shadow-md); */
box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2);
}
html[data-theme='dark'] .cardContainer.cardContainerLink:hover {
--ifm-card-background-color: #2d2d2d; /* original, non-hovered color is #242526 */
}
.cardContainer:not(.cardContainerLink) {
cursor: not-allowed;
}
.cardTitle {
font-size: 1.2rem;
min-height: 1.2rem;
}
.cardDescription {
font-size: 0.8rem;
min-height: 0.8rem;
}

View file

@ -0,0 +1,27 @@
/**
* 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 React from 'react';
import DocCard from '@theme/DocCard';
import {PropSidebarItem} from '@docusaurus/plugin-content-docs';
export default function DocCardList({
items,
}: {
items: PropSidebarItem[];
}): JSX.Element {
return (
<div className="row">
{items.map((item, index) => (
<article key={index} className="col col--6">
<DocCard item={item} />
</article>
))}
</div>
);
}

View file

@ -0,0 +1,53 @@
/**
* 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 React from 'react';
import {useCurrentSidebarCategory} from '@docusaurus/theme-common';
import type {Props} from '@theme/DocCategoryGeneratedIndexPage';
import DocCardList from '@theme/DocCardList';
import DocPaginator from '@theme/DocPaginator';
import Seo from '@theme/Seo';
import DocVersionBanner from '@theme/DocVersionBanner';
import DocVersionBadge from '@theme/DocVersionBadge';
import {MainHeading} from '@theme/Heading';
import styles from './styles.module.css';
export default function DocCategoryGeneratedIndexPage({
categoryGeneratedIndex,
}: Props): JSX.Element {
const category = useCurrentSidebarCategory();
return (
<>
<Seo
title={categoryGeneratedIndex.title}
description={categoryGeneratedIndex.description}
/>
<div className={styles.generatedIndexPage}>
<DocVersionBanner />
<DocVersionBadge />
<header>
<MainHeading className={styles.title}>
{categoryGeneratedIndex.title}
</MainHeading>
{categoryGeneratedIndex.description && (
<p>{categoryGeneratedIndex.description}</p>
)}
</header>
<main className="margin-top--lg">
<DocCardList items={category.items} />
</main>
<footer className="margin-top--lg">
<DocPaginator
previous={categoryGeneratedIndex.navigation.previous}
next={categoryGeneratedIndex.navigation.next}
/>
</footer>
</div>
</>
);
}

View file

@ -0,0 +1,18 @@
/**
* 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.
*/
@media (min-width: 997px) {
.generatedIndexPage {
max-width: 75% !important;
}
}
/* Duplicated from .markdown h1 */
.title {
--ifm-h1-font-size: 3rem;
margin-bottom: calc(1.25 * var(--ifm-leading));
}

View file

@ -10,6 +10,7 @@ import clsx from 'clsx';
import useWindowSize from '@theme/hooks/useWindowSize';
import DocPaginator from '@theme/DocPaginator';
import DocVersionBanner from '@theme/DocVersionBanner';
import DocVersionBadge from '@theme/DocVersionBadge';
import Seo from '@theme/Seo';
import type {Props} from '@theme/DocItem';
import DocItemFooter from '@theme/DocItemFooter';
@ -20,7 +21,7 @@ import styles from './styles.module.css';
import {ThemeClassNames} from '@docusaurus/theme-common';
export default function DocItem(props: Props): JSX.Element {
const {content: DocContent, versionMetadata} = props;
const {content: DocContent} = props;
const {metadata, frontMatter} = DocContent;
const {
image,
@ -55,18 +56,10 @@ export default function DocItem(props: Props): JSX.Element {
className={clsx('col', {
[styles.docItemCol]: !hideTableOfContents,
})}>
<DocVersionBanner versionMetadata={versionMetadata} />
<DocVersionBanner />
<div className={styles.docItemContainer}>
<article>
{versionMetadata.badge && (
<span
className={clsx(
ThemeClassNames.docs.docVersionBadge,
'badge badge--secondary',
)}>
Version: {versionMetadata.label}
</span>
)}
<DocVersionBadge />
{canRenderTOC && (
<TOCCollapsible
@ -95,7 +88,7 @@ export default function DocItem(props: Props): JSX.Element {
<DocItemFooter {...props} />
</article>
<DocPaginator metadata={metadata} />
<DocPaginator previous={metadata.previous} next={metadata.next} />
</div>
</div>
{renderTocDesktop && (

View file

@ -23,27 +23,30 @@ import {translate} from '@docusaurus/Translate';
import clsx from 'clsx';
import styles from './styles.module.css';
import {ThemeClassNames, docVersionSearchTag} from '@docusaurus/theme-common';
import {
ThemeClassNames,
docVersionSearchTag,
DocsSidebarProvider,
useDocsSidebar,
DocsVersionProvider,
} from '@docusaurus/theme-common';
import Head from '@docusaurus/Head';
type DocPageContentProps = {
readonly currentDocRoute: DocumentRoute;
readonly versionMetadata: PropVersionMetadata;
readonly children: ReactNode;
readonly sidebarName: string | undefined;
};
function DocPageContent({
currentDocRoute,
versionMetadata,
children,
sidebarName,
}: DocPageContentProps): JSX.Element {
const sidebar = useDocsSidebar();
const {pluginId, version} = versionMetadata;
const sidebarName = currentDocRoute.sidebar;
const sidebar = sidebarName
? versionMetadata.docsSidebars[sidebarName]
: undefined;
const [hiddenSidebarContainer, setHiddenSidebarContainer] = useState(false);
const [hiddenSidebar, setHiddenSidebar] = useState(false);
const toggleSidebar = useCallback(() => {
@ -150,17 +153,30 @@ function DocPage(props: Props): JSX.Element {
if (!currentDocRoute) {
return <NotFound />;
}
// For now, the sidebarName is added as route config: not ideal!
const sidebarName = currentDocRoute.sidebar;
const sidebar = sidebarName
? versionMetadata.docsSidebars[sidebarName]
: null;
return (
<>
<Head>
{/* TODO we should add a core addRoute({htmlClassName}) generic plugin option */}
<html className={versionMetadata.className} />
</Head>
<DocPageContent
currentDocRoute={currentDocRoute}
versionMetadata={versionMetadata}>
{renderRoutes(docRoutes, {versionMetadata})}
</DocPageContent>
<DocsVersionProvider version={versionMetadata}>
<DocsSidebarProvider sidebar={sidebar}>
<DocPageContent
currentDocRoute={currentDocRoute}
versionMetadata={versionMetadata}
sidebarName={sidebarName}>
{renderRoutes(docRoutes, {versionMetadata})}
</DocPageContent>
</DocsSidebarProvider>
</DocsVersionProvider>
</>
);
}

View file

@ -8,10 +8,10 @@
import React from 'react';
import Link from '@docusaurus/Link';
import Translate, {translate} from '@docusaurus/Translate';
import type {Props} from '@theme/DocPaginator';
import type {PropNavigation} from '@docusaurus/plugin-content-docs';
function DocPaginator(props: Props): JSX.Element {
const {metadata} = props;
function DocPaginator(props: PropNavigation): JSX.Element {
const {previous, next} = props;
return (
<nav
@ -22,10 +22,8 @@ function DocPaginator(props: Props): JSX.Element {
description: 'The ARIA label for the docs pagination',
})}>
<div className="pagination-nav__item">
{metadata.previous && (
<Link
className="pagination-nav__link"
to={metadata.previous.permalink}>
{previous && (
<Link className="pagination-nav__link" to={previous.permalink}>
<div className="pagination-nav__sublabel">
<Translate
id="theme.docs.paginator.previous"
@ -34,14 +32,14 @@ function DocPaginator(props: Props): JSX.Element {
</Translate>
</div>
<div className="pagination-nav__label">
&laquo; {metadata.previous.title}
&laquo; {previous.title}
</div>
</Link>
)}
</div>
<div className="pagination-nav__item pagination-nav__item--next">
{metadata.next && (
<Link className="pagination-nav__link" to={metadata.next.permalink}>
{next && (
<Link className="pagination-nav__link" to={next.permalink}>
<div className="pagination-nav__sublabel">
<Translate
id="theme.docs.paginator.next"
@ -49,9 +47,7 @@ function DocPaginator(props: Props): JSX.Element {
Next
</Translate>
</div>
<div className="pagination-nav__label">
{metadata.next.title} &raquo;
</div>
<div className="pagination-nav__label">{next.title} &raquo;</div>
</Link>
)}
</div>

View file

@ -100,7 +100,15 @@ const DocSidebarMobileSecondaryMenu: MobileSecondaryMenuComponent<Props> = ({
<DocSidebarItems
items={sidebar}
activePath={path}
onItemClick={() => toggleSidebar()}
onItemClick={(item) => {
// Mobile sidebar should only be closed if the category has a link
if (item.type === 'category' && item.href) {
toggleSidebar();
}
if (item.type === 'link') {
toggleSidebar();
}
}}
level={1}
/>
</ul>

View file

@ -5,17 +5,19 @@
* LICENSE file in the root directory of this source tree.
*/
import React, {useEffect, memo} from 'react';
import React, {useEffect, memo, useMemo} from 'react';
import clsx from 'clsx';
import {
isSamePath,
isActiveSidebarItem,
usePrevious,
Collapsible,
useCollapsible,
findFirstCategoryLink,
ThemeClassNames,
} from '@docusaurus/theme-common';
import Link from '@docusaurus/Link';
import isInternalUrl from '@docusaurus/isInternalUrl';
import {translate} from '@docusaurus/Translate';
import IconExternalLink from '@theme/IconExternalLink';
import type {Props, DocSidebarItemsProps} from '@theme/DocSidebarItem';
@ -25,21 +27,7 @@ import type {
} from '@docusaurus/plugin-content-docs';
import styles from './styles.module.css';
const isActiveSidebarItem = (
item: Props['item'],
activePath: string,
): boolean => {
if (item.type === 'link') {
return isSamePath(item.href, activePath);
}
if (item.type === 'category') {
return item.items.some((subItem) =>
isActiveSidebarItem(subItem, activePath),
);
}
return false;
};
import useIsBrowser from '@docusaurus/useIsBrowser';
// Optimize sidebar at each "level"
// TODO this item should probably not receive the "activePath" props
@ -93,6 +81,28 @@ function useAutoExpandActiveCategory({
}, [isActive, wasActive, collapsed, setCollapsed]);
}
// When a collapsible category has no link, we still link it to its first child during SSR as a temporary fallback
// This allows to be able to navigate inside the category even when JS fails to load, is delayed or simply disabled
// React hydration becomes an optional progressive enhancement
// see https://github.com/facebookincubator/infima/issues/36#issuecomment-772543188
// see https://github.com/facebook/docusaurus/issues/3030
function useCategoryHrefWithSSRFallback(
item: PropSidebarItemCategory,
): string | undefined {
const isBrowser = useIsBrowser();
return useMemo(() => {
if (item.href) {
return item.href;
}
// In these cases, it's not necessary to render a fallback
// We skip the "findFirstCategoryLink" computation
if (isBrowser || !item.collapsible) {
return undefined;
}
return findFirstCategoryLink(item);
}, [item, isBrowser]);
}
function DocSidebarItemCategory({
item,
onItemClick,
@ -100,7 +110,8 @@ function DocSidebarItemCategory({
level,
...props
}: Props & {item: PropSidebarItemCategory}) {
const {items, label, collapsible, className} = item;
const {items, label, collapsible, className, href} = item;
const hrefWithSSRFallback = useCategoryHrefWithSSRFallback(item);
const isActive = isActiveSidebarItem(item, activePath);
@ -128,25 +139,53 @@ function DocSidebarItemCategory({
},
className,
)}>
{/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
<a
className={clsx('menu__link', {
'menu__link--sublist': collapsible,
'menu__link--active': collapsible && isActive,
[styles.menuLinkText]: !collapsible,
})}
onClick={
collapsible
? (e) => {
e.preventDefault();
toggleCollapsed();
}
: undefined
}
href={collapsible ? '#' : undefined}
{...props}>
{label}
</a>
<div className="menu__list-item-collapsible">
<Link
className={clsx('menu__link', {
'menu__link--sublist': collapsible && !href,
'menu__link--active': isActive,
[styles.menuLinkText]: !collapsible,
[styles.hasHref]: !!hrefWithSSRFallback,
})}
onClick={
collapsible
? (e) => {
onItemClick?.(item);
if (href) {
setCollapsed(false);
} else {
e.preventDefault();
toggleCollapsed();
}
}
: () => {
onItemClick?.(item);
}
}
href={collapsible ? hrefWithSSRFallback ?? '#' : hrefWithSSRFallback}
{...props}>
{label}
</Link>
{href && collapsible && (
<button
aria-label={translate(
{
id: 'theme.DocSidebarItem.toggleCollapsedCategoryAriaLabel',
message: "Toggle the collapsible sidebar category '{label}'",
description:
'The ARIA label to toggle the collapsible sidebar category',
},
{label},
)}
type="button"
className="clean-btn menu__caret"
onClick={(e) => {
e.preventDefault();
toggleCollapsed();
}}
/>
)}
</div>
<Collapsible lazy as="ul" className="menu__list" collapsed={collapsed}>
<DocSidebarItems
@ -186,7 +225,7 @@ function DocSidebarItemLink({
aria-current={isActive ? 'page' : undefined}
to={href}
{...(isInternalUrl(href) && {
onClick: onItemClick,
onClick: onItemClick ? () => onItemClick(item) : undefined,
})}
{...props}>
{isInternalUrl(href) ? (

View file

@ -12,4 +12,8 @@
.menuLinkText:hover {
background: none;
}
.menuLinkText.hasHref {
cursor: pointer;
}
}

View file

@ -0,0 +1,30 @@
/**
* 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 React from 'react';
import {ThemeClassNames, useDocsVersion} from '@docusaurus/theme-common';
import clsx from 'clsx';
import type {Props} from '@theme/DocVersionBadge';
export default function DocVersionBadge({
className,
}: Props): JSX.Element | null {
const versionMetadata = useDocsVersion();
if (versionMetadata.badge) {
return (
<span
className={clsx(
className,
ThemeClassNames.docs.docVersionBadge,
'badge badge--secondary',
)}>
Version: {versionMetadata.label}
</span>
);
}
return null;
}

View file

@ -17,15 +17,19 @@ import {
import {
ThemeClassNames,
useDocsPreferredVersion,
useDocsVersion,
} from '@docusaurus/theme-common';
import type {Props} from '@theme/DocVersionBanner';
import clsx from 'clsx';
import type {VersionBanner} from '@docusaurus/plugin-content-docs';
import type {
VersionBanner,
PropVersionMetadata,
} from '@docusaurus/plugin-content-docs';
type BannerLabelComponentProps = {
siteTitle: string;
versionMetadata: Props['versionMetadata'];
versionMetadata: PropVersionMetadata;
};
function UnreleasedVersionLabel({
@ -114,7 +118,12 @@ function LatestVersionSuggestionLabel({
);
}
function DocVersionBannerEnabled({versionMetadata}: Props): JSX.Element {
function DocVersionBannerEnabled({
className,
versionMetadata,
}: Props & {
versionMetadata: PropVersionMetadata;
}): JSX.Element {
const {
siteConfig: {title: siteTitle},
} = useDocusaurusContext();
@ -136,6 +145,7 @@ function DocVersionBannerEnabled({versionMetadata}: Props): JSX.Element {
return (
<div
className={clsx(
className,
ThemeClassNames.docs.docVersionBanner,
'alert alert--warning margin-bottom--md',
)}
@ -154,11 +164,17 @@ function DocVersionBannerEnabled({versionMetadata}: Props): JSX.Element {
);
}
function DocVersionBanner({versionMetadata}: Props): JSX.Element | null {
export default function DocVersionBanner({
className,
}: Props): JSX.Element | null {
const versionMetadata = useDocsVersion();
if (versionMetadata.banner) {
return <DocVersionBannerEnabled versionMetadata={versionMetadata} />;
return (
<DocVersionBannerEnabled
className={className}
versionMetadata={versionMetadata}
/>
);
}
return null;
}
export default DocVersionBanner;

View file

@ -31,6 +31,7 @@
"devDependencies": {
"@docusaurus/module-type-aliases": "2.0.0-beta.9",
"@docusaurus/types": "2.0.0-beta.9",
"@testing-library/react-hooks": "^7.0.2",
"lodash": "^4.17.20"
},
"peerDependencies": {

View file

@ -32,7 +32,18 @@ export {
export {docVersionSearchTag, DEFAULT_SEARCH_TAG} from './utils/searchUtils';
export {isDocsPluginEnabled} from './utils/docsUtils';
export {
isDocsPluginEnabled,
DocsVersionProvider,
useDocsVersion,
useDocById,
DocsSidebarProvider,
useDocsSidebar,
findSidebarCategory,
findFirstCategoryLink,
useCurrentSidebarCategory,
isActiveSidebarItem,
} from './utils/docsUtils';
export {isSamePath} from './utils/pathUtils';

View file

@ -0,0 +1,331 @@
/**
* 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 React from 'react';
import {renderHook} from '@testing-library/react-hooks';
import {
findFirstCategoryLink,
isActiveSidebarItem,
DocsVersionProvider,
useDocsVersion,
useDocById,
useDocsSidebar,
DocsSidebarProvider,
findSidebarCategory,
} from '../docsUtils';
import {
PropSidebar,
PropSidebarItem,
PropSidebarItemCategory,
PropVersionMetadata,
} from '@docusaurus/plugin-content-docs';
// Make tests more readable with some useful category item defaults
function testCategory(
data?: Partial<PropSidebarItemCategory>,
): PropSidebarItemCategory {
return {
type: 'category',
href: undefined,
label: 'Category label',
items: [],
collapsed: true,
collapsible: true,
...data,
};
}
function testVersion(data?: Partial<PropVersionMetadata>): PropVersionMetadata {
return {
version: 'versionName',
label: 'Version Label',
className: 'version className',
badge: true,
banner: 'unreleased',
docs: {},
docsSidebars: {},
isLast: false,
pluginId: 'default',
...data,
};
}
describe('docsUtils', () => {
describe('useDocsVersion', () => {
test('should throw if context provider is missing', () => {
expect(
() => renderHook(() => useDocsVersion()).result.current,
).toThrowErrorMatchingInlineSnapshot(
`"This hook requires usage of <DocsVersionProvider>"`,
);
});
test('should read value from context provider', () => {
const version = testVersion();
const {result} = renderHook(() => useDocsVersion(), {
wrapper: ({children}) => (
<DocsVersionProvider version={version}>
{children}
</DocsVersionProvider>
),
});
expect(result.current).toBe(version);
});
});
describe('useDocsSidebar', () => {
test('should throw if context provider is missing', () => {
expect(
() => renderHook(() => useDocsSidebar()).result.current,
).toThrowErrorMatchingInlineSnapshot(
`"This hook requires usage of <DocsSidebarProvider>"`,
);
});
test('should read value from context provider', () => {
const sidebar: PropSidebar = [];
const {result} = renderHook(() => useDocsSidebar(), {
wrapper: ({children}) => (
<DocsSidebarProvider sidebar={sidebar}>
{children}
</DocsSidebarProvider>
),
});
expect(result.current).toBe(sidebar);
});
});
describe('useDocById', () => {
const version = testVersion({
docs: {
doc1: {
id: 'doc1',
title: 'Doc 1',
description: 'desc1',
sidebar: 'sidebar1',
},
doc2: {
id: 'doc2',
title: 'Doc 2',
description: 'desc2',
sidebar: 'sidebar2',
},
},
});
function callHook(docId: string | undefined) {
const {result} = renderHook(() => useDocById(docId), {
wrapper: ({children}) => (
<DocsVersionProvider version={version}>
{children}
</DocsVersionProvider>
),
});
return result.current;
}
test('should accept undefined', () => {
expect(callHook(undefined)).toBeUndefined();
});
test('should find doc1', () => {
expect(callHook('doc1')).toMatchObject({id: 'doc1'});
});
test('should find doc2', () => {
expect(callHook('doc2')).toMatchObject({id: 'doc2'});
});
test('should throw for doc3', () => {
expect(() => callHook('doc3')).toThrowErrorMatchingInlineSnapshot(
`"no version doc found by id=doc3"`,
);
});
});
describe('findSidebarCategory', () => {
test('should be able to return undefined', () => {
expect(findSidebarCategory([], () => false)).toBeUndefined();
expect(
findSidebarCategory([testCategory(), testCategory()], () => false),
).toBeUndefined();
});
test('should return first element matching predicate', () => {
const first = testCategory();
const second = testCategory();
const third = testCategory();
const sidebar = [first, second, third];
expect(findSidebarCategory(sidebar, () => true)).toEqual(first);
expect(findSidebarCategory(sidebar, (item) => item === first)).toEqual(
first,
);
expect(findSidebarCategory(sidebar, (item) => item === second)).toEqual(
second,
);
expect(findSidebarCategory(sidebar, (item) => item === third)).toEqual(
third,
);
});
test('should be able to search in sub items', () => {
const subsub1 = testCategory();
const subsub2 = testCategory();
const sub1 = testCategory({
items: [subsub1, subsub2],
});
const sub2 = testCategory();
const parent = testCategory({
items: [sub1, sub2],
});
const sidebar = [parent];
expect(findSidebarCategory(sidebar, () => true)).toEqual(parent);
expect(findSidebarCategory(sidebar, (item) => item === sub1)).toEqual(
sub1,
);
expect(findSidebarCategory(sidebar, (item) => item === sub2)).toEqual(
sub2,
);
expect(findSidebarCategory(sidebar, (item) => item === subsub1)).toEqual(
subsub1,
);
expect(findSidebarCategory(sidebar, (item) => item === subsub2)).toEqual(
subsub2,
);
});
});
describe('findFirstCategoryLink', () => {
test('category without link nor child', () => {
expect(
findFirstCategoryLink(
testCategory({
href: undefined,
}),
),
).toEqual(undefined);
});
test('category with link', () => {
expect(
findFirstCategoryLink(
testCategory({
href: '/itemPath',
}),
),
).toEqual('/itemPath');
});
test('category with deeply nested category link', () => {
expect(
findFirstCategoryLink(
testCategory({
href: undefined,
items: [
testCategory({
href: undefined,
items: [
testCategory({
href: '/itemPath',
}),
],
}),
],
}),
),
).toEqual('/itemPath');
});
test('category with deeply nested link', () => {
expect(
findFirstCategoryLink(
testCategory({
href: undefined,
items: [
testCategory({
href: undefined,
items: [{type: 'link', href: '/itemPath', label: 'Label'}],
}),
],
}),
),
).toEqual('/itemPath');
});
});
describe('isActiveSidebarItem', () => {
test('with link href', () => {
const item: PropSidebarItem = {
type: 'link',
href: '/itemPath',
label: 'Label',
};
expect(isActiveSidebarItem(item, '/unexistingPath')).toEqual(false);
expect(isActiveSidebarItem(item, '/itemPath')).toEqual(true);
// Ensure it's not trailing slash sensitive:
expect(isActiveSidebarItem(item, '/itemPath/')).toEqual(true);
expect(
isActiveSidebarItem({...item, href: '/itemPath/'}, '/itemPath'),
).toEqual(true);
});
test('with category href', () => {
const item: PropSidebarItem = testCategory({
href: '/itemPath',
});
expect(isActiveSidebarItem(item, '/unexistingPath')).toEqual(false);
expect(isActiveSidebarItem(item, '/itemPath')).toEqual(true);
// Ensure it's not trailing slash sensitive:
expect(isActiveSidebarItem(item, '/itemPath/')).toEqual(true);
expect(
isActiveSidebarItem({...item, href: '/itemPath/'}, '/itemPath'),
).toEqual(true);
});
test('with category nested items', () => {
const item: PropSidebarItem = testCategory({
href: '/category-path',
items: [
{
type: 'link',
href: '/sub-link-path',
label: 'Label',
},
testCategory({
href: '/sub-category-path',
items: [
{
type: 'link',
href: '/sub-sub-link-path',
label: 'Label',
},
],
}),
],
});
expect(isActiveSidebarItem(item, '/unexistingPath')).toEqual(false);
expect(isActiveSidebarItem(item, '/category-path')).toEqual(true);
expect(isActiveSidebarItem(item, '/sub-link-path')).toEqual(true);
expect(isActiveSidebarItem(item, '/sub-category-path')).toEqual(true);
expect(isActiveSidebarItem(item, '/sub-sub-link-path')).toEqual(true);
// Ensure it's not trailing slash sensitive:
expect(isActiveSidebarItem(item, '/category-path/')).toEqual(true);
expect(isActiveSidebarItem(item, '/sub-link-path/')).toEqual(true);
expect(isActiveSidebarItem(item, '/sub-category-path/')).toEqual(true);
expect(isActiveSidebarItem(item, '/sub-sub-link-path/')).toEqual(true);
});
});
});

View file

@ -1,11 +0,0 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import {useAllDocsData} from '@theme/hooks/useDocs';
// TODO not ideal, see also "useDocs"
export const isDocsPluginEnabled: boolean = !!useAllDocsData;

View file

@ -0,0 +1,185 @@
/**
* 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 React, {createContext, ReactNode, useContext} from 'react';
import {useAllDocsData} from '@theme/hooks/useDocs';
import {
PropSidebar,
PropSidebarItem,
PropSidebarItemCategory,
PropVersionDoc,
PropVersionMetadata,
} from '@docusaurus/plugin-content-docs';
import {isSamePath} from './pathUtils';
import {useLocation} from '@docusaurus/router';
// TODO not ideal, see also "useDocs"
export const isDocsPluginEnabled: boolean = !!useAllDocsData;
// Using a Symbol because null is a valid context value (a doc can have no sidebar)
// Inspired by https://github.com/jamiebuilds/unstated-next/blob/master/src/unstated-next.tsx
const EmptyContextValue: unique symbol = Symbol('EmptyContext');
const DocsVersionContext = createContext<
PropVersionMetadata | typeof EmptyContextValue
>(EmptyContextValue);
export function DocsVersionProvider({
children,
version,
}: {
children: ReactNode;
version: PropVersionMetadata | typeof EmptyContextValue;
}): JSX.Element {
return (
<DocsVersionContext.Provider value={version}>
{children}
</DocsVersionContext.Provider>
);
}
export function useDocsVersion(): PropVersionMetadata {
const version = useContext(DocsVersionContext);
if (version === EmptyContextValue) {
throw new Error('This hook requires usage of <DocsVersionProvider>');
}
return version;
}
export function useDocById(id: string): PropVersionDoc;
export function useDocById(id: string | undefined): PropVersionDoc | undefined;
export function useDocById(id: string | undefined): PropVersionDoc | undefined {
const version = useDocsVersion();
if (!id) {
return undefined;
}
const doc = version.docs[id];
if (!doc) {
throw new Error(`no version doc found by id=${id}`);
}
return doc;
}
const DocsSidebarContext = createContext<
PropSidebar | null | typeof EmptyContextValue
>(EmptyContextValue);
export function DocsSidebarProvider({
children,
sidebar,
}: {
children: ReactNode;
sidebar: PropSidebar | null;
}): JSX.Element {
return (
<DocsSidebarContext.Provider value={sidebar}>
{children}
</DocsSidebarContext.Provider>
);
}
export function useDocsSidebar(): PropSidebar | null {
const sidebar = useContext(DocsSidebarContext);
if (sidebar === EmptyContextValue) {
throw new Error('This hook requires usage of <DocsSidebarProvider>');
}
return sidebar;
}
// Use the components props and the sidebar in context
// to get back the related sidebar category that we want to render
export function findSidebarCategory(
sidebar: PropSidebar,
predicate: (category: PropSidebarItemCategory) => boolean,
): PropSidebarItemCategory | undefined {
// eslint-disable-next-line no-restricted-syntax
for (const item of sidebar) {
if (item.type === 'category') {
if (predicate(item)) {
return item;
} else {
const subItem = findSidebarCategory(item.items, predicate);
if (subItem) {
return subItem;
}
}
}
}
return undefined;
}
// If a category card has no link => link to the first subItem having a link
export function findFirstCategoryLink(
item: PropSidebarItemCategory,
): string | undefined {
if (item.href) {
return item.href;
}
// eslint-disable-next-line no-restricted-syntax
for (const subItem of item.items) {
if (subItem.type === 'link') {
return subItem.href;
}
if (subItem.type === 'category') {
const categoryLink = findFirstCategoryLink(subItem);
if (categoryLink) {
return categoryLink;
}
} else {
throw new Error(
`Unexpected category item type for ${JSON.stringify(subItem)}`,
);
}
}
return undefined;
}
export function useCurrentSidebarCategory(): PropSidebarItemCategory {
const {pathname} = useLocation();
const sidebar = useDocsSidebar();
if (!sidebar) {
throw new Error('Unexpected: cant find current sidebar in context');
}
const category = findSidebarCategory(sidebar, (item) =>
isSamePath(item.href, pathname),
);
if (!category) {
throw new Error(
`Unexpected: sidebar category could not be found for pathname='${pathname}'.
Hook useCurrentSidebarCategory() should only be used on Category pages`,
);
}
return category;
}
function containsActiveSidebarItem(
items: PropSidebarItem[],
activePath: string,
): boolean {
return items.some((subItem) => isActiveSidebarItem(subItem, activePath));
}
export function isActiveSidebarItem(
item: PropSidebarItem,
activePath: string,
): boolean {
const isActive = (testedPath: string | undefined) =>
typeof testedPath !== 'undefined' && isSamePath(testedPath, activePath);
if (item.type === 'link') {
return isActive(item.href);
}
if (item.type === 'category') {
return (
isActive(item.href) || containsActiveSidebarItem(item.items, activePath)
);
}
return false;
}

View file

@ -4,6 +4,7 @@
"theme.CodeBlock.copied": "تم النسخ",
"theme.CodeBlock.copy": "نسخ",
"theme.CodeBlock.copyButtonAriaLabel": "نسخ الرمز إلى الحافظة",
"theme.DocSidebarItem.toggleCollapsedCategoryAriaLabel": "Toggle the collapsible sidebar category '{label}'",
"theme.ErrorPageContent.title": "This page crashed.",
"theme.ErrorPageContent.tryAgain": "Try again",
"theme.NotFound.p1": "لم نتمكن من العثور على ما كنت تبحث عنه.",

View file

@ -9,6 +9,8 @@
"theme.CodeBlock.copy___DESCRIPTION": "The copy button label on code blocks",
"theme.CodeBlock.copyButtonAriaLabel": "Copy code to clipboard",
"theme.CodeBlock.copyButtonAriaLabel___DESCRIPTION": "The ARIA label for copy code blocks button",
"theme.DocSidebarItem.toggleCollapsedCategoryAriaLabel": "Toggle the collapsible sidebar category '{label}'",
"theme.DocSidebarItem.toggleCollapsedCategoryAriaLabel___DESCRIPTION": "The ARIA label to toggle the collapsible sidebar category",
"theme.ErrorPageContent.title": "This page crashed.",
"theme.ErrorPageContent.title___DESCRIPTION": "The title of the fallback page when the page crashed",
"theme.ErrorPageContent.tryAgain": "Try again",

View file

@ -4,6 +4,7 @@
"theme.CodeBlock.copied": "কপিড",
"theme.CodeBlock.copy": "কপি",
"theme.CodeBlock.copyButtonAriaLabel": "ক্লিপবোর্ডে কোড কপি করুন",
"theme.DocSidebarItem.toggleCollapsedCategoryAriaLabel": "Toggle the collapsible sidebar category '{label}'",
"theme.ErrorPageContent.title": "This page crashed.",
"theme.ErrorPageContent.tryAgain": "Try again",
"theme.NotFound.p1": "আপনি যা খুঁজছিলেন তা আমরা খুঁজে পাইনি।",

View file

@ -4,6 +4,7 @@
"theme.CodeBlock.copied": "Zkopírováno",
"theme.CodeBlock.copy": "Zkopírovat",
"theme.CodeBlock.copyButtonAriaLabel": "Zkopírovat kód do schránky",
"theme.DocSidebarItem.toggleCollapsedCategoryAriaLabel": "Toggle the collapsible sidebar category '{label}'",
"theme.ErrorPageContent.title": "This page crashed.",
"theme.ErrorPageContent.tryAgain": "Try again",
"theme.NotFound.p1": "Nepodařilo se nám najít co jste hledal(a).",

View file

@ -4,6 +4,7 @@
"theme.CodeBlock.copied": "Kopieret",
"theme.CodeBlock.copy": "Kopier",
"theme.CodeBlock.copyButtonAriaLabel": "Kopier kode til udklipsholder",
"theme.DocSidebarItem.toggleCollapsedCategoryAriaLabel": "Toggle the collapsible sidebar category '{label}'",
"theme.ErrorPageContent.title": "This page crashed.",
"theme.ErrorPageContent.tryAgain": "Try again",
"theme.NotFound.p1": "Vi kunne ikke finde det, du søgte.",

View file

@ -4,6 +4,7 @@
"theme.CodeBlock.copied": "Kopiert",
"theme.CodeBlock.copy": "Kopieren",
"theme.CodeBlock.copyButtonAriaLabel": "In die Zwischenablage kopieren",
"theme.DocSidebarItem.toggleCollapsedCategoryAriaLabel": "Toggle the collapsible sidebar category '{label}'",
"theme.ErrorPageContent.title": "This page crashed.",
"theme.ErrorPageContent.tryAgain": "Try again",
"theme.NotFound.p1": "Wir konnten nicht finden, wonach Sie gesucht haben.",

View file

@ -4,6 +4,7 @@
"theme.CodeBlock.copied": "Copiado",
"theme.CodeBlock.copy": "Copiar",
"theme.CodeBlock.copyButtonAriaLabel": "Copiar código al portapapeles",
"theme.DocSidebarItem.toggleCollapsedCategoryAriaLabel": "Toggle the collapsible sidebar category '{label}'",
"theme.ErrorPageContent.title": "This page crashed.",
"theme.ErrorPageContent.tryAgain": "Try again",
"theme.NotFound.p1": "No pudimos encontrar lo que buscaba.",

View file

@ -4,6 +4,7 @@
"theme.CodeBlock.copied": "کپی شد",
"theme.CodeBlock.copy": "کپی",
"theme.CodeBlock.copyButtonAriaLabel": "کپی به کلیپ بورد",
"theme.DocSidebarItem.toggleCollapsedCategoryAriaLabel": "Toggle the collapsible sidebar category '{label}'",
"theme.ErrorPageContent.title": "This page crashed.",
"theme.ErrorPageContent.tryAgain": "Try again",
"theme.NotFound.p1": "صفحه ای که دنبال آن بودید پیدا نشد.",

View file

@ -4,6 +4,7 @@
"theme.CodeBlock.copied": "Kinopya",
"theme.CodeBlock.copy": "Kopyahin",
"theme.CodeBlock.copyButtonAriaLabel": "Kopyahin ang code sa clipboard",
"theme.DocSidebarItem.toggleCollapsedCategoryAriaLabel": "Toggle the collapsible sidebar category '{label}'",
"theme.ErrorPageContent.title": "This page crashed.",
"theme.ErrorPageContent.tryAgain": "Try again",
"theme.NotFound.p1": "Hindi namin mahanap ang iyong hinananap.",

View file

@ -4,6 +4,7 @@
"theme.CodeBlock.copied": "Copié",
"theme.CodeBlock.copy": "Copier",
"theme.CodeBlock.copyButtonAriaLabel": "Copier le code",
"theme.DocSidebarItem.toggleCollapsedCategoryAriaLabel": "Toggle the collapsible sidebar category '{label}'",
"theme.ErrorPageContent.title": "Cette page a planté.",
"theme.ErrorPageContent.tryAgain": "Réessayer",
"theme.NotFound.p1": "Nous n'avons pas trouvé ce que vous recherchez.",

View file

@ -4,6 +4,7 @@
"theme.CodeBlock.copied": "הועתק",
"theme.CodeBlock.copy": "העתק",
"theme.CodeBlock.copyButtonAriaLabel": "העתק קוד ללוח העריכה",
"theme.DocSidebarItem.toggleCollapsedCategoryAriaLabel": "Toggle the collapsible sidebar category '{label}'",
"theme.ErrorPageContent.title": "This page crashed.",
"theme.ErrorPageContent.tryAgain": "Try again",
"theme.NotFound.p1": "אנחנו לא מוצאים את מה שאתה מנסה לחפש.",

View file

@ -4,6 +4,7 @@
"theme.CodeBlock.copied": "कॉपीड",
"theme.CodeBlock.copy": "कॉपी",
"theme.CodeBlock.copyButtonAriaLabel": "क्लिपबोर्ड पर कोड कॉपी करें",
"theme.DocSidebarItem.toggleCollapsedCategoryAriaLabel": "Toggle the collapsible sidebar category '{label}'",
"theme.ErrorPageContent.title": "This page crashed.",
"theme.ErrorPageContent.tryAgain": "Try again",
"theme.NotFound.p1": "हमें वह नहीं मिला, जिसकी आपको तलाश थी।",

View file

@ -4,6 +4,7 @@
"theme.CodeBlock.copied": "コピーしました",
"theme.CodeBlock.copy": "コピー",
"theme.CodeBlock.copyButtonAriaLabel": "クリップボードにコードをコピー",
"theme.DocSidebarItem.toggleCollapsedCategoryAriaLabel": "Toggle the collapsible sidebar category '{label}'",
"theme.ErrorPageContent.title": "This page crashed.",
"theme.ErrorPageContent.tryAgain": "Try again",
"theme.NotFound.p1": "お探しのページが見つかりませんでした。",

View file

@ -4,6 +4,7 @@
"theme.CodeBlock.copied": "복사했습니다",
"theme.CodeBlock.copy": "복사",
"theme.CodeBlock.copyButtonAriaLabel": "클립보드에 코드 복사",
"theme.DocSidebarItem.toggleCollapsedCategoryAriaLabel": "Toggle the collapsible sidebar category '{label}'",
"theme.ErrorPageContent.title": "This page crashed.",
"theme.ErrorPageContent.tryAgain": "Try again",
"theme.NotFound.p1": "원하는 페이지를 찾을 수 없습니다.",

View file

@ -4,6 +4,7 @@
"theme.CodeBlock.copied": "Skopiowano!",
"theme.CodeBlock.copy": "Kopiuj",
"theme.CodeBlock.copyButtonAriaLabel": "Kopiuj do schowka",
"theme.DocSidebarItem.toggleCollapsedCategoryAriaLabel": "Toggle the collapsible sidebar category '{label}'",
"theme.ErrorPageContent.title": "This page crashed.",
"theme.ErrorPageContent.tryAgain": "Try again",
"theme.NotFound.p1": "Nie mogliśmy znaleźć strony której szukasz.",

View file

@ -4,6 +4,7 @@
"theme.CodeBlock.copied": "Copiado",
"theme.CodeBlock.copy": "Copiar",
"theme.CodeBlock.copyButtonAriaLabel": "Copiar código para a área de transferência",
"theme.DocSidebarItem.toggleCollapsedCategoryAriaLabel": "Toggle the collapsible sidebar category '{label}'",
"theme.ErrorPageContent.title": "This page crashed.",
"theme.ErrorPageContent.tryAgain": "Try again",
"theme.NotFound.p1": "Não foi possível encontrar o que você está procurando.",

View file

@ -4,6 +4,7 @@
"theme.CodeBlock.copied": "Copiado",
"theme.CodeBlock.copy": "Copiar",
"theme.CodeBlock.copyButtonAriaLabel": "Copiar código para a área de transferência",
"theme.DocSidebarItem.toggleCollapsedCategoryAriaLabel": "Toggle the collapsible sidebar category '{label}'",
"theme.ErrorPageContent.title": "This page crashed.",
"theme.ErrorPageContent.tryAgain": "Try again",
"theme.NotFound.p1": "Não foi possível encontrar o que procura.",

View file

@ -4,6 +4,7 @@
"theme.CodeBlock.copied": "Скопировано",
"theme.CodeBlock.copy": "Скопировать",
"theme.CodeBlock.copyButtonAriaLabel": "Скопировать в буфер обмена",
"theme.DocSidebarItem.toggleCollapsedCategoryAriaLabel": "Toggle the collapsible sidebar category '{label}'",
"theme.ErrorPageContent.title": "This page crashed.",
"theme.ErrorPageContent.tryAgain": "Try again",
"theme.NotFound.p1": "К сожалению, мы не смогли найти запрашиваемую вами страницу.",

View file

@ -4,6 +4,7 @@
"theme.CodeBlock.copied": "Копирано",
"theme.CodeBlock.copy": "Копирај",
"theme.CodeBlock.copyButtonAriaLabel": "Копирај код у меморију",
"theme.DocSidebarItem.toggleCollapsedCategoryAriaLabel": "Toggle the collapsible sidebar category '{label}'",
"theme.ErrorPageContent.title": "This page crashed.",
"theme.ErrorPageContent.tryAgain": "Try again",
"theme.NotFound.p1": "Тражени резултат не постоји.",

View file

@ -4,6 +4,7 @@
"theme.CodeBlock.copied": "Kopyalandı",
"theme.CodeBlock.copy": "Kopyala",
"theme.CodeBlock.copyButtonAriaLabel": "Kodu panoya kopyala",
"theme.DocSidebarItem.toggleCollapsedCategoryAriaLabel": "Toggle the collapsible sidebar category '{label}'",
"theme.ErrorPageContent.title": "This page crashed.",
"theme.ErrorPageContent.tryAgain": "Try again",
"theme.NotFound.p1": "Aradığınız şeyi bulamadık.",

View file

@ -4,6 +4,7 @@
"theme.CodeBlock.copied": "Đã sao chép",
"theme.CodeBlock.copy": "Sao chép",
"theme.CodeBlock.copyButtonAriaLabel": "Sao chép code vào bộ nhớ tạm",
"theme.DocSidebarItem.toggleCollapsedCategoryAriaLabel": "Toggle the collapsible sidebar category '{label}'",
"theme.ErrorPageContent.title": "This page crashed.",
"theme.ErrorPageContent.tryAgain": "Try again",
"theme.NotFound.p1": "Chúng tôi không thể tìm thấy những gì bạn đang tìm kiếm.",

View file

@ -4,6 +4,7 @@
"theme.CodeBlock.copied": "复制成功",
"theme.CodeBlock.copy": "复制",
"theme.CodeBlock.copyButtonAriaLabel": "复制代码到剪贴板",
"theme.DocSidebarItem.toggleCollapsedCategoryAriaLabel": "Toggle the collapsible sidebar category '{label}'",
"theme.ErrorPageContent.title": "页面已崩溃。",
"theme.ErrorPageContent.tryAgain": "重试",
"theme.NotFound.p1": "我们找不到您要找的页面。",

View file

@ -4,6 +4,7 @@
"theme.CodeBlock.copied": "複製成功",
"theme.CodeBlock.copy": "複製",
"theme.CodeBlock.copyButtonAriaLabel": "複製代碼至剪貼簿",
"theme.DocSidebarItem.toggleCollapsedCategoryAriaLabel": "Toggle the collapsible sidebar category '{label}'",
"theme.ErrorPageContent.title": "頁面已崩潰。",
"theme.ErrorPageContent.tryAgain": "重試",
"theme.NotFound.p1": "我們找不到您要找的頁面。",

View file

@ -23,6 +23,7 @@
"chalk": "^4.1.2",
"escape-string-regexp": "^4.0.0",
"fs-extra": "^10.0.0",
"github-slugger": "^1.4.0",
"globby": "^11.0.4",
"gray-matter": "^4.0.3",
"lodash": "^4.17.20",
@ -37,6 +38,7 @@
},
"devDependencies": {
"@types/dedent": "^0.7.0",
"@types/github-slugger": "^1.3.0",
"@types/micromatch": "^4.0.2",
"@types/react-dom": "^17.0.1",
"dedent": "^0.7.0"

View file

@ -0,0 +1,27 @@
/**
* 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 {createSlugger} from '../slugger';
describe('createSlugger', () => {
test('can create unique slugs', () => {
const slugger = createSlugger();
expect(slugger.slug('Some$/vaLue$!^')).toEqual('somevalue');
expect(slugger.slug('Some$/vaLue$!^')).toEqual('somevalue-1');
expect(slugger.slug('Some$/vaLue$!^')).toEqual('somevalue-2');
expect(slugger.slug('Some$/vaLue$!^-1')).toEqual('somevalue-1-1');
});
test('can create unique slugs respecting case', () => {
const slugger = createSlugger();
const opt = {maintainCase: true};
expect(slugger.slug('Some$/vaLue$!^', opt)).toEqual('SomevaLue');
expect(slugger.slug('Some$/vaLue$!^', opt)).toEqual('SomevaLue-1');
expect(slugger.slug('Some$/vaLue$!^', opt)).toEqual('SomevaLue-2');
expect(slugger.slug('Some$/vaLue$!^-1', opt)).toEqual('SomevaLue-1-1');
});
});

View file

@ -33,6 +33,7 @@ export const posixPath = posixPathImport;
export * from './markdownParser';
export * from './markdownLinks';
export * from './escapePath';
export * from './slugger';
export {md5Hash, simpleHash, docuHash} from './hashUtils';
export {
Globby,

View file

@ -0,0 +1,24 @@
/**
* 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 GithubSlugger from 'github-slugger';
// We create our own abstraction on top of the lib:
// - unify usage everywhere in the codebase
// - ability to add extra options
export type SluggerOptions = {maintainCase?: boolean};
export type Slugger = {
slug: (value: string, options?: SluggerOptions) => string;
};
export function createSlugger(): Slugger {
const githubSlugger = new GithubSlugger();
return {
slug: (value, options) => githubSlugger.slug(value, options?.maintainCase),
};
}

View file

@ -36,7 +36,6 @@
"@types/copy-webpack-plugin": "^8.0.1",
"@types/css-minimizer-webpack-plugin": "^3.0.2",
"@types/detect-port": "^1.3.0",
"@types/github-slugger": "^1.3.0",
"@types/mini-css-extract-plugin": "^1.4.3",
"@types/nprogress": "^0.2.0",
"@types/react-dom": "^17.0.9",
@ -82,7 +81,6 @@
"eta": "^1.12.3",
"file-loader": "^6.2.0",
"fs-extra": "^10.0.0",
"github-slugger": "^1.4.0",
"globby": "^11.0.2",
"html-minifier-terser": "^6.0.2",
"html-tags": "^3.1.0",

View file

@ -9,51 +9,51 @@ import {
transformMarkdownHeadingLine,
transformMarkdownContent,
} from '../writeHeadingIds';
import GithubSlugger from 'github-slugger';
import {createSlugger} from '@docusaurus/utils';
describe('transformMarkdownHeadingLine', () => {
test('throws when not a heading', () => {
expect(() =>
transformMarkdownHeadingLine('ABC', new GithubSlugger()),
transformMarkdownHeadingLine('ABC', createSlugger()),
).toThrowErrorMatchingInlineSnapshot(
`"Line is not a Markdown heading: ABC."`,
);
});
test('works for simple level-2 heading', () => {
expect(transformMarkdownHeadingLine('## ABC', new GithubSlugger())).toEqual(
expect(transformMarkdownHeadingLine('## ABC', createSlugger())).toEqual(
'## ABC {#abc}',
);
});
test('works for simple level-3 heading', () => {
expect(
transformMarkdownHeadingLine('### ABC', new GithubSlugger()),
).toEqual('### ABC {#abc}');
expect(transformMarkdownHeadingLine('### ABC', createSlugger())).toEqual(
'### ABC {#abc}',
);
});
test('works for simple level-4 heading', () => {
expect(
transformMarkdownHeadingLine('#### ABC', new GithubSlugger()),
).toEqual('#### ABC {#abc}');
expect(transformMarkdownHeadingLine('#### ABC', createSlugger())).toEqual(
'#### ABC {#abc}',
);
});
test('works for simple level-2 heading', () => {
expect(transformMarkdownHeadingLine('## ABC', new GithubSlugger())).toEqual(
expect(transformMarkdownHeadingLine('## ABC', createSlugger())).toEqual(
'## ABC {#abc}',
);
});
test('unwraps markdown links', () => {
const input = `## hello [facebook](https://facebook.com) [crowdin](https://crowdin.com/translate/docusaurus-v2/126/en-fr?filter=basic&value=0)`;
expect(transformMarkdownHeadingLine(input, new GithubSlugger())).toEqual(
expect(transformMarkdownHeadingLine(input, createSlugger())).toEqual(
`${input} {#hello-facebook-crowdin}`,
);
});
test('can slugify complex headings', () => {
const input = '## abc [Hello] How are you %Sébastien_-_$)( ## -56756';
expect(transformMarkdownHeadingLine(input, new GithubSlugger())).toEqual(
expect(transformMarkdownHeadingLine(input, createSlugger())).toEqual(
`${input} {#abc-hello-how-are-you-sébastien_-_---56756}`,
);
});
@ -62,7 +62,7 @@ describe('transformMarkdownHeadingLine', () => {
expect(
transformMarkdownHeadingLine(
'## hello world {#hello-world}',
new GithubSlugger(),
createSlugger(),
),
).toEqual('## hello world {#hello-world}');
});
@ -71,7 +71,7 @@ describe('transformMarkdownHeadingLine', () => {
expect(
transformMarkdownHeadingLine(
'## New heading {#old-heading}',
new GithubSlugger(),
createSlugger(),
),
).toEqual('## New heading {#old-heading}');
});
@ -80,7 +80,7 @@ describe('transformMarkdownHeadingLine', () => {
expect(
transformMarkdownHeadingLine(
'## New heading {#old-heading}',
new GithubSlugger(),
createSlugger(),
{overwrite: true},
),
).toEqual('## New heading {#new-heading}');
@ -88,7 +88,7 @@ describe('transformMarkdownHeadingLine', () => {
test('maintains casing when asked to', () => {
expect(
transformMarkdownHeadingLine('## getDataFromAPI()', new GithubSlugger(), {
transformMarkdownHeadingLine('## getDataFromAPI()', createSlugger(), {
maintainCase: true,
}),
).toEqual('## getDataFromAPI() {#getDataFromAPI}');

View file

@ -6,12 +6,15 @@
*/
import fs from 'fs-extra';
import GithubSlugger from 'github-slugger';
import chalk from 'chalk';
import {loadContext, loadPluginConfigs} from '../server';
import initPlugins from '../server/plugins/init';
import {parseMarkdownHeadingId} from '@docusaurus/utils';
import {
parseMarkdownHeadingId,
createSlugger,
Slugger,
} from '@docusaurus/utils';
import {safeGlobby} from '../server/utils';
type Options = {
@ -25,7 +28,7 @@ function unwrapMarkdownLinks(line: string): string {
function addHeadingId(
line: string,
slugger: GithubSlugger,
slugger: Slugger,
maintainCase: boolean,
): string {
let headingLevel = 0;
@ -36,7 +39,7 @@ function addHeadingId(
const headingText = line.slice(headingLevel).trimEnd();
const headingHashes = line.slice(0, headingLevel);
const slug = slugger
.slug(unwrapMarkdownLinks(headingText).trim(), maintainCase)
.slug(unwrapMarkdownLinks(headingText).trim(), {maintainCase})
.replace(/^-+/, '')
.replace(/-+$/, '');
@ -45,7 +48,7 @@ function addHeadingId(
export function transformMarkdownHeadingLine(
line: string,
slugger: GithubSlugger,
slugger: Slugger,
options: Options = {maintainCase: false, overwrite: false},
): string {
const {maintainCase = false, overwrite = false} = options;
@ -64,7 +67,7 @@ export function transformMarkdownHeadingLine(
function transformMarkdownLine(
line: string,
slugger: GithubSlugger,
slugger: Slugger,
options?: Options,
): string {
// Ignore h1 headings on purpose, as we don't create anchor links for those
@ -77,7 +80,7 @@ function transformMarkdownLine(
function transformMarkdownLines(lines: string[], options?: Options): string[] {
let inCode = false;
const slugger = new GithubSlugger();
const slugger = createSlugger();
return lines.map((line) => {
if (line.startsWith('```')) {

View file

@ -0,0 +1,7 @@
{
"label": "Category Links",
"link": {
"type": "generated-index",
"slug": "/category-links-generated-index-slug"
}
}

View file

@ -0,0 +1,3 @@
# Readme
This `readme.md` should not be used as the category index due to the `_category_.json` link

View file

@ -0,0 +1,3 @@
# Sample doc
Lorem Ipsum

View file

@ -0,0 +1,3 @@
# Sample doc
Lorem Ipsum

View file

@ -0,0 +1,22 @@
# Category with a doc of category's name
You should be able to click on the category and browse this `<categoryName>/<categoryName>.md` doc
## Intro
Some intro text
:::tip
It is also possible to render the card items for that category thanks to MDX:
:::
## Category content
```mdx-code-block
import DocCardList from '@theme/DocCardList';
import {useCurrentSidebarCategory} from '@docusaurus/theme-common';
<DocCardList items={useCurrentSidebarCategory().items}/>
```

View file

@ -0,0 +1,3 @@
# Category with index.md doc
You should be able to click on the category and browse this `index.md` doc

View file

@ -0,0 +1,3 @@
# Sample doc
Lorem Ipsum

View file

@ -0,0 +1,3 @@
# Category with readme.md doc
You should be able to click on the category and browse this `readme.md` doc

View file

@ -0,0 +1,3 @@
# Sample doc
Lorem Ipsum

View file

@ -5,7 +5,8 @@
* LICENSE file in the root directory of this source tree.
*/
module.exports = {
/** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */
const sidebars = {
sidebar: [
{
type: 'doc',
@ -16,11 +17,19 @@ module.exports = {
{
type: 'category',
label: 'Tests',
link: {
type: 'generated-index',
},
items: [
{
type: 'autogenerated',
dirName: 'tests',
},
{
type: 'link',
label: 'External Link test',
href: 'https://docusaurus.io',
},
],
},
{
@ -40,7 +49,7 @@ module.exports = {
label: 'Test Test test test test test test',
},
],
...generateHugeSidebarItems(4),
...generateHugeSidebarItems(),
],
},
{
@ -62,6 +71,7 @@ module.exports = {
},
],
};
module.exports = sidebars;
function generateHugeSidebarItems() {
const linksCount = 8;
@ -87,5 +97,5 @@ function generateHugeSidebarItems() {
return [...linkItems, ...categoryItems];
}
return generateRecursive(4);
return generateRecursive(3);
}

View file

@ -1,2 +1,5 @@
label: Plugins
position: 2
link:
type: doc
id: api/plugins/plugins-overview # Dogfood using a "qualified id"

View file

@ -48,6 +48,7 @@ Accepted fields:
| `docItemComponent` | `string` | `'@theme/DocItem'` | Main doc container, with TOC, pagination, etc. |
| `docTagsListComponent` | `string` | `'@theme/DocTagsListPage'` | Root component of the tags list page |
| `docTagDocListComponent` | `string` | `'@theme/DocTagDocListPage'` | Root component of the "docs containing tag" page. |
| `docCategoryGeneratedIndexComponent` | `string` | `'@theme/DocCategoryGeneratedIndexPage'` | Root component of the generated category index page. |
| `remarkPlugins` | `any[]` | `[]` | Remark plugins passed to MDX. |
| `rehypePlugins` | `any[]` | `[]` | Rehype plugins passed to MDX. |
| `beforeDefaultRemarkPlugins` | `any[]` | `[]` | Custom Remark plugins passed to MDX before the default Docusaurus Remark plugins. |

View file

@ -1,2 +1,5 @@
label: Themes
position: 3
link:
type: doc
id: themes-overview # Dogfood using a "local id"

View file

@ -2,6 +2,7 @@
sidebar_position: 1
id: theme-configuration
title: 'Theme configuration'
sidebar_label: 'Configuration'
slug: '/api/themes/configuration'
toc_max_heading_level: 4
---

Some files were not shown because too many files have changed in this diff Show more