Changeset d18c60e in Klonkt


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@…>

Files:
1 added
10 edited

Legend:

Unmodified
Added
Removed
  • CHANGELOG.de.md

    r43273c9 rd18c60e  
    77
    88### Hinzugefügt
     9- **Alt-Text für Bilder.** Gib deinem Titelbild eine Beschreibung (Inline-Bilder behalten ihren
     10  eigenen Alt-Text) — sie föderiert ins Fediverse und lässt Screenreader das Bild beschreiben.
    911- **Erwähne Personen in einem Beitrag.** `@benutzer@server` in einem Beitrag verlinkt jetzt auf ihr
    1012  Profil und benachrichtigt sie im Fediverse — auch wenn sie dir nicht folgen — wie eine Erwähnung
  • CHANGELOG.md

    r43273c9 rd18c60e  
    77
    88### Added
     9- **Alt text for images.** Give your cover image a description (and inline images keep their own
     10  alt text) — it federates to the fediverse and lets screen readers describe the picture.
    911- **Mention people in a post.** Typing `@user@server` in a post now links to their profile and
    1012  notifies them on the fediverse — even if they don't follow you — just like a mention in a reply.
  • CHANGELOG.nl.md

    r43273c9 rd18c60e  
    77
    88### Toegevoegd
     9- **Alt-tekst voor afbeeldingen.** Geef je cover een beschrijving (en inline-afbeeldingen behouden
     10  hun eigen alt-tekst) — die federeert mee naar de fediverse en laat schermlezers de afbeelding beschrijven.
    911- **Noem mensen in een post.** `@gebruiker@server` in een post linkt nu naar hun profiel en stuurt
    1012  ze een melding op de fediverse — ook als ze je niet volgen — net als een vermelding in een reactie.
  • src/config/database.js

    r43273c9 rd18c60e  
    8989  ensureColumn('posts', 'nsfw',     'INTEGER DEFAULT 0');  // sensitive content → blur + click-to-reveal; fediverse sensitive
    9090  ensureColumn('posts', 'cover_video_url', 'TEXT');        // muted loop MP4 for an animated cover (Safari-smooth)
     91  ensureColumn('posts', 'cover_alt', 'TEXT');              // alt text / description for the cover (a11y → AS2 attachment `name`)
    9192  ensureColumn('posts', 'content_warning', 'TEXT');        // custom CW label (empty = default "Gevoelige inhoud")
    9293  ensureColumn('posts', 'type',    "TEXT DEFAULT 'post'");  // post | foto | video | audio
  • src/routes/posts.js

    r43273c9 rd18c60e  
    246246  const nsfw = req.body.nsfw ? 1 : 0;
    247247  const cw = (req.body.content_warning || '').trim().slice(0, 200);
     248  const coverAlt = (req.body.cover_alt || '').trim().slice(0, 1500) || null; // cover alt text (a11y)
    248249
    249250  // Content arrives as user-authored HTML from the WYSIWYG editor — sanitize
     
    284285    INSERT INTO posts (
    285286      id, site_id, slug, author_id, title, content, excerpt,
    286       status, cover_image_url, cover_video_url, pinned, tags, type, noindex, fan_only, nsfw, content_warning, poll_json, publish_at,
     287      status, cover_image_url, cover_video_url, cover_alt, pinned, tags, type, noindex, fan_only, nsfw, content_warning, poll_json, publish_at,
    287288      created_at, updated_at, published_at
    288     ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
     289    ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
    289290  `).run(
    290291    postId, site.id, finalSlug, req.session.user.id,
    291292    title || finalSlug, cleanContent, excerpt || '',
    292     finalStatus, cover_image_url || null, (req.body.cover_video_url || null), parsePinnedRank(pinned),
     293    finalStatus, cover_image_url || null, (req.body.cover_video_url || null), coverAlt, parsePinnedRank(pinned),
    293294    JSON.stringify((tags || '').split(',').map(t => t.trim()).filter(Boolean)),
    294295    finalType, noindex ? 1 : 0, fanOnly, nsfw, cw, pollJson, publishAt,
     
    312313      ActivityPubService.deliverCreate(site, {
    313314        id: postId, slug: finalSlug, title: title || finalSlug,
    314         content: cleanContent, cover_image_url: cover_image_url || null, cover_video_url: req.body.cover_video_url || null,
     315        content: cleanContent, cover_image_url: cover_image_url || null, cover_video_url: req.body.cover_video_url || null, cover_alt: coverAlt,
    315316        published_at: publishedAt, created_at: now, fan_only: fanOnly, nsfw, content_warning: cw, poll_json: pollJson,
    316317      }).catch(() => { /* best-effort */ });
     
    379380  const nsfw = req.body.nsfw ? 1 : 0;
    380381  const cw = (req.body.content_warning || '').trim().slice(0, 200);
     382  const coverAlt = (req.body.cover_alt || '').trim().slice(0, 1500) || null; // cover alt text (a11y)
    381383  const newSlug = req.body.slug;
    382384  const action = req.body.action || 'save';
     
    422424    UPDATE posts SET
    423425      title = ?, content = ?, excerpt = ?, status = ?,
    424       cover_image_url = ?, cover_video_url = ?, pinned = ?, tags = ?,
     426      cover_image_url = ?, cover_video_url = ?, cover_alt = ?, pinned = ?, tags = ?,
    425427      type = ?, noindex = ?, fan_only = ?, nsfw = ?, content_warning = ?, poll_json = ?, publish_at = ?,
    426428      slug = ?, published_at = ?, updated_at = ?
     
    428430  `).run(
    429431    title, cleanContent, excerpt, finalStatus,
    430     cover_image_url || null, (req.body.cover_video_url || null), parsePinnedRank(pinned),
     432    cover_image_url || null, (req.body.cover_video_url || null), coverAlt, parsePinnedRank(pinned),
    431433    JSON.stringify((tags || '').split(',').map(t => t.trim()).filter(Boolean)),
    432434    finalType, noindex ? 1 : 0, fanOnly, nsfw, cw, pollJson, publishAt,
     
    454456    const apPost = {
    455457      id: post.id, slug: finalSlug, title: title || finalSlug,
    456       content: cleanContent, cover_image_url: cover_image_url || null, cover_video_url: req.body.cover_video_url || null,
     458      content: cleanContent, cover_image_url: cover_image_url || null, cover_video_url: req.body.cover_video_url || null, cover_alt: coverAlt,
    457459      published_at: publishedAt, created_at: post.created_at, fan_only: fanOnly, nsfw, content_warning: cw, poll_json: pollJson,
    458460    };
  • 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
  • src/services/Scheduler.js

    r43273c9 rd18c60e  
    1515  try {
    1616    const due = db.prepare(`
    17       SELECT p.id, p.site_id, p.slug, p.title, p.content, p.cover_image_url, p.cover_video_url, p.fan_only, p.nsfw, p.content_warning, p.poll_json,
     17      SELECT p.id, p.site_id, p.slug, p.title, p.content, p.cover_image_url, p.cover_video_url, p.cover_alt, p.fan_only, p.nsfw, p.content_warning, p.poll_json,
    1818             p.published_at, p.publish_at, p.created_at, u.username
    1919      FROM posts p JOIN users u ON u.id = p.author_id
     
    3838          ActivityPubService.deliverCreate(site, {
    3939            id: p.id, slug: p.slug, title: p.title || p.slug,
    40             content: p.content, cover_image_url: p.cover_image_url || null, cover_video_url: p.cover_video_url || null,
     40            content: p.content, cover_image_url: p.cover_image_url || null, cover_video_url: p.cover_video_url || null, cover_alt: p.cover_alt,
    4141            published_at: p.published_at || p.publish_at, created_at: p.created_at, fan_only: p.fan_only, nsfw: p.nsfw, content_warning: p.content_warning, poll_json: p.poll_json,
    4242          }).catch(() => { /* best-effort */ });
  • src/services/i18n.js

    r43273c9 rd18c60e  
    637637    'pedit.f_cover_url': 'Cover URL',
    638638    'pedit.cover_url_placeholder': '/media/…  of https://…  (of upload met de knop)',
     639    'pedit.f_cover_alt': 'Alt-tekst (beschrijving)',
     640    'pedit.cover_alt_placeholder': 'Beschrijf de afbeelding voor schermlezers',
    639641    'pedit.cover_upload_btn': 'Upload nieuwe cover',
    640642    'pedit.s_content': 'Content',
     
    15551557    'pedit.f_cover_url': 'Cover URL',
    15561558    'pedit.cover_url_placeholder': '/media/…  or https://…  (or upload with the button)',
     1559    'pedit.f_cover_alt': 'Alt text (description)',
     1560    'pedit.cover_alt_placeholder': 'Describe the image for screen readers',
    15571561    'pedit.cover_upload_btn': 'Upload new cover',
    15581562    'pedit.s_content': 'Content',
     
    24732477    'pedit.f_cover_url': 'Titelbild-URL',
    24742478    'pedit.cover_url_placeholder': '/media/…  oder https://…  (oder per Schaltfläche hochladen)',
     2479    'pedit.f_cover_alt': 'Alt-Text (Beschreibung)',
     2480    'pedit.cover_alt_placeholder': 'Beschreibe das Bild für Screenreader',
    24752481    'pedit.cover_upload_btn': 'Neues Titelbild hochladen',
    24762482    'pedit.s_content': 'Inhalt',
  • src/views/pages/post-edit.ejs

    r43273c9 rd18c60e  
    105105                   inputmode="url" autocapitalize="none" spellcheck="false"
    106106                   placeholder="<%= t('pedit.cover_url_placeholder') %>">
     107          </label>
     108          <label class="pe-field">
     109            <span><%= t('pedit.f_cover_alt') %></span>
     110            <input type="text" name="cover_alt" id="cover-alt-field" maxlength="1500"
     111                   value="<%= post.cover_alt || '' %>"
     112                   placeholder="<%= t('pedit.cover_alt_placeholder') %>">
    107113          </label>
    108114          <%# Muted loop MP4 for an animated cover (auto-made from an animated WebP). Hidden — set by the uploader. %>
  • src/views/pages/post.ejs

    r43273c9 rd18c60e  
    2020  <% if (post.cover_image_url) { %>
    2121    <figure class="post-cover">
    22       <img src="<%= post.cover_image_url %>" alt=""<% if (post.cover_video_url) { %> data-ios-mp4="<%= post.cover_video_url %>"<% } %>>
     22      <img src="<%= post.cover_image_url %>" alt="<%= post.cover_alt || '' %>"<% if (post.cover_video_url) { %> data-ios-mp4="<%= post.cover_video_url %>"<% } %>>
    2323    </figure>
    2424  <% } %>
Note: See TracChangeset for help on using the changeset viewer.