Mercurial > addons > firefox-addons > feed-preview
diff 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 |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/content_scripts/feed-preview.js Wed Oct 03 23:40:57 2018 +0200 @@ -0,0 +1,573 @@ +/* + * Copyright (C) 2018 Guido Berhoerster <guido+feed-preview@berhoerster.name> + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +'use strict'; + +const ALLOWED_PROTOCOLS = new Set(['http:', 'https:', 'ftp:']); + +function encodeXML(str) { + return str.replace(/[<>&'"]/g, c => { + switch (c) { + case '<': return '<'; + case '>': return '>'; + case '&': return '&'; + case '\'': return '''; + case '"': return '"'; + } + }); +} + +function parseDate(s) { + let date = new Date(s); + + return isNaN(date) ? new Date(0) : date; +} + +function parseURL(text, baseURL = window.location.href) { + let url; + + try { + url = new URL(text, baseURL); + } catch (e) { + return null; + } + if (!ALLOWED_PROTOCOLS.has(url.protocol)) { + return null; + } + + return url; +} + +function normalizeHTML(text) { + let parsedDocument = (new DOMParser()).parseFromString(text, 'text/html'); + + let linkElement = parsedDocument.createElement('link'); + linkElement.rel = 'stylesheet'; + linkElement.href ='style/entry-content.css'; + parsedDocument.head.appendChild(linkElement); + + return (new XMLSerializer()).serializeToString(parsedDocument); +} + +function nsMapper(prefix) { + switch (prefix) { + case 'atom': + return 'http://www.w3.org/2005/Atom' + case 'rss': + return 'http://my.netscape.com/rdf/simple/0.9/' + } + return null; +} + +function xpathQuery(doc, scopeElement, xpathQuery, nsMapping) { + return doc.evaluate(xpathQuery, scopeElement, nsMapper, + XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; +} + +function xpathQueryAll(doc, scopeElement, xpathQuery, nsMapping) { + let result = doc.evaluate(xpathQuery, scopeElement, nsMapper, + XPathResult.ORDERED_NODE_ITERATOR_TYPE, null); + let nodes = []; + for (let node = result.iterateNext(); node !== null; + node = result.iterateNext()) { + nodes.push(node); + } + + return nodes; +} + +class FeedLogo { + constructor(url, title = '') { + this.url = url; + this.title = title; + } +} + +class RSS1Logo extends FeedLogo { + constructor(feedDocument, imageElement) { + let urlElement = xpathQuery(feedDocument, imageElement, './rss:url'); + if (urlElement === null) { + throw new TypeError('missing <url> element in <logo> element'); + } + let url = parseURL(urlElement.textContent.trim()); + if (url === null) { + throw new TypeError('invalid URL in <logo> element'); + } + super(url); + + let titleElement = xpathQuery(feedDocument, imageElement, + './rss:title'); + if (titleElement !== null) { + this.title = titleElement.textContent.trim(); + } + } +} + +class RSS2Logo extends FeedLogo { + constructor(feedDocument, imageElement) { + let urlElement = xpathQuery(feedDocument, imageElement, './url'); + if (urlElement === null) { + throw new TypeError('missing <url> element in <logo> element'); + } + let url = parseURL(urlElement.textContent.trim()); + if (url === null) { + throw new TypeError('invalid URL in <logo> element'); + } + super(url); + + let titleElement = xpathQuery(feedDocument, imageElement, './title'); + if (titleElement !== null) { + this.title = titleElement.textContent.trim(); + } + } +} + +class AtomLogo extends FeedLogo { + constructor(logoElement) { + let url = parseURL(logoElement.textContent.trim()); + if (url === null) { + throw new TypeError('invalid URL in <logo> element'); + } + super(url); + } +} + +class FeedEntryFile { + constructor(url, type = browser.i18n.getMessage('defaultFileType'), + size = 0) { + this.url = url; + let filename = url.pathname.split('/').pop(); + this.filename = filename !== '' ? filename : + browser.i18n.getMessage('defaultFileName'); + this.type = type; + this.size = size; + } +} + +class RSS2EntryFile extends FeedEntryFile { + constructor(enclosureElement) { + let url = parseURL(enclosureElement.getAttribute('url')); + if (url === null) { + throw new TypeError('invalid URL in <enclosure> element'); + } + super(url); + + let type = enclosureElement.getAttribute('type'); + if (type !== null) { + this.type = type; + } + + let size = parseInt(enclosureElement.getAttribute('length'), 10); + if (!isNaN(size)) { + this.size = size; + } + } +} + +class FeedEntry { + constructor(title = browser.i18n.getMessage('defaultFeedEntryTitle'), + url = null, date = new Date(0), content = '', files = []) { + this.title = title; + this.url = url; + this.date = date; + this.content = content; + this.files = files; + } +} + +class RSS1Entry extends FeedEntry { + constructor(feedDocument, itemElement) { + super(); + + let titleElement = xpathQuery(feedDocument, itemElement, './rss:title'); + if (titleElement !== null) { + this.title = titleElement.textContent; + } + + let linkElement = xpathQuery(feedDocument, itemElement, './rss:link'); + if (linkElement !== null) { + this.url = parseURL(linkElement.textContent); + } + } +} + +class RSS2Entry extends FeedEntry { + constructor(feedDocument, itemElement) { + super(); + + let titleElement = xpathQuery(feedDocument, itemElement, './title'); + if (titleElement !== null) { + this.title = titleElement.textContent; + } + + let linkElement = xpathQuery(feedDocument, itemElement, './link'); + if (linkElement !== null) { + this.url = parseURL(linkElement.textContent); + } + + let pubDateElement = xpathQuery(feedDocument, itemElement, './pubDate'); + if (pubDateElement !== null) { + this.date = parseDate(pubDateElement.textContent); + } + + let descriptionElement = xpathQuery(feedDocument, itemElement, + './description'); + if (descriptionElement !== null) { + this.content = normalizeHTML(descriptionElement.textContent.trim()); + } + + for (let enclosureElement of xpathQueryAll(feedDocument, itemElement, + './enclosure')) { + try { + let entryFile = new RSS2EntryFile(enclosureElement); + this.files.push(entryFile); + } catch (e) {} + } + } +} + +class AtomEntry extends FeedEntry { + constructor(feedDocument, entryElement) { + super(); + + let titleElement = xpathQuery(feedDocument, entryElement, + './atom:title'); + if (titleElement !== null) { + this.title = titleElement.textContent.trim(); + } + + let linkElement = xpathQuery(feedDocument, entryElement, + './atom:link[@href][@rel="alternate"]'); + if (linkElement !== null) { + this.url = parseURL(linkElement.getAttribute('href')); + } + + let updatedElement = xpathQuery(feedDocument, entryElement, + './atom:updated'); + if (updatedElement !== null) { + this.date = parseDate(updatedElement.textContent); + } + + let contentElement = xpathQuery(feedDocument, entryElement, + './atom:content'); + if (contentElement === null) { + contentElement = xpathQuery(feedDocument, entryElement, + './atom:summary'); + } + if (contentElement !== null) { + let contentType = contentElement.getAttribute('type'); + if (contentType === null) { + contentType = 'text'; + } + contentType = contentType.toLowerCase(); + if (contentType === 'xhtml') { + this.content = normalizeHTML(contentElement.innerHTML); + } else if (contentType === 'html') { + this.content = normalizeHTML(contentElement.textContent); + } else { + let encodedContent = + encodeXML(contentElement.textContent.trim()); + this.content = normalizeHTML(`<pre>${encodedContent}</pre>`); + } + } + } +} + +class Feed { + constructor(title = browser.i18n.getMessage('defaultFeedTitle'), + subtitle = '', logo = null, entries = []) { + this.title = title; + this.subtitle = subtitle; + this.logo = logo; + this.entries = entries; + } + + async createPreviewDocument() { + let url = browser.extension.getURL('web_resources/feed-preview.xhtml'); + let response; + let text; + try { + response = await fetch(url); + text = await response.text(); + } catch (e) { + console.log(`Error: failed to read preview template: ${e.message}`); + return; + } + let previewDocument = (new DOMParser()).parseFromString(text, + 'application/xhtml+xml'); + + previewDocument.querySelector('base').href = + browser.extension.getURL('web_resources/'); + + previewDocument.querySelector('title').textContent = this.title; + previewDocument.querySelector('#feed-title').textContent = this.title; + previewDocument.querySelector('#feed-subtitle').textContent = + this.subtitle; + if (this.logo !== null) { + let feedLogoTemplate = + previewDocument.querySelector('#feed-logo-template'); + let logoNode = previewDocument.importNode(feedLogoTemplate.content, + true); + let imgElement = logoNode.querySelector('#feed-logo'); + imgElement.setAttribute('src', this.logo.url); + imgElement.setAttribute('alt', this.logo.title); + previewDocument.querySelector('#feed-header').prepend(logoNode); + } + + let entryTemplateElement = + previewDocument.querySelector('#entry-template'); + let entryTitleTemplateElement = + previewDocument.querySelector('#entry-title-template'); + let entryTitleLinkedTemplateElement = + previewDocument.querySelector('#entry-title-linked-template'); + let entryFileListTemplateElement = + previewDocument.querySelector('#entry-files-list-template'); + let entryFileTemplateElement = + previewDocument.querySelector('#entry-file-template'); + for (let entry of this.entries) { + let entryNode = + previewDocument.importNode(entryTemplateElement.content, + true); + let titleElement; + let titleNode; + + if (entry.url !== null) { + titleNode = previewDocument + .importNode(entryTitleLinkedTemplateElement.content, + true); + titleElement = titleNode.querySelector('.entry-link'); + titleElement.href = entry.url; + titleElement.title = entry.title; + } else { + titleNode = previewDocument + .importNode(entryTitleTemplateElement.content, true); + titleElement = titleNode.querySelector('.entry-title'); + } + titleElement.textContent = entry.title; + entryNode.querySelector('.entry-header').prepend(titleNode); + + let timeElement = entryNode.querySelector('.entry-date > time'); + timeElement.textContent = entry.date.toLocaleString(); + + let contentElement = entryNode.querySelector('.entry-content'); + contentElement.srcdoc = entry.content; + contentElement.title = entry.title; + + if (entry.files.length > 0) { + let fileListNode = previewDocument + .importNode(entryFileListTemplateElement.content, true); + fileListNode.querySelector('.entry-files-title').textContent = + browser.i18n.getMessage('filesTitle'); + let fileListElement = + fileListNode.querySelector('.entry-files-list'); + + for (let file of entry.files) { + let fileNode = previewDocument + .importNode(entryFileTemplateElement.content, true); + + let fileLinkElement = + fileNode.querySelector('.entry-file-link'); + fileLinkElement.href = file.url; + fileLinkElement.title = file.filename; + fileLinkElement.textContent = file.filename; + + fileNode.querySelector('.entry-file-info').textContent = + `(${file.type}, ${file.size} bytes)`; + + fileListElement.appendChild(fileNode); + } + + entryNode.querySelector('.entry').append(fileListNode); + } + + previewDocument.body.append(entryNode); + } + + return previewDocument; + } +} + +class RSS1Feed extends Feed { + constructor(feedDocument) { + super(); + + let documentElement = feedDocument.documentElement; + let titleElement = xpathQuery(feedDocument, documentElement, + './rss:channel/rss:title'); + if (titleElement !== null) { + this.title = titleElement.textContent; + } + + let descriptionElement = xpathQuery(feedDocument, documentElement, + './channel/description'); + if (descriptionElement !== null) { + this.subtitle = descriptionElement.textContent; + } + + let imageElement = xpathQuery(feedDocument, documentElement, + './rss:image'); + if (imageElement !== null) { + try { + let logo = new RSS1Logo(feedDocument, imageElement); + this.logo = logo; + } catch (e) {} + } + + let itemElements = xpathQueryAll(feedDocument, documentElement, + './rss:item'); + for (let itemElement of itemElements) { + let entry = new RSS1Entry(feedDocument, itemElement); + if (typeof entry !== 'undefined') { + this.entries.push(entry); + } + } + } +} + +class RSS2Feed extends Feed { + constructor(feedDocument) { + super(); + + let documentElement = feedDocument.documentElement; + let titleElement = xpathQuery(feedDocument, documentElement, + './channel/title'); + if (titleElement !== null) { + this.title = titleElement.textContent; + } + + let descriptionElement = xpathQuery(feedDocument, documentElement, + './channel/description'); + if (descriptionElement !== null) { + this.subtitle = descriptionElement.textContent; + } + + let imageElement = xpathQuery(feedDocument, documentElement, + './channel/image'); + if (imageElement !== null) { + try { + let logo = new RSS2Logo(feedDocument, imageElement); + this.logo = logo; + } catch (e) {} + } + + let itemElements = xpathQueryAll(feedDocument, documentElement, + './channel/item'); + for (let itemElement of itemElements) { + let entry = new RSS2Entry(feedDocument, itemElement); + if (typeof entry !== 'undefined') { + this.entries.push(entry); + } + } + } +} + +class AtomFeed extends Feed { + constructor(feedDocument, atomVersion) { + super(); + + let documentElement = feedDocument.documentElement; + let titleElement = xpathQuery(feedDocument, documentElement, + './atom:title'); + if (titleElement !== null) { + this.title = titleElement.textContent.trim(); + } + + let subtitleElement = xpathQuery(feedDocument, documentElement, + './atom:subtitle'); + if (subtitleElement !== null) { + this.subtitle = subtitleElement.textContent.trim(); + } + + let logoElement = xpathQuery(feedDocument, documentElement, + './atom:logo'); + if (logoElement !== null) { + try { + let logo = new AtomLogo(logoElement); + this.logo = logo; + } catch (e) {} + } + + let entryElements = xpathQueryAll(feedDocument, documentElement, + './atom:entry'); + for (let entryElement of entryElements) { + this.entries.push(new AtomEntry(feedDocument, entryElement)); + } + } +} + +function probeFeedType(feedDocument) { + if (feedDocument.documentElement.nodeName === 'feed') { + let version = feedDocument.documentElement.getAttribute('version'); + if (version === null) { + version = '1.0'; + } + for (let attr of feedDocument.documentElement.attributes) { + if (attr.name === 'xmlns' && + attr.value === 'http://www.w3.org/2005/Atom') { + return ['atom', version]; + } + } + } else if (feedDocument.documentElement.nodeName === 'rss') { + let version = feedDocument.documentElement.getAttribute('version'); + if (version !== null) { + return ['rss', version]; + } + } else if (feedDocument.documentElement.localName.toLowerCase() === 'rdf') { + for (let attr of feedDocument.documentElement.attributes) { + if (attr.name === 'xmlns' && + attr.value === 'http://my.netscape.com/rdf/simple/0.9/') { + return ['rss', '0.9']; + } + } + } + + return [undefined, undefined]; +} + +async function replaceDocumentWithPreview(type, version) { + let feed; + switch (type) { + case 'rss': + switch (version) { + case '0.9': + case '1.0': + feed = new RSS1Feed(document, version); + break; + case '0.90': + case '0.91': + case '0.92': + case '0.93': + case '0.94': + case '2.0': + feed = new RSS2Feed(document, version); + break; + default: + return; + } + break; + case 'atom': + feed = new AtomFeed(document, version); + break; + default: + return; + } + + // replace original document with preview + let previewDocument = await feed.createPreviewDocument(); + if (typeof previewDocument === 'undefined') { + return; + } + let documentElement = previewDocument.documentElement; + document.replaceChild(document.importNode(documentElement, true), + document.documentElement); +} + +let [type, version] = probeFeedType(document); +if (typeof type !== 'undefined') { + replaceDocumentWithPreview(type, version); +}