/** * Convert Indiekit JF2 post properties to ActivityStreams 2.0 objects. * * Two export flavors: * - jf2ToActivityStreams() — returns plain JSON-LD objects (for content negotiation) * - jf2ToAS2Activity() — returns Fedify vocab instances (for outbox + syndicator) */ import { Temporal } from "@js-temporal/polyfill"; import { Announce, Article, Audio, Create, Hashtag, Image, Like, Mention, Note, Video, } from "@fedify/fedify/vocab"; // --------------------------------------------------------------------------- // Content helpers // --------------------------------------------------------------------------- /** * Convert bare URLs in HTML content to clickable links. * Skips URLs already inside href attributes or anchor tag text. */ function linkifyUrls(html) { if (!html) return html; return html.replace( /(?])(https?:\/\/[^\s<"]+)/g, (_, url) => { // Strip trailing punctuation that is almost never part of a URL // e.g. "See https://example.com." → link to https://example.com const clean = url.replace(/[.,;:!?)\]'"]+$/, ""); return `${clean}`; }, ); } /** * Parse @user@domain mention patterns from text content. * Returns array of { handle: "user@domain", username: "user", domain: "domain.tld" }. */ export function parseMentions(text) { if (!text) return []; // Strip HTML tags for parsing const plain = text.replace(/<[^>]*>/g, " "); const mentionRegex = /(?@${handle}`, ); } return html; } // --------------------------------------------------------------------------- // ActivityPub URL detection // --------------------------------------------------------------------------- /** * Check whether a URL serves ActivityPub content by doing a quick content * negotiation request. Returns true if the server responds with an AP * media type (application/activity+json or application/ld+json). * Fails silently — any network/timeout error returns false. */ async function isApUrl(url) { try { const res = await fetch(url, { headers: { Accept: "application/activity+json, application/ld+json" }, redirect: "follow", signal: AbortSignal.timeout(3000), }); const ct = res.headers.get("content-type") || ""; return ct.includes("activity+json") || ct.includes("ld+json"); } catch { return false; } } // --------------------------------------------------------------------------- // Plain JSON-LD (content negotiation on individual post URLs) // --------------------------------------------------------------------------- /** * Convert JF2 properties to a plain ActivityStreams JSON-LD object. * * @param {object} properties - JF2 post properties * @param {string} actorUrl - Actor URL (e.g. "https://example.com/activitypub/users/rick") * @param {string} publicationUrl - Publication base URL with trailing slash * @returns {object} ActivityStreams activity (Create, Like, or Announce) */ export function jf2ToActivityStreams(properties, actorUrl, publicationUrl, options = {}) { const postType = properties["post-type"]; // Likes are delivered as bookmarks — fall through to bookmark handling below // Reposts are always public — Mastodon and other implementations expect this if (postType === "repost") { // Same rationale as like — serve as Note for content negotiation. const repostOf = properties["repost-of"]; const postUrl = resolvePostUrl(properties.url, publicationUrl); const commentary = linkifyUrls(properties.content?.html || properties.content || ""); return { "@context": "https://www.w3.org/ns/activitystreams", type: "Note", id: postUrl, attributedTo: actorUrl, published: properties.published, url: postUrl, to: ["https://www.w3.org/ns/activitystreams#Public"], cc: [`${actorUrl.replace(/\/$/, "")}/followers`], content: commentary ? `${commentary}

\u{1F501} ${repostOf}` : `\u{1F501} ${repostOf}`, }; } const isArticle = postType === "article" && properties.name; const postUrl = resolvePostUrl(properties.url, publicationUrl); const visibility = properties.visibility || options.visibility || "public"; const followersUrl = `${actorUrl.replace(/\/$/, "")}/followers`; const object = { type: isArticle ? "Article" : "Note", id: postUrl, attributedTo: actorUrl, published: properties.published, url: postUrl, to: visibility === "unlisted" ? [followersUrl] : visibility === "followers" ? [followersUrl] : ["https://www.w3.org/ns/activitystreams#Public"], cc: visibility === "unlisted" ? ["https://www.w3.org/ns/activitystreams#Public"] : visibility === "followers" ? [] : [followersUrl], }; if (postType === "bookmark" || postType === "like") { const bookmarkUrl = properties["bookmark-of"] || properties["like-of"]; const commentary = linkifyUrls(properties.content?.html || properties.content || ""); object.content = commentary ? `${commentary}

\u{1F516} ${bookmarkUrl}` : `\u{1F516} ${bookmarkUrl}`; object.tag = [ { type: "Hashtag", name: "#bookmark", href: `${publicationUrl}categories/bookmark`, }, ]; } else { object.content = linkifyUrls(properties.content?.html || properties.content || ""); } // Append permalink to content so fediverse clients show a clickable link if (postUrl && object.content) { object.content += `

