diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 7e89af27..76becab3 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -83,6 +83,10 @@ "updateCheckHistory": { "message": "History of update checks" }, + "configureStyle": { + "message": "Configure", + "description": "Label for the button to configure userstyle" + }, "checkForUpdate": { "message": "Check for update", "description": "Label for the button to check a single style for an update" @@ -175,6 +179,10 @@ "message": "Yes", "description": "'Yes' button in a confirm dialog" }, + "confirmClose": { + "message": "Close", + "description": "'Close' button in a confirm dialog" + }, "dbError": { "message": "An error has occurred using the Stylus database. Would you like to visit a web page with possible solutions?", "description": "Prompt when a DB error is encountered" @@ -629,6 +637,43 @@ } } }, + "styleInstallOverwrite" : { + "message": "'$stylename$' is already installed. Overwrite?\nVersion: $oldVersion$ -> $newVersion$", + "description": "Confirmation when re-installing a style", + "placeholders": { + "stylename": { + "content": "$1" + }, + "oldVersion": { + "content": "$2" + }, + "newVersion": { + "content": "$3" + } + } + }, + "styleInstallNoName": { + "message": "Install this style into stylus?", + "description": "Confirmation when installing a style" + }, + "styleInstallFailed": { + "message": "Failed to install userstyle!\n$ERROR$", + "description": "Warning when installation failed", + "placeholders": { + "error": { + "content": "$1" + } + } + }, + "styleMissingMeta": { + "message": "Missing medata @$KEY$", + "description": "Error displayed when a mandatory metadata is missing", + "placeholders": { + "key": { + "content": "$1" + } + } + }, "styleMissingName": { "message": "Enter a name.", "description": "Error displayed when user saves without providing a name" diff --git a/background/background.js b/background/background.js index 1b3dbc70..70b26e0a 100644 --- a/background/background.js +++ b/background/background.js @@ -1,4 +1,4 @@ -/* global dbExec, getStyles, saveStyle */ +/* global dbExec, getStyles, saveStyle, filterUsercss, saveUsercss */ 'use strict'; // eslint-disable-next-line no-var @@ -322,6 +322,14 @@ function onRuntimeMessage(request, sender, sendResponse) { saveStyle(request).then(sendResponse); return KEEP_CHANNEL_OPEN; + case 'saveUsercss': + saveUsercss(request).then(sendResponse); + return KEEP_CHANNEL_OPEN; + + case 'filterUsercss': + filterUsercss(request).then(sendResponse); + return KEEP_CHANNEL_OPEN; + case 'healthCheck': dbExec() .then(() => sendResponse(true)) diff --git a/background/storage.js b/background/storage.js index bc566874..54ac566f 100644 --- a/background/storage.js +++ b/background/storage.js @@ -1,4 +1,6 @@ /* global LZString */ +/* global usercss, openEditor */ + 'use strict'; const RX_NAMESPACE = new RegExp([/[\s\r\n]*/, @@ -259,8 +261,43 @@ function filterStylesInternal({ } +// Parse the source and find the duplication +// {id: int, style: object, source: string, checkDup: boolean} +function filterUsercss(req) { + return Promise.resolve().then(() => { + let style; + if (req.source) { + style = usercss.buildMeta(req.source); + } else { + style = req.style; + } + if (!style.id && req.id) { + style.id = req.id; + } + if (!style.id && req.checkDup) { + return findDupUsercss(style) + .then(dup => ({status: 'success', style, dup})); + } + return {status: 'success', style}; + }).catch(err => ({status: 'error', error: String(err)})); +} + +function saveUsercss(style) { + // This function use `saveStyle`, however the response is different. + return saveStyle(style) + .then(result => ({ + status: 'success', + style: result + })) + .catch(err => ({ + status: 'error', + error: String(err) + })); +} + + function saveStyle(style) { - const id = Number(style.id) || null; + let id = Number(style.id) || null; const reason = style.reason; const notify = style.notify !== false; delete style.method; @@ -271,6 +308,11 @@ function saveStyle(style) { } let existed; let codeIsUpdated; + + if (style.usercss) { + return processUsercss(style).then(decide); + } + if (reason === 'update' || reason === 'update-digest') { return calcStyleDigest(style).then(digest => { style.originalDigest = digest; @@ -286,6 +328,27 @@ function saveStyle(style) { } return decide(); + function processUsercss(style) { + return findDupUsercss(style).then(dup => { + if (!dup) { + return; + } + if (!id) { + id = dup.id; + } + if (reason === 'config') { + return; + } + // preserve style.vars during update + for (const key of Object.keys(style.vars)) { + if (key in dup.vars) { + style.vars[key].value = dup.vars[key].value; + } + } + }) + .then(() => usercss.buildCode(style)); + } + function decide() { if (id !== null) { // Update or create @@ -338,6 +401,10 @@ function saveStyle(style) { style, codeIsUpdated, reason, }); } + if (style.usercss && !existed && reason === 'install') { + // open the editor for usercss with the first install? + openEditor(style.id); + } return style; } } @@ -354,6 +421,17 @@ function deleteStyle({id, notify = true}) { }); } +function findDupUsercss(style) { + if (style.id) { + return getStyles({id: style.id}).then(s => s[0]); + } + return getStyles().then(styles => + styles.find( + s => s.name === style.name && s.namespace === style.namespace + ) + ); +} + function getApplicableSections({ style, diff --git a/background/update.js b/background/update.js index 04f368f5..74a99bf2 100644 --- a/background/update.js +++ b/background/update.js @@ -1,5 +1,5 @@ /* global getStyles, saveStyle, styleSectionsEqual, chromeLocal */ -/* global calcStyleDigest */ +/* global calcStyleDigest, usercss */ 'use strict'; // eslint-disable-next-line no-var @@ -15,8 +15,10 @@ var updater = { MAYBE_EDITED: 'may be locally edited', SAME_MD5: 'up-to-date: MD5 is unchanged', SAME_CODE: 'up-to-date: code sections are unchanged', + SAME_VERSION: 'up-to-date: version is unchanged', ERROR_MD5: 'error: MD5 is invalid', ERROR_JSON: 'error: JSON is invalid', + ERROR_VERSION: 'error: version is invalid', lastUpdateTime: parseInt(localStorage.lastUpdateTime) || Date.now(), @@ -53,9 +55,10 @@ var updater = { 'ignoreDigest' option is set on the second manual individual update check on the manage page. */ + const maybeUpdate = style.usercss ? maybeUpdateUsercss : maybeUpdateUSO; return (ignoreDigest ? Promise.resolve() : calcStyleDigest(style)) - .then(maybeFetchMd5) - .then(maybeFetchCode) + .then(checkIfEdited) + .then(maybeUpdate) .then(maybeSave) .then(saved => { observer(updater.UPDATED, saved); @@ -67,25 +70,49 @@ var updater = { updater.log(updater.SKIPPED + ` (${err}) #${style.id} ${style.name}`); }); - function maybeFetchMd5(digest) { + function checkIfEdited(digest) { + if (style.usercss) { + // FIXME: remove this after we can calculate digest from style.source + return; + } if (!ignoreDigest && style.originalDigest && style.originalDigest !== digest) { return Promise.reject(updater.EDITED); } - return download(style.md5Url); } - function maybeFetchCode(md5) { - if (!md5 || md5.length !== 32) { - return Promise.reject(updater.ERROR_MD5); - } - if (md5 === style.originalMd5 && style.originalDigest && !ignoreDigest) { - return Promise.reject(updater.SAME_MD5); - } - return download(style.updateUrl); + function maybeUpdateUSO() { + return download(style.md5Url).then(md5 => { + if (!md5 || md5.length !== 32) { + return Promise.reject(updater.ERROR_MD5); + } + if (md5 === style.originalMd5 && style.originalDigest && !ignoreDigest) { + return Promise.reject(updater.SAME_MD5); + } + return download(style.updateUrl) + .then(text => tryJSONparse(text)); + }); } - function maybeSave(text) { - const json = tryJSONparse(text); + function maybeUpdateUsercss() { + return download(style.updateUrl).then(text => { + const json = usercss.buildMeta(text); + if (!json.version) { + return Promise.reject(updater.ERROR_VERSION); + } + if (style.version) { + if (usercss.semverTest(style.version, json.version) === 0) { + return Promise.reject(updater.SAME_VERSION); + } + if (usercss.semverTest(style.version, json.version) > 0) { + return Promise.reject(updater.ERROR_VERSION); + } + } + json.id = style.id; + return json; + }); + } + + function maybeSave(json) { if (!styleJSONseemsValid(json)) { return Promise.reject(updater.ERROR_JSON); } diff --git a/content/install-user-css.js b/content/install-user-css.js new file mode 100644 index 00000000..a8bb4f09 --- /dev/null +++ b/content/install-user-css.js @@ -0,0 +1,72 @@ +'use strict'; + +function fetchText(url) { + return new Promise((resolve, reject) => { + // you can't use fetch in Chrome under 'file:' protocol + const xhr = new XMLHttpRequest(); + xhr.open('GET', url); + xhr.addEventListener('load', () => resolve(xhr.responseText)); + xhr.addEventListener('error', () => reject(xhr)); + xhr.send(); + }); +} + +function install(style) { + const request = Object.assign(style, { + method: 'saveUsercss', + reason: 'install', + url: location.href, + updateUrl: location.href + }); + return communicate(request); +} + +function communicate(request) { + return new Promise((resolve, reject) => { + chrome.runtime.sendMessage(request, result => { + if (result.status === 'error') { + reject(result.error); + } else { + resolve(result); + } + }); + }); +} + +function initUsercssInstall() { + fetchText(location.href).then(source => + communicate({ + method: 'filterUsercss', + source: source, + checkDup: true + }) + ).then(({style, dup}) => { + if (dup) { + if (confirm(chrome.i18n.getMessage('styleInstallOverwrite', [style.name, dup.version, style.version]))) { + return install(style); + } + } else if (confirm(chrome.i18n.getMessage('styleInstall', [style.name]))) { + return install(style); + } + }).catch(err => { + alert(chrome.i18n.getMessage('styleInstallFailed', String(err))); + }); +} + +function isUsercss() { + if (!/\.user\.(css|styl|less|scss|sass)$/i.test(location.pathname)) { + return false; + } + if (!/text\/(css|plain)/.test(document.contentType)) { + return false; + } + if (!/==userstyle==/i.test(document.body.textContent)) { + return false; + } + return true; +} + +if (isUsercss()) { + // It seems that we need to wait some time to redraw the page. + setTimeout(initUsercssInstall, 500); +} diff --git a/edit.html b/edit.html index b8d2b243..0d51cc3b 100644 --- a/edit.html +++ b/edit.html @@ -6,6 +6,8 @@ + + diff --git a/edit/edit.js b/edit/edit.js index d28b15f4..39804c61 100644 --- a/edit/edit.js +++ b/edit/edit.js @@ -3,6 +3,8 @@ /* global onDOMscripted */ /* global css_beautify */ /* global CSSLint initLint linterConfig updateLintReport renderLintReport updateLinter */ +/* global mozParser */ + 'use strict'; let styleId = null; @@ -1498,17 +1500,7 @@ function showMozillaFormat() { } function toMozillaFormat() { - return getSectionsHashes().map(section => { - let cssMds = []; - for (const i in propertyToCss) { - if (section[i]) { - cssMds = cssMds.concat(section[i].map(v => - propertyToCss[i] + '("' + v.replace(/\\/g, '\\\\') + '")' - )); - } - } - return cssMds.length ? '@-moz-document ' + cssMds.join(', ') + ' {\n' + section.code + '\n}' : section.code; - }).join('\n\n'); + return mozParser.format({sections: getSectionsHashes()}); } function fromMozillaFormat() { @@ -1542,121 +1534,8 @@ function fromMozillaFormat() { const replaceOldStyle = target.name === 'import-replace'; $('.dismiss', popup).onclick(); const mozStyle = trimNewLines(popup.codebox.getValue()); - const parser = new parserlib.css.Parser(); - const lines = mozStyle.split('\n'); - const sectionStack = [{code: '', start: {line: 1, col: 1}}]; - const errors = []; - // let oldSectionCount = editors.length; - let firstAddedCM; - parser.addListener('startdocument', function (e) { - let outerText = getRange(sectionStack.last.start, (--e.col, e)); - const gapComment = outerText.match(/(\/\*[\s\S]*?\*\/)[\s\n]*$/); - const section = {code: '', start: backtrackTo(this, parserlib.css.Tokens.LBRACE, 'end')}; - // move last comment before @-moz-document inside the section - if (gapComment && !gapComment[1].match(/\/\*\s*AGENT_SHEET\s*\*\//)) { - section.code = gapComment[1] + '\n'; - outerText = trimNewLines(outerText.substring(0, gapComment.index)); - } - if (outerText.trim()) { - sectionStack.last.code = outerText; - doAddSection(sectionStack.last); - sectionStack.last.code = ''; - } - for (const f of e.functions) { - const m = f && f.match(/^([\w-]*)\((['"]?)(.+?)\2?\)$/); - if (!m || !/^(url|url-prefix|domain|regexp)$/.test(m[1])) { - errors.push(`${e.line}:${e.col + 1} invalid function "${m ? m[1] : f || ''}"`); - continue; - } - const aType = CssToProperty[m[1]]; - const aValue = aType !== 'regexps' ? m[3] : m[3].replace(/\\\\/g, '\\'); - (section[aType] = section[aType] || []).push(aValue); - } - sectionStack.push(section); - }); - - parser.addListener('enddocument', function () { - const end = backtrackTo(this, parserlib.css.Tokens.RBRACE, 'start'); - const section = sectionStack.pop(); - section.code += getRange(section.start, end); - sectionStack.last.start = (++end.col, end); - doAddSection(section); - }); - - parser.addListener('endstylesheet', () => { - // add nonclosed outer sections (either broken or the last global one) - const endOfText = {line: lines.length, col: lines.last.length + 1}; - sectionStack.last.code += getRange(sectionStack.last.start, endOfText); - sectionStack.forEach(doAddSection); - - delete maximizeCodeHeight.stats; - editors.forEach(cm => { - maximizeCodeHeight(cm.getSection(), cm === editors.last); - }); - - makeSectionVisible(firstAddedCM); - firstAddedCM.focus(); - - if (errors.length) { - showHelp(t('linterIssues'), $element({ - tag: 'pre', - textContent: errors.join('\n'), - })); - } - }); - - parser.addListener('error', e => { - errors.push(e.line + ':' + e.col + ' ' + - e.message.replace(/ at line \d.+$/, '')); - }); - - parser.parse(mozStyle); - - function getRange(start, end) { - const L1 = start.line - 1; - const C1 = start.col - 1; - const L2 = end.line - 1; - const C2 = end.col - 1; - if (L1 === L2) { - return lines[L1].substr(C1, C2 - C1 + 1); - } else { - const middle = lines.slice(L1 + 1, L2).join('\n'); - return lines[L1].substr(C1) + '\n' + middle + - (L2 >= lines.length ? '' : ((middle ? '\n' : '') + lines[L2].substring(0, C2))); - } - } - function doAddSection(section) { - section.code = section.code.trim(); - // don't add empty sections - if ( - !section.code && - !section.urls && - !section.urlPrefixes && - !section.domains && - !section.regexps - ) { - return; - } - if (!firstAddedCM) { - if (!initFirstSection(section)) { - return; - } - } - setCleanItem(addSection(null, section), false); - firstAddedCM = firstAddedCM || editors.last; - } - // do onetime housekeeping as the imported text is confirmed to be a valid style - function initFirstSection(section) { - // skip adding the first global section when there's no code/comments - if ( - /* ignore boilerplate NS */ - !section.code.replace('@namespace url(http://www.w3.org/1999/xhtml);', '') - /* ignore all whitespace including new lines */ - .replace(/[\s\n]/g, '') - ) { - return false; - } + mozParser.parse(mozStyle).then(sections => { if (replaceOldStyle) { editors.slice(0).reverse().forEach(cm => { removeSection({target: cm.getSection().firstElementChild}); @@ -1667,16 +1546,27 @@ function fromMozillaFormat() { removeSection({target: editors.last.getSection()}); } } - return true; - } - } - function backtrackTo(parser, tokenType, startEnd) { - const tokens = parser._tokenStream._lt; - for (let i = parser._tokenStream._ltIndex - 1; i >= 0; --i) { - if (tokens[i].type === tokenType) { - return {line: tokens[i][startEnd + 'Line'], col: tokens[i][startEnd + 'Col']}; + + const firstSection = sections[0]; + setCleanItem(addSection(null, firstSection), false); + const firstAddedCM = editors.last; + for (const section of sections.slice(1)) { + setCleanItem(addSection(null, section), false); } - } + + delete maximizeCodeHeight.stats; + editors.forEach(cm => { + maximizeCodeHeight(cm.getSection(), cm === editors.last); + }); + + makeSectionVisible(firstAddedCM); + firstAddedCM.focus(); + }, errors => { + showHelp(t('issues'), $element({ + tag: 'pre', + textContent: errors.join('\n'), + })); + }); } function trimNewLines(s) { return s.replace(/^[\s\n]+/, '').replace(/[\s\n]+$/, ''); diff --git a/js/messaging.js b/js/messaging.js index 594beeb0..acc48a8b 100644 --- a/js/messaging.js +++ b/js/messaging.js @@ -393,3 +393,16 @@ function invokeOrPostpone(isInvoke, fn, ...args) { ? fn(...args) : setTimeout(invokeOrPostpone, 0, true, fn, ...args); } + + +function openEditor(id) { + let url = '/edit.html'; + if (id) { + url += `?id=${id}`; + } + if (prefs.get('openEditInWindow')) { + chrome.windows.create(Object.assign({url}, prefs.get('windowPosition'))); + } else { + openURL({url}); + } +} diff --git a/js/moz-parser.js b/js/moz-parser.js new file mode 100644 index 00000000..df446aac --- /dev/null +++ b/js/moz-parser.js @@ -0,0 +1,149 @@ +/* global parserlib, loadScript */ + +'use strict'; + +// eslint-disable-next-line no-var +var mozParser = (function () { + // direct & reverse mapping of @-moz-document keywords and internal property names + const propertyToCss = {urls: 'url', urlPrefixes: 'url-prefix', domains: 'domain', regexps: 'regexp'}; + const CssToProperty = {'url': 'urls', 'url-prefix': 'urlPrefixes', 'domain': 'domains', 'regexp': 'regexps'}; + + function backtrackTo(parser, tokenType, startEnd) { + const tokens = parser._tokenStream._lt; + for (let i = parser._tokenStream._ltIndex - 1; i >= 0; --i) { + if (tokens[i].type === tokenType) { + return {line: tokens[i][startEnd + 'Line'], col: tokens[i][startEnd + 'Col']}; + } + } + } + + function trimNewLines(s) { + return s.replace(/^[\s\n]+/, '').replace(/[\s\n]+$/, ''); + } + + function parseMozFormat(mozStyle) { + return new Promise((resolve, reject) => { + const parser = new parserlib.css.Parser(); + const lines = mozStyle.split('\n'); + const sectionStack = [{code: '', start: {line: 1, col: 1}}]; + const errors = []; + const sections = []; + + parser.addListener('startdocument', function (e) { + const lastSection = sectionStack[sectionStack.length - 1]; + let outerText = getRange(lastSection.start, (--e.col, e)); + const gapComment = outerText.match(/(\/\*[\s\S]*?\*\/)[\s\n]*$/); + const section = {code: '', start: backtrackTo(this, parserlib.css.Tokens.LBRACE, 'end')}; + // move last comment before @-moz-document inside the section + if (gapComment && !gapComment[1].match(/\/\*\s*AGENT_SHEET\s*\*\//)) { + section.code = gapComment[1] + '\n'; + outerText = trimNewLines(outerText.substring(0, gapComment.index)); + } + if (outerText.trim()) { + lastSection.code = outerText; + doAddSection(lastSection); + lastSection.code = ''; + } + for (const f of e.functions) { + const m = f && f.match(/^([\w-]*)\((['"]?)(.+?)\2?\)$/); + if (!m || !/^(url|url-prefix|domain|regexp)$/.test(m[1])) { + errors.push(`${e.line}:${e.col + 1} invalid function "${m ? m[1] : f || ''}"`); + continue; + } + const aType = CssToProperty[m[1]]; + const aValue = aType !== 'regexps' ? m[3] : m[3].replace(/\\\\/g, '\\'); + (section[aType] = section[aType] || []).push(aValue); + } + sectionStack.push(section); + }); + + parser.addListener('enddocument', function () { + const end = backtrackTo(this, parserlib.css.Tokens.RBRACE, 'start'); + const section = sectionStack.pop(); + const lastSection = sectionStack[sectionStack.length - 1]; + section.code += getRange(section.start, end); + lastSection.start = (++end.col, end); + doAddSection(section); + }); + + parser.addListener('endstylesheet', () => { + // add nonclosed outer sections (either broken or the last global one) + const lastLine = lines[lines.length - 1]; + const endOfText = {line: lines.length, col: lastLine.length + 1}; + const lastSection = sectionStack[sectionStack.length - 1]; + lastSection.code += getRange(lastSection.start, endOfText); + sectionStack.forEach(doAddSection); + + if (errors.length) { + reject(errors); + } else { + resolve(sections); + } + }); + + parser.addListener('error', e => { + errors.push(e.line + ':' + e.col + ' ' + + e.message.replace(/ at line \d.+$/, '')); + }); + + parser.parse(mozStyle); + + function getRange(start, end) { + const L1 = start.line - 1; + const C1 = start.col - 1; + const L2 = end.line - 1; + const C2 = end.col - 1; + if (L1 === L2) { + return lines[L1].substr(C1, C2 - C1 + 1); + } else { + const middle = lines.slice(L1 + 1, L2).join('\n'); + return lines[L1].substr(C1) + '\n' + middle + + (L2 >= lines.length ? '' : ((middle ? '\n' : '') + lines[L2].substring(0, C2))); + } + } + + function doAddSection(section) { + section.code = section.code.trim(); + // don't add empty sections + if ( + !section.code && + !section.urls && + !section.urlPrefixes && + !section.domains && + !section.regexps + ) { + return; + } + /* ignore boilerplate NS */ + if (section.code === '@namespace url(http://www.w3.org/1999/xhtml);') { + return; + } + sections.push(Object.assign({}, section)); + } + }); + } + + return { + // Parse mozilla-format userstyle into sections + parse(text) { + if (typeof parserlib === 'undefined') { + return loadScript('vendor/csslint/csslint-worker.js') + .then(() => parseMozFormat(text)); + } + return parseMozFormat(text); + }, + format(style) { + return style.sections.map(section => { + let cssMds = []; + for (const i in propertyToCss) { + if (section[i]) { + cssMds = cssMds.concat(section[i].map(v => + propertyToCss[i] + '("' + v.replace(/\\/g, '\\\\') + '")' + )); + } + } + return cssMds.length ? '@-moz-document ' + cssMds.join(', ') + ' {\n' + section.code + '\n}' : section.code; + }).join('\n\n'); + } + }; +})(); diff --git a/js/script-loader.js b/js/script-loader.js new file mode 100644 index 00000000..ea31ba0d --- /dev/null +++ b/js/script-loader.js @@ -0,0 +1,34 @@ +'use strict'; + +// eslint-disable-next-line no-var +var loadScript = (function () { + const cache = new Map(); + + return function (path) { + if (!path.includes('://')) { + path = chrome.runtime.getURL(path); + } + return new Promise((resolve, reject) => { + if (cache.has(path)) { + resolve(cache.get(path)); + return; + } + const script = document.createElement('script'); + script.src = path; + script.onload = () => { + resolve(script); + script.onload = null; + script.onerror = null; + + cache.set(path, script); + }; + script.onerror = event => { + reject(event); + script.onload = null; + script.onerror = null; + script.parentNode.removeChild(script); + }; + document.head.appendChild(script); + }); + }; +})(); diff --git a/js/usercss.js b/js/usercss.js new file mode 100644 index 00000000..62ead841 --- /dev/null +++ b/js/usercss.js @@ -0,0 +1,202 @@ +/* global loadScript mozParser */ + +'use strict'; + +// eslint-disable-next-line no-var +var usercss = (function () { + function semverTest(a, b) { + a = a.split('.').map(Number); + b = b.split('.').map(Number); + + for (let i = 0; i < a.length; i++) { + if (!(i in b)) { + return 1; + } + if (a[i] < b[i]) { + return -1; + } + if (a[i] > b[i]) { + return 1; + } + } + + if (a.length < b.length) { + return -1; + } + + return 0; + } + + function guessType(value) { + if (/^url\(.+\)$/i.test(value)) { + return 'image'; + } + if (/^#[0-9a-f]{3,8}$/i.test(value)) { + return 'color'; + } + if (/^hsla?\(.+\)$/i.test(value)) { + return 'color'; + } + if (/^rgba?\(.+\)$/i.test(value)) { + return 'color'; + } + // should we use a color-name table to guess type? + return 'text'; + } + + const BUILDER = { + default: { + postprocess(sections, vars) { + let varDef = ':root {\n'; + for (const key of Object.keys(vars)) { + varDef += ` --${key}: ${vars[key].value};\n`; + } + varDef += '}\n'; + + for (const section of sections) { + section.code = varDef + section.code; + } + } + }, + stylus: { + preprocess(source, vars) { + return loadScript('vendor/stylus-lang/stylus.min.js').then(() => ( + new Promise((resolve, reject) => { + let varDef = ''; + for (const key of Object.keys(vars)) { + varDef += `${key} = ${vars[key].value};\n`; + } + + // eslint-disable-next-line no-undef + stylus(varDef + source).render((err, output) => { + if (err) { + reject(err); + } else { + resolve(output); + } + }); + }) + )); + } + } + }; + + 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 n[0]; + } + } + } + + function buildMeta(source) { + const style = _buildMeta(source); + validate(style); + return style; + } + + function _buildMeta(source) { + const style = { + name: null, + usercss: true, + version: null, + source: source, + enabled: true, + sections: [], + vars: {}, + preprocessor: null + }; + + const metaSource = getMetaSource(source); + + const match = (re, callback) => { + let m; + if (!re.global) { + if ((m = metaSource.match(re))) { + if (m.length === 1) { + callback(m[0]); + } else { + callback(...m.slice(1)); + } + } + } else { + const result = []; + while ((m = re.exec(metaSource))) { + if (m.length <= 2) { + result.push(m[m.length - 1]); + } else { + result.push(m.slice(1)); + } + } + if (result.length) { + callback(result); + } + } + }; + + // FIXME: finish all metas + match(/@name[^\S\r\n]+(.+?)[^\S\r\n]*$/m, m => (style.name = m)); + match(/@namespace[^\S\r\n]+(\S+)/, m => (style.namespace = m)); + match(/@preprocessor[^\S\r\n]+(\S+)/, m => (style.preprocessor = m)); + match(/@version[^\S\r\n]+(\S+)/, m => (style.version = m)); + match( + /@var[^\S\r\n]+(\S+)[^\S\r\n]+(?:(['"])((?:\\\2|.)*?)\2|(\S+))[^\S\r\n]+(.+?)[^\S\r\n]*$/gm, + ms => ms.forEach(([key,, label1, label2, value]) => ( + style.vars[key] = { + type: guessType(value), + label: label1 || label2, + value: value + } + )) + ); + + return style; + } + + function buildCode(style) { + let builder; + if (style.preprocessor && style.preprocessor in BUILDER) { + builder = BUILDER[style.preprocessor]; + } else { + builder = BUILDER.default; + } + + return Promise.resolve().then(() => { + // preprocess + if (builder.preprocess) { + return builder.preprocess(style.source, style.vars); + } + return style.source; + }).then(mozStyle => + // moz-parser + loadScript('/js/moz-parser.js').then(() => + mozParser.parse(mozStyle).then(sections => { + style.sections = sections; + }) + ) + ).then(() => { + // postprocess + if (builder.postprocess) { + return builder.postprocess(style.sections, style.vars); + } + }).then(() => style); + } + + function validate(style) { + // mandatory fields + for (const prop of ['name', 'namespace', 'version']) { + if (!style[prop]) { + throw new Error(chrome.i18n.getMessage('styleMissingMeta', prop)); + } + } + } + + return {buildMeta, buildCode, semverTest}; +})(); diff --git a/manage.html b/manage.html index bd6f73d8..76ecf071 100644 --- a/manage.html +++ b/manage.html @@ -73,6 +73,15 @@ + +