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

@ -19,6 +19,12 @@ const toc = require('./remark/toc');
const unwrapMdxCodeBlocks = require('./remark/unwrapMdxCodeBlocks');
const transformImage = require('./remark/transformImage');
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 = {
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) {
const callback = this.async();
const filePath = this.resourcePath;
const reqOptions = this.getOptions() || {};
const {frontMatter, content: contentWithTitle} = parseFrontMatter(fileString);
@ -51,8 +97,6 @@ module.exports = async function docusaurusMdxLoader(fileString) {
const hasFrontMatter = Object.keys(frontMatter).length > 0;
const filePath = this.resourcePath;
const options = {
...reqOptions,
remarkPlugins: [
@ -80,6 +124,11 @@ module.exports = async function docusaurusMdxLoader(fileString) {
let exportStr = ``;
exportStr += `\nexport const frontMatter = ${stringifyObject(frontMatter)};`;
exportStr += `\nexport const frontMatterAssets = ${createFrontMatterAssetsExportCode(
filePath,
frontMatter,
reqOptions.frontMatterAssetKeys,
)};`;
exportStr += `\nexport const contentTitle = ${stringifyObject(
contentTitle,
)};`;

View file

@ -42,6 +42,12 @@ declare module '@theme/BlogPostPage' {
readonly hide_table_of_contents?: boolean;
};
export type FrontMatterAssets = {
readonly image?: string;
readonly author_image_url?: string;
readonly authorImageURL?: string;
};
export type Metadata = {
readonly title: string;
readonly date: string;
@ -61,6 +67,7 @@ declare module '@theme/BlogPostPage' {
export type Content = {
readonly frontMatter: FrontMatter;
readonly frontMatterAssets: FrontMatterAssets;
readonly metadata: Metadata;
readonly toc: readonly TOCItem[];
(): JSX.Element;

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,97 +144,87 @@ export async function generateBlogFeed(
return feed;
}
export async function generateBlogPosts(
contentPaths: BlogContentPaths,
{siteConfig, siteDir, i18n}: LoadContext,
options: PluginOptions,
): Promise<BlogPost[]> {
const {
include,
exclude,
routeBasePath,
truncateMarker,
showReadingTime,
editUrl,
} = options;
if (!fs.existsSync(contentPaths.contentPath)) {
return [];
async function parseBlogPostMarkdownFile(blogSourceAbsolute: string) {
const result = await parseMarkdownFile(blogSourceAbsolute, {
removeContentTitle: true,
});
return {
...result,
frontMatter: validateBlogPostFrontMatter(result.frontMatter),
};
}
const {baseUrl = ''} = siteConfig;
const blogSourceFiles = await Globby(include, {
cwd: contentPaths.contentPath,
ignore: exclude,
});
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;
const blogPosts: BlogPost[] = [];
async function processBlogSourceFile(blogSourceFile: string) {
// Lookup in localized folder in priority
const blogDirPath = await getFolderContainingFile(
getContentPathList(contentPaths),
blogSourceFile,
blogSourceRelative,
);
const source = path.join(blogDirPath, blogSourceFile);
const blogSourceAbsolute = path.join(blogDirPath, blogSourceRelative);
const {
frontMatter: unsafeFrontMatter,
frontMatter,
content,
contentTitle,
excerpt,
} = await parseMarkdownFile(source, {removeContentTitle: true});
const frontMatter = validateBlogPostFrontMatter(unsafeFrontMatter);
} = await parseBlogPostMarkdownFile(blogSourceAbsolute);
const aliasedSource = aliasedSitePath(source, siteDir);
const blogFileName = path.basename(blogSourceFile);
const aliasedSource = aliasedSitePath(blogSourceAbsolute, siteDir);
if (frontMatter.draft && process.env.NODE_ENV === 'production') {
return;
return undefined;
}
if (frontMatter.id) {
console.warn(
chalk.yellow(
`"id" header option is deprecated in ${blogFileName} file. Please use "slug" option instead.`,
`"id" header option is deprecated in ${blogSourceRelative} 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;
}
const parsedBlogFileName = parseBlogFileName(blogSourceRelative);
async function getDate(): Promise<Date> {
// Prefer user-defined date.
if (frontMatter.date) {
date = new Date(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;
}
}
// Use file create time for blog.
date = date ?? (await fs.stat(source)).birthtime;
const date = await getDate();
const formattedDate = formatBlogPostDate(i18n.currentLocale, date);
const title = frontMatter.title ?? contentTitle ?? linkName;
const title = frontMatter.title ?? contentTitle ?? parsedBlogFileName.text;
const description = frontMatter.description ?? excerpt ?? '';
const slug =
frontMatter.slug ||
(dateFilenameMatch ? toUrl({date, link: linkName}) : linkName);
const slug = frontMatter.slug || parsedBlogFileName.slug;
const permalink = normalizeUrl([baseUrl, routeBasePath, slug]);
function getBlogEditUrl() {
const blogPathRelative = path.relative(blogDirPath, path.resolve(source));
const blogPathRelative = path.relative(
blogDirPath,
path.resolve(blogSourceAbsolute),
);
if (typeof editUrl === 'function') {
return editUrl({
@ -237,7 +251,7 @@ export async function generateBlogPosts(
}
}
blogPosts.push({
return {
id: frontMatter.slug ?? title,
metadata: {
permalink,
@ -251,13 +265,35 @@ export async function generateBlogPosts(
readingTime: showReadingTime ? readingTime(content).minutes : undefined,
truncated: truncateMarker?.test(content) || false,
},
});
};
}
export async function generateBlogPosts(
contentPaths: BlogContentPaths,
context: LoadContext,
options: PluginOptions,
): Promise<BlogPost[]> {
const {include, exclude} = options;
if (!fs.existsSync(contentPaths.contentPath)) {
return [];
}
const blogSourceFiles = await Globby(include, {
cwd: contentPaths.contentPath,
ignore: exclude,
});
const blogPosts: BlogPost[] = compact(
await Promise.all(
blogSourceFiles.map(async (blogSourceFile: string) => {
try {
return await processBlogSourceFile(blogSourceFile);
return await processBlogSourceFile(
blogSourceFile,
contentPaths,
context,
options,
);
} catch (e) {
console.error(
chalk.red(
@ -267,6 +303,7 @@ export async function generateBlogPosts(
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: {

View file

@ -38,6 +38,7 @@ function BlogListPage(props: Props): JSX.Element {
<BlogPostItem
key={BlogPostContent.metadata.permalink}
frontMatter={BlogPostContent.frontMatter}
frontMatterAssets={BlogPostContent.frontMatterAssets}
metadata={BlogPostContent.metadata}
truncated={BlogPostContent.metadata.truncated}>
<BlogPostContent />

View file

@ -43,6 +43,7 @@ function BlogPostItem(props: Props): JSX.Element {
const {
children,
frontMatter,
frontMatterAssets,
metadata,
truncated,
isBlogPostPage = false,
@ -56,12 +57,17 @@ function BlogPostItem(props: Props): JSX.Element {
title,
editUrl,
} = metadata;
const {author, image, keywords} = frontMatter;
const {author, keywords} = frontMatter;
const image = frontMatterAssets.image ?? frontMatter.image;
const authorURL = frontMatter.author_url || frontMatter.authorURL;
const authorTitle = frontMatter.author_title || frontMatter.authorTitle;
const authorImageURL =
frontMatter.author_image_url || frontMatter.authorImageURL;
frontMatterAssets.author_image_url ||
frontMatterAssets.authorImageURL ||
frontMatter.author_image_url ||
frontMatter.authorImageURL;
const renderPostHeader = () => {
const TitleHeading = isBlogPostPage ? 'h1' : 'h2';

View file

@ -14,7 +14,7 @@ import {ThemeClassNames} from '@docusaurus/theme-common';
function BlogPostPage(props: Props): JSX.Element {
const {content: BlogPostContents, sidebar} = props;
const {frontMatter, metadata} = BlogPostContents;
const {frontMatter, frontMatterAssets, metadata} = BlogPostContents;
const {title, description, nextItem, prevItem} = metadata;
const {hide_table_of_contents: hideTableOfContents} = frontMatter;
@ -32,6 +32,7 @@ function BlogPostPage(props: Props): JSX.Element {
}>
<BlogPostItem
frontMatter={frontMatter}
frontMatterAssets={frontMatterAssets}
metadata={metadata}
isBlogPostPage>
<BlogPostContents />

View file

@ -71,6 +71,7 @@ function BlogTagsPostPage(props: Props): JSX.Element {
<BlogPostItem
key={BlogPostContent.metadata.permalink}
frontMatter={BlogPostContent.frontMatter}
frontMatterAssets={BlogPostContent.frontMatterAssets}
metadata={BlogPostContent.metadata}
truncated>
<BlogPostContent />

View file

@ -27,10 +27,15 @@ declare module '@theme/BlogListPaginator' {
}
declare module '@theme/BlogPostItem' {
import type {FrontMatter, Metadata} from '@theme/BlogPostPage';
import type {
FrontMatter,
FrontMatterAssets,
Metadata,
} from '@theme/BlogPostPage';
export type Props = {
readonly frontMatter: FrontMatter;
readonly frontMatterAssets: FrontMatterAssets;
readonly metadata: Metadata;
readonly truncated?: string | boolean;
readonly isBlogPostPage?: boolean;

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

View file

@ -3,17 +3,17 @@ title: Announcing Docusaurus 2 Beta
author: Sébastien Lorber
authorTitle: Docusaurus maintainer
authorURL: https://sebastienlorber.com
authorImageURL: https://github.com/slorber.png
authorImageURL: ./img/author.jpeg
authorTwitter: sebastienlorber
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)**.
With the announcement of this beta, the team is even more confident that Docusaurus 2 is **ready for mainstream adoption**!
![Docusaurus beta party](/img/blog/2021-05-12-announcing-docusaurus-two-beta/image_cropped.png)
![Docusaurus beta party](./img/image_cropped.png)
<!--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 growth](/img/blog/2021-05-12-announcing-docusaurus-two-beta/trend.png)](https://www.npmtrends.com/docusaurus-vs-@docusaurus/core)
[![Docusaurus growth](./img/trend.png)](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!
Don't miss our [favorite](https://docusaurus.io/showcase?tags=favorite) sites; they all stand out with something unique:
[![Docusaurus growth](/img/blog/2021-05-12-announcing-docusaurus-two-beta/favorites.png)](https://docusaurus.io/showcase?tags=favorite)
[![Docusaurus growth](./img/favorites.png)](https://docusaurus.io/showcase?tags=favorite)
## Why was Docusaurus v2 in alpha for so long?

View file

@ -86,7 +86,7 @@ module.exports = {
blogSidebarCount: 5,
blogSidebarTitle: 'All our posts',
routeBasePath: 'blog',
include: ['*.md', '*.mdx'],
include: ['**/*.{md,mdx}'],
exclude: [
'**/_*.{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_title` | `string` | `undefined` | A description of the author. |
| `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. |
| `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. |

View file

@ -3,6 +3,14 @@ id: 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}
To setup your site's blog, start by creating a `blog` directory.
@ -26,9 +34,9 @@ module.exports = {
## 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
---
@ -51,6 +59,34 @@ This is my first post on Docusaurus 2.
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).
## Blog list {#blog-list}

View file

@ -2,6 +2,7 @@
id: creating-pages
title: Creating Pages
slug: /creating-pages
sidebar_label: Pages
---
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}
Create a file `/src/pages/helloReact.js`:

View file

@ -7,6 +7,12 @@ slug: /docs-introduction
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}
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.