2

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?

3 Answers 3

1

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

  • use a custom Remark or Rehype plugin
  • use an existing library that offers this functionality
  • map existing HTML tags to custom components

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.

Sign up to request clarification or add additional context in comments.

Comments

1

For what it's worth, on 2024-11-20, the example in your question works for me out of the box (using [email protected] and @astrojs/[email protected]):

// layout.astro
---

// ... other imports 

import YoutubeEmbed from "../../components/mdx/YoutubeEmbed.astro";

export const getStaticPaths = async (): StaticPaths => {
  const articles = await getCollection("articles");
  return articles.map((entry) => ({
    params: { slug: entry.slug },
    props: { entry },
  }));
};

const { entry } = Astro.props;
const { Content } = await entry.render();

const components = { YoutubeEmbed };

---

<Content components={components} />

Combined with:

// index.mdx

> What if you were playing a billiards RPG, but your cue ball was you, an adorable warrior mouse armed with a variety of pointy objects?

<YoutubeEmbed youtubeId="SZhqedzllLE" />

Gives:

screenshot of a webpage with an embedded youtube video in it

Comments

0

Thanks yo Armand's answer, I made it work with this rehype plugin:


import { parse, resolve } from 'node:path';

import { parse as parseJs } from 'acorn';
import type { VFile } from 'vfile';

const resolveModulePath = (path: string) => {
  // Resolve relative paths
  if (path.startsWith('.')) return resolve(path);
  // Don’t resolve other paths (e.g. npm modules)
  return path;
};

type NamedImportConfig = string | [from: string, as: string];
type ImportsConfig = (string | Record<string, string | NamedImportConfig[]>)[];

/**
 * Use a filename to generate a default import name.
 * @example
 * getDefaultImportName('/path/to/cool-component.astro');
 * // => coolcomponent
 */
function getDefaultImportName(path: string): string {
  return parse(path).name.replaceAll(/[^\w\d]/g, '');
}

/**
 * Create an import statement.
 * @param imported Stuff to import (e.g. `Thing` or `{ Named }`)
 * @param module Module to import from (e.g. `module-thing`)
 */
function formatImport(imported: string, module: string): string {
  return `import ${imported} from ${JSON.stringify(module)};`;
}

/** Get the parts for a named import statement from config. */
function formatNamedImports(namedImport: NamedImportConfig[]): string {
  const imports: string[] = [];
  for (const imp of namedImport) {
    if (typeof imp === 'string') {
      imports.push(imp);
    } else {
      const [from, as] = imp;
      imports.push(`${from} as ${as}`);
    }
  }
  return `{ ${imports.join(', ')} }`;
}

/** Generate imports from a full imports config array. */
function processImportsConfig(config: ImportsConfig) {
  const imports = [];
  for (const option of config) {
    if (typeof option === 'string') {
      imports.push(formatImport(getDefaultImportName(option), resolveModulePath(option)));
    } else {
      for (const path in option) {
        const namedImportsOrNamespace = option[path];
        if (typeof namedImportsOrNamespace === 'string') {
          imports.push(formatImport(`* as ${namedImportsOrNamespace}`, resolveModulePath(path)));
        } else {
          const importString = formatNamedImports(namedImportsOrNamespace);
          imports.push(formatImport(importString, resolveModulePath(path)));
        }
      }
    }
  }
  return imports;
}

/** Get an MDX node representing a block of imports based on user config. */
function generateImportsNode(config: ImportsConfig) {
  const imports = processImportsConfig(config);
  const js = imports.join('\n');
  return {
    type: 'mdxjsEsm',
    value: '',
    data: {
      estree: {
        ...parseJs(js, { ecmaVersion: 'latest', sourceType: 'module' }),
        type: 'Program',
        sourceType: 'module',
      },
    },
  };
}

type MfxFile = VFile & { data?: { astro?: { frontmatter?: { layout?: string } } } };

type PluginConfig = {
  [layoutName: string]: string[];
};

export function AutoImportComponentsPerLayout(pluginConfig: PluginConfig) {
  return function (tree: { children: unknown[] }, vfile: MfxFile) {
    const fileLayout: string =
      vfile?.data?.astro?.frontmatter?.layout?.split('/').pop()?.split('.')[0] || '';

    if (!fileLayout) {
      return;
    }

    // for each key in PluginConfig loop and add the array of imports to the imports array
    for (const key in pluginConfig) {
      if (Object.prototype.hasOwnProperty.call(pluginConfig, key)) {
        if (key === fileLayout) {
          const imports: string[] = [];
          const components: string[] = pluginConfig[key];
          components.forEach((component: string) => {
            imports.push(`@/components/${component}/${component}.tsx`);
          });
          const importsNode = generateImportsNode(imports);
          tree?.children.unshift(importsNode);
        }
      }
    }
  };
}

Which can be used like so:

  integrations: [
    mdx({
      rehypePlugins: [[AutoImportComponentsPerLayout, { BaseLayout: ["Button", "Card"], AnotherLayout: ["Button"] }  ]]
    })
  ],

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.