ember-template-lint
Version:
Linter for Ember or Handlebars templates.
226 lines (190 loc) • 6.39 kB
JavaScript
'use strict';
const AstNodeInfo = require('../helpers/ast-node-info');
const NodeMatcher = require('../helpers/node-matcher');
const Rule = require('./base');
class ConditionalScope {
constructor() {
this.seenIdStack = [new Set()];
this.conditionals = [];
}
enterConditional() {
this.conditionals.push(new Set());
}
exitConditional() {
let idsWithinConditional = this.conditionals.pop();
if (this.conditionals.length > 0) {
let parentConditional = this.conditionals[this.conditionals.length - 1];
for (const id of idsWithinConditional) {
parentConditional.add(id);
}
} else {
this.seenIdStack.push(idsWithinConditional);
}
}
enterConditionalBranch() {
this.seenIdStack.push(new Set());
}
exitConditionalBranch() {
this.seenIdStack.pop();
}
isDuplicateId(id) {
for (let seenIds of this.seenIdStack) {
if (seenIds.has(id)) {
return true;
}
}
}
addId(id) {
this.seenIdStack[this.seenIdStack.length - 1].add(id);
if (this.conditionals.length > 0) {
let currentConditional = this.conditionals[this.conditionals.length - 1];
currentConditional.add(id);
}
}
}
const ERROR_MESSAGE = 'ID attribute values must be unique';
module.exports = class NoDuplicateId extends Rule {
// Handles the primary logic for the rule:
// - if `id` is unique / not in the existing `ConditionalScope`; add it and carry on
// - if it is a duplicate value; log the error
logIfDuplicate(node, id) {
if (!this.conditionalScope.isDuplicateId(id)) {
this.conditionalScope.addId(id);
} else {
this.log({
message: ERROR_MESSAGE,
line: node.loc && node.loc.start.line,
column: node.loc && node.loc.start.column,
source: this.sourceForNode(node),
});
}
}
// Helper for getting `id` attribute node values from parent AST Node types
// that store their attributes as an Array of HashPairs -- in this case,
// MustacheStatements and BlockStatements
getHashArgIdValue(node, idAttrName) {
let id;
let refPair = { key: idAttrName, value: { type: 'StringLiteral' } };
let idHashArg = node.hash.pairs.find((testPair) => NodeMatcher.match(testPair, refPair));
if (idHashArg) {
id = idHashArg.value.value;
}
return id;
}
visitor() {
// Visiting function node filter for AttrNodes: attribute name, node type
function isValidIdAttrNode(node) {
// consider removing `@id` eventually (or make it a toggle available via config)
let isValidAttrNodeIdTag = ['id', '@id', '@elementId'].includes(node.name);
let isValidAttrNodeIdValueType = [
'TextNode',
'MustacheStatement',
'ConcatStatement',
].includes(node.value.type);
return node && isValidAttrNodeIdTag && isValidAttrNodeIdValueType;
}
// Resolve MustacheStatement value to StringLiteral where possible
function getMustacheValue(part, scope) {
let isMustacheWithStringLiteral = {
type: 'MustacheStatement',
path: { type: 'StringLiteral' },
};
if (NodeMatcher.match(part, isMustacheWithStringLiteral)) {
return part.path.value;
}
let isMustacheWithPathExpression = {
type: 'MustacheStatement',
path: { type: 'PathExpression' },
};
if (NodeMatcher.match(part, isMustacheWithPathExpression)) {
return scope.sourceForNode(part);
}
}
function getPartValue(part, scope) {
if (part.type === 'TextNode') {
return part.chars;
} else {
return getMustacheValue(part, scope);
}
}
// Resolve ConcatStatement parts values to StringLiteral where possible
function getJoinedConcatParts(node, scope) {
return node.value.parts.map((part) => getPartValue(part, scope)).join('');
}
function handleCurlyNode(node) {
let id = this.getHashArgIdValue(node, 'elementId');
if (id) {
this.logIfDuplicate(node, id);
return;
}
id = this.getHashArgIdValue(node, 'id');
if (id) {
this.logIfDuplicate(node, id);
}
}
// Store the id values collected; reference to look for duplicates
this.conditionalScope = new ConditionalScope();
return {
AttrNode(node) {
// Only check relevant nodes
if (!isValidIdAttrNode(node)) {
return;
}
let id;
switch (node.value.type) {
// ConcatStatement: try to resolve parts to StringLiteral where possible
// ex. id="id-{{"value"}}" becomes "id-value"
// ex. id="id-{{value}}-{{"number"}}" becomes "id-{{value}}-number"
case 'ConcatStatement':
id = getJoinedConcatParts(node, this);
break;
// TextNode: unwrap
// ex. id="id-value" becomes "id-value"
case 'TextNode':
id = node.value.chars;
break;
// MustacheStatement: try to resolve
// ex. id={{"id-value"}} becomes "id-value"
// ex. id={{idValue}} becomes "{{idValue}}"
case 'MustacheStatement':
id = getMustacheValue(node.value, this);
break;
default:
// If id is not assigned by this point, use the raw source
id = this.sourceForNode(node.value);
}
this.logIfDuplicate(node, id);
},
BlockStatement: {
enter(node) {
if (AstNodeInfo.isControlFlowHelper(node)) {
this.conditionalScope.enterConditional();
} else {
handleCurlyNode.call(this, node);
}
},
exit(node) {
if (AstNodeInfo.isControlFlowHelper(node)) {
this.conditionalScope.exitConditional();
}
},
},
Block: {
enter(_node, path) {
let parent = path.parent;
if (AstNodeInfo.isControlFlowHelper(parent.node)) {
this.conditionalScope.enterConditionalBranch();
}
},
exit(_node, path) {
let parent = path.parent;
if (AstNodeInfo.isControlFlowHelper(parent.node)) {
this.conditionalScope.exitConditionalBranch();
}
},
},
MustacheStatement: handleCurlyNode,
};
}
};
module.exports.ERROR_MESSAGE = ERROR_MESSAGE;