Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow MDX users to choose when rehypeCollectHeadings runs #5646

Closed
wants to merge 9 commits into from
24 changes: 24 additions & 0 deletions .changeset/lovely-bikes-develop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'@astrojs/mdx': minor
---

Inject heading IDs after user rehype plugins run.

This allows users to override Astro’s default ID generation algorithm with a standard tool like `rehype-slug` or any alternative. It is also consistent with the order of execution in Markdown files in Astro.

⚠️ BREAKING CHANGE ⚠️

If you are using a rehype plugin that depends on heading IDs injected by Astro, the IDs will no longer be available when your plugin runs by default.

To restore the previous behavior, set `collectHeadings: 'before'` in the MDX integration options:

```js
// astro.config.mjs
import mdx from '@astrojs/mdx';

export default {
integrations: [
mdx({ collectHeadings: 'before' }),
],
}
```
23 changes: 23 additions & 0 deletions packages/integrations/mdx/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ You can extend how your MDX is rendered by adding remark, rehype and recma plugi
- [`remarkPlugins`](#remarkplugins)
- [`rehypePlugins`](#rehypeplugins)
- [`recmaPlugins`](#recmaplugins)
- [`collectHeadings`](#collectheadings)

### `extendPlugins`

Expand Down Expand Up @@ -196,6 +197,28 @@ These are plugins that modify the output [estree](https://github.com/estree/estr

We suggest [using AST Explorer](https://astexplorer.net/) to play with estree outputs, and trying [`estree-util-visit`](https://unifiedjs.com/explore/package/estree-util-visit/) for searching across JavaScript nodes.

### `collectHeadings`

**Default:** `'after'`
**Type:** `'before' | 'after'`

Astro injects IDs to heading elements in MDX documents for you. By default, this happens after any other rehype plugins have run.

If you need to access these Astro-generated IDs from one of your other rehype plugins, you can toggle this order to inject headings before your plugins run:

```js
// astro.config.mjs

export default {
integrations: [
mdx({
collectHeadings: 'before',
rehypePlugins: [pluginThatNeedsAccessToHeadingIDs],
}),
],
}
```

## Examples

* The [Astro MDX starter template](https://github.com/withastro/astro/tree/latest/examples/with-mdx) shows how to use MDX files in your Astro project.
Expand Down
8 changes: 8 additions & 0 deletions packages/integrations/mdx/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,14 @@ export type MdxOptions = {
*/
extendPlugins?: 'markdown' | 'astroDefaults' | false;
remarkRehype?: RemarkRehypeOptions;
/**
* Specify when the internal rehype plugin injecting heading IDs should run.
* Set to `'before'` if you are using rehype plugins that need to access injected IDs.
* Defaults to `'after'` to allow you to override Astro’s default ID-generation algorithm.
*
* @default 'after'
*/
collectHeadings?: 'before' | 'after';
};

export default function mdx(mdxOptions: MdxOptions = {}): AstroIntegration {
Expand Down
10 changes: 8 additions & 2 deletions packages/integrations/mdx/src/plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,8 +153,6 @@ export function getRehypePlugins(
config: AstroConfig
): MdxRollupPluginOptions['rehypePlugins'] {
let rehypePlugins: PluggableList = [
// getHeadings() is guaranteed by TS, so we can't allow user to override
rehypeCollectHeadings,
// ensure `data.meta` is preserved in `properties.metastring` for rehype syntax highlighters
rehypeMetaString,
// rehypeRaw allows custom syntax highlighters to work without added config
Expand All @@ -176,6 +174,14 @@ export function getRehypePlugins(
}

rehypePlugins = [...rehypePlugins, ...(mdxOptions.rehypePlugins ?? [])];

// getHeadings() is guaranteed by TS, so we can't allow user to override
if (mdxOptions.collectHeadings === 'before') {
rehypePlugins.unshift(rehypeCollectHeadings);
} else {
rehypePlugins.push(rehypeCollectHeadings);
}

return rehypePlugins;
}

Expand Down
90 changes: 90 additions & 0 deletions packages/integrations/mdx/test/mdx-get-headings.test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import mdx from '@astrojs/mdx';
import { visit } from 'unist-util-visit';

import { expect } from 'chai';
import { parseHTML } from 'linkedom';
Expand Down Expand Up @@ -58,3 +59,92 @@ describe('MDX getHeadings', () => {
);
});
});

describe('MDX heading IDs can be customized by user plugins', () => {
let fixture;

before(async () => {
fixture = await loadFixture({
root: new URL('./fixtures/mdx-get-headings/', import.meta.url),
integrations: [mdx()],
markdown: {
rehypePlugins: [
() => (tree) => {
let count = 0;
visit(tree, 'element', (node, index, parent) => {
if (!/^h\d$/.test(node.tagName)) return;
if (!node.properties?.id) {
node.properties = { ...node.properties, id: String(count++) };
}
});
},
],
},
});

await fixture.build();
});

it('adds user-specified IDs to HTML output', async () => {
const html = await fixture.readFile('/test/index.html');
const { document } = parseHTML(html);

const h1 = document.querySelector('h1');
expect(h1?.textContent).to.equal('Heading test');
expect(h1?.getAttribute('id')).to.equal('0');

const headingIDs = document.querySelectorAll('h1,h2,h3').map((el) => el.id);
expect(JSON.stringify(headingIDs)).to.equal(
JSON.stringify(Array.from({ length: headingIDs.length }, (_, idx) => String(idx)))
);
});

it('generates correct getHeadings() export', async () => {
const { headingsByPage } = JSON.parse(await fixture.readFile('/pages.json'));
expect(JSON.stringify(headingsByPage['./test.mdx'])).to.equal(
JSON.stringify([
{ depth: 1, slug: '0', text: 'Heading test' },
{ depth: 2, slug: '1', text: 'Section 1' },
{ depth: 3, slug: '2', text: 'Subsection 1' },
{ depth: 3, slug: '3', text: 'Subsection 2' },
{ depth: 2, slug: '4', text: 'Section 2' },
])
);
});
});

describe('MDX heading IDs can be injected before user plugins', () => {
let fixture;

before(async () => {
fixture = await loadFixture({
root: new URL('./fixtures/mdx-get-headings/', import.meta.url),
integrations: [
mdx({
collectHeadings: 'before',
rehypePlugins: [
() => (tree) => {
visit(tree, 'element', (node, index, parent) => {
if (!/^h\d$/.test(node.tagName)) return;
if (node.properties?.id) {
node.children.push({ type: 'text', value: ' ' + node.properties.id });
}
});
},
],
}),
],
});

await fixture.build();
});

it('adds user-specified IDs to HTML output', async () => {
const html = await fixture.readFile('/test/index.html');
const { document } = parseHTML(html);

const h1 = document.querySelector('h1');
expect(h1?.textContent).to.equal('Heading test heading-test');
expect(h1?.id).to.equal('heading-test');
});
});