# Expressive Code in an Astro blog — filename tabs, file icons, and 97 unreadable fences

> Adopting Expressive Code in Astro 7: filename tabs, per-theme file icons, a Sätteri processor trap, and 97 fences Shiki was reading as a language name.

- Source: https://oharu121.com/blog/astro-expressive-code-snippet-ui-filename-tabs-file-icons/
- Published: 2026-08-12T22:12:24+09:00
- Tags: Astro, TypeScript, Expressive Code

---
## Introduction

Every build of this site printed about a hundred lines like
`[Shiki] The language "ts:src/lib/blog.ts" doesn't exist, falling back to "plaintext"`.
I had been treating them as noise from someone else's tool. They were not. **The fence
form that produced them was written down in my own style guide**, which meant every
article I wrote reproduced the bug faithfully, and had been doing so since the site
existed.

The damage was not the warnings. Ninety-seven code blocks were rendering as unformatted
plaintext, and the filename each one was labelled with never reached the page at all. On a
blog that is mostly code, both halves of what a fence is for were being dropped silently.

The fix was Expressive Code plus a one-line change to every fence, and it took four wrong
turns to get there: a CSS rule that never applied, a build that failed on a missing URI
prefix, a diagnosis the agent got wrong twice in a row, and a bug it "fixed" that had never
existed.

This article walks through what snippet UI in an Astro blog actually has to do, which
parts a plugin gives you, and which parts fight you.

## What a code block has to carry

Before choosing anything, it is worth being concrete about the parts, because the rest of
this article uses their names.

*Figure — BlockAnatomy: A file gets a tab and a name. A shell session gets window dots and no name. The shape is the signal.*

Four requirements, in the order they turned out to matter:

1. **Syntax highlighting**, which Astro gives you for free through Shiki.
2. **A filename**, when the file identity is the point of the snippet.
3. **Both themes.** This site is light and dark through `light-dark()`, and a code block
   that ignores that is a black rectangle on a white page.
4. **A copy button**, because the snippets are meant to be run.

Astro ships the first. The other three are where the work is.

## The fence form was a Zenn convention

The site was writing fences as ` ```ts:src/lib/blog.ts `. That is the
[Zenn](https://zenn.dev) and Qiita form, and it is genuinely nice to type. It is also
parsed by nothing in an Astro pipeline. Astro hands the whole token to Shiki as a
*language name*, Shiki finds no language called `ts:src/lib/blog.ts`, and falls back to
plaintext.

<Figure>
  <WhatTheReaderLost />
  <Fragment slot="caption">The left column is what had been shipping. The filename in the fence never appeared anywhere in the HTML.</Fragment>
</Figure>

The important part is that this is **not a warning about something cosmetic**. Grepping
the built output for a filename that a fence had declared returned zero. The block also
carried `data-language="plaintext"`, so the highlighting was genuinely off rather than
merely wrong.

Ninety-four fences across eighteen files were written this way, in all three locales, in
eleven languages:

```bash
grep -rhoE '^```[A-Za-z0-9_+#-]+:' src/content/blog | sort | uniq -c | sort -rn
```

```text
  48 ```ts:
  12 ```python:
  10 ```css:
   6 ```toml:
   5 ```astro:
```

Three more were written a fourth way, with the filename as a comment on the first line, so
the total came to 97.

**The rule mattered more than the fences.** Fixing 97 blocks without changing
`house-style.md` would have bought nothing, because the next article would have been
written to the old rule.

## Choosing what renders the block

