Skip to content

Commit

Permalink
Support rendering <Fragment> in MDX <Content /> component (#5522)
Browse files Browse the repository at this point in the history
  • Loading branch information
delucis committed Dec 5, 2022
1 parent c1a944d commit efc4363
Show file tree
Hide file tree
Showing 12 changed files with 286 additions and 8 deletions.
5 changes: 5 additions & 0 deletions .changeset/sweet-chairs-buy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@astrojs/mdx': patch
---

Support use of `<Fragment>` in MDX files rendered with `<Content />` component
1 change: 1 addition & 0 deletions packages/astro/src/core/render/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
Expand Down
29 changes: 23 additions & 6 deletions packages/integrations/mdx/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 [, moduleExports] = parseESM(code);
const [moduleImports, 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')) {
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# MDX containing `<Fragment />`

<p><Fragment>bar</Fragment></p>
Original file line number Diff line number Diff line change
@@ -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>
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
import WithFragment from '../components/WithFragment.mdx';
---

<WithFragment />
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<div class="slotted">
<div data-default-slot><slot /></div>
<div data-named-slot><slot name="named" /></div>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import Slotted from './Slotted.astro'

# Hello slotted component!

<Slotted>

Default content.

<Fragment slot="named">

Content for named slot.

</Fragment>

</Slotted>
Original file line number Diff line number Diff line change
@@ -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>
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
import Test from '../components/Test.mdx';
---

<Test />
79 changes: 79 additions & 0 deletions packages/integrations/mdx/test/mdx-component.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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');
});
});
});
});
124 changes: 124 additions & 0 deletions packages/integrations/mdx/test/mdx-slots.js
Original file line number Diff line number Diff line change
@@ -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.');
});
});
});

0 comments on commit efc4363

Please sign in to comment.