{{comment}}:
{{endif}}{{highlight}}
{{citation}}
+ * {{else}} + *{{highlight}} {{citation}} {{comment}} {{if tags}} #{{tags join=' #'}}{{endif}}
+ * {{endif}} + * + * @param {String} template + * @param {Object} vars + * @returns {String} HTML + */ +export function generateHTMLFromTemplate(template, vars) { + const hyphenToCamel = varName => varName.replace(/-(.)/g, (_, g1) => g1.toUpperCase()); + + const getAttributes = (part) => { + let attrsRegexp = new RegExp(/(([\w-]*) *=+ *(['"])((\\\3|[^\3])*?)\3)/g); + let attrs = {}; + let match; + while ((match = attrsRegexp.exec(part))) { + if (match[1]) { // if first alternative (i.e. argument with value wrapped in " or ') matched, even if value is empty + attrs[hyphenToCamel(match[2])] = match[4]; + } + } + return attrs; + }; + + + const evaluateIdentifier = (ident, args = {}) => { + ident = hyphenToCamel(ident); + + if (!(ident in vars)) { + return ''; + } + + const identValue = typeof vars[ident] === 'function' ? vars[ident](args) : vars[ident]; + + if (Array.isArray(identValue)) { + return identValue.length ? identValue.join(',') : ''; + } + + if (typeof identValue !== 'string') { + throw new Error(`Identifier "${ident}" does not evaluate to a string`); + } + + return identValue; + }; + + // evaluates extracted (i.e. without brackets) statement (e.g. `sum a="1" b="2"`) into a string value + const evaluateStatement = (statement) => { + statement = statement.trim(); + const operator = statement.split(' ', 1)[0].trim(); + const args = statement.slice(operator.length).trim(); + + return evaluateIdentifier(operator, getAttributes(args)); + }; + + // splits raw (i.e. bracketed) statement (e.g. `{{ sum a="1" b="2" }}) into operator and arguments (e.g. ['sum', 'a="1" b="2"']) + const splitStatement = (statement) => { + statement = statement.slice(2, -2).trim(); + const operator = statement.split(' ', 1)[0].trim(); + const args = statement.slice(operator.length).trim(); + return [operator, args]; + }; + + // We allow unquoted numbers in conditions, e.g. `a == 1` or `a == 1.0` but not `a == 1.0.0` or `a == 1st edition` + const asNumber = (string) => { + if (typeof string === 'number') { + return string; + } + const number = parseFloat(string); + if (!Number.isNaN(number) && string?.trim().match(/^[+-]?\d+(\.\d+)?$/)) { + return number; + } + return null; + }; + + // evaluates a condition (e.g. `a == "b"`) into a boolean value + const evaluateCondition = (condition) => { + const comparators = ['==', '!=', "<=", ">=", '<', '>']; + condition = condition.trim(); + + // Regular expression breakdown for condition matching: + // - `match[1]`: Left operand if it's a statement enclosed in `{{...}}`. + // - `match[3]`: Left operand if it's a string literal, extracted without quotes. + // - `match[4]`: Left operand if it's a standalone identifier (e.g., a variable) or a number + // - `match[6]`: Right operand if it's a statement enclosed in `{{...}}`. + // - `match[8]`: Right operand if it's a string literal, extracted without quotes. + // - `match[9]`: Right operand if it's a standalone identifier or a number. + // - `match[2]` and `match[7]`: Captured quotes around string literals, used to ensure matching pairs. + // - `match[5]`: The comparator (e.g., `==`, `!=`, `<`, `>`, etc.), extracted from `comparators.join('|')`. + const match = condition.match(new RegExp(String.raw`(?:{{(.*?)}}|(?:(['"])(.*?)\2)|([^ ]+)) *(${comparators.join('|')}) *(?:{{(.*?)}}|(?:(['"])(.*?)\7)|([^ ]+))`)); + + if (!match) { + // condition is a statement or identifier without a comparator + if (condition.startsWith('{{')) { + const [operator, args] = splitStatement(condition); + return !!evaluateIdentifier(operator, getAttributes(args)); + } + return !!evaluateIdentifier(condition); + } + + const left = match[1] ? evaluateStatement(match[1]) : match[3] ?? asNumber(match[4]) ?? evaluateIdentifier(match[4]) ?? ''; + const comparator = match[5]; + const right = match[6] ? evaluateStatement(match[6]) : match[8] ?? asNumber(match[9]) ?? evaluateIdentifier(match[9]) ?? ''; + + switch (comparator) { + default: + case '==': + return (asNumber(left) === null || asNumber(right === null)) ? left.toLowerCase() == right.toLowerCase() : asNumber(left) == asNumber(right); + case '!=': + return (asNumber(left) === null || asNumber(right === null)) ? left.toLowerCase() != right.toLowerCase() : asNumber(left) != asNumber(right); + case ">=": + return (asNumber(left) === null || asNumber(right === null)) ? left.toLowerCase() >= right.toLowerCase() : asNumber(left) >= asNumber(right); + case "<=": + return (asNumber(left) === null || asNumber(right === null)) ? left.toLowerCase() <= right.toLowerCase() : asNumber(left) <= asNumber(right); + case '>': + return (asNumber(left) === null || asNumber(right === null)) ? left.toLowerCase() > right.toLowerCase() : asNumber(left) > asNumber(right); + case '<': + return (asNumber(left) === null || asNumber(right === null)) ? left.toLowerCase() < right.toLowerCase() : asNumber(left) < asNumber(right); + } + }; + + let html = ''; + const levels = [{ condition: true }]; + const parts = splitByOuterBrackets(template); + + for (let i = 0; i < parts.length; i++) { + let part = parts[i]; + let level = levels[levels.length - 1]; + + if (part.startsWith('{{')) { + const [operator, args] = splitStatement(part); + + if (operator === 'if') { + level = { condition: false, executed: false, parentCondition: levels[levels.length - 1].condition }; + levels.push(level); + } + if (['if', 'elseif'].includes(operator)) { + if (!level.executed) { + level.condition = level.parentCondition && evaluateCondition(args); + level.executed = level.condition; + } + else { + level.condition = false; + } + continue; + } + else if (operator === 'else') { + level.condition = level.parentCondition && !level.executed; + level.executed = level.condition; + continue; + } + else if (operator === 'endif') { + // Don't pop the base level for an unbalanced {{endif}} (i.e. one without a matching + // {{if}}), which would leave `levels` empty and cause `level` to be undefined on the + // next iteration + if (levels.length > 1) { + levels.pop(); + } + continue; + } + if (level.condition) { + const attrs = getAttributes(part); + html += evaluateIdentifier(operator, attrs); + } + } + else if (level.condition) { + html += part; + } + } + return html; +} diff --git a/chrome/content/zotero/xpcom/attachments.js b/chrome/content/zotero/xpcom/attachments.js index b43e463689..3bafa0dad4 100644 --- a/chrome/content/zotero/xpcom/attachments.js +++ b/chrome/content/zotero/xpcom/attachments.js @@ -25,6 +25,10 @@ Zotero.Attachments = new function () { const { HiddenBrowser } = ChromeUtils.importESModule("chrome://zotero/content/HiddenBrowser.mjs"); + let lazy = {}; + ChromeUtils.defineESModuleGetters(lazy, { + generateHTMLFromTemplate: "chrome://zotero/content/modules/templates.mjs", + }); // Keep in sync with Zotero.Schema.integrityCheck() and this.linkModeToName() this.LINK_MODE_IMPORTED_FILE = 0; @@ -2548,7 +2552,7 @@ Zotero.Attachments = new function () { // Final name is generated twice. In the first pass we collect all affixed values and determine protected literals. // This is done in order to remove repeated suffixes, except if these appear in the value or the format string itself. // See "should suppress suffixes where they would create a repeat character" test for edge cases. - let formatted = Zotero.Utilities.Internal.generateHTMLFromTemplate(formatString, vars); + let formatted = lazy.generateHTMLFromTemplate(formatString, vars); let replacePairs = new Map(); for (let chunk of chunks) { @@ -2571,7 +2575,7 @@ Zotero.Attachments = new function () { ); } - formatted = Zotero.Utilities.Internal.generateHTMLFromTemplate(formatString, vars); + formatted = lazy.generateHTMLFromTemplate(formatString, vars); if (replacePairs.size > 0) { formatted = formatted.replace( new RegExp(`(${Array.from(replacePairs.keys()).map(replace => `(? (annotation.tags && annotation.tags.map(tag => tag.name) || []).join(attrs.join || ' ') }; - let templateHTML = Zotero.Utilities.Internal.generateHTMLFromTemplate(template, vars); + let templateHTML = lazy.generateHTMLFromTemplate(template, vars); // Remove some spaces at the end of paragraph templateHTML = templateHTML.replace(/([\s]*)(<\/p)/g, '$2'); // Remove multiple spaces @@ -1890,3 +1896,4 @@ class EditorInstanceUtilities { Zotero.EditorInstance = EditorInstance; Zotero.EditorInstanceUtilities = new EditorInstanceUtilities(); +})(); diff --git a/chrome/content/zotero/xpcom/utilities_internal.js b/chrome/content/zotero/xpcom/utilities_internal.js index b5e4667a49..5bf769aa9c 100644 --- a/chrome/content/zotero/xpcom/utilities_internal.js +++ b/chrome/content/zotero/xpcom/utilities_internal.js @@ -2201,229 +2201,12 @@ Zotero.Utilities.Internal = { /** - * Splits a string by outer-most brackets (`{{` and '}}' by default, configurable). - * - * @param {string} input - The input string to split. - * @returns {string[]} An array of strings split by outer-most brackets. - */ - splitByOuterBrackets: function (input, left = '{{', right = '}}') { - const result = []; - let startIndex = 0; - let depth = 0; - - for (let i = 0; i < input.length; i++) { - if (input.slice(i, i + 2) === left) { - if (depth === 0) { - result.push(input.slice(startIndex, i)); - startIndex = i; - } - depth++; - } - else if (input.slice(i, i + 2) === right) { - depth--; - if (depth === 0) { - result.push(input.slice(startIndex, i + 2)); - startIndex = i + 2; - } - } - } - - if (startIndex < input.length) { - result.push(input.slice(startIndex)); - } - - return result; - }, - - /** - * A basic templating engine - * - * - 'if' statement does case-insensitive string comparison - * - functions can be called from if statements but must be wrapped in {{}} if arguments are passed (e.g. {{myFunction arg1="foo" arg2="bar"}}) - * - * Vars example: - * { - * color: '#ff6666', - * highlight: 'This is a highlight, - * comment: 'This is a comment', - * citation: '(Author, 1900)', - * image: '{{comment}}:
{{endif}}{{highlight}}
{{citation}}
- * {{else}} - *{{highlight}} {{citation}} {{comment}} {{if tags}} #{{tags join=' #'}}{{endif}}
- * {{endif}} - * - * @param {String} template - * @param {Object} vars - * @returns {String} HTML + * @deprecated Use generateHTMLFromTemplate() from modules/templates.mjs */ generateHTMLFromTemplate: function (template, vars) { - const hyphenToCamel = varName => varName.replace(/-(.)/g, (_, g1) => g1.toUpperCase()); - - const getAttributes = (part) => { - let attrsRegexp = new RegExp(/(([\w-]*) *=+ *(['"])((\\\3|[^\3])*?)\3)/g); - let attrs = {}; - let match; - while ((match = attrsRegexp.exec(part))) { - if (match[1]) { // if first alternative (i.e. argument with value wrapped in " or ') matched, even if value is empty - attrs[hyphenToCamel(match[2])] = match[4]; - } - } - return attrs; - }; - - - const evaluateIdentifier = (ident, args = {}) => { - ident = hyphenToCamel(ident); - - if (!(ident in vars)) { - return ''; - } - - const identValue = typeof vars[ident] === 'function' ? vars[ident](args) : vars[ident]; - - if (Array.isArray(identValue)) { - return identValue.length ? identValue.join(',') : ''; - } - - if (typeof identValue !== 'string') { - throw new Error(`Identifier "${ident}" does not evaluate to a string`); - } - - return identValue; - }; - - // evaluates extracted (i.e. without brackets) statement (e.g. `sum a="1" b="2"`) into a string value - const evaluateStatement = (statement) => { - statement = statement.trim(); - const operator = statement.split(' ', 1)[0].trim(); - const args = statement.slice(operator.length).trim(); - - return evaluateIdentifier(operator, getAttributes(args)); - }; - - // splits raw (i.e. bracketed) statement (e.g. `{{ sum a="1" b="2" }}) into operator and arguments (e.g. ['sum', 'a="1" b="2"']) - const splitStatement = (statement) => { - statement = statement.slice(2, -2).trim(); - const operator = statement.split(' ', 1)[0].trim(); - const args = statement.slice(operator.length).trim(); - return [operator, args]; - }; - - // We allow unquoted numbers in conditions, e.g. `a == 1` or `a == 1.0` but not `a == 1.0.0` or `a == 1st edition` - const asNumber = (string) => { - if (typeof string === 'number') { - return string; - } - const number = parseFloat(string); - if (!Number.isNaN(number) && string?.trim().match(/^[+-]?\d+(\.\d+)?$/)) { - return number; - } - return null; - }; - - // evaluates a condition (e.g. `a == "b"`) into a boolean value - const evaluateCondition = (condition) => { - const comparators = ['==', '!=', "<=", ">=", '<', '>']; - condition = condition.trim(); - - // Regular expression breakdown for condition matching: - // - `match[1]`: Left operand if it's a statement enclosed in `{{...}}`. - // - `match[3]`: Left operand if it's a string literal, extracted without quotes. - // - `match[4]`: Left operand if it's a standalone identifier (e.g., a variable) or a number - // - `match[6]`: Right operand if it's a statement enclosed in `{{...}}`. - // - `match[8]`: Right operand if it's a string literal, extracted without quotes. - // - `match[9]`: Right operand if it's a standalone identifier or a number. - // - `match[2]` and `match[7]`: Captured quotes around string literals, used to ensure matching pairs. - // - `match[5]`: The comparator (e.g., `==`, `!=`, `<`, `>`, etc.), extracted from `comparators.join('|')`. - const match = condition.match(new RegExp(String.raw`(?:{{(.*?)}}|(?:(['"])(.*?)\2)|([^ ]+)) *(${comparators.join('|')}) *(?:{{(.*?)}}|(?:(['"])(.*?)\7)|([^ ]+))`)); - - if (!match) { - // condition is a statement or identifier without a comparator - if (condition.startsWith('{{')) { - const [operator, args] = splitStatement(condition); - return !!evaluateIdentifier(operator, getAttributes(args)); - } - return !!evaluateIdentifier(condition); - } - - const left = match[1] ? evaluateStatement(match[1]) : match[3] ?? asNumber(match[4]) ?? evaluateIdentifier(match[4]) ?? ''; - const comparator = match[5]; - const right = match[6] ? evaluateStatement(match[6]) : match[8] ?? asNumber(match[9]) ?? evaluateIdentifier(match[9]) ?? ''; - - switch (comparator) { - default: - case '==': - return (asNumber(left) === null || asNumber(right === null)) ? left.toLowerCase() == right.toLowerCase() : asNumber(left) == asNumber(right); - case '!=': - return (asNumber(left) === null || asNumber(right === null)) ? left.toLowerCase() != right.toLowerCase() : asNumber(left) != asNumber(right); - case ">=": - return (asNumber(left) === null || asNumber(right === null)) ? left.toLowerCase() >= right.toLowerCase() : asNumber(left) >= asNumber(right); - case "<=": - return (asNumber(left) === null || asNumber(right === null)) ? left.toLowerCase() <= right.toLowerCase() : asNumber(left) <= asNumber(right); - case '>': - return (asNumber(left) === null || asNumber(right === null)) ? left.toLowerCase() > right.toLowerCase() : asNumber(left) > asNumber(right); - case '<': - return (asNumber(left) === null || asNumber(right === null)) ? left.toLowerCase() < right.toLowerCase() : asNumber(left) < asNumber(right); - } - }; - - let html = ''; - const levels = [{ condition: true }]; - const parts = this.splitByOuterBrackets(template); - - for (let i = 0; i < parts.length; i++) { - let part = parts[i]; - let level = levels[levels.length - 1]; - - if (part.startsWith('{{')) { - const [operator, args] = splitStatement(part); - - if (operator === 'if') { - level = { condition: false, executed: false, parentCondition: levels[levels.length - 1].condition }; - levels.push(level); - } - if (['if', 'elseif'].includes(operator)) { - if (!level.executed) { - level.condition = level.parentCondition && evaluateCondition(args); - level.executed = level.condition; - } - else { - level.condition = false; - } - continue; - } - else if (operator === 'else') { - level.condition = level.parentCondition && !level.executed; - level.executed = level.condition; - continue; - } - else if (operator === 'endif') { - // Don't pop the base level for an unbalanced {{endif}} (i.e. one without a matching - // {{if}}), which would leave `levels` empty and cause `level` to be undefined on the - // next iteration - if (levels.length > 1) { - levels.pop(); - } - continue; - } - if (level.condition) { - const attrs = getAttributes(part); - html += evaluateIdentifier(operator, attrs); - } - } - else if (level.condition) { - html += part; - } - } - return html; + Zotero.debug("Zotero.Utilities.Internal.generateHTMLFromTemplate() is deprecated -- use modules/templates.mjs"); + let { generateHTMLFromTemplate } = ChromeUtils.importESModule("chrome://zotero/content/modules/templates.mjs"); + return generateHTMLFromTemplate(template, vars); }, /** diff --git a/test/tests/templatesTest.js b/test/tests/templatesTest.js new file mode 100644 index 0000000000..b6554a1608 --- /dev/null +++ b/test/tests/templatesTest.js @@ -0,0 +1,228 @@ +var { + generateHTMLFromTemplate, +} = ChromeUtils.importESModule("chrome://zotero/content/modules/templates.mjs"); + +describe("Templates", function () { + describe("#generateHTMLFromTemplate()", function () { + it("should support variables with attributes", function () { + var vars = { + v1: '1', + v2: pars => `${pars.a1 ?? ''}${pars.a2 ?? ''}${pars.a3 ?? ''}`, + v3: () => '', + v5: () => 'something', + ar1: [], + ar2: [1, 2] + }; + var template = `{{ v1}}{{v2 a1= "1" a2 =' 2' a3 = "3 "}}{{v3}}{{v4}}{{if ar1}}ar1{{endif}}{{if ar2}}{{ar2}}{{endif}}{{if v5}}yes{{endif}}{{if v3}}no1{{endif}}{{if v2}}{{v2}}{{endif}}`; + var html = generateHTMLFromTemplate(template, vars); + assert.equal(html, '11 23 1,2yes'); + }); + + it("should support empty string as attribute value and correctly render returned false-ish values", function () { + const vars = { + length: ({ string }) => string.length.toString(), + }; + const template = `"" has a length of {{ length string="" }} and "hello" has a length of {{ length string="hello" }}`; + const out = generateHTMLFromTemplate(template, vars); + assert.equal(out, '"" has a length of 0 and "hello" has a length of 5'); + }); + + it("should support functions in comparison statements", function () { + const vars = { + sum: ({ a, b }) => (parseInt(a) + parseInt(b)).toString(), + fooBar: ({ isFoo }) => (isFoo === 'true' ? 'foo' : 'bar'), + false: 'false', + twoWords: 'two words', + onlyOne: 'actually == 1' + }; + const template = `{{if {{ sum a="1" b="2" }} == "3"}}1 + 2 = {{sum a="1" b="2"}}{{else}}no speak math{{endif}}`; + const out = generateHTMLFromTemplate(template, vars); + assert.equal(out, '1 + 2 = 3'); + + const template2 = '{{if false != "false"}}no{{elseif false == "false"}}yes{{else}}no{{endif}}'; + const out2 = generateHTMLFromTemplate(template2, vars); + assert.equal(out2, 'yes'); + + const template3 = '{{ if twoWords == "two words" }}yes{{else}}no{{endif}}'; + const out3 = generateHTMLFromTemplate(template3, vars); + assert.equal(out3, 'yes'); + + const template4 = '{{ if onlyOne == \'actually == 1\' }}yes{{else}}no{{endif}}'; + const out4 = generateHTMLFromTemplate(template4, vars); + assert.equal(out4, 'yes'); + + const template5 = '{{ if "3" == {{ sum a="1" b="2" }} }}yes{{else}}no{{endif}}'; + const out5 = generateHTMLFromTemplate(template5, vars); + assert.equal(out5, 'yes'); + + const template6 = '{{ if {{ sum a="1" b="2" }} }}yes{{else}}no{{endif}}'; + const out6 = generateHTMLFromTemplate(template6, vars); + assert.equal(out6, 'yes'); + + const template7 = '{{ if {{ twoWords }} }}yes{{else}}no{{endif}}'; + const out7 = generateHTMLFromTemplate(template7, vars); + assert.equal(out7, 'yes'); + + const template8 = '{{ if twoWords }}yes{{else}}no{{endif}}'; + const out8 = generateHTMLFromTemplate(template8, vars); + assert.equal(out8, 'yes'); + + const template9 = '{{ if missing }}no{{else}}yes{{endif}}'; + const out9 = generateHTMLFromTemplate(template9, vars); + assert.equal(out9, 'yes'); + + const template10 = '{{ if {{ missing foo="bar" }} }}no{{else}}yes{{endif}}'; + const out10 = generateHTMLFromTemplate(template10, vars); + assert.equal(out10, 'yes'); + + const template11 = '{{ if {{ missing foo="bar" }} == "" }}yes{{else}}no{{endif}}'; + const out11 = generateHTMLFromTemplate(template11, vars); + assert.equal(out11, 'yes'); + + const template12 = '{{ if fooBar == "bar" }}yes{{else}}no{{endif}}'; + const out12 = generateHTMLFromTemplate(template12, vars); + assert.equal(out12, 'yes'); + + const template13 = '{{ if {{ fooBar }} == "bar" }}yes{{else}}no{{endif}}'; + const out13 = generateHTMLFromTemplate(template13, vars); + assert.equal(out13, 'yes'); + + const template14 = `{{if {{ sum a="1" b="2" }}=="3"}}1 + 2 = {{sum a="1" b="2"}}{{else}}no{{endif}}`; + const out14 = generateHTMLFromTemplate(template14, vars); + assert.equal(out14, '1 + 2 = 3'); + + const template15 = `{{if "two words"==twoWords}}yes{{else}}no{{endif}}`; + const out15 = generateHTMLFromTemplate(template15, vars); + assert.equal(out15, 'yes'); + }); + + it("should support relational operators", function () { + const vars = { + sum: ({ a, b }) => (parseInt(a) + parseInt(b)).toString(), + v1: '1', + v2: 'foo', + v3: '100', + v4: '99', + π: '3.14', + }; + + const template1 = `{{if v1 > π}}more than π{{elseif v1 <= π}}less or equal to π{{endif}}`; + const out1 = generateHTMLFromTemplate(template1, vars); + assert.equal(out1, 'less or equal to π'); + + const template2 = `{{if {{ sum a="2" b="3" }} > π}}more than π{{else}}less or equal to π{{endif}}`; + const out2 = generateHTMLFromTemplate(template2, vars); + assert.equal(out2, 'more than π'); + + const template3 = `{{if 3.14 >= π}}more than or equal to π{{else}}less than π{{endif}}`; + const out3 = generateHTMLFromTemplate(template3, vars); + assert.equal(out3, 'more than or equal to π'); + + const template4 = `{{if v3 > v4}}100 is more than 99{{else}}string "100" would be sorted before "99"{{endif}}`; + const out4 = generateHTMLFromTemplate(template4, vars); + assert.equal(out4, '100 is more than 99'); + + // This is undocumented and unsupported behavior, but comparing strings should work + const template5 = `{{if "test" > v2}}"test" > "foo"{{else}}no{{endif}}`; + const out5 = generateHTMLFromTemplate(template5, vars); + assert.equal(out5, '"test" > "foo"'); + + const template6 = `{{if "bar" < v2 }}"bar" < "foo"{{else}}no{{endif}}`; + const out6 = generateHTMLFromTemplate(template6, vars); + assert.equal(out6, '"bar" < "foo"'); + }); + + it("should accept hyphen-case variables and attributes", function () { + const vars = { + fooBar: ({ isFoo }) => (isFoo === 'true' ? 'foo' : 'bar'), + }; + const template = '{{ foo-bar is-foo="true" }}{{ if {{ foo-bar is-foo="false" }} == "bar" }}{{ foo-bar is-foo="false" }}{{ endif }}'; + const out = generateHTMLFromTemplate(template, vars); + assert.equal(out, 'foobar'); + }); + + it("should not throw on an unbalanced {{endif}}", function () { + const vars = { + v1: '1', + }; + // An {{endif}} without a matching {{if}} shouldn't pop the base level and crash + const template = 'foo{{endif}}bar{{if v1 == "1"}}baz{{endif}}'; + const out = generateHTMLFromTemplate(template, vars); + assert.equal(out, 'foobarbaz'); + }); + + it("should work with a condition in the middle", function () { + const vars = { + v1: '1', + }; + const template = 'test {{ if v1 == "1" }}yes{{ else }}no{{ endif }} foobar'; + const out = generateHTMLFromTemplate(template, vars); + assert.equal(out, 'test yes foobar'); + }); + + it("missing identifiers are evaluted as empty string", function () { + const vars = { + foo: 'foo', + }; + const template = '{{bar}}{{ if foo == "" }}no{{elseif foo}}{{foo}}{{else}}no{{endif}}'; + const out = generateHTMLFromTemplate(template, vars); + assert.equal(out, 'foo'); + + const template2 = 'test: {{ if bar == "" }}yes{{else}}no{{endif}}'; + const out2 = generateHTMLFromTemplate(template2, vars); + assert.equal(out2, 'test: yes'); + }); + + it("should preserve whitespace outside of brackets", function () { + const template = ' starts }} with {{ whitespace {"test"} == \'foobar\' '; + const out = generateHTMLFromTemplate(template, {}); + assert.equal(out, template); + const vars = { + space: ' ', + spaceFn: () => ' ', + }; + + const whitespace = ' {{if spaceFn}}{{else}} {{endif}}{{space}} {{space-fn}}'; + const out2 = generateHTMLFromTemplate(whitespace, vars); + assert.equal(out2, ' '); + }); + + it("should accept array values in logic statements", function () { + let someTags = ['foo', 'bar']; + const vars = { + tags: ({ join }) => (join ? someTags.join(join) : someTags), + }; + const template = '{{ if tags }}#{{ tags join=" #" }}{{else}}no tags{{endif}}'; + const out = generateHTMLFromTemplate(template, vars); + assert.equal(out, '#foo #bar'); + + someTags = []; + const out2 = generateHTMLFromTemplate(template, vars); + assert.equal(out2, 'no tags'); + }); + + + it("should throw if function returns anything else than a string (or an array which is always joined into string)", function () { + const vars = { + number: () => 1, + logic: () => true, + array: () => [], + fn: () => 1, + }; + assert.throws(() => generateHTMLFromTemplate('{{ number }}', vars), /Identifier "number" does not evaluate to a string/); + assert.throws(() => generateHTMLFromTemplate('{{ logic }}', vars), /Identifier "logic" does not evaluate to a string/); + assert.throws(() => generateHTMLFromTemplate('{{ if fn }}no{{endif}}', vars), /Identifier "fn" does not evaluate to a string/); + assert.throws(() => generateHTMLFromTemplate('{{ if {{ fn foo="bar" }} }}no{{endif}}', vars), /Identifier "fn" does not evaluate to a string/); + }); + + it("should support nested 'if' statements", function () { + var vars = { + v1: '1', + v2: 'H', + }; + var template = `{{if v1 == '1'}}yes1{{if x}}no{{elseif v2 == "h" }}yes2{{endif}}{{elseif v2 == "2"}}no{{else}}no{{endif}} {{if v2 == "1"}}not{{elseif x}}not{{else}}yes3{{ endif}}`; + var html = generateHTMLFromTemplate(template, vars); + assert.equal(html, 'yes1yes2 yes3'); + }); + }); +}); diff --git a/test/tests/utilities_internalTest.js b/test/tests/utilities_internalTest.js index 89624a1eb4..50b7fe8cf5 100644 --- a/test/tests/utilities_internalTest.js +++ b/test/tests/utilities_internalTest.js @@ -581,229 +581,6 @@ describe("Zotero.Utilities.Internal", function () { }); }); - describe("#generateHTMLFromTemplate()", function () { - it("should support variables with attributes", function () { - var vars = { - v1: '1', - v2: pars => `${pars.a1 ?? ''}${pars.a2 ?? ''}${pars.a3 ?? ''}`, - v3: () => '', - v5: () => 'something', - ar1: [], - ar2: [1, 2] - }; - var template = `{{ v1}}{{v2 a1= "1" a2 =' 2' a3 = "3 "}}{{v3}}{{v4}}{{if ar1}}ar1{{endif}}{{if ar2}}{{ar2}}{{endif}}{{if v5}}yes{{endif}}{{if v3}}no1{{endif}}{{if v2}}{{v2}}{{endif}}`; - var html = Zotero.Utilities.Internal.generateHTMLFromTemplate(template, vars); - assert.equal(html, '11 23 1,2yes'); - }); - - it("should support empty string as attribute value and correctly render returned false-ish values", function () { - const vars = { - length: ({ string }) => string.length.toString(), - }; - const template = `"" has a length of {{ length string="" }} and "hello" has a length of {{ length string="hello" }}`; - const out = Zotero.Utilities.Internal.generateHTMLFromTemplate(template, vars); - assert.equal(out, '"" has a length of 0 and "hello" has a length of 5'); - }); - - it("should support functions in comparison statements", function () { - const vars = { - sum: ({ a, b }) => (parseInt(a) + parseInt(b)).toString(), - fooBar: ({ isFoo }) => (isFoo === 'true' ? 'foo' : 'bar'), - false: 'false', - twoWords: 'two words', - onlyOne: 'actually == 1' - }; - const template = `{{if {{ sum a="1" b="2" }} == "3"}}1 + 2 = {{sum a="1" b="2"}}{{else}}no speak math{{endif}}`; - const out = Zotero.Utilities.Internal.generateHTMLFromTemplate(template, vars); - assert.equal(out, '1 + 2 = 3'); - - const template2 = '{{if false != "false"}}no{{elseif false == "false"}}yes{{else}}no{{endif}}'; - const out2 = Zotero.Utilities.Internal.generateHTMLFromTemplate(template2, vars); - assert.equal(out2, 'yes'); - - const template3 = '{{ if twoWords == "two words" }}yes{{else}}no{{endif}}'; - const out3 = Zotero.Utilities.Internal.generateHTMLFromTemplate(template3, vars); - assert.equal(out3, 'yes'); - - const template4 = '{{ if onlyOne == \'actually == 1\' }}yes{{else}}no{{endif}}'; - const out4 = Zotero.Utilities.Internal.generateHTMLFromTemplate(template4, vars); - assert.equal(out4, 'yes'); - - const template5 = '{{ if "3" == {{ sum a="1" b="2" }} }}yes{{else}}no{{endif}}'; - const out5 = Zotero.Utilities.Internal.generateHTMLFromTemplate(template5, vars); - assert.equal(out5, 'yes'); - - const template6 = '{{ if {{ sum a="1" b="2" }} }}yes{{else}}no{{endif}}'; - const out6 = Zotero.Utilities.Internal.generateHTMLFromTemplate(template6, vars); - assert.equal(out6, 'yes'); - - const template7 = '{{ if {{ twoWords }} }}yes{{else}}no{{endif}}'; - const out7 = Zotero.Utilities.Internal.generateHTMLFromTemplate(template7, vars); - assert.equal(out7, 'yes'); - - const template8 = '{{ if twoWords }}yes{{else}}no{{endif}}'; - const out8 = Zotero.Utilities.Internal.generateHTMLFromTemplate(template8, vars); - assert.equal(out8, 'yes'); - - const template9 = '{{ if missing }}no{{else}}yes{{endif}}'; - const out9 = Zotero.Utilities.Internal.generateHTMLFromTemplate(template9, vars); - assert.equal(out9, 'yes'); - - const template10 = '{{ if {{ missing foo="bar" }} }}no{{else}}yes{{endif}}'; - const out10 = Zotero.Utilities.Internal.generateHTMLFromTemplate(template10, vars); - assert.equal(out10, 'yes'); - - const template11 = '{{ if {{ missing foo="bar" }} == "" }}yes{{else}}no{{endif}}'; - const out11 = Zotero.Utilities.Internal.generateHTMLFromTemplate(template11, vars); - assert.equal(out11, 'yes'); - - const template12 = '{{ if fooBar == "bar" }}yes{{else}}no{{endif}}'; - const out12 = Zotero.Utilities.Internal.generateHTMLFromTemplate(template12, vars); - assert.equal(out12, 'yes'); - - const template13 = '{{ if {{ fooBar }} == "bar" }}yes{{else}}no{{endif}}'; - const out13 = Zotero.Utilities.Internal.generateHTMLFromTemplate(template13, vars); - assert.equal(out13, 'yes'); - - const template14 = `{{if {{ sum a="1" b="2" }}=="3"}}1 + 2 = {{sum a="1" b="2"}}{{else}}no{{endif}}`; - const out14 = Zotero.Utilities.Internal.generateHTMLFromTemplate(template14, vars); - assert.equal(out14, '1 + 2 = 3'); - - const template15 = `{{if "two words"==twoWords}}yes{{else}}no{{endif}}`; - const out15 = Zotero.Utilities.Internal.generateHTMLFromTemplate(template15, vars); - assert.equal(out15, 'yes'); - }); - - it("should support relational operators", function () { - const vars = { - sum: ({ a, b }) => (parseInt(a) + parseInt(b)).toString(), - v1: '1', - v2: 'foo', - v3: '100', - v4: '99', - π: '3.14', - }; - - const template1 = `{{if v1 > π}}more than π{{elseif v1 <= π}}less or equal to π{{endif}}`; - const out1 = Zotero.Utilities.Internal.generateHTMLFromTemplate(template1, vars); - assert.equal(out1, 'less or equal to π'); - - const template2 = `{{if {{ sum a="2" b="3" }} > π}}more than π{{else}}less or equal to π{{endif}}`; - const out2 = Zotero.Utilities.Internal.generateHTMLFromTemplate(template2, vars); - assert.equal(out2, 'more than π'); - - const template3 = `{{if 3.14 >= π}}more than or equal to π{{else}}less than π{{endif}}`; - const out3 = Zotero.Utilities.Internal.generateHTMLFromTemplate(template3, vars); - assert.equal(out3, 'more than or equal to π'); - - const template4 = `{{if v3 > v4}}100 is more than 99{{else}}string "100" would be sorted before "99"{{endif}}`; - const out4 = Zotero.Utilities.Internal.generateHTMLFromTemplate(template4, vars); - assert.equal(out4, '100 is more than 99'); - - // This is undocumented and unsupported behavior, but comparing strings should work - const template5 = `{{if "test" > v2}}"test" > "foo"{{else}}no{{endif}}`; - const out5 = Zotero.Utilities.Internal.generateHTMLFromTemplate(template5, vars); - assert.equal(out5, '"test" > "foo"'); - - const template6 = `{{if "bar" < v2 }}"bar" < "foo"{{else}}no{{endif}}`; - const out6 = Zotero.Utilities.Internal.generateHTMLFromTemplate(template6, vars); - assert.equal(out6, '"bar" < "foo"'); - }); - - it("should accept hyphen-case variables and attributes", function () { - const vars = { - fooBar: ({ isFoo }) => (isFoo === 'true' ? 'foo' : 'bar'), - }; - const template = '{{ foo-bar is-foo="true" }}{{ if {{ foo-bar is-foo="false" }} == "bar" }}{{ foo-bar is-foo="false" }}{{ endif }}'; - const out = Zotero.Utilities.Internal.generateHTMLFromTemplate(template, vars); - assert.equal(out, 'foobar'); - }); - - it("should not throw on an unbalanced {{endif}}", function () { - const vars = { - v1: '1', - }; - // An {{endif}} without a matching {{if}} shouldn't pop the base level and crash - const template = 'foo{{endif}}bar{{if v1 == "1"}}baz{{endif}}'; - const out = Zotero.Utilities.Internal.generateHTMLFromTemplate(template, vars); - assert.equal(out, 'foobarbaz'); - }); - - it("should work with a condition in the middle", function () { - const vars = { - v1: '1', - }; - const template = 'test {{ if v1 == "1" }}yes{{ else }}no{{ endif }} foobar'; - const out = Zotero.Utilities.Internal.generateHTMLFromTemplate(template, vars); - assert.equal(out, 'test yes foobar'); - }); - - it("missing identifiers are evaluted as empty string", function () { - const vars = { - foo: 'foo', - }; - const template = '{{bar}}{{ if foo == "" }}no{{elseif foo}}{{foo}}{{else}}no{{endif}}'; - const out = Zotero.Utilities.Internal.generateHTMLFromTemplate(template, vars); - assert.equal(out, 'foo'); - - const template2 = 'test: {{ if bar == "" }}yes{{else}}no{{endif}}'; - const out2 = Zotero.Utilities.Internal.generateHTMLFromTemplate(template2, vars); - assert.equal(out2, 'test: yes'); - }); - - it("should preserve whitespace outside of brackets", function () { - const template = ' starts }} with {{ whitespace {"test"} == \'foobar\' '; - const out = Zotero.Utilities.Internal.generateHTMLFromTemplate(template, {}); - assert.equal(out, template); - const vars = { - space: ' ', - spaceFn: () => ' ', - }; - - const whitespace = ' {{if spaceFn}}{{else}} {{endif}}{{space}} {{space-fn}}'; - const out2 = Zotero.Utilities.Internal.generateHTMLFromTemplate(whitespace, vars); - assert.equal(out2, ' '); - }); - - it("should accept array values in logic statements", function () { - let someTags = ['foo', 'bar']; - const vars = { - tags: ({ join }) => (join ? someTags.join(join) : someTags), - }; - const template = '{{ if tags }}#{{ tags join=" #" }}{{else}}no tags{{endif}}'; - const out = Zotero.Utilities.Internal.generateHTMLFromTemplate(template, vars); - assert.equal(out, '#foo #bar'); - - someTags = []; - const out2 = Zotero.Utilities.Internal.generateHTMLFromTemplate(template, vars); - assert.equal(out2, 'no tags'); - }); - - - it("should throw if function returns anything else than a string (or an array which is always joined into string)", function () { - const vars = { - number: () => 1, - logic: () => true, - array: () => [], - fn: () => 1, - }; - assert.throws(() => Zotero.Utilities.Internal.generateHTMLFromTemplate('{{ number }}', vars), /Identifier "number" does not evaluate to a string/); - assert.throws(() => Zotero.Utilities.Internal.generateHTMLFromTemplate('{{ logic }}', vars), /Identifier "logic" does not evaluate to a string/); - assert.throws(() => Zotero.Utilities.Internal.generateHTMLFromTemplate('{{ if fn }}no{{endif}}', vars), /Identifier "fn" does not evaluate to a string/); - assert.throws(() => Zotero.Utilities.Internal.generateHTMLFromTemplate('{{ if {{ fn foo="bar" }} }}no{{endif}}', vars), /Identifier "fn" does not evaluate to a string/); - }); - - it("should support nested 'if' statements", function () { - var vars = { - v1: '1', - v2: 'H', - }; - var template = `{{if v1 == '1'}}yes1{{if x}}no{{elseif v2 == "h" }}yes2{{endif}}{{elseif v2 == "2"}}no{{else}}no{{endif}} {{if v2 == "1"}}not{{elseif x}}not{{else}}yes3{{ endif}}`; - var html = Zotero.Utilities.Internal.generateHTMLFromTemplate(template, vars); - assert.equal(html, 'yes1yes2 yes3'); - }); - }); - describe("OpenURL", function () { var item;