mirror of
https://github.com/facebook/docusaurus.git
synced 2025-05-12 00:27:21 +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
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue