diff --git a/packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/component/index.astro b/packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/component/index.astro
new file mode 100644
index 000000000000..b91c608eb5d4
--- /dev/null
+++ b/packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/component/index.astro
@@ -0,0 +1,5 @@
+---
+import Test from '../../components/component/Test.mdx';
+---
+
+
diff --git a/packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/component/w-fragment.astro b/packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/component/w-fragment.astro
new file mode 100644
index 000000000000..3a8ca98240be
--- /dev/null
+++ b/packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/component/w-fragment.astro
@@ -0,0 +1,5 @@
+---
+import WithFragment from '../../components/component/WithFragment.mdx';
+---
+
+
diff --git a/packages/integrations/mdx/test/fixtures/mdx-frontmatter/src/pages/glob.json.js b/packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/frontmatter/glob.json.js
similarity index 100%
rename from packages/integrations/mdx/test/fixtures/mdx-frontmatter/src/pages/glob.json.js
rename to packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/frontmatter/glob.json.js
diff --git a/packages/integrations/mdx/test/fixtures/mdx-frontmatter/src/pages/index.mdx b/packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/frontmatter/index.mdx
similarity index 87%
rename from packages/integrations/mdx/test/fixtures/mdx-frontmatter/src/pages/index.mdx
rename to packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/frontmatter/index.mdx
index e6f9c8f4a689..a5f12f5af94d 100644
--- a/packages/integrations/mdx/test/fixtures/mdx-frontmatter/src/pages/index.mdx
+++ b/packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/frontmatter/index.mdx
@@ -1,6 +1,6 @@
---
title: 'Using YAML frontmatter'
-layout: '../layouts/Base.astro'
+layout: '../../layouts/Base.astro'
illThrowIfIDontExist: "Oh no, that's scary!"
---
diff --git a/packages/integrations/mdx/test/fixtures/mdx-frontmatter/src/pages/with-headings.mdx b/packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/frontmatter/with-headings.mdx
similarity index 50%
rename from packages/integrations/mdx/test/fixtures/mdx-frontmatter/src/pages/with-headings.mdx
rename to packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/frontmatter/with-headings.mdx
index cc4db9582f83..9fa414968938 100644
--- a/packages/integrations/mdx/test/fixtures/mdx-frontmatter/src/pages/with-headings.mdx
+++ b/packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/frontmatter/with-headings.mdx
@@ -1,5 +1,5 @@
---
-layout: '../layouts/Base.astro'
+layout: '../../layouts/Base.astro'
---
## Section 1
diff --git a/packages/integrations/mdx/test/fixtures/mdx-script-style-raw/src/pages/index.mdx b/packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/script-style-raw.mdx
similarity index 100%
rename from packages/integrations/mdx/test/fixtures/mdx-script-style-raw/src/pages/index.mdx
rename to packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/script-style-raw.mdx
diff --git a/packages/integrations/mdx/test/fixtures/mdx-slots/src/pages/glob.astro b/packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/slots/glob.astro
similarity index 63%
rename from packages/integrations/mdx/test/fixtures/mdx-slots/src/pages/glob.astro
rename to packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/slots/glob.astro
index 2bd8e613c113..74a9f043d5bb 100644
--- a/packages/integrations/mdx/test/fixtures/mdx-slots/src/pages/glob.astro
+++ b/packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/slots/glob.astro
@@ -1,5 +1,5 @@
---
-const components = Object.values(import.meta.glob('../components/*.mdx', { eager: true }));
+const components = Object.values(import.meta.glob('../../components/slots/*.mdx', { eager: true }));
---
diff --git a/packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/slots/index.astro b/packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/slots/index.astro
new file mode 100644
index 000000000000..0817e6a673aa
--- /dev/null
+++ b/packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/slots/index.astro
@@ -0,0 +1,5 @@
+---
+import Test from '../../components/slots/Test.mdx';
+---
+
+
diff --git a/packages/integrations/mdx/test/fixtures/mdx-get-static-paths/src/pages/[slug].astro b/packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/static-paths/[slug].astro
similarity index 87%
rename from packages/integrations/mdx/test/fixtures/mdx-get-static-paths/src/pages/[slug].astro
rename to packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/static-paths/[slug].astro
index 01fc3a2573f6..4e5e6b464ec6 100644
--- a/packages/integrations/mdx/test/fixtures/mdx-get-static-paths/src/pages/[slug].astro
+++ b/packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/static-paths/[slug].astro
@@ -1,6 +1,6 @@
---
export const getStaticPaths = async () => {
- const content = Object.values(import.meta.glob('../content/*.mdx', { eager: true }));
+ const content = Object.values(import.meta.glob('../../content/*.mdx', { eager: true }));
return content
.filter((page) => !page.frontmatter.draft) // skip drafts
diff --git a/packages/integrations/mdx/test/fixtures/mdx-url-export/src/pages/pages.json.js b/packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/url-export/pages.json.js
similarity index 100%
rename from packages/integrations/mdx/test/fixtures/mdx-url-export/src/pages/pages.json.js
rename to packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/url-export/pages.json.js
diff --git a/packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/url-export/test-1.mdx b/packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/url-export/test-1.mdx
new file mode 100644
index 000000000000..68ac2a064e03
--- /dev/null
+++ b/packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/url-export/test-1.mdx
@@ -0,0 +1 @@
+# I'm a page with a url of "/url-export/test-1!"
diff --git a/packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/url-export/test-2.mdx b/packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/url-export/test-2.mdx
new file mode 100644
index 000000000000..745ffee0d953
--- /dev/null
+++ b/packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/url-export/test-2.mdx
@@ -0,0 +1 @@
+# I'm a page with a url of "/url-export/test-2!"
diff --git a/packages/integrations/mdx/test/fixtures/mdx-url-export/src/pages/with-url-override.mdx b/packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/url-export/with-url-override.mdx
similarity index 100%
rename from packages/integrations/mdx/test/fixtures/mdx-url-export/src/pages/with-url-override.mdx
rename to packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/url-export/with-url-override.mdx
diff --git a/packages/integrations/mdx/test/fixtures/mdx-component/src/pages/index.astro b/packages/integrations/mdx/test/fixtures/mdx-component/src/pages/index.astro
deleted file mode 100644
index ed5ae98a3487..000000000000
--- a/packages/integrations/mdx/test/fixtures/mdx-component/src/pages/index.astro
+++ /dev/null
@@ -1,5 +0,0 @@
----
-import Test from '../components/Test.mdx';
----
-
-
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
deleted file mode 100644
index d394413f0903..000000000000
--- a/packages/integrations/mdx/test/fixtures/mdx-component/src/pages/w-fragment.astro
+++ /dev/null
@@ -1,5 +0,0 @@
----
-import WithFragment from '../components/WithFragment.mdx';
----
-
-
diff --git a/packages/integrations/mdx/test/fixtures/mdx-escape/src/components/Em.astro b/packages/integrations/mdx/test/fixtures/mdx-escape/src/components/Em.astro
deleted file mode 100644
index 8166c0586b60..000000000000
--- a/packages/integrations/mdx/test/fixtures/mdx-escape/src/components/Em.astro
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
-
diff --git a/packages/integrations/mdx/test/fixtures/mdx-escape/src/components/P.astro b/packages/integrations/mdx/test/fixtures/mdx-escape/src/components/P.astro
deleted file mode 100644
index e29ac6d8f0d3..000000000000
--- a/packages/integrations/mdx/test/fixtures/mdx-escape/src/components/P.astro
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/packages/integrations/mdx/test/fixtures/mdx-escape/src/components/Title.astro b/packages/integrations/mdx/test/fixtures/mdx-escape/src/components/Title.astro
deleted file mode 100644
index 333ec04a2c3f..000000000000
--- a/packages/integrations/mdx/test/fixtures/mdx-escape/src/components/Title.astro
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/packages/integrations/mdx/test/fixtures/mdx-escape/src/pages/html-tag.mdx b/packages/integrations/mdx/test/fixtures/mdx-escape/src/pages/html-tag.mdx
deleted file mode 100644
index e668c0dc7b1e..000000000000
--- a/packages/integrations/mdx/test/fixtures/mdx-escape/src/pages/html-tag.mdx
+++ /dev/null
@@ -1,5 +0,0 @@
-import P from '../components/P.astro';
-import Em from '../components/Em.astro';
-
-
Render Me
-
Me
diff --git a/packages/integrations/mdx/test/fixtures/mdx-escape/src/pages/index.mdx b/packages/integrations/mdx/test/fixtures/mdx-escape/src/pages/index.mdx
deleted file mode 100644
index d1c6cec9d9ec..000000000000
--- a/packages/integrations/mdx/test/fixtures/mdx-escape/src/pages/index.mdx
+++ /dev/null
@@ -1,13 +0,0 @@
-import P from '../components/P.astro';
-import Em from '../components/Em.astro';
-import Title from '../components/Title.astro';
-
-export const components = { p: P, em: Em, h1: Title };
-
-# Hello _there_
-
-# _there_
-
-Hello _there_
-
-_there_
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
deleted file mode 100644
index ed5ae98a3487..000000000000
--- a/packages/integrations/mdx/test/fixtures/mdx-slots/src/pages/index.astro
+++ /dev/null
@@ -1,5 +0,0 @@
----
-import Test from '../components/Test.mdx';
----
-
-
diff --git a/packages/integrations/mdx/test/fixtures/mdx-url-export/src/pages/test-1.mdx b/packages/integrations/mdx/test/fixtures/mdx-url-export/src/pages/test-1.mdx
deleted file mode 100644
index c9b984787ff2..000000000000
--- a/packages/integrations/mdx/test/fixtures/mdx-url-export/src/pages/test-1.mdx
+++ /dev/null
@@ -1 +0,0 @@
-# I'm a page with a url of "/test-1!"
diff --git a/packages/integrations/mdx/test/fixtures/mdx-url-export/src/pages/test-2.mdx b/packages/integrations/mdx/test/fixtures/mdx-url-export/src/pages/test-2.mdx
deleted file mode 100644
index 360f72fc351a..000000000000
--- a/packages/integrations/mdx/test/fixtures/mdx-url-export/src/pages/test-2.mdx
+++ /dev/null
@@ -1 +0,0 @@
-# I'm a page with a url of "/test-2!"
diff --git a/packages/integrations/mdx/test/mdx-basics.test.js b/packages/integrations/mdx/test/mdx-basics.test.js
new file mode 100644
index 000000000000..b2e4de4818fa
--- /dev/null
+++ b/packages/integrations/mdx/test/mdx-basics.test.js
@@ -0,0 +1,460 @@
+import * as assert from 'node:assert/strict';
+import { after, before, describe, it } from 'node:test';
+import mdx from '@astrojs/mdx';
+import * as cheerio from 'cheerio';
+import { parseHTML } from 'linkedom';
+import { loadFixture } from '../../../astro/test/test-utils.js';
+
+// Merged fixture: combines mdx-component, mdx-slots, mdx-frontmatter,
+// mdx-url-export, mdx-get-static-paths, and mdx-script-style-raw.
+// All use the same config: integrations: [mdx()], sharing one build and one dev server.
+const FIXTURE_ROOT = new URL('./fixtures/mdx-basics/', import.meta.url);
+
+describe('MDX basics (merged fixture)', () => {
+ let fixture;
+
+ before(async () => {
+ fixture = await loadFixture({
+ root: FIXTURE_ROOT,
+ integrations: [mdx()],
+ });
+ });
+
+ describe('build', () => {
+ before(async () => {
+ await fixture.build();
+ });
+
+ // --- MDX Component tests (was mdx-component.test.js) ---
+
+ describe('component', () => {
+ it('supports top-level imports', async () => {
+ const html = await fixture.readFile('/component/index.html');
+ const { document } = parseHTML(html);
+
+ const h1 = document.querySelector('h1');
+ const foo = document.querySelector('#foo');
+
+ assert.equal(h1.textContent, 'Hello component!');
+ assert.equal(foo.textContent, 'bar');
+ });
+
+ it('supports glob imports -
', async () => {
+ const html = await fixture.readFile('/component/glob/index.html');
+ const { document } = parseHTML(html);
+
+ const h1 = document.querySelector('[data-default-export] h1');
+ const foo = document.querySelector('[data-default-export] #foo');
+
+ assert.equal(h1.textContent, 'Hello component!');
+ assert.equal(foo.textContent, 'bar');
+ });
+
+ it('supports glob imports -
', async () => {
+ const html = await fixture.readFile('/component/glob/index.html');
+ const { document } = parseHTML(html);
+
+ const h1 = document.querySelector('[data-content-export] h1');
+ const foo = document.querySelector('[data-content-export] #foo');
+
+ assert.equal(h1.textContent, 'Hello component!');
+ assert.equal(foo.textContent, 'bar');
+ });
+
+ describe('with
', () => {
+ it('supports top-level imports', async () => {
+ const html = await fixture.readFile('/component/w-fragment/index.html');
+ const { document } = parseHTML(html);
+
+ const h1 = document.querySelector('h1');
+ const p = document.querySelector('p');
+
+ assert.equal(h1.textContent, 'MDX containing ');
+ assert.equal(p.textContent, 'bar');
+ });
+
+ it('supports glob imports - ', async () => {
+ const html = await fixture.readFile('/component/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',
+ );
+
+ assert.equal(h.textContent, 'MDX containing ');
+ assert.equal(p.textContent, 'bar');
+ });
+
+ it('supports glob imports - ', async () => {
+ const html = await fixture.readFile('/component/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',
+ );
+
+ assert.equal(h.textContent, 'MDX containing ');
+ assert.equal(p.textContent, 'bar');
+ });
+ });
+ });
+
+ // --- MDX Slots tests (was mdx-slots.test.js) ---
+
+ describe('slots', () => {
+ it('supports top-level imports', async () => {
+ const html = await fixture.readFile('/slots/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]');
+
+ assert.equal(h1.textContent, 'Hello slotted component!');
+ assert.equal(defaultSlot.textContent, 'Default content.');
+ assert.equal(namedSlot.textContent, 'Content for named slot.');
+ });
+
+ it('supports glob imports - ', async () => {
+ const html = await fixture.readFile('/slots/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]');
+
+ assert.equal(h1.textContent, 'Hello slotted component!');
+ assert.equal(defaultSlot.textContent, 'Default content.');
+ assert.equal(namedSlot.textContent, 'Content for named slot.');
+ });
+
+ it('supports glob imports - ', async () => {
+ const html = await fixture.readFile('/slots/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]');
+
+ assert.equal(h1.textContent, 'Hello slotted component!');
+ assert.equal(defaultSlot.textContent, 'Default content.');
+ assert.equal(namedSlot.textContent, 'Content for named slot.');
+ });
+ });
+
+ // --- MDX Frontmatter tests (was mdx-frontmatter.test.js) ---
+
+ describe('frontmatter', () => {
+ it('builds when "frontmatter.property" is in JSX expression', async () => {
+ assert.equal(true, true);
+ });
+
+ it('extracts frontmatter to "frontmatter" export', async () => {
+ const { titles } = JSON.parse(await fixture.readFile('/frontmatter/glob.json'));
+ assert.equal(titles.includes('Using YAML frontmatter'), true);
+ });
+
+ it('renders layout from "layout" frontmatter property', async () => {
+ const html = await fixture.readFile('/frontmatter/index.html');
+ const { document } = parseHTML(html);
+
+ const layoutParagraph = document.querySelector('[data-layout-rendered]');
+
+ assert.notEqual(layoutParagraph, null);
+ });
+
+ it('passes frontmatter to layout via "content" and "frontmatter" props', async () => {
+ const html = await fixture.readFile('/frontmatter/index.html');
+ const { document } = parseHTML(html);
+
+ const contentTitle = document.querySelector('[data-content-title]');
+ const frontmatterTitle = document.querySelector('[data-frontmatter-title]');
+
+ assert.equal(contentTitle.textContent, 'Using YAML frontmatter');
+ assert.equal(frontmatterTitle.textContent, 'Using YAML frontmatter');
+ });
+
+ it('passes headings to layout via "headings" prop', async () => {
+ const html = await fixture.readFile('/frontmatter/with-headings/index.html');
+ const { document } = parseHTML(html);
+
+ const headingSlugs = [...document.querySelectorAll('[data-headings] > li')].map(
+ (el) => el.textContent,
+ );
+
+ assert.equal(headingSlugs.length > 0, true);
+ assert.equal(headingSlugs.includes('section-1'), true);
+ assert.equal(headingSlugs.includes('section-2'), true);
+ });
+
+ it('passes "file" and "url" to layout', async () => {
+ const html = await fixture.readFile('/frontmatter/with-headings/index.html');
+ const { document } = parseHTML(html);
+
+ const frontmatterFile = document.querySelector('[data-frontmatter-file]')?.textContent;
+ const frontmatterUrl = document.querySelector('[data-frontmatter-url]')?.textContent;
+ const file = document.querySelector('[data-file]')?.textContent;
+ const url = document.querySelector('[data-url]')?.textContent;
+
+ assert.equal(
+ frontmatterFile?.endsWith('with-headings.mdx'),
+ true,
+ '"file" prop does not end with correct path or is undefined',
+ );
+ assert.equal(frontmatterUrl, '/frontmatter/with-headings');
+ assert.equal(file, frontmatterFile);
+ assert.equal(url, frontmatterUrl);
+ });
+ });
+
+ // --- MDX URL Export tests (was mdx-url-export.test.js) ---
+
+ describe('url export', () => {
+ it('generates correct urls in glob result', async () => {
+ const { urls } = JSON.parse(await fixture.readFile('/url-export/pages.json'));
+ assert.equal(urls.includes('/url-export/test-1'), true);
+ assert.equal(urls.includes('/url-export/test-2'), true);
+ });
+
+ it('respects "export url" overrides in glob result', async () => {
+ const { urls } = JSON.parse(await fixture.readFile('/url-export/pages.json'));
+ assert.equal(urls.includes('/AH!'), true);
+ });
+ });
+
+ // --- getStaticPaths tests (was mdx-get-static-paths.test.js) ---
+
+ describe('getStaticPaths', () => {
+ it('Provides file and url', async () => {
+ const html = await fixture.readFile('/static-paths/one/index.html');
+
+ const $ = cheerio.load(html);
+ assert.equal($('p').text(), 'First mdx file');
+ assert.equal($('#one').text(), 'hello', 'Frontmatter included');
+ assert.equal($('#url').text(), 'src/content/1.mdx', 'url is included');
+ assert.equal(
+ $('#file').text().includes('fixtures/mdx-basics/src/content/1.mdx'),
+ true,
+ 'file is included',
+ );
+ });
+ });
+
+ // --- MDX script/style raw tests (was mdx-script-style-raw.test.js, build part) ---
+
+ describe('script-style-raw', () => {
+ it('works with raw script and style strings', async () => {
+ const html = await fixture.readFile('/script-style-raw/index.html');
+ const { document } = parseHTML(html);
+
+ const scriptContent = document.getElementById('test-script').innerHTML;
+ assert.equal(
+ scriptContent.includes("console.log('raw script')"),
+ true,
+ 'script should not be html-escaped',
+ );
+
+ const styleContent = document.getElementById('test-style').innerHTML;
+ assert.equal(
+ styleContent.includes('h1[id="script-style-raw"]'),
+ true,
+ 'style should not be html-escaped',
+ );
+ });
+ });
+ });
+
+ describe('dev', () => {
+ let devServer;
+
+ before(async () => {
+ devServer = await fixture.startDevServer();
+ });
+
+ after(async () => {
+ await devServer.stop();
+ });
+
+ // --- MDX Component dev tests ---
+
+ describe('component', () => {
+ it('supports top-level imports', async () => {
+ const res = await fixture.fetch('/component');
+ assert.equal(res.status, 200);
+
+ const html = await res.text();
+ const { document } = parseHTML(html);
+
+ const h1 = document.querySelector('h1');
+ const foo = document.querySelector('#foo');
+
+ assert.equal(h1.textContent, 'Hello component!');
+ assert.equal(foo.textContent, 'bar');
+ });
+
+ it('supports glob imports - ', async () => {
+ const res = await fixture.fetch('/component/glob');
+ assert.equal(res.status, 200);
+
+ const html = await res.text();
+ const { document } = parseHTML(html);
+
+ const h1 = document.querySelector('[data-default-export] h1');
+ const foo = document.querySelector('[data-default-export] #foo');
+
+ assert.equal(h1.textContent, 'Hello component!');
+ assert.equal(foo.textContent, 'bar');
+ });
+
+ it('supports glob imports - ', async () => {
+ const res = await fixture.fetch('/component/glob');
+ assert.equal(res.status, 200);
+
+ const html = await res.text();
+ const { document } = parseHTML(html);
+
+ const h1 = document.querySelector('[data-content-export] h1');
+ const foo = document.querySelector('[data-content-export] #foo');
+
+ assert.equal(h1.textContent, 'Hello component!');
+ assert.equal(foo.textContent, 'bar');
+ });
+
+ describe('with ', () => {
+ it('supports top-level imports', async () => {
+ const res = await fixture.fetch('/component/w-fragment');
+ assert.equal(res.status, 200);
+
+ const html = await res.text();
+ const { document } = parseHTML(html);
+
+ const h1 = document.querySelector('h1');
+ const p = document.querySelector('p');
+
+ assert.equal(h1.textContent, 'MDX containing ');
+ assert.equal(p.textContent, 'bar');
+ });
+
+ it('supports glob imports - ', async () => {
+ const res = await fixture.fetch('/component/glob');
+ assert.equal(res.status, 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',
+ );
+
+ assert.equal(h.textContent, 'MDX containing ');
+ assert.equal(p.textContent, 'bar');
+ });
+
+ it('supports glob imports - ', async () => {
+ const res = await fixture.fetch('/component/glob');
+ assert.equal(res.status, 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',
+ );
+
+ assert.equal(h.textContent, 'MDX containing ');
+ assert.equal(p.textContent, 'bar');
+ });
+ });
+ });
+
+ // --- MDX Slots dev tests ---
+
+ describe('slots', () => {
+ it('supports top-level imports', async () => {
+ const res = await fixture.fetch('/slots');
+ assert.equal(res.status, 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]');
+
+ assert.equal(h1.textContent, 'Hello slotted component!');
+ assert.equal(defaultSlot.textContent, 'Default content.');
+ assert.equal(namedSlot.textContent, 'Content for named slot.');
+ });
+
+ it('supports glob imports - ', async () => {
+ const res = await fixture.fetch('/slots/glob');
+ assert.equal(res.status, 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]');
+
+ assert.equal(h1.textContent, 'Hello slotted component!');
+ assert.equal(defaultSlot.textContent, 'Default content.');
+ assert.equal(namedSlot.textContent, 'Content for named slot.');
+ });
+
+ it('supports glob imports - ', async () => {
+ const res = await fixture.fetch('/slots/glob');
+ assert.equal(res.status, 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]');
+
+ assert.equal(h1.textContent, 'Hello slotted component!');
+ assert.equal(defaultSlot.textContent, 'Default content.');
+ assert.equal(namedSlot.textContent, 'Content for named slot.');
+ });
+ });
+
+ // --- MDX script/style raw dev tests ---
+
+ describe('script-style-raw', () => {
+ it('works with raw script and style strings', async () => {
+ const res = await fixture.fetch('/script-style-raw');
+ assert.equal(res.status, 200);
+
+ const html = await res.text();
+ const { document } = parseHTML(html);
+
+ const scriptContent = document.getElementById('test-script').innerHTML;
+ assert.equal(
+ scriptContent.includes("console.log('raw script')"),
+ true,
+ 'script should not be html-escaped',
+ );
+
+ const styleContent = document.getElementById('test-style').innerHTML;
+ assert.equal(
+ styleContent.includes('h1[id="script-style-raw"]'),
+ true,
+ 'style should not be html-escaped',
+ );
+ });
+ });
+ });
+});
diff --git a/packages/integrations/mdx/test/mdx-component.test.js b/packages/integrations/mdx/test/mdx-component.test.js
deleted file mode 100644
index 895d83008244..000000000000
--- a/packages/integrations/mdx/test/mdx-component.test.js
+++ /dev/null
@@ -1,194 +0,0 @@
-import * as assert from 'node:assert/strict';
-import { after, before, describe, it } from 'node:test';
-import mdx from '@astrojs/mdx';
-import { parseHTML } from 'linkedom';
-import { loadFixture } from '../../../astro/test/test-utils.js';
-
-describe('MDX Component', () => {
- let fixture;
-
- before(async () => {
- fixture = await loadFixture({
- root: new URL('./fixtures/mdx-component/', 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 foo = document.querySelector('#foo');
-
- assert.equal(h1.textContent, 'Hello component!');
- assert.equal(foo.textContent, 'bar');
- });
-
- 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 foo = document.querySelector('[data-default-export] #foo');
-
- assert.equal(h1.textContent, 'Hello component!');
- assert.equal(foo.textContent, 'bar');
- });
-
- 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 foo = document.querySelector('[data-content-export] #foo');
-
- assert.equal(h1.textContent, 'Hello component!');
- assert.equal(foo.textContent, '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');
-
- assert.equal(h1.textContent, 'MDX containing ');
- assert.equal(p.textContent, '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');
-
- assert.equal(h.textContent, 'MDX containing ');
- assert.equal(p.textContent, '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');
-
- assert.equal(h.textContent, 'MDX containing ');
- assert.equal(p.textContent, 'bar');
- });
- });
- });
-
- 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('/');
-
- assert.equal(res.status, 200);
-
- const html = await res.text();
- const { document } = parseHTML(html);
-
- const h1 = document.querySelector('h1');
- const foo = document.querySelector('#foo');
-
- assert.equal(h1.textContent, 'Hello component!');
- assert.equal(foo.textContent, 'bar');
- });
-
- it('supports glob imports - ', async () => {
- const res = await fixture.fetch('/glob');
-
- assert.equal(res.status, 200);
-
- const html = await res.text();
- const { document } = parseHTML(html);
-
- const h1 = document.querySelector('[data-default-export] h1');
- const foo = document.querySelector('[data-default-export] #foo');
-
- assert.equal(h1.textContent, 'Hello component!');
- assert.equal(foo.textContent, 'bar');
- });
-
- it('supports glob imports - ', async () => {
- const res = await fixture.fetch('/glob');
-
- assert.equal(res.status, 200);
-
- const html = await res.text();
- const { document } = parseHTML(html);
-
- const h1 = document.querySelector('[data-content-export] h1');
- const foo = document.querySelector('[data-content-export] #foo');
-
- assert.equal(h1.textContent, 'Hello component!');
- assert.equal(foo.textContent, 'bar');
- });
-
- describe('with ', () => {
- it('supports top-level imports', async () => {
- const res = await fixture.fetch('/w-fragment');
-
- assert.equal(res.status, 200);
-
- const html = await res.text();
- const { document } = parseHTML(html);
-
- const h1 = document.querySelector('h1');
- const p = document.querySelector('p');
-
- assert.equal(h1.textContent, 'MDX containing ');
- assert.equal(p.textContent, 'bar');
- });
-
- it('supports glob imports - ', async () => {
- const res = await fixture.fetch('/glob');
-
- assert.equal(res.status, 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');
-
- assert.equal(h.textContent, 'MDX containing ');
- assert.equal(p.textContent, 'bar');
- });
-
- it('supports glob imports - ', async () => {
- const res = await fixture.fetch('/glob');
-
- assert.equal(res.status, 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');
-
- assert.equal(h.textContent, 'MDX containing ');
- assert.equal(p.textContent, 'bar');
- });
- });
- });
-});
diff --git a/packages/integrations/mdx/test/mdx-escape.test.js b/packages/integrations/mdx/test/mdx-escape.test.js
deleted file mode 100644
index 9770128384d1..000000000000
--- a/packages/integrations/mdx/test/mdx-escape.test.js
+++ /dev/null
@@ -1,32 +0,0 @@
-import * as assert from 'node:assert/strict';
-import { before, describe, it } from 'node:test';
-import mdx from '@astrojs/mdx';
-import { parseHTML } from 'linkedom';
-import { loadFixture } from '../../../astro/test/test-utils.js';
-
-const FIXTURE_ROOT = new URL('./fixtures/mdx-escape/', import.meta.url);
-
-describe('MDX frontmatter', () => {
- let fixture;
- before(async () => {
- fixture = await loadFixture({
- root: FIXTURE_ROOT,
- integrations: [mdx()],
- });
- await fixture.build();
- });
-
- it('does not have unescaped HTML at top-level', async () => {
- const html = await fixture.readFile('/index.html');
- const { document } = parseHTML(html);
-
- assert.equal(document.body.textContent.includes(' {
- const html = await fixture.readFile('/html-tag/index.html');
- const { document } = parseHTML(html);
-
- assert.equal(document.body.textContent.includes(' {
- let fixture;
- before(async () => {
- fixture = await loadFixture({
- root: FIXTURE_ROOT,
- integrations: [mdx()],
- });
- await fixture.build();
- });
- it('builds when "frontmatter.property" is in JSX expression', async () => {
- assert.equal(true, true);
- });
-
- it('extracts frontmatter to "frontmatter" export', async () => {
- const { titles } = JSON.parse(await fixture.readFile('/glob.json'));
- assert.equal(titles.includes('Using YAML frontmatter'), true);
- });
-
- it('renders layout from "layout" frontmatter property', async () => {
- const html = await fixture.readFile('/index.html');
- const { document } = parseHTML(html);
-
- const layoutParagraph = document.querySelector('[data-layout-rendered]');
-
- assert.notEqual(layoutParagraph, null);
- });
-
- it('passes frontmatter to layout via "content" and "frontmatter" props', async () => {
- const html = await fixture.readFile('/index.html');
- const { document } = parseHTML(html);
-
- const contentTitle = document.querySelector('[data-content-title]');
- const frontmatterTitle = document.querySelector('[data-frontmatter-title]');
-
- assert.equal(contentTitle.textContent, 'Using YAML frontmatter');
- assert.equal(frontmatterTitle.textContent, 'Using YAML frontmatter');
- });
-
- it('passes headings to layout via "headings" prop', async () => {
- const html = await fixture.readFile('/with-headings/index.html');
- const { document } = parseHTML(html);
-
- const headingSlugs = [...document.querySelectorAll('[data-headings] > li')].map(
- (el) => el.textContent,
- );
-
- assert.equal(headingSlugs.length > 0, true);
- assert.equal(headingSlugs.includes('section-1'), true);
- assert.equal(headingSlugs.includes('section-2'), true);
- });
-
- it('passes "file" and "url" to layout', async () => {
- const html = await fixture.readFile('/with-headings/index.html');
- const { document } = parseHTML(html);
-
- const frontmatterFile = document.querySelector('[data-frontmatter-file]')?.textContent;
- const frontmatterUrl = document.querySelector('[data-frontmatter-url]')?.textContent;
- const file = document.querySelector('[data-file]')?.textContent;
- const url = document.querySelector('[data-url]')?.textContent;
-
- assert.equal(
- frontmatterFile?.endsWith('with-headings.mdx'),
- true,
- '"file" prop does not end with correct path or is undefined',
- );
- assert.equal(frontmatterUrl, '/with-headings');
- assert.equal(file, frontmatterFile);
- assert.equal(url, frontmatterUrl);
- });
-});
diff --git a/packages/integrations/mdx/test/mdx-get-headings.test.js b/packages/integrations/mdx/test/mdx-get-headings.test.js
index 2776cfc7c3d6..e4e07a281fc8 100644
--- a/packages/integrations/mdx/test/mdx-get-headings.test.js
+++ b/packages/integrations/mdx/test/mdx-get-headings.test.js
@@ -63,6 +63,40 @@ describe('MDX getHeadings', () => {
]),
);
});
+
+ // These tests use the same config (integrations: [mdx()]) and share the build above
+ describe('with frontmatter', () => {
+ it('adds anchor IDs to headings', async () => {
+ const html = await fixture.readFile('/test-with-frontmatter/index.html');
+ const { document } = parseHTML(html);
+
+ const h3Ids = document.querySelectorAll('h3').map((el) => el?.id);
+
+ assert.equal(document.querySelector('h1').id, 'the-frontmatter-title');
+ assert.equal(document.querySelector('h2').id, 'frontmattertitle');
+ assert.equal(h3Ids.includes('keyword-2'), true);
+ assert.equal(h3Ids.includes('tag-1'), true);
+ assert.equal(document.querySelector('h4').id, 'item-2');
+ assert.equal(document.querySelector('h5').id, 'nested-item-3');
+ assert.equal(document.querySelector('h6').id, 'frontmatterunknown');
+ });
+
+ it('generates correct getHeadings() export', async () => {
+ const { headingsByPage } = JSON.parse(await fixture.readFile('/pages.json'));
+ assert.equal(
+ JSON.stringify(headingsByPage['./test-with-frontmatter.mdx']),
+ JSON.stringify([
+ { depth: 1, slug: 'the-frontmatter-title', text: 'The Frontmatter Title' },
+ { depth: 2, slug: 'frontmattertitle', text: 'frontmatter.title' },
+ { depth: 3, slug: 'keyword-2', text: 'Keyword 2' },
+ { depth: 3, slug: 'tag-1', text: 'Tag 1' },
+ { depth: 4, slug: 'item-2', text: 'Item 2' },
+ { depth: 5, slug: 'nested-item-3', text: 'Nested Item 3' },
+ { depth: 6, slug: 'frontmatterunknown', text: 'frontmatter.unknown' },
+ ]),
+ );
+ });
+ });
});
describe('MDX heading IDs can be customized by user plugins', () => {
@@ -157,47 +191,3 @@ describe('MDX heading IDs can be injected before user plugins', () => {
assert.equal(h1?.id, 'heading-test');
});
});
-
-describe('MDX headings with frontmatter', () => {
- let fixture;
-
- before(async () => {
- fixture = await loadFixture({
- root: new URL('./fixtures/mdx-get-headings/', import.meta.url),
- integrations: [mdx()],
- });
-
- await fixture.build();
- });
-
- it('adds anchor IDs to headings', async () => {
- const html = await fixture.readFile('/test-with-frontmatter/index.html');
- const { document } = parseHTML(html);
-
- const h3Ids = document.querySelectorAll('h3').map((el) => el?.id);
-
- assert.equal(document.querySelector('h1').id, 'the-frontmatter-title');
- assert.equal(document.querySelector('h2').id, 'frontmattertitle');
- assert.equal(h3Ids.includes('keyword-2'), true);
- assert.equal(h3Ids.includes('tag-1'), true);
- assert.equal(document.querySelector('h4').id, 'item-2');
- assert.equal(document.querySelector('h5').id, 'nested-item-3');
- assert.equal(document.querySelector('h6').id, 'frontmatterunknown');
- });
-
- it('generates correct getHeadings() export', async () => {
- const { headingsByPage } = JSON.parse(await fixture.readFile('/pages.json'));
- assert.equal(
- JSON.stringify(headingsByPage['./test-with-frontmatter.mdx']),
- JSON.stringify([
- { depth: 1, slug: 'the-frontmatter-title', text: 'The Frontmatter Title' },
- { depth: 2, slug: 'frontmattertitle', text: 'frontmatter.title' },
- { depth: 3, slug: 'keyword-2', text: 'Keyword 2' },
- { depth: 3, slug: 'tag-1', text: 'Tag 1' },
- { depth: 4, slug: 'item-2', text: 'Item 2' },
- { depth: 5, slug: 'nested-item-3', text: 'Nested Item 3' },
- { depth: 6, slug: 'frontmatterunknown', text: 'frontmatter.unknown' },
- ]),
- );
- });
-});
diff --git a/packages/integrations/mdx/test/mdx-get-static-paths.test.js b/packages/integrations/mdx/test/mdx-get-static-paths.test.js
deleted file mode 100644
index 74959ccd13bf..000000000000
--- a/packages/integrations/mdx/test/mdx-get-static-paths.test.js
+++ /dev/null
@@ -1,33 +0,0 @@
-import * as assert from 'node:assert/strict';
-import { before, describe, it } from 'node:test';
-import mdx from '@astrojs/mdx';
-import * as cheerio from 'cheerio';
-import { loadFixture } from '../../../astro/test/test-utils.js';
-
-const FIXTURE_ROOT = new URL('./fixtures/mdx-get-static-paths', import.meta.url);
-
-describe('getStaticPaths', () => {
- /** @type {import('astro/test/test-utils').Fixture} */
- let fixture;
- before(async () => {
- fixture = await loadFixture({
- root: FIXTURE_ROOT,
- integrations: [mdx()],
- });
- await fixture.build();
- });
-
- it('Provides file and url', async () => {
- const html = await fixture.readFile('/one/index.html');
-
- const $ = cheerio.load(html);
- assert.equal($('p').text(), 'First mdx file');
- assert.equal($('#one').text(), 'hello', 'Frontmatter included');
- assert.equal($('#url').text(), 'src/content/1.mdx', 'url is included');
- assert.equal(
- $('#file').text().includes('fixtures/mdx-get-static-paths/src/content/1.mdx'),
- true,
- 'file is included',
- );
- });
-});
diff --git a/packages/integrations/mdx/test/mdx-plugins.test.js b/packages/integrations/mdx/test/mdx-plugins.test.js
index ec967afcc872..b8fc73e8cef3 100644
--- a/packages/integrations/mdx/test/mdx-plugins.test.js
+++ b/packages/integrations/mdx/test/mdx-plugins.test.js
@@ -1,7 +1,6 @@
import * as assert from 'node:assert/strict';
import { before, describe, it } from 'node:test';
import mdx from '@astrojs/mdx';
-import { visit as estreeVisit } from 'estree-util-visit';
import { parseHTML } from 'linkedom';
import remarkToc from 'remark-toc';
import { loadFixture } from '../../../astro/test/test-utils.js';
@@ -9,60 +8,7 @@ import { loadFixture } from '../../../astro/test/test-utils.js';
const FIXTURE_ROOT = new URL('./fixtures/mdx-plugins/', import.meta.url);
const FILE = '/with-plugins/index.html';
-describe('MDX plugins', () => {
- it('supports custom remark plugins - TOC', async () => {
- const fixture = await buildFixture({
- integrations: [
- mdx({
- remarkPlugins: [remarkToc],
- }),
- ],
- });
-
- const html = await fixture.readFile(FILE);
- const { document } = parseHTML(html);
-
- assert.notEqual(selectTocLink(document), null);
- });
-
- it('Applies GFM by default', async () => {
- const fixture = await buildFixture({
- integrations: [mdx()],
- });
-
- const html = await fixture.readFile(FILE);
- const { document } = parseHTML(html);
-
- assert.notEqual(selectGfmLink(document), null);
- });
-
- it('Applies SmartyPants by default', async () => {
- const fixture = await buildFixture({
- integrations: [mdx()],
- });
-
- const html = await fixture.readFile(FILE);
- const { document } = parseHTML(html);
-
- const quote = selectSmartypantsQuote(document);
- assert.notEqual(quote, null);
- assert.equal(quote.textContent.includes('“Smartypants” is — awesome'), true);
- });
-
- it('supports custom rehype plugins', async () => {
- const fixture = await buildFixture({
- integrations: [
- mdx({
- rehypePlugins: [rehypeExamplePlugin],
- }),
- ],
- });
- const html = await fixture.readFile(FILE);
- const { document } = parseHTML(html);
-
- assert.notEqual(selectRehypeExample(document), null);
- });
-
+describe('MDX plugins - Astro config integration', () => {
it('supports custom rehype plugins from integrations', async () => {
const fixture = await buildFixture({
integrations: [
@@ -87,20 +33,6 @@ describe('MDX plugins', () => {
assert.notEqual(selectRehypeExample(document), null);
});
- it('supports custom rehype plugins with namespaced attributes', async () => {
- const fixture = await buildFixture({
- integrations: [
- mdx({
- rehypePlugins: [rehypeSvgPlugin],
- }),
- ],
- });
- const html = await fixture.readFile(FILE);
- const { document } = parseHTML(html);
-
- assert.notEqual(selectRehypeSvg(document), null);
- });
-
it('extends markdown config by default', async () => {
const fixture = await buildFixture({
markdown: {
@@ -117,25 +49,13 @@ describe('MDX plugins', () => {
assert.notEqual(selectRehypeExample(document), null);
});
- it('ignores string-based plugins in markdown config', async () => {
- const fixture = await buildFixture({
- markdown: {
- remarkPlugins: [['remark-toc', {}]],
- },
- integrations: [mdx()],
- });
-
- const html = await fixture.readFile(FILE);
- const { document } = parseHTML(html);
-
- assert.equal(selectTocLink(document), null);
- });
-
for (const extendMarkdownConfig of [true, false]) {
describe(`extendMarkdownConfig = ${extendMarkdownConfig}`, () => {
let fixture;
before(async () => {
fixture = await buildFixture({
+ // Use unique outDir to avoid cache pollution between builds with different configs
+ outDir: `./dist/mdx-plugins-extend-${extendMarkdownConfig}/`,
markdown: {
remarkPlugins: [remarkToc],
gfm: false,
@@ -190,36 +110,23 @@ describe('MDX plugins', () => {
const quote = selectSmartypantsQuote(document);
if (extendMarkdownConfig === true) {
+ // smartypants: false inherited from markdown config — straight quotes and dashes preserved
assert.equal(
- quote.textContent.includes('"Smartypants" is -- awesome'),
+ quote.textContent.includes('--'),
true,
- 'Does not respect `markdown.smartypants` option.',
+ 'Does not respect `markdown.smartypants` option: dashes should remain as --.',
);
} else {
+ // smartypants defaults to ON — converts quotes to curly and -- to em dash
assert.equal(
- quote.textContent.includes('“Smartypants” is — awesome'),
+ quote.textContent.includes('\u2014'),
true,
- 'Respects `markdown.smartypants` unexpectedly.',
+ 'Smartypants should be ON when not extending markdown config: -- should become em dash.',
);
}
});
});
}
-
- it('supports custom recma plugins', async () => {
- const fixture = await buildFixture({
- integrations: [
- mdx({
- recmaPlugins: [recmaExamplePlugin],
- }),
- ],
- });
-
- const html = await fixture.readFile(FILE);
- const { document } = parseHTML(html);
-
- assert.notEqual(selectRecmaExample(document), null);
- });
});
async function buildFixture(config) {
@@ -250,41 +157,6 @@ function rehypeExamplePlugin() {
};
}
-function rehypeSvgPlugin() {
- return (tree) => {
- tree.children.push({
- type: 'element',
- tagName: 'svg',
- properties: { xmlns: 'http://www.w3.org/2000/svg' },
- children: [
- {
- type: 'element',
- tagName: 'use',
- properties: { xLinkHref: '#icon' },
- },
- ],
- });
- };
-}
-
-function recmaExamplePlugin() {
- return (tree) => {
- estreeVisit(tree, (node) => {
- if (
- node.type === 'VariableDeclarator' &&
- node.id.name === 'recmaPluginWorking' &&
- node.init?.type === 'Literal'
- ) {
- node.init = {
- ...(node.init ?? {}),
- value: true,
- raw: 'true',
- };
- }
- });
- };
-}
-
function selectTocLink(document) {
return document.querySelector('ul a[href="#section-1"]');
}
@@ -304,11 +176,3 @@ function selectRemarkExample(document) {
function selectRehypeExample(document) {
return document.querySelector('div[data-rehype-plugin-works]');
}
-
-function selectRehypeSvg(document) {
- return document.querySelector('svg > use[xlink\\:href]');
-}
-
-function selectRecmaExample(document) {
- return document.querySelector('div[data-recma-plugin-works]');
-}
diff --git a/packages/integrations/mdx/test/mdx-script-style-raw.test.js b/packages/integrations/mdx/test/mdx-script-style-raw.test.js
deleted file mode 100644
index 3b0acefe04b3..000000000000
--- a/packages/integrations/mdx/test/mdx-script-style-raw.test.js
+++ /dev/null
@@ -1,75 +0,0 @@
-import * as assert from 'node:assert/strict';
-import { after, before, describe, it } from 'node:test';
-import mdx from '@astrojs/mdx';
-import { parseHTML } from 'linkedom';
-import { loadFixture } from '../../../astro/test/test-utils.js';
-
-const FIXTURE_ROOT = new URL('./fixtures/mdx-script-style-raw/', import.meta.url);
-
-describe('MDX script style raw', () => {
- describe('dev', () => {
- let fixture;
- let devServer;
-
- before(async () => {
- fixture = await loadFixture({
- root: FIXTURE_ROOT,
- integrations: [mdx()],
- });
- devServer = await fixture.startDevServer();
- });
-
- after(async () => {
- await devServer.stop();
- });
-
- it('works with raw script and style strings', async () => {
- const res = await fixture.fetch('/index.html');
- assert.equal(res.status, 200);
-
- const html = await res.text();
- const { document } = parseHTML(html);
-
- const scriptContent = document.getElementById('test-script').innerHTML;
- assert.equal(
- scriptContent.includes("console.log('raw script')"),
- true,
- 'script should not be html-escaped',
- );
-
- const styleContent = document.getElementById('test-style').innerHTML;
- assert.equal(
- styleContent.includes('h1[id="script-style-raw"]'),
- true,
- 'style should not be html-escaped',
- );
- });
- });
-
- describe('build', () => {
- it('works with raw script and style strings', 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 scriptContent = document.getElementById('test-script').innerHTML;
- assert.equal(
- scriptContent.includes("console.log('raw script')"),
- true,
- 'script should not be html-escaped',
- );
-
- const styleContent = document.getElementById('test-style').innerHTML;
- assert.equal(
- styleContent.includes('h1[id="script-style-raw"]'),
- true,
- 'style should not be html-escaped',
- );
- });
- });
-});
diff --git a/packages/integrations/mdx/test/mdx-slots.test.js b/packages/integrations/mdx/test/mdx-slots.test.js
deleted file mode 100644
index f1ee6a2377ec..000000000000
--- a/packages/integrations/mdx/test/mdx-slots.test.js
+++ /dev/null
@@ -1,124 +0,0 @@
-import * as assert from 'node:assert/strict';
-import { after, before, describe, it } from 'node:test';
-import mdx from '@astrojs/mdx';
-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]');
-
- assert.equal(h1.textContent, 'Hello slotted component!');
- assert.equal(defaultSlot.textContent, 'Default content.');
- assert.equal(namedSlot.textContent, '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]');
-
- assert.equal(h1.textContent, 'Hello slotted component!');
- assert.equal(defaultSlot.textContent, 'Default content.');
- assert.equal(namedSlot.textContent, '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]');
-
- assert.equal(h1.textContent, 'Hello slotted component!');
- assert.equal(defaultSlot.textContent, 'Default content.');
- assert.equal(namedSlot.textContent, '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('/');
-
- assert.equal(res.status, 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]');
-
- assert.equal(h1.textContent, 'Hello slotted component!');
- assert.equal(defaultSlot.textContent, 'Default content.');
- assert.equal(namedSlot.textContent, 'Content for named slot.');
- });
-
- it('supports glob imports - ', async () => {
- const res = await fixture.fetch('/glob');
-
- assert.equal(res.status, 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]');
-
- assert.equal(h1.textContent, 'Hello slotted component!');
- assert.equal(defaultSlot.textContent, 'Default content.');
- assert.equal(namedSlot.textContent, 'Content for named slot.');
- });
-
- it('supports glob imports - ', async () => {
- const res = await fixture.fetch('/glob');
-
- assert.equal(res.status, 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]');
-
- assert.equal(h1.textContent, 'Hello slotted component!');
- assert.equal(defaultSlot.textContent, 'Default content.');
- assert.equal(namedSlot.textContent, 'Content for named slot.');
- });
- });
-});
diff --git a/packages/integrations/mdx/test/mdx-url-export.test.js b/packages/integrations/mdx/test/mdx-url-export.test.js
deleted file mode 100644
index 66a34db75fc4..000000000000
--- a/packages/integrations/mdx/test/mdx-url-export.test.js
+++ /dev/null
@@ -1,28 +0,0 @@
-import * as assert from 'node:assert/strict';
-import { before, describe, it } from 'node:test';
-import mdx from '@astrojs/mdx';
-import { loadFixture } from '../../../astro/test/test-utils.js';
-
-describe('MDX url export', () => {
- let fixture;
-
- before(async () => {
- fixture = await loadFixture({
- root: new URL('./fixtures/mdx-url-export/', import.meta.url),
- integrations: [mdx()],
- });
-
- await fixture.build();
- });
-
- it('generates correct urls in glob result', async () => {
- const { urls } = JSON.parse(await fixture.readFile('/pages.json'));
- assert.equal(urls.includes('/test-1'), true);
- assert.equal(urls.includes('/test-2'), true);
- });
-
- it('respects "export url" overrides in glob result', async () => {
- const { urls } = JSON.parse(await fixture.readFile('/pages.json'));
- assert.equal(urls.includes('/AH!'), true);
- });
-});
diff --git a/packages/integrations/mdx/test/units/mdx-compilation.test.js b/packages/integrations/mdx/test/units/mdx-compilation.test.js
new file mode 100644
index 000000000000..945131ff5d6e
--- /dev/null
+++ b/packages/integrations/mdx/test/units/mdx-compilation.test.js
@@ -0,0 +1,268 @@
+import assert from 'node:assert/strict';
+import { describe, it } from 'node:test';
+import { compile as _compile } from '@mdx-js/mdx';
+import { rehypeHeadingIds } from '@astrojs/markdown-remark';
+import remarkGfm from 'remark-gfm';
+import remarkSmartypants from 'remark-smartypants';
+import { visit } from 'unist-util-visit';
+
+/**
+ * Compile MDX to JSX string output for inspection.
+ * @param {string} mdxCode
+ * @param {Readonly} options
+ */
+async function compile(mdxCode, options = {}) {
+ const result = await _compile(mdxCode, {
+ jsx: true,
+ ...options,
+ });
+ return result.toString();
+}
+
+/**
+ * Compile MDX with rehype-raw (like Astro does) and return the JSX output.
+ */
+async function compileWithRaw(mdxCode, options = {}) {
+ const { nodeTypes } = await import('@mdx-js/mdx');
+ const rehypeRaw = (await import('rehype-raw')).default;
+ return compile(mdxCode, {
+ rehypePlugins: [[rehypeRaw, { passThrough: nodeTypes }], ...(options.rehypePlugins || [])],
+ remarkPlugins: options.remarkPlugins || [],
+ recmaPlugins: options.recmaPlugins || [],
+ ...options,
+ });
+}
+
+describe('MDX escape handling', () => {
+ it('wraps escaped HTML in string expressions, not raw JSX', async () => {
+ // In MDX, \ is escaped and should be rendered as text, not as an HTML element.
+ // The compiled JSX wraps it in a string expression like {""}
+ const code = await compile('\\');
+ // The output should have the text as a JSX string expression, not as a JSX element
+ assert.ok(code.includes('{""}'), 'Escaped HTML should be wrapped in JSX string expression');
+ // Should NOT have as an actual JSX element (i.e. outside of string)
+ assert.ok(!code.includes('{"'), 'Should not have as an actual JSX element');
+ });
+
+ it('preserves angle brackets in inline code', async () => {
+ const code = await compile('` {
+ const code = await compile('{``}');
+ // JSX expression should contain the string
+ assert.ok(code.includes('
'), 'Should contain the escaped string');
+ });
+});
+
+describe('MDX GFM plugin', () => {
+ it('converts autolinks when GFM is applied', async () => {
+ const code = await compile('https://handle-me-gfm.com', {
+ remarkPlugins: [remarkGfm],
+ });
+ assert.ok(code.includes('https://handle-me-gfm.com'), 'Should contain the URL');
+ assert.ok(code.includes('href'), 'GFM should create an anchor element');
+ });
+
+ it('does not convert autolinks without GFM', async () => {
+ const code = await compile('https://handle-me-gfm.com');
+ // Without GFM, the URL should just be text, not wrapped in
+ assert.ok(code.includes('https://handle-me-gfm.com'));
+ });
+});
+
+describe('MDX SmartyPants plugin', () => {
+ it('converts quotes and dashes when SmartyPants is applied', async () => {
+ const code = await compile('> "Smartypants" is -- awesome', {
+ remarkPlugins: [remarkSmartypants],
+ });
+ // SmartyPants converts straight quotes to curly and -- to em dash
+ assert.ok(
+ code.includes('\u201C') || code.includes('\u201D') || code.includes('\u2014'),
+ 'SmartyPants should convert quotes or dashes to typographic equivalents',
+ );
+ });
+
+ it('does not convert quotes without SmartyPants', async () => {
+ const code = await compile('> "Smartypants" is -- awesome');
+ // Without SmartyPants, double dashes stay as -- (not converted to em dash \u2014)
+ assert.ok(code.includes('--'), 'Double dashes should remain unconverted');
+ assert.ok(!code.includes('\u2014'), 'Em dash should not appear without SmartyPants');
+ });
+});
+
+describe('MDX remark plugins', () => {
+ it('supports custom remark plugins that modify the tree', async () => {
+ /** Remark plugin that appends a div */
+ function remarkAddDiv() {
+ return (tree) => {
+ tree.children.push({
+ type: 'html',
+ value: '',
+ });
+ };
+ }
+
+ const code = await compileWithRaw('# Hello', {
+ remarkPlugins: [remarkAddDiv],
+ });
+ assert.ok(
+ code.includes('data-remark-works'),
+ 'Custom remark plugin output should be in compiled result',
+ );
+ });
+});
+
+describe('MDX rehype plugins', () => {
+ it('supports custom rehype plugins that modify the tree', async () => {
+ /** Rehype plugin that appends a div */
+ function rehypeAddDiv() {
+ return (tree) => {
+ tree.children.push({
+ type: 'element',
+ tagName: 'div',
+ properties: { 'data-rehype-works': 'true' },
+ children: [],
+ });
+ };
+ }
+
+ const code = await compileWithRaw('# Hello', {
+ rehypePlugins: [rehypeAddDiv],
+ });
+ assert.ok(
+ code.includes('data-rehype-works'),
+ 'Custom rehype plugin output should be in compiled result',
+ );
+ });
+
+ it('supports rehype plugins with namespaced SVG attributes', async () => {
+ function rehypeSvg() {
+ return (tree) => {
+ tree.children.push({
+ type: 'element',
+ tagName: 'svg',
+ properties: { xmlns: 'http://www.w3.org/2000/svg' },
+ children: [
+ {
+ type: 'element',
+ tagName: 'use',
+ properties: { xlinkHref: '#icon' },
+ children: [],
+ },
+ ],
+ });
+ };
+ }
+
+ const code = await compileWithRaw('# Hello', {
+ rehypePlugins: [rehypeSvg],
+ });
+ assert.ok(code.includes('svg'), 'Should contain SVG element');
+ });
+});
+
+describe('MDX recma plugins', () => {
+ it('supports custom recma plugins that transform the estree', async () => {
+ const { visit: estreeVisit } = await import('estree-util-visit');
+
+ function recmaExample() {
+ return (tree) => {
+ estreeVisit(tree, (node) => {
+ if (
+ node.type === 'VariableDeclarator' &&
+ node.id.name === 'recmaPluginWorking' &&
+ node.init?.type === 'Literal'
+ ) {
+ node.init = {
+ ...(node.init ?? {}),
+ value: true,
+ raw: 'true',
+ };
+ }
+ });
+ };
+ }
+
+ const mdxCode = `export const recmaPluginWorking = false;
+
+# Hello`;
+ const code = await compile(mdxCode, {
+ recmaPlugins: [recmaExample],
+ });
+ // The recma plugin should have changed false to true
+ assert.ok(code.includes('true'), 'Recma plugin should transform the value');
+ });
+});
+
+describe('MDX heading IDs', () => {
+ it('generates heading IDs with rehypeHeadingIds', async () => {
+ const mdxCode = `# Hello World
+
+## Section 1
+
+### Subsection 1
+`;
+ const code = await compileWithRaw(mdxCode, {
+ rehypePlugins: [rehypeHeadingIds],
+ });
+ assert.ok(code.includes('hello-world'), 'Should generate slug for h1');
+ assert.ok(code.includes('section-1'), 'Should generate slug for h2');
+ assert.ok(code.includes('subsection-1'), 'Should generate slug for h3');
+ });
+
+ it('generates correct slugs for special characters', async () => {
+ const mdxCode = `# \`\`
+
+### « Sacrebleu ! »
+`;
+ const code = await compileWithRaw(mdxCode, {
+ rehypePlugins: [rehypeHeadingIds],
+ });
+ assert.ok(code.includes('picture-'), 'Should generate slug for code in heading');
+ assert.ok(code.includes('-sacrebleu--'), 'Should generate slug for special chars');
+ });
+
+ it('allows user plugins to override heading IDs', async () => {
+ function customIdPlugin() {
+ return (tree) => {
+ let count = 0;
+ visit(tree, 'element', (node) => {
+ if (!/^h\d$/.test(node.tagName)) return;
+ if (!node.properties?.id) {
+ node.properties = { ...node.properties, id: String(count++) };
+ }
+ });
+ };
+ }
+
+ const mdxCode = `# Hello
+
+## World
+`;
+ const code = await compileWithRaw(mdxCode, {
+ rehypePlugins: [customIdPlugin],
+ });
+ // MDX JSX output uses id="0" as a JSX attribute
+ assert.ok(code.includes('id="0"'), 'Custom plugin should set id="0" on first heading');
+ assert.ok(code.includes('id="1"'), 'Custom plugin should set id="1" on second heading');
+ });
+});
+
+describe('MDX string-based plugin filtering', () => {
+ it('does not apply string-based remark plugins', async () => {
+ // When a string-based plugin is provided, the ignoreStringPlugins
+ // function filters it out. We test the filter function directly in utils.test.js.
+ // Here we verify that only function plugins affect output.
+ const { ignoreStringPlugins } = await import('../../dist/utils.js');
+ const logger = { warn() {} };
+
+ const plugins = ['remark-toc', () => (tree) => tree];
+ const filtered = ignoreStringPlugins(plugins, logger);
+
+ assert.equal(filtered.length, 1, 'Should filter out string plugin');
+ assert.equal(typeof filtered[0], 'function', 'Should keep function plugin');
+ });
+});
diff --git a/packages/integrations/mdx/test/units/rehype-plugins.test.js b/packages/integrations/mdx/test/units/rehype-plugins.test.js
new file mode 100644
index 000000000000..bbcc96241e1f
--- /dev/null
+++ b/packages/integrations/mdx/test/units/rehype-plugins.test.js
@@ -0,0 +1,139 @@
+import assert from 'node:assert/strict';
+import { describe, it } from 'node:test';
+import rehypeMetaString from '../../dist/rehype-meta-string.js';
+import { rehypeInjectHeadingsExport } from '../../dist/rehype-collect-headings.js';
+
+describe('rehypeMetaString', () => {
+ function createCodeNode(meta) {
+ return {
+ type: 'element',
+ tagName: 'code',
+ properties: {},
+ data: meta != null ? { meta } : undefined,
+ children: [{ type: 'text', value: 'const x = 1;' }],
+ };
+ }
+
+ function createTree(children) {
+ return { type: 'root', children };
+ }
+
+ it('copies data.meta to properties.metastring', () => {
+ const codeNode = createCodeNode('{1:3}');
+ const tree = createTree([
+ {
+ type: 'element',
+ tagName: 'pre',
+ properties: {},
+ children: [codeNode],
+ },
+ ]);
+
+ const transform = rehypeMetaString();
+ transform(tree);
+
+ assert.equal(codeNode.properties.metastring, '{1:3}');
+ });
+
+ it('does not set metastring when no data.meta', () => {
+ const codeNode = createCodeNode(undefined);
+ // Ensure no data property at all
+ delete codeNode.data;
+ const tree = createTree([codeNode]);
+
+ const transform = rehypeMetaString();
+ transform(tree);
+
+ assert.equal(codeNode.properties.metastring, undefined);
+ });
+
+ it('handles code elements without properties', () => {
+ const codeNode = {
+ type: 'element',
+ tagName: 'code',
+ data: { meta: 'title="test"' },
+ children: [],
+ };
+ const tree = createTree([codeNode]);
+
+ const transform = rehypeMetaString();
+ transform(tree);
+
+ assert.equal(codeNode.properties.metastring, 'title="test"');
+ });
+
+ it('ignores non-code elements', () => {
+ const divNode = {
+ type: 'element',
+ tagName: 'div',
+ properties: {},
+ data: { meta: 'should-not-copy' },
+ children: [],
+ };
+ const tree = createTree([divNode]);
+
+ const transform = rehypeMetaString();
+ transform(tree);
+
+ assert.equal(divNode.properties.metastring, undefined);
+ });
+});
+
+describe('rehypeInjectHeadingsExport', () => {
+ it('injects getHeadings export from vfile headings data', () => {
+ const headings = [
+ { depth: 1, slug: 'hello', text: 'Hello' },
+ { depth: 2, slug: 'world', text: 'World' },
+ ];
+
+ const tree = { type: 'root', children: [] };
+ const vfile = {
+ data: {
+ astro: { headings },
+ },
+ };
+
+ const transform = rehypeInjectHeadingsExport();
+ transform(tree, vfile);
+
+ assert.equal(tree.children.length, 1);
+ const injectedNode = tree.children[0];
+ assert.equal(injectedNode.type, 'mdxjsEsm');
+ // The node should contain a getHeadings function with our headings data
+ assert.ok(injectedNode.data.estree);
+ assert.equal(injectedNode.data.estree.type, 'Program');
+ });
+
+ it('injects empty array when no headings', () => {
+ const tree = { type: 'root', children: [] };
+ const vfile = {
+ data: {
+ astro: {},
+ },
+ };
+
+ const transform = rehypeInjectHeadingsExport();
+ transform(tree, vfile);
+
+ assert.equal(tree.children.length, 1);
+ const injectedNode = tree.children[0];
+ assert.equal(injectedNode.type, 'mdxjsEsm');
+ });
+
+ it('prepends to existing children', () => {
+ const existingChild = { type: 'element', tagName: 'p', children: [] };
+ const tree = { type: 'root', children: [existingChild] };
+ const vfile = {
+ data: {
+ astro: { headings: [] },
+ },
+ };
+
+ const transform = rehypeInjectHeadingsExport();
+ transform(tree, vfile);
+
+ assert.equal(tree.children.length, 2);
+ assert.equal(tree.children[0].type, 'mdxjsEsm');
+ assert.equal(tree.children[1], existingChild);
+ });
+});
diff --git a/packages/integrations/mdx/test/units/server.test.js b/packages/integrations/mdx/test/units/server.test.js
new file mode 100644
index 000000000000..520081a288df
--- /dev/null
+++ b/packages/integrations/mdx/test/units/server.test.js
@@ -0,0 +1,44 @@
+import assert from 'node:assert/strict';
+import { describe, it } from 'node:test';
+import { slotName } from '../../dist/server.js';
+
+describe('server', () => {
+ describe('slotName', () => {
+ it('converts kebab-case to camelCase', () => {
+ assert.equal(slotName('my-slot'), 'mySlot');
+ });
+
+ it('converts snake_case to camelCase', () => {
+ assert.equal(slotName('my_slot'), 'mySlot');
+ });
+
+ it('handles multiple separators', () => {
+ assert.equal(slotName('my-long-slot-name'), 'myLongSlotName');
+ });
+
+ it('handles mixed separators', () => {
+ assert.equal(slotName('my-slot_name'), 'mySlotName');
+ });
+
+ it('trims whitespace', () => {
+ assert.equal(slotName(' my-slot '), 'mySlot');
+ });
+
+ it('returns simple names unchanged', () => {
+ assert.equal(slotName('default'), 'default');
+ });
+
+ it('handles single character after separator', () => {
+ assert.equal(slotName('a-b'), 'aB');
+ });
+
+ it('handles empty string', () => {
+ assert.equal(slotName(''), '');
+ });
+
+ it('only converts lowercase letters after separators', () => {
+ // Uppercase letters after separators are not matched by the regex [a-z]
+ assert.equal(slotName('my-Slot'), 'my-Slot');
+ });
+ });
+});
diff --git a/packages/integrations/mdx/test/units/utils.test.js b/packages/integrations/mdx/test/units/utils.test.js
new file mode 100644
index 000000000000..3c8cc4ea8c9b
--- /dev/null
+++ b/packages/integrations/mdx/test/units/utils.test.js
@@ -0,0 +1,184 @@
+import assert from 'node:assert/strict';
+import { describe, it } from 'node:test';
+import {
+ appendForwardSlash,
+ getFileInfo,
+ ignoreStringPlugins,
+ jsToTreeNode,
+} from '../../dist/utils.js';
+
+describe('utils', () => {
+ describe('appendForwardSlash', () => {
+ it('appends slash when missing', () => {
+ assert.equal(appendForwardSlash('/foo'), '/foo/');
+ });
+
+ it('does not double-append slash', () => {
+ assert.equal(appendForwardSlash('/foo/'), '/foo/');
+ });
+
+ it('handles empty string', () => {
+ assert.equal(appendForwardSlash(''), '/');
+ });
+
+ it('handles root slash', () => {
+ assert.equal(appendForwardSlash('/'), '/');
+ });
+ });
+
+ describe('getFileInfo', () => {
+ /** @param {Partial} overrides */
+ function mockConfig(overrides = {}) {
+ return {
+ root: new URL('file:///project/'),
+ base: '/',
+ site: undefined,
+ trailingSlash: 'ignore',
+ ...overrides,
+ };
+ }
+
+ it('computes fileUrl for pages', () => {
+ const config = mockConfig();
+ const result = getFileInfo('/project/src/pages/test.mdx', config);
+ assert.equal(result.fileId, '/project/src/pages/test.mdx');
+ assert.equal(result.fileUrl, '/test');
+ });
+
+ it('computes fileUrl for nested pages', () => {
+ const config = mockConfig();
+ const result = getFileInfo('/project/src/pages/blog/post.mdx', config);
+ assert.equal(result.fileUrl, '/blog/post');
+ });
+
+ it('strips index from page URLs', () => {
+ const config = mockConfig();
+ const result = getFileInfo('/project/src/pages/index.mdx', config);
+ // The regex strips /index.mdx leaving an empty string
+ assert.equal(result.fileUrl, '');
+ });
+
+ it('strips query strings from fileId', () => {
+ const config = mockConfig();
+ const result = getFileInfo('/project/src/pages/test.mdx?astro&lang=mdx', config);
+ assert.equal(result.fileId, '/project/src/pages/test.mdx');
+ });
+
+ it('uses relative path for non-page files under root', () => {
+ const config = mockConfig();
+ const result = getFileInfo('/project/src/content/post.mdx', config);
+ assert.equal(result.fileUrl, 'src/content/post.mdx');
+ });
+
+ it('respects trailingSlash=always', () => {
+ const config = mockConfig({ trailingSlash: 'always' });
+ const result = getFileInfo('/project/src/pages/test.mdx', config);
+ assert.equal(result.fileUrl, '/test/');
+ });
+
+ it('respects site + base config for pages', () => {
+ const config = mockConfig({
+ site: 'https://example.com',
+ base: '/blog',
+ });
+ const result = getFileInfo('/project/src/pages/test.mdx', config);
+ assert.equal(result.fileUrl, '/blog/test');
+ });
+
+ it('handles files outside project root', () => {
+ const config = mockConfig();
+ const result = getFileInfo('/other/path/file.mdx', config);
+ assert.equal(result.fileId, '/other/path/file.mdx');
+ assert.equal(result.fileUrl, '/other/path/file.mdx');
+ });
+ });
+
+ describe('jsToTreeNode', () => {
+ it('parses a simple export statement', () => {
+ const node = jsToTreeNode('export const x = 1;');
+ assert.equal(node.type, 'mdxjsEsm');
+ assert.equal(node.data.estree.type, 'Program');
+ assert.equal(node.data.estree.sourceType, 'module');
+ assert.ok(node.data.estree.body.length > 0);
+ });
+
+ it('parses an import statement', () => {
+ const node = jsToTreeNode("import foo from 'bar';");
+ assert.equal(node.type, 'mdxjsEsm');
+ assert.equal(node.data.estree.body[0].type, 'ImportDeclaration');
+ });
+
+ it('parses a function export', () => {
+ const node = jsToTreeNode('export function getHeadings() { return []; }');
+ assert.equal(node.type, 'mdxjsEsm');
+ const decl = node.data.estree.body[0];
+ assert.equal(decl.type, 'ExportNamedDeclaration');
+ });
+
+ it('throws on invalid JS', () => {
+ assert.throws(() => jsToTreeNode('this is not valid javascript {{{'), {
+ name: 'SyntaxError',
+ });
+ });
+ });
+
+ describe('ignoreStringPlugins', () => {
+ function mockLogger() {
+ const warnings = [];
+ return {
+ warn(msg) {
+ warnings.push(msg);
+ },
+ warnings,
+ };
+ }
+
+ it('returns function plugins unchanged', () => {
+ const plugin1 = () => {};
+ const plugin2 = () => {};
+ const logger = mockLogger();
+ const result = ignoreStringPlugins([plugin1, plugin2], logger);
+ assert.equal(result.length, 2);
+ assert.equal(result[0], plugin1);
+ assert.equal(result[1], plugin2);
+ assert.equal(logger.warnings.length, 0);
+ });
+
+ it('filters out string-based plugins', () => {
+ const fnPlugin = () => {};
+ const logger = mockLogger();
+ const result = ignoreStringPlugins(['remark-toc', fnPlugin], logger);
+ assert.equal(result.length, 1);
+ assert.equal(result[0], fnPlugin);
+ });
+
+ it('filters out array-based string plugins [string, options]', () => {
+ const fnPlugin = () => {};
+ const logger = mockLogger();
+ const result = ignoreStringPlugins([['remark-toc', {}], fnPlugin], logger);
+ assert.equal(result.length, 1);
+ assert.equal(result[0], fnPlugin);
+ });
+
+ it('logs warnings for string plugins', () => {
+ const logger = mockLogger();
+ ignoreStringPlugins(['remark-toc', ['rehype-highlight', {}]], logger);
+ // One warning per string plugin + one summary warning
+ assert.equal(logger.warnings.length, 3);
+ });
+
+ it('returns empty array for all string plugins', () => {
+ const logger = mockLogger();
+ const result = ignoreStringPlugins(['remark-toc'], logger);
+ assert.equal(result.length, 0);
+ });
+
+ it('handles array-based function plugins [function, options]', () => {
+ const fnPlugin = () => {};
+ const logger = mockLogger();
+ const result = ignoreStringPlugins([[fnPlugin, { option: true }]], logger);
+ assert.equal(result.length, 1);
+ assert.equal(logger.warnings.length, 0);
+ });
+ });
+});
diff --git a/packages/integrations/mdx/test/units/vite-plugin-mdx-postprocess.test.js b/packages/integrations/mdx/test/units/vite-plugin-mdx-postprocess.test.js
new file mode 100644
index 000000000000..6e63c83cc9c8
--- /dev/null
+++ b/packages/integrations/mdx/test/units/vite-plugin-mdx-postprocess.test.js
@@ -0,0 +1,238 @@
+import assert from 'node:assert/strict';
+import { describe, it } from 'node:test';
+import { init, parse } from 'es-module-lexer';
+import {
+ annotateContentExport,
+ injectMetadataExports,
+ injectUnderscoreFragmentImport,
+ isSpecifierImported,
+ transformContentExport,
+} from '../../dist/vite-plugin-mdx-postprocess.js';
+
+await init;
+
+/**
+ * Helper: parse code with es-module-lexer and return [imports, exports]
+ */
+function parseCode(code) {
+ return parse(code);
+}
+
+describe('vite-plugin-mdx-postprocess', () => {
+ describe('injectUnderscoreFragmentImport', () => {
+ it('injects Fragment import when not present', () => {
+ const code = `import { jsx } from 'astro/jsx-runtime';`;
+ const [imports] = parseCode(code);
+ const result = injectUnderscoreFragmentImport(code, imports);
+ assert.ok(result.includes("import { Fragment as _Fragment } from 'astro/jsx-runtime'"));
+ });
+
+ it('does not inject Fragment import when already present', () => {
+ const code = `import { jsx, Fragment as _Fragment } from 'astro/jsx-runtime';`;
+ const [imports] = parseCode(code);
+ const result = injectUnderscoreFragmentImport(code, imports);
+ // Should not have a second import
+ const importCount = (result.match(/Fragment as _Fragment/g) || []).length;
+ assert.equal(importCount, 1);
+ });
+
+ it('does not inject when _Fragment is imported with different spacing', () => {
+ const code = `import { _Fragment } from 'astro/jsx-runtime';`;
+ const [imports] = parseCode(code);
+ const result = injectUnderscoreFragmentImport(code, imports);
+ // _Fragment is in the import statement, regex should match
+ assert.ok(result.includes("import { _Fragment } from 'astro/jsx-runtime'"));
+ // Should not add a second Fragment import
+ const fragmentImports = result.match(/from 'astro\/jsx-runtime'/g) || [];
+ assert.equal(fragmentImports.length, 1);
+ });
+
+ it('injects Fragment import when import is from a different source', () => {
+ const code = `import { Fragment as _Fragment } from 'react/jsx-runtime';`;
+ const [imports] = parseCode(code);
+ const result = injectUnderscoreFragmentImport(code, imports);
+ assert.ok(result.includes("import { Fragment as _Fragment } from 'astro/jsx-runtime'"));
+ });
+ });
+
+ describe('injectMetadataExports', () => {
+ it('injects url and file exports when not present', () => {
+ const code = `export const frontmatter = {};`;
+ const [, exports] = parseCode(code);
+ const result = injectMetadataExports(code, exports, {
+ fileUrl: '/test-page',
+ fileId: '/src/pages/test-page.mdx',
+ });
+ assert.ok(result.includes('export const url = "/test-page"'));
+ assert.ok(result.includes('export const file = "/src/pages/test-page.mdx"'));
+ });
+
+ it('does not inject url export when already present', () => {
+ const code = `export const url = "/custom";`;
+ const [, exports] = parseCode(code);
+ const result = injectMetadataExports(code, exports, {
+ fileUrl: '/test-page',
+ fileId: '/src/pages/test-page.mdx',
+ });
+ // Should not add a second url export
+ const urlExports = (result.match(/export const url/g) || []).length;
+ assert.equal(urlExports, 1);
+ // But should still add file
+ assert.ok(result.includes('export const file = "/src/pages/test-page.mdx"'));
+ });
+
+ it('does not inject file export when already present', () => {
+ const code = `export const file = "/custom.mdx";`;
+ const [, exports] = parseCode(code);
+ const result = injectMetadataExports(code, exports, {
+ fileUrl: '/test-page',
+ fileId: '/src/pages/test-page.mdx',
+ });
+ const fileExports = (result.match(/export const file/g) || []).length;
+ assert.equal(fileExports, 1);
+ // But should still add url
+ assert.ok(result.includes('export const url = "/test-page"'));
+ });
+
+ it('escapes special characters in fileUrl and fileId', () => {
+ const code = `export const frontmatter = {};`;
+ const [, exports] = parseCode(code);
+ const result = injectMetadataExports(code, exports, {
+ fileUrl: '/path/with "quotes"',
+ fileId: '/src/pages/with "quotes".mdx',
+ });
+ // JSON.stringify handles escaping
+ assert.ok(result.includes('export const url = "/path/with \\"quotes\\""'));
+ assert.ok(result.includes('export const file = "/src/pages/with \\"quotes\\".mdx"'));
+ });
+ });
+
+ describe('transformContentExport', () => {
+ it('wraps MDXContent as Content export', () => {
+ const code = `export default function MDXContent(props) { return jsx("div", {}); }`;
+ const [, exports] = parseCode(code);
+ const result = transformContentExport(code, exports);
+ // Should remove "export default" from MDXContent
+ assert.ok(result.includes('function MDXContent'));
+ assert.ok(!result.includes('export default function MDXContent'));
+ // Should create Content wrapper
+ assert.ok(result.includes('export const Content'));
+ assert.ok(result.includes('export default Content'));
+ // Should pass Fragment
+ assert.ok(result.includes('Fragment: _Fragment'));
+ });
+
+ it('skips transformation when Content export already exists', () => {
+ const code = `export const Content = () => {};\nexport default function MDXContent(props) { return jsx("div", {}); }`;
+ const [, exports] = parseCode(code);
+ const result = transformContentExport(code, exports);
+ // Should return code unchanged
+ assert.equal(result, code);
+ });
+
+ it('includes components spread when components export exists', () => {
+ const code = [
+ `export const components = { h1: CustomH1 };`,
+ `export default function MDXContent(props) { return jsx("div", {}); }`,
+ ].join('\n');
+ const [, exports] = parseCode(code);
+ const result = transformContentExport(code, exports);
+ assert.ok(result.includes('...components'));
+ });
+
+ it('does not include components spread when no components export', () => {
+ const code = `export default function MDXContent(props) { return jsx("div", {}); }`;
+ const [, exports] = parseCode(code);
+ const result = transformContentExport(code, exports);
+ assert.ok(!result.includes('...components,'));
+ });
+
+ it('includes astro-image handling when __usesAstroImage flag is exported', () => {
+ const code = [
+ `export const __usesAstroImage = true;`,
+ `export default function MDXContent(props) { return jsx("div", {}); }`,
+ ].join('\n');
+ const [, exports] = parseCode(code);
+ const result = transformContentExport(code, exports);
+ assert.ok(result.includes('astro-image'));
+ });
+ });
+
+ describe('annotateContentExport', () => {
+ it('adds mdx-component symbol', () => {
+ const code = `export const Content = () => {};`;
+ const [imports] = parseCode(code);
+ const result = annotateContentExport(code, '/test.mdx', false, imports);
+ assert.ok(result.includes("Content[Symbol.for('mdx-component')] = true"));
+ });
+
+ it('adds needsHeadRendering symbol', () => {
+ const code = `export const Content = () => {};`;
+ const [imports] = parseCode(code);
+ const result = annotateContentExport(code, '/test.mdx', false, imports);
+ assert.ok(result.includes("Content[Symbol.for('astro.needsHeadRendering')]"));
+ });
+
+ it('adds moduleId', () => {
+ const code = `export const Content = () => {};`;
+ const [imports] = parseCode(code);
+ const result = annotateContentExport(code, '/src/pages/test.mdx', false, imports);
+ assert.ok(result.includes('Content.moduleId = "/src/pages/test.mdx"'));
+ });
+
+ it('adds __astro_tag_component__ import and call in SSR mode', () => {
+ const code = `export const Content = () => {};`;
+ const [imports] = parseCode(code);
+ const result = annotateContentExport(code, '/test.mdx', true, imports);
+ assert.ok(result.includes('import { __astro_tag_component__ }'));
+ assert.ok(result.includes("__astro_tag_component__(Content, 'astro:jsx')"));
+ });
+
+ it('does not add __astro_tag_component__ in non-SSR mode', () => {
+ const code = `export const Content = () => {};`;
+ const [imports] = parseCode(code);
+ const result = annotateContentExport(code, '/test.mdx', false, imports);
+ assert.ok(!result.includes('__astro_tag_component__'));
+ });
+
+ it('does not duplicate __astro_tag_component__ import when already present', () => {
+ const code = `import { __astro_tag_component__ } from 'astro/runtime/server/index.js';\nexport const Content = () => {};`;
+ const [imports] = parseCode(code);
+ const result = annotateContentExport(code, '/test.mdx', true, imports);
+ const importCount = (
+ result.match(/import.*__astro_tag_component__.*astro\/runtime\/server/g) || []
+ ).length;
+ assert.equal(importCount, 1);
+ });
+ });
+
+ describe('isSpecifierImported', () => {
+ it('returns true when specifier matches in correct source', () => {
+ const code = `import { Fragment as _Fragment } from 'astro/jsx-runtime';`;
+ const [imports] = parseCode(code);
+ const regex = /[\s,{]_Fragment[\s,}]/;
+ assert.equal(isSpecifierImported(code, imports, regex, 'astro/jsx-runtime'), true);
+ });
+
+ it('returns false when specifier is from different source', () => {
+ const code = `import { Fragment as _Fragment } from 'react/jsx-runtime';`;
+ const [imports] = parseCode(code);
+ const regex = /[\s,{]_Fragment[\s,}]/;
+ assert.equal(isSpecifierImported(code, imports, regex, 'astro/jsx-runtime'), false);
+ });
+
+ it('returns false when specifier is not imported', () => {
+ const code = `import { jsx } from 'astro/jsx-runtime';`;
+ const [imports] = parseCode(code);
+ const regex = /[\s,{]_Fragment[\s,}]/;
+ assert.equal(isSpecifierImported(code, imports, regex, 'astro/jsx-runtime'), false);
+ });
+
+ it('returns false with no imports', () => {
+ const code = `const x = 1;`;
+ const [imports] = parseCode(code);
+ const regex = /[\s,{]_Fragment[\s,}]/;
+ assert.equal(isSpecifierImported(code, imports, regex, 'astro/jsx-runtime'), false);
+ });
+ });
+});