Ryan Schachte's Blog
Fancy code blocks in Astro
December 27th, 2023

Working with Markdown is fantastic because it allows you to write content without worrying about introducing pithy markup and one-off styling rules, which promotes future content mobility. Elegant typography, code blocks, and syntax highlighting have grown ubiquitous in the evolution of personal and professional blogs over time.

Let’s take a look at a boring and less captivating code block.

  const value = 123;
  for (let i = 0; i < value; i++) {
    console.log(i);
  }

While we do get some contrast from the other content by using a monospace font, there is a lot of lacking UX here.

  • line numbers
  • title
  • syntax highlighting
  • copy button
  • file extension/type

After adding just a tiny bit of magic 🪄 we can transform it into something like this:

fancier code block
  const value = 123;
  for (let i = 0; i < value; i++) {
    console.log(i);
  }

Moving forward, we’ll be leveraging Astro and MDX, but the concepts should translate fairly well to other frameworks, especially if they take advantage of MDX, remark or rehype.

Enhancing our Markdown

Using Markdown in Astro is insanely awesome because it’s treated as a first-class citizen and easily importable. From the docs:

To access your Markdown content, pass the <Content/> component through the Astro page’s props. You can then retrieve the component from Astro.props and render it in your page template.

[slug].js
<div>
  <Content />
</div>

The above is an Astro page that uses the [slug] convention to signify that this route is dynamic. This should be all that is necessary for rendering Markup within your component.

Astro configuration

Let’s install a couple of dependencies.

  • @astrojs/mdx allows MDX integration in Astro.
  • rehype-pretty-code adds syntax highlighting based on the semantics of the specified language.
dependencies
npm i rehype-pretty-code
npm i @astrojs/mdx

Rehype is basically a plugin ecosystem that works with Markdown files to change ASTs, or abstract syntax trees, and provide you more organized customization options for your markup.

Let’s expand our Astro config a bit to incorporate these.

astro.config.js
import { defineConfig } from "astro/config";
import rehypePrettyCode from "rehype-pretty-code";
 
export default defineConfig({
  integrations: [mdx()],
  markdown: {
    extendDefaultPlugins: true,
    rehypePlugins: [[rehypePrettyCode, {}]],
  },
});

If all is well, you should see something fairly similar to this on your Markdown page:

We’ll make a few adjustments:

  • Disable Astro’s built-in syntax highlighting
  • Customize the rehypePrettyCode plugin options
  • Add a custom theme

Note: We will implement prettyCodeOptions next.

astro.config.js
import { defineConfig } from "astro/config";
import rehypePrettyCode from "rehype-pretty-code";
 
export default defineConfig({
  integrations: [mdx()],
  markdown: {
    syntaxHighlight: false,
    rehypePlugins: [[rehypePrettyCode, prettyCodeOptions]]
  },
});

In order to customize individual lines, Rehype will run through a processing step by iterating over various options. The main ones we care about are onVisitHighlightedLine and onVisitHighlightedChars.

Different lifecycle hooks for rehype-pretty-code
interface Options {
  grid?: boolean;
  theme?: Theme | Record<string, Theme>;
  keepBackground?: boolean;
  defaultLang?: string | { block?: string; inline?: string };
  tokensMap?: Record<string, string>;
  transformers?: ShikijiTransformer[];
  filterMetaString?(str: string): string;
  getHighlighter?(options: BundledHighlighterOptions): Promise<Highlighter>;
  onVisitLine?(element: LineElement): void;
  onVisitHighlightedLine?(element: LineElement): void;
  onVisitHighlightedChars?(element: CharsElement, id: string | undefined): void;
  onVisitTitle?(element: Element): void;
  onVisitCaption?(element: Element): void;
}

Line highlighting

Let’s implement the prettyCodeOptions block.

astro.config.js
import { defineConfig } from "astro/config";
import mdx from "@astrojs/mdx";
import theme from "./syntax-theme.json";
import rehypePrettyCode from "rehype-pretty-code";
 
const prettyCodeOptions = {
  theme,
  onVisitHighlightedLine(node) {
    node?.properties?.className?.push("highlighted");
  },
  onVisitHighlightedChars(node) {
    console.log(node);
    node?.properties?.className
      ? node.properties.className.push("highlighted-chars")
      : (node.properties.className = ["highlighted-chars"]);
  },
  tokensMap: {},
};

Whether we’re highlighting an entire line or just a subset of a line, these functions will hook into the parsing process and allow us to manipulate what classes and IDs for elements render in the DOM.

I’m not going to dive too deep into the specific CSS you can apply, but with the above, you could have a rule like:

styles/highlighted.scss
div {
  .highlighted {
    background: var(--opaque-accent-color);
    padding: .5rem .75rem;
    color: white;
  }
}

This would yield something like:

The same idea would apply to the highlighted-chars CSS rule. In order to highlight certain lines and words, you can do the following:

```js {1, 2-3, 10} /highlight-this-word/
const largeCodeBlock = undefined;
...```

Check out the rehype-pretty-code docs for more customizations.

Custom themes

The easiest way I’ve learned to create and export custom themes is by using VSCode. Astro uses shiki as the highlighter engine, so we can simply export a JSON theme from VSCode and save the output JSON to a file in our Astro project.

