UNPKG

apostrophe

Version:
1,312 lines (1,173 loc) • 50.7 kB
// Implements template rendering via Nunjucks. **You should use the // `self.render` method of *your own* module**, // which exist courtesy of // [@apostrophecms/module](../@apostrophecms/module/index.html) and invoke // methods of this module more conveniently for you. // // You may have occasion to call `self.apos.template.safe` when // implementing a helper that returns a value that should not be // escaped by Nunjucks. You also might call `self.apos.template.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.template.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 to be checked for templates // if they are not found in the module that called `self.render` // or those it extends. This is a handy place for project-wide macro files. // Often set to `__dirname + '/views'` in `app.js`. const _ = require('lodash'); const dayjs = require('dayjs'); const qs = require('qs'); const Promise = require('bluebird'); const path = require('path'); const { stripIndent } = require('common-tags'); const { SemanticAttributes } = require('@opentelemetry/semantic-conventions'); const voidElements = require('void-elements'); module.exports = { options: { alias: 'template' }, customTags(self) { return { component: require('./lib/custom-tags/component')(self), fragment: require('./lib/custom-tags/fragment')(self), // render & rendercall ...require('./lib/custom-tags/render')(self) }; }, components(self) { return { async inject(req, data) { const key = `${data.end}-${data.where}`; const components = self.getInjectedComponents(key, data); const html = data.when ? '' : self.injectNodes(req, key); return { components, html }; } }; }, init(self) { self.templateApos = { modules: {}, log: function (msg) { self.apos.util.log.apply(self.apos, arguments); }, prefix: self.apos.prefix }; self.envs = {}; self.filters = {}; self.nunjucks = self.options.language || require('nunjucks'); self.insertions = {}; self.runtimeNodes = {}; }, handlers(self) { return { 'apostrophe:ready': { wrapHelpersForTemplateAposObject() { wrapFunctions(self.templateApos); _.each(self.templateApos.modules, function (helpers, moduleName) { const 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; }); function wrapFunctions(object) { _.each(object, function (value, key) { if (typeof value === 'object') { wrapFunctions(value); } else if (typeof value === 'function') { object[key] = function () { try { return value.apply(self, arguments); } catch (e) { self.apos.util.error(e); self.apos.util.error(e.stack); self.apos.util.error('^^^^^ LOOK UP HERE FOR THE LOCATION WITHIN YOUR HELPER'); throw e; } }; } }); } } }, 'apostrophe:destroy': { async nunjucksLoaderCleanup() { for (const loader of Object.values(self.loaders || {})) { await loader.destroy(); } } } }; }, methods(self) { return { ...require('./lib/bundlesLoader')(self), // 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). addHelpersForModule(module, object) { const helpersForModules = self.templateApos.modules; helpersForModules[module.__meta.name] = helpersForModules[module.__meta.name] || {}; const helpersForModule = helpersForModules[module.__meta.name]; if (typeof object === 'string') { helpersForModule[arguments[1]] = arguments[2]; } else { _.merge(helpersForModule, object); } }, // 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. addFilter(object) { 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. safe(s) { return new self.nunjucks.runtime.SafeString(s); }, // Escape any HTML markup in the given string and return a new Nunjucks // safe string, unless it is already marked as safe by Nunjucks. If it is // nullish treat it as an empty string. If it is not a string convert it // with its `toString` method before escaping. escapeIfNeeded(s) { if (!(s instanceof self.nunjucks.runtime.SafeString)) { return self.safe(self.apos.util.escapeHtml((s == null) ? '' : s.toString())); } else { return 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` is provided for convenience. // If there is no extension, looks for `.njk`, or `.html` // if `.njk` is not found. // Must be awaited (async function). async renderForModule(req, name, data, module) { if (typeof req !== 'object') { throw new Error('The first argument to module.render must be req.'); } 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. async renderStringForModule(req, s, data, module) { if (typeof req !== 'object') { throw new Error('The first argument to module.render must be req.'); } return self.renderBody(req, 'string', s, 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). jsonForHtml(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. async function. async renderBody(req, type, s, data, module) { let result; const args = self.getRenderArgs(req, data, module); const env = self.getEnv(req, module); if (type === 'file') { let finalName = s; if (!finalName.match(/\.\w+$/)) { finalName += '.html'; } result = await Promise.promisify(function (finalName, args, callback) { return env.getTemplate(finalName).render(args, callback); })(finalName, args); } else if (type === 'string') { result = await Promise.promisify(function (s, args, callback) { return env.renderString(s, args, callback); })(s, args); } else { throw new Error('renderBody does not support the type ' + type); } if (process.platform === 'win32') { // CR/LF is a waste of space in HTML and breaks unit tests result = result.replaceAll('\r', ''); } return result; }, // Implementation detail of `renderBody` responsible for // creating the input object passed to the template engine e.g. Nunjucks. // Includes both serializable data like `user` and non-JSON-friendly // properties like `apos`, `getOptions()` and `__req`. If you are only // interested in serializable data use `getRenderDataArgs` getRenderArgs(req, data, module) { const args = { data: self.getRenderDataArgs(req, data, module) }; args.apos = self.templateApos; args.__t = req.t; args.__ = key => { self.apos.util.warnDevOnce('old-i18n-nunjucks-helper', stripIndent` The __() Nunjucks helper is deprecated and does not localize in A3. Use __t() instead. `); return key; }; args.__req = req; args.getOption = (key, def) => { const colonAt = key.indexOf(':'); let optionModule = self.apos.modules[module.__meta.name]; if (colonAt !== -1) { const name = key.substring(0, colonAt); key = key.substring(colonAt + 1); optionModule = self.apos.modules[name]; } return optionModule.getOption(req, key, def); }; return args; }, // Just the external front-compatible parts of `getRenderArgs` that // go into `args.data` for Nunjucks, e.g. merging `req.data` and `data`, // adding `req.user` as `user`, etc. getRenderDataArgs(req, data, module) { const merged = {}; if (data) { _.defaults(merged, data); } 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); } merged.locale = merged.locale || req.locale; return merged; }, // 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` on your module // object rather than calling this directly. // // `req` is effectively here for bc purposes only. This method // does NOT always pass `req` to `newEnv` for every new release, as // `req` is separately supplied to each request to fix a memory leak // that occurs when Nunjucks environments are created for every request. getEnv(req, module) { const name = module.__meta.name; if (!_.has(self.envs, name)) { // Pass the original req for bc purposes only, // note that due to the reuse of envs there is // no guarantee newEnv will be called for every req self.envs[name] = self.newEnv(req, name, self.getViewFolders(module)); } return self.envs[name]; }, getViewFolders(module) { const dirs = _.map(module.__meta.chain, function (entry) { return entry.dirname + '/views'; }); // Final class should win dirs.reverse(); const viewsFolderFallback = self.options.viewsFolderFallback || path.join(self.apos.rootDir, 'views'); dirs.push(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.template.getEnv(req, module) // // `req` is effectively here for bc purposes only. Apostrophe // does NOT always pass `req` to `newEnv` for every new release, as // `req` is separately supplied to each request to fix a memory leak // that occurs when Nunjucks environments are created for every request. // If you must access `req` in a custom Nunjucks tag use // `context.ctx.__req`, NOT `env.opts.req` which is no longer provided. newEnv(req, moduleName, dirs) { const loader = self.getLoader(moduleName, dirs); const env = new self.nunjucks.Environment(loader, { autoescape: true, module: self.apos.modules[moduleName] }); env.addGlobal('apos', self.templateApos); env.addGlobal('module', self.templateApos.modules[moduleName]); 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); }); } _.each(self.apos.modules, function (module, name) { if (module.customTags) { _.each(module.customTags, function (config, tagName) { env.addExtension(tagName, configToExtension(tagName, config)); }); } }); function configToExtension(name, config) { // Legacy glue to create a Nunjucks custom tag extension from our // async/await-friendly, simplified format const extension = {}; extension.tags = [ name ]; extension.parse = function (parser, nodes, lexer) { const parse = config.parse ? config.parse : function (parser, nodes, lexer) { // Default parser gets comma separated arguments, // assumes no body // get the tag token const token = parser.nextToken(); // parse the args and move after the block end. passing true // as the second arg is required if there are no parentheses const args = parser.parseSignature(null, true); parser.advanceAfterBlockEnd(token.value); return { args }; }; const parsed = parse(parser, nodes, lexer); return new nodes.CallExtensionAsync(extension, 'run', parsed.args, parsed.blocks || []); }; extension.run = async function (context) { const callback = arguments[arguments.length - 1]; try { // Pass req, followed by other args that are not "context" (first) // or "callback" (last) const args = [ context, ...[].slice.call(arguments, 1, arguments.length - 1) ]; const result = await config.run.apply(config, args); return callback(null, self.apos.template.safe(result)); } catch (e) { return callback(e); } }; return extension; } 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. newLoader(moduleName, dirs) { const NunjucksLoader = require('./lib/nunjucksLoader.js'); return new NunjucksLoader(moduleName, dirs, undefined, self, self.options.loader); }, // Wrapper for newLoader with caching. You will not need // to call this directly. getLoader(moduleName, dirs) { const key = JSON.stringify({ moduleName, dirs }); if (!self.loaders) { self.loaders = {}; } if (!self.loaders[key]) { self.loaders[key] = self.newLoader(moduleName, dirs); } return self.loaders[key]; }, addStandardFilters(env) { // Format the given date with the given moment.js // format string. env.addFilter('date', function (date, format) { // Nunjucks is generally highly tolerant of bad // or missing data. Continue this tradition by not // crashing if date is null. -Tom if (!date) { return ''; } const s = dayjs(date).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.url.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.escapeIfNeeded(data); data = self.apos.util.globalReplace(data.toString(), '\n', '<br />\n'); return self.safe(data); }); // Newlines to paragraphs, produces better spacing and semantics env.addFilter('nlp', function (data) { data = self.escapeIfNeeded(data); const parts = data.toString().split(/\n/); const output = _.map(parts, function (part) { return '<p>' + part + '</p>\n'; }).join(''); return self.safe(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.util.cssName(s); }); env.addFilter('clonePermanent', function (o, keepScalars) { return self.apos.util.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.util.escapeHtml(JSON.stringify(data), options)); } else { // Make it a string for sure data += ''; return self.safe(self.apos.util.escapeHtml(data, options)); } }); env.addFilter('merge', function (data) { const output = {}; let i; for (i = 0; i < arguments.length; i++) { _.assign(output, arguments[i]); } return output; }); }, // Typically you will call the `sendPage` method of // your own module, provided by the `@apostrophecms/module` // base class, which is a wrapper for this method. // // Send a complete HTML page for to the // browser. // // `template` is a nunjucks template name, relative // to the provided module's views/ folder. // // `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... // // `@apostrophecms/template:outerLayout.html` // // Or: // // `@apostrophecms/template: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. // // If `req.query.aposRefresh` is `'1'`, // `refreshLayout.html` is used in place of `outerLayout.html`. // // 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) // // async function. async renderPageForModule(req, template, data, module) { const telemetry = self.apos.telemetry; const spanName = `render:${self.__meta.name}:renderPageForModule`; return telemetry.startActiveSpan(spanName, async (span) => { span.setAttributes({ [SemanticAttributes.CODE_FUNCTION]: 'renderPageForModule', [SemanticAttributes.CODE_NAMESPACE]: self.__meta.name }); span.setAttribute(telemetry.Attributes.TEMPLATE, template); const aposBodyData = await self.getBodyData(req); span.setAttribute(telemetry.Attributes.SCENE, aposBodyData.scene); self.addBodyDataAttribute(req, { apos: JSON.stringify(aposBodyData) }); // 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. const decorate = req.query.aposRefresh !== '1'; // 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 const args = { outerLayout: decorate ? '@apostrophecms/template:outerLayout.html' : '@apostrophecms/template:refreshLayout.html', permissions: req.user && (req.user._permissions || {}), scene: aposBodyData.scene, refreshing: !decorate, // Make the query available to templates for easy access to // filter settings etc. query: req.query, url: unrefreshed(req.prefix + req.url) }; _.extend(args, data); if (req.aposError) { // A 500-worthy error occurred already, i.e. in `pageBeforeSend` telemetry.handleError(span, req.aposError); span.end(); return error(req.aposError); } try { const spanRenderName = `render:${module.__meta.name}:render`; const content = await telemetry.startActiveSpan( spanRenderName, async (spanRender) => { spanRender.setAttribute(SemanticAttributes.CODE_FUNCTION, 'render'); spanRender.setAttribute( SemanticAttributes.CODE_NAMESPACE, module.__meta.name ); spanRender.setAttribute(telemetry.Attributes.SCENE, aposBodyData.scene); spanRender.setAttribute(telemetry.Attributes.TEMPLATE, template); try { const content = await module.render(req, template, args); spanRender.setStatus({ code: telemetry.api.SpanStatusCode.OK }); const filledContent = self.insertBundlesMarkup({ page: req.data.bestPage, scene: aposBodyData.scene, template, content, scriptsPlaceholder: req.scriptsPlaceholder, stylesheetsPlaceholder: req.stylesheetsPlaceholder, widgetsBundles: req.widgetsBundles }); return filledContent; } catch (err) { telemetry.handleError(spanRender, err); throw err; } finally { spanRender.end(); } }, span); span.setStatus({ code: telemetry.api.SpanStatusCode.OK }); return content; } catch (e) { // The page template threw an exception. Log where it // occurred for easier debugging telemetry.handleError(span, e); return error(e); } finally { span.end(); } }); function error(e) { self.logError(req, e); req.statusCode = 500; return self.render(req, 'templateError'); } function unrefreshed(url) { // Including aposRefresh=1 in data.url leads to busted pages in // navigation links, so strip that out. However this is invoked on // every page load so do it as quickly as we can to avoid the // overhead of a full parse and rebuild if (!url.includes('aposRefresh=1')) { return url; } else if (url.endsWith('?aposRefresh=1')) { return url.replace('?aposRefresh=1', ''); } else if (url.includes('?aposRefresh=1')) { return url.replace('?aposRefresh=1&', '?'); } else { return url.replace('&aposRefresh=1', ''); } } }, async getBodyData(req) { let scene = req.user ? 'apos' : 'public'; if (req.scene) { scene = req.scene; } else { req.scene = scene; } const aposBodyData = { modules: {}, prefix: req.prefix, sitePrefix: self.apos.prefix, shortName: self.apos.shortName, locale: req.locale, csrfCookieName: self.apos.csrfCookieName, tabId: self.apos.util.generateId(), uploadsUrl: self.apos.attachment.uploadfs.getUrl(), assetBaseUrl: self.apos.asset.getAssetBaseUrl(), scene }; if (req.user) { aposBodyData.user = { title: req.user.title, _id: req.user._id, username: req.user.username }; } await self.emit('addBodyData', req, aposBodyData); return aposBodyData; }, // Log the given template error with timestamp and user information logError(req, e) { let now = Date.now(); now = dayjs(now).format('YYYY-MM-DDTHH:mm:ssZZ'); self.apos.util.error(`:: ${now}: template error at ${req.url}`); self.apos.util.error(`Current user: ${req.user ? req.user.username : 'none'}`); self.apos.util.error(e); }, // 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. addBodyClass(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. // // Alternatively the second argument may be an object, in which case each // property becomes a data attribute, with the `data-` prefix. // // The default `outerLayoutBase.html` template outputs // the data attributes on the `body` tag. addBodyDataAttribute(req, name, value) { let 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; } // Single quotes are used to avoid unreadably massive data attributes // as double quotes are so common when the value is JSON req.data.aposBodyDataAttributes = (req.data.aposBodyDataAttributes ? req.data.aposBodyDataAttributes + ' ' : ' ') + ('data-' + (!_.isUndefined(value) && value.toString().length > 0 ? self.apos.util.escapeHtml(key) + (`='${self.apos.util.escapeHtml(value, { single: true })}'`) : self.apos.util.escapeHtml(key))); }); }, // Use this method to provide an async component name that will // be invoked at the point in the page layout identified by the string // `location`. Standard locations are `head`, `body`, and `main`. // // The page layout, template or outerLayout must contain a corresponding // `{% component '@apostrophecms/template:inject', 'location', 'prepend' // %}` call, with the same location, to actually insert the content. // // The output of components 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. // // `conditions` argument can be an object with keys `when` and `bundler` // to specify when the component should be injected. `bundler` is the // alias of the currently registered asset external build module. For now // only `when: hmr` and `bundler: xxx` are supported. // // A new single argument signature is also supported, where the first // argument is an object. See `insert()` for more details. // Usage: // apos.template.prepend({ // component: 'module-name:async-component-name', // where: 'head', // when: 'hmr', // or e.g. ['hmr', 'dev'], logical AND // bundler: 'vite', // }); // OR // apos.template.prepend('head', 'module-name:async-component-name', // { when: 'hmr', bundler: 'vite' }); // Such calls will match the following inject: // {% component '@apostrophecms/template:inject', 'head', 'prepend', // { when: 'hmr', bundler: 'vite' } %} // but only when the current asset bundler is 'vite'. prepend(location, componentName, conditions) { if (location && typeof location === 'object') { componentName = location.component; } if (typeof componentName !== 'string') { throw new Error('Do not pass a function to apos.template.prepend. Pass a fully qualified component name, i.e. module-name:async-component-name'); } return self.insert('prepend', location, componentName, conditions); }, // Use this method to provide an async component name that will // be invoked at the point in the page layout identified by the string // `location`. Standard locations are `head`, `body`, and `main`. // // The page layout, template or outerLayout must contain a corresponding // `apos.template.prepended('location')` call, with the same location, to // actually insert the content. // // The output of components 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. // // `conditions` argument can be an object with keys `when` and `bundler` // to specify when the component should be injected. `bundler` is the // alias of the currently registered asset external build module. For now // only `when: hmr` and `bundler: xxx` are supported. // // A new single argument signature is also supported, where the first and // only argument is an object. // See `insert()` for more details. // Usage: // apos.template.append({ // component: 'module-name:async-component-name', // where: 'head', // when: 'hmr', // or e.g. ['hmr', 'dev'], logical AND // bundler: 'vite', // }); // OR // apos.template.append('head', 'module-name:async-component-name', // { when: 'hmr', bundler: 'vite' }); // Such call will match the following inject: // {% component '@apostrophecms/template:inject' with // { where: 'head', end: 'append', when: 'hmr' } %} // but only when the current asset bundler is 'vite'. append(location, componentName, conditions) { if (location && typeof location === 'object') { componentName = location.component; } if (typeof componentName !== 'string') { throw new Error('Do not pass a function to apos.template.prepend. Pass a fully qualified component name, i.e. module-name:async-component-name'); } return self.insert('append', location, componentName, conditions); }, // Implementation detail of `apos.template.prepend` and // `apos.template.append`. `conditions` is an object (optional) that may // have `when` and `bundler` keys. See below for more info. All conditions // are evaluated at a runtime, when the component is injected. // // If `location` is an object, the following arguments are ignored. The // object should have the following keys: - `component`: the component // name (e.g. 'module-name:component-name') - `where`: the location (e.g. // 'head') - `when`: (optional) string or array of strings, the conditions // to be met to insert the component. When an array, a logical AND is // applied. One match against the injected `when` data is required. // Currently supported values are `hmr`, `dev`, `prod`. See // `getInjectConditionHandlers()` for more info. The `when` value can // include an argument separated by `:`. E.g. `hmr:apos`, `hmr:public`. If // the condition handler does not support arguments, it's ignored. - // `bundler`: (optional) string, the alias of the currently registered // asset external build module. The bundler condition is not parth of the // actual inject data. It's evaluated just on the registration data. insert(end, location, componentName, conditions) { if (location && typeof location === 'object') { const { component, where, ...rest } = location; conditions = Object.keys(rest).length ? rest : null; componentName = component; location = where; } const key = end + '-' + location; self.insertions[key] = self.insertions[key] || []; self.insertions[key].push({ component: componentName, conditions }); }, // Accepts the position and component key (e.g. `prepend-head`) and // returns an array of components that should be injected at that // position. getInjectedComponents(key, data) { const components = []; self.insertions[key]?.forEach(({ component, conditions = {} }) => { // BC if (!conditions.bundler && !conditions.when && !data.when) { components.push(component); return; } // Normalize in place if (!Array.isArray(conditions.when)) { conditions.when = conditions.when ? [ conditions.when ] : []; } // Both sides `when` should match if (data.when && !conditions.when.map(s => s.split(':')[0]).includes(data.when)) { return; } if (!data.when && conditions.when.length) { return; } // All `when` condition voters must agree. const { when, bundler } = conditions; const handlers = self.getInjectConditionHandlers(); // Optional support for logical OR might be implemented, // just use `some`. A possible implementation with // `when` being an object same as the schema `if`, supporting // the same logical operators. But it's too much for now. const conditionMet = when.every(val => { const [ fn, arg ] = val.split(':'); if (!handlers[fn]) { self.apos.util.error(`Invalid inject condition: ${when}`); return false; } return handlers[fn](arg, data); }); if (bundler) { // Support for the internal webpack bundler const currentBundler = self.apos.asset.hasBuildModule() ? self.apos.asset.getBuildModuleAlias() : 'webpack'; if (currentBundler !== bundler) { return; } } if (!conditionMet) { return; } components.push(component); }); return components; }, // Simple conditions handling for `when` injects. It can be extended to // support custom conditions in the future - registered by modules similar // to `helpers`. Every condition function receives an argument if // available and the nunjucks data object. For example `when: hmr:apos` // will call `hmr('apos', data)`. The function should return a boolean. getInjectConditionHandlers() { return { hmr(kind) { if (kind) { return self.apos.asset.hasHMR() && self.apos.asset.getBuildOptions().devServer === kind; } return self.apos.asset.hasHMR(); }, dev() { return self.apos.asset.isDevMode(); }, prod() { return self.apos.asset.isProductionMode(); } }; }, prependNodes(location, moduleName, method) { self.registerRuntimeNodes('prepend', location, moduleName, method); }, appendNodes(location, moduleName, method) { self.registerRuntimeNodes('append', location, moduleName, method); }, registerRuntimeNodes(end, location, moduleName, method) { if (typeof method !== 'string') { throw new Error( `Do not pass a function to "apos.template.${end}Nodes()". ` + 'Pass a string with the name of the method to call on the module, ' + 'e.g. "myMethod"' ); } if (typeof moduleName !== 'string') { throw new Error( `Invalid "moduleName" detected in "apos.template.${end}Nodes()". ` + 'Pass a string with the name of the module, e.g. "some-module"' ); } if (typeof self.apos.modules[moduleName] === 'undefined') { throw new Error( `Invalid module "${moduleName}"detected in "apos.template.${end}Nodes()". ` + 'Make sure the module is registered in your "app.js" file.' ); } if (!self.apos.modules[moduleName][method]) { throw new Error( `Invalid method "${method}" detected in "apos.template.${end}Nodes()". ` + `Make sure the module "${moduleName}" has a method named "${method}".` ); } const key = end + '-' + location; self.runtimeNodes[key] ||= []; self.runtimeNodes[key].push({ moduleName, method }); }, // Accepts array of node objects and returns a string - HTML // representation of the nodes. Example nodes: // [ // { // name: 'div', // attrs: { class: 'my-class' }, // body: [ // { // text: 'Hello world' // } // ] // }, // { // name: 'link', // attrs: { href: '/some/path', rel: 'stylesheet' } // } // ] // Node object SHOULD have either `name`, `text`, `raw` or `comment` property. // A node with `name` can have `attrs` (array of element attributes) // and `body` (array of child nodes, recursion). // `text` nodes are rendered as text (no HTML tags), the value is always a string. // `comment` nodes are rendered as HTML comments, the value is always a string. // `raw` nodes are rendered as is, no escaping, the value is always a string. renderNodes(nodes) { if (!Array.isArray(nodes)) { self.logError( 'render-nodes', 'Invalid nodes array passed to apos.template.renderNodes()' ); return ''; } return nodes.map(node => { if (node.text != null) { return self.apos.util.escapeHtml(node.text); } if (node.comment != null) { return `\n<!-- ${self.apos.util.escapeHtml(node.comment)} -->\n`; } if (node.raw != null) { return node.raw; } if (node.name != null) { const name = self.apos.util.escapeHtml(node.name); const attrs = Object.entries(node.attrs || {}) .map(([ key, value ]) => { if (value === false || value === null || value === undefined) { return ''; } if (value === true) { return ` ${self.apos.util.escapeHtml(key)}`; } return ` ${self.apos.util.escapeHtml(key)}="${self.apos.util.escapeHtml(value)}"`; }) .join('') .trimEnd(); if (!node.body && voidElements[name]) { return `<${name}${attrs} />`; } const body = ( node.body ? self.renderNodes(node.body) : '' ).trim(); return `<${name}${attrs}>${body}</${name}>`; } self.logError( 'render-nodes', 'Invalid node object passed to apos.template.renderNodes()', { node } ); return ''; }) .join('') .trim(); }, injectNodes(req, locationKey) { const nodes = self.runtimeNodes[locationKey] || []; if (!nodes.length) { return ''; } const output = []; for (const { moduleName, method } of nodes) { const handler = self.apos.modules[moduleName][method]; output.push(self.renderNodes(handler(req))); } return output.join('\n'); }, async annotateDataForExternalFront(req, template, data, moduleName) { const docs = self.getDocsForExternalFront(req, template, data, moduleName); for (const doc of docs) { self.annotateDocForExternalFront(doc, { scene: req.scene }); } data.aposBodyData = await self.getBodyData(req); // Already contains module name too data.template = template; // For simple cases (not piece pages and the like) data.module = moduleName; // Provide the `apos` scene bundles to the exsternal front-end if (self.apos.asset.hasBuildModule()) { const modulePreload = new Set(); data.bundleMarkup = { js: self.apos.asset.getBundlePageMarkup({ scene: 'apos', output: 'js', modulePreload }), css: self.apos.asset.getBundlePageMarkup({ scene: 'apos', output: 'css' }) }; data.bundleMarkup.js.push(...Array.from(modulePreload)); } // `node` injections data.prependHead = self.injectNodes(req, 'prepend-head'); data.appendHead = self.injectNodes(req, 'append-head'); data.prependBody = self.injectNodes(req, 'prepend-body'); data.appendBody = self.injectNodes(req, 'append-body'); data.prependMain = self.injectNodes(req, 'prepend-main'); data.appendMain = self.injectNodes(req, 'append-main'); return data; }, pruneDataForExternalFront(req, template, data, moduleName) { return data; }, getDocsForExternalFront(req, template, data, moduleName) { return [ data.home, ...(data.page?._ancestors || []), ...(data.page?._children || []), data.page, data.piece, ...(data.pieces || []) ].filter(doc => !!doc); }, annotateDocForExternalFront(doc, { scene } = {}) { self.apos.doc.walk(doc, (o, k, v) => { if (v && v.metaType === 'area') { const manager = self.apos.util.getManagerOf(o); if (!manager) { self.apos.util.warnDevOnce('noManagerForDocInExternalFront', `No manager for: ${o.metaType} ${o.type || ''}`); return; } const field = manager.schema.find(f => f.name === k); if (!field) { self.apos.util.warnDevOnce( 'noSchemaFieldForAreaInExternalFront', `Area ${k} has no matching schema field in ${o.metaType} ${o.type || ''}` ); return; } return self.annotateAreaForExternalFront(field, v, { scene }); } }); }, // Annotate an area for easy rendering by an external front end // such as Astro. This includes adding the `field`, `options`, `widgets` // and `choices` properties, and guaranteeing that `items` exists, // at least as an empty array. annotateAreaForExternalFront(field, area, { scene } = {}) { area.field = field; area.options = field.options; // Really widget configurations, but the method name is already set in // stone const widgets = self.apos.area.getWidgets(area.options); area.choices = Object.entries(widgets).map(([ name, options ]) => { const manager = self.apos.area.widgetManagers[name]; return manager && { name, icon: manager.options.icon, label: options.addLabel || manager.label || `No label for ${name}` }; }).filter(choice => !!choice); area.items ||= []; for (const i