lint.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553
  1. /* global CodeMirror messageBox */
  2. /* global editors makeSectionVisible showCodeMirrorPopup showHelp */
  3. /* global loadScript require CSSLint stylelint */
  4. /* global makeLink */
  5. 'use strict';
  6. onDOMready().then(loadLinterAssets);
  7. // eslint-disable-next-line no-var
  8. var linterConfig = {
  9. csslint: {},
  10. stylelint: {},
  11. defaults: {
  12. // set in lint-defaults-csslint.js
  13. csslint: {},
  14. // set in lint-defaults-stylelint.js
  15. stylelint: {},
  16. },
  17. storageName: {
  18. csslint: 'editorCSSLintConfig',
  19. stylelint: 'editorStylelintConfig',
  20. },
  21. getDefault() {
  22. // some dirty hacks to override editor.linter getting from prefs
  23. const linter = prefs.get('editor.linter');
  24. if (linter && editors[0] && editors[0].getOption('mode') !== 'css') {
  25. return 'stylelint';
  26. }
  27. return linter;
  28. },
  29. getCurrent(linter = linterConfig.getDefault()) {
  30. return this.fallbackToDefaults(this[linter] || {});
  31. },
  32. getForCodeMirror(linter = linterConfig.getDefault()) {
  33. return CodeMirror.lint && CodeMirror.lint[linter] ? {
  34. getAnnotations: CodeMirror.lint[linter],
  35. delay: prefs.get('editor.lintDelay'),
  36. } : false;
  37. },
  38. fallbackToDefaults(config, linter = linterConfig.getDefault()) {
  39. if (config && Object.keys(config).length) {
  40. if (linter === 'stylelint') {
  41. // always use default syntax because we don't expose it in config UI
  42. config.syntax = this.defaults.stylelint.syntax;
  43. }
  44. return config;
  45. } else {
  46. return deepCopy(this.defaults[linter] || {});
  47. }
  48. },
  49. setLinter(linter = linterConfig.getDefault()) {
  50. linter = linter.toLowerCase();
  51. linter = linter === 'csslint' || linter === 'stylelint' ? linter : '';
  52. if (linterConfig.getDefault() !== linter) {
  53. prefs.set('editor.linter', linter);
  54. }
  55. return linter;
  56. },
  57. findInvalidRules(config, linter = linterConfig.getDefault()) {
  58. const rules = linter === 'stylelint' ? config.rules : config;
  59. const allRules = new Set(
  60. linter === 'stylelint'
  61. ? Object.keys(stylelint.rules)
  62. : CSSLint.getRules().map(rule => rule.id)
  63. );
  64. return Object.keys(rules).filter(rule => !allRules.has(rule));
  65. },
  66. stringify(config = this.getCurrent()) {
  67. if (linterConfig.getDefault() === 'stylelint') {
  68. config.syntax = undefined;
  69. }
  70. return JSON.stringify(config, null, 2)
  71. .replace(/,\n\s+\{\n\s+("severity":\s"\w+")\n\s+\}/g, ', {$1}');
  72. },
  73. save(config) {
  74. config = this.fallbackToDefaults(config);
  75. const linter = linterConfig.getDefault();
  76. this[linter] = config;
  77. BG.chromeSync.setLZValue(this.storageName[linter], config);
  78. return config;
  79. },
  80. loadAll() {
  81. return BG.chromeSync.getLZValues([
  82. 'editorCSSLintConfig',
  83. 'editorStylelintConfig',
  84. ]).then(data => {
  85. this.csslint = this.fallbackToDefaults(data.editorCSSLintConfig, 'csslint');
  86. this.stylelint = this.fallbackToDefaults(data.editorStylelintConfig, 'stylelint');
  87. });
  88. },
  89. watchStorage() {
  90. chrome.storage.onChanged.addListener((changes, area) => {
  91. if (area === 'sync') {
  92. for (const name of ['editorCSSLintConfig', 'editorStylelintConfig']) {
  93. if (name in changes && changes[name].newValue !== changes[name].oldValue) {
  94. this.loadAll().then(updateLinter);
  95. break;
  96. }
  97. }
  98. }
  99. });
  100. },
  101. // this is an event listener so it can't refer to self via 'this'
  102. openOnClick() {
  103. setupLinterPopup(linterConfig.stringify());
  104. },
  105. showSavedMessage() {
  106. $('#help-popup .saved-message').classList.add('show');
  107. clearTimeout($('#help-popup .contents').timer);
  108. $('#help-popup .contents').timer = setTimeout(() => {
  109. // popup may be closed at this point
  110. const msg = $('#help-popup .saved-message');
  111. if (msg) {
  112. msg.classList.remove('show');
  113. }
  114. }, 2000);
  115. },
  116. init() {
  117. if (!linterConfig.init.pending) {
  118. linterConfig.init.pending = linterConfig.loadAll();
  119. }
  120. return linterConfig.init.pending;
  121. }
  122. };
  123. function initLint() {
  124. $('#lint-help').addEventListener('click', showLintHelp);
  125. $('#lint').addEventListener('click', gotoLintIssue);
  126. $('#linter-settings').addEventListener('click', linterConfig.openOnClick);
  127. window.addEventListener('resize', resizeLintReport);
  128. updateLinter();
  129. linterConfig.watchStorage();
  130. prefs.subscribe(['editor.linter'], updateLinter);
  131. }
  132. function updateLinter({immediately, linter = linterConfig.getDefault()} = {}) {
  133. if (!immediately) {
  134. debounce(updateLinter, 0, {immediately: true, linter});
  135. return;
  136. }
  137. const GUTTERS_CLASS = 'CodeMirror-lint-markers';
  138. Promise.all([
  139. linterConfig.init(),
  140. loadLinterAssets(linter)
  141. ]).then(updateEditors);
  142. $('#linter-settings').style.display = !linter ? 'none' : 'inline-block';
  143. $('#lint').classList.add('hidden');
  144. function updateEditors() {
  145. CodeMirror.defaults.lint = linterConfig.getForCodeMirror(linter);
  146. const guttersOption = prepareGuttersOption();
  147. editors.forEach(cm => {
  148. cm.setOption('lint', CodeMirror.defaults.lint);
  149. if (guttersOption) {
  150. cm.setOption('guttersOption', guttersOption);
  151. updateGutters(cm, guttersOption);
  152. cm.refresh();
  153. }
  154. setTimeout(updateLintReport, 0, cm);
  155. });
  156. }
  157. function prepareGuttersOption() {
  158. const gutters = CodeMirror.defaults.gutters;
  159. const needRefresh = Boolean(linter) !== gutters.includes(GUTTERS_CLASS);
  160. if (needRefresh) {
  161. if (linter) {
  162. gutters.push(GUTTERS_CLASS);
  163. } else {
  164. gutters.splice(gutters.indexOf(GUTTERS_CLASS), 1);
  165. }
  166. }
  167. return needRefresh && gutters;
  168. }
  169. function updateGutters(cm, guttersOption) {
  170. cm.options.gutters = guttersOption;
  171. const el = $('.' + GUTTERS_CLASS, cm.display.gutters);
  172. if (linter && !el) {
  173. cm.display.gutters.appendChild($element({
  174. className: 'CodeMirror-gutter ' + GUTTERS_CLASS
  175. }));
  176. } else if (!linter && el) {
  177. el.remove();
  178. }
  179. }
  180. }
  181. function updateLintReport(cm, delay) {
  182. if (cm && !cm.options.lint) {
  183. // add 'lint' option back to the freshly created section
  184. setTimeout(() => {
  185. if (!cm.options.lint) {
  186. cm.setOption('lint', linterConfig.getForCodeMirror());
  187. }
  188. });
  189. }
  190. const state = cm && cm.state && cm.state.lint || {};
  191. if (delay === 0) {
  192. // immediately show pending csslint/stylelint messages in onbeforeunload and save
  193. clearTimeout(state.lintTimeout);
  194. updateLintReportInternal(cm);
  195. return;
  196. }
  197. if (delay > 0) {
  198. clearTimeout(state.lintTimeout);
  199. state.lintTimeout = setTimeout(cm => {
  200. if (cm.performLint) {
  201. cm.performLint();
  202. updateLintReportInternal(cm);
  203. }
  204. }, delay, cm);
  205. return;
  206. }
  207. if (state.options) {
  208. clearTimeout(state.reportTimeout);
  209. const delay = cm && cm.state.renderLintReportNow ? 0 : state.options.delay + 100;
  210. state.reportTimeout = setTimeout(updateLintReportInternal, delay, cm, {
  211. postponeNewIssues: delay === undefined || delay === null
  212. });
  213. }
  214. }
  215. function updateLintReportInternal(scope, {postponeNewIssues} = {}) {
  216. const {changed, fixedSome} = (scope ? [scope] : editors).reduce(process, {});
  217. if (changed) {
  218. const renderNow = editors.last.state.renderLintReportNow =
  219. !postponeNewIssues || fixedSome || editors.last.state.renderLintReportNow;
  220. debounce(renderLintReport, renderNow ? 0 : CodeMirror.defaults.lintReportDelay, true);
  221. }
  222. function process(result, cm) {
  223. const lintState = cm.state.lint || {};
  224. const oldMarkers = lintState.stylusMarkers || new Map();
  225. const newMarkers = lintState.stylusMarkers = new Map();
  226. const oldText = (lintState.body || {}).textContentCached || '';
  227. const activeLine = cm.getCursor().line;
  228. const body = !(lintState.marked || {}).length ? {} : $element({
  229. tag: 'tbody',
  230. appendChild: lintState.marked.map(mark => {
  231. const info = mark.__annotation;
  232. const {line, ch} = info.from;
  233. const isActiveLine = line === activeLine;
  234. const pos = isActiveLine ? 'cursor' : (line + ',' + ch);
  235. const title = clipString(info.message, 1000) + `\n(${info.rule})`;
  236. const message = clipString(info.message, 100);
  237. if (isActiveLine || oldMarkers[pos] === message) {
  238. oldMarkers.delete(pos);
  239. }
  240. newMarkers.set(pos, message);
  241. return $element({
  242. tag: 'tr',
  243. className: info.severity,
  244. appendChild: [
  245. $element({
  246. tag: 'td',
  247. attributes: {role: 'severity'},
  248. dataset: {rule: info.rule},
  249. appendChild: $element({
  250. className: 'CodeMirror-lint-marker-' + info.severity,
  251. textContent: info.severity,
  252. }),
  253. }),
  254. $element({tag: 'td', attributes: {role: 'line'}, textContent: line + 1}),
  255. $element({tag: 'td', attributes: {role: 'sep'}, textContent: ':'}),
  256. $element({tag: 'td', attributes: {role: 'col'}, textContent: ch + 1}),
  257. $element({tag: 'td', attributes: {role: 'message'}, textContent: message, title}),
  258. ],
  259. });
  260. })
  261. });
  262. body.textContentCached = body.textContent || '';
  263. lintState.body = body.textContentCached && body;
  264. result.changed |= oldText !== body.textContentCached;
  265. result.fixedSome |= lintState.reportDisplayed && oldMarkers.size;
  266. return result;
  267. }
  268. function clipString(str, limit) {
  269. return str.length <= limit ? str : str.substr(0, limit) + '...';
  270. }
  271. }
  272. function renderLintReport(someBlockChanged) {
  273. const container = $('#lint');
  274. const content = container.children[1];
  275. const label = t('sectionCode');
  276. const newContent = content.cloneNode(false);
  277. let issueCount = 0;
  278. editors.forEach((cm, index) => {
  279. cm.state.renderLintReportNow = false;
  280. const lintState = cm.state.lint || {};
  281. const body = lintState.body;
  282. if (!body) {
  283. return;
  284. }
  285. const newBlock = $element({
  286. tag: 'table',
  287. appendChild: [
  288. $element({tag: 'caption', textContent: label + ' ' + (index + 1)}),
  289. body,
  290. ],
  291. cm,
  292. });
  293. newContent.appendChild(newBlock);
  294. issueCount += newBlock.rows.length;
  295. const block = content.children[newContent.children.length - 1];
  296. const blockChanged =
  297. !block ||
  298. block.cm !== cm ||
  299. body.textContentCached !== block.textContentCached;
  300. someBlockChanged |= blockChanged;
  301. lintState.reportDisplayed = blockChanged;
  302. });
  303. if (someBlockChanged || newContent.children.length !== content.children.length) {
  304. $('#issue-count').textContent = issueCount;
  305. container.replaceChild(newContent, content);
  306. container.classList.toggle('hidden', !newContent.children.length);
  307. resizeLintReport();
  308. }
  309. }
  310. function resizeLintReport() {
  311. // subtracted value to prevent scrollbar
  312. const magicBuffer = 20;
  313. const content = $('#lint table');
  314. if (content) {
  315. const bounds = content.getBoundingClientRect();
  316. const newMaxHeight = bounds.bottom <= window.innerHeight ? '' :
  317. // subtract out a bit of padding or the vertical scrollbar extends beyond the viewport
  318. (window.innerHeight - bounds.top - magicBuffer) + 'px';
  319. if (newMaxHeight !== content.style.maxHeight) {
  320. content.parentNode.style.maxHeight = newMaxHeight;
  321. }
  322. }
  323. }
  324. function gotoLintIssue(event) {
  325. const issue = event.target.closest('tr');
  326. if (!issue) {
  327. return;
  328. }
  329. const block = issue.closest('table');
  330. makeSectionVisible(block.cm);
  331. block.cm.focus();
  332. block.cm.setSelection({
  333. line: parseInt($('td[role="line"]', issue).textContent) - 1,
  334. ch: parseInt($('td[role="col"]', issue).textContent) - 1
  335. });
  336. }
  337. function showLintHelp() {
  338. const linter = linterConfig.getDefault();
  339. const baseUrl = linter === 'stylelint'
  340. ? 'https://stylelint.io/user-guide/rules/'
  341. // some CSSLint rules do not have a url
  342. : 'https://github.com/CSSLint/csslint/issues/535';
  343. let headerLink, template;
  344. if (linter === 'csslint') {
  345. const CSSLintRules = CSSLint.getRules();
  346. headerLink = makeLink('https://github.com/CSSLint/csslint/wiki/Rules-by-ID', 'CSSLint');
  347. template = ruleID => {
  348. const rule = CSSLintRules.find(rule => rule.id === ruleID);
  349. return rule &&
  350. $element({tag: 'li', appendChild: [
  351. $element({tag: 'b', appendChild: makeLink(rule.url || baseUrl, rule.name)}),
  352. $element({tag: 'br'}),
  353. rule.desc,
  354. ]});
  355. };
  356. } else {
  357. headerLink = makeLink(baseUrl, 'stylelint');
  358. template = rule =>
  359. $element({
  360. tag: 'li',
  361. appendChild: makeLink(baseUrl + rule, rule),
  362. });
  363. }
  364. const header = t('linterIssuesHelp', '\x01').split('\x01');
  365. const activeRules = new Set($$('#lint td[role="severity"]').map(el => el.dataset.rule));
  366. return showHelp(t('linterIssues'),
  367. $element({appendChild: [
  368. header[0], headerLink, header[1],
  369. $element({
  370. tag: 'ul',
  371. className: 'rules',
  372. appendChild: [...activeRules.values()].map(template),
  373. }),
  374. ]})
  375. );
  376. }
  377. function showLinterErrorMessage(title, contents) {
  378. messageBox({
  379. title,
  380. contents,
  381. className: 'danger center lint-config',
  382. buttons: [t('confirmOK')],
  383. });
  384. }
  385. function setupLinterSettingsEvents(popup) {
  386. $('.save', popup).addEventListener('click', event => {
  387. event.preventDefault();
  388. const linter = linterConfig.setLinter(event.target.dataset.linter);
  389. const json = tryJSONparse(popup.codebox.getValue());
  390. if (json) {
  391. const invalid = linterConfig.findInvalidRules(json, linter);
  392. if (invalid.length) {
  393. showLinterErrorMessage(linter, [
  394. t('linterInvalidConfigError'),
  395. $element({
  396. tag: 'ul',
  397. appendChild: invalid.map(name =>
  398. $element({tag: 'li', textContent: name})),
  399. }),
  400. ]);
  401. return;
  402. }
  403. linterConfig.save(json);
  404. linterConfig.showSavedMessage();
  405. popup.codebox.markClean();
  406. } else {
  407. showLinterErrorMessage(linter, t('linterJSONError'));
  408. }
  409. popup.codebox.focus();
  410. });
  411. $('.reset', popup).addEventListener('click', event => {
  412. event.preventDefault();
  413. const linter = linterConfig.setLinter(event.target.dataset.linter);
  414. popup.codebox.setValue(linterConfig.stringify(linterConfig.defaults[linter] || {}));
  415. popup.codebox.focus();
  416. });
  417. $('.cancel', popup).addEventListener('click', event => {
  418. event.preventDefault();
  419. $('.dismiss').dispatchEvent(new Event('click'));
  420. });
  421. }
  422. function setupLinterPopup(config) {
  423. const linter = linterConfig.getDefault();
  424. const linterTitle = linter === 'stylelint' ? 'Stylelint' : 'CSSLint';
  425. function makeButton(className, text, options = {}) {
  426. return $element(Object.assign(options, {
  427. tag: 'button',
  428. className,
  429. type: 'button',
  430. textContent: t(text),
  431. dataset: {linter}
  432. }));
  433. }
  434. function makeLink(url, textContent) {
  435. return $element({tag: 'a', target: '_blank', href: url, textContent});
  436. }
  437. const title = t('linterConfigPopupTitle', linterTitle);
  438. const contents = $element({
  439. appendChild: [
  440. $element({
  441. tag: 'p',
  442. appendChild: [
  443. t('linterRulesLink') + ' ',
  444. makeLink(
  445. linter === 'stylelint'
  446. ? 'https://stylelint.io/user-guide/rules/'
  447. : 'https://github.com/CSSLint/csslint/wiki/Rules-by-ID',
  448. linterTitle
  449. ),
  450. linter === 'csslint' ? ' ' + t('linterCSSLintSettings') : ''
  451. ]
  452. }),
  453. makeButton('save', 'styleSaveLabel', {disabled: true}),
  454. makeButton('cancel', 'confirmCancel'),
  455. makeButton('reset', 'genericResetLabel', {title: t('linterResetMessage')}),
  456. $element({
  457. tag: 'span',
  458. className: 'saved-message',
  459. textContent: t('genericSavedMessage')
  460. })
  461. ]
  462. });
  463. const popup = showCodeMirrorPopup(title, contents, {lint: false});
  464. contents.parentNode.appendChild(contents);
  465. popup.codebox.focus();
  466. popup.codebox.setValue(config);
  467. popup.codebox.clearHistory();
  468. popup.codebox.markClean();
  469. popup.codebox.on('change', cm => {
  470. $('.save', popup).disabled = cm.isClean();
  471. });
  472. setupLinterSettingsEvents(popup);
  473. loadScript([
  474. '/vendor/codemirror/mode/javascript/javascript.js',
  475. '/vendor/codemirror/addon/lint/json-lint.js',
  476. '/vendor/jsonlint/jsonlint.js'
  477. ]).then(() => {
  478. popup.codebox.setOption('mode', 'application/json');
  479. popup.codebox.setOption('lint', 'json');
  480. });
  481. }
  482. function loadLinterAssets(name = linterConfig.getDefault()) {
  483. if (!name) {
  484. return Promise.resolve();
  485. }
  486. return loadLibrary().then(loadAddon);
  487. function loadLibrary() {
  488. if (name === 'csslint' && !window.CSSLint) {
  489. return loadScript([
  490. '/vendor-overwrites/csslint/csslint-worker.js',
  491. '/edit/lint-defaults-csslint.js'
  492. ]);
  493. }
  494. if (name === 'stylelint' && !window.stylelint) {
  495. return loadScript([
  496. '/vendor-overwrites/stylelint/stylelint-bundle.min.js',
  497. '/edit/lint-defaults-stylelint.js'
  498. ]).then(() => (window.stylelint = require('stylelint')));
  499. }
  500. return Promise.resolve();
  501. }
  502. function loadAddon() {
  503. if (CodeMirror.lint) {
  504. return;
  505. }
  506. return loadScript([
  507. '/vendor/codemirror/addon/lint/lint.css',
  508. '/msgbox/msgbox.css',
  509. '/vendor/codemirror/addon/lint/lint.js',
  510. '/edit/lint-codemirror-helper.js',
  511. '/msgbox/msgbox.js'
  512. ]);
  513. }
  514. }