@rmdes/indiekit-endpoint-microsub 1.0.65 → 1.0.67
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 +8 -0
- package/lib/feeds/normalizer-rss.js +13 -0
- package/lib/feeds/source-meta.js +46 -0
- package/lib/polling/processor.js +22 -0
- package/lib/storage/items.js +72 -1
- package/package.json +1 -1
- package/views/partials/item-card-compact.njk +4 -1
- package/views/partials/item-card.njk +10 -8
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;
|
|
@@ -62,6 +62,19 @@ export function normalizeItem(item, feedUrl, feedType) {
|
|
|
62
62
|
};
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
// Item-level <source> (RSS 2.0) — per-item origin in aggregator feeds
|
|
66
|
+
// (e.g. a community feed where every item comes from a different user's
|
|
67
|
+
// feed). Feedparser exposes it as { url, title }.
|
|
68
|
+
if (item.source && (item.source.url || item.source.title)) {
|
|
69
|
+
normalized._source.itemSource = {
|
|
70
|
+
url: item.source.url,
|
|
71
|
+
title: item.source.title,
|
|
72
|
+
};
|
|
73
|
+
if (!normalized.author && item.source.title) {
|
|
74
|
+
normalized.author = { type: "card", name: item.source.title };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
65
78
|
// Categories/tags
|
|
66
79
|
if (item.categories && item.categories.length > 0) {
|
|
67
80
|
normalized.category = item.categories;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve metadata (name, photo, site url) of an item-level <source> feed.
|
|
3
|
+
* Aggregator feeds attribute each item to its originating feed via the
|
|
4
|
+
* RSS 2.0 <source url="..."> element; fetching that feed's channel image
|
|
5
|
+
* gives us the author's avatar. Results are cached in memory so each
|
|
6
|
+
* distinct source feed is fetched at most once per TTL.
|
|
7
|
+
* @module feeds/source-meta
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { fetchAndParseFeed } from "./fetcher.js";
|
|
11
|
+
|
|
12
|
+
const TTL = 24 * 60 * 60 * 1000; // 24h — feed titles/avatars change rarely
|
|
13
|
+
const MAX_CACHE_ENTRIES = 500;
|
|
14
|
+
|
|
15
|
+
/** @type {Map<string, { promise: Promise<object|undefined>, fetchedAt: number }>} */
|
|
16
|
+
const cache = new Map();
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Resolve a source feed's metadata, cached
|
|
20
|
+
* @param {string} url - Source feed URL
|
|
21
|
+
* @returns {Promise<object|undefined>} { name, photo, url } or undefined on failure
|
|
22
|
+
*/
|
|
23
|
+
export async function resolveSourceFeedMeta(url) {
|
|
24
|
+
const now = Date.now();
|
|
25
|
+
const hit = cache.get(url);
|
|
26
|
+
if (hit && now - hit.fetchedAt < TTL) {
|
|
27
|
+
return hit.promise;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ponytail: crude full-clear eviction; LRU if churn ever matters
|
|
31
|
+
if (cache.size >= MAX_CACHE_ENTRIES) {
|
|
32
|
+
cache.clear();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const promise = fetchAndParseFeed(url, { timeout: 10_000 })
|
|
36
|
+
.then((parsed) => ({
|
|
37
|
+
name: parsed.name,
|
|
38
|
+
photo: parsed.photo,
|
|
39
|
+
url: parsed.url,
|
|
40
|
+
}))
|
|
41
|
+
// Failures are cached too (negative cache) — avoids hammering dead feeds
|
|
42
|
+
.catch(() => {});
|
|
43
|
+
|
|
44
|
+
cache.set(url, { promise, fetchedAt: now });
|
|
45
|
+
return promise;
|
|
46
|
+
}
|
package/lib/polling/processor.js
CHANGED
|
@@ -9,6 +9,7 @@ const MAX_ITEMS_PER_CYCLE = 100; // Max items to process per feed per cycle
|
|
|
9
9
|
import { getRedisClient, publishEvent } from "../cache/redis.js";
|
|
10
10
|
import { detectCapabilities } from "../feeds/capabilities.js";
|
|
11
11
|
import { fetchAndParseFeed } from "../feeds/fetcher.js";
|
|
12
|
+
import { resolveSourceFeedMeta } from "../feeds/source-meta.js";
|
|
12
13
|
import { getChannelById } from "../storage/channels.js";
|
|
13
14
|
import {
|
|
14
15
|
updateFeed,
|
|
@@ -100,6 +101,22 @@ export async function processFeed(application, feed) {
|
|
|
100
101
|
item._source.source_type = classifyUrl(feed.url).type;
|
|
101
102
|
}
|
|
102
103
|
|
|
104
|
+
// Aggregator feeds: item-level <source> names the originating feed.
|
|
105
|
+
// Resolve its channel image/homepage (cached, one fetch per source
|
|
106
|
+
// feed per day) so the card shows the actual author's avatar.
|
|
107
|
+
const itemSource = item._source.itemSource;
|
|
108
|
+
if (itemSource?.url && !item.author?.photo) {
|
|
109
|
+
const meta = await resolveSourceFeedMeta(itemSource.url);
|
|
110
|
+
if (meta) {
|
|
111
|
+
item.author = {
|
|
112
|
+
type: "card",
|
|
113
|
+
name: item.author?.name || itemSource.title || meta.name,
|
|
114
|
+
url: item.author?.url || meta.url,
|
|
115
|
+
photo: meta.photo,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
103
120
|
// Store the item
|
|
104
121
|
const stored = await addItem(application, {
|
|
105
122
|
channelId: feed.channelId,
|
|
@@ -149,6 +166,11 @@ export async function processFeed(application, feed) {
|
|
|
149
166
|
if (parsed.feedType && !feed.feedType) {
|
|
150
167
|
updateData.feedType = parsed.feedType;
|
|
151
168
|
}
|
|
169
|
+
// Site homepage URL (parsers set parsed.url from channel <link>,
|
|
170
|
+
// home_page_url, etc.) — used for source attribution and favicon fallback
|
|
171
|
+
if (parsed.url && parsed.url !== feed.url && !feed.siteUrl) {
|
|
172
|
+
updateData.siteUrl = parsed.url;
|
|
173
|
+
}
|
|
152
174
|
|
|
153
175
|
await updateFeedAfterFetch(
|
|
154
176
|
application,
|
package/lib/storage/items.js
CHANGED
|
@@ -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
|
-
|
|
392
|
+
const jf2 = transformToJf2(item, userId);
|
|
393
|
+
await enrichItemsWithFeedSource(application, [jf2]);
|
|
394
|
+
return jf2;
|
|
324
395
|
}
|
|
325
396
|
|
|
326
397
|
/**
|
package/package.json
CHANGED
|
@@ -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">
|
|
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
|
-
{%
|
|
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
|
|
58
|
-
<img src="{{
|
|
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">{{
|
|
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">{{
|
|
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>
|