Skip to content

Astro Shiki: data-language, Not a language Class

Published: 2026-07-13

While porting my blog’s markdown rendering to Astro’s built-in Shiki highlighting, the code blocks looked perfect — right colors, both light and dark themes. But the small language label on each block’s copy button had gone blank. Every block reported Text, whether it held TypeScript or SQL. Nothing errored. The highlighting was correct; only the label was wrong.

The cause is a small difference in how Astro’s Shiki labels its output — one that a byte-for-byte port of the old code runs into immediately. I caught it during the migration, so it never reached the live site, but only because I happened to look at a code block.

Note — Neither Astro nor Shiki ships a copy button, so the one on my code blocks is mine to maintain. The ready-made alternative is Expressive Code, an Astro integration that replaces the code-fence renderer and brings a copy button and language label with it. If you use that, none of what follows will ever reach you.

Where the label used to come from

My copy-button enhancement reads a block’s language off the rendered <pre>. Under the old pipeline I called shiki directly with a custom transformer that appended a language-<lang> class to the <pre>, and extractLanguageIdentifier scanned for exactly that class.

function extractLanguageIdentifier(pre: HTMLPreElement): string {
  const cls = [...pre.classList].find((c) => c.startsWith('language-'));
  // ...falls back to the <code> child, then to 'text'
  return cls?.replace('language-', '') ?? 'text';
}

That only ever worked because the custom transformer put language-typescript somewhere the scan could find it. Nothing else guaranteed the class was there.

What Astro’s Shiki does instead

Astro’s built-in Shiki integration — configured through @astrojs/markdown-remark’s shikiConfig — doesn’t append a class. It replaces the node. For each <pre><code class="language-X"> the markdown step produces, the highlighter swaps in Shiki’s own codeToHast() output, discarding the original language-X class entirely. The transformer Astro registers then rewrites the class name and moves the language onto a data attribute.

node.properties.class = classValue.replace(/shiki/g, 'astro-code');
node.properties.dataLanguage = lang;

The built HTML makes the result concrete.

<pre class="astro-code astro-code-themes github-light github-dark" data-language="typescript"></pre>

Notice there is no language-* class anywhere — not on the <pre>, not on the <code>. The language now lives in data-language. My old scan found nothing and returned 'text' for every block. It fails silently because the highlighting comes from the same Shiki output, so the code still looks right; only the label, which reads a different place, is wrong.

The fix: read data-language first

data-language shows up on the element’s dataset, so the fix is to read that first and keep the class-scan only as a fallback.

function extractLanguageIdentifier(pre: HTMLPreElement): string {
  if (pre.dataset.language) return pre.dataset.language;
  const cls = [...pre.classList].find((c) => c.startsWith('language-'));
  return cls?.replace('language-', '') ?? 'text';
}

Reading pre.dataset.language covers Astro’s output directly. I kept the class-scan as a fallback because it costs nothing, and because a different pipeline might well emit the class instead of the attribute — the old one did, once I had taught it to.

The CSS made the same assumption

The label wasn’t the only thing keyed to the old class name. My stylesheet had a few rules scoped to pre.shiki — a transparent background, some dark-theme span colors — and those matched nothing under Astro’s astro-code class either. The fix has the same shape: extend each selector to match both, additively, so the old output still styles correctly if I ever compare against it.

pre.shiki,
pre.astro-code {
  background: transparent;
}

Nothing here meant rewriting the highlighting. It meant noticing that two separate pieces of code — one TypeScript, one CSS — had both been written against the same assumption: that Shiki marks its output with a language-* class.

If you’re porting copy-button or language-label logic onto Astro’s built-in Shiki, check data-language before any language-* class. The highlighting looks correct either way, which is exactly what makes the wrong label easy to miss.

Further Reading

  • Astro syntax highlighting — how Astro configures Shiki through shikiConfig, including themes and the rendered output shape.
  • Shiki — the highlighter itself, and the codeToHast() API whose output Astro inlines into the page.
  • Porting KaTeX to Astro 7’s Sätteri engine — Astro 7’s newer default engine highlights code through a different plugin entirely, with its own surprises around math blocks.