DEV Community

Cover image for SvelteKit Shiki Syntax Highlighting: Markdown Codeblocks
Rodney Lab
Rodney Lab

Posted on • Originally published at rodneylab.com

SvelteKit Shiki Syntax Highlighting: Markdown Codeblocks

🎨 Shiki and Prism Syntax Highlighting

Here we see how you can set up SvelteKit Shiki syntax highlighting in your Svelte app. Shiki and Prism are both fantastic code syntax highlighters. Although Prism is currently more popular, recent projects like Astro are using Shiki as the default. Shiki has many themes built into the package and also supports TextMate themes, used by VSCode. While Prism probably offers easier customisation, we will see here that adding line numbers and even applying individual line highlights are not too difficult with Shiki. An additional advantage of using Shiki is that it supports Svelte code highlighting out-of-the-box. We shall focus on Shiki in this post, but let me know if there is something you would like to see in Prism and I will see what I can do.

🧱 What we’re Building

We will build a single page of a SvelteKit app to show how you can use Shiki, SvelteKit and mdsvex. mdsvex uses PrismJS by default. However it also supports bringing your own highlighter. We will use Shiki as the custom highlighter and add line numbers. As well as line numbers, we will add additional highlighting to certain specified lines. Typically you would want to do this to make a line which you later refer to stand out. Finally we improve the accessibility of the output code blocks with a further tweak. If that sounds like what you were looking for, then let’s get cracking!

⚙️ SvelteKit Shiki Syntax Highlighting: Setup

We’ll start with a new SvelteKit project. Spin it up now form the Terminal:

pnpm init svelte@next sveltekit-shiki-code-highlighting
cd sveltekit-shiki-code-highlighting
pnpm install
pnpm run dev
Enter fullscreen mode Exit fullscreen mode

Choose a Skeleton project from the CLI init tool. We will use Type-checked JavaScript in the demo code, but feel free to choose TypeScript or None if you prefer. For the other options, choose what you are happiest with!

We will use a couple of packages in this project, let’s add them now:

pnpm add -D @fontsource/ibm-plex-mono mdsvex node-html-parser shiki
Enter fullscreen mode Exit fullscreen mode

That’s the initial prep. We have a touch more setup to do for mdsvex, and once that’s done, the plan of action is to move straight on to creating an initial code highlight function. After that we’ll create some Markdown code to test on and some styling. Finally we level up, making the highlight function more robust and accessible. Next up is the mdsvex config.

🔧 mdsvex Configuration

First create an mdsvex config file, mdsvex.config.mjs in the project root directory:

import { join, resolve } from 'node:path';
import highlighter from './src/lib/utilities/codeHighlighter.mjs';

const __dirname = resolve();

const config = {
  extensions: ['.svelte.md'],
  highlight: {
    highlighter,
  },
  // layout: join(__dirname, './src/lib/components/MarkdownLayout.svelte'),
};

export default config;
Enter fullscreen mode Exit fullscreen mode

We will create the highlighter function imported in line 2 in the next section. In lines 811 we see how we let mdsvex know about our custom highlighter. We will see it is a function which takes three arguments: code, language and meta. language is the language of the code block while meta is the extra parameters we can specify in a fenced Markdown code block. As an example we will later add a fenced code block which looks like this:

    ```

</span>svelte {5-7,10-11}
      <script context="module" lang="ts">
      <!-- TRUNCATED... -->


    ```</span>
Enter fullscreen mode Exit fullscreen mode

Please excuse formatting - this arises from using markdown in markdown - check original post for clearer formatting.

So here, the language is Svelte and the code is everything between the first and last line. {5-7,10-11} is our on custom syntax, the meta which we will parse in the highlighter function. You can add more bells and whistles if you build on the code for this project, here though we will just use this as a way to pass in the lines we want to style differently so as to add additional highlighting.

svelte.config.js

Finally, finish the config step by updating svelte.config.js in the project root directory to include our mdsvex config:

import adapter from '@sveltejs/adapter-auto';
import { mdsvex } from 'mdsvex';
import mdsvexConfig from './mdsvex.config.mjs';

