astrojsmdxjs

Astro layout for MDX with predefined allowed components


Using Astro, I have a layout file: /src/layouts/MdxLayout.astro where I try to set allowed components Button and Link to be rendered if passed in from a page file.

---
import BaseLayout from '@/layouts/Base/BaseLayout.astro';
import Button from '@/components/Button/Button';
import Link from "@/components/Link/Link";
---

<BaseLayout components={{ Button: Button, Link: Link }}>
  <slot />
</BaseLayout>

Hoping this would work in /src/pages/mdx.mdx:

---
layout: '@/layouts/MdxLayout.astro'
title: "Hello, World!"
---

# Lorem ipsum

<Button>Click me</Button>

So I will not have to add imports to all mdx files where I wish to use these components, (ie: import Button from '@/components/Button/Button'). Without this import though, I am faced with the error:

Expected component Button to be defined: you likely forgot to import, pass, or provide it.

My question: is there any way to predefine components in a layout file that can then be used in a mdx file that uses this layout without having to import them in the mdx file?


Solution

  • It is possible but not natively. You have three options:

    Using Remark or Rehype

    You can create a custom Remark/Rehype plugin to update the tree with the import statements for your components. Something like:

    import type { Root } from 'hast';
    import type { Plugin as UnifiedPlugin } from 'unified';
    import { visit } from 'unist-util-visit';
    
    export const rehypeAutoImportComponents: UnifiedPlugin<[], Root> =
      () => (tree, file) => {
        const importsStatements = [];
    
        visit(tree, 'mdxJsxFlowElement', (node, index, nodeParent) => {
          if (node.name === 'Button') importsStatements.push('import Button from "@/components/Button/Button"');
        });
    
        tree.children.unshift(...importsStatements);
      };
    

    You'll need to figure out how to resolve your path aliases and how to avoid duplicated import statements.

    Then update your Astro configuration file:

    import { rehypeAutoImportComponents } from './src/utils/rehype-auto-import-components';
    
    export default defineConfig({
      markdown: {
        rehypePlugins: [
          rehypeAutoImportComponents
        ],
      },
    });
    

    You can also add an option to your plugin to pass the components directly in your Astro config file instead of hardcoding them in your plugin.

    Note: with this solution, you can remove the components property in <BaseLayout ... />.

    Existing libraries

    I haven't test them but you can look for astro-auto-import or Astro-M²DX for example.

    Mapping HTML tags to custom components

    Another alternative is to map HTML tags to custom components:

    ---
    import BaseLayout from '@/layouts/Base/BaseLayout.astro';
    import Button from '@/components/Button/Button';
    import Link from "@/components/Link/Link";
    ---
    
    <BaseLayout components={{ a: Link, button: Button }}>
      <slot />
    </BaseLayout>
    

    However to be able to map HTML tags (ie. <button />) you need a plugin to disable the default behavior of MDXJS (ignoring HTML tags). Something like:

    import type { Root } from 'hast';
    import type { Plugin as UnifiedPlugin } from 'unified';
    import { visit } from 'unist-util-visit';
    
    export const rehypeDisableExplicitJsx: UnifiedPlugin<[], Root> =
      () => (tree) => {
        visit(tree, ['mdxJsxFlowElement', 'mdxJsxTextElement'], (node) => {
          if (node.data && '_mdxExplicitJsx' in node.data) {
            delete node.data._mdxExplicitJsx;
          }
        });
      };
    

    Then you can add this plugin to your Astro config:

    import { rehypeDisableExplicitJsx } from './src/utils/rehype-disable-explicit-jsx';
    
    export default defineConfig({
      markdown: {
        rehypePlugins: [
          rehypeDisableExplicitJsx
        ],
      },
    });
    

    With this, your MDX file can look like this:

    ---
    layout: '@/layouts/MdxLayout.astro'
    title: "Hello, World!"
    ---
    
    # Lorem ipsum
    
    <button>Click me</button>
    

    The caveat is that for more complex components (when a HTML tag does not exist for it) you'll need extra logic. You could map a div to a custom component where you check for a particular class/attribute to return the right component.