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

MD/MDX collect headings refactor #5654

Merged
merged 7 commits into from
Dec 20, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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