Add: install styles from *.user.css file

Fix: handle dup name+namespace

Fix: eslint eqeqeq

Fix: trim @name's spaces

Add: check update for userstyle

Add: build CSS variable

Fix: only check dup when id is not provided

Refactor: userStyle2json -> userstyle.json

Add: style for input

Add: config dialog

Fix: preserve config during update

Fix: onchange doesn't fire on keyboard enter event

Fix: remove empty file

Add: validator. Metas must stay in the same line

Add: warn the user if installation failed

Fix: add some delay before starting installation

Add: open the editor after first installation

Fix: add openEditor to globals

Fix: i18n

Add: preprocessor. Move userstyle.build to background page.

Fix: remove unused global

Fix: preserved unknown prop in saveStyleSource() like saveStyle()

Add: edit userstyle source

Fix: load preprocessor dynamically

Fix: load content script dynamically

Fix: buildCode is async function

Fix: drop Object.entries

Fix: style.sections is undefined

Fix: don't hide the name input but disable it

Fix: query the style before installation

Revert: changes to editor, editor.html

Refactor: use term `usercss` instead of `userstyle`

Fix: don't show homepage action for usercss

Refactor: move script-loader to js/

Refactor: pull out mozParser

Fix: code style

Fix: we don't need to build meta anymore

Fix: use saveUsercss instead of saveStyle to get responsed error

Fix: last is undefined, load script error

Fix: switch to moz-format

Fix: drop injectContentScript. Move usercss check into install-user-css

Fix: response -> respond

Fix: globals -> global

Fix: queryUsercss -> filterUsercss

Fix: add processUsercss function

Fix: only open editor for usercss

Fix: remove findupUsercss fixme

Fix: globals -> global

Fix: globals -> global

Fix: global pollution

Revert: update.js

Refactor: checkStyle

Add: support usercss

Fix: no need to getURL in background page

Fix: merget semver.js into usercss.js

Fix: drop all_urls in match pattern

Fix: drop respondWithError

Move stylus -> stylus-lang

Add stylus-lang/readme

Fix: use include_globs

Fix: global pollution
This commit is contained in:
eight 2017-08-06 00:49:25 +08:00
parent 6f0ab8113e
commit dece4b57f3
17 changed files with 755 additions and 152 deletions

View File

