UNPKG

apostrophe

Version:

The Apostrophe Content Management System.

868 lines (738 loc) • 32.8 kB
// Implements template rendering via Nunjucks. **You should use the // `self.render` and `self.partial` methods of *your own* module**, // which exist courtesy of [apostrophe-module](/reference/modules/apostrophe-module) // and invoke methods of this module more conveniently for you. // // You may have occasion to call `self.apos.templates.safe` when // implementing a helper that returns a value that should not be // escaped by Nunjucks. You also might call `self.apos.templates.filter` to // add a new filter to Nunjucks. // // ## Options // // ### `filters`: an object in which // each key is the name of a Nunjucks filter and // its corresponding value is a function that implements it. // You may find it easier and more maintainable to call `apos.templates.addFilter(name, fn)`. // // ### `language`: your own alternative to the object // returned by require('nunjucks'). Replacing Nunjucks // entirely in Apostrophe would be a vast undertaking, but perhaps // you have a custom version of Nunjucks that is compatible. // // ### `viewsFolderFallback`: specifies a folder or an array of folders to be checked for templates // if they are not found in the module that called `self.render` or `self.partial` // or those it extends. This is a handy place for project-wide macro files. // Often set to `__dirname + '/views'` in `app.js`. var _ = require('@sailshq/lodash'); var moment = require('moment'); var qs = require('qs'); module.exports = { alias: 'templates', afterConstruct: function(self) { self.enableHelpers(); }, construct: function(self, options) { self.templateApos = { modules: {}, log: function(msg) { self.apos.utils.log.apply(self.apos, arguments); }, prefix: self.apos.prefix }; self.helperShortcuts = {}; self.filters = {}; self.nunjucks = options.language || require('@apostrophecms/nunjucks'); // Add helpers in the namespace for a particular module. // They will be visible in nunjucks at // apos.modules[module-name].helperName. If the alias // option for the module is set to "shortname", then // they are also visible as apos.shortname.helperName. // Note that the alias option must be set only by the // project-level developer (except for core modules). self.addHelpersForModule = function(module, object /* or module, name, value */) { var helpersForModules = self.templateApos.modules; helpersForModules[module.__meta.name] = helpersForModules[module.__meta.name] || {}; var helpersForModule = helpersForModules[module.__meta.name]; if (typeof (object) === 'string') { helpersForModule[arguments[1]] = arguments[2]; } else { _.merge(helpersForModule, object); } }; self.addHelperShortcutForModule = function(module, name) { self.helperShortcuts[module.__meta.name] = self.helperShortcuts[module.__meta.name] || []; self.helperShortcuts[module.__meta.name].push(name); }; // The use of this method is restricted to core modules // and should only be used for `apos.area`, `apos.singleton`, // and anything we later decide is at least that important. // Everything else should be namespaced at all times, // at least under its module alias. self.addShortcutHelper = function(name, value) { self.shortcutHelpers[name] = value; }; // When all modules have finished adding helpers, wrap all // helper functions so that the true line numbers responsible // for any errors can be seen in the error logs. Also apply // module aliases, make the options passed to the module available // in nunjucks, and apply any helper shortcuts self.modulesReady = function() { wrapFunctions(self.templateApos); _.each(self.templateApos.modules, function(helpers, moduleName) { var alias = self.apos.modules[moduleName].options.alias; if (alias) { if (_.has(self.templateApos, alias)) { throw new Error('The module ' + moduleName + ' has the alias ' + alias + ' which conflicts with core functionality. Change the alias.'); } self.templateApos[alias] = helpers; } }); _.each(self.templateApos.modules, function(helpers, moduleName) { helpers.options = self.apos.modules[moduleName].options; }); _.each(self.helperShortcuts, function(list, moduleName) { _.each(list, function(name) { self.templateApos[name] = self.templateApos.modules[moduleName][name]; }); }); function wrapFunctions(object) { _.each(object, function(value, key) { if (typeof (value) === 'object') { wrapFunctions(value); } else if (typeof (value) === 'function') { object[key] = function() { return value.apply(self, arguments); }; } }); } }; // Add new filters to the Nunjucks environment. You // can add many by passing an object with named // properties, or add just one by passing a name // and a function. You can also do this through the // filters option of this module. self.addFilter = function(object /* or name, fn */) { if (typeof (object) === 'string') { self.filters[arguments[0]] = arguments[1]; } else { _.extend(self.filters, object); } }; // return a string which will not be escaped // by Nunjucks. Call this in your helper function // when your return value contains markup and you // are absolutely sure that any user input has // been correctly escaped already. self.safe = function(s) { return new self.nunjucks.runtime.SafeString(s); }; // Load and render a Nunjucks template, internationalized // by the given req object. The template with the name // specified is loaded from the views folder of the // specified module or its superclasses; the deepest // version of the template wins. You normally won't call // this directly; you'll call self.render on your module. // Apostrophe Nunjucks helpers such as `apos.area` are // attached to the `apos` object in your template. // Data passed in your `data` object is provided as the // `data` object in your template, which also contains // properties of `req.data` and `module.templateData`, // if those objects exist. // If there is a conflict, your `data` argument wins, // followed by `req.data`. // If not overridden, `data.user` and `data.permissions` // are provided for convenience. // The .html extension is assumed. self.renderForModule = function(req, name, data, module) { if (typeof (req) !== 'object') { throw new Error('The first argument to module.render must be req. If you are trying to implement a Nunjucks helper function, use module.partial.'); } return self.renderBody(req, 'file', name, data, module); }; // Works just like self.render, except that the // entire template is passed as a string rather than // a filename. self.renderStringForModule = function(req, s, data, module) { if (typeof (req) !== 'object') { throw new Error('The first argument to module.render must be req. If you are trying to implement a Nunjucks helper function, use module.partial.'); } return self.renderBody(req, 'string', s, data, module); }; self.partialForModule = function(name, data, module) { var req = self.contextReq; if (!req) { throw new Error('partial() must always be called from within a Nunjucks helper function invoked via a Nunjucks template. If you are rendering a template in your own route, use render() and pass req at the first argument.'); } return self.safe(self.renderForModule(req, name, data, module)); }; self.partialStringForModule = function(name, data, module) { var req = self.contextReq; if (!req) { throw new Error('partialString() must always be called from within a Nunjucks helper function invoked via a Nunjucks template. If you are rendering a template in your own route, use renderString() and pass req at the first argument.'); } return self.safe(self.renderStringForModule(req, name, data, module)); }; // Stringify the data as JSON, then escape any sequences // that would cause a `script` tag to end prematurely if // the JSON were embedded in it. Also make sure the JSON is // JS-friendly by escaping unicode line and paragraph // separators. // // If the argument is `undefined`, `"null"` is returned. This is // better than the behavior of JSON.stringify (which returns // `undefined`, not "undefined") while still being JSONic // (`undefined` is not valid in JSON). self.jsonForHtml = function(data) { if (data === undefined) { return 'null'; } data = JSON.stringify(data); // , null, ' '); data = data.replace(/<!--/g, '<\\!--'); data = data.replace(/<\/script>/gi, '<\\/script>'); // unicode line separator and paragraph separator break JavaScript parsing data = data.replace(/\u2028/g, "\\u2028"); data = data.replace(/\u2029/g, "\\u2029"); return data; }; // Implements `render` and `renderString`. See their // documentation. self.renderBody = function(req, type, s, data, module) { if (self.contextReq && (req !== self.contextReq)) { throw new Error('render() must not be called from a Nunjucks helper function nested inside another call to render(). Use partial() instead.'); } try { // "OMG, a global variable?" Yes, it's safe for the // duration of a single synchronous render operation, // which allows partial() to be called without a req. // // However note that partialForModule calls // renderForModule, so we track the depth of // those calls to avoid clearing contextReq // prematurely if (!self.renderDepth) { self.renderDepth = 0; self.contextReq = req; } self.renderDepth++; var merged = {}; if (data) { _.defaults(merged, data); } var args = {}; args.data = merged; args.module = self.templateApos.modules[module.__meta.name]; // // Allows templates to render other templates in an independent // // nunjucks environment, rather than including them // args.partial = function(name, data) { // return self.partialForModule(name, data, module); // }; if (req.data) { _.defaults(merged, req.data); } _.defaults(merged, { user: req.user, permissions: (req.user && req.user._permissions) || {} }); if (module.templateData) { _.defaults(merged, module.templateData); } args.data.locale = args.data.locale || req.locale; var result; var env = self.getEnv(module); // "Why are you using addGlobal here?" Because Apostrophe // developers expect these things (well, at least apos and __) // to be visible in all templates, including imported macro // templates. // // "Is this safe?" Yes, because we call nunjucks // synchronously. // // "Can we push `data` this way?" No. That would mess // up nested nunjucks render calls (partials). If you want // data in your imported macros you'll just have to pass it in. env.addGlobal('apos', self.templateApos); var fns = [ '__ns', '__ns_n', '__ns_mf', '__ns_l', '__ns_h', '__', '__n', '__mf', '__l', '__h' ]; _.each(fns, function(name) { env.addGlobal(name, function(key) { return self.i18n.apply(null, [ req, name ].concat(Array.prototype.slice.call(arguments))); }); }); if (type === 'file') { var finalName = s; if (!finalName.match(/\.\w+$/)) { finalName += '.html'; } result = env.getTemplate(finalName).render(args); } else if (type === 'string') { result = env.renderString(s, args); } else { throw new Error('renderBody does not support the type ' + type); } } catch (e) { self.renderDepth--; if (!self.renderDepth) { delete self.contextReq; } self.apos.utils.error('e.stack: ', e.stack); throw e; }; self.renderDepth--; if (!self.renderDepth) { delete self.contextReq; } return result; }; // Takes `req`, `operation` which will be '__', '__mf' or another // standard helper name provided by the i18n module, and the // arguments intended for that helper, beginning always with `key`. // Invokes the specified operation of the i18n module. This // method exists as an override and extension point for // Apostrophe's static text internationalization features. self.i18n = function(req, operation /* , additional arguments */) { return req.res[operation].apply(req.res, Array.prototype.slice.call(arguments, 2)); }; self.envs = {}; // Fetch a nunjucks environment in which `include`, // `extends`, etc. search the views directories of the // specified module and its ancestors. Typically you // will call `self.render`, `self.renderPage` or // `self.partial` on your module object rather than calling // this directly. self.getEnv = function(module) { var name = module.__meta.name; // Cache for performance if (_.has(self.envs, name)) { return self.envs[name]; } self.envs[name] = self.newEnv(name, self.getViewFolders(module)); return self.envs[name]; }; self.getViewFolders = function(module) { var dirs = _.map(module.__meta.chain, function(entry) { return entry.dirname + '/views'; }); // Final class should win dirs.reverse(); if (options.viewsFolderFallback) { if (typeof options.viewsFolderFallback === "string") { dirs.push(options.viewsFolderFallback); } else { dirs.push(...options.viewsFolderFallback); } } return dirs; }; // Create a new nunjucks environment in which the // specified directories are searched for includes, // etc. Don't call this directly, use: // // apos.templates.getEnv(module) self.newEnv = function(moduleName, dirs) { var loader = self.newLoader(moduleName, dirs, undefined, self); var env = new self.nunjucks.Environment(loader, { autoescape: true }); self.addStandardFilters(env); _.each(self.filters, function(filter, name) { env.addFilter(name, filter); }); if (self.options.filters) { _.each(self.options.filters, function(filter, name) { env.addFilter(name, filter); }); } return env; }; // Creates a Nunjucks loader object for the specified // list of directories, which can also call back to // this module to resolve cross-module includes. You // will not need to call this directly. self.newLoader = function(moduleName, dirs) { var NunjucksLoader = require('./lib/nunjucksLoader.js'); return new NunjucksLoader(moduleName, dirs, undefined, self, self.options.loader); }; self.addStandardFilters = function(env) { // Format the given date with the given momentjs // format string. env.addFilter('date', function(date, format, locale) { // Nunjucks is generally highly tolerant of bad // or missing data. Continue this tradition by not // crashing if date is null. -Tom if (!date) { return ''; } var dateLocale = locale || self.apos.templates.contextReq.locale; var m = moment(date); if (dateLocale) { m.locale(dateLocale); } var s = m.format(format); return s; }); // Stringify the given data as a query string. env.addFilter('query', function(data) { return qs.stringify(data || {}); }); // Stringify the given data as JSON, with // additional escaping for safe inclusion // in a script tag. env.addFilter('json', function(data) { return self.safe(self.jsonForHtml(data)); }); // Builds filter URLs. See the URLs module. env.addFilter('build', self.apos.urls.build); // Remove HTML tags from string, leaving only // the text. All lower case to match jinja2's naming. env.addFilter('striptags', function(data) { return data.replace(/(<([^>]+)>)/ig, ""); }); // Convert newlines to <br /> tags. env.addFilter('nlbr', function(data) { data = self.apos.utils.globalReplace(data, "\n", "<br />\n"); return self.apos.templates.safe(data); }); // Newlines to paragraphs, produces better spacing and semantics env.addFilter('nlp', function(data) { if ((data === null) || (data === undefined)) { // don't crash, nunjucks tolerates nulls return ''; } var parts = data.toString().split(/\n/); var output = _.map(parts, function(part) { return '<p>' + part + '</p>\n'; }).join(''); return output; }); // Convert the camelCasedString s to a hyphenated-string, // for use as a CSS class or similar. env.addFilter('css', function(s) { return self.apos.utils.cssName(s); }); env.addFilter('clonePermanent', function(o, keepScalars) { return self.apos.utils.clonePermanent(o, keepScalars); }); // Output "data" as JSON, escaped to be safe in an // HTML attribute. By default it is escaped to be // included in an attribute quoted with double-quotes, // so all double-quotes in the output must be escaped. // If you quote your attribute with single-quotes // and pass { single: true } to this filter, // single-quotes in the output are escaped instead, // which uses dramatically less space and produces // more readable attributes. // // EXCEPTION: if the data is not an object or array, // it is output literally as a string. This takes // advantage of jQuery .data()'s ability to treat // data attributes that "smell like" objects and arrays // as such and take the rest literally. env.addFilter('jsonAttribute', function(data, options) { if (typeof (data) === 'object') { return self.safe(self.apos.utils.escapeHtml(JSON.stringify(data), options)); } else { // Make it a string for sure data += ''; return self.safe(self.apos.utils.escapeHtml(data, options)); } }); env.addFilter('merge', function(data /* ,obj2, obj3... */) { var output = {}; var i; for (i = 0; (i < arguments.length); i++) { _.assign(output, arguments[i]); } return output; }); // "Blesses" a particular options object, remembering that it is acceptable // for API calls during the same session that check their options with // apos.utils.isBlessed and the same additional arguments. See // self.apos.utils.bless. env.addFilter('bless', function(options /* , 'widget', widget.type... */) { var args = [ self.contextReq ]; var i; for (i = 0; (i < arguments.length); i++) { args.push(arguments[i]); } self.apos.utils.bless.apply(self.apos.utils, args); return options; }); }; // Typically you will call the `sendPage` method of // your own module, provided by the `apostrophe-module` // base class, which is a wrapper for this method. // // Send a complete HTML page for to the // browser. // // If `template` is a function it is passed a data object, // otherwise it is rendered as a nunjucks template relative // to this module via self.render. // // `data` is provided to the template, with additional // default properties as described below. // // `module` is the module from which the template should // be rendered, if an explicit module name is not part // of the template name. // // Additional properties merged with the `data object: // // "outerLayout" is set to... // // `apostrophe-templates:outerLayout.html` // // Or: // // `apostrophe-templates:refreshLayout.html` // // This allows the template to handle either a content area // refresh or a full page render just by doing this: // // `{% extend outerLayout %}` // // Note the lack of quotes. // // Under **any** of the following conditions, "refreshLayout.html" // is used in place of "outerLayout.html": // // * `req.xhr` is true (always set on AJAX requests by jQuery) // * `req.query.xhr` is set to simulate an AJAX request // * `req.decorate` is false // * `req.query.apos_refresh` is true // // These default properties are also provided on the `data` object // visible in Nunjucks: // // * `url` (`req.url`) // * `user` (`req.user`) // * `query` (`req.query`) // * `permissions` (`req.user._permissions`) // * `refreshing` (true if we are refreshing the content area of the page without reloading) // * `js.globalCalls` (javascript markup to insert all global pushed javascript calls) // * `js.reqCalls` (javascript markup to insert all req-specific pushed javascript calls) self.renderPageForModule = function(req, template, data, module) { var content; var scene = req.user ? 'user' : 'anon'; if (req.scene) { scene = req.scene; } var globalCalls = self.apos.push.getBrowserCalls('always'); if (scene === 'user') { globalCalls += self.apos.push.getBrowserCalls('user'); } // Always the last call; signifies we're done initializing the // page as far as the core is concerned; a lovely time for other // modules and project-level javascript to do their own // enhancements. // // This method emits a 'ready' event, and also // emits an 'enhance' event with the entire $body // as its argument. // // Waits for DOMready to give other // things maximum opportunity to happen. var decorate = !(req.query.apos_refresh || req.query.xhr || req.xhr || (req.decorate === false)); if (req.query.apos_refresh) { // Trigger the apos.ready and apos.enhance events after the // DOM settles and pushed javascript has had a chance to run; // do it just in the context of the refreshed main content div req.browserCall('apos.pageReadyWhenCalm($("[data-apos-refreshable]"))'); } else if (decorate) { // Trigger the apos.ready and apos.enhance events after the // DOM settles and pushed javascript has had a chance to run req.browserCall('apos.pageReadyWhenCalm($("body"));'); } else { // If we're ajaxing something other than data-apos-refreshable, // responsibility for progressive enhancement falls on the developer } // JavaScript may want to know who the user is. Provide // just a conservative set of basics for security. Devs // who want to know more about the user in browserland // can push more data and it'll merge if (req.user) { req.browserCall('apos.user = ?;', _.pick(req.user, 'title', '_id', 'username')); } req.browserCall('apos.scene = ?', scene); var reqCalls = req.getBrowserCalls(); var urls = require('url'); // data.url will be the original requested page URL, for use in building // relative links, adding or removing query parameters, etc. If this is a // refresh request, we remove that so that frontend templates don't build // URLs that also refresh var dataUrl = req.url; var parsed = urls.parse(req.url, true); if (parsed.query && parsed.query.apos_refresh) { delete parsed.query.apos_refresh; delete parsed.search; dataUrl = urls.format(parsed); } var args = { outerLayout: decorate ? 'apostrophe-templates:outerLayout.html' : 'apostrophe-templates:refreshLayout.html', permissions: (req.user && req.user._permissions) || {}, when: scene, js: { globalCalls: self.safe(globalCalls), reqCalls: self.safe(reqCalls) }, refreshing: req.query && (!!req.query.apos_refresh), // Make the query available to templates for easy access to // filter settings etc. query: req.query, url: dataUrl }; _.extend(args, data); if (req.aposError) { // A 500-worthy error occurred already, i.e. in `pageBeforeSend` return error(req.aposError, 'template'); } try { if (typeof (template) === 'string') { content = module.render(req, template, args); } else { content = template(req, args); } } catch (e) { // The page template // threw an exception. Log where it // occurred for easier debugging return error(e, 'template'); } return content; function error(e, type) { var now = Date.now(); now = moment(now).format("YYYY-MM-DDTHH:mm:ssZZ"); self.apos.utils.error(':: ' + now + ': ' + type + ' error at ' + req.url); self.apos.utils.error('Current user: ' + (req.user ? req.user.username : 'none')); self.apos.utils.error(e); req.statusCode = 500; return self.render(req, 'templateError'); } }; // Add a body class or classes to be emitted when the page is rendered. This information // is attached to `req.data`, where the string `req.data.aposBodyClasses` is built up. // The default `outerLayoutBase.html` template outputs that string. // The string passed may contain space-separated class names. self.addBodyClass = function(req, bodyClass) { req.data.aposBodyClasses = (req.data.aposBodyClasses ? (req.data.aposBodyClasses + ' ') : '') + bodyClass; }; // Add a body attribute to be emitted when the page is rendered. This information // is attached to `req.data`, where `req.data.aposBodyDataAttributes` is built up // using `name` as the attribute name which is automatically prepended with "data-" // and the optional `value` argument // Also receives an object with key/pair values which becomes attribute name(s) and value(s) // The default `outerLayoutBase.html` template outputs that string. self.addBodyDataAttribute = function(req, name, value = '') { var values = {}; if (_.isObject(name) && !_.isArray(name) && !_.isFunction(name)) { values = name; } else { if (name && name.toString().length > 0 && value && value.toString().length > 0) { values[name] = value; } } _.each(values, (value, key) => { if (_.isEmpty(key)) { return; } req.data.aposBodyDataAttributes = (req.data.aposBodyDataAttributes ? (req.data.aposBodyDataAttributes + ' ') : ' ') + ('data-' + (!_.isUndefined(value) && value.toString().length > 0 ? (self.apos.utils.escapeHtml(key) + ('="' + self.apos.utils.escapeHtml(value) + '"')) : self.apos.utils.escapeHtml(key))); }); }; self.insertions = {}; // Use this method to provide a function that will be invoked at the point // in the page layout identified by the string `location`. Standard locations // are `head`, `body`, `main` and `contextMenu`. // // The page layout, template or outerLayout must contain a corresponding // `apos.templates.prepended('location')` call, with the same location, to // actually insert the content. // // **Your function is called once per request,** and will receive `req` as an argument // as a convenience. Since page rendering is in progress, `req` will be equal to // `apos.templates.contextReq`. // // The output of functions added with `prepend` is prepended just after the // opening tag of an element, such as `<head>`. Use `append` to insert material // before the closing tag. // // This method is most often used when writing a module that adds new UI // to Apostrophe and allows you to add that markup without forcing // developers to customize their layout for your module to work. self.prepend = function(location, helperFn) { if ((typeof helperFn) === 'string') { throw new Error('Do not pass a string to apos.templates.prepend. Pass a function which returns a string and optionally takes a req argument.'); } return self.insert('prepend', location, helperFn); }; // Use this method to provide a function that will be invoked at the point // in the page layout identified by the string `location`. Standard locations // are `head`, `body`, `main` and `contextMenu`. // // The page layout, template or outerLayout must contain a corresponding // `apos.templates.prepended('location')` call, with the same location, to // actually insert the content. // // **Your function is called once per request,** and will receive `req` as an argument // as a convenience. Since page rendering is in progress, `req` will be equal to // `apos.templates.contextReq`. // // The output of functions added with `append` is appended just before the // closing tag of an element, such as `</head>`. Use `prepend` to insert material // after the opening tag. // // This method is most often used when writing a module that adds new UI // to Apostrophe and allows you to add that markup without forcing // developers to customize their layout for your module to work. self.append = function(location, helperFn) { if ((typeof helperFn) === 'string') { throw new Error('Do not pass a string to apos.templates.append. Pass a function which returns a string and optionally takes a req argument.'); } return self.insert('append', location, helperFn); }; // Implementation detail of `apos.templates.prepend` and `apos.templates.append`. self.insert = function(end, location, helperFn) { var key = end + '-' + location; self.insertions[key] = self.insertions[key] || []; self.insertions[key].push(helperFn); }; // Implementation detail of `apos.templates.prepended` and `apos.templates.appended`. self.inserted = function(end, location) { var key = end + '-' + location; return self.apos.templates.safe(_.map(self.insertions[key] || [], function(helperFn) { return helperFn(self.apos.templates.contextReq); }).join('\n')); }; // Invokes all of the `prepend` helper functions registered for the given // location and returns the resulting markup as a Nunjucks "safe" string // (HTML tags are allowed). Available as the `apos.templates.prepended` // helper, and invoked as such by `outerLayoutBase.html`. self.prepended = function(location) { return self.inserted('prepend', location); }; // Invokes all of the `append` helper functions registered for the given // location and returns the resulting markup as a Nunjucks "safe" string // (HTML tags are allowed). Available as the `apos.templates.appended` // helper, and invoked as such by `outerLayoutBase.html`. self.appended = function(location) { return self.inserted('append', location); }; // Determines whether the aposContextMenu block, which contains // the "Page Settings" and "Published" UIs in the lower left corner // in addition to further UI pushed by modules like workflow, should // appear or not. Override to add additional nuances to this decision. self.showContextMenu = function(req) { return req.user && ( (req.data.page && req.data.page._edit) || (req.data.piece && req.data.piece._edit) ); }; self.enableHelpers = function() { self.addHelpers(_.pick(self, 'prepended', 'appended')); self.addHelpers({ showContextMenu: function() { return self.showContextMenu(self.contextReq); } }); }; } };