diff --git a/packages/integrations/mdx/src/server.ts b/packages/integrations/mdx/src/server.ts index 0d728d7a3b53..0671fd55442f 100644 --- a/packages/integrations/mdx/src/server.ts +++ b/packages/integrations/mdx/src/server.ts @@ -3,7 +3,8 @@ import { AstroError } from 'astro/errors'; import { AstroJSX, jsx } from 'astro/jsx-runtime'; import { renderJSX } from 'astro/runtime/server/index.js'; -const slotName = (str: string) => str.trim().replace(/[-_]([a-z])/g, (_, w) => w.toUpperCase()); +export const slotName = (str: string) => + str.trim().replace(/[-_]([a-z])/g, (_, w) => w.toUpperCase()); // NOTE: In practice, MDX components are always tagged with `__astro_tag_component__`, so the right renderer // is used directly, and this check is not often used to return true. diff --git a/packages/integrations/mdx/src/utils.ts b/packages/integrations/mdx/src/utils.ts index 2fe729f8cad9..b2e798212d34 100644 --- a/packages/integrations/mdx/src/utils.ts +++ b/packages/integrations/mdx/src/utils.ts @@ -6,7 +6,7 @@ import type { MdxjsEsm } from 'mdast-util-mdx'; import colors from 'piccolore'; import type { PluggableList } from 'unified'; -function appendForwardSlash(path: string) { +export function appendForwardSlash(path: string) { return path.endsWith('/') ? path : path + '/'; } diff --git a/packages/integrations/mdx/src/vite-plugin-mdx-postprocess.ts b/packages/integrations/mdx/src/vite-plugin-mdx-postprocess.ts index 6ef792ffbcd1..c401f25e5abf 100644 --- a/packages/integrations/mdx/src/vite-plugin-mdx-postprocess.ts +++ b/packages/integrations/mdx/src/vite-plugin-mdx-postprocess.ts @@ -45,7 +45,7 @@ export function vitePluginMdxPostprocess(astroConfig: AstroConfig): Plugin { /** * Inject `Fragment` identifier import if not already present. */ -function injectUnderscoreFragmentImport(code: string, imports: readonly ImportSpecifier[]) { +export function injectUnderscoreFragmentImport(code: string, imports: readonly ImportSpecifier[]) { if (!isSpecifierImported(code, imports, underscoreFragmentImportRegex, 'astro/jsx-runtime')) { code += `\nimport { Fragment as _Fragment } from 'astro/jsx-runtime';`; } @@ -55,7 +55,7 @@ function injectUnderscoreFragmentImport(code: string, imports: readonly ImportSp /** * Inject MDX metadata as exports of the module. */ -function injectMetadataExports( +export function injectMetadataExports( code: string, exports: readonly ExportSpecifier[], fileInfo: FileInfo, @@ -73,7 +73,7 @@ function injectMetadataExports( * Transforms the `MDXContent` default export as `Content`, which wraps `MDXContent` and * passes additional `components` props. */ -function transformContentExport(code: string, exports: readonly ExportSpecifier[]) { +export function transformContentExport(code: string, exports: readonly ExportSpecifier[]) { if (exports.find(({ n }) => n === 'Content')) return code; // If have `export const components`, pass that as props to `Content` as fallback @@ -105,7 +105,7 @@ export default Content;`; /** * Add properties to the `Content` export. */ -function annotateContentExport( +export function annotateContentExport( code: string, id: string, ssr: boolean, @@ -139,7 +139,7 @@ function annotateContentExport( /** * Check whether the `specifierRegex` matches for an import of `source` in the `code`. */ -function isSpecifierImported( +export function isSpecifierImported( code: string, imports: readonly ImportSpecifier[], specifierRegex: RegExp, diff --git a/packages/integrations/mdx/test/fixtures/mdx-component/src/components/Test.mdx b/packages/integrations/mdx/test/fixtures/mdx-basics/src/components/component/Test.mdx similarity index 100% rename from packages/integrations/mdx/test/fixtures/mdx-component/src/components/Test.mdx rename to packages/integrations/mdx/test/fixtures/mdx-basics/src/components/component/Test.mdx diff --git a/packages/integrations/mdx/test/fixtures/mdx-component/src/components/WithFragment.mdx b/packages/integrations/mdx/test/fixtures/mdx-basics/src/components/component/WithFragment.mdx similarity index 100% rename from packages/integrations/mdx/test/fixtures/mdx-component/src/components/WithFragment.mdx rename to packages/integrations/mdx/test/fixtures/mdx-basics/src/components/component/WithFragment.mdx diff --git a/packages/integrations/mdx/test/fixtures/mdx-slots/src/components/Slotted.astro b/packages/integrations/mdx/test/fixtures/mdx-basics/src/components/slots/Slotted.astro similarity index 100% rename from packages/integrations/mdx/test/fixtures/mdx-slots/src/components/Slotted.astro rename to packages/integrations/mdx/test/fixtures/mdx-basics/src/components/slots/Slotted.astro diff --git a/packages/integrations/mdx/test/fixtures/mdx-slots/src/components/Test.mdx b/packages/integrations/mdx/test/fixtures/mdx-basics/src/components/slots/Test.mdx similarity index 100% rename from packages/integrations/mdx/test/fixtures/mdx-slots/src/components/Test.mdx rename to packages/integrations/mdx/test/fixtures/mdx-basics/src/components/slots/Test.mdx diff --git a/packages/integrations/mdx/test/fixtures/mdx-get-static-paths/src/content/1.mdx b/packages/integrations/mdx/test/fixtures/mdx-basics/src/content/1.mdx similarity index 100% rename from packages/integrations/mdx/test/fixtures/mdx-get-static-paths/src/content/1.mdx rename to packages/integrations/mdx/test/fixtures/mdx-basics/src/content/1.mdx diff --git a/packages/integrations/mdx/test/fixtures/mdx-frontmatter/src/layouts/Base.astro b/packages/integrations/mdx/test/fixtures/mdx-basics/src/layouts/Base.astro similarity index 100% rename from packages/integrations/mdx/test/fixtures/mdx-frontmatter/src/layouts/Base.astro rename to packages/integrations/mdx/test/fixtures/mdx-basics/src/layouts/Base.astro diff --git a/packages/integrations/mdx/test/fixtures/mdx-component/src/pages/glob.astro b/packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/component/glob.astro similarity index 76% rename from packages/integrations/mdx/test/fixtures/mdx-component/src/pages/glob.astro rename to packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/component/glob.astro index b18f65fd383a..62e96523db49 100644 --- a/packages/integrations/mdx/test/fixtures/mdx-component/src/pages/glob.astro +++ b/packages/integrations/mdx/test/fixtures/mdx-basics/src/pages/component/glob.astro @@ -1,6 +1,6 @@ --- import { parse } from 'node:path'; -const components = Object.values(import.meta.glob('../components/*.mdx', { eager: true })); +const components = Object.values(import.meta.glob('../../components/component/*.mdx', { eager: true })); ---
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); + }); + }); +});