@rmdes/indiekit-endpoint-blogroll 1.0.3 → 1.0.5

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,9 @@ export default class BlogrollEndpoint {
86
86
  protectedRouter.post("/blogs/:id/delete", blogsController.remove);
87
87
  protectedRouter.post("/blogs/:id/refresh", blogsController.refresh);
88
88
 
89
+ // Feed discovery (protected to prevent abuse)
90
+ protectedRouter.get("/api/discover", apiController.discover);
91
+
89
92
  return protectedRouter;
90
93
  }
91
94
 
@@ -8,6 +8,7 @@ import { getBlogs, countBlogs, getBlog, getCategories } from "../storage/blogs.j
8
8
  import { getItems, getItemsForBlog } from "../storage/items.js";
9
9
  import { getSyncStatus } from "../sync/scheduler.js";
10
10
  import { generateOpml } from "../sync/opml.js";
11
+ import { discoverFeeds } from "../utils/feed-discovery.js";
11
12
 
12
13
  /**
13
14
  * List blogs with optional filtering
@@ -185,6 +186,26 @@ async function exportOpmlCategory(request, response) {
185
186
  }
186
187
  }
187
188
 
189
+ /**
190
+ * Discover feeds from a website URL
191
+ * GET /api/discover?url=...
192
+ */
193
+ async function discover(request, response) {
194
+ const { url } = request.query;
195
+
196
+ if (!url) {
197
+ return response.status(400).json({ error: "URL parameter required" });
198
+ }
199
+
200
+ try {
201
+ const result = await discoverFeeds(url);
202
+ response.json(result);
203
+ } catch (error) {
204
+ console.error("[Blogroll API] discover error:", error);
205
+ response.status(500).json({ error: "Failed to discover feeds" });
206
+ }
207
+ }
208
+
188
209
  // Helper functions
189
210
 
