From 3f55453b8ca57beeb64a42cd6bd21c0b4510914e Mon Sep 17 00:00:00 2001 From: Balthasar Hofer Date: Wed, 23 Nov 2022 21:19:29 +0100 Subject: [PATCH] fix(mdx-loader): support nested admonitions #8303 --- .../__tests__/__fixtures__/nesting.md | 10 ++ .../__snapshots__/index.test.ts.snap | 5 + .../admonitions/__tests__/index.test.ts | 5 + .../src/remark/admonitions/index.ts | 48 ++++++- .../_pages tests/markdownPageTests.md | 28 ++++ .../markdown-features-admonitions.mdx | 129 +++++++++++++++++- 6 files changed, 217 insertions(+), 8 deletions(-) create mode 100644 packages/docusaurus-mdx-loader/src/remark/admonitions/__tests__/__fixtures__/nesting.md diff --git a/packages/docusaurus-mdx-loader/src/remark/admonitions/__tests__/__fixtures__/nesting.md b/packages/docusaurus-mdx-loader/src/remark/admonitions/__tests__/__fixtures__/nesting.md new file mode 100644 index 0000000000..04cb34755c --- /dev/null +++ b/packages/docusaurus-mdx-loader/src/remark/admonitions/__tests__/__fixtures__/nesting.md @@ -0,0 +1,10 @@ +Test nested Admonitions + +::::info **Weather** +On nice days, you can enjoy skiing in the mountains. + +:::danger *Storms* +Take care of snowstorms... +::: + +:::: \ No newline at end of file diff --git a/packages/docusaurus-mdx-loader/src/remark/admonitions/__tests__/__snapshots__/index.test.ts.snap b/packages/docusaurus-mdx-loader/src/remark/admonitions/__tests__/__snapshots__/index.test.ts.snap index 227eb28b33..d02da45a66 100644 --- a/packages/docusaurus-mdx-loader/src/remark/admonitions/__tests__/__snapshots__/index.test.ts.snap +++ b/packages/docusaurus-mdx-loader/src/remark/admonitions/__tests__/__snapshots__/index.test.ts.snap @@ -42,3 +42,8 @@ exports[`admonitions remark plugin interpolation 1`] = ` "

Test admonition with interpolated title/body

My interpolated title <button style={{color: "red"}} onClick={() => alert("click")}>test

body interpolated content

" `; + +exports[`admonitions remark plugin nesting 1`] = ` +"

Test nested Admonitions

+Weather

On nice days, you can enjoy skiing in the mountains.

Storms

Take care of snowstorms...

