mirror of
https://github.com/facebook/docusaurus.git
synced 2025-04-28 17:57:48 +02:00
Co-authored-by: OzakIOne <OzakIOne@users.noreply.github.com> Co-authored-by: sebastien <lorber.sebastien@gmail.com>
449 lines
11 KiB
TypeScript
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;
|
|
}
|