diff --git a/packages/docusaurus-plugin-content-docs/package.json b/packages/docusaurus-plugin-content-docs/package.json index b14798f74c55..97b82637d5f8 100644 --- a/packages/docusaurus-plugin-content-docs/package.json +++ b/packages/docusaurus-plugin-content-docs/package.json @@ -12,6 +12,7 @@ "license": "MIT", "devDependencies": { "@docusaurus/types": "^2.0.0-alpha.36", + "commander": "^4.0.1", "picomatch": "^2.1.0" }, "dependencies": { @@ -22,6 +23,7 @@ "globby": "^10.0.1", "import-fresh": "^3.1.0", "loader-utils": "^1.2.3", + "lodash": "^4.17.15", "shelljs": "^0.8.3" }, "peerDependencies": { diff --git a/packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/simple-site/docs/lorem.md b/packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/simple-site/docs/lorem.md index b088a6ec006b..8e0e2fc39a7a 100644 --- a/packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/simple-site/docs/lorem.md +++ b/packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/simple-site/docs/lorem.md @@ -1,5 +1,6 @@ --- custom_edit_url: https://github.com/customUrl/docs/lorem.md +unrelated_frontmatter: won't be part of metadata --- Lorem ipsum. \ No newline at end of file diff --git a/packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/simple-site/docs/permalink.md b/packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/simple-site/docs/permalink.md deleted file mode 100644 index 6e31233dba20..000000000000 --- a/packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/simple-site/docs/permalink.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -id: permalink -title: Permalink -permalink: :baseUrl:docsUrl/endiliey/:id ---- - -This has a different permalink \ No newline at end of file diff --git a/packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/simple-site/docusaurus.config.js b/packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/simple-site/docusaurus.config.js index 4b278c9a7b13..69d61c8bc1cf 100644 --- a/packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/simple-site/docusaurus.config.js +++ b/packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/simple-site/docusaurus.config.js @@ -11,6 +11,4 @@ module.exports = { url: 'https://your-docusaurus-test-site.com', baseUrl: '/', favicon: 'img/favicon.ico', - organizationName: 'facebook', // Usually your GitHub org/user name. - projectName: 'docusaurus', // Usually your repo name. }; diff --git a/packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/versioned-site/docs/foo/bar.md b/packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/versioned-site/docs/foo/bar.md new file mode 100644 index 000000000000..5a5010601217 --- /dev/null +++ b/packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/versioned-site/docs/foo/bar.md @@ -0,0 +1 @@ +This is `next` version of bar. diff --git a/packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/versioned-site/docs/hello.md b/packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/versioned-site/docs/hello.md new file mode 100644 index 000000000000..41c6fd549aae --- /dev/null +++ b/packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/versioned-site/docs/hello.md @@ -0,0 +1 @@ +Hello `next` ! diff --git a/packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/versioned-site/docusaurus.config.js b/packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/versioned-site/docusaurus.config.js new file mode 100644 index 000000000000..7073bd822185 --- /dev/null +++ b/packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/versioned-site/docusaurus.config.js @@ -0,0 +1,14 @@ +/** + * Copyright (c) 2017-present, Facebook, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +module.exports = { + title: 'Versioned Site', + tagline: 'The tagline of my site', + url: 'https://your-docusaurus-test-site.com', + baseUrl: '/', + favicon: 'img/favicon.ico', +}; diff --git a/packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/versioned-site/sidebars.json b/packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/versioned-site/sidebars.json new file mode 100644 index 000000000000..147338771e65 --- /dev/null +++ b/packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/versioned-site/sidebars.json @@ -0,0 +1,10 @@ +{ + "docs": { + "Test": [ + "foo/bar" + ], + "Guides": [ + "hello" + ] + } +} diff --git a/packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/versioned-site/versioned_docs/version-1.0.0/foo/bar.md b/packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/versioned-site/versioned_docs/version-1.0.0/foo/bar.md new file mode 100644 index 000000000000..8ac92463918c --- /dev/null +++ b/packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/versioned-site/versioned_docs/version-1.0.0/foo/bar.md @@ -0,0 +1 @@ +Bar `1.0.0` ! diff --git a/packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/versioned-site/versioned_docs/version-1.0.0/foo/baz.md b/packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/versioned-site/versioned_docs/version-1.0.0/foo/baz.md new file mode 100644 index 000000000000..9d5356b0673c --- /dev/null +++ b/packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/versioned-site/versioned_docs/version-1.0.0/foo/baz.md @@ -0,0 +1 @@ +Baz `1.0.0` ! This will be deleted in next subsequent versions. diff --git a/packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/versioned-site/versioned_docs/version-1.0.0/hello.md b/packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/versioned-site/versioned_docs/version-1.0.0/hello.md new file mode 100644 index 000000000000..c286d646eda5 --- /dev/null +++ b/packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/versioned-site/versioned_docs/version-1.0.0/hello.md @@ -0,0 +1 @@ +Hello `1.0.0` ! diff --git a/packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/versioned-site/versioned_docs/version-1.0.1/foo/bar.md b/packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/versioned-site/versioned_docs/version-1.0.1/foo/bar.md new file mode 100644 index 000000000000..7244817fb7e8 --- /dev/null +++ b/packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/versioned-site/versioned_docs/version-1.0.1/foo/bar.md @@ -0,0 +1 @@ +Bar `1.0.1` ! diff --git a/packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/versioned-site/versioned_docs/version-1.0.1/hello.md b/packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/versioned-site/versioned_docs/version-1.0.1/hello.md new file mode 100644 index 000000000000..41a1573f227a --- /dev/null +++ b/packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/versioned-site/versioned_docs/version-1.0.1/hello.md @@ -0,0 +1 @@ +Hello `1.0.1` ! diff --git a/packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/versioned-site/versioned_sidebars/version-1.0.0-sidebars.json b/packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/versioned-site/versioned_sidebars/version-1.0.0-sidebars.json new file mode 100644 index 000000000000..c2739e632291 --- /dev/null +++ b/packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/versioned-site/versioned_sidebars/version-1.0.0-sidebars.json @@ -0,0 +1,11 @@ +{ + "version-1.0.0/docs": { + "Test": [ + "version-1.0.0/foo/bar", + "version-1.0.0/foo/baz" + ], + "Guides": [ + "version-1.0.0/hello" + ] + } +} diff --git a/packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/versioned-site/versioned_sidebars/version-1.0.1-sidebars.json b/packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/versioned-site/versioned_sidebars/version-1.0.1-sidebars.json new file mode 100644 index 000000000000..98a2d8a4f74b --- /dev/null +++ b/packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/versioned-site/versioned_sidebars/version-1.0.1-sidebars.json @@ -0,0 +1,10 @@ +{ + "version-1.0.1/docs": { + "Test": [ + "version-1.0.1/foo/bar" + ], + "Guides": [ + "version-1.0.1/hello" + ] + } +} diff --git a/packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/versioned-site/versions.json b/packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/versioned-site/versions.json new file mode 100644 index 000000000000..542b187dfeeb --- /dev/null +++ b/packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/versioned-site/versions.json @@ -0,0 +1,4 @@ +[ + "1.0.1", + "1.0.0" +] diff --git a/packages/docusaurus-plugin-content-docs/src/__tests__/__snapshots__/index.test.ts.snap b/packages/docusaurus-plugin-content-docs/src/__tests__/__snapshots__/index.test.ts.snap index 23cfb7cfbdc6..a0b9a2e46a9a 100644 --- a/packages/docusaurus-plugin-content-docs/src/__tests__/__snapshots__/index.test.ts.snap +++ b/packages/docusaurus-plugin-content-docs/src/__tests__/__snapshots__/index.test.ts.snap @@ -58,16 +58,8 @@ Array [ "docsMetadata": "@docusaurus-plugin-content-docs/docs-route-ff2.json", }, "path": "/docs/:route", + "priority": undefined, "routes": Array [ - Object { - "component": "@theme/DocItem", - "exact": true, - "modules": Object { - "content": "@site/docs/permalink.md", - "metadata": "@docusaurus-plugin-content-docs/docs-endiliey-permalink-086.json", - }, - "path": "/docs/endiliey/permalink", - }, Object { "component": "@theme/DocItem", "exact": true, @@ -108,3 +100,311 @@ Array [ }, ] `; + +exports[`versioned website content 1`] = ` +Array [ + Object { + "component": "@theme/DocPage", + "modules": Object { + "docsMetadata": "@docusaurus-plugin-content-docs/docs-1-0-0-route-660.json", + }, + "path": "/docs/1.0.0/:route", + "priority": undefined, + "routes": Array [ + Object { + "component": "@theme/DocItem", + "exact": true, + "modules": Object { + "content": "@site/versioned_docs/version-1.0.0/foo/bar.md", + "metadata": "@docusaurus-plugin-content-docs/docs-1-0-0-foo-bar-568.json", + }, + "path": "/docs/1.0.0/foo/bar", + }, + Object { + "component": "@theme/DocItem", + "exact": true, + "modules": Object { + "content": "@site/versioned_docs/version-1.0.0/foo/baz.md", + "metadata": "@docusaurus-plugin-content-docs/docs-1-0-0-foo-baz-5e1.json", + }, + "path": "/docs/1.0.0/foo/baz", + }, + Object { + "component": "@theme/DocItem", + "exact": true, + "modules": Object { + "content": "@site/versioned_docs/version-1.0.0/hello.md", + "metadata": "@docusaurus-plugin-content-docs/docs-1-0-0-hello-1d0.json", + }, + "path": "/docs/1.0.0/hello", + }, + ], + }, + Object { + "component": "@theme/DocPage", + "modules": Object { + "docsMetadata": "@docusaurus-plugin-content-docs/docs-next-route-1c8.json", + }, + "path": "/docs/next/:route", + "priority": undefined, + "routes": Array [ + Object { + "component": "@theme/DocItem", + "exact": true, + "modules": Object { + "content": "@site/docs/foo/bar.md", + "metadata": "@docusaurus-plugin-content-docs/docs-next-foo-bar-09c.json", + }, + "path": "/docs/next/foo/bar", + }, + Object { + "component": "@theme/DocItem", + "exact": true, + "modules": Object { + "content": "@site/docs/hello.md", + "metadata": "@docusaurus-plugin-content-docs/docs-next-hello-64c.json", + }, + "path": "/docs/next/hello", + }, + ], + }, + Object { + "component": "@theme/DocPage", + "modules": Object { + "docsMetadata": "@docusaurus-plugin-content-docs/docs-route-ff2.json", + }, + "path": "/docs/:route", + "priority": -1, + "routes": Array [ + Object { + "component": "@theme/DocItem", + "exact": true, + "modules": Object { + "content": "@site/versioned_docs/version-1.0.1/foo/bar.md", + "metadata": "@docusaurus-plugin-content-docs/docs-foo-bar-cef.json", + }, + "path": "/docs/foo/bar", + }, + Object { + "component": "@theme/DocItem", + "exact": true, + "modules": Object { + "content": "@site/versioned_docs/version-1.0.1/hello.md", + "metadata": "@docusaurus-plugin-content-docs/docs-hello-da2.json", + }, + "path": "/docs/hello", + }, + ], + }, +] +`; + +exports[`versioned website content: all sidebars 1`] = ` +Object { + "docs": Array [ + Object { + "items": Array [ + Object { + "href": "/docs/next/foo/bar", + "label": "bar", + "type": "link", + }, + ], + "label": "Test", + "type": "category", + }, + Object { + "items": Array [ + Object { + "href": "/docs/next/hello", + "label": "hello", + "type": "link", + }, + ], + "label": "Guides", + "type": "category", + }, + ], + "version-1.0.0/docs": Array [ + Object { + "items": Array [ + Object { + "href": "/docs/1.0.0/foo/bar", + "label": "bar", + "type": "link", + }, + Object { + "href": "/docs/1.0.0/foo/baz", + "label": "baz", + "type": "link", + }, + ], + "label": "Test", + "type": "category", + }, + Object { + "items": Array [ + Object { + "href": "/docs/1.0.0/hello", + "label": "hello", + "type": "link", + }, + ], + "label": "Guides", + "type": "category", + }, + ], + "version-1.0.1/docs": Array [ + Object { + "items": Array [ + Object { + "href": "/docs/foo/bar", + "label": "bar", + "type": "link", + }, + ], + "label": "Test", + "type": "category", + }, + Object { + "items": Array [ + Object { + "href": "/docs/hello", + "label": "hello", + "type": "link", + }, + ], + "label": "Guides", + "type": "category", + }, + ], +} +`; + +exports[`versioned website content: base metadata for first version 1`] = ` +Object { + "docsSidebars": Object { + "version-1.0.0/docs": Array [ + Object { + "items": Array [ + Object { + "href": "/docs/1.0.0/foo/bar", + "label": "bar", + "type": "link", + }, + Object { + "href": "/docs/1.0.0/foo/baz", + "label": "baz", + "type": "link", + }, + ], + "label": "Test", + "type": "category", + }, + Object { + "items": Array [ + Object { + "href": "/docs/1.0.0/hello", + "label": "hello", + "type": "link", + }, + ], + "label": "Guides", + "type": "category", + }, + ], + }, + "permalinkToSidebar": Object { + "/docs/1.0.0/foo/bar": "version-1.0.0/docs", + "/docs/1.0.0/foo/baz": "version-1.0.0/docs", + "/docs/1.0.0/hello": "version-1.0.0/docs", + }, + "version": "1.0.0", +} +`; + +exports[`versioned website content: base metadata for latest version 1`] = ` +Object { + "docsSidebars": Object { + "version-1.0.1/docs": Array [ + Object { + "items": Array [ + Object { + "href": "/docs/foo/bar", + "label": "bar", + "type": "link", + }, + ], + "label": "Test", + "type": "category", + }, + Object { + "items": Array [ + Object { + "href": "/docs/hello", + "label": "hello", + "type": "link", + }, + ], + "label": "Guides", + "type": "category", + }, + ], + }, + "permalinkToSidebar": Object { + "/docs/foo/bar": "version-1.0.1/docs", + "/docs/hello": "version-1.0.1/docs", + }, + "version": "1.0.1", +} +`; + +exports[`versioned website content: base metadata for next version 1`] = ` +Object { + "docsSidebars": Object { + "docs": Array [ + Object { + "items": Array [ + Object { + "href": "/docs/next/foo/bar", + "label": "bar", + "type": "link", + }, + ], + "label": "Test", + "type": "category", + }, + Object { + "items": Array [ + Object { + "href": "/docs/next/hello", + "label": "hello", + "type": "link", + }, + ], + "label": "Guides", + "type": "category", + }, + ], + }, + "permalinkToSidebar": Object { + "/docs/next/foo/bar": "docs", + "/docs/next/hello": "docs", + }, + "version": "next", +} +`; + +exports[`versioned website content: sidebars needed for each version 1`] = ` +Object { + "1.0.0": Set { + "version-1.0.0/docs", + }, + "1.0.1": Set { + "version-1.0.1/docs", + }, + "next": Set { + "docs", + }, +} +`; diff --git a/packages/docusaurus-plugin-content-docs/src/__tests__/__snapshots__/version.test.ts.snap b/packages/docusaurus-plugin-content-docs/src/__tests__/__snapshots__/version.test.ts.snap new file mode 100644 index 000000000000..47ffaef77ff4 --- /dev/null +++ b/packages/docusaurus-plugin-content-docs/src/__tests__/__snapshots__/version.test.ts.snap @@ -0,0 +1,74 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`docsVersion first time versioning 1`] = ` +Object { + "version-1.0.0/docs": Array [ + Object { + "items": Array [ + Object { + "items": Array [ + Object { + "id": "version-1.0.0/foo/bar", + "type": "doc", + }, + Object { + "id": "version-1.0.0/foo/baz", + "type": "doc", + }, + ], + "label": "foo", + "type": "category", + }, + Object { + "href": "https://github.com", + "label": "Github", + "type": "link", + }, + Object { + "id": "version-1.0.0/hello", + "type": "ref", + }, + ], + "label": "Test", + "type": "category", + }, + Object { + "items": Array [ + Object { + "id": "version-1.0.0/hello", + "type": "doc", + }, + ], + "label": "Guides", + "type": "category", + }, + ], +} +`; + +exports[`docsVersion not the first time versioning 1`] = ` +Object { + "version-2.0.0/docs": Array [ + Object { + "items": Array [ + Object { + "id": "version-2.0.0/foo/bar", + "type": "doc", + }, + ], + "label": "Test", + "type": "category", + }, + Object { + "items": Array [ + Object { + "id": "version-2.0.0/hello", + "type": "doc", + }, + ], + "label": "Guides", + "type": "category", + }, + ], +} +`; diff --git a/packages/docusaurus-plugin-content-docs/src/__tests__/env.test.ts b/packages/docusaurus-plugin-content-docs/src/__tests__/env.test.ts new file mode 100644 index 000000000000..b555c4acd2b2 --- /dev/null +++ b/packages/docusaurus-plugin-content-docs/src/__tests__/env.test.ts @@ -0,0 +1,38 @@ +/** + * Copyright (c) 2017-present, Facebook, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import path from 'path'; +import loadEnv from '../env'; + +describe('loadEnv', () => { + test('website with versioning disabled', () => { + const siteDir = path.join(__dirname, '__fixtures__', 'simple-site'); + const env = loadEnv(siteDir); + expect(env.versioning.enabled).toBe(false); + expect(env.versioning.versions).toStrictEqual([]); + }); + + test('website with versioning enabled', () => { + const siteDir = path.join(__dirname, '__fixtures__', 'versioned-site'); + const env = loadEnv(siteDir); + expect(env.versioning.enabled).toBe(true); + expect(env.versioning.latestVersion).toBe('1.0.1'); + expect(env.versioning.versions).toStrictEqual(['1.0.1', '1.0.0']); + }); + + test('website with invalid versions.json file', () => { + const siteDir = path.join(__dirname, '__fixtures__', 'versioned-site'); + const mock = jest.spyOn(JSON, 'parse').mockImplementationOnce(() => { + return { + invalid: 'json', + }; + }); + const env = loadEnv(siteDir); + expect(env.versioning.enabled).toBe(false); + mock.mockRestore(); + }); +}); diff --git a/packages/docusaurus-plugin-content-docs/src/__tests__/index.test.ts b/packages/docusaurus-plugin-content-docs/src/__tests__/index.test.ts index dd4b533d457f..ba05644b12a8 100644 --- a/packages/docusaurus-plugin-content-docs/src/__tests__/index.test.ts +++ b/packages/docusaurus-plugin-content-docs/src/__tests__/index.test.ts @@ -8,19 +8,31 @@ import path from 'path'; import {validate} from 'webpack'; import {isMatch} from 'picomatch'; +import commander from 'commander'; import fs from 'fs-extra'; import pluginContentDocs from '../index'; +import loadEnv from '../env'; import {loadContext} from '@docusaurus/core/src/server/index'; import {applyConfigureWebpack} from '@docusaurus/core/src/webpack/utils'; import {RouteConfig} from '@docusaurus/types'; import {posixPath} from '@docusaurus/utils'; +import {sortConfig} from '@docusaurus/core/src/server/plugins'; -const createFakeActions = (routeConfigs: RouteConfig[], contentDir) => { +import * as version from '../version'; + +const createFakeActions = ( + routeConfigs: RouteConfig[], + contentDir, + dataContainer?, +) => { return { addRoute: (config: RouteConfig) => { routeConfigs.push(config); }, - createData: async (name, _content) => { + createData: async (name, content) => { + if (dataContainer) { + dataContainer[name] = content; + } return path.join(contentDir, name); }, }; @@ -84,6 +96,18 @@ describe('simple website', () => { }); const pluginContentDir = path.join(context.generatedFilesDir, plugin.name); + test('extendCli - docsVersion', () => { + const mock = jest.spyOn(version, 'docsVersion').mockImplementation(); + const cli = new commander.Command(); + plugin.extendCli(cli); + cli.parse(['node', 'test', 'docs:version', '1.0.0']); + expect(mock).toHaveBeenCalledWith('1.0.0', siteDir, { + path: pluginPath, + sidebarPath, + }); + mock.mockRestore(); + }); + test('getPathToWatch', () => { const pathToWatch = plugin.getPathsToWatch(); const matchPattern = pathToWatch.map(filepath => @@ -126,7 +150,13 @@ describe('simple website', () => { test('content', async () => { const content = await plugin.loadContent(); - const {docsMetadata, docsSidebars} = content; + const { + docsMetadata, + docsSidebars, + versionToSidebars, + permalinkToSidebar, + } = content; + expect(versionToSidebars).toEqual({}); expect(docsMetadata.hello).toEqual({ id: 'hello', permalink: '/docs/hello', @@ -156,13 +186,233 @@ describe('simple website', () => { expect(docsSidebars).toMatchSnapshot(); const routeConfigs = []; - const actions = createFakeActions(routeConfigs, pluginContentDir); + const dataContainer = {}; + const actions = createFakeActions( + routeConfigs, + pluginContentDir, + dataContainer, + ); await plugin.contentLoaded({ content, actions, }); + // There is only one nested docs route for simple site + const baseMetadata = JSON.parse(dataContainer['docs-route-ff2.json']); + expect(baseMetadata.docsSidebars).toEqual(docsSidebars); + expect(baseMetadata.permalinkToSidebar).toEqual(permalinkToSidebar); + + expect(routeConfigs).not.toEqual([]); + expect(routeConfigs).toMatchSnapshot(); + }); +}); + +describe('versioned website', () => { + const siteDir = path.join(__dirname, '__fixtures__', 'versioned-site'); + const context = loadContext(siteDir); + const sidebarPath = path.join(siteDir, 'sidebars.json'); + const routeBasePath = 'docs'; + const plugin = pluginContentDocs(context, { + routeBasePath, + sidebarPath, + }); + const env = loadEnv(siteDir); + const {docsDir: versionedDir} = env.versioning; + const pluginContentDir = path.join(context.generatedFilesDir, plugin.name); + + test('extendCli - docsVersion', () => { + const mock = jest.spyOn(version, 'docsVersion').mockImplementation(); + const cli = new commander.Command(); + plugin.extendCli(cli); + cli.parse(['node', 'test', 'docs:version', '2.0.0']); + expect(mock).toHaveBeenCalledWith('2.0.0', siteDir, { + path: routeBasePath, + sidebarPath, + }); + mock.mockRestore(); + }); + + test('getPathToWatch', () => { + const pathToWatch = plugin.getPathsToWatch(); + const matchPattern = pathToWatch.map(filepath => + posixPath(path.relative(siteDir, filepath)), + ); + expect(matchPattern).not.toEqual([]); + expect(matchPattern).toMatchInlineSnapshot(` + Array [ + "docs/**/*.{md,mdx}", + "versioned_sidebars/version-1.0.1-sidebars.json", + "versioned_sidebars/version-1.0.0-sidebars.json", + "versioned_docs/version-1.0.1/**/*.{md,mdx}", + "versioned_docs/version-1.0.0/**/*.{md,mdx}", + "sidebars.json", + ] + `); + expect(isMatch('docs/hello.md', matchPattern)).toEqual(true); + expect(isMatch('docs/hello.mdx', matchPattern)).toEqual(true); + expect(isMatch('docs/foo/bar.md', matchPattern)).toEqual(true); + expect(isMatch('sidebars.json', matchPattern)).toEqual(true); + expect( + isMatch('versioned_docs/version-1.0.0/hello.md', matchPattern), + ).toEqual(true); + expect( + isMatch('versioned_docs/version-1.0.0/foo/bar.md', matchPattern), + ).toEqual(true); + expect( + isMatch('versioned_sidebars/version-1.0.0-sidebars.json', matchPattern), + ).toEqual(true); + + // Non existing version + expect( + isMatch('versioned_docs/version-2.0.0/foo/bar.md', matchPattern), + ).toEqual(false); + expect( + isMatch('versioned_docs/version-2.0.0/hello.md', matchPattern), + ).toEqual(false); + expect( + isMatch('versioned_sidebars/version-2.0.0-sidebars.json', matchPattern), + ).toEqual(false); + + expect(isMatch('docs/hello.js', matchPattern)).toEqual(false); + expect(isMatch('docs/super.mdl', matchPattern)).toEqual(false); + expect(isMatch('docs/mdx', matchPattern)).toEqual(false); + expect(isMatch('hello.md', matchPattern)).toEqual(false); + expect(isMatch('super/docs/hello.md', matchPattern)).toEqual(false); + }); + + test('content', async () => { + const content = await plugin.loadContent(); + const { + docsMetadata, + docsSidebars, + versionToSidebars, + permalinkToSidebar, + } = content; + + // foo/baz.md only exists in version -1.0.0 + expect(docsMetadata['foo/baz']).toBeUndefined(); + expect(docsMetadata['version-1.0.1/foo/baz']).toBeUndefined(); + expect(docsMetadata['foo/bar']).toEqual({ + id: 'foo/bar', + permalink: '/docs/next/foo/bar', + source: path.join('@site', routeBasePath, 'foo', 'bar.md'), + title: 'bar', + description: 'This is `next` version of bar.', + version: 'next', + sidebar: 'docs', + next: { + title: 'hello', + permalink: '/docs/next/hello', + }, + }); + expect(docsMetadata['hello']).toEqual({ + id: 'hello', + permalink: '/docs/next/hello', + source: path.join('@site', routeBasePath, 'hello.md'), + title: 'hello', + description: 'Hello `next` !', + version: 'next', + sidebar: 'docs', + previous: { + title: 'bar', + permalink: '/docs/next/foo/bar', + }, + }); + expect(docsMetadata['version-1.0.1/hello']).toEqual({ + id: 'version-1.0.1/hello', + permalink: '/docs/hello', + source: path.join( + '@site', + path.relative(siteDir, versionedDir), + 'version-1.0.1', + 'hello.md', + ), + title: 'hello', + description: 'Hello `1.0.1` !', + version: '1.0.1', + sidebar: 'version-1.0.1/docs', + previous: { + title: 'bar', + permalink: '/docs/foo/bar', + }, + }); + expect(docsMetadata['version-1.0.0/foo/baz']).toEqual({ + id: 'version-1.0.0/foo/baz', + permalink: '/docs/1.0.0/foo/baz', + source: path.join( + '@site', + path.relative(siteDir, versionedDir), + 'version-1.0.0', + 'foo', + 'baz.md', + ), + title: 'baz', + description: + 'Baz `1.0.0` ! This will be deleted in next subsequent versions.', + version: '1.0.0', + sidebar: 'version-1.0.0/docs', + next: { + title: 'hello', + permalink: '/docs/1.0.0/hello', + }, + previous: { + title: 'bar', + permalink: '/docs/1.0.0/foo/bar', + }, + }); + + expect(docsSidebars).toMatchSnapshot('all sidebars'); + expect(versionToSidebars).toMatchSnapshot( + 'sidebars needed for each version', + ); + const routeConfigs = []; + const dataContainer = {}; + const actions = createFakeActions( + routeConfigs, + pluginContentDir, + dataContainer, + ); + await plugin.contentLoaded({ + content, + actions, + }); + + // The created base metadata for each nested docs route is smartly chunked/ splitted across version + const latestVersionBaseMetadata = JSON.parse( + dataContainer['docs-route-ff2.json'], + ); + expect(latestVersionBaseMetadata).toMatchSnapshot( + 'base metadata for latest version', + ); + expect(latestVersionBaseMetadata.docsSidebars).not.toEqual(docsSidebars); + expect(latestVersionBaseMetadata.permalinkToSidebar).not.toEqual( + permalinkToSidebar, + ); + const nextVersionBaseMetadata = JSON.parse( + dataContainer['docs-next-route-1c8.json'], + ); + expect(nextVersionBaseMetadata).toMatchSnapshot( + 'base metadata for next version', + ); + expect(nextVersionBaseMetadata.docsSidebars).not.toEqual(docsSidebars); + expect(nextVersionBaseMetadata.permalinkToSidebar).not.toEqual( + permalinkToSidebar, + ); + const firstVersionBaseMetadata = JSON.parse( + dataContainer['docs-1-0-0-route-660.json'], + ); + expect(firstVersionBaseMetadata).toMatchSnapshot( + 'base metadata for first version', + ); + expect(nextVersionBaseMetadata.docsSidebars).not.toEqual(docsSidebars); + expect(nextVersionBaseMetadata.permalinkToSidebar).not.toEqual( + permalinkToSidebar, + ); + + // Sort the route config like in src/server/plugins/index.ts for consistent snapshot ordering + sortConfig(routeConfigs); + expect(routeConfigs).not.toEqual([]); expect(routeConfigs).toMatchSnapshot(); }); diff --git a/packages/docusaurus-plugin-content-docs/src/__tests__/metadata.test.ts b/packages/docusaurus-plugin-content-docs/src/__tests__/metadata.test.ts index b67bdccf5a9b..71c0c22ec87a 100644 --- a/packages/docusaurus-plugin-content-docs/src/__tests__/metadata.test.ts +++ b/packages/docusaurus-plugin-content-docs/src/__tests__/metadata.test.ts @@ -6,96 +6,81 @@ */ import path from 'path'; +import {loadContext} from '@docusaurus/core/src/server/index'; import processMetadata from '../metadata'; +import loadEnv from '../env'; -describe('processMetadata', () => { - const fixtureDir = path.join(__dirname, '__fixtures__'); +const fixtureDir = path.join(__dirname, '__fixtures__'); + +describe('simple site', () => { const simpleSiteDir = path.join(fixtureDir, 'simple-site'); - const siteConfig = { - title: 'Hello', - baseUrl: '/', - url: 'https://docusaurus.io', - }; - const pluginPath = 'docs'; - const docsDir = path.resolve(simpleSiteDir, pluginPath); + const context = loadContext(simpleSiteDir); + const routeBasePath = 'docs'; + const docsDir = path.resolve(simpleSiteDir, routeBasePath); + + const env = loadEnv(simpleSiteDir); test('normal docs', async () => { const sourceA = path.join('foo', 'bar.md'); const sourceB = path.join('hello.md'); + const options = { + routeBasePath, + }; const [dataA, dataB] = await Promise.all([ processMetadata({ source: sourceA, - docsDir, - order: {}, - siteConfig, - docsBasePath: pluginPath, - siteDir: simpleSiteDir, + refDir: docsDir, + context, + options, + env, }), processMetadata({ source: sourceB, - docsDir, - order: {}, - siteConfig, - docsBasePath: pluginPath, - siteDir: simpleSiteDir, + refDir: docsDir, + context, + options, + env, }), ]); expect(dataA).toEqual({ id: 'foo/bar', permalink: '/docs/foo/bar', - source: path.join('@site', pluginPath, sourceA), + source: path.join('@site', routeBasePath, sourceA), title: 'Bar', description: 'This is custom description', }); expect(dataB).toEqual({ id: 'hello', permalink: '/docs/hello', - source: path.join('@site', pluginPath, sourceB), + source: path.join('@site', routeBasePath, sourceB), title: 'Hello, World !', description: `Hi, Endilie here :)`, }); }); - test('docs with custom permalink', async () => { - const source = path.join('permalink.md'); - const data = await processMetadata({ - source, - docsDir, - order: {}, - siteConfig, - docsBasePath: pluginPath, - siteDir: simpleSiteDir, - }); - - expect(data).toEqual({ - id: 'permalink', - permalink: '/docs/endiliey/permalink', - source: path.join('@site', pluginPath, source), - title: 'Permalink', - description: 'This has a different permalink', - }); - }); - test('docs with editUrl', async () => { const editUrl = 'https://github.com/facebook/docusaurus/edit/master/website'; const source = path.join('foo', 'baz.md'); + const options = { + routeBasePath, + editUrl, + }; + const data = await processMetadata({ source, - docsDir, - order: {}, - siteConfig, - docsBasePath: pluginPath, - siteDir: simpleSiteDir, - editUrl, + refDir: docsDir, + context, + options, + env, }); expect(data).toEqual({ id: 'foo/baz', permalink: '/docs/foo/baz', - source: path.join('@site', pluginPath, source), + source: path.join('@site', routeBasePath, source), title: 'baz', editUrl: 'https://github.com/facebook/docusaurus/edit/master/website/docs/foo/baz.md', @@ -103,62 +88,73 @@ describe('processMetadata', () => { }); }); - test('docs with custom editUrl', async () => { + test('docs with custom editUrl & unrelated frontmatter', async () => { const source = 'lorem.md'; + const options = { + routeBasePath, + }; + const data = await processMetadata({ source, - docsDir, - order: {}, - siteConfig, - docsBasePath: pluginPath, - siteDir: simpleSiteDir, + refDir: docsDir, + context, + options, + env, }); expect(data).toEqual({ id: 'lorem', permalink: '/docs/lorem', - source: path.join('@site', pluginPath, source), + source: path.join('@site', routeBasePath, source), title: 'lorem', editUrl: 'https://github.com/customUrl/docs/lorem.md', description: 'Lorem ipsum.', }); + + // unrelated frontmatter is not part of metadata + expect(data['unrelated_frontmatter']).toBeUndefined(); }); test('docs with last update time and author', async () => { const source = 'lorem.md'; - const data = await processMetadata({ - source, - docsDir, - order: {}, - siteConfig, - docsBasePath: pluginPath, - siteDir: simpleSiteDir, + const options = { + routeBasePath, showLastUpdateAuthor: true, showLastUpdateTime: true, + }; + + const data = await processMetadata({ + source, + refDir: docsDir, + context, + options, + env, }); expect(data).toEqual({ id: 'lorem', permalink: '/docs/lorem', - source: path.join('@site', pluginPath, source), + source: path.join('@site', routeBasePath, source), title: 'lorem', editUrl: 'https://github.com/customUrl/docs/lorem.md', description: 'Lorem ipsum.', - lastUpdatedAt: '1539502055', + lastUpdatedAt: 1539502055, lastUpdatedBy: 'Author', }); }); test('docs with invalid id', async () => { const badSiteDir = path.join(fixtureDir, 'bad-site'); + const options = { + routeBasePath, + }; return processMetadata({ source: 'invalid-id.md', - docsDir: path.join(badSiteDir, 'docs'), - order: {}, - siteConfig, - docsBasePath: 'docs', - siteDir: simpleSiteDir, + refDir: path.join(badSiteDir, 'docs'), + context, + options, + env, }).catch(e => expect(e).toMatchInlineSnapshot( `[Error: Document id cannot include "/".]`, @@ -166,3 +162,128 @@ describe('processMetadata', () => { ); }); }); + +describe('versioned site', () => { + const siteDir = path.join(fixtureDir, 'versioned-site'); + const context = loadContext(siteDir); + const routeBasePath = 'docs'; + const docsDir = path.resolve(siteDir, routeBasePath); + const env = loadEnv(siteDir); + const {docsDir: versionedDir} = env.versioning; + + test('master/next docs', async () => { + const sourceA = path.join('foo', 'bar.md'); + const sourceB = path.join('hello.md'); + const options = { + routeBasePath, + }; + + const [dataA, dataB] = await Promise.all([ + processMetadata({ + source: sourceA, + refDir: docsDir, + context, + options, + env, + }), + processMetadata({ + source: sourceB, + refDir: docsDir, + context, + options, + env, + }), + ]); + + expect(dataA).toEqual({ + id: 'foo/bar', + permalink: '/docs/next/foo/bar', + source: path.join('@site', routeBasePath, sourceA), + title: 'bar', + description: 'This is `next` version of bar.', + version: 'next', + }); + expect(dataB).toEqual({ + id: 'hello', + permalink: '/docs/next/hello', + source: path.join('@site', routeBasePath, sourceB), + title: 'hello', + description: 'Hello `next` !', + version: 'next', + }); + }); + + test('versioned docs', async () => { + const sourceA = path.join('version-1.0.0', 'foo', 'bar.md'); + const sourceB = path.join('version-1.0.0', 'hello.md'); + const sourceC = path.join('version-1.0.1', 'foo', 'bar.md'); + const sourceD = path.join('version-1.0.1', 'hello.md'); + const options = { + routeBasePath, + }; + + const [dataA, dataB, dataC, dataD] = await Promise.all([ + processMetadata({ + source: sourceA, + refDir: versionedDir, + context, + options, + env, + }), + processMetadata({ + source: sourceB, + refDir: versionedDir, + context, + options, + env, + }), + processMetadata({ + source: sourceC, + refDir: versionedDir, + context, + options, + env, + }), + processMetadata({ + source: sourceD, + refDir: versionedDir, + context, + options, + env, + }), + ]); + + expect(dataA).toEqual({ + id: 'version-1.0.0/foo/bar', + permalink: '/docs/1.0.0/foo/bar', + source: path.join('@site', path.relative(siteDir, versionedDir), sourceA), + title: 'bar', + description: 'Bar `1.0.0` !', + version: '1.0.0', + }); + expect(dataB).toEqual({ + id: 'version-1.0.0/hello', + permalink: '/docs/1.0.0/hello', + source: path.join('@site', path.relative(siteDir, versionedDir), sourceB), + title: 'hello', + description: 'Hello `1.0.0` !', + version: '1.0.0', + }); + expect(dataC).toEqual({ + id: 'version-1.0.1/foo/bar', + permalink: '/docs/foo/bar', + source: path.join('@site', path.relative(siteDir, versionedDir), sourceC), + title: 'bar', + description: 'Bar `1.0.1` !', + version: '1.0.1', + }); + expect(dataD).toEqual({ + id: 'version-1.0.1/hello', + permalink: '/docs/hello', + source: path.join('@site', path.relative(siteDir, versionedDir), sourceD), + title: 'hello', + description: 'Hello `1.0.1` !', + version: '1.0.1', + }); + }); +}); diff --git a/packages/docusaurus-plugin-content-docs/src/__tests__/order.test.ts b/packages/docusaurus-plugin-content-docs/src/__tests__/order.test.ts index d870608cf2bd..b6cd5f3fcf0e 100644 --- a/packages/docusaurus-plugin-content-docs/src/__tests__/order.test.ts +++ b/packages/docusaurus-plugin-content-docs/src/__tests__/order.test.ts @@ -230,32 +230,6 @@ describe('createOrder', () => { }); }); - test('multiple sidebars with unknown sidebar item type', () => { - expect(() => - createOrder({ - docs: [ - { - type: 'category', - label: 'Category1', - items: [ - {type: 'endi', id: 'doc1'}, - {type: 'doc', id: 'doc2'}, - ], - }, - ], - otherDocs: [ - { - type: 'category', - label: 'Category1', - items: [{type: 'doc', id: 'doc5'}], - }, - ], - }), - ).toThrowErrorMatchingInlineSnapshot( - `"Unknown item type: endi. Item: {\\"type\\":\\"endi\\",\\"id\\":\\"doc1\\"}"`, - ); - }); - test('edge cases', () => { expect(createOrder({})).toEqual({}); expect(createOrder(undefined)).toEqual({}); diff --git a/packages/docusaurus-plugin-content-docs/src/__tests__/sidebars.test.ts b/packages/docusaurus-plugin-content-docs/src/__tests__/sidebars.test.ts index 9260cade731f..e1b7cfd63892 100644 --- a/packages/docusaurus-plugin-content-docs/src/__tests__/sidebars.test.ts +++ b/packages/docusaurus-plugin-content-docs/src/__tests__/sidebars.test.ts @@ -14,13 +14,13 @@ describe('loadSidebars', () => { const fixtureDir = path.join(__dirname, '__fixtures__', 'sidebars'); test('sidebars with known sidebar item type', async () => { const sidebarPath = path.join(fixtureDir, 'sidebars.json'); - const result = loadSidebars(sidebarPath); + const result = loadSidebars([sidebarPath]); expect(result).toMatchSnapshot(); }); test('sidebars with deep level of category', async () => { const sidebarPath = path.join(fixtureDir, 'sidebars-category.js'); - const result = loadSidebars(sidebarPath); + const result = loadSidebars([sidebarPath]); expect(result).toMatchSnapshot(); }); @@ -29,7 +29,9 @@ describe('loadSidebars', () => { fixtureDir, 'sidebars-category-wrong-items.json', ); - expect(() => loadSidebars(sidebarPath)).toThrowErrorMatchingInlineSnapshot( + expect(() => + loadSidebars([sidebarPath]), + ).toThrowErrorMatchingInlineSnapshot( `"Error loading \\"Category Label\\" category. Category items must be array."`, ); }); @@ -37,23 +39,29 @@ describe('loadSidebars', () => { test('sidebars with first level not a category', async () => { const sidebarPath = path.join( fixtureDir, - 'sidebars-first-level-not-category', + 'sidebars-first-level-not-category.js', ); - expect(() => loadSidebars(sidebarPath)).toThrowErrorMatchingInlineSnapshot( + expect(() => + loadSidebars([sidebarPath]), + ).toThrowErrorMatchingInlineSnapshot( `"Error loading {\\"type\\":\\"doc\\",\\"id\\":\\"api\\"}. First level item of a sidebar must be a category"`, ); }); test('sidebars with unknown sidebar item type', async () => { const sidebarPath = path.join(fixtureDir, 'sidebars-unknown-type.json'); - expect(() => loadSidebars(sidebarPath)).toThrowErrorMatchingInlineSnapshot( + expect(() => + loadSidebars([sidebarPath]), + ).toThrowErrorMatchingInlineSnapshot( `"Unknown sidebar item type: superman"`, ); }); test('sidebars with known sidebar item type but wrong field', async () => { const sidebarPath = path.join(fixtureDir, 'sidebars-wrong-field.json'); - expect(() => loadSidebars(sidebarPath)).toThrowErrorMatchingInlineSnapshot( + expect(() => + loadSidebars([sidebarPath]), + ).toThrowErrorMatchingInlineSnapshot( `"Unknown sidebar item keys: href. Item: {\\"type\\":\\"category\\",\\"label\\":\\"category\\",\\"href\\":\\"https://github.com\\"}"`, ); }); @@ -62,10 +70,4 @@ describe('loadSidebars', () => { const result = loadSidebars(null); expect(result).toEqual({}); }); - - test('fake sidebars path', () => { - expect(() => { - loadSidebars('/fake/path'); - }).toThrowError(); - }); }); diff --git a/packages/docusaurus-plugin-content-docs/src/__tests__/version.test.ts b/packages/docusaurus-plugin-content-docs/src/__tests__/version.test.ts new file mode 100644 index 000000000000..2e2e80f6852d --- /dev/null +++ b/packages/docusaurus-plugin-content-docs/src/__tests__/version.test.ts @@ -0,0 +1,198 @@ +/** + * Copyright (c) 2017-present, Facebook, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import path from 'path'; +import {docsVersion} from '../version'; +import {PathOptions} from '../types'; +import fs from 'fs-extra'; +import { + getVersionedDocsDir, + getVersionsJSONFile, + getVersionedSidebarsDir, +} from '../env'; + +const fixtureDir = path.join(__dirname, '__fixtures__'); + +describe('docsVersion', () => { + const simpleSiteDir = path.join(fixtureDir, 'simple-site'); + const versionedSiteDir = path.join(fixtureDir, 'versioned-site'); + const DEFAULT_OPTIONS: PathOptions = { + path: 'docs', + sidebarPath: '', + }; + + test('no version tag provided', () => { + expect(() => + docsVersion(null, simpleSiteDir, DEFAULT_OPTIONS), + ).toThrowErrorMatchingInlineSnapshot( + `"No version tag specified!. Pass the version you wish to create as an argument. Ex: 1.0.0"`, + ); + expect(() => + docsVersion(undefined, simpleSiteDir, DEFAULT_OPTIONS), + ).toThrowErrorMatchingInlineSnapshot( + `"No version tag specified!. Pass the version you wish to create as an argument. Ex: 1.0.0"`, + ); + expect(() => + docsVersion('', simpleSiteDir, DEFAULT_OPTIONS), + ).toThrowErrorMatchingInlineSnapshot( + `"No version tag specified!. Pass the version you wish to create as an argument. Ex: 1.0.0"`, + ); + }); + + test('version tag should not have slash', () => { + expect(() => + docsVersion('foo/bar', simpleSiteDir, DEFAULT_OPTIONS), + ).toThrowErrorMatchingInlineSnapshot( + `"Invalid version tag specified! Do not include slash (/) or (\\\\). Try something like: 1.0.0"`, + ); + expect(() => + docsVersion('foo\\bar', simpleSiteDir, DEFAULT_OPTIONS), + ).toThrowErrorMatchingInlineSnapshot( + `"Invalid version tag specified! Do not include slash (/) or (\\\\). Try something like: 1.0.0"`, + ); + }); + + test('version tag should not be too long', () => { + expect(() => + docsVersion('a'.repeat(255), simpleSiteDir, DEFAULT_OPTIONS), + ).toThrowErrorMatchingInlineSnapshot( + `"Invalid version tag specified! Length must <= 32 characters. Try something like: 1.0.0"`, + ); + }); + + test('version tag should not be a dot or two dots', () => { + expect(() => + docsVersion('..', simpleSiteDir, DEFAULT_OPTIONS), + ).toThrowErrorMatchingInlineSnapshot( + `"Invalid version tag specified! Do not name your version \\".\\" or \\"..\\". Try something like: 1.0.0"`, + ); + expect(() => + docsVersion('.', simpleSiteDir, DEFAULT_OPTIONS), + ).toThrowErrorMatchingInlineSnapshot( + `"Invalid version tag specified! Do not name your version \\".\\" or \\"..\\". Try something like: 1.0.0"`, + ); + }); + + test('version tag should be a valid pathname', () => { + expect(() => + docsVersion('', simpleSiteDir, DEFAULT_OPTIONS), + ).toThrowErrorMatchingInlineSnapshot( + `"Invalid version tag specified! Please ensure its a valid pathname too. Try something like: 1.0.0"`, + ); + expect(() => + docsVersion('foo\x00bar', simpleSiteDir, DEFAULT_OPTIONS), + ).toThrowErrorMatchingInlineSnapshot( + `"Invalid version tag specified! Please ensure its a valid pathname too. Try something like: 1.0.0"`, + ); + expect(() => + docsVersion('foo:bar', simpleSiteDir, DEFAULT_OPTIONS), + ).toThrowErrorMatchingInlineSnapshot( + `"Invalid version tag specified! Please ensure its a valid pathname too. Try something like: 1.0.0"`, + ); + }); + + test('version tag already exist', () => { + expect(() => + docsVersion('1.0.0', versionedSiteDir, DEFAULT_OPTIONS), + ).toThrowErrorMatchingInlineSnapshot( + `"This version already exists!. Use a version tag that does not already exist."`, + ); + }); + + test('no docs file to version', () => { + const emptySiteDir = path.join(fixtureDir, 'empty-site'); + expect(() => + docsVersion('1.0.0', emptySiteDir, DEFAULT_OPTIONS), + ).toThrowErrorMatchingInlineSnapshot(`"There is no docs to version !"`); + }); + + test('first time versioning', () => { + const copyMock = jest.spyOn(fs, 'copySync').mockImplementation(); + const ensureMock = jest.spyOn(fs, 'ensureDirSync').mockImplementation(); + const writeMock = jest.spyOn(fs, 'writeFileSync'); + let versionedSidebar; + let versionedSidebarPath; + writeMock.mockImplementationOnce((filepath, content) => { + versionedSidebarPath = filepath; + versionedSidebar = JSON.parse(content); + }); + let versionsPath; + let versions; + writeMock.mockImplementationOnce((filepath, content) => { + versionsPath = filepath; + versions = JSON.parse(content); + }); + const consoleMock = jest.spyOn(console, 'log').mockImplementation(); + const options = { + path: 'docs', + sidebarPath: path.join(simpleSiteDir, 'sidebars.json'), + }; + docsVersion('1.0.0', simpleSiteDir, options); + expect(copyMock).toHaveBeenCalledWith( + path.join(simpleSiteDir, options.path), + path.join(getVersionedDocsDir(simpleSiteDir), 'version-1.0.0'), + ); + expect(versionedSidebar).toMatchSnapshot(); + expect(versionedSidebarPath).toEqual( + path.join( + getVersionedSidebarsDir(simpleSiteDir), + 'version-1.0.0-sidebars.json', + ), + ); + expect(versionsPath).toEqual(getVersionsJSONFile(simpleSiteDir)); + expect(versions).toEqual(['1.0.0']); + expect(consoleMock).toHaveBeenCalledWith('Version 1.0.0 created!'); + + copyMock.mockRestore(); + writeMock.mockRestore(); + consoleMock.mockRestore(); + ensureMock.mockRestore(); + }); + + test('not the first time versioning', () => { + const copyMock = jest.spyOn(fs, 'copySync').mockImplementation(); + const ensureMock = jest.spyOn(fs, 'ensureDirSync').mockImplementation(); + const writeMock = jest.spyOn(fs, 'writeFileSync'); + let versionedSidebar; + let versionedSidebarPath; + writeMock.mockImplementationOnce((filepath, content) => { + versionedSidebarPath = filepath; + versionedSidebar = JSON.parse(content); + }); + let versionsPath; + let versions; + writeMock.mockImplementationOnce((filepath, content) => { + versionsPath = filepath; + versions = JSON.parse(content); + }); + const consoleMock = jest.spyOn(console, 'log').mockImplementation(); + const options = { + path: 'docs', + sidebarPath: path.join(versionedSiteDir, 'sidebars.json'), + }; + docsVersion('2.0.0', versionedSiteDir, options); + expect(copyMock).toHaveBeenCalledWith( + path.join(versionedSiteDir, options.path), + path.join(getVersionedDocsDir(versionedSiteDir), 'version-2.0.0'), + ); + expect(versionedSidebar).toMatchSnapshot(); + expect(versionedSidebarPath).toEqual( + path.join( + getVersionedSidebarsDir(versionedSiteDir), + 'version-2.0.0-sidebars.json', + ), + ); + expect(versionsPath).toEqual(getVersionsJSONFile(versionedSiteDir)); + expect(versions).toEqual(['2.0.0', '1.0.1', '1.0.0']); + expect(consoleMock).toHaveBeenCalledWith('Version 2.0.0 created!'); + + copyMock.mockRestore(); + writeMock.mockRestore(); + consoleMock.mockRestore(); + ensureMock.mockRestore(); + }); +}); diff --git a/packages/docusaurus-plugin-content-docs/src/constants.ts b/packages/docusaurus-plugin-content-docs/src/constants.ts new file mode 100644 index 000000000000..095fa361a033 --- /dev/null +++ b/packages/docusaurus-plugin-content-docs/src/constants.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) 2017-present, Facebook, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +export const VERSIONED_DOCS_DIR = 'versioned_docs'; +export const VERSIONED_SIDEBARS_DIR = 'versioned_sidebars'; +export const VERSIONS_JSON_FILE = 'versions.json'; diff --git a/packages/docusaurus-plugin-content-docs/src/env.ts b/packages/docusaurus-plugin-content-docs/src/env.ts new file mode 100644 index 000000000000..a9e5fae8cc5b --- /dev/null +++ b/packages/docusaurus-plugin-content-docs/src/env.ts @@ -0,0 +1,55 @@ +/** + * Copyright (c) 2017-present, Facebook, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import path from 'path'; +import fs from 'fs-extra'; +import {VersioningEnv, Env} from './types'; +import { + VERSIONS_JSON_FILE, + VERSIONED_DOCS_DIR, + VERSIONED_SIDEBARS_DIR, +} from './constants'; + +export function getVersionedDocsDir(siteDir: string) { + return path.join(siteDir, VERSIONED_DOCS_DIR); +} + +export function getVersionedSidebarsDir(siteDir: string) { + return path.join(siteDir, VERSIONED_SIDEBARS_DIR); +} + +export function getVersionsJSONFile(siteDir: string) { + return path.join(siteDir, VERSIONS_JSON_FILE); +} + +export default function(siteDir: string): Env { + const versioning: VersioningEnv = { + enabled: false, + versions: [], + latestVersion: null, + docsDir: '', + sidebarsDir: '', + }; + + const versionsJSONFile = getVersionsJSONFile(siteDir); + if (fs.existsSync(versionsJSONFile)) { + const parsedVersions = JSON.parse( + fs.readFileSync(versionsJSONFile, 'utf8'), + ); + if (parsedVersions && parsedVersions.length > 0) { + versioning.latestVersion = parsedVersions[0]; + versioning.enabled = true; + versioning.versions = parsedVersions; + versioning.docsDir = getVersionedDocsDir(siteDir); + versioning.sidebarsDir = getVersionedSidebarsDir(siteDir); + } + } + + return { + versioning, + }; +} diff --git a/packages/docusaurus-plugin-content-docs/src/index.ts b/packages/docusaurus-plugin-content-docs/src/index.ts index 8848409ad579..dbc278557dfe 100644 --- a/packages/docusaurus-plugin-content-docs/src/index.ts +++ b/packages/docusaurus-plugin-content-docs/src/index.ts @@ -5,20 +5,17 @@ * LICENSE file in the root directory of this source tree. */ +import _ from 'lodash'; import globby from 'globby'; import fs from 'fs-extra'; import path from 'path'; -import { - idx, - normalizeUrl, - docuHash, - objectWithKeySorted, -} from '@docusaurus/utils'; -import {LoadContext, Plugin} from '@docusaurus/types'; +import {normalizeUrl, docuHash, objectWithKeySorted} from '@docusaurus/utils'; +import {LoadContext, Plugin, RouteConfig} from '@docusaurus/types'; import createOrder from './order'; import loadSidebars from './sidebars'; import processMetadata from './metadata'; +import loadEnv from './env'; import { PluginOptions, @@ -35,8 +32,12 @@ import { DocsSidebar, DocsBaseMetadata, MetadataRaw, + DocsMetadataRaw, + Metadata, + VersionToSidebars, } from './types'; import {Configuration} from 'webpack'; +import {docsVersion} from './version'; const DEFAULT_OPTIONS: PluginOptions = { path: 'docs', // Path to data on filesystem, relative to site dir. @@ -56,101 +57,166 @@ export default function pluginContentDocs( opts: Partial, ): Plugin { const options = {...DEFAULT_OPTIONS, ...opts}; - const contentPath = path.resolve(context.siteDir, options.path); - let sourceToPermalink: SourceToPermalink = {}; + const {siteDir, generatedFilesDir, baseUrl} = context; + const docsDir = path.resolve(siteDir, options.path); + const sourceToPermalink: SourceToPermalink = {}; + const dataDir = path.join( - context.generatedFilesDir, + generatedFilesDir, 'docusaurus-plugin-content-docs', ); + // Versioning + const env = loadEnv(siteDir); + const {versioning} = env; + const { + versions, + docsDir: versionedDir, + sidebarsDir: versionedSidebarsDir, + } = versioning; + const versionsNames = versions.map(version => `version-${version}`); + return { name: 'docusaurus-plugin-content-docs', + extendCli(cli) { + cli + .command('docs:version') + .arguments('') + .description('Tag a new version for docs') + .action(version => { + docsVersion(version, siteDir, { + path: options.path, + sidebarPath: options.sidebarPath, + }); + }); + }, + getPathsToWatch() { - const {include = []} = options; - const globPattern = include.map(pattern => `${contentPath}/${pattern}`); + const {include} = options; + let globPattern = include.map(pattern => `${docsDir}/${pattern}`); + if (versioning.enabled) { + const docsGlob = _.flatten( + include.map(pattern => + versionsNames.map( + versionName => `${versionedDir}/${versionName}/${pattern}`, + ), + ), + ); + const sidebarsGlob = versionsNames.map( + versionName => `${versionedSidebarsDir}/${versionName}-sidebars.json`, + ); + globPattern = [...globPattern, ...sidebarsGlob, ...docsGlob]; + } return [...globPattern, options.sidebarPath]; }, // Fetches blog contents and returns metadata for the contents. async loadContent() { - const { - include, - routeBasePath, - sidebarPath, - editUrl, - showLastUpdateAuthor, - showLastUpdateTime, - } = options; - const {siteConfig, siteDir} = context; - const docsDir = contentPath; + const {include, sidebarPath} = options; if (!fs.existsSync(docsDir)) { return null; } - const loadedSidebars: Sidebar = loadSidebars(sidebarPath); - - // Build the docs ordering such as next, previous, category and sidebar. - const order: Order = createOrder(loadedSidebars); - // Prepare metadata container. - const docsMetadataRaw: { - [id: string]: MetadataRaw; - } = {}; + const docsMetadataRaw: DocsMetadataRaw = {}; + const docsPromises = []; - // Metadata for default docs files. + // Metadata for default/ master docs files. const docsFiles = await globby(include, { cwd: docsDir, }); - await Promise.all( - docsFiles.map(async source => { - const metadata: MetadataRaw = await processMetadata({ - source, - docsDir, - order, - siteConfig, - docsBasePath: routeBasePath, - siteDir, - editUrl, - showLastUpdateAuthor, - showLastUpdateTime, - }); - docsMetadataRaw[metadata.id] = metadata; - }), + docsPromises.push( + Promise.all( + docsFiles.map(async source => { + const metadata: MetadataRaw = await processMetadata({ + source, + refDir: docsDir, + context, + options, + env, + }); + docsMetadataRaw[metadata.id] = metadata; + }), + ), ); - // Construct docsMetadata + // Metadata for versioned docs + if (versioning.enabled) { + const versionedGlob = _.flatten( + include.map(pattern => + versionsNames.map(versionName => `${versionName}/${pattern}`), + ), + ); + const versionedFiles = await globby(versionedGlob, { + cwd: versionedDir, + }); + docsPromises.push( + Promise.all( + versionedFiles.map(async source => { + const metadata = await processMetadata({ + source, + refDir: versionedDir, + context, + options, + env, + }); + docsMetadataRaw[metadata.id] = metadata; + }), + ), + ); + } + + // Load the sidebars & create docs ordering + const sidebarPaths = [ + sidebarPath, + ...versionsNames.map( + versionName => `${versionedSidebarsDir}/${versionName}-sidebars.json`, + ), + ]; + const loadedSidebars: Sidebar = loadSidebars(sidebarPaths); + const order: Order = createOrder(loadedSidebars); + + await Promise.all(docsPromises); + + // Construct inter-metadata relationship in docsMetadata const docsMetadata: DocsMetadata = {}; const permalinkToSidebar: PermalinkToSidebar = {}; + const versionToSidebars: VersionToSidebars = {}; Object.keys(docsMetadataRaw).forEach(currentID => { - let previous; - let next; - const previousID = idx(docsMetadataRaw, [currentID, 'previous']); - if (previousID) { - previous = { - title: idx(docsMetadataRaw, [previousID, 'title']) || 'Previous', - permalink: idx(docsMetadataRaw, [previousID, 'permalink']), - }; - } - const nextID = idx(docsMetadataRaw, [currentID, 'next']); - if (nextID) { - next = { - title: idx(docsMetadataRaw, [nextID, 'title']) || 'Next', - permalink: idx(docsMetadataRaw, [nextID, 'permalink']), - }; - } + const {next: nextID, previous: previousID, sidebar} = + order[currentID] || {}; + const previous = previousID + ? { + title: docsMetadataRaw[previousID]?.title ?? 'Previous', + permalink: docsMetadataRaw[previousID]?.permalink, + } + : undefined; + const next = nextID + ? { + title: docsMetadataRaw[nextID]?.title ?? 'Next', + permalink: docsMetadataRaw[nextID]?.permalink, + } + : undefined; docsMetadata[currentID] = { ...docsMetadataRaw[currentID], + sidebar, previous, next, }; // sourceToPermalink and permalinkToSidebar mapping - const {source, permalink, sidebar} = docsMetadataRaw[currentID]; + const {source, permalink, version} = docsMetadataRaw[currentID]; sourceToPermalink[source] = permalink; if (sidebar) { permalinkToSidebar[permalink] = sidebar; + if (versioning.enabled && version) { + if (!versionToSidebars[version]) { + versionToSidebars[version] = new Set(); + } + versionToSidebars[version].add(sidebar); + } } }); @@ -206,8 +272,8 @@ export default function pluginContentDocs( docsMetadata, docsDir, docsSidebars, - sourceToPermalink, permalinkToSidebar: objectWithKeySorted(permalinkToSidebar), + versionToSidebars, }; }, @@ -221,49 +287,107 @@ export default function pluginContentDocs( const aliasedSource = (source: string) => `@docusaurus-plugin-content-docs/${path.relative(dataDir, source)}`; - const routes = await Promise.all( - Object.values(content.docsMetadata).map(async metadataItem => { - const metadataPath = await createData( - `${docuHash(metadataItem.permalink)}.json`, - JSON.stringify(metadataItem, null, 2), - ); - return { - path: metadataItem.permalink, - component: docItemComponent, - exact: true, - modules: { - content: metadataItem.source, - metadata: aliasedSource(metadataPath), - }, - }; - }), - ); + const genRoutes = async ( + metadataItems: Metadata[], + ): Promise => { + const routes = await Promise.all( + metadataItems.map(async metadataItem => { + const metadataPath = await createData( + `${docuHash(metadataItem.permalink)}.json`, + JSON.stringify(metadataItem, null, 2), + ); + return { + path: metadataItem.permalink, + component: docItemComponent, + exact: true, + modules: { + content: metadataItem.source, + metadata: aliasedSource(metadataPath), + }, + }; + }), + ); + return routes.sort((a, b) => + a.path > b.path ? 1 : b.path > a.path ? -1 : 0, + ); + }; - const docsBaseMetadata: DocsBaseMetadata = { - docsSidebars: content.docsSidebars, - permalinkToSidebar: content.permalinkToSidebar, + const addBaseRoute = async ( + docsBaseRoute: string, + docsBaseMetadata: DocsBaseMetadata, + routes: RouteConfig[], + priority?: number, + ) => { + const docsBaseMetadataPath = await createData( + `${docuHash(docsBaseRoute)}.json`, + JSON.stringify(docsBaseMetadata, null, 2), + ); + + addRoute({ + path: docsBaseRoute, + component: docLayoutComponent, + routes, + modules: { + docsMetadata: aliasedSource(docsBaseMetadataPath), + }, + priority, + }); }; - const docsBaseRoute = normalizeUrl([ - context.baseUrl, - routeBasePath, - ':route', - ]); - const docsBaseMetadataPath = await createData( - `${docuHash(docsBaseRoute)}.json`, - JSON.stringify(docsBaseMetadata, null, 2), - ); + // If versioning is enabled, we cleverly chunk the generated routes to be by version + // and pick only needed base metadata + if (versioning.enabled) { + const docsMetadataByVersion = _.groupBy( + Object.values(content.docsMetadata), + 'version', + ); + await Promise.all( + Object.keys(docsMetadataByVersion).map(async version => { + const routes: RouteConfig[] = await genRoutes( + docsMetadataByVersion[version], + ); - addRoute({ - path: docsBaseRoute, - component: docLayoutComponent, - routes: routes.sort((a, b) => - a.path > b.path ? 1 : b.path > a.path ? -1 : 0, - ), - modules: { - docsMetadata: aliasedSource(docsBaseMetadataPath), - }, - }); + const isLatestVersion = version === versioning.latestVersion; + const docsBasePermalink = normalizeUrl([ + baseUrl, + routeBasePath, + isLatestVersion ? '' : version, + ]); + const docsBaseRoute = normalizeUrl([docsBasePermalink, ':route']); + const neededSidebars: Set = + content.versionToSidebars[version] || new Set(); + const docsBaseMetadata: DocsBaseMetadata = { + docsSidebars: _.pick( + content.docsSidebars, + Array.from(neededSidebars), + ), + permalinkToSidebar: _.pickBy( + content.permalinkToSidebar, + sidebar => neededSidebars.has(sidebar), + ), + version, + }; + + // We want latest version route config to be placed last in the generated routeconfig. + // Otherwise, `/docs/next/foo` will match `/docs/:route` instead of `/docs/next/:route` + return addBaseRoute( + docsBaseRoute, + docsBaseMetadata, + routes, + isLatestVersion ? -1 : undefined, + ); + }), + ); + } else { + const routes = await genRoutes(Object.values(content.docsMetadata)); + const docsBaseMetadata: DocsBaseMetadata = { + docsSidebars: content.docsSidebars, + permalinkToSidebar: content.permalinkToSidebar, + }; + + const docsBaseRoute = normalizeUrl([baseUrl, routeBasePath, ':route']); + return addBaseRoute(docsBaseRoute, docsBaseMetadata, routes); + } }, configureWebpack(_config, isServer, utils) { @@ -279,7 +403,7 @@ export default function pluginContentDocs( rules: [ { test: /(\.mdx?)$/, - include: [contentPath], + include: [docsDir, versionedDir].filter(Boolean), use: [ getCacheLoader(isServer), getBabelLoader(isServer), @@ -293,9 +417,10 @@ export default function pluginContentDocs( { loader: path.resolve(__dirname, './markdown/index.js'), options: { - siteDir: context.siteDir, - docsDir: contentPath, + siteDir, + docsDir, sourceToPermalink: sourceToPermalink, + versionedDir, }, }, ].filter(Boolean), diff --git a/packages/docusaurus-plugin-content-docs/src/markdown/__tests__/__fixtures__/versioned_docs/version-1.0.0/doc2.md b/packages/docusaurus-plugin-content-docs/src/markdown/__tests__/__fixtures__/versioned_docs/version-1.0.0/doc2.md new file mode 100644 index 000000000000..a84f858148fb --- /dev/null +++ b/packages/docusaurus-plugin-content-docs/src/markdown/__tests__/__fixtures__/versioned_docs/version-1.0.0/doc2.md @@ -0,0 +1,6 @@ +### Existing Docs + +- [doc1](subdir/doc1.md) + +### With hash +- [doc2](doc2.md#existing-docs) \ No newline at end of file diff --git a/packages/docusaurus-plugin-content-docs/src/markdown/__tests__/__fixtures__/versioned_docs/version-1.0.0/subdir/doc1.md b/packages/docusaurus-plugin-content-docs/src/markdown/__tests__/__fixtures__/versioned_docs/version-1.0.0/subdir/doc1.md new file mode 100644 index 000000000000..6da3139921b5 --- /dev/null +++ b/packages/docusaurus-plugin-content-docs/src/markdown/__tests__/__fixtures__/versioned_docs/version-1.0.0/subdir/doc1.md @@ -0,0 +1,2 @@ +### Relative linking +- [doc1](../doc2.md) diff --git a/packages/docusaurus-plugin-content-docs/src/markdown/__tests__/__snapshots__/linkify.test.ts.snap b/packages/docusaurus-plugin-content-docs/src/markdown/__tests__/__snapshots__/linkify.test.ts.snap index c00708c8f472..d163bb7ec76e 100644 --- a/packages/docusaurus-plugin-content-docs/src/markdown/__tests__/__snapshots__/linkify.test.ts.snap +++ b/packages/docusaurus-plugin-content-docs/src/markdown/__tests__/__snapshots__/linkify.test.ts.snap @@ -34,6 +34,15 @@ exports[`transform to correct links 1`] = ` - [doc2](/docs/doc2)" `; +exports[`transforms absolute links in versioned docs 1`] = ` +"### Existing Docs + +- [doc1](/docs/1.0.0/subdir/doc1) + +### With hash +- [doc2](/docs/1.0.0/doc2#existing-docs)" +`; + exports[`transforms reference links 1`] = ` "### Existing Docs @@ -55,3 +64,9 @@ exports[`transforms reference links 1`] = ` [image1]: assets/image1.png " `; + +exports[`transforms relative links in versioned docs 1`] = ` +"### Relative linking +- [doc1](/docs/1.0.0/doc2) +" +`; diff --git a/packages/docusaurus-plugin-content-docs/src/markdown/__tests__/linkify.test.ts b/packages/docusaurus-plugin-content-docs/src/markdown/__tests__/linkify.test.ts index 3fbd8616baae..1d6c0fc1a90f 100644 --- a/packages/docusaurus-plugin-content-docs/src/markdown/__tests__/linkify.test.ts +++ b/packages/docusaurus-plugin-content-docs/src/markdown/__tests__/linkify.test.ts @@ -9,14 +9,19 @@ import fs from 'fs-extra'; import path from 'path'; import linkify from '../linkify'; import {SourceToPermalink} from '../../types'; +import {VERSIONED_DOCS_DIR} from '../../constants'; const siteDir = path.join(__dirname, '__fixtures__'); const docsDir = path.join(siteDir, 'docs'); +const versionedDir = path.join(siteDir, VERSIONED_DOCS_DIR); const sourceToPermalink: SourceToPermalink = { '@site/docs/doc1.md': '/docs/doc1', '@site/docs/doc2.md': '/docs/doc2', '@site/docs/subdir/doc3.md': '/docs/subdir/doc3', '@site/docs/doc4.md': '/docs/doc4', + '@site/versioned_docs/version-1.0.0/doc2.md': '/docs/1.0.0/doc2', + '@site/versioned_docs/version-1.0.0/subdir/doc1.md': + '/docs/1.0.0/subdir/doc1', }; const transform = filepath => { @@ -27,6 +32,7 @@ const transform = filepath => { docsDir, siteDir, sourceToPermalink, + versionedDir, ); return [content, transformedContent]; }; @@ -70,3 +76,23 @@ test('transforms reference links', () => { expect(transformedContent).not.toContain('[doc2]: ./doc2.md'); expect(content).not.toEqual(transformedContent); }); + +test('transforms absolute links in versioned docs', () => { + const doc2 = path.join(versionedDir, 'version-1.0.0', 'doc2.md'); + const [content, transformedContent] = transform(doc2); + expect(transformedContent).toMatchSnapshot(); + expect(transformedContent).toContain('](/docs/1.0.0/subdir/doc1'); + expect(transformedContent).toContain('](/docs/1.0.0/doc2#existing-docs'); + expect(transformedContent).not.toContain('](subdir/doc1.md)'); + expect(transformedContent).not.toContain('](doc2.md#existing-docs)'); + expect(content).not.toEqual(transformedContent); +}); + +test('transforms relative links in versioned docs', () => { + const doc1 = path.join(versionedDir, 'version-1.0.0', 'subdir', 'doc1.md'); + const [content, transformedContent] = transform(doc1); + expect(transformedContent).toMatchSnapshot(); + expect(transformedContent).toContain('](/docs/1.0.0/doc2'); + expect(transformedContent).not.toContain('](../doc2.md)'); + expect(content).not.toEqual(transformedContent); +}); diff --git a/packages/docusaurus-plugin-content-docs/src/markdown/index.ts b/packages/docusaurus-plugin-content-docs/src/markdown/index.ts index 407b2c3709fe..6f842ef0d288 100644 --- a/packages/docusaurus-plugin-content-docs/src/markdown/index.ts +++ b/packages/docusaurus-plugin-content-docs/src/markdown/index.ts @@ -11,7 +11,7 @@ import linkify from './linkify'; export = function(fileString: string) { const callback = this.async(); - const {docsDir, siteDir, sourceToPermalink} = getOptions(this); + const {docsDir, siteDir, versionedDir, sourceToPermalink} = getOptions(this); return ( callback && callback( @@ -22,6 +22,7 @@ export = function(fileString: string) { docsDir, siteDir, sourceToPermalink, + versionedDir, ), ) ); diff --git a/packages/docusaurus-plugin-content-docs/src/markdown/linkify.ts b/packages/docusaurus-plugin-content-docs/src/markdown/linkify.ts index 0d3e360eb3c0..c53f76d3f30d 100644 --- a/packages/docusaurus-plugin-content-docs/src/markdown/linkify.ts +++ b/packages/docusaurus-plugin-content-docs/src/markdown/linkify.ts @@ -7,6 +7,7 @@ import path from 'path'; import {resolve} from 'url'; +import {getSubFolder} from '@docusaurus/utils'; import {SourceToPermalink} from '../types'; export default function( @@ -15,12 +16,19 @@ export default function( docsDir: string, siteDir: string, sourceToPermalink: SourceToPermalink, + versionedDir?: string, ) { // Determine the source dir. e.g: /website/docs, /website/versioned_docs/version-1.0.0 let sourceDir: string | undefined; const thisSource = filePath; if (thisSource.startsWith(docsDir)) { sourceDir = docsDir; + } else if (versionedDir && thisSource.startsWith(versionedDir)) { + const specificVersionDir = getSubFolder(thisSource, versionedDir); + // e.g: specificVersionDir = version-1.0.0 + if (specificVersionDir) { + sourceDir = path.join(versionedDir, specificVersionDir); + } } let content = fileString; diff --git a/packages/docusaurus-plugin-content-docs/src/metadata.ts b/packages/docusaurus-plugin-content-docs/src/metadata.ts index 654f97b25716..31567af0e3c1 100644 --- a/packages/docusaurus-plugin-content-docs/src/metadata.ts +++ b/packages/docusaurus-plugin-content-docs/src/metadata.ts @@ -8,130 +8,138 @@ import fs from 'fs-extra'; import path from 'path'; import {parse, normalizeUrl, posixPath} from '@docusaurus/utils'; -import {DocusaurusConfig} from '@docusaurus/types'; +import {LoadContext} from '@docusaurus/types'; import lastUpdate from './lastUpdate'; -import {Order, MetadataRaw} from './types'; +import {MetadataRaw, LastUpdateData, MetadataOptions, Env} from './types'; type Args = { source: string; - docsDir: string; - order: Order; - siteConfig: Partial; - docsBasePath: string; - siteDir: string; - editUrl?: string; - showLastUpdateAuthor?: boolean; - showLastUpdateTime?: boolean; + refDir: string; + context: LoadContext; + options: MetadataOptions; + env: Env; }; +async function lastUpdated( + filePath: string, + options: MetadataOptions, +): Promise { + const {showLastUpdateAuthor, showLastUpdateTime} = options; + if (showLastUpdateAuthor || showLastUpdateTime) { + // Use fake data in dev for faster development + const fileLastUpdateData = + process.env.NODE_ENV === 'production' + ? await lastUpdate(filePath) + : { + author: 'Author', + timestamp: 1539502055, + }; + + if (fileLastUpdateData) { + const {author, timestamp} = fileLastUpdateData; + return { + lastUpdatedAt: showLastUpdateTime ? timestamp : undefined, + lastUpdatedBy: showLastUpdateAuthor ? author : undefined, + }; + } + } + return {}; +} + export default async function processMetadata({ source, - docsDir, - order, - siteConfig, - docsBasePath, - siteDir, - editUrl, - showLastUpdateAuthor, - showLastUpdateTime, + refDir, + context, + options, + env, }: Args): Promise { - const filePath = path.join(docsDir, source); + const {routeBasePath, editUrl} = options; + const {siteDir, baseUrl} = context; + const {versioning} = env; + const filePath = path.join(refDir, source); const fileString = await fs.readFile(filePath, 'utf-8'); - const {frontMatter: metadata = {}, excerpt} = parse(fileString); + const {frontMatter = {}, excerpt} = parse(fileString); // Default id is the file name. - if (!metadata.id) { - metadata.id = path.basename(source, path.extname(source)); - } - - if (metadata.id.includes('/')) { + const baseID: string = + frontMatter.id || path.basename(source, path.extname(source)); + if (baseID.includes('/')) { throw new Error('Document id cannot include "/".'); } // Default title is the id. - if (!metadata.title) { - metadata.title = metadata.id; - } + const title: string = frontMatter.title || baseID; - if (!metadata.description) { - metadata.description = excerpt; - } + const description: string = frontMatter.description || excerpt; + + let version; + let id = baseID; + // Append subdirectory as part of id. const dirName = path.dirname(source); if (dirName !== '.') { - const prefix = dirName; - if (prefix) { - metadata.id = `${prefix}/${metadata.id}`; - } - } - - // Cannot use path.join() as it resolves '../' and removes the '@site'. Let webpack loader resolve it. - const aliasedPath = `@site/${path.relative(siteDir, filePath)}`; - metadata.source = aliasedPath; - - // Build the permalink. - const {baseUrl} = siteConfig; - - // If user has own custom permalink defined in frontmatter - // e.g: :baseUrl:docsUrl/:langPart/:versionPart/endiliey/:id - if (metadata.permalink) { - metadata.permalink = path.resolve( - metadata.permalink - .replace(/:baseUrl/, baseUrl) - .replace(/:docsUrl/, docsBasePath) - .replace(/:id/, metadata.id), - ); - } else { - metadata.permalink = normalizeUrl([baseUrl, docsBasePath, metadata.id]); + id = `${dirName}/${baseID}`; } - // Determine order. - const {id} = metadata; - if (order[id]) { - metadata.sidebar = order[id].sidebar; - if (order[id].next) { - metadata.next = order[id].next; - } - if (order[id].previous) { - metadata.previous = order[id].previous; + if (versioning.enabled) { + if (/^version-/.test(dirName)) { + const inferredVersion = dirName + .split('/', 1) + .shift()! + .replace(/^version-/, ''); + if (inferredVersion && versioning.versions.includes(inferredVersion)) { + version = inferredVersion; + } + } else { + version = 'next'; } } - if (editUrl) { - metadata.editUrl = normalizeUrl([ - editUrl, - posixPath(path.relative(siteDir, filePath)), - ]); - } + // The version portion of the url path. Eg: 'next', '1.0.0', and '' + const versionPath = + version && version !== versioning.latestVersion ? version : ''; - if (metadata.custom_edit_url) { - metadata.editUrl = metadata.custom_edit_url; - delete metadata.custom_edit_url; - } - - if (showLastUpdateAuthor || showLastUpdateTime) { - // Use fake data in dev for faster development - const fileLastUpdateData = - process.env.NODE_ENV === 'production' - ? await lastUpdate(filePath) - : { - author: 'Author', - timestamp: '1539502055', - }; + // The last portion of the url path. Eg: 'foo/bar', 'bar' + const routePath = + version && version !== 'next' + ? id.replace(new RegExp(`^version-${version}/`), '') + : id; + const permalink = normalizeUrl([ + baseUrl, + routeBasePath, + versionPath, + routePath, + ]); - if (fileLastUpdateData) { - const {author, timestamp} = fileLastUpdateData; - if (showLastUpdateAuthor && author) { - metadata.lastUpdatedBy = author; - } + const {sidebar_label, custom_edit_url} = frontMatter; - if (showLastUpdateTime && timestamp) { - metadata.lastUpdatedAt = timestamp; - } - } - } + const relativePath = path.relative(siteDir, filePath); - return metadata as MetadataRaw; + // Cannot use path.join() as it resolves '../' and removes the '@site'. Let webpack loader resolve it. + const aliasedPath = `@site/${relativePath}`; + + const docsEditUrl = editUrl + ? normalizeUrl([editUrl, posixPath(relativePath)]) + : undefined; + + const {lastUpdatedAt, lastUpdatedBy} = await lastUpdated(filePath, options); + + // Assign all of object properties during instantiation (if possible) for NodeJS optimization + // Adding properties to object after instantiation will cause hidden class transitions. + const metadata: MetadataRaw = { + id, + title, + description, + source: aliasedPath, + permalink, + editUrl: custom_edit_url || docsEditUrl, + version, + lastUpdatedBy, + lastUpdatedAt, + sidebar_label, + }; + + return metadata; } diff --git a/packages/docusaurus-plugin-content-docs/src/order.ts b/packages/docusaurus-plugin-content-docs/src/order.ts index 8c9805d633c2..71b720fcad26 100644 --- a/packages/docusaurus-plugin-content-docs/src/order.ts +++ b/packages/docusaurus-plugin-content-docs/src/order.ts @@ -5,13 +5,7 @@ * LICENSE file in the root directory of this source tree. */ -import { - Sidebar, - SidebarItem, - SidebarItemDoc, - SidebarItemCategory, - Order, -} from './types'; +import {Sidebar, SidebarItem, Order} from './types'; // Build the docs meta such as next, previous, category and sidebar. export default function createOrder(allSidebars: Sidebar = {}): Order { @@ -26,7 +20,7 @@ export default function createOrder(allSidebars: Sidebar = {}): Order { switch (item.type) { case 'category': indexItems({ - items: (item as SidebarItemCategory).items, + items: item.items, }); break; case 'ref': @@ -34,12 +28,8 @@ export default function createOrder(allSidebars: Sidebar = {}): Order { // Refs and links should not be shown in navigation. break; case 'doc': - ids.push((item as SidebarItemDoc).id); + ids.push(item.id); break; - default: - throw new Error( - `Unknown item type: ${item.type}. Item: ${JSON.stringify(item)}`, - ); } }); }; diff --git a/packages/docusaurus-plugin-content-docs/src/sidebars.ts b/packages/docusaurus-plugin-content-docs/src/sidebars.ts index 462e6b6de24e..7346e4465b27 100644 --- a/packages/docusaurus-plugin-content-docs/src/sidebars.ts +++ b/packages/docusaurus-plugin-content-docs/src/sidebars.ts @@ -5,6 +5,7 @@ * LICENSE file in the root directory of this source tree. */ +import fs from 'fs-extra'; import importFresh from 'import-fresh'; import { SidebarItemCategory, @@ -108,11 +109,18 @@ function normalizeSidebar(sidebars: SidebarRaw): Sidebar { ); } -export default function loadSidebars(sidebarPath: string): Sidebar { +export default function loadSidebars(sidebarPaths?: string[]): Sidebar { // We don't want sidebars to be cached because of hotreloading. let allSidebars: SidebarRaw = {}; - if (sidebarPath) { - allSidebars = importFresh(sidebarPath) as SidebarRaw; + if (!sidebarPaths || !sidebarPaths.length) { + return {} as Sidebar; } + sidebarPaths.map(sidebarPath => { + if (sidebarPath && fs.existsSync(sidebarPath)) { + const sidebar = importFresh(sidebarPath) as SidebarRaw; + Object.assign(allSidebars, sidebar); + } + }); + return normalizeSidebar(allSidebars); } diff --git a/packages/docusaurus-plugin-content-docs/src/types.ts b/packages/docusaurus-plugin-content-docs/src/types.ts index 23ab9b380d92..d0cd03214e8e 100644 --- a/packages/docusaurus-plugin-content-docs/src/types.ts +++ b/packages/docusaurus-plugin-content-docs/src/types.ts @@ -5,39 +5,45 @@ * LICENSE file in the root directory of this source tree. */ -export interface PluginOptions { - path: string; +export interface MetadataOptions { routeBasePath: string; - include: string[]; + editUrl?: string; + showLastUpdateTime?: boolean; + showLastUpdateAuthor?: boolean; +} + +export interface PathOptions { + path: string; sidebarPath: string; +} + +export interface PluginOptions extends MetadataOptions, PathOptions { + include: string[]; docLayoutComponent: string; docItemComponent: string; remarkPlugins: string[]; rehypePlugins: string[]; - editUrl?: string; - showLastUpdateTime?: boolean; - showLastUpdateAuthor?: boolean; } export type SidebarItemDoc = { - type: string; + type: 'doc' | 'ref'; id: string; }; export interface SidebarItemLink { - type: string; + type: 'link'; href: string; label: string; } export interface SidebarItemCategory { - type: string; + type: 'category'; label: string; items: SidebarItem[]; } export interface SidebarItemCategoryRaw { - type: string; + type: 'category'; label: string; items: SidebarItemRaw[]; } @@ -51,7 +57,11 @@ export type SidebarItemRaw = | string | SidebarItemDoc | SidebarItemLink - | SidebarItemCategoryRaw; + | SidebarItemCategoryRaw + | { + type: string; + [key: string]: any; + }; // Sidebar given by user that is not normalized yet. e.g: sidebars.json export interface SidebarRaw { @@ -65,7 +75,7 @@ export interface Sidebar { } export interface DocsSidebarItemCategory { - type: string; + type: 'category'; label: string; items: (SidebarItemLink | DocsSidebarItemCategory)[]; } @@ -84,7 +94,12 @@ export interface Order { [id: string]: OrderMetadata; } -export interface MetadataRaw extends OrderMetadata { +export interface LastUpdateData { + lastUpdatedAt?: number; + lastUpdatedBy?: string; +} + +export interface MetadataRaw extends LastUpdateData { id: string; title: string; description: string; @@ -92,9 +107,7 @@ export interface MetadataRaw extends OrderMetadata { permalink: string; sidebar_label?: string; editUrl?: string; - lastUpdatedAt?: number; - lastUpdatedBy?: string; - [key: string]: any; + version?: string; } export interface Paginator { @@ -102,7 +115,8 @@ export interface Paginator { permalink: string; } -export interface Metadata extends Omit { +export interface Metadata extends MetadataRaw { + sidebar?: string; previous?: Paginator; next?: Paginator; } @@ -111,6 +125,10 @@ export interface DocsMetadata { [id: string]: Metadata; } +export interface DocsMetadataRaw { + [id: string]: MetadataRaw; +} + export interface SourceToPermalink { [source: string]: string; } @@ -119,15 +137,34 @@ export interface PermalinkToSidebar { [permalink: string]: string; } +export interface VersionToSidebars { + [version: string]: Set; +} + export interface LoadedContent { docsMetadata: DocsMetadata; docsDir: string; - docsSidebars: Sidebar; - sourceToPermalink: SourceToPermalink; + docsSidebars: DocsSidebar; permalinkToSidebar: PermalinkToSidebar; + versionToSidebars: VersionToSidebars; } export type DocsBaseMetadata = Pick< LoadedContent, 'docsSidebars' | 'permalinkToSidebar' ->; +> & { + version?: string; +}; + +export type VersioningEnv = { + enabled: boolean; + latestVersion: string | null; + versions: string[]; + docsDir: string; + sidebarsDir: string; +}; + +export interface Env { + versioning: VersioningEnv; + // TODO: translation +} diff --git a/packages/docusaurus-plugin-content-docs/src/version.ts b/packages/docusaurus-plugin-content-docs/src/version.ts new file mode 100644 index 000000000000..00d0566c963a --- /dev/null +++ b/packages/docusaurus-plugin-content-docs/src/version.ts @@ -0,0 +1,134 @@ +/** + * Copyright (c) 2017-present, Facebook, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { + getVersionsJSONFile, + getVersionedDocsDir, + getVersionedSidebarsDir, +} from './env'; +import fs from 'fs-extra'; +import path from 'path'; +import {Sidebar, SidebarItemCategory, PathOptions} from './types'; +import loadSidebars from './sidebars'; + +export function docsVersion( + version: string | null | undefined, + siteDir: string, + options: PathOptions, +) { + if (!version) { + throw new Error( + 'No version tag specified!. Pass the version you wish to create as an argument. Ex: 1.0.0', + ); + } + if (version.includes('/') || version.includes('\\')) { + throw new Error( + `Invalid version tag specified! Do not include slash (/) or (\\). Try something like: 1.0.0`, + ); + } + if (version.length > 32) { + throw new Error( + 'Invalid version tag specified! Length must <= 32 characters. Try something like: 1.0.0', + ); + } + + // Since we are going to create `version-${version}` folder, we need to make sure its a valid path name + if (/[<>:"\/\\|?*\x00-\x1F]/g.test(version)) { + throw new Error( + 'Invalid version tag specified! Please ensure its a valid pathname too. Try something like: 1.0.0', + ); + } + + if (/^\.\.?$/.test(version)) { + throw new Error( + 'Invalid version tag specified! Do not name your version "." or "..". Try something like: 1.0.0', + ); + } + + // Load existing versions + let versions = []; + const versionsJSONFile = getVersionsJSONFile(siteDir); + if (fs.existsSync(versionsJSONFile)) { + versions = JSON.parse(fs.readFileSync(versionsJSONFile, 'utf8')); + } + + // Check if version already exist + if (versions.includes(version)) { + throw new Error( + 'This version already exists!. Use a version tag that does not already exist.', + ); + } + + const {path: docsPath, sidebarPath} = options; + + // Copy docs files + const docsDir = path.join(siteDir, docsPath); + if (fs.existsSync(docsDir) && fs.readdirSync(docsDir).length > 0) { + const versionedDir = getVersionedDocsDir(siteDir); + const newVersionDir = path.join(versionedDir, `version-${version}`); + fs.copySync(docsDir, newVersionDir); + } else { + throw new Error('There is no docs to version !'); + } + + // Load current sidebar and create a new versioned sidebars file + if (fs.existsSync(sidebarPath)) { + const loadedSidebars: Sidebar = loadSidebars([sidebarPath]); + + // Transform id in original sidebar to versioned id + const normalizeCategory = ( + category: SidebarItemCategory, + ): SidebarItemCategory => { + const items = category.items.map(item => { + switch (item.type) { + case 'category': + return normalizeCategory(item); + case 'ref': + case 'doc': + return { + type: item.type, + id: `version-${version}/${item.id}`, + }; + } + return item; + }); + return {...category, items}; + }; + + const versionedSidebar: Sidebar = Object.entries(loadedSidebars).reduce( + (acc: Sidebar, [sidebarId, sidebarItemCategories]) => { + const newVersionedSidebarId = `version-${version}/${sidebarId}`; + acc[ + newVersionedSidebarId + ] = sidebarItemCategories.map(sidebarItemCategory => + normalizeCategory(sidebarItemCategory), + ); + return acc; + }, + {}, + ); + + const versionedSidebarsDir = getVersionedSidebarsDir(siteDir); + const newSidebarFile = path.join( + versionedSidebarsDir, + `version-${version}-sidebars.json`, + ); + fs.ensureDirSync(path.dirname(newSidebarFile)); + fs.writeFileSync( + newSidebarFile, + `${JSON.stringify(versionedSidebar, null, 2)}\n`, + 'utf8', + ); + } + + // update versions.json file + versions.unshift(version); + fs.ensureDirSync(path.dirname(versionsJSONFile)); + fs.writeFileSync(versionsJSONFile, `${JSON.stringify(versions, null, 2)}\n`); + + console.log(`Version ${version} created!`); +} diff --git a/packages/docusaurus-theme-classic/src/theme/DocItem/index.js b/packages/docusaurus-theme-classic/src/theme/DocItem/index.js index b45719bddb2a..74e55e379cdc 100644 --- a/packages/docusaurus-theme-classic/src/theme/DocItem/index.js +++ b/packages/docusaurus-theme-classic/src/theme/DocItem/index.js @@ -62,6 +62,7 @@ function DocItem(props) { lastUpdatedAt, lastUpdatedBy, keywords, + version, } = metadata; const { frontMatter: { @@ -96,6 +97,13 @@ function DocItem(props) {
+ {version && ( + + Version: {version} + + )} {!hideTitle && (

{metadata.title}

diff --git a/packages/docusaurus-theme-classic/src/theme/DocPage/index.js b/packages/docusaurus-theme-classic/src/theme/DocPage/index.js index 05d069aa1138..c2c448847418 100644 --- a/packages/docusaurus-theme-classic/src/theme/DocPage/index.js +++ b/packages/docusaurus-theme-classic/src/theme/DocPage/index.js @@ -24,7 +24,7 @@ function matchingRouteExist(routes, pathname) { function DocPage(props) { const {route, docsMetadata, location} = props; - const {permalinkToSidebar, docsSidebars} = docsMetadata; + const {permalinkToSidebar, docsSidebars, version} = docsMetadata; const sidebar = permalinkToSidebar[location.pathname.replace(/\/$/, '')]; const {siteConfig: {themeConfig = {}} = {}} = useDocusaurusContext(); const {sidebarCollapsible = true} = themeConfig; @@ -34,7 +34,7 @@ function DocPage(props) { } return ( - +
{sidebar && (
diff --git a/packages/docusaurus-theme-classic/src/theme/Layout/index.js b/packages/docusaurus-theme-classic/src/theme/Layout/index.js index a73d95b592fa..526b33cd35e5 100644 --- a/packages/docusaurus-theme-classic/src/theme/Layout/index.js +++ b/packages/docusaurus-theme-classic/src/theme/Layout/index.js @@ -31,6 +31,7 @@ function Layout(props) { image, keywords, permalink, + version, } = props; const metaTitle = title || `${defaultTitle} ยท ${tagline}`; const metaImage = image || defaultImage; @@ -47,6 +48,7 @@ function Layout(props) { {description && ( )} + {version && } {keywords && keywords.length && ( )} diff --git a/packages/docusaurus-types/src/index.d.ts b/packages/docusaurus-types/src/index.d.ts index 3027072f0bc7..715dc6bf5f2b 100644 --- a/packages/docusaurus-types/src/index.d.ts +++ b/packages/docusaurus-types/src/index.d.ts @@ -1,5 +1,5 @@ import {Loader, Configuration} from 'webpack'; -import {CommanderStatic} from 'commander'; +import {Command} from 'commander'; import {ParsedUrlQueryInput} from 'querystring'; export interface DocusaurusConfig { @@ -96,7 +96,7 @@ export interface Plugin { getThemePath?(): string; getPathsToWatch?(): string[]; getClientModules?(): string[]; - extendCli?(cli: CommanderStatic): void; + extendCli?(cli: Command): void; } export type PluginConfig = [string, Object] | [string] | string; @@ -124,6 +124,7 @@ export interface RouteConfig { modules?: RouteModule; routes?: RouteConfig[]; exact?: boolean; + priority?: number; } export interface ThemeAlias { diff --git a/packages/docusaurus/src/server/plugins/index.ts b/packages/docusaurus/src/server/plugins/index.ts index 2ebc1dc34246..6e43091fdc50 100644 --- a/packages/docusaurus/src/server/plugins/index.ts +++ b/packages/docusaurus/src/server/plugins/index.ts @@ -17,6 +17,28 @@ import { } from '@docusaurus/types'; import {initPlugins} from './init'; +export function sortConfig(routeConfigs: RouteConfig[]) { + // Sort the route config. This ensures that route with nested routes is always placed last + routeConfigs.sort((a, b) => { + if (a.routes && !b.routes) { + return 1; + } + if (!a.routes && b.routes) { + return -1; + } + // Higher priority get placed first + if (a.priority || b.priority) { + const priorityA = a.priority || 0; + const priorityB = b.priority || 0; + const score = priorityA > priorityB ? -1 : priorityB > priorityA ? 1 : 0; + if (score !== 0) { + return score; + } + } + return a.path > b.path ? 1 : b.path > a.path ? -1 : 0; + }); +} + export async function loadPlugins({ pluginConfigs, context, @@ -76,15 +98,7 @@ export async function loadPlugins({ ); // Sort the route config. This ensures that route with nested routes is always placed last - pluginsRouteConfigs.sort((a, b) => { - if (a.routes && !b.routes) { - return 1; - } - if (!a.routes && b.routes) { - return -1; - } - return a.path > b.path ? 1 : b.path > a.path ? -1 : 0; - }); + sortConfig(pluginsRouteConfigs); return { plugins, diff --git a/website/docs/advanced-plugins.md b/website/docs/advanced-plugins.md index 885c15f08af6..9af8ffb2f42c 100644 --- a/website/docs/advanced-plugins.md +++ b/website/docs/advanced-plugins.md @@ -133,7 +133,7 @@ module.exports = { /** * URL for editing website repo, example: 'https://github.com/facebook/docusaurus/edit/master/website/' */ - editUrl: 'https://github.com/repo/project/website/', + editUrl: 'https://github.com/facebook/docusaurus/edit/master/website/', /** * URL route for the blog section of your site * do not include trailing slash diff --git a/website/docs/markdown-features.mdx b/website/docs/markdown-features.mdx index 1836099fa9f6..4eda8ebcbe6f 100644 --- a/website/docs/markdown-features.mdx +++ b/website/docs/markdown-features.mdx @@ -67,7 +67,7 @@ The headers are well-spaced so that the hierarchy is clear. This will render in the browser as follows: -import BrowserWindow from '../src/components/BrowserWindow'; +import BrowserWindow from '@site/src/components/BrowserWindow';

Hello from Docusaurus