Skip to content

Instantly share code, notes, and snippets.

@tw3
Last active January 14, 2020 16:28
Show Gist options
  • Save tw3/c8440e49e535ab497a03a8ca498d696a to your computer and use it in GitHub Desktop.
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 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