diff --git a/.changeset/thirty-beds-smoke.md b/.changeset/thirty-beds-smoke.md new file mode 100644 index 000000000000..6d57166c12b9 --- /dev/null +++ b/.changeset/thirty-beds-smoke.md @@ -0,0 +1,12 @@ +--- +"@astrojs/mdx": minor +"@astrojs/markdown-remark": minor +--- + +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). + +This may be a breaking change if you are currently using: +- a remark plugin that relies on nodes of type `html` +- a rehype plugin that depends on nodes of type `raw`. + +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. diff --git a/packages/integrations/mdx/src/plugins.ts b/packages/integrations/mdx/src/plugins.ts index d52f303019a3..5bc7ca982c6c 100644 --- a/packages/integrations/mdx/src/plugins.ts +++ b/packages/integrations/mdx/src/plugins.ts @@ -1,8 +1,8 @@ import { rehypeHeadingIds, + rehypePrism, + rehypeShiki, remarkCollectImages, - remarkPrism, - remarkShiki, } from '@astrojs/markdown-remark'; import { createProcessor, nodeTypes } from '@mdx-js/mdx'; import rehypeRaw from 'rehype-raw'; @@ -54,22 +54,7 @@ function getRemarkPlugins(mdxOptions: MdxOptions): PluggableList { } } - remarkPlugins = [ - ...remarkPlugins, - ...mdxOptions.remarkPlugins, - remarkCollectImages, - remarkImageToComponent, - ]; - - if (!isPerformanceBenchmark) { - // Apply syntax highlighters after user plugins to match `markdown/remark` behavior - if (mdxOptions.syntaxHighlight === 'shiki') { - remarkPlugins.push([remarkShiki, mdxOptions.shikiConfig]); - } - if (mdxOptions.syntaxHighlight === 'prism') { - remarkPlugins.push(remarkPrism); - } - } + remarkPlugins.push(...mdxOptions.remarkPlugins, remarkCollectImages, remarkImageToComponent); return remarkPlugins; } @@ -79,18 +64,28 @@ function getRehypePlugins(mdxOptions: MdxOptions): PluggableList { // ensure `data.meta` is preserved in `properties.metastring` for rehype syntax highlighters rehypeMetaString, // rehypeRaw allows custom syntax highlighters to work without added config - [rehypeRaw, { passThrough: nodeTypes }] as any, + [rehypeRaw, { passThrough: nodeTypes }], ]; - rehypePlugins = [ - ...rehypePlugins, - ...mdxOptions.rehypePlugins, + if (!isPerformanceBenchmark) { + // Apply syntax highlighters after user plugins to match `markdown/remark` behavior + if (mdxOptions.syntaxHighlight === 'shiki') { + rehypePlugins.push([rehypeShiki, mdxOptions.shikiConfig]); + } else if (mdxOptions.syntaxHighlight === 'prism') { + rehypePlugins.push(rehypePrism); + } + } + + rehypePlugins.push(...mdxOptions.rehypePlugins); + + if (!isPerformanceBenchmark) { // getHeadings() is guaranteed by TS, so this must be included. // We run `rehypeHeadingIds` _last_ to respect any custom IDs set by user plugins. - ...(isPerformanceBenchmark ? [] : [rehypeHeadingIds, rehypeInjectHeadingsExport]), - // computed from `astro.data.frontmatter` in VFile data - rehypeApplyFrontmatterExport, - ]; + rehypePlugins.push(rehypeHeadingIds, rehypeInjectHeadingsExport); + } + + // computed from `astro.data.frontmatter` in VFile data + rehypePlugins.push(rehypeApplyFrontmatterExport); if (mdxOptions.optimize) { // Convert user `optimize` option to compatible `rehypeOptimizeStatic` option diff --git a/packages/markdown/remark/package.json b/packages/markdown/remark/package.json index 98a23930138c..3e07f49d15d9 100644 --- a/packages/markdown/remark/package.json +++ b/packages/markdown/remark/package.json @@ -36,6 +36,8 @@ "dependencies": { "@astrojs/prism": "^3.0.0", "github-slugger": "^2.0.0", + "hast-util-from-html": "^2.0.0", + "hast-util-to-text": "^4.0.0", "import-meta-resolve": "^4.0.0", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", @@ -46,7 +48,9 @@ "remark-smartypants": "^2.0.0", "shiki": "^1.1.2", "unified": "^11.0.4", + "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.0.0", + "unist-util-visit-parents": "^6.0.0", "vfile": "^6.0.1" }, "devDependencies": { diff --git a/packages/markdown/remark/src/highlight.ts b/packages/markdown/remark/src/highlight.ts new file mode 100644 index 000000000000..eaf4c9bdf9b8 --- /dev/null +++ b/packages/markdown/remark/src/highlight.ts @@ -0,0 +1,70 @@ +import type { Element, Root } from 'hast'; +import { fromHtml } from 'hast-util-from-html'; +import { toText } from 'hast-util-to-text'; +import { removePosition } from 'unist-util-remove-position'; +import { visitParents } from 'unist-util-visit-parents'; + +type Highlighter = (code: string, language: string) => string; + +const languagePattern = /\blanguage-(\S+)\b/; + +/** + * A hast utility to syntax highlight code blocks with a given syntax highlighter. + * + * @param tree + * The hast tree in which to syntax highlight code blocks. + * @param highlighter + * A fnction which receives the code and language, and returns the HTML of a syntax + * highlighted `
` element.
+ */
+export function highlightCodeBlocks(tree: Root, highlighter: Highlighter) {
+	// We’re looking for `` elements
+	visitParents(tree, { type: 'element', tagName: 'code' }, (node, ancestors) => {
+		const parent = ancestors.at(-1);
+
+		// Whose parent is a `
`.
+		if (parent?.type !== 'element' || parent.tagName !== 'pre') {
+			return;
+		}
+
+		// Where the `` is the only child.
+		if (parent.children.length !== 1) {
+			return;
+		}
+
+		// And the `` has a class name that starts with `language-`.
+		let languageMatch: RegExpMatchArray | null | undefined;
+		let { className } = node.properties;
+		if (typeof className === 'string') {
+			languageMatch = className.match(languagePattern);
+		} else if (Array.isArray(className)) {
+			for (const cls of className) {
+				if (typeof cls !== 'string') {
+					continue;
+				}
+
+				languageMatch = cls.match(languagePattern);
+				if (languageMatch) {
+					break;
+				}
+			}
+		}
+
+		// Don’t mighlight math code blocks.
+		if (languageMatch?.[1] === 'math') {
+			return;
+		}
+
+		const code = toText(node, { whitespace: 'pre' });
+		const html = highlighter(code, languageMatch?.[1] || 'plaintext');
+		// The replacement returns a root node with 1 child, the `` element replacement.
+		const replacement = fromHtml(html, { fragment: true }).children[0] as Element;
+		// We just generated this node, so any positional information is invalid.
+		removePosition(replacement);
+
+		// We replace the parent in its parent with the new `
` element.
+		const grandParent = ancestors.at(-2)!;
+		const index = grandParent.children.indexOf(parent);
+		grandParent.children[index] = replacement;
+	});
+}
diff --git a/packages/markdown/remark/src/index.ts b/packages/markdown/remark/src/index.ts
index 6ab0e5c6765c..ef1afc8fba18 100644
--- a/packages/markdown/remark/src/index.ts
+++ b/packages/markdown/remark/src/index.ts
@@ -7,9 +7,9 @@ import {
 } from './frontmatter-injection.js';
 import { loadPlugins } from './load-plugins.js';
 import { rehypeHeadingIds } from './rehype-collect-headings.js';
+import { rehypePrism } from './rehype-prism.js';
+import { rehypeShiki } from './rehype-shiki.js';
 import { remarkCollectImages } from './remark-collect-images.js';
-import { remarkPrism } from './remark-prism.js';
-import { remarkShiki } from './remark-shiki.js';
 
 import rehypeRaw from 'rehype-raw';
 import rehypeStringify from 'rehype-stringify';
@@ -24,6 +24,8 @@ import { rehypeImages } from './rehype-images.js';
 export { InvalidAstroDataError, setVfileFrontmatter } from './frontmatter-injection.js';
 export { rehypeHeadingIds } from './rehype-collect-headings.js';
 export { remarkCollectImages } from './remark-collect-images.js';
+export { rehypePrism } from './rehype-prism.js';
+export { rehypeShiki } from './rehype-shiki.js';
 export { remarkPrism } from './remark-prism.js';
 export { remarkShiki } from './remark-shiki.js';
 export { createShikiHighlighter, replaceCssVariables, type ShikiHighlighter } from './shiki.js';
@@ -85,13 +87,6 @@ export async function createMarkdownProcessor(
 	}
 
 	if (!isPerformanceBenchmark) {
-		// Syntax highlighting
-		if (syntaxHighlight === 'shiki') {
-			parser.use(remarkShiki, shikiConfig);
-		} else if (syntaxHighlight === 'prism') {
-			parser.use(remarkPrism);
-		}
-
 		// Apply later in case user plugins resolve relative image paths
 		parser.use(remarkCollectImages);
 	}
@@ -103,6 +98,15 @@ export async function createMarkdownProcessor(
 		...remarkRehypeOptions,
 	});
 
+	if (!isPerformanceBenchmark) {
+		// Syntax highlighting
+		if (syntaxHighlight === 'shiki') {
+			parser.use(rehypeShiki, shikiConfig);
+		} else if (syntaxHighlight === 'prism') {
+			parser.use(rehypePrism);
+		}
+	}
+
 	// User rehype plugins
 	for (const [plugin, pluginOpts] of loadedRehypePlugins) {
 		parser.use(plugin, pluginOpts);
diff --git a/packages/markdown/remark/src/rehype-prism.ts b/packages/markdown/remark/src/rehype-prism.ts
new file mode 100644
index 000000000000..4305a067677f
--- /dev/null
+++ b/packages/markdown/remark/src/rehype-prism.ts
@@ -0,0 +1,12 @@
+import { runHighlighterWithAstro } from '@astrojs/prism/dist/highlighter';
+import type { Root } from 'hast';
+import type { Plugin } from 'unified';
+import { highlightCodeBlocks } from './highlight.js';
+
+export const rehypePrism: Plugin<[], Root> = () => (tree) => {
+	highlightCodeBlocks(tree, (code, language) => {
+		let { html, classLanguage } = runHighlighterWithAstro(language, code);
+
+		return `
${html}
`; + }); +}; diff --git a/packages/markdown/remark/src/rehype-shiki.ts b/packages/markdown/remark/src/rehype-shiki.ts new file mode 100644 index 000000000000..137cb81411cc --- /dev/null +++ b/packages/markdown/remark/src/rehype-shiki.ts @@ -0,0 +1,16 @@ +import type { Root } from 'hast'; +import type { Plugin } from 'unified'; +import { createShikiHighlighter, type ShikiHighlighter } from './shiki.js'; +import type { ShikiConfig } from './types.js'; +import { highlightCodeBlocks } from './highlight.js'; + +export const rehypeShiki: Plugin<[ShikiConfig?], Root> = (config) => { + let highlighterAsync: Promise | undefined; + + return async (tree) => { + highlighterAsync ??= createShikiHighlighter(config); + const highlighter = await highlighterAsync; + + highlightCodeBlocks(tree, highlighter.highlight); + }; +}; diff --git a/packages/markdown/remark/src/remark-prism.ts b/packages/markdown/remark/src/remark-prism.ts index a3f476d6e4ab..49e38d73cfd7 100644 --- a/packages/markdown/remark/src/remark-prism.ts +++ b/packages/markdown/remark/src/remark-prism.ts @@ -2,6 +2,9 @@ import { runHighlighterWithAstro } from '@astrojs/prism/dist/highlighter'; import { visit } from 'unist-util-visit'; import type { RemarkPlugin } from './types.js'; +/** + * @deprecated Use `rehypePrism` instead + */ export function remarkPrism(): ReturnType { return function (tree: any) { visit(tree, 'code', (node) => { diff --git a/packages/markdown/remark/src/remark-shiki.ts b/packages/markdown/remark/src/remark-shiki.ts index 7c1cdd0750a3..512cd453aa38 100644 --- a/packages/markdown/remark/src/remark-shiki.ts +++ b/packages/markdown/remark/src/remark-shiki.ts @@ -2,6 +2,9 @@ import { visit } from 'unist-util-visit'; import { type ShikiHighlighter, createShikiHighlighter } from './shiki.js'; import type { RemarkPlugin, ShikiConfig } from './types.js'; +/** + * @deprecated Use `rehypeShiki` instead + */ export function remarkShiki(config?: ShikiConfig): ReturnType { let highlighterAsync: Promise | undefined; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e32f61f2b0a3..b8e8d09977b6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5212,6 +5212,12 @@ importers: github-slugger: specifier: ^2.0.0 version: 2.0.0 + hast-util-from-html: + specifier: ^2.0.0 + version: 2.0.1 + hast-util-to-text: + specifier: ^4.0.0 + version: 4.0.0 import-meta-resolve: specifier: ^4.0.0 version: 4.0.0 @@ -5242,9 +5248,15 @@ importers: unified: specifier: ^11.0.4 version: 11.0.4 + unist-util-remove-position: + specifier: ^5.0.0 + version: 5.0.0 unist-util-visit: specifier: ^5.0.0 version: 5.0.0 + unist-util-visit-parents: + specifier: ^6.0.0 + version: 6.0.1 vfile: specifier: ^6.0.1 version: 6.0.1 @@ -11259,7 +11271,6 @@ packages: '@types/unist': 3.0.2 hast-util-is-element: 3.0.0 unist-util-find-after: 5.0.0 - dev: true /hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} @@ -16068,7 +16079,6 @@ packages: dependencies: '@types/unist': 3.0.2 unist-util-is: 6.0.0 - dev: true /unist-util-is@3.0.0: resolution: {integrity: sha512-sVZZX3+kspVNmLWBPAB6r+7D9ZgAFPNWm66f7YNb420RlQSbn+n8rG8dGZSkrER7ZIXGQYNm5pqC3v3HopH24A==}