Skip to content

Better Kotlin syntax highlighting on Zensical with Shiki

Hossain Khan
5 min read

Recently, I used Zensical for my new Compose Highlight library and noticed Kotlin syntax highlighting was not fully accurate. I did a quick search to see whether I could change the highlighting pipeline on a Zensical site, but I could not find much documentation. Then I asked in the Discord community, and someone shared a helpful clue in this thread: Shiki highlighting discussion. That pointed me to a Shiki-based JavaScript approach that worked really well.

My Zensical based docs site already had build-time highlighting with Pygments which for most Kotlin code blocks was good enough, but I wanted to see if I could improve it without breaking the existing setup.

In this post, I will share how I added Shiki support for Kotlin code blocks without breaking Zensical’s existing code block UI.

Important

Zensical is still in an early stage. At the time of writing this post, the current release is 0.0.43. There may be a native way to handle this in a future release, so treat this approach as a practical workaround for now.

The highlighting overhaul

Based on the hint proivided on the Discord site, I took a hybrid approach:

  1. Keep Pygments as the default highlighter for all languages at build time.
  2. Re-highlight only Kotlin blocks on the client side with Shiki.
  3. Preserve the existing <pre> and wrapper structure so copy buttons and layout keep working.

That gave me better Kotlin tokenization while keeping the stability of the current Zensical pipeline.

💡 Tip: If your docs site already has working build-time highlighting, this incremental approach is much safer than swapping the whole highlighting system in one go.

Technical details

Here are key changes done to achive the new highlighting on top of Pygments:

The script scans code blocks, detects Kotlin blocks, asks Shiki to render highlighted HTML, and then swaps only the inner <code> content.

That “swap only inner HTML” decision mattered a lot because Zensical’s existing wrappers handle extra UI behavior (like copy actions). Replacing the whole block would have been risky.

Here is a simplified version of the core flow:

import { createHighlighter } from "shiki";

const highlighter = await createHighlighter({
  langs: ["kotlin"],
  themes: ["one-light", "one-dark-pro"],
});

function highlightKotlinBlocks() {
  const blocks = document.querySelectorAll("pre code");

  for (const block of blocks) {
    if (!block.classList.contains("language-kotlin")) continue;

    const source = block.textContent ?? "";
    const html = highlighter.codeToHtml(source, {
      lang: "kotlin",
      themes: {
        light: "one-light",
        dark: "one-dark-pro",
      },
      defaultColor: false,
    });

    const temp = document.createElement("div");
    temp.innerHTML = html;

    const shikiPre = temp.querySelector("pre");
    const shikiCode = temp.querySelector("code");
    const existingPre = block.closest("pre");

    if (!shikiPre || !shikiCode || !existingPre) continue;

    block.innerHTML = shikiCode.innerHTML;

    const shikiStyle = shikiPre.getAttribute("style") || "";
    const cssVars = shikiStyle.match(/--shiki[^;]+;?/g) || [];
    existingPre.style.cssText += cssVars.join("");
  }
}

I also wired this into Zensical’s SPA navigation lifecycle, so highlighting still runs after route transitions.

The related issue and PR are here:

Theme and CSS notes

For theming, I used Shiki’s CSS variables with defaultColor: false. That embeds both light and dark token values as variables, so theme switching is instant.

I also added a small CSS fix for blank-line rendering because line wrappers can collapse visually if you do not style them explicitly.

.shiki code {
  display: grid;
}

.shiki code > span {
  min-height: 1.4em;
}

Nothing fancy here, but this tiny part saved me from weird line spacing and “missing” blank lines.

Few issues I ran into

Sharing some issues I encountered during this process in case it help in your journey to do the same using AI Agents

If you hit similar issues, start by preserving existing DOM wrappers and replacing only the minimum possible HTML.

💡 Tip: Two things gave me the cleanest result - keep Zensical in charge of the code block container styles, and let Shiki handle token colors only.

Final result

After this update, Kotlin snippets are much more readable on my docs site, especially for newer syntax and mixed DSL-style code.

Here is a demo of the new highlighting in action:

Zensical syntax highlight using shiki

Overall, I am very happy with the outcome even if there is split second of “flash” when the page loads and Shiki re-highlights. Let me know if you do try this approach or have any questions about the implementation! Cheers ✌️

Next
Syntax Highlighting on Android - Highlight.js as a Native Compose Engine