Changeset d18c60e in Klonkt
- Timestamp:
- 07/01/2026 06:55:46 PM (2 months ago)
- Branches:
- main
- Children:
- 0688b5f
- Parents:
- 43273c9
- Files:
-
- 1 added
- 10 edited
-
CHANGELOG.de.md (modified) (1 diff)
-
CHANGELOG.md (modified) (1 diff)
-
CHANGELOG.nl.md (modified) (1 diff)
-
src/config/database.js (modified) (1 diff)
-
src/routes/posts.js (modified) (7 diffs)
-
src/services/ActivityPubService.js (modified) (3 diffs)
-
src/services/Scheduler.js (modified) (2 diffs)
-
src/services/i18n.js (modified) (3 diffs)
-
src/views/pages/post-edit.ejs (modified) (1 diff)
-
src/views/pages/post.ejs (modified) (1 diff)
-
test/media-alt.test.js (added)
Legend:
- Unmodified
- Added
- Removed
-
CHANGELOG.de.md
r43273c9 rd18c60e 7 7 8 8 ### 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. 9 11 - **Erwähne Personen in einem Beitrag.** `@benutzer@server` in einem Beitrag verlinkt jetzt auf ihr 10 12 Profil und benachrichtigt sie im Fediverse — auch wenn sie dir nicht folgen — wie eine Erwähnung -
CHANGELOG.md
r43273c9 rd18c60e 7 7 8 8 ### 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. 9 11 - **Mention people in a post.** Typing `@user@server` in a post now links to their profile and 10 12 notifies them on the fediverse — even if they don't follow you — just like a mention in a reply. -
CHANGELOG.nl.md
r43273c9 rd18c60e 7 7 8 8 ### 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. 9 11 - **Noem mensen in een post.** `@gebruiker@server` in een post linkt nu naar hun profiel en stuurt 10 12 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 89 89 ensureColumn('posts', 'nsfw', 'INTEGER DEFAULT 0'); // sensitive content → blur + click-to-reveal; fediverse sensitive 90 90 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`) 91 92 ensureColumn('posts', 'content_warning', 'TEXT'); // custom CW label (empty = default "Gevoelige inhoud") 92 93 ensureColumn('posts', 'type', "TEXT DEFAULT 'post'"); // post | foto | video | audio -
src/routes/posts.js
r43273c9 rd18c60e 246 246 const nsfw = req.body.nsfw ? 1 : 0; 247 247 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) 248 249 249 250 // Content arrives as user-authored HTML from the WYSIWYG editor — sanitize … … 284 285 INSERT INTO posts ( 285 286 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, 287 288 created_at, updated_at, published_at 288 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? )289 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 289 290 `).run( 290 291 postId, site.id, finalSlug, req.session.user.id, 291 292 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), 293 294 JSON.stringify((tags || '').split(',').map(t => t.trim()).filter(Boolean)), 294 295 finalType, noindex ? 1 : 0, fanOnly, nsfw, cw, pollJson, publishAt, … … 312 313 ActivityPubService.deliverCreate(site, { 313 314 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, 315 316 published_at: publishedAt, created_at: now, fan_only: fanOnly, nsfw, content_warning: cw, poll_json: pollJson, 316 317 }).catch(() => { /* best-effort */ }); … … 379 380 const nsfw = req.body.nsfw ? 1 : 0; 380 381 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) 381 383 const newSlug = req.body.slug; 382 384 const action = req.body.action || 'save'; … … 422 424 UPDATE posts SET 423 425 title = ?, content = ?, excerpt = ?, status = ?, 424 cover_image_url = ?, cover_video_url = ?, pinned = ?, tags = ?,426 cover_image_url = ?, cover_video_url = ?, cover_alt = ?, pinned = ?, tags = ?, 425 427 type = ?, noindex = ?, fan_only = ?, nsfw = ?, content_warning = ?, poll_json = ?, publish_at = ?, 426 428 slug = ?, published_at = ?, updated_at = ? … … 428 430 `).run( 429 431 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), 431 433 JSON.stringify((tags || '').split(',').map(t => t.trim()).filter(Boolean)), 432 434 finalType, noindex ? 1 : 0, fanOnly, nsfw, cw, pollJson, publishAt, … … 454 456 const apPost = { 455 457 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, 457 459 published_at: publishedAt, created_at: post.created_at, fan_only: fanOnly, nsfw, content_warning: cw, poll_json: pollJson, 458 460 }; -
src/services/ActivityPubService.js
r43273c9 rd18c60e 262 262 // An animated cover federates as the muted loop MP4 (→ a Video attachment): animated WebP is 263 263 // 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 || '' }); 266 267 let body = post.content || ''; 267 268 // Only federate inline images we can actually serve: absolute http(s) URLs, or our own 268 269 // /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 }); 273 278 } 274 279 body = body.replace(/<img\b[^>]*>/gi, ''); … … 345 350 } 346 351 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 Document352 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 350 355 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; }); 352 359 for (const a of openAudio) attachment.push(a); // fedi_open tracks → native Audio players 353 360 … … 385 392 if (post.cover_image_url && noImages) { 386 393 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); } 388 395 } 389 396 // Experiment (mirrors PeerTube / schema.org `embedUrl`): point at the GATED player page -
src/services/Scheduler.js
r43273c9 rd18c60e 15 15 try { 16 16 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, 18 18 p.published_at, p.publish_at, p.created_at, u.username 19 19 FROM posts p JOIN users u ON u.id = p.author_id … … 38 38 ActivityPubService.deliverCreate(site, { 39 39 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, 41 41 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, 42 42 }).catch(() => { /* best-effort */ }); -
src/services/i18n.js
r43273c9 rd18c60e 637 637 'pedit.f_cover_url': 'Cover URL', 638 638 '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', 639 641 'pedit.cover_upload_btn': 'Upload nieuwe cover', 640 642 'pedit.s_content': 'Content', … … 1555 1557 'pedit.f_cover_url': 'Cover URL', 1556 1558 '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', 1557 1561 'pedit.cover_upload_btn': 'Upload new cover', 1558 1562 'pedit.s_content': 'Content', … … 2473 2477 'pedit.f_cover_url': 'Titelbild-URL', 2474 2478 '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', 2475 2481 'pedit.cover_upload_btn': 'Neues Titelbild hochladen', 2476 2482 'pedit.s_content': 'Inhalt', -
src/views/pages/post-edit.ejs
r43273c9 rd18c60e 105 105 inputmode="url" autocapitalize="none" spellcheck="false" 106 106 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') %>"> 107 113 </label> 108 114 <%# 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 20 20 <% if (post.cover_image_url) { %> 21 21 <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 %>"<% } %>> 23 23 </figure> 24 24 <% } %>
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)