Tags
A dark code window with a violet titlebar on white, its lines of code drawn as grey and green bars with one band highlighted, and a paintbrush with rainbow bristles sweeping across the lower half

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.

On this page

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.

The parts of a rendered code blockTwo rendered code blocks. The upper one is an editor frame: a filename tab sits at the top left carrying a file-type icon and the name pyproject.toml, a copy button sits at the top right of the body, and the body below holds syntax-highlighted TOML. The lower one is a terminal frame: three window dots and no title, holding a shell command. The two shapes are what tells a reader whether they are looking at a file or at a session.pyproject.tomlFilename tabHighlighted bodyFile-type iconCopy buttonTerminal frameshell blocks, no title
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 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.

The same snippet, before and after the fence was rewrittenTwo renderings of one snippet. On the left, the fence written as three backticks followed by ts colon src slash lib slash blog dot ts: the block has no filename tab, its text is a single flat grey, its data-language attribute reads plaintext, and it emitted one build warning. On the right, the same fence written with title equals in quotes: the block carries a filename tab with a file-type icon, the code is coloured, data-language reads ts, and no warning is emitted.What shipped```ts:src/lib/blog.tsdata-languageplaintextno syntax highlightingfilename never renderedone warning per block, per buildWhat ships now```ts title="src/lib/blog.ts"src/lib/blog.tsdata-languagetshighlighted as TypeScriptfilename on the tab, with an iconno warnings
The left column is what had been shipping. The filename in the fence never appeared anywhere in the HTML.

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:

Terminal window
grep -rhoE '^```[A-Za-z0-9_+#-]+:' src/content/blog | sort | uniq -c | sort -rn
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 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.

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.

How a fence reaches Expressive Code, and where it does notA fence in an .mdx file passes through @astrojs/mdx to a fork: which markdown processor is configured. On the unified branch, Astro merges markdown.rehypePlugins and the integration runs. On the Sätteri branch, which is the Astro 7 default, those plugins are ignored entirely, so an integration that only registers that way installs cleanly and renders nothing at all. Expressive Code survives because it also pushes into options.hastPlugins, the path Sätteri does read, and the fence ends up as a figure containing a figcaption and a pre.Fence in an .mdx file@astrojs/mdxWhich markdown processor?unifiedmerges markdown.rehypePluginsIntegration runspushes into options.hastPluginsSätterithe Astro 7 defaultignores them entirelyIntegration never runsinstalls clean, renders nothingfigure › figcaption › pre › code
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:

Terminal window
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.

Three rules that lost to the same insulation layerThree rows comparing what the site asked for against what the plugin already had. For tab width, a rule on .expressive-code pre at specificity zero-one-one tied the plugin reset and lost on load order. For the copy button, identical selectors tied again. For the filename tab, the site set editorTabBarBorderBottomColor while the outline was drawn by editorTabBarBorderColor, so the outline stayed and the code block lost its top edge instead. In each case the gap was at most one notch of specificity, and a tie is resolved by which stylesheet loads last rather than by any decision.What the site asked forWhat the plugin already hadOutcomeTab width.expressive-code prevs.expressive-code *:not(:is(svg, svg *))lostCopy button.expressive-code .copy buttonvs.expressive-code .copy buttonlostFilename tabeditorTabBarBorderBottomColorvseditorTabBarBorderColorlostA tie is decided by load order, which is not a design decision anyone made.
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:

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():

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:

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, 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:

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:

.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:

Terminal window
pnpm build 2>&1 | grep -i shiki

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

Terminal window
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:

Terminal window
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:

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

Share this article