changeset 6:5d7c13e998e9

Create feed previews using a stream filter Instead of replacing the feed document with an XHTML preview from a content script after it has already been rendered, create an XHTML preview using a stream filter before it is passed into the rendering engine and use an XSL style sheet to convert it to HTML. This has two advantages, firstly it results in an HTMLDocument with the full HTML DOM available and secondly it avoids rendering the document twice. Refactor the feed preview creation and split parsing and rendering into seperate modules.
author Guido Berhoerster <guido+feed-preview@berhoerster.name>
date Thu, 08 Nov 2018 16:30:34 +0100
parents 341a0f4b7ce0
children 2bbb7617dd13
files Makefile background.html background.js content_scripts/feed-preview.js js/background.js js/feed-parser.js js/feed-preview.js manifest.json.in web_resources/feed-preview.xhtml web_resources/xhtml-to-html.xsl
diffstat 10 files changed, 902 insertions(+), 720 deletions(-) [+]
line wrap: on
line diff
--- a/Makefile	Sun Nov 04 10:03:05 2018 +0100
+++ b/Makefile	Thu Nov 08 16:30:34 2018 +0100
@@ -36,11 +36,13 @@
 		NEWS \
 		README \
 		$(wildcard _locales/*/messages.json) \
-		background.js \
+		background.html \
 		content_scripts/feed-probe.js \
-		content_scripts/feed-preview.js \
 		icons/feed-preview.svg \
 		$(BITMAP_ICONS) \
+		js/background.js \
+		js/feed-parser.js \
+		js/feed-preview.js \
 		popup/feed-selection.js \
 		popup/feed-selection.html \
 		web_resources/style/feed-preview.css \
@@ -48,6 +50,7 @@
 		web_resources/style/common.css \
 		web_resources/style/entry-content.css \
 		web_resources/feed-preview.xhtml \
+		web_resources/xhtml-to-html.xsl \
 		web_resources/images/arrow.svg
 
 .DEFAULT_TARGET = all
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/background.html	Thu Nov 08 16:30:34 2018 +0100
@@ -0,0 +1,9 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="utf-8">
+    <script type="module" src="js/feed-parser.js"></script>
+    <script type="module" src="js/feed-preview.js"></script>
+    <script type="module" src="js/background.js"></script>
+  </head>
+</html>
--- a/background.js	Sun Nov 04 10:03:05 2018 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,67 +0,0 @@
-/*
- * 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';
-
-var tabsFeeds = new Map();
-
-// until content handlers become available to webextensions
-// (https://bugzilla.mozilla.org/show_bug.cgi?id=1457500) intercept all
-// responses and change the content type from application/atom+xml or
-// application/rss+xml to application/xml which will then be handled by a
-// content script
-browser.webRequest.onHeadersReceived.addListener(details => {
-    if (details.statusCode != 200 ||
-            typeof details.responseHeaders === 'undefined') {
-        return;
-    }
-
-    let contentTypeHeader = details.responseHeaders.find(element => {
-        return element.name.toLowerCase() === 'content-type';
-    });
-    if (typeof contentTypeHeader !== 'undefined') {
-        let contentType = contentTypeHeader.value.split(';');
-        let mediaType = contentType[0].trim().toLowerCase();
-        if (mediaType === 'application/atom+xml' ||
-                mediaType === 'application/rss+xml') {
-            contentType[0] = 'application/xml';
-            contentTypeHeader.value = contentType.join(';');
-        }
-    }
-
-    return {responseHeaders: details.responseHeaders};
-}, {urls: ['http://*/*', 'https://*/*'], types: ['main_frame']},
-['blocking', 'responseHeaders']);
-
-browser.runtime.onMessage.addListener((request, sender, sendResponse) => {
-    let tab = sender.tab;
-    if (typeof tab !== 'undefined') {
-        // content script sending feeds
-        tabsFeeds.set(tab.id, request);
-        browser.pageAction.show(tab.id);
-    } else {
-        let response = tabsFeeds.get(request);
-        // popup querying feeds
-        sendResponse(tabsFeeds.get(request));
-    }
-});
-
-browser.tabs.onUpdated.addListener((id, changeInfo, tab) => {
-    if (typeof changeInfo.url === 'undefined') {
-        // filter out updates which do not change the URL
-        return;
-    }
-
-    // hide the page action when the URL changes since it is no longer valid,
-    // it will be shown again if the content script detects a feed
-    browser.pageAction.hide(tab.id);
-});
-
-browser.tabs.onRemoved.addListener((tabId, removeInfo) => {
-    tabsFeeds.delete(tabId);
-});
--- a/content_scripts/feed-preview.js	Sun Nov 04 10:03:05 2018 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,598 +0,0 @@
-/*
- * 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 feedNSResolver(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 feedQueryXPath(feedDocument, scopeElement, xpathQuery) {
-    return feedDocument.evaluate(xpathQuery, scopeElement, feedNSResolver,
-            XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
-}
-
-function feedQueryXPathAll(feedDocument, scopeElement, xpathQuery) {
-    let result = feedDocument.evaluate(xpathQuery, scopeElement, feedNSResolver,
-            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 = feedQueryXPath(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 = feedQueryXPath(feedDocument, imageElement,
-                './rss:title');
-        if (titleElement !== null) {
-            this.title = titleElement.textContent.trim();
-        }
-    }
-}
-
-class RSS2Logo extends FeedLogo {
-    constructor(feedDocument, imageElement) {
-        let urlElement = feedQueryXPath(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 = feedQueryXPath(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;
-        this.content = content;
-        this.files = files;
-    }
-
-    set content(content) {
-        this._content = this.normalizeContent(content);
-    }
-
-    get content() {
-        return this._content;
-    }
-
-    normalizeContent(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);
-    }
-}
-
-class RSS1Entry extends FeedEntry {
-    constructor(feedDocument, itemElement) {
-        super();
-
-        let titleElement = feedQueryXPath(feedDocument, itemElement,
-                './rss:title');
-        if (titleElement !== null) {
-            this.title = titleElement.textContent;
-        }
-
-        let linkElement = feedQueryXPath(feedDocument, itemElement,
-                './rss:link');
-        if (linkElement !== null) {
-            this.url = parseURL(linkElement.textContent);
-        }
-    }
-}
-
-class RSS2Entry extends FeedEntry {
-    constructor(feedDocument, itemElement) {
-        super();
-
-        let titleElement = feedQueryXPath(feedDocument, itemElement, './title');
-        if (titleElement !== null) {
-            this.title = titleElement.textContent;
-        }
-
-        let linkElement = feedQueryXPath(feedDocument, itemElement, './link');
-        if (linkElement !== null) {
-            this.url = parseURL(linkElement.textContent);
-        }
-
-        let pubDateElement = feedQueryXPath(feedDocument, itemElement,
-                './pubDate');
-        if (pubDateElement !== null) {
-            this.date = parseDate(pubDateElement.textContent);
-        }
-
-        let descriptionElement = feedQueryXPath(feedDocument, itemElement,
-                './description');
-        if (descriptionElement !== null) {
-            this.content = descriptionElement.textContent.trim();
-        }
-
-        for (let enclosureElement of
-                feedQueryXPathAll(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 = feedQueryXPath(feedDocument, entryElement,
-                './atom:title');
-        if (titleElement !== null) {
-            this.title = titleElement.textContent.trim();
-        }
-
-        let linkElement = feedQueryXPath(feedDocument, entryElement,
-                './atom:link[@href][@rel="alternate"]');
-        if (linkElement !== null) {
-            this.url = parseURL(linkElement.getAttribute('href'));
-        }
-
-        let updatedElement = feedQueryXPath(feedDocument, entryElement,
-                './atom:updated');
-        if (updatedElement !== null) {
-            this.date = parseDate(updatedElement.textContent);
-        }
-
-        let contentElement = feedQueryXPath(feedDocument, entryElement,
-                './atom:content');
-        if (contentElement === null) {
-            contentElement = feedQueryXPath(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 = contentElement.innerHTML;
-            } else if (contentType === 'html') {
-                this.content = contentElement.textContent;
-            } else {
-                let encodedContent =
-                        encodeXML(contentElement.textContent.trim());
-                this.content = `<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');
-        if (this.entries.length === 0) {
-            let hintTemplateElement =
-                    previewDocument.querySelector('#no-entries-hint-template');
-            let hintNode =
-                    previewDocument.importNode(hintTemplateElement.content,
-                    true);
-            hintNode.querySelector("#no-entries-hint").textContent =
-                browser.i18n.getMessage('noEntriesHint');
-
-            previewDocument.body.append(hintNode);
-        }
-        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 = feedQueryXPath(feedDocument, documentElement,
-                './rss:channel/rss:title');
-        if (titleElement !== null) {
-            this.title = titleElement.textContent;
-        }
-
-        let descriptionElement = feedQueryXPath(feedDocument, documentElement,
-                './channel/description');
-        if (descriptionElement !== null) {
-            this.subtitle = descriptionElement.textContent;
-        }
-
-        let imageElement = feedQueryXPath(feedDocument, documentElement,
-                './rss:image');
-        if (imageElement !== null) {
-            try {
-                let logo = new RSS1Logo(feedDocument, imageElement);
-                this.logo = logo;
-            } catch (e) {}
-        }
-
-        let itemElements = feedQueryXPathAll(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 = feedQueryXPath(feedDocument, documentElement,
-                './channel/title');
-        if (titleElement !== null) {
-            this.title = titleElement.textContent;
-        }
-
-        let descriptionElement = feedQueryXPath(feedDocument, documentElement,
-                './channel/description');
-        if (descriptionElement !== null) {
-            this.subtitle = descriptionElement.textContent;
-        }
-
-        let imageElement = feedQueryXPath(feedDocument, documentElement,
-                './channel/image');
-        if (imageElement !== null) {
-            try {
-                let logo = new RSS2Logo(feedDocument, imageElement);
-                this.logo = logo;
-            } catch (e) {}
-        }
-
-        let itemElements = feedQueryXPathAll(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 = feedQueryXPath(feedDocument, documentElement,
-                './atom:title');
-        if (titleElement !== null) {
-            this.title = titleElement.textContent.trim();
-        }
-
-        let subtitleElement = feedQueryXPath(feedDocument, documentElement,
-                './atom:subtitle');
-        if (subtitleElement !== null) {
-            this.subtitle = subtitleElement.textContent.trim();
-        }
-
-        let logoElement =  feedQueryXPath(feedDocument, documentElement,
-                './atom:logo');
-        if (logoElement !== null) {
-            try {
-                let logo = new AtomLogo(logoElement);
-                this.logo = logo;
-            } catch (e) {}
-        }
-
-        let entryElements = feedQueryXPathAll(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);
-}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/js/background.js	Thu Nov 08 16:30:34 2018 +0100
@@ -0,0 +1,161 @@
+/*
+ * 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';
+
+import * as feedParser from './feed-parser.js';
+import {renderFeedPreview} from './feed-preview.js';
+
+const FEED_MAGIC = [
+    '<rss',
+    '<feed',
+    ...Object.values(feedParser.XMLNS)
+];
+var tabsFeeds = new Map();
+var fetchingFeedPreview = fetch('web_resources/feed-preview.xhtml')
+        .then(response => response.text());
+
+function parseContentType(header) {
+    let contentType = {
+        mediaType: '',
+        charset: 'utf-8'
+    };
+    let parts = header.toLowerCase().split(';');
+    contentType.mediaType = parts.shift().trim();
+    for (let parameter of parts) {
+        let [, name, value, ] = parameter.trim().split(/([^=]+)="?([^"]*)"?/);
+        if (name.toLowerCase() === 'charset') {
+            contentType.charset = value.toLowerCase();
+            break;
+        }
+    }
+
+    return contentType;
+}
+
+async function handleFeed(inputText, url) {
+    // fast-path: eliminate XML documents which cannot be Atom nor RSS feeds
+    let inputTextStart = inputText.substring(0, 512);
+    if (!FEED_MAGIC.some(element => inputTextStart.includes(element))) {
+        return inputText;
+    }
+
+    let feed;
+    try {
+        feed = (new feedParser.FeedParser).parseFromString(inputText, url);
+    } catch (e) {
+        if (e instanceof feedParser.ParserError ||
+                e instanceof feedParser.UnsupportedFeedTypeError) {
+            // let the browser deal with non-well formed XML or XML documents
+            // which are not supported Atom or RSS feeds
+            return inputText;
+        }
+        throw e;
+    }
+    console.log(`parsed feed ${url}:\n`, feed);
+
+    // render the preview document
+    let feedPreviewDocument = new DOMParser()
+            .parseFromString(await fetchingFeedPreview, 'text/html');
+    renderFeedPreview(feedPreviewDocument, feed);
+
+    return new XMLSerializer().serializeToString(feedPreviewDocument);
+}
+
+browser.webRequest.onHeadersReceived.addListener(details => {
+    if (details.statusCode !== 200) {
+        return {};
+    }
+
+    let contentTypeIndex = details.responseHeaders.findIndex(header =>
+            header.name.toLowerCase() === 'content-type' &&
+            typeof header.value !== 'undefined');
+    if (contentTypeIndex < 0) {
+        // no Content-Type header found
+        return {};
+    }
+    let headerValue = details.responseHeaders[contentTypeIndex].value
+    let contentType = parseContentType(headerValue);
+    // until content handlers become available to webextensions
+    // (https://bugzilla.mozilla.org/show_bug.cgi?id=1457500) intercept all
+    // responses and change the content type from application/atom+xml or
+    // application/rss+xml to application/xml which will then be probed for
+    // Atom or RSS content
+    switch (contentType.mediaType) {
+        case 'application/atom+xml':
+        case 'application/rss+xml':
+        case 'application/rdf+xml':
+        case 'application/xml':
+            break;
+        default:
+            // non-XML media type
+            return {};
+    }
+    console.log(`response is an XML document\n`,
+            `media type: ${contentType.mediaType}\n`,
+            `charset: ${contentType.charset}`);
+
+    let decoder;
+    try {
+        decoder = new TextDecoder(contentType.charset);
+    } catch (e) {
+        if (e instanceof RangeError) {
+            // unsupported charset
+            return {};
+        } else {
+            throw e;
+        }
+    }
+    let encoder = new TextEncoder();
+    let inputText = '';
+    let filter = browser.webRequest.filterResponseData(details.requestId);
+    filter.addEventListener('data', ev => {
+        inputText += decoder.decode(ev.data, {stream: true});
+    });
+    filter.addEventListener('stop', async ev => {
+        let result = await handleFeed(inputText, details.url);
+        filter.write(encoder.encode(result));
+        filter.close();
+    });
+
+    details.responseHeaders[contentTypeIndex] = {
+        name: 'Content-Type',
+        value: `application/xml;charset=${contentType.charset}`
+    };
+
+    return {responseHeaders: details.responseHeaders};
+},
+        {urls: ['http://*/*', 'https://*/*'], types: ['main_frame']},
+        ['blocking', 'responseHeaders']);
+
+browser.runtime.onMessage.addListener((request, sender, sendResponse) => {
+    let tab = sender.tab;
+    if (typeof tab !== 'undefined') {
+        // content script sending feeds
+        tabsFeeds.set(tab.id, request);
+        browser.pageAction.show(tab.id);
+    } else {
+        // popup querying feeds
+        sendResponse(tabsFeeds.get(request));
+    }
+});
+
+browser.tabs.onUpdated.addListener((id, changeInfo, tab) => {
+    if (typeof changeInfo.url === 'undefined') {
+        // filter out updates which do not change the URL
+        return;
+    }
+
+    // hide the page action when the URL changes since it is no longer valid,
+    // it will be shown again if the content script detects a feed
+    browser.pageAction.hide(tab.id);
+});
+
+browser.tabs.onRemoved.addListener((tabId, removeInfo) => {
+    tabsFeeds.delete(tabId);
+});
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/js/feed-parser.js	Thu Nov 08 16:30:34 2018 +0100
@@ -0,0 +1,538 @@
+/*
+ * 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';
+
+export const XMLNS = {
+    ATOM10: 'http://www.w3.org/2005/Atom',
+    RSS09: 'http://my.netscape.com/rdf/simple/0.9/'
+}
+const ALLOWED_LINK_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 = '') {
+    let url;
+
+    try {
+        url = new URL(text, baseURL);
+    } catch (e) {
+        return null;
+    }
+    if (!ALLOWED_LINK_PROTOCOLS.has(url.protocol)) {
+        return null;
+    }
+
+    return url;
+}
+
+function feedNSResolver(prefix) {
+    switch (prefix) {
+        case 'atom':
+            return XMLNS.ATOM10;
+        case 'rss':
+            return XMLNS.RSS09;
+    }
+    return null;
+}
+
+function feedQueryXPath(feedDocument, scopeElement, xpathQuery) {
+    return feedDocument.evaluate(xpathQuery, scopeElement, feedNSResolver,
+            XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
+}
+
+function feedQueryXPathAll(feedDocument, scopeElement, xpathQuery) {
+    let result = feedDocument.evaluate(xpathQuery, scopeElement, feedNSResolver,
+            XPathResult.ORDERED_NODE_ITERATOR_TYPE, null);
+    let nodes = [];
+    for (let node = result.iterateNext(); node !== null;
+            node = result.iterateNext()) {
+        nodes.push(node);
+    }
+
+    return nodes;
+}
+
+export class ParserError extends Error {
+    constructor() {
+        super(...arguments);
+        this.name = this.constructor.name;
+    }
+}
+
+export class UnsupportedFeedTypeError extends Error {
+    constructor(message = 'Document is not a supported feed', ...params) {
+        super(message, ...params);
+        this.name = this.constructor.name;
+    }
+}
+
+export class ProtocolError extends Error {
+    constructor(url, status, statusText, ...params) {
+        let message = `Protocol error: Transfer of ${url} failed with: ` +
+                `${status} ${statusText}`
+        super(message, ...params);
+        this.name = this.constructor.name;
+        this.url = url;
+        this.status = status;
+        this.statusText = statusText;
+    }
+}
+
+class FeedLogo {
+    constructor(url, {title = ''} = {}) {
+        this.url = url;
+        this.title = title;
+    }
+}
+
+class FeedEntryFile {
+    constructor(url, {type = browser.i18n.getMessage('defaultFileType'),
+            size = 0} = {}) {
+        this.filename = undefined;
+        this._url = undefined;
+        this.url = url;
+        this.type = type;
+        this.size = size;
+    }
+
+    set url(url) {
+        this._url = url;
+        let filename = url.pathname.split('/').pop();
+        this.filename = filename !== '' ? filename :
+                browser.i18n.getMessage('defaultFileName');
+    }
+
+    get url() {
+        return this._url;
+    }
+}
+
+class FeedEntry {
+    constructor({title = browser.i18n.getMessage('defaultFeedEntryTitle'),
+            link = undefined, date = new Date(0), content = '',
+            files = []} = {}) {
+        this.title = title;
+        this.link = link;
+        this.date = date;
+        this._content = undefined;
+        this.content = content;
+        this.files = files;
+    }
+
+    normalizeContent(text) {
+        if (typeof text === 'undefined') {
+            return
+        }
+
+        let contentDocument = document.implementation.createHTMLDocument();
+        let parsedDocument = new DOMParser().parseFromString(text, 'text/html');
+        contentDocument.body = contentDocument.adoptNode(parsedDocument.body);
+        return new XMLSerializer().serializeToString(contentDocument);
+    }
+
+    set content(content) {
+        this._content = this.normalizeContent(content);
+    }
+
+    get content() {
+        return this._content;
+    }
+}
+
+class Feed {
+    constructor(url, {title = browser.i18n.getMessage('defaultFeedTitle'),
+            subtitle = '', logo, entries = []} = {}) {
+        this.url = url;
+        this.title = title;
+        this.subtitle = subtitle;
+        this.logo = logo;
+        this.entries = entries;
+    }
+}
+
+export class FeedParser {
+    static probeFeed(feedDocument) {
+        let documentElement = feedDocument.documentElement;
+        if (documentElement.nodeName === 'feed' &&
+                documentElement.namespaceURI === XMLNS.ATOM10) {
+            let version = documentElement.getAttribute('version');
+            if (version === null) {
+                version = '1.0';
+            }
+            if (version === '1.0') {
+                return ['atom', version];
+            }
+        } else if (documentElement.nodeName === 'rss') {
+            let version = documentElement.getAttribute('version');
+            switch (version) {
+                case '0.90':
+                case '0.91':
+                case '0.92':
+                case '0.93':
+                case '0.94':
+                case '2.0':
+                    return ['rss', version];
+            }
+        } else if (documentElement.localName.toLowerCase() === 'rdf' &&
+                documentElement.getAttribute('xmlns') === XMLNS.RSS09) {
+            return ['rss', '0.9'];
+        }
+
+        return [undefined, undefined];
+    }
+
+    constructor() {
+        this.url = undefined;
+        this.document = undefined;
+    }
+
+    parseAtomLogo(logoElement) {
+        let url = parseURL(logoElement.textContent.trim(), this.url);
+        if (url === null) {
+            throw new TypeError('invalid URL in <logo> element');
+        }
+        return new FeedLogo(url);
+    }
+
+    parseAtomEntry(entryElement) {
+        let title;
+        let link;
+        let date;
+        let content;
+        let titleElement = feedQueryXPath(this.document, entryElement,
+                './atom:title');
+        if (titleElement !== null) {
+            title = titleElement.textContent.trim();
+        }
+
+        let linkElement = feedQueryXPath(this.document, entryElement,
+                './atom:link[@href][@rel="alternate"]');
+        if (linkElement !== null) {
+            link = parseURL(linkElement.getAttribute('href'), this.url);
+        }
+
+        let updatedElement = feedQueryXPath(this.document, entryElement,
+                './atom:updated');
+        if (updatedElement !== null) {
+            date = parseDate(updatedElement.textContent);
+        }
+
+        let contentElement = feedQueryXPath(this.document, entryElement,
+                './atom:content');
+        if (contentElement === null) {
+            contentElement = feedQueryXPath(this.document, entryElement,
+                    './atom:summary');
+        }
+        if (contentElement !== null) {
+            let contentType = contentElement.getAttribute('type');
+            if (contentType === null) {
+                contentType = 'text';
+            }
+            contentType = contentType.toLowerCase();
+            if (contentType === 'xhtml') {
+                content = contentElement.innerHTML;
+            } else if (contentType === 'html') {
+                content = contentElement.textContent;
+            } else {
+                let encodedContent =
+                        encodeXML(contentElement.textContent.trim());
+                content = `<pre>${encodedContent}</pre>`;
+            }
+        }
+
+        return new FeedEntry({title, link, date, content});
+    }
+
+    parseAtomFeed() {
+        let title;
+        let subtitle;
+        let logo;
+        let entries = [];
+        let documentElement = this.document.documentElement;
+
+        let titleElement = feedQueryXPath(this.document, documentElement,
+                './atom:title');
+        if (titleElement !== null) {
+            title = titleElement.textContent.trim();
+        }
+
+        let subtitleElement = feedQueryXPath(this.document, documentElement,
+                './atom:subtitle');
+        if (subtitleElement !== null) {
+            subtitle = subtitleElement.textContent.trim();
+        }
+
+        let logoElement = feedQueryXPath(this.document, documentElement,
+                './atom:logo');
+        if (logoElement !== null) {
+            try {
+                logo = this.parseAtomLogo(logoElement);
+            } catch (e) {
+                if (!(e instanceof TypeError)) {
+                    throw e;
+                }
+            }
+        }
+
+        let entryElements = feedQueryXPathAll(this.document, documentElement,
+                './atom:entry');
+        for (let entryElement of entryElements) {
+            entries.push(this.parseAtomEntry(entryElement));
+        }
+
+        return new Feed(this.url, {title, subtitle, logo, entries});
+    }
+
+    parseRSS1Logo(imageElement) {
+        let title;
+        let urlElement = feedQueryXPath(this.document, imageElement,
+                './rss:url');
+        if (urlElement === null) {
+            throw new TypeError('missing <url> element in <logo> element');
+        }
+        let url = parseURL(urlElement.textContent.trim(), this.url);
+        if (url === null) {
+            throw new TypeError('invalid URL in <logo> element');
+        }
+
+        let titleElement = feedQueryXPath(this.document, imageElement,
+                './rss:title');
+        if (titleElement !== null) {
+            title = titleElement.textContent.trim();
+        }
+
+        return new FeedLogo(url, {title});
+    }
+
+    parseRSS1Entry(itemElement) {
+        let title;
+        let link;
+        let titleElement = feedQueryXPath(this.document, itemElement,
+                './rss:title');
+        if (titleElement !== null) {
+            title = titleElement.textContent;
+        }
+
+        let linkElement = feedQueryXPath(this.document, itemElement,
+                './rss:link');
+        if (linkElement !== null) {
+            link = parseURL(linkElement.textContent, this.url);
+        }
+
+        return new FeedEntry({title, link});
+    }
+
+    parseRSS1Feed() {
+        let title;
+        let subtitle;
+        let logo;
+        let entries = [];
+        let documentElement = this.document.documentElement;
+        let titleElement = feedQueryXPath(this.document, documentElement,
+                './rss:channel/rss:title');
+        if (titleElement !== null) {
+            title = titleElement.textContent;
+        }
+
+        let descriptionElement = feedQueryXPath(this.document, documentElement,
+                './channel/description');
+        if (descriptionElement !== null) {
+            subtitle = descriptionElement.textContent;
+        }
+
+        let imageElement = feedQueryXPath(this.document, documentElement,
+                './rss:image');
+        if (imageElement !== null) {
+            try {
+                logo = this.parseRSS1Logo(imageElement);
+            } catch (e) {
+                if (!(e instanceof TypeError)) {
+                    throw e;
+                }
+            }
+        }
+
+        let itemElements = feedQueryXPathAll(this.document, documentElement,
+                './rss:item');
+        for (let itemElement of itemElements) {
+            let entry = this.parseRSS1Entry(itemElement);
+            if (typeof entry !== 'undefined') {
+                entries.push(entry);
+            }
+        }
+
+        return new Feed(this.url, {title, subtitle, logo, entries});
+    }
+
+    parseRSS2Logo(imageElement) {
+        let title;
+        let urlElement = feedQueryXPath(this.document, imageElement, './url');
+        if (urlElement === null) {
+            throw new TypeError('missing <url> element in <logo> element');
+        }
+        let url = parseURL(urlElement.textContent.trim(), this.url);
+        if (url === null) {
+            throw new TypeError('invalid URL in <logo> element');
+        }
+
+        let titleElement = feedQueryXPath(this.document, imageElement,
+                './title');
+        if (titleElement !== null) {
+            title = titleElement.textContent.trim();
+        }
+
+        return new FeedLogo(url, {title});
+    }
+
+    parseRSS2EntryFile(enclosureElement) {
+        let type;
+        let size;
+        let url = parseURL(enclosureElement.getAttribute('url'), this.url);
+        if (url === null) {
+            throw new TypeError('invalid URL in <enclosure> element');
+        }
+
+        let typeAttribute = enclosureElement.getAttribute('type');
+        if (typeAttribute !== null) {
+            type = typeAttribute;
+        }
+
+        let length = parseInt(enclosureElement.getAttribute('length'),
+                10);
+        if (!isNaN(length)) {
+            size = length;
+        }
+
+        return new FeedEntryFile(url, {type, size});
+    }
+
+    parseRSS2Entry(itemElement) {
+        let title;
+        let link;
+        let date;
+        let content;
+        let files = [];
+        let titleElement = feedQueryXPath(this.document, itemElement,
+                './title');
+        if (titleElement !== null) {
+            title = titleElement.textContent;
+        }
+
+        let linkElement = feedQueryXPath(this.document, itemElement, './link');
+        if (linkElement !== null) {
+            link = parseURL(linkElement.textContent, this.url);
+        }
+
+        let pubDateElement = feedQueryXPath(this.document, itemElement,
+                './pubDate');
+        if (pubDateElement !== null) {
+            date = parseDate(pubDateElement.textContent);
+        }
+
+        let descriptionElement = feedQueryXPath(this.document, itemElement,
+                './description');
+        if (descriptionElement !== null) {
+            content = descriptionElement.textContent.trim();
+        }
+
+        for (let enclosureElement of
+                feedQueryXPathAll(this.document, itemElement, './enclosure')) {
+            try {
+                let entryFile = this.parseRSS2EntryFile(enclosureElement);
+                files.push(entryFile);
+            } catch (e) {
+                if (!(e instanceof TypeError)) {
+                    throw e;
+                }
+            }
+        }
+
+        return new FeedEntry({title, link, date, content, files});
+    }
+
+    parseRSS2Feed() {
+        let title;
+        let subtitle;
+        let logo;
+        let entries = [];
+        let documentElement = this.document.documentElement;
+        let titleElement = feedQueryXPath(this.document, documentElement,
+                './channel/title');
+        if (titleElement !== null) {
+            title = titleElement.textContent;
+        }
+
+        let descriptionElement = feedQueryXPath(this.document, documentElement,
+                './channel/description');
+        if (descriptionElement !== null) {
+            subtitle = descriptionElement.textContent;
+        }
+
+        let imageElement = feedQueryXPath(this.document, documentElement,
+                './channel/image');
+        if (imageElement !== null) {
+            try {
+                logo = this.parseRSS2Logo(imageElement);
+            } catch (e) {
+                if (!(e instanceof TypeError)) {
+                    throw e;
+                }
+            }
+        }
+
+        let itemElements = feedQueryXPathAll(this.document, documentElement,
+                './channel/item');
+        for (let itemElement of itemElements) {
+            let entry = this.parseRSS2Entry(itemElement);
+            if (typeof entry !== 'undefined') {
+                entries.push(entry);
+            }
+        }
+
+        return new Feed(this.url, {title, subtitle, logo, entries});
+    }
+
+    parseFromString(xmlString, url) {
+        this.url = url;
+        this.document = new DOMParser().parseFromString(xmlString,
+                'application/xml');
+        if (this.document.documentElement.nodeName.toLowerCase() ===
+                'parsererror') {
+            throw new ParserError(this.document.documentElement.textContent);
+        }
+
+        let [type, version] = this.constructor.probeFeed(this.document);
+        if (type === 'atom') {
+            return this.parseAtomFeed();
+        } else if (type === 'rss') {
+            if (version === '0.9') {
+                return this.parseRSS1Feed();
+            } else {
+                return this.parseRSS2Feed();
+            }
+        }
+        throw new UnsupportedFeedTypeError();
+    }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/js/feed-preview.js	Thu Nov 08 16:30:34 2018 +0100
@@ -0,0 +1,124 @@
+/*
+ * 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';
+
+export function renderFeedPreview(feedPreviewDocument, feed) {
+    // inject XSL stylesheet which transforms XHTML to HTML allowing the use of
+    // the HTML DOM
+    let xslFilename = browser.runtime.getURL('web_resources/xhtml-to-html.xsl');
+    let xmlStylesheetNode =
+            feedPreviewDocument.createProcessingInstruction('xml-stylesheet',
+            `type="application/xslt+xml" href="${xslFilename}"`);
+    feedPreviewDocument.firstChild.after(xmlStylesheetNode);
+
+    feedPreviewDocument.querySelector('link[rel=stylesheet]').href =
+            browser.runtime.getURL('web_resources/style/feed-preview.css');
+
+    feedPreviewDocument.querySelector('title').textContent = feed.title;
+    feedPreviewDocument.querySelector('#feed-title').textContent = feed.title;
+    feedPreviewDocument.querySelector('#feed-subtitle').textContent =
+            feed.subtitle;
+
+    if (typeof feed.logo !== 'undefined') {
+        let feedLogoTemplate =
+                feedPreviewDocument.querySelector('#feed-logo-template');
+        let logoNode = feedPreviewDocument.importNode(feedLogoTemplate.content,
+                true);
+        let imgElement = logoNode.querySelector('#feed-logo');
+        imgElement.setAttribute('src', feed.logo.url);
+        imgElement.setAttribute('alt', feed.logo.title);
+        feedPreviewDocument.querySelector('#feed-header').prepend(logoNode);
+    }
+
+    let entryTemplateElement =
+            feedPreviewDocument.querySelector('#entry-template');
+    let entryTitleTemplateElement =
+            feedPreviewDocument.querySelector('#entry-title-template');
+    let entryTitleLinkedTemplateElement =
+            feedPreviewDocument.querySelector('#entry-title-linked-template');
+    let entryFileListTemplateElement =
+            feedPreviewDocument.querySelector('#entry-files-list-template');
+    let entryFileTemplateElement =
+            feedPreviewDocument.querySelector('#entry-file-template');
+    if (feed.entries.length === 0) {
+        let hintTemplateElement =
+                previewDocument.querySelector('#no-entries-hint-template');
+        let hintNode = previewDocument.importNode(hintTemplateElement.content,
+                true);
+        hintNode.querySelector("#no-entries-hint").textContent =
+            browser.i18n.getMessage('noEntriesHint');
+
+        previewDocument.body.append(hintNode);
+    }
+    for (let entry of feed.entries) {
+        let entryNode =
+                feedPreviewDocument.importNode(entryTemplateElement.content,
+                true);
+        let titleElement;
+        let titleNode;
+
+        if (typeof entry.link !== 'undefined') {
+            titleNode = feedPreviewDocument
+                    .importNode(entryTitleLinkedTemplateElement.content, true);
+            titleElement = titleNode.querySelector('.entry-link');
+            titleElement.href = entry.link;
+            titleElement.title = entry.title;
+        } else {
+            titleNode = feedPreviewDocument
+                    .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');
+        let contentDocument = new DOMParser().parseFromString(entry.content,
+                'text/html');
+        let stylesheetElement = contentDocument.createElement('link');
+        stylesheetElement.rel = 'stylesheet';
+        stylesheetElement.href =
+                browser.runtime.getURL('web_resources/style/entry-content.css');
+        contentDocument.head.appendChild(stylesheetElement);
+        contentElement.srcdoc = new XMLSerializer()
+                .serializeToString(contentDocument);
+        contentElement.title = entry.title;
+
+        if (entry.files.length > 0) {
+            let fileListNode = feedPreviewDocument
+                    .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 = feedPreviewDocument
+                        .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);
+        }
+
+        feedPreviewDocument.body.append(entryNode);
+    }
+}
--- a/manifest.json.in	Sun Nov 04 10:03:05 2018 +0100
+++ b/manifest.json.in	Thu Nov 08 16:30:34 2018 +0100
@@ -24,24 +24,18 @@
         "webRequestBlocking"
     ],
     "background": {
-        "scripts": [ "background.js" ]
+        "page": "background.html"
     },
     "content_scripts": [
         {
             "matches": [ "http://*/*", "https://*/*", "file:///*" ],
