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

@ -0,0 +1,94 @@
/**
* 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 {parseBlogFileName} from '../blogUtils';
describe('parseBlogFileName', () => {
test('parse file', () => {
expect(parseBlogFileName('some-post.md')).toEqual({
date: undefined,
text: 'some-post',
slug: '/some-post',
});
});
test('parse folder', () => {
expect(parseBlogFileName('some-post/index.md')).toEqual({
date: undefined,
text: 'some-post',
slug: '/some-post',
});
});
test('parse nested file', () => {
expect(parseBlogFileName('some-post/some-file.md')).toEqual({
date: undefined,
text: 'some-post/some-file',
slug: '/some-post/some-file',
});
});
test('parse nested folder', () => {
expect(parseBlogFileName('some-post/some-subfolder/index.md')).toEqual({
date: undefined,
text: 'some-post/some-subfolder',
slug: '/some-post/some-subfolder',
});
});
test('parse file respecting date convention', () => {
expect(
parseBlogFileName('2021-05-12-announcing-docusaurus-two-beta.md'),
).toEqual({
date: new Date('2021-05-12Z'),
text: 'announcing-docusaurus-two-beta',
slug: '/2021/05/12/announcing-docusaurus-two-beta',
});
});
test('parse folder name respecting date convention', () => {
expect(
parseBlogFileName('2021-05-12-announcing-docusaurus-two-beta/index.md'),
).toEqual({
date: new Date('2021-05-12Z'),
text: 'announcing-docusaurus-two-beta',
slug: '/2021/05/12/announcing-docusaurus-two-beta',
});
});
test('parse folder tree respecting date convention', () => {
expect(
parseBlogFileName('2021/05/12/announcing-docusaurus-two-beta/index.md'),
).toEqual({
date: new Date('2021-05-12Z'),
text: 'announcing-docusaurus-two-beta',
slug: '/2021/05/12/announcing-docusaurus-two-beta',
});
});
test('parse folder name/tree (mixed) respecting date convention', () => {
expect(
parseBlogFileName('2021/05-12-announcing-docusaurus-two-beta/index.md'),
).toEqual({
date: new Date('2021-05-12Z'),
text: 'announcing-docusaurus-two-beta',
slug: '/2021/05/12/announcing-docusaurus-two-beta',
});
});
test('parse nested folder tree respecting date convention', () => {
expect(
parseBlogFileName(
'2021/05/12/announcing-docusaurus-two-beta/subfolder/subfile.md',
),
).toEqual({
date: new Date('2021-05-12Z'),
text: 'announcing-docusaurus-two-beta/subfolder/subfile',
slug: '/2021/05/12/announcing-docusaurus-two-beta/subfolder/subfile',
});
});
});

View file

@ -21,7 +21,7 @@ export type BlogPostFrontMatter = {
tags?: (string | Tag)[];
slug?: string;
draft?: boolean;
date?: Date;
date?: Date | string; // Yaml automagically convert some string patterns as Date, but not all
author?: string;
author_title?: string;

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(

View file

@ -467,6 +467,12 @@ export default function pluginContentBlog(
// For blog posts a title in markdown is always removed
// Blog posts title are rendered separately
removeContentTitle: true,
// those frontMatter fields will be exported as "frontMatterAssets" and eventually be converted to require() calls for relative file paths
frontMatterAssetKeys: [
'image',
'authorImageURL',
'author_image_URL',
],
},
},
{

View file

@ -32,7 +32,7 @@ export const DEFAULT_OPTIONS = {
blogSidebarCount: 5,
blogSidebarTitle: 'Recent posts',
postsPerPage: 10,
include: ['*.md', '*.mdx'],
include: ['**/*.{md,mdx}'],
exclude: GlobExcludeDefault,
routeBasePath: 'blog',
path: 'blog',

View file

@ -20,11 +20,6 @@ export interface BlogContent {
blogTagsListPath: string | null;
}
export interface DateLink {
date: Date;
link: string;
}
export type FeedType = 'rss' | 'atom';
export type EditUrlFunction = (editUrlParams: {