@rmdes/indiekit-endpoint-activitypub 2.0.31 → 2.0.33

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.
package/assets/reader.css CHANGED
@@ -4,6 +4,37 @@
4
4
  * Uses Indiekit CSS custom properties for automatic dark mode support
5
5
  */
6
6
 
7
+ /* ==========================================================================
8
+ Breadcrumb Navigation
9
+ ========================================================================== */
10
+
11
+ .ap-breadcrumb {
12
+ display: flex;
13
+ align-items: center;
14
+ gap: var(--space-xs);
15
+ margin-bottom: var(--space-m);
16
+ font-size: var(--font-size-s);
17
+ color: var(--color-on-offset);
18
+ }
19
+
20
+ .ap-breadcrumb a {
21
+ color: var(--color-accent);
22
+ text-decoration: none;
23
+ }
24
+
25
+ .ap-breadcrumb a:hover {
26
+ text-decoration: underline;
27
+ }
28
+
29
+ .ap-breadcrumb__separator {
30
+ color: var(--color-on-offset);
31
+ }
32
+
33
+ .ap-breadcrumb__current {
34
+ color: var(--color-on-background);
35
+ font-weight: var(--font-weight-bold);
36
+ }
37
+
7
38
  /* ==========================================================================
8
39
  Fediverse Lookup
9
40
  ========================================================================== */
