Mercurial > addons > firefox-addons > feed-preview
view content_scripts/feed-preview.js @ 5:341a0f4b7ce0
Handle feed entry content normalization with a setter
author | Guido Berhoerster <guido+feed-preview@berhoerster.name> |
---|---|
date | Sun, 04 Nov 2018 10:03:05 +0100 |
parents | 086ee559acbb |
children |
line wrap: on
line source
/* * 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 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); }