eslint-plugin-lit
Version:
lit-html support for ESLint
272 lines (271 loc) • 11.2 kB
JavaScript
import * as parse5 from 'parse5';
import treeAdapter from 'parse5-htmlparser2-tree-adapter';
import { templateExpressionToHtml, getExpressionPlaceholder } from './util.js';
const isRootNode = (node) => node.type === 'root';
const analyzerCache = new WeakMap();
/**
* Analyzes a given template expression for traversing its contained
* HTML tree.
*/
export class TemplateAnalyzer {
/**
* Create an analyzer instance for a given node
*
* @param {ESTree.TaggedTemplateExpression} node Node to use
* @return {!TemplateAnalyzer}
*/
static create(node) {
let cached = analyzerCache.get(node);
if (!cached) {
cached = new TemplateAnalyzer(node);
analyzerCache.set(node, cached);
}
return cached;
}
/**
* Constructor
*
* @param {ESTree.TaggedTemplateExpression} node Node to analyze
*/
constructor(node) {
this.errors = [];
this.source = '';
this._node = node;
this.source = templateExpressionToHtml(node);
const opts = {
treeAdapter: treeAdapter,
sourceCodeLocationInfo: true,
onParseError: (err) => {
this.errors.push(err);
}
};
if (/<html/i.test(this.source)) {
this._ast = parse5.parse(this.source, opts);
}
else {
this._ast = parse5.parseFragment(this.source, opts);
}
}
/**
* Returns the ESTree location equivalent of a given attribute
*
* @param {treeAdapter.Element} element Element which owns this attribute
* @param {string} attr Attribute name to retrieve
* @param {SourceCode} source Source code from ESLint
* @return {?ESTree.SourceLocation}
*/
getLocationForAttribute(element, attr, source) {
if (!element.sourceCodeLocation || !element.sourceCodeLocation.attrs) {
return null;
}
const loc = element.sourceCodeLocation.attrs[attr.toLowerCase()];
return loc ? this.resolveLocation(loc, source) : null;
}
/**
* Returns the value of the specified attribute.
* If this is an expression, the expression will be returned. Otherwise,
* the raw value will be returned.
* NOTE: if an attribute has multiple expressions in its value, this will
* return the *first* expression.
* @param {treeAdapter.Element} element Element which owns this attribute
* @param {string} attr Attribute name to retrieve
* @param {SourceCode} source Source code from ESLint
* @return {?ESTree.Expression|string}
*/
getAttributeValue(element, attr, source) {
const value = element.attribs[attr];
if (value === undefined) {
return null;
}
const loc = this.getLocationForAttribute(element, attr, source);
if (!loc) {
return value;
}
// We add the attribute name length so we only pick up expressions
// inside the value part
const start = source.getIndexFromLoc(loc.start) + attr.length;
const end = source.getIndexFromLoc(loc.end);
const containedExpr = this._node.quasi.expressions.find((expr) => {
if (!expr.loc) {
return false;
}
const exprStart = source.getIndexFromLoc(expr.loc.start);
const exprEnd = source.getIndexFromLoc(expr.loc.end);
return exprStart >= start && exprEnd <= end;
});
if (containedExpr !== undefined) {
return containedExpr;
}
return value;
}
/**
* Returns the raw attribute source of a given attribute
*
* @param {treeAdapter.Element} element Element which owns this attribute
* @param {string} attr Attribute name to retrieve
* @return {string}
*/
getRawAttributeValue(element, attr) {
if (!element.sourceCodeLocation) {
return null;
}
const xAttribs = element['x-attribsPrefix'];
let originalAttr = attr.toLowerCase();
if (xAttribs && xAttribs[attr]) {
originalAttr = `${xAttribs[attr]}:${attr}`;
}
const loc = element.sourceCodeLocation.attrs[originalAttr];
const source = this.source.substring(loc.startOffset, loc.endOffset);
const firstEq = source.indexOf('=');
const left = firstEq === -1 ? source : source.substr(0, firstEq);
const right = firstEq === -1 ? undefined : source.substr(firstEq + 1);
let unquotedValue = right;
if (right) {
if (right.startsWith('"') && right.endsWith('"')) {
unquotedValue = right.replace(/(^"|"$)/g, '');
}
else if (right.startsWith("'") && right.endsWith("'")) {
unquotedValue = right.replace(/(^'|'$)/g, '');
}
}
return {
name: left,
value: unquotedValue,
quotedValue: right
};
}
/**
* Resolves a Parse5 location into an ESTree range
*
* @param {parse5.Location} loc Location to convert
* @param {SourceCode} source ESLint source code object
* @return {ESTree.SourceLocation}
*/
resolveLocation(loc, source) {
var _a, _b, _c, _d, _e, _f, _g, _h;
if (!this._node.loc || !this._node.quasi.loc) {
return null;
}
let currentOffset = 0;
// Initial correction is the offset of the overall template literal
let endCorrection = ((_b = (_a = this._node.quasi.range) === null || _a === void 0 ? void 0 : _a[0]) !== null && _b !== void 0 ? _b : 0) + 1;
let startCorrection = endCorrection;
let startCorrected = false;
for (let i = 0; i < this._node.quasi.quasis.length; i++) {
const quasi = this._node.quasi.quasis[i];
const expr = this._node.quasi.expressions[i];
const nextQuasi = this._node.quasi.quasis[i + 1];
currentOffset += quasi.value.raw.length;
// If we haven't already found the quasi containing the start offset
// and this quasi ends after it, set the start offset's correction
// value and leave it from now on.
if (!startCorrected && loc.startOffset < currentOffset) {
startCorrection = endCorrection;
startCorrected = true;
}
// If the location ends before this point, it must fit entirely in
// this quasi and the quasis before it, so we don't care about the rest
if (loc.endOffset < currentOffset) {
break;
}
// If there's no range, something's really messed up so just fall back
// to the template literal's location
if (!quasi.range) {
return (_c = this._node.quasi.loc) !== null && _c !== void 0 ? _c : null;
}
if (expr) {
const placeholder = getExpressionPlaceholder(this._node, quasi);
const oldOffset = currentOffset;
// If there's an expression, increment the offset by its placeholder's
// length (e.g. ${v} may actually be {{__Q:0__}} in HTML)
currentOffset += placeholder.length;
// If the offset fits inside the placeholder range, there's nothing
// smart we can do, so return the expression's location
if ((loc.startOffset >= oldOffset && loc.startOffset < currentOffset) ||
(loc.endOffset >= oldOffset && loc.endOffset < currentOffset)) {
return (_d = expr.loc) !== null && _d !== void 0 ? _d : null;
}
// If the expression has no range, it won't be the only problem
// so lets just fall back to the template literal's location
if (!expr.range) {
return (_e = this._node.quasi.loc) !== null && _e !== void 0 ? _e : null;
}
// Increment the correction value by the size of the expression.
// Given an expression ${foo}, its range only covers "foo", not any
// whitespace or the brackets.
// To work around this, we use the end of the previous quasi and the
// start of the next quasi as our [start, end] rather than the
// expression's own [start, end].
const exprEnd = (_g = (_f = nextQuasi === null || nextQuasi === void 0 ? void 0 : nextQuasi.range) === null || _f === void 0 ? void 0 : _f[0]) !== null && _g !== void 0 ? _g : expr.range[1];
const exprStart = quasi.range[1];
endCorrection -= placeholder.length;
endCorrection += exprEnd - exprStart + 3;
}
}
// If the start never got corrected, parse5 is trying to give us a bad day
// and probably gave us an offset after the end of the string (it does
// this). So we should fall back to whatever the current end correction is
if (!startCorrected) {
startCorrection = endCorrection;
}
try {
const start = source.getLocFromIndex(loc.startOffset + startCorrection);
const end = source.getLocFromIndex(loc.endOffset + endCorrection);
return {
start,
end
};
}
catch (_err) {
return (_h = this._node.quasi.loc) !== null && _h !== void 0 ? _h : null;
}
}
/**
* Traverse the inner HTML tree with a given visitor
*
* @param {Visitor} visitor Visitor to apply
* @return {void}
*/
traverse(visitor) {
const visit = (node, parent) => {
if (!node) {
return;
}
if (visitor.enter) {
visitor.enter(node, parent);
}
if (isRootNode(node)) {
if (visitor.enterDocumentFragment) {
visitor.enterDocumentFragment(node, parent);
}
}
else if (treeAdapter.isCommentNode(node)) {
if (visitor.enterCommentNode) {
visitor.enterCommentNode(node, parent);
}
}
else if (treeAdapter.isTextNode(node)) {
if (visitor.enterTextNode) {
visitor.enterTextNode(node, parent);
}
}
else if (treeAdapter.isElementNode(node)) {
if (visitor.enterElement) {
visitor.enterElement(node, parent);
}
}
if (treeAdapter.isElementNode(node) || isRootNode(node)) {
const children = node.childNodes;
if (children && children.length > 0) {
children.forEach((child) => {
visit(child, node);
});
}
}
if (visitor.exit) {
visitor.exit(node, parent);
}
};
visit(this._ast, null);
}
}