\u{1F517} ${postUrl}

`; } // OG image for fediverse preview cards const ogMatch = postUrl && postUrl.match(/\/([\w-]+)\/(\d{4})\/(\d{2})\/(\d{2})\/([\w-]+)\/?$/); if (ogMatch) { object.image = { type: "Image", url: `${publicationUrl.replace(/\/$/, "")}/og/${ogMatch[5]}.png`, mediaType: "image/png", }; } if (isArticle) { object.name = properties.name; if (properties.summary) { object.summary = properties.summary; } } if (properties.sensitive || properties["post-status"] === "sensitive") { object.sensitive = true; } // Content warning text for Mastodon CW display if (properties["content-warning"]) { object.summary = properties["content-warning"]; object.sensitive = true; } if (properties["in-reply-to"]) { object.inReplyTo = properties["in-reply-to"]; } const attachments = buildPlainAttachments(properties, publicationUrl); if (attachments.length > 0) { object.attachment = attachments; } const tags = buildPlainTags(properties, publicationUrl, object.tag); // Add Mention tags + cc addressing + content linkification for @mentions const resolvedMentions = options.mentions || []; for (const { handle, actorUrl: mentionUrl } of resolvedMentions) { if (mentionUrl) { tags.push({ type: "Mention", href: mentionUrl, name: `@${handle}` }); if (!object.cc.includes(mentionUrl)) { object.cc.push(mentionUrl); } } } if (tags.length > 0) { object.tag = tags; } // Linkify @mentions in content (resolved get actor links, unresolved get profile links) if (resolvedMentions.length > 0 && object.content) { object.content = linkifyMentions(object.content, resolvedMentions); } return { "@context": "https://www.w3.org/ns/activitystreams", type: "Create", actor: actorUrl, object, }; } // --------------------------------------------------------------------------- // Fedify vocab objects (outbox dispatcher + syndicator delivery) // --------------------------------------------------------------------------- /** * Convert JF2 properties to a Fedify Activity object. * * @param {object} properties - JF2 post properties * @param {string} actorUrl - Actor URL (e.g. "https://example.com/activitypub/users/rick") * @param {string} publicationUrl - Publication base URL with trailing slash * @param {object} [options] - Optional settings * @param {string} [options.replyToActorUrl] - Original post author's actor URL (for reply addressing) * @param {string} [options.replyToActorHandle] - Original post author's handle (for Mention tag) * @returns {Promise} */ export async function jf2ToAS2Activity(properties, actorUrl, publicationUrl, options = {}) { const postType = properties["post-type"]; const actorUri = new URL(actorUrl); // Likes of ActivityPub objects are sent as a proper Like activity. // Likes of regular URLs fall through to bookmark-style Create(Note) below. if (postType === "like") { const likeOfUrl = properties["like-of"]; if (likeOfUrl && (await isApUrl(likeOfUrl))) { // Build a canonical id so remote servers can dereference this activity // (ActivityPub §6.2.1 — activities SHOULD have an id URI). // Derive the mount path from the actor URL (e.g. "/activitypub") so // we don't need mountPath threaded through as an option here. const actorPath = new URL(actorUrl).pathname; // e.g. "/activitypub/users/sven" const mp = actorPath.replace(/\/users\/[^/]+$/, ""); // → "/activitypub" const postRelPath = (properties.url || "") .replace(publicationUrl.replace(/\/$/, ""), "") .replace(/^\//, "") .replace(/\/$/, ""); // e.g. "likes/9acc3" const likeActivityId = `${publicationUrl.replace(/\/$/, "")}${mp}/activities/like/${postRelPath}`; return new Like({ id: new URL(likeActivityId), actor: actorUri, object: new URL(likeOfUrl), to: new URL("https://www.w3.org/ns/activitystreams#Public"), }); } } // Reposts are always public — upstream @rmdes addressing if (postType === "repost") { const repostOf = Array.isArray(properties["repost-of"]) ? properties["repost-of"][0] : properties["repost-of"]; if (!repostOf) return null; const repostContent = properties.content?.html || properties.content || ""; if (!repostContent) { // Only send Announce if repost-of is an ActivityPub URL. // Non-AP URLs (web articles) cannot be federated as a boost — fall // through to Create(Note) which renders as "🔁 " on the fediverse. if (await isApUrl(repostOf)) { const actorPath = new URL(actorUrl).pathname; const mp = actorPath.replace(/\/users\/[^/]+$/, ""); const postRelPath = (properties.url || "") .replace(publicationUrl.replace(/\/$/, ""), "") .replace(/^\//, "") .replace(/\/$/, ""); const announceId = `${publicationUrl.replace(/\/$/, "")}${mp}/activities/boost/${postRelPath}`; return new Announce({ id: new URL(announceId), actor: actorUri, object: new URL(repostOf), to: new URL("https://www.w3.org/ns/activitystreams#Public"), cc: new URL(`${actorUrl.replace(/\/$/, "")}/followers`), }); } } // Has commentary or non-AP repost-of URL — fall through to Create(Note) so the text is federated. // The note content block below handles the "repost" post-type. } const isArticle = postType === "article" && properties.name; const postUrl = resolvePostUrl(properties.url, publicationUrl); const followersUrl = `${actorUrl.replace(/\/$/, "")}/followers`; const { replyToActorUrl, replyToActorHandle } = options; const noteOptions = { attributedTo: actorUri, }; // Determine visibility: per-post override > option default > "public" const visibility = properties.visibility || options.visibility || "public"; // Addressing based on visibility: // - "public": to: PUBLIC, cc: followers (+ reply author) // - "unlisted": to: followers, cc: PUBLIC (+ reply author) // - "followers": to: followers (+ reply author), no PUBLIC const PUBLIC = new URL("https://www.w3.org/ns/activitystreams#Public"); const followersUri = new URL(followersUrl); if (replyToActorUrl && properties["in-reply-to"]) { const replyAuthor = new URL(replyToActorUrl); if (visibility === "unlisted") { noteOptions.to = followersUri; noteOptions.ccs = [PUBLIC, replyAuthor]; } else if (visibility === "followers") { noteOptions.tos = [followersUri, replyAuthor]; } else { // public (default) noteOptions.to = PUBLIC; noteOptions.ccs = [followersUri, replyAuthor]; } } else { if (visibility === "unlisted") { noteOptions.to = followersUri; noteOptions.cc = PUBLIC; } else if (visibility === "followers") { noteOptions.to = followersUri; } else { // public (default) noteOptions.to = PUBLIC; noteOptions.cc = followersUri; } } if (postUrl) { noteOptions.id = new URL(postUrl); noteOptions.url = new URL(postUrl); } if (properties.published) { try { noteOptions.published = Temporal.Instant.from(properties.published); } catch { // Invalid date format — skip } } // Content if (postType === "bookmark" || postType === "like") { const bookmarkUrl = properties["bookmark-of"] || properties["like-of"]; const commentary = linkifyUrls(properties.content?.html || properties.content || ""); noteOptions.content = commentary ? `${commentary}

