@rmdes/indiekit-endpoint-microsub 1.0.0-beta.1
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/README.md +111 -0
- package/index.js +140 -0
- package/lib/cache/redis.js +133 -0
- package/lib/controllers/block.js +85 -0
- package/lib/controllers/channels.js +135 -0
- package/lib/controllers/events.js +56 -0
- package/lib/controllers/follow.js +108 -0
- package/lib/controllers/microsub.js +138 -0
- package/lib/controllers/mute.js +124 -0
- package/lib/controllers/preview.js +67 -0
- package/lib/controllers/reader.js +218 -0
- package/lib/controllers/search.js +142 -0
- package/lib/controllers/timeline.js +117 -0
- package/lib/feeds/atom.js +61 -0
- package/lib/feeds/fetcher.js +205 -0
- package/lib/feeds/hfeed.js +177 -0
- package/lib/feeds/jsonfeed.js +43 -0
- package/lib/feeds/normalizer.js +586 -0
- package/lib/feeds/parser.js +124 -0
- package/lib/feeds/rss.js +61 -0
- package/lib/polling/processor.js +201 -0
- package/lib/polling/scheduler.js +128 -0
- package/lib/polling/tier.js +139 -0
- package/lib/realtime/broker.js +241 -0
- package/lib/search/indexer.js +90 -0
- package/lib/search/query.js +197 -0
- package/lib/storage/channels.js +281 -0
- package/lib/storage/feeds.js +286 -0
- package/lib/storage/filters.js +265 -0
- package/lib/storage/items.js +419 -0
- package/lib/storage/read-state.js +109 -0
- package/lib/utils/jf2.js +170 -0
- package/lib/utils/pagination.js +157 -0
- package/lib/utils/validation.js +217 -0
- package/lib/webmention/processor.js +214 -0
- package/lib/webmention/receiver.js +54 -0
- package/lib/webmention/verifier.js +308 -0
- package/lib/websub/discovery.js +129 -0
- package/lib/websub/handler.js +163 -0
- package/lib/websub/subscriber.js +181 -0
- package/locales/en.json +80 -0
- package/package.json +54 -0
- package/views/channel-new.njk +33 -0
- package/views/channel.njk +41 -0
- package/views/compose.njk +61 -0
- package/views/item.njk +85 -0
- package/views/partials/actions.njk +15 -0
- package/views/partials/author.njk +17 -0
- package/views/partials/item-card.njk +65 -0
- package/views/partials/timeline.njk +10 -0
- package/views/reader.njk +37 -0
- package/views/settings.njk +81 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON Feed parser
|
|
3
|
+
* @module feeds/jsonfeed
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { normalizeJsonFeedItem, normalizeJsonFeedMeta } from "./normalizer.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Parse JSON Feed content
|
|
10
|
+
* @param {string} content - JSON Feed content
|
|
11
|
+
* @param {string} feedUrl - URL of the feed
|
|
12
|
+
* @returns {Promise<object>} Parsed feed with metadata and items
|
|
13
|
+
*/
|
|
14
|
+
export async function parseJsonFeed(content, feedUrl) {
|
|
15
|
+
let feed;
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
feed = typeof content === "string" ? JSON.parse(content) : content;
|
|
19
|
+
} catch (error) {
|
|
20
|
+
throw new Error(`JSON Feed parse error: ${error.message}`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Validate JSON Feed structure
|
|
24
|
+
if (!feed.version || !feed.version.includes("jsonfeed.org")) {
|
|
25
|
+
throw new Error("Invalid JSON Feed: missing or invalid version");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (!Array.isArray(feed.items)) {
|
|
29
|
+
throw new TypeError("Invalid JSON Feed: items must be an array");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const normalizedMeta = normalizeJsonFeedMeta(feed, feedUrl);
|
|
33
|
+
const normalizedItems = feed.items.map((item) =>
|
|
34
|
+
normalizeJsonFeedItem(item, feedUrl),
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
type: "feed",
|
|
39
|
+
url: feedUrl,
|
|
40
|
+
...normalizedMeta,
|
|
41
|
+
items: normalizedItems,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feed normalizer - converts all feed formats to jf2
|
|
3
|
+
* @module feeds/normalizer
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import crypto from "node:crypto";
|
|
7
|
+
|
|
8
|
+
import sanitizeHtml from "sanitize-html";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Sanitize HTML options
|
|
12
|
+
*/
|
|
13
|
+
const SANITIZE_OPTIONS = {
|
|
14
|
+
allowedTags: [
|
|
15
|
+
"a",
|
|
16
|
+
"abbr",
|
|
17
|
+
"b",
|
|
18
|
+
"blockquote",
|
|
19
|
+
"br",
|
|
20
|
+
"code",
|
|
21
|
+
"em",
|
|
22
|
+
"figcaption",
|
|
23
|
+
"figure",
|
|
24
|
+
"h1",
|
|
25
|
+
"h2",
|
|
26
|
+
"h3",
|
|
27
|
+
"h4",
|
|
28
|
+
"h5",
|
|
29
|
+
"h6",
|
|
30
|
+
"hr",
|
|
31
|
+
"i",
|
|
32
|
+
"img",
|
|
33
|
+
"li",
|
|
34
|
+
"ol",
|
|
35
|
+
"p",
|
|
36
|
+
"pre",
|
|
37
|
+
"s",
|
|
38
|
+
"span",
|
|
39
|
+
"strike",
|
|
40
|
+
"strong",
|
|
41
|
+
"sub",
|
|
42
|
+
"sup",
|
|
43
|
+
"table",
|
|
44
|
+
"tbody",
|
|
45
|
+
"td",
|
|
46
|
+
"th",
|
|
47
|
+
"thead",
|
|
48
|
+
"tr",
|
|
49
|
+
"u",
|
|
50
|
+
"ul",
|
|
51
|
+
"video",
|
|
52
|
+
"audio",
|
|
53
|
+
"source",
|
|
54
|
+
],
|
|
55
|
+
allowedAttributes: {
|
|
56
|
+
a: ["href", "title", "rel"],
|
|
57
|
+
img: ["src", "alt", "title", "width", "height"],
|
|
58
|
+
video: ["src", "poster", "controls", "width", "height"],
|
|
59
|
+
audio: ["src", "controls"],
|
|
60
|
+
source: ["src", "type"],
|
|
61
|
+
"*": ["class"],
|
|
62
|
+
},
|
|
63
|
+
allowedSchemes: ["http", "https", "mailto"],
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Generate unique ID for an item
|
|
68
|
+
* @param {string} feedUrl - Feed URL
|
|
69
|
+
* @param {string} itemId - Item identifier (URL or ID)
|
|
70
|
+
* @returns {string} Unique ID hash
|
|
71
|
+
*/
|
|
72
|
+
export function generateItemUid(feedUrl, itemId) {
|
|
73
|
+
const hash = crypto.createHash("sha256");
|
|
74
|
+
hash.update(`${feedUrl}::${itemId}`);
|
|
75
|
+
return hash.digest("hex").slice(0, 24);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Normalize RSS/Atom item from feedparser
|
|
80
|
+
* @param {object} item - Feedparser item
|
|
81
|
+
* @param {string} feedUrl - Feed URL
|
|
82
|
+
* @param {string} feedType - 'rss' or 'atom'
|
|
83
|
+
* @returns {object} Normalized jf2 item
|
|
84
|
+
*/
|
|
85
|
+
export function normalizeItem(item, feedUrl, feedType) {
|
|
86
|
+
const url = item.link || item.origlink || item.guid;
|
|
87
|
+
const uid = generateItemUid(feedUrl, item.guid || url || item.title);
|
|
88
|
+
|
|
89
|
+
const normalized = {
|
|
90
|
+
type: "entry",
|
|
91
|
+
uid,
|
|
92
|
+
url,
|
|
93
|
+
name: item.title || undefined,
|
|
94
|
+
published: item.pubdate ? new Date(item.pubdate).toISOString() : undefined,
|
|
95
|
+
updated: item.date ? new Date(item.date).toISOString() : undefined,
|
|
96
|
+
_source: {
|
|
97
|
+
feedUrl,
|
|
98
|
+
feedType,
|
|
99
|
+
originalId: item.guid,
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// Content
|
|
104
|
+
if (item.description || item.summary) {
|
|
105
|
+
const html = item.description || item.summary;
|
|
106
|
+
normalized.content = {
|
|
107
|
+
html: sanitizeHtml(html, SANITIZE_OPTIONS),
|
|
108
|
+
text: sanitizeHtml(html, { allowedTags: [] }).trim(),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Summary (prefer explicit summary over truncated content)
|
|
113
|
+
if (item.summary && item.description && item.summary !== item.description) {
|
|
114
|
+
normalized.summary = sanitizeHtml(item.summary, { allowedTags: [] }).trim();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Author
|
|
118
|
+
if (item.author || item["dc:creator"]) {
|
|
119
|
+
const authorName = item.author || item["dc:creator"];
|
|
120
|
+
normalized.author = {
|
|
121
|
+
type: "card",
|
|
122
|
+
name: authorName,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Categories/tags
|
|
127
|
+
if (item.categories && item.categories.length > 0) {
|
|
128
|
+
normalized.category = item.categories;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Enclosures (media)
|
|
132
|
+
if (item.enclosures && item.enclosures.length > 0) {
|
|
133
|
+
for (const enclosure of item.enclosures) {
|
|
134
|
+
const mediaUrl = enclosure.url;
|
|
135
|
+
const mediaType = enclosure.type || "";
|
|
136
|
+
|
|
137
|
+
if (mediaType.startsWith("image/")) {
|
|
138
|
+
normalized.photo = normalized.photo || [];
|
|
139
|
+
normalized.photo.push(mediaUrl);
|
|
140
|
+
} else if (mediaType.startsWith("video/")) {
|
|
141
|
+
normalized.video = normalized.video || [];
|
|
142
|
+
normalized.video.push(mediaUrl);
|
|
143
|
+
} else if (mediaType.startsWith("audio/")) {
|
|
144
|
+
normalized.audio = normalized.audio || [];
|
|
145
|
+
normalized.audio.push(mediaUrl);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Featured image from media content
|
|
151
|
+
if (item["media:content"] && item["media:content"].url) {
|
|
152
|
+
const mediaType = item["media:content"].type || "";
|
|
153
|
+
if (
|
|
154
|
+
mediaType.startsWith("image/") ||
|
|
155
|
+
item["media:content"].medium === "image"
|
|
156
|
+
) {
|
|
157
|
+
normalized.photo = normalized.photo || [];
|
|
158
|
+
if (!normalized.photo.includes(item["media:content"].url)) {
|
|
159
|
+
normalized.photo.push(item["media:content"].url);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Image from item.image
|
|
165
|
+
if (item.image && item.image.url) {
|
|
166
|
+
normalized.photo = normalized.photo || [];
|
|
167
|
+
if (!normalized.photo.includes(item.image.url)) {
|
|
168
|
+
normalized.photo.push(item.image.url);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return normalized;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Normalize feed metadata from feedparser
|
|
177
|
+
* @param {object} meta - Feedparser meta object
|
|
178
|
+
* @param {string} feedUrl - Feed URL
|
|
179
|
+
* @returns {object} Normalized feed metadata
|
|
180
|
+
*/
|
|
181
|
+
export function normalizeFeedMeta(meta, feedUrl) {
|
|
182
|
+
const normalized = {
|
|
183
|
+
name: meta.title || feedUrl,
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
if (meta.description) {
|
|
187
|
+
normalized.summary = meta.description;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (meta.link) {
|
|
191
|
+
normalized.url = meta.link;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (meta.image && meta.image.url) {
|
|
195
|
+
normalized.photo = meta.image.url;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (meta.favicon) {
|
|
199
|
+
normalized.photo = normalized.photo || meta.favicon;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Author/publisher
|
|
203
|
+
if (meta.author) {
|
|
204
|
+
normalized.author = {
|
|
205
|
+
type: "card",
|
|
206
|
+
name: meta.author,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Hub for WebSub
|
|
211
|
+
if (meta.cloud && meta.cloud.href) {
|
|
212
|
+
normalized._hub = meta.cloud.href;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Look for hub in links
|
|
216
|
+
if (meta.link && meta["atom:link"]) {
|
|
217
|
+
const links = Array.isArray(meta["atom:link"])
|
|
218
|
+
? meta["atom:link"]
|
|
219
|
+
: [meta["atom:link"]];
|
|
220
|
+
for (const link of links) {
|
|
221
|
+
if (link["@"] && link["@"].rel === "hub") {
|
|
222
|
+
normalized._hub = link["@"].href;
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return normalized;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Normalize JSON Feed item
|
|
233
|
+
* @param {object} item - JSON Feed item
|
|
234
|
+
* @param {string} feedUrl - Feed URL
|
|
235
|
+
* @returns {object} Normalized jf2 item
|
|
236
|
+
*/
|
|
237
|
+
export function normalizeJsonFeedItem(item, feedUrl) {
|
|
238
|
+
const url = item.url || item.external_url;
|
|
239
|
+
const uid = generateItemUid(feedUrl, item.id || url);
|
|
240
|
+
|
|
241
|
+
const normalized = {
|
|
242
|
+
type: "entry",
|
|
243
|
+
uid,
|
|
244
|
+
url,
|
|
245
|
+
name: item.title || undefined,
|
|
246
|
+
published: item.date_published
|
|
247
|
+
? new Date(item.date_published).toISOString()
|
|
248
|
+
: undefined,
|
|
249
|
+
updated: item.date_modified
|
|
250
|
+
? new Date(item.date_modified).toISOString()
|
|
251
|
+
: undefined,
|
|
252
|
+
_source: {
|
|
253
|
+
feedUrl,
|
|
254
|
+
feedType: "jsonfeed",
|
|
255
|
+
originalId: item.id,
|
|
256
|
+
},
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
// Content
|
|
260
|
+
if (item.content_html || item.content_text) {
|
|
261
|
+
normalized.content = {};
|
|
262
|
+
if (item.content_html) {
|
|
263
|
+
normalized.content.html = sanitizeHtml(
|
|
264
|
+
item.content_html,
|
|
265
|
+
SANITIZE_OPTIONS,
|
|
266
|
+
);
|
|
267
|
+
normalized.content.text = sanitizeHtml(item.content_html, {
|
|
268
|
+
allowedTags: [],
|
|
269
|
+
}).trim();
|
|
270
|
+
} else if (item.content_text) {
|
|
271
|
+
normalized.content.text = item.content_text;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Summary
|
|
276
|
+
if (item.summary) {
|
|
277
|
+
normalized.summary = item.summary;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Author
|
|
281
|
+
if (item.author || item.authors) {
|
|
282
|
+
const author = item.author || (item.authors && item.authors[0]);
|
|
283
|
+
if (author) {
|
|
284
|
+
normalized.author = {
|
|
285
|
+
type: "card",
|
|
286
|
+
name: author.name,
|
|
287
|
+
url: author.url,
|
|
288
|
+
photo: author.avatar,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Tags
|
|
294
|
+
if (item.tags && item.tags.length > 0) {
|
|
295
|
+
normalized.category = item.tags;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Featured image
|
|
299
|
+
if (item.image) {
|
|
300
|
+
normalized.photo = [item.image];
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (item.banner_image && !normalized.photo) {
|
|
304
|
+
normalized.photo = [item.banner_image];
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Attachments
|
|
308
|
+
if (item.attachments && item.attachments.length > 0) {
|
|
309
|
+
for (const attachment of item.attachments) {
|
|
310
|
+
const mediaType = attachment.mime_type || "";
|
|
311
|
+
|
|
312
|
+
if (mediaType.startsWith("image/")) {
|
|
313
|
+
normalized.photo = normalized.photo || [];
|
|
314
|
+
normalized.photo.push(attachment.url);
|
|
315
|
+
} else if (mediaType.startsWith("video/")) {
|
|
316
|
+
normalized.video = normalized.video || [];
|
|
317
|
+
normalized.video.push(attachment.url);
|
|
318
|
+
} else if (mediaType.startsWith("audio/")) {
|
|
319
|
+
normalized.audio = normalized.audio || [];
|
|
320
|
+
normalized.audio.push(attachment.url);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// External URL
|
|
326
|
+
if (item.external_url && item.url !== item.external_url) {
|
|
327
|
+
normalized["bookmark-of"] = [item.external_url];
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return normalized;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Normalize JSON Feed metadata
|
|
335
|
+
* @param {object} feed - JSON Feed object
|
|
336
|
+
* @param {string} feedUrl - Feed URL
|
|
337
|
+
* @returns {object} Normalized feed metadata
|
|
338
|
+
*/
|
|
339
|
+
export function normalizeJsonFeedMeta(feed, feedUrl) {
|
|
340
|
+
const normalized = {
|
|
341
|
+
name: feed.title || feedUrl,
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
if (feed.description) {
|
|
345
|
+
normalized.summary = feed.description;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (feed.home_page_url) {
|
|
349
|
+
normalized.url = feed.home_page_url;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (feed.icon) {
|
|
353
|
+
normalized.photo = feed.icon;
|
|
354
|
+
} else if (feed.favicon) {
|
|
355
|
+
normalized.photo = feed.favicon;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (feed.author || feed.authors) {
|
|
359
|
+
const author = feed.author || (feed.authors && feed.authors[0]);
|
|
360
|
+
if (author) {
|
|
361
|
+
normalized.author = {
|
|
362
|
+
type: "card",
|
|
363
|
+
name: author.name,
|
|
364
|
+
url: author.url,
|
|
365
|
+
photo: author.avatar,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Hub for WebSub
|
|
371
|
+
if (feed.hubs && feed.hubs.length > 0) {
|
|
372
|
+
normalized._hub = feed.hubs[0].url;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
return normalized;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Normalize h-feed entry
|
|
380
|
+
* @param {object} entry - Microformats h-entry
|
|
381
|
+
* @param {string} feedUrl - Feed URL
|
|
382
|
+
* @returns {object} Normalized jf2 item
|
|
383
|
+
*/
|
|
384
|
+
export function normalizeHfeedItem(entry, feedUrl) {
|
|
385
|
+
const properties = entry.properties || {};
|
|
386
|
+
const url = getFirst(properties.url) || getFirst(properties.uid);
|
|
387
|
+
const uid = generateItemUid(feedUrl, getFirst(properties.uid) || url);
|
|
388
|
+
|
|
389
|
+
const normalized = {
|
|
390
|
+
type: "entry",
|
|
391
|
+
uid,
|
|
392
|
+
url,
|
|
393
|
+
_source: {
|
|
394
|
+
feedUrl,
|
|
395
|
+
feedType: "hfeed",
|
|
396
|
+
originalId: getFirst(properties.uid),
|
|
397
|
+
},
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
// Name/title
|
|
401
|
+
if (properties.name) {
|
|
402
|
+
const name = getFirst(properties.name);
|
|
403
|
+
// Only include name if it's not just the content
|
|
404
|
+
if (
|
|
405
|
+
name &&
|
|
406
|
+
(!properties.content || name !== getContentText(properties.content))
|
|
407
|
+
) {
|
|
408
|
+
normalized.name = name;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Published
|
|
413
|
+
if (properties.published) {
|
|
414
|
+
const published = getFirst(properties.published);
|
|
415
|
+
normalized.published = new Date(published).toISOString();
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Updated
|
|
419
|
+
if (properties.updated) {
|
|
420
|
+
const updated = getFirst(properties.updated);
|
|
421
|
+
normalized.updated = new Date(updated).toISOString();
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Content
|
|
425
|
+
if (properties.content) {
|
|
426
|
+
const content = getFirst(properties.content);
|
|
427
|
+
if (typeof content === "object") {
|
|
428
|
+
normalized.content = {
|
|
429
|
+
html: content.html
|
|
430
|
+
? sanitizeHtml(content.html, SANITIZE_OPTIONS)
|
|
431
|
+
: undefined,
|
|
432
|
+
text: content.value || undefined,
|
|
433
|
+
};
|
|
434
|
+
} else if (typeof content === "string") {
|
|
435
|
+
normalized.content = { text: content };
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Summary
|
|
440
|
+
if (properties.summary) {
|
|
441
|
+
normalized.summary = getFirst(properties.summary);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Author
|
|
445
|
+
if (properties.author) {
|
|
446
|
+
const author = getFirst(properties.author);
|
|
447
|
+
normalized.author = normalizeHcard(author);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Categories
|
|
451
|
+
if (properties.category) {
|
|
452
|
+
normalized.category = properties.category;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Photos
|
|
456
|
+
if (properties.photo) {
|
|
457
|
+
normalized.photo = properties.photo.map((p) =>
|
|
458
|
+
typeof p === "object" ? p.value || p.url : p,
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Videos
|
|
463
|
+
if (properties.video) {
|
|
464
|
+
normalized.video = properties.video.map((v) =>
|
|
465
|
+
typeof v === "object" ? v.value || v.url : v,
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// Audio
|
|
470
|
+
if (properties.audio) {
|
|
471
|
+
normalized.audio = properties.audio.map((a) =>
|
|
472
|
+
typeof a === "object" ? a.value || a.url : a,
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Interaction types
|
|
477
|
+
if (properties["like-of"]) {
|
|
478
|
+
normalized["like-of"] = properties["like-of"];
|
|
479
|
+
}
|
|
480
|
+
if (properties["repost-of"]) {
|
|
481
|
+
normalized["repost-of"] = properties["repost-of"];
|
|
482
|
+
}
|
|
483
|
+
if (properties["bookmark-of"]) {
|
|
484
|
+
normalized["bookmark-of"] = properties["bookmark-of"];
|
|
485
|
+
}
|
|
486
|
+
if (properties["in-reply-to"]) {
|
|
487
|
+
normalized["in-reply-to"] = properties["in-reply-to"];
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// RSVP
|
|
491
|
+
if (properties.rsvp) {
|
|
492
|
+
normalized.rsvp = getFirst(properties.rsvp);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Syndication
|
|
496
|
+
if (properties.syndication) {
|
|
497
|
+
normalized.syndication = properties.syndication;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
return normalized;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Normalize h-feed metadata
|
|
505
|
+
* @param {object} hfeed - h-feed microformat object
|
|
506
|
+
* @param {string} feedUrl - Feed URL
|
|
507
|
+
* @returns {object} Normalized feed metadata
|
|
508
|
+
*/
|
|
509
|
+
export function normalizeHfeedMeta(hfeed, feedUrl) {
|
|
510
|
+
const properties = hfeed.properties || {};
|
|
511
|
+
|
|
512
|
+
const normalized = {
|
|
513
|
+
name: getFirst(properties.name) || feedUrl,
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
if (properties.summary) {
|
|
517
|
+
normalized.summary = getFirst(properties.summary);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
if (properties.url) {
|
|
521
|
+
normalized.url = getFirst(properties.url);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
if (properties.photo) {
|
|
525
|
+
normalized.photo = getFirst(properties.photo);
|
|
526
|
+
if (typeof normalized.photo === "object") {
|
|
527
|
+
normalized.photo = normalized.photo.value || normalized.photo.url;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
if (properties.author) {
|
|
532
|
+
const author = getFirst(properties.author);
|
|
533
|
+
normalized.author = normalizeHcard(author);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
return normalized;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Normalize h-card author
|
|
541
|
+
* @param {object|string} hcard - h-card or author name string
|
|
542
|
+
* @returns {object} Normalized author object
|
|
543
|
+
*/
|
|
544
|
+
function normalizeHcard(hcard) {
|
|
545
|
+
if (typeof hcard === "string") {
|
|
546
|
+
return { type: "card", name: hcard };
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
if (!hcard || !hcard.properties) {
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const properties = hcard.properties;
|
|
554
|
+
|
|
555
|
+
return {
|
|
556
|
+
type: "card",
|
|
557
|
+
name: getFirst(properties.name),
|
|
558
|
+
url: getFirst(properties.url),
|
|
559
|
+
photo: getFirst(properties.photo),
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Get first item from array or return the value itself
|
|
565
|
+
* @param {Array|*} value - Value or array of values
|
|
566
|
+
* @returns {*} First value or the value itself
|
|
567
|
+
*/
|
|
568
|
+
function getFirst(value) {
|
|
569
|
+
if (Array.isArray(value)) {
|
|
570
|
+
return value[0];
|
|
571
|
+
}
|
|
572
|
+
return value;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Get text content from content property
|
|
577
|
+
* @param {Array} content - Content property array
|
|
578
|
+
* @returns {string} Text content
|
|
579
|
+
*/
|
|
580
|
+
function getContentText(content) {
|
|
581
|
+
const first = getFirst(content);
|
|
582
|
+
if (typeof first === "object") {
|
|
583
|
+
return first.value || first.text || "";
|
|
584
|
+
}
|
|
585
|
+
return first || "";
|
|
586
|
+
}
|