@formfeed/engine 0.3.5 → 0.3.7

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.js CHANGED
@@ -77,8 +77,234 @@ function depthGuard(resolve2, includeDepth) {
77
77
 
78
78
  // packages/engine/src/lib/helpers/codes.ts
79
79
  import { toSVG } from "bwip-js";
80
+
81
+ // packages/engine/src/lib/values.ts
82
+ function toNumber(value) {
83
+ if (typeof value === "number") return value;
84
+ if (typeof value === "string") {
85
+ const n = Number(value.replace(/\s/g, "").replace(",", "."));
86
+ return Number.isFinite(n) ? n : NaN;
87
+ }
88
+ if (typeof value === "bigint") return Number(value);
89
+ return NaN;
90
+ }
91
+
92
+ // packages/engine/src/lib/codes/epc.ts
93
+ function epcPayload(input) {
94
+ const amount = input.amount === void 0 || input.amount === "" ? "" : `EUR${toNumber(input.amount).toFixed(2)}`;
95
+ return [
96
+ "BCD",
97
+ "002",
98
+ "1",
99
+ "SCT",
100
+ (input.bic ?? "").replace(/\s/g, ""),
101
+ String(input.name ?? "").slice(0, 70),
102
+ String(input.iban ?? "").replace(/\s/g, ""),
103
+ amount,
104
+ (input.purpose ?? "").slice(0, 4),
105
+ (input.reference ?? "").slice(0, 35),
106
+ input.reference ? "" : (input.text ?? "").slice(0, 140),
107
+ ""
108
+ ].join("\n");
109
+ }
110
+ var IBAN_LENGTHS = {
111
+ AD: 24,
112
+ AT: 20,
113
+ BE: 16,
114
+ BG: 22,
115
+ CH: 21,
116
+ CY: 28,
117
+ CZ: 24,
118
+ DE: 22,
119
+ DK: 18,
120
+ EE: 20,
121
+ ES: 24,
122
+ FI: 18,
123
+ FO: 18,
124
+ FR: 27,
125
+ GB: 22,
126
+ GI: 23,
127
+ GL: 18,
128
+ GR: 27,
129
+ HR: 21,
130
+ HU: 28,
131
+ IE: 22,
132
+ IS: 26,
133
+ IT: 27,
134
+ LI: 21,
135
+ LT: 20,
136
+ LU: 20,
137
+ LV: 21,
138
+ MC: 27,
139
+ MT: 31,
140
+ NL: 18,
141
+ NO: 15,
142
+ PL: 28,
143
+ PT: 25,
144
+ RO: 24,
145
+ SE: 24,
146
+ SI: 19,
147
+ SK: 24,
148
+ SM: 27,
149
+ VA: 22
150
+ };
151
+ var EPC_MAX_BYTES = 331;
152
+ var byteLength = (value) => new TextEncoder().encode(value).length;
153
+ var normaliseIban = (iban) => iban.replace(/\s/g, "").toUpperCase();
154
+ function ibanChecksum(iban) {
155
+ const rearranged = normaliseIban(iban).slice(4) + normaliseIban(iban).slice(0, 4);
156
+ const digits = [...rearranged].map((c) => c >= "A" && c <= "Z" ? String(c.charCodeAt(0) - 55) : c).join("");
157
+ let remainder = 0;
158
+ for (const digit of digits) remainder = (remainder * 10 + Number(digit)) % 97;
159
+ return remainder === 1;
160
+ }
161
+ function validateEpc(input) {
162
+ const problems = [];
163
+ const name = String(input.name ?? "").trim();
164
+ if (!name) problems.push({ field: "name", code: "required" });
165
+ else if (name.length > 70) problems.push({ field: "name", code: "too_long", max: 70, actual: name.length });
166
+ const iban = normaliseIban(String(input.iban ?? ""));
167
+ if (!iban) problems.push({ field: "iban", code: "required" });
168
+ else if (!/^[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}$/.test(iban)) problems.push({ field: "iban", code: "iban_characters" });
169
+ else {
170
+ const expected = IBAN_LENGTHS[iban.slice(0, 2)];
171
+ if (expected !== void 0 && iban.length !== expected)
172
+ problems.push({ field: "iban", code: "iban_country_length", max: expected, actual: iban.length });
173
+ else if (!ibanChecksum(iban)) problems.push({ field: "iban", code: "iban_checksum" });
174
+ }
175
+ const bic = String(input.bic ?? "").replace(/\s/g, "").toUpperCase();
176
+ if (bic && !/^[A-Z]{6}[A-Z0-9]{2}([A-Z0-9]{3})?$/.test(bic)) problems.push({ field: "bic", code: "bic_format" });
177
+ if (input.amount !== void 0 && input.amount !== "") {
178
+ const amount = toNumber(input.amount);
179
+ if (Number.isNaN(amount)) problems.push({ field: "amount", code: "not_a_number" });
180
+ else if (amount < 0.01 || amount > 99999999999e-2) problems.push({ field: "amount", code: "out_of_range" });
181
+ }
182
+ const purpose = String(input.purpose ?? "");
183
+ if (purpose.length > 4) problems.push({ field: "purpose", code: "too_long", max: 4, actual: purpose.length });
184
+ const reference = String(input.reference ?? "").trim();
185
+ const text = String(input.text ?? "").trim();
186
+ if (reference && text) problems.push({ field: "reference", code: "reference_or_text" });
187
+ if (reference.length > 35)
188
+ problems.push({ field: "reference", code: "too_long", max: 35, actual: reference.length });
189
+ if (text.length > 140) problems.push({ field: "text", code: "too_long", max: 140, actual: text.length });
190
+ const bytes = byteLength(epcPayload(input));
191
+ if (bytes > EPC_MAX_BYTES)
192
+ problems.push({ field: "payload", code: "too_long", max: EPC_MAX_BYTES, actual: bytes });
193
+ return problems;
194
+ }
195
+
196
+ // packages/engine/src/lib/codes/qr.ts
80
197
  import { create as createQr } from "qrcode";
81
198
  import { render as renderQrSvg } from "qrcode/lib/renderer/svg-tag.js";
199
+ function qrSvg(value, options = {}) {
200
+ const qr = createQr(value, { errorCorrectionLevel: options.ecc ?? "M" });
201
+ return renderQrSvg(qr, {
202
+ width: options.size ?? 160,
203
+ margin: options.margin ?? 1,
204
+ color: {
205
+ dark: (options.color ?? "#000000").replace(/^(#?)/, "#").slice(0, 9),
206
+ light: (options.background ?? "#ffffff").replace(/^(#?)/, "#")
207
+ }
208
+ });
209
+ }
210
+ var svgDataUri = (svg) => `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;
211
+
212
+ // packages/engine/src/lib/helpers/codes.ts
213
+ var barcodeTypes = {
214
+ code128: "code128",
215
+ ean13: "ean13",
216
+ ean8: "ean8",
217
+ upc: "upca",
218
+ upca: "upca",
219
+ itf14: "itf14",
220
+ code39: "code39",
221
+ qrcode: "qrcode",
222
+ datamatrix: "datamatrix",
223
+ pdf417: "pdf417"
224
+ };
225
+ var codeHelpers = [
226
+ {
227
+ name: "qrcode",
228
+ aliases: ["qr"],
229
+ doc: {
230
+ signature: "qrcode(value, { size = 160, ecc = 'M', margin = 1, color, background })",
231
+ description: "QR code as an SVG data URI for an <img> src.",
232
+ examples: {
233
+ jinja2: '<img src="{{ qrcode(order.url, { size: 120 }) }}">',
234
+ liquid: '<img src="{{ order.url | qrcode: size: 120 }}">',
235
+ handlebars: '<img src="{{qrcode order.url size=120}}">'
236
+ },
237
+ category: "code"
238
+ },
239
+ fn: (ctx, value, options) => {
240
+ const o = options ?? {};
241
+ const svg = qrSvg(String(value ?? ""), o);
242
+ if (ctx.drawing) return ctx.drawing({ kind: "svg", svg, width: o.size, height: o.size, alt: "QR code" });
243
+ return svgDataUri(svg);
244
+ }
245
+ },
246
+ {
247
+ name: "barcode",
248
+ doc: {
249
+ signature: "barcode(value, { type = 'code128', height = 12, width, text = true, scale = 2 })",
250
+ description: "Barcode (code128, ean13, ean8, upc, itf14, code39, datamatrix, pdf417) as an SVG data URI.",
251
+ examples: {
252
+ jinja2: `<img src="{{ barcode(item.sku, { type: 'code128' }) }}">`,
253
+ liquid: `<img src="{{ item.sku | barcode: type: 'code128' }}">`,
254
+ handlebars: `<img src="{{barcode item.sku type='code128'}}">`
255
+ },
256
+ category: "code"
257
+ },
258
+ fn: (ctx, value, options) => {
259
+ const o = options ?? {};
260
+ const bcid = barcodeTypes[String(o.type ?? "code128").toLowerCase()] ?? "code128";
261
+ const svg = toSVG({
262
+ bcid,
263
+ text: String(value ?? ""),
264
+ height: o.height ?? 12,
265
+ ...o.width === void 0 ? {} : { width: o.width },
266
+ scale: o.scale ?? 2,
267
+ includetext: o.text !== false,
268
+ textxalign: "center"
269
+ });
270
+ if (ctx.drawing) return ctx.drawing({ kind: "svg", svg, alt: `Barcode ${String(value ?? "")}` });
271
+ return svgDataUri(svg);
272
+ }
273
+ },
274
+ {
275
+ name: "epcQr",
276
+ aliases: ["epc_qr", "girocode"],
277
+ doc: {
278
+ signature: "epcQr({ name, iban, bic?, amount?, reference?, text? }, { size })",
279
+ description: "SEPA credit transfer QR code (EPC069-12 / GiroCode) that banking apps scan to prefill a payment.",
280
+ examples: {
281
+ jinja2: '<img src="{{ epcQr({ name: company.name, iban: company.iban, amount: invoice.total, reference: invoice.number }) }}">',
282
+ liquid: '<img src="{{ company.iban | epcQr: name: company.name, amount: invoice.total, reference: invoice.number }}">',
283
+ handlebars: '<img src="{{epcQr name=company.name iban=company.iban amount=invoice.total reference=invoice.number}}">'
284
+ },
285
+ category: "code"
286
+ },
287
+ // Payment fields and QR options may arrive together: Jinja2 passes two objects, Handlebars one
288
+ // hash (`{{epcQr name=… iban=… size=200}}`), and Liquid, which cannot build an object literal,
289
+ // keyword arguments (`{{ company.iban | epcQr: name: company.name, amount: invoice.total }}`,
290
+ // where a string input is the IBAN). They are merged and split by field name.
291
+ fn: (ctx, input, options) => {
292
+ const fields = {
293
+ ...input && typeof input === "object" ? input : typeof input === "string" && input ? { iban: input } : {},
294
+ ...options ?? {}
295
+ };
296
+ const payment = {};
297
+ const qr = {};
298
+ for (const [key, value] of Object.entries(fields))
299
+ (EPC_FIELDS.has(key) ? payment : qr)[key] = value;
300
+ const o = { ecc: "M", ...qr };
301
+ const svg = qrSvg(epcPayload(payment), o);
302
+ if (ctx.drawing) return ctx.drawing({ kind: "svg", svg, width: o.size, height: o.size, alt: "GiroCode" });
303
+ return svgDataUri(svg);
304
+ }
305
+ }
306
+ ];
307
+ var EPC_FIELDS = /* @__PURE__ */ new Set(["name", "iban", "bic", "amount", "reference", "text", "purpose"]);
82
308
 
83
309
  // packages/engine/src/lib/helpers/format.ts
84
310
  import { TZDate } from "@date-fns/tz";
@@ -112,15 +338,6 @@ var dateLocales = {
112
338
  function dateLocale(locale) {
113
339
  return dateLocales[locale] ?? dateLocales[locale.split("-")[0] ?? ""] ?? enGB;
114
340
  }
115
- function toNumber(value) {
116
- if (typeof value === "number") return value;
117
- if (typeof value === "string") {
118
- const n = Number(value.replace(/\s/g, "").replace(",", "."));
119
- return Number.isFinite(n) ? n : NaN;
120
- }
121
- if (typeof value === "bigint") return Number(value);
122
- return NaN;
123
- }
124
341
  function toDate(value, tz) {
125
342
  if (value === "now" || value === void 0 || value === null || value === "")
126
343
  return new TZDate(Date.now(), tz);
@@ -143,6 +360,10 @@ function escapeHtml(value) {
143
360
  var isNamedArgs = (v) => !!v && typeof v === "object" && !Array.isArray(v) && !(v instanceof Date);
144
361
  var str = (v) => v === void 0 || v === null ? "" : String(v);
145
362
  var opt = (v, fallback) => v === void 0 || v === null || v === "" ? fallback : v;
363
+ function requireFinite(helper, value) {
364
+ if (typeof value === "number" && !Number.isFinite(value))
365
+ throw new Error(`${helper}: got ${value}, the result of a calculation with a value that is missing or not a number`);
366
+ }
146
367
  var wordsByLang = {
147
368
  de: deWords,
148
369
  en: enWords,
@@ -158,11 +379,16 @@ var formatHelpers = [
158
379
  deprecatedAlias: { currency_format: "money" },
159
380
  doc: {
160
381
  signature: "money(value, currency?, locale?)",
161
- description: "Formats a number as currency with Intl.NumberFormat. Defaults come from the template settings.",
162
- example: "{{ line.total | money('EUR') }} \u2192 1.234,50 \u20AC",
382
+ 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.",
383
+ examples: {
384
+ jinja2: "{{ line.total | money('EUR') }} \u2192 1.234,50 \u20AC",
385
+ liquid: "{{ line.total | money: 'EUR' }} \u2192 1.234,50 \u20AC",
386
+ handlebars: "{{money line.total 'EUR'}} \u2192 1.234,50 \u20AC"
387
+ },
163
388
  category: "format"
164
389
  },
165
390
  fn: (ctx, value, currency, locale) => {
391
+ requireFinite("money", value);
166
392
  const n = toNumber(value);
167
393
  if (Number.isNaN(n)) return str(value);
168
394
  return new Intl.NumberFormat(opt(locale, ctx.locale), {
@@ -176,10 +402,15 @@ var formatHelpers = [
176
402
  doc: {
177
403
  signature: "number(value, decimals?, locale?)",
178
404
  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.",
179
- example: "{{ qty | number(2) }} \u2192 1.234,00",
405
+ examples: {
406
+ jinja2: "{{ qty | number(2) }} \u2192 1.234,00",
407
+ liquid: "{{ qty | number: 2 }} \u2192 1.234,00",
408
+ handlebars: "{{number qty 2}} \u2192 1.234,00"
409
+ },
180
410
  category: "format"
181
411
  },
182
412
  fn: (ctx, value, decimals, locale) => {
413
+ requireFinite("number", value);
183
414
  const n = toNumber(value);
184
415
  if (Number.isNaN(n)) return str(value);
185
416
  const d = decimals === void 0 || decimals === null ? void 0 : Number(decimals);
@@ -194,7 +425,11 @@ var formatHelpers = [
194
425
  doc: {
195
426
  signature: "date(value, format?, tz?, locale?)",
196
427
  description: "Formats a date with date-fns tokens (e.g. 'dd.MM.yyyy', 'PPP'). Accepts ISO strings, epoch numbers and 'now'.",
197
- example: "{{ invoice.date | date('dd.MM.yyyy') }}",
428
+ examples: {
429
+ jinja2: "{{ invoice.date | date('dd.MM.yyyy') }}",
430
+ liquid: "{{ invoice.date | date: 'dd.MM.yyyy' }}",
431
+ handlebars: "{{date invoice.date 'dd.MM.yyyy'}}"
432
+ },
198
433
  category: "format"
199
434
  },
200
435
  fn: (ctx, value, format, tz, locale) => {
@@ -211,7 +446,11 @@ var formatHelpers = [
211
446
  doc: {
212
447
  signature: "dateAdd(value, amount, unit)",
213
448
  description: "Adds days, weeks, months or years to a date; returns an ISO string for further formatting.",
214
- example: "{{ invoice.date | dateAdd(14, 'days') | date('PP') }}",
449
+ examples: {
450
+ jinja2: "{{ invoice.date | dateAdd(14, 'days') | date('PP') }}",
451
+ liquid: "{{ invoice.date | dateAdd: 14, 'days' | date: 'PP' }}",
452
+ handlebars: "{{date (dateAdd invoice.date 14 'days') 'PP'}}"
453
+ },
215
454
  category: "format"
216
455
  },
217
456
  fn: (ctx, value, amount, unit) => {
@@ -228,7 +467,11 @@ var formatHelpers = [
228
467
  doc: {
229
468
  signature: "add(a, b)",
230
469
  description: "a + b (Handlebars has no arithmetic of its own).",
231
- example: "{{add @index 1}}",
470
+ examples: {
471
+ jinja2: "{{ line.qty | add(1) }}",
472
+ liquid: "{{ line.qty | add: 1 }}",
473
+ handlebars: "{{#each invoice.lines}}{{add @index 1}}{{/each}}"
474
+ },
232
475
  category: "format"
233
476
  },
234
477
  fn: (_ctx, a, b) => toNumber(a) + toNumber(b)
@@ -239,7 +482,11 @@ var formatHelpers = [
239
482
  doc: {
240
483
  signature: "subtract(a, b)",
241
484
  description: "a minus b.",
242
- example: "{{subtract total discount}}",
485
+ examples: {
486
+ jinja2: "{{ total | subtract(discount) }}",
487
+ liquid: "{{ total | subtract: discount }}",
488
+ handlebars: "{{subtract total discount}}"
489
+ },
243
490
  category: "format"
244
491
  },
245
492
  fn: (_ctx, a, b) => toNumber(a) - toNumber(b)
@@ -250,7 +497,11 @@ var formatHelpers = [
250
497
  doc: {
251
498
  signature: "multiply(a, b)",
252
499
  description: "a times b.",
253
- example: "{{money (multiply line.qty line.price)}}",
500
+ examples: {
501
+ jinja2: "{{ line.qty | multiply(line.price) | money }}",
502
+ liquid: "{{ line.qty | multiply: line.price | money }}",
503
+ handlebars: "{{money (multiply line.qty line.price)}}"
504
+ },
254
505
  category: "format"
255
506
  },
256
507
  fn: (_ctx, a, b) => toNumber(a) * toNumber(b)
@@ -261,7 +512,11 @@ var formatHelpers = [
261
512
  doc: {
262
513
  signature: "divide(a, b)",
263
514
  description: "a divided by b; empty when b is 0.",
264
- example: "{{divide total count}}",
515
+ examples: {
516
+ jinja2: "{{ total | divide(count) }}",
517
+ liquid: "{{ total | divide: count }}",
518
+ handlebars: "{{divide total count}}"
519
+ },
265
520
  category: "format"
266
521
  },
267
522
  fn: (_ctx, a, b) => toNumber(b) === 0 ? "" : toNumber(a) / toNumber(b)
@@ -271,7 +526,11 @@ var formatHelpers = [
271
526
  doc: {
272
527
  signature: "round(value, decimals = 0, method = 'common')",
273
528
  description: "Rounds to the given decimals, as Jinja2's round does: 'common' rounds half away from zero, 'floor' always down and 'ceil' always up.",
274
- example: "{{ 2.345 | round(2) }} gives 2.35, {{ 2.349 | round(2, 'floor') }} gives 2.34",
529
+ examples: {
530
+ jinja2: "{{ 2.345 | round(2) }} gives 2.35, {{ 2.349 | round(2, 'floor') }} gives 2.34",
531
+ liquid: "{{ 2.345 | round: 2 }} gives 2.35, {{ 2.349 | round: 2, 'floor' }} gives 2.34",
532
+ handlebars: "{{round 2.345 2}} gives 2.35, {{round 2.349 2 'floor'}} gives 2.34"
533
+ },
275
534
  category: "format"
276
535
  },
277
536
  // `round(2, method='floor')` (Jinja2) and `round: 2, method: 'floor'` (Liquid) arrive as a
@@ -297,7 +556,11 @@ var formatHelpers = [
297
556
  doc: {
298
557
  signature: "upper(value)",
299
558
  description: "Upper-cases a string.",
300
- example: "{{ name | upper }}",
559
+ examples: {
560
+ jinja2: "{{ name | upper }}",
561
+ liquid: "{{ name | upper }}",
562
+ handlebars: "{{upper name}}"
563
+ },
301
564
  category: "text"
302
565
  },
303
566
  fn: (_ctx, value) => str(value).toUpperCase()
@@ -308,7 +571,11 @@ var formatHelpers = [
308
571
  doc: {
309
572
  signature: "lower(value)",
310
573
  description: "Lower-cases a string.",
311
- example: "{{ name | lower }}",
574
+ examples: {
575
+ jinja2: "{{ name | lower }}",
576
+ liquid: "{{ name | lower }}",
577
+ handlebars: "{{lower name}}"
578
+ },
312
579
  category: "text"
313
580
  },
314
581
  fn: (_ctx, value) => str(value).toLowerCase()
@@ -319,7 +586,11 @@ var formatHelpers = [
319
586
  doc: {
320
587
  signature: "title(value)",
321
588
  description: "Capitalises every word.",
322
- example: "{{ 'hello world' | title }} \u2192 Hello World",
589
+ examples: {
590
+ jinja2: "{{ 'hello world' | title }} \u2192 Hello World",
591
+ liquid: "{{ 'hello world' | title }} \u2192 Hello World",
592
+ handlebars: "{{title 'hello world'}} \u2192 Hello World"
593
+ },
323
594
  category: "text"
324
595
  },
325
596
  fn: (_ctx, value) => str(value).replace(
@@ -332,7 +603,11 @@ var formatHelpers = [
332
603
  doc: {
333
604
  signature: "truncate(value, length = 255, suffix = '\u2026')",
334
605
  description: "Cuts a string to `length` characters and appends the suffix when cut.",
335
- example: "{{ description | truncate(40) }}",
606
+ examples: {
607
+ jinja2: "{{ description | truncate(40) }}",
608
+ liquid: "{{ description | truncate: 40 }}",
609
+ handlebars: "{{truncate description 40}}"
610
+ },
336
611
  category: "text"
337
612
  },
338
613
  fn: (_ctx, value, length, suffix) => {
@@ -347,7 +622,11 @@ var formatHelpers = [
347
622
  doc: {
348
623
  signature: "replace(value, search, replacement)",
349
624
  description: "Replaces every occurrence of `search`.",
350
- example: "{{ iban | replace(' ', '') }}",
625
+ examples: {
626
+ jinja2: "{{ iban | replace(' ', '') }}",
627
+ liquid: "{{ iban | replace: ' ', '' }}",
628
+ handlebars: "{{replace iban ' ' ''}}"
629
+ },
351
630
  category: "text"
352
631
  },
353
632
  fn: (_ctx, value, search, replacement) => str(value).split(str(search)).join(str(replacement))
@@ -357,7 +636,11 @@ var formatHelpers = [
357
636
  doc: {
358
637
  signature: "split(value, separator = ',')",
359
638
  description: "Splits a string into a list.",
360
- example: "{% for tag in tags | split(',') %}",
639
+ examples: {
640
+ jinja2: "{% for tag in keywords | split(',') %}{{ tag }}{% endfor %}",
641
+ liquid: "{% assign tags = keywords | split: ',' %}{% for tag in tags %}{{ tag }}{% endfor %}",
642
+ handlebars: "{{#each (split keywords ',')}}{{this}}{{/each}}"
643
+ },
361
644
  category: "text"
362
645
  },
363
646
  fn: (_ctx, value, separator) => str(value).split(str(opt(separator, ",")))
@@ -367,7 +650,11 @@ var formatHelpers = [
367
650
  doc: {
368
651
  signature: "join(list, separator = ', ')",
369
652
  description: "Joins a list into a string.",
370
- example: "{{ tags | join(' \xB7 ') }}",
653
+ examples: {
654
+ jinja2: "{{ tags | join(' \xB7 ') }}",
655
+ liquid: "{{ tags | join: ' \xB7 ' }}",
656
+ handlebars: "{{join tags ' \xB7 '}}"
657
+ },
371
658
  category: "collection"
372
659
  },
373
660
  fn: (_ctx, value, separator) => Array.isArray(value) ? value.map(str).join(str(opt(separator, ", "))) : str(value)
@@ -378,7 +665,11 @@ var formatHelpers = [
378
665
  doc: {
379
666
  signature: "default(value, fallback)",
380
667
  description: "Returns the fallback when the value is empty, null or undefined.",
381
- example: "{{ customer.vat | default('\u2014') }}",
668
+ examples: {
669
+ jinja2: "{{ customer.vat | default('\u2014') }}",
670
+ liquid: "{{ customer.vat | default: '\u2014' }}",
671
+ handlebars: "{{default customer.vat '\u2014'}}"
672
+ },
382
673
  category: "text"
383
674
  },
384
675
  fn: (_ctx, value, fallback) => value === void 0 || value === null || value === "" ? fallback : value
@@ -389,7 +680,11 @@ var formatHelpers = [
389
680
  doc: {
390
681
  signature: "nl2br(value)",
391
682
  description: "Escapes the text and turns line breaks into <br>.",
392
- example: "{{ address | nl2br }}",
683
+ examples: {
684
+ jinja2: "{{ address | nl2br }}",
685
+ liquid: "{{ address | nl2br }}",
686
+ handlebars: "{{nl2br address}}"
687
+ },
393
688
  category: "text"
394
689
  },
395
690
  // office templates turn line breaks into breaks themselves, and escape the text once
@@ -401,7 +696,11 @@ var formatHelpers = [
401
696
  doc: {
402
697
  signature: "markdown(value)",
403
698
  description: "Renders Markdown to HTML (no sanitiser: template data is trusted by the author).",
404
- example: "{{ notes | markdown }}",
699
+ examples: {
700
+ jinja2: "{{ notes | markdown }}",
701
+ liquid: "{{ notes | markdown }}",
702
+ handlebars: "{{markdown notes}}"
703
+ },
405
704
  category: "text"
406
705
  },
407
706
  fn: (ctx, value) => {
@@ -420,7 +719,11 @@ var formatHelpers = [
420
719
  doc: {
421
720
  signature: "safe(value)",
422
721
  description: "Marks a string as HTML so the engine does not escape it.",
423
- example: "{{ html_snippet | safe }}",
722
+ examples: {
723
+ jinja2: "{{ html_snippet | safe }}",
724
+ liquid: "{{ html_snippet | safe }}",
725
+ handlebars: "{{safe html_snippet}}"
726
+ },
424
727
  category: "text"
425
728
  },
426
729
  fn: (_ctx, value) => str(value)
@@ -431,7 +734,11 @@ var formatHelpers = [
431
734
  doc: {
432
735
  signature: "json(value, indent = 2)",
433
736
  description: "Serialises a value as JSON; handy while building a template.",
434
- example: "<pre>{{ invoice | json }}</pre>",
737
+ examples: {
738
+ jinja2: "<pre>{{ invoice | json }}</pre>",
739
+ liquid: "<pre>{{ invoice | json }}</pre>",
740
+ handlebars: "<pre>{{json invoice}}</pre>"
741
+ },
435
742
  category: "debug"
436
743
  },
437
744
  fn: (_ctx, value, indent) => JSON.stringify(value, null, opt(Number(indent), 2))
@@ -442,10 +749,15 @@ var formatHelpers = [
442
749
  doc: {
443
750
  signature: "numToWords(value, locale?)",
444
751
  description: "Spells a number out in words (de, en, fr, es, it, nl).",
445
- example: "{{ total | numToWords('de') }} \u2192 zweihundertdreiundvierzig",
752
+ examples: {
753
+ jinja2: "{{ total | numToWords('de') }} \u2192 zweihundertdreiundvierzig",
754
+ liquid: "{{ total | numToWords: 'de' }} \u2192 zweihundertdreiundvierzig",
755
+ handlebars: "{{numToWords total 'de'}} \u2192 zweihundertdreiundvierzig"
756
+ },
446
757
  category: "format"
447
758
  },
448
759
  fn: (ctx, value, locale) => {
760
+ requireFinite("numToWords", value);
449
761
  const n = toNumber(value);
450
762
  if (Number.isNaN(n)) return str(value);
451
763
  const lang = str(opt(locale, ctx.locale)).split("-")[0] ?? "en";
@@ -458,7 +770,11 @@ var formatHelpers = [
458
770
  doc: {
459
771
  signature: "t(key, params?)",
460
772
  description: "Looks a key up in the template's i18n dictionary for the request locale; `{name}` placeholders come from params.",
461
- example: "{{ t('invoice.title', { number: invoice.number }) }}",
773
+ examples: {
774
+ jinja2: "{{ t('invoice.title', { number: invoice.number }) }}",
775
+ liquid: "{{ 'invoice.title' | t: number: invoice.number }}",
776
+ handlebars: "{{t 'invoice.title' number=invoice.number}}"
777
+ },
462
778
  category: "text"
463
779
  },
464
780
  fn: (ctx, key, params) => {
@@ -474,7 +790,11 @@ var formatHelpers = [
474
790
  doc: {
475
791
  signature: "asset(name)",
476
792
  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.",
477
- example: `<img src="{{ asset('logo.png') }}">`,
793
+ examples: {
794
+ jinja2: `<img src="{{ asset('logo.png') }}">`,
795
+ liquid: `<img src="{{ 'logo.png' | asset }}">`,
796
+ handlebars: `<img src="{{asset 'logo.png'}}">`
797
+ },
478
798
  category: "document"
479
799
  },
480
800
  fn: (ctx, name) => `${(ctx.assetBaseUrl ?? "").replace(/\/$/, "")}/${str(name).replace(/^\//, "")}`
@@ -486,128 +806,17 @@ var formatHelpers = [
486
806
  doc: {
487
807
  signature: "pageBreak()",
488
808
  description: "Starts a new page in PDF output.",
489
- example: "{{ pageBreak() }}",
809
+ examples: {
810
+ jinja2: "{{ pageBreak() }}",
811
+ liquid: "{{ '' | pageBreak }}",
812
+ handlebars: "{{pageBreak}}"
813
+ },
490
814
  category: "document"
491
815
  },
492
816
  fn: (ctx) => ctx.drawing ? ctx.drawing({ kind: "page-break" }) : '<div class="page-break" style="break-after:page"></div>'
493
817
  }
494
818
  ];
495
819
 
496
- // packages/engine/src/lib/helpers/codes.ts
497
- var svgDataUri = (svg) => `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;
498
- var barcodeTypes = {
499
- code128: "code128",
500
- ean13: "ean13",
501
- ean8: "ean8",
502
- upc: "upca",
503
- upca: "upca",
504
- itf14: "itf14",
505
- code39: "code39",
506
- qrcode: "qrcode",
507
- datamatrix: "datamatrix",
508
- pdf417: "pdf417"
509
- };
510
- function qrSvg(value, options = {}) {
511
- const qr = createQr(value, { errorCorrectionLevel: options.ecc ?? "M" });
512
- const svg = renderQrSvg(qr, {
513
- width: options.size ?? 160,
514
- margin: options.margin ?? 1,
515
- color: {
516
- dark: (options.color ?? "#000000").replace(/^(#?)/, "#").slice(0, 9),
517
- light: (options.background ?? "#ffffff").replace(/^(#?)/, "#")
518
- }
519
- });
520
- return svg;
521
- }
522
- function epcPayload(input) {
523
- const amount = input.amount === void 0 || input.amount === "" ? "" : `EUR${toNumber(input.amount).toFixed(2)}`;
524
- return [
525
- "BCD",
526
- "002",
527
- "1",
528
- "SCT",
529
- (input.bic ?? "").replace(/\s/g, ""),
530
- String(input.name ?? "").slice(0, 70),
531
- String(input.iban ?? "").replace(/\s/g, ""),
532
- amount,
533
- (input.purpose ?? "").slice(0, 4),
534
- (input.reference ?? "").slice(0, 35),
535
- input.reference ? "" : (input.text ?? "").slice(0, 140),
536
- ""
537
- ].join("\n");
538
- }
539
- var codeHelpers = [
540
- {
541
- name: "qrcode",
542
- aliases: ["qr"],
543
- doc: {
544
- signature: "qrcode(value, { size = 160, ecc = 'M', margin = 1, color, background })",
545
- description: "QR code as an SVG data URI for an <img> src.",
546
- example: '<img src="{{ qrcode(order.url, { size: 120 }) }}">',
547
- category: "code"
548
- },
549
- fn: (ctx, value, options) => {
550
- const o = options ?? {};
551
- const svg = qrSvg(String(value ?? ""), o);
552
- if (ctx.drawing) return ctx.drawing({ kind: "svg", svg, width: o.size, height: o.size, alt: "QR code" });
553
- return svgDataUri(svg);
554
- }
555
- },
556
- {
557
- name: "barcode",
558
- doc: {
559
- signature: "barcode(value, { type = 'code128', height = 12, width, text = true, scale = 2 })",
560
- description: "Barcode (code128, ean13, ean8, upc, itf14, code39, datamatrix, pdf417) as an SVG data URI.",
561
- example: `<img src="{{ barcode(item.sku, { type: 'code128' }) }}">`,
562
- category: "code"
563
- },
564
- fn: (ctx, value, options) => {
565
- const o = options ?? {};
566
- const bcid = barcodeTypes[String(o.type ?? "code128").toLowerCase()] ?? "code128";
567
- const svg = toSVG({
568
- bcid,
569
- text: String(value ?? ""),
570
- height: o.height ?? 12,
571
- ...o.width === void 0 ? {} : { width: o.width },
572
- scale: o.scale ?? 2,
573
- includetext: o.text !== false,
574
- textxalign: "center"
575
- });
576
- if (ctx.drawing) return ctx.drawing({ kind: "svg", svg, alt: `Barcode ${String(value ?? "")}` });
577
- return svgDataUri(svg);
578
- }
579
- },
580
- {
581
- name: "epcQr",
582
- aliases: ["epc_qr", "girocode"],
583
- doc: {
584
- signature: "epcQr({ name, iban, bic?, amount?, reference?, text? }, { size })",
585
- description: "SEPA credit transfer QR code (EPC069-12 / GiroCode) that banking apps scan to prefill a payment.",
586
- example: '<img src="{{ epcQr({ name: company.name, iban: company.iban, amount: invoice.total, reference: invoice.number }) }}">',
587
- category: "code"
588
- },
589
- // Payment fields and QR options may arrive together: Jinja2 passes two objects, Handlebars one
590
- // hash (`{{epcQr name=… iban=… size=200}}`), and Liquid, which cannot build an object literal,
591
- // keyword arguments (`{{ company.iban | epcQr: name: company.name, amount: invoice.total }}`,
592
- // where a string input is the IBAN). They are merged and split by field name.
593
- fn: (ctx, input, options) => {
594
- const fields = {
595
- ...input && typeof input === "object" ? input : typeof input === "string" && input ? { iban: input } : {},
596
- ...options ?? {}
597
- };
598
- const payment = {};
599
- const qr = {};
600
- for (const [key, value] of Object.entries(fields))
601
- (EPC_FIELDS.has(key) ? payment : qr)[key] = value;
602
- const o = { ecc: "M", ...qr };
603
- const svg = qrSvg(epcPayload(payment), o);
604
- if (ctx.drawing) return ctx.drawing({ kind: "svg", svg, width: o.size, height: o.size, alt: "GiroCode" });
605
- return svgDataUri(svg);
606
- }
607
- }
608
- ];
609
- var EPC_FIELDS = /* @__PURE__ */ new Set(["name", "iban", "bic", "amount", "reference", "text", "purpose"]);
610
-
611
820
  // packages/engine/src/lib/helpers/collections.ts
612
821
  var list = (v) => Array.isArray(v) ? v : v === void 0 || v === null ? [] : [v];
613
822
  var get = (item, path) => String(path ?? "").split(".").filter(Boolean).reduce(
@@ -621,7 +830,11 @@ var collectionHelpers = [
621
830
  doc: {
622
831
  signature: "sum(list, path?)",
623
832
  description: "Sum of the items, or of `path` inside each item.",
624
- example: "{{ invoice.lines | sum('total') | money }}",
833
+ examples: {
834
+ jinja2: "{{ invoice.lines | sum('total') | money }}",
835
+ liquid: "{{ invoice.lines | sum: 'total' | money }}",
836
+ handlebars: "{{money (sum invoice.lines 'total')}}"
837
+ },
625
838
  category: "collection"
626
839
  },
627
840
  fn: (_ctx, value, path) => numbers(value, path).reduce((a, b) => a + b, 0)
@@ -631,7 +844,11 @@ var collectionHelpers = [
631
844
  doc: {
632
845
  signature: "avg(list, path?)",
633
846
  description: "Average of the items or of `path` inside each item.",
634
- example: "{{ scores | avg | number(1) }}",
847
+ examples: {
848
+ jinja2: "{{ scores | avg | number(1) }}",
849
+ liquid: "{{ scores | avg | number: 1 }}",
850
+ handlebars: "{{number (avg scores) 1}}"
851
+ },
635
852
  category: "collection"
636
853
  },
637
854
  fn: (_ctx, value, path) => {
@@ -644,7 +861,11 @@ var collectionHelpers = [
644
861
  doc: {
645
862
  signature: "min(list, path?)",
646
863
  description: "Smallest number.",
647
- example: "{{ prices | min }}",
864
+ examples: {
865
+ jinja2: "{{ prices | min }}",
866
+ liquid: "{{ prices | min }}",
867
+ handlebars: "{{min prices}}"
868
+ },
648
869
  category: "collection"
649
870
  },
650
871
  fn: (_ctx, value, path) => Math.min(...numbers(value, path))
@@ -654,7 +875,11 @@ var collectionHelpers = [
654
875
  doc: {
655
876
  signature: "max(list, path?)",
656
877
  description: "Largest number.",
657
- example: "{{ prices | max }}",
878
+ examples: {
879
+ jinja2: "{{ prices | max }}",
880
+ liquid: "{{ prices | max }}",
881
+ handlebars: "{{max prices}}"
882
+ },
658
883
  category: "collection"
659
884
  },
660
885
  fn: (_ctx, value, path) => Math.max(...numbers(value, path))
@@ -665,7 +890,11 @@ var collectionHelpers = [
665
890
  doc: {
666
891
  signature: "groupBy(list, path)",
667
892
  description: "Groups items by a field; returns a list of `{ key, items }`.",
668
- example: "{% for group in lines | groupBy('category') %}{{ group.key }}{% endfor %}",
893
+ examples: {
894
+ jinja2: "{% for group in lines | groupBy('category') %}{{ group.key }}{% endfor %}",
895
+ liquid: "{% assign groups = lines | groupBy: 'category' %}{% for group in groups %}{{ group.key }}{% endfor %}",
896
+ handlebars: "{{#each (groupBy lines 'category')}}{{key}}{{/each}}"
897
+ },
669
898
  category: "collection"
670
899
  },
671
900
  fn: (_ctx, value, path) => {
@@ -683,7 +912,11 @@ var collectionHelpers = [
683
912
  doc: {
684
913
  signature: "sortBy(list, path?, direction = 'asc')",
685
914
  description: "Sorts items by a field (numbers numerically, strings by locale).",
686
- example: "{% for l in lines | sortBy('position') %}",
915
+ examples: {
916
+ jinja2: "{% for l in lines | sortBy('position') %}{{ l.sku }}{% endfor %}",
917
+ liquid: "{% assign sorted = lines | sortBy: 'position' %}{% for l in sorted %}{{ l.sku }}{% endfor %}",
918
+ handlebars: "{{#each (sortBy lines 'position')}}{{sku}}{{/each}}"
919
+ },
687
920
  category: "collection"
688
921
  },
689
922
  fn: (ctx, value, path, direction) => {
@@ -704,7 +937,11 @@ var collectionHelpers = [
704
937
  doc: {
705
938
  signature: "where(list, path, value?)",
706
939
  description: "Keeps items whose field equals `value` (or is truthy when omitted).",
707
- example: "{% for l in lines | where('taxable', true) %}",
940
+ examples: {
941
+ jinja2: "{% for l in lines | where('taxable', true) %}{{ l.sku }}{% endfor %}",
942
+ liquid: "{% assign taxable = lines | where: 'taxable', true %}{% for l in taxable %}{{ l.sku }}{% endfor %}",
943
+ handlebars: "{{#each (where lines 'taxable' true)}}{{sku}}{{/each}}"
944
+ },
708
945
  category: "collection"
709
946
  },
710
947
  fn: (_ctx, value, path, expected) => list(value).filter((item) => {
@@ -718,7 +955,11 @@ var collectionHelpers = [
718
955
  doc: {
719
956
  signature: "pluck(list, path)",
720
957
  description: "Extracts one field from every item.",
721
- example: "{{ lines | pluck('sku') | join(', ') }}",
958
+ examples: {
959
+ jinja2: "{{ lines | pluck('sku') | join(', ') }}",
960
+ liquid: "{{ lines | pluck: 'sku' | join: ', ' }}",
961
+ handlebars: "{{join (pluck lines 'sku') ', '}}"
962
+ },
722
963
  category: "collection"
723
964
  },
724
965
  fn: (_ctx, value, path) => list(value).map((item) => get(item, path))
@@ -729,7 +970,11 @@ var collectionHelpers = [
729
970
  doc: {
730
971
  signature: "chunk(list, size)",
731
972
  description: "Splits a list into lists of `size` items (rows of labels, columns).",
732
- example: "{% for row in labels | chunk(3) %}",
973
+ examples: {
974
+ jinja2: "{% for row in labels | chunk(3) %}{{ row | join(', ') }}{% endfor %}",
975
+ liquid: "{% assign rows = labels | chunk: 3 %}{% for row in rows %}{{ row | join: ', ' }}{% endfor %}",
976
+ handlebars: "{{#each (chunk labels 3)}}{{join this ', '}}{{/each}}"
977
+ },
733
978
  category: "collection"
734
979
  },
735
980
  fn: (_ctx, value, size) => {
@@ -745,7 +990,11 @@ var collectionHelpers = [
745
990
  doc: {
746
991
  signature: "range(start, end?, step = 1)",
747
992
  description: "List of numbers from start to end (exclusive).",
748
- example: "{% for i in range(1, 4) %}",
993
+ examples: {
994
+ jinja2: "{% for i in range(1, 4) %}{{ i }}{% endfor %}",
995
+ liquid: "{% assign nums = 1 | range: 4 %}{% for i in nums %}{{ i }}{% endfor %}",
996
+ handlebars: "{{#each (range 1 4)}}{{this}}{{/each}}"
997
+ },
749
998
  category: "collection"
750
999
  },
751
1000
  fn: (_ctx, start, end, step) => {
@@ -763,7 +1012,11 @@ var collectionHelpers = [
763
1012
  doc: {
764
1013
  signature: "first(list)",
765
1014
  description: "First item.",
766
- example: "{{ lines | first }}",
1015
+ examples: {
1016
+ jinja2: "{{ tags | first }}",
1017
+ liquid: "{{ tags | first }}",
1018
+ handlebars: "{{first tags}}"
1019
+ },
767
1020
  category: "collection"
768
1021
  },
769
1022
  fn: (_ctx, value) => list(value)[0]
@@ -773,7 +1026,11 @@ var collectionHelpers = [
773
1026
  doc: {
774
1027
  signature: "last(list)",
775
1028
  description: "Last item.",
776
- example: "{{ lines | last }}",
1029
+ examples: {
1030
+ jinja2: "{{ tags | last }}",
1031
+ liquid: "{{ tags | last }}",
1032
+ handlebars: "{{last tags}}"
1033
+ },
777
1034
  category: "collection"
778
1035
  },
779
1036
  fn: (_ctx, value) => list(value).at(-1)
@@ -784,7 +1041,11 @@ var collectionHelpers = [
784
1041
  doc: {
785
1042
  signature: "length(value)",
786
1043
  description: "Number of items in a list, keys in an object or characters in a string.",
787
- example: "{{ lines | length }}",
1044
+ examples: {
1045
+ jinja2: "{{ lines | length }}",
1046
+ liquid: "{{ lines | length }}",
1047
+ handlebars: "{{length lines}}"
1048
+ },
788
1049
  category: "collection"
789
1050
  },
790
1051
  fn: (_ctx, value) => Array.isArray(value) || typeof value === "string" ? value.length : value && typeof value === "object" ? Object.keys(value).length : 0
@@ -860,7 +1121,11 @@ var documentHelpers = [
860
1121
  doc: {
861
1122
  signature: "chart({ type, labels, values, label, color } | { type, labels, series } | { type, data, options }, { width, height })",
862
1123
  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.",
863
- example: "{{ chart({ type: 'line', labels: ['Q1', 'Q2', 'Q3'], values: sales.quarters, width: 480 }) }}",
1124
+ examples: {
1125
+ jinja2: "{{ chart({ type: 'line', labels: sales.labels, values: sales.quarters, width: 480 }) }}",
1126
+ liquid: "{{ '' | chart: type: 'line', labels: sales.labels, values: sales.quarters, width: 480 }}",
1127
+ handlebars: "{{chart type='line' labels=sales.labels values=sales.quarters width=480}}"
1128
+ },
864
1129
  category: "document"
865
1130
  },
866
1131
  fn: (ctx, spec, extra) => {
@@ -874,7 +1139,11 @@ var documentHelpers = [
874
1139
  doc: {
875
1140
  signature: "image(url, { width, height, fit, alt })",
876
1141
  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.",
877
- example: "{{ image(product.photo, { width: 120, height: 80, fit: 'cover' }) }}",
1142
+ examples: {
1143
+ jinja2: "{{ image(product.photo, { width: 120, height: 80, fit: 'cover' }) }}",
1144
+ liquid: "{{ product.photo | image: width: 120, height: 80, fit: 'cover' }}",
1145
+ handlebars: "{{image product.photo width=120 height=80 fit='cover'}}"
1146
+ },
878
1147
  category: "document"
879
1148
  },
880
1149
  fn: (ctx, url, options) => {
@@ -900,7 +1169,12 @@ var comparison = (name, test, description) => ({
900
1169
  doc: {
901
1170
  signature: `${name}(a, b)`,
902
1171
  description,
903
- example: `{{#if (${name} invoice.total 1000)}}\u2026{{/if}}`,
1172
+ examples: {
1173
+ jinja2: `{% if ${name}(invoice.total, 1000) %}\u2026{% endif %}`,
1174
+ // Liquid takes no filter in a condition, so the comparison is assigned first
1175
+ liquid: `{% assign hit = invoice.total | ${name}: 1000 %}{% if hit %}\u2026{% endif %}`,
1176
+ handlebars: `{{#if (${name} invoice.total 1000)}}\u2026{{/if}}`
1177
+ },
904
1178
  category: "logic"
905
1179
  },
906
1180
  fn: (_ctx, a, b) => test(a, b)
@@ -917,7 +1191,12 @@ var logicHelpers = [
917
1191
  doc: {
918
1192
  signature: "and(a, b, \u2026)",
919
1193
  description: "True when every value is true; an empty list counts as false.",
920
- example: '{{#if (and customer.vatId (eq country "DE"))}}\u2026{{/if}}',
1194
+ examples: {
1195
+ jinja2: "{% if and(customer.vatId, eq(country, 'DE')) %}\u2026{% endif %}",
1196
+ // nesting one comparison in another needs its own assignment in Liquid
1197
+ liquid: "{% assign german = country | eq: 'DE' %}{% assign both = customer.vatId | and: german %}{% if both %}\u2026{% endif %}",
1198
+ handlebars: "{{#if (and customer.vatId (eq country 'DE'))}}\u2026{{/if}}"
1199
+ },
921
1200
  category: "logic"
922
1201
  },
923
1202
  fn: (_ctx, ...values) => values.length > 0 && values.every(truthy)
@@ -927,7 +1206,11 @@ var logicHelpers = [
927
1206
  doc: {
928
1207
  signature: "or(a, b, \u2026)",
929
1208
  description: "True when any value is true.",
930
- example: "{{#if (or invoice.paid invoice.credited)}}\u2026{{/if}}",
1209
+ examples: {
1210
+ jinja2: "{% if or(invoice.paid, invoice.credited) %}\u2026{% endif %}",
1211
+ liquid: "{% assign any = invoice.paid | or: invoice.credited %}{% if any %}\u2026{% endif %}",
1212
+ handlebars: "{{#if (or invoice.paid invoice.credited)}}\u2026{{/if}}"
1213
+ },
931
1214
  category: "logic"
932
1215
  },
933
1216
  fn: (_ctx, ...values) => values.some(truthy)
@@ -937,7 +1220,11 @@ var logicHelpers = [
937
1220
  doc: {
938
1221
  signature: "not(value)",
939
1222
  description: "The opposite truth value.",
940
- example: "{{#if (not invoice.paid)}}\u2026{{/if}}",
1223
+ examples: {
1224
+ jinja2: "{% if invoice.paid | not %}\u2026{% endif %}",
1225
+ liquid: "{% assign unpaid = invoice.paid | not %}{% if unpaid %}\u2026{% endif %}",
1226
+ handlebars: "{{#if (not invoice.paid)}}\u2026{{/if}}"
1227
+ },
941
1228
  category: "logic"
942
1229
  },
943
1230
  fn: (_ctx, value) => !truthy(value)
@@ -1010,7 +1297,9 @@ function helperDocs(registry = defaultHelpers()) {
1010
1297
  return [...registry.definitions.values()].map((d) => ({
1011
1298
  name: d.name,
1012
1299
  aliases: d.aliases ?? [],
1013
- ...d.doc
1300
+ ...d.doc,
1301
+ // derived rather than authored, so the old field cannot drift from `examples`
1302
+ example: d.doc.examples.jinja2
1014
1303
  }));
1015
1304
  }
1016
1305
 
@@ -1132,6 +1421,7 @@ function scanBlocks(source, spec) {
1132
1421
  severity: "error",
1133
1422
  code: "unexpected-end-tag",
1134
1423
  message: `Unexpected {% ${keyword} %} without an open block`,
1424
+ args: { keyword },
1135
1425
  range
1136
1426
  });
1137
1427
  } else if (top.keyword !== expected) {
@@ -1139,6 +1429,7 @@ function scanBlocks(source, spec) {
1139
1429
  severity: "error",
1140
1430
  code: "mismatched-end-tag",
1141
1431
  message: `Expected {% end${top.keyword} %} to close the {% ${top.keyword} %} at line ${top.block.open.start.line}, found {% ${keyword} %}`,
1432
+ args: { open: top.keyword, line: top.block.open.start.line, found: keyword },
1142
1433
  range
1143
1434
  });
1144
1435
  const idx = stack.map((s) => s.keyword).lastIndexOf(expected);
@@ -1155,6 +1446,7 @@ function scanBlocks(source, spec) {
1155
1446
  severity: "error",
1156
1447
  code: "unexpected-tag",
1157
1448
  message: `{% ${keyword} %} outside of a block`,
1449
+ args: { keyword },
1158
1450
  range
1159
1451
  });
1160
1452
  }
@@ -1164,6 +1456,7 @@ function scanBlocks(source, spec) {
1164
1456
  severity: "error",
1165
1457
  code: "unclosed-block",
1166
1458
  message: `{% ${open.keyword} %} is never closed; add {% end${open.keyword} %}`,
1459
+ args: { keyword: open.keyword },
1167
1460
  range: open.block.open
1168
1461
  });
1169
1462
  }
@@ -1218,6 +1511,7 @@ function missingPathDiagnostics(variables, sampleData, loopSources, builtinRoots
1218
1511
  severity: "warning",
1219
1512
  code: "unknown-variable",
1220
1513
  message: `"${v.path.join(".")}" is not present in the sample data`,
1514
+ args: { name: v.path.join(".") },
1221
1515
  range: v.range
1222
1516
  });
1223
1517
  }
@@ -1468,6 +1762,7 @@ function analyzeHandlebars(source, opts = {}) {
1468
1762
  severity: "warning",
1469
1763
  code: "deprecated-filter",
1470
1764
  message: `"${f.name}" is a compatibility alias; use "${preferred}"`,
1765
+ args: { name: f.name, preferred },
1471
1766
  range: f.range,
1472
1767
  fix: { title: `Rename to ${preferred}`, replacement: preferred }
1473
1768
  });
@@ -1483,6 +1778,7 @@ function analyzeHandlebars(source, opts = {}) {
1483
1778
  severity: "error",
1484
1779
  code: "unknown-filter",
1485
1780
  message: `Unknown helper "${f.name}"`,
1781
+ args: { name: f.name, kind: "helper" },
1486
1782
  range: f.range
1487
1783
  });
