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 '&lt;';
17 case '>': return '&gt;';
18 case '&': return '&amp;';
19 case '\'': return '&apos;';
20 case '"': return '&quot;';
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 }