Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion shared/html.utils.spec.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -184,3 +184,42 @@ describe('escapeHtml and decodeHtmlEntities roundtrip', () => {
});
});
});

describe('ensureImageAlt', () => {
it('adds an empty alt to a raw <img> without alt', () => {
expect(ensureImageAlt('<img src="test.png">')).toBe('<img src="test.png" alt="">');
});

it('leaves an <img> that already has an alt untouched', () => {
expect(ensureImageAlt('<img src="test.png" alt="A cat">')).toBe('<img src="test.png" alt="A cat">');
});

it('leaves an <img> with an empty alt untouched', () => {
expect(ensureImageAlt('<img alt="" src="test.png">')).toBe('<img alt="" src="test.png">');
});

it('preserves a self-closing slash', () => {
expect(ensureImageAlt('<img src="test.png" />')).toBe('<img src="test.png" alt=""/>');
});

it('only touches images without alt when several are present', () => {
const input = '<p><img src="a.png"><img src="b.png" alt="B"></p>';
expect(ensureImageAlt(input)).toBe('<p><img src="a.png" alt=""><img src="b.png" alt="B"></p>');
});

it('is case-insensitive about an existing ALT attribute', () => {
expect(ensureImageAlt('<img src="a.png" ALT="x">')).toBe('<img src="a.png" ALT="x">');
});

it('is not fooled by an "alt" substring inside another attribute', () => {
expect(ensureImageAlt('<img src="a.png" data-salt="1">')).toBe('<img src="a.png" data-salt="1" alt="">');
});

it('leaves non-img content unchanged', () => {
expect(ensureImageAlt('<a href="x">link</a>')).toBe('<a href="x">link</a>');
});

it('returns an empty string unchanged', () => {
expect(ensureImageAlt('')).toBe('');
});
});
16 changes: 16 additions & 0 deletions shared/html.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,19 @@ export function escapeHtml(text: string): string {
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}

/**
* Ensure every <img> 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 <img> tags (which frequently lack alt). A missing alt makes
* screen readers announce the file name, so any <img> 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(/<img\b[^>]*>/gi, (tag) =>
/\balt\s*=/i.test(tag) ? tag : tag.replace(/\s*(\/?)>$/, ' alt=""$1>'),
);
}
30 changes: 30 additions & 0 deletions shared/jekyll-markdown-parser.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -604,6 +604,36 @@ title: Test
expect(result.html).toContain('alt="Photo"');
});

it('should add a decorative empty alt to a raw <img> without alt', () => {
const input = `---
title: Test
---

<img src="photo.jpg">
`;
const parser = new JekyllMarkdownParser(baseUrl, linkBasePath);
const result = parser.parse(input);

// Manual raw <img> without alt must still get an alt="" (a11y). Applied late on the
// final HTML, so both markdown images and raw <img> 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 <img> that already has one', () => {
const input = `---
title: Test
---

<img src="photo.jpg" alt="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
Expand Down
8 changes: 6 additions & 2 deletions shared/jekyll-markdown-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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+:/;
Expand Down Expand Up @@ -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 <img> (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 };
}

Expand Down
Loading