mirror of
https://github.com/facebook/docusaurus.git
synced 2025-05-14 09:37:37 +02:00
feat(v2): docs last update timestamp and author (#1829)
* feat(v2): docs last update timestamp and author * misc(v2): changelog * misc(v2): better error messages
This commit is contained in:
parent
54e9e025d8
commit
4fe6ae3c24
12 changed files with 305 additions and 36 deletions
|
@ -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
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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',
|
||||
|
|
|
@ -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;
|
||||
}),
|
||||
);
|
||||
|
|
73
packages/docusaurus-plugin-content-docs/src/lastUpdate.ts
Normal file
73
packages/docusaurus-plugin-content-docs/src/lastUpdate.ts
Normal file
|
@ -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;
|
||||
}
|
|
@ -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<DocusaurusConfig>,
|
||||
docsBasePath: string,
|
||||
siteDir: string,
|
||||
editUrl?: string,
|
||||
): Promise<MetadataRaw> {
|
||||
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<DocusaurusConfig>;
|
||||
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<MetadataRaw> {
|
||||
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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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 (
|
||||
<div>
|
||||
|
@ -74,7 +82,7 @@ function DocItem(props) {
|
|||
<DocContent />
|
||||
</div>
|
||||
</article>
|
||||
{editUrl && (
|
||||
{(editUrl || lastUpdatedAt || lastUpdatedBy) && (
|
||||
<div className="margin-vert--xl">
|
||||
<div className="row">
|
||||
<div className="col">
|
||||
|
@ -87,6 +95,29 @@ function DocItem(props) {
|
|||
</a>
|
||||
)}
|
||||
</div>
|
||||
{(lastUpdatedAt || lastUpdatedBy) && (
|
||||
<div className="col text--right">
|
||||
<em>
|
||||
<small>
|
||||
Last updated{' '}
|
||||
{lastUpdatedAt && (
|
||||
<>
|
||||
on{' '}
|
||||
{new Date(
|
||||
lastUpdatedAt * 1000,
|
||||
).toLocaleDateString()}
|
||||
{lastUpdatedBy && ' '}
|
||||
</>
|
||||
)}
|
||||
{lastUpdatedBy && (
|
||||
<>
|
||||
by <strong>{lastUpdatedBy}</strong>
|
||||
</>
|
||||
)}
|
||||
</small>
|
||||
</em>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
],
|
||||
|
|
|
@ -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`
|
||||
|
||||
|
|
|
@ -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',
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue