isml-linter
Version:
ISML Linter is a tool for examining if your project's templates follow a specified set of rules defined by your dev team. The available rules can be roughly grouped into:
561 lines (450 loc) • 21.8 kB
JavaScript
/**
* The functions defined in this file do not create or modify a state, they
* simply analyze it and return relevant information;
*/
const path = require('path');
const Constants = require('../Constants');
const ExceptionUtils = require('../util/ExceptionUtils');
const SfccTagContainer = require('../enums/SfccTagContainer');
const GeneralUtils = require('../util/GeneralUtils');
const MaskUtils = require('./MaskUtils');
const getNextNonEmptyChar = content => {
return content.replace(new RegExp(Constants.EOL, 'g'), '').trim()[0];
};
const getCharOccurrenceQty = (string, char) => (string.match(new RegExp(char, 'g')) || []).length;
const getLineBreakQty = string => getCharOccurrenceQty(string, Constants.EOL);
const getNextNonEmptyCharPos = content => {
const firstNonEmptyChar = getNextNonEmptyChar(content);
const index = content.indexOf(firstNonEmptyChar);
return Math.max(index, 0);
};
const getLeadingEmptyChars = string => {
const leadingBlankSpacesQty = getNextNonEmptyCharPos(string);
return string.substring(0, leadingBlankSpacesQty);
};
const getElementColumnNumber = (newElement, state) => {
if (newElement.value.indexOf(Constants.EOL) >= 0) {
const firstNonEmptyCharPos = getNextNonEmptyCharPos(newElement.value);
return firstNonEmptyCharPos === 0 ?
1 :
newElement.value
.substring(0, firstNonEmptyCharPos)
.split('').reverse().join('')
.indexOf(Constants.EOL) + 1;
} else if (state.elementList.length === 0) {
return getNextNonEmptyCharPos(newElement.value) + 1;
} else {
let columnNumber = 1;
for (let i = state.elementList.length - 1; i >= 0; i--) {
const element = state.elementList[i];
if (element.value.indexOf(Constants.EOL) >= 0) {
columnNumber += element.value.length - 1;
break;
} else if (i === 0) {
columnNumber += element.value.length + 1;
break;
} else {
columnNumber += element.value.length;
}
}
return columnNumber;
}
};
const getLeadingLineBreakQty = string => {
const leadingString = getLeadingEmptyChars(string);
return this.getLineBreakQty(leadingString);
};
const getTrailingEmptyCharsQty = string => {
const invertedString = string.split('').reverse().join('').replace(Constants.EOL, '_');
return Math.max(getLeadingEmptyChars(invertedString).length, 0);
};
const checkBalance = (node, templatePath) => {
for (let i = 0; i < node.children.length; i++) {
checkBalance(node.children[i]);
}
if (!node.isRoot() &&
node.parent && !node.parent.isContainer() &&
(node.isHtmlTag() || node.isIsmlTag()) &&
!node.isSelfClosing() && !node.tail
&& !node.parent.isOfType('iscomment')
) {
throw ExceptionUtils.unbalancedElementError(
node.getType(),
node.lineNumber,
node.globalPos,
node.head.trim().length,
templatePath
);
}
};
const parseNextElement = state => {
const ConfigUtils = require('../util/ConfigUtils');
const config = ConfigUtils.load();
const newElement = getNewElement(state);
const trimmedElement = newElement.value.trim();
const previousElement = state.elementList[state.elementList.length - 1] || {};
const isIscommentContent = previousElement.tagType === 'iscomment' && !previousElement.isClosingTag && trimmedElement !== '</iscomment>';
const isIsscriptContent = previousElement.tagType === 'isscript' && !previousElement.isClosingTag && trimmedElement !== '</isscript>';
if (isIsscriptContent || isIscommentContent) {
newElement.lineNumber = getLineBreakQty(state.pastContent) + getLeadingLineBreakQty(newElement.value) + 1;
newElement.globalPos = state.pastContent.length + getLeadingEmptyChars(newElement.value).length;
newElement.type = 'text';
newElement.isSelfClosing = true;
if (state.isCrlfLineBreak && isIscommentContent) {
newElement.globalPos -= getLineBreakQty(newElement.value);
}
} else {
trimmedElement.startsWith('<') || trimmedElement.startsWith('${') ?
parseTagOrExpressionElement(state, newElement) :
parseTextElement(state, newElement);
}
newElement.columnNumber = getElementColumnNumber(newElement, state);
newElement.isVoidElement = !config.disableHtml5 && Constants.voidElementsArray.indexOf(newElement.tagType) >= 0;
if (state.isCrlfLineBreak) {
newElement.globalPos += newElement.lineNumber - 1;
}
state.elementList.push(newElement);
if (newElement.type === 'htmlTag' && newElement.value.indexOf('<isif') >= 0 && newElement.value.indexOf('</isif') < 0) {
throw ExceptionUtils.invalidNestedIsifError(
newElement.tagType,
newElement.lineNumber,
newElement.globalPos,
state.templatePath
);
} else if (newElement.value.trim() === '>') {
throw ExceptionUtils.invalidCharacterError(
'>',
newElement.lineNumber,
newElement.globalPos,
1,
state.templatePath
);
}
return newElement;
};
const parseTagOrExpressionElement = (state, newElement) => {
const trimmedElement = newElement.value.trim().toLowerCase();
const isTag = trimmedElement.startsWith('<') && !trimmedElement.startsWith('<!--');
const isExpression = trimmedElement.startsWith('${');
const isHtmlOrIsmlComment = trimmedElement.startsWith('<!--');
const isConditionalComment = trimmedElement.indexOf('<!--[if') >= 0 || trimmedElement.indexOf('<![endif') >= 0;
if (isTag) {
if (trimmedElement.startsWith('<is') || trimmedElement.startsWith('</is')) {
newElement.type = 'ismlTag';
} else if (trimmedElement.startsWith('<!DOCTYPE')) {
newElement.type = 'doctype';
} else {
newElement.type = 'htmlTag';
}
} else if (isConditionalComment) {
newElement.type = 'htmlConditionalComment';
} else if (isHtmlOrIsmlComment) {
newElement.type = 'htmlOrIsmlComment';
} else if (isExpression) {
newElement.type = 'expression';
} else {
newElement.type = 'text';
}
if (isTag) {
newElement.tagType = getElementType(trimmedElement);
newElement.isCustomTag = newElement.type === 'ismlTag' && !SfccTagContainer[newElement.tagType];
}
newElement.isSelfClosing = isSelfClosing(trimmedElement);
newElement.isClosingTag = isTag && trimmedElement.startsWith('</');
newElement.lineNumber = getLineBreakQty(state.pastContent) + getLeadingLineBreakQty(newElement.value) + 1;
newElement.globalPos = state.pastContent.length + getLeadingEmptyChars(newElement.value).length;
// TODO Refactor this, remove this post-processing;
if (newElement.type === 'htmlConditionalComment') {
newElement.tagType = 'html_conditional_comment';
newElement.isSelfClosing = newElement.value.indexOf('<!--[if') >= 0 && newElement.value.indexOf('<![endif') >= 0;
if (trimmedElement.indexOf('<!--<![endif]') >= 0) {
newElement.isClosingTag = true;
}
}
};
const parseTextElement = (state, newElement) => {
newElement.type = 'text';
newElement.lineNumber = getLineBreakQty(state.pastContent.substring(0, state.pastContent.length - state.cutSpot))
+ getLeadingLineBreakQty(newElement.value) + 1;
newElement.globalPos = state.pastContent.length - state.cutSpot + getLeadingEmptyChars(newElement.value).length;
newElement.isSelfClosing = true;
};
const getElementType = trimmedElement => {
if (trimmedElement.startsWith('</')) {
const tailElementType = trimmedElement.slice(2, -1);
if (tailElementType.startsWith('${')) {
return 'dynamic_element';
}
return tailElementType;
} else {
const typeValueLastPos = Math.min(...[
trimmedElement.indexOf(' '),
trimmedElement.indexOf('/'),
trimmedElement.indexOf(Constants.EOL),
trimmedElement.indexOf('>')
].filter(j => j >= 0));
const elementType = trimmedElement.substring(1, typeValueLastPos).trim();
if (elementType.startsWith('${')) {
return 'dynamic_element';
}
return elementType;
}
};
function isSelfClosing(trimmedElement) {
const ConfigUtils = require('../util/ConfigUtils');
const config = ConfigUtils.load();
const isTag = trimmedElement.startsWith('<') && !trimmedElement.startsWith('<!--');
const elementType = getElementType(trimmedElement);
const isDocType = trimmedElement.toLowerCase().startsWith('<!doctype ');
const isVoidElement = !config.disableHtml5 && Constants.voidElementsArray.indexOf(elementType) >= 0;
const isHtmlComment = trimmedElement.startsWith('<!--') && trimmedElement.endsWith('-->');
const isClosingTag = trimmedElement.endsWith('/>');
const isIsmlTag = trimmedElement.startsWith('<is');
const isStandardIsmlTag = !!SfccTagContainer[elementType];
const isCustomIsmlTag = isIsmlTag && !isStandardIsmlTag;
const isExpression = trimmedElement.startsWith('${') && trimmedElement.endsWith('}');
const isSfccSelfClosingTag = SfccTagContainer[elementType] && SfccTagContainer[elementType]['self-closing'];
// 'isif' tag is never self-closing;
if (['isif'].indexOf(elementType) >= 0) {
return false;
}
return !!(isDocType ||
isVoidElement ||
isExpression ||
isHtmlComment ||
isTag && isClosingTag ||
isCustomIsmlTag ||
isIsmlTag && isSfccSelfClosingTag);
}
const getNextOpeningTagOrExpressionInitPos = content => {
return Math.min(...[
content.indexOf('<'),
content.indexOf('<--'),
content.indexOf('${')
].filter(j => j >= 0)) + 1;
};
const getNextClosingTagOrExpressionEndPos = content => {
return Math.min(...[
content.indexOf('>'),
content.indexOf('-->'),
content.indexOf('}')
].filter(j => j >= 0)) + 1;
};
const getInitialState = (templateContent, templatePath, isCrlfLineBreak) => {
// TODO Check if "GeneralUtils.toLF" can be removed;
const originalContent = GeneralUtils.toLF(templateContent);
const originalShadowContent = MaskUtils.maskIgnorableContent(originalContent, null, templatePath);
return {
templatePath : templatePath,
templateName : templatePath ? path.basename(templatePath) : '',
originalContent : originalContent,
originalShadowContent : originalShadowContent,
remainingContent : originalContent,
remainingShadowContent : originalShadowContent,
pastContent : '',
elementList : [],
cutSpot : null,
isCrlfLineBreak
};
};
const initLoopState = state => {
state.nextOpeningTagOrExpressionInitPos = getNextOpeningTagOrExpressionInitPos(state.remainingShadowContent);
state.nextClosingTagOrExpressionEndPos = getNextClosingTagOrExpressionEndPos(state.remainingShadowContent);
state.cutSpot = null;
};
const finishLoopState = state => {
const newElement = state.elementList[state.elementList.length - 1];
// If there is no element left (only blank spaces and / or line breaks);
if (!isFinite(state.nextClosingTagOrExpressionEndPos)) {
state.nextClosingTagOrExpressionEndPos = state.remainingShadowContent.length - 1;
}
if (!state.cutSpot) {
state.remainingShadowContent = state.remainingShadowContent.substring(newElement.value.length);
state.remainingContent = state.remainingContent.substring(newElement.value.length);
state.pastContent = state.originalContent.substring(0, state.pastContent.length + newElement.value.length);
}
};
const mergeTrailingSpacesWithLastElement = state => {
const elementList = state.elementList;
const lastElement = elementList[elementList.length - 1];
const secondLastElement = elementList[elementList.length - 2];
if (lastElement.value.trim().length === 0) {
secondLastElement.value += lastElement.value;
elementList.pop();
}
};
const adjustTrailingSpaces = state => {
// Note that last element is not iterated over;
for (let i = 0; i < state.elementList.length - 1; i++) {
const previousElement = i > 0 ? state.elementList[i - 1] : null;
const currentElement = state.elementList[i];
if (currentElement.type === 'text'
&& previousElement
&& previousElement.lineNumber !== currentElement.lineNumber
&& previousElement.tagType !== 'isscript'
) {
const trailingSpacesQty = currentElement.value
.replace(/\r\n/g, '_')
.split('')
.reverse()
.join('')
.search(/\S/);
if (trailingSpacesQty > 0) {
const trailingSpaces = currentElement.value.slice(-trailingSpacesQty);
currentElement.value = currentElement.value.slice(0, -trailingSpacesQty);
const nextElement = state.elementList[i + 1];
nextElement.value = trailingSpaces + nextElement.value;
}
}
}
};
// TODO Refactor this function;
const checkIfNextElementIsATagOrHtmlComment = (content, state) => {
const previousElementType = state.elementList.length > 0 && state.elementList[state.elementList.length - 1].tagType;
const isIscommentContent = previousElementType === 'iscomment';
const isIsscriptContent = previousElementType === 'isscript';
const isScriptContent = previousElementType === 'script';
return !isIscommentContent && !isScriptContent && !isIsscriptContent && content.startsWith('<') && content.substring(1).match(/^[A-z]/i) || content.startsWith('</') || content.startsWith('<!');
};
const getWrapperTagContent = (state, wrapperTagType) => {
for (let i = 0; i < state.remainingContent.length; i++) {
const remainingString = state.remainingContent.substring(i);
if (remainingString.startsWith(`</${wrapperTagType}>`)) {
return state.remainingContent.substring(0, i);
}
}
return state.remainingContent;
};
const checkIfCurrentElementWrappedByTag = (state, wrapperTagType) => {
let depth = 0;
for (let i = state.elementList.length - 1; i >= 0 ; i--) {
const element = state.elementList[i];
if (element.tagType === wrapperTagType) {
depth += element.isClosingTag ? -1 : 1;
}
}
return depth > 0 && !state.remainingContent.trimStart().startsWith(`</${wrapperTagType}>`);
};
const getTextLastContiguousMaskedCharPos = (state, isNextElementATag, isNextElementAnExpression) => {
const localMaskedContent0 = MaskUtils.maskExpressionContent(state.remainingContent);
const localMaskedContent1 = MaskUtils.maskInBetween(localMaskedContent0, '<', '>');
for (let i = 0; i < localMaskedContent1.length; i++) {
if (isNextElementATag && localMaskedContent1[i] === '>') {
return i + 1;
}
if (isNextElementAnExpression && localMaskedContent1[i] === '}') {
return i + 1;
}
}
};
// TODO Refactor this function
const getNewElement = state => {
const trimmedContent = state.remainingContent.trimStart();
const isWithinIscomment = checkIfCurrentElementWrappedByTag(state, 'iscomment');
const isWithinIsscript = checkIfCurrentElementWrappedByTag(state, 'isscript');
const isNextElementATag = trimmedContent.startsWith('<');
const isNextElementAnExpression = trimmedContent.startsWith('${');
const isTextElement = !isNextElementATag && !isNextElementAnExpression;
let lastContiguousMaskedCharPos;
let elementValue;
if (isWithinIscomment) {
elementValue = getWrapperTagContent(state, 'iscomment');
} else if (isWithinIsscript) {
elementValue = getWrapperTagContent(state, 'isscript');
} else if (isTextElement) {
for (let i = 0; i < state.remainingContent.length; i++) {
const remainingString = state.remainingContent.substring(i);
const isNextElementATagOrHtmlComment = checkIfNextElementIsATagOrHtmlComment(remainingString, state);
if (isNextElementATagOrHtmlComment || remainingString.startsWith('${')) {
lastContiguousMaskedCharPos = i;
break;
}
}
elementValue = state.remainingContent.substring(0, lastContiguousMaskedCharPos);
} else {
if (state.elementList.length > 0 && state.elementList[state.elementList.length - 1].type === 'text') {
lastContiguousMaskedCharPos = getTextLastContiguousMaskedCharPos(state, isNextElementATag, isNextElementAnExpression);
} else {
let remainingMaskedContent = state.remainingContent;
if (isNextElementATag) {
remainingMaskedContent = MaskUtils.maskExpressionContent(remainingMaskedContent);
remainingMaskedContent = MaskUtils.maskQuoteContent(remainingMaskedContent);
}
for (let i = 0; i < remainingMaskedContent.length; i++) {
if (isNextElementATag && remainingMaskedContent[i] === '>') {
lastContiguousMaskedCharPos = i + 1;
break;
}
}
}
elementValue = state.remainingShadowContent.startsWith('_') ?
state.remainingContent.substring(0, lastContiguousMaskedCharPos) :
state.remainingContent.substring(0, state.nextClosingTagOrExpressionEndPos);
}
return {
value : elementValue,
type : undefined,
globalPos : undefined,
lineNumber : undefined,
isSelfClosing : undefined,
isClosingTag : undefined,
tagType : undefined
};
};
const getElementList = (templateContent, templatePath, isCrlfLineBreak) => {
const state = getInitialState(templateContent, templatePath, isCrlfLineBreak);
const elementList = state.elementList;
let previousStateContent = state.remainingShadowContent;
if (templateContent === '') {
return [];
}
do {
initLoopState(state);
parseNextElement(state);
finishLoopState(state);
if (previousStateContent.length === state.remainingShadowContent.length) {
throw ExceptionUtils.unkownError(templatePath);
}
previousStateContent = state.remainingShadowContent;
} while (state.remainingShadowContent.length > 0);
adjustTrailingSpaces(state);
mergeTrailingSpacesWithLastElement(state);
return elementList;
};
const getBlankSpaceString = length => {
let result = '';
for (let i = 0; i < length; i++) {
result += ' ';
}
return result;
};
const getColumnNumber = content => {
const leadingContent = content.substring(0, getNextNonEmptyCharPos(content));
const lastLineBreakPos = leadingContent.lastIndexOf(Constants.EOL);
const precedingEmptySpaces = leadingContent.substring(lastLineBreakPos + 1);
return precedingEmptySpaces.length + 1;
};
const getFirstEmptyCharPos = content => {
const firstLineBreakPos = content.indexOf(Constants.EOL);
const firstBlankSpacePos = content.indexOf(' ');
if (firstLineBreakPos === -1 && firstBlankSpacePos === -1) {
return content.length;
} else if (firstLineBreakPos >= 0 && firstBlankSpacePos === -1) {
return firstLineBreakPos;
} else if (firstLineBreakPos === -1 && firstBlankSpacePos >= 0) {
return firstBlankSpacePos;
} else if (firstLineBreakPos >= 0 && firstBlankSpacePos >= 0) {
return Math.min(firstLineBreakPos, firstBlankSpacePos);
}
};
module.exports.getElementList = getElementList;
module.exports.checkBalance = checkBalance;
module.exports.getLineBreakQty = getLineBreakQty;
module.exports.getCharOccurrenceQty = getCharOccurrenceQty;
module.exports.getNextNonEmptyCharPos = getNextNonEmptyCharPos;
module.exports.getLeadingEmptyChars = getLeadingEmptyChars;
module.exports.getLeadingLineBreakQty = getLeadingLineBreakQty;
module.exports.getTrailingEmptyCharsQty = getTrailingEmptyCharsQty;
module.exports.getBlankSpaceString = getBlankSpaceString;
module.exports.getColumnNumber = getColumnNumber;
module.exports.getFirstEmptyCharPos = getFirstEmptyCharPos;