Last active
January 14, 2020 16:28
-
-
Save tw3/c8440e49e535ab497a03a8ca498d696a to your computer and use it in GitHub Desktop.
A node script that lints / reformats Angular template (html) files to a certain standard
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* This node js script scans through the input component template files and tidy's them according to certain guidelines. | |
* | |
* Sample shell command: | |
* node tidy-templates.js src/app projects | |
* | |
* To disable lint for a template add this comment to the top of the file: | |
* <!-- tidy-templates: disable --> | |
* | |
* Derived from: | |
* https://github.com/dave-kennedy/clean-html/blob/master/index.js | |
* | |
* TODO: | |
* Detect "Else" Templates | |
* Prefer ng-container for empty divs | |
* Mark Localizable Text (in templates) with data-i18n | |
*/ | |
const fs = require('fs'); | |
const glob = require('glob'); | |
const HtmlParser = require('htmlparser2'); | |
const DomUtils = HtmlParser.DomUtils; | |
const CONSTANTS = { | |
CR: '\r', | |
LF: '\n', | |
CRLF: '\r\n', | |
ONE_WAY_BINDING_REGEX: new RegExp('^\\[([^(\\]]+)\\]$'), // e.g. [foobar] | |
TWO_WAY_BINDING_REGEX: new RegExp('^\\[\\(([^)\\]]+)\\)\\]$'), // e.g. [(foobar)] | |
EVENT_BINDING_REGEX: new RegExp('^\\(([^)]+)\\)$'), // e.g. (foobar) | |
QUOTED_STRING_REGEX: new RegExp('^\'([^\']+)\'$'), // e.g. 'foobar' | |
EXPRESSION_REGEX_FULL: new RegExp('^{{([^}]+)}}$'), // e.g. {{ foobar }} | |
EXPRESSION_REGEX_PARTIAL: new RegExp('{{([^}]+)}}'), // e.g. abc {{ foobar }} xyz | |
}; | |
const opt = { | |
attribute: { | |
// List of attribute names whose value should be always be added even if empty. | |
// Otherwise by default the attribute value (="") will be omitted when empty. | |
emptyValueNames: [], | |
// Number of attributes at which attribute indentation should start, or null if wrapping should never happen | |
indentLengthThreshold: 2, | |
// Alphabetical sorting flag, true for ascending order, false for descending order, null to disable | |
alphaSort: true, | |
// When true converts [property]="'value'" -> property="value" | |
convertStaticOneWayBinding: false, | |
// List of regular expressions that update the attribute order, unmatched values will be added afterwards | |
orderRegex: [ | |
'^\\*.', // asteriskDirectives (aka structural directives) | |
'^#', // template-id | |
'^modal-body$', // modal-body | |
'^modal-footer$', // modal-footer | |
'^formControlName$', // otherDirectives | |
'^appCurrencyInput$', // otherDirectives | |
'^accordion-header$', // otherDirectives | |
'^accordion-body$', // otherDirectives | |
'^matInput$', // otherDirectives | |
'^matColumnDef$', // otherDirectives | |
'^matSort$', // otherDirectives | |
'^matTooltip$', // otherDirectives | |
'^froalaEditor$', // otherDirectives | |
'^froalaView$', // otherDirectives | |
'^id$', // id | |
'^type$', // type | |
'^let-', // otherDirectives | |
CONSTANTS.ONE_WAY_BINDING_REGEX, // propertyBinding | |
CONSTANTS.TWO_WAY_BINDING_REGEX, // bananasInABox (aka two way bindings) | |
CONSTANTS.EVENT_BINDING_REGEX, // eventBinding | |
'^class$', // class | |
'^((?!(data-i18n)).)*$', // other-attributes | |
'data-i18n' // data-i18n | |
] | |
}, | |
validSelfClosingTags: ['area', 'base', 'basefont', 'br', 'col', 'command', 'embed', 'frame', 'hr', 'img', 'input', 'isindex', 'keygen', 'link', 'meta', 'param', 'source', 'track', 'wbr', 'circle', 'ellipse', 'line', 'path', 'polygon', 'polyline', 'rect', 'stop', 'use'], | |
indentChars: ' ', | |
newlineChars: CONSTANTS.CRLF, | |
defaultDirs: [], // e.g. ['src/app', 'projects'] | |
fileGlob: '*.component.html', | |
debugFlag: false, | |
}; | |
function init() { | |
if (opt.debugFlag) { | |
// DEBUG | |
// const fileName = "src/app/dashboard/group/shared/stepper-steps/group-budget-pricing-step/group-budget-pricing-step.component.html"; | |
// const fileName = "src/app/dashboard/applications/applications-filter/applications-filter.component.html"; | |
// const fileName = 'src/app/dashboard/applications/applications-table/applications-table.component.html'; | |
// const fileName = 'src/app/dashboard/billing/billing-home/billing-home.component.html'; | |
// const fileName = 'src/app/dashboard/billing/shared/forms/credit-card-form/credit-card-form.component.html'; | |
// const fileName = 'src/app/dashboard/about/about.component.html'; | |
// const fileName = 'src/app/dashboard/group/shared/stepper-steps/group-name-step/group-name-step.component.html'; | |
const fileName = 'src/app/dashboard/group/group-detail/group-detail-edit-budget-dialog/group-detail-edit-budget-dialog.component.html'; | |
cleanFile(fileName); | |
return; | |
} | |
const hasInputFileNames = (process.argv.length >= 3); | |
const fileNameArray = hasInputFileNames ? process.argv.slice(2) : opt.defaultDirs; | |
if (fileNameArray.length === 0) { | |
console.error('Please specify one or more files or folders to tidy'); | |
return; | |
} | |
cleanFiles(fileNameArray); | |
} | |
function cleanFiles(fileNameArray) { | |
fileNameArray.forEach((fileName) => { | |
fs.lstat(fileName, (err, stats) => { | |
if (err) throw err; | |
if (stats.isDirectory()) { | |
cleanDirectory(fileName); | |
return; | |
} | |
if (!stats.isFile()) { | |
throw new TidyException(`'${fileName}' is not a file`); | |
} | |
cleanFile(fileName); | |
}); | |
}); | |
} | |
function cleanDirectory(dirName) { | |
const globPattern = `${dirName}/**/${opt.fileGlob}`; | |
const options = {}; | |
glob(globPattern, options, (err, fileNameArray) => { | |
if (err) throw err; | |
cleanFiles(fileNameArray); | |
}); | |
} | |
function cleanFile(fileName) { | |
fs.readFile(fileName, 'utf8', (err, html) => { | |
if (err) throw err; | |
cleanHtml(html, (newHtml) => { | |
const isUpdated = (newHtml != null); | |
if (!isUpdated) { | |
return; | |
} | |
if (opt.debugFlag) { | |
// DEBUG | |
console.log(''); | |
console.log(newHtml); | |
} else { | |
const isHtmlChanged = (newHtml !== html); | |
if (!isHtmlChanged) { | |
return; | |
} | |
fs.writeFile(fileName, newHtml, (err) => { | |
if (err) throw err; | |
console.log('Updated', fileName); | |
}) | |
} | |
}); | |
}); | |
} | |
function cleanHtml(inputHtml, callback) { | |
const domHandler = new HtmlParser.DomHandler((err, allDomNodes) => { | |
if (err) { | |
console.log('err:', err); | |
callback(null); | |
return; | |
} | |
try { | |
const resultHtml = getNodeArrayHtml(allDomNodes); | |
callback(resultHtml); | |
} catch (e) { | |
if (!!e.message) { | |
console.log('ERROR:', e.message); | |
} | |
callback(null); | |
} | |
}); | |
const parser = new HtmlParser.Parser(domHandler, { | |
lowerCaseTags: false, | |
lowerCaseAttributeNames: false | |
}); | |
parser.write(inputHtml); | |
parser.done(); | |
} | |
function getNodeArrayHtml(nodes, indentLevel = 0) { | |
let html = ''; | |
nodes.forEach(function (node) { | |
if (node.type === 'root') { | |
html += getNodeArrayHtml(node.children, indentLevel + 1); | |
return; | |
} | |
if (node.type === 'text') { | |
html += getNodeText(node, indentLevel); | |
return; | |
} | |
if (node.type === 'comment') { | |
html += getCommentHtml(node, indentLevel); | |
return; | |
} | |
if (node.type === 'directive') { | |
html += getDirectiveHtml(node, indentLevel); | |
return; | |
} | |
html += getTagHtml(node, indentLevel); | |
}); | |
return html; | |
} | |
function getTagHtml(node, indentLevel) { | |
const tagIndent = getIndent(indentLevel); | |
const tagName = node.name; | |
let openTag = `${tagIndent}<${tagName}`; | |
// Add html for tag attributes | |
const tagAttributes = getTagAttributesHtml(node, indentLevel + 1); | |
openTag += tagAttributes; | |
// Determine if node has any children | |
let hasChildren = getNodeHasChildren(node); | |
// Return html if tag has no children | |
let closeTag = `</${tagName}>`; | |
if (!hasChildren) { | |
const useSelfClosingTag = opt.validSelfClosingTags.includes(tagName); | |
if (useSelfClosingTag) { | |
return `${openTag}/>${opt.newlineChars}`; | |
} | |
const indentRegex = new RegExp(opt.newlineChars); | |
const areAttributesIndented = indentRegex.test(tagAttributes); | |
if (areAttributesIndented) { | |
// Indent closing tag if attributes are indented | |
return `${openTag}>${opt.newlineChars}${tagIndent}${closeTag}${opt.newlineChars}`; | |
} | |
return `${openTag}>${closeTag}${opt.newlineChars}`; | |
} | |
openTag += `>${opt.newlineChars}`; | |
const childContent = getNodeArrayHtml(node.children, indentLevel + 1); | |
closeTag = `${tagIndent}${closeTag}`; | |
return `${openTag}${childContent}${closeTag}${opt.newlineChars}`; | |
} | |
function getTagAttributesHtml(node, indentLevel) { | |
if (node.attribs == null) { | |
return ''; | |
} | |
let result = ''; | |
// Convert format | |
const tagAttributeDataArray = []; | |
const attributeNameArray = []; | |
populateTagAttributeData(node.attribs, tagAttributeDataArray, attributeNameArray); | |
// Sort attributes | |
sortTagAttributeDataArray(tagAttributeDataArray); | |
// Determine prefix for attributes | |
const shouldIndent = Number.isInteger(opt.attribute.indentLengthThreshold) && | |
(attributeNameArray.length >= opt.attribute.indentLengthThreshold); | |
const prefix = shouldIndent ? `${opt.newlineChars}${getIndent(indentLevel)}` : ' '; | |
// Get tag attributes html | |
for (const tagAttributeData of tagAttributeDataArray) { | |
// Add attribute name | |
result += prefix + tagAttributeData.name; | |
// Add attribute value (possibly) | |
const isAttributeValueEmpty = (tagAttributeData.value === ""); | |
const isAngularAttributeName = [ | |
CONSTANTS.ONE_WAY_BINDING_REGEX, CONSTANTS.EVENT_BINDING_REGEX, CONSTANTS.TWO_WAY_BINDING_REGEX | |
].some(regex => regex.test(tagAttributeData.name)); | |
const shouldAddAttributeValue = ( | |
isAngularAttributeName || | |
!isAttributeValueEmpty || | |
opt.attribute.emptyValueNames.indexOf(tagAttributeData.name) >= 0 | |
); | |
if (shouldAddAttributeValue) { | |
result += `="${tagAttributeData.value}"`; | |
} | |
} | |
return result; | |
} | |
function getDirectiveHtml(node, indentLevel) { | |
const indent = getIndent(indentLevel); | |
const nodeHtml = getNodeOuterHtml(node); | |
return `${indent}${nodeHtml}${opt.newlineChars}`; | |
} | |
function getNodeText(node, indentLevel) { | |
let text = node.data; | |
// Clean up newlines | |
const newlineDetectRegexStr = `${CONSTANTS.CRLF}|${CONSTANTS.CR}(?!${CONSTANTS.LF})|${CONSTANTS.LF}`; // i.e. \r\n|\r(?!\n)|\n | |
const newlineDetectRegex = new RegExp(newlineDetectRegexStr, 'g'); | |
text = text.replace(newlineDetectRegex, opt.newlineChars); | |
// Get trimmed text | |
let trimText = text.trim(); | |
const isEmpty = (trimText === ""); | |
if (!isEmpty) { | |
const indent = getIndent(indentLevel); | |
trimText = `${indent}${trimText}${opt.newlineChars}`; | |
} | |
// Add proper spacing in expressions | |
trimText = trimText.replace(CONSTANTS.EXPRESSION_REGEX_PARTIAL, (fullMatch, innerText) => { | |
return `{{ ${innerText.trim()} }}`; | |
}); | |
// Add an extra newline for separation if desired | |
const multipleNewlinesRegex = new RegExp(`${opt.newlineChars}\\s*${opt.newlineChars}\\s*$`); | |
const endsWithMultipleNewlines = multipleNewlinesRegex.test(text); | |
if (endsWithMultipleNewlines) { | |
trimText += opt.newlineChars; | |
} | |
return trimText; | |
} | |
function getCommentHtml(node, indentLevel) { | |
if (!node.data) { | |
return ''; | |
} | |
if (/^tidy-templates:\s*disable$/.test(node.data.trim())) { | |
throw new TidyIgnoreFileException(); | |
} | |
const indent = getIndent(indentLevel); | |
const nodeHtml = getNodeOuterHtml(node); | |
return `${indent}${nodeHtml}${opt.newlineChars}`; | |
} | |
function populateTagAttributeData(nodeAttributes, tagAttributeDataArray, attributeNameArray) { | |
let attributeIndex = 0; | |
for (let attributeName in nodeAttributes) { | |
if (!nodeAttributes.hasOwnProperty(attributeName)) { | |
continue; | |
} | |
let attributeValue = nodeAttributes[attributeName]; | |
const tagAttributeData = { | |
name: attributeName, | |
value: attributeValue, | |
index: attributeIndex++ | |
}; | |
if (opt.convertStaticOneWayBinding) { | |
// Convert [property]="'value'" -> property="value" | |
convertStaticOneWayBinding(tagAttributeData); | |
} | |
// Convert property="{{ value }}" -> [property]="value" | |
convertAttributeExpression(tagAttributeData); | |
tagAttributeDataArray.push(tagAttributeData); | |
attributeNameArray.push(attributeName); | |
} | |
} | |
/** | |
* Converts [property]="'value'" -> property="value" | |
* @param tagAttributeData Object with name, value, and index properties | |
*/ | |
function convertStaticOneWayBinding(tagAttributeData) { | |
const oneWayBindingMatches = tagAttributeData.name.match(CONSTANTS.ONE_WAY_BINDING_REGEX); | |
const isOneWayBindingName = (oneWayBindingMatches && oneWayBindingMatches.length === 2); | |
if (isOneWayBindingName) { | |
const quotedStringValueMatches = tagAttributeData.value.match(CONSTANTS.QUOTED_STRING_REGEX); | |
const isQuotedStringValue = (quotedStringValueMatches && quotedStringValueMatches.length === 2); | |
if (isQuotedStringValue) { | |
tagAttributeData.name = oneWayBindingMatches[1]; | |
tagAttributeData.value = quotedStringValueMatches[1]; | |
} | |
} | |
} | |
/** | |
* Converts property="{{ value }}" -> [property]="value" | |
* @param tagAttributeData | |
*/ | |
function convertAttributeExpression(tagAttributeData) { | |
const expressionValueMatches = tagAttributeData.value.match(CONSTANTS.EXPRESSION_REGEX_FULL); | |
const isExpressionValue = (expressionValueMatches && expressionValueMatches.length === 2); | |
if (isExpressionValue) { | |
tagAttributeData.name = `[${tagAttributeData.name}]`; | |
tagAttributeData.value = expressionValueMatches[1].trim(); | |
} | |
} | |
function sortTagAttributeDataArray(tagAttributeDataArray) { | |
tagAttributeDataArray.sort((a, b) => { | |
const defaultResult = a.index - b.index; | |
if (a.name === b.name) { | |
return defaultResult; | |
} | |
// Sort by orderRegex | |
const aIndex = getOrderRegexMatchIndex(a.name); | |
const bIndex = getOrderRegexMatchIndex(b.name); | |
if (opt.debugFlag) { | |
// DEBUG | |
console.log(`${a.name} = ${aIndex}, ${b.name} = ${bIndex}`); | |
} | |
if (aIndex < bIndex) { | |
return -1; | |
} | |
if (aIndex > bIndex) { | |
return 1; | |
} | |
// Use default sort if attributes are logic attributes | |
const hasNgLogic = /^[*[(]/.test(a.name); | |
if (opt.attribute.alphaSort == null || hasNgLogic) { | |
return defaultResult; | |
} | |
// Sort alphabetically by name | |
const result = opt.attribute.alphaSort ? | |
a.name.localeCompare(b.name) : b.name.localeCompare(a.name); | |
return result; | |
}); | |
} | |
function getNodeHasChildren(node) { | |
let hasChildren = (node.children.length > 0); | |
if (hasChildren) { | |
const hasASingleChild = (node.children.length === 1); | |
if (hasASingleChild) { | |
const childNode = node.children[0]; | |
const isChildTextNode = (childNode.type === 'text'); | |
if (isChildTextNode) { | |
const isChildNodeEmpty = (childNode.data.trim() === ''); | |
if (isChildNodeEmpty) { | |
hasChildren = false; | |
} | |
} | |
} | |
} | |
return hasChildren; | |
} | |
function getOrderRegexMatchIndex(attributeName) { | |
return opt.attribute.orderRegex.findIndex((item) => { | |
const regex = (item instanceof RegExp) ? item : new RegExp(item); | |
const isMatch = regex.test(attributeName); | |
return isMatch; | |
}) | |
} | |
function getIndent(indentLevel) { | |
return opt.indentChars.repeat(indentLevel); | |
} | |
function getTagPath(node) { | |
let tagInfo = node.name; | |
return (node.parent != null) ? `${getTagPath(node.parent)} > ${tagInfo}` : tagInfo; | |
} | |
function getNodeOuterHtml(node) { | |
return DomUtils.getOuterHTML(node); | |
} | |
function TidyIgnoreFileException() { | |
this.name = 'TidyIgnoreFileException'; | |
} | |
function TidyException(message) { | |
this.message = message; | |
this.name = 'TidyException'; | |
} | |
init(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment