Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions bin/micro-template-serialize.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
#!/usr/bin/env node

import { serializeTemplates } from '../lib/serializer.js';
import { promises as fs } from 'fs';
import path from 'path';

const USAGE = `
Usage: micro-template-serialize <input1.tmpl> ... --output <output.js> [--root <dir>]

Purpose:
Reads one or more template files, extracts their content and meta information
(such as keys defined in <!--meta.keys=[...]-->), and serializes them into a JavaScript file.
The template ID is determined by the relative path from the root directory (without extension).

This tool is especially useful for environments where dynamic function generation
(such as with new Function()) is not allowed, as it outputs pre-serialized ESM modules.

Arguments:
<input1.tmpl> ... List of template files to serialize.
--output <output.js> Output JavaScript file (required).
--root <dir> Root directory for template IDs (default: current directory).
--help, -h Show this help message.

Example:
micro-template-serialize test/data-test1.tmpl test/data-fizzbuzz.tmpl --output templates.js --root test

And this will generate a file named 'templates.js' in the current directory.
You can then import the generated module in your JavaScript code:

import { extended as template } from './templates.js';
const result = template('main', { foo: 'world', baz: 'baz!' });
console.log('render result:', result);

Notes:
- If a template does not contain a <!--meta.keys=[...]--> comment, a warning will be shown.
- The output file will contain the serialized templates using the serializeTemplates function.
- The output file is an ESM module (use 'import' to load it).
`;

// --- 引数パース ---
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
console.log(USAGE);
process.exit(0);
}
let outputFile;
let rootDir = process.cwd();
const inputFiles = [];
for (let i = 0; i < args.length; i++) {
if (args[i] === '--output') {
outputFile = args[++i];
} else if (args[i] === '--root') {
rootDir = args[++i];
} else {
inputFiles.push(args[i]);
}
}
if (!outputFile || inputFiles.length === 0) {
console.error('Usage: micro-template-serialize <input1.tmpl> ... --output templates.js [--root <dir>]');
process.exit(1);
}

// --- テンプレートファイル読み込み ---
const templates = {};
for (const file of inputFiles) {
// id を rootDir からの相対パス(拡張子除く)にする
const relPath = path.relative(rootDir, file);
const id = relPath.replace(path.extname(relPath), '');
const source = await fs.readFile(file, 'utf-8');
templates[id] = { source };
source.replace(/<!--meta\.(\w+)=(.+?)-->/g, (match, key, value) => {
templates[id][key] = JSON.parse(value);
return ''; // Remove the comment
});
if (!templates[id].keys) {
console.warn(`Warning: Template "${id}" does not have keys defined. Please add <!--meta.keys=["key1", "key2"]--> in the template file.`);
templates[id].keys = [];
}
}

const code = serializeTemplates(templates, { exportName: 'extended' });
await fs.writeFile(outputFile, code);
console.log(`Wrote: ${outputFile}`);
19 changes: 10 additions & 9 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,14 @@
<header>
<h1>micro-template.js</h1>
<p class="subtitle">A minimal, blazing fast JavaScript template engine for hackers.</p>
<div style="margin:1em 0 0.5em 0;">
<img src="https://img.shields.io/npm/v/micro-template.svg?style=flat-square" alt="npm version"
style="vertical-align:middle; margin-right:8px;">
<img src="https://img.shields.io/github/stars/cho45/micro-template.js?style=flat-square&label=GitHub+Stars"
alt="GitHub stars" style="vertical-align:middle; margin-right:8px;">
<img src="https://img.shields.io/npm/l/micro-template.svg?style=flat-square" alt="MIT License"
style="vertical-align:middle;">
</div>
</header>
<section class="features">
<h2>Features</h2>
Expand All @@ -177,16 +185,8 @@ <h2>Features</h2>
</ul>
</section>
<section class="usage">
<h2>Usage Example</h2>
<h2>Getting Started</h2>
<div class="usage-blocks">
<div class="usage-block">
<h3>Basic</h3>
<pre><code class="language-js">import { template } from 'micro-template';

