Skip to content

Instantly share code, notes, and snippets.

@mgrahamjo
Created March 13, 2025 21:22
Show Gist options
  • Save mgrahamjo/2811ffd7f9831357762741ed270625f9 to your computer and use it in GitHub Desktop.
Save mgrahamjo/2811ffd7f9831357762741ed270625f9 to your computer and use it in GitHub Desktop.
World's smallest template engine for node.js
const fs = require('fs').promises,
path = require('path'),
escapeMap = {
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
'\'': '&apos;'
};
const htmlEscape = val => typeof val === 'string'
? val.replace(/[<>'"]/g, c => escapeMap[c])
: val;
function parse(template) {
const render = new Function('ctx', `
var p=[];
with (ctx || {}) {
p.push(\`${template
// escape backslashes and backticks
.replace(/(\\|`)/g, '\\$1')
// capture expressions that should not be HTML escaped
.replace(/\s*<!--=(?!\s*}.*?-->)(?!.*{\s*-->)(.*?)-->\s*/g, '`);try{p.push($1)}catch(e){}p.push(`')
// capture expressions that should be HTML escaped
.replace(/\s*<!--(?!\s*}.*?-->)(?!.*{\s*-->)(.*?)-->\s*/g, '`);try{p.push(this._e($1))}catch(e){}p.push(`')
// capture logical (control flow) tags
.replace(/\s*<!---?(.*?)-->\s*/g, '`);$1\np.push(`')
}\`);
}
return p.join("");
`);
return render.bind({ _e: htmlEscape });
};
async function parseIncludes(template, opts) {
const match = /<!--\s*?include\s(\S*?)\s*?-->/i.exec(template);
if (match !== null) {
const raw = match[0];
let include = opts.partials + match[1];
if (!include.endsWith(opts.extension)) {
include += opts.extension;
}
const html = await fs.readFile(include, { encoding: 'utf8' });
return parseIncludes(template.replace(raw, html), opts);
}
return template;
}
async function getTemplate(filepath, opts) {
// Support paths relative to views directory
if (!filepath.startsWith(opts.root)) {
filepath = path.join(opts.views, filepath);
}
if (!filepath.endsWith(opts.extension)) {
filepath += opts.extension;
}
const template = await fs.readFile(filepath, { encoding: 'utf8' });
const fullTemplate = await parseIncludes(template, opts);
return fullTemplate;
}
module.exports = function(opts = {}) {
opts.root = opts.root || path.dirname(require.main.filename);
opts.partials = path.join(opts.root, opts.partials || 'blocks', '/');
opts.views = path.join(opts.root, opts.views || 'pages', '/');
opts.extension = ('.' + (opts.extension || 'html')).replace('..', '.');
const compiled = {};
return async function(filepath, context) {
if (compiled[filepath]) {
return compiled[filepath](context);
}
const template = await getTemplate(filepath, opts);
compiled[filepath] = parse(template);
return compiled[filepath](context);
}
}
@mgrahamjo
Copy link
Author

mgrahamjo commented Mar 13, 2025

This script provides a template engine similar to the likes of Liquid, Jinja, and Mustache templates. It uses HTML comment syntax for the tags, which is kind of nice for syntax highlighting. The template "language" is javascript, so you can use any logic and reference any data and functions that are passed to the template as context.

By default tag output is HTML-escaped. To prevent HTML escaping, add an equals sign to the opening tag <!--= like this -->.

Example usage:
./index.js:

const http = require('http'),
    manila = require('manila')();
http.createServer((req, res) => {
    const context = { messages: ['Hello, world!'] };
    const html = manila('index.html', context);
    res.writeHead(200, 'text/html; charset=UTF-8');
    res.end(html);
}).listen(3000);

./pages/index.html:

<!--include head-->
<!--messages.forEach(message => {-->
    <h1><!--message--></h1>
<!--})-->
<!--include foot-->

./blocks/head.html:

<!doctype html>
<html>
<head>
    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
</head>
<body>

./blocks/foot.html:

</body>
</html>

Run node index.js, open http://localhost:3000, and you'll be served the following HTML:

<!doctype html>
<html>
<head>
    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
</head>
<body>
    <h1>Hello, world!</h1>
</body>
</html>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment