Skip to content

Commit a31bbd7

Browse files
remcohaszingematipicobluwysarah11918
authored
fix(markdown): don’t generate mdast html nodes (#10104)
* fix(markdown): don’t generate mdast html nodes `html` nodes from mdast are converted to `raw` hast nodes. These nodes are then not processed by proper rehype plugins. Typically if a remark plugin generates `html` nodes, this indicates it should have actually been a rehype plugin. This changes the remark plugins that generate `html` nodes into rehype nodes. These were `remarkPrism` and `remarkShiki`. Closes #9909 * Apply suggestions from code review * refactor(mdx): move user defined rehype plugins after syntax highlighting * fix(mdx): fix issue in mdx rehype plugin ordering * docs: explain why html/raw nodes are avoided in changeset This also includes some hints on what users could do to upgrade of they rely on these nodes. * Fix MDX rehype plugin ordering * refactor(remark): restore remarkPrism and remarkShiki They aren’t used anymore, but removing would be a breaking change. * chore: mark deprecated * Apply suggestions from code review Co-authored-by: Sarah Rainsberger <[email protected]> * Update .changeset/thirty-beds-smoke.md Co-authored-by: Sarah Rainsberger <[email protected]> --------- Co-authored-by: Emanuele Stoppa <[email protected]> Co-authored-by: Bjorn Lu <[email protected]> Co-authored-by: Sarah Rainsberger <[email protected]>
1 parent 5a95287 commit a31bbd7

File tree

10 files changed

+166
-37
lines changed

10 files changed

+166
-37
lines changed

.changeset/thirty-beds-smoke.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@astrojs/mdx": minor
3+
"@astrojs/markdown-remark": minor
4+
---
5+
6+
Changes Astro's internal syntax highlighting to use rehype plugins instead of remark plugins. This provides better interoperability with other [rehype plugins](https://github.com/rehypejs/rehype/blob/main/doc/plugins.md#list-of-plugins) that deal with code blocks, in particular with third party syntax highlighting plugins and [`rehype-mermaid`](https://github.com/remcohaszing/rehype-mermaid).
7+
8+
This may be a breaking change if you are currently using:
9+
- a remark plugin that relies on nodes of type `html`
10+
- a rehype plugin that depends on nodes of type `raw`.
11+
12+
Please review your rendered code samples carefully, and if necessary, consider using a rehype plugin that deals with the generated `element` nodes instead. You can transform the AST of raw HTML strings, or alternatively use [`hast-util-to-html`](https://github.com/syntax-tree/hast-util-to-html) to get a string from a `raw` node.

packages/integrations/mdx/src/plugins.ts

Lines changed: 21 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import {
22
rehypeHeadingIds,
3+
rehypePrism,
4+
rehypeShiki,
35
remarkCollectImages,
4-
remarkPrism,
5-
remarkShiki,
66
} from '@astrojs/markdown-remark';
77
import { createProcessor, nodeTypes } from '@mdx-js/mdx';
88
import rehypeRaw from 'rehype-raw';
@@ -54,22 +54,7 @@ function getRemarkPlugins(mdxOptions: MdxOptions): PluggableList {
5454
}
5555
}
5656

57-
remarkPlugins = [
58-
...remarkPlugins,
59-
...mdxOptions.remarkPlugins,
60-
remarkCollectImages,
61-
remarkImageToComponent,
62-
];
63-
64-
if (!isPerformanceBenchmark) {
65-
// Apply syntax highlighters after user plugins to match `markdown/remark` behavior
66-
if (mdxOptions.syntaxHighlight === 'shiki') {
67-
remarkPlugins.push([remarkShiki, mdxOptions.shikiConfig]);
68-
}
69-
if (mdxOptions.syntaxHighlight === 'prism') {
70-
remarkPlugins.push(remarkPrism);
71-
}
72-
}
57+
remarkPlugins.push(...mdxOptions.remarkPlugins, remarkCollectImages, remarkImageToComponent);
7358

7459
return remarkPlugins;
7560
}
@@ -79,18 +64,28 @@ function getRehypePlugins(mdxOptions: MdxOptions): PluggableList {
7964
// ensure `data.meta` is preserved in `properties.metastring` for rehype syntax highlighters
8065
rehypeMetaString,
8166
// rehypeRaw allows custom syntax highlighters to work without added config
82-
[rehypeRaw, { passThrough: nodeTypes }] as any,
67+
[rehypeRaw, { passThrough: nodeTypes }],
8368
];
8469

