@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/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
+ }
@@ -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
+ }