/** @type {import('@sveltejs/kit').Config} */
const config = {
  extensions: ['.svelte', '.svelte.md'],
  preprocess: [mdsvex(mdsvexConfig)],
  kit: {
    adapter: adapter(),
  },
};

export default config;
Enter fullscreen mode Exit fullscreen mode

👋🏽 highlighter: Initial Version

We will keep the code below but build on it as we go along. For now create a src/lib/utilities directory and add a codeHighlighter.js file there with the following content:

import { getHighlighter } from 'shiki';

const THEME = 'github-dark';

/**
 * @param code {string} - code to highlight
 * @param lang {string} - code language
 * @param meta {string} - code meta
 * @returns {string} - highlighted html
 */
async function highlighter(code, lang, meta) {
  const shikiHighlighter = await getHighlighter({
    theme: THEME,
  });
  const html = shikiHighlighter.codeToHtml(code, {
    lang,
  });
  return html;
}

export default highlighter;
Enter fullscreen mode Exit fullscreen mode

We are using the github-dark theme here. If you use a different theme, be sure to run an accessibility checker on your output to make sure the contrast ratios are good. You can see a list of all inbuilt Shiki themes on the Shiki project GitHub page.

With that is place why don’t we create some code to highlight?

🖥 Code

mdsvex lets us write source files in Markdown. We just need to register the file extensions we plan to use for Markdown. We did this in line 7 of mdsvex.config.mjs as well as line 7 of svelte.config.js. Here is some Rust and JavaScript code we can test on. Rename src/lib/routes/index.svelte to index.svelte.md and replace the content:

    ## Rust

    ```

rust
    println!("Made it here!");


    ```

    ## JavaScript

    ```

javascript
    console.log('Made it here!');


    ```
Enter fullscreen mode Exit fullscreen mode

Please excuse formatting - this arises from using markdown in markdown - check original post for clearer formatting.

When you paste in these code blocks, make sure the lines with backticks which start and end the fenced code blocks are not indented. If you open up the page in a browser you should now see some highlighted code. We will add more styling later. If this does not work for you, try stopping your dev server, running rm -r .svelte-kit and then restarting the dev server.

SvelteKit Shiki Syntax Highlighting: Initial Highlight: Screenshoot shows Rust and JavaScript code blocks, highlighted.  Beyond code, page is not styled.

Next try adding some Svelte code to the bottom of the same file (this is taken from the Svelte demo code):

    ## Svelte

    ```

svelte {5-7,10-11}
    <script context="module" lang="ts">
      export const prerender = true;
    </script>

    <script lang="ts">
      import Counter from '$lib/Counter.svelte';
    </script>

    <svelte:head>
      <title>Home</title>
      <meta name="description" content="Svelte demo app" />
    </svelte:head>

    <section>
      <h1>
        <div class="welcome">
          <picture>
            <source srcset="svelte-welcome.webp" type="image/webp" />
            <img src="svelte-welcome.png" alt="Welcome" />
          </picture>
        </div>

        to your new<br />SvelteKit app
      </h1>

      <h2>
        try editing <strong>src/routes/index.svelte</strong>
      </h2>

      <Counter />
    </section>

    <style>
      section {
        display: flex;
        flex-direction: column;
        justify-content: center;
        align-items: center;
        flex: 1;
      }

      h1 {
        width: 100%;
      }

      .welcome {
        position: relative;
        width: 100%;
        height: 0;
        padding: 0 0 calc(100% * 495 / 2048) 0;
      }

      .welcome img {
        position: absolute;
        width: 100%;
        height: 100%;
        top: 0;
        display: block;
      }
    </style>


    ```
Enter fullscreen mode Exit fullscreen mode

Please excuse formatting - this arises from using markdown in markdown - check original post for clearer formatting.

You should get an error because vite is parsing the code in the Svelte codeblock we just added. We can prevent this by escaping the HTML generated by the highlighter function. We will address this next.

SvelteKit Shiki Syntax Highlighting: Error: Screenshoot shows Unexpected token error.

🏃🏽 highlighter Escaping Generated HTML Code

We can create an escapeHtml function which replaces some characters with their HTML entity equivalents. Specifically we will replace “{”, “}” and “&grave;”. Add this function to src/lib/utilities/codeHighlighting.js:

