ember-template-lint
Version:
Linter for Ember or Handlebars templates.
425 lines (393 loc) • 12.3 kB
JavaScript
;
const Fuse = require('fuse.js');
const Rule = require('./_base');
function deprecateArgument(componentName, argumentName, replacementAttribute) {
const msgs = [`Passing the "${argumentName}" argument to <${componentName} /> is deprecated.`];
if (replacementAttribute) {
msgs.push(
`Instead, please pass the attribute directly, i.e. "<${componentName} ${replacementAttribute}={{...}} />" instead of "<${componentName} ${argumentName}={{...}} />".`
);
}
return msgs.join('\n');
}
function deprecateEvent(componentName, argumentName, replacementAttribute) {
const msgs = [`Passing the "${argumentName}" argument to <${componentName} /> is deprecated.`];
if (replacementAttribute) {
msgs.push(
`Instead, please use the {{on}} modifier, i.e. "<${componentName} {{on "${replacementAttribute}" ...}} />" instead of "<${componentName} ${argumentName}={{...}} />".`
);
}
return msgs.join('\n');
}
// from https://github.com/emberjs/rfcs/blob/master/text/0707-modernize-built-in-components-2.md#summary
const KnownArguments = {
LinkTo: {
arguments: [
'route',
'model',
'models',
'query',
'replace',
'disabled',
'current-when',
'activeClass',
'loadingClass',
'disabledClass',
],
deprecatedArguments: {
// key -> replacement attr
'@active': '',
'@loading': '',
'@init': '',
'@didRender': '',
'@willDestroy': '',
'@didReceiveAttrs': '',
'@willRender': '',
'@didInsertElement': '',
'@didUpdateAttrs': '',
'@willUpdate': '',
'@didUpdate': '',
'@willDestroyElement': '',
'@willClearRender': '',
'@didDestroyElement': '',
'@tagName': '',
'@id': 'id',
'@elementId': 'id',
'@ariaRole': 'role',
'@class': 'class',
'@classNames': 'class',
'@classNameBindings': 'class',
'@isVisible': 'style',
'@rel': 'rel',
'@tabindex': 'tabindex',
'@target': 'target',
'@title': 'title',
},
deprecatedEvents: {
'@click': 'click',
'@contextMenu': 'contextmenu',
'@doubleClick': 'dblclick',
'@drag': 'drag',
'@dragEnd': 'dragend',
'@dragEnter': 'dragenter',
'@dragLeave': 'dragleave',
'@dragOver': 'dragover',
'@dragStart': 'dragstart',
'@drop': 'drop',
'@focusIn': 'focusin',
'@focusOut': 'focusout',
'@input': 'input',
'@keyDown': 'keydown',
'@keyPress': 'keypress',
'@keyUp': 'keyup',
'@mouseDown': 'mousedown',
'@mouseEnter': 'mouseenter',
'@mouseLeave': 'mouseleave',
'@mouseMove': 'mousemove',
'@mouseUp': 'mouseup',
'@submit': 'submit',
'@touchCancel': 'touchcancel',
'@touchEnd': 'touchend',
'@touchMove': 'touchmove',
'@touchStart': 'touchstart',
},
conflicts: [['model', 'models']],
required: [['route', 'query']],
},
Input: {
arguments: ['type', 'value', 'checked', 'insert-newline', 'enter', 'escape-press'],
deprecatedArguments: {
'@bubbles': '',
'@cancel': '',
'@init': '',
'@didRender': '',
'@willDestroy': '',
'@didReceiveAttrs': '',
'@willRender': '',
'@didInsertElement': '',
'@didUpdateAttrs': '',
'@willUpdate': '',
'@didUpdate': '',
'@willDestroyElement': '',
'@willClearRender': '',
'@didDestroyElement': '',
'@id': 'id',
'@elementId': 'id',
'@ariaRole': 'role',
'@class': 'class',
'@classNames': 'class',
'@classNameBindings': 'class',
'@isVisible': 'style',
'@accept': 'accept',
'@autocapitalize': '',
'@autocomplete': 'autocomplete',
'@autocorrect': '',
'@autofocus': 'autofocus',
'@autosave': '',
'@dir': 'dir',
'@disabled': 'disabled',
'@form': 'form',
'@formaction': 'formaction',
'@formenctype': 'formenctype',
'@formmethod': 'formmethod',
'@formnovalidate': 'formnovalidate',
'@formtarget': 'formtarget',
'@height': 'height',
'@indeterminate': '',
'@inputmode': '',
'@lang': 'lang',
'@list': 'list',
'@max': 'max',
'@maxlength': 'maxlength',
'@min': 'min',
'@minlength': 'minlength',
'@multiple': 'multiple',
'@name': 'name',
'@pattern': 'pattern',
'@placeholder': 'placeholder',
'@readonly': 'readonly',
'@required': 'required',
'@selectionDirection': '',
'@size': 'size',
'@spellcheck': 'spellcheck',
'@step': 'step',
'@tabindex': 'tabindex',
'@title': 'title',
'@width': 'width',
},
conflicts: [['checked', 'value']],
deprecatedEvents: {
'@change': 'change',
'@click': 'click',
'@contextMenu': 'contextmenu',
'@doubleClick': 'dblclick',
'@drag': 'drag',
'@dragEnd': 'dragend',
'@dragEnter': 'dragenter',
'@dragLeave': 'dragleave',
'@dragOver': 'dragover',
'@dragStart': 'dragstart',
'@drop': 'drop',
'@input': 'input',
'@mouseDown': 'mousedown',
'@mouseEnter': 'mouseenter',
'@mouseLeave': 'mouseleave',
'@mouseMove': 'mousemove',
'@mouseUp': 'mouseup',
'@submit': 'submit',
'@touchCancel': 'touchcancel',
'@touchEnd': 'touchend',
'@touchMove': 'touchmove',
'@touchStart': 'touchstart',
'@focus-in': 'focusin',
'@focus-out': 'focusout',
'@key-down': 'keydown',
'@key-press': 'keypress',
'@key-up': 'keyup',
},
},
Textarea: {
arguments: ['value', 'insert-newline', 'enter', 'escape-press'],
deprecatedArguments: {
'@init': '',
'@didRender': '',
'@willDestroy': '',
'@didReceiveAttrs': '',
'@willRender': '',
'@didInsertElement': '',
'@didUpdateAttrs': '',
'@willUpdate': '',
'@didUpdate': '',
'@willDestroyElement': '',
'@willClearRender': '',
'@didDestroyElement': '',
'@id': 'id',
'@elementId': 'id',
'@ariaRole': 'role',
'@class': 'class',
'@classNames': 'class',
'@classNameBindings': 'class',
'@isVisible': 'style',
'@autocapitalize': '',
'@autocomplete': 'autocomplete',
'@autocorrect': '',
'@autofocus': 'autofocus',
'@cols': 'cols',
'@dir': 'dir',
'@disabled': 'disabled',
'@form': 'form',
'@lang': 'lang',
'@maxlength': 'maxlength',
'@minlength': 'minlength',
'@name': 'name',
'@placeholder': 'placeholder',
'@readonly': 'readonly',
'@required': 'required',
'@rows': 'rows',
'@selectionDirection': '',
'@selectionEnd': '',
'@selectionStart': '',
'@spellcheck': 'spellcheck',
'@tabindex': 'tabindex',
'@title': 'title',
'@wrap': 'wrap',
},
deprecatedEvents: {
'@bubbles': '',
'@cancel': '',
'@click': 'click',
'@contextMenu': 'contextmenu',
'@doubleClick': 'dblclick',
'@drag': 'drag',
'@dragEnd': 'dragend',
'@dragEnter': 'dragenter',
'@dragLeave': 'dragleave',
'@dragOver': 'dragover',
'@dragStart': 'dragstart',
'@drop': 'drop',
'@input': 'input',
'@mouseDown': 'mousedown',
'@mouseEnter': 'mouseenter',
'@mouseLeave': 'mouseleave',
'@mouseMove': 'mousemove',
'@mouseUp': 'mouseup',
'@submit': 'submit',
'@touchCancel': 'touchcancel',
'@touchEnd': 'touchend',
'@touchMove': 'touchmove',
'@touchStart': 'touchstart',
'@focus-in': 'focusin',
'@focus-out': 'focusout',
'@key-down': 'keydown',
'@key-press': 'keypress',
'@key-up': 'keyup',
},
},
};
function removeAtSymbol(txt) {
return txt.replace('@', '');
}
function ERROR_MESSAGE(tagName, argumentName) {
const tagMeta = KnownArguments[tagName];
const deprecatedArgs = tagMeta.deprecatedArguments || {};
const deprecatedEvents = tagMeta.deprecatedEvents || {};
const candidates = [
...new Set([
...tagMeta.arguments,
...Object.keys(deprecatedArgs).map((e) => removeAtSymbol(e)),
...Object.keys(deprecatedEvents).map((e) => removeAtSymbol(e)),
]),
];
const pureQuery = removeAtSymbol(argumentName);
let query = pureQuery;
let fuzzyResults = [];
while (!fuzzyResults.length && query.length) {
fuzzyResults = new Fuse(candidates).search(query);
query = query.slice(0, -1);
}
let closestKey = fuzzyResults.length ? `@${fuzzyResults[0].item}` : argumentName;
if (closestKey in deprecatedArgs) {
return deprecateArgument(tagName, closestKey, deprecatedArgs[closestKey]);
}
if (closestKey in deprecatedEvents) {
return deprecateEvent(tagName, closestKey, deprecatedEvents[closestKey]);
}
const msg = `"${argumentName}" is unknown argument for <${tagName} /> component.`;
if (fuzzyResults.length) {
return `${msg} Did you mean "@${fuzzyResults[0].item}"?`;
} else {
return msg;
}
}
function REQUIRED_MESSAGE(tagName, argumentNames) {
return `Argument${argumentNames.length > 1 ? 's' : ''} ${argumentNames
.map((el) => `"@${el}"`)
.join(' or ')} is required for <${tagName} /> component.`;
}
function CONFLICT_MESSAGE(argumentName, rawList) {
const conflictsList = rawList.filter((el) => `@${el}` !== argumentName);
return `"${argumentName}" conflicts with ${conflictsList
.map((el) => `"@${el}"`)
.join(', ')}, only one should exists.`;
}
function isArgument(attributeNode) {
return attributeNode.name.startsWith('@');
}
function pureName(attributeNode) {
return removeAtSymbol(attributeNode.name);
}
module.exports = class NoUnknownArgumentsForBuiltinComponents extends Rule {
visitor() {
return {
ElementNode(node) {
let nodeMeta = KnownArguments[node.tag];
if (!nodeMeta) {
return;
}
let warns = [];
let seen = [];
const logError = (attr) => {
this.log({
message: ERROR_MESSAGE(node.tag, attr.name),
line: attr.loc && attr.loc.start.line,
column: attr.loc && attr.loc.start.column,
source: (this.sourceForNode(attr) || '').split('=')[0],
});
};
const logConflict = (attr, conflictList) => {
this.log({
message: CONFLICT_MESSAGE(attr.name, conflictList),
line: attr.loc && attr.loc.start.line,
column: attr.loc && attr.loc.start.column,
source: (this.sourceForNode(attr) || '').split('=')[0],
});
};
const logRequired = (variants) => {
this.log({
message: REQUIRED_MESSAGE(node.tag, variants),
line: node.loc && node.loc.start.line,
column: node.loc && node.loc.start.column + 1,
source: node.tag,
});
};
for (let argument of node.attributes) {
if (!isArgument(argument)) {
continue;
}
const argumentName = pureName(argument);
if (!nodeMeta.arguments.includes(argumentName)) {
warns.push(argument);
} else {
seen.push(argumentName);
}
}
for (let warn of warns) {
logError(warn);
}
if ('conflicts' in nodeMeta) {
for (let conflictList of nodeMeta.conflicts) {
if (conflictList.every((item) => seen.includes(item))) {
for (let argumentName of conflictList) {
const attr = node.attributes.find(({ name }) => `@${argumentName}` === name);
if (attr) {
logConflict(attr, conflictList);
}
}
}
}
}
if ('required' in nodeMeta) {
for (let requiredItems of nodeMeta.required) {
let variants = Array.isArray(requiredItems) ? requiredItems : [requiredItems];
if (!variants.some((el) => seen.includes(el))) {
logRequired(variants);
}
}
}
},
};
}
};
module.exports.ERROR_MESSAGE = ERROR_MESSAGE;
module.exports.CONFLICT_MESSAGE = CONFLICT_MESSAGE;
module.exports.REQUIRED_MESSAGE = REQUIRED_MESSAGE;