190
211
  /**
@@ -237,4 +258,5 @@ export const apiController = {
237
258
  status,
238
259
  exportOpml,
239
260
  exportOpmlCategory,
261
+ discover,
240
262
  };
@@ -20,7 +20,17 @@ async function list(request, response) {
20
20
  const { application } = request.app.locals;
21
21
 
22
22
  try {
23
- const sources = await getSources(application);
23
+ const rawSources = await getSources(application);
24
+
25
+ // Convert Date objects to ISO strings for template date filter compatibility
26
+ const sources = rawSources.map((source) => ({
27
+ ...source,
28
+ lastSyncAt: source.lastSyncAt
29
+ ? (source.lastSyncAt instanceof Date
30
+ ? source.lastSyncAt.toISOString()
31
+ : source.lastSyncAt)
32
+ : null,
33
+ }));
24
34
 
25
35
  response.render("blogroll-sources", {
26
36
  title: request.__("blogroll.sources.title"),
@@ -83,25 +93,19 @@ async function create(request, response) {
83
93
  });
84
94
 
85
95
  // Trigger initial sync for OPML sources
86
- if (source.type === "opml_url" || source.type === "opml_file") {
87
- try {
88
- await syncOpmlSource(application, source);
89
- request.session.messages = [
90
- { type: "success", content: request.__("blogroll.sources.created_synced") },
91
- ];
92
- } catch (syncError) {
93
- request.session.messages = [
94
- {
95
- type: "warning",
96
- content: request.__("blogroll.sources.created_sync_failed", {
97
- error: syncError.message,
98
- }),
99
- },
100
- ];
101
- }
102
- } else {
96
+ try {
97
+ await syncOpmlSource(application, source);
98
+ request.session.messages = [
99
+ { type: "success", content: request.__("blogroll.sources.created_synced") },
100
+ ];
101
+ } catch (syncError) {
103
102
  request.session.messages = [
104
- { type: "success", content: request.__("blogroll.sources.created") },
103
+ {
104
+ type: "warning",
105
+ content: request.__("blogroll.sources.created_sync_failed", {
106
+ error: syncError.message,
107
+ }),
108
+ },
105
109
  ];
106
110
  }
107
111
 
@@ -0,0 +1,164 @@
1
+ /**
2
+ * RSS/Atom feed discovery from website URLs
3
+ * @module utils/feed-discovery
4
+ */
5
+
6
+ /**
7
+ * Discover RSS/Atom feeds from a website URL
8
+ * @param {string} websiteUrl - The website URL to check
9
+ * @param {number} timeout - Fetch timeout in ms
10
+ * @returns {Promise<object>} Discovery result with feeds array
11
+ */
12
+ export async function discoverFeeds(websiteUrl, timeout = 10000) {
13
+ const controller = new AbortController();
14
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
15
+
16
+ try {
17
+ // Normalize URL
18
+ let url = websiteUrl.trim();
19
+ if (!url.startsWith("http://") && !url.startsWith("https://")) {
20
+ url = "https://" + url;
21
+ }
22
+
23
+ const response = await fetch(url, {
24
+ signal: controller.signal,
25
+ headers: {
26
+ "User-Agent": "Indiekit-Blogroll/1.0 (Feed Discovery)",
27
+ Accept: "text/html,application/xhtml+xml",
28
+ },
29
+ });
30
+
31
+ if (!response.ok) {
32
+ return { success: false, error: `HTTP ${response.status}`, feeds: [] };
33
+ }
34
+
35
+ const html = await response.text();
36
+ const feeds = [];
37
+ const baseUrl = new URL(url);
38
+
39
+ // Find <link rel="alternate"> feeds in HTML
40
+ const linkRegex =
41
+ /<link[^>]+rel=["']alternate["'][^>]*>/gi;
42
+ const typeRegex = /type=["']([^"']+)["']/i;
43
+ const hrefRegex = /href=["']([^"']+)["']/i;
44
+ const titleRegex = /title=["']([^"']+)["']/i;
45
+
46
+ const feedTypes = [
47
+ "application/rss+xml",
48
+ "application/atom+xml",
49
+ "application/feed+json",
50
+ "application/json",
51
+ "text/xml",
52
+ ];
53
+
54
+ let match;
55
+ while ((match = linkRegex.exec(html)) !== null) {
56
+ const linkTag = match[0];
57
+ const typeMatch = typeRegex.exec(linkTag);
58
+ const hrefMatch = hrefRegex.exec(linkTag);
59
+
60
+ if (hrefMatch) {
61
+ const type = typeMatch ? typeMatch[1].toLowerCase() : "";
62
+ const href = hrefMatch[1];
63
+ const titleMatch = titleRegex.exec(linkTag);
64
+ const title = titleMatch ? titleMatch[1] : null;
65
+
66
+ // Check if it's a feed type
67
+ if (feedTypes.some((ft) => type.includes(ft.split("/")[1]))) {
68
+ // Resolve relative URLs
69
+ const feedUrl = new URL(href, baseUrl).href;
70
+
71
+ feeds.push({
72
+ url: feedUrl,
73
+ type: type.includes("atom")
74
+ ? "atom"
75
+ : type.includes("json")
76
+ ? "json"
77
+ : "rss",
78
+ title,
79
+ });
80
+ }
81
+ }
82
+ }
83
+
84
+ // Also check common feed paths if no feeds found in HTML
85
+ if (feeds.length === 0) {
86
+ const commonPaths = [
87
+ "/feed",
88
+ "/feed.xml",
89
+ "/rss",
90
+ "/rss.xml",
91
+ "/atom.xml",
92
+ "/feed/atom",
93
+ "/feed/rss",
94
+ "/index.xml",
95
+ "/blog/feed",
96
+ "/blog/rss",
97
+ "/.rss",
98
+ "/feed.json",
99
+ ];
100
+
101
+ for (const path of commonPaths) {
102
+ try {
103
+ const feedUrl = new URL(path, baseUrl).href;
104
+ const feedResponse = await fetch(feedUrl, {
105
+ method: "HEAD",
106
+ signal: controller.signal,
107
+ headers: {
108
+ "User-Agent": "Indiekit-Blogroll/1.0 (Feed Discovery)",
109
+ },
110
+ });
111
+
112
+ if (feedResponse.ok) {
113
+ const contentType = feedResponse.headers.get("content-type") || "";
114
+ if (
115
+ contentType.includes("xml") ||
116
+ contentType.includes("rss") ||
117
+ contentType.includes("atom") ||
118
+ contentType.includes("json")
119
+ ) {
120
+ feeds.push({
121
+ url: feedUrl,
122
+ type: contentType.includes("atom")
123
+ ? "atom"
124
+ : contentType.includes("json")
125
+ ? "json"
126
+ : "rss",
127
+ title: null,
128
+ });
129
+ break; // Found one, stop checking
130
+ }
131
+ }
132
+ } catch {
133
+ // Ignore individual path errors
134
+ }
135
+ }
136
+ }
137
+
138
+ // Try to extract page title for blog name
139
+ let pageTitle = null;
140
+ const titleTagMatch = /<title[^>]*>([^<]+)<\/title>/i.exec(html);
141
+ if (titleTagMatch) {
142
+ pageTitle = titleTagMatch[1].trim();
143
+ // Clean up common suffixes
144
+ pageTitle = pageTitle
145
+ .replace(/\s*[-|–—]\s*.*$/, "")
146
+ .replace(/\s*:\s*Home.*$/i, "")
147
+ .trim();
148
+ }
149
+
150
+ return {
151
+ success: true,
152
+ feeds,
153
+ pageTitle,
154
+ siteUrl: baseUrl.origin,
155
+ };
156
+ } catch (error) {
157
+ if (error.name === "AbortError") {
158
+ return { success: false, error: "Request timed out", feeds: [] };
159
+ }
160
+ return { success: false, error: error.message, feeds: [] };
161
+ } finally {
162
+ clearTimeout(timeoutId);
163
+ }
164
+ }
package/locales/en.json CHANGED
@@ -32,30 +32,30 @@
32
32
  },