-            "js": [
-                "content_scripts/feed-probe.js",
-                "content_scripts/feed-preview.js"
-            ]
+            "js": [ "content_scripts/feed-probe.js" ]
         }
     ],
     "web_accessible_resources": [
-        "web_resources/feed-preview.xhtml",
-        "web_resources/arrow.svg",
-        "web_resources/style/common.css",
-        "web_resources/style/entry-content.css",
-        "web_resources/style/feed-preview.css",
-        "web_resources/style/photon-colors.css"
+        "web_resources/xhtml-to-html.xsl",
+        "web_resources/images/arrow.svg",
+        "web_resources/style/*.css"
     ],
     "page_action": {
         "browser_style": true,
--- a/web_resources/feed-preview.xhtml	Sun Nov 04 10:03:05 2018 +0100
+++ b/web_resources/feed-preview.xhtml	Thu Nov 08 16:30:34 2018 +0100
@@ -1,3 +1,5 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE html>
 <html xmlns="http://www.w3.org/1999/xhtml">
   <head>
     <meta charset="utf-8"/>
@@ -9,50 +11,49 @@
    file, You can obtain one at http://mozilla.org/MPL/2.0/.
 -->
     <meta name="viewport" content="width=device-width, initial-scale=1"/>
-    <base href=""/>
-    <link rel="stylesheet" href="style/feed-preview.css"/>
+    <link rel="stylesheet" href=""/>
     <title></title>
   </head>
   <body>
-  <template id="feed-logo-template">
-    <img id="feed-logo" src="" alt=""/>
-  </template>
-  <template id="entry-template">
-    <article>
-      <details class="entry">
-        <summary>
-          <header class="entry-header">
-            <p class="entry-date"><time></time></p>
-          </header>
-        </summary>
-        <iframe class="entry-content" srcdoc="" title="" sandbox=""
-        width="800" height="360"/>
-      </details>
-    </article>
-  </template>
-  <template id="entry-title-template">
-    <h1 class="entry-title"></h1>
-  </template>
-  <template id="entry-title-linked-template">
-    <h1 class="entry-title"><a class="entry-link" href="" title=""></a></h1>
-  </template>
-  <template id="entry-files-list-template">
-    <footer class="entry-files">
-      <h2 class="entry-files-title"></h2>
-      <ul class="entry-files-list">
-      </ul>
-    </footer>
-  </template>
-  <template id="no-entries-hint-template">
-    <p id="no-entries-hint"></p>
-  </template>
-  <template id="entry-file-template">
-    <li class="entry-file"><a class="entry-file-link" href="" title=""></a>
-    <span class="entry-file-info"></span></li>
-  </template>
-  <header id="feed-header">
-    <h1 id="feed-title"></h1>
-    <p id="feed-subtitle"></p>
-  </header>
+    <template id="feed-logo-template">
+      <img id="feed-logo" src="" alt=""/>
+    </template>
+    <template id="entry-template">
+      <article>
+        <details class="entry">
+          <summary>
+            <header class="entry-header">
+              <p class="entry-date"><time></time></p>
+            </header>
+          </summary>
+          <iframe class="entry-content" srcdoc="" title="" sandbox=""
+          width="800" height="360"></iframe>
+        </details>
+      </article>
+    </template>
+    <template id="entry-title-template">
+      <h1 class="entry-title"></h1>
+    </template>
+    <template id="entry-title-linked-template">
+      <h1 class="entry-title"><a class="entry-link" href="" title=""></a></h1>
+    </template>
+    <template id="entry-files-list-template">
+      <footer class="entry-files">
+        <h2 class="entry-files-title"></h2>
+        <ul class="entry-files-list">
+        </ul>
+      </footer>
+    </template>
+    <template id="no-entries-hint-template">
+      <p id="no-entries-hint"></p>
+    </template>
+    <template id="entry-file-template">
+      <li class="entry-file"><a class="entry-file-link" href="" title=""></a>
+      <span class="entry-file-info"></span></li>
+    </template>
+    <header id="feed-header">
+      <h1 id="feed-title"></h1>
+      <p id="feed-subtitle"></p>
+    </header>
   </body>
 </html>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web_resources/xhtml-to-html.xsl	Thu Nov 08 16:30:34 2018 +0100
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+   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/.
+-->
+<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
+  <xsl:output method="html" doctype-system="about:legacy-compat"
+  encoding="UTF-8"/>
+  <xsl:template match="@*|node()">
+    <xsl:copy>
+      <xsl:apply-templates select="@*|node()"/>
+    </xsl:copy>
+  </xsl:template>
+</xsl:stylesheet>