" +`; diff --git a/packages/docusaurus-mdx-loader/src/remark/admonitions/__tests__/index.test.ts b/packages/docusaurus-mdx-loader/src/remark/admonitions/__tests__/index.test.ts index 3794562bb1..ee59edf894 100644 --- a/packages/docusaurus-mdx-loader/src/remark/admonitions/__tests__/index.test.ts +++ b/packages/docusaurus-mdx-loader/src/remark/admonitions/__tests__/index.test.ts @@ -50,4 +50,9 @@ describe('admonitions remark plugin', () => { const result = await processFixture('interpolation'); expect(result).toMatchSnapshot(); }); + + it('nesting', async () => { + const result = await processFixture('nesting'); + expect(result).toMatchSnapshot(); + }); }); diff --git a/packages/docusaurus-mdx-loader/src/remark/admonitions/index.ts b/packages/docusaurus-mdx-loader/src/remark/admonitions/index.ts index d6393b8c45..d7efad2a81 100644 --- a/packages/docusaurus-mdx-loader/src/remark/admonitions/index.ts +++ b/packages/docusaurus-mdx-loader/src/remark/admonitions/index.ts @@ -52,9 +52,20 @@ const plugin: Plugin = function plugin( const options = normalizeOptions(optionsInput); const keywords = Object.values(options.keywords).map(escapeRegExp).join('|'); + const nestingChar = escapeRegExp(options.tag.slice(0, 1)); const tag = escapeRegExp(options.tag); - const regex = new RegExp(`${tag}(${keywords})(?: *(.*))?\n`); - const escapeTag = new RegExp(escapeRegExp(`\\${options.tag}`), 'g'); + + // resolve th nesting level of an opening tag + // ::: -> 0, :::: -> 1, ::::: -> 2 ... + const nestingLevelRegex = new RegExp( + `^${tag}(?${nestingChar}*)`, + ); + + const regex = new RegExp(`${tag}${nestingChar}*(${keywords})(?: *(.*))?\n`); + const escapeTag = new RegExp( + escapeRegExp(`\\${options.tag}${options.tag.slice(0, 1)}*`), + 'g', + ); // The tokenizer is called on blocks to determine if there is an admonition // present and create tags for it @@ -77,6 +88,11 @@ const plugin: Plugin = function plugin( ]; const food = []; const content = []; + // get the nesting level of the opening tag + const openingLevel = + nestingLevelRegex.exec(opening)!.groups!.nestingLevel!.length; + // used as a stack to keep track of nested admonitions + const nestingLevels: number[] = [openingLevel]; let newValue = value; // consume lines until a closing tag @@ -88,12 +104,32 @@ const plugin: Plugin = function plugin( next !== -1 ? newValue.slice(idx + 1, next) : newValue.slice(idx + 1); food.push(line); newValue = newValue.slice(idx + 1); - // the closing tag is NOT part of the content - if (line.startsWith(options.tag)) { - break; + const nesting = nestingLevelRegex.exec(line); + idx = newValue.indexOf(NEWLINE); + if (!nesting) { + content.push(line); + continue; + } + const tagLevel = nesting.groups!.nestingLevel!.length; + // first level + if (nestingLevels.length === 0) { + nestingLevels.push(tagLevel); + content.push(line); + continue; + } + const currentLevel = nestingLevels[nestingLevels.length - 1]!; + if (tagLevel < currentLevel) { + // entering a nested admonition block + nestingLevels.push(tagLevel); + } else if (tagLevel === currentLevel) { + // closing a nested admonition block + nestingLevels.pop(); + // the closing tag is NOT part of the content + if (nestingLevels.length === 0) { + break; + } } content.push(line); - idx = newValue.indexOf(NEWLINE); } // consume the processed tag and replace escape sequences diff --git a/website/_dogfooding/_pages tests/markdownPageTests.md b/website/_dogfooding/_pages tests/markdownPageTests.md index 323c741ee3..ce16704134 100644 --- a/website/_dogfooding/_pages tests/markdownPageTests.md +++ b/website/_dogfooding/_pages tests/markdownPageTests.md @@ -239,3 +239,31 @@ Can be arbitrarily nested: Admonition body ::: + +:::important + +Admonition alias `:::important` should have Important title + +::: + +:::::note title + +Some **content** with _Markdown_ `syntax`. + +::::note nested Title + +:::tip very nested Title + +Some **content** with _Markdown_ `syntax`. + +::: + +Some **content** with _Markdown_ `syntax`. + +:::: + +hey + +::::: + +after admonition diff --git a/website/docs/guides/markdown-features/markdown-features-admonitions.mdx b/website/docs/guides/markdown-features/markdown-features-admonitions.mdx index 719dd371c9..bf95ccbf70 100644 --- a/website/docs/guides/markdown-features/markdown-features-admonitions.mdx +++ b/website/docs/guides/markdown-features/markdown-features-admonitions.mdx @@ -11,7 +11,7 @@ import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; import Admonition from '@theme/Admonition'; -In addition to the basic Markdown syntax, we use [remark-admonitions](https://github.com/elviswolcott/remark-admonitions) alongside MDX to add support for admonitions. Admonitions are wrapped by a set of 3 colons. +In addition to the basic Markdown syntax, we have a special admonitions syntax by wrapping text with a set of 3 colons, followed by a label denoting its type. Example: @@ -107,7 +107,7 @@ Hello world ## Specifying title {#specifying-title} -You may also specify an optional title +You may also specify an optional title. ```md :::note Your Title @@ -204,3 +204,128 @@ The types that are accepted are the same as above: `note`, `tip`, `danger`, `inf ``` + +## Customizing admonitions {#customizing-admonitions} + +There are two kinds of customizations possible with admonitions: **parsing** and **rendering**. + +### Customizing rendering behavior {#customizing-rendering-behavior} + +You can customize how each individual admonition type is rendered through [swizzling](../../swizzling.md). You can often achieve your goal through a simple wrapper. For example, in the follow example, we swap out the icon for `info` admonitions only. + +```jsx title="src/theme/Admonition.js" +import React from 'react'; +import Admonition from '@theme-original/Admonition'; +import MyCustomNoteIcon from '@site/static/img/info.svg'; + +export default function AdmonitionWrapper(props) { + if (props.type !== 'info') { + return ; + } + return } {...props} />; +} +``` + +### Customizing parsing behavior {#customizing-parsing-behavior} + +Admonitions are implemented with a [Remark plugin](./markdown-features-plugins.mdx). The plugin is designed to be configurable. To customize the Remark plugin for a specific content plugin (docs, blog, pages), pass the options through the `admonitions` key. + +```js title="docusaurus.config.js" +module.exports = { + presets: [ + [ + '@docusaurus/preset-classic', + { + docs: { + admonitions: { + tag: ':::', + keywords: ['note', 'tip', 'info', 'caution', 'danger'], + extendDefaults: true, + }, + }, + }, + ], + ], +}; +``` + +The plugin accepts the following options: + +- `tag`: The tag that encloses the admonition. Defaults to `:::`. +- `keywords`: An array of keywords that can be used as the type for the admonition. +- `extendDefaults`: Should the provided options (such as `keywords`) be merged into the existing defaults. Defaults to `false`. + +The `keyword` will be passed as the `type` prop of the `Admonition` component. + +### Custom admonition type components {#custom-admonition-type-components} + +By default, the theme doesn't know what do to with custom admonition keywords such as `:::my-custom-admonition`. It is your responsibility to map each admonition keyword to a React component so that the theme knows how to render them. + +If you registered a new admonition type `my-custom-admonition` via the following config: + +```js title="docusaurus.config.js" +module.exports = { + // ... + presets: [ + [ + 'classic', + { + // ... + docs: { + admonitions: { + tag: ':::', + keywords: ['my-custom-admonition'], + extendDefaults: true, + }, + }, + }, + ], + ], +}; +``` + +You can provide the corresponding React component for `:::my-custom-admonition` by creating the following file (unfortunately, since it's not a React component file, it's not swizzlable): + +```js title="src/theme/Admonition/Types.js" +import React from 'react'; +import DefaultAdmonitionTypes from '@theme-original/Admonition/Types'; + +function MyCustomAdmonition(props) { + return ( +
+
{props.title}
+
{props.children}
+
+ ); +} + +const AdmonitionTypes = { + ...DefaultAdmonitionTypes, + + // Add all your custom admonition types here... + // You can also override the default ones if you want + 'my-custom-admonition': MyCustomAdmonition, +}; + +export default AdmonitionTypes; +``` + +Now you can use your new admonition keyword in a Markdown file, and it will be parsed and rendered with your custom logic: + +```md +:::my-custom-admonition Custom Admonition + +It works! + +::: +``` + + + +:::my-custom-admonition Custom Admonition + +It works! + +::: + +