diff --git a/.changeset/satteri-prism.md b/.changeset/satteri-prism.md new file mode 100644 index 000000000000..0a28c7a601b6 --- /dev/null +++ b/.changeset/satteri-prism.md @@ -0,0 +1,18 @@ +--- +'@astrojs/markdown-satteri': minor +'@astrojs/mdx': patch +--- + +Adds support for Prism syntax highlighting to the Sätteri Markdown and MDX processors. Setting `markdown.syntaxHighlight` to `'prism'` now highlights your code blocks with Prism. + +```js +// astro.config.mjs +import { satteri } from '@astrojs/markdown-satteri'; + +export default defineConfig({ + markdown: { + processor: satteri(), + syntaxHighlight: 'prism', + }, +}); +``` diff --git a/packages/integrations/mdx/src/satteri/index.ts b/packages/integrations/mdx/src/satteri/index.ts index 636b47f54be8..861d6003c6e6 100644 --- a/packages/integrations/mdx/src/satteri/index.ts +++ b/packages/integrations/mdx/src/satteri/index.ts @@ -1,11 +1,11 @@ import { pathToFileURL } from 'node:url'; import type { MarkdownHeading } from '@astrojs/internal-helpers/markdown'; -import { createShikiHighlighter } from '@astrojs/internal-helpers/shiki'; import type { SatteriResolvedOptions } from '@astrojs/markdown-satteri'; import { satteriCollectImagesPlugin, + satteriCreateHighlightFn, satteriHeadingIdsPlugin, - satteriShikiPlugin, + satteriHighlightPlugin, } from '@astrojs/markdown-satteri'; import { createDefaultAstroMetadata } from 'astro/markdown'; import { @@ -48,38 +48,12 @@ export function createMdxProcessor( let highlightFn: HighlightFn | undefined; let initPromise: Promise | undefined; - function initShiki() { - const syntaxHighlight = mdxOptions.syntaxHighlight; - const syntaxHighlightType = - typeof syntaxHighlight === 'string' - ? syntaxHighlight - : syntaxHighlight - ? syntaxHighlight.type - : undefined; - - if (syntaxHighlightType === 'prism') { - throw new Error( - 'Prism syntax highlighting is not supported by the `satteri()` markdown processor. Use shiki instead, or switch to `markdown.processor: unified({...})`.', - ); - } - - if (syntaxHighlightType === 'shiki') { - const shikiConfig = mdxOptions.shikiConfig ?? {}; - initPromise = createShikiHighlighter({ - langs: shikiConfig.langs, - theme: shikiConfig.theme, - themes: shikiConfig.themes, - langAlias: shikiConfig.langAlias, - }).then((hl) => { - highlightFn = (code, lang, meta) => - hl.codeToHtml(code, lang, { - meta, - wrap: shikiConfig.wrap, - defaultColor: shikiConfig.defaultColor, - transformers: shikiConfig.transformers, - }); - }); - } + function initHighlighter() { + initPromise = satteriCreateHighlightFn(mdxOptions.syntaxHighlight, mdxOptions.shikiConfig).then( + (fn) => { + highlightFn = fn; + }, + ); } return { @@ -89,7 +63,7 @@ export function createMdxProcessor( frontmatter: Record, ): Promise { if (!highlightFn && !initPromise) { - initShiki(); + initHighlighter(); } if (initPromise) await initPromise; @@ -124,7 +98,7 @@ export function createMdxProcessor( if (highlightFn) { // `mdx: true` wraps the highlighted HTML in a JSX `` node // rather than a raw HTML node, since the Sätteri pipeline is compiling to JSX. - hastPlugins.push(satteriShikiPlugin(highlightFn, excludeLangs, { mdx: true })); + hastPlugins.push(satteriHighlightPlugin(highlightFn, excludeLangs, { mdx: true })); } if (satteriOptions.hastPlugins.length) { hastPlugins.push(...satteriOptions.hastPlugins); diff --git a/packages/markdown/satteri/package.json b/packages/markdown/satteri/package.json index 43ed79dabba4..b4eb79137a35 100644 --- a/packages/markdown/satteri/package.json +++ b/packages/markdown/satteri/package.json @@ -27,6 +27,7 @@ }, "dependencies": { "@astrojs/internal-helpers": "workspace:*", + "@astrojs/prism": "workspace:*", "github-slugger": "^2.0.0", "satteri": "^0.8.0" }, diff --git a/packages/markdown/satteri/src/index.ts b/packages/markdown/satteri/src/index.ts index f95d2a44bcd2..529187cefd89 100644 --- a/packages/markdown/satteri/src/index.ts +++ b/packages/markdown/satteri/src/index.ts @@ -2,7 +2,10 @@ export { createCollectImagesPlugin as satteriCollectImagesPlugin, createHeadingIdsPlugin as satteriHeadingIdsPlugin, createImageMarkerPlugin as satteriImageMarkerPlugin, - createShikiPlugin as satteriShikiPlugin, + createHighlightPlugin as satteriHighlightPlugin, + /** @deprecated Renamed to `satteriHighlightPlugin` (it drives both Shiki and Prism). */ + createHighlightPlugin as satteriShikiPlugin, + createHighlightFn as satteriCreateHighlightFn, collectHastText as satteriCollectHastText, makeFragmentNode as satteriMakeFragmentNode, createSatteriMarkdownProcessor, diff --git a/packages/markdown/satteri/src/satteri-processor.ts b/packages/markdown/satteri/src/satteri-processor.ts index 895924df914f..041bc42ac0e1 100644 --- a/packages/markdown/satteri/src/satteri-processor.ts +++ b/packages/markdown/satteri/src/satteri-processor.ts @@ -177,14 +177,14 @@ export function createImageMarkerPlugin( }; } -export function createShikiPlugin( +export function createHighlightPlugin( highlight: HighlightFn, excludeLangs: string[] | undefined, options?: { mdx?: boolean }, ): HastPluginDefinition { const wrapResult = options?.mdx ? makeFragmentNode : makeRawNode; return { - name: 'shiki-highlight', + name: 'highlight', element: { filter: ['pre'], async visit(node, ctx) { @@ -217,6 +217,49 @@ export interface SatteriMarkdownProcessorOptions extends AstroMarkdownOptions { features?: Features; } +/** + * Build the highlighter for the Sätteri pipeline, or `undefined` when syntax + * highlighting is disabled. Shiki and Prism both resolve to a `HighlightFn` + * that turns a code block into a complete `
` HTML string.
+ */
+export async function createHighlightFn(
+	syntaxHighlight: AstroMarkdownOptions['syntaxHighlight'],
+	shikiConfig: AstroMarkdownOptions['shikiConfig'] | undefined,
+): Promise {
+	const syntaxHighlightType =
+		typeof syntaxHighlight === 'string'
+			? syntaxHighlight
+			: syntaxHighlight
+				? syntaxHighlight.type
+				: undefined;
+
+	if (syntaxHighlightType === 'shiki') {
+		const hl = await createShikiHighlighter({
+			langs: shikiConfig?.langs,
+			theme: shikiConfig?.theme,
+			themes: shikiConfig?.themes,
+			langAlias: shikiConfig?.langAlias,
+		});
+		return (code, lang, meta) =>
+			hl.codeToHtml(code, lang, {
+				meta,
+				wrap: shikiConfig?.wrap,
+				defaultColor: shikiConfig?.defaultColor,
+				transformers: shikiConfig?.transformers,
+			});
+	}
+
+	if (syntaxHighlightType === 'prism') {
+		const { runHighlighterWithAstro } = await import('@astrojs/prism/dist/highlighter');
+		return async (code, lang) => {
+			const { html, classLanguage } = await runHighlighterWithAstro(lang, code);
+			return `
${html}
`; + }; + } + + return undefined; +} + export async function createSatteriMarkdownProcessor( opts?: SatteriMarkdownProcessorOptions, ): Promise { @@ -232,35 +275,7 @@ export async function createSatteriMarkdownProcessor( features: userFeatures, } = opts ?? {}; - const syntaxHighlightType = - typeof syntaxHighlight === 'string' - ? syntaxHighlight - : syntaxHighlight - ? syntaxHighlight.type - : undefined; - - if (syntaxHighlightType === 'prism') { - throw new Error( - 'Prism syntax highlighting is not supported by the `satteri()` markdown processor. Use shiki instead, or switch to `markdown.processor: unified({...})`.', - ); - } - - let highlightFn: HighlightFn | undefined; - if (syntaxHighlightType === 'shiki') { - const hl = await createShikiHighlighter({ - langs: shikiConfig.langs, - theme: shikiConfig.theme, - themes: shikiConfig.themes, - langAlias: shikiConfig.langAlias, - }); - highlightFn = (code, lang, meta) => - hl.codeToHtml(code, lang, { - meta, - wrap: shikiConfig.wrap, - defaultColor: shikiConfig.defaultColor, - transformers: shikiConfig.transformers, - }); - } + const highlightFn = await createHighlightFn(syntaxHighlight, shikiConfig); const syntaxHighlightExcludeLangs = typeof syntaxHighlight === 'object' ? syntaxHighlight.excludeLangs : undefined; @@ -279,7 +294,7 @@ export async function createSatteriMarkdownProcessor( const hastPlugins: HastPluginDefinition[] = []; if (highlightFn) { - hastPlugins.push(createShikiPlugin(highlightFn, syntaxHighlightExcludeLangs)); + hastPlugins.push(createHighlightPlugin(highlightFn, syntaxHighlightExcludeLangs)); } hastPlugins.push(...userHastPlugins); hastPlugins.push(createImageMarkerPlugin(localImagePaths, remoteImagePaths)); diff --git a/packages/markdown/satteri/test/highlight.test.ts b/packages/markdown/satteri/test/highlight.test.ts index 9ff091fbc3c2..251ba88c9c43 100644 --- a/packages/markdown/satteri/test/highlight.test.ts +++ b/packages/markdown/satteri/test/highlight.test.ts @@ -23,10 +23,20 @@ describe('satteri highlight', () => { assert.ok(!code.includes('background-color:')); }); - it('rejects prism highlighting', async () => { - await assert.rejects( - createSatteriMarkdownProcessor({ syntaxHighlight: { type: 'prism' } }), - /Prism syntax highlighting is not supported/, - ); + it('highlights using prism', async () => { + const processor = await createSatteriMarkdownProcessor({ + syntaxHighlight: { type: 'prism' }, + }); + const { code } = await processor.render('```js\nconsole.log("Hello, world!");\n```'); + assert.match(code, /
/);
+		assert.match(code, /class="token /);
+	});
+
+	it('supports prism excludeLangs', async () => {
+		const processor = await createSatteriMarkdownProcessor({
+			syntaxHighlight: { type: 'prism', excludeLangs: ['js'] },
+		});
+		const { code } = await processor.render('```js\nconsole.log("Hello, world!");\n```');
+		assert.ok(!code.includes('class="token '));
 	});
 });
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 51e1549cbdd9..a3c47d243f5a 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -6751,6 +6751,9 @@ importers:
       '@astrojs/internal-helpers':
         specifier: workspace:*
         version: link:../../internal-helpers
+      '@astrojs/prism':
+        specifier: workspace:*
+        version: link:../../astro-prism
       github-slugger:
         specifier: ^2.0.0
         version: 2.0.0