From 7f6811858f7953409b285293e2cf3ffff74c4650 Mon Sep 17 00:00:00 2001 From: Johannes Hoppe Date: Thu, 23 Jul 2026 11:37:23 +0200 Subject: [PATCH] feat: ensure every rendered has an alt attribute (a11y) Images reach the HTML two ways: Markdown images (the renderer already emits an alt, even an empty one) and manual raw tags, which frequently lack alt. A missing alt makes screen readers announce the file name. Add ensureImageAlt() and apply it LATE - as the final step in compileMarkdown() on the fully transformed HTML - so both image sources are covered uniformly. Images that already carry an alt (including alt="") are left untouched. Adds unit tests for ensureImageAlt plus parser integration tests proving a raw without alt gets alt="" while an existing alt is preserved. --- shared/html.utils.spec.ts | 41 ++++++++++++++++++++++++++- shared/html.utils.ts | 16 +++++++++++ shared/jekyll-markdown-parser.spec.ts | 30 ++++++++++++++++++++ shared/jekyll-markdown-parser.ts | 8 ++++-- 4 files changed, 92 insertions(+), 3 deletions(-) diff --git a/shared/html.utils.spec.ts b/shared/html.utils.spec.ts index fc590cb..b33d9df 100644 --- a/shared/html.utils.spec.ts +++ b/shared/html.utils.spec.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { stripHtmlTags, decodeHtmlEntities, escapeHtml } from './html.utils'; +import { stripHtmlTags, decodeHtmlEntities, escapeHtml, ensureImageAlt } from './html.utils'; describe('stripHtmlTags', () => { it('should return empty string for empty input', () => { @@ -184,3 +184,42 @@ describe('escapeHtml and decodeHtmlEntities roundtrip', () => { }); }); }); + +describe('ensureImageAlt', () => { + it('adds an empty alt to a raw without alt', () => { + expect(ensureImageAlt('')).toBe(''); + }); + + it('leaves an that already has an alt untouched', () => { + expect(ensureImageAlt('A cat')).toBe('A cat'); + }); + + it('leaves an with an empty alt untouched', () => { + expect(ensureImageAlt('')).toBe(''); + }); + + it('preserves a self-closing slash', () => { + expect(ensureImageAlt('')).toBe(''); + }); + + it('only touches images without alt when several are present', () => { + const input = '

B

'; + expect(ensureImageAlt(input)).toBe('

B

'); + }); + + it('is case-insensitive about an existing ALT attribute', () => { + expect(ensureImageAlt('x')).toBe('x'); + }); + + it('is not fooled by an "alt" substring inside another attribute', () => { + expect(ensureImageAlt('')).toBe(''); + }); + + it('leaves non-img content unchanged', () => { + expect(ensureImageAlt('link')).toBe('link'); + }); + + it('returns an empty string unchanged', () => { + expect(ensureImageAlt('')).toBe(''); + }); +}); diff --git a/shared/html.utils.ts b/shared/html.utils.ts index 8aeb971..64bcdcd 100644 --- a/shared/html.utils.ts +++ b/shared/html.utils.ts @@ -35,3 +35,19 @@ export function escapeHtml(text: string): string { .replace(//g, '>'); } + +/** + * Ensure every has an alt attribute. + * + * Images reach the HTML two ways: from Markdown (the renderer always emits an alt, even an + * empty one) and as manual raw tags (which frequently lack alt). A missing alt makes + * screen readers announce the file name, so any without an alt gets a decorative + * empty alt="". Images that already carry an alt (including alt="") are left untouched. + * + * Apply this LATE, on the final HTML, so both image sources are covered uniformly. + */ +export function ensureImageAlt(html: string): string { + return html.replace(/]*>/gi, (tag) => + /\balt\s*=/i.test(tag) ? tag : tag.replace(/\s*(\/?)>$/, ' alt=""$1>'), + ); +} diff --git a/shared/jekyll-markdown-parser.spec.ts b/shared/jekyll-markdown-parser.spec.ts index 5ab4aec..1cc6c25 100644 --- a/shared/jekyll-markdown-parser.spec.ts +++ b/shared/jekyll-markdown-parser.spec.ts @@ -604,6 +604,36 @@ title: Test expect(result.html).toContain('alt="Photo"'); }); + it('should add a decorative empty alt to a raw without alt', () => { + const input = `--- +title: Test +--- + + +`; + const parser = new JekyllMarkdownParser(baseUrl, linkBasePath); + const result = parser.parse(input); + + // Manual raw without alt must still get an alt="" (a11y). Applied late on the + // final HTML, so both markdown images and raw are covered uniformly. + expect(result.html).toContain(`src="${baseUrl}photo.jpg"`); + expect(result.html).toContain('alt=""'); + }); + + it('should not add a second alt to a raw that already has one', () => { + const input = `--- +title: Test +--- + +Photo +`; + const parser = new JekyllMarkdownParser(baseUrl, linkBasePath); + const result = parser.parse(input); + + expect(result.html).toContain('alt="Photo"'); + expect(result.html).not.toContain('alt=""'); + }); + it('should handle ./ prefix by stripping it', () => { const input = `--- title: Test diff --git a/shared/jekyll-markdown-parser.ts b/shared/jekyll-markdown-parser.ts index 4a29c00..6d1a015 100644 --- a/shared/jekyll-markdown-parser.ts +++ b/shared/jekyll-markdown-parser.ts @@ -4,7 +4,7 @@ import { Marked, Renderer, Tokens } from 'marked'; import { markedHighlight } from 'marked-highlight'; import { gfmHeadingId, getHeadingList, resetHeadings } from './gfm-heading-id'; import hljs from 'highlight.js'; -import { escapeHtml } from './html.utils'; +import { escapeHtml, ensureImageAlt } from './html.utils'; // Precompiled regexes for performance const PROTOCOL_REGEX = /^\w+:/; @@ -322,7 +322,11 @@ export class JekyllMarkdownParser { const html = this.marked.parse(processedMarkdown) as string; const headingIds = getHeadingList().map(h => h.id); const withImages = this.transformRelativeImagePaths(html); - const finalHtml = this.transformRelativeLinks(withImages); + const withLinks = this.transformRelativeLinks(withImages); + // Late a11y pass on the FINAL html: images come both from Markdown (which already carries + // an alt via imageRenderer) and as manual raw (which may lack alt). Adding a + // decorative alt="" here covers both sources uniformly, after all other transforms ran. + const finalHtml = ensureImageAlt(withLinks); return { html: finalHtml, headingIds }; }