\u{1F516} ${bookmarkUrl}` : `\u{1F516} ${bookmarkUrl}`; } else if (postType === "repost") { const repostUrl = properties["repost-of"]; const repostCommentary = linkifyUrls(properties.content?.html || properties.content || ""); noteOptions.content = repostCommentary ? `${repostCommentary}

\u{1F501} ${repostUrl}` : `\u{1F501} ${repostUrl}`; } else { noteOptions.content = linkifyUrls(properties.content?.html || properties.content || ""); } // Append permalink to content so fediverse clients show a clickable link // back to the canonical post on the author's site if (postUrl && noteOptions.content) { noteOptions.content += `

\u{1F517} ${postUrl}

`; } if (isArticle) { noteOptions.name = properties.name; if (properties.summary) { noteOptions.summary = properties.summary; } } // Content warning / sensitive flag if (properties.sensitive) { noteOptions.sensitive = true; } if (properties["post-status"] === "sensitive") { noteOptions.sensitive = true; } // Content warning text for Mastodon CW display if (properties["content-warning"]) { noteOptions.summary = properties["content-warning"]; noteOptions.sensitive = true; } if (properties["in-reply-to"]) { noteOptions.replyTarget = new URL(properties["in-reply-to"]); } // Attachments const fedifyAttachments = buildFedifyAttachments(properties, publicationUrl); if (fedifyAttachments.length > 0) { noteOptions.attachments = fedifyAttachments; } // OG image for fediverse preview cards const ogMatchF = postUrl && postUrl.match(/\/([\w-]+)\/(\d{4})\/(\d{2})\/(\d{2})\/([\w-]+)\/?$/); if (ogMatchF) { noteOptions.image = new Image({ url: new URL(`${publicationUrl.replace(/\/$/, "")}/og/${ogMatchF[5]}.png`), mediaType: "image/png", }); } // Tags: hashtags + Mention for reply addressing + @mentions const fedifyTags = buildFedifyTags(properties, publicationUrl, postType); if (replyToActorUrl) { fedifyTags.push( new Mention({ href: new URL(replyToActorUrl), name: replyToActorHandle ? `@${replyToActorHandle}` : undefined, }), ); } // Add Mention tags + cc addressing for resolved @mentions const resolvedMentions = options.mentions || []; const ccUrls = []; for (const { handle, actorUrl: mentionUrl } of resolvedMentions) { if (mentionUrl) { // Skip if same as replyToActorUrl (already added above) const alreadyTagged = replyToActorUrl && mentionUrl === replyToActorUrl; if (!alreadyTagged) { fedifyTags.push( new Mention({ href: new URL(mentionUrl), name: `@${handle}`, }), ); } ccUrls.push(new URL(mentionUrl)); } } // Merge mention actors into cc/ccs if (ccUrls.length > 0) { if (noteOptions.ccs) { noteOptions.ccs = [...noteOptions.ccs, ...ccUrls]; } else if (noteOptions.cc) { noteOptions.ccs = [noteOptions.cc, ...ccUrls]; delete noteOptions.cc; } else { noteOptions.ccs = ccUrls; } } if (fedifyTags.length > 0) { noteOptions.tags = fedifyTags; } // Linkify @mentions in content if (resolvedMentions.length > 0 && noteOptions.content) { noteOptions.content = linkifyMentions(noteOptions.content, resolvedMentions); } const object = isArticle ? new Article(noteOptions) : new Note(noteOptions); return new Create({ actor: actorUri, object, }); } // --------------------------------------------------------------------------- // URL resolution helpers // --------------------------------------------------------------------------- /** * Resolve a post URL, ensuring it's absolute. * @param {string} url - Post URL (may be relative or absolute) * @param {string} publicationUrl - Base publication URL * @returns {string} Absolute URL */ export function resolvePostUrl(url, publicationUrl) { if (!url) return ""; if (url.startsWith("http")) return url; const base = publicationUrl.replace(/\/$/, ""); return `${base}/${url.replace(/^\//, "")}`; } function resolveMediaUrl(url, publicationUrl) { if (!url) return ""; if (url.startsWith("http")) return url; const base = publicationUrl.replace(/\/$/, ""); return `${base}/${url.replace(/^\//, "")}`; } // --------------------------------------------------------------------------- // Attachment builders // --------------------------------------------------------------------------- function buildPlainAttachments(properties, publicationUrl) { const attachments = []; if (properties.photo) { for (const photo of asArray(properties.photo)) { const url = typeof photo === "string" ? photo : photo.url; const alt = typeof photo === "string" ? "" : photo.alt || ""; attachments.push({ type: "Image", mediaType: guessMediaType(url), url: resolveMediaUrl(url, publicationUrl), name: alt, }); } } if (properties.video) { for (const video of asArray(properties.video)) { const url = typeof video === "string" ? video : video.url; attachments.push({ type: "Video", url: resolveMediaUrl(url, publicationUrl), name: "", }); } } if (properties.audio) { for (const audio of asArray(properties.audio)) { const url = typeof audio === "string" ? audio : audio.url; attachments.push({ type: "Audio", url: resolveMediaUrl(url, publicationUrl), name: "", }); } } return attachments; } function buildFedifyAttachments(properties, publicationUrl) { const attachments = []; if (properties.photo) { for (const photo of asArray(properties.photo)) { const url = typeof photo === "string" ? photo : photo.url; const alt = typeof photo === "string" ? "" : photo.alt || ""; attachments.push( new Image({ url: new URL(resolveMediaUrl(url, publicationUrl)), mediaType: guessMediaType(url), name: alt, }), ); } } if (properties.video) { for (const video of asArray(properties.video)) { const url = typeof video === "string" ? video : video.url; attachments.push( new Video({ url: new URL(resolveMediaUrl(url, publicationUrl)), }), ); } } if (properties.audio) { for (const audio of asArray(properties.audio)) { const url = typeof audio === "string" ? audio : audio.url; attachments.push( new Audio({ url: new URL(resolveMediaUrl(url, publicationUrl)), }), ); } } return attachments; } // --------------------------------------------------------------------------- // Tag builders // --------------------------------------------------------------------------- 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: `#${normalized}`, href: `${publicationUrl}categories/${segments.join("/")}`, }); } } return tags; } function buildFedifyTags(properties, publicationUrl, postType) { const tags = []; if (postType === "bookmark" || postType === "like") { tags.push( new Hashtag({ name: "#bookmark", href: new URL(`${publicationUrl}categories/bookmark`), }), ); } 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: `#${normalized}`, href: new URL(`${publicationUrl}categories/${segments.join("/")}`), }), ); } } return tags; } // --------------------------------------------------------------------------- // Utilities // --------------------------------------------------------------------------- function asArray(value) { return Array.isArray(value) ? value : [value]; } function guessMediaType(url) { const ext = url.split(".").pop()?.toLowerCase(); const types = { jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", gif: "image/gif", webp: "image/webp", svg: "image/svg+xml", avif: "image/avif", }; return types[ext] || "image/jpeg"; }