85-
rehypePlugins = [
86-
...rehypePlugins,
87-
...mdxOptions.rehypePlugins,
70+
if (!isPerformanceBenchmark) {
71+
// Apply syntax highlighters after user plugins to match `markdown/remark` behavior
72+
if (mdxOptions.syntaxHighlight === 'shiki') {
73+
rehypePlugins.push([rehypeShiki, mdxOptions.shikiConfig]);
74+
} else if (mdxOptions.syntaxHighlight === 'prism') {
75+
rehypePlugins.push(rehypePrism);
76+
}
77+
}
78+
79+
rehypePlugins.push(...mdxOptions.rehypePlugins);
80+
81+
if (!isPerformanceBenchmark) {
8882
// getHeadings() is guaranteed by TS, so this must be included.
8983
// We run `rehypeHeadingIds` _last_ to respect any custom IDs set by user plugins.
90-
...(isPerformanceBenchmark ? [] : [rehypeHeadingIds, rehypeInjectHeadingsExport]),
91-
// computed from `astro.data.frontmatter` in VFile data
92-
rehypeApplyFrontmatterExport,
93-
];
84+
rehypePlugins.push(rehypeHeadingIds, rehypeInjectHeadingsExport);
85+
}
86+
87+
// computed from `astro.data.frontmatter` in VFile data
88+
rehypePlugins.push(rehypeApplyFrontmatterExport);
9489

