diff --git a/README.md b/README.md index 2d3b426..837eb4d 100644 --- a/README.md +++ b/README.md @@ -241,6 +241,42 @@ summary 23.28x faster than ejs.render ``` +## micro-template-serialize + +`micro-template-serialize` is a CLI tool for precompiling multiple template files into a single ESM (ECMAScript Module) JavaScript file. This is especially useful for environments where dynamic function generation (such as with `new Function()`) is not allowed, or for delivering precompiled templates to browsers or serverless environments. + +### Why use it? +- **Security:** Avoids the use of `new Function()` at runtime, which is often restricted in secure or serverless environments. +- **Performance:** Templates are precompiled, so rendering is fast and does not require parsing or compiling templates at runtime. +- **Convenience:** Bundles multiple templates into a single importable module. + +### Usage + +```sh +micro-template-serialize ... --output [--root ] +``` + +- ` ...` : List of template files to serialize. +- `--output ` : Output JavaScript file (required). +- `--root ` : Root directory for template IDs (default: current directory). + +#### Example + +```sh +micro-template-serialize test/data-test1.tmpl test/data-fizzbuzz.tmpl --output templates.js --root test +``` + +This will generate a file named `templates.js` in the current directory. You can then import the generated module in your JavaScript code: + +```js +import { extended as template } from './templates.js'; +const result = template('main', { foo: 'world', baz: 'baz!' }); +console.log('render result:', result); +``` + +- If a template does not contain a `` comment, a warning will be shown. +- The output file is an ESM module (use `import` to load it). + LICENSE ------- diff --git a/bin/micro-template-serialize.js b/bin/micro-template-serialize.js new file mode 100755 index 0000000..1373603 --- /dev/null +++ b/bin/micro-template-serialize.js @@ -0,0 +1,84 @@ +#!/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 ... --output [--root ] + +Purpose: + Reads one or more template files, extracts their content and meta information + (such as keys defined in ), 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: + ... List of template files to serialize. + --output Output JavaScript file (required). + --root 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 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 ... --output templates.js [--root ]'); + 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(//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 in the template file.`); + templates[id].keys = []; + } +} + +const code = serializeTemplates(templates); +await fs.writeFile(outputFile, code); +console.log(`Wrote: ${outputFile}`); diff --git a/index.html b/index.html index a214536..30d642e 100644 --- a/index.html +++ b/index.html @@ -166,6 +166,14 @@

micro-template.js

A minimal, blazing fast JavaScript template engine for hackers.

+
+ npm version + GitHub stars + MIT License +

Features

@@ -177,16 +185,8 @@

Features

-

Usage Example

+

Getting Started

-
-

Basic

-
import { template } from 'micro-template';
-
-const result = template('<div><%= message %></div>', { message: 'Hello, inline!' });
-console.log(result); // <div>Hello, inline!</div>
-
-

In HTML

<script type="application/x-template" id="tmpl1">
@@ -200,6 +200,7 @@ 

In HTML

In Node.js

+
npm install micro-template
import fs from 'node:fs';
 import { template } from 'micro-template';
 
diff --git a/lib/micro-template.js b/lib/micro-template.js
index b92496f..70c6028 100644
--- a/lib/micro-template.js
+++ b/lib/micro-template.js
@@ -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 {` +
@@ -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);
 }
diff --git a/lib/serializer.js b/lib/serializer.js
new file mode 100644
index 0000000..34e0d25
--- /dev/null
+++ b/lib/serializer.js
@@ -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}`);
+		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;
+}
diff --git a/misc/serialized.js b/misc/serialized.js
new file mode 100755
index 0000000..b324985
--- /dev/null
+++ b/misc/serialized.js
@@ -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);
diff --git a/package.json b/package.json
index faba2d6..a59e6e9 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/test/serialize/footer.tmpl b/test/serialize/footer.tmpl
new file mode 100644
index 0000000..18a9674
--- /dev/null
+++ b/test/serialize/footer.tmpl
@@ -0,0 +1,5 @@
+
+
+
+

Footer content <%= year %>

+
diff --git a/test/serialize/main.tmpl b/test/serialize/main.tmpl new file mode 100644 index 0000000..872f478 --- /dev/null +++ b/test/serialize/main.tmpl @@ -0,0 +1,11 @@ + + +HEADER +<% wrapper('wrapper', function () { %> + hello <%= foo %>, + <% [1,2].forEach( () => { %> + and <%= baz %> + <% }) %> +<% }) %> +<% include('footer', { year: 2025 }) %> +FOOTER diff --git a/test/serialize/wrapper.tmpl b/test/serialize/wrapper.tmpl new file mode 100644 index 0000000..2bfb4ba --- /dev/null +++ b/test/serialize/wrapper.tmpl @@ -0,0 +1,5 @@ + + +BEFORE CONTENT +<%=raw content %> +AFTER CONTENT diff --git a/test/serializer.test.js b/test/serializer.test.js new file mode 100644 index 0000000..d462b38 --- /dev/null +++ b/test/serializer.test.js @@ -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: '<%= value %>', + 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' }), 'X'); +}); + +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 ', + 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 ', + 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: '
<%= name %>
', + 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
foobar
AFTER' + ); +}); diff --git a/test/test.js b/test/test.js index 5ab93b2..7c8747a 100755 --- a/test/test.js +++ b/test/test.js @@ -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('<%= foo %><%= bar %>', { foo: 'foo', bar: 'bar' }); @@ -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');