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.
- package/.github/workflows/ci.yml +54 -0
- package/AGENT.md +71 -0
- package/API_REFERENCE.md +314 -0
- package/CHANGELOG.md +97 -0
- package/CODE_OF_CONDUCT.md +14 -0
- package/CONTRIBUTING.md +27 -0
- package/README.md +304 -0
- package/ROADMAP.md +40 -0
- package/benchmarks/report.json +17 -0
- package/benchmarks/run.js +49 -0
- package/benchmarks/templates/large.dtpl +7 -0
- package/benchmarks/templates/medium.dtpl +3 -0
- package/benchmarks/templates/small.dtpl +7 -0
- package/context/component.md +109 -0
- package/context/prd.md +131 -0
- package/context/project-structure.md +33 -0
- package/docs/README.md +18 -0
- package/docs/advanced_usage.md +71 -0
- package/docs/api.md +102 -0
- package/docs/filters.md +540 -0
- package/docs/installation.md +106 -0
- package/docs/overview.md +57 -0
- package/docs/partialdef.md +41 -0
- package/docs/security.md +27 -0
- package/docs/tags.md +610 -0
- package/docs/usage.md +599 -0
- package/eslint.config.mjs +34 -0
- package/miki-template-1.2.0.vsix +0 -0
- package/miki-template-extension/LICENSE +21 -0
- package/miki-template-extension/README.md +82 -0
- package/miki-template-extension/icon.png +0 -0
- package/miki-template-extension/icon.svg +10 -0
- package/miki-template-extension/package.json +46 -0
- package/miki-template-extension/snippets/miki-template.json +177 -0
- package/miki-template-extension/syntaxes/language-configuration.json +26 -0
- package/miki-template-extension/syntaxes/miki-template.tmLanguage.json +146 -0
- package/package.json +31 -0
- package/snippets/miki-template.json +177 -0
- package/src/asyncRender.js +21 -0
- package/src/cache.js +41 -0
- package/src/context.js +122 -0
- package/src/context_processors.js +41 -0
- package/src/esm.mjs +72 -0
- package/src/filters.js +527 -0
- package/src/i18n.js +171 -0
- package/src/index.js +454 -0
- package/src/lexer.js +92 -0
- package/src/libraries.js +240 -0
- package/src/parser.js +250 -0
- package/src/security.js +51 -0
- package/src/tags/control.js +591 -0
- package/src/tags/helpers.js +27 -0
- package/src/tags/i18n.js +230 -0
- package/src/tags/inheritance.js +216 -0
- package/src/tags/registry.js +18 -0
- package/src/tags/util.js +322 -0
- package/src/types.d.ts +107 -0
- package/syntaxes/language-configuration.json +26 -0
- package/syntaxes/miki-template.tmLanguage.json +146 -0
- package/tests/asyncRender.test.js +17 -0
- package/tests/base.html +6 -0
- package/tests/child.html +3 -0
- package/tests/context_processors.test.js +13 -0
- package/tests/esm.test.mjs +26 -0
- package/tests/filters.test.js +99 -0
- package/tests/include_security.test.js +9 -0
- package/tests/lexer.test.js +45 -0
- package/tests/parser.test.js +55 -0
- package/tests/partial.html +1 -0
- package/tests/partialdef.test.js +40 -0
- package/tests/production_checks.js +57 -0
- package/tests/security.test.js +28 -0
- 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
|
+
};
|