diff --git a/.eslintignore b/.eslintignore index 8e747f5b..a710e413 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,4 +1,2 @@ vendor/ -vendor-overwrites/* -!vendor-overwrites/colorpicker -!vendor-overwrites/csslint +vendor-overwrites/ diff --git a/.eslintrc.yml b/.eslintrc.yml index c99504b4..3d93786a 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -8,6 +8,9 @@ env: es6: true webextensions: true +globals: + define: readonly + rules: accessor-pairs: [2] array-bracket-spacing: [2, never] @@ -42,7 +45,15 @@ rules: id-blacklist: [0] id-length: [0] id-match: [0] - indent-legacy: [2, 2, {VariableDeclarator: 0, SwitchCase: 1}] + indent: [2, 2, { + SwitchCase: 1, + ignoreComments: true, + ignoredNodes: [ + "TemplateLiteral > *", + "ConditionalExpression", + "ForStatement" + ] + }] jsx-quotes: [0] key-spacing: [0] keyword-spacing: [2] @@ -86,7 +97,7 @@ rules: no-empty: [2, {allowEmptyCatch: true}] no-eq-null: [0] no-eval: [2] - no-ex-assign: [2] + no-ex-assign: [0] no-extend-native: [2] no-extra-bind: [2] no-extra-boolean-cast: [2] @@ -136,6 +147,9 @@ rules: no-proto: [2] no-redeclare: [2] no-regex-spaces: [2] + no-restricted-globals: [2, name, event] + # `name` and `event` (in Chrome) are built-in globals + # but we don't use these globals so it's most likely a mistake/typo no-restricted-imports: [0] no-restricted-modules: [2, domain, freelist, smalloc, sys] no-restricted-syntax: [2, WithStatement] @@ -165,7 +179,7 @@ rules: no-unsafe-negation: [2] no-unused-expressions: [1] no-unused-labels: [0] - no-unused-vars: [2, {args: after-used}] + no-unused-vars: [2, {args: after-used, argsIgnorePattern: "^require$"}] no-use-before-define: [2, nofunc] no-useless-call: [2] no-useless-computed-key: [2] @@ -220,3 +234,7 @@ overrides: webextensions: false parserOptions: ecmaVersion: 2017 + + - files: ["**/*worker*.js"] + env: + worker: true diff --git a/README.md b/README.md index 16d56267..b731931d 100644 --- a/README.md +++ b/README.md @@ -47,15 +47,15 @@ See our [contributing](./.github/CONTRIBUTING.md) page for more details. ## License -Inherited code from the original [Stylish](https://github.com/stylish-userstyles/stylish/): +Inherited code from the original [Stylish](https://github.com/stylish-userstyles/stylish/): Copyright © 2005-2014 [Jason Barnabe](jason.barnabe@gmail.com) -Current Stylus: +Current Stylus: Copyright © 2017-2019 [Stylus Team](https://github.com/openstyles/stylus/graphs/contributors) -**[GNU GPLv3](./LICENSE)** +**[GNU GPLv3](./LICENSE)** 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 diff --git a/background/background-api.js b/background/background-api.js new file mode 100644 index 00000000..8e6a066a --- /dev/null +++ b/background/background-api.js @@ -0,0 +1,154 @@ +'use strict'; + +/* Populates API */ + +define(require => { + const { + URLS, + activateTab, + findExistingTab, + getActiveTab, + isTabReplaceable, + openURL, + } = require('/js/toolbox'); + const {API, msg} = require('/js/msg'); + const {createWorker} = require('/js/worker-util'); + const prefs = require('/js/prefs'); + + Object.assign(API, ...[ + require('./icon-manager'), + require('./openusercss-api'), + require('./search-db'), + ], /** @namespace API */ { + + browserCommands: { + openManage: () => API.openManage(), + openOptions: () => API.openManage({options: true}), + reload: () => chrome.runtime.reload(), + styleDisableAll(info) { + prefs.set('disableAll', info ? info.checked : !prefs.get('disableAll')); + }, + }, + + /** @type {StyleManager} */ + styles: require('./style-manager'), + + /** @type {Sync} */ + sync: require('./sync'), + + /** @type {StyleUpdater} */ + updater: require('./update'), + + /** @type {UsercssHelper} */ + usercss: Object.assign({}, + require('./usercss-api-helper'), + require('./usercss-install-helper')), + + /** @type {BackgroundWorker} */ + worker: createWorker({ + url: '/background/background-worker.js', + }), + + /** @returns {string} */ + getTabUrlPrefix() { + const {url} = this.sender.tab; + if (url.startsWith(URLS.ownOrigin)) { + return 'stylus'; + } + return url.match(/^([\w-]+:\/+[^/#]+)/)[1]; + }, + + /** @returns {PrefsValues} */ + getPrefs: () => prefs.values, + setPref(key, value) { + prefs.set(key, value); + }, + + /** + * Opens the editor or activates an existing tab + * @param {{ + id?: number + domain?: string + 'url-prefix'?: string + }} params + * @returns {Promise} + */ + openEditor(params) { + const u = new URL(chrome.runtime.getURL('edit.html')); + u.search = new URLSearchParams(params); + return openURL({ + url: `${u}`, + currentWindow: null, + newWindow: prefs.get('openEditInWindow') && Object.assign({}, + prefs.get('openEditInWindow.popup') && {type: 'popup'}, + prefs.get('windowPosition')), + }); + }, + + /** @returns {Promise} */ + async openManage({options = false, search, searchMode} = {}) { + let url = chrome.runtime.getURL('manage.html'); + if (search) { + url += `?search=${encodeURIComponent(search)}&searchMode=${searchMode}`; + } + if (options) { + url += '#stylus-options'; + } + let tab = await findExistingTab({ + url, + currentWindow: null, + ignoreHash: true, + ignoreSearch: true, + }); + if (tab) { + await activateTab(tab); + if (url !== (tab.pendingUrl || tab.url)) { + await msg.sendTab(tab.id, {method: 'pushState', url}).catch(console.error); + } + return tab; + } + tab = await getActiveTab(); + return isTabReplaceable(tab, url) + ? activateTab(tab, {url}) + : browser.tabs.create({url}).then(activateTab); // activateTab unminimizes the window + }, + + /** + * Same as openURL, the only extra prop in `opts` is `message` - it'll be sent + * when the tab is ready, which is needed in the popup, otherwise another + * extension could force the tab to open in foreground thus auto-closing the + * popup (in Chrome at least) and preventing the sendMessage code from running + * @returns {Promise} + */ + async openURL(opts) { + const tab = await openURL(opts); + if (opts.message) { + await onTabReady(tab); + await msg.sendTab(tab.id, opts.message); + } + return tab; + function onTabReady(tab) { + return new Promise((resolve, reject) => + setTimeout(function ping(numTries = 10, delay = 100) { + msg.sendTab(tab.id, {method: 'ping'}) + .catch(() => false) + .then(pong => pong + ? resolve(tab) + : numTries && setTimeout(ping, delay, numTries - 1, delay * 1.5) || + reject('timeout')); + })); + } + }, + }); + + msg.on((msg, sender) => { + if (msg.method === 'invokeAPI') { + const fn = msg.path.reduce((res, name) => res && res[name], API); + if (!fn) throw new Error(`Unknown API.${msg.path.join('.')}`); + const res = typeof fn === 'function' + ? fn.apply({msg, sender}, msg.args) + : fn; + return res === undefined ? null : res; + } + }); +}); diff --git a/background/background-worker.js b/background/background-worker.js index 5c8136b4..94430d13 100644 --- a/background/background-worker.js +++ b/background/background-worker.js @@ -1,84 +1,44 @@ -/* global workerUtil importScripts parseMozFormat metaParser styleCodeEmpty colorConverter */ 'use strict'; -importScripts('/js/worker-util.js'); -const {loadScript} = workerUtil; +define(require => { // define and require use `importScripts` which is synchronous + const {createAPI} = require('/js/worker-util'); -/** @namespace ApiWorker */ -workerUtil.createAPI({ - parseMozFormat(arg) { - loadScript('/vendor-overwrites/csslint/parserlib.js', '/js/moz-parser.js'); - return parseMozFormat(arg); - }, - compileUsercss, - parseUsercssMeta(text, indexOffset = 0) { - loadScript( - '/vendor/usercss-meta/usercss-meta.min.js', - '/vendor-overwrites/colorpicker/colorconverter.js', - '/js/meta-parser.js' - ); - return metaParser.parse(text, indexOffset); - }, - nullifyInvalidVars(vars) { - loadScript( - '/vendor/usercss-meta/usercss-meta.min.js', - '/vendor-overwrites/colorpicker/colorconverter.js', - '/js/meta-parser.js' - ); - return metaParser.nullifyInvalidVars(vars); - }, -}); + let BUILDERS; + const bgw = /** @namespace BackgroundWorker */ { -function compileUsercss(preprocessor, code, vars) { - loadScript( - '/vendor-overwrites/csslint/parserlib.js', - '/vendor-overwrites/colorpicker/colorconverter.js', - '/js/moz-parser.js' - ); - const builder = getUsercssCompiler(preprocessor); - vars = simpleVars(vars); - return Promise.resolve(builder.preprocess ? builder.preprocess(code, vars) : code) - .then(code => parseMozFormat({code})) - .then(({sections, errors}) => { - if (builder.postprocess) { - builder.postprocess(sections, vars); - } - return {sections, errors}; - }); + async compileUsercss(preprocessor, code, vars) { + if (!BUILDERS) createBuilders(); + const builder = BUILDERS[preprocessor] || BUILDERS.default; + if (!builder) throw new Error(`Unknown preprocessor "${preprocessor}"`); + vars = simplifyVars(vars); + const {preprocess, postprocess} = builder; + if (preprocess) code = await preprocess(code, vars); + const res = bgw.parseMozFormat({code}); + if (postprocess) postprocess(res.sections, vars); + return res; + }, - function simpleVars(vars) { - if (!vars) { - return {}; - } - // simplify vars by merging `va.default` to `va.value`, so BUILDER don't - // need to test each va's default value. - return Object.keys(vars).reduce((output, key) => { - const va = vars[key]; - output[key] = Object.assign({}, va, { - value: va.value === null || va.value === undefined ? - getVarValue(va, 'default') : getVarValue(va, 'value'), - }); - return output; - }, {}); - } + parseMozFormat(...args) { + return require('/js/moz-parser').extractSections(...args); + }, - function getVarValue(va, prop) { - if (va.type === 'select' || va.type === 'dropdown' || va.type === 'image') { - // TODO: handle customized image - return va.options.find(o => o.name === va[prop]).value; - } - if ((va.type === 'number' || va.type === 'range') && va.units) { - return va[prop] + va.units; - } - return va[prop]; - } -} + parseUsercssMeta(text) { + return require('/js/meta-parser').parse(text); + }, -function getUsercssCompiler(preprocessor) { - const BUILDER = { - default: { + nullifyInvalidVars(vars) { + return require('/js/meta-parser').nullifyInvalidVars(vars); + }, + }; + + createAPI(bgw); + + function createBuilders() { + BUILDERS = Object.assign(Object.create(null)); + + BUILDERS.default = { postprocess(sections, vars) { - loadScript('/js/sections-util.js'); + const {styleCodeEmpty} = require('/js/sections-util'); let varDef = Object.keys(vars).map(k => ` --${k}: ${vars[k].value};\n`).join(''); if (!varDef) return; varDef = ':root {\n' + varDef + '}\n'; @@ -88,18 +48,20 @@ function getUsercssCompiler(preprocessor) { } } }, - }, - stylus: { + }; + + BUILDERS.stylus = { preprocess(source, vars) { - loadScript('/vendor/stylus-lang-bundle/stylus-renderer.min.js'); + require('/vendor/stylus-lang-bundle/stylus-renderer.min'); return new Promise((resolve, reject) => { const varDef = Object.keys(vars).map(key => `${key} = ${vars[key].value};\n`).join(''); new self.StylusRenderer(varDef + source) .render((err, output) => err ? reject(err) : resolve(output)); }); }, - }, - less: { + }; + + BUILDERS.less = { preprocess(source, vars) { if (!self.less) { self.less = { @@ -107,17 +69,18 @@ function getUsercssCompiler(preprocessor) { useFileCache: false, }; } - loadScript('/vendor/less-bundle/less.min.js'); + require('/vendor/less-bundle/less.min'); const varDefs = Object.keys(vars).map(key => `@${key}:${vars[key].value};\n`).join(''); return self.less.render(varDefs + source) .then(({css}) => css); }, - }, - uso: { - preprocess(source, vars) { - loadScript('/vendor-overwrites/colorpicker/colorconverter.js'); + }; + + BUILDERS.uso = { + async preprocess(source, vars) { + const colorConverter = require('/js/color/color-converter'); const pool = new Map(); - return Promise.resolve(doReplace(source)); + return doReplace(source); function getValue(name, rgbName) { if (!vars.hasOwnProperty(name)) { @@ -164,14 +127,35 @@ function getUsercssCompiler(preprocessor) { }); } }, - }, - }; - - if (preprocessor) { - if (!BUILDER[preprocessor]) { - throw new Error('unknwon preprocessor'); - } - return BUILDER[preprocessor]; + }; } - return BUILDER.default; -} + + function getVarValue(va, prop) { + if (va.type === 'select' || va.type === 'dropdown' || va.type === 'image') { + // TODO: handle customized image + return va.options.find(o => o.name === va[prop]).value; + } + if ((va.type === 'number' || va.type === 'range') && va.units) { + return va[prop] + va.units; + } + return va[prop]; + } + + function simplifyVars(vars) { + if (!vars) { + return {}; + } + // simplify vars by merging `va.default` to `va.value`, so BUILDER don't + // need to test each va's default value. + return Object.keys(vars).reduce((output, key) => { + const va = vars[key]; + output[key] = Object.assign({}, va, { + value: va.value === null || va.value === undefined ? + getVarValue(va, 'default') : getVarValue(va, 'value'), + }); + return output; + }, {}); + } + + return bgw; +}); diff --git a/background/background.js b/background/background.js index 35690c16..91b472ea 100644 --- a/background/background.js +++ b/background/background.js @@ -1,178 +1,39 @@ -/* global - activateTab - API - chromeLocal - findExistingTab - FIREFOX - getActiveTab - isTabReplaceable - msg - openURL - prefs - semverCompare - URLS - workerUtil -*/ 'use strict'; -//#region API +define(require => { + const {FIREFOX} = require('/js/toolbox'); + const {API, msg} = require('/js/msg'); + const styleManager = require('./style-manager'); + require('./background-api'); -Object.assign(API, { - - /** @type {ApiWorker} */ - worker: workerUtil.createWorker({ - url: '/background/background-worker.js', - }), - - /** @returns {string} */ - getTabUrlPrefix() { - const {url} = this.sender.tab; - if (url.startsWith(URLS.ownOrigin)) { - return 'stylus'; - } - return url.match(/^([\w-]+:\/+[^/#]+)/)[1]; - }, - - /** @returns {Prefs} */ - getPrefs: () => prefs.values, - setPref(key, value) { - prefs.set(key, value); - }, - - /** - * Opens the editor or activates an existing tab - * @param {{ - id?: number - domain?: string - 'url-prefix'?: string - }} params - * @returns {Promise} - */ - openEditor(params) { - const u = new URL(chrome.runtime.getURL('edit.html')); - u.search = new URLSearchParams(params); - return openURL({ - url: `${u}`, - currentWindow: null, - newWindow: prefs.get('openEditInWindow') && Object.assign({}, - prefs.get('openEditInWindow.popup') && {type: 'popup'}, - prefs.get('windowPosition')), - }); - }, - - /** @returns {Promise} */ - async openManage({options = false, search, searchMode} = {}) { - let url = chrome.runtime.getURL('manage.html'); - if (search) { - url += `?search=${encodeURIComponent(search)}&searchMode=${searchMode}`; - } - if (options) { - url += '#stylus-options'; - } - let tab = await findExistingTab({ - url, - currentWindow: null, - ignoreHash: true, - ignoreSearch: true, - }); - if (tab) { - await activateTab(tab); - if (url !== (tab.pendingUrl || tab.url)) { - await msg.sendTab(tab.id, {method: 'pushState', url}).catch(console.error); - } - return tab; - } - tab = await getActiveTab(); - return isTabReplaceable(tab, url) - ? activateTab(tab, {url}) - : browser.tabs.create({url}).then(activateTab); // activateTab unminimizes the window - }, - - /** - * Same as openURL, the only extra prop in `opts` is `message` - it'll be sent - * when the tab is ready, which is needed in the popup, otherwise another - * extension could force the tab to open in foreground thus auto-closing the - * popup (in Chrome at least) and preventing the sendMessage code from running - * @returns {Promise} - */ - async openURL(opts) { - const tab = await openURL(opts); - if (opts.message) { - await onTabReady(tab); - await msg.sendTab(tab.id, opts.message); - } - return tab; - function onTabReady(tab) { - return new Promise((resolve, reject) => - setTimeout(function ping(numTries = 10, delay = 100) { - msg.sendTab(tab.id, {method: 'ping'}) - .catch(() => false) - .then(pong => pong - ? resolve(tab) - : numTries && setTimeout(ping, delay, numTries - 1, delay * 1.5) || - reject('timeout')); - })); - } - }, -}); - -//#endregion -//#region browserCommands - -const browserCommands = { - openManage: () => API.openManage(), - openOptions: () => API.openManage({options: true}), - styleDisableAll(info) { - prefs.set('disableAll', info ? info.checked : !prefs.get('disableAll')); - }, - reload: () => chrome.runtime.reload(), -}; -if (chrome.commands) { - chrome.commands.onCommand.addListener(command => browserCommands[command]()); -} -if (FIREFOX && browser.commands && browser.commands.update) { - // register hotkeys in FF - const hotkeyPrefs = Object.keys(prefs.defaults).filter(k => k.startsWith('hotkey.')); - prefs.subscribe(hotkeyPrefs, (name, value) => { - try { - name = name.split('.')[1]; - if (value.trim()) { - browser.commands.update({name, shortcut: value}); - } else { - browser.commands.reset(name); - } - } catch (e) {} + // These are loaded conditionally. + // Each item uses `require` individually so IDE can jump to the source and track usage. + Promise.all([ + FIREFOX && + require(['./style-via-api']), + FIREFOX && ((browser.commands || {}).update) && + require(['./browser-cmd-hotkeys']), + !FIREFOX && + require(['./content-scripts']), + !FIREFOX && + require(['./style-via-webrequest']), + chrome.contextMenus && + require(['./context-menus']), + styleManager.ready, + ]).then(() => { + msg.isBgReady = true; + msg.broadcast({method: 'backgroundReady'}); }); -} -//#endregion -//#region Init - -msg.on((msg, sender) => { - if (msg.method === 'invokeAPI') { - const fn = msg.path.reduce((res, name) => res && res[name], API); - if (!fn) throw new Error(`Unknown API.${msg.path.join('.')}`); - const res = fn.apply({msg, sender}, msg.args); - return res === undefined ? null : res; + if (chrome.commands) { + chrome.commands.onCommand.addListener(id => API.browserCommands[id]()); } + + chrome.runtime.onInstalled.addListener(({reason, previousVersion}) => { + if (reason !== 'update') return; + const [a, b, c] = (previousVersion || '').split('.'); + if (a <= 1 && b <= 5 && c <= 13) { // 1.5.13 + require(['./remove-unused-storage']); + } + }); }); - -chrome.runtime.onInstalled.addListener(({reason, previousVersion}) => { - if (reason !== 'update') return; - if (semverCompare(previousVersion, '1.5.13') <= 0) { - // Removing unused stuff - // TODO: delete this entire block by the middle of 2021 - try { - localStorage.clear(); - } catch (e) {} - setTimeout(async () => { - const del = Object.keys(await chromeLocal.get()) - .filter(key => key.startsWith('usoSearchCache')); - if (del.length) chromeLocal.remove(del); - }, 15e3); - } -}); - -msg.broadcast({method: 'backgroundReady'}); - -//#endregion diff --git a/background/browser-cmd-hotkeys.js b/background/browser-cmd-hotkeys.js new file mode 100644 index 00000000..96587721 --- /dev/null +++ b/background/browser-cmd-hotkeys.js @@ -0,0 +1,23 @@ +'use strict'; + +/* + Registers hotkeys in FF + */ + +define(require => { + const prefs = require('/js/prefs'); + + const hotkeyPrefs = Object.keys(prefs.defaults).filter(k => k.startsWith('hotkey.')); + prefs.subscribe(hotkeyPrefs, updateHotkey, {runNow: true}); + + async function updateHotkey(name, value) { + try { + name = name.split('.')[1]; + if (value.trim()) { + await browser.commands.update({name, shortcut: value}); + } else { + await browser.commands.reset(name); + } + } catch (e) {} + } +}); diff --git a/background/content-scripts.js b/background/content-scripts.js index 08b7d144..ad05c03c 100644 --- a/background/content-scripts.js +++ b/background/content-scripts.js @@ -1,25 +1,24 @@ -/* global - FIREFOX - ignoreChromeError - msg - URLS -*/ 'use strict'; /* Reinject content scripts when the extension is reloaded/updated. - Firefox handles this automatically. + Not used in Firefox as it reinjects automatically. */ -// eslint-disable-next-line no-unused-expressions -!FIREFOX && (() => { +define(require => { + const { + URLS, + ignoreChromeError, + } = require('/js/toolbox'); + const {msg} = require('/js/msg'); + const NTP = 'chrome://newtab/'; const ALL_URLS = ''; const SCRIPTS = chrome.runtime.getManifest().content_scripts; // expand * as .*? const wildcardAsRegExp = (s, flags) => new RegExp( - s.replace(/[{}()[\]/\\.+?^$:=!|]/g, '\\$&') - .replace(/\*/g, '.*?'), flags); + s.replace(/[{}()[\]/\\.+?^$:=!|]/g, '\\$&') + .replace(/\*/g, '.*?'), flags); for (const cs of SCRIPTS) { cs.matches = cs.matches.map(m => ( m === ALL_URLS ? m : wildcardAsRegExp(m) @@ -118,4 +117,4 @@ function onBusyTabRemoved(tabId) { trackBusyTab(tabId, false); } -})(); +}); diff --git a/background/context-menus.js b/background/context-menus.js index b5f66d29..0fe70061 100644 --- a/background/context-menus.js +++ b/background/context-menus.js @@ -1,16 +1,15 @@ -/* global - browserCommands - CHROME - FIREFOX - ignoreChromeError - msg - prefs - URLS -*/ 'use strict'; -// eslint-disable-next-line no-unused-expressions -chrome.contextMenus && (() => { +define(require => { + const { + CHROME, + FIREFOX, + URLS, + ignoreChromeError, + } = require('/js/toolbox'); + const {API, msg} = require('/js/msg'); + const prefs = require('/js/prefs'); + const contextMenus = { 'show-badge': { title: 'menuShowBadge', @@ -18,20 +17,20 @@ chrome.contextMenus && (() => { }, 'disableAll': { title: 'disableAllStyles', - click: browserCommands.styleDisableAll, + click: API.browserCommands.styleDisableAll, }, 'open-manager': { title: 'openStylesManager', - click: browserCommands.openManage, + click: API.browserCommands.openManage, }, 'open-options': { title: 'openOptions', - click: browserCommands.openOptions, + click: API.browserCommands.openOptions, }, 'reload': { presentIf: async () => (await browser.management.getSelf()).installType === 'development', title: 'reload', - click: browserCommands.reload, + click: API.browserCommands.reload, }, 'editor.contextDelete': { presentIf: () => !FIREFOX && prefs.get('editor.contextDelete'), @@ -104,4 +103,4 @@ chrome.contextMenus && (() => { chrome.contextMenus.remove(id, ignoreChromeError); } } -})(); +}); diff --git a/background/db-chrome-storage.js b/background/db-chrome-storage.js index 6327a54c..8cda8eb7 100644 --- a/background/db-chrome-storage.js +++ b/background/db-chrome-storage.js @@ -1,56 +1,59 @@ -/* global chromeLocal */ -/* exported createChromeStorageDB */ 'use strict'; -function createChromeStorageDB() { - let INC; +define(require => { + const {chromeLocal} = require('/js/storage-util'); + let INC; const PREFIX = 'style-'; const METHODS = { + + delete: id => chromeLocal.remove(PREFIX + id), + // FIXME: we don't use this method at all. Should we remove this? get: id => chromeLocal.getValue(PREFIX + id), - put: obj => - // FIXME: should we clone the object? - Promise.resolve(!obj.id && prepareInc().then(() => Object.assign(obj, {id: INC++}))) - .then(() => chromeLocal.setValue(PREFIX + obj.id, obj)) - .then(() => obj.id), - putMany: items => prepareInc() - .then(() => - chromeLocal.set(items.reduce((data, item) => { - if (!item.id) item.id = INC++; - data[PREFIX + item.id] = item; - return data; - }, {}))) - .then(() => items.map(i => i.id)), - delete: id => chromeLocal.remove(PREFIX + id), - getAll: () => chromeLocal.get() - .then(result => { - const output = []; - for (const key in result) { - if (key.startsWith(PREFIX) && Number(key.slice(PREFIX.length))) { - output.push(result[key]); - } + + async getAll() { + return Object.entries(await chromeLocal.get()) + .map(([key, val]) => key.startsWith(PREFIX) && Number(key.slice(PREFIX.length)) && val) + .filter(Boolean); + }, + + async put(item) { + if (!item.id) { + if (!INC) await prepareInc(); + item.id = INC++; + } + await chromeLocal.setValue(PREFIX + item.id, item); + return item.id; + }, + + async putMany(items) { + const data = {}; + for (const item of items) { + if (!item.id) { + if (!INC) await prepareInc(); + item.id = INC++; } - return output; - }), + data[PREFIX + item.id] = item; + } + await chromeLocal.set(data); + return items.map(_ => _.id); + }, }; - return { - exec: (method, ...args) => METHODS[method](...args), - }; - - function prepareInc() { - if (INC) return Promise.resolve(); - return chromeLocal.get().then(result => { - INC = 1; - for (const key in result) { - if (key.startsWith(PREFIX)) { - const id = Number(key.slice(PREFIX.length)); - if (id >= INC) { - INC = id + 1; - } + async function prepareInc() { + INC = 1; + for (const key in await chromeLocal.get()) { + if (key.startsWith(PREFIX)) { + const id = Number(key.slice(PREFIX.length)); + if (id >= INC) { + INC = id + 1; } } - }); + } } -} + + return function dbExecChromeStorage(method, ...args) { + return METHODS[method](...args); + }; +}); diff --git a/background/db.js b/background/db.js index 3b6f8bc3..ec96c928 100644 --- a/background/db.js +++ b/background/db.js @@ -1,25 +1,25 @@ -/* global chromeLocal workerUtil createChromeStorageDB */ -/* exported db */ /* -Initialize a database. There are some problems using IndexedDB in Firefox: -https://www.reddit.com/r/firefox/comments/74wttb/note_to_firefox_webextension_developers_who_use/ - -Some of them are fixed in FF59: -https://www.reddit.com/r/firefox/comments/7ijuaq/firefox_59_webextensions_can_use_indexeddb_when/ + Initialize a database. There are some problems using IndexedDB in Firefox: + https://www.reddit.com/r/firefox/comments/74wttb/note_to_firefox_webextension_developers_who_use/ + Some of them are fixed in FF59: + https://www.reddit.com/r/firefox/comments/7ijuaq/firefox_59_webextensions_can_use_indexeddb_when/ */ 'use strict'; -const db = (() => { +define(require => { + const {chromeLocal} = require('/js/storage-util'); + const {cloneError} = require('/js/worker-util'); + const DATABASE = 'stylish'; const STORE = 'styles'; const FALLBACK = 'dbInChromeStorage'; - const dbApi = { + const execFn = tryUsingIndexedDB().catch(useChromeStorage); + + const exports = { async exec(...args) { - dbApi.exec = await tryUsingIndexedDB().catch(useChromeStorage); - return dbApi.exec(...args); + return (await execFn)(...args); }, }; - return dbApi; async function tryUsingIndexedDB() { // we use chrome.storage.local fallback if IndexedDB doesn't save data, @@ -44,13 +44,13 @@ const db = (() => { await dbExecIndexedDB('delete', e.id); // throws if `e` or id is null } - function useChromeStorage(err) { + async function useChromeStorage(err) { chromeLocal.setValue(FALLBACK, true); if (err) { - chromeLocal.setValue(FALLBACK + 'Reason', workerUtil.cloneError(err)); + chromeLocal.setValue(FALLBACK + 'Reason', cloneError(err)); console.warn('Failed to access indexedDB. Switched to storage API.', err); } - return createChromeStorageDB().exec; + return require(['./db-chrome-storage']); } async function dbExecIndexedDB(method, ...args) { @@ -90,4 +90,6 @@ const db = (() => { }); } } -})(); + + return exports; +}); diff --git a/background/icon-manager.js b/background/icon-manager.js index e2781fde..a60fb209 100644 --- a/background/icon-manager.js +++ b/background/icon-manager.js @@ -1,11 +1,44 @@ -/* global prefs debounce iconUtil FIREFOX CHROME VIVALDI tabManager navigatorUtil API */ -/* exported iconManager */ 'use strict'; -const iconManager = (() => { +define(require => { + const { + FIREFOX, + VIVALDI, + CHROME, + debounce, + } = require('/js/toolbox'); + const prefs = require('/js/prefs'); + const { + setBadgeBackgroundColor, + setBadgeText, + setIcon, + } = require('./icon-util'); + const tabManager = require('./tab-manager'); + const ICON_SIZES = FIREFOX || CHROME >= 55 && !VIVALDI ? [16, 32] : [19, 38]; const staleBadges = new Set(); + let exports; + const { + + updateIconBadge, + + } = exports = /** @namespace API */ { + /** + * @param {(number|string)[]} styleIds + * @param {boolean} [lazyBadge=false] preventing flicker during page load + */ + updateIconBadge(styleIds, {lazyBadge} = {}) { + // FIXME: in some cases, we only have to redraw the badge. is it worth a optimization? + const {frameId, tab: {id: tabId}} = this.sender; + const value = styleIds.length ? styleIds.map(Number) : undefined; + tabManager.set(tabId, 'styleIds', frameId, value); + debounce(refreshStaleBadges, frameId && lazyBadge ? 250 : 0); + staleBadges.add(tabId); + if (!frameId) refreshIcon(tabId, true); + }, + }; + prefs.subscribe([ 'disableAll', 'badgeDisabled', @@ -27,21 +60,7 @@ const iconManager = (() => { refreshAllIcons(); }); - Object.assign(API, { - /** @param {(number|string)[]} styleIds - * @param {boolean} [lazyBadge=false] preventing flicker during page load */ - updateIconBadge(styleIds, {lazyBadge} = {}) { - // FIXME: in some cases, we only have to redraw the badge. is it worth a optimization? - const {frameId, tab: {id: tabId}} = this.sender; - const value = styleIds.length ? styleIds.map(Number) : undefined; - tabManager.set(tabId, 'styleIds', frameId, value); - debounce(refreshStaleBadges, frameId && lazyBadge ? 250 : 0); - staleBadges.add(tabId); - if (!frameId) refreshIcon(tabId, true); - }, - }); - - navigatorUtil.onCommitted(({tabId, frameId}) => { + chrome.webNavigation.onCommitted.addListener(({tabId, frameId}) => { if (!frameId) tabManager.set(tabId, 'styleIds', undefined); }); @@ -53,13 +72,13 @@ const iconManager = (() => { function onPortDisconnected({sender}) { if (tabManager.get(sender.tab.id, 'styleIds')) { - API.updateIconBadge.call({sender}, [], {lazyBadge: true}); + updateIconBadge.call({sender}, [], {lazyBadge: true}); } } function refreshIconBadgeText(tabId) { const text = prefs.get('show-badge') ? `${getStyleCount(tabId)}` : ''; - iconUtil.setBadgeText({tabId, text}); + setBadgeText({tabId, text}); } function getIconName(hasStyles = false) { @@ -77,7 +96,7 @@ const iconManager = (() => { return; } tabManager.set(tabId, 'icon', newIcon); - iconUtil.setIcon({ + setIcon({ path: getIconPath(newIcon), tabId, }); @@ -102,14 +121,14 @@ const iconManager = (() => { } function refreshGlobalIcon() { - iconUtil.setIcon({ + setIcon({ path: getIconPath(getIconName()), }); } function refreshIconBadgeColor() { const color = prefs.get(prefs.get('disableAll') ? 'badgeDisabled' : 'badgeNormal'); - iconUtil.setBadgeBackgroundColor({ + setBadgeBackgroundColor({ color, }); } @@ -133,4 +152,6 @@ const iconManager = (() => { } staleBadges.clear(); } -})(); + + return exports; +}); diff --git a/background/icon-util.js b/background/icon-util.js index 4bfffe31..90d07b78 100644 --- a/background/icon-util.js +++ b/background/icon-util.js @@ -1,91 +1,73 @@ -/* global ignoreChromeError */ -/* exported iconUtil */ 'use strict'; -const iconUtil = (() => { - const canvas = document.createElement('canvas'); - const ctx = canvas.getContext('2d'); - // https://github.com/openstyles/stylus/issues/335 - let noCanvas; +define(require => { + const {ignoreChromeError} = require('/js/toolbox'); + const imageDataCache = new Map(); - // test if canvas is usable - const canvasReady = loadImage('/images/icon/16.png') - .then(imageData => { - noCanvas = imageData.data.every(b => b === 255); - }); + // https://github.com/openstyles/stylus/issues/335 + const hasCanvas = loadImage('/images/icon/16.png') + .then(({data}) => data.some(b => b !== 255)); - return extendNative({ - /* - Cache imageData for paths - */ - setIcon, - setBadgeText, - }); + const exports = { - function loadImage(url) { - let result = imageDataCache.get(url); - if (!result) { - result = new Promise((resolve, reject) => { - const img = new Image(); - img.src = url; - img.onload = () => { - const w = canvas.width = img.width; - const h = canvas.height = img.height; - ctx.clearRect(0, 0, w, h); - ctx.drawImage(img, 0, 0, w, h); - resolve(ctx.getImageData(0, 0, w, h)); - }; - img.onerror = reject; - }); - imageDataCache.set(url, result); - } + /** @param {chrome.browserAction.TabIconDetails} data */ + async setIcon(data) { + if (await hasCanvas) { + data.imageData = {}; + for (const [key, url] of Object.entries(data.path)) { + data.imageData[key] = imageDataCache.get(url) || await loadImage(url); + } + delete data.path; + } + safeCall('setIcon', data); + }, + + /** @param {chrome.browserAction.BadgeTextDetails} data */ + setBadgeText(data) { + safeCall('setBadgeText', data); + }, + + /** @param {chrome.browserAction.BadgeBackgroundColorDetails} data */ + setBadgeBackgroundColor(data) { + safeCall('setBadgeBackgroundColor', data); + }, + }; + + // Caches imageData for icon paths + async function loadImage(url) { + const {OffscreenCanvas} = self.createImageBitmap && self || {}; + const img = OffscreenCanvas + ? await createImageBitmap(await (await fetch(url)).blob()) + : await new Promise((resolve, reject) => + Object.assign(new Image(), { + src: url, + onload: e => resolve(e.target), + onerror: reject, + })); + const {width: w, height: h} = img; + const canvas = OffscreenCanvas + ? new OffscreenCanvas(w, h) + : Object.assign(document.createElement('canvas'), {width: w, height: h}); + const ctx = canvas.getContext('2d'); + ctx.drawImage(img, 0, 0, w, h); + const result = ctx.getImageData(0, 0, w, h); + imageDataCache.set(url, result); return result; } - function setIcon(data) { - canvasReady.then(() => { - if (noCanvas) { - chrome.browserAction.setIcon(data, ignoreChromeError); - return; + function safeCall(method, data) { + const {browserAction = {}} = chrome; + const fn = browserAction[method]; + if (fn) { + try { + // Chrome supports the callback since 67.0.3381.0, see https://crbug.com/451320 + fn.call(browserAction, data, ignoreChromeError); + } catch (e) { + // FIXME: skip pre-rendered tabs? + fn.call(browserAction, data); } - const pending = []; - data.imageData = {}; - for (const [key, url] of Object.entries(data.path)) { - pending.push(loadImage(url) - .then(imageData => { - data.imageData[key] = imageData; - })); - } - Promise.all(pending).then(() => { - delete data.path; - chrome.browserAction.setIcon(data, ignoreChromeError); - }); - }); - } - - function setBadgeText(data) { - try { - // Chrome supports the callback since 67.0.3381.0, see https://crbug.com/451320 - chrome.browserAction.setBadgeText(data, ignoreChromeError); - } catch (e) { - // FIXME: skip pre-rendered tabs? - chrome.browserAction.setBadgeText(data); } } - function extendNative(target) { - return new Proxy(target, { - get: (target, prop) => { - // FIXME: do we really need this? - if (!chrome.browserAction || - !['setIcon', 'setBadgeBackgroundColor', 'setBadgeText'].every(name => chrome.browserAction[name])) { - return () => {}; - } - if (target[prop]) { - return target[prop]; - } - return chrome.browserAction[prop].bind(chrome.browserAction); - }, - }); - } -})(); + return exports; +}); diff --git a/background/navigator-util.js b/background/navigator-util.js index bdcdbedb..b5bfe8b6 100644 --- a/background/navigator-util.js +++ b/background/navigator-util.js @@ -1,31 +1,27 @@ -/* global - CHROME - FIREFOX - ignoreChromeError - msg - URLS -*/ 'use strict'; -(() => { +define(require => { + const { + CHROME, + FIREFOX, + URLS, + ignoreChromeError, + } = require('/js/toolbox'); + const {msg} = require('/js/msg'); + /** @type {Set} */ const listeners = new Set(); - /** @type {NavigatorUtil} */ - const navigatorUtil = window.navigatorUtil = new Proxy({ + + const exports = { onUrlChange(fn) { listeners.add(fn); }, - }, { - get(target, prop) { - return target[prop] || - (target = chrome.webNavigation[prop]).addListener.bind(target); - }, - }); + }; - navigatorUtil.onCommitted(onNavigation.bind('committed')); - navigatorUtil.onHistoryStateUpdated(onFakeNavigation.bind('history')); - navigatorUtil.onReferenceFragmentUpdated(onFakeNavigation.bind('hash')); - navigatorUtil.onCommitted(runGreasyforkContentScript, { + chrome.webNavigation.onCommitted.addListener(onNavigation.bind('committed')); + chrome.webNavigation.onHistoryStateUpdated.addListener(onFakeNavigation.bind('history')); + chrome.webNavigation.onReferenceFragmentUpdated.addListener(onFakeNavigation.bind('hash')); + chrome.webNavigation.onCommitted.addListener(runGreasyforkContentScript, { // expose style version on greasyfork/sleazyfork 1) info page and 2) code page url: ['greasyfork', 'sleazyfork'].map(host => ({ hostEquals: host + '.org', @@ -33,7 +29,7 @@ })), }); if (FIREFOX) { - navigatorUtil.onDOMContentLoaded(runMainContentScripts, { + chrome.webNavigation.onDOMContentLoaded.addListener(runMainContentScripts, { url: [{ urlEquals: 'about:blank', }], @@ -84,20 +80,6 @@ runAt: 'document_start', }); } -})(); -/** - * @typedef NavigatorUtil - * @property {NavigatorUtilEvent} onBeforeNavigate - * @property {NavigatorUtilEvent} onCommitted - * @property {NavigatorUtilEvent} onCompleted - * @property {NavigatorUtilEvent} onCreatedNavigationTarget - * @property {NavigatorUtilEvent} onDOMContentLoaded - * @property {NavigatorUtilEvent} onErrorOccurred - * @property {NavigatorUtilEvent} onHistoryStateUpdated - * @property {NavigatorUtilEvent} onReferenceFragmentUpdated - * @property {NavigatorUtilEvent} onTabReplaced -*/ -/** - * @typedef {function(cb: function, filters: WebNavigationEventFilter?)} NavigatorUtilEvent - */ + return exports; +}); diff --git a/background/openusercss-api.js b/background/openusercss-api.js index 73a3ec3c..c947f91d 100644 --- a/background/openusercss-api.js +++ b/background/openusercss-api.js @@ -1,7 +1,6 @@ -/* global API */ 'use strict'; -(() => { +define(require => { // begin:nanographql - Tiny graphQL client library // Author: yoshuawuyts (https://github.com/yoshuawuyts) // License: MIT @@ -37,11 +36,10 @@ body: query({ id, }), - }) - .then(res => res.json()); + }).then(res => res.json()); }; - API.openusercss = { + const exports = /** @namespace API */ { /** * This function can be used to retrieve a theme object from the * GraphQL API, set above @@ -100,4 +98,6 @@ } `), }; -})(); + + return exports; +}); diff --git a/background/remove-unused-storage.js b/background/remove-unused-storage.js new file mode 100644 index 00000000..7349e766 --- /dev/null +++ b/background/remove-unused-storage.js @@ -0,0 +1,25 @@ +'use strict'; + +// Removing unused stuff from storage on extension update +// TODO: delete this by the middle of 2021 + +define(require => { + const {chromeLocal} = require('/js/storage-util'); + + function cleanLocalStorage() { + try { + localStorage.clear(); + } catch (e) {} + } + + async function cleanChromeLocal() { + const del = Object.keys(await chromeLocal.get()) + .filter(key => key.startsWith('usoSearchCache')); + if (del.length) chromeLocal.remove(del); + } + + return () => { + cleanLocalStorage(); + setTimeout(cleanChromeLocal, 15e3); + }; +}); diff --git a/background/search-db.js b/background/search-db.js index b23679ed..d774297d 100644 --- a/background/search-db.js +++ b/background/search-db.js @@ -1,74 +1,69 @@ -/* global - API - debounce - stringAsRegExp - tryRegExp - usercss -*/ 'use strict'; -(() => { +define(require => { + const { + debounce, + stringAsRegExp, + tryRegExp, + } = require('/js/toolbox'); + const {API} = require('/js/msg'); + // toLocaleLowerCase cache, autocleared after 1 minute const cache = new Map(); const METAKEYS = ['customName', 'name', 'url', 'installationUrl', 'updateUrl']; + const MODES = createModes(); - const extractMeta = style => - style.usercssData - ? (style.sourceCode.match(usercss.RX_META) || [''])[0] - : null; + const exports = /** @namespace API */ { + /** + * @param params + * @param {string} params.query - 1. url:someurl 2. text (may contain quoted parts like "qUot Ed") + * @param {'name'|'meta'|'code'|'all'|'url'} [params.mode=all] + * @param {number[]} [params.ids] - if not specified, all styles are searched + * @returns {number[]} - array of matched styles ids + */ + async searchDB({query, mode = 'all', ids}) { + let res = []; + if (mode === 'url' && query) { + res = (await API.styles.getByUrl(query)).map(r => r.style.id); + } else if (mode in MODES) { + const modeHandler = MODES[mode]; + const m = /^\/(.+?)\/([gimsuy]*)$/.exec(query); + const rx = m && tryRegExp(m[1], m[2]); + const test = rx ? rx.test.bind(rx) : createTester(query); + res = (await API.styles.getAll()) + .filter(style => + (!ids || ids.includes(style.id)) && + (!query || modeHandler(style, test))) + .map(style => style.id); + if (cache.size) debounce(clearCache, 60e3); + } + return res; + }, + }; - const stripMeta = style => - style.usercssData - ? style.sourceCode.replace(usercss.RX_META, '') - : null; + function createModes() { + return Object.assign(Object.create(null), { + code: (style, test) => + style.usercssData + ? test(stripMeta(style)) + : searchSections(style, test, 'code'), - const MODES = Object.assign(Object.create(null), { - code: (style, test) => - style.usercssData - ? test(stripMeta(style)) - : searchSections(style, test, 'code'), - - meta: (style, test, part) => - METAKEYS.some(key => test(style[key])) || + meta: (style, test, part) => + METAKEYS.some(key => test(style[key])) || test(part === 'all' ? style.sourceCode : extractMeta(style)) || searchSections(style, test, 'funcs'), - name: (style, test) => - test(style.customName) || - test(style.name), + name: (style, test) => + test(style.customName) || + test(style.name), - all: (style, test) => - MODES.meta(style, test, 'all') || - !style.usercssData && MODES.code(style, test), - }); + all: (style, test) => + MODES.meta(style, test, 'all') || + !style.usercssData && MODES.code(style, test), + }); + } - /** - * @param params - * @param {string} params.query - 1. url:someurl 2. text (may contain quoted parts like "qUot Ed") - * @param {'name'|'meta'|'code'|'all'|'url'} [params.mode=all] - * @param {number[]} [params.ids] - if not specified, all styles are searched - * @returns {number[]} - array of matched styles ids - */ - API.searchDB = async ({query, mode = 'all', ids}) => { - let res = []; - if (mode === 'url' && query) { - res = (await API.styles.getByUrl(query)).map(r => r.style.id); - } else if (mode in MODES) { - const modeHandler = MODES[mode]; - const m = /^\/(.+?)\/([gimsuy]*)$/.exec(query); - const rx = m && tryRegExp(m[1], m[2]); - const test = rx ? rx.test.bind(rx) : makeTester(query); - res = (await API.styles.getAll()) - .filter(style => - (!ids || ids.includes(style.id)) && - (!query || modeHandler(style, test))) - .map(style => style.id); - if (cache.size) debounce(clearCache, 60e3); - } - return res; - }; - - function makeTester(query) { + function createTester(query) { const flags = `u${lower(query) === query ? 'i' : ''}`; const words = query .split(/(".*?")|\s+/) @@ -105,4 +100,18 @@ function clearCache() { cache.clear(); } -})(); + + function extractMeta(style) { + return style.usercssData + ? (style.sourceCode.match(API.usercss.rxMETA) || [''])[0] + : null; + } + + function stripMeta(style) { + return style.usercssData + ? style.sourceCode.replace(API.usercss.rxMETA, '') + : null; + } + + return exports; +}); diff --git a/background/style-manager.js b/background/style-manager.js index c9497288..70239fef 100644 --- a/background/style-manager.js +++ b/background/style-manager.js @@ -1,17 +1,3 @@ -/* global - API - calcStyleDigest - createCache - db - msg - prefs - stringAsRegExp - styleCodeEmpty - styleSectionGlobal - tabManager - tryRegExp - URLS -*/ 'use strict'; /* @@ -23,10 +9,25 @@ The live preview feature relies on `runtime.connect` and `port.onDisconnect` to cleanup the temporary code. See /edit/live-preview.js. */ -/* exported styleManager */ -const styleManager = API.styles = (() => { +define(require => { + const { + stringAsRegExp, + tryRegExp, + URLS, + } = require('/js/toolbox'); + const {API, msg} = require('/js/msg'); + const { + calcStyleDigest, + styleCodeEmpty, + styleSectionGlobal, + } = require('/js/sections-util'); + const createCache = require('/js/cache'); + const prefs = require('/js/prefs'); + const db = require('./db'); + const tabManager = require('./tab-manager'); //#region Declarations + const ready = init(); /** * @typedef StyleMapData @@ -40,7 +41,7 @@ const styleManager = API.styles = (() => { /** @typedef {Object} StyleSectionsToApply */ /** @type {Map, sections: StyleSectionsToApply}>} */ const cachedStyleForUrl = createCache({ - onDeleted: (url, cache) => { + onDeleted(url, cache) { for (const section of Object.values(cache.sections)) { const data = id2data(section.id); if (data) data.appliesTo.delete(url); @@ -51,36 +52,30 @@ const styleManager = API.styles = (() => { const compileRe = createCompiler(text => `^(${text})$`); const compileSloppyRe = createCompiler(text => `^${text}$`); const compileExclusion = createCompiler(buildExclusion); - const DUMMY_URL = { - hash: '', - host: '', - hostname: '', - href: '', - origin: '', - password: '', - pathname: '', - port: '', - protocol: '', - search: '', - searchParams: new URLSearchParams(), - username: '', - }; const MISSING_PROPS = { name: style => `ID: ${style.id}`, _id: () => uuidv4(), _rev: () => Date.now(), }; const DELETE_IF_NULL = ['id', 'customName']; - //#endregion chrome.runtime.onConnect.addListener(handleLivePreview); - //#region Public surface + //#endregion + //#region Exports - // Sorted alphabetically - return { + /** @type {StyleManager} */ + const styleManager = /** @namespace StyleManager */ { - compareRevision, + /* props first, + then method shorthands if any, + then inlined methods sorted alphabetically */ + + ready, + + compareRevision(rev1, rev2) { // TODO: move somewhere else so it doesn't pollute API + return rev1 - rev2; + }, /** @returns {Promise} style id */ async delete(id, reason) { @@ -108,9 +103,9 @@ const styleManager = API.styles = (() => { await ready; const id = uuidIndex.get(_id); const oldDoc = id && id2style(id); - if (oldDoc && compareRevision(oldDoc._rev, rev) <= 0) { + if (oldDoc && styleManager.compareRevision(oldDoc._rev, rev) <= 0) { // FIXME: does it make sense to set reason to 'sync' in deleteByUUID? - return API.styles.delete(id, 'sync'); + return styleManager.delete(id, 'sync'); } }, @@ -151,7 +146,7 @@ const styleManager = API.styles = (() => { await ready; /* Chrome hides text frament from location.href of the page e.g. #:~:text=foo so we'll use the real URL reported by webNavigation API */ - const {tab, frameId} = this.sender; + const {tab, frameId} = this && this.sender || {}; url = tab && tabManager.get(tab.id, 'url', frameId) || url; let cache = cachedStyleForUrl.get(url); if (!cache) { @@ -215,7 +210,7 @@ const styleManager = API.styles = (() => { } } if (sectionMatched) { - result.push(/** @namespace StylesByUrlResult */{style, excluded, sloppy}); + result.push(/** @namespace StylesByUrlResult */ {style, excluded, sloppy}); } } return result; @@ -265,7 +260,7 @@ const styleManager = API.styles = (() => { const oldDoc = id && id2style(id); let diff = -1; if (oldDoc) { - diff = compareRevision(oldDoc._rev, doc._rev); + diff = styleManager.compareRevision(oldDoc._rev, doc._rev); if (diff > 0) { API.sync.put(oldDoc._id, oldDoc._rev); return; @@ -297,8 +292,8 @@ const styleManager = API.styles = (() => { /** @returns {Promise} */ removeInclusion: removeIncludeExclude.bind(null, 'inclusions'), }; - //#endregion + //#endregion //#region Implementation /** @returns {StyleMapData} */ @@ -318,7 +313,7 @@ const styleManager = API.styles = (() => { /** @returns {StyleObj} */ function createNewStyle() { - return /** @namespace StyleObj */{ + return /** @namespace StyleObj */ { enabled: true, updateUrl: null, md5Url: null, @@ -366,10 +361,6 @@ const styleManager = API.styles = (() => { }); } - function compareRevision(rev1, rev2) { - return rev1 - rev2; - } - async function addIncludeExclude(type, id, rule) { await ready; const style = Object.assign({}, id2style(id)); @@ -661,7 +652,20 @@ const styleManager = API.styles = (() => { try { return new URL(url); } catch (err) { - return DUMMY_URL; + return { + hash: '', + host: '', + hostname: '', + href: '', + origin: '', + password: '', + pathname: '', + port: '', + protocol: '', + search: '', + searchParams: new URLSearchParams(), + username: '', + }; } } @@ -677,5 +681,8 @@ const styleManager = API.styles = (() => { function hex4dashed(num, i) { return (num + 0x10000).toString(16).slice(-4) + (i >= 1 && i <= 4 ? '-' : ''); } + //#endregion -})(); + + return styleManager; +}); diff --git a/background/style-via-api.js b/background/style-via-api.js index 7da780ae..4116abbc 100644 --- a/background/style-via-api.js +++ b/background/style-via-api.js @@ -1,7 +1,10 @@ -/* global API CHROME prefs */ 'use strict'; -API.styleViaAPI = !CHROME && (() => { +define(require => { + const {isEmptyObj} = require('/js/polyfill'); + const {API} = require('/js/msg'); + const prefs = require('/js/prefs'); + const ACTIONS = { styleApply, styleDeleted, @@ -11,24 +14,27 @@ API.styleViaAPI = !CHROME && (() => { prefChanged, updateCount, }; - const NOP = Promise.resolve(new Error('NOP')); + const NOP = new Error('NOP'); const onError = () => {}; - /* : Object : Object url: String, non-enumerable : Array of strings section code */ const cache = new Map(); - let observingTabs = false; - return function (request) { - const action = ACTIONS[request.method]; - return !action ? NOP : - action(request, this.sender) - .catch(onError) - .then(maybeToggleObserver); + const exports = /** @namespace API */ { + /** + * Uses chrome.tabs.insertCSS + */ + async styleViaAPI(request) { + try { + const fn = ACTIONS[request.method]; + return fn ? fn(request, this.sender) : NOP; + } catch (e) {} + maybeToggleObserver(); + }, }; function updateCount(request, sender) { @@ -125,7 +131,7 @@ API.styleViaAPI = !CHROME && (() => { } const {tab, frameId} = sender; const {tabFrames, frameStyles} = getCachedData(tab.id, frameId); - if (isEmpty(frameStyles)) { + if (isEmptyObj(frameStyles)) { return NOP; } removeFrameIfEmpty(tab.id, frameId, tabFrames, {}); @@ -162,7 +168,7 @@ API.styleViaAPI = !CHROME && (() => { const tabFrames = cache.get(tabId); if (tabFrames && frameId in tabFrames) { delete tabFrames[frameId]; - if (isEmpty(tabFrames)) { + if (isEmptyObj(tabFrames)) { onTabRemoved(tabId); } } @@ -178,9 +184,9 @@ API.styleViaAPI = !CHROME && (() => { } function removeFrameIfEmpty(tabId, frameId, tabFrames, frameStyles) { - if (isEmpty(frameStyles)) { + if (isEmptyObj(frameStyles)) { delete tabFrames[frameId]; - if (isEmpty(tabFrames)) { + if (isEmptyObj(tabFrames)) { cache.delete(tabId); } return true; @@ -224,10 +230,5 @@ API.styleViaAPI = !CHROME && (() => { .catch(onError); } - function isEmpty(obj) { - for (const k in obj) { - return false; - } - return true; - } -})(); + return exports; +}); diff --git a/background/style-via-webrequest.js b/background/style-via-webrequest.js index 45e12d65..00e2e89b 100644 --- a/background/style-via-webrequest.js +++ b/background/style-via-webrequest.js @@ -1,12 +1,10 @@ -/* global - API - CHROME - prefs -*/ 'use strict'; -// eslint-disable-next-line no-unused-expressions -CHROME && (async () => { +define(async require => { + const {API} = require('/js/msg'); + const {isEmptyObj} = require('/js/polyfill'); + const prefs = require('/js/prefs'); + const idCSP = 'patchCsp'; const idOFF = 'disableAll'; const idXHR = 'styleViaXhr'; @@ -16,7 +14,7 @@ CHROME && (async () => { const enabled = {}; await prefs.initializing; - prefs.subscribe([idXHR, idOFF, idCSP], toggle, {now: true}); + prefs.subscribe([idXHR, idOFF, idCSP], toggle, {runNow: true}); function toggle() { const csp = prefs.get(idCSP) && !prefs.get(idOFF); @@ -73,14 +71,17 @@ CHROME && (async () => { /** @param {chrome.webRequest.WebRequestBodyDetails} req */ async function prepareStyles(req) { const sections = await API.styles.getSectionsByUrl(req.url); - if (Object.keys(sections).length) { - stylesToPass[req.requestId] = !enabled.xhr ? true : - URL.createObjectURL(new Blob([JSON.stringify(sections)])) - .slice(blobUrlPrefix.length); + if (!isEmptyObj(sections)) { + stylesToPass[req.requestId] = !enabled.xhr || makeObjectUrl(sections); setTimeout(cleanUp, 600e3, req.requestId); } } + function makeObjectUrl(sections) { + const blob = new Blob([JSON.stringify(sections)]); + return URL.createObjectURL(blob).slice(blobUrlPrefix.length); + } + /** @param {chrome.webRequest.WebResponseHeadersDetails} req */ function modifyHeaders(req) { const {responseHeaders} = req; @@ -115,7 +116,7 @@ CHROME && (async () => { patchCspSrc(src, 'img-src', 'data:', '*'); patchCspSrc(src, 'font-src', 'data:', '*'); // Allow our DOM styles - patchCspSrc(src, 'style-src', '\'unsafe-inline\''); + patchCspSrc(src, 'style-src', "'unsafe-inline'"); // Allow our XHR cookies in CSP sandbox (known case: raw github urls) if (src.sandbox && !src.sandbox.includes('allow-same-origin')) { src.sandbox.push('allow-same-origin'); @@ -141,4 +142,4 @@ CHROME && (async () => { delete stylesToPass[key]; if (blobId) URL.revokeObjectURL(blobUrlPrefix + blobId); } -})(); +}); diff --git a/background/sync.js b/background/sync.js index be478545..64a0a528 100644 --- a/background/sync.js +++ b/background/sync.js @@ -1,24 +1,29 @@ -/* global - API - chromeLocal - dbToCloud - msg - prefs - styleManager - tokenManager -*/ -/* exported sync */ - 'use strict'; -const sync = API.sync = (() => { +define(require => { + const {API, msg} = require('/js/msg'); + const {chromeLocal} = require('/js/storage-util'); + const prefs = require('/js/prefs'); + const {compareRevision} = require('./style-manager'); + const tokenManager = require('./token-manager'); + + /** @type Sync */ + let sync; + + //#region Init + const SYNC_DELAY = 1; // minutes const SYNC_INTERVAL = 30; // minutes - - /** @typedef API.sync.Status */ - const status = { - /** @type {'connected'|'connecting'|'disconnected'|'disconnecting'} */ - state: 'disconnected', + const STATES = Object.freeze({ + connected: 'connected', + connecting: 'connecting', + disconnected: 'disconnected', + disconnecting: 'disconnecting', + }); + const STORAGE_KEY = 'sync/state/'; + const status = /** @namespace Sync.Status */ { + STATES, + state: STATES.disconnected, syncing: false, progress: null, currentDriveName: null, @@ -26,51 +31,14 @@ const sync = API.sync = (() => { login: false, }; let currentDrive; - const ctrl = dbToCloud.dbToCloud({ - onGet(id) { - return API.styles.getByUUID(id); - }, - onPut(doc) { - return API.styles.putByUUID(doc); - }, - onDelete(id, rev) { - return API.styles.deleteByUUID(id, rev); - }, - async onFirstSync() { - for (const i of await API.styles.getAll()) { - ctrl.put(i._id, i._rev); - } - }, - onProgress(e) { - if (e.phase === 'start') { - status.syncing = true; - } else if (e.phase === 'end') { - status.syncing = false; - status.progress = null; - } else { - status.progress = e; - } - emitStatusChange(); - }, - compareRevision(a, b) { - return styleManager.compareRevision(a, b); - }, - getState(drive) { - const key = `sync/state/${drive.name}`; - return chromeLocal.getValue(key); - }, - setState(drive, state) { - const key = `sync/state/${drive.name}`; - return chromeLocal.setValue(key, state); - }, - }); + let ctrl; const ready = prefs.initializing.then(() => { prefs.subscribe('sync.enabled', (_, val) => val === 'none' ? sync.stop() : sync.start(val, true), - {now: true}); + {runNow: true}); }); chrome.alarms.onAlarm.addListener(info => { @@ -79,8 +47,12 @@ const sync = API.sync = (() => { } }); - // Sorted alphabetically - return { + //#endregion + //#region Exports + + sync = /** @namespace Sync */ { + + // sorted alphabetically async delete(...args) { await ready; @@ -89,9 +61,7 @@ const sync = API.sync = (() => { return ctrl.delete(...args); }, - /** - * @returns {Promise} - */ + /** @returns {Promise} */ async getStatus() { return status; }, @@ -124,8 +94,9 @@ const sync = API.sync = (() => { return; } currentDrive = getDrive(name); + if (!ctrl) await initController(); ctrl.use(currentDrive); - status.state = 'connecting'; + status.state = STATES.connecting; status.currentDriveName = currentDrive.name; status.login = true; emitStatusChange(); @@ -144,7 +115,7 @@ const sync = API.sync = (() => { } } prefs.set('sync.enabled', name); - status.state = 'connected'; + status.state = STATES.connected; schedule(SYNC_INTERVAL); emitStatusChange(); }, @@ -155,17 +126,16 @@ const sync = API.sync = (() => { return; } chrome.alarms.clear('syncNow'); - status.state = 'disconnecting'; + status.state = STATES.disconnecting; emitStatusChange(); try { await ctrl.stop(); await tokenManager.revokeToken(currentDrive.name); - await chromeLocal.remove(`sync/state/${currentDrive.name}`); - } catch (e) { - } + await chromeLocal.remove(STORAGE_KEY + currentDrive.name); + } catch (e) {} currentDrive = null; prefs.set('sync.enabled', 'none'); - status.state = 'disconnected'; + status.state = STATES.disconnected; status.currentDriveName = null; status.login = false; emitStatusChange(); @@ -186,6 +156,47 @@ const sync = API.sync = (() => { }, }; + //#endregion + //#region Utils + + async function initController() { + await require(['js!/vendor/db-to-cloud/db-to-cloud.min']); /* global dbToCloud */ + ctrl = dbToCloud.dbToCloud({ + onGet(id) { + return API.styles.getByUUID(id); + }, + onPut(doc) { + return API.styles.putByUUID(doc); + }, + onDelete(id, rev) { + return API.styles.deleteByUUID(id, rev); + }, + async onFirstSync() { + for (const i of await API.styles.getAll()) { + ctrl.put(i._id, i._rev); + } + }, + onProgress(e) { + if (e.phase === 'start') { + status.syncing = true; + } else if (e.phase === 'end') { + status.syncing = false; + status.progress = null; + } else { + status.progress = e; + } + emitStatusChange(); + }, + compareRevision, + getState(drive) { + return chromeLocal.getValue(STORAGE_KEY + drive.name); + }, + setState(drive, state) { + return chromeLocal.setValue(STORAGE_KEY + drive.name, state); + }, + }); + } + function schedule(delay = SYNC_DELAY) { chrome.alarms.create('syncNow', { delayInMinutes: delay, @@ -220,4 +231,8 @@ const sync = API.sync = (() => { } throw new Error(`unknown cloud name: ${name}`); } -})(); + + //#endregion + + return sync; +}); diff --git a/background/tab-manager.js b/background/tab-manager.js index 5a341c1f..b366a87f 100644 --- a/background/tab-manager.js +++ b/background/tab-manager.js @@ -1,32 +1,23 @@ -/* global navigatorUtil */ -/* exported tabManager */ 'use strict'; -const tabManager = (() => { - const listeners = []; +define(require => { + const navigatorUtil = require('./navigator-util'); + + const listeners = new Set(); const cache = new Map(); chrome.tabs.onRemoved.addListener(tabId => cache.delete(tabId)); chrome.tabs.onReplaced.addListener((added, removed) => cache.delete(removed)); - navigatorUtil.onUrlChange(({tabId, frameId, url}) => { - const oldUrl = !frameId && tabManager.get(tabId, 'url', frameId); - tabManager.set(tabId, 'url', frameId, url); - if (frameId) return; - for (const fn of listeners) { - try { - fn({tabId, url, oldUrl}); - } catch (err) { - console.error(err); - } - } - }); + navigatorUtil.onUrlChange(notify); - return { + const tabManager = { onUpdate(fn) { - listeners.push(fn); + listeners.add(fn); }, + get(tabId, ...keys) { return keys.reduce((meta, key) => meta && meta[key], cache.get(tabId)); }, + /** * number of keys is arbitrary, last arg is value, `undefined` will delete the last key from meta * (tabId, 'foo', 123) will set tabId's meta to {foo: 123}, @@ -47,8 +38,24 @@ const tabManager = (() => { meta[lastKey] = value; } }, + list() { return cache.keys(); }, }; -})(); + + function notify({tabId, frameId, url}) { + const oldUrl = !frameId && tabManager.get(tabId, 'url', frameId); + tabManager.set(tabId, 'url', frameId, url); + if (frameId) return; + for (const fn of listeners) { + try { + fn({tabId, url, oldUrl}); + } catch (err) { + console.error(err); + } + } + } + + return tabManager; +}); diff --git a/background/token-manager.js b/background/token-manager.js index a5738e0f..9988032f 100644 --- a/background/token-manager.js +++ b/background/token-manager.js @@ -1,113 +1,125 @@ -/* global chromeLocal webextLaunchWebAuthFlow FIREFOX */ -/* exported tokenManager */ 'use strict'; -const tokenManager = (() => { - const AUTH = { - dropbox: { - flow: 'token', - clientId: 'zg52vphuapvpng9', - authURL: 'https://www.dropbox.com/oauth2/authorize', - tokenURL: 'https://api.dropboxapi.com/oauth2/token', - revoke: token => - fetch('https://api.dropboxapi.com/2/auth/token/revoke', { - method: 'POST', - headers: { - 'Authorization': `Bearer ${token}`, - }, - }), - }, - google: { - flow: 'code', - clientId: '283762574871-d4u58s4arra5jdan2gr00heasjlttt1e.apps.googleusercontent.com', - clientSecret: 'J0nc5TlR_0V_ex9-sZk-5faf', - authURL: 'https://accounts.google.com/o/oauth2/v2/auth', - authQuery: { - // NOTE: Google needs 'prompt' parameter to deliver multiple refresh - // tokens for multiple machines. - // https://stackoverflow.com/q/18519185 - access_type: 'offline', - prompt: 'consent', - }, - tokenURL: 'https://oauth2.googleapis.com/token', - scopes: ['https://www.googleapis.com/auth/drive.appdata'], - revoke: token => { - const params = {token}; - return postQuery(`https://accounts.google.com/o/oauth2/revoke?${new URLSearchParams(params)}`); - }, - }, - onedrive: { - flow: 'code', - clientId: '3864ce03-867c-4ad8-9856-371a097d47b1', - clientSecret: '9Pj=TpsrStq8K@1BiwB9PIWLppM:@s=w', - authURL: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', - tokenURL: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', - redirect_uri: FIREFOX ? - 'https://clngdbkpkpeebahjckkjfobafhncgmne.chromiumapp.org/' : - 'https://' + location.hostname + '.chromiumapp.org/', - scopes: ['Files.ReadWrite.AppFolder', 'offline_access'], - }, - }; +define(require => { + const {FIREFOX} = require('/js/toolbox'); + const {chromeLocal} = require('/js/storage-util'); + + const AUTH = createAuth(); const NETWORK_LATENCY = 30; // seconds - return {getToken, revokeToken, getClientId, buildKeys}; + let exports; + const { - function getClientId(name) { - return AUTH[name].clientId; - } + buildKeys, - function buildKeys(name) { - const k = { - TOKEN: `secure/token/${name}/token`, - EXPIRE: `secure/token/${name}/expire`, - REFRESH: `secure/token/${name}/refresh`, - }; - k.LIST = Object.values(k); - return k; - } + } = exports = { - function getToken(name, interactive) { - const k = buildKeys(name); - return chromeLocal.get(k.LIST) - .then(obj => { - if (!obj[k.TOKEN]) { + buildKeys(name) { + const k = { + TOKEN: `secure/token/${name}/token`, + EXPIRE: `secure/token/${name}/expire`, + REFRESH: `secure/token/${name}/refresh`, + }; + k.LIST = Object.values(k); + return k; + }, + + getClientId(name) { + return AUTH[name].clientId; + }, + + getToken(name, interactive) { + const k = buildKeys(name); + return chromeLocal.get(k.LIST) + .then(obj => { + if (!obj[k.TOKEN]) { + return authUser(name, k, interactive); + } + if (!obj[k.EXPIRE] || Date.now() < obj[k.EXPIRE]) { + return obj[k.TOKEN]; + } + if (obj[k.REFRESH]) { + return refreshToken(name, k, obj) + .catch(err => { + if (err.code === 401) { + return authUser(name, k, interactive); + } + throw err; + }); + } return authUser(name, k, interactive); - } - if (!obj[k.EXPIRE] || Date.now() < obj[k.EXPIRE]) { - return obj[k.TOKEN]; - } - if (obj[k.REFRESH]) { - return refreshToken(name, k, obj) - .catch(err => { - if (err.code === 401) { - return authUser(name, k, interactive); - } - throw err; - }); - } - return authUser(name, k, interactive); - }); - } + }); + }, - async function revokeToken(name) { - const provider = AUTH[name]; - const k = buildKeys(name); - if (provider.revoke) { - try { - const token = await chromeLocal.getValue(k.TOKEN); - if (token) { - await provider.revoke(token); + async revokeToken(name) { + const provider = AUTH[name]; + const k = buildKeys(name); + if (provider.revoke) { + try { + const token = await chromeLocal.getValue(k.TOKEN); + if (token) { + await provider.revoke(token); + } + } catch (e) { + console.error(e); } - } catch (e) { - console.error(e); } - } - await chromeLocal.remove(k.LIST); + await chromeLocal.remove(k.LIST); + }, + }; + + function createAuth() { + return { + dropbox: { + flow: 'token', + clientId: 'zg52vphuapvpng9', + authURL: 'https://www.dropbox.com/oauth2/authorize', + tokenURL: 'https://api.dropboxapi.com/oauth2/token', + revoke: token => + fetch('https://api.dropboxapi.com/2/auth/token/revoke', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + }, + }), + }, + google: { + flow: 'code', + clientId: '283762574871-d4u58s4arra5jdan2gr00heasjlttt1e.apps.googleusercontent.com', + clientSecret: 'J0nc5TlR_0V_ex9-sZk-5faf', + authURL: 'https://accounts.google.com/o/oauth2/v2/auth', + authQuery: { + // NOTE: Google needs 'prompt' parameter to deliver multiple refresh + // tokens for multiple machines. + // https://stackoverflow.com/q/18519185 + access_type: 'offline', + prompt: 'consent', + }, + tokenURL: 'https://oauth2.googleapis.com/token', + scopes: ['https://www.googleapis.com/auth/drive.appdata'], + revoke: token => { + const params = {token}; + return postQuery( + `https://accounts.google.com/o/oauth2/revoke?${new URLSearchParams(params)}`); + }, + }, + onedrive: { + flow: 'code', + clientId: '3864ce03-867c-4ad8-9856-371a097d47b1', + clientSecret: '9Pj=TpsrStq8K@1BiwB9PIWLppM:@s=w', + authURL: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', + tokenURL: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', + redirect_uri: FIREFOX ? + 'https://clngdbkpkpeebahjckkjfobafhncgmne.chromiumapp.org/' : + 'https://' + location.hostname + '.chromiumapp.org/', + scopes: ['Files.ReadWrite.AppFolder', 'offline_access'], + }, + }; } - function refreshToken(name, k, obj) { + async function refreshToken(name, k, obj) { if (!obj[k.REFRESH]) { - return Promise.reject(new Error('no refresh token')); + throw new Error('No refresh token'); } const provider = AUTH[name]; const body = { @@ -119,17 +131,17 @@ const tokenManager = (() => { if (provider.clientSecret) { body.client_secret = provider.clientSecret; } - return postQuery(provider.tokenURL, body) - .then(result => { - if (!result.refresh_token) { - // reuse old refresh token - result.refresh_token = obj[k.REFRESH]; - } - return handleTokenResult(result, k); - }); + const result = await postQuery(provider.tokenURL, body); + if (!result.refresh_token) { + // reuse old refresh token + result.refresh_token = obj[k.REFRESH]; + } + return handleTokenResult(result, k); } - function authUser(name, k, interactive = false) { + async function authUser(name, k, interactive = false) { + await require(['js!/vendor/webext-launch-web-auth-flow/webext-launch-web-auth-flow.min']); + /* global webextLaunchWebAuthFlow */ const provider = AUTH[name]; const state = Math.random().toFixed(8).slice(2); const query = { @@ -145,52 +157,54 @@ const tokenManager = (() => { Object.assign(query, provider.authQuery); } const url = `${provider.authURL}?${new URLSearchParams(query)}`; - return webextLaunchWebAuthFlow({ + const finalUrl = await webextLaunchWebAuthFlow({ url, interactive, redirect_uri: query.redirect_uri, - }) - .then(url => { - const params = new URLSearchParams( - provider.flow === 'token' ? - new URL(url).hash.slice(1) : - new URL(url).search.slice(1) - ); - if (params.get('state') !== state) { - throw new Error(`unexpected state: ${params.get('state')}, expected: ${state}`); - } - if (provider.flow === 'token') { - const obj = {}; - for (const [key, value] of params.entries()) { - obj[key] = value; - } - return obj; - } - const code = params.get('code'); - const body = { - code, - grant_type: 'authorization_code', - client_id: provider.clientId, - redirect_uri: query.redirect_uri, - }; - if (provider.clientSecret) { - body.client_secret = provider.clientSecret; - } - return postQuery(provider.tokenURL, body); - }) - .then(result => handleTokenResult(result, k)); + }); + const params = new URLSearchParams( + provider.flow === 'token' ? + new URL(finalUrl).hash.slice(1) : + new URL(finalUrl).search.slice(1) + ); + if (params.get('state') !== state) { + throw new Error(`Unexpected state: ${params.get('state')}, expected: ${state}`); + } + let result; + if (provider.flow === 'token') { + const obj = {}; + for (const [key, value] of params) { + obj[key] = value; + } + result = obj; + } else { + const code = params.get('code'); + const body = { + code, + grant_type: 'authorization_code', + client_id: provider.clientId, + redirect_uri: query.redirect_uri, + }; + if (provider.clientSecret) { + body.client_secret = provider.clientSecret; + } + result = await postQuery(provider.tokenURL, body); + } + return handleTokenResult(result, k); } - function handleTokenResult(result, k) { - return chromeLocal.set({ + async function handleTokenResult(result, k) { + await chromeLocal.set({ [k.TOKEN]: result.access_token, - [k.EXPIRE]: result.expires_in ? Date.now() + (Number(result.expires_in) - NETWORK_LATENCY) * 1000 : undefined, + [k.EXPIRE]: result.expires_in + ? Date.now() + (result.expires_in - NETWORK_LATENCY) * 1000 + : undefined, [k.REFRESH]: result.refresh_token, - }) - .then(() => result.access_token); + }); + return result.access_token; } - function postQuery(url, body) { + async function postQuery(url, body) { const options = { method: 'POST', headers: { @@ -198,17 +212,15 @@ const tokenManager = (() => { }, body: body ? new URLSearchParams(body) : null, }; - return fetch(url, options) - .then(r => { - if (r.ok) { - return r.json(); - } - return r.text() - .then(body => { - const err = new Error(`failed to fetch (${r.status}): ${body}`); - err.code = r.status; - throw err; - }); - }); + const r = await fetch(url, options); + if (r.ok) { + return r.json(); + } + const text = await r.text(); + const err = new Error(`Failed to fetch (${r.status}): ${text}`); + err.code = r.status; + throw err; } -})(); + + return exports; +}); diff --git a/background/update.js b/background/update.js index 182a008d..e16831d6 100644 --- a/background/update.js +++ b/background/update.js @@ -1,20 +1,21 @@ -/* global - API - calcStyleDigest - chromeLocal - debounce - download - ignoreChromeError - prefs - semverCompare - styleJSONseemsValid - styleSectionsEqual - usercss -*/ 'use strict'; -(() => { - const STATES = /** @namespace UpdaterStates */{ +define(require => { + const {API} = require('/js/msg'); + const { + debounce, + download, + ignoreChromeError, + } = require('/js/toolbox'); + const { + calcStyleDigest, + styleJSONseemsValid, + styleSectionsEqual, + } = require('/js/sections-util'); + const {chromeLocal} = require('/js/storage-util'); + const prefs = require('/js/prefs'); + + const STATES = /** @namespace UpdaterStates */ { UPDATED: 'updated', SKIPPED: 'skipped', UNREACHABLE: 'server unreachable', @@ -28,6 +29,7 @@ ERROR_JSON: 'error: JSON is invalid', ERROR_VERSION: 'error: version is older than installed style', }; + const ALARM_NAME = 'scheduledUpdate'; const MIN_INTERVAL_MS = 60e3; const RETRY_ERRORS = [ @@ -39,173 +41,174 @@ let logQueue = []; let logLastWriteTime = 0; - API.updater = { - checkAllStyles, - checkStyle, - getStates: () => STATES, - }; - chromeLocal.getValue('lastUpdateTime').then(val => { lastUpdateTime = val || Date.now(); - prefs.subscribe('updateInterval', schedule, {now: true}); + prefs.subscribe('updateInterval', schedule, {runNow: true}); chrome.alarms.onAlarm.addListener(onAlarm); }); - async function checkAllStyles({ - save = true, - ignoreDigest, - observe, - } = {}) { - resetInterval(); - checkingAll = true; - const port = observe && chrome.runtime.connect({name: 'updater'}); - const styles = (await API.styles.getAll()) - .filter(style => style.updateUrl); - if (port) port.postMessage({count: styles.length}); - log(''); - log(`${save ? 'Scheduled' : 'Manual'} update check for ${styles.length} styles`); - await Promise.all( - styles.map(style => - checkStyle({style, port, save, ignoreDigest}))); - if (port) port.postMessage({done: true}); - if (port) port.disconnect(); - log(''); - checkingAll = false; - } + /** @type {StyleUpdater} */ + const updater = /** @namespace StyleUpdater */ { - /** - * @param {{ - id?: number - style?: StyleObj - port?: chrome.runtime.Port - save?: boolean = true - ignoreDigest?: boolean - }} opts - * @returns {{ - style: StyleObj - updated?: boolean - error?: any - STATES: UpdaterStates - }} - - Original style digests are calculated in these cases: - * style is installed or updated from server - * non-usercss style is checked for an update and styleSectionsEqual considers it unchanged - - Update check proceeds in these cases: - * style has the original digest and it's equal to the current digest - * [ignoreDigest: true] style doesn't yet have the original digest but we ignore it - * [ignoreDigest: none/false] style doesn't yet have the original digest - so we compare the code to the server code and if it's the same we save the digest, - otherwise we skip the style and report MAYBE_EDITED status - - 'ignoreDigest' option is set on the second manual individual update check on the manage page. - */ - async function checkStyle(opts) { - const { - id, - style = await API.styles.get(id), + async checkAllStyles({ + save = true, ignoreDigest, - port, - save, - } = opts; - const ucd = style.usercssData; - let res, state; - try { - await checkIfEdited(); - res = { - style: await (ucd ? updateUsercss : updateUSO)().then(maybeSave), - updated: true, - }; - state = STATES.UPDATED; - } catch (err) { - const error = err === 0 && STATES.UNREACHABLE || - err && err.message || - err; - res = {error, style, STATES}; - state = `${STATES.SKIPPED} (${error})`; - } - log(`${state} #${style.id} ${style.customName || style.name}`); - if (port) port.postMessage(res); - return res; + observe, + } = {}) { + resetInterval(); + checkingAll = true; + const port = observe && chrome.runtime.connect({name: 'updater'}); + const styles = (await API.styles.getAll()) + .filter(style => style.updateUrl); + if (port) port.postMessage({count: styles.length}); + log(''); + log(`${save ? 'Scheduled' : 'Manual'} update check for ${styles.length} styles`); + await Promise.all( + styles.map(style => + updater.checkStyle({style, port, save, ignoreDigest}))); + if (port) port.postMessage({done: true}); + if (port) port.disconnect(); + log(''); + checkingAll = false; + }, - async function checkIfEdited() { - if (!ignoreDigest && - style.originalDigest && - style.originalDigest !== await calcStyleDigest(style)) { - return Promise.reject(STATES.EDITED); - } - } + /** + * @param {{ + id?: number + style?: StyleObj + port?: chrome.runtime.Port + save?: boolean = true + ignoreDigest?: boolean + }} opts + * @returns {{ + style: StyleObj + updated?: boolean + error?: any + STATES: UpdaterStates + }} - async function updateUSO() { - const md5 = await tryDownload(style.md5Url); - if (!md5 || md5.length !== 32) { - return Promise.reject(STATES.ERROR_MD5); - } - if (md5 === style.originalMd5 && style.originalDigest && !ignoreDigest) { - return Promise.reject(STATES.SAME_MD5); - } - const json = await tryDownload(style.updateUrl, {responseType: 'json'}); - if (!styleJSONseemsValid(json)) { - return Promise.reject(STATES.ERROR_JSON); - } - // USO may not provide a correctly updated originalMd5 (#555) - json.originalMd5 = md5; - return json; - } + Original style digests are calculated in these cases: + * style is installed or updated from server + * non-usercss style is checked for an update and styleSectionsEqual considers it unchanged - async function updateUsercss() { - // TODO: when sourceCode is > 100kB use http range request(s) for version check - const text = await tryDownload(style.updateUrl); - const json = await usercss.buildMeta(text); - const delta = semverCompare(json.usercssData.version, ucd.version); - if (!delta && !ignoreDigest) { - // re-install is invalid in a soft upgrade - const sameCode = text === style.sourceCode; - return Promise.reject(sameCode ? STATES.SAME_CODE : STATES.SAME_VERSION); - } - if (delta < 0) { - // downgrade is always invalid - return Promise.reject(STATES.ERROR_VERSION); - } - return usercss.buildCode(json); - } + Update check proceeds in these cases: + * style has the original digest and it's equal to the current digest + * [ignoreDigest: true] style doesn't yet have the original digest but we ignore it + * [ignoreDigest: none/false] style doesn't yet have the original digest + so we compare the code to the server code and if it's the same we save the digest, + otherwise we skip the style and report MAYBE_EDITED status - async function maybeSave(json) { - json.id = style.id; - json.updateDate = Date.now(); - // keep current state - delete json.customName; - delete json.enabled; - const newStyle = Object.assign({}, style, json); - // update digest even if save === false as there might be just a space added etc. - if (!ucd && styleSectionsEqual(json, style)) { - style.originalDigest = (await API.styles.install(newStyle)).originalDigest; - return Promise.reject(STATES.SAME_CODE); + 'ignoreDigest' option is set on the second manual individual update check on the manage page. + */ + async checkStyle(opts) { + const { + id, + style = await API.styles.get(id), + ignoreDigest, + port, + save, + } = opts; + const ucd = style.usercssData; + let res, state; + try { + await checkIfEdited(); + res = { + style: await (ucd ? updateUsercss : updateUSO)().then(maybeSave), + updated: true, + }; + state = STATES.UPDATED; + } catch (err) { + const error = err === 0 && STATES.UNREACHABLE || + err && err.message || + err; + res = {error, style, STATES}; + state = `${STATES.SKIPPED} (${error})`; } - if (!style.originalDigest && !ignoreDigest) { - return Promise.reject(STATES.MAYBE_EDITED); - } - return !save ? newStyle : - (ucd ? API.usercss : API.styles).install(newStyle); - } + log(`${state} #${style.id} ${style.customName || style.name}`); + if (port) port.postMessage(res); + return res; - async function tryDownload(url, params) { - let {retryDelay = 1000} = opts; - while (true) { - try { - return await download(url, params); - } catch (code) { - if (!RETRY_ERRORS.includes(code) || - retryDelay > MIN_INTERVAL_MS) { - return Promise.reject(code); - } + async function checkIfEdited() { + if (!ignoreDigest && + style.originalDigest && + style.originalDigest !== await calcStyleDigest(style)) { + return Promise.reject(STATES.EDITED); } - retryDelay *= 1.25; - await new Promise(resolve => setTimeout(resolve, retryDelay)); } - } - } + + async function updateUSO() { + const md5 = await tryDownload(style.md5Url); + if (!md5 || md5.length !== 32) { + return Promise.reject(STATES.ERROR_MD5); + } + if (md5 === style.originalMd5 && style.originalDigest && !ignoreDigest) { + return Promise.reject(STATES.SAME_MD5); + } + const json = await tryDownload(style.updateUrl, {responseType: 'json'}); + if (!styleJSONseemsValid(json)) { + return Promise.reject(STATES.ERROR_JSON); + } + // USO may not provide a correctly updated originalMd5 (#555) + json.originalMd5 = md5; + return json; + } + + async function updateUsercss() { + // TODO: when sourceCode is > 100kB use http range request(s) for version check + const text = await tryDownload(style.updateUrl); + const json = await API.usercss.buildMeta({sourceCode: text}); + await require(['js!/vendor/semver-bundle/semver']); /* global semverCompare */ + const delta = semverCompare(json.usercssData.version, ucd.version); + if (!delta && !ignoreDigest) { + // re-install is invalid in a soft upgrade + const sameCode = text === style.sourceCode; + return Promise.reject(sameCode ? STATES.SAME_CODE : STATES.SAME_VERSION); + } + if (delta < 0) { + // downgrade is always invalid + return Promise.reject(STATES.ERROR_VERSION); + } + return API.usercss.buildCode(json); + } + + async function maybeSave(json) { + json.id = style.id; + json.updateDate = Date.now(); + // keep current state + delete json.customName; + delete json.enabled; + const newStyle = Object.assign({}, style, json); + // update digest even if save === false as there might be just a space added etc. + if (!ucd && styleSectionsEqual(json, style)) { + style.originalDigest = (await API.styles.install(newStyle)).originalDigest; + return Promise.reject(STATES.SAME_CODE); + } + if (!style.originalDigest && !ignoreDigest) { + return Promise.reject(STATES.MAYBE_EDITED); + } + return !save ? newStyle : + (ucd ? API.usercss.install : API.styles.install)(newStyle); + } + + async function tryDownload(url, params) { + let {retryDelay = 1000} = opts; + while (true) { + try { + return await download(url, params); + } catch (code) { + if (!RETRY_ERRORS.includes(code) || + retryDelay > MIN_INTERVAL_MS) { + return Promise.reject(code); + } + } + retryDelay *= 1.25; + await new Promise(resolve => setTimeout(resolve, retryDelay)); + } + } + }, + + getStates: () => STATES, + }; function schedule() { const interval = prefs.get('updateInterval') * 60 * 60 * 1000; @@ -220,7 +223,7 @@ } function onAlarm({name}) { - if (name === ALARM_NAME) checkAllStyles(); + if (name === ALARM_NAME) updater.checkAllStyles(); } function resetInterval() { @@ -253,4 +256,6 @@ logLastWriteTime = Date.now(); logQueue = []; } -})(); + + return updater; +}); diff --git a/background/usercss-api-helper.js b/background/usercss-api-helper.js index b6508896..8f489618 100644 --- a/background/usercss-api-helper.js +++ b/background/usercss-api-helper.js @@ -1,81 +1,161 @@ -/* global - API - deepCopy - usercss -*/ 'use strict'; -API.usercss = { +define(require => { + const {API} = require('/js/msg'); + const {deepCopy, download} = require('/js/toolbox'); - async build({ - styleId, - sourceCode, - vars, - checkDup, - metaOnly, - assignVars, - }) { - let style = await usercss.buildMeta(sourceCode); - const dup = (checkDup || assignVars) && - await API.usercss.find(styleId ? {id: styleId} : style); - if (!metaOnly) { - if (vars || assignVars) { - await usercss.assignVars(style, vars ? {usercssData: {vars}} : dup); + const GLOBAL_METAS = { + author: undefined, + description: undefined, + homepageURL: 'url', + updateURL: 'updateUrl', + name: undefined, + }; + const ERR_ARGS_IS_LIST = [ + 'missingMandatory', + 'missingChar', + ]; + + const usercss = /** @namespace UsercssHelper */ { + + rxMETA: /\/\*!?\s*==userstyle==[\s\S]*?==\/userstyle==\s*\*\//i, + + async assignVars(style, oldStyle) { + const vars = style.usercssData.vars; + const oldVars = oldStyle.usercssData.vars; + if (vars && oldVars) { + // The type of var might be changed during the update. Set value to null if the value is invalid. + for (const [key, v] of Object.entries(vars)) { + const old = oldVars[key] && oldVars[key].value; + if (old) v.value = old; + } + style.usercssData.vars = await API.worker.nullifyInvalidVars(vars); } - style = await usercss.buildCode(style); - } - return {style, dup}; - }, + }, - async buildMeta(style) { - if (style.usercssData) { + async build({ + styleId, + sourceCode, + vars, + checkDup, + metaOnly, + assignVars, + initialUrl, + }) { + // downloading here while install-usercss page is loading to avoid the wait + if (initialUrl) sourceCode = await download(initialUrl); + const style = await usercss.buildMeta({sourceCode}); + const dup = (checkDup || assignVars) && + await usercss.find(styleId ? {id: styleId} : style); + if (!metaOnly) { + if (vars || assignVars) { + await usercss.assignVars(style, vars ? {usercssData: {vars}} : dup); + } + await usercss.buildCode(style); + } + return {style, dup}; + }, + + async buildCode(style) { + const {sourceCode: code, usercssData: {vars, preprocessor}} = style; + const match = code.match(usercss.rxMETA); + const i = match.index; + const j = i + match[0].length; + const codeNoMeta = code.slice(0, i) + blankOut(code, i, j) + code.slice(j); + const {sections, errors} = await API.worker.compileUsercss(preprocessor, codeNoMeta, vars); + const recoverable = errors.every(e => e.recoverable); + if (!sections.length || !recoverable) { + throw !recoverable ? errors : 'Style does not contain any actual CSS to apply.'; + } + style.sections = sections; return style; - } - // allow sourceCode to be normalized - const {sourceCode} = style; - delete style.sourceCode; - return Object.assign(await usercss.buildMeta(sourceCode), style); - }, + }, - async configVars(id, vars) { - let style = deepCopy(await API.styles.get(id)); - style.usercssData.vars = vars; - style = await usercss.buildCode(style); - style = await API.styles.install(style, 'config'); - return style.usercssData.vars; - }, - - async editSave(style) { - return API.styles.editSave(await API.usercss.parse(style)); - }, - - async find(styleOrData) { - if (styleOrData.id) { - return API.styles.get(styleOrData.id); - } - const {name, namespace} = styleOrData.usercssData || styleOrData; - for (const dup of await API.styles.getAll()) { - const data = dup.usercssData; - if (data && - data.name === name && - data.namespace === namespace) { - return dup; + async buildMeta(style) { + if (style.usercssData) { + return style; } - } - }, + // remember normalized sourceCode + let code = style.sourceCode = style.sourceCode.replace(/\r\n?/g, '\n'); + style = Object.assign({ + enabled: true, + sections: [], + }, style); + const match = code.match(usercss.rxMETA); + if (!match) { + return Promise.reject(new Error('Could not find metadata.')); + } + try { + code = blankOut(code, 0, match.index) + match[0]; + const {metadata} = await API.worker.parseUsercssMeta(code); + style.usercssData = metadata; + // https://github.com/openstyles/stylus/issues/560#issuecomment-440561196 + for (const [key, value] of Object.entries(GLOBAL_METAS)) { + if (metadata[key] !== undefined) { + style[value || key] = metadata[key]; + } + } + return style; + } catch (err) { + if (err.code) { + const args = ERR_ARGS_IS_LIST.includes(err.code) + ? err.args.map(e => e.length === 1 ? JSON.stringify(e) : e).join(', ') + : err.args; + const msg = chrome.i18n.getMessage(`meta_${err.code}`, args); + if (msg) err.message = msg; + } + return Promise.reject(err); + } + }, - async install(style) { - return API.styles.install(await API.usercss.parse(style)); - }, + async configVars(id, vars) { + let style = deepCopy(await API.styles.get(id)); + style.usercssData.vars = vars; + await usercss.buildCode(style); + style = await API.styles.install(style, 'config'); + return style.usercssData.vars; + }, - async parse(style) { - style = await API.usercss.buildMeta(style); - // preserve style.vars during update - const dup = await API.usercss.find(style); - if (dup) { - style.id = dup.id; - await usercss.assignVars(style, dup); - } - return usercss.buildCode(style); - }, -}; + async editSave(style) { + return API.styles.editSave(await usercss.parse(style)); + }, + + async find(styleOrData) { + if (styleOrData.id) { + return API.styles.get(styleOrData.id); + } + const {name, namespace} = styleOrData.usercssData || styleOrData; + for (const dup of await API.styles.getAll()) { + const data = dup.usercssData; + if (data && + data.name === name && + data.namespace === namespace) { + return dup; + } + } + }, + + async install(style) { + return API.styles.install(await usercss.parse(style)); + }, + + async parse(style) { + style = await usercss.buildMeta(style); + // preserve style.vars during update + const dup = await usercss.find(style); + if (dup) { + style.id = dup.id; + await usercss.assignVars(style, dup); + } + return usercss.buildCode(style); + }, + }; + + /** Replaces everything with spaces to keep the original length, + * but preserves the line breaks to keep the original line/col relation */ + function blankOut(str, start = 0, end = str.length) { + return str.slice(start, end).replace(/[^\r\n]/g, ' '); + } + + return usercss; +}); diff --git a/background/usercss-install-helper.js b/background/usercss-install-helper.js index 099a0822..c4147a13 100644 --- a/background/usercss-install-helper.js +++ b/background/usercss-install-helper.js @@ -1,36 +1,24 @@ -/* global - API - download - openURL - tabManager - URLS -*/ 'use strict'; -(() => { +define(require => { + const { + URLS, + download, + openURL, + } = require('/js/toolbox'); + const tabManager = require('./tab-manager'); + const installCodeCache = {}; - const clearInstallCode = url => delete installCodeCache[url]; - /** Sites may be using custom types like text/stylus so this coarse filter only excludes html */ - const isContentTypeText = type => /^text\/(?!html)/i.test(type); - // in Firefox we have to use a content script to read file:// - const fileLoader = !chrome.app && ( - async tabId => - (await browser.tabs.executeScript(tabId, {file: '/content/install-hook-usercss.js'}))[0]); + const exports = /** @namespace UsercssHelper */ { - const urlLoader = - async (tabId, url) => ( - url.startsWith('file:') || - tabManager.get(tabId, isContentTypeText.name) || - isContentTypeText((await fetch(url, {method: 'HEAD'})).headers.get('content-type')) - ) && download(url); - - API.usercss.getInstallCode = url => { - // when the installer tab is reloaded after the cache is expired, this will throw intentionally - const {code, timer} = installCodeCache[url]; - clearInstallCode(url); - clearTimeout(timer); - return code; + getInstallCode(url) { + // when the installer tab is reloaded after the cache is expired, this will throw intentionally + const {code, timer} = installCodeCache[url]; + clearInstallCode(url); + clearTimeout(timer); + return code; + }, }; // `glob`: pathname match pattern for webRequest @@ -48,17 +36,7 @@ }, }; - // Faster installation on known distribution sites to avoid flicker of css text - chrome.webRequest.onBeforeSendHeaders.addListener(({tabId, url}) => { - const u = new URL(url); - const m = maybeDistro[u.hostname]; - if (!m || m.rx.test(u.pathname)) { - openInstallerPage(tabId, url, {}); - // Silently suppress navigation. - // Don't redirect to the install URL as it'll flash the text! - return {redirectUrl: 'javascript:void 0'}; // eslint-disable-line no-script-url - } - }, { + chrome.webRequest.onBeforeSendHeaders.addListener(maybeInstallFromDistro, { urls: [ URLS.usoArchiveRaw + 'usercss/*.user.css', '*://greasyfork.org/scripts/*/code/*.user.css', @@ -70,27 +48,63 @@ types: ['main_frame'], }, ['blocking']); - // Remember Content-Type to avoid re-fetching of the headers in urlLoader as it can be very slow - chrome.webRequest.onHeadersReceived.addListener(({tabId, responseHeaders}) => { - const h = responseHeaders.find(h => h.name.toLowerCase() === 'content-type'); - tabManager.set(tabId, isContentTypeText.name, h && isContentTypeText(h.value) || undefined); - }, { + chrome.webRequest.onHeadersReceived.addListener(rememberContentType, { urls: makeUsercssGlobs('*', '/*'), types: ['main_frame'], }, ['responseHeaders']); - tabManager.onUpdate(async ({tabId, url, oldUrl = ''}) => { + tabManager.onUpdate(maybeInstall); + + function clearInstallCode(url) { + return delete installCodeCache[url]; + } + + /** Sites may be using custom types like text/stylus so this coarse filter only excludes html */ + function isContentTypeText(type) { + return /^text\/(?!html)/i.test(type); + } + + // in Firefox we have to use a content script to read file:// + async function loadFromFile(tabId) { + return (await browser.tabs.executeScript(tabId, {file: '/content/install-hook-usercss.js'}))[0]; + } + + async function loadFromUrl(tabId, url) { + return ( + url.startsWith('file:') || + tabManager.get(tabId, isContentTypeText.name) || + isContentTypeText((await fetch(url, {method: 'HEAD'})).headers.get('content-type')) + ) && download(url); + } + + function makeUsercssGlobs(host, path) { + return '%css,%css?*,%styl,%styl?*'.replace(/%/g, `*://${host}${path}.user.`).split(','); + } + + async function maybeInstall({tabId, url, oldUrl = ''}) { if (url.includes('.user.') && /^(https?|file|ftps?):/.test(url) && /\.user\.(css|styl)$/.test(url.split(/[#?]/, 1)[0]) && !oldUrl.startsWith(URLS.installUsercss)) { - const inTab = url.startsWith('file:') && Boolean(fileLoader); - const code = await (inTab ? fileLoader : urlLoader)(tabId, url); + const inTab = url.startsWith('file:') && !chrome.app; + const code = await (inTab ? loadFromFile : loadFromUrl)(tabId, url); if (/==userstyle==/i.test(code) && !/^\s* h.name.toLowerCase() === 'content-type'); + tabManager.set(tabId, isContentTypeText.name, h && isContentTypeText(h.value) || undefined); } -})(); + + return exports; +}); diff --git a/content/apply.js b/content/apply.js index b4905b38..de80bb2b 100644 --- a/content/apply.js +++ b/content/apply.js @@ -1,18 +1,14 @@ -/* global msg API prefs createStyleInjector */ 'use strict'; -// Chrome reruns content script when documentElement is replaced. -// Note, we're checking against a literal `1`, not just `if (truthy)`, -// because is exposed per HTML spec as a global variable and `window.INJECTED`. - -// eslint-disable-next-line no-unused-expressions -self.INJECTED !== 1 && (() => { - self.INJECTED = 1; +define(require => { + const {API, msg} = require('/js/msg'); + const prefs = require('/js/prefs'); let IS_TAB = !chrome.tabs || location.pathname !== '/popup.html'; const IS_FRAME = window !== parent; const STYLE_VIA_API = !chrome.app && document instanceof XMLDocument; - const styleInjector = createStyleInjector({ + /** @type {StyleInjector} */ + const styleInjector = require('/content/style-injector')({ compare: (a, b) => a.id - b.id, onUpdate: onInjectorUpdate, }); @@ -210,4 +206,4 @@ self.INJECTED !== 1 && (() => { msg.off(applyOnMessage); } catch (e) {} } -})(); +}); diff --git a/content/install-hook-greasyfork.js b/content/install-hook-greasyfork.js index 9de4cab3..e9a4293a 100644 --- a/content/install-hook-greasyfork.js +++ b/content/install-hook-greasyfork.js @@ -1,21 +1,14 @@ -/* global API */ 'use strict'; -// onCommitted may fire twice -// Note, we're checking against a literal `1`, not just `if (truthy)`, -// because is exposed per HTML spec as a global variable and `window.INJECTED`. - -if (window.INJECTED_GREASYFORK !== 1) { - window.INJECTED_GREASYFORK = 1; - addEventListener('message', async function onMessage(e) { - if (e.origin === location.origin && - e.data && - e.data.name && - e.data.type === 'style-version-query') { - removeEventListener('message', onMessage); - const style = await API.usercss.find(e.data) || {}; - const {version} = style.usercssData || {}; - postMessage({type: 'style-version', version}, '*'); - } - }); -} +addEventListener('message', async function onMessage(e) { + if (e.origin === location.origin && + e.data && + e.data.name && + e.data.type === 'style-version-query') { + removeEventListener('message', onMessage); + const {API} = self.require('/js/msg'); + const style = await API.usercss.find(e.data) || {}; + const {version} = style.usercssData || {}; + postMessage({type: 'style-version', version}, '*'); + } +}); diff --git a/content/install-hook-openusercss.js b/content/install-hook-openusercss.js index e57da66d..edec2150 100644 --- a/content/install-hook-openusercss.js +++ b/content/install-hook-openusercss.js @@ -1,7 +1,8 @@ -/* global API */ 'use strict'; -(() => { +define(require => { + const {API} = require('/js/msg'); + const manifest = chrome.runtime.getManifest(); const allowedOrigins = [ 'https://openusercss.org', @@ -55,7 +56,7 @@ window.addEventListener('message', installedHandler); }; - const doHandshake = () => { + const doHandshake = event => { // This is a representation of features that Stylus is capable of const implementedFeatures = [ 'install-usercss', @@ -106,7 +107,7 @@ && event.data.type === 'ouc-handshake-question' && allowedOrigins.includes(event.origin) ) { - doHandshake(); + doHandshake(event); } }; @@ -171,4 +172,4 @@ attachInstallListeners(); attachInstalledListeners(); askHandshake(); -})(); +}); diff --git a/content/install-hook-userstyles.js b/content/install-hook-userstyles.js index f9330f43..dbdc1980 100644 --- a/content/install-hook-userstyles.js +++ b/content/install-hook-userstyles.js @@ -1,8 +1,10 @@ -/* global cloneInto msg API */ 'use strict'; // eslint-disable-next-line no-unused-expressions -/^\/styles\/(\d+)(\/([^/]*))?([?#].*)?$/.test(location.pathname) && (() => { +/^\/styles\/(\d+)(\/([^/]*))?([?#].*)?$/.test(location.pathname) && +define(require => { + const {API, msg} = require('/js/msg'); + const styleId = RegExp.$1; const pageEventId = `${performance.now()}${Math.random()}`; @@ -119,7 +121,7 @@ if (typeof cloneInto !== 'undefined') { // Firefox requires explicit cloning, however USO can't process our messages anyway // because USO tries to use a global "event" variable deprecated in Firefox - detail = cloneInto({detail}, document); + detail = cloneInto({detail}, document); /* global cloneInto */ } else { detail = {detail}; } @@ -325,7 +327,7 @@ msg.off(onMessage); } catch (e) {} } -})(); +}); function inPageContext(eventId) { document.currentScript.remove(); diff --git a/content/style-injector.js b/content/style-injector.js index 7a3fb0f2..e046e675 100644 --- a/content/style-injector.js +++ b/content/style-injector.js @@ -1,6 +1,10 @@ 'use strict'; -self.createStyleInjector = self.INJECTED === 1 ? self.createStyleInjector : ({ +/** The name is needed when running in content scripts but specifying it in define() + breaks IDE detection of exports so here's a workaround */ +define.currentModule = '/content/style-injector'; + +define(require => ({ compare, onUpdate = () => {}, }) => { @@ -17,22 +21,22 @@ self.createStyleInjector = self.INJECTED === 1 ? self.createStyleInjector : ({ // will store the original method refs because the page can override them let creationDoc, createElement, createElementNS; - return { + return /** @namespace StyleInjector */ { list, - apply(styleMap) { + async apply(styleMap) { const styles = _styleMapToArray(styleMap); - return ( - !styles.length ? - Promise.resolve([]) : - docRootObserver.evade(() => { - if (!isTransitionPatched && isEnabled) { - _applyTransitionPatch(styles); - } - return styles.map(_addUpdate); - }) - ).then(_emitUpdate); + const value = !styles.length + ? [] + : await docRootObserver.evade(() => { + if (!isTransitionPatched && isEnabled) { + _applyTransitionPatch(styles); + } + return styles.map(_addUpdate); + }); + _emitUpdate(); + return value; }, clear() { @@ -155,10 +159,9 @@ self.createStyleInjector = self.INJECTED === 1 ? self.createStyleInjector : ({ docRootObserver[onOff](); } - function _emitUpdate(value) { + function _emitUpdate() { _toggleObservers(list.length); onUpdate(); - return value; } /* @@ -321,4 +324,4 @@ self.createStyleInjector = self.INJECTED === 1 ? self.createStyleInjector : ({ .observe(document, {childList: true}); } } -}; +}); diff --git a/edit.html b/edit.html index e5bc1fdb..34889910 100644 --- a/edit.html +++ b/edit.html @@ -4,8 +4,6 @@ - - - - + - - + + + + + diff --git a/options/options.js b/options/options.js index 6a9f9127..ae6d2c40 100644 --- a/options/options.js +++ b/options/options.js @@ -1,326 +1,340 @@ -/* global - $ - $$ - $create - $createLink - API - capitalize - CHROME - CHROME_HAS_BORDER_BUG - enforceInputRange - FIREFOX - getEventKeyName - ignoreChromeError - messageBox - msg - openURL - OPERA - prefs - setupLivePrefs - t - URLS -*/ 'use strict'; -setupLivePrefs(); -setupRadioButtons(); -$$('input[min], input[max]').forEach(enforceInputRange); -setTimeout(splitLongTooltips); +define(require => { + const {API, msg} = require('/js/msg'); + const { + CHROME, + CHROME_HAS_BORDER_BUG, + OPERA, + FIREFOX, + URLS, + capitalize, + ignoreChromeError, + openURL, + } = require('/js/toolbox'); + const t = require('/js/localization'); + const { + $, + $$, + $create, + $createLink, + getEventKeyName, + messageBoxProxy, + setupLivePrefs, + } = require('/js/dom'); + const prefs = require('/js/prefs'); -if (CHROME_HAS_BORDER_BUG) { - const borderOption = $('.chrome-no-popup-border'); - if (borderOption) { - borderOption.classList.remove('chrome-no-popup-border'); - } -} + setupLivePrefs(); + setupRadioButtons(); + $$('input[min], input[max]').forEach(enforceInputRange); + setTimeout(splitLongTooltips); -// collapse #advanced block in Chrome pre-66 (classic chrome://extensions UI) -if (!FIREFOX && !OPERA && CHROME < 66) { - const block = $('#advanced'); - $('h1', block).onclick = event => { - event.preventDefault(); - block.classList.toggle('collapsed'); - const isCollapsed = block.classList.contains('collapsed'); - const visibleToggle = $(isCollapsed ? '.is-collapsed' : '.is-expanded', block); - visibleToggle.focus(); - }; - block.classList.add('collapsible', 'collapsed'); -} - -if (FIREFOX && 'update' in (chrome.commands || {})) { - $('[data-cmd="open-keyboard"]').classList.remove('chromium-only'); - msg.onExtension(msg => { - if (msg.method === 'optionsCustomizeHotkeys') { - customizeHotkeys(); - } - }); -} - -if (CHROME && !chrome.declarativeContent) { - // Show the option as disabled until the permission is actually granted - const el = $('#styleViaXhr'); - prefs.initializing.then(() => { - el.checked = false; - }); - el.on('click', () => { - if (el.checked) { - chrome.permissions.request({permissions: ['declarativeContent']}, ignoreChromeError); - } - }); -} - -// actions -$('#options-close-icon').onclick = () => { - top.dispatchEvent(new CustomEvent('closeOptions')); -}; - -document.onclick = e => { - const target = e.target.closest('[data-cmd]'); - if (!target) { - return; - } - // prevent double-triggering in case a sub-element was clicked - e.stopPropagation(); - - switch (target.dataset.cmd) { - case 'open-manage': - API.openManage(); - break; - - case 'check-updates': - checkUpdates(); - break; - - case 'open-keyboard': - if (FIREFOX) { - customizeHotkeys(); - } else { - openURL({url: URLS.configureCommands}); - } - e.preventDefault(); - break; - - case 'reset': - $$('input') - .filter(input => input.id in prefs.defaults) - .forEach(input => prefs.reset(input.id)); - break; - - case 'note': { - e.preventDefault(); - messageBox({ - className: 'note', - contents: target.dataset.title, - buttons: [t('confirmClose')], - }); + if (CHROME_HAS_BORDER_BUG) { + const borderOption = $('.chrome-no-popup-border'); + if (borderOption) { + borderOption.classList.remove('chrome-no-popup-border'); } } -}; -// sync to cloud -(() => { - const elCloud = $('.sync-options .cloud-name'); - const elStart = $('.sync-options .connect'); - const elStop = $('.sync-options .disconnect'); - const elSyncNow = $('.sync-options .sync-now'); - const elStatus = $('.sync-options .sync-status'); - const elLogin = $('.sync-options .sync-login'); - /** @type {API.sync.Status} */ - let status = {}; - msg.onExtension(e => { - if (e.method === 'syncStatusUpdate') { - setStatus(e.status); - } - }); - API.sync.getStatus() - .then(setStatus); + // collapse #advanced block in Chrome pre-66 (classic chrome://extensions UI) + if (!FIREFOX && !OPERA && CHROME < 66) { + const block = $('#advanced'); + $('h1', block).onclick = event => { + event.preventDefault(); + block.classList.toggle('collapsed'); + const isCollapsed = block.classList.contains('collapsed'); + const visibleToggle = $(isCollapsed ? '.is-collapsed' : '.is-expanded', block); + visibleToggle.focus(); + }; + block.classList.add('collapsible', 'collapsed'); + } - elCloud.on('change', updateButtons); - for (const [btn, fn] of [ - [elStart, () => API.sync.start(elCloud.value)], - [elStop, API.sync.stop], - [elSyncNow, API.sync.syncNow], - [elLogin, API.sync.login], - ]) { - btn.on('click', e => { - if (getEventKeyName(e) === 'L') { - fn(); + if (FIREFOX && 'update' in (chrome.commands || {})) { + $('[data-cmd="open-keyboard"]').classList.remove('chromium-only'); + } + + if (CHROME && !chrome.declarativeContent) { + // Show the option as disabled until the permission is actually granted + const el = $('#styleViaXhr'); + prefs.initializing.then(() => { + el.checked = false; + }); + el.on('click', () => { + if (el.checked) { + chrome.permissions.request({permissions: ['declarativeContent']}, ignoreChromeError); } }); } - function setStatus(newStatus) { - status = newStatus; - updateButtons(); - } - - function updateButtons() { - const isConnected = status.state === 'connected'; - const isDisconnected = status.state === 'disconnected'; - if (status.currentDriveName) { - elCloud.value = status.currentDriveName; - } - for (const [el, enable] of [ - [elCloud, isDisconnected], - [elStart, isDisconnected && elCloud.value !== 'none'], - [elStop, isConnected && !status.syncing], - [elSyncNow, isConnected && !status.syncing], - ]) { - el.disabled = !enable; - } - elStatus.textContent = getStatusText(); - elLogin.hidden = !isConnected || status.login; - } - - function getStatusText() { - // chrome.i18n.getMessage is used instead of t() because calculated ids may be absent - let res; - if (status.syncing) { - const {phase, loaded, total} = status.progress || {}; - res = phase - ? chrome.i18n.getMessage(`optionsSyncStatus${capitalize(phase)}`, [loaded + 1, total]) || - `${phase} ${loaded} / ${total}` - : t('optionsSyncStatusSyncing'); - } else { - const {state, errorMessage} = status; - res = (state === 'connected' || state === 'disconnected') && errorMessage || - chrome.i18n.getMessage(`optionsSyncStatus${capitalize(state)}`) || state; - } - return res; - } -})(); - -function checkUpdates() { - let total = 0; - let checked = 0; - let updated = 0; - const maxWidth = $('#update-progress').parentElement.clientWidth; - - chrome.runtime.onConnect.addListener(function onConnect(port) { - if (port.name !== 'updater') return; - port.onMessage.addListener(observer); - chrome.runtime.onConnect.removeListener(onConnect); - }); - - API.updater.checkAllStyles({observe: true}); - - function observer(info) { - if ('count' in info) { - total = info.count; - document.body.classList.add('update-in-progress'); - } else if (info.updated) { - updated++; - checked++; - } else if (info.error) { - checked++; - } else if (info.done) { - document.body.classList.remove('update-in-progress'); - } - $('#update-progress').style.width = Math.round(checked / total * maxWidth) + 'px'; - $('#updates-installed').dataset.value = updated || ''; - } -} - -function setupRadioButtons() { - const sets = {}; - const onChange = function () { - const newValue = sets[this.name].indexOf(this); - if (newValue >= 0 && prefs.get(this.name) !== newValue) { - prefs.set(this.name, newValue); - } + // actions + $('#options-close-icon').onclick = () => { + top.dispatchEvent(new CustomEvent('closeOptions')); }; - // group all radio-inputs by name="prefName" attribute - for (const el of $$('input[type="radio"][name]')) { - (sets[el.name] = sets[el.name] || []).push(el); - el.on('change', onChange); - } - // select the input corresponding to the actual pref value - for (const name in sets) { - sets[name][prefs.get(name)].checked = true; - } - // listen to pref changes and update the values - prefs.subscribe(Object.keys(sets), (key, value) => { - sets[key][value].checked = true; - }); -} -function splitLongTooltips() { - for (const el of $$('[title]')) { - el.dataset.title = el.title; - el.title = el.title.replace(/<\/?\w+>/g, ''); // strip html tags - if (el.title.length < 50) { - continue; - } - const newTitle = el.title - .split('\n') - .map(s => s.replace(/([^.][.。?!]|.{50,60},)\s+/g, '$1\n')) - .map(s => s.replace(/(.{50,80}(?=.{40,}))\s+/g, '$1\n')) - .join('\n'); - if (newTitle !== el.title) el.title = newTitle; - } -} - -function customizeHotkeys() { - // command name -> i18n id - const hotkeys = new Map([ - ['_execute_browser_action', 'optionsCustomizePopup'], - ['openManage', 'openManage'], - ['styleDisableAll', 'disableAllStyles'], - ]); - - messageBox({ - title: t('shortcutsNote'), - contents: [ - $create('table', - [...hotkeys.entries()].map(([cmd, i18n]) => - $create('tr', [ - $create('td', t(i18n)), - $create('td', - $create('input', { - id: 'hotkey.' + cmd, - type: 'search', - //placeholder: t('helpKeyMapHotkey'), - })), - ]))), - ], - className: 'center', - buttons: [t('confirmClose')], - onshow(box) { - const ids = []; - for (const cmd of hotkeys.keys()) { - const id = 'hotkey.' + cmd; - ids.push(id); - $('#' + id).oninput = onInput; - } - setupLivePrefs(ids); - $('button', box).insertAdjacentElement('beforebegin', - $createLink( - 'https://developer.mozilla.org/Add-ons/WebExtensions/manifest.json/commands#Key_combinations', - t('helpAlt'))); - }, - }); - - function onInput() { - const name = this.id.split('.')[1]; - const shortcut = this.value.trim(); - if (!shortcut) { - browser.commands.reset(name).catch(ignoreChromeError); - this.setCustomValidity(''); + document.onclick = async e => { + const target = e.target.closest('[data-cmd]'); + if (!target) { return; } - try { - browser.commands.update({name, shortcut}).then( - () => this.setCustomValidity(''), - err => this.setCustomValidity(err) - ); - } catch (err) { - this.setCustomValidity(err); + // prevent double-triggering in case a sub-element was clicked + e.stopPropagation(); + + switch (target.dataset.cmd) { + case 'open-manage': + API.openManage(); + break; + + case 'check-updates': + checkUpdates(); + break; + + case 'open-keyboard': + if (FIREFOX) { + customizeHotkeys(); + } else { + openURL({url: URLS.configureCommands}); + } + e.preventDefault(); + break; + + case 'reset': + $$('input') + .filter(input => input.id in prefs.defaults) + .forEach(input => prefs.reset(input.id)); + break; + + case 'note': { + e.preventDefault(); + messageBoxProxy.show({ + className: 'note', + contents: target.dataset.title, + buttons: [t('confirmClose')], + }); + } + } + }; + + // sync to cloud + (() => { + const elCloud = $('.sync-options .cloud-name'); + const elStart = $('.sync-options .connect'); + const elStop = $('.sync-options .disconnect'); + const elSyncNow = $('.sync-options .sync-now'); + const elStatus = $('.sync-options .sync-status'); + const elLogin = $('.sync-options .sync-login'); + /** @type {Sync.Status} */ + let status = {}; + msg.onExtension(e => { + if (e.method === 'syncStatusUpdate') { + setStatus(e.status); + } + }); + API.sync.getStatus() + .then(setStatus); + + elCloud.on('change', updateButtons); + for (const [btn, fn] of [ + [elStart, () => API.sync.start(elCloud.value)], + [elStop, API.sync.stop], + [elSyncNow, API.sync.syncNow], + [elLogin, API.sync.login], + ]) { + btn.on('click', e => { + if (getEventKeyName(e) === 'L') { + fn(); + } + }); + } + + function setStatus(newStatus) { + status = newStatus; + updateButtons(); + } + + function updateButtons() { + const {state, STATES} = status; + const isConnected = state === STATES.connected; + const isDisconnected = state === STATES.disconnected; + if (status.currentDriveName) { + elCloud.value = status.currentDriveName; + } + for (const [el, enable] of [ + [elCloud, isDisconnected], + [elStart, isDisconnected && elCloud.value !== 'none'], + [elStop, isConnected && !status.syncing], + [elSyncNow, isConnected && !status.syncing], + ]) { + el.disabled = !enable; + } + elStatus.textContent = getStatusText(); + elLogin.hidden = !isConnected || status.login; + } + + function getStatusText() { + let res; + if (status.syncing) { + const {phase, loaded, total} = status.progress || {}; + res = phase + ? t(`optionsSyncStatus${capitalize(phase)}`, [loaded + 1, total], false) || + `${phase} ${loaded} / ${total}` + : t('optionsSyncStatusSyncing'); + } else { + const {state, errorMessage, STATES} = status; + res = (state === STATES.connected || state === STATES.disconnected) && errorMessage || + t(`optionsSyncStatus${capitalize(state)}`, null, false) || state; + } + return res; + } + })(); + + function checkUpdates() { + let total = 0; + let checked = 0; + let updated = 0; + const maxWidth = $('#update-progress').parentElement.clientWidth; + + chrome.runtime.onConnect.addListener(function onConnect(port) { + if (port.name !== 'updater') return; + port.onMessage.addListener(observer); + chrome.runtime.onConnect.removeListener(onConnect); + }); + + API.updater.checkAllStyles({observe: true}); + + function observer(info) { + if ('count' in info) { + total = info.count; + document.body.classList.add('update-in-progress'); + } else if (info.updated) { + updated++; + checked++; + } else if (info.error) { + checked++; + } else if (info.done) { + document.body.classList.remove('update-in-progress'); + } + $('#update-progress').style.width = Math.round(checked / total * maxWidth) + 'px'; + $('#updates-installed').dataset.value = updated || ''; } } -} -window.onkeydown = event => { - if (event.key === 'Escape') { - top.dispatchEvent(new CustomEvent('closeOptions')); + async function customizeHotkeys() { + // command name -> i18n id + const hotkeys = new Map([ + ['_execute_browser_action', 'optionsCustomizePopup'], + ['openManage', 'openManage'], + ['styleDisableAll', 'disableAllStyles'], + ]); + + messageBoxProxy.show({ + title: t('shortcutsNote'), + contents: [ + $create('table', + [...hotkeys.entries()].map(([cmd, i18n]) => + $create('tr', [ + $create('td', t(i18n)), + $create('td', + $create('input', { + id: 'hotkey.' + cmd, + type: 'search', + //placeholder: t('helpKeyMapHotkey'), + })), + ]))), + ], + className: 'center', + buttons: [t('confirmClose')], + onshow(box) { + const ids = []; + for (const cmd of hotkeys.keys()) { + const id = 'hotkey.' + cmd; + ids.push(id); + $('#' + id).oninput = onInput; + } + setupLivePrefs(ids); + $('button', box).insertAdjacentElement('beforebegin', + $createLink( + 'https://developer.mozilla.org/Add-ons/WebExtensions/manifest.json/commands#Key_combinations', + t('helpAlt'))); + }, + }); + + function onInput() { + const name = this.id.split('.')[1]; + const shortcut = this.value.trim(); + if (!shortcut) { + browser.commands.reset(name).catch(ignoreChromeError); + this.setCustomValidity(''); + return; + } + try { + browser.commands.update({name, shortcut}).then( + () => this.setCustomValidity(''), + err => this.setCustomValidity(err) + ); + } catch (err) { + this.setCustomValidity(err); + } + } } -}; + + function enforceInputRange(element) { + const min = Number(element.min); + const max = Number(element.max); + const doNotify = () => element.dispatchEvent(new Event('change', {bubbles: true})); + const onChange = ({type}) => { + if (type === 'input' && element.checkValidity()) { + doNotify(); + } else if (type === 'change' && !element.checkValidity()) { + element.value = Math.max(min, Math.min(max, Number(element.value))); + doNotify(); + } + }; + element.on('change', onChange); + element.on('input', onChange); + } + + function splitLongTooltips() { + for (const el of $$('[title]')) { + el.dataset.title = el.title; + el.title = el.title.replace(/<\/?\w+>/g, ''); // strip html tags + if (el.title.length < 50) { + continue; + } + const newTitle = el.title + .split('\n') + .map(s => s.replace(/([^.][.。?!]|.{50,60},)\s+/g, '$1\n')) + .map(s => s.replace(/(.{50,80}(?=.{40,}))\s+/g, '$1\n')) + .join('\n'); + if (newTitle !== el.title) el.title = newTitle; + } + } + + function setupRadioButtons() { + const sets = {}; + const onChange = function () { + const newValue = sets[this.name].indexOf(this); + if (newValue >= 0 && prefs.get(this.name) !== newValue) { + prefs.set(this.name, newValue); + } + }; + // group all radio-inputs by name="prefName" attribute + for (const el of $$('input[type="radio"][name]')) { + (sets[el.name] = sets[el.name] || []).push(el); + el.on('change', onChange); + } + // select the input corresponding to the actual pref value + for (const name in sets) { + sets[name][prefs.get(name)].checked = true; + } + // listen to pref changes and update the values + prefs.subscribe(Object.keys(sets), (key, value) => { + sets[key][value].checked = true; + }); + } + + window.onkeydown = e => { + if (getEventKeyName(e) === 'Escape') { + top.dispatchEvent(new CustomEvent('closeOptions')); + } + }; +}); diff --git a/popup.html b/popup.html index 5a894c16..dc9fa803 100644 --- a/popup.html +++ b/popup.html @@ -178,35 +178,24 @@ i18n-title="searchResultNotMatchingNote">

