mirror of
https://github.com/facebook/docusaurus.git
synced 2025-05-10 07:37:19 +02:00
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:
parent
34e9080232
commit
cabb768473
24 changed files with 417 additions and 166 deletions
|
@ -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(
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue