
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.
Four requirements, in the order they turned out to matter:
- Syntax highlighting, which Astro gives you for free through Shiki.
- A filename, when the file identity is the point of the snippet.
- 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. - 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 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:
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.
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.
Before committing to Expressive Code, the agent checked the published bundle directly rather than trusting the docs:
grep -c -i satteri node_modules/astro-expressive-code/dist/index.jsIt 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.
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:
/* 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():
.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:
.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 tabThat 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:
pnpm build 2>&1 | grep -i shikiThen the two that prove the fence rewrite landed rather than merely ran:
grep -rcE '^```[A-Za-z0-9_+#-]+:[^ ]+$' src/content # 0 colon fences leftgrep -rl 'astro-code' dist/ # empty: Shiki markup goneAnd 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:
grep -c 'the Vertex AI API has to be enabled' dist/blog/aimock-*/index.htmlGenerated 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:
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
rehypePluginspath 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-imageand 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:checkcosts 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 and its frames documentation, which covers
title=, the filename-comment heuristic, and the terminal frame - Astro syntax highlighting, including
shikiConfigand the dual-theme setup - simple-icons, CC0, the source of the file-type marks
- WCAG 2.2 non-text contrast, the 3:1 floor the icons are measured against