From 017fd7e28f0e6e8bfa1352b6c289f98c88840e1f Mon Sep 17 00:00:00 2001 From: Yahor Siarheyenka Date: Sun, 15 Mar 2026 17:12:16 +0100 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20add=20optional=20chaining=20`(=3F.)?= =?UTF-8?q?`=20support=20=F0=9F=94=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/en/reference/implementation-notes.md | 3 +- node/build.js | 10 +- src/twig.expression.js | 134 +++- test/test.expressions.optional-chaining.js | 717 +++++++++++++++++++++ 4 files changed, 823 insertions(+), 41 deletions(-) create mode 100644 test/test.expressions.optional-chaining.js diff --git a/docs/en/reference/implementation-notes.md b/docs/en/reference/implementation-notes.md index d6c4d8cb..1dd2c66f 100644 --- a/docs/en/reference/implementation-notes.md +++ b/docs/en/reference/implementation-notes.md @@ -140,4 +140,5 @@ Example syntax: `{{ expression operator expression }}` - Bitwise (`b-and`, `b-or`, `b-xor`): Supported - Comparisons (`==`, `!=`, `<`, `>`, `>=`, `<=`, `===`): Supported - Others (`..`, `|`, `~`, `.`, `[]`, `?:`): Supported -- Null-coalescing (`??`): Supported \ No newline at end of file +- Optional chaining (`?.`): Supported +- Null-coalescing (`??`): Supported diff --git a/node/build.js b/node/build.js index c09d6ca9..acba633d 100644 --- a/node/build.js +++ b/node/build.js @@ -1,17 +1,17 @@ const child_process = require('child_process'); const semver = require('semver'); -const environmentVariables = {...process.env}; +const environmentVariables = structuredClone(process.env); if (semver.satisfies(process.version, '>=17')) { environmentVariables['NODE_OPTIONS'] = '--openssl-legacy-provider'; } -child_process.spawn( - 'webpack', - [], +child_process.execFile( + 'npx', + ['webpack'], { env: environmentVariables, stdio: 'inherit', - } + }, ); diff --git a/src/twig.expression.js b/src/twig.expression.js index 50f85d67..24af2776 100644 --- a/src/twig.expression.js +++ b/src/twig.expression.js @@ -4,6 +4,26 @@ module.exports = function (Twig) { 'use strict'; + /** + * Normalize an object. + * + * @param {*} object The value to normalize. + * + * @return {*} Returns null if the value is null or undefined, otherwise returns an Object. + */ + function normalizeObject(object) { + return object === null || object === undefined ? null : Object(object); + } + + /** + * Parse parameters. + * + * @param {Object} state The expression state. + * @param {Array|Object} params The parameters to parse. + * @param {Object} context The render context. + * + * @return {Promise} A promise that resolves to the parsed parameters or false. + */ function parseParams(state, params, context) { if (params) { return Twig.expression.parseAsync.call(state, params, context); @@ -214,7 +234,7 @@ module.exports = function (Twig) { type: Twig.expression.type.operator.binary, // Match any of ??, ?:, +, *, /, -, %, ~, <=>, <, <=, >, >=, !=, ==, **, ?, :, and, b-and, or, b-or, b-xor, in, not in // and, or, in, not in, matches, starts with, ends with can be followed by a space or parenthesis - regex: /(^\?\?|^\?\s*:|^(b-and)|^(b-or)|^(b-xor)|^[+\-~%?]|^(<=>)|^[:](?!\d\])|^[!=]==?|^[!<>]=?|^\*\*?|^\/\/?|^(and)[(|\s+]|^(or)[(|\s+]|^(in)[(|\s+]|^(not in)[(|\s+]|^(matches)|^(starts with)|^(ends with)|^\.\.)/, + regex: /(^\?\?|^\?\s*:|^(b-and)|^(b-or)|^(b-xor)|^[+\-~%]|^\?(?![.\[])|^(<=>)|^:(?!\d])|^[!=]==?|^[!<>]=?|^\*\*?|^\/\/?|^(and)[(|\s+]|^(or)[(|\s+]|^(in)[(|\s+]|^(not in)[(|\s+]|^(matches)|^(starts with)|^(ends with)|^\.\.)/, next: Twig.expression.set.expressions, transform(match, tokens) { switch (match[0]) { @@ -222,7 +242,7 @@ module.exports = function (Twig) { case 'or(': case 'in(': case 'not in(': - // Strip off the ( if it exists + // Strip off the (if it exists) tokens[tokens.length - 1].value = match[2]; return match[0]; default: @@ -348,7 +368,7 @@ module.exports = function (Twig) { */ type: Twig.expression.type.string, // See: http://blog.stevenlevithan.com/archives/match-quoted-string - regex: /^(["'])(?:(?=(\\?))\2[\s\S])*?\1/, + regex: /^(["'])(?:\\[\s\S]|(?!\1)[^\\])*\1/, next: Twig.expression.set.operationsExtended, compile(token, stack, output) { let {value} = token; @@ -488,14 +508,20 @@ module.exports = function (Twig) { * Match a parameter set start. */ type: Twig.expression.type.parameter.start, - regex: /^\(/, + regex: /^(\?\.)?\(/, next: Twig.expression.set.expressions.concat([Twig.expression.type.parameter.end]), validate(match, tokens) { const lastToken = tokens[tokens.length - 1]; // We can't use the regex to test if we follow a space because expression is trimmed return lastToken && (!Twig.expression.reservedWords.includes(lastToken.value.trim())); }, - compile: Twig.expression.fn.compile.pushBoth, + compile(token, stack, output) { + token.optionalCall = token.match[1] === '?.'; + token.value = '('; + delete token.match; + output.push(token); + stack.push(token); + }, parse: Twig.expression.fn.parse.push }, { @@ -523,6 +549,7 @@ module.exports = function (Twig) { token = output.pop(); } + endToken.optionalCall = token.optionalCall; paramStack.unshift(token); // Get the token preceding the parameters @@ -580,7 +607,7 @@ module.exports = function (Twig) { }, { type: Twig.expression.type.slice, - regex: /^\[(-?\w*:-?\w*)\]/, + regex: /^\[(-?\w*:-?\w*)]/, next: Twig.expression.set.operationsExtended, compile(token, stack, output) { const sliceRange = token.match[1].split(':'); @@ -651,7 +678,7 @@ module.exports = function (Twig) { * Match an array end. */ type: Twig.expression.type.array.end, - regex: /^\]/, + regex: /^]/, next: Twig.expression.set.operationsExtended, compile(token, stack, output) { let i = stack.length - 1; @@ -711,7 +738,7 @@ module.exports = function (Twig) { // representation of a hash map is defined. { type: Twig.expression.type.object.end, - regex: /^\}/, + regex: /^}/, next: Twig.expression.set.operationsExtended, compile(token, stack, output) { let i = stack.length - 1; @@ -820,8 +847,7 @@ module.exports = function (Twig) { return '('; }, compile(token, stack, output) { - const fn = token.match[1]; - token.fn = fn; + token.fn = token.match[1]; // Cleanup token delete token.match; delete token.value; @@ -872,13 +898,18 @@ module.exports = function (Twig) { validate(match) { return (!Twig.expression.reservedWords.includes(match[0])); }, - parse(token, stack, context) { + parse(token, stack, context, nextToken) { const state = this; // Get the variable from the context return Twig.expression.resolveAsync.call(state, context[token.value], context) .then(value => { - if (state.template.options.strictVariables && value === undefined) { + const isOptionalChain = nextToken && + (nextToken.type === Twig.expression.type.key.period || + nextToken.type === Twig.expression.type.key.brackets) && + nextToken.optional; + + if (state.template.options.strictVariables && value === undefined && !isOptionalChain) { throw new Twig.Error('Variable "' + token.value + '" does not exist.'); } @@ -888,12 +919,13 @@ module.exports = function (Twig) { }, { type: Twig.expression.type.key.period, - regex: /^\.(\w+)/, + regex: /^(\?\.|\.)(\w+)/, next: Twig.expression.set.operationsExtended.concat([ Twig.expression.type.parameter.start ]), compile(token, stack, output) { - token.key = token.match[1]; + token.optional = token.match[1] === '?.'; + token.key = token.match[2]; delete token.match; delete token.value; @@ -903,12 +935,16 @@ module.exports = function (Twig) { const state = this; const {key} = token; const object = stack.pop(); + const normalizedObject = normalizeObject(object); let value; - if (object && !Object.prototype.hasOwnProperty.call(object, key) && state.template.options.strictVariables) { - const keys = Object.keys(object); + if (normalizedObject && !(key in normalizedObject) && + !normalizedObject['get' + key.slice(0, 1).toUpperCase() + key.slice(1)] && + !normalizedObject['is' + key.slice(0, 1).toUpperCase() + key.slice(1)] && + state.template.options.strictVariables) { + const keys = Object.keys(normalizedObject); if (keys.length > 0) { - throw new Twig.Error('Key "' + key + '" for object with keys "' + Object.keys(object).join(', ') + '" does not exist.'); + throw new Twig.Error('Key "' + key + '" for object with keys "' + keys.join(', ') + '" does not exist.'); } else { throw new Twig.Error('Key "' + key + '" does not exist as the object is empty.'); } @@ -924,12 +960,12 @@ module.exports = function (Twig) { }; // Get the variable from the context - if (typeof object === 'object' && key in object) { - value = object[key]; - } else if (object['get' + capitalize(key)]) { - value = object['get' + capitalize(key)]; - } else if (object['is' + capitalize(key)]) { - value = object['is' + capitalize(key)]; + if (key in normalizedObject) { + value = normalizedObject[key]; + } else if (normalizedObject['get' + capitalize(key)]) { + value = normalizedObject['get' + capitalize(key)]; + } else if (normalizedObject['is' + capitalize(key)]) { + value = normalizedObject['is' + capitalize(key)]; } else { value = undefined; } @@ -945,12 +981,13 @@ module.exports = function (Twig) { }, { type: Twig.expression.type.key.brackets, - regex: /^\[([^\]]*)\]/, + regex: /^(\?\.)?\[([^\]]*)]/, next: Twig.expression.set.operationsExtended.concat([ Twig.expression.type.parameter.start ]), compile(token, stack, output) { - const match = token.match[1]; + const match = token.match[2]; + token.optional = token.match[1] === '?.'; delete token.value; delete token.match; @@ -975,9 +1012,10 @@ module.exports = function (Twig) { }) .then(key => { object = stack.pop(); + const normalizedObject = normalizeObject(object); - if (object && !Object.prototype.hasOwnProperty.call(object, key) && state.template.options.strictVariables) { - const keys = Object.keys(object); + if (normalizedObject && !(key in normalizedObject) && state.template.options.strictVariables) { + const keys = Object.keys(normalizedObject); if (keys.length > 0) { throw new Twig.Error('Key "' + key + '" for array with keys "' + keys.join(', ') + '" does not exist.'); } else { @@ -988,13 +1026,13 @@ module.exports = function (Twig) { } // Get the variable from the context - if (typeof object === 'object' && key in object) { - value = object[key]; + if (key in normalizedObject) { + value = normalizedObject[key]; } else { value = null; } - // When resolving an expression we need to pass nextToken in case the expression is a function + // When resolving an expression, we need to pass nextToken in case the expression is a function return Twig.expression.resolveAsync.call(state, value, object, params, nextToken); }) .then(result => { @@ -1052,13 +1090,25 @@ module.exports = function (Twig) { * * If the value is a function, it is executed with a context parameter. * - * @param {string} key The context object key. + * @param {*} value The value to resolve. * @param {Object} context The render context. + * @param {Array} params The parameters to pass to the function. + * @param {Object} nextToken The next token in the expression. + * @param {Object} object The object context. + * + * @return {Promise} A promise that resolves to the resolved value. */ Twig.expression.resolveAsync = function (value, context, params, nextToken, object) { const state = this; if (typeof value !== 'function') { + if (nextToken && + nextToken.type === Twig.expression.type.parameter.end && + nextToken.optionalCall) { + nextToken.cleanup = true; + return Twig.Promise.resolve(undefined); + } + return Twig.Promise.resolve(value); } @@ -1093,6 +1143,17 @@ module.exports = function (Twig) { }); }; + /** + * Resolve a context value synchronously. + * + * @param {*} value The value to resolve. + * @param {Object} context The render context. + * @param {Array} params The parameters to pass to the function. + * @param {Object} nextToken The next token in the expression. + * @param {Object} object The object context. + * + * @return {Promise} A promise that resolves to the resolved value. + */ Twig.expression.resolve = function (value, context, params, nextToken, object) { return Twig.async.potentiallyAsync(this, false, function () { return Twig.expression.resolveAsync.call(this, value, context, params, nextToken, object); @@ -1151,6 +1212,8 @@ module.exports = function (Twig) { * Break an expression into tokens defined in Twig.expression.definitions. * * @param {Object} rawToken The string to tokenize. + * @param {string} rawToken.value The expression string to tokenize. + * @param {Object} rawToken.position Optional position information for error messages. * * @return {Array} An array of tokens. */ @@ -1275,8 +1338,9 @@ module.exports = function (Twig) { * Compile an expression token. * * @param {Object} rawToken The uncompiled token. + * @param {string} rawToken.value The expression string to compile. * - * @return {Object} The compiled token. + * @return {Object} The compiled token with a `stack` property. */ Twig.expression.compile = function (rawToken) { // Tokenize expression @@ -1321,10 +1385,10 @@ module.exports = function (Twig) { * * @param {Array} tokens An array of compiled expression tokens. * @param {Object} context The render context to parse the tokens with. + * @param {boolean} tokensAreParameters Indicates if the tokens are parameters. + * @param {boolean} allowAsync Indicates if async operations are allowed. * - * @return {Object} The result of parsing all the tokens. The result - * can be anything, String, Array, Object, etc... based on - * the given expression. + * @return {Promise} A promise that resolves to the parsed result. */ Twig.expression.parse = function (tokens, context, tokensAreParameters, allowAsync) { const state = this; diff --git a/test/test.expressions.optional-chaining.js b/test/test.expressions.optional-chaining.js new file mode 100644 index 00000000..7a6bfd5f --- /dev/null +++ b/test/test.expressions.optional-chaining.js @@ -0,0 +1,717 @@ +const Twig = require('..').factory(); + +const {twig} = Twig; + +describe('Twig.js Expression -> Optional Chaining (?.) ->', function () { + describe('Basic property access ->', function () { + it('should return undefined when accessing property of null', function () { + const testTemplate = twig({data: '{{ a?.prop }}'}); + const output = testTemplate.render({a: null}); + + output.should.equal(''); + }); + + it('should return undefined when accessing property of undefined', function () { + const testTemplate = twig({data: '{{ a?.prop }}'}); + const output = testTemplate.render({a: undefined}); + + output.should.equal(''); + }); + + it('should return property value when object exists', function () { + const testTemplate = twig({data: '{{ a?.prop }}'}); + const output = testTemplate.render({a: {prop: 'value'}}); + + output.should.equal('value'); + }); + + it('should return undefined when property does not exist', function () { + const testTemplate = twig({data: '{{ a?.prop }}'}); + const output = testTemplate.render({a: {other: 'value'}}); + + output.should.equal(''); + }); + }); + + describe('Nested property access ->', function () { + it('should return value when all levels exist', function () { + const testTemplate = twig({data: '{{ a?.b?.c?.d }}'}); + const output = testTemplate.render({a: {b: {c: {d: 'deep'}}}}); + + output.should.equal('deep'); + }); + + it('should stop at first null and return undefined', function () { + const testTemplate = twig({data: '{{ a?.b?.c?.d }}'}); + const output = testTemplate.render({a: {b: null, c: {d: 'deep'}}}); + + output.should.equal(''); + }); + + it('should stop at first undefined and return undefined', function () { + const testTemplate = twig({data: '{{ a?.b?.c?.d }}'}); + const output = testTemplate.render({a: {b: {c: undefined, d: 'deep'}}}); + + output.should.equal(''); + }); + + it('should stop at undefined in nested chain', function () { + const testTemplate = twig({data: '{{ a?.b?.c }}'}); + const output = testTemplate.render({a: {b: undefined}}); + + output.should.equal(''); + }); + + it('should handle deeply nested chains (5 levels)', function () { + const testTemplate = twig({data: '{{ a?.b?.c?.d?.e }}'}); + const output = testTemplate.render({a: {b: {c: {d: {e: 'deep'}}}}}); + + output.should.equal('deep'); + }); + + it('should stop at null in deeply nested chains', function () { + const testTemplate = twig({data: '{{ a?.b?.c?.d?.e }}'}); + const output = testTemplate.render({a: {b: {c: null, d: {e: 'deep'}}}}); + + output.should.equal(''); + }); + }); + + describe('Array element access ->', function () { + it('should return element when index exists', function () { + const testTemplate = twig({data: '{{ a?.[0] }}'}); + const output = testTemplate.render({a: ['first', 'second', 'third']}); + + output.should.equal('first'); + }); + + it('should return undefined when accessing out of bounds', function () { + const testTemplate = twig({data: '{{ a?.[10] }}'}); + const output = testTemplate.render({a: ['first', 'second']}); + + output.should.equal(''); + }); + + it('should return undefined when accessing negative index', function () { + const testTemplate = twig({data: '{{ a?.[-1] }}'}); + const output = testTemplate.render({a: ['first', 'second']}); + + output.should.equal(''); + }); + + it('should return undefined when accessing non-numeric index', function () { + const testTemplate = twig({data: '{{ a?.["test"] }}'}); + const output = testTemplate.render({a: ['first', 'second']}); + + output.should.equal(''); + }); + + it('should return undefined when array is null', function () { + const testTemplate = twig({data: '{{ a?.[0] }}'}); + const output = testTemplate.render({a: null}); + + output.should.equal(''); + }); + + it('should return undefined when array is undefined', function () { + const testTemplate = twig({data: '{{ a?.[0] }}'}); + const output = testTemplate.render({a: undefined}); + + output.should.equal(''); + }); + + it('should work with nested arrays', function () { + const testTemplate = twig({data: '{{ a?.[0]?.[1] }}'}); + const output = testTemplate.render({a: [[1, 2, 3], [4, 5, 6]]}); + + output.should.equal('2'); + }); + + it('should stop at null in nested arrays', function () { + const testTemplate = twig({data: '{{ a?.[0]?.[1] }}'}); + const output = testTemplate.render({a: [null, [4, 5, 6]]}); + + output.should.equal(''); + }); + + it('should stop at undefined in nested arrays', function () { + const testTemplate = twig({data: '{{ a?.[0]?.[1] }}'}); + const output = testTemplate.render({a: [undefined, [4, 5, 6]]}); + + output.should.equal(''); + }); + }); + + describe('Method calls ->', function () { + it('should call method when object exists', function () { + const testTemplate = twig({data: '{{ a?.toUpperCase() }}'}); + const output = testTemplate.render({a: 'hello'}); + + output.should.equal('HELLO'); + }); + + it('should support optional call method when object exists', function () { + const testTemplate = twig({data: '{{ a?.toUpperCase?.() }}'}); + const output = testTemplate.render({a: 'hello'}); + + output.should.equal('HELLO'); + }); + + it('should return undefined when object is null', function () { + const testTemplate = twig({data: '{{ a?.toUpperCase() }}'}); + const output = testTemplate.render({a: null}); + + output.should.equal(''); + }); + + it('should return undefined when object is undefined', function () { + const testTemplate = twig({data: '{{ a?.toUpperCase() }}'}); + const output = testTemplate.render({a: undefined}); + + output.should.equal(''); + }); + + it('should return undefined when method does not exist', function () { + const testTemplate = twig({data: '{{ a?.nonExistentMethod() }}'}); + const output = testTemplate.render({a: 'hello'}); + + output.should.equal(''); + }); + + it('should support optional call syntax on a missing method', function () { + const testTemplate = twig({data: '{{ a.toCamelCase?.() }}'}); + const output = testTemplate.render({a: 'hello'}); + + output.should.equal(''); + }); + + it('should work with nested method calls', function () { + const testTemplate = twig({data: '{{ a?.b?.c?.toUpperCase() }}'}); + const output = testTemplate.render({a: {b: {c: 'hello'}}}); + + output.should.equal('HELLO'); + }); + + it('should stop at null in nested method calls', function () { + const testTemplate = twig({data: '{{ a?.b?.c?.toUpperCase() }}'}); + const output = testTemplate.render({a: {b: null, c: 'hello'}}); + + output.should.equal(''); + }); + + it('should work with method that takes parameters', function () { + const testTemplate = twig({data: '{{ a?.replace("e", "3") }}'}); + const output = testTemplate.render({a: 'hello'}); + + output.should.equal('h3llo'); + }); + + it('should work with built-in functions after optional chaining', function () { + const testTemplate = twig({data: '{{ a?.length }}'}); + const output = testTemplate.render({a: 'hello'}); + + output.should.equal('5'); + }); + + it('should work with array methods', function () { + const testTemplate = twig({data: '{{ a?.slice(0, 1) }}'}); + const output = testTemplate.render({a: ['first', 'second', 'third']}); + + output.should.equal('first'); + }); + + it('should return undefined when array method called on null', function () { + const testTemplate = twig({data: '{{ a?.slice?.length }}'}); + const output = testTemplate.render({a: null}); + + output.should.equal(''); + }); + + it('should work with array method on nested array', function () { + const testTemplate = twig({data: '{{ a?.[0]?.slice(1) }}'}); + const output = testTemplate.render({a: ['abc', 'def', 'ghi']}); + + output.should.equal('bc'); + }); + }); + + describe('Filters after optional chaining ->', function () { + it('should apply filter when object exists', function () { + const testTemplate = twig({data: '{{ a?.value | upper }}'}); + const output = testTemplate.render({a: {value: 'hello'}}); + + output.should.equal('HELLO'); + }); + + it('should return undefined when object is null', function () { + const testTemplate = twig({data: '{{ a?.value | upper }}'}); + const output = testTemplate.render({a: null}); + + output.should.equal(''); + }); + + it('should apply filter on array elements', function () { + const testTemplate = twig({data: '{{ a?.[0] | upper }}'}); + const output = testTemplate.render({a: ['hello', 'world']}); + + output.should.equal('HELLO'); + }); + + it('should return undefined when array is null', function () { + const testTemplate = twig({data: '{{ a?.[0] | upper }}'}); + const output = testTemplate.render({a: null}); + + output.should.equal(''); + }); + + it('should work with nested optional chaining and filters', function () { + const testTemplate = twig({data: '{{ a?.b?.c | upper }}'}); + const output = testTemplate.render({a: {b: {c: 'hello'}}}); + + output.should.equal('HELLO'); + }); + + it('should stop at null in nested optional chaining with filters', function () { + const testTemplate = twig({data: '{{ a?.b?.c | upper }}'}); + const output = testTemplate.render({a: {b: null, c: 'hello'}}); + + output.should.equal(''); + }); + + it('should apply default filter after optional chaining', function () { + const testTemplate = twig({data: '{{ a?.value | default("N/A") }}'}); + const output = testTemplate.render({a: {value: 'hello'}}); + + output.should.equal('hello'); + }); + + it('should use default filter value when optional chaining returns undefined', function () { + const testTemplate = twig({data: '{{ a?.value | default("N/A") }}'}); + const output = testTemplate.render({a: null}); + + output.should.equal('N/A'); + }); + }); + + describe('Combination of optional chaining ->', function () { + it('should work with property and method call', function () { + const testTemplate = twig({data: '{{ a?.b?.toUpperCase() }}'}); + const output = testTemplate.render({a: {b: 'hello'}}); + + output.should.equal('HELLO'); + }); + + it('should work with array and property access', function () { + const testTemplate = twig({data: '{{ a?.[0]?.prop }}'}); + const output = testTemplate.render({a: [{prop: 'value'}]}); + + output.should.equal('value'); + }); + + it('should work with array and method call', function () { + const testTemplate = twig({data: '{{ a?.[0]?.toUpperCase() }}'}); + const output = testTemplate.render({a: ['hello']}); + + output.should.equal('HELLO'); + }); + + it('should handle empty string gracefully', function () { + const testTemplate = twig({data: '{{ a?.b?.c }}'}); + const output = testTemplate.render({a: {b: {c: ''}}}); + + output.should.equal(''); + }); + + it('should handle zero value', function () { + const testTemplate = twig({data: '{{ a?.b?.c }}'}); + const output = testTemplate.render({a: {b: {c: 0}}}); + + output.should.equal('0'); + }); + + it('should handle false value', function () { + const testTemplate = twig({data: '{{ a?.b?.c }}'}); + const output = testTemplate.render({a: {b: {c: false}}}); + + output.should.equal('false'); + }); + + it('should handle number value', function () { + const testTemplate = twig({data: '{{ a?.b?.c }}'}); + const output = testTemplate.render({a: {b: {c: 42}}}); + + output.should.equal('42'); + }); + + it('should handle boolean true value', function () { + const testTemplate = twig({data: '{{ a?.b?.c }}'}); + const output = testTemplate.render({a: {b: {c: true}}}); + + output.should.equal('true'); + }); + + it('should handle null value', function () { + const testTemplate = twig({data: '{{ a?.b?.c }}'}); + const output = testTemplate.render({a: {b: {c: null}}}); + + output.should.equal(''); + }); + + it('should handle undefined value', function () { + const testTemplate = twig({data: '{{ a?.b?.c }}'}); + const output = testTemplate.render({a: {b: {c: undefined}}}); + + output.should.equal(''); + }); + }); + + describe('Optional chaining with if conditions ->', function () { + it('should work in conditional context', function () { + const testTemplate = twig({data: '{% if a?.b?.c %}exists{% else %}empty{% endif %}'}); + const output = testTemplate.render({a: {b: {c: 'yes'}}}); + + output.should.equal('exists'); + }); + + it('should work with if condition for undefined', function () { + const testTemplate = twig({data: '{% if a?.b?.c %}exists{% else %}empty{% endif %}'}); + const output = testTemplate.render({a: {b: {c: undefined}}}); + + output.should.equal('empty'); + }); + + it('should work with if condition for null', function () { + const testTemplate = twig({data: '{% if a?.b?.c %}exists{% else %}empty{% endif %}'}); + const output = testTemplate.render({a: {b: {c: null}}}); + + output.should.equal('empty'); + }); + + it('should work with if condition for empty string', function () { + const testTemplate = twig({data: '{% if a?.b?.c %}exists{% else %}empty{% endif %}'}); + const output = testTemplate.render({a: {b: {c: ''}}}); + + output.should.equal('empty'); + }); + + it('should work with if condition for zero (treated as falsy)', function () { + const testTemplate = twig({data: '{% if a?.b?.c %}exists{% else %}empty{% endif %}'}); + const output = testTemplate.render({a: {b: {c: 0}}}); + + output.should.equal('empty'); + }); + + it('should work with if condition for false', function () { + const testTemplate = twig({data: '{% if a?.b?.c %}exists{% else %}empty{% endif %}'}); + const output = testTemplate.render({a: {b: {c: false}}}); + + output.should.equal('empty'); + }); + + it('should work with if condition for true', function () { + const testTemplate = twig({data: '{% if a?.b?.c %}exists{% else %}empty{% endif %}'}); + const output = testTemplate.render({a: {b: {c: true}}}); + + output.should.equal('exists'); + }); + + it('should work with if condition for nested null', function () { + const testTemplate = twig({data: '{% if a?.b?.c %}exists{% else %}empty{% endif %}'}); + const output = testTemplate.render({a: {b: null}}); + + output.should.equal('empty'); + }); + + it('should work with nested if conditions', function () { + const testTemplate = twig({data: '{% if a?.b %}has b{% else %}no b{% endif %} {% if a?.c %}has c{% else %}no c{% endif %}'}); + const output = testTemplate.render({a: {b: 'yes', c: 'yes'}}); + + output.should.equal('has b has c'); + }); + + it('should work with nested if conditions with null', function () { + const testTemplate = twig({data: '{% if a?.b %}has b{% else %}no b{% endif %} {% if a?.c %}has c{% else %}no c{% endif %}'}); + const output = testTemplate.render({a: {b: 'yes', c: null}}); + + output.should.equal('has b no c'); + }); + }); + + describe('Optional chaining with null-coalescing operator ->', function () { + it('should work with null-coalescing operator', function () { + const testTemplate = twig({data: '{{ a?.b ?? "default" }}'}); + const output = testTemplate.render({a: {b: 'value'}}); + + output.should.equal('value'); + }); + + it('should use default when optional chaining returns undefined', function () { + const testTemplate = twig({data: '{{ a?.b ?? "default" }}'}); + const output = testTemplate.render({a: null}); + + output.should.equal('default'); + }); + + it('should work with nested null-coalescing', function () { + const testTemplate = twig({data: '{{ a?.b?.c ?? "default" }}'}); + const output = testTemplate.render({a: {b: {c: 'value'}}}); + + output.should.equal('value'); + }); + + it('should use default when nested chain has null', function () { + const testTemplate = twig({data: '{{ a?.b?.c ?? "default" }}'}); + const output = testTemplate.render({a: {b: null}}); + + output.should.equal('default'); + }); + + it('should use default when nested chain has undefined', function () { + const testTemplate = twig({data: '{{ a?.b?.c ?? "default" }}'}); + const output = testTemplate.render({a: {b: undefined}}); + + output.should.equal('default'); + }); + + it('should work with multiple optional chaining and null-coalescing', function () { + const testTemplate = twig({data: '{{ a?.b?.c?.d ?? "default" }}'}); + const output = testTemplate.render({a: {b: {c: {d: 'value'}}}}); + + output.should.equal('value'); + }); + + it('should use default when deeply nested chain has null', function () { + const testTemplate = twig({data: '{{ a?.b?.c?.d ?? "default" }}'}); + const output = testTemplate.render({a: {b: {c: null}}}); + + output.should.equal('default'); + }); + + it('should work with null-coalescing on array access', function () { + const testTemplate = twig({data: '{{ a?.[0] ?? "default" }}'}); + const output = testTemplate.render({a: ['value']}); + + output.should.equal('value'); + }); + + it('should use default when array access is null', function () { + const testTemplate = twig({data: '{{ a?.[0] ?? "default" }}'}); + const output = testTemplate.render({a: [null]}); + + output.should.equal('default'); + }); + + it('should work with chained null-coalescing', function () { + const testTemplate = twig({data: '{{ a?.b ?? c?.d ?? "default" }}'}); + const output = testTemplate.render({a: {b: 'value'}, c: {d: 'fallback'}}); + + output.should.equal('value'); + }); + + it('should use second default when first optional chaining returns value', function () { + const testTemplate = twig({data: '{{ a?.b ?? c?.d ?? "default" }}'}); + const output = testTemplate.render({a: {b: 'value'}, c: null}); + + output.should.equal('value'); + }); + + it('should use second default when first optional chaining returns undefined', function () { + const testTemplate = twig({data: '{{ a?.b ?? c?.d ?? "default" }}'}); + const output = testTemplate.render({a: {b: undefined}, c: {d: 'fallback'}}); + + output.should.equal('fallback'); + }); + + it('should use final default when all optional chaining return undefined', function () { + const testTemplate = twig({data: '{{ a?.b ?? c?.d ?? "default" }}'}); + const output = testTemplate.render({a: null, c: null}); + + output.should.equal('default'); + }); + }); + + describe('Optional chaining with ternary operator ->', function () { + it('should work with ternary operator', function () { + const testTemplate = twig({data: '{{ a?.b ? "exists" : "does not exist" }}'}); + const output = testTemplate.render({a: {b: 'value'}}); + + output.should.equal('exists'); + }); + + it('should work with ternary for undefined', function () { + const testTemplate = twig({data: '{{ a?.b ? "exists" : "does not exist" }}'}); + const output = testTemplate.render({a: null}); + + output.should.equal('does not exist'); + }); + + it('should work with ternary for null', function () { + const testTemplate = twig({data: '{{ a?.b ? "exists" : "does not exist" }}'}); + const output = testTemplate.render({a: {b: null}}); + + output.should.equal('does not exist'); + }); + + it('should work with ternary for zero (treated as falsy)', function () { + const testTemplate = twig({data: '{{ a?.b ? "exists" : "does not exist" }}'}); + const output = testTemplate.render({a: {b: 0}}); + + output.should.equal('does not exist'); + }); + + it('should work with ternary for false (falsy)', function () { + const testTemplate = twig({data: '{{ a?.b ? "exists" : "does not exist" }}'}); + const output = testTemplate.render({a: {b: false}}); + + output.should.equal('does not exist'); + }); + + it('should work with ternary for true (truthy)', function () { + const testTemplate = twig({data: '{{ a?.b ? "exists" : "does not exist" }}'}); + const output = testTemplate.render({a: {b: true}}); + + output.should.equal('exists'); + }); + + it('should work with ternary for empty string (falsy)', function () { + const testTemplate = twig({data: '{{ a?.b ? "exists" : "does not exist" }}'}); + const output = testTemplate.render({a: {b: ''}}); + + output.should.equal('does not exist'); + }); + + it('should work with ternary for empty array (falsy)', function () { + const testTemplate = twig({data: '{{ a?.b ? "exists" : "does not exist" }}'}); + const output = testTemplate.render({a: {b: []}}); + + output.should.equal('does not exist'); + }); + + it('should work with ternary for empty object (truthy)', function () { + const testTemplate = twig({data: '{{ a?.b ? "exists" : "does not exist" }}'}); + const output = testTemplate.render({a: {b: {}}}); + + output.should.equal('exists'); + }); + }); + + describe('Optional chaining with computed property access ->', function () { + it('should work with computed property access', function () { + const testTemplate = twig({data: '{{ a?.["key"] }}'}); + const output = testTemplate.render({a: {key: 'value'}}); + + output.should.equal('value'); + }); + + it('should work with dynamic key in computed property access', function () { + const testTemplate = twig({data: '{{ a?.[key] }}'}); + const output = testTemplate.render({a: {prop: 'value'}, key: 'prop'}); + + output.should.equal('value'); + }); + + it('should stop at null in computed property access', function () { + const testTemplate = twig({data: '{{ a?.[key] }}'}); + const output = testTemplate.render({a: null, key: 'prop'}); + + output.should.equal(''); + }); + + it('should stop at undefined in computed property access', function () { + const testTemplate = twig({data: '{{ a?.[key] }}'}); + const output = testTemplate.render({a: undefined, key: 'prop'}); + + output.should.equal(''); + }); + + it('should work with computed property in nested chain', function () { + const testTemplate = twig({data: '{{ a?.[key]?.[nested] }}'}); + const output = testTemplate.render({a: {prop: {test: 'value'}}, key: 'prop', nested: 'test'}); + + output.should.equal('value'); + }); + + it('should work with computed property accessing array', function () { + const testTemplate = twig({data: '{{ a?.[index] }}'}); + const output = testTemplate.render({a: ['first', 'second', 'third'], index: 1}); + + output.should.equal('second'); + }); + }); + + describe('Strict variables mode with optional chaining ->', function () { + it('should throw error for missing key in strict mode', function () { + const testTemplate = twig({data: '{{ a?.b }}', options: {strictVariables: true}}); + try { + testTemplate.render({a: {}}); + } catch (error) { + error.message.should.match(/does not exist/); + } + }); + + it('should not throw for null in strict mode', function () { + const testTemplate = twig({data: '{{ a?.b }}', options: {strictVariables: true}}); + const output = testTemplate.render({a: null}); + + output.should.equal(''); + }); + + it('should not throw for undefined in strict mode', function () { + const testTemplate = twig({data: '{{ a?.b }}', options: {strictVariables: true}}); + const output = testTemplate.render({a: undefined}); + + output.should.equal(''); + }); + + it('should not throw for null in nested chain in strict mode', function () { + const testTemplate = twig({data: '{{ a?.b?.c }}', options: {strictVariables: true}}); + const output = testTemplate.render({a: {b: null}}); + + output.should.equal(''); + }); + + it('should not throw for undefined in nested chain in strict mode', function () { + const testTemplate = twig({data: '{{ a?.b?.c }}', options: {strictVariables: true}}); + const output = testTemplate.render({a: {b: undefined}}); + + output.should.equal(''); + }); + }); + + describe('Optional chaining in loops ->', function () { + it('should work in for loop', function () { + const testTemplate = twig({data: '{% for item in a?.items %}{{ item }},{% endfor %}'}); + const output = testTemplate.render({a: {items: ['a', 'b', 'c']}}); + + output.should.equal('a,b,c,'); + }); + + it('should handle null in for loop', function () { + const testTemplate = twig({data: '{% for item in a?.items %}{{ item }},{% endfor %}'}); + const output = testTemplate.render({a: {items: null}}); + + output.should.equal(''); + }); + + it('should handle undefined in for loop', function () { + const testTemplate = twig({data: '{% for item in a?.items %}{{ item }},{% endfor %}'}); + const output = testTemplate.render({a: {items: undefined}}); + + output.should.equal(''); + }); + + it('should work with nested optional chaining in for loop', function () { + const testTemplate = twig({data: '{% for item in a?.items %}{{ item?.name }},{% endfor %}'}); + const output = testTemplate.render({a: {items: [{name: 'first'}, {name: 'second'}]}}); + + output.should.equal('first,second,'); + }); + + it('should handle null items in for loop', function () { + const testTemplate = twig({data: '{% for item in a?.items %}{{ item?.name }},{% endfor %}'}); + const output = testTemplate.render({a: {items: [null, {name: 'second'}]}}); + + output.should.equal(',second,'); + }); + }); +}); From ffb9663fa61da9bf9270a8b5d471d23a974d077b Mon Sep 17 00:00:00 2001 From: Yahor Siarheyenka Date: Mon, 16 Mar 2026 20:33:54 +0100 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20address=20comments=20=F0=9F=93=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/en/reference/implementation-notes.md | 3 +-- node/build.js | 10 ++++----- src/twig.expression.js | 26 ++++++++++++----------- 3 files changed, 20 insertions(+), 19 deletions(-) diff --git a/docs/en/reference/implementation-notes.md b/docs/en/reference/implementation-notes.md index 1dd2c66f..a5104fbc 100644 --- a/docs/en/reference/implementation-notes.md +++ b/docs/en/reference/implementation-notes.md @@ -139,6 +139,5 @@ Example syntax: `{{ expression operator expression }}` - Logic (`and`, `or`, `not`, `()`): Supported - Bitwise (`b-and`, `b-or`, `b-xor`): Supported - Comparisons (`==`, `!=`, `<`, `>`, `>=`, `<=`, `===`): Supported -- Others (`..`, `|`, `~`, `.`, `[]`, `?:`): Supported -- Optional chaining (`?.`): Supported +- Others (`..`, `|`, `~`, `.`, `?.`, `[]`, `?:`): Supported - Null-coalescing (`??`): Supported diff --git a/node/build.js b/node/build.js index acba633d..c09d6ca9 100644 --- a/node/build.js +++ b/node/build.js @@ -1,17 +1,17 @@ const child_process = require('child_process'); const semver = require('semver'); -const environmentVariables = structuredClone(process.env); +const environmentVariables = {...process.env}; if (semver.satisfies(process.version, '>=17')) { environmentVariables['NODE_OPTIONS'] = '--openssl-legacy-provider'; } -child_process.execFile( - 'npx', - ['webpack'], +child_process.spawn( + 'webpack', + [], { env: environmentVariables, stdio: 'inherit', - }, + } ); diff --git a/src/twig.expression.js b/src/twig.expression.js index 24af2776..85a1bfe4 100644 --- a/src/twig.expression.js +++ b/src/twig.expression.js @@ -22,7 +22,7 @@ module.exports = function (Twig) { * @param {Array|Object} params The parameters to parse. * @param {Object} context The render context. * - * @return {Promise} A promise that resolves to the parsed parameters or false. + * @return {Promise<*>} A promise that resolves to the parsed parameters or false. */ function parseParams(state, params, context) { if (params) { @@ -234,7 +234,7 @@ module.exports = function (Twig) { type: Twig.expression.type.operator.binary, // Match any of ??, ?:, +, *, /, -, %, ~, <=>, <, <=, >, >=, !=, ==, **, ?, :, and, b-and, or, b-or, b-xor, in, not in // and, or, in, not in, matches, starts with, ends with can be followed by a space or parenthesis - regex: /(^\?\?|^\?\s*:|^(b-and)|^(b-or)|^(b-xor)|^[+\-~%]|^\?(?![.\[])|^(<=>)|^:(?!\d])|^[!=]==?|^[!<>]=?|^\*\*?|^\/\/?|^(and)[(|\s+]|^(or)[(|\s+]|^(in)[(|\s+]|^(not in)[(|\s+]|^(matches)|^(starts with)|^(ends with)|^\.\.)/, + regex: /(^\?\?|^\?\s*:|^(b-and)|^(b-or)|^(b-xor)|^[+\-~%]|^\?(?![.\[])|^(<=>)|^[:](?!\d\])|^[!=]==?|^[!<>]=?|^\*\*?|^\/\/?|^(and)[(|\s+]|^(or)[(|\s+]|^(in)[(|\s+]|^(not in)[(|\s+]|^(matches)|^(starts with)|^(ends with)|^\.\.)/, next: Twig.expression.set.expressions, transform(match, tokens) { switch (match[0]) { @@ -242,7 +242,7 @@ module.exports = function (Twig) { case 'or(': case 'in(': case 'not in(': - // Strip off the (if it exists) + // Strip off the ( if it exists tokens[tokens.length - 1].value = match[2]; return match[0]; default: @@ -368,7 +368,7 @@ module.exports = function (Twig) { */ type: Twig.expression.type.string, // See: http://blog.stevenlevithan.com/archives/match-quoted-string - regex: /^(["'])(?:\\[\s\S]|(?!\1)[^\\])*\1/, + regex: /^(["'])(?:(?=(\\?))\2[\s\S])*?\1/, next: Twig.expression.set.operationsExtended, compile(token, stack, output) { let {value} = token; @@ -607,7 +607,7 @@ module.exports = function (Twig) { }, { type: Twig.expression.type.slice, - regex: /^\[(-?\w*:-?\w*)]/, + regex: /^\[(-?\w*:-?\w*)\]/, next: Twig.expression.set.operationsExtended, compile(token, stack, output) { const sliceRange = token.match[1].split(':'); @@ -678,7 +678,7 @@ module.exports = function (Twig) { * Match an array end. */ type: Twig.expression.type.array.end, - regex: /^]/, + regex: /^\]/, next: Twig.expression.set.operationsExtended, compile(token, stack, output) { let i = stack.length - 1; @@ -738,7 +738,7 @@ module.exports = function (Twig) { // representation of a hash map is defined. { type: Twig.expression.type.object.end, - regex: /^}/, + regex: /^\}/, next: Twig.expression.set.operationsExtended, compile(token, stack, output) { let i = stack.length - 1; @@ -1032,7 +1032,7 @@ module.exports = function (Twig) { value = null; } - // When resolving an expression, we need to pass nextToken in case the expression is a function + // When resolving an expression we need to pass nextToken in case the expression is a function return Twig.expression.resolveAsync.call(state, value, object, params, nextToken); }) .then(result => { @@ -1096,7 +1096,7 @@ module.exports = function (Twig) { * @param {Object} nextToken The next token in the expression. * @param {Object} object The object context. * - * @return {Promise} A promise that resolves to the resolved value. + * @return {Promise<*>} A promise that resolves to the resolved value. */ Twig.expression.resolveAsync = function (value, context, params, nextToken, object) { const state = this; @@ -1146,13 +1146,13 @@ module.exports = function (Twig) { /** * Resolve a context value synchronously. * - * @param {*} value The value to resolve. + * @param {string} value The context object key. * @param {Object} context The render context. * @param {Array} params The parameters to pass to the function. * @param {Object} nextToken The next token in the expression. * @param {Object} object The object context. * - * @return {Promise} A promise that resolves to the resolved value. + * @return {*} The resolved value. */ Twig.expression.resolve = function (value, context, params, nextToken, object) { return Twig.async.potentiallyAsync(this, false, function () { @@ -1388,7 +1388,9 @@ module.exports = function (Twig) { * @param {boolean} tokensAreParameters Indicates if the tokens are parameters. * @param {boolean} allowAsync Indicates if async operations are allowed. * - * @return {Promise} A promise that resolves to the parsed result. + * @return {*|Promise<*>} The result of parsing all the tokens. The result + * can be anything, String, Array, Object, etc... based on + * the given expression. */ Twig.expression.parse = function (tokens, context, tokensAreParameters, allowAsync) { const state = this; From bce47923c8e59a089800efa9d44502e2347e8190 Mon Sep 17 00:00:00 2001 From: Yahor Siarheyenka Date: Tue, 17 Mar 2026 20:22:06 +0100 Subject: [PATCH 3/7] =?UTF-8?q?chore:=20refine=20jsdoc=20annotations=20?= =?UTF-8?q?=F0=9F=93=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/twig.expression.js | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/twig.expression.js b/src/twig.expression.js index 85a1bfe4..29a80c98 100644 --- a/src/twig.expression.js +++ b/src/twig.expression.js @@ -9,7 +9,7 @@ module.exports = function (Twig) { * * @param {*} object The value to normalize. * - * @return {*} Returns null if the value is null or undefined, otherwise returns an Object. + * @return {(Object|null)} Returns null if the value is null or undefined, otherwise returns an Object. */ function normalizeObject(object) { return object === null || object === undefined ? null : Object(object); @@ -19,7 +19,7 @@ module.exports = function (Twig) { * Parse parameters. * * @param {Object} state The expression state. - * @param {Array|Object} params The parameters to parse. + * @param {(Array|Object|null|undefined)} params The parameters to parse. * @param {Object} context The render context. * * @return {Promise<*>} A promise that resolves to the parsed parameters or false. @@ -1088,13 +1088,13 @@ module.exports = function (Twig) { /** * Resolve a context value. * - * If the value is a function, it is executed with a context parameter. + * If the value is a function, it is called with `object || context` as `this` and `params` as arguments. * * @param {*} value The value to resolve. * @param {Object} context The render context. - * @param {Array} params The parameters to pass to the function. - * @param {Object} nextToken The next token in the expression. - * @param {Object} object The object context. + * @param {(Array<*>|null|undefined)} params The parameters to pass to the function. + * @param {(Object|null|undefined)} nextToken The next token in the expression. + * @param {(Object|null|undefined)} object The object context. * * @return {Promise<*>} A promise that resolves to the resolved value. */ @@ -1146,11 +1146,11 @@ module.exports = function (Twig) { /** * Resolve a context value synchronously. * - * @param {string} value The context object key. + * @param {*} value The value to resolve. * @param {Object} context The render context. - * @param {Array} params The parameters to pass to the function. - * @param {Object} nextToken The next token in the expression. - * @param {Object} object The object context. + * @param {(Array<*>|null|undefined)} params The parameters to pass to the function. + * @param {(Object|null|undefined)} nextToken The next token in the expression. + * @param {(Object|null|undefined)} object The object context. * * @return {*} The resolved value. */ @@ -1166,7 +1166,7 @@ module.exports = function (Twig) { Twig.expression.handler = {}; /** - * Define a new expression type, available at Twig.logic.type.{type} + * Define a new expression type, available at Twig.expression.type.{type} * * @param {string} type The name of the new type. */ @@ -1215,7 +1215,7 @@ module.exports = function (Twig) { * @param {string} rawToken.value The expression string to tokenize. * @param {Object} rawToken.position Optional position information for error messages. * - * @return {Array} An array of tokens. + * @return {Object[]} An array of tokens. */ Twig.expression.tokenize = function (rawToken) { let expression = rawToken.value; @@ -1340,7 +1340,7 @@ module.exports = function (Twig) { * @param {Object} rawToken The uncompiled token. * @param {string} rawToken.value The expression string to compile. * - * @return {Object} The compiled token with a `stack` property. + * @return {Object} The compiled token with a `stack` property of compiled tokens. */ Twig.expression.compile = function (rawToken) { // Tokenize expression @@ -1383,7 +1383,7 @@ module.exports = function (Twig) { /** * Parse an RPN expression stack within a context. * - * @param {Array} tokens An array of compiled expression tokens. + * @param {(Object|Object[])} tokens A compiled expression token or array of compiled expression tokens. * @param {Object} context The render context to parse the tokens with. * @param {boolean} tokensAreParameters Indicates if the tokens are parameters. * @param {boolean} allowAsync Indicates if async operations are allowed. From dda08dd13c5d9323c665cbc1afd3de5446fe931e Mon Sep 17 00:00:00 2001 From: Yahor Siarheyenka Date: Wed, 18 Mar 2026 00:00:30 +0100 Subject: [PATCH 4/7] =?UTF-8?q?chore:=20undo=20=F0=9F=97=91=EF=B8=8F=20ref?= =?UTF-8?q?ined=20jsdoc=20annotations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/twig.expression.js | 55 +++++++----------------------------------- 1 file changed, 9 insertions(+), 46 deletions(-) diff --git a/src/twig.expression.js b/src/twig.expression.js index 29a80c98..6d57e5dd 100644 --- a/src/twig.expression.js +++ b/src/twig.expression.js @@ -4,26 +4,10 @@ module.exports = function (Twig) { 'use strict'; - /** - * Normalize an object. - * - * @param {*} object The value to normalize. - * - * @return {(Object|null)} Returns null if the value is null or undefined, otherwise returns an Object. - */ function normalizeObject(object) { return object === null || object === undefined ? null : Object(object); } - /** - * Parse parameters. - * - * @param {Object} state The expression state. - * @param {(Array|Object|null|undefined)} params The parameters to parse. - * @param {Object} context The render context. - * - * @return {Promise<*>} A promise that resolves to the parsed parameters or false. - */ function parseParams(state, params, context) { if (params) { return Twig.expression.parseAsync.call(state, params, context); @@ -1088,15 +1072,10 @@ module.exports = function (Twig) { /** * Resolve a context value. * - * If the value is a function, it is called with `object || context` as `this` and `params` as arguments. + * If the value is a function, it is executed with a context parameter. * - * @param {*} value The value to resolve. + * @param {string} key The context object key. * @param {Object} context The render context. - * @param {(Array<*>|null|undefined)} params The parameters to pass to the function. - * @param {(Object|null|undefined)} nextToken The next token in the expression. - * @param {(Object|null|undefined)} object The object context. - * - * @return {Promise<*>} A promise that resolves to the resolved value. */ Twig.expression.resolveAsync = function (value, context, params, nextToken, object) { const state = this; @@ -1143,17 +1122,6 @@ module.exports = function (Twig) { }); }; - /** - * Resolve a context value synchronously. - * - * @param {*} value The value to resolve. - * @param {Object} context The render context. - * @param {(Array<*>|null|undefined)} params The parameters to pass to the function. - * @param {(Object|null|undefined)} nextToken The next token in the expression. - * @param {(Object|null|undefined)} object The object context. - * - * @return {*} The resolved value. - */ Twig.expression.resolve = function (value, context, params, nextToken, object) { return Twig.async.potentiallyAsync(this, false, function () { return Twig.expression.resolveAsync.call(this, value, context, params, nextToken, object); @@ -1166,7 +1134,7 @@ module.exports = function (Twig) { Twig.expression.handler = {}; /** - * Define a new expression type, available at Twig.expression.type.{type} + * Define a new expression type, available at Twig.logic.type.{type} * * @param {string} type The name of the new type. */ @@ -1212,10 +1180,8 @@ module.exports = function (Twig) { * Break an expression into tokens defined in Twig.expression.definitions. * * @param {Object} rawToken The string to tokenize. - * @param {string} rawToken.value The expression string to tokenize. - * @param {Object} rawToken.position Optional position information for error messages. * - * @return {Object[]} An array of tokens. + * @return {Array} An array of tokens. */ Twig.expression.tokenize = function (rawToken) { let expression = rawToken.value; @@ -1338,9 +1304,8 @@ module.exports = function (Twig) { * Compile an expression token. * * @param {Object} rawToken The uncompiled token. - * @param {string} rawToken.value The expression string to compile. * - * @return {Object} The compiled token with a `stack` property of compiled tokens. + * @return {Object} The compiled token. */ Twig.expression.compile = function (rawToken) { // Tokenize expression @@ -1383,14 +1348,12 @@ module.exports = function (Twig) { /** * Parse an RPN expression stack within a context. * - * @param {(Object|Object[])} tokens A compiled expression token or array of compiled expression tokens. + * @param {Array} tokens An array of compiled expression tokens. * @param {Object} context The render context to parse the tokens with. - * @param {boolean} tokensAreParameters Indicates if the tokens are parameters. - * @param {boolean} allowAsync Indicates if async operations are allowed. * - * @return {*|Promise<*>} The result of parsing all the tokens. The result - * can be anything, String, Array, Object, etc... based on - * the given expression. + * @return {Object} The result of parsing all the tokens. The result + * can be anything, String, Array, Object, etc... based on + * the given expression. */ Twig.expression.parse = function (tokens, context, tokensAreParameters, allowAsync) { const state = this; From 2a8ee381c425c34be24babaf9736d4b9802d3180 Mon Sep 17 00:00:00 2001 From: Yahor Siarheyenka Date: Sun, 22 Mar 2026 19:14:17 +0100 Subject: [PATCH 5/7] =?UTF-8?q?feat:=20betterment=20=F0=9F=92=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/twig.expression.js | 66 +++++++++++++++++++++++++----------------- 1 file changed, 40 insertions(+), 26 deletions(-) diff --git a/src/twig.expression.js b/src/twig.expression.js index 6d57e5dd..807e5f4e 100644 --- a/src/twig.expression.js +++ b/src/twig.expression.js @@ -5,7 +5,11 @@ module.exports = function (Twig) { 'use strict'; function normalizeObject(object) { - return object === null || object === undefined ? null : Object(object); + return object === null ? null : Object(object); + } + + function isSafeAccess(value) { + return value != null; // Use != null to check for both null and undefined } function parseParams(state, params, context) { @@ -897,7 +901,11 @@ module.exports = function (Twig) { throw new Twig.Error('Variable "' + token.value + '" does not exist.'); } - stack.push(value); + if (isOptionalChain && !isSafeAccess(value)) { + stack.push(undefined); + } else { + stack.push(value); + } }); } }, @@ -922,6 +930,11 @@ module.exports = function (Twig) { const normalizedObject = normalizeObject(object); let value; + if (token.optional && !isSafeAccess(object)) { + stack.push(undefined); + return; + } + if (normalizedObject && !(key in normalizedObject) && !normalizedObject['get' + key.slice(0, 1).toUpperCase() + key.slice(1)] && !normalizedObject['is' + key.slice(0, 1).toUpperCase() + key.slice(1)] && @@ -936,23 +949,19 @@ module.exports = function (Twig) { return parseParams(state, token.params, context) .then(params => { - if (object === null || object === undefined) { - value = undefined; + const capitalize = function (value) { + return value.slice(0, 1).toUpperCase() + value.slice(1); + }; + + // Get the variable from the context + if (key in normalizedObject) { + value = normalizedObject[key]; + } else if (normalizedObject['get' + capitalize(key)]) { + value = normalizedObject['get' + capitalize(key)]; + } else if (normalizedObject['is' + capitalize(key)]) { + value = normalizedObject['is' + capitalize(key)]; } else { - const capitalize = function (value) { - return value.slice(0, 1).toUpperCase() + value.slice(1); - }; - - // Get the variable from the context - if (key in normalizedObject) { - value = normalizedObject[key]; - } else if (normalizedObject['get' + capitalize(key)]) { - value = normalizedObject['get' + capitalize(key)]; - } else if (normalizedObject['is' + capitalize(key)]) { - value = normalizedObject['is' + capitalize(key)]; - } else { - value = undefined; - } + value = undefined; } // When resolving an expression we need to pass nextToken in case the expression is a function @@ -998,6 +1007,12 @@ module.exports = function (Twig) { object = stack.pop(); const normalizedObject = normalizeObject(object); + // For optional chaining, short-circuit on null/undefined + if (token.optional && !isSafeAccess(object)) { + stack.push(undefined); + return; + } + if (normalizedObject && !(key in normalizedObject) && state.template.options.strictVariables) { const keys = Object.keys(normalizedObject); if (keys.length > 0) { @@ -1005,8 +1020,6 @@ module.exports = function (Twig) { } else { throw new Twig.Error('Key "' + key + '" does not exist as the array is empty.'); } - } else if (object === null || object === undefined) { - return null; } // Get the variable from the context @@ -1081,14 +1094,15 @@ module.exports = function (Twig) { const state = this; if (typeof value !== 'function') { - if (nextToken && - nextToken.type === Twig.expression.type.parameter.end && - nextToken.optionalCall) { - nextToken.cleanup = true; + return Twig.Promise.resolve(value); + } + + // Handle optional chaining for method calls + if (nextToken && nextToken.type === Twig.expression.type.parameter.end && nextToken.optionalCall) { + // For optional calls, only return undefined if the object is null/undefined + if (!isSafeAccess(object)) { return Twig.Promise.resolve(undefined); } - - return Twig.Promise.resolve(value); } let promise = Twig.Promise.resolve(params); From 85c2ae26dc615a4a6d42179f8a4d36a1eea94199 Mon Sep 17 00:00:00 2001 From: Yahor Siarheyenka Date: Sun, 22 Mar 2026 19:33:54 +0100 Subject: [PATCH 6/7] =?UTF-8?q?feat:=20betterment=20=F0=9F=92=88=20take=20?= =?UTF-8?q?2=20=F0=9F=8E=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/twig.expression.js | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/src/twig.expression.js b/src/twig.expression.js index 807e5f4e..5cad2a41 100644 --- a/src/twig.expression.js +++ b/src/twig.expression.js @@ -4,14 +4,14 @@ module.exports = function (Twig) { 'use strict'; - function normalizeObject(object) { - return object === null ? null : Object(object); - } - function isSafeAccess(value) { return value != null; // Use != null to check for both null and undefined } + function normalizeObject(object) { + return object === null ? null : Object(object); + } + function parseParams(state, params, context) { if (params) { return Twig.expression.parseAsync.call(state, params, context); @@ -901,11 +901,7 @@ module.exports = function (Twig) { throw new Twig.Error('Variable "' + token.value + '" does not exist.'); } - if (isOptionalChain && !isSafeAccess(value)) { - stack.push(undefined); - } else { - stack.push(value); - } + stack.push(value); }); } }, From c75eb01af7b6eb5bc94812f6687a91e7c38a96a1 Mon Sep 17 00:00:00 2001 From: Yahor Siarheyenka Date: Sun, 22 Mar 2026 19:36:13 +0100 Subject: [PATCH 7/7] =?UTF-8?q?feat:=20betterment=20=F0=9F=92=88=20take=20?= =?UTF-8?q?3=20=F0=9F=8E=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/twig.expression.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/twig.expression.js b/src/twig.expression.js index 5cad2a41..a61196b4 100644 --- a/src/twig.expression.js +++ b/src/twig.expression.js @@ -901,7 +901,11 @@ module.exports = function (Twig) { throw new Twig.Error('Variable "' + token.value + '" does not exist.'); } - stack.push(value); + if (isOptionalChain && !isSafeAccess(value)) { + stack.push(undefined); + } else { + stack.push(value); + } }); } },