Skip to content

Commit

Permalink
fix(markdown): don’t generate mdast html nodes (#10104)
Browse files Browse the repository at this point in the history
* 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]>
  • Loading branch information
4 people authored Mar 8, 2024
1 parent 5a95287 commit a31bbd7
Show file tree
Hide file tree
Showing 10 changed files with 166 additions and 37 deletions.
12 changes: 12 additions & 0 deletions .changeset/thirty-beds-smoke.md
Original file line number Diff line number Diff line change
@@ -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.
47 changes: 21 additions & 26 deletions packages/integrations/mdx/src/plugins.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
}
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions packages/markdown/remark/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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": {
Expand Down
70 changes: 70 additions & 0 deletions packages/markdown/remark/src/highlight.ts
Original file line number Diff line number Diff line change
@@ -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 `<pre>` element.
*/
export function highlightCodeBlocks(tree: Root, highlighter: Highlighter) {
// We’re looking for `<code>` elements
visitParents(tree, { type: 'element', tagName: 'code' }, (node, ancestors) => {
const parent = ancestors.at(-1);

// Whose parent is a `<pre>`.
if (parent?.type !== 'element' || parent.tagName !== 'pre') {
return;
}

// Where the `<code>` is the only child.
if (parent.children.length !== 1) {
return;
}

// And the `<code>` 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 `<pr>` 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 `<pre>` element.
const grandParent = ancestors.at(-2)!;
const index = grandParent.children.indexOf(parent);
grandParent.children[index] = replacement;
});
}
22 changes: 13 additions & 9 deletions packages/markdown/remark/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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);
}
Expand All @@ -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);
Expand Down
12 changes: 12 additions & 0 deletions packages/markdown/remark/src/rehype-prism.ts
Original file line number Diff line number Diff line change
@@ -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 `<pre class="${classLanguage}"><code is:raw class="${classLanguage}">${html}</code></pre>`;
});
};
16 changes: 16 additions & 0 deletions packages/markdown/remark/src/rehype-shiki.ts
Original file line number Diff line number Diff line change
@@ -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<ShikiHighlighter> | undefined;

return async (tree) => {
highlighterAsync ??= createShikiHighlighter(config);
const highlighter = await highlighterAsync;

highlightCodeBlocks(tree, highlighter.highlight);
};
};
3 changes: 3 additions & 0 deletions packages/markdown/remark/src/remark-prism.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RemarkPlugin> {
return function (tree: any) {
visit(tree, 'code', (node) => {
Expand Down
3 changes: 3 additions & 0 deletions packages/markdown/remark/src/remark-shiki.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RemarkPlugin> {
let highlighterAsync: Promise<ShikiHighlighter> | undefined;

Expand Down
14 changes: 12 additions & 2 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit a31bbd7

Please sign in to comment.