Index: CHANGELOG.de.md
===================================================================
--- CHANGELOG.de.md	(revision d18c60e0878b426798d32187e85100a2262f1430)
+++ CHANGELOG.de.md	(revision 0688b5fcf8935a7e243f429c90946fd67027fb3c)
@@ -7,4 +7,6 @@
 
 ### Hinzugefügt
+- **Sprache eines Beitrags festlegen.** Wähle, in welcher Sprache du einen Beitrag geschrieben hast —
+  im Fediverse funktionieren damit der Sprachfilter der Timeline und die Übersetzen-Schaltfläche.
 - **Alt-Text für Bilder.** Gib deinem Titelbild eine Beschreibung (Inline-Bilder behalten ihren
   eigenen Alt-Text) — sie föderiert ins Fediverse und lässt Screenreader das Bild beschreiben.
Index: CHANGELOG.md
===================================================================
--- CHANGELOG.md	(revision d18c60e0878b426798d32187e85100a2262f1430)
+++ CHANGELOG.md	(revision 0688b5fcf8935a7e243f429c90946fd67027fb3c)
@@ -7,4 +7,6 @@
 
 ### Added
+- **Set a post's language.** Choose the language you wrote a post in — on the fediverse it enables
+  timeline language filtering and the translate button.
 - **Alt text for images.** Give your cover image a description (and inline images keep their own
   alt text) — it federates to the fediverse and lets screen readers describe the picture.
Index: CHANGELOG.nl.md
===================================================================
--- CHANGELOG.nl.md	(revision d18c60e0878b426798d32187e85100a2262f1430)
+++ CHANGELOG.nl.md	(revision 0688b5fcf8935a7e243f429c90946fd67027fb3c)
@@ -7,4 +7,6 @@
 
 ### Toegevoegd
+- **Stel de taal van een post in.** Kies in welke taal je een post schreef — op de fediverse
+  werkt daarmee het taalfilter van de tijdlijn en de vertaal-knop.
 - **Alt-tekst voor afbeeldingen.** Geef je cover een beschrijving (en inline-afbeeldingen behouden
   hun eigen alt-tekst) — die federeert mee naar de fediverse en laat schermlezers de afbeelding beschrijven.
Index: src/config/database.js
===================================================================
--- src/config/database.js	(revision d18c60e0878b426798d32187e85100a2262f1430)
+++ src/config/database.js	(revision 0688b5fcf8935a7e243f429c90946fd67027fb3c)
@@ -90,4 +90,5 @@
   ensureColumn('posts', 'cover_video_url', 'TEXT');        // muted loop MP4 for an animated cover (Safari-smooth)
   ensureColumn('posts', 'cover_alt', 'TEXT');              // alt text / description for the cover (a11y → AS2 attachment `name`)
+  ensureColumn('posts', 'language', 'TEXT');               // BCP-47 content language → federates as AS2 contentMap (Mastodon language filter/translate)
   ensureColumn('posts', 'content_warning', 'TEXT');        // custom CW label (empty = default "Gevoelige inhoud")
   ensureColumn('posts', 'type',    "TEXT DEFAULT 'post'");  // post | foto | video | audio
Index: src/routes/posts.js
===================================================================
--- src/routes/posts.js	(revision d18c60e0878b426798d32187e85100a2262f1430)
+++ src/routes/posts.js	(revision 0688b5fcf8935a7e243f429c90946fd67027fb3c)
@@ -247,4 +247,5 @@
   const cw = (req.body.content_warning || '').trim().slice(0, 200);
   const coverAlt = (req.body.cover_alt || '').trim().slice(0, 1500) || null; // cover alt text (a11y)
+  const language = /^[a-z]{2,3}(-[A-Za-z]{2,4})?$/.test(req.body.language || '') ? req.body.language : (res.locals.lang || null); // BCP-47 content language
 
   // Content arrives as user-authored HTML from the WYSIWYG editor — sanitize
