@dev-crew-berlin/enter-js-utils 0.98.12 → 0.99.0

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.
@@ -0,0 +1,31 @@
1
+ import { LinkBuilderOptions } from './email-links';
2
+ /**
3
+ * A from-scratch TS port of the pybars helper set registered in
4
+ * app/templates.py (enter-core) -- the "mechanical, data-only" ones the
5
+ * plan calls out for reuse, plus the link builders (wired to the shared
6
+ * TS builders in email-links.ts instead of a backend round trip, since
7
+ * tracking links in particular are template-dependent: an author can wrap
8
+ * an arbitrary one-off URL anywhere in a block, so the set of links
9
+ * needing signing can't be enumerated ahead of render time).
10
+ *
11
+ * Each helper's behaviour is checked against a characterization test of
12
+ * the *actual* pybars implementation in
13
+ * enter-core/tests/unittests/app/render_templates_test.py -- see
14
+ * email-handlebars.test.ts for the mirrored cases, including two
15
+ * intentional divergences documented inline below (`escape`, `if`'s `!=`).
16
+ */
17
+ export type EmailLinkContext = {
18
+ attendeeId: string;
19
+ emailName: string;
20
+ mailingId?: string;
21
+ trackingEnabled: boolean;
22
+ };
23
+ export type PartialResolver = (name: string) => Promise<string>;
24
+ export type RenderHandlebarsOptions = {
25
+ lang: string;
26
+ languages: string[];
27
+ linkOptions: LinkBuilderOptions;
28
+ linkContext: EmailLinkContext;
29
+ resolvePartial: PartialResolver;
30
+ };
31
+ export declare function renderHandlebars(source: string, data: Record<string, unknown>, options: RenderHandlebarsOptions): Promise<string>;
@@ -0,0 +1,436 @@
1
+ import Handlebars from 'handlebars';
2
+ import { makeGoogleWalletLink, makeQrCodeLink, makeRegistrationLink, makeTrackingLink, makeUnsubscribeLink, makeWalletLink } from './email-links';
3
+
4
+ /**
5
+ * A from-scratch TS port of the pybars helper set registered in
6
+ * app/templates.py (enter-core) -- the "mechanical, data-only" ones the
7
+ * plan calls out for reuse, plus the link builders (wired to the shared
8
+ * TS builders in email-links.ts instead of a backend round trip, since
9
+ * tracking links in particular are template-dependent: an author can wrap
10
+ * an arbitrary one-off URL anywhere in a block, so the set of links
11
+ * needing signing can't be enumerated ahead of render time).
12
+ *
13
+ * Each helper's behaviour is checked against a characterization test of
14
+ * the *actual* pybars implementation in
15
+ * enter-core/tests/unittests/app/render_templates_test.py -- see
16
+ * email-handlebars.test.ts for the mirrored cases, including two
17
+ * intentional divergences documented inline below (`escape`, `if`'s `!=`).
18
+ */
19
+
20
+ function translate(item, lang) {
21
+ if (typeof item === 'string') return item;
22
+ if (item && typeof item === 'object' && !Array.isArray(item)) {
23
+ return item[lang];
24
+ }
25
+ return item;
26
+ }
27
+ function stringifyTranslated(item, lang) {
28
+ const value = translate(item, lang);
29
+ return value == null ? '' : String(value);
30
+ }
31
+ function helperArgs(args) {
32
+ return {
33
+ options: args[args.length - 1],
34
+ explicit: args.slice(0, -1)
35
+ };
36
+ }
37
+ function compareValues(op, left, right) {
38
+ try {
39
+ switch (op) {
40
+ case '==':
41
+ return left === right;
42
+ // mirrors a bug in the legacy pybars `if` helper where != behaves
43
+ // exactly like == -- documented, not "fixed", so templates ported
44
+ // from the legacy renderer keep behaving the same way here
45
+ case '!=':
46
+ return left === right;
47
+ case '>':
48
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
49
+ return left > right;
50
+ case '<':
51
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
52
+ return left < right;
53
+ case '>=':
54
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
55
+ return left >= right;
56
+ case '<=':
57
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
58
+ return left <= right;
59
+ case 'in':
60
+ if (typeof right === 'string' || Array.isArray(right)) {
61
+ return right.includes(left);
62
+ }
63
+ return false;
64
+ default:
65
+ return false;
66
+ }
67
+ } catch {
68
+ return false;
69
+ }
70
+ }
71
+ function decimalPlaces(value) {
72
+ const s = String(value);
73
+ return s.includes('.') ? s.split('.')[1].length : 0;
74
+ }
75
+ function roundHalfToEven(value, decimals) {
76
+ const factor = 10 ** decimals;
77
+ const scaled = value * factor;
78
+ if (Math.abs(scaled - Math.trunc(scaled) - 0.5) < 1e-9) {
79
+ const floor = Math.floor(scaled);
80
+ return (floor % 2 === 0 ? floor : floor + 1) / factor;
81
+ }
82
+ return Math.round(scaled) / factor;
83
+ }
84
+ function formatCalcResult(value) {
85
+ // Python's round() always returns a float, and str(float) always shows
86
+ // at least one decimal digit (2.0, not 2) -- JS's default number ->
87
+ // string conversion drops it, so whole numbers need it added back.
88
+ return Number.isInteger(value) ? `${value}.0` : String(value);
89
+ }
90
+ function parseDayFirst(value) {
91
+ if (value instanceof Date) return value;
92
+ const s = String(value);
93
+ // "DD.MM.YYYY HH:mm" -- the format legacy templates author literal
94
+ // comparison timestamps in; anything else is handed to Date() as-is
95
+ // (covers ISO strings, which is what real attendee/event data uses).
96
+ const match = /^(\d{1,2})\.(\d{1,2})\.(\d{4})(?:\s+(\d{1,2}):(\d{2}))?/.exec(s);
97
+ if (match) {
98
+ const [, day, month, year, hour, minute] = match;
99
+ return new Date(Date.UTC(Number(year), Number(month) - 1, Number(day), Number(hour ?? 0), Number(minute ?? 0)));
100
+ }
101
+ return new Date(s);
102
+ }
103
+ function sizeofFmt(value) {
104
+ let num = Number(value);
105
+ if (!Number.isFinite(num)) num = 0;
106
+ if (num < 1024) return `${Math.trunc(num)} B`;
107
+ for (const unit of ['KB', 'MB', 'GB']) {
108
+ num /= 1024;
109
+ if (Math.abs(num) < 100) return `${num.toFixed(1)} ${unit}`;
110
+ if (Math.abs(num) < 1024) return `${Math.trunc(num)} ${unit}`;
111
+ }
112
+ return `${num.toFixed(1)} TB`;
113
+ }
114
+ function registerDataHelpers(hb, lang, languages) {
115
+ hb.registerHelper('_', function (value, ...rest) {
116
+ const explicit = rest.slice(0, -1);
117
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
118
+ const key = explicit.length > 0 ? String(explicit[0]) : lang;
119
+ return value[key];
120
+ }
121
+ return value;
122
+ });
123
+ hb.registerHelper('upper', function (value, ...rest) {
124
+ const explicit = rest.slice(0, -1);
125
+ const key = explicit.length > 0 ? String(explicit[0]) : lang;
126
+ const translated = value && typeof value === 'object' && !Array.isArray(value) ? value[key] : value;
127
+ return translated == null ? '' : String(translated).toUpperCase();
128
+ });
129
+ hb.registerHelper('lower', function (value, ...rest) {
130
+ const explicit = rest.slice(0, -1);
131
+ const key = explicit.length > 0 ? String(explicit[0]) : lang;
132
+ const translated = value && typeof value === 'object' && !Array.isArray(value) ? value[key] : value;
133
+ return translated == null ? '' : String(translated).toLowerCase();
134
+ });
135
+ for (const targetLang of languages) {
136
+ hb.registerHelper(targetLang, function (...args) {
137
+ const {
138
+ options,
139
+ explicit
140
+ } = helperArgs(args);
141
+ const matches = targetLang === lang;
142
+ if (typeof options.fn === 'function') {
143
+ return matches ? options.fn(this) : options.inverse(this);
144
+ }
145
+ if (!matches) return undefined;
146
+ return explicit.map(a => stringifyTranslated(a, lang)).join('');
147
+ });
148
+ }
149
+ hb.registerHelper('eq', function (...args) {
150
+ const {
151
+ options,
152
+ explicit
153
+ } = helperArgs(args);
154
+ if (explicit.length >= 2) {
155
+ const left = translate(explicit[0], lang);
156
+ for (const arg of explicit.slice(1)) {
157
+ if (left === translate(arg, lang)) return options.fn(this);
158
+ }
159
+ }
160
+ return options.inverse(this);
161
+ });
162
+ hb.registerHelper('if', function (...args) {
163
+ const {
164
+ options,
165
+ explicit
166
+ } = helperArgs(args);
167
+ if (explicit.length >= 3) {
168
+ const operator = String(explicit[1]);
169
+ const left = translate(explicit[0], lang);
170
+ for (const arg of explicit.slice(2)) {
171
+ if (compareValues(operator, left, translate(arg, lang))) return options.fn(this);
172
+ }
173
+ } else if (explicit.length === 1) {
174
+ if (explicit[0]) return options.fn(this);
175
+ }
176
+ return options.inverse(this);
177
+ });
178
+ hb.registerHelper('if_in', function (...args) {
179
+ const {
180
+ options,
181
+ explicit
182
+ } = helperArgs(args);
183
+ const haystack = explicit[0];
184
+ if (explicit.length >= 2 && (Array.isArray(haystack) || typeof haystack === 'string')) {
185
+ for (const arg of explicit.slice(1)) {
186
+ const needle = translate(arg, lang);
187
+ if (haystack.includes(needle)) return options.fn(this);
188
+ }
189
+ }
190
+ return options.inverse(this);
191
+ });
192
+ hb.registerHelper('join', function (...args) {
193
+ const {
194
+ explicit
195
+ } = helperArgs(args);
196
+ const list = explicit[0];
197
+ if (!Array.isArray(list)) return '';
198
+ if (explicit.length === 2) {
199
+ return list.join(stringifyTranslated(explicit[1], lang));
200
+ }
201
+ if (explicit.length === 3 && list.length) {
202
+ if (list.length === 1) return list[0];
203
+ const joiner = stringifyTranslated(explicit[1], lang);
204
+ return list.slice(0, -1).join(joiner) + String(explicit[2]) + list[list.length - 1];
205
+ }
206
+ return '';
207
+ });
208
+ hb.registerHelper('calc', function (...args) {
209
+ const {
210
+ explicit
211
+ } = helperArgs(args);
212
+ if (explicit.length !== 3) return '';
213
+ const a = Number(explicit[0]);
214
+ const b = Number(explicit[2]);
215
+ if (Number.isNaN(a) || Number.isNaN(b)) return '';
216
+ const maxDecimals = Math.max(decimalPlaces(explicit[0]), decimalPlaces(explicit[2]));
217
+ let result = null;
218
+ switch (explicit[1]) {
219
+ case '+':
220
+ result = a + b;
221
+ break;
222
+ case '-':
223
+ result = a - b;
224
+ break;
225
+ case '*':
226
+ result = a * b;
227
+ break;
228
+ case '/':
229
+ result = a / b;
230
+ break;
231
+ }
232
+ if (result === null) return '';
233
+ return formatCalcResult(roundHalfToEven(result, maxDecimals));
234
+ });
235
+ hb.registerHelper('count', function (value) {
236
+ if (Array.isArray(value) || typeof value === 'string') return value.length;
237
+ if (value && typeof value === 'object') return Object.keys(value).length;
238
+ return '';
239
+ });
240
+ hb.registerHelper('get', function (...args) {
241
+ const {
242
+ options,
243
+ explicit
244
+ } = helperArgs(args);
245
+ const obj = explicit[0];
246
+ const key = explicit[1];
247
+ const value = obj != null ? obj[key] : undefined;
248
+ const fallback = explicit.length > 2 ? explicit[2] : '';
249
+ if (typeof options.fn === 'function') {
250
+ return options.fn(value !== undefined ? value : fallback);
251
+ }
252
+ return value !== undefined ? value : fallback;
253
+ });
254
+ hb.registerHelper('range', function (...args) {
255
+ const {
256
+ options,
257
+ explicit
258
+ } = helperArgs(args);
259
+ const nums = explicit.map(Number);
260
+ const [start, stop, step] = nums.length === 1 ? [0, nums[0], 1] : nums.length === 2 ? [nums[0], nums[1], 1] : nums;
261
+ let result = '';
262
+ for (let x = start; step > 0 ? x < stop : x > stop; x += step) {
263
+ result += options.fn(x);
264
+ }
265
+ return result;
266
+ });
267
+ hb.registerHelper('size', function (value) {
268
+ return sizeofFmt(value);
269
+ });
270
+ hb.registerHelper('escape', function (value) {
271
+ // Deliberate deviation from the legacy pybars `escape` helper, which
272
+ // returns str(x).encode(...) -- a bytes object -- and ends up
273
+ // rendering its own Python repr (e.g. "b'&lt;b&gt;'") once pybars
274
+ // stringifies it. That's almost certainly an unintentional bug no
275
+ // template actually relies on; this does the HTML-escaping the
276
+ // helper's name promises instead of reproducing the bug.
277
+ return new Handlebars.SafeString(Handlebars.escapeExpression(String(value ?? '')));
278
+ });
279
+ function dateComparisonHelper(mode) {
280
+ return function (...args) {
281
+ const {
282
+ options,
283
+ explicit
284
+ } = helperArgs(args);
285
+ const forcedIndex = mode === 'between' ? 2 : 1;
286
+ if (explicit.length > forcedIndex && explicit[forcedIndex]) return options.fn(this);
287
+ if (mode === 'between') {
288
+ if (explicit.length < 2) return options.inverse(this);
289
+ const a = parseDayFirst(explicit[0]).getTime();
290
+ const b = parseDayFirst(explicit[1]).getTime();
291
+ const now = Date.now();
292
+ const inside = a < now && now < b || b < now && now < a;
293
+ return inside ? options.fn(this) : options.inverse(this);
294
+ }
295
+ if (explicit.length === 0) return options.inverse(this);
296
+ const timestamp = parseDayFirst(explicit[0]).getTime();
297
+ const now = Date.now();
298
+ const passed = mode === 'before' ? now < timestamp : now > timestamp;
299
+ return passed ? options.fn(this) : options.inverse(this);
300
+ };
301
+ }
302
+ hb.registerHelper('before', dateComparisonHelper('before'));
303
+ hb.registerHelper('after', dateComparisonHelper('after'));
304
+ hb.registerHelper('between', dateComparisonHelper('between'));
305
+ }
306
+ function registerDateHelpers(hb, lang) {
307
+ const dateFormat = (style, timeZone = 'UTC') => function (value) {
308
+ const date = value instanceof Date ? value : new Date(String(value));
309
+ return new Intl.DateTimeFormat(lang, {
310
+ dateStyle: style,
311
+ timeZone
312
+ }).format(date);
313
+ };
314
+ hb.registerHelper('shortdate', dateFormat('short'));
315
+ hb.registerHelper('date', dateFormat('medium'));
316
+ hb.registerHelper('longdate', dateFormat('long'));
317
+ hb.registerHelper('fulldate', dateFormat('full'));
318
+ hb.registerHelper('time', function (value) {
319
+ const date = value instanceof Date ? value : new Date(String(value));
320
+ return new Intl.DateTimeFormat(lang, {
321
+ timeStyle: 'short',
322
+ timeZone: 'Europe/Berlin'
323
+ }).format(date);
324
+ });
325
+ hb.registerHelper('longdateUtc', function (value) {
326
+ const date = value instanceof Date ? value : new Date(String(value));
327
+ return new Intl.DateTimeFormat(lang, {
328
+ dateStyle: 'long',
329
+ timeZone: 'Europe/Berlin'
330
+ }).format(date);
331
+ });
332
+ }
333
+
334
+ // Handlebars.js HTML-escapes every helper return value by default (e.g. "="
335
+ // becomes "&#x3D;"), which corrupts any URL with a query string --
336
+ // "?token=..." becomes "?token&#x3D;...". pybars does not do this to
337
+ // helper-produced strings, so every link helper below needs to opt out via
338
+ // SafeString to match legacy template behaviour (confirmed against a real
339
+ // pybars render in enter-core/tests/unittests/app/render_templates_test.py).
340
+ function safe(value) {
341
+ return new Handlebars.SafeString(value);
342
+ }
343
+ function registerLinkHelpers(hb, linkOptions, ctx) {
344
+ hb.registerHelper('link', function (path) {
345
+ return safe(makeTrackingLink(linkOptions, {
346
+ attendeeId: ctx.attendeeId,
347
+ emailName: ctx.emailName,
348
+ mailingId: ctx.mailingId,
349
+ urlLink: path,
350
+ trackingEnabled: ctx.trackingEnabled
351
+ }));
352
+ });
353
+ hb.registerHelper('unsubscribe_link', function (emailAddress) {
354
+ return safe(makeUnsubscribeLink(linkOptions, {
355
+ attendeeId: ctx.attendeeId,
356
+ emailName: ctx.emailName,
357
+ emailAddress
358
+ }));
359
+ });
360
+ hb.registerHelper('registration_link', function (...args) {
361
+ const {
362
+ explicit
363
+ } = helperArgs(args);
364
+ const id = explicit[0] ?? ctx.attendeeId;
365
+ const sessionType = explicit[1] ?? 'register';
366
+ const daysValid = explicit[2] != null ? Number(explicit[2]) : 15;
367
+ return safe(makeRegistrationLink(linkOptions, {
368
+ attendeeId: id,
369
+ sessionType,
370
+ expiresInSeconds: daysValid * 24 * 60 * 60,
371
+ email: ctx.mailingId ? {
372
+ name: ctx.emailName,
373
+ mailingId: ctx.mailingId
374
+ } : undefined
375
+ }));
376
+ });
377
+ hb.registerHelper('wallet_link', function (id) {
378
+ const link = makeWalletLink(linkOptions, id ?? ctx.attendeeId);
379
+ // mirrors the legacy helper: the wallet link itself is wrapped in a
380
+ // tracked link, unlike google_wallet_link below
381
+ return safe(makeTrackingLink(linkOptions, {
382
+ attendeeId: ctx.attendeeId,
383
+ emailName: ctx.emailName,
384
+ mailingId: ctx.mailingId,
385
+ urlLink: link,
386
+ trackingEnabled: ctx.trackingEnabled
387
+ }));
388
+ });
389
+ hb.registerHelper('google_wallet_link', function (id) {
390
+ return safe(makeGoogleWalletLink(linkOptions, id ?? ctx.attendeeId));
391
+ });
392
+ hb.registerHelper('qr_code_link', function (...args) {
393
+ const {
394
+ explicit
395
+ } = helperArgs(args);
396
+ return safe(makeQrCodeLink(linkOptions, {
397
+ content: String(explicit[0]),
398
+ size: explicit[1] != null ? Number(explicit[1]) : undefined,
399
+ background: explicit[2],
400
+ body: explicit[3],
401
+ corners: explicit[4],
402
+ fancy: explicit[5] != null ? Number(explicit[5]) : undefined
403
+ }));
404
+ });
405
+ }
406
+ const PARTIAL_REFERENCE_PATTERN = /\{\{>\s*([a-zA-Z0-9_-]+)/g;
407
+ const MAX_PARTIAL_DEPTH = 10;
408
+ function extractPartialNames(source) {
409
+ return [...source.matchAll(PARTIAL_REFERENCE_PATTERN)].map(m => m[1]);
410
+ }
411
+ async function resolvePartialsRecursively(hb, source, resolvePartial, seen, depth) {
412
+ if (depth > MAX_PARTIAL_DEPTH) return;
413
+ for (const name of extractPartialNames(source)) {
414
+ if (seen.has(name)) continue;
415
+ seen.add(name);
416
+ let partialSource;
417
+ try {
418
+ partialSource = await resolvePartial(name);
419
+ } catch {
420
+ // mirrors get_template()'s behaviour of never raising -- an
421
+ // unresolvable partial silently becomes a literal placeholder
422
+ partialSource = 'Template not found';
423
+ }
424
+ hb.registerPartial(name, partialSource);
425
+ await resolvePartialsRecursively(hb, partialSource, resolvePartial, seen, depth + 1);
426
+ }
427
+ }
428
+ export async function renderHandlebars(source, data, options) {
429
+ const hb = Handlebars.create();
430
+ registerDataHelpers(hb, options.lang, options.languages);
431
+ registerDateHelpers(hb, options.lang);
432
+ registerLinkHelpers(hb, options.linkOptions, options.linkContext);
433
+ await resolvePartialsRecursively(hb, source, options.resolvePartial, new Set(), 0);
434
+ const template = hb.compile(source);
435
+ return template(data);
436
+ }