@rmdes/indiekit-endpoint-microsub 1.0.56 → 1.0.58

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.
Files changed (51) hide show
  1. package/assets/reader.js +408 -0
  2. package/index.js +37 -36
  3. package/lib/cache/redis.js +12 -3
  4. package/lib/controllers/reader/actor.js +142 -0
  5. package/lib/controllers/reader/channel.js +301 -0
  6. package/lib/controllers/reader/compose.js +242 -0
  7. package/lib/controllers/reader/deck.js +129 -0
  8. package/lib/controllers/reader/feed-repair.js +117 -0
  9. package/lib/controllers/reader/feed.js +246 -0
  10. package/lib/controllers/reader/index.js +126 -0
  11. package/lib/controllers/reader/search.js +157 -0
  12. package/lib/controllers/reader/timeline.js +251 -0
  13. package/lib/controllers/timeline.js +4 -2
  14. package/lib/feeds/atom.js +1 -1
  15. package/lib/feeds/fetcher.js +1 -30
  16. package/lib/feeds/hfeed.js +1 -1
  17. package/lib/feeds/jsonfeed.js +1 -1
  18. package/lib/feeds/normalizer-hfeed.js +209 -0
  19. package/lib/feeds/normalizer-jsonfeed.js +171 -0
  20. package/lib/feeds/normalizer-rss.js +178 -0
  21. package/lib/feeds/normalizer.js +20 -560
  22. package/lib/feeds/rss.js +1 -1
  23. package/lib/polling/processor.js +3 -17
  24. package/lib/storage/items-read-state.js +287 -0
  25. package/lib/storage/items-retention.js +174 -0
  26. package/lib/storage/items-search.js +34 -0
  27. package/lib/storage/items.js +99 -590
  28. package/lib/storage/read-state.js +1 -1
  29. package/lib/utils/async-handler.js +7 -0
  30. package/lib/utils/html.js +25 -0
  31. package/lib/utils/source-type.js +28 -0
  32. package/lib/webmention/processor.js +1 -1
  33. package/locales/de.json +3 -0
  34. package/locales/en.json +2 -0
  35. package/locales/es-419.json +3 -0
  36. package/locales/es.json +3 -0
  37. package/locales/fr.json +3 -0
  38. package/locales/hi.json +3 -0
  39. package/locales/id.json +3 -0
  40. package/locales/it.json +3 -0
  41. package/locales/nl.json +3 -0
  42. package/locales/pl.json +3 -0
  43. package/locales/pt-BR.json +3 -0
  44. package/locales/pt.json +3 -0
  45. package/locales/sr.json +3 -0
  46. package/locales/sv.json +3 -0
  47. package/locales/zh-Hans-CN.json +3 -0
  48. package/package.json +1 -1
  49. package/views/channel.njk +1 -348
  50. package/views/timeline.njk +3 -274
  51. package/lib/controllers/reader.js +0 -1562
