Ignore:
Timestamp:
07/01/2026 06:55:46 PM (2 months ago)
Author:
roboburr <roboburr@…>
Branches:
main
Children:
0688b5f
Parents:
43273c9
Message:

feat(fediverse): alt text for images (accessibility → AS2 attachment name)

Media federated to the fediverse now carries a description (AS2 name on the
attachment), which Mastodon shows and screen readers read. The cover gets an alt
field in the editor; inline images keep their own <img alt="…">. Also used as the
cover's on-site alt.

  • src/config/database.js — posts.cover_alt column.
  • src/services/ActivityPubService.js — buildNote carries alt per media URL (cover_alt + inline <img alt>) and emits it as the attachment name (and on the image fallback).
  • src/routes/posts.js — capture/store cover_alt on create/save and pass it to the federation hooks.
  • src/services/Scheduler.js — carry cover_alt when a scheduled post goes live.
  • src/views/pages/post-edit.ejs — "Alt text (description)" field under the cover URL.
  • src/views/pages/post.ejs — the on-page cover <img> uses cover_alt.
  • src/services/i18n.js — pedit.f_cover_alt + pedit.cover_alt_placeholder (nl/en/de).
  • test/media-alt.test.js — cover alt, inline alt, and no-alt = no name.
  • CHANGELOG(.nl/.de).md — "Alt text for images" under Unreleased.

Co-Authored-By: Claude <noreply@…>

File:
1 edited

Legend:

Unmodified
Added
Removed
  • src/services/ActivityPubService.js

    r43273c9 rd18c60e  
    262262  // An animated cover federates as the muted loop MP4 (→ a Video attachment): animated WebP is
    263263  // unreliable on Mastodon and its iOS apps; the MP4 plays everywhere. Else the still cover image.
    264   if (post.cover_video_url && !noImages) urls.push(abs(post.cover_video_url));
    265   else if (post.cover_image_url && !noImages) urls.push(abs(post.cover_image_url));
     264  // Each entry carries the media URL + its alt text (federated as the AS2 attachment `name`, for a11y).
     265  if (post.cover_video_url && !noImages) urls.push({ url: abs(post.cover_video_url), name: post.cover_alt || '' });
     266  else if (post.cover_image_url && !noImages) urls.push({ url: abs(post.cover_image_url), name: post.cover_alt || '' });
    266267  let body = post.content || '';
    267268  // Only federate inline images we can actually serve: absolute http(s) URLs, or our own
    268269  // /media/ uploads. A relative path we don't host (e.g. a stale /images/... ref) would 404
    269   // and show up as a black tile in Mastodon's attachment grid.
    270   if (!noImages) for (const m of body.matchAll(/<img\b[^>]*\bsrc="([^"]+)"[^>]*>/gi)) {
    271     const src = m[1];
    272     if (/^https?:\/\//i.test(src) || src.startsWith('/media/')) urls.push(abs(src));
     270  // and show up as a black tile in Mastodon's attachment grid. Carry the <img alt="…"> through
     271  // as the attachment description.
     272  if (!noImages) for (const m of body.matchAll(/<img\b[^>]*>/gi)) {
     273    const tag = m[0];
     274    const src = (tag.match(/\bsrc="([^"]+)"/i) || [])[1];
     275    if (!src || !(/^https?:\/\//i.test(src) || src.startsWith('/media/'))) continue;
     276    const alt = (tag.match(/\balt="([^"]*)"/i) || [])[1] || '';
     277    urls.push({ url: abs(src), name: alt });
    273278  }
    274279  body = body.replace(/<img\b[^>]*>/gi, '');
     
    345350  }
    346351  const seen = new Set();
    347   const attachment = urls.filter(Boolean)
    348     .filter((u) => { if (seen.has(u)) return false; seen.add(u); return true; })
    349     .map((u) => { const mt = mediaType(u); // specific AS2 subtype (Image/Audio/Video) over generic Document
     352  const attachment = urls.filter((x) => x && x.url)
     353    .filter((x) => { if (seen.has(x.url)) return false; seen.add(x.url); return true; })
     354    .map((x) => { const mt = mediaType(x.url); // specific AS2 subtype (Image/Audio/Video) over generic Document
    350355      const ty = /^image\//i.test(mt) ? 'Image' : /^video\//i.test(mt) ? 'Video' : /^audio\//i.test(mt) ? 'Audio' : 'Document';
    351       return { type: ty, mediaType: mt, url: u }; });
     356      const a = { type: ty, mediaType: mt, url: x.url };
     357      if (x.name) a.name = String(x.name).slice(0, 1500); // alt text / description (AS2 `name`)
     358      return a; });
    352359  for (const a of openAudio) attachment.push(a); // fedi_open tracks → native Audio players
    353360
     
    385392  if (post.cover_image_url && noImages) {
    386393    const cov = abs(post.cover_image_url);
    387     if (cov) note.image = { type: 'Image', mediaType: mediaType(cov), url: cov };
     394    if (cov) { note.image = { type: 'Image', mediaType: mediaType(cov), url: cov }; if (post.cover_alt) note.image.name = String(post.cover_alt).slice(0, 1500); }
    388395  }
    389396  // Experiment (mirrors PeerTube / schema.org `embedUrl`): point at the GATED player page
Note: See TracChangeset for help on using the changeset viewer.