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 ozakione
parent 5453bc517d
commit 32fa7e0a8d
40 changed files with 833 additions and 359 deletions

2
.eslintrc.js vendored
View file

@ -91,7 +91,7 @@ module.exports = {
'no-constant-binary-expression': ERROR, 'no-constant-binary-expression': ERROR,
'no-continue': OFF, 'no-continue': OFF,
'no-control-regex': WARNING, 'no-control-regex': WARNING,
'no-else-return': [WARNING, {allowElseIf: true}], 'no-else-return': OFF,
'no-empty': [WARNING, {allowEmptyCatch: true}], 'no-empty': [WARNING, {allowEmptyCatch: true}],
'no-lonely-if': WARNING, 'no-lonely-if': WARNING,
'no-nested-ternary': WARNING, 'no-nested-ternary': WARNING,

View file

@ -0,0 +1,9 @@
---
title: Author
slug: author
author: ozaki
last_update:
author: seb
---
author

View file

@ -0,0 +1,11 @@
---
title: Both
slug: both
date: 2020-01-01
last_update:
date: 2021-01-01
author: seb
author: ozaki
---
last update date

View file

@ -0,0 +1,9 @@
---
title: Last update date
slug: lastUpdateDate
date: 2020-01-01
last_update:
date: 2021-01-01
---
last update date

View file

@ -0,0 +1,6 @@
---
title: Nothing
slug: nothing
---
nothing

View file

@ -137,6 +137,8 @@ exports[`blog plugin process blog posts load content 2`] = `
"title": "Another Simple Slug", "title": "Another Simple Slug",
}, },
"hasTruncateMarker": false, "hasTruncateMarker": false,
"lastUpdatedAt": undefined,
"lastUpdatedBy": undefined,
"nextItem": { "nextItem": {
"permalink": "/blog/another/tags", "permalink": "/blog/another/tags",
"title": "Another With Tag", "title": "Another With Tag",
@ -172,6 +174,8 @@ exports[`blog plugin process blog posts load content 2`] = `
"title": "Another With Tag", "title": "Another With Tag",
}, },
"hasTruncateMarker": false, "hasTruncateMarker": false,
"lastUpdatedAt": undefined,
"lastUpdatedBy": undefined,
"nextItem": { "nextItem": {
"permalink": "/blog/another/tags2", "permalink": "/blog/another/tags2",
"title": "Another With Tag", "title": "Another With Tag",
@ -215,6 +219,8 @@ exports[`blog plugin process blog posts load content 2`] = `
"title": "Another With Tag", "title": "Another With Tag",
}, },
"hasTruncateMarker": false, "hasTruncateMarker": false,
"lastUpdatedAt": undefined,
"lastUpdatedBy": undefined,
"permalink": "/blog/another/tags2", "permalink": "/blog/another/tags2",
"prevItem": { "prevItem": {
"permalink": "/blog/another/tags", "permalink": "/blog/another/tags",

View file

@ -8,7 +8,12 @@
import {jest} from '@jest/globals'; import {jest} from '@jest/globals';
import path from 'path'; import path from 'path';
import {normalizePluginOptions} from '@docusaurus/utils-validation'; import {normalizePluginOptions} from '@docusaurus/utils-validation';
import {posixPath, getFileCommitDate} from '@docusaurus/utils'; import {
posixPath,
getFileCommitDate,
GIT_FALLBACK_LAST_UPDATE_DATE,
GIT_FALLBACK_LAST_UPDATE_AUTHOR,
} from '@docusaurus/utils';
import pluginContentBlog from '../index'; import pluginContentBlog from '../index';
import {validateOptions} from '../options'; import {validateOptions} from '../options';
import type { import type {
@ -510,7 +515,7 @@ describe('blog plugin', () => {
{ {
postsPerPage: 1, postsPerPage: 1,
processBlogPosts: async ({blogPosts}) => processBlogPosts: async ({blogPosts}) =>
blogPosts.filter((blog) => blog.metadata.tags[0].label === 'tag1'), blogPosts.filter((blog) => blog.metadata.tags[0]?.label === 'tag1'),
}, },
DefaultI18N, DefaultI18N,
); );
@ -526,3 +531,137 @@ describe('blog plugin', () => {
expect(blogPosts).toMatchSnapshot(); expect(blogPosts).toMatchSnapshot();
}); });
}); });
describe('last update', () => {
const siteDir = path.join(
__dirname,
'__fixtures__',
'website-blog-with-last-update',
);
const lastUpdateFor = (date: string) => new Date(date).getTime() / 1000;
it('author and time', async () => {
const plugin = await getPlugin(
siteDir,
{
showLastUpdateAuthor: true,
showLastUpdateTime: true,
},
DefaultI18N,
);
const {blogPosts} = (await plugin.loadContent!())!;
expect(blogPosts[0]?.metadata.lastUpdatedBy).toBe('seb');
expect(blogPosts[0]?.metadata.lastUpdatedAt).toBe(
GIT_FALLBACK_LAST_UPDATE_DATE,
);
expect(blogPosts[1]?.metadata.lastUpdatedBy).toBe(
GIT_FALLBACK_LAST_UPDATE_AUTHOR,
);
expect(blogPosts[1]?.metadata.lastUpdatedAt).toBe(
GIT_FALLBACK_LAST_UPDATE_DATE,
);
expect(blogPosts[2]?.metadata.lastUpdatedBy).toBe('seb');
expect(blogPosts[2]?.metadata.lastUpdatedAt).toBe(
lastUpdateFor('2021-01-01'),
);
expect(blogPosts[3]?.metadata.lastUpdatedBy).toBe(
GIT_FALLBACK_LAST_UPDATE_AUTHOR,
);
expect(blogPosts[3]?.metadata.lastUpdatedAt).toBe(
lastUpdateFor('2021-01-01'),
);
});
it('time only', async () => {
const plugin = await getPlugin(
siteDir,
{
showLastUpdateAuthor: false,
showLastUpdateTime: true,
},
DefaultI18N,
);
const {blogPosts} = (await plugin.loadContent!())!;
expect(blogPosts[0]?.metadata.title).toBe('Author');
expect(blogPosts[0]?.metadata.lastUpdatedBy).toBeUndefined();
expect(blogPosts[0]?.metadata.lastUpdatedAt).toBe(
GIT_FALLBACK_LAST_UPDATE_DATE,
);
expect(blogPosts[1]?.metadata.title).toBe('Nothing');
expect(blogPosts[1]?.metadata.lastUpdatedBy).toBeUndefined();
expect(blogPosts[1]?.metadata.lastUpdatedAt).toBe(
GIT_FALLBACK_LAST_UPDATE_DATE,
);
expect(blogPosts[2]?.metadata.title).toBe('Both');
expect(blogPosts[2]?.metadata.lastUpdatedBy).toBeUndefined();
expect(blogPosts[2]?.metadata.lastUpdatedAt).toBe(
lastUpdateFor('2021-01-01'),
);
expect(blogPosts[3]?.metadata.title).toBe('Last update date');
expect(blogPosts[3]?.metadata.lastUpdatedBy).toBeUndefined();
expect(blogPosts[3]?.metadata.lastUpdatedAt).toBe(
lastUpdateFor('2021-01-01'),
);
});
it('author only', async () => {
const plugin = await getPlugin(
siteDir,
{
showLastUpdateAuthor: true,
showLastUpdateTime: false,
},
DefaultI18N,
);
const {blogPosts} = (await plugin.loadContent!())!;
expect(blogPosts[0]?.metadata.lastUpdatedBy).toBe('seb');
expect(blogPosts[0]?.metadata.lastUpdatedAt).toBeUndefined();
expect(blogPosts[1]?.metadata.lastUpdatedBy).toBe(
GIT_FALLBACK_LAST_UPDATE_AUTHOR,
);
expect(blogPosts[1]?.metadata.lastUpdatedAt).toBeUndefined();
expect(blogPosts[2]?.metadata.lastUpdatedBy).toBe('seb');
expect(blogPosts[2]?.metadata.lastUpdatedAt).toBeUndefined();
expect(blogPosts[3]?.metadata.lastUpdatedBy).toBe(
GIT_FALLBACK_LAST_UPDATE_AUTHOR,
);
expect(blogPosts[3]?.metadata.lastUpdatedAt).toBeUndefined();
});
it('none', async () => {
const plugin = await getPlugin(
siteDir,
{
showLastUpdateAuthor: false,
showLastUpdateTime: false,
},
DefaultI18N,
);
const {blogPosts} = (await plugin.loadContent!())!;
expect(blogPosts[0]?.metadata.lastUpdatedBy).toBeUndefined();
expect(blogPosts[0]?.metadata.lastUpdatedAt).toBeUndefined();
expect(blogPosts[1]?.metadata.lastUpdatedBy).toBeUndefined();
expect(blogPosts[1]?.metadata.lastUpdatedAt).toBeUndefined();
expect(blogPosts[2]?.metadata.lastUpdatedBy).toBeUndefined();
expect(blogPosts[2]?.metadata.lastUpdatedAt).toBeUndefined();
expect(blogPosts[3]?.metadata.lastUpdatedBy).toBeUndefined();
expect(blogPosts[3]?.metadata.lastUpdatedAt).toBeUndefined();
});
});

View file

@ -26,6 +26,7 @@ import {
getContentPathList, getContentPathList,
isUnlisted, isUnlisted,
isDraft, isDraft,
readLastUpdateData,
} from '@docusaurus/utils'; } from '@docusaurus/utils';
import {validateBlogPostFrontMatter} from './frontMatter'; import {validateBlogPostFrontMatter} from './frontMatter';
import {type AuthorsMap, getAuthorsMap, getBlogPostAuthors} from './authors'; import {type AuthorsMap, getAuthorsMap, getBlogPostAuthors} from './authors';
@ -231,6 +232,12 @@ async function processBlogSourceFile(
const aliasedSource = aliasedSitePath(blogSourceAbsolute, siteDir); const aliasedSource = aliasedSitePath(blogSourceAbsolute, siteDir);
const lastUpdate = await readLastUpdateData(
blogSourceAbsolute,
options,
frontMatter.last_update,
);
const draft = isDraft({frontMatter}); const draft = isDraft({frontMatter});
const unlisted = isUnlisted({frontMatter}); const unlisted = isUnlisted({frontMatter});
@ -337,6 +344,8 @@ async function processBlogSourceFile(
authors, authors,
frontMatter, frontMatter,
unlisted, unlisted,
lastUpdatedAt: lastUpdate.lastUpdatedAt,
lastUpdatedBy: lastUpdate.lastUpdatedBy,
}, },
content, content,
}; };

View file

@ -4,14 +4,14 @@
* This source code is licensed under the MIT license found in the * This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree. * LICENSE file in the root directory of this source tree.
*/ */
import { import {
ContentVisibilitySchema,
FrontMatterLastUpdateSchema,
FrontMatterTOCHeadingLevels,
FrontMatterTagsSchema,
JoiFrontMatter as Joi, // Custom instance for front matter JoiFrontMatter as Joi, // Custom instance for front matter
URISchema, URISchema,
validateFrontMatter, validateFrontMatter,
FrontMatterTagsSchema,
FrontMatterTOCHeadingLevels,
ContentVisibilitySchema,
} from '@docusaurus/utils-validation'; } from '@docusaurus/utils-validation';
import type {BlogPostFrontMatter} from '@docusaurus/plugin-content-blog'; import type {BlogPostFrontMatter} from '@docusaurus/plugin-content-blog';
@ -69,6 +69,7 @@ const BlogFrontMatterSchema = Joi.object<BlogPostFrontMatter>({
hide_table_of_contents: Joi.boolean(), hide_table_of_contents: Joi.boolean(),
...FrontMatterTOCHeadingLevels, ...FrontMatterTOCHeadingLevels,
last_update: FrontMatterLastUpdateSchema,
}) })
.messages({ .messages({
'deprecate.error': 'deprecate.error':

View file

@ -51,6 +51,8 @@ export const DEFAULT_OPTIONS: PluginOptions = {
authorsMapPath: 'authors.yml', authorsMapPath: 'authors.yml',
readingTime: ({content, defaultReadingTime}) => defaultReadingTime({content}), readingTime: ({content, defaultReadingTime}) => defaultReadingTime({content}),
sortPosts: 'descending', sortPosts: 'descending',
showLastUpdateTime: false,
showLastUpdateAuthor: false,
processBlogPosts: async () => undefined, processBlogPosts: async () => undefined,
}; };
@ -135,6 +137,10 @@ const PluginOptionSchema = Joi.object<PluginOptions>({
sortPosts: Joi.string() sortPosts: Joi.string()
.valid('descending', 'ascending') .valid('descending', 'ascending')
.default(DEFAULT_OPTIONS.sortPosts), .default(DEFAULT_OPTIONS.sortPosts),
showLastUpdateTime: Joi.bool().default(DEFAULT_OPTIONS.showLastUpdateTime),
showLastUpdateAuthor: Joi.bool().default(
DEFAULT_OPTIONS.showLastUpdateAuthor,
),
processBlogPosts: Joi.function() processBlogPosts: Joi.function()
.optional() .optional()
.default(() => DEFAULT_OPTIONS.processBlogPosts), .default(() => DEFAULT_OPTIONS.processBlogPosts),

View file

@ -10,7 +10,12 @@
declare module '@docusaurus/plugin-content-blog' { declare module '@docusaurus/plugin-content-blog' {
import type {LoadedMDXContent} from '@docusaurus/mdx-loader'; import type {LoadedMDXContent} from '@docusaurus/mdx-loader';
import type {MDXOptions} from '@docusaurus/mdx-loader'; import type {MDXOptions} from '@docusaurus/mdx-loader';
import type {FrontMatterTag, Tag} from '@docusaurus/utils'; import type {
FrontMatterTag,
Tag,
LastUpdateData,
FrontMatterLastUpdate,
} from '@docusaurus/utils';
import type {DocusaurusConfig, Plugin, LoadContext} from '@docusaurus/types'; import type {DocusaurusConfig, Plugin, LoadContext} from '@docusaurus/types';
import type {Item as FeedItem} from 'feed'; import type {Item as FeedItem} from 'feed';
import type {Overwrite} from 'utility-types'; import type {Overwrite} from 'utility-types';
@ -156,6 +161,8 @@ yarn workspace v1.22.19image` is a collocated image path, this entry will be the
toc_min_heading_level?: number; toc_min_heading_level?: number;
/** Maximum TOC heading level. Must be between 2 and 6. */ /** Maximum TOC heading level. Must be between 2 and 6. */
toc_max_heading_level?: number; toc_max_heading_level?: number;
/** Allows overriding the last updated author and/or date. */
last_update?: FrontMatterLastUpdate;
}; };
export type BlogPostFrontMatterAuthor = Author & { export type BlogPostFrontMatterAuthor = Author & {
@ -180,7 +187,7 @@ yarn workspace v1.22.19image` is a collocated image path, this entry will be the
| BlogPostFrontMatterAuthor | BlogPostFrontMatterAuthor
| (string | BlogPostFrontMatterAuthor)[]; | (string | BlogPostFrontMatterAuthor)[];
export type BlogPostMetadata = { export type BlogPostMetadata = LastUpdateData & {
/** Path to the Markdown source, with `@site` alias. */ /** Path to the Markdown source, with `@site` alias. */
readonly source: string; readonly source: string;
/** /**
@ -426,6 +433,10 @@ yarn workspace v1.22.19image` is a collocated image path, this entry will be the
readingTime: ReadingTimeFunctionOption; readingTime: ReadingTimeFunctionOption;
/** Governs the direction of blog post sorting. */ /** Governs the direction of blog post sorting. */
sortPosts: 'ascending' | 'descending'; sortPosts: 'ascending' | 'descending';
/** Whether to display the last date the doc was updated. */
showLastUpdateTime: boolean;
/** Whether to display the author who last updated the doc. */
showLastUpdateAuthor: boolean;
/** An optional function which can be used to transform blog posts /** An optional function which can be used to transform blog posts
* (filter, modify, delete, etc...). * (filter, modify, delete, etc...).
*/ */

View file

@ -444,19 +444,19 @@ describe('validateDocFrontMatter last_update', () => {
invalidFrontMatters: [ invalidFrontMatters: [
[ [
{last_update: null}, {last_update: null},
'does not look like a valid front matter FileChange object. Please use a FileChange object (with an author and/or date).', '"last_update" does not look like a valid last update object. Please use an author key with a string or a date with a string or Date',
], ],
[ [
{last_update: {}}, {last_update: {}},
'does not look like a valid front matter FileChange object. Please use a FileChange object (with an author and/or date).', '"last_update" does not look like a valid last update object. Please use an author key with a string or a date with a string or Date',
], ],
[ [
{last_update: ''}, {last_update: ''},
'does not look like a valid front matter FileChange object. Please use a FileChange object (with an author and/or date).', '"last_update" does not look like a valid last update object. Please use an author key with a string or a date with a string or Date',
], ],
[ [
{last_update: {invalid: 'key'}}, {last_update: {invalid: 'key'}},
'does not look like a valid front matter FileChange object. Please use a FileChange object (with an author and/or date).', '"last_update" does not look like a valid last update object. Please use an author key with a string or a date with a string or Date',
], ],
[ [
{last_update: {author: 'test author', date: 'I am not a date :('}}, {last_update: {author: 'test author', date: 'I am not a date :('}},

View file

@ -1,117 +0,0 @@
/**
* 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 shell from 'shelljs';
import {createTempRepo} from '@testing-utils/git';
import {getFileLastUpdate} from '../lastUpdate';
describe('getFileLastUpdate', () => {
const existingFilePath = path.join(
__dirname,
'__fixtures__/simple-site/docs/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/docs/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)).resolves.toBeNull();
expect(consoleMock).toHaveBeenCalledTimes(1);
expect(consoleMock).toHaveBeenLastCalledWith(
expect.stringMatching(/because the file does not exist./),
);
consoleMock.mockRestore();
});
it('temporary created file that is not tracked by git', async () => {
const consoleMock = jest
.spyOn(console, 'warn')
.mockImplementation(() => {});
const {repoDir} = createTempRepo();
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);
});
it('multiple files not tracked by git', async () => {
const consoleMock = jest
.spyOn(console, 'warn')
.mockImplementation(() => {});
const {repoDir} = createTempRepo();
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);
});
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 docs plugin last update options require Git\..*/,
),
);
consoleMock.mockRestore();
mock.mockRestore();
});
});

View file

@ -20,12 +20,11 @@ import {
normalizeFrontMatterTags, normalizeFrontMatterTags,
isUnlisted, isUnlisted,
isDraft, isDraft,
readLastUpdateData,
} from '@docusaurus/utils'; } from '@docusaurus/utils';
import {validateDocFrontMatter} from './frontMatter';
import {getFileLastUpdate} from './lastUpdate';
import getSlug from './slug'; import getSlug from './slug';
import {stripPathNumberPrefixes} from './numberPrefix'; import {stripPathNumberPrefixes} from './numberPrefix';
import {validateDocFrontMatter} from './frontMatter';
import {toDocNavigationLink, toNavigationLink} from './sidebars/utils'; import {toDocNavigationLink, toNavigationLink} from './sidebars/utils';
import type { import type {
MetadataOptions, MetadataOptions,
@ -34,61 +33,13 @@ import type {
DocMetadataBase, DocMetadataBase,
DocMetadata, DocMetadata,
PropNavigationLink, PropNavigationLink,
LastUpdateData,
VersionMetadata, VersionMetadata,
LoadedVersion, LoadedVersion,
FileChange,
} from '@docusaurus/plugin-content-docs'; } from '@docusaurus/plugin-content-docs';
import type {LoadContext} from '@docusaurus/types'; import type {LoadContext} from '@docusaurus/types';
import type {SidebarsUtils} from './sidebars/utils'; import type {SidebarsUtils} from './sidebars/utils';
import type {DocFile} from './types'; import type {DocFile} from './types';
type LastUpdateOptions = Pick<
PluginOptions,
'showLastUpdateAuthor' | 'showLastUpdateTime'
>;
async function readLastUpdateData(
filePath: string,
options: LastUpdateOptions,
lastUpdateFrontMatter: FileChange | undefined,
): Promise<LastUpdateData> {
const {showLastUpdateAuthor, showLastUpdateTime} = options;
if (showLastUpdateAuthor || showLastUpdateTime) {
const frontMatterTimestamp = lastUpdateFrontMatter?.date
? new Date(lastUpdateFrontMatter.date).getTime() / 1000
: undefined;
if (lastUpdateFrontMatter?.author && lastUpdateFrontMatter.date) {
return {
lastUpdatedAt: frontMatterTimestamp,
lastUpdatedBy: lastUpdateFrontMatter.author,
};
}
// Use fake data in dev for faster development.
const fileLastUpdateData =
process.env.NODE_ENV === 'production'
? await getFileLastUpdate(filePath)
: {
author: 'Author',
timestamp: 1539502055,
};
const {author, timestamp} = fileLastUpdateData ?? {};
return {
lastUpdatedBy: showLastUpdateAuthor
? lastUpdateFrontMatter?.author ?? author
: undefined,
lastUpdatedAt: showLastUpdateTime
? frontMatterTimestamp ?? timestamp
: undefined,
};
}
return {};
}
export async function readDocFile( export async function readDocFile(
versionMetadata: Pick< versionMetadata: Pick<
VersionMetadata, VersionMetadata,

View file

@ -4,7 +4,6 @@
* This source code is licensed under the MIT license found in the * This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree. * LICENSE file in the root directory of this source tree.
*/ */
import { import {
JoiFrontMatter as Joi, // Custom instance for front matter JoiFrontMatter as Joi, // Custom instance for front matter
URISchema, URISchema,
@ -12,17 +11,15 @@ import {
FrontMatterTOCHeadingLevels, FrontMatterTOCHeadingLevels,
validateFrontMatter, validateFrontMatter,
ContentVisibilitySchema, ContentVisibilitySchema,
FrontMatterLastUpdateSchema,
} from '@docusaurus/utils-validation'; } from '@docusaurus/utils-validation';
import type {DocFrontMatter} from '@docusaurus/plugin-content-docs'; import type {DocFrontMatter} from '@docusaurus/plugin-content-docs';
const FrontMatterLastUpdateErrorMessage =
'{{#label}} does not look like a valid front matter FileChange object. Please use a FileChange object (with an author and/or date).';
// NOTE: we don't add any default value on purpose here // NOTE: we don't add any default value on purpose here
// We don't want default values to magically appear in doc metadata and props // We don't want default values to magically appear in doc metadata and props
// While the user did not provide those values explicitly // While the user did not provide those values explicitly
// We use default values in code instead // We use default values in code instead
const DocFrontMatterSchema = Joi.object<DocFrontMatter>({ export const DocFrontMatterSchema = Joi.object<DocFrontMatter>({
id: Joi.string(), id: Joi.string(),
// See https://github.com/facebook/docusaurus/issues/4591#issuecomment-822372398 // See https://github.com/facebook/docusaurus/issues/4591#issuecomment-822372398
title: Joi.string().allow(''), title: Joi.string().allow(''),
@ -45,15 +42,7 @@ const DocFrontMatterSchema = Joi.object<DocFrontMatter>({
pagination_next: Joi.string().allow(null), pagination_next: Joi.string().allow(null),
pagination_prev: Joi.string().allow(null), pagination_prev: Joi.string().allow(null),
...FrontMatterTOCHeadingLevels, ...FrontMatterTOCHeadingLevels,
last_update: Joi.object({ last_update: FrontMatterLastUpdateSchema,
author: Joi.string(),
date: Joi.date().raw(),
})
.or('author', 'date')
.messages({
'object.missing': FrontMatterLastUpdateErrorMessage,
'object.base': FrontMatterLastUpdateErrorMessage,
}),
}) })
.unknown() .unknown()
.concat(ContentVisibilitySchema); .concat(ContentVisibilitySchema);

View file

@ -1,52 +0,0 @@
/**
* 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 logger from '@docusaurus/logger';
import {
getFileCommitDate,
FileNotTrackedError,
GitNotFoundError,
} from '@docusaurus/utils';
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 docs plugin 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 {
logger.warn(err);
}
return null;
}
}

View file

@ -16,6 +16,7 @@ declare module '@docusaurus/plugin-content-docs' {
TagsListItem, TagsListItem,
TagModule, TagModule,
Tag, Tag,
FrontMatterLastUpdate,
} from '@docusaurus/utils'; } from '@docusaurus/utils';
import type {Plugin, LoadContext} from '@docusaurus/types'; import type {Plugin, LoadContext} from '@docusaurus/types';
import type {Overwrite, Required} from 'utility-types'; import type {Overwrite, Required} from 'utility-types';
@ -24,14 +25,6 @@ declare module '@docusaurus/plugin-content-docs' {
image?: string; image?: string;
}; };
export type FileChange = {
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;
};
/** /**
* Custom callback for parsing number prefixes from file/folder names. * Custom callback for parsing number prefixes from file/folder names.
*/ */
@ -93,9 +86,9 @@ declare module '@docusaurus/plugin-content-docs' {
*/ */
editLocalizedFiles: boolean; editLocalizedFiles: boolean;
/** Whether to display the last date the doc was updated. */ /** Whether to display the last date the doc was updated. */
showLastUpdateTime?: boolean; showLastUpdateTime: boolean;
/** Whether to display the author who last updated the doc. */ /** Whether to display the author who last updated the doc. */
showLastUpdateAuthor?: boolean; showLastUpdateAuthor: boolean;
/** /**
* Custom parsing logic to extract number prefixes from file names. Use * Custom parsing logic to extract number prefixes from file names. Use
* `false` to disable this behavior and leave the docs untouched, and `true` * `false` to disable this behavior and leave the docs untouched, and `true`
@ -401,7 +394,7 @@ declare module '@docusaurus/plugin-content-docs' {
/** Should this doc be accessible but hidden in production builds? */ /** Should this doc be accessible but hidden in production builds? */
unlisted?: boolean; unlisted?: boolean;
/** Allows overriding the last updated author and/or date. */ /** Allows overriding the last updated author and/or date. */
last_update?: FileChange; last_update?: FrontMatterLastUpdate;
}; };
export type LastUpdateData = { export type LastUpdateData = {

View file

@ -676,6 +676,16 @@ declare module '@theme/DocVersionSuggestions' {
export default function DocVersionSuggestions(): JSX.Element; export default function DocVersionSuggestions(): JSX.Element;
} }
declare module '@theme/EditMetaRow' {
export interface Props {
readonly className: string;
readonly editUrl: string | null | undefined;
readonly lastUpdatedAt: number | undefined;
readonly lastUpdatedBy: string | undefined;
}
export default function EditMetaRow(props: Props): JSX.Element;
}
declare module '@theme/EditThisPage' { declare module '@theme/EditThisPage' {
export interface Props { export interface Props {
readonly editUrl: string; readonly editUrl: string;

View file

@ -8,15 +8,21 @@
import React from 'react'; import React from 'react';
import clsx from 'clsx'; import clsx from 'clsx';
import {useBlogPost} from '@docusaurus/theme-common/internal'; import {useBlogPost} from '@docusaurus/theme-common/internal';
import EditThisPage from '@theme/EditThisPage'; import {ThemeClassNames} from '@docusaurus/theme-common';
import EditMetaRow from '@theme/EditMetaRow';
import TagsListInline from '@theme/TagsListInline'; import TagsListInline from '@theme/TagsListInline';
import ReadMoreLink from '@theme/BlogPostItem/Footer/ReadMoreLink'; import ReadMoreLink from '@theme/BlogPostItem/Footer/ReadMoreLink';
import styles from './styles.module.css';
export default function BlogPostItemFooter(): JSX.Element | null { export default function BlogPostItemFooter(): JSX.Element | null {
const {metadata, isBlogPostPage} = useBlogPost(); const {metadata, isBlogPostPage} = useBlogPost();
const {tags, title, editUrl, hasTruncateMarker} = metadata; const {
tags,
title,
editUrl,
hasTruncateMarker,
lastUpdatedBy,
lastUpdatedAt,
} = metadata;
// A post is truncated if it's in the "list view" and it has a truncate marker // A post is truncated if it's in the "list view" and it has a truncate marker
const truncatedPost = !isBlogPostPage && hasTruncateMarker; const truncatedPost = !isBlogPostPage && hasTruncateMarker;
@ -29,32 +35,56 @@ export default function BlogPostItemFooter(): JSX.Element | null {
return null; return null;
} }
return ( // BlogPost footer - details view
<footer if (isBlogPostPage) {
className={clsx( const canDisplayEditMetaRow = !!(editUrl || lastUpdatedAt || lastUpdatedBy);
'row docusaurus-mt-lg',
isBlogPostPage && styles.blogPostFooterDetailsFull,
)}>
{tagsExists && (
<div className={clsx('col', {'col--9': truncatedPost})}>
<TagsListInline tags={tags} />
</div>
)}
{isBlogPostPage && editUrl && ( return (
<div className="col margin-top--sm"> <footer className="docusaurus-mt-lg">
<EditThisPage editUrl={editUrl} /> {tagsExists && (
</div> <div
)} className={clsx(
'row',
{truncatedPost && ( 'margin-top--sm',
<div ThemeClassNames.blog.blogFooterEditMetaRow,
className={clsx('col text--right', { )}>
'col--3': tagsExists, <div className="col">
})}> <TagsListInline tags={tags} />
<ReadMoreLink blogPostTitle={title} to={metadata.permalink} /> </div>
</div> </div>
)} )}
</footer> {canDisplayEditMetaRow && (
); <EditMetaRow
className={clsx(
'margin-top--sm',
ThemeClassNames.blog.blogFooterEditMetaRow,
)}
editUrl={editUrl}
lastUpdatedAt={lastUpdatedAt}
lastUpdatedBy={lastUpdatedBy}
/>
)}
</footer>
);
}
// BlogPost footer - list view
else {
return (
<footer className="row docusaurus-mt-lg">
{tagsExists && (
<div className={clsx('col', {'col--9': truncatedPost})}>
<TagsListInline tags={tags} />
</div>
)}
{truncatedPost && (
<div
className={clsx('col text--right', {
'col--3': tagsExists,
})}>
<ReadMoreLink blogPostTitle={title} to={metadata.permalink} />
</div>
)}
</footer>
);
}
} }

View file

@ -1,10 +0,0 @@
/**
* 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.
*/
.blogPostFooterDetailsFull {
flex-direction: column;
}

View file

@ -8,53 +8,10 @@
import React from 'react'; import React from 'react';
import clsx from 'clsx'; import clsx from 'clsx';
import {ThemeClassNames} from '@docusaurus/theme-common'; import {ThemeClassNames} from '@docusaurus/theme-common';
import {useDoc, type DocContextValue} from '@docusaurus/theme-common/internal'; import {useDoc} from '@docusaurus/theme-common/internal';
import LastUpdated from '@theme/LastUpdated'; import TagsListInline from '@theme/TagsListInline';
import EditThisPage from '@theme/EditThisPage';
import TagsListInline, {
type Props as TagsListInlineProps,
} from '@theme/TagsListInline';
import styles from './styles.module.css'; import EditMetaRow from '@theme/EditMetaRow';
function TagsRow(props: TagsListInlineProps) {
return (
<div
className={clsx(
ThemeClassNames.docs.docFooterTagsRow,
'row margin-bottom--sm',
)}>
<div className="col">
<TagsListInline {...props} />
</div>
</div>
);
}
type EditMetaRowProps = Pick<
DocContextValue['metadata'],
'editUrl' | 'lastUpdatedAt' | 'lastUpdatedBy'
>;
function EditMetaRow({
editUrl,
lastUpdatedAt,
lastUpdatedBy,
}: EditMetaRowProps) {
return (
<div className={clsx(ThemeClassNames.docs.docFooterEditMetaRow, 'row')}>
<div className="col">{editUrl && <EditThisPage editUrl={editUrl} />}</div>
<div className={clsx('col', styles.lastUpdated)}>
{(lastUpdatedAt || lastUpdatedBy) && (
<LastUpdated
lastUpdatedAt={lastUpdatedAt}
lastUpdatedBy={lastUpdatedBy}
/>
)}
</div>
</div>
);
}
export default function DocItemFooter(): JSX.Element | null { export default function DocItemFooter(): JSX.Element | null {
const {metadata} = useDoc(); const {metadata} = useDoc();
@ -72,9 +29,23 @@ export default function DocItemFooter(): JSX.Element | null {
return ( return (
<footer <footer
className={clsx(ThemeClassNames.docs.docFooter, 'docusaurus-mt-lg')}> className={clsx(ThemeClassNames.docs.docFooter, 'docusaurus-mt-lg')}>
{canDisplayTagsRow && <TagsRow tags={tags} />} {canDisplayTagsRow && (
<div
className={clsx(
'row margin-top--sm',
ThemeClassNames.docs.docFooterTagsRow,
)}>
<div className="col">
<TagsListInline tags={tags} />
</div>
</div>
)}
{canDisplayEditMetaRow && ( {canDisplayEditMetaRow && (
<EditMetaRow <EditMetaRow
className={clsx(
'margin-top--sm',
ThemeClassNames.docs.docFooterEditMetaRow,
)}
editUrl={editUrl} editUrl={editUrl}
lastUpdatedAt={lastUpdatedAt} lastUpdatedAt={lastUpdatedAt}
lastUpdatedBy={lastUpdatedBy} lastUpdatedBy={lastUpdatedBy}

View file

@ -0,0 +1,34 @@
/**
* 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 React from 'react';
import clsx from 'clsx';
import EditThisPage from '@theme/EditThisPage';
import type {Props} from '@theme/EditMetaRow';
import LastUpdated from '@theme/LastUpdated';
import styles from './styles.module.css';
export default function EditMetaRow({
className,
editUrl,
lastUpdatedAt,
lastUpdatedBy,
}: Props): JSX.Element {
return (
<div className={clsx('row', className)}>
<div className="col">{editUrl && <EditThisPage editUrl={editUrl} />}</div>
<div className={clsx('col', styles.lastUpdated)}>
{(lastUpdatedAt || lastUpdatedBy) && (
<LastUpdated
lastUpdatedAt={lastUpdatedAt}
lastUpdatedBy={lastUpdatedBy}
/>
)}
</div>
</div>
);
}

View file

@ -6,9 +6,9 @@
*/ */
.lastUpdated { .lastUpdated {
margin-top: 0.2rem;
font-style: italic;
font-size: smaller; font-size: smaller;
font-style: italic;
margin-top: 0.2rem;
} }
@media (min-width: 997px) { @media (min-width: 997px) {

View file

@ -34,7 +34,7 @@ function LastUpdatedAtDate({
values={{ values={{
date: ( date: (
<b> <b>
<time dateTime={atDate.toISOString()}> <time dateTime={atDate.toISOString()} itemProp="dateModified">
{formattedLastUpdatedAt} {formattedLastUpdatedAt}
</time> </time>
</b> </b>

View file

@ -73,5 +73,7 @@ export const ThemeClassNames = {
}, },
blog: { blog: {
// TODO add other stable classNames here // TODO add other stable classNames here
blogFooterTagsRow: 'theme-blog-footer-tags-row',
blogFooterEditMetaRow: 'theme-blog-footer-edit-meta-row',
}, },
} as const; } as const;

View file

@ -23,19 +23,23 @@ import type {
} from '@docusaurus/plugin-content-blog'; } from '@docusaurus/plugin-content-blog';
import type {DocusaurusConfig} from '@docusaurus/types'; import type {DocusaurusConfig} from '@docusaurus/types';
const convertDate = (dateMs: number) => new Date(dateMs * 1000).toISOString();
function getBlogPost( function getBlogPost(
blogPostContent: PropBlogPostContent, blogPostContent: PropBlogPostContent,
siteConfig: DocusaurusConfig, siteConfig: DocusaurusConfig,
withBaseUrl: BaseUrlUtils['withBaseUrl'], withBaseUrl: BaseUrlUtils['withBaseUrl'],
) { ): BlogPosting {
const {assets, frontMatter, metadata} = blogPostContent; const {assets, frontMatter, metadata} = blogPostContent;
const {date, title, description} = metadata; const {date, title, description, lastUpdatedAt} = metadata;
const image = assets.image ?? frontMatter.image; const image = assets.image ?? frontMatter.image;
const keywords = frontMatter.keywords ?? []; const keywords = frontMatter.keywords ?? [];
const blogUrl = `${siteConfig.url}${metadata.permalink}`; const blogUrl = `${siteConfig.url}${metadata.permalink}`;
const dateModified = lastUpdatedAt ? convertDate(lastUpdatedAt) : undefined;
return { return {
'@type': 'BlogPosting', '@type': 'BlogPosting',
'@id': blogUrl, '@id': blogUrl,
@ -45,6 +49,7 @@ function getBlogPost(
name: title, name: title,
description, description,
datePublished: date, datePublished: date,
...(dateModified ? {dateModified} : {}),
...getAuthor(metadata.authors), ...getAuthor(metadata.authors),
...getImage(image, withBaseUrl, title), ...getImage(image, withBaseUrl, title),
...(keywords ? {keywords} : {}), ...(keywords ? {keywords} : {}),
@ -108,11 +113,13 @@ export function useBlogPostStructuredData(): WithContext<BlogPosting> {
const {siteConfig} = useDocusaurusContext(); const {siteConfig} = useDocusaurusContext();
const {withBaseUrl} = useBaseUrlUtils(); const {withBaseUrl} = useBaseUrlUtils();
const {date, title, description, frontMatter} = metadata; const {date, title, description, frontMatter, lastUpdatedAt} = metadata;
const image = assets.image ?? frontMatter.image; const image = assets.image ?? frontMatter.image;
const keywords = frontMatter.keywords ?? []; const keywords = frontMatter.keywords ?? [];
const dateModified = lastUpdatedAt ? convertDate(lastUpdatedAt) : undefined;
const url = `${siteConfig.url}${metadata.permalink}`; const url = `${siteConfig.url}${metadata.permalink}`;
// details on structured data support: https://schema.org/BlogPosting // details on structured data support: https://schema.org/BlogPosting
@ -128,6 +135,7 @@ export function useBlogPostStructuredData(): WithContext<BlogPosting> {
name: title, name: title,
description, description,
datePublished: date, datePublished: date,
...(dateModified ? {dateModified} : {}),
...getAuthor(metadata.authors), ...getAuthor(metadata.authors),
...getImage(image, withBaseUrl, title), ...getImage(image, withBaseUrl, title),
...(keywords ? {keywords} : {}), ...(keywords ? {keywords} : {}),

View file

@ -30,6 +30,22 @@ exports[`validation schemas contentVisibilitySchema: for value={"unlisted":"bad
exports[`validation schemas contentVisibilitySchema: for value={"unlisted":42} 1`] = `""unlisted" must be a boolean"`; exports[`validation schemas contentVisibilitySchema: for value={"unlisted":42} 1`] = `""unlisted" must be a boolean"`;
exports[`validation schemas frontMatterLastUpdateSchema schema: for value="string" 1`] = `""value" does not look like a valid last update object. Please use an author key with a string or a date with a string or Date."`;
exports[`validation schemas frontMatterLastUpdateSchema schema: for value=[] 1`] = `""value" does not look like a valid last update object. Please use an author key with a string or a date with a string or Date."`;
exports[`validation schemas frontMatterLastUpdateSchema schema: for value={"author":23} 1`] = `""author" must be a string"`;
exports[`validation schemas frontMatterLastUpdateSchema schema: for value={"date":"20-20-20"} 1`] = `""date" must be a valid date"`;
exports[`validation schemas frontMatterLastUpdateSchema schema: for value={} 1`] = `""value" does not look like a valid last update object. Please use an author key with a string or a date with a string or Date."`;
exports[`validation schemas frontMatterLastUpdateSchema schema: for value=42 1`] = `""value" does not look like a valid last update object. Please use an author key with a string or a date with a string or Date."`;
exports[`validation schemas frontMatterLastUpdateSchema schema: for value=null 1`] = `""value" does not look like a valid last update object. Please use an author key with a string or a date with a string or Date."`;
exports[`validation schemas frontMatterLastUpdateSchema schema: for value=true 1`] = `""value" does not look like a valid last update object. Please use an author key with a string or a date with a string or Date."`;
exports[`validation schemas pathnameSchema: for value="foo" 1`] = `""value" (foo) is not a valid pathname. Pathname should start with slash and not contain any domain or query string."`; exports[`validation schemas pathnameSchema: for value="foo" 1`] = `""value" (foo) is not a valid pathname. Pathname should start with slash and not contain any domain or query string."`;
exports[`validation schemas pathnameSchema: for value="https://github.com/foo" 1`] = `""value" (https://github.com/foo) is not a valid pathname. Pathname should start with slash and not contain any domain or query string."`; exports[`validation schemas pathnameSchema: for value="https://github.com/foo" 1`] = `""value" (https://github.com/foo) is not a valid pathname. Pathname should start with slash and not contain any domain or query string."`;

View file

@ -16,6 +16,7 @@ import {
PathnameSchema, PathnameSchema,
RouteBasePathSchema, RouteBasePathSchema,
ContentVisibilitySchema, ContentVisibilitySchema,
FrontMatterLastUpdateSchema,
} from '../validationSchemas'; } from '../validationSchemas';
function createTestHelpers({ function createTestHelpers({
@ -216,4 +217,28 @@ describe('validation schemas', () => {
testFail({unlisted: 42}); testFail({unlisted: 42});
testFail({draft: true, unlisted: true}); testFail({draft: true, unlisted: true});
}); });
it('frontMatterLastUpdateSchema schema', () => {
const {testFail, testOK} = createTestHelpers({
schema: FrontMatterLastUpdateSchema,
});
testOK(undefined);
testOK({date: '2021-01-01'});
testOK({date: '2021-01'});
testOK({date: '2021'});
testOK({date: new Date()});
testOK({author: 'author'});
testOK({author: 'author', date: '2021-01-01'});
testOK({author: 'author', date: new Date()});
testFail(null);
testFail({});
testFail('string');
testFail(42);
testFail(true);
testFail([]);
testFail({author: 23});
testFail({date: '20-20-20'});
});
}); });

View file

@ -26,4 +26,6 @@ export {
FrontMatterTagsSchema, FrontMatterTagsSchema,
FrontMatterTOCHeadingLevels, FrontMatterTOCHeadingLevels,
ContentVisibilitySchema, ContentVisibilitySchema,
FrontMatterLastUpdateErrorMessage,
FrontMatterLastUpdateSchema,
} from './validationSchemas'; } from './validationSchemas';

View file

@ -167,3 +167,16 @@ export const ContentVisibilitySchema = JoiFrontMatter.object<ContentVisibility>(
"Can't be draft and unlisted at the same time.", "Can't be draft and unlisted at the same time.",
}) })
.unknown(); .unknown();
export const FrontMatterLastUpdateErrorMessage =
'{{#label}} does not look like a valid last update object. Please use an author key with a string or a date with a string or Date.';
export const FrontMatterLastUpdateSchema = Joi.object({
author: Joi.string(),
date: Joi.date().raw(),
})
.or('author', 'date')
.messages({
'object.missing': FrontMatterLastUpdateErrorMessage,
'object.base': FrontMatterLastUpdateErrorMessage,
});

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 path from 'path';
import {createTempRepo} from '@testing-utils/git'; import {createTempRepo} from '@testing-utils/git';
import {FileNotTrackedError, getFileCommitDate} from '../gitUtils'; import {FileNotTrackedError, getFileCommitDate} from '../gitUtils';
import {getFileLastUpdate} from '../lastUpdateUtils';
/* eslint-disable no-restricted-properties */ /* eslint-disable no-restricted-properties */
function initializeTempRepo() { function initializeTempRepo() {
@ -136,4 +137,22 @@ describe('getFileCommitDate', () => {
/Failed to retrieve git history for ".*nonexistent.txt" because the file does not exist./, /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

@ -117,3 +117,12 @@ export {
export {isDraft, isUnlisted} from './contentVisibilityUtils'; export {isDraft, isUnlisted} from './contentVisibilityUtils';
export {escapeRegexp} from './regExpUtils'; export {escapeRegexp} from './regExpUtils';
export {askPreferredLanguage} from './cliUtils'; 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,
};
}

View file

@ -227,6 +227,7 @@ orta
Outerbounds Outerbounds
outerbounds outerbounds
overrideable overrideable
ozaki
OShannessy OShannessy
pageview pageview
Palenight Palenight

View file

@ -76,6 +76,8 @@ Accepted fields:
| `feedOptions.language` | `string` (See [documentation](http://www.w3.org/TR/REC-html40/struct/dirlang.html#langcodes) for possible values) | `undefined` | Language metadata of the feed. | | `feedOptions.language` | `string` (See [documentation](http://www.w3.org/TR/REC-html40/struct/dirlang.html#langcodes) for possible values) | `undefined` | Language metadata of the feed. |
| `sortPosts` | <code>'descending' \| 'ascending' </code> | `'descending'` | Governs the direction of blog post sorting. | | `sortPosts` | <code>'descending' \| 'ascending' </code> | `'descending'` | Governs the direction of blog post sorting. |
| `processBlogPosts` | <code>[ProcessBlogPostsFn](#ProcessBlogPostsFn)</code> | `undefined` | An optional function which can be used to transform blog posts (filter, modify, delete, etc...). | | `processBlogPosts` | <code>[ProcessBlogPostsFn](#ProcessBlogPostsFn)</code> | `undefined` | An optional function which can be used to transform blog posts (filter, modify, delete, etc...). |
| `showLastUpdateAuthor` | `boolean` | `false` | Whether to display the author who last updated the blog post. |
| `showLastUpdateTime` | `boolean` | `false` | Whether to display the last date the blog post was updated. This requires access to git history during the build, so will not work correctly with shallow clones (a common default for CI systems). With GitHub `actions/checkout`, use`fetch-depth: 0`. |
```mdx-code-block ```mdx-code-block
</APITable> </APITable>
@ -232,12 +234,15 @@ Accepted fields:
| `description` | `string` | The first line of Markdown content | The description of your document, which will become the `<meta name="description" content="..."/>` and `<meta property="og:description" content="..."/>` in `<head>`, used by search engines. | | `description` | `string` | The first line of Markdown content | The description of your document, which will become the `<meta name="description" content="..."/>` and `<meta property="og:description" content="..."/>` in `<head>`, used by search engines. |
| `image` | `string` | `undefined` | Cover or thumbnail image that will be used as the `<meta property="og:image" content="..."/>` in the `<head>`, enhancing link previews on social media and messaging platforms. | | `image` | `string` | `undefined` | Cover or thumbnail image that will be used as the `<meta property="og:image" content="..."/>` in the `<head>`, enhancing link previews on social media and messaging platforms. |
| `slug` | `string` | File path | Allows to customize the blog post URL (`/<routeBasePath>/<slug>`). Support multiple patterns: `slug: my-blog-post`, `slug: /my/path/to/blog/post`, slug: `/`. | | `slug` | `string` | File path | Allows to customize the blog post URL (`/<routeBasePath>/<slug>`). Support multiple patterns: `slug: my-blog-post`, `slug: /my/path/to/blog/post`, slug: `/`. |
| `last_update` | `FrontMatterLastUpdate` | `undefined` | Allows overriding the last update author/date. Date can be any [parsable date string](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/parse). |
```mdx-code-block ```mdx-code-block
</APITable> </APITable>
``` ```
```ts ```ts
type FrontMatterLastUpdate = {date?: string; author?: string};
type Tag = string | {label: string; permalink: string}; type Tag = string | {label: string; permalink: string};
// An author key references an author from the global plugin authors.yml file // An author key references an author from the global plugin authors.yml file

View file

@ -58,7 +58,7 @@ Accepted fields:
| `beforeDefaultRemarkPlugins` | `any[]` | `[]` | Custom Remark plugins passed to MDX before the default Docusaurus Remark plugins. | | `beforeDefaultRemarkPlugins` | `any[]` | `[]` | Custom Remark plugins passed to MDX before the default Docusaurus Remark plugins. |
| `beforeDefaultRehypePlugins` | `any[]` | `[]` | Custom Rehype plugins passed to MDX before the default Docusaurus Rehype plugins. | | `beforeDefaultRehypePlugins` | `any[]` | `[]` | Custom Rehype plugins passed to MDX before the default Docusaurus Rehype plugins. |
| `showLastUpdateAuthor` | `boolean` | `false` | Whether to display the author who last updated the doc. | | `showLastUpdateAuthor` | `boolean` | `false` | Whether to display the author who last updated the doc. |
| `showLastUpdateTime` | `boolean` | `false` | Whether to display the last date the doc was updated. This requires access to git history during the build, so will not work correctly with shallow clones (a common default for CI systems). | | `showLastUpdateTime` | `boolean` | `false` | Whether to display the last date the doc was updated. This requires access to git history during the build, so will not work correctly with shallow clones (a common default for CI systems). With GitHub `actions/checkout`, use`fetch-depth: 0`. |
| `breadcrumbs` | `boolean` | `true` | Enable or disable the breadcrumbs on doc pages. | | `breadcrumbs` | `boolean` | `true` | Enable or disable the breadcrumbs on doc pages. |
| `disableVersioning` | `boolean` | `false` | Explicitly disable versioning even when multiple versions exist. This will make the site only include the current version. Will error if `includeCurrentVersion: false` and `disableVersioning: true`. | | `disableVersioning` | `boolean` | `false` | Explicitly disable versioning even when multiple versions exist. This will make the site only include the current version. Will error if `includeCurrentVersion: false` and `disableVersioning: true`. |
| `includeCurrentVersion` | `boolean` | `true` | Include the current version of your docs. | | `includeCurrentVersion` | `boolean` | `true` | Include the current version of your docs. |
@ -296,18 +296,16 @@ Accepted fields:
| `tags` | `Tag[]` | `undefined` | A list of strings or objects of two string fields `label` and `permalink` to tag to your docs. | | `tags` | `Tag[]` | `undefined` | A list of strings or objects of two string fields `label` and `permalink` to tag to your docs. |
| `draft` | `boolean` | `false` | Draft documents will only be available during development. | | `draft` | `boolean` | `false` | Draft documents will only be available during development. |
| `unlisted` | `boolean` | `false` | Unlisted documents will be available in both development and production. They will be "hidden" in production, not indexed, excluded from sitemaps, and can only be accessed by users having a direct link. | | `unlisted` | `boolean` | `false` | Unlisted documents will be available in both development and production. They will be "hidden" in production, not indexed, excluded from sitemaps, and can only be accessed by users having a direct link. |
| `last_update` | `FileChange` | `undefined` | Allows overriding the last updated author and/or date. Date can be any [parsable date string](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/parse). | | `last_update` | `FrontMatterLastUpdate` | `undefined` | Allows overriding the last update author/date. Date can be any [parsable date string](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/parse). |
```mdx-code-block ```mdx-code-block
</APITable> </APITable>
``` ```
```ts ```ts
type Tag = string | {label: string; permalink: string}; type FrontMatterLastUpdate = {date?: string; author?: string};
```
```ts type Tag = string | {label: string; permalink: string};
type FileChange = {date: string; author: string};
``` ```
Example: Example:

View file

@ -195,7 +195,9 @@ export default async function createConfigAsync() {
result = result.replaceAll('{/_', '{/*'); result = result.replaceAll('{/_', '{/*');
result = result.replaceAll('_/}', '*/}'); result = result.replaceAll('_/}', '*/}');
if (isDev) { const showDevLink = false;
if (isDev && showDevLink) {
const isPartial = path.basename(filePath).startsWith('_'); const isPartial = path.basename(filePath).startsWith('_');
if (!isPartial) { if (!isPartial) {
// "vscode://file/${projectPath}${filePath}:${line}:${column}", // "vscode://file/${projectPath}${filePath}:${line}:${column}",
@ -441,6 +443,8 @@ export default async function createConfigAsync() {
blog: { blog: {
// routeBasePath: '/', // routeBasePath: '/',
path: 'blog', path: 'blog',
showLastUpdateAuthor: true,
showLastUpdateTime: true,
editUrl: ({locale, blogDirPath, blogPath}) => { editUrl: ({locale, blogDirPath, blogPath}) => {
if (locale !== defaultLocale) { if (locale !== defaultLocale) {
return `https://crowdin.com/project/docusaurus-v2/${locale}`; return `https://crowdin.com/project/docusaurus-v2/${locale}`;