Support rendering <Fragment>
in MDX <Content />
component (#5522)
This commit is contained in:
parent
c1a944d55a
commit
efc4363e0b
12 changed files with 285 additions and 7 deletions
5
.changeset/sweet-chairs-buy.md
Normal file
5
.changeset/sweet-chairs-buy.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'@astrojs/mdx': patch
|
||||
---
|
||||
|
||||
Support use of `<Fragment>` in MDX files rendered with `<Content />` component
|
|
@ -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 }),
|
||||
|
|
|
@ -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) {
|
||||
|
|
3
packages/integrations/mdx/test/fixtures/mdx-component/src/components/WithFragment.mdx
vendored
Normal file
3
packages/integrations/mdx/test/fixtures/mdx-component/src/components/WithFragment.mdx
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
# MDX containing `<Fragment />`
|
||||
|
||||
<p><Fragment>bar</Fragment></p>
|
|
@ -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>
|
||||
|
|
5
packages/integrations/mdx/test/fixtures/mdx-component/src/pages/w-fragment.astro
vendored
Normal file
5
packages/integrations/mdx/test/fixtures/mdx-component/src/pages/w-fragment.astro
vendored
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
import WithFragment from '../components/WithFragment.mdx';
|
||||
---
|
||||
|
||||
<WithFragment />
|
4
packages/integrations/mdx/test/fixtures/mdx-slots/src/components/Slotted.astro
vendored
Normal file
4
packages/integrations/mdx/test/fixtures/mdx-slots/src/components/Slotted.astro
vendored
Normal file
|
@ -0,0 +1,4 @@
|
|||
<div class="slotted">
|
||||
<div data-default-slot><slot /></div>
|
||||
<div data-named-slot><slot name="named" /></div>
|
||||
</div>
|
15
packages/integrations/mdx/test/fixtures/mdx-slots/src/components/Test.mdx
vendored
Normal file
15
packages/integrations/mdx/test/fixtures/mdx-slots/src/components/Test.mdx
vendored
Normal 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>
|
11
packages/integrations/mdx/test/fixtures/mdx-slots/src/pages/glob.astro
vendored
Normal file
11
packages/integrations/mdx/test/fixtures/mdx-slots/src/pages/glob.astro
vendored
Normal 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>
|
5
packages/integrations/mdx/test/fixtures/mdx-slots/src/pages/index.astro
vendored
Normal file
5
packages/integrations/mdx/test/fixtures/mdx-slots/src/pages/index.astro
vendored
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
import Test from '../components/Test.mdx';
|
||||
---
|
||||
|
||||
<Test />
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
124
packages/integrations/mdx/test/mdx-slots.js
Normal file
124
packages/integrations/mdx/test/mdx-slots.js
Normal 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.');
|
||||
});
|
||||
});
|
||||
});
|
Loading…
Reference in a new issue