@rmdes/indiekit-endpoint-rss 1.0.18 → 1.1.0

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/README.md CHANGED
@@ -37,7 +37,8 @@ export default {
37
37
  fetchTimeout: 10_000, // 10 second timeout per feed
38
38
  maxConcurrentFetches: 3, // Parallel feed fetches
39
39
  retentionDays: 30, // Days to keep items
40
- minItemsPerFeed: 10 // Newest items always kept, whatever their age
40
+ minItemsPerFeed: 10, // Newest items always kept, whatever their age
41
+ maxPostsPerCycle: 10 // Max posts created per sync cycle
41
42
  })
42
43
  ],
43
44
  // MongoDB database is REQUIRED
package/index.js CHANGED
@@ -19,6 +19,7 @@ const defaults = {
19
19
  maxConcurrentFetches: 3,
20
20
  retentionDays: 30,
21
21
  minItemsPerFeed: 10,
22
+ maxPostsPerCycle: 10,
22
23
  };
23
24
 
24
25
  export default class RssEndpoint {
@@ -77,6 +78,7 @@ export default class RssEndpoint {
77
78
  router.post("/api/feeds", express.json(), feedsController.add);
78
79
  router.delete("/api/feeds/:id", feedsController.remove);
79
80
  router.patch("/api/feeds/:id", express.json(), feedsController.toggle);
81
+ router.post("/api/feeds/:id/backfill", express.json(), feedsController.backfill);
80
82
 
81
83
  // Manual refresh (protected)
82
84
  router.post("/api/refresh", statusController.refresh);
@@ -73,6 +73,7 @@ export const dashboardController = {
73
73
  },
74
74
  mountPath: request.baseUrl,
75
75
  publicUrl: rssEndpoint,
76
+ postTypes: Object.keys(request.app.locals.publication?.postTypes || {}),
76
77
  ...flash,
77
78
  });
