Support rendering <Fragment> in MDX <Content /> component (#5522)

This commit is contained in:
Chris Swithinbank 2022-12-05 21:56:43 +01:00 committed by GitHub
parent c1a944d55a
commit efc4363e0b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 285 additions and 7 deletions

View file

@ -0,0 +1,5 @@
---
'@astrojs/mdx': patch
---
Support use of `<Fragment>` in MDX files rendered with `<Content />` component

View file

@ -113,6 +113,7 @@ export async function renderPage(mod: ComponentInstance, ctx: RenderContext, env
}
// HACK: expose `Fragment` for all MDX components
// TODO: Remove in Astro v2 — redundant as of @astrojs/mdx@>0.12.0
if (typeof mod.default === 'function' && mod.default.name.startsWith('MDX')) {
Object.assign(pageProps, {
components: Object.assign((pageProps?.components as any) ?? {}, { Fragment }),

View file

@ -132,11 +132,18 @@ export default function mdx(mdxOptions: MdxOptions = {}): AstroIntegration {
transform(code, id) {
if (!id.endsWith('.mdx')) return;
// Ensures styles and scripts are injected into a `<head>`
// When a layout is not applied
code += `\nMDXContent[Symbol.for('astro.needsHeadRendering')] = !Boolean(frontmatter.layout);`;
const [moduleImports, moduleExports] = parseESM(code);
const [, moduleExports] = parseESM(code);
// Fragment import should already be injected, but check just to be safe.
const importsFromJSXRuntime = moduleImports
.filter(({ n }) => n === 'astro/jsx-runtime')
.map(({ ss, se }) => code.substring(ss, se));
const hasFragmentImport = importsFromJSXRuntime.some((statement) =>
/[\s,{](Fragment,|Fragment\s*})/.test(statement)
);
if (!hasFragmentImport) {
code = 'import { Fragment } from "astro/jsx-runtime"\n' + code;
}
const { fileUrl, fileId } = getFileInfo(id, config);
if (!moduleExports.includes('url')) {
@ -156,9 +163,19 @@ export default function mdx(mdxOptions: MdxOptions = {}): AstroIntegration {
)}) };`;
}
if (!moduleExports.includes('Content')) {
code += `\nexport const Content = MDXContent;`;
// Make `Content` the default export so we can wrap `MDXContent` and pass in `Fragment`
code = code.replace('export default MDXContent;', '');
code += `\nexport const Content = (props = {}) => MDXContent({
...props,
components: { Fragment, ...props.components },
});
export default Content;`;
}
// Ensures styles and scripts are injected into a `<head>`
// When a layout is not applied
code += `\nContent[Symbol.for('astro.needsHeadRendering')] = !Boolean(frontmatter.layout);`;
if (command === 'dev') {
// TODO: decline HMR updates until we have a stable approach
code += `\nif (import.meta.hot) {

View file

@ -0,0 +1,3 @@
# MDX containing `<Fragment />`
<p><Fragment>bar</Fragment></p>

View file

@ -1,11 +1,20 @@
---
import { parse } from 'node:path';
const components = await Astro.glob('../components/*.mdx');
---
<div data-default-export>
{components.map(Component => <Component.default />)}
{components.map(Component => (
<div data-file={parse(Component.file).base}>
<Component.default />
</div>
))}
</div>
<div data-content-export>
{components.map(({ Content }) => <Content />)}
{components.map(({ Content, file }) => (
<div data-file={parse(file).base}>
<Content />
</div>
))}
</div>

View file

@ -0,0 +1,5 @@
---
import WithFragment from '../components/WithFragment.mdx';
---
<WithFragment />

View file

@ -0,0 +1,4 @@
<div class="slotted">
<div data-default-slot><slot /></div>
<div data-named-slot><slot name="named" /></div>
</div>

View file

@ -0,0 +1,15 @@
import Slotted from './Slotted.astro'
# Hello slotted component!
<Slotted>
Default content.
<Fragment slot="named">
Content for named slot.
</Fragment>
</Slotted>

View file

@ -0,0 +1,11 @@
---
const components = await Astro.glob('../components/*.mdx');
---
<div data-default-export>
{components.map(Component => <Component.default />)}
</div>
<div data-content-export>
{components.map(({ Content }) => <Content />)}
</div>

View file

@ -0,0 +1,5 @@
---
import Test from '../components/Test.mdx';
---
<Test />

View file

@ -51,6 +51,41 @@ describe('MDX Component', () => {
expect(h1.textContent).to.equal('Hello component!');
expect(foo.textContent).to.equal('bar');
});
describe('with <Fragment>', () => {
it('supports top-level imports', async () => {
const html = await fixture.readFile('/w-fragment/index.html');
const { document } = parseHTML(html);
const h1 = document.querySelector('h1');
const p = document.querySelector('p');
expect(h1.textContent).to.equal('MDX containing <Fragment />');
expect(p.textContent).to.equal('bar');
});
it('supports glob imports - <Component.default />', async () => {
const html = await fixture.readFile('/glob/index.html');
const { document } = parseHTML(html);
const h = document.querySelector('[data-default-export] [data-file="WithFragment.mdx"] h1');
const p = document.querySelector('[data-default-export] [data-file="WithFragment.mdx"] p');
expect(h.textContent).to.equal('MDX containing <Fragment />');
expect(p.textContent).to.equal('bar');
});
it('supports glob imports - <Content />', async () => {
const html = await fixture.readFile('/glob/index.html');
const { document } = parseHTML(html);
const h = document.querySelector('[data-content-export] [data-file="WithFragment.mdx"] h1');
const p = document.querySelector('[data-content-export] [data-file="WithFragment.mdx"] p');
expect(h.textContent).to.equal('MDX containing <Fragment />');
expect(p.textContent).to.equal('bar');
});
});
});
describe('dev', () => {
@ -108,5 +143,49 @@ describe('MDX Component', () => {
expect(h1.textContent).to.equal('Hello component!');
expect(foo.textContent).to.equal('bar');
});
describe('with <Fragment>', () => {
it('supports top-level imports', async () => {
const res = await fixture.fetch('/w-fragment');
expect(res.status).to.equal(200);
const html = await res.text();
const { document } = parseHTML(html);
const h1 = document.querySelector('h1');
const p = document.querySelector('p');
expect(h1.textContent).to.equal('MDX containing <Fragment />');
expect(p.textContent).to.equal('bar');
});
it('supports glob imports - <Component.default />', async () => {
const res = await fixture.fetch('/glob');
expect(res.status).to.equal(200);
const html = await res.text();
const { document } = parseHTML(html);
const h = document.querySelector('[data-default-export] [data-file="WithFragment.mdx"] h1');
const p = document.querySelector('[data-default-export] [data-file="WithFragment.mdx"] p');
expect(h.textContent).to.equal('MDX containing <Fragment />');
expect(p.textContent).to.equal('bar');
});
it('supports glob imports - <Content />', async () => {
const res = await fixture.fetch('/glob');
expect(res.status).to.equal(200);
const html = await res.text();
const { document } = parseHTML(html);
const h = document.querySelector('[data-content-export] [data-file="WithFragment.mdx"] h1');
const p = document.querySelector('[data-content-export] [data-file="WithFragment.mdx"] p');
expect(h.textContent).to.equal('MDX containing <Fragment />');
expect(p.textContent).to.equal('bar');
});
});
});
});

View file

@ -0,0 +1,124 @@
import mdx from '@astrojs/mdx';
import { expect } from 'chai';
import { parseHTML } from 'linkedom';
import { loadFixture } from '../../../astro/test/test-utils.js';
describe('MDX slots', () => {
let fixture;
before(async () => {
fixture = await loadFixture({
root: new URL('./fixtures/mdx-slots/', import.meta.url),
integrations: [mdx()],
});
});
describe('build', () => {
before(async () => {
await fixture.build();
});
it('supports top-level imports', async () => {
const html = await fixture.readFile('/index.html');
const { document } = parseHTML(html);
const h1 = document.querySelector('h1');
const defaultSlot = document.querySelector('[data-default-slot]');
const namedSlot = document.querySelector('[data-named-slot]');
expect(h1.textContent).to.equal('Hello slotted component!');
expect(defaultSlot.textContent).to.equal('Default content.');
expect(namedSlot.textContent).to.equal('Content for named slot.');
});
it('supports glob imports - <Component.default />', async () => {
const html = await fixture.readFile('/glob/index.html');
const { document } = parseHTML(html);
const h1 = document.querySelector('[data-default-export] h1');
const defaultSlot = document.querySelector('[data-default-export] [data-default-slot]');
const namedSlot = document.querySelector('[data-default-export] [data-named-slot]');
expect(h1.textContent).to.equal('Hello slotted component!');
expect(defaultSlot.textContent).to.equal('Default content.');
expect(namedSlot.textContent).to.equal('Content for named slot.');
});
it('supports glob imports - <Content />', async () => {
const html = await fixture.readFile('/glob/index.html');
const { document } = parseHTML(html);
const h1 = document.querySelector('[data-content-export] h1');
const defaultSlot = document.querySelector('[data-content-export] [data-default-slot]');
const namedSlot = document.querySelector('[data-content-export] [data-named-slot]');
expect(h1.textContent).to.equal('Hello slotted component!');
expect(defaultSlot.textContent).to.equal('Default content.');
expect(namedSlot.textContent).to.equal('Content for named slot.');
});
});
describe('dev', () => {
let devServer;
before(async () => {
devServer = await fixture.startDevServer();
});
after(async () => {
await devServer.stop();
});
it('supports top-level imports', async () => {
const res = await fixture.fetch('/');
expect(res.status).to.equal(200);
const html = await res.text();
const { document } = parseHTML(html);
const h1 = document.querySelector('h1');
const defaultSlot = document.querySelector('[data-default-slot]');
const namedSlot = document.querySelector('[data-named-slot]');
expect(h1.textContent).to.equal('Hello slotted component!');
expect(defaultSlot.textContent).to.equal('Default content.');
expect(namedSlot.textContent).to.equal('Content for named slot.');
});
it('supports glob imports - <Component.default />', async () => {
const res = await fixture.fetch('/glob');
expect(res.status).to.equal(200);
const html = await res.text();
const { document } = parseHTML(html);
const h1 = document.querySelector('[data-default-export] h1');
const defaultSlot = document.querySelector('[data-default-export] [data-default-slot]');
const namedSlot = document.querySelector('[data-default-export] [data-named-slot]');
expect(h1.textContent).to.equal('Hello slotted component!');
expect(defaultSlot.textContent).to.equal('Default content.');
expect(namedSlot.textContent).to.equal('Content for named slot.');
});
it('supports glob imports - <Content />', async () => {
const res = await fixture.fetch('/glob');
expect(res.status).to.equal(200);
const html = await res.text();
const { document } = parseHTML(html);
const h1 = document.querySelector('[data-content-export] h1');
const defaultSlot = document.querySelector('[data-content-export] [data-default-slot]');
const namedSlot = document.querySelector('[data-content-export] [data-named-slot]');
expect(h1.textContent).to.equal('Hello slotted component!');
expect(defaultSlot.textContent).to.equal('Default content.');
expect(namedSlot.textContent).to.equal('Content for named slot.');
});
});
});