diff --git a/.changeset/witty-crews-worry.md b/.changeset/witty-crews-worry.md new file mode 100644 index 000000000000..767b96bf4202 --- /dev/null +++ b/.changeset/witty-crews-worry.md @@ -0,0 +1,5 @@ +--- +'@astrojs/mdx': minor +--- + +Support "layout" frontmatter property diff --git a/packages/integrations/mdx/README.md b/packages/integrations/mdx/README.md index fe68738044a5..6d9876ee891a 100644 --- a/packages/integrations/mdx/README.md +++ b/packages/integrations/mdx/README.md @@ -136,15 +136,37 @@ const posts = await Astro.glob('./*.mdx'); ### Layouts -You can use the [MDX layout component](https://mdxjs.com/docs/using-mdx/#layout) to specify a layout component to wrap all page content. This is done with a default export statement at the end of your `.mdx` file: +Layouts can be applied [in the same way as standard Astro Markdown](https://docs.astro.build/en/guides/markdown-content/#markdown-layouts). You can add a `layout` to [your frontmatter](#frontmatter) like so: -```mdx -// src/pages/my-page.mdx +```yaml +--- +layout: '../layouts/BaseLayout.astro' +title: 'My Blog Post' +--- +``` + +Then, you can retrieve all other frontmatter properties from your layout via the `content` property, and render your MDX using the default [``](https://docs.astro.build/en/core-concepts/astro-components/#slots): -export {default} from '../../layouts/BaseLayout.astro'; +```astro +--- +// src/layouts/BaseLayout.astro +const { content } = Astro.props; +--- + + + {content.title} + + +

{content.title}

+ + + + ``` -You can also import and use a [`` component](/en/core-concepts/layouts/) for your MDX page content, and pass all the variables declared in frontmatter as props. +#### Importing layouts manually + +You may need to pass information to your layouts that does not (or cannot) exist in your frontmatter. In this case, you can import and use a [`` component](https://docs.astro.build/en/core-concepts/layouts/) like any other component: ```mdx --- @@ -155,9 +177,11 @@ publishDate: '21 September 2022' --- import BaseLayout from '../layouts/BaseLayout.astro'; - - # {frontmatter.title} +function fancyJsHelper() { + return "Try doing that with YAML!"; +} + Welcome to my new Astro blog, using MDX! ``` @@ -166,12 +190,12 @@ Then, your values are available to you through `Astro.props` in your layout, and ```astro --- // src/layouts/BaseLayout.astro -const { title, publishDate } = Astro.props; +const { title, fancyJsHelper } = Astro.props; ---

{title}

-

Published on {publishDate}

+

{fancyJsHelper()}

