@formfeed/engine 0.3.4 → 0.3.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/index.cjs +466 -172
- package/index.js +466 -172
- package/package.json +1 -1
- package/types/index.d.cts +2 -1
- package/types/index.d.ts +2 -1
- package/types/lib/codes/epc.d.cts +56 -0
- package/types/lib/codes/epc.d.ts +56 -0
- package/types/lib/codes/index.d.cts +7 -0
- package/types/lib/codes/index.d.ts +7 -0
- package/types/lib/codes/qr.d.cts +11 -0
- package/types/lib/codes/qr.d.ts +11 -0
- package/types/lib/helpers/codes.d.cts +0 -19
- package/types/lib/helpers/codes.d.ts +0 -19
- package/types/lib/helpers/format.d.cts +2 -1
- package/types/lib/helpers/format.d.ts +2 -1
- package/types/lib/helpers/index.d.cts +4 -6
- package/types/lib/helpers/index.d.ts +4 -6
- package/types/lib/types.d.cts +14 -1
- package/types/lib/types.d.ts +14 -1
- package/types/lib/values.d.cts +8 -0
- package/types/lib/values.d.ts +8 -0
package/index.cjs
CHANGED
|
@@ -32,6 +32,7 @@ var index_exports = {};
|
|
|
32
32
|
__export(index_exports, {
|
|
33
33
|
DEFAULT_CHROME_PADDING: () => DEFAULT_CHROME_PADDING,
|
|
34
34
|
DefaultHelperRegistry: () => DefaultHelperRegistry,
|
|
35
|
+
EPC_MAX_BYTES: () => EPC_MAX_BYTES,
|
|
35
36
|
EngineSyntaxError: () => EngineSyntaxError,
|
|
36
37
|
OFFICE_FILLED_MAX_BYTES: () => OFFICE_FILLED_MAX_BYTES,
|
|
37
38
|
OfficeTemplateError: () => OfficeTemplateError,
|
|
@@ -68,6 +69,7 @@ __export(index_exports, {
|
|
|
68
69
|
getEngine: () => getEngine,
|
|
69
70
|
handlebarsEngine: () => handlebarsEngine,
|
|
70
71
|
helperDocs: () => helperDocs,
|
|
72
|
+
ibanChecksum: () => ibanChecksum,
|
|
71
73
|
imageMarkup: () => imageMarkup,
|
|
72
74
|
imageOutput: () => imageOutput,
|
|
73
75
|
importApitemplate: () => importApitemplate,
|
|
@@ -92,6 +94,7 @@ __export(index_exports, {
|
|
|
92
94
|
mergeSettings: () => mergeSettings,
|
|
93
95
|
mergeSnippetCss: () => mergeSnippetCss,
|
|
94
96
|
mergeSnippetI18n: () => mergeSnippetI18n,
|
|
97
|
+
normaliseIban: () => normaliseIban,
|
|
95
98
|
normalisePartialName: () => normalisePartialName,
|
|
96
99
|
nunjucksBuiltinFilters: () => nunjucksBuiltinFilters,
|
|
97
100
|
officeDrawingLimits: () => officeDrawingLimits,
|
|
@@ -122,8 +125,10 @@ __export(index_exports, {
|
|
|
122
125
|
starterDocument: () => starterDocument,
|
|
123
126
|
starterPresentation: () => starterPresentation,
|
|
124
127
|
strftimeToDateFns: () => strftimeToDateFns,
|
|
128
|
+
svgDataUri: () => svgDataUri,
|
|
125
129
|
toDate: () => toDate,
|
|
126
130
|
toNumber: () => toNumber,
|
|
131
|
+
validateEpc: () => validateEpc,
|
|
127
132
|
zoomGestureScript: () => zoomGestureScript
|
|
128
133
|
});
|
|
129
134
|
module.exports = __toCommonJS(index_exports);
|
|
@@ -207,8 +212,234 @@ function depthGuard(resolve2, includeDepth) {
|
|
|
207
212
|
|
|
208
213
|
// packages/engine/src/lib/helpers/codes.ts
|
|
209
214
|
var import_bwip_js = require("bwip-js");
|
|
215
|
+
|
|
216
|
+
// packages/engine/src/lib/values.ts
|
|
217
|
+
function toNumber(value) {
|
|
218
|
+
if (typeof value === "number") return value;
|
|
219
|
+
if (typeof value === "string") {
|
|
220
|
+
const n = Number(value.replace(/\s/g, "").replace(",", "."));
|
|
221
|
+
return Number.isFinite(n) ? n : NaN;
|
|
222
|
+
}
|
|
223
|
+
if (typeof value === "bigint") return Number(value);
|
|
224
|
+
return NaN;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// packages/engine/src/lib/codes/epc.ts
|
|
228
|
+
function epcPayload(input) {
|
|
229
|
+
const amount = input.amount === void 0 || input.amount === "" ? "" : `EUR${toNumber(input.amount).toFixed(2)}`;
|
|
230
|
+
return [
|
|
231
|
+
"BCD",
|
|
232
|
+
"002",
|
|
233
|
+
"1",
|
|
234
|
+
"SCT",
|
|
235
|
+
(input.bic ?? "").replace(/\s/g, ""),
|
|
236
|
+
String(input.name ?? "").slice(0, 70),
|
|
237
|
+
String(input.iban ?? "").replace(/\s/g, ""),
|
|
238
|
+
amount,
|
|
239
|
+
(input.purpose ?? "").slice(0, 4),
|
|
240
|
+
(input.reference ?? "").slice(0, 35),
|
|
241
|
+
input.reference ? "" : (input.text ?? "").slice(0, 140),
|
|
242
|
+
""
|
|
243
|
+
].join("\n");
|
|
244
|
+
}
|
|
245
|
+
var IBAN_LENGTHS = {
|
|
246
|
+
AD: 24,
|
|
247
|
+
AT: 20,
|
|
248
|
+
BE: 16,
|
|
249
|
+
BG: 22,
|
|
250
|
+
CH: 21,
|
|
251
|
+
CY: 28,
|
|
252
|
+
CZ: 24,
|
|
253
|
+
DE: 22,
|
|
254
|
+
DK: 18,
|
|
255
|
+
EE: 20,
|
|
256
|
+
ES: 24,
|
|
257
|
+
FI: 18,
|
|
258
|
+
FO: 18,
|
|
259
|
+
FR: 27,
|
|
260
|
+
GB: 22,
|
|
261
|
+
GI: 23,
|
|
262
|
+
GL: 18,
|
|
263
|
+
GR: 27,
|
|
264
|
+
HR: 21,
|
|
265
|
+
HU: 28,
|
|
266
|
+
IE: 22,
|
|
267
|
+
IS: 26,
|
|
268
|
+
IT: 27,
|
|
269
|
+
LI: 21,
|
|
270
|
+
LT: 20,
|
|
271
|
+
LU: 20,
|
|
272
|
+
LV: 21,
|
|
273
|
+
MC: 27,
|
|
274
|
+
MT: 31,
|
|
275
|
+
NL: 18,
|
|
276
|
+
NO: 15,
|
|
277
|
+
PL: 28,
|
|
278
|
+
PT: 25,
|
|
279
|
+
RO: 24,
|
|
280
|
+
SE: 24,
|
|
281
|
+
SI: 19,
|
|
282
|
+
SK: 24,
|
|
283
|
+
SM: 27,
|
|
284
|
+
VA: 22
|
|
285
|
+
};
|
|
286
|
+
var EPC_MAX_BYTES = 331;
|
|
287
|
+
var byteLength = (value) => new TextEncoder().encode(value).length;
|
|
288
|
+
var normaliseIban = (iban) => iban.replace(/\s/g, "").toUpperCase();
|
|
289
|
+
function ibanChecksum(iban) {
|
|
290
|
+
const rearranged = normaliseIban(iban).slice(4) + normaliseIban(iban).slice(0, 4);
|
|
291
|
+
const digits = [...rearranged].map((c) => c >= "A" && c <= "Z" ? String(c.charCodeAt(0) - 55) : c).join("");
|
|
292
|
+
let remainder = 0;
|
|
293
|
+
for (const digit of digits) remainder = (remainder * 10 + Number(digit)) % 97;
|
|
294
|
+
return remainder === 1;
|
|
295
|
+
}
|
|
296
|
+
function validateEpc(input) {
|
|
297
|
+
const problems = [];
|
|
298
|
+
const name = String(input.name ?? "").trim();
|
|
299
|
+
if (!name) problems.push({ field: "name", code: "required" });
|
|
300
|
+
else if (name.length > 70) problems.push({ field: "name", code: "too_long", max: 70, actual: name.length });
|
|
301
|
+
const iban = normaliseIban(String(input.iban ?? ""));
|
|
302
|
+
if (!iban) problems.push({ field: "iban", code: "required" });
|
|
303
|
+
else if (!/^[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}$/.test(iban)) problems.push({ field: "iban", code: "iban_characters" });
|
|
304
|
+
else {
|
|
305
|
+
const expected = IBAN_LENGTHS[iban.slice(0, 2)];
|
|
306
|
+
if (expected !== void 0 && iban.length !== expected)
|
|
307
|
+
problems.push({ field: "iban", code: "iban_country_length", max: expected, actual: iban.length });
|
|
308
|
+
else if (!ibanChecksum(iban)) problems.push({ field: "iban", code: "iban_checksum" });
|
|
309
|
+
}
|
|
310
|
+
const bic = String(input.bic ?? "").replace(/\s/g, "").toUpperCase();
|
|
311
|
+
if (bic && !/^[A-Z]{6}[A-Z0-9]{2}([A-Z0-9]{3})?$/.test(bic)) problems.push({ field: "bic", code: "bic_format" });
|
|
312
|
+
if (input.amount !== void 0 && input.amount !== "") {
|
|
313
|
+
const amount = toNumber(input.amount);
|
|
314
|
+
if (Number.isNaN(amount)) problems.push({ field: "amount", code: "not_a_number" });
|
|
315
|
+
else if (amount < 0.01 || amount > 99999999999e-2) problems.push({ field: "amount", code: "out_of_range" });
|
|
316
|
+
}
|
|
317
|
+
const purpose = String(input.purpose ?? "");
|
|
318
|
+
if (purpose.length > 4) problems.push({ field: "purpose", code: "too_long", max: 4, actual: purpose.length });
|
|
319
|
+
const reference = String(input.reference ?? "").trim();
|
|
320
|
+
const text = String(input.text ?? "").trim();
|
|
321
|
+
if (reference && text) problems.push({ field: "reference", code: "reference_or_text" });
|
|
322
|
+
if (reference.length > 35)
|
|
323
|
+
problems.push({ field: "reference", code: "too_long", max: 35, actual: reference.length });
|
|
324
|
+
if (text.length > 140) problems.push({ field: "text", code: "too_long", max: 140, actual: text.length });
|
|
325
|
+
const bytes = byteLength(epcPayload(input));
|
|
326
|
+
if (bytes > EPC_MAX_BYTES)
|
|
327
|
+
problems.push({ field: "payload", code: "too_long", max: EPC_MAX_BYTES, actual: bytes });
|
|
328
|
+
return problems;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// packages/engine/src/lib/codes/qr.ts
|
|
210
332
|
var import_qrcode = require("qrcode");
|
|
211
333
|
var import_svg_tag = require("qrcode/lib/renderer/svg-tag.js");
|
|
334
|
+
function qrSvg(value, options = {}) {
|
|
335
|
+
const qr = (0, import_qrcode.create)(value, { errorCorrectionLevel: options.ecc ?? "M" });
|
|
336
|
+
return (0, import_svg_tag.render)(qr, {
|
|
337
|
+
width: options.size ?? 160,
|
|
338
|
+
margin: options.margin ?? 1,
|
|
339
|
+
color: {
|
|
340
|
+
dark: (options.color ?? "#000000").replace(/^(#?)/, "#").slice(0, 9),
|
|
341
|
+
light: (options.background ?? "#ffffff").replace(/^(#?)/, "#")
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
var svgDataUri = (svg) => `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;
|
|
346
|
+
|
|
347
|
+
// packages/engine/src/lib/helpers/codes.ts
|
|
348
|
+
var barcodeTypes = {
|
|
349
|
+
code128: "code128",
|
|
350
|
+
ean13: "ean13",
|
|
351
|
+
ean8: "ean8",
|
|
352
|
+
upc: "upca",
|
|
353
|
+
upca: "upca",
|
|
354
|
+
itf14: "itf14",
|
|
355
|
+
code39: "code39",
|
|
356
|
+
qrcode: "qrcode",
|
|
357
|
+
datamatrix: "datamatrix",
|
|
358
|
+
pdf417: "pdf417"
|
|
359
|
+
};
|
|
360
|
+
var codeHelpers = [
|
|
361
|
+
{
|
|
362
|
+
name: "qrcode",
|
|
363
|
+
aliases: ["qr"],
|
|
364
|
+
doc: {
|
|
365
|
+
signature: "qrcode(value, { size = 160, ecc = 'M', margin = 1, color, background })",
|
|
366
|
+
description: "QR code as an SVG data URI for an <img> src.",
|
|
367
|
+
examples: {
|
|
368
|
+
jinja2: '<img src="{{ qrcode(order.url, { size: 120 }) }}">',
|
|
369
|
+
liquid: '<img src="{{ order.url | qrcode: size: 120 }}">',
|
|
370
|
+
handlebars: '<img src="{{qrcode order.url size=120}}">'
|
|
371
|
+
},
|
|
372
|
+
category: "code"
|
|
373
|
+
},
|
|
374
|
+
fn: (ctx, value, options) => {
|
|
375
|
+
const o = options ?? {};
|
|
376
|
+
const svg = qrSvg(String(value ?? ""), o);
|
|
377
|
+
if (ctx.drawing) return ctx.drawing({ kind: "svg", svg, width: o.size, height: o.size, alt: "QR code" });
|
|
378
|
+
return svgDataUri(svg);
|
|
379
|
+
}
|
|
380
|
+
},
|
|
381
|
+
{
|
|
382
|
+
name: "barcode",
|
|
383
|
+
doc: {
|
|
384
|
+
signature: "barcode(value, { type = 'code128', height = 12, width, text = true, scale = 2 })",
|
|
385
|
+
description: "Barcode (code128, ean13, ean8, upc, itf14, code39, datamatrix, pdf417) as an SVG data URI.",
|
|
386
|
+
examples: {
|
|
387
|
+
jinja2: `<img src="{{ barcode(item.sku, { type: 'code128' }) }}">`,
|
|
388
|
+
liquid: `<img src="{{ item.sku | barcode: type: 'code128' }}">`,
|
|
389
|
+
handlebars: `<img src="{{barcode item.sku type='code128'}}">`
|
|
390
|
+
},
|
|
391
|
+
category: "code"
|
|
392
|
+
},
|
|
393
|
+
fn: (ctx, value, options) => {
|
|
394
|
+
const o = options ?? {};
|
|
395
|
+
const bcid = barcodeTypes[String(o.type ?? "code128").toLowerCase()] ?? "code128";
|
|
396
|
+
const svg = (0, import_bwip_js.toSVG)({
|
|
397
|
+
bcid,
|
|
398
|
+
text: String(value ?? ""),
|
|
399
|
+
height: o.height ?? 12,
|
|
400
|
+
...o.width === void 0 ? {} : { width: o.width },
|
|
401
|
+
scale: o.scale ?? 2,
|
|
402
|
+
includetext: o.text !== false,
|
|
403
|
+
textxalign: "center"
|
|
404
|
+
});
|
|
405
|
+
if (ctx.drawing) return ctx.drawing({ kind: "svg", svg, alt: `Barcode ${String(value ?? "")}` });
|
|
406
|
+
return svgDataUri(svg);
|
|
407
|
+
}
|
|
408
|
+
},
|
|
409
|
+
{
|
|
410
|
+
name: "epcQr",
|
|
411
|
+
aliases: ["epc_qr", "girocode"],
|
|
412
|
+
doc: {
|
|
413
|
+
signature: "epcQr({ name, iban, bic?, amount?, reference?, text? }, { size })",
|
|
414
|
+
description: "SEPA credit transfer QR code (EPC069-12 / GiroCode) that banking apps scan to prefill a payment.",
|
|
415
|
+
examples: {
|
|
416
|
+
jinja2: '<img src="{{ epcQr({ name: company.name, iban: company.iban, amount: invoice.total, reference: invoice.number }) }}">',
|
|
417
|
+
liquid: '<img src="{{ company.iban | epcQr: name: company.name, amount: invoice.total, reference: invoice.number }}">',
|
|
418
|
+
handlebars: '<img src="{{epcQr name=company.name iban=company.iban amount=invoice.total reference=invoice.number}}">'
|
|
419
|
+
},
|
|
420
|
+
category: "code"
|
|
421
|
+
},
|
|
422
|
+
// Payment fields and QR options may arrive together: Jinja2 passes two objects, Handlebars one
|
|
423
|
+
// hash (`{{epcQr name=… iban=… size=200}}`), and Liquid, which cannot build an object literal,
|
|
424
|
+
// keyword arguments (`{{ company.iban | epcQr: name: company.name, amount: invoice.total }}`,
|
|
425
|
+
// where a string input is the IBAN). They are merged and split by field name.
|
|
426
|
+
fn: (ctx, input, options) => {
|
|
427
|
+
const fields = {
|
|
428
|
+
...input && typeof input === "object" ? input : typeof input === "string" && input ? { iban: input } : {},
|
|
429
|
+
...options ?? {}
|
|
430
|
+
};
|
|
431
|
+
const payment = {};
|
|
432
|
+
const qr = {};
|
|
433
|
+
for (const [key, value] of Object.entries(fields))
|
|
434
|
+
(EPC_FIELDS.has(key) ? payment : qr)[key] = value;
|
|
435
|
+
const o = { ecc: "M", ...qr };
|
|
436
|
+
const svg = qrSvg(epcPayload(payment), o);
|
|
437
|
+
if (ctx.drawing) return ctx.drawing({ kind: "svg", svg, width: o.size, height: o.size, alt: "GiroCode" });
|
|
438
|
+
return svgDataUri(svg);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
];
|
|
442
|
+
var EPC_FIELDS = /* @__PURE__ */ new Set(["name", "iban", "bic", "amount", "reference", "text", "purpose"]);
|
|
212
443
|
|
|
213
444
|
// packages/engine/src/lib/helpers/format.ts
|
|
214
445
|
var import_tz = require("@date-fns/tz");
|
|
@@ -242,15 +473,6 @@ var dateLocales = {
|
|
|
242
473
|
function dateLocale(locale) {
|
|
243
474
|
return dateLocales[locale] ?? dateLocales[locale.split("-")[0] ?? ""] ?? import_locale.enGB;
|
|
244
475
|
}
|
|
245
|
-
function toNumber(value) {
|
|
246
|
-
if (typeof value === "number") return value;
|
|
247
|
-
if (typeof value === "string") {
|
|
248
|
-
const n = Number(value.replace(/\s/g, "").replace(",", "."));
|
|
249
|
-
return Number.isFinite(n) ? n : NaN;
|
|
250
|
-
}
|
|
251
|
-
if (typeof value === "bigint") return Number(value);
|
|
252
|
-
return NaN;
|
|
253
|
-
}
|
|
254
476
|
function toDate(value, tz) {
|
|
255
477
|
if (value === "now" || value === void 0 || value === null || value === "")
|
|
256
478
|
return new import_tz.TZDate(Date.now(), tz);
|
|
@@ -273,6 +495,10 @@ function escapeHtml(value) {
|
|
|
273
495
|
var isNamedArgs = (v) => !!v && typeof v === "object" && !Array.isArray(v) && !(v instanceof Date);
|
|
274
496
|
var str = (v) => v === void 0 || v === null ? "" : String(v);
|
|
275
497
|
var opt = (v, fallback) => v === void 0 || v === null || v === "" ? fallback : v;
|
|
498
|
+
function requireFinite(helper, value) {
|
|
499
|
+
if (typeof value === "number" && !Number.isFinite(value))
|
|
500
|
+
throw new Error(`${helper}: got ${value}, the result of a calculation with a value that is missing or not a number`);
|
|
501
|
+
}
|
|
276
502
|
var wordsByLang = {
|
|
277
503
|
de: import_de.toCardinal,
|
|
278
504
|
en: import_en.toCardinal,
|
|
@@ -288,11 +514,16 @@ var formatHelpers = [
|
|
|
288
514
|
deprecatedAlias: { currency_format: "money" },
|
|
289
515
|
doc: {
|
|
290
516
|
signature: "money(value, currency?, locale?)",
|
|
291
|
-
description: "Formats a number as currency with Intl.NumberFormat. Defaults come from the template settings.",
|
|
292
|
-
|
|
517
|
+
description: "Formats a number as currency with Intl.NumberFormat. Defaults come from the template settings. Text that is not a number is printed as it is; a calculation that came out as NaN stops the render.",
|
|
518
|
+
examples: {
|
|
519
|
+
jinja2: "{{ line.total | money('EUR') }} \u2192 1.234,50 \u20AC",
|
|
520
|
+
liquid: "{{ line.total | money: 'EUR' }} \u2192 1.234,50 \u20AC",
|
|
521
|
+
handlebars: "{{money line.total 'EUR'}} \u2192 1.234,50 \u20AC"
|
|
522
|
+
},
|
|
293
523
|
category: "format"
|
|
294
524
|
},
|
|
295
525
|
fn: (ctx, value, currency, locale) => {
|
|
526
|
+
requireFinite("money", value);
|
|
296
527
|
const n = toNumber(value);
|
|
297
528
|
if (Number.isNaN(n)) return str(value);
|
|
298
529
|
return new Intl.NumberFormat(opt(locale, ctx.locale), {
|
|
@@ -306,10 +537,15 @@ var formatHelpers = [
|
|
|
306
537
|
doc: {
|
|
307
538
|
signature: "number(value, decimals?, locale?)",
|
|
308
539
|
description: "Formats a number with grouping. With decimals, exactly that many (rounded half away from zero); without, the decimals the number has, at most three, so 1234.5 \u2192 1.234,5 and 2.34567 \u2192 2,346. Use number(0) for whole numbers.",
|
|
309
|
-
|
|
540
|
+
examples: {
|
|
541
|
+
jinja2: "{{ qty | number(2) }} \u2192 1.234,00",
|
|
542
|
+
liquid: "{{ qty | number: 2 }} \u2192 1.234,00",
|
|
543
|
+
handlebars: "{{number qty 2}} \u2192 1.234,00"
|
|
544
|
+
},
|
|
310
545
|
category: "format"
|
|
311
546
|
},
|
|
312
547
|
fn: (ctx, value, decimals, locale) => {
|
|
548
|
+
requireFinite("number", value);
|
|
313
549
|
const n = toNumber(value);
|
|
314
550
|
if (Number.isNaN(n)) return str(value);
|
|
315
551
|
const d = decimals === void 0 || decimals === null ? void 0 : Number(decimals);
|
|
@@ -324,7 +560,11 @@ var formatHelpers = [
|
|
|
324
560
|
doc: {
|
|
325
561
|
signature: "date(value, format?, tz?, locale?)",
|
|
326
562
|
description: "Formats a date with date-fns tokens (e.g. 'dd.MM.yyyy', 'PPP'). Accepts ISO strings, epoch numbers and 'now'.",
|
|
327
|
-
|
|
563
|
+
examples: {
|
|
564
|
+
jinja2: "{{ invoice.date | date('dd.MM.yyyy') }}",
|
|
565
|
+
liquid: "{{ invoice.date | date: 'dd.MM.yyyy' }}",
|
|
566
|
+
handlebars: "{{date invoice.date 'dd.MM.yyyy'}}"
|
|
567
|
+
},
|
|
328
568
|
category: "format"
|
|
329
569
|
},
|
|
330
570
|
fn: (ctx, value, format, tz, locale) => {
|
|
@@ -341,7 +581,11 @@ var formatHelpers = [
|
|
|
341
581
|
doc: {
|
|
342
582
|
signature: "dateAdd(value, amount, unit)",
|
|
343
583
|
description: "Adds days, weeks, months or years to a date; returns an ISO string for further formatting.",
|
|
344
|
-
|
|
584
|
+
examples: {
|
|
585
|
+
jinja2: "{{ invoice.date | dateAdd(14, 'days') | date('PP') }}",
|
|
586
|
+
liquid: "{{ invoice.date | dateAdd: 14, 'days' | date: 'PP' }}",
|
|
587
|
+
handlebars: "{{date (dateAdd invoice.date 14 'days') 'PP'}}"
|
|
588
|
+
},
|
|
345
589
|
category: "format"
|
|
346
590
|
},
|
|
347
591
|
fn: (ctx, value, amount, unit) => {
|
|
@@ -358,7 +602,11 @@ var formatHelpers = [
|
|
|
358
602
|
doc: {
|
|
359
603
|
signature: "add(a, b)",
|
|
360
604
|
description: "a + b (Handlebars has no arithmetic of its own).",
|
|
361
|
-
|
|
605
|
+
examples: {
|
|
606
|
+
jinja2: "{{ line.qty | add(1) }}",
|
|
607
|
+
liquid: "{{ line.qty | add: 1 }}",
|
|
608
|
+
handlebars: "{{#each invoice.lines}}{{add @index 1}}{{/each}}"
|
|
609
|
+
},
|
|
362
610
|
category: "format"
|
|
363
611
|
},
|
|
364
612
|
fn: (_ctx, a, b) => toNumber(a) + toNumber(b)
|
|
@@ -369,7 +617,11 @@ var formatHelpers = [
|
|
|
369
617
|
doc: {
|
|
370
618
|
signature: "subtract(a, b)",
|
|
371
619
|
description: "a minus b.",
|
|
372
|
-
|
|
620
|
+
examples: {
|
|
621
|
+
jinja2: "{{ total | subtract(discount) }}",
|
|
622
|
+
liquid: "{{ total | subtract: discount }}",
|
|
623
|
+
handlebars: "{{subtract total discount}}"
|
|
624
|
+
},
|
|
373
625
|
category: "format"
|
|
374
626
|
},
|
|
375
627
|
fn: (_ctx, a, b) => toNumber(a) - toNumber(b)
|
|
@@ -380,7 +632,11 @@ var formatHelpers = [
|
|
|
380
632
|
doc: {
|
|
381
633
|
signature: "multiply(a, b)",
|
|
382
634
|
description: "a times b.",
|
|
383
|
-
|
|
635
|
+
examples: {
|
|
636
|
+
jinja2: "{{ line.qty | multiply(line.price) | money }}",
|
|
637
|
+
liquid: "{{ line.qty | multiply: line.price | money }}",
|
|
638
|
+
handlebars: "{{money (multiply line.qty line.price)}}"
|
|
639
|
+
},
|
|
384
640
|
category: "format"
|
|
385
641
|
},
|
|
386
642
|
fn: (_ctx, a, b) => toNumber(a) * toNumber(b)
|
|
@@ -391,7 +647,11 @@ var formatHelpers = [
|
|
|
391
647
|
doc: {
|
|
392
648
|
signature: "divide(a, b)",
|
|
393
649
|
description: "a divided by b; empty when b is 0.",
|
|
394
|
-
|
|
650
|
+
examples: {
|
|
651
|
+
jinja2: "{{ total | divide(count) }}",
|
|
652
|
+
liquid: "{{ total | divide: count }}",
|
|
653
|
+
handlebars: "{{divide total count}}"
|
|
654
|
+
},
|
|
395
655
|
category: "format"
|
|
396
656
|
},
|
|
397
657
|
fn: (_ctx, a, b) => toNumber(b) === 0 ? "" : toNumber(a) / toNumber(b)
|
|
@@ -401,7 +661,11 @@ var formatHelpers = [
|
|
|
401
661
|
doc: {
|
|
402
662
|
signature: "round(value, decimals = 0, method = 'common')",
|
|
403
663
|
description: "Rounds to the given decimals, as Jinja2's round does: 'common' rounds half away from zero, 'floor' always down and 'ceil' always up.",
|
|
404
|
-
|
|
664
|
+
examples: {
|
|
665
|
+
jinja2: "{{ 2.345 | round(2) }} gives 2.35, {{ 2.349 | round(2, 'floor') }} gives 2.34",
|
|
666
|
+
liquid: "{{ 2.345 | round: 2 }} gives 2.35, {{ 2.349 | round: 2, 'floor' }} gives 2.34",
|
|
667
|
+
handlebars: "{{round 2.345 2}} gives 2.35, {{round 2.349 2 'floor'}} gives 2.34"
|
|
668
|
+
},
|
|
405
669
|
category: "format"
|
|
406
670
|
},
|
|
407
671
|
// `round(2, method='floor')` (Jinja2) and `round: 2, method: 'floor'` (Liquid) arrive as a
|
|
@@ -427,7 +691,11 @@ var formatHelpers = [
|
|
|
427
691
|
doc: {
|
|
428
692
|
signature: "upper(value)",
|
|
429
693
|
description: "Upper-cases a string.",
|
|
430
|
-
|
|
694
|
+
examples: {
|
|
695
|
+
jinja2: "{{ name | upper }}",
|
|
696
|
+
liquid: "{{ name | upper }}",
|
|
697
|
+
handlebars: "{{upper name}}"
|
|
698
|
+
},
|
|
431
699
|
category: "text"
|
|
432
700
|
},
|
|
433
701
|
fn: (_ctx, value) => str(value).toUpperCase()
|
|
@@ -438,7 +706,11 @@ var formatHelpers = [
|
|
|
438
706
|
doc: {
|
|
439
707
|
signature: "lower(value)",
|
|
440
708
|
description: "Lower-cases a string.",
|
|
441
|
-
|
|
709
|
+
examples: {
|
|
710
|
+
jinja2: "{{ name | lower }}",
|
|
711
|
+
liquid: "{{ name | lower }}",
|
|
712
|
+
handlebars: "{{lower name}}"
|
|
713
|
+
},
|
|
442
714
|
category: "text"
|
|
443
715
|
},
|
|
444
716
|
fn: (_ctx, value) => str(value).toLowerCase()
|
|
@@ -449,7 +721,11 @@ var formatHelpers = [
|
|
|
449
721
|
doc: {
|
|
450
722
|
signature: "title(value)",
|
|
451
723
|
description: "Capitalises every word.",
|
|
452
|
-
|
|
724
|
+
examples: {
|
|
725
|
+
jinja2: "{{ 'hello world' | title }} \u2192 Hello World",
|
|
726
|
+
liquid: "{{ 'hello world' | title }} \u2192 Hello World",
|
|
727
|
+
handlebars: "{{title 'hello world'}} \u2192 Hello World"
|
|
728
|
+
},
|
|
453
729
|
category: "text"
|
|
454
730
|
},
|
|
455
731
|
fn: (_ctx, value) => str(value).replace(
|
|
@@ -462,7 +738,11 @@ var formatHelpers = [
|
|
|
462
738
|
doc: {
|
|
463
739
|
signature: "truncate(value, length = 255, suffix = '\u2026')",
|
|
464
740
|
description: "Cuts a string to `length` characters and appends the suffix when cut.",
|
|
465
|
-
|
|
741
|
+
examples: {
|
|
742
|
+
jinja2: "{{ description | truncate(40) }}",
|
|
743
|
+
liquid: "{{ description | truncate: 40 }}",
|
|
744
|
+
handlebars: "{{truncate description 40}}"
|
|
745
|
+
},
|
|
466
746
|
category: "text"
|
|
467
747
|
},
|
|
468
748
|
fn: (_ctx, value, length, suffix) => {
|
|
@@ -477,7 +757,11 @@ var formatHelpers = [
|
|
|
477
757
|
doc: {
|
|
478
758
|
signature: "replace(value, search, replacement)",
|
|
479
759
|
description: "Replaces every occurrence of `search`.",
|
|
480
|
-
|
|
760
|
+
examples: {
|
|
761
|
+
jinja2: "{{ iban | replace(' ', '') }}",
|
|
762
|
+
liquid: "{{ iban | replace: ' ', '' }}",
|
|
763
|
+
handlebars: "{{replace iban ' ' ''}}"
|
|
764
|
+
},
|
|
481
765
|
category: "text"
|
|
482
766
|
},
|
|
483
767
|
fn: (_ctx, value, search, replacement) => str(value).split(str(search)).join(str(replacement))
|
|
@@ -487,7 +771,11 @@ var formatHelpers = [
|
|
|
487
771
|
doc: {
|
|
488
772
|
signature: "split(value, separator = ',')",
|
|
489
773
|
description: "Splits a string into a list.",
|
|
490
|
-
|
|
774
|
+
examples: {
|
|
775
|
+
jinja2: "{% for tag in keywords | split(',') %}{{ tag }}{% endfor %}",
|
|
776
|
+
liquid: "{% assign tags = keywords | split: ',' %}{% for tag in tags %}{{ tag }}{% endfor %}",
|
|
777
|
+
handlebars: "{{#each (split keywords ',')}}{{this}}{{/each}}"
|
|
778
|
+
},
|
|
491
779
|
category: "text"
|
|
492
780
|
},
|
|
493
781
|
fn: (_ctx, value, separator) => str(value).split(str(opt(separator, ",")))
|
|
@@ -497,7 +785,11 @@ var formatHelpers = [
|
|
|
497
785
|
doc: {
|
|
498
786
|
signature: "join(list, separator = ', ')",
|
|
499
787
|
description: "Joins a list into a string.",
|
|
500
|
-
|
|
788
|
+
examples: {
|
|
789
|
+
jinja2: "{{ tags | join(' \xB7 ') }}",
|
|
790
|
+
liquid: "{{ tags | join: ' \xB7 ' }}",
|
|
791
|
+
handlebars: "{{join tags ' \xB7 '}}"
|
|
792
|
+
},
|
|
501
793
|
category: "collection"
|
|
502
794
|
},
|
|
503
795
|
fn: (_ctx, value, separator) => Array.isArray(value) ? value.map(str).join(str(opt(separator, ", "))) : str(value)
|
|
@@ -508,7 +800,11 @@ var formatHelpers = [
|
|
|
508
800
|
doc: {
|
|
509
801
|
signature: "default(value, fallback)",
|
|
510
802
|
description: "Returns the fallback when the value is empty, null or undefined.",
|
|
511
|
-
|
|
803
|
+
examples: {
|
|
804
|
+
jinja2: "{{ customer.vat | default('\u2014') }}",
|
|
805
|
+
liquid: "{{ customer.vat | default: '\u2014' }}",
|
|
806
|
+
handlebars: "{{default customer.vat '\u2014'}}"
|
|
807
|
+
},
|
|
512
808
|
category: "text"
|
|
513
809
|
},
|
|
514
810
|
fn: (_ctx, value, fallback) => value === void 0 || value === null || value === "" ? fallback : value
|
|
@@ -519,7 +815,11 @@ var formatHelpers = [
|
|
|
519
815
|
doc: {
|
|
520
816
|
signature: "nl2br(value)",
|
|
521
817
|
description: "Escapes the text and turns line breaks into <br>.",
|
|
522
|
-
|
|
818
|
+
examples: {
|
|
819
|
+
jinja2: "{{ address | nl2br }}",
|
|
820
|
+
liquid: "{{ address | nl2br }}",
|
|
821
|
+
handlebars: "{{nl2br address}}"
|
|
822
|
+
},
|
|
523
823
|
category: "text"
|
|
524
824
|
},
|
|
525
825
|
// office templates turn line breaks into breaks themselves, and escape the text once
|
|
@@ -531,7 +831,11 @@ var formatHelpers = [
|
|
|
531
831
|
doc: {
|
|
532
832
|
signature: "markdown(value)",
|
|
533
833
|
description: "Renders Markdown to HTML (no sanitiser: template data is trusted by the author).",
|
|
534
|
-
|
|
834
|
+
examples: {
|
|
835
|
+
jinja2: "{{ notes | markdown }}",
|
|
836
|
+
liquid: "{{ notes | markdown }}",
|
|
837
|
+
handlebars: "{{markdown notes}}"
|
|
838
|
+
},
|
|
535
839
|
category: "text"
|
|
536
840
|
},
|
|
537
841
|
fn: (ctx, value) => {
|
|
@@ -550,7 +854,11 @@ var formatHelpers = [
|
|
|
550
854
|
doc: {
|
|
551
855
|
signature: "safe(value)",
|
|
552
856
|
description: "Marks a string as HTML so the engine does not escape it.",
|
|
553
|
-
|
|
857
|
+
examples: {
|
|
858
|
+
jinja2: "{{ html_snippet | safe }}",
|
|
859
|
+
liquid: "{{ html_snippet | safe }}",
|
|
860
|
+
handlebars: "{{safe html_snippet}}"
|
|
861
|
+
},
|
|
554
862
|
category: "text"
|
|
555
863
|
},
|
|
556
864
|
fn: (_ctx, value) => str(value)
|
|
@@ -561,7 +869,11 @@ var formatHelpers = [
|
|
|
561
869
|
doc: {
|
|
562
870
|
signature: "json(value, indent = 2)",
|
|
563
871
|
description: "Serialises a value as JSON; handy while building a template.",
|
|
564
|
-
|
|
872
|
+
examples: {
|
|
873
|
+
jinja2: "<pre>{{ invoice | json }}</pre>",
|
|
874
|
+
liquid: "<pre>{{ invoice | json }}</pre>",
|
|
875
|
+
handlebars: "<pre>{{json invoice}}</pre>"
|
|
876
|
+
},
|
|
565
877
|
category: "debug"
|
|
566
878
|
},
|
|
567
879
|
fn: (_ctx, value, indent) => JSON.stringify(value, null, opt(Number(indent), 2))
|
|
@@ -572,10 +884,15 @@ var formatHelpers = [
|
|
|
572
884
|
doc: {
|
|
573
885
|
signature: "numToWords(value, locale?)",
|
|
574
886
|
description: "Spells a number out in words (de, en, fr, es, it, nl).",
|
|
575
|
-
|
|
887
|
+
examples: {
|
|
888
|
+
jinja2: "{{ total | numToWords('de') }} \u2192 zweihundertdreiundvierzig",
|
|
889
|
+
liquid: "{{ total | numToWords: 'de' }} \u2192 zweihundertdreiundvierzig",
|
|
890
|
+
handlebars: "{{numToWords total 'de'}} \u2192 zweihundertdreiundvierzig"
|
|
891
|
+
},
|
|
576
892
|
category: "format"
|
|
577
893
|
},
|
|
578
894
|
fn: (ctx, value, locale) => {
|
|
895
|
+
requireFinite("numToWords", value);
|
|
579
896
|
const n = toNumber(value);
|
|
580
897
|
if (Number.isNaN(n)) return str(value);
|
|
581
898
|
const lang = str(opt(locale, ctx.locale)).split("-")[0] ?? "en";
|
|
@@ -588,7 +905,11 @@ var formatHelpers = [
|
|
|
588
905
|
doc: {
|
|
589
906
|
signature: "t(key, params?)",
|
|
590
907
|
description: "Looks a key up in the template's i18n dictionary for the request locale; `{name}` placeholders come from params.",
|
|
591
|
-
|
|
908
|
+
examples: {
|
|
909
|
+
jinja2: "{{ t('invoice.title', { number: invoice.number }) }}",
|
|
910
|
+
liquid: "{{ 'invoice.title' | t: number: invoice.number }}",
|
|
911
|
+
handlebars: "{{t 'invoice.title' number=invoice.number}}"
|
|
912
|
+
},
|
|
592
913
|
category: "text"
|
|
593
914
|
},
|
|
594
915
|
fn: (ctx, key, params) => {
|
|
@@ -604,7 +925,11 @@ var formatHelpers = [
|
|
|
604
925
|
doc: {
|
|
605
926
|
signature: "asset(name)",
|
|
606
927
|
description: "URL of a file in the workspace library (logo, background) on the CDN. Upload it on the Files page or with `POST /files` and use the name it has there; relative image paths resolve against the same library.",
|
|
607
|
-
|
|
928
|
+
examples: {
|
|
929
|
+
jinja2: `<img src="{{ asset('logo.png') }}">`,
|
|
930
|
+
liquid: `<img src="{{ 'logo.png' | asset }}">`,
|
|
931
|
+
handlebars: `<img src="{{asset 'logo.png'}}">`
|
|
932
|
+
},
|
|
608
933
|
category: "document"
|
|
609
934
|
},
|
|
610
935
|
fn: (ctx, name) => `${(ctx.assetBaseUrl ?? "").replace(/\/$/, "")}/${str(name).replace(/^\//, "")}`
|
|
@@ -616,128 +941,17 @@ var formatHelpers = [
|
|
|
616
941
|
doc: {
|
|
617
942
|
signature: "pageBreak()",
|
|
618
943
|
description: "Starts a new page in PDF output.",
|
|
619
|
-
|
|
944
|
+
examples: {
|
|
945
|
+
jinja2: "{{ pageBreak() }}",
|
|
946
|
+
liquid: "{{ '' | pageBreak }}",
|
|
947
|
+
handlebars: "{{pageBreak}}"
|
|
948
|
+
},
|
|
620
949
|
category: "document"
|
|
621
950
|
},
|
|
622
951
|
fn: (ctx) => ctx.drawing ? ctx.drawing({ kind: "page-break" }) : '<div class="page-break" style="break-after:page"></div>'
|
|
623
952
|
}
|
|
624
953
|
];
|
|
625
954
|
|
|
626
|
-
// packages/engine/src/lib/helpers/codes.ts
|
|
627
|
-
var svgDataUri = (svg) => `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;
|
|
628
|
-
var barcodeTypes = {
|
|
629
|
-
code128: "code128",
|
|
630
|
-
ean13: "ean13",
|
|
631
|
-
ean8: "ean8",
|
|
632
|
-
upc: "upca",
|
|
633
|
-
upca: "upca",
|
|
634
|
-
itf14: "itf14",
|
|
635
|
-
code39: "code39",
|
|
636
|
-
qrcode: "qrcode",
|
|
637
|
-
datamatrix: "datamatrix",
|
|
638
|
-
pdf417: "pdf417"
|
|
639
|
-
};
|
|
640
|
-
function qrSvg(value, options = {}) {
|
|
641
|
-
const qr = (0, import_qrcode.create)(value, { errorCorrectionLevel: options.ecc ?? "M" });
|
|
642
|
-
const svg = (0, import_svg_tag.render)(qr, {
|
|
643
|
-
width: options.size ?? 160,
|
|
644
|
-
margin: options.margin ?? 1,
|
|
645
|
-
color: {
|
|
646
|
-
dark: (options.color ?? "#000000").replace(/^(#?)/, "#").slice(0, 9),
|
|
647
|
-
light: (options.background ?? "#ffffff").replace(/^(#?)/, "#")
|
|
648
|
-
}
|
|
649
|
-
});
|
|
650
|
-
return svg;
|
|
651
|
-
}
|
|
652
|
-
function epcPayload(input) {
|
|
653
|
-
const amount = input.amount === void 0 || input.amount === "" ? "" : `EUR${toNumber(input.amount).toFixed(2)}`;
|
|
654
|
-
return [
|
|
655
|
-
"BCD",
|
|
656
|
-
"002",
|
|
657
|
-
"1",
|
|
658
|
-
"SCT",
|
|
659
|
-
(input.bic ?? "").replace(/\s/g, ""),
|
|
660
|
-
String(input.name ?? "").slice(0, 70),
|
|
661
|
-
String(input.iban ?? "").replace(/\s/g, ""),
|
|
662
|
-
amount,
|
|
663
|
-
(input.purpose ?? "").slice(0, 4),
|
|
664
|
-
(input.reference ?? "").slice(0, 35),
|
|
665
|
-
input.reference ? "" : (input.text ?? "").slice(0, 140),
|
|
666
|
-
""
|
|
667
|
-
].join("\n");
|
|
668
|
-
}
|
|
669
|
-
var codeHelpers = [
|
|
670
|
-
{
|
|
671
|
-
name: "qrcode",
|
|
672
|
-
aliases: ["qr"],
|
|
673
|
-
doc: {
|
|
674
|
-
signature: "qrcode(value, { size = 160, ecc = 'M', margin = 1, color, background })",
|
|
675
|
-
description: "QR code as an SVG data URI for an <img> src.",
|
|
676
|
-
example: '<img src="{{ qrcode(order.url, { size: 120 }) }}">',
|
|
677
|
-
category: "code"
|
|
678
|
-
},
|
|
679
|
-
fn: (ctx, value, options) => {
|
|
680
|
-
const o = options ?? {};
|
|
681
|
-
const svg = qrSvg(String(value ?? ""), o);
|
|
682
|
-
if (ctx.drawing) return ctx.drawing({ kind: "svg", svg, width: o.size, height: o.size, alt: "QR code" });
|
|
683
|
-
return svgDataUri(svg);
|
|
684
|
-
}
|
|
685
|
-
},
|
|
686
|
-
{
|
|
687
|
-
name: "barcode",
|
|
688
|
-
doc: {
|
|
689
|
-
signature: "barcode(value, { type = 'code128', height = 12, width, text = true, scale = 2 })",
|
|
690
|
-
description: "Barcode (code128, ean13, ean8, upc, itf14, code39, datamatrix, pdf417) as an SVG data URI.",
|
|
691
|
-
example: `<img src="{{ barcode(item.sku, { type: 'code128' }) }}">`,
|
|
692
|
-
category: "code"
|
|
693
|
-
},
|
|
694
|
-
fn: (ctx, value, options) => {
|
|
695
|
-
const o = options ?? {};
|
|
696
|
-
const bcid = barcodeTypes[String(o.type ?? "code128").toLowerCase()] ?? "code128";
|
|
697
|
-
const svg = (0, import_bwip_js.toSVG)({
|
|
698
|
-
bcid,
|
|
699
|
-
text: String(value ?? ""),
|
|
700
|
-
height: o.height ?? 12,
|
|
701
|
-
...o.width === void 0 ? {} : { width: o.width },
|
|
702
|
-
scale: o.scale ?? 2,
|
|
703
|
-
includetext: o.text !== false,
|
|
704
|
-
textxalign: "center"
|
|
705
|
-
});
|
|
706
|
-
if (ctx.drawing) return ctx.drawing({ kind: "svg", svg, alt: `Barcode ${String(value ?? "")}` });
|
|
707
|
-
return svgDataUri(svg);
|
|
708
|
-
}
|
|
709
|
-
},
|
|
710
|
-
{
|
|
711
|
-
name: "epcQr",
|
|
712
|
-
aliases: ["epc_qr", "girocode"],
|
|
713
|
-
doc: {
|
|
714
|
-
signature: "epcQr({ name, iban, bic?, amount?, reference?, text? }, { size })",
|
|
715
|
-
description: "SEPA credit transfer QR code (EPC069-12 / GiroCode) that banking apps scan to prefill a payment.",
|
|
716
|
-
example: '<img src="{{ epcQr({ name: company.name, iban: company.iban, amount: invoice.total, reference: invoice.number }) }}">',
|
|
717
|
-
category: "code"
|
|
718
|
-
},
|
|
719
|
-
// Payment fields and QR options may arrive together: Jinja2 passes two objects, Handlebars one
|
|
720
|
-
// hash (`{{epcQr name=… iban=… size=200}}`), and Liquid, which cannot build an object literal,
|
|
721
|
-
// keyword arguments (`{{ company.iban | epcQr: name: company.name, amount: invoice.total }}`,
|
|
722
|
-
// where a string input is the IBAN). They are merged and split by field name.
|
|
723
|
-
fn: (ctx, input, options) => {
|
|
724
|
-
const fields = {
|
|
725
|
-
...input && typeof input === "object" ? input : typeof input === "string" && input ? { iban: input } : {},
|
|
726
|
-
...options ?? {}
|
|
727
|
-
};
|
|
728
|
-
const payment = {};
|
|
729
|
-
const qr = {};
|
|
730
|
-
for (const [key, value] of Object.entries(fields))
|
|
731
|
-
(EPC_FIELDS.has(key) ? payment : qr)[key] = value;
|
|
732
|
-
const o = { ecc: "M", ...qr };
|
|
733
|
-
const svg = qrSvg(epcPayload(payment), o);
|
|
734
|
-
if (ctx.drawing) return ctx.drawing({ kind: "svg", svg, width: o.size, height: o.size, alt: "GiroCode" });
|
|
735
|
-
return svgDataUri(svg);
|
|
736
|
-
}
|
|
737
|
-
}
|
|
738
|
-
];
|
|
739
|
-
var EPC_FIELDS = /* @__PURE__ */ new Set(["name", "iban", "bic", "amount", "reference", "text", "purpose"]);
|
|
740
|
-
|
|
741
955
|
// packages/engine/src/lib/helpers/collections.ts
|
|
742
956
|
var list = (v) => Array.isArray(v) ? v : v === void 0 || v === null ? [] : [v];
|
|
743
957
|
var get = (item, path) => String(path ?? "").split(".").filter(Boolean).reduce(
|
|
@@ -751,7 +965,11 @@ var collectionHelpers = [
|
|
|
751
965
|
doc: {
|
|
752
966
|
signature: "sum(list, path?)",
|
|
753
967
|
description: "Sum of the items, or of `path` inside each item.",
|
|
754
|
-
|
|
968
|
+
examples: {
|
|
969
|
+
jinja2: "{{ invoice.lines | sum('total') | money }}",
|
|
970
|
+
liquid: "{{ invoice.lines | sum: 'total' | money }}",
|
|
971
|
+
handlebars: "{{money (sum invoice.lines 'total')}}"
|
|
972
|
+
},
|
|
755
973
|
category: "collection"
|
|
756
974
|
},
|
|
757
975
|
fn: (_ctx, value, path) => numbers(value, path).reduce((a, b) => a + b, 0)
|
|
@@ -761,7 +979,11 @@ var collectionHelpers = [
|
|
|
761
979
|
doc: {
|
|
762
980
|
signature: "avg(list, path?)",
|
|
763
981
|
description: "Average of the items or of `path` inside each item.",
|
|
764
|
-
|
|
982
|
+
examples: {
|
|
983
|
+
jinja2: "{{ scores | avg | number(1) }}",
|
|
984
|
+
liquid: "{{ scores | avg | number: 1 }}",
|
|
985
|
+
handlebars: "{{number (avg scores) 1}}"
|
|
986
|
+
},
|
|
765
987
|
category: "collection"
|
|
766
988
|
},
|
|
767
989
|
fn: (_ctx, value, path) => {
|
|
@@ -774,7 +996,11 @@ var collectionHelpers = [
|
|
|
774
996
|
doc: {
|
|
775
997
|
signature: "min(list, path?)",
|
|
776
998
|
description: "Smallest number.",
|
|
777
|
-
|
|
999
|
+
examples: {
|
|
1000
|
+
jinja2: "{{ prices | min }}",
|
|
1001
|
+
liquid: "{{ prices | min }}",
|
|
1002
|
+
handlebars: "{{min prices}}"
|
|
1003
|
+
},
|
|
778
1004
|
category: "collection"
|
|
779
1005
|
},
|
|
780
1006
|
fn: (_ctx, value, path) => Math.min(...numbers(value, path))
|
|
@@ -784,7 +1010,11 @@ var collectionHelpers = [
|
|
|
784
1010
|
doc: {
|
|
785
1011
|
signature: "max(list, path?)",
|
|
786
1012
|
description: "Largest number.",
|
|
787
|
-
|
|
1013
|
+
examples: {
|
|
1014
|
+
jinja2: "{{ prices | max }}",
|
|
1015
|
+
liquid: "{{ prices | max }}",
|
|
1016
|
+
handlebars: "{{max prices}}"
|
|
1017
|
+
},
|
|
788
1018
|
category: "collection"
|
|
789
1019
|
},
|
|
790
1020
|
fn: (_ctx, value, path) => Math.max(...numbers(value, path))
|
|
@@ -795,7 +1025,11 @@ var collectionHelpers = [
|
|
|
795
1025
|
doc: {
|
|
796
1026
|
signature: "groupBy(list, path)",
|
|
797
1027
|
description: "Groups items by a field; returns a list of `{ key, items }`.",
|
|
798
|
-
|
|
1028
|
+
examples: {
|
|
1029
|
+
jinja2: "{% for group in lines | groupBy('category') %}{{ group.key }}{% endfor %}",
|
|
1030
|
+
liquid: "{% assign groups = lines | groupBy: 'category' %}{% for group in groups %}{{ group.key }}{% endfor %}",
|
|
1031
|
+
handlebars: "{{#each (groupBy lines 'category')}}{{key}}{{/each}}"
|
|
1032
|
+
},
|
|
799
1033
|
category: "collection"
|
|
800
1034
|
},
|
|
801
1035
|
fn: (_ctx, value, path) => {
|
|
@@ -813,7 +1047,11 @@ var collectionHelpers = [
|
|
|
813
1047
|
doc: {
|
|
814
1048
|
signature: "sortBy(list, path?, direction = 'asc')",
|
|
815
1049
|
description: "Sorts items by a field (numbers numerically, strings by locale).",
|
|
816
|
-
|
|
1050
|
+
examples: {
|
|
1051
|
+
jinja2: "{% for l in lines | sortBy('position') %}{{ l.sku }}{% endfor %}",
|
|
1052
|
+
liquid: "{% assign sorted = lines | sortBy: 'position' %}{% for l in sorted %}{{ l.sku }}{% endfor %}",
|
|
1053
|
+
handlebars: "{{#each (sortBy lines 'position')}}{{sku}}{{/each}}"
|
|
1054
|
+
},
|
|
817
1055
|
category: "collection"
|
|
818
1056
|
},
|
|
819
1057
|
fn: (ctx, value, path, direction) => {
|
|
@@ -834,7 +1072,11 @@ var collectionHelpers = [
|
|
|
834
1072
|
doc: {
|
|
835
1073
|
signature: "where(list, path, value?)",
|
|
836
1074
|
description: "Keeps items whose field equals `value` (or is truthy when omitted).",
|
|
837
|
-
|
|
1075
|
+
examples: {
|
|
1076
|
+
jinja2: "{% for l in lines | where('taxable', true) %}{{ l.sku }}{% endfor %}",
|
|
1077
|
+
liquid: "{% assign taxable = lines | where: 'taxable', true %}{% for l in taxable %}{{ l.sku }}{% endfor %}",
|
|
1078
|
+
handlebars: "{{#each (where lines 'taxable' true)}}{{sku}}{{/each}}"
|
|
1079
|
+
},
|
|
838
1080
|
category: "collection"
|
|
839
1081
|
},
|
|
840
1082
|
fn: (_ctx, value, path, expected) => list(value).filter((item) => {
|
|
@@ -848,7 +1090,11 @@ var collectionHelpers = [
|
|
|
848
1090
|
doc: {
|
|
849
1091
|
signature: "pluck(list, path)",
|
|
850
1092
|
description: "Extracts one field from every item.",
|
|
851
|
-
|
|
1093
|
+
examples: {
|
|
1094
|
+
jinja2: "{{ lines | pluck('sku') | join(', ') }}",
|
|
1095
|
+
liquid: "{{ lines | pluck: 'sku' | join: ', ' }}",
|
|
1096
|
+
handlebars: "{{join (pluck lines 'sku') ', '}}"
|
|
1097
|
+
},
|
|
852
1098
|
category: "collection"
|
|
853
1099
|
},
|
|
854
1100
|
fn: (_ctx, value, path) => list(value).map((item) => get(item, path))
|
|
@@ -859,7 +1105,11 @@ var collectionHelpers = [
|
|
|
859
1105
|
doc: {
|
|
860
1106
|
signature: "chunk(list, size)",
|
|
861
1107
|
description: "Splits a list into lists of `size` items (rows of labels, columns).",
|
|
862
|
-
|
|
1108
|
+
examples: {
|
|
1109
|
+
jinja2: "{% for row in labels | chunk(3) %}{{ row | join(', ') }}{% endfor %}",
|
|
1110
|
+
liquid: "{% assign rows = labels | chunk: 3 %}{% for row in rows %}{{ row | join: ', ' }}{% endfor %}",
|
|
1111
|
+
handlebars: "{{#each (chunk labels 3)}}{{join this ', '}}{{/each}}"
|
|
1112
|
+
},
|
|
863
1113
|
category: "collection"
|
|
864
1114
|
},
|
|
865
1115
|
fn: (_ctx, value, size) => {
|
|
@@ -875,7 +1125,11 @@ var collectionHelpers = [
|
|
|
875
1125
|
doc: {
|
|
876
1126
|
signature: "range(start, end?, step = 1)",
|
|
877
1127
|
description: "List of numbers from start to end (exclusive).",
|
|
878
|
-
|
|
1128
|
+
examples: {
|
|
1129
|
+
jinja2: "{% for i in range(1, 4) %}{{ i }}{% endfor %}",
|
|
1130
|
+
liquid: "{% assign nums = 1 | range: 4 %}{% for i in nums %}{{ i }}{% endfor %}",
|
|
1131
|
+
handlebars: "{{#each (range 1 4)}}{{this}}{{/each}}"
|
|
1132
|
+
},
|
|
879
1133
|
category: "collection"
|
|
880
1134
|
},
|
|
881
1135
|
fn: (_ctx, start, end, step) => {
|
|
@@ -893,7 +1147,11 @@ var collectionHelpers = [
|
|
|
893
1147
|
doc: {
|
|
894
1148
|
signature: "first(list)",
|
|
895
1149
|
description: "First item.",
|
|
896
|
-
|
|
1150
|
+
examples: {
|
|
1151
|
+
jinja2: "{{ tags | first }}",
|
|
1152
|
+
liquid: "{{ tags | first }}",
|
|
1153
|
+
handlebars: "{{first tags}}"
|
|
1154
|
+
},
|
|
897
1155
|
category: "collection"
|
|
898
1156
|
},
|
|
899
1157
|
fn: (_ctx, value) => list(value)[0]
|
|
@@ -903,7 +1161,11 @@ var collectionHelpers = [
|
|
|
903
1161
|
doc: {
|
|
904
1162
|
signature: "last(list)",
|
|
905
1163
|
description: "Last item.",
|
|
906
|
-
|
|
1164
|
+
examples: {
|
|
1165
|
+
jinja2: "{{ tags | last }}",
|
|
1166
|
+
liquid: "{{ tags | last }}",
|
|
1167
|
+
handlebars: "{{last tags}}"
|
|
1168
|
+
},
|
|
907
1169
|
category: "collection"
|
|
908
1170
|
},
|
|
909
1171
|
fn: (_ctx, value) => list(value).at(-1)
|
|
@@ -914,7 +1176,11 @@ var collectionHelpers = [
|
|
|
914
1176
|
doc: {
|
|
915
1177
|
signature: "length(value)",
|
|
916
1178
|
description: "Number of items in a list, keys in an object or characters in a string.",
|
|
917
|
-
|
|
1179
|
+
examples: {
|
|
1180
|
+
jinja2: "{{ lines | length }}",
|
|
1181
|
+
liquid: "{{ lines | length }}",
|
|
1182
|
+
handlebars: "{{length lines}}"
|
|
1183
|
+
},
|
|
918
1184
|
category: "collection"
|
|
919
1185
|
},
|
|
920
1186
|
fn: (_ctx, value) => Array.isArray(value) || typeof value === "string" ? value.length : value && typeof value === "object" ? Object.keys(value).length : 0
|
|
@@ -990,7 +1256,11 @@ var documentHelpers = [
|
|
|
990
1256
|
doc: {
|
|
991
1257
|
signature: "chart({ type, labels, values, label, color } | { type, labels, series } | { type, data, options }, { width, height })",
|
|
992
1258
|
description: "Draws a Chart.js chart (bar, line, pie, doughnut, radar, \u2026) from plain data, `labels` with `values` or several `series` of `{ label, values, color }`, or from a full Chart.js `data` and `options`; animations are disabled so PDF and preview match.",
|
|
993
|
-
|
|
1259
|
+
examples: {
|
|
1260
|
+
jinja2: "{{ chart({ type: 'line', labels: sales.labels, values: sales.quarters, width: 480 }) }}",
|
|
1261
|
+
liquid: "{{ '' | chart: type: 'line', labels: sales.labels, values: sales.quarters, width: 480 }}",
|
|
1262
|
+
handlebars: "{{chart type='line' labels=sales.labels values=sales.quarters width=480}}"
|
|
1263
|
+
},
|
|
994
1264
|
category: "document"
|
|
995
1265
|
},
|
|
996
1266
|
fn: (ctx, spec, extra) => {
|
|
@@ -1004,7 +1274,11 @@ var documentHelpers = [
|
|
|
1004
1274
|
doc: {
|
|
1005
1275
|
signature: "image(url, { width, height, fit, alt })",
|
|
1006
1276
|
description: "Places an image with fixed size and crop mode (`cover`, `contain`, `fill`); the render-worker fetches it through its cache and blocks private hosts.",
|
|
1007
|
-
|
|
1277
|
+
examples: {
|
|
1278
|
+
jinja2: "{{ image(product.photo, { width: 120, height: 80, fit: 'cover' }) }}",
|
|
1279
|
+
liquid: "{{ product.photo | image: width: 120, height: 80, fit: 'cover' }}",
|
|
1280
|
+
handlebars: "{{image product.photo width=120 height=80 fit='cover'}}"
|
|
1281
|
+
},
|
|
1008
1282
|
category: "document"
|
|
1009
1283
|
},
|
|
1010
1284
|
fn: (ctx, url, options) => {
|
|
@@ -1030,7 +1304,12 @@ var comparison = (name, test, description) => ({
|
|
|
1030
1304
|
doc: {
|
|
1031
1305
|
signature: `${name}(a, b)`,
|
|
1032
1306
|
description,
|
|
1033
|
-
|
|
1307
|
+
examples: {
|
|
1308
|
+
jinja2: `{% if ${name}(invoice.total, 1000) %}\u2026{% endif %}`,
|
|
1309
|
+
// Liquid takes no filter in a condition, so the comparison is assigned first
|
|
1310
|
+
liquid: `{% assign hit = invoice.total | ${name}: 1000 %}{% if hit %}\u2026{% endif %}`,
|
|
1311
|
+
handlebars: `{{#if (${name} invoice.total 1000)}}\u2026{{/if}}`
|
|
1312
|
+
},
|
|
1034
1313
|
category: "logic"
|
|
1035
1314
|
},
|
|
1036
1315
|
fn: (_ctx, a, b) => test(a, b)
|
|
@@ -1047,7 +1326,12 @@ var logicHelpers = [
|
|
|
1047
1326
|
doc: {
|
|
1048
1327
|
signature: "and(a, b, \u2026)",
|
|
1049
1328
|
description: "True when every value is true; an empty list counts as false.",
|
|
1050
|
-
|
|
1329
|
+
examples: {
|
|
1330
|
+
jinja2: "{% if and(customer.vatId, eq(country, 'DE')) %}\u2026{% endif %}",
|
|
1331
|
+
// nesting one comparison in another needs its own assignment in Liquid
|
|
1332
|
+
liquid: "{% assign german = country | eq: 'DE' %}{% assign both = customer.vatId | and: german %}{% if both %}\u2026{% endif %}",
|
|
1333
|
+
handlebars: "{{#if (and customer.vatId (eq country 'DE'))}}\u2026{{/if}}"
|
|
1334
|
+
},
|
|
1051
1335
|
category: "logic"
|
|
1052
1336
|
},
|
|
1053
1337
|
fn: (_ctx, ...values) => values.length > 0 && values.every(truthy)
|
|
@@ -1057,7 +1341,11 @@ var logicHelpers = [
|
|
|
1057
1341
|
doc: {
|
|
1058
1342
|
signature: "or(a, b, \u2026)",
|
|
1059
1343
|
description: "True when any value is true.",
|
|
1060
|
-
|
|
1344
|
+
examples: {
|
|
1345
|
+
jinja2: "{% if or(invoice.paid, invoice.credited) %}\u2026{% endif %}",
|
|
1346
|
+
liquid: "{% assign any = invoice.paid | or: invoice.credited %}{% if any %}\u2026{% endif %}",
|
|
1347
|
+
handlebars: "{{#if (or invoice.paid invoice.credited)}}\u2026{{/if}}"
|
|
1348
|
+
},
|
|
1061
1349
|
category: "logic"
|
|
1062
1350
|
},
|
|
1063
1351
|
fn: (_ctx, ...values) => values.some(truthy)
|
|
@@ -1067,7 +1355,11 @@ var logicHelpers = [
|
|
|
1067
1355
|
doc: {
|
|
1068
1356
|
signature: "not(value)",
|
|
1069
1357
|
description: "The opposite truth value.",
|
|
1070
|
-
|
|
1358
|
+
examples: {
|
|
1359
|
+
jinja2: "{% if invoice.paid | not %}\u2026{% endif %}",
|
|
1360
|
+
liquid: "{% assign unpaid = invoice.paid | not %}{% if unpaid %}\u2026{% endif %}",
|
|
1361
|
+
handlebars: "{{#if (not invoice.paid)}}\u2026{{/if}}"
|
|
1362
|
+
},
|
|
1071
1363
|
category: "logic"
|
|
1072
1364
|
},
|
|
1073
1365
|
fn: (_ctx, value) => !truthy(value)
|
|
@@ -1140,7 +1432,9 @@ function helperDocs(registry = defaultHelpers()) {
|
|
|
1140
1432
|
return [...registry.definitions.values()].map((d) => ({
|
|
1141
1433
|
name: d.name,
|
|
1142
1434
|
aliases: d.aliases ?? [],
|
|
1143
|
-
...d.doc
|
|
1435
|
+
...d.doc,
|
|
1436
|
+
// derived rather than authored, so the old field cannot drift from `examples`
|
|
1437
|
+
example: d.doc.examples.jinja2
|
|
1144
1438
|
}));
|
|
1145
1439
|
}
|
|
1146
1440
|
|
|
@@ -3572,7 +3866,7 @@ ${loop(e, "line", "invoice.lines", ` <tr><td>${val(e, "line.description")}</t
|
|
|
3572
3866
|
invoice: { due_date: "2026-10-13" },
|
|
3573
3867
|
payment: {
|
|
3574
3868
|
name: "Fennlor Studio GmbH",
|
|
3575
|
-
iban: "
|
|
3869
|
+
iban: "DE36000000000000000000",
|
|
3576
3870
|
bic: "XXXXDEXXXXX",
|
|
3577
3871
|
amount: 3760.4,
|
|
3578
3872
|
reference: "2026-0042"
|
|
@@ -3759,7 +4053,7 @@ ${loop(e, "line", "invoice.lines", ` <tr><td>${val(e, "line.description")}</t
|
|
|
3759
4053
|
sampleData: {
|
|
3760
4054
|
payment: {
|
|
3761
4055
|
name: "Fennlor Studio GmbH",
|
|
3762
|
-
iban: "
|
|
4056
|
+
iban: "DE36000000000000000000",
|
|
3763
4057
|
bic: "XXXXDEXXXXX",
|
|
3764
4058
|
amount: 3760.4,
|
|
3765
4059
|
reference: "2026-0042"
|