Move templating engine to a standalone, lazy-loaded module (#5989)

This commit is contained in:
Tom Najdek 2026-07-22 14:56:19 +02:00 • committed by Dan Stillman
parent dc6d55a137
commit efcfaec5d4
6 changed files with 497 additions and 448 deletions

View file

@ -0,0 +1,250 @@
/*
***** BEGIN LICENSE BLOCK *****
Copyright © 2026 Corporation for Digital Scholarship
Vienna, Virginia, USA
http://zotero.org
This file is part of Zotero.
Zotero is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Zotero is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with Zotero. If not, see <http://www.gnu.org/licenses/>.
***** END LICENSE BLOCK *****
*/
/**
* 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.
*/
export function splitByOuterBrackets(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: '<span class="highlight">This is a highlight</span>,
* comment: 'This is a comment',
* citation: '<span class="citation">(Author, 1900)</citation>',
* image: '<img src="…"/>',
* tags: (attrs) => ['tag1', 'tag2'].map(tag => tag.name).join(attrs.join || ' ')
* }
*
* Template example:
* {{if color == '#ff6666'}}
* <h2>{{highlight}}</h2>
* {{elseif color == '#2ea8e5'}}
* {{if comment}}<p>{{comment}}:</p>{{endif}}<blockquote>{{highlight}}</blockquote><p>{{citation}}</p>
* {{else}}
* <p>{{highlight}} {{citation}} {{comment}} {{if tags}} #{{tags join=' #'}}{{endif}}</p>
* {{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;
}

View file

@ -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 => `(?<!\\\\)${replace}(?!//)`).join('|')})`, 'g'),

View file

@ -23,9 +23,15 @@
***** END LICENSE BLOCK *****
*/
(function () {
var { InlineSpellChecker } = ChromeUtils.importESModule("resource://gre/modules/InlineSpellChecker.sys.mjs");
var { FilePicker } = ChromeUtils.importESModule('chrome://zotero/content/modules/filePicker.mjs');
let lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
generateHTMLFromTemplate: "chrome://zotero/content/modules/templates.mjs",
});
// Note: TinyMCE is automatically doing some meaningless corrections to
// note-editor produced HTML. Which might result to more
// conflicts, especially in group libraries
@ -1495,7 +1501,7 @@ class EditorInstance {
title: Zotero.getString('reader-annotations'),
date: new Date().toLocaleString()
};
html = Zotero.Utilities.Internal.generateHTMLFromTemplate(Zotero.Prefs.get('annotations.noteTemplates.title'), vars);
html = lazy.generateHTMLFromTemplate(Zotero.Prefs.get('annotations.noteTemplates.title'), vars);
// New line is needed for note title parser
html += '\n';
}
@ -1730,7 +1736,7 @@ class EditorInstanceUtilities {
tags: (attrs) => (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();
})();

View file

@ -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: '<span class="highlight">This is a highlight</span>,
* comment: 'This is a comment',
* citation: '<span class="citation">(Author, 1900)</citation>',
* image: '<img src="…"/>',
* tags: (attrs) => ['tag1', 'tag2'].map(tag => tag.name).join(attrs.join || ' ')
* }
*
* Template example:
* {{if color == '#ff6666'}}
* <h2>{{highlight}}</h2>
* {{elseif color == '#2ea8e5'}}
* {{if comment}}<p>{{comment}}:</p>{{endif}}<blockquote>{{highlight}}</blockquote><p>{{citation}}</p>
* {{else}}
* <p>{{highlight}} {{citation}} {{comment}} {{if tags}} #{{tags join=' #'}}{{endif}}</p>
* {{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);
},
/**

228
test/tests/templatesTest.js Normal file
View file

@ -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');
});
});
});

View file

@ -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;