UNPKG

22.9 kBJavaScriptView Raw
1/*
2 * EJS Embedded JavaScript templates
3 * Copyright 2112 Matthew Eernisse (mde@fleegix.org)
4 *
5 * Licensed under the Apache License, Version 2.0 (the "License");
6 * you may not use this file except in compliance with the License.
7 * You may obtain a copy of the License at
8 *
9 * http://www.apache.org/licenses/LICENSE-2.0
10 *
11 * Unless required by applicable law or agreed to in writing, software
12 * distributed under the License is distributed on an "AS IS" BASIS,
13 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 * See the License for the specific language governing permissions and
15 * limitations under the License.
16 *
17*/
18
19'use strict';
20
21/**
22 * @file Embedded JavaScript templating engine.
23 * @author Matthew Eernisse <mde@fleegix.org>
24 * @author Tiancheng "Timothy" Gu <timothygu99@gmail.com>
25 * @project EJS
26 * @license {@link http://www.apache.org/licenses/LICENSE-2.0 Apache License, Version 2.0}
27 */
28
29/**
30 * EJS internal functions.
31 *
32 * Technically this "module" lies in the same file as {@link module:ejs}, for
33 * the sake of organization all the private functions re grouped into this
34 * module.
35 *
36 * @module ejs-internal
37 * @private
38 */
39
40/**
41 * Embedded JavaScript templating engine.
42 *
43 * @module ejs
44 * @public
45 */
46
47var fs = require('fs');
48var path = require('path');
49var utils = require('./utils');
50
51var scopeOptionWarned = false;
52var _VERSION_STRING = require('../package.json').version;
53var _DEFAULT_DELIMITER = '%';
54var _DEFAULT_LOCALS_NAME = 'locals';
55var _REGEX_STRING = '(<%%|%%>|<%=|<%-|<%_|<%#|<%|%>|-%>|_%>)';
56var _OPTS = [ 'cache', 'filename', 'delimiter', 'scope', 'context',
57 'debug', 'compileDebug', 'client', '_with', 'root', 'rmWhitespace',
58 'strict', 'localsName'];
59var _TRAILING_SEMCOL = /;\s*$/;
60var _BOM = /^\uFEFF/;
61
62/**
63 * EJS template function cache. This can be a LRU object from lru-cache NPM
64 * module. By default, it is {@link module:utils.cache}, a simple in-process
65 * cache that grows continuously.
66 *
67 * @type {Cache}
68 */
69
70exports.cache = utils.cache;
71
72/**
73 * Name of the object containing the locals.
74 *
75 * This variable is overridden by {@link Options}`.localsName` if it is not
76 * `undefined`.
77 *
78 * @type {String}
79 * @public
80 */
81
82exports.localsName = _DEFAULT_LOCALS_NAME;
83
84/**
85 * Get the path to the included file from the parent file path and the
86 * specified path.
87 *
88 * @param {String} name specified path
89 * @param {String} filename parent file path
90 * @param {Boolean} isDir parent file path whether is directory
91 * @return {String}
92 */
93exports.resolveInclude = function(name, filename, isDir) {
94 var dirname = path.dirname;
95 var extname = path.extname;
96 var resolve = path.resolve;
97 var includePath = resolve(isDir ? filename : dirname(filename), name);
98 var ext = extname(name);
99 if (!ext) {
100 includePath += '.ejs';
101 }
102 return includePath;
103};
104
105/**
106 * Get the path to the included file by Options
107 *
108 * @param {String} path specified path
109 * @param {Options} options compilation options
110 * @return {String}
111 */
112function getIncludePath(path, options){
113 var includePath;
114 if (path.charAt(0) == '/') {
115 includePath = exports.resolveInclude(path.replace(/^\/*/,''), options.root || '/', true);
116 }
117 else {
118 if (!options.filename) {
119 throw new Error('`include` use relative path requires the \'filename\' option.');
120 }
121 includePath = exports.resolveInclude(path, options.filename);
122 }
123 return includePath;
124}
125
126/**
127 * Get the template from a string or a file, either compiled on-the-fly or
128 * read from cache (if enabled), and cache the template if needed.
129 *
130 * If `template` is not set, the file specified in `options.filename` will be
131 * read.
132 *
133 * If `options.cache` is true, this function reads the file from
134 * `options.filename` so it must be set prior to calling this function.
135 *
136 * @memberof module:ejs-internal
137 * @param {Options} options compilation options
138 * @param {String} [template] template source
139 * @return {(TemplateFunction|ClientFunction)}
140 * Depending on the value of `options.client`, either type might be returned.
141 * @static
142 */
143
144function handleCache(options, template) {
145 var func;
146 var filename = options.filename;
147 var hasTemplate = arguments.length > 1;
148
149 if (options.cache) {
150 if (!filename) {
151 throw new Error('cache option requires a filename');
152 }
153 func = exports.cache.get(filename);
154 if (func) {
155 return func;
156 }
157 if (!hasTemplate) {
158 template = fs.readFileSync(filename).toString().replace(_BOM, '');
159 }
160 }
161 else if (!hasTemplate) {
162 // istanbul ignore if: should not happen at all
163 if (!filename) {
164 throw new Error('Internal EJS error: no file name or template '
165 + 'provided');
166 }
167 template = fs.readFileSync(filename).toString().replace(_BOM, '');
168 }
169 func = exports.compile(template, options);
170 if (options.cache) {
171 exports.cache.set(filename, func);
172 }
173 return func;
174}
175
176/**
177 * Get the template function.
178 *
179 * If `options.cache` is `true`, then the template is cached.
180 *
181 * @memberof module:ejs-internal
182 * @param {String} path path for the specified file
183 * @param {Options} options compilation options
184 * @return {(TemplateFunction|ClientFunction)}
185 * Depending on the value of `options.client`, either type might be returned
186 * @static
187 */
188
189function includeFile(path, options) {
190 var opts = utils.shallowCopy({}, options);
191 opts.filename = getIncludePath(path, opts);
192 return handleCache(opts);
193}
194
195/**
196 * Get the JavaScript source of an included file.
197 *
198 * @memberof module:ejs-internal
199 * @param {String} path path for the specified file
200 * @param {Options} options compilation options
201 * @return {Object}
202 * @static
203 */
204
205function includeSource(path, options) {
206 var opts = utils.shallowCopy({}, options);
207 var includePath;
208 var template;
209 includePath = getIncludePath(path,opts);
210 template = fs.readFileSync(includePath).toString().replace(_BOM, '');
211 opts.filename = includePath;
212 var templ = new Template(template, opts);
213 templ.generateSource();
214 return {
215 source: templ.source,
216 filename: includePath,
217 template: template
218 };
219}
220
221/**
222 * Re-throw the given `err` in context to the `str` of ejs, `filename`, and
223 * `lineno`.
224 *
225 * @implements RethrowCallback
226 * @memberof module:ejs-internal
227 * @param {Error} err Error object
228 * @param {String} str EJS source
229 * @param {String} filename file name of the EJS file
230 * @param {String} lineno line number of the error
231 * @static
232 */
233
234function rethrow(err, str, filename, lineno){
235 var lines = str.split('\n');
236 var start = Math.max(lineno - 3, 0);
237 var end = Math.min(lines.length, lineno + 3);
238 // Error context
239 var context = lines.slice(start, end).map(function (line, i){
240 var curr = i + start + 1;
241 return (curr == lineno ? ' >> ' : ' ')
242 + curr
243 + '| '
244 + line;
245 }).join('\n');
246
247 // Alter exception message
248 err.path = filename;
249 err.message = (filename || 'ejs') + ':'
250 + lineno + '\n'
251 + context + '\n\n'
252 + err.message;
253
254 throw err;
255}
256
257/**
258 * Copy properties in data object that are recognized as options to an
259 * options object.
260 *
261 * This is used for compatibility with earlier versions of EJS and Express.js.
262 *
263 * @memberof module:ejs-internal
264 * @param {Object} data data object
265 * @param {Options} opts options object
266 * @static
267 */
268
269function cpOptsInData(data, opts) {
270 _OPTS.forEach(function (p) {
271 if (typeof data[p] != 'undefined') {
272 // Disallow setting the root opt for includes via a passed data obj
273 // Unsanitized, parameterized use of `render` could allow the
274 // include directory to be reset, opening up the possibility of
275 // remote code execution
276 if (p == 'root') {
277 return;
278 }
279 opts[p] = data[p];
280 }
281 });
282}
283
284/**
285 * Compile the given `str` of ejs into a template function.
286 *
287 * @param {String} template EJS template
288 *
289 * @param {Options} opts compilation options
290 *
291 * @return {(TemplateFunction|ClientFunction)}
292 * Depending on the value of `opts.client`, either type might be returned.
293 * @public
294 */
295
296exports.compile = function compile(template, opts) {
297 var templ;
298
299 // v1 compat
300 // 'scope' is 'context'
301 // FIXME: Remove this in a future version
302 if (opts && opts.scope) {
303 if (!scopeOptionWarned){
304 console.warn('`scope` option is deprecated and will be removed in EJS 3');
305 scopeOptionWarned = true;
306 }
307 if (!opts.context) {
308 opts.context = opts.scope;
309 }
310 delete opts.scope;
311 }
312 templ = new Template(template, opts);
313 return templ.compile();
314};
315
316/**
317 * Render the given `template` of ejs.
318 *
319 * If you would like to include options but not data, you need to explicitly
320 * call this function with `data` being an empty object or `null`.
321 *
322 * @param {String} template EJS template
323 * @param {Object} [data={}] template data
324 * @param {Options} [opts={}] compilation and rendering options
325 * @return {String}
326 * @public
327 */
328
329exports.render = function (template, d, o) {
330 var data = d || {};
331 var opts = o || {};
332
333 // No options object -- if there are optiony names
334 // in the data, copy them to options
335 if (arguments.length == 2) {
336 cpOptsInData(data, opts);
337 }
338
339 return handleCache(opts, template)(data);
340};
341
342/**
343 * Render an EJS file at the given `path` and callback `cb(err, str)`.
344 *
345 * If you would like to include options but not data, you need to explicitly
346 * call this function with `data` being an empty object or `null`.
347 *
348 * @param {String} path path to the EJS file
349 * @param {Object} [data={}] template data
350 * @param {Options} [opts={}] compilation and rendering options
351 * @param {RenderFileCallback} cb callback
352 * @public
353 */
354
355exports.renderFile = function () {
356 var args = Array.prototype.slice.call(arguments);
357 var filename = args.shift();
358 var cb = args.pop();
359 var data = args.shift() || {};
360 var opts = args.pop() || {};
361 var result;
362
363 // Don't pollute passed in opts obj with new vals
364 opts = utils.shallowCopy({}, opts);
365
366 // No options object -- if there are optiony names
367 // in the data, copy them to options
368 if (arguments.length == 3) {
369 // Express 4
370 if (data.settings && data.settings['view options']) {
371 cpOptsInData(data.settings['view options'], opts);
372 }
373 // Express 3 and lower
374 else {
375 cpOptsInData(data, opts);
376 }
377 }
378 opts.filename = filename;
379
380 try {
381 result = handleCache(opts)(data);
382 }
383 catch(err) {
384 return cb(err);
385 }
386 return cb(null, result);
387};
388
389/**
390 * Clear intermediate JavaScript cache. Calls {@link Cache#reset}.
391 * @public
392 */
393
394exports.clearCache = function () {
395 exports.cache.reset();
396};
397
398function Template(text, opts) {
399 opts = opts || {};
400 var options = {};
401 this.templateText = text;
402 this.mode = null;
403 this.truncate = false;
404 this.currentLine = 1;
405 this.source = '';
406 this.dependencies = [];
407 options.client = opts.client || false;
408 options.escapeFunction = opts.escape || utils.escapeXML;
409 options.compileDebug = opts.compileDebug !== false;
410 options.debug = !!opts.debug;
411 options.filename = opts.filename;
412 options.delimiter = opts.delimiter || exports.delimiter || _DEFAULT_DELIMITER;
413 options.strict = opts.strict || false;
414 options.context = opts.context;
415 options.cache = opts.cache || false;
416 options.rmWhitespace = opts.rmWhitespace;
417 options.root = opts.root;
418 options.localsName = opts.localsName || exports.localsName || _DEFAULT_LOCALS_NAME;
419
420 if (options.strict) {
421 options._with = false;
422 }
423 else {
424 options._with = typeof opts._with != 'undefined' ? opts._with : true;
425 }
426
427 this.opts = options;
428
429 this.regex = this.createRegex();
430}
431
432Template.modes = {
433 EVAL: 'eval',
434 ESCAPED: 'escaped',
435 RAW: 'raw',
436 COMMENT: 'comment',
437 LITERAL: 'literal'
438};
439
440Template.prototype = {
441 createRegex: function () {
442 var str = _REGEX_STRING;
443 var delim = utils.escapeRegExpChars(this.opts.delimiter);
444 str = str.replace(/%/g, delim);
445 return new RegExp(str);
446 },
447
448 compile: function () {
449 var src;
450 var fn;
451 var opts = this.opts;
452 var prepended = '';
453 var appended = '';
454 var escape = opts.escapeFunction;
455
456 if (!this.source) {
457 this.generateSource();
458 prepended += ' var __output = [], __append = __output.push.bind(__output);' + '\n';
459 if (opts._with !== false) {
460 prepended += ' with (' + opts.localsName + ' || {}) {' + '\n';
461 appended += ' }' + '\n';
462 }
463 appended += ' return __output.join("");' + '\n';
464 this.source = prepended + this.source + appended;
465 }
466
467 if (opts.compileDebug) {
468 src = 'var __line = 1' + '\n'
469 + ' , __lines = ' + JSON.stringify(this.templateText) + '\n'
470 + ' , __filename = ' + (opts.filename ?
471 JSON.stringify(opts.filename) : 'undefined') + ';' + '\n'
472 + 'try {' + '\n'
473 + this.source
474 + '} catch (e) {' + '\n'
475 + ' rethrow(e, __lines, __filename, __line);' + '\n'
476 + '}' + '\n';
477 }
478 else {
479 src = this.source;
480 }
481
482 if (opts.debug) {
483 console.log(src);
484 }
485
486 if (opts.client) {
487 src = 'escape = escape || ' + escape.toString() + ';' + '\n' + src;
488 if (opts.compileDebug) {
489 src = 'rethrow = rethrow || ' + rethrow.toString() + ';' + '\n' + src;
490 }
491 }
492
493 if (opts.strict) {
494 src = '"use strict";\n' + src;
495 }
496
497 try {
498 fn = new Function(opts.localsName + ', escape, include, rethrow', src);
499 }
500 catch(e) {
501 // istanbul ignore else
502 if (e instanceof SyntaxError) {
503 if (opts.filename) {
504 e.message += ' in ' + opts.filename;
505 }
506 e.message += ' while compiling ejs\n\n';
507 e.message += 'If the above error is not helpful, you may want to try EJS-Lint:\n';
508 e.message += 'https://github.com/RyanZim/EJS-Lint';
509 }
510 throw e;
511 }
512
513 if (opts.client) {
514 fn.dependencies = this.dependencies;
515 return fn;
516 }
517
518 // Return a callable function which will execute the function
519 // created by the source-code, with the passed data as locals
520 // Adds a local `include` function which allows full recursive include
521 var returnedFn = function (data) {
522 var include = function (path, includeData) {
523 var d = utils.shallowCopy({}, data);
524 if (includeData) {
525 d = utils.shallowCopy(d, includeData);
526 }
527 return includeFile(path, opts)(d);
528 };
529 return fn.apply(opts.context, [data || {}, escape, include, rethrow]);
530 };
531 returnedFn.dependencies = this.dependencies;
532 return returnedFn;
533 },
534
535 generateSource: function () {
536 var opts = this.opts;
537
538 if (opts.rmWhitespace) {
539 // Have to use two separate replace here as `^` and `$` operators don't
540 // work well with `\r`.
541 this.templateText =
542 this.templateText.replace(/\r/g, '').replace(/^\s+|\s+$/gm, '');
543 }
544
545 // Slurp spaces and tabs before <%_ and after _%>
546 this.templateText =
547 this.templateText.replace(/[ \t]*<%_/gm, '<%_').replace(/_%>[ \t]*/gm, '_%>');
548
549 var self = this;
550 var matches = this.parseTemplateText();
551 var d = this.opts.delimiter;
552
553 if (matches && matches.length) {
554 matches.forEach(function (line, index) {
555 var opening;
556 var closing;
557 var include;
558 var includeOpts;
559 var includeObj;
560 var includeSrc;
561 // If this is an opening tag, check for closing tags
562 // FIXME: May end up with some false positives here
563 // Better to store modes as k/v with '<' + delimiter as key
564 // Then this can simply check against the map
565 if ( line.indexOf('<' + d) === 0 // If it is a tag
566 && line.indexOf('<' + d + d) !== 0) { // and is not escaped
567 closing = matches[index + 2];
568 if (!(closing == d + '>' || closing == '-' + d + '>' || closing == '_' + d + '>')) {
569 throw new Error('Could not find matching close tag for "' + line + '".');
570 }
571 }
572 // HACK: backward-compat `include` preprocessor directives
573 if ((include = line.match(/^\s*include\s+(\S+)/))) {
574 opening = matches[index - 1];
575 // Must be in EVAL or RAW mode
576 if (opening && (opening == '<' + d || opening == '<' + d + '-' || opening == '<' + d + '_')) {
577 includeOpts = utils.shallowCopy({}, self.opts);
578 includeObj = includeSource(include[1], includeOpts);
579 if (self.opts.compileDebug) {
580 includeSrc =
581 ' ; (function(){' + '\n'
582 + ' var __line = 1' + '\n'
583 + ' , __lines = ' + JSON.stringify(includeObj.template) + '\n'
584 + ' , __filename = ' + JSON.stringify(includeObj.filename) + ';' + '\n'
585 + ' try {' + '\n'
586 + includeObj.source
587 + ' } catch (e) {' + '\n'
588 + ' rethrow(e, __lines, __filename, __line);' + '\n'
589 + ' }' + '\n'
590 + ' ; }).call(this)' + '\n';
591 }else{
592 includeSrc = ' ; (function(){' + '\n' + includeObj.source +
593 ' ; }).call(this)' + '\n';
594 }
595 self.source += includeSrc;
596 self.dependencies.push(exports.resolveInclude(include[1],
597 includeOpts.filename));
598 return;
599 }
600 }
601 self.scanLine(line);
602 });
603 }
604
605 },
606
607 parseTemplateText: function () {
608 var str = this.templateText;
609 var pat = this.regex;
610 var result = pat.exec(str);
611 var arr = [];
612 var firstPos;
613
614 while (result) {
615 firstPos = result.index;
616
617 if (firstPos !== 0) {
618 arr.push(str.substring(0, firstPos));
619 str = str.slice(firstPos);
620 }
621
622 arr.push(result[0]);
623 str = str.slice(result[0].length);
624 result = pat.exec(str);
625 }
626
627 if (str) {
628 arr.push(str);
629 }
630
631 return arr;
632 },
633
634 scanLine: function (line) {
635 var self = this;
636 var d = this.opts.delimiter;
637 var newLineCount = 0;
638
639 function _addOutput() {
640 if (self.truncate) {
641 // Only replace single leading linebreak in the line after
642 // -%> tag -- this is the single, trailing linebreak
643 // after the tag that the truncation mode replaces
644 // Handle Win / Unix / old Mac linebreaks -- do the \r\n
645 // combo first in the regex-or
646 line = line.replace(/^(?:\r\n|\r|\n)/, '');
647 self.truncate = false;
648 }
649 else if (self.opts.rmWhitespace) {
650 // Gotta be more careful here.
651 // .replace(/^(\s*)\n/, '$1') might be more appropriate here but as
652 // rmWhitespace already removes trailing spaces anyway so meh.
653 line = line.replace(/^\n/, '');
654 }
655 if (!line) {
656 return;
657 }
658
659 // Preserve literal slashes
660 line = line.replace(/\\/g, '\\\\');
661
662 // Convert linebreaks
663 line = line.replace(/\n/g, '\\n');
664 line = line.replace(/\r/g, '\\r');
665
666 // Escape double-quotes
667 // - this will be the delimiter during execution
668 line = line.replace(/"/g, '\\"');
669 self.source += ' ; __append("' + line + '")' + '\n';
670 }
671
672 newLineCount = (line.split('\n').length - 1);
673
674 switch (line) {
675 case '<' + d:
676 case '<' + d + '_':
677 this.mode = Template.modes.EVAL;
678 break;
679 case '<' + d + '=':
680 this.mode = Template.modes.ESCAPED;
681 break;
682 case '<' + d + '-':
683 this.mode = Template.modes.RAW;
684 break;
685 case '<' + d + '#':
686 this.mode = Template.modes.COMMENT;
687 break;
688 case '<' + d + d:
689 this.mode = Template.modes.LITERAL;
690 this.source += ' ; __append("' + line.replace('<' + d + d, '<' + d) + '")' + '\n';
691 break;
692 case d + d + '>':
693 this.mode = Template.modes.LITERAL;
694 this.source += ' ; __append("' + line.replace(d + d + '>', d + '>') + '")' + '\n';
695 break;
696 case d + '>':
697 case '-' + d + '>':
698 case '_' + d + '>':
699 if (this.mode == Template.modes.LITERAL) {
700 _addOutput();
701 }
702
703 this.mode = null;
704 this.truncate = line.indexOf('-') === 0 || line.indexOf('_') === 0;
705 break;
706 default:
707 // In script mode, depends on type of tag
708 if (this.mode) {
709 // If '//' is found without a line break, add a line break.
710 switch (this.mode) {
711 case Template.modes.EVAL:
712 case Template.modes.ESCAPED:
713 case Template.modes.RAW:
714 if (line.lastIndexOf('//') > line.lastIndexOf('\n')) {
715 line += '\n';
716 }
717 }
718 switch (this.mode) {
719 // Just executing code
720 case Template.modes.EVAL:
721 this.source += ' ; ' + line + '\n';
722 break;
723 // Exec, esc, and output
724 case Template.modes.ESCAPED:
725 this.source += ' ; __append(escape(' +
726 line.replace(_TRAILING_SEMCOL, '').trim() + '))' + '\n';
727 break;
728 // Exec and output
729 case Template.modes.RAW:
730 this.source += ' ; __append(' +
731 line.replace(_TRAILING_SEMCOL, '').trim() + ')' + '\n';
732 break;
733 case Template.modes.COMMENT:
734 // Do nothing
735 break;
736 // Literal <%% mode, append as raw output
737 case Template.modes.LITERAL:
738 _addOutput();
739 break;
740 }
741 }
742 // In string mode, just add the output
743 else {
744 _addOutput();
745 }
746 }
747
748 if (self.opts.compileDebug && newLineCount) {
749 this.currentLine += newLineCount;
750 this.source += ' ; __line = ' + this.currentLine + '\n';
751 }
752 }
753};
754
755/**
756 * Escape characters reserved in XML.
757 *
758 * This is simply an export of {@link module:utils.escapeXML}.
759 *
760 * If `markup` is `undefined` or `null`, the empty string is returned.
761 *
762 * @param {String} markup Input string
763 * @return {String} Escaped string
764 * @public
765 * @func
766 * */
767exports.escapeXML = utils.escapeXML;
768
769/**
770 * Express.js support.
771 *
772 * This is an alias for {@link module:ejs.renderFile}, in order to support
773 * Express.js out-of-the-box.
774 *
775 * @func
776 */
777
778exports.__express = exports.renderFile;
779
780// Add require support
781/* istanbul ignore else */
782if (require.extensions) {
783 require.extensions['.ejs'] = function (module, flnm) {
784 var filename = flnm || /* istanbul ignore next */ module.filename;
785 var options = {
786 filename: filename,
787 client: true
788 };
789 var template = fs.readFileSync(filename).toString();
790 var fn = exports.compile(template, options);
791 module._compile('module.exports = ' + fn.toString() + ';', filename);
792 };
793}
794
795/**
796 * Version of EJS.
797 *
798 * @readonly
799 * @type {String}
800 * @public
801 */
802
803exports.VERSION = _VERSION_STRING;
804
805/* istanbul ignore if */
806if (typeof window != 'undefined') {
807 window.ejs = exports;
808}