@@ -0,0 +1,209 @@
1
+ /**
2
+ * h-feed (Microformats2) normalization
3
+ * @module feeds/normalizer-hfeed
4
+ */
5
+
6
+ import {
7
+ generateItemUid,
8
+ extractImagesFromHtml,
9
+ extractPhotoUrl,
10
+ normalizeUrlArray,
11
+ getFirst,
12
+ getContentText,
13
+ sanitizeHtml,
14
+ SANITIZE_OPTIONS,
15
+ } from "./normalizer.js";
16
+
17
+ /**
18
+ * Normalize h-card author
19
+ * @param {object|string} hcard - h-card or author name string
20
+ * @returns {object} Normalized author object
21
+ */
22
+ function normalizeHcard(hcard) {
23
+ if (typeof hcard === "string") {
24
+ return { type: "card", name: hcard };
25
+ }
26
+
27
+ if (!hcard || !hcard.properties) {
28
+ return;
29
+ }
30
+
31
+ const properties = hcard.properties;
32
+
33
+ return {
34
+ type: "card",
35
+ name: getFirst(properties.name),
36
+ url: getFirst(properties.url),
37
+ photo: extractPhotoUrl(getFirst(properties.photo)),
38
+ };
39
+ }
40
+
41
+ /**
42
+ * Normalize h-feed entry
43
+ * @param {object} entry - Microformats h-entry
44
+ * @param {string} feedUrl - Feed URL
45
+ * @returns {object} Normalized jf2 item
46
+ */
47
+ export function normalizeHfeedItem(entry, feedUrl) {
48
+ const properties = entry.properties || {};
49
+ const url = getFirst(properties.url) || getFirst(properties.uid);
50
+ const uid = generateItemUid(feedUrl, getFirst(properties.uid) || url);
51
+
52
+ const normalized = {
53
+ type: "entry",
54
+ uid,
55
+ url,
56
+ _source: {
57
+ url: feedUrl,
58
+ feedUrl,
59
+ feedType: "hfeed",
60
+ originalId: getFirst(properties.uid),
61
+ },
62
+ };
63
+
64
+ // Name/title
65
+ if (properties.name) {
66
+ const name = getFirst(properties.name);
67
+ // Only include name if it's not just the content
68
+ if (
69
+ name &&
70
+ (!properties.content || name !== getContentText(properties.content))
71
+ ) {
72
+ normalized.name = name;
73
+ }
74
+ }
75
+
76
+ // Published
77
+ if (properties.published) {
78
+ const published = getFirst(properties.published);
79
+ normalized.published = new Date(published).toISOString();
80
+ }
81
+
82
+ // Updated
83
+ if (properties.updated) {
84
+ const updated = getFirst(properties.updated);
85
+ normalized.updated = new Date(updated).toISOString();
86
+ }
87
+
88
+ // Content
89
+ if (properties.content) {
90
+ const content = getFirst(properties.content);
91
+ if (typeof content === "object") {
92
+ normalized.content = {
93
+ html: content.html
94
+ ? sanitizeHtml(content.html, SANITIZE_OPTIONS)
95
+ : undefined,
96
+ text: content.value || undefined,
97
+ };
98
+ } else if (typeof content === "string") {
99
+ normalized.content = { text: content };
100
+ }
101
+ }
102
+
103
+ // Summary
104
+ if (properties.summary) {
105
+ normalized.summary = getFirst(properties.summary);
106
+ }
107
+
108
+ // Author
109
+ if (properties.author) {
110
+ const author = getFirst(properties.author);
111
+ normalized.author = normalizeHcard(author);
112
+ }
113
+
114
+ // Categories
115
+ if (properties.category) {
116
+ normalized.category = properties.category;
117
+ }
118
+
119
+ // Photos
120
+ if (properties.photo) {
121
+ normalized.photo = properties.photo.map((p) =>
122
+ typeof p === "object" ? p.value || p.url : p,
123
+ );
124
+ }
125
+
126
+ // Videos
127
+ if (properties.video) {
128
+ normalized.video = properties.video.map((v) =>
129
+ typeof v === "object" ? v.value || v.url : v,
130
+ );
131
+ }
132
+
133
+ // Audio
134
+ if (properties.audio) {
135
+ normalized.audio = properties.audio.map((a) =>
136
+ typeof a === "object" ? a.value || a.url : a,
137
+ );
138
+ }
139
+
140
+ // Interaction types - normalize to string URLs
141
+ if (properties["like-of"]) {
142
+ normalized["like-of"] = normalizeUrlArray(properties["like-of"]);
143
+ }
144
+ if (properties["repost-of"]) {
145
+ normalized["repost-of"] = normalizeUrlArray(properties["repost-of"]);
146
+ }
147
+ if (properties["bookmark-of"]) {
148
+ normalized["bookmark-of"] = normalizeUrlArray(properties["bookmark-of"]);
149
+ }
150
+ if (properties["in-reply-to"]) {
151
+ normalized["in-reply-to"] = normalizeUrlArray(properties["in-reply-to"]);
152
+ }
153
+
154
+ // RSVP
155
+ if (properties.rsvp) {
156
+ normalized.rsvp = getFirst(properties.rsvp);
157
+ }
158
+
159
+ // Syndication
160
+ if (properties.syndication) {
161
+ normalized.syndication = properties.syndication;
162
+ }
163
+
164
+ // Extract images from HTML content as fallback
165
+ if (!normalized.photo && normalized.content?.html) {
166
+ const extracted = extractImagesFromHtml(normalized.content.html);
167
+ if (extracted.length > 0) {
168
+ normalized.photo = extracted;
169
+ }
170
+ }
171
+
172
+ return normalized;
173
+ }
174
+
175
+ /**
176
+ * Normalize h-feed metadata
177
+ * @param {object} hfeed - h-feed microformat object
178
+ * @param {string} feedUrl - Feed URL
179
+ * @returns {object} Normalized feed metadata
180
+ */
181
+ export function normalizeHfeedMeta(hfeed, feedUrl) {
182
+ const properties = hfeed.properties || {};
183
+
184
+ const normalized = {
185
+ name: getFirst(properties.name) || feedUrl,
186
+ };
187
+
188
+ if (properties.summary) {
189
+ normalized.summary = getFirst(properties.summary);
190
+ }
191
+
192
+ if (properties.url) {
193
+ normalized.url = getFirst(properties.url);
194
+ }
195
+
196
+ if (properties.photo) {
197
+ normalized.photo = getFirst(properties.photo);
198
+ if (typeof normalized.photo === "object") {
199
+ normalized.photo = normalized.photo.value || normalized.photo.url;
200
+ }
201
+ }
202
+
203
+ if (properties.author) {
204
+ const author = getFirst(properties.author);
205
+ normalized.author = normalizeHcard(author);
206
+ }
207
+
208
+ return normalized;
209
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * JSON Feed normalization
3
+ * @module feeds/normalizer-jsonfeed
4
+ */
5
+
6
+ import {
7
+ generateItemUid,
8
+ extractImagesFromHtml,
9
+ sanitizeHtml,
10
+ SANITIZE_OPTIONS,
11
+ } from "./normalizer.js";
12
+
13
+ /**
14
+ * Normalize JSON Feed item
15
+ * @param {object} item - JSON Feed item
16
+ * @param {string} feedUrl - Feed URL
17
+ * @returns {object} Normalized jf2 item
18
+ */
19
+ export function normalizeJsonFeedItem(item, feedUrl) {
20
+ const url = item.url || item.external_url;
21
+ const uid = generateItemUid(feedUrl, item.id || url);
22
+
23
+ const normalized = {
24
+ type: "entry",
25
+ uid,
26
+ url,
27
+ name: item.title
28
+ ? sanitizeHtml(item.title, { allowedTags: [] }).trim()
29
+ : undefined,
30
+ published: item.date_published
31
+ ? new Date(item.date_published).toISOString()
32
+ : undefined,
33
+ updated: item.date_modified
34
+ ? new Date(item.date_modified).toISOString()
35
+ : undefined,
36
+ _source: {
37
+ url: feedUrl,
38
+ feedUrl,
39
+ feedType: "jsonfeed",
40
+ originalId: item.id,
41
+ },
42
+ };
43
+
44
+ // Content
45
+ if (item.content_html || item.content_text) {
46
+ normalized.content = {};
47
+ if (item.content_html) {
48
+ normalized.content.html = sanitizeHtml(
49
+ item.content_html,
50
+ SANITIZE_OPTIONS,
51
+ );
52
+ normalized.content.text = sanitizeHtml(item.content_html, {
53
+ allowedTags: [],
54
+ }).trim();
55
+ } else if (item.content_text) {
56
+ normalized.content.text = item.content_text;
57
+ }
58
+ }
59
+
60
+ // Summary
61
+ if (item.summary) {
62
+ normalized.summary = item.summary;
63
+ }
64
+
65
+ // Author
66
+ if (item.author || item.authors) {
67
+ const author = item.author || (item.authors && item.authors[0]);
68
+ if (author) {
69
+ normalized.author = {
70
+ type: "card",
71
+ name: author.name,
72
+ url: author.url,
73
+ photo: author.avatar,
74
+ };
75
+ }
76
+ }
77
+
78
+ // Tags
79
+ if (item.tags && item.tags.length > 0) {
80
+ normalized.category = item.tags;
81
+ }
82
+
83
+ // Featured image
84
+ if (item.image) {
85
+ normalized.photo = [item.image];
86
+ }
87
+
88
+ if (item.banner_image && !normalized.photo) {
89
+ normalized.photo = [item.banner_image];
90
+ }
91
+
92
+ // Attachments
93
+ if (item.attachments && item.attachments.length > 0) {
94
+ for (const attachment of item.attachments) {
95
+ const mediaType = attachment.mime_type || "";
96
+
97
+ if (mediaType.startsWith("image/")) {
98
+ normalized.photo = normalized.photo || [];
99
+ normalized.photo.push(attachment.url);
100
+ } else if (mediaType.startsWith("video/")) {
101
+ normalized.video = normalized.video || [];
102
+ normalized.video.push(attachment.url);
103
+ } else if (mediaType.startsWith("audio/")) {
104
+ normalized.audio = normalized.audio || [];
105
+ normalized.audio.push(attachment.url);
106
+ }
107
+ }
108
+ }
109
+
110
+ // External URL
111
+ if (item.external_url && item.url !== item.external_url) {
112
+ normalized["bookmark-of"] = [item.external_url];
113
+ }
114
+
115
+ // Extract images from HTML content as fallback
116
+ if (!normalized.photo && normalized.content?.html) {
117
+ const extracted = extractImagesFromHtml(normalized.content.html);
118
+ if (extracted.length > 0) {
119
+ normalized.photo = extracted;
120
+ }
121
+ }
122
+
123
+ return normalized;
124
+ }
125
+
126
+ /**
127
+ * Normalize JSON Feed metadata
128
+ * @param {object} feed - JSON Feed object
129
+ * @param {string} feedUrl - Feed URL
130
+ * @returns {object} Normalized feed metadata
131
+ */
132
+ export function normalizeJsonFeedMeta(feed, feedUrl) {
133
+ const normalized = {
134
+ name: feed.title
135
+ ? sanitizeHtml(feed.title, { allowedTags: [] }).trim()
136
+ : feedUrl,
137
+ };
138
+
139
+ if (feed.description) {
140
+ normalized.summary = feed.description;
141
+ }
142
+
143
+ if (feed.home_page_url) {
144
+ normalized.url = feed.home_page_url;
145
+ }
146
+
147
+ if (feed.icon) {
148
+ normalized.photo = feed.icon;
149
+ } else if (feed.favicon) {
150
+ normalized.photo = feed.favicon;
151
+ }
152
+
153
+ if (feed.author || feed.authors) {
154
+ const author = feed.author || (feed.authors && feed.authors[0]);
155
+ if (author) {
156
+ normalized.author = {
157
+ type: "card",
158
+ name: author.name,
159
+ url: author.url,
160
+ photo: author.avatar,
161
+ };
162
+ }
163
+ }
164
+
165
+ // Hub for WebSub
166
+ if (feed.hubs && feed.hubs.length > 0) {
167
+ normalized._hub = feed.hubs[0].url;
168
+ }
169
+
170
+ return normalized;
171
+ }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * RSS/Atom feed normalization
3
+ * @module feeds/normalizer-rss
4
+ */
5
+
6
+ import {
7
+ generateItemUid,
8
+ toISOStringSafe,
9
+ extractImagesFromHtml,
10
+ sanitizeHtml,
11
+ SANITIZE_OPTIONS,
12
+ } from "./normalizer.js";
13
+
14
+ /**
15
+ * Normalize RSS/Atom item from feedparser
16
+ * @param {object} item - Feedparser item
17
+ * @param {string} feedUrl - Feed URL
18
+ * @param {string} feedType - 'rss' or 'atom'
19
+ * @returns {object} Normalized jf2 item
20
+ */
21
+ export function normalizeItem(item, feedUrl, feedType) {
22
+ const url = item.link || item.origlink || item.guid;
23
+ const uid = generateItemUid(feedUrl, item.guid || url || item.title);
24
+
25
+ const normalized = {
26
+ type: "entry",
27
+ uid,
28
+ url,
29
+ name: item.title
30
+ ? sanitizeHtml(item.title, { allowedTags: [] }).trim()
31
+ : undefined,
32
+ published: toISOStringSafe(item.pubdate),
33
+ updated: toISOStringSafe(item.date),
34
+ _source: {
35
+ url: feedUrl,
36
+ feedUrl,
37
+ feedType,
38
+ originalId: item.guid,
39
+ },
40
+ };
41
+
42
+ // Content
43
+ if (item.description || item.summary) {
44
+ const html = item.description || item.summary;
45
+ normalized.content = {
46
+ html: sanitizeHtml(html, SANITIZE_OPTIONS),
47
+ text: sanitizeHtml(html, { allowedTags: [] }).trim(),
48
+ };
49
+ }
50
+
51
+ // Summary (prefer explicit summary over truncated content)
52
+ if (item.summary && item.description && item.summary !== item.description) {
53
+ normalized.summary = sanitizeHtml(item.summary, { allowedTags: [] }).trim();
54
+ }
55
+
56
+ // Author
57
+ if (item.author || item["dc:creator"]) {
58
+ const authorName = item.author || item["dc:creator"];
59
+ normalized.author = {
60
+ type: "card",
61
+ name: authorName,
62
+ };
63
+ }
64
+
65
+ // Categories/tags
66
+ if (item.categories && item.categories.length > 0) {
67
+ normalized.category = item.categories;
68
+ }
69
+
70
+ // Enclosures (media)
71
+ if (item.enclosures && item.enclosures.length > 0) {
72
+ for (const enclosure of item.enclosures) {
73
+ const mediaUrl = enclosure.url;
74
+ const mediaType = enclosure.type || "";
75
+
76
+ if (mediaType.startsWith("image/")) {
77
+ normalized.photo = normalized.photo || [];
78
+ normalized.photo.push(mediaUrl);
79
+ } else if (mediaType.startsWith("video/")) {
80
+ normalized.video = normalized.video || [];
81
+ normalized.video.push(mediaUrl);
82
+ } else if (mediaType.startsWith("audio/")) {
83
+ normalized.audio = normalized.audio || [];
84
+ normalized.audio.push(mediaUrl);
85
+ }
86
+ }
87
+ }
88
+
89
+ // Featured image from media content
90
+ if (item["media:content"] && item["media:content"].url) {
91
+ const mediaType = item["media:content"].type || "";
92
+ if (
93
+ mediaType.startsWith("image/") ||
94
+ item["media:content"].medium === "image"
95
+ ) {
96
+ normalized.photo = normalized.photo || [];
97
+ if (!normalized.photo.includes(item["media:content"].url)) {
98
+ normalized.photo.push(item["media:content"].url);
99
+ }
100
+ }
101
+ }
102
+
103
+ // Image from item.image
104
+ if (item.image && item.image.url) {
105
+ normalized.photo = normalized.photo || [];
106
+ if (!normalized.photo.includes(item.image.url)) {
107
+ normalized.photo.push(item.image.url);
108
+ }
109
+ }
110
+
111
+ // Extract images from HTML content as fallback
112
+ if (!normalized.photo && normalized.content?.html) {
113
+ const extracted = extractImagesFromHtml(normalized.content.html);
114
+ if (extracted.length > 0) {
115
+ normalized.photo = extracted;
116
+ }
117
+ }
118
+
119
+ return normalized;
120
+ }
121
+
122
+ /**
123
+ * Normalize feed metadata from feedparser
124
+ * @param {object} meta - Feedparser meta object
125
+ * @param {string} feedUrl - Feed URL
126
+ * @returns {object} Normalized feed metadata
127
+ */
128
+ export function normalizeFeedMeta(meta, feedUrl) {
129
+ const normalized = {
130
+ name: meta.title
131
+ ? sanitizeHtml(meta.title, { allowedTags: [] }).trim()
132
+ : feedUrl,
133
+ };
134
+
135
+ if (meta.description) {
136
+ normalized.summary = meta.description;
137
+ }
138
+
139
+ if (meta.link) {
140
+ normalized.url = meta.link;
141
+ }
142
+
143
+ if (meta.image && meta.image.url) {
144
+ normalized.photo = meta.image.url;
145
+ }
146
+
147
+ if (meta.favicon) {
148
+ normalized.photo = normalized.photo || meta.favicon;
149
+ }
150
+
151
+ // Author/publisher
152
+ if (meta.author) {
153
+ normalized.author = {
154
+ type: "card",
155
+ name: meta.author,
156
+ };
157
+ }
158
+
159
+ // Hub for WebSub
160
+ if (meta.cloud && meta.cloud.href) {
161
+ normalized._hub = meta.cloud.href;
162
+ }
163
+
164
+ // Look for hub in links
165
+ if (meta.link && meta["atom:link"]) {
166
+ const links = Array.isArray(meta["atom:link"])
167
+ ? meta["atom:link"]
168
+ : [meta["atom:link"]];
169
+ for (const link of links) {
170
+ if (link["@"] && link["@"].rel === "hub") {
171
+ normalized._hub = link["@"].href;
172
+ break;
173
+ }
174
+ }
175
+ }
176
+
177
+ return normalized;
178
+ }