diff --git a/index.js b/index.js new file mode 100644 index 0000000..f071f41 --- /dev/null +++ b/index.js @@ -0,0 +1,3 @@ +'use strict'; + +module.exports = require('./src/adapter'); diff --git a/package.json b/package.json new file mode 100644 index 0000000..d73cf9d --- /dev/null +++ b/package.json @@ -0,0 +1,104 @@ +{ + "_from": "github:CandoImage/fractal-twig-context-subrender", + "_id": "fractal-twig-context-subrender@1.0.0", + "_inBundle": false, + "_integrity": "", + "_location": "/fractal-twig-context-subrender", + "_phantomChildren": { + "@allmarkedup/fang": "1.0.0", + "@frctl/handlebars": "1.1.5", + "@frctl/mandelbrot": "1.2.0", + "anymatch": "1.3.2", + "bluebird": "3.5.3", + "browser-sync": "2.26.3", + "chokidar": "1.7.0", + "cli-table2": "0.2.0", + "co": "4.6.0", + "columnify": "1.5.4", + "escape-string-regexp": "1.0.5", + "express": "4.17.1", + "globby": "6.1.0", + "graceful-fs": "4.1.15", + "gray-matter": "2.1.1", + "handlebars": "4.1.0", + "has-ansi": "2.0.0", + "highlight.js": "9.14.2", + "inquirer": "1.2.3", + "istextorbinary": "2.5.1", + "js-beautify": "1.6.14", + "js-yaml": "3.13.1", + "klaw": "1.3.1", + "liftoff": "2.5.0", + "lodash": "4.17.11", + "log-update": "1.0.2", + "marked": "0.3.19", + "mime": "1.6.0", + "minimist": "1.2.0", + "mixwith": "0.1.1", + "nunjucks": "2.5.2", + "path-is-absolute": "1.0.1", + "path-to-regexp": "1.7.0", + "portscanner": "1.2.0", + "readable-stream": "2.3.6", + "require-all": "2.2.0", + "rimraf": "2.6.3", + "semver": "5.6.0", + "shelljs": "0.7.8", + "strip-ansi": "3.0.1", + "throat": "3.2.0", + "update-notifier": "1.0.3", + "vinyl": "1.2.0", + "vorpal": "1.11.4" + }, + "_requested": { + "type": "git", + "raw": "github:CandoImage/fractal-twig-context-subrender", + "rawSpec": "github:CandoImage/fractal-twig-context-subrender", + "saveSpec": "github:CandoImage/fractal-twig-context-subrender", + "fetchSpec": null, + "gitCommittish": null + }, + "_requiredBy": [ + "#DEV:/" + ], + "_resolved": "github:CandoImage/fractal-twig-context-subrender#bdf808598d9d2ea10b2cecaea039ed41e6c5c0f1", + "_spec": "github:CandoImage/fractal-twig-context-subrender", + "author": { + "name": "ADYAX TEAM & das-peter" + }, + "babel": { + "presets": [ + "es2015" + ] + }, + "bugs": { + "url": "https://github.com/das-peter/fractal-twig-context-subrender/issues" + }, + "bundleDependencies": false, + "dependencies": { + "@frctl/fractal": "latest", + "lodash": "^4.17.4", + "query-string": "^5.0.0", + "twig": "^1.10.5" + }, + "deprecated": false, + "description": "Extension for the Fractal Twig Adapter to allow sub-render context data.", + "files": [ + "src", + "index.js" + ], + "homepage": "https://github.com/das-peter/fractal-twig-context-subrender#readme", + "license": "MIT", + "main": "index.js", + "name": "fractal-twig-context-subrender", + "repository": { + "type": "git", + "url": "git+https://github.com/das-peter/fractal-twig-context-subrender.git" + }, + "scripts": { + "release:major": "npm version major -m \"Released version %s\" && npm publish && git push --follow-tags", + "release:minor": "npm version minor -m \"Released version %s\" && npm publish && git push --follow-tags", + "release:patch": "npm version patch -m \"Released version %s\" && npm publish && git push --follow-tags" + }, + "version": "1.0.0" +} diff --git a/src/adapter.js b/src/adapter.js new file mode 100644 index 0000000..d9e516e --- /dev/null +++ b/src/adapter.js @@ -0,0 +1,371 @@ +'use strict'; + +const Fractal = require('@frctl/core'); +const _ = require('lodash'); +const Path = require('path'); +const utils = Fractal.utils; +const adapterUtils = require('@frctl/twig/src/utils'); + + +class TwigAdapter extends Fractal.Adapter { + constructor(Twig, source, app, config) { + super(Twig, source); + this._app = app; + this._config = config; + this._loaderName = `fractal-${source.name}`; + + source.set('engine', '@frctl/twig'); + + let self = this; + + Twig.extend(function (Twig) { + /* + * Register a Fractal template loader. Locations can be handles or paths. + */ + Twig.Templates.registerLoader(self._loaderName, function (location, params) { + if (params.precompiled) { + params.data = params.precompiled; + } else { + let view = adapterUtils.isHandle(location, self._config.handlePrefix) + ? self.getView(location) + : _.find(self.views, { path: Path.join(source.fullPath, location) }); + if (!view) { + throw new Error(`Template ${location} not found`); + } + params.data = view.content; + } + + return new Twig.Template(params); + }); + + /* + * Monkey patch the render method to make sure that the _self variable + * always refers to the actual component/sub-component being rendered. + * Without this _self would always refer to the root component. + */ + + const render = Twig.Template.prototype.render; + Twig.Template.prototype.render = function (context, params) { + if (!self._config.pristine && this.id) { + let handle = null; + + if (adapterUtils.isHandle(this.id, self._config.handlePrefix)) { + handle = this.id; + } else { + let view = _.find(self.views, { path: Path.join(source.fullPath, this.id) }); + if (view) { + handle = view.handle; + } + } + + if (handle) { + let entity = source.find(adapterUtils.replaceHandlePrefix(handle, self._config.handlePrefix)); + if (entity) { + entity = entity.isComponent ? entity.variants().default() : entity; + if (config.importContext) { + context = utils.defaultsDeep(_.cloneDeep(context), entity.getContext()); + context._self = entity.toJSON(); + setKeys(context); + } + } + } + } + + if (config.supportIncludesInTheContextData) { + // Handling includes + processIncludes(context); + setKeys(context); + } + + /* + * Twig JS uses an internal _keys property on the context data + * which we need to regenerate every time we patch the context. + */ + + function setKeys(obj) { + obj._keys = _.compact( + _.map(obj, (val, key) => { + return _.isString(key) && !key.startsWith('_') ? key : undefined; + }) + ); + _.each(obj, (val, key) => { + if (_.isPlainObject(val) && _.isString(key) && !key.startsWith('_')) { + setKeys(val); + } + }); + } + + return render.call(this, context, params); + }; + + /* + * Twig caching is enabled for better perf, so we need to + * manually update the cache when a template is updated or removed. + */ + + Twig.cache = false; + + self.on('view:updated', unCache); + self.on('view:removed', unCache); + self.on('wrapper:updated', unCache); + self.on('wrapper:removed', unCache); + + function unCache(view) { + let path = Path.relative(source.fullPath, _.isString(view) ? view : view.path); + if (view.handle && Twig.Templates.registry[view.handle]) { + delete Twig.Templates.registry[view.handle]; + } + if (Twig.Templates.registry[path]) { + delete Twig.Templates.registry[path]; + } + } + }); + + function getDataByPath(object, path, omitLastElement) { + if (path === null) { + return object; + } + + const pathParts = path.split('.'); + + if (omitLastElement) { + pathParts.pop(); + } + + let currentValue = object; + for (let i = 0; i < pathParts.length; i++) { + const pathPart = pathParts[i]; + currentValue = currentValue[pathPart]; + } + + return currentValue; + } + + // Example input: component-name--variant-name.path.to.the.source.data.object + // As a result we retrieve information about the context name (component-name--variant-name), full object path (path.to.the.source.data.object) and the final property name (object) + function fullDataPath2Info(fullDataPath) { + const componentName_dataPath = fullDataPath.split(/\.(.*)/s); + const pathParts = fullDataPath.split('.'); + if (pathParts.length > 1) { + return { contextName: componentName_dataPath[0], path: componentName_dataPath[1], lastPart: pathParts[pathParts.length - 1] }; + } else { + return { contextName: componentName_dataPath[0], path: null, lastPart: null }; + } + } + + /* eslint-disable complexity */ + // Goes throught the whole context data tree and turns all includes into a desired data. + function processIncludes(destinationDataNode) { + console.log("PI................7") + if (typeof destinationDataNode === 'object') { + + for (let propertyName in destinationDataNode) { + if (propertyName.startsWith('include')) { + + let fullDataPaths = destinationDataNode[propertyName]; + + if (fullDataPaths.startsWith) { + for (let fullDataPath of fullDataPaths.split(',')) { + fullDataPath = fullDataPath.trim(); + + // Indicates whether inner properties of the source data should be spread. + let spreadInnerData = fullDataPath.startsWith('...'); + // Indicates whether the include data has a priority over already existing data. + const overrideExistingData = fullDataPath.endsWith('!'); + // Allows for property rename. Helpfull especially when including full context data which doesn't have a name. + const definedCustomPropertyName = fullDataPath.indexOf(' as ') !== -1; + + let customPropertyName = null; + + if (spreadInnerData) { + fullDataPath = fullDataPath.slice(3); + } + + if (overrideExistingData) { + fullDataPath = fullDataPath.slice(0, -1); + } + + if (definedCustomPropertyName) { + const fullDataPath_customPropertyName = fullDataPath.split(" as ") + fullDataPath = fullDataPath_customPropertyName[0] + customPropertyName = fullDataPath_customPropertyName[1] + + // In case we specify variable name, the spread operator doesn't make sense in the current implementation. + spreadInnerData = false + } + + const contextDataPathInfo = fullDataPath2Info(fullDataPath); + + const sourceComponentContextData = getEntityInfoByName(self._config.handlePrefix + contextDataPathInfo.contextName).contextData; + const sourceData = getDataByPath(sourceComponentContextData, contextDataPathInfo.path, !spreadInnerData); + + if (spreadInnerData) { + // Iterates all inner properties and add them to the destination object. + + for (let sourceDataPropertyName in sourceData) { + if ((overrideExistingData || typeof destinationDataNode[sourceDataPropertyName] === 'undefined') && typeof sourceData[sourceDataPropertyName] !== 'undefined') { + destinationDataNode[sourceDataPropertyName] = sourceData[sourceDataPropertyName]; + // The sub-tree might contain include statements as well so let's process them. Without this sub processing it could stil work if we are lucky with processing order. + processIncludes(destinationDataNode[sourceDataPropertyName]); + } + } + } else { + // In this case, it takes the whole sub-property by the requested property name (sourceDataPropertyName). + // In case property name is not specified the whole object is used. + + const sourceDataPropertyName = contextDataPathInfo.lastPart; + const finalDestinationPropertyName = customPropertyName || sourceDataPropertyName + + if ((overrideExistingData || typeof destinationDataNode[finalDestinationPropertyName] === 'undefined') && (!sourceDataPropertyName || typeof sourceData[sourceDataPropertyName] !== 'undefined')) { + destinationDataNode[finalDestinationPropertyName] = sourceDataPropertyName ? sourceData[sourceDataPropertyName] : sourceData; + // The sub-tree might contain include statements as well so let's process them. Without this sub processing it could stil work if we are lucky with processing order. + processIncludes(destinationDataNode[finalDestinationPropertyName]); + } + } + + } + + // Removes "include" entry that is not needed anymore. + delete destinationDataNode[propertyName]; + } + } else { + // Recursively processing the sub-tree. + const subObject = destinationDataNode[propertyName]; + + if (Array.isArray(subObject)) { + for (let subObjectPropertyName in subObject) { + // Processing each element in the array. + processIncludes(subObject[subObjectPropertyName]); + } + } else { + if (typeof subObject === 'object') { + // Processing sub-objects. + processIncludes(subObject); + } + } + } + } + } + + return destinationDataNode; + } + + function getEntityInfoByName(item) { + let item_id = item.trim().replace('$', self._config.handlePrefix); + let entity = source.find(item_id); + if (typeof entity === 'undefined') { + throw new Error(`Sub-Render item ${item_id} not found`); + } + return { entity: entity.isVariant ? entity : entity.variants().default(), item_id: item_id, contextData: entity.getContext() }; + } + + + + } + + get twig() { + return this._engine; + } + + render(path, str, context, meta) { + let self = this; + + meta = meta || {}; + + if (!this._config.pristine) { + setEnv('_self', meta.self, context); + setEnv('_target', meta.target, context); + setEnv('_env', meta.env, context); + setEnv('_config', this._app.config(), context); + } + + return new Promise(function (resolve, reject) { + let tplPath = path ? Path.relative(self._source.fullPath, path) : undefined; + + try { + let template = self.engine.twig({ + method: self._config.method === 'fractal' ? self._loaderName : self._config.method, + async: false, + rethrow: true, + name: + self._config.method === 'fractal' + ? meta.self + ? `${self._config.handlePrefix}${meta.self.handle}` + : tplPath + : undefined, + path: path, + precompiled: str, + base: self._config.base, + strict_variables: self._config.strict_variables, + namespaces: self._config.namespaces, + }); + resolve(template.render(context)); + } catch (e) { + reject(new Error(e)); + } + }); + + function setEnv(key, value, context) { + if (context[key] === undefined && value !== undefined) { + context[key] = value; + } + } + } +} + +module.exports = function (config) { + config = _.defaults(config || {}, { + method: 'fractal', + pristine: false, + handlePrefix: '@', + importContext: false, + base: null, + strict_variables: false, + namespaces: {}, + supportIncludesInTheContextData: false, + }); + + return { + register(source, app) { + const Twig = require('twig'); + + if (!config.pristine) { + _.each(require('./functions')(app) || {}, function (func, name) { + Twig.extendFunction(name, func); + }); + _.each(require('./filters')(app), function (filter, name) { + Twig.extendFilter(name, filter); + }); + _.each(require('./tests')(app), function (test, name) { + Twig.extendTest(name, test); + }); + Twig.extend(function (Twig) { + _.each(require('./tags')(app, config), function (tag) { + Twig.exports.extendTag(tag(Twig)); + }); + }); + } + + _.each(config.functions || {}, function (func, name) { + Twig.extendFunction(name, func); + }); + _.each(config.filters || {}, function (filter, name) { + Twig.extendFilter(name, filter); + }); + _.each(config.tests || {}, function (test, name) { + Twig.extendTest(name, test); + }); + Twig.extend(function (Twig) { + _.each(config.tags || {}, function (tag) { + Twig.exports.extendTag(tag(Twig)); + }); + }); + + const adapter = new TwigAdapter(Twig, source, app, config); + + adapter.setHandlePrefix(config.handlePrefix); + + return adapter; + }, + }; +}; diff --git a/src/filters/index.js b/src/filters/index.js new file mode 100644 index 0000000..3fd528f --- /dev/null +++ b/src/filters/index.js @@ -0,0 +1,9 @@ +'use strict'; + +module.exports = function(fractal){ + + return { + path: require('./path.js')(fractal), + } + +}; diff --git a/src/filters/package.json b/src/filters/package.json new file mode 100644 index 0000000..f68471d --- /dev/null +++ b/src/filters/package.json @@ -0,0 +1,3 @@ +{ + "main": "index.js" +} diff --git a/src/filters/path.js b/src/filters/path.js new file mode 100644 index 0000000..0265324 --- /dev/null +++ b/src/filters/path.js @@ -0,0 +1,19 @@ +'use strict'; + +const _ = require('lodash'); +const utils = require('@frctl/fractal').utils; + +module.exports = function(fractal) { + + return function(path) { + + if(!this.context._env || this.context._env.server) { + return path; + } else { + const request = this.context._env.request || this.context._request; + return utils.relUrlPath(path, _.get(request, 'path', '/'), fractal.web.get('builder.urls')); + } + + } + +}; diff --git a/src/functions/index.js b/src/functions/index.js new file mode 100644 index 0000000..d744b6c --- /dev/null +++ b/src/functions/index.js @@ -0,0 +1,9 @@ +'use strict'; + +module.exports = function(fractal){ + + return { + + } + +}; diff --git a/src/functions/package.json b/src/functions/package.json new file mode 100644 index 0000000..f68471d --- /dev/null +++ b/src/functions/package.json @@ -0,0 +1,3 @@ +{ + "main": "index.js" +} diff --git a/src/tags/index.js b/src/tags/index.js new file mode 100644 index 0000000..2bb4573 --- /dev/null +++ b/src/tags/index.js @@ -0,0 +1,9 @@ +'use strict'; + +module.exports = function(fractal){ + + return { + render: require('./render.js')(fractal) + } + +}; diff --git a/src/tags/package.json b/src/tags/package.json new file mode 100644 index 0000000..f68471d --- /dev/null +++ b/src/tags/package.json @@ -0,0 +1,3 @@ +{ + "main": "index.js" +} diff --git a/src/tags/render.js b/src/tags/render.js new file mode 100644 index 0000000..7166b3a --- /dev/null +++ b/src/tags/render.js @@ -0,0 +1,83 @@ +'use strict'; + +const _ = require('lodash'); +const path = require('path'); + +/** + * Render tag + * + * Format: {% render "@component" with {some: 'values'} %} + */ +module.exports = function (fractal) { + return function (Twig) { + return { + type: 'rendertag', + regex: /^render\s+(.+?)\s*(?:with\s+([\S\s]+?))?\s*$/, + next: [], + open: true, + compile: function (token) { + const match = token.match, + handle = match[1].trim(), + context = match[2]; + + token.stack = Twig.expression.compile.apply(this, [ + { + type: Twig.expression.type.expression, + value: handle + } + ]).stack; + + if (context !== undefined) { + token.contextStack = Twig.expression.compile.apply(this, [ + { + type: Twig.expression.type.expression, + value: context.trim() + } + ]).stack; + } + + delete token.match; + return token; + }, + parse: function (token, context, chain) { + const file = Twig.expression.parse.apply(this, [token.stack, context]); + const handle = path.parse(file).name; + + if (!handle.startsWith('@')) { + throw new Error(`You must provide a valid component handle to the render tag.`); + } + + const entity = fractal.components.find(handle); + + if (!entity) { + throw new Error(`Could not render component '${handle}' - component not found.`); + } + + let innerContext = entity.isComponent ? entity.variants().default().getContext() : entity.getContext(); + + if (token.contextStack !== undefined) { + _.assign(innerContext, Twig.expression.parse.apply(this, [token.contextStack, context])); + } + + let template; + + if (file instanceof Twig.Template) { + template = file; + } + else { + if (typeof this.importFile !== 'undefined') { + template = this.importFile(file); + } + else if (typeof this.template !== 'undefined' && typeof this.template.importFile !== 'undefined') { + template = this.template.importFile(file); + } + } + + return { + chain: chain, + output: template.render(innerContext) + }; + } + }; + }; +}; diff --git a/src/tests/index.js b/src/tests/index.js new file mode 100644 index 0000000..d744b6c --- /dev/null +++ b/src/tests/index.js @@ -0,0 +1,9 @@ +'use strict'; + +module.exports = function(fractal){ + + return { + + } + +}; diff --git a/src/tests/package.json b/src/tests/package.json new file mode 100644 index 0000000..f68471d --- /dev/null +++ b/src/tests/package.json @@ -0,0 +1,3 @@ +{ + "main": "index.js" +}