diff --git a/CHANGELOG-2.x.md b/CHANGELOG-2.x.md index d1ee5baf96..aa2290c371 100644 --- a/CHANGELOG-2.x.md +++ b/CHANGELOG-2.x.md @@ -7,12 +7,13 @@ - Docs sidebar can now be more than one level deep, theoretically up to infinity - Collapsible docs sidebar! - Make doc page title larger - - Add `editUrl` option (URL for editing) to docs plugin. If this field is set, there will be an "Edit this page" link for each doc page. Example: 'https://github.com/facebook/docusaurus/edit/master/docs'. -- More documentation ... + - Add `editUrl` option (URL for editing) to docs plugin. If this field is set, there will be an "Edit this page" link for each doc page. Example: 'https://github.com/facebook/docusaurus/edit/master/docs' + - Add `showLastUpdateTime` and `showLastUpdateAuthor` options to docs plugin to further achieve v1 parity of showing last update data for a particular doc - Slight tweaks to the Blog components - blog title is larger now - Code Blocks - Change default theme from Night Owl to Palenight - Slight tweaks to playground/preview components +- More documentation... ## 2.0.0-alpha.25 diff --git a/packages/docusaurus-plugin-content-docs/package.json b/packages/docusaurus-plugin-content-docs/package.json index cb5b3d44b3..d512f49b01 100644 --- a/packages/docusaurus-plugin-content-docs/package.json +++ b/packages/docusaurus-plugin-content-docs/package.json @@ -19,7 +19,8 @@ "fs-extra": "^8.1.0", "globby": "^10.0.1", "import-fresh": "^3.1.0", - "loader-utils": "^1.2.3" + "loader-utils": "^1.2.3", + "shelljs": "^0.8.3" }, "peerDependencies": { "@docusaurus/core": "^2.0.0", diff --git a/packages/docusaurus-plugin-content-docs/src/__tests__/lastUpdate.test.ts b/packages/docusaurus-plugin-content-docs/src/__tests__/lastUpdate.test.ts new file mode 100644 index 0000000000..5ca5e87328 --- /dev/null +++ b/packages/docusaurus-plugin-content-docs/src/__tests__/lastUpdate.test.ts @@ -0,0 +1,87 @@ +/** + * 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 fs from 'fs'; +import path from 'path'; +import shell from 'shelljs'; + +import lastUpdate from '../lastUpdate'; + +describe('lastUpdate', () => { + test('existing test file in repository with Git timestamp', () => { + const existingFilePath = path.join( + __dirname, + '__fixtures__/website/docs/hello.md', + ); + const lastUpdateData = lastUpdate(existingFilePath); + expect(lastUpdateData).not.toBeNull(); + + const {author, timestamp} = lastUpdateData; + expect(author).not.toBeNull(); + expect(typeof author).toBe('string'); + + expect(timestamp).not.toBeNull(); + expect(typeof timestamp).toBe('number'); + }); + + test('non-existing file', () => { + const nonExistingFilePath = path.join( + __dirname, + '__fixtures__', + '.nonExisting', + ); + expect(lastUpdate(null)).toBeNull(); + expect(lastUpdate(undefined)).toBeNull(); + expect(lastUpdate(nonExistingFilePath)).toBeNull(); + }); + + test('temporary created file that has no git timestamp', () => { + const tempFilePath = path.join(__dirname, '__fixtures__', '.temp'); + fs.writeFileSync(tempFilePath, 'Lorem ipsum :)'); + expect(lastUpdate(tempFilePath)).toBeNull(); + fs.unlinkSync(tempFilePath); + }); + + test('test renaming and moving file', () => { + const mock = jest.spyOn(shell, 'exec'); + mock + .mockImplementationOnce(() => ({ + stdout: + '1539502055, Yangshun Tay\n' + + '\n' + + ' create mode 100644 v1/lib/core/__tests__/__fixtures__/.temp2\n', + })) + .mockImplementationOnce(() => ({ + stdout: + '1539502056, Joel Marcey\n' + + '\n' + + ' rename v1/lib/core/__tests__/__fixtures__/{.temp2 => test/.temp3} (100%)\n' + + '1539502055, Yangshun Tay\n' + + '\n' + + ' create mode 100644 v1/lib/core/__tests__/__fixtures__/.temp2\n', + })); + const tempFilePath2 = path.join(__dirname, '__fixtures__', '.temp2'); + const tempFilePath3 = path.join( + __dirname, + '__fixtures__', + 'test', + '.temp3', + ); + + // Create new file. + const createData = lastUpdate(tempFilePath2); + expect(createData.timestamp).not.toBeNull(); + + // Rename/move the file. + const updateData = lastUpdate(tempFilePath3); + expect(updateData.timestamp).not.toBeNull(); + // Should only consider file content change. + expect(updateData.timestamp).toEqual(createData.timestamp); + + mock.mockRestore(); + }); +}); 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 ff3ec530d3..c2d9c0818d 100644 --- a/packages/docusaurus-plugin-content-docs/src/__tests__/metadata.test.ts +++ b/packages/docusaurus-plugin-content-docs/src/__tests__/metadata.test.ts @@ -23,9 +23,24 @@ describe('processMetadata', () => { const sourceB = path.join('hello.md'); const [dataA, dataB] = await Promise.all([ - processMetadata(sourceA, docsDir, {}, siteConfig, pluginPath, siteDir), - processMetadata(sourceB, docsDir, {}, siteConfig, pluginPath, siteDir), + processMetadata({ + source: sourceA, + docsDir, + order: {}, + siteConfig, + docsBasePath: pluginPath, + siteDir, + }), + processMetadata({ + source: sourceB, + docsDir, + order: {}, + siteConfig, + docsBasePath: pluginPath, + siteDir, + }), ]); + expect(dataA).toEqual({ id: 'foo/bar', permalink: '/docs/foo/bar', @@ -44,14 +59,15 @@ describe('processMetadata', () => { test('docs with custom permalink', async () => { const source = path.join('permalink.md'); - const data = await processMetadata( + const data = await processMetadata({ source, docsDir, - {}, + order: {}, siteConfig, - pluginPath, + docsBasePath: pluginPath, siteDir, - ); + }); + expect(data).toEqual({ id: 'permalink', permalink: '/docs/endiliey/permalink', @@ -65,15 +81,16 @@ describe('processMetadata', () => { const editUrl = 'https://github.com/facebook/docusaurus/edit/master/website/docs/'; const source = path.join('foo', 'baz.md'); - const data = await processMetadata( + const data = await processMetadata({ source, docsDir, - {}, + order: {}, siteConfig, - pluginPath, + docsBasePath: pluginPath, siteDir, editUrl, - ); + }); + expect(data).toEqual({ id: 'foo/baz', permalink: '/docs/foo/baz', diff --git a/packages/docusaurus-plugin-content-docs/src/index.ts b/packages/docusaurus-plugin-content-docs/src/index.ts index cd266b44f5..df3d710fa4 100644 --- a/packages/docusaurus-plugin-content-docs/src/index.ts +++ b/packages/docusaurus-plugin-content-docs/src/index.ts @@ -14,6 +14,7 @@ import {LoadContext, Plugin, DocusaurusConfig} from '@docusaurus/types'; import createOrder from './order'; import loadSidebars from './sidebars'; import processMetadata from './metadata'; + import { PluginOptions, Sidebar, @@ -41,6 +42,8 @@ const DEFAULT_OPTIONS: PluginOptions = { docItemComponent: '@theme/DocItem', remarkPlugins: [], rehypePlugins: [], + showLastUpdateTime: false, + showLastUpdateAuthor: false, }; export default function pluginContentDocs( @@ -62,7 +65,14 @@ export default function pluginContentDocs( // Fetches blog contents and returns metadata for the contents. async loadContent() { - const {include, routeBasePath, sidebarPath, editUrl} = options; + const { + include, + routeBasePath, + sidebarPath, + editUrl, + showLastUpdateAuthor, + showLastUpdateTime, + } = options; const {siteConfig, siteDir} = context; const docsDir = contentPath; @@ -86,15 +96,17 @@ export default function pluginContentDocs( }); await Promise.all( docsFiles.map(async source => { - const metadata: MetadataRaw = await processMetadata( + const metadata: MetadataRaw = await processMetadata({ source, docsDir, order, siteConfig, - routeBasePath, + docsBasePath: routeBasePath, siteDir, editUrl, - ); + showLastUpdateAuthor, + showLastUpdateTime, + }); docsMetadataRaw[metadata.id] = metadata; }), ); diff --git a/packages/docusaurus-plugin-content-docs/src/lastUpdate.ts b/packages/docusaurus-plugin-content-docs/src/lastUpdate.ts new file mode 100644 index 0000000000..b98c80a5ce --- /dev/null +++ b/packages/docusaurus-plugin-content-docs/src/lastUpdate.ts @@ -0,0 +1,73 @@ +/** + * 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 shell from 'shelljs'; + +type FileLastUpdateData = {timestamp?: number; author?: string}; + +const GIT_COMMIT_TIMESTAMP_AUTHOR_REGEX = /^(\d+), (.+)$/; + +export default function getFileLastUpdate( + filePath: string, +): FileLastUpdateData | null { + function isTimestampAndAuthor(str: string): boolean { + return GIT_COMMIT_TIMESTAMP_AUTHOR_REGEX.test(str); + } + + function getTimestampAndAuthor(str: string): FileLastUpdateData | null { + if (!str) { + return null; + } + + const temp = str.match(GIT_COMMIT_TIMESTAMP_AUTHOR_REGEX); + return !temp || temp.length < 3 + ? null + : {timestamp: +temp[1], author: temp[2]}; + } + + // Wrap in try/catch in case the shell commands fail (e.g. project doesn't use Git, etc). + try { + if (!shell.which('git')) { + console.log('Sorry, the docs plugin last update options require Git.'); + return null; + } + // To differentiate between content change and file renaming/moving, use --summary + // To follow the file history until before it is moved (when we create new version), use + // --follow. + const silentState = shell.config.silent; // Save old silent state. + shell.config.silent = true; + const result = shell + .exec(`git log --follow --summary --format="%ct, %an" ${filePath}`) + .stdout.trim(); + shell.config.silent = silentState; + + // Format the log results to be + // ['1234567890, Yangshun Tay', 'rename ...', '1234567880, + // 'Joel Marcey', 'move ...', '1234567870', '1234567860'] + const records = result + .replace(/\n\s*\n/g, '\n') + .split('\n') + .filter(String); + const lastContentModifierCommit = records.find((item, index, arr) => { + const currentItemIsTimestampAndAuthor = isTimestampAndAuthor(item); + const isLastTwoItem = index + 2 >= arr.length; + const nextItemIsTimestampAndAuthor = isTimestampAndAuthor(arr[index + 1]); + return ( + currentItemIsTimestampAndAuthor && + (isLastTwoItem || nextItemIsTimestampAndAuthor) + ); + }); + + return lastContentModifierCommit + ? getTimestampAndAuthor(lastContentModifierCommit) + : null; + } catch (error) { + console.error(error); + } + + return null; +} diff --git a/packages/docusaurus-plugin-content-docs/src/metadata.ts b/packages/docusaurus-plugin-content-docs/src/metadata.ts index dddc92c916..bd40344d13 100644 --- a/packages/docusaurus-plugin-content-docs/src/metadata.ts +++ b/packages/docusaurus-plugin-content-docs/src/metadata.ts @@ -8,21 +8,37 @@ import fs from 'fs-extra'; import path from 'path'; import {parse, normalizeUrl} from '@docusaurus/utils'; -import {Order, MetadataRaw} from './types'; import {DocusaurusConfig} from '@docusaurus/types'; -export default async function processMetadata( - source: string, - docsDir: string, - order: Order, - siteConfig: Partial, - docsBasePath: string, - siteDir: string, - editUrl?: string, -): Promise { - const filepath = path.join(docsDir, source); +import lastUpdate from './lastUpdate'; +import {Order, MetadataRaw} from './types'; - const fileString = await fs.readFile(filepath, 'utf-8'); +type Args = { + source: string; + docsDir: string; + order: Order; + siteConfig: Partial; + docsBasePath: string; + siteDir: string; + editUrl?: string; + showLastUpdateAuthor?: boolean; + showLastUpdateTime?: boolean; +}; + +export default async function processMetadata({ + source, + docsDir, + order, + siteConfig, + docsBasePath, + siteDir, + editUrl, + showLastUpdateAuthor, + showLastUpdateTime, +}: Args): Promise { + const filePath = path.join(docsDir, source); + + const fileString = await fs.readFile(filePath, 'utf-8'); const {frontMatter: metadata = {}, excerpt} = parse(fileString); // Default id is the file name. @@ -52,7 +68,7 @@ export default async function processMetadata( } // Cannot use path.join() as it resolves '../' and removes the '@site'. Let webpack loader resolve it. - const aliasedPath = `@site/${path.relative(siteDir, filepath)}`; + const aliasedPath = `@site/${path.relative(siteDir, filePath)}`; metadata.source = aliasedPath; // Build the permalink. @@ -87,5 +103,20 @@ export default async function processMetadata( metadata.editUrl = normalizeUrl([editUrl, source]); } + if (showLastUpdateAuthor || showLastUpdateTime) { + const fileLastUpdateData = lastUpdate(filePath); + + if (fileLastUpdateData) { + const {author, timestamp} = fileLastUpdateData; + if (showLastUpdateAuthor && author) { + metadata.lastUpdatedBy = author; + } + + if (showLastUpdateTime && timestamp) { + metadata.lastUpdatedAt = timestamp; + } + } + } + return metadata as MetadataRaw; } diff --git a/packages/docusaurus-plugin-content-docs/src/types.ts b/packages/docusaurus-plugin-content-docs/src/types.ts index bf29eb8553..23ab9b380d 100644 --- a/packages/docusaurus-plugin-content-docs/src/types.ts +++ b/packages/docusaurus-plugin-content-docs/src/types.ts @@ -15,6 +15,8 @@ export interface PluginOptions { remarkPlugins: string[]; rehypePlugins: string[]; editUrl?: string; + showLastUpdateTime?: boolean; + showLastUpdateAuthor?: boolean; } export type SidebarItemDoc = { @@ -90,6 +92,8 @@ export interface MetadataRaw extends OrderMetadata { permalink: string; sidebar_label?: string; editUrl?: string; + lastUpdatedAt?: number; + lastUpdatedBy?: string; [key: string]: any; } diff --git a/packages/docusaurus-theme-classic/src/theme/DocItem/index.js b/packages/docusaurus-theme-classic/src/theme/DocItem/index.js index bd6c783722..7511988e0b 100644 --- a/packages/docusaurus-theme-classic/src/theme/DocItem/index.js +++ b/packages/docusaurus-theme-classic/src/theme/DocItem/index.js @@ -34,7 +34,15 @@ function DocItem(props) { const {siteConfig = {}} = useDocusaurusContext(); const {url: siteUrl} = siteConfig; const {metadata, content: DocContent} = props; - const {description, title, permalink, image: metaImage, editUrl} = metadata; + const { + description, + title, + permalink, + image: metaImage, + editUrl, + lastUpdatedAt, + lastUpdatedBy, + } = metadata; return (
@@ -74,7 +82,7 @@ function DocItem(props) {
- {editUrl && ( + {(editUrl || lastUpdatedAt || lastUpdatedBy) && (
@@ -87,6 +95,29 @@ function DocItem(props) { )}
+ {(lastUpdatedAt || lastUpdatedBy) && ( +
+ + + Last updated{' '} + {lastUpdatedAt && ( + <> + on{' '} + {new Date( + lastUpdatedAt * 1000, + ).toLocaleDateString()} + {lastUpdatedBy && ' '} + + )} + {lastUpdatedBy && ( + <> + by {lastUpdatedBy} + + )} + + +
+ )}
)} diff --git a/website/docs/advanced-plugins.md b/website/docs/advanced-plugins.md index 4ba547d837..f14add4482 100644 --- a/website/docs/advanced-plugins.md +++ b/website/docs/advanced-plugins.md @@ -169,6 +169,14 @@ module.exports = { */ remarkPlugins: [], rehypePlugins: [], + /** + * Whether to display the author who last updated the doc. + * / + showLastUpdateAuthor: false, + /** + * Whether to display the last date the doc was updated. + * / + showLastUpdateTime: false, }, ], ], diff --git a/website/docs/migration-from-v1-to-v2.md b/website/docs/migration-from-v1-to-v2.md index 707c59ac2e..f98011cc8f 100644 --- a/website/docs/migration-from-v1-to-v2.md +++ b/website/docs/migration-from-v1-to-v2.md @@ -265,7 +265,7 @@ module.exports = { Deprecated. Create a `CNAME` file in your `static` folder instead. Files in the `static` folder will be copied into the root of the `build` folder during execution of the build command. -#### `customDocsPath`, `docsUrl`, `editUrl` +#### `customDocsPath`, `docsUrl`, `editUrl`, `enableUpdateBy`, `enableUpdateTime` Deprecated. Pass it as an option to `@docusaurus/preset-classic` docs instead: @@ -286,6 +286,10 @@ module.exports = { // Remark and Rehype plugins passed to MDX. Replaces `markdownOptions` and `markdownPlugins`. remarkPlugins: [], rehypePlugins: [], + // Equivalent to `enableUpdateBy`. + showLastUpdateAuthor: true, + // Equivalent to `enableUpdateTime`. + showLastUpdateTime: true, }, ... }, @@ -322,8 +326,6 @@ module.exports = { ### Deprecated fields that may be implemented using a plugin -- `enableUpdateBy` -- `enableUpdateTime` - `scripts` - `stylesheets` @@ -433,4 +435,4 @@ yarn start ## Step 7 - Configure your build directory -In Docusaurus 1, all the build artifacts are located within `website/build/`. However, in Docusaurus 2, it is now moved to just `website/build`. Make sure that you update your deployment configuration to read the generated files from the correct `build` directory. \ No newline at end of file +In Docusaurus 1, all the build artifacts are located within `website/build/`. However, in Docusaurus 2, it is now moved to just `website/build`. Make sure that you update your deployment configuration to read the generated files from the correct `build` directory. diff --git a/website/docusaurus.config.js b/website/docusaurus.config.js index 2d6648c7a8..ee1965a80b 100644 --- a/website/docusaurus.config.js +++ b/website/docusaurus.config.js @@ -34,6 +34,8 @@ module.exports = { sidebarPath: require.resolve('./sidebars.js'), editUrl: 'https://github.com/facebook/docusaurus/edit/master/website/docs/', + showLastUpdateAuthor: true, + showLastUpdateTime: true, }, blog: { path: '../website-1.x/blog',