``` diff --git a/packages/integrations/mdx/package.json b/packages/integrations/mdx/package.json index 8f9a1357a866..d2d97bd919e1 100644 --- a/packages/integrations/mdx/package.json +++ b/packages/integrations/mdx/package.json @@ -34,15 +34,16 @@ "@mdx-js/mdx": "^2.1.2", "@mdx-js/rollup": "^2.1.1", "es-module-lexer": "^0.10.5", + "gray-matter": "^4.0.3", "prismjs": "^1.28.0", "rehype-raw": "^6.1.1", + "remark-frontmatter": "^4.0.1", "remark-gfm": "^3.0.1", + "remark-mdx-frontmatter": "^2.0.2", "remark-shiki-twoslash": "^3.1.0", "remark-smartypants": "^2.0.0", "shiki": "^0.10.1", - "unist-util-visit": "^4.1.0", - "remark-frontmatter": "^4.0.1", - "remark-mdx-frontmatter": "^2.0.2" + "unist-util-visit": "^4.1.0" }, "devDependencies": { "@types/chai": "^4.3.1", diff --git a/packages/integrations/mdx/src/index.ts b/packages/integrations/mdx/src/index.ts index cb3f7c3fe906..9313d10eeae5 100644 --- a/packages/integrations/mdx/src/index.ts +++ b/packages/integrations/mdx/src/index.ts @@ -1,3 +1,4 @@ +import type { Plugin as VitePlugin } from 'vite'; import { nodeTypes } from '@mdx-js/mdx'; import mdxPlugin, { Options as MdxRollupPluginOptions } from '@mdx-js/rollup'; import type { AstroIntegration } from 'astro'; @@ -10,7 +11,7 @@ import remarkMdxFrontmatter from 'remark-mdx-frontmatter'; import remarkShikiTwoslash from 'remark-shiki-twoslash'; import remarkSmartypants from 'remark-smartypants'; import remarkPrism from './remark-prism.js'; -import { getFileInfo } from './utils.js'; +import { getFileInfo, getFrontmatter } from './utils.js'; type WithExtends = T | { extends: T }; @@ -68,24 +69,47 @@ export default function mdx(mdxOptions: MdxOptions = {}): AstroIntegration { }, ]); + const configuredMdxPlugin = mdxPlugin({ + remarkPlugins, + rehypePlugins, + jsx: true, + jsxImportSource: 'astro', + // Note: disable `.md` support + format: 'mdx', + mdExtensions: [], + }) + updateConfig({ vite: { plugins: [ { enforce: 'pre', - ...mdxPlugin({ - remarkPlugins, - rehypePlugins, - jsx: true, - jsxImportSource: 'astro', - // Note: disable `.md` support - format: 'mdx', - mdExtensions: [], - }), + ...configuredMdxPlugin, + // Override transform to inject layouts before MDX compilation + async transform(this, code, id) { + if (!id.endsWith('.mdx')) return; + + const mdxPluginTransform = configuredMdxPlugin.transform?.bind(this); + // If user overrides our default YAML parser, + // do not attempt to parse the `layout` via gray-matter + if (mdxOptions.frontmatterOptions?.parsers) { + return mdxPluginTransform?.(code, id); + } + const frontmatter = getFrontmatter(code, id); + if (frontmatter.layout) { + const { layout, ...content } = frontmatter; + code += `\nexport default async function({ children }) {\nconst Layout = (await import(${ + JSON.stringify(frontmatter.layout) + })).default;\nreturn {children} }` + } + return mdxPluginTransform?.(code, id); + } }, { name: '@astrojs/mdx', - transform(code: string, id: string) { + transform(code, id) { if (!id.endsWith('.mdx')) return; const [, moduleExports] = parseESM(code); @@ -113,7 +137,7 @@ export default function mdx(mdxOptions: MdxOptions = {}): AstroIntegration { return code; }, }, - ], + ] as VitePlugin[], }, }); }, diff --git a/packages/integrations/mdx/src/utils.ts b/packages/integrations/mdx/src/utils.ts index 6eb7a3570260..97bc72d7420c 100644 --- a/packages/integrations/mdx/src/utils.ts +++ b/packages/integrations/mdx/src/utils.ts @@ -1,4 +1,5 @@ -import type { AstroConfig } from 'astro'; +import type { AstroConfig, SSRError } from 'astro'; +import matter from 'gray-matter'; function appendForwardSlash(path: string) { return path.endsWith('/') ? path : path + '/'; @@ -37,3 +38,23 @@ export function getFileInfo(id: string, config: AstroConfig): FileInfo { } return { fileId, fileUrl }; } + +/** + * Match YAML exception handling from Astro core errors + * @see 'astro/src/core/errors.ts' + */ +export function getFrontmatter(code: string, id: string) { + try { + return matter(code).data; + } catch (e: any) { + if (e.name === 'YAMLException') { + const err: SSRError = e; + err.id = id; + err.loc = { file: e.id, line: e.mark.line + 1, column: e.mark.column }; + err.message = e.reason; + throw err; + } else { + throw e; + } + } +} diff --git a/packages/integrations/mdx/test/fixtures/mdx-frontmatter/src/layouts/Base.astro b/packages/integrations/mdx/test/fixtures/mdx-frontmatter/src/layouts/Base.astro new file mode 100644 index 000000000000..0d18725f282d --- /dev/null +++ b/packages/integrations/mdx/test/fixtures/mdx-frontmatter/src/layouts/Base.astro @@ -0,0 +1,18 @@ +--- +const { content = { title: "Didn't work" } } = Astro.props; +--- + + + + + + + + {content.title} + + +

{content.title}

+

Layout rendered!

+ + + diff --git a/packages/integrations/mdx/test/fixtures/mdx-frontmatter/src/pages/index.mdx b/packages/integrations/mdx/test/fixtures/mdx-frontmatter/src/pages/index.mdx index 333026f85404..e8815b9c3954 100644 --- a/packages/integrations/mdx/test/fixtures/mdx-frontmatter/src/pages/index.mdx +++ b/packages/integrations/mdx/test/fixtures/mdx-frontmatter/src/pages/index.mdx @@ -1,6 +1,7 @@ --- title: 'Using YAML frontmatter' +layout: '../layouts/Base.astro' illThrowIfIDontExist: "Oh no, that's scary!" --- -# {frontmatter.illThrowIfIDontExist} +{frontmatter.illThrowIfIDontExist} diff --git a/packages/integrations/mdx/test/mdx-frontmatter.test.js b/packages/integrations/mdx/test/mdx-frontmatter.test.js index 3021f926fb89..4d25e0ed33f1 100644 --- a/packages/integrations/mdx/test/mdx-frontmatter.test.js +++ b/packages/integrations/mdx/test/mdx-frontmatter.test.js @@ -1,6 +1,7 @@ import mdx from '@astrojs/mdx'; import { expect } from 'chai'; +import { parseHTML } from 'linkedom'; import { loadFixture } from '../../../astro/test/test-utils.js'; const FIXTURE_ROOT = new URL('./fixtures/mdx-frontmatter/', import.meta.url); @@ -26,6 +27,36 @@ describe('MDX frontmatter', () => { expect(titles).to.include('Using YAML frontmatter'); }); + it('renders layout from "layout" frontmatter property', async () => { + const fixture = await loadFixture({ + root: FIXTURE_ROOT, + integrations: [mdx()], + }); + await fixture.build(); + + const html = await fixture.readFile('/index.html'); + const { document } = parseHTML(html); + + const layoutParagraph = document.querySelector('[data-layout-rendered]'); + + expect(layoutParagraph).to.not.be.null; + }); + + it('passes frontmatter to layout via "content" prop', async () => { + const fixture = await loadFixture({ + root: FIXTURE_ROOT, + integrations: [mdx()], + }); + await fixture.build(); + + const html = await fixture.readFile('/index.html'); + const { document } = parseHTML(html); + + const h1 = document.querySelector('h1'); + + expect(h1.textContent).to.equal('Using YAML frontmatter'); + }); + it('extracts frontmatter to "customFrontmatter" export when configured', async () => { const fixture = await loadFixture({ root: new URL('./fixtures/mdx-custom-frontmatter-name/', import.meta.url), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 881cc0da5589..37792b3291dd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2174,6 +2174,7 @@ importers: astro-scripts: workspace:* chai: ^4.3.6 es-module-lexer: ^0.10.5 + gray-matter: ^4.0.3 linkedom: ^0.14.12 mocha: ^9.2.2 prismjs: ^1.28.0 @@ -2191,6 +2192,7 @@ importers: '@mdx-js/mdx': 2.1.2 '@mdx-js/rollup': 2.1.2 es-module-lexer: 0.10.5 + gray-matter: 4.0.3 prismjs: 1.28.0 rehype-raw: 6.1.1 remark-frontmatter: 4.0.1 @@ -9836,7 +9838,7 @@ packages: dev: true /concat-map/0.0.1: - resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + resolution: {integrity: sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=} /concurrently/7.3.0: resolution: {integrity: sha512-IiDwm+8DOcFEInca494A8V402tNTQlJaYq78RF2rijOrKEk/AOHTxhN4U1cp7GYKYX5Q6Ymh1dLTBlzIMN0ikA==}