From 7e9e5c7b310711beff29b53ad810121825c6ff74 Mon Sep 17 00:00:00 2001 From: svemagie <869694+svemagie@users.noreply.github.com> Date: Thu, 9 Apr 2026 20:17:17 +0200 Subject: [PATCH] fix(tags): normalize nested category paths for ActivityPub hashtags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nested categories like `on/tech` or `art/music` were being sent as-is to ActivityPub consumers. Three fixes: - status.js (Mastodon Client API): raw category used as tag name/URL instead of the normalized last segment - jf2-to-as2.js buildPlainTags/buildFedifyTags: href used encodeURIComponent() on the full path, encoding the `/` separator and producing broken category URLs (e.g. `categories/on%2Ftech` instead of `categories/on/tech`) Tag names are now consistently normalized to the last path segment (`on/tech` → `#tech`). Category hrefs encode each segment individually but preserve the `/` separator. Co-Authored-By: Claude Sonnet 4.6 --- lib/jf2-to-as2.js | 14 ++++++++------ lib/mastodon/entities/status.js | 13 ++++++++----- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/lib/jf2-to-as2.js b/lib/jf2-to-as2.js index d5d402b..7492421 100644 --- a/lib/jf2-to-as2.js +++ b/lib/jf2-to-as2.js @@ -621,10 +621,12 @@ function buildPlainTags(properties, publicationUrl, existing) { const tags = [...(existing || [])]; if (properties.category) { for (const cat of asArray(properties.category)) { + const normalized = cat.split("/").at(-1).replace(/\s+/g, ""); + const segments = cat.split("/").map((s) => encodeURIComponent(s.replace(/\s+/g, ""))); tags.push({ type: "Hashtag", - name: `#${cat.split("/").at(-1).replace(/\s+/g, "")}`, - href: `${publicationUrl}categories/${encodeURIComponent(cat)}`, + name: `#${normalized}`, + href: `${publicationUrl}categories/${segments.join("/")}`, }); } } @@ -643,12 +645,12 @@ function buildFedifyTags(properties, publicationUrl, postType) { } if (properties.category) { for (const cat of asArray(properties.category)) { + const normalized = cat.split("/").at(-1).replace(/\s+/g, ""); + const segments = cat.split("/").map((s) => encodeURIComponent(s.replace(/\s+/g, ""))); tags.push( new Hashtag({ - name: `#${cat.split("/").at(-1).replace(/\s+/g, "")}`, - href: new URL( - `${publicationUrl}categories/${encodeURIComponent(cat)}`, - ), + name: `#${normalized}`, + href: new URL(`${publicationUrl}categories/${segments.join("/")}`), }), ); } diff --git a/lib/mastodon/entities/status.js b/lib/mastodon/entities/status.js index c38fcdd..d8883d5 100644 --- a/lib/mastodon/entities/status.js +++ b/lib/mastodon/entities/status.js @@ -173,11 +173,14 @@ export function serializeStatus(item, { baseUrl, favouritedIds, rebloggedIds, bo // Link preview -> card const card = serializeCard(item.linkPreviews?.[0]); - // Tags from category[] - const tags = (item.category || []).map((tag) => ({ - name: tag, - url: `${baseUrl}/tags/${encodeURIComponent(tag)}`, - })); + // Tags from category[] — normalize nested paths (e.g. "on/tech" → "tech") + const tags = (item.category || []).map((tag) => { + const normalized = tag.split("/").at(-1).replace(/\s+/g, ""); + return { + name: normalized, + url: `${baseUrl}/tags/${encodeURIComponent(normalized)}`, + }; + }); // Mentions — use actorUrl for deterministic ID, parse acct from handle const mentions = (item.mentions || []).map((m) => {