+ + - - + + - - - - - - - + + - - - - - - - - - - - diff --git a/popup/events.js b/popup/events.js new file mode 100644 index 00000000..9c4cb9d6 --- /dev/null +++ b/popup/events.js @@ -0,0 +1,226 @@ +'use strict'; + +define(require => { + const {API} = require('/js/msg'); + const {getActiveTab, tryJSONparse} = require('/js/toolbox'); + const t = require('/js/localization'); + const { + $, + $$, + $remove, + animateElement, + getEventKeyName, + moveFocus, + } = require('/js/dom'); + + const MODAL_SHOWN = 'data-display'; // attribute name + + /** @type {PopupEvents} */ + let exports; + const { + + closeExplanation, + getClickedStyleElement, + getClickedStyleId, + getExcludeRule, + hideModal, + openURLandHide, + showModal, + thisTab, + + } = exports = /** @namespace PopupEvents */ { + + thisTab: {url: ''}, + + closeExplanation() { + $('#regexp-explanation').remove(); + }, + + async configure(event) { + const {styleId, styleIsUsercss} = getClickedStyleElement(event); + if (styleIsUsercss) { + const style = await API.styles.get(styleId); + const hotkeys = await require(['./hotkeys']); + hotkeys.setState(false); + const configDialog = await require(['/js/dlg/config-dialog']); + await configDialog(style); + hotkeys.setState(true); + } else { + openURLandHide.call(this, event); + } + }, + + copyContent(event) { + event.preventDefault(); + const target = document.activeElement; + const message = $('.copy-message'); + navigator.clipboard.writeText(target.textContent); + target.classList.add('copied'); + message.classList.add('show-message'); + setTimeout(() => { + target.classList.remove('copied'); + message.classList.remove('show-message'); + }, 1000); + }, + + delete(event) { + const entry = getClickedStyleElement(event); + const box = $('#confirm'); + box.dataset.id = entry.styleId; + $('b', box).textContent = $('.style-name', entry).textContent; + showModal(box, '[data-cmd=cancel]'); + }, + + getClickedStyleId(event) { + return (getClickedStyleElement(event) || {}).styleId; + }, + + getClickedStyleElement(event) { + return event.target.closest('.entry'); + }, + + getExcludeRule(type) { + const u = new URL(thisTab.url); + return type === 'domain' + ? u.origin + '/*' + : escapeGlob(u.origin + u.pathname); // current page + }, + + async hideModal(box, {animate} = {}) { + window.off('keydown', box._onkeydown); + box._onkeydown = null; + if (animate) { + box.style.animationName = ''; + await animateElement(box, 'lights-on'); + } + box.removeAttribute(MODAL_SHOWN); + }, + + indicator(event) { + const entry = getClickedStyleElement(event); + const info = t.template.regexpProblemExplanation.cloneNode(true); + $remove('#' + info.id); + $$('a', info).forEach(el => (el.onclick = openURLandHide)); + $$('button', info).forEach(el => (el.onclick = closeExplanation)); + entry.appendChild(info); + }, + + isStyleExcluded({exclusions}, type) { + if (!exclusions) { + return false; + } + const rule = getExcludeRule(type); + return exclusions.includes(rule); + }, + + maybeEdit(event) { + if (!( + event.button === 0 && (event.ctrlKey || event.metaKey) || + event.button === 1 || + event.button === 2)) { + return; + } + // open an editor on middleclick + const el = event.target; + if (el.matches('.entry, .style-edit-link') || el.closest('.style-name')) { + this.onmouseup = () => $('.style-edit-link', this).click(); + this.oncontextmenu = event => event.preventDefault(); + event.preventDefault(); + return; + } + // prevent the popup being opened in a background tab + // when an irrelevant link was accidentally clicked + if (el.closest('a')) { + event.preventDefault(); + return; + } + }, + + name(event) { + $('input', this).dispatchEvent(new MouseEvent('click')); + event.preventDefault(); + }, + + async openEditor(event, options) { + event.preventDefault(); + await API.openEditor(options); + window.close(); + }, + + async openManager(event) { + event.preventDefault(); + const isSearch = thisTab.url && (event.shiftKey || event.button === 2); + await API.openManage(isSearch ? {search: thisTab.url, searchMode: 'url'} : {}); + window.close(); + }, + + async openURLandHide(event) { + event.preventDefault(); + await API.openURL({ + url: this.href || this.dataset.href, + index: (await getActiveTab()).index + 1, + message: tryJSONparse(this.dataset.sendMessage), + }); + window.close(); + }, + + showModal(box, cancelButtonSelector) { + const oldBox = $(`[${MODAL_SHOWN}]`); + if (oldBox) box.style.animationName = 'none'; + // '' would be fine but 'true' is backward-compatible with the existing userstyles + box.setAttribute(MODAL_SHOWN, 'true'); + box._onkeydown = e => { + const key = getEventKeyName(e); + switch (key) { + case 'Tab': + case 'Shift-Tab': + e.preventDefault(); + moveFocus(box, e.shiftKey ? -1 : 1); + break; + case 'Escape': { + e.preventDefault(); + window.onkeydown = null; + $(cancelButtonSelector, box).click(); + break; + } + } + }; + window.on('keydown', box._onkeydown); + moveFocus(box, 0); + hideModal(oldBox); + }, + + async toggleState(event) { + // when fired on checkbox, prevent the parent label from seeing the event, see #501 + event.stopPropagation(); + await API.styles.toggle(getClickedStyleId(event), this.checked); + require(['./popup'], res => res.resortEntries()); + }, + + toggleExclude(event, type) { + const entry = getClickedStyleElement(event); + if (event.target.checked) { + API.styles.addExclusion(entry.styleMeta.id, getExcludeRule(type)); + } else { + API.styles.removeExclusion(entry.styleMeta.id, getExcludeRule(type)); + } + }, + + toggleMenu(event) { + const entry = getClickedStyleElement(event); + const menu = $('.menu', entry); + if (menu.hasAttribute(MODAL_SHOWN)) { + hideModal(menu, {animate: true}); + } else { + $('.menu-title', entry).textContent = $('.style-name', entry).textContent; + showModal(menu, '.menu-close'); + } + }, + }; + + function escapeGlob(text) { + return text.replace(/\*/g, '\\*'); + } + + return exports; +}); diff --git a/popup/hotkeys.js b/popup/hotkeys.js index 511f096c..690d2f55 100644 --- a/popup/hotkeys.js +++ b/popup/hotkeys.js @@ -1,35 +1,35 @@ -/* global $ $$ API debounce $create t */ 'use strict'; -/* exported hotkeys */ -const hotkeys = (() => { +define(require => { + const {API} = require('/js/msg'); + const {debounce} = require('/js/toolbox'); + const {$, $$, $create} = require('/js/dom'); + const t = require('/js/localization'); + const entries = document.getElementsByClassName('entry'); let togglablesShown; let togglables; let enabled = false; let ready = false; - window.addEventListener('showStyles:done', () => { - togglablesShown = true; - togglables = getTogglables(); - ready = true; - setState(true); - initHotkeyInfo(); - }, {once: true}); + const hotkeys = { - window.addEventListener('resize', adjustInfoPosition); + initHotkeys() { + togglablesShown = true; + togglables = getTogglables(); + ready = true; + hotkeys.setState(true); + window.on('resize', adjustInfoPosition); + initHotkeyInfo(); + }, - return {setState}; - - function setState(newState = !enabled) { - if (!ready) { - throw new Error('hotkeys no ready'); - } - if (newState !== enabled) { - window[`${newState ? 'add' : 'remove'}EventListener`]('keydown', onKeyDown); - enabled = newState; - } - } + setState(newState = !enabled) { + if (newState !== enabled && ready) { + window[newState ? 'on' : 'off']('keydown', onKeyDown); + enabled = newState; + } + }, + }; function onKeyDown(event) { if (event.ctrlKey || event.altKey || event.metaKey || !enabled || @@ -116,11 +116,11 @@ const hotkeys = (() => { delete container.dataset.active; document.body.style.height = ''; container.title = title; - window.addEventListener('resize', adjustInfoPosition); + window.on('resize', adjustInfoPosition); } function open() { - window.removeEventListener('resize', adjustInfoPosition); + window.off('resize', adjustInfoPosition); debounce.unregister(adjustInfoPosition); title = container.title; container.title = ''; @@ -172,4 +172,6 @@ const hotkeys = (() => { return; } } -})(); + + return hotkeys; +}); diff --git a/popup/popup-preinit.js b/popup/popup-preinit.js deleted file mode 100644 index 3e3693e2..00000000 --- a/popup/popup-preinit.js +++ /dev/null @@ -1,87 +0,0 @@ -/* global - API - URLS -*/ -'use strict'; - -const ABOUT_BLANK = 'about:blank'; -/* exported tabURL */ -/** @type string */ -let tabURL; - -/* exported initializing */ -const initializing = (async () => { - let [tab] = await browser.tabs.query({currentWindow: true, active: true}); - if (!chrome.app && tab.status === 'loading' && tab.url === 'about:blank') { - tab = await waitForTabUrlFF(tab); - } - const frames = sortTabFrames(await browser.webNavigation.getAllFrames({tabId: tab.id})); - let url = tab.pendingUrl || tab.url || ''; // new Chrome uses pendingUrl while connecting - if (url === 'chrome://newtab/' && !URLS.chromeProtectsNTP) { - url = frames[0].url || ''; - } - if (!URLS.supported(url)) { - url = ''; - frames.length = 1; - } - tabURL = frames[0].url = url; - const uniqFrames = frames.filter(f => f.url && !f.isDupe); - const styles = await Promise.all(uniqFrames.map(getFrameStyles)); - return {frames, styles}; - - async function getFrameStyles({url}) { - return { - url, - styles: await getStyleDataMerged(url), - }; - } - - /** @param {chrome.webNavigation.GetAllFrameResultDetails[]} frames */ - function sortTabFrames(frames) { - const unknown = new Map(frames.map(f => [f.frameId, f])); - const known = new Map([[0, unknown.get(0) || {frameId: 0, url: ''}]]); - unknown.delete(0); - let lastSize = 0; - while (unknown.size !== lastSize) { - for (const [frameId, f] of unknown) { - if (known.has(f.parentFrameId)) { - unknown.delete(frameId); - if (!f.errorOccurred) known.set(frameId, f); - if (f.url === ABOUT_BLANK) f.url = known.get(f.parentFrameId).url; - } - } - lastSize = unknown.size; // guard against an infinite loop due to a weird frame structure - } - const sortedFrames = [...known.values(), ...unknown.values()]; - const urls = new Set([ABOUT_BLANK]); - for (const f of sortedFrames) { - if (!f.url) f.url = ''; - f.isDupe = urls.has(f.url); - urls.add(f.url); - } - return sortedFrames; - } - - function waitForTabUrlFF(tab) { - return new Promise(resolve => { - browser.tabs.onUpdated.addListener(...[ - function onUpdated(tabId, info, updatedTab) { - if (info.url && tabId === tab.id) { - browser.tabs.onUpdated.removeListener(onUpdated); - resolve(updatedTab); - } - }, - ...'UpdateFilter' in browser.tabs ? [{tabId: tab.id}] : [], - // TODO: remove both spreads and tabId check when strict_min_version >= 61 - ]); - }); - } -})(); - -/* Merges the extra props from API into style data. - * When `id` is specified returns a single object otherwise an array */ -async function getStyleDataMerged(url, id) { - const styles = (await API.styles.getByUrl(url, id)) - .map(r => Object.assign(r.style, r)); - return id ? styles[0] : styles; -} diff --git a/popup/popup.css b/popup/popup.css index 40369b72..7936ee02 100644 --- a/popup/popup.css +++ b/popup/popup.css @@ -112,6 +112,10 @@ body > div:not(#installed):not(#message-box):not(.colorpicker-popup) { cursor: pointer; margin-right: .5em; } +#find-styles-inline-group label { + position: relative; + padding-left: 16px; +} .checker { display: inline; diff --git a/popup/popup.js b/popup/popup.js index 1c5fe508..7d948364 100644 --- a/popup/popup.js +++ b/popup/popup.js @@ -1,657 +1,459 @@ -/* global - $ - $$ - $create - animateElement - ABOUT_BLANK - API - CHROME - CHROME_HAS_BORDER_BUG - configDialog - FIREFOX - getActiveTab - getEventKeyName - getStyleDataMerged - hotkeys - initializing - moveFocus - msg - onDOMready - prefs - setupLivePrefs - t - tabURL - tryJSONparse - URLS -*/ - 'use strict'; -/** @type Element */ -let installed; -const handleEvent = {}; +define(require => { + const {API, msg} = require('/js/msg'); + const {isEmptyObj} = require('/js/polyfill'); + const { + CHROME, + CHROME_HAS_BORDER_BUG, + FIREFOX, + URLS, + getActiveTab, + } = require('/js/toolbox'); + const { + ABOUT_BLANK, + getStyleDataMerged, + initializing, + } = require('./preinit'); + const t = require('/js/localization'); + const { + $, + $$, + $create, + setupLivePrefs, + } = require('/js/dom'); + const prefs = require('/js/prefs'); + const Events = require('./events'); -const ENTRY_ID_PREFIX_RAW = 'style-'; -const MODAL_SHOWN = 'data-display'; // attribute name + /** @type Element */ + let installed; + let tabURL; + const ENTRY_ID_PREFIX_RAW = 'style-'; -$.entry = styleOrId => $(`#${ENTRY_ID_PREFIX_RAW}${styleOrId.id || styleOrId}`); - -if (CHROME >= 66 && CHROME <= 69) { // Chrome 66-69 adds a gap, https://crbug.com/821143 - document.head.appendChild($create('style', 'html { overflow: overlay }')); -} - -toggleSideBorders(); - -Promise.all([ - initializing, - onDOMready(), -]).then(([ - {frames, styles}, -]) => { - toggleUiSliders(); - initPopup(frames); - if (styles[0]) { - showStyles(styles); - } else { - // unsupported URL; - $('#popup-manage-button').removeAttribute('title'); - } -}); - -msg.onExtension(onRuntimeMessage); - -prefs.subscribe(['popup.stylesFirst'], (key, stylesFirst) => { - const actions = $('body > .actions'); - const before = stylesFirst ? actions : actions.nextSibling; - document.body.insertBefore(installed, before); -}); -prefs.subscribe(['popupWidth'], (key, value) => setPopupWidth(value)); - -if (CHROME_HAS_BORDER_BUG) { - prefs.subscribe(['popup.borders'], (key, value) => toggleSideBorders(value)); -} - -function onRuntimeMessage(msg) { - if (!tabURL) return; - let ready = Promise.resolve(); - switch (msg.method) { - case 'styleAdded': - case 'styleUpdated': - if (msg.reason === 'editPreview' || msg.reason === 'editPreviewEnd') return; - ready = handleUpdate(msg); - break; - case 'styleDeleted': - handleDelete(msg.style.id); - break; - } - ready.then(() => dispatchEvent(new CustomEvent(msg.method, {detail: msg}))); -} - - -function setPopupWidth(width = prefs.get('popupWidth')) { - document.body.style.width = - Math.max(200, Math.min(800, width)) + 'px'; -} - - -function toggleSideBorders(state = prefs.get('popup.borders')) { - // runs before is parsed - const style = document.documentElement.style; - if (CHROME_HAS_BORDER_BUG && state) { - style.cssText += - 'border-left: 2px solid white !important;' + - 'border-right: 2px solid white !important;'; - } else if (style.cssText) { - style.borderLeft = style.borderRight = ''; - } -} - -function toggleUiSliders() { - const sliders = prefs.get('ui.sliders'); - const slot = $('toggle', t.template.style); - const toggle = t.template[sliders ? 'toggleSlider' : 'toggleChecker']; - slot.parentElement.replaceChild(toggle.cloneNode(true), slot); - document.body.classList.toggle('has-sliders', sliders); -} - -/** @param {chrome.webNavigation.GetAllFrameResultDetails[]} frames */ -async function initPopup(frames) { - installed = $('#installed'); - - setPopupWidth(); - - // action buttons - $('#disableAll').onchange = function () { - installed.classList.toggle('disabled', this.checked); - }; - setupLivePrefs(); - - Object.assign($('#popup-manage-button'), { - onclick: handleEvent.openManager, - oncontextmenu: handleEvent.openManager, + initializing.then(({frames, styles, url}) => { + tabURL = url; + Events.thisTab.url = url; + toggleUiSliders(); + initPopup(frames); + if (styles[0]) { + showStyles(styles); + } else { + // unsupported URL; + $('#popup-manage-button').removeAttribute('title'); + } }); - $('#popup-options-button').onclick = () => { - API.openManage({options: true}); - window.close(); - }; + $.entry = styleOrId => $(`#${ENTRY_ID_PREFIX_RAW}${styleOrId.id || styleOrId}`); - $('#popup-wiki-button').onclick = handleEvent.openURLandHide; + msg.onExtension(onRuntimeMessage); - $('#confirm').onclick = function (e) { - const {id} = this.dataset; - switch (e.target.dataset.cmd) { - case 'ok': - hideModal(this, {animate: true}); - API.styles.delete(Number(id)); + prefs.subscribe('popup.stylesFirst', (key, stylesFirst) => { + const actions = $('body > .actions'); + const before = stylesFirst ? actions : actions.nextSibling; + document.body.insertBefore(installed, before); + }); + if (CHROME_HAS_BORDER_BUG) { + prefs.subscribe('popup.borders', toggleSideBorders, {runNow: true}); + } + if (CHROME >= 66 && CHROME <= 69) { // Chrome 66-69 adds a gap, https://crbug.com/821143 + document.head.appendChild($create('style', 'html { overflow: overlay }')); + } + + function onRuntimeMessage(msg) { + if (!tabURL) return; + let ready = Promise.resolve(); + switch (msg.method) { + case 'styleAdded': + case 'styleUpdated': + if (msg.reason === 'editPreview' || msg.reason === 'editPreviewEnd') return; + ready = handleUpdate(msg); break; - case 'cancel': - showModal($('.menu', $.entry(id)), '.menu-close'); + case 'styleDeleted': + handleDelete(msg.style.id); break; } - }; - - if (!prefs.get('popup.stylesFirst')) { - document.body.insertBefore( - $('body > .actions'), - installed); + ready.then(() => dispatchEvent(new CustomEvent(msg.method, {detail: msg}))); } - for (const el of $$('link[media=print]')) { - el.removeAttribute('media'); + function setPopupWidth(_key, width) { + document.body.style.width = + Math.max(200, Math.min(800, width)) + 'px'; } - if (!tabURL) { - blockPopup(); - return; + function toggleSideBorders(_key, state) { + // runs before is parsed + const style = document.documentElement.style; + if (state) { + style.cssText += + 'border-left: 2px solid white !important;' + + 'border-right: 2px solid white !important;'; + } else if (style.cssText) { + style.borderLeft = style.borderRight = ''; + } } - frames.forEach(createWriterElement); - if (frames.length > 1) { - const el = $('#write-for-frames'); - el.hidden = false; - el.onclick = () => el.classList.toggle('expanded'); + function toggleUiSliders() { + const sliders = prefs.get('ui.sliders'); + const slot = $('toggle', t.template.style); + const toggle = t.template[sliders ? 'toggleSlider' : 'toggleChecker']; + slot.parentElement.replaceChild(toggle.cloneNode(true), slot); + document.body.classList.toggle('has-sliders', sliders); } - const isStore = tabURL.startsWith(URLS.browserWebStore); - if (isStore && !FIREFOX) { - blockPopup(); - return; - } + /** @param {chrome.webNavigation.GetAllFrameResultDetails[]} frames */ + async function initPopup(frames) { + installed = $('#installed'); - for (let retryCountdown = 10; retryCountdown-- > 0;) { - const tab = await getActiveTab(); - if (await msg.sendTab(tab.id, {method: 'ping'}, {frameId: 0}).catch(() => {})) { + prefs.subscribe('popupWidth', setPopupWidth, {runNow: true}); + + // action buttons + $('#disableAll').onchange = function () { + installed.classList.toggle('disabled', this.checked); + }; + setupLivePrefs(); + + Object.assign($('#find-styles-link'), { + href: URLS.usoArchive, + onclick(e) { + e.preventDefault(); + require(['./search'], res => res.onclick.call(this, e)); + }, + }); + + Object.assign($('#popup-manage-button'), { + onclick: Events.openManager, + oncontextmenu: Events.openManager, + }); + + $('#popup-options-button').onclick = () => { + API.openManage({options: true}); + window.close(); + }; + + $('#popup-wiki-button').onclick = Events.openURLandHide; + + $('#confirm').onclick = function (e) { + const {id} = this.dataset; + switch (e.target.dataset.cmd) { + case 'ok': + Events.hideModal(this, {animate: true}); + API.styles.delete(Number(id)); + break; + case 'cancel': + Events.showModal($('.menu', $.entry(id)), '.menu-close'); + break; + } + }; + + if (!prefs.get('popup.stylesFirst')) { + document.body.insertBefore( + $('body > .actions'), + installed); + } + + for (const el of $$('link[media=print]')) { + el.removeAttribute('media'); + } + + if (!tabURL) { + blockPopup(); return; } - if (tab.status === 'complete' && (!FIREFOX || tab.url !== ABOUT_BLANK)) { - break; + + frames.forEach(createWriterElement); + if (frames.length > 1) { + const el = $('#write-for-frames'); + el.hidden = false; + el.onclick = () => el.classList.toggle('expanded'); } - // FF and some Chrome forks (e.g. CentBrowser) implement tab-on-demand - // so we'll wait a bit to handle popup being invoked right after switching - await new Promise(resolve => setTimeout(resolve, 100)); - } - initUnreachable(isStore); -} - -function initUnreachable(isStore) { - const info = t.template.unreachableInfo; - if (!FIREFOX) { - // Chrome "Allow access to file URLs" in chrome://extensions message - info.appendChild($create('p', t('unreachableFileHint'))); - } else if (isStore) { - $('label', info).textContent = t('unreachableAMO'); - const note = (FIREFOX < 59 ? t('unreachableAMOHintOldFF') : t('unreachableAMOHint')) + - (FIREFOX < 60 ? '' : '\n' + t('unreachableAMOHintNewFF')); - const renderToken = s => s[0] === '<' - ? $create('a', { - textContent: s.slice(1, -1), - onclick: handleEvent.copyContent, - href: '#', - className: 'copy', - tabIndex: 0, - title: t('copy'), - }) - : s; - const renderLine = line => $create('p', line.split(/(<.*?>)/).map(renderToken)); - const noteNode = $create('fragment', note.split('\n').map(renderLine)); - info.appendChild(noteNode); - } - // Inaccessible locally hosted file type, e.g. JSON, PDF, etc. - if (tabURL.length - tabURL.lastIndexOf('.') <= 5) { - info.appendChild($create('p', t('InaccessibleFileHint'))); - } - document.body.classList.add('unreachable'); - document.body.insertBefore(info, document.body.firstChild); -} - -/** @param {chrome.webNavigation.GetAllFrameResultDetails} frame */ -function createWriterElement(frame) { - const {url, frameId, parentFrameId, isDupe} = frame; - const targets = $create('span'); - - // For this URL - const urlLink = t.template.writeStyle.cloneNode(true); - const isAboutBlank = url === ABOUT_BLANK; - Object.assign(urlLink, { - href: 'edit.html?url-prefix=' + encodeURIComponent(url), - title: `url-prefix("${url}")`, - tabIndex: isAboutBlank ? -1 : 0, - textContent: prefs.get('popup.breadcrumbs.usePath') - ? new URL(url).pathname.slice(1) - : frameId - ? isAboutBlank ? url : 'URL' - : t('writeStyleForURL').replace(/ /g, '\u00a0'), // this URL - onclick: e => handleEvent.openEditor(e, {'url-prefix': url}), - }); - if (prefs.get('popup.breadcrumbs')) { - urlLink.onmouseenter = - urlLink.onfocus = () => urlLink.parentNode.classList.add('url()'); - urlLink.onmouseleave = - urlLink.onblur = () => urlLink.parentNode.classList.remove('url()'); - } - targets.appendChild(urlLink); - - // For domain - const domains = getDomains(url); - for (const domain of domains) { - const numParts = domain.length - domain.replace(/\./g, '').length + 1; - // Don't include TLD - if (domains.length > 1 && numParts === 1) { - continue; + const isStore = tabURL.startsWith(URLS.browserWebStore); + if (isStore && !FIREFOX) { + blockPopup(); + return; } - const domainLink = t.template.writeStyle.cloneNode(true); - Object.assign(domainLink, { - href: 'edit.html?domain=' + encodeURIComponent(domain), - textContent: numParts > 2 ? domain.split('.')[0] : domain, - title: `domain("${domain}")`, - onclick: e => handleEvent.openEditor(e, {domain}), - }); - domainLink.setAttribute('subdomain', numParts > 1 ? 'true' : ''); - targets.appendChild(domainLink); - } - if (prefs.get('popup.breadcrumbs')) { - targets.classList.add('breadcrumbs'); - targets.appendChild(urlLink); // making it the last element - } - - const root = $('#write-style'); - const parent = $(`[data-frame-id="${parentFrameId}"]`, root) || root; - const child = $create({ - tag: 'span', - className: `match${isDupe ? ' dupe' : ''}${isAboutBlank ? ' about-blank' : ''}`, - dataset: {frameId}, - appendChild: targets, - }); - parent.appendChild(child); - parent.dataset.children = (Number(parent.dataset.children) || 0) + 1; -} - -function getDomains(url) { - let d = url.split(/[/:]+/, 2)[1]; - if (!d || url.startsWith('file:')) { - return []; - } - const domains = [d]; - while (d.includes('.')) { - d = d.substring(d.indexOf('.') + 1); - domains.push(d); - } - return domains; -} - -function sortStyles(entries) { - const enabledFirst = prefs.get('popup.enabledFirst'); - return entries.sort(({styleMeta: a}, {styleMeta: b}) => - Boolean(a.frameUrl) - Boolean(b.frameUrl) || - enabledFirst && Boolean(b.enabled) - Boolean(a.enabled) || - (a.customName || a.name).localeCompare(b.customName || b.name)); -} - -function showStyles(frameResults) { - const entries = new Map(); - frameResults.forEach(({styles = [], url}, index) => { - styles.forEach(style => { - const {id} = style; - if (!entries.has(id)) { - style.frameUrl = index === 0 ? '' : url; - entries.set(id, createStyleElement(style)); + for (let retryCountdown = 10; retryCountdown-- > 0;) { + const tab = await getActiveTab(); + if (await msg.sendTab(tab.id, {method: 'ping'}, {frameId: 0}).catch(() => {})) { + return; } - }); - }); - if (entries.size) { - resortEntries([...entries.values()]); - } else { - installed.appendChild(t.template.noStyles); + if (tab.status === 'complete' && (!FIREFOX || tab.url !== ABOUT_BLANK)) { + break; + } + // FF and some Chrome forks (e.g. CentBrowser) implement tab-on-demand + // so we'll wait a bit to handle popup being invoked right after switching + await new Promise(resolve => setTimeout(resolve, 100)); + } + + initUnreachable(isStore); } - window.dispatchEvent(new Event('showStyles:done')); -} -function resortEntries(entries) { - // `entries` is specified only at startup, after that we respect the prefs - if (entries || prefs.get('popup.autoResort')) { - installed.append(...sortStyles(entries || $$('.entry', installed))); + function initUnreachable(isStore) { + const info = t.template.unreachableInfo; + if (!FIREFOX) { + // Chrome "Allow access to file URLs" in chrome://extensions message + info.appendChild($create('p', t('unreachableFileHint'))); + } else if (isStore) { + $('label', info).textContent = t('unreachableAMO'); + const note = (FIREFOX < 59 ? t('unreachableAMOHintOldFF') : t('unreachableAMOHint')) + + (FIREFOX < 60 ? '' : '\n' + t('unreachableAMOHintNewFF')); + const renderToken = s => s[0] === '<' + ? $create('a', { + textContent: s.slice(1, -1), + onclick: Events.copyContent, + href: '#', + className: 'copy', + tabIndex: 0, + title: t('copy'), + }) + : s; + const renderLine = line => $create('p', line.split(/(<.*?>)/).map(renderToken)); + const noteNode = $create('fragment', note.split('\n').map(renderLine)); + info.appendChild(noteNode); + } + // Inaccessible locally hosted file type, e.g. JSON, PDF, etc. + if (tabURL.length - tabURL.lastIndexOf('.') <= 5) { + info.appendChild($create('p', t('InaccessibleFileHint'))); + } + document.body.classList.add('unreachable'); + document.body.insertBefore(info, document.body.firstChild); } -} -function createStyleElement(style) { - let entry = $.entry(style); - if (!entry) { - entry = t.template.style.cloneNode(true); - Object.assign(entry, { - id: ENTRY_ID_PREFIX_RAW + style.id, - styleId: style.id, - styleIsUsercss: Boolean(style.usercssData), - onmousedown: handleEvent.maybeEdit, - styleMeta: style, - }); - Object.assign($('input', entry), { - onclick: handleEvent.toggle, - }); - const editLink = $('.style-edit-link', entry); - Object.assign(editLink, { - href: editLink.getAttribute('href') + style.id, - onclick: e => handleEvent.openEditor(e, {id: style.id}), - }); - const styleName = $('.style-name', entry); - Object.assign(styleName, { - htmlFor: ENTRY_ID_PREFIX_RAW + style.id, - onclick: handleEvent.name, - }); - styleName.appendChild(document.createTextNode(' ')); + /** @param {chrome.webNavigation.GetAllFrameResultDetails} frame */ + function createWriterElement(frame) { + const {url, frameId, parentFrameId, isDupe} = frame; + const targets = $create('span'); - const config = $('.configure', entry); - config.onclick = handleEvent.configure; - if (!style.usercssData) { - if (style.updateUrl && style.updateUrl.includes('?') && style.url) { - config.href = style.url; - config.target = '_blank'; - config.title = t('configureStyleOnHomepage'); - config.dataset.sendMessage = JSON.stringify({method: 'openSettings'}); - $('use', config).attributes['xlink:href'].nodeValue = '#svg-icon-config-uso'; - } else { + // For this URL + const urlLink = t.template.writeStyle.cloneNode(true); + const isAboutBlank = url === ABOUT_BLANK; + Object.assign(urlLink, { + href: 'edit.html?url-prefix=' + encodeURIComponent(url), + title: `url-prefix("${url}")`, + tabIndex: isAboutBlank ? -1 : 0, + textContent: prefs.get('popup.breadcrumbs.usePath') + ? new URL(url).pathname.slice(1) + : frameId + ? isAboutBlank ? url : 'URL' + : t('writeStyleForURL').replace(/ /g, '\u00a0'), // this URL + onclick: e => Events.openEditor(e, {'url-prefix': url}), + }); + if (prefs.get('popup.breadcrumbs')) { + urlLink.onmouseenter = + urlLink.onfocus = () => urlLink.parentNode.classList.add('url()'); + urlLink.onmouseleave = + urlLink.onblur = () => urlLink.parentNode.classList.remove('url()'); + } + targets.appendChild(urlLink); + + // For domain + const domains = getDomains(url); + for (const domain of domains) { + const numParts = domain.length - domain.replace(/\./g, '').length + 1; + // Don't include TLD + if (domains.length > 1 && numParts === 1) { + continue; + } + const domainLink = t.template.writeStyle.cloneNode(true); + Object.assign(domainLink, { + href: 'edit.html?domain=' + encodeURIComponent(domain), + textContent: numParts > 2 ? domain.split('.')[0] : domain, + title: `domain("${domain}")`, + onclick: e => Events.openEditor(e, {domain}), + }); + domainLink.setAttribute('subdomain', numParts > 1 ? 'true' : ''); + targets.appendChild(domainLink); + } + + if (prefs.get('popup.breadcrumbs')) { + targets.classList.add('breadcrumbs'); + targets.appendChild(urlLink); // making it the last element + } + + const root = $('#write-style'); + const parent = $(`[data-frame-id="${parentFrameId}"]`, root) || root; + const child = $create({ + tag: 'span', + className: `match${isDupe ? ' dupe' : ''}${isAboutBlank ? ' about-blank' : ''}`, + dataset: {frameId}, + appendChild: targets, + }); + parent.appendChild(child); + parent.dataset.children = (Number(parent.dataset.children) || 0) + 1; + } + + function getDomains(url) { + let d = url.split(/[/:]+/, 2)[1]; + if (!d || url.startsWith('file:')) { + return []; + } + const domains = [d]; + while (d.includes('.')) { + d = d.substring(d.indexOf('.') + 1); + domains.push(d); + } + return domains; + } + + function sortStyles(entries) { + const enabledFirst = prefs.get('popup.enabledFirst'); + return entries.sort(({styleMeta: a}, {styleMeta: b}) => + Boolean(a.frameUrl) - Boolean(b.frameUrl) || + enabledFirst && Boolean(b.enabled) - Boolean(a.enabled) || + (a.customName || a.name).localeCompare(b.customName || b.name)); + } + + function showStyles(frameResults) { + const entries = new Map(); + frameResults.forEach(({styles = [], url}, index) => { + styles.forEach(style => { + const {id} = style; + if (!entries.has(id)) { + style.frameUrl = index === 0 ? '' : url; + entries.set(id, createStyleElement(style)); + } + }); + }); + if (entries.size) { + resortEntries([...entries.values()]); + } else { + installed.appendChild(t.template.noStyles); + } + require(['./hotkeys'], m => m.initHotkeys()); + } + + function resortEntries(entries) { + // `entries` is specified only at startup, after that we respect the prefs + if (entries || prefs.get('popup.autoResort')) { + installed.append(...sortStyles(entries || $$('.entry', installed))); + } + } + + function createStyleElement(style) { + let entry = $.entry(style); + if (!entry) { + entry = t.template.style.cloneNode(true); + Object.assign(entry, { + id: ENTRY_ID_PREFIX_RAW + style.id, + styleId: style.id, + styleIsUsercss: Boolean(style.usercssData), + onmousedown: Events.maybeEdit, + styleMeta: style, + }); + Object.assign($('input', entry), { + onclick: Events.toggleState, + }); + const editLink = $('.style-edit-link', entry); + Object.assign(editLink, { + href: editLink.getAttribute('href') + style.id, + onclick: e => Events.openEditor(e, {id: style.id}), + }); + const styleName = $('.style-name', entry); + Object.assign(styleName, { + htmlFor: ENTRY_ID_PREFIX_RAW + style.id, + onclick: Events.name, + }); + styleName.appendChild(document.createTextNode(' ')); + + const config = $('.configure', entry); + config.onclick = Events.configure; + if (!style.usercssData) { + if (style.updateUrl && style.updateUrl.includes('?') && style.url) { + config.href = style.url; + config.target = '_blank'; + config.title = t('configureStyleOnHomepage'); + config.dataset.sendMessage = JSON.stringify({method: 'openSettings'}); + $('use', config).attributes['xlink:href'].nodeValue = '#svg-icon-config-uso'; + } else { + config.classList.add('hidden'); + } + } else if (isEmptyObj(style.usercssData.vars)) { config.classList.add('hidden'); } - } else if (Object.keys(style.usercssData.vars || {}).length === 0) { - config.classList.add('hidden'); + + $('.delete', entry).onclick = Events.delete; + + const indicator = t.template.regexpProblemIndicator.cloneNode(true); + indicator.appendChild(document.createTextNode('!')); + indicator.onclick = Events.indicator; + $('.main-controls', entry).appendChild(indicator); + + $('.menu-button', entry).onclick = Events.toggleMenu; + $('.menu-close', entry).onclick = Events.toggleMenu; + + $('.exclude-by-domain-checkbox', entry).onchange = e => Events.toggleExclude(e, 'domain'); + $('.exclude-by-url-checkbox', entry).onchange = e => Events.toggleExclude(e, 'url'); } - $('.delete', entry).onclick = handleEvent.delete; + style = Object.assign(entry.styleMeta, style); - const indicator = t.template.regexpProblemIndicator.cloneNode(true); - indicator.appendChild(document.createTextNode('!')); - indicator.onclick = handleEvent.indicator; - $('.main-controls', entry).appendChild(indicator); + entry.classList.toggle('disabled', !style.enabled); + entry.classList.toggle('enabled', style.enabled); + $('input', entry).checked = style.enabled; - $('.menu-button', entry).onclick = handleEvent.toggleMenu; - $('.menu-close', entry).onclick = handleEvent.toggleMenu; - - $('.exclude-by-domain-checkbox', entry).onchange = e => handleEvent.toggleExclude(e, 'domain'); - $('.exclude-by-url-checkbox', entry).onchange = e => handleEvent.toggleExclude(e, 'url'); - } - - style = Object.assign(entry.styleMeta, style); - - entry.classList.toggle('disabled', !style.enabled); - entry.classList.toggle('enabled', style.enabled); - $('input', entry).checked = style.enabled; - - const styleName = $('.style-name', entry); - styleName.lastChild.textContent = style.customName || style.name; - setTimeout(() => { - styleName.title = entry.styleMeta.sloppy ? - t('styleNotAppliedRegexpProblemTooltip') : - styleName.scrollWidth > styleName.clientWidth + 1 ? - styleName.textContent : ''; - }); - - entry.classList.toggle('not-applied', style.excluded || style.sloppy); - entry.classList.toggle('regexp-partial', style.sloppy); - - $('.exclude-by-domain-checkbox', entry).checked = styleExcluded(style, 'domain'); - $('.exclude-by-url-checkbox', entry).checked = styleExcluded(style, 'url'); - - $('.exclude-by-domain', entry).title = getExcludeRule('domain'); - $('.exclude-by-url', entry).title = getExcludeRule('url'); - - const {frameUrl} = style; - if (frameUrl) { - const sel = 'span.frame-url'; - const frameEl = $(sel, entry) || styleName.insertBefore($create(sel), styleName.lastChild); - frameEl.title = frameUrl; - frameEl.onmousedown = handleEvent.maybeEdit; - } - entry.classList.toggle('frame', Boolean(frameUrl)); - - return entry; -} - -function styleExcluded({exclusions}, type) { - if (!exclusions) { - return false; - } - const rule = getExcludeRule(type); - return exclusions.includes(rule); -} - -function getExcludeRule(type) { - const u = new URL(tabURL); - if (type === 'domain') { - return u.origin + '/*'; - } - // current page - return escapeGlob(u.origin + u.pathname); -} - -function escapeGlob(text) { - return text.replace(/\*/g, '\\*'); -} - -Object.assign(handleEvent, { - - getClickedStyleId(event) { - return (handleEvent.getClickedStyleElement(event) || {}).styleId; - }, - - getClickedStyleElement(event) { - return event.target.closest('.entry'); - }, - - name(event) { - $('input', this).dispatchEvent(new MouseEvent('click')); - event.preventDefault(); - }, - - async toggle(event) { - // when fired on checkbox, prevent the parent label from seeing the event, see #501 - event.stopPropagation(); - await API.styles.toggle(handleEvent.getClickedStyleId(event), this.checked); - resortEntries(); - }, - - toggleExclude(event, type) { - const entry = handleEvent.getClickedStyleElement(event); - if (event.target.checked) { - API.styles.addExclusion(entry.styleMeta.id, getExcludeRule(type)); - } else { - API.styles.removeExclusion(entry.styleMeta.id, getExcludeRule(type)); - } - }, - - toggleMenu(event) { - const entry = handleEvent.getClickedStyleElement(event); - const menu = $('.menu', entry); - if (menu.hasAttribute(MODAL_SHOWN)) { - hideModal(menu, {animate: true}); - } else { - $('.menu-title', entry).textContent = $('.style-name', entry).textContent; - showModal(menu, '.menu-close'); - } - }, - - delete(event) { - const entry = handleEvent.getClickedStyleElement(event); - const box = $('#confirm'); - box.dataset.id = entry.styleId; - $('b', box).textContent = $('.style-name', entry).textContent; - showModal(box, '[data-cmd=cancel]'); - }, - - configure(event) { - const {styleId, styleIsUsercss} = handleEvent.getClickedStyleElement(event); - if (styleIsUsercss) { - API.styles.get(styleId).then(style => { - hotkeys.setState(false); - configDialog(style).then(() => { - hotkeys.setState(true); - }); - }); - } else { - handleEvent.openURLandHide.call(this, event); - } - }, - - indicator(event) { - const entry = handleEvent.getClickedStyleElement(event); - const info = t.template.regexpProblemExplanation.cloneNode(true); - $.remove('#' + info.id); - $$('a', info).forEach(el => (el.onclick = handleEvent.openURLandHide)); - $$('button', info).forEach(el => (el.onclick = handleEvent.closeExplanation)); - entry.appendChild(info); - }, - - closeExplanation() { - $('#regexp-explanation').remove(); - }, - - openEditor(event, options) { - event.preventDefault(); - API.openEditor(options); - window.close(); - }, - - maybeEdit(event) { - if (!( - event.button === 0 && (event.ctrlKey || event.metaKey) || - event.button === 1 || - event.button === 2)) { - return; - } - // open an editor on middleclick - const el = event.target; - if (el.matches('.entry, .style-edit-link') || el.closest('.style-name')) { - this.onmouseup = () => $('.style-edit-link', this).click(); - this.oncontextmenu = event => event.preventDefault(); - event.preventDefault(); - return; - } - // prevent the popup being opened in a background tab - // when an irrelevant link was accidentally clicked - if (el.closest('a')) { - event.preventDefault(); - return; - } - }, - - openURLandHide(event) { - event.preventDefault(); - getActiveTab() - .then(activeTab => API.openURL({ - url: this.href || this.dataset.href, - index: activeTab.index + 1, - message: tryJSONparse(this.dataset.sendMessage), - })) - .then(window.close); - }, - - openManager(event) { - event.preventDefault(); - const isSearch = tabURL && (event.shiftKey || event.button === 2); - API.openManage(isSearch ? {search: tabURL, searchMode: 'url'} : {}); - window.close(); - }, - - copyContent(event) { - event.preventDefault(); - const target = document.activeElement; - const message = $('.copy-message'); - navigator.clipboard.writeText(target.textContent); - target.classList.add('copied'); - message.classList.add('show-message'); + const styleName = $('.style-name', entry); + styleName.lastChild.textContent = style.customName || style.name; setTimeout(() => { - target.classList.remove('copied'); - message.classList.remove('show-message'); - }, 1000); - }, -}); + styleName.title = entry.styleMeta.sloppy ? + t('styleNotAppliedRegexpProblemTooltip') : + styleName.scrollWidth > styleName.clientWidth + 1 ? + styleName.textContent : ''; + }); + entry.classList.toggle('not-applied', style.excluded || style.sloppy); + entry.classList.toggle('regexp-partial', style.sloppy); -async function handleUpdate({style, reason}) { - if (reason !== 'toggle' || !$.entry(style)) { - style = await getStyleDataMerged(tabURL, style.id); - if (!style) return; - } - const el = createStyleElement(style); - if (!el.parentNode) { - installed.appendChild(el); - blockPopup(false); - } - resortEntries(); -} + $('.exclude-by-domain-checkbox', entry).checked = Events.isStyleExcluded(style, 'domain'); + $('.exclude-by-url-checkbox', entry).checked = Events.isStyleExcluded(style, 'url'); + $('.exclude-by-domain', entry).title = Events.getExcludeRule('domain'); + $('.exclude-by-url', entry).title = Events.getExcludeRule('url'); -function handleDelete(id) { - const el = $.entry(id); - if (el) { - el.remove(); - if (!$('.entry')) installed.appendChild(t.template.noStyles); - } -} - -function blockPopup(isBlocked = true) { - document.body.classList.toggle('blocked', isBlocked); - if (isBlocked) { - document.body.prepend(t.template.unavailableInfo); - } else { - t.template.unavailableInfo.remove(); - t.template.noStyles.remove(); - } -} - -function showModal(box, cancelButtonSelector) { - const oldBox = $(`[${MODAL_SHOWN}]`); - if (oldBox) box.style.animationName = 'none'; - // '' would be fine but 'true' is backward-compatible with the existing userstyles - box.setAttribute(MODAL_SHOWN, 'true'); - box._onkeydown = e => { - const key = getEventKeyName(e); - switch (key) { - case 'Tab': - case 'Shift-Tab': - e.preventDefault(); - moveFocus(box, e.shiftKey ? -1 : 1); - break; - case 'Escape': { - e.preventDefault(); - window.onkeydown = null; - $(cancelButtonSelector, box).click(); - break; - } + const {frameUrl} = style; + if (frameUrl) { + const sel = 'span.frame-url'; + const frameEl = $(sel, entry) || styleName.insertBefore($create(sel), styleName.lastChild); + frameEl.title = frameUrl; + frameEl.onmousedown = Events.maybeEdit; } - }; - window.on('keydown', box._onkeydown); - moveFocus(box, 0); - hideModal(oldBox); -} + entry.classList.toggle('frame', Boolean(frameUrl)); -async function hideModal(box, {animate} = {}) { - window.off('keydown', box._onkeydown); - box._onkeydown = null; - if (animate) { - box.style.animationName = ''; - await animateElement(box, 'lights-on'); + return entry; } - box.removeAttribute(MODAL_SHOWN); -} + + async function handleUpdate({style, reason}) { + if (reason !== 'toggle' || !$.entry(style)) { + style = await getStyleDataMerged(tabURL, style.id); + if (!style) return; + } + const el = createStyleElement(style); + if (!el.parentNode) { + installed.appendChild(el); + blockPopup(false); + } + resortEntries(); + } + + function handleDelete(id) { + const el = $.entry(id); + if (el) { + el.remove(); + if (!$('.entry')) installed.appendChild(t.template.noStyles); + } + } + + function blockPopup(isBlocked = true) { + document.body.classList.toggle('blocked', isBlocked); + if (isBlocked) { + document.body.prepend(t.template.unavailableInfo); + } else { + t.template.unavailableInfo.remove(); + t.template.noStyles.remove(); + } + } + + return { + resortEntries, + }; +}); diff --git a/popup/preinit.js b/popup/preinit.js new file mode 100644 index 00000000..0e788db3 --- /dev/null +++ b/popup/preinit.js @@ -0,0 +1,85 @@ +'use strict'; + +define(require => { + const {API} = require('/js/msg'); + const {URLS} = require('/js/toolbox'); + require(['./popup']); // async + + const ABOUT_BLANK = 'about:blank'; + + /* Merges the extra props from API into style data. + * When `id` is specified returns a single object otherwise an array */ + async function getStyleDataMerged(url, id) { + const styles = (await API.styles.getByUrl(url, id)) + .map(r => Object.assign(r.style, r)); + return id ? styles[0] : styles; + } + + /** @param {browser.webNavigation._GetAllFramesReturnDetails[]} frames */ + function sortTabFrames(frames) { + const unknown = new Map(frames.map(f => [f.frameId, f])); + const known = new Map([[0, unknown.get(0) || {frameId: 0, url: ''}]]); + unknown.delete(0); + let lastSize = 0; + while (unknown.size !== lastSize) { + for (const [frameId, f] of unknown) { + if (known.has(f.parentFrameId)) { + unknown.delete(frameId); + if (!f.errorOccurred) known.set(frameId, f); + if (f.url === ABOUT_BLANK) f.url = known.get(f.parentFrameId).url; + } + } + lastSize = unknown.size; // guard against an infinite loop due to a weird frame structure + } + const sortedFrames = [...known.values(), ...unknown.values()]; + const urls = new Set([ABOUT_BLANK]); + for (const f of sortedFrames) { + if (!f.url) f.url = ''; + f.isDupe = urls.has(f.url); + urls.add(f.url); + } + return sortedFrames; + } + + function waitForTabUrlFF(tab) { + return new Promise(resolve => { + browser.tabs.onUpdated.addListener(...[ + function onUpdated(tabId, info, updatedTab) { + if (info.url && tabId === tab.id) { + browser.tabs.onUpdated.removeListener(onUpdated); + resolve(updatedTab); + } + }, + ...'UpdateFilter' in browser.tabs ? [{tabId: tab.id}] : [], + // TODO: remove both spreads and tabId check when strict_min_version >= 61 + ]); + }); + } + + return { + ABOUT_BLANK, + getStyleDataMerged, + initializing: (async () => { + let [tab] = await browser.tabs.query({currentWindow: true, active: true}); + if (!chrome.app && tab.status === 'loading' && tab.url === ABOUT_BLANK) { + tab = await waitForTabUrlFF(tab); + } + const frames = sortTabFrames(await browser.webNavigation.getAllFrames({tabId: tab.id})); + let url = tab.pendingUrl || tab.url || ''; // new Chrome uses pendingUrl while connecting + if (url === 'chrome://newtab/' && !URLS.chromeProtectsNTP) { + url = frames[0].url || ''; + } + if (!URLS.supported(url)) { + url = ''; + frames.length = 1; + } + frames[0].url = url; + const uniqFrames = frames.filter(f => f.url && !f.isDupe); + const styles = await Promise.all(uniqFrames.map(async ({url}) => ({ + url, + styles: await getStyleDataMerged(url), + }))); + return {frames, styles, url}; + })(), + }; +}); diff --git a/popup/search-results.css b/popup/search.css old mode 100755 new mode 100644 similarity index 98% rename from popup/search-results.css rename to popup/search.css index c20847ec..127738b4 --- a/popup/search-results.css +++ b/popup/search.css @@ -1,3 +1,7 @@ +/* IMPORTANT! + This file is loaded dynamically when the inline search is invoked. + So don't put main popup's stuff here. */ + body.search-results-shown { overflow-y: auto; overflow-x: hidden; @@ -255,11 +259,6 @@ body.search-results-shown { text-shadow: 0 1px 4px rgba(0, 0, 0, .5); } -#find-styles-inline-group label { - position: relative; - padding-left: 16px; -} - #search-params { display: flex; position: relative; diff --git a/popup/search-results.js b/popup/search.js old mode 100755 new mode 100644 similarity index 94% rename from popup/search-results.js rename to popup/search.js index dad898cb..8faaf6fd --- a/popup/search-results.js +++ b/popup/search.js @@ -1,22 +1,20 @@ -/* global - $ - $$ - $create - API - debounce - download - FIREFOX - handleEvent - prefs - t - tabURL - tryCatch - URLS -*/ 'use strict'; -window.addEventListener('showStyles:done', () => { - if (!tabURL) return; +define(require => { + const {API} = require('/js/msg'); + const { + FIREFOX, + URLS, + debounce, + download, + tryCatch, + } = require('/js/toolbox'); + const t = require('/js/localization'); + const {$, $$, $create, $remove} = require('/js/dom'); + const prefs = require('/js/prefs'); + const Events = require('./events'); + require('./search.css'); + const RESULT_ID_PREFIX = 'search-result-'; const INDEX_URL = URLS.usoArchiveRaw + 'search-index.json'; const STYLUS_CATEGORY = 'chrome-extension'; @@ -61,26 +59,23 @@ window.addEventListener('showStyles:done', () => { const show = sel => $class(sel).remove('hidden'); const hide = sel => $class(sel).add('hidden'); - Object.assign($('#find-styles-link'), { - href: URLS.usoArchive, + const exports = { + /** @this {HTMLAnchorElement} */ onclick(event) { if (!prefs.get('popup.findStylesInline') || dom.container) { // use a less specific category if the inline search wasn't used yet if (!category) calcCategory({retry: 1}); this.search = new URLSearchParams({category, search: $('#search-query').value}); - handleEvent.openURLandHide.call(this, event); + Events.openURLandHide.call(this, event); return; } - event.preventDefault(); this.textContent = this.title; this.title = ''; init(); calcCategory(); ready = start(); }, - }); - - return; + }; function init() { setTimeout(() => document.body.classList.add('search-results-shown')); @@ -127,7 +122,7 @@ window.addEventListener('showStyles:done', () => { if (FIREFOX) { let lastShift; - addEventListener('resize', () => { + window.on('resize', () => { const scrollbarWidth = window.innerWidth - document.scrollingElement.clientWidth; const shift = document.body.getBoundingClientRect().left; if (!scrollbarWidth || shift === lastShift) return; @@ -137,7 +132,7 @@ window.addEventListener('showStyles:done', () => { }, {passive: true}); } - addEventListener('styleDeleted', ({detail: {style: {id}}}) => { + window.on('styleDeleted', ({detail: {style: {id}}}) => { restoreScrollPosition(); const result = results.find(r => r.installedStyleId === id); if (result) { @@ -146,7 +141,7 @@ window.addEventListener('showStyles:done', () => { } }); - addEventListener('styleAdded', async ({detail: {style}}) => { + window.on('styleAdded', async ({detail: {style}}) => { restoreScrollPosition(); const usoId = calcUsoId(style) || calcUsoId(await API.styles.get(style.id)); @@ -285,7 +280,7 @@ window.addEventListener('showStyles:done', () => { entry.id = RESULT_ID_PREFIX + id; // title Object.assign($('.search-result-title', entry), { - onclick: handleEvent.openURLandHide, + onclick: Events.openURLandHide, href: URLS.usoArchive + `?category=${category}&style=${id}`, }); $('.search-result-title span', entry).textContent = @@ -304,7 +299,7 @@ window.addEventListener('showStyles:done', () => { textContent: author, title: author, href: URLS.usoArchive + '?author=' + encodeURIComponent(author).replace(/%20/g, '+'), - onclick: handleEvent.openURLandHide, + onclick: Events.openURLandHide, }); // rating $('[data-type="rating"]', entry).dataset.class = @@ -424,7 +419,7 @@ window.addEventListener('showStyles:done', () => { } catch (reason) { error(`Error while downloading usoID:${id}\nReason: ${reason}`); } - $.remove('.lds-spinner', entry); + $remove('.lds-spinner', entry); installButton.disabled = false; entry.style.pointerEvents = ''; } @@ -449,7 +444,7 @@ window.addEventListener('showStyles:done', () => { * @returns {boolean} true if the category has actually changed */ function calcCategory({retry} = {}) { - const u = tryCatch(() => new URL(tabURL)); + const u = tryCatch(() => new URL(Events.thisTab.url)); const old = category; if (!u) { // Invalid URL @@ -479,7 +474,7 @@ window.addEventListener('showStyles:done', () => { index = (await download(INDEX_URL, {responseType: 'json'})) .filter(res => res.f === 'uso'); clearTimeout(timer); - $.remove(':scope > .lds-spinner', dom.list); + $remove(':scope > .lds-spinner', dom.list); return index; } @@ -533,4 +528,6 @@ window.addEventListener('showStyles:done', () => { if (!res._year) res._year = new Date(res.u * 1000).getFullYear(); return res; } -}, {once: true}); + + return exports; +}); diff --git a/tools/build-vendor.js b/tools/build-vendor.js index 449ed144..e7620794 100644 --- a/tools/build-vendor.js +++ b/tools/build-vendor.js @@ -90,11 +90,10 @@ async function generateThemeList() { /* Do not edit. This file is auto-generated by build-vendor.js */ 'use strict'; - /* exported CODEMIRROR_THEMES */ - const CODEMIRROR_THEMES = [ + define([], [ ${ themes.map(t => ` '${t.replace(/'/g, '\\$&')}',\n`).join('') - }]; + }]); ` + '\n'; } @@ -161,10 +160,3 @@ function generateList(list) { return `* ${src}`; }).join('\n'); } - -// Rename CodeMirror$1 -> CodeMirror for development purposes -// FIXME: is this a workaround for old version of codemirror? -// function renameCodeMirrorVariable(filePath) { - // const file = fs.readFileSync(filePath, 'utf8'); - // fs.writeFileSync(filePath, file.replace(/CodeMirror\$1/g, 'CodeMirror')); -// } diff --git a/vendor-overwrites/codemirror-addon/match-highlighter.js b/vendor-overwrites/codemirror-addon/match-highlighter.js index cf2a53b0..1d0cc5bc 100644 --- a/vendor-overwrites/codemirror-addon/match-highlighter.js +++ b/vendor-overwrites/codemirror-addon/match-highlighter.js @@ -23,7 +23,10 @@ if (typeof exports == "object" && typeof module == "object") // CommonJS mod(require("../../lib/codemirror"), require("./matchesonscrollbar")); else if (typeof define == "function" && define.amd) // AMD - define(["../../lib/codemirror", "./matchesonscrollbar"], mod); + define([ + "/vendor/codemirror/lib/codemirror", + "/vendor/codemirror/addon/search/matchesonscrollbar", + ], mod); else // Plain browser env mod(CodeMirror); })(function(CodeMirror) { diff --git a/vendor-overwrites/colorpicker/colorconverter.js b/vendor-overwrites/colorpicker/colorconverter.js deleted file mode 100644 index 2889ef44..00000000 --- a/vendor-overwrites/colorpicker/colorconverter.js +++ /dev/null @@ -1,371 +0,0 @@ -'use strict'; - -const colorConverter = (() => { - - return { - parse, - format, - formatAlpha, - RGBtoHSV, - HSVtoRGB, - HSLtoHSV, - HSVtoHSL, - constrainHue, - snapToInt, - ALPHA_DIGITS: 3, - // NAMED_COLORS is added below - }; - - function format(color = '', type = color.type, hexUppercase) { - if (!color || !type) return typeof color === 'string' ? color : ''; - const a = formatAlpha(color.a); - const hasA = Boolean(a); - if (type === 'rgb' && color.type === 'hsl') { - color = HSVtoRGB(HSLtoHSV(color)); - } - const {r, g, b, h, s, l} = color; - switch (type) { - case 'hex': { - const rgbStr = (0x1000000 + (r << 16) + (g << 8) + (b | 0)).toString(16).slice(1); - const aStr = hasA ? (0x100 + Math.round(a * 255)).toString(16).slice(1) : ''; - const hexStr = `#${rgbStr + aStr}`.replace(/^#(.)\1(.)\2(.)\3(?:(.)\4)?$/, '#$1$2$3$4'); - return hexUppercase ? hexStr.toUpperCase() : hexStr.toLowerCase(); - } - case 'rgb': - return hasA ? - `rgba(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)}, ${a})` : - `rgb(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)})`; - case 'hsl': - return hasA ? - `hsla(${h}, ${s}%, ${l}%, ${a})` : - `hsl(${h}, ${s}%, ${l}%)`; - } - } - - // Copied from _hexcolor() in parserlib.js - function validateHex(color) { - return /^#[a-f\d]+$/i.test(color) && [4, 5, 7, 9].some(n => color.length === n); - } - - function validateRGB(nums) { - const isPercentage = nums[0].endsWith('%'); - const valid = isPercentage ? validatePercentage : validateNum; - return nums.slice(0, 3).every(valid); - } - - function validatePercentage(s) { - if (!s.endsWith('%')) return false; - const n = Number(s.slice(0, -1)); - return n >= 0 && n <= 100; - } - - function validateNum(s) { - const n = Number(s); - return n >= 0 && n <= 255; - } - - function validateHSL(nums) { - return validateAngle(nums[0]) && nums.slice(1, 3).every(validatePercentage); - } - - function validateAngle(s) { - return /^-?(\d+|\d*\.\d+)(deg|grad|rad|turn)?$/i.test(s); - } - - function validateAlpha(alpha) { - if (alpha.endsWith('%')) { - return validatePercentage(alpha); - } - const n = Number(alpha); - return n >= 0 && n <= 1; - } - - function parse(str) { - if (typeof str !== 'string') return; - str = str.trim(); - if (!str) return; - - if (str[0] !== '#' && !str.includes('(')) { - // eslint-disable-next-line no-use-before-define - str = colorConverter.NAMED_COLORS.get(str); - if (!str) return; - } - - if (str[0] === '#') { - if (!validateHex(str)) { - return null; - } - str = str.slice(1); - const [r, g, b, a = 255] = str.length <= 4 ? - str.match(/(.)/g).map(c => parseInt(c + c, 16)) : - str.match(/(..)/g).map(c => parseInt(c, 16)); - return {type: 'hex', r, g, b, a: a === 255 ? undefined : a / 255}; - } - - const [, type, value] = str.match(/^(rgb|hsl)a?\((.*?)\)|$/i); - if (!type) return; - - const comma = value.includes(',') && !value.includes('/'); - const num = value.trim().split(comma ? /\s*,\s*/ : /\s+(?!\/)|\s*\/\s*/); - if (num.length < 3 || num.length > 4) return; - if (num[3] && !validateAlpha(num[3])) return null; - - let a = !num[3] ? 1 : parseFloat(num[3]) / (num[3].endsWith('%') ? 100 : 1); - if (isNaN(a)) a = 1; - - const first = num[0]; - if (/rgb/i.test(type)) { - if (!validateRGB(num)) { - return null; - } - const k = first.endsWith('%') ? 2.55 : 1; - const [r, g, b] = num.map(s => Math.round(parseFloat(s) * k)); - return {type: 'rgb', r, g, b, a}; - } else { - if (!validateHSL(num)) { - return null; - } - let h = parseFloat(first); - if (first.endsWith('grad')) h *= 360 / 400; - else if (first.endsWith('rad')) h *= 180 / Math.PI; - else if (first.endsWith('turn')) h *= 360; - const s = parseFloat(num[1]); - const l = parseFloat(num[2]); - return {type: 'hsl', h, s, l, a}; - } - } - - function formatAlpha(a) { - return isNaN(a) ? '' : - (a + .5 * Math.pow(10, -colorConverter.ALPHA_DIGITS)) - .toFixed(colorConverter.ALPHA_DIGITS + 1) - .slice(0, -1) - .replace(/^0(?=\.[1-9])|^1\.0+?$|\.?0+$/g, ''); - } - - function RGBtoHSV({r, g, b, a}) { - r /= 255; - g /= 255; - b /= 255; - const MaxC = Math.max(r, g, b); - const MinC = Math.min(r, g, b); - const DeltaC = MaxC - MinC; - - let h = - DeltaC === 0 ? 0 : - MaxC === r ? 60 * (((g - b) / DeltaC) % 6) : - MaxC === g ? 60 * (((b - r) / DeltaC) + 2) : - MaxC === b ? 60 * (((r - g) / DeltaC) + 4) : - 0; - h = constrainHue(h); - return { - h, - s: MaxC === 0 ? 0 : DeltaC / MaxC, - v: MaxC, - a, - }; - } - - function HSVtoRGB({h, s, v}) { - h = constrainHue(h) % 360; - const C = s * v; - const X = C * (1 - Math.abs((h / 60) % 2 - 1)); - const m = v - C; - const [r, g, b] = - h >= 0 && h < 60 ? [C, X, 0] : - h >= 60 && h < 120 ? [X, C, 0] : - h >= 120 && h < 180 ? [0, C, X] : - h >= 180 && h < 240 ? [0, X, C] : - h >= 240 && h < 300 ? [X, 0, C] : - h >= 300 && h < 360 ? [C, 0, X] : []; - return { - r: snapToInt(Math.round((r + m) * 255)), - g: snapToInt(Math.round((g + m) * 255)), - b: snapToInt(Math.round((b + m) * 255)), - }; - } - - function HSLtoHSV({h, s, l, a}) { - const t = s * (l < 50 ? l : 100 - l) / 100; - return { - h: constrainHue(h), - s: t + l ? 200 * t / (t + l) / 100 : 0, - v: (t + l) / 100, - a, - }; - } - - function HSVtoHSL({h, s, v}) { - const l = (2 - s) * v / 2; - const t = l < .5 ? l * 2 : 2 - l * 2; - return { - h: Math.round(constrainHue(h)), - s: Math.round(t ? s * v / t * 100 : 0), - l: Math.round(l * 100), - }; - } - - function constrainHue(h) { - return h < 0 ? h % 360 + 360 : - h > 360 ? h % 360 : - h; - } - - function snapToInt(num) { - const int = Math.round(num); - return Math.abs(int - num) < 1e-3 ? int : num; - } -})(); - -colorConverter.NAMED_COLORS = new Map([ - ['transparent', 'rgba(0, 0, 0, 0)'], - // CSS4 named colors - ['aliceblue', '#f0f8ff'], - ['antiquewhite', '#faebd7'], - ['aqua', '#00ffff'], - ['aquamarine', '#7fffd4'], - ['azure', '#f0ffff'], - ['beige', '#f5f5dc'], - ['bisque', '#ffe4c4'], - ['black', '#000000'], - ['blanchedalmond', '#ffebcd'], - ['blue', '#0000ff'], - ['blueviolet', '#8a2be2'], - ['brown', '#a52a2a'], - ['burlywood', '#deb887'], - ['cadetblue', '#5f9ea0'], - ['chartreuse', '#7fff00'], - ['chocolate', '#d2691e'], - ['coral', '#ff7f50'], - ['cornflowerblue', '#6495ed'], - ['cornsilk', '#fff8dc'], - ['crimson', '#dc143c'], - ['cyan', '#00ffff'], - ['darkblue', '#00008b'], - ['darkcyan', '#008b8b'], - ['darkgoldenrod', '#b8860b'], - ['darkgray', '#a9a9a9'], - ['darkgrey', '#a9a9a9'], - ['darkgreen', '#006400'], - ['darkkhaki', '#bdb76b'], - ['darkmagenta', '#8b008b'], - ['darkolivegreen', '#556b2f'], - ['darkorange', '#ff8c00'], - ['darkorchid', '#9932cc'], - ['darkred', '#8b0000'], - ['darksalmon', '#e9967a'], - ['darkseagreen', '#8fbc8f'], - ['darkslateblue', '#483d8b'], - ['darkslategray', '#2f4f4f'], - ['darkslategrey', '#2f4f4f'], - ['darkturquoise', '#00ced1'], - ['darkviolet', '#9400d3'], - ['deeppink', '#ff1493'], - ['deepskyblue', '#00bfff'], - ['dimgray', '#696969'], - ['dimgrey', '#696969'], - ['dodgerblue', '#1e90ff'], - ['firebrick', '#b22222'], - ['floralwhite', '#fffaf0'], - ['forestgreen', '#228b22'], - ['fuchsia', '#ff00ff'], - ['gainsboro', '#dcdcdc'], - ['ghostwhite', '#f8f8ff'], - ['gold', '#ffd700'], - ['goldenrod', '#daa520'], - ['gray', '#808080'], - ['grey', '#808080'], - ['green', '#008000'], - ['greenyellow', '#adff2f'], - ['honeydew', '#f0fff0'], - ['hotpink', '#ff69b4'], - ['indianred', '#cd5c5c'], - ['indigo', '#4b0082'], - ['ivory', '#fffff0'], - ['khaki', '#f0e68c'], - ['lavender', '#e6e6fa'], - ['lavenderblush', '#fff0f5'], - ['lawngreen', '#7cfc00'], - ['lemonchiffon', '#fffacd'], - ['lightblue', '#add8e6'], - ['lightcoral', '#f08080'], - ['lightcyan', '#e0ffff'], - ['lightgoldenrodyellow', '#fafad2'], - ['lightgray', '#d3d3d3'], - ['lightgrey', '#d3d3d3'], - ['lightgreen', '#90ee90'], - ['lightpink', '#ffb6c1'], - ['lightsalmon', '#ffa07a'], - ['lightseagreen', '#20b2aa'], - ['lightskyblue', '#87cefa'], - ['lightslategray', '#778899'], - ['lightslategrey', '#778899'], - ['lightsteelblue', '#b0c4de'], - ['lightyellow', '#ffffe0'], - ['lime', '#00ff00'], - ['limegreen', '#32cd32'], - ['linen', '#faf0e6'], - ['magenta', '#ff00ff'], - ['maroon', '#800000'], - ['mediumaquamarine', '#66cdaa'], - ['mediumblue', '#0000cd'], - ['mediumorchid', '#ba55d3'], - ['mediumpurple', '#9370db'], - ['mediumseagreen', '#3cb371'], - ['mediumslateblue', '#7b68ee'], - ['mediumspringgreen', '#00fa9a'], - ['mediumturquoise', '#48d1cc'], - ['mediumvioletred', '#c71585'], - ['midnightblue', '#191970'], - ['mintcream', '#f5fffa'], - ['mistyrose', '#ffe4e1'], - ['moccasin', '#ffe4b5'], - ['navajowhite', '#ffdead'], - ['navy', '#000080'], - ['oldlace', '#fdf5e6'], - ['olive', '#808000'], - ['olivedrab', '#6b8e23'], - ['orange', '#ffa500'], - ['orangered', '#ff4500'], - ['orchid', '#da70d6'], - ['palegoldenrod', '#eee8aa'], - ['palegreen', '#98fb98'], - ['paleturquoise', '#afeeee'], - ['palevioletred', '#db7093'], - ['papayawhip', '#ffefd5'], - ['peachpuff', '#ffdab9'], - ['peru', '#cd853f'], - ['pink', '#ffc0cb'], - ['plum', '#dda0dd'], - ['powderblue', '#b0e0e6'], - ['purple', '#800080'], - ['rebeccapurple', '#663399'], - ['red', '#ff0000'], - ['rosybrown', '#bc8f8f'], - ['royalblue', '#4169e1'], - ['saddlebrown', '#8b4513'], - ['salmon', '#fa8072'], - ['sandybrown', '#f4a460'], - ['seagreen', '#2e8b57'], - ['seashell', '#fff5ee'], - ['sienna', '#a0522d'], - ['silver', '#c0c0c0'], - ['skyblue', '#87ceeb'], - ['slateblue', '#6a5acd'], - ['slategray', '#708090'], - ['slategrey', '#708090'], - ['snow', '#fffafa'], - ['springgreen', '#00ff7f'], - ['steelblue', '#4682b4'], - ['tan', '#d2b48c'], - ['teal', '#008080'], - ['thistle', '#d8bfd8'], - ['tomato', '#ff6347'], - ['turquoise', '#40e0d0'], - ['violet', '#ee82ee'], - ['wheat', '#f5deb3'], - ['white', '#ffffff'], - ['whitesmoke', '#f5f5f5'], - ['yellow', '#ffff00'], - ['yellowgreen', '#9acd32'], -]); diff --git a/vendor-overwrites/csslint/csslint.js b/vendor-overwrites/csslint/csslint.js deleted file mode 100644 index 9555890e..00000000 --- a/vendor-overwrites/csslint/csslint.js +++ /dev/null @@ -1,1778 +0,0 @@ -/* -Modded by tophf -========== Original disclaimer: - -Copyright (c) 2016 Nicole Sullivan and Nicholas C. Zakas. All rights reserved. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the 'Software'), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. -*/ - -/* global parserlib */ -'use strict'; - -//region Reporter - -class Reporter { - /** - * An instance of Report is used to report results of the - * verification back to the main API. - * @class Reporter - * @constructor - * @param {String[]} lines - The text lines of the source. - * @param {Object} ruleset - The set of rules to work with, including if - * they are errors or warnings. - * @param {Object} allow - explicitly allowed lines - * @param {[][]} ingore - list of line ranges to be ignored - */ - constructor(lines, ruleset, allow, ignore) { - this.messages = []; - this.stats = []; - this.lines = lines; - this.ruleset = ruleset; - this.allow = allow || {}; - this.ignore = ignore || []; - } - - error(message, line, col, rule = {}) { - this.messages.push({ - type: 'error', - evidence: this.lines[line - 1], - line, col, - message, - rule, - }); - } - - report(message, line, col, rule) { - if (line in this.allow && rule.id in this.allow[line] || - this.ignore.some(range => range[0] <= line && line <= range[1])) { - return; - } - this.messages.push({ - type: this.ruleset[rule.id] === 2 ? 'error' : 'warning', - evidence: this.lines[line - 1], - line, col, - message, - rule, - }); - } - - info(message, line, col, rule) { - this.messages.push({ - type: 'info', - evidence: this.lines[line - 1], - line, col, - message, - rule, - }); - } - - rollupError(message, rule) { - this.messages.push({ - type: 'error', - rollup: true, - message, - rule, - }); - } - - rollupWarn(message, rule) { - this.messages.push({ - type: 'warning', - rollup: true, - message, - rule, - }); - } - - stat(name, value) { - this.stats[name] = value; - } -} - -//endregion -//region CSSLint - -//eslint-disable-next-line no-var -var CSSLint = (() => { - - const RX_EMBEDDED = /\/\*\s*csslint\s+((?:[^*]|\*(?!\/))+?)\*\//ig; - const EBMEDDED_RULE_VALUE_MAP = { - // error - 'true': 2, - '2': 2, - // warning - '': 1, - '1': 1, - // ignore - 'false': 0, - '0': 0, - }; - const rules = []; - - // previous CSSLint overrides are used to decide whether the parserlib's cache should be reset - let prevOverrides; - - return Object.assign(new parserlib.util.EventTarget(), { - - addRule(rule) { - rules.push(rule); - rules[rule.id] = rule; - }, - - clearRules() { - rules.length = 0; - }, - - getRules() { - return rules - .slice() - .sort((a, b) => - a.id < b.id ? -1 : - a.id > b.id ? 1 : 0); - }, - - getRuleset() { - const ruleset = {}; - // by default, everything is a warning - for (const rule of rules) { - ruleset[rule.id] = 1; - } - return ruleset; - }, - - /** - * Starts the verification process for the given CSS text. - * @param {String} text The CSS text to verify. - * @param {Object} ruleset (Optional) List of rules to apply. If null, then - * all rules are used. If a rule has a value of 1 then it's a warning, - * a value of 2 means it's an error. - * @return {Object} Results of the verification. - */ - verify(text, ruleset) { - - if (!ruleset) ruleset = this.getRuleset(); - - const allow = {}; - const ignore = []; - RX_EMBEDDED.lastIndex = - text.lastIndexOf('/*', - text.indexOf('csslint', - text.indexOf('/*') + 1 || text.length) + 1); - if (RX_EMBEDDED.lastIndex >= 0) { - ruleset = Object.assign({}, ruleset); - applyEmbeddedOverrides(text, ruleset, allow, ignore); - } - - const parser = new parserlib.css.Parser({ - starHack: true, - ieFilters: true, - underscoreHack: true, - strict: false, - }); - - const reporter = new Reporter([], ruleset, allow, ignore); - - // always report parsing errors as errors - ruleset.errors = 2; - Object.keys(ruleset).forEach(id => - ruleset[id] && - rules[id] && - rules[id].init(parser, reporter)); - - // TODO: when ruleset is unchanged we can try to invalidate only line ranges in 'allow' and 'ignore' - const newOvr = [ruleset, allow, ignore]; - const reuseCache = !prevOverrides || JSON.stringify(prevOverrides) === JSON.stringify(newOvr); - prevOverrides = newOvr; - - try { - parser.parse(text, {reuseCache}); - } catch (ex) { - reporter.error('Fatal error, cannot continue: ' + ex.message, ex.line, ex.col, {}); - } - - const report = { - messages: reporter.messages, - stats: reporter.stats, - ruleset: reporter.ruleset, - allow: reporter.allow, - ignore: reporter.ignore, - }; - - // sort by line numbers, rollups at the bottom - report.messages.sort((a, b) => - a.rollup && !b.rollup ? 1 : - !a.rollup && b.rollup ? -1 : - a.line - b.line); - - parserlib.cache.feedback(report); - - return report; - }, - }); - - // Example 1: - - /* csslint ignore:start */ - /* - the chunk of code where errors won't be reported - the chunk's start is hardwired to the line of the opening comment - the chunk's end is hardwired to the line of the closing comment - */ - /* csslint ignore:end */ - - // Example 2: - // allow rule violations on the current line: - - // foo: bar; /* csslint allow:rulename1,rulename2,... */ - /* csslint allow:rulename1,rulename2,... */ // foo: bar; - - // Example 3: - - /* csslint rulename1 */ - /* csslint rulename2:N */ - /* csslint rulename3:N, rulename4:N */ - - /* entire code is affected; - * comments futher down the code extend/override previous comments of this kind - * values for N (without the backquotes): - `2` or `true` means "error" - `1` or omitted means "warning" (when omitting, the colon can be omitted too) - `0` or `false` means "ignore" - */ - - function applyEmbeddedOverrides(text, ruleset, allow, ignore) { - let ignoreStart = null; - let ignoreEnd = null; - let lineno = 0; - let eol = -1; - let m; - - while ((m = RX_EMBEDDED.exec(text))) { - // account for the lines between the previous and current match - while (eol <= m.index) { - eol = text.indexOf('\n', eol + 1); - if (eol < 0) eol = text.length; - lineno++; - } - - const ovr = m[1].toLowerCase(); - const cmd = ovr.split(':', 1)[0]; - const i = cmd.length + 1; - - switch (cmd.trim()) { - - case 'allow': { - const allowRuleset = {}; - let num = 0; - ovr.slice(i).split(',').forEach(allowRule => { - allowRuleset[allowRule.trim()] = true; - num++; - }); - if (num) allow[lineno] = allowRuleset; - break; - } - - case 'ignore': - if (ovr.includes('start')) { - ignoreStart = ignoreStart || lineno; - break; - } - if (ovr.includes('end')) { - ignoreEnd = lineno; - if (ignoreStart && ignoreEnd) { - ignore.push([ignoreStart, ignoreEnd]); - ignoreStart = ignoreEnd = null; - } - } - break; - - default: - ovr.slice(i).split(',').forEach(rule => { - const pair = rule.split(':'); - const property = pair[0] || ''; - const value = pair[1] || ''; - const mapped = EBMEDDED_RULE_VALUE_MAP[value.trim()]; - ruleset[property.trim()] = mapped === undefined ? 1 : mapped; - }); - } - } - - // Close remaining ignore block, if any - if (ignoreStart) { - ignore.push([ignoreStart, lineno]); - } - } -})(); - -//endregion -//region Util - -// expose for testing purposes -CSSLint._Reporter = Reporter; - -CSSLint.Util = { - - indexOf(values, value) { - if (typeof values.indexOf === 'function') { - return values.indexOf(value); - } - for (let i = 0, len = values.length; i < len; i++) { - if (values[i] === value) { - return i; - } - } - return -1; - }, - - registerBlockEvents(parser, start, end, property) { - for (const e of [ - 'document', - 'fontface', - 'keyframerule', - 'media', - 'page', - 'pagemargin', - 'rule', - 'supports', - 'viewport', - ]) { - if (start) parser.addListener('start' + e, start); - if (end) parser.addListener('end' + e, end); - } - if (property) parser.addListener('property', property); - }, -}; - -//endregion -//region Rules - -CSSLint.addRule({ - id: 'adjoining-classes', - name: 'Disallow adjoining classes', - desc: "Don't use adjoining classes.", - url: 'https://github.com/CSSLint/csslint/wiki/Disallow-adjoining-classes', - browsers: 'IE6', - - init(parser, reporter) { - parser.addListener('startrule', event => { - for (const selector of event.selectors) { - for (const part of selector.parts) { - if (part.type !== parser.SELECTOR_PART_TYPE) continue; - let classCount = 0; - for (const modifier of part.modifiers) { - classCount += modifier.type === 'class'; - if (classCount > 1) { - reporter.report('Adjoining classes: ' + selector.text, part.line, part.col, this); - } - } - } - } - }); - }, -}); - -CSSLint.addRule({ - id: 'box-model', - name: 'Beware of broken box size', - desc: "Don't use width or height when using padding or border.", - url: 'https://github.com/CSSLint/csslint/wiki/Beware-of-box-model-size', - browsers: 'All', - - init(parser, reporter) { - const sizeProps = { - width: [ - 'border', - 'border-left', - 'border-right', - 'padding', - 'padding-left', - 'padding-right', - ], - height: [ - 'border', - 'border-bottom', - 'border-top', - 'padding', - 'padding-bottom', - 'padding-top', - ], - }; - let properties = {}; - let boxSizing = false; - let started = 0; - - const startRule = () => { - started = 1; - properties = {}; - boxSizing = false; - }; - - const property = event => { - if (!started) return; - const name = event.property.text.toLowerCase(); - - if (sizeProps.width.includes(name) || sizeProps.height.includes(name)) { - - if (!/^0+\D*$/.test(event.value) && - (name !== 'border' || !/^none$/i.test(event.value))) { - properties[name] = { - line: event.property.line, - col: event.property.col, - value: event.value, - }; - } - - } else if (/^(width|height)/i.test(name) && - /^(length|percentage)/.test(event.value.parts[0].type)) { - properties[name] = 1; - - } else if (name === 'box-sizing') { - boxSizing = true; - } - }; - - const endRule = () => { - started = 0; - if (boxSizing) return; - - for (const size in sizeProps) { - if (!properties[size]) continue; - - for (const prop of sizeProps[size]) { - if (prop !== 'padding' || !properties[prop]) continue; - - const {value: {parts}, line, col} = properties[prop].value; - if (parts.length !== 2 || Number(parts[0].value) !== 0) { - reporter.report(`Using ${size} with ${prop} can sometimes make elements larger than you expect.`, - line, col, this); - } - } - } - }; - - CSSLint.Util.registerBlockEvents(parser, startRule, endRule, property); - }, -}); - -CSSLint.addRule({ - id: 'box-sizing', - name: 'Disallow use of box-sizing', - desc: "The box-sizing properties isn't supported in IE6 and IE7.", - url: 'https://github.com/CSSLint/csslint/wiki/Disallow-box-sizing', - browsers: 'IE6, IE7', - tags: ['Compatibility'], - - init(parser, reporter) { - parser.addListener('property', event => { - if (event.property.text.toLowerCase() === 'box-sizing') { - reporter.report(this.desc, event.line, event.col, this); - } - }); - }, -}); - -CSSLint.addRule({ - id: 'bulletproof-font-face', - name: 'Use the bulletproof @font-face syntax', - desc: 'Use the bulletproof @font-face syntax to avoid 404\'s in old IE ' + - '(http://www.fontspring.com/blog/the-new-bulletproof-font-face-syntax).', - url: 'https://github.com/CSSLint/csslint/wiki/Bulletproof-font-face', - browsers: 'All', - - init(parser, reporter) { - const regex = /^\s?url\(['"].+\.eot\?.*['"]\)\s*format\(['"]embedded-opentype['"]\).*$/i; - let firstSrc = true; - let ruleFailed = false; - let line, col; - - // Mark the start of a @font-face declaration so we only test properties inside it - parser.addListener('startfontface', () => { - parser.addListener('property', property); - }); - - function property(event) { - const propertyName = event.property.toString().toLowerCase(); - if (propertyName !== 'src') return; - - const value = event.value.toString(); - line = event.line; - col = event.col; - - const matched = regex.test(value); - if (firstSrc && !matched) { - ruleFailed = true; - firstSrc = false; - } else if (!firstSrc && matched) { - ruleFailed = false; - } - } - - // Back to normal rules that we don't need to test - parser.addListener('endfontface', () => { - parser.removeListener('property', property); - if (!ruleFailed) return; - reporter.report("@font-face declaration doesn't follow the fontspring bulletproof syntax.", - line, col, this); - }); - }, -}); - -CSSLint.addRule({ - id: 'compatible-vendor-prefixes', - name: 'Require compatible vendor prefixes', - desc: 'Include all compatible vendor prefixes to reach a wider range of users.', - url: 'https://github.com/CSSLint/csslint/wiki/Require-compatible-vendor-prefixes', - browsers: 'All', - - init(parser, reporter) { - // See http://peter.sh/experiments/vendor-prefixed-css-property-overview/ for details - const compatiblePrefixes = { - 'animation': 'webkit', - 'animation-delay': 'webkit', - 'animation-direction': 'webkit', - 'animation-duration': 'webkit', - 'animation-fill-mode': 'webkit', - 'animation-iteration-count': 'webkit', - 'animation-name': 'webkit', - 'animation-play-state': 'webkit', - 'animation-timing-function': 'webkit', - 'appearance': 'webkit moz', - 'border-end': 'webkit moz', - 'border-end-color': 'webkit moz', - 'border-end-style': 'webkit moz', - 'border-end-width': 'webkit moz', - 'border-image': 'webkit moz o', - 'border-radius': 'webkit', - 'border-start': 'webkit moz', - 'border-start-color': 'webkit moz', - 'border-start-style': 'webkit moz', - 'border-start-width': 'webkit moz', - 'box-align': 'webkit moz', - 'box-direction': 'webkit moz', - 'box-flex': 'webkit moz', - 'box-lines': 'webkit', - 'box-ordinal-group': 'webkit moz', - 'box-orient': 'webkit moz', - 'box-pack': 'webkit moz', - 'box-sizing': '', - 'box-shadow': '', - 'column-count': 'webkit moz ms', - 'column-gap': 'webkit moz ms', - 'column-rule': 'webkit moz ms', - 'column-rule-color': 'webkit moz ms', - 'column-rule-style': 'webkit moz ms', - 'column-rule-width': 'webkit moz ms', - 'column-width': 'webkit moz ms', - 'flex': 'webkit ms', - 'flex-basis': 'webkit', - 'flex-direction': 'webkit ms', - 'flex-flow': 'webkit', - 'flex-grow': 'webkit', - 'flex-shrink': 'webkit', - 'hyphens': 'epub moz', - 'line-break': 'webkit ms', - 'margin-end': 'webkit moz', - 'margin-start': 'webkit moz', - 'marquee-speed': 'webkit wap', - 'marquee-style': 'webkit wap', - 'padding-end': 'webkit moz', - 'padding-start': 'webkit moz', - 'tab-size': 'moz o', - 'text-size-adjust': 'webkit ms', - 'transform': 'webkit ms', - 'transform-origin': 'webkit ms', - 'transition': '', - 'transition-delay': '', - 'transition-duration': '', - 'transition-property': '', - 'transition-timing-function': '', - 'user-modify': 'webkit moz', - 'user-select': 'webkit moz ms', - 'word-break': 'epub ms', - 'writing-mode': 'epub ms', - }; - const applyTo = []; - let properties = []; - let inKeyFrame = false; - let started = 0; - - for (const prop in compatiblePrefixes) { - const variations = compatiblePrefixes[prop].split(' ').map(s => `-${s}-${prop}`); - compatiblePrefixes[prop] = variations; - applyTo.push(...variations); - } - - parser.addListener('startrule', () => { - started++; - properties = []; - }); - - parser.addListener('startkeyframes', event => { - started++; - inKeyFrame = event.prefix || true; - if (inKeyFrame && typeof inKeyFrame === 'string') { - inKeyFrame = '-' + inKeyFrame + '-'; - } - }); - - parser.addListener('endkeyframes', () => { - started--; - inKeyFrame = false; - }); - - parser.addListener('property', event => { - if (!started) return; - const name = event.property.text; - if (inKeyFrame && - typeof inKeyFrame === 'string' && - name.startsWith(inKeyFrame) || - CSSLint.Util.indexOf(applyTo, name) < 0) { - return; - } - properties.push(event.property); - }); - - parser.addListener('endrule', () => { - started = 0; - if (!properties.length) return; - const propertyGroups = {}; - - for (const name of properties) { - for (const prop in compatiblePrefixes) { - const variations = compatiblePrefixes[prop]; - if (CSSLint.Util.indexOf(variations, name.text) <= -1) continue; - - if (!propertyGroups[prop]) { - propertyGroups[prop] = { - full: variations.slice(0), - actual: [], - actualNodes: [], - }; - } - - if (CSSLint.Util.indexOf(propertyGroups[prop].actual, name.text) === -1) { - propertyGroups[prop].actual.push(name.text); - propertyGroups[prop].actualNodes.push(name); - } - } - } - - for (const prop in propertyGroups) { - const value = propertyGroups[prop]; - const actual = value.actual; - if (value.full.length <= actual.length) continue; - - for (const item of value.full) { - if (CSSLint.Util.indexOf(actual, item) !== -1) continue; - - const propertiesSpecified = - actual.length === 1 ? - actual[0] : - actual.length === 2 ? - actual.join(' and ') : - actual.join(', '); - - const {line, col} = value.actualNodes[0]; - reporter.report( - `The property ${item} is compatible with ${propertiesSpecified} and should be included as well.`, - line, col, this); - } - } - }); - }, -}); - -CSSLint.addRule({ - id: 'display-property-grouping', - name: 'Require properties appropriate for display', - desc: "Certain properties shouldn't be used with certain display property values.", - url: 'https://github.com/CSSLint/csslint/wiki/Require-properties-appropriate-for-display', - browsers: 'All', - - init(parser, reporter) { - const propertiesToCheck = { - 'display': 1, - 'float': 'none', - 'height': 1, - 'width': 1, - 'margin': 1, - 'margin-left': 1, - 'margin-right': 1, - 'margin-bottom': 1, - 'margin-top': 1, - 'padding': 1, - 'padding-left': 1, - 'padding-right': 1, - 'padding-bottom': 1, - 'padding-top': 1, - 'vertical-align': 1, - }; - let properties; - let started = 0; - - const startRule = () => { - started = 1; - properties = {}; - }; - - const property = event => { - if (!started) return; - const name = event.property.text.toLowerCase(); - if (name in propertiesToCheck) { - properties[name] = { - value: event.value.text, - line: event.property.line, - col: event.property.col, - }; - } - }; - - const reportProperty = (name, display, msg) => { - const prop = properties[name]; - if (!prop) return; - - const toCheck = propertiesToCheck[name]; - if (typeof toCheck === 'string' && toCheck === prop.value.toLowerCase()) return; - - const {line, col} = prop; - reporter.report(msg || `${name} can't be used with display: ${display}.`, - line, col, this); - }; - - const endRule = () => { - started = 0; - const display = properties.display && properties.display.value; - if (!display) return; - - switch (display.toLowerCase()) { - - case 'inline': - ['height', 'width', 'margin', 'margin-top', 'margin-bottom'] - .forEach(p => reportProperty(p, display)); - - reportProperty('float', display, - 'display:inline has no effect on floated elements ' + - '(but may be used to fix the IE6 double-margin bug).'); - break; - - case 'block': - // vertical-align should not be used with block - reportProperty('vertical-align', display); - break; - - case 'inline-block': - // float should not be used with inline-block - reportProperty('float', display); - break; - - default: - // margin, float should not be used with table - if (display.indexOf('table-') !== 0) { - return; - } - ['margin', 'margin-left', 'margin-right', 'margin-top', 'margin-bottom', 'float'] - .forEach(p => reportProperty(p, display)); - } - }; - - CSSLint.Util.registerBlockEvents(parser, startRule, endRule, property); - }, -}); - -CSSLint.addRule({ - id: 'duplicate-background-images', - name: 'Disallow duplicate background images', - desc: 'Every background-image should be unique. Use a common class for e.g. sprites.', - url: 'https://github.com/CSSLint/csslint/wiki/Disallow-duplicate-background-images', - browsers: 'All', - - init(parser, reporter) { - const stack = {}; - - parser.addListener('property', event => { - const name = event.property.text; - if (!name.match(/background/i)) return; - - for (const part of event.value.parts) { - if (part.type !== 'uri') continue; - - const uri = stack[part.uri]; - if (uri === undefined) { - stack[part.uri] = event; - continue; - } - - reporter.report( - `Background image '${part.uri}' was used multiple times, ` + - `first declared at line ${uri.line}, col ${uri.col}.`, - event.line, event.col, this); - } - }); - }, -}); - -CSSLint.addRule({ - id: 'duplicate-properties', - name: 'Disallow duplicate properties', - desc: 'Duplicate properties must appear one after the other.', - url: 'https://github.com/CSSLint/csslint/wiki/Disallow-duplicate-properties', - browsers: 'All', - - init(parser, reporter) { - let properties, lastName; - let started = 0; - - const startRule = () => { - started = 1; - properties = {}; - }; - - const endRule = () => { - started = 0; - properties = {}; - }; - - const property = event => { - if (!started) return; - const property = event.property; - const name = property.text.toLowerCase(); - const last = properties[name]; - if (last && (lastName !== name || last === event.value.text)) { - reporter.report(`Duplicate property '${property}' found.`, event.line, event.col, this); - } - properties[name] = event.value.text; - lastName = name; - }; - - CSSLint.Util.registerBlockEvents(parser, startRule, endRule, property); - }, -}); - -CSSLint.addRule({ - id: 'empty-rules', - name: 'Disallow empty rules', - desc: 'Rules without any properties specified should be removed.', - url: 'https://github.com/CSSLint/csslint/wiki/Disallow-empty-rules', - browsers: 'All', - - init(parser, reporter) { - let count = 0; - parser.addListener('startrule', () => (count = 0)); - parser.addListener('property', () => count++); - parser.addListener('endrule', event => { - if (!count) { - const {line, col} = event.selectors[0]; - reporter.report('Rule is empty.', line, col, this); - } - }); - }, - -}); - -CSSLint.addRule({ - id: 'errors', - name: 'Parsing Errors', - desc: 'This rule looks for recoverable syntax errors.', - browsers: 'All', - - init(parser, reporter) { - parser.addListener('error', ({message, line, col}) => { - reporter.error(message, line, col, this); - }); - }, -}); - -CSSLint.addRule({ - id: 'warnings', - name: 'Parsing warnings', - desc: 'This rule looks for parser warnings.', - browsers: 'All', - - init(parser, reporter) { - parser.addListener('warning', ({message, line, col}) => { - reporter.report(message, line, col, this); - }); - }, -}); - -CSSLint.addRule({ - id: 'fallback-colors', - name: 'Require fallback colors', - desc: "For older browsers that don't support RGBA, HSL, or HSLA, provide a fallback color.", - url: 'https://github.com/CSSLint/csslint/wiki/Require-fallback-colors', - browsers: 'IE6,IE7,IE8', - - init(parser, reporter) { - const propertiesToCheck = new Set([ - 'color', - 'background', - 'border-color', - 'border-top-color', - 'border-right-color', - 'border-bottom-color', - 'border-left-color', - 'border', - 'border-top', - 'border-right', - 'border-bottom', - 'border-left', - 'background-color', - ]); - let lastProperty; - const startRule = () => (lastProperty = null); - - CSSLint.Util.registerBlockEvents(parser, startRule, null, event => { - const name = event.property.text.toLowerCase(); - if (!propertiesToCheck.has(name)) { - lastProperty = event; - return; - } - - let colorType = ''; - for (const part of event.value.parts) { - if (part.type !== 'color') continue; - - if (!('alpha' in part || 'hue' in part)) { - event.colorType = 'compat'; - continue; - } - - if (/([^)]+)\(/.test(part)) { - colorType = RegExp.$1.toUpperCase(); - } - - if (!lastProperty || - lastProperty.property.text.toLowerCase() !== name || - lastProperty.colorType !== 'compat') { - reporter.report(`Fallback ${name} (hex or RGB) should precede ${colorType} ${name}.`, - event.line, event.col, this); - } - } - lastProperty = event; - }); - }, -}); - -CSSLint.addRule({ - id: 'floats', - name: 'Disallow too many floats', - desc: 'This rule tests if the float property is used too many times', - url: 'https://github.com/CSSLint/csslint/wiki/Disallow-too-many-floats', - browsers: 'All', - - init(parser, reporter) { - let count = 0; - - parser.addListener('property', ({property, value}) => { - count += - property.text.toLowerCase() === 'float' && - value.text.toLowerCase() !== 'none'; - }); - - parser.addListener('endstylesheet', () => { - reporter.stat('floats', count); - if (count >= 10) { - reporter.rollupWarn( - `Too many floats (${count}), you're probably using them for layout. ` + - 'Consider using a grid system instead.', this); - } - }); - }, - -}); - -CSSLint.addRule({ - id: 'font-faces', - name: "Don't use too many web fonts", - desc: 'Too many different web fonts in the same stylesheet.', - url: 'https://github.com/CSSLint/csslint/wiki/Don%27t-use-too-many-web-fonts', - browsers: 'All', - - init(parser, reporter) { - let count = 0; - parser.addListener('startfontface', () => count++); - parser.addListener('endstylesheet', () => { - if (count > 5) { - reporter.rollupWarn(`Too many @font-face declarations (${count}).`, this); - } - }); - }, - -}); - -CSSLint.addRule({ - id: 'font-sizes', - name: 'Disallow too many font sizes', - desc: 'Checks the number of font-size declarations.', - url: 'https://github.com/CSSLint/csslint/wiki/Don%27t-use-too-many-font-size-declarations', - browsers: 'All', - - init(parser, reporter) { - let count = 0; - - parser.addListener('property', event => { - count += event.property.toString() === 'font-size'; - }); - - parser.addListener('endstylesheet', () => { - reporter.stat('font-sizes', count); - if (count >= 10) { - reporter.rollupWarn('Too many font-size declarations (' + count + '), abstraction needed.', this); - } - }); - }, - -}); - -CSSLint.addRule({ - - id: 'gradients', - name: 'Require all gradient definitions', - desc: 'When using a vendor-prefixed gradient, make sure to use them all.', - url: 'https://github.com/CSSLint/csslint/wiki/Require-all-gradient-definitions', - browsers: 'All', - - init(parser, reporter) { - let gradients; - - parser.addListener('startrule', () => { - gradients = { - moz: 0, - webkit: 0, - oldWebkit: 0, - o: 0, - }; - }); - - parser.addListener('property', event => { - if (/-(moz|o|webkit)(?:-(?:linear|radial))-gradient/i.test(event.value)) { - gradients[RegExp.$1] = 1; - } else if (/-webkit-gradient/i.test(event.value)) { - gradients.oldWebkit = 1; - } - }); - - parser.addListener('endrule', event => { - const missing = []; - if (!gradients.moz) missing.push('Firefox 3.6+'); - if (!gradients.webkit) missing.push('Webkit (Safari 5+, Chrome)'); - if (!gradients.oldWebkit) missing.push('Old Webkit (Safari 4+, Chrome)'); - if (!gradients.o) missing.push('Opera 11.1+'); - if (missing.length && missing.length < 4) { - const {line, col} = event.selectors[0]; - reporter.report(`Missing vendor-prefixed CSS gradients for ${missing.join(', ')}.`, - line, col, this); - } - }); - }, -}); - -CSSLint.addRule({ - id: 'ids', - name: 'Disallow IDs in selectors', - desc: 'Selectors should not contain IDs.', - url: 'https://github.com/CSSLint/csslint/wiki/Disallow-IDs-in-selectors', - browsers: 'All', - - init(parser, reporter) { - parser.addListener('startrule', event => { - for (const {line, col, parts} of event.selectors) { - const idCount = - parts.reduce((sum = 0, {type, modifiers}) => - type === parser.SELECTOR_PART_TYPE ? - modifiers.reduce(sum, mod => sum + (mod.type === 'id')) : - sum); - if (idCount === 1) { - reporter.report("Don't use IDs in selectors.", line, col, this); - } else if (idCount > 1) { - reporter.report(idCount + ' IDs in the selector, really?', line, col, this); - } - } - }); - }, -}); - -CSSLint.addRule({ - id: 'import-ie-limit', - name: '@import limit on IE6-IE9', - desc: 'IE6-9 supports up to 31 @import per stylesheet', - browsers: 'IE6, IE7, IE8, IE9', - - init(parser, reporter) { - const MAX_IMPORT_COUNT = 31; - let count = 0; - parser.addListener('startpage', () => (count = 0)); - parser.addListener('import', () => count++); - parser.addListener('endstylesheet', () => { - if (count > MAX_IMPORT_COUNT) { - reporter.rollupError(`Too many @import rules (${count}). IE6-9 supports up to 31 import per stylesheet.`, this); - } - }); - }, -}); - -CSSLint.addRule({ - id: 'import', - name: 'Disallow @import', - desc: "Don't use @import, use instead.", - url: 'https://github.com/CSSLint/csslint/wiki/Disallow-%40import', - browsers: 'All', - - init(parser, reporter) { - parser.addListener('import', ({line, col}) => { - reporter.report('@import prevents parallel downloads, use instead.', line, col, this); - }); - }, -}); - -CSSLint.addRule({ - id: 'important', - name: 'Disallow !important', - desc: 'Be careful when using !important declaration', - url: 'https://github.com/CSSLint/csslint/wiki/Disallow-%21important', - browsers: 'All', - - init(parser, reporter) { - let count = 0; - - parser.addListener('property', event => { - if (!event.important) return; - count++; - reporter.report('Use of !important', event.line, event.col, this); - }); - - parser.addListener('endstylesheet', () => { - reporter.stat('important', count); - if (count >= 10) { - reporter.rollupWarn( - `Too many !important declarations (${count}), ` + - 'try to use less than 10 to avoid specificity issues.', this); - } - }); - }, - -}); - -CSSLint.addRule({ - id: 'known-properties', - name: 'Require use of known properties', - desc: 'Properties should be known (listed in CSS3 specification) or be a vendor-prefixed property.', - url: 'https://github.com/CSSLint/csslint/wiki/Require-use-of-known-properties', - browsers: 'All', - - init(parser, reporter) { - parser.addListener('property', event => { - if (event.invalid) { - reporter.report(event.invalid.message, event.line, event.col, this); - } - }); - }, -}); - -CSSLint.addRule({ - id: 'order-alphabetical', - name: 'Alphabetical order', - desc: 'Assure properties are in alphabetical order', - browsers: 'All', - - init(parser, reporter) { - let properties; - let started = 0; - - const startRule = () => { - started = 1; - properties = []; - }; - - const property = event => { - if (!started) return; - const name = event.property.text; - const lowerCasePrefixLessName = name.toLowerCase().replace(/^-.*?-/, ''); - properties.push(lowerCasePrefixLessName); - }; - - const endRule = event => { - started = 0; - if (properties.join(',') !== properties.sort().join(',')) { - reporter.report("Rule doesn't have all its properties in alphabetical order.", event.line, event.col, this); - } - }; - - CSSLint.Util.registerBlockEvents(parser, startRule, endRule, property); - }, -}); - -CSSLint.addRule({ - id: 'outline-none', - name: 'Disallow outline: none', - desc: 'Use of outline: none or outline: 0 should be limited to :focus rules.', - url: 'https://github.com/CSSLint/csslint/wiki/Disallow-outline%3Anone', - browsers: 'All', - tags: ['Accessibility'], - - init(parser, reporter) { - let lastRule; - - const startRule = event => { - lastRule = !event.selectors ? null : { - line: event.line, - col: event.col, - selectors: event.selectors, - propCount: 0, - outline: false, - }; - }; - - const property = event => { - if (!lastRule) return; - const name = event.property.text.toLowerCase(); - const value = event.value; - lastRule.propCount++; - if (name === 'outline' && /^(none|0)$/i.test(value)) { - lastRule.outline = true; - } - }; - - const endRule = () => { - const {outline, selectors, propCount, line, col} = lastRule || {}; - lastRule = null; - if (!outline) return; - if (selectors.toString().toLowerCase().indexOf(':focus') === -1) { - reporter.report('Outlines should only be modified using :focus.', line, col, this); - } else if (propCount === 1) { - reporter.report("Outlines shouldn't be hidden unless other visual changes are made.", - line, col, this); - } - }; - - CSSLint.Util.registerBlockEvents(parser, startRule, endRule, property); - }, -}); - -CSSLint.addRule({ - id: 'overqualified-elements', - name: 'Disallow overqualified elements', - desc: "Don't use classes or IDs with elements (a.foo or a#foo).", - url: 'https://github.com/CSSLint/csslint/wiki/Disallow-overqualified-elements', - browsers: 'All', - - init(parser, reporter) { - const classes = {}; - - parser.addListener('startrule', event => { - for (const selector of event.selectors) { - for (const part of selector.parts) { - if (part.type !== parser.SELECTOR_PART_TYPE) continue; - for (const mod of part.modifiers) { - if (part.elementName && mod.type === 'id') { - reporter.report('Element (' + part + ') is overqualified, just use ' + mod + - ' without element name.', part.line, part.col, this); - } else if (mod.type === 'class') { - let classMods = classes[mod]; - if (!classMods) classMods = classes[mod] = []; - classMods.push({modifier: mod, part}); - } - } - } - } - }); - - // one use means that this is overqualified - parser.addListener('endstylesheet', () => { - for (const prop in classes) { - const {part, modifier} = classes[prop][0]; - if (part.elementName && classes[prop].length === 1) { - reporter.report(`Element (${part}) is overqualified, just use ${modifier} without element name.`, - part.line, part.col, this); - } - } - }); - }, -}); - -CSSLint.addRule({ - id: 'qualified-headings', - name: 'Disallow qualified headings', - desc: 'Headings should not be qualified (namespaced).', - url: 'https://github.com/CSSLint/csslint/wiki/Disallow-qualified-headings', - browsers: 'All', - - init(parser, reporter) { - parser.addListener('startrule', event => { - for (const selector of event.selectors) { - let first = true; - for (const part of selector.parts) { - const name = part.elementName; - if (!first && - name && - part.type === parser.SELECTOR_PART_TYPE && - /h[1-6]/.test(name.toString())) { - reporter.report(`Heading (${name}) should not be qualified.`, - part.line, part.col, this); - } - first = false; - } - } - }); - }, - -}); - -CSSLint.addRule({ - id: 'regex-selectors', - name: 'Disallow selectors that look like regexs', - desc: 'Selectors that look like regular expressions are slow and should be avoided.', - url: 'https://github.com/CSSLint/csslint/wiki/Disallow-selectors-that-look-like-regular-expressions', - browsers: 'All', - - init(parser, reporter) { - parser.addListener('startrule', event => { - for (const selector of event.selectors) { - for (const part of selector.parts) { - if (part.type !== parser.SELECTOR_PART_TYPE) continue; - for (const mod of part.modifiers) { - if (mod.type !== 'attribute' || !/([~|^$*]=)/.test(mod)) continue; - reporter.report(`Attribute selectors with ${RegExp.$1} are slow!`, - mod.line, mod.col, this); - } - } - } - }); - }, - -}); - -CSSLint.addRule({ - id: 'rules-count', - name: 'Rules Count', - desc: 'Track how many rules there are.', - browsers: 'All', - - init(parser, reporter) { - let count = 0; - parser.addListener('startrule', () => count++); - parser.addListener('endstylesheet', () => reporter.stat('rule-count', count)); - }, -}); - -CSSLint.addRule({ - id: 'selector-max-approaching', - name: 'Warn when approaching the 4095 selector limit for IE', - desc: 'Will warn when selector count is >= 3800 selectors.', - browsers: 'IE', - - init(parser, reporter) { - let count = 0; - parser.addListener('startrule', event => (count += event.selectors.length)); - parser.addListener('endstylesheet', () => { - if (count >= 3800) { - reporter.report( - `You have ${count} selectors. ` + - 'Internet Explorer supports a maximum of 4095 selectors per stylesheet. ' + - 'Consider refactoring.', 0, 0, this); - } - }); - }, - -}); - -CSSLint.addRule({ - id: 'selector-max', - name: 'Error when past the 4095 selector limit for IE', - desc: 'Will error when selector count is > 4095.', - browsers: 'IE', - - init(parser, reporter) { - let count = 0; - parser.addListener('startrule', event => (count += event.selectors.length)); - parser.addListener('endstylesheet', () => { - if (count > 4095) { - reporter.report( - `You have ${count} selectors. ` + - 'Internet Explorer supports a maximum of 4095 selectors per stylesheet. ' + - 'Consider refactoring.', 0, 0, this); - } - }); - }, -}); - -CSSLint.addRule({ - id: 'selector-newline', - name: 'Disallow new-line characters in selectors', - desc: 'New-line characters in selectors are usually a forgotten comma and not a descendant combinator.', - browsers: 'All', - - init(parser, reporter) { - parser.addListener('startrule', event => { - for (const {parts} of event.selectors) { - for (let p = 0, pLen = parts.length; p < pLen; p++) { - for (let n = p + 1; n < pLen; n++) { - if (parts[p].type === 'descendant' && - parts[n].line > parts[p].line) { - reporter.report('newline character found in selector (forgot a comma?)', - parts[p].line, parts[0].col, this); - } - } - } - } - }); - }, -}); - -CSSLint.addRule({ - id: 'shorthand', - name: 'Require shorthand properties', - desc: 'Use shorthand properties where possible.', - url: 'https://github.com/CSSLint/csslint/wiki/Require-shorthand-properties', - browsers: 'All', - - init(parser, reporter) { - const propertiesToCheck = {}; - const mapping = { - margin: ['margin-top', 'margin-bottom', 'margin-left', 'margin-right'], - padding: ['padding-top', 'padding-bottom', 'padding-left', 'padding-right'], - }; - let properties; - let started = 0; - - for (const short in mapping) { - for (const full of mapping[short]) { - propertiesToCheck[full] = short; - } - } - - const startRule = () => { - started = 1; - properties = {}; - }; - - const property = event => { - if (!started) return; - const name = event.property.toString().toLowerCase(); - if (name in propertiesToCheck) { - properties[name] = 1; - } - }; - - const endRule = event => { - started = 0; - for (const short in mapping) { - const fullList = mapping[short]; - const total = fullList.reduce((sum = 0, name) => sum + (properties[name] ? 1 : 0)); - if (total === fullList.length) { - reporter.report(`The properties ${fullList.join(', ')} can be replaced by ${short}.`, - event.line, event.col, this); - } - } - }; - - parser.addListener('startrule', startRule); - parser.addListener('startfontface', startRule); - parser.addListener('property', property); - parser.addListener('endrule', endRule); - parser.addListener('endfontface', endRule); - }, -}); - -CSSLint.addRule({ - id: 'star-property-hack', - name: 'Disallow properties with a star prefix', - desc: 'Checks for the star property hack (targets IE6/7)', - url: 'https://github.com/CSSLint/csslint/wiki/Disallow-star-hack', - browsers: 'All', - - init(parser, reporter) { - parser.addListener('property', ({property: {hack, line, col}}) => { - if (hack === '*') { - reporter.report('Property with star prefix found.', line, col, this); - } - }); - }, -}); - -CSSLint.addRule({ - id: 'text-indent', - name: 'Disallow negative text-indent', - desc: 'Checks for text indent less than -99px', - url: 'https://github.com/CSSLint/csslint/wiki/Disallow-negative-text-indent', - browsers: 'All', - - init(parser, reporter) { - let textIndent, direction; - - const startRule = () => { - textIndent = false; - direction = 'inherit'; - }; - - const endRule = () => { - if (textIndent && direction !== 'ltr') { - reporter.report( - "Negative text-indent doesn't work well with RTL. " + - 'If you use text-indent for image replacement explicitly set direction for that item to ltr.', - textIndent.line, textIndent.col, this); - } - }; - - parser.addListener('startrule', startRule); - parser.addListener('startfontface', startRule); - - parser.addListener('property', event => { - const name = event.property.toString().toLowerCase(); - const value = event.value; - - if (name === 'text-indent' && value.parts[0].value < -99) { - textIndent = event.property; - } else if (name === 'direction' && value.toString().toLowerCase() === 'ltr') { - direction = 'ltr'; - } - }); - - parser.addListener('endrule', endRule); - parser.addListener('endfontface', endRule); - }, -}); - -CSSLint.addRule({ - id: 'underscore-property-hack', - name: 'Disallow properties with an underscore prefix', - desc: 'Checks for the underscore property hack (targets IE6)', - url: 'https://github.com/CSSLint/csslint/wiki/Disallow-underscore-hack', - browsers: 'All', - - init(parser, reporter) { - parser.addListener('property', ({property: {hack, line, col}}) => { - if (hack === '_') { - reporter.report('Property with underscore prefix found.', line, col, this); - } - }); - }, -}); - -CSSLint.addRule({ - id: 'unique-headings', - name: 'Headings should only be defined once', - desc: 'Headings should be defined only once.', - url: 'https://github.com/CSSLint/csslint/wiki/Headings-should-only-be-defined-once', - browsers: 'All', - - init(parser, reporter) { - const headings = new Array(6).fill(0); - - parser.addListener('startrule', event => { - for (const {parts} of event.selectors) { - const part = parts[parts.length - 1]; - if (!part.elementName || !/h([1-6])/i.test(part.elementName)) continue; - if (part.modifiers.some(mod => mod.type === 'pseudo')) continue; - if (++headings[Number(RegExp.$1) - 1] > 1) { - reporter.report(`Heading (${part.elementName}) has already been defined.`, - part.line, part.col, this); - } - } - }); - - parser.addListener('endstylesheet', () => { - const messages = headings - .filter(h => h > 1) - .map((h, i) => `${h} H${i + 1}s`); - if (messages.length) { - reporter.rollupWarn(`You have ${messages.join(', ')} defined in this stylesheet.`, this); - } - }); - }, -}); - -CSSLint.addRule({ - id: 'universal-selector', - name: 'Disallow universal selector', - desc: 'The universal selector (*) is known to be slow.', - url: 'https://github.com/CSSLint/csslint/wiki/Disallow-universal-selector', - browsers: 'All', - - init(parser, reporter) { - parser.addListener('startrule', event => { - for (const {parts} of event.selectors) { - const part = parts[parts.length - 1]; - if (part.elementName === '*') { - reporter.report(this.desc, part.line, part.col, this); - } - } - }); - }, -}); - -CSSLint.addRule({ - id: 'unqualified-attributes', - name: 'Disallow unqualified attribute selectors', - desc: 'Unqualified attribute selectors are known to be slow.', - url: 'https://github.com/CSSLint/csslint/wiki/Disallow-unqualified-attribute-selectors', - browsers: 'All', - - init(parser, reporter) { - parser.addListener('startrule', event => { - for (const {parts} of event.selectors) { - const part = parts[parts.length - 1]; - if (part.type !== parser.SELECTOR_PART_TYPE) continue; - if (part.modifiers.some(mod => mod.type === 'class' || mod.type === 'id')) continue; - - const isUnqualified = !part.elementName || part.elementName === '*'; - for (const mod of part.modifiers) { - if (mod.type === 'attribute' && isUnqualified) { - reporter.report(this.desc, part.line, part.col, this); - } - } - } - }); - }, -}); - -CSSLint.addRule({ - id: 'vendor-prefix', - name: 'Require standard property with vendor prefix', - desc: 'When using a vendor-prefixed property, make sure to include the standard one.', - url: 'https://github.com/CSSLint/csslint/wiki/Require-standard-property-with-vendor-prefix', - browsers: 'All', - - init(parser, reporter) { - const propertiesToCheck = { - '-webkit-border-radius': 'border-radius', - '-webkit-border-top-left-radius': 'border-top-left-radius', - '-webkit-border-top-right-radius': 'border-top-right-radius', - '-webkit-border-bottom-left-radius': 'border-bottom-left-radius', - '-webkit-border-bottom-right-radius': 'border-bottom-right-radius', - - '-o-border-radius': 'border-radius', - '-o-border-top-left-radius': 'border-top-left-radius', - '-o-border-top-right-radius': 'border-top-right-radius', - '-o-border-bottom-left-radius': 'border-bottom-left-radius', - '-o-border-bottom-right-radius': 'border-bottom-right-radius', - - '-moz-border-radius': 'border-radius', - '-moz-border-radius-topleft': 'border-top-left-radius', - '-moz-border-radius-topright': 'border-top-right-radius', - '-moz-border-radius-bottomleft': 'border-bottom-left-radius', - '-moz-border-radius-bottomright': 'border-bottom-right-radius', - - '-moz-column-count': 'column-count', - '-webkit-column-count': 'column-count', - - '-moz-column-gap': 'column-gap', - '-webkit-column-gap': 'column-gap', - - '-moz-column-rule': 'column-rule', - '-webkit-column-rule': 'column-rule', - - '-moz-column-rule-style': 'column-rule-style', - '-webkit-column-rule-style': 'column-rule-style', - - '-moz-column-rule-color': 'column-rule-color', - '-webkit-column-rule-color': 'column-rule-color', - - '-moz-column-rule-width': 'column-rule-width', - '-webkit-column-rule-width': 'column-rule-width', - - '-moz-column-width': 'column-width', - '-webkit-column-width': 'column-width', - - '-webkit-column-span': 'column-span', - '-webkit-columns': 'columns', - - '-moz-box-shadow': 'box-shadow', - '-webkit-box-shadow': 'box-shadow', - - '-moz-transform': 'transform', - '-webkit-transform': 'transform', - '-o-transform': 'transform', - '-ms-transform': 'transform', - - '-moz-transform-origin': 'transform-origin', - '-webkit-transform-origin': 'transform-origin', - '-o-transform-origin': 'transform-origin', - '-ms-transform-origin': 'transform-origin', - - '-moz-box-sizing': 'box-sizing', - '-webkit-box-sizing': 'box-sizing', - }; - let properties, num, started; - - const startRule = () => { - started = 1; - properties = {}; - num = 1; - }; - - const endRule = () => { - started = 0; - const needsStandard = []; - - for (const prop in properties) { - if (prop in propertiesToCheck) { - needsStandard.push({ - actual: prop, - needed: propertiesToCheck[prop], - }); - } - } - - for (const {needed, actual} of needsStandard) { - const {line, col} = properties[actual][0].name; - if (!properties[needed]) { - reporter.report(`Missing standard property '${needed}' to go along with '${actual}'.`, - line, col, this); - } else if (properties[needed][0].pos < properties[actual][0].pos) { - reporter.report(`Standard property '${needed}' should come after vendor-prefixed property '${actual}'.`, - line, col, this); - } - } - }; - - CSSLint.Util.registerBlockEvents(parser, startRule, endRule, event => { - if (!started) return; - const name = event.property.text.toLowerCase(); - let prop = properties[name]; - if (!prop) prop = properties[name] = []; - prop.push({ - name: event.property, - value: event.value, - pos: num++, - }); - }); - }, -}); - -CSSLint.addRule({ - id: 'zero-units', - name: 'Disallow units for 0 values', - desc: "You don't need to specify units when a value is 0.", - url: 'https://github.com/CSSLint/csslint/wiki/Disallow-units-for-zero-values', - browsers: 'All', - - init(parser, reporter) { - parser.addListener('property', event => { - for (const {units, type, value, line, col} of event.value.parts) { - if ((units || type === 'percentage') && value === 0 && type !== 'time') { - reporter.report("Values of 0 shouldn't have units specified.", line, col, this); - } - } - }); - }, -}); - -//endregion