A Micro Template

The following microtemplate works fine based on preliminary testing. But my brain still hasn’t “clicked” with promises and async await. I learned a lot from your rewrite of my fetchNdjson routine, I was hoping you’d do the same for this code. I think template “engines” are a little overkill. This might be useful to someone else in itself after some refinement.

Please rewrite this code as you would like it to be so that I might better understand some of the metal gynastics involved here. Note that cache_load prevents building the same template Function unnecessarily (async memoize).

const fs = require('fs');

const tmpl_load = async (tmpl_path) => {

    const text = await new Promise((resolve, reject) => {
        fs.readFile(tmpl_path, 'utf8', function (err, data) {
            if (err) {
                reject(err);
            } else {
                resolve(data);
            }   
        }); 
    }); 

    const toks = text.split(/(\n<%\n|\n%>\n)/g);
    let incode = false;
    let fnbody = "let _ret = [];\n";

    for (const tok of toks) {
        if (!incode) {
            if (tok === "\n<%\n") {
                incode = true;
            } else {
                fnbody += "_ret.push(`" + tok + "`);\n";
            }   
        } else {
            if (tok === "\n%>\n") {
                incode = false;
            } else {
                fnbody += tok + "\n";
            }   
        }   
    }   

    fnbody += "return _ret.join('');\n";

console.log('This should only run once!');

    return new Function("_params", fnbody);
}

let cache = {}; 

function cache_load(tmpl_path) {
    if (!cache[tmpl_path]) {
        cache[tmpl_path] = tmpl_load(tmpl_path);
    }   
    return cache[tmpl_path];
}

function render(tmpl_path, params) {
    return new Promise(resolve => {
        cache_load(tmpl_path).then(fn => {
            resolve(fn(params));
        }); 
    }); 
}

render('./hello.tmpl', {
    msg: 'Hello, World',
}).then(console.log);
render('./hello.tmpl', {
    msg: 'Hello, World (again)',
}).then(console.log);

and here is the template:

<html>
<%
	_ret.push('The msg is: ' + _params.msg);
%>
</html>

and output:

This should only run once!
<html>The msg is: Hello, World</html>

<html>The msg is: Hello, World (again)</html>

Surely this can be reduced / simplified. Show me how please!

1 Like

Hi @ioplex, a template engine would certainly make your life easier – e.g. considering template tags inside comments or as part of string expressions, or nested template tags. Leaving that aside for the purpose of the exercise, I’d suggest the following improvements (mostly unopinionated haha):

  • Use util.promisify() to wrap fs.readFile()
  • Don’t forget to catch promise rejections
  • Prefer const over let unless you really need to reassign a variable, such as your incode flag but not the cache dictionary
  • The promise wrapper in the render() function is not necessary as the cache already returns a promise anyway
  • A bit nitpicky but as for general code style, avoid unnecessary abbreviations like sparing 2 characters for toks instead of simply writing out tokens… see here for the definite guide to ninja code. :-) Also I’d suggest to stick to the universally accepted convention of using camel case rather than train case.
  • Most importantly, avoid creating functions from strings; not only is that slower than regular function declarations / expressions, but it’s an actual security risk – see here for details (using the Function() constructor is effectively the same as using eval()). Considering this, don’t allow JS in templates that will run in the execution context of your application; template engines would usually define their own syntaxes for whatever features they provide.

So here’s my approach; it’s admittedly, but purposely restricted compared to your version as it only allows parameter interpolation. For other things like conditions you’d have to come up with dedicated directives such as <%if ... fi%>, say.

const fs = require('fs')
const util = require('util')
const readFile = util.promisify(fs.readFile)
const cache = {}

const loadTemplate = async (templatePath) => {
  const text = await readFile(templatePath).catch(reason => reason)
  const tokens = `${text}`.split(/(<%|%>)/)

  return params => {
    let incode = false

    return tokens.reduce((result, token) => {
      if (!incode) {
        if (token === '<%') {
          incode = true
        } else {
          result += token
        }
      } else {
        if (token === '%>') {
          incode = false
        } else {
          result += params[token.trim()]
        }
      }

      return result
    }, '')
  }
}

function cacheTemplate (templatePath) {
  if (!cache[templatePath]) {
    cache[templatePath] = loadTemplate(templatePath)
  }

  return cache[templatePath]
}