const result = template('&lt;div&gt;&lt;%= message %&gt;&lt;/div&gt;', { message: 'Hello, inline!' });
console.log(result); // &lt;div&gt;Hello, inline!&lt;/div&gt;
</code></pre>
</div>
<div class="usage-block">
<h3>In HTML</h3>
<pre><code class="language-html">&lt;script type="application/x-template" id="tmpl1"&gt;
Expand All @@ -200,6 +200,7 @@ <h3>In HTML</h3>
</div>
<div class="usage-block">
<h3>In Node.js</h3>
<pre><code>npm install micro-template</code></pre>
<pre><code class="language-js">import fs from 'node:fs';
import { template } from 'micro-template';

Expand Down
7 changes: 4 additions & 3 deletions lib/micro-template.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ const template = function (id, data) {
if (arguments.length < 2) throw new Error('template() must be called with (template, data)');
const me = template, isArray = Array.isArray(data), keys = isArray ? data : Object.keys(data || {}), key = `data:${id}:${keys.sort().join(':')}`;
if (!me.cache.has(key)) me.cache.set(key, (function () {
let name = id, string = /^[\w\-]+$/.test(id) ? me.get(id): (name = `template-${Math.random().toString(36).slice(2)}`, id); // no warnings
let name = id, string = /^[/\w\-]+$/.test(id) ? me.get(id): (name = `template-${Math.random().toString(36).slice(2)}`, id); // no warnings
let line = 1;
const body = (
`try {` +
Expand All @@ -27,8 +27,9 @@ const template = function (id, data) {
`//# sourceURL=${name}\n` +
`//# sourceMappingURL=data:application/json,${encodeURIComponent(JSON.stringify({version:3, file:name, sources:[`${name}.ejs`], sourcesContent:[string], mappings:";;AAAA;"+Array(line-1).fill('AACA').join(';')}))}`
);
const func = new Function("__this", ...keys, body);
return function (stash) { return func.call(null, me.context = { escapeHTML: me.escapeHTML, line: 1, ret : '', stash: stash }, ...keys.map(key => stash[key])) };
const compiled = new Function("__this", ...keys, body);
const ret = function (stash) { return compiled.call(null, me.context = { escapeHTML: me.escapeHTML, line: 1, ret : '', stash: stash }, ...keys.map(key => stash[key])) };
return (ret.compiled = compiled, ret.keys = keys, ret);
})());
return isArray ? me.cache.get(key) : me.cache.get(key)(data);
}
Expand Down
37 changes: 37 additions & 0 deletions lib/serializer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
#!/usr/bin/env node

import { template, extended } from './micro-template.js';

export function serializeTemplates(target) {
template.get = id => target[id].source;
template.cache.clear();

let serialized = 'const compiled = {};\n';

for (const [id, entry] of Object.entries(target)) {
const keys = entry.keys || [];
console.log(`Compiling template: ${id}`);
Copy link

Copilot AI Jun 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider removing or gating the console.log statement to avoid unwanted logging in production builds.

Suggested change
console.log(`Compiling template: ${id}`);
if (process.env.NODE_ENV !== 'production') {
console.log(`Compiling template: ${id}`);
}

Copilot uses AI. Check for mistakes.
const func = extended(id, keys);
const compiled = func.compiled;
serialized += `compiled['${id}'] = ` + compiled.toString().replace(/\/\/#.*/g, '') + ';\n';
serialized += `compiled['${id}'].keys = ` + JSON.stringify(func.keys) + ';\n';
}

serialized += `const regexp = /[<>"'&]/;\n`;
serialized += `const escapeHTML = ` + template.escapeHTML.toString() + ';\n';

serialized += `const template = ` + (function (id, stash) {
const me = template;
const func = compiled[id];
if (!func) {
throw new Error(`Template "${id}" not found.`);
}
return func.call(null, me.context = { escapeHTML, line: 1, ret: '', stash }, ...func.keys.map(key => stash[key]));
}).toString() + ';\n';

serialized += `const extended = ` + extended.toString() + ';\n';

serialized += `export { template, extended };\n`;

return serialized;
}
5 changes: 5 additions & 0 deletions misc/serialized.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
#!/usr/bin/env node
import { extended as template } from '../_serialized.js';

const result = template('main', { foo: 'world', baz: 'baz!' });
console.log('render result:', result);
8 changes: 8 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@
"bench": "node --expose-gc ./misc/benchmark.js",
"test:types": "npx tsc --lib es2015 --noEmit test/types.test.ts"
},
"bin": {
"micro-template-serialize": "./bin/micro-template-serialize.js"
},
"exports": {
".": "./lib/micro-template.js",
"./micro-template": "./lib/micro-template.js",
"./serializer": "./lib/serializer.js"
},
"keywords": [
"template",
"engine",
Expand Down
5 changes: 5 additions & 0 deletions test/serialize/footer.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<!--meta.keys=["year"] -->

<footer>
<p>Footer content <%= year %></p>
</footer>
11 changes: 11 additions & 0 deletions test/serialize/main.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<!--meta.keys=["foo", "baz"] -->

HEADER
<% wrapper('wrapper', function () { %>
hello <%= foo %>,
<% [1,2].forEach( () => { %>
and <%= baz %>
<% }) %>
<% }) %>
<% include('footer', { year: 2025 }) %>
FOOTER
5 changes: 5 additions & 0 deletions test/serialize/wrapper.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<!--meta.keys=["content"] -->

BEFORE CONTENT
<%=raw content %>
AFTER CONTENT
143 changes: 143 additions & 0 deletions test/serializer.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import assert from 'assert';
import { test } from 'node:test';
import { serializeTemplates } from '../lib/serializer.js';
import { writeFile } from 'fs/promises';
import { tmpdir } from 'os';
import { join } from 'path';

const templates = {
'main': {
source: 'Hello <%= name %>!',
keys: ['name']
},
'sub/partial': {
source: '<b><%= value %></b>',
keys: ['value']
}
};

test('serializeTemplates: ESM code can be imported and used', async (t) => {
const code = serializeTemplates(templates, { exportName: 'extended' });
const outFile = join(tmpdir(), 'generated-templates-' + Date.now() + '.mjs');
await writeFile(outFile, code);
const { extended: importedExtended } = await import('file://' + outFile + '?t=' + Date.now());
assert.strictEqual(importedExtended('main', { name: 'world' }), 'Hello world!');
assert.strictEqual(importedExtended('sub/partial', { value: 'X' }), '<b>X</b>');
});

test('serializeTemplates: handles empty keys and missing keys meta', async (t) => {
const templates = {
'noKeys': { source: 'foo' },
'emptyKeys': { source: 'bar', keys: [] },
};
const code = serializeTemplates(templates, { exportName: 'extended' });
const outFile = join(tmpdir(), 'generated-templates-nokeys-' + Date.now() + '.mjs');
await writeFile(outFile, code);
const { extended } = await import('file://' + outFile + '?t=' + Date.now());
assert.strictEqual(extended('noKeys', {}), 'foo');
assert.strictEqual(extended('emptyKeys', {}), 'bar');
});

test('serializeTemplates: template id is path without extension', async (t) => {
const templates = {
'foo/bar/baz': { source: 'baz', keys: [] }
};
const code = serializeTemplates(templates, { exportName: 'extended' });
assert.match(code, /'foo\/bar\/baz'/);
});

test('serializeTemplates: handles multiple meta comments', async (t) => {
const templates = {
'multi': {
source: 'foo <!--meta.keys=[\"x\"]--><!--meta.description=\"desc\"-->',
keys: ['x'],
description: 'desc'
}
};
const code = serializeTemplates(templates, { exportName: 'extended' });
assert.match(code, /description/);
assert.match(code, /keys/);
});

test('serializeTemplates: handles invalid meta JSON gracefully', async (t) => {
const templates = {
'invalid': {
source: 'foo <!--meta.keys=notjson-->',
keys: []
}
};
const code = serializeTemplates(templates, { exportName: 'extended' });
assert.match(code, /invalid/);
});

test('serializeTemplates: works with nested template ids', async (t) => {
const templates = {
'a/b/c': { source: 'nested', keys: [] }
};
const code = serializeTemplates(templates, { exportName: 'extended' });
const outFile = join(tmpdir(), 'generated-templates-nested-' + Date.now() + '.mjs');
await writeFile(outFile, code);
const { extended } = await import('file://' + outFile + '?t=' + Date.now());
assert.strictEqual(extended('a/b/c', {}), 'nested');
});

test('serializeTemplates: missing variable is undefined string', async (t) => {
const templates = {
'main': { source: 'Hello <%= name %>!', keys: ['name'] }
};
const code = serializeTemplates(templates, { exportName: 'extended' });
const outFile = join(tmpdir(), 'generated-templates-missingvar-' + Date.now() + '.mjs');
await writeFile(outFile, code);
const { extended } = await import('file://' + outFile + '?t=' + Date.now());
assert.strictEqual(extended('main', {}), 'Hello undefined!');
});

test('serializeTemplates: extra variables are ignored', async (t) => {
const templates = {
'main': { source: 'Hello <%= name %>!', keys: ['name'] }
};
const code = serializeTemplates(templates, { exportName: 'extended' });
const outFile = join(tmpdir(), 'generated-templates-extravars-' + Date.now() + '.mjs');
await writeFile(outFile, code);
const { extended } = await import('file://' + outFile + '?t=' + Date.now());
assert.strictEqual(extended('main', { name: 'world', foo: 'bar' }), 'Hello world!');
});

test('serializeTemplates: wrapper template renders content', async (t) => {
const templates = {
'wrapper': {
source: 'BEFORE CONTENT <%= content %> AFTER CONTENT',
keys: ['content']
},
'main': {
source: 'BEFORE WRAPPER <% wrapper("wrapper", function () { %> Hello, <%= name %>! <% }) %> AFTER WRAPPER',
keys: ['name']
}
};
const code = serializeTemplates(templates, { exportName: 'extended' });
const outFile = join(tmpdir(), 'generated-templates-wrapper-' + Date.now() + '.mjs');
await writeFile(outFile, code);
const { extended } = await import('file://' + outFile + '?t=' + Date.now());
assert.strictEqual(extended('main', { name: 'foobar' }), 'BEFORE WRAPPER BEFORE CONTENT Hello, foobar! AFTER CONTENT AFTER WRAPPER');
});

test('serializeTemplates: include renders template', async (t) => {
const templates = {
'other': {
source: '<div><%= name %></div>',
keys: ['name']
},
'main': {
source: 'BEFORE <% include("other", { name: "foobar" }) %> AFTER',
keys: []
}
};
const code = serializeTemplates(templates, { exportName: 'extended' });
const outFile = join(tmpdir(), 'generated-templates-include-' + Date.now() + '.mjs');
await writeFile(outFile, code);
const { extended } = await import('file://' + outFile + '?t=' + Date.now());
assert.strictEqual(
extended('main', {}),
'BEFORE <div>foobar</div> AFTER'
);
});
12 changes: 2 additions & 10 deletions test/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,6 @@ import fs from 'fs';
// template.get の上書き
template.get = function (id) { return fs.readFileSync('test/data-' + id + '.tmpl', 'utf-8') };

function templateWithCompiledFunction(stringTemplate, data) {
data = Object.assign({}, data);
data.__context = {};
stringTemplate += `\n<% __context.compiled = arguments.callee; %>`;
const result = template(stringTemplate, data);
return [result, data.__context.compiled];
}

// --- template 基本テスト ---
test('template renders with data', (t) => {
const result = template('<b><%= foo %></b><i><%= bar %></i>', { foo: 'foo', bar: 'bar' });
Expand Down Expand Up @@ -326,8 +318,8 @@ test('template with properties script tags', (t) => {
});

test('template output includes sourceMappingURL comment', (t) => {
const [_, compiledFunction] = templateWithCompiledFunction('foo bar', {});
const compiledFunctionString = compiledFunction.toString();
const func = template('foo bar', []);
const compiledFunctionString = func.compiled.toString();
console.log(compiledFunctionString);
const match = compiledFunctionString.match(/\n\/\/\# sourceMappingURL=data:application\/json,(.+)\n/);
assert(match, 'sourceMappingURL comment is present and correctly formatted');
Expand Down