The agent laid out three options. A small remark plugin could have split `lang:path` into
a language and a title, keeping the existing syntax and leaving every article untouched.
Adopting [Expressive Code](https://expressive-code.com) meant rewriting all 97 fences to
`title="…"`, the form that Docusaurus, rehype-pretty-code and Expressive Code all
understand.

I chose Expressive Code. Keeping a house syntax that no tool in the ecosystem parses is
how the problem started, and the plugin brings the filename header, both themes, a copy
button and terminal framing without a line of custom rendering code.

```js title="astro.config.mjs"
integrations: [
	expressiveCode({
		themes: ['github-light', 'github-dark'],
		themeCssSelector: (theme) => `[data-theme='${theme.type}']`,
	}),
	mdx(),
]
```

Two of those lines are worth pausing on. `expressiveCode()` must come **before** `mdx()`;
the integration asserts it. And `themeCssSelector` has to be overridden, because the
default keys off `theme.name` and would emit `[data-theme='github-dark']`, while this
site's toggle writes `light` and `dark`.

### The tab group that was not built

Expressive Code has no tab groups, and the agent argued against adding them. Its reasoning
held up: of the twelve places in this corpus where two code blocks sit adjacent with no
prose between them, **not one is a set of alternatives**. They are a file and the command
that uses it, a command and its output, two config files that have to agree. Tabs are for
"A or B". Hiding half of "A and B" behind a click destroys the comparison the passage is
making.

## The trap that would have made it do nothing

This is the part most worth knowing if you are on Astro 7.

Astro 7 changed its default markdown processor from unified to Sätteri, and
`@astrojs/mdx` only merges `markdown.rehypePlugins` when the processor is unified. An
integration that registers itself the documented way therefore **reaches nothing at all**
on a default Astro 7 install. It does not warn. It installs cleanly and renders nothing.

*Figure — FenceToDom: The right-hand branch is the default. An integration that only knows the left-hand path fails without saying so.*

Before committing to Expressive Code, the agent checked the published bundle directly
rather than trusting the docs:

```bash
grep -c -i satteri node_modules/astro-expressive-code/dist/index.js
```

It has an `isSatteriProcessor` branch that pushes into `options.hastPlugins`, which is the
path Sätteri does read. Had that check come back empty, the migration would have installed
successfully and produced no visible change, which is a failure mode I would much rather
find in ten seconds than in a browser.

## Three rules that lost to the plugin's own CSS

Expressive Code insulates its blocks from host CSS, and that insulation is more aggressive
than it looks. Three separate site rules lost to it, and **every loss was invisible in the
build output**.

*Figure — ThreeCssFights: Two of the three were specificity ties. A tie is settled by which stylesheet loads last.*

The first was the one that mattered. Articles here quote this repo's own source, which is
tab-indented, and an earlier fix had set `tab-size: 2` so those snippets render at one
indent unit instead of eight. The migration plan re-scoped that rule to
`.expressive-code pre`, and it silently stopped applying:

```css title="src/styles/global.css"
/* Loses. Expressive Code resets tab-size on every descendant. */
.expressive-code pre {
	tab-size: 2;
}
```

Nothing failed. `pnpm check` and `pnpm build` were both green. The only way this surfaced
was measuring computed style in a real browser, where `tabSize` came back as `8`. Injecting
an identical rule at runtime also failed, while an inline style worked, which is what
identified it as a specificity problem rather than a load-order one.

The rule that wins repeats the plugin's own `:not()`:

```css title="src/styles/global.css"
.expressive-code pre:not(:is(svg, svg *)) {
	tab-size: 2;
}
```

That selector is ugly and it is deliberate. Expressive Code exposes no tab-size option, so
the only way to keep the tabs was to outrank a rule that was written specifically to stop
host CSS from reaching in.

**The generalisable part: a green build is not evidence that your CSS applied.** Every one
of these three fights passed every check the project has.

## Baking a colour into an image forfeits the theme

I asked for full-colour file-type icons, the way an editor's file tree shows them. The
agent built them as SVG data URIs with the brand colour inside the SVG bytes, keyed off the
language:

```css title="src/styles/code-file-icons.css"
.frame.has-title:has(pre[data-language='ts']) .title::before {
	background-image: url("data:image/svg+xml,…");
}
```

`:has()` is doing real work here. The language lives on the `<pre>`, which is a *sibling
after* the caption, so the selector has to reach forward from the tab to the block below
it. The marks come from [`simple-icons`](https://simpleicons.org), which is CC0, so nothing
carries an attribution obligation.

The catch is structural. A `background-image` cannot be retinted by CSS, so each icon
ships exactly one colour while the tab it sits on flips between near-white and near-black.
Brand colours are chosen against one background. JavaScript yellow assumes a dark editor;
CSS purple assumes a light page.

| mark | on the light tab | on the dark tab |
| --- | --- | --- |
| `js` `#F7DF1E` | **1.29:1** | 13.12:1 |
| `css` `#663399` | 8.03:1 | **2.11:1** |
| `toml` `#9C4121` | 6.29:1 | **2.70:1** |

Three of eleven failed the 3:1 floor for non-text contrast, and one of them was
effectively invisible.

The agent's fix was to correct them. It measured every mark against both tab backgrounds
at build time, blended a failing one toward black or white until it cleared the threshold,
and emitted a second dark-theme rule where the two corrections differed.

I rejected it on sight. A JavaScript badge darkened until it passes on a white tab is a
muddy olive, and **an icon whose entire job is instant recognition had stopped looking like
the thing it names**. The ratio was satisfied and the feature was gone.

So the marks ship at their real brand colours in both themes, and the generator only
reports what it would have changed:

```text
src/styles/code-file-icons.css: 11 icons, 14949 bytes
  3 mark(s) below 3:1, kept at brand colour:
    js #F7DF1E — 1.29:1 on the light tab
    css #663399 — 2.11:1 on the dark tab
    toml #9C4121 — 2.70:1 on the dark tab
```

That is a real accessibility cost and it is written down rather than argued away. **A
measurement that is not going to change the outcome still belongs in the output**, because
the alternative is a decision nobody can find later, and a maintainer six months from now
"fixing" it back.

One mark is still overridden: JSON's brand colour is pure `#000000`, which scores 1.06:1
on the dark tab. That is not weak, it is absent, and the recognition argument has nothing
left to protect.

## What the review caught

I asked for a code review before the pull request opened. It found two things the agent had
verified and still got wrong.

The first was the invisible `js` icon above. The second was worse: the agent had "fixed" a
bug that did not exist.

The agent had reported that Expressive Code hides its copy button on hover with no
`(hover: none)` fallback, leaving it unreachable on touch. That reading came from a script
that flattened the stylesheet and dropped the enclosing at-rules. What it ships is:

```css
.expressive-code .copy button              { opacity: 0.75; width: 2.5rem }
@media (hover: hover) {
	.expressive-code .copy button          { opacity: 0; width: 2rem }
}
```

The hide is *inside* the media query. Touch devices were always fine, and the "fix" had
been overriding the base rule to `0.5`, making the button **dimmer on touch than it was
before**. Scoping it to `(hover: hover)` was the correction that was needed.

Two of the four wrong turns in this migration came from the same habit: reading CSS with a
script instead of measuring it in a browser. The tab-size rule lost because specificity was
computed on paper. The copy button was "fixed" because a regex saw a rule without its media
query.

## Verification that actually proves something

The original symptom is the cheapest check, and it has to print nothing:

```bash
pnpm build 2>&1 | grep -i shiki
```

Then the two that prove the fence rewrite landed rather than merely ran:

```bash
grep -rcE '^```[A-Za-z0-9_+#-]+:[^ ]+$' src/content   # 0 colon fences left
grep -rl 'astro-code' dist/                           # empty: Shiki markup gone
```

And the one that catches a silent content loss. Expressive Code has a heuristic that reads
a filename out of a comment in the first four lines and **deletes the line it matched**.
Forty-two fences in this corpus open with a comment, and three of them are literally
`# pyproject.toml`. The option is off, and this is what proves it stayed off:

```bash
grep -c 'the Vertex AI API has to be enabled' dist/blog/aimock-*/index.html
```

Generated files get a drift guard rather than a convention. `pnpm icons:check` regenerates
the icon CSS in memory, compares it, and fails if the committed file is stale. It runs as
part of `pnpm check`, next to the existing thumbnail check.

## The copy button was English in two of three languages

Months after this migration shipped, building a second copy control for share links
surfaced something this one had been doing since it shipped. `@expressive-code/plugin-frames`
carries translations for **English and German only**, and nothing here had set
`getBlockLocale`, so the plugin had no idea which language a page was in. Every Japanese
and Traditional Chinese article had been labelling its copy button `Copy to clipboard` and
answering `Copied!` since the day this landed.

The fix is a locale derived from the article's filename, plus the texts registered from the
site's own UI dictionary:

```js title="astro.config.mjs"
for (const locale of LOCALES) {
	pluginFramesTexts.addLocale(locale, {
		terminalWindowFallbackTitle: UI.terminalWindow[locale],
		copyButtonTooltip: UI.copyCode[locale],
		copyButtonCopied: UI.copied[locale],
	});
}

getBlockLocale: ({ file }) => file.path.match(LOCALE_FILENAME)?.[1] ?? DEFAULT_LOCALE,
```

Two details are easy to get wrong. `addLocale` replaces a locale's texts wholesale rather
than merging them, so all three keys have to be supplied; omit
`terminalWindowFallbackTitle` and that one string stays English on every terminal frame.
And the registration goes under `zh-tw` rather than `zh`, because the lookup walks
`['zh', 'zh-tw']` and returns the first hit, so a `zh` entry would shadow the Traditional
Chinese one.

This is the same failure mode as the rest of this article, which is why it belongs in it.
`pnpm check` was green, `astro check` reported no hints, and the build succeeded. Nothing
in the pipeline knows what language a rendered button is written in.

## Summary

If you are building snippet UI in an Astro blog, the practices that came out of this:

- **Use `title="…"` for filenames.** It is what the ecosystem parses. A house syntax that
  only your editor understands will be read as a language name and fail quietly.
- **Write the fence rule into your style guide, and fix the guide first.** A convention
  that generates its own bug will regenerate it after every cleanup.
- **On Astro 7, confirm your integration handles Sätteri** before building on it. The
  documented `rehypePlugins` path reaches nothing on a default install, and nothing warns.
- **Measure CSS in a browser, not in a script.** Two of the four failures here were
  reading errors: one computed specificity on paper, the other dropped a media query.
- **A baked-in colour cannot follow a theme.** Measure every mark against both backgrounds
  at build time, then choose deliberately: correct the colour, switch to `mask-image` and
  give up the colour, or keep the brand mark and record what it costs. Not knowing is the
  only option that is wrong.
- **Guard generated files with a check, not a comment.** `icons:check` costs ten lines and
  removes a whole category of "someone forgot to re-run it".

The part I keep coming back to is that every one of these passed a green build. The tab
width, the invisible icon, the dimmed copy button, and the hundred blocks that started it
all were, as far as the tooling was concerned, fine.

## References

- [Expressive Code](https://expressive-code.com) and its [frames documentation](https://expressive-code.com/key-features/frames/), which covers `title=`, the filename-comment heuristic, and the terminal frame
- [Astro syntax highlighting](https://docs.astro.build/en/guides/syntax-highlighting/), including `shikiConfig` and the dual-theme setup
- [simple-icons](https://simpleicons.org), CC0, the source of the file-type marks
- [WCAG 2.2 non-text contrast](https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html), the 3:1 floor the icons are measured against
