# Layout that imports components for you instead of each individual MDX file 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? ## Answer 1 It is possible but not natively. You have three options: 1. use a custom Remark or Rehype plugin 2. use an existing library that offers this functionality 3. 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. ## Answer 2 Thanks to 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"] } ]] }) ], ```