9590
if (mdxOptions.optimize) {
9691
// Convert user `optimize` option to compatible `rehypeOptimizeStatic` option

packages/markdown/remark/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@
3636
"dependencies": {
3737
"@astrojs/prism": "^3.0.0",
3838
"github-slugger": "^2.0.0",
39+
"hast-util-from-html": "^2.0.0",
40+
"hast-util-to-text": "^4.0.0",
3941
"import-meta-resolve": "^4.0.0",
4042
"mdast-util-definitions": "^6.0.0",
4143
"rehype-raw": "^7.0.0",
@@ -46,7 +48,9 @@
4648
"remark-smartypants": "^2.0.0",
4749
"shiki": "^1.1.2",
4850
"unified": "^11.0.4",
51+
"unist-util-remove-position": "^5.0.0",
4952
"unist-util-visit": "^5.0.0",
53+
"unist-util-visit-parents": "^6.0.0",
5054
"vfile": "^6.0.1"
5155
},
5256
"devDependencies": {
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import type { Element, Root } from 'hast';
2+
import { fromHtml } from 'hast-util-from-html';
3+
import { toText } from 'hast-util-to-text';
4+
import { removePosition } from 'unist-util-remove-position';
5+
import { visitParents } from 'unist-util-visit-parents';
6+
7+
type Highlighter = (code: string, language: string) => string;
8+
9+
const languagePattern = /\blanguage-(\S+)\b/;
10+
11+
/**
12+
* A hast utility to syntax highlight code blocks with a given syntax highlighter.
13+
*
14+
* @param tree
15+
* The hast tree in which to syntax highlight code blocks.
16+
* @param highlighter
17+
* A fnction which receives the code and language, and returns the HTML of a syntax
18+
* highlighted `<pre>` element.
19+
*/
20+
export function highlightCodeBlocks(tree: Root, highlighter: Highlighter) {
21+
// We’re looking for `<code>` elements
22+
visitParents(tree, { type: 'element', tagName: 'code' }, (node, ancestors) => {
23+
const parent = ancestors.at(-1);
24+
25+
// Whose parent is a `<pre>`.
26+
if (parent?.type !== 'element' || parent.tagName !== 'pre') {
27+
return;
28+
}
29+
30+
// Where the `<code>` is the only child.
31+
if (parent.children.length !== 1) {
32+
return;
33+
}
34+
35+
// And the `<code>` has a class name that starts with `language-`.
36+
let languageMatch: RegExpMatchArray | null | undefined;
37+
let { className } = node.properties;
38+
if (typeof className === 'string') {
39+
languageMatch = className.match(languagePattern);
40+
} else if (Array.isArray(className)) {
41+
for (const cls of className) {
42+
if (typeof cls !== 'string') {
43+
continue;
44+
}
45+
46+
languageMatch = cls.match(languagePattern);
47+
if (languageMatch) {
48+
break;
49+
}
50+
}
51+
}
52+
53+
// Don’t mighlight math code blocks.
54+
if (languageMatch?.[1] === 'math') {
55+
return;
56+
}
57+
58+
const code = toText(node, { whitespace: 'pre' });
59+
const html = highlighter(code, languageMatch?.[1] || 'plaintext');
60+
// The replacement returns a root node with 1 child, the `<pr>` element replacement.
61+
const replacement = fromHtml(html, { fragment: true }).children[0] as Element;
62+
// We just generated this node, so any positional information is invalid.
63+
removePosition(replacement);
64+
65+
// We replace the parent in its parent with the new `<pre>` element.
66+
const grandParent = ancestors.at(-2)!;
67+
const index = grandParent.children.indexOf(parent);
68+
grandParent.children[index] = replacement;
69+
});
70+
}

packages/markdown/remark/src/index.ts

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@ import {
77
} from './frontmatter-injection.js';
88
import { loadPlugins } from './load-plugins.js';
99
import { rehypeHeadingIds } from './rehype-collect-headings.js';
10+
import { rehypePrism } from './rehype-prism.js';
11+
import { rehypeShiki } from './rehype-shiki.js';
1012
import { remarkCollectImages } from './remark-collect-images.js';
11-
import { remarkPrism } from './remark-prism.js';
12-
import { remarkShiki } from './remark-shiki.js';
1313

1414
import rehypeRaw from 'rehype-raw';
1515
import rehypeStringify from 'rehype-stringify';
@@ -24,6 +24,8 @@ import { rehypeImages } from './rehype-images.js';
2424
export { InvalidAstroDataError, setVfileFrontmatter } from './frontmatter-injection.js';
2525
export { rehypeHeadingIds } from './rehype-collect-headings.js';
2626
export { remarkCollectImages } from './remark-collect-images.js';
27+
export { rehypePrism } from './rehype-prism.js';
28+
export { rehypeShiki } from './rehype-shiki.js';
2729
export { remarkPrism } from './remark-prism.js';
2830
export { remarkShiki } from './remark-shiki.js';
2931
export { createShikiHighlighter, replaceCssVariables, type ShikiHighlighter } from './shiki.js';
@@ -85,13 +87,6 @@ export async function createMarkdownProcessor(
8587
}
8688

8789
if (!isPerformanceBenchmark) {
88-
// Syntax highlighting
89-
if (syntaxHighlight === 'shiki') {
90-
parser.use(remarkShiki, shikiConfig);
91-
} else if (syntaxHighlight === 'prism') {
92-
parser.use(remarkPrism);
93-
}
94-
9590
// Apply later in case user plugins resolve relative image paths
9691
parser.use(remarkCollectImages);
9792
}
@@ -103,6 +98,15 @@ export async function createMarkdownProcessor(
10398
...remarkRehypeOptions,
10499
});
105100

101+
if (!isPerformanceBenchmark) {
102+
// Syntax highlighting
103+
if (syntaxHighlight === 'shiki') {
104+
parser.use(rehypeShiki, shikiConfig);
105+
} else if (syntaxHighlight === 'prism') {
106+
parser.use(rehypePrism);
107+
}
108+
}
109+
106110
// User rehype plugins
107111
for (const [plugin, pluginOpts] of loadedRehypePlugins) {
108112
parser.use(plugin, pluginOpts);
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { runHighlighterWithAstro } from '@astrojs/prism/dist/highlighter';
2+
import type { Root } from 'hast';
3+
import type { Plugin } from 'unified';
4+
import { highlightCodeBlocks } from './highlight.js';
5+
6+
export const rehypePrism: Plugin<[], Root> = () => (tree) => {
7+
highlightCodeBlocks(tree, (code, language) => {
8+
let { html, classLanguage } = runHighlighterWithAstro(language, code);
9+
10+
return `<pre class="${classLanguage}"><code is:raw class="${classLanguage}">${html}</code></pre>`;
11+
});
12+
};
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import type { Root } from 'hast';
2+
import type { Plugin } from 'unified';
3+
import { createShikiHighlighter, type ShikiHighlighter } from './shiki.js';
4+
import type { ShikiConfig } from './types.js';
5+
import { highlightCodeBlocks } from './highlight.js';
6+
7+
export const rehypeShiki: Plugin<[ShikiConfig?], Root> = (config) => {
8+
let highlighterAsync: Promise<ShikiHighlighter> | undefined;
9+
10+
return async (tree) => {
11+
highlighterAsync ??= createShikiHighlighter(config);
12+
const highlighter = await highlighterAsync;
13+
14+
highlightCodeBlocks(tree, highlighter.highlight);
15+
};
16+
};

packages/markdown/remark/src/remark-prism.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ import { runHighlighterWithAstro } from '@astrojs/prism/dist/highlighter';
22
import { visit } from 'unist-util-visit';
33
import type { RemarkPlugin } from './types.js';
44

5+
/**
6+
* @deprecated Use `rehypePrism` instead
7+
*/
58
export function remarkPrism(): ReturnType<RemarkPlugin> {
69
return function (tree: any) {
710
visit(tree, 'code', (node) => {

packages/markdown/remark/src/remark-shiki.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ import { visit } from 'unist-util-visit';
22
import { type ShikiHighlighter, createShikiHighlighter } from './shiki.js';
33
import type { RemarkPlugin, ShikiConfig } from './types.js';
44

5+
/**
6+
* @deprecated Use `rehypeShiki` instead
7+
*/
58
export function remarkShiki(config?: ShikiConfig): ReturnType<RemarkPlugin> {
69
let highlighterAsync: Promise<ShikiHighlighter> | undefined;
710

pnpm-lock.yaml

Lines changed: 12 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)