@messagevisor/sdk 0.0.1 → 0.1.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,553 @@
1
+ import type { DatafileContent } from "@messagevisor/types";
2
+
3
+ import { createMessagevisor } from "./index";
4
+ import type { MessagevisorModule } from "./instance";
5
+
6
+ const interpolationModule: MessagevisorModule = {
7
+ name: "test-interpolation",
8
+ format(payload) {
9
+ if (typeof payload.translation !== "string") {
10
+ return;
11
+ }
12
+
13
+ return payload.translation.replace(
14
+ /\{([a-zA-Z0-9_.]+)(?:,\s*(number|date|time|relative),\s*([a-zA-Z0-9_.-]+))?\}/g,
15
+ (match, name, formatter, preset) => {
16
+ const value = payload.values?.[name];
17
+
18
+ if (typeof value === "undefined" || value === null) {
19
+ return match;
20
+ }
21
+
22
+ if (formatter === "number") {
23
+ const options = preset ? payload.formats.number?.[preset] : undefined;
24
+ return new Intl.NumberFormat(payload.locale, options as Intl.NumberFormatOptions).format(
25
+ value as number,
26
+ );
27
+ }
28
+
29
+ if (formatter === "date") {
30
+ const options = preset ? payload.formats.date?.[preset] : undefined;
31
+ return new Intl.DateTimeFormat(
32
+ payload.locale,
33
+ options as Intl.DateTimeFormatOptions,
34
+ ).format(value as any);
35
+ }
36
+
37
+ if (formatter === "time") {
38
+ const options = preset ? payload.formats.time?.[preset] : undefined;
39
+ return new Intl.DateTimeFormat(
40
+ payload.locale,
41
+ options as Intl.DateTimeFormatOptions,
42
+ ).format(value as any);
43
+ }
44
+
45
+ if (formatter === "relative") {
46
+ const options = preset ? payload.formats.relative?.[preset] : undefined;
47
+ return new Intl.RelativeTimeFormat(
48
+ payload.locale,
49
+ options as Intl.RelativeTimeFormatOptions,
50
+ ).format(value as number, "day");
51
+ }
52
+
53
+ return String(value);
54
+ },
55
+ );
56
+ },
57
+ };
58
+
59
+ const enDatafile: DatafileContent = {
60
+ schemaVersion: "1",
61
+ messagevisorVersion: "0.0.1",
62
+ revision: "en-1",
63
+ target: "web",
64
+ locale: "en-US",
65
+ direction: "ltr",
66
+ formats: {
67
+ number: {
68
+ money: { style: "currency", currency: "USD", currencyDisplay: "symbol" },
69
+ compact: { notation: "compact", compactDisplay: "short" },
70
+ percentage: { style: "percent", maximumFractionDigits: 1 },
71
+ },
72
+ date: {
73
+ short: { year: "numeric", month: "short", day: "numeric", timeZone: "UTC" },
74
+ },
75
+ time: {
76
+ short: { hour: "numeric", minute: "2-digit", timeZone: "UTC" },
77
+ },
78
+ relative: {
79
+ auto: { numeric: "auto" },
80
+ },
81
+ },
82
+ segments: {
83
+ "platform-web": {
84
+ conditions: { attribute: "platform", operator: "equals", value: "web" },
85
+ },
86
+ "platform-mobile": {
87
+ conditions: { attribute: "platform", operator: "in", value: ["ios", "android"] },
88
+ },
89
+ "plan-pro": {
90
+ conditions: JSON.stringify({ attribute: "account.plan", operator: "equals", value: "pro" }),
91
+ },
92
+ "enterprise-eu": {
93
+ conditions: {
94
+ and: [
95
+ { attribute: "account.plan", operator: "equals", value: "enterprise" },
96
+ { attribute: "account.region", operator: "in", value: ["NL", "BE", "DE"] },
97
+ ],
98
+ },
99
+ },
100
+ adult: {
101
+ conditions: { attribute: "age", operator: "greaterThanOrEquals", value: 18 },
102
+ },
103
+ "recent-signup": {
104
+ conditions: { attribute: "signupDate", operator: "after", value: "2026-01-01T00:00:00Z" },
105
+ },
106
+ "beta-enabled": {
107
+ conditions: { feature: "checkout-copy", operator: "isEnabled" },
108
+ },
109
+ "experiment-b": {
110
+ conditions: { experiment: "checkout-banner", operator: "hasVariation", value: "b" },
111
+ },
112
+ "push-capable": {
113
+ conditions: [
114
+ { attribute: "permissions", operator: "includes", value: "push" },
115
+ { attribute: "device.id", operator: "startsWith", value: "ios-" },
116
+ ],
117
+ },
118
+ archived: {
119
+ archived: true,
120
+ conditions: "*",
121
+ },
122
+ },
123
+ messages: {
124
+ "common.welcome": {
125
+ meta: { area: "common", owner: "growth" },
126
+ overrides: [
127
+ {
128
+ key: "enterprise-eu",
129
+ segments: { and: ["platform-web", "enterprise-eu"] },
130
+ translation: "Enterprise review for {name}",
131
+ },
132
+ {
133
+ key: "pro-web",
134
+ segments: ["platform-web", "plan-pro"],
135
+ translation: "Welcome back, pro {name}",
136
+ },
137
+ {
138
+ key: "mobile",
139
+ segments: "platform-mobile",
140
+ translation: "Welcome from the app, {name}",
141
+ },
142
+ ],
143
+ },
144
+ "checkout.banner": {
145
+ overrides: [
146
+ {
147
+ key: "beta-experiment",
148
+ segments: { and: ["beta-enabled", "experiment-b"] },
149
+ translation: "Try the faster checkout",
150
+ },
151
+ {
152
+ key: "recent-adult",
153
+ conditions: {
154
+ and: [
155
+ { attribute: "age", operator: "greaterThanOrEquals", value: 18 },
156
+ { attribute: "signupDate", operator: "after", value: "2026-01-01T00:00:00Z" },
157
+ ],
158
+ },
159
+ translation: "Welcome to checkout",
160
+ },
161
+ ],
162
+ },
163
+ "billing.total": {},
164
+ "billing.usage": {},
165
+ "billing.dueDate": {},
166
+ "billing.startTime": {},
167
+ "support.sla": {},
168
+ "raw.prompt": {},
169
+ "deprecated.old": {
170
+ deprecated: true,
171
+ deprecationWarning: "Use common.welcome instead.",
172
+ },
173
+ "archived.segment": {
174
+ overrides: [
175
+ {
176
+ key: "archived",
177
+ segments: "archived",
178
+ translation: "Archived override",
179
+ },
180
+ ],
181
+ },
182
+ "push.install": {
183
+ overrides: [
184
+ {
185
+ key: "push",
186
+ segments: "push-capable",
187
+ translation: "Enable rich push on {deviceName}",
188
+ },
189
+ ],
190
+ },
191
+ "stringified.group": {
192
+ overrides: [
193
+ {
194
+ key: "stringified",
195
+ segments: JSON.stringify({
196
+ or: [
197
+ { and: ["platform-web", "plan-pro"] },
198
+ { and: ["platform-mobile", "push-capable"] },
199
+ ],
200
+ }),
201
+ translation: "Stringified group matched",
202
+ },
203
+ ],
204
+ },
205
+ },
206
+ translations: {
207
+ "common.welcome": "Welcome, {name}",
208
+ "checkout.banner": "Checkout your way",
209
+ "billing.total": "Total: {amount, number, money}",
210
+ "billing.usage": "Usage: {count, number, compact}",
211
+ "billing.dueDate": "Due {date, date, short}",
212
+ "billing.startTime": "Starts {date, time, short}",
213
+ "support.sla": "Reply {days, relative, auto}",
214
+ "raw.prompt": "{count, plural, one {# file} other {# files}}",
215
+ "deprecated.old": "Old welcome",
216
+ "archived.segment": "Visible fallback",
217
+ "push.install": "Install the app",
218
+ "stringified.group": "Default group",
219
+ },
220
+ };
221
+
222
+ const nlDatafile: DatafileContent = {
223
+ ...enDatafile,
224
+ revision: "nl-1",
225
+ locale: "nl-NL",
226
+ formats: {
227
+ number: {
228
+ money: { style: "currency", currency: "EUR", currencyDisplay: "symbol" },
229
+ compact: { notation: "compact", compactDisplay: "short" },
230
+ percentage: { style: "percent", maximumFractionDigits: 0 },
231
+ },
232
+ date: {
233
+ short: { year: "numeric", month: "2-digit", day: "2-digit", timeZone: "UTC" },
234
+ },
235
+ time: {
236
+ short: { hour: "2-digit", minute: "2-digit", hour12: false, timeZone: "UTC" },
237
+ },
238
+ relative: {
239
+ auto: { numeric: "auto" },
240
+ },
241
+ },
242
+ translations: {
243
+ ...enDatafile.translations,
244
+ "common.welcome": "Welkom, {name}",
245
+ "checkout.banner": "Afrekenen zoals jij wilt",
246
+ "billing.total": "Totaal: {amount, number, money}",
247
+ "billing.usage": "Gebruik: {count, number, compact}",
248
+ "billing.dueDate": "Verschuldigd op {date, date, short}",
249
+ "billing.startTime": "Start om {date, time, short}",
250
+ "support.sla": "Antwoord {days, relative, auto}",
251
+ "deprecated.old": "Oude begroeting",
252
+ },
253
+ };
254
+
255
+ const arDatafile: DatafileContent = {
256
+ ...enDatafile,
257
+ revision: "ar-1",
258
+ locale: "ar-SA",
259
+ direction: "rtl",
260
+ formats: {
261
+ number: {
262
+ money: { style: "currency", currency: "SAR", currencyDisplay: "symbol" },
263
+ compact: { notation: "compact", compactDisplay: "short" },
264
+ },
265
+ date: {
266
+ short: { year: "numeric", month: "2-digit", day: "2-digit", timeZone: "UTC" },
267
+ },
268
+ time: {
269
+ short: { hour: "numeric", minute: "2-digit", timeZone: "UTC" },
270
+ },
271
+ relative: {
272
+ auto: { numeric: "auto" },
273
+ },
274
+ },
275
+ translations: {
276
+ ...enDatafile.translations,
277
+ "common.welcome": "مرحبا، {name}",
278
+ "billing.total": "الإجمالي: {amount, number, money}",
279
+ },
280
+ };
281
+
282
+ function createConformanceMessagevisor(options: Record<string, unknown> = {}) {
283
+ const diagnostics: any[] = [];
284
+ const m = createMessagevisor({
285
+ datafile: enDatafile,
286
+ context: {
287
+ platform: "web",
288
+ account: { plan: "free", region: "US" },
289
+ age: 17,
290
+ signupDate: "2025-12-01T00:00:00Z",
291
+ permissions: [],
292
+ device: { id: "web-1" },
293
+ },
294
+ modules: [interpolationModule],
295
+ logLevel: "debug",
296
+ onDiagnostic: (diagnostic) => diagnostics.push(diagnostic),
297
+ ...options,
298
+ });
299
+
300
+ return { m, diagnostics };
301
+ }
302
+
303
+ describe("SDK conformance scenarios", function () {
304
+ let consoleInfoSpy: jest.SpyInstance;
305
+ let consoleWarnSpy: jest.SpyInstance;
306
+ let consoleErrorSpy: jest.SpyInstance;
307
+ let consoleDebugSpy: jest.SpyInstance;
308
+
309
+ beforeEach(function () {
310
+ consoleInfoSpy = jest.spyOn(console, "info").mockImplementation(function () {});
311
+ consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation(function () {});
312
+ consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(function () {});
313
+ consoleDebugSpy = jest.spyOn(console, "debug").mockImplementation(function () {});
314
+ });
315
+
316
+ afterEach(function () {
317
+ consoleInfoSpy.mockRestore();
318
+ consoleWarnSpy.mockRestore();
319
+ consoleErrorSpy.mockRestore();
320
+ consoleDebugSpy.mockRestore();
321
+ });
322
+
323
+ it("keeps raw datafile messages intact when no formatting module is registered", function () {
324
+ const m = createMessagevisor({ datafile: enDatafile, logLevel: "fatal" });
325
+
326
+ expect(m.translate("billing.total", { amount: 12 })).toEqual("Total: {amount, number, money}");
327
+ expect(m.translate("raw.prompt", { count: 2 })).toEqual(
328
+ "{count, plural, one {# file} other {# files}}",
329
+ );
330
+ expect(m.formatMessage("Hello {name}", { name: "Ada" })).toEqual("Hello {name}");
331
+ });
332
+
333
+ it("applies ordered overrides with instance context, per-call context, and fallback defaults", function () {
334
+ const { m, diagnostics } = createConformanceMessagevisor();
335
+
336
+ expect(m.translate("common.welcome", { name: "Ada" })).toEqual("Welcome, Ada");
337
+ expect(
338
+ m.translate("common.welcome", { name: "Ada" }, { context: { account: { plan: "pro" } } }),
339
+ ).toEqual("Welcome back, pro Ada");
340
+ expect(
341
+ m.translate("common.welcome", { name: "Ada" }, { context: { platform: "ios" } }),
342
+ ).toEqual("Welcome from the app, Ada");
343
+ expect(
344
+ m.translate(
345
+ "common.welcome",
346
+ { name: "Ada" },
347
+ {
348
+ context: { account: { plan: "enterprise", region: "NL" } },
349
+ },
350
+ ),
351
+ ).toEqual("Enterprise review for Ada");
352
+
353
+ expect(
354
+ diagnostics
355
+ .filter((diagnostic) => diagnostic.code === "message_override_matched")
356
+ .map((diagnostic) => diagnostic.overrideKey),
357
+ ).toEqual(["pro-web", "mobile", "enterprise-eu"]);
358
+ });
359
+
360
+ it("evaluates complex segment groups, archived segments, and stringified group segments", function () {
361
+ const { m } = createConformanceMessagevisor({
362
+ context: {
363
+ platform: "ios",
364
+ account: { plan: "free", region: "US" },
365
+ age: 21,
366
+ signupDate: "2026-02-01T00:00:00Z",
367
+ permissions: ["push"],
368
+ device: { id: "ios-abc" },
369
+ deviceName: "iPhone",
370
+ },
371
+ });
372
+
373
+ expect(m.translate("push.install", { deviceName: "iPhone" })).toEqual(
374
+ "Enable rich push on iPhone",
375
+ );
376
+ expect(m.translate("archived.segment")).toEqual("Visible fallback");
377
+ expect(m.translate("stringified.group")).toEqual("Stringified group matched");
378
+ });
379
+
380
+ it("passes merged context to feature and experiment resolvers", function () {
381
+ const resolverContexts: any[] = [];
382
+ const { m } = createConformanceMessagevisor({
383
+ resolveFlag: (featureKey, context) => {
384
+ resolverContexts.push({ type: "flag", featureKey, context });
385
+ return featureKey === "checkout-copy" && context?.platform === "web";
386
+ },
387
+ resolveVariation: (experimentKey, context) => {
388
+ resolverContexts.push({ type: "experiment", experimentKey, context });
389
+ return context?.account && (context.account as any).plan === "pro" ? "b" : "a";
390
+ },
391
+ });
392
+
393
+ expect(
394
+ m.translate("checkout.banner", undefined, {
395
+ context: { account: { plan: "pro" }, platform: "web" },
396
+ }),
397
+ ).toEqual("Try the faster checkout");
398
+ expect(resolverContexts).toEqual([
399
+ expect.objectContaining({
400
+ type: "flag",
401
+ featureKey: "checkout-copy",
402
+ context: expect.objectContaining({ platform: "web", account: { plan: "pro" } }),
403
+ }),
404
+ expect.objectContaining({
405
+ type: "experiment",
406
+ experimentKey: "checkout-banner",
407
+ context: expect.objectContaining({ platform: "web", account: { plan: "pro" } }),
408
+ }),
409
+ ]);
410
+ });
411
+
412
+ it("uses datafile, default, and per-call formats across locales", function () {
413
+ const { m } = createConformanceMessagevisor({
414
+ defaultFormats: {
415
+ "en-US": {
416
+ number: {
417
+ money: { style: "currency", currency: "GBP", currencyDisplay: "code" },
418
+ fallbackOnly: { minimumFractionDigits: 3, maximumFractionDigits: 3 },
419
+ },
420
+ },
421
+ "nl-NL": {
422
+ number: {
423
+ fallbackOnly: { minimumFractionDigits: 1, maximumFractionDigits: 1 },
424
+ },
425
+ },
426
+ },
427
+ });
428
+
429
+ expect(m.translate("billing.total", { amount: 12 })).toEqual("Total: $12.00");
430
+ expect(m.formatNumber(12, "fallbackOnly")).toEqual("12.000");
431
+ expect(
432
+ m.translate(
433
+ "billing.total",
434
+ { amount: 12 },
435
+ {
436
+ formats: {
437
+ number: { money: { style: "currency", currency: "CAD", currencyDisplay: "code" } },
438
+ },
439
+ },
440
+ ),
441
+ ).toEqual("Total: CAD\u00a012.00");
442
+
443
+ m.setDatafile(nlDatafile);
444
+ m.setLocale("nl-NL");
445
+
446
+ expect(m.translate("billing.total", { amount: 12 })).toEqual("Totaal: \u20ac\u00a012,00");
447
+ expect(m.formatNumber(12, "fallbackOnly")).toEqual("12,0");
448
+ });
449
+
450
+ it("switches locale datafiles while preserving locale-specific direction, revision, and formatting", function () {
451
+ const { m } = createConformanceMessagevisor();
452
+
453
+ m.setDatafile(nlDatafile);
454
+ m.setDatafile(arDatafile);
455
+
456
+ expect(m.getLocale()).toEqual("en-US");
457
+ expect(m.getRevision()).toEqual("en-1");
458
+ expect(m.getDirection()).toEqual("ltr");
459
+ expect(m.translate("billing.total", { amount: 1234.5 })).toEqual("Total: $1,234.50");
460
+
461
+ m.setLocale("nl-NL");
462
+ expect(m.getRevision()).toEqual("nl-1");
463
+ expect(m.getDirection()).toEqual("ltr");
464
+ expect(m.translate("billing.total", { amount: 1234.5 })).toEqual(
465
+ "Totaal: \u20ac\u00a01.234,50",
466
+ );
467
+
468
+ m.setLocale("ar-SA");
469
+ expect(m.getRevision()).toEqual("ar-1");
470
+ expect(m.getDirection()).toEqual("rtl");
471
+ expect(m.translate("billing.total", { amount: 1234.5 })).toContain("ر.س");
472
+ });
473
+
474
+ it("keeps defaultTranslation as the last translation fallback and reports missing translations", function () {
475
+ const { m, diagnostics } = createConformanceMessagevisor({
476
+ defaultTranslations: {
477
+ "en-US": {
478
+ "fallback.only": "Fallback message for {name}",
479
+ },
480
+ },
481
+ });
482
+
483
+ expect(m.translate("fallback.only", { name: "Ada" })).toEqual("Fallback message for Ada");
484
+ expect(
485
+ m.translate(
486
+ "missing.with.default",
487
+ { name: "Ada" },
488
+ {
489
+ defaultTranslation: "Default message for {name}",
490
+ },
491
+ ),
492
+ ).toEqual("Default message for Ada");
493
+ expect(m.translate("missing.without.default")).toEqual("missing.without.default");
494
+
495
+ expect(
496
+ diagnostics
497
+ .filter((diagnostic) => diagnostic.code === "missing_translation")
498
+ .map((diagnostic) => diagnostic.messageKey),
499
+ ).toEqual(["missing.with.default", "missing.without.default"]);
500
+ });
501
+
502
+ it("emits deprecated-message diagnostics without changing successful resolution", function () {
503
+ const { m, diagnostics } = createConformanceMessagevisor({ logLevel: "warn" });
504
+
505
+ expect(m.translate("deprecated.old")).toEqual("Old welcome");
506
+ expect(m.translate("deprecated.old")).toEqual("Old welcome");
507
+ expect(diagnostics).toEqual([
508
+ expect.objectContaining({ code: "deprecated_message", messageKey: "deprecated.old" }),
509
+ expect.objectContaining({ code: "deprecated_message", messageKey: "deprecated.old" }),
510
+ ]);
511
+ });
512
+
513
+ it("passes message metadata and module options through formatting and transform hooks", function () {
514
+ const seenPayloads: any[] = [];
515
+ const module: MessagevisorModule = {
516
+ name: "observer",
517
+ format(payload) {
518
+ seenPayloads.push({ phase: "format", ...payload });
519
+ },
520
+ transform(payload) {
521
+ seenPayloads.push({ phase: "transform", ...payload });
522
+ },
523
+ };
524
+ const m = createMessagevisor({
525
+ datafile: enDatafile,
526
+ modules: [module],
527
+ logLevel: "fatal",
528
+ });
529
+
530
+ expect(
531
+ m.translate("common.welcome", undefined, {
532
+ moduleOptions: { observer: { enabled: true } },
533
+ }),
534
+ ).toEqual("Welcome, {name}");
535
+ expect(seenPayloads).toEqual([
536
+ expect.objectContaining({
537
+ phase: "format",
538
+ locale: "en-US",
539
+ source: "translation",
540
+ messageKey: "common.welcome",
541
+ meta: { area: "common", owner: "growth" },
542
+ moduleOptions: { observer: { enabled: true } },
543
+ }),
544
+ expect.objectContaining({
545
+ phase: "transform",
546
+ locale: "en-US",
547
+ source: "translation",
548
+ messageKey: "common.welcome",
549
+ meta: { area: "common", owner: "growth" },
550
+ }),
551
+ ]);
552
+ });
553
+ });