@@ -285,11 +286,11 @@
     INSERT INTO posts (
       id, site_id, slug, author_id, title, content, excerpt,
-      status, cover_image_url, cover_video_url, cover_alt, pinned, tags, type, noindex, fan_only, nsfw, content_warning, poll_json, publish_at,
+      status, cover_image_url, cover_video_url, cover_alt, language, pinned, tags, type, noindex, fan_only, nsfw, content_warning, poll_json, publish_at,
       created_at, updated_at, published_at
-    ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+    ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
   `).run(
     postId, site.id, finalSlug, req.session.user.id,
     title || finalSlug, cleanContent, excerpt || '',
-    finalStatus, cover_image_url || null, (req.body.cover_video_url || null), coverAlt, parsePinnedRank(pinned),
+    finalStatus, cover_image_url || null, (req.body.cover_video_url || null), coverAlt, language, parsePinnedRank(pinned),
     JSON.stringify((tags || '').split(',').map(t => t.trim()).filter(Boolean)),
     finalType, noindex ? 1 : 0, fanOnly, nsfw, cw, pollJson, publishAt,
@@ -313,5 +314,5 @@
       ActivityPubService.deliverCreate(site, {
         id: postId, slug: finalSlug, title: title || finalSlug,
-        content: cleanContent, cover_image_url: cover_image_url || null, cover_video_url: req.body.cover_video_url || null, cover_alt: coverAlt,
+        content: cleanContent, cover_image_url: cover_image_url || null, cover_video_url: req.body.cover_video_url || null, cover_alt: coverAlt, language,
         published_at: publishedAt, created_at: now, fan_only: fanOnly, nsfw, content_warning: cw, poll_json: pollJson,
       }).catch(() => { /* best-effort */ });
@@ -381,4 +382,5 @@
   const cw = (req.body.content_warning || '').trim().slice(0, 200);
   const coverAlt = (req.body.cover_alt || '').trim().slice(0, 1500) || null; // cover alt text (a11y)
+  const language = /^[a-z]{2,3}(-[A-Za-z]{2,4})?$/.test(req.body.language || '') ? req.body.language : (res.locals.lang || null); // BCP-47 content language
   const newSlug = req.body.slug;
   const action = req.body.action || 'save';
@@ -424,5 +426,5 @@
     UPDATE posts SET
       title = ?, content = ?, excerpt = ?, status = ?,
-      cover_image_url = ?, cover_video_url = ?, cover_alt = ?, pinned = ?, tags = ?,
+      cover_image_url = ?, cover_video_url = ?, cover_alt = ?, language = ?, pinned = ?, tags = ?,
       type = ?, noindex = ?, fan_only = ?, nsfw = ?, content_warning = ?, poll_json = ?, publish_at = ?,
       slug = ?, published_at = ?, updated_at = ?
@@ -430,5 +432,5 @@
   `).run(
     title, cleanContent, excerpt, finalStatus,
-    cover_image_url || null, (req.body.cover_video_url || null), coverAlt, parsePinnedRank(pinned),
+    cover_image_url || null, (req.body.cover_video_url || null), coverAlt, language, parsePinnedRank(pinned),
     JSON.stringify((tags || '').split(',').map(t => t.trim()).filter(Boolean)),
     finalType, noindex ? 1 : 0, fanOnly, nsfw, cw, pollJson, publishAt,
@@ -456,5 +458,5 @@
     const apPost = {
       id: post.id, slug: finalSlug, title: title || finalSlug,
-      content: cleanContent, cover_image_url: cover_image_url || null, cover_video_url: req.body.cover_video_url || null, cover_alt: coverAlt,
+      content: cleanContent, cover_image_url: cover_image_url || null, cover_video_url: req.body.cover_video_url || null, cover_alt: coverAlt, language,
       published_at: publishedAt, created_at: post.created_at, fan_only: fanOnly, nsfw, content_warning: cw, poll_json: pollJson,
     };
Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision d18c60e0878b426798d32187e85100a2262f1430)
+++ src/services/ActivityPubService.js	(revision 0688b5fcf8935a7e243f429c90946fd67027fb3c)
@@ -400,4 +400,8 @@
   // make it JSON-LD-clean with a context term, otherwise it degrades to the player card.
   if (playable) note.embedUrl = `${base}/embed?post=${encodeURIComponent(post.slug)}`;
