/******************************************************************************* µMatrix - a Chromium browser extension to black/white list requests. Copyright (C) 2014 Raymond Hill This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see {http://www.gnu.org/licenses/}. Home: https://github.com/gorhill/uMatrix */ 'use strict'; /******************************************************************************/ // This file should always be included at the end of the `body` tag, so as // to ensure all i18n targets are already loaded. { // >>>>> start of local scope /******************************************************************************/ // https://github.com/gorhill/uBlock/issues/2084 // Anything else than , , , , , , and will // be rendered as plain text. // For , only the type attribute is allowed. // For , only href attribute must be present, and it MUST starts with // `https://`, and includes no single- or double-quotes. // No HTML entities are allowed, there is code to handle existing HTML // entities already present in translation files until they are all gone. const reSafeTags = /^([\s\S]*?)<(b|code|em|i|span)>(.+?)<\/\2>([\s\S]*)$/; const reSafeLink = /^([\s\S]*?)<(a href=['"]https:\/\/[^'" <>]+['"])>(.+?)<\/a>([\s\S]*)$/; const reLink = /^a href=(['"])(https:\/\/[^'"]+)\1$/; const safeTextToTagNode = function(text) { if ( text.lastIndexOf('a ', 0) === 0 ) { const matches = reLink.exec(text); if ( matches === null ) { return null; } const node = document.createElement('a'); node.setAttribute('href', matches[2]); return node; } // Firefox extension validator warns if using a variable as argument for // document.createElement(). switch ( text ) { case 'b': return document.createElement('b'); case 'blockquote': return document.createElement('blockquote'); case 'code': return document.createElement('code'); case 'em': return document.createElement('em'); case 'i': return document.createElement('i'); case 'kbd': return document.createElement('kbd'); case 'span': return document.createElement('span'); case 'sup': return document.createElement('sup'); default: break; } }; const safeTextToTextNode = (( ) => { const entities = new Map([ // TODO: Remove quote entities once no longer present in translation // files. Other entities must stay. [ '“', '“' ], [ '”', '”' ], [ '‘', '‘' ], [ '’', '’' ], [ '<', '<' ], [ '>', '>' ], ]); const decodeEntities = match => { return entities.get(match) || match; }; return function(text) { if ( text.indexOf('&') !== -1 ) { text = text.replace(/&[a-z]+;/g, decodeEntities); } return document.createTextNode(text); }; })(); const safeTextToDOM = function(text, parent) { if ( text === '' ) { return; } // Fast path (most common). if ( text.indexOf('<') === -1 ) { parent.appendChild(safeTextToTextNode(text)); return; } // Slow path. // `

` no longer allowed. Code below can be removed once all

's are // gone from translation files. text = text.replace(/^

|<\/p>/g, '') .replace(/

/g, '\n\n'); // Parse allowed HTML tags. let matches = reSafeTags.exec(text); if ( matches === null ) { matches = reSafeLink.exec(text); if ( matches === null ) { parent.appendChild(safeTextToTextNode(text)); return; } } const fragment = document.createDocumentFragment(); safeTextToDOM(matches[1], fragment); let node = safeTextToTagNode(matches[2]); safeTextToDOM(matches[3], node); fragment.appendChild(node); safeTextToDOM(matches[4], fragment); parent.appendChild(fragment); }; /******************************************************************************/ vAPI.i18n.safeTemplateToDOM = function(id, dict, parent) { if ( parent === undefined ) { parent = document.createDocumentFragment(); } let textin = vAPI.i18n(id); if ( textin === '' ) { return parent; } if ( textin.indexOf('{{') === -1 ) { safeTextToDOM(textin, parent); return parent; } const re = /\{\{\w+\}\}/g; let textout = ''; for (;;) { let match = re.exec(textin); if ( match === null ) { textout += textin; break; } textout += textin.slice(0, match.index); let prop = match[0].slice(2, -2); if ( dict.hasOwnProperty(prop) ) { textout += dict[prop].replace(//g, '>'); } else { textout += prop; } textin = textin.slice(re.lastIndex); } safeTextToDOM(textout, parent); return parent; }; /******************************************************************************/ // Helper to deal with the i18n'ing of HTML files. vAPI.i18n.render = function(context) { const docu = document; const root = context || docu; for ( const elem of root.querySelectorAll('[data-i18n]') ) { let text = vAPI.i18n(elem.getAttribute('data-i18n')); if ( !text ) { continue; } if ( text.indexOf('{{') === -1 ) { safeTextToDOM(text, elem); continue; } // Handle selector-based placeholders: these placeholders tell where // existing child DOM element are to be positioned relative to the // localized text nodes. const parts = text.split(/(\{\{[^}]+\}\})/); const fragment = document.createDocumentFragment(); let textBefore = ''; for ( let part of parts ) { if ( part === '' ) { continue; } if ( part.startsWith('{{') && part.endsWith('}}') ) { // TODO: remove detection of ':' once it no longer appears // in translation files. const pos = part.indexOf(':'); if ( pos !== -1 ) { part = part.slice(0, pos) + part.slice(-2); } const node = elem.querySelector(part.slice(2, -2)); if ( node !== null ) { safeTextToDOM(textBefore, fragment); fragment.appendChild(node); textBefore = ''; continue; } } textBefore += part; } if ( textBefore !== '' ) { safeTextToDOM(textBefore, fragment); } elem.appendChild(fragment); } for ( const elem of root.querySelectorAll('[data-i18n-title]') ) { const text = vAPI.i18n(elem.getAttribute('data-i18n-title')); if ( !text ) { continue; } elem.setAttribute('title', text); } for ( const elem of root.querySelectorAll('[placeholder]') ) { elem.setAttribute( 'placeholder', vAPI.i18n(elem.getAttribute('placeholder')) ); } for ( const elem of root.querySelectorAll('[data-i18n-tip]') ) { const text = vAPI.i18n(elem.getAttribute('data-i18n-tip')) .replace(/
/g, '\n') .replace(/\n{3,}/g, '\n\n'); elem.setAttribute('data-tip', text); if ( elem.getAttribute('aria-label') === 'data-tip' ) { elem.setAttribute('aria-label', text); } } }; vAPI.i18n.render(); /******************************************************************************/ vAPI.i18n.renderElapsedTimeToString = function(tstamp) { let value = (Date.now() - tstamp) / 60000; if ( value < 2 ) { return vAPI.i18n('elapsedOneMinuteAgo'); } if ( value < 60 ) { return vAPI.i18n('elapsedManyMinutesAgo').replace('{{value}}', Math.floor(value).toLocaleString()); } value /= 60; if ( value < 2 ) { return vAPI.i18n('elapsedOneHourAgo'); } if ( value < 24 ) { return vAPI.i18n('elapsedManyHoursAgo').replace('{{value}}', Math.floor(value).toLocaleString()); } value /= 24; if ( value < 2 ) { return vAPI.i18n('elapsedOneDayAgo'); } return vAPI.i18n('elapsedManyDaysAgo').replace('{{value}}', Math.floor(value).toLocaleString()); }; /******************************************************************************/ // <<<<< end of local scope } /******************************************************************************/