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 '&lt;';
+            case '>': return '&gt;';
+            case '&': return '&amp;';
+            case '\'': return '&apos;';
+            case '"': return '&quot;';
+        }
+    });
+}
+
+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);
+}