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
@@ -0,0 +1,322 @@
1
+ /**
2
+ * Utility template tags: static, url, regroup, spaceless.
3
+ */
4
+
5
+ // Helper to resolve expression values
6
+ function resolveValue(token, context) {
7
+ if (!token) return '';
8
+ if ((token.startsWith('"') && token.endsWith('"')) || (token.startsWith('\'') && token.endsWith('\''))) {
9
+ return token.slice(1, -1);
10
+ }
11
+ return context.get(token);
12
+ }
13
+
14
+ class StaticNode {
15
+ constructor(pathExpr) {
16
+ this.pathExpr = pathExpr;
17
+ }
18
+
19
+ render(context) {
20
+ const resolvedPath = resolveValue(this.pathExpr, context);
21
+
22
+ // Resolve static prefix
23
+ let prefix = '/static/';
24
+ if (context.options && context.options.staticUrl) {
25
+ prefix = context.options.staticUrl;
26
+ } else if (context.options && context.options.settings && context.options.settings.staticUrl) {
27
+ prefix = context.options.settings.staticUrl;
28
+ }
29
+
30
+ // Ensure double-slash doesn't occur and ends/starts correctly
31
+ const base = prefix.endsWith('/') ? prefix : prefix + '/';
32
+ const relative = resolvedPath.startsWith('/') ? resolvedPath.slice(1) : resolvedPath;
33
+
34
+ return base + relative;
35
+ }
36
+ }
37
+
38
+ class UrlNode {
39
+ constructor(routeNameExpr, argsExprs) {
40
+ this.routeNameExpr = routeNameExpr;
41
+ this.argsExprs = argsExprs || [];
42
+ }
43
+
44
+ render(context) {
45
+ const routeName = resolveValue(this.routeNameExpr, context);
46
+ const resolvedArgs = this.argsExprs.map(arg => resolveValue(arg, context));
47
+
48
+ // If an Express urlHelper is provided, use it
49
+ if (context.options && typeof context.options.urlHelper === 'function') {
50
+ return context.options.urlHelper(routeName, ...resolvedArgs);
51
+ }
52
+
53
+ // Fallback URL generator
54
+ return '/' + [routeName, ...resolvedArgs].filter(Boolean).join('/');
55
+ }
56
+ }
57
+
58
+ class RegroupNode {
59
+ constructor(listPath, attr, targetName) {
60
+ this.listPath = listPath;
61
+ this.attr = attr;
62
+ this.targetName = targetName;
63
+ }
64
+
65
+ render(context) {
66
+ const list = context.get(this.listPath);
67
+ if (!Array.isArray(list)) {
68
+ context.scopes[0][this.targetName] = [];
69
+ return '';
70
+ }
71
+
72
+ const groups = [];
73
+ const groupMap = new Map();
74
+
75
+ for (const item of list) {
76
+ // Resolve attribute (support nested lookup on item)
77
+ let val = '';
78
+ if (item && typeof item === 'object') {
79
+ const parts = this.attr.split('.');
80
+ let current = item;
81
+ for (const part of parts) {
82
+ if (current && typeof current === 'object' && part in current) {
83
+ current = current[part];
84
+ } else {
85
+ current = undefined;
86
+ break;
87
+ }
88
+ }
89
+ val = current !== undefined ? current : '';
90
+ }
91
+
92
+ if (!groupMap.has(val)) {
93
+ const newGroup = { grouper: val, list: [] };
94
+ groupMap.set(val, newGroup);
95
+ groups.push(newGroup);
96
+ }
97
+ groupMap.get(val).list.push(item);
98
+ }
99
+
100
+ // Store in the current context scope
101
+ context.scopes[0][this.targetName] = groups;
102
+ return '';
103
+ }
104
+ }
105
+
106
+ class SpacelessNode {
107
+ constructor(body) {
108
+ this.body = body;
109
+ }
110
+
111
+ render(context) {
112
+ const content = this.body.map(n => n.render(context)).join('');
113
+ // Remove space between HTML tags
114
+ return content.replace(/>\s+</g, '><');
115
+ }
116
+ }
117
+
118
+ class CsrfTokenNode {
119
+ render(context) {
120
+ const token = context.get('csrf_token') || '';
121
+ const html = `<input type="hidden" name="csrfmiddlewaretoken" value="${token}">`;
122
+ const { markSafe } = require('../security');
123
+ return markSafe(html);
124
+ }
125
+ }
126
+
127
+ class CspNonceAttrNode {
128
+ render(context) {
129
+ const nonce = context.get('csp_nonce') || '';
130
+ if (!nonce) return '';
131
+ const html = `nonce="${nonce}"`;
132
+ const { markSafe } = require('../security');
133
+ return markSafe(html);
134
+ }
135
+ }
136
+
137
+ // --- Tag Registry Parsers ---
138
+
139
+ function parseStatic(tagContent, _parser) {
140
+ // tagContent: "static 'css/style.css'"
141
+ const pathExpr = tagContent.slice(6).trim();
142
+ return new StaticNode(pathExpr);
143
+ }
144
+
145
+ function parseUrl(tagContent, _parser) {
146
+ // tagContent: "url 'route_name' arg1 arg2"
147
+ const content = tagContent.slice(3).trim();
148
+
149
+ // Extract route name and arguments
150
+ const argRegex = /(".*?"|'.*?'|[^\s]+)/g;
151
+ const matches = content.match(argRegex) || [];
152
+
153
+ const routeNameExpr = matches[0];
154
+ const argsExprs = matches.slice(1);
155
+
156
+ return new UrlNode(routeNameExpr, argsExprs);
157
+ }
158
+
159
+ function parseRegroup(tagContent, _parser) {
160
+ // tagContent: "regroup people by gender as grouped"
161
+ const match = tagContent.match(/^regroup\s+(.+?)\s+by\s+(.+?)\s+as\s+(.+)$/);
162
+ if (!match) {
163
+ throw new Error(`Invalid regroup tag format: '${tagContent}'`);
164
+ }
165
+
166
+ const listPath = match[1].trim();
167
+ const attr = match[2].trim();
168
+ const targetName = match[3].trim();
169
+
170
+ return new RegroupNode(listPath, attr, targetName);
171
+ }
172
+
173
+ function parseSpaceless(tagContent, parser) {
174
+ const body = parser.parse(['endspaceless']);
175
+ const next = parser.peek();
176
+ if (next && next.type === 'block' && next.content.split(/\s+/)[0] === 'endspaceless') {
177
+ parser.advance();
178
+ }
179
+ return new SpacelessNode(body);
180
+ }
181
+
182
+ function parseCsrfToken(_tagContent, _parser) {
183
+ return new CsrfTokenNode();
184
+ }
185
+
186
+ class WidthRatioNode {
187
+ constructor(value, maxValue, maxWidth) {
188
+ this.value = parseFloat(value);
189
+ this.maxValue = parseFloat(maxValue);
190
+ this.maxWidth = parseInt(maxWidth, 10);
191
+ }
192
+
193
+ render(_context) {
194
+ if (!isFinite(this.value) || !isFinite(this.maxValue) || this.maxValue === 0) {
195
+ return '0';
196
+ }
197
+ const ratio = Math.floor((this.value / this.maxValue) * this.maxWidth);
198
+ return String(Math.max(0, Math.min(ratio, this.maxWidth)));
199
+ }
200
+ }
201
+
202
+ class DebugNode {
203
+ render(context) {
204
+ // Dump the current context (scopes) for debugging
205
+ const dump = {
206
+ scopes: context.scopes.map(s => {
207
+ if (s && typeof s === 'object') {
208
+ const out = {};
209
+ for (const k of Object.keys(s)) {
210
+ if (k.startsWith('__')) continue;
211
+ try {
212
+ const v = s[k];
213
+ out[k] = typeof v === 'function' ? '[function]' : v;
214
+ } catch {
215
+ out[k] = '[unreadable]';
216
+ }
217
+ }
218
+ return out;
219
+ }
220
+ return String(s);
221
+ })
222
+ };
223
+ const { escapeHtml, markSafe } = require('../security');
224
+ return markSafe('<pre>' + escapeHtml(JSON.stringify(dump, null, 2)) + '</pre>');
225
+ }
226
+ }
227
+
228
+ function parseWidthRatio(tagContent, _parser) {
229
+ // {% widthratio this_value max_value max_width %}
230
+ const parts = tagContent.replace(/^widthratio\s+/, '').trim().split(/\s+/);
231
+ if (parts.length < 3) {
232
+ throw new Error('\'widthratio\' tag requires 3 arguments: value, max_value, max_width');
233
+ }
234
+ return new WidthRatioNode(parts[0], parts[1], parts[2]);
235
+ }
236
+
237
+ function parseDebug(_tagContent, _parser) {
238
+ return new DebugNode();
239
+ }
240
+
241
+ function parseCspNonceAttr(_tagContent, _parser) {
242
+ return new CspNonceAttrNode();
243
+ }
244
+
245
+ class LoadNode {
246
+ constructor(libraries) {
247
+ this.libraries = Array.isArray(libraries) ? libraries : [libraries];
248
+ }
249
+
250
+ render(_context) {
251
+ const { activateLibrary, hasLibrary } = require('../libraries');
252
+ for (const lib of this.libraries) {
253
+ if (hasLibrary(lib)) {
254
+ activateLibrary(lib);
255
+ } else {
256
+ // Emit a comment-style warning for missing library
257
+ console.warn(`[miki-template] Library not found: '${lib}'`);
258
+ }
259
+ }
260
+ return '';
261
+ }
262
+ }
263
+
264
+ class TemplatetagNode {
265
+ constructor(token) {
266
+ this.token = token;
267
+ }
268
+
269
+ render(_context) {
270
+ const map = {
271
+ 'openblock': '{%',
272
+ 'closeblock': '%}',
273
+ 'openvariable': '{{',
274
+ 'closevariable': '}}',
275
+ 'openbrace': '{',
276
+ 'closebrace': '}',
277
+ 'opencomment': '{#',
278
+ 'closecomment': '#}'
279
+ };
280
+ return map[this.token] || this.token;
281
+ }
282
+ }
283
+
284
+ function parseLoad(tagContent, _parser) {
285
+ const parts = tagContent.trim().split(/\s+/);
286
+ if (parts.length === 0) {
287
+ throw new Error('\'load\' tag requires at least one argument');
288
+ }
289
+ return new LoadNode(parts);
290
+ }
291
+
292
+ function parseTemplatetag(tagContent, _parser) {
293
+ const parts = tagContent.trim().split(/\s+/);
294
+ // First part is the tag name, rest is the argument
295
+ const token = parts.length > 1 ? parts.slice(1).join(' ') : parts[0];
296
+ return new TemplatetagNode(token);
297
+ }
298
+
299
+ module.exports = {
300
+ StaticNode,
301
+ UrlNode,
302
+ RegroupNode,
303
+ SpacelessNode,
304
+ CsrfTokenNode,
305
+ CspNonceAttrNode,
306
+ LoadNode,
307
+ TemplatetagNode,
308
+ WidthRatioNode,
309
+ DebugNode,
310
+ parsers: {
311
+ static: parseStatic,
312
+ url: parseUrl,
313
+ regroup: parseRegroup,
314
+ spaceless: parseSpaceless,
315
+ csrf_token: parseCsrfToken,
316
+ csp_nonce_attr: parseCspNonceAttr,
317
+ load: parseLoad,
318
+ templatetag: parseTemplatetag,
319
+ widthratio: parseWidthRatio,
320
+ debug: parseDebug
321
+ }
322
+ };
package/src/types.d.ts ADDED
@@ -0,0 +1,107 @@
1
+ /**
2
+ * TypeScript definitions for miki-template
3
+ * Generated manually to match the actual runtime API.
4
+ */
5
+
6
+ declare namespace miki {
7
+ /**
8
+ * A compiled template instance returned by `compile()`.
9
+ */
10
+ interface CompiledTemplate {
11
+ /** Synchronously render the template. */
12
+ render(context?: TemplateContext): string;
13
+ /** Asynchronously render the template (awaits Promise helpers). */
14
+ asyncRender(context?: TemplateContext): Promise<string>;
15
+ /** Render a single named block. */
16
+ renderBlock(blockName: string, context?: TemplateContext): string;
17
+ /** Render a defined `{% partialdef %}` by name. */
18
+ renderPartial(partialName: string, context?: TemplateContext): string;
19
+ }
20
+
21
+ /** Context object passed to render functions. */
22
+ interface TemplateContext {
23
+ [key: string]: any;
24
+ }
25
+
26
+ /** Options for `compile()` and `render()`. */
27
+ interface CompileOptions {
28
+ /** Directories to search for `extends`/`include` templates. */
29
+ views?: string | string[];
30
+ /** Prefix for the `{% static %}` tag. */
31
+ staticUrl?: string;
32
+ /** Custom URL resolver for `{% url %}` tag. */
33
+ urlHelper?: (routeName: string, ...args: any[]) => string;
34
+ }
35
+
36
+ /** A custom filter function. */
37
+ type FilterFunction = (value: any, arg?: any) => any;
38
+
39
+ /** A custom tag parser function. */
40
+ type TagParserFunction = (tagContent: string, parser: any) => ASTNode;
41
+
42
+ /** A custom helper function (block tag). */
43
+ type HelperFunction = (content: string, context: any) => string | Promise<string>;
44
+
45
+ /** A context processor. */
46
+ type ContextProcessor = (context: TemplateContext) => TemplateContext | void;
47
+
48
+ /** An AST node with a `render(context)` method. */
49
+ interface ASTNode {
50
+ render(context: any): string | Promise<string>;
51
+ }
52
+
53
+ /**
54
+ * SafeString — a string marker that bypasses HTML auto-escaping.
55
+ */
56
+ class SafeString {
57
+ constructor(value: string);
58
+ toString(): string;
59
+ }
60
+ }
61
+
62
+ declare module "miki-template" {
63
+ export = miki;
64
+ export as namespace miki;
65
+
66
+ /** Compile a template string into a reusable renderable object. */
67
+ export function compile(templateStr: string, options?: miki.CompileOptions): miki.CompiledTemplate;
68
+
69
+ /** One-off template render. */
70
+ export function render(templateStr: string, context?: miki.TemplateContext, options?: miki.CompileOptions): string;
71
+
72
+ /** Async one-off render (supports async helpers). */
73
+ export function asyncRender(templateStr: string, context?: miki.TemplateContext, options?: miki.CompileOptions): Promise<string>;
74
+
75
+ /** Express view engine adapter. */
76
+ export function __express(filePath: string, options: miki.TemplateContext, callback: (err: Error | null, html?: string) => void): void;
77
+
78
+ /** Async Express view engine adapter (Express 5+). */
79
+ export function __expressAsync(filePath: string, options: miki.TemplateContext): Promise<string>;
80
+
81
+ /** Clear the in-memory compiled template cache. */
82
+ export function clearCache(): void;
83
+
84
+ /** Register a custom tag parser. */
85
+ export function registerTag(name: string, parserFn: miki.TagParserFunction): void;
86
+
87
+ /** Register a custom filter. */
88
+ export function registerFilter(name: string, filterFn: miki.FilterFunction): void;
89
+
90
+ /** Register a custom block helper. */
91
+ export function registerHelper(name: string, fn: miki.HelperFunction): void;
92
+
93
+ /** Register a context processor. */
94
+ export function registerContextProcessor(fn: miki.ContextProcessor): void;
95
+
96
+ /** Mark a value as HTML-safe (bypasses auto-escaping). */
97
+ export function markSafe(value: any): miki.SafeString;
98
+
99
+ /** Check if a value is a SafeString. */
100
+ export function isSafe(value: any): boolean;
101
+
102
+ /** Escape HTML special characters. */
103
+ export function escapeHtml(str: string): string;
104
+
105
+ /** SafeString class for marking values as safe. */
106
+ export const SafeString: typeof miki.SafeString;
107
+ }
@@ -0,0 +1,26 @@
1
+ {
2
+ "comments": {
3
+ "lineComment": "{#",
4
+ "blockComment": ["{#", "#}"]
5
+ },
6
+ "brackets": [
7
+ ["{", "}"],
8
+ ["[", "]"],
9
+ ["(", ")"]
10
+ ],
11
+ "autoClosingPairs": [
12
+ { "open": "{", "close": "}" },
13
+ { "open": "[", "close": "]" },
14
+ { "open": "(", "close": ")" },
15
+ { "open": "\"", "close": "\"", "notIn": ["string"] },
16
+ { "open": "'", "close": "'", "notIn": ["string", "comment"] }
17
+ ],
18
+ "surroundingPairs": [
19
+ { "open": "{", "close": "}" },
20
+ { "open": "[", "close": "]" },
21
+ { "open": "(", "close": ")" },
22
+ { "open": "\"", "close": "\"" },
23
+ { "open": "'", "close": "'" }
24
+ ],
25
+ "wordPattern": "[a-zA-Z_][a-zA-Z0-9_.]*"
26
+ }
@@ -0,0 +1,146 @@
1
+ {
2
+ "scopeName": "text.html.miki",
3
+ "name": "miki-template",
4
+ "patterns": [
5
+ {
6
+ "include": "#comment"
7
+ },
8
+ {
9
+ "include": "#tag"
10
+ },
11
+ {
12
+ "include": "#variable"
13
+ },
14
+ {
15
+ "include": "#string"
16
+ }
17
+ ],
18
+ "repository": {
19
+ "comment": {
20
+ "name": "comment.block.miki",
21
+ "begin": "\\{#",
22
+ "end": "#\\}",
23
+ "beginCaptures": {
24
+ "0": { "name": "punctuation.definition.comment.begin.miki" }
25
+ },
26
+ "endCaptures": {
27
+ "0": { "name": "punctuation.definition.comment.end.miki" }
28
+ }
29
+ },
30
+ "tag": {
31
+ "name": "meta.tag.miki",
32
+ "begin": "\\{%",
33
+ "end": "%\\}",
34
+ "beginCaptures": {
35
+ "0": { "name": "punctuation.definition.tag.begin.miki" }
36
+ },
37
+ "endCaptures": {
38
+ "0": { "name": "punctuation.definition.tag.end.miki" }
39
+ },
40
+ "patterns": [
41
+ {
42
+ "name": "entity.name.tag.miki",
43
+ "match": "(?i)\\b(if|elif|else|endif|for|empty|endfor|with|endwith|cycle|comment|endcomment|verbatim|endverbatim|include|extends|block|endblock|partialdef|endpartialdef|partial|load|templatetag|trans|blocktrans|language|endlanguage|plural|widthratio|debug|endif|endfor|endwith|endblock|endcomment|endverbatim|endpartialdef|endblocktrans|endlanguage|endcache|endmarkdown)\\b"
44
+ },
45
+ {
46
+ "name": "string.quoted.double.miki",
47
+ "match": "\"[^\"]*\""
48
+ },
49
+ {
50
+ "name": "string.quoted.single.miki",
51
+ "match": "'[^']*'"
52
+ },
53
+ {
54
+ "name": "constant.numeric.miki",
55
+ "match": "\\b\\d+\\b"
56
+ },
57
+ {
58
+ "name": "keyword.operator.miki",
59
+ "match": "(?i)\\b(and|or|not|in|not in)\\b"
60
+ },
61
+ {
62
+ "name": "punctuation.separator.miki",
63
+ "match": "="
64
+ }
65
+ ]
66
+ },
67
+ "variable": {
68
+ "name": "variable.other.miki",
69
+ "begin": "\\{\\{",
70
+ "end": "\\}\\}",
71
+ "beginCaptures": {
72
+ "0": { "name": "punctuation.definition.variable.begin.miki" }
73
+ },
74
+ "endCaptures": {
75
+ "0": { "name": "punctuation.definition.variable.end.miki" }
76
+ },
77
+ "patterns": [
78
+ {
79
+ "name": "variable.other.miki",
80
+ "match": "[a-zA-Z_][a-zA-Z0-9_.]*"
81
+ },
82
+ {
83
+ "include": "#filter"
84
+ },
85
+ {
86
+ "name": "string.quoted.double.miki",
87
+ "match": "\"[^\"]*\""
88
+ },
89
+ {
90
+ "name": "string.quoted.single.miki",
91
+ "match": "'[^']*'"
92
+ }
93
+ ]
94
+ },
95
+ "filter": {
96
+ "name": "meta.filter.miki",
97
+ "begin": "\\|",
98
+ "end": "(?=\\}|$)",
99
+ "beginCaptures": {
100
+ "0": { "name": "punctuation.separator.filter.miki" }
101
+ },
102
+ "patterns": [
103
+ {
104
+ "name": "support.function.miki",
105
+ "match": "[a-zA-Z_][a-zA-Z0-9_]*"
106
+ },
107
+ {
108
+ "begin": ":",
109
+ "beginCaptures": {
110
+ "0": { "name": "punctuation.separator.filter.argument.miki" }
111
+ },
112
+ "end": "(?=\\||\\}\\})",
113
+ "patterns": [
114
+ {
115
+ "name": "string.quoted.double.miki",
116
+ "match": "\"[^\"]*\""
117
+ },
118
+ {
119
+ "name": "string.quoted.single.miki",
120
+ "match": "'[^']*'"
121
+ },
122
+ {
123
+ "name": "constant.numeric.miki",
124
+ "match": "\\b\\d+\\b"
125
+ },
126
+ {
127
+ "name": "variable.other.miki",
128
+ "match": "[a-zA-Z_][a-zA-Z0-9_.]*"
129
+ }
130
+ ]
131
+ }
132
+ ]
133
+ },
134
+ "string": {
135
+ "name": "string.quoted.double.miki",
136
+ "begin": "\"",
137
+ "end": "\"",
138
+ "beginCaptures": {
139
+ "0": { "name": "punctuation.definition.string.begin.miki" }
140
+ },
141
+ "endCaptures": {
142
+ "0": { "name": "punctuation.definition.string.end.miki" }
143
+ }
144
+ }
145
+ }
146
+ }
@@ -0,0 +1,17 @@
1
+ // tests/asyncRender.test.js
2
+ const test = require('node:test');
3
+ const { asyncRender, registerHelper } = require('../src');
4
+
5
+ // Register a simple async helper that sleeps
6
+ registerHelper('sleep', async (content) => {
7
+ const ms = parseInt(content, 10) || 0;
8
+ await new Promise(r => setTimeout(r, ms));
9
+ return `slept ${ms}ms`;
10
+ });
11
+
12
+ test('asyncRender with sleep helper', async () => {
13
+ const tmpl = `{% sleep %}30{% endsleep %}`;
14
+ const result = await asyncRender(tmpl, {});
15
+ const assert = require('node:assert');
16
+ assert.strictEqual(result, 'slept 30ms');
17
+ });
@@ -0,0 +1,6 @@
1
+ <html>
2
+ <body>
3
+ {% block header %}Default Header{% endblock %}
4
+ {% block content %}{% endblock %}
5
+ </body>
6
+ </html>
@@ -0,0 +1,3 @@
1
+ {% extends "base.html" %}
2
+ {% block header %}Child Header ({{ block.super }}){% endblock %}
3
+ {% block content %}Child Content{% endblock %}
@@ -0,0 +1,13 @@
1
+ // Context Processors test using node:test
2
+ const test = require('node:test');
3
+ const assert = require('node:assert');
4
+ const { registerContextProcessor, clearContextProcessors } = require('../src/context_processors');
5
+ const { compile } = require('../src');
6
+
7
+ test('Context processor injects variable', () => {
8
+ clearContextProcessors();
9
+ registerContextProcessor(() => ({ siteName: 'DemoSite' }));
10
+ const tmpl = '{{ siteName }}';
11
+ const rendered = compile(tmpl).render({});
12
+ assert.strictEqual(rendered, 'DemoSite');
13
+ });
@@ -0,0 +1,26 @@
1
+ // Test ESM import support
2
+ import { render, compile, SafeString, markSafe } from '../src/esm.mjs';
3
+ import test from 'node:test';
4
+ import assert from 'node:assert';
5
+
6
+ test('ESM - default named imports', () => {
7
+ const result = render('Hello {{ name }}!', { name: 'ESM' });
8
+ assert.strictEqual(result, 'Hello ESM!');
9
+ });
10
+
11
+ test('ESM - compile import', () => {
12
+ const compiled = compile('Hi {{ user }}!');
13
+ assert.strictEqual(compiled.render({ user: 'World' }), 'Hi World!');
14
+ });
15
+
16
+ test('ESM - SafeString import', () => {
17
+ const html = new SafeString('<b>Bold</b>');
18
+ const result = render('{{ html }}', { html });
19
+ assert.strictEqual(result, '<b>Bold</b>');
20
+ });
21
+
22
+ test('ESM - markSafe import', () => {
23
+ const safe = markSafe('<i>Italic</i>');
24
+ const result = render('{{ x }}', { x: safe });
25
+ assert.strictEqual(result, '<i>Italic</i>');
26
+ });