function render (templatePath, params) {
  return cacheTemplate(templatePath).then(fn => fn(params))
}

render('./hello.tmpl', {
  msg: 'Hello, World'
}).then(console.log)

render('./hello.tmpl', {
  msg: 'Hello, World (again)'
}).then(console.log)

render('./goodbye.tmpl', {
  msg: 'Good bye, World'
}).then(console.log) // Renders "Error: ENOENT: no such file or directory, open './goodbye.tmpl'"

Template:

<html>
<h1><% msg %></h1>
</html>

Unless I’m misunderstanding, your version does not support code?

I guess my example was stupid simple but the templates can contain arbitrary code (including nested template calls) like:

...
render('./beer99.tmpl', {
	numBeers: 3
}).then(console.log).catch(console.log);
render('./beer99.tmpl', {
	numBeers: 1
}).then(console.log).catch(console.log);

More sophisticated template:

<html>
<ul>
<%
	let numBeers = _params.numBeers;

	while (numBeers > 0) {
%>

  <li>
<%
		_ret.push(`${numBeers} bottles of beer on the wall, ${numBeers} bottles of beer\n`);
		_ret.push(`Take one down and pass it around, ${numBeers - 1} bottles of beer on the wall`);
%>
</li>
<%
		numBeers--;
	}
%>

</ul>
</html>

and the output:

This should only run once!
<html>
<ul>
  <li>3 bottles of beer on the wall, 3 bottles of beer
Take one down and pass it around, 2 bottles of beer on the wall</li>
  <li>2 bottles of beer on the wall, 2 bottles of beer
Take one down and pass it around, 1 bottles of beer on the wall</li>
  <li>1 bottles of beer on the wall, 1 bottles of beer
Take one down and pass it around, 0 bottles of beer on the wall</li>
</ul>
</html>

<html>
<ul>
  <li>1 bottles of beer on the wall, 1 bottles of beer
Take one down and pass it around, 0 bottles of beer on the wall</li>
</ul>
</html>

Yes, that was the idea… although thinking about it, such template code is probably as trustworthy as any other module code on the file system. So as long as you don’t attempt to run code from dynamic content, I suppose we can indeed consider the code safe. In fact, the popular EJS template engine (that follows the same approach of mixing vanilla JS with HTML) does it just like this.

That said, I’d still keep the parsed code to a minimum; e.g. rather than exposing the output array directly, we might pass a helper function to push to that array:

index.js

const fs = require('fs')
const util = require('util')
const readFile = util.promisify(fs.readFile)
const cache = {}

function parseTemplate (template) {
  const tokens = `${template}`.split(/(<%|%>)/)
  let incode = false

  /* eslint-disable-next-line no-new-func */
  return new Function(
    'echo',
    'params',
    tokens.reduce((result, token) => {
      if (!incode) {
        if (token === '<%') {
          incode = true
        } else {
          // TODO: properly escape the token
          result += `echo(\`${token}\`);`
        }
      } else {
        if (token === '%>') {
          incode = false
        } else {
          result += `${token};`
        }
      }

      return result
    }, '')
  )
}

async function createRenderFunction (templatePath) {
  const template = await readFile(templatePath)
  const view = parseTemplate(template)

  return params => {
    const output = []

    view((...values) => {
      output.push(...values)
    }, params)

    return output.join('')
  }
}

function getRenderFunction (templatePath) {
  if (!cache[templatePath]) {
    cache[templatePath] = createRenderFunction(templatePath)
  }

  return cache[templatePath]
}

function render (templatePath, params) {
  return getRenderFunction(templatePath).then(render => render(params))
}

render('./hello.tmpl', {
  message: 'Hello World!'
}).then(console.log).catch(console.error)

hello.tmpl

<html>
  <h1><% echo(params.message) %></h1>
</html>

Also I have to say that I’m not a huge fan of this approach as for all intents and purposes, we might just as well use regular template literals and avoid the template overhead… but that’s probably a matter of personal preference. ^^

index.js

const views = require('./views')

async function render (name, params) {
  try {
    const result = await views[name](params)
    return result
  } catch (error) {
    return error.message
  }
}

render('hello', {
  message: 'Hello World!'
}).then(console.log).catch(console.error)

views.js

module.exports.hello = ({ message }) => `
<html>
  <h1>${message}</h1>
</html>
`

// ... other views