miki-template 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/.github/workflows/ci.yml +54 -0
  2. package/AGENT.md +71 -0
  3. package/API_REFERENCE.md +314 -0
  4. package/CHANGELOG.md +97 -0
  5. package/CODE_OF_CONDUCT.md +14 -0
  6. package/CONTRIBUTING.md +27 -0
  7. package/README.md +304 -0
  8. package/ROADMAP.md +40 -0
  9. package/benchmarks/report.json +17 -0
  10. package/benchmarks/run.js +49 -0
  11. package/benchmarks/templates/large.dtpl +7 -0
  12. package/benchmarks/templates/medium.dtpl +3 -0
  13. package/benchmarks/templates/small.dtpl +7 -0
  14. package/context/component.md +109 -0
  15. package/context/prd.md +131 -0
  16. package/context/project-structure.md +33 -0
  17. package/docs/README.md +18 -0
  18. package/docs/advanced_usage.md +71 -0
  19. package/docs/api.md +102 -0
  20. package/docs/filters.md +540 -0
  21. package/docs/installation.md +106 -0
  22. package/docs/overview.md +57 -0
  23. package/docs/partialdef.md +41 -0
  24. package/docs/security.md +27 -0
  25. package/docs/tags.md +610 -0
  26. package/docs/usage.md +599 -0
  27. package/eslint.config.mjs +34 -0
  28. package/miki-template-1.2.0.vsix +0 -0
  29. package/miki-template-extension/LICENSE +21 -0
  30. package/miki-template-extension/README.md +82 -0
  31. package/miki-template-extension/icon.png +0 -0
  32. package/miki-template-extension/icon.svg +10 -0
  33. package/miki-template-extension/package.json +46 -0
  34. package/miki-template-extension/snippets/miki-template.json +177 -0
  35. package/miki-template-extension/syntaxes/language-configuration.json +26 -0
  36. package/miki-template-extension/syntaxes/miki-template.tmLanguage.json +146 -0
  37. package/package.json +31 -0
  38. package/snippets/miki-template.json +177 -0
  39. package/src/asyncRender.js +21 -0
  40. package/src/cache.js +41 -0
  41. package/src/context.js +122 -0
  42. package/src/context_processors.js +41 -0
  43. package/src/esm.mjs +72 -0
  44. package/src/filters.js +527 -0
  45. package/src/i18n.js +171 -0
  46. package/src/index.js +454 -0
  47. package/src/lexer.js +92 -0
  48. package/src/libraries.js +240 -0
  49. package/src/parser.js +250 -0
  50. package/src/security.js +51 -0
  51. package/src/tags/control.js +591 -0
  52. package/src/tags/helpers.js +27 -0
  53. package/src/tags/i18n.js +230 -0
  54. package/src/tags/inheritance.js +216 -0
  55. package/src/tags/registry.js +18 -0
  56. package/src/tags/util.js +322 -0
  57. package/src/types.d.ts +107 -0
  58. package/syntaxes/language-configuration.json +26 -0
  59. package/syntaxes/miki-template.tmLanguage.json +146 -0
  60. package/tests/asyncRender.test.js +17 -0
  61. package/tests/base.html +6 -0
  62. package/tests/child.html +3 -0
  63. package/tests/context_processors.test.js +13 -0
  64. package/tests/esm.test.mjs +26 -0
  65. package/tests/filters.test.js +99 -0
  66. package/tests/include_security.test.js +9 -0
  67. package/tests/lexer.test.js +45 -0
  68. package/tests/parser.test.js +55 -0
  69. package/tests/partial.html +1 -0
  70. package/tests/partialdef.test.js +40 -0
  71. package/tests/production_checks.js +57 -0
  72. package/tests/security.test.js +28 -0
  73. package/tests/tags.test.js +203 -0
