Skip to content

Commit

Permalink
MD/MDX collect headings refactor (#5654)
Browse files Browse the repository at this point in the history
  • Loading branch information
delucis authored Dec 20, 2022
1 parent a467139 commit 2c65b43
Show file tree
Hide file tree
Showing 10 changed files with 212 additions and 108 deletions.
27 changes: 27 additions & 0 deletions .changeset/fast-baboons-prove.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
'@astrojs/mdx': minor
---

Run heading ID injection after user plugins

⚠️ 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 inject IDs before your plugins run, import and add the `rehypeHeadingIds` plugin to your `rehypePlugins` config:

```diff
// astro.config.mjs
+ import { rehypeHeadingIds } from '@astrojs/markdown-remark';
import mdx from '@astrojs/mdx';

export default {
integrations: [mdx()],
markdown: {
rehypePlugins: [
+ rehypeHeadingIds,
otherPluginThatReliesOnHeadingIDs,
],
},
}
```
7 changes: 7 additions & 0 deletions .changeset/violet-mice-push.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@astrojs/markdown-remark': minor
---

Refactor and export `rehypeHeadingIds` plugin

The `rehypeHeadingIds` plugin injects IDs for all headings in a Markdown document and can now also handle MDX inputs if needed. You can import and use this plugin if you need heading IDs to be injected _before_ other rehype plugins run.
1 change: 1 addition & 0 deletions packages/integrations/mdx/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"test:match": "mocha --timeout 20000 -g"
},
"dependencies": {
"@astrojs/markdown-remark": "^1.1.3",
"@astrojs/prism": "^1.0.2",
"@mdx-js/mdx": "^2.1.2",
"@mdx-js/rollup": "^2.1.1",
Expand Down
14 changes: 10 additions & 4 deletions packages/integrations/mdx/src/plugins.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { rehypeHeadingIds } from '@astrojs/markdown-remark';
import { nodeTypes } from '@mdx-js/mdx';
import type { PluggableList } from '@mdx-js/mdx/lib/core.js';
import type { Options as MdxRollupPluginOptions } from '@mdx-js/rollup';
Expand All @@ -10,7 +11,7 @@ import remarkGfm from 'remark-gfm';
import remarkSmartypants from 'remark-smartypants';
import type { Data, VFile } from 'vfile';
import { MdxOptions } from './index.js';
import rehypeCollectHeadings from './rehype-collect-headings.js';
import { rehypeInjectHeadingsExport } from './rehype-collect-headings.js';
import rehypeMetaString from './rehype-meta-string.js';
import remarkPrism from './remark-prism.js';
import remarkShiki from './remark-shiki.js';
Expand Down Expand Up @@ -153,8 +154,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 @@ -175,7 +174,14 @@ export function getRehypePlugins(
break;
}

rehypePlugins = [...rehypePlugins, ...(mdxOptions.rehypePlugins ?? [])];
rehypePlugins = [
...rehypePlugins,
...(mdxOptions.rehypePlugins ?? []),
// getHeadings() is guaranteed by TS, so this must be included.
// We run `rehypeHeadingIds` _last_ to respect any custom IDs set by user plugins.
rehypeHeadingIds,
rehypeInjectHeadingsExport,
];
return rehypePlugins;
}

Expand Down
47 changes: 4 additions & 43 deletions packages/integrations/mdx/src/rehype-collect-headings.ts
Original file line number Diff line number Diff line change
@@ -1,48 +1,9 @@
import Slugger from 'github-slugger';
import { visit } from 'unist-util-visit';
import { MarkdownVFile, MarkdownHeading } from '@astrojs/markdown-remark';
import { jsToTreeNode } from './utils.js';

export interface MarkdownHeading {
depth: number;
slug: string;
text: string;
}

export default function rehypeCollectHeadings() {
const slugger = new Slugger();
return function (tree: any) {
const headings: MarkdownHeading[] = [];
visit(tree, (node) => {
if (node.type !== 'element') return;
const { tagName } = node;
if (tagName[0] !== 'h') return;
const [_, level] = tagName.match(/h([0-6])/) ?? [];
if (!level) return;
const depth = Number.parseInt(level);

let text = '';
visit(node, (child, __, parent) => {
if (child.type === 'element' || parent == null) {
return;
}
if (child.type === 'raw' && child.value.match(/^\n?<.*>\n?$/)) {
return;
}
if (new Set(['text', 'raw', 'mdxTextExpression']).has(child.type)) {
text += child.value;
}
});

node.properties = node.properties || {};
if (typeof node.properties.id !== 'string') {
let slug = slugger.slug(text);
if (slug.endsWith('-')) {
slug = slug.slice(0, -1);
}
node.properties.id = slug;
}
headings.push({ depth, slug: node.properties.id, text });
});
export function rehypeInjectHeadingsExport() {
return function (tree: any, file: MarkdownVFile) {
const headings: MarkdownHeading[] = file.data.__astroHeadings || [];
tree.children.unshift(
jsToTreeNode(`export function getHeadings() { return ${JSON.stringify(headings)} }`)
);
Expand Down
91 changes: 91 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,6 @@
import { rehypeHeadingIds } from '@astrojs/markdown-remark';
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 +60,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({
rehypePlugins: [
rehypeHeadingIds,
() => (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');
});
});
13 changes: 7 additions & 6 deletions packages/markdown/remark/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { MarkdownRenderingOptions, MarkdownRenderingResult } from './types';
import type { MarkdownRenderingOptions, MarkdownRenderingResult, MarkdownVFile } from './types';

import { loadPlugins } from './load-plugins.js';
import createCollectHeadings from './rehype-collect-headings.js';
import { rehypeHeadingIds } from './rehype-collect-headings.js';
import rehypeEscape from './rehype-escape.js';
import rehypeExpressions from './rehype-expressions.js';
import rehypeIslands from './rehype-islands.js';
Expand All @@ -22,6 +22,7 @@ import markdownToHtml from 'remark-rehype';
import { unified } from 'unified';
import { VFile } from 'vfile';

export { rehypeHeadingIds } from './rehype-collect-headings.js';
export * from './types.js';

export const DEFAULT_REMARK_PLUGINS = ['remark-gfm', 'remark-smartypants'];
Expand All @@ -44,7 +45,6 @@ export async function renderMarkdown(
} = opts;
const input = new VFile({ value: content, path: fileURL });
const scopedClassName = opts.$?.scopedClassName;
const { headings, rehypeCollectHeadings } = createCollectHeadings();

let parser = unified()
.use(markdown)
Expand Down Expand Up @@ -99,12 +99,12 @@ export async function renderMarkdown(
parser
.use(
isAstroFlavoredMd
? [rehypeJsx, rehypeExpressions, rehypeEscape, rehypeIslands, rehypeCollectHeadings]
: [rehypeCollectHeadings, rehypeRaw]
? [rehypeJsx, rehypeExpressions, rehypeEscape, rehypeIslands, rehypeHeadingIds]
: [rehypeHeadingIds, rehypeRaw]
)
.use(rehypeStringify, { allowDangerousHtml: true });

let vfile: VFile;
let vfile: MarkdownVFile;
try {
vfile = await parser.process(input);
} catch (err) {
Expand All @@ -116,6 +116,7 @@ export async function renderMarkdown(
throw err;
}

const headings = vfile?.data.__astroHeadings || [];
return {
metadata: { headings, source: content, html: String(vfile.value) },
code: String(vfile.value),
Expand Down
Loading

0 comments on commit 2c65b43

Please sign in to comment.