diff --git a/src/.eslintrc.js b/src/.eslintrc.js index 2d4e08c3..ec6733fe 100644 --- a/src/.eslintrc.js +++ b/src/.eslintrc.js @@ -12,5 +12,14 @@ module.exports = { '@typescript-eslint/no-namespace': [1, {allowDeclarations: true}], }, }, + { + files: ['*.ts', '*.tsx'], + parser: '@typescript-eslint/parser', + parserOptions: { + sourceType: 'module', + project: ['./tsconfig.transform.json', './tsconfig.json'], + tsconfigRootDir: __dirname + '/../', + }, + } ], }; diff --git a/src/transform/getObject.ts b/src/transform/getObject.ts index efe1b627..bee2ccc1 100644 --- a/src/transform/getObject.ts +++ b/src/transform/getObject.ts @@ -1,10 +1,17 @@ -export = function getObject(path: string, obj: Object) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return path.split('.').reduce((acc: any | undefined, item) => { - if (!acc || !Object.getOwnPropertyNames(acc).includes(item)) { - return undefined; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export = function getObject(path: string, obj: Record, fallback?: any) { + const queue = path.split('.'); + + let box = obj; + while (queue.length) { + const step = queue.shift() as string; + + if (!Object.prototype.hasOwnProperty.call(box, step)) { + return fallback || undefined; } - return acc[item]; - }, obj); + box = box[step]; + } + + return box; }; diff --git a/src/transform/liquid/conditions.ts b/src/transform/liquid/conditions.ts index e347ad78..cfd86b8e 100644 --- a/src/transform/liquid/conditions.ts +++ b/src/transform/liquid/conditions.ts @@ -1,193 +1,231 @@ import {bold} from 'chalk'; - -import evalExp from './evaluation'; +import {NoValue, evalExp} from './evaluation'; import {tagLine} from './lexical'; import {log} from '../log'; -import {getPreparedLeftContent, removeIndentBlock} from './utils'; -import {createSourceMapApi, getLineNumber} from './sourceMap'; - -type Options = { - firstLineNumber: number; - lastLineNumber: number; - resFirstLineNumber: number; - resLastLineNumber: number; - linesTotal: number; - sourceMap: Record; -}; +import {SourceMapApi, createSourceMapApi, getLineNumber} from './sourceMap'; -function changeSourceMap({ - firstLineNumber, - lastLineNumber, - resFirstLineNumber, - resLastLineNumber, - linesTotal, - sourceMap, -}: Options) { - if (!sourceMap) { - return; - } +interface SourceMap { + start: number; + end: number; + rawStart: string; + rawEnd: string; +} - const {isInlineTag, getSourceMapValue, moveLines, removeLines} = createSourceMapApi({ - firstLineNumber, - lastLineNumber, - sourceMap, - }); +function resourcemap(source: string, ifTag: SourceMap, ifCon: SourceMap | null, api: SourceMapApi) { + const [sourseStartLine, sourceEndLine] = [ + getLineNumber(source, ifTag.start + 1), + getLineNumber(source, ifTag.end - 1), + ]; - if (isInlineTag) { + if (sourseStartLine === sourceEndLine || ifTag === ifCon) { return; } + const linesTotal = source.split('\n').length; + const {getSourceMapValue, moveLines, removeLines} = api; + let offsetRestLines; - if (resFirstLineNumber) { + if (ifCon) { + const [resultStartLine, resultEndLine] = [ + getLineNumber(source, ifCon.start), + getLineNumber(source, ifCon.end), + ]; + // Move condition's content to the top - const offsetContentLines = firstLineNumber - resFirstLineNumber; + const offsetContentLines = sourseStartLine - resultStartLine; moveLines({ - start: resFirstLineNumber, - end: resLastLineNumber - 1, + start: resultStartLine, + end: resultEndLine, offset: offsetContentLines, withReplace: true, }); // Remove the rest lines of the condition block - removeLines({start: firstLineNumber, end: resFirstLineNumber - 1}); - removeLines({start: resLastLineNumber, end: lastLineNumber}); + removeLines({start: sourseStartLine, end: resultStartLine - 1}); + removeLines({start: resultEndLine + 1, end: sourceEndLine}); // Calculate an offset of the rest lines - offsetRestLines = getSourceMapValue(resLastLineNumber - 1) - lastLineNumber; + offsetRestLines = getSourceMapValue(resultEndLine) - sourceEndLine; } else { // Remove the whole condition block - removeLines({start: firstLineNumber, end: lastLineNumber}); + removeLines({start: sourseStartLine, end: sourceEndLine}); // Calculate offset of the rest lines - offsetRestLines = firstLineNumber - lastLineNumber - 1; + offsetRestLines = sourseStartLine - sourceEndLine - 1; } // Offset the rest lines - moveLines({start: lastLineNumber + 1, end: linesTotal, offset: offsetRestLines}); + moveLines({start: sourceEndLine + 1, end: linesTotal, offset: offsetRestLines}); +} + +type IfCondition = SourceMap & { + expr: string; +}; + +function headLinebreak(raw: string) { + const match = raw.match(/^([^{]+){.*/); + + return match ? match[1] : ''; } -function getElseProp({elses}: {elses: Elses[]}, propName: B, index = 0) { - if (!elses.length || index >= elses.length) { - return undefined; +function tailLinebreak(raw: string) { + const match = raw.match(/.*}(\s*\n)$/); + + return match ? match[1] : ''; +} + +function trimResult(content: string, ifTag: IfTag, ifCon: IfCondition | null) { + if (!ifCon) { + return ifTag.isBlock ? '\n' : ''; } - return elses[index][propName]; + content = content.substring(ifCon.start, ifCon.end); + + const head = headLinebreak(ifCon.rawStart); + if (head) { + content = (ifTag.isBlock ? '\n' : head) + content; + } + + const tail = tailLinebreak(ifCon.rawEnd); + if (tail) { + content = content + (ifTag.isBlock ? '\n' : tail); + } + + return content; } -type Opts = { - ifTag: Tag; - vars: Record; - content: string; - match: RegExpExecArray; - lastIndex: number; - sourceMap: Record; - linesTotal: number; -}; +class IfTag implements SourceMap { + private conditions: IfCondition[] = []; -function inlineConditions({ifTag, vars, content, match, lastIndex, sourceMap, linesTotal}: Opts) { - let res = ''; - const firstLineNumber = getLineNumber(content, ifTag.startPos); - const lastLineNumber = getLineNumber(content, lastIndex); - let resFirstLineNumber = 0; - let resLastLineNumber = 0; + get start() { + if (!this.conditions.length) { + return -1; + } - if (evalExp(ifTag.condition, vars)) { - const ifRawLastIndex = ifTag.startPos + ifTag.ifRaw.length; - const contentLastIndex = getElseProp(ifTag, 'startPos') || match.index; + const first = this.conditions[0]; - res = content.substring(ifRawLastIndex, contentLastIndex); - resFirstLineNumber = getLineNumber(content, ifRawLastIndex + 1); - resLastLineNumber = getLineNumber(content, contentLastIndex + 1); - } else { - ifTag.elses.some(({condition, startPos, raw}, index) => { - const isTruthy = !condition || evalExp(condition, vars); + return first.start - first.rawStart.length; + } - if (isTruthy) { - const elseRawLastIndex = startPos + raw.length; - const contentLastIndex = getElseProp(ifTag, 'startPos', index + 1) || match.index; + get end() { + if (!this.conditions.length) { + return -1; + } - res = content.substring(elseRawLastIndex, contentLastIndex); - resFirstLineNumber = getLineNumber(content, elseRawLastIndex + 1); - resLastLineNumber = getLineNumber(content, contentLastIndex + 1); + const last = this.conditions[this.conditions.length - 1]; - return true; - } + return last.end + last.rawEnd.length; + } - return false; - }); + get rawStart() { + if (!this.conditions.length) { + return ''; + } + + const first = this.conditions[0]; + + return first.rawStart; } - changeSourceMap({ - firstLineNumber, - lastLineNumber, - resFirstLineNumber, - resLastLineNumber, - linesTotal, - sourceMap, - }); - - const preparedLeftContent = getPreparedLeftContent({ - content, - tagStartPos: ifTag.startPos, - tagContent: res, - }); - - let shift = 0; - if ( - res === '' && - preparedLeftContent[preparedLeftContent.length - 1] === '\n' && - content[lastIndex] === '\n' - ) { - shift = 1; + get rawEnd() { + if (!this.conditions.length) { + return ''; + } + + const last = this.conditions[this.conditions.length - 1]; + + return last.rawEnd; + } + + get isBlock() { + const first = this.conditions[0]; + const last = this.conditions[this.conditions.length - 1]; + + return tailLinebreak(first.rawStart) && headLinebreak(last.rawEnd); } - if (res !== '') { - if (res[0] === '\n') { - res = res.substring(1); + *[Symbol.iterator](): Generator { + for (const condition of this.conditions) { + yield condition; } + } + + openCondition(raw: string, expr: string, start: number) { + this.closeCondition(raw, start); + this.conditions.push({ + rawStart: raw, + start: start + raw.length, + expr, + } as IfCondition); - res = removeIndentBlock(res); + return start + raw.length - tailLinebreak(raw).length; + } - if (res[res.length - 1] === '\n') { - res = res.slice(0, -1); + closeCondition(raw: string, end: number) { + const condition = this.conditions[this.conditions.length - 1]; + if (condition) { + condition.rawEnd = raw; + condition.end = end; } } +} + +function inlineConditions( + content: string, + ifTag: IfTag, + vars: Record, + strict: boolean, +) { + let ifCon = null; + + for (const condition of ifTag) { + const value = evalExp(condition.expr, vars, strict); + + if (condition.expr && value === NoValue) { + return { + result: content, + // Fix offset for next matches. + // There can be some significant linebreak and spaces. + lastIndex: ifTag.end - tailLinebreak(ifTag.rawEnd).length, + ifCon: ifTag, + }; + } - const leftPart = preparedLeftContent + res; + if (!condition.expr || value) { + ifCon = condition; + break; + } + } + + const start = content.slice(0, ifTag.start); + const end = content.slice(ifTag.end); + const result = trimResult(content, ifTag, ifCon); return { - result: leftPart + content.substring(lastIndex + shift), - idx: leftPart.length, + result: start + result + end, + lastIndex: start.length + result.length, + ifCon, }; } -type Elses = {startPos: number; raw: string; condition?: string}; - -type Tag = { - isOpen: Boolean; - condition: string; - startPos: number; - ifRaw: string; - elses: Elses[]; -}; - export = function conditions( - originInput: string, + input: string, vars: Record, path?: string, settings?: { sourceMap: Record; + strict?: boolean; }, ) { const sourceMap = settings?.sourceMap || {}; + const strict = settings?.strict || false; + const tagStack: IfTag[] = []; - const R_LIQUID = /({%-?([\s\S]*?)-?%})/g; + // Consumes all between curly braces + // and all closest upon to first linebreak before and after braces. + const R_LIQUID = /((?:\n[^\n{]*)?{%-?([\s\S]*?)-?%}(?:\s*\n)?)/g; let match; - const tagStack: Tag[] = []; - let input = originInput; - let linesTotal = originInput.split('\n').length; - while ((match = R_LIQUID.exec(input)) !== null) { if (!match[1]) { continue; @@ -201,50 +239,41 @@ export = function conditions( const [type, args] = tagMatch.slice(1); switch (type) { - case 'if': - tagStack.push({ - isOpen: true, - condition: args, - startPos: match.index, - ifRaw: match[1], - elses: [], - }); - break; - case 'else': - tagStack[tagStack.length - 1].elses.push({ - startPos: match.index, - raw: match[1], - }); + case 'if': { + const tag = new IfTag(); + + R_LIQUID.lastIndex = tag.openCondition(match[1], args, match.index); + + tagStack.push(tag); break; + } case 'elsif': - tagStack[tagStack.length - 1].elses.push({ - condition: args, - startPos: match.index, - raw: match[1], - }); + case 'else': { + const tag = tagStack[tagStack.length - 1] as IfTag; + + R_LIQUID.lastIndex = tag.openCondition(match[1], args, match.index); + break; + } case 'endif': { const ifTag = tagStack.pop(); if (!ifTag) { + // TODO(3y3): make lint rule log.error( `If block must be opened before close${path ? ` in ${bold(path)}` : ''}`, ); break; } - const {idx, result} = inlineConditions({ - ifTag, - vars, - content: input, - match, - lastIndex: R_LIQUID.lastIndex, - sourceMap, - linesTotal, - }); - R_LIQUID.lastIndex = idx; + ifTag.closeCondition(match[1], match.index); + + const {result, lastIndex, ifCon} = inlineConditions(input, ifTag, vars, strict); + + resourcemap(input, ifTag, ifCon, createSourceMapApi(sourceMap)); + + R_LIQUID.lastIndex = lastIndex; input = result; - linesTotal = result.split('\n').length; break; } diff --git a/src/transform/liquid/cycles.ts b/src/transform/liquid/cycles.ts index 551d1b8f..acf6bc3b 100644 --- a/src/transform/liquid/cycles.ts +++ b/src/transform/liquid/cycles.ts @@ -30,11 +30,8 @@ function changeSourceMap({ return; } - const {isInlineTag, moveLines, removeLine} = createSourceMapApi({ - firstLineNumber, - lastLineNumber, - sourceMap, - }); + const isInlineTag = firstLineNumber === lastLineNumber; + const {moveLines, removeLine} = createSourceMapApi(sourceMap); if (isInlineTag || !resFirstLineNumber) { return; diff --git a/src/transform/liquid/evaluation.ts b/src/transform/liquid/evaluation.ts index e6ed65b1..0b429ba2 100644 --- a/src/transform/liquid/evaluation.ts +++ b/src/transform/liquid/evaluation.ts @@ -39,7 +39,9 @@ const operators: Record = { const parsed = lexical.getParsedMethod(r); try { - if (!parsed) throw new Error(); + if (!parsed) { + throw new Error(); + } const {name, args} = parsed; return l[name](...args); @@ -56,7 +58,9 @@ const operators: Record = { }) as DotOperator, }; -function evalValue(originStr: string, scope: Scope) { +export const NoValue = Symbol('NoValue'); + +function evalValue(originStr: string, scope: Scope, strict: boolean) { const str = originStr && originStr.trim(); if (!str) { return undefined; @@ -66,7 +70,7 @@ function evalValue(originStr: string, scope: Scope) { return lexical.parseLiteral(str); } if (lexical.isVariable(str)) { - return getObject(str, scope); + return getObject(str, scope, strict ? NoValue : undefined); } throw new TypeError(`cannot eval '${str}' as value`); @@ -80,13 +84,25 @@ function isFalsy(val: unknown) { return val === false || undefined === val || val === null; } +const operatorREs = lexical.operators.map( + (op) => + new RegExp( + `^(${lexical.quoteBalanced.source})(${op.source})(${lexical.quoteBalanced.source})$`, + ), +); + export function evalExp( exp: string, scope: Record, -): string[] | boolean | string | undefined | ((input: string) => number | string) { - const operatorREs = lexical.operators; - let match; - + strict = false, +): + | string[] + | number[] + | boolean + | string + | symbol + | undefined + | ((input: string) => number | string) { if (Object.getOwnPropertyNames(filters).includes(exp.trim())) { return filters[exp.trim() as keyof typeof filters]; } @@ -98,28 +114,30 @@ export function evalExp( try { for (let i = 0; i < operatorREs.length; i++) { const operatorRE = operatorREs[i]; - const expRE = new RegExp( - `^(${lexical.quoteBalanced.source})(${operatorRE.source})(${lexical.quoteBalanced.source})$`, - ); - if ((match = exp.match(expRE))) { - const l = evalExp(match[1], scope); + const match = exp.match(operatorRE); + if (match) { const operator = match[2].trim(); + if (operator === '.' && !lexical.isSupportedMethod(match[3].trim())) { + break; + } + const op = operators[operator]; - const r = evalExp(match[3], scope); - - if ( - operator !== '.' || - (operator === '.' && lexical.isSupportedMethod(r as string)) - ) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return op(l as any, r as any, exp); + const l = evalExp(match[1], scope, strict); + const r = evalExp(match[3], scope, strict); + + if (l === NoValue || r === NoValue) { + return NoValue; } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return op(l as any, r as any, exp); } } - if ((match = exp.match(lexical.rangeLine))) { - const low = evalValue(match[1], scope); - const high = evalValue(match[2], scope); + const match = exp.match(lexical.rangeLine); + if (match) { + const low = Number(evalValue(match[1], scope, strict)); + const high = Number(evalValue(match[2], scope, strict)); const range = []; for (let j = low; j <= high; j++) { @@ -129,7 +147,7 @@ export function evalExp( return range; } - return evalValue(exp, scope); + return evalValue(exp, scope, strict); } catch (e) { if (e instanceof SkippedEvalError) { log.warn(`Skip error: ${e}`); @@ -142,4 +160,5 @@ export function evalExp( return undefined; } -export default (exp: string, scope: Record) => Boolean(evalExp(exp, scope)); +export default (exp: string, scope: Record, strict = false) => + Boolean(evalExp(exp, scope, strict)); diff --git a/src/transform/liquid/index.ts b/src/transform/liquid/index.ts index 305d0c2e..69e4b563 100644 --- a/src/transform/liquid/index.ts +++ b/src/transform/liquid/index.ts @@ -116,7 +116,8 @@ function liquid< } if (conditions) { - output = applyConditions(output, vars, path, {sourceMap}); + const strict = conditions === 'strict'; + output = applyConditions(output, vars, path, {sourceMap, strict}); } if (substitutions) { diff --git a/src/transform/liquid/services/argv.ts b/src/transform/liquid/services/argv.ts index a4de41a0..ad957684 100644 --- a/src/transform/liquid/services/argv.ts +++ b/src/transform/liquid/services/argv.ts @@ -1,5 +1,5 @@ export type ArgvSettings = { - conditions?: boolean; + conditions?: boolean | 'strict'; conditionsInCode?: boolean; cycles?: boolean; substitutions?: boolean; diff --git a/src/transform/liquid/sourceMap.ts b/src/transform/liquid/sourceMap.ts index 89fec5dd..e7b3e5d5 100644 --- a/src/transform/liquid/sourceMap.ts +++ b/src/transform/liquid/sourceMap.ts @@ -13,12 +13,6 @@ export function prepareSourceMap(sourceMap: object) { return newToOldMap; } -type Options = { - firstLineNumber: number; - lastLineNumber: number; - sourceMap: Record; -}; - type MoveLinesOptions = { start: number; end: number; @@ -26,8 +20,9 @@ type MoveLinesOptions = { withReplace?: boolean; }; -export function createSourceMapApi({firstLineNumber, lastLineNumber, sourceMap}: Options) { - const isInlineTag = firstLineNumber === lastLineNumber; +export type SourceMapApi = ReturnType; + +export function createSourceMapApi(sourceMap: Record) { const newToOldIndexes = invert(sourceMap); const getOriginIndex = (i: number) => Number(newToOldIndexes[i]); @@ -38,10 +33,6 @@ export function createSourceMapApi({firstLineNumber, lastLineNumber, sourceMap}: const getSourceMapValue = (i: number) => sourceMap[getOriginIndex(i)]; const moveLines = ({start, end, offset, withReplace = false}: MoveLinesOptions) => { - if (isInlineTag) { - return; - } - for (let i = start; i <= end; i++) { const newLineNumber = i + offset; setSourceMapValue(i, newLineNumber); @@ -63,7 +54,6 @@ export function createSourceMapApi({firstLineNumber, lastLineNumber, sourceMap}: }; return { - isInlineTag, getSourceMapValue, moveLines, removeLines, diff --git a/test/.eslintrc b/test/.eslintrc deleted file mode 100644 index 507489dd..00000000 --- a/test/.eslintrc +++ /dev/null @@ -1,12 +0,0 @@ -{ - "extends": ['@diplodoc/eslint-config'], - "overrides": [{ - "files": ["*"], - "rules": { - "@typescript-eslint/no-explicit-any": 2, - "no-useless-concat": 0, - "no-shadow": "off", - "@typescript-eslint/no-shadow": "off" - } - }] -} diff --git a/test/.eslintrc.js b/test/.eslintrc.js new file mode 100644 index 00000000..ee7aeef0 --- /dev/null +++ b/test/.eslintrc.js @@ -0,0 +1,26 @@ +process.env.ESLINT_ENV = 'client'; + +module.exports = { + root: true, + extends: ['@diplodoc/eslint-config'], + overrides: [ + { + "files": ["*"], + "rules": { + "@typescript-eslint/no-explicit-any": 2, + "no-useless-concat": 0, + "no-shadow": "off", + "@typescript-eslint/no-shadow": "off" + } + }, + { + files: ['*.ts', '*.tsx'], + parser: '@typescript-eslint/parser', + parserOptions: { + sourceType: 'module', + project: ['./tsconfig.transform.json', './tsconfig.json'], + tsconfigRootDir: __dirname + '/../', + }, + } + ], +}; diff --git a/test/liquid/conditions.test.ts b/test/liquid/conditions.test.ts index e3ccada2..523edcee 100644 --- a/test/liquid/conditions.test.ts +++ b/test/liquid/conditions.test.ts @@ -56,46 +56,63 @@ describe('Conditions', () => { test('Should works for multiple if block', () => { expect( conditions( - 'Prefix\n' + - '{% if test %}\n' + - `${' '.repeat(4)}How are you?\n` + - '{% endif %}\n' + - 'Postfix', + ` + Prefix + {% if test %} + How are you? + {% endif %} + Postfix + `, {test: true}, '', {sourceMap: {}}, ), - ).toEqual('Prefix\n' + `${' '.repeat(4)}How are you?\n` + 'Postfix'); + ).toEqual(` + Prefix + How are you? + Postfix + `); }); test('Multiple if block with indent', () => { expect( conditions( - 'Prefix\n' + - `${' '.repeat(4)}{% if test %}\n` + - `${' '.repeat(4)}How are you?\n` + - `${' '.repeat(4)}{% endif %}\n` + - 'Postfix', + ` + Prefix + {% if test %} + How are you? + {% endif %} + Postfix + `, {test: true}, '', {sourceMap: {}}, ), - ).toEqual('Prefix\n' + `${' '.repeat(8)}How are you?\n` + 'Postfix'); + ).toEqual(` + Prefix + How are you? + Postfix + `); }); test('Multiple if block with indent and negative condition', () => { expect( conditions( - 'Prefix\n' + - `${' '.repeat(4)}{% if test %}\n` + - `${' '.repeat(8)}How are you?\n` + - `${' '.repeat(4)}{% endif %}\n` + - 'Postfix', + ` + Prefix + {% if test %} + How are you? + {% endif %} + Postfix + `, {test: false}, '', {sourceMap: {}}, ), - ).toEqual('Prefix\n' + 'Postfix'); + ).toEqual(` + Prefix + Postfix + `); }); test('Two multiple if blocks in a row', () => { @@ -331,6 +348,46 @@ describe('Conditions', () => { ).toEqual('Prefix else Postfix'); }); }); + + describe('Strict', () => { + test('Should handle strict if check', () => { + expect( + conditions( + 'Prefix{% if name != "test" %} Inline if {% endif %}Postfix', + {user: {name: 'Alice'}}, + '', + {sourceMap: {}, strict: true}, + ), + ).toEqual('Prefix{% if name != "test" %} Inline if {% endif %}Postfix'); + }); + + test('Should handle strict elseif', () => { + expect( + conditions( + ` + Prefix + {% if user.name == "Test" %} + Test + {% elsif user.lastname == "Markovna" %} + Markovna + {% endif %} + Postfix + `, + {user: {name: 'Alice'}}, + '', + {sourceMap: {}, strict: true}, + ), + ).toEqual(` + Prefix + {% if user.name == "Test" %} + Test + {% elsif user.lastname == "Markovna" %} + Markovna + {% endif %} + Postfix + `); + }); + }); }); describe('Nested conditions', () => { @@ -358,6 +415,39 @@ describe('Conditions', () => { ), ).toEqual('Prefix Before nested if After nested if Postfix'); }); + + test('Should handle nested strict if', () => { + expect( + conditions( + ` + Prefix + {% if user.name == "Alice" %} + Alice + {% if user.lastname == "Markovna" %} + Ok + {% endif %} + {% elsif user.name == "Bob" %} + {% if user.lastname == "Markovich" %} + Ok + {% endif %} + {% else %} + Bad + {% endif %} + Postfix + `, + {user: {name: 'Alice'}}, + '', + {sourceMap: {}, strict: true}, + ), + ).toEqual(` + Prefix + Alice + {% if user.lastname == "Markovna" %} + Ok + {% endif %} + Postfix + `); + }); }); });