/* global CodeMirror messageBox */ /* global editors makeSectionVisible showCodeMirrorPopup showHelp hotkeyRerouter */ /* global loadScript require CSSLint stylelint */ /* global makeLink */ 'use strict'; onDOMready().then(loadLinterAssets); // 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', }, worker: { csslint: {path: '/vendor-overwrites/csslint/csslint-worker.js'}, stylelint: {path: '/vendor-overwrites/stylelint/stylelint-bundle.min.js'}, }, allRuleIds: { csslint: null, stylelint: null, }, getName() { // some dirty hacks to override editor.linter getting from prefs const linter = prefs.get('editor.linter'); if (linter && editors[0] && editors[0].getOption('mode') !== 'css') { return 'stylelint'; } return linter; }, getCurrent(linter = linterConfig.getName()) { return this.fallbackToDefaults(this[linter] || {}); }, getForCodeMirror(linter = linterConfig.getName()) { return CodeMirror.lint && CodeMirror.lint[linter] ? { getAnnotations: CodeMirror.lint[linter], delay: prefs.get('editor.lintDelay'), onUpdateLinting(annotationsNotSorted, annotations, cm) { updateLintReport(cm, 0); }, } : false; }, fallbackToDefaults(config, linter = linterConfig.getName()) { 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 = linterConfig.getName()) { linter = linter.toLowerCase(); linter = linter === 'csslint' || linter === 'stylelint' ? linter : ''; if (linterConfig.getName() !== linter) { prefs.set('editor.linter', linter); } return linter; }, invokeWorker(message) { const worker = linterConfig.worker[message.linter || linterConfig.getName()]; if (!worker.queue) { worker.queue = []; worker.instance.onmessage = ({data}) => { worker.queue.shift().resolve(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); } }); }, getAllRuleIds(linter = linterConfig.getName()) { return Promise.resolve( this.allRuleIds[linter] || this.invokeWorker({linter, action: 'getAllRuleIds'}) .then(ids => (this.allRuleIds[linter] = ids.sort())) ); }, findInvalidRules(config, linter = linterConfig.getName()) { return this.getAllRuleIds(linter).then(allRuleIds => { const allRuleIdsSet = new Set(allRuleIds); const rules = linter === 'stylelint' ? config.rules : config; return Object.keys(rules).filter(rule => !allRuleIdsSet.has(rule)); }); }, stringify(config = this.getCurrent()) { if (linterConfig.getName() === '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 = linterConfig.getName(); 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(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); }, init() { if (!linterConfig.init.pending) { linterConfig.init.pending = linterConfig.loadAll(); } return linterConfig.init.pending; } }; function initLint() { $('#lint-help').addEventListener('click', showLintHelp); $('#lint').addEventListener('click', gotoLintIssue); $('#linter-settings').addEventListener('click', linterConfig.openOnClick); updateLinter(); linterConfig.watchStorage(); prefs.subscribe(['editor.linter'], updateLinter); } function updateLinter({immediately, linter = linterConfig.getName()} = {}) { if (!immediately) { debounce(updateLinter, 0, {immediately: true, linter}); return; } const GUTTERS_CLASS = 'CodeMirror-lint-markers'; Promise.all([ linterConfig.init(), loadLinterAssets(linter) ]).then(updateEditors); $('#linter-settings').style.display = !linter ? 'none' : 'inline-block'; $('#lint').classList.add('hidden'); function updateEditors() { CodeMirror.defaults.lint = linterConfig.getForCodeMirror(linter); const guttersOption = prepareGuttersOption(); editors.forEach(cm => { cm.setOption('lint', CodeMirror.defaults.lint); if (guttersOption) { cm.setOption('guttersOption', guttersOption); updateGutters(cm, guttersOption); cm.refresh(); } setTimeout(updateLintReport, 0, cm); }); } function prepareGuttersOption() { const gutters = CodeMirror.defaults.gutters; const needRefresh = Boolean(linter) !== gutters.includes(GUTTERS_CLASS); if (needRefresh) { if (linter) { gutters.push(GUTTERS_CLASS); } else { gutters.splice(gutters.indexOf(GUTTERS_CLASS), 1); } } return needRefresh && gutters; } function updateGutters(cm, guttersOption) { cm.options.gutters = guttersOption; const el = $('.' + GUTTERS_CLASS, cm.display.gutters); if (linter && !el) { cm.display.gutters.appendChild($element({ className: 'CodeMirror-gutter ' + GUTTERS_CLASS })); } else if (!linter && el) { el.remove(); } } } function updateLintReport(cm, delay) { if (cm && !cm.options.lint) { // add 'lint' option back to the freshly created section setTimeout(() => { if (!cm.options.lint) { cm.setOption('lint', linterConfig.getForCodeMirror()); } }); } const state = cm && cm.state && cm.state.lint || {}; if (delay === 0) { // immediately show pending csslint/stylelint messages in onbeforeunload and save clearTimeout(state.lintTimeout); updateLintReportInternal(cm); return; } if (delay > 0) { clearTimeout(state.lintTimeout); state.lintTimeout = setTimeout(cm => { if (cm.performLint) { cm.performLint(); updateLintReportInternal(cm); } }, delay, cm); return; } if (state.options) { clearTimeout(state.reportTimeout); const delay = cm && cm.state.renderLintReportNow ? 0 : state.options.delay + 100; state.reportTimeout = setTimeout(updateLintReportInternal, delay, cm, { postponeNewIssues: delay === undefined || delay === null }); } } function updateLintReportInternal(scope, {postponeNewIssues} = {}) { const {changed, fixedSome} = (scope ? [scope] : editors).reduce(process, {}); if (changed) { const renderNow = editors.last.state.renderLintReportNow = !postponeNewIssues || fixedSome || editors.last.state.renderLintReportNow; debounce(renderLintReport, renderNow ? 0 : CodeMirror.defaults.lintReportDelay, true); } function process(result, cm) { const lintState = cm.state.lint || {}; const oldMarkers = lintState.stylusMarkers || new Map(); const newMarkers = lintState.stylusMarkers = new Map(); const oldText = (lintState.body || {}).textContentCached || ''; const activeLine = cm.getCursor().line; const body = !(lintState.marked || {}).length ? {} : $element({ tag: 'tbody', appendChild: lintState.marked.map(mark => { const info = mark.__annotation; const {line, ch} = info.from; const isActiveLine = line === activeLine; const pos = isActiveLine ? 'cursor' : (line + ',' + ch); const title = clipString(info.message, 1000) + `\n(${info.rule})`; const message = clipString(info.message, 100); if (isActiveLine || oldMarkers[pos] === message) { oldMarkers.delete(pos); } newMarkers.set(pos, message); return $element({ tag: 'tr', className: info.severity, appendChild: [ $element({ tag: 'td', attributes: {role: 'severity'}, dataset: {rule: info.rule}, appendChild: $element({ className: 'CodeMirror-lint-marker-' + info.severity, textContent: info.severity, }), }), $element({tag: 'td', attributes: {role: 'line'}, textContent: line + 1}), $element({tag: 'td', attributes: {role: 'sep'}, textContent: ':'}), $element({tag: 'td', attributes: {role: 'col'}, textContent: ch + 1}), $element({tag: 'td', attributes: {role: 'message'}, textContent: message, title}), ], }); }) }); body.textContentCached = body.textContent || ''; lintState.body = body.textContentCached && body; result.changed |= oldText !== body.textContentCached; result.fixedSome |= lintState.reportDisplayed && oldMarkers.size; return result; } function clipString(str, limit) { return str.length <= limit ? str : str.substr(0, limit) + '...'; } } function renderLintReport(someBlockChanged) { const container = $('#lint'); const content = container.children[1]; const label = t('sectionCode'); const newContent = content.cloneNode(false); let issueCount = 0; editors.forEach((cm, index) => { cm.state.renderLintReportNow = false; const lintState = cm.state.lint || {}; const body = lintState.body; if (!body) { return; } const newBlock = $element({ tag: 'table', appendChild: [ $element({tag: 'caption', textContent: label + ' ' + (index + 1)}), body, ], cm, }); newContent.appendChild(newBlock); issueCount += newBlock.rows.length; const block = content.children[newContent.children.length - 1]; const blockChanged = !block || block.cm !== cm || body.textContentCached !== block.textContentCached; someBlockChanged |= blockChanged; lintState.reportDisplayed = blockChanged; }); if (someBlockChanged || newContent.children.length !== content.children.length) { $('#issue-count').textContent = issueCount; container.replaceChild(newContent, content); container.classList.toggle('hidden', !newContent.children.length); } } 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({ line: parseInt($('td[role="line"]', issue).textContent) - 1, ch: parseInt($('td[role="col"]', issue).textContent) - 1 }); } function showLintHelp() { const linter = linterConfig.getName(); const baseUrl = linter === 'stylelint' ? 'https://stylelint.io/user-guide/rules/' // some CSSLint rules do not have a url : 'https://github.com/CSSLint/csslint/issues/535'; let headerLink, template; if (linter === 'csslint') { headerLink = makeLink('https://github.com/CSSLint/csslint/wiki/Rules-by-ID', 'CSSLint'); template = ruleID => { const rule = linterConfig.allRuleIds.csslint.find(rule => rule.id === ruleID); return rule && $element({tag: 'li', appendChild: [ $element({tag: 'b', appendChild: makeLink(rule.url || baseUrl, rule.name)}), $element({tag: 'br'}), rule.desc, ]}); }; } else { headerLink = makeLink(baseUrl, 'stylelint'); template = rule => $element({ tag: 'li', appendChild: makeLink(baseUrl + rule, rule), }); } const header = t('linterIssuesHelp', '\x01').split('\x01'); const activeRules = new Set($$('#lint td[role="severity"]').map(el => el.dataset.rule)); return showHelp(t('linterIssues'), $element({appendChild: [ header[0], headerLink, header[1], $element({ tag: 'ul', className: 'rules', appendChild: [...activeRules.values()].map(template), }), ]}) ); } function showLinterErrorMessage(title, contents, popup) { messageBox({ title, contents, className: 'danger center lint-config', buttons: [t('confirmOK')], }).then(() => popup && popup.codebox && popup.codebox.focus()); } function setupLinterPopup(config) { const linter = linterConfig.getName(); const linterTitle = linter === 'stylelint' ? 'Stylelint' : 'CSSLint'; const defaultConfig = linterConfig.stringify(linterConfig.defaults[linter] || {}); const title = t('linterConfigPopupTitle', linterTitle); const popup = showCodeMirrorPopup(title, null, { lint: false, extraKeys: {'Ctrl-Enter': save}, hintOptions: {hint}, }); $('.contents', popup).appendChild(makeFooter()); const cm = popup.codebox; cm.focus(); cm.setValue(config); cm.clearHistory(); cm.markClean(); cm.on('changes', updateButtonState); updateButtonState(); hotkeyRerouter.setState(false); window.addEventListener('closeHelp', function _() { window.removeEventListener('closeHelp', _); hotkeyRerouter.setState(true); }); loadScript([ '/vendor/codemirror/mode/javascript/javascript.js', '/vendor/codemirror/addon/lint/json-lint.js', '/vendor/jsonlint/jsonlint.js' ]).then(() => { cm.setOption('mode', 'application/json'); cm.setOption('lint', 'json'); }); function makeFooter() { const makeButton = (className, onclick, text, options = {}) => $element(Object.assign(options, { className, onclick, tag: 'button', type: 'button', textContent: t(text), })); return $element({ appendChild: [ $element({ tag: 'p', appendChild: [ t('linterRulesLink') + ' ', $element({ tag: 'a', target: '_blank', href: linter === 'stylelint' ? 'https://stylelint.io/user-guide/rules/' : 'https://github.com/CSSLint/csslint/wiki/Rules-by-ID', textContent: linterTitle }), linter === 'csslint' ? ' ' + t('linterCSSLintSettings') : '' ] }), makeButton('save', save, 'styleSaveLabel', {title: 'Ctrl-Enter'}), makeButton('cancel', cancel, 'confirmClose'), makeButton('reset', reset, 'genericResetLabel', {title: t('linterResetMessage')}), $element({ tag: 'span', className: 'saved-message', textContent: t('genericSavedMessage') }) ] }); } function save(event) { if (event instanceof Event) { event.preventDefault(); } const json = tryJSONparse(cm.getValue()); if (!json) { showLinterErrorMessage(linter, t('linterJSONError'), popup); cm.focus(); return; } linterConfig.findInvalidRules(json, linter).then(invalid => { if (invalid.length) { showLinterErrorMessage(linter, [ t('linterInvalidConfigError'), $element({tag: 'ul', appendChild: invalid.map(name => $element({tag: 'li', textContent: name})), }), ], popup); return; } linterConfig.setLinter(linter); linterConfig.save(json); linterConfig.showSavedMessage(); cm.markClean(); cm.focus(); updateButtonState(); }); } function reset(event) { event.preventDefault(); linterConfig.setLinter(linter); cm.setValue(defaultConfig); cm.focus(); updateButtonState(); } function cancel(event) { event.preventDefault(); $('.dismiss').dispatchEvent(new Event('click')); } function updateButtonState() { $('.save', popup).disabled = cm.isClean(); $('.reset', popup).disabled = cm.getValue() === defaultConfig; $('.cancel', popup).textContent = t(cm.isClean() ? 'confirmClose' : 'confirmCancel'); } function hint(cm) { return Promise.all([ linterConfig.getAllRuleIds(linter), linter !== 'stylelint' || hint.allOptions || linterConfig.invokeWorker({action: 'getAllRuleOptions', linter}) .then(options => (hint.allOptions = options)), ]) .then(([ruleIds, options]) => { const cursor = cm.getCursor(); const {start, end, string, type, state: {lexical}} = cm.getTokenAt(cursor); const {line, ch} = cursor; const quoted = string.startsWith('"'); const leftPart = string.slice(quoted ? 1 : 0, ch - start).trim(); const depth = getLexicalDepth(lexical); const search = cm.getSearchCursor(/"([-\w]+)"/, {line, ch: start - 1}); let [, prevWord] = search.find(true) || []; let words = []; if (depth === 1 && linter === 'stylelint') { words = quoted ? ['rules'] : []; } else if ((depth === 1 || depth === 2) && type && type.includes('property')) { words = ruleIds; } else if (depth === 2 || depth === 3 && lexical.type === ']') { words = !quoted ? ['true', 'false', 'null'] : ruleIds.includes(prevWord) && (options[prevWord] || [])[0] || []; } else if (depth === 4 && prevWord === 'severity') { words = ['error', 'warning']; } else if (depth === 4) { words = ['ignore', 'ignoreAtRules', 'except', 'severity']; } else if (depth === 5 && lexical.type === ']' && quoted) { while (prevWord && !ruleIds.includes(prevWord)) { prevWord = (search.find(true) || [])[1]; } words = (options[prevWord] || []).slice(-1)[0] || ruleIds; } return { list: words.filter(word => word.startsWith(leftPart)), from: {line, ch: start + (quoted ? 1 : 0)}, to: {line, ch: string.endsWith('"') ? end - 1 : end}, }; }); } function getLexicalDepth(lexicalState) { let depth = 0; while ((lexicalState = lexicalState.prev)) { depth++; } return depth; } } function loadLinterAssets(name = linterConfig.getName()) { const worker = linterConfig.worker[name]; return !name || !worker || worker.instance ? Promise.resolve() : loadScript((worker.instance ? [] : [ (worker.instance = new Worker(worker.path)), `/edit/lint-defaults-${name}.js`, ]).concat(CodeMirror.lint ? [] : [ '/vendor/codemirror/addon/lint/lint.css', '/msgbox/msgbox.css', '/vendor/codemirror/addon/lint/lint.js', '/edit/lint-codemirror-helper.js', '/msgbox/msgbox.js' ])); }