UNPKG

ember-template-lint

Version:
226 lines (190 loc) 6.39 kB
'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;