feat(blog): add LastUpdateAuthor & LastUpdateTime (#9912)

Co-authored-by: OzakIOne <OzakIOne@users.noreply.github.com>
Co-authored-by: sebastien <lorber.sebastien@gmail.com>
This commit is contained in:
ozaki 2024-03-15 12:50:06 +01:00 committed by GitHub
parent 7938803747
commit c745021b01
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 833 additions and 359 deletions

View file

@ -0,0 +1 @@
# Hoo hoo, if this path tricks you...

View file

@ -0,0 +1,7 @@
---
id: hello
title: Hello, World !
slug: /
---
Hello

View file

@ -9,6 +9,7 @@ import fs from 'fs-extra';
import path from 'path';
import {createTempRepo} from '@testing-utils/git';
import {FileNotTrackedError, getFileCommitDate} from '../gitUtils';
import {getFileLastUpdate} from '../lastUpdateUtils';
/* eslint-disable no-restricted-properties */
function initializeTempRepo() {
@ -136,4 +137,22 @@ describe('getFileCommitDate', () => {
/Failed to retrieve git history for ".*nonexistent.txt" because the file does not exist./,
);
});
it('multiple files not tracked by git', async () => {
const consoleMock = jest
.spyOn(console, 'warn')
.mockImplementation(() => {});
const tempFilePath1 = path.join(repoDir, 'file1.md');
const tempFilePath2 = path.join(repoDir, 'file2.md');
await fs.writeFile(tempFilePath1, 'Lorem ipsum :)');
await fs.writeFile(tempFilePath2, 'Lorem ipsum :)');
await expect(getFileLastUpdate(tempFilePath1)).resolves.toBeNull();
await expect(getFileLastUpdate(tempFilePath2)).resolves.toBeNull();
expect(consoleMock).toHaveBeenCalledTimes(1);
expect(consoleMock).toHaveBeenLastCalledWith(
expect.stringMatching(/not tracked by git./),
);
await fs.unlink(tempFilePath1);
await fs.unlink(tempFilePath2);
});
});

View file

