feat(v2): global data + useGlobalData + docs versions dropdown (#2971)

* doc components initial simplification

* doc components initial simplification

* add docContext test

* Add poc of global data system + use it in the theme

* Revert "doc components initial simplification"

This reverts commit f657b4c4

* revert useless changes

* avoid loosing context on docs switch

* fix docs tests

* fix @generated/globalData ts declaration / es import

* typo

* revert bad commit

* refactor navbar in multiple parts + add navbar item types validation + try to fix remaining merge bugs

* add missing watch mode for plugin debug

* fix docs global data integration, move related hooks to docs plugin + convert to TS

* change versions link label

* fix activeClassName react warning

* improve docs global data system + contextual navbar dropdown

* fix bug preventing the deployment

* refactor the global data system to namespace automatically by plugin name + plugin id

* proper NavbarItem comp

* fix tests

* fix snapshot

* extract theme config schema in separate file + rename navbar links to navbar items

* minor typos

* polish docs components/api

* polish useDocs api surface

* fix the docs version suggestions comp + data

* refactors + add docsClientUtils unit tests

* Add documentation

* typo

* Add check for duplicate plugin ids detection

* multi-instance: createData plugin data should be namespaced by plugin instance id

* remove attempt for multi-instance support
This commit is contained in:
Sébastien Lorber 2020-07-21 11:16:08 +02:00 committed by GitHub
parent a51a56ec42
commit 15e73daae7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
53 changed files with 1954 additions and 531 deletions

View file

@ -78,6 +78,7 @@ module.exports = {
'@typescript-eslint/no-inferrable-types': OFF, '@typescript-eslint/no-inferrable-types': OFF,
'import/first': OFF, 'import/first': OFF,
'import/order': OFF, 'import/order': OFF,
'import/prefer-default-export': OFF,
'lines-between-class-members': OFF, 'lines-between-class-members': OFF,
'no-use-before-define': [ 'no-use-before-define': [
ERROR, ERROR,

View file

@ -28,4 +28,7 @@ module.exports = {
'^.+\\.[jt]sx?$': 'babel-jest', '^.+\\.[jt]sx?$': 'babel-jest',
}, },
setupFiles: ['./jest/stylelint-rule-test.js'], setupFiles: ['./jest/stylelint-rule-test.js'],
moduleNameMapper: {
'@docusaurus/router': 'react-router-dom',
},
}; };

View file

@ -56,6 +56,7 @@
"@types/lodash.kebabcase": "^4.1.6", "@types/lodash.kebabcase": "^4.1.6",
"@types/lodash.pick": "^4.4.6", "@types/lodash.pick": "^4.4.6",
"@types/lodash.pickby": "^4.6.6", "@types/lodash.pickby": "^4.6.6",
"@types/lodash.sortby": "^4.6.6",
"@types/node": "^13.11.0", "@types/node": "^13.11.0",
"@types/prismjs": "^1.16.1", "@types/prismjs": "^1.16.1",
"@types/react": "^16.9.38", "@types/react": "^16.9.38",

View file

@ -13,7 +13,7 @@ module.exports = {
alt: 'My Site Logo', alt: 'My Site Logo',
src: 'img/logo.svg', src: 'img/logo.svg',
}, },
links: [ items: [
{ {
to: 'docs/', to: 'docs/',
activeBasePath: 'docs', activeBasePath: 'docs',

View file

@ -13,7 +13,7 @@ module.exports = {
alt: 'My Site Logo', alt: 'My Site Logo',
src: 'img/logo.svg', src: 'img/logo.svg',
}, },
links: [ items: [
{ {
to: 'docs/', to: 'docs/',
activeBasePath: 'docs', activeBasePath: 'docs',

View file

@ -22,7 +22,7 @@ module.exports = {
alt: 'My Facebook Project Logo', alt: 'My Facebook Project Logo',
src: 'img/logo.svg', src: 'img/logo.svg',
}, },
links: [ items: [
{ {
to: 'docs/', to: 'docs/',
activeBasePath: 'docs', activeBasePath: 'docs',

View file

@ -42,6 +42,11 @@ declare module '@generated/routesChunkNames' {
export default routesChunkNames; export default routesChunkNames;
} }
declare module '@generated/globalData' {
const globalData: any;
export default globalData;
}
declare module '@theme/*'; declare module '@theme/*';
declare module '@theme-original/*'; declare module '@theme-original/*';

View file

@ -50,6 +50,7 @@ export default function pluginContentBlog(
const dataDir = path.join( const dataDir = path.join(
generatedFilesDir, generatedFilesDir,
'docusaurus-plugin-content-blog', 'docusaurus-plugin-content-blog',
// options.id ?? 'default', // TODO support multi-instance
); );
let blogPosts: BlogPost[] = []; let blogPosts: BlogPost[] = [];

View file

@ -20,6 +20,7 @@ export interface DateLink {
export type FeedType = 'rss' | 'atom'; export type FeedType = 'rss' | 'atom';
export interface PluginOptions { export interface PluginOptions {
id?: string;
path: string; path: string;
routeBasePath: string; routeBasePath: string;
include: string[]; include: string[];

View file

@ -12,6 +12,7 @@
}, },
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@docusaurus/module-type-aliases": "^2.0.0-alpha.58",
"commander": "^5.0.0", "commander": "^5.0.0",
"picomatch": "^2.1.1", "picomatch": "^2.1.1",
"@types/hapi__joi": "^17.1.2" "@types/hapi__joi": "^17.1.2"
@ -30,6 +31,7 @@
"lodash.groupby": "^4.6.0", "lodash.groupby": "^4.6.0",
"lodash.pick": "^4.4.0", "lodash.pick": "^4.4.0",
"lodash.pickby": "^4.6.0", "lodash.pickby": "^4.6.0",
"lodash.sortby": "^4.6.0",
"remark-admonitions": "^1.2.1", "remark-admonitions": "^1.2.1",
"shelljs": "^0.8.4" "shelljs": "^0.8.4"
}, },

View file

@ -109,6 +109,46 @@ Array [
] ]
`; `;
exports[`simple website content 3`] = `
Object {
"pluginName": Object {
"pluginId": Object {
"latestVersionName": null,
"path": "docs",
"versions": Array [
Object {
"docs": Array [
Object {
"id": "foo/bar",
"path": "/docs/foo/bar",
},
Object {
"id": "foo/baz",
"path": "/docs/foo/bazSlug.html",
},
Object {
"id": "hello",
"path": "/docs/",
},
Object {
"id": "ipsum",
"path": "/docs/ipsum",
},
Object {
"id": "lorem",
"path": "/docs/lorem",
},
],
"mainDocId": "hello",
"name": null,
"path": "/docs",
},
],
},
},
}
`;
exports[`site with wrong sidebar file 1`] = ` exports[`site with wrong sidebar file 1`] = `
"Bad sidebars file. The document id 'goku' was used in the sidebar, but no document with this id could be found. "Bad sidebars file. The document id 'goku' was used in the sidebar, but no document with this id could be found.
Available document ids= Available document ids=
@ -213,6 +253,68 @@ Array [
] ]
`; `;
exports[`versioned website content 2`] = `
Object {
"pluginName": Object {
"pluginId": Object {
"latestVersionName": "1.0.1",
"path": "docs",
"versions": Array [
Object {
"docs": Array [
Object {
"id": "foo/bar",
"path": "/docs/next/foo/barSlug",
},
Object {
"id": "hello",
"path": "/docs/next/",
},
],
"mainDocId": "hello",
"name": "next",
"path": "/docs/next",
},
Object {
"docs": Array [
Object {
"id": "foo/bar",
"path": "/docs/foo/bar",
},
Object {
"id": "hello",
"path": "/docs/",
},
],
"mainDocId": "hello",
"name": "1.0.1",
"path": "/docs",
},
Object {
"docs": Array [
Object {
"id": "foo/bar",
"path": "/docs/1.0.0/foo/barSlug",
},
Object {
"id": "foo/baz",
"path": "/docs/1.0.0/foo/baz",
},
Object {
"id": "hello",
"path": "/docs/1.0.0/",
},
],
"mainDocId": "hello",
"name": "1.0.0",
"path": "/docs/1.0.0",
},
],
},
},
}
`;
exports[`versioned website content: all sidebars 1`] = ` exports[`versioned website content: all sidebars 1`] = `
Object { Object {
"docs": Array [ "docs": Array [

View file

@ -0,0 +1,361 @@
/**
* 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 {
ActivePlugin,
getActivePlugin,
getLatestVersion,
getActiveDocContext,
getActiveVersion,
getDocVersionSuggestions,
} from '../../client/docsClientUtils';
import {GlobalPluginData, GlobalVersion} from '../../types';
import {shuffle} from 'lodash';
describe('docsClientUtils', () => {
test('getActivePlugin', () => {
const data: Record<string, GlobalPluginData> = {
pluginIosId: {
path: 'ios',
latestVersionName: 'xyz',
versions: [],
},
pluginAndroidId: {
path: 'android',
latestVersionName: 'xyz',
versions: [],
},
};
expect(getActivePlugin(data, '/')).toEqual(undefined);
expect(getActivePlugin(data, '/xyz')).toEqual(undefined);
const activePluginIos: ActivePlugin = {
pluginId: 'pluginIosId',
pluginData: data.pluginIosId,
};
expect(getActivePlugin(data, '/ios')).toEqual(activePluginIos);
expect(getActivePlugin(data, '/ios/')).toEqual(activePluginIos);
expect(getActivePlugin(data, '/ios/abc/def')).toEqual(activePluginIos);
const activePluginAndroid: ActivePlugin = {
pluginId: 'pluginAndroidId',
pluginData: data.pluginAndroidId,
};
expect(getActivePlugin(data, '/android')).toEqual(activePluginAndroid);
expect(getActivePlugin(data, '/android/')).toEqual(activePluginAndroid);
expect(getActivePlugin(data, '/android/ijk')).toEqual(activePluginAndroid);
});
test('getLatestVersion', () => {
const versions: GlobalVersion[] = [
{
name: 'version1',
path: '/???',
docs: [],
mainDocId: '???',
},
{
name: 'version2',
path: '/???',
docs: [],
mainDocId: '???',
},
{
name: 'version3',
path: '/???',
docs: [],
mainDocId: '???',
},
];
expect(
getLatestVersion({
path: '???',
latestVersionName: 'does not exist',
versions,
}),
).toEqual(undefined);
expect(
getLatestVersion({
path: '???',
latestVersionName: 'version1',
versions,
})?.name,
).toEqual('version1');
expect(
getLatestVersion({
path: '???',
latestVersionName: 'version2',
versions,
})?.name,
).toEqual('version2');
expect(
getLatestVersion({
path: '???',
latestVersionName: 'version3',
versions,
})?.name,
).toEqual('version3');
});
test('getActiveVersion', () => {
const data: GlobalPluginData = {
path: 'docs',
latestVersionName: 'version2',
versions: [
{
name: 'next',
path: '/docs/next',
docs: [],
mainDocId: '???',
},
{
name: 'version2',
path: '/docs',
docs: [],
mainDocId: '???',
},
{
name: 'version1',
path: '/docs/version1',
docs: [],
mainDocId: '???',
},
],
};
expect(getActiveVersion(data, '/docs/next')?.name).toEqual('next');
expect(getActiveVersion(data, '/docs/next/')?.name).toEqual('next');
expect(getActiveVersion(data, '/docs/next/someDoc')?.name).toEqual('next');
expect(getActiveVersion(data, '/docs')?.name).toEqual('version2');
expect(getActiveVersion(data, '/docs/')?.name).toEqual('version2');
expect(getActiveVersion(data, '/docs/someDoc')?.name).toEqual('version2');
expect(getActiveVersion(data, '/docs/version1')?.name).toEqual('version1');
expect(getActiveVersion(data, '/docs/version1')?.name).toEqual('version1');
expect(getActiveVersion(data, '/docs/version1/someDoc')?.name).toEqual(
'version1',
);
});
test('getActiveDocContext', () => {
const versionNext: GlobalVersion = {
name: 'next',
path: '/docs/next',
mainDocId: 'doc1',
docs: [
{
id: 'doc1',
path: '/docs/next/',
},
{
id: 'doc2',
path: '/docs/next/doc2',
},
],
};
const version2: GlobalVersion = {
name: 'version2',
path: '/docs',
mainDocId: 'doc1',
docs: [
{
id: 'doc1',
path: '/docs/',
},
{
id: 'doc2',
path: '/docs/doc2',
},
],
};
const version1: GlobalVersion = {
name: 'version1',
path: '/docs/version1',
mainDocId: 'doc1',
docs: [
{
id: 'doc1',
path: '/docs/version1/',
},
],
};
// shuffle, because order shouldn't matter
const versions: GlobalVersion[] = shuffle([
versionNext,
version2,
version1,
]);
const data: GlobalPluginData = {
path: 'docs',
latestVersionName: 'version2',
versions,
};
expect(getActiveDocContext(data, '/doesNotExist')).toEqual({
activeVersion: undefined,
activeDoc: undefined,
alternateDocVersions: {},
});
expect(getActiveDocContext(data, '/docs/next/doesNotExist')).toEqual({
activeVersion: versionNext,
activeDoc: undefined,
alternateDocVersions: {},
});
expect(getActiveDocContext(data, '/docs/next')).toEqual({
activeVersion: versionNext,
activeDoc: versionNext.docs[0],
alternateDocVersions: {
next: versionNext.docs[0],
version2: version2.docs[0],
version1: version1.docs[0],
},
});
expect(getActiveDocContext(data, '/docs/next/doc2')).toEqual({
activeVersion: versionNext,
activeDoc: versionNext.docs[1],
alternateDocVersions: {
next: versionNext.docs[1],
version2: version2.docs[1],
version1: undefined,
},
});
expect(getActiveDocContext(data, '/docs/')).toEqual({
activeVersion: version2,
activeDoc: version2.docs[0],
alternateDocVersions: {
next: versionNext.docs[0],
version2: version2.docs[0],
version1: version1.docs[0],
},
});
expect(getActiveDocContext(data, '/docs/doc2')).toEqual({
activeVersion: version2,
activeDoc: version2.docs[1],
alternateDocVersions: {
next: versionNext.docs[1],
version2: version2.docs[1],
version1: undefined,
},
});
expect(getActiveDocContext(data, '/docs/version1')).toEqual({
activeVersion: version1,
activeDoc: version1.docs[0],
alternateDocVersions: {
next: versionNext.docs[0],
version2: version2.docs[0],
version1: version1.docs[0],
},
});
expect(getActiveDocContext(data, '/docs/version1/doc2')).toEqual({
activeVersion: version1,
activeDoc: undefined,
alternateDocVersions: {},
});
});
test('getDocVersionSuggestions', () => {
const versionNext: GlobalVersion = {
name: 'next',
path: '/docs/next',
mainDocId: 'doc1',
docs: [
{
id: 'doc1',
path: '/docs/next/',
},
{
id: 'doc2',
path: '/docs/next/doc2',
},
],
};
const version2: GlobalVersion = {
name: 'version2',
path: '/docs',
mainDocId: 'doc1',
docs: [
{
id: 'doc1',
path: '/docs/',
},
{
id: 'doc2',
path: '/docs/doc2',
},
],
};
const version1: GlobalVersion = {
name: 'version1',
path: '/docs/version1',
mainDocId: 'doc1',
docs: [
{
id: 'doc1',
path: '/docs/version1/',
},
],
};
// shuffle, because order shouldn't matter
const versions: GlobalVersion[] = shuffle([
versionNext,
version2,
version1,
]);
const data: GlobalPluginData = {
path: 'docs',
latestVersionName: 'version2',
versions,
};
expect(getDocVersionSuggestions(data, '/doesNotExist')).toEqual({
latestDocSuggestion: undefined,
latestVersionSuggestion: version2,
});
expect(getDocVersionSuggestions(data, '/docs/next')).toEqual({
latestDocSuggestion: version2.docs[0],
latestVersionSuggestion: version2,
});
expect(getDocVersionSuggestions(data, '/docs/next/doc2')).toEqual({
latestDocSuggestion: version2.docs[1],
latestVersionSuggestion: version2,
});
// nothing to suggest, we are already on latest version
expect(getDocVersionSuggestions(data, '/docs/')).toEqual({
latestDocSuggestion: undefined,
latestVersionSuggestion: undefined,
});
expect(getDocVersionSuggestions(data, '/docs/doc2')).toEqual({
latestDocSuggestion: undefined,
latestVersionSuggestion: undefined,
});
expect(getDocVersionSuggestions(data, '/docs/version1/')).toEqual({
latestDocSuggestion: version2.docs[0],
latestVersionSuggestion: version2,
});
expect(getDocVersionSuggestions(data, '/docs/version1/doc2')).toEqual({
latestDocSuggestion: undefined, // because /docs/version1/doc2 does not exist
latestVersionSuggestion: version2,
});
});
});

View file

@ -25,6 +25,7 @@ const createFakeActions = (
routeConfigs: RouteConfig[], routeConfigs: RouteConfig[],
contentDir, contentDir,
dataContainer?, dataContainer?,
globalDataContainer?,
) => { ) => {
return { return {
addRoute: (config: RouteConfig) => { addRoute: (config: RouteConfig) => {
@ -36,6 +37,9 @@ const createFakeActions = (
} }
return path.join(contentDir, name); return path.join(contentDir, name);
}, },
setGlobalData: (data) => {
globalDataContainer.pluginName = {pluginId: data};
},
}; };
}; };
@ -166,6 +170,7 @@ describe('simple website', () => {
expect(versionToSidebars).toEqual({}); expect(versionToSidebars).toEqual({});
expect(docsMetadata.hello).toEqual({ expect(docsMetadata.hello).toEqual({
id: 'hello', id: 'hello',
unversionedId: 'hello',
isDocsHomePage: true, isDocsHomePage: true,
permalink: '/docs/', permalink: '/docs/',
previous: { previous: {
@ -176,11 +181,11 @@ describe('simple website', () => {
source: path.join('@site', pluginPath, 'hello.md'), source: path.join('@site', pluginPath, 'hello.md'),
title: 'Hello, World !', title: 'Hello, World !',
description: 'Hi, Endilie here :)', description: 'Hi, Endilie here :)',
latestVersionMainDocPermalink: undefined,
}); });
expect(docsMetadata['foo/bar']).toEqual({ expect(docsMetadata['foo/bar']).toEqual({
id: 'foo/bar', id: 'foo/bar',
unversionedId: 'foo/bar',
isDocsHomePage: false, isDocsHomePage: false,
next: { next: {
title: 'baz', title: 'baz',
@ -191,17 +196,18 @@ describe('simple website', () => {
source: path.join('@site', pluginPath, 'foo', 'bar.md'), source: path.join('@site', pluginPath, 'foo', 'bar.md'),
title: 'Bar', title: 'Bar',
description: 'This is custom description', description: 'This is custom description',
latestVersionMainDocPermalink: undefined,
}); });
expect(docsSidebars).toMatchSnapshot(); expect(docsSidebars).toMatchSnapshot();
const routeConfigs = []; const routeConfigs = [];
const dataContainer = {}; const dataContainer = {};
const globalDataContainer = {};
const actions = createFakeActions( const actions = createFakeActions(
routeConfigs, routeConfigs,
pluginContentDir, pluginContentDir,
dataContainer, dataContainer,
globalDataContainer,
); );
await plugin.contentLoaded({ await plugin.contentLoaded({
@ -219,6 +225,7 @@ describe('simple website', () => {
expect(routeConfigs).not.toEqual([]); expect(routeConfigs).not.toEqual([]);
expect(routeConfigs).toMatchSnapshot(); expect(routeConfigs).toMatchSnapshot();
expect(globalDataContainer).toMatchSnapshot();
}); });
}); });
@ -313,6 +320,7 @@ describe('versioned website', () => {
expect(docsMetadata['version-1.0.1/foo/baz']).toBeUndefined(); expect(docsMetadata['version-1.0.1/foo/baz']).toBeUndefined();
expect(docsMetadata['foo/bar']).toEqual({ expect(docsMetadata['foo/bar']).toEqual({
id: 'foo/bar', id: 'foo/bar',
unversionedId: 'foo/bar',
isDocsHomePage: false, isDocsHomePage: false,
permalink: '/docs/next/foo/barSlug', permalink: '/docs/next/foo/barSlug',
source: path.join('@site', routeBasePath, 'foo', 'bar.md'), source: path.join('@site', routeBasePath, 'foo', 'bar.md'),
@ -327,6 +335,7 @@ describe('versioned website', () => {
}); });
expect(docsMetadata.hello).toEqual({ expect(docsMetadata.hello).toEqual({
id: 'hello', id: 'hello',
unversionedId: 'hello',
isDocsHomePage: true, isDocsHomePage: true,
permalink: '/docs/next/', permalink: '/docs/next/',
source: path.join('@site', routeBasePath, 'hello.md'), source: path.join('@site', routeBasePath, 'hello.md'),
@ -341,6 +350,7 @@ describe('versioned website', () => {
}); });
expect(docsMetadata['version-1.0.1/hello']).toEqual({ expect(docsMetadata['version-1.0.1/hello']).toEqual({
id: 'version-1.0.1/hello', id: 'version-1.0.1/hello',
unversionedId: 'hello',
isDocsHomePage: true, isDocsHomePage: true,
permalink: '/docs/', permalink: '/docs/',
source: path.join( source: path.join(
@ -357,10 +367,10 @@ describe('versioned website', () => {
title: 'bar', title: 'bar',
permalink: '/docs/foo/bar', permalink: '/docs/foo/bar',
}, },
latestVersionMainDocPermalink: undefined,
}); });
expect(docsMetadata['version-1.0.0/foo/baz']).toEqual({ expect(docsMetadata['version-1.0.0/foo/baz']).toEqual({
id: 'version-1.0.0/foo/baz', id: 'version-1.0.0/foo/baz',
unversionedId: 'foo/baz',
isDocsHomePage: false, isDocsHomePage: false,
permalink: '/docs/1.0.0/foo/baz', permalink: '/docs/1.0.0/foo/baz',
source: path.join( source: path.join(
@ -391,10 +401,12 @@ describe('versioned website', () => {
); );
const routeConfigs = []; const routeConfigs = [];
const dataContainer = {}; const dataContainer = {};
const globalDataContainer = {};
const actions = createFakeActions( const actions = createFakeActions(
routeConfigs, routeConfigs,
pluginContentDir, pluginContentDir,
dataContainer, dataContainer,
globalDataContainer,
); );
await plugin.contentLoaded({ await plugin.contentLoaded({
content, content,
@ -438,5 +450,6 @@ describe('versioned website', () => {
expect(routeConfigs).not.toEqual([]); expect(routeConfigs).not.toEqual([]);
expect(routeConfigs).toMatchSnapshot(); expect(routeConfigs).toMatchSnapshot();
expect(globalDataContainer).toMatchSnapshot();
}); });
}); });

View file

@ -46,21 +46,21 @@ describe('simple site', () => {
expect(dataA).toEqual({ expect(dataA).toEqual({
id: 'foo/bar', id: 'foo/bar',
unversionedId: 'foo/bar',
isDocsHomePage: false, isDocsHomePage: false,
permalink: '/docs/foo/bar', permalink: '/docs/foo/bar',
source: path.join('@site', routeBasePath, sourceA), source: path.join('@site', routeBasePath, sourceA),
title: 'Bar', title: 'Bar',
description: 'This is custom description', description: 'This is custom description',
latestVersionMainDocPermalink: undefined,
}); });
expect(dataB).toEqual({ expect(dataB).toEqual({
id: 'hello', id: 'hello',
unversionedId: 'hello',
isDocsHomePage: false, isDocsHomePage: false,
permalink: '/docs/hello', permalink: '/docs/hello',
source: path.join('@site', routeBasePath, sourceB), source: path.join('@site', routeBasePath, sourceB),
title: 'Hello, World !', title: 'Hello, World !',
description: `Hi, Endilie here :)`, description: `Hi, Endilie here :)`,
latestVersionMainDocPermalink: undefined,
}); });
}); });
@ -81,6 +81,7 @@ describe('simple site', () => {
expect(data).toEqual({ expect(data).toEqual({
id: 'hello', id: 'hello',
unversionedId: 'hello',
isDocsHomePage: true, isDocsHomePage: true,
permalink: '/docs/', permalink: '/docs/',
source: path.join('@site', routeBasePath, source), source: path.join('@site', routeBasePath, source),
@ -106,6 +107,7 @@ describe('simple site', () => {
expect(data).toEqual({ expect(data).toEqual({
id: 'foo/bar', id: 'foo/bar',
unversionedId: 'foo/bar',
isDocsHomePage: true, isDocsHomePage: true,
permalink: '/docs/', permalink: '/docs/',
source: path.join('@site', routeBasePath, source), source: path.join('@site', routeBasePath, source),
@ -133,6 +135,7 @@ describe('simple site', () => {
expect(data).toEqual({ expect(data).toEqual({
id: 'foo/baz', id: 'foo/baz',
unversionedId: 'foo/baz',
isDocsHomePage: false, isDocsHomePage: false,
permalink: '/docs/foo/bazSlug.html', permalink: '/docs/foo/bazSlug.html',
source: path.join('@site', routeBasePath, source), source: path.join('@site', routeBasePath, source),
@ -140,7 +143,6 @@ describe('simple site', () => {
editUrl: editUrl:
'https://github.com/facebook/docusaurus/edit/master/website/docs/foo/baz.md', 'https://github.com/facebook/docusaurus/edit/master/website/docs/foo/baz.md',
description: 'Images', description: 'Images',
latestVersionMainDocPermalink: undefined,
}); });
}); });
@ -160,13 +162,13 @@ describe('simple site', () => {
expect(data).toEqual({ expect(data).toEqual({
id: 'lorem', id: 'lorem',
unversionedId: 'lorem',
isDocsHomePage: false, isDocsHomePage: false,
permalink: '/docs/lorem', permalink: '/docs/lorem',
source: path.join('@site', routeBasePath, source), source: path.join('@site', routeBasePath, source),
title: 'lorem', title: 'lorem',
editUrl: 'https://github.com/customUrl/docs/lorem.md', editUrl: 'https://github.com/customUrl/docs/lorem.md',
description: 'Lorem ipsum.', description: 'Lorem ipsum.',
latestVersionMainDocPermalink: undefined,
}); });
// unrelated frontmatter is not part of metadata // unrelated frontmatter is not part of metadata
@ -192,6 +194,7 @@ describe('simple site', () => {
expect(data).toEqual({ expect(data).toEqual({
id: 'lorem', id: 'lorem',
unversionedId: 'lorem',
isDocsHomePage: false, isDocsHomePage: false,
permalink: '/docs/lorem', permalink: '/docs/lorem',
source: path.join('@site', routeBasePath, source), source: path.join('@site', routeBasePath, source),
@ -200,7 +203,6 @@ describe('simple site', () => {
description: 'Lorem ipsum.', description: 'Lorem ipsum.',
lastUpdatedAt: 1539502055, lastUpdatedAt: 1539502055,
lastUpdatedBy: 'Author', lastUpdatedBy: 'Author',
latestVersionMainDocPermalink: undefined,
}); });
}); });
@ -222,6 +224,7 @@ describe('simple site', () => {
expect(data).toEqual({ expect(data).toEqual({
id: 'ipsum', id: 'ipsum',
unversionedId: 'ipsum',
isDocsHomePage: false, isDocsHomePage: false,
permalink: '/docs/ipsum', permalink: '/docs/ipsum',
source: path.join('@site', routeBasePath, source), source: path.join('@site', routeBasePath, source),
@ -230,7 +233,6 @@ describe('simple site', () => {
description: 'Lorem ipsum.', description: 'Lorem ipsum.',
lastUpdatedAt: 1539502055, lastUpdatedAt: 1539502055,
lastUpdatedBy: 'Author', lastUpdatedBy: 'Author',
latestVersionMainDocPermalink: undefined,
}); });
}); });
@ -327,6 +329,7 @@ describe('versioned site', () => {
expect(dataA).toEqual({ expect(dataA).toEqual({
id: 'foo/bar', id: 'foo/bar',
unversionedId: 'foo/bar',
isDocsHomePage: false, isDocsHomePage: false,
permalink: '/docs/next/foo/barSlug', permalink: '/docs/next/foo/barSlug',
source: path.join('@site', routeBasePath, sourceA), source: path.join('@site', routeBasePath, sourceA),
@ -336,6 +339,7 @@ describe('versioned site', () => {
}); });
expect(dataB).toEqual({ expect(dataB).toEqual({
id: 'hello', id: 'hello',
unversionedId: 'hello',
isDocsHomePage: false, isDocsHomePage: false,
permalink: '/docs/next/hello', permalink: '/docs/next/hello',
source: path.join('@site', routeBasePath, sourceB), source: path.join('@site', routeBasePath, sourceB),
@ -387,6 +391,7 @@ describe('versioned site', () => {
expect(dataA).toEqual({ expect(dataA).toEqual({
id: 'version-1.0.0/foo/bar', id: 'version-1.0.0/foo/bar',
unversionedId: 'foo/bar',
isDocsHomePage: false, isDocsHomePage: false,
permalink: '/docs/1.0.0/foo/barSlug', permalink: '/docs/1.0.0/foo/barSlug',
source: path.join('@site', path.relative(siteDir, versionedDir), sourceA), source: path.join('@site', path.relative(siteDir, versionedDir), sourceA),
@ -396,6 +401,7 @@ describe('versioned site', () => {
}); });
expect(dataB).toEqual({ expect(dataB).toEqual({
id: 'version-1.0.0/hello', id: 'version-1.0.0/hello',
unversionedId: 'hello',
isDocsHomePage: false, isDocsHomePage: false,
permalink: '/docs/1.0.0/hello', permalink: '/docs/1.0.0/hello',
source: path.join('@site', path.relative(siteDir, versionedDir), sourceB), source: path.join('@site', path.relative(siteDir, versionedDir), sourceB),
@ -405,6 +411,7 @@ describe('versioned site', () => {
}); });
expect(dataC).toEqual({ expect(dataC).toEqual({
id: 'version-1.0.1/foo/bar', id: 'version-1.0.1/foo/bar',
unversionedId: 'foo/bar',
isDocsHomePage: false, isDocsHomePage: false,
permalink: '/docs/foo/bar', permalink: '/docs/foo/bar',
source: path.join('@site', path.relative(siteDir, versionedDir), sourceC), source: path.join('@site', path.relative(siteDir, versionedDir), sourceC),
@ -414,6 +421,7 @@ describe('versioned site', () => {
}); });
expect(dataD).toEqual({ expect(dataD).toEqual({
id: 'version-1.0.1/hello', id: 'version-1.0.1/hello',
unversionedId: 'hello',
isDocsHomePage: false, isDocsHomePage: false,
permalink: '/docs/hello', permalink: '/docs/hello',
source: path.join('@site', path.relative(siteDir, versionedDir), sourceD), source: path.join('@site', path.relative(siteDir, versionedDir), sourceD),

View file

@ -0,0 +1,144 @@
/**
* 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 {matchPath} from '@docusaurus/router';
import {GlobalPluginData, GlobalVersion, GlobalDoc} from '../types';
// This code is not part of the api surface, not in ./theme on purpose
// Short/convenient type aliases
type Version = GlobalVersion;
type Doc = GlobalDoc;
export type ActivePlugin = {
pluginId: string;
pluginData: GlobalPluginData;
};
// get the data of the plugin that is currently "active"
// ie the docs of that plugin are currently browsed
// it is useful to support multiple docs plugin instances
export const getActivePlugin = (
allPluginDatas: Record<string, GlobalPluginData>,
pathname: string,
): ActivePlugin | undefined => {
const activeEntry = Object.entries(allPluginDatas).find(
([_id, pluginData]) => {
return !!matchPath(pathname, {
path: `/${pluginData.path}`,
exact: false,
strict: false,
});
},
);
return activeEntry
? {pluginId: activeEntry[0], pluginData: activeEntry[1]}
: undefined;
};
export type ActiveDocContext = {
activeVersion?: Version;
activeDoc?: Doc;
alternateDocVersions: Record<string, Doc>;
};
export const getLatestVersion = (data: GlobalPluginData): Version => {
return data.versions.find(
(version) => version.name === data.latestVersionName,
)!;
};
// Note: return undefined on doc-unrelated pages,
// because there's no version currently considered as active
export const getActiveVersion = (
data: GlobalPluginData,
pathname: string,
): Version | undefined => {
const lastVersion = getLatestVersion(data);
// Last version is a route like /docs/*,
// we need to try to match it last or it would match /docs/version-1.0/* as well
const orderedVersionsMetadata = [
...data.versions.filter((version) => version !== lastVersion),
lastVersion,
];
return orderedVersionsMetadata.find((version) => {
return !!matchPath(pathname, {
path: version.path,
exact: false,
strict: false,
});
});
};
export const getActiveDocContext = (
data: GlobalPluginData,
pathname: string,
): ActiveDocContext => {
const activeVersion = getActiveVersion(data, pathname);
const activeDoc = activeVersion?.docs.find(
(doc) =>
!!matchPath(pathname, {
path: doc.path,
exact: true,
strict: false,
}),
);
function getAlternateVersionDocs(
docId: string,
): ActiveDocContext['alternateDocVersions'] {
const result: ActiveDocContext['alternateDocVersions'] = {};
data.versions.forEach((version) => {
version.docs.forEach((doc) => {
if (doc.id === docId) {
result[version.name!] = doc;
}
});
});
return result;
}
const alternateVersionDocs = activeDoc
? getAlternateVersionDocs(activeDoc.id)
: {};
return {
activeVersion,
activeDoc,
alternateDocVersions: alternateVersionDocs,
};
};
export type DocVersionSuggestions = {
// suggest the same doc, in latest version (if exist)
latestDocSuggestion?: GlobalDoc;
// suggest the latest version
latestVersionSuggestion?: GlobalVersion;
};
export const getDocVersionSuggestions = (
data: GlobalPluginData,
pathname: string,
): DocVersionSuggestions => {
const latestVersion = getLatestVersion(data);
const activeDocContext = getActiveDocContext(data, pathname);
// We only suggest another doc/version if user is not using the latest version
const isNotOnLatestVersion = activeDocContext.activeVersion !== latestVersion;
const latestDocSuggestion: GlobalDoc | undefined = isNotOnLatestVersion
? activeDocContext?.alternateDocVersions[latestVersion.name!]
: undefined;
const latestVersionSuggestion = isNotOnLatestVersion
? latestVersion
: undefined;
return {latestDocSuggestion, latestVersionSuggestion};
};

View file

@ -8,6 +8,7 @@
import groupBy from 'lodash.groupby'; import groupBy from 'lodash.groupby';
import pick from 'lodash.pick'; import pick from 'lodash.pick';
import pickBy from 'lodash.pickby'; import pickBy from 'lodash.pickby';
import sortBy from 'lodash.sortby';
import globby from 'globby'; import globby from 'globby';
import fs from 'fs-extra'; import fs from 'fs-extra';
import path from 'path'; import path from 'path';
@ -49,6 +50,10 @@ import {
VersionToSidebars, VersionToSidebars,
SidebarItem, SidebarItem,
DocsSidebarItem, DocsSidebarItem,
GlobalPluginData,
DocsVersion,
GlobalVersion,
GlobalDoc,
} from './types'; } from './types';
import {Configuration} from 'webpack'; import {Configuration} from 'webpack';
import {docsVersion} from './version'; import {docsVersion} from './version';
@ -56,22 +61,6 @@ import {VERSIONS_JSON_FILE} from './constants';
import {PluginOptionSchema} from './pluginOptionSchema'; import {PluginOptionSchema} from './pluginOptionSchema';
import {ValidationError} from '@hapi/joi'; import {ValidationError} from '@hapi/joi';
function getFirstDocLinkOfSidebar(
sidebarItems: DocsSidebarItem[],
): string | null {
for (const sidebarItem of sidebarItems) {
if (sidebarItem.type === 'category') {
const url = getFirstDocLinkOfSidebar(sidebarItem.items);
if (url) {
return url;
}
} else {
return sidebarItem.href;
}
}
return null;
}
export default function pluginContentDocs( export default function pluginContentDocs(
context: LoadContext, context: LoadContext,
options: PluginOptions, options: PluginOptions,
@ -92,6 +81,7 @@ export default function pluginContentDocs(
const dataDir = path.join( const dataDir = path.join(
generatedFilesDir, generatedFilesDir,
'docusaurus-plugin-content-docs', 'docusaurus-plugin-content-docs',
// options.id ?? 'default', // TODO support multi-instance
); );
// Versioning. // Versioning.
@ -329,11 +319,23 @@ Available document ids=
} }
const {docLayoutComponent, docItemComponent, routeBasePath} = options; const {docLayoutComponent, docItemComponent, routeBasePath} = options;
const {addRoute, createData} = actions; const {addRoute, createData, setGlobalData} = actions;
const pluginInstanceGlobalData: GlobalPluginData = {
path: options.path,
latestVersionName: versioning.latestVersion,
// Initialized empty, will be mutated
versions: [],
};
setGlobalData<GlobalPluginData>(pluginInstanceGlobalData);
const aliasedSource = (source: string) => const aliasedSource = (source: string) =>
`~docs/${path.relative(dataDir, source)}`; `~docs/${path.relative(dataDir, source)}`;
const createDocsBaseMetadata = (version?: string): DocsBaseMetadata => { const createDocsBaseMetadata = (
version: DocsVersion,
): DocsBaseMetadata => {
const {docsSidebars, permalinkToSidebar, versionToSidebars} = content; const {docsSidebars, permalinkToSidebar, versionToSidebars} = content;
const neededSidebars: Set<string> = const neededSidebars: Set<string> =
versionToSidebars[version!] || new Set(); versionToSidebars[version!] || new Set();
@ -377,13 +379,19 @@ Available document ids=
return routes.sort((a, b) => a.path.localeCompare(b.path)); return routes.sort((a, b) => a.path.localeCompare(b.path));
}; };
// We want latest version route to have lower priority
// Otherwise `/docs/next/foo` would match
// `/docs/:route` instead of `/docs/next/:route`.
const getVersionRoutePriority = (version: DocsVersion) =>
version === versioning.latestVersion ? -1 : undefined;
// This is the base route of the document root (for a doc given version) // This is the base route of the document root (for a doc given version)
// (/docs, /docs/next, /docs/1.0 etc...) // (/docs, /docs/next, /docs/1.0 etc...)
// The component applies the layout and renders the appropriate doc // The component applies the layout and renders the appropriate doc
const addBaseRoute = async ( const addVersionRoute = async (
docsBasePath: string, docsBasePath: string,
docsBaseMetadata: DocsBaseMetadata, docsBaseMetadata: DocsBaseMetadata,
routes: RouteConfig[], docs: Metadata[],
priority?: number, priority?: number,
) => { ) => {
const docsBaseMetadataPath = await createData( const docsBaseMetadataPath = await createData(
@ -391,18 +399,38 @@ Available document ids=
JSON.stringify(docsBaseMetadata, null, 2), JSON.stringify(docsBaseMetadata, null, 2),
); );
const docsRoutes = await genRoutes(docs);
const mainDoc: Metadata =
docs.find((doc) => doc.unversionedId === options.homePageId) ??
docs[0];
const toGlobalDataDoc = (doc: Metadata): GlobalDoc => ({
id: doc.unversionedId,
path: doc.permalink,
});
pluginInstanceGlobalData.versions.push({
name: docsBaseMetadata.version,
path: docsBasePath,
mainDocId: mainDoc.unversionedId,
docs: docs
.map(toGlobalDataDoc)
// stable ordering, useful for tests
.sort((a, b) => a.id.localeCompare(b.id)),
});
addRoute({ addRoute({
path: docsBasePath, path: docsBasePath,
exact: false, // allow matching /docs/* as well exact: false, // allow matching /docs/* as well
component: docLayoutComponent, // main docs component (DocPage) component: docLayoutComponent, // main docs component (DocPage)
routes, // subroute for each doc routes: docsRoutes, // subroute for each doc
modules: { modules: {
docsMetadata: aliasedSource(docsBaseMetadataPath), docsMetadata: aliasedSource(docsBaseMetadataPath),
}, },
priority, priority,
}); });
}; };
// If versioning is enabled, we cleverly chunk the generated routes // If versioning is enabled, we cleverly chunk the generated routes
// to be by version and pick only needed base metadata. // to be by version and pick only needed base metadata.
if (versioning.enabled) { if (versioning.enabled) {
@ -410,27 +438,10 @@ Available document ids=
Object.values(content.docsMetadata), Object.values(content.docsMetadata),
'version', 'version',
); );
const rootUrl =
options.homePageId && content.docsMetadata[options.homePageId]
? normalizeUrl([baseUrl, routeBasePath])
: getFirstDocLinkOfSidebar(
content.docsSidebars[
`version-${versioning.latestVersion}/docs`
],
);
if (!rootUrl) {
throw new Error('Bad sidebars file. No document linked');
}
Object.values(content.docsMetadata).forEach((docMetadata) => {
if (docMetadata.version !== versioning.latestVersion) {
docMetadata.latestVersionMainDocPermalink = rootUrl;
}
});
await Promise.all( await Promise.all(
Object.keys(docsMetadataByVersion).map(async (version) => { Object.keys(docsMetadataByVersion).map(async (version) => {
const routes: RouteConfig[] = await genRoutes( const docsMetadata = docsMetadataByVersion[version];
docsMetadataByVersion[version],
);
const isLatestVersion = version === versioning.latestVersion; const isLatestVersion = version === versioning.latestVersion;
const docsBaseRoute = normalizeUrl([ const docsBaseRoute = normalizeUrl([
@ -440,23 +451,29 @@ Available document ids=
]); ]);
const docsBaseMetadata = createDocsBaseMetadata(version); const docsBaseMetadata = createDocsBaseMetadata(version);
return addBaseRoute( await addVersionRoute(
docsBaseRoute, docsBaseRoute,
docsBaseMetadata, docsBaseMetadata,
routes, docsMetadata,
// We want latest version route config to be placed last in the getVersionRoutePriority(version),
// generated routeconfig. Otherwise, `/docs/next/foo` will match
// `/docs/:route` instead of `/docs/next/:route`.
isLatestVersion ? -1 : undefined,
); );
}), }),
); );
} else { } else {
const routes = await genRoutes(Object.values(content.docsMetadata)); const docsMetadata = Object.values(content.docsMetadata);
const docsBaseMetadata = createDocsBaseMetadata(); const docsBaseMetadata = createDocsBaseMetadata(null);
const docsBaseRoute = normalizeUrl([baseUrl, routeBasePath]); const docsBaseRoute = normalizeUrl([baseUrl, routeBasePath]);
await addBaseRoute(docsBaseRoute, docsBaseMetadata, routes); await addVersionRoute(docsBaseRoute, docsBaseMetadata, docsMetadata);
} }
// ensure version ordering on the global data (latest first)
pluginInstanceGlobalData.versions = sortBy(
pluginInstanceGlobalData.versions,
(versionMetadata: GlobalVersion) => {
const orderedVersionNames = ['next', ...versions];
return orderedVersionNames.indexOf(versionMetadata.name!);
},
);
}, },
async routesLoaded(routes) { async routesLoaded(routes) {

View file

@ -123,9 +123,9 @@ export default async function processMetadata({
throw new Error('Document id cannot include "/".'); throw new Error('Document id cannot include "/".');
} }
const id = dirName !== '.' ? `${dirName}/${baseID}` : baseID; const id = dirName !== '.' ? `${dirName}/${baseID}` : baseID;
const idWithoutVersion = version ? removeVersionPrefix(id, version) : id; const unversionedId = version ? removeVersionPrefix(id, version) : id;
const isDocsHomePage = idWithoutVersion === homePageId; const isDocsHomePage = unversionedId === homePageId;
if (frontMatter.slug && isDocsHomePage) { if (frontMatter.slug && isDocsHomePage) {
throw new Error( throw new Error(
`The docs homepage (homePageId=${homePageId}) is not allowed to have a frontmatter slug=${frontMatter.slug} => you have to chooser either homePageId or slug, not both`, `The docs homepage (homePageId=${homePageId}) is not allowed to have a frontmatter slug=${frontMatter.slug} => you have to chooser either homePageId or slug, not both`,
@ -169,6 +169,7 @@ export default async function processMetadata({
// Adding properties to object after instantiation will cause hidden // Adding properties to object after instantiation will cause hidden
// class transitions. // class transitions.
const metadata: MetadataRaw = { const metadata: MetadataRaw = {
unversionedId,
id, id,
isDocsHomePage, isDocsHomePage,
title, title,

View file

@ -0,0 +1,65 @@
/**
* 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 {useLocation} from '@docusaurus/router';
import {
useAllPluginInstancesData,
usePluginData,
} from '@docusaurus/useGlobalData';
import {GlobalPluginData, GlobalVersion} from '../../types';
import {
getActivePlugin,
getLatestVersion,
getActiveVersion,
getActiveDocContext,
getDocVersionSuggestions,
} from '../../client/docsClientUtils';
const useAllDocsData = (): Record<string, GlobalPluginData> =>
useAllPluginInstancesData('docusaurus-plugin-content-docs');
const useDocsData = (pluginId: string | undefined) =>
usePluginData('docusaurus-plugin-content-docs', pluginId) as GlobalPluginData;
export const useActivePlugin = () => {
const data = useAllDocsData();
const {pathname} = useLocation();
return getActivePlugin(data, pathname);
};
// versions are returned ordered (most recent first)
export const useVersions = (pluginId: string | undefined): GlobalVersion[] => {
const data = useDocsData(pluginId);
return data.versions;
};
export const useLatestVersion = (pluginId: string | undefined) => {
const data = useDocsData(pluginId);
return getLatestVersion(data);
};
// Note: return undefined on doc-unrelated pages,
// because there's no version currently considered as active
export const useActiveVersion = (pluginId: string | undefined) => {
const data = useDocsData(pluginId);
const {pathname} = useLocation();
return getActiveVersion(data, pathname);
};
export const useActiveDocContext = (pluginId: string | undefined) => {
const data = useDocsData(pluginId);
const {pathname} = useLocation();
return getActiveDocContext(data, pathname);
};
// Useful to say "hey, you are not on the latest docs version, please switch"
export const useDocVersionSuggestions = (pluginId: string | undefined) => {
const data = useDocsData(pluginId);
const {pathname} = useLocation();
return getDocVersionSuggestions(data, pathname);
};

View file

@ -5,6 +5,11 @@
* LICENSE file in the root directory of this source tree. * LICENSE file in the root directory of this source tree.
*/ */
// eslint-disable-next-line spaced-comment
/// <reference types="@docusaurus/module-type-aliases" />
export type DocsVersion = string | null; // null = unversioned sites
export interface MetadataOptions { export interface MetadataOptions {
routeBasePath: string; routeBasePath: string;
homePageId?: string; homePageId?: string;
@ -19,6 +24,7 @@ export interface PathOptions {
} }
export interface PluginOptions extends MetadataOptions, PathOptions { export interface PluginOptions extends MetadataOptions, PathOptions {
id?: string;
include: string[]; include: string[];
docLayoutComponent: string; docLayoutComponent: string;
docItemComponent: string; docItemComponent: string;
@ -112,6 +118,7 @@ export interface LastUpdateData {
} }
export interface MetadataRaw extends LastUpdateData { export interface MetadataRaw extends LastUpdateData {
unversionedId: string;
id: string; id: string;
isDocsHomePage: boolean; isDocsHomePage: boolean;
title: string; title: string;
@ -121,7 +128,6 @@ export interface MetadataRaw extends LastUpdateData {
sidebar_label?: string; sidebar_label?: string;
editUrl?: string; editUrl?: string;
version?: string; version?: string;
latestVersionMainDocPermalink?: string;
} }
export interface Paginator { export interface Paginator {
@ -167,7 +173,7 @@ export type DocsBaseMetadata = Pick<
LoadedContent, LoadedContent,
'docsSidebars' | 'permalinkToSidebar' 'docsSidebars' | 'permalinkToSidebar'
> & { > & {
version?: string; version: string | null;
}; };
export type VersioningEnv = { export type VersioningEnv = {
@ -182,3 +188,21 @@ export interface Env {
versioning: VersioningEnv; versioning: VersioningEnv;
// TODO: translation // TODO: translation
} }
export type GlobalDoc = {
id: string;
path: string;
};
export type GlobalVersion = {
name: DocsVersion;
path: string;
mainDocId: string; // home doc (if docs homepage configured), or first doc
docs: GlobalDoc[];
};
export type GlobalPluginData = {
path: string;
latestVersionName: DocsVersion;
versions: GlobalVersion[];
};

View file

@ -4,7 +4,8 @@
"description": "Debug plugin for Docusaurus", "description": "Debug plugin for Docusaurus",
"main": "lib/index.js", "main": "lib/index.js",
"scripts": { "scripts": {
"build": "tsc" "build": "tsc",
"watch": "tsc --watch"
}, },
"publishConfig": { "publishConfig": {
"access": "public" "access": "public"

View file

@ -7,7 +7,7 @@
const path = require('path'); const path = require('path');
const Module = require('module'); const Module = require('module');
const Joi = require('@hapi/joi'); const ThemeConfigSchema = require('./themeConfigSchema');
const createRequire = Module.createRequire || Module.createRequireFromPath; const createRequire = Module.createRequire || Module.createRequireFromPath;
const requireFromDocusaurusCore = createRequire( const requireFromDocusaurusCore = createRequire(
@ -120,88 +120,6 @@ module.exports = function (context, options) {
}; };
}; };
const NavbarLinkSchema = Joi.object({
items: Joi.array().optional().items(Joi.link('#navbarLinkSchema')),
to: Joi.string(),
href: Joi.string().uri(),
prependBaseUrlToHref: Joi.bool().default(true),
label: Joi.string(),
position: Joi.string().equal('left', 'right').default('left'),
activeBasePath: Joi.string(),
activeBaseRegex: Joi.string(),
className: Joi.string(),
'aria-label': Joi.string(),
})
.xor('href', 'to')
.id('navbarLinkSchema');
const ColorModeSchema = Joi.object({
defaultMode: Joi.string().equal('dark', 'light').default('light'),
disableSwitch: Joi.bool().default(false),
respectPrefersColorScheme: Joi.bool().default(false),
}).default({
defaultMode: 'light',
disableSwitch: false,
respectPrefersColorScheme: false,
});
const ThemeConfigSchema = Joi.object({
disableDarkMode: Joi.any().forbidden(false).messages({
'any.unknown':
'disableDarkMode theme config is deprecated. Please use the new colorMode attribute. You likely want: config.themeConfig.colorMode.disableSwitch = true',
}),
defaultDarkMode: Joi.any().forbidden(false).messages({
'any.unknown':
'defaultDarkMode theme config is deprecated. Please use the new colorMode attribute. You likely want: config.themeConfig.colorMode.defaultMode = "dark"',
}),
colorMode: ColorModeSchema,
image: Joi.string(),
announcementBar: Joi.object({
id: Joi.string(),
content: Joi.string(),
backgroundColor: Joi.string().default('#fff'),
textColor: Joi.string().default('#000'),
}).optional(),
navbar: Joi.object({
hideOnScroll: Joi.bool().default(false),
links: Joi.array().items(NavbarLinkSchema),
title: Joi.string().required(),
logo: Joi.object({
alt: Joi.string(),
src: Joi.string().required(),
srcDark: Joi.string(),
href: Joi.string(),
target: Joi.string(),
}),
}),
footer: Joi.object({
style: Joi.string().equal('dark', 'light').default('light'),
logo: Joi.object({
alt: Joi.string(),
src: Joi.string(),
href: Joi.string(),
}),
copyright: Joi.string(),
links: Joi.array().items(
Joi.object({
title: Joi.string().required(),
items: Joi.array().items(
Joi.object({
to: Joi.string(),
href: Joi.string().uri(),
html: Joi.string(),
label: Joi.string(),
})
.xor('to', 'href', 'html')
.with('to', 'label')
.with('href', 'label')
.nand('html', 'label'),
),
}),
),
}),
});
module.exports.validateThemeConfig = ({validate, themeConfig}) => { module.exports.validateThemeConfig = ({validate, themeConfig}) => {
return validate(ThemeConfigSchema, themeConfig); return validate(ThemeConfigSchema, themeConfig);
}; };

View file

@ -12,7 +12,7 @@ import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
import useBaseUrl from '@docusaurus/useBaseUrl'; import useBaseUrl from '@docusaurus/useBaseUrl';
import DocPaginator from '@theme/DocPaginator'; import DocPaginator from '@theme/DocPaginator';
import useTOCHighlight from '@theme/hooks/useTOCHighlight'; import useTOCHighlight from '@theme/hooks/useTOCHighlight';
import Link from '@docusaurus/Link'; import DocVersionSuggestions from '@theme/DocVersionSuggestions';
import clsx from 'clsx'; import clsx from 'clsx';
import styles from './styles.module.css'; import styles from './styles.module.css';
@ -71,7 +71,6 @@ function DocItem(props): JSX.Element {
lastUpdatedAt, lastUpdatedAt,
lastUpdatedBy, lastUpdatedBy,
version, version,
latestVersionMainDocPermalink,
} = metadata; } = metadata;
const { const {
frontMatter: { frontMatter: {
@ -84,7 +83,6 @@ function DocItem(props): JSX.Element {
const metaTitle = title ? `${title} | ${siteTitle}` : siteTitle; const metaTitle = title ? `${title} | ${siteTitle}` : siteTitle;
const metaImageUrl = useBaseUrl(metaImage, {absolute: true}); const metaImageUrl = useBaseUrl(metaImage, {absolute: true});
return ( return (
<> <>
<Head> <Head>
@ -112,33 +110,7 @@ function DocItem(props): JSX.Element {
className={clsx('col', { className={clsx('col', {
[styles.docItemCol]: !hideTableOfContents, [styles.docItemCol]: !hideTableOfContents,
})}> })}>
{latestVersionMainDocPermalink && ( <DocVersionSuggestions />
<div
className="alert alert--warning margin-bottom--md"
role="alert">
{version === 'next' ? (
<div>
This is unreleased documentation for {siteTitle}{' '}
<strong>{version}</strong> version.
</div>
) : (
<div>
This is archived documentation for {siteTitle}{' '}
<strong>v{version}</strong>, which is no longer actively
maintained.
</div>
)}
<div className="margin-top--md">
For up-to-date documentation, see the{' '}
<strong>
<Link to={latestVersionMainDocPermalink}>
latest version
</Link>
</strong>
.
</div>
</div>
)}
<div className={styles.docItemContainer}> <div className={styles.docItemContainer}>
<article> <article>
{version && ( {version && (

View file

@ -18,47 +18,56 @@ import {matchPath} from '@docusaurus/router';
import styles from './styles.module.css'; import styles from './styles.module.css';
function DocPage(props): JSX.Element { function DocPageContent({
const {route: baseRoute, docsMetadata, location} = props; currentDocRoute,
// case-sensitive route such as it is defined in the sidebar docsMetadata,
const currentRoute = children,
baseRoute.routes.find((route) => { }): JSX.Element {
return matchPath(location.pathname, route); const {siteConfig, isClient} = useDocusaurusContext();
}) || {};
const {permalinkToSidebar, docsSidebars, version} = docsMetadata; const {permalinkToSidebar, docsSidebars, version} = docsMetadata;
const sidebar = permalinkToSidebar[currentRoute.path]; const sidebarName = permalinkToSidebar[currentDocRoute.path];
const { const sidebar = docsSidebars[sidebarName];
siteConfig: {themeConfig = {}} = {},
isClient,
} = useDocusaurusContext();
const {sidebarCollapsible = true} = themeConfig;
if (Object.keys(currentRoute).length === 0) {
return <NotFound {...props} />;
}
return ( return (
<Layout version={version} key={isClient}> <Layout version={version} key={isClient}>
<div className={styles.docPage}> <div className={styles.docPage}>
{sidebar && ( {sidebar && (
<div className={styles.docSidebarContainer} role="complementary"> <div className={styles.docSidebarContainer} role="complementary">
<DocSidebar <DocSidebar
docsSidebars={docsSidebars}
path={currentRoute.path}
sidebar={sidebar} sidebar={sidebar}
sidebarCollapsible={sidebarCollapsible} path={currentDocRoute.path}
sidebarCollapsible={
siteConfig.themeConfig?.sidebarCollapsible ?? true
}
/> />
</div> </div>
)} )}
<main className={styles.docMainContainer}> <main className={styles.docMainContainer}>
<MDXProvider components={MDXComponents}> <MDXProvider components={MDXComponents}>{children}</MDXProvider>
{renderRoutes(baseRoute.routes)}
</MDXProvider>
</main> </main>
</div> </div>
</Layout> </Layout>
); );
} }
function DocPage(props) {
const {
route: {routes: docRoutes},
docsMetadata,
location,
} = props;
const currentDocRoute = docRoutes.find((docRoute) =>
matchPath(location.pathname, docRoute),
);
if (!currentDocRoute) {
return <NotFound {...props} />;
}
return (
<DocPageContent
currentDocRoute={currentDocRoute}
docsMetadata={docsMetadata}>
{renderRoutes(docRoutes)}
</DocPageContent>
);
}
export default DocPage; export default DocPage;

View file

@ -163,7 +163,11 @@ function DocSidebarItem(props) {
} }
} }
function DocSidebar(props): JSX.Element | null { function DocSidebar({
path,
sidebar,
sidebarCollapsible = true,
}): JSX.Element | null {
const [showResponsiveSidebar, setShowResponsiveSidebar] = useState(false); const [showResponsiveSidebar, setShowResponsiveSidebar] = useState(false);
const { const {
siteConfig: { siteConfig: {
@ -175,13 +179,6 @@ function DocSidebar(props): JSX.Element | null {
const {isAnnouncementBarClosed} = useUserPreferencesContext(); const {isAnnouncementBarClosed} = useUserPreferencesContext();
const {scrollY} = useScrollPosition(); const {scrollY} = useScrollPosition();
const {
docsSidebars,
path,
sidebar: currentSidebar,
sidebarCollapsible,
} = props;
useLockBodyScroll(showResponsiveSidebar); useLockBodyScroll(showResponsiveSidebar);
const windowSize = useWindowSize(); const windowSize = useWindowSize();
@ -191,18 +188,6 @@ function DocSidebar(props): JSX.Element | null {
} }
}, [windowSize]); }, [windowSize]);
if (!currentSidebar) {
return null;
}
const sidebarData = docsSidebars[currentSidebar];
if (!sidebarData) {
throw new Error(
`Cannot find the sidebar "${currentSidebar}" in the sidebar config!`,
);
}
return ( return (
<div <div
className={clsx(styles.sidebar, { className={clsx(styles.sidebar, {
@ -264,7 +249,7 @@ function DocSidebar(props): JSX.Element | null {
)} )}
</button> </button>
<ul className="menu__list"> <ul className="menu__list">
{sidebarData.map((item) => ( {sidebar.map((item) => (
<DocSidebarItem <DocSidebarItem
key={item.label} key={item.label}
item={item} item={item}

View file

@ -0,0 +1,78 @@
/**
* 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 useDocusaurusContext from '@docusaurus/useDocusaurusContext';
import Link from '@docusaurus/Link';
import {
useActivePlugin,
useActiveVersion,
useDocVersionSuggestions,
} from '@theme/hooks/useDocs';
const useMandatoryActiveDocsPluginId = () => {
const activePlugin = useActivePlugin();
if (!activePlugin) {
throw new Error(
'DocVersionCallout is only supposed to be used on docs-related routes',
);
}
return activePlugin.pluginId;
};
const getVersionMainDoc = (version) =>
version.docs.find((doc) => doc.id === version.mainDocId);
function DocVersionSuggestions(): JSX.Element {
const {
siteConfig: {title: siteTitle},
} = useDocusaurusContext();
const pluginId = useMandatoryActiveDocsPluginId();
const activeVersion = useActiveVersion(pluginId);
const {
latestDocSuggestion,
latestVersionSuggestion,
} = useDocVersionSuggestions(pluginId);
// No suggestion to be made
if (!latestVersionSuggestion) {
return <></>;
}
const activeVersionName = activeVersion.name;
// try to link to same doc in latest version (not always possible)
// fallback to main doc of latest version
const suggestedDoc =
latestDocSuggestion ?? getVersionMainDoc(latestVersionSuggestion);
return (
<div className="alert alert--warning margin-bottom--md" role="alert">
{activeVersionName === 'next' ? (
<div>
This is unreleased documentation for {siteTitle}{' '}
<strong>{activeVersionName}</strong> version.
</div>
) : (
<div>
This is documentation for {siteTitle}{' '}
<strong>v{activeVersionName}</strong>, which is no longer actively
maintained.
</div>
)}
<div className="margin-top--md">
For up-to-date documentation, see the{' '}
<strong>
<Link to={suggestedDoc.path}>latest version</Link>
</strong>{' '}
({latestVersionSuggestion.name}).
</div>
</div>
);
}
export default DocVersionSuggestions;

View file

@ -18,6 +18,14 @@ import Footer from '@theme/Footer';
import './styles.css'; import './styles.css';
function Providers({children}) {
return (
<ThemeProvider>
<UserPreferencesProvider>{children}</UserPreferencesProvider>
</ThemeProvider>
);
}
type Props = { type Props = {
children: ReactNode; children: ReactNode;
title?: string; title?: string;
@ -53,8 +61,7 @@ function Layout(props: Props): JSX.Element {
const faviconUrl = useBaseUrl(favicon); const faviconUrl = useBaseUrl(favicon);
return ( return (
<ThemeProvider> <Providers>
<UserPreferencesProvider>
<Head> <Head>
{/* TODO: Do not assume that it is in english language */} {/* TODO: Do not assume that it is in english language */}
<html lang="en" /> <html lang="en" />
@ -71,15 +78,11 @@ function Layout(props: Props): JSX.Element {
<meta name="keywords" content={keywords.join(',')} /> <meta name="keywords" content={keywords.join(',')} />
)} )}
{metaImage && <meta property="og:image" content={metaImageUrl} />} {metaImage && <meta property="og:image" content={metaImageUrl} />}
{metaImage && ( {metaImage && <meta property="twitter:image" content={metaImageUrl} />}
<meta property="twitter:image" content={metaImageUrl} />
)}
{metaImage && ( {metaImage && (
<meta name="twitter:image:alt" content={`Image for ${metaTitle}`} /> <meta name="twitter:image:alt" content={`Image for ${metaTitle}`} />
)} )}
{permalink && ( {permalink && <meta property="og:url" content={siteUrl + permalink} />}
<meta property="og:url" content={siteUrl + permalink} />
)}
{permalink && <link rel="canonical" href={siteUrl + permalink} />} {permalink && <link rel="canonical" href={siteUrl + permalink} />}
<meta name="twitter:card" content="summary_large_image" /> <meta name="twitter:card" content="summary_large_image" />
</Head> </Head>
@ -87,8 +90,7 @@ function Layout(props: Props): JSX.Element {
<Navbar /> <Navbar />
<div className="main-wrapper">{children}</div> <div className="main-wrapper">{children}</div>
{!noFooter && <Footer />} {!noFooter && <Footer />}
</UserPreferencesProvider> </Providers>
</ThemeProvider>
); );
} }

View file

@ -5,11 +5,10 @@
* LICENSE file in the root directory of this source tree. * LICENSE file in the root directory of this source tree.
*/ */
import React, {useCallback, useState, useEffect, ComponentProps} from 'react'; import React, {useCallback, useState, useEffect} from 'react';
import clsx from 'clsx'; import clsx from 'clsx';
import Link from '@docusaurus/Link'; import Link from '@docusaurus/Link';
import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
import useBaseUrl from '@docusaurus/useBaseUrl';
import SearchBar from '@theme/SearchBar'; import SearchBar from '@theme/SearchBar';
import Toggle from '@theme/Toggle'; import Toggle from '@theme/Toggle';
@ -20,163 +19,23 @@ import useWindowSize, {windowSizes} from '@theme/hooks/useWindowSize';
import useLogo from '@theme/hooks/useLogo'; import useLogo from '@theme/hooks/useLogo';
import styles from './styles.module.css'; import styles from './styles.module.css';
import NavbarItem from '@theme/NavbarItem';
// retrocompatible with v1 // retrocompatible with v1
const DefaultNavItemPosition = 'right'; const DefaultNavItemPosition = 'right';
function NavLink({
activeBasePath,
activeBaseRegex,
to,
href,
label,
activeClassName = 'navbar__link--active',
prependBaseUrlToHref,
...props
}: {
activeBasePath?: string;
activeBaseRegex?: string;
to?: string;
href?: string;
label?: string;
activeClassName?: string;
prependBaseUrlToHref?: string;
} & ComponentProps<'a'>) {
const toUrl = useBaseUrl(to);
const activeBaseUrl = useBaseUrl(activeBasePath);
const normalizedHref = useBaseUrl(href, {forcePrependBaseUrl: true});
return (
<Link
{...(href
? {
target: '_blank',
rel: 'noopener noreferrer',
href: prependBaseUrlToHref ? normalizedHref : href,
}
: {
isNavLink: true,
activeClassName,
to: toUrl,
...(activeBasePath || activeBaseRegex
? {
isActive: (_match, location) =>
activeBaseRegex
? new RegExp(activeBaseRegex).test(location.pathname)
: location.pathname.startsWith(activeBaseUrl),
}
: null),
})}
{...props}>
{label}
</Link>
);
}
function NavItem({
items,
position = DefaultNavItemPosition,
className,
...props
}) {
const navLinkClassNames = (extraClassName, isDropdownItem = false) =>
clsx(
{
'navbar__item navbar__link': !isDropdownItem,
dropdown__link: isDropdownItem,
},
extraClassName,
);
if (!items) {
return <NavLink className={navLinkClassNames(className)} {...props} />;
}
return (
<div
className={clsx('navbar__item', 'dropdown', 'dropdown--hoverable', {
'dropdown--left': position === 'left',
'dropdown--right': position === 'right',
})}>
<NavLink
className={navLinkClassNames(className)}
{...props}
onClick={(e) => e.preventDefault()}
onKeyDown={(e) => {
if (e.key === 'Enter') {
((e.target as HTMLElement)
.parentNode as HTMLElement).classList.toggle('dropdown--show');
}
}}>
{props.label}
</NavLink>
<ul className="dropdown__menu">
{items.map(({className: childItemClassName, ...childItemProps}, i) => (
<li key={i}>
<NavLink
activeClassName="dropdown__link--active"
className={navLinkClassNames(childItemClassName, true)}
{...childItemProps}
/>
</li>
))}
</ul>
</div>
);
}
function MobileNavItem({items, position: _position, className, ...props}) {
// Need to destructure position from props so that it doesn't get passed on.
const navLinkClassNames = (extraClassName, isSubList = false) =>
clsx(
'menu__link',
{
'menu__link--sublist': isSubList,
},
extraClassName,
);
if (!items) {
return (
<li className="menu__list-item">
<NavLink className={navLinkClassNames(className)} {...props} />
</li>
);
}
return (
<li className="menu__list-item">
<NavLink className={navLinkClassNames(className, true)} {...props}>
{props.label}
</NavLink>
<ul className="menu__list">
{items.map(({className: childItemClassName, ...childItemProps}, i) => (
<li className="menu__list-item" key={i}>
<NavLink
activeClassName="menu__link--active"
className={navLinkClassNames(childItemClassName)}
{...childItemProps}
onClick={props.onClick}
/>
</li>
))}
</ul>
</li>
);
}
// If split links by left/right // If split links by left/right
// if position is unspecified, fallback to right (as v1) // if position is unspecified, fallback to right (as v1)
function splitLinks(links) { function splitNavItemsByPosition(items) {
const leftLinks = links.filter( const leftItems = items.filter(
(linkItem) => (linkItem.position ?? DefaultNavItemPosition) === 'left', (item) => (item.position ?? DefaultNavItemPosition) === 'left',
); );
const rightLinks = links.filter( const rightItems = items.filter(
(linkItem) => (linkItem.position ?? DefaultNavItemPosition) === 'right', (item) => (item.position ?? DefaultNavItemPosition) === 'right',
); );
return { return {
leftLinks, leftItems,
rightLinks, rightItems,
}; };
} }
@ -184,7 +43,7 @@ function Navbar(): JSX.Element {
const { const {
siteConfig: { siteConfig: {
themeConfig: { themeConfig: {
navbar: {title = '', links = [], hideOnScroll = false} = {}, navbar: {title = '', items = [], hideOnScroll = false} = {},
colorMode: {disableSwitch: disableColorModeSwitch = false} = {}, colorMode: {disableSwitch: disableColorModeSwitch = false} = {},
}, },
}, },
@ -219,7 +78,7 @@ function Navbar(): JSX.Element {
} }
}, [windowSize]); }, [windowSize]);
const {leftLinks, rightLinks} = splitLinks(links); const {leftItems, rightItems} = splitNavItemsByPosition(items);
return ( return (
<nav <nav
@ -231,7 +90,7 @@ function Navbar(): JSX.Element {
})}> })}>
<div className="navbar__inner"> <div className="navbar__inner">
<div className="navbar__items"> <div className="navbar__items">
{links != null && links.length !== 0 && ( {items != null && items.length !== 0 && (
<div <div
aria-label="Navigation bar toggle" aria-label="Navigation bar toggle"
className="navbar__toggle" className="navbar__toggle"
@ -275,13 +134,13 @@ function Navbar(): JSX.Element {
</strong> </strong>
)} )}
</Link> </Link>
{leftLinks.map((linkItem, i) => ( {leftItems.map((item, i) => (
<NavItem {...linkItem} key={i} /> <NavbarItem {...item} key={i} />
))} ))}
</div> </div>
<div className="navbar__items navbar__items--right"> <div className="navbar__items navbar__items--right">
{rightLinks.map((linkItem, i) => ( {rightItems.map((item, i) => (
<NavItem {...linkItem} key={i} /> <NavbarItem {...item} key={i} />
))} ))}
{!disableColorModeSwitch && ( {!disableColorModeSwitch && (
<Toggle <Toggle
@ -332,8 +191,8 @@ function Navbar(): JSX.Element {
<div className="navbar-sidebar__items"> <div className="navbar-sidebar__items">
<div className="menu"> <div className="menu">
<ul className="menu__list"> <ul className="menu__list">
{links.map((linkItem, i) => ( {items.map((item, i) => (
<MobileNavItem {...linkItem} onClick={hideSidebar} key={i} /> <NavbarItem mobile {...item} onClick={hideSidebar} key={i} />
))} ))}
</ul> </ul>
</div> </div>

View file

@ -0,0 +1,154 @@
/**
* 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, {ComponentProps, ComponentType} from 'react';
import clsx from 'clsx';
import Link from '@docusaurus/Link';
import useBaseUrl from '@docusaurus/useBaseUrl';
function NavLink({
activeBasePath,
activeBaseRegex,
to,
href,
label,
activeClassName = 'navbar__link--active',
prependBaseUrlToHref,
...props
}: {
activeBasePath?: string;
activeBaseRegex?: string;
to?: string;
href?: string;
label?: string;
activeClassName?: string;
prependBaseUrlToHref?: string;
} & ComponentProps<'a'>) {
const toUrl = useBaseUrl(to);
const activeBaseUrl = useBaseUrl(activeBasePath);
const normalizedHref = useBaseUrl(href, {forcePrependBaseUrl: true});
return (
<Link
{...(href
? {
target: '_blank',
rel: 'noopener noreferrer',
href: prependBaseUrlToHref ? normalizedHref : href,
}
: {
isNavLink: true,
activeClassName,
to: toUrl,
...(activeBasePath || activeBaseRegex
? {
isActive: (_match, location) =>
activeBaseRegex
? new RegExp(activeBaseRegex).test(location.pathname)
: location.pathname.startsWith(activeBaseUrl),
}
: null),
})}
{...props}>
{label}
</Link>
);
}
function NavItemDesktop({items, position, className, ...props}) {
const navLinkClassNames = (extraClassName, isDropdownItem = false) =>
clsx(
{
'navbar__item navbar__link': !isDropdownItem,
dropdown__link: isDropdownItem,
},
extraClassName,
);
if (!items) {
return <NavLink className={navLinkClassNames(className)} {...props} />;
}
return (
<div
className={clsx('navbar__item', 'dropdown', 'dropdown--hoverable', {
'dropdown--left': position === 'left',
'dropdown--right': position === 'right',
})}>
<NavLink
className={navLinkClassNames(className)}
{...props}
onClick={(e) => e.preventDefault()}
onKeyDown={(e) => {
if (e.key === 'Enter') {
((e.target as HTMLElement)
.parentNode as HTMLElement).classList.toggle('dropdown--show');
}
}}>
{props.label}
</NavLink>
<ul className="dropdown__menu">
{items.map(({className: childItemClassName, ...childItemProps}, i) => (
<li key={i}>
<NavLink
activeClassName="dropdown__link--active"
className={navLinkClassNames(childItemClassName, true)}
{...childItemProps}
/>
</li>
))}
</ul>
</div>
);
}
function NavItemMobile({items, position: _position, className, ...props}) {
// Need to destructure position from props so that it doesn't get passed on.
const navLinkClassNames = (extraClassName, isSubList = false) =>
clsx(
'menu__link',
{
'menu__link--sublist': isSubList,
},
extraClassName,
);
if (!items) {
return (
<li className="menu__list-item">
<NavLink className={navLinkClassNames(className)} {...props} />
</li>
);
}
return (
<li className="menu__list-item">
<NavLink className={navLinkClassNames(className, true)} {...props}>
{props.label}
</NavLink>
<ul className="menu__list">
{items.map(({className: childItemClassName, ...childItemProps}, i) => (
<li className="menu__list-item" key={i}>
<NavLink
activeClassName="menu__link--active"
className={navLinkClassNames(childItemClassName)}
{...childItemProps}
onClick={props.onClick}
/>
</li>
))}
</ul>
</li>
);
}
function DefaultNavbarItem({mobile = false, ...props}) {
const Comp: ComponentType<any> = mobile ? NavItemMobile : NavItemDesktop;
return <Comp {...props} />;
}
export default DefaultNavbarItem;

View file

@ -0,0 +1,54 @@
/**
* 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 DefaultNavbarItem from './DefaultNavbarItem';
import {
useVersions,
useLatestVersion,
useActiveDocContext,
} from '@theme/hooks/useDocs';
const versionLabel = (version) =>
version.name === 'next' ? 'Next/Master' : version.name;
const getVersionMainDoc = (version) =>
version.docs.find((doc) => doc.id === version.mainDocId);
export default function DocsVersionDropdownNavbarItem({
docsPluginId,
...props
}) {
const activeDocContext = useActiveDocContext(docsPluginId);
const versions = useVersions(docsPluginId);
const latestVersion = useLatestVersion(docsPluginId);
const items = versions.map((version) => {
// We try to link to the same doc, in another version
// When not possible, fallback to the "main doc" of the version
const versionDoc =
activeDocContext?.alternateDocVersions[version.name] ||
getVersionMainDoc(version);
return {
isNavLink: true,
label: versionLabel(version),
to: versionDoc.path,
isActive: () => version === activeDocContext?.activeVersion,
};
});
const dropdownVersion = activeDocContext.activeVersion ?? latestVersion;
return (
<DefaultNavbarItem
{...props}
label={versionLabel(dropdownVersion)}
to={getVersionMainDoc(dropdownVersion).path}
items={items}
/>
);
}

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 DefaultNavbarItem from './DefaultNavbarItem';
import {useActiveVersion, useLatestVersion} from '@theme/hooks/useDocs';
const getVersionMainDoc = (version) =>
version.docs.find((doc) => doc.id === version.mainDocId);
export default function DocsVersionNavbarItem({
label: staticLabel,
to: staticTo,
docsPluginId,
...props
}) {
const activeVersion = useActiveVersion(docsPluginId);
const latestVersion = useLatestVersion(docsPluginId);
const version = activeVersion ?? latestVersion;
const label = staticLabel ?? version.name;
const path = staticTo ?? getVersionMainDoc(version).path;
return <DefaultNavbarItem {...props} label={label} to={path} />;
}

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 DocsVersionNavbarItem from '@theme/NavbarItem/DocsVersionNavbarItem';
import DefaultNavbarItem from '@theme/NavbarItem/DefaultNavbarItem';
import DocsVersionDropdownNavbarItem from '@theme/NavbarItem/DocsVersionDropdownNavbarItem';
const NavbarItemComponents = {
default: DefaultNavbarItem,
docsVersion: DocsVersionNavbarItem,
docsVersionDropdown: DocsVersionDropdownNavbarItem,
};
const getNavbarItemComponent = (type: string = 'default') => {
const NavbarItemComponent = NavbarItemComponents[type];
if (!NavbarItemComponent) {
throw new Error(`No NavbarItem component found for type=${type}.`);
}
return NavbarItemComponent;
};
export default function NavbarItem({type, ...props}) {
const NavbarItemComponent = getNavbarItemComponent(type);
return <NavbarItemComponent {...props} />;
}

View file

@ -0,0 +1,168 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
const Joi = require('@hapi/joi');
const NavbarItemPosition = Joi.string().equal('left', 'right').default('left');
const DefaultNavbarItemSchema = Joi.object({
items: Joi.array().optional().items(Joi.link('...')),
to: Joi.string(),
href: Joi.string().uri(),
prependBaseUrlToHref: Joi.bool().default(true),
label: Joi.string(),
position: NavbarItemPosition,
activeBasePath: Joi.string(),
activeBaseRegex: Joi.string(),
className: Joi.string(),
'aria-label': Joi.string(),
}).xor('href', 'to');
const DocsVersionNavbarItemSchema = Joi.object({
type: Joi.string().equal('docsVersion').required(),
position: NavbarItemPosition,
label: Joi.string(),
to: Joi.string(),
docsPluginId: Joi.string(),
});
const DocsVersionDropdownNavbarItemSchema = Joi.object({
type: Joi.string().equal('docsVersionDropdown').required(),
position: NavbarItemPosition,
docsPluginId: Joi.string(),
});
// Can this be made easier? :/
const isOfType = (type) => {
let typeSchema = Joi.string().required();
// because equal(undefined) is not supported :/
if (type) {
typeSchema = typeSchema.equal(type);
}
return Joi.object({
type: typeSchema,
})
.unknown()
.required();
};
const NavbarItemSchema = Joi.object().when({
switch: [
{
is: isOfType('docsVersion'),
then: DocsVersionNavbarItemSchema,
},
{
is: isOfType('docsVersionDropdown'),
then: DocsVersionDropdownNavbarItemSchema,
},
{
is: isOfType(undefined),
then: Joi.forbidden().messages({
'any.unknown': 'Bad nav item type {.type}',
}),
},
],
otherwise: DefaultNavbarItemSchema,
});
/*
const NavbarItemSchema = Joi.object({
type: Joi.string().only(['docsVersion'])
})
.when(Joi.object({ type: 'docsVersion' }).unknown(), {
then: Joi.object({ pepperoni: Joi.boolean() })
})
.when(Joi.object().unknown(), {
then: Joi.object({ croutons: Joi.boolean() })
})
*/
/*
const NavbarItemSchema = Joi.object().when('type', {
is: Joi.valid('docsVersion'),
then: DocsVersionNavbarItemSchema,
otherwise: DefaultNavbarItemSchema,
});
*/
const ColorModeSchema = Joi.object({
defaultMode: Joi.string().equal('dark', 'light').default('light'),
disableSwitch: Joi.bool().default(false),
respectPrefersColorScheme: Joi.bool().default(false),
}).default({
defaultMode: 'light',
disableSwitch: false,
respectPrefersColorScheme: false,
});
const ThemeConfigSchema = Joi.object({
// TODO temporary (@alpha-58)
disableDarkMode: Joi.any().forbidden(false).messages({
'any.unknown':
'disableDarkMode theme config is deprecated. Please use the new colorMode attribute. You likely want: config.themeConfig.colorMode.disableSwitch = true',
}),
// TODO temporary (@alpha-58)
defaultDarkMode: Joi.any().forbidden(false).messages({
'any.unknown':
'defaultDarkMode theme config is deprecated. Please use the new colorMode attribute. You likely want: config.themeConfig.colorMode.defaultMode = "dark"',
}),
colorMode: ColorModeSchema,
image: Joi.string(),
announcementBar: Joi.object({
id: Joi.string(),
content: Joi.string(),
backgroundColor: Joi.string().default('#fff'),
textColor: Joi.string().default('#000'),
}).optional(),
navbar: Joi.object({
hideOnScroll: Joi.bool().default(false),
// TODO temporary (@alpha-58)
links: Joi.any().forbidden().messages({
'any.unknown':
'themeConfig.navbar.links has been renamed as themeConfig.navbar.items',
}),
items: Joi.array().items(NavbarItemSchema),
title: Joi.string().required(),
logo: Joi.object({
alt: Joi.string(),
src: Joi.string().required(),
srcDark: Joi.string(),
href: Joi.string(),
target: Joi.string(),
}),
}),
footer: Joi.object({
style: Joi.string().equal('dark', 'light').default('light'),
logo: Joi.object({
alt: Joi.string(),
src: Joi.string(),
href: Joi.string(),
}),
copyright: Joi.string(),
links: Joi.array().items(
Joi.object({
title: Joi.string().required(),
items: Joi.array().items(
Joi.object({
to: Joi.string(),
href: Joi.string().uri(),
html: Joi.string(),
label: Joi.string(),
})
.xor('to', 'href', 'html')
.with('to', 'label')
.with('href', 'label')
.nand('html', 'label'),
),
}),
),
}),
});
module.exports = ThemeConfigSchema;

View file

@ -65,6 +65,7 @@ export interface DocusaurusSiteMetadata {
export interface DocusaurusContext { export interface DocusaurusContext {
siteConfig: DocusaurusConfig; siteConfig: DocusaurusConfig;
siteMetadata: DocusaurusSiteMetadata; siteMetadata: DocusaurusSiteMetadata;
globalData: Record<string, any>;
isClient: boolean; isClient: boolean;
} }
@ -117,9 +118,11 @@ export interface Props extends LoadContext, InjectedHtmlTags {
export interface PluginContentLoadedActions { export interface PluginContentLoadedActions {
addRoute(config: RouteConfig): void; addRoute(config: RouteConfig): void;
createData(name: string, data: any): Promise<string>; createData(name: string, data: any): Promise<string>;
setGlobalData<T = unknown>(data: T): void;
} }
export interface Plugin<T, U = unknown> { export interface Plugin<T, U = unknown> {
id?: string;
name: string; name: string;
loadContent?(): Promise<T>; loadContent?(): Promise<T>;
validateOptions?(): ValidationResult<U>; validateOptions?(): ValidationResult<U>;
@ -154,10 +157,9 @@ export interface Plugin<T, U = unknown> {
export type ConfigureWebpackFn = Plugin<unknown>['configureWebpack']; export type ConfigureWebpackFn = Plugin<unknown>['configureWebpack'];
export type ConfigureWebpackFnMergeStrategy = Record<string, MergeStrategy>; export type ConfigureWebpackFnMergeStrategy = Record<string, MergeStrategy>;
export type PluginConfig = export type PluginOptions = {id?: string} & Record<string, unknown>;
| [string, Record<string, unknown>]
| [string] export type PluginConfig = [string, PluginOptions] | [string] | string;
| string;
export interface ChunkRegistry { export interface ChunkRegistry {
loader: string; loader: string;
@ -248,7 +250,9 @@ export interface ThemeConfigValidationContext<T, E extends Error = Error> {
themeConfig: Partial<T>; themeConfig: Partial<T>;
} }
// TODO we should use a Joi type here
export interface ValidationSchema<T> { export interface ValidationSchema<T> {
validate(options: Partial<T>, opt: object): ValidationResult<T>; validate(options: Partial<T>, opt: object): ValidationResult<T>;
unknown(): ValidationSchema<T>; unknown(): ValidationSchema<T>;
append(data: any): ValidationSchema<T>;
} }

View file

@ -9,6 +9,7 @@ import React, {useEffect, useState} from 'react';
import routes from '@generated/routes'; import routes from '@generated/routes';
import siteConfig from '@generated/docusaurus.config'; import siteConfig from '@generated/docusaurus.config';
import globalData from '@generated/globalData';
import siteMetadata from '@generated/site-metadata'; import siteMetadata from '@generated/site-metadata';
import renderRoutes from './exports/renderRoutes'; import renderRoutes from './exports/renderRoutes';
import DocusaurusContext from './exports/context'; import DocusaurusContext from './exports/context';
@ -24,7 +25,8 @@ function App(): JSX.Element {
}, []); }, []);
return ( return (
<DocusaurusContext.Provider value={{siteConfig, siteMetadata, isClient}}> <DocusaurusContext.Provider
value={{siteConfig, siteMetadata, globalData, isClient}}>
<PendingNavigation routes={routes}> <PendingNavigation routes={routes}>
{renderRoutes(routes)} {renderRoutes(routes)}
</PendingNavigation> </PendingNavigation>

View file

@ -20,11 +20,12 @@ declare global {
interface Props { interface Props {
readonly isNavLink?: boolean; readonly isNavLink?: boolean;
readonly to?: string; readonly to?: string;
readonly activeClassName?: string;
readonly href: string; readonly href: string;
readonly children?: ReactNode; readonly children?: ReactNode;
} }
function Link({isNavLink, ...props}: Props): JSX.Element { function Link({isNavLink, activeClassName, ...props}: Props): JSX.Element {
const {to, href} = props; const {to, href} = props;
const targetLink = to || href; const targetLink = to || href;
const isInternal = isInternalUrl(targetLink); const isInternal = isInternalUrl(targetLink);
@ -97,6 +98,8 @@ function Link({isNavLink, ...props}: Props): JSX.Element {
onMouseEnter={onMouseEnter} onMouseEnter={onMouseEnter}
innerRef={handleRef} innerRef={handleRef}
to={targetLink} to={targetLink}
// avoid "React does not recognize the `activeClassName` prop on a DOM element"
{...(isNavLink && {activeClassName})}
/> />
); );
} }

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.
*/
import useDocusaurusContext from './useDocusaurusContext';
export default function useGlobalData() {
const {globalData} = useDocusaurusContext();
if (!globalData) {
throw new Error('Docusaurus global data not found');
}
return globalData;
}
export function useAllPluginInstancesData<T = unknown>(
pluginName: string,
): Record<string, T> {
const globalData = useGlobalData();
const pluginGlobalData = globalData[pluginName];
if (!pluginGlobalData) {
throw new Error(
`Docusaurus plugin global data not found for pluginName=${pluginName}`,
);
}
return pluginGlobalData;
}
export function usePluginData<T = unknown>(
pluginName: string,
pluginId: string = 'default',
): T {
const pluginGlobalData = useAllPluginInstancesData(pluginName);
const pluginInstanceGlobalData = pluginGlobalData[pluginId];
if (!pluginInstanceGlobalData) {
throw new Error(
`Docusaurus plugin global data not found for pluginName=${pluginName} and pluginId=${pluginId}`,
);
}
return pluginInstanceGlobalData as T;
}

View file

@ -30,6 +30,7 @@ Object {
], ],
"@docusaurus/plugin-content-pages", "@docusaurus/plugin-content-pages",
], ],
"presets": Array [],
"projectName": "hello", "projectName": "hello",
"tagline": "Hello World", "tagline": "Hello World",
"themeConfig": Object {}, "themeConfig": Object {},

View file

@ -0,0 +1,16 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ensureUniquePluginInstanceIds reject multi instance plugins with same id 1`] = `
"Plugin plugin-docs is used 2 times with id=sameId.
To use the same plugin multiple times on a Docusaurus site, you need to assign a unique id to each plugin instance."
`;
exports[`ensureUniquePluginInstanceIds reject multi instance plugins without id 1`] = `
"Plugin plugin-docs is used 2 times with id=default.
To use the same plugin multiple times on a Docusaurus site, you need to assign a unique id to each plugin instance."
`;
exports[`ensureUniquePluginInstanceIds reject multi instance plugins without id 2`] = `
"Plugin plugin-docs is used 2 times with id=default.
To use the same plugin multiple times on a Docusaurus site, you need to assign a unique id to each plugin instance."
`;

View file

@ -0,0 +1,74 @@
/**
* 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 {ensureUniquePluginInstanceIds} from '../../plugins/pluginIds';
import {InitPlugin} from '../../plugins/init';
function createTestPlugin(name: string, id?: string): InitPlugin {
// @ts-expect-error: good enough for tests
return {
name,
options: {id},
};
}
describe('ensureUniquePluginInstanceIds', () => {
test('accept single instance plugins', async () => {
ensureUniquePluginInstanceIds([
createTestPlugin('plugin-docs'),
createTestPlugin('plugin-blog'),
createTestPlugin('plugin-pages'),
]);
});
test('accept single instance plugins, all with sameId', async () => {
ensureUniquePluginInstanceIds([
createTestPlugin('plugin-docs', 'sameId'),
createTestPlugin('plugin-blog', 'sameId'),
createTestPlugin('plugin-pages', 'sameId'),
]);
});
test('accept multi instance plugins without id', async () => {
ensureUniquePluginInstanceIds([
createTestPlugin('plugin-docs', 'ios'),
createTestPlugin('plugin-docs', 'android'),
createTestPlugin('plugin-pages', 'pages'),
]);
});
test('reject multi instance plugins without id', async () => {
expect(() =>
ensureUniquePluginInstanceIds([
createTestPlugin('plugin-docs'),
createTestPlugin('plugin-docs'),
]),
).toThrowErrorMatchingSnapshot();
});
test('reject multi instance plugins with same id', async () => {
expect(() =>
ensureUniquePluginInstanceIds([
createTestPlugin('plugin-docs', 'sameId'),
createTestPlugin('plugin-docs', 'sameId'),
]),
).toThrowErrorMatchingSnapshot();
});
test('reject multi instance plugins without id', async () => {
expect(() =>
ensureUniquePluginInstanceIds([
createTestPlugin('plugin-docs'),
createTestPlugin('plugin-docs', 'ios'),
createTestPlugin('plugin-docs'),
createTestPlugin('plugin-pages'),
createTestPlugin('plugin-pages', 'pages2'),
]),
).toThrowErrorMatchingSnapshot();
});
});

View file

@ -12,6 +12,7 @@ import {CONFIG_FILE_NAME} from '../constants';
export const DEFAULT_CONFIG: { export const DEFAULT_CONFIG: {
plugins: PluginConfig[]; plugins: PluginConfig[];
themes: PluginConfig[]; themes: PluginConfig[];
presets: PluginConfig[];
customFields: { customFields: {
[key: string]: unknown; [key: string]: unknown;
}; };
@ -21,10 +22,26 @@ export const DEFAULT_CONFIG: {
} = { } = {
plugins: [], plugins: [],
themes: [], themes: [],
presets: [],
customFields: {}, customFields: {},
themeConfig: {}, themeConfig: {},
}; };
const PluginSchema = Joi.alternatives().try(
Joi.string(),
Joi.array().items(Joi.string().required(), Joi.object().required()).length(2),
);
const ThemeSchema = Joi.alternatives().try(
Joi.string(),
Joi.array().items(Joi.string().required(), Joi.object().required()).length(2),
);
const PresetSchema = Joi.alternatives().try(
Joi.string(),
Joi.array().items(Joi.string().required(), Joi.object().required()).length(2),
);
const ConfigSchema = Joi.object({ const ConfigSchema = Joi.object({
baseUrl: Joi.string() baseUrl: Joi.string()
.required() .required()
@ -37,33 +54,9 @@ const ConfigSchema = Joi.object({
projectName: Joi.string(), projectName: Joi.string(),
customFields: Joi.object().unknown().default(DEFAULT_CONFIG.customFields), customFields: Joi.object().unknown().default(DEFAULT_CONFIG.customFields),
githubHost: Joi.string(), githubHost: Joi.string(),
plugins: Joi.array() plugins: Joi.array().items(PluginSchema).default(DEFAULT_CONFIG.plugins),
.items( themes: Joi.array().items(ThemeSchema).default(DEFAULT_CONFIG.themes),
Joi.alternatives().try( presets: Joi.array().items(PresetSchema).default(DEFAULT_CONFIG.presets),
Joi.string(),
Joi.array()
.items(Joi.string().required(), Joi.object().required())
.length(2),
),
)
.default(DEFAULT_CONFIG.plugins),
themes: Joi.array()
.items(
Joi.alternatives().try(
Joi.string(),
Joi.array()
.items(Joi.string().required(), Joi.object().required())
.length(2),
),
)
.default(DEFAULT_CONFIG.themes),
presets: Joi.array().items(
Joi.alternatives().try(
Joi.string(),
Joi.array().items(Joi.string(), Joi.object()).length(2),
),
),
themeConfig: Joi.object().unknown().default(DEFAULT_CONFIG.themeConfig), themeConfig: Joi.object().unknown().default(DEFAULT_CONFIG.themeConfig),
scripts: Joi.array().items( scripts: Joi.array().items(
Joi.string(), Joi.string(),

View file

@ -79,7 +79,7 @@ export async function load(
// Plugins. // Plugins.
const pluginConfigs: PluginConfig[] = loadPluginConfigs(context); const pluginConfigs: PluginConfig[] = loadPluginConfigs(context);
const {plugins, pluginsRouteConfigs} = await loadPlugins({ const {plugins, pluginsRouteConfigs, globalData} = await loadPlugins({
pluginConfigs, pluginConfigs,
context, context,
}); });
@ -98,6 +98,7 @@ export async function load(
const {stylesheets = [], scripts = []} = siteConfig; const {stylesheets = [], scripts = []} = siteConfig;
plugins.push({ plugins.push({
name: 'docusaurus-bootstrap-plugin', name: 'docusaurus-bootstrap-plugin',
options: {},
version: {type: 'synthetic'}, version: {type: 'synthetic'},
configureWebpack: () => ({ configureWebpack: () => ({
resolve: { resolve: {
@ -181,6 +182,12 @@ ${Object.keys(registry)
const genRoutes = generate(generatedFilesDir, 'routes.js', routesConfig); const genRoutes = generate(generatedFilesDir, 'routes.js', routesConfig);
const genGlobalData = generate(
generatedFilesDir,
'globalData.json',
JSON.stringify(globalData, null, 2),
);
// Version metadata. // Version metadata.
const siteMetadata: DocusaurusSiteMetadata = { const siteMetadata: DocusaurusSiteMetadata = {
docusaurusVersion: getPackageJsonVersion( docusaurusVersion: getPackageJsonVersion(
@ -206,6 +213,7 @@ ${Object.keys(registry)
genRegistry, genRegistry,
genRoutesChunkNames, genRoutesChunkNames,
genRoutes, genRoutes,
genGlobalData,
genSiteMetadata, genSiteMetadata,
]); ]);

View file

@ -14,7 +14,9 @@ import {
PluginContentLoadedActions, PluginContentLoadedActions,
RouteConfig, RouteConfig,
} from '@docusaurus/types'; } from '@docusaurus/types';
import initPlugins, {PluginWithVersionInformation} from './init'; import initPlugins, {InitPlugin} from './init';
const DefaultPluginId = 'default';
export function sortConfig(routeConfigs: RouteConfig[]): void { export function sortConfig(routeConfigs: RouteConfig[]): void {
// Sort the route config. This ensures that route with nested // Sort the route config. This ensures that route with nested
@ -52,11 +54,12 @@ export async function loadPlugins({
pluginConfigs: PluginConfig[]; pluginConfigs: PluginConfig[];
context: LoadContext; context: LoadContext;
}): Promise<{ }): Promise<{
plugins: PluginWithVersionInformation[]; plugins: InitPlugin[];
pluginsRouteConfigs: RouteConfig[]; pluginsRouteConfigs: RouteConfig[];
globalData: any;
}> { }> {
// 1. Plugin Lifecycle - Initialization/Constructor. // 1. Plugin Lifecycle - Initialization/Constructor.
const plugins: PluginWithVersionInformation[] = initPlugins({ const plugins: InitPlugin[] = initPlugins({
pluginConfigs, pluginConfigs,
context, context,
}); });
@ -78,25 +81,50 @@ export async function loadPlugins({
// 3. Plugin Lifecycle - contentLoaded. // 3. Plugin Lifecycle - contentLoaded.
const pluginsRouteConfigs: RouteConfig[] = []; const pluginsRouteConfigs: RouteConfig[] = [];
const globalData = {};
await Promise.all( await Promise.all(
plugins.map(async (plugin, index) => { plugins.map(async (plugin, index) => {
if (!plugin.contentLoaded) { if (!plugin.contentLoaded) {
return; return;
} }
const pluginId = plugin.options.id ?? DefaultPluginId;
const pluginContentDir = path.join( const pluginContentDir = path.join(
context.generatedFilesDir, context.generatedFilesDir,
plugin.name, plugin.name,
// TODO each plugin instance should have its folder
// pluginId,
); );
const actions: PluginContentLoadedActions = { const addRoute: PluginContentLoadedActions['addRoute'] = (config) =>
addRoute: (config) => pluginsRouteConfigs.push(config), pluginsRouteConfigs.push(config);
createData: async (name, content) => {
const createData: PluginContentLoadedActions['createData'] = async (
name,
content,
) => {
const modulePath = path.join(pluginContentDir, name); const modulePath = path.join(pluginContentDir, name);
await fs.ensureDir(path.dirname(modulePath)); await fs.ensureDir(path.dirname(modulePath));
await generate(pluginContentDir, name, content); await generate(pluginContentDir, name, content);
return modulePath; return modulePath;
}, };
// the plugins global data are namespaced to avoid data conflicts:
// - by plugin name
// - by plugin id (allow using multiple instances of the same plugin)
const setGlobalData: PluginContentLoadedActions['setGlobalData'] = (
data,
) => {
globalData[plugin.name] = globalData[plugin.name] ?? {};
globalData[plugin.name][pluginId] = data;
};
const actions: PluginContentLoadedActions = {
addRoute,
createData,
setGlobalData,
}; };
await plugin.contentLoaded({ await plugin.contentLoaded({
@ -127,5 +155,6 @@ export async function loadPlugins({
return { return {
plugins, plugins,
pluginsRouteConfigs, pluginsRouteConfigs,
globalData,
}; };
} }

View file

@ -11,15 +11,26 @@ import importFresh from 'import-fresh';
import { import {
LoadContext, LoadContext,
Plugin, Plugin,
PluginOptions,
PluginConfig, PluginConfig,
ValidationSchema, ValidationSchema,
DocusaurusPluginVersionInformation, DocusaurusPluginVersionInformation,
} from '@docusaurus/types'; } from '@docusaurus/types';
import {CONFIG_FILE_NAME} from '../../constants'; import {CONFIG_FILE_NAME} from '../../constants';
import {getPluginVersion} from '../versions'; import {getPluginVersion} from '../versions';
import {ensureUniquePluginInstanceIds} from './pluginIds';
import * as Joi from '@hapi/joi';
function validate<T>(schema: ValidationSchema<T>, options: Partial<T>) { function pluginOptionsValidator<T>(
const {error, value} = schema.validate(options, { schema: ValidationSchema<T>,
options: Partial<T>,
) {
// All plugins can be provided an "id" for multi-instance support
// we don't ask the user to implement id validation, we add it automatically
const finalSchema = schema.append({
id: Joi.string(),
});
const {error, value} = finalSchema.validate(options, {
convert: false, convert: false,
}); });
if (error) { if (error) {
@ -28,8 +39,15 @@ function validate<T>(schema: ValidationSchema<T>, options: Partial<T>) {
return value; return value;
} }
function validateAndStrip<T>(schema: ValidationSchema<T>, options: Partial<T>) { function themeConfigValidator<T>(
const {error, value} = schema.unknown().validate(options, { schema: ValidationSchema<T>,
options: Partial<T>,
) {
// A theme should only validate his "slice" of the full themeConfig,
// not the whole object, so we allow unknown attributes to pass a theme validation
const finalSchema = schema.unknown();
const {error, value} = finalSchema.validate(options, {
convert: false, convert: false,
}); });
@ -39,7 +57,8 @@ function validateAndStrip<T>(schema: ValidationSchema<T>, options: Partial<T>) {
return value; return value;
} }
export type PluginWithVersionInformation = Plugin<unknown> & { export type InitPlugin = Plugin<unknown> & {
readonly options: PluginOptions;
readonly version: DocusaurusPluginVersionInformation; readonly version: DocusaurusPluginVersionInformation;
}; };
@ -49,7 +68,7 @@ export default function initPlugins({
}: { }: {
pluginConfigs: PluginConfig[]; pluginConfigs: PluginConfig[];
context: LoadContext; context: LoadContext;
}): PluginWithVersionInformation[] { }): InitPlugin[] {
// We need to resolve plugins from the perspective of the siteDir, since the siteDir's package.json // We need to resolve plugins from the perspective of the siteDir, since the siteDir's package.json
// declares the dependency on these plugins. // declares the dependency on these plugins.
// We need to fallback to createRequireFromPath since createRequire is only available in node v12. // We need to fallback to createRequireFromPath since createRequire is only available in node v12.
@ -57,10 +76,10 @@ export default function initPlugins({
const createRequire = Module.createRequire || Module.createRequireFromPath; const createRequire = Module.createRequire || Module.createRequireFromPath;
const pluginRequire = createRequire(join(context.siteDir, CONFIG_FILE_NAME)); const pluginRequire = createRequire(join(context.siteDir, CONFIG_FILE_NAME));
const plugins: PluginWithVersionInformation[] = pluginConfigs const plugins: InitPlugin[] = pluginConfigs
.map((pluginItem) => { .map((pluginItem) => {
let pluginModuleImport: string | undefined; let pluginModuleImport: string | undefined;
let pluginOptions = {}; let pluginOptions: PluginOptions = {};
if (!pluginItem) { if (!pluginItem) {
return null; return null;
@ -90,7 +109,7 @@ export default function initPlugins({
if (validateOptions) { if (validateOptions) {
const normalizedOptions = validateOptions({ const normalizedOptions = validateOptions({
validate, validate: pluginOptionsValidator,
options: pluginOptions, options: pluginOptions,
}); });
pluginOptions = normalizedOptions; pluginOptions = normalizedOptions;
@ -103,7 +122,7 @@ export default function initPlugins({
if (validateThemeConfig) { if (validateThemeConfig) {
const normalizedThemeConfig = validateThemeConfig({ const normalizedThemeConfig = validateThemeConfig({
validate: validateAndStrip, validate: themeConfigValidator,
themeConfig: context.siteConfig.themeConfig, themeConfig: context.siteConfig.themeConfig,
}); });
@ -112,8 +131,16 @@ export default function initPlugins({
...normalizedThemeConfig, ...normalizedThemeConfig,
}; };
} }
return {...plugin(context, pluginOptions), version: pluginVersion};
return {
...plugin(context, pluginOptions),
options: pluginOptions,
version: pluginVersion,
};
}) })
.filter(Boolean); .filter(Boolean);
ensureUniquePluginInstanceIds(plugins);
return plugins; return plugins;
} }

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 {groupBy} from 'lodash';
import {InitPlugin} from './init';
// It is forbidden to have 2 plugins of the same name sharind the same id
// this is required to support multi-instance plugins without conflict
export function ensureUniquePluginInstanceIds(plugins: InitPlugin[]) {
const pluginsByName = groupBy(plugins, (p) => p.name);
Object.entries(pluginsByName).forEach(([pluginName, pluginInstances]) => {
const pluginInstancesById = groupBy(
pluginInstances,
(p) => p.options.id ?? 'default',
);
Object.entries(pluginInstancesById).forEach(
([pluginId, pluginInstancesWithId]) => {
if (pluginInstancesWithId.length !== 1) {
throw new Error(
`Plugin ${pluginName} is used ${pluginInstancesWithId.length} times with id=${pluginId}.\nTo use the same plugin multiple times on a Docusaurus site, you need to assign a unique id to each plugin instance.`,
);
}
},
);
});
}

View file

@ -7,14 +7,14 @@ title: Blog
To setup your site's blog, start by creating a `blog` directory. To setup your site's blog, start by creating a `blog` directory.
Then, add a navbar link to your blog within `docusaurus.config.js`: Then, add a item link to your blog within `docusaurus.config.js`:
```js title="docusaurus.config.js" ```js title="docusaurus.config.js"
module.exports = { module.exports = {
themeConfig: { themeConfig: {
// ... // ...
navbar: { navbar: {
links: [ items: [
// ... // ...
// highlight-next-line // highlight-next-line
{to: 'blog', label: 'Blog', position: 'left'}, // or position: 'right' {to: 'blog', label: 'Blog', position: 'left'}, // or position: 'right'

View file

@ -220,6 +220,84 @@ function Component() {
} }
``` ```
### `useGlobalData()`
React hook to access Docusaurus global data created by all the plugins.
Global data is namespaced by plugin name, and plugin id.
:::info
Plugin id is only useful when a plugin is used multiple times on the same site. Each plugin instance is able to create its own global data.
:::
```ts
type GlobalData = Record<
PluginName,
Record<
PluginId, // "default" by default
any // plugin-specific data
>
>;
```
Usage example:
```jsx {2,5,6,7}
import React from 'react';
import useGlobalData from '@docusaurus/useGlobalData';
const MyComponent = () => {
const globalData = useDocusaurusContext();
const myPluginData = globalData['my-plugin']['default'];
return <div>{myPluginData.someAttribute}</div>;
};
```
:::tip
Inspect your site's global data at `./docusaurus/globalData.json`
:::
### `usePluginData(pluginName: string, pluginId?: string)`
Access global data created by a specific plugin instance.
This is the most convenient hook to access plugin global data, and should be used most of the time.
`pluginId` is optional if you don't use multi-instance plugins.
Usage example:
```jsx {2,5,6}
import React from 'react';
import {usePluginData} from '@docusaurus/useGlobalData';
const MyComponent = () => {
const myPluginData = usePluginData('my-plugin');
return <div>{myPluginData.someAttribute}</div>;
};
```
### `useAllPluginInstancesData(pluginName: string)`
Access global data created by a specific plugin. Given a plugin name, it returns the data of all the plugins instances of that name, by pluginId.
Usage example:
```jsx {2,5,6,7}
import React from 'react';
import {useAllPluginInstancesData} from '@docusaurus/useGlobalData';
const MyComponent = () => {
const allPluginInstancesData = useAllPluginInstancesData('my-plugin');
const myPluginData = allPluginInstancesData['default'];
return <div>{myPluginData.someAttribute}</div>;
};
```
## Modules ## Modules
### `ExecutionEnvironment` ### `ExecutionEnvironment`

View file

@ -150,7 +150,7 @@ module.exports = {
alt: 'Site Logo', alt: 'Site Logo',
src: 'img/logo.svg', src: 'img/logo.svg',
}, },
links: [ items: [
{ {
to: 'docs/docusaurus.config.js', to: 'docs/docusaurus.config.js',
activeBasePath: 'docs', activeBasePath: 'docs',

View file

@ -152,10 +152,6 @@ module.exports = function (context, options) {
Plugins should use the data loaded in `loadContent` and construct the pages/routes that consume the loaded data (optional). Plugins should use the data loaded in `loadContent` and construct the pages/routes that consume the loaded data (optional).
## `async routesLoaded(routes)`
Plugins can modify the routes that were generated by all plugins. `routesLoaded` is called after `contentLoaded` hook.
### `content` ### `content`
`contentLoaded` will be called _after_ `loadContent` is done, the return value of `loadContent()` will be passed to `contentLoaded` as `content`. `contentLoaded` will be called _after_ `loadContent` is done, the return value of `loadContent()` will be passed to `contentLoaded` as `content`.
@ -191,45 +187,98 @@ type Module =
- `createData(name: string, data: any): Promise<string>` - `createData(name: string, data: any): Promise<string>`
A helper function to help you write some data (usually a string or JSON) to disk with in-built caching. It takes a file name relative to to your plugin's directory **(name)**, your data **(data)**, and will return a path to where the data is created. A function to help you create static data (generally json or string), that you can provide to your routes as props.
For example, this plugin below create a `/roll` page which display "You won xxxx" to user. For example, this plugin below create a `/friends` page which display `Your friends are: Yangshun, Sebastien`:
```jsx title="website/src/components/roll.js" ```jsx title="website/src/components/Friends.js"
import React from 'react'; import React from 'react';
export default function (props) { export default function FriendsComponent({friends}) {
const {prizes} = props; return <div>Your friends are {friends.join(',')}</div>;
const index = Math.floor(Math.random() * 3);
return <div> You won ${prizes[index]} </div>;
} }
``` ```
```javascript {4-19} title="docusaurus-plugin/src/index.js" ```js {4-23} title="docusaurus-friends-plugin/src/index.js"
module.exports = function(context, options) { export default function friendsPlugin(context, options) {
return { return {
name: 'docusaurus-plugin', name: 'docusaurus-friends-plugin',
async contentLoaded({content, actions}) { async contentLoaded({content, actions}) {
const {createData, addRoute} = actions; const {createData, addRoute} = actions;
// Create a data named 'prizes.json'. // Create friends.json
const prizes = JSON.stringify(['$1', 'a cybertruck', 'nothing']); const friends = ['Yangshun', 'Sebastien'];
const prizesDataPath = await createData('prizes.json', prizes); const friendsJsonPath = await createData(
'friends.json',
JSON.stringify(friends),
);
// Add '/roll' page using 'website/src/component/roll.js` as the component // Add the '/friends' routes, and ensure it receives the friends props
// and providing 'prizes' as props.
addRoute({ addRoute({
path: '/roll', path: '/friends',
component: '@site/src/components/roll.js', component: '@site/src/components/Friends.js',
modules: { modules: {
prizes: prizesDataPath // propName -> json file path
} friends: friendsJsonPath,
},
exact: true, exact: true,
}); });
}, },
}; };
}; }
``` ```
- `setGlobalData(data: any): void`
This function permits to create some global plugin data, that can be read from any page, including the pages created by other plugins, and your theme layout.
This data become accessible to your client-side/theme code, through the [`useGlobalData`](./docusaurus-core.md#useglobaldata) and [`usePluginData`](./docusaurus-core.md#useplugindatapluginname-string-pluginid-string)
One this data is created, you can access it with the global data hooks APIs
:::caution
Global data is... global: its size affects the loading time of all pages of your site, so try to keep it small.
Prefer `createData` and page-specific data whenever possible.
:::
For example, this plugin below create a `/friends` page which display `Your friends are: Yangshun, Sebastien`:
```jsx title="website/src/components/Friends.js"
import React from 'react';
import {usePluginData} from '@docusaurus/useGlobalData';
export default function FriendsComponent() {
const {friends} = usePluginData('my-friends-plugin');
return <div>Your friends are {friends.join(',')}</div>;
}
```
```js {4-14} title="docusaurus-friends-plugin/src/index.js"
export default function friendsPlugin(context, options) {
return {
name: 'docusaurus-friends-plugin',
async contentLoaded({content, actions}) {
const {setGlobalData, addRoute} = actions;
// Create friends global data
setGlobalData({friends: ['Yangshun', 'Sebastien']});
// Add the '/friends' routes
addRoute({
path: '/friends',
component: '@site/src/components/Friends.js',
exact: true,
});
},
};
}
```
## `async routesLoaded(routes)`
Plugins can modify the routes that were generated by all plugins. `routesLoaded` is called after `contentLoaded` hook.
## `configureWebpack(config, isServer, utils)` ## `configureWebpack(config, isServer, utils)`
Modifies the internal webpack config. If the return value is a JavaScript object, it will be merged into the final config using [`webpack-merge`](https://github.com/survivejs/webpack-merge). If it is a function, it will be called and receive `config` as the first argument and an `isServer` flag as the argument argument. Modifies the internal webpack config. If the return value is a JavaScript object, it will be merged into the final config using [`webpack-merge`](https://github.com/survivejs/webpack-merge). If it is a function, it will be called and receive `config` as the first argument and an `isServer` flag as the argument argument.

View file

@ -295,7 +295,7 @@ module.exports = {
alt: 'Docusaurus Logo', alt: 'Docusaurus Logo',
src: 'img/docusaurus.svg', src: 'img/docusaurus.svg',
}, },
links: [ items: [
{to: 'docs/doc1', label: 'Getting Started', position: 'left'}, {to: 'docs/doc1', label: 'Getting Started', position: 'left'},
{to: 'help', label: 'Help', position: 'left'}, {to: 'help', label: 'Help', position: 'left'},
{ {

View file

@ -148,16 +148,18 @@ module.exports = {
}; };
``` ```
### Navbar links ### Navbar items
You can add links to the navbar via `themeConfig.navbar.links`: You can add items to the navbar via `themeConfig.navbar.items`.
By default, Navbar items are regular links (internal or external).
```js {5-15} title="docusaurus.config.js" ```js {5-15} title="docusaurus.config.js"
module.exports = { module.exports = {
// ... // ...
themeConfig: { themeConfig: {
navbar: { navbar: {
links: [ items: [
{ {
// Client-side routing, used for navigating within the website. // Client-side routing, used for navigating within the website.
// The baseUrl will be automatically prepended to this value. // The baseUrl will be automatically prepended to this value.
@ -180,7 +182,7 @@ module.exports = {
// Custom CSS class (for styling any item). // Custom CSS class (for styling any item).
className: '', className: '',
}, },
// ... other links // ... other items
], ],
}, },
// ... // ...
@ -194,14 +196,14 @@ Outbound (external) links automatically get `target="_blank" rel="noopener noref
### Navbar dropdown ### Navbar dropdown
Navbar items can also be dropdown items by specifying the `items`, an inner array of navbar links. Navbar items can also be dropdown items by specifying the `items`, an inner array of navbar items.
```js {9-19} title="docusaurus.config.js" ```js {9-19} title="docusaurus.config.js"
module.exports = { module.exports = {
// ... // ...
themeConfig: { themeConfig: {
navbar: { navbar: {
links: [ items: [
{ {
label: 'Community', label: 'Community',
position: 'left', // or 'right' position: 'left', // or 'right'
@ -224,6 +226,46 @@ module.exports = {
}; };
``` ```
### Navbar docs version dropdown
If you use docs with versioning, this special navbar item type that will render a dropdown with all your site's available versions. The user will be able to switch from one version to another, while staying on the same doc (as long as the doc id is constant across versions).
```js {5-8} title="docusaurus.config.js"
module.exports = {
themeConfig: {
navbar: {
items: [
{
type: 'docsVersionDropdown',
position: 'left',
},
],
},
},
};
```
### Navbar docs version
If you use docs with versioning, this special navbar item type will link to the active/browsed version of your doc (depends on the current url), and fallback to the latest version.
```js {5-10} title="docusaurus.config.js"
module.exports = {
themeConfig: {
navbar: {
items: [
{
type: 'docsVersion',
position: 'left',
// to: "/path // by default, link to active/latest version
// label: "label" // by default, show active/latest version label
},
],
},
},
};
```
### Auto-hide sticky navbar ### Auto-hide sticky navbar
You can enable this cool UI feature that automatically hides the navbar when a user starts scrolling down the page, and show it again when the user scrolls up. You can enable this cool UI feature that automatically hides the navbar when a user starts scrolling down the page, and show it again when the user scrolls up.

View file

@ -72,6 +72,35 @@ module.exports = {
}; };
``` ```
## Multi-instance plugins and plugin ids
It is possible to use multiple times the same plugin, on the same Docusaurus website.
In this case, you it is required to assign a unique id to each plugin instance.
By default, the plugin id is `default`.
```js {6,13} title="docusaurus.config.js"
module.exports = {
plugins: [
[
'@docusaurus/plugin-xxx',
{
id: 'plugin-xxx-1',
// other options
},
],
[
'@docusaurus/plugin-xxx',
{
id: 'plugin-xxx-2',
// other options
},
],
],
};
```
## Plugins design ## Plugins design
Docusaurus' implementation of the plugins system provides us with a convenient way to hook into the website's lifecycle to modify what goes on during development/build, which involves (but not limited to) extending the webpack config, modifying the data being loaded and creating new components to be used in a page. Docusaurus' implementation of the plugins system provides us with a convenient way to hook into the website's lifecycle to modify what goes on during development/build, which involves (but not limited to) extending the webpack config, modifying the data being loaded and creating new components to be used in a page.

View file

@ -173,28 +173,10 @@ module.exports = {
src: 'img/docusaurus.svg', src: 'img/docusaurus.svg',
srcDark: 'img/docusaurus_keytar.svg', srcDark: 'img/docusaurus_keytar.svg',
}, },
links: [
{
label: 'Docs',
to: 'docs', // "fake" link
position: 'left',
activeBaseRegex: `docs/(?!next/(support|team|resources))`,
items: [ items: [
{ {
label: versions[0], type: 'docsVersionDropdown',
to: 'docs/', position: 'left',
activeBaseRegex: `docs/(?!${versions.join('|')}|next)`,
},
...versions.slice(1).map((version) => ({
label: version,
to: `docs/${version}/`,
})),
{
label: 'Master/Unreleased',
to: 'docs/next/',
activeBaseRegex: `docs/next/(?!support|team|resources)`,
},
],
}, },
{to: 'blog', label: 'Blog', position: 'left'}, {to: 'blog', label: 'Blog', position: 'left'},
{to: 'showcase', label: 'Showcase', position: 'left'}, {to: 'showcase', label: 'Showcase', position: 'left'},
@ -205,8 +187,8 @@ module.exports = {
activeBaseRegex: `docs/next/(support|team|resources)`, activeBaseRegex: `docs/next/(support|team|resources)`,
}, },
{ {
type: 'docsVersion',
to: 'versions', to: 'versions',
label: `v${versions[0]}`,
position: 'right', position: 'right',
}, },
{ {

View file

@ -3083,6 +3083,13 @@
dependencies: dependencies:
"@types/lodash" "*" "@types/lodash" "*"
"@types/lodash.sortby@^4.6.6":
version "4.7.6"
resolved "https://registry.yarnpkg.com/@types/lodash.sortby/-/lodash.sortby-4.7.6.tgz#eed689835f274b553db4ae16a4a23f58b79618a1"
integrity sha512-EnvAOmKvEg7gdYpYrS6+fVFPw5dL9rBnJi3vcKI7wqWQcLJVF/KRXK9dH29HjGNVvFUj0s9prRP3J8jEGnGKDw==
dependencies:
"@types/lodash" "*"
"@types/lodash@*", "@types/lodash@^4.14.53": "@types/lodash@*", "@types/lodash@^4.14.53":
version "4.14.149" version "4.14.149"
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.149.tgz#1342d63d948c6062838fbf961012f74d4e638440" resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.149.tgz#1342d63d948c6062838fbf961012f74d4e638440"
@ -11646,7 +11653,7 @@ lodash.some@^4.4.0:
resolved "https://registry.yarnpkg.com/lodash.some/-/lodash.some-4.6.0.tgz#1bb9f314ef6b8baded13b549169b2a945eb68e4d" resolved "https://registry.yarnpkg.com/lodash.some/-/lodash.some-4.6.0.tgz#1bb9f314ef6b8baded13b549169b2a945eb68e4d"
integrity sha1-G7nzFO9ri63tE7VJFpsqlF62jk0= integrity sha1-G7nzFO9ri63tE7VJFpsqlF62jk0=
lodash.sortby@^4.7.0: lodash.sortby@^4.6.0, lodash.sortby@^4.7.0:
version "4.7.0" version "4.7.0"
resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438" resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438"
integrity sha1-7dFMgk4sycHgsKG0K7UhBRakJDg= integrity sha1-7dFMgk4sycHgsKG0K7UhBRakJDg=