feat: linkify URLs and extract @mentions in status creation

Mastodon clients send plain text — the server must convert bare URLs
and @user@domain mentions into HTML links. Previously, URLs appeared
as plain text and mentions were not stored as mention objects.

- Bare URLs (http/https) are wrapped in <a> tags
- @user@domain patterns are converted to profile links with h-card markup
- Mentions are extracted into the mentions[] array with name and URL
- Only processes content that doesn't already contain <a> tags
  (avoids double-linkifying Micropub-rendered content)
This commit is contained in:
Ricardo
2026-03-21 19:01:05 +01:00
parent cad9829cd7
commit 94c4546234
2 changed files with 72 additions and 3 deletions

View File

@@ -247,12 +247,17 @@ router.post("/api/v1/statuses", async (req, res, next) => {
});
};
// Process content: linkify URLs and extract @mentions
const rawContent = data.properties.content || { text: statusText || "", html: "" };
const processedContent = processStatusContent(rawContent, statusText || "");
const mentions = extractMentions(statusText || "");
const now = new Date().toISOString();
const timelineItem = await addTimelineItem(collections, {
uid: postUrl,
url: postUrl,
type: data.properties["post-type"] || "note",
content: data.properties.content || { text: statusText || "", html: "" },
content: processedContent,
summary: spoilerText || "",
sensitive: sensitive === true || sensitive === "true",
visibility: visibility || "public",
@@ -274,7 +279,7 @@ router.post("/api/v1/statuses", async (req, res, next) => {
category: categories,
counts: { replies: 0, boosts: 0, likes: 0 },
linkPreviews: [],
mentions: [],
mentions,
emojis: [],
});
@@ -636,4 +641,68 @@ async function loadItemInteractions(collections, item) {
return { favouritedIds, rebloggedIds, bookmarkedIds };
}
/**
* Process status content: linkify bare URLs and convert @mentions to links.
*
* Mastodon clients send plain text — the server is responsible for
* converting URLs and mentions into HTML links.
*
* @param {object} content - { text, html } from Micropub pipeline
* @param {string} rawText - Original status text from client
* @returns {object} { text, html } with linkified content
*/
function processStatusContent(content, rawText) {
let html = content.html || content.text || rawText || "";
// If the HTML is just plain text wrapped in <p>, process it
// Don't touch HTML that already has links (from Micropub rendering)
if (!html.includes("<a ")) {
// Linkify bare URLs (http/https)
html = html.replace(
/(https?:\/\/[^\s<>"')\]]+)/g,
'<a href="$1" rel="nofollow noopener noreferrer" target="_blank">$1</a>',
);
// Convert @user@domain mentions to profile links
html = html.replace(
/(?:^|\s)(@([a-zA-Z0-9_]+)@([a-zA-Z0-9.-]+\.[a-zA-Z]{2,}))/g,
(match, full, username, domain) =>
match.replace(
full,
`<span class="h-card"><a href="https://${domain}/@${username}" class="u-url mention" rel="nofollow noopener noreferrer" target="_blank">@${username}@${domain}</a></span>`,
),
);
}
return {
text: content.text || rawText || "",
html,
};
}
/**
* Extract @user@domain mentions from text into mention objects.
*
* @param {string} text - Status text
* @returns {Array<{name: string, url: string}>} Mention objects
*/
function extractMentions(text) {
if (!text) return [];
const mentionRegex = /@([a-zA-Z0-9_]+)@([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/g;
const mentions = [];
const seen = new Set();
let match;
while ((match = mentionRegex.exec(text)) !== null) {
const [, username, domain] = match;
const key = `${username}@${domain}`.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
mentions.push({
name: `@${username}@${domain}`,
url: `https://${domain}/@${username}`,
});
}
return mentions;
}
export default router;

View File

@@ -1,6 +1,6 @@
{
"name": "@rmdes/indiekit-endpoint-activitypub",
"version": "3.7.2",
"version": "3.7.3",
"description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
"keywords": [
"indiekit",