Aug 10 2024

How to Replace a plain Shiki integration with Expressive Code in your Astro-Markdoc setup

To Shiki or not to Shiki, that is the question. The latest Shiki update broke my setup and I had the perfect excuse to try out Expressive Code. But it doesn't come with support for Markdoc. It's custom-component time!

Expressive code config.
Last updated: Tuesday, August 20th, 2024

I started using Shiki the day I migrated my blog from N(ext.js|uxt.js) to Astro. I fell in love with it and couldn't go back. But recently, I discovered an even better option.

Expressive Code claims to be a fully-featured,framework-agnostic, plugin-based and accessible toolkit for enhancing code blocks in technical articles. You can probably see it in action these days on many technical blogs, by way of Starlight .

But, after a quick look in the Expressive Code repository, I knew I had to use my Saturday coding because Markdoc support is not added yet.

The "metas" of it all

Markdoc only recognizes content and language attributes on the so-called fence nodes. So even overwriting the fence node and adding a ton of attributes on it might not work as expected if using the wrong syntax in the meta string.

What is the meta string? It's the bit after this:

1
```{language}

So, something like {3} ins={1-2} after the code fence and language (as shown in the docs) will not highlight line 3 and mark lines 1-2 as inserts in Markdoc because they are ignored.

I also discovered that the Expressive Code component distinguishes between meta configuration for the default set of features (meta), and the optional plugins (collapse, showLineNumbers, startLineNumber, etc).

To replicate my solution in your project, start with at least a basic Astro + Markdoc app. See how you can organize your Markdoc configs in the Astro project .

Let's go!

Install and configure Expressive Code

I tried following the documentation to install the library, but for some reason the npx astro add astro-expressive-code failed and I had to go the manual configuration route.

For the full setup, we will be using the Collapsible Sections and the Line Numbers plugings, so the installation and configuration procedure below covers them as well.

To configure Expressive Code and plugins manually:

  1. In your terminal, change directory to your Astro app.

  2. Install the package and plugins using you package manager of choice:

    npm
    npm install astro-expressive-code @expressive-code/plugin-collapsible-sections @expressive-code/plugin-line-numbers
    pnpm
    pnpm install astro-expressive-code @expressive-code/plugin-collapsible-sections @expressive-code/plugin-line-numbers
    yarn
    yarn add astro-expressive-code @expressive-code/plugin-collapsible-sections @expressive-code/plugin-line-numbers

    Sidenote: I need tabs :|

  3. In the astro.config.m(js|ts) file, import and enable the Expressive Code integration:

    astro.config
    1
    import { defineConfig } from 'astro/config';
    2
    import markdoc from "@astrojs/markdoc";
    3
    import expressiveCode from "astro-expressive-code"
    4
    5
    export default defineConfig({
    6
    // ...other configs
    7
    integrations: [
    8
    react(),
    9
    markdoc(),
    10
    expressiveCode(),
    11
    ]
    12
    // ...other configs
    13
    });
  4. In the root folder of your application create a new file named ec.config.mjs, with the following content:

    ./ec.config.mjs
    1
    2
    import { defineEcConfig } from 'astro-expressive-code'
    3
    import { pluginCollapsibleSections } from '@expressive-code/plugin-collapsible-sections'
    4
    import { pluginLineNumbers } from '@expressive-code/plugin-line-numbers'
    5
    6
    export default defineEcConfig({
    7
    8
    themes: ['nord'],
    9
    10
    defaultProps: {
    11
    wrap: true,
    12
    preserveIndent: true,
    13
    showLineNumbers: true,
    14
    overridesByLang: {
    15
    'bash,sh,zsh': { wrap: false }
    16
    }
    17
    },
    18
    19
    styleOverrides: {
    20
    codeFontSize: "0.9rem",
    21
    },
    22
    23
    shiki: {
    24
    wrap: true,
    25
    },
    26
    27
    plugins: [
    28
    pluginCollapsibleSections(),
    29
    pluginLineNumbers()
    30
    ]
    31
    })

Override the fence node in the Markdoc configuration

For all this to work, however, we need to override the default behavior of the fence Markdoc node.

By default, the fence node supports the attributes language and content. To make it accept the meta string and other custom attributes that Expressive Code can use, we need to add them as shown in the following example:

fence.markdoc.ts
1
2
import { nodes, component } from "@astrojs/markdoc/config";
3
4
export default {
5
6
render: component('@components/astro/CodeBlock.astro'),
7
attributes: {
8
...nodes.fence.attributes,
9
content: { type: String, required: true },
10
language: { type: String },
11
12
meta: {
13
type: String,
14
required: false
15
},
16
collapse: {
17
type: String,
18
required: false,
19
},
20
showLineNumbers: {
21
type: Boolean,
22
required: false,
23
},
24
startLineNumber: {
25
type: Number,
26
required: false,
27
}
28
},
29
}

Import the fence node configuration in the main Markdoc config, and add it to the nodes configuration, if not already added.

Create the CodeBlock component

With all the configuration in place, it's time to define the CodeBlock component that we assigned to the fence node renderer. It's a tiny Astro component:

@components/astro/CodeBlock.astro
1
---
2
3
import {Code} from 'astro-expressive-code/components';
4
5
interface Props {
6
content: string;
7
language?: string;
8
meta?: string;
9
collapse?: string;
10
showLineNumbers?: boolean;
11
startLineNumber?: number;
12
}
13
14
const {content, language, meta, collapse, showLineNumbers, startLineNumber} = Astro.props;
15
16
---
17
18
<Code code={content} lang={language} meta={meta} collapse={collapse} showLineNumbers={showLineNumbers} startLineNumber={startLineNumber}/>

With everything in place, we can now test things out.

Configuration examples

While not perfect, this implementation works well with Markdoc. Configuring the meta strings can be tricky, but by using single and double quotation marks carefully you can get the desired behavior.

Usig the configuration ts {% meta="{'3': 8} del={'1': 5} ins={'2': 6} 'friggin cool!'" collapse="1-3,5-7" showLineNumbers=true startLineNumber=5 %}, you should get:

./src/markdoc/config/nodes/link.markdoc.ts
1 collapsed line
5
import AppLink from '@components/astro/AppLink.astro'
6
3 collapsed lines
7
const removeThis = "remove this";
8
const addThis="add this";
9
10
const allConsts = `highlight this`;
11
12
export default {
13
render: AppLink,
14
children: ['strong', 'em', 's', 'code', 'text', 'tag', 'image', 'heading'],
15
attributes: {
16
href: { type: String, required: true },
17
target: { type: String },
18
rel: { type: String },
19
title: { type: String },
20
},
21
}
22
23
console.log("This is friggin cool!")
24
25
const articleIntro = "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum."

Here's a breakdown for configuring various directives:

  • Give astro code boxes a title - you must use the meta property: {% meta="title='@components/astro/CodeBlock.astro'"%}. For others, use the comment syntax for that language in the first line. For example // my-file.js.
  • Set the frame: {% meta="frame='none'"%}. See the editor & terminal frames documentation for more information.
  • Add text and line markers :
    • Highlight line 5: {% meta="{5}"%}.
    • Highlight the word "something": {% meta=" 'something' "%}. You can use spaces, any alphanumeric character without issues. Apostrophes and quotes would be tricky.
    • Highlight multiple lines: {% meta="{5, 7-9}"%} highlights line 5 and 7 through 9.
    • Mark lines added and removed: {% meta="del={1-2} ins={3-6, 8}"%}.
  • Wrap lines : {% meta=" wrap=true "%} marks lines 1 and 2 as deleted, 3 through 6 and 8 as insertions.
  • Collapse lines: {% collapse="1-3,5-7" %} collapses lines 1-3 and 5-7. See the documentation on collapsible sections of code .
  • Configure line numbers : {% showLineNumbers=true startLineNumber=5 %}.

Points to remember

For future use, and when updating to the component and fence node, unless Expressive Code makes breaking changes:

  • If it is a key/default feature, use the meta string attribute.
  • If it's a plugin, you configure the plugin "metas" individually.
  • When labelling the code with very long strings, you need to add empty lines and adjust the line number as needed.
  • Juggling the single and double quotes for the meta attribute value can get tricky, but the result is totally worth it.

Explore by Tag

On each tag page, you can find a link to that tag's RSS feed so you only get the content you want.