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
|
@ -19,6 +19,12 @@ const toc = require('./remark/toc');
|
||||||
const unwrapMdxCodeBlocks = require('./remark/unwrapMdxCodeBlocks');
|
const unwrapMdxCodeBlocks = require('./remark/unwrapMdxCodeBlocks');
|
||||||
const transformImage = require('./remark/transformImage');
|
const transformImage = require('./remark/transformImage');
|
||||||
const transformLinks = require('./remark/transformLinks');
|
const transformLinks = require('./remark/transformLinks');
|
||||||
|
const {escapePath} = require('@docusaurus/utils');
|
||||||
|
const {getFileLoaderUtils} = require('@docusaurus/core/lib/webpack/utils');
|
||||||
|
|
||||||
|
const {
|
||||||
|
loaders: {inlineMarkdownImageFileLoader},
|
||||||
|
} = getFileLoaderUtils();
|
||||||
|
|
||||||
const DEFAULT_OPTIONS = {
|
const DEFAULT_OPTIONS = {
|
||||||
rehypePlugins: [],
|
rehypePlugins: [],
|
||||||
|
@ -38,9 +44,49 @@ async function readMetadataPath(metadataPath) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For some specific FrontMatter fields, we want to allow referencing local relative assets so that they enter the Webpack asset pipeline
|
||||||
|
// We don't do that for all frontMatters, only for the configured keys
|
||||||
|
// {image: "./myImage.png"} => {image: require("./myImage.png")}
|
||||||
|
function createFrontMatterAssetsExportCode(
|
||||||
|
filePath,
|
||||||
|
frontMatter,
|
||||||
|
frontMatterAssetKeys = [],
|
||||||
|
) {
|
||||||
|
if (frontMatterAssetKeys.length === 0) {
|
||||||
|
return 'undefined';
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFrontMatterAssetRequireCode(value) {
|
||||||
|
// Only process string values starting with ./
|
||||||
|
// We could enhance this logic and check if file exists on disc?
|
||||||
|
if (typeof value === 'string' && value.startsWith('./')) {
|
||||||
|
// TODO do we have other use-cases than image assets?
|
||||||
|
// Probably not worth adding more support, as we want to move to Webpack 5 new asset system (https://github.com/facebook/docusaurus/pull/4708)
|
||||||
|
const inlineLoader = inlineMarkdownImageFileLoader;
|
||||||
|
return `require("${inlineLoader}${escapePath(value)}").default`;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const frontMatterAssetEntries = Object.entries(frontMatter).filter(([key]) =>
|
||||||
|
frontMatterAssetKeys.includes(key),
|
||||||
|
);
|
||||||
|
|
||||||
|
const lines = frontMatterAssetEntries
|
||||||
|
.map(([key, value]) => {
|
||||||
|
const assetRequireCode = createFrontMatterAssetRequireCode(value);
|
||||||
|
return assetRequireCode ? `"${key}": ${assetRequireCode},` : undefined;
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const exportValue = `{\n${lines.join('\n')}\n}`;
|
||||||
|
|
||||||
|
return exportValue;
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = async function docusaurusMdxLoader(fileString) {
|
module.exports = async function docusaurusMdxLoader(fileString) {
|
||||||
const callback = this.async();
|
const callback = this.async();
|
||||||
|
const filePath = this.resourcePath;
|
||||||
const reqOptions = this.getOptions() || {};
|
const reqOptions = this.getOptions() || {};
|
||||||
|
|
||||||
const {frontMatter, content: contentWithTitle} = parseFrontMatter(fileString);
|
const {frontMatter, content: contentWithTitle} = parseFrontMatter(fileString);
|
||||||
|
@ -51,8 +97,6 @@ module.exports = async function docusaurusMdxLoader(fileString) {
|
||||||
|
|
||||||
const hasFrontMatter = Object.keys(frontMatter).length > 0;
|
const hasFrontMatter = Object.keys(frontMatter).length > 0;
|
||||||
|
|
||||||
const filePath = this.resourcePath;
|
|
||||||
|
|
||||||
const options = {
|
const options = {
|
||||||
...reqOptions,
|
...reqOptions,
|
||||||
remarkPlugins: [
|
remarkPlugins: [
|
||||||
|
@ -80,6 +124,11 @@ module.exports = async function docusaurusMdxLoader(fileString) {
|
||||||
|
|
||||||
let exportStr = ``;
|
let exportStr = ``;
|
||||||
exportStr += `\nexport const frontMatter = ${stringifyObject(frontMatter)};`;
|
exportStr += `\nexport const frontMatter = ${stringifyObject(frontMatter)};`;
|
||||||
|
exportStr += `\nexport const frontMatterAssets = ${createFrontMatterAssetsExportCode(
|
||||||
|
filePath,
|
||||||
|
frontMatter,
|
||||||
|
reqOptions.frontMatterAssetKeys,
|
||||||
|
)};`;
|
||||||
exportStr += `\nexport const contentTitle = ${stringifyObject(
|
exportStr += `\nexport const contentTitle = ${stringifyObject(
|
||||||
contentTitle,
|
contentTitle,
|
||||||
)};`;
|
)};`;
|
||||||
|
|
|
@ -42,6 +42,12 @@ declare module '@theme/BlogPostPage' {
|
||||||
readonly hide_table_of_contents?: boolean;
|
readonly hide_table_of_contents?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type FrontMatterAssets = {
|
||||||
|
readonly image?: string;
|
||||||
|
readonly author_image_url?: string;
|
||||||
|
readonly authorImageURL?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type Metadata = {
|
export type Metadata = {
|
||||||
readonly title: string;
|
readonly title: string;
|
||||||
readonly date: string;
|
readonly date: string;
|
||||||
|
@ -61,6 +67,7 @@ declare module '@theme/BlogPostPage' {
|
||||||
|
|
||||||
export type Content = {
|
export type Content = {
|
||||||
readonly frontMatter: FrontMatter;
|
readonly frontMatter: FrontMatter;
|
||||||
|
readonly frontMatterAssets: FrontMatterAssets;
|
||||||
readonly metadata: Metadata;
|
readonly metadata: Metadata;
|
||||||
readonly toc: readonly TOCItem[];
|
readonly toc: readonly TOCItem[];
|
||||||
(): JSX.Element;
|
(): JSX.Element;
|
||||||
|
|
|
@ -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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -21,7 +21,7 @@ export type BlogPostFrontMatter = {
|
||||||
tags?: (string | Tag)[];
|
tags?: (string | Tag)[];
|
||||||
slug?: string;
|
slug?: string;
|
||||||
draft?: boolean;
|
draft?: boolean;
|
||||||
date?: Date;
|
date?: Date | string; // Yaml automagically convert some string patterns as Date, but not all
|
||||||
|
|
||||||
author?: string;
|
author?: string;
|
||||||
author_title?: string;
|
author_title?: string;
|
||||||
|
|
|
@ -10,11 +10,10 @@ import chalk from 'chalk';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import readingTime from 'reading-time';
|
import readingTime from 'reading-time';
|
||||||
import {Feed} from 'feed';
|
import {Feed} from 'feed';
|
||||||
import {keyBy, mapValues} from 'lodash';
|
import {compact, keyBy, mapValues} from 'lodash';
|
||||||
import {
|
import {
|
||||||
PluginOptions,
|
PluginOptions,
|
||||||
BlogPost,
|
BlogPost,
|
||||||
DateLink,
|
|
||||||
BlogContentPaths,
|
BlogContentPaths,
|
||||||
BlogMarkdownLoaderOptions,
|
BlogMarkdownLoaderOptions,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
@ -44,15 +43,40 @@ export function getSourceToPermalink(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// YYYY-MM-DD-{name}.mdx?
|
const DATE_FILENAME_REGEX = /^(?<date>\d{4}[-\/]\d{1,2}[-\/]\d{1,2})[-\/]?(?<text>.*?)(\/index)?.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?$/;
|
|
||||||
|
|
||||||
function toUrl({date, link}: DateLink) {
|
type ParsedBlogFileName = {
|
||||||
return `${date
|
date: Date | undefined;
|
||||||
.toISOString()
|
text: string;
|
||||||
.substring(0, '2019-01-01'.length)
|
slug: string;
|
||||||
.replace(/-/g, '/')}/${link}`;
|
};
|
||||||
|
|
||||||
|
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 {
|
function formatBlogPostDate(locale: string, date: Date): string {
|
||||||
|
@ -120,153 +144,166 @@ export async function generateBlogFeed(
|
||||||
return feed;
|
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(
|
export async function generateBlogPosts(
|
||||||
contentPaths: BlogContentPaths,
|
contentPaths: BlogContentPaths,
|
||||||
{siteConfig, siteDir, i18n}: LoadContext,
|
context: LoadContext,
|
||||||
options: PluginOptions,
|
options: PluginOptions,
|
||||||
): Promise<BlogPost[]> {
|
): Promise<BlogPost[]> {
|
||||||
const {
|
const {include, exclude} = options;
|
||||||
include,
|
|
||||||
exclude,
|
|
||||||
routeBasePath,
|
|
||||||
truncateMarker,
|
|
||||||
showReadingTime,
|
|
||||||
editUrl,
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
if (!fs.existsSync(contentPaths.contentPath)) {
|
if (!fs.existsSync(contentPaths.contentPath)) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const {baseUrl = ''} = siteConfig;
|
|
||||||
const blogSourceFiles = await Globby(include, {
|
const blogSourceFiles = await Globby(include, {
|
||||||
cwd: contentPaths.contentPath,
|
cwd: contentPaths.contentPath,
|
||||||
ignore: exclude,
|
ignore: exclude,
|
||||||
});
|
});
|
||||||
|
|
||||||
const blogPosts: BlogPost[] = [];
|
const blogPosts: BlogPost[] = compact(
|
||||||
|
await Promise.all(
|
||||||
async function processBlogSourceFile(blogSourceFile: string) {
|
blogSourceFiles.map(async (blogSourceFile: string) => {
|
||||||
// Lookup in localized folder in priority
|
try {
|
||||||
const blogDirPath = await getFolderContainingFile(
|
return await processBlogSourceFile(
|
||||||
getContentPathList(contentPaths),
|
blogSourceFile,
|
||||||
blogSourceFile,
|
contentPaths,
|
||||||
);
|
context,
|
||||||
|
options,
|
||||||
const source = path.join(blogDirPath, blogSourceFile);
|
);
|
||||||
|
} catch (e) {
|
||||||
const {
|
console.error(
|
||||||
frontMatter: unsafeFrontMatter,
|
chalk.red(
|
||||||
content,
|
`Processing of blog source file failed for path "${blogSourceFile}"`,
|
||||||
contentTitle,
|
),
|
||||||
excerpt,
|
);
|
||||||
} = await parseMarkdownFile(source, {removeContentTitle: true});
|
throw e;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
blogPosts.sort(
|
blogPosts.sort(
|
||||||
|
|
|
@ -467,6 +467,12 @@ export default function pluginContentBlog(
|
||||||
// For blog posts a title in markdown is always removed
|
// For blog posts a title in markdown is always removed
|
||||||
// Blog posts title are rendered separately
|
// Blog posts title are rendered separately
|
||||||
removeContentTitle: true,
|
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',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
@ -32,7 +32,7 @@ export const DEFAULT_OPTIONS = {
|
||||||
blogSidebarCount: 5,
|
blogSidebarCount: 5,
|
||||||
blogSidebarTitle: 'Recent posts',
|
blogSidebarTitle: 'Recent posts',
|
||||||
postsPerPage: 10,
|
postsPerPage: 10,
|
||||||
include: ['*.md', '*.mdx'],
|
include: ['**/*.{md,mdx}'],
|
||||||
exclude: GlobExcludeDefault,
|
exclude: GlobExcludeDefault,
|
||||||
routeBasePath: 'blog',
|
routeBasePath: 'blog',
|
||||||
path: 'blog',
|
path: 'blog',
|
||||||
|
|
|
@ -20,11 +20,6 @@ export interface BlogContent {
|
||||||
blogTagsListPath: string | null;
|
blogTagsListPath: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DateLink {
|
|
||||||
date: Date;
|
|
||||||
link: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type FeedType = 'rss' | 'atom';
|
export type FeedType = 'rss' | 'atom';
|
||||||
|
|
||||||
export type EditUrlFunction = (editUrlParams: {
|
export type EditUrlFunction = (editUrlParams: {
|
||||||
|
|
|
@ -38,6 +38,7 @@ function BlogListPage(props: Props): JSX.Element {
|
||||||
<BlogPostItem
|
<BlogPostItem
|
||||||
key={BlogPostContent.metadata.permalink}
|
key={BlogPostContent.metadata.permalink}
|
||||||
frontMatter={BlogPostContent.frontMatter}
|
frontMatter={BlogPostContent.frontMatter}
|
||||||
|
frontMatterAssets={BlogPostContent.frontMatterAssets}
|
||||||
metadata={BlogPostContent.metadata}
|
metadata={BlogPostContent.metadata}
|
||||||
truncated={BlogPostContent.metadata.truncated}>
|
truncated={BlogPostContent.metadata.truncated}>
|
||||||
<BlogPostContent />
|
<BlogPostContent />
|
||||||
|
|
|
@ -43,6 +43,7 @@ function BlogPostItem(props: Props): JSX.Element {
|
||||||
const {
|
const {
|
||||||
children,
|
children,
|
||||||
frontMatter,
|
frontMatter,
|
||||||
|
frontMatterAssets,
|
||||||
metadata,
|
metadata,
|
||||||
truncated,
|
truncated,
|
||||||
isBlogPostPage = false,
|
isBlogPostPage = false,
|
||||||
|
@ -56,12 +57,17 @@ function BlogPostItem(props: Props): JSX.Element {
|
||||||
title,
|
title,
|
||||||
editUrl,
|
editUrl,
|
||||||
} = metadata;
|
} = metadata;
|
||||||
const {author, image, keywords} = frontMatter;
|
const {author, keywords} = frontMatter;
|
||||||
|
|
||||||
|
const image = frontMatterAssets.image ?? frontMatter.image;
|
||||||
|
|
||||||
const authorURL = frontMatter.author_url || frontMatter.authorURL;
|
const authorURL = frontMatter.author_url || frontMatter.authorURL;
|
||||||
const authorTitle = frontMatter.author_title || frontMatter.authorTitle;
|
const authorTitle = frontMatter.author_title || frontMatter.authorTitle;
|
||||||
const authorImageURL =
|
const authorImageURL =
|
||||||
frontMatter.author_image_url || frontMatter.authorImageURL;
|
frontMatterAssets.author_image_url ||
|
||||||
|
frontMatterAssets.authorImageURL ||
|
||||||
|
frontMatter.author_image_url ||
|
||||||
|
frontMatter.authorImageURL;
|
||||||
|
|
||||||
const renderPostHeader = () => {
|
const renderPostHeader = () => {
|
||||||
const TitleHeading = isBlogPostPage ? 'h1' : 'h2';
|
const TitleHeading = isBlogPostPage ? 'h1' : 'h2';
|
||||||
|
|
|
@ -14,7 +14,7 @@ import {ThemeClassNames} from '@docusaurus/theme-common';
|
||||||
|
|
||||||
function BlogPostPage(props: Props): JSX.Element {
|
function BlogPostPage(props: Props): JSX.Element {
|
||||||
const {content: BlogPostContents, sidebar} = props;
|
const {content: BlogPostContents, sidebar} = props;
|
||||||
const {frontMatter, metadata} = BlogPostContents;
|
const {frontMatter, frontMatterAssets, metadata} = BlogPostContents;
|
||||||
const {title, description, nextItem, prevItem} = metadata;
|
const {title, description, nextItem, prevItem} = metadata;
|
||||||
const {hide_table_of_contents: hideTableOfContents} = frontMatter;
|
const {hide_table_of_contents: hideTableOfContents} = frontMatter;
|
||||||
|
|
||||||
|
@ -32,6 +32,7 @@ function BlogPostPage(props: Props): JSX.Element {
|
||||||
}>
|
}>
|
||||||
<BlogPostItem
|
<BlogPostItem
|
||||||
frontMatter={frontMatter}
|
frontMatter={frontMatter}
|
||||||
|
frontMatterAssets={frontMatterAssets}
|
||||||
metadata={metadata}
|
metadata={metadata}
|
||||||
isBlogPostPage>
|
isBlogPostPage>
|
||||||
<BlogPostContents />
|
<BlogPostContents />
|
||||||
|
|
|
@ -71,6 +71,7 @@ function BlogTagsPostPage(props: Props): JSX.Element {
|
||||||
<BlogPostItem
|
<BlogPostItem
|
||||||
key={BlogPostContent.metadata.permalink}
|
key={BlogPostContent.metadata.permalink}
|
||||||
frontMatter={BlogPostContent.frontMatter}
|
frontMatter={BlogPostContent.frontMatter}
|
||||||
|
frontMatterAssets={BlogPostContent.frontMatterAssets}
|
||||||
metadata={BlogPostContent.metadata}
|
metadata={BlogPostContent.metadata}
|
||||||
truncated>
|
truncated>
|
||||||
<BlogPostContent />
|
<BlogPostContent />
|
||||||
|
|
|
@ -27,10 +27,15 @@ declare module '@theme/BlogListPaginator' {
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@theme/BlogPostItem' {
|
declare module '@theme/BlogPostItem' {
|
||||||
import type {FrontMatter, Metadata} from '@theme/BlogPostPage';
|
import type {
|
||||||
|
FrontMatter,
|
||||||
|
FrontMatterAssets,
|
||||||
|
Metadata,
|
||||||
|
} from '@theme/BlogPostPage';
|
||||||
|
|
||||||
export type Props = {
|
export type Props = {
|
||||||
readonly frontMatter: FrontMatter;
|
readonly frontMatter: FrontMatter;
|
||||||
|
readonly frontMatterAssets: FrontMatterAssets;
|
||||||
readonly metadata: Metadata;
|
readonly metadata: Metadata;
|
||||||
readonly truncated?: string | boolean;
|
readonly truncated?: string | boolean;
|
||||||
readonly isBlogPostPage?: boolean;
|
readonly isBlogPostPage?: boolean;
|
||||||
|
|
After Width: | Height: | Size: 3.9 KiB |
Before Width: | Height: | Size: 488 KiB After Width: | Height: | Size: 488 KiB |
Before Width: | Height: | Size: 200 KiB After Width: | Height: | Size: 200 KiB |
Before Width: | Height: | Size: 188 KiB After Width: | Height: | Size: 188 KiB |
Before Width: | Height: | Size: 728 KiB After Width: | Height: | Size: 728 KiB |
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 126 KiB |
|
@ -3,17 +3,17 @@ title: Announcing Docusaurus 2 Beta
|
||||||
author: Sébastien Lorber
|
author: Sébastien Lorber
|
||||||
authorTitle: Docusaurus maintainer
|
authorTitle: Docusaurus maintainer
|
||||||
authorURL: https://sebastienlorber.com
|
authorURL: https://sebastienlorber.com
|
||||||
authorImageURL: https://github.com/slorber.png
|
authorImageURL: ./img/author.jpeg
|
||||||
authorTwitter: sebastienlorber
|
authorTwitter: sebastienlorber
|
||||||
tags: [release, beta]
|
tags: [release, beta]
|
||||||
image: /img/blog/2021-05-12-announcing-docusaurus-two-beta/social-card.png
|
image: ./img/social-card.png
|
||||||
---
|
---
|
||||||
|
|
||||||
After a lengthy alpha stage in order to ensure feature parity and quality, we are excited to officially release the first **[Docusaurus 2 beta](https://github.com/facebook/docusaurus/releases/tag/v2.0.0-beta.0)**.
|
After a lengthy alpha stage in order to ensure feature parity and quality, we are excited to officially release the first **[Docusaurus 2 beta](https://github.com/facebook/docusaurus/releases/tag/v2.0.0-beta.0)**.
|
||||||
|
|
||||||
With the announcement of this beta, the team is even more confident that Docusaurus 2 is **ready for mainstream adoption**!
|
With the announcement of this beta, the team is even more confident that Docusaurus 2 is **ready for mainstream adoption**!
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
<!--truncate-->
|
<!--truncate-->
|
||||||
|
|
||||||
|
@ -23,13 +23,13 @@ With the announcement of this beta, the team is even more confident that Docusau
|
||||||
|
|
||||||
Docusaurus 2 is widely adopted and growing fast:
|
Docusaurus 2 is widely adopted and growing fast:
|
||||||
|
|
||||||
[](https://www.npmtrends.com/docusaurus-vs-@docusaurus/core)
|
[](https://www.npmtrends.com/docusaurus-vs-@docusaurus/core)
|
||||||
|
|
||||||
To get a fuller understanding of the quality of current Docusaurus 2 sites, our new [showcase](https://docusaurus.io/showcase) page allows you to filter Docusaurus sites by features, so you may get inspired by real-world production sites with a similar use-case as yours!
|
To get a fuller understanding of the quality of current Docusaurus 2 sites, our new [showcase](https://docusaurus.io/showcase) page allows you to filter Docusaurus sites by features, so you may get inspired by real-world production sites with a similar use-case as yours!
|
||||||
|
|
||||||
Don't miss our [favorite](https://docusaurus.io/showcase?tags=favorite) sites; they all stand out with something unique:
|
Don't miss our [favorite](https://docusaurus.io/showcase?tags=favorite) sites; they all stand out with something unique:
|
||||||
|
|
||||||
[](https://docusaurus.io/showcase?tags=favorite)
|
[](https://docusaurus.io/showcase?tags=favorite)
|
||||||
|
|
||||||
## Why was Docusaurus v2 in alpha for so long?
|
## Why was Docusaurus v2 in alpha for so long?
|
||||||
|
|
|
@ -86,7 +86,7 @@ module.exports = {
|
||||||
blogSidebarCount: 5,
|
blogSidebarCount: 5,
|
||||||
blogSidebarTitle: 'All our posts',
|
blogSidebarTitle: 'All our posts',
|
||||||
routeBasePath: 'blog',
|
routeBasePath: 'blog',
|
||||||
include: ['*.md', '*.mdx'],
|
include: ['**/*.{md,mdx}'],
|
||||||
exclude: [
|
exclude: [
|
||||||
'**/_*.{js,jsx,ts,tsx,md,mdx}',
|
'**/_*.{js,jsx,ts,tsx,md,mdx}',
|
||||||
'**/_*/**',
|
'**/_*/**',
|
||||||
|
@ -132,7 +132,7 @@ Accepted fields:
|
||||||
| `author_image_url` | `string` | `undefined` | The URL to the author's thumbnail image. |
|
| `author_image_url` | `string` | `undefined` | The URL to the author's thumbnail image. |
|
||||||
| `author_title` | `string` | `undefined` | A description of the author. |
|
| `author_title` | `string` | `undefined` | A description of the author. |
|
||||||
| `title` | `string` | Markdown title | The blog post title. |
|
| `title` | `string` | Markdown title | The blog post title. |
|
||||||
| `date` | `string` | File name or file creation time | The blog post creation date. If not specified, this could be extracted from the file name, e.g, `2021-04-15-blog-post.mdx`. Otherwise, it is the Markdown file creation time. |
|
| `date` | `string` | File name or file creation time | The blog post creation date. If not specified, this can be extracted from the file or folder name, e.g, `2021-04-15-blog-post.mdx`, `2021-04-15-blog-post/index.mdx`, `2021/04/15/blog-post.mdx`. Otherwise, it is the Markdown file creation time. |
|
||||||
| `tags` | `Tag[]` | `undefined` | A list of strings or objects of two string fields `label` and `permalink` to tag to your post. |
|
| `tags` | `Tag[]` | `undefined` | A list of strings or objects of two string fields `label` and `permalink` to tag to your post. |
|
||||||
| `draft` | `boolean` | `false` | A boolean flag to indicate that the blog post is work-in-progress and therefore should not be published yet. However, draft blog posts will be displayed during development. |
|
| `draft` | `boolean` | `false` | A boolean flag to indicate that the blog post is work-in-progress and therefore should not be published yet. However, draft blog posts will be displayed during development. |
|
||||||
| `hide_table_of_contents` | `boolean` | `false` | Whether to hide the table of contents to the right. |
|
| `hide_table_of_contents` | `boolean` | `false` | Whether to hide the table of contents to the right. |
|
||||||
|
|
|
@ -3,6 +3,14 @@ id: blog
|
||||||
title: Blog
|
title: Blog
|
||||||
---
|
---
|
||||||
|
|
||||||
|
The blog feature enables you to deploy in no time a full-featured blog.
|
||||||
|
|
||||||
|
:::info
|
||||||
|
|
||||||
|
Check the [Blog Plugin API Reference documentation](./api/plugins/plugin-content-blog.md) for an exhaustive list of options.
|
||||||
|
|
||||||
|
:::
|
||||||
|
|
||||||
## Initial setup {#initial-setup}
|
## Initial setup {#initial-setup}
|
||||||
|
|
||||||
To setup your site's blog, start by creating a `blog` directory.
|
To setup your site's blog, start by creating a `blog` directory.
|
||||||
|
@ -26,9 +34,9 @@ module.exports = {
|
||||||
|
|
||||||
## Adding posts {#adding-posts}
|
## Adding posts {#adding-posts}
|
||||||
|
|
||||||
To publish in the blog, create a file within the blog directory with a formatted name of `YYYY-MM-DD-my-blog-post-title.md`. The post date is extracted from the file name.
|
To publish in the blog, create a Markdown file within the blog directory.
|
||||||
|
|
||||||
For example, at `my-website/blog/2019-09-05-hello-docusaurus-v2.md`:
|
For example, create a file at `my-website/blog/2019-09-05-hello-docusaurus-v2.md`:
|
||||||
|
|
||||||
```yml
|
```yml
|
||||||
---
|
---
|
||||||
|
@ -51,6 +59,34 @@ This is my first post on Docusaurus 2.
|
||||||
A whole bunch of exploration to follow.
|
A whole bunch of exploration to follow.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
:::note
|
||||||
|
|
||||||
|
Docusaurus will extract a `YYYY-MM-DD` date from a file/folder name such as `YYYY-MM-DD-my-blog-post-title.md`.
|
||||||
|
|
||||||
|
This naming convention is optional, and you can provide the date as FrontMatter.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Example supported patterns</summary>
|
||||||
|
|
||||||
|
- `2021-05-28-my-blog-post-title.md`
|
||||||
|
- `2021-05-28-my-blog-post-title.mdx`
|
||||||
|
- `2021-05-28-my-blog-post-title/index.md`
|
||||||
|
- `2021-05-28/my-blog-post-title.md`
|
||||||
|
- `2021/05/28/my-blog-post-title.md`
|
||||||
|
- `2021/05-28-my-blog-post-title.md`
|
||||||
|
- `2021/05/28/my-blog-post-title/index.md`
|
||||||
|
- ...
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
:::
|
||||||
|
|
||||||
|
:::tip
|
||||||
|
|
||||||
|
Using a folder can be convenient to co-locate blog post images alongside the Markdown file.
|
||||||
|
|
||||||
|
:::
|
||||||
|
|
||||||
The only required field in the front matter is `title`; however, we provide options to add more metadata to your blog post, for example, author information. For all possible fields, see [the API documentation](api/plugins/plugin-content-blog.md#markdown-frontmatter).
|
The only required field in the front matter is `title`; however, we provide options to add more metadata to your blog post, for example, author information. For all possible fields, see [the API documentation](api/plugins/plugin-content-blog.md#markdown-frontmatter).
|
||||||
|
|
||||||
## Blog list {#blog-list}
|
## Blog list {#blog-list}
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
id: creating-pages
|
id: creating-pages
|
||||||
title: Creating Pages
|
title: Creating Pages
|
||||||
slug: /creating-pages
|
slug: /creating-pages
|
||||||
|
sidebar_label: Pages
|
||||||
---
|
---
|
||||||
|
|
||||||
In this section, we will learn about creating pages in Docusaurus.
|
In this section, we will learn about creating pages in Docusaurus.
|
||||||
|
@ -18,6 +19,12 @@ Pages do not have sidebars, only [docs](./docs/docs-introduction.md) do.
|
||||||
|
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
:::info
|
||||||
|
|
||||||
|
Check the [Pages Plugin API Reference documentation](./../api/plugins/plugin-content-pages.md) for an exhaustive list of options.
|
||||||
|
|
||||||
|
:::
|
||||||
|
|
||||||
## Add a React page {#add-a-react-page}
|
## Add a React page {#add-a-react-page}
|
||||||
|
|
||||||
Create a file `/src/pages/helloReact.js`:
|
Create a file `/src/pages/helloReact.js`:
|
||||||
|
|
|
@ -7,6 +7,12 @@ slug: /docs-introduction
|
||||||
|
|
||||||
The docs feature provides users with a way to organize Markdown files in a hierarchical format.
|
The docs feature provides users with a way to organize Markdown files in a hierarchical format.
|
||||||
|
|
||||||
|
:::info
|
||||||
|
|
||||||
|
Check the [Docs Plugin API Reference documentation](./../../api/plugins/plugin-content-docs.md) for an exhaustive list of options.
|
||||||
|
|
||||||
|
:::
|
||||||
|
|
||||||
## Document ID {#document-id}
|
## Document ID {#document-id}
|
||||||
|
|
||||||
Every document has a unique `id`. By default, a document `id` is the name of the document (without the extension) relative to the root docs directory.
|
Every document has a unique `id`. By default, a document `id` is the name of the document (without the extension) relative to the root docs directory.
|
||||||
|
|