Skip to content

Commit

Permalink
[MDX] Extend Markdown plugin config, with customization options (#4504)
Browse files Browse the repository at this point in the history
* test: new combined remark / rehype suite

* fix: use with-plugins fixture

* chore: remove old mdx plugin tests

* docs: add JS docs

* docs: update README with thorough example

* chore: changeset

* fix: add "extends" error message

* fix: ignore string-based plugins in md

* feat: add warning log for string plugins

* docs: highlight `extendPlugins`

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>

* nit: highlight "extendPlugins"

* fix: md plugins type check

* chore: "defaults" -> "astroDefaults"

* nit: info log when inheriting markdown plugins

* refactor: one big log on new behavior

* dan: dan nit

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>
  • Loading branch information
bholmesdev and sarah11918 committed Aug 30, 2022
1 parent e905784 commit 8f8dff4
Show file tree
Hide file tree
Showing 14 changed files with 435 additions and 254 deletions.
5 changes: 5 additions & 0 deletions .changeset/ten-walls-listen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@astrojs/mdx': minor
---

Introduce new `extendPlugins` configuration option. This defaults to inheriting all remark and rehype plugins from your `markdown` config, with options to use either Astro's defaults or no inheritance at all.
81 changes: 64 additions & 17 deletions packages/integrations/mdx/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -354,55 +354,102 @@ export default {
### remarkPlugins
**Default plugins:** [remark-gfm](https://github.com/remarkjs/remark-gfm), [remark-smartypants](https://github.com/silvenon/remark-smartypants)
[Remark plugins](https://github.com/remarkjs/remark/blob/main/doc/plugins.md) allow you to extend your Markdown with new capabilities. This includes [auto-generating a table of contents](https://github.com/remarkjs/remark-toc), [applying accessible emoji labels](https://github.com/florianeckerstorfer/remark-a11y-emoji), and more. We encourage you to browse [awesome-remark](https://github.com/remarkjs/awesome-remark) for a full curated list!
We apply [GitHub-flavored Markdown](https://github.com/remarkjs/remark-gfm) and [Smartypants](https://github.com/silvenon/remark-smartypants) by default. This brings some niceties like auto-generating clickable links from text (ex. `https://example.com`) and formatting quotes for readability. When applying your own plugins, you can choose to preserve or remove these defaults.

To apply plugins _while preserving_ Astro's default plugins, use a nested `extends` object like so:
This example applies the [`remark-toc`](https://github.com/remarkjs/remark-toc) plugin to `.mdx` files. To customize plugin inheritance from your Markdown config or Astro's defaults, [see the `extendPlugins` option](#extendPlugins).
```js
// astro.config.mjs
import remarkToc from 'remark-toc';

export default {
integrations: [mdx({
// apply remark-toc alongside GitHub-flavored markdown and Smartypants
remarkPlugins: { extends: [remarkToc] },
remarkPlugins: [remarkToc],
})],
}
```
To apply plugins _without_ Astro's defaults, you can apply a plain array:
### rehypePlugins
[Rehype plugins](https://github.com/rehypejs/rehype/blob/main/doc/plugins.md) allow you to transform the HTML that your Markdown generates. We encourage you to browse [awesome-rehype](https://github.com/rehypejs/awesome-rehype) for a full curated list of plugins!
We apply our own (non-removable) [`collect-headings`](https://github.com/withastro/astro/blob/main/packages/integrations/mdx/src/rehype-collect-headings.ts) plugin. This applies IDs to all headings (i.e. `h1 -> h6`) in your MDX files to [link to headings via anchor tags](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#linking_to_an_element_on_the_same_page).
This example applies the [`rehype-minify`](https://github.com/rehypejs/rehype-minify) plugin to `.mdx` files. To customize plugin inheritance from your Markdown config or Astro's defaults, [see the `extendPlugins` option](#extendPlugins).
```js
// astro.config.mjs
import remarkToc from 'remark-toc';
import rehypeMinifyHtml from 'rehype-minify';

export default {
integrations: [mdx({
// apply remark-toc alone, removing other defaults
remarkPlugins: [remarkToc],
rehypePlugins: [rehypeMinifyHtml],
})],
}
```
### rehypePlugins
### extendPlugins
[Rehype plugins](https://github.com/rehypejs/rehype/blob/main/doc/plugins.md) allow you to transform the HTML that your Markdown generates. We recommend checking the [Remark plugin](https://github.com/remarkjs/remark/blob/main/doc/plugins.md) catalog first _before_ considering rehype plugins, since most users want to transform their Markdown syntax instead. If HTML transforms are what you need, we encourage you to browse [awesome-rehype](https://github.com/rehypejs/awesome-rehype) for a full curated list of plugins!
**Type:** `'markdown' | 'astroDefaults' | false`
We apply our own (non-overridable) [`collect-headings`](https://github.com/withastro/astro/blob/main/packages/integrations/mdx/src/rehype-collect-headings.ts) plugin. This applies IDs to all headings (i.e. `h1 -> h6`) in your MDX files to [link to headings via anchor tags](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#linking_to_an_element_on_the_same_page).
**Default:** `'markdown'`
To apply additional rehype plugins, pass an array to the `rehypePlugins` option like so:
#### `markdown` (default)
By default, Astro inherits all [remark](#remarkPlugins) and [rehype](#rehypePlugins) plugins from [the `markdown` option in your Astro config](https://docs.astro.build/en/guides/markdown-content/#markdown-plugins). This also respects the [`markdown.extendDefaultPlugins`](https://docs.astro.build/en/reference/configuration-reference/#markdownextenddefaultplugins) option to extend Astro's defaults. Any additional plugins you apply in your MDX config will be applied _after_ your configured Markdown plugins.
This example applies [`remark-toc`](https://github.com/remarkjs/remark-toc) to Markdown _and_ MDX, and [`rehype-minify`](https://github.com/rehypejs/rehype-minify) to MDX alone:
```js
// astro.config.mjs
import rehypeMinifyHtml from 'rehype-minify';
import remarkToc from 'remark-toc';
import rehypeMinify from 'rehype-minify';

export default {
markdown: {
// Applied to .md and .mdx files
remarkPlugins: [remarkToc],
},
integrations: [mdx({
rehypePlugins: [rehypeMinifyHtml],
// Applied to .mdx files only
rehypePlugins: [rehypeMinify],
})],
}
```
#### `astroDefaults`
You may _only_ want to extend [Astro's default plugins](https://docs.astro.build/en/reference/configuration-reference/#markdownextenddefaultplugins) without inheriting your Markdown config. This example will apply the default [GitHub-Flavored Markdown](https://github.com/remarkjs/remark-gfm) and [Smartypants](https://github.com/silvenon/remark-smartypants) plugins alongside [`remark-toc`](https://github.com/remarkjs/remark-toc):
```js "extendPlugins: 'astroDefaults'"
// astro.config.mjs
import remarkToc from 'remark-toc';

export default {
markdown: {
remarkPlugins: [/** ignored */]
},
integrations: [mdx({
remarkPlugins: [remarkToc],
// Astro defaults applied
extendPlugins: 'astroDefaults',
})],
}
```
#### `false`
If you don't want to extend any plugins, set `extendPlugins` to `false`:
```js "extendPlugins: false"
// astro.config.mjs
import remarkToc from 'remark-toc';

export default {
integrations: [mdx({
remarkPlugins: [remarkToc],
// Astro defaults not applied
extendPlugins: false,
})],
}
```
Expand Down
1 change: 1 addition & 0 deletions packages/integrations/mdx/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"es-module-lexer": "^0.10.5",
"github-slugger": "^1.4.0",
"gray-matter": "^4.0.3",
"kleur": "^4.1.4",
"rehype-raw": "^6.1.1",
"remark-frontmatter": "^4.0.1",
"remark-gfm": "^3.0.1",
Expand Down
94 changes: 32 additions & 62 deletions packages/integrations/mdx/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,82 +1,52 @@
import { compile as mdxCompile, nodeTypes } from '@mdx-js/mdx';
import { compile as mdxCompile } from '@mdx-js/mdx';
import mdxPlugin, { Options as MdxRollupPluginOptions } from '@mdx-js/rollup';
import type { AstroConfig, AstroIntegration } from 'astro';
import type { AstroIntegration } from 'astro';
import { parse as parseESM } from 'es-module-lexer';
import rehypeRaw from 'rehype-raw';
import remarkGfm from 'remark-gfm';
import remarkSmartypants from 'remark-smartypants';
import { VFile } from 'vfile';
import type { Plugin as VitePlugin } from 'vite';
import { rehypeApplyFrontmatterExport, remarkInitializeAstroData } from './astro-data-utils.js';
import rehypeCollectHeadings from './rehype-collect-headings.js';
import remarkPrism from './remark-prism.js';
import remarkShiki from './remark-shiki.js';
import { getFileInfo, parseFrontmatter } from './utils.js';

type WithExtends<T> = T | { extends: T };

type MdxOptions = {
remarkPlugins?: WithExtends<MdxRollupPluginOptions['remarkPlugins']>;
rehypePlugins?: WithExtends<MdxRollupPluginOptions['rehypePlugins']>;
};

const DEFAULT_REMARK_PLUGINS: MdxRollupPluginOptions['remarkPlugins'] = [
remarkGfm,
remarkSmartypants,
];
const DEFAULT_REHYPE_PLUGINS: MdxRollupPluginOptions['rehypePlugins'] = [];
import { bold, blue } from 'kleur/colors';
import { rehypeApplyFrontmatterExport } from './astro-data-utils.js';
import {
getFileInfo,
parseFrontmatter,
handleExtendsNotSupported,
getRehypePlugins,
getRemarkPlugins,
} from './utils.js';
import type { MdxOptions } from './utils.js';

const RAW_CONTENT_ERROR =
'MDX does not support rawContent()! If you need to read the Markdown contents to calculate values (ex. reading time), we suggest injecting frontmatter via remark plugins. Learn more on our docs: https://docs.astro.build/en/guides/integrations-guide/mdx/#inject-frontmatter-via-remark-or-rehype-plugins';

const COMPILED_CONTENT_ERROR =
'MDX does not support compiledContent()! If you need to read the HTML contents to calculate values (ex. reading time), we suggest injecting frontmatter via rehype plugins. Learn more on our docs: https://docs.astro.build/en/guides/integrations-guide/mdx/#inject-frontmatter-via-remark-or-rehype-plugins';

function handleExtends<T>(config: WithExtends<T[] | undefined>, defaults: T[] = []): T[] {
if (Array.isArray(config)) return config;

return [...defaults, ...(config?.extends ?? [])];
}

async function getRemarkPlugins(
mdxOptions: MdxOptions,
config: AstroConfig
): Promise<MdxRollupPluginOptions['remarkPlugins']> {
let remarkPlugins = [
// Initialize vfile.data.astroExports before all plugins are run
remarkInitializeAstroData,
...handleExtends(mdxOptions.remarkPlugins, DEFAULT_REMARK_PLUGINS),
];
if (config.markdown.syntaxHighlight === 'shiki') {
remarkPlugins.push([await remarkShiki(config.markdown.shikiConfig)]);
}
if (config.markdown.syntaxHighlight === 'prism') {
remarkPlugins.push(remarkPrism);
}
return remarkPlugins;
}

function getRehypePlugins(
mdxOptions: MdxOptions,
config: AstroConfig
): MdxRollupPluginOptions['rehypePlugins'] {
let rehypePlugins = [
[rehypeRaw, { passThrough: nodeTypes }] as any,
...handleExtends(mdxOptions.rehypePlugins, DEFAULT_REHYPE_PLUGINS),
];

// getHeadings() is guaranteed by TS, so we can't allow user to override
rehypePlugins.unshift(rehypeCollectHeadings);

return rehypePlugins;
}

export default function mdx(mdxOptions: MdxOptions = {}): AstroIntegration {
return {
name: '@astrojs/mdx',
hooks: {
'astro:config:setup': async ({ updateConfig, config, addPageExtension, command }: any) => {
addPageExtension('.mdx');
mdxOptions.extendPlugins ??= 'markdown';

handleExtendsNotSupported(mdxOptions.remarkPlugins);
handleExtendsNotSupported(mdxOptions.rehypePlugins);

// TODO: remove for 1.0. Shipping to ease migration to new minor
if (
mdxOptions.extendPlugins === 'markdown' &&
(config.markdown.rehypePlugins?.length || config.markdown.remarkPlugins?.length)
) {
console.log(
blue(`[MDX] Now inheriting remark and rehype plugins from "markdown" config.`)
);
console.log(
`If you applied a plugin to both your Markdown and MDX configs, we suggest ${bold(
'removing the duplicate MDX entry.'
)}`
);
console.log(`See "extendPlugins" option to configure this behavior.`);
}

const mdxPluginOpts: MdxRollupPluginOptions = {
remarkPlugins: await getRemarkPlugins(mdxOptions, config),
Expand Down
127 changes: 125 additions & 2 deletions packages/integrations/mdx/src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,33 @@
import type { Options as AcornOpts } from 'acorn';
import { parse } from 'acorn';
import type { AstroConfig, SSRError } from 'astro';
import type { MdxjsEsm } from 'mdast-util-mdx';

import type { PluggableList } from '@mdx-js/mdx/lib/core.js';
import type { Options as MdxRollupPluginOptions } from '@mdx-js/rollup';
import { bold, yellow } from 'kleur/colors';
import { nodeTypes } from '@mdx-js/mdx';
import { parse } from 'acorn';
import rehypeRaw from 'rehype-raw';
import remarkGfm from 'remark-gfm';
import remarkSmartypants from 'remark-smartypants';
import { remarkInitializeAstroData } from './astro-data-utils.js';
import rehypeCollectHeadings from './rehype-collect-headings.js';
import remarkPrism from './remark-prism.js';
import remarkShiki from './remark-shiki.js';
import matter from 'gray-matter';

export type MdxOptions = {
remarkPlugins?: PluggableList;
rehypePlugins?: PluggableList;
/**
* Choose which remark and rehype plugins to inherit, if any.
*
* - "markdown" (default) - inherit your project’s markdown plugin config ([see Markdown docs](https://docs.astro.build/en/guides/markdown-content/#configuring-markdown))
* - "astroDefaults" - inherit Astro’s default plugins only ([see defaults](https://docs.astro.build/en/reference/configuration-reference/#markdownextenddefaultplugins))
* - false - do not inherit any plugins
*/
extendPlugins?: 'markdown' | 'astroDefaults' | false;
};

function appendForwardSlash(path: string) {
return path.endsWith('/') ? path : path + '/';
}
Expand All @@ -14,6 +37,9 @@ interface FileInfo {
fileUrl: string;
}

const DEFAULT_REMARK_PLUGINS: PluggableList = [remarkGfm, remarkSmartypants];
const DEFAULT_REHYPE_PLUGINS: PluggableList = [];

/** @see 'vite-plugin-utils' for source */
export function getFileInfo(id: string, config: AstroConfig): FileInfo {
const sitePathname = appendForwardSlash(
Expand Down Expand Up @@ -83,3 +109,100 @@ export function jsToTreeNode(
},
};
}

export async function getRemarkPlugins(
mdxOptions: MdxOptions,
config: AstroConfig
): Promise<MdxRollupPluginOptions['remarkPlugins']> {
let remarkPlugins: PluggableList = [
// Set "vfile.data.astro" for plugins to inject frontmatter
remarkInitializeAstroData,
];
switch (mdxOptions.extendPlugins) {
case false:
break;
case 'astroDefaults':
remarkPlugins = [...remarkPlugins, ...DEFAULT_REMARK_PLUGINS];
break;
default:
remarkPlugins = [
...remarkPlugins,
...(config.markdown.extendDefaultPlugins ? DEFAULT_REMARK_PLUGINS : []),
...ignoreStringPlugins(config.markdown.remarkPlugins ?? []),
];
break;
}
if (config.markdown.syntaxHighlight === 'shiki') {
remarkPlugins.push([await remarkShiki(config.markdown.shikiConfig)]);
}
if (config.markdown.syntaxHighlight === 'prism') {
remarkPlugins.push(remarkPrism);
}

remarkPlugins = [...remarkPlugins, ...(mdxOptions.remarkPlugins ?? [])];
return remarkPlugins;
}

export function getRehypePlugins(
mdxOptions: MdxOptions,
config: AstroConfig
): MdxRollupPluginOptions['rehypePlugins'] {
let rehypePlugins: PluggableList = [
// getHeadings() is guaranteed by TS, so we can't allow user to override
rehypeCollectHeadings,
// rehypeRaw allows custom syntax highlighters to work without added config
[rehypeRaw, { passThrough: nodeTypes }] as any,
];
switch (mdxOptions.extendPlugins) {
case false:
break;
case 'astroDefaults':
rehypePlugins = [...rehypePlugins, ...DEFAULT_REHYPE_PLUGINS];
break;
default:
rehypePlugins = [
...rehypePlugins,
...(config.markdown.extendDefaultPlugins ? DEFAULT_REHYPE_PLUGINS : []),
...ignoreStringPlugins(config.markdown.rehypePlugins ?? []),
];
break;
}

rehypePlugins = [...rehypePlugins, ...(mdxOptions.rehypePlugins ?? [])];
return rehypePlugins;
}

function ignoreStringPlugins(plugins: any[]) {
let validPlugins: PluggableList = [];
let hasInvalidPlugin = false;
for (const plugin of plugins) {
if (typeof plugin === 'string') {
console.warn(yellow(`[MDX] ${bold(plugin)} not applied.`));
hasInvalidPlugin = true;
} else if (Array.isArray(plugin) && typeof plugin[0] === 'string') {
console.warn(yellow(`[MDX] ${bold(plugin[0])} not applied.`));
hasInvalidPlugin = true;
} else {
validPlugins.push(plugin);
}
}
if (hasInvalidPlugin) {
console.warn(
`To inherit Markdown plugins in MDX, please use explicit imports in your config instead of "strings." See Markdown docs: https://docs.astro.build/en/guides/markdown-content/#markdown-plugins`
);
}
return validPlugins;
}

// TODO: remove for 1.0
export function handleExtendsNotSupported(pluginConfig: any) {
if (
typeof pluginConfig === 'object' &&
pluginConfig !== null &&
(pluginConfig as any).hasOwnProperty('extends')
) {
throw new Error(
`[MDX] The "extends" plugin option is no longer supported! Astro now extends your project's \`markdown\` plugin configuration by default. To customize this behavior, see the \`extendPlugins\` option instead: https://docs.astro.build/en/guides/integrations-guide/mdx/#extendplugins`
);
}
}
Loading

0 comments on commit 8f8dff4

Please sign in to comment.