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

[MDX] Add Prism and Shiki support #4002

Merged
merged 21 commits into from
Jul 21, 2022
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
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
12 changes: 12 additions & 0 deletions examples/with-mdx/src/pages/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,15 @@ Written by: {new Intl.ListFormat('en').format(authors.map(d => d.name))}.
Published on: {new Intl.DateTimeFormat('en', {dateStyle: 'long'}).format(published)}.

<Counter client:idle>This is a **counter**!</Counter>

## Syntax highlighting

We also support syntax highlighting in MDX out-of-the-box! This example uses our default [Shiki theme](https://github.com/shikijs/shiki). See full configuration options in [our markdown documentation](https://docs.astro.build/en/guides/markdown-content/#syntax-highlighting).
bholmesdev marked this conversation as resolved.
Show resolved Hide resolved

```astro
---
const weSupportAstro = true
---

<h1>Love seeing this theme out of the box!</h1>
```
12 changes: 12 additions & 0 deletions packages/astro/src/runtime/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -542,6 +542,8 @@ export function createAstro(
const toAttributeString = (value: any, shouldEscape = true) =>
shouldEscape ? String(value).replace(/&/g, '&#38;').replace(/"/g, '&#34;') : value;

const kebab = (k: string) => k.toLowerCase() === k ? k : k.replace(/[A-Z]/g, match => `-${match.toLowerCase()}`);
const toStyleString = (obj: Record<string, any>) => Object.entries(obj).map(([k, v]) => `${kebab(k)}:${v}`).join(';')
const STATIC_DIRECTIVES = new Set(['set:html', 'set:text']);

// A helper used to turn expressions into attribute key/value
Expand Down Expand Up @@ -575,6 +577,16 @@ Make sure to use the static attribute syntax (\`${key}={value}\`) instead of the
return markHTMLString(` ${key.slice(0, -5)}="${listValue}"`);
}

// support object styles for better JSX compat
if (key === 'style' && typeof value === 'object') {
return markHTMLString(` ${key}="${toStyleString(value)}"`);
}

// support `className` for better JSX compat
if (key === 'className') {
return markHTMLString(` class="${toAttributeString(value, shouldEscape)}"`);
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO: remove changes when blocking draft PR is ready!

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bholmesdev should be good to remove this now!

// Boolean values only need the key
if (value === true && (key.startsWith('data-') || htmlBooleanAttributes.test(key))) {
return markHTMLString(` ${key}`);
Expand Down
36 changes: 36 additions & 0 deletions packages/integrations/mdx/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,42 @@ const posts = await Astro.glob('./*.mdx');
))}
```

### Syntax highlighting
Copy link
Contributor Author

@bholmesdev bholmesdev Jul 21, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tagging @sarah11918 for proofing!


The MDX integration respects [your project's `markdown.syntaxHighlight` configuration](https://docs.astro.build/en/guides/markdown-content/#syntax-highlighting).

We will highlight your code blocks with [Shiki](https://github.com/shikijs/shiki) by default [using Shiki twoslash](https://shikijs.github.io/twoslash/). You can customize [this remark plugin](https://www.npmjs.com/package/remark-shiki-twoslash) using the `markdown.shikiConfig` option in your `astro.config`. For example, you can apply a different built-in theme like so:

```js
// astro.config.mjs
export default {
markdown: {
shikiConfig: {
theme: 'dracula',
},
},
integrations: [mdx()],
}
```

Visit [our "Shiki configuration" docs](https://docs.astro.build/en/guides/markdown-content/#shiki-configuration) for more on using Shiki with Astro.
bholmesdev marked this conversation as resolved.
Show resolved Hide resolved

#### Switch to Prism

You can also use the [Prism](https://prismjs.com/) syntax highlighter by setting `markdown.syntaxHighlight` to `'prism'` in your `astro.config` like so:

```js
// astro.config.mjs
export default {
markdown: {
syntaxHighlight: 'prism',
},
integrations: [mdx()],
}
```

This applies a minimal Prism renderer with added support for `astro` code blocks. Visit [our "Prism configuration" docs](https://docs.astro.build/en/guides/markdown-content/#prism-configuration) for more on using Prism with Astro.

## Configuration

<details>
Expand Down
13 changes: 10 additions & 3 deletions packages/integrations/mdx/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,19 @@
"test": "mocha --exit --timeout 20000"
},
"dependencies": {
"@astrojs/prism": "^0.6.1",
"@mdx-js/mdx": "^2.1.2",
"@mdx-js/rollup": "^2.1.1",
"es-module-lexer": "^0.10.5",
"remark-frontmatter": "^4.0.1",
"prismjs": "^1.28.0",
"rehype-raw": "^6.1.1",
"remark-gfm": "^3.0.1",
"remark-mdx-frontmatter": "^2.0.2",
"remark-smartypants": "^2.0.0"
"remark-shiki-twoslash": "^3.1.0",
"remark-smartypants": "^2.0.0",
"shiki": "^0.10.1",
"unist-util-visit": "^4.1.0",
"remark-frontmatter": "^4.0.1",
"remark-mdx-frontmatter": "^2.0.2"
},
"devDependencies": {
"@types/chai": "^4.3.1",
Expand Down
59 changes: 41 additions & 18 deletions packages/integrations/mdx/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import mdxPlugin, { Options as MdxRollupPluginOptions } from '@mdx-js/rollup';
import type { RemarkMdxFrontmatterOptions } from 'remark-mdx-frontmatter';
import type { AstroIntegration } from 'astro';
import remarkShikiTwoslash from 'remark-shiki-twoslash';
import { nodeTypes } from '@mdx-js/mdx';
import rehypeRaw from 'rehype-raw';
import mdxPlugin, { Options as MdxRollupPluginOptions } from '@mdx-js/rollup';
import { parse as parseESM } from 'es-module-lexer';
import remarkFrontmatter from 'remark-frontmatter';
import remarkGfm from 'remark-gfm';
import type { RemarkMdxFrontmatterOptions } from 'remark-mdx-frontmatter';
import remarkMdxFrontmatter from 'remark-mdx-frontmatter';
import remarkSmartypants from 'remark-smartypants';
import remarkPrism from './remark-prism.js';
import { getFileInfo } from './utils.js';

type WithExtends<T> = T | { extends: T };
Expand All @@ -23,7 +27,10 @@ type MdxOptions = {

const DEFAULT_REMARK_PLUGINS = [remarkGfm, remarkSmartypants];

function handleExtends<T>(config: WithExtends<T[] | undefined>, defaults: T[] = []): T[] {
function handleExtends<T>(
config: WithExtends<T[] | undefined>,
defaults: T[] = [],
): T[] {
if (Array.isArray(config)) return config;

return [...defaults, ...(config?.extends ?? [])];
Expand All @@ -35,27 +42,43 @@ export default function mdx(mdxOptions: MdxOptions = {}): AstroIntegration {
hooks: {
'astro:config:setup': ({ updateConfig, config, addPageExtension, command }: any) => {
addPageExtension('.mdx');
let remarkPlugins = handleExtends(mdxOptions.remarkPlugins, DEFAULT_REMARK_PLUGINS);
let rehypePlugins = handleExtends(mdxOptions.rehypePlugins);

if (config.markdown.syntaxHighlight === 'shiki') {
remarkPlugins.push([
// Default export still requires ".default" chaining for some reason
// Workarounds tried:
// - "import * as remarkShikiTwoslash"
// - "import { default as remarkShikiTwoslash }"
(remarkShikiTwoslash as any).default,
config.markdown.shikiConfig,
]);
rehypePlugins.push([rehypeRaw, { passThrough: nodeTypes }]);
}

if (config.markdown.syntaxHighlight === 'prism') {
remarkPlugins.push(remarkPrism);
rehypePlugins.push([rehypeRaw, { passThrough: nodeTypes }]);
}

remarkPlugins.push(remarkFrontmatter);
remarkPlugins.push([
remarkMdxFrontmatter,
{
name: 'frontmatter',
...mdxOptions.frontmatterOptions,
},
]);

updateConfig({
vite: {
plugins: [
{
enforce: 'pre',
...mdxPlugin({
remarkPlugins: [
...handleExtends(mdxOptions.remarkPlugins, DEFAULT_REMARK_PLUGINS),
// Frontmatter plugins should always be applied!
// We can revisit this if a strong use case to *remove*
// YAML frontmatter via config is reported.
remarkFrontmatter,
[
remarkMdxFrontmatter,
{
name: 'frontmatter',
...mdxOptions.frontmatterOptions,
},
],
],
rehypePlugins: handleExtends(mdxOptions.rehypePlugins),
remarkPlugins,
rehypePlugins,
jsx: true,
jsxImportSource: 'astro',
// Note: disable `.md` support
Expand Down
59 changes: 59 additions & 0 deletions packages/integrations/mdx/src/remark-prism.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// TODO: discuss extracting this file to @astrojs/prism
import { addAstro } from '@astrojs/prism/internal';
import Prism from 'prismjs';
import loadLanguages from 'prismjs/components/index.js';
import { visit } from 'unist-util-visit';

const languageMap = new Map([['ts', 'typescript']]);

function runHighlighter(lang: string, code: string) {
let classLanguage = `language-${lang}`;

if (lang == null) {
lang = 'plaintext';
}

const ensureLoaded = (language: string) => {
if (language && !Prism.languages[language]) {
loadLanguages([language]);
}
};

if (languageMap.has(lang)) {
ensureLoaded(languageMap.get(lang)!);
} else if (lang === 'astro') {
ensureLoaded('typescript');
addAstro(Prism);
} else {
ensureLoaded('markup-templating'); // Prism expects this to exist for a number of other langs
ensureLoaded(lang);
}

if (lang && !Prism.languages[lang]) {
// eslint-disable-next-line no-console
console.warn(`Unable to load the language: ${lang}`);
}

const grammar = Prism.languages[lang];
let html = code;
if (grammar) {
html = Prism.highlight(code, grammar, lang);
}

return { classLanguage, html };
}

/** */
export default function remarkPrism() {
return (tree: any) => visit(tree, 'code', (node: any) => {
let { lang, value } = node;
node.type = 'html';

let { html, classLanguage } = runHighlighter(lang, value);
let classes = [classLanguage];
node.value = `<pre class="${classes.join(
' '
)}"><code class="${classLanguage}">${html}</code></pre>`;
return node;
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Syntax highlighting

```astro
---
const handlesAstroSyntax = true
---

<h1>{handlesAstroSyntax}</h1>
```
67 changes: 67 additions & 0 deletions packages/integrations/mdx/test/mdx-syntax-highlighting.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import mdx from '@astrojs/mdx';

import { expect } from 'chai';
import { parseHTML } from 'linkedom';
import { loadFixture } from '../../../astro/test/test-utils.js';

const FIXTURE_ROOT = new URL('./fixtures/mdx-syntax-hightlighting/', import.meta.url);

describe('MDX syntax highlighting', () => {
describe('shiki', () => {
it('works', async () => {
const fixture = await loadFixture({
root: FIXTURE_ROOT,
markdown: {
syntaxHighlight: 'shiki',
},
integrations: [mdx()],
});
await fixture.build();

const html = await fixture.readFile('/index.html');
const { document } = parseHTML(html);

const shikiCodeBlock = document.querySelector('pre.shiki');
expect(shikiCodeBlock).to.not.be.null;
});

it('respects markdown.shikiConfig.theme', async () => {
const fixture = await loadFixture({
root: FIXTURE_ROOT,
markdown: {
syntaxHighlight: 'shiki',
shikiConfig: {
theme: 'dracula',
},
},
integrations: [mdx()],
});
await fixture.build();

const html = await fixture.readFile('/index.html');
const { document } = parseHTML(html);

const shikiCodeBlock = document.querySelector('pre.shiki.dracula');
expect(shikiCodeBlock).to.not.be.null;
});
});

describe('prism', () => {
it('works', async () => {
const fixture = await loadFixture({
root: FIXTURE_ROOT,
markdown: {
syntaxHighlight: 'prism',
},
integrations: [mdx()],
});
await fixture.build();

const html = await fixture.readFile('/index.html');
const { document } = parseHTML(html);

const prismCodeBlock = document.querySelector('pre.language-astro');
expect(prismCodeBlock).to.not.be.null;
});
});
});
Loading