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', 'head',
'tail', 'tail',
'initial', 'initial',
'last',
], ],
message: 'These APIs have their ES counterparts.', message: 'These APIs have their ES counterparts.',
}, },

View file

@ -28,7 +28,6 @@
"escape-html": "^1.0.3", "escape-html": "^1.0.3",
"file-loader": "^6.2.0", "file-loader": "^6.2.0",
"fs-extra": "^10.0.0", "fs-extra": "^10.0.0",
"github-slugger": "^1.4.0",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"mdast-util-to-string": "^2.0.0", "mdast-util-to-string": "^2.0.0",
"remark-emoji": "^2.1.0", "remark-emoji": "^2.1.0",
@ -40,7 +39,6 @@
"devDependencies": { "devDependencies": {
"@docusaurus/types": "2.0.0-beta.9", "@docusaurus/types": "2.0.0-beta.9",
"@types/escape-html": "^1.0.1", "@types/escape-html": "^1.0.1",
"@types/github-slugger": "^1.3.0",
"@types/mdast": "^3.0.7", "@types/mdast": "^3.0.7",
"@types/stringify-object": "^3.3.1", "@types/stringify-object": "^3.3.1",
"@types/unist": "^2.0.6", "@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) */ /* 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 visit, {Visitor} from 'unist-util-visit';
import toString from 'mdast-util-to-string'; import toString from 'mdast-util-to-string';
import Slugger from 'github-slugger';
import type {Transformer} from 'unified'; import type {Transformer} from 'unified';
import type {Parent} from 'unist'; import type {Parent} from 'unist';
import type {Heading, Text} from 'mdast'; import type {Heading, Text} from 'mdast';
const slugs = new Slugger();
function headings(): Transformer { function headings(): Transformer {
const transformer: Transformer = (ast) => { const transformer: Transformer = (ast) => {
slugs.reset(); const slugs = createSlugger();
const visitor: Visitor<Heading> = (headingNode) => { const visitor: Visitor<Heading> = (headingNode) => {
const data = headingNode.data || (headingNode.data = {}); const data = headingNode.data || (headingNode.data = {});
@ -29,7 +26,7 @@ function headings(): Transformer {
let {id} = properties; let {id} = properties;
if (id) { if (id) {
id = slugs.slug(id, true); id = slugs.slug(id, {maintainCase: true});
} else { } else {
const headingTextNodes = headingNode.children.filter( const headingTextNodes = headingNode.children.filter(
({type}) => !['html', 'jsx'].includes(type), ({type}) => !['html', 'jsx'].includes(type),

View file

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

View file

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

View file

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

View file

@ -8,6 +8,14 @@ Array [
"description": "The label for category Getting started in sidebar docs", "description": "The label for category Getting started in sidebar docs",
"message": "Getting started", "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 { "sidebar.docs.link.Link label": Object {
"description": "The label for link Link label in sidebar docs, linking to https://facebook.com", "description": "The label for link Link label in sidebar docs, linking to https://facebook.com",
"message": "Link label", "message": "Link label",
@ -25,6 +33,14 @@ Array [
"description": "The label for category Getting started in sidebar docs", "description": "The label for category Getting started in sidebar docs",
"message": "Getting started", "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 { "sidebar.docs.link.Link label": Object {
"description": "The label for link Link label in sidebar docs, linking to https://facebook.com", "description": "The label for link Link label in sidebar docs, linking to https://facebook.com",
"message": "Link label", "message": "Link label",
@ -42,6 +58,14 @@ Array [
"description": "The label for category Getting started in sidebar docs", "description": "The label for category Getting started in sidebar docs",
"message": "Getting started", "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 { "sidebar.docs.link.Link label": Object {
"description": "The label for link Link label in sidebar docs, linking to https://facebook.com", "description": "The label for link Link label in sidebar docs, linking to https://facebook.com",
"message": "Link label", "message": "Link label",
@ -177,6 +201,13 @@ Object {
}, },
], ],
"label": "Getting started (translated)", "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", "type": "category",
}, },
Object { Object {
@ -317,6 +348,13 @@ Object {
}, },
], ],
"label": "Getting started (translated)", "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", "type": "category",
}, },
Object { Object {
@ -457,6 +495,13 @@ Object {
}, },
], ],
"label": "Getting started (translated)", "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", "type": "category",
}, },
Object { Object {

View file

@ -11,7 +11,8 @@ import {
processDocMetadata, processDocMetadata,
readVersionDocs, readVersionDocs,
readDocFile, readDocFile,
handleNavigation, addDocNavigation,
isConventionalDocIndex,
} from '../docs'; } from '../docs';
import {loadSidebars} from '../sidebars'; import {loadSidebars} from '../sidebars';
import {readVersionsMetadata} from '../versions'; 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_PLUGIN_ID} from '@docusaurus/core/lib/constants';
import {DEFAULT_OPTIONS} from '../options'; import {DEFAULT_OPTIONS} from '../options';
import {Optional} from 'utility-types'; 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__'); const fixtureDir = path.join(__dirname, '__fixtures__');
@ -119,7 +121,7 @@ function createTestUtils({
async function generateNavigation( async function generateNavigation(
docFiles: DocFile[], docFiles: DocFile[],
): Promise<[DocNavLink, DocNavLink][]> { ): Promise<[DocNavLink | undefined, DocNavLink | undefined][]> {
const rawDocs = await Promise.all( const rawDocs = await Promise.all(
docFiles.map((docFile) => docFiles.map((docFile) =>
processDocMetadata({ processDocMetadata({
@ -136,16 +138,19 @@ function createTestUtils({
numberPrefixParser: options.numberPrefixParser, numberPrefixParser: options.numberPrefixParser,
docs: rawDocs, docs: rawDocs,
version: versionMetadata, version: versionMetadata,
options: { sidebarOptions: {
sidebarCollapsed: false, sidebarCollapsed: false,
sidebarCollapsible: true, sidebarCollapsible: true,
}, },
categoryLabelSlugger: createSlugger(),
}); });
return handleNavigation( const sidebarsUtils = createSidebarsUtils(sidebars);
return addDocNavigation(
rawDocs, rawDocs,
sidebars, sidebarsUtils,
versionMetadata.sidebarFilePath as string, versionMetadata.sidebarFilePath as string,
).docs.map((doc) => [doc.previous, doc.next]); ).map((doc) => [doc.previous, doc.next]);
} }
return {processDocFile, testMeta, testSlug, generateNavigation}; 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'}, {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({ expect(getDocById(currentVersion, 'hello')).toEqual({
...defaultDocMetadata, ...defaultDocMetadata,
id: 'hello', id: 'hello',
@ -659,7 +685,7 @@ describe('versioned website', () => {
description: 'Hello 1.0.1 !', description: 'Hello 1.0.1 !',
frontMatter: {}, frontMatter: {},
version: '1.0.1', version: '1.0.1',
sidebar: 'version-1.0.1/docs', sidebar: 'VersionedSideBarNameDoesNotMatter/docs',
previous: { previous: {
title: 'bar', title: 'bar',
permalink: '/docs/foo/bar', permalink: '/docs/foo/bar',

View file

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

View file

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

View file

@ -68,6 +68,13 @@ function createSampleVersion(
type: 'category', type: 'category',
label: 'Getting started', label: 'Getting started',
collapsed: false, 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: [ items: [
{ {
type: 'doc', 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 fs from 'fs-extra';
import path from 'path'; import path from 'path';
import type {PathOptions, SidebarOptions} from './types'; import type {PathOptions, SidebarOptions} from './types';
import {transformSidebarItems} from './sidebars/utils'; import {loadSidebarsFile, resolveSidebarPathOption} from './sidebars';
import type {SidebarItem, NormalizedSidebars, Sidebar} from './sidebars/types';
import {loadUnprocessedSidebars, resolveSidebarPathOption} from './sidebars';
import {DEFAULT_PLUGIN_ID} from '@docusaurus/core/lib/constants'; import {DEFAULT_PLUGIN_ID} from '@docusaurus/core/lib/constants';
function createVersionedSidebarFile({ function createVersionedSidebarFile({
@ -23,47 +21,20 @@ function createVersionedSidebarFile({
pluginId, pluginId,
sidebarPath, sidebarPath,
version, version,
options,
}: { }: {
siteDir: string; siteDir: string;
pluginId: string; pluginId: string;
sidebarPath: string | false | undefined; sidebarPath: string | false | undefined;
version: string; version: string;
options: SidebarOptions;
}) { }) {
// Load current sidebar and create a new versioned sidebars file (if needed). // 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) // Do not create a useless versioned sidebars file if sidebars file is empty or sidebars are disabled/false)
const shouldCreateVersionedSidebarFile = const shouldCreateVersionedSidebarFile = Object.keys(sidebars).length > 0;
Object.keys(loadedSidebars).length > 0;
if (shouldCreateVersionedSidebarFile) { 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 versionedSidebarsDir = getVersionedSidebarsDirPath(siteDir, pluginId);
const newSidebarFile = path.join( const newSidebarFile = path.join(
versionedSidebarsDir, versionedSidebarsDir,
@ -72,7 +43,7 @@ function createVersionedSidebarFile({
fs.ensureDirSync(path.dirname(newSidebarFile)); fs.ensureDirSync(path.dirname(newSidebarFile));
fs.writeFileSync( fs.writeFileSync(
newSidebarFile, newSidebarFile,
`${JSON.stringify(versionedSidebar, null, 2)}\n`, `${JSON.stringify(sidebars, null, 2)}\n`,
'utf8', 'utf8',
); );
} }
@ -155,7 +126,6 @@ export function cliDocsVersionCommand(
pluginId, pluginId,
version, version,
sidebarPath: resolveSidebarPathOption(siteDir, sidebarPath), sidebarPath: resolveSidebarPathOption(siteDir, sidebarPath),
options,
}); });
// Update versions.json file. // Update versions.json file.

View file

@ -8,7 +8,7 @@
import path from 'path'; import path from 'path';
import fs from 'fs-extra'; import fs from 'fs-extra';
import chalk from 'chalk'; import chalk from 'chalk';
import {keyBy} from 'lodash'; import {keyBy, last} from 'lodash';
import { import {
aliasedSitePath, aliasedSitePath,
getEditUrl, getEditUrl,
@ -38,8 +38,11 @@ import {CURRENT_VERSION_NAME} from './constants';
import {getDocsDirPaths} from './versions'; import {getDocsDirPaths} from './versions';
import {stripPathNumberPrefixes} from './numberPrefix'; import {stripPathNumberPrefixes} from './numberPrefix';
import {validateDocFrontMatter} from './docFrontMatter'; import {validateDocFrontMatter} from './docFrontMatter';
import type {Sidebars} from './sidebars/types'; import {
import {createSidebarsUtils} from './sidebars/utils'; SidebarsUtils,
toDocNavigationLink,
toNavigationLink,
} from './sidebars/utils';
type LastUpdateOptions = Pick< type LastUpdateOptions = Pick<
PluginOptions, PluginOptions,
@ -205,7 +208,8 @@ function doProcessDocMetadata({
? '/' ? '/'
: getSlug({ : getSlug({
baseID, baseID,
dirName: sourceDirName, source,
sourceDirName,
frontmatterSlug: frontMatter.slug, frontmatterSlug: frontMatter.slug,
stripDirNumberPrefixes: parseNumberPrefixes, stripDirNumberPrefixes: parseNumberPrefixes,
numberPrefixParser: options.numberPrefixParser, numberPrefixParser: options.numberPrefixParser,
@ -291,68 +295,76 @@ export function processDocMetadata(args: {
} }
} }
export function handleNavigation( export function addDocNavigation(
docsBase: DocMetadataBase[], docsBase: DocMetadataBase[],
sidebars: Sidebars, sidebarsUtils: SidebarsUtils,
sidebarFilePath: string, sidebarFilePath: string,
): Pick<LoadedVersion, 'mainDocId' | 'docs'> { ): LoadedVersion['docs'] {
const docsBaseById = keyBy(docsBase, (doc) => doc.id); const docsById = createDocsByIdIndex(docsBase);
const {checkSidebarsDocIds, getDocNavigation, getFirstDocIdOfFirstSidebar} =
createSidebarsUtils(sidebars);
const validDocIds = Object.keys(docsBaseById); sidebarsUtils.checkSidebarsDocIds(
checkSidebarsDocIds(validDocIds, sidebarFilePath); docsBase.flatMap(getDocIds),
sidebarFilePath,
);
// Add sidebar/next/previous to the docs // Add sidebar/next/previous to the docs
function addNavData(doc: DocMetadataBase): DocMetadata { function addNavData(doc: DocMetadataBase): DocMetadata {
const {sidebarName, previousId, nextId} = getDocNavigation(doc.id); const navigation = sidebarsUtils.getDocNavigation(
const toDocNavLink = ( doc.unversionedId,
doc.id,
);
const toNavigationLinkByDocId = (
docId: string | null | undefined, docId: string | null | undefined,
type: 'prev' | 'next', type: 'prev' | 'next',
): DocNavLink | undefined => { ): DocNavLink | undefined => {
if (!docId) { if (!docId) {
return undefined; return undefined;
} }
if (!docsBaseById[docId]) { const navDoc = docsById[docId];
if (!navDoc) {
// This could only happen if user provided the ID through front matter // This could only happen if user provided the ID through front matter
throw new Error( throw new Error(
`Error when loading ${doc.id} in ${doc.sourceDirName}: the pagination_${type} front matter points to a non-existent ID ${docId}.`, `Error when loading ${doc.id} in ${doc.sourceDirName}: the pagination_${type} front matter points to a non-existent ID ${docId}.`,
); );
} }
const { return toDocNavigationLink(navDoc);
title,
permalink,
frontMatter: {
pagination_label: paginationLabel,
sidebar_label: sidebarLabel,
},
} = docsBaseById[docId];
return {title: paginationLabel ?? sidebarLabel ?? title, permalink};
}; };
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));
/** const previous: DocNavLink | undefined = doc.frontMatter.pagination_prev
* The "main doc" is the "version entry point" ? toNavigationLinkByDocId(doc.frontMatter.pagination_prev, 'prev')
* We browse this doc by clicking on a version: : toNavigationLink(navigation.previous, docsById);
* - the "home" doc (at '/docs/') const next: DocNavLink | undefined = doc.frontMatter.pagination_next
* - the first doc of the first sidebar ? toNavigationLinkByDocId(doc.frontMatter.pagination_next, 'next')
* - a random doc (if no docs are in any sidebar... edge case) : 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 { function getMainDoc(): DocMetadata {
const versionHomeDoc = docs.find((doc) => doc.slug === '/'); const versionHomeDoc = docs.find((doc) => doc.slug === '/');
const firstDocIdOfFirstSidebar = getFirstDocIdOfFirstSidebar(); const firstDocIdOfFirstSidebar =
sidebarsUtils.getFirstDocIdOfFirstSidebar();
if (versionHomeDoc) { if (versionHomeDoc) {
return versionHomeDoc; return versionHomeDoc;
} else if (firstDocIdOfFirstSidebar) { } 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, posixPath,
addTrailingPathSeparator, addTrailingPathSeparator,
createAbsoluteFilePathMatcher, createAbsoluteFilePathMatcher,
createSlugger,
} from '@docusaurus/utils'; } from '@docusaurus/utils';
import type {LoadContext, Plugin, RouteConfig} from '@docusaurus/types'; import type {LoadContext, Plugin} from '@docusaurus/types';
import {loadSidebars} from './sidebars'; import {loadSidebars} from './sidebars';
import {CategoryMetadataFilenamePattern} from './sidebars/generator'; import {CategoryMetadataFilenamePattern} from './sidebars/generator';
import {readVersionDocs, processDocMetadata, handleNavigation} from './docs'; import {
readVersionDocs,
processDocMetadata,
addDocNavigation,
getMainDocId,
} from './docs';
import {getDocsDirPaths, readVersionsMetadata} from './versions'; import {getDocsDirPaths, readVersionsMetadata} from './versions';
import { import {
@ -28,7 +34,6 @@ import {
LoadedContent, LoadedContent,
SourceToPermalink, SourceToPermalink,
DocMetadataBase, DocMetadataBase,
DocMetadata,
GlobalPluginData, GlobalPluginData,
VersionMetadata, VersionMetadata,
LoadedVersion, LoadedVersion,
@ -41,14 +46,17 @@ import {cliDocsVersionCommand} from './cli';
import {VERSIONS_JSON_FILE} from './constants'; import {VERSIONS_JSON_FILE} from './constants';
import {keyBy, mapValues} from 'lodash'; import {keyBy, mapValues} from 'lodash';
import {toGlobalDataVersion} from './globalData'; import {toGlobalDataVersion} from './globalData';
import {toTagDocListProp, toVersionMetadataProp} from './props'; import {toTagDocListProp} from './props';
import { import {
translateLoadedContent, translateLoadedContent,
getLoadedContentTranslationFiles, getLoadedContentTranslationFiles,
} from './translations'; } from './translations';
import chalk from 'chalk'; import chalk from 'chalk';
import {getVersionTags} from './tags'; import {getVersionTags} from './tags';
import {createVersionRoutes} from './routes';
import type {PropTagsListPage} from '@docusaurus/plugin-content-docs'; import type {PropTagsListPage} from '@docusaurus/plugin-content-docs';
import {createSidebarsUtils} from './sidebars/utils';
import {getCategoryGeneratedIndexMetadataList} from './categoryGeneratedIndex';
export default function pluginContentDocs( export default function pluginContentDocs(
context: LoadContext, context: LoadContext,
@ -157,28 +165,37 @@ export default function pluginContentDocs(
async function doLoadVersion( async function doLoadVersion(
versionMetadata: VersionMetadata, versionMetadata: VersionMetadata,
): Promise<LoadedVersion> { ): Promise<LoadedVersion> {
const docsBase: DocMetadataBase[] = await loadVersionDocsBase( const docs: DocMetadataBase[] = await loadVersionDocsBase(
versionMetadata, versionMetadata,
); );
const sidebars = await loadSidebars(versionMetadata.sidebarFilePath, { const sidebars = await loadSidebars(versionMetadata.sidebarFilePath, {
sidebarItemsGenerator: options.sidebarItemsGenerator, sidebarItemsGenerator: options.sidebarItemsGenerator,
numberPrefixParser: options.numberPrefixParser, numberPrefixParser: options.numberPrefixParser,
docs: docsBase, docs,
version: versionMetadata, version: versionMetadata,
options: { sidebarOptions: {
sidebarCollapsed: options.sidebarCollapsed, sidebarCollapsed: options.sidebarCollapsed,
sidebarCollapsible: options.sidebarCollapsible, sidebarCollapsible: options.sidebarCollapsible,
}, },
categoryLabelSlugger: createSlugger(),
}); });
const sidebarsUtils = createSidebarsUtils(sidebars);
return { return {
...versionMetadata, ...versionMetadata,
...handleNavigation( docs: addDocNavigation(
docsBase, docs,
sidebars, sidebarsUtils,
versionMetadata.sidebarFilePath as string, versionMetadata.sidebarFilePath as string,
), ),
sidebars, sidebars,
mainDocId: getMainDocId({docs, sidebarsUtils}),
categoryGeneratedIndices: getCategoryGeneratedIndexMetadataList({
docs,
sidebarsUtils,
}),
}; };
} }
@ -206,45 +223,17 @@ export default function pluginContentDocs(
async contentLoaded({content, actions}) { async contentLoaded({content, actions}) {
const {loadedVersions} = content; const {loadedVersions} = content;
const {docLayoutComponent, docItemComponent} = options; const {
docLayoutComponent,
docItemComponent,
docCategoryGeneratedIndexComponent,
} = options;
const {addRoute, createData, setGlobalData} = actions; const {addRoute, createData, setGlobalData} = actions;
const createDocRoutes = async ( async function createVersionTagsRoutes(version: LoadedVersion) {
docs: DocMetadata[], const versionTags = getVersionTags(version.docs);
): 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);
// TODO tags should be a sub route of the version route
async function createTagsListPage() { async function createTagsListPage() {
const tagsProp: PropTagsListPage['tags'] = Object.values( const tagsProp: PropTagsListPage['tags'] = Object.values(
versionTags, versionTags,
@ -257,11 +246,11 @@ export default function pluginContentDocs(
// Only create /tags page if there are tags. // Only create /tags page if there are tags.
if (Object.keys(tagsProp).length > 0) { if (Object.keys(tagsProp).length > 0) {
const tagsPropPath = await createData( const tagsPropPath = await createData(
`${docuHash(`tags-list-${loadedVersion.versionName}-prop`)}.json`, `${docuHash(`tags-list-${version.versionName}-prop`)}.json`,
JSON.stringify(tagsProp, null, 2), JSON.stringify(tagsProp, null, 2),
); );
addRoute({ addRoute({
path: loadedVersion.tagsPath, path: version.tagsPath,
exact: true, exact: true,
component: options.docTagsListComponent, component: options.docTagsListComponent,
modules: { 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) { async function createTagDocListPage(tag: VersionTag) {
const tagProps = toTagDocListProp({ const tagProps = toTagDocListProp({
allTagsPath: loadedVersion.tagsPath, allTagsPath: version.tagsPath,
tag, tag,
docs: loadedVersion.docs, docs: version.docs,
}); });
const tagPropPath = await createData( const tagPropPath = await createData(
`${docuHash(`tag-${tag.permalink}`)}.json`, `${docuHash(`tag-${tag.permalink}`)}.json`,
@ -295,50 +285,22 @@ export default function pluginContentDocs(
await Promise.all(Object.values(versionTags).map(createTagDocListPage)); await Promise.all(Object.values(versionTags).map(createTagDocListPage));
} }
async function doCreateVersionRoutes( await Promise.all(
loadedVersion: LoadedVersion, loadedVersions.map((loadedVersion) =>
): Promise<void> { createVersionRoutes({
await createVersionTagsRoutes(loadedVersion); loadedVersion,
docItemComponent,
docLayoutComponent,
docCategoryGeneratedIndexComponent,
pluginId,
aliasedSource,
actions,
}),
),
);
const versionMetadata = toVersionMetadataProp(pluginId, loadedVersion); // TODO tags should be a sub route of the version route
const versionMetadataPropPath = await createData( await Promise.all(loadedVersions.map(createVersionTagsRoutes));
`${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));
setGlobalData<GlobalPluginData>({ setGlobalData<GlobalPluginData>({
path: normalizeUrl([baseUrl, options.routeBasePath]), path: normalizeUrl([baseUrl, options.routeBasePath]),

View file

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

View file

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

View file

@ -8,25 +8,28 @@
import type {LoadedVersion, VersionTag, DocMetadata} from './types'; import type {LoadedVersion, VersionTag, DocMetadata} from './types';
import type { import type {
SidebarItemDoc, SidebarItemDoc,
SidebarItemLink,
SidebarItem, SidebarItem,
SidebarItemCategory,
SidebarItemCategoryLink,
PropVersionDocs,
} from './sidebars/types'; } from './sidebars/types';
import type { import type {
PropSidebars, PropSidebars,
PropVersionMetadata, PropVersionMetadata,
PropSidebarItem, PropSidebarItem,
PropSidebarItemCategory,
PropTagDocList, PropTagDocList,
PropTagDocListDoc, PropTagDocListDoc,
PropSidebarItemLink,
} from '@docusaurus/plugin-content-docs'; } from '@docusaurus/plugin-content-docs';
import {compact, keyBy, mapValues} from 'lodash'; import {compact, keyBy, mapValues} from 'lodash';
import {createDocsByIdIndex} from './docs';
export function toSidebarsProp(loadedVersion: LoadedVersion): PropSidebars { export function toSidebarsProp(loadedVersion: LoadedVersion): PropSidebars {
const docsById = keyBy(loadedVersion.docs, (doc) => doc.id); const docsById = createDocsByIdIndex(loadedVersion.docs);
const convertDocLink = (item: SidebarItemDoc): SidebarItemLink => { function getDocById(docId: string): DocMetadata {
const docId = item.id;
const docMetadata = docsById[docId]; const docMetadata = docsById[docId];
if (!docMetadata) { if (!docMetadata) {
throw new Error( 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. `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- ')}`, - ${Object.keys(docsById).sort().join('\n- ')}`,
); );
} }
return docMetadata;
}
const convertDocLink = (item: SidebarItemDoc): PropSidebarItemLink => {
const docMetadata = getDocById(item.id);
const { const {
title, title,
permalink, permalink,
frontMatter: {sidebar_label: sidebarLabel}, frontMatter: {sidebar_label: sidebarLabel},
} = docMetadata; } = docMetadata;
return { return {
type: 'link', type: 'link',
label: sidebarLabel || item.label || title, label: sidebarLabel || item.label || title,
href: permalink, href: permalink,
className: item.className, className: item.className,
customProps: item.customProps, 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) { switch (item.type) {
case 'category': case 'category':
return {...item, items: item.items.map(normalizeItem)}; return convertCategory(item);
case 'ref': case 'ref':
case 'doc': case 'doc':
return convertDocLink(item); return convertDocLink(item);
@ -61,7 +87,7 @@ Available document ids are:
default: default:
return item; return item;
} }
}; }
// Transform the sidebar so that all sidebar item will be in the // Transform the sidebar so that all sidebar item will be in the
// form of 'link' or 'category' only. // form of 'link' or 'category' only.
@ -69,6 +95,18 @@ Available document ids are:
return mapValues(loadedVersion.sidebars, (items) => items.map(normalizeItem)); 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( export function toVersionMetadataProp(
pluginId: string, pluginId: string,
loadedVersion: LoadedVersion, loadedVersion: LoadedVersion,
@ -82,6 +120,7 @@ export function toVersionMetadataProp(
className: loadedVersion.versionClassName, className: loadedVersion.versionClassName,
isLast: loadedVersion.isLast, isLast: loadedVersion.isLast,
docsSidebars: toSidebarsProp(loadedVersion), 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 // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`loadUnprocessedSidebars sidebars link 1`] = ` exports[`loadNormalizedSidebars sidebars link 1`] = `
Object { Object {
"docs": Array [ "docs": Array [
Object { Object {
@ -14,13 +14,14 @@ Object {
}, },
], ],
"label": "Test", "label": "Test",
"link": undefined,
"type": "category", "type": "category",
}, },
], ],
} }
`; `;
exports[`loadUnprocessedSidebars sidebars with category.collapsed property 1`] = ` exports[`loadNormalizedSidebars sidebars with category.collapsed property 1`] = `
Object { Object {
"docs": Array [ "docs": Array [
Object { Object {
@ -37,10 +38,12 @@ Object {
}, },
], ],
"label": "Introduction", "label": "Introduction",
"link": undefined,
"type": "category", "type": "category",
}, },
], ],
"label": "Test", "label": "Test",
"link": undefined,
"type": "category", "type": "category",
}, },
Object { Object {
@ -57,17 +60,19 @@ Object {
}, },
], ],
"label": "Powering MDX", "label": "Powering MDX",
"link": undefined,
"type": "category", "type": "category",
}, },
], ],
"label": "Reference", "label": "Reference",
"link": undefined,
"type": "category", "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 { Object {
"docs": Array [ "docs": Array [
Object { Object {
@ -80,6 +85,7 @@ Object {
}, },
], ],
"label": "Introduction", "label": "Introduction",
"link": undefined,
"type": "category", "type": "category",
}, },
Object { Object {
@ -92,13 +98,14 @@ Object {
}, },
], ],
"label": "Powering MDX", "label": "Powering MDX",
"link": undefined,
"type": "category", "type": "category",
}, },
], ],
} }
`; `;
exports[`loadUnprocessedSidebars sidebars with deep level of category 1`] = ` exports[`loadNormalizedSidebars sidebars with deep level of category 1`] = `
Object { Object {
"docs": Array [ "docs": Array [
Object { Object {
@ -139,14 +146,17 @@ Object {
}, },
], ],
"label": "deeper more more", "label": "deeper more more",
"link": undefined,
"type": "category", "type": "category",
}, },
], ],
"label": "level 4", "label": "level 4",
"link": undefined,
"type": "category", "type": "category",
}, },
], ],
"label": "level 3", "label": "level 3",
"link": undefined,
"type": "category", "type": "category",
}, },
Object { Object {
@ -155,17 +165,19 @@ Object {
}, },
], ],
"label": "level 2", "label": "level 2",
"link": undefined,
"type": "category", "type": "category",
}, },
], ],
"label": "level 1", "label": "level 1",
"link": undefined,
"type": "category", "type": "category",
}, },
], ],
} }
`; `;
exports[`loadUnprocessedSidebars sidebars with first level not a category 1`] = ` exports[`loadNormalizedSidebars sidebars with first level not a category 1`] = `
Object { Object {
"docs": Array [ "docs": Array [
Object { Object {
@ -178,6 +190,7 @@ Object {
}, },
], ],
"label": "Getting Started", "label": "Getting Started",
"link": undefined,
"type": "category", "type": "category",
}, },
Object { 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 { Object {
"docs": Array [ "docs": Array [
Object { Object {
@ -214,6 +227,7 @@ Object {
}, },
], ],
"label": "Test", "label": "Test",
"link": undefined,
"type": "category", "type": "category",
}, },
Object { Object {
@ -226,6 +240,7 @@ Object {
}, },
], ],
"label": "Guides", "label": "Guides",
"link": undefined,
"type": "category", "type": "category",
}, },
], ],

View file

@ -129,9 +129,15 @@ describe('DefaultSidebarItemsGenerator', () => {
test('generates complex nested sidebar', async () => { test('generates complex nested sidebar', async () => {
mockCategoryMetadataFiles({ mockCategoryMetadataFiles({
'02-Guides/_category_.json': {collapsed: false}, '02-Guides/_category_.json': {collapsed: false} as CategoryMetadataFile,
'02-Guides/01-SubGuides/_category_.yml': { '02-Guides/01-SubGuides/_category_.yml': {
label: 'SubGuides (metadata file label)', 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, sidebarPosition: 1,
frontMatter: {}, frontMatter: {},
}, },
{
id: 'tutorials-index',
source: 'index.md',
sourceDirName: '01-Tutorials',
sidebarPosition: 2,
frontMatter: {},
},
{ {
id: 'tutorial2', id: 'tutorial2',
source: 'tutorial2.md', source: 'tutorial2.md',
@ -167,6 +180,12 @@ describe('DefaultSidebarItemsGenerator', () => {
sidebarPosition: 1, sidebarPosition: 1,
frontMatter: {}, 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', id: 'guide2',
source: 'guide2.md', source: 'guide2.md',
@ -209,6 +228,10 @@ describe('DefaultSidebarItemsGenerator', () => {
label: 'Tutorials', label: 'Tutorials',
collapsed: true, collapsed: true,
collapsible: true, collapsible: true,
link: {
type: 'doc',
id: 'tutorials-index',
},
items: [ items: [
{type: 'doc', id: 'tutorial1'}, {type: 'doc', id: 'tutorial1'},
{type: 'doc', id: 'tutorial2'}, {type: 'doc', id: 'tutorial2'},
@ -219,6 +242,10 @@ describe('DefaultSidebarItemsGenerator', () => {
label: 'Guides', label: 'Guides',
collapsed: false, collapsed: false,
collapsible: true, collapsible: true,
link: {
type: 'doc',
id: 'guides-index',
},
items: [ items: [
{type: 'doc', id: 'guide1'}, {type: 'doc', id: 'guide1'},
{ {
@ -227,6 +254,12 @@ describe('DefaultSidebarItemsGenerator', () => {
collapsed: true, collapsed: true,
collapsible: true, collapsible: true,
items: [{type: 'doc', id: 'nested-guide'}], 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'}, {type: 'doc', id: 'guide2'},
], ],
@ -354,4 +387,75 @@ describe('DefaultSidebarItemsGenerator', () => {
}, },
] as Sidebar); ] 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 path from 'path';
import { import {
loadUnprocessedSidebars, loadNormalizedSidebars,
DefaultSidebars, DefaultSidebars,
DisabledSidebars, DisabledSidebars,
} from '../index'; } 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 fixtureDir = path.join(__dirname, '__fixtures__', 'sidebars');
const options: SidebarOptions = { const options: NormalizeSidebarsParams = {
sidebarCollapsed: true, sidebarCollapsed: true,
sidebarCollapsible: true, sidebarCollapsible: true,
version: {
versionName: 'version',
versionPath: 'versionPath',
} as VersionMetadata,
}; };
test('sidebars with known sidebar item type', async () => { test('sidebars with known sidebar item type', async () => {
const sidebarPath = path.join(fixtureDir, 'sidebars.json'); const sidebarPath = path.join(fixtureDir, 'sidebars.json');
const result = loadUnprocessedSidebars(sidebarPath, options); const result = loadNormalizedSidebars(sidebarPath, options);
expect(result).toMatchSnapshot(); expect(result).toMatchSnapshot();
}); });
test('sidebars with deep level of category', async () => { test('sidebars with deep level of category', async () => {
const sidebarPath = path.join(fixtureDir, 'sidebars-category.js'); const sidebarPath = path.join(fixtureDir, 'sidebars-category.js');
const result = loadUnprocessedSidebars(sidebarPath, options); const result = loadNormalizedSidebars(sidebarPath, options);
expect(result).toMatchSnapshot(); expect(result).toMatchSnapshot();
}); });
@ -37,8 +41,8 @@ describe('loadUnprocessedSidebars', () => {
fixtureDir, fixtureDir,
'sidebars-category-shorthand.js', 'sidebars-category-shorthand.js',
); );
const sidebar1 = loadUnprocessedSidebars(sidebarPath1, options); const sidebar1 = loadNormalizedSidebars(sidebarPath1, options);
const sidebar2 = loadUnprocessedSidebars(sidebarPath2, options); const sidebar2 = loadNormalizedSidebars(sidebarPath2, options);
expect(sidebar1).toEqual(sidebar2); expect(sidebar1).toEqual(sidebar2);
}); });
@ -47,7 +51,7 @@ describe('loadUnprocessedSidebars', () => {
fixtureDir, fixtureDir,
'sidebars-category-wrong-items.json', 'sidebars-category-wrong-items.json',
); );
expect(() => loadUnprocessedSidebars(sidebarPath, options)) expect(() => loadNormalizedSidebars(sidebarPath, options))
.toThrowErrorMatchingInlineSnapshot(` .toThrowErrorMatchingInlineSnapshot(`
"{ "{
\\"type\\": \\"category\\", \\"type\\": \\"category\\",
@ -64,7 +68,7 @@ describe('loadUnprocessedSidebars', () => {
fixtureDir, fixtureDir,
'sidebars-category-wrong-label.json', 'sidebars-category-wrong-label.json',
); );
expect(() => loadUnprocessedSidebars(sidebarPath, options)) expect(() => loadNormalizedSidebars(sidebarPath, options))
.toThrowErrorMatchingInlineSnapshot(` .toThrowErrorMatchingInlineSnapshot(`
"{ "{
\\"type\\": \\"category\\", \\"type\\": \\"category\\",
@ -83,7 +87,7 @@ describe('loadUnprocessedSidebars', () => {
fixtureDir, fixtureDir,
'sidebars-doc-id-not-string.json', 'sidebars-doc-id-not-string.json',
); );
expect(() => loadUnprocessedSidebars(sidebarPath, options)) expect(() => loadNormalizedSidebars(sidebarPath, options))
.toThrowErrorMatchingInlineSnapshot(` .toThrowErrorMatchingInlineSnapshot(`
"{ "{
\\"type\\": \\"doc\\", \\"type\\": \\"doc\\",
@ -101,19 +105,19 @@ describe('loadUnprocessedSidebars', () => {
fixtureDir, fixtureDir,
'sidebars-first-level-not-category.js', 'sidebars-first-level-not-category.js',
); );
const result = loadUnprocessedSidebars(sidebarPath, options); const result = loadNormalizedSidebars(sidebarPath, options);
expect(result).toMatchSnapshot(); expect(result).toMatchSnapshot();
}); });
test('sidebars link', async () => { test('sidebars link', async () => {
const sidebarPath = path.join(fixtureDir, 'sidebars-link.json'); const sidebarPath = path.join(fixtureDir, 'sidebars-link.json');
const result = loadUnprocessedSidebars(sidebarPath, options); const result = loadNormalizedSidebars(sidebarPath, options);
expect(result).toMatchSnapshot(); expect(result).toMatchSnapshot();
}); });
test('sidebars link wrong label', async () => { test('sidebars link wrong label', async () => {
const sidebarPath = path.join(fixtureDir, 'sidebars-link-wrong-label.json'); const sidebarPath = path.join(fixtureDir, 'sidebars-link-wrong-label.json');
expect(() => loadUnprocessedSidebars(sidebarPath, options)) expect(() => loadNormalizedSidebars(sidebarPath, options))
.toThrowErrorMatchingInlineSnapshot(` .toThrowErrorMatchingInlineSnapshot(`
"{ "{
\\"type\\": \\"link\\", \\"type\\": \\"link\\",
@ -127,7 +131,7 @@ describe('loadUnprocessedSidebars', () => {
test('sidebars link wrong href', async () => { test('sidebars link wrong href', async () => {
const sidebarPath = path.join(fixtureDir, 'sidebars-link-wrong-href.json'); const sidebarPath = path.join(fixtureDir, 'sidebars-link-wrong-href.json');
expect(() => loadUnprocessedSidebars(sidebarPath, options)) expect(() => loadNormalizedSidebars(sidebarPath, options))
.toThrowErrorMatchingInlineSnapshot(` .toThrowErrorMatchingInlineSnapshot(`
"{ "{
\\"type\\": \\"link\\", \\"type\\": \\"link\\",
@ -143,7 +147,7 @@ describe('loadUnprocessedSidebars', () => {
test('sidebars with unknown sidebar item type', async () => { test('sidebars with unknown sidebar item type', async () => {
const sidebarPath = path.join(fixtureDir, 'sidebars-unknown-type.json'); const sidebarPath = path.join(fixtureDir, 'sidebars-unknown-type.json');
expect(() => loadUnprocessedSidebars(sidebarPath, options)) expect(() => loadNormalizedSidebars(sidebarPath, options))
.toThrowErrorMatchingInlineSnapshot(` .toThrowErrorMatchingInlineSnapshot(`
"{ "{
\\"type\\": \\"superman\\", \\"type\\": \\"superman\\",
@ -156,7 +160,7 @@ describe('loadUnprocessedSidebars', () => {
test('sidebars with known sidebar item type but wrong field', async () => { test('sidebars with known sidebar item type but wrong field', async () => {
const sidebarPath = path.join(fixtureDir, 'sidebars-wrong-field.json'); const sidebarPath = path.join(fixtureDir, 'sidebars-wrong-field.json');
expect(() => loadUnprocessedSidebars(sidebarPath, options)) expect(() => loadNormalizedSidebars(sidebarPath, options))
.toThrowErrorMatchingInlineSnapshot(` .toThrowErrorMatchingInlineSnapshot(`
"{ "{
\\"type\\": \\"category\\", \\"type\\": \\"category\\",
@ -170,24 +174,22 @@ describe('loadUnprocessedSidebars', () => {
}); });
test('unexisting path', () => { test('unexisting path', () => {
expect(loadUnprocessedSidebars('badpath', options)).toEqual( expect(loadNormalizedSidebars('badpath', options)).toEqual(
DisabledSidebars, DisabledSidebars,
); );
}); });
test('undefined path', () => { test('undefined path', () => {
expect(loadUnprocessedSidebars(undefined, options)).toEqual( expect(loadNormalizedSidebars(undefined, options)).toEqual(DefaultSidebars);
DefaultSidebars,
);
}); });
test('literal false path', () => { test('literal false path', () => {
expect(loadUnprocessedSidebars(false, options)).toEqual(DisabledSidebars); expect(loadNormalizedSidebars(false, options)).toEqual(DisabledSidebars);
}); });
test('sidebars with category.collapsed property', async () => { test('sidebars with category.collapsed property', async () => {
const sidebarPath = path.join(fixtureDir, 'sidebars-collapsed.json'); const sidebarPath = path.join(fixtureDir, 'sidebars-collapsed.json');
const result = loadUnprocessedSidebars(sidebarPath, options); const result = loadNormalizedSidebars(sidebarPath, options);
expect(result).toMatchSnapshot(); expect(result).toMatchSnapshot();
}); });
@ -196,7 +198,7 @@ describe('loadUnprocessedSidebars', () => {
fixtureDir, fixtureDir,
'sidebars-collapsed-first-level.json', 'sidebars-collapsed-first-level.json',
); );
const result = loadUnprocessedSidebars(sidebarPath, options); const result = loadNormalizedSidebars(sidebarPath, options);
expect(result).toMatchSnapshot(); expect(result).toMatchSnapshot();
}); });
}); });

View file

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

View file

@ -12,8 +12,12 @@ import {
collectSidebarLinks, collectSidebarLinks,
transformSidebarItems, transformSidebarItems,
collectSidebarsDocIds, collectSidebarsDocIds,
SidebarNavigation,
toDocNavigationLink,
toNavigationLink,
} from '../utils'; } from '../utils';
import type {Sidebar, Sidebars} from '../types'; import type {Sidebar, Sidebars} from '../types';
import {DocMetadataBase, DocNavLink} from '../../types';
describe('createSidebarsUtils', () => { describe('createSidebarsUtils', () => {
const sidebar1: Sidebar = [ const sidebar1: Sidebar = [
@ -21,13 +25,13 @@ describe('createSidebarsUtils', () => {
type: 'category', type: 'category',
collapsed: false, collapsed: false,
collapsible: true, collapsible: true,
label: 'Category1', label: 'S1 Category',
items: [ items: [
{ {
type: 'category', type: 'category',
collapsed: false, collapsed: false,
collapsible: true, collapsible: true,
label: 'Subcategory 1', label: 'S1 Subcategory',
items: [{type: 'doc', id: 'doc1'}], items: [{type: 'doc', id: 'doc1'}],
}, },
{type: 'doc', id: 'doc2'}, {type: 'doc', id: 'doc2'},
@ -40,7 +44,7 @@ describe('createSidebarsUtils', () => {
type: 'category', type: 'category',
collapsed: false, collapsed: false,
collapsible: true, collapsible: true,
label: 'Category2', label: 'S2 Category',
items: [ items: [
{type: 'doc', id: 'doc3'}, {type: 'doc', id: 'doc3'},
{type: 'doc', id: 'doc4'}, {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} = const sidebars: Sidebars = {sidebar1, sidebar2, sidebar3};
createSidebarsUtils(sidebars);
const {
getFirstDocIdOfFirstSidebar,
getSidebarNameByDocId,
getDocNavigation,
getCategoryGeneratedIndexNavigation,
getCategoryGeneratedIndexList,
} = createSidebarsUtils(sidebars);
test('getSidebarNameByDocId', async () => { test('getSidebarNameByDocId', async () => {
expect(getFirstDocIdOfFirstSidebar()).toEqual('doc1'); expect(getFirstDocIdOfFirstSidebar()).toEqual('doc1');
@ -62,32 +114,117 @@ describe('createSidebarsUtils', () => {
expect(getSidebarNameByDocId('doc2')).toEqual('sidebar1'); expect(getSidebarNameByDocId('doc2')).toEqual('sidebar1');
expect(getSidebarNameByDocId('doc3')).toEqual('sidebar2'); expect(getSidebarNameByDocId('doc3')).toEqual('sidebar2');
expect(getSidebarNameByDocId('doc4')).toEqual('sidebar2'); expect(getSidebarNameByDocId('doc4')).toEqual('sidebar2');
expect(getSidebarNameByDocId('doc5')).toEqual(undefined); expect(getSidebarNameByDocId('doc5')).toEqual('sidebar3');
expect(getSidebarNameByDocId('doc6')).toEqual(undefined); expect(getSidebarNameByDocId('doc6')).toEqual('sidebar3');
expect(getSidebarNameByDocId('doc7')).toEqual('sidebar3');
expect(getSidebarNameByDocId('unknown_id')).toEqual(undefined);
}); });
test('getDocNavigation', async () => { test('getDocNavigation', async () => {
expect(getDocNavigation('doc1')).toEqual({ expect(getDocNavigation('doc1')).toEqual({
sidebarName: 'sidebar1', sidebarName: 'sidebar1',
previousId: undefined, previous: undefined,
nextId: 'doc2', next: {
}); type: 'doc',
id: 'doc2',
},
} as SidebarNavigation);
expect(getDocNavigation('doc2')).toEqual({ expect(getDocNavigation('doc2')).toEqual({
sidebarName: 'sidebar1', sidebarName: 'sidebar1',
previousId: 'doc1', previous: {
nextId: undefined, type: 'doc',
}); id: 'doc1',
},
next: undefined,
} as SidebarNavigation);
expect(getDocNavigation('doc3')).toEqual({ expect(getDocNavigation('doc3')).toEqual({
sidebarName: 'sidebar2', sidebarName: 'sidebar2',
previousId: undefined, previous: undefined,
nextId: 'doc4', next: {
}); type: 'doc',
id: 'doc4',
},
} as SidebarNavigation);
expect(getDocNavigation('doc4')).toEqual({ expect(getDocNavigation('doc4')).toEqual({
sidebarName: 'sidebar2', sidebarName: 'sidebar2',
previousId: 'doc3', previous: {
nextId: undefined, 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, SidebarItemCategory,
SidebarItemsGenerator, SidebarItemsGenerator,
SidebarItemsGeneratorDoc, SidebarItemsGeneratorDoc,
SidebarItemCategoryLink,
SidebarItemCategoryLinkConfig,
} from './types'; } from './types';
import {keyBy, sortBy} from 'lodash'; import {sortBy, last} from 'lodash';
import {addTrailingSlash, posixPath} from '@docusaurus/utils'; import {addTrailingSlash, posixPath} from '@docusaurus/utils';
import {Joi} from '@docusaurus/utils-validation';
import chalk from 'chalk'; import chalk from 'chalk';
import path from 'path'; import path from 'path';
import fs from 'fs-extra'; import fs from 'fs-extra';
import Yaml from 'js-yaml'; import Yaml from 'js-yaml';
import {validateCategoryMetadataFile} from './validation';
import {createDocsByIdIndex, isConventionalDocIndex} from '../docs';
const BreadcrumbSeparator = '/'; const BreadcrumbSeparator = '/';
// To avoid possible name clashes with a folder of the same name as the ID // To avoid possible name clashes with a folder of the same name as the ID
const docIdPrefix = '$doc$/'; 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 CategoryMetadataFilenameBase = '_category_';
export const CategoryMetadataFilenamePattern = '_category_.{json,yml,yaml}'; export const CategoryMetadataFilenamePattern = '_category_.{json,yml,yaml}';
@ -33,6 +41,7 @@ export type CategoryMetadataFile = {
collapsed?: boolean; collapsed?: boolean;
collapsible?: boolean; collapsible?: boolean;
className?: string; className?: string;
link?: SidebarItemCategoryLinkConfig;
// TODO should we allow "items" here? how would this work? would an "autogenerated" type be allowed? // 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/ // 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; [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 // 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... // 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 // see https://github.com/facebook/docusaurus/issues/3464#issuecomment-818670449
async function readCategoryMetadataFile( async function readCategoryMetadataFile(
categoryDirPath: string, categoryDirPath: string,
@ -69,7 +70,7 @@ async function readCategoryMetadataFile(
const contentString = await fs.readFile(filePath, {encoding: 'utf8'}); const contentString = await fs.readFile(filePath, {encoding: 'utf8'});
const unsafeContent = Yaml.load(contentString); const unsafeContent = Yaml.load(contentString);
try { try {
return Joi.attempt(unsafeContent, CategoryMetadataFileSchema); return validateCategoryMetadataFile(unsafeContent);
} catch (e) { } catch (e) {
console.error( console.error(
chalk.red( chalk.red(
@ -100,6 +101,21 @@ export const DefaultSidebarItemsGenerator: SidebarItemsGenerator = async ({
item: {dirName: autogenDir}, item: {dirName: autogenDir},
version, 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. * 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) * (From a record to an array of items, akin to normalizing shorthand)
*/ */
function generateSidebar(fsModel: Dir): Promise<WithPosition<SidebarItem>[]> { function generateSidebar(fsModel: Dir): Promise<WithPosition<SidebarItem>[]> {
const docsById = keyBy(allDocs, (doc) => doc.id);
function createDocItem(id: string): WithPosition<SidebarItemDoc> { function createDocItem(id: string): WithPosition<SidebarItemDoc> {
const { const {
sidebarPosition: position, sidebarPosition: position,
frontMatter: {sidebar_label: label, sidebar_class_name: className}, frontMatter: {sidebar_label: label, sidebar_class_name: className},
} = docsById[id]; } = getDoc(id);
return { return {
type: 'doc', type: 'doc',
id, id,
@ -187,6 +202,57 @@ export const DefaultSidebarItemsGenerator: SidebarItemsGenerator = async ({
const categoryMetadata = await readCategoryMetadataFile(categoryPath); const categoryMetadata = await readCategoryMetadataFile(categoryPath);
const className = categoryMetadata?.className; const className = categoryMetadata?.className;
const {filename, numberPrefix} = numberPrefixParser(folderName); 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 { return {
type: 'category', type: 'category',
label: categoryMetadata?.label ?? filename, label: categoryMetadata?.label ?? filename,
@ -195,11 +261,8 @@ export const DefaultSidebarItemsGenerator: SidebarItemsGenerator = async ({
collapsed: categoryMetadata?.collapsed ?? options.sidebarCollapsed, collapsed: categoryMetadata?.collapsed ?? options.sidebarCollapsed,
position: categoryMetadata?.position ?? numberPrefix, position: categoryMetadata?.position ?? numberPrefix,
...(className !== undefined && {className}), ...(className !== undefined && {className}),
items: await Promise.all( items,
Object.entries(dir).map(([key, content]) => ...(link && {link}),
dirToItem(content, key, `${fullPath}/${key}`),
),
),
}; };
} }
async function dirToItem( async function dirToItem(

View file

@ -8,11 +8,12 @@
import fs from 'fs-extra'; import fs from 'fs-extra';
import importFresh from 'import-fresh'; import importFresh from 'import-fresh';
import type {SidebarsConfig, Sidebars, NormalizedSidebars} from './types'; import type {SidebarsConfig, Sidebars, NormalizedSidebars} from './types';
import type {PluginOptions} from '../types'; import type {NormalizeSidebarsParams, PluginOptions} from '../types';
import {validateSidebars} from './validation'; import {validateSidebars} from './validation';
import {normalizeSidebars} from './normalization'; import {normalizeSidebars} from './normalization';
import {processSidebars, SidebarProcessorProps} from './processor'; import {processSidebars, SidebarProcessorParams} from './processor';
import path from 'path'; import path from 'path';
import {createSlugger} from '@docusaurus/utils';
export const DefaultSidebars: SidebarsConfig = { export const DefaultSidebars: SidebarsConfig = {
defaultSidebar: [ defaultSidebar: [
@ -36,7 +37,7 @@ export function resolveSidebarPathOption(
: sidebarPathOption; : sidebarPathOption;
} }
function loadSidebarFile( function loadSidebarsFileUnsafe(
sidebarFilePath: string | false | undefined, sidebarFilePath: string | false | undefined,
): SidebarsConfig { ): SidebarsConfig {
// false => no sidebars // false => no sidebars
@ -60,25 +61,34 @@ function loadSidebarFile(
return importFresh(sidebarFilePath); return importFresh(sidebarFilePath);
} }
export function loadUnprocessedSidebars( export function loadSidebarsFile(
sidebarFilePath: string | false | undefined, sidebarFilePath: string | false | undefined,
options: SidebarProcessorProps['options'], ): SidebarsConfig {
): NormalizedSidebars { const sidebarsConfig = loadSidebarsFileUnsafe(sidebarFilePath);
const sidebarsConfig = loadSidebarFile(sidebarFilePath);
validateSidebars(sidebarsConfig); validateSidebars(sidebarsConfig);
return sidebarsConfig;
}
const normalizedSidebars = normalizeSidebars(sidebarsConfig, options); export function loadNormalizedSidebars(
return normalizedSidebars; sidebarFilePath: string | false | undefined,
params: NormalizeSidebarsParams,
): NormalizedSidebars {
return normalizeSidebars(loadSidebarsFile(sidebarFilePath), params);
} }
// Note: sidebarFilePath must be absolute, use resolveSidebarPathOption // Note: sidebarFilePath must be absolute, use resolveSidebarPathOption
export async function loadSidebars( export async function loadSidebars(
sidebarFilePath: string | false | undefined, sidebarFilePath: string | false | undefined,
options: SidebarProcessorProps, options: SidebarProcessorParams,
): Promise<Sidebars> { ): Promise<Sidebars> {
const unprocessedSidebars = loadUnprocessedSidebars( const normalizeSidebarsParams: NormalizeSidebarsParams = {
...options.sidebarOptions,
version: options.version,
categoryLabelSlugger: createSlugger(),
};
const normalizedSidebars = loadNormalizedSidebars(
sidebarFilePath, 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. * LICENSE file in the root directory of this source tree.
*/ */
import type {SidebarOptions} from '../types'; import type {NormalizeSidebarsParams, SidebarOptions} from '../types';
import type { import type {
NormalizedSidebarItem, NormalizedSidebarItem,
NormalizedSidebar, NormalizedSidebar,
@ -15,9 +15,31 @@ import type {
SidebarItemConfig, SidebarItemConfig,
SidebarConfig, SidebarConfig,
SidebarsConfig, SidebarsConfig,
SidebarItemCategoryLink,
NormalizedSidebarItemCategory,
} from './types'; } from './types';
import {mapValues} from 'lodash';
import {isCategoriesShorthand} from './utils'; 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( function normalizeCategoriesShorthand(
sidebar: SidebarCategoriesShorthand, sidebar: SidebarCategoriesShorthand,
@ -36,9 +58,9 @@ function normalizeCategoriesShorthand(
* Normalizes recursively item and all its children. Ensures that at the end * Normalizes recursively item and all its children. Ensures that at the end
* each item will be an object with the corresponding type. * each item will be an object with the corresponding type.
*/ */
function normalizeItem( export function normalizeItem(
item: SidebarItemConfig, item: SidebarItemConfig,
options: SidebarOptions, options: NormalizeSidebarsParams,
): NormalizedSidebarItem[] { ): NormalizedSidebarItem[] {
if (typeof item === 'string') { if (typeof item === 'string') {
return [ return [
@ -49,40 +71,42 @@ function normalizeItem(
]; ];
} }
if (isCategoriesShorthand(item)) { if (isCategoriesShorthand(item)) {
return normalizeCategoriesShorthand(item, options).flatMap((subitem) => return normalizeCategoriesShorthand(item, options).flatMap((subItem) =>
normalizeItem(subitem, options), normalizeItem(subItem, options),
); );
} }
return item.type === 'category' if (item.type === 'category') {
? [ const link = normalizeCategoryLink(item, options);
{ const normalizedCategory: NormalizedSidebarItemCategory = {
...item, ...item,
items: item.items.flatMap((subItem) => link,
normalizeItem(subItem, options), items: (item.items ?? []).flatMap((subItem) =>
), normalizeItem(subItem, options),
collapsible: item.collapsible ?? options.sidebarCollapsible, ),
collapsed: item.collapsed ?? options.sidebarCollapsed, collapsible: item.collapsible ?? options.sidebarCollapsible,
}, collapsed: item.collapsed ?? options.sidebarCollapsed,
] };
: [item]; return [normalizedCategory];
}
return [item];
} }
function normalizeSidebar( function normalizeSidebar(
sidebar: SidebarConfig, sidebar: SidebarConfig,
options: SidebarOptions, options: NormalizeSidebarsParams,
): NormalizedSidebar { ): NormalizedSidebar {
const normalizedSidebar = Array.isArray(sidebar) const normalizedSidebar = Array.isArray(sidebar)
? sidebar ? sidebar
: normalizeCategoriesShorthand(sidebar, options); : normalizeCategoriesShorthand(sidebar, options);
return normalizedSidebar.flatMap((subitem) => return normalizedSidebar.flatMap((subItem) =>
normalizeItem(subitem, options), normalizeItem(subItem, options),
); );
} }
export function normalizeSidebars( export function normalizeSidebars(
sidebars: SidebarsConfig, sidebars: SidebarsConfig,
options: SidebarOptions, params: NormalizeSidebarsParams,
): NormalizedSidebars { ): NormalizedSidebars {
return mapValues(sidebars, (subitem) => normalizeSidebar(subitem, options)); return mapValues(sidebars, (items) => normalizeSidebar(items, params));
} }

View file

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

View file

@ -12,6 +12,7 @@ import type {
NumberPrefixParser, NumberPrefixParser,
SidebarOptions, SidebarOptions,
} from '../types'; } from '../types';
import {Required} from 'utility-types';
// Makes all properties visible when hovering over the type // Makes all properties visible when hovering over the type
type Expand<T extends Record<string, unknown>> = {[P in keyof T]: T[P]}; type Expand<T extends Record<string, unknown>> = {[P in keyof T]: T[P]};
@ -45,10 +46,35 @@ type SidebarItemCategoryBase = SidebarItemBase & {
collapsible: boolean; 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 // The user-given configuration in sidebars.js, before normalization
export type SidebarItemCategoryConfig = Expand< export type SidebarItemCategoryConfig = Expand<
Optional<SidebarItemCategoryBase, 'collapsed' | 'collapsible'> & { Optional<SidebarItemCategoryBase, 'collapsed' | 'collapsible'> & {
items: SidebarItemConfig[]; items: SidebarItemConfig[];
link?: SidebarItemCategoryLinkConfig;
} }
>; >;
@ -73,6 +99,7 @@ export type SidebarsConfig = {
export type NormalizedSidebarItemCategory = Expand< export type NormalizedSidebarItemCategory = Expand<
SidebarItemCategoryBase & { SidebarItemCategoryBase & {
items: NormalizedSidebarItem[]; items: NormalizedSidebarItem[];
link?: SidebarItemCategoryLink;
} }
>; >;
@ -90,14 +117,25 @@ export type NormalizedSidebars = {
export type SidebarItemCategory = Expand< export type SidebarItemCategory = Expand<
SidebarItemCategoryBase & { SidebarItemCategoryBase & {
items: SidebarItem[]; items: SidebarItem[];
link?: SidebarItemCategoryLink;
} }
>; >;
export type SidebarItemCategoryWithLink = Required<SidebarItemCategory, 'link'>;
export type SidebarItemCategoryWithGeneratedIndex =
SidebarItemCategoryWithLink & {link: SidebarItemCategoryLinkGeneratedIndex};
export type SidebarItem = export type SidebarItem =
| SidebarItemDoc | SidebarItemDoc
| SidebarItemLink | SidebarItemLink
| SidebarItemCategory; | SidebarItemCategory;
// A sidebar item that is part of the previous/next ordered navigation
export type SidebarNavigationItem =
| SidebarItemDoc
| SidebarItemCategoryWithLink;
export type Sidebar = SidebarItem[]; export type Sidebar = SidebarItem[];
export type SidebarItemType = SidebarItem['type']; export type SidebarItemType = SidebarItem['type'];
export type Sidebars = { export type Sidebars = {
@ -108,21 +146,42 @@ export type Sidebars = {
export type PropSidebarItemCategory = Expand< export type PropSidebarItemCategory = Expand<
SidebarItemCategoryBase & { SidebarItemCategoryBase & {
items: PropSidebarItem[]; 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 PropSidebar = PropSidebarItem[];
export type PropSidebars = { export type PropSidebars = {
[sidebarId: string]: PropSidebar; [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 // Reduce API surface for options.sidebarItemsGenerator
// The user-provided generator fn should receive only a subset of metadata // 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 // A change to any of these metadata can be considered as a breaking change
export type SidebarItemsGeneratorDoc = Pick< export type SidebarItemsGeneratorDoc = Pick<
DocMetadataBase, DocMetadataBase,
'id' | 'frontMatter' | 'source' | 'sourceDirName' | 'sidebarPosition' | 'id'
| 'unversionedId'
| 'frontMatter'
| 'source'
| 'sourceDirName'
| 'sidebarPosition'
>; >;
export type SidebarItemsGeneratorVersion = Pick< export type SidebarItemsGeneratorVersion = Pick<
VersionMetadata, VersionMetadata,
@ -138,7 +197,9 @@ export type SidebarItemsGeneratorArgs = {
}; };
export type SidebarItemsGenerator = ( export type SidebarItemsGenerator = (
generatorArgs: SidebarItemsGeneratorArgs, 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 // 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 // see https://github.com/facebook/docusaurus/issues/4640#issuecomment-822292320

View file

@ -16,8 +16,15 @@ import type {
SidebarCategoriesShorthand, SidebarCategoriesShorthand,
SidebarItemConfig, SidebarItemConfig,
} from './types'; } from './types';
import {mapValues, difference} from 'lodash';
import {mapValues, difference, uniq} from 'lodash';
import {getElementsAround, toMessageRelativeFilePath} from '@docusaurus/utils'; import {getElementsAround, toMessageRelativeFilePath} from '@docusaurus/utils';
import {DocMetadataBase, DocNavLink} from '../types';
import {
SidebarItemCategoryWithGeneratedIndex,
SidebarItemCategoryWithLink,
SidebarNavigationItem,
} from './types';
export function isCategoriesShorthand( export function isCategoriesShorthand(
item: SidebarItemConfig, item: SidebarItemConfig,
@ -41,21 +48,24 @@ export function transformSidebarItems(
return sidebar.map(transformRecursive); 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< function collectSidebarItemsOfType<
Type extends SidebarItemType, Type extends SidebarItemType,
Item extends SidebarItem & {type: SidebarItemType}, Item extends SidebarItem & {type: SidebarItemType},
>(type: Type, sidebar: Sidebar): Item[] { >(type: Type, sidebar: Sidebar): Item[] {
function collectRecursive(item: SidebarItem): Item[] { return flattenSidebarItems(sidebar).filter(
const currentItemsCollected: Item[] = (item) => item.type === type,
item.type === type ? [item as Item] : []; ) as Item[];
const childItemsCollected: Item[] =
item.type === 'category' ? item.items.flatMap(collectRecursive) : [];
return [...currentItemsCollected, ...childItemsCollected];
}
return sidebar.flatMap(collectRecursive);
} }
export function collectSidebarDocItems(sidebar: Sidebar): SidebarItemDoc[] { export function collectSidebarDocItems(sidebar: Sidebar): SidebarItemDoc[] {
@ -70,25 +80,72 @@ export function collectSidebarLinks(sidebar: Sidebar): SidebarItemLink[] {
return collectSidebarItemsOfType('link', sidebar); 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( export function collectSidebarsDocIds(
sidebars: Sidebars, sidebars: Sidebars,
): Record<string, string[]> { ): Record<string, string[]> {
return mapValues(sidebars, (sidebar) => return mapValues(sidebars, collectSidebarDocIds);
collectSidebarDocItems(sidebar).map((docItem) => docItem.id),
);
} }
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; getFirstDocIdOfFirstSidebar: () => string | undefined;
getSidebarNameByDocId: (docId: string) => string | undefined; getSidebarNameByDocId: (docId: string) => string | undefined;
getDocNavigation: (docId: string) => { getDocNavigation: (
sidebarName: string | undefined; unversionedId: string,
previousId: string | undefined; versionedId: string,
nextId: string | undefined; ) => SidebarNavigation;
}; getCategoryGeneratedIndexList: () => SidebarItemCategoryWithGeneratedIndex[];
getCategoryGeneratedIndexNavigation: (
categoryGeneratedIndexPermalink: string,
) => SidebarNavigation;
checkSidebarsDocIds: (validDocIds: string[], sidebarFilePath: string) => void; checkSidebarsDocIds: (validDocIds: string[], sidebarFilePath: string) => void;
} { };
export function createSidebarsUtils(sidebars: Sidebars): SidebarsUtils {
const sidebarNameToDocIds = collectSidebarsDocIds(sidebars); const sidebarNameToDocIds = collectSidebarsDocIds(sidebars);
const sidebarNameToNavigationItems = collectSidebarsNavigations(sidebars);
// Reverse mapping // Reverse mapping
const docIdToSidebarName = Object.fromEntries( const docIdToSidebarName = Object.fromEntries(
Object.entries(sidebarNameToDocIds).flatMap(([sidebarName, docIds]) => Object.entries(sidebarNameToDocIds).flatMap(([sidebarName, docIds]) =>
@ -104,27 +161,91 @@ export function createSidebarsUtils(sidebars: Sidebars): {
return docIdToSidebarName[docId]; return docIdToSidebarName[docId];
} }
function getDocNavigation(docId: string): { function emptySidebarNavigation(): SidebarNavigation {
sidebarName: string | undefined; return {
previousId: string | undefined; sidebarName: undefined,
nextId: string | undefined; previous: undefined,
} { next: undefined,
const sidebarName = getSidebarNameByDocId(docId); };
}
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) { if (sidebarName) {
const docIds = sidebarNameToDocIds[sidebarName]; const navigationItems = sidebarNameToNavigationItems[sidebarName];
const currentIndex = docIds.indexOf(docId); const currentItemIndex = navigationItems.findIndex((item) => {
const {previous, next} = getElementsAround(docIds, currentIndex); if (item.type === 'doc') {
return { return item.id === docId;
sidebarName, }
previousId: previous, if (item.type === 'category' && item.link.type === 'doc') {
nextId: next, return item.link.id === docId;
}; }
return false;
});
const {previous, next} = getElementsAround(
navigationItems,
currentItemIndex,
);
return {sidebarName, previous, next};
} else { } else {
return { return emptySidebarNavigation();
sidebarName: undefined, }
previousId: undefined, }
nextId: undefined,
}; 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- ')} - ${invalidSidebarDocIds.sort().join('\n- ')}
Available document ids are: Available document ids are:
- ${validDocIds.sort().join('\n- ')}`, - ${uniq(validDocIds).sort().join('\n- ')}`,
); );
} }
} }
return { return {
sidebars,
getFirstDocIdOfFirstSidebar, getFirstDocIdOfFirstSidebar,
getSidebarNameByDocId, getSidebarNameByDocId,
getDocNavigation, getDocNavigation,
getCategoryGeneratedIndexList,
getCategoryGeneratedIndexNavigation,
checkSidebarsDocIds, 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, SidebarItemDoc,
SidebarItemLink, SidebarItemLink,
SidebarItemCategoryConfig, SidebarItemCategoryConfig,
SidebarItemCategoryLink,
SidebarsConfig, SidebarsConfig,
SidebarItemCategoryLinkDoc,
SidebarItemCategoryLinkGeneratedIndex,
} from './types'; } from './types';
import {isCategoriesShorthand} from './utils'; import {isCategoriesShorthand} from './utils';
import {CategoryMetadataFile} from './generator';
const sidebarItemBaseSchema = Joi.object<SidebarItemBase>({ const sidebarItemBaseSchema = Joi.object<SidebarItemBase>({
className: Joi.string(), className: Joi.string(),
@ -48,6 +52,36 @@ const sidebarItemLinkSchema = sidebarItemBaseSchema.append<SidebarItemLink>({
.messages({'any.unknown': '"label" must be a string'}), .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 = const sidebarItemCategorySchema =
sidebarItemBaseSchema.append<SidebarItemCategoryConfig>({ sidebarItemBaseSchema.append<SidebarItemCategoryConfig>({
type: 'category', type: 'category',
@ -58,6 +92,7 @@ const sidebarItemCategorySchema =
items: Joi.array() items: Joi.array()
.required() .required()
.messages({'any.unknown': '"items" must be an array'}), // .items(Joi.link('#sidebarItemSchema')), .messages({'any.unknown': '"items" must be an array'}), // .items(Joi.link('#sidebarItemSchema')),
link: sidebarItemCategoryLinkSchema,
collapsed: Joi.boolean().messages({ collapsed: Joi.boolean().messages({
'any.unknown': '"collapsed" must be a boolean', 'any.unknown': '"collapsed" must be a boolean',
}), }),
@ -77,14 +112,7 @@ const sidebarItemSchema: Joi.Schema<SidebarItemConfig> = Joi.object()
{is: 'autogenerated', then: sidebarItemAutogeneratedSchema}, {is: 'autogenerated', then: sidebarItemAutogeneratedSchema},
{is: 'category', then: sidebarItemCategorySchema}, {is: 'category', then: sidebarItemCategorySchema},
{ {
is: 'subcategory', is: Joi.any().required(),
then: Joi.forbidden().messages({
'any.unknown':
'Docusaurus v2: "subcategory" has been renamed as "category".',
}),
},
{
is: Joi.string().required(),
then: Joi.forbidden().messages({ then: Joi.forbidden().messages({
'any.unknown': 'Unknown sidebar item type "{.type}".', 'any.unknown': 'Unknown sidebar item type "{.type}".',
}), }),
@ -105,6 +133,7 @@ function validateSidebarItem(item: unknown): asserts item is SidebarItemConfig {
); );
} else { } else {
Joi.assert(item, sidebarItemSchema); Joi.assert(item, sidebarItemSchema);
if ((item as SidebarItemCategoryConfig).type === 'category') { if ((item as SidebarItemCategoryConfig).type === 'category') {
(item as SidebarItemCategoryConfig).items.forEach(validateSidebarItem); (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, DefaultNumberPrefixParser,
stripPathNumberPrefixes, stripPathNumberPrefixes,
} from './numberPrefix'; } from './numberPrefix';
import type {NumberPrefixParser} from './types'; import type {DocMetadataBase, NumberPrefixParser} from './types';
import {isConventionalDocIndex} from './docs';
export default function getSlug({ export default function getSlug({
baseID, baseID,
frontmatterSlug, frontmatterSlug,
dirName, source,
sourceDirName,
stripDirNumberPrefixes = true, stripDirNumberPrefixes = true,
numberPrefixParser = DefaultNumberPrefixParser, numberPrefixParser = DefaultNumberPrefixParser,
}: { }: {
baseID: string; baseID: string;
frontmatterSlug?: string; frontmatterSlug?: string;
dirName: string; source: DocMetadataBase['slug'];
sourceDirName: DocMetadataBase['sourceDirName'];
stripDirNumberPrefixes?: boolean; stripDirNumberPrefixes?: boolean;
numberPrefixParser?: NumberPrefixParser; numberPrefixParser?: NumberPrefixParser;
}): string { }): string {
const baseSlug = frontmatterSlug || baseID; function getDirNameSlug(): string {
let slug: string;
if (baseSlug.startsWith('/')) {
slug = baseSlug;
} else {
const dirNameStripped = stripDirNumberPrefixes const dirNameStripped = stripDirNumberPrefixes
? stripPathNumberPrefixes(dirName, numberPrefixParser) ? stripPathNumberPrefixes(sourceDirName, numberPrefixParser)
: dirName; : sourceDirName;
const resolveDirname = const resolveDirname =
dirName === '.' sourceDirName === '.'
? '/' ? '/'
: addLeadingSlash(addTrailingSlash(dirNameStripped)); : addLeadingSlash(addTrailingSlash(dirNameStripped));
slug = resolvePathname(baseSlug, resolveDirname); return resolveDirname;
} }
if (!isValidPathname(slug)) { function computeSlug(): string {
throw new Error( if (frontmatterSlug?.startsWith('/')) {
`We couldn't compute a valid slug for document with id "${baseID}" in "${dirName}" directory. 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}. The slug we computed looks invalid: ${slug}.
Maybe your slug frontmatter is incorrect or you use weird chars in the file path? 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: 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 slug: /my/customDocPath
--- ---
`, `,
); );
}
return slug;
} }
return slug; return ensureValidSlug(computeSlug());
} }

View file

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

View file

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

View file

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

View file

@ -107,11 +107,12 @@ declare module '@theme/CodeBlock' {
} }
declare module '@theme/DocPaginator' { declare module '@theme/DocPaginator' {
import type {PropNavigation} from '@docusaurus/plugin-content-docs';
type PageInfo = {readonly permalink: string; readonly title: string}; type PageInfo = {readonly permalink: string; readonly title: string};
export interface Props { // May be simpler to provide a {navigation: PropNavigation} prop?
readonly metadata: {readonly previous?: PageInfo; readonly next?: PageInfo}; export interface Props extends PropNavigation {}
}
const DocPaginator: (props: Props) => JSX.Element; const DocPaginator: (props: Props) => JSX.Element;
export default DocPaginator; export default DocPaginator;
@ -138,7 +139,7 @@ declare module '@theme/DocSidebarItem' {
type DocSidebarPropsBase = { type DocSidebarPropsBase = {
readonly activePath: string; readonly activePath: string;
readonly onItemClick?: () => void; readonly onItemClick?: (item: PropSidebarItem) => void;
readonly level: number; readonly level: number;
readonly tabIndex?: 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 useWindowSize from '@theme/hooks/useWindowSize';
import DocPaginator from '@theme/DocPaginator'; import DocPaginator from '@theme/DocPaginator';
import DocVersionBanner from '@theme/DocVersionBanner'; import DocVersionBanner from '@theme/DocVersionBanner';
import DocVersionBadge from '@theme/DocVersionBadge';
import Seo from '@theme/Seo'; import Seo from '@theme/Seo';
import type {Props} from '@theme/DocItem'; import type {Props} from '@theme/DocItem';
import DocItemFooter from '@theme/DocItemFooter'; import DocItemFooter from '@theme/DocItemFooter';
@ -20,7 +21,7 @@ import styles from './styles.module.css';
import {ThemeClassNames} from '@docusaurus/theme-common'; import {ThemeClassNames} from '@docusaurus/theme-common';
export default function DocItem(props: Props): JSX.Element { export default function DocItem(props: Props): JSX.Element {
const {content: DocContent, versionMetadata} = props; const {content: DocContent} = props;
const {metadata, frontMatter} = DocContent; const {metadata, frontMatter} = DocContent;
const { const {
image, image,
@ -55,18 +56,10 @@ export default function DocItem(props: Props): JSX.Element {
className={clsx('col', { className={clsx('col', {
[styles.docItemCol]: !hideTableOfContents, [styles.docItemCol]: !hideTableOfContents,
})}> })}>
<DocVersionBanner versionMetadata={versionMetadata} /> <DocVersionBanner />
<div className={styles.docItemContainer}> <div className={styles.docItemContainer}>
<article> <article>
{versionMetadata.badge && ( <DocVersionBadge />
<span
className={clsx(
ThemeClassNames.docs.docVersionBadge,
'badge badge--secondary',
)}>
Version: {versionMetadata.label}
</span>
)}
{canRenderTOC && ( {canRenderTOC && (
<TOCCollapsible <TOCCollapsible
@ -95,7 +88,7 @@ export default function DocItem(props: Props): JSX.Element {
<DocItemFooter {...props} /> <DocItemFooter {...props} />
</article> </article>
<DocPaginator metadata={metadata} /> <DocPaginator previous={metadata.previous} next={metadata.next} />
</div> </div>
</div> </div>
{renderTocDesktop && ( {renderTocDesktop && (

View file

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

View file

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

View file

@ -100,7 +100,15 @@ const DocSidebarMobileSecondaryMenu: MobileSecondaryMenuComponent<Props> = ({
<DocSidebarItems <DocSidebarItems
items={sidebar} items={sidebar}
activePath={path} 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} level={1}
/> />
</ul> </ul>

View file

@ -5,17 +5,19 @@
* LICENSE file in the root directory of this source tree. * 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 clsx from 'clsx';
import { import {
isSamePath, isActiveSidebarItem,
usePrevious, usePrevious,
Collapsible, Collapsible,
useCollapsible, useCollapsible,
findFirstCategoryLink,
ThemeClassNames, ThemeClassNames,
} from '@docusaurus/theme-common'; } from '@docusaurus/theme-common';
import Link from '@docusaurus/Link'; import Link from '@docusaurus/Link';
import isInternalUrl from '@docusaurus/isInternalUrl'; import isInternalUrl from '@docusaurus/isInternalUrl';
import {translate} from '@docusaurus/Translate';
import IconExternalLink from '@theme/IconExternalLink'; import IconExternalLink from '@theme/IconExternalLink';
import type {Props, DocSidebarItemsProps} from '@theme/DocSidebarItem'; import type {Props, DocSidebarItemsProps} from '@theme/DocSidebarItem';
@ -25,21 +27,7 @@ import type {
} from '@docusaurus/plugin-content-docs'; } from '@docusaurus/plugin-content-docs';
import styles from './styles.module.css'; import styles from './styles.module.css';
import useIsBrowser from '@docusaurus/useIsBrowser';
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;
};
// Optimize sidebar at each "level" // Optimize sidebar at each "level"
// TODO this item should probably not receive the "activePath" props // TODO this item should probably not receive the "activePath" props
@ -93,6 +81,28 @@ function useAutoExpandActiveCategory({
}, [isActive, wasActive, collapsed, setCollapsed]); }, [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({ function DocSidebarItemCategory({
item, item,
onItemClick, onItemClick,
@ -100,7 +110,8 @@ function DocSidebarItemCategory({
level, level,
...props ...props
}: Props & {item: PropSidebarItemCategory}) { }: 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); const isActive = isActiveSidebarItem(item, activePath);
@ -128,25 +139,53 @@ function DocSidebarItemCategory({
}, },
className, className,
)}> )}>
{/* eslint-disable-next-line jsx-a11y/anchor-is-valid */} <div className="menu__list-item-collapsible">
<a <Link
className={clsx('menu__link', { className={clsx('menu__link', {
'menu__link--sublist': collapsible, 'menu__link--sublist': collapsible && !href,
'menu__link--active': collapsible && isActive, 'menu__link--active': isActive,
[styles.menuLinkText]: !collapsible, [styles.menuLinkText]: !collapsible,
})} [styles.hasHref]: !!hrefWithSSRFallback,
onClick={ })}
collapsible onClick={
? (e) => { collapsible
e.preventDefault(); ? (e) => {
toggleCollapsed(); onItemClick?.(item);
} if (href) {
: undefined setCollapsed(false);
} } else {
href={collapsible ? '#' : undefined} e.preventDefault();
{...props}> toggleCollapsed();
{label} }
</a> }
: () => {
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}> <Collapsible lazy as="ul" className="menu__list" collapsed={collapsed}>
<DocSidebarItems <DocSidebarItems
@ -186,7 +225,7 @@ function DocSidebarItemLink({
aria-current={isActive ? 'page' : undefined} aria-current={isActive ? 'page' : undefined}
to={href} to={href}
{...(isInternalUrl(href) && { {...(isInternalUrl(href) && {
onClick: onItemClick, onClick: onItemClick ? () => onItemClick(item) : undefined,
})} })}
{...props}> {...props}>
{isInternalUrl(href) ? ( {isInternalUrl(href) ? (

View file

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

View file

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

View file

@ -32,7 +32,18 @@ export {
export {docVersionSearchTag, DEFAULT_SEARCH_TAG} from './utils/searchUtils'; 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'; 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.copied": "تم النسخ",
"theme.CodeBlock.copy": "نسخ", "theme.CodeBlock.copy": "نسخ",
"theme.CodeBlock.copyButtonAriaLabel": "نسخ الرمز إلى الحافظة", "theme.CodeBlock.copyButtonAriaLabel": "نسخ الرمز إلى الحافظة",
"theme.DocSidebarItem.toggleCollapsedCategoryAriaLabel": "Toggle the collapsible sidebar category '{label}'",
"theme.ErrorPageContent.title": "This page crashed.", "theme.ErrorPageContent.title": "This page crashed.",
"theme.ErrorPageContent.tryAgain": "Try again", "theme.ErrorPageContent.tryAgain": "Try again",
"theme.NotFound.p1": "لم نتمكن من العثور على ما كنت تبحث عنه.", "theme.NotFound.p1": "لم نتمكن من العثور على ما كنت تبحث عنه.",

View file

@ -9,6 +9,8 @@
"theme.CodeBlock.copy___DESCRIPTION": "The copy button label on code blocks", "theme.CodeBlock.copy___DESCRIPTION": "The copy button label on code blocks",
"theme.CodeBlock.copyButtonAriaLabel": "Copy code to clipboard", "theme.CodeBlock.copyButtonAriaLabel": "Copy code to clipboard",
"theme.CodeBlock.copyButtonAriaLabel___DESCRIPTION": "The ARIA label for copy code blocks button", "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": "This page crashed.",
"theme.ErrorPageContent.title___DESCRIPTION": "The title of the fallback page when the page crashed", "theme.ErrorPageContent.title___DESCRIPTION": "The title of the fallback page when the page crashed",
"theme.ErrorPageContent.tryAgain": "Try again", "theme.ErrorPageContent.tryAgain": "Try again",

View file

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

View file

@ -4,6 +4,7 @@
"theme.CodeBlock.copied": "Zkopírováno", "theme.CodeBlock.copied": "Zkopírováno",
"theme.CodeBlock.copy": "Zkopírovat", "theme.CodeBlock.copy": "Zkopírovat",
"theme.CodeBlock.copyButtonAriaLabel": "Zkopírovat kód do schránky", "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.title": "This page crashed.",
"theme.ErrorPageContent.tryAgain": "Try again", "theme.ErrorPageContent.tryAgain": "Try again",
"theme.NotFound.p1": "Nepodařilo se nám najít co jste hledal(a).", "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.copied": "Kopieret",
"theme.CodeBlock.copy": "Kopier", "theme.CodeBlock.copy": "Kopier",
"theme.CodeBlock.copyButtonAriaLabel": "Kopier kode til udklipsholder", "theme.CodeBlock.copyButtonAriaLabel": "Kopier kode til udklipsholder",
"theme.DocSidebarItem.toggleCollapsedCategoryAriaLabel": "Toggle the collapsible sidebar category '{label}'",
"theme.ErrorPageContent.title": "This page crashed.", "theme.ErrorPageContent.title": "This page crashed.",
"theme.ErrorPageContent.tryAgain": "Try again", "theme.ErrorPageContent.tryAgain": "Try again",
"theme.NotFound.p1": "Vi kunne ikke finde det, du søgte.", "theme.NotFound.p1": "Vi kunne ikke finde det, du søgte.",

View file

@ -4,6 +4,7 @@
"theme.CodeBlock.copied": "Kopiert", "theme.CodeBlock.copied": "Kopiert",
"theme.CodeBlock.copy": "Kopieren", "theme.CodeBlock.copy": "Kopieren",
"theme.CodeBlock.copyButtonAriaLabel": "In die Zwischenablage 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.title": "This page crashed.",
"theme.ErrorPageContent.tryAgain": "Try again", "theme.ErrorPageContent.tryAgain": "Try again",
"theme.NotFound.p1": "Wir konnten nicht finden, wonach Sie gesucht haben.", "theme.NotFound.p1": "Wir konnten nicht finden, wonach Sie gesucht haben.",

View file

@ -4,6 +4,7 @@
"theme.CodeBlock.copied": "Copiado", "theme.CodeBlock.copied": "Copiado",
"theme.CodeBlock.copy": "Copiar", "theme.CodeBlock.copy": "Copiar",
"theme.CodeBlock.copyButtonAriaLabel": "Copiar código al portapapeles", "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.title": "This page crashed.",
"theme.ErrorPageContent.tryAgain": "Try again", "theme.ErrorPageContent.tryAgain": "Try again",
"theme.NotFound.p1": "No pudimos encontrar lo que buscaba.", "theme.NotFound.p1": "No pudimos encontrar lo que buscaba.",

View file

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

View file

@ -4,6 +4,7 @@
"theme.CodeBlock.copied": "Kinopya", "theme.CodeBlock.copied": "Kinopya",
"theme.CodeBlock.copy": "Kopyahin", "theme.CodeBlock.copy": "Kopyahin",
"theme.CodeBlock.copyButtonAriaLabel": "Kopyahin ang code sa clipboard", "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.title": "This page crashed.",
"theme.ErrorPageContent.tryAgain": "Try again", "theme.ErrorPageContent.tryAgain": "Try again",
"theme.NotFound.p1": "Hindi namin mahanap ang iyong hinananap.", "theme.NotFound.p1": "Hindi namin mahanap ang iyong hinananap.",

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -4,6 +4,7 @@
"theme.CodeBlock.copied": "Copiado", "theme.CodeBlock.copied": "Copiado",
"theme.CodeBlock.copy": "Copiar", "theme.CodeBlock.copy": "Copiar",
"theme.CodeBlock.copyButtonAriaLabel": "Copiar código para a área de transferência", "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.title": "This page crashed.",
"theme.ErrorPageContent.tryAgain": "Try again", "theme.ErrorPageContent.tryAgain": "Try again",
"theme.NotFound.p1": "Não foi possível encontrar o que você está procurando.", "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.copied": "Copiado",
"theme.CodeBlock.copy": "Copiar", "theme.CodeBlock.copy": "Copiar",
"theme.CodeBlock.copyButtonAriaLabel": "Copiar código para a área de transferência", "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.title": "This page crashed.",
"theme.ErrorPageContent.tryAgain": "Try again", "theme.ErrorPageContent.tryAgain": "Try again",
"theme.NotFound.p1": "Não foi possível encontrar o que procura.", "theme.NotFound.p1": "Não foi possível encontrar o que procura.",

View file

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

View file

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

View file

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

View file

@ -4,6 +4,7 @@
"theme.CodeBlock.copied": "Đã sao chép", "theme.CodeBlock.copied": "Đã sao chép",
"theme.CodeBlock.copy": "Sao chép", "theme.CodeBlock.copy": "Sao chép",
"theme.CodeBlock.copyButtonAriaLabel": "Sao chép code vào bộ nhớ tạm", "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.title": "This page crashed.",
"theme.ErrorPageContent.tryAgain": "Try again", "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.", "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.copied": "复制成功",
"theme.CodeBlock.copy": "复制", "theme.CodeBlock.copy": "复制",
"theme.CodeBlock.copyButtonAriaLabel": "复制代码到剪贴板", "theme.CodeBlock.copyButtonAriaLabel": "复制代码到剪贴板",
"theme.DocSidebarItem.toggleCollapsedCategoryAriaLabel": "Toggle the collapsible sidebar category '{label}'",
"theme.ErrorPageContent.title": "页面已崩溃。", "theme.ErrorPageContent.title": "页面已崩溃。",
"theme.ErrorPageContent.tryAgain": "重试", "theme.ErrorPageContent.tryAgain": "重试",
"theme.NotFound.p1": "我们找不到您要找的页面。", "theme.NotFound.p1": "我们找不到您要找的页面。",

View file

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

View file

@ -23,6 +23,7 @@
"chalk": "^4.1.2", "chalk": "^4.1.2",
"escape-string-regexp": "^4.0.0", "escape-string-regexp": "^4.0.0",
"fs-extra": "^10.0.0", "fs-extra": "^10.0.0",
"github-slugger": "^1.4.0",
"globby": "^11.0.4", "globby": "^11.0.4",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"lodash": "^4.17.20", "lodash": "^4.17.20",
@ -37,6 +38,7 @@
}, },
"devDependencies": { "devDependencies": {
"@types/dedent": "^0.7.0", "@types/dedent": "^0.7.0",
"@types/github-slugger": "^1.3.0",
"@types/micromatch": "^4.0.2", "@types/micromatch": "^4.0.2",
"@types/react-dom": "^17.0.1", "@types/react-dom": "^17.0.1",
"dedent": "^0.7.0" "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 './markdownParser';
export * from './markdownLinks'; export * from './markdownLinks';
export * from './escapePath'; export * from './escapePath';
export * from './slugger';
export {md5Hash, simpleHash, docuHash} from './hashUtils'; export {md5Hash, simpleHash, docuHash} from './hashUtils';
export { export {
Globby, 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/copy-webpack-plugin": "^8.0.1",
"@types/css-minimizer-webpack-plugin": "^3.0.2", "@types/css-minimizer-webpack-plugin": "^3.0.2",
"@types/detect-port": "^1.3.0", "@types/detect-port": "^1.3.0",
"@types/github-slugger": "^1.3.0",
"@types/mini-css-extract-plugin": "^1.4.3", "@types/mini-css-extract-plugin": "^1.4.3",
"@types/nprogress": "^0.2.0", "@types/nprogress": "^0.2.0",
"@types/react-dom": "^17.0.9", "@types/react-dom": "^17.0.9",
@ -82,7 +81,6 @@
"eta": "^1.12.3", "eta": "^1.12.3",
"file-loader": "^6.2.0", "file-loader": "^6.2.0",
"fs-extra": "^10.0.0", "fs-extra": "^10.0.0",
"github-slugger": "^1.4.0",
"globby": "^11.0.2", "globby": "^11.0.2",
"html-minifier-terser": "^6.0.2", "html-minifier-terser": "^6.0.2",
"html-tags": "^3.1.0", "html-tags": "^3.1.0",

View file

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

View file

@ -6,12 +6,15 @@
*/ */
import fs from 'fs-extra'; import fs from 'fs-extra';
import GithubSlugger from 'github-slugger';
import chalk from 'chalk'; import chalk from 'chalk';
import {loadContext, loadPluginConfigs} from '../server'; import {loadContext, loadPluginConfigs} from '../server';
import initPlugins from '../server/plugins/init'; import initPlugins from '../server/plugins/init';
import {parseMarkdownHeadingId} from '@docusaurus/utils'; import {
parseMarkdownHeadingId,
createSlugger,
Slugger,
} from '@docusaurus/utils';
import {safeGlobby} from '../server/utils'; import {safeGlobby} from '../server/utils';
type Options = { type Options = {
@ -25,7 +28,7 @@ function unwrapMarkdownLinks(line: string): string {
function addHeadingId( function addHeadingId(
line: string, line: string,
slugger: GithubSlugger, slugger: Slugger,
maintainCase: boolean, maintainCase: boolean,
): string { ): string {
let headingLevel = 0; let headingLevel = 0;
@ -36,7 +39,7 @@ function addHeadingId(
const headingText = line.slice(headingLevel).trimEnd(); const headingText = line.slice(headingLevel).trimEnd();
const headingHashes = line.slice(0, headingLevel); const headingHashes = line.slice(0, headingLevel);
const slug = slugger const slug = slugger
.slug(unwrapMarkdownLinks(headingText).trim(), maintainCase) .slug(unwrapMarkdownLinks(headingText).trim(), {maintainCase})
.replace(/^-+/, '') .replace(/^-+/, '')
.replace(/-+$/, ''); .replace(/-+$/, '');
@ -45,7 +48,7 @@ function addHeadingId(
export function transformMarkdownHeadingLine( export function transformMarkdownHeadingLine(
line: string, line: string,
slugger: GithubSlugger, slugger: Slugger,
options: Options = {maintainCase: false, overwrite: false}, options: Options = {maintainCase: false, overwrite: false},
): string { ): string {
const {maintainCase = false, overwrite = false} = options; const {maintainCase = false, overwrite = false} = options;
@ -64,7 +67,7 @@ export function transformMarkdownHeadingLine(
function transformMarkdownLine( function transformMarkdownLine(
line: string, line: string,
slugger: GithubSlugger, slugger: Slugger,
options?: Options, options?: Options,
): string { ): string {
// Ignore h1 headings on purpose, as we don't create anchor links for those // 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[] { function transformMarkdownLines(lines: string[], options?: Options): string[] {
let inCode = false; let inCode = false;
const slugger = new GithubSlugger(); const slugger = createSlugger();
return lines.map((line) => { return lines.map((line) => {
if (line.startsWith('```')) { 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. * LICENSE file in the root directory of this source tree.
*/ */
module.exports = { /** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */
const sidebars = {
sidebar: [ sidebar: [
{ {
type: 'doc', type: 'doc',
@ -16,11 +17,19 @@ module.exports = {
{ {
type: 'category', type: 'category',
label: 'Tests', label: 'Tests',
link: {
type: 'generated-index',
},
items: [ items: [
{ {
type: 'autogenerated', type: 'autogenerated',
dirName: 'tests', 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', label: 'Test Test test test test test test',
}, },
], ],
...generateHugeSidebarItems(4), ...generateHugeSidebarItems(),
], ],
}, },
{ {
@ -62,6 +71,7 @@ module.exports = {
}, },
], ],
}; };
module.exports = sidebars;
function generateHugeSidebarItems() { function generateHugeSidebarItems() {
const linksCount = 8; const linksCount = 8;
@ -87,5 +97,5 @@ function generateHugeSidebarItems() {
return [...linkItems, ...categoryItems]; return [...linkItems, ...categoryItems];
} }
return generateRecursive(4); return generateRecursive(3);
} }

View file

@ -1,2 +1,5 @@
label: Plugins label: Plugins
position: 2 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. | | `docItemComponent` | `string` | `'@theme/DocItem'` | Main doc container, with TOC, pagination, etc. |
| `docTagsListComponent` | `string` | `'@theme/DocTagsListPage'` | Root component of the tags list page | | `docTagsListComponent` | `string` | `'@theme/DocTagsListPage'` | Root component of the tags list page |
| `docTagDocListComponent` | `string` | `'@theme/DocTagDocListPage'` | Root component of the "docs containing tag" 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. | | `remarkPlugins` | `any[]` | `[]` | Remark plugins passed to MDX. |
| `rehypePlugins` | `any[]` | `[]` | Rehype 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. | | `beforeDefaultRemarkPlugins` | `any[]` | `[]` | Custom Remark plugins passed to MDX before the default Docusaurus Remark plugins. |

View file

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

View file

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

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