Upgrading Astro Code Snippets — NamcheeBack to Posts
Upgrading Astro Code Snippets
16 minutes read
Markdown is commonly used to store textual contents when building a site with Astro. During the build process, Astro will take
all markdowns transform them into its HTML equivalent. Users can then style the markups with CSS however they wish.
However, customization through CSS is pretty limited as customizing the actual markup or behavior is out of the table. To allow
greater degree of customization, users can opt-in to MDX.
MDX allows users to utilize custom components with markup-like syntax similar to how we use those components in Astro files or JSX.
Using custom components in MDX
markdown
---title: 'Hello World!'publishedAt: 2024-12-17---import MyComponent from 'path/to/component.astro';<MyComponent> Hello World!</MyComponent>
However, this is impractical when users want to replace default elements as they
are forced to use JSX-like syntax for every element and lose the simplicity of defining elements in markdown.
Fortunately, MDX also allows users to re-map HTML elements into custom components
by exporting component map in MDX files as components constant.
Replacing default elements with custom elements
markdown
---title: 'Hello World!'publishedAt: 2024-12-17---import Head1 from 'path/to/Head1.astro';export const components = { // Replace <h1> with Head1 h1: Head1,}
Alternatively, users can also provide the mapped components by passing them as a prop to the <Content /> element
Passing component mapping via <Content />
astro
---import { Content } from 'path/to/Content.mdx';import Head1 from 'path/to/Head1.astro';---<Content components={{ h1: Head1,}}>
After seeing the syntax, you might be wondering: how do I manage the props? What attributes are passed to the mapped element?
Can I apply a pre-defined CSS classes to mapped elements?
This post will guide you through customizing default markdown elements using the components syntax. We will using the default code snippets
component as the guinea pig and upgrade them into similar code snippets that you see in this site.
The default snippet. Looks great, but can we make it even better?
I will be using UnoCSS for styling, which is an alternative of TailwindCSS. However, the styling can 100% be implemented with vanilla CSS.
Deconstructing the Code Snippet
Before moving on to the actual customization of the snippet, we need to understand how Astro handles markdown files.
For each markdown file, Astro will parse the content using a processor called remark, which is a part of the
unified toolchain. From the markdown, remark will produce an abstract syntax tree (AST). The AST will
then be transformed into HTML tags using another processor in the same toolchain: rehype.
You might be wondering why we need to transform the markdown into an AST and not directly use something like RegExp.
This is because markdown (and HTML) is not a regular language. Trying to parse non-regular language with regular tends to be error prone.
The functionality of remark and rehype can be augmented using plugins that provides additional features,
such as custom syntax and syntax highlighting.
By default, Astro enables GitHub-flavored markdown and SmartyPants
plugin to extend the basic markdown syntax. For syntax highlighting, Astro uses Shiki by default or
users can opt-in to Prism; both available as Rehype plugins.
The diagram below shows a simplified pipeline of markdown transformation to HTML that occurs during the build process.
When processing code blocks, the process above will output HTML tags similar to the following:
Example HTML output of a code block after Remark + Rehype pipeline.
This HTML tags will then be used by Astro to replace code blocks during the markdown
transformation process into an HTML page for:
All code fences (```) defined in Markdown and MDX files.
Content defined with <Code /> or <Prism /> component.
Replacing The Default Code Snippet
Our first step to customize the code snippet is by replacing the default component with
a custom component that wraps the original one using Astro <slot />.
To do that, we can assign a custom mapping for HTML elements by exporting a new constant
called components in our MDX files.
hello-world.mdx
markdown
---title: 'Hello World!'publishedAt: 2024-12-17---import CodeSnippet from '../../components/CodeSnippet.astro';export const components = { // in MDX, code snippets are defined with <pre> pre: CodeSnippet,}
We can then define CodeSnippet as:
CodeSnippet.astro
astro
<figure> <figcaption> This is a caption </figcaption> <slot /></figure>
Save the file and we will be greeted by this weird element.
Well... that was unexpected
So what’s happening here? Why does wrapping the code block in a <figure> element ruins the structure entirely? Well it turns out, we replaced
the entire <pre> element with <figure> as shown in the following snippet.
HTML output of CodeSnippet.astro
html
<figure> <figcaption> This is a caption </figcaption> <!-- The <pre> is gone! --> <code> <span class="line"> <span style="color:#F97583"><!</span> <span style="color:#E1E4E8">doctype html</span> <span style="color:#F97583">></span> </span> <span class="line"> <span style="color:#E1E4E8"><</span> <span style="color:#85E89D">html</span> <span style="color:#B392F0"> lang</span> <span style="color:#F97583">=</span> <span style="color:#9ECBFF">"en"</span> <span style="color:#E1E4E8">></span> </span> <span class="line"> <span style="color:#E1E4E8"> <</span> <span style="color:#85E89D">head</span> <span style="color:#E1E4E8">></span> </span> <!-- and the rest of the tags --> </code></figure>
Well, that’s not what we wanted at all! We don’t want to remove the <pre> entirely as we need to preserve
the preformatted styling. After wrapping the <code> block inside a <pre> element, the previously
butchered element will look like this.
Look ma! I restored the formatting!
We’ve restored the formatting at this point, but we’re still missing the theming from the original component. Obviously, we can apply the intended
class, style, and other attributes to the <pre> element directly. However, this means that we wasted the theming we’ve done from Astro’s config entirely.
Fortunately, since the custom component is implemented as an Astro component, Astro treats those passed attributes as component props which is
provided by Astro.props globals.
CodeSnippet.astro
astro
---const props = Astro.props;---<figure> <figcaption> This is a caption </figcaption> <pre {...props}><slot /></pre></figure>
which results in
Passing the props surely do the trick
We did it! We’ve successfully rendered a custom code snippet component without breaking the existing components. We can now begin customizing the component.
Supercharging The Code Snippet
Since we have successfully replaced the default code block with our custom element, it’s time to upgrade our components with the following additional features:
Captions
Line Numbers
Line or Code Highlighting
Show Code Language
Copy to Clipboard Button
Before we implement these new features however, there is one important trick that we should know: passing additional props.
Passing Additional Props to Code Snippet
In the previous section, all we did with the props is passing an already defined props. However, some of our additional properties requires
additional information that Shiki doesn’t provide by default (namely captions and copy to clipboard).
The good news is, Shiki already addresses this issue by giving users the ability to add metadata to code blocks that users can provide
in the opening tag after language definition.
The bad news is, Shiki integration in Astro doesn’t pass them to the element in any form, so trying to access Astro.props to get the extra metadata will yield nothing.
Trying to pass 'title' as a metadata to the component (it doesn't work!)
Since Astro doesn’t pass metadata by default, we need to play around with the Shiki Transformer API to create a transformer
that passes the metadata as an attribute. Fortunately, it’s quite simple to do so through shikiConfig that Astro exposes.
Since we might be passing more metadata in the future, it’s advisable to define a schema for writing metadata to avoid
problems during parsing in the future.
For the sake of simplicity, we are going to use key1=value1,key2,[...key] schema in this post that can be parsed with:
After the changes has been applied, our component should look like this:
Look ma! I've added title to my component
Adding Line Numbers
Unfortunately, neither Astro nor Shiki provides us with any APIs regarding line numbers. Therefore, we have to rely to good ol’ CSS
to implement this feature.
Since all lines are actually <span>s that have line as one of its class, we can combine the ::before pseudo-element with CSS Counters:
Applying line numbers to code snippet using CSS counters
css
.astro-code { overflow-x: auto;}.astro-code code { /* Define a counter for each <code> inside .astro-code */ counter-reset: step; /* Start from zero, increment the counter */ counter-increment: step 0; font-size: 14px; width: fit-content; min-width: 100%; display: block;}.astro-code code .line { display: inline-block; width: 100%; padding-right: 2rem;}.astro-code code .line::before { content: counter(step); counter-increment: step; width: 2rem; margin-right: 1.25rem; display: inline-block; margin-left: auto; text-align: right; /* Fix element position during horizontal scroll */ position: sticky; left: 0; z-index: 1; /* Give a bit of space to counter on horizontal scroll */ padding-right: 0.25rem; /* Illustrative purpose, please extract the value from the theme instead */ background-color: white; color: hsla(0, 0%, 0%, 0.5);}
After applying the CSS, our component should look like this:
Look ma, I've added line numbers to my component!
Adding Line Highlighting
To add highlights, we need to tell Shiki which line should be highlighted and Shiki needs to transform those lines to be distinguishable from non-highlighted lines. We can
then write a CSS style to mark those highlighted lines.
Fortunately, Shiki has already provided us with transformerMetaHighlight transformer that handles
this use case that we can install through @shikijs/transformers package and enable in our Astro config:
After applying the highlighted CSS, our component should now look like this:
There, a beautiful highlight!
Showing Language
Since Shiki has already passed the language in the data-language prop, we can easily grab it and display it on the top right of the <pre> as such:
CodeSnippet.astro
astro
---const meta: Record<string, string> = {};const { "data-meta": dataMeta, "data-language": lang, class: className, ...props} = Astro.props;if (dataMeta) { dataMeta.split(",").forEach((prop: string) => { const tokens = prop.split("="); meta[tokens[0].trim()] = tokens[1]; });}const title = meta.title;---<figure class="overflow-hidden border border-gray-300 rounded-md"> { title && ( <figcaption class="flex justify-between items-center text-xs p-2 px-4 border-b border-b-gray-300 leading-normal rounded-t-md bg-gray-100"> <p class="mb-0">{title}</p> </figcaption> ) } <!-- We can't directly set <pre> with relative, as we need to preserve original block size on horizontal scroll --> <div class="relative"> <p class="absolute top-3 right-4 text-xs text-gray-500 font-mono">{lang}</p> <pre class={`${className} rounded-none p-0`} {...props}><slot /></pre> </div></figure>
The language should be shown as a fixed element on the top right of the code block.
Which language was it again? Oh it was JavaScript
Adding Copy to Clipboard Button
After a series of features that are uninteractive, we are finally moving to the final feature that requires a sprinkle of interactivity: copy to clipboard button.
Before we implement this feature, we need to ensure that the original content is accessible by our component. We can implement the same trick that we use
to pass captions: pass them as an attribute called data-code
astro.config.js
js
import { defineConfig } from 'astro/config';import { transformerNotationHighlight } from '@shikijs/transformers';export default defineConfig({ markdown: { shikiConfig: { transformers: [ { pre(hast) { hast.properties['data-meta'] = this.options.meta?.__raw; // the original source code is stored in `source` property hast.properties['data-code'] = this.source; } }, transformerNotationHighlight(), ] } },});
To access user’s clipboard, we need to use JavaScript to access the Clipboard API
which is available on the navigator interface through the browser. The functionality
can then be triggered through a button click:
Now, there should be a ‘Copy Code’ text on the top right of the code block. Try clicking the element
and see if the code inside the code block is copied to your clipboard by pasting it somewhere.
Try clicking the Copy Code text!
Final Thoughts
And there we have it. We have explored the capabilities of Astro API when handling MDX to transform our default code snippet component into a more feature-packed
code snippet without having to directly define them as markups.
Lastly, you can try Astro Expressive Code if you want a feature-packed code snippet without the hassle. However, I believe the
the method shown here still has its merits as outside syntax highlighting, we are in full control of the markup and behavior.