/**
 * Returns code with curly braces and backticks replaced by HTML entity equivalents
 * @param {string} html - highlighted HTML
 * @returns {string} - escaped HTML
 */
function escapeHtml(code) {
  return code.replace(
    /[{}`]/g,
    (character) => ({ '{': '&l\u0062race;', '}': '&r\u0062race;', '`': '&\u0067rave;' }[character]),
  );
}
Enter fullscreen mode Exit fullscreen mode

Then use the function in line 25:

async function highlighter(code, lang, meta) {
  const shikiHighlighter = await getHighlighter({
    theme: THEME,
  });
  const html = shikiHighlighter.codeToHtml(code, {
    lang,
  });
  return escapeHtml(html);
}
Enter fullscreen mode Exit fullscreen mode

You might need to restart the dev server for this to take effect. The browser will now show the Svelte code without errors. We are not yet done with the highlighter but will look at some styling before returning to it.

💄 SvelteKit Shiki Syntax Highlighting: Styling

First create a global CSS file. Make a new directory src/lib/styles/ and in it create styles.css with the following content:

html {
  font-family: IBM Plex Mono;
}

h1 {
  font-size: var(--font-size-5);
  font-weight: var(--font-weight-bold);
  margin-bottom: var(--spacing-12);
}

h2 {
  font-size: var(--font-size-4);
  font-weight: var(--font-weight-semibold);
}

body {
  margin: 0;
  font-weight: var(--font-weight-normal);
  background-color: var(--colour-brand);
}

code {
  counter-reset: step;
  counter-increment: step 0;
}

.shiki {
  border-radius: var(--spacing-1);
  padding: var(--spacing-6) var(--spacing-4);
  margin: var(--spacing-4) auto var(--spacing-18);
  box-shadow: var(--shadow-elevation-medium);
  overflow-x: auto;
  max-width: var(--max-width-full);
}

.highlight-line {
  display: inline-block;
  background-color: var(--colour-code-background-highlighted);
  width: calc(var(--max-width-full) + var(--spacing-4));
  border-left: var(--spacing-1) solid var(--colour-code-line-highlight);
  margin-left: calc(-1 * var(--spacing-1));
}

:root {
  --colour-brand: hsl(193 67% 34%); /* elm */
  --colour-light: hsl(7 53% 97%); /* fantasy */

  --colour-code-line-numbers: hsl(219 14% 71% / 0.8);
  --colour-code-line-highlight: hsl(34 96% 55%);
  --colour-code-background-hue: 220;
  --colour-code-background-saturation: 13%;
  --colour-code-background-lightness: 18%;
  --colour-code-background-highlighted: hsl(
    var(--colour-code-background-hue) var(--colour-code-background-saturation)
      calc(var(--colour-code-background-lightness) + 5%)
  );

  --spacing-px: 1px;
  --spacing-px-2: 2px;
  --spacing-0: 0;
  --spacing-1: 0.25rem;
  --spacing-2: 0.5rem;
  --spacing-4: 1rem;
  --spacing-6: 1.5rem;
  --spacing-12: 3rem;
  --spacing-18: 4.5rem;
  --max-width-wrapper: 48rem;
  --max-width-full: 100%;

  --font-size-root: 16px;
  --font-size-3: 1.563rem;
  --font-size-4: 1.953rem;
  --font-size-5: 2.441rem;
  --font-size-6: 3.052rem;

  --font-weight-normal: 400;
  --font-weight-semibold: 600;
  --font-weight-bold: 700;

  --line-height-normal: 1.5;

  /* CREDIT: https://www.joshwcomeau.com/shadow-palette/ */
  --shadow-color: 194deg 84% 18%;
  --shadow-elevation-medium: -1px 1px 1.6px hsl(var(--shadow-color) / 0.36),
    -3.3px 3.3px 5.3px -0.8px hsl(var(--shadow-color) / 0.36),
    -8.2px 8.2px 13px -1.7px hsl(var(--shadow-color) / 0.36),
    -20px 20px 31.8px -2.5px hsl(var(--shadow-color) / 0.36);
}

code .line::before {
  display: inline-block;
  content: counter(step);
  counter-increment: step;
  width: var(--spacing-6);
  margin-right: var(--spacing-6);
  text-align: right;
  font-variant-numeric: tabular-nums;
  color: var(--colour-line-numbers);
}
Enter fullscreen mode Exit fullscreen mode

Then we create a layout file which mdsvex will use for all pages it process. Make a src/lib/components directory and add a MarkdownLayout.svelte file:

<script>
  import '$lib/styles/styles.css';
  import '@fontsource/ibm-plex-mono';
</script>

<svelte:head>
  <title>SvelteKit Shiki Syntax Highlighting: Markdown Codeblocks</title>
  <meta
    name="description"
    content="SvelteKit Shiki syntax highlighting: use any VSCode colour theme to accessibly syntax highlight code on your SvelteKit app with line numbers."
  />
</svelte:head>
<main class="container">
  <h1 class="heading">SvelteKit Shiki Code Highlighting</h1>
  <slot />
</main>

<style>
  .container {
    background-color: var(--colour-brand);
    color: var(--colour-light);
    width: min(100% - var(--spacing-12), var(--max-width-wrapper));
    margin: var(--spacing-0) auto;
    padding: var(--spacing-12) var(--spacing-0);
    font-size: var(--font-size-1);
    line-height: var(--line-height-normal);
  }
  .heading {
    text-align: center;
  }
</style>
Enter fullscreen mode Exit fullscreen mode

The final missing piece here is to use the new layout, uncomment line 11 in mdsvex.config.mjs:

  },
  layout: join(__dirname, './src/lib/components/MarkdownLayout.svelte'),
};

