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:
Yangshun Tay 2019-10-10 21:45:39 -07:00 committed by GitHub
parent 54e9e025d8
commit 4fe6ae3c24
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 305 additions and 36 deletions

View file

@ -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();
});
});

View file

@ -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',

View file

@ -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;
}),
);

View 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;
}

View file

@ -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;
}

View file

@ -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;
}