fix(tags): normalize nested category paths for ActivityPub hashtags

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 <noreply@anthropic.com>
This commit is contained in:
svemagie
2026-04-09 20:17:17 +02:00
parent cfe35b28e8
commit 7e9e5c7b31
2 changed files with 16 additions and 11 deletions
+8 -6
View File
@@ -621,10 +621,12 @@ function buildPlainTags(properties, publicationUrl, existing) {
const tags = [...(existing || [])]; const tags = [...(existing || [])];
if (properties.category) { if (properties.category) {
for (const cat of asArray(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({ tags.push({
type: "Hashtag", type: "Hashtag",
name: `#${cat.split("/").at(-1).replace(/\s+/g, "")}`, name: `#${normalized}`,
href: `${publicationUrl}categories/${encodeURIComponent(cat)}`, href: `${publicationUrl}categories/${segments.join("/")}`,
}); });
} }
} }
@@ -643,12 +645,12 @@ function buildFedifyTags(properties, publicationUrl, postType) {
} }
if (properties.category) { if (properties.category) {
for (const cat of asArray(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( tags.push(
new Hashtag({ new Hashtag({
name: `#${cat.split("/").at(-1).replace(/\s+/g, "")}`, name: `#${normalized}`,
href: new URL( href: new URL(`${publicationUrl}categories/${segments.join("/")}`),
`${publicationUrl}categories/${encodeURIComponent(cat)}`,
),
}), }),
); );
} }
+8 -5
View File
@@ -173,11 +173,14 @@ export function serializeStatus(item, { baseUrl, favouritedIds, rebloggedIds, bo
// Link preview -> card // Link preview -> card
const card = serializeCard(item.linkPreviews?.[0]); const card = serializeCard(item.linkPreviews?.[0]);
// Tags from category[] // Tags from category[] — normalize nested paths (e.g. "on/tech" → "tech")
const tags = (item.category || []).map((tag) => ({ const tags = (item.category || []).map((tag) => {
name: tag, const normalized = tag.split("/").at(-1).replace(/\s+/g, "");
url: `${baseUrl}/tags/${encodeURIComponent(tag)}`, return {
})); name: normalized,
url: `${baseUrl}/tags/${encodeURIComponent(normalized)}`,
};
});
// Mentions — use actorUrl for deterministic ID, parse acct from handle // Mentions — use actorUrl for deterministic ID, parse acct from handle
const mentions = (item.mentions || []).map((m) => { const mentions = (item.mentions || []).map((m) => {