diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 48782997..8fbf088e 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -738,6 +738,194 @@ "message": "Show active style count", "description": "Label (must be very short) for the checkbox in the toolbar button context menu controlling toolbar badge text." }, + "meta_invalidCheckboxDefault": { + "message": "Invalid @var checkbox: value must be 0 or 1", + "description": "Error displayed when the value of @var checkbox is invalid" + }, + "meta_invalidColor": { + "message": "Invalid @var color: $color$ is not a color", + "description": "Error displayed when the value of @var color is invalid", + "placeholders": { + "color": { + "content": "$1" + } + } + }, + "meta_invalidRange": { + "message": "Invalid @var $type$: value must be a number or an array", + "description": "Error displayed when the value of @var range or @var number is invalid", + "placeholders": { + "type": { + "content": "$1" + } + } + }, + "meta_invalidRangeMultipleUnits": { + "message": "Invalid @var $type$: multiple units are defined", + "description": "Error displayed when the value of @var range or @var number is invalid", + "placeholders": { + "type": { + "content": "$1" + } + } + }, + "meta_invalidRangeTooManyValues": { + "message": "Invalid @var $type$: the array contains too many items", + "description": "Error displayed when the value of @var range or @var number is invalid", + "placeholders": { + "type": { + "content": "$1" + } + } + }, + "meta_invalidRangeValue": { + "message": "Invalid @var $type$: items in the array must be number, string, or null", + "description": "Error displayed when the value of @var range or @var number is invalid", + "placeholders": { + "type": { + "content": "$1" + } + } + }, + "meta_invalidRangeDefault": { + "message": "Invalid @var $type$: default value is null", + "description": "Error displayed when the value of @var range or @var number is invalid", + "placeholders": { + "type": { + "content": "$1" + } + } + }, + "meta_invalidRangeMin": { + "message": "Invalid @var $type$: default value is lower than the minimum", + "description": "Error displayed when the value of @var range or @var number is invalid", + "placeholders": { + "type": { + "content": "$1" + } + } + }, + "meta_invalidRangeMax": { + "message": "Invalid @var $type$: default value is larger than the maximum", + "description": "Error displayed when the value of @var range or @var number is invalid", + "placeholders": { + "type": { + "content": "$1" + } + } + }, + "meta_invalidRangeStep": { + "message": "Invalid @var $type$: default value is not a mutiple of the step", + "description": "Error displayed when the value of @var range or @var number is invalid", + "placeholders": { + "type": { + "content": "$1" + } + } + }, + "meta_invalidSelectEmptyOptions": { + "message": "Invalid @var select: options list is empty", + "description": "Error displayed when the value of @var select is invalid" + }, + "meta_invalidSelectMultipleDefaults": { + "message": "Invalid @var select: multiple default options are defined", + "description": "Error displayed when the value of @var select is invalid" + }, + "meta_invalidSelectValueMismatch": { + "message": "Invalid @var select: value doesn't exist in the option list", + "description": "Error displayed when the value of @var select is invalid" + }, + "meta_invalidURLProtocol": { + "message": "Invalid URL protocol. Only http and https are allowed: $protocol$", + "description": "Error displayed when the protocol of the URL is invalid", + "placeholders": { + "protocol": { + "content": "$1" + } + } + }, + "meta_invalidVersion": { + "message": "Invalid version number. The value doesn't match SemVer pattern: $version$", + "description": "Error displayed when @version is invalid", + "placeholders": { + "version": { + "content": "$1" + } + } + }, + "meta_invalidNumber": { + "message": "Expect a number", + "description": "Error displayed when the value is expected to be a number" + }, + "meta_invalidString": { + "message": "Expect a quoted string", + "description": "Error displayed when the value is expected to be a quoted string" + }, + "meta_invalidWord": { + "message": "Expect a word", + "description": "Error displayed when the value is expected to be a word" + }, + "meta_missingChar": { + "message": "Expect characters: $chars$", + "description": "Error displayed when the value is expected to be some characters", + "placeholders": { + "chars": { + "content": "$1" + } + } + }, + "meta_missingEOT": { + "message": "Expect EOT data", + "description": "Error displayed when the value is expected to be an EOT list" + }, + "meta_missingMandatory": { + "message": "Missing mandatory metadata: $keys$", + "description": "Error displayed when mandatory keys are missing", + "placeholders": { + "keys": { + "content": "$1" + } + } + }, + "meta_unknownJSONLiteral": { + "message": "Invalid JSON: $literal$ is not a valid JSON literal", + "description": "Error displayed when JSON value is invalid", + "placeholders": { + "literal": { + "content": "$1" + } + } + }, + "meta_unknownMeta": { + "message": "Unknown metadata: $key$", + "description": "Error displayed when unknown metadata is parsed", + "placeholders": { + "key": { + "content": "$1" + } + } + }, + "meta_unknownVarType": { + "message": "Unknown @$varkey$ type: $vartype$", + "description": "Error displayed when unknown variable type is parsed", + "placeholders": { + "varkey": { + "content": "$1" + }, + "vartype": { + "content": "$2" + } + } + }, + "meta_unknownPreprocessor": { + "message": "Unknown @preprocessor: $preprocessor$", + "description": "Error displayed when unknown @preprocessor is parsed", + "placeholders": { + "preprocessor": { + "content": "$1" + } + } + }, "noStylesForSite": { "message": "No styles installed for this site.", "description": "Text displayed when no styles are installed for the current site" @@ -1087,50 +1275,6 @@ }, "description": "Confirmation when re-installing a style" }, - "styleMetaErrorCheckbox": { - "message": "Invalid @var checkbox: value must be 0 or 1", - "description": "Error displayed when the value of @var checkbox is invalid" - }, - "styleMetaErrorColor": { - "message": "$color$ is not a valid color", - "placeholders": { - "color": { - "content": "$1" - } - }, - "description": "Error displayed when the value of @var color is invalid" - }, - "styleMetaErrorRangeOrNumber": { - "message": "Invalid @var $type$: value must be an array containing at least one number at index zero", - "description": "Error displayed when the value of @var number or @var range is invalid", - "placeholders": { - "type": { - "content": "$1" - } - } - }, - "styleMetaErrorPreprocessor": { - "message": "Unsupported @preprocessor: $preprocessor$", - "placeholders": { - "preprocessor": { - "content": "$1" - } - }, - "description": "Error displayed when the value of @preprocessor is not supported" - }, - "styleMetaErrorSelectValueMismatch": { - "message": "Invalid @select: value doesn't exist in the list", - "description": "Error displayed when the value of @select is invalid" - }, - "styleMissingMeta": { - "message": "Missing metadata @$key$", - "placeholders": { - "key": { - "content": "$1" - } - }, - "description": "Error displayed when a mandatory metadata is missing" - }, "styleMissingName": { "message": "Enter a name", "description": "Error displayed when user saves without providing a name" diff --git a/background/background-worker.js b/background/background-worker.js new file mode 100644 index 00000000..630c33b0 --- /dev/null +++ b/background/background-worker.js @@ -0,0 +1,167 @@ +/* global workerUtil importScripts parseMozFormat metaParser styleCodeEmpty colorConverter */ +'use strict'; + +importScripts('/js/worker-util.js'); +const {loadScript, createAPI} = 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); + } +}); + +function compileUsercss(preprocessor, code, vars) { + loadScript('/vendor-overwrites/csslint/parserlib.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}; + }); + + 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; + }, {}); + } + + 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 getUsercssCompiler(preprocessor) { + const BUILDER = { + default: { + postprocess(sections, vars) { + loadScript('/js/sections-util.js'); + let varDef = Object.keys(vars).map(k => ` --${k}: ${vars[k].value};\n`).join(''); + if (!varDef) return; + varDef = ':root {\n' + varDef + '}\n'; + for (const section of sections) { + if (!styleCodeEmpty(section.code)) { + section.code = varDef + section.code; + } + } + } + }, + stylus: { + preprocess(source, vars) { + loadScript('/vendor/stylus-lang-bundle/stylus.min.js'); + return new Promise((resolve, reject) => { + const varDef = Object.keys(vars).map(key => `${key} = ${vars[key].value};\n`).join(''); + if (!Error.captureStackTrace) Error.captureStackTrace = () => {}; + self.stylus(varDef + source).render((err, output) => { + if (err) { + reject(err); + } else { + resolve(output); + } + }); + }); + } + }, + less: { + preprocess(source, vars) { + if (!self.less) { + self.less = { + logLevel: 0, + useFileCache: false, + }; + } + loadScript('/vendor/less/less.min.js'); + 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'); + const pool = new Map(); + return Promise.resolve(doReplace(source)); + + function getValue(name, rgb) { + if (!vars.hasOwnProperty(name)) { + if (name.endsWith('-rgb')) { + return getValue(name.slice(0, -4), true); + } + return null; + } + if (rgb) { + if (vars[name].type === 'color') { + const color = colorConverter.parse(vars[name].value); + if (!color) return null; + const {r, g, b} = color; + return `${r}, ${g}, ${b}`; + } + return null; + } + if (vars[name].type === 'dropdown' || vars[name].type === 'select') { + // prevent infinite recursion + pool.set(name, ''); + return doReplace(vars[name].value); + } + return vars[name].value; + } + + function doReplace(text) { + return text.replace(/\/\*\[\[([\w-]+)\]\]\*\//g, (match, name) => { + if (!pool.has(name)) { + const value = getValue(name); + pool.set(name, value === null ? match : value); + } + return pool.get(name); + }); + } + } + } + }; + + if (preprocessor) { + if (!BUILDER[preprocessor]) { + throw new Error('unknwon preprocessor'); + } + return BUILDER[preprocessor]; + } + return BUILDER.default; +} diff --git a/background/background.js b/background/background.js index 7ab9dd6b..f5dbce9a 100644 --- a/background/background.js +++ b/background/background.js @@ -1,9 +1,13 @@ /* global detectSloppyRegexps download prefs openURL FIREFOX CHROME VIVALDI openEditor debounce URLS ignoreChromeError queryTabs getTab - usercss styleManager db msg navigatorUtil iconUtil -*/ + usercss styleManager db msg navigatorUtil iconUtil */ 'use strict'; +// eslint-disable-next-line no-var +var backgroundWorker = workerUtil.createWorker({ + url: '/background/background-worker.js' +}); + window.API_METHODS = Object.assign(window.API_METHODS || {}, { getSectionsByUrl: styleManager.getSectionsByUrl, getSectionsById: styleManager.getSectionsById, @@ -26,7 +30,7 @@ window.API_METHODS = Object.assign(window.API_METHODS || {}, { return download(msg.url, msg); }, parseCss({code}) { - return usercss.invokeWorker({action: 'parse', code}); + return backgroundWorker.parseMozFormat({code}); }, getPrefs: prefs.getAll, diff --git a/background/parserlib-loader.js b/background/parserlib-loader.js deleted file mode 100644 index cf5ec6e6..00000000 --- a/background/parserlib-loader.js +++ /dev/null @@ -1,9 +0,0 @@ -/* global importScripts parserlib parseMozFormat */ -'use strict'; - -importScripts('/vendor-overwrites/csslint/parserlib.js', '/js/moz-parser.js'); -parserlib.css.Tokens[parserlib.css.Tokens.COMMENT].hide = false; - -self.onmessage = ({data}) => { - self.postMessage(parseMozFormat(data)); -}; diff --git a/background/update.js b/background/update.js index 0f089cf7..15256fc2 100644 --- a/background/update.js +++ b/background/update.js @@ -153,24 +153,27 @@ function maybeUpdateUsercss() { // TODO: when sourceCode is > 100kB use http range request(s) for version check - return download(style.updateUrl).then(text => { - const json = usercss.buildMeta(text); - const {usercssData: {version}} = style; - const {usercssData: {version: newVersion}} = json; - switch (Math.sign(semverCompare(version, newVersion))) { - case 0: - // re-install is invalid in a soft upgrade - if (!ignoreDigest) { - const sameCode = text === style.sourceCode; - return Promise.reject(sameCode ? STATES.SAME_CODE : STATES.SAME_VERSION); - } - break; - case 1: - // downgrade is always invalid - return Promise.reject(STATES.ERROR_VERSION); - } - return usercss.buildCode(json); - }); + return download(style.updateUrl) + .then(text => + usercss.buildMeta(text) + .then(json => { + const {usercssData: {version}} = style; + const {usercssData: {version: newVersion}} = json; + switch (Math.sign(semverCompare(version, newVersion))) { + case 0: + // re-install is invalid in a soft upgrade + if (!ignoreDigest) { + const sameCode = text === style.sourceCode; + return Promise.reject(sameCode ? STATES.SAME_CODE : STATES.SAME_VERSION); + } + break; + case 1: + // downgrade is always invalid + return Promise.reject(STATES.ERROR_VERSION); + } + return usercss.buildCode(json); + }) + ); } function maybeSave(json = {}) { diff --git a/background/usercss-helper.js b/background/usercss-helper.js index 572f925c..e32c9f34 100644 --- a/background/usercss-helper.js +++ b/background/usercss-helper.js @@ -42,14 +42,13 @@ if (style.usercssData) { return Promise.resolve(style); } - try { - const {sourceCode} = style; - // allow sourceCode to be normalized - delete style.sourceCode; - return Promise.resolve(Object.assign(usercss.buildMeta(sourceCode), style)); - } catch (e) { - return Promise.reject(e); - } + + // allow sourceCode to be normalized + const {sourceCode} = style; + delete style.sourceCode; + + return usercss.buildMeta(sourceCode) + .then(newStyle => Object.assign(newStyle, style)); } function assignVars(style) { @@ -62,7 +61,8 @@ style.id = dup.id; if (style.reason !== 'config') { // preserve style.vars during update - usercss.assignVars(style, dup); + return usercss.assignVars(style, dup) + .then(() => style); } } return style; @@ -95,7 +95,8 @@ function doBuild(style) { if (vars) { const oldStyle = {usercssData: {vars}}; - usercss.assignVars(style, oldStyle); + return usercss.assignVars(style, oldStyle) + .then(() => usercss.buildCode(style)); } return usercss.buildCode(style); } diff --git a/edit.html b/edit.html index b5b183c2..03eef893 100644 --- a/edit.html +++ b/edit.html @@ -70,6 +70,7 @@ + @@ -81,8 +82,6 @@ - - diff --git a/edit/codemirror-default.js b/edit/codemirror-default.js index 4bf9498e..417c8bf2 100644 --- a/edit/codemirror-default.js +++ b/edit/codemirror-default.js @@ -351,8 +351,9 @@ CodeMirror.hint && (() => { } // USO vars in usercss mode editor - const list = Object.keys(editor.getStyle().usercssData.vars) - .filter(name => name.startsWith(leftPart)); + const vars = editor.getStyle().usercssData.vars; + const list = vars ? + Object.keys(vars).filter(name => name.startsWith(leftPart)) : []; return { list, from: {line, ch: prev}, diff --git a/edit/edit.js b/edit/edit.js index d4dbd0b2..a0bad638 100644 --- a/edit/edit.js +++ b/edit/edit.js @@ -2,11 +2,14 @@ createSourceEditor queryTabs sessionStorageHash getOwnTab FIREFOX API tryCatch closeCurrentTab messageBox debounce beautify - moveFocus msg createSectionsEditor rerouteHotkeys -*/ + moveFocus msg createSectionsEditor rerouteHotkeys */ /* exported showCodeMirrorPopup */ 'use strict'; +const editorWorker = workerUtil.createWorker({ + url: '/edit/editor-worker.js' +}); + let saveSizeOnClose; // direct & reverse mapping of @-moz-document keywords and internal property names diff --git a/edit/editor-worker-body.js b/edit/editor-worker-body.js deleted file mode 100644 index a4458dd6..00000000 --- a/edit/editor-worker-body.js +++ /dev/null @@ -1,118 +0,0 @@ -/* global importScripts parseMozFormat parserlib CSSLint require */ -'use strict'; - -createAPI({ - csslint: (code, config) => { - loadParserLib(); - loadScript(['/vendor-overwrites/csslint/csslint.js']); - return CSSLint.verify(code, config).messages - .map(m => Object.assign(m, {rule: {id: m.rule.id}})); - }, - stylelint: (code, config) => { - loadScript(['/vendor/stylelint-bundle/stylelint-bundle.min.js']); - return require('stylelint').lint({code, config}); - }, - parseMozFormat: data => { - loadParserLib(); - loadScript(['/js/moz-parser.js']); - return parseMozFormat(data); - }, - getStylelintRules, - getCsslintRules -}); - -function getCsslintRules() { - loadScript(['/vendor-overwrites/csslint/csslint.js']); - return CSSLint.getRules().map(rule => { - const output = {}; - for (const [key, value] of Object.entries(rule)) { - if (typeof value !== 'function') { - output[key] = value; - } - } - return output; - }); -} - -function getStylelintRules() { - loadScript(['/vendor/stylelint-bundle/stylelint-bundle.min.js']); - const stylelint = require('stylelint'); - const options = {}; - const rxPossible = /\bpossible:("(?:[^"]*?)"|\[(?:[^\]]*?)\]|\{(?:[^}]*?)\})/g; - const rxString = /"([-\w\s]{3,}?)"/g; - for (const id of Object.keys(stylelint.rules)) { - const ruleCode = String(stylelint.rules[id]); - const sets = []; - let m, mStr; - while ((m = rxPossible.exec(ruleCode))) { - const possible = m[1]; - const set = []; - while ((mStr = rxString.exec(possible))) { - const s = mStr[1]; - if (s.includes(' ')) { - set.push(...s.split(/\s+/)); - } else { - set.push(s); - } - } - if (possible.includes('ignoreAtRules')) { - set.push('ignoreAtRules'); - } - if (possible.includes('ignoreShorthands')) { - set.push('ignoreShorthands'); - } - if (set.length) { - sets.push(set); - } - } - if (sets.length) { - options[id] = sets; - } - } - return options; -} - -function loadParserLib() { - if (typeof parserlib !== 'undefined') { - return; - } - importScripts('/vendor-overwrites/csslint/parserlib.js'); - parserlib.css.Tokens[parserlib.css.Tokens.COMMENT].hide = false; -} - -const loadedUrls = new Set(); -function loadScript(urls) { - urls = urls.filter(u => !loadedUrls.has(u)); - importScripts(...urls); - urls.forEach(u => loadedUrls.add(u)); -} - -function createAPI(methods) { - self.onmessage = e => { - const message = e.data; - Promise.resolve() - .then(() => methods[message.action](...message.args)) - .then(result => ({ - id: message.id, - error: false, - data: result - })) - .catch(err => ({ - id: message.id, - error: true, - data: cloneError(err) - })) - .then(data => self.postMessage(data)); - }; -} - -function cloneError(err) { - return Object.assign({ - name: err.name, - stack: err.stack, - message: err.message, - lineNumber: err.lineNumber, - columnNumber: err.columnNumber, - fileName: err.fileName - }, err); -} diff --git a/edit/editor-worker.js b/edit/editor-worker.js index aaf4bd46..a84bb939 100644 --- a/edit/editor-worker.js +++ b/edit/editor-worker.js @@ -1,40 +1,89 @@ +/* global importScripts workerUtil CSSLint require metaParser */ /* exported editorWorker */ 'use strict'; -// eslint-disable-next-line no-var -var editorWorker = (() => { - let worker; - return new Proxy({}, { - get: (target, prop) => - (...args) => { - if (!worker) { - worker = createWorker(); - } - return worker.invoke(prop, args); +importScripts('/js/worker-util.js'); +const {createAPI, loadScript} = workerUtil; + +createAPI({ + csslint: (code, config) => { + loadScript('/vendor-overwrites/csslint/parserlib.js', '/vendor-overwrites/csslint/csslint.js'); + return CSSLint.verify(code, config).messages + .map(m => Object.assign(m, {rule: {id: m.rule.id}})); + }, + stylelint: (code, config) => { + loadScript('/vendor/stylelint-bundle/stylelint-bundle.min.js'); + return require('stylelint').lint({code, config}); + }, + metalint: code => { + loadScript( + '/vendor/usercss-meta/usercss-meta.min.js', + '/vendor-overwrites/colorpicker/colorconverter.js', + '/js/meta-parser.js' + ); + const result = metaParser.lint(code); + // extract needed info + result.errors = result.errors.map(err => + ({ + code: err.code, + args: err.args, + message: err.message, + index: err.index + }) + ); + return result; + }, + getStylelintRules, + getCsslintRules +}); + +function getCsslintRules() { + loadScript('/vendor-overwrites/csslint/csslint.js'); + return CSSLint.getRules().map(rule => { + const output = {}; + for (const [key, value] of Object.entries(rule)) { + if (typeof value !== 'function') { + output[key] = value; } + } + return output; }); +} - function createWorker() { - let id = 0; - const pendingResponse = new Map(); - const worker = new Worker('/edit/editor-worker-body.js'); - worker.onmessage = e => { - const message = e.data; - pendingResponse.get(message.id)[message.error ? 'reject' : 'resolve'](message.data); - pendingResponse.delete(message.id); - }; - return {invoke}; - - function invoke(action, args) { - return new Promise((resolve, reject) => { - pendingResponse.set(id, {resolve, reject}); - worker.postMessage({ - id, - action, - args - }); - id++; - }); +function getStylelintRules() { + loadScript('/vendor/stylelint-bundle/stylelint-bundle.min.js'); + const stylelint = require('stylelint'); + const options = {}; + const rxPossible = /\bpossible:("(?:[^"]*?)"|\[(?:[^\]]*?)\]|\{(?:[^}]*?)\})/g; + const rxString = /"([-\w\s]{3,}?)"/g; + for (const id of Object.keys(stylelint.rules)) { + const ruleCode = String(stylelint.rules[id]); + const sets = []; + let m, mStr; + while ((m = rxPossible.exec(ruleCode))) { + const possible = m[1]; + const set = []; + while ((mStr = rxString.exec(possible))) { + const s = mStr[1]; + if (s.includes(' ')) { + set.push(...s.split(/\s+/)); + } else { + set.push(s); + } + } + if (possible.includes('ignoreAtRules')) { + set.push('ignoreAtRules'); + } + if (possible.includes('ignoreShorthands')) { + set.push('ignoreShorthands'); + } + if (set.length) { + sets.push(set); + } + } + if (sets.length) { + options[id] = sets; } } -})(); + return options; +} diff --git a/edit/linter-meta.js b/edit/linter-meta.js index b453df9c..99ab04f6 100644 --- a/edit/linter-meta.js +++ b/edit/linter-meta.js @@ -1,4 +1,4 @@ -/* global linter API */ +/* global linter API editorWorker */ /* exported createMetaCompiler */ 'use strict'; @@ -19,25 +19,23 @@ function createMetaCompiler(cm) { if (match[0] === meta && match.index === metaIndex) { return cache; } - return API.parseUsercss({sourceCode: match[0], metaOnly: true}) - .then(result => result.usercssData) - .then(result => { - for (const cb of updateListeners) { - cb(result); + return editorWorker.metalint(match[0]) + .then(({metadata, errors}) => { + if (errors.every(err => err.code === 'unknownMeta')) { + for (const cb of updateListeners) { + cb(metadata); + } } + cache = errors.map(err => + ({ + from: cm.posFromIndex((err.index || 0) + match.index), + to: cm.posFromIndex((err.index || 0) + match.index), + message: err.code && chrome.i18n.getMessage(`meta_${err.code}`, err.args) || err.message, + severity: err.code === 'unknownMeta' ? 'warning' : 'error' + }) + ); meta = match[0]; metaIndex = match.index; - cache = []; - return cache; - }, err => { - meta = match[0]; - metaIndex = match.index; - cache = [{ - from: cm.posFromIndex((err.index || 0) + match.index), - to: cm.posFromIndex((err.index || 0) + match.index), - message: err.message, - severity: 'error' - }]; return cache; }); }); diff --git a/edit/sections-editor.js b/edit/sections-editor.js index 8d0e7ff7..1fdb39b3 100644 --- a/edit/sections-editor.js +++ b/edit/sections-editor.js @@ -432,7 +432,7 @@ function createSectionsEditor(style) { function doImport({replaceOldStyle = false}) { lockPageUI(true); - editorWorker.parseMozFormat({code: popup.codebox.getValue().trim()}) + API.parseCss({code: popup.codebox.getValue().trim()}) .then(({sections, errors}) => { // shouldn't happen but just in case if (!sections.length || errors.length) { diff --git a/edit/source-editor.js b/edit/source-editor.js index c0476f80..ce2f4fa1 100644 --- a/edit/source-editor.js +++ b/edit/source-editor.js @@ -249,7 +249,7 @@ function createSourceEditor(style) { .then(replaceStyle) .catch(err => { if (err.handled) return; - if (err.message === t('styleMissingMeta', 'name')) { + if (err.code === 'missingMandatory' && err.args.includes('name')) { messageBox.confirm(t('usercssReplaceTemplateConfirmation')).then(ok => ok && chromeSync.setLZValue('usercssTemplate', code) .then(() => chromeSync.getLZValue('usercssTemplate')) @@ -258,7 +258,7 @@ function createSourceEditor(style) { } const contents = Array.isArray(err) ? $create('pre', err.join('\n')) : - [String(err)]; + [err.message || String(err)]; if (Number.isInteger(err.index)) { const pos = cm.posFromIndex(err.index); contents[0] += ` (line ${pos.line + 1} col ${pos.ch + 1})`; diff --git a/install-usercss/install-usercss.js b/install-usercss/install-usercss.js index 8b375e60..3d2df039 100644 --- a/install-usercss/install-usercss.js +++ b/install-usercss/install-usercss.js @@ -241,7 +241,7 @@ const contents = Array.isArray(err) ? [$create('pre', err.join('\n'))] : [err && err.message && $create('pre', err.message) || err || 'Unknown error']; - if (Number.isInteger(err.index)) { + if (Number.isInteger(err.index) && typeof contents[0] === 'string') { const pos = cm.posFromIndex(err.index); contents[0] = `${pos.line + 1}:${pos.ch + 1} ` + contents[0]; contents.push($create('pre', drawLinePointer(pos))); diff --git a/js/meta-parser.js b/js/meta-parser.js new file mode 100644 index 00000000..e660effb --- /dev/null +++ b/js/meta-parser.js @@ -0,0 +1,78 @@ +/* global usercssMeta colorConverter */ +'use strict'; + +// eslint-disable-next-line no-var +var metaParser = (() => { + const {createParser, ParseError} = usercssMeta; + const PREPROCESSORS = new Set(['default', 'uso', 'stylus', 'less']); + const options = { + validateKey: { + preprocessor: state => { + if (!PREPROCESSORS.has(state.value)) { + throw new ParseError({ + code: 'unknownPreprocessor', + args: [state.value], + index: state.valueIndex + }); + } + } + }, + validateVar: { + select: state => { + if (state.varResult.options.every(o => o.name !== state.value)) { + throw new ParseError({ + code: 'invalidSelectValueMismatch', + index: state.valueIndex + }); + } + }, + color: state => { + const color = colorConverter.parse(state.value); + if (!color) { + throw new ParseError({ + code: 'invalidColor', + args: [state.value], + index: state.valueIndex + }); + } + state.value = colorConverter.format(color, 'rgb'); + } + } + }; + const parser = createParser(options); + const looseParser = createParser(Object.assign({}, options, {allowErrors: true, unknownKey: 'throw'})); + return { + parse, + lint, + nullifyInvalidVars + }; + + function parse(text, indexOffset) { + try { + return parser.parse(text); + } catch (err) { + if (typeof err.index === 'number') { + err.index += indexOffset; + } + throw err; + } + } + + function lint(text) { + return looseParser.parse(text); + } + + function nullifyInvalidVars(vars) { + for (const va of Object.values(vars)) { + if (va.value === null) { + continue; + } + try { + parser.validateVar(va); + } catch (err) { + va.value = null; + } + } + return vars; + } +})(); diff --git a/js/sections-equal.js b/js/sections-util.js similarity index 68% rename from js/sections-equal.js rename to js/sections-util.js index 68260560..e85c4ea6 100644 --- a/js/sections-equal.js +++ b/js/sections-util.js @@ -1,6 +1,29 @@ /* exported styleSectionsEqual */ 'use strict'; +const RX_NAMESPACE = /\s*(@namespace\s+(?:\S+\s+)?url\(http:\/\/.*?\);)\s*/g; +const RX_CHARSET = /\s*@charset\s+(['"]).*?\1\s*;\s*/g; +const RX_CSS_COMMENTS = /\/\*[\s\S]*?(?:\*\/|$)/g; + +function styleCodeEmpty(code) { + // Collect the global section if it's not empty, not comment-only, not namespace-only. + const cmtOpen = code && code.indexOf('/*'); + if (cmtOpen >= 0) { + const cmtCloseLast = code.lastIndexOf('*/'); + if (cmtCloseLast < 0) { + code = code.substr(0, cmtOpen); + } else { + code = code.substr(0, cmtOpen) + + code.substring(cmtOpen, cmtCloseLast + 2).replace(RX_CSS_COMMENTS, '') + + code.substr(cmtCloseLast + 2); + } + } + if (!code || !code.trim()) return true; + if (code.includes('@namespace')) code = code.replace(RX_NAMESPACE, '').trim(); + if (code.includes('@charset')) code = code.replace(RX_CHARSET, '').trim(); + return !code; +} + /** * @param {Style} a - first style object * @param {Style} b - second style object diff --git a/js/usercss.js b/js/usercss.js index bb4efc5e..3d2bb5a8 100644 --- a/js/usercss.js +++ b/js/usercss.js @@ -1,514 +1,56 @@ -/* global loadScript semverCompare colorConverter styleCodeEmpty */ +/* global loadScript semverCompare colorConverter styleCodeEmpty backgroundWorker */ /* exported usercss */ 'use strict'; const usercss = (() => { - // true = global - // false or 0 = private - // = global key name - // = (style, newValue) - const KNOWN_META = new Map([ - ['author', true], - ['advanced', 0], - ['description', true], - ['homepageURL', 'url'], - ['icon', 0], - ['license', 0], - ['name', true], - ['namespace', 0], - //['noframes', 0], - ['preprocessor', 0], - ['supportURL', 0], - ['updateURL', (style, newValue) => { - // always preserve locally installed style's updateUrl - if (!/^file:/.test(style.updateUrl)) { - style.updateUrl = newValue; - } - }], - ['var', 0], - ['version', 0], - ]); - const MANDATORY_META = ['name', 'namespace', 'version']; - const META_VARS = ['text', 'color', 'checkbox', 'select', 'dropdown', 'image', 'number', 'range']; - const META_URLS = [...KNOWN_META.keys()].filter(k => k.endsWith('URL')); - - const BUILDER = { - default: { - postprocess(sections, vars) { - let varDef = Object.keys(vars).map(k => ` --${k}: ${vars[k].value};\n`).join(''); - if (!varDef) return; - varDef = ':root {\n' + varDef + '}\n'; - for (const section of sections) { - if (!styleCodeEmpty(section.code)) { - section.code = varDef + section.code; - } - } - } - }, - stylus: { - preprocess(source, vars) { - return loadScript('/vendor/stylus-lang-bundle/stylus.min.js').then(() => ( - new Promise((resolve, reject) => { - const varDef = Object.keys(vars).map(key => `${key} = ${vars[key].value};\n`).join(''); - if (!Error.captureStackTrace) Error.captureStackTrace = () => {}; - window.stylus(varDef + source).render((err, output) => { - if (err) { - reject(err); - } else { - resolve(output); - } - }); - }) - )); - } - }, - less: { - preprocess(source, vars) { - window.less = window.less || { - logLevel: 0, - useFileCache: false, - }; - const varDefs = Object.keys(vars).map(key => `@${key}:${vars[key].value};\n`).join(''); - return loadScript('/vendor/less/less.min.js') - .then(() => window.less.render(varDefs + source)) - .then(({css}) => css); - } - }, - uso: { - preprocess(source, vars) { - const pool = new Map(); - return Promise.resolve(doReplace(source)); - - function getValue(name, rgb) { - if (!vars.hasOwnProperty(name)) { - if (name.endsWith('-rgb')) { - return getValue(name.slice(0, -4), true); - } - return null; - } - if (rgb) { - if (vars[name].type === 'color') { - const color = colorConverter.parse(vars[name].value); - if (!color) return null; - const {r, g, b} = color; - return `${r}, ${g}, ${b}`; - } - return null; - } - if (vars[name].type === 'dropdown' || vars[name].type === 'select') { - // prevent infinite recursion - pool.set(name, ''); - return doReplace(vars[name].value); - } - return vars[name].value; - } - - function doReplace(text) { - return text.replace(/\/\*\[\[([\w-]+)\]\]\*\//g, (match, name) => { - if (!pool.has(name)) { - const value = getValue(name); - pool.set(name, value === null ? match : value); - } - return pool.get(name); - }); - } - } - } + const GLOBAL_METAS = { + author: undefined, + description: undefined, + homepageURL: 'url', + // updateURL: 'updateUrl', + name: undefined, }; - - const RX_NUMBER = /-?\d+(\.\d+)?\s*/y; - const RX_WHITESPACE = /\s*/y; - const RX_WORD = /([\w-]+)\s*/y; - const RX_STRING_BACKTICK = /(`(?:\\`|[\s\S])*?`)\s*/y; - const RX_STRING_QUOTED = /((['"])(?:\\\2|[^\n])*?\2|\w+)\s*/y; - - const worker = {}; - - function getMetaSource(source) { - const commentRe = /\/\*[\s\S]*?\*\//g; - const metaRe = /==userstyle==[\s\S]*?==\/userstyle==/i; - - let m; - // iterate through each comment - while ((m = commentRe.exec(source))) { - const commentSource = source.slice(m.index, m.index + m[0].length); - const n = commentSource.match(metaRe); - if (n) { - return { - index: m.index + n.index, - text: n[0] - }; - } - } - return {text: '', index: 0}; - } - - function parseWord(state, error = 'invalid word') { - RX_WORD.lastIndex = state.re.lastIndex; - const match = RX_WORD.exec(state.text); - if (!match) { - throw new Error((state.errorPrefix || '') + error); - } - state.value = match[1]; - state.re.lastIndex += match[0].length; - } - - function parseVar(state) { - const result = { - type: null, - label: null, - name: null, - value: null, - default: null, - options: null - }; - - parseWord(state, 'missing type'); - result.type = state.type = state.value; - - if (!META_VARS.includes(state.type)) { - throw new Error(`unknown type: ${state.type}`); - } - - parseWord(state, 'missing name'); - result.name = state.value; - - parseString(state); - result.label = state.value; - - const {re, type, text} = state; - - switch (type === 'image' && state.key === 'var' ? '@image@var' : type) { - case 'checkbox': { - const match = text.slice(re.lastIndex).match(/([01])\s+/); - if (!match) { - throw new Error('value must be 0 or 1'); - } - re.lastIndex += match[0].length; - result.default = match[1]; - break; - } - - case 'select': - case '@image@var': { - state.errorPrefix = 'Invalid JSON: '; - parseJSONValue(state); - state.errorPrefix = ''; - const extractDefaultOption = (key, value) => { - if (key.endsWith('*')) { - const option = createOption(key.slice(0, -1), value); - result.default = option.name; - return option; - } - return createOption(key, value); - }; - if (Array.isArray(state.value)) { - result.options = state.value.map(k => extractDefaultOption(k)); - } else { - result.options = Object.keys(state.value).map(k => extractDefaultOption(k, state.value[k])); - } - if (result.default === null) { - result.default = (result.options[0] || {}).name || ''; - } - break; - } - - case 'number': - case 'range': { - state.errorPrefix = 'Invalid JSON: '; - parseJSONValue(state); - state.errorPrefix = ''; - // [default, start, end, step, units] (start, end, step & units are optional) - if (Array.isArray(state.value) && state.value.length) { - // label may be placed anywhere - result.units = (state.value.find(i => typeof i === 'string') || '').replace(/[\d.+-]/g, ''); - const range = state.value.filter(i => typeof i === 'number' || i === null); - result.default = range[0]; - result.min = range[1]; - result.max = range[2]; - result.step = range[3] === 0 ? 1 : range[3]; - } - break; - } - - case 'dropdown': - case 'image': { - if (text[re.lastIndex] !== '{') { - throw new Error('no open {'); - } - result.options = []; - re.lastIndex++; - while (text[re.lastIndex] !== '}') { - const option = {}; - - parseStringUnquoted(state); - option.name = state.value; - - parseString(state); - option.label = state.value; - - if (type === 'dropdown') { - parseEOT(state); - } else { - parseString(state); - } - option.value = state.value; - - result.options.push(option); - } - re.lastIndex++; - eatWhitespace(state); - result.default = result.options[0].name; - break; - } - - default: { - // text, color - parseStringToEnd(state); - result.default = state.value; - } - } - state.usercssData.vars[result.name] = result; - validateVar(result); - } - - function createOption(label, value) { - let name; - const match = label.match(/^(\w+):(.*)/); - if (match) { - ([, name, label] = match); - } - if (!name) { - name = label; - } - if (!value) { - value = name; - } - return {name, label, value}; - } - - function parseEOT(state) { - const re = /<< { - if (s[1] === q) { - return q; - } - return JSON.parse(`"${s}"`); - } - ); - } - return s; - } - - function posOrEnd(haystack, needle, start) { - const pos = haystack.indexOf(needle, start); - return pos < 0 ? haystack.length : pos; - } + const RX_META = /\/\*\s*==userstyle==[\s\S]*?==\/userstyle==\s*\*\//i; + const ERR_ARGS_IS_LIST = new Set(['missingMandatory', 'missingChar']); + return {buildMeta, buildCode, assignVars}; function buildMeta(sourceCode) { sourceCode = sourceCode.replace(/\r\n?/g, '\n'); - const usercssData = { - vars: {} - }; - const style = { reason: 'install', enabled: true, sourceCode, - sections: [], - usercssData + sections: [] }; - const {text, index: metaIndex} = getMetaSource(sourceCode); - const re = /@(\w+)[ \t\xA0]*/mg; - const state = {style, re, text, usercssData}; + const match = sourceCode.match(RX_META); + if (!match) { + throw new Error('can not find metadata'); + } - function doParse() { - let match; - while ((match = re.exec(text))) { - const key = state.key = match[1]; - const route = KNOWN_META.get(key); - if (route === undefined) { - continue; - } - if (key === 'var' || key === 'advanced') { - if (key === 'advanced') { - state.maybeUSO = true; + return backgroundWorker.parseUsercssMeta(match[0], match.index) + .catch(err => { + if (err.code) { + const args = ERR_ARGS_IS_LIST.has(err.code) ? drawList(err.args) : err.args; + const message = chrome.i18n.getMessage(`meta_${err.code}`, args); + if (message) { + err.message = message; } - parseVar(state); - } else { - parseStringToEnd(state); - usercssData[key] = state.value; } - let value = state.value; - if (key === 'version') { - value = usercssData[key] = normalizeVersion(value); - validateVersion(value); + throw err; + }) + .then(({metadata}) => { + style.usercssData = metadata; + for (const [key, value = key] of Object.entries(GLOBAL_METAS)) { + style[value] = metadata[key]; } - if (META_URLS.includes(key)) { - validateUrl(key, value); - } - switch (typeof route) { - case 'function': - route(style, value); - break; - case 'string': - style[route] = value; - break; - default: - if (route) { - style[key] = value; - } - } - } - } - - try { - doParse(); - } catch (e) { - // the source code string offset - e.index = metaIndex + state.re.lastIndex; - throw e; - } - - if (state.maybeUSO && !usercssData.preprocessor) { - usercssData.preprocessor = 'uso'; - } - - validateStyle(style); - return style; + return style; + }); } - function normalizeVersion(version) { - // https://docs.npmjs.com/misc/semver#versions - if (version[0] === 'v' || version[0] === '=') { - return version.slice(1); - } - return version; + function drawList(items) { + return items.map(i => i.length === 1 ? JSON.stringify(i) : i).join(', '); } /** @@ -518,136 +60,37 @@ const usercss = (() => { * when allowErrors is falsy or {style, errors} object when allowErrors is truthy */ function buildCode(style, allowErrors) { - const {usercssData: {preprocessor, vars}, sourceCode} = style; - let builder; - if (preprocessor) { - if (!BUILDER[preprocessor]) { - return Promise.reject(chrome.i18n.getMessage('styleMetaErrorPreprocessor', preprocessor)); - } - builder = BUILDER[preprocessor]; - } else { - builder = BUILDER.default; - } - - const sVars = simpleVars(vars); - - return ( - Promise.resolve( - builder.preprocess && builder.preprocess(sourceCode, sVars) || - sourceCode) - .then(mozStyle => invokeWorker({ - action: 'parse', - styleId: style.id, - code: mozStyle, - })) + const match = style.sourceCode.match(RX_META); + return backgroundWorker.compileUsercss( + style.usercssData.preprocessor, + style.sourceCode.slice(0, match.index) + style.sourceCode.slice(match.index + match[0].length), + style.usercssData.vars + ) .then(({sections, errors}) => { if (!errors.length) errors = false; if (!sections.length || errors && !allowErrors) { - return Promise.reject(errors); + throw errors; } style.sections = sections; - if (builder.postprocess) builder.postprocess(style.sections, sVars); return allowErrors ? {style, errors} : style; - })); - } - - function simpleVars(vars) { - // 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; - }, {}); - } - - 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 validateStyle({usercssData: data}) { - for (const prop of MANDATORY_META) { - if (!data[prop]) { - throw new Error(chrome.i18n.getMessage('styleMissingMeta', prop)); - } - } - validateVersion(data.version); - META_URLS.forEach(k => validateUrl(k, data[k])); - Object.keys(data.vars).forEach(k => validateVar(data.vars[k])); - } - - function validateVersion(version) { - semverCompare(version, '0.0.0'); - } - - function validateUrl(key, url) { - if (!url) { - return; - } - url = new URL(url); - if (!/^https?:/.test(url.protocol)) { - throw new Error(`${url.protocol} is not a valid protocol in ${key}`); - } - } - - function validateVar(va, value = 'default') { - if (va.type === 'select' || va.type === 'dropdown') { - if (va.options.every(o => o.name !== va[value])) { - throw new Error(chrome.i18n.getMessage('styleMetaErrorSelectValueMismatch')); - } - } else if (va.type === 'checkbox' && !/^[01]$/.test(va[value])) { - throw new Error(chrome.i18n.getMessage('styleMetaErrorCheckbox')); - } else if (va.type === 'color') { - va[value] = colorConverter.format(colorConverter.parse(va[value]), 'rgb'); - } else if ((va.type === 'number' || va.type === 'range') && typeof va[value] !== 'number') { - throw new Error(chrome.i18n.getMessage('styleMetaErrorRangeOrNumber', va.type)); - } } function assignVars(style, oldStyle) { const {usercssData: {vars}} = style; const {usercssData: {vars: oldVars}} = oldStyle; + if (!vars || !oldVars) { + return Promise.resolve(); + } // The type of var might be changed during the update. Set value to null if the value is invalid. for (const key of Object.keys(vars)) { if (oldVars[key] && oldVars[key].value) { vars[key].value = oldVars[key].value; - try { - validateVar(vars[key], 'value'); - } catch (e) { - vars[key].value = null; - } } } + return backgroundWorker.nullifyInvalidVars(vars) + .then(vars => { + style.usercssData.vars = vars; + }); } - - function invokeWorker(message) { - if (!worker.queue) { - worker.instance = new Worker('/background/parserlib-loader.js'); - worker.queue = []; - worker.instance.onmessage = ({data}) => { - worker.queue.shift().resolve(data.__ERROR__ ? Promise.reject(data.__ERROR__) : data); - if (worker.queue.length) { - worker.instance.postMessage(worker.queue[0].message); - } - }; - } - return new Promise(resolve => { - worker.queue.push({message, resolve}); - if (worker.queue.length === 1) { - worker.instance.postMessage(message); - } - }); - } - - return {buildMeta, buildCode, assignVars, invokeWorker}; })(); diff --git a/js/worker-util.js b/js/worker-util.js new file mode 100644 index 00000000..5e081959 --- /dev/null +++ b/js/worker-util.js @@ -0,0 +1,98 @@ +/* global importScripts */ +'use strict'; + +// eslint-disable-next-line no-var +var workerUtil = (() => { + const loadedScripts = new Set(); + return {createWorker, createAPI, loadScript, cloneError}; + + function createWorker({url, lifeTime = 30}) { + let worker; + let id; + let timer; + const pendingResponse = new Map(); + + return new Proxy({}, { + get: (target, prop) => + (...args) => { + if (!worker) { + init(); + } + return invoke(prop, args); + } + }); + + function init() { + id = 0; + worker = new Worker(url); + worker.onmessage = onMessage; + } + + function uninit() { + worker.onmessage = null; + worker.terminate(); + worker = null; + } + + function onMessage(e) { + const message = e.data; + pendingResponse.get(message.id)[message.error ? 'reject' : 'resolve'](message.data); + pendingResponse.delete(message.id); + if (!pendingResponse.size && lifeTime >= 0) { + timer = setTimeout(uninit, lifeTime * 1000); + } + } + + function invoke(action, args) { + return new Promise((resolve, reject) => { + pendingResponse.set(id, {resolve, reject}); + clearTimeout(timer); + worker.postMessage({ + id, + action, + args + }); + id++; + }); + } + } + + function createAPI(methods) { + self.onmessage = e => { + const message = e.data; + Promise.resolve() + .then(() => methods[message.action](...message.args)) + .then(result => ({ + id: message.id, + error: false, + data: result + })) + .catch(err => ({ + id: message.id, + error: true, + data: cloneError(err) + })) + .then(data => self.postMessage(data)); + }; + } + + function cloneError(err) { + return Object.assign({ + name: err.name, + stack: err.stack, + message: err.message, + lineNumber: err.lineNumber, + columnNumber: err.columnNumber, + fileName: err.fileName + }, err); + } + + function loadScript(...scripts) { + const urls = scripts.filter(u => !loadedScripts.has(u)); + if (!urls.length) { + return; + } + importScripts(...urls); + urls.forEach(u => loadedScripts.add(u)); + } +})(); diff --git a/manage.html b/manage.html index 6c97fc4e..c68be215 100644 --- a/manage.html +++ b/manage.html @@ -172,7 +172,7 @@ - + diff --git a/manage/config-dialog.js b/manage/config-dialog.js index a3188ee3..dc1b2477 100644 --- a/manage/config-dialog.js +++ b/manage/config-dialog.js @@ -184,7 +184,7 @@ function configDialog(style) { .catch(errors => { const el = $('.config-error', messageBox.element) || $('#message-box-buttons').insertAdjacentElement('afterbegin', $create('.config-error')); - el.textContent = el.title = Array.isArray(errors) ? errors.join('\n') : errors; + el.textContent = el.title = Array.isArray(errors) ? errors.join('\n') : errors.message || String(errors); }) .then(() => { saving = false; diff --git a/manifest.json b/manifest.json index bf69609a..0550b064 100644 --- a/manifest.json +++ b/manifest.json @@ -27,7 +27,8 @@ "js/messaging.js", "js/msg.js", "js/storage-util.js", - "js/sections-equal.js", + "js/sections-util.js", + "js/worker-util.js", "background/storage.js", "js/prefs.js", "js/script-loader.js", @@ -43,8 +44,7 @@ "background/search-db.js", "background/update.js", "background/openusercss-api.js", - "vendor/semver-bundle/semver.js", - "vendor-overwrites/colorpicker/colorconverter.js" + "vendor/semver-bundle/semver.js" ] }, "commands": { diff --git a/package.json b/package.json index f0fdf8dc..4ae95fa9 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "stylelint-bundle": "^8.0.0", "stylus-lang-bundle": "^0.54.5", "updates": "^4.2.1", - "web-ext": "^2.9.1" + "web-ext": "^2.9.1", + "usercss-meta": "^0.8.1" }, "scripts": { "lint": "eslint **/*.js --cache || exit 0", diff --git a/tools/update-libraries.js b/tools/update-libraries.js index 13c4b126..8e490af1 100644 --- a/tools/update-libraries.js +++ b/tools/update-libraries.js @@ -28,6 +28,9 @@ const files = { ], 'stylus-lang-bundle': [ 'stylus.min.js' + ], + 'usercss-meta': [ + 'dist/usercss-meta.min.js → usercss-meta.min.js' ] }; @@ -35,7 +38,7 @@ async function updateReadme(lib) { const pkg = await fs.readJson(`${root}/node_modules/${lib}/package.json`); const file = `${root}/vendor/${lib}/README.md`; const txt = await fs.readFile(file, 'utf8'); - return fs.writeFile(file, txt.replace(/\bv[\d.]+[-\w]*\b/g, `v${pkg.version}`)); + return fs.writeFile(file, txt.replace(/\b([v@])[\d.]+[-\w]*\b/g, `$1${pkg.version}`)); } function isFolder(fileOrFolder) { diff --git a/vendor-overwrites/csslint/parserlib.js b/vendor-overwrites/csslint/parserlib.js index 6e3a24e5..d5cbf67a 100644 --- a/vendor-overwrites/csslint/parserlib.js +++ b/vendor-overwrites/csslint/parserlib.js @@ -5505,3 +5505,5 @@ self.parserlib = (() => { //endregion })(); + +self.parserlib.css.Tokens[self.parserlib.css.Tokens.COMMENT].hide = false; diff --git a/vendor/README.md b/vendor/README.md index fc0b6e78..9c519da7 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -9,7 +9,8 @@ Using this repo, run `npm install`... the latest versions of: * `less` (https://github.com/less/less.js) is installed. * `lz-string-unsafe` (https://github.com/openstyles/lz-string-unsafe) is installed. * `semver-bundle` (https://github.com/openstyles/semver-bundle) is installed. -* `stylus-lang` (https://github.com/openstyles/stylus-lang-bundle) is installed.

+* `stylus-lang` (https://github.com/openstyles/stylus-lang-bundle) is installed. +* `usercss-meta` (https://github.com/StylishThemes/parse-usercss) is installed. * The necessary build tools are installed; see `devDependencies` in the `package.json`. ## Running the build script @@ -24,6 +25,7 @@ The following changes are made: * `lz-string-unsafe`: The compressed `lz-string-unsafe.min.js` file is copied directly into `vendor/lz-string-unsafe`. * `semver-bundle`: The `dist/semver.js` file is copied directly into `vendor/semver`. * `stylus-lang-bundle`: The `stylus.min.js` file is copied directly into `vendor/stylus-lang-bundle`. +* `usercss-meta`: The `dist/usercss-meta.min.js` file is copied directly into `vendor/usercss-meta`. ## Creating the ZIP diff --git a/vendor/usercss-meta/LICENCE b/vendor/usercss-meta/LICENCE new file mode 100644 index 00000000..4f7e567b --- /dev/null +++ b/vendor/usercss-meta/LICENCE @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Original code: Copyright (c) Stylus team (github.com/openstyles/stylus) +Modified code: Copyright (c) StylishThemes (github.com/StylishThemes/parse-usercss) + +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. diff --git a/vendor/usercss-meta/README.md b/vendor/usercss-meta/README.md new file mode 100644 index 00000000..b1eb9236 --- /dev/null +++ b/vendor/usercss-meta/README.md @@ -0,0 +1,5 @@ +## usercss-meta v0.8.1 + +usercss-meta installed via npm - source repo: + +https://unpkg.com/usercss-meta@0.8.1/dist/usercss-meta.min.js diff --git a/vendor/usercss-meta/usercss-meta.min.js b/vendor/usercss-meta/usercss-meta.min.js new file mode 100644 index 00000000..68c1c8fe --- /dev/null +++ b/vendor/usercss-meta/usercss-meta.min.js @@ -0,0 +1,2 @@ +var usercssMeta=function(e){"use strict";class n extends Error{constructor(e){super(e.message),delete e.message,this.name="ParseError",Object.assign(this,e)}}class t extends n{constructor(e,n){super({code:"missingChar",args:e,message:`Missing character: ${e.map(e=>`'${e}'`).join(", ")}`,index:n})}}class a extends n{constructor(e){super({code:"EOF",message:"Unexpected end of file",index:e})}}const s=/<<e[1]===n?n:JSON.parse(`"${e}"`))}function v(e){l.lastIndex=e.lastIndex,l.exec(e.text),e.lastIndex=l.lastIndex}function y(e){i.lastIndex=e.lastIndex,e.lastIndex+=i.exec(e.text)[0].length}function g(e){if(e.lastIndex>=e.text.length)throw new a(e.lastIndex);e.index=e.lastIndex,e.value=e.text[e.lastIndex],e.lastIndex++,y(e)}function m(e){const t=e.lastIndex;o.lastIndex=t;const a=o.exec(e.text);if(!a)throw new n({code:"invalidWord",message:"Invalid word",index:t});e.index=t,e.value=a[1],e.lastIndex+=a[0].length}function h(e){const a=e.lastIndex;try{!function e(a){const{text:s}=a;if("{"===s[a.lastIndex]){const n={};for(a.lastIndex++,y(a);"}"!==s[a.lastIndex];){b(a);const l=a.value;if(":"!==s[a.lastIndex])throw new t([":"],a.lastIndex);if(a.lastIndex++,y(a),e(a),n[l]=a.value,","===s[a.lastIndex])a.lastIndex++,y(a);else if("}"!==s[a.lastIndex])throw new t([",","}"],a.lastIndex)}a.lastIndex++,y(a),a.value=n}else if("["===s[a.lastIndex]){const n=[];for(a.lastIndex++,y(a);"]"!==s[a.lastIndex];)if(e(a),n.push(a.value),","===s[a.lastIndex])a.lastIndex++,y(a);else if("]"!==s[a.lastIndex])throw new t([",","]"],a.lastIndex);a.lastIndex++,y(a),a.value=n}else if('"'===s[a.lastIndex]||"'"===s[a.lastIndex]||"`"===s[a.lastIndex])b(a);else if(/[-\d.]/.test(s[a.lastIndex]))O(a);else{if(m(a),!(a.value in x))throw new n({code:"unknownJSONLiteral",args:[a.value],message:`Unknown literal '${a.value}'`,index:a.index});a.value=x[a.value]}}(e)}catch(e){throw e.message=`Invalid JSON: ${e.message}`,e}e.index=a}function I(e){const t=e.lastIndex;s.lastIndex=t;const a=e.text.match(s);if(!a)throw new n({code:"missingEOT",message:"Missing EOT",index:t});e.index=t,e.lastIndex+=a[0].length,e.value=p(a[1].trim()),y(e)}function w(e){c.lastIndex=e.lastIndex;const n=c.exec(e.text);e.index=e.lastIndex,e.lastIndex=c.lastIndex,e.value=n[0].trim().replace(/\s+/g,"-")}function b(e){const t=e.lastIndex,a="`"===e.text[t]?u:d;a.lastIndex=t;const s=a.exec(e.text);if(!s)throw new n({code:"invalidString",message:"Invalid string",index:t});e.index=t,e.lastIndex+=s[0].length,e.value=f(s[1])}function O(e){const t=e.lastIndex;r.lastIndex=t;const a=r.exec(e.text);if(!a)throw new n({code:"invalidNumber",message:"Invalid number",index:t});e.index=t,e.value=Number(a[0].trim()),e.lastIndex+=a[0].length}function $(e){l.lastIndex=e.lastIndex;const n=l.exec(e.text);e.index=e.lastIndex,e.value=f(n[0].trim()),e.lastIndex=l.lastIndex}var k={eatLine:v,eatWhitespace:y,parseChar:g,parseEOT:I,parseJSON:h,parseNumber:O,parseString:b,parseStringToEnd:$,parseStringUnquoted:w,parseWord:m,unquote:f};const S=self.URL,R={name:$,version:$,namespace:$,author:$,description:$,homepageURL:$,supportURL:$,updateURL:$,license:$,preprocessor:$},E={version:function(e){if(!/\bv?(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[\da-z-]+(?:\.[\da-z-]+)*)?(?:\+[\da-z-]+(?:\.[\da-z-]+)*)?\b/gi.test(e.value))throw new n({code:"invalidVersion",args:[e.value],message:`Invalid version: ${e.value}`,index:e.valueIndex});var t;e.value="v"===(t=e.value)[0]||"="===t[0]?t.slice(1):t},homepageURL:V,supportURL:V,updateURL:V},j={text:$,color:$,checkbox:g,select:J,dropdown:{advanced:D},image:{var:J,advanced:D},number:M,range:M},U={checkbox:function(e){if("1"!==e.value&&"0"!==e.value)throw new n({code:"invalidCheckboxDefault",message:"value must be 0 or 1",index:e.valueIndex})},number:_,range:_},N=["name","namespace","version"],T=["default","min","max","step"];function M(e){h(e);const t={min:null,max:null,step:null,units:null};if("number"==typeof e.value)t.default=e.value;else{if(!Array.isArray(e.value))throw new n({code:"invalidRange",message:"the default value must be an array or a number",index:e.valueIndex,args:[e.type]});{let a=0;for(const s of e.value)if("string"==typeof s){if(null!=t.units)throw new n({code:"invalidRangeMultipleUnits",message:"units is alredy defined",args:[e.type],index:e.valueIndex});t.units=s}else{if("number"!=typeof s&&null!==s)throw new n({code:"invalidRangeValue",message:"value must be number, string, or null",args:[e.type],index:e.valueIndex});if(a>=T.length)throw new n({code:"invalidRangeTooManyValues",message:"the array contains too many values",args:[e.type],index:e.valueIndex});t[T[a++]]=s}}}e.value=t.default,Object.assign(e.varResult,t)}function J(e){h(e);const t=Array.isArray(e.value)?e.value.map(e=>L(e)):Object.entries(e.value).map(([e,n])=>L(e,n));if(0===t.length)throw new n({code:"invalidSelectEmptyOptions",message:"Option list is empty",index:e.valueIndex});const a=t.filter(e=>e.isDefault);if(a.length>1)throw new n({code:"invalidSelectMultipleDefaults",message:"multiple default values",index:e.valueIndex});t.forEach(e=>{delete e.isDefault}),e.varResult.options=t,e.value=(a.length>0?a[0]:t[0]).name}function D(e){const a=e.lastIndex;if("{"!==e.text[e.lastIndex])throw new t(["{"],a);const s=[];for(e.lastIndex++;"}"!==e.text[e.lastIndex];){const n={};w(e),n.name=e.value,b(e),n.label=e.value,"dropdown"===e.type?I(e):b(e),n.value=e.value,s.push(n)}if(e.lastIndex++,y(e),0===s.length)throw new n({code:"invalidSelectEmptyOptions",message:"Option list is empty",index:a});"dropdown"===e.type&&(e.varResult.type="select",e.type="select"),e.varResult.options=s,e.value=s[0].name}function L(e,n){let t,a=!1;e.endsWith("*")&&(a=!0,e=e.slice(0,-1));const s=e.match(/^(\w+):(.*)/);return s&&([,t,e]=s),t||(t=e),n||(n=t),{name:t,label:e,value:n,isDefault:a}}function A(e,n){if(n)try{e()}catch(e){n.push(e)}else e()}function V(e){let t;try{t=new S(e.value)}catch(n){throw n.args=[e.value],n.index=e.valueIndex,n}if(!/^https?:/.test(t.protocol))throw new n({code:"invalidURLProtocol",args:[t.protocol],message:`Invalid protocol: ${t.protocol}`,index:e.valueIndex})}function _(e){const t=e.value;if("number"!=typeof t)throw new n({code:"invalidRangeDefault",message:`the default value of @var ${e.type} must be a number`,index:e.valueIndex,args:[e.type]});const a=e.varResult;if(null!=a.min&&ta.max)throw new n({code:"invalidRangeMax",message:"the value is larger than the maximum",index:e.valueIndex,args:[e.type]});if(null!=a.step){const s=a.step.toString().split(".")[1],l=s?10**s.length:0;if(t*l%(a.step*l))throw new n({code:"invalidRangeStep",message:"the value is not a multiple of the step",index:e.valueIndex,args:[e.type]})}}function K({unknownKey:e="ignore",mandatoryKeys:t=N,parseKey:a,parseVar:s,validateKey:l,validateVar:r,allowErrors:i=!1}={}){if(!["ignore","assign","throw"].includes(e))throw new TypeError("unknownKey must be 'ignore', 'assign', or 'throw'");const o=Object.assign({__proto__:null},R,a),u=Object.assign({},j,s),d=Object.assign({},E,l),c=Object.assign({},U,r);return{parse:function(e){if(e.includes("\r"))throw new TypeError("metadata includes invalid character: '\\r'");const a={},s=[],l=/@(\w+)[^\S\r\n]*/gm,r={index:0,lastIndex:0,text:e,usercssData:a,warn:e=>s.push(e)};let o;for(;o=l.exec(e);)r.index=o.index,r.lastIndex=l.lastIndex,r.key=o[1],r.shouldIgnore=!1,A(()=>{try{"var"===r.key||"advanced"===r.key?p(r):f(r)}catch(e){throw void 0===e.index&&(e.index=r.index),e}"var"===r.key||"advanced"===r.key||r.shouldIgnore||(a[r.key]=r.value)},i&&s),l.lastIndex=r.lastIndex;return r.maybeUSO&&!a.preprocessor&&(a.preprocessor="uso"),A(()=>{const e=t.filter(e=>!Object.prototype.hasOwnProperty.call(a,e));if(e.length>0)throw new n({code:"missingMandatory",args:e,message:`Missing metadata: ${e.map(e=>`@${e}`).join(", ")}`})},i&&s),{metadata:a,errors:s}},validateVar:function(e){x({key:"var",type:e.type,value:e.value,varResult:e})}};function x(e){const n="object"==typeof c[e.type]?c[e.type][e.key]:c[e.type];n&&n(e)}function p(e){const t={type:null,label:null,name:null,value:null,default:null,options:null};e.varResult=t,m(e),e.type=e.value,t.type=e.type;const a="object"==typeof u[e.type]?u[e.type][e.key]:u[e.type];if(!a)throw new n({code:"unknownVarType",message:`Unknown @${e.key} type: ${e.type}`,args:[e.key,e.type],index:e.index});m(e),t.name=e.value,b(e),t.label=e.value,e.valueIndex=e.lastIndex,a(e),x(e),t.default=e.value,e.usercssData.vars||(e.usercssData.vars={}),e.usercssData.vars[t.name]=t,"advanced"===e.key&&(e.maybeUSO=!0)}function f(t){let a=o[t.key];if(!a){if("assign"!==e){if(v(t),"ignore"===e)return void(t.shouldIgnore=!0);throw new n({code:"unknownMeta",args:[t.key],message:`Unknown metadata: @${t.key}`,index:t.index})}a=$}t.valueIndex=t.lastIndex,a(t),d[t.key]&&d[t.key](t)}}function P({alignKeys:e=!1,space:n=2,format:t="stylus",stringifyKey:a={},stringifyVar:s={}}={}){return{stringify:function(l){let r;if("stylus"===t)r="var";else{if("xstyle"!==t)throw new TypeError("options.format must be 'stylus' or 'xstyle'");r="advanced"}const i=[];for(const[e,o]of Object.entries(l))if(Object.prototype.hasOwnProperty.call(a,e)){const n=a[e](o);Array.isArray(n)?i.push(...n.map(n=>[e,n])):i.push([e,n])}else if("vars"===e)for(const e of Object.values(o))i.push([r,z(e,t,s,n)]);else if(Array.isArray(o))for(const n of o)i.push([e,W(n)]);else i.push([e,W(o)]);const o=e?Math.max(...i.map(e=>e[0].length)):0;return`/* ==UserStyle==\n${u=i.map(([e,n])=>`@${e.padEnd(o)} ${n}`).join("\n"),u.replace(/\*\//g,"*\\/")}\n==/UserStyle== */`;var u}}}function z(e,n,t,a){return`${"xstyle"===n&&"select"===e.type?"dropdown":e.type} ${e.name} ${JSON.stringify(e.label)} ${function(){if(Object.prototype.hasOwnProperty.call(t,e.type))return t[e.type](e,n,a);if(e.options)return"stylus"===n?JSON.stringify(e.options.reduce((n,t)=>{const a=t.name===e.default?"*":"";return n[`${t.name}:${t.label}${a}`]=t.value,n},{}),null,a):function(e,n=!1,t=0){const a="string"==typeof t?t:" ".repeat(t);return`{\n${e.map(e=>`${a}${e.name} ${JSON.stringify(e.label)} ${function(e){return n?JSON.stringify(e):`<<