@rmdes/indiekit-endpoint-rss 1.1.4 → 1.2.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/index.js CHANGED
@@ -86,6 +86,11 @@ export default class RssEndpoint {
86
86
  express.urlencoded({ extended: false }),
87
87
  dashboardController.backfillFeed,
88
88
  );
89
+ router.post(
90
+ "/feeds/:id/republish",
91
+ express.urlencoded({ extended: false }),
92
+ dashboardController.republishFeed,
93
+ );
89
94
 
90
95
  // Feed management (protected - requires auth)
91
96
  router.post("/api/feeds", express.json(), feedsController.add);
@@ -1,4 +1,8 @@
1
- import { applyFeedSettings, rewindWatermark } from "../feed-settings.js";
1
+ import {
2
+ applyFeedSettings,
3
+ resetPublishState,
4
+ rewindWatermark,
5
+ } from "../feed-settings.js";
2
6
  import { getSyncState, runSync } from "../sync.js";
3
7
  import { formatFeed, formatItem } from "../utils.js";
4
8
 
@@ -130,6 +134,7 @@ export const dashboardController = {
130
134
  content: request.body.content,
131
135
  linkProperty: request.body.linkProperty || null,
132
136
  status: request.body.status,
137
+ dateSource: request.body.dateSource,
133
138
  },
134
139
  };
135
140
 
@@ -214,6 +219,44 @@ export const dashboardController = {
214
219
  }
215
220
  },
216
221
 
222
+
223
+ /**
224
+ * Forget what a feed has published, then publish it again
225
+ * POST /feeds/:id/republish
226
+ */
227
+ async republishFeed(request, response) {
228
+ try {
229
+ const { rssConfig, getRssDb } = request.app.locals.application;
230
+ const db = getRssDb?.();
231
+ if (!db) {
232
+ return flashBack(request, response, "error", "Database not available");
233
+ }
234
+
235
+ const reset = await resetPublishState(db, request.params.id);
236
+ if (reset.error) {
237
+ return flashBack(request, response, "error", reset.error);
238
+ }
239
+
240
+ const sync = await runSync(db, rssConfig, publishContextFrom(request));
241
+ if (sync.error) {
242
+ return flashBack(request, response, "error", sync.error);
243
+ }
244
+
245
+ const published = sync.itemsPublished || 0;
246
+ flashBack(
247
+ request,
248
+ response,
249
+ published > 0 ? "success" : "warning",
250
+ published > 0
251
+ ? `Cleared ${reset.reset} item(s) and republished ${published}.`
252
+ : `Cleared ${reset.reset} item(s), but none published — check the watermark and the logs.`,
253
+ );
254
+ } catch (error) {
255
+ console.error("[RSS] Error republishing feed:", error.message);
256
+ flashBack(request, response, "error", error.message);
257
+ }
258
+ },
259
+
217
260
  /**
218
261
  * Clear all items and re-sync
219
262
  * POST /clear-resync
@@ -181,3 +181,34 @@ export async function rewindWatermark(db, id, body) {
181
181
 
182
182
  return { status: 200, since: watermark, eligible };
183
183
  }
184
+
185
+ /**
186
+ * Clear a feed's publish state so its items can be published again.
187
+ *
188
+ * Without this a feed is stuck the moment its items carry postedAt: changing
189
+ * the template, the post type or the date source has no way to reach items
190
+ * already sent. Existing posts are not deleted — republishing writes over the
191
+ * same URL, because the slug is derived from the item's guid.
192
+ * @param {object} db - Database
193
+ * @param {string} id - Feed id
194
+ * @returns {Promise<object>} Result carrying status, error, reset
195
+ */
196
+ export async function resetPublishState(db, id) {
197
+ if (!ObjectId.isValid(id)) {
198
+ return { status: 400, error: "Invalid feed ID" };
199
+ }
200
+
201
+ const feedId = new ObjectId(id);
202
+ const feed = await db.collection("rssFeeds").findOne({ _id: feedId });
203
+
204
+ if (!feed) {
205
+ return { status: 404, error: "Feed not found" };
206
+ }
207
+
208
+ const result = await db.collection("rssItems").updateMany(
209
+ { feedId },
210
+ { $unset: { postedAt: "", postUrl: "", postSkipped: "", postError: "", postErrorAt: "", postAttempts: "" } },
211
+ );
212
+
213
+ return { status: 200, reset: result.modifiedCount };
214
+ }
@@ -1,3 +1,5 @@
1
+ import { createHash } from "node:crypto";
2
+
1
3
  import { sanitizeHtml, stripHtml } from "./utils.js";
