feat: support layout in MDX frontmatter (#4088)

* deps: add gray-matter

* feat: support layout frontmatter property

* test: frontmatter, content prop

* docs: update layout recommendation

* deps: fix lockfile

* chore: changeset

* fix: inherit rollup plugin transform

* fix: avoid parsing frontmatter on custom parsers

* fix: match YAML err handling from md

* docs: absolute url to docs

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

* chore: formatting

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>
This commit is contained in:
Ben Holmes 2022-07-29 10:22:57 -05:00 committed by GitHub
parent 45bec97d28
commit 1743fe140e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 155 additions and 28 deletions

View file

@ -0,0 +1,5 @@
---
'@astrojs/mdx': minor
---
Support "layout" frontmatter property

View file

@ -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
export {default} from '../../layouts/BaseLayout.astro';
```yaml
---
layout: '../layouts/BaseLayout.astro'
title: 'My Blog Post'
---
```
You can also import and use a [`<Layout />` component](/en/core-concepts/layouts/) for your MDX page content, and pass all the variables declared in frontmatter as props.
Then, you can retrieve all other frontmatter properties from your layout via the `content` property, and render your MDX using the default [`<slot />`](https://docs.astro.build/en/core-concepts/astro-components/#slots):
```astro
---
// src/layouts/BaseLayout.astro
const { content } = Astro.props;
---
<html>
<head>
<title>{content.title}</title>
</head>
<body>
<h1>{content.title}</h1>
<!-- Rendered MDX will be passed into the default slot. -->
<slot />
</body>
</html>
```
#### 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 [`<Layout />` 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';
<BaseLayout {...frontmatter}>
# {frontmatter.title}
function fancyJsHelper() {
return "Try doing that with YAML!";
}
<BaseLayout title={frontmatter.title} fancyJsHelper={fancyJsHelper}>
Welcome to my new Astro blog, using MDX!
</BaseLayout>
```
@ -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;
---
<!-- -->
<h1>{title}</h1>
<slot />
<p>Published on {publishDate}</p>
<p>{fancyJsHelper()}</p>
<!-- -->
```

View file

@ -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",

View file

@ -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> = 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 <Layout content={${
JSON.stringify(content)
}}>{children}</Layout> }`
}
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[],
},
});
},

View file

@ -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;
}
}
}

View file

@ -0,0 +1,18 @@
---
const { content = { title: "Didn't work" } } = Astro.props;
---
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{content.title}</title>
</head>
<body>
<h1>{content.title}</h1>
<p data-layout-rendered>Layout rendered!</p>
<slot />
</body>
</html>

View file

@ -1,6 +1,7 @@
---
title: 'Using YAML frontmatter'
layout: '../layouts/Base.astro'
illThrowIfIDontExist: "Oh no, that's scary!"
---
# {frontmatter.illThrowIfIDontExist}
{frontmatter.illThrowIfIDontExist}

View file

@ -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),

View file

@ -2182,6 +2182,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
@ -2199,6 +2200,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
@ -9844,7 +9846,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==}