78
79
  } catch (error) {
@@ -111,7 +112,15 @@ export const dashboardController = {
111
112
  await feedsCollection.updateMany({}, { $set: { itemCount: 0 } });
112
113
 
113
114
  // Trigger sync
114
- const result = await runSync(db, rssConfig);
115
+ const { application, publication } = request.app.locals;
116
+ const publishContext =
117
+ application?.micropubEndpoint && publication?.me
118
+ ? {
119
+ micropubEndpoint: application.micropubEndpoint,
120
+ me: publication.me,
121
+ }
122
+ : null;
123
+ const result = await runSync(db, rssConfig, publishContext);
115
124
 
116
125
  if (result.error) {
117
126
  return response.status(500).json({
@@ -164,7 +173,15 @@ export const dashboardController = {
164
173
  return response.redirect(request.baseUrl);
165
174
  }
166
175
 
167
- const result = await runSync(db, rssConfig);
176
+ const { application, publication } = request.app.locals;
177
+ const publishContext =
178
+ application?.micropubEndpoint && publication?.me
179
+ ? {
180
+ micropubEndpoint: application.micropubEndpoint,
181
+ me: publication.me,
182
+ }
183
+ : null;
184
+ const result = await runSync(db, rssConfig, publishContext);
168
185
 
169
186
  if (result.error) {
170
187
  request.session.messages = [
@@ -1,5 +1,7 @@
1
1
  import { ObjectId } from "mongodb";
2
2
  import { RssClient } from "../rss-client.js";
3
+ import { defaultContent, defaultLinkProperty } from "../jf2-builder.js";
4
+ import { watermarkFor } from "../publisher.js";
3
5
  import { formatFeed, isValidUrl, normalizeUrl } from "../utils.js";
4
6
 
5
7
  export const feedsController = {
@@ -147,20 +149,24 @@ export const feedsController = {
147
149
  },
148
150
 
149
151
  /**
150
- * Toggle feed enabled/disabled
152
+ * Update a feed: enable/disable, or change its publish configuration
151
153
  * PATCH /api/feeds/:id
152
- * Body: { enabled: boolean }
154
+ * Body: { enabled?: boolean, publish?: object }
153
155
  */
154
156
  async toggle(request, response) {
155
157
  try {
156
158
  const { id } = request.params;
157
- const { enabled } = request.body;
159
+ const { enabled, publish } = request.body;
158
160
 
159
161
  if (!ObjectId.isValid(id)) {
160
162
  return response.status(400).json({ error: "Invalid feed ID" });
161
163
  }
162
164
 
163
- if (typeof enabled !== "boolean") {
165
+ if (enabled === undefined && publish === undefined) {
166
+ return response.status(400).json({ error: "Nothing to update" });
167
+ }
168
+
169
+ if (enabled !== undefined && typeof enabled !== "boolean") {
164
170
  return response.status(400).json({ error: "enabled must be boolean" });
165
171
  }
166
172
 
@@ -171,27 +177,152 @@ export const feedsController = {
171
177
 
172
178
  const feedsCollection = db.collection("rssFeeds");
173
179
  const feedId = new ObjectId(id);
180
+ const feed = await feedsCollection.findOne({ _id: feedId });
181
+
182
+ if (!feed) {
183
+ return response.status(404).json({
184
+ error: response.locals.__("rss.error.feedNotFound"),
185
+ });
186
+ }
187
+
188
+ const update = {};
189
+
190
+ if (enabled !== undefined) {
191
+ update.enabled = enabled;
192
+ }
193
+
194
+ if (publish !== undefined) {
195
+ const { postTypes } = request.app.locals.publication || {};
196
+
197
+ if (publish.enabled) {
198
+ if (!postTypes) {
199
+ return response.status(500).json({
200
+ error: "Post type configuration unavailable",
201
+ });
202
+ }
203
+
204
+ // postData.create() throws notImplemented for an unconfigured type,
205
+ // and the failure would be buried deep in the sync loop. Sites
206
+ // differ: chardonsbleus has audio, event, jam and rsvp disabled.
207
+ if (!Object.hasOwn(postTypes, publish.postType)) {
208
+ return response.status(400).json({
209
+ error: `Post type "${publish.postType}" is not enabled on this site`,
210
+ });
211
+ }
212
+ }
213
+
214
+ const merged = { ...feed.publish, ...publish };
215
+
216
+ update.publish = {
217
+ ...merged,
218
+ // Pre-fill from the post type when the caller left them out, so a
219
+ // minimal PATCH of { enabled, postType } yields a working config.
220
+ // An explicit null is respected: it means "no link property".
221
+ content: merged.content || defaultContent(merged.postType),
222
+ linkProperty:
223
+ publish.linkProperty === undefined
224
+ ? (merged.linkProperty ?? defaultLinkProperty(merged.postType))
225
+ : publish.linkProperty,
226
+ // Stamp the watermark the first time publishing is switched on, so
227
+ // enabling a feed does not publish its entire cached backlog.
228
+ since:
229
+ publish.enabled && !feed.publish?.since
230
+ ? new Date().toISOString()
231
+ : (feed.publish?.since ?? null),
232
+ };
233
+ }
174
234
 
175
235
  const result = await feedsCollection.findOneAndUpdate(
176
236
  { _id: feedId },
177
- { $set: { enabled } },
178
- { returnDocument: "after" }
237
+ { $set: update },
238
+ { returnDocument: "after" },
179
239
  );
180
240
 
181
- if (!result) {
241
+ response.json({
242
+ message:
243
+ enabled === undefined
244
+ ? response.locals.__("rss.success.feedUpdated")
245
+ : enabled
246
+ ? response.locals.__("rss.success.feedEnabled")
247
+ : response.locals.__("rss.success.feedDisabled"),
248
+ feed: formatFeed(result),
249
+ });
250
+ } catch (error) {
251
+ console.error("[RSS] Error updating feed:", error.message);
252
+ response.status(500).json({ error: error.message });
253
+ }
254
+ },
255
+
256
+ /**
257
+ * Rewind a feed's watermark so past items get published
258
+ * POST /api/feeds/:id/backfill
259
+ * Body: { since?: string } or { last?: number }
260
+ */
261
+ async backfill(request, response) {
262
+ try {
263
+ const { id } = request.params;
264
+ const { since, last } = request.body;
265
+
266
+ if (!ObjectId.isValid(id)) {
267
+ return response.status(400).json({ error: "Invalid feed ID" });
268
+ }
269
+
270
+ const db = request.app.locals.application.getRssDb?.();
271
+ if (!db) {
272
+ return response.status(500).json({ error: "Database not available" });
273
+ }
274
+
275
+ const feedsCollection = db.collection("rssFeeds");
276
+ const itemsCollection = db.collection("rssItems");
277
+ const feedId = new ObjectId(id);
278
+ const feed = await feedsCollection.findOne({ _id: feedId });
279
+
280
+ if (!feed) {
182
281
  return response.status(404).json({
183
282
  error: response.locals.__("rss.error.feedNotFound"),
184
283
  });
185
284
  }
186
285
 
286
+ if (!feed.publish?.enabled) {
287
+ return response.status(400).json({
288
+ error: "Enable publishing on this feed first",
289
+ });
290
+ }
291
+
292
+ let oldestPubDate = null;
293
+ if (last) {
294
+ const items = await itemsCollection
295
+ .find({ feedId })
296
+ .sort({ pubDate: -1 })
297
+ .limit(Number(last))
298
+ .toArray();
299
+ oldestPubDate = items.at(-1)?.pubDate || null;
300
+ }
301
+
302
+ let watermark;
303
+ try {
304
+ watermark = watermarkFor({ since, last }, oldestPubDate);
305
+ } catch (error) {
306
+ return response.status(400).json({ error: error.message });
307
+ }
308
+
309
+ await feedsCollection.updateOne(
310
+ { _id: feedId },
311
+ {
312
+ $set: {
313
+ "publish.since": watermark,
314
+ // Imported history is always drafts, whatever the feed is set to.
315
+ "publish.status": "draft",
316
+ },
317
+ },
318
+ );
319
+
187
320
  response.json({
188
- message: enabled
189
- ? response.locals.__("rss.success.feedEnabled")
190
- : response.locals.__("rss.success.feedDisabled"),
191
- feed: formatFeed(result),
321
+ message: response.locals.__("rss.success.backfillQueued"),
322
+ since: watermark,
192
323
  });
193
324
  } catch (error) {
194
- console.error("[RSS] Error toggling feed:", error.message);
325
+ console.error("[RSS] Error queueing backfill:", error.message);
195
326
  response.status(500).json({ error: error.message });
196
327
  }
197
328
  },
@@ -0,0 +1,115 @@
1
+ import { sanitizeHtml, stripHtml } from "./utils.js";
2
+
3
+ /**
4
+ * Discovery property pre-filled for each post type. Form defaults only —
5
+ * any combination the user sets is accepted.
6
+ */
7
+ const LINK_PROPERTY_DEFAULTS = {
8
+ bookmark: "bookmark-of",
9
+ like: "like-of",
10
+ repost: "repost-of",
11
+ reply: "in-reply-to",
12
+ video: "video",
13
+ photo: "photo",
14
+ };
15
+
16
+ const PLACEHOLDERS = {
17
+ title: (item) => stripHtml(item.title),
18
+ link: (item) => item.link,
19
+ description: (item) => stripHtml(item.description),
20
+ content: (item) => sanitizeHtml(item.content),
21
+ author: (item) => stripHtml(item.author),
22
+ sourceTitle: (item) => stripHtml(item.sourceTitle),
23
+ };
24
+
25
+ /**
26
+ * Link property offered when a post type is chosen
27
+ * @param {string} postType - Post type key
28
+ * @returns {string|null} Property name, or null when the type needs none
29
+ */
30
+ export const defaultLinkProperty = (postType) =>
31
+ LINK_PROPERTY_DEFAULTS[postType] || null;
32
+
33
+ /**
34
+ * Content template offered when a post type is chosen
35
+ * @param {string} postType - Post type key
36
+ * @returns {string} Template string
37
+ */
38
+ export const defaultContent = (postType) =>
39
+ postType === "article" ? "{{content}}" : "{{description}}";
40
+
41
+ /**
42
+ * Render a feed item into a content string
43
+ * @param {string} template - Template with {{placeholder}} tokens
44
+ * @param {object} item - RSS item document
45
+ * @returns {string} Rendered content
46
+ */
47
+ export function renderTemplate(template, item) {
48
+ return template.replaceAll(/\{\{(\w+)\}\}/g, (match, key) => {
49
+ const source = PLACEHOLDERS[key];
50
+ return source ? (source(item) ?? "") : match;
51
+ });
52
+ }
53
+
54
+ /**
55
+ * Build JF2 properties for one feed item.
56
+ *
57
+ * The post type is never sent: getPostType() derives it from the properties
58
+ * present, so `publish.postType` decides which discovery property to set.
59
+ * @param {object} item - RSS item document
60
+ * @param {object} publish - Feed publish configuration
61
+ * @returns {object} JF2 properties
62
+ */
63
+ export function buildJf2(item, publish) {
64
+ const template = publish.content || defaultContent(publish.postType);
65
+ const rendered = renderTemplate(template, item);
66
+ const isHtml = template.includes("{{content}}");
67
+
68
+ const properties = {
69
+ type: "entry",
70
+ content: isHtml ? { html: rendered } : rendered,
71
+ };
72
+
73
+ // A `name` next to content makes discovery return "article" — see
74
+ // post-type-discovery.js: `if (content && properties.name) return "article"`.
75
+ // A note must therefore carry none.
76
+ const name = stripHtml(item.title);
77
+ if (publish.postType !== "note" && name) {
78
+ properties.name = name;
79
+ }
80
+
81
+ if (publish.linkProperty) {
82
+ const value =
83
+ publish.linkProperty === "photo" ? item.imageUrl : item.link;
84
+
85
+ if (!value) {
86
+ throw new Error(
87
+ `Item has no value for "${publish.linkProperty}": ${item.guid}`,
88
+ );
89
+ }
90
+
91
+ properties[publish.linkProperty] = value;
92
+ }
93
+
94
+ if (item.pubDate) {
95
+ properties.published =
96
+ item.pubDate instanceof Date ? item.pubDate.toISOString() : item.pubDate;
97
+ }
98
+
99
+ if (item.categories?.length) {
100
+ properties.category = item.categories;
101
+ }
102
+
103
+ if (publish.declareSyndication && item.link) {
104
+ properties.syndication = [item.link];
105
+ }
106
+
107
+ properties["post-status"] =
108
+ publish.status === "published" ? "published" : "draft";
109
+
110
+ if (publish.syndicateTo?.length) {
111
+ properties["mp-syndicate-to"] = publish.syndicateTo;
112
+ }
113
+
114
+ return properties;
115
+ }
@@ -0,0 +1,239 @@
1
+ import jwt from "jsonwebtoken";
2
+ import { jf2ToMf2 } from "@indiekit/endpoint-micropub/lib/mf2.js";
3
+ import { buildJf2 } from "./jf2-builder.js";
4
+
5
+ const TOKEN_TTL = "5m";
6
+
7
+ /**
8
+ * Mint a short-lived token for background publishing.
9
+ *
10
+ * Background sync has no user session, so it cannot reuse the admin UI's
11
+ * access token. endpoint-auth signs and verifies with process.env.SECRET,
12
+ * the same mechanism start.sh already uses for the syndication poller.
13
+ * @param {string} me - Publication URL
14
+ * @returns {string} Signed JWT
15
+ */
16
+ export function mintToken(me) {
17
+ if (!process.env.SECRET) {
18
+ throw new Error("Cannot publish: SECRET is not configured");
19
+ }
20
+
21
+ return jwt.sign({ me, scope: "create" }, process.env.SECRET, {
22
+ expiresIn: TOKEN_TTL,
23
+ });
24
+ }
25
+
26
+ /**
27
+ * POST one post to the site's own Micropub endpoint.
28
+ *
29
+ * The endpoint parses any JSON body as mf2, so JF2 properties are converted
30
+ * first — the same thing endpoint-posts does before calling the endpoint.
31
+ * jf2ToMf2 mutates its input (deletes `properties.type`), so a copy is
32
+ * passed to avoid corrupting the caller's object.
33
+ * @param {string} micropubEndpoint - Micropub endpoint URL
34
+ * @param {string} accessToken - Bearer token
35
+ * @param {object} properties - JF2 properties
36
+ * @param {object} [options] - Options
37
+ * @param {Function} [options.fetchImpl] - fetch implementation, for tests
38
+ * @returns {Promise<string|null>} URL of the created post
39
+ */
40
+ export async function postToMicropub(
41
+ micropubEndpoint,
42
+ accessToken,
43
+ properties,
44
+ { fetchImpl = fetch } = {},
45
+ ) {
46
+ const mf2 = jf2ToMf2({ properties: { ...properties } });
47
+
48
+ const response = await fetchImpl(micropubEndpoint, {
49
+ method: "POST",
50
+ headers: {
51
+ accept: "application/json",
52
+ authorization: `Bearer ${accessToken}`,
53
+ "content-type": "application/json",
54
+ },
55
+ body: JSON.stringify(mf2),
56
+ });
57
+
58
+ if (!response.ok) {
59
+ const body = await response.text();
60
+ const error = new Error(`Micropub ${response.status}: ${body.slice(0, 200)}`);
61
+ error.status = response.status;
62
+ throw error;
63
+ }
64
+
65
+ return response.headers.get("location");
66
+ }
67
+
68
+ /**
69
+ * Resolve what publishing needs from Indiekit configuration.
70
+ *
71
+ * micropubEndpoint is only resolved against the request in Indiekit's locals
72
+ * middleware, and background sync has no request. Relative endpoints are
73
+ * therefore resolved against localhost — the same loopback start.sh already
74
+ * uses for the syndication and webmention pollers.
75
+ * @param {object} application - Application configuration
76
+ * @param {object} publication - Publication configuration
77
+ * @returns {object|null} { micropubEndpoint, me }, or null when unconfigured
78
+ */
79
+ export function resolvePublishContext(application = {}, publication = {}) {
80
+ const { micropubEndpoint, port } = application;
81
+ const { me } = publication;
82
+
83
+ if (!micropubEndpoint || !me) {
84
+ return null;
85
+ }
86
+
87
+ if (URL.canParse(micropubEndpoint)) {
88
+ return { micropubEndpoint, me };
89
+ }
90
+
91
+ // A misconfigured port must cost this site its publishing, not its whole
92
+ // sync cycle: this runs before runSync's try block, so a throw here would
93
+ // skip the feed fetch, the inserts and the prune too, every cycle, with
94
+ // nothing but a console.error to show for it.
95
+ const base = `http://localhost:${port || "3000"}`;
96
+ if (!URL.canParse(micropubEndpoint, base)) {
97
+ return null;
98
+ }
99
+
100
+ return { micropubEndpoint: new URL(micropubEndpoint, base).href, me };
101
+ }
102
+
103
+ const MAX_ATTEMPTS = 3;
104
+
105
+ /**
106
+ * Publish the pending items of one feed.
107
+ *
108
+ * Retries are bounded on purpose: leaving postedAt unset retries next cycle,
109
+ * and unbounded that reproduces the prune churn fixed in 1.0.17 — a
110
+ * permanently invalid item retried every fifteen minutes forever.
111
+ * @param {object} feed - Feed document
112
+ * @param {object} itemsCollection - Items collection
113
+ * @param {object} options - Options
114
+ * @param {string} options.micropubEndpoint - Micropub endpoint URL
115
+ * @param {string} options.me - Publication URL
116
+ * @param {number} options.maxPostsPerCycle - Cap per cycle
117
+ * @param {Function} [options.postImpl] - Post implementation, for tests
118
+ * @param {Function} [options.mintImpl] - Token minter, for tests
119
+ * @returns {Promise<object>} Counts for this feed
120
+ */
121
+ export async function publishPending(feed, itemsCollection, options) {
122
+ if (!feed.publish?.enabled) {
123
+ return { published: 0, failed: 0 };
124
+ }
125
+
126
+ const {
127
+ micropubEndpoint,
128
+ me,
129
+ maxPostsPerCycle,
130
+ postImpl = postToMicropub,
131
+ mintImpl = mintToken,
132
+ } = options;
133
+
134
+ const query = {
135
+ feedId: feed._id,
136
+ postedAt: { $exists: false },
137
+ postSkipped: { $ne: true },
138
+ };
139
+
140
+ if (feed.publish.since) {
141
+ // An item with no parsable date would be excluded forever by a bare
142
+ // `pubDate: { $gt: since }`, because null sorts below every Date in BSON —
143
+ // a silent permanent no-op. fetchedAt is always set on insert and is
144
+ // monotonic with cache entry, so it stands in when pubDate is missing.
145
+ query.$or = [
146
+ { pubDate: { $gt: feed.publish.since } },
147
+ { pubDate: null, fetchedAt: { $gt: feed.publish.since } },
148
+ ];
149
+ }
150
+
151
+ const items = await itemsCollection
152
+ .find(query)
153
+ .sort({ pubDate: 1 })
154
+ .limit(maxPostsPerCycle)
155
+ .toArray();
156
+
157
+ if (items.length === 0) {
158
+ return { published: 0, failed: 0 };
159
+ }
160
+
161
+ let published = 0;
162
+ let failed = 0;
163
+
164
+ // ponytail: a crash between POST and $set re-posts the item next cycle;
165
+ // postData.create replaceOne's on the same URL, so it overwrites rather
166
+ // than duplicates. Add a q=source reconciliation if a feed ever produces
167
+ // non-deterministic slugs.
168
+ for (const item of items) {
169
+ try {
170
+ // Minted per item, not once for the batch: signing is a cheap HMAC, and
171
+ // a single batch token can expire mid-cycle on a slow endpoint. The
172
+ // resulting 401 is a 4xx, so under the permanent-failure rule the tail
173
+ // of the batch would be marked postSkipped and lost for good.
174
+ const token = mintImpl(me);
175
+ const properties = buildJf2(item, feed.publish);
176
+ const postUrl = await postImpl(micropubEndpoint, token, properties);
177
+
178
+ await itemsCollection.updateOne(
179
+ { _id: item._id },
180
+ { $set: { postedAt: new Date().toISOString(), postUrl } },
181
+ );
182
+ published++;
183
+ } catch (error) {
184
+ failed++;
185
+
186
+ // A 4xx does not become a 201 on retry, and neither does an item that
187
+ // structurally cannot be built. Both are permanent. Note postedAt is
188
+ // never written here: pruneOldItems spares any item where it exists.
189
+ const isPermanent =
190
+ !error.status || (error.status >= 400 && error.status < 500);
191
+ const attempts = (item.postAttempts || 0) + 1;
192
+
193
+ await itemsCollection.updateOne(
194
+ { _id: item._id },
195
+ {
196
+ $set: {
197
+ postError: error.message,
198
+ postErrorAt: new Date().toISOString(),
199
+ ...(isPermanent || attempts >= MAX_ATTEMPTS
200
+ ? { postSkipped: true }
201
+ : {}),
202
+ },
203
+ $inc: { postAttempts: 1 },
204
+ },
205
+ );
206
+
207
+ console.error(`[RSS] Publish failed for ${item.guid}: ${error.message}`);
208
+ }
209
+ }
210
+
211
+ console.log(
212
+ `[RSS] Published ${published} item(s) from ${feed.title || feed.url}${failed ? `, ${failed} failed` : ""}`,
213
+ );
214
+
215
+ return { published, failed };
216
+ }
217
+
218
+ /**
219
+ * Resolve the watermark a backfill request should move the feed to.
220
+ *
221
+ * Backfill is not a separate publishing path: it rewinds publish.since and
222
+ * lets the normal loop catch up at maxPostsPerCycle per cycle, which is
223
+ * where the cap and the rate limit already live.
224
+ * @param {object} request - { since } ISO date, or { last } item count
225
+ * @param {Date|string|null} oldestPubDate - pubDate of the Nth newest item
226
+ * @returns {string} ISO watermark
227
+ */
228
+ export function watermarkFor({ since, last }, oldestPubDate) {
229
+ if (since) {
230
+ return new Date(since).toISOString();
231
+ }
232
+
233
+ if (last && oldestPubDate) {
234
+ // One millisecond below, so the Nth item passes the loop's `$gt` filter.
235
+ return new Date(new Date(oldestPubDate).getTime() - 1).toISOString();
236
+ }
237
+
238
+ throw new Error("Backfill requires since or last");
239
+ }
package/lib/sync.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { RssClient } from "./rss-client.js";
2
+ import { publishPending, resolvePublishContext } from "./publisher.js";
2
3
 
3
4
  let syncInterval = null;
4
5
  let initialSyncTimeout = null;
@@ -71,9 +72,13 @@ export function stopSync() {
71
72
  * Run a single sync cycle
72
73
  * @param {Object} dbOrIndiekit - Database instance or Indiekit instance (for backwards compat)
73
74
  * @param {Object} options - Plugin options
75
+ * @param {Object|null} [publishContext] - { micropubEndpoint, me }. Callers with
76
+ * a request (manual sync controllers) should supply their own resolved
77
+ * values; background sync has none, so it derives its own from the
78
+ * Indiekit instance.
74
79
  * @returns {Promise<Object>}
75
80
  */
76
- export async function runSync(dbOrIndiekit, options) {
81
+ export async function runSync(dbOrIndiekit, options, publishContext) {
77
82
  // Support both direct db object and Indiekit object (for background sync)
78
83
  const db = dbOrIndiekit.database || dbOrIndiekit;
79
84
  if (!db || typeof db.collection !== "function") {
@@ -81,6 +86,15 @@ export async function runSync(dbOrIndiekit, options) {
81
86
  return { error: syncState.lastError };
82
87
  }
83
88
 
89
+ publishContext =
90
+ publishContext ??
91
+ (dbOrIndiekit.config
92
+ ? resolvePublishContext(
93
+ dbOrIndiekit.config.application,
94
+ dbOrIndiekit.config.publication,
95
+ )
96
+ : null);
97
+
84
98
  if (syncState.syncing) {
85
99
  return { error: "Sync already in progress" };
86
100
  }
@@ -129,6 +143,30 @@ export async function runSync(dbOrIndiekit, options) {
129
143
  syncState.feedsProcessed++;
130
144
  }
131
145
 
146
+ // Publish items from feeds wired to Micropub. Runs after insertion so it
147
+ // reads what syncFeed just wrote, and before the prune so nothing is
148
+ // published from items about to be removed.
149
+ let itemsPublished = 0;
150
+
151
+ if (publishContext) {
152
+ for (const feed of feeds.filter((entry) => entry.publish?.enabled)) {
153
+ try {
154
+ const result = await publishPending(feed, itemsCollection, {
155
+ ...publishContext,
156
+ maxPostsPerCycle: options.maxPostsPerCycle || 10,
157
+ });
158
+ itemsPublished += result.published;
159
+ } catch (error) {
160
+ // One feed's failure must not cost the remaining feeds their publish
161
+ // step, nor the cycle its prune. syncFeed isolates per feed the same
162
+ // way.
163
+ console.error(
164
+ `[RSS] Publish failed for ${feed.url}: ${error.message}`,
165
+ );
166
+ }
167
+ }
168
+ }
169
+
132
170
  // Prune old items
133
171
  const retentionDays = options.retentionDays || 30;
134
172
  const itemsPruned = await pruneOldItems(
@@ -142,12 +180,13 @@ export async function runSync(dbOrIndiekit, options) {
142
180
  syncState.syncing = false;
143
181
 
144
182
  console.log(
145
- `[RSS] Sync complete: ${syncState.feedsProcessed} feeds, ${syncState.itemsAdded} new items, ${itemsPruned} pruned`
183
+ `[RSS] Sync complete: ${syncState.feedsProcessed} feeds, ${syncState.itemsAdded} new items, ${itemsPublished} published, ${itemsPruned} pruned`
146
184
  );
147
185
 
148
186
  return {
149
187
  feedsProcessed: syncState.feedsProcessed,
150
188
  itemsAdded: syncState.itemsAdded,
189
+ itemsPublished,
151
190
  itemsPruned,
152
191
  };
153
192
  } catch (error) {
@@ -339,6 +378,9 @@ export async function pruneOldItems(
339
378
  // $ne: null also excludes missing dates: in BSON null sorts before
340
379
  // Date, so a bare $lt would delete every undated item as "too old".
341
380
  pubDate: { $lt: cutoff, $ne: null },
381
+ // A published item is never forgotten: if the feed re-serves it later
382
+ // it would return without postedAt and be published a second time.
383
+ postedAt: { $exists: false },
342
384
  _id: { $nin: keep.map((item) => item._id) },
343
385
  });
344
386
 
package/lib/utils.js CHANGED
@@ -38,6 +38,12 @@ export function sanitizeHtml(html) {
38
38
  ],
39
39
  allowedAttributes: {
40
40
  a: ["href", "title", "rel"],
41
+ code: ["class"],
42
+ pre: ["class"],
43
+ },
44
+ allowedClasses: {
45
+ code: ["language-*"],
46
+ pre: ["language-*"],
41
47
  },
42
48
  allowedSchemes: ["http", "https", "mailto"],
43
49
  });
@@ -172,6 +178,9 @@ export function formatFeed(feed) {
172
178
  lastFetchedAt: toISO(feed.lastFetchedAt),
173
179
  lastError: feed.lastError,
174
180
  itemCount: feed.itemCount || 0,
181
+ publish: feed.publish
182
+ ? { ...feed.publish, since: toISO(feed.publish.since) }
183
+ : null,
175
184
  };
176
185
  }
177
186
 
package/locales/en.json CHANGED
@@ -39,8 +39,10 @@
39
39
  "success": {
40
40
  "feedAdded": "Feed added successfully",
41
41
  "feedRemoved": "Feed removed",
42
- "feedEnabled": "Feed enabled",
42
+ "feedUpdated": "Feed updated",
43
+ "feedEnabled": "Feed enabled",
43
44
  "feedDisabled": "Feed disabled",
45
+ "backfillQueued": "Backfill queued; items will be published as drafts over the next sync cycles",
44
46
  "syncComplete": "Sync complete",
45
47
  "clearResync": "Items cleared and re-synced"
46
48
  },
@@ -48,6 +50,15 @@
48
50
  "title": "Public Page",
49
51
  "description": "View aggregated RSS feeds on the public page",
50
52
  "view": "View News Page"
53
+ },
54
+ "publish": {
55
+ "title": "Publish to site",
56
+ "enabled": "Create posts from this feed",
57
+ "postType": "Post type",
58
+ "content": "Content template",
59
+ "linkProperty": "Link property",
60
+ "status": "Post status",
61
+ "save": "Save publish settings"
51
62
  }
52
63
  }
53
64
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-rss",
3
- "version": "1.0.18",
3
+ "version": "1.1.0",
4
4
  "description": "RSS feed reader endpoint for Indiekit. Aggregates multiple feeds, caches in MongoDB, displays on frontend.",
5
5
  "keywords": [
6
6
  "indiekit",
@@ -44,9 +44,12 @@
44
44
  "index.js"
45
45
  ],
46
46
  "dependencies": {
47
- "@rmdes/indiekit-startup-gate": "^1.0.0",
47
+ "@indiekit/endpoint-micropub": "^1.0.0-beta.29",
48
48
  "@indiekit/error": "^1.0.0-beta.25",
49
+ "@rmdes/indiekit-startup-gate": "^1.0.0",
49
50
  "express": "^5.0.0",
51
+ "jsonwebtoken": "^9.0.2",
52
+ "mongodb": "^7.5.0",
50
53
  "rss-parser": "^3.13.0",
51
54
  "sanitize-html": "^2.13.0"
52
55
  },
package/views/rss.njk CHANGED
@@ -101,6 +101,39 @@
101
101
  <div class="rss-feed-error">{{ feed.lastError }}</div>
102
102
  {% endif %}
103
103
  </div>
104
+ <details class="rss-publish">
105
+ <summary>{{ __("rss.publish.title") }}{% if feed.publish.enabled %} &bull; {{ feed.publish.postType }}{% endif %}</summary>
106
+ <form data-publish-feed="{{ feed.id }}">
107
+ <label>
108
+ <input type="checkbox" name="enabled" {{ "checked" if feed.publish.enabled }}>
109
+ {{ __("rss.publish.enabled") }}
110
+ </label>
111
+ <label>
112
+ {{ __("rss.publish.postType") }}
113
+ <select name="postType">
114
+ {% for type in postTypes %}
115
+ <option value="{{ type }}" {{ "selected" if feed.publish.postType == type }}>{{ type }}</option>
116
+ {% endfor %}
117
+ </select>
118
+ </label>
119
+ <label>
120
+ {{ __("rss.publish.content") }}
121
+ <input class="input" type="text" name="content" value="{{ feed.publish.content or '{{description}}' }}">
122
+ </label>
123
+ <label>
124
+ {{ __("rss.publish.linkProperty") }}
125
+ <input class="input" type="text" name="linkProperty" value="{{ feed.publish.linkProperty or '' }}">
126
+ </label>
127
+ <label>
128
+ {{ __("rss.publish.status") }}
129
+ <select name="status">
130
+ <option value="draft" {{ "selected" if feed.publish.status != "published" }}>draft</option>
131
+ <option value="published" {{ "selected" if feed.publish.status == "published" }}>published</option>
132
+ </select>
133
+ </label>
134
+ {{ button({ type: "submit", text: __("rss.publish.save") }) }}
135
+ </form>
136
+ </details>
104
137
  <div class="rss-feed-actions">
105
138
  <label class="rss-toggle">
106
139
  <input
@@ -280,5 +313,39 @@
280
313
  }
281
314
  });
282
315
  });
316
+
317
+ // Handle publish configuration
318
+ document.querySelectorAll('[data-publish-feed]').forEach(form => {
319
+ form.addEventListener('submit', async (e) => {
320
+ e.preventDefault();
321
+ const feedId = e.target.dataset.publishFeed;
322
+ const data = new FormData(e.target);
323
+
324
+ const publish = {
325
+ enabled: data.get('enabled') === 'on',
326
+ postType: data.get('postType'),
327
+ content: data.get('content'),
328
+ linkProperty: data.get('linkProperty') || null,
329
+ status: data.get('status'),
330
+ };
331
+
332
+ try {
333
+ const response = await fetch(`{{ mountPath }}/api/feeds/${feedId}`, {
334
+ method: 'PATCH',
335
+ headers: { 'Content-Type': 'application/json' },
336
+ body: JSON.stringify({ publish })
337
+ });
338
+
339
+ if (response.ok) {
340
+ location.reload();
341
+ } else {
342
+ const body = await response.json();
343
+ alert(body.error || 'Failed to save publish settings');
344
+ }
345
+ } catch (err) {
346
+ alert('Failed to save publish settings: ' + err.message);
347
+ }
348
+ });
349
+ });
283
350
  </script>
284
351
  {% endblock %}