Skip to content

Commit

Permalink
feat: support layout in MDX frontmatter (#4088)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
bholmesdev and sarah11918 committed Jul 29, 2022
1 parent 45bec97 commit 1743fe1
Show file tree
Hide file tree
Showing 9 changed files with 154 additions and 27 deletions.
5 changes: 5 additions & 0 deletions .changeset/witty-crews-worry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@astrojs/mdx': minor
---

Support "layout" frontmatter property
42 changes: 33 additions & 9 deletions packages/integrations/mdx/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 [`<slot />`](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;
---
<html>
<head>
<title>{content.title}</title>
</head>
<body>
<h1>{content.title}</h1>
<!-- Rendered MDX will be passed into the default slot. -->
<slot />
</body>
</html>
```

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.
#### 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
---
Expand All @@ -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>
```
Expand All @@ -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>
<!-- -->
```

Expand Down
7 changes: 4 additions & 3 deletions packages/integrations/mdx/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
48 changes: 36 additions & 12 deletions packages/integrations/mdx/src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 };

Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -113,7 +137,7 @@ export default function mdx(mdxOptions: MdxOptions = {}): AstroIntegration {
return code;
},
},
],
] as VitePlugin[],
},
});
},
Expand Down
23 changes: 22 additions & 1 deletion packages/integrations/mdx/src/utils.ts
Original file line number Diff line number Diff line change
@@ -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 + '/';
Expand Down Expand Up @@ -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;
}
}
}
Original file line number Diff line number Diff line change
@@ -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>
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
---
title: 'Using YAML frontmatter'
layout: '../layouts/Base.astro'
illThrowIfIDontExist: "Oh no, that's scary!"
---

# {frontmatter.illThrowIfIDontExist}
{frontmatter.illThrowIfIDontExist}
31 changes: 31 additions & 0 deletions packages/integrations/mdx/test/mdx-frontmatter.test.js
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -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),
Expand Down
4 changes: 3 additions & 1 deletion pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 1743fe1

Please sign in to comment.