@ -0,0 +1,226 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import {jest} from '@jest/globals';
import fs from 'fs-extra';
import path from 'path';
import {createTempRepo} from '@testing-utils/git';
import shell from 'shelljs';
import {
getFileLastUpdate,
GIT_FALLBACK_LAST_UPDATE_AUTHOR,
GIT_FALLBACK_LAST_UPDATE_DATE,
readLastUpdateData,
} from '@docusaurus/utils';
describe('getFileLastUpdate', () => {
const {repoDir} = createTempRepo();
const existingFilePath = path.join(
__dirname,
'__fixtures__/simple-site/hello.md',
);
it('existing test file in repository with Git timestamp', async () => {
const lastUpdateData = await getFileLastUpdate(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');
});
it('existing test file with spaces in path', async () => {
const filePathWithSpace = path.join(
__dirname,
'__fixtures__/simple-site/doc with space.md',
);
const lastUpdateData = await getFileLastUpdate(filePathWithSpace);
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');
});
it('non-existing file', async () => {
const consoleMock = jest
.spyOn(console, 'warn')
.mockImplementation(() => {});
const nonExistingFileName = '.nonExisting';
const nonExistingFilePath = path.join(
__dirname,
'__fixtures__',
nonExistingFileName,
);
await expect(getFileLastUpdate(nonExistingFilePath)).rejects.toThrow(
/An error occurred when trying to get the last update date/,
);
expect(consoleMock).toHaveBeenCalledTimes(0);
consoleMock.mockRestore();
});
it('git does not exist', async () => {
const mock = jest.spyOn(shell, 'which').mockImplementationOnce(() => null);
const consoleMock = jest
.spyOn(console, 'warn')
.mockImplementation(() => {});
const lastUpdateData = await getFileLastUpdate(existingFilePath);
expect(lastUpdateData).toBeNull();
expect(consoleMock).toHaveBeenLastCalledWith(
expect.stringMatching(
/.*\[WARNING\].* Sorry, the last update options require Git\..*/,
),
);
consoleMock.mockRestore();
mock.mockRestore();
});
it('temporary created file that is not tracked by git', async () => {
const consoleMock = jest
.spyOn(console, 'warn')
.mockImplementation(() => {});
const tempFilePath = path.join(repoDir, 'file.md');
await fs.writeFile(tempFilePath, 'Lorem ipsum :)');
await expect(getFileLastUpdate(tempFilePath)).resolves.toBeNull();
expect(consoleMock).toHaveBeenCalledTimes(1);
expect(consoleMock).toHaveBeenLastCalledWith(
expect.stringMatching(/not tracked by git./),
);
await fs.unlink(tempFilePath);
});
});
describe('readLastUpdateData', () => {
const testDate = '2021-01-01';
const testDateTime = new Date(testDate).getTime() / 1000;
const testAuthor = 'ozaki';
it('read last time show author time', async () => {
const {lastUpdatedAt, lastUpdatedBy} = await readLastUpdateData(
'',
{showLastUpdateAuthor: true, showLastUpdateTime: true},
{date: testDate},
);
expect(lastUpdatedAt).toEqual(testDateTime);
expect(lastUpdatedBy).toBe(GIT_FALLBACK_LAST_UPDATE_AUTHOR);
});
it('read last author show author time', async () => {
const {lastUpdatedAt, lastUpdatedBy} = await readLastUpdateData(
'',
{showLastUpdateAuthor: true, showLastUpdateTime: true},
{author: testAuthor},
);
expect(lastUpdatedBy).toEqual(testAuthor);
expect(lastUpdatedAt).toBe(GIT_FALLBACK_LAST_UPDATE_DATE);
});
it('read last all show author time', async () => {
const {lastUpdatedAt, lastUpdatedBy} = await readLastUpdateData(
'',
{showLastUpdateAuthor: true, showLastUpdateTime: true},
{author: testAuthor, date: testDate},
);
expect(lastUpdatedBy).toEqual(testAuthor);
expect(lastUpdatedAt).toEqual(testDateTime);
});
it('read last default show none', async () => {
const lastUpdate = await readLastUpdateData(
'',
{showLastUpdateAuthor: false, showLastUpdateTime: false},
{},
);
expect(lastUpdate).toEqual({});
});
it('read last author show none', async () => {
const lastUpdate = await readLastUpdateData(
'',
{showLastUpdateAuthor: false, showLastUpdateTime: false},
{author: testAuthor},
);
expect(lastUpdate).toEqual({});
});
it('read last time show author', async () => {
const {lastUpdatedAt, lastUpdatedBy} = await readLastUpdateData(
'',
{showLastUpdateAuthor: true, showLastUpdateTime: false},
{date: testDate},
);
expect(lastUpdatedBy).toBe(GIT_FALLBACK_LAST_UPDATE_AUTHOR);
expect(lastUpdatedAt).toBeUndefined();
});
it('read last author show author', async () => {
const {lastUpdatedAt, lastUpdatedBy} = await readLastUpdateData(
'',
{showLastUpdateAuthor: true, showLastUpdateTime: false},
{author: testAuthor},
);
expect(lastUpdatedBy).toBe('ozaki');
expect(lastUpdatedAt).toBeUndefined();
});
it('read last default show author default', async () => {
const {lastUpdatedAt, lastUpdatedBy} = await readLastUpdateData(
'',
{showLastUpdateAuthor: true, showLastUpdateTime: false},
{},
);
expect(lastUpdatedBy).toBe(GIT_FALLBACK_LAST_UPDATE_AUTHOR);
expect(lastUpdatedAt).toBeUndefined();
});
it('read last time show time', async () => {
const {lastUpdatedAt, lastUpdatedBy} = await readLastUpdateData(
'',
{showLastUpdateAuthor: false, showLastUpdateTime: true},
{date: testDate},
);
expect(lastUpdatedBy).toBeUndefined();
expect(lastUpdatedAt).toEqual(testDateTime);
});
it('read last author show time', async () => {
const {lastUpdatedAt, lastUpdatedBy} = await readLastUpdateData(
'',
{showLastUpdateAuthor: false, showLastUpdateTime: true},
{author: testAuthor},
);
expect(lastUpdatedBy).toBeUndefined();
expect(lastUpdatedAt).toEqual(GIT_FALLBACK_LAST_UPDATE_DATE);
});
it('read last author show time only - both front matter', async () => {
const {lastUpdatedAt, lastUpdatedBy} = await readLastUpdateData(
'',
{showLastUpdateAuthor: false, showLastUpdateTime: true},
{author: testAuthor, date: testDate},
);
expect(lastUpdatedBy).toBeUndefined();
expect(lastUpdatedAt).toEqual(testDateTime);
});
it('read last author show author only - both front matter', async () => {
const {lastUpdatedAt, lastUpdatedBy} = await readLastUpdateData(
'',
{showLastUpdateAuthor: true, showLastUpdateTime: false},
{author: testAuthor, date: testDate},
);
expect(lastUpdatedBy).toEqual(testAuthor);
expect(lastUpdatedAt).toBeUndefined();
});
});

View file

@ -118,3 +118,12 @@ export {
export {isDraft, isUnlisted} from './contentVisibilityUtils';
export {escapeRegexp} from './regExpUtils';
export {askPreferredLanguage} from './cliUtils';
export {
getFileLastUpdate,
type LastUpdateData,
type FrontMatterLastUpdate,
readLastUpdateData,
GIT_FALLBACK_LAST_UPDATE_AUTHOR,
GIT_FALLBACK_LAST_UPDATE_DATE,
} from './lastUpdateUtils';

View file

@ -0,0 +1,132 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import _ from 'lodash';
import logger from '@docusaurus/logger';
import {
FileNotTrackedError,
GitNotFoundError,
getFileCommitDate,
} from './gitUtils';
import type {PluginOptions} from '@docusaurus/types';
export const GIT_FALLBACK_LAST_UPDATE_DATE = 1539502055;
export const GIT_FALLBACK_LAST_UPDATE_AUTHOR = 'Author';
async function getGitLastUpdate(filePath: string): Promise<LastUpdateData> {
if (process.env.NODE_ENV !== 'production') {
// Use fake data in dev/test for faster development.
return {
lastUpdatedBy: GIT_FALLBACK_LAST_UPDATE_AUTHOR,
lastUpdatedAt: GIT_FALLBACK_LAST_UPDATE_DATE,
};
}
const {author, timestamp} = (await getFileLastUpdate(filePath)) ?? {};
return {lastUpdatedBy: author, lastUpdatedAt: timestamp};
}
export type LastUpdateData = {
/** A timestamp in **seconds**, directly acquired from `git log`. */
lastUpdatedAt?: number;
/** The author's name directly acquired from `git log`. */
lastUpdatedBy?: string;
};
export type FrontMatterLastUpdate = {
author?: string;
/** Date can be any
* [parsable date string](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/parse).
*/
date?: Date | string;
};
let showedGitRequirementError = false;
let showedFileNotTrackedError = false;
export async function getFileLastUpdate(
filePath: string,
): Promise<{timestamp: number; author: string} | null> {
if (!filePath) {
return null;
}
// Wrap in try/catch in case the shell commands fail
// (e.g. project doesn't use Git, etc).
try {
const result = await getFileCommitDate(filePath, {
age: 'newest',
includeAuthor: true,
});
return {timestamp: result.timestamp, author: result.author};
} catch (err) {
if (err instanceof GitNotFoundError) {
if (!showedGitRequirementError) {
logger.warn('Sorry, the last update options require Git.');
showedGitRequirementError = true;
}
} else if (err instanceof FileNotTrackedError) {
if (!showedFileNotTrackedError) {
logger.warn(
'Cannot infer the update date for some files, as they are not tracked by git.',
);
showedFileNotTrackedError = true;
}
} else {
throw new Error(
`An error occurred when trying to get the last update date`,
{cause: err},
);
}
return null;
}
}
type LastUpdateOptions = Pick<
PluginOptions,
'showLastUpdateAuthor' | 'showLastUpdateTime'
>;
export async function readLastUpdateData(
filePath: string,
options: LastUpdateOptions,
lastUpdateFrontMatter: FrontMatterLastUpdate | undefined,
): Promise<LastUpdateData> {
const {showLastUpdateAuthor, showLastUpdateTime} = options;
if (!showLastUpdateAuthor && !showLastUpdateTime) {
return {};
}
const frontMatterAuthor = lastUpdateFrontMatter?.author;
const frontMatterTimestamp = lastUpdateFrontMatter?.date
? new Date(lastUpdateFrontMatter.date).getTime() / 1000
: undefined;
// We try to minimize git last update calls
// We call it at most once
// If all the data is provided as front matter, we do not call it
const getGitLastUpdateMemoized = _.memoize(() => getGitLastUpdate(filePath));
const getGitLastUpdateBy = () =>
getGitLastUpdateMemoized().then((update) => update.lastUpdatedBy);
const getGitLastUpdateAt = () =>
getGitLastUpdateMemoized().then((update) => update.lastUpdatedAt);
const lastUpdatedBy = showLastUpdateAuthor
? frontMatterAuthor ?? (await getGitLastUpdateBy())
: undefined;
const lastUpdatedAt = showLastUpdateTime
? frontMatterTimestamp ?? (await getGitLastUpdateAt())
: undefined;
return {
lastUpdatedBy,
lastUpdatedAt,
};
}