export interface IView { [key: string]: any } export interface ICharMap { [key: string]: string } export interface IFilterMap { [key: string]: (res: string) => string } export interface ISubTemplates { [key: string]: string } export interface ICacheMap { [key: string]: string } export interface IStencilOptions { newLineToBr?: boolean filters?: IFilterMap } export const REGEX_SECTIONS = /\{{2}(#|\^)([^}]+)\}{2}((.|\n)*?)\{{2}\/\2\}{2}(\n)?/gm export const REGEX_INNER_SECTION = /\{{2}(#|\^)([^}]+)\}{2}/gm export const REGEX_TEMPLATES = /\{{2}>([^}]+)\}{2}/g export const REGEX_TAGS = /\{{2}([^}]+)\}{2,3}/gm export const REGEX_HTML_CHARS = /[&<>"']/g export const REGEX_LINK = /(http|https|ftp|ftps)\:\/\/[a-zA-Z0-9\-\.]+\.[a-zA-Z]{2,3}(\/\S*)?/gm export const REGEX_VARS = /{|\$|}/gm export const REGEX_HTML_TAGS = /<((\/|[a-zA-Z0-9="]){1})([^<>]*)>/gm export const REGEX_UNICODE = /\\u[\dA-F]{4}/gi export const HTML_CHAR_MAP: ICharMap = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', '`': '`', } export const IGNORE_HTML_FILTERS = ['raw', 'linkify'] export const render = ( template: string, view: IView, subTemplates?: ISubTemplates, ): string => { return compileBlocks(template, view, subTemplates) .split('\n') .map((line: string) => _compileTags(line, view)) .join('\n') } export const _compileBlock = ( section: any, view: IView, comparator?: any, ): string => { let [, , mVar, inner] = section let observe: any = comparator ? comparator : view[mVar] let innerResult: string = '' if (~inner.indexOf('{{.}}')) { // All items in array if (Array.isArray(observe)) { observe.forEach( (item: string) => (innerResult += inner.replace('{{.}}', item).trim() + '\n'), ) } else if (typeof observe === 'object') { // Section within a section innerResult = compileBlocks(inner, Object.assign({}, view, observe)) } } else { // Check if we have variables that are part of the section if (typeof observe[0] === 'object') { observe.forEach( (item: any) => (innerResult += _compileTags( inner, Object.assign({}, view, item), ).trim() + '\n'), ) } else if (!Array.isArray(observe)) { innerResult += _compileTags(inner, Object.assign({}, view, observe)) } } return innerResult } export const compileBlocks = ( template: string, view: IView, subTemplates?: ISubTemplates, ): string => { let nTemplate: string = template, section: string[] | null if (subTemplates) { while ( (section = new RegExp(REGEX_TEMPLATES).exec(nTemplate)) !== null ) { let [match, mVar] = section mVar = mVar.trim() nTemplate = subTemplates[mVar] ? nTemplate.replace(match, subTemplates[mVar]) : nTemplate.replace(match, '') } } let r: RegExp = new RegExp(REGEX_SECTIONS), idx: number = 0, nView: IView = Object.assign({}, view) while ((section = r.exec(nTemplate)) !== null) { let [match, operator, mVar, inner] = section if (~mVar.indexOf('.')) { const node = accessNode(mVar, view) nTemplate = node ? nTemplate.replace(match, _compileBlock(section, nView, node)) : nTemplate.replace(match, '') } else if ( (operator === '#' && view[mVar]) || (operator === '^' && !view[mVar]) ) { if (~inner.indexOf('{{#' + mVar + '}}')) { // Handles cases where we have nested nodes with the same name r.lastIndex = idx + 1 nView = Object.assign(nView, view[mVar]) } else { idx = r.lastIndex nTemplate = nTemplate.replace( match, _compileBlock(section, nView), ) r = new RegExp(REGEX_SECTIONS) // Reset RegExp } } else { nTemplate = nTemplate.replace(match, '') } } return nTemplate } export const accessNode = (mVar: string, view: IView): string => { const nodes = mVar.split('.') let vLevel: any = '' vLevel = view[nodes[0]] if (vLevel === undefined) { return '' } for (let i = 1; i < nodes.length; i++) { vLevel = vLevel[nodes[i]] if (typeof vLevel !== 'object') { break } } return vLevel } export const _compileTags = (line: string, view: IView): string => { const tags = line.match(REGEX_TAGS) if (!tags) { return line } tags.forEach((match: string) => { let tag: string = match.replace(REGEX_VARS, '') let data: string, filter: string = '' if (~tag.indexOf('|')) { ;[tag, filter] = tag.split('|') } if (filter) { if (~tag.indexOf('.')) { const node = accessNode(tag, view) data = node ? filterVar(node, filter) : '' } else { data = view[tag] ? filterVar(view[tag], filter) : '' } } else { if (~tag.indexOf('.')) { const node = accessNode(tag, view) data = node ? node : '' } else { if (match.startsWith('{{{') && match.endsWith('}}}')) { if (~tag.indexOf(' ')) { let [fTag, ...args] = tag.split(' ') data = typeof view[fTag] === 'function' ? view[fTag](...args) : view[tag] ? view[tag] : '' } else { data = typeof view[tag] === 'function' ? view[tag]() : view[tag] ? view[tag] : '' } } else { if (~tag.indexOf(' ')) { let [fTag, ...args] = tag.split(' ') data = typeof view[fTag] === 'function' ? decodeHTML(view[fTag](...args)) : view[tag] ? decodeHTML(view[tag]) : '' } else { data = typeof view[tag] === 'function' ? decodeHTML(view[tag]()) : view[tag] ? decodeHTML(view[tag]) : '' } } } } line = line.replace(match, data) }) return line } export const decodeHTML = (html: string): string => html.toString().replace(REGEX_HTML_CHARS, (m: string) => HTML_CHAR_MAP[m]) export const defaultFilterMap: IFilterMap = { lower: (res: string): string => res.toLowerCase(), upper: (res: string): string => res.toUpperCase(), linkify: (res: string): string => res.replace( REGEX_LINK, (m: string) => `${m}`, ), ucwords: (res: string): string => ~res.indexOf(' ') ? res .split(' ') .map( (word: string) => word.charAt(0).toUpperCase() + word.slice(1), ) .join(' ') : res.charAt(0).toUpperCase() + res.slice(1), excerpt: (res: string): string => res.length <= 255 ? res : res.slice(0, res.substring(0, 255).lastIndexOf(' ')) + '...', stripTags: (res: string): string => res.replace(REGEX_HTML_TAGS, ''), } let filterMap: IFilterMap = Object.assign({}, defaultFilterMap) export const filterVar = (str: string, filter: string): string => { let result: string = filterMap[filter] ? filterMap[filter](str) : str return ~IGNORE_HTML_FILTERS.indexOf(filter) ? result : decodeHTML(result) || result } export const Stencil = { render: ( template: string, view: IView, subTemplates?: ISubTemplates, options?: IStencilOptions, ): string => { if (options) { if (options.filters) { filterMap = Object.assign(filterMap, options.filters) } if (options.newLineToBr) { return render(template, view, subTemplates).replace( /\n/g, '
\n', ) } } return render(template, view, subTemplates) }, } module.exports = Stencil