feat: blog posts support /YYYY/MM/DD/blog-post/index.md pattern + blog frontmatter can reference relative images (#5309)

* POC of blog post folder

* add parseBlogFileName with tests + refactor and extract processBlogSourceFile in separate method

* improve blog date pattern doc + link from content plugin guides to API ref docs

* Some FrontMatter fields should be able to reference relative image assets, converted to Webpack require calls and exposed as frontMatterAssets

* remove log
This commit is contained in:
Sébastien Lorber 2021-08-06 17:51:59 +02:00 committed by GitHub
parent 34e9080232
commit cabb768473
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 417 additions and 166 deletions

View file

@ -10,11 +10,10 @@ import chalk from 'chalk';
import path from 'path';
import readingTime from 'reading-time';
import {Feed} from 'feed';
import {keyBy, mapValues} from 'lodash';
import {compact, keyBy, mapValues} from 'lodash';
import {
PluginOptions,
BlogPost,
DateLink,
BlogContentPaths,
BlogMarkdownLoaderOptions,
} from './types';
@ -44,15 +43,40 @@ export function getSourceToPermalink(
);
}
// YYYY-MM-DD-{name}.mdx?
// Prefer named capture, but older Node versions do not support it.
const DATE_FILENAME_PATTERN = /^(\d{4}-\d{1,2}-\d{1,2})-?(.*?).mdx?$/;
const DATE_FILENAME_REGEX = /^(?<date>\d{4}[-\/]\d{1,2}[-\/]\d{1,2})[-\/]?(?<text>.*?)(\/index)?.mdx?$/;
function toUrl({date, link}: DateLink) {
return `${date
.toISOString()
.substring(0, '2019-01-01'.length)
.replace(/-/g, '/')}/${link}`;
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 dateString = dateFilenameMatch.groups!.date!;
const text = dateFilenameMatch.groups!.text!;
// Always treat dates as UTC by adding the `Z`
const date = new Date(`${dateString}Z`);
// TODO use replaceAll once we require NodeJS 16
const slugDate = dateString.replace('-', '/').replace('-', '/');
const slug = `/${slugDate}/${text}`;
return {
date,
text,
slug,
};
} else {
const text = blogSourceRelative.replace(/(\/index)?\.mdx?$/, '');
const slug = `/${text}`;
return {
date: undefined,
text,
slug,
};
}
}
function formatBlogPostDate(locale: string, date: Date): string {
@ -120,153 +144,166 @@ export async function generateBlogFeed(
return feed;
}
async function parseBlogPostMarkdownFile(blogSourceAbsolute: string) {
const result = await parseMarkdownFile(blogSourceAbsolute, {
removeContentTitle: true,
});
return {
...result,
frontMatter: validateBlogPostFrontMatter(result.frontMatter),
};
}
async function processBlogSourceFile(
blogSourceRelative: string,
contentPaths: BlogContentPaths,
context: LoadContext,
options: PluginOptions,
): Promise<BlogPost | undefined> {
const {
siteConfig: {baseUrl},
siteDir,
i18n,
} = context;
const {routeBasePath, 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(blogSourceAbsolute);
const aliasedSource = aliasedSitePath(blogSourceAbsolute, siteDir);
if (frontMatter.draft && process.env.NODE_ENV === 'production') {
return undefined;
}
if (frontMatter.id) {
console.warn(
chalk.yellow(
`"id" header option is deprecated in ${blogSourceRelative} file. Please use "slug" option instead.`,
),
);
}
const parsedBlogFileName = parseBlogFileName(blogSourceRelative);
async function getDate(): Promise<Date> {
// Prefer user-defined date.
if (frontMatter.date) {
return new Date(frontMatter.date);
} else if (parsedBlogFileName.date) {
return parsedBlogFileName.date;
} else {
// Fallback to file create time
return (await fs.stat(blogSourceAbsolute)).birthtime;
}
}
const date = await getDate();
const formattedDate = formatBlogPostDate(i18n.currentLocale, date);
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);
} else {
return undefined;
}
}
return {
id: frontMatter.slug ?? title,
metadata: {
permalink,
editUrl: getBlogEditUrl(),
source: aliasedSource,
title,
description,
date,
formattedDate,
tags: frontMatter.tags ?? [],
readingTime: showReadingTime ? readingTime(content).minutes : undefined,
truncated: truncateMarker?.test(content) || false,
},
};
}
export async function generateBlogPosts(
contentPaths: BlogContentPaths,
{siteConfig, siteDir, i18n}: LoadContext,
context: LoadContext,
options: PluginOptions,
): Promise<BlogPost[]> {
const {
include,
exclude,
routeBasePath,
truncateMarker,
showReadingTime,
editUrl,
} = options;
const {include, exclude} = options;
if (!fs.existsSync(contentPaths.contentPath)) {
return [];
}
const {baseUrl = ''} = siteConfig;
const blogSourceFiles = await Globby(include, {
cwd: contentPaths.contentPath,
ignore: exclude,
});
const blogPosts: BlogPost[] = [];
async function processBlogSourceFile(blogSourceFile: string) {
// Lookup in localized folder in priority
const blogDirPath = await getFolderContainingFile(
getContentPathList(contentPaths),
blogSourceFile,
);
const source = path.join(blogDirPath, blogSourceFile);
const {
frontMatter: unsafeFrontMatter,
content,
contentTitle,
excerpt,
} = await parseMarkdownFile(source, {removeContentTitle: true});
const frontMatter = validateBlogPostFrontMatter(unsafeFrontMatter);
const aliasedSource = aliasedSitePath(source, siteDir);
const blogFileName = path.basename(blogSourceFile);
if (frontMatter.draft && process.env.NODE_ENV === 'production') {
return;
}
if (frontMatter.id) {
console.warn(
chalk.yellow(
`"id" header option is deprecated in ${blogFileName} file. Please use "slug" option instead.`,
),
);
}
let date: Date | undefined;
// Extract date and title from filename.
const dateFilenameMatch = blogFileName.match(DATE_FILENAME_PATTERN);
let linkName = blogFileName.replace(/\.mdx?$/, '');
if (dateFilenameMatch) {
const [, dateString, name] = dateFilenameMatch;
// Always treat dates as UTC by adding the `Z`
date = new Date(`${dateString}Z`);
linkName = name;
}
// Prefer user-defined date.
if (frontMatter.date) {
date = new Date(frontMatter.date);
}
// Use file create time for blog.
date = date ?? (await fs.stat(source)).birthtime;
const formattedDate = formatBlogPostDate(i18n.currentLocale, date);
const title = frontMatter.title ?? contentTitle ?? linkName;
const description = frontMatter.description ?? excerpt ?? '';
const slug =
frontMatter.slug ||
(dateFilenameMatch ? toUrl({date, link: linkName}) : linkName);
const permalink = normalizeUrl([baseUrl, routeBasePath, slug]);
function getBlogEditUrl() {
const blogPathRelative = path.relative(blogDirPath, path.resolve(source));
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);
} else {
return undefined;
}
}
blogPosts.push({
id: frontMatter.slug ?? title,
metadata: {
permalink,
editUrl: getBlogEditUrl(),
source: aliasedSource,
title,
description,
date,
formattedDate,
tags: frontMatter.tags ?? [],
readingTime: showReadingTime ? readingTime(content).minutes : undefined,
truncated: truncateMarker?.test(content) || false,
},
});
}
await Promise.all(
blogSourceFiles.map(async (blogSourceFile: string) => {
try {
return await processBlogSourceFile(blogSourceFile);
} catch (e) {
console.error(
chalk.red(
`Processing of blog source file failed for path "${blogSourceFile}"`,
),
);
throw e;
}
}),
const blogPosts: BlogPost[] = compact(
await Promise.all(
blogSourceFiles.map(async (blogSourceFile: string) => {
try {
return await processBlogSourceFile(
blogSourceFile,
contentPaths,
context,
options,
);
} catch (e) {
console.error(
chalk.red(
`Processing of blog source file failed for path "${blogSourceFile}"`,
),
);
throw e;
}
}),
),
);
blogPosts.sort(