/**
* 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\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"; }