htmlhint
Version:
The Static Code Analysis Tool for your HTML
1,780 lines (1,585 loc) • 55.5 kB
JavaScript
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
typeof define === 'function' && define.amd ? define(['exports'], factory) :
(global = global || self, factory(global.HTMLHint = {}));
}(this, (function (exports) { 'use strict';
class HTMLParser {
constructor() {
this._listeners = {};
this._mapCdataTags = this.makeMap('script,style');
this._arrBlocks = [];
this.lastEvent = null;
}
makeMap(str) {
var obj = {};
var items = str.split(',');
for (var i = 0; i < items.length; i++) {
obj[items[i]] = true;
}
return obj
}
parse(html) {
var self = this;
var mapCdataTags = self._mapCdataTags;
// eslint-disable-next-line
var regTag = /<(?:\/([^\s>]+)\s*|!--([\s\S]*?)--|!([^>]*?)|([\w\-:]+)((?:\s+[^\s"'>\/=\x00-\x0F\x7F\x80-\x9F]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'>]*))?)*?)\s*(\/?))>/g,
// eslint-disable-next-line
regAttr = /\s*([^\s"'>\/=\x00-\x0F\x7F\x80-\x9F]+)(?:\s*=\s*(?:(")([^"]*)"|(')([^']*)'|([^\s"'>]*)))?/g,
regLine = /\r?\n/g;
var match;
var matchIndex;
var lastIndex = 0;
var tagName;
var arrAttrs;
var tagCDATA;
var attrsCDATA;
var arrCDATA;
var lastCDATAIndex = 0;
var text;
var lastLineIndex = 0;
var line = 1;
var arrBlocks = self._arrBlocks;
self.fire('start', {
pos: 0,
line: 1,
col: 1,
});
// Memory block
function saveBlock(type, raw, pos, data) {
var col = pos - lastLineIndex + 1;
if (data === undefined) {
data = {};
}
data.raw = raw;
data.pos = pos;
data.line = line;
data.col = col;
arrBlocks.push(data);
self.fire(type, data);
// eslint-disable-next-line
var lineMatch;
while ((lineMatch = regLine.exec(raw))) {
line++;
lastLineIndex = pos + regLine.lastIndex;
}
}
while ((match = regTag.exec(html))) {
matchIndex = match.index;
if (matchIndex > lastIndex) {
// Save the previous text or CDATA
text = html.substring(lastIndex, matchIndex);
if (tagCDATA) {
arrCDATA.push(text);
} else {
// text
saveBlock('text', text, lastIndex);
}
}
lastIndex = regTag.lastIndex;
if ((tagName = match[1])) {
if (tagCDATA && tagName === tagCDATA) {
// Output CDATA before closing the label
text = arrCDATA.join('');
saveBlock('cdata', text, lastCDATAIndex, {
tagName: tagCDATA,
attrs: attrsCDATA,
});
tagCDATA = null;
attrsCDATA = null;
arrCDATA = null;
}
if (!tagCDATA) {
// End of label
saveBlock('tagend', match[0], matchIndex, {
tagName: tagName,
});
continue
}
}
if (tagCDATA) {
arrCDATA.push(match[0]);
} else {
if ((tagName = match[4])) {
// Label start
arrAttrs = [];
var attrs = match[5];
var attrMatch;
var attrMatchCount = 0;
while ((attrMatch = regAttr.exec(attrs))) {
var name = attrMatch[1];
var quote = attrMatch[2]
? attrMatch[2]
: attrMatch[4]
? attrMatch[4]
: '';
var value = attrMatch[3]
? attrMatch[3]
: attrMatch[5]
? attrMatch[5]
: attrMatch[6]
? attrMatch[6]
: '';
arrAttrs.push({
name: name,
value: value,
quote: quote,
index: attrMatch.index,
raw: attrMatch[0],
});
attrMatchCount += attrMatch[0].length;
}
if (attrMatchCount === attrs.length) {
saveBlock('tagstart', match[0], matchIndex, {
tagName: tagName,
attrs: arrAttrs,
close: match[6],
});
if (mapCdataTags[tagName]) {
tagCDATA = tagName;
attrsCDATA = arrAttrs.concat();
arrCDATA = [];
lastCDATAIndex = lastIndex;
}
} else {
// If a miss match occurs, the current content is matched to text
saveBlock('text', match[0], matchIndex);
}
} else if (match[2] || match[3]) {
// Comment tag
saveBlock('comment', match[0], matchIndex, {
content: match[2] || match[3],
long: match[2] ? true : false,
});
}
}
}
if (html.length > lastIndex) {
// End text
text = html.substring(lastIndex, html.length);
saveBlock('text', text, lastIndex);
}
self.fire('end', {
pos: lastIndex,
line: line,
col: html.length - lastLineIndex + 1,
});
}
addListener(types, listener) {
var _listeners = this._listeners;
var arrTypes = types.split(/[,\s]/);
var type;
for (var i = 0, l = arrTypes.length; i < l; i++) {
type = arrTypes[i];
if (_listeners[type] === undefined) {
_listeners[type] = [];
}
_listeners[type].push(listener);
}
}
fire(type, data) {
if (data === undefined) {
data = {};
}
data.type = type;
var self = this;
var listeners = [];
var listenersType = self._listeners[type];
var listenersAll = self._listeners['all'];
if (listenersType !== undefined) {
listeners = listeners.concat(listenersType);
}
if (listenersAll !== undefined) {
listeners = listeners.concat(listenersAll);
}
var lastEvent = self.lastEvent;
if (lastEvent !== null) {
delete lastEvent['lastEvent'];
data.lastEvent = lastEvent;
}
self.lastEvent = data;
for (var i = 0, l = listeners.length; i < l; i++) {
listeners[i].call(self, data);
}
}
removeListener(type, listener) {
var listenersType = this._listeners[type];
if (listenersType !== undefined) {
for (var i = 0, l = listenersType.length; i < l; i++) {
if (listenersType[i] === listener) {
listenersType.splice(i, 1);
break
}
}
}
}
fixPos(event, index) {
var text = event.raw.substr(0, index);
var arrLines = text.split(/\r?\n/);
var lineCount = arrLines.length - 1;
var line = event.line;
var col;
if (lineCount > 0) {
line += lineCount;
col = arrLines[lineCount].length + 1;
} else {
col = event.col + index;
}
return {
line: line,
col: col,
}
}
getMapAttrs(arrAttrs) {
var mapAttrs = {};
var attr;
for (var i = 0, l = arrAttrs.length; i < l; i++) {
attr = arrAttrs[i];
mapAttrs[attr.name] = attr.value;
}
return mapAttrs
}
}
class Reporter {
constructor(html, ruleset) {
this.html = html;
this.lines = html.split(/\r?\n/);
var match = html.match(/\r?\n/);
this.brLen = match !== null ? match[0].length : 0;
this.ruleset = ruleset;
this.messages = [];
this.error = this.report.bind(this, 'error');
this.warn = this.report.bind(this, 'warning');
this.info = this.report.bind(this, 'info');
}
report(type, message, line, col, rule, raw) {
var self = this;
var lines = self.lines;
var brLen = self.brLen;
var evidence;
var evidenceLen;
for (var i = line - 1, lineCount = lines.length; i < lineCount; i++) {
evidence = lines[i];
evidenceLen = evidence.length;
if (col > evidenceLen && line < lineCount) {
line++;
col -= evidenceLen;
if (col !== 1) {
col -= brLen;
}
} else {
break
}
}
self.messages.push({
type: type,
message: message,
raw: raw,
evidence: evidence,
line: line,
col: col,
rule: {
id: rule.id,
description: rule.description,
link: 'https://github.com/thedaviddias/HTMLHint/wiki/' + rule.id,
},
});
}
}
var altRequire = {
id: 'alt-require',
description:
'The alt attribute of an <img> element must be present and alt attribute of area[href] and input[type=image] must have a value.',
init: function (parser, reporter) {
var self = this;
parser.addListener('tagstart', function (event) {
var tagName = event.tagName.toLowerCase();
var mapAttrs = parser.getMapAttrs(event.attrs);
var col = event.col + tagName.length + 1;
var selector;
if (tagName === 'img' && !('alt' in mapAttrs)) {
reporter.warn(
'An alt attribute must be present on <img> elements.',
event.line,
col,
self,
event.raw
);
} else if (
(tagName === 'area' && 'href' in mapAttrs) ||
(tagName === 'input' && mapAttrs['type'] === 'image')
) {
if (!('alt' in mapAttrs) || mapAttrs['alt'] === '') {
selector = tagName === 'area' ? 'area[href]' : 'input[type=image]';
reporter.warn(
'The alt attribute of ' + selector + ' must have a value.',
event.line,
col,
self,
event.raw
);
}
}
});
},
};
/**
* testAgainstStringOrRegExp
*
* @param {string} value string to test
* @param {string|RegExp} comparison raw string or regex string
* @returns {boolean}
*/
function testAgainstStringOrRegExp(value, comparison) {
// If it's a RegExp, test directly
if (comparison instanceof RegExp) {
return comparison.test(value)
? { match: value, pattern: comparison }
: false
}
// Check if it's RegExp in a string
const firstComparisonChar = comparison[0];
const lastComparisonChar = comparison[comparison.length - 1];
const secondToLastComparisonChar = comparison[comparison.length - 2];
const comparisonIsRegex =
firstComparisonChar === '/' &&
(lastComparisonChar === '/' ||
(secondToLastComparisonChar === '/' && lastComparisonChar === 'i'));
const hasCaseInsensitiveFlag = comparisonIsRegex && lastComparisonChar === 'i';
// If so, create a new RegExp from it
if (comparisonIsRegex) {
const valueMatches = hasCaseInsensitiveFlag
? new RegExp(comparison.slice(1, -2), 'i').test(value)
: new RegExp(comparison.slice(1, -1)).test(value);
return valueMatches
}
// Otherwise, it's a string. Do a strict comparison
return value === comparison
}
var attrLowercase = {
id: 'attr-lowercase',
description: 'All attribute names must be in lowercase.',
init: function (parser, reporter, options) {
var self = this;
var exceptions = Array.isArray(options) ? options : [];
parser.addListener('tagstart', function (event) {
var attrs = event.attrs;
var attr;
var col = event.col + event.tagName.length + 1;
for (var i = 0, l = attrs.length; i < l; i++) {
attr = attrs[i];
var attrName = attr.name;
if (
!exceptions.find((exp) => testAgainstStringOrRegExp(attrName, exp)) &&
attrName !== attrName.toLowerCase()
) {
reporter.error(
'The attribute name of [ ' + attrName + ' ] must be in lowercase.',
event.line,
col + attr.index,
self,
attr.raw
);
}
}
});
},
};
var attrSorted = {
id: 'attr-sorted',
description: 'Attribute tags must be in proper order.',
init: function (parser, reporter) {
var self = this;
var orderMap = {};
var sortOrder = [
'class',
'id',
'name',
'src',
'for',
'type',
'href',
'value',
'title',
'alt',
'role',
];
for (var i = 0; i < sortOrder.length; i++) {
orderMap[sortOrder[i]] = i;
}
parser.addListener('tagstart', function (event) {
var attrs = event.attrs;
var listOfAttributes = [];
for (var i = 0; i < attrs.length; i++) {
listOfAttributes.push(attrs[i].name);
}
var originalAttrs = JSON.stringify(listOfAttributes);
listOfAttributes.sort(function (a, b) {
if (orderMap[a] == undefined && orderMap[b] == undefined) {
return 0
}
if (orderMap[a] == undefined) {
return 1
} else if (orderMap[b] == undefined) {
return -1
}
return orderMap[a] - orderMap[b] || a.localeCompare(b)
});
if (originalAttrs !== JSON.stringify(listOfAttributes)) {
reporter.error(
'Inaccurate order ' +
originalAttrs +
' should be in hierarchy ' +
JSON.stringify(listOfAttributes) +
' ',
event.line,
event.col,
self
);
}
});
},
};
var attrNoDuplication = {
id: 'attr-no-duplication',
description: 'Elements cannot have duplicate attributes.',
init: function (parser, reporter) {
var self = this;
parser.addListener('tagstart', function (event) {
var attrs = event.attrs;
var attr;
var attrName;
var col = event.col + event.tagName.length + 1;
var mapAttrName = {};
for (var i = 0, l = attrs.length; i < l; i++) {
attr = attrs[i];
attrName = attr.name;
if (mapAttrName[attrName] === true) {
reporter.error(
'Duplicate of attribute name [ ' + attr.name + ' ] was found.',
event.line,
col + attr.index,
self,
attr.raw
);
}
mapAttrName[attrName] = true;
}
});
},
};
var attrUnsafeChars = {
id: 'attr-unsafe-chars',
description: 'Attribute values cannot contain unsafe chars.',
init: function (parser, reporter) {
var self = this;
parser.addListener('tagstart', function (event) {
var attrs = event.attrs;
var attr;
var col = event.col + event.tagName.length + 1;
// exclude \x09(\t), \x0a(\r), \x0d(\n)
// eslint-disable-next-line
var regUnsafe = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\u00ad\u0600-\u0604\u070f\u17b4\u17b5\u200c-\u200f\u2028-\u202f\u2060-\u206f\ufeff\ufff0-\uffff]/;
var match;
for (var i = 0, l = attrs.length; i < l; i++) {
attr = attrs[i];
match = attr.value.match(regUnsafe);
if (match !== null) {
var unsafeCode = escape(match[0])
.replace(/%u/, '\\u')
.replace(/%/, '\\x');
reporter.warn(
'The value of attribute [ ' +
attr.name +
' ] cannot contain an unsafe char [ ' +
unsafeCode +
' ].',
event.line,
col + attr.index,
self,
attr.raw
);
}
}
});
},
};
var attrValueDoubleQuotes = {
id: 'attr-value-double-quotes',
description: 'Attribute values must be in double quotes.',
init: function (parser, reporter) {
var self = this;
parser.addListener('tagstart', function (event) {
var attrs = event.attrs;
var attr;
var col = event.col + event.tagName.length + 1;
for (var i = 0, l = attrs.length; i < l; i++) {
attr = attrs[i];
if (
(attr.value !== '' && attr.quote !== '"') ||
(attr.value === '' && attr.quote === "'")
) {
reporter.error(
'The value of attribute [ ' +
attr.name +
' ] must be in double quotes.',
event.line,
col + attr.index,
self,
attr.raw
);
}
}
});
},
};
var attrValueNotEmpty = {
id: 'attr-value-not-empty',
description: 'All attributes must have values.',
init: function (parser, reporter) {
var self = this;
parser.addListener('tagstart', function (event) {
var attrs = event.attrs;
var attr;
var col = event.col + event.tagName.length + 1;
for (var i = 0, l = attrs.length; i < l; i++) {
attr = attrs[i];
if (attr.quote === '' && attr.value === '') {
reporter.warn(
'The attribute [ ' + attr.name + ' ] must have a value.',
event.line,
col + attr.index,
self,
attr.raw
);
}
}
});
},
};
var attrValueSingleQuotes = {
id: 'attr-value-single-quotes',
description: 'Attribute values must be in single quotes.',
init: function (parser, reporter) {
var self = this;
parser.addListener('tagstart', function (event) {
var attrs = event.attrs;
var attr;
var col = event.col + event.tagName.length + 1;
for (var i = 0, l = attrs.length; i < l; i++) {
attr = attrs[i];
if (
(attr.value !== '' && attr.quote !== "'") ||
(attr.value === '' && attr.quote === '"')
) {
reporter.error(
'The value of attribute [ ' +
attr.name +
' ] must be in single quotes.',
event.line,
col + attr.index,
self,
attr.raw
);
}
}
});
},
};
var attrWhitespace = {
id: 'attr-whitespace',
description:
'All attributes should be separated by only one space and not have leading/trailing whitespace.',
init: function (parser, reporter, options) {
var self = this;
var exceptions = Array.isArray(options) ? options : [];
parser.addListener('tagstart', function (event) {
var attrs = event.attrs;
var attr;
var col = event.col + event.tagName.length + 1;
attrs.forEach(function (elem) {
attr = elem;
var attrName = elem.name;
if (exceptions.indexOf(attrName) !== -1) {
return
}
//Check first and last characters for spaces
if (elem.value.trim(elem.value) !== elem.value) {
reporter.error(
'The attributes of [ ' +
attrName +
' ] must not have trailing whitespace.',
event.line,
col + attr.index,
self,
attr.raw
);
}
if (elem.value.replace(/ +(?= )/g, '') !== elem.value) {
reporter.error(
'The attributes of [ ' +
attrName +
' ] must be separated by only one space.',
event.line,
col + attr.index,
self,
attr.raw
);
}
});
});
},
};
var doctypeFirst = {
id: 'doctype-first',
description: 'Doctype must be declared first.',
init: function (parser, reporter) {
var self = this;
var allEvent = function (event) {
if (
event.type === 'start' ||
(event.type === 'text' && /^\s*$/.test(event.raw))
) {
return
}
if (
(event.type !== 'comment' && event.long === false) ||
/^DOCTYPE\s+/i.test(event.content) === false
) {
reporter.error(
'Doctype must be declared first.',
event.line,
event.col,
self,
event.raw
);
}
parser.removeListener('all', allEvent);
};
parser.addListener('all', allEvent);
},
};
var doctypeHtml5 = {
id: 'doctype-html5',
description: 'Invalid doctype. Use: "<!DOCTYPE html>"',
init: function (parser, reporter) {
var self = this;
function onComment(event) {
if (
event.long === false &&
event.content.toLowerCase() !== 'doctype html'
) {
reporter.warn(
'Invalid doctype. Use: "<!DOCTYPE html>"',
event.line,
event.col,
self,
event.raw
);
}
}
function onTagStart() {
parser.removeListener('comment', onComment);
parser.removeListener('tagstart', onTagStart);
}
parser.addListener('all', onComment);
parser.addListener('tagstart', onTagStart);
},
};
var headScriptDisabled = {
id: 'head-script-disabled',
description: 'The <script> tag cannot be used in a <head> tag.',
init: function (parser, reporter) {
var self = this;
var reScript = /^(text\/javascript|application\/javascript)$/i;
var isInHead = false;
function onTagStart(event) {
var mapAttrs = parser.getMapAttrs(event.attrs);
var type = mapAttrs.type;
var tagName = event.tagName.toLowerCase();
if (tagName === 'head') {
isInHead = true;
}
if (
isInHead === true &&
tagName === 'script' &&
(!type || reScript.test(type) === true)
) {
reporter.warn(
'The <script> tag cannot be used in a <head> tag.',
event.line,
event.col,
self,
event.raw
);
}
}
function onTagEnd(event) {
if (event.tagName.toLowerCase() === 'head') {
parser.removeListener('tagstart', onTagStart);
parser.removeListener('tagend', onTagEnd);
}
}
parser.addListener('tagstart', onTagStart);
parser.addListener('tagend', onTagEnd);
},
};
var hrefAbsOrRel = {
id: 'href-abs-or-rel',
description: 'An href attribute must be either absolute or relative.',
init: function (parser, reporter, options) {
var self = this;
var hrefMode = options === 'abs' ? 'absolute' : 'relative';
parser.addListener('tagstart', function (event) {
var attrs = event.attrs;
var attr;
var col = event.col + event.tagName.length + 1;
for (var i = 0, l = attrs.length; i < l; i++) {
attr = attrs[i];
if (attr.name === 'href') {
if (
(hrefMode === 'absolute' && /^\w+?:/.test(attr.value) === false) ||
(hrefMode === 'relative' &&
/^https?:\/\//.test(attr.value) === true)
) {
reporter.warn(
'The value of the href attribute [ ' +
attr.value +
' ] must be ' +
hrefMode +
'.',
event.line,
col + attr.index,
self,
attr.raw
);
}
break
}
}
});
},
};
var idClassAdDisabled = {
id: 'id-class-ad-disabled',
description:
'The id and class attributes cannot use the ad keyword, it will be blocked by adblock software.',
init: function (parser, reporter) {
var self = this;
parser.addListener('tagstart', function (event) {
var attrs = event.attrs;
var attr;
var attrName;
var col = event.col + event.tagName.length + 1;
for (var i = 0, l = attrs.length; i < l; i++) {
attr = attrs[i];
attrName = attr.name;
if (/^(id|class)$/i.test(attrName)) {
if (/(^|[-_])ad([-_]|$)/i.test(attr.value)) {
reporter.warn(
'The value of attribute ' +
attrName +
' cannot use the ad keyword.',
event.line,
col + attr.index,
self,
attr.raw
);
}
}
}
});
},
};
var idClassValue = {
id: 'id-class-value',
description:
'The id and class attribute values must meet the specified rules.',
init: function (parser, reporter, options) {
var self = this;
var arrRules = {
underline: {
regId: /^[a-z\d]+(_[a-z\d]+)*$/,
message:
'The id and class attribute values must be in lowercase and split by an underscore.',
},
dash: {
regId: /^[a-z\d]+(-[a-z\d]+)*$/,
message:
'The id and class attribute values must be in lowercase and split by a dash.',
},
hump: {
regId: /^[a-z][a-zA-Z\d]*([A-Z][a-zA-Z\d]*)*$/,
message:
'The id and class attribute values must meet the camelCase style.',
},
};
var rule;
if (typeof options === 'string') {
rule = arrRules[options];
} else {
rule = options;
}
if (rule && rule.regId) {
var regId = rule.regId;
var message = rule.message;
if (!(regId instanceof RegExp)) {
regId = new RegExp(regId);
}
parser.addListener('tagstart', function (event) {
var attrs = event.attrs;
var attr;
var col = event.col + event.tagName.length + 1;
for (var i = 0, l1 = attrs.length; i < l1; i++) {
attr = attrs[i];
if (attr.name.toLowerCase() === 'id') {
if (regId.test(attr.value) === false) {
reporter.warn(
message,
event.line,
col + attr.index,
self,
attr.raw
);
}
}
if (attr.name.toLowerCase() === 'class') {
var arrClass = attr.value.split(/\s+/g);
var classValue;
for (var j = 0, l2 = arrClass.length; j < l2; j++) {
classValue = arrClass[j];
if (classValue && regId.test(classValue) === false) {
reporter.warn(
message,
event.line,
col + attr.index,
self,
classValue
);
}
}
}
}
});
}
},
};
var idUnique = {
id: 'id-unique',
description: 'The value of id attributes must be unique.',
init: function (parser, reporter) {
var self = this;
var mapIdCount = {};
parser.addListener('tagstart', function (event) {
var attrs = event.attrs;
var attr;
var id;
var col = event.col + event.tagName.length + 1;
for (var i = 0, l = attrs.length; i < l; i++) {
attr = attrs[i];
if (attr.name.toLowerCase() === 'id') {
id = attr.value;
if (id) {
if (mapIdCount[id] === undefined) {
mapIdCount[id] = 1;
} else {
mapIdCount[id]++;
}
if (mapIdCount[id] > 1) {
reporter.error(
'The id value [ ' + id + ' ] must be unique.',
event.line,
col + attr.index,
self,
attr.raw
);
}
}
break
}
}
});
},
};
var inlineScriptDisabled = {
id: 'inline-script-disabled',
description: 'Inline script cannot be used.',
init: function (parser, reporter) {
var self = this;
parser.addListener('tagstart', function (event) {
var attrs = event.attrs;
var attr;
var col = event.col + event.tagName.length + 1;
var attrName;
var reEvent = /^on(unload|message|submit|select|scroll|resize|mouseover|mouseout|mousemove|mouseleave|mouseenter|mousedown|load|keyup|keypress|keydown|focus|dblclick|click|change|blur|error)$/i;
for (var i = 0, l = attrs.length; i < l; i++) {
attr = attrs[i];
attrName = attr.name.toLowerCase();
if (reEvent.test(attrName) === true) {
reporter.warn(
'Inline script [ ' + attr.raw + ' ] cannot be used.',
event.line,
col + attr.index,
self,
attr.raw
);
} else if (attrName === 'src' || attrName === 'href') {
if (/^\s*javascript:/i.test(attr.value)) {
reporter.warn(
'Inline script [ ' + attr.raw + ' ] cannot be used.',
event.line,
col + attr.index,
self,
attr.raw
);
}
}
}
});
},
};
var inlineStyleDisabled = {
id: 'inline-style-disabled',
description: 'Inline style cannot be used.',
init: function (parser, reporter) {
var self = this;
parser.addListener('tagstart', function (event) {
var attrs = event.attrs;
var attr;
var col = event.col + event.tagName.length + 1;
for (var i = 0, l = attrs.length; i < l; i++) {
attr = attrs[i];
if (attr.name.toLowerCase() === 'style') {
reporter.warn(
'Inline style [ ' + attr.raw + ' ] cannot be used.',
event.line,
col + attr.index,
self,
attr.raw
);
}
}
});
},
};
var inputRequiresLabel = {
id: 'input-requires-label',
description: 'All [ input ] tags must have a corresponding [ label ] tag. ',
init: function (parser, reporter) {
var self = this;
var labelTags = [];
var inputTags = [];
parser.addListener('tagstart', function (event) {
var tagName = event.tagName.toLowerCase();
var mapAttrs = parser.getMapAttrs(event.attrs);
var col = event.col + tagName.length + 1;
if (tagName === 'input') {
inputTags.push({ event: event, col: col, id: mapAttrs['id'] });
}
if (tagName === 'label') {
if ('for' in mapAttrs && mapAttrs['for'] !== '') {
labelTags.push({ event: event, col: col, forValue: mapAttrs['for'] });
}
}
});
parser.addListener('end', function () {
inputTags.forEach(function (inputTag) {
if (!hasMatchingLabelTag(inputTag)) {
reporter.warn(
'No matching [ label ] tag found.',
inputTag.event.line,
inputTag.col,
self,
inputTag.event.raw
);
}
});
});
function hasMatchingLabelTag(inputTag) {
var found = false;
labelTags.forEach(function (labelTag) {
if (inputTag.id && inputTag.id === labelTag.forValue) {
found = true;
}
});
return found
}
},
};
var scriptDisabled = {
id: 'script-disabled',
description: 'The <script> tag cannot be used.',
init: function (parser, reporter) {
var self = this;
parser.addListener('tagstart', function (event) {
if (event.tagName.toLowerCase() === 'script') {
reporter.error(
'The <script> tag cannot be used.',
event.line,
event.col,
self,
event.raw
);
}
});
},
};
var spaceTabMixedDisabled = {
id: 'space-tab-mixed-disabled',
description: 'Do not mix tabs and spaces for indentation.',
init: function (parser, reporter, options) {
var self = this;
var indentMode = 'nomix';
var spaceLengthRequire = null;
if (typeof options === 'string') {
var match = options.match(/^([a-z]+)(\d+)?/);
indentMode = match[1];
spaceLengthRequire = match[2] && parseInt(match[2], 10);
}
parser.addListener('text', function (event) {
var raw = event.raw;
var reMixed = /(^|\r?\n)([ \t]+)/g;
var match;
while ((match = reMixed.exec(raw))) {
var fixedPos = parser.fixPos(event, match.index + match[1].length);
if (fixedPos.col !== 1) {
continue
}
var whiteSpace = match[2];
if (indentMode === 'space') {
if (spaceLengthRequire) {
if (
/^ +$/.test(whiteSpace) === false ||
whiteSpace.length % spaceLengthRequire !== 0
) {
reporter.warn(
'Please use space for indentation and keep ' +
spaceLengthRequire +
' length.',
fixedPos.line,
1,
self,
event.raw
);
}
} else {
if (/^ +$/.test(whiteSpace) === false) {
reporter.warn(
'Please use space for indentation.',
fixedPos.line,
1,
self,
event.raw
);
}
}
} else if (indentMode === 'tab' && /^\t+$/.test(whiteSpace) === false) {
reporter.warn(
'Please use tab for indentation.',
fixedPos.line,
1,
self,
event.raw
);
} else if (/ +\t|\t+ /.test(whiteSpace) === true) {
reporter.warn(
'Do not mix tabs and spaces for indentation.',
fixedPos.line,
1,
self,
event.raw
);
}
}
});
},
};
var specCharEscape = {
id: 'spec-char-escape',
description: 'Special characters must be escaped.',
init: function (parser, reporter) {
var self = this;
parser.addListener('text', function (event) {
var raw = event.raw;
// TODO: improve use-cases for &
// eslint-disable-next-line
var reSpecChar = /([<>])|( \& )/g;
var match;
while ((match = reSpecChar.exec(raw))) {
var fixedPos = parser.fixPos(event, match.index);
reporter.error(
'Special characters must be escaped : [ ' + match[0] + ' ].',
fixedPos.line,
fixedPos.col,
self,
event.raw
);
}
});
},
};
var srcNotEmpty = {
id: 'src-not-empty',
description: 'The src attribute of an img(script,link) must have a value.',
init: function (parser, reporter) {
var self = this;
parser.addListener('tagstart', function (event) {
var tagName = event.tagName;
var attrs = event.attrs;
var attr;
var col = event.col + tagName.length + 1;
for (var i = 0, l = attrs.length; i < l; i++) {
attr = attrs[i];
if (
((/^(img|script|embed|bgsound|iframe)$/.test(tagName) === true &&
attr.name === 'src') ||
(tagName === 'link' && attr.name === 'href') ||
(tagName === 'object' && attr.name === 'data')) &&
attr.value === ''
) {
reporter.error(
'The attribute [ ' +
attr.name +
' ] of the tag [ ' +
tagName +
' ] must have a value.',
event.line,
col + attr.index,
self,
attr.raw
);
}
}
});
},
};
var styleDisabled = {
id: 'style-disabled',
description: '<style> tags cannot be used.',
init: function (parser, reporter) {
var self = this;
parser.addListener('tagstart', function (event) {
if (event.tagName.toLowerCase() === 'style') {
reporter.warn(
'The <style> tag cannot be used.',
event.line,
event.col,
self,
event.raw
);
}
});
},
};
var tagPair = {
id: 'tag-pair',
description: 'Tag must be paired.',
init: function (parser, reporter) {
var self = this;
var stack = [];
var mapEmptyTags = parser.makeMap(
'area,base,basefont,br,col,frame,hr,img,input,isindex,link,meta,param,embed,track,command,source,keygen,wbr'
); //HTML 4.01 + HTML 5
parser.addListener('tagstart', function (event) {
var tagName = event.tagName.toLowerCase();
if (mapEmptyTags[tagName] === undefined && !event.close) {
stack.push({
tagName: tagName,
line: event.line,
raw: event.raw,
});
}
});
parser.addListener('tagend', function (event) {
var tagName = event.tagName.toLowerCase();
// Look up the matching start tag
for (var pos = stack.length - 1; pos >= 0; pos--) {
if (stack[pos].tagName === tagName) {
break
}
}
if (pos >= 0) {
var arrTags = [];
for (var i = stack.length - 1; i > pos; i--) {
arrTags.push('</' + stack[i].tagName + '>');
}
if (arrTags.length > 0) {
var lastEvent = stack[stack.length - 1];
reporter.error(
'Tag must be paired, missing: [ ' +
arrTags.join('') +
' ], start tag match failed [ ' +
lastEvent.raw +
' ] on line ' +
lastEvent.line +
'.',
event.line,
event.col,
self,
event.raw
);
}
stack.length = pos;
} else {
reporter.error(
'Tag must be paired, no start tag: [ ' + event.raw + ' ]',
event.line,
event.col,
self,
event.raw
);
}
});
parser.addListener('end', function (event) {
var arrTags = [];
for (var i = stack.length - 1; i >= 0; i--) {
arrTags.push('</' + stack[i].tagName + '>');
}
if (arrTags.length > 0) {
var lastEvent = stack[stack.length - 1];
reporter.error(
'Tag must be paired, missing: [ ' +
arrTags.join('') +
' ], open tag match failed [ ' +
lastEvent.raw +
' ] on line ' +
lastEvent.line +
'.',
event.line,
event.col,
self,
''
);
}
});
},
};
var tagSelfClose = {
id: 'tag-self-close',
description: 'Empty tags must be self closed.',
init: function (parser, reporter) {
var self = this;
var mapEmptyTags = parser.makeMap(
'area,base,basefont,br,col,frame,hr,img,input,isindex,link,meta,param,embed,track,command,source,keygen,wbr'
); //HTML 4.01 + HTML 5
parser.addListener('tagstart', function (event) {
var tagName = event.tagName.toLowerCase();
if (mapEmptyTags[tagName] !== undefined) {
if (!event.close) {
reporter.warn(
'The empty tag : [ ' + tagName + ' ] must be self closed.',
event.line,
event.col,
self,
event.raw
);
}
}
});
},
};
var tagnameLowercase = {
id: 'tagname-lowercase',
description: 'All html element names must be in lowercase.',
init: function (parser, reporter, options) {
var self = this;
var exceptions = Array.isArray(options) ? options : [];
parser.addListener('tagstart,tagend', function (event) {
var tagName = event.tagName;
if (
exceptions.indexOf(tagName) === -1 &&
tagName !== tagName.toLowerCase()
) {
reporter.error(
'The html element name of [ ' + tagName + ' ] must be in lowercase.',
event.line,
event.col,
self,
event.raw
);
}
});
},
};
var tagnameSpecialchars = {
id: 'tagname-specialchars',
description: 'All html element names must be in lowercase.',
init: function (parser, reporter) {
var self = this;
var specialchars = /[^a-zA-Z0-9\-:_]/;
parser.addListener('tagstart,tagend', function (event) {
var tagName = event.tagName;
if (specialchars.test(tagName)) {
reporter.error(
'The html element name of [ ' +
tagName +
' ] contains special character.',
event.line,
event.col,
self,
event.raw
);
}
});
},
};
var titleRequire = {
id: 'title-require',
description: '<title> must be present in <head> tag.',
init: function (parser, reporter) {
var self = this;
var headBegin = false;
var hasTitle = false;
function onTagStart(event) {
var tagName = event.tagName.toLowerCase();
if (tagName === 'head') {
headBegin = true;
} else if (tagName === 'title' && headBegin) {
hasTitle = true;
}
}
function onTagEnd(event) {
var tagName = event.tagName.toLowerCase();
if (hasTitle && tagName === 'title') {
var lastEvent = event.lastEvent;
if (
lastEvent.type !== 'text' ||
(lastEvent.type === 'text' && /^\s*$/.test(lastEvent.raw) === true)
) {
reporter.error(
'<title></title> must not be empty.',
event.line,
event.col,
self,
event.raw
);
}
} else if (tagName === 'head') {
if (hasTitle === false) {
reporter.error(
'<title> must be present in <head> tag.',
event.line,
event.col,
self,
event.raw
);
}
parser.removeListener('tagstart', onTagStart);
parser.removeListener('tagend', onTagEnd);
}
}
parser.addListener('tagstart', onTagStart);
parser.addListener('tagend', onTagEnd);
},
};
var tagsTypings = {
a: {
selfclosing: false,
attrsRequired: ['href', 'title'],
redundantAttrs: ['alt'],
},
div: {
selfclosing: false,
},
main: {
selfclosing: false,
redundantAttrs: ['role'],
},
nav: {
selfclosing: false,
redundantAttrs: ['role'],
},
script: {
attrsOptional: [
['async', 'async'],
['defer', 'defer'],
],
},
img: {
selfclosing: true,
attrsRequired: ['src', 'alt', 'title'],
},
};
var assign = function (target) {
var _source;
for (var i = 1; i < arguments.length; i++) {
_source = arguments[i];
for (var prop in _source) {
target[prop] = _source[prop];
}
}
return target
};
var tagsCheck = {
id: 'tags-check',
description: 'Checks html tags.',
init: function (parser, reporter, options) {
var self = this;
if (typeof options !== 'boolean') {
assign(tagsTypings, options);
}
parser.addListener('tagstart', function (event) {
var attrs = event.attrs;
var col = event.col + event.tagName.length + 1;
var tagName = event.tagName.toLowerCase();
if (tagsTypings[tagName]) {
var currentTagType = tagsTypings[tagName];
if (currentTagType.selfclosing === true && !event.close) {
reporter.warn(
'The <' + tagName + '> tag must be selfclosing.',
event.line,
event.col,
self,
event.raw
);
} else if (currentTagType.selfclosing === false && event.close) {
reporter.warn(
'The <' + tagName + '> tag must not be selfclosing.',
event.line,
event.col,
self,
event.raw
);
}
if (currentTagType.attrsRequired) {
currentTagType.attrsRequired.forEach(function (id) {
if (Array.isArray(id)) {
var copyOfId = id.map(function (a) {
return a
});
var realID = copyOfId.shift();
var values = copyOfId;
if (
attrs.some(function (attr) {
return attr.name === realID
})
) {
attrs.forEach(function (attr) {
if (
attr.name === realID &&
values.indexOf(attr.value) === -1
) {
reporter.error(
'The <' +
tagName +
"> tag must have attr '" +
realID +
"' with one value of '" +
values.join("' or '") +
"'.",
event.line,
col,
self,
event.raw
);
}
});
} else {
reporter.error(
'The <' + tagName + "> tag must have attr '" + realID + "'.",
event.line,
col,
self,
event.raw
);
}
} else if (
!attrs.some(function (attr) {
return id.split('|').indexOf(attr.name) !== -1
})
) {
reporter.error(
'The <' + tagName + "> tag must have attr '" + id + "'.",
event.line,
col,
self,
event.raw
);
}
});
}
if (currentTagType.attrsOptional) {
currentTagType.attrsOptional.forEach(function (id) {
if (Array.isArray(id)) {
var copyOfId = id.map(function (a) {
return a
});
var realID = copyOfId.shift();
var values = copyOfId;
if (
attrs.some(function (attr) {
return attr.name === realID
})
) {
attrs.forEach(function (attr) {
if (
attr.name === realID &&
values.indexOf(attr.value) === -1
) {
reporter.error(
'The <' +
tagName +
"> tag must have optional attr '" +
realID +
"' with one value of '" +
values.join("' or '") +
"'.",
event.line,
col,
self,
event.raw
);
}
});
}
}
});
}
if (currentTagType.redundantAttrs) {
currentTagType.redundantAttrs.forEach(function (attrName) {
if (
attrs.some(function (attr) {
return attr.name === attrName
})
) {
reporter.error(
"The attr '" +
attrName +
"' is redundant for <" +
tagName +
'> and should be ommited.',
event.line,
col,
self,
event.raw
);
}
});
}
}
});
},
};
var attrNoUnnecessaryWhitespace = {
id: 'attr-no-unnecessary-whitespace',
description: 'No spaces between attribute names and values.',
init: function (parser, reporter, options) {
var self = this;
var exceptions = Array.isArray(options) ? options : [];
parser.addListener('tagstart', function (event) {
var attrs = event.attrs;
var col = event.col + event.tagName.length + 1;
for (var i = 0; i < attrs.length; i++) {
if (
exceptions.indexOf(attrs[i].name) === -1 &&
/[^=](\s+=\s+|=\s+|\s+=)/g.test(attrs[i].raw.trim())
) {
reporter.error(
"The attribute '" +
attrs[i].name +
"' must not have spaces between the name and value.",
event.line,
col + attrs[i].index,
self,
attrs[i].raw
);
}