@ -83,6 +83,10 @@
"updateCheckHistory": { "updateCheckHistory": {
"message": "History of update checks" "message": "History of update checks"
}, },
"configureStyle": {
"message": "Configure",
"description": "Label for the button to configure userstyle"
},
"checkForUpdate": { "checkForUpdate": {
"message": "Check for update", "message": "Check for update",
"description": "Label for the button to check a single style for an update" "description": "Label for the button to check a single style for an update"
@ -175,6 +179,10 @@
"message": "Yes", "message": "Yes",
"description": "'Yes' button in a confirm dialog" "description": "'Yes' button in a confirm dialog"
}, },
"confirmClose": {
"message": "Close",
"description": "'Close' button in a confirm dialog"
},
"dbError": { "dbError": {
"message": "An error has occurred using the Stylus database. Would you like to visit a web page with possible solutions?", "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" "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": { "styleMissingName": {
"message": "Enter a name.", "message": "Enter a name.",
"description": "Error displayed when user saves without providing a name" "description": "Error displayed when user saves without providing a name"

View File

@ -1,4 +1,4 @@
/* global dbExec, getStyles, saveStyle */ /* global dbExec, getStyles, saveStyle, filterUsercss, saveUsercss */
'use strict'; 'use strict';
// eslint-disable-next-line no-var // eslint-disable-next-line no-var
@ -322,6 +322,14 @@ function onRuntimeMessage(request, sender, sendResponse) {
saveStyle(request).then(sendResponse); saveStyle(request).then(sendResponse);
return KEEP_CHANNEL_OPEN; 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': case 'healthCheck':
dbExec() dbExec()
.then(() => sendResponse(true)) .then(() => sendResponse(true))

View File

@ -1,4 +1,6 @@
/* global LZString */ /* global LZString */
/* global usercss, openEditor */
'use strict'; 'use strict';
const RX_NAMESPACE = new RegExp([/[\s\r\n]*/, 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) { function saveStyle(style) {
const id = Number(style.id) || null; let id = Number(style.id) || null;
const reason = style.reason; const reason = style.reason;
const notify = style.notify !== false; const notify = style.notify !== false;
delete style.method; delete style.method;
@ -271,6 +308,11 @@ function saveStyle(style) {
} }
let existed; let existed;
let codeIsUpdated; let codeIsUpdated;
if (style.usercss) {
return processUsercss(style).then(decide);
}
if (reason === 'update' || reason === 'update-digest') { if (reason === 'update' || reason === 'update-digest') {
return calcStyleDigest(style).then(digest => { return calcStyleDigest(style).then(digest => {
style.originalDigest = digest; style.originalDigest = digest;
@ -286,6 +328,27 @@ function saveStyle(style) {
} }
return decide(); 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() { function decide() {
if (id !== null) { if (id !== null) {
// Update or create // Update or create
@ -338,6 +401,10 @@ function saveStyle(style) {
style, codeIsUpdated, reason, style, codeIsUpdated, reason,
}); });
} }
if (style.usercss && !existed && reason === 'install') {
// open the editor for usercss with the first install?
openEditor(style.id);
}
return style; 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({ function getApplicableSections({
style, style,

View File

@ -1,5 +1,5 @@
/* global getStyles, saveStyle, styleSectionsEqual, chromeLocal */ /* global getStyles, saveStyle, styleSectionsEqual, chromeLocal */
/* global calcStyleDigest */ /* global calcStyleDigest, usercss */
'use strict'; 'use strict';
// eslint-disable-next-line no-var // eslint-disable-next-line no-var
@ -15,8 +15,10 @@ var updater = {
MAYBE_EDITED: 'may be locally edited', MAYBE_EDITED: 'may be locally edited',
SAME_MD5: 'up-to-date: MD5 is unchanged', SAME_MD5: 'up-to-date: MD5 is unchanged',
SAME_CODE: 'up-to-date: code sections are 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_MD5: 'error: MD5 is invalid',
ERROR_JSON: 'error: JSON is invalid', ERROR_JSON: 'error: JSON is invalid',
ERROR_VERSION: 'error: version is invalid',
lastUpdateTime: parseInt(localStorage.lastUpdateTime) || Date.now(), 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. '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)) return (ignoreDigest ? Promise.resolve() : calcStyleDigest(style))
.then(maybeFetchMd5) .then(checkIfEdited)
.then(maybeFetchCode) .then(maybeUpdate)
.then(maybeSave) .then(maybeSave)
.then(saved => { .then(saved => {
observer(updater.UPDATED, saved); observer(updater.UPDATED, saved);
@ -67,25 +70,49 @@ var updater = {
updater.log(updater.SKIPPED + ` (${err}) #${style.id} ${style.name}`); 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) { if (!ignoreDigest && style.originalDigest && style.originalDigest !== digest) {
return Promise.reject(updater.EDITED); return Promise.reject(updater.EDITED);
} }
return download(style.md5Url);
} }
function maybeFetchCode(md5) { function maybeUpdateUSO() {
return download(style.md5Url).then(md5 => {
if (!md5 || md5.length !== 32) { if (!md5 || md5.length !== 32) {
return Promise.reject(updater.ERROR_MD5); return Promise.reject(updater.ERROR_MD5);
} }
if (md5 === style.originalMd5 && style.originalDigest && !ignoreDigest) { if (md5 === style.originalMd5 && style.originalDigest && !ignoreDigest) {
return Promise.reject(updater.SAME_MD5); return Promise.reject(updater.SAME_MD5);
} }
return download(style.updateUrl); return download(style.updateUrl)
.then(text => tryJSONparse(text));
});
} }
function maybeSave(text) { function maybeUpdateUsercss() {
const json = tryJSONparse(text); 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)) { if (!styleJSONseemsValid(json)) {
return Promise.reject(updater.ERROR_JSON); return Promise.reject(updater.ERROR_JSON);
} }

View File

@ -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);
}

View File

@ -6,6 +6,8 @@
<script src="js/messaging.js"></script> <script src="js/messaging.js"></script>
<script src="js/prefs.js"></script> <script src="js/prefs.js"></script>
<script src="js/localization.js"></script> <script src="js/localization.js"></script>
<script src="js/script-loader.js"></script>
<script src="js/moz-parser.js"></script>
<script src="content/apply.js"></script> <script src="content/apply.js"></script>
<link rel="stylesheet" href="edit/edit.css"> <link rel="stylesheet" href="edit/edit.css">
<script src="edit/lint.js"></script> <script src="edit/lint.js"></script>

View File

@ -3,6 +3,8 @@
/* global onDOMscripted */ /* global onDOMscripted */
/* global css_beautify */ /* global css_beautify */
/* global CSSLint initLint linterConfig updateLintReport renderLintReport updateLinter */ /* global CSSLint initLint linterConfig updateLintReport renderLintReport updateLinter */
/* global mozParser */
'use strict'; 'use strict';
let styleId = null; let styleId = null;
@ -1498,17 +1500,7 @@ function showMozillaFormat() {
} }
function toMozillaFormat() { function toMozillaFormat() {
return getSectionsHashes().map(section => { return mozParser.format({sections: getSectionsHashes()});
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');
} }
function fromMozillaFormat() { function fromMozillaFormat() {
@ -1542,121 +1534,8 @@ function fromMozillaFormat() {
const replaceOldStyle = target.name === 'import-replace'; const replaceOldStyle = target.name === 'import-replace';
$('.dismiss', popup).onclick(); $('.dismiss', popup).onclick();
const mozStyle = trimNewLines(popup.codebox.getValue()); 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) { mozParser.parse(mozStyle).then(sections => {
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;
}
if (replaceOldStyle) { if (replaceOldStyle) {
editors.slice(0).reverse().forEach(cm => { editors.slice(0).reverse().forEach(cm => {
removeSection({target: cm.getSection().firstElementChild}); removeSection({target: cm.getSection().firstElementChild});
@ -1667,16 +1546,27 @@ function fromMozillaFormat() {
removeSection({target: editors.last.getSection()}); removeSection({target: editors.last.getSection()});
} }
} }
return true;
} const firstSection = sections[0];
} setCleanItem(addSection(null, firstSection), false);
function backtrackTo(parser, tokenType, startEnd) { const firstAddedCM = editors.last;
const tokens = parser._tokenStream._lt; for (const section of sections.slice(1)) {
for (let i = parser._tokenStream._ltIndex - 1; i >= 0; --i) { setCleanItem(addSection(null, section), false);
if (tokens[i].type === tokenType) {
return {line: tokens[i][startEnd + 'Line'], col: tokens[i][startEnd + 'Col']};
}
} }
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) { function trimNewLines(s) {
return s.replace(/^[\s\n]+/, '').replace(/[\s\n]+$/, ''); return s.replace(/^[\s\n]+/, '').replace(/[\s\n]+$/, '');

View File

@ -393,3 +393,16 @@ function invokeOrPostpone(isInvoke, fn, ...args) {
? fn(...args) ? fn(...args)
: setTimeout(invokeOrPostpone, 0, true, 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});
}
}

149
js/moz-parser.js Normal file
View File

@ -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');
}
};
})();

34
js/script-loader.js Normal file
View File

@ -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);
});
};
})();

202
js/usercss.js Normal file
View File

@ -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};
})();

View File

@ -73,6 +73,15 @@
</svg> </svg>
</template> </template>
<template data-id="configureIcon">
<!-- FIXME: icon -->
<span class="configure-usercss" i18n-title="configureStyle">
<svg class="svg-icon" viewBox="0 0 20 20">
<path d="M4,4h5v2H6v8h8v-3h2v5H4V4z M11,3h6v6l-2-2l-4,4L9,9l4-4L11,3z"/>
</svg>
</span>
</template>
<template data-id="updaterIcons"> <template data-id="updaterIcons">
<span class="updater-icons"> <span class="updater-icons">
<span class="check-update" i18n-title="checkForUpdate"> <span class="check-update" i18n-title="checkForUpdate">

View File

@ -186,12 +186,15 @@ function createStyleElement({style, name}) {
(style.enabled ? 'enabled' : 'disabled') + (style.enabled ? 'enabled' : 'disabled') +
(style.updateUrl ? ' updatable' : ''); (style.updateUrl ? ' updatable' : '');
if (style.url) { if (style.url && !style.usercss) {
$('.homepage', entry).appendChild(parts.homepageIcon.cloneNode(true)); $('.homepage', entry).appendChild(parts.homepageIcon.cloneNode(true));
} }
if (style.updateUrl && newUI.enabled) { if (style.updateUrl && newUI.enabled) {
$('.actions', entry).appendChild(template.updaterIcons.cloneNode(true)); $('.actions', entry).appendChild(template.updaterIcons.cloneNode(true));
} }
if (style.vars && Object.keys(style.vars).length && newUI.enabled) {
$('.actions', entry).appendChild(template.configureIcon.cloneNode(true));
}
// name being supplied signifies we're invoked by showStyles() // name being supplied signifies we're invoked by showStyles()
// which debounces its main loop thus loading the postponed favicons // which debounces its main loop thus loading the postponed favicons
@ -275,6 +278,39 @@ Object.assign(handleEvent, {
'.update': 'update', '.update': 'update',
'.delete': 'delete', '.delete': 'delete',
'.applies-to .expander': 'expandTargets', '.applies-to .expander': 'expandTargets',
'.configure-usercss': 'config'
},
config(event, {styleMeta: style}) {
let isChanged = false;
messageBox({
title: `Configure ${style.name}`,
className: 'regular-form',
contents: buildConfigForm(),
buttons: [t('confirmClose')]
}).then(() => {
if (!isChanged) {
return;
}
style.reason = 'config';
saveStyleSafe(style);
});
function buildConfigForm() {
const labels = [];
for (const va of Object.values(style.vars)) {
const input = $element({tag: 'input', type: 'text', value: va.value});
input.oninput = () => {
isChanged = true;
va.value = input.value;
animateElement(input, {className: 'value-update'});
};
const label = $element({tag: 'label', appendChild: [va.label, input]});
labels.push(label);
}
return labels;
}
}, },
entryClicked(event) { entryClicked(event) {

View File

@ -22,8 +22,10 @@
"scripts": [ "scripts": [
"js/messaging.js", "js/messaging.js",
"vendor-overwrites/lz-string/LZString-2xspeedup.js", "vendor-overwrites/lz-string/LZString-2xspeedup.js",
"js/usercss.js",
"background/storage.js", "background/storage.js",
"js/prefs.js", "js/prefs.js",
"js/script-loader.js",
"background/background.js", "background/background.js",
"background/update.js" "background/update.js"
] ]
@ -49,6 +51,13 @@
"run_at": "document_start", "run_at": "document_start",
"all_frames": false, "all_frames": false,
"js": ["content/install.js"] "js": ["content/install.js"]
},
{
"matches": ["<all_urls>"],
"include_globs": ["*.user.css", "*.user.styl"],
"run_at": "document_idle",
"all_frames": false,
"js": ["content/install-user-css.js"]
} }
], ],
"browser_action": { "browser_action": {

View File

@ -137,3 +137,25 @@
} }
} }
#message-box.regular-form input[type=text] {
display: block;
width: 100%;
margin: .4rem 0 .6rem;
padding-left: .25rem;
border-radius: .25rem;
border-width: 1px;
}
#message-box.regular-form input[type=text].value-update {
animation-name: input-fadeout;
animation-duration: .4s;
}
@keyframes input-fadeout {
from {
background: palegreen;
}
to {
background: white;
}
}

1
vendor/stylus-lang/README.md vendored Normal file
View File

@ -0,0 +1 @@
The content of this folder belongs to [stylus preprocessor](https://github.com/stylus/stylus/).

6
vendor/stylus-lang/stylus.min.js vendored Normal file

File diff suppressed because one or more lines are too long