@rmdes/indiekit-endpoint-activitypub 3.6.2 → 3.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -21,6 +21,7 @@ export async function backfillTimeline(collections) {
|
|
|
21
21
|
|
|
22
22
|
// Get local profile for author info
|
|
23
23
|
const profile = await ap_profile?.findOne({});
|
|
24
|
+
const siteUrl = profile?.url?.replace(/\/$/, "") || "";
|
|
24
25
|
const author = profile
|
|
25
26
|
? {
|
|
26
27
|
name: profile.name || "",
|
|
@@ -58,24 +59,30 @@ export async function backfillTimeline(collections) {
|
|
|
58
59
|
continue;
|
|
59
60
|
}
|
|
60
61
|
|
|
61
|
-
//
|
|
62
|
-
|
|
62
|
+
// Build content — interaction types (bookmark, like, repost) may not have
|
|
63
|
+
// body content, so synthesize it from the interaction target URL
|
|
64
|
+
const content = buildContent(props);
|
|
63
65
|
const type = mapPostType(props["post-type"]);
|
|
64
66
|
|
|
67
|
+
// Extract categories + inline hashtags from content
|
|
68
|
+
const categories = normalizeArray(props.category);
|
|
69
|
+
const inlineHashtags = extractHashtags(content.text + " " + (content.html || ""));
|
|
70
|
+
const mergedCategories = mergeCategories(categories, inlineHashtags);
|
|
71
|
+
|
|
65
72
|
const timelineItem = {
|
|
66
73
|
uid,
|
|
67
74
|
url: uid,
|
|
68
75
|
type,
|
|
69
|
-
content,
|
|
76
|
+
content: rewriteHashtagLinks(content, siteUrl),
|
|
70
77
|
author,
|
|
71
78
|
published: props.published || props.date || new Date().toISOString(),
|
|
72
79
|
createdAt: props.published || props.date || new Date().toISOString(),
|
|
73
80
|
visibility: "public",
|
|
74
81
|
sensitive: false,
|
|
75
|
-
category:
|
|
76
|
-
photo: normalizeMediaArray(props.photo),
|
|
77
|
-
video: normalizeMediaArray(props.video),
|
|
78
|
-
audio: normalizeMediaArray(props.audio),
|
|
82
|
+
category: mergedCategories,
|
|
83
|
+
photo: normalizeMediaArray(props.photo, siteUrl),
|
|
84
|
+
video: normalizeMediaArray(props.video, siteUrl),
|
|
85
|
+
audio: normalizeMediaArray(props.audio, siteUrl),
|
|
79
86
|
readBy: [],
|
|
80
87
|
};
|
|
81
88
|
|
|
@@ -107,6 +114,55 @@ export async function backfillTimeline(collections) {
|
|
|
107
114
|
return { total: allPosts.length, inserted, skipped };
|
|
108
115
|
}
|
|
109
116
|
|
|
117
|
+
// ─── Content Building ─────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Build content from JF2 properties, synthesizing content for interaction types.
|
|
121
|
+
* Bookmarks, likes, and reposts often have no body text — show the target URL.
|
|
122
|
+
*/
|
|
123
|
+
function buildContent(props) {
|
|
124
|
+
const raw = normalizeContent(props.content);
|
|
125
|
+
|
|
126
|
+
// If there's already content, use it
|
|
127
|
+
if (raw.text || raw.html) return raw;
|
|
128
|
+
|
|
129
|
+
// Synthesize content for interaction types
|
|
130
|
+
const bookmarkOf = props["bookmark-of"];
|
|
131
|
+
const likeOf = props["like-of"];
|
|
132
|
+
const repostOf = props["repost-of"];
|
|
133
|
+
const name = props.name;
|
|
134
|
+
|
|
135
|
+
if (bookmarkOf) {
|
|
136
|
+
const label = name || bookmarkOf;
|
|
137
|
+
return {
|
|
138
|
+
text: `Bookmarked: ${label}`,
|
|
139
|
+
html: `<p>Bookmarked: <a href="${escapeHtml(bookmarkOf)}">${escapeHtml(label)}</a></p>`,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (likeOf) {
|
|
144
|
+
return {
|
|
145
|
+
text: `Liked: ${likeOf}`,
|
|
146
|
+
html: `<p>Liked: <a href="${escapeHtml(likeOf)}">${escapeHtml(likeOf)}</a></p>`,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (repostOf) {
|
|
151
|
+
const label = name || repostOf;
|
|
152
|
+
return {
|
|
153
|
+
text: `Reposted: ${label}`,
|
|
154
|
+
html: `<p>Reposted: <a href="${escapeHtml(repostOf)}">${escapeHtml(label)}</a></p>`,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Article with title but no body
|
|
159
|
+
if (name) {
|
|
160
|
+
return { text: name, html: `<p>${escapeHtml(name)}</p>` };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return raw;
|
|
164
|
+
}
|
|
165
|
+
|
|
110
166
|
/**
|
|
111
167
|
* Normalize content from JF2 properties to { text, html } format.
|
|
112
168
|
*/
|
|
@@ -122,6 +178,58 @@ function normalizeContent(content) {
|
|
|
122
178
|
return { text: "", html: "" };
|
|
123
179
|
}
|
|
124
180
|
|
|
181
|
+
// ─── Hashtag Handling ─────────────────────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Extract hashtags from text content.
|
|
185
|
+
* Matches #word patterns, returns lowercase tag names without the # prefix.
|
|
186
|
+
*/
|
|
187
|
+
function extractHashtags(text) {
|
|
188
|
+
if (!text) return [];
|
|
189
|
+
const matches = text.match(/(?:^|\s)#([a-zA-Z_]\w*)/g);
|
|
190
|
+
if (!matches) return [];
|
|
191
|
+
return [...new Set(matches.map((m) => m.trim().slice(1).toLowerCase()))];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Merge explicit categories with inline hashtags (deduplicated, case-insensitive).
|
|
196
|
+
*/
|
|
197
|
+
function mergeCategories(categories, hashtags) {
|
|
198
|
+
const seen = new Set(categories.map((c) => c.toLowerCase()));
|
|
199
|
+
const result = [...categories];
|
|
200
|
+
for (const tag of hashtags) {
|
|
201
|
+
if (!seen.has(tag)) {
|
|
202
|
+
seen.add(tag);
|
|
203
|
+
result.push(tag);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return result;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Rewrite hashtag links in HTML from site-internal (/categories/tag/) to
|
|
211
|
+
* Mastodon-compatible format. Mastodon clients use the tag objects, not
|
|
212
|
+
* inline links, but having correct href helps with link following.
|
|
213
|
+
*/
|
|
214
|
+
function rewriteHashtagLinks(content, siteUrl) {
|
|
215
|
+
if (!content.html) return content;
|
|
216
|
+
// Rewrite /categories/tag/ links to /tags/tag (Mastodon convention)
|
|
217
|
+
let html = content.html.replace(
|
|
218
|
+
/href="\/categories\/([^/"]+)\/?"/g,
|
|
219
|
+
(_, tag) => `href="${siteUrl}/tags/${tag}" class="hashtag" rel="tag"`,
|
|
220
|
+
);
|
|
221
|
+
// Also rewrite absolute site category links
|
|
222
|
+
if (siteUrl) {
|
|
223
|
+
html = html.replace(
|
|
224
|
+
new RegExp(`href="${escapeRegex(siteUrl)}/categories/([^/"]+)/?"`, "g"),
|
|
225
|
+
(_, tag) => `href="${siteUrl}/tags/${tag}" class="hashtag" rel="tag"`,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
return { ...content, html };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ─── Post Type Mapping ────────────────────────────────────────────────────────
|
|
232
|
+
|
|
125
233
|
/**
|
|
126
234
|
* Map Micropub post-type to timeline type.
|
|
127
235
|
*/
|
|
@@ -138,12 +246,15 @@ function mapPostType(postType) {
|
|
|
138
246
|
case "repost":
|
|
139
247
|
return "boost";
|
|
140
248
|
case "like":
|
|
249
|
+
case "bookmark":
|
|
141
250
|
return "note";
|
|
142
251
|
default:
|
|
143
252
|
return "note";
|
|
144
253
|
}
|
|
145
254
|
}
|
|
146
255
|
|
|
256
|
+
// ─── Normalization Helpers ────────────────────────────────────────────────────
|
|
257
|
+
|
|
147
258
|
/**
|
|
148
259
|
* Normalize a value to an array of strings.
|
|
149
260
|
*/
|
|
@@ -154,14 +265,47 @@ function normalizeArray(value) {
|
|
|
154
265
|
}
|
|
155
266
|
|
|
156
267
|
/**
|
|
157
|
-
* Normalize media values
|
|
268
|
+
* Normalize media values — resolves relative URLs to absolute.
|
|
269
|
+
*
|
|
270
|
+
* @param {*} value - String, object with url, or array thereof
|
|
271
|
+
* @param {string} siteUrl - Base site URL for resolving relative paths
|
|
158
272
|
*/
|
|
159
|
-
function normalizeMediaArray(value) {
|
|
273
|
+
function normalizeMediaArray(value, siteUrl) {
|
|
160
274
|
if (!value) return [];
|
|
161
275
|
const arr = Array.isArray(value) ? value : [value];
|
|
162
276
|
return arr.map((item) => {
|
|
163
|
-
if (typeof item === "string") return item;
|
|
164
|
-
if (typeof item === "object" && item.url)
|
|
277
|
+
if (typeof item === "string") return resolveUrl(item, siteUrl);
|
|
278
|
+
if (typeof item === "object" && item.url) {
|
|
279
|
+
return { ...item, url: resolveUrl(item.url, siteUrl) };
|
|
280
|
+
}
|
|
165
281
|
return null;
|
|
166
282
|
}).filter(Boolean);
|
|
167
283
|
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Resolve a URL — if relative, prepend the site URL.
|
|
287
|
+
*/
|
|
288
|
+
function resolveUrl(url, siteUrl) {
|
|
289
|
+
if (!url) return url;
|
|
290
|
+
if (url.startsWith("http://") || url.startsWith("https://")) return url;
|
|
291
|
+
if (url.startsWith("/")) return `${siteUrl}${url}`;
|
|
292
|
+
return `${siteUrl}/${url}`;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Escape HTML entities.
|
|
297
|
+
*/
|
|
298
|
+
function escapeHtml(str) {
|
|
299
|
+
return str
|
|
300
|
+
.replace(/&/g, "&")
|
|
301
|
+
.replace(/</g, "<")
|
|
302
|
+
.replace(/>/g, ">")
|
|
303
|
+
.replace(/"/g, """);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Escape regex special characters.
|
|
308
|
+
*/
|
|
309
|
+
function escapeRegex(str) {
|
|
310
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
311
|
+
}
|
|
@@ -219,6 +219,34 @@ router.post("/api/v1/statuses", async (req, res, next) => {
|
|
|
219
219
|
const handle = pluginOptions.handle || "user";
|
|
220
220
|
const actorUrl = profile?.url || `${publicationUrl}/users/${handle}`;
|
|
221
221
|
|
|
222
|
+
// Extract hashtags from status text and merge with any Micropub categories
|
|
223
|
+
const categories = data.properties.category || [];
|
|
224
|
+
const inlineHashtags = (statusText || "").match(/(?:^|\s)#([a-zA-Z_]\w*)/g);
|
|
225
|
+
if (inlineHashtags) {
|
|
226
|
+
const existing = new Set(categories.map((c) => c.toLowerCase()));
|
|
227
|
+
for (const match of inlineHashtags) {
|
|
228
|
+
const tag = match.trim().slice(1).toLowerCase();
|
|
229
|
+
if (!existing.has(tag)) {
|
|
230
|
+
existing.add(tag);
|
|
231
|
+
categories.push(tag);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Resolve relative media URLs to absolute
|
|
237
|
+
const resolveMedia = (items) => {
|
|
238
|
+
if (!items || !items.length) return [];
|
|
239
|
+
return items.map((item) => {
|
|
240
|
+
if (typeof item === "string") {
|
|
241
|
+
return item.startsWith("http") ? item : `${publicationUrl.replace(/\/$/, "")}/${item.replace(/^\//, "")}`;
|
|
242
|
+
}
|
|
243
|
+
if (item?.url && !item.url.startsWith("http")) {
|
|
244
|
+
return { ...item, url: `${publicationUrl.replace(/\/$/, "")}/${item.url.replace(/^\//, "")}` };
|
|
245
|
+
}
|
|
246
|
+
return item;
|
|
247
|
+
});
|
|
248
|
+
};
|
|
249
|
+
|
|
222
250
|
const now = new Date().toISOString();
|
|
223
251
|
const timelineItem = await addTimelineItem(collections, {
|
|
224
252
|
uid: postUrl,
|
|
@@ -234,16 +262,16 @@ router.post("/api/v1/statuses", async (req, res, next) => {
|
|
|
234
262
|
createdAt: now,
|
|
235
263
|
author: {
|
|
236
264
|
name: profile?.name || handle,
|
|
237
|
-
url:
|
|
265
|
+
url: profile?.url || publicationUrl,
|
|
238
266
|
photo: profile?.icon || "",
|
|
239
267
|
handle: `@${handle}`,
|
|
240
268
|
emojis: [],
|
|
241
269
|
bot: false,
|
|
242
270
|
},
|
|
243
|
-
photo: data.properties.photo || [],
|
|
244
|
-
video: data.properties.video || [],
|
|
245
|
-
audio: data.properties.audio || [],
|
|
246
|
-
category:
|
|
271
|
+
photo: resolveMedia(data.properties.photo || []),
|
|
272
|
+
video: resolveMedia(data.properties.video || []),
|
|
273
|
+
audio: resolveMedia(data.properties.audio || []),
|
|
274
|
+
category: categories,
|
|
247
275
|
counts: { replies: 0, boosts: 0, likes: 0 },
|
|
248
276
|
linkPreviews: [],
|
|
249
277
|
mentions: [],
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rmdes/indiekit-endpoint-activitypub",
|
|
3
|
-
"version": "3.6.
|
|
3
|
+
"version": "3.6.3",
|
|
4
4
|
"description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"indiekit",
|