@rmdes/indiekit-endpoint-rss 1.0.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 +108 -0
- package/lib/controllers/dashboard.js +106 -0
- package/lib/controllers/feeds.js +198 -0
- package/lib/controllers/items.js +95 -0
- package/lib/controllers/status.js +90 -0
- package/lib/rss-client.js +408 -0
- package/lib/sync.js +299 -0
- package/lib/utils.js +204 -0
- package/locales/en.json +50 -0
- package/package.json +54 -0
- package/views/rss.njk +498 -0
package/lib/utils.js
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import sanitizeHtmlLib from "sanitize-html";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Sanitize HTML content - strip dangerous tags, keep basic formatting
|
|
5
|
+
* @param {string} html - Raw HTML
|
|
6
|
+
* @returns {string}
|
|
7
|
+
*/
|
|
8
|
+
export function sanitizeHtml(html) {
|
|
9
|
+
if (!html) return "";
|
|
10
|
+
return sanitizeHtmlLib(html, {
|
|
11
|
+
allowedTags: [
|
|
12
|
+
"p",
|
|
13
|
+
"br",
|
|
14
|
+
"b",
|
|
15
|
+
"i",
|
|
16
|
+
"em",
|
|
17
|
+
"strong",
|
|
18
|
+
"a",
|
|
19
|
+
"ul",
|
|
20
|
+
"ol",
|
|
21
|
+
"li",
|
|
22
|
+
"blockquote",
|
|
23
|
+
"code",
|
|
24
|
+
"pre",
|
|
25
|
+
],
|
|
26
|
+
allowedAttributes: {
|
|
27
|
+
a: ["href", "title", "rel"],
|
|
28
|
+
},
|
|
29
|
+
allowedSchemes: ["http", "https", "mailto"],
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Strip all HTML tags
|
|
35
|
+
* @param {string} html - HTML content
|
|
36
|
+
* @returns {string}
|
|
37
|
+
*/
|
|
38
|
+
export function stripHtml(html) {
|
|
39
|
+
if (!html) return "";
|
|
40
|
+
return sanitizeHtmlLib(html, {
|
|
41
|
+
allowedTags: [],
|
|
42
|
+
allowedAttributes: {},
|
|
43
|
+
}).trim();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Truncate text to specified length with ellipsis
|
|
48
|
+
* @param {string} text - Text to truncate
|
|
49
|
+
* @param {number} maxLength - Maximum length
|
|
50
|
+
* @returns {string}
|
|
51
|
+
*/
|
|
52
|
+
export function truncateText(text, maxLength = 200) {
|
|
53
|
+
if (!text) return "";
|
|
54
|
+
const stripped = stripHtml(text);
|
|
55
|
+
if (stripped.length <= maxLength) return stripped;
|
|
56
|
+
const truncated = stripped.slice(0, maxLength);
|
|
57
|
+
const lastSpace = truncated.lastIndexOf(" ");
|
|
58
|
+
return (lastSpace > maxLength * 0.8 ? truncated.slice(0, lastSpace) : truncated) + "...";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Extract image URL from item content/enclosure
|
|
63
|
+
* @param {Object} item - RSS item
|
|
64
|
+
* @returns {string|null}
|
|
65
|
+
*/
|
|
66
|
+
export function extractImageUrl(item) {
|
|
67
|
+
// Already extracted by client
|
|
68
|
+
if (item.imageUrl) return item.imageUrl;
|
|
69
|
+
|
|
70
|
+
// From enclosure
|
|
71
|
+
if (item.enclosure?.url && isImageType(item.enclosure.type)) {
|
|
72
|
+
return item.enclosure.url;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// From content
|
|
76
|
+
const content = item.content || item.description || "";
|
|
77
|
+
const imgMatch = content.match(/<img[^>]+src=["']([^"']+)["']/i);
|
|
78
|
+
if (imgMatch) return imgMatch[1];
|
|
79
|
+
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Check if MIME type is an image
|
|
85
|
+
* @param {string} type - MIME type
|
|
86
|
+
* @returns {boolean}
|
|
87
|
+
*/
|
|
88
|
+
function isImageType(type) {
|
|
89
|
+
if (!type) return false;
|
|
90
|
+
return type.startsWith("image/");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Format item for API response
|
|
95
|
+
* @param {Object} item - MongoDB item document
|
|
96
|
+
* @param {Object} options - Formatting options
|
|
97
|
+
* @returns {Object}
|
|
98
|
+
*/
|
|
99
|
+
export function formatItem(item, options = {}) {
|
|
100
|
+
const { includeContent = false, descriptionLength = 200 } = options;
|
|
101
|
+
|
|
102
|
+
const formatted = {
|
|
103
|
+
id: item._id?.toString(),
|
|
104
|
+
feedId: item.feedId?.toString(),
|
|
105
|
+
feedTitle: item.feedTitle,
|
|
106
|
+
guid: item.guid,
|
|
107
|
+
title: item.title,
|
|
108
|
+
link: item.link,
|
|
109
|
+
description: truncateText(item.description, descriptionLength),
|
|
110
|
+
author: item.author,
|
|
111
|
+
pubDate: item.pubDate?.toISOString(),
|
|
112
|
+
imageUrl: item.imageUrl,
|
|
113
|
+
categories: item.categories || [],
|
|
114
|
+
fetchedAt: item.fetchedAt?.toISOString(),
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
if (includeContent) {
|
|
118
|
+
formatted.content = sanitizeHtml(item.content);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (item.enclosure) {
|
|
122
|
+
formatted.enclosure = item.enclosure;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return formatted;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Format feed for API response
|
|
130
|
+
* @param {Object} feed - MongoDB feed document
|
|
131
|
+
* @returns {Object}
|
|
132
|
+
*/
|
|
133
|
+
export function formatFeed(feed) {
|
|
134
|
+
return {
|
|
135
|
+
id: feed._id?.toString(),
|
|
136
|
+
url: feed.url,
|
|
137
|
+
title: feed.title,
|
|
138
|
+
siteUrl: feed.siteUrl,
|
|
139
|
+
description: feed.description,
|
|
140
|
+
imageUrl: feed.imageUrl,
|
|
141
|
+
enabled: feed.enabled,
|
|
142
|
+
addedAt: feed.addedAt?.toISOString(),
|
|
143
|
+
lastFetchedAt: feed.lastFetchedAt?.toISOString(),
|
|
144
|
+
lastError: feed.lastError,
|
|
145
|
+
itemCount: feed.itemCount || 0,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Format relative time (e.g., "5 minutes ago")
|
|
151
|
+
* @param {Date|string} date - Date to format
|
|
152
|
+
* @returns {string}
|
|
153
|
+
*/
|
|
154
|
+
export function formatRelativeTime(date) {
|
|
155
|
+
if (!date) return "";
|
|
156
|
+
const d = date instanceof Date ? date : new Date(date);
|
|
157
|
+
if (isNaN(d.getTime())) return "";
|
|
158
|
+
|
|
159
|
+
const now = new Date();
|
|
160
|
+
const diffMs = now - d;
|
|
161
|
+
const diffMins = Math.floor(diffMs / 60_000);
|
|
162
|
+
const diffHours = Math.floor(diffMins / 60);
|
|
163
|
+
const diffDays = Math.floor(diffHours / 24);
|
|
164
|
+
|
|
165
|
+
if (diffMins < 1) return "just now";
|
|
166
|
+
if (diffMins < 60) return `${diffMins}m ago`;
|
|
167
|
+
if (diffHours < 24) return `${diffHours}h ago`;
|
|
168
|
+
if (diffDays < 7) return `${diffDays}d ago`;
|
|
169
|
+
if (diffDays < 30) return `${Math.floor(diffDays / 7)}w ago`;
|
|
170
|
+
return d.toLocaleDateString();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Validate URL format
|
|
175
|
+
* @param {string} url - URL to validate
|
|
176
|
+
* @returns {boolean}
|
|
177
|
+
*/
|
|
178
|
+
export function isValidUrl(url) {
|
|
179
|
+
try {
|
|
180
|
+
const parsed = new URL(url);
|
|
181
|
+
return ["http:", "https:"].includes(parsed.protocol);
|
|
182
|
+
} catch {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Normalize feed URL
|
|
189
|
+
* @param {string} url - URL to normalize
|
|
190
|
+
* @returns {string}
|
|
191
|
+
*/
|
|
192
|
+
export function normalizeUrl(url) {
|
|
193
|
+
try {
|
|
194
|
+
const parsed = new URL(url);
|
|
195
|
+
// Remove trailing slash
|
|
196
|
+
let normalized = parsed.href;
|
|
197
|
+
if (normalized.endsWith("/") && parsed.pathname !== "/") {
|
|
198
|
+
normalized = normalized.slice(0, -1);
|
|
199
|
+
}
|
|
200
|
+
return normalized;
|
|
201
|
+
} catch {
|
|
202
|
+
return url;
|
|
203
|
+
}
|
|
204
|
+
}
|
package/locales/en.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"rss": {
|
|
3
|
+
"title": "RSS Reader",
|
|
4
|
+
"feeds": "Feeds",
|
|
5
|
+
"items": "Feed Items",
|
|
6
|
+
"addFeed": "Add Feed",
|
|
7
|
+
"removeFeed": "Remove Feed",
|
|
8
|
+
"enableFeed": "Enable",
|
|
9
|
+
"disableFeed": "Disable",
|
|
10
|
+
"syncNow": "Sync Now",
|
|
11
|
+
"syncing": "Syncing...",
|
|
12
|
+
"lastSynced": "Last synced",
|
|
13
|
+
"noFeeds": "No feeds configured. Add a feed URL to get started.",
|
|
14
|
+
"noItems": "No items found.",
|
|
15
|
+
"feedUrl": "Feed URL",
|
|
16
|
+
"feedUrlPlaceholder": "https://example.com/feed.xml",
|
|
17
|
+
"recentItems": "Recent Items",
|
|
18
|
+
"viewAll": "View All",
|
|
19
|
+
"enabled": "Enabled",
|
|
20
|
+
"disabled": "Disabled",
|
|
21
|
+
"itemCount": "items",
|
|
22
|
+
"actions": "Actions",
|
|
23
|
+
"status": "Status",
|
|
24
|
+
"syncStatus": "Sync Status",
|
|
25
|
+
"feedsCount": "Total Feeds",
|
|
26
|
+
"itemsCount": "Total Items",
|
|
27
|
+
"lastSync": "Last Sync",
|
|
28
|
+
"nextSync": "Next Sync",
|
|
29
|
+
"never": "Never",
|
|
30
|
+
"error": {
|
|
31
|
+
"invalidUrl": "Please enter a valid feed URL",
|
|
32
|
+
"feedExists": "This feed has already been added",
|
|
33
|
+
"feedNotFound": "Feed not found",
|
|
34
|
+
"fetchFailed": "Failed to fetch feed",
|
|
35
|
+
"noConfig": "RSS endpoint not configured correctly",
|
|
36
|
+
"noDatabase": "Database not available"
|
|
37
|
+
},
|
|
38
|
+
"success": {
|
|
39
|
+
"feedAdded": "Feed added successfully",
|
|
40
|
+
"feedRemoved": "Feed removed",
|
|
41
|
+
"feedEnabled": "Feed enabled",
|
|
42
|
+
"feedDisabled": "Feed disabled",
|
|
43
|
+
"syncComplete": "Sync complete"
|
|
44
|
+
},
|
|
45
|
+
"widget": {
|
|
46
|
+
"description": "View aggregated RSS feeds on the public page",
|
|
47
|
+
"view": "View News Page"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rmdes/indiekit-endpoint-rss",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "RSS feed reader endpoint for Indiekit. Aggregates multiple feeds, caches in MongoDB, displays on frontend.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"indiekit",
|
|
7
|
+
"indiekit-plugin",
|
|
8
|
+
"indieweb",
|
|
9
|
+
"rss",
|
|
10
|
+
"atom",
|
|
11
|
+
"feed",
|
|
12
|
+
"reader",
|
|
13
|
+
"aggregator"
|
|
14
|
+
],
|
|
15
|
+
"homepage": "https://github.com/rmdes/indiekit-endpoint-rss",
|
|
16
|
+
"bugs": {
|
|
17
|
+
"url": "https://github.com/rmdes/indiekit-endpoint-rss/issues"
|
|
18
|
+
},
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/rmdes/indiekit-endpoint-rss.git"
|
|
22
|
+
},
|
|
23
|
+
"author": {
|
|
24
|
+
"name": "Ricardo Mendes",
|
|
25
|
+
"url": "https://rmendes.net"
|
|
26
|
+
},
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=20"
|
|
30
|
+
},
|
|
31
|
+
"type": "module",
|
|
32
|
+
"main": "index.js",
|
|
33
|
+
"exports": {
|
|
34
|
+
".": "./index.js"
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"lib",
|
|
38
|
+
"locales",
|
|
39
|
+
"views",
|
|
40
|
+
"index.js"
|
|
41
|
+
],
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@indiekit/error": "^1.0.0-beta.25",
|
|
44
|
+
"express": "^5.0.0",
|
|
45
|
+
"rss-parser": "^3.13.0",
|
|
46
|
+
"sanitize-html": "^2.13.0"
|
|
47
|
+
},
|
|
48
|
+
"peerDependencies": {
|
|
49
|
+
"@indiekit/indiekit": ">=1.0.0-beta.25"
|
|
50
|
+
},
|
|
51
|
+
"publishConfig": {
|
|
52
|
+
"access": "public"
|
|
53
|
+
}
|
|
54
|
+
}
|