@formfeed/engine 0.3.5 → 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/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
- example: "{{ line.total | money('EUR') }} \u2192 1.234,50 \u20AC",
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
- example: "{{ qty | number(2) }} \u2192 1.234,00",
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
- example: "{{ invoice.date | date('dd.MM.yyyy') }}",
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
- example: "{{ invoice.date | dateAdd(14, 'days') | date('PP') }}",
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
- example: "{{add @index 1}}",
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
- example: "{{subtract total discount}}",
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
- example: "{{money (multiply line.qty line.price)}}",
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
- example: "{{divide total count}}",
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
- example: "{{ 2.345 | round(2) }} gives 2.35, {{ 2.349 | round(2, 'floor') }} gives 2.34",
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
- example: "{{ name | upper }}",
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
- example: "{{ name | lower }}",
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
- example: "{{ 'hello world' | title }} \u2192 Hello World",
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
- example: "{{ description | truncate(40) }}",
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
- example: "{{ iban | replace(' ', '') }}",
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
- example: "{% for tag in tags | split(',') %}",
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
- example: "{{ tags | join(' \xB7 ') }}",
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
- example: "{{ customer.vat | default('\u2014') }}",
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
- example: "{{ address | nl2br }}",
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
- example: "{{ notes | markdown }}",
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
- example: "{{ html_snippet | safe }}",
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
- example: "<pre>{{ invoice | json }}</pre>",
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
- example: "{{ total | numToWords('de') }} \u2192 zweihundertdreiundvierzig",
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
- example: "{{ t('invoice.title', { number: invoice.number }) }}",
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
- example: `<img src="{{ asset('logo.png') }}">`,
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
- example: "{{ pageBreak() }}",
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
- example: "{{ invoice.lines | sum('total') | money }}",
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
- example: "{{ scores | avg | number(1) }}",
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
- example: "{{ prices | min }}",
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
- example: "{{ prices | max }}",
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
- example: "{% for group in lines | groupBy('category') %}{{ group.key }}{% endfor %}",
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
- example: "{% for l in lines | sortBy('position') %}",
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
- example: "{% for l in lines | where('taxable', true) %}",
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
- example: "{{ lines | pluck('sku') | join(', ') }}",
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
- example: "{% for row in labels | chunk(3) %}",
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
- example: "{% for i in range(1, 4) %}",
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
- example: "{{ lines | first }}",
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
- example: "{{ lines | last }}",
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
- example: "{{ lines | length }}",
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
- example: "{{ chart({ type: 'line', labels: ['Q1', 'Q2', 'Q3'], values: sales.quarters, width: 480 }) }}",
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
- example: "{{ image(product.photo, { width: 120, height: 80, fit: 'cover' }) }}",
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
- example: `{{#if (${name} invoice.total 1000)}}\u2026{{/if}}`,
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
- example: '{{#if (and customer.vatId (eq country "DE"))}}\u2026{{/if}}',
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
- example: "{{#if (or invoice.paid invoice.credited)}}\u2026{{/if}}",
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
- example: "{{#if (not invoice.paid)}}\u2026{{/if}}",
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: "DE00000000000000000000",
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: "DE00000000000000000000",
4056
+ iban: "DE36000000000000000000",
3763
4057
  bic: "XXXXDEXXXXX",
3764
4058
  amount: 3760.4,
3765
4059
  reference: "2026-0042"