1488
1784
  }
@@ -2277,6 +2573,7 @@ function analyzeJinja2(source, opts = {}) {
2277
2573
  severity: "warning",
2278
2574
  code: "deprecated-filter",
2279
2575
  message: `"${f.name}" is an apitemplate.io compatibility alias; use "${preferred}"`,
2576
+ args: { name: f.name, preferred },
2280
2577
  range: f.range,
2281
2578
  fix: { title: `Rename to ${preferred}`, replacement: preferred }
2282
2579
  });
@@ -2292,6 +2589,7 @@ function analyzeJinja2(source, opts = {}) {
2292
2589
  severity: "error",
2293
2590
  code: "unknown-filter",
2294
2591
  message: `Unknown filter or helper "${f.name}"`,
2592
+ args: { name: f.name, kind: "filter-or-helper" },
2295
2593
  range: f.range
2296
2594
  });
2297
2595
  }
@@ -2692,6 +2990,7 @@ function analyzeLiquid(source, opts = {}) {
2692
2990
  severity: "warning",
2693
2991
  code: "deprecated-filter",
2694
2992
  message: `"${f.name}" is a compatibility alias; use "${preferred}"`,
2993
+ args: { name: f.name, preferred },
2695
2994
  range: f.range,
2696
2995
  fix: { title: `Rename to ${preferred}`, replacement: preferred }
2697
2996
  });
