diff --git a/.changeset/sweet-chairs-buy.md b/.changeset/sweet-chairs-buy.md new file mode 100644 index 000000000..43148b12c --- /dev/null +++ b/.changeset/sweet-chairs-buy.md @@ -0,0 +1,5 @@ +--- +'@astrojs/mdx': patch +--- + +Support use of `` in MDX files rendered with `` component diff --git a/packages/astro/src/core/render/core.ts b/packages/astro/src/core/render/core.ts index b43d21d27..86aa7fb3f 100644 --- a/packages/astro/src/core/render/core.ts +++ b/packages/astro/src/core/render/core.ts @@ -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 }), diff --git a/packages/integrations/mdx/src/index.ts b/packages/integrations/mdx/src/index.ts index e7a896d18..20a6bee82 100644 --- a/packages/integrations/mdx/src/index.ts +++ b/packages/integrations/mdx/src/index.ts @@ -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 `` - // 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 `` + // 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) { diff --git a/packages/integrations/mdx/test/fixtures/mdx-component/src/components/WithFragment.mdx b/packages/integrations/mdx/test/fixtures/mdx-component/src/components/WithFragment.mdx new file mode 100644 index 000000000..c6058697e --- /dev/null +++ b/packages/integrations/mdx/test/fixtures/mdx-component/src/components/WithFragment.mdx @@ -0,0 +1,3 @@ +# MDX containing `` + +

bar

diff --git a/packages/integrations/mdx/test/fixtures/mdx-component/src/pages/glob.astro b/packages/integrations/mdx/test/fixtures/mdx-component/src/pages/glob.astro index ae857fe27..ab5f417b1 100644 --- a/packages/integrations/mdx/test/fixtures/mdx-component/src/pages/glob.astro +++ b/packages/integrations/mdx/test/fixtures/mdx-component/src/pages/glob.astro @@ -1,11 +1,20 @@ --- +import { parse } from 'node:path'; const components = await Astro.glob('../components/*.mdx'); ---
- {components.map(Component => )} + {components.map(Component => ( +
+ +
+ ))}
- {components.map(({ Content }) => )} + {components.map(({ Content, file }) => ( +
+ +
+ ))}
diff --git a/packages/integrations/mdx/test/fixtures/mdx-component/src/pages/w-fragment.astro b/packages/integrations/mdx/test/fixtures/mdx-component/src/pages/w-fragment.astro new file mode 100644 index 000000000..d394413f0 --- /dev/null +++ b/packages/integrations/mdx/test/fixtures/mdx-component/src/pages/w-fragment.astro @@ -0,0 +1,5 @@ +--- +import WithFragment from '../components/WithFragment.mdx'; +--- + + diff --git a/packages/integrations/mdx/test/fixtures/mdx-slots/src/components/Slotted.astro b/packages/integrations/mdx/test/fixtures/mdx-slots/src/components/Slotted.astro new file mode 100644 index 000000000..99453b685 --- /dev/null +++ b/packages/integrations/mdx/test/fixtures/mdx-slots/src/components/Slotted.astro @@ -0,0 +1,4 @@ +
+
+
+
diff --git a/packages/integrations/mdx/test/fixtures/mdx-slots/src/components/Test.mdx b/packages/integrations/mdx/test/fixtures/mdx-slots/src/components/Test.mdx new file mode 100644 index 000000000..8e901aa1a --- /dev/null +++ b/packages/integrations/mdx/test/fixtures/mdx-slots/src/components/Test.mdx @@ -0,0 +1,15 @@ +import Slotted from './Slotted.astro' + +# Hello slotted component! + + + +Default content. + + + +Content for named slot. + + + + diff --git a/packages/integrations/mdx/test/fixtures/mdx-slots/src/pages/glob.astro b/packages/integrations/mdx/test/fixtures/mdx-slots/src/pages/glob.astro new file mode 100644 index 000000000..ae857fe27 --- /dev/null +++ b/packages/integrations/mdx/test/fixtures/mdx-slots/src/pages/glob.astro @@ -0,0 +1,11 @@ +--- +const components = await Astro.glob('../components/*.mdx'); +--- + +
+ {components.map(Component => )} +
+ +
+ {components.map(({ Content }) => )} +
diff --git a/packages/integrations/mdx/test/fixtures/mdx-slots/src/pages/index.astro b/packages/integrations/mdx/test/fixtures/mdx-slots/src/pages/index.astro new file mode 100644 index 000000000..ed5ae98a3 --- /dev/null +++ b/packages/integrations/mdx/test/fixtures/mdx-slots/src/pages/index.astro @@ -0,0 +1,5 @@ +--- +import Test from '../components/Test.mdx'; +--- + + diff --git a/packages/integrations/mdx/test/mdx-component.test.js b/packages/integrations/mdx/test/mdx-component.test.js index 84210b342..e2c8417f9 100644 --- a/packages/integrations/mdx/test/mdx-component.test.js +++ b/packages/integrations/mdx/test/mdx-component.test.js @@ -51,6 +51,41 @@ describe('MDX Component', () => { expect(h1.textContent).to.equal('Hello component!'); expect(foo.textContent).to.equal('bar'); }); + + describe('with ', () => { + 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 '); + expect(p.textContent).to.equal('bar'); + }); + + it('supports glob imports - ', 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 '); + expect(p.textContent).to.equal('bar'); + }); + + it('supports glob imports - ', 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 '); + 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 ', () => { + 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 '); + expect(p.textContent).to.equal('bar'); + }); + + it('supports glob imports - ', 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 '); + expect(p.textContent).to.equal('bar'); + }); + + it('supports glob imports - ', 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 '); + expect(p.textContent).to.equal('bar'); + }); + }); }); }); diff --git a/packages/integrations/mdx/test/mdx-slots.js b/packages/integrations/mdx/test/mdx-slots.js new file mode 100644 index 000000000..f0557cc4a --- /dev/null +++ b/packages/integrations/mdx/test/mdx-slots.js @@ -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 - ', 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 - ', 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 - ', 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 - ', 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.'); + }); + }); +});