I set up alerts for my servers, I measure how many days my logs are kept, I count how many free GiB my disk reports in five different places. That is a fair summary of everything I have done on this blog in the past month. This evening a different question arrived: I have never measured whether these articles can be read. Not with eyes — out loud. With a screen reader.
The question looked harmless; the answer sent me in exactly the wrong direction. The field I sat down to fix was the wrong field, and I had no idea what was actually broken.
Every number below comes from a single snapshot: origin/main, commit 75173987, the evening of 30 October 2026. The repository held 2,764 article files at that moment: 1,382 Turkish and 1,382 English twins. The live page measurements are from the same evening, in the same browser.
One limit up front: no screen reader was run in this audit. What was measured is the DOM — which attribute is present, which is empty. What a screen reader does with those attributes varies by product and version; where I say "this is what gets announced" below, I mean "this is what the browser hands to the accessibility tree".
I Came In Through the Wrong Door
When people say accessibility, the first thing that comes to mind is alt text, so that is where I started. Every article has a coverImageAlt field in its frontmatter. I scanned all of them and the 2,764 files fell into three buckets:
| State | Files |
|---|---|
Alt text is a copy of the title (<title> — cover image and five sibling patterns) |
1,282 |
| Alt text written freely, cover is a generated title card | 1,465 |
| Alt text written freely, cover really is that image | 17 |
The first row only lands on 1,282 if you count six patterns at once: alongside the Turkish and English "cover image" forms there are "fiction" and "true story" variants. Count only the first two and you get 1,242.
I expected a fourth row — files with no alt text — and my first scan found one. I opened the file; the alt text was there, only written with single quotes, while my pattern was looking for double ones. All 2,764 files carry a filled coverImageAlt. That was the first phantom finding my script produced this evening; the second comes a few sections later.
I had to look at the third row separately, because it did not agree with the count on disk: there were sixteen covers that were not 1,200×630, but seventeen rows in the table. The difference came from one file, and that file's cover was not a different size — it did not exist. There is no bootstrap-deadlock-cluster-domain-controller.png in the repository; only the English twin's PNG is there. The Turkish article's og:image tag still points at that missing address. I opened it live: 404. If I shared that article on a social network, the preview would come up empty. The article was published on 9 June, so it has been like this for 143 days — and I found it this evening while measuring something else entirely.
The middle row is what stopped me. I opened the 1,465 free-form alt texts and read twelve of them at random. Here are three:
- "Coolify ve Docker logolu sunucu mimarisi illüstrasyonu" (an illustration of server architecture with Coolify and Docker logos)
- "A small mini PC with an Intel N100 processor, labeled 'Home Server'."
- "Dijital kimlik doğrulama kavramını temsil eden bir anahtar ve kilit simgesi" (a key and lock icon representing digital authentication)
Then I opened those articles' covers one by one. No key, no lock, no mini PC in any of them. What I saw was the same thing every time: my logo in the corner, a category badge at the top, the title in the middle, my name and domain at the bottom. The card design has changed over the years — some are nearly black, some are a green gradient — but none of them contains the object its alt text describes.
To tie the number to evidence, the covers themselves had to be counted. Of the 2,764 PNGs under src/assets/blog/, 2,748 are exactly 1,200×630 pixels — the size of the SVG that scripts/generate-hero.ts produces. The remaining sixteen are 1,600×900, 1,200×800 or 1,199×799; those are genuinely hand-placed images. So the covers of the files I had flagged as having "wrong" alt text are title cards that replaced a photograph.
Half of the mechanism is written in the code. There is a note dated 16 May 2026 in generate-hero-en.ts's injectCoverIntoFrontmatter function: if alt text already exists, do not overwrite it, because we were clobbering the descriptive text coming from translation with a generic pattern. The intent is right. The result is the opposite: when cover photos were replaced by title cards, the thing preserving the alt texts that describe those photos was precisely this safeguard.
But that note is only in the English generator. The Turkish one, generate-hero.ts, does the exact opposite in the same place: if coverImageAlt exists it overwrites it unconditionally via altValueRe. And of the 1,465 stale alt texts, 728 are Turkish and 737 English. So one comment explains half the files and says nothing about the other 728. On the Turkish side the likeliest explanation is that this generator simply never ran over those files again — I can see the effect, not the run, and that is as far as I will put it.
I had already written a fix plan at this point. Then I looked at the page.
That Image Is Not on the Page
I opened a live article page and counted every <img> element. The result: one, and it is empty.
// https://mustafaerbay.com.tr/en/blog/life/<any-article>/
[...document.images].map(i => ({ src: i.currentSrc, alt: i.getAttribute('alt') }))
// → [ { src: "", alt: "" } ]
That single element is the empty <img id="lightbox-img" alt=""> tag the lightbox component places in advance. The cover image is not on the article page at all. It is not on the listing either: /blog/ and /blog/category/life/ contain zero <img> elements. The home page has one, and that is my profile photo.
I found the reason in the wrong place first. The comment at the top of PostCard.astro says "no cover image, because the cards already contain the title", and the comment block is dated 7 October 2026. That made me write "three weeks ago". It was wrong. A comment's date is not the date of the decision, it is the date I last touched that file.
Git history says something else. The <img> tag left the article page on 30 June 2026, in commit 05f8122f; the commit that moved the title into an animated cover also dropped coverImageAlt from BlogPost.astro's variable list. The listing card lost its own cover on 18 September 2026, and the "the title gets read twice" reasoning was written during that rewrite — the October terminal redesign only re-dated the header.
So the alt text's audience left four months ago. I noticed today, and on my first attempt I blamed the wrong commit, by trusting a comment's date instead of the history.
So where do the 2,764 alt texts go? To exactly one place: RSS. rss.xml.ts writes that field into <media:description type="plain">. I pulled the live feed; there is not a single <img> tag in it. The alt text does not sit next to a picture, it sits as a Media RSS description. So the 1,465 wrong descriptions are not dead — but their only audience is the handful of people whose feed reader displays that field.
media:description is neither alt text nor something that stands beside an image; it is a free description field that Media RSS reserves for an image attachment. Some feed readers show it as a caption under the card, others ignore it entirely. My Turkish feed has 1,382 entries and the field is filled in all of them. I cannot measure how many people see it — reader apps do not tell me, and I am not going to guess. The one thing I can say is this: that is where a wrong description can reach, and it arrives as the description of an attachment, not of a picture.
I threw away my fix plan. The real question was still unanswered: what is actually on this page?
What Is Actually There: 821 Diagrams
What is there is diagrams. I counted 821 <Mermaid> instances across 782 files in the repository — a flowchart in nearly a third of the articles. How many of them have a text alternative?
Zero.
The distribution is telling on its own. On the Turkish side, 391 of 1,382 articles contain a diagram: a third of the technology and tutorial articles (160/480 and 134/403), a fifth of the career and life ones (51/278 and 46/221). So a diagram is not decoration for me, it carries the technical argument. Wherever I struggled to explain a mechanism in sentences, I escaped into a diagram — and that is exactly where the spoken version of the article breaks.
Mermaid has two keywords for this and they are documented: accTitle: provides the accessible title, accDescr: the accessible description. If the author writes those two lines, the project puts them inside the SVG as <title> and <desc> and adds aria-labelledby and aria-describedby on top; if not, none of them appears. It inserts aria-roledescription by itself even when the author writes nothing. Not one of the 821 diagrams in my repository contains accTitle or accDescr; grep returned no lines.
I wanted to see what that looks like on a live page, so I read the SVG attributes in three different articles — /blog/life/uykuyu-sekiz-satir-engelliyordu-kapak-kapaninca-hicbiri-yok/, /blog/technology/gunlugume-7-gun-dedim-6-gun-18-saat-tuttu/ and /blog/life/7-eklentimin-her-siteyi-okuma-izni-var-kisitlama-varsayilani-kapali/. The same table in all three:
{
"role": "graphics-document document",
"aria-roledescription": "flowchart-v2",
"aria-label": null,
"aria-labelledby": null,
"aria-describedby": null,
"title": null,
"desc": null
}
There is a role description telling the screen reader "there is a flowchart here", and nothing at all saying what it shows.
Let me open the mechanism one step further, because "there is no alt text" undersells it. Two separate things decide how an element is announced: its role and its accessible name. The role answers "what is this"; the name answers "which one is this". aria-roledescription only customises the first answer — instead of the default "graphics document" it makes the reader say "flowchart-v2". The name comes from the SVG's <title> element, from aria-label or from aria-labelledby; MDN's page on the SVG <title> element is clear about this. All three are empty, so my diagrams have no name.
In practice: a reader navigating the page meets a graphics document, hears that its type is a flowchart, and even if the node labels inside can be read one by one, how those nodes connect — the entire meaning of the diagram — is lost. Arrows do not turn into sound. Something that could be summarised in one sentence collapses into a list of words with no relation to each other.
W3C's 1.1.1 criterion itself only asks for "a text alternative that serves the equivalent purpose"; the long description for charts and diagrams is not in the criterion but in the Understanding page's list of sufficient techniques, for the case where a short description cannot carry the same information. So a long description is one documented route, not the only one. My diagrams have neither. I was ready for a round of fixes on the cover image; for the only real kind of image on the page I have not written a single line to date.
If the long description does not fit on one line, accDescr has a braced block form (accDescr { … }); add a second line to the plain accDescr: and the diagram fails to parse, at which point my component hides it silently — so you fall into the trap this article just described while applying the fix this article recommends.
The diagram in this article carries both — the first one on the blog to do so:
Before JavaScript Arrives, the Diagram Is Raw Text
I noticed something else. Mermaid.astro first prints the diagram as raw source inside <pre class="mermaid">, then, once JavaScript runs in the browser, downloads the library from a CDN and turns it into an SVG. I fetched the HTML coming off the server as it is:
<pre class="mermaid">flowchart TD
A["Uyku talebi geldi"] --> B["Sebep: boşta kalma"]
A --> C["Sebep: kapak kapandı<br/>Elma menüsü · düşük pil"]
If the CDN cannot be reached — a corporate network, a blocked extension, a slow line — this is what stays on screen and in the screen reader. Not a diagram, but the diagram's source code, escape sequences and all. The component leaves it this way on purpose; inside script.onerror I noted that raw text remains if the CDN fails to load. A correct decision for not printing an error box, not for readability.
While I was there the library's size came up, out of curiosity: the minified file for mermaid@11.16.0 weighs 3,565,102 bytes, i.e. 3.4 MiB. To see one flowchart, the reader's browser downloads 3.4 MiB from a third-party network. I pinned the version and added an integrity hash — the supply chain side was thought through. The side that was not: part of the page's meaning depends on a file arriving from outside my own server. With a text alternative that dependency would only be a visual loss; without one, it is a loss of content.
The second detail is sneakier: if a diagram is invalid, the component sets the container to display: none. The measure I added so Mermaid's ugly error box would never appear quietly hides a broken diagram from everyone — including sighted readers.
I do not know how many diagrams have disappeared this way, because nothing counts them. Counting is one line, though, and anyone using the same guard can run it on their own page:
[...document.querySelectorAll('.mermaid-container')]
.filter(c => c.style.display === 'none').length
Writing an audit that runs this across the whole site instead of page by page is the subject of the next measurement.
Most of the Places I Feared Came Back Clean
When the scan finished I had a list I did not expect: most of what I assumed was bad was fine.
-
Language attribute.
lang="tr"on the Turkish page,lang="en"on the English twin. The screen reader picks the right pronunciation. -
Skip link. The first focusable element on the page is "Skip to content" →
#main-content. It exists, it works, it is invisible until focused. - Link text. All 4,553 external links in article bodies went through the scan; zero carried context-free text like "here", "click", "more".
- Inline images. There are 84 markdown images and every one has alt text.
-
Text contrast. In the dark theme, body text is
#dce2e8on#0e1014. The ratio is 14.6:1 — the threshold is 4.5:1.
One place did not come back clean, and it belongs here too. The same page carries 55 inline SVGs: menu icons, arrows, the theme toggle, and one diagram. Five are hidden with aria-hidden="true"; the other 50 have neither a name nor that attribute. Most of those icons are decoration, and the correct alt text for a decorative image is an empty one — that is, aria-hidden. I was about to close this list with "all clean"; 50 rows say otherwise.
And one more thing: my first scan found "heading level skips" in 224 files. Jumping straight from H2 to H4 breaks structural perception in a screen reader; it is a serious finding. Before celebrating I looked at the script: the ^#{1,6}\s pattern was also counting shell comments inside code blocks. A # let us look at the state first line inside a bash block was, as far as my scanner was concerned, an H1.
I stripped the code blocks and ran the same scan again: 0. All two hundred and twenty-four findings had been produced by the script itself.
A zero is not reassuring on its own either, so this time I deliberately tried to fool the script. I wrote three small files by hand: one with a genuine H2-to-H4 jump, one with an H3-looking comment inside a code block plus a proper H3 outside it, and one with a diagram carrying an accTitle line. The script caught the first, correctly called the second clean, and saw accTitle in the third. Once all three answered the way I expected, I believed the zero.
I am putting this in the article because it happens to everyone who measures, and it is usually only noticed in the other direction: when the number comes out small, you relax and move on.
I Was Going to Blame the Keyboard Too, and It Did Not Hold
I had one hypothesis left. The diagram container is scrollable via overflow-x-auto; a scrollable area that cannot be focused by keyboard is stuck with the mouse. W3C's test rules for criterion 2.1.1 include exactly this check: scrollable content must be reachable with sequential focus navigation.
The container has no tabindex. The finding looked ready. The measurement went ahead anyway: with the browser shrunk to 375 pixels, scrollWidth and clientWidth were compared in three different articles.
Both were 341. No overflow. Mermaid renders the SVG with width="100%" and a computed max-width, so on a narrow screen the diagram does not overflow, it shrinks. With nothing to scroll, there is no scrolling trap either.
What is interesting is what actually does overflow on the same page: on /blog/life/uykuyu-sekiz-satir-engelliyordu-kapak-kapaninca-hicbiri-yok/, 18 of 20 code blocks overflow horizontally at 375 pixels. And every one of them carries tabindex="0" — Shiki, which Astro uses, adds it by itself. So you can scroll my code blocks with the keyboard, because I did not write that part.
The lesson I noted here: every wrapper you write by hand carries the risk of quietly undoing something the framework was handling for you. Shiki did not ask me before adding that tabindex; I did not ask Shiki before writing my own container.
What I Am Going to Do
At the end of the measurement I was left with three jobs and their costs:
-
Add
accTitleandaccDescrto the production pipeline. Those two lines will be mandatory when generating a diagram for a new article; then I will wire it into thevalidate-mdx-staged.tsgate. The core of that gate is three lines:
git diff --cached --name-only --diff-filter=ACM -- '*.mdx' \
| xargs -r grep -l '<Mermaid' \
| xargs -r grep -L 'accTitle:'
If that command prints a filename, the commit should stop. Two sentences per new article. That is the cheap part.
- 821 existing diagrams. I worked out the cost roughly. The description for the diagram in this article is 26 words in Turkish and 39 in English; reading a diagram and boiling it down to one sentence takes a few minutes. For 821 instances that is at least twenty-one thousand words in Turkish (821 × 26 = 21,346) and over thirty thousand in English, and the two are separate jobs. Writing each one by hand takes weeks; having a machine write them all carries the risk of invention. A description that skips actually reading the diagram and waves at "a flow summarising the process" is worse than no description at all — because a screen reader reads it as reliable information and the reader has no way to check. I will do this gradually, starting with the most-read articles, verifying every description I produce against the diagram's source.
-
1,465 stale alt texts. The most honest move here is deletion. The cover is now a card containing the title; the card's alt text does not need to be a copy of the title, and not having one at all is more correct. On the RSS side, a short summary makes more sense for
media:descriptionthan a description does.
All three have something in common: none of them finished this evening. This article is not a solution, it is an inventory.
A Half-Hour Check on Your Own Site
You do not need to repeat this whole measurement. For your own site there are four steps where the order matters; all of them run in the browser console and the shell.
One. Look first at what the page shows, not at what it stores:
[...document.images].map(i => [i.currentSrc.split('/').pop(), i.alt])
If that list comes back empty, that is where you stop before fixing your alt text.
Two. Check the diagrams, if you have any. Keep the selector narrow, or every icon on the page floods the list:
[...document.querySelectorAll('.mermaid-container svg, svg[role^="graphics"]')]
.map(s => [s.getAttribute('aria-roledescription'),
s.getAttribute('aria-label') || s.querySelector('title')?.textContent || null])
Every row with null in the second column is a diagram with no name. If you also want to count the decorative icons, widen the selector to svg and look at the aria-hidden column instead.
Three. Make sure every box that overflows horizontally can be scrolled with the keyboard. Shrink the browser to 375 pixels and run:
[...document.querySelectorAll('pre, .mermaid-container, table')]
.filter(e => e.scrollWidth > e.clientWidth + 1)
.map(e => [e.tagName, e.getAttribute('tabindex')])
Every row showing null in the tabindex column is an area stuck with the mouse.
Four. Count the fields in your repository last — and deliberately try to fool the script you count with. Do not trust a result until you have planted a broken example in a file and watched the script catch it. My 224 phantom findings came from skipping exactly this step.
I Was Not Fixing the Thing I Had Not Measured, I Was Fixing the Thing Nobody Reads
The real lesson from this measurement is not about missing alt text.
For a whole evening I prepared to fix a field. The field looked right, it was measurable, it produced a satisfying number like 1,465. It had only one flaw: nobody was reading it. At the same time, the 821 things the page genuinely shows had never crossed my mind — because they do not sit in the frontmatter as a field, they sit inside the article, and I call them "content", not "a field to be audited".
This is the second time I have fallen into it. Two weeks ago I counted how I wrote the reading-time field 2,680 times and the arithmetic held in 618 of them; that was a frontmatter field too, and it was chosen because it could be counted. I like counting fields because a field answers back. A page does not; you have to go and look at it.
Measurement is drawn to whatever is easy. A field that can be scanned gets scanned, and the moment it yields a number it joins the list of things to fix; nobody steps in to ask "who actually reads this?" That is the first question. The day I counted how many of my sources were still alive and the day I looked at which sites my extensions could read, I started from the same place: from what was countable. This time what was countable walked me into the wrong room.
If you have half an hour for your own site, reverse the order. Open the page first, count document.images, and if there is a diagram, check whether its SVG has a title element. Count the fields afterwards. A field tells you how many there are; the page tells you how many of them have a reader.
Official Sources
- Understanding Success Criterion 1.1.1: Non-text Content — W3C WAI
- ACT Rule: Scrollable content can be reached with sequential focus navigation — W3C WAI
- Understanding Success Criterion 2.1.1: Keyboard — W3C WAI
- Mermaid 11.16.0 — Accessibility Options (accTitle / accDescr)
- SVG
<title>element — MDN Web Docs
Top comments (0)