First thing you should do is cmd + shift + p and select Developer: Generate Color Theme From Current Settings. This will export whatever theme you have activated in your IDE as a structured JSON file.

The file should look something like:

{
	"$schema": "vscode://schemas/color-theme",
	"type": "dark",
	"colors": {
		"activityBar.activeBorder": "#ffcc66",
		"activityBar.background": "#1f2430",
		"activityBar.border": "#1f2430",
		"activityBar.foreground": "#707a8ccc",
		"activityBar.inactiveForeground": "#707a8c99",
		"activityBarBadge.background": "#ffcc66",
		"activityBarBadge.foreground": "#805500",
		"badge.background": "#ffcc6633",
		"badge.foreground": "#ffcc66",
		"button.background": "#ffcc66",
  }
      // abbreviated for readability
}

In order to leverage the new theme, we simply import it and apply it as the theme in our prettyCodeOptions configuration block.

astro.config.js
import { defineConfig } from "astro/config";
import mdx from "@astrojs/mdx";
import theme from "./syntax-theme.json";
import rehypePrettyCode from "rehype-pretty-code";
 
const prettyCodeOptions = {
  theme,
  onVisitHighlightedLine(node) {
    node?.properties?.className
      ? node.properties.className.push("highlighted")
      : (node.properties.className = ["highlighted"]);
  },
  onVisitHighlightedChars(node) {
    console.log(node);
    node?.properties?.className
      ? node.properties.className.push("highlighted-chars")
      : (node.properties.className = ["highlighted-chars"]);
  },
  tokensMap: {},
};
 
export default defineConfig({
  integrations: [mdx()],
  markdown: {
    syntaxHighlight: false,
    rehypePlugins: [[rehypePrettyCode, prettyCodeOptions]],
    shikiConfig: {
      theme,
    },
  },
});

Custom components

To continue the mantra of keeping our Markdown pure we will leverage the idea of custom components to override styles for certain elements like <pre/> or <code/> blocks.

Create a new component for something like an <img/> element:

OptimizedImage.astro
---
const { src: { src = "", width = "10px", height = "10px" } = {}, alt = "" } =
  Astro.props;
---
 
<div class="image-container">
  <a href=`${Astro.props.src?.src ?? '#'}`>
    <span class="enlarge">Click to enlarge</span>
    <img src={src} alt={alt} width={width} height={height} />
  </a>
</div>

In order to use this in our [slug].js page, we will just import the Image component directly.

[slug].js
---
import img from './OptimizedImage.astro'
const components = { img }
---
 
<div>
  <Content
    components={{
      ...components,
    }}
  />
</div>

The components object would hold a reference to any overrides we want to perform. Once you destructure the components into <Content>, the rest is handled.

“Click to copy” button

Now that we know how to tap into the build process a bit and manipulate the styling of our content, let’s add physical buttons into the DOM dynamically. If you open up devtools and inspect the DOM tree, you will notice the selectors needed to target where you want to inject the button.

This tells us to target figure > figcaption or figure > figcaption.custom-figcaption in this case as I want my copy button to exist in my <figcaption/> element.

Figcaption.astro
---
// grab a reference to the programming language specified in the {.mdx, .md} files
const { "data-language": lang } = Astro.props;
---
 
<figcaption class="custom-figcaption">
  <slot />
  <span class="custom-figcaption__lang">{lang}</span>
  <button class="custom-figcaption__copy"></button>
</figcaption>

The <slot/> will be replaced with what would’ve been the content and we will just encapsulate it with the <figcaption/> element and throw some classes on there that we can style more granularly.

Client-side event-handlers

There are a few different ways to approach this, but the problem we need to solve is to properly attach a click event listener to each unique <figcaption/> element. The logic should take the code specific to the code block we are targeting and place it on the users clipboard.

Inside the [slug].js page we can target all of our elements dynamically within a <script> tag.

[slug].js
<script>
  const codeContainers = Array.from(
    document.querySelectorAll("figure")
  ) as HTMLElement[];
 
  for (let codeContainer of codeContainers) {
    try {
      const copyButton = codeContainer.querySelector(
        "button"
      ) as HTMLButtonElement;
      const copyButtonHTML = copyButton.innerHTML;
      const codeContents = (codeContainer.querySelector("code") as HTMLElement)
        .innerText;
      copyButton.addEventListener("click", async () => {
        await navigator.clipboard.writeText(codeContents);
        copyButton.innerText = "Copied!";
        setTimeout(() => {
          copyButton.innerHTML = copyButtonHTML;
        }, 1500);
      });
    } catch (e) {
      console.error(e)
    }
  }
</script>

This isn’t the prettiest code, but it gets the job done.

  1. Target every <figure> element in the DOM.
  2. Find the nested button so we can temporarily modify the text displayed to the user.
  3. Store a reference to copyButtonHTML so the contents can reset after the 1500ms timeout.
  4. Push the text contents (code) into the users clipboard.

If you have any issues with timing or values coming out as null, you may want to explore using something like DOMContentLoaded to make sure you fire your script at the right lifecycle point. document.addEventListener("DOMContentLoaded", ... can wrap your function logic.

Care to comment?