2
4
 
3
5
  /**
@@ -14,14 +16,44 @@ const LINK_PROPERTY_DEFAULTS = {
14
16
  };
15
17
 
16
18
  const PLACEHOLDERS = {
17
- title: (item) => stripHtml(item.title),
19
+ title: (item) => oneLine(stripHtml(item.title)),
18
20
  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),
21
+ description: (item) => oneLine(stripHtml(item.description)),
22
+ // The item's own link is the right base: a feed's markup is relative to
23
+ // the site that published it, not to ours.
24
+ content: (item) => sanitizeHtml(item.content, { baseUrl: item.link }),
25
+ author: (item) => oneLine(stripHtml(item.author)),
26
+ sourceTitle: (item) => oneLine(stripHtml(item.sourceTitle)),
23
27
  };
24
28
 
29
+
30
+ /**
31
+ * Collapse whitespace runs so an excerpt stays one line.
32
+ *
33
+ * Feed descriptions often arrive indented. Markdown reads an indented line as
34
+ * a code block, which is how a GitHub timeline entry rendered as <pre><code>.
35
+ * @param {string} value - Text
36
+ * @returns {string} Single-line text
37
+ */
38
+ const oneLine = (value) => (value ? String(value).replace(/\s+/g, " ").trim() : "");
39
+
40
+ /**
41
+ * Short, stable discriminator for an item.
42
+ *
43
+ * Indiekit derives a slug from the first five words of the name, so feed items
44
+ * sharing a title prefix produce the same URL — and postData.create replaces
45
+ * on that URL, so the second item silently overwrites the first. Three GitHub
46
+ * timeline entries collapsed into one post that way. The guid is already the
47
+ * deduplication key, so it is the right thing to discriminate on.
48
+ * @param {object} item - RSS item document
49
+ * @returns {string} Six hex characters
50
+ */
51
+ const discriminator = (item) =>
52
+ createHash("sha1")
53
+ .update(String(item.guid ?? item.link ?? item.title ?? ""))
54
+ .digest("hex")
55
+ .slice(0, 6);
56
+
25
57
  /**
26
58
  * Link property offered when a post type is chosen
27
59
  * @param {string} postType - Post type key
@@ -73,7 +105,7 @@ export function buildJf2(item, publish) {
73
105
  // A `name` next to content makes discovery return "article" — see
74
106
  // post-type-discovery.js: `if (content && properties.name) return "article"`.
75
107
  // A note must therefore carry none.
76
- const name = stripHtml(item.title);
108
+ const name = oneLine(stripHtml(item.title));
77
109
  if (publish.postType !== "note" && name) {
78
110
  properties.name = name;
79
111
  }
@@ -91,7 +123,14 @@ export function buildJf2(item, publish) {
91
123
  properties[publish.linkProperty] = value;
92
124
  }
93
125
 
94
- if (item.pubDate) {
126
+ // dateSource decides whether the post carries the item's own date or the
127
+ // moment it was published here. The item's date is truthful — the article
128
+ // really was written then — but it buries the post at its chronological
129
+ // place, so a feed of older items produces posts nobody sees. "created"
130
+ // trades that accuracy for visibility.
131
+ if (publish.dateSource === "created") {
132
+ properties.published = new Date().toISOString();
133
+ } else if (item.pubDate) {
95
134
  properties.published =
96
135
  item.pubDate instanceof Date ? item.pubDate.toISOString() : item.pubDate;
97
136
  }
@@ -104,6 +143,13 @@ export function buildJf2(item, publish) {
104
143
  properties.syndication = [item.link];
105
144
  }
106
145
 
146
+ // Five title words plus a guid-derived suffix: readable, and impossible for
147
+ // two distinct items to collide on.
148
+ properties["mp-slug"] = [
149
+ ...name.split(/\s+/).filter(Boolean).slice(0, 5),
150
+ discriminator(item),
151
+ ].join(" ");
152
+
107
153
  properties["post-status"] =
108
154
  publish.status === "published" ? "published" : "draft";
109
155
 
package/lib/utils.js CHANGED
@@ -14,13 +14,34 @@ function toISO(value) {
14
14
  }
15
15
 
16
16
  /**
17
- * Sanitize HTML content - strip dangerous tags, keep basic formatting
17
+ * Sanitize HTML content, keeping as much of it as is safe to keep.
18
+ *
19
+ * Feeds carry real formatting — headings, images, links, code — and dropping
20
+ * it produces a flat paraphrase of the source. The job here is to remove what
21
+ * is dangerous, not what is rich.
18
22
  * @param {string} html - Raw HTML
19
- * @returns {string}
23
+ * @param {object} [options] - Options
24
+ * @param {string} [options.baseUrl] - Resolves relative hrefs and image srcs
25
+ * @returns {string} Sanitized HTML
20
26
  */