@@ -2707,6 +3006,7 @@ function analyzeLiquid(source, opts = {}) {
2707
3006
  severity: "error",
2708
3007
  code: "unknown-filter",
2709
3008
  message: `Unknown filter "${f.name}"`,
3009
+ args: { name: f.name, kind: "filter" },
2710
3010
  range: f.range
2711
3011
  });
2712
3012
  }
@@ -3442,7 +3742,7 @@ ${loop(e, "line", "invoice.lines", ` <tr><td>${val(e, "line.description")}</t
3442
3742
  invoice: { due_date: "2026-10-13" },
3443
3743
  payment: {
3444
3744
  name: "Fennlor Studio GmbH",
3445
- iban: "DE00000000000000000000",
3745
+ iban: "DE36000000000000000000",
3446
3746
  bic: "XXXXDEXXXXX",
3447
3747
  amount: 3760.4,
3448
3748
  reference: "2026-0042"
@@ -3629,7 +3929,7 @@ ${loop(e, "line", "invoice.lines", ` <tr><td>${val(e, "line.description")}</t
3629
3929
  sampleData: {
3630
3930
  payment: {
3631
3931
  name: "Fennlor Studio GmbH",
3632
- iban: "DE00000000000000000000",
3932
+ iban: "DE36000000000000000000",
3633
3933
  bic: "XXXXDEXXXXX",
3634
3934
  amount: 3760.4,
3635
3935
  reference: "2026-0042"
@@ -5239,6 +5539,58 @@ var pageNavScript = `<script>
5239
5539
  }, { passive: true });
5240
5540
  })();
5241
5541
  </script>`;
5542
+ function overflowScript(mode) {
5543
+ return `<script>
5544
+ (function () {
5545
+ var clipped = function (el, stop) {
5546
+ for (var p = el.parentElement; p && p !== stop; p = p.parentElement)
5547
+ if (getComputedStyle(p).overflowX !== 'visible') return true;
5548
+ return false;
5549
+ };
5550
+ var measure = function () {
5551
+ try {
5552
+ var boxes = ${mode === "paged" ? "document.querySelectorAll('.pagedjs_page_content')" : "[document.body]"};
5553
+ var worst = null;
5554
+ for (var i = 0; i < boxes.length; i++) {
5555
+ var box = boxes[i], rect = box.getBoundingClientRect(), style = getComputedStyle(box);
5556
+ var left = rect.left + parseFloat(style.paddingLeft || '0') + parseFloat(style.borderLeftWidth || '0');
5557
+ var right = rect.right - parseFloat(style.paddingRight || '0') - parseFloat(style.borderRightWidth || '0');
5558
+ if (right - left <= 0) continue;
5559
+ var all = box.querySelectorAll('*');
5560
+ for (var j = 0; j < all.length; j++) {
5561
+ var el = all[j];
5562
+ // Paged.js wraps each page's content in a div of its own that spans its layout columns
5563
+ if (${mode === "paged"} && el.parentElement === box && !el.className) continue;
5564
+ if (el.closest('.formfeed-chrome, .ff-running-header, .ff-running-footer, script, style, head')) continue;
5565
+ var over = el.getBoundingClientRect().right - right;
5566
+ if (over <= 1 || (worst && over <= worst.over)) continue;
5567
+ if (getComputedStyle(el).position === 'fixed' || clipped(el, box)) continue;
5568
+ worst = { over: over, width: right - left, el: el };
5569
+ }
5570
+ }
5571
+ var overflow = null;
5572
+ if (worst) {
5573
+ var ref = document.createElement('div');
5574
+ ref.style.cssText = 'position:absolute;visibility:hidden;width:100mm;height:0';
5575
+ document.body.appendChild(ref);
5576
+ var pxPerMm = ref.getBoundingClientRect().width / 100 || 1;
5577
+ ref.remove();
5578
+ var annotated = worst.el.closest('[data-ff-src]');
5579
+ overflow = {
5580
+ overflowMm: Math.round((worst.over / pxPerMm) * 10) / 10,
5581
+ scale: Math.round((worst.width / (worst.width + worst.over)) * 1000) / 1000,
5582
+ tag: worst.el.tagName.toLowerCase(),
5583
+ src: annotated ? annotated.getAttribute('data-ff-src') : null,
5584
+ };
5585
+ }
5586
+ parent.postMessage({ type: 'formfeed:overflow', overflow: overflow }, '*');
5587
+ } catch (e) {}
5588
+ };
5589
+ window.formfeedCheckOverflow = measure;
5590
+ ${mode === "flow" ? "window.addEventListener('load', function () { (document.fonts ? document.fonts.ready : Promise.resolve()).then(measure); });" : ""}
5591
+ })();
5592
+ </script>`;
5593
+ }
5242
5594
  var zoomGestureScript = `<script>
5243
5595
  (function () {
5244
5596
  var say = function (message) { try { parent.postMessage(message, '*'); } catch (e) {} };
@@ -5285,7 +5637,7 @@ body.formfeed-preview { position: relative; box-sizing: border-box; width: ${pap
5285
5637
  </style>` : `<style data-formfeed="preview">html { background: #e5e7eb; } body.formfeed-preview { margin: 24px auto; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.2); }</style>`;
5286
5638
  const header = headerHtml ? `<div class="formfeed-chrome" style="${pdf ? flowChromeStyle(settings, "header") : `${chromeBoxStyle(settings, "header")}margin-bottom:8px`}">${headerHtml}</div>` : "";
5287
5639
  const footer = footerHtml ? `<div class="formfeed-chrome" style="${pdf ? flowChromeStyle(settings, "footer") : `${chromeBoxStyle(settings, "footer")}margin-top:8px`}">${pageNumberSpans(footerHtml, "1", "1")}</div>` : "";
5288
- return intoHead(draft.document, `${chrome}${inspectScript}${zoomGestureScript}`).replace(/(<body[^>]*>)/, `$1${header}`).replace("</body>", `${footer}</body>`);
5640
+ return intoHead(draft.document, `${chrome}${inspectScript}${zoomGestureScript}${pdf ? overflowScript("flow") : ""}`).replace(/(<body[^>]*>)/, `$1${header}`).replace("</body>", `${footer}</body>`);
5289
5641
  }
5290
5642
  function pagedDocument(draft, options) {
5291
5643
  const { headerHtml, footerHtml, settings } = draft;
@@ -5307,13 +5659,13 @@ html { background: #e5e7eb; }
5307
5659
  `;
5308
5660
  const selectorGuard = `<script>(function(){var q=DocumentFragment.prototype.querySelectorAll;DocumentFragment.prototype.querySelectorAll=function(s){try{return q.call(this,s);}catch(e){console.warn('formfeed paged preview: skipped a selector Paged.js could not use:',s);return q.call(this,':not(*)');}};})();</script>`;
5309
5661
  const boxSizingGuard = `<script>(function(){var rule='.pagedjs_pagebox *';var o=new MutationObserver(function(ms){ms.forEach(function(m){m.addedNodes.forEach(function(n){if(n.nodeName==='STYLE'&&n.textContent&&n.textContent.indexOf(rule)>=0){n.textContent=n.textContent.split(rule).join('.pagedjs_pagebox [class*="pagedjs_"]');o.disconnect();}});});});o.observe(document.documentElement,{childList:true,subtree:true});})();</script>`;
5310
- const config = `<script>window.PagedConfig = { auto: true, after: function (flow) { try { if (window.formfeedDrawCharts) window.formfeedDrawCharts(); } catch (e) {} try { parent.postMessage({ type: 'formfeed:pages', pages: flow.total }, '*'); } catch (e) {} } };</script>`;
5662
+ const config = `<script>window.PagedConfig = { auto: true, after: function (flow) { try { if (window.formfeedDrawCharts) window.formfeedDrawCharts(); } catch (e) {} try { parent.postMessage({ type: 'formfeed:pages', pages: flow.total }, '*'); } catch (e) {} if (window.formfeedCheckOverflow) window.formfeedCheckOverflow(); } };</script>`;
5311
5663
  const script = `<script src="${options.pagedScriptUrl}"></script>`;
5312
5664
  const header = headerHtml ? `<div class="ff-running-header">${pageNumberSpans(headerHtml, '<span class="ff-page-no"></span>', '<span class="ff-page-total"></span>')}</div>` : "";
5313
5665
  const footer = footerHtml ? `<div class="ff-running-footer">${pageNumberSpans(footerHtml, '<span class="ff-page-no"></span>', '<span class="ff-page-total"></span>')}</div>` : "";
5314
5666
  return intoHead(
5315
5667
  draft.document,
5316
- `<style data-formfeed="paged">${runningCss}</style>${selectorGuard}${boxSizingGuard}${config}${script}${inspectScript}${pageNavScript}${zoomGestureScript}`
5668
+ `<style data-formfeed="paged">${runningCss}</style>${selectorGuard}${boxSizingGuard}${overflowScript("paged")}${config}${script}${inspectScript}${pageNavScript}${zoomGestureScript}`
5317
5669
  ).replace(/(<body[^>]*>)/, `$1${header}${footer}`);
5318
5670
  }
5319
5671
 
@@ -8464,6 +8816,7 @@ function starterPresentation(engine) {
8464
8816
  export {
8465
8817
  DEFAULT_CHROME_PADDING,
8466
8818
  DefaultHelperRegistry,
8819
+ EPC_MAX_BYTES,
8467
8820
  EngineSyntaxError,
8468
8821
  OFFICE_FILLED_MAX_BYTES,
8469
8822
  OfficeTemplateError,
@@ -8500,6 +8853,7 @@ export {
8500
8853
  getEngine,
8501
8854
  handlebarsEngine,
8502
8855
  helperDocs,
8856
+ ibanChecksum,
8503
8857
  imageMarkup,
8504
8858
  imageOutput,
8505
8859
  importApitemplate,
@@ -8524,6 +8878,7 @@ export {
8524
8878
  mergeSettings,
8525
8879
  mergeSnippetCss,
8526
8880
  mergeSnippetI18n,
8881
+ normaliseIban,
8527
8882
  normalisePartialName,
8528
8883
  nunjucksBuiltinFilters,
8529
8884
  officeDrawingLimits,
@@ -8554,7 +8909,9 @@ export {
8554
8909
  starterDocument,
8555
8910
  starterPresentation,
8556
8911
  strftimeToDateFns,
8912
+ svgDataUri,
8557
8913
  toDate,
8558
8914
  toNumber,
8915
+ validateEpc,
8559
8916
  zoomGestureScript
8560
8917
  };