The aria-label i18n bug that was hiding in plain sight
@vdaluz/astro-blog has shipped an en/es i18n layer for a while now. Every visible string in PostCard, Pagination, BlogPostMeta goes through a t(locale) lookup. I was fairly proud of that when I built it. So it was deflating to find two aria-labels that never got the memo.
Where they were hiding
Pagination.astro’s individual arrow buttons - first, prev, next, last - all correctly call strings.goToFirstPage and friends. But the outer <nav> wrapping the whole pagination block had:
<nav class="..." aria-label="Blog pagination">
A bare string literal, sitting one line above code already doing the right thing three separate times over. TagFilterNav.astro had the same shape: a locale prop was never even part of its interface, so its default aria-label was just 'Filter posts', full stop.
On an English page nobody notices. On a Spanish blog index, a screen reader announces “Blog pagination” in English on an otherwise Spanish page. Small, but exactly the kind of small that erodes trust in a translation.
Why partial correctness is better camouflage
The thing that got me was how plausible the bug looked. Grep Pagination.astro for aria-label and you see four correct call sites in a row. The fifth one, the nav wrapper, blends right in unless you’re specifically checking each against the i18n file.
A component that’s 100% hardcoded gets caught the first time someone loads the Spanish site. A component that’s 90% correct passes a skim review and survives until someone reads every line against the source of truth - the same reason a subheading prop lying about its color went unnoticed across six call sites.
The fix
Two new strings in i18n.ts (blogPagination and filterPosts, with their Spanish counterparts). Pagination.astro’s nav now reads aria-label={strings.blogPagination}. TagFilterNav.astro got an actual locale prop defaulting to 'en', with the default aria-label falling back to strings.filterPosts - but only when the consumer doesn’t pass an explicit ariaLabel override, since some sites already translate that label themselves at the app level.
Verifying it without a build step
astro-blog ships raw source with no build step, so I could point directly at the bug: overlay the local fix into a consuming site’s installed copy, run that site’s dev server, load the real /es/blog page in a browser. The nav’s accessibility-tree label read “Paginación del blog.” Not a snapshot test, the actual rendered page.
That also surfaced something useful. The site I tested against already passes its own explicit ariaLabel override to TagFilterNav, so my new default fallback never fires on that page. Which is correct, the override is supposed to win, but it means I only proved the override path still works, not the new default. The default string is covered by a unit test, not a live page. Worth being honest about the difference between those two kinds of verified, rather than letting a passing check stand in for the wrong claim.
What I’d check next time
Before shipping an i18n layer, grep every literal string prop across every component and diff it against the strings file, not just the ones that already look like they’re using t(). A partially-wired component is a worse bug than a fully hardcoded one, because it’s harder to spot.
Related reading
Clearing a backlog: five small fixes in one release
A dead-decoration a11y fix, a schema field that validated but never rendered, and a formatter run that silently flipped quote style across four files.
The linter was pointing at the right line for the wrong reason
An attribute I nearly deleted on an assumption, an overflow that only exists at phone width, and an a11y suite that passed on every version of this - broken and fixed.
The escaping bug that only shows up with a second query param
A rewrite function that worked in every test and every real URL it had ever seen, and would still have broken the moment someone added a second query param.