docusaurus/packages/docusaurus-plugin-content-blog/src/blogUtils.ts
ozaki 32fa7e0a8d feat(blog): add LastUpdateAuthor & LastUpdateTime (#9912)
Co-authored-by: OzakIOne <OzakIOne@users.noreply.github.com>
Co-authored-by: sebastien <lorber.sebastien@gmail.com>
2024-03-15 14:54:38 +01:00

449 lines
11 KiB
TypeScript

/**
* 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 fs from 'fs-extra';
import path from 'path';
import _ from 'lodash';
import logger from '@docusaurus/logger';
import readingTime from 'reading-time';
import {
parseMarkdownFile,
normalizeUrl,
aliasedSitePath,
getEditUrl,
getFolderContainingFile,
posixPath,
replaceMarkdownLinks,
Globby,
normalizeFrontMatterTags,
groupTaggedItems,
getTagVisibility,
getFileCommitDate,
getContentPathList,
isUnlisted,
isDraft,
readLastUpdateData,
} from '@docusaurus/utils';
import {validateBlogPostFrontMatter} from './frontMatter';
import {type AuthorsMap, getAuthorsMap, getBlogPostAuthors} from './authors';
import type {LoadContext, ParseFrontMatter} from '@docusaurus/types';
import type {
PluginOptions,
ReadingTimeFunction,
BlogPost,
BlogTags,
BlogPaginated,
} from '@docusaurus/plugin-content-blog';
import type {BlogContentPaths, BlogMarkdownLoaderOptions} from './types';
export function truncate(fileString: string, truncateMarker: RegExp): string {
return fileString.split(truncateMarker, 1).shift()!;
}
export function getSourceToPermalink(blogPosts: BlogPost[]): {
[aliasedPath: string]: string;
} {
return Object.fromEntries(
blogPosts.map(({metadata: {source, permalink}}) => [source, permalink]),
);
}
export function paginateBlogPosts({
blogPosts,
basePageUrl,
blogTitle,
blogDescription,
postsPerPageOption,
pageBasePath,
}: {
blogPosts: BlogPost[];
basePageUrl: string;
blogTitle: string;
blogDescription: string;
postsPerPageOption: number | 'ALL';
pageBasePath: string;
}): BlogPaginated[] {
const totalCount = blogPosts.length;
const postsPerPage =
postsPerPageOption === 'ALL' ? totalCount : postsPerPageOption;
const numberOfPages = Math.ceil(totalCount / postsPerPage);
const pages: BlogPaginated[] = [];
function permalink(page: number) {
return page > 0
? normalizeUrl([basePageUrl, pageBasePath, `${page + 1}`])
: basePageUrl;
}
for (let page = 0; page < numberOfPages; page += 1) {
pages.push({
items: blogPosts
.slice(page * postsPerPage, (page + 1) * postsPerPage)
.map((item) => item.id),
metadata: {
permalink: permalink(page),
page: page + 1,
postsPerPage,
totalPages: numberOfPages,
totalCount,
previousPage: page !== 0 ? permalink(page - 1) : undefined,
nextPage: page < numberOfPages - 1 ? permalink(page + 1) : undefined,
blogDescription,
blogTitle,
},
});
}
return pages;
}
export function shouldBeListed(blogPost: BlogPost): boolean {
return !blogPost.metadata.unlisted;
}
export function getBlogTags({
blogPosts,
...params
}: {
blogPosts: BlogPost[];
blogTitle: string;
blogDescription: string;
postsPerPageOption: number | 'ALL';
pageBasePath: string;
}): BlogTags {
const groups = groupTaggedItems(
blogPosts,
(blogPost) => blogPost.metadata.tags,
);
return _.mapValues(groups, ({tag, items: tagBlogPosts}) => {
const tagVisibility = getTagVisibility({
items: tagBlogPosts,
isUnlisted: (item) => item.metadata.unlisted,
});
return {
label: tag.label,
items: tagVisibility.listedItems.map((item) => item.id),
permalink: tag.permalink,
pages: paginateBlogPosts({
blogPosts: tagVisibility.listedItems,
basePageUrl: tag.permalink,
...params,
}),
unlisted: tagVisibility.unlisted,
};
});
}
const DATE_FILENAME_REGEX =
/^(?<folder>.*)(?<date>\d{4}[-/]\d{1,2}[-/]\d{1,2})[-/]?(?<text>.*?)(?:\/index)?.mdx?$/;
type ParsedBlogFileName = {
date: Date | undefined;
text: string;
slug: string;
};
export function parseBlogFileName(
blogSourceRelative: string,
): ParsedBlogFileName {
const dateFilenameMatch = blogSourceRelative.match(DATE_FILENAME_REGEX);
if (dateFilenameMatch) {
const {folder, text, date: dateString} = dateFilenameMatch.groups!;
// Always treat dates as UTC by adding the `Z`
const date = new Date(`${dateString!}Z`);
const slugDate = dateString!.replace(/-/g, '/');
const slug = `/${slugDate}/${folder!}${text!}`;
return {date, text: text!, slug};
}
const text = blogSourceRelative.replace(/(?:\/index)?\.mdx?$/, '');
const slug = `/${text}`;
return {date: undefined, text, slug};
}
async function parseBlogPostMarkdownFile({
filePath,
parseFrontMatter,
}: {
filePath: string;
parseFrontMatter: ParseFrontMatter;
}) {
const fileContent = await fs.readFile(filePath, 'utf-8');
try {
const result = await parseMarkdownFile({
filePath,
fileContent,
parseFrontMatter,
removeContentTitle: true,
});
return {
...result,
frontMatter: validateBlogPostFrontMatter(result.frontMatter),
};
} catch (err) {
logger.error`Error while parsing blog post file path=${filePath}.`;
throw err;
}
}
const defaultReadingTime: ReadingTimeFunction = ({content, options}) =>
readingTime(content, options).minutes;
async function processBlogSourceFile(
blogSourceRelative: string,
contentPaths: BlogContentPaths,
context: LoadContext,
options: PluginOptions,
authorsMap?: AuthorsMap,
): Promise<BlogPost | undefined> {
const {
siteConfig: {
baseUrl,
markdown: {parseFrontMatter},
},
siteDir,
i18n,
} = context;
const {
routeBasePath,
tagsBasePath: tagsRouteBasePath,
truncateMarker,
showReadingTime,
editUrl,
} = options;
// Lookup in localized folder in priority
const blogDirPath = await getFolderContainingFile(
getContentPathList(contentPaths),
blogSourceRelative,
);
const blogSourceAbsolute = path.join(blogDirPath, blogSourceRelative);
const {frontMatter, content, contentTitle, excerpt} =
await parseBlogPostMarkdownFile({
filePath: blogSourceAbsolute,
parseFrontMatter,
});
const aliasedSource = aliasedSitePath(blogSourceAbsolute, siteDir);
const lastUpdate = await readLastUpdateData(
blogSourceAbsolute,
options,
frontMatter.last_update,
);
const draft = isDraft({frontMatter});
const unlisted = isUnlisted({frontMatter});
if (draft) {
return undefined;
}
if (frontMatter.id) {
logger.warn`name=${'id'} header option is deprecated in path=${blogSourceRelative} file. Please use name=${'slug'} option instead.`;
}
const parsedBlogFileName = parseBlogFileName(blogSourceRelative);
async function getDate(): Promise<Date> {
// Prefer user-defined date.
if (frontMatter.date) {
if (typeof frontMatter.date === 'string') {
// Always treat dates as UTC by adding the `Z`
return new Date(`${frontMatter.date}Z`);
}
// YAML only converts YYYY-MM-DD to dates and leaves others as strings.
return frontMatter.date;
} else if (parsedBlogFileName.date) {
return parsedBlogFileName.date;
}
try {
const result = await getFileCommitDate(blogSourceAbsolute, {
age: 'oldest',
includeAuthor: false,
});
return result.date;
} catch (err) {
logger.warn(err);
return (await fs.stat(blogSourceAbsolute)).birthtime;
}
}
const date = await getDate();
const title = frontMatter.title ?? contentTitle ?? parsedBlogFileName.text;
const description = frontMatter.description ?? excerpt ?? '';
const slug = frontMatter.slug ?? parsedBlogFileName.slug;
const permalink = normalizeUrl([baseUrl, routeBasePath, slug]);
function getBlogEditUrl() {
const blogPathRelative = path.relative(
blogDirPath,
path.resolve(blogSourceAbsolute),
);
if (typeof editUrl === 'function') {
return editUrl({
blogDirPath: posixPath(path.relative(siteDir, blogDirPath)),
blogPath: posixPath(blogPathRelative),
permalink,
locale: i18n.currentLocale,
});
} else if (typeof editUrl === 'string') {
const isLocalized = blogDirPath === contentPaths.contentPathLocalized;
const fileContentPath =
isLocalized && options.editLocalizedFiles
? contentPaths.contentPathLocalized
: contentPaths.contentPath;
const contentPathEditUrl = normalizeUrl([
editUrl,
posixPath(path.relative(siteDir, fileContentPath)),
]);
return getEditUrl(blogPathRelative, contentPathEditUrl);
}
return undefined;
}
const tagsBasePath = normalizeUrl([
baseUrl,
routeBasePath,
tagsRouteBasePath,
]);
const authors = getBlogPostAuthors({authorsMap, frontMatter, baseUrl});
return {
id: slug,
metadata: {
permalink,
editUrl: getBlogEditUrl(),
source: aliasedSource,
title,
description,
date,
tags: normalizeFrontMatterTags(tagsBasePath, frontMatter.tags),
readingTime: showReadingTime
? options.readingTime({
content,
frontMatter,
defaultReadingTime,
})
: undefined,
hasTruncateMarker: truncateMarker.test(content),
authors,
frontMatter,
unlisted,
lastUpdatedAt: lastUpdate.lastUpdatedAt,
lastUpdatedBy: lastUpdate.lastUpdatedBy,
},
content,
};
}
export async function generateBlogPosts(
contentPaths: BlogContentPaths,
context: LoadContext,
options: PluginOptions,
): Promise<BlogPost[]> {
const {include, exclude} = options;
if (!(await fs.pathExists(contentPaths.contentPath))) {
return [];
}
const blogSourceFiles = await Globby(include, {
cwd: contentPaths.contentPath,
ignore: exclude,
});
const authorsMap = await getAuthorsMap({
contentPaths,
authorsMapPath: options.authorsMapPath,
});
async function doProcessBlogSourceFile(blogSourceFile: string) {
try {
return await processBlogSourceFile(
blogSourceFile,
contentPaths,
context,
options,
authorsMap,
);
} catch (err) {
throw new Error(
`Processing of blog source file path=${blogSourceFile} failed.`,
{cause: err as Error},
);
}
}
const blogPosts = (
await Promise.all(blogSourceFiles.map(doProcessBlogSourceFile))
).filter(Boolean) as BlogPost[];
blogPosts.sort(
(a, b) => b.metadata.date.getTime() - a.metadata.date.getTime(),
);
if (options.sortPosts === 'ascending') {
return blogPosts.reverse();
}
return blogPosts;
}
export type LinkifyParams = {
filePath: string;
fileString: string;
} & Pick<
BlogMarkdownLoaderOptions,
'sourceToPermalink' | 'siteDir' | 'contentPaths' | 'onBrokenMarkdownLink'
>;
export function linkify({
filePath,
contentPaths,
fileString,
siteDir,
sourceToPermalink,
onBrokenMarkdownLink,
}: LinkifyParams): string {
const {newContent, brokenMarkdownLinks} = replaceMarkdownLinks({
siteDir,
fileString,
filePath,
contentPaths,
sourceToPermalink,
});
brokenMarkdownLinks.forEach((l) => onBrokenMarkdownLink(l));
return newContent;
}
export async function applyProcessBlogPosts({
blogPosts,
processBlogPosts,
}: {
blogPosts: BlogPost[];
processBlogPosts: PluginOptions['processBlogPosts'];
}): Promise<BlogPost[]> {
const processedBlogPosts = await processBlogPosts({blogPosts});
if (Array.isArray(processedBlogPosts)) {
return processedBlogPosts;
}
return blogPosts;
}