UNPKG

23.4 kBJavaScriptView Raw
1/**
2 * Module dependencies
3 */
4var path = require('path'),
5 fs = require('fs'),
6 exists = fs.exists || path.exists,
7 crypto = require('crypto'),
8 utils = require('./utils'),
9 FormForResource = require('./form-for-resource.js'),
10 _url = require('url');
11
12/**
13 * Import utilities
14 */
15var htmlTagParams = utils.html_tag_params,
16 safe_merge = utils.safe_merge,
17 humanize = utils.humanize,
18 undef;
19
20/**
21 * Config
22 */
23var regexps = {
24 'cached': /^cache\//,
25 'isHttp': /^https?:\/\/|\/\//
26},
27exts = {
28 'css': '.css',
29 'js' : '.js'
30},
31paths = {
32 'css': '/stylesheets/',
33 'js' : '/javascripts/'
34},
35merged = {
36 stylesheets: {ext: exts.css},
37 javascripts: {ext: exts.js}
38},
39globalContents = {};
40
41/**
42 * Publish HelperSet
43 */
44module.exports = new HelperSet(null);
45module.exports.HelperSet = HelperSet;
46
47/**
48 * Set of helper methods
49 *
50 * @namespace
51 * @param {Object} ctl Controller object.
52 */
53function HelperSet(ctl) {
54 var helpers = this;
55 this.controller = ctl;
56 this._contents = ctl ? new ContentsBuffer() : globalContents;
57 this.htmlEscape = true;
58 if (ctl) {
59 this.controllerName = ctl.controllerName;
60 this.actionName = ctl.actionName;
61 this.pathTo = this.path_to = ctl.pathTo;
62 this.t = ctl.t.bind(ctl);
63 this.t.locale = ctl._t && ctl._t.locale;
64 this.htmlEscape = ctl && ctl.compound.app && ctl.compound.app.enabled('escape html');
65 }
66
67 /**
68 * CSRF Meta Tag generation
69 *
70 * @return {String} Meta tags against CSRF-attacks
71 */
72 this.csrfMetaTag = this.csrf_meta_tag = function() {
73 return ctl && ctl.protectedFromForgery() ? [
74 helpers.metaTag('csrf-param', ctl.req.csrfParam),
75 helpers.metaTag('csrf-token', ctl.req.csrfToken)
76 ].join('\n') : '';
77 };
78
79 // for templating engines losing context
80 Object.keys(HelperSet.prototype).forEach(function(name) {
81 helpers[name] = HelperSet.prototype[name].bind(helpers);
82 });
83}
84
85function ContentsBuffer() {
86 var buf = this;
87 Object.keys(globalContents).forEach(function(key) {
88 buf[key] = [];
89 globalContents[key].forEach(function (val) {
90 buf[key].push(val);
91 });
92 });
93}
94
95
96/**
97 * Make helpers local to query
98 *
99 * @param {Object} controller Controller Object.
100 * @return {Object} containing all helpers
101 */
102module.exports.personalize = function(controller) {
103 return new module.exports.HelperSet(controller);
104};
105
106HelperSet.prototype.metaTag = function (name, content, params) {
107 var undef;
108 params = params || {};
109 if (content && typeof content === 'object') {
110 params = content;
111 content = undef || params.content;
112 }
113 if (name && typeof name === 'object') {
114 params = name;
115 name = undef || params.name;
116 content = undef || params.content;
117 }
118 return genericTagSelfclosing('meta', {name: name, content: content}, params);
119};
120
121/**
122 * Return bunch of stylesheets link tags
123 *
124 * Example in ejs:
125 *
126 * <%- stylesheet_link_tag('bootstrap', 'style') %>
127 *
128 * This returns:
129 *
130 * <link media="screen" rel="stylesheet" type="text/css" href="/stylesheets/bootstrap.css" />
131 * <link media="screen" rel="stylesheet" type="text/css" href="/stylesheets/style.css" />
132 *
133 * @param {String} stylesheet filename.
134 * @return {String} HTML code to the stylesheets in the parameters
135 */
136HelperSet.prototype.stylesheetLinkTag = function stylesheetLinkTag() {
137 var args;
138 var app = this.controller ? this.controller.app : null;
139 if (!paths.css || !paths.stylesheets) {
140 paths.css = app && this.controller.app.settings.cssDirectory || '/stylesheets/';
141 paths.stylesheets = paths.css;
142 }
143
144 if (arguments[0] instanceof Array) {
145 args = arguments[0];
146 } else {
147 args = Array.prototype.slice.call(arguments);
148 }
149 var options = {media: 'screen', rel: 'stylesheet', type: 'text/css'};
150 var links = [];
151 if (typeof args[args.length - 1] == 'object') {
152 options = safe_merge(options, args.pop());
153 }
154 mergeFiles(app, 'stylesheets', args).forEach(function(file) {
155 delete options.href;
156 // there should be an option to change the /stylesheets/ folder
157 var href = checkFile(app, 'css', file);
158 links.push(genericTagSelfclosing('link', options, { href: href }));
159 });
160 return links.join('\n ');
161};
162HelperSet.prototype.stylesheet_link_tag = HelperSet.prototype.stylesheetLinkTag;
163
164/**
165 * Generates set of javascript includes composed from arguments
166 *
167 * Example in ejs:
168 *
169 * <%- javascript_include_tag('rails', 'application') %>
170 *
171 * This returns:
172 *
173 * <script type="text/javascript" src="/javascripts/rails.js"></script>
174 * <script type="text/javascript" src="/javascripts/application.js"></script>
175 *
176 * @param {String} script filename.
177 * @return {String} the generated &lt;script&gt; tags
178 */
179HelperSet.prototype.javascriptIncludeTag = function javascriptIncludeTag() {
180 var helpers = this;
181 var args;
182 var app = this.controller ? this.controller.app : null;
183 if (!paths.js || !paths.javascripts) {
184 paths.js = app && this.controller.app.settings.jsDirectory || '/javascripts/';
185 paths.javascripts = paths.js;
186 }
187 if (arguments[0] instanceof Array) {
188 args = arguments[0];
189 } else {
190 args = Array.prototype.slice.call(arguments);
191 }
192 var options = {type: 'text/javascript'};
193 if (typeof args[args.length - 1] == 'object') {
194 options = safe_merge(options, args.pop());
195 }
196 var scripts = [];
197 mergeFiles(app, 'javascripts', args).forEach(function(file) {
198 // there should be an option to change the /javascripts/ folder
199 var href = checkFile(app, 'js', file);
200 delete options.src;
201 scripts.push(helpers.tag('script', '', options, {src: href}));
202 });
203 return scripts.join('\n ');
204};
205HelperSet.prototype.javascript_include_tag = HelperSet.prototype.javascriptIncludeTag;
206
207/**
208 * Merge files when caching enabled
209 *
210 * You can enable merging manually with the following configuration:
211 *
212 * app.set('merge javascripts')
213 * app.set('merge stylesheets')
214 *
215 * @param {String} scope Scope which is merged, e.g. javascripts or stylesheets.
216 * @param {Array} files Array of files which should be merged.
217 * @see https://github.com/1602/express-on-railway/issues/152
218 * @return {String} Pathname to the merged file
219 */
220function mergeFiles(app, scope, files) {
221 // ensure that feature is enabled
222 if (!app || app.disabled('merge ' + scope)) {
223 return files;
224 }
225 var ext = merged[scope].ext,
226 result = [],
227 shasum = crypto.createHash('sha1'),
228 minify = [],
229 directory = merged[scope].directory = paths[scope].replace(/^\/|\/$/g, '');
230 // only merge local files
231 files.forEach(function(file) {
232 if (!regexps.isHttp.test(file)) {
233 shasum.update(file);
234 minify.push(file);
235 } else {
236 result.push(file);
237 }
238 });
239
240 // calculate name of new script based on names of merged files
241 var digest = shasum.digest('hex');
242 // check cache state (undefined = not cached, false = cache in progress, String = cached)
243 var cached = merged[scope][digest];
244 var root = app.get(ext.substr(1) + ' app root') || app.root;
245 if (cached || !fs.createWriteStream) {
246 // push resulted filename to result
247 result.push('cache_' + digest);
248 } else if (fs.existsSync(path.join(root, 'public', directory))) {
249 // if caching process is not started yet
250 if (!fs.existsSync(path.join(root, 'public', directory, 'cache_' + digest + ext))) {
251 // write resulted script as merged `minify` files
252 var stream = fs.createWriteStream(path.join(root, 'public', directory, 'cache_' + digest + ext));
253 var counter = 0;
254 var fileContents = {};
255 minify.forEach(function(file) {
256 var filename = path.join(root, 'public', directory, file + ext);
257 exists(filename, function(exists) {
258 if (exists) {
259 counter += 1;
260
261 fs.readFile(filename, 'utf8', function(err, data) {
262 fileContents[file] = data;
263 done();
264 });
265 }
266 });
267 });
268 function done() {
269 if (--counter === 0) {
270 minify.forEach(function(file) {
271 data = fileContents[file];
272 stream.write('/* /' + directory + '/' + file + ext + ' */ \n');
273 stream.write(data + '\n');
274 });
275
276 stream.end();
277 }
278 }
279 // save name of resulted file to the merge scope registry
280 stream.on('close', function() {
281 merged[scope][digest] = ['cache', digest].join('_');
282 });
283
284 result.push(['cache', digest].join('_'));
285 } else {
286 merged[scope][digest] = ['cache', digest].join('_');
287 }
288 }
289 return result;
290}
291
292/**
293 * Link helper
294 *
295 * Example in ejs:
296 *
297 * <%- link_to("Home", '/') %>
298 *
299 * This returns:
300 *
301 * <a href="/">Home</a>
302 *
303 * @param {String} text Text of the link.
304 * @param {String} url Url where the link points to.
305 * @param {Object} params Set of html params (class, style, etc..).
306 * @return {String} Generated html for link
307 */
308HelperSet.prototype.linkTo = function linkTo(text, url, params) {
309 ['remote', 'method', 'jsonp', 'confirm'].forEach(dataParam.bind(params));
310 return this.tag('a', text, {href: url}, params);
311};
312HelperSet.prototype.link_to = HelperSet.prototype.linkTo;
313
314HelperSet.prototype.linkToRemote = function linkToRemote(text, url, params) {
315 params = params || {};
316 params.remote = true;
317 return this.linkTo(text, url, params);
318};
319
320/**
321 * Link helper if not in the current url
322 *
323 * @param {String} text
324 * @param {String} url
325 * @param {Object} params - set of html params (class, style, etc..)
326 *
327 * <a href="url">text</a>
328 */
329HelperSet.prototype.linkToIfNotCurrent = function linkTo(text, url, params) {
330 if (url && url[0]=='/') url = url.substring(1); //trim first '/' if exists
331 return (url.toLowerCase() == _url.parse( this.controller.request.url ).pathname.substring(1).toLowerCase() ) ? text : HelperSet.prototype.link_to(text, url, params) ;
332};
333HelperSet.prototype.link_to_if_not_current = HelperSet.prototype.linkToIfNotCurrent;
334
335/**
336 * Form tag helper
337 *
338 * @methodOf HelperSet.prototype
339 * @param {Object} params
340 * @param {Function} block
341 */
342HelperSet.prototype.formTag = function(params, block) {
343 throw new Error('Helpers formTag and formFor(with block) are deprecated, use',
344 'block-less version of formFor helper, or formTagBegin and formTagEnd tags');
345};
346HelperSet.prototype.form_tag = HelperSet.prototype.formTag;
347
348HelperSet.prototype.formTagRemote = function(params) {
349 params = params || {};
350 params.remote = true;
351 return this.formTag(params);
352};
353
354/**
355 * Prints error messages for the model instance
356 *
357 * @methodOf HelperSet.prototype
358 * @param {ModelInstance} resource
359 * @param {Object} params Custom message params
360 * @returns {String} Error messages from the model instance
361 */
362HelperSet.prototype.errorMessagesFor = function errorMessagesFor(resource, params) {
363 var out = '';
364 var h = this;
365 params = params || {};
366
367 if (resource.errors) {
368 var cls = (params.class) ? params.class : 'alert alert-error';
369 out += this.tag('div', this.html(printErrors()), {class: cls});
370 }
371
372 return out;
373
374 function printErrors() {
375 var msg = (params.message) ? params.message : 'Validation failed. Fix the following errors to continue:';
376 var out = '<p>';
377 out += h.tag('strong', msg);
378 out += '</p>';
379 for (var prop in resource.errors) {
380 if (resource.errors.hasOwnProperty(prop)) {
381 out += '<ul>';
382 resource.errors[prop].forEach(function (msg) {
383 out += h.tag('li', utils.camelize(prop, true) + ' ' + msg, {class: 'error-message'});
384 });
385 out += '</ul>';
386 }
387 }
388 return out;
389 }
390};
391
392/**
393 * Form fields for resource helper
394 *
395 * @methodOf HelperSet.prototype
396 * @param {ModelInstance} resource
397 * @param {Function} block
398 * @namespace
399 */
400HelperSet.prototype.fieldsFor = function (resource, formParams) {
401 return new FormForResource(resource || {}, formParams, null, this);
402};
403
404
405
406/**
407 * Form for resource helper
408 *
409 * @methodOf HelperSet.prototype
410 * @param {ModelInstance} resource
411 * @param {Object} params
412 * @param {Function} block
413 */
414HelperSet.prototype.formFor = function formFor(resource, params, block) {
415 var self = this;
416
417 if (resource && resource.constructor && resource.constructor.modelName) {
418 if (typeof params !== 'object') {
419 params = {};
420 }
421 if (!params.method) {
422 params.method = resource && resource.id ? 'PUT' : 'POST';
423 }
424 if (!params.action) {
425 params.action = this.controller.app.compound.map.pathTo[utils.underscore(resource.constructor.modelName)](resource);
426 }
427 }
428
429 /**
430 * If we don't have a block we don't want any output per default
431 */
432 if (block) {
433 this.formTag(params, function () {
434 if (block) self.fieldsFor(resource, params, block);
435 });
436 } else {
437 // No block function given, just return our field creator hash
438 return self.fieldsFor(resource, params);
439 }
440};
441HelperSet.prototype.form_for = HelperSet.prototype.formFor;
442
443HelperSet.prototype.formForRemote = function(resource, params) {
444 params = params || {};
445 params.remote = true;
446 return this.formFor(resource, params);
447};
448
449/**
450 * Form tag begin helper
451 *
452 * @methodOf HelperSet.prototype
453 * @param {Object} params - set of tag attributes
454 * @returns {String} Form tag with csrfTag as well as method tag
455 */
456HelperSet.prototype.formTagBegin = function (params) {
457 // default method is POST
458 if (!params.method) {
459 params.method = 'POST';
460 }
461
462 // hook up alternative methods (PUT, DELETE)
463 var method = params.method.toUpperCase();
464 var _method = method;
465
466 if (method != 'GET' && method != 'POST') {
467 _method = method;
468 params.method = 'POST';
469 }
470
471 // hook up data-params
472 ['remote', 'jsonp', 'confirm'].forEach(dataParam.bind(params));
473
474 // push output
475 var html = '<form' + htmlTagParams(params) + '>';
476 html += this.csrfTag();
477
478 // alternative method
479 if(_method !== params.method) {
480 html += HelperSet.prototype.inputTag({type: "hidden", name: "_method", value: _method });
481 }
482 return html;
483};
484
485HelperSet.prototype.formTagRemoteBegin = function(params) {
486 params = params || {};
487 params.remote = true;
488 return this.formTagBegin(params);
489};
490
491/**
492 * Form tag end helper
493 *
494 * @methodOf HelperSet.prototype
495 * @returns {String} Closing tag for form
496 */
497HelperSet.prototype.formTagEnd = function (params) {
498 return '</form>';
499};
500
501/**
502 * Input tag helper
503 *
504 * @methodOf HelperSet.prototype
505 * @param {String} text - inner html.
506 * @param {Object} params - set of tag attributes.
507 * @param {Object} override - set params to override params in previous arg.
508 * @returns {String} Finalized input tag
509 */
510HelperSet.prototype.inputTag = function (params, override) {
511 return '<input' + htmlTagParams(params, override) + ' />';
512};
513HelperSet.prototype.input_tag = HelperSet.prototype.inputTag;
514
515/**
516 * Textarea tag helper
517 *
518 * @methodOf HelperSet.prototype
519 * @param {String} text - inner html.
520 * @param {Object} params - set of tag attributes.
521 * @param {Object} override - set params to override params in previous arg.
522 * @returns {String} Finalized input tag
523 */
524HelperSet.prototype.textareaTag = function (value, params, override) {
525 if (typeof value === 'object') {
526 params = value;
527 override = params;
528 value = params.value;
529 }
530 return this.tag('textarea', value || '', params, override);
531};
532
533/**
534 * Label tag helper
535 *
536 * Result:
537 *
538 * <label>text</label>
539 *
540 * @methodOf HelperSet.prototype
541 * @param {String} text - inner html
542 * @param {Object} params - set of tag attributes
543 * @param {Object} override - set params to override params in previous arg
544 * @returns {String} Finalized label tag
545 */
546HelperSet.prototype.labelTag = function (text, params, override) {
547 return this.tag('label', text, params, override);
548};
549HelperSet.prototype.label_tag = HelperSet.prototype.labelTag;
550
551/**
552 * Submit tag helper
553 *
554 * Result:
555 *
556 * <button class="btn">Text</button>
557 *
558 * @param {String} text - value (text on button).
559 * @param {Object} params - set of tag attributes.
560 * @returns {String} Finalized input tag.
561 */
562HelperSet.prototype.submitTag = function (text, params) {
563 return this.inputTag({value: text, type: 'submit'}, params);
564};
565
566/**
567 * Button tag helper
568 *
569 * Usage (ejs):
570 *
571 * <%- buttonTag('Text', {class: 'btn'}) %>
572 *
573 * Result:
574 *
575 * <button class="btn">Text</button>
576 *
577 * @param {String} text - value (text on button).
578 * @param {Object} params - set of tag attributes.
579 * @returns {String} Finalized input tag.
580 */
581HelperSet.prototype.buttonTag = function (text, params) {
582 return this.tag('button', text, params);
583};
584
585/**
586 * Cross-site request forgery hidden inputs
587 *
588 * @methodOf HelperSet.prototype
589 * @returns {String} CSRF-Tag with parameters
590 */
591HelperSet.prototype.csrfTag = function () {
592 return '<input type="hidden" name="' + this.controller.req.csrfParam + '" value="' + this.controller.req.csrfToken + '" />';
593};
594HelperSet.prototype.csrf_tag = HelperSet.prototype.csrfTag;
595
596/**
597 * Select tag helper
598 *
599 * Result:
600 *
601 * <select>innerOptions</select>
602 *
603 * @methodOf HelperSet.prototype
604 * @author [Uli Wolf](https://github.com/SirUli)
605 * @param {String} innerOptions Inner html of the select tag
606 * @param {Object} params Set of tag attributes
607 * @param {Object} override Set params to override params in previous arg
608 * @returns {String} Finalized select tag
609 */
610HelperSet.prototype.selectTag = function (innerOptions, params, override) {
611 return this.tag('select', html(innerOptions), params, override);
612};
613HelperSet.prototype.select_tag = HelperSet.prototype.selectTag;
614
615/**
616 * Option tag helper
617 *
618 * Result:
619 *
620 * <option>text</option>
621 *
622 * @methodOf HelperSet.prototype
623 * @author [Uli Wolf](https://github.com/SirUli)
624 * @param {String} text Inner html
625 * @param {Object} params Set of tag attributes
626 * @param {Object} override Set params to override params in previous arg
627 * @returns {String} Finalized option tag
628 */
629HelperSet.prototype.optionTag = function (text, params, override) {
630 return this.tag('option', text, params, override);
631};
632HelperSet.prototype.option_tag = HelperSet.prototype.optionTag;
633
634/**
635 * This helper returns method which calculates matching his single argument with
636 * pattern and returns second arg
637 * in case if result true, otherwise it returns third argument
638 *
639 * Example:
640 *
641 * var item = matcher(pageName, '<li class="active">', '<li>');
642 * item('home') + 'Home</li>'
643 * item('about-us') + 'About us</li>'
644 */
645HelperSet.prototype.matcher = function (pattern, positive, negative) {
646 negative = negative || '';
647 return function (value) {
648 return value === pattern ? positive : negative;
649 };
650};
651
652HelperSet.prototype.icon = function (type, params) {
653 return this.tag('i', '', {class: 'icon-' + type}, params) + ' ';
654};
655
656HelperSet.prototype.imageTag = function (src, params) {
657 return genericTagSelfclosing('img', {src: src}, params);
658};
659
660/**
661 * Anchor tag
662 *
663 * Example:
664 *
665 * <%- anchor('some-thing') %>
666 * <a name="some-thing"></a>
667 */
668HelperSet.prototype.anchor = function anchor(name, params) {
669 params = params || {};
670 params.name = name;
671 return this.linkTo('', '', params);
672};
673
674/**
675 * Content for named section.
676 *
677 * Called with one param acts as getter and returns all content pieces,
678 * collected before. Called with two params accumulates second param in named
679 * collection.
680 *
681 * Examples:
682 *
683 * In layout:
684 *
685 * <%- contentFor('javascripts') %>
686 *
687 * In view:
688 *
689 * <% contentFor('javascripts', javascriptIncludeTag('view-specific')) %>
690 *
691 * This will add some view-specific content to layout.
692 * This method also could be called from controller.
693 */
694HelperSet.prototype.contentFor = function contentFor(name, content) {
695 if (content) {
696 this._contents[name] = this._contents[name] || [];
697 this._contents[name].push(content);
698 } else {
699 return (this._contents[name] || []).join('');
700 }
701};
702
703
704/**
705 * Private util methods
706 */
707
708/**
709 * Returns html code of one tag with contents
710 *
711 * @param {String} name name of tag
712 * @param {String} inner inner html
713 * @param {Object} params set of tag attributes
714 * @param {Object} override set params to override params in previous arg
715 * @returns {String} Finalized generic tag
716 */
717function genericTag(name, inner, params, override) {
718 return html('<' + name + htmlTagParams(params, override) + '>' + this.text(inner) + '</' + name + '>');
719}
720HelperSet.prototype.tag = genericTag;
721
722function html(res) {
723 res = new String(res);
724 res.toHtmlString = function() {
725 return this;
726 };
727 return res;
728}
729HelperSet.prototype.html = html;
730
731/**
732 * Returns html code of a selfclosing tag
733 *
734 * @param {String} name name of tag
735 * @param {Object} params set of tag attributes
736 * @param {Object} override set params to override params in previous arg
737 * @returns {String} Finalized generic selfclosing tag
738 */
739function genericTagSelfclosing(name, params, override) {
740 return html('<' + name + htmlTagParams(params, override) + ' />');
741}
742
743
744/**
745 * Prefixes key with 'data-'
746 *
747 * @param {String} key name of key
748 */
749function dataParam(key) {
750 if (this[key]) {
751 this['data-' + key] = this[key];
752 delete this[key];
753 }
754}
755
756/**
757 * Escape &, < and > symbols
758 *
759 * @param {String} html String with possible HTML-Elements
760 * @returns {String} resulting string with escaped characters
761 */
762function sanitizeHTML(text) {
763 if (!this.htmlEscape) return text;
764 if (typeof text === 'object') {
765 if (text instanceof String && text.toHtmlString) {
766 return text.toHtmlString();
767 }
768 text = JSON.stringify(text, null, ' ');
769 }
770 return text.replace(/&/g, '&amp;').replace(/>/g, '&gt;').replace(/</g, '&lt;');
771}
772HelperSet.prototype.sanitize = sanitizeHTML;
773HelperSet.prototype.text = sanitizeHTML;
774
775/**
776 * Provides the link to a file. Checks if a file needs to be suffixed with a timestamp
777 *
778 * @param {String} type Type of the file, e.g. css or js
779 * @param {String} file name (local file) or link (external) to the file
780 * @returns {String} Final Link to the file
781 */
782function checkFile(app, type, file) {
783 var isExternalFile = regexps.isHttp.test(file),
784 isCached = file.match(regexps.cached),
785 href = !isExternalFile ? paths[type] + file + exts[type] : file;
786 var appprefix;
787 if (!app) {
788 appprefix = '';
789 } else if (app.path) {
790 appprefix = app.path();
791 } else {
792 appprefix = app.set('basepath') || '';
793 }
794 return isExternalFile ? href : appprefix + href;
795}
796