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:
parent
45bec97d28
commit
1743fe140e
9 changed files with 155 additions and 28 deletions
5
.changeset/witty-crews-worry.md
Normal file
5
.changeset/witty-crews-worry.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'@astrojs/mdx': minor
|
||||
---
|
||||
|
||||
Support "layout" frontmatter property
|
|
@ -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>
|
||||
<!-- -->
|
||||
```
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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[],
|
||||
},
|
||||
});
|
||||
},
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
18
packages/integrations/mdx/test/fixtures/mdx-frontmatter/src/layouts/Base.astro
vendored
Normal file
18
packages/integrations/mdx/test/fixtures/mdx-frontmatter/src/layouts/Base.astro
vendored
Normal 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>
|
|
@ -1,6 +1,7 @@
|
|||
---
|
||||
title: 'Using YAML frontmatter'
|
||||
layout: '../layouts/Base.astro'
|
||||
illThrowIfIDontExist: "Oh no, that's scary!"
|
||||
---
|
||||
|
||||
# {frontmatter.illThrowIfIDontExist}
|
||||
{frontmatter.illThrowIfIDontExist}
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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==}
|
||||
|
|
Loading…
Reference in a new issue