Mercurial > addons > firefox-addons > feed-preview
comparison content_scripts/feed-preview.js @ 0:bc5cc170163c
Initial revision
author | Guido Berhoerster <guido+feed-preview@berhoerster.name> |
---|---|
date | Wed, 03 Oct 2018 23:40:57 +0200 |
parents | |
children | 1c31f4102408 |
comparison
equal
deleted
inserted
replaced
-1:000000000000 | 0:bc5cc170163c |
---|---|
1 /* | |
2 * Copyright (C) 2018 Guido Berhoerster <guido+feed-preview@berhoerster.name> | |
3 * | |
4 * This Source Code Form is subject to the terms of the Mozilla Public | |
5 * License, v. 2.0. If a copy of the MPL was not distributed with this | |
6 * file, You can obtain one at http://mozilla.org/MPL/2.0/. | |
7 */ | |
8 | |
9 'use strict'; | |
10 | |
11 const ALLOWED_PROTOCOLS = new Set(['http:', 'https:', 'ftp:']); | |
12 | |
13 function encodeXML(str) { | |
14 return str.replace(/[<>&'"]/g, c => { | |
15 switch (c) { | |
16 case '<': return '<'; | |
17 case '>': return '>'; | |
18 case '&': return '&'; | |
19 case '\'': return '''; | |
20 case '"': return '"'; | |
21 } | |
22 }); | |
23 } | |
24 | |
25 function parseDate(s) { | |
26 let date = new Date(s); | |
27 | |
28 return isNaN(date) ? new Date(0) : date; | |
29 } | |
30 | |
31 function parseURL(text, baseURL = window.location.href) { | |
32 let url; | |
33 | |
34 try { | |
35 url = new URL(text, baseURL); | |
36 } catch (e) { | |
37 return null; | |
38 } | |
39 if (!ALLOWED_PROTOCOLS.has(url.protocol)) { | |
40 return null; | |
41 } | |
42 | |
43 return url; | |
44 } | |
45 | |
46 function normalizeHTML(text) { | |
47 let parsedDocument = (new DOMParser()).parseFromString(text, 'text/html'); | |
48 | |
49 let linkElement = parsedDocument.createElement('link'); | |
50 linkElement.rel = 'stylesheet'; | |
51 linkElement.href ='style/entry-content.css'; | |
52 parsedDocument.head.appendChild(linkElement); | |
53 | |
54 return (new XMLSerializer()).serializeToString(parsedDocument); | |
55 } | |
56 | |
57 function nsMapper(prefix) { | |
58 switch (prefix) { | |
59 case 'atom': | |
60 return 'http://www.w3.org/2005/Atom' | |
61 case 'rss': | |
62 return 'http://my.netscape.com/rdf/simple/0.9/' | |
63 } | |
64 return null; | |
65 } | |
66 | |
67 function xpathQuery(doc, scopeElement, xpathQuery, nsMapping) { | |
68 return doc.evaluate(xpathQuery, scopeElement, nsMapper, | |
69 XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; | |
70 } | |
71 | |
72 function xpathQueryAll(doc, scopeElement, xpathQuery, nsMapping) { | |
73 let result = doc.evaluate(xpathQuery, scopeElement, nsMapper, | |
74 XPathResult.ORDERED_NODE_ITERATOR_TYPE, null); | |
75 let nodes = []; | |
76 for (let node = result.iterateNext(); node !== null; | |
77 node = result.iterateNext()) { | |
78 nodes.push(node); | |
79 } | |
80 | |
81 return nodes; | |
82 } | |
83 | |
84 class FeedLogo { | |
85 constructor(url, title = '') { | |
86 this.url = url; | |
87 this.title = title; | |
88 } | |
89 } | |
90 | |
91 class RSS1Logo extends FeedLogo { | |
92 constructor(feedDocument, imageElement) { | |
93 let urlElement = xpathQuery(feedDocument, imageElement, './rss:url'); | |
94 if (urlElement === null) { | |
95 throw new TypeError('missing <url> element in <logo> element'); | |
96 } | |
97 let url = parseURL(urlElement.textContent.trim()); | |
98 if (url === null) { | |
99 throw new TypeError('invalid URL in <logo> element'); | |
100 } | |
101 super(url); | |
102 | |
103 let titleElement = xpathQuery(feedDocument, imageElement, | |
104 './rss:title'); | |
105 if (titleElement !== null) { | |
106 this.title = titleElement.textContent.trim(); | |
107 } | |
108 } | |
109 } | |
110 | |
111 class RSS2Logo extends FeedLogo { | |
112 constructor(feedDocument, imageElement) { | |
113 let urlElement = xpathQuery(feedDocument, imageElement, './url'); | |
114 if (urlElement === null) { | |
115 throw new TypeError('missing <url> element in <logo> element'); | |
116 } | |
117 let url = parseURL(urlElement.textContent.trim()); | |
118 if (url === null) { | |
119 throw new TypeError('invalid URL in <logo> element'); | |
120 } | |
121 super(url); | |
122 | |
123 let titleElement = xpathQuery(feedDocument, imageElement, './title'); | |
124 if (titleElement !== null) { | |
125 this.title = titleElement.textContent.trim(); | |
126 } | |
127 } | |
128 } | |
129 | |
130 class AtomLogo extends FeedLogo { | |
131 constructor(logoElement) { | |
132 let url = parseURL(logoElement.textContent.trim()); | |
133 if (url === null) { | |
134 throw new TypeError('invalid URL in <logo> element'); | |
135 } | |
136 super(url); | |
137 } | |
138 } | |
139 | |
140 class FeedEntryFile { | |
141 constructor(url, type = browser.i18n.getMessage('defaultFileType'), | |
142 size = 0) { | |
143 this.url = url; | |
144 let filename = url.pathname.split('/').pop(); | |
145 this.filename = filename !== '' ? filename : | |
146 browser.i18n.getMessage('defaultFileName'); | |
147 this.type = type; | |
148 this.size = size; | |
149 } | |
150 } | |
151 | |
152 class RSS2EntryFile extends FeedEntryFile { | |
153 constructor(enclosureElement) { | |
154 let url = parseURL(enclosureElement.getAttribute('url')); | |
155 if (url === null) { | |
156 throw new TypeError('invalid URL in <enclosure> element'); | |
157 } | |
158 super(url); | |
159 | |
160 let type = enclosureElement.getAttribute('type'); | |
161 if (type !== null) { | |
162 this.type = type; | |
163 } | |
164 | |
165 let size = parseInt(enclosureElement.getAttribute('length'), 10); | |
166 if (!isNaN(size)) { | |
167 this.size = size; | |
168 } | |
169 } | |
170 } | |
171 | |
172 class FeedEntry { | |
173 constructor(title = browser.i18n.getMessage('defaultFeedEntryTitle'), | |
174 url = null, date = new Date(0), content = '', files = []) { | |
175 this.title = title; | |
176 this.url = url; | |
177 this.date = date; | |
178 this.content = content; | |
179 this.files = files; | |
180 } | |
181 } | |
182 | |
183 class RSS1Entry extends FeedEntry { | |
184 constructor(feedDocument, itemElement) { | |
185 super(); | |
186 | |
187 let titleElement = xpathQuery(feedDocument, itemElement, './rss:title'); | |
188 if (titleElement !== null) { | |
189 this.title = titleElement.textContent; | |
190 } | |
191 | |
192 let linkElement = xpathQuery(feedDocument, itemElement, './rss:link'); | |
193 if (linkElement !== null) { | |
194 this.url = parseURL(linkElement.textContent); | |
195 } | |
196 } | |
197 } | |
198 | |
199 class RSS2Entry extends FeedEntry { | |
200 constructor(feedDocument, itemElement) { | |
201 super(); | |
202 | |
203 let titleElement = xpathQuery(feedDocument, itemElement, './title'); | |
204 if (titleElement !== null) { | |
205 this.title = titleElement.textContent; | |
206 } | |
207 | |
208 let linkElement = xpathQuery(feedDocument, itemElement, './link'); | |
209 if (linkElement !== null) { | |
210 this.url = parseURL(linkElement.textContent); | |
211 } | |
212 | |
213 let pubDateElement = xpathQuery(feedDocument, itemElement, './pubDate'); | |
214 if (pubDateElement !== null) { | |
215 this.date = parseDate(pubDateElement.textContent); | |
216 } | |
217 | |
218 let descriptionElement = xpathQuery(feedDocument, itemElement, | |
219 './description'); | |
220 if (descriptionElement !== null) { | |
221 this.content = normalizeHTML(descriptionElement.textContent.trim()); | |
222 } | |
223 | |
224 for (let enclosureElement of xpathQueryAll(feedDocument, itemElement, | |
225 './enclosure')) { | |
226 try { | |
227 let entryFile = new RSS2EntryFile(enclosureElement); | |
228 this.files.push(entryFile); | |
229 } catch (e) {} | |
230 } | |
231 } | |
232 } | |
233 | |
234 class AtomEntry extends FeedEntry { | |
235 constructor(feedDocument, entryElement) { | |
236 super(); | |
237 | |
238 let titleElement = xpathQuery(feedDocument, entryElement, | |
239 './atom:title'); | |
240 if (titleElement !== null) { | |
241 this.title = titleElement.textContent.trim(); | |
242 } | |
243 | |
244 let linkElement = xpathQuery(feedDocument, entryElement, | |
245 './atom:link[@href][@rel="alternate"]'); | |
246 if (linkElement !== null) { | |
247 this.url = parseURL(linkElement.getAttribute('href')); | |
248 } | |
249 | |
250 let updatedElement = xpathQuery(feedDocument, entryElement, | |
251 './atom:updated'); | |
252 if (updatedElement !== null) { | |
253 this.date = parseDate(updatedElement.textContent); | |
254 } | |
255 | |
256 let contentElement = xpathQuery(feedDocument, entryElement, | |
257 './atom:content'); | |
258 if (contentElement === null) { | |
259 contentElement = xpathQuery(feedDocument, entryElement, | |
260 './atom:summary'); | |
261 } | |
262 if (contentElement !== null) { | |
263 let contentType = contentElement.getAttribute('type'); | |
264 if (contentType === null) { | |
265 contentType = 'text'; | |
266 } | |
267 contentType = contentType.toLowerCase(); | |
268 if (contentType === 'xhtml') { | |
269 this.content = normalizeHTML(contentElement.innerHTML); | |
270 } else if (contentType === 'html') { | |
271 this.content = normalizeHTML(contentElement.textContent); | |
272 } else { | |
273 let encodedContent = | |
274 encodeXML(contentElement.textContent.trim()); | |
275 this.content = normalizeHTML(`<pre>${encodedContent}</pre>`); | |
276 } | |
277 } | |
278 } | |
279 } | |
280 | |
281 class Feed { | |
282 constructor(title = browser.i18n.getMessage('defaultFeedTitle'), | |
283 subtitle = '', logo = null, entries = []) { | |
284 this.title = title; | |
285 this.subtitle = subtitle; | |
286 this.logo = logo; | |
287 this.entries = entries; | |
288 } | |
289 | |
290 async createPreviewDocument() { | |
291 let url = browser.extension.getURL('web_resources/feed-preview.xhtml'); | |
292 let response; | |
293 let text; | |
294 try { | |
295 response = await fetch(url); | |
296 text = await response.text(); | |
297 } catch (e) { | |
298 console.log(`Error: failed to read preview template: ${e.message}`); | |
299 return; | |
300 } | |
301 let previewDocument = (new DOMParser()).parseFromString(text, | |
302 'application/xhtml+xml'); | |
303 | |
304 previewDocument.querySelector('base').href = | |
305 browser.extension.getURL('web_resources/'); | |
306 | |
307 previewDocument.querySelector('title').textContent = this.title; | |
308 previewDocument.querySelector('#feed-title').textContent = this.title; | |
309 previewDocument.querySelector('#feed-subtitle').textContent = | |
310 this.subtitle; | |
311 if (this.logo !== null) { | |
312 let feedLogoTemplate = | |
313 previewDocument.querySelector('#feed-logo-template'); | |
314 let logoNode = previewDocument.importNode(feedLogoTemplate.content, | |
315 true); | |
316 let imgElement = logoNode.querySelector('#feed-logo'); | |
317 imgElement.setAttribute('src', this.logo.url); | |
318 imgElement.setAttribute('alt', this.logo.title); | |
319 previewDocument.querySelector('#feed-header').prepend(logoNode); | |
320 } | |
321 | |
322 let entryTemplateElement = | |
323 previewDocument.querySelector('#entry-template'); | |
324 let entryTitleTemplateElement = | |
325 previewDocument.querySelector('#entry-title-template'); | |
326 let entryTitleLinkedTemplateElement = | |
327 previewDocument.querySelector('#entry-title-linked-template'); | |
328 let entryFileListTemplateElement = | |
329 previewDocument.querySelector('#entry-files-list-template'); | |
330 let entryFileTemplateElement = | |
331 previewDocument.querySelector('#entry-file-template'); | |
332 for (let entry of this.entries) { | |
333 let entryNode = | |
334 previewDocument.importNode(entryTemplateElement.content, | |
335 true); | |
336 let titleElement; | |
337 let titleNode; | |
338 | |
339 if (entry.url !== null) { | |
340 titleNode = previewDocument | |
341 .importNode(entryTitleLinkedTemplateElement.content, | |
342 true); | |
343 titleElement = titleNode.querySelector('.entry-link'); | |
344 titleElement.href = entry.url; | |
345 titleElement.title = entry.title; | |
346 } else { | |
347 titleNode = previewDocument | |
348 .importNode(entryTitleTemplateElement.content, true); | |
349 titleElement = titleNode.querySelector('.entry-title'); | |
350 } | |
351 titleElement.textContent = entry.title; | |
352 entryNode.querySelector('.entry-header').prepend(titleNode); | |
353 | |
354 let timeElement = entryNode.querySelector('.entry-date > time'); | |
355 timeElement.textContent = entry.date.toLocaleString(); | |
356 | |
357 let contentElement = entryNode.querySelector('.entry-content'); | |
358 contentElement.srcdoc = entry.content; | |
359 contentElement.title = entry.title; | |
360 | |
361 if (entry.files.length > 0) { | |
362 let fileListNode = previewDocument | |
363 .importNode(entryFileListTemplateElement.content, true); | |
364 fileListNode.querySelector('.entry-files-title').textContent = | |
365 browser.i18n.getMessage('filesTitle'); | |
366 let fileListElement = | |
367 fileListNode.querySelector('.entry-files-list'); | |
368 | |
369 for (let file of entry.files) { | |
370 let fileNode = previewDocument | |
371 .importNode(entryFileTemplateElement.content, true); | |
372 | |
373 let fileLinkElement = | |
374 fileNode.querySelector('.entry-file-link'); | |
375 fileLinkElement.href = file.url; | |
376 fileLinkElement.title = file.filename; | |
377 fileLinkElement.textContent = file.filename; | |
378 | |
379 fileNode.querySelector('.entry-file-info').textContent = | |
380 `(${file.type}, ${file.size} bytes)`; | |
381 | |
382 fileListElement.appendChild(fileNode); | |
383 } | |
384 | |
385 entryNode.querySelector('.entry').append(fileListNode); | |
386 } | |
387 | |
388 previewDocument.body.append(entryNode); | |
389 } | |
390 | |
391 return previewDocument; | |
392 } | |
393 } | |
394 | |
395 class RSS1Feed extends Feed { | |
396 constructor(feedDocument) { | |
397 super(); | |
398 | |
399 let documentElement = feedDocument.documentElement; | |
400 let titleElement = xpathQuery(feedDocument, documentElement, | |
401 './rss:channel/rss:title'); | |
402 if (titleElement !== null) { | |
403 this.title = titleElement.textContent; | |
404 } | |
405 | |
406 let descriptionElement = xpathQuery(feedDocument, documentElement, | |
407 './channel/description'); | |
408 if (descriptionElement !== null) { | |
409 this.subtitle = descriptionElement.textContent; | |
410 } | |
411 | |
412 let imageElement = xpathQuery(feedDocument, documentElement, | |
413 './rss:image'); | |
414 if (imageElement !== null) { | |
415 try { | |
416 let logo = new RSS1Logo(feedDocument, imageElement); | |
417 this.logo = logo; | |
418 } catch (e) {} | |
419 } | |
420 | |
421 let itemElements = xpathQueryAll(feedDocument, documentElement, | |
422 './rss:item'); | |
423 for (let itemElement of itemElements) { | |
424 let entry = new RSS1Entry(feedDocument, itemElement); | |
425 if (typeof entry !== 'undefined') { | |
426 this.entries.push(entry); | |
427 } | |
428 } | |
429 } | |
430 } | |
431 | |
432 class RSS2Feed extends Feed { | |
433 constructor(feedDocument) { | |
434 super(); | |
435 | |
436 let documentElement = feedDocument.documentElement; | |
437 let titleElement = xpathQuery(feedDocument, documentElement, | |
438 './channel/title'); | |
439 if (titleElement !== null) { | |
440 this.title = titleElement.textContent; | |
441 } | |
442 | |
443 let descriptionElement = xpathQuery(feedDocument, documentElement, | |
444 './channel/description'); | |
445 if (descriptionElement !== null) { | |
446 this.subtitle = descriptionElement.textContent; | |
447 } | |
448 | |
449 let imageElement = xpathQuery(feedDocument, documentElement, | |
450 './channel/image'); | |
451 if (imageElement !== null) { | |
452 try { | |
453 let logo = new RSS2Logo(feedDocument, imageElement); | |
454 this.logo = logo; | |
455 } catch (e) {} | |
456 } | |
457 | |
458 let itemElements = xpathQueryAll(feedDocument, documentElement, | |
459 './channel/item'); | |
460 for (let itemElement of itemElements) { | |
461 let entry = new RSS2Entry(feedDocument, itemElement); | |
462 if (typeof entry !== 'undefined') { | |
463 this.entries.push(entry); | |
464 } | |
465 } | |
466 } | |
467 } | |
468 | |
469 class AtomFeed extends Feed { | |
470 constructor(feedDocument, atomVersion) { | |
471 super(); | |
472 | |
473 let documentElement = feedDocument.documentElement; | |
474 let titleElement = xpathQuery(feedDocument, documentElement, | |
475 './atom:title'); | |
476 if (titleElement !== null) { | |
477 this.title = titleElement.textContent.trim(); | |
478 } | |
479 | |
480 let subtitleElement = xpathQuery(feedDocument, documentElement, | |
481 './atom:subtitle'); | |
482 if (subtitleElement !== null) { | |
483 this.subtitle = subtitleElement.textContent.trim(); | |
484 } | |
485 | |
486 let logoElement = xpathQuery(feedDocument, documentElement, | |
487 './atom:logo'); | |
488 if (logoElement !== null) { | |
489 try { | |
490 let logo = new AtomLogo(logoElement); | |
491 this.logo = logo; | |
492 } catch (e) {} | |
493 } | |
494 | |
495 let entryElements = xpathQueryAll(feedDocument, documentElement, | |
496 './atom:entry'); | |
497 for (let entryElement of entryElements) { | |
498 this.entries.push(new AtomEntry(feedDocument, entryElement)); | |
499 } | |
500 } | |
501 } | |
502 | |
503 function probeFeedType(feedDocument) { | |
504 if (feedDocument.documentElement.nodeName === 'feed') { | |
505 let version = feedDocument.documentElement.getAttribute('version'); | |
506 if (version === null) { | |
507 version = '1.0'; | |
508 } | |
509 for (let attr of feedDocument.documentElement.attributes) { | |
510 if (attr.name === 'xmlns' && | |
511 attr.value === 'http://www.w3.org/2005/Atom') { | |
512 return ['atom', version]; | |
513 } | |
514 } | |
515 } else if (feedDocument.documentElement.nodeName === 'rss') { | |
516 let version = feedDocument.documentElement.getAttribute('version'); | |
517 if (version !== null) { | |
518 return ['rss', version]; | |
519 } | |
520 } else if (feedDocument.documentElement.localName.toLowerCase() === 'rdf') { | |
521 for (let attr of feedDocument.documentElement.attributes) { | |
522 if (attr.name === 'xmlns' && | |
523 attr.value === 'http://my.netscape.com/rdf/simple/0.9/') { | |
524 return ['rss', '0.9']; | |
525 } | |
526 } | |
527 } | |
528 | |
529 return [undefined, undefined]; | |
530 } | |
531 | |
532 async function replaceDocumentWithPreview(type, version) { | |
533 let feed; | |
534 switch (type) { | |
535 case 'rss': | |
536 switch (version) { | |
537 case '0.9': | |
538 case '1.0': | |
539 feed = new RSS1Feed(document, version); | |
540 break; | |
541 case '0.90': | |
542 case '0.91': | |
543 case '0.92': | |
544 case '0.93': | |
545 case '0.94': | |
546 case '2.0': | |
547 feed = new RSS2Feed(document, version); | |
548 break; | |
549 default: | |
550 return; | |
551 } | |
552 break; | |
553 case 'atom': | |
554 feed = new AtomFeed(document, version); | |
555 break; | |
556 default: | |
557 return; | |
558 } | |
559 | |
560 // replace original document with preview | |
561 let previewDocument = await feed.createPreviewDocument(); | |
562 if (typeof previewDocument === 'undefined') { | |
563 return; | |
564 } | |
565 let documentElement = previewDocument.documentElement; | |
566 document.replaceChild(document.importNode(documentElement, true), | |
567 document.documentElement); | |
568 } | |
569 | |
570 let [type, version] = probeFeedType(document); | |
571 if (typeof type !== 'undefined') { | |
572 replaceDocumentWithPreview(type, version); | |
573 } |