export default config;
Enter fullscreen mode Exit fullscreen mode

You might need to restart you dev server once more for changes to take effect. The code blocks should look a lot nicer now. You will also notice we now have line numbers. These come from the CSS in lines 2225 & 9099 in the src/lib/styles/styles.css file.

🤗 Accessibility Improvement

Exploring the HTML from the first block, we can see it looks something like this:

<pre class="shiki" style="background-color: #0d1117">
  <code>
    <span class="line"> <span style="color: #D2A8FF">println</span><span style="color: #C9D1D9">(</span><span style="color: #A5D6FF">"Made it here!"</span><span style="color: #C9D1D9">);</span></span>
  </code>
</pre>
Enter fullscreen mode Exit fullscreen mode

For long lines the <pre> block will be horizontally scrollable. For accessibility it will need to be focusable. This is possible by adding a tabindex attribute to the element. At the time of writing it is not possible to do this within Shiki, though there is a pull request to modify the API. Instead we will just update the output HTML using node-html-parser. Add this new function to src/lib/utilitties/codeHighlighter.mjs:

/**
 * @param html {string} - code to highlight
 * @returns {string} - highlighted html
 */
function makeFocussable(html) {
  const root = parse(html);
  root.querySelector('pre').setAttribute('tabIndex', '0');
  return root.toString();
}
Enter fullscreen mode Exit fullscreen mode

Then use the function in line 42:

async function highlighter(code, lang, meta) {
  const shikiHighlighter = await getHighlighter({
    theme: THEME,
  });
  let html = shikiHighlighter.codeToHtml(code, {
    lang,
  });
  html = makeFocussable(html);
  return escapeHtml(html);
}
Enter fullscreen mode Exit fullscreen mode

⭐ Using SvelteKit with Shiki: Extra Line Highlighting

We might want to add additional highlighting to particular lines. One way to do this with Shiki is to add a new class to certain lines. This is possible using the lineOptions field in the Shiki codeToHtml function call options. We just need to pass in an array of objects representing lines we want to add the class to. This array’s elements include the class we want to add to the line. Although the class can be different for each line, that’s a little more than we need here. Here is the final version of the highlighter function:

/**
 * @param code {string} - code to highlight
 * @param lang {string} - code language
 * @param meta {string} - code meta
 * @returns {string} - highlighted html
 */
