@rmdes/indiekit-endpoint-rss 1.1.0 → 1.1.2

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 CHANGED
@@ -83,13 +83,30 @@
83
83
  }
84
84
 
85
85
  .rss-feed-item {
86
- align-items: center;
86
+ align-items: flex-start;
87
87
  border-block-end: 1px solid var(--color-border);
88
88
  display: flex;
89
89
  gap: var(--space-s);
90
90
  padding: var(--space-s) 0;
91
91
  }
92
92
 
93
+ /* The publish disclosure lives inside the feed's own column. As a full-width
94
+ flex row it turned every feed into three rows and put a bar wider than the
95
+ feed between each entry, which made the list unscannable. Its summary stays
96
+ quiet so the feed title remains what the eye lands on. */
97
+ .rss-publish {
98
+ margin-block-start: var(--space-2xs);
99
+ }
100
+
101
+ .rss-publish .details__summary {
102
+ color: var(--color-text-secondary);
103
+ font-size: var(--step--1);
104
+ }
105
+
106
+ .rss-publish .details__main {
107
+ padding-block-start: var(--space-2xs);
108
+ }
109
+
93
110
  .rss-feed-item:last-child {
94
111
  border-block-end: none;
95
112
  }
@@ -156,13 +156,13 @@ export const feedsController = {
156
156
  async toggle(request, response) {
157
157
  try {
158
158
  const { id } = request.params;
159
- const { enabled, publish } = request.body;
159
+ const { enabled, publish, url } = request.body;
160
160
 
161
161
  if (!ObjectId.isValid(id)) {
162
162
  return response.status(400).json({ error: "Invalid feed ID" });
163
163
  }
164
164
 
165
- if (enabled === undefined && publish === undefined) {
165
+ if (enabled === undefined && publish === undefined && url === undefined) {
166
166
  return response.status(400).json({ error: "Nothing to update" });
167
167
  }
168
168
 
@@ -191,6 +191,31 @@ export const feedsController = {
191
191
  update.enabled = enabled;
192
192
  }
193
193
 
194
+ if (url !== undefined) {
195
+ if (!isValidUrl(url)) {
196
+ return response.status(400).json({
197
+ error: response.locals.__("rss.error.invalidUrl"),
198
+ });
199
+ }
200
+
201
+ const normalizedUrl = normalizeUrl(url);
202
+ const clash = await feedsCollection.findOne({
203
+ url: normalizedUrl,
204
+ _id: { $ne: feedId },
205
+ });
206
+
207
+ if (clash) {
208
+ return response.status(409).json({
209
+ error: response.locals.__("rss.error.feedExists"),
210
+ });
211
+ }
212
+
213
+ update.url = normalizedUrl;
214
+ // Items key on feedId, so they survive the change. Any error from the
215
+ // old URL is stale the moment it is corrected.
216
+ update.lastError = null;
217
+ }
218
+
194
219
  if (publish !== undefined) {
195
220
  const { postTypes } = request.app.locals.publication || {};
196
221
 
package/lib/sync.js CHANGED
@@ -173,7 +173,7 @@ export async function runSync(dbOrIndiekit, options, publishContext) {
173
173
  itemsCollection,
174
174
  feedsCollection,
175
175
  retentionDays,
176
- options.minItemsPerFeed ?? 10,
176
+ retentionFloor(options),
177
177
  );
178
178
 
179
179
  syncState.lastSync = new Date().toISOString();
@@ -310,6 +310,21 @@ async function createIndexes(feedsCollection, itemsCollection) {
310
310
  await itemsCollection.createIndex({ fetchedAt: -1 });
311
311
  }
312
312
 
313
+ /**
314
+ * Number of newest items per feed that pruning must never touch.
315
+ *
316
+ * Pruning an item the feed still serves is a no-op by construction — the next
317
+ * sync re-inserts it. A sync stores at most `maxItemsPerFeed` items per feed,
318
+ * so age-based retention below that threshold can only churn: insert, delete,
319
+ * refetch, forever. The floor is therefore derived from what a sync can store,
320
+ * not set as an independent constant.
321
+ * @param {object} options - Plugin options
322
+ * @returns {number} Items to keep per feed regardless of age
323
+ */
324
+ export function retentionFloor(options = {}) {
325
+ return Math.max(options.minItemsPerFeed ?? 10, options.maxItemsPerFeed ?? 50);
326
+ }
327
+
313
328
  /**
314
329
  * Rewrite legacy BSON Date values of `addedAt` as ISO 8601 strings.
315
330
  *
package/locales/en.json CHANGED
@@ -40,7 +40,7 @@
40
40
  "feedAdded": "Feed added successfully",
41
41
  "feedRemoved": "Feed removed",
42
42
  "feedUpdated": "Feed updated",
43
- "feedEnabled": "Feed enabled",
43
+ "feedEnabled": "Feed enabled",
44
44
  "feedDisabled": "Feed disabled",
45
45
  "backfillQueued": "Backfill queued; items will be published as drafts over the next sync cycles",
46
46
  "syncComplete": "Sync complete",
@@ -56,9 +56,18 @@
56
56
  "enabled": "Create posts from this feed",
57
57
  "postType": "Post type",
58
58
  "content": "Content template",
59
+ "contentHint": "Available: title, link, description, content, author, sourceTitle. Write each one in double braces, as shown below.",
59
60
  "linkProperty": "Link property",
61
+ "linkPropertyHint": "Discovery property carrying the item URL, e.g. bookmark-of. Leave empty for a plain note or article.",
60
62
  "status": "Post status",
61
- "save": "Save publish settings"
63
+ "draft": "Draft",
64
+ "published": "Published",
65
+ "save": "Save settings"
66
+ },
67
+ "settings": {
68
+ "title": "Feed settings",
69
+ "url": "Feed URL",
70
+ "urlHint": "Correcting this keeps the cached items; removing and re-adding the feed would lose them."
62
71
  }
63
72
  }
64
73
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-rss",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
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
@@ -100,40 +100,88 @@
100
100
  {% if feed.lastError %}
101
101
  <div class="rss-feed-error">{{ feed.lastError }}</div>
102
102
  {% endif %}
103
+ {% set typeItems = [] %}
104
+ {% for type in postTypes %}
105
+ {% set typeItems = (typeItems.push({
106
+ text: type,
107
+ value: type,
108
+ selected: feed.publish.postType == type
109
+ }), typeItems) %}
110
+ {% endfor %}
111
+ {% set publishSummary %}{{ __("rss.settings.title") }}{% if feed.publish.enabled %} &bull; {{ feed.publish.postType }}{% endif %}{% endset %}
112
+ {% call details({
113
+ classes: "rss-publish",
114
+ summary: publishSummary
115
+ }) %}
116
+ <form data-publish-feed="{{ feed.id }}">
117
+ {{ input({
118
+ id: "feed-url-" + feed.id,
119
+ name: "url",
120
+ type: "url",
121
+ label: __("rss.settings.url"),
122
+ hint: __("rss.settings.urlHint"),
123
+ value: feed.url
124
+ }) }}
125
+
126
+ {{ checkboxes({
127
+ idPrefix: "publish-enabled-" + feed.id,
128
+ name: "enabled",
129
+ items: [{
130
+ value: "true",
131
+ label: __("rss.publish.enabled"),
132
+ checked: feed.publish.enabled
133
+ }]
134
+ }) }}
135
+
136
+ {{ select({
137
+ id: "publish-post-type-" + feed.id,
138
+ name: "postType",
139
+ label: __("rss.publish.postType"),
140
+ items: typeItems
141
+ }) }}
142
+
143
+ {{ input({
144
+ id: "publish-content-" + feed.id,
145
+ name: "content",
146
+ label: __("rss.publish.content"),
147
+ hint: __("rss.publish.contentHint"),
148
+ value: feed.publish.content or "{{description}}"
149
+ }) }}
150
+
151
+ {{ input({
152
+ id: "publish-link-property-" + feed.id,
153
+ name: "linkProperty",
154
+ label: __("rss.publish.linkProperty"),
155
+ hint: __("rss.publish.linkPropertyHint"),
156
+ optional: true,
157
+ value: feed.publish.linkProperty
158
+ }) }}
159
+
160
+ {{ select({
161
+ id: "publish-status-" + feed.id,
162
+ name: "status",
163
+ label: __("rss.publish.status"),
164
+ items: [
165
+ {
166
+ text: __("rss.publish.draft"),
167
+ value: "draft",
168
+ selected: feed.publish.status != "published"
169
+ },
170
+ {
171
+ text: __("rss.publish.published"),
172
+ value: "published",
173
+ selected: feed.publish.status == "published"
174
+ }
175
+ ]
176
+ }) }}
177
+
178
+ {{ button({
179
+ type: "submit",
180
+ text: __("rss.publish.save")
181
+ }) }}
182
+ </form>
183
+ {% endcall %}
103
184
  </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>
137
185
  <div class="rss-feed-actions">
138
186
  <label class="rss-toggle">
139
187
  <input
@@ -322,7 +370,9 @@
322
370
  const data = new FormData(e.target);
323
371
 
324
372
  const publish = {
325
- enabled: data.get('enabled') === 'on',
373
+ // The checkboxes component sets value="true"; an unchecked box is
374
+ // omitted from FormData entirely, so presence is the signal.
375
+ enabled: data.get('enabled') !== null,
326
376
  postType: data.get('postType'),
327
377
  content: data.get('content'),
328
378
  linkProperty: data.get('linkProperty') || null,
@@ -333,7 +383,7 @@
333
383
  const response = await fetch(`{{ mountPath }}/api/feeds/${feedId}`, {
334
384
  method: 'PATCH',
335
385
  headers: { 'Content-Type': 'application/json' },
336
- body: JSON.stringify({ publish })
386
+ body: JSON.stringify({ url: data.get('url'), publish })
337
387
  });
338
388
 
339
389
  if (response.ok) {