package/src/index.js ADDED
@@ -0,0 +1,454 @@
1
+ /**
2
+ * Django-Style Template Engine for Node.js/Express
3
+ * Main entrypoint.
4
+ */
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const { tokenize } = require('./lexer');
8
+ const { Parser } = require('./parser');
9
+ const { Context } = require('./context');
10
+
11
+ const { registerContextProcessor, applyContextProcessors } = require('./context_processors');
12
+ const { registerFilter, getFilter } = require('./filters');
13
+ const { SafeString, markSafe, isSafe, escapeHtml } = require('./security');
14
+ const { getCompiled, clearCache } = require('./cache');
15
+ const { registerHelper } = require('./tags/helpers');
16
+ const { registerTag, getTagRegistry } = require('./tags/registry');
17
+
18
+ // Load control tags
19
+ const controlTags = require('./tags/control');
20
+ for (const [name, parserFn] of Object.entries(controlTags.parsers)) {
21
+ registerTag(name, parserFn);
22
+ }
23
+
24
+ // Load inheritance tags
25
+ const inheritanceTags = require('./tags/inheritance');
26
+ for (const [name, parserFn] of Object.entries(inheritanceTags.parsers)) {
27
+ registerTag(name, parserFn);
28
+ }
29
+
30
+ // Load utility tags
31
+ const utilTags = require('./tags/util');
32
+ for (const [name, parserFn] of Object.entries(utilTags.parsers)) {
33
+ registerTag(name, parserFn);
34
+ }
35
+
36
+ // Load i18n tags
37
+ const i18nTags = require('./tags/i18n');
38
+ for (const [name, parserFn] of Object.entries(i18nTags.parsers)) {
39
+ registerTag(name, parserFn);
40
+ }
41
+
42
+ // i18n module
43
+ const i18n = require('./i18n');
44
+
45
+ // Plugin/filter library system
46
+ const libraries = require('./libraries');
47
+
48
+ // Re-export the library module APIs
49
+ module.exports = {
50
+ compile,
51
+ render,
52
+ asyncRender,
53
+ __express,
54
+ __expressAsync,
55
+ stripExpressContext,
56
+ clearCache,
57
+ registerTag,
58
+ registerFilter,
59
+ getFilter,
60
+ registerHelper,
61
+ registerContextProcessor,
62
+ SafeString,
63
+ markSafe,
64
+ isSafe,
65
+ escapeHtml,
66
+ // i18n
67
+ registerTranslation: i18n.registerTranslation,
68
+ unregisterTranslation: i18n.unregisterTranslation,
69
+ setLanguage: i18n.setLanguage,
70
+ getLanguage: i18n.getLanguage,
71
+ setFallbackLanguage: i18n.setFallbackLanguage,
72
+ getFallbackLanguage: i18n.getFallbackLanguage,
73
+ getAvailableLanguages: i18n.getAvailableLanguages,
74
+ // Plugin/filter libraries
75
+ registerLibrary: libraries.registerLibrary,
76
+ unregisterLibrary: libraries.unregisterLibrary,
77
+ getLibrary: libraries.getLibrary,
78
+ getLibraryNames: libraries.getLibraryNames,
79
+ hasLibrary: libraries.hasLibrary,
80
+ registerLibraryFromPath: libraries.registerLibraryFromPath,
81
+ activateLibrary: libraries.activateLibrary
82
+ };
83
+
84
+ /**
85
+ * Render an AST recursively to resolve inheritance chain.
86
+ * Async-aware: awaits Promises from any node.
87
+ */
88
+ async function renderASTAsync(nodes, context) {
89
+ context.parentTemplate = null;
90
+ const parts = [];
91
+ for (const node of nodes) {
92
+ const result = node.render(context);
93
+ parts.push(result instanceof Promise ? await result : result);
94
+ }
95
+ let output = parts.join('');
96
+
97
+ if (context.parentTemplate) {
98
+ const parentName = context.parentTemplate;
99
+ context.parentTemplate = null;
100
+
101
+ let viewsDirs = ['.'];
102
+ if (context.options && context.options.settings && context.options.settings.views) {
103
+ const views = context.options.settings.views;
104
+ viewsDirs = Array.isArray(views) ? views : [views];
105
+ } else if (context.options && context.options.views) {
106
+ const views = context.options.views;
107
+ viewsDirs = Array.isArray(views) ? views : [views];
108
+ }
109
+
110
+ let fileContent = '';
111
+ let loaded = false;
112
+ for (const dir of viewsDirs) {
113
+ try {
114
+ const fullPath = path.resolve(dir, parentName);
115
+ const relative = path.relative(path.resolve(dir), fullPath);
116
+ if (relative.startsWith('..') || path.isAbsolute(relative)) {
117
+ throw new Error(`Extends tag attempted path traversal outside allowed views: '${parentName}'`);
118
+ }
119
+ fileContent = fs.readFileSync(fullPath, 'utf8');
120
+ loaded = true;
121
+ break;
122
+ } catch (e) {
123
+ if (e.message && e.message.startsWith('Extends tag attempted path traversal')) {
124
+ throw e;
125
+ }
126
+ }
127
+ }
128
+
129
+ if (!loaded) {
130
+ throw new Error(`Template not found: '${parentName}' in directories ${JSON.stringify(viewsDirs)}`);
131
+ }
132
+
133
+ const parentTokens = tokenize(fileContent);
134
+ const parentParser = new Parser(parentTokens, getTagRegistry());
135
+ const parentNodes = parentParser.parse();
136
+
137
+ if (parentParser.blocks) {
138
+ for (const [name, blockList] of Object.entries(parentParser.blocks)) {
139
+ if (!context.blocks[name]) {
140
+ context.blocks[name] = [];
141
+ }
142
+ for (const blockNode of blockList) {
143
+ if (!context.blocks[name].includes(blockNode)) {
144
+ context.blocks[name].push(blockNode);
145
+ }
146
+ }
147
+ }
148
+ }
149
+
150
+ return await renderASTAsync(parentNodes, context);
151
+ }
152
+
153
+ return output;
154
+ }
155
+
156
+ /**
157
+ * Synchronous renderAST - throws if any node returns a Promise.
158
+ */
159
+ function renderAST(nodes, context) {
160
+ context.parentTemplate = null;
161
+ const output = nodes.map(node => {
162
+ const result = node.render(context);
163
+ if (result instanceof Promise) {
164
+ throw new Error('Async node encountered during sync render. Use asyncRender() instead.');
165
+ }
166
+ return result;
167
+ }).join('');
168
+
169
+ if (context.parentTemplate) {
170
+ const parentName = context.parentTemplate;
171
+ context.parentTemplate = null;
172
+
173
+ let viewsDirs = ['.'];
174
+ if (context.options && context.options.settings && context.options.settings.views) {
175
+ const views = context.options.settings.views;
176
+ viewsDirs = Array.isArray(views) ? views : [views];
177
+ } else if (context.options && context.options.views) {
178
+ const views = context.options.views;
179
+ viewsDirs = Array.isArray(views) ? views : [views];
180
+ }
181
+
182
+ let fileContent = '';
183
+ let loaded = false;
184
+ for (const dir of viewsDirs) {
185
+ try {
186
+ const fullPath = path.resolve(dir, parentName);
187
+ const relative = path.relative(path.resolve(dir), fullPath);
188
+ if (relative.startsWith('..') || path.isAbsolute(relative)) {
189
+ throw new Error(`Extends tag attempted path traversal outside allowed views: '${parentName}'`);
190
+ }
191
+ fileContent = fs.readFileSync(fullPath, 'utf8');
192
+ loaded = true;
193
+ break;
194
+ } catch (e) {
195
+ if (e.message && e.message.startsWith('Extends tag attempted path traversal')) {
196
+ throw e;
197
+ }
198
+ }
199
+ }
200
+
201
+ if (!loaded) {
202
+ throw new Error(`Template not found: '${parentName}' in directories ${JSON.stringify(viewsDirs)}`);
203
+ }
204
+
205
+ const parentTokens = tokenize(fileContent);
206
+ const parentParser = new Parser(parentTokens, getTagRegistry());
207
+ const parentNodes = parentParser.parse();
208
+
209
+ if (parentParser.blocks) {
210
+ for (const [name, blockList] of Object.entries(parentParser.blocks)) {
211
+ if (!context.blocks[name]) {
212
+ context.blocks[name] = [];
213
+ }
214
+ for (const blockNode of blockList) {
215
+ if (!context.blocks[name].includes(blockNode)) {
216
+ context.blocks[name].push(blockNode);
217
+ }
218
+ }
219
+ }
220
+ }
221
+
222
+ return renderAST(parentNodes, context);
223
+ }
224
+
225
+ return output;
226
+ }
227
+
228
+ /**
229
+ * Compiles a template string into a renderable object.
230
+ */
231
+ function compile(templateStr, options = {}) {
232
+ return getCompiled(templateStr, options, (tmpl, opts) => {
233
+ const tokens = tokenize(tmpl);
234
+ const parser = new Parser(tokens, getTagRegistry());
235
+ const nodes = parser.parse();
236
+
237
+ // Collect partial definitions at compile time
238
+ const partialDefs = {};
239
+ function collectPartials(nodeList) {
240
+ for (const node of nodeList) {
241
+ if (node.constructor.name === 'PartialDefNode') {
242
+ partialDefs[node.name] = node;
243
+ }
244
+ if (node.body) {
245
+ collectPartials(node.body);
246
+ }
247
+ if (node.elifBranches) {
248
+ for (const branch of node.elifBranches) {
249
+ collectPartials(branch.body);
250
+ }
251
+ }
252
+ if (node.elseBody) {
253
+ collectPartials(node.elseBody);
254
+ }
255
+ }
256
+ }
257
+ collectPartials(nodes);
258
+
259
+ return {
260
+ render: (contextObj = {}) => {
261
+ const processedContextObj = applyContextProcessors({ ...contextObj });
262
+ const context = new Context(processedContextObj, opts);
263
+ context.reset();
264
+ if (parser.blocks) {
265
+ for (const [name, blockList] of Object.entries(parser.blocks)) {
266
+ context.blocks[name] = [...blockList];
267
+ }
268
+ }
269
+ // Register partial definitions from compile-time
270
+ for (const [name, partial] of Object.entries(partialDefs)) {
271
+ context.registerPartial(name, partial);
272
+ }
273
+ return renderAST(nodes, context);
274
+ },
275
+ asyncRender: async (contextObj = {}) => {
276
+ const processedContextObj = applyContextProcessors({ ...contextObj });
277
+ const context = new Context(processedContextObj, opts);
278
+ context.blocks = {};
279
+ if (parser.blocks) {
280
+ for (const [name, blockList] of Object.entries(parser.blocks)) {
281
+ context.blocks[name] = [...blockList];
282
+ }
283
+ }
284
+ for (const [name, partial] of Object.entries(partialDefs)) {
285
+ context.registerPartial(name, partial);
286
+ }
287
+ return await renderASTAsync(nodes, context);
288
+ },
289
+ renderBlock: (blockName, contextObj = {}) => {
290
+ const processedContextObj = applyContextProcessors({ ...contextObj });
291
+ const context = new Context(processedContextObj, opts);
292
+ context.blocks = {};
293
+ if (parser.blocks) {
294
+ for (const [name, blockList] of Object.entries(parser.blocks)) {
295
+ context.blocks[name] = [...blockList];
296
+ }
297
+ }
298
+ for (const [name, partial] of Object.entries(partialDefs)) {
299
+ context.registerPartial(name, partial);
300
+ }
301
+ renderAST(nodes, context);
302
+ const blockStack = context.blocks[blockName];
303
+ if (!blockStack || blockStack.length === 0) {
304
+ throw new Error(`Block '${blockName}' not found in template`);
305
+ }
306
+ if (!context.blockRenderIndices) {
307
+ context.blockRenderIndices = {};
308
+ }
309
+ context.blockRenderIndices[blockName] = 0;
310
+ let superVal = '';
311
+ if (blockStack.length > 1) {
312
+ context.blockRenderIndices[blockName] = 1;
313
+ superVal = blockStack[1].render(context);
314
+ }
315
+ context.push({ block: { super: superVal } });
316
+ context.blockRenderIndices[blockName] = 0;
317
+ const result = blockStack[0].body.map(n => n.render(context)).join('');
318
+ context.pop();
319
+ context.blockRenderIndices[blockName] = -1;
320
+ return result;
321
+ },
322
+ renderPartial: (partialName, contextObj = {}) => {
323
+ const processedContextObj = applyContextProcessors({ ...contextObj });
324
+ const context = new Context(processedContextObj, opts);
325
+ for (const [name, partial] of Object.entries(partialDefs)) {
326
+ context.registerPartial(name, partial);
327
+ }
328
+ const partial = context.getPartial(partialName);
329
+ if (!partial) {
330
+ throw new Error(`Partial '${partialName}' not found`);
331
+ }
332
+ return partial.body.map(n => n.render(context)).join('');
333
+ }
334
+ };
335
+ });
336
+ }
337
+
338
+ /**
339
+ * Convenience rendering function.
340
+ */
341
+ function render(templateStr, contextObj = {}, options = {}) {
342
+ return compile(templateStr, options).render(contextObj);
343
+ }
344
+
345
+ /**
346
+ * Async rendering function – returns a Promise.
347
+ */
348
+ function asyncRender(templateStr, contextObj = {}, options = {}) {
349
+ return compile(templateStr, options).asyncRender(contextObj);
350
+ }
351
+
352
+ /**
353
+ * Express adapter engine (synchronous callback form).
354
+ * Strips Express framework keys from the context so they don't leak
355
+ * into the template scope.
356
+ */
357
+ function __express(filePath, options, callback) {
358
+ // Detect Express 5+ async view engine signature:
359
+ // Express 5 calls engine(path, options) and awaits the return value when
360
+ // the engine returns a Promise. We support BOTH signatures.
361
+ if (typeof callback !== 'function') {
362
+ // Express 5 async signature: return a Promise
363
+ return __expressAsync(filePath, options);
364
+ }
365
+
366
+ try {
367
+ const fileContent = fs.readFileSync(filePath, 'utf8');
368
+ const renderOptions = {
369
+ views: options && options.settings ? options.settings.views : path.dirname(filePath),
370
+ ...(options || {})
371
+ };
372
+ // Strip Express framework keys from the context
373
+ const ctx = stripExpressContext(options);
374
+ const result = render(fileContent, ctx, renderOptions);
375
+ return callback(null, result);
376
+ } catch (err) {
377
+ return callback(err);
378
+ }
379
+ }
380
+
381
+ /**
382
+ * Strip Express-specific framework keys from a context object.
383
+ * Internal keys (those starting with `_`), `settings`, and `cache` are removed.
384
+ */
385
+ function stripExpressContext(options) {
386
+ if (!options) return {};
387
+ const ctx = {};
388
+ for (const [k, v] of Object.entries(options)) {
389
+ if (!k.startsWith('_') && k !== 'settings' && k !== 'cache') {
390
+ ctx[k] = v;
391
+ }
392
+ }
393
+ return ctx;
394
+ }
395
+
396
+ /**
397
+ * Async view engine for Express 5+. Returns a Promise that resolves
398
+ * to the rendered HTML. Use this when your templates have async helpers.
399
+ *
400
+ * app.engine('html', miki.__expressAsync);
401
+ */
402
+ function __expressAsync(filePath, options) {
403
+ return new Promise((resolve, reject) => {
404
+ try {
405
+ const fileContent = fs.readFileSync(filePath, 'utf8');
406
+ const renderOptions = {
407
+ views: options && options.settings ? options.settings.views : path.dirname(filePath),
408
+ ...(options || {})
409
+ };
410
+ const ctx = stripExpressContext(options);
411
+ // Use asyncRender so async helpers are awaited
412
+ asyncRender(fileContent, ctx, renderOptions)
413
+ .then(resolve)
414
+ .catch(reject);
415
+ } catch (err) {
416
+ reject(err);
417
+ }
418
+ });
419
+ }
420
+
421
+ module.exports = {
422
+ compile,
423
+ render,
424
+ asyncRender,
425
+ __express,
426
+ __expressAsync,
427
+ stripExpressContext,
428
+ clearCache,
429
+ registerTag,
430
+ registerFilter,
431
+ getFilter,
432
+ registerHelper,
433
+ registerContextProcessor,
434
+ SafeString,
435
+ markSafe,
436
+ isSafe,
437
+ escapeHtml,
438
+ // i18n
439
+ registerTranslation: i18n.registerTranslation,
440
+ unregisterTranslation: i18n.unregisterTranslation,
441
+ setLanguage: i18n.setLanguage,
442
+ getLanguage: i18n.getLanguage,
443
+ setFallbackLanguage: i18n.setFallbackLanguage,
444
+ getFallbackLanguage: i18n.getFallbackLanguage,
445
+ getAvailableLanguages: i18n.getAvailableLanguages,
446
+ // Plugin/filter libraries
447
+ registerLibrary: libraries.registerLibrary,
448
+ unregisterLibrary: libraries.unregisterLibrary,
449
+ getLibrary: libraries.getLibrary,
450
+ getLibraryNames: libraries.getLibraryNames,
451
+ hasLibrary: libraries.hasLibrary,
452
+ registerLibraryFromPath: libraries.registerLibraryFromPath,
453
+ activateLibrary: libraries.activateLibrary
454
+ };
package/src/lexer.js ADDED
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Lexer for tokenizing Django-style templates.
3
+ */
4
+
5
+ /**
6
+ * Token types:
7
+ * - 'text': Raw template content.
8
+ * - 'var': Variable interpolation, e.g. {{ user.name }}.
9
+ * - 'block': Structural tag block, e.g. {% if user.is_active %}.
10
+ */
11
+
12
+ function tokenize(template) {
13
+ if (typeof template !== 'string') {
14
+ throw new TypeError('Template must be a string');
15
+ }
16
+
17
+ // Regex to split by delimiters: {{ ... }}, {% ... %}, and {# ... #}
18
+ // Using 's' flag to match dot as newline (dotAll)
19
+ const tagRegexp = /(\{\{.*?\}\}|\{\%.*?\%\}|\{\#.*?\#\})/gs;
20
+ const parts = template.split(tagRegexp);
21
+ const tokens = [];
22
+
23
+ let inVerbatim = false;
24
+ let verbatimBuffer = [];
25
+
26
+ for (let i = 0; i < parts.length; i++) {
27
+ const part = parts[i];
28
+ if (part === undefined || part === null) continue;
29
+
30
+ // Handle verbatim mode
31
+ if (inVerbatim) {
32
+ if (part.startsWith('{%') && part.endsWith('%}') && part.slice(2, -2).trim() === 'endverbatim') {
33
+ inVerbatim = false;
34
+ if (verbatimBuffer.length > 0) {
35
+ tokens.push({
36
+ type: 'text',
37
+ content: verbatimBuffer.join('')
38
+ });
39
+ verbatimBuffer = [];
40
+ }
41
+ } else {
42
+ verbatimBuffer.push(part);
43
+ }
44
+ continue;
45
+ }
46
+
47
+ if (part.startsWith('{#') && part.endsWith('#}')) {
48
+ // Comments are ignored in the token output
49
+ continue;
50
+ } else if (part.startsWith('{{') && part.endsWith('}}')) {
51
+ const content = part.slice(2, -2).trim();
52
+ tokens.push({
53
+ type: 'var',
54
+ content,
55
+ raw: part
56
+ });
57
+ } else if (part.startsWith('{%') && part.endsWith('%}')) {
58
+ const content = part.slice(2, -2).trim();
59
+ if (content === 'verbatim') {
60
+ inVerbatim = true;
61
+ } else {
62
+ tokens.push({
63
+ type: 'block',
64
+ content,
65
+ raw: part
66
+ });
67
+ }
68
+ } else {
69
+ // Do not push empty text tokens to keep AST clean
70
+ if (part !== '') {
71
+ tokens.push({
72
+ type: 'text',
73
+ content: part
74
+ });
75
+ }
76
+ }
77
+ }
78
+
79
+ // If verbatim wasn't closed, treat the remaining buffer as text
80
+ if (inVerbatim && verbatimBuffer.length > 0) {
81
+ tokens.push({
82
+ type: 'text',
83
+ content: verbatimBuffer.join('')
84
+ });
85
+ }
86
+
87
+ return tokens;
88
+ }
89
+
90
+ module.exports = {
91
+ tokenize
92
+ };