import {Parser} from 'htmlparser2';
import {Options} from './Options';
import {DefaultTagHandlers} from './Options';
import {StringDictionary} from './StringDictionary';
import {TagHandler} from './Options';
function assign(...args) {
return args.reduce(function (target, source) {
return Object.keys(Object(source)).reduce((target, key) => {
target[key] = source[key];
return target;
}, target);
});
}
/**
* The default implementation of the default tag handlers.
*/
const DEFAULT_TAGS_HANDLERS: DefaultTagHandlers = {
'tpl-logger': {
onopentag(name, attrs, key, statics, varArgs) {
let level = statics.level || varArgs.level || 'log',
content = statics.content || varArgs.content || '';
return `console.${level}(${content});`;
}
},
'tpl-each': {
onopentag(name, attrs, key, statics, varArgs) {
let itemsName = statics.items || varArgs.items || `items`,
itemName = statics.item || varArgs.item || `item`,
indexName = statics.index || varArgs.index || `index`;
return `(${itemsName} || []).forEach(function (${itemName}, ${indexName}) {`;
},
onclosetag() {
return `});`;
}
},
'tpl-if': {
onopentag(name, attrs, key, statics, varArgs) {
let expression = statics.expression || varArgs.expression || 'false';
return `if (${expression}) {`;
},
onclosetag() {
return `}`;
}
},
'tpl-else-if': {
onopentag(name, attrs, key, statics, varArgs) {
let expression = statics.expression || varArgs.expression || 'false';
return ` } else if (${expression}) { `;
}
},
'tpl-else': {
onopentag() {
return ` } else { `;
}
},
'tpl-text': {
onopentag(name, attrs, key, statics, varArgs, options) {
return inlineInterpolationEvaluator.inject(statics.value || varArgs.value, options);
}
},
'tpl-call': {
onopentag(name, attrs, key, statics, varArgs, options) {
let helperName = statics.name || varArgs.name;
return `${options.varHelpersName}.${helperName}(${options.varDataName});`;
}
}
};
/**
* The default options.
*/
const DEFAULT_OPTIONS: Options = {
pretty: false,
ignoreStaticAttributes: false,
interpolation: /{{([\s\S]+?)}}/gm,
expression: /\[\[([\s\S]+?)]]/gm,
attributeKey: 'tpl-key',
attributeSkip: 'tpl-skip',
skipExceptions: true,
skipCustomElements: true,
varDataName: 'data',
varHelpersName: 'helpers',
selfClosingElements: ['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'keygen', 'link', 'meta', 'param', 'source', 'track', 'wbr'],
tags: DEFAULT_TAGS_HANDLERS
};
function stringify(value = ''): string {
return value.replace(/'/gim, '\\\'').replace(/\n/gi, '\\n');
}
function isSelfClosing(name = '', options = DEFAULT_OPTIONS): boolean {
return options.selfClosingElements.indexOf(name) > -1;
}
function getFunctionName(name = '', options = DEFAULT_OPTIONS): string {
return isSelfClosing(name, options) ? '_elementVoid' : '_elementOpen';
}
function append(body = '', line = '', options = DEFAULT_OPTIONS): string {
if (line) {
return body + (options.pretty ? '\n' : '') + line;
}
return body;
}
function createSafeJsBlock(value: string) {
return `(function () { try { return ${value} } catch(e) { return '' } })()`;
}
/**
* Configuration to transform an expression into a compliant JavaScript fragment.
*/
interface Evaluator {
/**
* Appender between statements.
*/
appender: string
/**
* To convert a text statements.
* @param value the value
*/
toText?: (value: string, options: Options) => string
/**
* To convert a js statements.
* @param value the value
* @param options the options
*/
inject: (value: string, options: Options) => string
}
const attributeEvaluator: Evaluator = {
appender: ' + ',
toText: (value) => `'${stringify(value)}'`,
inject: (value, options: Options) => options.skipExceptions ? createSafeJsBlock(value.trim()) : `(${value.trim()})`
};
const inlineInterpolationEvaluator: Evaluator = {
appender: ' ',
inject: (value, options) => options.skipExceptions ? `_text(${createSafeJsBlock(value.trim())});` : `_text(${value.trim()});`
};
const inlineExpressionEvaluator: Evaluator = {
appender: ' ',
inject: value => `${value}`
};
function evaluate(value: string, evaluator: Evaluator, regex: RegExp, options: Options): string {
let js = [];
let result;
let lastIndex = 0;
while ((result = regex.exec(value)) !== null) {
let full = result[0];
let group = result[1];
let index = result.index;
let before = value.substring(lastIndex, index);
if (before) {
js.push(evaluator.toText(before, options));
}
if (group.trim()) {
js.push(evaluator.inject(group, options));
}
lastIndex = index + full.length;
}
let after = value.substring(lastIndex, value.length);
if (after) {
js.push(evaluator.toText(after, options));
}
return js.join(evaluator.appender);
}
function wrapExpressions(value: string, options: Options): string {
return value.replace(options.interpolation, '').replace(options.expression, '');
}
function unwrapExpressions(value: string): string {
return value.replace(//gim, '');
}
function checkSkipAttribute(attrs: StringDictionary = {}, options = DEFAULT_OPTIONS): boolean {
return attrs.hasOwnProperty(options.attributeSkip) && attrs[options.attributeSkip] !== 'deactivated';
}
function checkIsAttribute(attrs: StringDictionary = {}, options = DEFAULT_OPTIONS): boolean {
return options.skipCustomElements && attrs.hasOwnProperty('is') && attrs[options.attributeSkip] !== 'deactivated';
}
function parseAttributes(attrs: StringDictionary = {}, options = DEFAULT_OPTIONS) {
const skip: boolean = checkSkipAttribute(attrs, options) || checkIsAttribute(attrs, options);
const statics: StringDictionary = {};
const varArgs: StringDictionary = {};
Object.keys(attrs)
.filter(key => [options.attributeSkip].indexOf(key) < 0)
.forEach(function (key) {
let value = unwrapExpressions(attrs[key]);
if (value.search(options.interpolation) > -1 || options.ignoreStaticAttributes) {
varArgs[key] = evaluate(value, attributeEvaluator, options.interpolation, options);
} else {
statics[key] = value;
}
});
const key = statics[options.attributeKey] || varArgs[options.attributeKey];
delete statics[options.attributeKey];
delete varArgs[options.attributeKey];
return {statics, varArgs, key, skip};
}
function varArgsToJs(varArgs = {}): string {
let keys = Object.keys(varArgs);
return keys.length > 0 ? (keys.map(key => `'${key}', ${varArgs[key]}`).join(', ')) : 'null';
}
function staticsToJs(statics = {}): string {
let keys = Object.keys(statics);
return keys.length > 0 ? `[${keys.map(key => `'${key}', '${stringify(statics[key])}'`).join(', ')}]` : 'null';
}
function checkCustomElement(name = '', attrs: StringDictionary = {}, options = DEFAULT_OPTIONS): boolean {
return options.skipCustomElements && attrs[options.attributeSkip] !== 'deactivated' && name.indexOf('-') > -1;
}
/**
* Compile the given HTML template into a function factory.
*
* If the incrementalDOM argument is provided, this function will return a render function.
* The render function is used with IncrementalDOM.patch.
*
* If the incrementalDOM argument is not provided, this function will return a factory function.
* The factory function requires the IncrementalDOM library as argument and return the render function..
*
* Basically, when the template is compiled at build time, the IncrementalDOM should not be given.
* When the template is compiled at runtime, the IncrementalDOM should be given.
*
* @param html the template
* @param options the options
* @returns the function factory
*/
export function compile(html = '', options: Options = DEFAULT_OPTIONS): Function {
options = assign({}, DEFAULT_OPTIONS, options, {
tags: assign({}, DEFAULT_TAGS_HANDLERS, options.tags)
});
let fnBody = '';
let parser = new Parser({
onopentag(name, attrs) {
const {statics, varArgs, key, skip} = parseAttributes(attrs, options);
if (options.tags[name]) {
const tagHandler: TagHandler = options.tags[name];
if (typeof tagHandler.onopentag === 'function') {
fnBody = append(
fnBody,
tagHandler.onopentag(name, attrs, key, statics, varArgs, options),
options
);
}
} else {
const fn = getFunctionName(name, options);
fnBody = append(
fnBody,
`${fn}('${name}', ${key ? `${key}` : 'null'}, ${staticsToJs(statics)}, ${varArgsToJs(varArgs)});`,
options
);
if (skip || checkCustomElement(name, attrs, options)) {
fnBody = append(
fnBody,
`_skip();`,
options
);
}
}
},
onclosetag(name) {
if (options.tags[name]) {
const tagHandler: TagHandler = options.tags[name];
if (typeof tagHandler.onclosetag === 'function') {
fnBody = append(
fnBody,
tagHandler.onclosetag(name, options),
options
);
}
} else if (!isSelfClosing(name, options)) {
fnBody = append(
fnBody,
`_elementClose('${name}');`,
options
);
}
},
ontext(text) {
if (text.search(options.expression) > -1) {
fnBody = append(
fnBody,
`${evaluate(text, inlineExpressionEvaluator, options.expression, options)}`,
options
);
} else if (text.search(options.interpolation) > -1) {
fnBody = append(
fnBody,
`${evaluate(text, inlineInterpolationEvaluator, options.interpolation, options)}`,
options
);
} else {
fnBody = append(
fnBody,
`_text('${stringify(text)}');`,
options
);
}
}
}, {
xmlMode: false,
decodeEntities: true,
lowerCaseTags: false,
lowerCaseAttributeNames: false,
recognizeSelfClosing: true,
recognizeCDATA: true
});
// wrap inline expression with a CDATA tag to allow inline javascript
parser.parseComplete(wrapExpressions(html, options));
let fnWrapper = `
var _elementOpen = _i.elementOpen,
_elementClose = _i.elementClose,
_elementVoid = _i.elementVoid,
_text = _i.text,
_skip = _i.skip;
return function (_data_) {
var ${options.varHelpersName || 'helpers'} = _h,
${options.varDataName || 'data'} = _data_;
${fnBody}
};
`;
// @ts-ignore
return new Function(['_i', '_h'], fnWrapper);
}