33
33
 
34
34
  "sources": {
35
- "title": "Sources",
36
- "manage": "Manage Sources",
37
- "add": "Add Source",
38
- "new": "New Source",
39
- "edit": "Edit Source",
40
- "create": "Create Source",
41
- "save": "Save Source",
42
- "empty": "No sources configured yet.",
43
- "recent": "Recent Sources",
35
+ "title": "OPML Sync",
36
+ "manage": "OPML Sync",
37
+ "add": "Add OPML Source",
38
+ "new": "New OPML Source",
39
+ "edit": "Edit OPML Source",
40
+ "create": "Create",
41
+ "save": "Save",
42
+ "empty": "No OPML sources configured. Use this to bulk-import blogs from FreshRSS or other feed readers.",
43
+ "recent": "OPML Sources",
44
44
  "interval": "Every %{minutes} min",
45
45
  "lastSync": "Last synced",
46
- "deleteConfirm": "Delete this source? Blogs imported from it will remain.",
47
- "created": "Source created successfully.",
48
- "created_synced": "Source created and synced successfully.",
49
- "created_sync_failed": "Source created, but sync failed: %{error}",
50
- "updated": "Source updated successfully.",
51
- "deleted": "Source deleted successfully.",
46
+ "deleteConfirm": "Delete this OPML source? Blogs imported from it will remain.",
47
+ "created": "OPML source created successfully.",
48
+ "created_synced": "OPML source created and synced successfully.",
49
+ "created_sync_failed": "OPML source created, but sync failed: %{error}",
50
+ "updated": "OPML source updated successfully.",
51
+ "deleted": "OPML source deleted successfully.",
52
52
  "synced": "Synced successfully. Added: %{added}, Updated: %{updated}",
53
53
  "form": {
54
54
  "name": "Name",
55
- "type": "Type",
56
- "typeHint": "How to import blogs from this source",
55
+ "type": "Import Type",
56
+ "typeHint": "URL syncs periodically, File is a one-time import",
57
57
  "url": "OPML URL",
58
- "urlHint": "URL of the OPML file to import",
58
+ "urlHint": "URL to your OPML file (e.g., FreshRSS export URL)",
59
59
  "opmlContent": "OPML Content",
60
60
  "opmlContentHint": "Paste the full OPML XML content here",
61
61
  "syncInterval": "Sync Interval",
@@ -91,6 +91,17 @@
91
91
  "deleted": "Blog deleted successfully.",
92
92
  "refreshed": "Blog refreshed. Added %{items} new items.",
93
93
  "form": {
94
+ "discoverUrl": "Website URL",
95
+ "discover": "Discover Feed",
96
+ "discoverHint": "Enter a website URL to auto-discover its RSS/Atom feed",
97
+ "discoverNoUrl": "Please enter a website URL",
98
+ "discovering": "Discovering...",
99
+ "discoveringHint": "Checking for RSS/Atom feeds...",
100
+ "discoverFailed": "Failed to discover feeds",
101
+ "discoverNoFeeds": "No feeds found on this website",
102
+ "discoverFoundOne": "Found feed:",
103
+ "discoverFoundMultiple": "Multiple feeds found. Click one to select:",
104
+ "discoverSelected": "Selected feed:",
94
105
  "feedUrl": "Feed URL",
95
106
  "feedUrlHint": "RSS, Atom, or JSON Feed URL",
96
107
  "title": "Title",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-blogroll",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "description": "Blogroll endpoint for Indiekit. Aggregates blog feeds from OPML, JSON feeds, or manual entry.",
5
5
  "keywords": [
6
6
  "indiekit",
@@ -107,6 +107,93 @@
107
107
  text-align: center;
108
108
  padding: var(--space-m, 1rem);
109
109
  }
110
+
111
+ .br-discover-section {
112
+ background: var(--color-offset, #f5f5f5);
113
+ border-radius: var(--border-radius-small, 0.5rem);
114
+ padding: var(--space-m, 1rem);
115
+ margin-block-end: var(--space-m, 1rem);
116
+ }
117
+
118
+ .br-discover-section .br-field {
119
+ margin-block-end: var(--space-s, 0.75rem);
120
+ }
121
+
122
+ .br-discover-input {
123
+ display: flex;
124
+ gap: var(--space-s, 0.75rem);
125
+ }
126
+
127
+ .br-discover-input input {
128
+ flex: 1;
129
+ appearance: none;
130
+ background-color: var(--color-background, #fff);
131
+ border: 1px solid var(--color-outline-variant, #ccc);
132
+ border-radius: var(--border-radius-small, 0.25rem);
133
+ font: var(--font-body, 0.875rem/1.4 sans-serif);
134
+ padding: calc(var(--space-s, 0.75rem) / 2) var(--space-s, 0.75rem);
135
+ }
136
+
137
+ .br-discover-result {
138
+ margin-block-start: var(--space-s, 0.75rem);
139
+ padding: var(--space-s, 0.75rem);
140
+ background: var(--color-background, #fff);
141
+ border-radius: var(--border-radius-small, 0.25rem);
142
+ font: var(--font-caption, 0.875rem/1.4 sans-serif);
143
+ }
144
+
145
+ .br-discover-result.br-discover-result--error {
146
+ color: var(--color-error, #dc3545);
147
+ }
148
+
149
+ .br-discover-result.br-discover-result--success {
150
+ color: var(--color-success, #28a745);
151
+ }
152
+
153
+ .br-discover-feeds {
154
+ list-style: none;
155
+ padding: 0;
156
+ margin: var(--space-xs, 0.5rem) 0 0 0;
157
+ display: flex;
158
+ flex-direction: column;
159
+ gap: var(--space-xs, 0.5rem);
160
+ }
161
+
162
+ .br-discover-feed {
163
+ display: flex;
164
+ align-items: center;
165
+ gap: var(--space-s, 0.75rem);
166
+ padding: var(--space-xs, 0.5rem);
167
+ background: var(--color-offset, #f5f5f5);
168
+ border-radius: var(--border-radius-small, 0.25rem);
169
+ cursor: pointer;
170
+ }
171
+
172
+ .br-discover-feed:hover {
173
+ background: var(--color-primary-offset, #e6f0ff);
174
+ }
175
+
176
+ .br-discover-feed-url {
177
+ flex: 1;
178
+ font-family: monospace;
179
+ font-size: 0.75rem;
180
+ word-break: break-all;
181
+ }
182
+
183
+ .br-discover-feed-type {
184
+ background: var(--color-primary, #0066cc);
185
+ color: white;
186
+ padding: 0.125rem 0.5rem;
187
+ border-radius: 0.25rem;
188
+ font-size: 0.625rem;
189
+ text-transform: uppercase;
190
+ }
191
+
192
+ .br-divider {
193
+ border: none;
194
+ border-block-start: 1px solid var(--color-outline-variant, #ddd);
195
+ margin: var(--space-m, 1rem) 0;
196
+ }
110
197
  </style>
111
198
 
112
199
  <header class="page-header">
@@ -121,6 +208,23 @@
121
208
  {% endfor %}
122
209
 
123
210
  <form method="post" action="{% if isNew %}{{ baseUrl }}/blogs{% else %}{{ baseUrl }}/blogs/{{ blog._id }}{% endif %}" class="br-form">
211
+ {% if isNew %}
212
+ <div class="br-discover-section">
213
+ <div class="br-field">
214
+ <label for="discoverUrl">{{ __("blogroll.blogs.form.discoverUrl") }}</label>
215
+ <div class="br-discover-input">
216
+ <input type="url" id="discoverUrl" placeholder="https://tantek.com">
217
+ <button type="button" id="discoverBtn" class="button button--secondary">
218
+ {{ __("blogroll.blogs.form.discover") }}
219
+ </button>
220
+ </div>
221
+ <span class="br-field-hint">{{ __("blogroll.blogs.form.discoverHint") }}</span>
222
+ </div>
223
+ <div id="discoverResult" class="br-discover-result" style="display: none;"></div>
224
+ </div>
225
+ <hr class="br-divider">
226
+ {% endif %}
227
+
124
228
  <div class="br-field">
125
229
  <label for="feedUrl">{{ __("blogroll.blogs.form.feedUrl") }}</label>
126
230
  <input type="url" id="feedUrl" name="feedUrl" value="{{ blog.feedUrl if blog else '' }}" required placeholder="https://example.com/feed.xml">
@@ -196,4 +300,127 @@
196
300
  {% endif %}
197
301
  </div>
198
302
  {% endif %}
303
+
304
+ {% if isNew %}
305
+ <script>
306
+ (function() {
307
+ const discoverBtn = document.getElementById('discoverBtn');
308
+ const discoverUrl = document.getElementById('discoverUrl');
309
+ const discoverResult = document.getElementById('discoverResult');
310
+ const feedUrlInput = document.getElementById('feedUrl');
311
+ const titleInput = document.getElementById('title');
312
+ const siteUrlInput = document.getElementById('siteUrl');
313
+
314
+ function showResult(message, isError, isSuccess) {
315
+ discoverResult.style.display = 'block';
316
+ discoverResult.className = 'br-discover-result' +
317
+ (isError ? ' br-discover-result--error' : '') +
318
+ (isSuccess ? ' br-discover-result--success' : '');
319
+ discoverResult.textContent = '';
320
+
321
+ const span = document.createElement('span');
322
+ span.textContent = message;
323
+ discoverResult.appendChild(span);
324
+ }
325
+
326
+ function showFeedUrl(message, url) {
327
+ discoverResult.style.display = 'block';
328
+ discoverResult.className = 'br-discover-result br-discover-result--success';
329
+ discoverResult.textContent = '';
330
+
331
+ const span = document.createElement('span');
332
+ span.textContent = message + ' ';
333
+ discoverResult.appendChild(span);
334
+
335
+ const code = document.createElement('code');
336
+ code.textContent = url;
337
+ discoverResult.appendChild(code);
338
+ }
339
+
340
+ discoverBtn.addEventListener('click', async function() {
341
+ const url = discoverUrl.value.trim();
342
+ if (!url) {
343
+ showResult('{{ __("blogroll.blogs.form.discoverNoUrl") }}', true, false);
344
+ return;
345
+ }
346
+
347
+ discoverBtn.disabled = true;
348
+ discoverBtn.textContent = '{{ __("blogroll.blogs.form.discovering") }}';
349
+ showResult('{{ __("blogroll.blogs.form.discoveringHint") }}', false, false);
350
+
351
+ try {
352
+ const response = await fetch('{{ baseUrl }}/api/discover?url=' + encodeURIComponent(url));
353
+ const data = await response.json();
354
+
355
+ if (!data.success) {
356
+ showResult(data.error || '{{ __("blogroll.blogs.form.discoverFailed") }}', true, false);
357
+ return;
358
+ }
359
+
360
+ if (data.feeds.length === 0) {
361
+ showResult('{{ __("blogroll.blogs.form.discoverNoFeeds") }}', true, false);
362
+ return;
363
+ }
364
+
365
+ // Auto-fill siteUrl and title if available
366
+ if (data.siteUrl) {
367
+ siteUrlInput.value = data.siteUrl;
368
+ }
369
+ if (data.pageTitle && !titleInput.value) {
370
+ titleInput.value = data.pageTitle;
371
+ }
372
+
373
+ // If only one feed, auto-select it
374
+ if (data.feeds.length === 1) {
375
+ feedUrlInput.value = data.feeds[0].url;
376
+ showFeedUrl('{{ __("blogroll.blogs.form.discoverFoundOne") }}', data.feeds[0].url);
377
+ return;
378
+ }
379
+
380
+ // Multiple feeds - let user choose
381
+ showResult('{{ __("blogroll.blogs.form.discoverFoundMultiple") }}', false, true);
382
+
383
+ const feedList = document.createElement('ul');
384
+ feedList.className = 'br-discover-feeds';
385
+
386
+ data.feeds.forEach(function(feed) {
387
+ const li = document.createElement('li');
388
+ li.className = 'br-discover-feed';
389
+
390
+ const typeSpan = document.createElement('span');
391
+ typeSpan.className = 'br-discover-feed-type';
392
+ typeSpan.textContent = feed.type;
393
+ li.appendChild(typeSpan);
394
+
395
+ const urlSpan = document.createElement('span');
396
+ urlSpan.className = 'br-discover-feed-url';
397
+ urlSpan.textContent = feed.url;
398
+ li.appendChild(urlSpan);
399
+
400
+ li.addEventListener('click', function() {
401
+ feedUrlInput.value = feed.url;
402
+ showFeedUrl('{{ __("blogroll.blogs.form.discoverSelected") }}', feed.url);
403
+ });
404
+ feedList.appendChild(li);
405
+ });
406
+
407
+ discoverResult.appendChild(feedList);
408
+ } catch (error) {
409
+ showResult(error.message || '{{ __("blogroll.blogs.form.discoverFailed") }}', true, false);
410
+ } finally {
411
+ discoverBtn.disabled = false;
412
+ discoverBtn.textContent = '{{ __("blogroll.blogs.form.discover") }}';
413
+ }
414
+ });
415
+
416
+ // Allow pressing Enter in the URL field
417
+ discoverUrl.addEventListener('keypress', function(e) {
418
+ if (e.key === 'Enter') {
419
+ e.preventDefault();
420
+ discoverBtn.click();
421
+ }
422
+ });
423
+ })();
424
+ </script>
425
+ {% endif %}
199
426
  {% endblock %}
@@ -78,9 +78,8 @@
78
78
  <div class="br-field">
79
79
  <label for="type">{{ __("blogroll.sources.form.type") }}</label>
80
80
  <select id="type" name="type" required onchange="toggleTypeFields()">
81
- <option value="opml_url" {% if source.type == 'opml_url' %}selected{% endif %}>OPML URL</option>
82
- <option value="opml_file" {% if source.type == 'opml_file' %}selected{% endif %}>OPML File (paste content)</option>
83
- <option value="manual" {% if source.type == 'manual' %}selected{% endif %}>Manual (add blogs individually)</option>
81
+ <option value="opml_url" {% if source.type == 'opml_url' %}selected{% endif %}>OPML URL (auto-sync)</option>
82
+ <option value="opml_file" {% if source.type == 'opml_file' %}selected{% endif %}>OPML File (one-time import)</option>
84
83
  </select>
85
84
  <span class="br-field-hint">{{ __("blogroll.sources.form.typeHint") }}</span>
86
85
  </div>
@@ -134,9 +133,6 @@ function toggleTypeFields() {
134
133
  } else if (type === 'opml_file') {
135
134
  urlField.style.display = 'none';
136
135
  opmlContentField.style.display = 'flex';
137
- } else {
138
- urlField.style.display = 'none';
139
- opmlContentField.style.display = 'none';
140
136
  }
141
137
  }
142
138