async function highlighter(code, lang, meta) {
  const shikiHighlighter = await getHighlighter({
    theme: THEME,
  });

  let html;
  if (!meta) {
    html = shikiHighlighter.codeToHtml(code, {
      lang,
    });
  } else {
    const highlightMeta = /{([\d,-]+)}/.exec(meta)[1];
    const highlightLines = rangeParser(highlightMeta);

    html = shikiHighlighter.codeToHtml(code, {
      lang,
      lineOptions: highlightLines.map((element) => ({
        line: element, // line number
        classes: ['highlight-line'],
      })),
    });
  }
  html = makeFocussable(html);
  return escapeHtml(html);
}

export default highlighter;
Enter fullscreen mode Exit fullscreen mode

So here we check if there is range meta passed into the highlighter. When we do have meta, we parse that into an array of individual line numbers which need highlighting in the rangeParser function which we will add in a moment. For each line we need to highlight, we add the highlight-line class. We added styling for this earlier in src/styles/styles.css (lines 3642), though we did not mention it at the time.

Moving on add this code for range parsing to the same codeHighligher.mjs file:

import { parse } from 'node-html-parser';
import { getHighlighter } from 'shiki';
Enter fullscreen mode Exit fullscreen mode

Then:

/**
 * Returns array of line numbers to be highlghted
 * @param {string} rangeString - range string to be parsed (e.g. {1,3-5,8})
 * @returns {number[]}
 */
function rangeParser(rangeString) {
  const result = [];
  const ranges = rangeString.split(',');
  ranges.forEach((element) => {
    if (element.indexOf('-') === -1) {
      result.push(parseInt(element, 10));
    } else {
      const limits = element.split('-');
      const start = parseInt(limits[0], 10);
      const end = parseInt(limits[1], 10);
      for (let i = start; i <= end; i += 1) {
        result.push(i);
      }
    }
  });
  return result;
}
Enter fullscreen mode Exit fullscreen mode

Restart your dev server once more if the changes do not appear in the browser. Try adding highlight meta to the other code blocks or changing the highlight ranges for the Svelte block. Do not include any spaces in the ranges you specify.

SvelteKit Shiki Syntax Highlighting: Extra Highlighting: Screenshoot Svelte code block is highlighted with lines  5-7, 10 & 11 having extra highlighting: they have a lighter background and a coloured tab on the left

💯 SvelteKit Shiki Syntax Highlighting: Test

It should all be working now. We focussed on getting through the demo, rather than explaining every detail. Because of that please do let me know if there is some part which needs some clarification. Also remember to run accessibility contrast checks especially if you have changed the themes.

🙌🏽 SvelteKit Shiki Syntax Highlighting: Wrapup

In this post we looked at:

  • how to add Shiki syntax highlighting in SvelteKit, including line numbers and extra highlighting styles for some lines,
  • generating your own code highlighter with mdsvex,
  • how to manipulate the output Shiki HTML to make the result more accessible.

I do hope there is at least one thing in this article which you can use in your work or a side project. Also let me know if you feel more explanation of the config is needed.

This post is related to a question I got on Twitter on adding line numbers to code blocks, so do reach out if you have questions I might be able to write a post on. I also create short videos focussed on a particular topic.

You can see the full SvelteKit code for this project on the Rodney Lab Git Hub repo. If you run into any issues, you can drop a comment below as well as reach out for a chat on Element. Also Twitter @mention if you have suggestions for improvements or questions.

🙏🏽 Feedback

If you have found this video useful, see links below for further related content on this site. I do hope you learned one new thing from the video. Let me know if there are any ways I can improve on it. I hope you will use the code or starter in your own projects. Be sure to share your work on Twitter, giving me a mention so I can see what you did. Finally be sure to let me know ideas for other short videos you would like to see. Read on to find ways to get in touch, further below. If you have found this post useful, even though you can only afford even a tiny contribution, please consider supporting me through Buy me a Coffee.

Finally, feel free to share the post on your social media accounts for all your followers who will find it useful. As well as leaving a comment below, you can get in touch via @askRodney on Twitter and also askRodney on Telegram. Also, see further ways to get in touch with Rodney Lab. I post regularly on SvelteKit as well as Search Engine Optimisation among other topics. Also subscribe to the newsletter to keep up-to-date with our latest projects.

Top comments (0)