@@ -12,6 +12,7 @@ export function activitiesController(mountPath) {
12
12
  if (!collection) {
13
13
  return response.render("activitypub-activities", {
14
14
  title: response.locals.__("activitypub.activities"),
15
+ parent: { href: mountPath, text: response.locals.__("activitypub.title") },
15
16
  activities: [],
16
17
  mountPath,
17
18
  });
@@ -32,6 +33,7 @@ export function activitiesController(mountPath) {
32
33
 
33
34
  response.render("activitypub-activities", {
34
35
  title: response.locals.__("activitypub.activities"),
36
+ parent: { href: mountPath, text: response.locals.__("activitypub.title") },
35
37
  activities,
36
38
  mountPath,
37
39
  cursor,
@@ -141,6 +141,7 @@ export function composeController(mountPath, plugin) {
141
141
 
142
142
  response.render("activitypub-compose", {
143
143
  title: response.locals.__("activitypub.compose.title"),
144
+ readerParent: { href: `${mountPath}/admin/reader`, text: response.locals.__("activitypub.reader.title") },
144
145
  replyTo,
145
146
  replyContext,
146
147
  syndicationTargets,
@@ -141,10 +141,13 @@ export function exploreController(mountPath) {
141
141
  const csrfToken = getToken(request.session);
142
142
  const deckCount = decks.length;
143
143
 
144
+ const readerParent = { href: `${mountPath}/admin/reader`, text: response.locals.__("activitypub.reader.title") };
145
+
144
146
  // No instance specified — render clean initial page (no error)
145
147
  if (!rawInstance.trim()) {
146
148
  return response.render("activitypub-explore", {
147
149
  title: response.locals.__("activitypub.reader.explore.title"),
150
+ readerParent,
148
151
  instance: "",
149
152
  scope,
150
153
  items: [],
@@ -163,6 +166,7 @@ export function exploreController(mountPath) {
163
166
  if (!instance) {
164
167
  return response.render("activitypub-explore", {
165
168
  title: response.locals.__("activitypub.reader.explore.title"),
169
+ readerParent,
166
170
  instance: rawInstance,
167
171
  scope,
168
172
  items: [],
@@ -228,6 +232,7 @@ export function exploreController(mountPath) {
228
232
 
229
233
  response.render("activitypub-explore", {
230
234
  title: response.locals.__("activitypub.reader.explore.title"),
235
+ readerParent,
231
236
  instance,
232
237
  scope,
233
238
  items,
@@ -15,6 +15,7 @@ export function featuredTagsGetController(mountPath) {
15
15
  response.render("activitypub-featured-tags", {
16
16
  title:
17
17
  response.locals.__("activitypub.featuredTags") || "Featured Tags",
18
+ parent: { href: mountPath, text: response.locals.__("activitypub.title") },
18
19
  tags,
19
20
  mountPath,
20
21
  });
@@ -57,6 +57,7 @@ export function featuredGetController(mountPath) {
57
57
 
58
58
  response.render("activitypub-featured", {
59
59
  title: response.locals.__("activitypub.featured") || "Pinned Posts",
60
+ parent: { href: mountPath, text: response.locals.__("activitypub.title") },
60
61
  pinned,
61
62
  availablePosts,
62
63
  maxPins: MAX_PINS,
@@ -12,6 +12,7 @@ export function followersController(mountPath) {
12
12
  if (!collection) {
13
13
  return response.render("activitypub-followers", {
14
14
  title: response.locals.__("activitypub.followers"),
15
+ parent: { href: mountPath, text: response.locals.__("activitypub.title") },
15
16
  followers: [],
16
17
  followerCount: 0,
17
18
  mountPath,
@@ -33,6 +34,7 @@ export function followersController(mountPath) {
33
34
 
34
35
  response.render("activitypub-followers", {
35
36
  title: `${totalCount} ${response.locals.__("activitypub.followers")}`,
37
+ parent: { href: mountPath, text: response.locals.__("activitypub.title") },
36
38
  followers,
37
39
  followerCount: totalCount,
38
40
  mountPath,
@@ -12,6 +12,7 @@ export function followingController(mountPath) {
12
12
  if (!collection) {
13
13
  return response.render("activitypub-following", {
14
14
  title: response.locals.__("activitypub.following"),
15
+ parent: { href: mountPath, text: response.locals.__("activitypub.title") },
15
16
  following: [],
16
17
  followingCount: 0,
17
18
  mountPath,
@@ -33,6 +34,7 @@ export function followingController(mountPath) {
33
34
 
34
35
  response.render("activitypub-following", {
35
36
  title: `${totalCount} ${response.locals.__("activitypub.following")}`,
37
+ parent: { href: mountPath, text: response.locals.__("activitypub.title") },
36
38
  following,
37
39
  followingCount: totalCount,
38
40
  mountPath,
@@ -24,6 +24,7 @@ export function migrateGetController(mountPath, pluginOptions) {
24
24
 
25
25
  response.render("activitypub-migrate", {
26
26
  title: response.locals.__("activitypub.migrate.title"),
27
+ parent: { href: mountPath, text: response.locals.__("activitypub.title") },
27
28
  mountPath,
28
29
  currentAlias,
29
30
  result: null,
@@ -61,6 +62,7 @@ export function migratePostController(mountPath, pluginOptions) {
61
62
 
62
63
  response.render("activitypub-migrate", {
63
64
  title: response.locals.__("activitypub.migrate.title"),
65
+ parent: { href: mountPath, text: response.locals.__("activitypub.title") },
64
66
  mountPath,
65
67
  currentAlias,
66
68
  result,
@@ -301,6 +301,7 @@ export function moderationController(mountPath) {
301
301
 
302
302
  response.render("activitypub-moderation", {
303
303
  title: response.locals.__("activitypub.moderation.title"),
304
+ readerParent: { href: `${mountPath}/admin/reader`, text: response.locals.__("activitypub.reader.title") },
304
305
  muted,
305
306
  blocked,
306
307
  mutedActors,
@@ -219,6 +219,7 @@ export function myProfileController(plugin) {
219
219
 
220
220
  response.render("activitypub-my-profile", {
221
221
  title: response.locals.__("activitypub.myProfile.title"),
222
+ readerParent: { href: mountPath, text: response.locals.__("activitypub.title") },
222
223
  profile: profile || {},
223
224
  handle,
224
225
  domain,
@@ -197,6 +197,7 @@ export function postDetailController(mountPath, plugin) {
197
197
  // Truly not found (no local item either)
198
198
  return response.status(404).render("activitypub-post-detail", {
199
199
  title: response.locals.__("activitypub.reader.post.title"),
200
+ readerParent: { href: `${mountPath}/admin/reader`, text: response.locals.__("activitypub.reader.title") },
200
201
  notFound: true, objectUrl, mountPath,
201
202
  item: null, interactionMap: {}, csrfToken: null,
202
203
  parentPosts: [], replyPosts: [],
@@ -318,6 +319,7 @@ export function postDetailController(mountPath, plugin) {
318
319
 
319
320
  response.render("activitypub-post-detail", {
320
321
  title: response.locals.__("activitypub.reader.post.title"),
322
+ readerParent: { href: `${mountPath}/admin/reader`, text: response.locals.__("activitypub.reader.title") },
321
323
  item: timelineItem,
322
324
  interactionMap,
323
325
  csrfToken,
@@ -18,6 +18,7 @@ export function profileGetController(mountPath) {
18
18
 
19
19
  response.render("activitypub-profile", {
20
20
  title: response.locals.__("activitypub.profile.title"),
21
+ parent: { href: mountPath, text: response.locals.__("activitypub.title") },
21
22
  mountPath,
22
23
  profile,
23
24
  actorTypes: ACTOR_TYPES,
@@ -96,6 +97,7 @@ export function profilePostController(mountPath, plugin) {
96
97
 
97
98
  response.render("activitypub-profile", {
98
99
  title: response.locals.__("activitypub.profile.title"),
100
+ parent: { href: mountPath, text: response.locals.__("activitypub.title") },
99
101
  mountPath,
100
102
  profile,
101
103
  actorTypes: ACTOR_TYPES,
@@ -126,6 +126,7 @@ export function remoteProfileController(mountPath, plugin) {
126
126
 
127
127
  response.render("activitypub-remote-profile", {
128
128
  title: name,
129
+ readerParent: { href: `${mountPath}/admin/reader`, text: response.locals.__("activitypub.reader.title") },
129
130
  actorUrl,
130
131
  name,
131
132
  actorHandle,
@@ -203,6 +203,7 @@ export function readerController(mountPath) {
203
203
 
204
204
  response.render("activitypub-reader", {
205
205
  title: response.locals.__("activitypub.reader.title"),
206
+ readerParent: { href: mountPath, text: response.locals.__("activitypub.title") },
206
207
  items,
207
208
  tab,
208
209
  before: result.before,
@@ -253,6 +254,7 @@ export function notificationsController(mountPath) {
253
254
 
254
255
  response.render("activitypub-notifications", {
255
256
  title: response.locals.__("activitypub.notifications.title"),
257
+ readerParent: { href: `${mountPath}/admin/reader`, text: response.locals.__("activitypub.reader.title") },
256
258
  items: result.items,
257
259
  before: result.before,
258
260
  tab,
@@ -131,6 +131,7 @@ export function tagTimelineController(mountPath) {
131
131
 
132
132
  response.render("activitypub-tag-timeline", {
133
133
  title: `#${tag}`,
134
+ readerParent: { href: `${mountPath}/admin/reader`, text: response.locals.__("activitypub.reader.title") },
134
135
  tag,
135
136
  items,
136
137
  before: result.before,
package/lib/fedidb.js CHANGED
@@ -2,10 +2,13 @@
2
2
  * FediDB API client with MongoDB caching.
3
3
  *
4
4
  * Wraps https://api.fedidb.org/v1/ endpoints:
5
- * - /servers?q=...search known fediverse instances
5
+ * - /servers — cursor-paginated list of known fediverse instances (ranked by size)
6
6
  * - /popular-accounts — top accounts by follower count
7
7
  *
8
- * Responses are cached in ap_kv to avoid hitting the API on every keystroke.
8
+ * NOTE: The /servers endpoint ignores query params (q, search, name) and always
9
+ * returns the same ranked list. We paginate through ~500 servers, cache the full
10
+ * corpus for 24 hours, and filter locally when the user searches.
11
+ *
9
12
  * Cache TTL: 24 hours for both datasets.
10
13
  */
11
14
 
@@ -71,46 +74,90 @@ async function writeToCache(kvCollection, cacheKey, data) {
71
74
  }
72
75
 
73
76
  /**
74
- * Search FediDB for instances matching a query.
75
- * Returns a flat array of { domain, software, description, mau, openRegistration }.
77
+ * Fetch the FediDB server catalogue by paginating through cursor-based results.
78
+ * Cached for 24 hours as a single entry. The API ignores the `q` param and
79
+ * always returns a ranked list, so we collect a large corpus and filter locally.
76
80
  *
77
- * Results are cached per normalized query for 24 hours.
81
+ * Paginates up to MAX_PAGES (13 pages × 40 = ~520 servers), which covers
82
+ * all well-known instances. Results are cached in ap_kv for 24 hours.
78
83
  *
79
84
  * @param {object} kvCollection - MongoDB ap_kv collection
80
- * @param {string} query - Search term (e.g. "mast")
81
- * @param {number} [limit=10] - Max results
82
85
  * @returns {Promise<Array>}
83
86
  */
84
- export async function searchInstances(kvCollection, query, limit = 10) {
85
- const q = (query || "").trim().toLowerCase();
86
- if (!q) return [];
87
+ const MAX_PAGES = 13;
87
88
 
88
- const cacheKey = `fedidb:instances:${q}:${limit}`;
89
+ async function getAllServers(kvCollection) {
90
+ const cacheKey = "fedidb:servers-all";
89
91
  const cached = await getFromCache(kvCollection, cacheKey);
90
92
  if (cached) return cached;
91
93
 
94
+ const results = [];
95
+
92
96
  try {
93
- const url = `${API_BASE}/servers?q=${encodeURIComponent(q)}&limit=${limit}`;
94
- const res = await fetchWithTimeout(url);
95
- if (!res.ok) return [];
97
+ let cursor = null;
98
+
99
+ for (let page = 0; page < MAX_PAGES; page++) {
100
+ let url = `${API_BASE}/servers?limit=40`;
101
+ if (cursor) url += `&cursor=${cursor}`;
102
+
103
+ const res = await fetchWithTimeout(url);
104
+ if (!res.ok) break;
105
+
106
+ const json = await res.json();
107
+ const servers = json.data || [];
108
+ if (servers.length === 0) break;
109
+
110
+ for (const s of servers) {
111
+ results.push({
112
+ domain: s.domain,
113
+ software: s.software?.name || "Unknown",
114
+ description: s.description || "",
115
+ mau: s.stats?.monthly_active_users || 0,
116
+ userCount: s.stats?.user_count || 0,
117
+ openRegistration: s.open_registration || false,
118
+ });
119
+ }
96
120
 
97
- const json = await res.json();
98
- const servers = json.data || [];
99
-
100
- const results = servers.map((s) => ({
101
- domain: s.domain,
102
- software: s.software?.name || "Unknown",
103
- description: s.description || "",
104
- mau: s.stats?.monthly_active_users || 0,
105
- userCount: s.stats?.user_count || 0,
106
- openRegistration: s.open_registration || false,
107
- }));
121
+ cursor = json.meta?.next_cursor;
122
+ if (!cursor) break;
123
+ }
108
124
 
109
- await writeToCache(kvCollection, cacheKey, results);
110
- return results;
125
+ if (results.length > 0) {
126
+ await writeToCache(kvCollection, cacheKey, results);
127
+ }
111
128
  } catch {
112
- return [];
129
+ // Return whatever we collected so far
113
130
  }
131
+
132
+ return results;
133
+ }
134
+
135
+ /**
136
+ * Search FediDB for instances matching a query.
137
+ * Returns a flat array of { domain, software, description, mau, openRegistration }.
138
+ *
139
+ * Fetches the full server list once (cached 24h) and filters by domain/software match.
140
+ * FediDB's /v1/servers endpoint ignores the `q` param and always returns a static
141
+ * ranked list, so server-side filtering is the only way to get relevant results.
142
+ *
143
+ * @param {object} kvCollection - MongoDB ap_kv collection
144
+ * @param {string} query - Search term (e.g. "mast")
145
+ * @param {number} [limit=10] - Max results
146
+ * @returns {Promise<Array>}
147
+ */
148
+ export async function searchInstances(kvCollection, query, limit = 10) {
149
+ const q = (query || "").trim().toLowerCase();
150
+ if (!q) return [];
151
+
152
+ const allServers = await getAllServers(kvCollection);
153
+
154
+ return allServers
155
+ .filter(
156
+ (s) =>
157
+ s.domain.toLowerCase().includes(q) ||
158
+ s.software.toLowerCase().includes(q),
159
+ )
160
+ .slice(0, limit);
114
161
  }
115
162
 
116
163
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-activitypub",
3
- "version": "2.0.31",
3
+ "version": "2.0.33",
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",
@@ -18,6 +18,14 @@
18
18
  {# AP link interception for internal navigation #}
19
19
  <script defer src="/assets/@rmdes-indiekit-endpoint-activitypub/reader-links.js"></script>
20
20
 
21
+ {% if readerParent %}
22
+ <nav class="ap-breadcrumb" aria-label="Breadcrumb">
23
+ <a href="{{ readerParent.href }}">{{ readerParent.text }}</a>
24
+ <span class="ap-breadcrumb__separator" aria-hidden="true">/</span>
25
+ <span class="ap-breadcrumb__current" aria-current="page">{{ title }}</span>
26
+ </nav>
27
+ {% endif %}
28
+
21
29
  {% block readercontent %}
22
30
  {% endblock %}
23
31
  {% endblock %}