stylus/edit/lint.js

465 lines
15 KiB
JavaScript
Raw Normal View History

2017-08-26 14:49:14 +00:00
/* global CodeMirror messageBox */
/* global editors makeSectionVisible showCodeMirrorPopup showHelp */
/* global onDOMscripted injectCSS require CSSLint stylelint */
2017-08-16 21:01:45 +00:00
'use strict';
// eslint-disable-next-line no-var
var linterConfig = {
csslint: {},
stylelint: {},
defaults: {
// set in lint-defaults-csslint.js
csslint: {},
// set in lint-defaults-stylelint.js
stylelint: {},
},
storageName: {
csslint: 'editorCSSLintConfig',
stylelint: 'editorStylelintConfig',
},
getCurrent(linter = prefs.get('editor.linter')) {
return this.fallbackToDefaults(this[linter] || {});
},
getForCodeMirror(linter = prefs.get('editor.linter')) {
return CodeMirror.lint && CodeMirror.lint[linter] ? {
getAnnotations: CodeMirror.lint[linter],
delay: prefs.get('editor.lintDelay'),
} : false;
},
fallbackToDefaults(config, linter = prefs.get('editor.linter')) {
if (config && Object.keys(config).length) {
if (linter === 'stylelint') {
// always use default syntax because we don't expose it in config UI
config.syntax = this.defaults.stylelint.syntax;
}
return config;
} else {
return deepCopy(this.defaults[linter] || {});
}
},
setLinter(linter = prefs.get('editor.linter')) {
linter = linter.toLowerCase();
linter = linter === 'csslint' || linter === 'stylelint' ? linter : '';
if (prefs.get('editor.linter') !== linter) {
prefs.set('editor.linter', linter);
}
return linter;
},
findInvalidRules(config, linter = prefs.get('editor.linter')) {
const rules = linter === 'stylelint' ? config.rules : config;
const allRules = new Set(
linter === 'stylelint'
? Object.keys(stylelint.rules)
: CSSLint.getRules().map(rule => rule.id)
);
return Object.keys(rules).filter(rule => !allRules.has(rule));
},
stringify(config = this.getCurrent()) {
if (prefs.get('editor.linter') === 'stylelint') {
config.syntax = undefined;
}
return JSON.stringify(config, null, 2)
.replace(/,\n\s+\{\n\s+("severity":\s"\w+")\n\s+\}/g, ', {$1}');
},
save(config) {
config = this.fallbackToDefaults(config);
const linter = prefs.get('editor.linter');
this[linter] = config;
BG.chromeSync.setLZValue(this.storageName[linter], config);
return config;
},
loadAll() {
return BG.chromeSync.getLZValues([
'editorCSSLintConfig',
'editorStylelintConfig',
]).then(data => {
this.csslint = this.fallbackToDefaults(data.editorCSSLintConfig, 'csslint');
this.stylelint = this.fallbackToDefaults(data.editorStylelintConfig, 'stylelint');
});
},
watchStorage() {
chrome.storage.onChanged.addListener((changes, area) => {
if (area === 'sync') {
for (const name of ['editorCSSLintConfig', 'editorStylelintConfig']) {
if (name in changes && changes[name].newValue !== changes[name].oldValue) {
this.loadAll().then(() => debounce(updateLinter));
break;
}
}
}
});
},
// this is an event listener so it can't refer to self via 'this'
openOnClick() {
setupLinterPopup(linterConfig.stringify());
},
showSavedMessage() {
$('#help-popup .saved-message').classList.add('show');
clearTimeout($('#help-popup .contents').timer);
$('#help-popup .contents').timer = setTimeout(() => {
// popup may be closed at this point
const msg = $('#help-popup .saved-message');
if (msg) {
msg.classList.remove('show');
}
}, 2000);
},
};
2017-08-17 19:08:48 +00:00
function initLint() {
2017-08-20 18:32:41 +00:00
$('#lint-help').addEventListener('click', showLintHelp);
$('#lint').addEventListener('click', gotoLintIssue);
$('#linter-settings').addEventListener('click', linterConfig.openOnClick);
2017-08-16 21:01:45 +00:00
window.addEventListener('resize', resizeLintReport);
// touch devices don't have onHover events so the element we'll be toggled via clicking (touching)
if ('ontouchstart' in document.body) {
2017-08-20 18:32:41 +00:00
$('#lint h2').addEventListener('click', toggleLintReport);
2017-08-16 21:01:45 +00:00
}
2017-08-17 19:08:48 +00:00
linterConfig.loadAll();
linterConfig.watchStorage();
2017-08-16 21:01:45 +00:00
}
function updateLinter(linter = prefs.get('editor.linter')) {
2017-08-20 14:06:17 +00:00
function updateEditors() {
const options = linterConfig.getForCodeMirror(linter);
CodeMirror.defaults.lint = options;
2017-08-20 14:06:17 +00:00
editors.forEach(cm => {
// set lint to "null" to disable
cm.setOption('lint', options);
2017-08-20 18:56:18 +00:00
// enabling/disabling linting changes the gutter width
cm.refresh();
2017-08-20 14:06:17 +00:00
updateLintReport(cm, 200);
});
}
// load scripts
2017-08-23 22:13:55 +00:00
loadSelectedLinter(linter).then(() => {
2017-08-20 14:06:17 +00:00
updateEditors();
2017-08-17 19:08:48 +00:00
});
$('#linter-settings').style.display = !linter ? 'none' : 'inline-block';
2017-08-17 19:08:48 +00:00
}
2017-08-16 21:01:45 +00:00
function updateLintReport(cm, delay) {
if (delay === 0) {
// immediately show pending csslint/stylelint messages in onbeforeunload and save
update(cm);
return;
}
if (delay > 0) {
setTimeout(cm => {
cm.performLint();
update(cm);
}, delay, cm);
2017-08-16 21:01:45 +00:00
return;
}
// eslint-disable-next-line no-var
var state = cm.state.lint;
if (!state) {
return;
}
// user is editing right now: postpone updating the report for the new issues (default: 500ms lint + 4500ms)
// or update it as soon as possible (default: 500ms lint + 100ms) in case an existing issue was just fixed
clearTimeout(state.reportTimeout);
state.reportTimeout = setTimeout(update, state.options.delay + 100, cm);
state.postponeNewIssues = delay === undefined || delay === null;
function update(cm) {
const scope = cm ? [cm] : editors;
let changed = false;
let fixedOldIssues = false;
scope.forEach(cm => {
const scopedState = cm.state.lint || {};
const oldMarkers = scopedState.markedLast || {};
const newMarkers = {};
const html = !scopedState.marked || scopedState.marked.length === 0 ? '' : '<tbody>' +
scopedState.marked.map(mark => {
const info = mark.__annotation;
const isActiveLine = info.from.line === cm.getCursor().line;
const pos = isActiveLine ? 'cursor' : (info.from.line + ',' + info.from.ch);
// rule name added in parentheses at the end; extract it out for the info popup
const text = info.message;
const parenPos = text.endsWith(')') ? text.lastIndexOf('(') : text.length;
const ruleName = text.slice(parenPos + 1, -1);
const title = escapeHtml(text);
const message = escapeHtml(text.substr(0, Math.min(100, parenPos)), {limit: 100});
2017-08-16 21:01:45 +00:00
if (isActiveLine || oldMarkers[pos] === message) {
delete oldMarkers[pos];
}
newMarkers[pos] = message;
return `<tr class="${info.severity}">
<td role="severity" data-rule="${ruleName}">
<div class="CodeMirror-lint-marker-${info.severity}">${info.severity}</div>
2017-08-16 21:01:45 +00:00
</td>
<td role="line">${info.from.line + 1}</td>
<td role="sep">:</td>
<td role="col">${info.from.ch + 1}</td>
<td role="message" title="${title}">${message}</td>
2017-08-16 21:01:45 +00:00
</tr>`;
}).join('') + '</tbody>';
scopedState.markedLast = newMarkers;
fixedOldIssues |= scopedState.reportDisplayed && Object.keys(oldMarkers).length > 0;
if (scopedState.html !== html) {
scopedState.html = html;
changed = true;
}
});
if (changed) {
clearTimeout(state ? state.renderTimeout : undefined);
if (!state || !state.postponeNewIssues || fixedOldIssues) {
renderLintReport(true);
} else {
state.renderTimeout = setTimeout(() => {
renderLintReport(true);
}, CodeMirror.defaults.lintReportDelay);
}
}
}
function escapeHtml(html, {limit} = {}) {
2017-08-16 21:01:45 +00:00
const chars = {'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;', '/': '&#x2F;'};
let ellipsis = '';
if (limit && html.length > limit) {
html = html.substr(0, limit);
ellipsis = '...';
}
return html.replace(/[&<>"'/]/g, char => chars[char]) + ellipsis;
2017-08-16 21:01:45 +00:00
}
}
function renderLintReport(someBlockChanged) {
2017-08-20 18:32:41 +00:00
const container = $('#lint');
2017-08-16 21:01:45 +00:00
const content = container.children[1];
const label = t('sectionCode');
const newContent = content.cloneNode(false);
let issueCount = 0;
editors.forEach((cm, index) => {
if (cm.state.lint && cm.state.lint.html) {
const html = '<caption>' + label + ' ' + (index + 1) + '</caption>' + cm.state.lint.html;
const newBlock = newContent.appendChild(tHTML(html, 'table'));
newBlock.cm = cm;
issueCount += newBlock.rows.length;
const block = content.children[newContent.children.length - 1];
const blockChanged = !block || cm !== block.cm || html !== block.innerHTML;
someBlockChanged |= blockChanged;
cm.state.lint.reportDisplayed = blockChanged;
}
});
if (someBlockChanged || newContent.children.length !== content.children.length) {
2017-08-20 18:32:41 +00:00
$('#issue-count').textContent = issueCount;
2017-08-16 21:01:45 +00:00
container.replaceChild(newContent, content);
container.style.display = newContent.children.length ? 'block' : 'none';
2017-08-17 19:47:45 +00:00
resizeLintReport();
2017-08-16 21:01:45 +00:00
}
}
2017-08-17 19:47:45 +00:00
function resizeLintReport() {
2017-08-20 18:56:18 +00:00
// subtracted value to prevent scrollbar
const magicBuffer = 20;
2017-08-17 19:47:45 +00:00
const content = $('#lint table');
if (content) {
2017-08-16 21:01:45 +00:00
const bounds = content.getBoundingClientRect();
2017-08-17 19:47:45 +00:00
const newMaxHeight = bounds.bottom <= window.innerHeight ? '' :
// subtract out a bit of padding or the vertical scrollbar extends beyond the viewport
(window.innerHeight - bounds.top - magicBuffer) + 'px';
2017-08-16 21:01:45 +00:00
if (newMaxHeight !== content.style.maxHeight) {
2017-08-17 19:47:45 +00:00
content.parentNode.style.maxHeight = newMaxHeight;
2017-08-16 21:01:45 +00:00
}
}
}
function gotoLintIssue(event) {
const issue = event.target.closest('tr');
if (!issue) {
return;
}
const block = issue.closest('table');
makeSectionVisible(block.cm);
block.cm.focus();
block.cm.setSelection({
2017-08-20 18:32:41 +00:00
line: parseInt($('td[role="line"]', issue).textContent) - 1,
ch: parseInt($('td[role="col"]', issue).textContent) - 1
2017-08-16 21:01:45 +00:00
});
}
function toggleLintReport() {
2017-08-20 18:32:41 +00:00
$('#lint').classList.toggle('collapsed');
2017-08-16 21:01:45 +00:00
}
function showLintHelp() {
2017-08-23 22:13:55 +00:00
const makeLink = (url, txt) => `<a target="_blank" href="${url}">${txt}</a>`;
const linter = prefs.get('editor.linter');
const url = linter === 'stylelint'
? 'https://stylelint.io/user-guide/rules/'
// some CSSLint rules do not have a url
: 'https://github.com/CSSLint/csslint/issues/535';
const rules = [];
let template;
2017-08-16 21:01:45 +00:00
let list = '<ul class="rules">';
let header = '';
2017-08-23 22:13:55 +00:00
if (linter === 'csslint') {
const CSSLintRules = CSSLint.getRules();
2017-08-25 23:54:37 +00:00
const findCSSLintRule = id => CSSLintRules.find(rule => rule.id === id);
2017-08-26 16:48:32 +00:00
header = t('linterIssuesHelp', makeLink('https://github.com/CSSLint/csslint/wiki/Rules-by-ID', 'CSSLint'));
2017-08-23 22:13:55 +00:00
template = ruleID => {
const rule = findCSSLintRule(ruleID);
return rule ? `<li><b>${makeLink(rule.url || url, rule.name)}</b><br>${rule.desc}</li>` : '';
};
2017-08-16 21:01:45 +00:00
} else {
2017-08-26 16:48:32 +00:00
header = t('linterIssuesHelp', makeLink(url, 'stylelint'));
2017-08-23 22:13:55 +00:00
template = rule => `<li>${makeLink(url + rule, rule)}</li>`;
2017-08-16 21:01:45 +00:00
}
2017-08-27 17:36:36 +00:00
// Only show rules with issues in the popup
2017-08-23 22:13:55 +00:00
$$('#lint td[role="severity"]').forEach(el => {
const rule = el.dataset.rule;
if (!rules.includes(rule)) {
list += template(rule);
rules.push(rule);
}
});
2017-08-26 16:48:32 +00:00
return showHelp(t('linterIssues'), header + list + '</ul>');
2017-08-16 21:01:45 +00:00
}
2017-08-17 19:08:48 +00:00
function showLinterErrorMessage(title, contents) {
messageBox({
title,
contents,
className: 'danger center lint-config',
buttons: [t('confirmOK')],
});
}
2017-08-23 22:13:55 +00:00
function setupLinterSettingsEvents(popup) {
2017-08-20 18:32:41 +00:00
$('.save', popup).addEventListener('click', event => {
2017-08-18 15:46:09 +00:00
event.preventDefault();
const linter = linterConfig.setLinter(event.target.dataset.linter);
const json = tryJSONparse(popup.codebox.getValue());
2017-08-26 14:30:33 +00:00
if (json) {
const invalid = linterConfig.findInvalidRules(json, linter);
if (invalid.length) {
showLinterErrorMessage(linter, [
t('linterInvalidConfigError'),
$element({
tag: 'ul',
appendChild: invalid.map(name =>
$element({tag: 'li', textContent: name})),
}),
]);
return;
}
linterConfig.save(json);
linterConfig.showSavedMessage();
debounce(updateLinter, 0, linter);
2017-08-18 15:44:19 +00:00
} else {
2017-08-26 16:48:32 +00:00
showLinterErrorMessage(linter, t('linterJSONError'));
2017-08-17 19:08:48 +00:00
}
2017-08-26 15:46:04 +00:00
popup.codebox.focus();
2017-08-17 19:08:48 +00:00
});
2017-08-20 18:32:41 +00:00
$('.reset', popup).addEventListener('click', event => {
2017-08-18 15:46:09 +00:00
event.preventDefault();
const linter = linterConfig.setLinter(event.target.dataset.linter);
popup.codebox.setValue(linterConfig.stringify(linterConfig.defaults[linter] || {}));
2017-08-26 15:46:04 +00:00
popup.codebox.focus();
2017-08-17 19:08:48 +00:00
});
2017-08-26 15:03:28 +00:00
$('.cancel', popup).addEventListener('click', event => {
event.preventDefault();
$('.dismiss').dispatchEvent(new Event('click'));
});
2017-08-17 19:08:48 +00:00
}
2017-08-27 17:36:36 +00:00
function setupLinterPopup(config) {
2017-08-23 22:13:55 +00:00
const linter = prefs.get('editor.linter');
const linterTitle = linter === 'stylelint' ? 'Stylelint' : 'CSSLint';
2017-08-26 16:03:51 +00:00
function makeButton(className, text, options = {}) {
return $element(Object.assign(options, {
tag: 'button',
className,
type: 'button',
textContent: t(text),
dataset: {linter}
}));
}
function makeLink(url, textContent) {
return $element({tag: 'a', target: '_blank', href: url, textContent});
}
function setJSONMode(cm) {
cm.setOption('mode', 'application/json');
2017-08-20 14:06:17 +00:00
cm.setOption('lint', 'json');
}
2017-08-27 17:36:36 +00:00
const popup = showCodeMirrorPopup(t('linterConfigPopupTitle', linterTitle), $element({
appendChild: [
$element({
tag: 'p',
appendChild: [
2017-08-26 16:48:32 +00:00
t('linterRulesLink') + ' ',
2017-08-23 22:13:55 +00:00
makeLink(
linter === 'stylelint'
2017-08-27 17:36:36 +00:00
? 'https://stylelint.io/user-guide/rules/'
2017-08-23 22:13:55 +00:00
: 'https://github.com/CSSLint/csslint/wiki/Rules-by-ID',
linterTitle
),
2017-08-26 16:48:32 +00:00
linter === 'csslint' ? ' ' + t('linterCSSLintSettings') : ''
]
}),
makeButton('save', 'styleSaveLabel'),
2017-08-26 15:03:28 +00:00
makeButton('cancel', 'confirmCancel'),
2017-08-26 16:48:32 +00:00
makeButton('reset', 'genericResetLabel', {title: t('linterResetMessage')}),
$element({
tag: 'span',
className: 'saved-message',
textContent: t('genericSavedMessage')
})
]
}));
2017-08-20 18:32:41 +00:00
const contents = $('.contents', popup);
2017-08-20 14:50:18 +00:00
const loadJSON = window.jsonlint ? [] : [
2017-08-20 15:15:26 +00:00
'vendor/codemirror/mode/javascript/javascript.js',
'vendor/codemirror/addon/lint/json-lint.js',
2017-08-20 14:50:18 +00:00
'vendor/jsonlint/jsonlint.js'
];
contents.insertBefore(popup.codebox.display.wrapper, contents.firstElementChild);
popup.codebox.focus();
2017-08-27 17:36:36 +00:00
popup.codebox.setValue(config);
2017-08-26 15:29:33 +00:00
popup.codebox.clearHistory();
2017-08-20 19:05:54 +00:00
onDOMscripted(loadJSON).then(() => setJSONMode(popup.codebox));
2017-08-23 22:13:55 +00:00
setupLinterSettingsEvents(popup);
}
2017-08-20 14:06:17 +00:00
function loadSelectedLinter(name) {
2017-08-20 20:03:42 +00:00
const scripts = [];
2017-08-20 14:06:17 +00:00
if (name === 'csslint' && !window.CSSLint) {
2017-08-23 22:13:55 +00:00
scripts.push(
'vendor-overwrites/csslint/csslint-worker.js',
'edit/lint-defaults-csslint.js'
2017-08-23 22:13:55 +00:00
);
2017-08-20 14:06:17 +00:00
} else if (name === 'stylelint' && !window.stylelint) {
2017-08-20 19:07:14 +00:00
scripts.push(
2017-08-20 14:06:17 +00:00
'vendor-overwrites/stylelint/stylelint-bundle.min.js',
() => (window.stylelint = require('stylelint')),
'edit/lint-defaults-stylelint.js'
);
}
if (name && !$('script[src$="vendor/codemirror/addon/lint/lint.js"]')) {
injectCSS('vendor/codemirror/addon/lint/lint.css');
injectCSS('msgbox/msgbox.css');
scripts.push(
'vendor/codemirror/addon/lint/lint.js',
'edit/lint-codemirror-helper.js',
'msgbox/msgbox.js'
2017-08-20 19:07:14 +00:00
);
2017-08-20 14:06:17 +00:00
}
return onDOMscripted(scripts);
}