@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 +5 -0
- package/lib/controllers/dashboard.js +44 -1
- package/lib/feed-settings.js +31 -0
- package/lib/jf2-builder.js +53 -7
- package/lib/utils.js +73 -4
- package/locales/en.json +9 -1
- package/package.json +1 -1
- package/views/rss.njk +28 -0
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 {
|
|
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
|
package/lib/feed-settings.js
CHANGED
|
@@ -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
|
+
}
|
package/lib/jf2-builder.js
CHANGED
|
@@ -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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
* @
|
|
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
|
-
|
|
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
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>
|