+  // Content language → AS2 contentMap (a BCP-47-keyed copy of the content). Mastodon reads the
+  // language from its key for the timeline language filter + the translate button. Emitted
+  // alongside `content` (Mastodon sends both); a plain receiver just uses `content`.
+  if (post.language && /^[a-z]{2,3}(-[A-Za-z]{2,4})?$/.test(post.language)) note.contentMap = { [post.language]: note.content };
   // A hosted poll → federate as an AS2 Question (options + live tally). Do this last so it
   // reuses the note's content/addressing/tags, then swaps the type and strips media.
Index: src/services/Scheduler.js
===================================================================
--- src/services/Scheduler.js	(revision d18c60e0878b426798d32187e85100a2262f1430)
+++ src/services/Scheduler.js	(revision 0688b5fcf8935a7e243f429c90946fd67027fb3c)
@@ -15,5 +15,5 @@
   try {
     const due = db.prepare(`
-      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,
+      SELECT p.id, p.site_id, p.slug, p.title, p.content, p.cover_image_url, p.cover_video_url, p.cover_alt, p.language, p.fan_only, p.nsfw, p.content_warning, p.poll_json,
              p.published_at, p.publish_at, p.created_at, u.username
       FROM posts p JOIN users u ON u.id = p.author_id
@@ -38,5 +38,5 @@
           ActivityPubService.deliverCreate(site, {
             id: p.id, slug: p.slug, title: p.title || p.slug,
-            content: p.content, cover_image_url: p.cover_image_url || null, cover_video_url: p.cover_video_url || null, cover_alt: p.cover_alt,
+            content: p.content, cover_image_url: p.cover_image_url || null, cover_video_url: p.cover_video_url || null, cover_alt: p.cover_alt, language: p.language,
             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,
           }).catch(() => { /* best-effort */ });
Index: src/services/i18n.js
===================================================================
--- src/services/i18n.js	(revision d18c60e0878b426798d32187e85100a2262f1430)
+++ src/services/i18n.js	(revision 0688b5fcf8935a7e243f429c90946fd67027fb3c)
@@ -639,4 +639,6 @@
     'pedit.f_cover_alt': 'Alt-tekst (beschrijving)',
     'pedit.cover_alt_placeholder': 'Beschrijf de afbeelding voor schermlezers',
+    'pedit.f_language': 'Taal',
+    'pedit.language_hint': 'voor het fediverse-taalfilter',
     'pedit.cover_upload_btn': 'Upload nieuwe cover',
     'pedit.s_content': 'Content',
@@ -1559,4 +1561,6 @@
     'pedit.f_cover_alt': 'Alt text (description)',
     'pedit.cover_alt_placeholder': 'Describe the image for screen readers',
+    'pedit.f_language': 'Language',
+    'pedit.language_hint': 'for the fediverse language filter',
     'pedit.cover_upload_btn': 'Upload new cover',
     'pedit.s_content': 'Content',
@@ -2479,4 +2483,6 @@
     'pedit.f_cover_alt': 'Alt-Text (Beschreibung)',
     'pedit.cover_alt_placeholder': 'Beschreibe das Bild für Screenreader',
+    'pedit.f_language': 'Sprache',
+    'pedit.language_hint': 'für den Fediverse-Sprachfilter',
     'pedit.cover_upload_btn': 'Neues Titelbild hochladen',
     'pedit.s_content': 'Inhalt',
Index: src/views/pages/post-edit.ejs
===================================================================
--- src/views/pages/post-edit.ejs	(revision d18c60e0878b426798d32187e85100a2262f1430)
+++ src/views/pages/post-edit.ejs	(revision 0688b5fcf8935a7e243f429c90946fd67027fb3c)
@@ -87,4 +87,13 @@
         <textarea name="excerpt" rows="2"><%= post.excerpt %></textarea>
         <small class="pe-hint" style="opacity:.7"><%- t('pedit.excerpt_hint') %></small>
+      </label>
+      <% var _postLang = (typeof post.language !== 'undefined' && post.language) ? post.language : (typeof lang !== 'undefined' ? lang : 'en'); %>
+      <label class="pe-field">
+        <span><%= t('pedit.f_language') %> <small><%= t('pedit.language_hint') %></small></span>
+        <select name="language" id="pe-language" style="max-width:220px">
+          <% [['en','English'],['nl','Nederlands'],['de','Deutsch'],['fr','Français'],['es','Español'],['it','Italiano'],['pt','Português'],['pl','Polski'],['ru','Русский'],['uk','Українська'],['sv','Svenska'],['da','Dansk'],['nb','Norsk'],['fi','Suomi'],['tr','Türkçe'],['ja','日本語'],['zh','中文'],['ar','العربية']].forEach(function (l) { %>
+            <option value="<%= l[0] %>" <%= l[0] === _postLang ? 'selected' : '' %>><%= l[1] %></option>
+          <% }); %>
+        </select>
       </label>
     </section>
Index: test/activitypub-as2.test.js
===================================================================
--- test/activitypub-as2.test.js	(revision d18c60e0878b426798d32187e85100a2262f1430)
+++ test/activitypub-as2.test.js	(revision 0688b5fcf8935a7e243f429c90946fd67027fb3c)
@@ -27,4 +27,5 @@
   'published', 'updated', 'attributedTo', 'inReplyTo', 'replies',
   'attachment', 'tag', 'icon', 'image', 'duration',
+  'contentMap', 'nameMap', 'summaryMap', // AS2 @language-map counterparts of content/name/summary
   'totalItems', 'orderedItems', 'items', 'first', 'last', 'partOf', 'next', 'prev',
   'preferredUsername', 'inbox', 'outbox', 'followers', 'following', 'endpoints', 'sharedInbox',
@@ -47,4 +48,5 @@
       keys.add(k);
       if (k === 'type' && typeof v === 'string') keys.add(v);
+      if (/Map$/.test(k)) continue; // a @language map (contentMap/…): its keys are BCP-47 tags, not vocab terms
       collect(v, keys);
     }
@@ -70,5 +72,5 @@
   id: 'p1', slug: 'hello', title: 'Hi', content: '<p>hello #music</p>',
   nsfw: 1, content_warning: 'cw', tags: JSON.stringify(['mood']),
-  cover_image_url: '/media/c.webp',
+  cover_image_url: '/media/c.webp', cover_alt: 'A cover', language: 'en',
   published_at: '2026-01-01T00:00:00Z', created_at: '2026-01-01T00:00:00Z',
 };
Index: test/post-language.test.js
===================================================================
--- test/post-language.test.js	(revision 0688b5fcf8935a7e243f429c90946fd67027fb3c)
+++ test/post-language.test.js	(revision 0688b5fcf8935a7e243f429c90946fd67027fb3c)
@@ -0,0 +1,37 @@
+// Content language — a post's language federates as an AS2 contentMap (Mastodon's language
+// filter + translate button read the map's key). In-memory SQLite. Run: npm test
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://test.example';
+
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+dbMod.initializeDatabase();
+const AP = (await import('../src/services/ActivityPubService.js')).default;
+
+const BASE = 'https://test.example';
+db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)').run('u1', 'u1', 'u1@test', 'x', 'god');
+db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary) VALUES (?,?,?,?,?)').run('s1', 'demo', 'Demo', 'u1', 1);
+const site = db.prepare('SELECT * FROM sites WHERE id = ?').get('s1');
+site.primary_slug = 'demo';
+
+test('a post language federates as a contentMap keyed by the language', () => {
+  const post = { id: 'l1', slug: 'x', title: '', content: '<p>hallo</p>', tags: '[]', language: 'nl', created_at: '2026-01-01T00:00:00Z' };
+  const note = AP.buildNote(BASE, site, post);
+  assert.ok(note.contentMap, 'contentMap present');
+  assert.deepEqual(Object.keys(note.contentMap), ['nl']);
+  assert.equal(note.contentMap.nl, note.content, 'contentMap value mirrors content');
+  assert.ok(note.content, 'plain content still emitted alongside');
+});
+
+test('no language → no contentMap', () => {
+  const post = { id: 'l2', slug: 'y', title: '', content: '<p>hi</p>', tags: '[]', created_at: '2026-01-01T00:00:00Z' };
+  assert.ok(!('contentMap' in AP.buildNote(BASE, site, post)));
+});
+
+test('an invalid language code is ignored', () => {
+  const post = { id: 'l3', slug: 'z', title: '', content: '<p>hi</p>', tags: '[]', language: 'not-a-lang!!', created_at: '2026-01-01T00:00:00Z' };
+  assert.ok(!('contentMap' in AP.buildNote(BASE, site, post)));
+});
