@rmdes/indiekit-endpoint-microsub 1.0.65 → 1.0.66

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/styles.css CHANGED
@@ -1379,6 +1379,14 @@
1379
1379
  white-space: nowrap;
1380
1380
  }
1381
1381
 
1382
+ .ms-item-card-compact__favicon {
1383
+ border-radius: 3px;
1384
+ height: 16px;
1385
+ object-fit: cover;
1386
+ vertical-align: -3px;
1387
+ width: 16px;
1388
+ }
1389
+
1382
1390
  .ms-item-card-compact__date {
1383
1391
  flex-shrink: 0;
1384
1392
  white-space: nowrap;
@@ -149,6 +149,11 @@ export async function processFeed(application, feed) {
149
149
  if (parsed.feedType && !feed.feedType) {
150
150
  updateData.feedType = parsed.feedType;
151
151
  }
152
+ // Site homepage URL (parsers set parsed.url from channel <link>,
153
+ // home_page_url, etc.) — used for source attribution and favicon fallback
154
+ if (parsed.url && parsed.url !== feed.url && !feed.siteUrl) {
155
+ updateData.siteUrl = parsed.url;
156
+ }
152
157
 
153
158
  await updateFeedAfterFetch(
154
159
  application,
@@ -121,6 +121,73 @@ export function transformToJf2(item, userId) {
121
121
  return jf2;
122
122
  }
123
123
 
124
+ /**
125
+ * Derive a favicon URL from a site or feed URL
126
+ * @param {string} url - Site or feed URL
127
+ * @returns {string|undefined} Favicon URL at the site origin
128
+ */
129
+ function faviconUrl(url) {
130
+ try {
131
+ return new URL(url).origin + "/favicon.ico";
132
+ } catch {
133
+ return;
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Enrich jf2 items with source identity (name, photo, url) from their feeds.
139
+ * Read-time join against microsub_feeds so existing items pick up feed
140
+ * metadata (title, channel image, site URL) without a migration, and stay
141
+ * fresh when a feed's metadata improves. Falls back to the site favicon
142
+ * when the feed has no image.
143
+ * @param {object} application - Indiekit application
144
+ * @param {Array<object>} items - jf2 items (mutated in place)
145
+ * @returns {Promise<Array<object>>} The same items, enriched
146
+ */
147
+ export async function enrichItemsWithFeedSource(application, items) {
148
+ const feedIds = [
149
+ ...new Set(items.map((item) => item._feedId).filter(Boolean)),
150
+ ];
151
+ if (feedIds.length === 0) {
152
+ return items;
153
+ }
154
+
155
+ const feeds = await application.collections
156
+ .get("microsub_feeds")
157
+ .find({ _id: { $in: feedIds.map((id) => new ObjectId(id)) } })
158
+ .project({ title: 1, photo: 1, url: 1, siteUrl: 1 })
159
+ .toArray();
160
+ const feedsById = new Map(feeds.map((feed) => [feed._id.toString(), feed]));
161
+
162
+ for (const item of items) {
163
+ const feed = feedsById.get(item._feedId);
164
+ if (!feed) continue;
165
+
166
+ const siteUrl = feed.siteUrl || feed.url;
167
+ item._source = {
168
+ ...item._source,
169
+ name: feed.title || item._source?.name || safeHostname(siteUrl),
170
+ photo: feed.photo || faviconUrl(siteUrl),
171
+ url: siteUrl,
172
+ };
173
+ }
174
+
175
+ return items;
176
+ }
177
+
178
+ /**
179
+ * Extract hostname from a URL, or return the input on parse failure
180
+ * @param {string} url - URL string
181
+ * @returns {string} Hostname
182
+ */
183
+ function safeHostname(url) {
184
+ try {
185
+ return new URL(url).hostname;
186
+ } catch {
187
+ return url;
188
+ }
189
+ }
190
+
124
191
  /**
125
192
  * Add an item to a channel
126
193
  * @param {object} application - Indiekit application
@@ -218,6 +285,7 @@ export async function getTimelineItems(application, channelId, options = {}) {
218
285
 
219
286
  // Transform to jf2 format
220
287
  const jf2Items = items.map((item) => transformToJf2(item, options.userId));
288
+ await enrichItemsWithFeedSource(application, jf2Items);
221
289
 
222
290
  // Generate paging cursors
223
291
  const paging = generatePagingCursors(items, limit, hasMore, options.before);
@@ -282,6 +350,7 @@ export async function getAllTimelineItems(application, options = {}) {
282
350
  }
283
351
 
284
352
  const jf2Items = items.map((item) => transformToJf2(item, options.userId));
353
+ await enrichItemsWithFeedSource(application, jf2Items);
285
354
 
286
355
  const paging = generatePagingCursors(items, limit, hasMore, options.before);
287
356
 
@@ -320,7 +389,9 @@ export async function getItemById(application, id, userId) {
320
389
  return;
321
390
  }
322
391
 
323
- return transformToJf2(item, userId);
392
+ const jf2 = transformToJf2(item, userId);
393
+ await enrichItemsWithFeedSource(application, [jf2]);
394
+ return jf2;
324
395
  }
325
396
 
326
397
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-microsub",
3
- "version": "1.0.65",
3
+ "version": "1.0.66",
4
4
  "description": "Microsub endpoint for Indiekit. Enables subscribing to feeds and reading content using the Microsub protocol.",
5
5
  "keywords": [
6
6
  "indiekit",
@@ -18,7 +18,10 @@
18
18
  {% endif %}
19
19
  <div class="ms-item-card-compact__meta">
20
20
  {% if item._source %}
21
- <span class="ms-item-card-compact__source">{{ item._source.name or item._source.url }}</span>
21
+ <span class="ms-item-card-compact__source">
22
+ {%- if item._source.photo %}<img src="{{ item._source.photo }}" alt="" class="ms-item-card-compact__favicon" width="16" height="16" loading="lazy"> {% endif -%}
23
+ {{- item._source.name or item._source.url -}}
24
+ </span>
22
25
  {% elif item.author %}
23
26
  <span class="ms-item-card-compact__source">{{ item.author.name }}</span>
24
27
  {% endif %}
@@ -50,25 +50,27 @@
50
50
  {% endif %}
51
51
 
52
52
  <a href="{{ baseUrl }}/item/{{ item._id }}" class="ms-item-card__link">
53
- {# Author #}
54
- {% if item.author %}
53
+ {# Author / source identity — prefer item author, fall back to feed source #}
54
+ {% set identityPhoto = item.author.photo or item._source.photo %}
55
+ {% set identityName = item.author.name or item._source.name %}
56
+ {% if identityName or identityPhoto %}
55
57
  <div class="ms-item-card__author">
56
58
  <div class="ms-item-card__avatar-wrap" data-avatar-fallback>
57
- {% if item.author.photo %}
58
- <img src="{{ item.author.photo }}"
59
+ {% if identityPhoto %}
60
+ <img src="{{ identityPhoto }}"
59
61
  alt=""
60
62
  class="ms-item-card__author-photo"
61
63
  width="40"
62
64
  height="40"
63
65
  loading="lazy">
64
66
  {% endif %}
65
- <span class="ms-item-card__author-photo ms-item-card__author-photo--default" aria-hidden="true">{{ item.author.name[0] | upper if item.author.name else "?" }}</span>
67
+ <span class="ms-item-card__author-photo ms-item-card__author-photo--default" aria-hidden="true">{{ identityName[0] | upper if identityName else "?" }}</span>
66
68
  </div>
67
69
  <div class="ms-item-card__author-info">
68
- <span class="ms-item-card__author-name">{{ item.author.name or "Unknown" }}</span>
69
- {% if item._source %}
70
+ <span class="ms-item-card__author-name">{{ identityName or "Unknown" }}</span>
71
+ {% if item._source and (item._source.name or item._source.url) and item._source.name != identityName %}
70
72
  <span class="ms-item-card__source">{{ item._source.name or item._source.url }}</span>
71
- {% elif item.author.url %}
73
+ {% elif item.author.url and item.author.name %}
72
74
  <span class="ms-item-card__source">{{ item.author.url | replace("https://", "") | replace("http://", "") }}</span>
73
75
  {% endif %}
74
76
  </div>