21
- export function sanitizeHtml(html) {
27
+ export function sanitizeHtml(html, { baseUrl } = {}) {
22
28
  if (!html) return "";
23
- return sanitizeHtmlLib(html, {
29
+
30
+ /**
31
+ * Make a feed-relative URL absolute against its source.
32
+ * @param {string} value - href or src
33
+ * @returns {string} Absolute URL where possible
34
+ */
35
+ const absolute = (value) => {
36
+ if (!value || !baseUrl) return value;
37
+ try {
38
+ return new URL(value, baseUrl).href;
39
+ } catch {
40
+ return value;
41
+ }
42
+ };
43
+
44
+ const clean = sanitizeHtmlLib(html, {
24
45
  allowedTags: [
25
46
  "p",
26
47
  "br",
@@ -35,9 +56,17 @@ export function sanitizeHtml(html) {
35
56
  "blockquote",
36
57
  "code",
37
58
  "pre",
59
+ "img",
60
+ "figure",
61
+ "figcaption",
62
+ "h2",
63
+ "h3",
64
+ "h4",
65
+ "hr",
38
66
  ],
39
67
  allowedAttributes: {
40
68
  a: ["href", "title", "rel"],
69
+ img: ["src", "alt", "title", "width", "height", "loading"],
41
70
  code: ["class"],
42
71
  pre: ["class"],
43
72
  },
@@ -46,7 +75,47 @@ export function sanitizeHtml(html) {
46
75
  pre: ["language-*"],
47
76
  },
48
77
  allowedSchemes: ["http", "https", "mailto"],
78
+ transformTags: {
79
+ // A feed's markup is relative to the feed's own site. Left as-is these
80
+ // resolve against the publishing site instead, so every link and image
81
+ // silently points at the wrong host.
82
+ a: (tagName, attribs) => ({
83
+ tagName,
84
+ attribs: { ...attribs, ...(attribs.href && { href: absolute(attribs.href) }) },
85
+ }),
86
+ img: (tagName, attribs) => ({
87
+ tagName,
88
+ attribs: {
89
+ ...attribs,
90
+ ...(attribs.src && { src: absolute(attribs.src) }),
91
+ loading: "lazy",
92
+ },
93
+ }),
94
+ },
95
+ // An anchor whose only child was a stripped element leaves an empty link.
96
+ exclusiveFilter: (frame) =>
97
+ frame.tag === "a" && !frame.text.trim() && !frame.mediaChildren?.length,
49
98
  });
99
+
100
+ return collapseIndentation(clean).trim();
101
+ }
102
+
103
+ /**
104
+ * Collapse whitespace runs outside <pre> blocks.
105
+ *
106
+ * Micropub turns HTML into the markdown that gets stored, normalising through
107
+ * markdown-it first — where a line starting with four spaces is an indented
108
+ * code block. Feed markup is usually pretty-printed, so whole paragraphs came
109
+ * back wrapped in a fence with their tags escaped. Inside <pre> the
110
+ * indentation is the content, so it is left alone.
111
+ * @param {string} html - Sanitized HTML
112
+ * @returns {string} HTML without cosmetic indentation
113
+ */
114
+ function collapseIndentation(html) {
115
+ return html
116
+ .split(/(<pre[\s\S]*?<\/pre>)/i)
117
+ .map((part, index) => (index % 2 ? part : part.replace(/\s+/g, " ")))
118
+ .join("");
50
119
  }
51
120
 
52
121
  /**
package/locales/en.json CHANGED
@@ -62,7 +62,11 @@
62
62
  "status": "Post status",
63
63
  "draft": "Draft",
64
64
  "published": "Published",
65
- "save": "Save settings"
65
+ "save": "Save settings",
66
+ "dateSource": "Post date",
67
+ "dateFromItem": "The item's own publication date",
68
+ "dateFromCreation": "When it was published here",
69
+ "dateSourceHint": "The item's own date is accurate but sorts the post into the past, where a feed of older items produces posts nobody sees."
66
70
  },
67
71
  "settings": {
68
72
  "title": "Feed settings",
@@ -73,6 +77,10 @@
73
77
  "label": "Publish existing items",
74
78
  "hint": "How many of the newest cached items to publish. They are created with this feed's post status, a few per sync cycle.",
75
79
  "submit": "Publish existing items"
80
+ },
81
+ "republish": {
82
+ "submit": "Forget and republish",
83
+ "hint": "Clears what this feed has published so the current settings apply to every item again. Existing posts are overwritten, not duplicated."
76
84
  }
77
85
  }
78
86
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-rss",
3
- "version": "1.1.4",
3
+ "version": "1.2.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",
package/views/rss.njk CHANGED
@@ -175,6 +175,25 @@
175
175
  ]
176
176
  }) }}
177
177
 
178
+ {{ select({
179
+ id: "publish-date-source-" + feed.id,
180
+ name: "dateSource",
181
+ label: __("rss.publish.dateSource"),
182
+ hint: __("rss.publish.dateSourceHint"),
183
+ items: [
184
+ {
185
+ text: __("rss.publish.dateFromItem"),
186
+ value: "item",
187
+ selected: feed.publish.dateSource != "created"
188
+ },
189
+ {
190
+ text: __("rss.publish.dateFromCreation"),
191
+ value: "created",
192
+ selected: feed.publish.dateSource == "created"
193
+ }
194
+ ]
195
+ }) }}
196
+
178
197
  {{ button({
179
198
  type: "submit",
180
199
  text: __("rss.publish.save")
@@ -197,6 +216,15 @@
197
216
  text: __("rss.backfill.submit")
198
217
  }) }}
199
218
  </form>
219
+
220
+ <form class="rss-republish" action="{{ mountPath }}/feeds/{{ feed.id }}/republish" method="post">
221
+ <p class="hint">{{ __("rss.republish.hint") }}</p>
222
+ {{ button({
223
+ classes: "button--secondary",
224
+ type: "submit",
225
+ text: __("rss.republish.submit")
226
+ }) }}
227
+ </form>
200
228
  {% endif %}
201
229
  {% endcall %}
202
230
  </div>